diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e99aaf9..7f4bb8c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,9 +140,16 @@ jobs: # ── Electron GUI(纯 Node 测试,零依赖)── # - # `npm test` 只跑三个 .mjs,全部只 import node: 内置模块(fs/url/path/vm), + # `npm test` 跑五个 .mjs,全部只 import node: 内置模块(fs/url/path/vm), # 所以**不需要 npm ci、不需要 electron**,秒级完成。 # `npm run test-live` 需真 Electron + 真后端 ⇒ 不进 CI。 + # + # endpoint-align.test.mjs 跨出 cmd/gui 读 ../../internal/plugins/webui/handler.go + # 做端点对账 —— checkout 深度足够(默认 fetch-depth:1 已含工作树文件), + # 不需要额外 checkout 步骤。 + # + # connections-io.test.mjs 同样是沙箱里跑 main.js 的**真实函数**(node:vm), + # 在临时目录里断言连接配置的加载/保存行为,不碰真实用户数据。 gui: name: GUI (node) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e0ebef00..4ad383bf 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,37 @@ dist/ # 漂移巡检的运行时状态(含时间戳与指纹,每次跑都变) .drift-watch/ + +# ——— 本地运维脚本 / 一次性工具(不入库,克隆后需另行获取)——— +# deploy-*.sh 是生产部署入口,含环境相关的 IP/路径约定; +# 文档见 docs/zh/deploy-runbook.md。换机器部署前先确认脚本来源。 +/deploy-plan.sh +/deploy-waiter.sh +/deploy-sdk-site.sh + +# 一次性跑分/设计提取脚本,用完即弃 +/tmp_jinabench.py +/napcat-design-dna.json + +# 已删除的动态工作文档(2e30e06)。曾被本机某进程恢复过一次 —— +# 它的 mtime 还是删除前的 Sep 26,说明是外部写回而不是 git 操作。 +# 不加这行的话,恢复一次就重新入库一次。 +/plan.md + +# 临时探针目录(scripts/ 之外的临时 main.go 探针) +/tools/zz_*/ + +# 编译产物(迁移工具) +/homed-graph-migrate + +# 构建产物落在仓库根(go build ./cmd/xxx 时最易发生) +/homed +/homed-graph-migrate +/homed-graph-distill +/homed-centroid + +# ── 本机运维脚本(含 /home/newqqagent 等本机路径与凭据搬运逻辑,不进仓库)── +/deploy/scripts/deploy.sh +/scripts/verify_deploy.sh +/scripts/kernel-stress/ +/scripts/gui-package-smoke.sh diff --git a/.mailmap b/.mailmap index aaf84bc6..6664f3f1 100644 --- a/.mailmap +++ b/.mailmap @@ -1,24 +1,49 @@ # .mailmap —— 提交身份归并 # -# 为什么需要:GitHub 的 Contributors 列表按 **提交邮箱** 归并身份,而本项目历史里 -# 同一个人的提交来自多个邮箱(本地 root、个人 QQ、gitcode noreply、GitHub noreply), -# 于是列表里出现若干「虚拟贡献者」,看起来像有多个协作者,实际只有一个人。 +# 为什么需要:GitHub 的 Contributors 按**提交邮箱**归并身份,而本项目历史里 +# 同一个人的提交来自多个邮箱(本地 root、个人 QQ、gitcode noreply、 +# GitHub noreply),于是列表里出现若干「虚拟贡献者」。 # -# 本文件只影响 **展示层**(git shortlog / git log --use-mailmap / GitHub Contributors), +# 本文件只影响 **展示层**(git log --use-mailmap / GitHub Contributors), # 不改写任何提交对象,历史 SHA 全部保持不变。 # # 格式:<归并到的姓名> <归并到的邮箱> <历史姓名> <历史邮箱> # +# ★★ 写法要点(2026-10-05 实测踩坑,附实证): +# +# ① 右端必须写成「历史姓名 + 历史邮箱」成对形式。 +# 只写邮箱时git 要求提交的 author name **也为空**才匹配, +# 而历史提交都带名字 ⇒ 映射静默失效,且**不报任何错**。 +# (实证:只写邮箱的那版,`jianf` 仍出现在 Contributors 里。) +# +# ② 注释里**不要写出形如映射的示例行**。 +# git 只认行首 `#` 作为注释起点;缩进注释里若出现 +# 「姓名 邮箱」或「若干空格 + 邮箱」,仍会被解析成真实条目。 +# 同名目标后者覆盖前者 ⇒ 真正的映射被悄悄顶掉。 +# +# ③ 坏字节邮箱无法在文本文件里表达 —— 那种提交只能改写历史 +# (git filter-repo --mailmap)。本文件已不含此类条目: +# 唯一那���(58ef728,邮箱前缀多出 3 个坏字节)已于 2026-10-05 改写。 +# # 注意:HomeAgent Agent **不归并** —— agent 的自动提交 # 保留独立身份,便于区分人类提交与自动化提交。 +# ── 主身份(显式写出,避免依赖隐式默认)── JianFeeeee JianFeeeee -JianFeeeee -JianFeeeee <2198972886@qq.com> -JianFeeeee -JianFeeeee -JianFeeeee -JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> -# 说明:上表用同一主身份覆盖所有历史邮箱;`git shortlog -sne --use-mailmap` 应只剩 -# JianFeeeee 与 HomeAgent Agent 两条。 +# ── 本地 root(历史署名有 root 与 JianFeeeee 两种)── +JianFeeeee root +JianFeeeee JianFeeeee + +# ── 个人 QQ 邮箱(历史上配过 jianf 与 JianFeeeee 两个名字)── +JianFeeeee jianf <2198972886@qq.com> +JianFeeeee JianFeeeee <2198972886@qq.com> + +# ── 本地开发身份 ── +JianFeeeee JianFeeeee +JianFeeeee dev +JianFeeeee root +JianFeeeee JianFeeeee <109188060+JianFeeeee@users.noreply.github.com> + +# ── pi(自动化 agent,保留独立身份)── +pi pi \ No newline at end of file diff --git a/Makefile b/Makefile index d5996735..c6c4fd9d 100644 --- a/Makefile +++ b/Makefile @@ -294,7 +294,7 @@ build-cli: @echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))" build-gui: - @cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox + @cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox --icon=icon.ico @echo "Built: $(BUILD_DIR)/$(GUI_BINARY)" build-static: diff --git a/assets/skills/knowledge-base/DEPLOY.md b/assets/skills/knowledge-base/DEPLOY.md index c61ba3bd..b4338f1e 100644 --- a/assets/skills/knowledge-base/DEPLOY.md +++ b/assets/skills/knowledge-base/DEPLOY.md @@ -8,7 +8,7 @@ | 项 | 状态 | |---|---| | kbtree 代码 | ✅ 已在 `main`(`41d7543`),已推送 | -| skill | ✅ 已装 `/home/newqqagent/skills/knowledge-base/` | +| skill | ✅ 已装 `${HA_DATA}/skills/knowledge-base/` | | token | ✅ 已写入 `config_kbtree` 表(固定值,重启不变) | | 配置库备份 | ✅ `config.db.bak-20260926-143643` | | **服务** | ❌ **未上线** —— 运行中的二进制里没有 kbtree | @@ -30,7 +30,7 @@ ss -ltn | grep 9892 # → 无监听 **必须按顺序,且第 1 步不能用 `cp`**: ```bash -DATA=/home/newqqagent +DATA=${HA_DATA} # 1. 备份配置库(WAL 模式下 cp 会拿到不一致快照,必须用 .backup) sqlite3 $DATA/config.db ".backup '$DATA/config.db.bak-$(date +%Y%m%d-%H%M%S)'" @@ -60,7 +60,7 @@ ss -ltn | grep 9892 ## 部署后的冒烟测试 ```bash -cd /home/newqqagent/skills/knowledge-base +cd ${HA_DATA}/skills/knowledge-base ./scripts/kb_tree.sh -h # 帮助(不需要 token) ./scripts/kb_tree.sh categories # 分类列表 diff --git a/cmd/gui/DESIGN-NOTES.md b/cmd/gui/DESIGN-NOTES.md new file mode 100644 index 00000000..35191d8d --- /dev/null +++ b/cmd/gui/DESIGN-NOTES.md @@ -0,0 +1,61 @@ +# HomeAgent GUI 设计说明与外部借鉴 + +本文件记录 GUI(`cmd/gui`)在设计过程中**明确借鉴的外部实现**,以及本仓自行做出的设计决策。 + +目的是让后来人能判断:某段 UI 行为是本地决定,还是有外部出处。这对追溯许可、 +理解设计动机、以及避免无意中"重新发明"已有方案都有用。 + +--- + +## 借鉴来源 + +### PiDeck(`C:\Users\21989\AppData\Local\Programs\PiDeck`) + +**PiDeck 是本机安装的一个第三方 Electron 应用**(其前端依赖 +`@deepseek-ai/dsh-web-frontend`)。HomeAgent GUI 在交互设计上参考了它的以下 +做法。参考方式为**阅读其打包产物 `app.asar` 中的 CSS 实现**,理解其设计意图后 +在 HomeAgent 的样式体系中独立重写 —— 未复制其代码,也未使用其资源文件。 + +| 借鉴项 | PiDeck 的实现 | HomeAgent 的落地 | 动机 | +|---|---|---|---| +| **加载 / 思考动画** | `@keyframes _dsh-state-dot-chase`(三点透明度递减 `1 → .6 → .35 → .15`)与 `tool-activity-dot`(上浮 + 缩放) | `renderer/style.css` 的 `haDots` / `haDotFloat` | 旧实现是 `border + rotate` 的经典 spinner。旋转语义上暗示"在加载某个确定的东西",而 agent 思考本身没有进度可转;且在 15px 的消息气泡里转圈糊成一团 | +| **运行态呼吸光晕** | `.turn-row--running:before` —— 一层几乎看不见的 accent 底色(`color-mix(... 3%)`)以 `2.2s` 周期做 `opacity: 0 → 1 → 0` 呼吸 | `.msg-bubble.msg-running::before` | 单看气泡里的几个点,视线要缩到一小块;光晕铺在整条消息上,余光就能感知"agent 正在回" | +| **工具调用分组卡** | `.tool-group-card` + `.tool-group-card-dot`(`toolGroupDotPulse`:缩放 + 透明度脉动)与 `tone-running/tone-error` 三态 | `renderToolGroup()` / `renderToolGroupOrSingle()` + `tool-group-card` 样式 | 原来一轮里每个工具调用各占一张卡,调 5 个工具就是 5 张卡竖排,把真正的回复挤到很下面;而这些卡形态高度相似(图标+工具名+状态),信息密度极低 | +| **组合键处理** | ——(PiDeck 为 Web 应用,不涉及系统级输入注入) | —— | —— | + +**关于分组卡的规则差异**:PiDeck 的分组策略我们理解为"折叠成组", +HomeAgent 额外加了一条规则 —— **只有 1 个工具时保持单卡**(组卡没意义, +反而多一层点击)。这是本地决策,不是 PiDeck 的做法。 + +--- + +## 本仓自行做出的设计(未借鉴) + +以下几项与 PiDeck 无关,是为 HomeAgent 自身场景做的决定: + +- **SSE 取代 EventSource**:需要自定义 `X-API-Key` 与 `Last-Event-ID` 请求头, + 浏览器原生 `EventSource` 不支持。 +- **增量聊天渲染**(`applyIncrementalChatRender`):按 `data-msgkey` 复用 DOM + 节点。性能问题由本仓实测定位(旧实现 200 条消息单次全量重建 235ms)。 +- **设备令牌注入**(`isDeviceTokenPath` / `deviceApiKey`):HomeAgent 服务端 + `/api/v1/device/` 命名空间下并存两套鉴权(`requireAPI` 认 cookie、 + `requireToken` 只认 `X-API-Key`),需要客户端主动带令牌。 +- **静默启动的"创建后隐藏"**:不创建窗口会导致渲染进程不启动(SSE、设备桥 + 全部失效),且托盘菜单唤不起窗口。PiDeck 为纯前端应用,无此问题。 +- **原子写 connections.json**:`rename` 原子性用于消除写入竞争导致的配置清空。 + +--- + +## 其他外部依赖 + +GUI 的第三方库已全部本地化到 `renderer/vendor/`,与 +`internal/plugins/webui/static/` 保持字节一致(由 `package-config.test.mjs` 钉住): + +| 库 | 版本 | 许可 | 用途 | +|---|---|---|---| +| three.js | r128 | MIT | 3D 星图 | +| OrbitControls.js | r128 examples | MIT | 星图视角控制 | +| marked | 4.3.0 | MIT | Markdown 渲染 | +| DOMPurify | 3.2.4 | Apache-2.0 / MPL-2.0 | HTML 净化(**安全必需**,不可降级) | + +运行时依赖 `koffi`(MIT,FFI)用于调用 `user32.dll` 实现键鼠注入。 diff --git a/cmd/gui/GUI-PLAN.md b/cmd/gui/GUI-PLAN.md new file mode 100644 index 00000000..1604ddc8 --- /dev/null +++ b/cmd/gui/GUI-PLAN.md @@ -0,0 +1,334 @@ +# GUI 改造计划与进度 + +> 本文件是 GUI(`cmd/gui`)当前改造的**单一事实来源**:做完了什么、为什么这么做、 +> 哪些结论是实测得到的、哪些还没做完。 +> +> 相关文档: +> - `cmd/gui/DESIGN-NOTES.md` —— 借鉴 PiDeck 的设计与实现 +> - `SERVER-HANDOFF.md` —— 需要服务端同步配合的部分 +> - 判据:`cmd/gui/*.test.mjs`,`npm test` 全绿为准(当前 127 条) + +--- + +## 一、当前状态 + +分支:`feature/gui-starmap-pulse-vendor-align`(不合入 main) + +最近提交: + +| commit | 内容 | +|---|---| +| `02566f3` | 修启动期误判服务端未启动;keybd_event 类型名致组合键全废 | +| `0d8b129` | computeruse 补高级操作(两端 9→18);修 HiDPI 坐标;记录 PiDeck 借鉴 | +| `3059c99` | 加载动画改三点式 + 新增系统通知 | + +判据:`cd cmd/gui && npm test` —— **127 条全绿**(其中 `computeruse.test.mjs` 43 条)。 + +--- + +## 二、已完成 + +### 2.1 computeruse 能力面:9 → 18 个动作 + +此前 GUI 侧(`cmd/gui/main.js`)与 waiter 侧(`cmd/waiter/device.go`)都只有 +9 个基础动作,与业界 computer-use 的基本盘差距明显。 + +GUI 侧现支持 **18 个**(已用脚本从 `main.js` 的 `case "computeruse"` 块精确提取核对): + +``` +click doubleclick rightclick middleclick tripleclick +move hover drag mousedown mouseup +scroll keypress hotkey combo +type wait sleep display +``` + +waiter 侧(Linux)为 **20 个** —— 比 GUI 多 `middle`、`right` 两个 +「button 简写形式」,少 `physical` 相关处理;其余一致。 + +要点: + +- **拖拽分步移动**(默认 12 步,上限 60)。一步到位会让 canvas 拖拽、列表排序、 + 滑动条失效 —— 这类应用依赖中间 `mousemove` 事件。 +- **组合键归一化**(waiter 侧 `normKeySpec`)。旧实现把模型给的 key 字符串原样丢给 + `xdotool key`,于是 `"Ctrl+C"`、`"ctrl + c"`、`"CTRL-C"` **全部失效**。 +- **`type` 加 `--clearmodifiers`**(waiter 侧)。避免残留的 Ctrl/Alt 把后续输入 + 变成快捷键。 +- **scroll 修正**(两端)。旧实现固定滚 `click 4/5`,忽略方向语义也不支持横向。 + +### 2.2 修 HiDPI 坐标错位 ⚠️ 重要,详见第五节 + +截图给模型的是**物理像素**,Win32 坐标 API 在 Electron 里也是物理像素, +**不需要换算**。详见第五节的实测与踩坑记录。 + +### 2.3 修启动期误判「服务端未启动」 + +`isServerRunning()` 原硬编码 `http://localhost:8080/`。实测该部署下: + +``` +localhost:8080 -> 000 连不上 +192.168.2.60:8080 -> 302 正常(homed 跑在 WSL 里) +``` + +于是每次启动都:探测失败 → 误判服务没起 → 拉起 GUI 旁并不存在的 `homed.exe` +→ 干等 8s 超时 → 打印 `homed failed to start within timeout`。 +该 ERR 曾累计出现数十次(实测 40+ 次,日志轮转后计数会变), +且是 `gui.log` 里唯一的错误项。 + +改法:按 `connections.json` 里已配置的连接(当前优先)探测,命中即在线; +服务可达时跳过 homed 自动拉起;无本地 homed 可执行文件时不再空等超时。 + +实测验证:修复后启动日志为 `server reachable, skip homed autostart`,该 ERR 消失。 + +> 这条很可能就是此前「聊天链路有问题」的根因 —— GUI 自以为服务端由自己托管, +> 实际聊天依赖的是 `connections.json` 里的连接。 + +### 2.4 修 keybd_event 类型名致组合键全废 + +koffi 不认 Win32 头文件里的 `byte`: + +``` +"void keybd_event(byte bVk, ...)" -> Error: Unknown or invalid type name 'byte' +"void keybd_event(uint8 bVk, ...)" -> 正常 +``` + +原代码用的正是 `byte`,且整段包在**空 catch** 里,于是 `keybd_event` 恒为 null, +所有组合键返回 `keybd_event unavailable`。 + +这个 bug 极难发现:单键 click/move/scroll 走 `mouse_event`,完全不受影响。 +现在签名解析失败会打日志,不再静默。 + +### 2.5 模式 A:agent 独立光标 + 后台注入(**代码已通,效果待验证**) + +见第三节。 + +--- + +## 三、模式 A:agent 独立光标(进行中) + +### 3.1 目标 + +现有 `computeruse` 走 `SetCursorPos` + `mouse_event`,**会抢走用户的真实鼠标**, +agent 一干活用户就没法用电脑。 + +模式 A 的目标:GUI 自绘一个 agent 专属光标(mascot 形象),实际输入通过 +`PostMessage` 直接投递到目标窗口句柄,**用户真实鼠标全程不动**。 + +### 3.2 新增文件 + +| 文件 | 职责 | +|---|---| +| `cmd/gui/agent-cursor.js` | 透明置顶层窗口 + mascot 指针 + 移动/点击脉冲动画;记录上次命中的窗口句柄 | +| `cmd/gui/agent-inject.js` | 两种注入路径:`SendInput`(默认)与 `PostMessage`(后台);含用户活动感知 | + +### 3.3 配置 + +`gui-prefs.json` → `deviceBridge.agentCursor`: + +| 键 | 值 | 行为 | +|---|---|---| +| `mode` | `"sendinput"` | **默认**。`SendInput` 注入系统输入队列,可靠性高(Chromium/游戏都能操作)。代价:会移动真实光标,故先等用户停手 | +| `mode` | `"overlay"` | `PostMessage` 直投窗口句柄。用户鼠标纹丝不动,但自绘界面常忽略合成消息 | +| `mode` | `"real"` | 保留旧代码路径(兼容) | +| `deferMs` | 数字 | 用户活动时最多等多久(默认 3000,上限 10000)。0 = 不等待 | + +> 为什么默认改成 `sendinput`:用户要求「可靠 + 用户正在操作则暂缓」。 +> 参考 `Pal-AI-Lab/Coopanion`(见 `DESKTOP-PET-NOTES.md` 第七节)。 +> +> ⚠ 实测发现:`GetLastInputInfo` 是**全局**的,agent 自己的 `SendInput` +> 也会被算作「用户输入」,若不排除则**永远处于「用户正在操作」状态**。 +> 已按 Coopanion 的三重判定修正(ownTick / ownCursor / 无符号差值)。 + +### 3.4 已验证(端到端) + +用假网关(自实现 WS 服务端)下发 computeruse 命令,GUI 执行并回执。 +两轮合计: + +**overlay(PostMessage)路径**:8/8 有响应,错误处理正确 —— +无窗口时报「WindowFromPoint 返回空」,而不是静默回退到真实鼠标。 + +**sendinput 路径**(当前默认):8/8 有响应,回执形如 +`sendinput click @ (300,300)` / `sendinput typed 2 chars` / +`sendinput drag (100,100) -> (400,300)`。 + +### 3.5 ⚠️ 未完成:真实点击效果未验证 + +当前执行环境**没有可供注入的可见目标窗口** +(`WindowFromPoint` 对所有坐标返回 null,`GetCursorPos` 读回 0,0)。 + +**因此:「点击真的落到目标窗口上」这一步尚未被证明。** 需要在有交互桌面的 +会话里跑一个端到端测试: + +> 打开记事本 → agent 点它 → 输入文字 → 截图回读验证 + +同时验证「`overlay` 模式下用户鼠标位置在全过程未变」。 + +### 3.6 设计取舍(明确的边界) + +- **两种注入的适用范围不同**: + - `sendinput`(默认):注入系统输入队列,等价真实硬件事件。 + Chromium/Electron 自绘界面、多数游戏都能接收 —— **可靠**。 + 代价:会移动用户真实光标,故先等用户停手(`deferMs`)。 + - `overlay`:`PostMessage` 直投窗口句柄,用户鼠标纹丝不动。 + 但自绘界面常忽略合成消息 ⇒ 只能操作原生 Win32 程序。 + + > 本仓曾把「自绘界面点不动」写成「Windows 消息模型的硬限制」—— + > **那个说法是错的**,它只是 `PostMessage` 这条 API 的限制。 + > 用 `SendInput` 就不受此限(参考 Coopanion)。 + +- **不做静默降级**。无论哪种模式,失败即 `status=error` 并说明原因, + 不会自作主张换成另一种注入方式。 +- **`sendinput` 模式下用户正在操作会暂缓**(而不是硬抢): + 轮询等到用户空闲,超时则跳过本次操作并报错。 + 已在 `agent-inject.js` 中排除「自身注入被误判为用户操作」的陷阱。 +- **`overlay` 模式下键盘类动作依赖先前的鼠标操作**。 + `keypress`/`hotkey`/`type` 不带坐标,而该模式不移动真实光标, + 所以 `GetCursorPos` 拿到的是**用户自己**的光标位置; + 故记录「上次鼠标操作命中的 hwnd」,键盘动作投递到那里。从未点过则报错。 + +### 3.7 跨平台状态 + +| 平台 | 状态 | +|---|---| +| Windows | 已实现(sendinput 默认 / overlay 可选;真实效果待验证) | +| Linux | waiter 侧仍是 `xdotool`,**会动真鼠标**。未做独立光标 | +| macOS | 同上 | + +--- + +## 四、待办 + +### 4.1 优先:修 `deferMs` 读取不生效 + +实测:`gui-prefs.json` 里 `agentCursor.deferMs = 0`,但命令仍按默认 3000ms +暂缓(回执写「已等待 3000ms」)。`loadGuiPrefs()` 会重建对象且**不透传 +`agentCursor` 字段**,导致读取永远拿到默认值。 + +- [ ] `loadGuiPrefs()` 透传 `deviceBridge.agentCursor` +- [ ] 判据:配置 `deferMs:0` 时不得进入暂缓路径 + +### 4.2 验证 sendinput 的真实效果 + +当前环境无可注入的可见目标窗口(`WindowFromPoint` 恒返回 null, +`GetCursorPos` 读回 0,0),故「点击真的落到目标窗口上」尚未证明。 + +- [ ] 端到端测试:打开真实应用 → 点它 → 输入 → 截图回读 +- [ ] 断言:`overlay` 模式下全过程用户鼠标位置未变 +- [ ] 判据:固化进 `npm test`(需能在无桌面 CI 环境跳过) + +### 4.3 服务端配合(详见 `SERVER-HANDOFF.md`) + +- [ ] 放开 `device.go` 的 action 白名单(schema enum + switch default 双重限制) +- [ ] 透传新参数 `tox`/`toy`/`steps`/`dx`/`ms`/`physical` +- [ ] 统一 scroll 方向语义(服务端写「正=向下」,Windows 惯例是正=向上) +- [ ] 把坐标约定写进注释(防重蹈 `/scale` 覆辙) + +> ⚠ 不改白名单,则新增的 11 个动作**根本下发不到设备**。 + +### 4.4 设置页 + +- [ ] `agentCursor.mode` / `deferMs` 的 UI(目前只能手改 `gui-prefs.json`) + +### 4.5 可选增强(详见 `DESKTOP-PET-NOTES.md` 第六、九节) + +- [ ] `main.js` 显式调 `SetProcessDpiAwarenessContext(-4)`,不依赖 Electron 默认值 +- [ ] mascot 姿态:思考 / 等待 / 出错 +- [ ] 动作解析容错:未知动作丢弃而非整条失败 +- [ ] mascot 待机自主行为:闲置时轻微眨眼/呼吸 +- [ ] Linux 侧若也要「不抢鼠标」,需 Wayland + uinput 虚拟设备 + (X11 下做不到 —— XTEST 合成的是真实设备事件) + +### 4.6 其它已定位但未修 + +- [ ] `overlay` 模式下键盘类动作「必须先点过」的限制可放宽: + 可改为「取当前前台窗口」作为兵底,而不是直接报错。 +- [ ] `real` 模式与 `sendinput` 模式代码重叠较多,可考虑合并; + 目前保留 `real` 仅为兼容旧路径,实际上与 `sendinput` 行为重复。 +- [ ] `p3.js` / `p4.js` 是本轮遗留的临时补丁脚本(未被跟踪), + 确认无价值后删除,避免占坑。 + +--- + +## 五、坐标空间:实测结论与踩坑记录 ⚠️ + +**结论:截图给模型的像素、Win32 坐标 API 的坐标,是同一个坐标系 —— 物理像素。 +不要换算。** + +实测证据(Electron 主进程内,192 DPI / 200% 缩放屏): + +``` +Electron screen API (DIP) : 1260 x 840 scaleFactor=2 +GetSystemMetrics : 2520 x 1680 +SetCursorPos(200,200) → GetCursorPos 读回 (200,200) ← 恒等,不缩放 +``` + +原因是 Electron 主进程是 **per-monitor DPI-aware**,此感知下 Win32 坐标不做虚拟化。 + +### 踩坑:曾经加错的 `/scaleFactor` 换算 + +本仓曾在 `main.js` 加过: + +```js +absX = ox + rawX / scaleFactor // ← 错的 +``` + +理由是「SetCursorPos 期望 DIP」—— **那个前提不成立**,DIP 行为只出现在 +DPI-*unaware* 进程里。加了之后反而把原本正确的点击改坏:200% 缩放屏上 +点 (600,400) 会被送到 (400,267),**越靠右下偏得越远**。 + +已改回 `physical ? rawX : rawX * scale`,并在 `computeruse.test.mjs` 加判据 +防止回退(检测 `/scale` 是否回来了)。 + +### 为什么会误判(重要) + +**在普通 node 进程里测同一个函数,读到的是 1260×840**(虚拟化后的值), +会让人以为 Win32 用 DIP。**必须在 Electron 内测** —— 同一份代码, +不同进程的 DPI 感知状态不同,答案也不同。 + +同理,`python`/`go` 等非 DPI-aware 宿主里测同样不可作为依据。 + +--- + +## 六、端到端测试方法(可复用) + +不依赖真实服务端,用自实现的假网关驱动 GUI: + +1. 起一个最小 WS 服务端(自实现 HTTP `Upgrade` 握手 + 帧编解码) +2. 改写 `gui-prefs.json` 指向它,并置 `deviceBridge.authorized = true` +3. 启动 GUI,等 `hello` / `bind` 完成 +4. 下发 `{op:"cmd", cmd_type:"homeagent", command:"computeruse {...}"}` +5. 读取 GUI 回的 `{op:"cmd_result", status, output, error}` + +### 两个踩过的坑 + +- **服务端→客户端的帧不能加掩码。** RFC 6455 只要求客户端→服务端掩码。 + 照抄 GUI 主进程(客户端方向)的 `sendDeviceFrame` 会导致 GUI 解不出。 +- **客户端→服务端的帧是掩码的**,解帧时必须先解掩码。 + +--- + +## 七、koffi 3.x 使用约定(踩坑记录) + +| 事项 | 正确做法 | 错误做法 / 现象 | +|---|---|---| +| 类型名 | `uint8` / `int32_t` / `long` | `byte` → `Unknown or invalid type name` | +| 指针参数 | 传 **Node `Buffer`** | 传普通 JS 对象 → **不写回**,读回仍是原值 | +| 结构体 | `koffi.struct(name, {...})` 后传普通对象 | 重复 `koffi.struct("POINT")` → `Duplicate type name` | +| 字符串 | `Buffer.from(s + "\0", "utf16le")` | `koffi.alloc(str)` 把 str 当**类型名** | + +**指针参数必须用 Buffer** 这条尤其重要:`WindowFromPoint` 若用普通对象, +会拿不到真实 hwnd ⇒ 点击落到错误窗口上。 + +跨进程/跨模块重复声明同名结构体会抛 `Duplicate type name`,需 try/catch 换名注册。 + +--- + +## 八、环境注意事项 + +- 本仓库文件多为 **CRLF**。用 Node 脚本打补丁时先 `replace(/\r\n/g, "\n")` 处理, + 写回时按原行尾还原。 +- `git status` 会把大量文件标成 `M`,但 `git diff --numstat` 显示 `0 0` + —— 那是 CRLF 归一化噪声,不是真实改动。**提交前用 `--numstat` 甄别。** +- 本机没有 Go 工具链(`go: command not found`),`cmd/waiter/` 的改动 + **未经过编译验证**,需在有 Go 环境的机器上 `go build ./cmd/waiter` 确认。 +- 本会话多数为无头/无交互桌面环境,`WindowFromPoint` / 真实光标相关行为 + **无法在此验证**,需在有桌面的会话实测。 diff --git a/cmd/gui/agent-cursor.js b/cmd/gui/agent-cursor.js new file mode 100644 index 00000000..f909d350 --- /dev/null +++ b/cmd/gui/agent-cursor.js @@ -0,0 +1,241 @@ +// Agent 独立光标(模式 A):GUI 自绘 mascot 指针 + 后台注入,全程不动用户鼠标。 +// +// 目标:agent 操作用户电脑时,不再抢走真实光标 —— 用户可以同时干自己的事。 +// +// 三层结构: +// 1) overlay 窗口:全屏透明置顶层,setIgnoreMouseEvents(true) 让它不吃点击 +// 2) mascot 指针:在这个窗口里画 mascot.webp + 点击脉冲环,做移动/点击动画 +// 3) 后台注入:PostMessage 直接投递到目标窗口 hwnd,不经过系统光标 +// +// 已实测确认的两条硬事实(cmd/gui/probe2 与 probe-inject/electron-probe.js): +// A) 坐标空间:Win32 坐标 = 物理像素 = 截图像素,**不需要换算 scaleFactor** +// (Electron 主进程 per-monitor DPI-aware,Win32 不虚拟化) +// B) koffi 3.x 传指针参数必须用 **Buffer**;用普通 JS 对象不写回 +// (GetCursorPos 传 {x,y} 读回仍是原值,传 Buffer 才拿到真值) +// —— 这条决定了 WindowFromPoint 必须走 Buffer,写错会拿错目标窗口。 +// +// 不做的事(明确的取舍): +// · 不做真实鼠标降级。模式 A 只保证对「接受窗口消息的程序」可靠; +// Chromium/Electron 自绘、游戏、DirectX 会忽略合成消息,这是 +// Windows 消息模型的硬限制,不是实现缺陷。 +// · 不劫持用户鼠标。任何情况下都不调用 SetCursorPos。 + +const path = require("path"); +const fs = require("fs"); +const { BrowserWindow, screen } = require("electron"); + +// ── overlay 与动画状态 ────────────────────────────────────────────── +let cursorWin = null; // 自绘光标窗口 +let cursorVisible = false; +let cursorBusy = false; // 正在执行命令时(用于画"忙碌"光环) +let cursorRaf = null; +let lastHwnd = null; // 上一次鼠标操作命中的目标窗口(键盘动作投递到这里) +const cursorPos = { x: 0, y: 0 }; // 物理像素,屏幕坐标 +const cursorTarget = { x: 0, y: 0 }; +let cursorPulse = 0; // 点击脉冲环 0..1,>0 表示正在播放 +let cursorMotion = 0; // 移动拖尾强度 + +const CURSOR_PAGE = ` + + + +
+
+ +
+
+`; + +// 确保 overlay 窗口存在;dispIdx 决定铺在哪块屏 +function ensureCursorWindow(dispIdx) { + try { + if (cursorWin && !cursorWin.isDestroyed()) return cursorWin; + } catch (e) {} + const displays = screen.getAllDisplays(); + const d = displays[dispIdx] || displays[0] || screen.getPrimaryDisplay(); + const b = d.bounds; + cursorWin = new BrowserWindow({ + x: b.x, + y: b.y, + width: b.width, + height: b.height, + transparent: true, + frame: false, + resizable: false, + movable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + skipTaskbar: true, + show: false, + hasShadow: false, + enableLargerThanScreen: false, + // ★ 关键:不参与焦点与输入,只作视觉层 + focusable: false, + acceptFirstMouse: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + backgroundThrottling: false, + }, + }); + // 鼠标穿透:光标层绝不能挡住用户点击 + try { + cursorWin.setIgnoreMouseEvents(true, { forward: true }); + } catch (e) {} + // 置顶但**不抢焦点**(screen-saver 级会盖住全屏应用,normal 会挡在普通窗口之上) + try { + cursorWin.setAlwaysOnTop(true, "screen-saver"); + } catch (e) {} + try { + cursorWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } catch (e) {} + cursorWin.loadURL("data:text/html;charset=utf-8," + encodeURIComponent(CURSOR_PAGE)); + cursorWin.webContents.once("did-finish-load", () => { + // 把 mascot 注入为 data URL:file:// 在 data: 页面里取不到本地文件 + try { + const p = path.join(__dirname, "renderer", "mascot.webp"); + if (fs.existsSync(p)) { + const b64 = fs.readFileSync(p).toString("base64"); + cursorWin.webContents + .executeJavaScript( + "document.getElementById('mascot').src='data:image/webp;base64," + b64 + "'", + ) + .catch(() => {}); + } + } catch (e) {} + try { + cursorWin.showInactive(); + } catch (e) {} + }); + return cursorWin; +} + +function agentCursorEval(js, dispIdx) { + try { + const w = ensureCursorWindow(dispIdx); + if (!w || w.webContents.isLoading()) return null; + w.webContents.executeJavaScript(js).catch(() => {}); + } catch (e) {} + return null; +} + +// 移动自绘光标到屏幕物理坐标(不动真实鼠标) +function agentCursorMove(x, y, dispIdx) { + cursorPos.x = x; + cursorPos.y = y; + agentCursorEval( + "window.__agentCursor&&window.__agentCursor.move(" + + Math.round(x) + + "," + + Math.round(y) + + ",1)", + dispIdx, + ); + showAgentCursor(dispIdx); +} + +// 点击脉冲 +function agentCursorClick(dispIdx) { + agentCursorEval("window.__agentCursor&&window.__agentCursor.click()", dispIdx); +} + +// 忙碌呼吸 +function agentCursorBusy(busy, dispIdx) { + cursorBusy = !!busy; + agentCursorEval("window.__agentCursor&&window.__agentCursor.setBusy(" + (busy ? "1" : "0") + ")", dispIdx); +} + +function showAgentCursor(dispIdx) { + try { + const w = ensureCursorWindow(dispIdx); + cursorVisible = true; + agentCursorEval("window.__agentCursor&&window.__agentCursor.setVisible(1)", dispIdx); + if (!w.isVisible()) w.showInactive(); + } catch (e) {} +} + +function hideAgentCursor() { + cursorVisible = false; + try { + if (cursorWin && !cursorWin.isDestroyed()) { + agentCursorEval("window.__agentCursor&&window.__agentCursor.setVisible(0)", 0); + cursorWin.hide(); + } + } catch (e) {} +} + +module.exports = { + ensureCursorWindow, + agentCursorMove, + agentCursorClick, + agentCursorBusy, + showAgentCursor, + hideAgentCursor, + isVisible: () => cursorVisible, + pos: () => ({ ...cursorPos }), + // 记住最后一次点击命中的窗口句柄。 + // + // 键盘类 action(keypress/hotkey/type)不带坐标,Win32 又必须知道往哪个 + // 窗口发消息 —— 而后台注入**不会移动真实光标**,所以 GetCursorPos 拿到的是 + // **用户自己**的光标位置(很可能根本不在 agent 要操作的地方)。 + // 因此这里显式记录上一次鼠标操作的目标窗口,键盘动作就投递到那里。 + lastHwnd: null, + setLastHwnd(h) { + lastHwnd = h || null; + }, + get busy() { + return cursorBusy; + }, +}; diff --git a/cmd/gui/agent-inject.js b/cmd/gui/agent-inject.js new file mode 100644 index 00000000..e49e8959 --- /dev/null +++ b/cmd/gui/agent-inject.js @@ -0,0 +1,599 @@ +// Windows 输入注入:两种模式。 +// +// ┌─ SendInput(默认,可靠)────────────────────────────────────────┐ +// │ 注入到**系统输入队列**,等价真实硬件事件。Chromium/Electron/ │ +// │ 游戏等自绘界面都能接收。代价:会移动用户的真实光标。 │ +// │ 参考实现:Pal-AI-Lab/Coopanion 的 cortico-world-cua/win32.ts │ +// └────────────────────────────────────────────────────────────────┘ +// ┌─ PostMessage(后台,不干扰用户)───────────────────────────────┐ +// │ 直接投递给**某个窗口句柄**,用户鼠标纹丝不动。代价:自绘界面 │ +// │ (Chromium/Electron/游戏)常忽略合成消息,点不动。 │ +// └────────────────────────────────────────────────────────────────┘ +// +// ★ 已实测确认(本轮): +// · SendInput 结构体尺寸 MOUSEINPUT=32 / KEYBDINPUT=24 / INPUT=40(x64), +// layout 正确,SendInput 返回 1(成功送出)。 +// · GetLastInputInfo 可用,配合 GetTickCount 可算「用户空闲多久」。 +// · koffi 指针参数必须传 **Buffer**;传普通 JS 对象不写回 +// (GetCursorPos({x,y}) 读回仍是原值)。 +// +// ★ SendInput 绝对坐标是 **0–65535 归一化值**,不是像素 —— 最容易踩的坑。 +// 本次实现按**虚拟桌面**归一化并带 MOUSEEVENTF_VIRTUALDESK, +// 因此多显示器下坐标也正确(Coopanion 只按主屏归一化)。 + +let koffi = null; +let u32 = null; +let k32 = null; +let cached = null; + +// koffi 类型名是**全局注册**的:同进程重复注册同名 struct 会抛 +// "Duplicate type name"。统一用 reg() 尝试原名、失败换 HA_ 前缀。 +function reg(name, def) { + try { + return koffi.struct(name, def); + } catch (e) { + return koffi.struct("HA_" + name, def); + } +} + +function lib() { + if (cached) return cached; + koffi = require("koffi"); + u32 = koffi.load("user32.dll"); + k32 = koffi.load("kernel32.dll"); + + const MOUSEINPUT = reg("MOUSEINPUT", { + dx: "long", dy: "long", mouseData: "uint32_t", + dwFlags: "uint32_t", time: "uint32_t", dwExtraInfo: "uintptr_t", + }); + const KEYBDINPUT = reg("KEYBDINPUT", { + wVk: "uint16_t", wScan: "uint16_t", dwFlags: "uint32_t", + time: "uint32_t", dwExtraInfo: "uintptr_t", + }); + const HARDWAREINPUT = reg("HARDWAREINPUT", { + uMsg: "uint32_t", wParamL: "uint16_t", wParamH: "uint16_t", + }); + const INPUT = koffi.struct( + MOUSEINPUT.name.startsWith("HA_") ? "HA_INPUT" : "INPUT", + { type: "uint32_t", u: koffi.union({ mi: MOUSEINPUT, ki: KEYBDINPUT, hi: HARDWAREINPUT }) }, + ); + const LASTINPUTINFO = reg("LASTINPUTINFO", { cbSize: "uint32_t", dwTime: "uint32_t" }); + + const f = (sig) => u32.func(sig); + cached = { + POINT: reg("POINT", { x: "long", y: "long" }), + INPUT, + LASTINPUTINFO, + INPUT_SIZE: koffi.sizeof(INPUT), + SendInput: f("uint32_t __stdcall SendInput(uint32_t count, " + INPUT.name + " *inputs, int size)"), + GetLastInputInfo: f("bool __stdcall GetLastInputInfo(_Inout_ " + LASTINPUTINFO.name + " *info)"), + GetTickCount: k32.func("uint32_t __stdcall GetTickCount()"), + GetSystemMetrics: f("int GetSystemMetrics(int i)"), + SetProcessDpiAwarenessContext: f("bool __stdcall SetProcessDpiAwarenessContext(intptr_t value)"), + // 指针参数一律用 void *,调用时传 Buffer + WindowFromPoint: f("void *WindowFromPoint(void *pt)"), + ChildWindowFromPointEx: f("void *ChildWindowFromPointEx(void *hwndParent, void *pt, uint flags)"), + GetAncestor: f("void *GetAncestor(void *hwnd, uint flags)"), + IsWindow: f("int IsWindow(void *hwnd)"), + IsWindowVisible: f("int IsWindowVisible(void *hwnd)"), + GetWindowThreadProcessId: f("uint GetWindowThreadProcessId(void *hwnd, uint *pid)"), + GetClientRect: f("int GetClientRect(void *hwnd, void *rect)"), + ScreenToClient: f("int ScreenToClient(void *hwnd, void *pt)"), + GetCursorPos: f("bool GetCursorPos(void *pt)"), + PostMessageW: f("intptr_t PostMessageW(void *hwnd, uint msg, uintptr_t w, intptr_t l)"), + SendMessageW: f("intptr_t SendMessageW(void *hwnd, uint msg, uintptr_t w, intptr_t l)"), + GetWindowLongPtrW: f("intptr_t GetWindowLongPtrW(void *hwnd, int i)"), + }; + return cached; +} + +// ── Win32 常量 ───────────────────────────────────────────────────── +const GA_ROOT = 2; +const CWP_SKIPINVISIBLE = 0x0002; +const CWP_SKIPTRANSPARENT = 0x0004; +const GWL_STYLE = -16; +const GWL_EXSTYLE = -20; +const WS_EX_TRANSPARENT = 0x00000020; + +// SendInput +const INPUT_MOUSE = 0, INPUT_KEYBOARD = 1; +const ME = { + MOVE: 0x1, LEFTDOWN: 0x2, LEFTUP: 0x4, RIGHTDOWN: 0x8, RIGHTUP: 0x10, + MIDDLEDOWN: 0x20, MIDDLEUP: 0x40, WHEEL: 0x800, HWHEEL: 0x1000, + VIRTUALDESK: 0x4000, ABSOLUTE: 0x8000, +}; +const KE = { EXTENDEDKEY: 0x1, KEYUP: 0x2, UNICODE: 0x4 }; +const WHEEL_DELTA = 120; + +// PostMessage +const WM_MOUSEMOVE = 0x0200; +const WM_LBUTTONDOWN = 0x0201, WM_LBUTTONUP = 0x0202, WM_LBUTTONDBLCLK = 0x0203; +const WM_RBUTTONDOWN = 0x0204, WM_RBUTTONUP = 0x0205; +const WM_MBUTTONDOWN = 0x0207, WM_MBUTTONUP = 0x0208; +const WM_MOUSEWHEEL = 0x020A, WM_MOUSEHWHEEL = 0x020E; +const WM_KEYDOWN = 0x0100, WM_KEYUP = 0x0101, WM_CHAR = 0x0102; +const MK_LBUTTON = 0x0001, MK_RBUTTON = 0x0002, MK_MBUTTON = 0x0010; + +const SM_XVIRTUALSCREEN = 76, SM_YVIRTUALSCREEN = 77; +const SM_CXVIRTUALSCREEN = 78, SM_CYVIRTUALSCREEN = 79; + +const VK = { + ctrl: 0x11, control: 0x11, alt: 0x12, shift: 0x10, win: 0x5b, meta: 0x5b, super: 0x5b, + enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, + space: 0x20, backspace: 0x08, delete: 0x2e, del: 0x2e, + up: 0x26, down: 0x28, left: 0x25, right: 0x27, + home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22, +}; +const VK_EXTENDED = new Set([0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x2e, 0x5b, 0x5c, 0x6f, 0x74]); +const isExtVK = (vk) => VK_EXTENDED.has(vk); + +// ── 公共小工具 ───────────────────────────────────────────────────── +function virtualScreen() { + const k = lib(); + return { + x: k.GetSystemMetrics(SM_XVIRTUALSCREEN), + y: k.GetSystemMetrics(SM_YVIRTUALSCREEN), + w: k.GetSystemMetrics(SM_CXVIRTUALSCREEN), + h: k.GetSystemMetrics(SM_CYVIRTUALSCREEN), + }; +} + +function hwndStr(h) { + return h ? "0x" + h.toString(16) : "(null)"; +} + +/** 屏幕坐标 → 0–65535 归一化(按虚拟桌面)。SendInput 绝对坐标用这个。 */ +function toAbsolute(x, y) { + const v = virtualScreen(); + const nx = Math.round(((x - v.x) * 65535) / Math.max(1, v.w - 1)); + const ny = Math.round(((y - v.y) * 65535) / Math.max(1, v.h - 1)); + return { nx: Math.max(0, Math.min(65535, nx)), ny: Math.max(0, Math.min(65535, ny)) }; +} + +// ── 用户活动感知(「用户在操作就暂缓」的核心)─────────────────────── +// +// ★ 关键陷阱:GetLastInputInfo 是**全局**的 —— agent 自己的 SendInput +// 也会被算作「输入」。若不排除自己的注入,每次注入后 idleMs() 都归零, +// 于是默认永远处于「用户正在操作」状态,所有命令都被暂缓。 +// (实测踩到:deferMs 设为 0 仍被暂缓,因为上一条命令刚注入过。) +// +// 参照 Pal-AI-Lab/Coopanion 的做法,用三重判定区分「自己注入」vs「用户操作」: +// 1. ownTick —— 自己最后一次注入的时刻,之后 40ms 内的输入忽略 +// 2. ownCursor —— 自己放的光标位置;偏离 >2px 说明是用户动的 +// 3. 无符号差值 —— GetTickCount 每 2^32 ms 回绕,必须按无符号比较 +let ownTick = 0; // 自己最后一次 SendInput 的 tick +let ownCursor = null; // 自己放下的光标位置 {x,y} +let ownMovedAt = 0; // 检测到光标被外力移动的时刻 + +/** 无符号「a - b」,正确处理 2^32 回绕。 */ +function since(a, b) { + return (a - b) >>> 0; +} + +function cursorNow() { + const k = lib(); + const pt = Buffer.alloc(8); + if (!k.GetCursorPos(pt)) return null; + return { x: pt.readInt32LE(0), y: pt.readInt32LE(4) }; +} + +/** 标记「刚刚由我们自己注入了输入」。 */ +function markOwnInput() { + ownTick = lib().GetTickCount(); + ownCursor = cursorNow(); +} + +/** 距上次**用户**输入过了多少毫秒(排除自己的注入)。无记录时返回 Infinity。 */ +function idleMs() { + const k = lib(); + const now = k.GetTickCount(); + + // 1) 若光标偏离我们自己放的位置 >2px,说明是用户移动的 + const cur = cursorNow(); + if (ownCursor && cur) { + if (Math.abs(cur.x - ownCursor.x) > 2 || Math.abs(cur.y - ownCursor.y) > 2) { + ownCursor = cur; + ownMovedAt = now; + } + } + + // 2) 取全局最后输入时刻,并判断它是否发生在「我们自己注入」之后 + const info = { cbSize: koffi.sizeof(k.LASTINPUTINFO), dwTime: 0 }; + if (!k.GetLastInputInfo(info)) return Number.MAX_SAFE_INTEGER; + const last = info.dwTime; + // ownTick 之后 40ms 内的输入视为我们自己造成的(SendInput 生效有延迟) + const afterOwn = + ownTick === 0 || (since(last, ownTick) > 40 && since(last, ownTick) < 0x80000000); + let userAt = afterOwn ? last : 0; + + // 3) 光标移动也算用户活动,取两者中更晚的 + if (ownMovedAt && (!userAt || since(ownMovedAt, userAt) < 0x80000000)) userAt = ownMovedAt; + + return userAt ? since(now, userAt) : Number.MAX_SAFE_INTEGER; +} + +/** 用户是否在最近 thresholdMs 内操作过(已排除 agent 自身注入)。 */ +function userIsActive(thresholdMs = 1200) { + return idleMs() < thresholdMs; +} + +/** + * 等到用户停手再执行。 + * + * 为什么需要:SendInput 会移动真实光标。若用户正在打字/拖拽,agent + * 插进来会抢走光标、打断输入。做法是轮询等待用户空闲,超过 maxWait + * 则放弃等待(返回 false)—— 宁可跳过本次操作,也不硬抢。 + */ +async function waitForUserIdle(maxWaitMs = 3000, thresholdMs = 800) { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + if (!userIsActive(thresholdMs)) return true; + await new Promise((r) => setTimeout(r, 120)); + } + return !userIsActive(thresholdMs); +} + +// ── SendInput 实现 ───────────────────────────────────────────────── +const mouseEvent = (dwFlags, dx = 0, dy = 0, mouseData = 0) => ({ + type: INPUT_MOUSE, + u: { mi: { dx, dy, mouseData, dwFlags, time: 0, dwExtraInfo: 0 } }, +}); +const keyEvent = (vk, up, extended, unicode) => ({ + type: INPUT_KEYBOARD, + u: { + ki: { + wVk: unicode ? 0 : vk, + wScan: unicode ? vk : 0, + dwFlags: (up ? KE.KEYUP : 0) | (extended ? KE.EXTENDEDKEY : 0) | (unicode ? KE.UNICODE : 0), + time: 0, + dwExtraInfo: 0, + }, + }, +}); + +function send(events) { + const k = lib(); + const sent = k.SendInput(events.length, events, k.INPUT_SIZE); + if (sent !== events.length) { + // 常见原因:目标窗口权限更高(UIPI 阻挡)。这必须报出来, + // 不能静默 —— 否则会表现为「命令成功但什么都没发生」。 + throw new Error( + "SendInput 只送出 " + sent + "/" + events.length + " 个事件(可能被更高权限的窗口挡住)", + ); + } + // 记录「这次是我们自己注入的」,避免被 idleMs() 误判为用户活动 + markOwnInput(); + return true; +} + +const BUTTON_FLAGS = { + left: [ME.LEFTDOWN, ME.LEFTUP], + right: [ME.RIGHTDOWN, ME.RIGHTUP], + middle: [ME.MIDDLEDOWN, ME.MIDDLEUP], +}; + +/** 移动真实光标(SendInput)。坐标:屏幕物理像素。 */ +function siMove(x, y) { + const { nx, ny } = toAbsolute(x, y); + send([mouseEvent(ME.MOVE | ME.ABSOLUTE | ME.VIRTUALDESK, nx, ny)]); + return { ok: true, x, y }; +} + +function siButton(x, y, action, button) { + const b = BUTTON_FLAGS[button] || BUTTON_FLAGS.left; + if (action === "move") return siMove(x, y); + siMove(x, y); // 先到位,再按键:否则会点到上一个位置 + if (action === "click") { + send([mouseEvent(b[0]), mouseEvent(b[1])]); + } else if (action === "doubleclick") { + send([mouseEvent(b[0]), mouseEvent(b[1]), mouseEvent(b[0]), mouseEvent(b[1])]); + } else if (action === "tripleclick") { + send([ + mouseEvent(b[0]), mouseEvent(b[1]), + mouseEvent(b[0]), mouseEvent(b[1]), + mouseEvent(b[0]), mouseEvent(b[1]), + ]); + } else if (action === "mousedown") { + send([mouseEvent(b[0])]); + } else if (action === "mouseup") { + send([mouseEvent(b[1])]); + } else { + return { ok: false, error: "unsupported action " + action }; + } + return { ok: true, x, y, action, button: button || "left" }; +} + +/** + * 拖拽:按下 → 分步移动 → 释放。 + * 分步是必需的:canvas 拖拽、列表排序、滑动条依赖中间 mousemove 事件, + * 一步到位会失效。 + */ +function siDrag(sx, sy, tx, ty, button, steps) { + const b = BUTTON_FLAGS[button] || BUTTON_FLAGS.left; + const n = Math.max(1, Math.min(60, steps || 12)); + siMove(sx, sy); + send([mouseEvent(b[0])]); + for (let i = 1; i <= n; i++) { + const t = i / n; + siMove(sx + (tx - sx) * t, sy + (ty - sy) * t); + } + siMove(tx, ty); + send([mouseEvent(b[1])]); + return { ok: true, from: { x: sx, y: sy }, to: { x: tx, y: ty }, steps: n }; +} + +/** + * 滚轮。dy/dx 单位为「格」(1 格 = WHEEL_DELTA=120)。 + * 约定:dy > 0 = 向上滚(与 Windows 惯例一致,也与本仓既有实现一致)。 + */ +function siScroll(dy, dx) { + const events = []; + if (dy) events.push(mouseEvent(ME.WHEEL, 0, 0, (Math.round(dy) * WHEEL_DELTA) >>> 0)); + if (dx) events.push(mouseEvent(ME.HWHEEL, 0, 0, (Math.round(dx) * WHEEL_DELTA) >>> 0)); + if (events.length) send(events); + return { ok: true, dy: dy || 0, dx: dx || 0 }; +} + +/** 组合键。spec 形如 "ctrl+shift+t"。 */ +function siKeyTap(spec) { + const parts = String(spec || "") + .split("+") + .map((x) => x.trim().toLowerCase()) + .filter(Boolean); + if (!parts.length) return { ok: false, error: "empty key" }; + const last = parts[parts.length - 1]; + const mods = parts.slice(0, -1); + const codes = []; + for (const m of mods) { + const c = VK[m]; + if (c === undefined) return { ok: false, error: "unknown modifier: " + m }; + codes.push(c); + } + let keyCode = VK[last]; + if (keyCode === undefined) { + if (last.length === 1) keyCode = last.toUpperCase().charCodeAt(0); + else return { ok: false, error: "unknown key: " + last }; + } + // 按下的按正序、释放按反序 —— 与真实手指动作一致 + const events = [ + ...codes.map((c) => keyEvent(c, false, false, false)), + keyEvent(keyCode, false, isExtVK(keyCode), false), + keyEvent(keyCode, true, isExtVK(keyCode), false), + ...[...codes].reverse().map((c) => keyEvent(c, true, false, false)), + ]; + send(events); + return { ok: true, key: spec }; +} + +/** + * 文本输入:用 KEYEVENTF.UNICODE 逐字符发送。 + * 比 WM_CHAR / SendKeys 可靠:不受键盘布局和输入法状态影响, + * 中文、emoji 也能进(emoji 需按 UTF-16 代理对逐个发)。 + */ +function siType(text) { + const t = String(text || ""); + if (!t) return { ok: false, error: "empty text" }; + const events = []; + for (let i = 0; i < t.length; i++) { + const ch = t[i]; + if (ch === "\n") { + events.push(keyEvent(VK.enter, false, false, false), keyEvent(VK.enter, true, false, false)); + continue; + } + if (ch === "\r") continue; + if (ch === "\t") { + events.push(keyEvent(VK.tab, false, false, false), keyEvent(VK.tab, true, false, false)); + continue; + } + const unit = t.charCodeAt(i); // UTF-16 code unit(代理对会被拆成两个,正好) + events.push(keyEvent(unit, false, false, true), keyEvent(unit, true, false, true)); + } + if (events.length) send(events); + return { ok: true, chars: t.length }; +} + +// ── PostMessage 实现(后台,不干扰用户)──────────────────────────── +function windowAtPoint(sx, sy) { + const k = lib(); + const pt = Buffer.alloc(8); + pt.writeInt32LE(Math.round(sx), 0); + pt.writeInt32LE(Math.round(sy), 4); + let h = k.WindowFromPoint(pt); + if (!h) return null; + const flags = CWP_SKIPINVISIBLE | CWP_SKIPTRANSPARENT; + let guard = 0; + for (;;) { + const child = k.ChildWindowFromPointEx(h, pt, flags); + if (!child || child === h || guard++ > 8) break; + h = child; + } + return h; +} + +function toClient(hwnd, sx, sy) { + const k = lib(); + const pt = Buffer.alloc(8); + pt.writeInt32LE(Math.round(sx), 0); + pt.writeInt32LE(Math.round(sy), 4); + k.ScreenToClient(hwnd, pt); + return { x: pt.readInt32LE(0), y: pt.readInt32LE(4) }; +} + +function acceptsSynthetic(hwnd) { + const k = lib(); + if (!hwnd || !k.IsWindow(hwnd)) return { ok: false, why: "invalid hwnd" }; + if (!k.IsWindowVisible(hwnd)) return { ok: false, why: "window hidden" }; + const ex = k.GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + if (ex & WS_EX_TRANSPARENT) return { ok: false, why: "WS_EX_TRANSPARENT" }; + return { ok: true, ex }; +} + +function post(hwnd, msg, w, l) { + return lib().PostMessageW(hwnd, msg, w, l) !== 0; +} +const pack = (x, y) => ((y & 0xffff) << 16) | (x & 0xffff); + +const PM_BTN = { + left: { down: WM_LBUTTONDOWN, up: WM_LBUTTONUP, dbl: WM_LBUTTONDBLCLK, mk: MK_LBUTTON }, + right: { down: WM_RBUTTONDOWN, up: WM_RBUTTONUP, mk: MK_RBUTTON }, + middle: { down: WM_MBUTTONDOWN, up: WM_MBUTTONUP, mk: MK_MBUTTON }, +}; + +function pmMouse(sx, sy, action, opts) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空(该坐标无窗口)" }; + const c = toClient(hwnd, sx, sy); + const lp = pack(c.x, c.y); + const btn = PM_BTN[(opts && opts.button) || "left"] || PM_BTN.left; + const P = (m, w) => post(hwnd, m, w, lp); + switch (action) { + case "move": + return { ok: P(WM_MOUSEMOVE, 0), hwnd, client: c }; + case "click": + P(btn.down, btn.mk); + return { ok: P(btn.up, 0), hwnd, client: c }; + case "doubleclick": + P(btn.down, btn.mk); P(btn.up, 0); + P(btn.dbl, MK_LBUTTON); + return { ok: P(btn.up, 0), hwnd, client: c }; + case "mousedown": + return { ok: P(btn.down, btn.mk), hwnd, client: c }; + case "mouseup": + return { ok: P(btn.up, 0), hwnd, client: c }; + default: + return { ok: false, error: "unsupported mouse action " + action }; + } +} + +function pmTripleClick(sx, sy) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" }; + const c = toClient(hwnd, sx, sy); + const lp = pack(c.x, c.y); + post(hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lp); + post(hwnd, WM_LBUTTONUP, 0, lp); + post(hwnd, WM_LBUTTONDBLCLK, MK_LBUTTON, lp); + post(hwnd, WM_LBUTTONUP, 0, lp); + return { ok: true, hwnd }; +} + +function pmDrag(sx, sy, tx, ty, opts) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" }; + const btn = PM_BTN[(opts && opts.button) || "left"] || PM_BTN.left; + const steps = Math.max(1, Math.min(60, (opts && opts.steps) || 12)); + const c0 = toClient(hwnd, sx, sy); + post(hwnd, btn.down, btn.mk, pack(c0.x, c0.y)); + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const c = toClient(hwnd, sx + (tx - sx) * t, sy + (ty - sy) * t); + post(hwnd, WM_MOUSEMOVE, btn.mk, pack(c.x, c.y)); + } + const ce = toClient(hwnd, tx, ty); + const ok = post(hwnd, btn.up, 0, pack(ce.x, ce.y)); + return { ok, hwnd, steps }; +} + +function pmScroll(sx, sy, dy, dx) { + const hwnd = windowAtPoint(sx, sy); + if (!hwnd) return { ok: false, error: "WindowFromPoint 返回空" }; + const lp = pack(Math.round(sx), Math.round(sy)); // 滚轮用屏幕坐标 + let ok = true; + if (dy) ok = post(hwnd, WM_MOUSEWHEEL, (Math.round(dy) * WHEEL_DELTA) >>> 0, lp) && ok; + if (dx) ok = post(hwnd, WM_MOUSEHWHEEL, (Math.round(dx) * WHEEL_DELTA) >>> 0, lp) && ok; + return { ok, hwnd }; +} + +function pmKeyTap(hwnd, spec) { + const parts = String(spec || "").split("+").map((x) => x.trim().toLowerCase()).filter(Boolean); + if (!parts.length) return { ok: false, error: "empty key" }; + const last = parts[parts.length - 1]; + const mods = parts.slice(0, -1); + const codes = []; + for (const m of mods) { + const c = VK[m]; + if (c === undefined) return { ok: false, error: "unknown modifier: " + m }; + codes.push(c); + } + let keyCode = VK[last]; + if (keyCode === undefined) { + if (last.length === 1) keyCode = last.toUpperCase().charCodeAt(0); + else return { ok: false, error: "unknown key: " + last }; + } + let ok = true; + for (const c of codes) ok = post(hwnd, WM_KEYDOWN, c, 0) && ok; + ok = post(hwnd, WM_KEYDOWN, keyCode, isExtVK(keyCode) ? 1 : 0) && ok; + ok = post(hwnd, WM_KEYUP, keyCode, isExtVK(keyCode) ? 1 : 0) && ok; + for (const c of codes.slice().reverse()) ok = post(hwnd, WM_KEYUP, c, 0) && ok; + return { ok, key: spec }; +} + +function pmTypeText(hwnd, text) { + const t = String(text || ""); + if (!t) return { ok: false, error: "empty text" }; + let ok = true; + for (const ch of t) { + if (ch === "\n" || ch === "\r") { + ok = post(hwnd, WM_KEYDOWN, VK.enter, 0) && ok; + ok = post(hwnd, WM_KEYUP, VK.enter, 0) && ok; + continue; + } + if (ch === "\t") { + ok = post(hwnd, WM_KEYDOWN, VK.tab, 0) && ok; + ok = post(hwnd, WM_KEYUP, VK.tab, 0) && ok; + continue; + } + ok = post(hwnd, WM_CHAR, ch.codePointAt(0), 0) && ok; + } + return { ok, chars: t.length }; +} + +module.exports = { + available() { + try { + lib(); + return true; + } catch (e) { + return false; + } + }, + // 坐标/桌面 + virtualScreen, + toAbsolute, + hwndStr, + // 用户活动感知 + idleMs, + userIsActive, + waitForUserIdle, + markOwnInput, + cursorNow, + // SendInput(默认路径) + si: { + move: siMove, + button: siButton, + drag: siDrag, + scroll: siScroll, + key: siKeyTap, + type: siType, + }, + // PostMessage(后台路径) + pm: { + mouse: pmMouse, + tripleClick: pmTripleClick, + drag: pmDrag, + scroll: pmScroll, + key: pmKeyTap, + type: pmTypeText, + }, + // 兼容旧名(PostMessage) + windowAtPoint, + toClient, + acceptsSynthetic, + mouse: pmMouse, + tripleClick: pmTripleClick, + drag: pmDrag, + scroll: pmScroll, + keyTap: pmKeyTap, + typeText: pmTypeText, + VK, +}; diff --git a/cmd/gui/computeruse.test.mjs b/cmd/gui/computeruse.test.mjs new file mode 100644 index 00000000..2e355d74 --- /dev/null +++ b/cmd/gui/computeruse.test.mjs @@ -0,0 +1,263 @@ +// computeruse 能力面 + GUI 启动期误判的判据。 +// +// 覆盖三组回归: +// +// 1) computeruse 高级操作(两端:GUI 本机桥 / waiter 设备侧)。 +// 此前两端都只有 9 个基础 action,缺拖拽、悬停、按下/释放、三击、 +// 显式等待、显示器信息 —— 与业界 computer-use 的基本盘差距明显。 +// +// 2) HiDPI 与 koffi 调用约定。 +// · 坐标:截图给模型的是**物理像素**,而 SetCursorPos/xdotool 期望 +// **逻辑点(DIP)**;原先只加了 bounds 原点偏移,没换算 scaleFactor +// ⇒ 150% 缩放屏上点击系统性偏移。 +// · 类型名:koffi 不认 Win32 的 `byte`,曾让 keybd_event 整体 +// 拿不到 ⇒ 组合键全废,而单键 click 不受影响、极易漏掉。 +// +// 3) 启动期误判「服务端未启动」。 +// isServerRunning 硬编码 localhost:8080,在「服务端跑在 WSL/远端」 +// 的部署下必然探不通 → 误判服务没起 → 去拉起 GUI 旁并不存在的 +// homed.exe → 干等 8s 超时 → 每次启动刷一条 +// "homed failed to start within timeout"。 +// +// 另含 PiDeck 借鉴声明(用户明确要求显式记录设计与实现的出处)。 +// +// 运行:node computeruse.test.mjs + +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, "..", ".."); +const main = readFileSync(join(here, "main.js"), "utf8"); +const app = readFileSync(join(here, "renderer", "app.js"), "utf8"); +const css = readFileSync(join(here, "renderer", "style.css"), "utf8"); +const waiter = readFileSync(join(repoRoot, "cmd", "waiter", "device.go"), "utf8"); + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// ── 1) 高级操作 ──────────────────────────────────────────────────── +const advanced = [ + ["drag", "拖拽"], + ["hover", "悬停(触发 tooltip)"], + ["mousedown", "按键按下(与 mouseup 配对做按住)"], + ["mouseup", "按键释放"], + ["tripleclick", "三击选中整行"], + ["middleclick", "中键点击"], + ["hotkey", "组合键"], + ["wait", "显式等待(等 UI/动画完成)"], + ["display", "显示器几何信息(多屏定位)"], +]; +for (const [act, desc] of advanced) { + check( + `GUI 支持 ${act}(${desc})`, + new RegExp('case\\s+"' + act + '"').test(main), + "未实现 " + act, + ); +} + +const wStart = waiter.indexOf("func execComputeruseLinux("); +const wEnd = waiter.indexOf("func execComputeruseWindows("); +const linuxSeg = waiter.slice(wStart, wEnd); +for (const [act, desc] of advanced) { + if (act === "display") continue; // waiter 侧无 Electron screen API + // 匹配要允许 `case "a", "b":` 合并写法:waiter 的 hover 与 move 同分支、 + // keypress 与 hotkey/combo 同分支,只匹配单个 case 名会误报「未实现」。 + const re = new RegExp('case[^:]*"' + act + '"[^:]*:'); + check(`waiter Linux 支持 ${act}(${desc})`, re.test(linuxSeg), "waiter Linux 未实现 " + act); +} + +// ── 2) HiDPI 与 koffi 约定 ───────────────────────────────────────── +// ★ 坐标空间:Win32 = 物理像素 = 截图像素,**不应再除以 scaleFactor**。 +// +// 实测(Electron 主进程内): +// screen API (DIP) : 1260 x 840 scaleFactor=2 +// GetSystemMetrics : 2520 x 1680 +// SetCursorPos(200,200) -> GetCursorPos 读回 (200,200) ← 恒等,不缩放 +// +// 因为 Electron 主进程是 per-monitor DPI-aware,Win32 坐标不虚拟化。 +// (对照:普通 node 进程里同一函数读到 1260x840,那是 DPI-*unaware* +// 的虚拟化行为,不能拿来推断 Electron 内的行为。) +// +// 本仓曾按「SetCursorPos 期望 DIP」加过 `/ scaleFactor`,那个前提是错的, +// 会把原本正确的点击改坏(150% 屏上点 (600,400) 被送到 (400,267))。 +const kx = main.match(/const absX = Math\.round\([^;]*?physical \? ([^:]+):/); +check( + "★ Win32 坐标按物理像素直接用(不除 scaleFactor)", + !!kx && !/\/\s*scale\b/.test(kx[1]), + kx + ? "absX 仍对物理像素做了 /scale 换算 ⇒ HiDPI 屏上点击偏移" + : "未找到 absX 计算", +); +check( + "physical:false 时才乘回 scaleFactor(DIP→物理)", + /physical \? rawX : rawX \* scale/.test(main), + "DIP 输入未乘回缩放比", +); +check( + "拖拽终点坐标与主坐标用同一套换算", + /physical \? \(parseFloat\(params\.tox[^\n]*\* scale\)/.test(main), + "drag 终点仍在用旧的 /scale 逻辑,会与起点偏移不一致", +); +// 只看**真实代码**里 user32.func(...) 传的签名,不看注释 +// (注释里会引用 `keybd_event(byte ...)` 描述旧 bug,不能因此误报)。 +const kbdSig = main.match(/user32\.func\(\s*"[^"]*keybd_event\((uint8|byte)/); +check( + "★ keybd_event 用 koffi 支持的类型名(uint8 而非 byte)", + !!kbdSig && kbdSig[1] === "uint8", + kbdSig + ? "user32.func 里用的是 " + kbdSig[1] + ",koffi 不认 `byte` ⇒ 组合键整体报 keybd_event unavailable" + : "未找到 user32.func(...keybd_event...) 调用", +); +check( + "keybd_event 解析失败会记录日志", + /keybd_event signature failed/.test(main), + "静默 catch 掉签名错误,线上无法诊断", +); +check( + "★ waiter Linux 有 normKeySpec 归一化组合键", + /func normKeySpec/.test(waiter), + "缺少组合键归一化 ⇒ 模型输出 \"Ctrl+C\" 会失效", +); +check( + "waiter type 用 --clearmodifiers", + /type[\s\S]{0,80}--clearmodifiers/.test(linuxSeg), + "type 未清修饰键 ⇒ 残留 Ctrl 会把后续输入变成快捷键", +); +check( + "★ 拖拽分步移动(GUI)", + /steps[\s\S]{0,400}SetCursorPos/.test(main), + "一步到位 ⇒ canvas 拖拽/排序失效", +); +check( + "★ 拖拽分步移动(waiter)", + /steps[\s\S]{0,400}mousemove/.test(linuxSeg), + "一步到位 ⇒ canvas 拖拽/排序失效", +); + +// ── 3) 启动期不得误判服务端 ─────────────────────────────────────── +check( + "★ 服务探测不只探 localhost", + /function serverProbeTargets/.test(main) && /loadConnections\(\)/.test( + main.slice(main.indexOf("function serverProbeTargets"), main.indexOf("function probeOne")), + ), + "未从 connections.json 取已配置端点", +); +check( + "★ localhost 仅作为兜底候选", + /push\("http:\/\/localhost:8080"\)/.test(main), + "缺少 localhost 兜底", +); +check( + "★ 仅当存在本地 homed 才尝试拉起", + /else if \(!findHomed\(\)\)/.test(main), + "无 homed 可执行文件时仍会空等 8s 超时", +); +check( + "★ 服务可达时跳过 homed 自动拉起", + /server reachable, skip homed autostart/.test(main), + "服务在远端时仍会拉起 homed 并超时", +); +check( + "探测有超时保护(不会无限等)", + /probeOne[\s\S]{0,300}setTimeout/.test(main), + "探测缺超时 ⇒ 启动被挂住", +); + +// ── 4) 模式 A:agent 独立光标 + 后台注入 ───────────────────────── +// 模式 A 的核心承诺是「不动用户真实鼠标」。这条承诺必须由判据钉住 —— +// 一旦有人图省事改回 SetCursorPos,用户的光标就会被抢,且很难归因。 +const injectSrc = readFileSync(join(here, "agent-inject.js"), "utf8"); +const cursorSrc = readFileSync(join(here, "agent-cursor.js"), "utf8"); +// 去掉整行注释后再查 SetCursorPos —— 注释里会引用这个名字来解释「为什么不用它」 +const stripComments = (s) => s.replace(/^\s*\/\/.*$/gm, ""); +check( + "★ 模式 A 不碰真实鼠标(agent-inject 无 SetCursorPos 调用)", + !/SetCursorPos\(/.test(stripComments(injectSrc)), + "agent-inject 出现 SetCursorPos 调用 ⇒ 会抢用户鼠标", +); +check( + "★ 模式 A 不碰真实鼠标(agent-cursor 无 SetCursorPos 调用)", + !/SetCursorPos\(/.test(stripComments(cursorSrc)), + "agent-cursor 出现 SetCursorPos 调用 ⇒ 会抢用户鼠标", +); +check( + "★ koffi 指针参数用 Buffer(普通对象不写回)", + /Buffer\.alloc\(8\)/.test(injectSrc), + "指针参数未用 Buffer ⇒ WindowFromPoint 拿不到真实 hwnd,点击会落错窗口", +); +check( + "★ 注入模式默认 sendinput,且可切 overlay/real", + // overlay/real 只出现在条件判断里(不是赋值),不能要求 "cursorMode = \"overlay\"" + /let cursorMode = "sendinput"/.test(main) && + /_ac\.mode === "overlay" \|\| _ac\.mode === "real"\) cursorMode = _ac\.mode/.test(main), + "未提供 sendinput/overlay/real 三档切换", +); +check( + "★ 用户活动感知排除自身注入(ownTick/ownCursor)", + // GetLastInputInfo 是全局的,agent 自己的 SendInput 也会被算作「用户输入」。 + // 不排除则永远判定「用户正在操作」,所有命令被暂缓(实测踩到)。 + /ownTick/.test(injectSrc) && /ownCursor/.test(injectSrc), + "未排除自身注入 ⇒ 永远处于「用户正在操作」状态", +); +check( + "★ send() 成功后必须记录自身注入(否则 idleMs 归零)", + // 这个断言必须盯**调用点**而不是函数名:只检查 /markOwnInput/ 时, + // 把 send() 里的调用删掉仍会误报绿(已用品变异验证)。 + // function send(...) { ...; markOwnInput(); return true; } + /function send\(events\)[\s\S]{0,600}?markOwnInput\(\);[\s\S]{0,60}?return true;/.test( + injectSrc, + ), + "send() 内未调用 markOwnInput ⇒ 自己的注入会被误判为用户操作", +); +check( + "★ SendInput 绝对坐标按虚拟桌面归一化到 0–65535", + // 像素直接传给 SendInput 会落在错误位置(它要的是归一化值)。 + /65535/.test(injectSrc) && /VIRTUALDESK/.test(injectSrc), + "绝对坐标未归一化,或未带 VIRTUALDESK(多屏会错位)", +); +check( + "★ sendinput 分支结束后必须 return(防双执行)", + // runAction 里的 return 只退出它自己;少了外层 return 会 fall through + // 到旧 real 路径 —— 实测 8 条命令回了 16 个结果。 + /startDefer\(\(proceed\)[\s\S]{0,400}?\n\s+return;\n\s*\} catch \(e\) \{/.test(main), + "缺少外层 return,同一条命令会被执行两遍", +); +check( + "自绘光标层不吃点击(setIgnoreMouseEvents)", + /setIgnoreMouseEvents\(true/.test(cursorSrc), + "光标层会挡住用户点击", +); + +// ── 5) PiDeck 借鉴声明 ───────────────────────────────────────────── +check("★ app.js 有 PiDeck 借鉴说明", /PiDeck/.test(app), "app.js 缺 PiDeck 借鉴说明"); +check("★ style.css 有 PiDeck 借鉴说明", /PiDeck/.test(css), "style.css 缺 PiDeck 借鉴说明"); +check( + "★ 文档记录了 PiDeck 借鉴", + existsSync(join(here, "DESIGN-NOTES.md")), + "缺 cmd/gui/DESIGN-NOTES.md(借鉴来源与设计决策的落点)", +); +check("声明了三点渐次明暗动画", /haDots/.test(css) && /PiDeck/.test(css)); +check("声明了运行态呼吸光晕", /haRunBreathe/.test(css) && /msg-running/.test(css)); +check("声明了工具调用分组卡", /tool-group-card/.test(app) && /tool-group-card/.test(css)); +check( + "工具组卡有 PiDeck 出处注释", + /renderToolGroupOrSingle[\s\S]{0,400}PiDeck/.test(app) || + /tool-group-card[\s\S]{0,300}PiDeck/.test(app) || + /PiDeck[\s\S]{0,300}tool-group-card/.test(app), + "renderToolGroupOrSingle 上方应有 PiDeck 出处说明", +); + +console.log(""); +if (failures > 0) { + console.log(`全部失败:${failures} 条`); + process.exit(1); +} +console.log("全部通过"); diff --git a/cmd/gui/connections-io.test.mjs b/cmd/gui/connections-io.test.mjs new file mode 100644 index 00000000..56e220fd --- /dev/null +++ b/cmd/gui/connections-io.test.mjs @@ -0,0 +1,180 @@ +// connections.json 加载/保存的行为判据。 +// +// ## 要判的是什么 +// +// loadConnections() 的损坏恢复分支曾把「读到空/半截文件」当成 +// 「配置损坏」,于是**把文件改写成空配置**。而 saveConnections() 用 +// 非原子的 writeFileSync 截断重写,制造了产生半截内容的窗口。 +// 两者叠加 = 一次并发读就能让用户的连接列表永久消失。 +// +// 实测事故(2026-10-01 17:21):connections.json 变成 +// {"connections":[],"currentId":null},连接全丢。 +// +// ## 为什么用「跑真实函数」而不是检查源码文本 +// +// 判据在 node:vm 沙箱里抽出 main.js 的**真实函数**执行, +// 断言的是文件最终内容与返回值 —— 与线上走同一条代码。 +// +// 运行:node connections-io.test.mjs + +import { readFileSync, writeFileSync, existsSync, mkdtempSync, readdirSync, unlinkSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import vm from "node:vm"; + +const here = dirname(fileURLToPath(import.meta.url)); +const mainSrc = readFileSync(join(here, "main.js"), "utf8"); + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// 抽出真实实现(从 loadConnections 到 findHomed,含 normalize/save) +function extractImpl() { + const start = mainSrc.indexOf("function loadConnections() {"); + const end = mainSrc.indexOf("function findHomed() {"); + if (start < 0 || end < 0) throw new Error("无法从 main.js 抽出 load/save 实现"); + return mainSrc.slice(start, end); +} + +function makeSandbox(file) { + // 本地 stub:Node 的 renameSync 在同卷内会移动(源消失), +// 但为了让沙箱能在临时目录里跑,这里显式模拟真实语义。 +const realUnlink = (p) => { + try { + unlinkSync(p); + } catch (e) { + /* ignore */ + } +}; +const fsStub = { + existsSync: (p) => existsSync(p), + readFileSync: (p) => readFileSync(p, "utf8"), + writeFileSync: (p, c) => writeFileSync(p, c, "utf8"), + copyFileSync: (a, b) => writeFileSync(b, readFileSync(a)), + renameSync: (a, b) => { + writeFileSync(b, readFileSync(a)); + realUnlink(a); + }, + unlinkSync: realUnlink, + }; + const ctx = { + CONNECTIONS_FILE: file, + fs: fsStub, + console: { log() {}, error() {} }, + process: { pid: 1234 }, + __dirname: here, + Date, + JSON, + console: { log() {}, error() {}, warn() {} }, + }; + vm.createContext(ctx); + vm.runInContext(extractImpl(), ctx); + return ctx; +} + +function scenario(name, initialContent) { + const dir = mkdtempSync(join(tmpdir(), "conn-")); + const file = join(dir, "connections.json"); + if (initialContent !== null) writeFileSync(file, initialContent, "utf8"); + const ctx = makeSandbox(file); + const ret = vm.runInContext("loadConnections()", ctx); + return { file, ret, after: readFileSync(file, "utf8") }; +} + +const REAL = JSON.stringify({ + connections: [{ id: "abc", name: "local", url: "http://192.168.2.60:8080" }], + currentId: "abc", +}); + +// ── 1) 正常配置:原样返回,不改写文件 ───────────────────────────────── +{ + const r = scenario("normal", REAL); + check( + "合法 JSON 原样返回", + r.ret.connections.length === 1 && r.ret.connections[0].id === "abc", + JSON.stringify(r.ret).slice(0, 80), + ); + check("合法 JSON 不被改写", r.after === REAL, "文件内容被动了"); +} + +// ── 2) ★ 核心判据:空文件不得被改写成空配置 ────────────────────────── +{ + const r = scenario("empty", ""); + check( + "空文件:返回空配置", + r.ret.connections.length === 0 && r.ret.currentId === null, + JSON.stringify(r.ret).slice(0, 80), + ); + check( + "★ 空文件不被覆写(用户配置不被销毁)", + r.after === "", + "空文件被改写成:" + r.after.slice(0, 80), + ); +} + +// ── 3) 纯空白(BOM / 换行 / 空格)同样不得覆写 ──────────────────────── +{ + const r = scenario("whitespace", " \r\n "); + check( + "★ 纯空白不被覆写", + r.after === " \r\n ", + "被改写成:" + JSON.stringify(r.after).slice(0, 60), + ); +} + +// ── 4) 真损坏(半截 JSON):该备份重建,且备份带时间戳 ──────────────── +{ + const r = scenario("corrupt", '{"connections":[{"id":"abc"'); + const dir = r.file.replace(/connections\.json$/, ""); + const backups = existsSync(dir) ? readdirSync(dir).filter((f) => f.includes("corrupt")) : []; + check( + "损坏:备份文件已生成", + backups.length === 1, + "目录里没有 .corrupt-* 备份:" + JSON.stringify(backups), + ); + check( + "★ 备份带时间戳(反复损坏不覆盖唯一退路)", + backups.length === 1 && /corrupt-\d{4}-\d{2}-\d{2}T.*\.bak$/.test(backups[0]), + "备份名不是带时间戳的形式:" + (backups[0] || "(无)"), + ); + check( + "损坏:重建为空配置(应用不卡在坏状态)", + /"connections"\s*:\s*\[\s*\]/.test(r.after), + r.after.slice(0, 60), + ); +} + +// ── 5) saveConnections 必须是原子写(不留 .tmp 残留) ──────────────── +{ + const dir = mkdtempSync(join(tmpdir(), "conn-save-")); + const file = join(dir, "connections.json"); + const ctx = makeSandbox(file); + vm.runInContext("saveConnections(" + JSON.stringify({ connections: [{ id: "z" }], currentId: "z" }) + ")", ctx); + const left = readdirSync(dir); + check( + "saveConnections 写出了目标文件", + existsSync(file), + "文件不存在", + ); + check( + "★ saveConnections 不留 .tmp 残留", + !left.some((f) => f.includes(".tmp-")), + "残留:" + JSON.stringify(left), + ); + const back = JSON.parse(readFileSync(file, "utf8")); + check("saveConnections 内容正确", back.connections[0].id === "z", JSON.stringify(back)); +} + +console.log(""); +if (failures > 0) { + console.log(`全部失败:${failures} 条`); + process.exit(1); +} +console.log("全部通过"); \ No newline at end of file diff --git a/cmd/gui/doc.go b/cmd/gui/doc.go new file mode 100644 index 00000000..26c432e2 --- /dev/null +++ b/cmd/gui/doc.go @@ -0,0 +1,7 @@ +// Package gui 是 Electron 桌面外壳(Node/Electron 实现,无 Go 运行时代码)。 +// +// 这个包存在的原因不是功能,而是让 `go test ./...` / `go build ./...` 能 +// 统一遍历仓库:cmd/gui 下没有任何 Go 源文件时,go 工具链对它报 +// "no Go files"(exit 1),会被测试编排器(pi-lens test-runner 等)当成 +// 失败并反复重试。一个只有包注释的 doc.go 让该目录成为合法(空)包。 +package gui diff --git a/cmd/gui/endpoint-align.test.mjs b/cmd/gui/endpoint-align.test.mjs new file mode 100644 index 00000000..1fe923a1 --- /dev/null +++ b/cmd/gui/endpoint-align.test.mjs @@ -0,0 +1,232 @@ +// GUI ↔ WebUI 端点对齐门禁。 +// +// ## 要判的是什么 +// +// GUI(cmd/gui/renderer/app.js)与服务端 WebUI 插件(internal/plugins/webui) +// 是两个独立演进的客户端,它们共享同一套 REST 接口。这个共享关系**没有任何 +// 强制手段**:服务端加端点不必通知 GUI,GUI 改端点也不必通知服务端。 +// +// 真实漂移实例(2026-10-01 核实):服务端 handler.go 里有 39 个路径, +// GUI 只用 28 个。差集里最刺眼的是 `/memory/graph/pulse` —— +// 服务端**专门为星图「跟随 agent 动」**造这个端点,handler 注释写了动机 +// (全量图谱生产实例 408KB / 1151 节点,pulse 只有几 KB,差两个数量级), +// WebUI dashboard 接了它(dashboard.js 里 5 处),GUI 却只在初始化时拉一次 +// 全量且完全不轮询 ⇒ GUI 星图停在打开那一刻的快照。 +// +// 之前没人发现,是因为 protocol-align.test.mjs 是**真浏览器**判据 +// (要 Electron + Xvfb + 真后端),CI 明确不跑。 +// +// ## 为什么这个判据必须是「文本扫描」而不是运行时探测 +// +// 两个已知端点集合(GUI 的 api("...") 与服务端 mux.HandleFunc)都是**源码文本** +// 里的东西,扫源码就能拿到完整集合,不需要起服务、不需要鉴权。 +// 而运行时探测(真连一个后端)在这两个维度上不可用: +// - 要真后端 + 有效凭据(本机实测 admin/admin 已失效); +// - 只能看到「已部署版本」的端点,看不到源码里的 —— 而漂移恰恰 +// 发生在「源码已加、客户端还没接」的窗口里,那正是要抓的。 +// +// 与既有判据(sse-backoff / retry-guard)同一路子:从**真实源码**提取, +// 不抄一份逻辑重写(抄的那份会和真实代码漂移,而漂移正是本判据要防的)。 +// +// 运行:node endpoint-align.test.mjs + +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, "..", ".."); + +const APP = join(here, "renderer", "app.js"); +const HANDLER_GO = join(repoRoot, "internal", "plugins", "webui", "handler.go"); + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) { + console.log(` ✓ ${name}`); + } else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// ── 1) 读源码 ─────────────────────────────────────────────────────── + +if (!existsSync(HANDLER_GO)) { + console.error(`找不到服务端路由表:${HANDLER_GO}`); + process.exit(1); +} +const appSrc = readFileSync(APP, "utf8"); +const handlerSrc = readFileSync(HANDLER_GO, "utf8"); + +// ── 2) 抽端点集合 ─────────────────────────────────────────────────── + +// 服务端:mux.HandleFunc("...", ...) 里的第一个参数。 +// 只取 /api/v1 与 /v1 前缀的(/files/ /uploads/ /static/ / 是页面与静态资源, +// 不是 GUI 的 api() 目标;带上它们只会制造噪音)。 +function serverRoutes() { + const out = new Set(); + const re = /mux\.HandleFunc\(\s*"([^"]+)"/g; + let m; + while ((m = re.exec(handlerSrc)) !== null) { + const p = m[1]; + if (p.startsWith("/api/v1/") || p.startsWith("/v1/")) out.add(p); + } + return out; +} + +// GUI:api("...") 的第一个参数。带 query 串的按 '?' 截断 +// (如 "/chat/history?limit=" → "/chat/history")。 +// +// ★ 正则必须允许字符串后面跟表达式(+ 拼接):星图 pulse 写成 +// api("/memory/graph/pulse?since=" + since),只匹配到右引号为止会漏掉它 —— +// 而本判据的由来恰恰就是这个端点,用一个会漏掉它的正则去防它漏接是自相矛盾。 +// (?= 30, + `只抽到 ${routes.size} 条 —— 正则可能已与 handler.go 漂移`, +); +check( + "能抽到 GUI 调用", + calls.size >= 20, + `只抽到 ${calls.size} 条 —— 正则可能已与 app.js 漂移`, +); + +// ── 3) GUI 调用的每个端点都必须在服务端存在 ────────────────────────── +// +// 这是**硬门禁**:GUI 调了一个服务端没有的端点 ⇒ 运行时必然 404, +// 属于真缺陷。反向(服务端有、GUI 没接)是能力差距,按需评估。 + +const missing = [...calls].filter((p) => { + if (routes.has(p)) return false; + // 前缀通配:服务端有 "/api/v1/adapters/" 而 GUI 调 "/adapters" + // → api() 会拼成 /api/v1/adapters,靠 Go 1.22 ServeMux 最长前缀匹配命中。 + const withPrefix = "/api/v1" + p; + if (routes.has(withPrefix)) return false; + for (const r of routes) { + if (r.endsWith("/") && withPrefix.startsWith(r)) return false; + } + return true; +}); + +check( + "GUI 调用的端点服务端都存在", + missing.length === 0, + missing.length + ? "服务端无此路由(运行时必然 404):\n - " + missing.join("\n - ") + : "", +); + +// ── 4) 关键能力端点必须被 GUI 用上 ────────────────────────────────── +// +// 只判「服务端有 + GUI 必须有」的一小撮**能力面**端点,不是全部差集。 +// 理由:差集里的管理面端点(/login /logout /tracker/ /knowledge/tree/ +// /memory/text ...)该不该接取决于产品决策,逐条人工判;把这几类硬编码 +// 进判据会让人为了让门禁变绿而随手接端点。 +// +// 这里只钉**服务端已明确为其设计、且 GUI 已经在用同类能力**的端点: +// 星图活动(pulse)是本判据的由来——服务端专门造它、WebUI 用了、GUI 没接。 +// 若将来 GUI 确实不再需要星图,请连同下面这条判据一起删,并在注释里 +// 写明理由;不要静默留着一条红的门禁。 + +const mustUse = [ + { + path: "/api/v1/memory/graph/pulse", + why: "星图活动源:服务端为「不重复拉 408KB 全量」专门造的轻量端点", + }, +]; + +const unused = mustUse.filter((m) => { + // mustUse 写的是服务端全路径(/api/v1/…),GUI 侧 api() 传的是 + // 去掉 /api/v1 前缀的短路径(api() 内部会拼)。两边都比一次。 + const short = m.path.replace(/^\/api\/v1/, ""); + return !calls.has(m.path) && !calls.has(short); +}).map((m) => m.why); +check( + "能力面端点未被 GUI 漏接", + unused.length === 0, + unused.length ? unused.join(";") : "", +); + +// ── 5) 页面不得再引用公网 CDN ─────────────────────────────────────── +// +// 与服务端 starmap_vendor_test.go 同一判据(那里钉 WebUI,这里钉 GUI)。 +// HomeAgent 支持离线/内网部署,而前端有 4 个硬依赖在公网上时, +// 断网/出口受限环境下星图必坏、且用户无从修复。 + +const htmlSrc = readFileSync(join(here, "renderer", "index.html"), "utf8"); +const cdnRe = /(?:src|href)\s*=\s*["'](https?:\/\/[^"']+)["']/g; +const cdnUrls = [...htmlSrc.matchAll(cdnRe)].map((m) => m[1]); +check( + "index.html 不引用外部 CDN", + cdnUrls.length === 0, + cdnUrls.length ? "仍引用:" + cdnUrls.join(", ") : "", +); + +// 本地 vendor 文件必须真的存在(embed/打包漏文件 ⇒ 静默失效)。 +const vendorFiles = [ + "three.min.js", + "OrbitControls.js", + "marked.min.js", + "purify.min.js", +]; +const missingVendor = vendorFiles.filter( + (f) => !existsSync(join(here, "renderer", "vendor", f)), +); +check( + "vendor 第三方库文件都在", + missingVendor.length === 0, + missingVendor.length ? "缺少:" + missingVendor.join(", ") : "", +); + +// 两端 vendor 版本必须一致(否则星图/markdown 行为会在两端分叉)。 +const serverStatic = join(repoRoot, "internal", "plugins", "webui", "static"); +const drift = vendorFiles.filter((f) => { + const a = join(here, "renderer", "vendor", f); + const b = join(serverStatic, f); + if (!existsSync(b)) return true; + return readFileSync(a, "utf8") !== readFileSync(b, "utf8"); +}); +check( + "GUI 与 WebUI 的 vendor 版本一致", + drift.length === 0, + drift.length + ? "内容与 internal/plugins/webui/static 不一致:" + drift.join(", ") + : "", +); + +// ── 汇总 ──────────────────────────────────────────────────────────── + +console.log(""); +console.log(` 服务端路由 ${routes.size} 条 / GUI 调用 ${calls.size} 条`); +if (missing.length === 0) { + console.log(" 服务端有、GUI 未使用的端点(差集,供人工评估,非门禁):"); + const notUsed = [...routes].filter((r) => { + const short = r.replace(/^\/api\/v1\//, "/").replace(/^\/v1\//, "/"); + return !calls.has(r) && !calls.has(short); + }); + notUsed.slice(0, 14).forEach((p) => console.log(` · ${p}`)); + console.log(` (共 ${notUsed.length} 条未使用)`); +} +console.log(""); + +if (failures > 0) { + console.log(`全部失败:${failures} 条`); + process.exit(1); +} +console.log("全部通过"); \ No newline at end of file diff --git a/cmd/gui/loading-notify.test.mjs b/cmd/gui/loading-notify.test.mjs new file mode 100644 index 00000000..327f7abb --- /dev/null +++ b/cmd/gui/loading-notify.test.mjs @@ -0,0 +1,127 @@ +// 加载动画 + 系统通知的判据。 +// +// ## 要判的是什么 +// +// 1) 加载动画:旧实现是 border + rotate 的 spinner(.loading / +// .loading-spinner / .live-spinner 三处),在消息气泡里刺眼, +// 且"持续旋转"语义不对 —— 旋转暗示在加载某个确定的东西, +// 而 agent 思考本身没有进度。改为 PiDeck 用的「等距三点 + +// 透明度递减 + 轻微上浮」。 +// +// 2) 系统通知:**此前完全不存在**(主进程无 Notification、preload 无接口、 +// 渲染层只有页面内 toast)。这是新增能力,容易被后续改动静默删掉。 +// +// 运行:node loading-notify.test.mjs + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const css = readFileSync(join(here, "renderer", "style.css"), "utf8"); +const app = readFileSync(join(here, "renderer", "app.js"), "utf8"); +const main = readFileSync(join(here, "main.js"), "utf8"); +const preload = readFileSync(join(here, "preload.js"), "utf8"); + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// ── 1) 加载动画:三点而非旋转 ──────────────────────────────────────── +check("定义 haDots 关键帧", /@keyframes haDots/.test(css), "缺 @keyframes haDots"); +check("定义 haDotFloat 关键帧", /@keyframes haDotFloat/.test(css)); +check("定义 haSweep 关键帧", /@keyframes haSweep/.test(css)); + +// 三点的错峰 delay 是"追逐感"的来源,缺了就变成同步闪烁 +check( + "三点有错峰 delay(nth-child 2/3)", + /\.ha-dots\s*>?\s*i:nth-child\(2\)/.test(css) || + /\.ha-dots > i:nth-child\(2\)/.test(css), + "缺 nth-child delay ⇒ 三点同步闪,不像追逐", +); + +// 旧类名必须仍然可用(还有调用点用它们) +check( + ".loading 别名仍指向新动画", + /\.loading,\s*\.loading-spinner\s*{[^}]*animation:\s*none/.test(css), + "别名没解除旋转 ⇒ 旧调用点仍是转圈", +); +check( + "live-spinner 已是 flex 三点容器", + /\.msg-bubble \.live-spinner\s*{[^}]*display:\s*inline-flex/.test(css), + "live-spinner 仍是 inline-block 圆环", +); + +// 旧的旋转实现不应再被这三类使用 +const usesOldSpin = /\.loading[^{]*\{[^}]*border-top-color/.test(css); +check("没有残留的 border 旋转 spinner", !usesOldSpin, "仍有 border-top-color 旋转实现"); + +// ── 2) 调用点都换成了三点结构 ────────────────────────────────────── +const dotMarkup = (app.match(/class="ha-dots[^"]*"><\/i><\/i><\/i>/g) || []).length; +check( + "★ 面板/列表加载已换成三点结构", + dotMarkup >= 4, + `只找到 ${dotMarkup} 处 x3 结构(预期 >=4:星图 2 + 列表 3 + 其他)`, +); +check( + "★ 消息内 live-spinner 带三个点", + /class="live-spinner"><\/i><\/i><\/i>/.test(app), + "live-spinner 仍是空的(会看不到任何点)", +); +check( + "旧的空
已清理", + !/class="loading"><\/div>/.test(app), + "仍有裸
(无点)", +); + +// ── 3) 系统通知:主进程 ──────────────────────────────────────────── +check("main 引入 Notification", /new Notification\(/.test(main), "未调用原生 Notification"); +check("有 notify:show IPC", /ipcMain\.handle\("notify:show"/.test(main)); +check("有 notify:supported IPC", /ipcMain\.handle\("notify:supported"/.test(main)); +check( + "★ 前台聚焦时不打扰用户", + /isFocused\(\)/.test(main) && /skipped:\s*"focused"/.test(main), + "没有「窗口在前台就不弹」判定 ⇒ 正看着界面时会被弹窗打断", +); +check( + "★ 点击通知会回传并唤起窗口", + /notify:clicked/.test(main) && /mainWindow\.show\(\)/.test(main), + "点击通知后没有 show()+回传,点了等于没点", +); +check( + "正文超长截断(避免通知被撑爆)", + /body\.length\s*>\s*\d+/.test(main), + "未截断正文", +); + +// ── 4) preload 桥 ────────────────────────────────────────────────── +check("preload 暴露 notify.show", /notify:\s*{[\s\S]{0,200}show:/.test(preload)); +check("preload 暴露 notify.supported", /supported:\s*\(\)\s*=>\s*ipcRenderer\.invoke\("notify:supported"/.test(preload)); +check("preload 暴露 notify.onClicked", /onClicked:/.test(preload)); + +// ── 5) 渲染层 ────────────────────────────────────────────────────── +check("渲染层有 notifyTurnOnce 去重", /function notifyTurnOnce\(/.test(app), "缺去重 ⇒ 一轮会弹多次"); +check( + "去重基于 turnSig", + /turnSig/.test(app), + "去重没按轮次签名", +); +check( + "★ 通知挂在 SSE agent_output 上(覆盖旁观路径)", + /notifyTurnOnce\(/.test(app) && /starmapPulse\("output"[\s\S]{0,400}notifyTurnOnce/.test(app), + "没挂在 SSE 收尾处 ⇒ 旁观其他渠道的回复时不会通知", +); +check("注册了通知点击跳转", /onNotifyClicked/.test(app) && /onClicked\(onNotifyClicked\)/.test(app)); +check("启动时探测通知可用性", /notify\.supported\(\)/.test(app), "未探测 Notification.isSupported"); + +console.log(""); +if (failures > 0) { + console.log(`全部失败:${failures} 条`); + process.exit(1); +} +console.log("全部通过"); \ No newline at end of file diff --git a/cmd/gui/main.js b/cmd/gui/main.js index ee5729fd..7c98dee2 100644 --- a/cmd/gui/main.js +++ b/cmd/gui/main.js @@ -10,6 +10,8 @@ const { spawn } = require("child_process"); const http = require("http"); const crypto = require("crypto"); const { pathToFileURL } = require("url"); +const agentCursor = require("./agent-cursor"); +const agentInject = require("./agent-inject"); const CONNECTIONS_FILE = path.join(app.getPath("userData"), "connections.json"); const LOG_FILE = path.join(app.getPath("userData"), "gui.log"); @@ -209,6 +211,19 @@ function loadConnections() { const raw = fs .readFileSync(CONNECTIONS_FILE, "utf-8") .replace(/^\uFEFF/, ""); + // ★ 空/纯空白 = 「还没有配置」,不是「配置损坏」。 + // + // 原实现在这里直接 JSON.parse,空文件会抛错并掉进下面的 catch, + // 而 catch 会**把文件改写成空配置**。也就是说:只要读到一次空/半截 + // 内容(写入竞争、上次异常退出的残留、另一实例正在改写), + // 用户的真实连接列表就被永久覆盖成空 —— 且没有二次确认。 + // + // 实测(沙箱复现原函数):空文件 -> 返回空配置, + // **并把文件写成 {"connections":[],"currentId":null}**。 + if (raw.trim() === "") { + console.log("connections.json is empty; treating as uninitialized"); + return { connections: [], currentId: null }; + } const data = JSON.parse(raw); normalizeConnections(data); return data; @@ -217,7 +232,10 @@ function loadConnections() { console.error("Failed to load connections:", e); // 配置损坏:备份后重建,避免应用一直处于"无连接"状态 try { - const backup = CONNECTIONS_FILE + ".bak"; + // 带时间戳:原实现只写一个固定的 .bak,反复损坏会把上一份 + // 真实配置覆盖掉,于是「唯一的退路」也丢了。 + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = CONNECTIONS_FILE + ".corrupt-" + stamp + ".bak"; fs.copyFileSync(CONNECTIONS_FILE, backup); fs.writeFileSync( CONNECTIONS_FILE, @@ -255,10 +273,22 @@ function normalizeConnections(data) { } function saveConnections(data) { + // ★ 原子写:先写同目录临时文件,再 rename 覆盖。 + // + // 为什么必须:原实现直接 writeFileSync 截断重写,落地过程中存在 + // 「已截断、内容未写完」的窗口。若此刻另一个实例(或本实例另一次 + // loadConnections)读到那份半截内容,JSON.parse 抛错就走损坏分支 —— + // 于是「一次并发读」变成「配置被清空」。rename 在同一文件系统内 + // 是原子的:读者只会看到改写前或改写后的完整文件。 + const tmp = CONNECTIONS_FILE + ".tmp-" + process.pid; try { - fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2), "utf-8"); + fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8"); + fs.renameSync(tmp, CONNECTIONS_FILE); } catch (e) { console.error("Failed to save connections:", e); + try { + if (fs.existsSync(tmp)) fs.unlinkSync(tmp); + } catch (_) {} } } @@ -269,29 +299,83 @@ function findHomed() { return fs.existsSync(p) ? p : null; } -function isServerRunning() { +// ★ 探测服务端候选地址。 +// +// 旧实现硬编码 http://localhost:8080/,在「服务端跑在 WSL/另一台机器」 +// 的常见部署下必然探不通,进而误判服务未启动、去拉起 GUI 旁边并不存在的 +// homed.exe,最后打印 "homed failed to start within timeout"。 +// 实测证据:localhost:8080 返回 000,而 connections.json 里配置的 +// 192.168.2.60:8080 返回 302 —— 服务一直好好地在那儿。 +// +// 现在按「已配置的连接 → localhost」顺序探,命中任一即视为在线。 +function serverProbeTargets() { + const targets = []; + const seen = new Set(); + const push = (u) => { + if (!u) return; + const s = String(u).replace(/\/+$/, ""); + if (!/^https?:\/\//.test(s)) return; + if (seen.has(s)) return; + seen.add(s); + targets.push(s); + }; + try { + const data = loadConnections(); + // loadConnections 返回 {connections:[...], currentId}, + // 不是数组(早期按数组写的判断在此处不成立)。 + const list = data && Array.isArray(data.connections) ? data.connections : []; + // 当前连接优先,其余也一并探测(用户可能切过连接) + const curId = data ? data.currentId : null; + const ordered = list + .slice() + .sort((a, b) => (a && b && a.id === curId ? -1 : 0)); + for (const c of ordered) if (c && c.url) push(c.url); + } catch (e) {} + push("http://localhost:8080"); + return targets; +} + +function probeOne(target, timeoutMs) { return new Promise((resolve) => { - const req = http.get("http://localhost:8080/", () => resolve(true)); - req.on("error", () => resolve(false)); - req.setTimeout(2000, () => { - req.destroy(); - resolve(false); + let done = false; + const finish = (ok, via) => { + if (done) return; + done = true; + resolve({ ok, via }); + }; + let req; + try { + req = http.get(target + "/", () => finish(true, target)); + } catch (e) { + return finish(false, target); + } + req.on("error", () => finish(false, target)); + req.setTimeout(timeoutMs || 2000, () => { + try { + req.destroy(); + } catch (e) {} + finish(false, target); }); }); } -function waitForServer(maxWait = 8000) { - return new Promise((resolve) => { - const start = Date.now(); - const check = () => { - isServerRunning().then((running) => { - if (running) return resolve(true); - if (Date.now() - start > maxWait) return resolve(false); - setTimeout(check, 300); - }); - }; - check(); - }); +// 任一候选端点可达即认为服务在线。 +async function isServerRunning() { + const targets = serverProbeTargets(); + for (const t of targets) { + const r = await probeOne(t, 1500); + if (r.ok) return true; + } + return false; +} + +async function waitForServer(maxWait = 8000) { + const start = Date.now(); + for (;;) { + if (await isServerRunning()) return true; + if (Date.now() - start > maxWait) return false; + await new Promise((r) => setTimeout(r, 300)); + } } function startHomed() { @@ -324,7 +408,20 @@ function stopHomed() { } } -function createWindow() { +// createWindow 创建主窗口。 +// +// ★ show=false = **静默启动**:窗口照常创建(渲染进程随之启动,SSE、 +// 设备桥、聊天全部可用),只是不显示。 +// +// 为什么不是「不创建窗口」:不创建 ⇒ 渲染进程不启动 ⇒ app.js 整个不执行 +// ⇒ SSE 通道与设备桥轮询全废,GUI 退化成纯托盘图标;而且 +// showMainWindow() 只做 show()、不会创建窗口,app.on("activate") 又只在 +// macOS 触发 —— Windows 上托盘菜单点「显示主界面」将**毫无反应**, +// 进程变成无法唤起的僵尸。 +// +// 为什么不用 show:false 再 show():那会先显示一帧再隐藏,肉眼可见闪一下。 +// 这里用 ready-to-show + 条件 show,避免闪烁。 +function createWindow(show = true) { const menu = Menu.buildFromTemplate([]); Menu.setApplicationMenu(menu); @@ -335,6 +432,7 @@ function createWindow() { minHeight: 600, title: "HomeAgent", frame: false, + show: false, // 一律先不显示,由 ready-to-show 决定 icon: path.join(__dirname, "icon.ico"), webPreferences: { preload: path.join(__dirname, "preload.js"), @@ -343,6 +441,15 @@ function createWindow() { }, }); + // 静默时不显示;非静默时在首帧就绪后显示,避免白屏闪现。 + if (show) { + mainWindow.once("ready-to-show", () => { + try { + mainWindow.show(); + } catch (e) {} + }); + } + mainWindow.loadFile(path.join(__dirname, "renderer", "index.html")); if (process.argv.includes("--dev")) { @@ -1316,6 +1423,15 @@ function sendCmdResult(a, b) { } } +// 安全设置自绘光标的「忙碌」呼吸态(agentCursor 可能未就绪) +function agentCursorBusySafe(dispIdx, busy) { + try { + if (agentCursor && typeof agentCursor.agentCursorBusy === "function") { + agentCursor.agentCursorBusy(busy, dispIdx); + } + } catch (e) {} +} + function baseResult(reqId, status, output, error) { return { op: "cmd_result", @@ -1840,6 +1956,40 @@ function executeHomeagentCmd(capability, reqId) { params.action = t[2] || "click"; } const action = params.action || "click"; + + // ★ 注入模式:sendinput(默认)/ overlay / real + // + // sendinput SendInput 注入到系统输入队列。等价真实硬件事件, + // Chromium/Electron/游戏等自绘界面都能接收 —— 可靠。 + // 代价:会移动用户的真实光标,故操作前先等用户停手。 + // 参考 Pal-AI-Lab/Coopanion 的 cortico-world-cua。 + // overlay PostMessage 直投窗口句柄。用户鼠标纹丝不动,但自绘 + // 界面常忽略合成消息 ⇒ 只能操作原生 Win32 程序。 + // real 同上但用旧代码路径(保留以兼容)。 + // + // 默认 sendinput:用户要求「可靠 + 用户正在操作则暂缓」。 + let cursorMode = "sendinput"; + try { + const _p = loadGuiPrefs(); + const _ac = (_p && _p.deviceBridge && _p.deviceBridge.agentCursor) || {}; + if (_ac.mode === "overlay" || _ac.mode === "real") cursorMode = _ac.mode; + } catch (e) {} + const injOk = agentInject.available(); + const useOverlay = cursorMode === "overlay" && injOk; + const useSendInput = cursorMode === "sendinput" && injOk; + // 用户活动暂缓:等用户停手再注入(毫秒)。0 = 不等待。 + let deferMs = 3000; + try { + const _p2 = loadGuiPrefs(); + const _ac2 = (_p2 && _p2.deviceBridge && _p2.deviceBridge.agentCursor) || {}; + if (typeof _ac2.deferMs === "number") deferMs = Math.max(0, Math.min(10000, _ac2.deferMs)); + } catch (e) {} + + // 把内部参数提到 action 分支之前共用 + const cuNumber = (v, dflt) => { + const n = parseFloat(v); + return Number.isFinite(n) ? n : dflt; + }; // 跨平台输入模拟:Linux=xdotool / macOS=cliclick(或用osascript) / Windows=PowerShell user32 const os_ = platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux"; let tool = null; // {cmd, args, shell} @@ -1867,8 +2017,309 @@ function executeHomeagentCmd(capability, reqId) { const disp = displays[dispIdx]; const ox = disp ? (disp.bounds.x || 0) : 0; const oy = disp ? (disp.bounds.y || 0) : 0; - const absX = Math.round(ox + (parseFloat(params.x) || 0)); - const absY = Math.round(oy + (parseFloat(params.y) || 0)); + + // ★ 坐标空间:Win32 坐标与截图一样,都是**物理像素**,不要换算。 + // + // 实测(Electron 主进程内,cmd/gui/probe2): + // screen API (DIP) : 1260 x 840 scaleFactor=2 + // GetSystemMetrics : 2520 x 1680 ← Win32 用的是物理像素 + // SetCursorPos(1260,840) -> GetCursorPos 读回 (1260,840) + // 而 Electron 读回 DIP (630,420) + // 比值 = 2.000 + // + // 原因是 Electron 主进程默认 per-monitor DPI-aware:这种感知下 + // Win32 坐标 API 不做虚拟化,直接就是物理像素。 + // + // ⚠ 本仓曾在这一段加过 `/ scaleFactor` 换算,理由是「SetCursorPos + // 期望 DIP」——那个前提是错的(那是 DPI-*unaware* 进程的行为)。 + // 加了之后反而把原本正确的点击改坏:150% 缩放屏上点 (600,400) + // 会被送到 (400,267),越靠右下偏得越远。现已改回直接使用。 + // + // 保留 physical 开关:多数截图工具给物理像素(默认),若调用方 + // 明确声明坐标已是 DIP(physical:false),再乘回 scaleFactor。 + const scale = disp && disp.scaleFactor ? disp.scaleFactor : 1; + const rawX = parseFloat(params.x) || 0; + const rawY = parseFloat(params.y) || 0; + const physical = params.physical !== false; + const absX = Math.round(ox + (physical ? rawX : rawX * scale)); + const absY = Math.round(oy + (physical ? rawY : rawY * scale)); + + // === 模式 sendinput:SendInput 注入 + 用户在操作则暂缓 === + // + // SendInput 会动真实光标,因此注入前先等用户停手(deferMs 上限)。 + // 等不到就跳过本次操作并明确报错 —— 宁可失败,也不打断用户。 + if (os_ === "win32" && useSendInput) { + try { + const busy = () => agentCursorBusySafe(dispIdx, true); + const idle = () => agentCursorBusySafe(dispIdx, false); + const fin = (ok, msg) => { + idle(); + sendCmdResult(reqId, baseResult(reqId, ok ? "ok" : "error", msg, ok ? "" : msg)); + }; + const fail = (m) => fin(false, m); + + // 等待用户停手 + // + // 注意:executeHomeagentCmd 是**同步函数**,不能 await。 + // 故改为「同步查询 + 轮询回调」:先看用户是否在操作;若是, + // 定时轮询到空闲或超时再继续;全程不阻塞主进程。 + const startDefer = (cb) => { + if (deferMs <= 0 || !agentInject.userIsActive(1200)) return cb(true); + const before = agentInject.idleMs(); + const t0 = Date.now(); + const tick = () => { + if (!agentInject.userIsActive(800)) return cb(true); + if (Date.now() - t0 >= deferMs) { + idle(); + sendCmdResult( + reqId, + baseResult( + reqId, + "error", + "", + "用户正在操作(已等待 " + deferMs + "ms,开始时空闲仅 " + before + + "ms)。为避免抢走光标已跳过本次操作。可在设置里调整 agentCursor.deferMs。", + ), + ); + return cb(false); + } + setTimeout(tick, 120); + }; + tick(); + }; + + const runAction = () => { + const btnName = params.button === "right" ? "right" : params.button === "middle" ? "middle" : "left"; + const toX = () => Math.round(ox + (physical ? (parseFloat(params.tox || params.tx) || 0) : (parseFloat(params.tox || params.tx) || 0) * scale)); + const toY = () => Math.round(oy + (physical ? (parseFloat(params.toy || params.ty) || 0) : (parseFloat(params.toy || params.ty) || 0) * scale)); + + // 自绘光标同步到目标位置(视觉指示 agent 在做什么) + const needsXY = ["click", "doubleclick", "rightclick", "middleclick", "tripleclick", + "mousedown", "mouseup", "move", "hover", "drag", "scroll"].includes(action); + if (needsXY) agentCursor.agentCursorMove(absX, absY, dispIdx); + busy(); + + switch (action) { + case "move": + case "hover": { + const r = agentInject.si.move(absX, absY); + return fin(r.ok, "sendinput " + action + " @ (" + absX + "," + absY + ")"); + } + case "click": + case "rightclick": + case "middleclick": + case "doubleclick": + case "tripleclick": + case "mousedown": + case "mouseup": { + const btn = action === "rightclick" ? "right" : action === "middleclick" ? "middle" : btnName; + const act = action === "rightclick" || action === "middleclick" ? "click" : action; + const r = agentInject.si.button(absX, absY, act, btn); + if (act === "click" || act === "doubleclick" || act === "tripleclick") { + agentCursor.agentCursorClick(dispIdx); + } + return fin(r.ok, "sendinput " + action + " @ (" + absX + "," + absY + ")"); + } + case "drag": { + const steps = Math.max(1, Math.min(60, parseInt(params.steps || 12, 10) || 12)); + const r = agentInject.si.drag(absX, absY, toX(), toY(), btnName, steps); + agentCursor.agentCursorMove(toX(), toY(), dispIdx); + agentCursor.agentCursorClick(dispIdx); + return fin(r.ok, "sendinput drag (" + absX + "," + absY + ") -> (" + toX() + "," + toY() + ")"); + } + case "scroll": { + const dy = cuNumber(params.dy !== undefined ? params.dy : params.y, 0); + const dx = cuNumber(params.dx !== undefined ? params.dx : params.x, 0); + // GUI 侧约定:dy 正=向上。SendInput 的 WHEEL 正值即向上,直接传。 + const r = agentInject.si.scroll(dy, dx); + return fin(r.ok, "sendinput scroll dx=" + dx + " dy=" + dy); + } + case "keypress": + case "hotkey": + case "combo": { + const keys = String(params.keys || params.key || params.text || ""); + const r = agentInject.si.key(keys); + return fin(r.ok, "sendinput keypress " + keys); + } + case "type": { + const r = agentInject.si.type(String(params.text || "")); + return fin(r.ok, "sendinput typed " + r.chars + " chars"); + } + case "wait": + case "sleep": { + const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500)); + idle(); + setTimeout(() => sendCmdResult(reqId, baseResult(reqId, "ok", "sendinput waited " + ms + "ms", "")), ms); + return; + } + case "display": { + const info = displays.map((d, i) => ({ + index: i, bounds: d.bounds, scaleFactor: d.scaleFactor, + primary: d.id === screen.getPrimaryDisplay().id, + })); + const v = agentInject.virtualScreen(); + idle(); + sendCmdResult(reqId, baseResult(reqId, "ok", + "displays: " + JSON.stringify(info) + + " | virtualScreen: " + JSON.stringify(v) + + " | inputMode: sendinput | coordinateSpace: physical-pixel", "")); + return; + } + default: + return fail("unknown action " + action); + } + }; + startDefer((proceed) => { if (proceed) runAction(); }); + // ★ 必须在这里 return。 + // + // runAction 里的 `return fin(...)` 只退出 runAction,不会退出 + // executeHomeagentCmd。少了这句,执行完 sendinput 后会继续往下 + // fall through 到旧的 real 鼠标路径 —— 同一条命令被彻底执行两遍 + // (实测:8 条命令回了 16 个 cmd_result,且真光标被额外抢一次)。 + return; + } catch (e) { + try { agentCursorBusySafe(dispIdx, false); } catch (e2) {} + console.error("[sendinput] failed: " + e.message); + sendCmdResult(reqId, baseResult(reqId, "error", "", "sendinput: " + e.message)); + return; + } + } + + // === 模式 A:后台注入(PostMessage 到目标窗口),不动用户真实鼠标 === + if (os_ === "win32" && useOverlay) { + const btnName = params.button === "right" ? "right" : params.button === "middle" ? "middle" : "left"; + const beginBusy = () => agentCursor.agentCursorBusy(true, dispIdx); + const endBusy = () => agentCursor.agentCursorBusy(false, dispIdx); + const done = (ok, msg) => { + endBusy(); + sendCmdResult(reqId, baseResult(reqId, ok ? "ok" : "error", msg, ok ? "" : msg)); + }; + const fail = (msg) => done(false, "agent-overlay: " + msg); + // 位置类 action 之外(type/keypress 等)不需要先移动光标 + const needsXY = ["click", "doubleclick", "rightclick", "middleclick", "tripleclick", + "mousedown", "mouseup", "move", "hover", "drag", "scroll"].includes(action); + try { + if (needsXY) { + const h = agentInject.windowAtPoint(absX, absY); + if (!h) { + return fail( + "该坐标下没有窗口(WindowFromPoint 返回空)。" + + "后台注入只能作用于可见窗口,请确认 screensueDisplay 与截图用的屏一致。", + ); + } + // 自绘光标移到目标位置(不动真实鼠标) + agentCursor.agentCursorMove(absX, absY, dispIdx); + } + beginBusy(); + switch (action) { + case "move": + case "hover": { + const r = agentInject.mouse(absX, absY, "move", { button: btnName }); + agentCursor.setLastHwnd(r.hwnd); + return done(r.ok, "agent-overlay " + action + " @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "click": { + const r = agentInject.mouse(absX, absY, "click", { button: btnName }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay click @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "rightclick": { + const r = agentInject.mouse(absX, absY, "click", { button: "right" }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay rightclick @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "middleclick": { + const r = agentInject.mouse(absX, absY, "click", { button: "middle" }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay middleclick @ (" + absX + "," + absY + ") hwnd=" + agentInject.hwndStr(r.hwnd)); + } + case "doubleclick": { + const r = agentInject.mouse(absX, absY, "doubleclick", { button: btnName }); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay doubleclick @ (" + absX + "," + absY + ")"); + } + case "tripleclick": { + const r = agentInject.tripleClick(absX, absY); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay tripleclick @ (" + absX + "," + absY + ")"); + } + case "mousedown": { + const r = agentInject.mouse(absX, absY, "mousedown", { button: btnName }); + return done(r.ok, "agent-overlay mousedown(" + btnName + ")"); + } + case "mouseup": { + const r = agentInject.mouse(absX, absY, "mouseup", { button: btnName }); + return done(r.ok, "agent-overlay mouseup(" + btnName + ")"); + } + case "drag": { + const toX = Math.round(ox + (physical ? (parseFloat(params.tox || params.tx) || 0) : (parseFloat(params.tox || params.tx) || 0) * scale)); + const toY = Math.round(oy + (physical ? (parseFloat(params.toy || params.ty) || 0) : (parseFloat(params.toy || params.ty) || 0) * scale)); + agentCursor.agentCursorMove(absX, absY, dispIdx); + const steps = Math.max(1, Math.min(60, parseInt(params.steps || 12, 10) || 12)); + // 拖拽过程中让自绘光标跟着走,视觉上与真实拖拽一致 + const r = agentInject.drag(absX, absY, toX, toY, { button: btnName, steps }); + agentCursor.setLastHwnd(r.hwnd); + agentCursor.agentCursorMove(toX, toY, dispIdx); + agentCursor.agentCursorClick(dispIdx); + return done(r.ok, "agent-overlay drag (" + absX + "," + absY + ") -> (" + toX + "," + toY + ")"); + } + case "scroll": { + const dy = cuNumber(params.dy !== undefined ? params.dy : params.y, 0); + const dx = cuNumber(params.dx !== undefined ? params.dx : params.x, 0); + const r = agentInject.scroll(absX, absY, dy, dx); + return done(r.ok, "agent-overlay scroll dx=" + dx + " dy=" + dy); + } + case "keypress": + case "hotkey": + case "combo": { + const keys = String(params.keys || params.key || params.text || ""); + // 无坐标语义:发给「当前光标所在窗口」,即上次 agent 操作过的窗口 + const h = agentInject.windowAtPoint(absX, absY) || agentCursor.lastHwnd; + if (!h) { + return fail("keypress 需要先有点击类操作确定目标窗口(当前无法确定目标窗口)"); + } + const r = agentInject.keyTap(h, keys); + return done(r.ok, "agent-overlay keypress " + keys + " -> hwnd=" + agentInject.hwndStr(h)); + } + case "type": { + const t = String(params.text || ""); + const h = agentInject.windowAtPoint(absX, absY) || agentCursor.lastHwnd; + if (!h) return fail("type 需要先有点击类操作确定目标窗口"); + const r = agentInject.typeText(h, t); + return done(r.ok, "agent-overlay typed " + r.chars + " chars -> hwnd=" + agentInject.hwndStr(h)); + } + case "wait": + case "sleep": { + const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500)); + endBusy(); + setTimeout(() => sendCmdResult(reqId, baseResult(reqId, "ok", "agent-overlay waited " + ms + "ms", "")), ms); + return; + } + case "display": { + const info = displays.map((d, i) => ({ + index: i, bounds: d.bounds, scaleFactor: d.scaleFactor, + primary: d.id === screen.getPrimaryDisplay().id, + })); + endBusy(); + sendCmdResult(reqId, baseResult(reqId, "ok", + "displays: " + JSON.stringify(info) + + " | coordinateSpace: physical-pixel (Win32 与截图像素一致, 无需换算)", "")); + return; + } + default: + return fail("unknown action " + action); + } + } catch (e) { + endBusy(); + console.error("[agent-overlay] failed: " + e.message); + sendCmdResult(reqId, baseResult(reqId, "error", "", "agent-overlay: " + e.message)); + return; + } + } // === Windows: 用 koffi 直接调用 user32.dll,不依赖 PowerShell C# 编译 === if (os_ === "win32") { @@ -1878,8 +2329,69 @@ function executeHomeagentCmd(capability, reqId) { const SetCursorPos = user32.func("bool SetCursorPos(int x, int y)"); const mouse_event = user32.func("void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, uint dwExtraInfo)"); - const btnDown = params.button === "right" ? 0x0008 : params.button === "middle" ? 0x0020 : 0x0002; - const btnUp = params.button === "right" ? 0x0010 : params.button === "middle" ? 0x0040 : 0x0004; + const BTN = { left: [0x0002, 0x0004], middle: [0x0020, 0x0040], right: [0x0008, 0x0010] }; + const btn = params.button === "right" ? "right" : params.button === "middle" ? "middle" : "left"; + const btnDown = BTN[btn][0]; + const btnUp = BTN[btn][1]; + // MOUSEEVENTF_WHEEL=0x0800, HWHEEL=0x0100;WHEEL_DELTA=120 + const WHEEL = 0x0800; + const HWHEEL = 0x0100; + // 修饰键虚拟键码(VK_*) + const VK = { + ctrl: 0x11, control: 0x11, alt: 0x12, shift: 0x10, win: 0x5b, meta: 0x5b, super: 0x5b, + enter: 0x0d, return: 0x0d, tab: 0x09, esc: 0x1b, escape: 0x1b, + space: 0x20, backspace: 0x08, delete: 0x2e, del: 0x2e, + up: 0x26, down: 0x28, left: 0x25, right: 0x27, + home: 0x24, end: 0x23, pageup: 0x21, pagedown: 0x22, + }; + // 修饰键的 keybd_event 标志(EXTENDEDKEY 表示右侧/小键盘扩展键) + const VK_EXTENDED = new Set([0x21,0x22,0x23,0x24,0x25,0x26,0x27,0x28,0x2e,0x5b,0x5c,0x6f,0x74]); + + // keybd_event 全局声明(拿得到 user32 后用) + // + // ★ 类型名必须是 uint8:koffi 不认 Win32 头文件里的 `byte`, + // 实测 `void keybd_event(byte bVk, ...)` 抛 + // "Unknown or invalid type name 'byte'",而 uint8 可正常解析。 + // 这个 bug 曾让组合键(hotkey/keypress)整体报 + // "keybd_event unavailable" —— 单键 click 不受影响,所以很容易漏掉。 + let keybd_event = null; + try { + keybd_event = user32.func( + "void keybd_event(uint8 bVk, uint8 bScan, uint32 dwFlags, uintptr_t dwExtraInfo)", + ); + } catch (e) { + console.error("keybd_event signature failed: " + e.message); + } + + // tapVk 按键一次:支持 "ctrl+c"、"ctrl+shift+t"、"alt+f4" + const tapVk = (spec) => { + if (!keybd_event) return "keybd_event unavailable"; + const parts = String(spec || "").split("+").map((x) => x.trim().toLowerCase()).filter(Boolean); + if (parts.length === 0) return "empty key"; + const last = parts[parts.length - 1]; + const mods = parts.slice(0, -1); + const modCodes = mods.map((m) => VK[m]).filter((c) => c !== undefined); + if (modCodes.length !== mods.length) return "unknown modifier in " + spec; + let keyCode = VK[last]; + if (keyCode === undefined) { + // 单字符:字母/数字 → ASCII 大写 + if (last.length === 1) keyCode = last.toUpperCase().charCodeAt(0); + else return "unknown key: " + last; + } + for (const c of modCodes) keybd_event(c, 0, 0, 0); + keybd_event(keyCode, 0, VK_EXTENDED.has(keyCode) ? 1 : 0, 0); + keybd_event(keyCode, 0, (VK_EXTENDED.has(keyCode) ? 1 : 0) | 0x0002, 0); + for (const c of modCodes.slice().reverse()) keybd_event(c, 0, 0x0002, 0); + return null; + }; + + // 滚动:dy 纵向、dx 横向,正数向上 + const doScroll = () => { + const dy = Number(params.dy !== undefined ? params.dy : params.y) || 0; + const dx = Number(params.dx !== undefined ? params.dx : params.x) || 0; + if (dx) mouse_event(HWHEEL, 0, 0, -Math.round(dx * 120), 0); + if (dy) mouse_event(WHEEL, 0, 0, Math.round(dy * 120), 0); + }; // 先移动鼠标到目标位置 SetCursorPos(absX, absY); @@ -1907,10 +2419,142 @@ function executeHomeagentCmd(capability, reqId) { mouse_event(0x0010, 0, 0, 0, 0); sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse rightclick @ (" + absX + "," + absY + ")", "")); break; - case "scroll": - mouse_event(0x0800, 0, 0, Math.round((params.dy || 120) * 120), 0); - sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse scroll @ (" + absX + "," + absY + ")", "")); + case "scroll": { + // 旧实现固定向上滚 120*dy,且不支持横向。 + // 现在按参数给方向/量级,并支持 dx(横向滚动)。 + doScroll(); + sendCmdResult( + reqId, + baseResult( + reqId, + "ok", + "computeruse scroll dx=" + (params.dx || 0) + " dy=" + (params.dy || params.y || 0) + + " @ (" + absX + "," + absY + ")", + "", + ), + ); break; + } + // === 以下为补齐的高级操作 === + case "mousedown": + // 按下不释放:用于与 mouseup 配对做"按住拖拽" + mouse_event(btnDown, 0, 0, 0, 0); + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse mousedown(" + btn + ")", "")); + break; + case "mouseup": + mouse_event(btnUp, 0, 0, 0, 0); + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse mouseup(" + btn + ")", "")); + break; + case "drag": { + // 一步拖拽:从 (x,y) 按下 → 移动到 (tox,toy) → 释放 + const toX = Math.round( + ox + (physical ? (parseFloat(params.tox || params.tx) || 0) : (parseFloat(params.tox || params.tx) || 0) * scale), + ); + const toY = Math.round( + oy + (physical ? (parseFloat(params.toy || params.ty) || 0) : (parseFloat(params.toy || params.ty) || 0) * scale), + ); + SetCursorPos(absX, absY); + mouse_event(btnDown, 0, 0, 0, 0); + // 分步移动:部分应用(浏览器 canvas、拖拽排序)需要中间 move 事件 + const steps = Math.max(1, Math.min(40, parseInt(params.steps || 12, 10) || 12)); + for (let i = 1; i <= steps; i++) { + const t = i / steps; + SetCursorPos( + Math.round(absX + (toX - absX) * t), + Math.round(absY + (toY - absY) * t), + ); + } + mouse_event(btnUp, 0, 0, 0, 0); + sendCmdResult( + reqId, + baseResult(reqId, "ok", "computeruse drag (" + absX + "," + absY + ") -> (" + toX + "," + toY + ")", ""), + ); + break; + } + case "hover": + // 悬停:只移动不点击(触发 tooltip / hover 菜单) + SetCursorPos(absX, absY); + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse hover @ (" + absX + "," + absY + ")", "")); + break; + case "keypress": { + // 支持 "ctrl+c" 这类组合键;旧实现只发单键 + const err2 = tapVk(params.key || params.keys || params.text); + if (err2) { + sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse keypress: " + err2)); + } else { + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse keypress " + (params.key || params.keys || params.text), "")); + } + break; + } + case "hotkey": + case "combo": { + const keys = String(params.keys || params.key || ""); + const err2 = tapVk(keys); + if (err2) { + sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse hotkey: " + err2)); + } else { + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse hotkey " + keys, "")); + } + break; + } + case "middleclick": + mouse_event(BTN.middle[0], 0, 0, 0, 0); + mouse_event(BTN.middle[1], 0, 0, 0, 0); + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse middleclick @ (" + absX + "," + absY + ")", "")); + break; + case "tripleclick": + // 三击:三次快速左键,用于选中整行 + for (let i = 0; i < 3; i++) { + mouse_event(BTN.left[0], 0, 0, 0, 0); + mouse_event(BTN.left[1], 0, 0, 0, 0); + } + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse tripleclick @ (" + absX + "," + absY + ")", "")); + break; + case "type": { + // 用 SendKeys 注入文本(koffi 只能发按键,文本需走 SendInput) + const t = String(params.text || ""); + if (!t) { + sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse type: empty text")); + break; + } + // 逐字符发送:SendKeys 语法里 + ^ % ~ { } 都是特殊字符 + const SPECIAL = { "+": "{+}", "^": "{^}", "%": "{%}", "~": "{~}", "{": "{{}", "}": "{}}", "(": "{(}", ")": "{)}", "[": "{[}", "]": "{]}" }; + let expr = ""; + for (const ch of t) expr += SPECIAL[ch] !== undefined ? SPECIAL[ch] : ch; + const ps = + "Add-Type -AssemblyName System.Windows.Forms;" + + "[System.Windows.Forms.SendKeys]::SendWait('" + + expr.replace(/'/g, "''") + + "')"; + cp.exec("powershell", ["-NoProfile", "-Command", ps], { timeout: 15000 }, (err) => { + if (err) { + sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse type failed: " + err.message)); + } else { + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse typed " + t.length + " chars", "")); + } + }); + break; + } + case "wait": + case "sleep": { + // 显式等待:UI 更新/动画未完成时先等一下再下一步 + const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500)); + setTimeout(() => { + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse waited " + ms + "ms", "")); + }, ms); + break; + } + case "display": { + // 返回各显示器信息:模型需要知道有几个屏、逻辑尺寸与缩放 + const info = displays.map((d, i) => ({ + index: i, + bounds: d.bounds, + scaleFactor: d.scaleFactor, + primary: d.id === screen.getPrimaryDisplay().id, + })); + sendCmdResult(reqId, baseResult(reqId, "ok", "displays: " + JSON.stringify(info), "")); + break; + } default: sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse: unknown action " + action)); } @@ -1930,6 +2574,12 @@ function executeHomeagentCmd(capability, reqId) { case "doubleclick": return ["dc:" + pos]; case "rightclick": return ["c:" + pos]; case "scroll": return ["w:" + (params.dy > 0 ? "+" : "-")]; + case "hover": return ["m:" + pos]; + case "middleclick": return ["dc:" + pos]; + // cliclick 原生支持 dd(按下拖拽)/ du(释放)/ dm(拖到) + case "mousedown": return ["dd:" + pos]; + case "mouseup": return ["du:" + pos]; + case "drag": return ["dm:" + (absX) + "," + (absY) + "," + (ox + (parseFloat(params.tox) || 0) / scale) + "," + (oy + (parseFloat(params.toy) || 0) / scale)]; default: return a; } } @@ -1954,8 +2604,82 @@ function executeHomeagentCmd(capability, reqId) { run(L(["mousemove", String(absX), String(absY), "scroll", "--button", "5", String(Math.round(params.dy || params.y || 0))])); return; case "keypress": - run(L(["key", String(params.key || params.text || "")])); + case "hotkey": + case "combo": { + // xdotool/ cliclick 的 key 参数本身就接受 "ctrl+c" 这类组合键, + // 但旧代码把整个字符串原样透传,模型传 "Ctrl+C"(大写/带引号)就失效。 + // 这里做一次归一化。 + let spec = String(params.key || params.keys || params.text || "").trim(); + spec = spec + .replace(/\+/g, " plus ") + .replace(/ctrl\s*\+\s*ctrl\s*\+/gi, "ctrl+") + .split(/\s*\+\s*/) + .filter(Boolean) + .map((x) => x.toLowerCase().trim()) + .join("+"); + if (!spec) { + sendCmdResult(reqId, baseResult(reqId, "error", "", "computeruse keypress: empty key")); + return; + } + run(L(["key", spec])); return; + } + case "mousedown": + run(L([os_ === "darwin" ? "mousedown" : "mousedown", String(absX), String(absY)])); + return; + case "mouseup": + run(L([os_ === "darwin" ? "mouseup" : "mouseup", String(absX), String(absY)])); + return; + case "drag": { + const toX = Math.round(ox + (parseFloat(params.tox) || 0) / scale); + const toY = Math.round(oy + (parseFloat(params.toy) || 0) / scale); + const steps = Math.max(1, Math.min(40, parseInt(params.steps || 12, 10) || 12)); + run(L(["mousemove", String(absX), String(absY), "mousedown", "1"])); + for (let i = 1; i <= steps; i++) { + const tt = i / steps; + run( + L([ + "mousemove", + String(Math.round(absX + (toX - absX) * tt)), + String(Math.round(absY + (toY - absY) * tt)), + ]), + ); + } + run(L(["mouseup", "1"])); + return; + } + case "hover": + run(L(["mousemove", String(absX), String(absY)])); + return; + case "middleclick": + run(L(["mousemove", String(absX), String(absY), "click", "2"])); + return; + case "tripleclick": + run( + L([ + "mousemove", String(absX), String(absY), + "click", "--repeat", "3", "--delay", "60", "1", + ]), + ); + return; + case "wait": + case "sleep": { + const ms = Math.max(0, Math.min(10000, parseInt(params.ms || params.duration || 500, 10) || 500)); + setTimeout(() => { + sendCmdResult(reqId, baseResult(reqId, "ok", "computeruse waited " + ms + "ms", "")); + }, ms); + return; + } + case "display": { + const info = displays.map((d, i) => ({ + index: i, + bounds: d.bounds, + scaleFactor: d.scaleFactor, + primary: d.id === screen.getPrimaryDisplay().id, + })); + sendCmdResult(reqId, baseResult(reqId, "ok", "displays: " + JSON.stringify(info), "")); + return; + } case "type": { // macOS cliclick 无 type,用 osascript if (os_ === "darwin") { @@ -2427,10 +3151,21 @@ function initTray() { function showMainWindow() { try { if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.show(); + // 窗口是静默创建时已处于「未显示」态,这里正常显示即可。 + if (!mainWindow.isVisible()) mainWindow.show(); mainWindow.focus(); + return; } - } catch (e) {} + } catch (e) { + /* 落到下面重建 */ + } + // 兜底:窗口不存在(异常退出后残留状态、或未来改成不建窗)时重建, + // 否则托盘菜单点了没反应,进程就成了唤不起来的僵尸。 + try { + createWindow(true); + } catch (e) { + console.error("showMainWindow: recreate failed: " + e.message); + } } function destroyTray() { try { @@ -2444,7 +3179,16 @@ function destroyTray() { app.whenReady().then(async () => { installAuthRule(); const running = await isServerRunning(); - if (!running) { + if (running) { + // 已探测到可用端点:服务端在别处正常运行(远程/WSL/另一台机器)。 + // 这类部署下 GUI 不该、也拉不起 homed —— 此前仍会去 startHomed() + // 再等 8s 超时,刷出误导性的 "homed failed to start within timeout"。 + console.log("server reachable, skip homed autostart"); + } else if (!findHomed()) { + // 没有本地 homed 可执行文件 ⇒ 本机根本不具备托管服务端的能力, + // 此时干等超时毫无意义,直接说明情况即可。 + console.log("no local homed binary and no reachable endpoint; GUI will use configured connection"); + } else { startHomed(); const started = await waitForServer(); if (started) { @@ -2478,7 +3222,28 @@ app.whenReady().then(async () => { } catch (e) { console.error("[auth-schedule] start failed: " + e.message); } - createWindow(); + + // ★ 静默启动:prefs.silentStart 为真时不显示主窗口,驻留托盘。 + // + // 此前这个开关是**死的**:设置页有它、gui-prefs.json 存了它、文案写着 + // 「启动时不显示主窗口,驻留托盘后台运行」,但主进程从来没读过它 —— + // whenReady 无条件 createWindow(),于是必然弹窗。 + // + // 实测(本机,prefs.silentStart=true):窗口标题 HomeAgent 照样出现。 + // + // 与 applyAutoLaunch 里的 openAsHidden 是两回事:那个是告诉 OS「开机自启 + // 时如何启动」(仅 Windows/macOS 生效),这里是本进程自己决定要不要显示, + // 对手动启动同样生效。 + let silent = false; + try { + silent = !!loadGuiPrefs().silentStart; + } catch (e) { + console.error("[startup] read silentStart failed: " + e.message); + } + createWindow(!silent); + if (silent) { + console.log("[startup] silentStart=on: window created hidden (tray only)"); + } }); app.on("before-quit", () => { @@ -2512,6 +3277,62 @@ app.on("activate", () => { createWindow(); } }); +// ============ 系统通知(Electron 原生)============ +// +// 之前**完全没有**这个能力:主进程没引入 Notification、preload 没暴露接口、 +// 渲染层只有页面内的 toast(用户不盯着窗口就看不到)。 +// 这套是补齐:渲染层判断"该不该通知",主进程负责弹系统通知。 +// +// 为什么点击要回传会话:点了通知却不知道该看哪句话,等于没通知。 +// 点击时通过 webContents 回到渲染进程,由它切到 chat 视图并滚到该条。 +let notifSeq = 0; + +ipcMain.handle("notify:show", (_, payload) => { + try { + const p = payload || {}; + const title = String(p.title || "HomeAgent"); + const body = String(p.body || ""); + if (!body) return { ok: false, error: "empty body" }; + if (!Notification.isSupported()) return { ok: false, error: "unsupported" }; + + // 已有窗口在前台且可见时不再打扰:用户正看着界面。 + // 静默启动场景下 mainWindow 存在但 hidden,正好走"要通知"这条路。 + if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible() && mainWindow.isFocused()) { + return { ok: false, skipped: "focused" }; + } + + const id = ++notifSeq; + const n = new Notification({ + title: title, + body: body.length > 180 ? body.slice(0, 180) + "\u2026" : body, + silent: !!p.silent, + urgency: p.urgent ? "critical" : "normal", + }); + n.on("click", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (!mainWindow.isVisible()) mainWindow.show(); + mainWindow.focus(); + } + try { + mainWindow.webContents.send("notify:clicked", { id: id, msgKey: p.msgKey || "" }); + } catch (e) {} + n.close(); + }); + n.show(); + return { ok: true, id: id }; + } catch (e) { + return { ok: false, error: e.message }; + } +}); + +ipcMain.handle("notify:supported", () => { + try { + return { supported: Notification.isSupported() }; + } catch (e) { + return { supported: false }; + } +}); + // IPC:prefs(renderer 设置页需要) // IPC:本机设备身份(renderer 设备页需要) ipcMain.handle("device:identity", () => @@ -2528,7 +3349,19 @@ const GUI_PREFS_FILE = path.join(app.getPath("userData"), "gui-prefs.json"); function loadGuiPrefs() { try { if (fs.existsSync(GUI_PREFS_FILE)) { - const d = JSON.parse(fs.readFileSync(GUI_PREFS_FILE, "utf-8")); + // ★ 去 BOM 后再 parse。 + // + // Windows 上用 PowerShell Set-Content -Encoding UTF8、记事本等工具改过 + // 这个文件,会在开头写入 UTF-8 BOM(EF BB BF)。JSON.parse 遇到它直接 + // 抛错 → 走 catch → 返回**默认 prefs**(silentStart/exitToTray/ + // deviceBridge 全部被重置)→ 用户界面上的开关像是"保存了但不起作用"。 + // + // 本次实测踩到:判据脚本用 Set-Content 改 silentStart,文件带上 BOM 后 + // 静默启动不生效,而 loadConnections 早就有去 BOM 处理、这里没有 —— + // 两个读取点不一致,属实打实的疏漏。 + const d = JSON.parse( + fs.readFileSync(GUI_PREFS_FILE, "utf-8").replace(/^\uFEFF/, ""), + ); const db = d.deviceBridge || {}; return { autoLaunch: !!d.autoLaunch, @@ -2630,6 +3463,12 @@ ipcMain.handle("device-bridge:get", () => { authorized: !!db.authorized, // 客户端本地授权状态 gateway: db.gateway || "", tokenSet: !!(db.token || ""), + // 设备接入面(/device/online 等)走服务端 requireToken,只认 X-API-Key。 + // 渲染进程要用同一份令牌去拉设备列表,而此前只给了 tokenSet(布尔), + // 于是渲染侧永远拿不到明文 ⇒ 设备页恒 401(显示为空列表)。 + // 明文本来就存在本机 gui-prefs.json,不新增任何密钥存储面; + // 且只经 contextBridge 送到渲染进程,不落日志、不进 URL。 + token: db.token || "", connected: !!deviceBridge, deviceId: deviceBridgeId, address: deviceBridgeAddr, diff --git a/cmd/gui/package-config.test.mjs b/cmd/gui/package-config.test.mjs new file mode 100644 index 00000000..a38a41fe --- /dev/null +++ b/cmd/gui/package-config.test.mjs @@ -0,0 +1,139 @@ +// GUI 发行打包配置的一致性判据。 +// +// ## 要判的是什么 +// +// 两条打包路径的配置各自漏过东西,且都是**静默**失败: +// +// 1) electron-packager(make build-gui,Windows 发行 + 本机安装都走它) +// · 曾漏 --icon=icon.ico ⇒ exe 一直是 Electron 默认图标 +// (已修:Makefile e79eff6) +// +// 2) package.json 的 build 段(electron-builder,Linux 发行走它) +// · files 白名单漏了 icon.ico / icon-tray*.png ⇒ 图标不进包 +// · win.icon 路径写错(electron-builder 相对**项目目录**解析, +// 写 'build/icon.ico' 会找不到文件) +// +// 3) deploy/packaging/package-linux.sh 的手工组装曾逐文件 cp 七个文件, +// 漏掉 renderer/vendor/*(本地化的 four 个库)与全部图标文件 +// ⇒ Linux 发行包装完星图与托盘图标直接坏,且**没有任何构建报错** +// (已修:改目录整体同步 + 必备文件校验) +// +// 打包配置类问题的特点是"改了没人知道、坏了要等用户装上才发现", +// 所以在这里钉死。 +// +// 运行:node package-config.test.mjs + +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, "..", ".."); +const guiDir = here; + +let failures = 0; +const check = (name, ok, detail) => { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// ── 1) Makefile: electron-packager 必须带 --icon ────────────────────── +const mk = readFileSync(join(repoRoot, "Makefile"), "utf8"); +const buildGui = mk.slice( + mk.indexOf("build-gui:"), + mk.indexOf("\nbuild-static:"), +); + +check("能定位 build-gui 目标", buildGui.length > 0); +check( + "★ build-gui 传 --icon(否则 exe 一直是 Electron 默认图标)", + /--icon[= ]\S*icon\.ico/.test(buildGui), + "缺 --icon ⇒ exe 内嵌图标为 Electron 默认", +); +check( + "build-gui 仍带 --no-sandbox", + /--no-sandbox/.test(buildGui), + "--no-sandbox 没了,本机可能起不来", +); + +// ── 2) package.json build 段 ───────────────────────────────────────── +const pkg = JSON.parse(readFileSync(join(guiDir, "package.json"), "utf8")); +const files = pkg.build?.files || []; + +check( + "★ files 白名单含 icon.ico", + files.includes("icon.ico"), + "files = " + JSON.stringify(files), +); +check( + "files 白名单含托盘图标", + files.includes("icon-tray.png") && files.includes("icon-tray@2x.png"), + "缺 icon-tray*.png ⇒ 托盘图标在发行包里丢失", +); +check( + "files 含 renderer/**/*(含 vendor/)", + files.some((f) => f.startsWith("renderer/")), + "renderer 未列入 ⇒ vendor 库不会进包", +); + +// ── 3) win.icon 路径语义 ───────────────────────────────────────────── +// electron-builder 的 icon 相对**项目目录**(package.json 所在)解析。 +// 写成 'build/icon.ico'(相对输出目录)会找不到文件。 +const winIcon = pkg.build?.win?.icon; +check("配置了 win.icon", !!winIcon, "未配置 win.icon"); +if (winIcon) { + check( + "★ win.icon 路径不是误写的 build/ 前缀", + !winIcon.startsWith("build/"), + `win.icon='${winIcon}' —— electron-builder 相对项目目录解析,` + + "带 build/ 前缀会找不到文件", + ); + check( + "win.icon 指向的文件真实存在", + existsSync(join(guiDir, winIcon)), + `${winIcon} 不存在于 cmd/gui/`, + ); +} + +// ── 4) icon.ico 本身要含足够尺寸 ──────────────────────────────────── +const ico = readFileSync(join(guiDir, "icon.ico")); +const isIco = ico.readUInt16LE(0) === 0 && ico.readUInt16LE(2) === 1; +check("icon.ico 是合法 ICO", isIco, "头部不是 00 00 01 00"); +const icoCount = ico.readUInt16LE(4); +check( + "icon.ico 含多尺寸(含 256x256,electron-builder 需要)", + icoCount >= 2, + `只含 ${icoCount} 个尺寸`, +); + +// ── 5) package-linux.sh 不得再逐文件手列 ──────────────────────────── +const pkgLinux = readFileSync( + join(repoRoot, "deploy", "packaging", "package-linux.sh"), + "utf8", +); +check( + "★ package-linux.sh 按目录整体同步 renderer", + /cp -r "\$gui_dir\/renderer\/\."/.test(pkgLinux), + "仍在逐文件 cp ⇒ 新增资源(如 vendor/)不会被打进 Linux 包", +); +check( + "package-linux.sh 同步根级图标", + /cp "\$gui_dir"\/\*\.ico/.test(pkgLinux) && + /icon-tray/.test(pkgLinux), + "未同步根级 ico / 托盘图标", +); +check( + "package-linux.sh 有必备文件校验", + /vendor\/three\.min\.js/.test(pkgLinux) && /rm -rf "\$gui_out"/.test(pkgLinux), + "缺必备文件校验 ⇒ 装完才发现坏包", +); + +console.log(""); +if (failures > 0) { + console.log(`全部失败:${failures} 条`); + process.exit(1); +} +console.log("全部通过"); \ No newline at end of file diff --git a/cmd/gui/package.json b/cmd/gui/package.json index 9399facf..e6dcf3a0 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -8,8 +8,9 @@ "scripts": { "start": "electron . --no-sandbox", "dev": "electron . --no-sandbox --dev", - "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs", - "test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs" + "test": "node sse-backoff.test.mjs && node sse-backoff-behavior.test.mjs && node retry-guard.test.mjs && node endpoint-align.test.mjs && node connections-io.test.mjs && node silent-start.test.mjs && node package-config.test.mjs && node loading-notify.test.mjs && node computeruse.test.mjs", + "test-live": "node chat-perf.test.mjs && node protocol-align.test.mjs", + "test-package": "../../scripts/gui-package-smoke.sh" }, "dependencies": { "koffi": "^3.1.6" @@ -37,10 +38,21 @@ "files": [ "main.js", "preload.js", + "agent-inject.js", + "agent-cursor.js", "renderer/**/*", "icon.svg", "node_modules/**/*", - "!node_modules/electron-builder/**" - ] + "!node_modules/electron-builder/**", + "icon.ico", + "icon-tray.png", + "icon-tray@2x.png" + ], + "win": { + "target": [ + "nsis" + ], + "icon": "icon.ico" + } } } diff --git a/cmd/gui/preload.js b/cmd/gui/preload.js index f446a35c..71e322ff 100644 --- a/cmd/gui/preload.js +++ b/cmd/gui/preload.js @@ -34,6 +34,11 @@ contextBridge.exposeInMainWorld("homeagent", { }, cacheBg: (src) => ipcRenderer.invoke("bg:cache", { src }), log: (m) => ipcRenderer.invoke("log:r", m), + notify: { + show: (payload) => ipcRenderer.invoke("notify:show", payload), + supported: () => ipcRenderer.invoke("notify:supported"), + onClicked: (cb) => ipcRenderer.on("notify:clicked", (_e, d) => cb(d)), + }, prefs: { get: () => ipcRenderer.invoke("prefs:get"), set: (p) => ipcRenderer.invoke("prefs:set", p), diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index cee74392..bf97d7b1 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -55,6 +55,17 @@ const state = { displays: [], selfDeviceId: "", selfGateway: "", + deviceBridgeToken: "", // 设备接入令牌明文(来自 device-bridge:get),用于 /device/* 的 requireToken + _notifyState: null, // 本轮通知去重状态 { turnSig, notified } + _notifySupported: null, // 系统通知是否可用(null=未探测) + // ---- 星图活动数据源(对齐服务端 /memory/graph/pulse + /runtime)---- + starmapPulses: [], // { mesh, until, kind } 活动脉冲队列 + starmapGrown: {}, // nodeId -> 生长动画截止时间戳(ms) + starmapLastPulseAt: 0, // 最后一次活动时间(ms),驱动全局呼吸 + starmapPulseSince: 0, // 下次 pulse 的回看起点(unix 秒) + starmapPulseTimer: null, // /runtime 3s 轮询 id + starmapActivityTimer: null, // /memory/graph/pulse 10s 轮询 id + _smPrevSched: null, // 上一拍 /runtime 调度器快照(做差值判定) }; // ===== I18n ===== @@ -331,26 +342,31 @@ function toggleAppearance() { // ===== Utility ===== // 安全渲染 markdown:marked 转 HTML 后由 DOMPurify 剥离脚本/事件/危险标签。 -// CDN 加载失败时降级为纯转义文本,绝不把未消毒 HTML 直接写入 innerHTML。 +// +// ★ 净化器不可用时**不降级**,直接退到纯文本。 +// +// 旧写法是在净化器缺失时掉到手写正则(剥 - - - + + + + + diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index 70e889a1..c7c8c2d6 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -1290,9 +1290,27 @@ code { border: 1px solid var(--kv-border); border-left: 3px solid var(--accent); cursor: pointer; + position: relative; transition: border-color 0.2s var(--ease-out), - background 0.2s var(--ease-out); + background 0.2s var(--ease-out), + box-shadow 0.2s var(--ease-out); +} + +/* 运行中的工具卡:极淡的呼吸光晕。 + 参考 PiDeck 的 .turn-row--running:before —— 用一层几乎看不见的 + accent 底色缓慢呼吸来表达"正在跑",而不是放一个转圈。 + 转圈的语义是"在加载某个确定的东西",而工具执行没有进度可转。 */ +.tool-card.tc-running::before { + content: ""; + pointer-events: none; + position: absolute; + inset: -1px -3px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 4%, transparent); + opacity: 0; + animation: haRunBreathe 2.2s ease-in-out infinite; + z-index: -1; } .tool-card.tc-running { border-left-color: var(--accent); @@ -1303,6 +1321,15 @@ code { .tool-card.tc-error { border-left-color: var(--error, #db3694); } +@keyframes haRunBreathe { + 0%, + 100% { + opacity: 0; + } + 50% { + opacity: 1; + } +} .tool-card .tc-line { display: flex; align-items: center; @@ -1357,14 +1384,114 @@ code { background: rgba(219, 54, 148, 0.18); color: #ff9ec6; } +/* 工具卡内的活动指示:三点脉冲。 + 旧实现是 11px 的 border 转圈(.tc-spinner)—— 与上一条同样的问题, + 且在 11px 尺寸下转圈糊成一团。改为 PiDeck 的 tool-activity-dot 三点。 */ +/* ===== 工具调用组卡(参考 PiDeck 的 tool-group-card)===== + 一轮里多个工具调用折成一张卡:收起时只留「脉冲点 + 计数 + 状态摘要」, + 展开才逐个看细节。相比每工具一张卡,回复正文不会被推到很下面。 */ +.tool-group-card { + border: 1px solid var(--kv-border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + margin: 6px 0; + font-size: 11px; + background: var(--bg-panel, var(--bg-hover)); + overflow: hidden; +} +.tool-group-card.tone-error { + border-left-color: var(--error, #db3694); +} +.tool-group-card.tone-done { + border-left-color: rgba(23, 169, 100, 0.8); +} +.tool-group-head { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 10px; + cursor: pointer; + color: var(--text-secondary); + transition: background 0.2s var(--ease-out); +} +.tool-group-head:hover { + background: var(--bg-hover); +} +.tool-group-card .tool-group-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--accent); + flex-shrink: 0; +} +/* 运行中的组:脉冲点呼吸(PiDeck 的 toolGroupDotPulse:缩放+透明度) */ +.tool-group-card.tone-running .tool-group-dot { + background: var(--loading-top, var(--accent)); + animation: haGroupDotPulse 1.4s ease-in-out infinite; +} +.tool-group-card.tone-error .tool-group-dot { + background: var(--error, #db3694); +} +.tool-group-card.tone-done .tool-group-dot { + background: rgba(23, 169, 100, 0.9); +} +@keyframes haGroupDotPulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.25); + } +} +.tool-group-count { + font-weight: 600; + color: var(--text-primary); +} +.tool-group-label { + color: var(--text-muted); +} +.tool-group-head .tc-caret { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.15s var(--ease-out); +} +.tool-group-head.open .tc-caret { + transform: rotate(180deg); +} +.tool-group-body { + padding: 0 6px 4px; + border-top: 1px solid var(--kv-border); +} +.tool-group-body .tool-card { + margin: 4px 0; +} + .tool-card .tc-spinner { - width: 11px; - height: 11px; - border-radius: 50%; - border: 2px solid rgba(63, 110, 245, 0.25); - border-top-color: #3f6ef5; - animation: spin 0.7s linear infinite; - display: inline-block; + display: inline-flex; + align-items: center; + gap: 2px; + width: auto; + height: auto; + border: none; + border-radius: 0; + background: none; + vertical-align: middle; +} +.tool-card .tc-spinner::before, +.tool-card .tc-spinner::after { + content: ""; + display: block; + width: 3px; + height: 3px; + border-radius: 999px; + background: var(--accent); + animation: haDots 1.1s ease-in-out infinite; +} +.tool-card .tc-spinner::after { + animation-delay: 0.2s; } .tool-card .tc-caret { font-size: 10px; @@ -1458,21 +1585,154 @@ code { transform: none; } } -.msg-bubble .live-spinner { - width: 15px; - height: 15px; - border-radius: 50%; - border: 2px solid var(--msg-user-bg); - border-top-color: var(--accent); - animation: spin 0.7s linear infinite; - flex-shrink: 0; - display: inline-block; +/* ============================================================ + 加载 / 思考动画 + ------------------------------------------------------------ + 设计参考 PiDeck(@deepseek-ai/dsh-web-frontend / mermaid bundle): + 它**不用边框旋转**,而是「等距三点 + 透明度递减 + 轻微上浮」, + 静默、不抢视线,且在深浅色下都成立。 + + 旧实现是 border + rotate 的经典 spinner(.loading / .loading-spinner / + .live-spinner),在消息气泡里尤其刺眼 —— 一个持续旋转的圆环 + 与「正在思考」的语义也不匹配(旋转暗示在加载某个确定的东西, + 而思考本身没有进度)。 + + 下面是等价改造,三处调用点(面板加载 / 列表加载 / 消息内思考) + 统一到同一套视觉语言。 + ============================================================ */ + +/* 三点渐次亮起(PiDeck: _dsh-state-dot-chase 的等价实现) */ +@keyframes haDots { + 0%, + 12.4% { + opacity: 1; + } + 12.5%, + 24.9% { + opacity: 0.6; + } + 25%, + 37.4% { + opacity: 0.35; + } + 37.5%, + 100% { + opacity: 0.15; + } +} + +/* 单点呼吸(PiDeck: tool-activity-dot) */ +@keyframes haDotFloat { + 0%, + 100% { + opacity: 0.35; + transform: translateY(0) scale(0.8); + } + 50% { + opacity: 1; + transform: translateY(-2px) scale(1); + } +} + +/* 扫光(PiDeck: thinking-sweep),用于「思考中」横向扫光 */ +@keyframes haSweep { + 0% { + left: -300px; + } + 90%, + 100% { + left: 100%; + } +} + +/* 三点基类:用 flex 排布,gap 固定,避免各调用点尺寸不一 */ +.ha-dots { + display: inline-flex; + align-items: center; + gap: 3px; vertical-align: middle; + flex-shrink: 0; +} +.ha-dots > i { + display: block; + width: 4px; + height: 4px; + border-radius: 999px; + background: var(--accent); + animation: haDots 1.1s ease-in-out infinite; +} +.ha-dots > i:nth-child(2) { + animation-delay: 0.15s; +} +.ha-dots > i:nth-child(3) { + animation-delay: 0.3s; +} + +/* 尺寸变体:替换旧的 .loading / .loading-spinner / .live-spinner */ +.ha-dots-sm > i { + width: 3px; + height: 3px; + gap: 2px; +} +.ha-dots-lg > i { + width: 6px; + height: 6px; + gap: 5px; +} + +/* 面板级:居中容器里的加载态(替代 32px 旋转圈) */ +.ha-dots-panel { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +/* 消息气泡内:思考中 */ +.msg-bubble .live-spinner { + display: inline-flex; + align-items: center; + gap: 3px; + vertical-align: middle; + flex-shrink: 0; +} +.msg-bubble .live-spinner > i { + display: block; + width: 4px; + height: 4px; + border-radius: 999px; + background: var(--accent); + animation: haDots 1.1s ease-in-out infinite; +} +.msg-bubble .live-spinner > i:nth-child(2) { + animation-delay: 0.15s; +} +.msg-bubble .live-spinner > i:nth-child(3) { + animation-delay: 0.3s; } .msg-bubble .live-spinner + .thinking-tools, .msg-bubble .live-spinner + .text { margin-left: 8px; } + +/* 正在生成的那条消息:整条气泡的呼吸光晕。 + 参考 PiDeck 的 .turn-row--running:before —— 一层几乎看不见的 + accent 底色缓慢明暗交替。比对单点动画的优势:余光就能感知 + 「agent 正在回」,不必把视线缩到气泡里那几个点上。 */ +.msg-bubble.msg-running { + position: relative; +} +.msg-bubble.msg-running::before { + content: ""; + pointer-events: none; + position: absolute; + inset: -4px -6px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--accent) 4%, transparent); + opacity: 0; + animation: haRunBreathe 2.2s ease-in-out infinite; + z-index: -1; +} .msg-bubble.grow-in { animation: bubbleGrow 0.5s var(--ease-out) both; } @@ -1535,22 +1795,44 @@ code { #sm-container-chat canvas { display: block; } -.loading { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid var(--loading-border); - border-radius: 50%; - border-top-color: var(--loading-top); - animation: spin 0.6s linear infinite; -} +/* 旧类名保留为别名:已有 6+ 处调用点直接用 .loading / .loading-spinner。 + 把它们指到新的三点动画,而不是删掉后逐处改 HTML —— 后者容易漏。 + (用 flex 布局装不出第三个点,::before/::after 只能造 2 个, + 所以这三个点由 JS 插入;没插入时至少是个 2 点的退化态, + 不会退化成旋转圈。) */ +.loading, .loading-spinner { - width: 32px; - height: 32px; - border: 3px solid color-mix(in srgb, var(--accent) 15%, transparent); - border-top: 3px solid var(--accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; + display: inline-flex; + align-items: center; + gap: 4px; + vertical-align: middle; + width: auto; + height: auto; + border: none; + border-radius: 0; + background: none; + animation: none; +} +.loading::before, +.loading::after, +.loading-spinner::before, +.loading-spinner::after { + content: ""; + display: block; + width: 5px; + height: 5px; + border-radius: 999px; + background: var(--accent); + animation: haDots 1.1s ease-in-out infinite; +} +.loading::after, +.loading-spinner::after { + animation-delay: 0.2s; +} +.loading-spinner::before, +.loading-spinner::after { + width: 6px; + height: 6px; } @keyframes spin { to { diff --git a/cmd/gui/renderer/vendor/OrbitControls.js b/cmd/gui/renderer/vendor/OrbitControls.js new file mode 100644 index 00000000..08730971 --- /dev/null +++ b/cmd/gui/renderer/vendor/OrbitControls.js @@ -0,0 +1,1045 @@ +( function () { + + // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). + // + // Orbit - left mouse / touch: one-finger move + // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish + // Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move + + const _changeEvent = { + type: 'change' + }; + const _startEvent = { + type: 'start' + }; + const _endEvent = { + type: 'end' + }; + + class OrbitControls extends THREE.EventDispatcher { + + constructor( object, domElement ) { + + super(); + if ( domElement === undefined ) console.warn( 'THREE.OrbitControls: The second parameter "domElement" is now mandatory.' ); + if ( domElement === document ) console.error( 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.' ); + this.object = object; + this.domElement = domElement; // Set to false to disable this control + + this.enabled = true; // "target" sets the location of focus, where the object orbits around + + this.target = new THREE.Vector3(); // How far you can dolly in and out ( PerspectiveCamera only ) + + this.minDistance = 0; + this.maxDistance = Infinity; // How far you can zoom in and out ( OrthographicCamera only ) + + this.minZoom = 0; + this.maxZoom = Infinity; // How far you can orbit vertically, upper and lower limits. + // Range is 0 to Math.PI radians. + + this.minPolarAngle = 0; // radians + + this.maxPolarAngle = Math.PI; // radians + // How far you can orbit horizontally, upper and lower limits. + // If set, the interval [ min, max ] must be a sub-interval of [ - 2 PI, 2 PI ], with ( max - min < 2 PI ) + + this.minAzimuthAngle = - Infinity; // radians + + this.maxAzimuthAngle = Infinity; // radians + // Set to true to enable damping (inertia) + // If damping is enabled, you must call controls.update() in your animation loop + + this.enableDamping = false; + this.dampingFactor = 0.05; // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. + // Set to false to disable zooming + + this.enableZoom = true; + this.zoomSpeed = 1.0; // Set to false to disable rotating + + this.enableRotate = true; + this.rotateSpeed = 1.0; // Set to false to disable panning + + this.enablePan = true; + this.panSpeed = 1.0; + this.screenSpacePanning = true; // if false, pan orthogonal to world-space direction camera.up + + this.keyPanSpeed = 7.0; // pixels moved per arrow key push + // Set to true to automatically rotate around the target + // If auto-rotate is enabled, you must call controls.update() in your animation loop + + this.autoRotate = false; + this.autoRotateSpeed = 2.0; // 30 seconds per orbit when fps is 60 + // The four arrow keys + + this.keys = { + LEFT: 'ArrowLeft', + UP: 'ArrowUp', + RIGHT: 'ArrowRight', + BOTTOM: 'ArrowDown' + }; // Mouse buttons + + this.mouseButtons = { + LEFT: THREE.MOUSE.ROTATE, + MIDDLE: THREE.MOUSE.DOLLY, + RIGHT: THREE.MOUSE.PAN + }; // Touch fingers + + this.touches = { + ONE: THREE.TOUCH.ROTATE, + TWO: THREE.TOUCH.DOLLY_PAN + }; // for reset + + this.target0 = this.target.clone(); + this.position0 = this.object.position.clone(); + this.zoom0 = this.object.zoom; // the target DOM element for key events + + this._domElementKeyEvents = null; // + // public methods + // + + this.getPolarAngle = function () { + + return spherical.phi; + + }; + + this.getAzimuthalAngle = function () { + + return spherical.theta; + + }; + + this.listenToKeyEvents = function ( domElement ) { + + domElement.addEventListener( 'keydown', onKeyDown ); + this._domElementKeyEvents = domElement; + + }; + + this.saveState = function () { + + scope.target0.copy( scope.target ); + scope.position0.copy( scope.object.position ); + scope.zoom0 = scope.object.zoom; + + }; + + this.reset = function () { + + scope.target.copy( scope.target0 ); + scope.object.position.copy( scope.position0 ); + scope.object.zoom = scope.zoom0; + scope.object.updateProjectionMatrix(); + scope.dispatchEvent( _changeEvent ); + scope.update(); + state = STATE.NONE; + + }; // this method is exposed, but perhaps it would be better if we can make it private... + + + this.update = function () { + + const offset = new THREE.Vector3(); // so camera.up is the orbit axis + + const quat = new THREE.Quaternion().setFromUnitVectors( object.up, new THREE.Vector3( 0, 1, 0 ) ); + const quatInverse = quat.clone().invert(); + const lastPosition = new THREE.Vector3(); + const lastQuaternion = new THREE.Quaternion(); + const twoPI = 2 * Math.PI; + return function update() { + + const position = scope.object.position; + offset.copy( position ).sub( scope.target ); // rotate offset to "y-axis-is-up" space + + offset.applyQuaternion( quat ); // angle from z-axis around y-axis + + spherical.setFromVector3( offset ); + + if ( scope.autoRotate && state === STATE.NONE ) { + + rotateLeft( getAutoRotationAngle() ); + + } + + if ( scope.enableDamping ) { + + spherical.theta += sphericalDelta.theta * scope.dampingFactor; + spherical.phi += sphericalDelta.phi * scope.dampingFactor; + + } else { + + spherical.theta += sphericalDelta.theta; + spherical.phi += sphericalDelta.phi; + + } // restrict theta to be between desired limits + + + let min = scope.minAzimuthAngle; + let max = scope.maxAzimuthAngle; + + if ( isFinite( min ) && isFinite( max ) ) { + + if ( min < - Math.PI ) min += twoPI; else if ( min > Math.PI ) min -= twoPI; + if ( max < - Math.PI ) max += twoPI; else if ( max > Math.PI ) max -= twoPI; + + if ( min <= max ) { + + spherical.theta = Math.max( min, Math.min( max, spherical.theta ) ); + + } else { + + spherical.theta = spherical.theta > ( min + max ) / 2 ? Math.max( min, spherical.theta ) : Math.min( max, spherical.theta ); + + } + + } // restrict phi to be between desired limits + + + spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) ); + spherical.makeSafe(); + spherical.radius *= scale; // restrict radius to be between desired limits + + spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) ); // move target to panned location + + if ( scope.enableDamping === true ) { + + scope.target.addScaledVector( panOffset, scope.dampingFactor ); + + } else { + + scope.target.add( panOffset ); + + } + + offset.setFromSpherical( spherical ); // rotate offset back to "camera-up-vector-is-up" space + + offset.applyQuaternion( quatInverse ); + position.copy( scope.target ).add( offset ); + scope.object.lookAt( scope.target ); + + if ( scope.enableDamping === true ) { + + sphericalDelta.theta *= 1 - scope.dampingFactor; + sphericalDelta.phi *= 1 - scope.dampingFactor; + panOffset.multiplyScalar( 1 - scope.dampingFactor ); + + } else { + + sphericalDelta.set( 0, 0, 0 ); + panOffset.set( 0, 0, 0 ); + + } + + scale = 1; // update condition is: + // min(camera displacement, camera rotation in radians)^2 > EPS + // using small-angle approximation cos(x/2) = 1 - x^2 / 8 + + if ( zoomChanged || lastPosition.distanceToSquared( scope.object.position ) > EPS || 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ) { + + scope.dispatchEvent( _changeEvent ); + lastPosition.copy( scope.object.position ); + lastQuaternion.copy( scope.object.quaternion ); + zoomChanged = false; + return true; + + } + + return false; + + }; + + }(); + + this.dispose = function () { + + scope.domElement.removeEventListener( 'contextmenu', onContextMenu ); + scope.domElement.removeEventListener( 'pointerdown', onPointerDown ); + scope.domElement.removeEventListener( 'wheel', onMouseWheel ); + scope.domElement.removeEventListener( 'touchstart', onTouchStart ); + scope.domElement.removeEventListener( 'touchend', onTouchEnd ); + scope.domElement.removeEventListener( 'touchmove', onTouchMove ); + scope.domElement.ownerDocument.removeEventListener( 'pointermove', onPointerMove ); + scope.domElement.ownerDocument.removeEventListener( 'pointerup', onPointerUp ); + + if ( scope._domElementKeyEvents !== null ) { + + scope._domElementKeyEvents.removeEventListener( 'keydown', onKeyDown ); + + } //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? + + }; // + // internals + // + + + const scope = this; + const STATE = { + NONE: - 1, + ROTATE: 0, + DOLLY: 1, + PAN: 2, + TOUCH_ROTATE: 3, + TOUCH_PAN: 4, + TOUCH_DOLLY_PAN: 5, + TOUCH_DOLLY_ROTATE: 6 + }; + let state = STATE.NONE; + const EPS = 0.000001; // current position in spherical coordinates + + const spherical = new THREE.Spherical(); + const sphericalDelta = new THREE.Spherical(); + let scale = 1; + const panOffset = new THREE.Vector3(); + let zoomChanged = false; + const rotateStart = new THREE.Vector2(); + const rotateEnd = new THREE.Vector2(); + const rotateDelta = new THREE.Vector2(); + const panStart = new THREE.Vector2(); + const panEnd = new THREE.Vector2(); + const panDelta = new THREE.Vector2(); + const dollyStart = new THREE.Vector2(); + const dollyEnd = new THREE.Vector2(); + const dollyDelta = new THREE.Vector2(); + + function getAutoRotationAngle() { + + return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; + + } + + function getZoomScale() { + + return Math.pow( 0.95, scope.zoomSpeed ); + + } + + function rotateLeft( angle ) { + + sphericalDelta.theta -= angle; + + } + + function rotateUp( angle ) { + + sphericalDelta.phi -= angle; + + } + + const panLeft = function () { + + const v = new THREE.Vector3(); + return function panLeft( distance, objectMatrix ) { + + v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix + + v.multiplyScalar( - distance ); + panOffset.add( v ); + + }; + + }(); + + const panUp = function () { + + const v = new THREE.Vector3(); + return function panUp( distance, objectMatrix ) { + + if ( scope.screenSpacePanning === true ) { + + v.setFromMatrixColumn( objectMatrix, 1 ); + + } else { + + v.setFromMatrixColumn( objectMatrix, 0 ); + v.crossVectors( scope.object.up, v ); + + } + + v.multiplyScalar( distance ); + panOffset.add( v ); + + }; + + }(); // deltaX and deltaY are in pixels; right and down are positive + + + const pan = function () { + + const offset = new THREE.Vector3(); + return function pan( deltaX, deltaY ) { + + const element = scope.domElement; + + if ( scope.object.isPerspectiveCamera ) { + + // perspective + const position = scope.object.position; + offset.copy( position ).sub( scope.target ); + let targetDistance = offset.length(); // half of the fov is center to top of screen + + targetDistance *= Math.tan( scope.object.fov / 2 * Math.PI / 180.0 ); // we use only clientHeight here so aspect ratio does not distort speed + + panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix ); + panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix ); + + } else if ( scope.object.isOrthographicCamera ) { + + // orthographic + panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix ); + panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix ); + + } else { + + // camera neither orthographic nor perspective + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' ); + scope.enablePan = false; + + } + + }; + + }(); + + function dollyOut( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale /= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + + } + + } + + function dollyIn( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale *= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + + } + + } // + // event callbacks - update the object state + // + + + function handleMouseDownRotate( event ) { + + rotateStart.set( event.clientX, event.clientY ); + + } + + function handleMouseDownDolly( event ) { + + dollyStart.set( event.clientX, event.clientY ); + + } + + function handleMouseDownPan( event ) { + + panStart.set( event.clientX, event.clientY ); + + } + + function handleMouseMoveRotate( event ) { + + rotateEnd.set( event.clientX, event.clientY ); + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); + const element = scope.domElement; + rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height + + rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); + rotateStart.copy( rotateEnd ); + scope.update(); + + } + + function handleMouseMoveDolly( event ) { + + dollyEnd.set( event.clientX, event.clientY ); + dollyDelta.subVectors( dollyEnd, dollyStart ); + + if ( dollyDelta.y > 0 ) { + + dollyOut( getZoomScale() ); + + } else if ( dollyDelta.y < 0 ) { + + dollyIn( getZoomScale() ); + + } + + dollyStart.copy( dollyEnd ); + scope.update(); + + } + + function handleMouseMovePan( event ) { + + panEnd.set( event.clientX, event.clientY ); + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); + pan( panDelta.x, panDelta.y ); + panStart.copy( panEnd ); + scope.update(); + + } + + function handleMouseUp( ) { // no-op + } + + function handleMouseWheel( event ) { + + if ( event.deltaY < 0 ) { + + dollyIn( getZoomScale() ); + + } else if ( event.deltaY > 0 ) { + + dollyOut( getZoomScale() ); + + } + + scope.update(); + + } + + function handleKeyDown( event ) { + + let needsUpdate = false; + + switch ( event.code ) { + + case scope.keys.UP: + pan( 0, scope.keyPanSpeed ); + needsUpdate = true; + break; + + case scope.keys.BOTTOM: + pan( 0, - scope.keyPanSpeed ); + needsUpdate = true; + break; + + case scope.keys.LEFT: + pan( scope.keyPanSpeed, 0 ); + needsUpdate = true; + break; + + case scope.keys.RIGHT: + pan( - scope.keyPanSpeed, 0 ); + needsUpdate = true; + break; + + } + + if ( needsUpdate ) { + + // prevent the browser from scrolling on cursor keys + event.preventDefault(); + scope.update(); + + } + + } + + function handleTouchStartRotate( event ) { + + if ( event.touches.length == 1 ) { + + rotateStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + + } else { + + const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); + rotateStart.set( x, y ); + + } + + } + + function handleTouchStartPan( event ) { + + if ( event.touches.length == 1 ) { + + panStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + + } else { + + const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); + panStart.set( x, y ); + + } + + } + + function handleTouchStartDolly( event ) { + + const dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; + const dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; + const distance = Math.sqrt( dx * dx + dy * dy ); + dollyStart.set( 0, distance ); + + } + + function handleTouchStartDollyPan( event ) { + + if ( scope.enableZoom ) handleTouchStartDolly( event ); + if ( scope.enablePan ) handleTouchStartPan( event ); + + } + + function handleTouchStartDollyRotate( event ) { + + if ( scope.enableZoom ) handleTouchStartDolly( event ); + if ( scope.enableRotate ) handleTouchStartRotate( event ); + + } + + function handleTouchMoveRotate( event ) { + + if ( event.touches.length == 1 ) { + + rotateEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + + } else { + + const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); + rotateEnd.set( x, y ); + + } + + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); + const element = scope.domElement; + rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height + + rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); + rotateStart.copy( rotateEnd ); + + } + + function handleTouchMovePan( event ) { + + if ( event.touches.length == 1 ) { + + panEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + + } else { + + const x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + const y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); + panEnd.set( x, y ); + + } + + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); + pan( panDelta.x, panDelta.y ); + panStart.copy( panEnd ); + + } + + function handleTouchMoveDolly( event ) { + + const dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; + const dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; + const distance = Math.sqrt( dx * dx + dy * dy ); + dollyEnd.set( 0, distance ); + dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) ); + dollyOut( dollyDelta.y ); + dollyStart.copy( dollyEnd ); + + } + + function handleTouchMoveDollyPan( event ) { + + if ( scope.enableZoom ) handleTouchMoveDolly( event ); + if ( scope.enablePan ) handleTouchMovePan( event ); + + } + + function handleTouchMoveDollyRotate( event ) { + + if ( scope.enableZoom ) handleTouchMoveDolly( event ); + if ( scope.enableRotate ) handleTouchMoveRotate( event ); + + } + + function handleTouchEnd( ) { // no-op + } // + // event handlers - FSM: listen for events and reset state + // + + + function onPointerDown( event ) { + + if ( scope.enabled === false ) return; + + switch ( event.pointerType ) { + + case 'mouse': + case 'pen': + onMouseDown( event ); + break; + // TODO touch + + } + + } + + function onPointerMove( event ) { + + if ( scope.enabled === false ) return; + + switch ( event.pointerType ) { + + case 'mouse': + case 'pen': + onMouseMove( event ); + break; + // TODO touch + + } + + } + + function onPointerUp( event ) { + + switch ( event.pointerType ) { + + case 'mouse': + case 'pen': + onMouseUp( event ); + break; + // TODO touch + + } + + } + + function onMouseDown( event ) { + + // Prevent the browser from scrolling. + event.preventDefault(); // Manually set the focus since calling preventDefault above + // prevents the browser from setting it automatically. + + scope.domElement.focus ? scope.domElement.focus() : window.focus(); + let mouseAction; + + switch ( event.button ) { + + case 0: + mouseAction = scope.mouseButtons.LEFT; + break; + + case 1: + mouseAction = scope.mouseButtons.MIDDLE; + break; + + case 2: + mouseAction = scope.mouseButtons.RIGHT; + break; + + default: + mouseAction = - 1; + + } + + switch ( mouseAction ) { + + case THREE.MOUSE.DOLLY: + if ( scope.enableZoom === false ) return; + handleMouseDownDolly( event ); + state = STATE.DOLLY; + break; + + case THREE.MOUSE.ROTATE: + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + if ( scope.enablePan === false ) return; + handleMouseDownPan( event ); + state = STATE.PAN; + + } else { + + if ( scope.enableRotate === false ) return; + handleMouseDownRotate( event ); + state = STATE.ROTATE; + + } + + break; + + case THREE.MOUSE.PAN: + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { + + if ( scope.enableRotate === false ) return; + handleMouseDownRotate( event ); + state = STATE.ROTATE; + + } else { + + if ( scope.enablePan === false ) return; + handleMouseDownPan( event ); + state = STATE.PAN; + + } + + break; + + default: + state = STATE.NONE; + + } + + if ( state !== STATE.NONE ) { + + scope.domElement.ownerDocument.addEventListener( 'pointermove', onPointerMove ); + scope.domElement.ownerDocument.addEventListener( 'pointerup', onPointerUp ); + scope.dispatchEvent( _startEvent ); + + } + + } + + function onMouseMove( event ) { + + if ( scope.enabled === false ) return; + event.preventDefault(); + + switch ( state ) { + + case STATE.ROTATE: + if ( scope.enableRotate === false ) return; + handleMouseMoveRotate( event ); + break; + + case STATE.DOLLY: + if ( scope.enableZoom === false ) return; + handleMouseMoveDolly( event ); + break; + + case STATE.PAN: + if ( scope.enablePan === false ) return; + handleMouseMovePan( event ); + break; + + } + + } + + function onMouseUp( event ) { + + scope.domElement.ownerDocument.removeEventListener( 'pointermove', onPointerMove ); + scope.domElement.ownerDocument.removeEventListener( 'pointerup', onPointerUp ); + if ( scope.enabled === false ) return; + handleMouseUp( event ); + scope.dispatchEvent( _endEvent ); + state = STATE.NONE; + + } + + function onMouseWheel( event ) { + + if ( scope.enabled === false || scope.enableZoom === false || state !== STATE.NONE && state !== STATE.ROTATE ) return; + event.preventDefault(); + scope.dispatchEvent( _startEvent ); + handleMouseWheel( event ); + scope.dispatchEvent( _endEvent ); + + } + + function onKeyDown( event ) { + + if ( scope.enabled === false || scope.enablePan === false ) return; + handleKeyDown( event ); + + } + + function onTouchStart( event ) { + + if ( scope.enabled === false ) return; + event.preventDefault(); // prevent scrolling + + switch ( event.touches.length ) { + + case 1: + switch ( scope.touches.ONE ) { + + case THREE.TOUCH.ROTATE: + if ( scope.enableRotate === false ) return; + handleTouchStartRotate( event ); + state = STATE.TOUCH_ROTATE; + break; + + case THREE.TOUCH.PAN: + if ( scope.enablePan === false ) return; + handleTouchStartPan( event ); + state = STATE.TOUCH_PAN; + break; + + default: + state = STATE.NONE; + + } + + break; + + case 2: + switch ( scope.touches.TWO ) { + + case THREE.TOUCH.DOLLY_PAN: + if ( scope.enableZoom === false && scope.enablePan === false ) return; + handleTouchStartDollyPan( event ); + state = STATE.TOUCH_DOLLY_PAN; + break; + + case THREE.TOUCH.DOLLY_ROTATE: + if ( scope.enableZoom === false && scope.enableRotate === false ) return; + handleTouchStartDollyRotate( event ); + state = STATE.TOUCH_DOLLY_ROTATE; + break; + + default: + state = STATE.NONE; + + } + + break; + + default: + state = STATE.NONE; + + } + + if ( state !== STATE.NONE ) { + + scope.dispatchEvent( _startEvent ); + + } + + } + + function onTouchMove( event ) { + + if ( scope.enabled === false ) return; + event.preventDefault(); // prevent scrolling + + switch ( state ) { + + case STATE.TOUCH_ROTATE: + if ( scope.enableRotate === false ) return; + handleTouchMoveRotate( event ); + scope.update(); + break; + + case STATE.TOUCH_PAN: + if ( scope.enablePan === false ) return; + handleTouchMovePan( event ); + scope.update(); + break; + + case STATE.TOUCH_DOLLY_PAN: + if ( scope.enableZoom === false && scope.enablePan === false ) return; + handleTouchMoveDollyPan( event ); + scope.update(); + break; + + case STATE.TOUCH_DOLLY_ROTATE: + if ( scope.enableZoom === false && scope.enableRotate === false ) return; + handleTouchMoveDollyRotate( event ); + scope.update(); + break; + + default: + state = STATE.NONE; + + } + + } + + function onTouchEnd( event ) { + + if ( scope.enabled === false ) return; + handleTouchEnd( event ); + scope.dispatchEvent( _endEvent ); + state = STATE.NONE; + + } + + function onContextMenu( event ) { + + if ( scope.enabled === false ) return; + event.preventDefault(); + + } // + + + scope.domElement.addEventListener( 'contextmenu', onContextMenu ); + scope.domElement.addEventListener( 'pointerdown', onPointerDown ); + scope.domElement.addEventListener( 'wheel', onMouseWheel, { + passive: false + } ); + scope.domElement.addEventListener( 'touchstart', onTouchStart, { + passive: false + } ); + scope.domElement.addEventListener( 'touchend', onTouchEnd ); + scope.domElement.addEventListener( 'touchmove', onTouchMove, { + passive: false + } ); // force an update at start + + this.update(); + + } + + } // This set of controls performs orbiting, dollying (zooming), and panning. + // Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). + // This is very similar to OrbitControls, another set of touch behavior + // + // Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate + // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish + // Pan - left mouse, or arrow keys / touch: one-finger move + + + class MapControls extends OrbitControls { + + constructor( object, domElement ) { + + super( object, domElement ); + this.screenSpacePanning = false; // pan orthogonal to world-space direction camera.up + + this.mouseButtons.LEFT = THREE.MOUSE.PAN; + this.mouseButtons.RIGHT = THREE.MOUSE.ROTATE; + this.touches.ONE = THREE.TOUCH.PAN; + this.touches.TWO = THREE.TOUCH.DOLLY_ROTATE; + + } + + } + + THREE.MapControls = MapControls; + THREE.OrbitControls = OrbitControls; + +} )(); diff --git a/cmd/gui/renderer/vendor/marked.min.js b/cmd/gui/renderer/vendor/marked.min.js new file mode 100644 index 00000000..9402998a --- /dev/null +++ b/cmd/gui/renderer/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v4.3.0 - a markdown parser + * Copyright (c) 2011-2023, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,function(r){"use strict";function i(e,t){for(var u=0;ue.length)&&(t=e.length);for(var u=0,n=new Array(t);u=e.length?{done:!0}:{done:!1,value:e[u++]}};throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function e(){return{async:!1,baseUrl:null,breaks:!1,extensions:null,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,hooks:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:null,sanitize:!1,sanitizer:null,silent:!1,smartypants:!1,tokenizer:null,walkTokens:null,xhtml:!1}}r.defaults=e();function u(e){return t[e]}var n=/[&<>"']/,l=new RegExp(n.source,"g"),o=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,a=new RegExp(o.source,"g"),t={"&":"&","<":"<",">":">",'"':""","'":"'"};function A(e,t){if(t){if(n.test(e))return e.replace(l,u)}else if(o.test(e))return e.replace(a,u);return e}var c=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function x(e){return e.replace(c,function(e,t){return"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""})}var h=/(^|[^\[])\^/g;function p(u,e){u="string"==typeof u?u:u.source,e=e||"";var n={replace:function(e,t){return t=(t=t.source||t).replace(h,"$1"),u=u.replace(e,t),n},getRegex:function(){return new RegExp(u,e)}};return n}var Z=/[^\w:]/g,O=/^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;function f(e,t,u){if(e){try{n=decodeURIComponent(x(u)).replace(Z,"").toLowerCase()}catch(e){return null}if(0===n.indexOf("javascript:")||0===n.indexOf("vbscript:")||0===n.indexOf("data:"))return null}var n;t&&!O.test(u)&&(e=u,g[" "+(n=t)]||(q.test(n)?g[" "+n]=n+"/":g[" "+n]=C(n,"/",!0)),t=-1===(n=g[" "+n]).indexOf(":"),u="//"===e.substring(0,2)?t?e:n.replace(j,"$1")+e:"/"===e.charAt(0)?t?e:n.replace(P,"$1")+e:n+e);try{u=encodeURI(u).replace(/%25/g,"%")}catch(e){return null}return u}var g={},q=/^[^:]+:\/*[^/]*$/,j=/^([^:]+:)[\s\S]*$/,P=/^([^:]+:\/*[^/]*)[\s\S]*$/;var k={exec:function(){}};function d(e,t){var u=e.replace(/\|/g,function(e,t,u){for(var n=!1,r=t;0<=--r&&"\\"===u[r];)n=!n;return n?"|":" |"}).split(/ \|/),n=0;if(u[0].trim()||u.shift(),0t)u.splice(t);else for(;u.length>=1,e+=e;return u+e}function m(e,t,u,n){var r=t.href,t=t.title?A(t.title):null,i=e[1].replace(/\\([\[\]])/g,"$1");return"!"!==e[0].charAt(0)?(n.state.inLink=!0,e={type:"link",raw:u,href:r,title:t,text:i,tokens:n.inlineTokens(i)},n.state.inLink=!1,e):{type:"image",raw:u,href:r,title:t,text:A(i)}}var b=function(){function e(e){this.options=e||r.defaults}var t=e.prototype;return t.space=function(e){e=this.rules.block.newline.exec(e);if(e&&0=r.length?e.slice(r.length):e}).join("\n")),{type:"code",raw:t,lang:e[2]&&e[2].trim().replace(this.rules.inline._escapes,"$1"),text:u}},t.heading=function(e){var t,u,e=this.rules.block.heading.exec(e);if(e)return t=e[2].trim(),/#$/.test(t)&&(u=C(t,"#"),!this.options.pedantic&&u&&!/ $/.test(u)||(t=u.trim())),{type:"heading",raw:e[0],depth:e[1].length,text:t,tokens:this.lexer.inline(t)}},t.hr=function(e){e=this.rules.block.hr.exec(e);if(e)return{type:"hr",raw:e[0]}},t.blockquote=function(e){var t,u,n,e=this.rules.block.blockquote.exec(e);if(e)return t=e[0].replace(/^ *>[ \t]?/gm,""),u=this.lexer.state.top,this.lexer.state.top=!0,n=this.lexer.blockTokens(t),this.lexer.state.top=u,{type:"blockquote",raw:e[0],tokens:n,text:t}},t.list=function(e){var t=this.rules.block.list.exec(e);if(t){var u,n,r,i,s,l,o,a,D,c,h,p=1<(g=t[1].trim()).length,f={type:"list",raw:"",ordered:p,start:p?+g.slice(0,-1):"",loose:!1,items:[]},g=p?"\\d{1,9}\\"+g.slice(-1):"\\"+g;this.options.pedantic&&(g=p?g:"[*+-]");for(var F=new RegExp("^( {0,3}"+g+")((?:[\t ][^\\n]*)?(?:\\n|$))");e&&(h=!1,t=F.exec(e))&&!this.rules.block.hr.test(e);){if(u=t[0],e=e.substring(u.length),o=t[2].split("\n",1)[0].replace(/^\t+/,function(e){return" ".repeat(3*e.length)}),a=e.split("\n",1)[0],this.options.pedantic?(i=2,c=o.trimLeft()):(i=t[2].search(/[^ ]/),c=o.slice(i=4=i||!a.trim())c+="\n"+a.slice(i);else{if(s)break;if(4<=o.search(/[^ ]/))break;if(d.test(o))break;if(C.test(o))break;if(k.test(o))break;c+="\n"+a}s||a.trim()||(s=!0),u+=D+"\n",e=e.substring(D.length+1),o=a.slice(i)}f.loose||(l?f.loose=!0:/\n *\n *$/.test(u)&&(l=!0)),this.options.gfm&&(n=/^\[[ xX]\] /.exec(c))&&(r="[ ] "!==n[0],c=c.replace(/^\[[ xX]\] +/,"")),f.items.push({type:"list_item",raw:u,task:!!n,checked:r,loose:!1,text:c}),f.raw+=u}f.items[f.items.length-1].raw=u.trimRight(),f.items[f.items.length-1].text=c.trimRight(),f.raw=f.raw.trimRight();for(var E,x=f.items.length,m=0;m$/,"$1").replace(this.rules.inline._escapes,"$1"):"",n=e[3]&&e[3].substring(1,e[3].length-1).replace(this.rules.inline._escapes,"$1"),{type:"def",tag:t,raw:e[0],href:u,title:n}},t.table=function(e){e=this.rules.block.table.exec(e);if(e){var t={type:"table",header:d(e[1]).map(function(e){return{text:e}}),align:e[2].replace(/^ *|\| *$/g,"").split(/ *\| */),rows:e[3]&&e[3].trim()?e[3].replace(/\n[ \t]*$/,"").split("\n"):[]};if(t.header.length===t.align.length){t.raw=e[0];for(var u,n,r,i=t.align.length,s=0;s/i.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:this.options.sanitize?"text":"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,text:this.options.sanitize?this.options.sanitizer?this.options.sanitizer(e[0]):A(e[0]):e[0]}},t.link=function(e){e=this.rules.inline.link.exec(e);if(e){var t=e[2].trim();if(!this.options.pedantic&&/^$/.test(t))return;var u=C(t.slice(0,-1),"\\");if((t.length-u.length)%2==0)return}else{u=function(e,t){if(-1!==e.indexOf(t[1]))for(var u=e.length,n=0,r=0;r$/.test(t)?u.slice(1):u.slice(1,-1):u)&&u.replace(this.rules.inline._escapes,"$1"),title:r&&r.replace(this.rules.inline._escapes,"$1")},e[0],this.lexer)}},t.reflink=function(e,t){var u;if(u=(u=this.rules.inline.reflink.exec(e))||this.rules.inline.nolink.exec(e))return(e=t[(e=(u[2]||u[1]).replace(/\s+/g," ")).toLowerCase()])?m(u,e,u[0],this.lexer):{type:"text",raw:t=u[0].charAt(0),text:t}},t.emStrong=function(e,t,u){void 0===u&&(u="");var n=this.rules.inline.emStrong.lDelim.exec(e);if(n&&(!n[3]||!u.match(/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])/))){var r=n[1]||n[2]||"";if(!r||""===u||this.rules.inline.punctuation.exec(u)){var i=n[0].length-1,s=i,l=0,o="*"===n[0][0]?this.rules.inline.emStrong.rDelimAst:this.rules.inline.emStrong.rDelimUnd;for(o.lastIndex=0,t=t.slice(-1*e.length+i);null!=(n=o.exec(t));){var a,D=n[1]||n[2]||n[3]||n[4]||n[5]||n[6];if(D)if(a=D.length,n[3]||n[4])s+=a;else if((n[5]||n[6])&&i%3&&!((i+a)%3))l+=a;else if(!(0<(s-=a)))return a=Math.min(a,a+s+l),D=e.slice(0,i+n.index+(n[0].length-D.length)+a),Math.min(i,a)%2?(a=D.slice(1,-1),{type:"em",raw:D,text:a,tokens:this.lexer.inlineTokens(a)}):(a=D.slice(2,-2),{type:"strong",raw:D,text:a,tokens:this.lexer.inlineTokens(a)})}}}},t.codespan=function(e){var t,u,n,e=this.rules.inline.code.exec(e);if(e)return n=e[2].replace(/\n/g," "),t=/[^ ]/.test(n),u=/^ /.test(n)&&/ $/.test(n),n=A(n=t&&u?n.substring(1,n.length-1):n,!0),{type:"codespan",raw:e[0],text:n}},t.br=function(e){e=this.rules.inline.br.exec(e);if(e)return{type:"br",raw:e[0]}},t.del=function(e){e=this.rules.inline.del.exec(e);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}},t.autolink=function(e,t){var u,e=this.rules.inline.autolink.exec(e);if(e)return t="@"===e[2]?"mailto:"+(u=A(this.options.mangle?t(e[1]):e[1])):u=A(e[1]),{type:"link",raw:e[0],text:u,href:t,tokens:[{type:"text",raw:u,text:u}]}},t.url=function(e,t){var u,n,r,i;if(u=this.rules.inline.url.exec(e)){if("@"===u[2])r="mailto:"+(n=A(this.options.mangle?t(u[0]):u[0]));else{for(;i=u[0],u[0]=this.rules.inline._backpedal.exec(u[0])[0],i!==u[0];);n=A(u[0]),r="www."===u[1]?"http://"+u[0]:u[0]}return{type:"link",raw:u[0],text:n,href:r,tokens:[{type:"text",raw:n,text:n}]}}},t.inlineText=function(e,t){e=this.rules.inline.text.exec(e);if(e)return t=this.lexer.state.inRawBlock?this.options.sanitize?this.options.sanitizer?this.options.sanitizer(e[0]):A(e[0]):e[0]:A(this.options.smartypants?t(e[0]):e[0]),{type:"text",raw:e[0],text:t}},e}(),B={newline:/^(?: *(?:\n|$))+/,code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,hr:/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/,html:"^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))",def:/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/,table:k,lheading:/^((?:.|\n(?!\n))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,_paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,text:/^[^\n]+/,_label:/(?!\s*\])(?:\\.|[^\[\]\\])+/,_title:/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/},w=(B.def=p(B.def).replace("label",B._label).replace("title",B._title).getRegex(),B.bullet=/(?:[*+-]|\d{1,9}[.)])/,B.listItemStart=p(/^( *)(bull) */).replace("bull",B.bullet).getRegex(),B.list=p(B.list).replace(/bull/g,B.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+B.def.source+")").getRegex(),B._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",B._comment=/|$)/,B.html=p(B.html,"i").replace("comment",B._comment).replace("tag",B._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),B.paragraph=p(B._paragraph).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",B._tag).getRegex(),B.blockquote=p(B.blockquote).replace("paragraph",B.paragraph).getRegex(),B.normal=F({},B),B.gfm=F({},B.normal,{table:"^ *([^\\n ].*\\|.*)\\n {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)"}),B.gfm.table=p(B.gfm.table).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",B._tag).getRegex(),B.gfm.paragraph=p(B._paragraph).replace("hr",B.hr).replace("heading"," {0,3}#{1,6} ").replace("|lheading","").replace("table",B.gfm.table).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",B._tag).getRegex(),B.pedantic=F({},B.normal,{html:p("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",B._comment).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:k,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:p(B.normal._paragraph).replace("hr",B.hr).replace("heading"," *#{1,6} *[^\n]").replace("lheading",B.lheading).replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").getRegex()}),{escape:/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,autolink:/^<(scheme:[^\s\x00-\x1f<>]*|email)>/,url:k,tag:"^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^",link:/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,reflink:/^!?\[(label)\]\[(ref)\]/,nolink:/^!?\[(ref)\](?:\[\])?/,reflinkSearch:"reflink|nolink(?!\\()",emStrong:{lDelim:/^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/,rDelimAst:/^(?:[^_*\\]|\\.)*?\_\_(?:[^_*\\]|\\.)*?\*(?:[^_*\\]|\\.)*?(?=\_\_)|(?:[^*\\]|\\.)+(?=[^*])|[punct_](\*+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|(?:[^punct*_\s\\]|\\.)(\*+)(?=[^punct*_\s])/,rDelimUnd:/^(?:[^_*\\]|\\.)*?\*\*(?:[^_*\\]|\\.)*?\_(?:[^_*\\]|\\.)*?(?=\*\*)|(?:[^_\\]|\\.)+(?=[^_])|[punct*](\_+)(?=[\s]|$)|(?:[^punct*_\s\\]|\\.)(\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/},code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,br:/^( {2,}|\\)\n(?!\s*$)/,del:k,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\?@\\[\\]`^{|}~",w.punctuation=p(w.punctuation).replace(/punctuation/g,w._punctuation).getRegex(),w.blockSkip=/\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g,w.escapedEmSt=/(?:^|[^\\])(?:\\\\)*\\[*_]/g,w._comment=p(B._comment).replace("(?:--\x3e|$)","--\x3e").getRegex(),w.emStrong.lDelim=p(w.emStrong.lDelim).replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimAst=p(w.emStrong.rDelimAst,"g").replace(/punct/g,w._punctuation).getRegex(),w.emStrong.rDelimUnd=p(w.emStrong.rDelimUnd,"g").replace(/punct/g,w._punctuation).getRegex(),w._escapes=/\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g,w._scheme=/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/,w._email=/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/,w.autolink=p(w.autolink).replace("scheme",w._scheme).replace("email",w._email).getRegex(),w._attribute=/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/,w.tag=p(w.tag).replace("comment",w._comment).replace("attribute",w._attribute).getRegex(),w._label=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,w._href=/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/,w._title=/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/,w.link=p(w.link).replace("label",w._label).replace("href",w._href).replace("title",w._title).getRegex(),w.reflink=p(w.reflink).replace("label",w._label).replace("ref",B._label).getRegex(),w.nolink=p(w.nolink).replace("ref",B._label).getRegex(),w.reflinkSearch=p(w.reflinkSearch,"g").replace("reflink",w.reflink).replace("nolink",w.nolink).getRegex(),w.normal=F({},w),w.pedantic=F({},w.normal,{strong:{start:/^__|\*\*/,middle:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,endAst:/\*\*(?!\*)/g,endUnd:/__(?!_)/g},em:{start:/^_|\*/,middle:/^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/,endAst:/\*(?!\*)/g,endUnd:/_(?!_)/g},link:p(/^!?\[(label)\]\((.*?)\)/).replace("label",w._label).getRegex(),reflink:p(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",w._label).getRegex()}),w.gfm=F({},w.normal,{escape:p(w.escape).replace("])","~|])").getRegex(),_extended_email:/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,url:/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\'+(u?e:A(e,!0))+"\n":"
"+(u?e:A(e,!0))+"
\n"},t.blockquote=function(e){return"
\n"+e+"
\n"},t.html=function(e){return e},t.heading=function(e,t,u,n){return this.options.headerIds?"'+e+"\n":""+e+"\n"},t.hr=function(){return this.options.xhtml?"
\n":"
\n"},t.list=function(e,t,u){var n=t?"ol":"ul";return"<"+n+(t&&1!==u?' start="'+u+'"':"")+">\n"+e+"\n"},t.listitem=function(e){return"
  • "+e+"
  • \n"},t.checkbox=function(e){return" "},t.paragraph=function(e){return"

    "+e+"

    \n"},t.table=function(e,t){return"\n\n"+e+"\n"+(t=t&&""+t+"")+"
    \n"},t.tablerow=function(e){return"\n"+e+"\n"},t.tablecell=function(e,t){var u=t.header?"th":"td";return(t.align?"<"+u+' align="'+t.align+'">':"<"+u+">")+e+"\n"},t.strong=function(e){return""+e+""},t.em=function(e){return""+e+""},t.codespan=function(e){return""+e+""},t.br=function(){return this.options.xhtml?"
    ":"
    "},t.del=function(e){return""+e+""},t.link=function(e,t,u){return null===(e=f(this.options.sanitize,this.options.baseUrl,e))?u:(e='"+u+"")},t.image=function(e,t,u){return null===(e=f(this.options.sanitize,this.options.baseUrl,e))?u:(e=''+u+'":">"))},t.text=function(e){return e},e}(),z=function(){function e(){}var t=e.prototype;return t.strong=function(e){return e},t.em=function(e){return e},t.codespan=function(e){return e},t.del=function(e){return e},t.html=function(e){return e},t.text=function(e){return e},t.link=function(e,t,u){return""+u},t.image=function(e,t,u){return""+u},t.br=function(){return""},e}(),$=function(){function e(){this.seen={}}var t=e.prototype;return t.serialize=function(e){return e.toLowerCase().trim().replace(/<[!\/a-z].*?>/gi,"").replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g,"").replace(/\s/g,"-")},t.getNextSafeSlug=function(e,t){var u=e,n=0;if(this.seen.hasOwnProperty(u))for(n=this.seen[e];u=e+"-"+ ++n,this.seen.hasOwnProperty(u););return t||(this.seen[e]=n,this.seen[u]=0),u},t.slug=function(e,t){void 0===t&&(t={});e=this.serialize(e);return this.getNextSafeSlug(e,t.dryrun)},e}(),S=function(){function u(e){this.options=e||r.defaults,this.options.renderer=this.options.renderer||new _,this.renderer=this.options.renderer,this.renderer.options=this.options,this.textRenderer=new z,this.slugger=new $}u.parse=function(e,t){return new u(t).parse(e)},u.parseInline=function(e,t){return new u(t).parseInline(e)};var e=u.prototype;return e.parse=function(e,t){void 0===t&&(t=!0);for(var u,n,r,i,s,l,o,a,D,c,h,p,f,g,F,A,k="",d=e.length,C=0;C",i?Promise.resolve(t):s?void s(null,t):t;if(i)return Promise.reject(e);if(!s)throw e;s(e)});if(null==e)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof e)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if((t=u)&&t.sanitize&&!t.silent&&console.warn("marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options"),u.hooks&&(u.hooks.options=u),n){var o,a=u.highlight;try{u.hooks&&(e=u.hooks.preprocess(e)),o=f(e,u)}catch(e){return l(e)}var D,c=function(t){var e;if(!t)try{u.walkTokens&&I.walkTokens(o,u.walkTokens),e=g(o,u),u.hooks&&(e=u.hooks.postprocess(e))}catch(e){t=e}return u.highlight=a,t?l(t):n(null,e)};return!a||a.length<3?c():(delete u.highlight,o.length?(D=0,I.walkTokens(o,function(u){"code"===u.type&&(D++,setTimeout(function(){a(u.text,u.lang,function(e,t){if(e)return c(e);null!=t&&t!==u.text&&(u.text=t,u.escaped=!0),0===--D&&c()})},0))}),void(0===D&&c())):c())}if(u.async)return Promise.resolve(u.hooks?u.hooks.preprocess(e):e).then(function(e){return f(e,u)}).then(function(e){return u.walkTokens?Promise.all(I.walkTokens(e,u.walkTokens)).then(function(){return e}):e}).then(function(e){return g(e,u)}).then(function(e){return u.hooks?u.hooks.postprocess(e):e}).catch(l);try{u.hooks&&(e=u.hooks.preprocess(e));var h=f(e,u),p=(u.walkTokens&&I.walkTokens(h,u.walkTokens),g(h,u));return p=u.hooks?u.hooks.postprocess(p):p}catch(e){return l(e)}}}function I(e,t,u){return R(v.lex,S.parse)(e,t,u)}T.passThroughHooks=new Set(["preprocess","postprocess"]),I.options=I.setOptions=function(e){return I.defaults=F({},I.defaults,e),e=I.defaults,r.defaults=e,I},I.getDefaults=e,I.defaults=r.defaults,I.use=function(){for(var D=I.defaults.extensions||{renderers:{},childTokens:{}},e=arguments.length,t=new Array(e),u=0;u1?n-1:0),r=1;r2&&void 0!==arguments[2]?arguments[2]:h;t&&t(e,null);let i=o.length;for(;i--;){let t=o[i];if("string"==typeof t){const e=r(t);e!==t&&(n(o)||(o[i]=e),t=e)}e[t]=!0}return e}function O(e){for(let t=0;t/gm),G=a(/\$\{[\w\W]*/gm),Y=a(/^data-[\-\w.\u00B7-\uFFFF]+$/),j=a(/^aria-[\-\w]+$/),X=a(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),q=a(/^(?:\w+script|data):/i),$=a(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),K=a(/^html$/i),V=a(/^[a-z][.\w]*(-[.\w]+)+$/i);var Z=Object.freeze({__proto__:null,ARIA_ATTR:j,ATTR_WHITESPACE:$,CUSTOM_ELEMENT:V,DATA_ATTR:Y,DOCTYPE_NAME:K,ERB_EXPR:W,IS_ALLOWED_URI:X,IS_SCRIPT_OR_DATA:q,MUSTACHE_EXPR:B,TMPLIT_EXPR:G});const J=1,Q=3,ee=7,te=8,ne=9,oe=function(){return"undefined"==typeof window?null:window};var re=function t(){let n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:oe();const o=e=>t(e);if(o.version="3.2.4",o.removed=[],!n||!n.document||n.document.nodeType!==ne||!n.Element)return o.isSupported=!1,o;let{document:r}=n;const a=r,c=a.currentScript,{DocumentFragment:s,HTMLTemplateElement:N,Node:R,Element:O,NodeFilter:B,NamedNodeMap:W=n.NamedNodeMap||n.MozNamedAttrMap,HTMLFormElement:G,DOMParser:Y,trustedTypes:j}=n,q=O.prototype,$=v(q,"cloneNode"),V=v(q,"remove"),re=v(q,"nextSibling"),ie=v(q,"childNodes"),ae=v(q,"parentNode");if("function"==typeof N){const e=r.createElement("template");e.content&&e.content.ownerDocument&&(r=e.content.ownerDocument)}let le,ce="";const{implementation:se,createNodeIterator:ue,createDocumentFragment:me,getElementsByTagName:pe}=r,{importNode:fe}=a;let de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]};o.isSupported="function"==typeof e&&"function"==typeof ae&&se&&void 0!==se.createHTMLDocument;const{MUSTACHE_EXPR:he,ERB_EXPR:ge,TMPLIT_EXPR:Te,DATA_ATTR:ye,ARIA_ATTR:Ee,IS_SCRIPT_OR_DATA:Ae,ATTR_WHITESPACE:_e,CUSTOM_ELEMENT:Se}=Z;let{IS_ALLOWED_URI:be}=Z,Ne=null;const Re=w({},[...L,...C,...x,...k,...U]);let we=null;const Oe=w({},[...z,...P,...H,...F]);let De=Object.seal(l(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ve=null,Le=null,Ce=!0,xe=!0,Me=!1,ke=!0,Ie=!1,Ue=!0,ze=!1,Pe=!1,He=!1,Fe=!1,Be=!1,We=!1,Ge=!0,Ye=!1,je=!0,Xe=!1,qe={},$e=null;const Ke=w({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let Ve=null;const Ze=w({},["audio","video","img","source","image","track"]);let Je=null;const Qe=w({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),et="http://www.w3.org/1998/Math/MathML",tt="http://www.w3.org/2000/svg",nt="http://www.w3.org/1999/xhtml";let ot=nt,rt=!1,it=null;const at=w({},[et,tt,nt],g);let lt=w({},["mi","mo","mn","ms","mtext"]),ct=w({},["annotation-xml"]);const st=w({},["title","style","font","a","script"]);let ut=null;const mt=["application/xhtml+xml","text/html"];let pt=null,ft=null;const dt=r.createElement("form"),ht=function(e){return e instanceof RegExp||e instanceof Function},gt=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};if(!ft||ft!==e){if(e&&"object"==typeof e||(e={}),e=D(e),ut=-1===mt.indexOf(e.PARSER_MEDIA_TYPE)?"text/html":e.PARSER_MEDIA_TYPE,pt="application/xhtml+xml"===ut?g:h,Ne=_(e,"ALLOWED_TAGS")?w({},e.ALLOWED_TAGS,pt):Re,we=_(e,"ALLOWED_ATTR")?w({},e.ALLOWED_ATTR,pt):Oe,it=_(e,"ALLOWED_NAMESPACES")?w({},e.ALLOWED_NAMESPACES,g):at,Je=_(e,"ADD_URI_SAFE_ATTR")?w(D(Qe),e.ADD_URI_SAFE_ATTR,pt):Qe,Ve=_(e,"ADD_DATA_URI_TAGS")?w(D(Ze),e.ADD_DATA_URI_TAGS,pt):Ze,$e=_(e,"FORBID_CONTENTS")?w({},e.FORBID_CONTENTS,pt):Ke,ve=_(e,"FORBID_TAGS")?w({},e.FORBID_TAGS,pt):{},Le=_(e,"FORBID_ATTR")?w({},e.FORBID_ATTR,pt):{},qe=!!_(e,"USE_PROFILES")&&e.USE_PROFILES,Ce=!1!==e.ALLOW_ARIA_ATTR,xe=!1!==e.ALLOW_DATA_ATTR,Me=e.ALLOW_UNKNOWN_PROTOCOLS||!1,ke=!1!==e.ALLOW_SELF_CLOSE_IN_ATTR,Ie=e.SAFE_FOR_TEMPLATES||!1,Ue=!1!==e.SAFE_FOR_XML,ze=e.WHOLE_DOCUMENT||!1,Fe=e.RETURN_DOM||!1,Be=e.RETURN_DOM_FRAGMENT||!1,We=e.RETURN_TRUSTED_TYPE||!1,He=e.FORCE_BODY||!1,Ge=!1!==e.SANITIZE_DOM,Ye=e.SANITIZE_NAMED_PROPS||!1,je=!1!==e.KEEP_CONTENT,Xe=e.IN_PLACE||!1,be=e.ALLOWED_URI_REGEXP||X,ot=e.NAMESPACE||nt,lt=e.MATHML_TEXT_INTEGRATION_POINTS||lt,ct=e.HTML_INTEGRATION_POINTS||ct,De=e.CUSTOM_ELEMENT_HANDLING||{},e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.tagNameCheck)&&(De.tagNameCheck=e.CUSTOM_ELEMENT_HANDLING.tagNameCheck),e.CUSTOM_ELEMENT_HANDLING&&ht(e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)&&(De.attributeNameCheck=e.CUSTOM_ELEMENT_HANDLING.attributeNameCheck),e.CUSTOM_ELEMENT_HANDLING&&"boolean"==typeof e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements&&(De.allowCustomizedBuiltInElements=e.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements),Ie&&(xe=!1),Be&&(Fe=!0),qe&&(Ne=w({},U),we=[],!0===qe.html&&(w(Ne,L),w(we,z)),!0===qe.svg&&(w(Ne,C),w(we,P),w(we,F)),!0===qe.svgFilters&&(w(Ne,x),w(we,P),w(we,F)),!0===qe.mathMl&&(w(Ne,k),w(we,H),w(we,F))),e.ADD_TAGS&&(Ne===Re&&(Ne=D(Ne)),w(Ne,e.ADD_TAGS,pt)),e.ADD_ATTR&&(we===Oe&&(we=D(we)),w(we,e.ADD_ATTR,pt)),e.ADD_URI_SAFE_ATTR&&w(Je,e.ADD_URI_SAFE_ATTR,pt),e.FORBID_CONTENTS&&($e===Ke&&($e=D($e)),w($e,e.FORBID_CONTENTS,pt)),je&&(Ne["#text"]=!0),ze&&w(Ne,["html","head","body"]),Ne.table&&(w(Ne,["tbody"]),delete ve.tbody),e.TRUSTED_TYPES_POLICY){if("function"!=typeof e.TRUSTED_TYPES_POLICY.createHTML)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if("function"!=typeof e.TRUSTED_TYPES_POLICY.createScriptURL)throw b('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');le=e.TRUSTED_TYPES_POLICY,ce=le.createHTML("")}else void 0===le&&(le=function(e,t){if("object"!=typeof e||"function"!=typeof e.createPolicy)return null;let n=null;const o="data-tt-policy-suffix";t&&t.hasAttribute(o)&&(n=t.getAttribute(o));const r="dompurify"+(n?"#"+n:"");try{return e.createPolicy(r,{createHTML:e=>e,createScriptURL:e=>e})}catch(e){return console.warn("TrustedTypes policy "+r+" could not be created."),null}}(j,c)),null!==le&&"string"==typeof ce&&(ce=le.createHTML(""));i&&i(e),ft=e}},Tt=w({},[...C,...x,...M]),yt=w({},[...k,...I]),Et=function(e){f(o.removed,{element:e});try{ae(e).removeChild(e)}catch(t){V(e)}},At=function(e,t){try{f(o.removed,{attribute:t.getAttributeNode(e),from:t})}catch(e){f(o.removed,{attribute:null,from:t})}if(t.removeAttribute(e),"is"===e)if(Fe||Be)try{Et(t)}catch(e){}else try{t.setAttribute(e,"")}catch(e){}},_t=function(e){let t=null,n=null;if(He)e=""+e;else{const t=T(e,/^[\r\n\t ]+/);n=t&&t[0]}"application/xhtml+xml"===ut&&ot===nt&&(e=''+e+"");const o=le?le.createHTML(e):e;if(ot===nt)try{t=(new Y).parseFromString(o,ut)}catch(e){}if(!t||!t.documentElement){t=se.createDocument(ot,"template",null);try{t.documentElement.innerHTML=rt?ce:o}catch(e){}}const i=t.body||t.documentElement;return e&&n&&i.insertBefore(r.createTextNode(n),i.childNodes[0]||null),ot===nt?pe.call(t,ze?"html":"body")[0]:ze?t.documentElement:i},St=function(e){return ue.call(e.ownerDocument||e,e,B.SHOW_ELEMENT|B.SHOW_COMMENT|B.SHOW_TEXT|B.SHOW_PROCESSING_INSTRUCTION|B.SHOW_CDATA_SECTION,null)},bt=function(e){return e instanceof G&&("string"!=typeof e.nodeName||"string"!=typeof e.textContent||"function"!=typeof e.removeChild||!(e.attributes instanceof W)||"function"!=typeof e.removeAttribute||"function"!=typeof e.setAttribute||"string"!=typeof e.namespaceURI||"function"!=typeof e.insertBefore||"function"!=typeof e.hasChildNodes)},Nt=function(e){return"function"==typeof R&&e instanceof R};function Rt(e,t,n){u(e,(e=>{e.call(o,t,n,ft)}))}const wt=function(e){let t=null;if(Rt(de.beforeSanitizeElements,e,null),bt(e))return Et(e),!0;const n=pt(e.nodeName);if(Rt(de.uponSanitizeElement,e,{tagName:n,allowedTags:Ne}),e.hasChildNodes()&&!Nt(e.firstElementChild)&&S(/<[/\w]/g,e.innerHTML)&&S(/<[/\w]/g,e.textContent))return Et(e),!0;if(e.nodeType===ee)return Et(e),!0;if(Ue&&e.nodeType===te&&S(/<[/\w]/g,e.data))return Et(e),!0;if(!Ne[n]||ve[n]){if(!ve[n]&&Dt(n)){if(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n))return!1;if(De.tagNameCheck instanceof Function&&De.tagNameCheck(n))return!1}if(je&&!$e[n]){const t=ae(e)||e.parentNode,n=ie(e)||e.childNodes;if(n&&t){for(let o=n.length-1;o>=0;--o){const r=$(n[o],!0);r.__removalCount=(e.__removalCount||0)+1,t.insertBefore(r,re(e))}}}return Et(e),!0}return e instanceof O&&!function(e){let t=ae(e);t&&t.tagName||(t={namespaceURI:ot,tagName:"template"});const n=h(e.tagName),o=h(t.tagName);return!!it[e.namespaceURI]&&(e.namespaceURI===tt?t.namespaceURI===nt?"svg"===n:t.namespaceURI===et?"svg"===n&&("annotation-xml"===o||lt[o]):Boolean(Tt[n]):e.namespaceURI===et?t.namespaceURI===nt?"math"===n:t.namespaceURI===tt?"math"===n&&ct[o]:Boolean(yt[n]):e.namespaceURI===nt?!(t.namespaceURI===tt&&!ct[o])&&!(t.namespaceURI===et&&!lt[o])&&!yt[n]&&(st[n]||!Tt[n]):!("application/xhtml+xml"!==ut||!it[e.namespaceURI]))}(e)?(Et(e),!0):"noscript"!==n&&"noembed"!==n&&"noframes"!==n||!S(/<\/no(script|embed|frames)/i,e.innerHTML)?(Ie&&e.nodeType===Q&&(t=e.textContent,u([he,ge,Te],(e=>{t=y(t,e," ")})),e.textContent!==t&&(f(o.removed,{element:e.cloneNode()}),e.textContent=t)),Rt(de.afterSanitizeElements,e,null),!1):(Et(e),!0)},Ot=function(e,t,n){if(Ge&&("id"===t||"name"===t)&&(n in r||n in dt))return!1;if(xe&&!Le[t]&&S(ye,t));else if(Ce&&S(Ee,t));else if(!we[t]||Le[t]){if(!(Dt(e)&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,e)||De.tagNameCheck instanceof Function&&De.tagNameCheck(e))&&(De.attributeNameCheck instanceof RegExp&&S(De.attributeNameCheck,t)||De.attributeNameCheck instanceof Function&&De.attributeNameCheck(t))||"is"===t&&De.allowCustomizedBuiltInElements&&(De.tagNameCheck instanceof RegExp&&S(De.tagNameCheck,n)||De.tagNameCheck instanceof Function&&De.tagNameCheck(n))))return!1}else if(Je[t]);else if(S(be,y(n,_e,"")));else if("src"!==t&&"xlink:href"!==t&&"href"!==t||"script"===e||0!==E(n,"data:")||!Ve[e]){if(Me&&!S(Ae,y(n,_e,"")));else if(n)return!1}else;return!0},Dt=function(e){return"annotation-xml"!==e&&T(e,Se)},vt=function(e){Rt(de.beforeSanitizeAttributes,e,null);const{attributes:t}=e;if(!t||bt(e))return;const n={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:we,forceKeepAttr:void 0};let r=t.length;for(;r--;){const i=t[r],{name:a,namespaceURI:l,value:c}=i,s=pt(a);let m="value"===a?c:A(c);if(n.attrName=s,n.attrValue=m,n.keepAttr=!0,n.forceKeepAttr=void 0,Rt(de.uponSanitizeAttribute,e,n),m=n.attrValue,!Ye||"id"!==s&&"name"!==s||(At(a,e),m="user-content-"+m),Ue&&S(/((--!?|])>)|<\/(style|title)/i,m)){At(a,e);continue}if(n.forceKeepAttr)continue;if(At(a,e),!n.keepAttr)continue;if(!ke&&S(/\/>/i,m)){At(a,e);continue}Ie&&u([he,ge,Te],(e=>{m=y(m,e," ")}));const f=pt(e.nodeName);if(Ot(f,s,m)){if(le&&"object"==typeof j&&"function"==typeof j.getAttributeType)if(l);else switch(j.getAttributeType(f,s)){case"TrustedHTML":m=le.createHTML(m);break;case"TrustedScriptURL":m=le.createScriptURL(m)}try{l?e.setAttributeNS(l,a,m):e.setAttribute(a,m),bt(e)?Et(e):p(o.removed)}catch(e){}}}Rt(de.afterSanitizeAttributes,e,null)},Lt=function e(t){let n=null;const o=St(t);for(Rt(de.beforeSanitizeShadowDOM,t,null);n=o.nextNode();)Rt(de.uponSanitizeShadowNode,n,null),wt(n),vt(n),n.content instanceof s&&e(n.content);Rt(de.afterSanitizeShadowDOM,t,null)};return o.sanitize=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null,r=null,i=null,l=null;if(rt=!e,rt&&(e="\x3c!--\x3e"),"string"!=typeof e&&!Nt(e)){if("function"!=typeof e.toString)throw b("toString is not a function");if("string"!=typeof(e=e.toString()))throw b("dirty is not a string, aborting")}if(!o.isSupported)return e;if(Pe||gt(t),o.removed=[],"string"==typeof e&&(Xe=!1),Xe){if(e.nodeName){const t=pt(e.nodeName);if(!Ne[t]||ve[t])throw b("root node is forbidden and cannot be sanitized in-place")}}else if(e instanceof R)n=_t("\x3c!----\x3e"),r=n.ownerDocument.importNode(e,!0),r.nodeType===J&&"BODY"===r.nodeName||"HTML"===r.nodeName?n=r:n.appendChild(r);else{if(!Fe&&!Ie&&!ze&&-1===e.indexOf("<"))return le&&We?le.createHTML(e):e;if(n=_t(e),!n)return Fe?null:We?ce:""}n&&He&&Et(n.firstChild);const c=St(Xe?e:n);for(;i=c.nextNode();)wt(i),vt(i),i.content instanceof s&&Lt(i.content);if(Xe)return e;if(Fe){if(Be)for(l=me.call(n.ownerDocument);n.firstChild;)l.appendChild(n.firstChild);else l=n;return(we.shadowroot||we.shadowrootmode)&&(l=fe.call(a,l,!0)),l}let m=ze?n.outerHTML:n.innerHTML;return ze&&Ne["!doctype"]&&n.ownerDocument&&n.ownerDocument.doctype&&n.ownerDocument.doctype.name&&S(K,n.ownerDocument.doctype.name)&&(m="\n"+m),Ie&&u([he,ge,Te],(e=>{m=y(m,e," ")})),le&&We?le.createHTML(m):m},o.setConfig=function(){gt(arguments.length>0&&void 0!==arguments[0]?arguments[0]:{}),Pe=!0},o.clearConfig=function(){ft=null,Pe=!1},o.isValidAttribute=function(e,t,n){ft||gt({});const o=pt(e),r=pt(t);return Ot(o,r,n)},o.addHook=function(e,t){"function"==typeof t&&f(de[e],t)},o.removeHook=function(e,t){if(void 0!==t){const n=m(de[e],t);return-1===n?void 0:d(de[e],n,1)[0]}return p(de[e])},o.removeHooks=function(e){de[e]=[]},o.removeAllHooks=function(){de={afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}},o}();return re})); +//# sourceMappingURL=purify.min.js.map diff --git a/cmd/gui/renderer/vendor/three.min.js b/cmd/gui/renderer/vendor/three.min.js index e63cd53a..b14f47c0 100644 --- a/cmd/gui/renderer/vendor/three.min.js +++ b/cmd/gui/renderer/vendor/three.min.js @@ -3,4 +3,4 @@ * Copyright 2010-2021 Three.js Authors * SPDX-License-Identifier: MIT */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="128",n=100,i=300,r=301,s=302,a=303,o=304,l=306,c=307,h=1e3,u=1001,d=1002,p=1003,m=1004,f=1005,g=1006,v=1007,y=1008,x=1009,_=1012,w=1014,b=1015,M=1016,S=1020,T=1022,E=1023,A=1026,L=1027,R=33776,C=33777,P=33778,D=33779,I=35840,N=35841,B=35842,z=35843,F=37492,O=37496,H=2300,G=2301,U=2302,k=2400,V=2401,W=2402,j=2500,q=2501,X=3e3,Y=3001,Z=3007,J=3002,Q=3004,K=3005,$=3006,tt=7680,et=35044,nt=35048,it="300 es";class rt{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e>8&255]+st[t>>16&255]+st[t>>24&255]+"-"+st[255&e]+st[e>>8&255]+"-"+st[e>>16&15|64]+st[e>>24&255]+"-"+st[63&n|128]+st[n>>8&255]+"-"+st[n>>16&255]+st[n>>24&255]+st[255&i]+st[i>>8&255]+st[i>>16&255]+st[i>>24&255]).toUpperCase()}function ht(t,e,n){return Math.max(e,Math.min(n,t))}function ut(t,e){return(t%e+e)%e}function dt(t,e,n){return(1-n)*t+n*e}function pt(t){return 0==(t&t-1)&&0!==t}function mt(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function ft(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}var gt=Object.freeze({__proto__:null,DEG2RAD:ot,RAD2DEG:lt,generateUUID:ct,clamp:ht,euclideanModulo:ut,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:dt,damp:function(t,e,n,i){return dt(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(ut(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){return void 0!==t&&(at=t%2147483647),at=16807*at%2147483647,(at-1)/2147483646},degToRad:function(t){return t*ot},radToDeg:function(t){return t*lt},isPowerOfTwo:pt,ceilPowerOfTwo:mt,floorPowerOfTwo:ft,setQuaternionFromProperEuler:function(t,e,n,i,r){const s=Math.cos,a=Math.sin,o=s(n/2),l=a(n/2),c=s((e+i)/2),h=a((e+i)/2),u=s((e-i)/2),d=a((e-i)/2),p=s((i-e)/2),m=a((i-e)/2);switch(r){case"XYX":t.set(o*h,l*u,l*d,o*c);break;case"YZY":t.set(l*d,o*h,l*u,o*c);break;case"ZXZ":t.set(l*u,l*d,o*h,o*c);break;case"XZX":t.set(o*h,l*m,l*p,o*c);break;case"YXY":t.set(l*p,o*h,l*m,o*c);break;case"ZYZ":t.set(l*m,l*p,o*h,o*c);break;default:console.warn("THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+r)}}});class vt{constructor(t=0,e=0){this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this)}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this)}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){const e=this.x,n=this.y,i=t.elements;return this.x=i[0]*e+i[3]*n+i[6],this.y=i[1]*e+i[4]*n+i[7],this}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}roundToZero(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this}negate(){return this.x=-this.x,this.y=-this.y,this}dot(t){return this.x*t.x+this.y*t.y}cross(t){return this.x*t.y-this.y*t.x}lengthSq(){return this.x*this.x+this.y*this.y}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)}normalize(){return this.divideScalar(this.length()||1)}angle(){return Math.atan2(-this.y,-this.x)+Math.PI}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y;return e*e+n*n}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this}equals(t){return t.x===this.x&&t.y===this.y}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t}fromBufferAttribute(t,e,n){return void 0!==n&&console.warn("THREE.Vector2: offset has been removed from .fromBufferAttribute()."),this.x=t.getX(e),this.y=t.getY(e),this}rotateAround(t,e){const n=Math.cos(e),i=Math.sin(e),r=this.x-t.x,s=this.y-t.y;return this.x=r*n-s*i+t.x,this.y=r*i+s*n+t.y,this}random(){return this.x=Math.random(),this.y=Math.random(),this}}vt.prototype.isVector2=!0;class yt{constructor(){this.elements=[1,0,0,0,1,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix3: the constructor no longer reads arguments. use .set() instead.")}set(t,e,n,i,r,s,a,o,l){const c=this.elements;return c[0]=t,c[1]=i,c[2]=a,c[3]=e,c[4]=r,c[5]=o,c[6]=n,c[7]=s,c[8]=l,this}identity(){return this.set(1,0,0,0,1,0,0,0,1),this}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],this}extractBasis(t,e,n){return t.setFromMatrix3Column(this,0),e.setFromMatrix3Column(this,1),n.setFromMatrix3Column(this,2),this}setFromMatrix4(t){const e=t.elements;return this.set(e[0],e[4],e[8],e[1],e[5],e[9],e[2],e[6],e[10]),this}multiply(t){return this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[3],o=n[6],l=n[1],c=n[4],h=n[7],u=n[2],d=n[5],p=n[8],m=i[0],f=i[3],g=i[6],v=i[1],y=i[4],x=i[7],_=i[2],w=i[5],b=i[8];return r[0]=s*m+a*v+o*_,r[3]=s*f+a*y+o*w,r[6]=s*g+a*x+o*b,r[1]=l*m+c*v+h*_,r[4]=l*f+c*y+h*w,r[7]=l*g+c*x+h*b,r[2]=u*m+d*v+p*_,r[5]=u*f+d*y+p*w,r[8]=u*g+d*x+p*b,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[3]*=t,e[6]*=t,e[1]*=t,e[4]*=t,e[7]*=t,e[2]*=t,e[5]*=t,e[8]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8];return e*s*c-e*a*l-n*r*c+n*a*o+i*r*l-i*s*o}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=c*s-a*l,u=a*o-c*r,d=l*r-s*o,p=e*h+n*u+i*d;if(0===p)return this.set(0,0,0,0,0,0,0,0,0);const m=1/p;return t[0]=h*m,t[1]=(i*l-c*n)*m,t[2]=(a*n-i*s)*m,t[3]=u*m,t[4]=(c*e-i*o)*m,t[5]=(i*r-a*e)*m,t[6]=d*m,t[7]=(n*o-l*e)*m,t[8]=(s*e-n*r)*m,this}transpose(){let t;const e=this.elements;return t=e[1],e[1]=e[3],e[3]=t,t=e[2],e[2]=e[6],e[6]=t,t=e[5],e[5]=e[7],e[7]=t,this}getNormalMatrix(t){return this.setFromMatrix4(t).invert().transpose()}transposeIntoArray(t){const e=this.elements;return t[0]=e[0],t[1]=e[3],t[2]=e[6],t[3]=e[1],t[4]=e[4],t[5]=e[7],t[6]=e[2],t[7]=e[5],t[8]=e[8],this}setUvTransform(t,e,n,i,r,s,a){const o=Math.cos(r),l=Math.sin(r);return this.set(n*o,n*l,-n*(o*s+l*a)+s+t,-i*l,i*o,-i*(-l*s+o*a)+a+e,0,0,1),this}scale(t,e){const n=this.elements;return n[0]*=t,n[3]*=t,n[6]*=t,n[1]*=e,n[4]*=e,n[7]*=e,this}rotate(t){const e=Math.cos(t),n=Math.sin(t),i=this.elements,r=i[0],s=i[3],a=i[6],o=i[1],l=i[4],c=i[7];return i[0]=e*r+n*o,i[3]=e*s+n*l,i[6]=e*a+n*c,i[1]=-n*r+e*o,i[4]=-n*s+e*l,i[7]=-n*a+e*c,this}translate(t,e){const n=this.elements;return n[0]+=t*n[2],n[3]+=t*n[5],n[6]+=t*n[8],n[1]+=e*n[2],n[4]+=e*n[5],n[7]+=e*n[8],this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<9;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<9;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t}clone(){return(new this.constructor).fromArray(this.elements)}}let xt;yt.prototype.isMatrix3=!0;class _t{static getDataURL(t){if(/^data:/i.test(t.src))return t.src;if("undefined"==typeof HTMLCanvasElement)return t.src;let e;if(t instanceof HTMLCanvasElement)e=t;else{void 0===xt&&(xt=document.createElementNS("http://www.w3.org/1999/xhtml","canvas")),xt.width=t.width,xt.height=t.height;const n=xt.getContext("2d");t instanceof ImageData?n.putImageData(t,0,0):n.drawImage(t,0,0,t.width,t.height),e=xt}return e.width>2048||e.height>2048?(console.warn("THREE.ImageUtils.getDataURL: Image converted to jpg for performance reasons",t),e.toDataURL("image/jpeg",.6)):e.toDataURL("image/png")}}let wt=0;class bt extends rt{constructor(t=bt.DEFAULT_IMAGE,e=bt.DEFAULT_MAPPING,n=1001,i=1001,r=1006,s=1008,a=1023,o=1009,l=1,c=3e3){super(),Object.defineProperty(this,"id",{value:wt++}),this.uuid=ct(),this.name="",this.image=t,this.mipmaps=[],this.mapping=e,this.wrapS=n,this.wrapT=i,this.magFilter=r,this.minFilter=s,this.anisotropy=l,this.format=a,this.internalFormat=null,this.type=o,this.offset=new vt(0,0),this.repeat=new vt(1,1),this.center=new vt(0,0),this.rotation=0,this.matrixAutoUpdate=!0,this.matrix=new yt,this.generateMipmaps=!0,this.premultiplyAlpha=!1,this.flipY=!0,this.unpackAlignment=4,this.encoding=c,this.version=0,this.onUpdate=null}updateMatrix(){this.matrix.setUvTransform(this.offset.x,this.offset.y,this.repeat.x,this.repeat.y,this.rotation,this.center.x,this.center.y)}clone(){return(new this.constructor).copy(this)}copy(t){return this.name=t.name,this.image=t.image,this.mipmaps=t.mipmaps.slice(0),this.mapping=t.mapping,this.wrapS=t.wrapS,this.wrapT=t.wrapT,this.magFilter=t.magFilter,this.minFilter=t.minFilter,this.anisotropy=t.anisotropy,this.format=t.format,this.internalFormat=t.internalFormat,this.type=t.type,this.offset.copy(t.offset),this.repeat.copy(t.repeat),this.center.copy(t.center),this.rotation=t.rotation,this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrix.copy(t.matrix),this.generateMipmaps=t.generateMipmaps,this.premultiplyAlpha=t.premultiplyAlpha,this.flipY=t.flipY,this.unpackAlignment=t.unpackAlignment,this.encoding=t.encoding,this}toJSON(t){const e=void 0===t||"string"==typeof t;if(!e&&void 0!==t.textures[this.uuid])return t.textures[this.uuid];const n={metadata:{version:4.5,type:"Texture",generator:"Texture.toJSON"},uuid:this.uuid,name:this.name,mapping:this.mapping,repeat:[this.repeat.x,this.repeat.y],offset:[this.offset.x,this.offset.y],center:[this.center.x,this.center.y],rotation:this.rotation,wrap:[this.wrapS,this.wrapT],format:this.format,type:this.type,encoding:this.encoding,minFilter:this.minFilter,magFilter:this.magFilter,anisotropy:this.anisotropy,flipY:this.flipY,premultiplyAlpha:this.premultiplyAlpha,unpackAlignment:this.unpackAlignment};if(void 0!==this.image){const i=this.image;if(void 0===i.uuid&&(i.uuid=ct()),!e&&void 0===t.images[i.uuid]){let e;if(Array.isArray(i)){e=[];for(let t=0,n=i.length;t1)switch(this.wrapS){case h:t.x=t.x-Math.floor(t.x);break;case u:t.x=t.x<0?0:1;break;case d:1===Math.abs(Math.floor(t.x)%2)?t.x=Math.ceil(t.x)-t.x:t.x=t.x-Math.floor(t.x)}if(t.y<0||t.y>1)switch(this.wrapT){case h:t.y=t.y-Math.floor(t.y);break;case u:t.y=t.y<0?0:1;break;case d:1===Math.abs(Math.floor(t.y)%2)?t.y=Math.ceil(t.y)-t.y:t.y=t.y-Math.floor(t.y)}return this.flipY&&(t.y=1-t.y),t}set needsUpdate(t){!0===t&&this.version++}}function Mt(t){return"undefined"!=typeof HTMLImageElement&&t instanceof HTMLImageElement||"undefined"!=typeof HTMLCanvasElement&&t instanceof HTMLCanvasElement||"undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap?_t.getDataURL(t):t.data?{data:Array.prototype.slice.call(t.data),width:t.width,height:t.height,type:t.data.constructor.name}:(console.warn("THREE.Texture: Unable to serialize Texture."),{})}bt.DEFAULT_IMAGE=void 0,bt.DEFAULT_MAPPING=i,bt.prototype.isTexture=!0;class St{constructor(t=0,e=0,n=0,i=1){this.x=t,this.y=e,this.z=n,this.w=i}get width(){return this.z}set width(t){this.z=t}get height(){return this.w}set height(t){this.w=t}set(t,e,n,i){return this.x=t,this.y=e,this.z=n,this.w=i,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this.w=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setW(t){return this.w=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;case 3:this.w=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z,this.w)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this.w=void 0!==t.w?t.w:1,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector4: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this.z+=t.z,this.w+=t.w,this)}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this.w+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this.w=t.w+e.w,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this.w+=t.w*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector4: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this.z-=t.z,this.w-=t.w,this)}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this.w-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this.w=t.w-e.w,this}multiply(t){return this.x*=t.x,this.y*=t.y,this.z*=t.z,this.w*=t.w,this}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this.w*=t,this}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=this.w,s=t.elements;return this.x=s[0]*e+s[4]*n+s[8]*i+s[12]*r,this.y=s[1]*e+s[5]*n+s[9]*i+s[13]*r,this.z=s[2]*e+s[6]*n+s[10]*i+s[14]*r,this.w=s[3]*e+s[7]*n+s[11]*i+s[15]*r,this}divideScalar(t){return this.multiplyScalar(1/t)}setAxisAngleFromQuaternion(t){this.w=2*Math.acos(t.w);const e=Math.sqrt(1-t.w*t.w);return e<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=t.x/e,this.y=t.y/e,this.z=t.z/e),this}setAxisAngleFromRotationMatrix(t){let e,n,i,r;const s=.01,a=.1,o=t.elements,l=o[0],c=o[4],h=o[8],u=o[1],d=o[5],p=o[9],m=o[2],f=o[6],g=o[10];if(Math.abs(c-u)o&&t>v?tv?o=0?1:-1,i=1-e*e;if(i>Number.EPSILON){const r=Math.sqrt(i),s=Math.atan2(r,e*n);t=Math.sin(t*s)/r,a=Math.sin(a*s)/r}const r=a*n;if(o=o*t+u*r,l=l*t+d*r,c=c*t+p*r,h=h*t+m*r,t===1-a){const t=1/Math.sqrt(o*o+l*l+c*c+h*h);o*=t,l*=t,c*=t,h*=t}}t[e]=o,t[e+1]=l,t[e+2]=c,t[e+3]=h}static multiplyQuaternionsFlat(t,e,n,i,r,s){const a=n[i],o=n[i+1],l=n[i+2],c=n[i+3],h=r[s],u=r[s+1],d=r[s+2],p=r[s+3];return t[e]=a*p+c*h+o*d-l*u,t[e+1]=o*p+c*u+l*h-a*d,t[e+2]=l*p+c*d+a*u-o*h,t[e+3]=c*p-a*h-o*u-l*d,t}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get w(){return this._w}set w(t){this._w=t,this._onChangeCallback()}set(t,e,n,i){return this._x=t,this._y=e,this._z=n,this._w=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._w)}copy(t){return this._x=t.x,this._y=t.y,this._z=t.z,this._w=t.w,this._onChangeCallback(),this}setFromEuler(t,e){if(!t||!t.isEuler)throw new Error("THREE.Quaternion: .setFromEuler() now expects an Euler rotation rather than a Vector3 and order.");const n=t._x,i=t._y,r=t._z,s=t._order,a=Math.cos,o=Math.sin,l=a(n/2),c=a(i/2),h=a(r/2),u=o(n/2),d=o(i/2),p=o(r/2);switch(s){case"XYZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"YXZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"ZXY":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"ZYX":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"YZX":this._x=u*c*h+l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h-u*d*p;break;case"XZY":this._x=u*c*h-l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h+u*d*p;break;default:console.warn("THREE.Quaternion: .setFromEuler() encountered an unknown order: "+s)}return!1!==e&&this._onChangeCallback(),this}setFromAxisAngle(t,e){const n=e/2,i=Math.sin(n);return this._x=t.x*i,this._y=t.y*i,this._z=t.z*i,this._w=Math.cos(n),this._onChangeCallback(),this}setFromRotationMatrix(t){const e=t.elements,n=e[0],i=e[4],r=e[8],s=e[1],a=e[5],o=e[9],l=e[2],c=e[6],h=e[10],u=n+a+h;if(u>0){const t=.5/Math.sqrt(u+1);this._w=.25/t,this._x=(c-o)*t,this._y=(r-l)*t,this._z=(s-i)*t}else if(n>a&&n>h){const t=2*Math.sqrt(1+n-a-h);this._w=(c-o)/t,this._x=.25*t,this._y=(i+s)/t,this._z=(r+l)/t}else if(a>h){const t=2*Math.sqrt(1+a-n-h);this._w=(r-l)/t,this._x=(i+s)/t,this._y=.25*t,this._z=(o+c)/t}else{const t=2*Math.sqrt(1+h-n-a);this._w=(s-i)/t,this._x=(r+l)/t,this._y=(o+c)/t,this._z=.25*t}return this._onChangeCallback(),this}setFromUnitVectors(t,e){let n=t.dot(e)+1;return nMath.abs(t.z)?(this._x=-t.y,this._y=t.x,this._z=0,this._w=n):(this._x=0,this._y=-t.z,this._z=t.y,this._w=n)):(this._x=t.y*e.z-t.z*e.y,this._y=t.z*e.x-t.x*e.z,this._z=t.x*e.y-t.y*e.x,this._w=n),this.normalize()}angleTo(t){return 2*Math.acos(Math.abs(ht(this.dot(t),-1,1)))}rotateTowards(t,e){const n=this.angleTo(t);if(0===n)return this;const i=Math.min(1,e/n);return this.slerp(t,i),this}identity(){return this.set(0,0,0,1)}invert(){return this.conjugate()}conjugate(){return this._x*=-1,this._y*=-1,this._z*=-1,this._onChangeCallback(),this}dot(t){return this._x*t._x+this._y*t._y+this._z*t._z+this._w*t._w}lengthSq(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w}length(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)}normalize(){let t=this.length();return 0===t?(this._x=0,this._y=0,this._z=0,this._w=1):(t=1/t,this._x=this._x*t,this._y=this._y*t,this._z=this._z*t,this._w=this._w*t),this._onChangeCallback(),this}multiply(t,e){return void 0!==e?(console.warn("THREE.Quaternion: .multiply() now only accepts one argument. Use .multiplyQuaternions( a, b ) instead."),this.multiplyQuaternions(t,e)):this.multiplyQuaternions(this,t)}premultiply(t){return this.multiplyQuaternions(t,this)}multiplyQuaternions(t,e){const n=t._x,i=t._y,r=t._z,s=t._w,a=e._x,o=e._y,l=e._z,c=e._w;return this._x=n*c+s*a+i*l-r*o,this._y=i*c+s*o+r*a-n*l,this._z=r*c+s*l+n*o-i*a,this._w=s*c-n*a-i*o-r*l,this._onChangeCallback(),this}slerp(t,e){if(0===e)return this;if(1===e)return this.copy(t);const n=this._x,i=this._y,r=this._z,s=this._w;let a=s*t._w+n*t._x+i*t._y+r*t._z;if(a<0?(this._w=-t._w,this._x=-t._x,this._y=-t._y,this._z=-t._z,a=-a):this.copy(t),a>=1)return this._w=s,this._x=n,this._y=i,this._z=r,this;const o=1-a*a;if(o<=Number.EPSILON){const t=1-e;return this._w=t*s+e*this._w,this._x=t*n+e*this._x,this._y=t*i+e*this._y,this._z=t*r+e*this._z,this.normalize(),this._onChangeCallback(),this}const l=Math.sqrt(o),c=Math.atan2(l,a),h=Math.sin((1-e)*c)/l,u=Math.sin(e*c)/l;return this._w=s*h+this._w*u,this._x=n*h+this._x*u,this._y=i*h+this._y*u,this._z=r*h+this._z*u,this._onChangeCallback(),this}slerpQuaternions(t,e,n){this.copy(t).slerp(e,n)}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._w===this._w}fromArray(t,e=0){return this._x=t[e],this._y=t[e+1],this._z=t[e+2],this._w=t[e+3],this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._w,t}fromBufferAttribute(t,e){return this._x=t.getX(e),this._y=t.getY(e),this._z=t.getZ(e),this._w=t.getW(e),this}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}}At.prototype.isQuaternion=!0;class Lt{constructor(t=0,e=0,n=0){this.x=t,this.y=e,this.z=n}set(t,e,n){return void 0===n&&(n=this.z),this.x=t,this.y=e,this.z=n,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector3: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this.z+=t.z,this)}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector3: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this.z-=t.z,this)}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this}multiply(t,e){return void 0!==e?(console.warn("THREE.Vector3: .multiply() now only accepts one argument. Use .multiplyVectors( a, b ) instead."),this.multiplyVectors(t,e)):(this.x*=t.x,this.y*=t.y,this.z*=t.z,this)}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this}multiplyVectors(t,e){return this.x=t.x*e.x,this.y=t.y*e.y,this.z=t.z*e.z,this}applyEuler(t){return t&&t.isEuler||console.error("THREE.Vector3: .applyEuler() now expects an Euler rotation rather than a Vector3 and order."),this.applyQuaternion(Ct.setFromEuler(t))}applyAxisAngle(t,e){return this.applyQuaternion(Ct.setFromAxisAngle(t,e))}applyMatrix3(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[3]*n+r[6]*i,this.y=r[1]*e+r[4]*n+r[7]*i,this.z=r[2]*e+r[5]*n+r[8]*i,this}applyNormalMatrix(t){return this.applyMatrix3(t).normalize()}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=t.elements,s=1/(r[3]*e+r[7]*n+r[11]*i+r[15]);return this.x=(r[0]*e+r[4]*n+r[8]*i+r[12])*s,this.y=(r[1]*e+r[5]*n+r[9]*i+r[13])*s,this.z=(r[2]*e+r[6]*n+r[10]*i+r[14])*s,this}applyQuaternion(t){const e=this.x,n=this.y,i=this.z,r=t.x,s=t.y,a=t.z,o=t.w,l=o*e+s*i-a*n,c=o*n+a*e-r*i,h=o*i+r*n-s*e,u=-r*e-s*n-a*i;return this.x=l*o+u*-r+c*-a-h*-s,this.y=c*o+u*-s+h*-r-l*-a,this.z=h*o+u*-a+l*-s-c*-r,this}project(t){return this.applyMatrix4(t.matrixWorldInverse).applyMatrix4(t.projectionMatrix)}unproject(t){return this.applyMatrix4(t.projectionMatrixInverse).applyMatrix4(t.matrixWorld)}transformDirection(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[4]*n+r[8]*i,this.y=r[1]*e+r[5]*n+r[9]*i,this.z=r[2]*e+r[6]*n+r[10]*i,this.normalize()}divide(t){return this.x/=t.x,this.y/=t.y,this.z/=t.z,this}divideScalar(t){return this.multiplyScalar(1/t)}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this.z=Math.min(this.z,t.z),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this.z=Math.max(this.z,t.z),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this.z=Math.max(t.z,Math.min(e.z,this.z)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this.z=Math.max(t,Math.min(e,this.z)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this}roundToZero(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this.z=this.z<0?Math.ceil(this.z):Math.floor(this.z),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this}dot(t){return this.x*t.x+this.y*t.y+this.z*t.z}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)}normalize(){return this.divideScalar(this.length()||1)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this.z+=(t.z-this.z)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this.z=t.z+(e.z-t.z)*n,this}cross(t,e){return void 0!==e?(console.warn("THREE.Vector3: .cross() now only accepts one argument. Use .crossVectors( a, b ) instead."),this.crossVectors(t,e)):this.crossVectors(this,t)}crossVectors(t,e){const n=t.x,i=t.y,r=t.z,s=e.x,a=e.y,o=e.z;return this.x=i*o-r*a,this.y=r*s-n*o,this.z=n*a-i*s,this}projectOnVector(t){const e=t.lengthSq();if(0===e)return this.set(0,0,0);const n=t.dot(this)/e;return this.copy(t).multiplyScalar(n)}projectOnPlane(t){return Rt.copy(this).projectOnVector(t),this.sub(Rt)}reflect(t){return this.sub(Rt.copy(t).multiplyScalar(2*this.dot(t)))}angleTo(t){const e=Math.sqrt(this.lengthSq()*t.lengthSq());if(0===e)return Math.PI/2;const n=this.dot(t)/e;return Math.acos(ht(n,-1,1))}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y,i=this.z-t.z;return e*e+n*n+i*i}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)+Math.abs(this.z-t.z)}setFromSpherical(t){return this.setFromSphericalCoords(t.radius,t.phi,t.theta)}setFromSphericalCoords(t,e,n){const i=Math.sin(e)*t;return this.x=i*Math.sin(n),this.y=Math.cos(e)*t,this.z=i*Math.cos(n),this}setFromCylindrical(t){return this.setFromCylindricalCoords(t.radius,t.theta,t.y)}setFromCylindricalCoords(t,e,n){return this.x=t*Math.sin(e),this.y=n,this.z=t*Math.cos(e),this}setFromMatrixPosition(t){const e=t.elements;return this.x=e[12],this.y=e[13],this.z=e[14],this}setFromMatrixScale(t){const e=this.setFromMatrixColumn(t,0).length(),n=this.setFromMatrixColumn(t,1).length(),i=this.setFromMatrixColumn(t,2).length();return this.x=e,this.y=n,this.z=i,this}setFromMatrixColumn(t,e){return this.fromArray(t.elements,4*e)}setFromMatrix3Column(t,e){return this.fromArray(t.elements,3*e)}equals(t){return t.x===this.x&&t.y===this.y&&t.z===this.z}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this.z=t[e+2],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t[e+2]=this.z,t}fromBufferAttribute(t,e,n){return void 0!==n&&console.warn("THREE.Vector3: offset has been removed from .fromBufferAttribute()."),this.x=t.getX(e),this.y=t.getY(e),this.z=t.getZ(e),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this}}Lt.prototype.isVector3=!0;const Rt=new Lt,Ct=new At;class Pt{constructor(t=new Lt(1/0,1/0,1/0),e=new Lt(-1/0,-1/0,-1/0)){this.min=t,this.max=e}set(t,e){return this.min.copy(t),this.max.copy(e),this}setFromArray(t){let e=1/0,n=1/0,i=1/0,r=-1/0,s=-1/0,a=-1/0;for(let o=0,l=t.length;or&&(r=l),c>s&&(s=c),h>a&&(a=h)}return this.min.set(e,n,i),this.max.set(r,s,a),this}setFromBufferAttribute(t){let e=1/0,n=1/0,i=1/0,r=-1/0,s=-1/0,a=-1/0;for(let o=0,l=t.count;or&&(r=l),c>s&&(s=c),h>a&&(a=h)}return this.min.set(e,n,i),this.max.set(r,s,a),this}setFromPoints(t){this.makeEmpty();for(let e=0,n=t.length;ethis.max.x||t.ythis.max.y||t.zthis.max.z)}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y&&this.min.z<=t.min.z&&t.max.z<=this.max.z}getParameter(t,e){return void 0===e&&(console.warn("THREE.Box3: .getParameter() target is now required"),e=new Lt),e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y),(t.z-this.min.z)/(this.max.z-this.min.z))}intersectsBox(t){return!(t.max.xthis.max.x||t.max.ythis.max.y||t.max.zthis.max.z)}intersectsSphere(t){return this.clampPoint(t.center,It),It.distanceToSquared(t.center)<=t.radius*t.radius}intersectsPlane(t){let e,n;return t.normal.x>0?(e=t.normal.x*this.min.x,n=t.normal.x*this.max.x):(e=t.normal.x*this.max.x,n=t.normal.x*this.min.x),t.normal.y>0?(e+=t.normal.y*this.min.y,n+=t.normal.y*this.max.y):(e+=t.normal.y*this.max.y,n+=t.normal.y*this.min.y),t.normal.z>0?(e+=t.normal.z*this.min.z,n+=t.normal.z*this.max.z):(e+=t.normal.z*this.max.z,n+=t.normal.z*this.min.z),e<=-t.constant&&n>=-t.constant}intersectsTriangle(t){if(this.isEmpty())return!1;this.getCenter(Ut),kt.subVectors(this.max,Ut),Bt.subVectors(t.a,Ut),zt.subVectors(t.b,Ut),Ft.subVectors(t.c,Ut),Ot.subVectors(zt,Bt),Ht.subVectors(Ft,zt),Gt.subVectors(Bt,Ft);let e=[0,-Ot.z,Ot.y,0,-Ht.z,Ht.y,0,-Gt.z,Gt.y,Ot.z,0,-Ot.x,Ht.z,0,-Ht.x,Gt.z,0,-Gt.x,-Ot.y,Ot.x,0,-Ht.y,Ht.x,0,-Gt.y,Gt.x,0];return!!jt(e,Bt,zt,Ft,kt)&&(e=[1,0,0,0,1,0,0,0,1],!!jt(e,Bt,zt,Ft,kt)&&(Vt.crossVectors(Ot,Ht),e=[Vt.x,Vt.y,Vt.z],jt(e,Bt,zt,Ft,kt)))}clampPoint(t,e){return void 0===e&&(console.warn("THREE.Box3: .clampPoint() target is now required"),e=new Lt),e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return It.copy(t).clamp(this.min,this.max).sub(t).length()}getBoundingSphere(t){return void 0===t&&console.error("THREE.Box3: .getBoundingSphere() target is now required"),this.getCenter(t.center),t.radius=.5*this.getSize(It).length(),t}intersect(t){return this.min.max(t.min),this.max.min(t.max),this.isEmpty()&&this.makeEmpty(),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}applyMatrix4(t){return this.isEmpty()||(Dt[0].set(this.min.x,this.min.y,this.min.z).applyMatrix4(t),Dt[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(t),Dt[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(t),Dt[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(t),Dt[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(t),Dt[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(t),Dt[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(t),Dt[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(t),this.setFromPoints(Dt)),this}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}}Pt.prototype.isBox3=!0;const Dt=[new Lt,new Lt,new Lt,new Lt,new Lt,new Lt,new Lt,new Lt],It=new Lt,Nt=new Pt,Bt=new Lt,zt=new Lt,Ft=new Lt,Ot=new Lt,Ht=new Lt,Gt=new Lt,Ut=new Lt,kt=new Lt,Vt=new Lt,Wt=new Lt;function jt(t,e,n,i,r){for(let s=0,a=t.length-3;s<=a;s+=3){Wt.fromArray(t,s);const a=r.x*Math.abs(Wt.x)+r.y*Math.abs(Wt.y)+r.z*Math.abs(Wt.z),o=e.dot(Wt),l=n.dot(Wt),c=i.dot(Wt);if(Math.max(-Math.max(o,l,c),Math.min(o,l,c))>a)return!1}return!0}const qt=new Pt,Xt=new Lt,Yt=new Lt,Zt=new Lt;class Jt{constructor(t=new Lt,e=-1){this.center=t,this.radius=e}set(t,e){return this.center.copy(t),this.radius=e,this}setFromPoints(t,e){const n=this.center;void 0!==e?n.copy(e):qt.setFromPoints(t).getCenter(n);let i=0;for(let e=0,r=t.length;ethis.radius*this.radius&&(e.sub(this.center).normalize(),e.multiplyScalar(this.radius).add(this.center)),e}getBoundingBox(t){return void 0===t&&(console.warn("THREE.Sphere: .getBoundingBox() target is now required"),t=new Pt),this.isEmpty()?(t.makeEmpty(),t):(t.set(this.center,this.center),t.expandByScalar(this.radius),t)}applyMatrix4(t){return this.center.applyMatrix4(t),this.radius=this.radius*t.getMaxScaleOnAxis(),this}translate(t){return this.center.add(t),this}expandByPoint(t){Zt.subVectors(t,this.center);const e=Zt.lengthSq();if(e>this.radius*this.radius){const t=Math.sqrt(e),n=.5*(t-this.radius);this.center.add(Zt.multiplyScalar(n/t)),this.radius+=n}return this}union(t){return Yt.subVectors(t.center,this.center).normalize().multiplyScalar(t.radius),this.expandByPoint(Xt.copy(t.center).add(Yt)),this.expandByPoint(Xt.copy(t.center).sub(Yt)),this}equals(t){return t.center.equals(this.center)&&t.radius===this.radius}clone(){return(new this.constructor).copy(this)}}const Qt=new Lt,Kt=new Lt,$t=new Lt,te=new Lt,ee=new Lt,ne=new Lt,ie=new Lt;class re{constructor(t=new Lt,e=new Lt(0,0,-1)){this.origin=t,this.direction=e}set(t,e){return this.origin.copy(t),this.direction.copy(e),this}copy(t){return this.origin.copy(t.origin),this.direction.copy(t.direction),this}at(t,e){return void 0===e&&(console.warn("THREE.Ray: .at() target is now required"),e=new Lt),e.copy(this.direction).multiplyScalar(t).add(this.origin)}lookAt(t){return this.direction.copy(t).sub(this.origin).normalize(),this}recast(t){return this.origin.copy(this.at(t,Qt)),this}closestPointToPoint(t,e){void 0===e&&(console.warn("THREE.Ray: .closestPointToPoint() target is now required"),e=new Lt),e.subVectors(t,this.origin);const n=e.dot(this.direction);return n<0?e.copy(this.origin):e.copy(this.direction).multiplyScalar(n).add(this.origin)}distanceToPoint(t){return Math.sqrt(this.distanceSqToPoint(t))}distanceSqToPoint(t){const e=Qt.subVectors(t,this.origin).dot(this.direction);return e<0?this.origin.distanceToSquared(t):(Qt.copy(this.direction).multiplyScalar(e).add(this.origin),Qt.distanceToSquared(t))}distanceSqToSegment(t,e,n,i){Kt.copy(t).add(e).multiplyScalar(.5),$t.copy(e).sub(t).normalize(),te.copy(this.origin).sub(Kt);const r=.5*t.distanceTo(e),s=-this.direction.dot($t),a=te.dot(this.direction),o=-te.dot($t),l=te.lengthSq(),c=Math.abs(1-s*s);let h,u,d,p;if(c>0)if(h=s*o-a,u=s*a-o,p=r*c,h>=0)if(u>=-p)if(u<=p){const t=1/c;h*=t,u*=t,d=h*(h+s*u+2*a)+u*(s*h+u+2*o)+l}else u=r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u=-r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u<=-p?(h=Math.max(0,-(-s*r+a)),u=h>0?-r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l):u<=p?(h=0,u=Math.min(Math.max(-r,-o),r),d=u*(u+2*o)+l):(h=Math.max(0,-(s*r+a)),u=h>0?r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l);else u=s>0?-r:r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;return n&&n.copy(this.direction).multiplyScalar(h).add(this.origin),i&&i.copy($t).multiplyScalar(u).add(Kt),d}intersectSphere(t,e){Qt.subVectors(t.center,this.origin);const n=Qt.dot(this.direction),i=Qt.dot(Qt)-n*n,r=t.radius*t.radius;if(i>r)return null;const s=Math.sqrt(r-i),a=n-s,o=n+s;return a<0&&o<0?null:a<0?this.at(o,e):this.at(a,e)}intersectsSphere(t){return this.distanceSqToPoint(t.center)<=t.radius*t.radius}distanceToPlane(t){const e=t.normal.dot(this.direction);if(0===e)return 0===t.distanceToPoint(this.origin)?0:null;const n=-(this.origin.dot(t.normal)+t.constant)/e;return n>=0?n:null}intersectPlane(t,e){const n=this.distanceToPlane(t);return null===n?null:this.at(n,e)}intersectsPlane(t){const e=t.distanceToPoint(this.origin);if(0===e)return!0;return t.normal.dot(this.direction)*e<0}intersectBox(t,e){let n,i,r,s,a,o;const l=1/this.direction.x,c=1/this.direction.y,h=1/this.direction.z,u=this.origin;return l>=0?(n=(t.min.x-u.x)*l,i=(t.max.x-u.x)*l):(n=(t.max.x-u.x)*l,i=(t.min.x-u.x)*l),c>=0?(r=(t.min.y-u.y)*c,s=(t.max.y-u.y)*c):(r=(t.max.y-u.y)*c,s=(t.min.y-u.y)*c),n>s||r>i?null:((r>n||n!=n)&&(n=r),(s=0?(a=(t.min.z-u.z)*h,o=(t.max.z-u.z)*h):(a=(t.max.z-u.z)*h,o=(t.min.z-u.z)*h),n>o||a>i?null:((a>n||n!=n)&&(n=a),(o=0?n:i,e)))}intersectsBox(t){return null!==this.intersectBox(t,Qt)}intersectTriangle(t,e,n,i,r){ee.subVectors(e,t),ne.subVectors(n,t),ie.crossVectors(ee,ne);let s,a=this.direction.dot(ie);if(a>0){if(i)return null;s=1}else{if(!(a<0))return null;s=-1,a=-a}te.subVectors(this.origin,t);const o=s*this.direction.dot(ne.crossVectors(te,ne));if(o<0)return null;const l=s*this.direction.dot(ee.cross(te));if(l<0)return null;if(o+l>a)return null;const c=-s*te.dot(ie);return c<0?null:this.at(c/a,r)}applyMatrix4(t){return this.origin.applyMatrix4(t),this.direction.transformDirection(t),this}equals(t){return t.origin.equals(this.origin)&&t.direction.equals(this.direction)}clone(){return(new this.constructor).copy(this)}}class se{constructor(){this.elements=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix4: the constructor no longer reads arguments. use .set() instead.")}set(t,e,n,i,r,s,a,o,l,c,h,u,d,p,m,f){const g=this.elements;return g[0]=t,g[4]=e,g[8]=n,g[12]=i,g[1]=r,g[5]=s,g[9]=a,g[13]=o,g[2]=l,g[6]=c,g[10]=h,g[14]=u,g[3]=d,g[7]=p,g[11]=m,g[15]=f,this}identity(){return this.set(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1),this}clone(){return(new se).fromArray(this.elements)}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],e[9]=n[9],e[10]=n[10],e[11]=n[11],e[12]=n[12],e[13]=n[13],e[14]=n[14],e[15]=n[15],this}copyPosition(t){const e=this.elements,n=t.elements;return e[12]=n[12],e[13]=n[13],e[14]=n[14],this}setFromMatrix3(t){const e=t.elements;return this.set(e[0],e[3],e[6],0,e[1],e[4],e[7],0,e[2],e[5],e[8],0,0,0,0,1),this}extractBasis(t,e,n){return t.setFromMatrixColumn(this,0),e.setFromMatrixColumn(this,1),n.setFromMatrixColumn(this,2),this}makeBasis(t,e,n){return this.set(t.x,e.x,n.x,0,t.y,e.y,n.y,0,t.z,e.z,n.z,0,0,0,0,1),this}extractRotation(t){const e=this.elements,n=t.elements,i=1/ae.setFromMatrixColumn(t,0).length(),r=1/ae.setFromMatrixColumn(t,1).length(),s=1/ae.setFromMatrixColumn(t,2).length();return e[0]=n[0]*i,e[1]=n[1]*i,e[2]=n[2]*i,e[3]=0,e[4]=n[4]*r,e[5]=n[5]*r,e[6]=n[6]*r,e[7]=0,e[8]=n[8]*s,e[9]=n[9]*s,e[10]=n[10]*s,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromEuler(t){t&&t.isEuler||console.error("THREE.Matrix4: .makeRotationFromEuler() now expects a Euler rotation rather than a Vector3 and order.");const e=this.elements,n=t.x,i=t.y,r=t.z,s=Math.cos(n),a=Math.sin(n),o=Math.cos(i),l=Math.sin(i),c=Math.cos(r),h=Math.sin(r);if("XYZ"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=-o*h,e[8]=l,e[1]=n+i*l,e[5]=t-r*l,e[9]=-a*o,e[2]=r-t*l,e[6]=i+n*l,e[10]=s*o}else if("YXZ"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t+r*a,e[4]=i*a-n,e[8]=s*l,e[1]=s*h,e[5]=s*c,e[9]=-a,e[2]=n*a-i,e[6]=r+t*a,e[10]=s*o}else if("ZXY"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t-r*a,e[4]=-s*h,e[8]=i+n*a,e[1]=n+i*a,e[5]=s*c,e[9]=r-t*a,e[2]=-s*l,e[6]=a,e[10]=s*o}else if("ZYX"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=i*l-n,e[8]=t*l+r,e[1]=o*h,e[5]=r*l+t,e[9]=n*l-i,e[2]=-l,e[6]=a*o,e[10]=s*o}else if("YZX"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=r-t*h,e[8]=i*h+n,e[1]=h,e[5]=s*c,e[9]=-a*c,e[2]=-l*c,e[6]=n*h+i,e[10]=t-r*h}else if("XZY"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=-h,e[8]=l*c,e[1]=t*h+r,e[5]=s*c,e[9]=n*h-i,e[2]=i*h-n,e[6]=a*c,e[10]=r*h+t}return e[3]=0,e[7]=0,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromQuaternion(t){return this.compose(le,t,ce)}lookAt(t,e,n){const i=this.elements;return de.subVectors(t,e),0===de.lengthSq()&&(de.z=1),de.normalize(),he.crossVectors(n,de),0===he.lengthSq()&&(1===Math.abs(n.z)?de.x+=1e-4:de.z+=1e-4,de.normalize(),he.crossVectors(n,de)),he.normalize(),ue.crossVectors(de,he),i[0]=he.x,i[4]=ue.x,i[8]=de.x,i[1]=he.y,i[5]=ue.y,i[9]=de.y,i[2]=he.z,i[6]=ue.z,i[10]=de.z,this}multiply(t,e){return void 0!==e?(console.warn("THREE.Matrix4: .multiply() now only accepts one argument. Use .multiplyMatrices( a, b ) instead."),this.multiplyMatrices(t,e)):this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[4],o=n[8],l=n[12],c=n[1],h=n[5],u=n[9],d=n[13],p=n[2],m=n[6],f=n[10],g=n[14],v=n[3],y=n[7],x=n[11],_=n[15],w=i[0],b=i[4],M=i[8],S=i[12],T=i[1],E=i[5],A=i[9],L=i[13],R=i[2],C=i[6],P=i[10],D=i[14],I=i[3],N=i[7],B=i[11],z=i[15];return r[0]=s*w+a*T+o*R+l*I,r[4]=s*b+a*E+o*C+l*N,r[8]=s*M+a*A+o*P+l*B,r[12]=s*S+a*L+o*D+l*z,r[1]=c*w+h*T+u*R+d*I,r[5]=c*b+h*E+u*C+d*N,r[9]=c*M+h*A+u*P+d*B,r[13]=c*S+h*L+u*D+d*z,r[2]=p*w+m*T+f*R+g*I,r[6]=p*b+m*E+f*C+g*N,r[10]=p*M+m*A+f*P+g*B,r[14]=p*S+m*L+f*D+g*z,r[3]=v*w+y*T+x*R+_*I,r[7]=v*b+y*E+x*C+_*N,r[11]=v*M+y*A+x*P+_*B,r[15]=v*S+y*L+x*D+_*z,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[4]*=t,e[8]*=t,e[12]*=t,e[1]*=t,e[5]*=t,e[9]*=t,e[13]*=t,e[2]*=t,e[6]*=t,e[10]*=t,e[14]*=t,e[3]*=t,e[7]*=t,e[11]*=t,e[15]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[4],i=t[8],r=t[12],s=t[1],a=t[5],o=t[9],l=t[13],c=t[2],h=t[6],u=t[10],d=t[14];return t[3]*(+r*o*h-i*l*h-r*a*u+n*l*u+i*a*d-n*o*d)+t[7]*(+e*o*d-e*l*u+r*s*u-i*s*d+i*l*c-r*o*c)+t[11]*(+e*l*h-e*a*d-r*s*h+n*s*d+r*a*c-n*l*c)+t[15]*(-i*a*c-e*o*h+e*a*u+i*s*h-n*s*u+n*o*c)}transpose(){const t=this.elements;let e;return e=t[1],t[1]=t[4],t[4]=e,e=t[2],t[2]=t[8],t[8]=e,e=t[6],t[6]=t[9],t[9]=e,e=t[3],t[3]=t[12],t[12]=e,e=t[7],t[7]=t[13],t[13]=e,e=t[11],t[11]=t[14],t[14]=e,this}setPosition(t,e,n){const i=this.elements;return t.isVector3?(i[12]=t.x,i[13]=t.y,i[14]=t.z):(i[12]=t,i[13]=e,i[14]=n),this}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=t[9],u=t[10],d=t[11],p=t[12],m=t[13],f=t[14],g=t[15],v=h*f*l-m*u*l+m*o*d-a*f*d-h*o*g+a*u*g,y=p*u*l-c*f*l-p*o*d+s*f*d+c*o*g-s*u*g,x=c*m*l-p*h*l+p*a*d-s*m*d-c*a*g+s*h*g,_=p*h*o-c*m*o-p*a*u+s*m*u+c*a*f-s*h*f,w=e*v+n*y+i*x+r*_;if(0===w)return this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);const b=1/w;return t[0]=v*b,t[1]=(m*u*r-h*f*r-m*i*d+n*f*d+h*i*g-n*u*g)*b,t[2]=(a*f*r-m*o*r+m*i*l-n*f*l-a*i*g+n*o*g)*b,t[3]=(h*o*r-a*u*r-h*i*l+n*u*l+a*i*d-n*o*d)*b,t[4]=y*b,t[5]=(c*f*r-p*u*r+p*i*d-e*f*d-c*i*g+e*u*g)*b,t[6]=(p*o*r-s*f*r-p*i*l+e*f*l+s*i*g-e*o*g)*b,t[7]=(s*u*r-c*o*r+c*i*l-e*u*l-s*i*d+e*o*d)*b,t[8]=x*b,t[9]=(p*h*r-c*m*r-p*n*d+e*m*d+c*n*g-e*h*g)*b,t[10]=(s*m*r-p*a*r+p*n*l-e*m*l-s*n*g+e*a*g)*b,t[11]=(c*a*r-s*h*r-c*n*l+e*h*l+s*n*d-e*a*d)*b,t[12]=_*b,t[13]=(c*m*i-p*h*i+p*n*u-e*m*u-c*n*f+e*h*f)*b,t[14]=(p*a*i-s*m*i-p*n*o+e*m*o+s*n*f-e*a*f)*b,t[15]=(s*h*i-c*a*i+c*n*o-e*h*o-s*n*u+e*a*u)*b,this}scale(t){const e=this.elements,n=t.x,i=t.y,r=t.z;return e[0]*=n,e[4]*=i,e[8]*=r,e[1]*=n,e[5]*=i,e[9]*=r,e[2]*=n,e[6]*=i,e[10]*=r,e[3]*=n,e[7]*=i,e[11]*=r,this}getMaxScaleOnAxis(){const t=this.elements,e=t[0]*t[0]+t[1]*t[1]+t[2]*t[2],n=t[4]*t[4]+t[5]*t[5]+t[6]*t[6],i=t[8]*t[8]+t[9]*t[9]+t[10]*t[10];return Math.sqrt(Math.max(e,n,i))}makeTranslation(t,e,n){return this.set(1,0,0,t,0,1,0,e,0,0,1,n,0,0,0,1),this}makeRotationX(t){const e=Math.cos(t),n=Math.sin(t);return this.set(1,0,0,0,0,e,-n,0,0,n,e,0,0,0,0,1),this}makeRotationY(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,0,n,0,0,1,0,0,-n,0,e,0,0,0,0,1),this}makeRotationZ(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,-n,0,0,n,e,0,0,0,0,1,0,0,0,0,1),this}makeRotationAxis(t,e){const n=Math.cos(e),i=Math.sin(e),r=1-n,s=t.x,a=t.y,o=t.z,l=r*s,c=r*a;return this.set(l*s+n,l*a-i*o,l*o+i*a,0,l*a+i*o,c*a+n,c*o-i*s,0,l*o-i*a,c*o+i*s,r*o*o+n,0,0,0,0,1),this}makeScale(t,e,n){return this.set(t,0,0,0,0,e,0,0,0,0,n,0,0,0,0,1),this}makeShear(t,e,n){return this.set(1,e,n,0,t,1,n,0,t,e,1,0,0,0,0,1),this}compose(t,e,n){const i=this.elements,r=e._x,s=e._y,a=e._z,o=e._w,l=r+r,c=s+s,h=a+a,u=r*l,d=r*c,p=r*h,m=s*c,f=s*h,g=a*h,v=o*l,y=o*c,x=o*h,_=n.x,w=n.y,b=n.z;return i[0]=(1-(m+g))*_,i[1]=(d+x)*_,i[2]=(p-y)*_,i[3]=0,i[4]=(d-x)*w,i[5]=(1-(u+g))*w,i[6]=(f+v)*w,i[7]=0,i[8]=(p+y)*b,i[9]=(f-v)*b,i[10]=(1-(u+m))*b,i[11]=0,i[12]=t.x,i[13]=t.y,i[14]=t.z,i[15]=1,this}decompose(t,e,n){const i=this.elements;let r=ae.set(i[0],i[1],i[2]).length();const s=ae.set(i[4],i[5],i[6]).length(),a=ae.set(i[8],i[9],i[10]).length();this.determinant()<0&&(r=-r),t.x=i[12],t.y=i[13],t.z=i[14],oe.copy(this);const o=1/r,l=1/s,c=1/a;return oe.elements[0]*=o,oe.elements[1]*=o,oe.elements[2]*=o,oe.elements[4]*=l,oe.elements[5]*=l,oe.elements[6]*=l,oe.elements[8]*=c,oe.elements[9]*=c,oe.elements[10]*=c,e.setFromRotationMatrix(oe),n.x=r,n.y=s,n.z=a,this}makePerspective(t,e,n,i,r,s){void 0===s&&console.warn("THREE.Matrix4: .makePerspective() has been redefined and has a new signature. Please check the docs.");const a=this.elements,o=2*r/(e-t),l=2*r/(n-i),c=(e+t)/(e-t),h=(n+i)/(n-i),u=-(s+r)/(s-r),d=-2*s*r/(s-r);return a[0]=o,a[4]=0,a[8]=c,a[12]=0,a[1]=0,a[5]=l,a[9]=h,a[13]=0,a[2]=0,a[6]=0,a[10]=u,a[14]=d,a[3]=0,a[7]=0,a[11]=-1,a[15]=0,this}makeOrthographic(t,e,n,i,r,s){const a=this.elements,o=1/(e-t),l=1/(n-i),c=1/(s-r),h=(e+t)*o,u=(n+i)*l,d=(s+r)*c;return a[0]=2*o,a[4]=0,a[8]=0,a[12]=-h,a[1]=0,a[5]=2*l,a[9]=0,a[13]=-u,a[2]=0,a[6]=0,a[10]=-2*c,a[14]=-d,a[3]=0,a[7]=0,a[11]=0,a[15]=1,this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<16;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<16;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t[e+9]=n[9],t[e+10]=n[10],t[e+11]=n[11],t[e+12]=n[12],t[e+13]=n[13],t[e+14]=n[14],t[e+15]=n[15],t}}se.prototype.isMatrix4=!0;const ae=new Lt,oe=new se,le=new Lt(0,0,0),ce=new Lt(1,1,1),he=new Lt,ue=new Lt,de=new Lt,pe=new se,me=new At;class fe{constructor(t=0,e=0,n=0,i=fe.DefaultOrder){this._x=t,this._y=e,this._z=n,this._order=i}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get order(){return this._order}set order(t){this._order=t,this._onChangeCallback()}set(t,e,n,i){return this._x=t,this._y=e,this._z=n,this._order=i||this._order,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._order)}copy(t){return this._x=t._x,this._y=t._y,this._z=t._z,this._order=t._order,this._onChangeCallback(),this}setFromRotationMatrix(t,e,n){const i=t.elements,r=i[0],s=i[4],a=i[8],o=i[1],l=i[5],c=i[9],h=i[2],u=i[6],d=i[10];switch(e=e||this._order){case"XYZ":this._y=Math.asin(ht(a,-1,1)),Math.abs(a)<.9999999?(this._x=Math.atan2(-c,d),this._z=Math.atan2(-s,r)):(this._x=Math.atan2(u,l),this._z=0);break;case"YXZ":this._x=Math.asin(-ht(c,-1,1)),Math.abs(c)<.9999999?(this._y=Math.atan2(a,d),this._z=Math.atan2(o,l)):(this._y=Math.atan2(-h,r),this._z=0);break;case"ZXY":this._x=Math.asin(ht(u,-1,1)),Math.abs(u)<.9999999?(this._y=Math.atan2(-h,d),this._z=Math.atan2(-s,l)):(this._y=0,this._z=Math.atan2(o,r));break;case"ZYX":this._y=Math.asin(-ht(h,-1,1)),Math.abs(h)<.9999999?(this._x=Math.atan2(u,d),this._z=Math.atan2(o,r)):(this._x=0,this._z=Math.atan2(-s,l));break;case"YZX":this._z=Math.asin(ht(o,-1,1)),Math.abs(o)<.9999999?(this._x=Math.atan2(-c,l),this._y=Math.atan2(-h,r)):(this._x=0,this._y=Math.atan2(a,d));break;case"XZY":this._z=Math.asin(-ht(s,-1,1)),Math.abs(s)<.9999999?(this._x=Math.atan2(u,l),this._y=Math.atan2(a,r)):(this._x=Math.atan2(-c,d),this._y=0);break;default:console.warn("THREE.Euler: .setFromRotationMatrix() encountered an unknown order: "+e)}return this._order=e,!1!==n&&this._onChangeCallback(),this}setFromQuaternion(t,e,n){return pe.makeRotationFromQuaternion(t),this.setFromRotationMatrix(pe,e,n)}setFromVector3(t,e){return this.set(t.x,t.y,t.z,e||this._order)}reorder(t){return me.setFromEuler(this),this.setFromQuaternion(me,t)}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._order===this._order}fromArray(t){return this._x=t[0],this._y=t[1],this._z=t[2],void 0!==t[3]&&(this._order=t[3]),this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._order,t}toVector3(t){return t?t.set(this._x,this._y,this._z):new Lt(this._x,this._y,this._z)}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}}fe.prototype.isEuler=!0,fe.DefaultOrder="XYZ",fe.RotationOrders=["XYZ","YZX","ZXY","XZY","YXZ","ZYX"];class ge{constructor(){this.mask=1}set(t){this.mask=1<1){for(let t=0;t1){for(let t=0;t0){i.children=[];for(let e=0;e0){i.animations=[];for(let e=0;e0&&(n.geometries=e),i.length>0&&(n.materials=i),r.length>0&&(n.textures=r),a.length>0&&(n.images=a),o.length>0&&(n.shapes=o),l.length>0&&(n.skeletons=l),c.length>0&&(n.animations=c)}return n.object=i,n;function s(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}}clone(t){return(new this.constructor).copy(this,t)}copy(t,e=!0){if(this.name=t.name,this.up.copy(t.up),this.position.copy(t.position),this.rotation.order=t.rotation.order,this.quaternion.copy(t.quaternion),this.scale.copy(t.scale),this.matrix.copy(t.matrix),this.matrixWorld.copy(t.matrixWorld),this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrixWorldNeedsUpdate=t.matrixWorldNeedsUpdate,this.layers.mask=t.layers.mask,this.visible=t.visible,this.castShadow=t.castShadow,this.receiveShadow=t.receiveShadow,this.frustumCulled=t.frustumCulled,this.renderOrder=t.renderOrder,this.userData=JSON.parse(JSON.stringify(t.userData)),!0===e)for(let e=0;e1?null:e.copy(n).multiplyScalar(r).add(t.start)}intersectsLine(t){const e=this.distanceToPoint(t.start),n=this.distanceToPoint(t.end);return e<0&&n>0||n<0&&e>0}intersectsBox(t){return t.intersectsPlane(this)}intersectsSphere(t){return t.intersectsPlane(this)}coplanarPoint(t){return void 0===t&&(console.warn("THREE.Plane: .coplanarPoint() target is now required"),t=new Lt),t.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(t,e){const n=e||Ie.getNormalMatrix(t),i=this.coplanarPoint(Pe).applyMatrix4(t),r=this.normal.applyMatrix3(n).normalize();return this.constant=-i.dot(r),this}translate(t){return this.constant-=t.dot(this.normal),this}equals(t){return t.normal.equals(this.normal)&&t.constant===this.constant}clone(){return(new this.constructor).copy(this)}}Ne.prototype.isPlane=!0;const Be=new Lt,ze=new Lt,Fe=new Lt,Oe=new Lt,He=new Lt,Ge=new Lt,Ue=new Lt,ke=new Lt,Ve=new Lt,We=new Lt;class je{constructor(t=new Lt,e=new Lt,n=new Lt){this.a=t,this.b=e,this.c=n}static getNormal(t,e,n,i){void 0===i&&(console.warn("THREE.Triangle: .getNormal() target is now required"),i=new Lt),i.subVectors(n,e),Be.subVectors(t,e),i.cross(Be);const r=i.lengthSq();return r>0?i.multiplyScalar(1/Math.sqrt(r)):i.set(0,0,0)}static getBarycoord(t,e,n,i,r){Be.subVectors(i,e),ze.subVectors(n,e),Fe.subVectors(t,e);const s=Be.dot(Be),a=Be.dot(ze),o=Be.dot(Fe),l=ze.dot(ze),c=ze.dot(Fe),h=s*l-a*a;if(void 0===r&&(console.warn("THREE.Triangle: .getBarycoord() target is now required"),r=new Lt),0===h)return r.set(-2,-1,-1);const u=1/h,d=(l*o-a*c)*u,p=(s*c-a*o)*u;return r.set(1-d-p,p,d)}static containsPoint(t,e,n,i){return this.getBarycoord(t,e,n,i,Oe),Oe.x>=0&&Oe.y>=0&&Oe.x+Oe.y<=1}static getUV(t,e,n,i,r,s,a,o){return this.getBarycoord(t,e,n,i,Oe),o.set(0,0),o.addScaledVector(r,Oe.x),o.addScaledVector(s,Oe.y),o.addScaledVector(a,Oe.z),o}static isFrontFacing(t,e,n,i){return Be.subVectors(n,e),ze.subVectors(t,e),Be.cross(ze).dot(i)<0}set(t,e,n){return this.a.copy(t),this.b.copy(e),this.c.copy(n),this}setFromPointsAndIndices(t,e,n,i){return this.a.copy(t[e]),this.b.copy(t[n]),this.c.copy(t[i]),this}clone(){return(new this.constructor).copy(this)}copy(t){return this.a.copy(t.a),this.b.copy(t.b),this.c.copy(t.c),this}getArea(){return Be.subVectors(this.c,this.b),ze.subVectors(this.a,this.b),.5*Be.cross(ze).length()}getMidpoint(t){return void 0===t&&(console.warn("THREE.Triangle: .getMidpoint() target is now required"),t=new Lt),t.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)}getNormal(t){return je.getNormal(this.a,this.b,this.c,t)}getPlane(t){return void 0===t&&(console.warn("THREE.Triangle: .getPlane() target is now required"),t=new Ne),t.setFromCoplanarPoints(this.a,this.b,this.c)}getBarycoord(t,e){return je.getBarycoord(t,this.a,this.b,this.c,e)}getUV(t,e,n,i,r){return je.getUV(t,this.a,this.b,this.c,e,n,i,r)}containsPoint(t){return je.containsPoint(t,this.a,this.b,this.c)}isFrontFacing(t){return je.isFrontFacing(this.a,this.b,this.c,t)}intersectsBox(t){return t.intersectsTriangle(this)}closestPointToPoint(t,e){void 0===e&&(console.warn("THREE.Triangle: .closestPointToPoint() target is now required"),e=new Lt);const n=this.a,i=this.b,r=this.c;let s,a;He.subVectors(i,n),Ge.subVectors(r,n),ke.subVectors(t,n);const o=He.dot(ke),l=Ge.dot(ke);if(o<=0&&l<=0)return e.copy(n);Ve.subVectors(t,i);const c=He.dot(Ve),h=Ge.dot(Ve);if(c>=0&&h<=c)return e.copy(i);const u=o*h-c*l;if(u<=0&&o>=0&&c<=0)return s=o/(o-c),e.copy(n).addScaledVector(He,s);We.subVectors(t,r);const d=He.dot(We),p=Ge.dot(We);if(p>=0&&d<=p)return e.copy(r);const m=d*l-o*p;if(m<=0&&l>=0&&p<=0)return a=l/(l-p),e.copy(n).addScaledVector(Ge,a);const f=c*p-d*h;if(f<=0&&h-c>=0&&d-p>=0)return Ue.subVectors(r,i),a=(h-c)/(h-c+(d-p)),e.copy(i).addScaledVector(Ue,a);const g=1/(f+m+u);return s=m*g,a=u*g,e.copy(n).addScaledVector(He,s).addScaledVector(Ge,a)}equals(t){return t.a.equals(this.a)&&t.b.equals(this.b)&&t.c.equals(this.c)}}let qe=0;function Xe(){Object.defineProperty(this,"id",{value:qe++}),this.uuid=ct(),this.name="",this.type="Material",this.fog=!0,this.blending=1,this.side=0,this.vertexColors=!1,this.opacity=1,this.transparent=!1,this.blendSrc=204,this.blendDst=205,this.blendEquation=n,this.blendSrcAlpha=null,this.blendDstAlpha=null,this.blendEquationAlpha=null,this.depthFunc=3,this.depthTest=!0,this.depthWrite=!0,this.stencilWriteMask=255,this.stencilFunc=519,this.stencilRef=0,this.stencilFuncMask=255,this.stencilFail=tt,this.stencilZFail=tt,this.stencilZPass=tt,this.stencilWrite=!1,this.clippingPlanes=null,this.clipIntersection=!1,this.clipShadows=!1,this.shadowSide=null,this.colorWrite=!0,this.precision=null,this.polygonOffset=!1,this.polygonOffsetFactor=0,this.polygonOffsetUnits=0,this.dithering=!1,this.alphaTest=0,this.alphaToCoverage=!1,this.premultipliedAlpha=!1,this.visible=!0,this.toneMapped=!0,this.userData={},this.version=0}Xe.prototype=Object.assign(Object.create(rt.prototype),{constructor:Xe,isMaterial:!0,onBuild:function(){},onBeforeCompile:function(){},customProgramCacheKey:function(){return this.onBeforeCompile.toString()},setValues:function(t){if(void 0!==t)for(const e in t){const n=t[e];if(void 0===n){console.warn("THREE.Material: '"+e+"' parameter is undefined.");continue}if("shading"===e){console.warn("THREE."+this.type+": .shading has been removed. Use the boolean .flatShading instead."),this.flatShading=1===n;continue}const i=this[e];void 0!==i?i&&i.isColor?i.set(n):i&&i.isVector3&&n&&n.isVector3?i.copy(n):this[e]=n:console.warn("THREE."+this.type+": '"+e+"' is not a property of this material.")}},toJSON:function(t){const e=void 0===t||"string"==typeof t;e&&(t={textures:{},images:{}});const n={metadata:{version:4.5,type:"Material",generator:"Material.toJSON"}};function i(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}if(n.uuid=this.uuid,n.type=this.type,""!==this.name&&(n.name=this.name),this.color&&this.color.isColor&&(n.color=this.color.getHex()),void 0!==this.roughness&&(n.roughness=this.roughness),void 0!==this.metalness&&(n.metalness=this.metalness),this.sheen&&this.sheen.isColor&&(n.sheen=this.sheen.getHex()),this.emissive&&this.emissive.isColor&&(n.emissive=this.emissive.getHex()),this.emissiveIntensity&&1!==this.emissiveIntensity&&(n.emissiveIntensity=this.emissiveIntensity),this.specular&&this.specular.isColor&&(n.specular=this.specular.getHex()),void 0!==this.shininess&&(n.shininess=this.shininess),void 0!==this.clearcoat&&(n.clearcoat=this.clearcoat),void 0!==this.clearcoatRoughness&&(n.clearcoatRoughness=this.clearcoatRoughness),this.clearcoatMap&&this.clearcoatMap.isTexture&&(n.clearcoatMap=this.clearcoatMap.toJSON(t).uuid),this.clearcoatRoughnessMap&&this.clearcoatRoughnessMap.isTexture&&(n.clearcoatRoughnessMap=this.clearcoatRoughnessMap.toJSON(t).uuid),this.clearcoatNormalMap&&this.clearcoatNormalMap.isTexture&&(n.clearcoatNormalMap=this.clearcoatNormalMap.toJSON(t).uuid,n.clearcoatNormalScale=this.clearcoatNormalScale.toArray()),this.map&&this.map.isTexture&&(n.map=this.map.toJSON(t).uuid),this.matcap&&this.matcap.isTexture&&(n.matcap=this.matcap.toJSON(t).uuid),this.alphaMap&&this.alphaMap.isTexture&&(n.alphaMap=this.alphaMap.toJSON(t).uuid),this.lightMap&&this.lightMap.isTexture&&(n.lightMap=this.lightMap.toJSON(t).uuid,n.lightMapIntensity=this.lightMapIntensity),this.aoMap&&this.aoMap.isTexture&&(n.aoMap=this.aoMap.toJSON(t).uuid,n.aoMapIntensity=this.aoMapIntensity),this.bumpMap&&this.bumpMap.isTexture&&(n.bumpMap=this.bumpMap.toJSON(t).uuid,n.bumpScale=this.bumpScale),this.normalMap&&this.normalMap.isTexture&&(n.normalMap=this.normalMap.toJSON(t).uuid,n.normalMapType=this.normalMapType,n.normalScale=this.normalScale.toArray()),this.displacementMap&&this.displacementMap.isTexture&&(n.displacementMap=this.displacementMap.toJSON(t).uuid,n.displacementScale=this.displacementScale,n.displacementBias=this.displacementBias),this.roughnessMap&&this.roughnessMap.isTexture&&(n.roughnessMap=this.roughnessMap.toJSON(t).uuid),this.metalnessMap&&this.metalnessMap.isTexture&&(n.metalnessMap=this.metalnessMap.toJSON(t).uuid),this.emissiveMap&&this.emissiveMap.isTexture&&(n.emissiveMap=this.emissiveMap.toJSON(t).uuid),this.specularMap&&this.specularMap.isTexture&&(n.specularMap=this.specularMap.toJSON(t).uuid),this.envMap&&this.envMap.isTexture&&(n.envMap=this.envMap.toJSON(t).uuid,void 0!==this.combine&&(n.combine=this.combine)),void 0!==this.envMapIntensity&&(n.envMapIntensity=this.envMapIntensity),void 0!==this.reflectivity&&(n.reflectivity=this.reflectivity),void 0!==this.refractionRatio&&(n.refractionRatio=this.refractionRatio),this.gradientMap&&this.gradientMap.isTexture&&(n.gradientMap=this.gradientMap.toJSON(t).uuid),void 0!==this.size&&(n.size=this.size),null!==this.shadowSide&&(n.shadowSide=this.shadowSide),void 0!==this.sizeAttenuation&&(n.sizeAttenuation=this.sizeAttenuation),1!==this.blending&&(n.blending=this.blending),0!==this.side&&(n.side=this.side),this.vertexColors&&(n.vertexColors=!0),this.opacity<1&&(n.opacity=this.opacity),!0===this.transparent&&(n.transparent=this.transparent),n.depthFunc=this.depthFunc,n.depthTest=this.depthTest,n.depthWrite=this.depthWrite,n.colorWrite=this.colorWrite,n.stencilWrite=this.stencilWrite,n.stencilWriteMask=this.stencilWriteMask,n.stencilFunc=this.stencilFunc,n.stencilRef=this.stencilRef,n.stencilFuncMask=this.stencilFuncMask,n.stencilFail=this.stencilFail,n.stencilZFail=this.stencilZFail,n.stencilZPass=this.stencilZPass,this.rotation&&0!==this.rotation&&(n.rotation=this.rotation),!0===this.polygonOffset&&(n.polygonOffset=!0),0!==this.polygonOffsetFactor&&(n.polygonOffsetFactor=this.polygonOffsetFactor),0!==this.polygonOffsetUnits&&(n.polygonOffsetUnits=this.polygonOffsetUnits),this.linewidth&&1!==this.linewidth&&(n.linewidth=this.linewidth),void 0!==this.dashSize&&(n.dashSize=this.dashSize),void 0!==this.gapSize&&(n.gapSize=this.gapSize),void 0!==this.scale&&(n.scale=this.scale),!0===this.dithering&&(n.dithering=!0),this.alphaTest>0&&(n.alphaTest=this.alphaTest),!0===this.alphaToCoverage&&(n.alphaToCoverage=this.alphaToCoverage),!0===this.premultipliedAlpha&&(n.premultipliedAlpha=this.premultipliedAlpha),!0===this.wireframe&&(n.wireframe=this.wireframe),this.wireframeLinewidth>1&&(n.wireframeLinewidth=this.wireframeLinewidth),"round"!==this.wireframeLinecap&&(n.wireframeLinecap=this.wireframeLinecap),"round"!==this.wireframeLinejoin&&(n.wireframeLinejoin=this.wireframeLinejoin),!0===this.morphTargets&&(n.morphTargets=!0),!0===this.morphNormals&&(n.morphNormals=!0),!0===this.skinning&&(n.skinning=!0),!0===this.flatShading&&(n.flatShading=this.flatShading),!1===this.visible&&(n.visible=!1),!1===this.toneMapped&&(n.toneMapped=!1),"{}"!==JSON.stringify(this.userData)&&(n.userData=this.userData),e){const e=i(t.textures),r=i(t.images);e.length>0&&(n.textures=e),r.length>0&&(n.images=r)}return n},clone:function(){return(new this.constructor).copy(this)},copy:function(t){this.name=t.name,this.fog=t.fog,this.blending=t.blending,this.side=t.side,this.vertexColors=t.vertexColors,this.opacity=t.opacity,this.transparent=t.transparent,this.blendSrc=t.blendSrc,this.blendDst=t.blendDst,this.blendEquation=t.blendEquation,this.blendSrcAlpha=t.blendSrcAlpha,this.blendDstAlpha=t.blendDstAlpha,this.blendEquationAlpha=t.blendEquationAlpha,this.depthFunc=t.depthFunc,this.depthTest=t.depthTest,this.depthWrite=t.depthWrite,this.stencilWriteMask=t.stencilWriteMask,this.stencilFunc=t.stencilFunc,this.stencilRef=t.stencilRef,this.stencilFuncMask=t.stencilFuncMask,this.stencilFail=t.stencilFail,this.stencilZFail=t.stencilZFail,this.stencilZPass=t.stencilZPass,this.stencilWrite=t.stencilWrite;const e=t.clippingPlanes;let n=null;if(null!==e){const t=e.length;n=new Array(t);for(let i=0;i!==t;++i)n[i]=e[i].clone()}return this.clippingPlanes=n,this.clipIntersection=t.clipIntersection,this.clipShadows=t.clipShadows,this.shadowSide=t.shadowSide,this.colorWrite=t.colorWrite,this.precision=t.precision,this.polygonOffset=t.polygonOffset,this.polygonOffsetFactor=t.polygonOffsetFactor,this.polygonOffsetUnits=t.polygonOffsetUnits,this.dithering=t.dithering,this.alphaTest=t.alphaTest,this.alphaToCoverage=t.alphaToCoverage,this.premultipliedAlpha=t.premultipliedAlpha,this.visible=t.visible,this.toneMapped=t.toneMapped,this.userData=JSON.parse(JSON.stringify(t.userData)),this},dispose:function(){this.dispatchEvent({type:"dispose"})}}),Object.defineProperty(Xe.prototype,"needsUpdate",{set:function(t){!0===t&&this.version++}});const Ye={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074},Ze={h:0,s:0,l:0},Je={h:0,s:0,l:0};function Qe(t,e,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?t+6*(e-t)*n:n<.5?e:n<2/3?t+6*(e-t)*(2/3-n):t}function Ke(t){return t<.04045?.0773993808*t:Math.pow(.9478672986*t+.0521327014,2.4)}function $e(t){return t<.0031308?12.92*t:1.055*Math.pow(t,.41666)-.055}class tn{constructor(t,e,n){return void 0===e&&void 0===n?this.set(t):this.setRGB(t,e,n)}set(t){return t&&t.isColor?this.copy(t):"number"==typeof t?this.setHex(t):"string"==typeof t&&this.setStyle(t),this}setScalar(t){return this.r=t,this.g=t,this.b=t,this}setHex(t){return t=Math.floor(t),this.r=(t>>16&255)/255,this.g=(t>>8&255)/255,this.b=(255&t)/255,this}setRGB(t,e,n){return this.r=t,this.g=e,this.b=n,this}setHSL(t,e,n){if(t=ut(t,1),e=ht(e,0,1),n=ht(n,0,1),0===e)this.r=this.g=this.b=n;else{const i=n<=.5?n*(1+e):n+e-n*e,r=2*n-i;this.r=Qe(r,i,t+1/3),this.g=Qe(r,i,t),this.b=Qe(r,i,t-1/3)}return this}setStyle(t){function e(e){void 0!==e&&parseFloat(e)<1&&console.warn("THREE.Color: Alpha component of "+t+" will be ignored.")}let n;if(n=/^((?:rgb|hsl)a?)\(([^\)]*)\)/.exec(t)){let t;const i=n[1],r=n[2];switch(i){case"rgb":case"rgba":if(t=/^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r))return this.r=Math.min(255,parseInt(t[1],10))/255,this.g=Math.min(255,parseInt(t[2],10))/255,this.b=Math.min(255,parseInt(t[3],10))/255,e(t[4]),this;if(t=/^\s*(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r))return this.r=Math.min(100,parseInt(t[1],10))/100,this.g=Math.min(100,parseInt(t[2],10))/100,this.b=Math.min(100,parseInt(t[3],10))/100,e(t[4]),this;break;case"hsl":case"hsla":if(t=/^\s*(\d*\.?\d+)\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r)){const n=parseFloat(t[1])/360,i=parseInt(t[2],10)/100,r=parseInt(t[3],10)/100;return e(t[4]),this.setHSL(n,i,r)}}}else if(n=/^\#([A-Fa-f\d]+)$/.exec(t)){const t=n[1],e=t.length;if(3===e)return this.r=parseInt(t.charAt(0)+t.charAt(0),16)/255,this.g=parseInt(t.charAt(1)+t.charAt(1),16)/255,this.b=parseInt(t.charAt(2)+t.charAt(2),16)/255,this;if(6===e)return this.r=parseInt(t.charAt(0)+t.charAt(1),16)/255,this.g=parseInt(t.charAt(2)+t.charAt(3),16)/255,this.b=parseInt(t.charAt(4)+t.charAt(5),16)/255,this}return t&&t.length>0?this.setColorName(t):this}setColorName(t){const e=Ye[t.toLowerCase()];return void 0!==e?this.setHex(e):console.warn("THREE.Color: Unknown color "+t),this}clone(){return new this.constructor(this.r,this.g,this.b)}copy(t){return this.r=t.r,this.g=t.g,this.b=t.b,this}copyGammaToLinear(t,e=2){return this.r=Math.pow(t.r,e),this.g=Math.pow(t.g,e),this.b=Math.pow(t.b,e),this}copyLinearToGamma(t,e=2){const n=e>0?1/e:1;return this.r=Math.pow(t.r,n),this.g=Math.pow(t.g,n),this.b=Math.pow(t.b,n),this}convertGammaToLinear(t){return this.copyGammaToLinear(this,t),this}convertLinearToGamma(t){return this.copyLinearToGamma(this,t),this}copySRGBToLinear(t){return this.r=Ke(t.r),this.g=Ke(t.g),this.b=Ke(t.b),this}copyLinearToSRGB(t){return this.r=$e(t.r),this.g=$e(t.g),this.b=$e(t.b),this}convertSRGBToLinear(){return this.copySRGBToLinear(this),this}convertLinearToSRGB(){return this.copyLinearToSRGB(this),this}getHex(){return 255*this.r<<16^255*this.g<<8^255*this.b<<0}getHexString(){return("000000"+this.getHex().toString(16)).slice(-6)}getHSL(t){void 0===t&&(console.warn("THREE.Color: .getHSL() target is now required"),t={h:0,s:0,l:0});const e=this.r,n=this.g,i=this.b,r=Math.max(e,n,i),s=Math.min(e,n,i);let a,o;const l=(s+r)/2;if(s===r)a=0,o=0;else{const t=r-s;switch(o=l<=.5?t/(r+s):t/(2-r-s),r){case e:a=(n-i)/t+(ne&&(e=t[n]);return e}const vn={Int8Array:Int8Array,Uint8Array:Uint8Array,Uint8ClampedArray:Uint8ClampedArray,Int16Array:Int16Array,Uint16Array:Uint16Array,Int32Array:Int32Array,Uint32Array:Uint32Array,Float32Array:Float32Array,Float64Array:Float64Array};function yn(t,e){return new vn[t](e)}let xn=0;const _n=new se,wn=new Ce,bn=new Lt,Mn=new Pt,Sn=new Pt,Tn=new Lt;class En extends rt{constructor(){super(),Object.defineProperty(this,"id",{value:xn++}),this.uuid=ct(),this.name="",this.type="BufferGeometry",this.index=null,this.attributes={},this.morphAttributes={},this.morphTargetsRelative=!1,this.groups=[],this.boundingBox=null,this.boundingSphere=null,this.drawRange={start:0,count:1/0},this.userData={}}getIndex(){return this.index}setIndex(t){return Array.isArray(t)?this.index=new(gn(t)>65535?dn:hn)(t,1):this.index=t,this}getAttribute(t){return this.attributes[t]}setAttribute(t,e){return this.attributes[t]=e,this}deleteAttribute(t){return delete this.attributes[t],this}hasAttribute(t){return void 0!==this.attributes[t]}addGroup(t,e,n=0){this.groups.push({start:t,count:e,materialIndex:n})}clearGroups(){this.groups=[]}setDrawRange(t,e){this.drawRange.start=t,this.drawRange.count=e}applyMatrix4(t){const e=this.attributes.position;void 0!==e&&(e.applyMatrix4(t),e.needsUpdate=!0);const n=this.attributes.normal;if(void 0!==n){const e=(new yt).getNormalMatrix(t);n.applyNormalMatrix(e),n.needsUpdate=!0}const i=this.attributes.tangent;return void 0!==i&&(i.transformDirection(t),i.needsUpdate=!0),null!==this.boundingBox&&this.computeBoundingBox(),null!==this.boundingSphere&&this.computeBoundingSphere(),this}rotateX(t){return _n.makeRotationX(t),this.applyMatrix4(_n),this}rotateY(t){return _n.makeRotationY(t),this.applyMatrix4(_n),this}rotateZ(t){return _n.makeRotationZ(t),this.applyMatrix4(_n),this}translate(t,e,n){return _n.makeTranslation(t,e,n),this.applyMatrix4(_n),this}scale(t,e,n){return _n.makeScale(t,e,n),this.applyMatrix4(_n),this}lookAt(t){return wn.lookAt(t),wn.updateMatrix(),this.applyMatrix4(wn.matrix),this}center(){return this.computeBoundingBox(),this.boundingBox.getCenter(bn).negate(),this.translate(bn.x,bn.y,bn.z),this}setFromPoints(t){const e=[];for(let n=0,i=t.length;n0&&(t.userData=this.userData),void 0!==this.parameters){const e=this.parameters;for(const n in e)void 0!==e[n]&&(t[n]=e[n]);return t}t.data={attributes:{}};const e=this.index;null!==e&&(t.data.index={type:e.array.constructor.name,array:Array.prototype.slice.call(e.array)});const n=this.attributes;for(const e in n){const i=n[e];t.data.attributes[e]=i.toJSON(t.data)}const i={};let r=!1;for(const e in this.morphAttributes){const n=this.morphAttributes[e],s=[];for(let e=0,i=n.length;e0&&(i[e]=s,r=!0)}r&&(t.data.morphAttributes=i,t.data.morphTargetsRelative=this.morphTargetsRelative);const s=this.groups;s.length>0&&(t.data.groups=JSON.parse(JSON.stringify(s)));const a=this.boundingSphere;return null!==a&&(t.data.boundingSphere={center:a.center.toArray(),radius:a.radius}),t}clone(){return(new En).copy(this)}copy(t){this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null;const e={};this.name=t.name;const n=t.index;null!==n&&this.setIndex(n.clone(e));const i=t.attributes;for(const t in i){const n=i[t];this.setAttribute(t,n.clone(e))}const r=t.morphAttributes;for(const t in r){const n=[],i=r[t];for(let t=0,r=i.length;t0){const t=e[n[0]];if(void 0!==t){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let e=0,n=t.length;e0&&console.error("THREE.Mesh.updateMorphTargets() no longer supports THREE.Geometry. Use THREE.BufferGeometry instead.")}}raycast(t,e){const n=this.geometry,i=this.material,r=this.matrixWorld;if(void 0===i)return;if(null===n.boundingSphere&&n.computeBoundingSphere(),Rn.copy(n.boundingSphere),Rn.applyMatrix4(r),!1===t.ray.intersectsSphere(Rn))return;if(An.copy(r).invert(),Ln.copy(t.ray).applyMatrix4(An),null!==n.boundingBox&&!1===Ln.intersectsBox(n.boundingBox))return;let s;if(n.isBufferGeometry){const r=n.index,a=n.attributes.position,o=n.morphAttributes.position,l=n.morphTargetsRelative,c=n.attributes.uv,h=n.attributes.uv2,u=n.groups,d=n.drawRange;if(null!==r)if(Array.isArray(i))for(let n=0,p=u.length;nn.far?null:{distance:c,point:Vn.clone(),object:t}}(t,e,n,i,Cn,Pn,Dn,kn);if(p){o&&(Hn.fromBufferAttribute(o,c),Gn.fromBufferAttribute(o,h),Un.fromBufferAttribute(o,u),p.uv=je.getUV(kn,Cn,Pn,Dn,Hn,Gn,Un,new vt)),l&&(Hn.fromBufferAttribute(l,c),Gn.fromBufferAttribute(l,h),Un.fromBufferAttribute(l,u),p.uv2=je.getUV(kn,Cn,Pn,Dn,Hn,Gn,Un,new vt));const t={a:c,b:h,c:u,normal:new Lt,materialIndex:0};je.getNormal(Cn,Pn,Dn,t.normal),p.face=t}return p}Wn.prototype.isMesh=!0;class qn extends En{constructor(t=1,e=1,n=1,i=1,r=1,s=1){super(),this.type="BoxGeometry",this.parameters={width:t,height:e,depth:n,widthSegments:i,heightSegments:r,depthSegments:s};const a=this;i=Math.floor(i),r=Math.floor(r),s=Math.floor(s);const o=[],l=[],c=[],h=[];let u=0,d=0;function p(t,e,n,i,r,s,p,m,f,g,v){const y=s/f,x=p/g,_=s/2,w=p/2,b=m/2,M=f+1,S=g+1;let T=0,E=0;const A=new Lt;for(let s=0;s0?1:-1,c.push(A.x,A.y,A.z),h.push(o/f),h.push(1-s/g),T+=1}}for(let t=0;t0&&(e.defines=this.defines),e.vertexShader=this.vertexShader,e.fragmentShader=this.fragmentShader;const n={};for(const t in this.extensions)!0===this.extensions[t]&&(n[t]=!0);return Object.keys(n).length>0&&(e.extensions=n),e}}Jn.prototype.isShaderMaterial=!0;class Qn extends Ce{constructor(){super(),this.type="Camera",this.matrixWorldInverse=new se,this.projectionMatrix=new se,this.projectionMatrixInverse=new se}copy(t,e){return super.copy(t,e),this.matrixWorldInverse.copy(t.matrixWorldInverse),this.projectionMatrix.copy(t.projectionMatrix),this.projectionMatrixInverse.copy(t.projectionMatrixInverse),this}getWorldDirection(t){void 0===t&&(console.warn("THREE.Camera: .getWorldDirection() target is now required"),t=new Lt),this.updateWorldMatrix(!0,!1);const e=this.matrixWorld.elements;return t.set(-e[8],-e[9],-e[10]).normalize()}updateMatrixWorld(t){super.updateMatrixWorld(t),this.matrixWorldInverse.copy(this.matrixWorld).invert()}updateWorldMatrix(t,e){super.updateWorldMatrix(t,e),this.matrixWorldInverse.copy(this.matrixWorld).invert()}clone(){return(new this.constructor).copy(this)}}Qn.prototype.isCamera=!0;class Kn extends Qn{constructor(t=50,e=1,n=.1,i=2e3){super(),this.type="PerspectiveCamera",this.fov=t,this.zoom=1,this.near=n,this.far=i,this.focus=10,this.aspect=e,this.view=null,this.filmGauge=35,this.filmOffset=0,this.updateProjectionMatrix()}copy(t,e){return super.copy(t,e),this.fov=t.fov,this.zoom=t.zoom,this.near=t.near,this.far=t.far,this.focus=t.focus,this.aspect=t.aspect,this.view=null===t.view?null:Object.assign({},t.view),this.filmGauge=t.filmGauge,this.filmOffset=t.filmOffset,this}setFocalLength(t){const e=.5*this.getFilmHeight()/t;this.fov=2*lt*Math.atan(e),this.updateProjectionMatrix()}getFocalLength(){const t=Math.tan(.5*ot*this.fov);return.5*this.getFilmHeight()/t}getEffectiveFOV(){return 2*lt*Math.atan(Math.tan(.5*ot*this.fov)/this.zoom)}getFilmWidth(){return this.filmGauge*Math.min(this.aspect,1)}getFilmHeight(){return this.filmGauge/Math.max(this.aspect,1)}setViewOffset(t,e,n,i,r,s){this.aspect=t/e,null===this.view&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=t,this.view.fullHeight=e,this.view.offsetX=n,this.view.offsetY=i,this.view.width=r,this.view.height=s,this.updateProjectionMatrix()}clearViewOffset(){null!==this.view&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const t=this.near;let e=t*Math.tan(.5*ot*this.fov)/this.zoom,n=2*e,i=this.aspect*n,r=-.5*i;const s=this.view;if(null!==this.view&&this.view.enabled){const t=s.fullWidth,a=s.fullHeight;r+=s.offsetX*i/t,e-=s.offsetY*n/a,i*=s.width/t,n*=s.height/a}const a=this.filmOffset;0!==a&&(r+=t*a/this.getFilmWidth()),this.projectionMatrix.makePerspective(r,r+i,e,e-n,t,this.far),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(t){const e=super.toJSON(t);return e.object.fov=this.fov,e.object.zoom=this.zoom,e.object.near=this.near,e.object.far=this.far,e.object.focus=this.focus,e.object.aspect=this.aspect,null!==this.view&&(e.object.view=Object.assign({},this.view)),e.object.filmGauge=this.filmGauge,e.object.filmOffset=this.filmOffset,e}}Kn.prototype.isPerspectiveCamera=!0;const $n=90;class ti extends Ce{constructor(t,e,n){if(super(),this.type="CubeCamera",!0!==n.isWebGLCubeRenderTarget)return void console.error("THREE.CubeCamera: The constructor now expects an instance of WebGLCubeRenderTarget as third parameter.");this.renderTarget=n;const i=new Kn($n,1,t,e);i.layers=this.layers,i.up.set(0,-1,0),i.lookAt(new Lt(1,0,0)),this.add(i);const r=new Kn($n,1,t,e);r.layers=this.layers,r.up.set(0,-1,0),r.lookAt(new Lt(-1,0,0)),this.add(r);const s=new Kn($n,1,t,e);s.layers=this.layers,s.up.set(0,0,1),s.lookAt(new Lt(0,1,0)),this.add(s);const a=new Kn($n,1,t,e);a.layers=this.layers,a.up.set(0,0,-1),a.lookAt(new Lt(0,-1,0)),this.add(a);const o=new Kn($n,1,t,e);o.layers=this.layers,o.up.set(0,-1,0),o.lookAt(new Lt(0,0,1)),this.add(o);const l=new Kn($n,1,t,e);l.layers=this.layers,l.up.set(0,-1,0),l.lookAt(new Lt(0,0,-1)),this.add(l)}update(t,e){null===this.parent&&this.updateMatrixWorld();const n=this.renderTarget,[i,r,s,a,o,l]=this.children,c=t.xr.enabled,h=t.getRenderTarget();t.xr.enabled=!1;const u=n.texture.generateMipmaps;n.texture.generateMipmaps=!1,t.setRenderTarget(n,0),t.render(e,i),t.setRenderTarget(n,1),t.render(e,r),t.setRenderTarget(n,2),t.render(e,s),t.setRenderTarget(n,3),t.render(e,a),t.setRenderTarget(n,4),t.render(e,o),n.texture.generateMipmaps=u,t.setRenderTarget(n,5),t.render(e,l),t.setRenderTarget(h),t.xr.enabled=c}}class ei extends bt{constructor(t,e,n,i,s,a,o,l,c,h){super(t=void 0!==t?t:[],e=void 0!==e?e:r,n,i,s,a,o=void 0!==o?o:T,l,c,h),this._needsFlipEnvMap=!0,this.flipY=!1}get images(){return this.image}set images(t){this.image=t}}ei.prototype.isCubeTexture=!0;class ni extends Tt{constructor(t,e,n){Number.isInteger(e)&&(console.warn("THREE.WebGLCubeRenderTarget: constructor signature is now WebGLCubeRenderTarget( size, options )"),e=n),super(t,t,e),e=e||{},this.texture=new ei(void 0,e.mapping,e.wrapS,e.wrapT,e.magFilter,e.minFilter,e.format,e.type,e.anisotropy,e.encoding),this.texture.generateMipmaps=void 0!==e.generateMipmaps&&e.generateMipmaps,this.texture.minFilter=void 0!==e.minFilter?e.minFilter:g,this.texture._needsFlipEnvMap=!1}fromEquirectangularTexture(t,e){this.texture.type=e.type,this.texture.format=E,this.texture.encoding=e.encoding,this.texture.generateMipmaps=e.generateMipmaps,this.texture.minFilter=e.minFilter,this.texture.magFilter=e.magFilter;const n={uniforms:{tEquirect:{value:null}},vertexShader:"\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\tvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\n\t\t\t\t\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvWorldDirection = transformDirection( position, modelMatrix );\n\n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\n\t\t\t\t}\n\t\t\t",fragmentShader:"\n\n\t\t\t\tuniform sampler2D tEquirect;\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\t#include \n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvec3 direction = normalize( vWorldDirection );\n\n\t\t\t\t\tvec2 sampleUV = equirectUv( direction );\n\n\t\t\t\t\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\n\t\t\t\t}\n\t\t\t"},i=new qn(5,5,5),r=new Jn({name:"CubemapFromEquirect",uniforms:Xn(n.uniforms),vertexShader:n.vertexShader,fragmentShader:n.fragmentShader,side:1,blending:0});r.uniforms.tEquirect.value=e;const s=new Wn(i,r),a=e.minFilter;e.minFilter===y&&(e.minFilter=g);return new ti(1,10,this).update(t,s),e.minFilter=a,s.geometry.dispose(),s.material.dispose(),this}clear(t,e,n,i){const r=t.getRenderTarget();for(let r=0;r<6;r++)t.setRenderTarget(this,r),t.clear(e,n,i);t.setRenderTarget(r)}}ni.prototype.isWebGLCubeRenderTarget=!0;class ii extends bt{constructor(t,e,n,i,r,s,a,o,l,c,h,u){super(null,s,a,o,l,c,i,r,h,u),this.image={data:t||null,width:e||1,height:n||1},this.magFilter=void 0!==l?l:p,this.minFilter=void 0!==c?c:p,this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1,this.needsUpdate=!0}}ii.prototype.isDataTexture=!0;const ri=new Jt,si=new Lt;class ai{constructor(t=new Ne,e=new Ne,n=new Ne,i=new Ne,r=new Ne,s=new Ne){this.planes=[t,e,n,i,r,s]}set(t,e,n,i,r,s){const a=this.planes;return a[0].copy(t),a[1].copy(e),a[2].copy(n),a[3].copy(i),a[4].copy(r),a[5].copy(s),this}copy(t){const e=this.planes;for(let n=0;n<6;n++)e[n].copy(t.planes[n]);return this}setFromProjectionMatrix(t){const e=this.planes,n=t.elements,i=n[0],r=n[1],s=n[2],a=n[3],o=n[4],l=n[5],c=n[6],h=n[7],u=n[8],d=n[9],p=n[10],m=n[11],f=n[12],g=n[13],v=n[14],y=n[15];return e[0].setComponents(a-i,h-o,m-u,y-f).normalize(),e[1].setComponents(a+i,h+o,m+u,y+f).normalize(),e[2].setComponents(a+r,h+l,m+d,y+g).normalize(),e[3].setComponents(a-r,h-l,m-d,y-g).normalize(),e[4].setComponents(a-s,h-c,m-p,y-v).normalize(),e[5].setComponents(a+s,h+c,m+p,y+v).normalize(),this}intersectsObject(t){const e=t.geometry;return null===e.boundingSphere&&e.computeBoundingSphere(),ri.copy(e.boundingSphere).applyMatrix4(t.matrixWorld),this.intersectsSphere(ri)}intersectsSprite(t){return ri.center.set(0,0,0),ri.radius=.7071067811865476,ri.applyMatrix4(t.matrixWorld),this.intersectsSphere(ri)}intersectsSphere(t){const e=this.planes,n=t.center,i=-t.radius;for(let t=0;t<6;t++){if(e[t].distanceToPoint(n)0?t.max.x:t.min.x,si.y=i.normal.y>0?t.max.y:t.min.y,si.z=i.normal.z>0?t.max.z:t.min.z,i.distanceToPoint(si)<0)return!1}return!0}containsPoint(t){const e=this.planes;for(let n=0;n<6;n++)if(e[n].distanceToPoint(t)<0)return!1;return!0}clone(){return(new this.constructor).copy(this)}}function oi(){let t=null,e=!1,n=null,i=null;function r(e,s){n(e,s),i=t.requestAnimationFrame(r)}return{start:function(){!0!==e&&null!==n&&(i=t.requestAnimationFrame(r),e=!0)},stop:function(){t.cancelAnimationFrame(i),e=!1},setAnimationLoop:function(t){n=t},setContext:function(e){t=e}}}function li(t,e){const n=e.isWebGL2,i=new WeakMap;return{get:function(t){return t.isInterleavedBufferAttribute&&(t=t.data),i.get(t)},remove:function(e){e.isInterleavedBufferAttribute&&(e=e.data);const n=i.get(e);n&&(t.deleteBuffer(n.buffer),i.delete(e))},update:function(e,r){if(e.isGLBufferAttribute){const t=i.get(e);return void((!t||t.version 0.0 ) {\n\t\tdistanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t}\n\treturn distanceFalloff;\n#else\n\tif( cutoffDistance > 0.0 && decayExponent > 0.0 ) {\n\t\treturn pow( saturate( -lightDistance / cutoffDistance + 1.0 ), decayExponent );\n\t}\n\treturn 1.0;\n#endif\n}\nvec3 BRDF_Diffuse_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 specularColor, const in float dotLH ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotLH - 6.98316 ) * dotLH );\n\treturn ( 1.0 - specularColor ) * fresnel + specularColor;\n}\nvec3 F_Schlick_RoughnessDependent( const in vec3 F0, const in float dotNV, const in float roughness ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotNV - 6.98316 ) * dotNV );\n\tvec3 Fr = max( vec3( 1.0 - roughness ), F0 ) - F0;\n\treturn Fr * fresnel + F0;\n}\nfloat G_GGX_Smith( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gl = dotNL + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\tfloat gv = dotNV + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\treturn 1.0 / ( gl * gv );\n}\nfloat G_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\nvec3 BRDF_Specular_GGX( const in IncidentLight incidentLight, const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float roughness ) {\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( incidentLight.direction + viewDir );\n\tfloat dotNL = saturate( dot( normal, incidentLight.direction ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\tfloat D = D_GGX( alpha, dotNH );\n\treturn F * ( G * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transposeMat3( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\nvec3 BRDF_Specular_GGX_Environment( const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tvec2 brdf = integrateSpecularBRDF( dotNV, roughness );\n\treturn specularColor * brdf.x + brdf.y;\n}\nvoid BRDF_Specular_Multiscattering_Environment( const in GeometricContext geometry, const in vec3 specularColor, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\tvec3 F = F_Schlick_RoughnessDependent( specularColor, dotNV, roughness );\n\tvec2 brdf = integrateSpecularBRDF( dotNV, roughness );\n\tvec3 FssEss = F * brdf.x + brdf.y;\n\tfloat Ess = brdf.x + brdf.y;\n\tfloat Ems = 1.0 - Ess;\n\tvec3 Favg = specularColor + ( 1.0 - specularColor ) * 0.047619;\tvec3 Fms = FssEss * Favg / ( 1.0 - Ems * Favg );\n\tsingleScatter += FssEss;\n\tmultiScatter += Fms * Ems;\n}\nfloat G_BlinnPhong_Implicit( ) {\n\treturn 0.25;\n}\nfloat D_BlinnPhong( const in float shininess, const in float dotNH ) {\n\treturn RECIPROCAL_PI * ( shininess * 0.5 + 1.0 ) * pow( dotNH, shininess );\n}\nvec3 BRDF_Specular_BlinnPhong( const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float shininess ) {\n\tvec3 halfDir = normalize( incidentLight.direction + geometry.viewDir );\n\tfloat dotNH = saturate( dot( geometry.normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_BlinnPhong_Implicit( );\n\tfloat D = D_BlinnPhong( shininess, dotNH );\n\treturn F * ( G * D );\n}\nfloat GGXRoughnessToBlinnExponent( const in float ggxRoughness ) {\n\treturn ( 2.0 / pow2( ggxRoughness + 0.0001 ) - 2.0 );\n}\nfloat BlinnExponentToGGXRoughness( const in float blinnExponent ) {\n\treturn sqrt( 2.0 / ( blinnExponent + 2.0 ) );\n}\n#if defined( USE_SHEEN )\nfloat D_Charlie(float roughness, float NoH) {\n\tfloat invAlpha = 1.0 / roughness;\n\tfloat cos2h = NoH * NoH;\n\tfloat sin2h = max(1.0 - cos2h, 0.0078125);\treturn (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * PI);\n}\nfloat V_Neubelt(float NoV, float NoL) {\n\treturn saturate(1.0 / (4.0 * (NoL + NoV - NoL * NoV)));\n}\nvec3 BRDF_Specular_Sheen( const in float roughness, const in vec3 L, const in GeometricContext geometry, vec3 specularColor ) {\n\tvec3 N = geometry.normal;\n\tvec3 V = geometry.viewDir;\n\tvec3 H = normalize( V + L );\n\tfloat dotNH = saturate( dot( N, H ) );\n\treturn specularColor * D_Charlie( roughness, dotNH ) * V_Neubelt( dot(N, V), dot(N, L) );\n}\n#endif",bumpmap_pars_fragment:"#ifdef USE_BUMPMAP\n\tuniform sampler2D bumpMap;\n\tuniform float bumpScale;\n\tvec2 dHdxy_fwd() {\n\t\tvec2 dSTdx = dFdx( vUv );\n\t\tvec2 dSTdy = dFdy( vUv );\n\t\tfloat Hll = bumpScale * texture2D( bumpMap, vUv ).x;\n\t\tfloat dBx = bumpScale * texture2D( bumpMap, vUv + dSTdx ).x - Hll;\n\t\tfloat dBy = bumpScale * texture2D( bumpMap, vUv + dSTdy ).x - Hll;\n\t\treturn vec2( dBx, dBy );\n\t}\n\tvec3 perturbNormalArb( vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection ) {\n\t\tvec3 vSigmaX = vec3( dFdx( surf_pos.x ), dFdx( surf_pos.y ), dFdx( surf_pos.z ) );\n\t\tvec3 vSigmaY = vec3( dFdy( surf_pos.x ), dFdy( surf_pos.y ), dFdy( surf_pos.z ) );\n\t\tvec3 vN = surf_norm;\n\t\tvec3 R1 = cross( vSigmaY, vN );\n\t\tvec3 R2 = cross( vN, vSigmaX );\n\t\tfloat fDet = dot( vSigmaX, R1 ) * faceDirection;\n\t\tvec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );\n\t\treturn normalize( abs( fDet ) * surf_norm - vGrad );\n\t}\n#endif",clipping_planes_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvec4 plane;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\tplane = clippingPlanes[ i ];\n\t\tif ( dot( vClipPosition, plane.xyz ) > plane.w ) discard;\n\t}\n\t#pragma unroll_loop_end\n\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\tbool clipped = true;\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tclipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t\tif ( clipped ) discard;\n\t#endif\n#endif",clipping_planes_pars_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif",clipping_planes_pars_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n#endif",clipping_planes_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvClipPosition = - mvPosition.xyz;\n#endif",color_fragment:"#if defined( USE_COLOR_ALPHA )\n\tdiffuseColor *= vColor;\n#elif defined( USE_COLOR )\n\tdiffuseColor.rgb *= vColor;\n#endif",color_pars_fragment:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR )\n\tvarying vec3 vColor;\n#endif",color_pars_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvarying vec3 vColor;\n#endif",color_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvColor = vec4( 1.0 );\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvColor = vec3( 1.0 );\n#endif\n#ifdef USE_COLOR\n\tvColor *= color;\n#endif\n#ifdef USE_INSTANCING_COLOR\n\tvColor.xyz *= instanceColor.xyz;\n#endif",common:"#define PI 3.141592653589793\n#define PI2 6.283185307179586\n#define PI_HALF 1.5707963267948966\n#define RECIPROCAL_PI 0.3183098861837907\n#define RECIPROCAL_PI2 0.15915494309189535\n#define EPSILON 1e-6\n#ifndef saturate\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\n#define whiteComplement(a) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat average( const in vec3 color ) { return dot( color, vec3( 0.3333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract(sin(sn) * c);\n}\n#ifdef HIGH_PRECISION\n\tfloat precisionSafeLength( vec3 v ) { return length( v ); }\n#else\n\tfloat max3( vec3 v ) { return max( max( v.x, v.y ), v.z ); }\n\tfloat precisionSafeLength( vec3 v ) {\n\t\tfloat maxComponent = max3( abs( v ) );\n\t\treturn length( v / maxComponent ) * maxComponent;\n\t}\n#endif\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\nstruct GeometricContext {\n\tvec3 position;\n\tvec3 normal;\n\tvec3 viewDir;\n#ifdef CLEARCOAT\n\tvec3 clearcoatNormal;\n#endif\n};\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nvec3 projectOnPlane(in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\tfloat distance = dot( planeNormal, point - pointOnPlane );\n\treturn - distance * planeNormal + point;\n}\nfloat sideOfPlane( in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn sign( dot( point - pointOnPlane, planeNormal ) );\n}\nvec3 linePlaneIntersect( in vec3 pointOnLine, in vec3 lineDirection, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn lineDirection * ( dot( planeNormal, pointOnPlane - pointOnLine ) / dot( planeNormal, lineDirection ) ) + pointOnLine;\n}\nmat3 transposeMat3( const in mat3 m ) {\n\tmat3 tmp;\n\ttmp[ 0 ] = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x );\n\ttmp[ 1 ] = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y );\n\ttmp[ 2 ] = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z );\n\treturn tmp;\n}\nfloat linearToRelativeLuminance( const in vec3 color ) {\n\tvec3 weights = vec3( 0.2126, 0.7152, 0.0722 );\n\treturn dot( weights, color.rgb );\n}\nbool isPerspectiveMatrix( mat4 m ) {\n\treturn m[ 2 ][ 3 ] == - 1.0;\n}\nvec2 equirectUv( in vec3 dir ) {\n\tfloat u = atan( dir.z, dir.x ) * RECIPROCAL_PI2 + 0.5;\n\tfloat v = asin( clamp( dir.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\treturn vec2( u, v );\n}",cube_uv_reflection_fragment:"#ifdef ENVMAP_TYPE_CUBE_UV\n\t#define cubeUV_maxMipLevel 8.0\n\t#define cubeUV_minMipLevel 4.0\n\t#define cubeUV_maxTileSize 256.0\n\t#define cubeUV_minTileSize 16.0\n\tfloat getFace( vec3 direction ) {\n\t\tvec3 absDirection = abs( direction );\n\t\tfloat face = - 1.0;\n\t\tif ( absDirection.x > absDirection.z ) {\n\t\t\tif ( absDirection.x > absDirection.y )\n\t\t\t\tface = direction.x > 0.0 ? 0.0 : 3.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t} else {\n\t\t\tif ( absDirection.z > absDirection.y )\n\t\t\t\tface = direction.z > 0.0 ? 2.0 : 5.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t}\n\t\treturn face;\n\t}\n\tvec2 getUV( vec3 direction, float face ) {\n\t\tvec2 uv;\n\t\tif ( face == 0.0 ) {\n\t\t\tuv = vec2( direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 1.0 ) {\n\t\t\tuv = vec2( - direction.x, - direction.z ) / abs( direction.y );\n\t\t} else if ( face == 2.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.y ) / abs( direction.z );\n\t\t} else if ( face == 3.0 ) {\n\t\t\tuv = vec2( - direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 4.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.z ) / abs( direction.y );\n\t\t} else {\n\t\t\tuv = vec2( direction.x, direction.y ) / abs( direction.z );\n\t\t}\n\t\treturn 0.5 * ( uv + 1.0 );\n\t}\n\tvec3 bilinearCubeUV( sampler2D envMap, vec3 direction, float mipInt ) {\n\t\tfloat face = getFace( direction );\n\t\tfloat filterInt = max( cubeUV_minMipLevel - mipInt, 0.0 );\n\t\tmipInt = max( mipInt, cubeUV_minMipLevel );\n\t\tfloat faceSize = exp2( mipInt );\n\t\tfloat texelSize = 1.0 / ( 3.0 * cubeUV_maxTileSize );\n\t\tvec2 uv = getUV( direction, face ) * ( faceSize - 1.0 );\n\t\tvec2 f = fract( uv );\n\t\tuv += 0.5 - f;\n\t\tif ( face > 2.0 ) {\n\t\t\tuv.y += faceSize;\n\t\t\tface -= 3.0;\n\t\t}\n\t\tuv.x += face * faceSize;\n\t\tif ( mipInt < cubeUV_maxMipLevel ) {\n\t\t\tuv.y += 2.0 * cubeUV_maxTileSize;\n\t\t}\n\t\tuv.y += filterInt * 2.0 * cubeUV_minTileSize;\n\t\tuv.x += 3.0 * max( 0.0, cubeUV_maxTileSize - 2.0 * faceSize );\n\t\tuv *= texelSize;\n\t\tvec3 tl = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.x += texelSize;\n\t\tvec3 tr = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.y += texelSize;\n\t\tvec3 br = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.x -= texelSize;\n\t\tvec3 bl = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tvec3 tm = mix( tl, tr, f.x );\n\t\tvec3 bm = mix( bl, br, f.x );\n\t\treturn mix( tm, bm, f.y );\n\t}\n\t#define r0 1.0\n\t#define v0 0.339\n\t#define m0 - 2.0\n\t#define r1 0.8\n\t#define v1 0.276\n\t#define m1 - 1.0\n\t#define r4 0.4\n\t#define v4 0.046\n\t#define m4 2.0\n\t#define r5 0.305\n\t#define v5 0.016\n\t#define m5 3.0\n\t#define r6 0.21\n\t#define v6 0.0038\n\t#define m6 4.0\n\tfloat roughnessToMip( float roughness ) {\n\t\tfloat mip = 0.0;\n\t\tif ( roughness >= r1 ) {\n\t\t\tmip = ( r0 - roughness ) * ( m1 - m0 ) / ( r0 - r1 ) + m0;\n\t\t} else if ( roughness >= r4 ) {\n\t\t\tmip = ( r1 - roughness ) * ( m4 - m1 ) / ( r1 - r4 ) + m1;\n\t\t} else if ( roughness >= r5 ) {\n\t\t\tmip = ( r4 - roughness ) * ( m5 - m4 ) / ( r4 - r5 ) + m4;\n\t\t} else if ( roughness >= r6 ) {\n\t\t\tmip = ( r5 - roughness ) * ( m6 - m5 ) / ( r5 - r6 ) + m5;\n\t\t} else {\n\t\t\tmip = - 2.0 * log2( 1.16 * roughness );\t\t}\n\t\treturn mip;\n\t}\n\tvec4 textureCubeUV( sampler2D envMap, vec3 sampleDir, float roughness ) {\n\t\tfloat mip = clamp( roughnessToMip( roughness ), m0, cubeUV_maxMipLevel );\n\t\tfloat mipF = fract( mip );\n\t\tfloat mipInt = floor( mip );\n\t\tvec3 color0 = bilinearCubeUV( envMap, sampleDir, mipInt );\n\t\tif ( mipF == 0.0 ) {\n\t\t\treturn vec4( color0, 1.0 );\n\t\t} else {\n\t\t\tvec3 color1 = bilinearCubeUV( envMap, sampleDir, mipInt + 1.0 );\n\t\t\treturn vec4( mix( color0, color1, mipF ), 1.0 );\n\t\t}\n\t}\n#endif",defaultnormal_vertex:"vec3 transformedNormal = objectNormal;\n#ifdef USE_INSTANCING\n\tmat3 m = mat3( instanceMatrix );\n\ttransformedNormal /= vec3( dot( m[ 0 ], m[ 0 ] ), dot( m[ 1 ], m[ 1 ] ), dot( m[ 2 ], m[ 2 ] ) );\n\ttransformedNormal = m * transformedNormal;\n#endif\ntransformedNormal = normalMatrix * transformedNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n#ifdef USE_TANGENT\n\tvec3 transformedTangent = ( modelViewMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#ifdef FLIP_SIDED\n\t\ttransformedTangent = - transformedTangent;\n\t#endif\n#endif",displacementmap_pars_vertex:"#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif",displacementmap_vertex:"#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, vUv ).x * displacementScale + displacementBias );\n#endif",emissivemap_fragment:"#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vUv );\n\temissiveColor.rgb = emissiveMapTexelToLinear( emissiveColor ).rgb;\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif",emissivemap_pars_fragment:"#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif",encodings_fragment:"gl_FragColor = linearToOutputTexel( gl_FragColor );",encodings_pars_fragment:"\nvec4 LinearToLinear( in vec4 value ) {\n\treturn value;\n}\nvec4 GammaToLinear( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.rgb, vec3( gammaFactor ) ), value.a );\n}\nvec4 LinearToGamma( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.rgb, vec3( 1.0 / gammaFactor ) ), value.a );\n}\nvec4 sRGBToLinear( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.a );\n}\nvec4 LinearTosRGB( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );\n}\nvec4 RGBEToLinear( in vec4 value ) {\n\treturn vec4( value.rgb * exp2( value.a * 255.0 - 128.0 ), 1.0 );\n}\nvec4 LinearToRGBE( in vec4 value ) {\n\tfloat maxComponent = max( max( value.r, value.g ), value.b );\n\tfloat fExp = clamp( ceil( log2( maxComponent ) ), -128.0, 127.0 );\n\treturn vec4( value.rgb / exp2( fExp ), ( fExp + 128.0 ) / 255.0 );\n}\nvec4 RGBMToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * value.a * maxRange, 1.0 );\n}\nvec4 LinearToRGBM( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.r, max( value.g, value.b ) );\n\tfloat M = clamp( maxRGB / maxRange, 0.0, 1.0 );\n\tM = ceil( M * 255.0 ) / 255.0;\n\treturn vec4( value.rgb / ( M * maxRange ), M );\n}\nvec4 RGBDToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * ( ( maxRange / 255.0 ) / value.a ), 1.0 );\n}\nvec4 LinearToRGBD( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.r, max( value.g, value.b ) );\n\tfloat D = max( maxRange / maxRGB, 1.0 );\n\tD = clamp( floor( D ) / 255.0, 0.0, 1.0 );\n\treturn vec4( value.rgb * ( D * ( 255.0 / maxRange ) ), D );\n}\nconst mat3 cLogLuvM = mat3( 0.2209, 0.3390, 0.4184, 0.1138, 0.6780, 0.7319, 0.0102, 0.1130, 0.2969 );\nvec4 LinearToLogLuv( in vec4 value ) {\n\tvec3 Xp_Y_XYZp = cLogLuvM * value.rgb;\n\tXp_Y_XYZp = max( Xp_Y_XYZp, vec3( 1e-6, 1e-6, 1e-6 ) );\n\tvec4 vResult;\n\tvResult.xy = Xp_Y_XYZp.xy / Xp_Y_XYZp.z;\n\tfloat Le = 2.0 * log2(Xp_Y_XYZp.y) + 127.0;\n\tvResult.w = fract( Le );\n\tvResult.z = ( Le - ( floor( vResult.w * 255.0 ) ) / 255.0 ) / 255.0;\n\treturn vResult;\n}\nconst mat3 cLogLuvInverseM = mat3( 6.0014, -2.7008, -1.7996, -1.3320, 3.1029, -5.7721, 0.3008, -1.0882, 5.6268 );\nvec4 LogLuvToLinear( in vec4 value ) {\n\tfloat Le = value.z * 255.0 + value.w;\n\tvec3 Xp_Y_XYZp;\n\tXp_Y_XYZp.y = exp2( ( Le - 127.0 ) / 2.0 );\n\tXp_Y_XYZp.z = Xp_Y_XYZp.y / value.y;\n\tXp_Y_XYZp.x = value.x * Xp_Y_XYZp.z;\n\tvec3 vRGB = cLogLuvInverseM * Xp_Y_XYZp.rgb;\n\treturn vec4( max( vRGB, 0.0 ), 1.0 );\n}",envmap_fragment:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvec3 cameraToFrag;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToFrag = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToFrag = normalize( vWorldPosition - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToFrag, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToFrag, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\tvec4 envColor = textureCubeUV( envMap, reflectVec, 0.0 );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\t#ifndef ENVMAP_TYPE_CUBE_UV\n\t\tenvColor = envMapTexelToLinear( envColor );\n\t#endif\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif",envmap_common_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float envMapIntensity;\n\tuniform float flipEnvMap;\n\tuniform int maxMipLevel;\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n\t\n#endif",envmap_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float reflectivity;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\tvarying vec3 vWorldPosition;\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif",envmap_pars_vertex:"#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) ||defined( PHONG )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\t\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif",envmap_physical_pars_fragment:"#if defined( USE_ENVMAP )\n\t#ifdef ENVMAP_MODE_REFRACTION\n\t\tuniform float refractionRatio;\n\t#endif\n\tvec3 getLightProbeIndirectIrradiance( const in GeometricContext geometry, const in int maxMIPLevel ) {\n\t\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryVec = vec3( flipEnvMap * worldNormal.x, worldNormal.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, worldNormal, 1.0 );\n\t\t#else\n\t\t\tvec4 envMapColor = vec4( 0.0 );\n\t\t#endif\n\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t}\n\tfloat getSpecularMIPLevel( const in float roughness, const in int maxMIPLevel ) {\n\t\tfloat maxMIPLevelScalar = float( maxMIPLevel );\n\t\tfloat sigma = PI * roughness * roughness / ( 1.0 + roughness );\n\t\tfloat desiredMIPLevel = maxMIPLevelScalar + log2( sigma );\n\t\treturn clamp( desiredMIPLevel, 0.0, maxMIPLevelScalar );\n\t}\n\tvec3 getLightProbeIndirectRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in int maxMIPLevel ) {\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( -viewDir, normal );\n\t\t\treflectVec = normalize( mix( reflectVec, normal, roughness * roughness) );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( -viewDir, normal, refractionRatio );\n\t\t#endif\n\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\tfloat specularMIPLevel = getSpecularMIPLevel( roughness, maxMIPLevel );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryReflectVec = vec3( flipEnvMap * reflectVec.x, reflectVec.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, reflectVec, roughness );\n\t\t#endif\n\t\treturn envMapColor.rgb * envMapIntensity;\n\t}\n#endif",envmap_vertex:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToVertex = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif",fog_vertex:"#ifdef USE_FOG\n\tfogDepth = - mvPosition.z;\n#endif",fog_pars_vertex:"#ifdef USE_FOG\n\tvarying float fogDepth;\n#endif",fog_fragment:"#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = 1.0 - exp( - fogDensity * fogDensity * fogDepth * fogDepth );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, fogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif",fog_pars_fragment:"#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float fogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif",gradientmap_pars_fragment:"#ifdef USE_GRADIENTMAP\n\tuniform sampler2D gradientMap;\n#endif\nvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\tfloat dotNL = dot( normal, lightDirection );\n\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t#ifdef USE_GRADIENTMAP\n\t\treturn texture2D( gradientMap, coord ).rgb;\n\t#else\n\t\treturn ( coord.x < 0.7 ) ? vec3( 0.7 ) : vec3( 1.0 );\n\t#endif\n}",lightmap_fragment:"#ifdef USE_LIGHTMAP\n\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\treflectedLight.indirectDiffuse += PI * lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n#endif",lightmap_pars_fragment:"#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif",lights_lambert_vertex:"vec3 diffuse = vec3( 1.0 );\nGeometricContext geometry;\ngeometry.position = mvPosition.xyz;\ngeometry.normal = normalize( transformedNormal );\ngeometry.viewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( -mvPosition.xyz );\nGeometricContext backGeometry;\nbackGeometry.position = geometry.position;\nbackGeometry.normal = -geometry.normal;\nbackGeometry.viewDir = geometry.viewDir;\nvLightFront = vec3( 0.0 );\nvIndirectFront = vec3( 0.0 );\n#ifdef DOUBLE_SIDED\n\tvLightBack = vec3( 0.0 );\n\tvIndirectBack = vec3( 0.0 );\n#endif\nIncidentLight directLight;\nfloat dotNL;\nvec3 directLightColor_Diffuse;\nvIndirectFront += getAmbientLightIrradiance( ambientLightColor );\nvIndirectFront += getLightProbeIrradiance( lightProbe, geometry );\n#ifdef DOUBLE_SIDED\n\tvIndirectBack += getAmbientLightIrradiance( ambientLightColor );\n\tvIndirectBack += getLightProbeIrradiance( lightProbe, backGeometry );\n#endif\n#if NUM_POINT_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tgetPointDirectLightIrradiance( pointLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tgetSpotDirectLightIrradiance( spotLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_DIR_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tgetDirectionalDirectLightIrradiance( directionalLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\tvIndirectFront += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvIndirectBack += getHemisphereLightIrradiance( hemisphereLights[ i ], backGeometry );\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif",lights_pars_begin:"uniform bool receiveShadow;\nuniform vec3 ambientLightColor;\nuniform vec3 lightProbe[ 9 ];\nvec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) {\n\tfloat x = normal.x, y = normal.y, z = normal.z;\n\tvec3 result = shCoefficients[ 0 ] * 0.886227;\n\tresult += shCoefficients[ 1 ] * 2.0 * 0.511664 * y;\n\tresult += shCoefficients[ 2 ] * 2.0 * 0.511664 * z;\n\tresult += shCoefficients[ 3 ] * 2.0 * 0.511664 * x;\n\tresult += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y;\n\tresult += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z;\n\tresult += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 );\n\tresult += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z;\n\tresult += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y );\n\treturn result;\n}\nvec3 getLightProbeIrradiance( const in vec3 lightProbe[ 9 ], const in GeometricContext geometry ) {\n\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\tvec3 irradiance = shGetIrradianceAt( worldNormal, lightProbe );\n\treturn irradiance;\n}\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treturn irradiance;\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalDirectLightIrradiance( const in DirectionalLight directionalLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tdirectLight.color = directionalLight.color;\n\t\tdirectLight.direction = directionalLight.direction;\n\t\tdirectLight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointDirectLightIrradiance( const in PointLight pointLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = pointLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tdirectLight.color = pointLight.color;\n\t\tdirectLight.color *= punctualLightIntensityToIrradianceFactor( lightDistance, pointLight.distance, pointLight.decay );\n\t\tdirectLight.visible = ( directLight.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotDirectLightIrradiance( const in SpotLight spotLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = spotLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tfloat angleCos = dot( directLight.direction, spotLight.direction );\n\t\tif ( angleCos > spotLight.coneCos ) {\n\t\t\tfloat spotEffect = smoothstep( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\t\tdirectLight.color = spotLight.color;\n\t\t\tdirectLight.color *= spotEffect * punctualLightIntensityToIrradianceFactor( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tdirectLight.visible = true;\n\t\t} else {\n\t\t\tdirectLight.color = vec3( 0.0 );\n\t\t\tdirectLight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in GeometricContext geometry ) {\n\t\tfloat dotNL = dot( geometry.normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tirradiance *= PI;\n\t\t#endif\n\t\treturn irradiance;\n\t}\n#endif",lights_toon_fragment:"ToonMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;",lights_toon_pars_fragment:"varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct ToonMaterial {\n\tvec3 diffuseColor;\n};\nvoid RE_Direct_Toon( const in IncidentLight directLight, const in GeometricContext geometry, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\tvec3 irradiance = getGradientIrradiance( geometry.normal, directLight.direction ) * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Toon( const in vec3 irradiance, const in GeometricContext geometry, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Toon\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Toon\n#define Material_LightProbeLOD( material )\t(0)",lights_phong_fragment:"BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;",lights_phong_pars_fragment:"varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct BlinnPhongMaterial {\n\tvec3 diffuseColor;\n\tvec3 specularColor;\n\tfloat specularShininess;\n\tfloat specularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_Specular_BlinnPhong( directLight, geometry, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong\n#define Material_LightProbeLOD( material )\t(0)",lights_physical_fragment:"PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nvec3 dxy = max( abs( dFdx( geometryNormal ) ), abs( dFdy( geometryNormal ) ) );\nfloat geometryRoughness = max( max( dxy.x, dxy.y ), dxy.z );\nmaterial.specularRoughness = max( roughnessFactor, 0.0525 );material.specularRoughness += geometryRoughness;\nmaterial.specularRoughness = min( material.specularRoughness, 1.0 );\n#ifdef REFLECTIVITY\n\tmaterial.specularColor = mix( vec3( MAXIMUM_SPECULAR_COEFFICIENT * pow2( reflectivity ) ), diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( DEFAULT_SPECULAR_COEFFICIENT ), diffuseColor.rgb, metalnessFactor );\n#endif\n#ifdef CLEARCOAT\n\tmaterial.clearcoat = clearcoat;\n\tmaterial.clearcoatRoughness = clearcoatRoughness;\n\t#ifdef USE_CLEARCOATMAP\n\t\tmaterial.clearcoat *= texture2D( clearcoatMap, vUv ).x;\n\t#endif\n\t#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\t\tmaterial.clearcoatRoughness *= texture2D( clearcoatRoughnessMap, vUv ).y;\n\t#endif\n\tmaterial.clearcoat = saturate( material.clearcoat );\tmaterial.clearcoatRoughness = max( material.clearcoatRoughness, 0.0525 );\n\tmaterial.clearcoatRoughness += geometryRoughness;\n\tmaterial.clearcoatRoughness = min( material.clearcoatRoughness, 1.0 );\n#endif\n#ifdef USE_SHEEN\n\tmaterial.sheenColor = sheen;\n#endif",lights_physical_pars_fragment:"struct PhysicalMaterial {\n\tvec3 diffuseColor;\n\tfloat specularRoughness;\n\tvec3 specularColor;\n#ifdef CLEARCOAT\n\tfloat clearcoat;\n\tfloat clearcoatRoughness;\n#endif\n#ifdef USE_SHEEN\n\tvec3 sheenColor;\n#endif\n};\n#define MAXIMUM_SPECULAR_COEFFICIENT 0.16\n#define DEFAULT_SPECULAR_COEFFICIENT 0.04\nfloat clearcoatDHRApprox( const in float roughness, const in float dotNL ) {\n\treturn DEFAULT_SPECULAR_COEFFICIENT + ( 1.0 - DEFAULT_SPECULAR_COEFFICIENT ) * ( pow( 1.0 - dotNL, 5.0 ) * pow( 1.0 - roughness, 2.0 ) );\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometry.normal;\n\t\tvec3 viewDir = geometry.viewDir;\n\t\tvec3 position = geometry.position;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.specularRoughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos + halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos - halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos - halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos + halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3(\t\t0, 1,\t\t0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\t#ifdef CLEARCOAT\n\t\tfloat ccDotNL = saturate( dot( geometry.clearcoatNormal, directLight.direction ) );\n\t\tvec3 ccIrradiance = ccDotNL * directLight.color;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tccIrradiance *= PI;\n\t\t#endif\n\t\tfloat clearcoatDHR = material.clearcoat * clearcoatDHRApprox( material.clearcoatRoughness, ccDotNL );\n\t\treflectedLight.directSpecular += ccIrradiance * material.clearcoat * BRDF_Specular_GGX( directLight, geometry.viewDir, geometry.clearcoatNormal, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearcoatRoughness );\n\t#else\n\t\tfloat clearcoatDHR = 0.0;\n\t#endif\n\t#ifdef USE_SHEEN\n\t\treflectedLight.directSpecular += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Specular_Sheen(\n\t\t\tmaterial.specularRoughness,\n\t\t\tdirectLight.direction,\n\t\t\tgeometry,\n\t\t\tmaterial.sheenColor\n\t\t);\n\t#else\n\t\treflectedLight.directSpecular += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Specular_GGX( directLight, geometry.viewDir, geometry.normal, material.specularColor, material.specularRoughness);\n\t#endif\n\treflectedLight.directDiffuse += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {\n\t#ifdef CLEARCOAT\n\t\tfloat ccDotNV = saturate( dot( geometry.clearcoatNormal, geometry.viewDir ) );\n\t\treflectedLight.indirectSpecular += clearcoatRadiance * material.clearcoat * BRDF_Specular_GGX_Environment( geometry.viewDir, geometry.clearcoatNormal, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearcoatRoughness );\n\t\tfloat ccDotNL = ccDotNV;\n\t\tfloat clearcoatDHR = material.clearcoat * clearcoatDHRApprox( material.clearcoatRoughness, ccDotNL );\n\t#else\n\t\tfloat clearcoatDHR = 0.0;\n\t#endif\n\tfloat clearcoatInv = 1.0 - clearcoatDHR;\n\tvec3 singleScattering = vec3( 0.0 );\n\tvec3 multiScattering = vec3( 0.0 );\n\tvec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI;\n\tBRDF_Specular_Multiscattering_Environment( geometry, material.specularColor, material.specularRoughness, singleScattering, multiScattering );\n\tvec3 diffuse = material.diffuseColor * ( 1.0 - ( singleScattering + multiScattering ) );\n\treflectedLight.indirectSpecular += clearcoatInv * radiance * singleScattering;\n\treflectedLight.indirectSpecular += multiScattering * cosineWeightedIrradiance;\n\treflectedLight.indirectDiffuse += diffuse * cosineWeightedIrradiance;\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}",lights_fragment_begin:"\nGeometricContext geometry;\ngeometry.position = - vViewPosition;\ngeometry.normal = normal;\ngeometry.viewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition );\n#ifdef CLEARCOAT\n\tgeometry.clearcoatNormal = clearcoatNormal;\n#endif\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointDirectLightIrradiance( pointLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_POINT_LIGHT_SHADOWS )\n\t\tpointLightShadow = pointLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getPointShadow( pointShadowMap[ i ], pointLightShadow.shadowMapSize, pointLightShadow.shadowBias, pointLightShadow.shadowRadius, vPointShadowCoord[ i ], pointLightShadow.shadowCameraNear, pointLightShadow.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotDirectLightIrradiance( spotLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\tspotLightShadow = spotLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getShadow( spotShadowMap[ i ], spotLightShadow.shadowMapSize, spotLightShadow.shadowBias, spotLightShadow.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalDirectLightIrradiance( directionalLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 iblIrradiance = vec3( 0.0 );\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\tirradiance += getLightProbeIrradiance( lightProbe, geometry );\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearcoatRadiance = vec3( 0.0 );\n#endif",lights_fragment_maps:"#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\t\tvec3 lightMapIrradiance = lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tlightMapIrradiance *= PI;\n\t\t#endif\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tiblIrradiance += getLightProbeIndirectIrradiance( geometry, maxMipLevel );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\tradiance += getLightProbeIndirectRadiance( geometry.viewDir, geometry.normal, material.specularRoughness, maxMipLevel );\n\t#ifdef CLEARCOAT\n\t\tclearcoatRadiance += getLightProbeIndirectRadiance( geometry.viewDir, geometry.clearcoatNormal, material.clearcoatRoughness, maxMipLevel );\n\t#endif\n#endif",lights_fragment_end:"#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometry, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, iblIrradiance, clearcoatRadiance, geometry, material, reflectedLight );\n#endif",logdepthbuf_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tgl_FragDepthEXT = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif",logdepthbuf_pars_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tuniform float logDepthBufFC;\n\tvarying float vFragDepth;\n\tvarying float vIsPerspective;\n#endif",logdepthbuf_pars_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t\tvarying float vIsPerspective;\n\t#else\n\t\tuniform float logDepthBufFC;\n\t#endif\n#endif",logdepthbuf_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvFragDepth = 1.0 + gl_Position.w;\n\t\tvIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) );\n\t#else\n\t\tif ( isPerspectiveMatrix( projectionMatrix ) ) {\n\t\t\tgl_Position.z = log2( max( EPSILON, gl_Position.w + 1.0 ) ) * logDepthBufFC - 1.0;\n\t\t\tgl_Position.z *= gl_Position.w;\n\t\t}\n\t#endif\n#endif",map_fragment:"#ifdef USE_MAP\n\tvec4 texelColor = texture2D( map, vUv );\n\ttexelColor = mapTexelToLinear( texelColor );\n\tdiffuseColor *= texelColor;\n#endif",map_pars_fragment:"#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif",map_particle_fragment:"#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n#endif\n#ifdef USE_MAP\n\tvec4 mapTexel = texture2D( map, uv );\n\tdiffuseColor *= mapTexelToLinear( mapTexel );\n#endif\n#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, uv ).g;\n#endif",map_particle_pars_fragment:"#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\tuniform mat3 uvTransform;\n#endif\n#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif",metalnessmap_fragment:"float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif",metalnessmap_pars_fragment:"#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif",morphnormal_vertex:"#ifdef USE_MORPHNORMALS\n\tobjectNormal *= morphTargetBaseInfluence;\n\tobjectNormal += morphNormal0 * morphTargetInfluences[ 0 ];\n\tobjectNormal += morphNormal1 * morphTargetInfluences[ 1 ];\n\tobjectNormal += morphNormal2 * morphTargetInfluences[ 2 ];\n\tobjectNormal += morphNormal3 * morphTargetInfluences[ 3 ];\n#endif",morphtarget_pars_vertex:"#ifdef USE_MORPHTARGETS\n\tuniform float morphTargetBaseInfluence;\n\t#ifndef USE_MORPHNORMALS\n\t\tuniform float morphTargetInfluences[ 8 ];\n\t#else\n\t\tuniform float morphTargetInfluences[ 4 ];\n\t#endif\n#endif",morphtarget_vertex:"#ifdef USE_MORPHTARGETS\n\ttransformed *= morphTargetBaseInfluence;\n\ttransformed += morphTarget0 * morphTargetInfluences[ 0 ];\n\ttransformed += morphTarget1 * morphTargetInfluences[ 1 ];\n\ttransformed += morphTarget2 * morphTargetInfluences[ 2 ];\n\ttransformed += morphTarget3 * morphTargetInfluences[ 3 ];\n\t#ifndef USE_MORPHNORMALS\n\t\ttransformed += morphTarget4 * morphTargetInfluences[ 4 ];\n\t\ttransformed += morphTarget5 * morphTargetInfluences[ 5 ];\n\t\ttransformed += morphTarget6 * morphTargetInfluences[ 6 ];\n\t\ttransformed += morphTarget7 * morphTargetInfluences[ 7 ];\n\t#endif\n#endif",normal_fragment_begin:"float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;\n#ifdef FLAT_SHADED\n\tvec3 fdx = vec3( dFdx( vViewPosition.x ), dFdx( vViewPosition.y ), dFdx( vViewPosition.z ) );\n\tvec3 fdy = vec3( dFdy( vViewPosition.x ), dFdy( vViewPosition.y ), dFdy( vViewPosition.z ) );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\t#ifdef USE_TANGENT\n\t\tvec3 tangent = normalize( vTangent );\n\t\tvec3 bitangent = normalize( vBitangent );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\ttangent = tangent * faceDirection;\n\t\t\tbitangent = bitangent * faceDirection;\n\t\t#endif\n\t\t#if defined( TANGENTSPACE_NORMALMAP ) || defined( USE_CLEARCOAT_NORMALMAP )\n\t\t\tmat3 vTBN = mat3( tangent, bitangent, normal );\n\t\t#endif\n\t#endif\n#endif\nvec3 geometryNormal = normal;",normal_fragment_maps:"#ifdef OBJECTSPACE_NORMALMAP\n\tnormal = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\t#ifdef FLIP_SIDED\n\t\tnormal = - normal;\n\t#endif\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\tnormal = normalize( normalMatrix * normal );\n#elif defined( TANGENTSPACE_NORMALMAP )\n\tvec3 mapN = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\tmapN.xy *= normalScale;\n\t#ifdef USE_TANGENT\n\t\tnormal = normalize( vTBN * mapN );\n\t#else\n\t\tnormal = perturbNormal2Arb( -vViewPosition, normal, mapN, faceDirection );\n\t#endif\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( -vViewPosition, normal, dHdxy_fwd(), faceDirection );\n#endif",normalmap_pars_fragment:"#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n#endif\n#ifdef OBJECTSPACE_NORMALMAP\n\tuniform mat3 normalMatrix;\n#endif\n#if ! defined ( USE_TANGENT ) && ( defined ( TANGENTSPACE_NORMALMAP ) || defined ( USE_CLEARCOAT_NORMALMAP ) )\n\tvec3 perturbNormal2Arb( vec3 eye_pos, vec3 surf_norm, vec3 mapN, float faceDirection ) {\n\t\tvec3 q0 = vec3( dFdx( eye_pos.x ), dFdx( eye_pos.y ), dFdx( eye_pos.z ) );\n\t\tvec3 q1 = vec3( dFdy( eye_pos.x ), dFdy( eye_pos.y ), dFdy( eye_pos.z ) );\n\t\tvec2 st0 = dFdx( vUv.st );\n\t\tvec2 st1 = dFdy( vUv.st );\n\t\tvec3 N = surf_norm;\n\t\tvec3 q1perp = cross( q1, N );\n\t\tvec3 q0perp = cross( N, q0 );\n\t\tvec3 T = q1perp * st0.x + q0perp * st1.x;\n\t\tvec3 B = q1perp * st0.y + q0perp * st1.y;\n\t\tfloat det = max( dot( T, T ), dot( B, B ) );\n\t\tfloat scale = ( det == 0.0 ) ? 0.0 : faceDirection * inversesqrt( det );\n\t\treturn normalize( T * ( mapN.x * scale ) + B * ( mapN.y * scale ) + N * mapN.z );\n\t}\n#endif",clearcoat_normal_fragment_begin:"#ifdef CLEARCOAT\n\tvec3 clearcoatNormal = geometryNormal;\n#endif",clearcoat_normal_fragment_maps:"#ifdef USE_CLEARCOAT_NORMALMAP\n\tvec3 clearcoatMapN = texture2D( clearcoatNormalMap, vUv ).xyz * 2.0 - 1.0;\n\tclearcoatMapN.xy *= clearcoatNormalScale;\n\t#ifdef USE_TANGENT\n\t\tclearcoatNormal = normalize( vTBN * clearcoatMapN );\n\t#else\n\t\tclearcoatNormal = perturbNormal2Arb( - vViewPosition, clearcoatNormal, clearcoatMapN, faceDirection );\n\t#endif\n#endif",clearcoat_pars_fragment:"#ifdef USE_CLEARCOATMAP\n\tuniform sampler2D clearcoatMap;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform sampler2D clearcoatRoughnessMap;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform sampler2D clearcoatNormalMap;\n\tuniform vec2 clearcoatNormalScale;\n#endif",packing:"vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;\nconst vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );\nconst vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );\nconst float ShiftRight8 = 1. / 256.;\nvec4 packDepthToRGBA( const in float v ) {\n\tvec4 r = vec4( fract( v * PackFactors ), v );\n\tr.yzw -= r.xyz * ShiftRight8;\treturn r * PackUpscale;\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors );\n}\nvec4 pack2HalfToRGBA( vec2 v ) {\n\tvec4 r = vec4( v.x, fract( v.x * 255.0 ), v.y, fract( v.y * 255.0 ));\n\treturn vec4( r.x - r.y / 255.0, r.y, r.z - r.w / 255.0, r.w);\n}\nvec2 unpackRGBATo2Half( vec4 v ) {\n\treturn vec2( v.x + ( v.y / 255.0 ), v.z + ( v.w / 255.0 ) );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float linearClipZ, const in float near, const in float far ) {\n\treturn linearClipZ * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn (( near + viewZ ) * far ) / (( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float invClipZ, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * invClipZ - far );\n}",premultiplied_alpha_fragment:"#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif",project_vertex:"vec4 mvPosition = vec4( transformed, 1.0 );\n#ifdef USE_INSTANCING\n\tmvPosition = instanceMatrix * mvPosition;\n#endif\nmvPosition = modelViewMatrix * mvPosition;\ngl_Position = projectionMatrix * mvPosition;",dithering_fragment:"#ifdef DITHERING\n\tgl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif",dithering_pars_fragment:"#ifdef DITHERING\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif",roughnessmap_fragment:"float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vUv );\n\troughnessFactor *= texelRoughness.g;\n#endif",roughnessmap_pars_fragment:"#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif",shadowmap_pars_fragment:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\treturn step( compare, unpackRGBAToDepth( texture2D( depths, uv ) ) );\n\t}\n\tvec2 texture2DDistribution( sampler2D shadow, vec2 uv ) {\n\t\treturn unpackRGBATo2Half( texture2D( shadow, uv ) );\n\t}\n\tfloat VSMShadow (sampler2D shadow, vec2 uv, float compare ){\n\t\tfloat occlusion = 1.0;\n\t\tvec2 distribution = texture2DDistribution( shadow, uv );\n\t\tfloat hard_shadow = step( compare , distribution.x );\n\t\tif (hard_shadow != 1.0 ) {\n\t\t\tfloat distance = compare - distribution.x ;\n\t\t\tfloat variance = max( 0.00000, distribution.y * distribution.y );\n\t\t\tfloat softness_probability = variance / (variance + distance * distance );\t\t\tsoftness_probability = clamp( ( softness_probability - 0.3 ) / ( 0.95 - 0.3 ), 0.0, 1.0 );\t\t\tocclusion = clamp( max( hard_shadow, softness_probability ), 0.0, 1.0 );\n\t\t}\n\t\treturn occlusion;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbvec4 inFrustumVec = bvec4 ( shadowCoord.x >= 0.0, shadowCoord.x <= 1.0, shadowCoord.y >= 0.0, shadowCoord.y <= 1.0 );\n\t\tbool inFrustum = all( inFrustumVec );\n\t\tbvec2 frustumTestVec = bvec2( inFrustum, shadowCoord.z <= 1.0 );\n\t\tbool frustumTest = all( frustumTestVec );\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tfloat dx2 = dx0 / 2.0;\n\t\t\tfloat dy2 = dy0 / 2.0;\n\t\t\tfloat dx3 = dx1 / 2.0;\n\t\t\tfloat dy3 = dy1 / 2.0;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 17.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx = texelSize.x;\n\t\t\tfloat dy = texelSize.y;\n\t\t\tvec2 uv = shadowCoord.xy;\n\t\t\tvec2 f = fract( uv * shadowMapSize + 0.5 );\n\t\t\tuv -= f * texelSize;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, uv, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( dx, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 0.0, dy ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + texelSize, shadowCoord.z ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, 0.0 ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, dy ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( 0.0, -dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 0.0, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( dx, -dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( mix( texture2DCompare( shadowMap, uv + vec2( -dx, -dy ), shadowCoord.z ), \n\t\t\t\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 2.0 * dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t\tf.x ),\n\t\t\t\t\t mix( texture2DCompare( shadowMap, uv + vec2( -dx, 2.0 * dy ), shadowCoord.z ), \n\t\t\t\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t\tf.x ),\n\t\t\t\t\t f.y )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_VSM )\n\t\t\tshadow = VSMShadow( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn shadow;\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\tfloat dp = ( length( lightToPosition ) - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\tdp += shadowBias;\n\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT ) || defined( SHADOWMAP_TYPE_VSM )\n\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\treturn (\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\treturn texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t#endif\n\t}\n#endif",shadowmap_pars_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 spotShadowMatrix[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n#endif",shadowmap_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0 || NUM_SPOT_LIGHT_SHADOWS > 0 || NUM_POINT_LIGHT_SHADOWS > 0\n\t\tvec3 shadowWorldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\tvec4 shadowWorldPosition;\n\t#endif\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * spotLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvSpotShadowCoord[ i ] = spotShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * pointLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n#endif",shadowmask_pars_fragment:"float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tdirectionalLight = directionalLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tspotLight = spotLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tpointLight = pointLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#endif\n\treturn shadow;\n}",skinbase_vertex:"#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif",skinning_pars_vertex:"#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\t#ifdef BONE_TEXTURE\n\t\tuniform highp sampler2D boneTexture;\n\t\tuniform int boneTextureSize;\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tfloat j = i * 4.0;\n\t\t\tfloat x = mod( j, float( boneTextureSize ) );\n\t\t\tfloat y = floor( j / float( boneTextureSize ) );\n\t\t\tfloat dx = 1.0 / float( boneTextureSize );\n\t\t\tfloat dy = 1.0 / float( boneTextureSize );\n\t\t\ty = dy * ( y + 0.5 );\n\t\t\tvec4 v1 = texture2D( boneTexture, vec2( dx * ( x + 0.5 ), y ) );\n\t\t\tvec4 v2 = texture2D( boneTexture, vec2( dx * ( x + 1.5 ), y ) );\n\t\t\tvec4 v3 = texture2D( boneTexture, vec2( dx * ( x + 2.5 ), y ) );\n\t\t\tvec4 v4 = texture2D( boneTexture, vec2( dx * ( x + 3.5 ), y ) );\n\t\t\tmat4 bone = mat4( v1, v2, v3, v4 );\n\t\t\treturn bone;\n\t\t}\n\t#else\n\t\tuniform mat4 boneMatrices[ MAX_BONES ];\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tmat4 bone = boneMatrices[ int(i) ];\n\t\t\treturn bone;\n\t\t}\n\t#endif\n#endif",skinning_vertex:"#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif",skinnormal_vertex:"#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n\t#ifdef USE_TANGENT\n\t\tobjectTangent = vec4( skinMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#endif\n#endif",specularmap_fragment:"float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif",specularmap_pars_fragment:"#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif",tonemapping_fragment:"#if defined( TONE_MAPPING )\n\tgl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif",tonemapping_pars_fragment:"#ifndef saturate\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn toneMappingExposure * color;\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\nvec3 OptimizedCineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\nvec3 RRTAndODTFit( vec3 v ) {\n\tvec3 a = v * ( v + 0.0245786 ) - 0.000090537;\n\tvec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081;\n\treturn a / b;\n}\nvec3 ACESFilmicToneMapping( vec3 color ) {\n\tconst mat3 ACESInputMat = mat3(\n\t\tvec3( 0.59719, 0.07600, 0.02840 ),\t\tvec3( 0.35458, 0.90834, 0.13383 ),\n\t\tvec3( 0.04823, 0.01566, 0.83777 )\n\t);\n\tconst mat3 ACESOutputMat = mat3(\n\t\tvec3(\t1.60475, -0.10208, -0.00327 ),\t\tvec3( -0.53108,\t1.10813, -0.07276 ),\n\t\tvec3( -0.07367, -0.00605,\t1.07602 )\n\t);\n\tcolor *= toneMappingExposure / 0.6;\n\tcolor = ACESInputMat * color;\n\tcolor = RRTAndODTFit( color );\n\tcolor = ACESOutputMat * color;\n\treturn saturate( color );\n}\nvec3 CustomToneMapping( vec3 color ) { return color; }",transmissionmap_fragment:"#ifdef USE_TRANSMISSIONMAP\n\ttotalTransmission *= texture2D( transmissionMap, vUv ).r;\n#endif",transmissionmap_pars_fragment:"#ifdef USE_TRANSMISSIONMAP\n\tuniform sampler2D transmissionMap;\n#endif",uv_pars_fragment:"#if ( defined( USE_UV ) && ! defined( UVS_VERTEX_ONLY ) )\n\tvarying vec2 vUv;\n#endif",uv_pars_vertex:"#ifdef USE_UV\n\t#ifdef UVS_VERTEX_ONLY\n\t\tvec2 vUv;\n\t#else\n\t\tvarying vec2 vUv;\n\t#endif\n\tuniform mat3 uvTransform;\n#endif",uv_vertex:"#ifdef USE_UV\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n#endif",uv2_pars_fragment:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvarying vec2 vUv2;\n#endif",uv2_pars_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tattribute vec2 uv2;\n\tvarying vec2 vUv2;\n\tuniform mat3 uv2Transform;\n#endif",uv2_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvUv2 = ( uv2Transform * vec3( uv2, 1 ) ).xy;\n#endif",worldpos_vertex:"#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP )\n\tvec4 worldPosition = vec4( transformed, 1.0 );\n\t#ifdef USE_INSTANCING\n\t\tworldPosition = instanceMatrix * worldPosition;\n\t#endif\n\tworldPosition = modelMatrix * worldPosition;\n#endif",background_frag:"uniform sampler2D t2D;\nvarying vec2 vUv;\nvoid main() {\n\tvec4 texColor = texture2D( t2D, vUv );\n\tgl_FragColor = mapTexelToLinear( texColor );\n\t#include \n\t#include \n}",background_vert:"varying vec2 vUv;\nuniform mat3 uvTransform;\nvoid main() {\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\tgl_Position = vec4( position.xy, 1.0, 1.0 );\n}",cube_frag:"#include \nuniform float opacity;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvec3 vReflect = vWorldDirection;\n\t#include \n\tgl_FragColor = envColor;\n\tgl_FragColor.a *= opacity;\n\t#include \n\t#include \n}",cube_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n\tgl_Position.z = gl_Position.w;\n}",depth_frag:"#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\tfloat fragCoordZ = 0.5 * vHighPrecisionZW[0] / vHighPrecisionZW[1] + 0.5;\n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( fragCoordZ );\n\t#endif\n}",depth_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvHighPrecisionZW = gl_Position.zw;\n}",distanceRGBA_frag:"#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main () {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include \n\t#include \n\t#include \n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}",distanceRGBA_vert:"#define DISTANCE\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvWorldPosition = worldPosition.xyz;\n}",equirect_frag:"uniform sampler2D tEquirect;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvec3 direction = normalize( vWorldDirection );\n\tvec2 sampleUV = equirectUv( direction );\n\tvec4 texColor = texture2D( tEquirect, sampleUV );\n\tgl_FragColor = mapTexelToLinear( texColor );\n\t#include \n\t#include \n}",equirect_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n}",linedashed_frag:"uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}",linedashed_vert:"uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tvLineDistance = scale * lineDistance;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\n\t\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\t\treflectedLight.indirectDiffuse += lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef USE_ENVMAP\n\t#include \n\t#include \n\t#include \n\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_frag:"uniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\nvarying vec3 vLightFront;\nvarying vec3 vIndirectFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n\tvarying vec3 vIndirectBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.indirectDiffuse += ( gl_FrontFacing ) ? vIndirectFront : vIndirectBack;\n\t#else\n\t\treflectedLight.indirectDiffuse += vIndirectFront;\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb );\n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.directDiffuse = ( gl_FrontFacing ) ? vLightFront : vLightBack;\n\t#else\n\t\treflectedLight.directDiffuse = vLightFront;\n\t#endif\n\treflectedLight.directDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb ) * getShadowMask();\n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_vert:"#define LAMBERT\nvarying vec3 vLightFront;\nvarying vec3 vIndirectFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n\tvarying vec3 vIndirectBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshmatcap_frag:"#define MATCAP\nuniform vec3 diffuse;\nuniform float opacity;\nuniform sampler2D matcap;\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 viewDir = normalize( vViewPosition );\n\tvec3 x = normalize( vec3( viewDir.z, 0.0, - viewDir.x ) );\n\tvec3 y = cross( viewDir, x );\n\tvec2 uv = vec2( dot( x, normal ), dot( y, normal ) ) * 0.495 + 0.5;\n\t#ifdef USE_MATCAP\n\t\tvec4 matcapColor = texture2D( matcap, uv );\n\t\tmatcapColor = matcapTexelToLinear( matcapColor );\n\t#else\n\t\tvec4 matcapColor = vec4( 1.0 );\n\t#endif\n\tvec3 outgoingLight = diffuseColor.rgb * matcapColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshmatcap_vert:"#define MATCAP\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifndef FLAT_SHADED\n\t\tvNormal = normalize( transformedNormal );\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n}",meshtoon_frag:"#define TOON\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshtoon_vert:"#define TOON\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}",meshphong_frag:"#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphong_vert:"#define PHONG\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphysical_frag:"#define STANDARD\n#ifdef PHYSICAL\n\t#define REFLECTIVITY\n\t#define CLEARCOAT\n\t#define TRANSMISSION\n#endif\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifdef TRANSMISSION\n\tuniform float transmission;\n#endif\n#ifdef REFLECTIVITY\n\tuniform float reflectivity;\n#endif\n#ifdef CLEARCOAT\n\tuniform float clearcoat;\n\tuniform float clearcoatRoughness;\n#endif\n#ifdef USE_SHEEN\n\tuniform vec3 sheen;\n#endif\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#ifdef TRANSMISSION\n\t\tfloat totalTransmission = transmission;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#ifdef TRANSMISSION\n\t\tdiffuseColor.a *= mix( saturate( 1. - totalTransmission + linearToRelativeLuminance( reflectedLight.directSpecular + reflectedLight.indirectSpecular ) ), 1.0, metalness );\n\t#endif\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphysical_vert:"#define STANDARD\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}",normal_frag:"#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_FragColor = vec4( packNormalToRGB( normal ), opacity );\n}",normal_vert:"#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}",points_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}",points_vert:"uniform float size;\nuniform float scale;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_PointSize = size;\n\t#ifdef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n}",shadow_frag:"uniform vec3 color;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include \n\t#include \n\t#include \n}",shadow_vert:"#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",sprite_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n}",sprite_vert:"uniform float rotation;\nuniform vec2 center;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );\n\tvec2 scale;\n\tscale.x = length( vec3( modelMatrix[ 0 ].x, modelMatrix[ 0 ].y, modelMatrix[ 0 ].z ) );\n\tscale.y = length( vec3( modelMatrix[ 1 ].x, modelMatrix[ 1 ].y, modelMatrix[ 1 ].z ) );\n\t#ifndef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) scale *= - mvPosition.z;\n\t#endif\n\tvec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;\n\tvec2 rotatedPosition;\n\trotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;\n\trotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;\n\tmvPosition.xy += rotatedPosition;\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include \n\t#include \n\t#include \n}"},ui={common:{diffuse:{value:new tn(15658734)},opacity:{value:1},map:{value:null},uvTransform:{value:new yt},uv2Transform:{value:new yt},alphaMap:{value:null}},specularmap:{specularMap:{value:null}},envmap:{envMap:{value:null},flipEnvMap:{value:-1},reflectivity:{value:1},refractionRatio:{value:.98},maxMipLevel:{value:0}},aomap:{aoMap:{value:null},aoMapIntensity:{value:1}},lightmap:{lightMap:{value:null},lightMapIntensity:{value:1}},emissivemap:{emissiveMap:{value:null}},bumpmap:{bumpMap:{value:null},bumpScale:{value:1}},normalmap:{normalMap:{value:null},normalScale:{value:new vt(1,1)}},displacementmap:{displacementMap:{value:null},displacementScale:{value:1},displacementBias:{value:0}},roughnessmap:{roughnessMap:{value:null}},metalnessmap:{metalnessMap:{value:null}},gradientmap:{gradientMap:{value:null}},fog:{fogDensity:{value:25e-5},fogNear:{value:1},fogFar:{value:2e3},fogColor:{value:new tn(16777215)}},lights:{ambientLightColor:{value:[]},lightProbe:{value:[]},directionalLights:{value:[],properties:{direction:{},color:{}}},directionalLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},directionalShadowMap:{value:[]},directionalShadowMatrix:{value:[]},spotLights:{value:[],properties:{color:{},position:{},direction:{},distance:{},coneCos:{},penumbraCos:{},decay:{}}},spotLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},spotShadowMap:{value:[]},spotShadowMatrix:{value:[]},pointLights:{value:[],properties:{color:{},position:{},decay:{},distance:{}}},pointLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{},shadowCameraNear:{},shadowCameraFar:{}}},pointShadowMap:{value:[]},pointShadowMatrix:{value:[]},hemisphereLights:{value:[],properties:{direction:{},skyColor:{},groundColor:{}}},rectAreaLights:{value:[],properties:{color:{},position:{},width:{},height:{}}},ltc_1:{value:null},ltc_2:{value:null}},points:{diffuse:{value:new tn(15658734)},opacity:{value:1},size:{value:1},scale:{value:1},map:{value:null},alphaMap:{value:null},uvTransform:{value:new yt}},sprite:{diffuse:{value:new tn(15658734)},opacity:{value:1},center:{value:new vt(.5,.5)},rotation:{value:0},map:{value:null},alphaMap:{value:null},uvTransform:{value:new yt}}},di={basic:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.fog]),vertexShader:hi.meshbasic_vert,fragmentShader:hi.meshbasic_frag},lambert:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.fog,ui.lights,{emissive:{value:new tn(0)}}]),vertexShader:hi.meshlambert_vert,fragmentShader:hi.meshlambert_frag},phong:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.fog,ui.lights,{emissive:{value:new tn(0)},specular:{value:new tn(1118481)},shininess:{value:30}}]),vertexShader:hi.meshphong_vert,fragmentShader:hi.meshphong_frag},standard:{uniforms:Yn([ui.common,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.roughnessmap,ui.metalnessmap,ui.fog,ui.lights,{emissive:{value:new tn(0)},roughness:{value:1},metalness:{value:0},envMapIntensity:{value:1}}]),vertexShader:hi.meshphysical_vert,fragmentShader:hi.meshphysical_frag},toon:{uniforms:Yn([ui.common,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.gradientmap,ui.fog,ui.lights,{emissive:{value:new tn(0)}}]),vertexShader:hi.meshtoon_vert,fragmentShader:hi.meshtoon_frag},matcap:{uniforms:Yn([ui.common,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.fog,{matcap:{value:null}}]),vertexShader:hi.meshmatcap_vert,fragmentShader:hi.meshmatcap_frag},points:{uniforms:Yn([ui.points,ui.fog]),vertexShader:hi.points_vert,fragmentShader:hi.points_frag},dashed:{uniforms:Yn([ui.common,ui.fog,{scale:{value:1},dashSize:{value:1},totalSize:{value:2}}]),vertexShader:hi.linedashed_vert,fragmentShader:hi.linedashed_frag},depth:{uniforms:Yn([ui.common,ui.displacementmap]),vertexShader:hi.depth_vert,fragmentShader:hi.depth_frag},normal:{uniforms:Yn([ui.common,ui.bumpmap,ui.normalmap,ui.displacementmap,{opacity:{value:1}}]),vertexShader:hi.normal_vert,fragmentShader:hi.normal_frag},sprite:{uniforms:Yn([ui.sprite,ui.fog]),vertexShader:hi.sprite_vert,fragmentShader:hi.sprite_frag},background:{uniforms:{uvTransform:{value:new yt},t2D:{value:null}},vertexShader:hi.background_vert,fragmentShader:hi.background_frag},cube:{uniforms:Yn([ui.envmap,{opacity:{value:1}}]),vertexShader:hi.cube_vert,fragmentShader:hi.cube_frag},equirect:{uniforms:{tEquirect:{value:null}},vertexShader:hi.equirect_vert,fragmentShader:hi.equirect_frag},distanceRGBA:{uniforms:Yn([ui.common,ui.displacementmap,{referencePosition:{value:new Lt},nearDistance:{value:1},farDistance:{value:1e3}}]),vertexShader:hi.distanceRGBA_vert,fragmentShader:hi.distanceRGBA_frag},shadow:{uniforms:Yn([ui.lights,ui.fog,{color:{value:new tn(0)},opacity:{value:1}}]),vertexShader:hi.shadow_vert,fragmentShader:hi.shadow_frag}};function pi(t,e,n,i,r){const s=new tn(0);let a,o,c=0,h=null,u=0,d=null;function p(t,e){n.buffers.color.setClear(t.r,t.g,t.b,e,r)}return{getClearColor:function(){return s},setClearColor:function(t,e=1){s.set(t),c=e,p(s,c)},getClearAlpha:function(){return c},setClearAlpha:function(t){c=t,p(s,c)},render:function(n,r,m,f){let g=!0===r.isScene?r.background:null;g&&g.isTexture&&(g=e.get(g));const v=t.xr,y=v.getSession&&v.getSession();y&&"additive"===y.environmentBlendMode&&(g=null),null===g?p(s,c):g&&g.isColor&&(p(g,1),f=!0),(t.autoClear||f)&&t.clear(t.autoClearColor,t.autoClearDepth,t.autoClearStencil),g&&(g.isCubeTexture||g.mapping===l)?(void 0===o&&(o=new Wn(new qn(1,1,1),new Jn({name:"BackgroundCubeMaterial",uniforms:Xn(di.cube.uniforms),vertexShader:di.cube.vertexShader,fragmentShader:di.cube.fragmentShader,side:1,depthTest:!1,depthWrite:!1,fog:!1})),o.geometry.deleteAttribute("normal"),o.geometry.deleteAttribute("uv"),o.onBeforeRender=function(t,e,n){this.matrixWorld.copyPosition(n.matrixWorld)},Object.defineProperty(o.material,"envMap",{get:function(){return this.uniforms.envMap.value}}),i.update(o)),o.material.uniforms.envMap.value=g,o.material.uniforms.flipEnvMap.value=g.isCubeTexture&&g._needsFlipEnvMap?-1:1,h===g&&u===g.version&&d===t.toneMapping||(o.material.needsUpdate=!0,h=g,u=g.version,d=t.toneMapping),n.unshift(o,o.geometry,o.material,0,0,null)):g&&g.isTexture&&(void 0===a&&(a=new Wn(new ci(2,2),new Jn({name:"BackgroundMaterial",uniforms:Xn(di.background.uniforms),vertexShader:di.background.vertexShader,fragmentShader:di.background.fragmentShader,side:0,depthTest:!1,depthWrite:!1,fog:!1})),a.geometry.deleteAttribute("normal"),Object.defineProperty(a.material,"map",{get:function(){return this.uniforms.t2D.value}}),i.update(a)),a.material.uniforms.t2D.value=g,!0===g.matrixAutoUpdate&&g.updateMatrix(),a.material.uniforms.uvTransform.value.copy(g.matrix),h===g&&u===g.version&&d===t.toneMapping||(a.material.needsUpdate=!0,h=g,u=g.version,d=t.toneMapping),n.unshift(a,a.geometry,a.material,0,0,null))}}}function mi(t,e,n,i){const r=t.getParameter(34921),s=i.isWebGL2?null:e.get("OES_vertex_array_object"),a=i.isWebGL2||null!==s,o={},l=d(null);let c=l;function h(e){return i.isWebGL2?t.bindVertexArray(e):s.bindVertexArrayOES(e)}function u(e){return i.isWebGL2?t.deleteVertexArray(e):s.deleteVertexArrayOES(e)}function d(t){const e=[],n=[],i=[];for(let t=0;t=0){const s=l[e];if(void 0!==s){const e=s.normalized,r=s.itemSize,a=n.get(s);if(void 0===a)continue;const l=a.buffer,c=a.type,h=a.bytesPerElement;if(s.isInterleavedBufferAttribute){const n=s.data,a=n.stride,u=s.offset;n&&n.isInstancedInterleavedBuffer?(f(i,n.meshPerAttribute),void 0===o._maxInstanceCount&&(o._maxInstanceCount=n.meshPerAttribute*n.count)):m(i),t.bindBuffer(34962,l),v(i,r,c,e,a*h,u*h)}else s.isInstancedBufferAttribute?(f(i,s.meshPerAttribute),void 0===o._maxInstanceCount&&(o._maxInstanceCount=s.meshPerAttribute*s.count)):m(i),t.bindBuffer(34962,l),v(i,r,c,e,0,0)}else if("instanceMatrix"===e){const e=n.get(r.instanceMatrix);if(void 0===e)continue;const s=e.buffer,a=e.type;f(i+0,1),f(i+1,1),f(i+2,1),f(i+3,1),t.bindBuffer(34962,s),t.vertexAttribPointer(i+0,4,a,!1,64,0),t.vertexAttribPointer(i+1,4,a,!1,64,16),t.vertexAttribPointer(i+2,4,a,!1,64,32),t.vertexAttribPointer(i+3,4,a,!1,64,48)}else if("instanceColor"===e){const e=n.get(r.instanceColor);if(void 0===e)continue;const s=e.buffer,a=e.type;f(i,1),t.bindBuffer(34962,s),t.vertexAttribPointer(i,3,a,!1,12,0)}else if(void 0!==h){const n=h[e];if(void 0!==n)switch(n.length){case 2:t.vertexAttrib2fv(i,n);break;case 3:t.vertexAttrib3fv(i,n);break;case 4:t.vertexAttrib4fv(i,n);break;default:t.vertexAttrib1fv(i,n)}}}}g()}(r,l,u,y),null!==x&&t.bindBuffer(34963,n.get(x).buffer))},reset:y,resetDefaultState:x,dispose:function(){y();for(const t in o){const e=o[t];for(const t in e){const n=e[t];for(const t in n)u(n[t].object),delete n[t];delete e[t]}delete o[t]}},releaseStatesOfGeometry:function(t){if(void 0===o[t.id])return;const e=o[t.id];for(const t in e){const n=e[t];for(const t in n)u(n[t].object),delete n[t];delete e[t]}delete o[t.id]},releaseStatesOfProgram:function(t){for(const e in o){const n=o[e];if(void 0===n[t.id])continue;const i=n[t.id];for(const t in i)u(i[t].object),delete i[t];delete n[t.id]}},initAttributes:p,enableAttribute:m,disableUnusedAttributes:g}}function fi(t,e,n,i){const r=i.isWebGL2;let s;this.setMode=function(t){s=t},this.render=function(e,i){t.drawArrays(s,e,i),n.update(i,s,1)},this.renderInstances=function(i,a,o){if(0===o)return;let l,c;if(r)l=t,c="drawArraysInstanced";else if(l=e.get("ANGLE_instanced_arrays"),c="drawArraysInstancedANGLE",null===l)return void console.error("THREE.WebGLBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.");l[c](s,i,a,o),n.update(a,s,o)}}function gi(t,e,n){let i;function r(e){if("highp"===e){if(t.getShaderPrecisionFormat(35633,36338).precision>0&&t.getShaderPrecisionFormat(35632,36338).precision>0)return"highp";e="mediump"}return"mediump"===e&&t.getShaderPrecisionFormat(35633,36337).precision>0&&t.getShaderPrecisionFormat(35632,36337).precision>0?"mediump":"lowp"}const s="undefined"!=typeof WebGL2RenderingContext&&t instanceof WebGL2RenderingContext||"undefined"!=typeof WebGL2ComputeRenderingContext&&t instanceof WebGL2ComputeRenderingContext;let a=void 0!==n.precision?n.precision:"highp";const o=r(a);o!==a&&(console.warn("THREE.WebGLRenderer:",a,"not supported, using",o,"instead."),a=o);const l=!0===n.logarithmicDepthBuffer,c=t.getParameter(34930),h=t.getParameter(35660),u=t.getParameter(3379),d=t.getParameter(34076),p=t.getParameter(34921),m=t.getParameter(36347),f=t.getParameter(36348),g=t.getParameter(36349),v=h>0,y=s||e.has("OES_texture_float");return{isWebGL2:s,getMaxAnisotropy:function(){if(void 0!==i)return i;if(!0===e.has("EXT_texture_filter_anisotropic")){const n=e.get("EXT_texture_filter_anisotropic");i=t.getParameter(n.MAX_TEXTURE_MAX_ANISOTROPY_EXT)}else i=0;return i},getMaxPrecision:r,precision:a,logarithmicDepthBuffer:l,maxTextures:c,maxVertexTextures:h,maxTextureSize:u,maxCubemapSize:d,maxAttributes:p,maxVertexUniforms:m,maxVaryings:f,maxFragmentUniforms:g,vertexTextures:v,floatFragmentTextures:y,floatVertexTextures:v&&y,maxSamples:s?t.getParameter(36183):0}}function vi(t){const e=this;let n=null,i=0,r=!1,s=!1;const a=new Ne,o=new yt,l={value:null,needsUpdate:!1};function c(){l.value!==n&&(l.value=n,l.needsUpdate=i>0),e.numPlanes=i,e.numIntersection=0}function h(t,n,i,r){const s=null!==t?t.length:0;let c=null;if(0!==s){if(c=l.value,!0!==r||null===c){const e=i+4*s,r=n.matrixWorldInverse;o.getNormalMatrix(r),(null===c||c.length0){const a=t.getRenderTarget(),o=new ni(s.height/2);return o.fromEquirectangularTexture(t,r),e.set(r,o),t.setRenderTarget(a),r.addEventListener("dispose",i),n(o.texture,r.mapping)}return null}}}return r},dispose:function(){e=new WeakMap}}}function xi(t){const e={};function n(n){if(void 0!==e[n])return e[n];let i;switch(n){case"WEBGL_depth_texture":i=t.getExtension("WEBGL_depth_texture")||t.getExtension("MOZ_WEBGL_depth_texture")||t.getExtension("WEBKIT_WEBGL_depth_texture");break;case"EXT_texture_filter_anisotropic":i=t.getExtension("EXT_texture_filter_anisotropic")||t.getExtension("MOZ_EXT_texture_filter_anisotropic")||t.getExtension("WEBKIT_EXT_texture_filter_anisotropic");break;case"WEBGL_compressed_texture_s3tc":i=t.getExtension("WEBGL_compressed_texture_s3tc")||t.getExtension("MOZ_WEBGL_compressed_texture_s3tc")||t.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");break;case"WEBGL_compressed_texture_pvrtc":i=t.getExtension("WEBGL_compressed_texture_pvrtc")||t.getExtension("WEBKIT_WEBGL_compressed_texture_pvrtc");break;default:i=t.getExtension(n)}return e[n]=i,i}return{has:function(t){return null!==n(t)},init:function(t){t.isWebGL2?n("EXT_color_buffer_float"):(n("WEBGL_depth_texture"),n("OES_texture_float"),n("OES_texture_half_float"),n("OES_texture_half_float_linear"),n("OES_standard_derivatives"),n("OES_element_index_uint"),n("OES_vertex_array_object"),n("ANGLE_instanced_arrays")),n("OES_texture_float_linear"),n("EXT_color_buffer_half_float")},get:function(t){const e=n(t);return null===e&&console.warn("THREE.WebGLRenderer: "+t+" extension not supported."),e}}}function _i(t,e,n,i){const r={},s=new WeakMap;function a(t){const o=t.target;null!==o.index&&e.remove(o.index);for(const t in o.attributes)e.remove(o.attributes[t]);o.removeEventListener("dispose",a),delete r[o.id];const l=s.get(o);l&&(e.remove(l),s.delete(o)),i.releaseStatesOfGeometry(o),!0===o.isInstancedBufferGeometry&&delete o._maxInstanceCount,n.memory.geometries--}function o(t){const n=[],i=t.index,r=t.attributes.position;let a=0;if(null!==i){const t=i.array;a=i.version;for(let e=0,i=t.length;e65535?dn:hn)(n,1);o.version=a;const l=s.get(t);l&&e.remove(l),s.set(t,o)}return{get:function(t,e){return!0===r[e.id]||(e.addEventListener("dispose",a),r[e.id]=!0,n.memory.geometries++),e},update:function(t){const n=t.attributes;for(const t in n)e.update(n[t],34962);const i=t.morphAttributes;for(const t in i){const n=i[t];for(let t=0,i=n.length;t0)return t;const r=e*n;let s=Ii[r];if(void 0===s&&(s=new Float32Array(r),Ii[r]=s),0!==e){i.toArray(s,0);for(let i=1,r=0;i!==e;++i)r+=n,t[i].toArray(s,r)}return s}function Hi(t,e){if(t.length!==e.length)return!1;for(let n=0,i=t.length;n>8&255]+st[t>>16&255]+st[t>>24&255]+"-"+st[255&e]+st[e>>8&255]+"-"+st[e>>16&15|64]+st[e>>24&255]+"-"+st[63&n|128]+st[n>>8&255]+"-"+st[n>>16&255]+st[n>>24&255]+st[255&i]+st[i>>8&255]+st[i>>16&255]+st[i>>24&255]).toUpperCase()}function ht(t,e,n){return Math.max(e,Math.min(n,t))}function ut(t,e){return(t%e+e)%e}function dt(t,e,n){return(1-n)*t+n*e}function pt(t){return 0==(t&t-1)&&0!==t}function mt(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function ft(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}var gt=Object.freeze({__proto__:null,DEG2RAD:ot,RAD2DEG:lt,generateUUID:ct,clamp:ht,euclideanModulo:ut,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:dt,damp:function(t,e,n,i){return dt(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(ut(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){return void 0!==t&&(at=t%2147483647),at=16807*at%2147483647,(at-1)/2147483646},degToRad:function(t){return t*ot},radToDeg:function(t){return t*lt},isPowerOfTwo:pt,ceilPowerOfTwo:mt,floorPowerOfTwo:ft,setQuaternionFromProperEuler:function(t,e,n,i,r){const s=Math.cos,a=Math.sin,o=s(n/2),l=a(n/2),c=s((e+i)/2),h=a((e+i)/2),u=s((e-i)/2),d=a((e-i)/2),p=s((i-e)/2),m=a((i-e)/2);switch(r){case"XYX":t.set(o*h,l*u,l*d,o*c);break;case"YZY":t.set(l*d,o*h,l*u,o*c);break;case"ZXZ":t.set(l*u,l*d,o*h,o*c);break;case"XZX":t.set(o*h,l*m,l*p,o*c);break;case"YXY":t.set(l*p,o*h,l*m,o*c);break;case"ZYZ":t.set(l*m,l*p,o*h,o*c);break;default:console.warn("THREE.MathUtils: .setQuaternionFromProperEuler() encountered an unknown order: "+r)}}});class vt{constructor(t=0,e=0){this.x=t,this.y=e}get width(){return this.x}set width(t){this.x=t}get height(){return this.y}set height(t){this.y=t}set(t,e){return this.x=t,this.y=e,this}setScalar(t){return this.x=t,this.y=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y)}copy(t){return this.x=t.x,this.y=t.y,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this)}addScalar(t){return this.x+=t,this.y+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this)}subScalar(t){return this.x-=t,this.y-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this}multiply(t){return this.x*=t.x,this.y*=t.y,this}multiplyScalar(t){return this.x*=t,this.y*=t,this}divide(t){return this.x/=t.x,this.y/=t.y,this}divideScalar(t){return this.multiplyScalar(1/t)}applyMatrix3(t){const e=this.x,n=this.y,i=t.elements;return this.x=i[0]*e+i[3]*n+i[6],this.y=i[1]*e+i[4]*n+i[7],this}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this}roundToZero(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this}negate(){return this.x=-this.x,this.y=-this.y,this}dot(t){return this.x*t.x+this.y*t.y}cross(t){return this.x*t.y-this.y*t.x}lengthSq(){return this.x*this.x+this.y*this.y}length(){return Math.sqrt(this.x*this.x+this.y*this.y)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)}normalize(){return this.divideScalar(this.length()||1)}angle(){return Math.atan2(-this.y,-this.x)+Math.PI}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y;return e*e+n*n}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this}equals(t){return t.x===this.x&&t.y===this.y}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t}fromBufferAttribute(t,e,n){return void 0!==n&&console.warn("THREE.Vector2: offset has been removed from .fromBufferAttribute()."),this.x=t.getX(e),this.y=t.getY(e),this}rotateAround(t,e){const n=Math.cos(e),i=Math.sin(e),r=this.x-t.x,s=this.y-t.y;return this.x=r*n-s*i+t.x,this.y=r*i+s*n+t.y,this}random(){return this.x=Math.random(),this.y=Math.random(),this}}vt.prototype.isVector2=!0;class yt{constructor(){this.elements=[1,0,0,0,1,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix3: the constructor no longer reads arguments. use .set() instead.")}set(t,e,n,i,r,s,a,o,l){const c=this.elements;return c[0]=t,c[1]=i,c[2]=a,c[3]=e,c[4]=r,c[5]=o,c[6]=n,c[7]=s,c[8]=l,this}identity(){return this.set(1,0,0,0,1,0,0,0,1),this}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],this}extractBasis(t,e,n){return t.setFromMatrix3Column(this,0),e.setFromMatrix3Column(this,1),n.setFromMatrix3Column(this,2),this}setFromMatrix4(t){const e=t.elements;return this.set(e[0],e[4],e[8],e[1],e[5],e[9],e[2],e[6],e[10]),this}multiply(t){return this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[3],o=n[6],l=n[1],c=n[4],h=n[7],u=n[2],d=n[5],p=n[8],m=i[0],f=i[3],g=i[6],v=i[1],y=i[4],x=i[7],_=i[2],w=i[5],b=i[8];return r[0]=s*m+a*v+o*_,r[3]=s*f+a*y+o*w,r[6]=s*g+a*x+o*b,r[1]=l*m+c*v+h*_,r[4]=l*f+c*y+h*w,r[7]=l*g+c*x+h*b,r[2]=u*m+d*v+p*_,r[5]=u*f+d*y+p*w,r[8]=u*g+d*x+p*b,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[3]*=t,e[6]*=t,e[1]*=t,e[4]*=t,e[7]*=t,e[2]*=t,e[5]*=t,e[8]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8];return e*s*c-e*a*l-n*r*c+n*a*o+i*r*l-i*s*o}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=c*s-a*l,u=a*o-c*r,d=l*r-s*o,p=e*h+n*u+i*d;if(0===p)return this.set(0,0,0,0,0,0,0,0,0);const m=1/p;return t[0]=h*m,t[1]=(i*l-c*n)*m,t[2]=(a*n-i*s)*m,t[3]=u*m,t[4]=(c*e-i*o)*m,t[5]=(i*r-a*e)*m,t[6]=d*m,t[7]=(n*o-l*e)*m,t[8]=(s*e-n*r)*m,this}transpose(){let t;const e=this.elements;return t=e[1],e[1]=e[3],e[3]=t,t=e[2],e[2]=e[6],e[6]=t,t=e[5],e[5]=e[7],e[7]=t,this}getNormalMatrix(t){return this.setFromMatrix4(t).invert().transpose()}transposeIntoArray(t){const e=this.elements;return t[0]=e[0],t[1]=e[3],t[2]=e[6],t[3]=e[1],t[4]=e[4],t[5]=e[7],t[6]=e[2],t[7]=e[5],t[8]=e[8],this}setUvTransform(t,e,n,i,r,s,a){const o=Math.cos(r),l=Math.sin(r);return this.set(n*o,n*l,-n*(o*s+l*a)+s+t,-i*l,i*o,-i*(-l*s+o*a)+a+e,0,0,1),this}scale(t,e){const n=this.elements;return n[0]*=t,n[3]*=t,n[6]*=t,n[1]*=e,n[4]*=e,n[7]*=e,this}rotate(t){const e=Math.cos(t),n=Math.sin(t),i=this.elements,r=i[0],s=i[3],a=i[6],o=i[1],l=i[4],c=i[7];return i[0]=e*r+n*o,i[3]=e*s+n*l,i[6]=e*a+n*c,i[1]=-n*r+e*o,i[4]=-n*s+e*l,i[7]=-n*a+e*c,this}translate(t,e){const n=this.elements;return n[0]+=t*n[2],n[3]+=t*n[5],n[6]+=t*n[8],n[1]+=e*n[2],n[4]+=e*n[5],n[7]+=e*n[8],this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<9;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<9;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t}clone(){return(new this.constructor).fromArray(this.elements)}}let xt;yt.prototype.isMatrix3=!0;class _t{static getDataURL(t){if(/^data:/i.test(t.src))return t.src;if("undefined"==typeof HTMLCanvasElement)return t.src;let e;if(t instanceof HTMLCanvasElement)e=t;else{void 0===xt&&(xt=document.createElementNS("http://www.w3.org/1999/xhtml","canvas")),xt.width=t.width,xt.height=t.height;const n=xt.getContext("2d");t instanceof ImageData?n.putImageData(t,0,0):n.drawImage(t,0,0,t.width,t.height),e=xt}return e.width>2048||e.height>2048?(console.warn("THREE.ImageUtils.getDataURL: Image converted to jpg for performance reasons",t),e.toDataURL("image/jpeg",.6)):e.toDataURL("image/png")}}let wt=0;class bt extends rt{constructor(t=bt.DEFAULT_IMAGE,e=bt.DEFAULT_MAPPING,n=1001,i=1001,r=1006,s=1008,a=1023,o=1009,l=1,c=3e3){super(),Object.defineProperty(this,"id",{value:wt++}),this.uuid=ct(),this.name="",this.image=t,this.mipmaps=[],this.mapping=e,this.wrapS=n,this.wrapT=i,this.magFilter=r,this.minFilter=s,this.anisotropy=l,this.format=a,this.internalFormat=null,this.type=o,this.offset=new vt(0,0),this.repeat=new vt(1,1),this.center=new vt(0,0),this.rotation=0,this.matrixAutoUpdate=!0,this.matrix=new yt,this.generateMipmaps=!0,this.premultiplyAlpha=!1,this.flipY=!0,this.unpackAlignment=4,this.encoding=c,this.version=0,this.onUpdate=null}updateMatrix(){this.matrix.setUvTransform(this.offset.x,this.offset.y,this.repeat.x,this.repeat.y,this.rotation,this.center.x,this.center.y)}clone(){return(new this.constructor).copy(this)}copy(t){return this.name=t.name,this.image=t.image,this.mipmaps=t.mipmaps.slice(0),this.mapping=t.mapping,this.wrapS=t.wrapS,this.wrapT=t.wrapT,this.magFilter=t.magFilter,this.minFilter=t.minFilter,this.anisotropy=t.anisotropy,this.format=t.format,this.internalFormat=t.internalFormat,this.type=t.type,this.offset.copy(t.offset),this.repeat.copy(t.repeat),this.center.copy(t.center),this.rotation=t.rotation,this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrix.copy(t.matrix),this.generateMipmaps=t.generateMipmaps,this.premultiplyAlpha=t.premultiplyAlpha,this.flipY=t.flipY,this.unpackAlignment=t.unpackAlignment,this.encoding=t.encoding,this}toJSON(t){const e=void 0===t||"string"==typeof t;if(!e&&void 0!==t.textures[this.uuid])return t.textures[this.uuid];const n={metadata:{version:4.5,type:"Texture",generator:"Texture.toJSON"},uuid:this.uuid,name:this.name,mapping:this.mapping,repeat:[this.repeat.x,this.repeat.y],offset:[this.offset.x,this.offset.y],center:[this.center.x,this.center.y],rotation:this.rotation,wrap:[this.wrapS,this.wrapT],format:this.format,type:this.type,encoding:this.encoding,minFilter:this.minFilter,magFilter:this.magFilter,anisotropy:this.anisotropy,flipY:this.flipY,premultiplyAlpha:this.premultiplyAlpha,unpackAlignment:this.unpackAlignment};if(void 0!==this.image){const i=this.image;if(void 0===i.uuid&&(i.uuid=ct()),!e&&void 0===t.images[i.uuid]){let e;if(Array.isArray(i)){e=[];for(let t=0,n=i.length;t1)switch(this.wrapS){case h:t.x=t.x-Math.floor(t.x);break;case u:t.x=t.x<0?0:1;break;case d:1===Math.abs(Math.floor(t.x)%2)?t.x=Math.ceil(t.x)-t.x:t.x=t.x-Math.floor(t.x)}if(t.y<0||t.y>1)switch(this.wrapT){case h:t.y=t.y-Math.floor(t.y);break;case u:t.y=t.y<0?0:1;break;case d:1===Math.abs(Math.floor(t.y)%2)?t.y=Math.ceil(t.y)-t.y:t.y=t.y-Math.floor(t.y)}return this.flipY&&(t.y=1-t.y),t}set needsUpdate(t){!0===t&&this.version++}}function Mt(t){return"undefined"!=typeof HTMLImageElement&&t instanceof HTMLImageElement||"undefined"!=typeof HTMLCanvasElement&&t instanceof HTMLCanvasElement||"undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap?_t.getDataURL(t):t.data?{data:Array.prototype.slice.call(t.data),width:t.width,height:t.height,type:t.data.constructor.name}:(console.warn("THREE.Texture: Unable to serialize Texture."),{})}bt.DEFAULT_IMAGE=void 0,bt.DEFAULT_MAPPING=i,bt.prototype.isTexture=!0;class St{constructor(t=0,e=0,n=0,i=1){this.x=t,this.y=e,this.z=n,this.w=i}get width(){return this.z}set width(t){this.z=t}get height(){return this.w}set height(t){this.w=t}set(t,e,n,i){return this.x=t,this.y=e,this.z=n,this.w=i,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this.w=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setW(t){return this.w=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;case 3:this.w=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z,this.w)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this.w=void 0!==t.w?t.w:1,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector4: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this.z+=t.z,this.w+=t.w,this)}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this.w+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this.w=t.w+e.w,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this.w+=t.w*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector4: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this.z-=t.z,this.w-=t.w,this)}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this.w-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this.w=t.w-e.w,this}multiply(t){return this.x*=t.x,this.y*=t.y,this.z*=t.z,this.w*=t.w,this}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this.w*=t,this}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=this.w,s=t.elements;return this.x=s[0]*e+s[4]*n+s[8]*i+s[12]*r,this.y=s[1]*e+s[5]*n+s[9]*i+s[13]*r,this.z=s[2]*e+s[6]*n+s[10]*i+s[14]*r,this.w=s[3]*e+s[7]*n+s[11]*i+s[15]*r,this}divideScalar(t){return this.multiplyScalar(1/t)}setAxisAngleFromQuaternion(t){this.w=2*Math.acos(t.w);const e=Math.sqrt(1-t.w*t.w);return e<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=t.x/e,this.y=t.y/e,this.z=t.z/e),this}setAxisAngleFromRotationMatrix(t){let e,n,i,r;const s=.01,a=.1,o=t.elements,l=o[0],c=o[4],h=o[8],u=o[1],d=o[5],p=o[9],m=o[2],f=o[6],g=o[10];if(Math.abs(c-u)o&&t>v?tv?o=0?1:-1,i=1-e*e;if(i>Number.EPSILON){const r=Math.sqrt(i),s=Math.atan2(r,e*n);t=Math.sin(t*s)/r,a=Math.sin(a*s)/r}const r=a*n;if(o=o*t+u*r,l=l*t+d*r,c=c*t+p*r,h=h*t+m*r,t===1-a){const t=1/Math.sqrt(o*o+l*l+c*c+h*h);o*=t,l*=t,c*=t,h*=t}}t[e]=o,t[e+1]=l,t[e+2]=c,t[e+3]=h}static multiplyQuaternionsFlat(t,e,n,i,r,s){const a=n[i],o=n[i+1],l=n[i+2],c=n[i+3],h=r[s],u=r[s+1],d=r[s+2],p=r[s+3];return t[e]=a*p+c*h+o*d-l*u,t[e+1]=o*p+c*u+l*h-a*d,t[e+2]=l*p+c*d+a*u-o*h,t[e+3]=c*p-a*h-o*u-l*d,t}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get w(){return this._w}set w(t){this._w=t,this._onChangeCallback()}set(t,e,n,i){return this._x=t,this._y=e,this._z=n,this._w=i,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._w)}copy(t){return this._x=t.x,this._y=t.y,this._z=t.z,this._w=t.w,this._onChangeCallback(),this}setFromEuler(t,e){if(!t||!t.isEuler)throw new Error("THREE.Quaternion: .setFromEuler() now expects an Euler rotation rather than a Vector3 and order.");const n=t._x,i=t._y,r=t._z,s=t._order,a=Math.cos,o=Math.sin,l=a(n/2),c=a(i/2),h=a(r/2),u=o(n/2),d=o(i/2),p=o(r/2);switch(s){case"XYZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"YXZ":this._x=u*c*h+l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"ZXY":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h-u*d*p;break;case"ZYX":this._x=u*c*h-l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h+u*d*p;break;case"YZX":this._x=u*c*h+l*d*p,this._y=l*d*h+u*c*p,this._z=l*c*p-u*d*h,this._w=l*c*h-u*d*p;break;case"XZY":this._x=u*c*h-l*d*p,this._y=l*d*h-u*c*p,this._z=l*c*p+u*d*h,this._w=l*c*h+u*d*p;break;default:console.warn("THREE.Quaternion: .setFromEuler() encountered an unknown order: "+s)}return!1!==e&&this._onChangeCallback(),this}setFromAxisAngle(t,e){const n=e/2,i=Math.sin(n);return this._x=t.x*i,this._y=t.y*i,this._z=t.z*i,this._w=Math.cos(n),this._onChangeCallback(),this}setFromRotationMatrix(t){const e=t.elements,n=e[0],i=e[4],r=e[8],s=e[1],a=e[5],o=e[9],l=e[2],c=e[6],h=e[10],u=n+a+h;if(u>0){const t=.5/Math.sqrt(u+1);this._w=.25/t,this._x=(c-o)*t,this._y=(r-l)*t,this._z=(s-i)*t}else if(n>a&&n>h){const t=2*Math.sqrt(1+n-a-h);this._w=(c-o)/t,this._x=.25*t,this._y=(i+s)/t,this._z=(r+l)/t}else if(a>h){const t=2*Math.sqrt(1+a-n-h);this._w=(r-l)/t,this._x=(i+s)/t,this._y=.25*t,this._z=(o+c)/t}else{const t=2*Math.sqrt(1+h-n-a);this._w=(s-i)/t,this._x=(r+l)/t,this._y=(o+c)/t,this._z=.25*t}return this._onChangeCallback(),this}setFromUnitVectors(t,e){let n=t.dot(e)+1;return nMath.abs(t.z)?(this._x=-t.y,this._y=t.x,this._z=0,this._w=n):(this._x=0,this._y=-t.z,this._z=t.y,this._w=n)):(this._x=t.y*e.z-t.z*e.y,this._y=t.z*e.x-t.x*e.z,this._z=t.x*e.y-t.y*e.x,this._w=n),this.normalize()}angleTo(t){return 2*Math.acos(Math.abs(ht(this.dot(t),-1,1)))}rotateTowards(t,e){const n=this.angleTo(t);if(0===n)return this;const i=Math.min(1,e/n);return this.slerp(t,i),this}identity(){return this.set(0,0,0,1)}invert(){return this.conjugate()}conjugate(){return this._x*=-1,this._y*=-1,this._z*=-1,this._onChangeCallback(),this}dot(t){return this._x*t._x+this._y*t._y+this._z*t._z+this._w*t._w}lengthSq(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w}length(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)}normalize(){let t=this.length();return 0===t?(this._x=0,this._y=0,this._z=0,this._w=1):(t=1/t,this._x=this._x*t,this._y=this._y*t,this._z=this._z*t,this._w=this._w*t),this._onChangeCallback(),this}multiply(t,e){return void 0!==e?(console.warn("THREE.Quaternion: .multiply() now only accepts one argument. Use .multiplyQuaternions( a, b ) instead."),this.multiplyQuaternions(t,e)):this.multiplyQuaternions(this,t)}premultiply(t){return this.multiplyQuaternions(t,this)}multiplyQuaternions(t,e){const n=t._x,i=t._y,r=t._z,s=t._w,a=e._x,o=e._y,l=e._z,c=e._w;return this._x=n*c+s*a+i*l-r*o,this._y=i*c+s*o+r*a-n*l,this._z=r*c+s*l+n*o-i*a,this._w=s*c-n*a-i*o-r*l,this._onChangeCallback(),this}slerp(t,e){if(0===e)return this;if(1===e)return this.copy(t);const n=this._x,i=this._y,r=this._z,s=this._w;let a=s*t._w+n*t._x+i*t._y+r*t._z;if(a<0?(this._w=-t._w,this._x=-t._x,this._y=-t._y,this._z=-t._z,a=-a):this.copy(t),a>=1)return this._w=s,this._x=n,this._y=i,this._z=r,this;const o=1-a*a;if(o<=Number.EPSILON){const t=1-e;return this._w=t*s+e*this._w,this._x=t*n+e*this._x,this._y=t*i+e*this._y,this._z=t*r+e*this._z,this.normalize(),this._onChangeCallback(),this}const l=Math.sqrt(o),c=Math.atan2(l,a),h=Math.sin((1-e)*c)/l,u=Math.sin(e*c)/l;return this._w=s*h+this._w*u,this._x=n*h+this._x*u,this._y=i*h+this._y*u,this._z=r*h+this._z*u,this._onChangeCallback(),this}slerpQuaternions(t,e,n){this.copy(t).slerp(e,n)}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._w===this._w}fromArray(t,e=0){return this._x=t[e],this._y=t[e+1],this._z=t[e+2],this._w=t[e+3],this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._w,t}fromBufferAttribute(t,e){return this._x=t.getX(e),this._y=t.getY(e),this._z=t.getZ(e),this._w=t.getW(e),this}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}}At.prototype.isQuaternion=!0;class Lt{constructor(t=0,e=0,n=0){this.x=t,this.y=e,this.z=n}set(t,e,n){return void 0===n&&(n=this.z),this.x=t,this.y=e,this.z=n,this}setScalar(t){return this.x=t,this.y=t,this.z=t,this}setX(t){return this.x=t,this}setY(t){return this.y=t,this}setZ(t){return this.z=t,this}setComponent(t,e){switch(t){case 0:this.x=e;break;case 1:this.y=e;break;case 2:this.z=e;break;default:throw new Error("index is out of range: "+t)}return this}getComponent(t){switch(t){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error("index is out of range: "+t)}}clone(){return new this.constructor(this.x,this.y,this.z)}copy(t){return this.x=t.x,this.y=t.y,this.z=t.z,this}add(t,e){return void 0!==e?(console.warn("THREE.Vector3: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(t,e)):(this.x+=t.x,this.y+=t.y,this.z+=t.z,this)}addScalar(t){return this.x+=t,this.y+=t,this.z+=t,this}addVectors(t,e){return this.x=t.x+e.x,this.y=t.y+e.y,this.z=t.z+e.z,this}addScaledVector(t,e){return this.x+=t.x*e,this.y+=t.y*e,this.z+=t.z*e,this}sub(t,e){return void 0!==e?(console.warn("THREE.Vector3: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(t,e)):(this.x-=t.x,this.y-=t.y,this.z-=t.z,this)}subScalar(t){return this.x-=t,this.y-=t,this.z-=t,this}subVectors(t,e){return this.x=t.x-e.x,this.y=t.y-e.y,this.z=t.z-e.z,this}multiply(t,e){return void 0!==e?(console.warn("THREE.Vector3: .multiply() now only accepts one argument. Use .multiplyVectors( a, b ) instead."),this.multiplyVectors(t,e)):(this.x*=t.x,this.y*=t.y,this.z*=t.z,this)}multiplyScalar(t){return this.x*=t,this.y*=t,this.z*=t,this}multiplyVectors(t,e){return this.x=t.x*e.x,this.y=t.y*e.y,this.z=t.z*e.z,this}applyEuler(t){return t&&t.isEuler||console.error("THREE.Vector3: .applyEuler() now expects an Euler rotation rather than a Vector3 and order."),this.applyQuaternion(Ct.setFromEuler(t))}applyAxisAngle(t,e){return this.applyQuaternion(Ct.setFromAxisAngle(t,e))}applyMatrix3(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[3]*n+r[6]*i,this.y=r[1]*e+r[4]*n+r[7]*i,this.z=r[2]*e+r[5]*n+r[8]*i,this}applyNormalMatrix(t){return this.applyMatrix3(t).normalize()}applyMatrix4(t){const e=this.x,n=this.y,i=this.z,r=t.elements,s=1/(r[3]*e+r[7]*n+r[11]*i+r[15]);return this.x=(r[0]*e+r[4]*n+r[8]*i+r[12])*s,this.y=(r[1]*e+r[5]*n+r[9]*i+r[13])*s,this.z=(r[2]*e+r[6]*n+r[10]*i+r[14])*s,this}applyQuaternion(t){const e=this.x,n=this.y,i=this.z,r=t.x,s=t.y,a=t.z,o=t.w,l=o*e+s*i-a*n,c=o*n+a*e-r*i,h=o*i+r*n-s*e,u=-r*e-s*n-a*i;return this.x=l*o+u*-r+c*-a-h*-s,this.y=c*o+u*-s+h*-r-l*-a,this.z=h*o+u*-a+l*-s-c*-r,this}project(t){return this.applyMatrix4(t.matrixWorldInverse).applyMatrix4(t.projectionMatrix)}unproject(t){return this.applyMatrix4(t.projectionMatrixInverse).applyMatrix4(t.matrixWorld)}transformDirection(t){const e=this.x,n=this.y,i=this.z,r=t.elements;return this.x=r[0]*e+r[4]*n+r[8]*i,this.y=r[1]*e+r[5]*n+r[9]*i,this.z=r[2]*e+r[6]*n+r[10]*i,this.normalize()}divide(t){return this.x/=t.x,this.y/=t.y,this.z/=t.z,this}divideScalar(t){return this.multiplyScalar(1/t)}min(t){return this.x=Math.min(this.x,t.x),this.y=Math.min(this.y,t.y),this.z=Math.min(this.z,t.z),this}max(t){return this.x=Math.max(this.x,t.x),this.y=Math.max(this.y,t.y),this.z=Math.max(this.z,t.z),this}clamp(t,e){return this.x=Math.max(t.x,Math.min(e.x,this.x)),this.y=Math.max(t.y,Math.min(e.y,this.y)),this.z=Math.max(t.z,Math.min(e.z,this.z)),this}clampScalar(t,e){return this.x=Math.max(t,Math.min(e,this.x)),this.y=Math.max(t,Math.min(e,this.y)),this.z=Math.max(t,Math.min(e,this.z)),this}clampLength(t,e){const n=this.length();return this.divideScalar(n||1).multiplyScalar(Math.max(t,Math.min(e,n)))}floor(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this}ceil(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this}round(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this}roundToZero(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this.z=this.z<0?Math.ceil(this.z):Math.floor(this.z),this}negate(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this}dot(t){return this.x*t.x+this.y*t.y+this.z*t.z}lengthSq(){return this.x*this.x+this.y*this.y+this.z*this.z}length(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)}manhattanLength(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)}normalize(){return this.divideScalar(this.length()||1)}setLength(t){return this.normalize().multiplyScalar(t)}lerp(t,e){return this.x+=(t.x-this.x)*e,this.y+=(t.y-this.y)*e,this.z+=(t.z-this.z)*e,this}lerpVectors(t,e,n){return this.x=t.x+(e.x-t.x)*n,this.y=t.y+(e.y-t.y)*n,this.z=t.z+(e.z-t.z)*n,this}cross(t,e){return void 0!==e?(console.warn("THREE.Vector3: .cross() now only accepts one argument. Use .crossVectors( a, b ) instead."),this.crossVectors(t,e)):this.crossVectors(this,t)}crossVectors(t,e){const n=t.x,i=t.y,r=t.z,s=e.x,a=e.y,o=e.z;return this.x=i*o-r*a,this.y=r*s-n*o,this.z=n*a-i*s,this}projectOnVector(t){const e=t.lengthSq();if(0===e)return this.set(0,0,0);const n=t.dot(this)/e;return this.copy(t).multiplyScalar(n)}projectOnPlane(t){return Rt.copy(this).projectOnVector(t),this.sub(Rt)}reflect(t){return this.sub(Rt.copy(t).multiplyScalar(2*this.dot(t)))}angleTo(t){const e=Math.sqrt(this.lengthSq()*t.lengthSq());if(0===e)return Math.PI/2;const n=this.dot(t)/e;return Math.acos(ht(n,-1,1))}distanceTo(t){return Math.sqrt(this.distanceToSquared(t))}distanceToSquared(t){const e=this.x-t.x,n=this.y-t.y,i=this.z-t.z;return e*e+n*n+i*i}manhattanDistanceTo(t){return Math.abs(this.x-t.x)+Math.abs(this.y-t.y)+Math.abs(this.z-t.z)}setFromSpherical(t){return this.setFromSphericalCoords(t.radius,t.phi,t.theta)}setFromSphericalCoords(t,e,n){const i=Math.sin(e)*t;return this.x=i*Math.sin(n),this.y=Math.cos(e)*t,this.z=i*Math.cos(n),this}setFromCylindrical(t){return this.setFromCylindricalCoords(t.radius,t.theta,t.y)}setFromCylindricalCoords(t,e,n){return this.x=t*Math.sin(e),this.y=n,this.z=t*Math.cos(e),this}setFromMatrixPosition(t){const e=t.elements;return this.x=e[12],this.y=e[13],this.z=e[14],this}setFromMatrixScale(t){const e=this.setFromMatrixColumn(t,0).length(),n=this.setFromMatrixColumn(t,1).length(),i=this.setFromMatrixColumn(t,2).length();return this.x=e,this.y=n,this.z=i,this}setFromMatrixColumn(t,e){return this.fromArray(t.elements,4*e)}setFromMatrix3Column(t,e){return this.fromArray(t.elements,3*e)}equals(t){return t.x===this.x&&t.y===this.y&&t.z===this.z}fromArray(t,e=0){return this.x=t[e],this.y=t[e+1],this.z=t[e+2],this}toArray(t=[],e=0){return t[e]=this.x,t[e+1]=this.y,t[e+2]=this.z,t}fromBufferAttribute(t,e,n){return void 0!==n&&console.warn("THREE.Vector3: offset has been removed from .fromBufferAttribute()."),this.x=t.getX(e),this.y=t.getY(e),this.z=t.getZ(e),this}random(){return this.x=Math.random(),this.y=Math.random(),this.z=Math.random(),this}}Lt.prototype.isVector3=!0;const Rt=new Lt,Ct=new At;class Pt{constructor(t=new Lt(1/0,1/0,1/0),e=new Lt(-1/0,-1/0,-1/0)){this.min=t,this.max=e}set(t,e){return this.min.copy(t),this.max.copy(e),this}setFromArray(t){let e=1/0,n=1/0,i=1/0,r=-1/0,s=-1/0,a=-1/0;for(let o=0,l=t.length;or&&(r=l),c>s&&(s=c),h>a&&(a=h)}return this.min.set(e,n,i),this.max.set(r,s,a),this}setFromBufferAttribute(t){let e=1/0,n=1/0,i=1/0,r=-1/0,s=-1/0,a=-1/0;for(let o=0,l=t.count;or&&(r=l),c>s&&(s=c),h>a&&(a=h)}return this.min.set(e,n,i),this.max.set(r,s,a),this}setFromPoints(t){this.makeEmpty();for(let e=0,n=t.length;ethis.max.x||t.ythis.max.y||t.zthis.max.z)}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y&&this.min.z<=t.min.z&&t.max.z<=this.max.z}getParameter(t,e){return void 0===e&&(console.warn("THREE.Box3: .getParameter() target is now required"),e=new Lt),e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y),(t.z-this.min.z)/(this.max.z-this.min.z))}intersectsBox(t){return!(t.max.xthis.max.x||t.max.ythis.max.y||t.max.zthis.max.z)}intersectsSphere(t){return this.clampPoint(t.center,It),It.distanceToSquared(t.center)<=t.radius*t.radius}intersectsPlane(t){let e,n;return t.normal.x>0?(e=t.normal.x*this.min.x,n=t.normal.x*this.max.x):(e=t.normal.x*this.max.x,n=t.normal.x*this.min.x),t.normal.y>0?(e+=t.normal.y*this.min.y,n+=t.normal.y*this.max.y):(e+=t.normal.y*this.max.y,n+=t.normal.y*this.min.y),t.normal.z>0?(e+=t.normal.z*this.min.z,n+=t.normal.z*this.max.z):(e+=t.normal.z*this.max.z,n+=t.normal.z*this.min.z),e<=-t.constant&&n>=-t.constant}intersectsTriangle(t){if(this.isEmpty())return!1;this.getCenter(Ut),kt.subVectors(this.max,Ut),Bt.subVectors(t.a,Ut),zt.subVectors(t.b,Ut),Ft.subVectors(t.c,Ut),Ot.subVectors(zt,Bt),Ht.subVectors(Ft,zt),Gt.subVectors(Bt,Ft);let e=[0,-Ot.z,Ot.y,0,-Ht.z,Ht.y,0,-Gt.z,Gt.y,Ot.z,0,-Ot.x,Ht.z,0,-Ht.x,Gt.z,0,-Gt.x,-Ot.y,Ot.x,0,-Ht.y,Ht.x,0,-Gt.y,Gt.x,0];return!!jt(e,Bt,zt,Ft,kt)&&(e=[1,0,0,0,1,0,0,0,1],!!jt(e,Bt,zt,Ft,kt)&&(Vt.crossVectors(Ot,Ht),e=[Vt.x,Vt.y,Vt.z],jt(e,Bt,zt,Ft,kt)))}clampPoint(t,e){return void 0===e&&(console.warn("THREE.Box3: .clampPoint() target is now required"),e=new Lt),e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return It.copy(t).clamp(this.min,this.max).sub(t).length()}getBoundingSphere(t){return void 0===t&&console.error("THREE.Box3: .getBoundingSphere() target is now required"),this.getCenter(t.center),t.radius=.5*this.getSize(It).length(),t}intersect(t){return this.min.max(t.min),this.max.min(t.max),this.isEmpty()&&this.makeEmpty(),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}applyMatrix4(t){return this.isEmpty()||(Dt[0].set(this.min.x,this.min.y,this.min.z).applyMatrix4(t),Dt[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(t),Dt[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(t),Dt[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(t),Dt[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(t),Dt[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(t),Dt[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(t),Dt[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(t),this.setFromPoints(Dt)),this}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}}Pt.prototype.isBox3=!0;const Dt=[new Lt,new Lt,new Lt,new Lt,new Lt,new Lt,new Lt,new Lt],It=new Lt,Nt=new Pt,Bt=new Lt,zt=new Lt,Ft=new Lt,Ot=new Lt,Ht=new Lt,Gt=new Lt,Ut=new Lt,kt=new Lt,Vt=new Lt,Wt=new Lt;function jt(t,e,n,i,r){for(let s=0,a=t.length-3;s<=a;s+=3){Wt.fromArray(t,s);const a=r.x*Math.abs(Wt.x)+r.y*Math.abs(Wt.y)+r.z*Math.abs(Wt.z),o=e.dot(Wt),l=n.dot(Wt),c=i.dot(Wt);if(Math.max(-Math.max(o,l,c),Math.min(o,l,c))>a)return!1}return!0}const qt=new Pt,Xt=new Lt,Yt=new Lt,Zt=new Lt;class Jt{constructor(t=new Lt,e=-1){this.center=t,this.radius=e}set(t,e){return this.center.copy(t),this.radius=e,this}setFromPoints(t,e){const n=this.center;void 0!==e?n.copy(e):qt.setFromPoints(t).getCenter(n);let i=0;for(let e=0,r=t.length;ethis.radius*this.radius&&(e.sub(this.center).normalize(),e.multiplyScalar(this.radius).add(this.center)),e}getBoundingBox(t){return void 0===t&&(console.warn("THREE.Sphere: .getBoundingBox() target is now required"),t=new Pt),this.isEmpty()?(t.makeEmpty(),t):(t.set(this.center,this.center),t.expandByScalar(this.radius),t)}applyMatrix4(t){return this.center.applyMatrix4(t),this.radius=this.radius*t.getMaxScaleOnAxis(),this}translate(t){return this.center.add(t),this}expandByPoint(t){Zt.subVectors(t,this.center);const e=Zt.lengthSq();if(e>this.radius*this.radius){const t=Math.sqrt(e),n=.5*(t-this.radius);this.center.add(Zt.multiplyScalar(n/t)),this.radius+=n}return this}union(t){return Yt.subVectors(t.center,this.center).normalize().multiplyScalar(t.radius),this.expandByPoint(Xt.copy(t.center).add(Yt)),this.expandByPoint(Xt.copy(t.center).sub(Yt)),this}equals(t){return t.center.equals(this.center)&&t.radius===this.radius}clone(){return(new this.constructor).copy(this)}}const Qt=new Lt,Kt=new Lt,$t=new Lt,te=new Lt,ee=new Lt,ne=new Lt,ie=new Lt;class re{constructor(t=new Lt,e=new Lt(0,0,-1)){this.origin=t,this.direction=e}set(t,e){return this.origin.copy(t),this.direction.copy(e),this}copy(t){return this.origin.copy(t.origin),this.direction.copy(t.direction),this}at(t,e){return void 0===e&&(console.warn("THREE.Ray: .at() target is now required"),e=new Lt),e.copy(this.direction).multiplyScalar(t).add(this.origin)}lookAt(t){return this.direction.copy(t).sub(this.origin).normalize(),this}recast(t){return this.origin.copy(this.at(t,Qt)),this}closestPointToPoint(t,e){void 0===e&&(console.warn("THREE.Ray: .closestPointToPoint() target is now required"),e=new Lt),e.subVectors(t,this.origin);const n=e.dot(this.direction);return n<0?e.copy(this.origin):e.copy(this.direction).multiplyScalar(n).add(this.origin)}distanceToPoint(t){return Math.sqrt(this.distanceSqToPoint(t))}distanceSqToPoint(t){const e=Qt.subVectors(t,this.origin).dot(this.direction);return e<0?this.origin.distanceToSquared(t):(Qt.copy(this.direction).multiplyScalar(e).add(this.origin),Qt.distanceToSquared(t))}distanceSqToSegment(t,e,n,i){Kt.copy(t).add(e).multiplyScalar(.5),$t.copy(e).sub(t).normalize(),te.copy(this.origin).sub(Kt);const r=.5*t.distanceTo(e),s=-this.direction.dot($t),a=te.dot(this.direction),o=-te.dot($t),l=te.lengthSq(),c=Math.abs(1-s*s);let h,u,d,p;if(c>0)if(h=s*o-a,u=s*a-o,p=r*c,h>=0)if(u>=-p)if(u<=p){const t=1/c;h*=t,u*=t,d=h*(h+s*u+2*a)+u*(s*h+u+2*o)+l}else u=r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u=-r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;else u<=-p?(h=Math.max(0,-(-s*r+a)),u=h>0?-r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l):u<=p?(h=0,u=Math.min(Math.max(-r,-o),r),d=u*(u+2*o)+l):(h=Math.max(0,-(s*r+a)),u=h>0?r:Math.min(Math.max(-r,-o),r),d=-h*h+u*(u+2*o)+l);else u=s>0?-r:r,h=Math.max(0,-(s*u+a)),d=-h*h+u*(u+2*o)+l;return n&&n.copy(this.direction).multiplyScalar(h).add(this.origin),i&&i.copy($t).multiplyScalar(u).add(Kt),d}intersectSphere(t,e){Qt.subVectors(t.center,this.origin);const n=Qt.dot(this.direction),i=Qt.dot(Qt)-n*n,r=t.radius*t.radius;if(i>r)return null;const s=Math.sqrt(r-i),a=n-s,o=n+s;return a<0&&o<0?null:a<0?this.at(o,e):this.at(a,e)}intersectsSphere(t){return this.distanceSqToPoint(t.center)<=t.radius*t.radius}distanceToPlane(t){const e=t.normal.dot(this.direction);if(0===e)return 0===t.distanceToPoint(this.origin)?0:null;const n=-(this.origin.dot(t.normal)+t.constant)/e;return n>=0?n:null}intersectPlane(t,e){const n=this.distanceToPlane(t);return null===n?null:this.at(n,e)}intersectsPlane(t){const e=t.distanceToPoint(this.origin);if(0===e)return!0;return t.normal.dot(this.direction)*e<0}intersectBox(t,e){let n,i,r,s,a,o;const l=1/this.direction.x,c=1/this.direction.y,h=1/this.direction.z,u=this.origin;return l>=0?(n=(t.min.x-u.x)*l,i=(t.max.x-u.x)*l):(n=(t.max.x-u.x)*l,i=(t.min.x-u.x)*l),c>=0?(r=(t.min.y-u.y)*c,s=(t.max.y-u.y)*c):(r=(t.max.y-u.y)*c,s=(t.min.y-u.y)*c),n>s||r>i?null:((r>n||n!=n)&&(n=r),(s=0?(a=(t.min.z-u.z)*h,o=(t.max.z-u.z)*h):(a=(t.max.z-u.z)*h,o=(t.min.z-u.z)*h),n>o||a>i?null:((a>n||n!=n)&&(n=a),(o=0?n:i,e)))}intersectsBox(t){return null!==this.intersectBox(t,Qt)}intersectTriangle(t,e,n,i,r){ee.subVectors(e,t),ne.subVectors(n,t),ie.crossVectors(ee,ne);let s,a=this.direction.dot(ie);if(a>0){if(i)return null;s=1}else{if(!(a<0))return null;s=-1,a=-a}te.subVectors(this.origin,t);const o=s*this.direction.dot(ne.crossVectors(te,ne));if(o<0)return null;const l=s*this.direction.dot(ee.cross(te));if(l<0)return null;if(o+l>a)return null;const c=-s*te.dot(ie);return c<0?null:this.at(c/a,r)}applyMatrix4(t){return this.origin.applyMatrix4(t),this.direction.transformDirection(t),this}equals(t){return t.origin.equals(this.origin)&&t.direction.equals(this.direction)}clone(){return(new this.constructor).copy(this)}}class se{constructor(){this.elements=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix4: the constructor no longer reads arguments. use .set() instead.")}set(t,e,n,i,r,s,a,o,l,c,h,u,d,p,m,f){const g=this.elements;return g[0]=t,g[4]=e,g[8]=n,g[12]=i,g[1]=r,g[5]=s,g[9]=a,g[13]=o,g[2]=l,g[6]=c,g[10]=h,g[14]=u,g[3]=d,g[7]=p,g[11]=m,g[15]=f,this}identity(){return this.set(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1),this}clone(){return(new se).fromArray(this.elements)}copy(t){const e=this.elements,n=t.elements;return e[0]=n[0],e[1]=n[1],e[2]=n[2],e[3]=n[3],e[4]=n[4],e[5]=n[5],e[6]=n[6],e[7]=n[7],e[8]=n[8],e[9]=n[9],e[10]=n[10],e[11]=n[11],e[12]=n[12],e[13]=n[13],e[14]=n[14],e[15]=n[15],this}copyPosition(t){const e=this.elements,n=t.elements;return e[12]=n[12],e[13]=n[13],e[14]=n[14],this}setFromMatrix3(t){const e=t.elements;return this.set(e[0],e[3],e[6],0,e[1],e[4],e[7],0,e[2],e[5],e[8],0,0,0,0,1),this}extractBasis(t,e,n){return t.setFromMatrixColumn(this,0),e.setFromMatrixColumn(this,1),n.setFromMatrixColumn(this,2),this}makeBasis(t,e,n){return this.set(t.x,e.x,n.x,0,t.y,e.y,n.y,0,t.z,e.z,n.z,0,0,0,0,1),this}extractRotation(t){const e=this.elements,n=t.elements,i=1/ae.setFromMatrixColumn(t,0).length(),r=1/ae.setFromMatrixColumn(t,1).length(),s=1/ae.setFromMatrixColumn(t,2).length();return e[0]=n[0]*i,e[1]=n[1]*i,e[2]=n[2]*i,e[3]=0,e[4]=n[4]*r,e[5]=n[5]*r,e[6]=n[6]*r,e[7]=0,e[8]=n[8]*s,e[9]=n[9]*s,e[10]=n[10]*s,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromEuler(t){t&&t.isEuler||console.error("THREE.Matrix4: .makeRotationFromEuler() now expects a Euler rotation rather than a Vector3 and order.");const e=this.elements,n=t.x,i=t.y,r=t.z,s=Math.cos(n),a=Math.sin(n),o=Math.cos(i),l=Math.sin(i),c=Math.cos(r),h=Math.sin(r);if("XYZ"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=-o*h,e[8]=l,e[1]=n+i*l,e[5]=t-r*l,e[9]=-a*o,e[2]=r-t*l,e[6]=i+n*l,e[10]=s*o}else if("YXZ"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t+r*a,e[4]=i*a-n,e[8]=s*l,e[1]=s*h,e[5]=s*c,e[9]=-a,e[2]=n*a-i,e[6]=r+t*a,e[10]=s*o}else if("ZXY"===t.order){const t=o*c,n=o*h,i=l*c,r=l*h;e[0]=t-r*a,e[4]=-s*h,e[8]=i+n*a,e[1]=n+i*a,e[5]=s*c,e[9]=r-t*a,e[2]=-s*l,e[6]=a,e[10]=s*o}else if("ZYX"===t.order){const t=s*c,n=s*h,i=a*c,r=a*h;e[0]=o*c,e[4]=i*l-n,e[8]=t*l+r,e[1]=o*h,e[5]=r*l+t,e[9]=n*l-i,e[2]=-l,e[6]=a*o,e[10]=s*o}else if("YZX"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=r-t*h,e[8]=i*h+n,e[1]=h,e[5]=s*c,e[9]=-a*c,e[2]=-l*c,e[6]=n*h+i,e[10]=t-r*h}else if("XZY"===t.order){const t=s*o,n=s*l,i=a*o,r=a*l;e[0]=o*c,e[4]=-h,e[8]=l*c,e[1]=t*h+r,e[5]=s*c,e[9]=n*h-i,e[2]=i*h-n,e[6]=a*c,e[10]=r*h+t}return e[3]=0,e[7]=0,e[11]=0,e[12]=0,e[13]=0,e[14]=0,e[15]=1,this}makeRotationFromQuaternion(t){return this.compose(le,t,ce)}lookAt(t,e,n){const i=this.elements;return de.subVectors(t,e),0===de.lengthSq()&&(de.z=1),de.normalize(),he.crossVectors(n,de),0===he.lengthSq()&&(1===Math.abs(n.z)?de.x+=1e-4:de.z+=1e-4,de.normalize(),he.crossVectors(n,de)),he.normalize(),ue.crossVectors(de,he),i[0]=he.x,i[4]=ue.x,i[8]=de.x,i[1]=he.y,i[5]=ue.y,i[9]=de.y,i[2]=he.z,i[6]=ue.z,i[10]=de.z,this}multiply(t,e){return void 0!==e?(console.warn("THREE.Matrix4: .multiply() now only accepts one argument. Use .multiplyMatrices( a, b ) instead."),this.multiplyMatrices(t,e)):this.multiplyMatrices(this,t)}premultiply(t){return this.multiplyMatrices(t,this)}multiplyMatrices(t,e){const n=t.elements,i=e.elements,r=this.elements,s=n[0],a=n[4],o=n[8],l=n[12],c=n[1],h=n[5],u=n[9],d=n[13],p=n[2],m=n[6],f=n[10],g=n[14],v=n[3],y=n[7],x=n[11],_=n[15],w=i[0],b=i[4],M=i[8],S=i[12],T=i[1],E=i[5],A=i[9],L=i[13],R=i[2],C=i[6],P=i[10],D=i[14],I=i[3],N=i[7],B=i[11],z=i[15];return r[0]=s*w+a*T+o*R+l*I,r[4]=s*b+a*E+o*C+l*N,r[8]=s*M+a*A+o*P+l*B,r[12]=s*S+a*L+o*D+l*z,r[1]=c*w+h*T+u*R+d*I,r[5]=c*b+h*E+u*C+d*N,r[9]=c*M+h*A+u*P+d*B,r[13]=c*S+h*L+u*D+d*z,r[2]=p*w+m*T+f*R+g*I,r[6]=p*b+m*E+f*C+g*N,r[10]=p*M+m*A+f*P+g*B,r[14]=p*S+m*L+f*D+g*z,r[3]=v*w+y*T+x*R+_*I,r[7]=v*b+y*E+x*C+_*N,r[11]=v*M+y*A+x*P+_*B,r[15]=v*S+y*L+x*D+_*z,this}multiplyScalar(t){const e=this.elements;return e[0]*=t,e[4]*=t,e[8]*=t,e[12]*=t,e[1]*=t,e[5]*=t,e[9]*=t,e[13]*=t,e[2]*=t,e[6]*=t,e[10]*=t,e[14]*=t,e[3]*=t,e[7]*=t,e[11]*=t,e[15]*=t,this}determinant(){const t=this.elements,e=t[0],n=t[4],i=t[8],r=t[12],s=t[1],a=t[5],o=t[9],l=t[13],c=t[2],h=t[6],u=t[10],d=t[14];return t[3]*(+r*o*h-i*l*h-r*a*u+n*l*u+i*a*d-n*o*d)+t[7]*(+e*o*d-e*l*u+r*s*u-i*s*d+i*l*c-r*o*c)+t[11]*(+e*l*h-e*a*d-r*s*h+n*s*d+r*a*c-n*l*c)+t[15]*(-i*a*c-e*o*h+e*a*u+i*s*h-n*s*u+n*o*c)}transpose(){const t=this.elements;let e;return e=t[1],t[1]=t[4],t[4]=e,e=t[2],t[2]=t[8],t[8]=e,e=t[6],t[6]=t[9],t[9]=e,e=t[3],t[3]=t[12],t[12]=e,e=t[7],t[7]=t[13],t[13]=e,e=t[11],t[11]=t[14],t[14]=e,this}setPosition(t,e,n){const i=this.elements;return t.isVector3?(i[12]=t.x,i[13]=t.y,i[14]=t.z):(i[12]=t,i[13]=e,i[14]=n),this}invert(){const t=this.elements,e=t[0],n=t[1],i=t[2],r=t[3],s=t[4],a=t[5],o=t[6],l=t[7],c=t[8],h=t[9],u=t[10],d=t[11],p=t[12],m=t[13],f=t[14],g=t[15],v=h*f*l-m*u*l+m*o*d-a*f*d-h*o*g+a*u*g,y=p*u*l-c*f*l-p*o*d+s*f*d+c*o*g-s*u*g,x=c*m*l-p*h*l+p*a*d-s*m*d-c*a*g+s*h*g,_=p*h*o-c*m*o-p*a*u+s*m*u+c*a*f-s*h*f,w=e*v+n*y+i*x+r*_;if(0===w)return this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0);const b=1/w;return t[0]=v*b,t[1]=(m*u*r-h*f*r-m*i*d+n*f*d+h*i*g-n*u*g)*b,t[2]=(a*f*r-m*o*r+m*i*l-n*f*l-a*i*g+n*o*g)*b,t[3]=(h*o*r-a*u*r-h*i*l+n*u*l+a*i*d-n*o*d)*b,t[4]=y*b,t[5]=(c*f*r-p*u*r+p*i*d-e*f*d-c*i*g+e*u*g)*b,t[6]=(p*o*r-s*f*r-p*i*l+e*f*l+s*i*g-e*o*g)*b,t[7]=(s*u*r-c*o*r+c*i*l-e*u*l-s*i*d+e*o*d)*b,t[8]=x*b,t[9]=(p*h*r-c*m*r-p*n*d+e*m*d+c*n*g-e*h*g)*b,t[10]=(s*m*r-p*a*r+p*n*l-e*m*l-s*n*g+e*a*g)*b,t[11]=(c*a*r-s*h*r-c*n*l+e*h*l+s*n*d-e*a*d)*b,t[12]=_*b,t[13]=(c*m*i-p*h*i+p*n*u-e*m*u-c*n*f+e*h*f)*b,t[14]=(p*a*i-s*m*i-p*n*o+e*m*o+s*n*f-e*a*f)*b,t[15]=(s*h*i-c*a*i+c*n*o-e*h*o-s*n*u+e*a*u)*b,this}scale(t){const e=this.elements,n=t.x,i=t.y,r=t.z;return e[0]*=n,e[4]*=i,e[8]*=r,e[1]*=n,e[5]*=i,e[9]*=r,e[2]*=n,e[6]*=i,e[10]*=r,e[3]*=n,e[7]*=i,e[11]*=r,this}getMaxScaleOnAxis(){const t=this.elements,e=t[0]*t[0]+t[1]*t[1]+t[2]*t[2],n=t[4]*t[4]+t[5]*t[5]+t[6]*t[6],i=t[8]*t[8]+t[9]*t[9]+t[10]*t[10];return Math.sqrt(Math.max(e,n,i))}makeTranslation(t,e,n){return this.set(1,0,0,t,0,1,0,e,0,0,1,n,0,0,0,1),this}makeRotationX(t){const e=Math.cos(t),n=Math.sin(t);return this.set(1,0,0,0,0,e,-n,0,0,n,e,0,0,0,0,1),this}makeRotationY(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,0,n,0,0,1,0,0,-n,0,e,0,0,0,0,1),this}makeRotationZ(t){const e=Math.cos(t),n=Math.sin(t);return this.set(e,-n,0,0,n,e,0,0,0,0,1,0,0,0,0,1),this}makeRotationAxis(t,e){const n=Math.cos(e),i=Math.sin(e),r=1-n,s=t.x,a=t.y,o=t.z,l=r*s,c=r*a;return this.set(l*s+n,l*a-i*o,l*o+i*a,0,l*a+i*o,c*a+n,c*o-i*s,0,l*o-i*a,c*o+i*s,r*o*o+n,0,0,0,0,1),this}makeScale(t,e,n){return this.set(t,0,0,0,0,e,0,0,0,0,n,0,0,0,0,1),this}makeShear(t,e,n){return this.set(1,e,n,0,t,1,n,0,t,e,1,0,0,0,0,1),this}compose(t,e,n){const i=this.elements,r=e._x,s=e._y,a=e._z,o=e._w,l=r+r,c=s+s,h=a+a,u=r*l,d=r*c,p=r*h,m=s*c,f=s*h,g=a*h,v=o*l,y=o*c,x=o*h,_=n.x,w=n.y,b=n.z;return i[0]=(1-(m+g))*_,i[1]=(d+x)*_,i[2]=(p-y)*_,i[3]=0,i[4]=(d-x)*w,i[5]=(1-(u+g))*w,i[6]=(f+v)*w,i[7]=0,i[8]=(p+y)*b,i[9]=(f-v)*b,i[10]=(1-(u+m))*b,i[11]=0,i[12]=t.x,i[13]=t.y,i[14]=t.z,i[15]=1,this}decompose(t,e,n){const i=this.elements;let r=ae.set(i[0],i[1],i[2]).length();const s=ae.set(i[4],i[5],i[6]).length(),a=ae.set(i[8],i[9],i[10]).length();this.determinant()<0&&(r=-r),t.x=i[12],t.y=i[13],t.z=i[14],oe.copy(this);const o=1/r,l=1/s,c=1/a;return oe.elements[0]*=o,oe.elements[1]*=o,oe.elements[2]*=o,oe.elements[4]*=l,oe.elements[5]*=l,oe.elements[6]*=l,oe.elements[8]*=c,oe.elements[9]*=c,oe.elements[10]*=c,e.setFromRotationMatrix(oe),n.x=r,n.y=s,n.z=a,this}makePerspective(t,e,n,i,r,s){void 0===s&&console.warn("THREE.Matrix4: .makePerspective() has been redefined and has a new signature. Please check the docs.");const a=this.elements,o=2*r/(e-t),l=2*r/(n-i),c=(e+t)/(e-t),h=(n+i)/(n-i),u=-(s+r)/(s-r),d=-2*s*r/(s-r);return a[0]=o,a[4]=0,a[8]=c,a[12]=0,a[1]=0,a[5]=l,a[9]=h,a[13]=0,a[2]=0,a[6]=0,a[10]=u,a[14]=d,a[3]=0,a[7]=0,a[11]=-1,a[15]=0,this}makeOrthographic(t,e,n,i,r,s){const a=this.elements,o=1/(e-t),l=1/(n-i),c=1/(s-r),h=(e+t)*o,u=(n+i)*l,d=(s+r)*c;return a[0]=2*o,a[4]=0,a[8]=0,a[12]=-h,a[1]=0,a[5]=2*l,a[9]=0,a[13]=-u,a[2]=0,a[6]=0,a[10]=-2*c,a[14]=-d,a[3]=0,a[7]=0,a[11]=0,a[15]=1,this}equals(t){const e=this.elements,n=t.elements;for(let t=0;t<16;t++)if(e[t]!==n[t])return!1;return!0}fromArray(t,e=0){for(let n=0;n<16;n++)this.elements[n]=t[n+e];return this}toArray(t=[],e=0){const n=this.elements;return t[e]=n[0],t[e+1]=n[1],t[e+2]=n[2],t[e+3]=n[3],t[e+4]=n[4],t[e+5]=n[5],t[e+6]=n[6],t[e+7]=n[7],t[e+8]=n[8],t[e+9]=n[9],t[e+10]=n[10],t[e+11]=n[11],t[e+12]=n[12],t[e+13]=n[13],t[e+14]=n[14],t[e+15]=n[15],t}}se.prototype.isMatrix4=!0;const ae=new Lt,oe=new se,le=new Lt(0,0,0),ce=new Lt(1,1,1),he=new Lt,ue=new Lt,de=new Lt,pe=new se,me=new At;class fe{constructor(t=0,e=0,n=0,i=fe.DefaultOrder){this._x=t,this._y=e,this._z=n,this._order=i}get x(){return this._x}set x(t){this._x=t,this._onChangeCallback()}get y(){return this._y}set y(t){this._y=t,this._onChangeCallback()}get z(){return this._z}set z(t){this._z=t,this._onChangeCallback()}get order(){return this._order}set order(t){this._order=t,this._onChangeCallback()}set(t,e,n,i){return this._x=t,this._y=e,this._z=n,this._order=i||this._order,this._onChangeCallback(),this}clone(){return new this.constructor(this._x,this._y,this._z,this._order)}copy(t){return this._x=t._x,this._y=t._y,this._z=t._z,this._order=t._order,this._onChangeCallback(),this}setFromRotationMatrix(t,e,n){const i=t.elements,r=i[0],s=i[4],a=i[8],o=i[1],l=i[5],c=i[9],h=i[2],u=i[6],d=i[10];switch(e=e||this._order){case"XYZ":this._y=Math.asin(ht(a,-1,1)),Math.abs(a)<.9999999?(this._x=Math.atan2(-c,d),this._z=Math.atan2(-s,r)):(this._x=Math.atan2(u,l),this._z=0);break;case"YXZ":this._x=Math.asin(-ht(c,-1,1)),Math.abs(c)<.9999999?(this._y=Math.atan2(a,d),this._z=Math.atan2(o,l)):(this._y=Math.atan2(-h,r),this._z=0);break;case"ZXY":this._x=Math.asin(ht(u,-1,1)),Math.abs(u)<.9999999?(this._y=Math.atan2(-h,d),this._z=Math.atan2(-s,l)):(this._y=0,this._z=Math.atan2(o,r));break;case"ZYX":this._y=Math.asin(-ht(h,-1,1)),Math.abs(h)<.9999999?(this._x=Math.atan2(u,d),this._z=Math.atan2(o,r)):(this._x=0,this._z=Math.atan2(-s,l));break;case"YZX":this._z=Math.asin(ht(o,-1,1)),Math.abs(o)<.9999999?(this._x=Math.atan2(-c,l),this._y=Math.atan2(-h,r)):(this._x=0,this._y=Math.atan2(a,d));break;case"XZY":this._z=Math.asin(-ht(s,-1,1)),Math.abs(s)<.9999999?(this._x=Math.atan2(u,l),this._y=Math.atan2(a,r)):(this._x=Math.atan2(-c,d),this._y=0);break;default:console.warn("THREE.Euler: .setFromRotationMatrix() encountered an unknown order: "+e)}return this._order=e,!1!==n&&this._onChangeCallback(),this}setFromQuaternion(t,e,n){return pe.makeRotationFromQuaternion(t),this.setFromRotationMatrix(pe,e,n)}setFromVector3(t,e){return this.set(t.x,t.y,t.z,e||this._order)}reorder(t){return me.setFromEuler(this),this.setFromQuaternion(me,t)}equals(t){return t._x===this._x&&t._y===this._y&&t._z===this._z&&t._order===this._order}fromArray(t){return this._x=t[0],this._y=t[1],this._z=t[2],void 0!==t[3]&&(this._order=t[3]),this._onChangeCallback(),this}toArray(t=[],e=0){return t[e]=this._x,t[e+1]=this._y,t[e+2]=this._z,t[e+3]=this._order,t}toVector3(t){return t?t.set(this._x,this._y,this._z):new Lt(this._x,this._y,this._z)}_onChange(t){return this._onChangeCallback=t,this}_onChangeCallback(){}}fe.prototype.isEuler=!0,fe.DefaultOrder="XYZ",fe.RotationOrders=["XYZ","YZX","ZXY","XZY","YXZ","ZYX"];class ge{constructor(){this.mask=1}set(t){this.mask=1<1){for(let t=0;t1){for(let t=0;t0){i.children=[];for(let e=0;e0){i.animations=[];for(let e=0;e0&&(n.geometries=e),i.length>0&&(n.materials=i),r.length>0&&(n.textures=r),a.length>0&&(n.images=a),o.length>0&&(n.shapes=o),l.length>0&&(n.skeletons=l),c.length>0&&(n.animations=c)}return n.object=i,n;function s(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}}clone(t){return(new this.constructor).copy(this,t)}copy(t,e=!0){if(this.name=t.name,this.up.copy(t.up),this.position.copy(t.position),this.rotation.order=t.rotation.order,this.quaternion.copy(t.quaternion),this.scale.copy(t.scale),this.matrix.copy(t.matrix),this.matrixWorld.copy(t.matrixWorld),this.matrixAutoUpdate=t.matrixAutoUpdate,this.matrixWorldNeedsUpdate=t.matrixWorldNeedsUpdate,this.layers.mask=t.layers.mask,this.visible=t.visible,this.castShadow=t.castShadow,this.receiveShadow=t.receiveShadow,this.frustumCulled=t.frustumCulled,this.renderOrder=t.renderOrder,this.userData=JSON.parse(JSON.stringify(t.userData)),!0===e)for(let e=0;e1?null:e.copy(n).multiplyScalar(r).add(t.start)}intersectsLine(t){const e=this.distanceToPoint(t.start),n=this.distanceToPoint(t.end);return e<0&&n>0||n<0&&e>0}intersectsBox(t){return t.intersectsPlane(this)}intersectsSphere(t){return t.intersectsPlane(this)}coplanarPoint(t){return void 0===t&&(console.warn("THREE.Plane: .coplanarPoint() target is now required"),t=new Lt),t.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(t,e){const n=e||Ie.getNormalMatrix(t),i=this.coplanarPoint(Pe).applyMatrix4(t),r=this.normal.applyMatrix3(n).normalize();return this.constant=-i.dot(r),this}translate(t){return this.constant-=t.dot(this.normal),this}equals(t){return t.normal.equals(this.normal)&&t.constant===this.constant}clone(){return(new this.constructor).copy(this)}}Ne.prototype.isPlane=!0;const Be=new Lt,ze=new Lt,Fe=new Lt,Oe=new Lt,He=new Lt,Ge=new Lt,Ue=new Lt,ke=new Lt,Ve=new Lt,We=new Lt;class je{constructor(t=new Lt,e=new Lt,n=new Lt){this.a=t,this.b=e,this.c=n}static getNormal(t,e,n,i){void 0===i&&(console.warn("THREE.Triangle: .getNormal() target is now required"),i=new Lt),i.subVectors(n,e),Be.subVectors(t,e),i.cross(Be);const r=i.lengthSq();return r>0?i.multiplyScalar(1/Math.sqrt(r)):i.set(0,0,0)}static getBarycoord(t,e,n,i,r){Be.subVectors(i,e),ze.subVectors(n,e),Fe.subVectors(t,e);const s=Be.dot(Be),a=Be.dot(ze),o=Be.dot(Fe),l=ze.dot(ze),c=ze.dot(Fe),h=s*l-a*a;if(void 0===r&&(console.warn("THREE.Triangle: .getBarycoord() target is now required"),r=new Lt),0===h)return r.set(-2,-1,-1);const u=1/h,d=(l*o-a*c)*u,p=(s*c-a*o)*u;return r.set(1-d-p,p,d)}static containsPoint(t,e,n,i){return this.getBarycoord(t,e,n,i,Oe),Oe.x>=0&&Oe.y>=0&&Oe.x+Oe.y<=1}static getUV(t,e,n,i,r,s,a,o){return this.getBarycoord(t,e,n,i,Oe),o.set(0,0),o.addScaledVector(r,Oe.x),o.addScaledVector(s,Oe.y),o.addScaledVector(a,Oe.z),o}static isFrontFacing(t,e,n,i){return Be.subVectors(n,e),ze.subVectors(t,e),Be.cross(ze).dot(i)<0}set(t,e,n){return this.a.copy(t),this.b.copy(e),this.c.copy(n),this}setFromPointsAndIndices(t,e,n,i){return this.a.copy(t[e]),this.b.copy(t[n]),this.c.copy(t[i]),this}clone(){return(new this.constructor).copy(this)}copy(t){return this.a.copy(t.a),this.b.copy(t.b),this.c.copy(t.c),this}getArea(){return Be.subVectors(this.c,this.b),ze.subVectors(this.a,this.b),.5*Be.cross(ze).length()}getMidpoint(t){return void 0===t&&(console.warn("THREE.Triangle: .getMidpoint() target is now required"),t=new Lt),t.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)}getNormal(t){return je.getNormal(this.a,this.b,this.c,t)}getPlane(t){return void 0===t&&(console.warn("THREE.Triangle: .getPlane() target is now required"),t=new Ne),t.setFromCoplanarPoints(this.a,this.b,this.c)}getBarycoord(t,e){return je.getBarycoord(t,this.a,this.b,this.c,e)}getUV(t,e,n,i,r){return je.getUV(t,this.a,this.b,this.c,e,n,i,r)}containsPoint(t){return je.containsPoint(t,this.a,this.b,this.c)}isFrontFacing(t){return je.isFrontFacing(this.a,this.b,this.c,t)}intersectsBox(t){return t.intersectsTriangle(this)}closestPointToPoint(t,e){void 0===e&&(console.warn("THREE.Triangle: .closestPointToPoint() target is now required"),e=new Lt);const n=this.a,i=this.b,r=this.c;let s,a;He.subVectors(i,n),Ge.subVectors(r,n),ke.subVectors(t,n);const o=He.dot(ke),l=Ge.dot(ke);if(o<=0&&l<=0)return e.copy(n);Ve.subVectors(t,i);const c=He.dot(Ve),h=Ge.dot(Ve);if(c>=0&&h<=c)return e.copy(i);const u=o*h-c*l;if(u<=0&&o>=0&&c<=0)return s=o/(o-c),e.copy(n).addScaledVector(He,s);We.subVectors(t,r);const d=He.dot(We),p=Ge.dot(We);if(p>=0&&d<=p)return e.copy(r);const m=d*l-o*p;if(m<=0&&l>=0&&p<=0)return a=l/(l-p),e.copy(n).addScaledVector(Ge,a);const f=c*p-d*h;if(f<=0&&h-c>=0&&d-p>=0)return Ue.subVectors(r,i),a=(h-c)/(h-c+(d-p)),e.copy(i).addScaledVector(Ue,a);const g=1/(f+m+u);return s=m*g,a=u*g,e.copy(n).addScaledVector(He,s).addScaledVector(Ge,a)}equals(t){return t.a.equals(this.a)&&t.b.equals(this.b)&&t.c.equals(this.c)}}let qe=0;function Xe(){Object.defineProperty(this,"id",{value:qe++}),this.uuid=ct(),this.name="",this.type="Material",this.fog=!0,this.blending=1,this.side=0,this.vertexColors=!1,this.opacity=1,this.transparent=!1,this.blendSrc=204,this.blendDst=205,this.blendEquation=n,this.blendSrcAlpha=null,this.blendDstAlpha=null,this.blendEquationAlpha=null,this.depthFunc=3,this.depthTest=!0,this.depthWrite=!0,this.stencilWriteMask=255,this.stencilFunc=519,this.stencilRef=0,this.stencilFuncMask=255,this.stencilFail=tt,this.stencilZFail=tt,this.stencilZPass=tt,this.stencilWrite=!1,this.clippingPlanes=null,this.clipIntersection=!1,this.clipShadows=!1,this.shadowSide=null,this.colorWrite=!0,this.precision=null,this.polygonOffset=!1,this.polygonOffsetFactor=0,this.polygonOffsetUnits=0,this.dithering=!1,this.alphaTest=0,this.alphaToCoverage=!1,this.premultipliedAlpha=!1,this.visible=!0,this.toneMapped=!0,this.userData={},this.version=0}Xe.prototype=Object.assign(Object.create(rt.prototype),{constructor:Xe,isMaterial:!0,onBuild:function(){},onBeforeCompile:function(){},customProgramCacheKey:function(){return this.onBeforeCompile.toString()},setValues:function(t){if(void 0!==t)for(const e in t){const n=t[e];if(void 0===n){console.warn("THREE.Material: '"+e+"' parameter is undefined.");continue}if("shading"===e){console.warn("THREE."+this.type+": .shading has been removed. Use the boolean .flatShading instead."),this.flatShading=1===n;continue}const i=this[e];void 0!==i?i&&i.isColor?i.set(n):i&&i.isVector3&&n&&n.isVector3?i.copy(n):this[e]=n:console.warn("THREE."+this.type+": '"+e+"' is not a property of this material.")}},toJSON:function(t){const e=void 0===t||"string"==typeof t;e&&(t={textures:{},images:{}});const n={metadata:{version:4.5,type:"Material",generator:"Material.toJSON"}};function i(t){const e=[];for(const n in t){const i=t[n];delete i.metadata,e.push(i)}return e}if(n.uuid=this.uuid,n.type=this.type,""!==this.name&&(n.name=this.name),this.color&&this.color.isColor&&(n.color=this.color.getHex()),void 0!==this.roughness&&(n.roughness=this.roughness),void 0!==this.metalness&&(n.metalness=this.metalness),this.sheen&&this.sheen.isColor&&(n.sheen=this.sheen.getHex()),this.emissive&&this.emissive.isColor&&(n.emissive=this.emissive.getHex()),this.emissiveIntensity&&1!==this.emissiveIntensity&&(n.emissiveIntensity=this.emissiveIntensity),this.specular&&this.specular.isColor&&(n.specular=this.specular.getHex()),void 0!==this.shininess&&(n.shininess=this.shininess),void 0!==this.clearcoat&&(n.clearcoat=this.clearcoat),void 0!==this.clearcoatRoughness&&(n.clearcoatRoughness=this.clearcoatRoughness),this.clearcoatMap&&this.clearcoatMap.isTexture&&(n.clearcoatMap=this.clearcoatMap.toJSON(t).uuid),this.clearcoatRoughnessMap&&this.clearcoatRoughnessMap.isTexture&&(n.clearcoatRoughnessMap=this.clearcoatRoughnessMap.toJSON(t).uuid),this.clearcoatNormalMap&&this.clearcoatNormalMap.isTexture&&(n.clearcoatNormalMap=this.clearcoatNormalMap.toJSON(t).uuid,n.clearcoatNormalScale=this.clearcoatNormalScale.toArray()),this.map&&this.map.isTexture&&(n.map=this.map.toJSON(t).uuid),this.matcap&&this.matcap.isTexture&&(n.matcap=this.matcap.toJSON(t).uuid),this.alphaMap&&this.alphaMap.isTexture&&(n.alphaMap=this.alphaMap.toJSON(t).uuid),this.lightMap&&this.lightMap.isTexture&&(n.lightMap=this.lightMap.toJSON(t).uuid,n.lightMapIntensity=this.lightMapIntensity),this.aoMap&&this.aoMap.isTexture&&(n.aoMap=this.aoMap.toJSON(t).uuid,n.aoMapIntensity=this.aoMapIntensity),this.bumpMap&&this.bumpMap.isTexture&&(n.bumpMap=this.bumpMap.toJSON(t).uuid,n.bumpScale=this.bumpScale),this.normalMap&&this.normalMap.isTexture&&(n.normalMap=this.normalMap.toJSON(t).uuid,n.normalMapType=this.normalMapType,n.normalScale=this.normalScale.toArray()),this.displacementMap&&this.displacementMap.isTexture&&(n.displacementMap=this.displacementMap.toJSON(t).uuid,n.displacementScale=this.displacementScale,n.displacementBias=this.displacementBias),this.roughnessMap&&this.roughnessMap.isTexture&&(n.roughnessMap=this.roughnessMap.toJSON(t).uuid),this.metalnessMap&&this.metalnessMap.isTexture&&(n.metalnessMap=this.metalnessMap.toJSON(t).uuid),this.emissiveMap&&this.emissiveMap.isTexture&&(n.emissiveMap=this.emissiveMap.toJSON(t).uuid),this.specularMap&&this.specularMap.isTexture&&(n.specularMap=this.specularMap.toJSON(t).uuid),this.envMap&&this.envMap.isTexture&&(n.envMap=this.envMap.toJSON(t).uuid,void 0!==this.combine&&(n.combine=this.combine)),void 0!==this.envMapIntensity&&(n.envMapIntensity=this.envMapIntensity),void 0!==this.reflectivity&&(n.reflectivity=this.reflectivity),void 0!==this.refractionRatio&&(n.refractionRatio=this.refractionRatio),this.gradientMap&&this.gradientMap.isTexture&&(n.gradientMap=this.gradientMap.toJSON(t).uuid),void 0!==this.size&&(n.size=this.size),null!==this.shadowSide&&(n.shadowSide=this.shadowSide),void 0!==this.sizeAttenuation&&(n.sizeAttenuation=this.sizeAttenuation),1!==this.blending&&(n.blending=this.blending),0!==this.side&&(n.side=this.side),this.vertexColors&&(n.vertexColors=!0),this.opacity<1&&(n.opacity=this.opacity),!0===this.transparent&&(n.transparent=this.transparent),n.depthFunc=this.depthFunc,n.depthTest=this.depthTest,n.depthWrite=this.depthWrite,n.colorWrite=this.colorWrite,n.stencilWrite=this.stencilWrite,n.stencilWriteMask=this.stencilWriteMask,n.stencilFunc=this.stencilFunc,n.stencilRef=this.stencilRef,n.stencilFuncMask=this.stencilFuncMask,n.stencilFail=this.stencilFail,n.stencilZFail=this.stencilZFail,n.stencilZPass=this.stencilZPass,this.rotation&&0!==this.rotation&&(n.rotation=this.rotation),!0===this.polygonOffset&&(n.polygonOffset=!0),0!==this.polygonOffsetFactor&&(n.polygonOffsetFactor=this.polygonOffsetFactor),0!==this.polygonOffsetUnits&&(n.polygonOffsetUnits=this.polygonOffsetUnits),this.linewidth&&1!==this.linewidth&&(n.linewidth=this.linewidth),void 0!==this.dashSize&&(n.dashSize=this.dashSize),void 0!==this.gapSize&&(n.gapSize=this.gapSize),void 0!==this.scale&&(n.scale=this.scale),!0===this.dithering&&(n.dithering=!0),this.alphaTest>0&&(n.alphaTest=this.alphaTest),!0===this.alphaToCoverage&&(n.alphaToCoverage=this.alphaToCoverage),!0===this.premultipliedAlpha&&(n.premultipliedAlpha=this.premultipliedAlpha),!0===this.wireframe&&(n.wireframe=this.wireframe),this.wireframeLinewidth>1&&(n.wireframeLinewidth=this.wireframeLinewidth),"round"!==this.wireframeLinecap&&(n.wireframeLinecap=this.wireframeLinecap),"round"!==this.wireframeLinejoin&&(n.wireframeLinejoin=this.wireframeLinejoin),!0===this.morphTargets&&(n.morphTargets=!0),!0===this.morphNormals&&(n.morphNormals=!0),!0===this.skinning&&(n.skinning=!0),!0===this.flatShading&&(n.flatShading=this.flatShading),!1===this.visible&&(n.visible=!1),!1===this.toneMapped&&(n.toneMapped=!1),"{}"!==JSON.stringify(this.userData)&&(n.userData=this.userData),e){const e=i(t.textures),r=i(t.images);e.length>0&&(n.textures=e),r.length>0&&(n.images=r)}return n},clone:function(){return(new this.constructor).copy(this)},copy:function(t){this.name=t.name,this.fog=t.fog,this.blending=t.blending,this.side=t.side,this.vertexColors=t.vertexColors,this.opacity=t.opacity,this.transparent=t.transparent,this.blendSrc=t.blendSrc,this.blendDst=t.blendDst,this.blendEquation=t.blendEquation,this.blendSrcAlpha=t.blendSrcAlpha,this.blendDstAlpha=t.blendDstAlpha,this.blendEquationAlpha=t.blendEquationAlpha,this.depthFunc=t.depthFunc,this.depthTest=t.depthTest,this.depthWrite=t.depthWrite,this.stencilWriteMask=t.stencilWriteMask,this.stencilFunc=t.stencilFunc,this.stencilRef=t.stencilRef,this.stencilFuncMask=t.stencilFuncMask,this.stencilFail=t.stencilFail,this.stencilZFail=t.stencilZFail,this.stencilZPass=t.stencilZPass,this.stencilWrite=t.stencilWrite;const e=t.clippingPlanes;let n=null;if(null!==e){const t=e.length;n=new Array(t);for(let i=0;i!==t;++i)n[i]=e[i].clone()}return this.clippingPlanes=n,this.clipIntersection=t.clipIntersection,this.clipShadows=t.clipShadows,this.shadowSide=t.shadowSide,this.colorWrite=t.colorWrite,this.precision=t.precision,this.polygonOffset=t.polygonOffset,this.polygonOffsetFactor=t.polygonOffsetFactor,this.polygonOffsetUnits=t.polygonOffsetUnits,this.dithering=t.dithering,this.alphaTest=t.alphaTest,this.alphaToCoverage=t.alphaToCoverage,this.premultipliedAlpha=t.premultipliedAlpha,this.visible=t.visible,this.toneMapped=t.toneMapped,this.userData=JSON.parse(JSON.stringify(t.userData)),this},dispose:function(){this.dispatchEvent({type:"dispose"})}}),Object.defineProperty(Xe.prototype,"needsUpdate",{set:function(t){!0===t&&this.version++}});const Ye={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074},Ze={h:0,s:0,l:0},Je={h:0,s:0,l:0};function Qe(t,e,n){return n<0&&(n+=1),n>1&&(n-=1),n<1/6?t+6*(e-t)*n:n<.5?e:n<2/3?t+6*(e-t)*(2/3-n):t}function Ke(t){return t<.04045?.0773993808*t:Math.pow(.9478672986*t+.0521327014,2.4)}function $e(t){return t<.0031308?12.92*t:1.055*Math.pow(t,.41666)-.055}class tn{constructor(t,e,n){return void 0===e&&void 0===n?this.set(t):this.setRGB(t,e,n)}set(t){return t&&t.isColor?this.copy(t):"number"==typeof t?this.setHex(t):"string"==typeof t&&this.setStyle(t),this}setScalar(t){return this.r=t,this.g=t,this.b=t,this}setHex(t){return t=Math.floor(t),this.r=(t>>16&255)/255,this.g=(t>>8&255)/255,this.b=(255&t)/255,this}setRGB(t,e,n){return this.r=t,this.g=e,this.b=n,this}setHSL(t,e,n){if(t=ut(t,1),e=ht(e,0,1),n=ht(n,0,1),0===e)this.r=this.g=this.b=n;else{const i=n<=.5?n*(1+e):n+e-n*e,r=2*n-i;this.r=Qe(r,i,t+1/3),this.g=Qe(r,i,t),this.b=Qe(r,i,t-1/3)}return this}setStyle(t){function e(e){void 0!==e&&parseFloat(e)<1&&console.warn("THREE.Color: Alpha component of "+t+" will be ignored.")}let n;if(n=/^((?:rgb|hsl)a?)\(([^\)]*)\)/.exec(t)){let t;const i=n[1],r=n[2];switch(i){case"rgb":case"rgba":if(t=/^\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r))return this.r=Math.min(255,parseInt(t[1],10))/255,this.g=Math.min(255,parseInt(t[2],10))/255,this.b=Math.min(255,parseInt(t[3],10))/255,e(t[4]),this;if(t=/^\s*(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r))return this.r=Math.min(100,parseInt(t[1],10))/100,this.g=Math.min(100,parseInt(t[2],10))/100,this.b=Math.min(100,parseInt(t[3],10))/100,e(t[4]),this;break;case"hsl":case"hsla":if(t=/^\s*(\d*\.?\d+)\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(?:,\s*(\d*\.?\d+)\s*)?$/.exec(r)){const n=parseFloat(t[1])/360,i=parseInt(t[2],10)/100,r=parseInt(t[3],10)/100;return e(t[4]),this.setHSL(n,i,r)}}}else if(n=/^\#([A-Fa-f\d]+)$/.exec(t)){const t=n[1],e=t.length;if(3===e)return this.r=parseInt(t.charAt(0)+t.charAt(0),16)/255,this.g=parseInt(t.charAt(1)+t.charAt(1),16)/255,this.b=parseInt(t.charAt(2)+t.charAt(2),16)/255,this;if(6===e)return this.r=parseInt(t.charAt(0)+t.charAt(1),16)/255,this.g=parseInt(t.charAt(2)+t.charAt(3),16)/255,this.b=parseInt(t.charAt(4)+t.charAt(5),16)/255,this}return t&&t.length>0?this.setColorName(t):this}setColorName(t){const e=Ye[t.toLowerCase()];return void 0!==e?this.setHex(e):console.warn("THREE.Color: Unknown color "+t),this}clone(){return new this.constructor(this.r,this.g,this.b)}copy(t){return this.r=t.r,this.g=t.g,this.b=t.b,this}copyGammaToLinear(t,e=2){return this.r=Math.pow(t.r,e),this.g=Math.pow(t.g,e),this.b=Math.pow(t.b,e),this}copyLinearToGamma(t,e=2){const n=e>0?1/e:1;return this.r=Math.pow(t.r,n),this.g=Math.pow(t.g,n),this.b=Math.pow(t.b,n),this}convertGammaToLinear(t){return this.copyGammaToLinear(this,t),this}convertLinearToGamma(t){return this.copyLinearToGamma(this,t),this}copySRGBToLinear(t){return this.r=Ke(t.r),this.g=Ke(t.g),this.b=Ke(t.b),this}copyLinearToSRGB(t){return this.r=$e(t.r),this.g=$e(t.g),this.b=$e(t.b),this}convertSRGBToLinear(){return this.copySRGBToLinear(this),this}convertLinearToSRGB(){return this.copyLinearToSRGB(this),this}getHex(){return 255*this.r<<16^255*this.g<<8^255*this.b<<0}getHexString(){return("000000"+this.getHex().toString(16)).slice(-6)}getHSL(t){void 0===t&&(console.warn("THREE.Color: .getHSL() target is now required"),t={h:0,s:0,l:0});const e=this.r,n=this.g,i=this.b,r=Math.max(e,n,i),s=Math.min(e,n,i);let a,o;const l=(s+r)/2;if(s===r)a=0,o=0;else{const t=r-s;switch(o=l<=.5?t/(r+s):t/(2-r-s),r){case e:a=(n-i)/t+(ne&&(e=t[n]);return e}const vn={Int8Array:Int8Array,Uint8Array:Uint8Array,Uint8ClampedArray:Uint8ClampedArray,Int16Array:Int16Array,Uint16Array:Uint16Array,Int32Array:Int32Array,Uint32Array:Uint32Array,Float32Array:Float32Array,Float64Array:Float64Array};function yn(t,e){return new vn[t](e)}let xn=0;const _n=new se,wn=new Ce,bn=new Lt,Mn=new Pt,Sn=new Pt,Tn=new Lt;class En extends rt{constructor(){super(),Object.defineProperty(this,"id",{value:xn++}),this.uuid=ct(),this.name="",this.type="BufferGeometry",this.index=null,this.attributes={},this.morphAttributes={},this.morphTargetsRelative=!1,this.groups=[],this.boundingBox=null,this.boundingSphere=null,this.drawRange={start:0,count:1/0},this.userData={}}getIndex(){return this.index}setIndex(t){return Array.isArray(t)?this.index=new(gn(t)>65535?dn:hn)(t,1):this.index=t,this}getAttribute(t){return this.attributes[t]}setAttribute(t,e){return this.attributes[t]=e,this}deleteAttribute(t){return delete this.attributes[t],this}hasAttribute(t){return void 0!==this.attributes[t]}addGroup(t,e,n=0){this.groups.push({start:t,count:e,materialIndex:n})}clearGroups(){this.groups=[]}setDrawRange(t,e){this.drawRange.start=t,this.drawRange.count=e}applyMatrix4(t){const e=this.attributes.position;void 0!==e&&(e.applyMatrix4(t),e.needsUpdate=!0);const n=this.attributes.normal;if(void 0!==n){const e=(new yt).getNormalMatrix(t);n.applyNormalMatrix(e),n.needsUpdate=!0}const i=this.attributes.tangent;return void 0!==i&&(i.transformDirection(t),i.needsUpdate=!0),null!==this.boundingBox&&this.computeBoundingBox(),null!==this.boundingSphere&&this.computeBoundingSphere(),this}rotateX(t){return _n.makeRotationX(t),this.applyMatrix4(_n),this}rotateY(t){return _n.makeRotationY(t),this.applyMatrix4(_n),this}rotateZ(t){return _n.makeRotationZ(t),this.applyMatrix4(_n),this}translate(t,e,n){return _n.makeTranslation(t,e,n),this.applyMatrix4(_n),this}scale(t,e,n){return _n.makeScale(t,e,n),this.applyMatrix4(_n),this}lookAt(t){return wn.lookAt(t),wn.updateMatrix(),this.applyMatrix4(wn.matrix),this}center(){return this.computeBoundingBox(),this.boundingBox.getCenter(bn).negate(),this.translate(bn.x,bn.y,bn.z),this}setFromPoints(t){const e=[];for(let n=0,i=t.length;n0&&(t.userData=this.userData),void 0!==this.parameters){const e=this.parameters;for(const n in e)void 0!==e[n]&&(t[n]=e[n]);return t}t.data={attributes:{}};const e=this.index;null!==e&&(t.data.index={type:e.array.constructor.name,array:Array.prototype.slice.call(e.array)});const n=this.attributes;for(const e in n){const i=n[e];t.data.attributes[e]=i.toJSON(t.data)}const i={};let r=!1;for(const e in this.morphAttributes){const n=this.morphAttributes[e],s=[];for(let e=0,i=n.length;e0&&(i[e]=s,r=!0)}r&&(t.data.morphAttributes=i,t.data.morphTargetsRelative=this.morphTargetsRelative);const s=this.groups;s.length>0&&(t.data.groups=JSON.parse(JSON.stringify(s)));const a=this.boundingSphere;return null!==a&&(t.data.boundingSphere={center:a.center.toArray(),radius:a.radius}),t}clone(){return(new En).copy(this)}copy(t){this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null;const e={};this.name=t.name;const n=t.index;null!==n&&this.setIndex(n.clone(e));const i=t.attributes;for(const t in i){const n=i[t];this.setAttribute(t,n.clone(e))}const r=t.morphAttributes;for(const t in r){const n=[],i=r[t];for(let t=0,r=i.length;t0){const t=e[n[0]];if(void 0!==t){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let e=0,n=t.length;e0&&console.error("THREE.Mesh.updateMorphTargets() no longer supports THREE.Geometry. Use THREE.BufferGeometry instead.")}}raycast(t,e){const n=this.geometry,i=this.material,r=this.matrixWorld;if(void 0===i)return;if(null===n.boundingSphere&&n.computeBoundingSphere(),Rn.copy(n.boundingSphere),Rn.applyMatrix4(r),!1===t.ray.intersectsSphere(Rn))return;if(An.copy(r).invert(),Ln.copy(t.ray).applyMatrix4(An),null!==n.boundingBox&&!1===Ln.intersectsBox(n.boundingBox))return;let s;if(n.isBufferGeometry){const r=n.index,a=n.attributes.position,o=n.morphAttributes.position,l=n.morphTargetsRelative,c=n.attributes.uv,h=n.attributes.uv2,u=n.groups,d=n.drawRange;if(null!==r)if(Array.isArray(i))for(let n=0,p=u.length;nn.far?null:{distance:c,point:Vn.clone(),object:t}}(t,e,n,i,Cn,Pn,Dn,kn);if(p){o&&(Hn.fromBufferAttribute(o,c),Gn.fromBufferAttribute(o,h),Un.fromBufferAttribute(o,u),p.uv=je.getUV(kn,Cn,Pn,Dn,Hn,Gn,Un,new vt)),l&&(Hn.fromBufferAttribute(l,c),Gn.fromBufferAttribute(l,h),Un.fromBufferAttribute(l,u),p.uv2=je.getUV(kn,Cn,Pn,Dn,Hn,Gn,Un,new vt));const t={a:c,b:h,c:u,normal:new Lt,materialIndex:0};je.getNormal(Cn,Pn,Dn,t.normal),p.face=t}return p}Wn.prototype.isMesh=!0;class qn extends En{constructor(t=1,e=1,n=1,i=1,r=1,s=1){super(),this.type="BoxGeometry",this.parameters={width:t,height:e,depth:n,widthSegments:i,heightSegments:r,depthSegments:s};const a=this;i=Math.floor(i),r=Math.floor(r),s=Math.floor(s);const o=[],l=[],c=[],h=[];let u=0,d=0;function p(t,e,n,i,r,s,p,m,f,g,v){const y=s/f,x=p/g,_=s/2,w=p/2,b=m/2,M=f+1,S=g+1;let T=0,E=0;const A=new Lt;for(let s=0;s0?1:-1,c.push(A.x,A.y,A.z),h.push(o/f),h.push(1-s/g),T+=1}}for(let t=0;t0&&(e.defines=this.defines),e.vertexShader=this.vertexShader,e.fragmentShader=this.fragmentShader;const n={};for(const t in this.extensions)!0===this.extensions[t]&&(n[t]=!0);return Object.keys(n).length>0&&(e.extensions=n),e}}Jn.prototype.isShaderMaterial=!0;class Qn extends Ce{constructor(){super(),this.type="Camera",this.matrixWorldInverse=new se,this.projectionMatrix=new se,this.projectionMatrixInverse=new se}copy(t,e){return super.copy(t,e),this.matrixWorldInverse.copy(t.matrixWorldInverse),this.projectionMatrix.copy(t.projectionMatrix),this.projectionMatrixInverse.copy(t.projectionMatrixInverse),this}getWorldDirection(t){void 0===t&&(console.warn("THREE.Camera: .getWorldDirection() target is now required"),t=new Lt),this.updateWorldMatrix(!0,!1);const e=this.matrixWorld.elements;return t.set(-e[8],-e[9],-e[10]).normalize()}updateMatrixWorld(t){super.updateMatrixWorld(t),this.matrixWorldInverse.copy(this.matrixWorld).invert()}updateWorldMatrix(t,e){super.updateWorldMatrix(t,e),this.matrixWorldInverse.copy(this.matrixWorld).invert()}clone(){return(new this.constructor).copy(this)}}Qn.prototype.isCamera=!0;class Kn extends Qn{constructor(t=50,e=1,n=.1,i=2e3){super(),this.type="PerspectiveCamera",this.fov=t,this.zoom=1,this.near=n,this.far=i,this.focus=10,this.aspect=e,this.view=null,this.filmGauge=35,this.filmOffset=0,this.updateProjectionMatrix()}copy(t,e){return super.copy(t,e),this.fov=t.fov,this.zoom=t.zoom,this.near=t.near,this.far=t.far,this.focus=t.focus,this.aspect=t.aspect,this.view=null===t.view?null:Object.assign({},t.view),this.filmGauge=t.filmGauge,this.filmOffset=t.filmOffset,this}setFocalLength(t){const e=.5*this.getFilmHeight()/t;this.fov=2*lt*Math.atan(e),this.updateProjectionMatrix()}getFocalLength(){const t=Math.tan(.5*ot*this.fov);return.5*this.getFilmHeight()/t}getEffectiveFOV(){return 2*lt*Math.atan(Math.tan(.5*ot*this.fov)/this.zoom)}getFilmWidth(){return this.filmGauge*Math.min(this.aspect,1)}getFilmHeight(){return this.filmGauge/Math.max(this.aspect,1)}setViewOffset(t,e,n,i,r,s){this.aspect=t/e,null===this.view&&(this.view={enabled:!0,fullWidth:1,fullHeight:1,offsetX:0,offsetY:0,width:1,height:1}),this.view.enabled=!0,this.view.fullWidth=t,this.view.fullHeight=e,this.view.offsetX=n,this.view.offsetY=i,this.view.width=r,this.view.height=s,this.updateProjectionMatrix()}clearViewOffset(){null!==this.view&&(this.view.enabled=!1),this.updateProjectionMatrix()}updateProjectionMatrix(){const t=this.near;let e=t*Math.tan(.5*ot*this.fov)/this.zoom,n=2*e,i=this.aspect*n,r=-.5*i;const s=this.view;if(null!==this.view&&this.view.enabled){const t=s.fullWidth,a=s.fullHeight;r+=s.offsetX*i/t,e-=s.offsetY*n/a,i*=s.width/t,n*=s.height/a}const a=this.filmOffset;0!==a&&(r+=t*a/this.getFilmWidth()),this.projectionMatrix.makePerspective(r,r+i,e,e-n,t,this.far),this.projectionMatrixInverse.copy(this.projectionMatrix).invert()}toJSON(t){const e=super.toJSON(t);return e.object.fov=this.fov,e.object.zoom=this.zoom,e.object.near=this.near,e.object.far=this.far,e.object.focus=this.focus,e.object.aspect=this.aspect,null!==this.view&&(e.object.view=Object.assign({},this.view)),e.object.filmGauge=this.filmGauge,e.object.filmOffset=this.filmOffset,e}}Kn.prototype.isPerspectiveCamera=!0;const $n=90;class ti extends Ce{constructor(t,e,n){if(super(),this.type="CubeCamera",!0!==n.isWebGLCubeRenderTarget)return void console.error("THREE.CubeCamera: The constructor now expects an instance of WebGLCubeRenderTarget as third parameter.");this.renderTarget=n;const i=new Kn($n,1,t,e);i.layers=this.layers,i.up.set(0,-1,0),i.lookAt(new Lt(1,0,0)),this.add(i);const r=new Kn($n,1,t,e);r.layers=this.layers,r.up.set(0,-1,0),r.lookAt(new Lt(-1,0,0)),this.add(r);const s=new Kn($n,1,t,e);s.layers=this.layers,s.up.set(0,0,1),s.lookAt(new Lt(0,1,0)),this.add(s);const a=new Kn($n,1,t,e);a.layers=this.layers,a.up.set(0,0,-1),a.lookAt(new Lt(0,-1,0)),this.add(a);const o=new Kn($n,1,t,e);o.layers=this.layers,o.up.set(0,-1,0),o.lookAt(new Lt(0,0,1)),this.add(o);const l=new Kn($n,1,t,e);l.layers=this.layers,l.up.set(0,-1,0),l.lookAt(new Lt(0,0,-1)),this.add(l)}update(t,e){null===this.parent&&this.updateMatrixWorld();const n=this.renderTarget,[i,r,s,a,o,l]=this.children,c=t.xr.enabled,h=t.getRenderTarget();t.xr.enabled=!1;const u=n.texture.generateMipmaps;n.texture.generateMipmaps=!1,t.setRenderTarget(n,0),t.render(e,i),t.setRenderTarget(n,1),t.render(e,r),t.setRenderTarget(n,2),t.render(e,s),t.setRenderTarget(n,3),t.render(e,a),t.setRenderTarget(n,4),t.render(e,o),n.texture.generateMipmaps=u,t.setRenderTarget(n,5),t.render(e,l),t.setRenderTarget(h),t.xr.enabled=c}}class ei extends bt{constructor(t,e,n,i,s,a,o,l,c,h){super(t=void 0!==t?t:[],e=void 0!==e?e:r,n,i,s,a,o=void 0!==o?o:T,l,c,h),this._needsFlipEnvMap=!0,this.flipY=!1}get images(){return this.image}set images(t){this.image=t}}ei.prototype.isCubeTexture=!0;class ni extends Tt{constructor(t,e,n){Number.isInteger(e)&&(console.warn("THREE.WebGLCubeRenderTarget: constructor signature is now WebGLCubeRenderTarget( size, options )"),e=n),super(t,t,e),e=e||{},this.texture=new ei(void 0,e.mapping,e.wrapS,e.wrapT,e.magFilter,e.minFilter,e.format,e.type,e.anisotropy,e.encoding),this.texture.generateMipmaps=void 0!==e.generateMipmaps&&e.generateMipmaps,this.texture.minFilter=void 0!==e.minFilter?e.minFilter:g,this.texture._needsFlipEnvMap=!1}fromEquirectangularTexture(t,e){this.texture.type=e.type,this.texture.format=E,this.texture.encoding=e.encoding,this.texture.generateMipmaps=e.generateMipmaps,this.texture.minFilter=e.minFilter,this.texture.magFilter=e.magFilter;const n={uniforms:{tEquirect:{value:null}},vertexShader:"\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\tvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\n\t\t\t\t\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n\n\t\t\t\t}\n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvWorldDirection = transformDirection( position, modelMatrix );\n\n\t\t\t\t\t#include \n\t\t\t\t\t#include \n\n\t\t\t\t}\n\t\t\t",fragmentShader:"\n\n\t\t\t\tuniform sampler2D tEquirect;\n\n\t\t\t\tvarying vec3 vWorldDirection;\n\n\t\t\t\t#include \n\n\t\t\t\tvoid main() {\n\n\t\t\t\t\tvec3 direction = normalize( vWorldDirection );\n\n\t\t\t\t\tvec2 sampleUV = equirectUv( direction );\n\n\t\t\t\t\tgl_FragColor = texture2D( tEquirect, sampleUV );\n\n\t\t\t\t}\n\t\t\t"},i=new qn(5,5,5),r=new Jn({name:"CubemapFromEquirect",uniforms:Xn(n.uniforms),vertexShader:n.vertexShader,fragmentShader:n.fragmentShader,side:1,blending:0});r.uniforms.tEquirect.value=e;const s=new Wn(i,r),a=e.minFilter;e.minFilter===y&&(e.minFilter=g);return new ti(1,10,this).update(t,s),e.minFilter=a,s.geometry.dispose(),s.material.dispose(),this}clear(t,e,n,i){const r=t.getRenderTarget();for(let r=0;r<6;r++)t.setRenderTarget(this,r),t.clear(e,n,i);t.setRenderTarget(r)}}ni.prototype.isWebGLCubeRenderTarget=!0;class ii extends bt{constructor(t,e,n,i,r,s,a,o,l,c,h,u){super(null,s,a,o,l,c,i,r,h,u),this.image={data:t||null,width:e||1,height:n||1},this.magFilter=void 0!==l?l:p,this.minFilter=void 0!==c?c:p,this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1,this.needsUpdate=!0}}ii.prototype.isDataTexture=!0;const ri=new Jt,si=new Lt;class ai{constructor(t=new Ne,e=new Ne,n=new Ne,i=new Ne,r=new Ne,s=new Ne){this.planes=[t,e,n,i,r,s]}set(t,e,n,i,r,s){const a=this.planes;return a[0].copy(t),a[1].copy(e),a[2].copy(n),a[3].copy(i),a[4].copy(r),a[5].copy(s),this}copy(t){const e=this.planes;for(let n=0;n<6;n++)e[n].copy(t.planes[n]);return this}setFromProjectionMatrix(t){const e=this.planes,n=t.elements,i=n[0],r=n[1],s=n[2],a=n[3],o=n[4],l=n[5],c=n[6],h=n[7],u=n[8],d=n[9],p=n[10],m=n[11],f=n[12],g=n[13],v=n[14],y=n[15];return e[0].setComponents(a-i,h-o,m-u,y-f).normalize(),e[1].setComponents(a+i,h+o,m+u,y+f).normalize(),e[2].setComponents(a+r,h+l,m+d,y+g).normalize(),e[3].setComponents(a-r,h-l,m-d,y-g).normalize(),e[4].setComponents(a-s,h-c,m-p,y-v).normalize(),e[5].setComponents(a+s,h+c,m+p,y+v).normalize(),this}intersectsObject(t){const e=t.geometry;return null===e.boundingSphere&&e.computeBoundingSphere(),ri.copy(e.boundingSphere).applyMatrix4(t.matrixWorld),this.intersectsSphere(ri)}intersectsSprite(t){return ri.center.set(0,0,0),ri.radius=.7071067811865476,ri.applyMatrix4(t.matrixWorld),this.intersectsSphere(ri)}intersectsSphere(t){const e=this.planes,n=t.center,i=-t.radius;for(let t=0;t<6;t++){if(e[t].distanceToPoint(n)0?t.max.x:t.min.x,si.y=i.normal.y>0?t.max.y:t.min.y,si.z=i.normal.z>0?t.max.z:t.min.z,i.distanceToPoint(si)<0)return!1}return!0}containsPoint(t){const e=this.planes;for(let n=0;n<6;n++)if(e[n].distanceToPoint(t)<0)return!1;return!0}clone(){return(new this.constructor).copy(this)}}function oi(){let t=null,e=!1,n=null,i=null;function r(e,s){n(e,s),i=t.requestAnimationFrame(r)}return{start:function(){!0!==e&&null!==n&&(i=t.requestAnimationFrame(r),e=!0)},stop:function(){t.cancelAnimationFrame(i),e=!1},setAnimationLoop:function(t){n=t},setContext:function(e){t=e}}}function li(t,e){const n=e.isWebGL2,i=new WeakMap;return{get:function(t){return t.isInterleavedBufferAttribute&&(t=t.data),i.get(t)},remove:function(e){e.isInterleavedBufferAttribute&&(e=e.data);const n=i.get(e);n&&(t.deleteBuffer(n.buffer),i.delete(e))},update:function(e,r){if(e.isGLBufferAttribute){const t=i.get(e);return void((!t||t.version 0.0 ) {\n\t\tdistanceFalloff *= pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t}\n\treturn distanceFalloff;\n#else\n\tif( cutoffDistance > 0.0 && decayExponent > 0.0 ) {\n\t\treturn pow( saturate( -lightDistance / cutoffDistance + 1.0 ), decayExponent );\n\t}\n\treturn 1.0;\n#endif\n}\nvec3 BRDF_Diffuse_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 specularColor, const in float dotLH ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotLH - 6.98316 ) * dotLH );\n\treturn ( 1.0 - specularColor ) * fresnel + specularColor;\n}\nvec3 F_Schlick_RoughnessDependent( const in vec3 F0, const in float dotNV, const in float roughness ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotNV - 6.98316 ) * dotNV );\n\tvec3 Fr = max( vec3( 1.0 - roughness ), F0 ) - F0;\n\treturn Fr * fresnel + F0;\n}\nfloat G_GGX_Smith( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gl = dotNL + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\tfloat gv = dotNV + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\treturn 1.0 / ( gl * gv );\n}\nfloat G_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\nvec3 BRDF_Specular_GGX( const in IncidentLight incidentLight, const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float roughness ) {\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( incidentLight.direction + viewDir );\n\tfloat dotNL = saturate( dot( normal, incidentLight.direction ) );\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tfloat dotNH = saturate( dot( normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\tfloat D = D_GGX( alpha, dotNH );\n\treturn F * ( G * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transposeMat3( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\nvec3 BRDF_Specular_GGX_Environment( const in vec3 viewDir, const in vec3 normal, const in vec3 specularColor, const in float roughness ) {\n\tfloat dotNV = saturate( dot( normal, viewDir ) );\n\tvec2 brdf = integrateSpecularBRDF( dotNV, roughness );\n\treturn specularColor * brdf.x + brdf.y;\n}\nvoid BRDF_Specular_Multiscattering_Environment( const in GeometricContext geometry, const in vec3 specularColor, const in float roughness, inout vec3 singleScatter, inout vec3 multiScatter ) {\n\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\tvec3 F = F_Schlick_RoughnessDependent( specularColor, dotNV, roughness );\n\tvec2 brdf = integrateSpecularBRDF( dotNV, roughness );\n\tvec3 FssEss = F * brdf.x + brdf.y;\n\tfloat Ess = brdf.x + brdf.y;\n\tfloat Ems = 1.0 - Ess;\n\tvec3 Favg = specularColor + ( 1.0 - specularColor ) * 0.047619;\tvec3 Fms = FssEss * Favg / ( 1.0 - Ems * Favg );\n\tsingleScatter += FssEss;\n\tmultiScatter += Fms * Ems;\n}\nfloat G_BlinnPhong_Implicit( ) {\n\treturn 0.25;\n}\nfloat D_BlinnPhong( const in float shininess, const in float dotNH ) {\n\treturn RECIPROCAL_PI * ( shininess * 0.5 + 1.0 ) * pow( dotNH, shininess );\n}\nvec3 BRDF_Specular_BlinnPhong( const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float shininess ) {\n\tvec3 halfDir = normalize( incidentLight.direction + geometry.viewDir );\n\tfloat dotNH = saturate( dot( geometry.normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_BlinnPhong_Implicit( );\n\tfloat D = D_BlinnPhong( shininess, dotNH );\n\treturn F * ( G * D );\n}\nfloat GGXRoughnessToBlinnExponent( const in float ggxRoughness ) {\n\treturn ( 2.0 / pow2( ggxRoughness + 0.0001 ) - 2.0 );\n}\nfloat BlinnExponentToGGXRoughness( const in float blinnExponent ) {\n\treturn sqrt( 2.0 / ( blinnExponent + 2.0 ) );\n}\n#if defined( USE_SHEEN )\nfloat D_Charlie(float roughness, float NoH) {\n\tfloat invAlpha = 1.0 / roughness;\n\tfloat cos2h = NoH * NoH;\n\tfloat sin2h = max(1.0 - cos2h, 0.0078125);\treturn (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * PI);\n}\nfloat V_Neubelt(float NoV, float NoL) {\n\treturn saturate(1.0 / (4.0 * (NoL + NoV - NoL * NoV)));\n}\nvec3 BRDF_Specular_Sheen( const in float roughness, const in vec3 L, const in GeometricContext geometry, vec3 specularColor ) {\n\tvec3 N = geometry.normal;\n\tvec3 V = geometry.viewDir;\n\tvec3 H = normalize( V + L );\n\tfloat dotNH = saturate( dot( N, H ) );\n\treturn specularColor * D_Charlie( roughness, dotNH ) * V_Neubelt( dot(N, V), dot(N, L) );\n}\n#endif",bumpmap_pars_fragment:"#ifdef USE_BUMPMAP\n\tuniform sampler2D bumpMap;\n\tuniform float bumpScale;\n\tvec2 dHdxy_fwd() {\n\t\tvec2 dSTdx = dFdx( vUv );\n\t\tvec2 dSTdy = dFdy( vUv );\n\t\tfloat Hll = bumpScale * texture2D( bumpMap, vUv ).x;\n\t\tfloat dBx = bumpScale * texture2D( bumpMap, vUv + dSTdx ).x - Hll;\n\t\tfloat dBy = bumpScale * texture2D( bumpMap, vUv + dSTdy ).x - Hll;\n\t\treturn vec2( dBx, dBy );\n\t}\n\tvec3 perturbNormalArb( vec3 surf_pos, vec3 surf_norm, vec2 dHdxy, float faceDirection ) {\n\t\tvec3 vSigmaX = vec3( dFdx( surf_pos.x ), dFdx( surf_pos.y ), dFdx( surf_pos.z ) );\n\t\tvec3 vSigmaY = vec3( dFdy( surf_pos.x ), dFdy( surf_pos.y ), dFdy( surf_pos.z ) );\n\t\tvec3 vN = surf_norm;\n\t\tvec3 R1 = cross( vSigmaY, vN );\n\t\tvec3 R2 = cross( vN, vSigmaX );\n\t\tfloat fDet = dot( vSigmaX, R1 ) * faceDirection;\n\t\tvec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );\n\t\treturn normalize( abs( fDet ) * surf_norm - vGrad );\n\t}\n#endif",clipping_planes_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvec4 plane;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\tplane = clippingPlanes[ i ];\n\t\tif ( dot( vClipPosition, plane.xyz ) > plane.w ) discard;\n\t}\n\t#pragma unroll_loop_end\n\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\tbool clipped = true;\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tclipped = ( dot( vClipPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t}\n\t\t#pragma unroll_loop_end\n\t\tif ( clipped ) discard;\n\t#endif\n#endif",clipping_planes_pars_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif",clipping_planes_pars_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvarying vec3 vClipPosition;\n#endif",clipping_planes_vertex:"#if NUM_CLIPPING_PLANES > 0\n\tvClipPosition = - mvPosition.xyz;\n#endif",color_fragment:"#if defined( USE_COLOR_ALPHA )\n\tdiffuseColor *= vColor;\n#elif defined( USE_COLOR )\n\tdiffuseColor.rgb *= vColor;\n#endif",color_pars_fragment:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR )\n\tvarying vec3 vColor;\n#endif",color_pars_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvarying vec4 vColor;\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvarying vec3 vColor;\n#endif",color_vertex:"#if defined( USE_COLOR_ALPHA )\n\tvColor = vec4( 1.0 );\n#elif defined( USE_COLOR ) || defined( USE_INSTANCING_COLOR )\n\tvColor = vec3( 1.0 );\n#endif\n#ifdef USE_COLOR\n\tvColor *= color;\n#endif\n#ifdef USE_INSTANCING_COLOR\n\tvColor.xyz *= instanceColor.xyz;\n#endif",common:"#define PI 3.141592653589793\n#define PI2 6.283185307179586\n#define PI_HALF 1.5707963267948966\n#define RECIPROCAL_PI 0.3183098861837907\n#define RECIPROCAL_PI2 0.15915494309189535\n#define EPSILON 1e-6\n#ifndef saturate\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\n#define whiteComplement(a) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat average( const in vec3 color ) { return dot( color, vec3( 0.3333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract(sin(sn) * c);\n}\n#ifdef HIGH_PRECISION\n\tfloat precisionSafeLength( vec3 v ) { return length( v ); }\n#else\n\tfloat max3( vec3 v ) { return max( max( v.x, v.y ), v.z ); }\n\tfloat precisionSafeLength( vec3 v ) {\n\t\tfloat maxComponent = max3( abs( v ) );\n\t\treturn length( v / maxComponent ) * maxComponent;\n\t}\n#endif\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\nstruct GeometricContext {\n\tvec3 position;\n\tvec3 normal;\n\tvec3 viewDir;\n#ifdef CLEARCOAT\n\tvec3 clearcoatNormal;\n#endif\n};\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nvec3 projectOnPlane(in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\tfloat distance = dot( planeNormal, point - pointOnPlane );\n\treturn - distance * planeNormal + point;\n}\nfloat sideOfPlane( in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn sign( dot( point - pointOnPlane, planeNormal ) );\n}\nvec3 linePlaneIntersect( in vec3 pointOnLine, in vec3 lineDirection, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn lineDirection * ( dot( planeNormal, pointOnPlane - pointOnLine ) / dot( planeNormal, lineDirection ) ) + pointOnLine;\n}\nmat3 transposeMat3( const in mat3 m ) {\n\tmat3 tmp;\n\ttmp[ 0 ] = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x );\n\ttmp[ 1 ] = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y );\n\ttmp[ 2 ] = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z );\n\treturn tmp;\n}\nfloat linearToRelativeLuminance( const in vec3 color ) {\n\tvec3 weights = vec3( 0.2126, 0.7152, 0.0722 );\n\treturn dot( weights, color.rgb );\n}\nbool isPerspectiveMatrix( mat4 m ) {\n\treturn m[ 2 ][ 3 ] == - 1.0;\n}\nvec2 equirectUv( in vec3 dir ) {\n\tfloat u = atan( dir.z, dir.x ) * RECIPROCAL_PI2 + 0.5;\n\tfloat v = asin( clamp( dir.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\treturn vec2( u, v );\n}",cube_uv_reflection_fragment:"#ifdef ENVMAP_TYPE_CUBE_UV\n\t#define cubeUV_maxMipLevel 8.0\n\t#define cubeUV_minMipLevel 4.0\n\t#define cubeUV_maxTileSize 256.0\n\t#define cubeUV_minTileSize 16.0\n\tfloat getFace( vec3 direction ) {\n\t\tvec3 absDirection = abs( direction );\n\t\tfloat face = - 1.0;\n\t\tif ( absDirection.x > absDirection.z ) {\n\t\t\tif ( absDirection.x > absDirection.y )\n\t\t\t\tface = direction.x > 0.0 ? 0.0 : 3.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t} else {\n\t\t\tif ( absDirection.z > absDirection.y )\n\t\t\t\tface = direction.z > 0.0 ? 2.0 : 5.0;\n\t\t\telse\n\t\t\t\tface = direction.y > 0.0 ? 1.0 : 4.0;\n\t\t}\n\t\treturn face;\n\t}\n\tvec2 getUV( vec3 direction, float face ) {\n\t\tvec2 uv;\n\t\tif ( face == 0.0 ) {\n\t\t\tuv = vec2( direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 1.0 ) {\n\t\t\tuv = vec2( - direction.x, - direction.z ) / abs( direction.y );\n\t\t} else if ( face == 2.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.y ) / abs( direction.z );\n\t\t} else if ( face == 3.0 ) {\n\t\t\tuv = vec2( - direction.z, direction.y ) / abs( direction.x );\n\t\t} else if ( face == 4.0 ) {\n\t\t\tuv = vec2( - direction.x, direction.z ) / abs( direction.y );\n\t\t} else {\n\t\t\tuv = vec2( direction.x, direction.y ) / abs( direction.z );\n\t\t}\n\t\treturn 0.5 * ( uv + 1.0 );\n\t}\n\tvec3 bilinearCubeUV( sampler2D envMap, vec3 direction, float mipInt ) {\n\t\tfloat face = getFace( direction );\n\t\tfloat filterInt = max( cubeUV_minMipLevel - mipInt, 0.0 );\n\t\tmipInt = max( mipInt, cubeUV_minMipLevel );\n\t\tfloat faceSize = exp2( mipInt );\n\t\tfloat texelSize = 1.0 / ( 3.0 * cubeUV_maxTileSize );\n\t\tvec2 uv = getUV( direction, face ) * ( faceSize - 1.0 );\n\t\tvec2 f = fract( uv );\n\t\tuv += 0.5 - f;\n\t\tif ( face > 2.0 ) {\n\t\t\tuv.y += faceSize;\n\t\t\tface -= 3.0;\n\t\t}\n\t\tuv.x += face * faceSize;\n\t\tif ( mipInt < cubeUV_maxMipLevel ) {\n\t\t\tuv.y += 2.0 * cubeUV_maxTileSize;\n\t\t}\n\t\tuv.y += filterInt * 2.0 * cubeUV_minTileSize;\n\t\tuv.x += 3.0 * max( 0.0, cubeUV_maxTileSize - 2.0 * faceSize );\n\t\tuv *= texelSize;\n\t\tvec3 tl = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.x += texelSize;\n\t\tvec3 tr = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.y += texelSize;\n\t\tvec3 br = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tuv.x -= texelSize;\n\t\tvec3 bl = envMapTexelToLinear( texture2D( envMap, uv ) ).rgb;\n\t\tvec3 tm = mix( tl, tr, f.x );\n\t\tvec3 bm = mix( bl, br, f.x );\n\t\treturn mix( tm, bm, f.y );\n\t}\n\t#define r0 1.0\n\t#define v0 0.339\n\t#define m0 - 2.0\n\t#define r1 0.8\n\t#define v1 0.276\n\t#define m1 - 1.0\n\t#define r4 0.4\n\t#define v4 0.046\n\t#define m4 2.0\n\t#define r5 0.305\n\t#define v5 0.016\n\t#define m5 3.0\n\t#define r6 0.21\n\t#define v6 0.0038\n\t#define m6 4.0\n\tfloat roughnessToMip( float roughness ) {\n\t\tfloat mip = 0.0;\n\t\tif ( roughness >= r1 ) {\n\t\t\tmip = ( r0 - roughness ) * ( m1 - m0 ) / ( r0 - r1 ) + m0;\n\t\t} else if ( roughness >= r4 ) {\n\t\t\tmip = ( r1 - roughness ) * ( m4 - m1 ) / ( r1 - r4 ) + m1;\n\t\t} else if ( roughness >= r5 ) {\n\t\t\tmip = ( r4 - roughness ) * ( m5 - m4 ) / ( r4 - r5 ) + m4;\n\t\t} else if ( roughness >= r6 ) {\n\t\t\tmip = ( r5 - roughness ) * ( m6 - m5 ) / ( r5 - r6 ) + m5;\n\t\t} else {\n\t\t\tmip = - 2.0 * log2( 1.16 * roughness );\t\t}\n\t\treturn mip;\n\t}\n\tvec4 textureCubeUV( sampler2D envMap, vec3 sampleDir, float roughness ) {\n\t\tfloat mip = clamp( roughnessToMip( roughness ), m0, cubeUV_maxMipLevel );\n\t\tfloat mipF = fract( mip );\n\t\tfloat mipInt = floor( mip );\n\t\tvec3 color0 = bilinearCubeUV( envMap, sampleDir, mipInt );\n\t\tif ( mipF == 0.0 ) {\n\t\t\treturn vec4( color0, 1.0 );\n\t\t} else {\n\t\t\tvec3 color1 = bilinearCubeUV( envMap, sampleDir, mipInt + 1.0 );\n\t\t\treturn vec4( mix( color0, color1, mipF ), 1.0 );\n\t\t}\n\t}\n#endif",defaultnormal_vertex:"vec3 transformedNormal = objectNormal;\n#ifdef USE_INSTANCING\n\tmat3 m = mat3( instanceMatrix );\n\ttransformedNormal /= vec3( dot( m[ 0 ], m[ 0 ] ), dot( m[ 1 ], m[ 1 ] ), dot( m[ 2 ], m[ 2 ] ) );\n\ttransformedNormal = m * transformedNormal;\n#endif\ntransformedNormal = normalMatrix * transformedNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n#ifdef USE_TANGENT\n\tvec3 transformedTangent = ( modelViewMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#ifdef FLIP_SIDED\n\t\ttransformedTangent = - transformedTangent;\n\t#endif\n#endif",displacementmap_pars_vertex:"#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif",displacementmap_vertex:"#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, vUv ).x * displacementScale + displacementBias );\n#endif",emissivemap_fragment:"#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vUv );\n\temissiveColor.rgb = emissiveMapTexelToLinear( emissiveColor ).rgb;\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif",emissivemap_pars_fragment:"#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif",encodings_fragment:"gl_FragColor = linearToOutputTexel( gl_FragColor );",encodings_pars_fragment:"\nvec4 LinearToLinear( in vec4 value ) {\n\treturn value;\n}\nvec4 GammaToLinear( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.rgb, vec3( gammaFactor ) ), value.a );\n}\nvec4 LinearToGamma( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.rgb, vec3( 1.0 / gammaFactor ) ), value.a );\n}\nvec4 sRGBToLinear( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.a );\n}\nvec4 LinearTosRGB( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.a );\n}\nvec4 RGBEToLinear( in vec4 value ) {\n\treturn vec4( value.rgb * exp2( value.a * 255.0 - 128.0 ), 1.0 );\n}\nvec4 LinearToRGBE( in vec4 value ) {\n\tfloat maxComponent = max( max( value.r, value.g ), value.b );\n\tfloat fExp = clamp( ceil( log2( maxComponent ) ), -128.0, 127.0 );\n\treturn vec4( value.rgb / exp2( fExp ), ( fExp + 128.0 ) / 255.0 );\n}\nvec4 RGBMToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * value.a * maxRange, 1.0 );\n}\nvec4 LinearToRGBM( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.r, max( value.g, value.b ) );\n\tfloat M = clamp( maxRGB / maxRange, 0.0, 1.0 );\n\tM = ceil( M * 255.0 ) / 255.0;\n\treturn vec4( value.rgb / ( M * maxRange ), M );\n}\nvec4 RGBDToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * ( ( maxRange / 255.0 ) / value.a ), 1.0 );\n}\nvec4 LinearToRGBD( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.r, max( value.g, value.b ) );\n\tfloat D = max( maxRange / maxRGB, 1.0 );\n\tD = clamp( floor( D ) / 255.0, 0.0, 1.0 );\n\treturn vec4( value.rgb * ( D * ( 255.0 / maxRange ) ), D );\n}\nconst mat3 cLogLuvM = mat3( 0.2209, 0.3390, 0.4184, 0.1138, 0.6780, 0.7319, 0.0102, 0.1130, 0.2969 );\nvec4 LinearToLogLuv( in vec4 value ) {\n\tvec3 Xp_Y_XYZp = cLogLuvM * value.rgb;\n\tXp_Y_XYZp = max( Xp_Y_XYZp, vec3( 1e-6, 1e-6, 1e-6 ) );\n\tvec4 vResult;\n\tvResult.xy = Xp_Y_XYZp.xy / Xp_Y_XYZp.z;\n\tfloat Le = 2.0 * log2(Xp_Y_XYZp.y) + 127.0;\n\tvResult.w = fract( Le );\n\tvResult.z = ( Le - ( floor( vResult.w * 255.0 ) ) / 255.0 ) / 255.0;\n\treturn vResult;\n}\nconst mat3 cLogLuvInverseM = mat3( 6.0014, -2.7008, -1.7996, -1.3320, 3.1029, -5.7721, 0.3008, -1.0882, 5.6268 );\nvec4 LogLuvToLinear( in vec4 value ) {\n\tfloat Le = value.z * 255.0 + value.w;\n\tvec3 Xp_Y_XYZp;\n\tXp_Y_XYZp.y = exp2( ( Le - 127.0 ) / 2.0 );\n\tXp_Y_XYZp.z = Xp_Y_XYZp.y / value.y;\n\tXp_Y_XYZp.x = value.x * Xp_Y_XYZp.z;\n\tvec3 vRGB = cLogLuvInverseM * Xp_Y_XYZp.rgb;\n\treturn vec4( max( vRGB, 0.0 ), 1.0 );\n}",envmap_fragment:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvec3 cameraToFrag;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToFrag = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToFrag = normalize( vWorldPosition - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToFrag, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToFrag, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\tvec4 envColor = textureCubeUV( envMap, reflectVec, 0.0 );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\t#ifndef ENVMAP_TYPE_CUBE_UV\n\t\tenvColor = envMapTexelToLinear( envColor );\n\t#endif\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif",envmap_common_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float envMapIntensity;\n\tuniform float flipEnvMap;\n\tuniform int maxMipLevel;\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n\t\n#endif",envmap_pars_fragment:"#ifdef USE_ENVMAP\n\tuniform float reflectivity;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\tvarying vec3 vWorldPosition;\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif",envmap_pars_vertex:"#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) ||defined( PHONG )\n\t\t#define ENV_WORLDPOS\n\t#endif\n\t#ifdef ENV_WORLDPOS\n\t\t\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif",envmap_physical_pars_fragment:"#if defined( USE_ENVMAP )\n\t#ifdef ENVMAP_MODE_REFRACTION\n\t\tuniform float refractionRatio;\n\t#endif\n\tvec3 getLightProbeIndirectIrradiance( const in GeometricContext geometry, const in int maxMIPLevel ) {\n\t\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryVec = vec3( flipEnvMap * worldNormal.x, worldNormal.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, worldNormal, 1.0 );\n\t\t#else\n\t\t\tvec4 envMapColor = vec4( 0.0 );\n\t\t#endif\n\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t}\n\tfloat getSpecularMIPLevel( const in float roughness, const in int maxMIPLevel ) {\n\t\tfloat maxMIPLevelScalar = float( maxMIPLevel );\n\t\tfloat sigma = PI * roughness * roughness / ( 1.0 + roughness );\n\t\tfloat desiredMIPLevel = maxMIPLevelScalar + log2( sigma );\n\t\treturn clamp( desiredMIPLevel, 0.0, maxMIPLevelScalar );\n\t}\n\tvec3 getLightProbeIndirectRadiance( const in vec3 viewDir, const in vec3 normal, const in float roughness, const in int maxMIPLevel ) {\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( -viewDir, normal );\n\t\t\treflectVec = normalize( mix( reflectVec, normal, roughness * roughness) );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( -viewDir, normal, refractionRatio );\n\t\t#endif\n\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\tfloat specularMIPLevel = getSpecularMIPLevel( roughness, maxMIPLevel );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryReflectVec = vec3( flipEnvMap * reflectVec.x, reflectVec.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec4 envMapColor = textureCubeUV( envMap, reflectVec, roughness );\n\t\t#endif\n\t\treturn envMapColor.rgb * envMapIntensity;\n\t}\n#endif",envmap_vertex:"#ifdef USE_ENVMAP\n\t#ifdef ENV_WORLDPOS\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex;\n\t\tif ( isOrthographic ) {\n\t\t\tcameraToVertex = normalize( vec3( - viewMatrix[ 0 ][ 2 ], - viewMatrix[ 1 ][ 2 ], - viewMatrix[ 2 ][ 2 ] ) );\n\t\t} else {\n\t\t\tcameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\t}\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif",fog_vertex:"#ifdef USE_FOG\n\tfogDepth = - mvPosition.z;\n#endif",fog_pars_vertex:"#ifdef USE_FOG\n\tvarying float fogDepth;\n#endif",fog_fragment:"#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = 1.0 - exp( - fogDensity * fogDensity * fogDepth * fogDepth );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, fogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif",fog_pars_fragment:"#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float fogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif",gradientmap_pars_fragment:"#ifdef USE_GRADIENTMAP\n\tuniform sampler2D gradientMap;\n#endif\nvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\tfloat dotNL = dot( normal, lightDirection );\n\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t#ifdef USE_GRADIENTMAP\n\t\treturn texture2D( gradientMap, coord ).rgb;\n\t#else\n\t\treturn ( coord.x < 0.7 ) ? vec3( 0.7 ) : vec3( 1.0 );\n\t#endif\n}",lightmap_fragment:"#ifdef USE_LIGHTMAP\n\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\treflectedLight.indirectDiffuse += PI * lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n#endif",lightmap_pars_fragment:"#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif",lights_lambert_vertex:"vec3 diffuse = vec3( 1.0 );\nGeometricContext geometry;\ngeometry.position = mvPosition.xyz;\ngeometry.normal = normalize( transformedNormal );\ngeometry.viewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( -mvPosition.xyz );\nGeometricContext backGeometry;\nbackGeometry.position = geometry.position;\nbackGeometry.normal = -geometry.normal;\nbackGeometry.viewDir = geometry.viewDir;\nvLightFront = vec3( 0.0 );\nvIndirectFront = vec3( 0.0 );\n#ifdef DOUBLE_SIDED\n\tvLightBack = vec3( 0.0 );\n\tvIndirectBack = vec3( 0.0 );\n#endif\nIncidentLight directLight;\nfloat dotNL;\nvec3 directLightColor_Diffuse;\nvIndirectFront += getAmbientLightIrradiance( ambientLightColor );\nvIndirectFront += getLightProbeIrradiance( lightProbe, geometry );\n#ifdef DOUBLE_SIDED\n\tvIndirectBack += getAmbientLightIrradiance( ambientLightColor );\n\tvIndirectBack += getLightProbeIrradiance( lightProbe, backGeometry );\n#endif\n#if NUM_POINT_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tgetPointDirectLightIrradiance( pointLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tgetSpotDirectLightIrradiance( spotLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_DIR_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tgetDirectionalDirectLightIrradiance( directionalLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\tvIndirectFront += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvIndirectBack += getHemisphereLightIrradiance( hemisphereLights[ i ], backGeometry );\n\t\t#endif\n\t}\n\t#pragma unroll_loop_end\n#endif",lights_pars_begin:"uniform bool receiveShadow;\nuniform vec3 ambientLightColor;\nuniform vec3 lightProbe[ 9 ];\nvec3 shGetIrradianceAt( in vec3 normal, in vec3 shCoefficients[ 9 ] ) {\n\tfloat x = normal.x, y = normal.y, z = normal.z;\n\tvec3 result = shCoefficients[ 0 ] * 0.886227;\n\tresult += shCoefficients[ 1 ] * 2.0 * 0.511664 * y;\n\tresult += shCoefficients[ 2 ] * 2.0 * 0.511664 * z;\n\tresult += shCoefficients[ 3 ] * 2.0 * 0.511664 * x;\n\tresult += shCoefficients[ 4 ] * 2.0 * 0.429043 * x * y;\n\tresult += shCoefficients[ 5 ] * 2.0 * 0.429043 * y * z;\n\tresult += shCoefficients[ 6 ] * ( 0.743125 * z * z - 0.247708 );\n\tresult += shCoefficients[ 7 ] * 2.0 * 0.429043 * x * z;\n\tresult += shCoefficients[ 8 ] * 0.429043 * ( x * x - y * y );\n\treturn result;\n}\nvec3 getLightProbeIrradiance( const in vec3 lightProbe[ 9 ], const in GeometricContext geometry ) {\n\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\tvec3 irradiance = shGetIrradianceAt( worldNormal, lightProbe );\n\treturn irradiance;\n}\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treturn irradiance;\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalDirectLightIrradiance( const in DirectionalLight directionalLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tdirectLight.color = directionalLight.color;\n\t\tdirectLight.direction = directionalLight.direction;\n\t\tdirectLight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointDirectLightIrradiance( const in PointLight pointLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = pointLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tdirectLight.color = pointLight.color;\n\t\tdirectLight.color *= punctualLightIntensityToIrradianceFactor( lightDistance, pointLight.distance, pointLight.decay );\n\t\tdirectLight.visible = ( directLight.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotDirectLightIrradiance( const in SpotLight spotLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = spotLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tfloat angleCos = dot( directLight.direction, spotLight.direction );\n\t\tif ( angleCos > spotLight.coneCos ) {\n\t\t\tfloat spotEffect = smoothstep( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\t\tdirectLight.color = spotLight.color;\n\t\t\tdirectLight.color *= spotEffect * punctualLightIntensityToIrradianceFactor( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tdirectLight.visible = true;\n\t\t} else {\n\t\t\tdirectLight.color = vec3( 0.0 );\n\t\t\tdirectLight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in GeometricContext geometry ) {\n\t\tfloat dotNL = dot( geometry.normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tirradiance *= PI;\n\t\t#endif\n\t\treturn irradiance;\n\t}\n#endif",lights_toon_fragment:"ToonMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;",lights_toon_pars_fragment:"varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct ToonMaterial {\n\tvec3 diffuseColor;\n};\nvoid RE_Direct_Toon( const in IncidentLight directLight, const in GeometricContext geometry, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\tvec3 irradiance = getGradientIrradiance( geometry.normal, directLight.direction ) * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Toon( const in vec3 irradiance, const in GeometricContext geometry, const in ToonMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_Toon\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Toon\n#define Material_LightProbeLOD( material )\t(0)",lights_phong_fragment:"BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;",lights_phong_pars_fragment:"varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct BlinnPhongMaterial {\n\tvec3 diffuseColor;\n\tvec3 specularColor;\n\tfloat specularShininess;\n\tfloat specularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_Specular_BlinnPhong( directLight, geometry, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong\n#define Material_LightProbeLOD( material )\t(0)",lights_physical_fragment:"PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nvec3 dxy = max( abs( dFdx( geometryNormal ) ), abs( dFdy( geometryNormal ) ) );\nfloat geometryRoughness = max( max( dxy.x, dxy.y ), dxy.z );\nmaterial.specularRoughness = max( roughnessFactor, 0.0525 );material.specularRoughness += geometryRoughness;\nmaterial.specularRoughness = min( material.specularRoughness, 1.0 );\n#ifdef REFLECTIVITY\n\tmaterial.specularColor = mix( vec3( MAXIMUM_SPECULAR_COEFFICIENT * pow2( reflectivity ) ), diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( DEFAULT_SPECULAR_COEFFICIENT ), diffuseColor.rgb, metalnessFactor );\n#endif\n#ifdef CLEARCOAT\n\tmaterial.clearcoat = clearcoat;\n\tmaterial.clearcoatRoughness = clearcoatRoughness;\n\t#ifdef USE_CLEARCOATMAP\n\t\tmaterial.clearcoat *= texture2D( clearcoatMap, vUv ).x;\n\t#endif\n\t#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\t\tmaterial.clearcoatRoughness *= texture2D( clearcoatRoughnessMap, vUv ).y;\n\t#endif\n\tmaterial.clearcoat = saturate( material.clearcoat );\tmaterial.clearcoatRoughness = max( material.clearcoatRoughness, 0.0525 );\n\tmaterial.clearcoatRoughness += geometryRoughness;\n\tmaterial.clearcoatRoughness = min( material.clearcoatRoughness, 1.0 );\n#endif\n#ifdef USE_SHEEN\n\tmaterial.sheenColor = sheen;\n#endif",lights_physical_pars_fragment:"struct PhysicalMaterial {\n\tvec3 diffuseColor;\n\tfloat specularRoughness;\n\tvec3 specularColor;\n#ifdef CLEARCOAT\n\tfloat clearcoat;\n\tfloat clearcoatRoughness;\n#endif\n#ifdef USE_SHEEN\n\tvec3 sheenColor;\n#endif\n};\n#define MAXIMUM_SPECULAR_COEFFICIENT 0.16\n#define DEFAULT_SPECULAR_COEFFICIENT 0.04\nfloat clearcoatDHRApprox( const in float roughness, const in float dotNL ) {\n\treturn DEFAULT_SPECULAR_COEFFICIENT + ( 1.0 - DEFAULT_SPECULAR_COEFFICIENT ) * ( pow( 1.0 - dotNL, 5.0 ) * pow( 1.0 - roughness, 2.0 ) );\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometry.normal;\n\t\tvec3 viewDir = geometry.viewDir;\n\t\tvec3 position = geometry.position;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.specularRoughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos + halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos - halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos - halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos + halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3(\t\t0, 1,\t\t0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\t#ifdef CLEARCOAT\n\t\tfloat ccDotNL = saturate( dot( geometry.clearcoatNormal, directLight.direction ) );\n\t\tvec3 ccIrradiance = ccDotNL * directLight.color;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tccIrradiance *= PI;\n\t\t#endif\n\t\tfloat clearcoatDHR = material.clearcoat * clearcoatDHRApprox( material.clearcoatRoughness, ccDotNL );\n\t\treflectedLight.directSpecular += ccIrradiance * material.clearcoat * BRDF_Specular_GGX( directLight, geometry.viewDir, geometry.clearcoatNormal, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearcoatRoughness );\n\t#else\n\t\tfloat clearcoatDHR = 0.0;\n\t#endif\n\t#ifdef USE_SHEEN\n\t\treflectedLight.directSpecular += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Specular_Sheen(\n\t\t\tmaterial.specularRoughness,\n\t\t\tdirectLight.direction,\n\t\t\tgeometry,\n\t\t\tmaterial.sheenColor\n\t\t);\n\t#else\n\t\treflectedLight.directSpecular += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Specular_GGX( directLight, geometry.viewDir, geometry.normal, material.specularColor, material.specularRoughness);\n\t#endif\n\treflectedLight.directDiffuse += ( 1.0 - clearcoatDHR ) * irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 irradiance, const in vec3 clearcoatRadiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight) {\n\t#ifdef CLEARCOAT\n\t\tfloat ccDotNV = saturate( dot( geometry.clearcoatNormal, geometry.viewDir ) );\n\t\treflectedLight.indirectSpecular += clearcoatRadiance * material.clearcoat * BRDF_Specular_GGX_Environment( geometry.viewDir, geometry.clearcoatNormal, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearcoatRoughness );\n\t\tfloat ccDotNL = ccDotNV;\n\t\tfloat clearcoatDHR = material.clearcoat * clearcoatDHRApprox( material.clearcoatRoughness, ccDotNL );\n\t#else\n\t\tfloat clearcoatDHR = 0.0;\n\t#endif\n\tfloat clearcoatInv = 1.0 - clearcoatDHR;\n\tvec3 singleScattering = vec3( 0.0 );\n\tvec3 multiScattering = vec3( 0.0 );\n\tvec3 cosineWeightedIrradiance = irradiance * RECIPROCAL_PI;\n\tBRDF_Specular_Multiscattering_Environment( geometry, material.specularColor, material.specularRoughness, singleScattering, multiScattering );\n\tvec3 diffuse = material.diffuseColor * ( 1.0 - ( singleScattering + multiScattering ) );\n\treflectedLight.indirectSpecular += clearcoatInv * radiance * singleScattering;\n\treflectedLight.indirectSpecular += multiScattering * cosineWeightedIrradiance;\n\treflectedLight.indirectDiffuse += diffuse * cosineWeightedIrradiance;\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}",lights_fragment_begin:"\nGeometricContext geometry;\ngeometry.position = - vViewPosition;\ngeometry.normal = normal;\ngeometry.viewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition );\n#ifdef CLEARCOAT\n\tgeometry.clearcoatNormal = clearcoatNormal;\n#endif\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointDirectLightIrradiance( pointLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_POINT_LIGHT_SHADOWS )\n\t\tpointLightShadow = pointLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getPointShadow( pointShadowMap[ i ], pointLightShadow.shadowMapSize, pointLightShadow.shadowBias, pointLightShadow.shadowRadius, vPointShadowCoord[ i ], pointLightShadow.shadowCameraNear, pointLightShadow.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotDirectLightIrradiance( spotLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_SPOT_LIGHT_SHADOWS )\n\t\tspotLightShadow = spotLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getShadow( spotShadowMap[ i ], spotLightShadow.shadowMapSize, spotLightShadow.shadowBias, spotLightShadow.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLightShadow;\n\t#endif\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalDirectLightIrradiance( directionalLight, geometry, directLight );\n\t\t#if defined( USE_SHADOWMAP ) && ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS )\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tdirectLight.color *= all( bvec2( directLight.visible, receiveShadow ) ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometry, material, reflectedLight );\n\t}\n\t#pragma unroll_loop_end\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 iblIrradiance = vec3( 0.0 );\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\tirradiance += getLightProbeIrradiance( lightProbe, geometry );\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop_start\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t}\n\t\t#pragma unroll_loop_end\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearcoatRadiance = vec3( 0.0 );\n#endif",lights_fragment_maps:"#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\t\tvec3 lightMapIrradiance = lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tlightMapIrradiance *= PI;\n\t\t#endif\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( STANDARD ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tiblIrradiance += getLightProbeIndirectIrradiance( geometry, maxMipLevel );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\tradiance += getLightProbeIndirectRadiance( geometry.viewDir, geometry.normal, material.specularRoughness, maxMipLevel );\n\t#ifdef CLEARCOAT\n\t\tclearcoatRadiance += getLightProbeIndirectRadiance( geometry.viewDir, geometry.clearcoatNormal, material.clearcoatRoughness, maxMipLevel );\n\t#endif\n#endif",lights_fragment_end:"#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometry, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, iblIrradiance, clearcoatRadiance, geometry, material, reflectedLight );\n#endif",logdepthbuf_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tgl_FragDepthEXT = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif",logdepthbuf_pars_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tuniform float logDepthBufFC;\n\tvarying float vFragDepth;\n\tvarying float vIsPerspective;\n#endif",logdepthbuf_pars_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t\tvarying float vIsPerspective;\n\t#else\n\t\tuniform float logDepthBufFC;\n\t#endif\n#endif",logdepthbuf_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvFragDepth = 1.0 + gl_Position.w;\n\t\tvIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) );\n\t#else\n\t\tif ( isPerspectiveMatrix( projectionMatrix ) ) {\n\t\t\tgl_Position.z = log2( max( EPSILON, gl_Position.w + 1.0 ) ) * logDepthBufFC - 1.0;\n\t\t\tgl_Position.z *= gl_Position.w;\n\t\t}\n\t#endif\n#endif",map_fragment:"#ifdef USE_MAP\n\tvec4 texelColor = texture2D( map, vUv );\n\ttexelColor = mapTexelToLinear( texelColor );\n\tdiffuseColor *= texelColor;\n#endif",map_pars_fragment:"#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif",map_particle_fragment:"#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n#endif\n#ifdef USE_MAP\n\tvec4 mapTexel = texture2D( map, uv );\n\tdiffuseColor *= mapTexelToLinear( mapTexel );\n#endif\n#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, uv ).g;\n#endif",map_particle_pars_fragment:"#if defined( USE_MAP ) || defined( USE_ALPHAMAP )\n\tuniform mat3 uvTransform;\n#endif\n#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif",metalnessmap_fragment:"float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif",metalnessmap_pars_fragment:"#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif",morphnormal_vertex:"#ifdef USE_MORPHNORMALS\n\tobjectNormal *= morphTargetBaseInfluence;\n\tobjectNormal += morphNormal0 * morphTargetInfluences[ 0 ];\n\tobjectNormal += morphNormal1 * morphTargetInfluences[ 1 ];\n\tobjectNormal += morphNormal2 * morphTargetInfluences[ 2 ];\n\tobjectNormal += morphNormal3 * morphTargetInfluences[ 3 ];\n#endif",morphtarget_pars_vertex:"#ifdef USE_MORPHTARGETS\n\tuniform float morphTargetBaseInfluence;\n\t#ifndef USE_MORPHNORMALS\n\t\tuniform float morphTargetInfluences[ 8 ];\n\t#else\n\t\tuniform float morphTargetInfluences[ 4 ];\n\t#endif\n#endif",morphtarget_vertex:"#ifdef USE_MORPHTARGETS\n\ttransformed *= morphTargetBaseInfluence;\n\ttransformed += morphTarget0 * morphTargetInfluences[ 0 ];\n\ttransformed += morphTarget1 * morphTargetInfluences[ 1 ];\n\ttransformed += morphTarget2 * morphTargetInfluences[ 2 ];\n\ttransformed += morphTarget3 * morphTargetInfluences[ 3 ];\n\t#ifndef USE_MORPHNORMALS\n\t\ttransformed += morphTarget4 * morphTargetInfluences[ 4 ];\n\t\ttransformed += morphTarget5 * morphTargetInfluences[ 5 ];\n\t\ttransformed += morphTarget6 * morphTargetInfluences[ 6 ];\n\t\ttransformed += morphTarget7 * morphTargetInfluences[ 7 ];\n\t#endif\n#endif",normal_fragment_begin:"float faceDirection = gl_FrontFacing ? 1.0 : - 1.0;\n#ifdef FLAT_SHADED\n\tvec3 fdx = vec3( dFdx( vViewPosition.x ), dFdx( vViewPosition.y ), dFdx( vViewPosition.z ) );\n\tvec3 fdy = vec3( dFdy( vViewPosition.x ), dFdy( vViewPosition.y ), dFdy( vViewPosition.z ) );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\t#ifdef USE_TANGENT\n\t\tvec3 tangent = normalize( vTangent );\n\t\tvec3 bitangent = normalize( vBitangent );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\ttangent = tangent * faceDirection;\n\t\t\tbitangent = bitangent * faceDirection;\n\t\t#endif\n\t\t#if defined( TANGENTSPACE_NORMALMAP ) || defined( USE_CLEARCOAT_NORMALMAP )\n\t\t\tmat3 vTBN = mat3( tangent, bitangent, normal );\n\t\t#endif\n\t#endif\n#endif\nvec3 geometryNormal = normal;",normal_fragment_maps:"#ifdef OBJECTSPACE_NORMALMAP\n\tnormal = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\t#ifdef FLIP_SIDED\n\t\tnormal = - normal;\n\t#endif\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * faceDirection;\n\t#endif\n\tnormal = normalize( normalMatrix * normal );\n#elif defined( TANGENTSPACE_NORMALMAP )\n\tvec3 mapN = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\tmapN.xy *= normalScale;\n\t#ifdef USE_TANGENT\n\t\tnormal = normalize( vTBN * mapN );\n\t#else\n\t\tnormal = perturbNormal2Arb( -vViewPosition, normal, mapN, faceDirection );\n\t#endif\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( -vViewPosition, normal, dHdxy_fwd(), faceDirection );\n#endif",normalmap_pars_fragment:"#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n#endif\n#ifdef OBJECTSPACE_NORMALMAP\n\tuniform mat3 normalMatrix;\n#endif\n#if ! defined ( USE_TANGENT ) && ( defined ( TANGENTSPACE_NORMALMAP ) || defined ( USE_CLEARCOAT_NORMALMAP ) )\n\tvec3 perturbNormal2Arb( vec3 eye_pos, vec3 surf_norm, vec3 mapN, float faceDirection ) {\n\t\tvec3 q0 = vec3( dFdx( eye_pos.x ), dFdx( eye_pos.y ), dFdx( eye_pos.z ) );\n\t\tvec3 q1 = vec3( dFdy( eye_pos.x ), dFdy( eye_pos.y ), dFdy( eye_pos.z ) );\n\t\tvec2 st0 = dFdx( vUv.st );\n\t\tvec2 st1 = dFdy( vUv.st );\n\t\tvec3 N = surf_norm;\n\t\tvec3 q1perp = cross( q1, N );\n\t\tvec3 q0perp = cross( N, q0 );\n\t\tvec3 T = q1perp * st0.x + q0perp * st1.x;\n\t\tvec3 B = q1perp * st0.y + q0perp * st1.y;\n\t\tfloat det = max( dot( T, T ), dot( B, B ) );\n\t\tfloat scale = ( det == 0.0 ) ? 0.0 : faceDirection * inversesqrt( det );\n\t\treturn normalize( T * ( mapN.x * scale ) + B * ( mapN.y * scale ) + N * mapN.z );\n\t}\n#endif",clearcoat_normal_fragment_begin:"#ifdef CLEARCOAT\n\tvec3 clearcoatNormal = geometryNormal;\n#endif",clearcoat_normal_fragment_maps:"#ifdef USE_CLEARCOAT_NORMALMAP\n\tvec3 clearcoatMapN = texture2D( clearcoatNormalMap, vUv ).xyz * 2.0 - 1.0;\n\tclearcoatMapN.xy *= clearcoatNormalScale;\n\t#ifdef USE_TANGENT\n\t\tclearcoatNormal = normalize( vTBN * clearcoatMapN );\n\t#else\n\t\tclearcoatNormal = perturbNormal2Arb( - vViewPosition, clearcoatNormal, clearcoatMapN, faceDirection );\n\t#endif\n#endif",clearcoat_pars_fragment:"#ifdef USE_CLEARCOATMAP\n\tuniform sampler2D clearcoatMap;\n#endif\n#ifdef USE_CLEARCOAT_ROUGHNESSMAP\n\tuniform sampler2D clearcoatRoughnessMap;\n#endif\n#ifdef USE_CLEARCOAT_NORMALMAP\n\tuniform sampler2D clearcoatNormalMap;\n\tuniform vec2 clearcoatNormalScale;\n#endif",packing:"vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;\nconst vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );\nconst vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );\nconst float ShiftRight8 = 1. / 256.;\nvec4 packDepthToRGBA( const in float v ) {\n\tvec4 r = vec4( fract( v * PackFactors ), v );\n\tr.yzw -= r.xyz * ShiftRight8;\treturn r * PackUpscale;\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors );\n}\nvec4 pack2HalfToRGBA( vec2 v ) {\n\tvec4 r = vec4( v.x, fract( v.x * 255.0 ), v.y, fract( v.y * 255.0 ));\n\treturn vec4( r.x - r.y / 255.0, r.y, r.z - r.w / 255.0, r.w);\n}\nvec2 unpackRGBATo2Half( vec4 v ) {\n\treturn vec2( v.x + ( v.y / 255.0 ), v.z + ( v.w / 255.0 ) );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float linearClipZ, const in float near, const in float far ) {\n\treturn linearClipZ * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn (( near + viewZ ) * far ) / (( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float invClipZ, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * invClipZ - far );\n}",premultiplied_alpha_fragment:"#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif",project_vertex:"vec4 mvPosition = vec4( transformed, 1.0 );\n#ifdef USE_INSTANCING\n\tmvPosition = instanceMatrix * mvPosition;\n#endif\nmvPosition = modelViewMatrix * mvPosition;\ngl_Position = projectionMatrix * mvPosition;",dithering_fragment:"#ifdef DITHERING\n\tgl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif",dithering_pars_fragment:"#ifdef DITHERING\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif",roughnessmap_fragment:"float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vUv );\n\troughnessFactor *= texelRoughness.g;\n#endif",roughnessmap_pars_fragment:"#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif",shadowmap_pars_fragment:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\treturn step( compare, unpackRGBAToDepth( texture2D( depths, uv ) ) );\n\t}\n\tvec2 texture2DDistribution( sampler2D shadow, vec2 uv ) {\n\t\treturn unpackRGBATo2Half( texture2D( shadow, uv ) );\n\t}\n\tfloat VSMShadow (sampler2D shadow, vec2 uv, float compare ){\n\t\tfloat occlusion = 1.0;\n\t\tvec2 distribution = texture2DDistribution( shadow, uv );\n\t\tfloat hard_shadow = step( compare , distribution.x );\n\t\tif (hard_shadow != 1.0 ) {\n\t\t\tfloat distance = compare - distribution.x ;\n\t\t\tfloat variance = max( 0.00000, distribution.y * distribution.y );\n\t\t\tfloat softness_probability = variance / (variance + distance * distance );\t\t\tsoftness_probability = clamp( ( softness_probability - 0.3 ) / ( 0.95 - 0.3 ), 0.0, 1.0 );\t\t\tocclusion = clamp( max( hard_shadow, softness_probability ), 0.0, 1.0 );\n\t\t}\n\t\treturn occlusion;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbvec4 inFrustumVec = bvec4 ( shadowCoord.x >= 0.0, shadowCoord.x <= 1.0, shadowCoord.y >= 0.0, shadowCoord.y <= 1.0 );\n\t\tbool inFrustum = all( inFrustumVec );\n\t\tbvec2 frustumTestVec = bvec2( inFrustum, shadowCoord.z <= 1.0 );\n\t\tbool frustumTest = all( frustumTestVec );\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tfloat dx2 = dx0 / 2.0;\n\t\t\tfloat dy2 = dy0 / 2.0;\n\t\t\tfloat dx3 = dx1 / 2.0;\n\t\t\tfloat dy3 = dy1 / 2.0;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy2 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx2, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx3, dy3 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 17.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx = texelSize.x;\n\t\t\tfloat dy = texelSize.y;\n\t\t\tvec2 uv = shadowCoord.xy;\n\t\t\tvec2 f = fract( uv * shadowMapSize + 0.5 );\n\t\t\tuv -= f * texelSize;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, uv, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( dx, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 0.0, dy ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, uv + texelSize, shadowCoord.z ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, 0.0 ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 0.0 ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( -dx, dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 2.0 * dx, dy ), shadowCoord.z ),\n\t\t\t\t\t f.x ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( 0.0, -dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( 0.0, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( texture2DCompare( shadowMap, uv + vec2( dx, -dy ), shadowCoord.z ), \n\t\t\t\t\t texture2DCompare( shadowMap, uv + vec2( dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t f.y ) +\n\t\t\t\tmix( mix( texture2DCompare( shadowMap, uv + vec2( -dx, -dy ), shadowCoord.z ), \n\t\t\t\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 2.0 * dx, -dy ), shadowCoord.z ),\n\t\t\t\t\t\t\tf.x ),\n\t\t\t\t\t mix( texture2DCompare( shadowMap, uv + vec2( -dx, 2.0 * dy ), shadowCoord.z ), \n\t\t\t\t\t\t\ttexture2DCompare( shadowMap, uv + vec2( 2.0 * dx, 2.0 * dy ), shadowCoord.z ),\n\t\t\t\t\t\t\tf.x ),\n\t\t\t\t\t f.y )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_VSM )\n\t\t\tshadow = VSMShadow( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn shadow;\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\tfloat dp = ( length( lightToPosition ) - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\tdp += shadowBias;\n\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT ) || defined( SHADOWMAP_TYPE_VSM )\n\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\treturn (\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\treturn texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t#endif\n\t}\n#endif",shadowmap_pars_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHT_SHADOWS ];\n\t\tstruct DirectionalLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform DirectionalLightShadow directionalLightShadows[ NUM_DIR_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 spotShadowMatrix[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHT_SHADOWS ];\n\t\tstruct SpotLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t};\n\t\tuniform SpotLightShadow spotLightShadows[ NUM_SPOT_LIGHT_SHADOWS ];\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHT_SHADOWS ];\n\t\tstruct PointLightShadow {\n\t\t\tfloat shadowBias;\n\t\t\tfloat shadowNormalBias;\n\t\t\tfloat shadowRadius;\n\t\t\tvec2 shadowMapSize;\n\t\t\tfloat shadowCameraNear;\n\t\t\tfloat shadowCameraFar;\n\t\t};\n\t\tuniform PointLightShadow pointLightShadows[ NUM_POINT_LIGHT_SHADOWS ];\n\t#endif\n#endif",shadowmap_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0 || NUM_SPOT_LIGHT_SHADOWS > 0 || NUM_POINT_LIGHT_SHADOWS > 0\n\t\tvec3 shadowWorldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\tvec4 shadowWorldPosition;\n\t#endif\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * spotLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvSpotShadowCoord[ i ] = spotShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tshadowWorldPosition = worldPosition + vec4( shadowWorldNormal * pointLightShadows[ i ].shadowNormalBias, 0 );\n\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * shadowWorldPosition;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n#endif",shadowmask_pars_fragment:"float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHT_SHADOWS > 0\n\tDirectionalLightShadow directionalLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\tdirectionalLight = directionalLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_SPOT_LIGHT_SHADOWS > 0\n\tSpotLightShadow spotLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_SPOT_LIGHT_SHADOWS; i ++ ) {\n\t\tspotLight = spotLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#if NUM_POINT_LIGHT_SHADOWS > 0\n\tPointLightShadow pointLight;\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_POINT_LIGHT_SHADOWS; i ++ ) {\n\t\tpointLight = pointLightShadows[ i ];\n\t\tshadow *= receiveShadow ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\t#endif\n\treturn shadow;\n}",skinbase_vertex:"#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif",skinning_pars_vertex:"#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\t#ifdef BONE_TEXTURE\n\t\tuniform highp sampler2D boneTexture;\n\t\tuniform int boneTextureSize;\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tfloat j = i * 4.0;\n\t\t\tfloat x = mod( j, float( boneTextureSize ) );\n\t\t\tfloat y = floor( j / float( boneTextureSize ) );\n\t\t\tfloat dx = 1.0 / float( boneTextureSize );\n\t\t\tfloat dy = 1.0 / float( boneTextureSize );\n\t\t\ty = dy * ( y + 0.5 );\n\t\t\tvec4 v1 = texture2D( boneTexture, vec2( dx * ( x + 0.5 ), y ) );\n\t\t\tvec4 v2 = texture2D( boneTexture, vec2( dx * ( x + 1.5 ), y ) );\n\t\t\tvec4 v3 = texture2D( boneTexture, vec2( dx * ( x + 2.5 ), y ) );\n\t\t\tvec4 v4 = texture2D( boneTexture, vec2( dx * ( x + 3.5 ), y ) );\n\t\t\tmat4 bone = mat4( v1, v2, v3, v4 );\n\t\t\treturn bone;\n\t\t}\n\t#else\n\t\tuniform mat4 boneMatrices[ MAX_BONES ];\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tmat4 bone = boneMatrices[ int(i) ];\n\t\t\treturn bone;\n\t\t}\n\t#endif\n#endif",skinning_vertex:"#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif",skinnormal_vertex:"#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n\t#ifdef USE_TANGENT\n\t\tobjectTangent = vec4( skinMatrix * vec4( objectTangent, 0.0 ) ).xyz;\n\t#endif\n#endif",specularmap_fragment:"float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif",specularmap_pars_fragment:"#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif",tonemapping_fragment:"#if defined( TONE_MAPPING )\n\tgl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif",tonemapping_pars_fragment:"#ifndef saturate\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn toneMappingExposure * color;\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\nvec3 OptimizedCineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\nvec3 RRTAndODTFit( vec3 v ) {\n\tvec3 a = v * ( v + 0.0245786 ) - 0.000090537;\n\tvec3 b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081;\n\treturn a / b;\n}\nvec3 ACESFilmicToneMapping( vec3 color ) {\n\tconst mat3 ACESInputMat = mat3(\n\t\tvec3( 0.59719, 0.07600, 0.02840 ),\t\tvec3( 0.35458, 0.90834, 0.13383 ),\n\t\tvec3( 0.04823, 0.01566, 0.83777 )\n\t);\n\tconst mat3 ACESOutputMat = mat3(\n\t\tvec3(\t1.60475, -0.10208, -0.00327 ),\t\tvec3( -0.53108,\t1.10813, -0.07276 ),\n\t\tvec3( -0.07367, -0.00605,\t1.07602 )\n\t);\n\tcolor *= toneMappingExposure / 0.6;\n\tcolor = ACESInputMat * color;\n\tcolor = RRTAndODTFit( color );\n\tcolor = ACESOutputMat * color;\n\treturn saturate( color );\n}\nvec3 CustomToneMapping( vec3 color ) { return color; }",transmissionmap_fragment:"#ifdef USE_TRANSMISSIONMAP\n\ttotalTransmission *= texture2D( transmissionMap, vUv ).r;\n#endif",transmissionmap_pars_fragment:"#ifdef USE_TRANSMISSIONMAP\n\tuniform sampler2D transmissionMap;\n#endif",uv_pars_fragment:"#if ( defined( USE_UV ) && ! defined( UVS_VERTEX_ONLY ) )\n\tvarying vec2 vUv;\n#endif",uv_pars_vertex:"#ifdef USE_UV\n\t#ifdef UVS_VERTEX_ONLY\n\t\tvec2 vUv;\n\t#else\n\t\tvarying vec2 vUv;\n\t#endif\n\tuniform mat3 uvTransform;\n#endif",uv_vertex:"#ifdef USE_UV\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n#endif",uv2_pars_fragment:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvarying vec2 vUv2;\n#endif",uv2_pars_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tattribute vec2 uv2;\n\tvarying vec2 vUv2;\n\tuniform mat3 uv2Transform;\n#endif",uv2_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvUv2 = ( uv2Transform * vec3( uv2, 1 ) ).xy;\n#endif",worldpos_vertex:"#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP )\n\tvec4 worldPosition = vec4( transformed, 1.0 );\n\t#ifdef USE_INSTANCING\n\t\tworldPosition = instanceMatrix * worldPosition;\n\t#endif\n\tworldPosition = modelMatrix * worldPosition;\n#endif",background_frag:"uniform sampler2D t2D;\nvarying vec2 vUv;\nvoid main() {\n\tvec4 texColor = texture2D( t2D, vUv );\n\tgl_FragColor = mapTexelToLinear( texColor );\n\t#include \n\t#include \n}",background_vert:"varying vec2 vUv;\nuniform mat3 uvTransform;\nvoid main() {\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n\tgl_Position = vec4( position.xy, 1.0, 1.0 );\n}",cube_frag:"#include \nuniform float opacity;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvec3 vReflect = vWorldDirection;\n\t#include \n\tgl_FragColor = envColor;\n\tgl_FragColor.a *= opacity;\n\t#include \n\t#include \n}",cube_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n\tgl_Position.z = gl_Position.w;\n}",depth_frag:"#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\tfloat fragCoordZ = 0.5 * vHighPrecisionZW[0] / vHighPrecisionZW[1] + 0.5;\n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( fragCoordZ );\n\t#endif\n}",depth_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \nvarying vec2 vHighPrecisionZW;\nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvHighPrecisionZW = gl_Position.zw;\n}",distanceRGBA_frag:"#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main () {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include \n\t#include \n\t#include \n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}",distanceRGBA_vert:"#define DISTANCE\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvWorldPosition = worldPosition.xyz;\n}",equirect_frag:"uniform sampler2D tEquirect;\nvarying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvec3 direction = normalize( vWorldDirection );\n\tvec2 sampleUV = equirectUv( direction );\n\tvec4 texColor = texture2D( tEquirect, sampleUV );\n\tgl_FragColor = mapTexelToLinear( texColor );\n\t#include \n\t#include \n}",equirect_vert:"varying vec3 vWorldDirection;\n#include \nvoid main() {\n\tvWorldDirection = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n}",linedashed_frag:"uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}",linedashed_vert:"uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tvLineDistance = scale * lineDistance;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\n\t\tvec4 lightMapTexel= texture2D( lightMap, vUv2 );\n\t\treflectedLight.indirectDiffuse += lightMapTexelToLinear( lightMapTexel ).rgb * lightMapIntensity;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshbasic_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef USE_ENVMAP\n\t#include \n\t#include \n\t#include \n\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_frag:"uniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\nvarying vec3 vLightFront;\nvarying vec3 vIndirectFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n\tvarying vec3 vIndirectBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.indirectDiffuse += ( gl_FrontFacing ) ? vIndirectFront : vIndirectBack;\n\t#else\n\t\treflectedLight.indirectDiffuse += vIndirectFront;\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb );\n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.directDiffuse = ( gl_FrontFacing ) ? vLightFront : vLightBack;\n\t#else\n\t\treflectedLight.directDiffuse = vLightFront;\n\t#endif\n\treflectedLight.directDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb ) * getShadowMask();\n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshlambert_vert:"#define LAMBERT\nvarying vec3 vLightFront;\nvarying vec3 vIndirectFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n\tvarying vec3 vIndirectBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshmatcap_frag:"#define MATCAP\nuniform vec3 diffuse;\nuniform float opacity;\nuniform sampler2D matcap;\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 viewDir = normalize( vViewPosition );\n\tvec3 x = normalize( vec3( viewDir.z, 0.0, - viewDir.x ) );\n\tvec3 y = cross( viewDir, x );\n\tvec2 uv = vec2( dot( x, normal ), dot( y, normal ) ) * 0.495 + 0.5;\n\t#ifdef USE_MATCAP\n\t\tvec4 matcapColor = texture2D( matcap, uv );\n\t\tmatcapColor = matcapTexelToLinear( matcapColor );\n\t#else\n\t\tvec4 matcapColor = vec4( 1.0 );\n\t#endif\n\tvec3 outgoingLight = diffuseColor.rgb * matcapColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshmatcap_vert:"#define MATCAP\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifndef FLAT_SHADED\n\t\tvNormal = normalize( transformedNormal );\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n}",meshtoon_frag:"#define TOON\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshtoon_vert:"#define TOON\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}",meshphong_frag:"#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphong_vert:"#define PHONG\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphysical_frag:"#define STANDARD\n#ifdef PHYSICAL\n\t#define REFLECTIVITY\n\t#define CLEARCOAT\n\t#define TRANSMISSION\n#endif\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifdef TRANSMISSION\n\tuniform float transmission;\n#endif\n#ifdef REFLECTIVITY\n\tuniform float reflectivity;\n#endif\n#ifdef CLEARCOAT\n\tuniform float clearcoat;\n\tuniform float clearcoatRoughness;\n#endif\n#ifdef USE_SHEEN\n\tuniform vec3 sheen;\n#endif\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#ifdef TRANSMISSION\n\t\tfloat totalTransmission = transmission;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#ifdef TRANSMISSION\n\t\tdiffuseColor.a *= mix( saturate( 1. - totalTransmission + linearToRelativeLuminance( reflectedLight.directSpecular + reflectedLight.indirectSpecular ) ), 1.0, metalness );\n\t#endif\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",meshphysical_vert:"#define STANDARD\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}",normal_frag:"#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_FragColor = vec4( packNormalToRGB( normal ), opacity );\n}",normal_vert:"#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n\t#ifdef USE_TANGENT\n\t\tvarying vec3 vTangent;\n\t\tvarying vec3 vBitangent;\n\t#endif\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n\t#ifdef USE_TANGENT\n\t\tvTangent = normalize( transformedTangent );\n\t\tvBitangent = normalize( cross( vNormal, vTangent ) * tangent.w );\n\t#endif\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( TANGENTSPACE_NORMALMAP )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}",points_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}",points_vert:"uniform float size;\nuniform float scale;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\tgl_PointSize = size;\n\t#ifdef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n}",shadow_frag:"uniform vec3 color;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include \n\t#include \n\t#include \n}",shadow_vert:"#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}",sprite_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n}",sprite_vert:"uniform float rotation;\nuniform vec2 center;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );\n\tvec2 scale;\n\tscale.x = length( vec3( modelMatrix[ 0 ].x, modelMatrix[ 0 ].y, modelMatrix[ 0 ].z ) );\n\tscale.y = length( vec3( modelMatrix[ 1 ].x, modelMatrix[ 1 ].y, modelMatrix[ 1 ].z ) );\n\t#ifndef USE_SIZEATTENUATION\n\t\tbool isPerspective = isPerspectiveMatrix( projectionMatrix );\n\t\tif ( isPerspective ) scale *= - mvPosition.z;\n\t#endif\n\tvec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;\n\tvec2 rotatedPosition;\n\trotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;\n\trotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;\n\tmvPosition.xy += rotatedPosition;\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include \n\t#include \n\t#include \n}"},ui={common:{diffuse:{value:new tn(15658734)},opacity:{value:1},map:{value:null},uvTransform:{value:new yt},uv2Transform:{value:new yt},alphaMap:{value:null}},specularmap:{specularMap:{value:null}},envmap:{envMap:{value:null},flipEnvMap:{value:-1},reflectivity:{value:1},refractionRatio:{value:.98},maxMipLevel:{value:0}},aomap:{aoMap:{value:null},aoMapIntensity:{value:1}},lightmap:{lightMap:{value:null},lightMapIntensity:{value:1}},emissivemap:{emissiveMap:{value:null}},bumpmap:{bumpMap:{value:null},bumpScale:{value:1}},normalmap:{normalMap:{value:null},normalScale:{value:new vt(1,1)}},displacementmap:{displacementMap:{value:null},displacementScale:{value:1},displacementBias:{value:0}},roughnessmap:{roughnessMap:{value:null}},metalnessmap:{metalnessMap:{value:null}},gradientmap:{gradientMap:{value:null}},fog:{fogDensity:{value:25e-5},fogNear:{value:1},fogFar:{value:2e3},fogColor:{value:new tn(16777215)}},lights:{ambientLightColor:{value:[]},lightProbe:{value:[]},directionalLights:{value:[],properties:{direction:{},color:{}}},directionalLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},directionalShadowMap:{value:[]},directionalShadowMatrix:{value:[]},spotLights:{value:[],properties:{color:{},position:{},direction:{},distance:{},coneCos:{},penumbraCos:{},decay:{}}},spotLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{}}},spotShadowMap:{value:[]},spotShadowMatrix:{value:[]},pointLights:{value:[],properties:{color:{},position:{},decay:{},distance:{}}},pointLightShadows:{value:[],properties:{shadowBias:{},shadowNormalBias:{},shadowRadius:{},shadowMapSize:{},shadowCameraNear:{},shadowCameraFar:{}}},pointShadowMap:{value:[]},pointShadowMatrix:{value:[]},hemisphereLights:{value:[],properties:{direction:{},skyColor:{},groundColor:{}}},rectAreaLights:{value:[],properties:{color:{},position:{},width:{},height:{}}},ltc_1:{value:null},ltc_2:{value:null}},points:{diffuse:{value:new tn(15658734)},opacity:{value:1},size:{value:1},scale:{value:1},map:{value:null},alphaMap:{value:null},uvTransform:{value:new yt}},sprite:{diffuse:{value:new tn(15658734)},opacity:{value:1},center:{value:new vt(.5,.5)},rotation:{value:0},map:{value:null},alphaMap:{value:null},uvTransform:{value:new yt}}},di={basic:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.fog]),vertexShader:hi.meshbasic_vert,fragmentShader:hi.meshbasic_frag},lambert:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.fog,ui.lights,{emissive:{value:new tn(0)}}]),vertexShader:hi.meshlambert_vert,fragmentShader:hi.meshlambert_frag},phong:{uniforms:Yn([ui.common,ui.specularmap,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.fog,ui.lights,{emissive:{value:new tn(0)},specular:{value:new tn(1118481)},shininess:{value:30}}]),vertexShader:hi.meshphong_vert,fragmentShader:hi.meshphong_frag},standard:{uniforms:Yn([ui.common,ui.envmap,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.roughnessmap,ui.metalnessmap,ui.fog,ui.lights,{emissive:{value:new tn(0)},roughness:{value:1},metalness:{value:0},envMapIntensity:{value:1}}]),vertexShader:hi.meshphysical_vert,fragmentShader:hi.meshphysical_frag},toon:{uniforms:Yn([ui.common,ui.aomap,ui.lightmap,ui.emissivemap,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.gradientmap,ui.fog,ui.lights,{emissive:{value:new tn(0)}}]),vertexShader:hi.meshtoon_vert,fragmentShader:hi.meshtoon_frag},matcap:{uniforms:Yn([ui.common,ui.bumpmap,ui.normalmap,ui.displacementmap,ui.fog,{matcap:{value:null}}]),vertexShader:hi.meshmatcap_vert,fragmentShader:hi.meshmatcap_frag},points:{uniforms:Yn([ui.points,ui.fog]),vertexShader:hi.points_vert,fragmentShader:hi.points_frag},dashed:{uniforms:Yn([ui.common,ui.fog,{scale:{value:1},dashSize:{value:1},totalSize:{value:2}}]),vertexShader:hi.linedashed_vert,fragmentShader:hi.linedashed_frag},depth:{uniforms:Yn([ui.common,ui.displacementmap]),vertexShader:hi.depth_vert,fragmentShader:hi.depth_frag},normal:{uniforms:Yn([ui.common,ui.bumpmap,ui.normalmap,ui.displacementmap,{opacity:{value:1}}]),vertexShader:hi.normal_vert,fragmentShader:hi.normal_frag},sprite:{uniforms:Yn([ui.sprite,ui.fog]),vertexShader:hi.sprite_vert,fragmentShader:hi.sprite_frag},background:{uniforms:{uvTransform:{value:new yt},t2D:{value:null}},vertexShader:hi.background_vert,fragmentShader:hi.background_frag},cube:{uniforms:Yn([ui.envmap,{opacity:{value:1}}]),vertexShader:hi.cube_vert,fragmentShader:hi.cube_frag},equirect:{uniforms:{tEquirect:{value:null}},vertexShader:hi.equirect_vert,fragmentShader:hi.equirect_frag},distanceRGBA:{uniforms:Yn([ui.common,ui.displacementmap,{referencePosition:{value:new Lt},nearDistance:{value:1},farDistance:{value:1e3}}]),vertexShader:hi.distanceRGBA_vert,fragmentShader:hi.distanceRGBA_frag},shadow:{uniforms:Yn([ui.lights,ui.fog,{color:{value:new tn(0)},opacity:{value:1}}]),vertexShader:hi.shadow_vert,fragmentShader:hi.shadow_frag}};function pi(t,e,n,i,r){const s=new tn(0);let a,o,c=0,h=null,u=0,d=null;function p(t,e){n.buffers.color.setClear(t.r,t.g,t.b,e,r)}return{getClearColor:function(){return s},setClearColor:function(t,e=1){s.set(t),c=e,p(s,c)},getClearAlpha:function(){return c},setClearAlpha:function(t){c=t,p(s,c)},render:function(n,r,m,f){let g=!0===r.isScene?r.background:null;g&&g.isTexture&&(g=e.get(g));const v=t.xr,y=v.getSession&&v.getSession();y&&"additive"===y.environmentBlendMode&&(g=null),null===g?p(s,c):g&&g.isColor&&(p(g,1),f=!0),(t.autoClear||f)&&t.clear(t.autoClearColor,t.autoClearDepth,t.autoClearStencil),g&&(g.isCubeTexture||g.mapping===l)?(void 0===o&&(o=new Wn(new qn(1,1,1),new Jn({name:"BackgroundCubeMaterial",uniforms:Xn(di.cube.uniforms),vertexShader:di.cube.vertexShader,fragmentShader:di.cube.fragmentShader,side:1,depthTest:!1,depthWrite:!1,fog:!1})),o.geometry.deleteAttribute("normal"),o.geometry.deleteAttribute("uv"),o.onBeforeRender=function(t,e,n){this.matrixWorld.copyPosition(n.matrixWorld)},Object.defineProperty(o.material,"envMap",{get:function(){return this.uniforms.envMap.value}}),i.update(o)),o.material.uniforms.envMap.value=g,o.material.uniforms.flipEnvMap.value=g.isCubeTexture&&g._needsFlipEnvMap?-1:1,h===g&&u===g.version&&d===t.toneMapping||(o.material.needsUpdate=!0,h=g,u=g.version,d=t.toneMapping),n.unshift(o,o.geometry,o.material,0,0,null)):g&&g.isTexture&&(void 0===a&&(a=new Wn(new ci(2,2),new Jn({name:"BackgroundMaterial",uniforms:Xn(di.background.uniforms),vertexShader:di.background.vertexShader,fragmentShader:di.background.fragmentShader,side:0,depthTest:!1,depthWrite:!1,fog:!1})),a.geometry.deleteAttribute("normal"),Object.defineProperty(a.material,"map",{get:function(){return this.uniforms.t2D.value}}),i.update(a)),a.material.uniforms.t2D.value=g,!0===g.matrixAutoUpdate&&g.updateMatrix(),a.material.uniforms.uvTransform.value.copy(g.matrix),h===g&&u===g.version&&d===t.toneMapping||(a.material.needsUpdate=!0,h=g,u=g.version,d=t.toneMapping),n.unshift(a,a.geometry,a.material,0,0,null))}}}function mi(t,e,n,i){const r=t.getParameter(34921),s=i.isWebGL2?null:e.get("OES_vertex_array_object"),a=i.isWebGL2||null!==s,o={},l=d(null);let c=l;function h(e){return i.isWebGL2?t.bindVertexArray(e):s.bindVertexArrayOES(e)}function u(e){return i.isWebGL2?t.deleteVertexArray(e):s.deleteVertexArrayOES(e)}function d(t){const e=[],n=[],i=[];for(let t=0;t=0){const s=l[e];if(void 0!==s){const e=s.normalized,r=s.itemSize,a=n.get(s);if(void 0===a)continue;const l=a.buffer,c=a.type,h=a.bytesPerElement;if(s.isInterleavedBufferAttribute){const n=s.data,a=n.stride,u=s.offset;n&&n.isInstancedInterleavedBuffer?(f(i,n.meshPerAttribute),void 0===o._maxInstanceCount&&(o._maxInstanceCount=n.meshPerAttribute*n.count)):m(i),t.bindBuffer(34962,l),v(i,r,c,e,a*h,u*h)}else s.isInstancedBufferAttribute?(f(i,s.meshPerAttribute),void 0===o._maxInstanceCount&&(o._maxInstanceCount=s.meshPerAttribute*s.count)):m(i),t.bindBuffer(34962,l),v(i,r,c,e,0,0)}else if("instanceMatrix"===e){const e=n.get(r.instanceMatrix);if(void 0===e)continue;const s=e.buffer,a=e.type;f(i+0,1),f(i+1,1),f(i+2,1),f(i+3,1),t.bindBuffer(34962,s),t.vertexAttribPointer(i+0,4,a,!1,64,0),t.vertexAttribPointer(i+1,4,a,!1,64,16),t.vertexAttribPointer(i+2,4,a,!1,64,32),t.vertexAttribPointer(i+3,4,a,!1,64,48)}else if("instanceColor"===e){const e=n.get(r.instanceColor);if(void 0===e)continue;const s=e.buffer,a=e.type;f(i,1),t.bindBuffer(34962,s),t.vertexAttribPointer(i,3,a,!1,12,0)}else if(void 0!==h){const n=h[e];if(void 0!==n)switch(n.length){case 2:t.vertexAttrib2fv(i,n);break;case 3:t.vertexAttrib3fv(i,n);break;case 4:t.vertexAttrib4fv(i,n);break;default:t.vertexAttrib1fv(i,n)}}}}g()}(r,l,u,y),null!==x&&t.bindBuffer(34963,n.get(x).buffer))},reset:y,resetDefaultState:x,dispose:function(){y();for(const t in o){const e=o[t];for(const t in e){const n=e[t];for(const t in n)u(n[t].object),delete n[t];delete e[t]}delete o[t]}},releaseStatesOfGeometry:function(t){if(void 0===o[t.id])return;const e=o[t.id];for(const t in e){const n=e[t];for(const t in n)u(n[t].object),delete n[t];delete e[t]}delete o[t.id]},releaseStatesOfProgram:function(t){for(const e in o){const n=o[e];if(void 0===n[t.id])continue;const i=n[t.id];for(const t in i)u(i[t].object),delete i[t];delete n[t.id]}},initAttributes:p,enableAttribute:m,disableUnusedAttributes:g}}function fi(t,e,n,i){const r=i.isWebGL2;let s;this.setMode=function(t){s=t},this.render=function(e,i){t.drawArrays(s,e,i),n.update(i,s,1)},this.renderInstances=function(i,a,o){if(0===o)return;let l,c;if(r)l=t,c="drawArraysInstanced";else if(l=e.get("ANGLE_instanced_arrays"),c="drawArraysInstancedANGLE",null===l)return void console.error("THREE.WebGLBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.");l[c](s,i,a,o),n.update(a,s,o)}}function gi(t,e,n){let i;function r(e){if("highp"===e){if(t.getShaderPrecisionFormat(35633,36338).precision>0&&t.getShaderPrecisionFormat(35632,36338).precision>0)return"highp";e="mediump"}return"mediump"===e&&t.getShaderPrecisionFormat(35633,36337).precision>0&&t.getShaderPrecisionFormat(35632,36337).precision>0?"mediump":"lowp"}const s="undefined"!=typeof WebGL2RenderingContext&&t instanceof WebGL2RenderingContext||"undefined"!=typeof WebGL2ComputeRenderingContext&&t instanceof WebGL2ComputeRenderingContext;let a=void 0!==n.precision?n.precision:"highp";const o=r(a);o!==a&&(console.warn("THREE.WebGLRenderer:",a,"not supported, using",o,"instead."),a=o);const l=!0===n.logarithmicDepthBuffer,c=t.getParameter(34930),h=t.getParameter(35660),u=t.getParameter(3379),d=t.getParameter(34076),p=t.getParameter(34921),m=t.getParameter(36347),f=t.getParameter(36348),g=t.getParameter(36349),v=h>0,y=s||e.has("OES_texture_float");return{isWebGL2:s,getMaxAnisotropy:function(){if(void 0!==i)return i;if(!0===e.has("EXT_texture_filter_anisotropic")){const n=e.get("EXT_texture_filter_anisotropic");i=t.getParameter(n.MAX_TEXTURE_MAX_ANISOTROPY_EXT)}else i=0;return i},getMaxPrecision:r,precision:a,logarithmicDepthBuffer:l,maxTextures:c,maxVertexTextures:h,maxTextureSize:u,maxCubemapSize:d,maxAttributes:p,maxVertexUniforms:m,maxVaryings:f,maxFragmentUniforms:g,vertexTextures:v,floatFragmentTextures:y,floatVertexTextures:v&&y,maxSamples:s?t.getParameter(36183):0}}function vi(t){const e=this;let n=null,i=0,r=!1,s=!1;const a=new Ne,o=new yt,l={value:null,needsUpdate:!1};function c(){l.value!==n&&(l.value=n,l.needsUpdate=i>0),e.numPlanes=i,e.numIntersection=0}function h(t,n,i,r){const s=null!==t?t.length:0;let c=null;if(0!==s){if(c=l.value,!0!==r||null===c){const e=i+4*s,r=n.matrixWorldInverse;o.getNormalMatrix(r),(null===c||c.length0){const a=t.getRenderTarget(),o=new ni(s.height/2);return o.fromEquirectangularTexture(t,r),e.set(r,o),t.setRenderTarget(a),r.addEventListener("dispose",i),n(o.texture,r.mapping)}return null}}}return r},dispose:function(){e=new WeakMap}}}function xi(t){const e={};function n(n){if(void 0!==e[n])return e[n];let i;switch(n){case"WEBGL_depth_texture":i=t.getExtension("WEBGL_depth_texture")||t.getExtension("MOZ_WEBGL_depth_texture")||t.getExtension("WEBKIT_WEBGL_depth_texture");break;case"EXT_texture_filter_anisotropic":i=t.getExtension("EXT_texture_filter_anisotropic")||t.getExtension("MOZ_EXT_texture_filter_anisotropic")||t.getExtension("WEBKIT_EXT_texture_filter_anisotropic");break;case"WEBGL_compressed_texture_s3tc":i=t.getExtension("WEBGL_compressed_texture_s3tc")||t.getExtension("MOZ_WEBGL_compressed_texture_s3tc")||t.getExtension("WEBKIT_WEBGL_compressed_texture_s3tc");break;case"WEBGL_compressed_texture_pvrtc":i=t.getExtension("WEBGL_compressed_texture_pvrtc")||t.getExtension("WEBKIT_WEBGL_compressed_texture_pvrtc");break;default:i=t.getExtension(n)}return e[n]=i,i}return{has:function(t){return null!==n(t)},init:function(t){t.isWebGL2?n("EXT_color_buffer_float"):(n("WEBGL_depth_texture"),n("OES_texture_float"),n("OES_texture_half_float"),n("OES_texture_half_float_linear"),n("OES_standard_derivatives"),n("OES_element_index_uint"),n("OES_vertex_array_object"),n("ANGLE_instanced_arrays")),n("OES_texture_float_linear"),n("EXT_color_buffer_half_float")},get:function(t){const e=n(t);return null===e&&console.warn("THREE.WebGLRenderer: "+t+" extension not supported."),e}}}function _i(t,e,n,i){const r={},s=new WeakMap;function a(t){const o=t.target;null!==o.index&&e.remove(o.index);for(const t in o.attributes)e.remove(o.attributes[t]);o.removeEventListener("dispose",a),delete r[o.id];const l=s.get(o);l&&(e.remove(l),s.delete(o)),i.releaseStatesOfGeometry(o),!0===o.isInstancedBufferGeometry&&delete o._maxInstanceCount,n.memory.geometries--}function o(t){const n=[],i=t.index,r=t.attributes.position;let a=0;if(null!==i){const t=i.array;a=i.version;for(let e=0,i=t.length;e65535?dn:hn)(n,1);o.version=a;const l=s.get(t);l&&e.remove(l),s.set(t,o)}return{get:function(t,e){return!0===r[e.id]||(e.addEventListener("dispose",a),r[e.id]=!0,n.memory.geometries++),e},update:function(t){const n=t.attributes;for(const t in n)e.update(n[t],34962);const i=t.morphAttributes;for(const t in i){const n=i[t];for(let t=0,i=n.length;t0)return t;const r=e*n;let s=Ii[r];if(void 0===s&&(s=new Float32Array(r),Ii[r]=s),0!==e){i.toArray(s,0);for(let i=1,r=0;i!==e;++i)r+=n,t[i].toArray(s,r)}return s}function Hi(t,e){if(t.length!==e.length)return!1;for(let n=0,i=t.length;n/gm;function kr(t){return t.replace(Ur,Vr)}function Vr(t,e){const n=hi[e];if(void 0===n)throw new Error("Can not resolve #include <"+e+">");return kr(n)}const Wr=/#pragma unroll_loop[\s]+?for \( int i \= (\d+)\; i < (\d+)\; i \+\+ \) \{([\s\S]+?)(?=\})\}/g,jr=/#pragma unroll_loop_start\s+for\s*\(\s*int\s+i\s*=\s*(\d+)\s*;\s*i\s*<\s*(\d+)\s*;\s*i\s*\+\+\s*\)\s*{([\s\S]+?)}\s+#pragma unroll_loop_end/g;function qr(t){return t.replace(jr,Yr).replace(Wr,Xr)}function Xr(t,e,n,i){return console.warn("WebGLProgram: #pragma unroll_loop shader syntax is deprecated. Please use #pragma unroll_loop_start syntax instead."),Yr(t,e,n,i)}function Yr(t,e,n,i){let r="";for(let t=parseInt(e);t0?t.gammaFactor:1,v=n.isWebGL2?"":function(t){return[t.extensionDerivatives||t.envMapCubeUV||t.bumpMap||t.tangentSpaceNormalMap||t.clearcoatNormalMap||t.flatShading||"physical"===t.shaderID?"#extension GL_OES_standard_derivatives : enable":"",(t.extensionFragDepth||t.logarithmicDepthBuffer)&&t.rendererExtensionFragDepth?"#extension GL_EXT_frag_depth : enable":"",t.extensionDrawBuffers&&t.rendererExtensionDrawBuffers?"#extension GL_EXT_draw_buffers : require":"",(t.extensionShaderTextureLOD||t.envMap)&&t.rendererExtensionShaderTextureLod?"#extension GL_EXT_shader_texture_lod : enable":""].filter(Or).join("\n")}(n),y=function(t){const e=[];for(const n in t){const i=t[n];!1!==i&&e.push("#define "+n+" "+i)}return e.join("\n")}(o),x=a.createProgram();let _,w,b=n.glslVersion?"#version "+n.glslVersion+"\n":"";n.isRawShaderMaterial?(_=[y].filter(Or).join("\n"),_.length>0&&(_+="\n"),w=[v,y].filter(Or).join("\n"),w.length>0&&(w+="\n")):(_=[Zr(n),"#define SHADER_NAME "+n.shaderName,y,n.instancing?"#define USE_INSTANCING":"",n.instancingColor?"#define USE_INSTANCING_COLOR":"",n.supportsVertexTextures?"#define VERTEX_TEXTURES":"","#define GAMMA_FACTOR "+g,"#define MAX_BONES "+n.maxBones,n.useFog&&n.fog?"#define USE_FOG":"",n.useFog&&n.fogExp2?"#define FOG_EXP2":"",n.map?"#define USE_MAP":"",n.envMap?"#define USE_ENVMAP":"",n.envMap?"#define "+m:"",n.lightMap?"#define USE_LIGHTMAP":"",n.aoMap?"#define USE_AOMAP":"",n.emissiveMap?"#define USE_EMISSIVEMAP":"",n.bumpMap?"#define USE_BUMPMAP":"",n.normalMap?"#define USE_NORMALMAP":"",n.normalMap&&n.objectSpaceNormalMap?"#define OBJECTSPACE_NORMALMAP":"",n.normalMap&&n.tangentSpaceNormalMap?"#define TANGENTSPACE_NORMALMAP":"",n.clearcoatMap?"#define USE_CLEARCOATMAP":"",n.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",n.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",n.displacementMap&&n.supportsVertexTextures?"#define USE_DISPLACEMENTMAP":"",n.specularMap?"#define USE_SPECULARMAP":"",n.roughnessMap?"#define USE_ROUGHNESSMAP":"",n.metalnessMap?"#define USE_METALNESSMAP":"",n.alphaMap?"#define USE_ALPHAMAP":"",n.transmissionMap?"#define USE_TRANSMISSIONMAP":"",n.vertexTangents?"#define USE_TANGENT":"",n.vertexColors?"#define USE_COLOR":"",n.vertexAlphas?"#define USE_COLOR_ALPHA":"",n.vertexUvs?"#define USE_UV":"",n.uvsVertexOnly?"#define UVS_VERTEX_ONLY":"",n.flatShading?"#define FLAT_SHADED":"",n.skinning?"#define USE_SKINNING":"",n.useVertexTexture?"#define BONE_TEXTURE":"",n.morphTargets?"#define USE_MORPHTARGETS":"",n.morphNormals&&!1===n.flatShading?"#define USE_MORPHNORMALS":"",n.doubleSided?"#define DOUBLE_SIDED":"",n.flipSided?"#define FLIP_SIDED":"",n.shadowMapEnabled?"#define USE_SHADOWMAP":"",n.shadowMapEnabled?"#define "+d:"",n.sizeAttenuation?"#define USE_SIZEATTENUATION":"",n.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",n.logarithmicDepthBuffer&&n.rendererExtensionFragDepth?"#define USE_LOGDEPTHBUF_EXT":"","uniform mat4 modelMatrix;","uniform mat4 modelViewMatrix;","uniform mat4 projectionMatrix;","uniform mat4 viewMatrix;","uniform mat3 normalMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;","#ifdef USE_INSTANCING","\tattribute mat4 instanceMatrix;","#endif","#ifdef USE_INSTANCING_COLOR","\tattribute vec3 instanceColor;","#endif","attribute vec3 position;","attribute vec3 normal;","attribute vec2 uv;","#ifdef USE_TANGENT","\tattribute vec4 tangent;","#endif","#if defined( USE_COLOR_ALPHA )","\tattribute vec4 color;","#elif defined( USE_COLOR )","\tattribute vec3 color;","#endif","#ifdef USE_MORPHTARGETS","\tattribute vec3 morphTarget0;","\tattribute vec3 morphTarget1;","\tattribute vec3 morphTarget2;","\tattribute vec3 morphTarget3;","\t#ifdef USE_MORPHNORMALS","\t\tattribute vec3 morphNormal0;","\t\tattribute vec3 morphNormal1;","\t\tattribute vec3 morphNormal2;","\t\tattribute vec3 morphNormal3;","\t#else","\t\tattribute vec3 morphTarget4;","\t\tattribute vec3 morphTarget5;","\t\tattribute vec3 morphTarget6;","\t\tattribute vec3 morphTarget7;","\t#endif","#endif","#ifdef USE_SKINNING","\tattribute vec4 skinIndex;","\tattribute vec4 skinWeight;","#endif","\n"].filter(Or).join("\n"),w=[v,Zr(n),"#define SHADER_NAME "+n.shaderName,y,n.alphaTest?"#define ALPHATEST "+n.alphaTest+(n.alphaTest%1?"":".0"):"","#define GAMMA_FACTOR "+g,n.useFog&&n.fog?"#define USE_FOG":"",n.useFog&&n.fogExp2?"#define FOG_EXP2":"",n.map?"#define USE_MAP":"",n.matcap?"#define USE_MATCAP":"",n.envMap?"#define USE_ENVMAP":"",n.envMap?"#define "+p:"",n.envMap?"#define "+m:"",n.envMap?"#define "+f:"",n.lightMap?"#define USE_LIGHTMAP":"",n.aoMap?"#define USE_AOMAP":"",n.emissiveMap?"#define USE_EMISSIVEMAP":"",n.bumpMap?"#define USE_BUMPMAP":"",n.normalMap?"#define USE_NORMALMAP":"",n.normalMap&&n.objectSpaceNormalMap?"#define OBJECTSPACE_NORMALMAP":"",n.normalMap&&n.tangentSpaceNormalMap?"#define TANGENTSPACE_NORMALMAP":"",n.clearcoatMap?"#define USE_CLEARCOATMAP":"",n.clearcoatRoughnessMap?"#define USE_CLEARCOAT_ROUGHNESSMAP":"",n.clearcoatNormalMap?"#define USE_CLEARCOAT_NORMALMAP":"",n.specularMap?"#define USE_SPECULARMAP":"",n.roughnessMap?"#define USE_ROUGHNESSMAP":"",n.metalnessMap?"#define USE_METALNESSMAP":"",n.alphaMap?"#define USE_ALPHAMAP":"",n.sheen?"#define USE_SHEEN":"",n.transmissionMap?"#define USE_TRANSMISSIONMAP":"",n.vertexTangents?"#define USE_TANGENT":"",n.vertexColors||n.instancingColor?"#define USE_COLOR":"",n.vertexAlphas?"#define USE_COLOR_ALPHA":"",n.vertexUvs?"#define USE_UV":"",n.uvsVertexOnly?"#define UVS_VERTEX_ONLY":"",n.gradientMap?"#define USE_GRADIENTMAP":"",n.flatShading?"#define FLAT_SHADED":"",n.doubleSided?"#define DOUBLE_SIDED":"",n.flipSided?"#define FLIP_SIDED":"",n.shadowMapEnabled?"#define USE_SHADOWMAP":"",n.shadowMapEnabled?"#define "+d:"",n.premultipliedAlpha?"#define PREMULTIPLIED_ALPHA":"",n.physicallyCorrectLights?"#define PHYSICALLY_CORRECT_LIGHTS":"",n.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",n.logarithmicDepthBuffer&&n.rendererExtensionFragDepth?"#define USE_LOGDEPTHBUF_EXT":"",(n.extensionShaderTextureLOD||n.envMap)&&n.rendererExtensionShaderTextureLod?"#define TEXTURE_LOD_EXT":"","uniform mat4 viewMatrix;","uniform vec3 cameraPosition;","uniform bool isOrthographic;",0!==n.toneMapping?"#define TONE_MAPPING":"",0!==n.toneMapping?hi.tonemapping_pars_fragment:"",0!==n.toneMapping?Fr("toneMapping",n.toneMapping):"",n.dithering?"#define DITHERING":"",hi.encodings_pars_fragment,n.map?Br("mapTexelToLinear",n.mapEncoding):"",n.matcap?Br("matcapTexelToLinear",n.matcapEncoding):"",n.envMap?Br("envMapTexelToLinear",n.envMapEncoding):"",n.emissiveMap?Br("emissiveMapTexelToLinear",n.emissiveMapEncoding):"",n.lightMap?Br("lightMapTexelToLinear",n.lightMapEncoding):"",zr("linearToOutputTexel",n.outputEncoding),n.depthPacking?"#define DEPTH_PACKING "+n.depthPacking:"","\n"].filter(Or).join("\n")),h=kr(h),h=Hr(h,n),h=Gr(h,n),u=kr(u),u=Hr(u,n),u=Gr(u,n),h=qr(h),u=qr(u),n.isWebGL2&&!0!==n.isRawShaderMaterial&&(b="#version 300 es\n",_=["#define attribute in","#define varying out","#define texture2D texture"].join("\n")+"\n"+_,w=["#define varying in",n.glslVersion===it?"":"out highp vec4 pc_fragColor;",n.glslVersion===it?"":"#define gl_FragColor pc_fragColor","#define gl_FragDepthEXT gl_FragDepth","#define texture2D texture","#define textureCube texture","#define texture2DProj textureProj","#define texture2DLodEXT textureLod","#define texture2DProjLodEXT textureProjLod","#define textureCubeLodEXT textureLod","#define texture2DGradEXT textureGrad","#define texture2DProjGradEXT textureProjGrad","#define textureCubeGradEXT textureGrad"].join("\n")+"\n"+w);const M=b+w+u,S=Pr(a,35633,b+_+h),T=Pr(a,35632,M);if(a.attachShader(x,S),a.attachShader(x,T),void 0!==n.index0AttributeName?a.bindAttribLocation(x,0,n.index0AttributeName):!0===n.morphTargets&&a.bindAttribLocation(x,0,"position"),a.linkProgram(x),t.debug.checkShaderErrors){const t=a.getProgramInfoLog(x).trim(),e=a.getShaderInfoLog(S).trim(),n=a.getShaderInfoLog(T).trim();let i=!0,r=!0;if(!1===a.getProgramParameter(x,35714)){i=!1;const e=Nr(a,S,"vertex"),n=Nr(a,T,"fragment");console.error("THREE.WebGLProgram: shader error: ",a.getError(),"35715",a.getProgramParameter(x,35715),"gl.getProgramInfoLog",t,e,n)}else""!==t?console.warn("THREE.WebGLProgram: gl.getProgramInfoLog()",t):""!==e&&""!==n||(r=!1);r&&(this.diagnostics={runnable:i,programLog:t,vertexShader:{log:e,prefix:_},fragmentShader:{log:n,prefix:w}})}let E,A;return a.deleteShader(S),a.deleteShader(T),this.getUniforms=function(){return void 0===E&&(E=new Cr(a,x)),E},this.getAttributes=function(){return void 0===A&&(A=function(t,e){const n={},i=t.getProgramParameter(e,35721);for(let r=0;r0,maxBones:S,useVertexTexture:u,morphTargets:r.morphTargets,morphNormals:r.morphNormals,numDirLights:a.directional.length,numPointLights:a.point.length,numSpotLights:a.spot.length,numRectAreaLights:a.rectArea.length,numHemiLights:a.hemi.length,numDirLightShadows:a.directionalShadowMap.length,numPointLightShadows:a.pointShadowMap.length,numSpotLightShadows:a.spotShadowMap.length,numClippingPlanes:s.numPlanes,numClipIntersection:s.numIntersection,dithering:r.dithering,shadowMapEnabled:t.shadowMap.enabled&&g.length>0,shadowMapType:t.shadowMap.type,toneMapping:r.toneMapped?t.toneMapping:0,physicallyCorrectLights:t.physicallyCorrectLights,premultipliedAlpha:r.premultipliedAlpha,alphaTest:r.alphaTest,doubleSided:2===r.side,flipSided:1===r.side,depthPacking:void 0!==r.depthPacking&&r.depthPacking,index0AttributeName:r.index0AttributeName,extensionDerivatives:r.extensions&&r.extensions.derivatives,extensionFragDepth:r.extensions&&r.extensions.fragDepth,extensionDrawBuffers:r.extensions&&r.extensions.drawBuffers,extensionShaderTextureLOD:r.extensions&&r.extensions.shaderTextureLOD,rendererExtensionFragDepth:o||n.has("EXT_frag_depth"),rendererExtensionDrawBuffers:o||n.has("WEBGL_draw_buffers"),rendererExtensionShaderTextureLod:o||n.has("EXT_shader_texture_lod"),customProgramCacheKey:r.customProgramCacheKey()}},getProgramCacheKey:function(e){const n=[];if(e.shaderID?n.push(e.shaderID):(n.push(e.fragmentShader),n.push(e.vertexShader)),void 0!==e.defines)for(const t in e.defines)n.push(t),n.push(e.defines[t]);if(!1===e.isRawShaderMaterial){for(let t=0;t1&&i.sort(t||$r),r.length>1&&r.sort(e||ts)}}}function ns(t){let e=new WeakMap;return{get:function(n,i){let r;return!1===e.has(n)?(r=new es(t),e.set(n,[r])):i>=e.get(n).length?(r=new es(t),e.get(n).push(r)):r=e.get(n)[i],r},dispose:function(){e=new WeakMap}}}function is(){const t={};return{get:function(e){if(void 0!==t[e.id])return t[e.id];let n;switch(e.type){case"DirectionalLight":n={direction:new Lt,color:new tn};break;case"SpotLight":n={position:new Lt,direction:new Lt,color:new tn,distance:0,coneCos:0,penumbraCos:0,decay:0};break;case"PointLight":n={position:new Lt,color:new tn,distance:0,decay:0};break;case"HemisphereLight":n={direction:new Lt,skyColor:new tn,groundColor:new tn};break;case"RectAreaLight":n={color:new tn,position:new Lt,halfWidth:new Lt,halfHeight:new Lt}}return t[e.id]=n,n}}}let rs=0;function ss(t,e){return(e.castShadow?1:0)-(t.castShadow?1:0)}function as(t,e){const n=new is,i=function(){const t={};return{get:function(e){if(void 0!==t[e.id])return t[e.id];let n;switch(e.type){case"DirectionalLight":case"SpotLight":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new vt};break;case"PointLight":n={shadowBias:0,shadowNormalBias:0,shadowRadius:1,shadowMapSize:new vt,shadowCameraNear:1,shadowCameraFar:1e3}}return t[e.id]=n,n}}}(),r={version:0,hash:{directionalLength:-1,pointLength:-1,spotLength:-1,rectAreaLength:-1,hemiLength:-1,numDirectionalShadows:-1,numPointShadows:-1,numSpotShadows:-1},ambient:[0,0,0],probe:[],directional:[],directionalShadow:[],directionalShadowMap:[],directionalShadowMatrix:[],spot:[],spotShadow:[],spotShadowMap:[],spotShadowMatrix:[],rectArea:[],rectAreaLTC1:null,rectAreaLTC2:null,point:[],pointShadow:[],pointShadowMap:[],pointShadowMatrix:[],hemi:[]};for(let t=0;t<9;t++)r.probe.push(new Lt);const s=new Lt,a=new se,o=new se;return{setup:function(s){let a=0,o=0,l=0;for(let t=0;t<9;t++)r.probe[t].set(0,0,0);let c=0,h=0,u=0,d=0,p=0,m=0,f=0,g=0;s.sort(ss);for(let t=0,e=s.length;t0&&(e.isWebGL2||!0===t.has("OES_texture_float_linear")?(r.rectAreaLTC1=ui.LTC_FLOAT_1,r.rectAreaLTC2=ui.LTC_FLOAT_2):!0===t.has("OES_texture_half_float_linear")?(r.rectAreaLTC1=ui.LTC_HALF_1,r.rectAreaLTC2=ui.LTC_HALF_2):console.error("THREE.WebGLRenderer: Unable to use RectAreaLight. Missing WebGL extensions.")),r.ambient[0]=a,r.ambient[1]=o,r.ambient[2]=l;const v=r.hash;v.directionalLength===c&&v.pointLength===h&&v.spotLength===u&&v.rectAreaLength===d&&v.hemiLength===p&&v.numDirectionalShadows===m&&v.numPointShadows===f&&v.numSpotShadows===g||(r.directional.length=c,r.spot.length=u,r.rectArea.length=d,r.point.length=h,r.hemi.length=p,r.directionalShadow.length=m,r.directionalShadowMap.length=m,r.pointShadow.length=f,r.pointShadowMap.length=f,r.spotShadow.length=g,r.spotShadowMap.length=g,r.directionalShadowMatrix.length=m,r.pointShadowMatrix.length=f,r.spotShadowMatrix.length=g,v.directionalLength=c,v.pointLength=h,v.spotLength=u,v.rectAreaLength=d,v.hemiLength=p,v.numDirectionalShadows=m,v.numPointShadows=f,v.numSpotShadows=g,r.version=rs++)},setupView:function(t,e){let n=0,i=0,l=0,c=0,h=0;const u=e.matrixWorldInverse;for(let e=0,d=t.length;e=n.get(i).length?(s=new os(t,e),n.get(i).push(s)):s=n.get(i)[r],s},dispose:function(){n=new WeakMap}}}class cs extends Xe{constructor(t){super(),this.type="MeshDepthMaterial",this.depthPacking=3200,this.skinning=!1,this.morphTargets=!1,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.fog=!1,this.setValues(t)}copy(t){return super.copy(t),this.depthPacking=t.depthPacking,this.skinning=t.skinning,this.morphTargets=t.morphTargets,this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this.wireframe=t.wireframe,this.wireframeLinewidth=t.wireframeLinewidth,this}}cs.prototype.isMeshDepthMaterial=!0;class hs extends Xe{constructor(t){super(),this.type="MeshDistanceMaterial",this.referencePosition=new Lt,this.nearDistance=1,this.farDistance=1e3,this.skinning=!1,this.morphTargets=!1,this.map=null,this.alphaMap=null,this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.fog=!1,this.setValues(t)}copy(t){return super.copy(t),this.referencePosition.copy(t.referencePosition),this.nearDistance=t.nearDistance,this.farDistance=t.farDistance,this.skinning=t.skinning,this.morphTargets=t.morphTargets,this.map=t.map,this.alphaMap=t.alphaMap,this.displacementMap=t.displacementMap,this.displacementScale=t.displacementScale,this.displacementBias=t.displacementBias,this}}hs.prototype.isMeshDistanceMaterial=!0;function us(t,e,n){let i=new ai;const r=new vt,s=new vt,a=new St,o=[],l=[],c={},h=n.maxTextureSize,u={0:1,1:0,2:2},d=new Jn({defines:{SAMPLE_RATE:2/8,HALF_SAMPLE_RATE:1/8},uniforms:{shadow_pass:{value:null},resolution:{value:new vt},radius:{value:4}},vertexShader:"void main() {\n\tgl_Position = vec4( position, 1.0 );\n}",fragmentShader:"uniform sampler2D shadow_pass;\nuniform vec2 resolution;\nuniform float radius;\n#include \nvoid main() {\n\tfloat mean = 0.0;\n\tfloat squared_mean = 0.0;\n\tfloat depth = unpackRGBAToDepth( texture2D( shadow_pass, ( gl_FragCoord.xy ) / resolution ) );\n\tfor ( float i = -1.0; i < 1.0 ; i += SAMPLE_RATE) {\n\t\t#ifdef HORIZONTAL_PASS\n\t\t\tvec2 distribution = unpackRGBATo2Half( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( i, 0.0 ) * radius ) / resolution ) );\n\t\t\tmean += distribution.x;\n\t\t\tsquared_mean += distribution.y * distribution.y + distribution.x * distribution.x;\n\t\t#else\n\t\t\tfloat depth = unpackRGBAToDepth( texture2D( shadow_pass, ( gl_FragCoord.xy + vec2( 0.0, i ) * radius ) / resolution ) );\n\t\t\tmean += depth;\n\t\t\tsquared_mean += depth * depth;\n\t\t#endif\n\t}\n\tmean = mean * HALF_SAMPLE_RATE;\n\tsquared_mean = squared_mean * HALF_SAMPLE_RATE;\n\tfloat std_dev = sqrt( squared_mean - mean * mean );\n\tgl_FragColor = pack2HalfToRGBA( vec2( mean, std_dev ) );\n}"}),m=d.clone();m.defines.HORIZONTAL_PASS=1;const f=new En;f.setAttribute("position",new sn(new Float32Array([-1,-1,.5,3,-1,.5,-1,3,.5]),3));const v=new Wn(f,d),y=this;function x(n,i){const r=e.update(v);d.uniforms.shadow_pass.value=n.map.texture,d.uniforms.resolution.value=n.mapSize,d.uniforms.radius.value=n.radius,t.setRenderTarget(n.mapPass),t.clear(),t.renderBufferDirect(i,null,r,d,v,null),m.uniforms.shadow_pass.value=n.mapPass.texture,m.uniforms.resolution.value=n.mapSize,m.uniforms.radius.value=n.radius,t.setRenderTarget(n.map),t.clear(),t.renderBufferDirect(i,null,r,m,v,null)}function _(t,e,n){const i=t<<0|e<<1|n<<2;let r=o[i];return void 0===r&&(r=new cs({depthPacking:3201,morphTargets:t,skinning:e}),o[i]=r),r}function w(t,e,n){const i=t<<0|e<<1|n<<2;let r=l[i];return void 0===r&&(r=new hs({morphTargets:t,skinning:e}),l[i]=r),r}function b(e,n,i,r,s,a,o){let l=null,h=_,d=e.customDepthMaterial;if(!0===r.isPointLight&&(h=w,d=e.customDistanceMaterial),void 0===d){let t=!1;!0===i.morphTargets&&(t=n.morphAttributes&&n.morphAttributes.position&&n.morphAttributes.position.length>0);let r=!1;!0===e.isSkinnedMesh&&(!0===i.skinning?r=!0:console.warn("THREE.WebGLShadowMap: THREE.SkinnedMesh with material.skinning set to false:",e));l=h(t,r,!0===e.isInstancedMesh)}else l=d;if(t.localClippingEnabled&&!0===i.clipShadows&&0!==i.clippingPlanes.length){const t=l.uuid,e=i.uuid;let n=c[t];void 0===n&&(n={},c[t]=n);let r=n[e];void 0===r&&(r=l.clone(),n[e]=r),l=r}return l.visible=i.visible,l.wireframe=i.wireframe,l.side=3===o?null!==i.shadowSide?i.shadowSide:i.side:null!==i.shadowSide?i.shadowSide:u[i.side],l.clipShadows=i.clipShadows,l.clippingPlanes=i.clippingPlanes,l.clipIntersection=i.clipIntersection,l.wireframeLinewidth=i.wireframeLinewidth,l.linewidth=i.linewidth,!0===r.isPointLight&&!0===l.isMeshDistanceMaterial&&(l.referencePosition.setFromMatrixPosition(r.matrixWorld),l.nearDistance=s,l.farDistance=a),l}function M(n,r,s,a,o){if(!1===n.visible)return;if(n.layers.test(r.layers)&&(n.isMesh||n.isLine||n.isPoints)&&(n.castShadow||n.receiveShadow&&3===o)&&(!n.frustumCulled||i.intersectsObject(n))){n.modelViewMatrix.multiplyMatrices(s.matrixWorldInverse,n.matrixWorld);const i=e.update(n),r=n.material;if(Array.isArray(r)){const e=i.groups;for(let l=0,c=e.length;lh||r.y>h)&&(r.x>h&&(s.x=Math.floor(h/m.x),r.x=s.x*m.x,u.mapSize.x=s.x),r.y>h&&(s.y=Math.floor(h/m.y),r.y=s.y*m.y,u.mapSize.y=s.y)),null===u.map&&!u.isPointLightShadow&&3===this.type){const t={minFilter:g,magFilter:g,format:E};u.map=new Tt(r.x,r.y,t),u.map.texture.name=c.name+".shadowMap",u.mapPass=new Tt(r.x,r.y,t),u.camera.updateProjectionMatrix()}if(null===u.map){const t={minFilter:p,magFilter:p,format:E};u.map=new Tt(r.x,r.y,t),u.map.texture.name=c.name+".shadowMap",u.camera.updateProjectionMatrix()}t.setRenderTarget(u.map),t.clear();const f=u.getViewportCount();for(let t=0;t=1):-1!==R.indexOf("OpenGL ES")&&(L=parseFloat(/^OpenGL ES (\d)/.exec(R)[1]),A=L>=2);let C=null,P={};const D=new St(0,0,t.canvas.width,t.canvas.height),I=new St(0,0,t.canvas.width,t.canvas.height);function N(e,n,i){const r=new Uint8Array(4),s=t.createTexture();t.bindTexture(e,s),t.texParameteri(e,10241,9728),t.texParameteri(e,10240,9728);for(let e=0;ei||t.height>i)&&(r=i/Math.max(t.width,t.height)),r<1||!0===e){if("undefined"!=typeof HTMLImageElement&&t instanceof HTMLImageElement||"undefined"!=typeof HTMLCanvasElement&&t instanceof HTMLCanvasElement||"undefined"!=typeof ImageBitmap&&t instanceof ImageBitmap){const i=e?ft:Math.floor,s=i(r*t.width),a=i(r*t.height);void 0===P&&(P=I(s,a));const o=n?I(s,a):P;o.width=s,o.height=a;return o.getContext("2d").drawImage(t,0,0,s,a),console.warn("THREE.WebGLRenderer: Texture has been resized from ("+t.width+"x"+t.height+") to ("+s+"x"+a+")."),o}return"data"in t&&console.warn("THREE.WebGLRenderer: Image in DataTexture is too big ("+t.width+"x"+t.height+")."),t}return t}function B(t){return pt(t.width)&&pt(t.height)}function z(t,e){return t.generateMipmaps&&e&&t.minFilter!==p&&t.minFilter!==g}function F(e,n,r,s){t.generateMipmap(e);i.get(n).__maxMipLevel=Math.log2(Math.max(r,s))}function O(n,i,r){if(!1===o)return i;if(null!==n){if(void 0!==t[n])return t[n];console.warn("THREE.WebGLRenderer: Attempt to use non-existing WebGL internal format '"+n+"'")}let s=i;return 6403===i&&(5126===r&&(s=33326),5131===r&&(s=33325),5121===r&&(s=33321)),6407===i&&(5126===r&&(s=34837),5131===r&&(s=34843),5121===r&&(s=32849)),6408===i&&(5126===r&&(s=34836),5131===r&&(s=34842),5121===r&&(s=32856)),33325!==s&&33326!==s&&34842!==s&&34836!==s||e.get("EXT_color_buffer_float"),s}function H(t){return t===p||t===m||t===f?9728:9729}function G(e){const n=e.target;n.removeEventListener("dispose",G),function(e){const n=i.get(e);if(void 0===n.__webglInit)return;t.deleteTexture(n.__webglTexture),i.remove(e)}(n),n.isVideoTexture&&C.delete(n),a.memory.textures--}function U(e){const n=e.target;n.removeEventListener("dispose",U),function(e){const n=e.texture,r=i.get(e),s=i.get(n);if(!e)return;void 0!==s.__webglTexture&&t.deleteTexture(s.__webglTexture);e.depthTexture&&e.depthTexture.dispose();if(e.isWebGLCubeRenderTarget)for(let e=0;e<6;e++)t.deleteFramebuffer(r.__webglFramebuffer[e]),r.__webglDepthbuffer&&t.deleteRenderbuffer(r.__webglDepthbuffer[e]);else t.deleteFramebuffer(r.__webglFramebuffer),r.__webglDepthbuffer&&t.deleteRenderbuffer(r.__webglDepthbuffer),r.__webglMultisampledFramebuffer&&t.deleteFramebuffer(r.__webglMultisampledFramebuffer),r.__webglColorRenderbuffer&&t.deleteRenderbuffer(r.__webglColorRenderbuffer),r.__webglDepthRenderbuffer&&t.deleteRenderbuffer(r.__webglDepthRenderbuffer);i.remove(n),i.remove(e)}(n),a.memory.textures--}let k=0;function V(t,e){const r=i.get(t);if(t.isVideoTexture&&function(t){const e=a.render.frame;C.get(t)!==e&&(C.set(t,e),t.update())}(t),t.version>0&&r.__version!==t.version){const n=t.image;if(void 0===n)console.warn("THREE.WebGLRenderer: Texture marked for update but image is undefined");else{if(!1!==n.complete)return void Z(r,t,e);console.warn("THREE.WebGLRenderer: Texture marked for update but image is incomplete")}}n.activeTexture(33984+e),n.bindTexture(3553,r.__webglTexture)}function W(e,r){const a=i.get(e);e.version>0&&a.__version!==e.version?function(e,i,r){if(6!==i.image.length)return;Y(e,i),n.activeTexture(33984+r),n.bindTexture(34067,e.__webglTexture),t.pixelStorei(37440,i.flipY),t.pixelStorei(37441,i.premultiplyAlpha),t.pixelStorei(3317,i.unpackAlignment),t.pixelStorei(37443,0);const a=i&&(i.isCompressedTexture||i.image[0].isCompressedTexture),l=i.image[0]&&i.image[0].isDataTexture,h=[];for(let t=0;t<6;t++)h[t]=a||l?l?i.image[t].image:i.image[t]:N(i.image[t],!1,!0,c);const u=h[0],d=B(u)||o,p=s.convert(i.format),m=s.convert(i.type),f=O(i.internalFormat,p,m);let g;if(X(34067,i,d),a){for(let t=0;t<6;t++){g=h[t].mipmaps;for(let e=0;e1||i.get(s).__currentAnisotropy)&&(t.texParameterf(n,a.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(s.anisotropy,r.getMaxAnisotropy())),i.get(s).__currentAnisotropy=s.anisotropy)}}function Y(e,n){void 0===e.__webglInit&&(e.__webglInit=!0,n.addEventListener("dispose",G),e.__webglTexture=t.createTexture(),a.memory.textures++)}function Z(e,i,r){let a=3553;i.isDataTexture2DArray&&(a=35866),i.isDataTexture3D&&(a=32879),Y(e,i),n.activeTexture(33984+r),n.bindTexture(a,e.__webglTexture),t.pixelStorei(37440,i.flipY),t.pixelStorei(37441,i.premultiplyAlpha),t.pixelStorei(3317,i.unpackAlignment),t.pixelStorei(37443,0);const l=function(t){return!o&&(t.wrapS!==u||t.wrapT!==u||t.minFilter!==p&&t.minFilter!==g)}(i)&&!1===B(i.image),c=N(i.image,l,!1,x),h=B(c)||o,d=s.convert(i.format);let m,f=s.convert(i.type),v=O(i.internalFormat,d,f);X(a,i,h);const y=i.mipmaps;if(i.isDepthTexture)v=6402,o?v=i.type===b?36012:i.type===w?33190:i.type===S?35056:33189:i.type===b&&console.error("WebGLRenderer: Floating point depth texture requires WebGL2."),i.format===A&&6402===v&&i.type!==_&&i.type!==w&&(console.warn("THREE.WebGLRenderer: Use UnsignedShortType or UnsignedIntType for DepthFormat DepthTexture."),i.type=_,f=s.convert(i.type)),i.format===L&&6402===v&&(v=34041,i.type!==S&&(console.warn("THREE.WebGLRenderer: Use UnsignedInt248Type for DepthStencilFormat DepthTexture."),i.type=S,f=s.convert(i.type))),n.texImage2D(3553,0,v,c.width,c.height,0,d,f,null);else if(i.isDataTexture)if(y.length>0&&h){for(let t=0,e=y.length;t0&&h){for(let t=0,e=y.length;t=l&&console.warn("THREE.WebGLTextures: Trying to use "+t+" texture units while this GPU supports only "+l),k+=1,t},this.resetTextureUnits=function(){k=0},this.setTexture2D=V,this.setTexture2DArray=function(t,e){const r=i.get(t);t.version>0&&r.__version!==t.version?Z(r,t,e):(n.activeTexture(33984+e),n.bindTexture(35866,r.__webglTexture))},this.setTexture3D=function(t,e){const r=i.get(t);t.version>0&&r.__version!==t.version?Z(r,t,e):(n.activeTexture(33984+e),n.bindTexture(32879,r.__webglTexture))},this.setTextureCube=W,this.setupRenderTarget=function(e){const r=e.texture,l=i.get(e),c=i.get(r);e.addEventListener("dispose",U),c.__webglTexture=t.createTexture(),c.__version=r.version,a.memory.textures++;const h=!0===e.isWebGLCubeRenderTarget,u=!0===e.isWebGLMultisampleRenderTarget,d=r.isDataTexture3D||r.isDataTexture2DArray,p=B(e)||o;if(!o||r.format!==T||r.type!==b&&r.type!==M||(r.format=E,console.warn("THREE.WebGLRenderer: Rendering to textures with RGB format is not supported. Using RGBA format instead.")),h){l.__webglFramebuffer=[];for(let e=0;e<6;e++)l.__webglFramebuffer[e]=t.createFramebuffer()}else if(l.__webglFramebuffer=t.createFramebuffer(),u)if(o){l.__webglMultisampledFramebuffer=t.createFramebuffer(),l.__webglColorRenderbuffer=t.createRenderbuffer(),t.bindRenderbuffer(36161,l.__webglColorRenderbuffer);const i=s.convert(r.format),a=s.convert(r.type),o=O(r.internalFormat,i,a),c=$(e);t.renderbufferStorageMultisample(36161,c,o,e.width,e.height),n.bindFramebuffer(36160,l.__webglMultisampledFramebuffer),t.framebufferRenderbuffer(36160,36064,36161,l.__webglColorRenderbuffer),t.bindRenderbuffer(36161,null),e.depthBuffer&&(l.__webglDepthRenderbuffer=t.createRenderbuffer(),Q(l.__webglDepthRenderbuffer,e,!0)),n.bindFramebuffer(36160,null)}else console.warn("THREE.WebGLRenderer: WebGLMultisampleRenderTarget can only be used with WebGL2.");if(h){n.bindTexture(34067,c.__webglTexture),X(34067,r,p);for(let t=0;t<6;t++)J(l.__webglFramebuffer[t],e,36064,34069+t);z(r,p)&&F(34067,r,e.width,e.height),n.bindTexture(34067,null)}else{let t=3553;if(d)if(o){t=r.isDataTexture3D?32879:35866}else console.warn("THREE.DataTexture3D and THREE.DataTexture2DArray only supported with WebGL2.");n.bindTexture(t,c.__webglTexture),X(t,r,p),J(l.__webglFramebuffer,e,36064,t),z(r,p)&&F(3553,r,e.width,e.height),n.bindTexture(3553,null)}e.depthBuffer&&K(e)},this.updateRenderTargetMipmap=function(t){const e=t.texture;if(z(e,B(t)||o)){const r=t.isWebGLCubeRenderTarget?34067:3553,s=i.get(e).__webglTexture;n.bindTexture(r,s),F(r,e,t.width,t.height),n.bindTexture(r,null)}},this.updateMultisampleRenderTarget=function(e){if(e.isWebGLMultisampleRenderTarget)if(o){const r=e.width,s=e.height;let a=16384;e.depthBuffer&&(a|=256),e.stencilBuffer&&(a|=1024);const o=i.get(e);n.bindFramebuffer(36008,o.__webglMultisampledFramebuffer),n.bindFramebuffer(36009,o.__webglFramebuffer),t.blitFramebuffer(0,0,r,s,0,0,r,s,a,9728),n.bindFramebuffer(36008,null),n.bindFramebuffer(36009,o.__webglMultisampledFramebuffer)}else console.warn("THREE.WebGLRenderer: WebGLMultisampleRenderTarget can only be used with WebGL2.")},this.safeSetTexture2D=function(t,e){t&&t.isWebGLRenderTarget&&(!1===tt&&(console.warn("THREE.WebGLTextures.safeSetTexture2D: don't use render targets as textures. Use their .texture property instead."),tt=!0),t=t.texture),V(t,e)},this.safeSetTextureCube=function(t,e){t&&t.isWebGLCubeRenderTarget&&(!1===et&&(console.warn("THREE.WebGLTextures.safeSetTextureCube: don't use cube render targets as textures. Use their .texture property instead."),et=!0),t=t.texture),W(t,e)}}function ms(t,e,n){const i=n.isWebGL2;return{convert:function(t){let n;if(t===x)return 5121;if(1017===t)return 32819;if(1018===t)return 32820;if(1019===t)return 33635;if(1010===t)return 5120;if(1011===t)return 5122;if(t===_)return 5123;if(1013===t)return 5124;if(t===w)return 5125;if(t===b)return 5126;if(t===M)return i?5131:(n=e.get("OES_texture_half_float"),null!==n?n.HALF_FLOAT_OES:null);if(1021===t)return 6406;if(t===T)return 6407;if(t===E)return 6408;if(1024===t)return 6409;if(1025===t)return 6410;if(t===A)return 6402;if(t===L)return 34041;if(1028===t)return 6403;if(1029===t)return 36244;if(1030===t)return 33319;if(1031===t)return 33320;if(1032===t)return 36248;if(1033===t)return 36249;if(t===R||t===C||t===P||t===D){if(n=e.get("WEBGL_compressed_texture_s3tc"),null===n)return null;if(t===R)return n.COMPRESSED_RGB_S3TC_DXT1_EXT;if(t===C)return n.COMPRESSED_RGBA_S3TC_DXT1_EXT;if(t===P)return n.COMPRESSED_RGBA_S3TC_DXT3_EXT;if(t===D)return n.COMPRESSED_RGBA_S3TC_DXT5_EXT}if(t===I||t===N||t===B||t===z){if(n=e.get("WEBGL_compressed_texture_pvrtc"),null===n)return null;if(t===I)return n.COMPRESSED_RGB_PVRTC_4BPPV1_IMG;if(t===N)return n.COMPRESSED_RGB_PVRTC_2BPPV1_IMG;if(t===B)return n.COMPRESSED_RGBA_PVRTC_4BPPV1_IMG;if(t===z)return n.COMPRESSED_RGBA_PVRTC_2BPPV1_IMG}if(36196===t)return n=e.get("WEBGL_compressed_texture_etc1"),null!==n?n.COMPRESSED_RGB_ETC1_WEBGL:null;if((t===F||t===O)&&(n=e.get("WEBGL_compressed_texture_etc"),null!==n)){if(t===F)return n.COMPRESSED_RGB8_ETC2;if(t===O)return n.COMPRESSED_RGBA8_ETC2_EAC}return 37808===t||37809===t||37810===t||37811===t||37812===t||37813===t||37814===t||37815===t||37816===t||37817===t||37818===t||37819===t||37820===t||37821===t||37840===t||37841===t||37842===t||37843===t||37844===t||37845===t||37846===t||37847===t||37848===t||37849===t||37850===t||37851===t||37852===t||37853===t?(n=e.get("WEBGL_compressed_texture_astc"),null!==n?t:null):36492===t?(n=e.get("EXT_texture_compression_bptc"),null!==n?t:null):t===S?i?34042:(n=e.get("WEBGL_depth_texture"),null!==n?n.UNSIGNED_INT_24_8_WEBGL:null):void 0}}}class fs extends Kn{constructor(t=[]){super(),this.cameras=t}}fs.prototype.isArrayCamera=!0;class gs extends Ce{constructor(){super(),this.type="Group"}}gs.prototype.isGroup=!0;const vs={type:"move"};class ys{constructor(){this._targetRay=null,this._grip=null,this._hand=null}getHandSpace(){return null===this._hand&&(this._hand=new gs,this._hand.matrixAutoUpdate=!1,this._hand.visible=!1,this._hand.joints={},this._hand.inputState={pinching:!1}),this._hand}getTargetRaySpace(){return null===this._targetRay&&(this._targetRay=new gs,this._targetRay.matrixAutoUpdate=!1,this._targetRay.visible=!1,this._targetRay.hasLinearVelocity=!1,this._targetRay.linearVelocity=new Lt,this._targetRay.hasAngularVelocity=!1,this._targetRay.angularVelocity=new Lt),this._targetRay}getGripSpace(){return null===this._grip&&(this._grip=new gs,this._grip.matrixAutoUpdate=!1,this._grip.visible=!1,this._grip.hasLinearVelocity=!1,this._grip.linearVelocity=new Lt,this._grip.hasAngularVelocity=!1,this._grip.angularVelocity=new Lt),this._grip}dispatchEvent(t){return null!==this._targetRay&&this._targetRay.dispatchEvent(t),null!==this._grip&&this._grip.dispatchEvent(t),null!==this._hand&&this._hand.dispatchEvent(t),this}disconnect(t){return this.dispatchEvent({type:"disconnected",data:t}),null!==this._targetRay&&(this._targetRay.visible=!1),null!==this._grip&&(this._grip.visible=!1),null!==this._hand&&(this._hand.visible=!1),this}update(t,e,n){let i=null,r=null,s=null;const a=this._targetRay,o=this._grip,l=this._hand;if(t&&"visible-blurred"!==e.session.visibilityState)if(null!==a&&(i=e.getPose(t.targetRaySpace,n),null!==i&&(a.matrix.fromArray(i.transform.matrix),a.matrix.decompose(a.position,a.rotation,a.scale),i.linearVelocity?(a.hasLinearVelocity=!0,a.linearVelocity.copy(i.linearVelocity)):a.hasLinearVelocity=!1,i.angularVelocity?(a.hasAngularVelocity=!0,a.angularVelocity.copy(i.angularVelocity)):a.hasAngularVelocity=!1,this.dispatchEvent(vs))),l&&t.hand){s=!0;for(const i of t.hand.values()){const t=e.getJointPose(i,n);if(void 0===l.joints[i.jointName]){const t=new gs;t.matrixAutoUpdate=!1,t.visible=!1,l.joints[i.jointName]=t,l.add(t)}const r=l.joints[i.jointName];null!==t&&(r.matrix.fromArray(t.transform.matrix),r.matrix.decompose(r.position,r.rotation,r.scale),r.jointRadius=t.radius),r.visible=null!==t}const i=l.joints["index-finger-tip"],r=l.joints["thumb-tip"],a=i.position.distanceTo(r.position),o=.02,c=.005;l.inputState.pinching&&a>o+c?(l.inputState.pinching=!1,this.dispatchEvent({type:"pinchend",handedness:t.handedness,target:this})):!l.inputState.pinching&&a<=o-c&&(l.inputState.pinching=!0,this.dispatchEvent({type:"pinchstart",handedness:t.handedness,target:this}))}else null!==o&&t.gripSpace&&(r=e.getPose(t.gripSpace,n),null!==r&&(o.matrix.fromArray(r.transform.matrix),o.matrix.decompose(o.position,o.rotation,o.scale),r.linearVelocity?(o.hasLinearVelocity=!0,o.linearVelocity.copy(r.linearVelocity)):o.hasLinearVelocity=!1,r.angularVelocity?(o.hasAngularVelocity=!0,o.angularVelocity.copy(r.angularVelocity)):o.hasAngularVelocity=!1));return null!==a&&(a.visible=null!==i),null!==o&&(o.visible=null!==r),null!==l&&(l.visible=null!==s),this}}class xs extends rt{constructor(t,e){super();const n=this,i=t.state;let r=null,s=1,a=null,o="local-floor",l=null;const c=[],h=new Map,u=new Kn;u.layers.enable(1),u.viewport=new St;const d=new Kn;d.layers.enable(2),d.viewport=new St;const p=[u,d],m=new fs;m.layers.enable(1),m.layers.enable(2);let f=null,g=null;function v(t){const e=h.get(t.inputSource);e&&e.dispatchEvent({type:t.type,data:t.inputSource})}function y(){h.forEach((function(t,e){t.disconnect(e)})),h.clear(),f=null,g=null,i.bindXRFramebuffer(null),t.setRenderTarget(t.getRenderTarget()),S.stop(),n.isPresenting=!1,n.dispatchEvent({type:"sessionend"})}function x(t){const e=r.inputSources;for(let t=0;t0&&Rt(s,t,e),a.length>0&&Rt(a,t,e),null!==_&&(J.updateRenderTargetMipmap(_),J.updateMultisampleRenderTarget(_)),!0===t.isScene&&t.onAfterRender(f,t,e),q.buffers.depth.setTest(!0),q.buffers.depth.setMask(!0),q.buffers.color.setMask(!0),q.setPolygonOffset(!1),dt.resetDefaultState(),w=-1,S=null,m.pop(),d=m.length>0?m[m.length-1]:null,p.pop(),u=p.length>0?p[p.length-1]:null},this.getActiveCubeFace=function(){return v},this.getActiveMipmapLevel=function(){return y},this.getRenderTarget=function(){return _},this.setRenderTarget=function(t,e=0,n=0){_=t,v=e,y=n,t&&void 0===Z.get(t).__webglFramebuffer&&J.setupRenderTarget(t);let i=null,r=!1,s=!1;if(t){const n=t.texture;(n.isDataTexture3D||n.isDataTexture2DArray)&&(s=!0);const a=Z.get(t).__webglFramebuffer;t.isWebGLCubeRenderTarget?(i=a[e],r=!0):i=t.isWebGLMultisampleRenderTarget?Z.get(t).__webglMultisampledFramebuffer:a,T.copy(t.viewport),A.copy(t.scissor),L=t.scissorTest}else T.copy(N).multiplyScalar(P).floor(),A.copy(B).multiplyScalar(P).floor(),L=z;if(q.bindFramebuffer(36160,i),q.viewport(T),q.scissor(A),q.setScissorTest(L),r){const i=Z.get(t.texture);pt.framebufferTexture2D(36160,36064,34069+e,i.__webglTexture,n)}else if(s){const i=Z.get(t.texture),r=e||0;pt.framebufferTextureLayer(36160,36064,i.__webglTexture,n||0,r)}},this.readRenderTargetPixels=function(t,e,n,i,r,s,a){if(!t||!t.isWebGLRenderTarget)return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.");let o=Z.get(t).__webglFramebuffer;if(t.isWebGLCubeRenderTarget&&void 0!==a&&(o=o[a]),o){q.bindFramebuffer(36160,o);try{const a=t.texture,o=a.format,l=a.type;if(o!==E&&ut.convert(o)!==pt.getParameter(35739))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.");const c=l===M&&(W.has("EXT_color_buffer_half_float")||j.isWebGL2&&W.has("EXT_color_buffer_float"));if(!(l===x||ut.convert(l)===pt.getParameter(35738)||l===b&&(j.isWebGL2||W.has("OES_texture_float")||W.has("WEBGL_color_buffer_float"))||c))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.");36053===pt.checkFramebufferStatus(36160)?e>=0&&e<=t.width-i&&n>=0&&n<=t.height-r&&pt.readPixels(e,n,i,r,ut.convert(o),ut.convert(l),s):console.error("THREE.WebGLRenderer.readRenderTargetPixels: readPixels from renderTarget failed. Framebuffer not complete.")}finally{const t=null!==_?Z.get(_).__webglFramebuffer:null;q.bindFramebuffer(36160,t)}}},this.copyFramebufferToTexture=function(t,e,n=0){const i=Math.pow(2,-n),r=Math.floor(e.image.width*i),s=Math.floor(e.image.height*i),a=ut.convert(e.format);J.setTexture2D(e,0),pt.copyTexImage2D(3553,n,a,t.x,t.y,r,s,0),q.unbindTexture()},this.copyTextureToTexture=function(t,e,n,i=0){const r=e.image.width,s=e.image.height,a=ut.convert(n.format),o=ut.convert(n.type);J.setTexture2D(n,0),pt.pixelStorei(37440,n.flipY),pt.pixelStorei(37441,n.premultiplyAlpha),pt.pixelStorei(3317,n.unpackAlignment),e.isDataTexture?pt.texSubImage2D(3553,i,t.x,t.y,r,s,a,o,e.image.data):e.isCompressedTexture?pt.compressedTexSubImage2D(3553,i,t.x,t.y,e.mipmaps[0].width,e.mipmaps[0].height,a,e.mipmaps[0].data):pt.texSubImage2D(3553,i,t.x,t.y,a,o,e.image),0===i&&n.generateMipmaps&&pt.generateMipmap(3553),q.unbindTexture()},this.copyTextureToTexture3D=function(t,e,n,i,r=0){if(f.isWebGL1Renderer)return void console.warn("THREE.WebGLRenderer.copyTextureToTexture3D: can only be used with WebGL2.");const{width:s,height:a,data:o}=n.image,l=ut.convert(i.format),c=ut.convert(i.type);let h;if(i.isDataTexture3D)J.setTexture3D(i,0),h=32879;else{if(!i.isDataTexture2DArray)return void console.warn("THREE.WebGLRenderer.copyTextureToTexture3D: only supports THREE.DataTexture3D and THREE.DataTexture2DArray.");J.setTexture2DArray(i,0),h=35866}pt.pixelStorei(37440,i.flipY),pt.pixelStorei(37441,i.premultiplyAlpha),pt.pixelStorei(3317,i.unpackAlignment);const u=pt.getParameter(3314),d=pt.getParameter(32878),p=pt.getParameter(3316),m=pt.getParameter(3315),g=pt.getParameter(32877);pt.pixelStorei(3314,s),pt.pixelStorei(32878,a),pt.pixelStorei(3316,t.min.x),pt.pixelStorei(3315,t.min.y),pt.pixelStorei(32877,t.min.z),pt.texSubImage3D(h,r,e.x,e.y,e.z,t.max.x-t.min.x+1,t.max.y-t.min.y+1,t.max.z-t.min.z+1,l,c,o),pt.pixelStorei(3314,u),pt.pixelStorei(32878,d),pt.pixelStorei(3316,p),pt.pixelStorei(3315,m),pt.pixelStorei(32877,g),0===r&&i.generateMipmaps&&pt.generateMipmap(h),q.unbindTexture()},this.initTexture=function(t){J.setTexture2D(t,0),q.unbindTexture()},this.resetState=function(){v=0,y=0,_=null,q.reset(),dt.reset()},"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("observe",{detail:this}))}class bs extends ws{}bs.prototype.isWebGL1Renderer=!0;class Ms{constructor(t,e=25e-5){this.name="",this.color=new tn(t),this.density=e}clone(){return new Ms(this.color,this.density)}toJSON(){return{type:"FogExp2",color:this.color.getHex(),density:this.density}}}Ms.prototype.isFogExp2=!0;class Ss{constructor(t,e=1,n=1e3){this.name="",this.color=new tn(t),this.near=e,this.far=n}clone(){return new Ss(this.color,this.near,this.far)}toJSON(){return{type:"Fog",color:this.color.getHex(),near:this.near,far:this.far}}}Ss.prototype.isFog=!0;class Ts extends Ce{constructor(){super(),this.type="Scene",this.background=null,this.environment=null,this.fog=null,this.overrideMaterial=null,this.autoUpdate=!0,"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("observe",{detail:this}))}copy(t,e){return super.copy(t,e),null!==t.background&&(this.background=t.background.clone()),null!==t.environment&&(this.environment=t.environment.clone()),null!==t.fog&&(this.fog=t.fog.clone()),null!==t.overrideMaterial&&(this.overrideMaterial=t.overrideMaterial.clone()),this.autoUpdate=t.autoUpdate,this.matrixAutoUpdate=t.matrixAutoUpdate,this}toJSON(t){const e=super.toJSON(t);return null!==this.background&&(e.object.background=this.background.toJSON(t)),null!==this.environment&&(e.object.environment=this.environment.toJSON(t)),null!==this.fog&&(e.object.fog=this.fog.toJSON()),e}}Ts.prototype.isScene=!0;class Es{constructor(t,e){this.array=t,this.stride=e,this.count=void 0!==t?t.length/e:0,this.usage=et,this.updateRange={offset:0,count:-1},this.version=0,this.uuid=ct(),this.onUploadCallback=function(){}}set needsUpdate(t){!0===t&&this.version++}setUsage(t){return this.usage=t,this}copy(t){return this.array=new t.array.constructor(t.array),this.count=t.count,this.stride=t.stride,this.usage=t.usage,this}copyAt(t,e,n){t*=this.stride,n*=e.stride;for(let i=0,r=this.stride;it.far||e.push({distance:o,point:Ps.clone(),uv:je.getUV(Ps,Fs,Os,Hs,Gs,Us,ks,new vt),face:null,object:this})}copy(t){return super.copy(t),void 0!==t.center&&this.center.copy(t.center),this.material=t.material,this}}function Ws(t,e,n,i,r,s){Ns.subVectors(t,n).addScalar(.5).multiply(i),void 0!==r?(Bs.x=s*Ns.x-r*Ns.y,Bs.y=r*Ns.x+s*Ns.y):Bs.copy(Ns),t.copy(e),t.x+=Bs.x,t.y+=Bs.y,t.applyMatrix4(zs)}Vs.prototype.isSprite=!0;const js=new Lt,qs=new Lt;class Xs extends Ce{constructor(){super(),this._currentLevel=0,this.type="LOD",Object.defineProperties(this,{levels:{enumerable:!0,value:[]},isLOD:{value:!0}}),this.autoUpdate=!0}copy(t){super.copy(t,!1);const e=t.levels;for(let t=0,n=e.length;t0){let n,i;for(n=1,i=e.length;n0){js.setFromMatrixPosition(this.matrixWorld);const n=t.ray.origin.distanceTo(js);this.getObjectForDistance(n).raycast(t,e)}}update(t){const e=this.levels;if(e.length>1){js.setFromMatrixPosition(t.matrixWorld),qs.setFromMatrixPosition(this.matrixWorld);const n=js.distanceTo(qs)/t.zoom;let i,r;for(e[0].object.visible=!0,i=1,r=e.length;i=e[i].distance;i++)e[i-1].object.visible=!1,e[i].object.visible=!0;for(this._currentLevel=i-1;io)continue;u.applyMatrix4(this.matrixWorld);const d=t.ray.origin.distanceTo(u);dt.far||e.push({distance:d,point:h.clone().applyMatrix4(this.matrixWorld),index:n,face:null,faceIndex:null,object:this})}}else{for(let n=Math.max(0,s.start),i=Math.min(r.count,s.start+s.count)-1;no)continue;u.applyMatrix4(this.matrixWorld);const i=t.ray.origin.distanceTo(u);it.far||e.push({distance:i,point:h.clone().applyMatrix4(this.matrixWorld),index:n,face:null,faceIndex:null,object:this})}}}else n.isGeometry&&console.error("THREE.Line.raycast() no longer supports THREE.Geometry. Use THREE.BufferGeometry instead.")}updateMorphTargets(){const t=this.geometry;if(t.isBufferGeometry){const e=t.morphAttributes,n=Object.keys(e);if(n.length>0){const t=e[n[0]];if(void 0!==t){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let e=0,n=t.length;e0&&console.error("THREE.Line.updateMorphTargets() does not support THREE.Geometry. Use THREE.BufferGeometry instead.")}}}fa.prototype.isLine=!0;const ga=new Lt,va=new Lt;class ya extends fa{constructor(t,e){super(t,e),this.type="LineSegments"}computeLineDistances(){const t=this.geometry;if(t.isBufferGeometry)if(null===t.index){const e=t.attributes.position,n=[];for(let t=0,i=e.count;t0){const t=e[n[0]];if(void 0!==t){this.morphTargetInfluences=[],this.morphTargetDictionary={};for(let e=0,n=t.length;e0&&console.error("THREE.Points.updateMorphTargets() does not support THREE.Geometry. Use THREE.BufferGeometry instead.")}}}function Ea(t,e,n,i,r,s,a){const o=ba.distanceSqToPoint(t);if(or.far)return;s.push({distance:l,distanceToRay:Math.sqrt(o),point:n,index:e,face:null,object:a})}}Ta.prototype.isPoints=!0;class Aa extends bt{constructor(t,e,n,i,r,s,a,o,l){super(t,e,n,i,r,s,a,o,l),this.format=void 0!==a?a:T,this.minFilter=void 0!==s?s:g,this.magFilter=void 0!==r?r:g,this.generateMipmaps=!1;const c=this;"requestVideoFrameCallback"in t&&t.requestVideoFrameCallback((function e(){c.needsUpdate=!0,t.requestVideoFrameCallback(e)}))}clone(){return new this.constructor(this.image).copy(this)}update(){const t=this.image;!1==="requestVideoFrameCallback"in t&&t.readyState>=t.HAVE_CURRENT_DATA&&(this.needsUpdate=!0)}}Aa.prototype.isVideoTexture=!0;class La extends bt{constructor(t,e,n,i,r,s,a,o,l,c,h,u){super(null,s,a,o,l,c,i,r,h,u),this.image={width:e,height:n},this.mipmaps=t,this.flipY=!1,this.generateMipmaps=!1}}La.prototype.isCompressedTexture=!0;class Ra extends bt{constructor(t,e,n,i,r,s,a,o,l){super(t,e,n,i,r,s,a,o,l),this.needsUpdate=!0}}Ra.prototype.isCanvasTexture=!0;class Ca extends bt{constructor(t,e,n,i,r,s,a,o,l,c){if((c=void 0!==c?c:A)!==A&&c!==L)throw new Error("DepthTexture format must be either THREE.DepthFormat or THREE.DepthStencilFormat");void 0===n&&c===A&&(n=_),void 0===n&&c===L&&(n=S),super(null,i,r,s,a,o,c,n,l),this.image={width:t,height:e},this.magFilter=void 0!==a?a:p,this.minFilter=void 0!==o?o:p,this.flipY=!1,this.generateMipmaps=!1}}Ca.prototype.isDepthTexture=!0;class Pa extends En{constructor(t=1,e=8,n=0,i=2*Math.PI){super(),this.type="CircleGeometry",this.parameters={radius:t,segments:e,thetaStart:n,thetaLength:i},e=Math.max(3,e);const r=[],s=[],a=[],o=[],l=new Lt,c=new vt;s.push(0,0,0),a.push(0,0,1),o.push(.5,.5);for(let r=0,h=3;r<=e;r++,h+=3){const u=n+r/e*i;l.x=t*Math.cos(u),l.y=t*Math.sin(u),s.push(l.x,l.y,l.z),a.push(0,0,1),c.x=(s[h]/t+1)/2,c.y=(s[h+1]/t+1)/2,o.push(c.x,c.y)}for(let t=1;t<=e;t++)r.push(t,t+1,0);this.setIndex(r),this.setAttribute("position",new mn(s,3)),this.setAttribute("normal",new mn(a,3)),this.setAttribute("uv",new mn(o,2))}}class Da extends En{constructor(t=1,e=1,n=1,i=8,r=1,s=!1,a=0,o=2*Math.PI){super(),this.type="CylinderGeometry",this.parameters={radiusTop:t,radiusBottom:e,height:n,radialSegments:i,heightSegments:r,openEnded:s,thetaStart:a,thetaLength:o};const l=this;i=Math.floor(i),r=Math.floor(r);const c=[],h=[],u=[],d=[];let p=0;const m=[],f=n/2;let g=0;function v(n){const r=p,s=new vt,m=new Lt;let v=0;const y=!0===n?t:e,x=!0===n?1:-1;for(let t=1;t<=i;t++)h.push(0,f*x,0),u.push(0,x,0),d.push(.5,.5),p++;const _=p;for(let t=0;t<=i;t++){const e=t/i*o+a,n=Math.cos(e),r=Math.sin(e);m.x=y*r,m.y=f*x,m.z=y*n,h.push(m.x,m.y,m.z),u.push(0,x,0),s.x=.5*n+.5,s.y=.5*r*x+.5,d.push(s.x,s.y),p++}for(let t=0;t0&&v(!0),e>0&&v(!1)),this.setIndex(c),this.setAttribute("position",new mn(h,3)),this.setAttribute("normal",new mn(u,3)),this.setAttribute("uv",new mn(d,2))}}class Ia extends Da{constructor(t=1,e=1,n=8,i=1,r=!1,s=0,a=2*Math.PI){super(0,t,e,n,i,r,s,a),this.type="ConeGeometry",this.parameters={radius:t,height:e,radialSegments:n,heightSegments:i,openEnded:r,thetaStart:s,thetaLength:a}}}class Na extends En{constructor(t,e,n=1,i=0){super(),this.type="PolyhedronGeometry",this.parameters={vertices:t,indices:e,radius:n,detail:i};const r=[],s=[];function a(t,e,n,i){const r=i+1,s=[];for(let i=0;i<=r;i++){s[i]=[];const a=t.clone().lerp(n,i/r),o=e.clone().lerp(n,i/r),l=r-i;for(let t=0;t<=l;t++)s[i][t]=0===t&&i===r?a:a.clone().lerp(o,t/l)}for(let t=0;t.9&&a<.1&&(e<.2&&(s[t+0]+=1),n<.2&&(s[t+2]+=1),i<.2&&(s[t+4]+=1))}}()}(),this.setAttribute("position",new mn(r,3)),this.setAttribute("normal",new mn(r.slice(),3)),this.setAttribute("uv",new mn(s,2)),0===i?this.computeVertexNormals():this.normalizeNormals()}}class Ba extends Na{constructor(t=1,e=0){const n=(1+Math.sqrt(5))/2,i=1/n;super([-1,-1,-1,-1,-1,1,-1,1,-1,-1,1,1,1,-1,-1,1,-1,1,1,1,-1,1,1,1,0,-i,-n,0,-i,n,0,i,-n,0,i,n,-i,-n,0,-i,n,0,i,-n,0,i,n,0,-n,0,-i,n,0,-i,-n,0,i,n,0,i],[3,11,7,3,7,15,3,15,13,7,19,17,7,17,6,7,6,15,17,4,8,17,8,10,17,10,6,8,0,16,8,16,2,8,2,10,0,12,1,0,1,18,0,18,16,6,10,2,6,2,13,6,13,15,2,16,18,2,18,3,2,3,13,18,1,9,18,9,11,18,11,3,4,14,12,4,12,0,4,0,8,11,9,5,11,5,19,11,19,7,19,5,14,19,14,4,19,4,17,1,12,14,1,14,5,1,5,9],t,e),this.type="DodecahedronGeometry",this.parameters={radius:t,detail:e}}}const za=new Lt,Fa=new Lt,Oa=new Lt,Ha=new je;class Ga extends En{constructor(t,e){if(super(),this.type="EdgesGeometry",this.parameters={thresholdAngle:e},e=void 0!==e?e:1,!0===t.isGeometry)return void console.error("THREE.EdgesGeometry no longer supports THREE.Geometry. Use THREE.BufferGeometry instead.");const n=Math.pow(10,4),i=Math.cos(ot*e),r=t.getIndex(),s=t.getAttribute("position"),a=r?r.count:s.count,o=[0,0,0],l=["a","b","c"],c=new Array(3),h={},u=[];for(let t=0;t80*n){o=c=t[0],l=h=t[1];for(let e=n;ec&&(c=u),d>h&&(h=d);p=Math.max(c-o,h-l),p=0!==p?1/p:0}return Wa(s,a,n,o,l,p),a};function ka(t,e,n,i,r){let s,a;if(r===function(t,e,n,i){let r=0;for(let s=e,a=n-i;s0)for(s=e;s=e;s-=i)a=co(s,t[s],t[s+1],a);return a&&io(a,a.next)&&(ho(a),a=a.next),a}function Va(t,e){if(!t)return t;e||(e=t);let n,i=t;do{if(n=!1,i.steiner||!io(i,i.next)&&0!==no(i.prev,i,i.next))i=i.next;else{if(ho(i),i=e=i.prev,i===i.next)break;n=!0}}while(n||i!==e);return e}function Wa(t,e,n,i,r,s,a){if(!t)return;!a&&s&&function(t,e,n,i){let r=t;do{null===r.z&&(r.z=Ka(r.x,r.y,e,n,i)),r.prevZ=r.prev,r.nextZ=r.next,r=r.next}while(r!==t);r.prevZ.nextZ=null,r.prevZ=null,function(t){let e,n,i,r,s,a,o,l,c=1;do{for(n=t,t=null,s=null,a=0;n;){for(a++,i=n,o=0,e=0;e0||l>0&&i;)0!==o&&(0===l||!i||n.z<=i.z)?(r=n,n=n.nextZ,o--):(r=i,i=i.nextZ,l--),s?s.nextZ=r:t=r,r.prevZ=s,s=r;n=i}s.nextZ=null,c*=2}while(a>1)}(r)}(t,i,r,s);let o,l,c=t;for(;t.prev!==t.next;)if(o=t.prev,l=t.next,s?qa(t,i,r,s):ja(t))e.push(o.i/n),e.push(t.i/n),e.push(l.i/n),ho(t),t=l.next,c=l.next;else if((t=l)===c){a?1===a?Wa(t=Xa(Va(t),e,n),e,n,i,r,s,2):2===a&&Ya(t,e,n,i,r,s):Wa(Va(t),e,n,i,r,s,1);break}}function ja(t){const e=t.prev,n=t,i=t.next;if(no(e,n,i)>=0)return!1;let r=t.next.next;for(;r!==t.prev;){if(to(e.x,e.y,n.x,n.y,i.x,i.y,r.x,r.y)&&no(r.prev,r,r.next)>=0)return!1;r=r.next}return!0}function qa(t,e,n,i){const r=t.prev,s=t,a=t.next;if(no(r,s,a)>=0)return!1;const o=r.xs.x?r.x>a.x?r.x:a.x:s.x>a.x?s.x:a.x,h=r.y>s.y?r.y>a.y?r.y:a.y:s.y>a.y?s.y:a.y,u=Ka(o,l,e,n,i),d=Ka(c,h,e,n,i);let p=t.prevZ,m=t.nextZ;for(;p&&p.z>=u&&m&&m.z<=d;){if(p!==t.prev&&p!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,p.x,p.y)&&no(p.prev,p,p.next)>=0)return!1;if(p=p.prevZ,m!==t.prev&&m!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,m.x,m.y)&&no(m.prev,m,m.next)>=0)return!1;m=m.nextZ}for(;p&&p.z>=u;){if(p!==t.prev&&p!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,p.x,p.y)&&no(p.prev,p,p.next)>=0)return!1;p=p.prevZ}for(;m&&m.z<=d;){if(m!==t.prev&&m!==t.next&&to(r.x,r.y,s.x,s.y,a.x,a.y,m.x,m.y)&&no(m.prev,m,m.next)>=0)return!1;m=m.nextZ}return!0}function Xa(t,e,n){let i=t;do{const r=i.prev,s=i.next.next;!io(r,s)&&ro(r,i,i.next,s)&&oo(r,s)&&oo(s,r)&&(e.push(r.i/n),e.push(i.i/n),e.push(s.i/n),ho(i),ho(i.next),i=t=s),i=i.next}while(i!==t);return Va(i)}function Ya(t,e,n,i,r,s){let a=t;do{let t=a.next.next;for(;t!==a.prev;){if(a.i!==t.i&&eo(a,t)){let o=lo(a,t);return a=Va(a,a.next),o=Va(o,o.next),Wa(a,e,n,i,r,s),void Wa(o,e,n,i,r,s)}t=t.next}a=a.next}while(a!==t)}function Za(t,e){return t.x-e.x}function Ja(t,e){if(e=function(t,e){let n=e;const i=t.x,r=t.y;let s,a=-1/0;do{if(r<=n.y&&r>=n.next.y&&n.next.y!==n.y){const t=n.x+(r-n.y)*(n.next.x-n.x)/(n.next.y-n.y);if(t<=i&&t>a){if(a=t,t===i){if(r===n.y)return n;if(r===n.next.y)return n.next}s=n.x=n.x&&n.x>=l&&i!==n.x&&to(rs.x||n.x===s.x&&Qa(s,n)))&&(s=n,u=h)),n=n.next}while(n!==o);return s}(t,e)){const n=lo(e,t);Va(e,e.next),Va(n,n.next)}}function Qa(t,e){return no(t.prev,t,e.prev)<0&&no(e.next,t,t.next)<0}function Ka(t,e,n,i,r){return(t=1431655765&((t=858993459&((t=252645135&((t=16711935&((t=32767*(t-n)*r)|t<<8))|t<<4))|t<<2))|t<<1))|(e=1431655765&((e=858993459&((e=252645135&((e=16711935&((e=32767*(e-i)*r)|e<<8))|e<<4))|e<<2))|e<<1))<<1}function $a(t){let e=t,n=t;do{(e.x=0&&(t-a)*(i-o)-(n-a)*(e-o)>=0&&(n-a)*(s-o)-(r-a)*(i-o)>=0}function eo(t,e){return t.next.i!==e.i&&t.prev.i!==e.i&&!function(t,e){let n=t;do{if(n.i!==t.i&&n.next.i!==t.i&&n.i!==e.i&&n.next.i!==e.i&&ro(n,n.next,t,e))return!0;n=n.next}while(n!==t);return!1}(t,e)&&(oo(t,e)&&oo(e,t)&&function(t,e){let n=t,i=!1;const r=(t.x+e.x)/2,s=(t.y+e.y)/2;do{n.y>s!=n.next.y>s&&n.next.y!==n.y&&r<(n.next.x-n.x)*(s-n.y)/(n.next.y-n.y)+n.x&&(i=!i),n=n.next}while(n!==t);return i}(t,e)&&(no(t.prev,t,e.prev)||no(t,e.prev,e))||io(t,e)&&no(t.prev,t,t.next)>0&&no(e.prev,e,e.next)>0)}function no(t,e,n){return(e.y-t.y)*(n.x-e.x)-(e.x-t.x)*(n.y-e.y)}function io(t,e){return t.x===e.x&&t.y===e.y}function ro(t,e,n,i){const r=ao(no(t,e,n)),s=ao(no(t,e,i)),a=ao(no(n,i,t)),o=ao(no(n,i,e));return r!==s&&a!==o||(!(0!==r||!so(t,n,e))||(!(0!==s||!so(t,i,e))||(!(0!==a||!so(n,t,i))||!(0!==o||!so(n,e,i)))))}function so(t,e,n){return e.x<=Math.max(t.x,n.x)&&e.x>=Math.min(t.x,n.x)&&e.y<=Math.max(t.y,n.y)&&e.y>=Math.min(t.y,n.y)}function ao(t){return t>0?1:t<0?-1:0}function oo(t,e){return no(t.prev,t,t.next)<0?no(t,e,t.next)>=0&&no(t,t.prev,e)>=0:no(t,e,t.prev)<0||no(t,t.next,e)<0}function lo(t,e){const n=new uo(t.i,t.x,t.y),i=new uo(e.i,e.x,e.y),r=t.next,s=e.prev;return t.next=e,e.prev=t,n.next=r,r.prev=n,i.next=n,n.prev=i,s.next=i,i.prev=s,i}function co(t,e,n,i){const r=new uo(t,e,n);return i?(r.next=i.next,r.prev=i,i.next.prev=r,i.next=r):(r.prev=r,r.next=r),r}function ho(t){t.next.prev=t.prev,t.prev.next=t.next,t.prevZ&&(t.prevZ.nextZ=t.nextZ),t.nextZ&&(t.nextZ.prevZ=t.prevZ)}function uo(t,e,n){this.i=t,this.x=e,this.y=n,this.prev=null,this.next=null,this.z=null,this.prevZ=null,this.nextZ=null,this.steiner=!1}class po{static area(t){const e=t.length;let n=0;for(let i=e-1,r=0;r2&&t[e-1].equals(t[0])&&t.pop()}function fo(t,e){for(let n=0;nNumber.EPSILON){const u=Math.sqrt(h),d=Math.sqrt(l*l+c*c),p=e.x-o/u,m=e.y+a/u,f=((n.x-c/d-p)*c-(n.y+l/d-m)*l)/(a*c-o*l);i=p+a*f-t.x,r=m+o*f-t.y;const g=i*i+r*r;if(g<=2)return new vt(i,r);s=Math.sqrt(g/2)}else{let t=!1;a>Number.EPSILON?l>Number.EPSILON&&(t=!0):a<-Number.EPSILON?l<-Number.EPSILON&&(t=!0):Math.sign(o)===Math.sign(c)&&(t=!0),t?(i=-o,r=a,s=Math.sqrt(h)):(i=a,r=o,s=Math.sqrt(h/2))}return new vt(i/s,r/s)}const P=[];for(let t=0,e=E.length,n=e-1,i=t+1;t=0;t--){const e=t/p,n=h*Math.cos(e*Math.PI/2),i=u*Math.sin(e*Math.PI/2)+d;for(let t=0,e=E.length;t=0;){const i=n;let r=n-1;r<0&&(r=t.length-1);for(let t=0,n=o+2*p;t=0?(t(i-o,p,h),u.subVectors(c,h)):(t(i+o,p,h),u.subVectors(h,c)),p-o>=0?(t(i,p-o,h),d.subVectors(c,h)):(t(i,p+o,h),d.subVectors(h,c)),l.crossVectors(u,d).normalize(),s.push(l.x,l.y,l.z),a.push(i,p)}}for(let t=0;t0)&&d.push(e,r,l),(t!==n-1||o=i)){l.push(e.times[t]);for(let n=0;ns.tracks[t].times[0]&&(o=s.tracks[t].times[0]);for(let t=0;t=i.times[u]){const t=u*l+o,e=t+l-o;d=Vo.arraySlice(i.values,t,e)}else{const t=i.createInterpolant(),e=o,n=l-o;t.evaluate(s),d=Vo.arraySlice(t.resultBuffer,e,n)}if("quaternion"===r){(new At).fromArray(d).normalize().conjugate().toArray(d)}const p=a.times.length;for(let t=0;t=r)break t;{const a=e[1];t=r)break e}s=n,n=0}}for(;n>>1;te;)--s;if(++s,0!==r||s!==i){r>=s&&(s=Math.max(s,1),r=s-1);const t=this.getValueSize();this.times=Vo.arraySlice(n,r,s),this.values=Vo.arraySlice(this.values,r*t,s*t)}return this}validate(){let t=!0;const e=this.getValueSize();e-Math.floor(e)!=0&&(console.error("THREE.KeyframeTrack: Invalid value size in track.",this),t=!1);const n=this.times,i=this.values,r=n.length;0===r&&(console.error("THREE.KeyframeTrack: Track is empty.",this),t=!1);let s=null;for(let e=0;e!==r;e++){const i=n[e];if("number"==typeof i&&isNaN(i)){console.error("THREE.KeyframeTrack: Time is not a valid number.",this,e,i),t=!1;break}if(null!==s&&s>i){console.error("THREE.KeyframeTrack: Out of order keys.",this,e,i,s),t=!1;break}s=i}if(void 0!==i&&Vo.isTypedArray(i))for(let e=0,n=i.length;e!==n;++e){const n=i[e];if(isNaN(n)){console.error("THREE.KeyframeTrack: Value is not a valid number.",this,e,n),t=!1;break}}return t}optimize(){const t=Vo.arraySlice(this.times),e=Vo.arraySlice(this.values),n=this.getValueSize(),i=this.getInterpolation()===U,r=t.length-1;let s=1;for(let a=1;a0){t[s]=t[r];for(let t=r*n,i=s*n,a=0;a!==n;++a)e[i+a]=e[t+a];++s}return s!==t.length?(this.times=Vo.arraySlice(t,0,s),this.values=Vo.arraySlice(e,0,s*n)):(this.times=t,this.values=e),this}clone(){const t=Vo.arraySlice(this.times,0),e=Vo.arraySlice(this.values,0),n=new(0,this.constructor)(this.name,t,e);return n.createInterpolant=this.createInterpolant,n}}Yo.prototype.TimeBufferType=Float32Array,Yo.prototype.ValueBufferType=Float32Array,Yo.prototype.DefaultInterpolation=G;class Zo extends Yo{}Zo.prototype.ValueTypeName="bool",Zo.prototype.ValueBufferType=Array,Zo.prototype.DefaultInterpolation=H,Zo.prototype.InterpolantFactoryMethodLinear=void 0,Zo.prototype.InterpolantFactoryMethodSmooth=void 0;class Jo extends Yo{}Jo.prototype.ValueTypeName="color";class Qo extends Yo{}Qo.prototype.ValueTypeName="number";class Ko extends Wo{constructor(t,e,n,i){super(t,e,n,i)}interpolate_(t,e,n,i){const r=this.resultBuffer,s=this.sampleValues,a=this.valueSize,o=(n-e)/(i-e);let l=t*a;for(let t=l+a;l!==t;l+=4)At.slerpFlat(r,0,s,l-a,s,l,o);return r}}class $o extends Yo{InterpolantFactoryMethodLinear(t){return new Ko(this.times,this.values,this.getValueSize(),t)}}$o.prototype.ValueTypeName="quaternion",$o.prototype.DefaultInterpolation=G,$o.prototype.InterpolantFactoryMethodSmooth=void 0;class tl extends Yo{}tl.prototype.ValueTypeName="string",tl.prototype.ValueBufferType=Array,tl.prototype.DefaultInterpolation=H,tl.prototype.InterpolantFactoryMethodLinear=void 0,tl.prototype.InterpolantFactoryMethodSmooth=void 0;class el extends Yo{}el.prototype.ValueTypeName="vector";class nl{constructor(t,e=-1,n,i=2500){this.name=t,this.tracks=n,this.duration=e,this.blendMode=i,this.uuid=ct(),this.duration<0&&this.resetDuration()}static parse(t){const e=[],n=t.tracks,i=1/(t.fps||1);for(let t=0,r=n.length;t!==r;++t)e.push(il(n[t]).scale(i));const r=new this(t.name,t.duration,e,t.blendMode);return r.uuid=t.uuid,r}static toJSON(t){const e=[],n=t.tracks,i={name:t.name,duration:t.duration,tracks:e,uuid:t.uuid,blendMode:t.blendMode};for(let t=0,i=n.length;t!==i;++t)e.push(Yo.toJSON(n[t]));return i}static CreateFromMorphTargetSequence(t,e,n,i){const r=e.length,s=[];for(let t=0;t1){const t=s[1];let e=i[t];e||(i[t]=e=[]),e.push(n)}}const s=[];for(const t in i)s.push(this.CreateFromMorphTargetSequence(t,i[t],e,n));return s}static parseAnimation(t,e){if(!t)return console.error("THREE.AnimationClip: No animation in JSONLoader data."),null;const n=function(t,e,n,i,r){if(0!==n.length){const s=[],a=[];Vo.flattenJSON(n,s,a,i),0!==s.length&&r.push(new t(e,s,a))}},i=[],r=t.name||"default",s=t.fps||30,a=t.blendMode;let o=t.length||-1;const l=t.hierarchy||[];for(let t=0;t0||0===t.search(/^data\:image\/jpeg/);r.format=i?T:E,r.needsUpdate=!0,void 0!==e&&e(r)}),n,i),r}}class ml{constructor(){this.type="Curve",this.arcLengthDivisions=200}getPoint(){return console.warn("THREE.Curve: .getPoint() not implemented."),null}getPointAt(t,e){const n=this.getUtoTmapping(t);return this.getPoint(n,e)}getPoints(t=5){const e=[];for(let n=0;n<=t;n++)e.push(this.getPoint(n/t));return e}getSpacedPoints(t=5){const e=[];for(let n=0;n<=t;n++)e.push(this.getPointAt(n/t));return e}getLength(){const t=this.getLengths();return t[t.length-1]}getLengths(t=this.arcLengthDivisions){if(this.cacheArcLengths&&this.cacheArcLengths.length===t+1&&!this.needsUpdate)return this.cacheArcLengths;this.needsUpdate=!1;const e=[];let n,i=this.getPoint(0),r=0;e.push(0);for(let s=1;s<=t;s++)n=this.getPoint(s/t),r+=n.distanceTo(i),e.push(r),i=n;return this.cacheArcLengths=e,e}updateArcLengths(){this.needsUpdate=!0,this.getLengths()}getUtoTmapping(t,e){const n=this.getLengths();let i=0;const r=n.length;let s;s=e||t*n[r-1];let a,o=0,l=r-1;for(;o<=l;)if(i=Math.floor(o+(l-o)/2),a=n[i]-s,a<0)o=i+1;else{if(!(a>0)){l=i;break}l=i-1}if(i=l,n[i]===s)return i/(r-1);const c=n[i];return(i+(s-c)/(n[i+1]-c))/(r-1)}getTangent(t,e){const n=1e-4;let i=t-n,r=t+n;i<0&&(i=0),r>1&&(r=1);const s=this.getPoint(i),a=this.getPoint(r),o=e||(s.isVector2?new vt:new Lt);return o.copy(a).sub(s).normalize(),o}getTangentAt(t,e){const n=this.getUtoTmapping(t);return this.getTangent(n,e)}computeFrenetFrames(t,e){const n=new Lt,i=[],r=[],s=[],a=new Lt,o=new se;for(let e=0;e<=t;e++){const n=e/t;i[e]=this.getTangentAt(n,new Lt),i[e].normalize()}r[0]=new Lt,s[0]=new Lt;let l=Number.MAX_VALUE;const c=Math.abs(i[0].x),h=Math.abs(i[0].y),u=Math.abs(i[0].z);c<=l&&(l=c,n.set(1,0,0)),h<=l&&(l=h,n.set(0,1,0)),u<=l&&n.set(0,0,1),a.crossVectors(i[0],n).normalize(),r[0].crossVectors(i[0],a),s[0].crossVectors(i[0],r[0]);for(let e=1;e<=t;e++){if(r[e]=r[e-1].clone(),s[e]=s[e-1].clone(),a.crossVectors(i[e-1],i[e]),a.length()>Number.EPSILON){a.normalize();const t=Math.acos(ht(i[e-1].dot(i[e]),-1,1));r[e].applyMatrix4(o.makeRotationAxis(a,t))}s[e].crossVectors(i[e],r[e])}if(!0===e){let e=Math.acos(ht(r[0].dot(r[t]),-1,1));e/=t,i[0].dot(a.crossVectors(r[0],r[t]))>0&&(e=-e);for(let n=1;n<=t;n++)r[n].applyMatrix4(o.makeRotationAxis(i[n],e*n)),s[n].crossVectors(i[n],r[n])}return{tangents:i,normals:r,binormals:s}}clone(){return(new this.constructor).copy(this)}copy(t){return this.arcLengthDivisions=t.arcLengthDivisions,this}toJSON(){const t={metadata:{version:4.5,type:"Curve",generator:"Curve.toJSON"}};return t.arcLengthDivisions=this.arcLengthDivisions,t.type=this.type,t}fromJSON(t){return this.arcLengthDivisions=t.arcLengthDivisions,this}}class fl extends ml{constructor(t=0,e=0,n=1,i=1,r=0,s=2*Math.PI,a=!1,o=0){super(),this.type="EllipseCurve",this.aX=t,this.aY=e,this.xRadius=n,this.yRadius=i,this.aStartAngle=r,this.aEndAngle=s,this.aClockwise=a,this.aRotation=o}getPoint(t,e){const n=e||new vt,i=2*Math.PI;let r=this.aEndAngle-this.aStartAngle;const s=Math.abs(r)i;)r-=i;r0?0:(Math.floor(Math.abs(l)/r)+1)*r:0===c&&l===r-1&&(l=r-2,c=1),this.closed||l>0?a=i[(l-1)%r]:(yl.subVectors(i[0],i[1]).add(i[0]),a=yl);const h=i[l%r],u=i[(l+1)%r];if(this.closed||l+2i.length-2?i.length-1:s+1],h=i[s>i.length-3?i.length-1:s+2];return n.set(Ml(a,o.x,l.x,c.x,h.x),Ml(a,o.y,l.y,c.y,h.y)),n}copy(t){super.copy(t),this.points=[];for(let e=0,n=t.points.length;e=e){const t=n[i]-e,r=this.curves[i],s=r.getLength(),a=0===s?0:1-t/s;return r.getPointAt(a)}i++}return null}getLength(){const t=this.getCurveLengths();return t[t.length-1]}updateArcLengths(){this.needsUpdate=!0,this.cacheLengths=null,this.getCurveLengths()}getCurveLengths(){if(this.cacheLengths&&this.cacheLengths.length===this.curves.length)return this.cacheLengths;const t=[];let e=0;for(let n=0,i=this.curves.length;n1&&!e[e.length-1].equals(e[0])&&e.push(e[0]),e}copy(t){super.copy(t),this.curves=[];for(let e=0,n=t.curves.length;e0){const t=l.getPoint(0);t.equals(this.currentPoint)||this.lineTo(t.x,t.y)}this.curves.push(l);const c=l.getPoint(1);return this.currentPoint.copy(c),this}copy(t){return super.copy(t),this.currentPoint.copy(t.currentPoint),this}toJSON(){const t=super.toJSON();return t.currentPoint=this.currentPoint.toArray(),t}fromJSON(t){return super.fromJSON(t),this.currentPoint.fromArray(t.currentPoint),this}}class zl extends Bl{constructor(t){super(t),this.uuid=ct(),this.type="Shape",this.holes=[]}getPointsHoles(t){const e=[];for(let n=0,i=this.holes.length;n0:i.vertexColors=t.vertexColors),void 0!==t.uniforms)for(const e in t.uniforms){const r=t.uniforms[e];switch(i.uniforms[e]={},r.type){case"t":i.uniforms[e].value=n(r.value);break;case"c":i.uniforms[e].value=(new tn).setHex(r.value);break;case"v2":i.uniforms[e].value=(new vt).fromArray(r.value);break;case"v3":i.uniforms[e].value=(new Lt).fromArray(r.value);break;case"v4":i.uniforms[e].value=(new St).fromArray(r.value);break;case"m3":i.uniforms[e].value=(new yt).fromArray(r.value);break;case"m4":i.uniforms[e].value=(new se).fromArray(r.value);break;default:i.uniforms[e].value=r.value}}if(void 0!==t.defines&&(i.defines=t.defines),void 0!==t.vertexShader&&(i.vertexShader=t.vertexShader),void 0!==t.fragmentShader&&(i.fragmentShader=t.fragmentShader),void 0!==t.extensions)for(const e in t.extensions)i.extensions[e]=t.extensions[e];if(void 0!==t.shading&&(i.flatShading=1===t.shading),void 0!==t.size&&(i.size=t.size),void 0!==t.sizeAttenuation&&(i.sizeAttenuation=t.sizeAttenuation),void 0!==t.map&&(i.map=n(t.map)),void 0!==t.matcap&&(i.matcap=n(t.matcap)),void 0!==t.alphaMap&&(i.alphaMap=n(t.alphaMap)),void 0!==t.bumpMap&&(i.bumpMap=n(t.bumpMap)),void 0!==t.bumpScale&&(i.bumpScale=t.bumpScale),void 0!==t.normalMap&&(i.normalMap=n(t.normalMap)),void 0!==t.normalMapType&&(i.normalMapType=t.normalMapType),void 0!==t.normalScale){let e=t.normalScale;!1===Array.isArray(e)&&(e=[e,e]),i.normalScale=(new vt).fromArray(e)}return void 0!==t.displacementMap&&(i.displacementMap=n(t.displacementMap)),void 0!==t.displacementScale&&(i.displacementScale=t.displacementScale),void 0!==t.displacementBias&&(i.displacementBias=t.displacementBias),void 0!==t.roughnessMap&&(i.roughnessMap=n(t.roughnessMap)),void 0!==t.metalnessMap&&(i.metalnessMap=n(t.metalnessMap)),void 0!==t.emissiveMap&&(i.emissiveMap=n(t.emissiveMap)),void 0!==t.emissiveIntensity&&(i.emissiveIntensity=t.emissiveIntensity),void 0!==t.specularMap&&(i.specularMap=n(t.specularMap)),void 0!==t.envMap&&(i.envMap=n(t.envMap)),void 0!==t.envMapIntensity&&(i.envMapIntensity=t.envMapIntensity),void 0!==t.reflectivity&&(i.reflectivity=t.reflectivity),void 0!==t.refractionRatio&&(i.refractionRatio=t.refractionRatio),void 0!==t.lightMap&&(i.lightMap=n(t.lightMap)),void 0!==t.lightMapIntensity&&(i.lightMapIntensity=t.lightMapIntensity),void 0!==t.aoMap&&(i.aoMap=n(t.aoMap)),void 0!==t.aoMapIntensity&&(i.aoMapIntensity=t.aoMapIntensity),void 0!==t.gradientMap&&(i.gradientMap=n(t.gradientMap)),void 0!==t.clearcoatMap&&(i.clearcoatMap=n(t.clearcoatMap)),void 0!==t.clearcoatRoughnessMap&&(i.clearcoatRoughnessMap=n(t.clearcoatRoughnessMap)),void 0!==t.clearcoatNormalMap&&(i.clearcoatNormalMap=n(t.clearcoatNormalMap)),void 0!==t.clearcoatNormalScale&&(i.clearcoatNormalScale=(new vt).fromArray(t.clearcoatNormalScale)),void 0!==t.transmission&&(i.transmission=t.transmission),void 0!==t.transmissionMap&&(i.transmissionMap=n(t.transmissionMap)),i}setTextures(t){return this.textures=t,this}}class rc{static decodeText(t){if("undefined"!=typeof TextDecoder)return(new TextDecoder).decode(t);let e="";for(let n=0,i=t.length;nNumber.EPSILON){if(l<0&&(n=e[s],o=-o,a=e[r],l=-l),t.ya.y)continue;if(t.y===n.y){if(t.x===n.x)return!0}else{const e=l*(t.x-n.x)-o*(t.y-n.y);if(0===e)return!0;if(e<0)continue;i=!i}}else{if(t.y!==n.y)continue;if(a.x<=t.x&&t.x<=n.x||n.x<=t.x&&t.x<=a.x)return!0}}return i}const r=po.isClockWise,s=this.subPaths;if(0===s.length)return[];if(!0===e)return n(s);let a,o,l;const c=[];if(1===s.length)return o=s[0],l=new zl,l.curves=o.curves,c.push(l),c;let h=!r(s[0].getPoints());h=t?!h:h;const u=[],d=[];let p,m,f=[],g=0;d[g]=void 0,f[g]=[];for(let e=0,n=s.length;e1){let t=!1;const e=[];for(let t=0,e=d.length;t0&&(t||(f=u))}for(let t=0,e=d.length;t0){this.source.connect(this.filters[0]);for(let t=1,e=this.filters.length;t0){this.source.disconnect(this.filters[0]);for(let t=1,e=this.filters.length;t0&&this._mixBufferRegionAdditive(n,i,this._addIndex*e,1,e);for(let t=e,r=e+e;t!==r;++t)if(n[t]!==n[t+e]){a.setValue(n,i);break}}saveOriginalState(){const t=this.binding,e=this.buffer,n=this.valueSize,i=n*this._origIndex;t.getValue(e,i);for(let t=n,r=i;t!==r;++t)e[t]=e[i+t%n];this._setIdentity(),this.cumulativeWeight=0,this.cumulativeWeightAdditive=0}restoreOriginalState(){const t=3*this.valueSize;this.binding.setValue(this.buffer,t)}_setAdditiveIdentityNumeric(){const t=this._addIndex*this.valueSize,e=t+this.valueSize;for(let n=t;n=.5)for(let i=0;i!==r;++i)t[e+i]=t[n+i]}_slerp(t,e,n,i){At.slerpFlat(t,e,t,e,t,n,i)}_slerpAdditive(t,e,n,i,r){const s=this._workIndex*r;At.multiplyQuaternionsFlat(t,s,t,e,t,n),At.slerpFlat(t,e,t,e,t,s,i)}_lerp(t,e,n,i,r){const s=1-i;for(let a=0;a!==r;++a){const r=e+a;t[r]=t[r]*s+t[n+a]*i}}_lerpAdditive(t,e,n,i,r){for(let s=0;s!==r;++s){const r=e+s;t[r]=t[r]+t[n+s]*i}}}const Bc="\\[\\]\\.:\\/",zc=new RegExp("[\\[\\]\\.:\\/]","g"),Fc="[^\\[\\]\\.:\\/]",Oc="[^"+Bc.replace("\\.","")+"]",Hc=/((?:WC+[\/:])*)/.source.replace("WC",Fc),Gc=/(WCOD+)?/.source.replace("WCOD",Oc),Uc=/(?:\.(WC+)(?:\[(.+)\])?)?/.source.replace("WC",Fc),kc=/\.(WC+)(?:\[(.+)\])?/.source.replace("WC",Fc),Vc=new RegExp("^"+Hc+Gc+Uc+kc+"$"),Wc=["material","materials","bones"];class jc{constructor(t,e,n){this.path=e,this.parsedPath=n||jc.parseTrackName(e),this.node=jc.findNode(t,this.parsedPath.nodeName)||t,this.rootNode=t,this.getValue=this._getValue_unbound,this.setValue=this._setValue_unbound}static create(t,e,n){return t&&t.isAnimationObjectGroup?new jc.Composite(t,e,n):new jc(t,e,n)}static sanitizeNodeName(t){return t.replace(/\s/g,"_").replace(zc,"")}static parseTrackName(t){const e=Vc.exec(t);if(!e)throw new Error("PropertyBinding: Cannot parse trackName: "+t);const n={nodeName:e[2],objectName:e[3],objectIndex:e[4],propertyName:e[5],propertyIndex:e[6]},i=n.nodeName&&n.nodeName.lastIndexOf(".");if(void 0!==i&&-1!==i){const t=n.nodeName.substring(i+1);-1!==Wc.indexOf(t)&&(n.nodeName=n.nodeName.substring(0,i),n.objectName=t)}if(null===n.propertyName||0===n.propertyName.length)throw new Error("PropertyBinding: can not parse propertyName from trackName: "+t);return n}static findNode(t,e){if(!e||""===e||"."===e||-1===e||e===t.name||e===t.uuid)return t;if(t.skeleton){const n=t.skeleton.getBoneByName(e);if(void 0!==n)return n}if(t.children){const n=function(t){for(let i=0;i=r){const s=r++,c=t[s];e[c.uuid]=l,t[l]=c,e[o]=s,t[s]=a;for(let t=0,e=i;t!==e;++t){const e=n[t],i=e[s],r=e[l];e[l]=i,e[s]=r}}}this.nCachedObjects_=r}uncache(){const t=this._objects,e=this._indicesByUUID,n=this._bindings,i=n.length;let r=this.nCachedObjects_,s=t.length;for(let a=0,o=arguments.length;a!==o;++a){const o=arguments[a].uuid,l=e[o];if(void 0!==l)if(delete e[o],l0&&(e[a.uuid]=l),t[l]=a,t.pop();for(let t=0,e=i;t!==e;++t){const e=n[t];e[l]=e[r],e.pop()}}}this.nCachedObjects_=r}subscribe_(t,e){const n=this._bindingsIndicesByPath;let i=n[t];const r=this._bindings;if(void 0!==i)return r[i];const s=this._paths,a=this._parsedPaths,o=this._objects,l=o.length,c=this.nCachedObjects_,h=new Array(l);i=r.length,n[t]=i,s.push(t),a.push(e),r.push(h);for(let n=c,i=o.length;n!==i;++n){const i=o[n];h[n]=new jc(i,t,e)}return h}unsubscribe_(t){const e=this._bindingsIndicesByPath,n=e[t];if(void 0!==n){const i=this._paths,r=this._parsedPaths,s=this._bindings,a=s.length-1,o=s[a];e[t[a]]=n,s[n]=o,s.pop(),r[n]=r[a],r.pop(),i[n]=i[a],i.pop()}}}qc.prototype.isAnimationObjectGroup=!0;class Xc{constructor(t,e,n=null,i=e.blendMode){this._mixer=t,this._clip=e,this._localRoot=n,this.blendMode=i;const r=e.tracks,s=r.length,a=new Array(s),o={endingStart:k,endingEnd:k};for(let t=0;t!==s;++t){const e=r[t].createInterpolant(null);a[t]=e,e.settings=o}this._interpolantSettings=o,this._interpolants=a,this._propertyBindings=new Array(s),this._cacheIndex=null,this._byClipCacheIndex=null,this._timeScaleInterpolant=null,this._weightInterpolant=null,this.loop=2201,this._loopCount=-1,this._startTime=null,this.time=0,this.timeScale=1,this._effectiveTimeScale=1,this.weight=1,this._effectiveWeight=1,this.repetitions=1/0,this.paused=!1,this.enabled=!0,this.clampWhenFinished=!1,this.zeroSlopeAtStart=!0,this.zeroSlopeAtEnd=!0}play(){return this._mixer._activateAction(this),this}stop(){return this._mixer._deactivateAction(this),this.reset()}reset(){return this.paused=!1,this.enabled=!0,this.time=0,this._loopCount=-1,this._startTime=null,this.stopFading().stopWarping()}isRunning(){return this.enabled&&!this.paused&&0!==this.timeScale&&null===this._startTime&&this._mixer._isActiveAction(this)}isScheduled(){return this._mixer._isActiveAction(this)}startAt(t){return this._startTime=t,this}setLoop(t,e){return this.loop=t,this.repetitions=e,this}setEffectiveWeight(t){return this.weight=t,this._effectiveWeight=this.enabled?t:0,this.stopFading()}getEffectiveWeight(){return this._effectiveWeight}fadeIn(t){return this._scheduleFading(t,0,1)}fadeOut(t){return this._scheduleFading(t,1,0)}crossFadeFrom(t,e,n){if(t.fadeOut(e),this.fadeIn(e),n){const n=this._clip.duration,i=t._clip.duration,r=i/n,s=n/i;t.warp(1,r,e),this.warp(s,1,e)}return this}crossFadeTo(t,e,n){return t.crossFadeFrom(this,e,n)}stopFading(){const t=this._weightInterpolant;return null!==t&&(this._weightInterpolant=null,this._mixer._takeBackControlInterpolant(t)),this}setEffectiveTimeScale(t){return this.timeScale=t,this._effectiveTimeScale=this.paused?0:t,this.stopWarping()}getEffectiveTimeScale(){return this._effectiveTimeScale}setDuration(t){return this.timeScale=this._clip.duration/t,this.stopWarping()}syncWith(t){return this.time=t.time,this.timeScale=t.timeScale,this.stopWarping()}halt(t){return this.warp(this._effectiveTimeScale,0,t)}warp(t,e,n){const i=this._mixer,r=i.time,s=this.timeScale;let a=this._timeScaleInterpolant;null===a&&(a=i._lendControlInterpolant(),this._timeScaleInterpolant=a);const o=a.parameterPositions,l=a.sampleValues;return o[0]=r,o[1]=r+n,l[0]=t/s,l[1]=e/s,this}stopWarping(){const t=this._timeScaleInterpolant;return null!==t&&(this._timeScaleInterpolant=null,this._mixer._takeBackControlInterpolant(t)),this}getMixer(){return this._mixer}getClip(){return this._clip}getRoot(){return this._localRoot||this._mixer._root}_update(t,e,n,i){if(!this.enabled)return void this._updateWeight(t);const r=this._startTime;if(null!==r){const i=(t-r)*n;if(i<0||0===n)return;this._startTime=null,e=n*i}e*=this._updateTimeScale(t);const s=this._updateTime(e),a=this._updateWeight(t);if(a>0){const t=this._interpolants,e=this._propertyBindings;switch(this.blendMode){case q:for(let n=0,i=t.length;n!==i;++n)t[n].evaluate(s),e[n].accumulateAdditive(a);break;case j:default:for(let n=0,r=t.length;n!==r;++n)t[n].evaluate(s),e[n].accumulate(i,a)}}}_updateWeight(t){let e=0;if(this.enabled){e=this.weight;const n=this._weightInterpolant;if(null!==n){const i=n.evaluate(t)[0];e*=i,t>n.parameterPositions[1]&&(this.stopFading(),0===i&&(this.enabled=!1))}}return this._effectiveWeight=e,e}_updateTimeScale(t){let e=0;if(!this.paused){e=this.timeScale;const n=this._timeScaleInterpolant;if(null!==n){e*=n.evaluate(t)[0],t>n.parameterPositions[1]&&(this.stopWarping(),0===e?this.paused=!0:this.timeScale=e)}}return this._effectiveTimeScale=e,e}_updateTime(t){const e=this._clip.duration,n=this.loop;let i=this.time+t,r=this._loopCount;const s=2202===n;if(0===t)return-1===r?i:s&&1==(1&r)?e-i:i;if(2200===n){-1===r&&(this._loopCount=0,this._setEndings(!0,!0,!1));t:{if(i>=e)i=e;else{if(!(i<0)){this.time=i;break t}i=0}this.clampWhenFinished?this.paused=!0:this.enabled=!1,this.time=i,this._mixer.dispatchEvent({type:"finished",action:this,direction:t<0?-1:1})}}else{if(-1===r&&(t>=0?(r=0,this._setEndings(!0,0===this.repetitions,s)):this._setEndings(0===this.repetitions,!0,s)),i>=e||i<0){const n=Math.floor(i/e);i-=e*n,r+=Math.abs(n);const a=this.repetitions-r;if(a<=0)this.clampWhenFinished?this.paused=!0:this.enabled=!1,i=t>0?e:0,this.time=i,this._mixer.dispatchEvent({type:"finished",action:this,direction:t>0?1:-1});else{if(1===a){const e=t<0;this._setEndings(e,!e,s)}else this._setEndings(!1,!1,s);this._loopCount=r,this.time=i,this._mixer.dispatchEvent({type:"loop",action:this,loopDelta:n})}}else this.time=i;if(s&&1==(1&r))return e-i}return i}_setEndings(t,e,n){const i=this._interpolantSettings;n?(i.endingStart=V,i.endingEnd=V):(i.endingStart=t?this.zeroSlopeAtStart?V:k:W,i.endingEnd=e?this.zeroSlopeAtEnd?V:k:W)}_scheduleFading(t,e,n){const i=this._mixer,r=i.time;let s=this._weightInterpolant;null===s&&(s=i._lendControlInterpolant(),this._weightInterpolant=s);const a=s.parameterPositions,o=s.sampleValues;return a[0]=r,o[0]=e,a[1]=r+t,o[1]=n,this}}class Yc extends rt{constructor(t){super(),this._root=t,this._initMemoryManager(),this._accuIndex=0,this.time=0,this.timeScale=1}_bindAction(t,e){const n=t._localRoot||this._root,i=t._clip.tracks,r=i.length,s=t._propertyBindings,a=t._interpolants,o=n.uuid,l=this._bindingsByRootAndName;let c=l[o];void 0===c&&(c={},l[o]=c);for(let t=0;t!==r;++t){const r=i[t],l=r.name;let h=c[l];if(void 0!==h)s[t]=h;else{if(h=s[t],void 0!==h){null===h._cacheIndex&&(++h.referenceCount,this._addInactiveBinding(h,o,l));continue}const i=e&&e._propertyBindings[t].binding.parsedPath;h=new Nc(jc.create(n,l,i),r.ValueTypeName,r.getValueSize()),++h.referenceCount,this._addInactiveBinding(h,o,l),s[t]=h}a[t].resultBuffer=h.buffer}}_activateAction(t){if(!this._isActiveAction(t)){if(null===t._cacheIndex){const e=(t._localRoot||this._root).uuid,n=t._clip.uuid,i=this._actionsByClip[n];this._bindAction(t,i&&i.knownActions[0]),this._addInactiveAction(t,n,e)}const e=t._propertyBindings;for(let t=0,n=e.length;t!==n;++t){const n=e[t];0==n.useCount++&&(this._lendBinding(n),n.saveOriginalState())}this._lendAction(t)}}_deactivateAction(t){if(this._isActiveAction(t)){const e=t._propertyBindings;for(let t=0,n=e.length;t!==n;++t){const n=e[t];0==--n.useCount&&(n.restoreOriginalState(),this._takeBackBinding(n))}this._takeBackAction(t)}}_initMemoryManager(){this._actions=[],this._nActiveActions=0,this._actionsByClip={},this._bindings=[],this._nActiveBindings=0,this._bindingsByRootAndName={},this._controlInterpolants=[],this._nActiveControlInterpolants=0;const t=this;this.stats={actions:{get total(){return t._actions.length},get inUse(){return t._nActiveActions}},bindings:{get total(){return t._bindings.length},get inUse(){return t._nActiveBindings}},controlInterpolants:{get total(){return t._controlInterpolants.length},get inUse(){return t._nActiveControlInterpolants}}}}_isActiveAction(t){const e=t._cacheIndex;return null!==e&&e=0;--e)t[e].stop();return this}update(t){t*=this.timeScale;const e=this._actions,n=this._nActiveActions,i=this.time+=t,r=Math.sign(t),s=this._accuIndex^=1;for(let a=0;a!==n;++a){e[a]._update(i,t,r,s)}const a=this._bindings,o=this._nActiveBindings;for(let t=0;t!==o;++t)a[t].apply(s);return this}setTime(t){this.time=0;for(let t=0;tthis.max.x||t.ythis.max.y)}containsBox(t){return this.min.x<=t.min.x&&t.max.x<=this.max.x&&this.min.y<=t.min.y&&t.max.y<=this.max.y}getParameter(t,e){return void 0===e&&(console.warn("THREE.Box2: .getParameter() target is now required"),e=new vt),e.set((t.x-this.min.x)/(this.max.x-this.min.x),(t.y-this.min.y)/(this.max.y-this.min.y))}intersectsBox(t){return!(t.max.xthis.max.x||t.max.ythis.max.y)}clampPoint(t,e){return void 0===e&&(console.warn("THREE.Box2: .clampPoint() target is now required"),e=new vt),e.copy(t).clamp(this.min,this.max)}distanceToPoint(t){return th.copy(t).clamp(this.min,this.max).sub(t).length()}intersect(t){return this.min.max(t.min),this.max.min(t.max),this}union(t){return this.min.min(t.min),this.max.max(t.max),this}translate(t){return this.min.add(t),this.max.add(t),this}equals(t){return t.min.equals(this.min)&&t.max.equals(this.max)}}eh.prototype.isBox2=!0;const nh=new Lt,ih=new Lt;class rh{constructor(t=new Lt,e=new Lt){this.start=t,this.end=e}set(t,e){return this.start.copy(t),this.end.copy(e),this}copy(t){return this.start.copy(t.start),this.end.copy(t.end),this}getCenter(t){return void 0===t&&(console.warn("THREE.Line3: .getCenter() target is now required"),t=new Lt),t.addVectors(this.start,this.end).multiplyScalar(.5)}delta(t){return void 0===t&&(console.warn("THREE.Line3: .delta() target is now required"),t=new Lt),t.subVectors(this.end,this.start)}distanceSq(){return this.start.distanceToSquared(this.end)}distance(){return this.start.distanceTo(this.end)}at(t,e){return void 0===e&&(console.warn("THREE.Line3: .at() target is now required"),e=new Lt),this.delta(e).multiplyScalar(t).add(this.start)}closestPointToPointParameter(t,e){nh.subVectors(t,this.start),ih.subVectors(this.end,this.start);const n=ih.dot(ih);let i=ih.dot(nh)/n;return e&&(i=ht(i,0,1)),i}closestPointToPoint(t,e,n){const i=this.closestPointToPointParameter(t,e);return void 0===n&&(console.warn("THREE.Line3: .closestPointToPoint() target is now required"),n=new Lt),this.delta(n).multiplyScalar(i).add(this.start)}applyMatrix4(t){return this.start.applyMatrix4(t),this.end.applyMatrix4(t),this}equals(t){return t.start.equals(this.start)&&t.end.equals(this.end)}clone(){return(new this.constructor).copy(this)}}class sh extends Ce{constructor(t){super(),this.material=t,this.render=function(){},this.hasPositions=!1,this.hasNormals=!1,this.hasColors=!1,this.hasUvs=!1,this.positionArray=null,this.normalArray=null,this.colorArray=null,this.uvArray=null,this.count=0}}sh.prototype.isImmediateRenderObject=!0;const ah=new Lt;const oh=new Lt,lh=new se,ch=new se;class hh extends ya{constructor(t){const e=uh(t),n=new En,i=[],r=[],s=new tn(0,0,1),a=new tn(0,1,0);for(let t=0;t4?a=Ph[r-8+4-1]:0==r&&(a=0),n.push(a);const o=1/(s-1),l=-o/2,c=1+o/2,h=[l,l,c,l,c,c,l,l,c,c,l,c],u=6,d=6,p=3,m=2,f=1,g=new Float32Array(p*d*u),v=new Float32Array(m*d*u),y=new Float32Array(f*d*u);for(let t=0;t2?0:-1,i=[e,n,0,e+2/3,n,0,e+2/3,n+1,0,e,n,0,e+2/3,n+1,0,e,n+1,0];g.set(i,p*d*t),v.set(h,m*d*t);const r=[t,t,t,t,t,t];y.set(r,f*d*t)}const x=new En;x.setAttribute("position",new sn(g,p)),x.setAttribute("uv",new sn(v,m)),x.setAttribute("faceIndex",new sn(y,f)),t.push(x),i>4&&i--}return{_lodPlanes:t,_sizeLods:e,_sigmas:n}}function Zh(t){const e=new Tt(3*Ch,3*Ch,t);return e.texture.mapping=l,e.texture.name="PMREM.cubeUv",e.scissorTest=!0,e}function Jh(t,e,n,i,r){t.viewport.set(e,n,i,r),t.scissor.set(e,n,i,r)}function Qh(){const t=new vt(1,1);return new Io({name:"EquirectangularToCubeUV",uniforms:{envMap:{value:null},texelSize:{value:t},inputEncoding:{value:Nh[3e3]},outputEncoding:{value:Nh[3e3]}},vertexShader:$h(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform sampler2D envMap;\n\t\t\tuniform vec2 texelSize;\n\n\t\t\t${tu()}\n\n\t\t\t#include \n\n\t\t\tvoid main() {\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\n\t\t\t\tvec3 outputDirection = normalize( vOutputDirection );\n\t\t\t\tvec2 uv = equirectUv( outputDirection );\n\n\t\t\t\tvec2 f = fract( uv / texelSize - 0.5 );\n\t\t\t\tuv -= f * texelSize;\n\t\t\t\tvec3 tl = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\t\t\t\tuv.x += texelSize.x;\n\t\t\t\tvec3 tr = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\t\t\t\tuv.y += texelSize.y;\n\t\t\t\tvec3 br = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\t\t\t\tuv.x -= texelSize.x;\n\t\t\t\tvec3 bl = envMapTexelToLinear( texture2D ( envMap, uv ) ).rgb;\n\n\t\t\t\tvec3 tm = mix( tl, tr, f.x );\n\t\t\t\tvec3 bm = mix( bl, br, f.x );\n\t\t\t\tgl_FragColor.rgb = mix( tm, bm, f.y );\n\n\t\t\t\tgl_FragColor = linearToOutputTexel( gl_FragColor );\n\n\t\t\t}\n\t\t`,blending:0,depthTest:!1,depthWrite:!1})}function Kh(){return new Io({name:"CubemapToCubeUV",uniforms:{envMap:{value:null},inputEncoding:{value:Nh[3e3]},outputEncoding:{value:Nh[3e3]}},vertexShader:$h(),fragmentShader:`\n\n\t\t\tprecision mediump float;\n\t\t\tprecision mediump int;\n\n\t\t\tvarying vec3 vOutputDirection;\n\n\t\t\tuniform samplerCube envMap;\n\n\t\t\t${tu()}\n\n\t\t\tvoid main() {\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t\t\t\tgl_FragColor.rgb = envMapTexelToLinear( textureCube( envMap, vec3( - vOutputDirection.x, vOutputDirection.yz ) ) ).rgb;\n\t\t\t\tgl_FragColor = linearToOutputTexel( gl_FragColor );\n\n\t\t\t}\n\t\t`,blending:0,depthTest:!1,depthWrite:!1})}function $h(){return"\n\n\t\tprecision mediump float;\n\t\tprecision mediump int;\n\n\t\tattribute vec3 position;\n\t\tattribute vec2 uv;\n\t\tattribute float faceIndex;\n\n\t\tvarying vec3 vOutputDirection;\n\n\t\t// RH coordinate system; PMREM face-indexing convention\n\t\tvec3 getDirection( vec2 uv, float face ) {\n\n\t\t\tuv = 2.0 * uv - 1.0;\n\n\t\t\tvec3 direction = vec3( uv, 1.0 );\n\n\t\t\tif ( face == 0.0 ) {\n\n\t\t\t\tdirection = direction.zyx; // ( 1, v, u ) pos x\n\n\t\t\t} else if ( face == 1.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xz *= -1.0; // ( -u, 1, -v ) pos y\n\n\t\t\t} else if ( face == 2.0 ) {\n\n\t\t\t\tdirection.x *= -1.0; // ( -u, v, 1 ) pos z\n\n\t\t\t} else if ( face == 3.0 ) {\n\n\t\t\t\tdirection = direction.zyx;\n\t\t\t\tdirection.xz *= -1.0; // ( -1, v, -u ) neg x\n\n\t\t\t} else if ( face == 4.0 ) {\n\n\t\t\t\tdirection = direction.xzy;\n\t\t\t\tdirection.xy *= -1.0; // ( -u, -1, v ) neg y\n\n\t\t\t} else if ( face == 5.0 ) {\n\n\t\t\t\tdirection.z *= -1.0; // ( u, v, -1 ) neg z\n\n\t\t\t}\n\n\t\t\treturn direction;\n\n\t\t}\n\n\t\tvoid main() {\n\n\t\t\tvOutputDirection = getDirection( uv, faceIndex );\n\t\t\tgl_Position = vec4( position, 1.0 );\n\n\t\t}\n\t"}function tu(){return"\n\n\t\tuniform int inputEncoding;\n\t\tuniform int outputEncoding;\n\n\t\t#include \n\n\t\tvec4 inputTexelToLinear( vec4 value ) {\n\n\t\t\tif ( inputEncoding == 0 ) {\n\n\t\t\t\treturn value;\n\n\t\t\t} else if ( inputEncoding == 1 ) {\n\n\t\t\t\treturn sRGBToLinear( value );\n\n\t\t\t} else if ( inputEncoding == 2 ) {\n\n\t\t\t\treturn RGBEToLinear( value );\n\n\t\t\t} else if ( inputEncoding == 3 ) {\n\n\t\t\t\treturn RGBMToLinear( value, 7.0 );\n\n\t\t\t} else if ( inputEncoding == 4 ) {\n\n\t\t\t\treturn RGBMToLinear( value, 16.0 );\n\n\t\t\t} else if ( inputEncoding == 5 ) {\n\n\t\t\t\treturn RGBDToLinear( value, 256.0 );\n\n\t\t\t} else {\n\n\t\t\t\treturn GammaToLinear( value, 2.2 );\n\n\t\t\t}\n\n\t\t}\n\n\t\tvec4 linearToOutputTexel( vec4 value ) {\n\n\t\t\tif ( outputEncoding == 0 ) {\n\n\t\t\t\treturn value;\n\n\t\t\t} else if ( outputEncoding == 1 ) {\n\n\t\t\t\treturn LinearTosRGB( value );\n\n\t\t\t} else if ( outputEncoding == 2 ) {\n\n\t\t\t\treturn LinearToRGBE( value );\n\n\t\t\t} else if ( outputEncoding == 3 ) {\n\n\t\t\t\treturn LinearToRGBM( value, 7.0 );\n\n\t\t\t} else if ( outputEncoding == 4 ) {\n\n\t\t\t\treturn LinearToRGBM( value, 16.0 );\n\n\t\t\t} else if ( outputEncoding == 5 ) {\n\n\t\t\t\treturn LinearToRGBD( value, 256.0 );\n\n\t\t\t} else {\n\n\t\t\t\treturn LinearToGamma( value, 2.2 );\n\n\t\t\t}\n\n\t\t}\n\n\t\tvec4 envMapTexelToLinear( vec4 color ) {\n\n\t\t\treturn inputTexelToLinear( color );\n\n\t\t}\n\t"}ml.create=function(t,e){return console.log("THREE.Curve.create() has been deprecated"),t.prototype=Object.create(ml.prototype),t.prototype.constructor=t,t.prototype.getPoint=e,t},Bl.prototype.fromPoints=function(t){return console.warn("THREE.Path: .fromPoints() has been renamed to .setFromPoints()."),this.setFromPoints(t)},fh.prototype.setColors=function(){console.error("THREE.GridHelper: setColors() has been deprecated, pass them in the constructor instead.")},hh.prototype.update=function(){console.error("THREE.SkeletonHelper: update() no longer needs to be called.")},ol.prototype.extractUrlBase=function(t){return console.warn("THREE.Loader: .extractUrlBase() has been deprecated. Use THREE.LoaderUtils.extractUrlBase() instead."),rc.extractUrlBase(t)},ol.Handlers={add:function(){console.error("THREE.Loader: Handlers.add() has been removed. Use LoadingManager.addHandler() instead.")},get:function(){console.error("THREE.Loader: Handlers.get() has been removed. Use LoadingManager.getHandler() instead.")}},eh.prototype.center=function(t){return console.warn("THREE.Box2: .center() has been renamed to .getCenter()."),this.getCenter(t)},eh.prototype.empty=function(){return console.warn("THREE.Box2: .empty() has been renamed to .isEmpty()."),this.isEmpty()},eh.prototype.isIntersectionBox=function(t){return console.warn("THREE.Box2: .isIntersectionBox() has been renamed to .intersectsBox()."),this.intersectsBox(t)},eh.prototype.size=function(t){return console.warn("THREE.Box2: .size() has been renamed to .getSize()."),this.getSize(t)},Pt.prototype.center=function(t){return console.warn("THREE.Box3: .center() has been renamed to .getCenter()."),this.getCenter(t)},Pt.prototype.empty=function(){return console.warn("THREE.Box3: .empty() has been renamed to .isEmpty()."),this.isEmpty()},Pt.prototype.isIntersectionBox=function(t){return console.warn("THREE.Box3: .isIntersectionBox() has been renamed to .intersectsBox()."),this.intersectsBox(t)},Pt.prototype.isIntersectionSphere=function(t){return console.warn("THREE.Box3: .isIntersectionSphere() has been renamed to .intersectsSphere()."),this.intersectsSphere(t)},Pt.prototype.size=function(t){return console.warn("THREE.Box3: .size() has been renamed to .getSize()."),this.getSize(t)},Jt.prototype.empty=function(){return console.warn("THREE.Sphere: .empty() has been renamed to .isEmpty()."),this.isEmpty()},ai.prototype.setFromMatrix=function(t){return console.warn("THREE.Frustum: .setFromMatrix() has been renamed to .setFromProjectionMatrix()."),this.setFromProjectionMatrix(t)},rh.prototype.center=function(t){return console.warn("THREE.Line3: .center() has been renamed to .getCenter()."),this.getCenter(t)},yt.prototype.flattenToArrayOffset=function(t,e){return console.warn("THREE.Matrix3: .flattenToArrayOffset() has been deprecated. Use .toArray() instead."),this.toArray(t,e)},yt.prototype.multiplyVector3=function(t){return console.warn("THREE.Matrix3: .multiplyVector3() has been removed. Use vector.applyMatrix3( matrix ) instead."),t.applyMatrix3(this)},yt.prototype.multiplyVector3Array=function(){console.error("THREE.Matrix3: .multiplyVector3Array() has been removed.")},yt.prototype.applyToBufferAttribute=function(t){return console.warn("THREE.Matrix3: .applyToBufferAttribute() has been removed. Use attribute.applyMatrix3( matrix ) instead."),t.applyMatrix3(this)},yt.prototype.applyToVector3Array=function(){console.error("THREE.Matrix3: .applyToVector3Array() has been removed.")},yt.prototype.getInverse=function(t){return console.warn("THREE.Matrix3: .getInverse() has been removed. Use matrixInv.copy( matrix ).invert(); instead."),this.copy(t).invert()},se.prototype.extractPosition=function(t){return console.warn("THREE.Matrix4: .extractPosition() has been renamed to .copyPosition()."),this.copyPosition(t)},se.prototype.flattenToArrayOffset=function(t,e){return console.warn("THREE.Matrix4: .flattenToArrayOffset() has been deprecated. Use .toArray() instead."),this.toArray(t,e)},se.prototype.getPosition=function(){return console.warn("THREE.Matrix4: .getPosition() has been removed. Use Vector3.setFromMatrixPosition( matrix ) instead."),(new Lt).setFromMatrixColumn(this,3)},se.prototype.setRotationFromQuaternion=function(t){return console.warn("THREE.Matrix4: .setRotationFromQuaternion() has been renamed to .makeRotationFromQuaternion()."),this.makeRotationFromQuaternion(t)},se.prototype.multiplyToArray=function(){console.warn("THREE.Matrix4: .multiplyToArray() has been removed.")},se.prototype.multiplyVector3=function(t){return console.warn("THREE.Matrix4: .multiplyVector3() has been removed. Use vector.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.multiplyVector4=function(t){return console.warn("THREE.Matrix4: .multiplyVector4() has been removed. Use vector.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.multiplyVector3Array=function(){console.error("THREE.Matrix4: .multiplyVector3Array() has been removed.")},se.prototype.rotateAxis=function(t){console.warn("THREE.Matrix4: .rotateAxis() has been removed. Use Vector3.transformDirection( matrix ) instead."),t.transformDirection(this)},se.prototype.crossVector=function(t){return console.warn("THREE.Matrix4: .crossVector() has been removed. Use vector.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.translate=function(){console.error("THREE.Matrix4: .translate() has been removed.")},se.prototype.rotateX=function(){console.error("THREE.Matrix4: .rotateX() has been removed.")},se.prototype.rotateY=function(){console.error("THREE.Matrix4: .rotateY() has been removed.")},se.prototype.rotateZ=function(){console.error("THREE.Matrix4: .rotateZ() has been removed.")},se.prototype.rotateByAxis=function(){console.error("THREE.Matrix4: .rotateByAxis() has been removed.")},se.prototype.applyToBufferAttribute=function(t){return console.warn("THREE.Matrix4: .applyToBufferAttribute() has been removed. Use attribute.applyMatrix4( matrix ) instead."),t.applyMatrix4(this)},se.prototype.applyToVector3Array=function(){console.error("THREE.Matrix4: .applyToVector3Array() has been removed.")},se.prototype.makeFrustum=function(t,e,n,i,r,s){return console.warn("THREE.Matrix4: .makeFrustum() has been removed. Use .makePerspective( left, right, top, bottom, near, far ) instead."),this.makePerspective(t,e,i,n,r,s)},se.prototype.getInverse=function(t){return console.warn("THREE.Matrix4: .getInverse() has been removed. Use matrixInv.copy( matrix ).invert(); instead."),this.copy(t).invert()},Ne.prototype.isIntersectionLine=function(t){return console.warn("THREE.Plane: .isIntersectionLine() has been renamed to .intersectsLine()."),this.intersectsLine(t)},At.prototype.multiplyVector3=function(t){return console.warn("THREE.Quaternion: .multiplyVector3() has been removed. Use is now vector.applyQuaternion( quaternion ) instead."),t.applyQuaternion(this)},At.prototype.inverse=function(){return console.warn("THREE.Quaternion: .inverse() has been renamed to invert()."),this.invert()},re.prototype.isIntersectionBox=function(t){return console.warn("THREE.Ray: .isIntersectionBox() has been renamed to .intersectsBox()."),this.intersectsBox(t)},re.prototype.isIntersectionPlane=function(t){return console.warn("THREE.Ray: .isIntersectionPlane() has been renamed to .intersectsPlane()."),this.intersectsPlane(t)},re.prototype.isIntersectionSphere=function(t){return console.warn("THREE.Ray: .isIntersectionSphere() has been renamed to .intersectsSphere()."),this.intersectsSphere(t)},je.prototype.area=function(){return console.warn("THREE.Triangle: .area() has been renamed to .getArea()."),this.getArea()},je.prototype.barycoordFromPoint=function(t,e){return console.warn("THREE.Triangle: .barycoordFromPoint() has been renamed to .getBarycoord()."),this.getBarycoord(t,e)},je.prototype.midpoint=function(t){return console.warn("THREE.Triangle: .midpoint() has been renamed to .getMidpoint()."),this.getMidpoint(t)},je.prototypenormal=function(t){return console.warn("THREE.Triangle: .normal() has been renamed to .getNormal()."),this.getNormal(t)},je.prototype.plane=function(t){return console.warn("THREE.Triangle: .plane() has been renamed to .getPlane()."),this.getPlane(t)},je.barycoordFromPoint=function(t,e,n,i,r){return console.warn("THREE.Triangle: .barycoordFromPoint() has been renamed to .getBarycoord()."),je.getBarycoord(t,e,n,i,r)},je.normal=function(t,e,n,i){return console.warn("THREE.Triangle: .normal() has been renamed to .getNormal()."),je.getNormal(t,e,n,i)},zl.prototype.extractAllPoints=function(t){return console.warn("THREE.Shape: .extractAllPoints() has been removed. Use .extractPoints() instead."),this.extractPoints(t)},zl.prototype.extrude=function(t){return console.warn("THREE.Shape: .extrude() has been removed. Use ExtrudeGeometry() instead."),new go(this,t)},zl.prototype.makeGeometry=function(t){return console.warn("THREE.Shape: .makeGeometry() has been removed. Use ShapeGeometry() instead."),new Mo(this,t)},vt.prototype.fromAttribute=function(t,e,n){return console.warn("THREE.Vector2: .fromAttribute() has been renamed to .fromBufferAttribute()."),this.fromBufferAttribute(t,e,n)},vt.prototype.distanceToManhattan=function(t){return console.warn("THREE.Vector2: .distanceToManhattan() has been renamed to .manhattanDistanceTo()."),this.manhattanDistanceTo(t)},vt.prototype.lengthManhattan=function(){return console.warn("THREE.Vector2: .lengthManhattan() has been renamed to .manhattanLength()."),this.manhattanLength()},Lt.prototype.setEulerFromRotationMatrix=function(){console.error("THREE.Vector3: .setEulerFromRotationMatrix() has been removed. Use Euler.setFromRotationMatrix() instead.")},Lt.prototype.setEulerFromQuaternion=function(){console.error("THREE.Vector3: .setEulerFromQuaternion() has been removed. Use Euler.setFromQuaternion() instead.")},Lt.prototype.getPositionFromMatrix=function(t){return console.warn("THREE.Vector3: .getPositionFromMatrix() has been renamed to .setFromMatrixPosition()."),this.setFromMatrixPosition(t)},Lt.prototype.getScaleFromMatrix=function(t){return console.warn("THREE.Vector3: .getScaleFromMatrix() has been renamed to .setFromMatrixScale()."),this.setFromMatrixScale(t)},Lt.prototype.getColumnFromMatrix=function(t,e){return console.warn("THREE.Vector3: .getColumnFromMatrix() has been renamed to .setFromMatrixColumn()."),this.setFromMatrixColumn(e,t)},Lt.prototype.applyProjection=function(t){return console.warn("THREE.Vector3: .applyProjection() has been removed. Use .applyMatrix4( m ) instead."),this.applyMatrix4(t)},Lt.prototype.fromAttribute=function(t,e,n){return console.warn("THREE.Vector3: .fromAttribute() has been renamed to .fromBufferAttribute()."),this.fromBufferAttribute(t,e,n)},Lt.prototype.distanceToManhattan=function(t){return console.warn("THREE.Vector3: .distanceToManhattan() has been renamed to .manhattanDistanceTo()."),this.manhattanDistanceTo(t)},Lt.prototype.lengthManhattan=function(){return console.warn("THREE.Vector3: .lengthManhattan() has been renamed to .manhattanLength()."),this.manhattanLength()},St.prototype.fromAttribute=function(t,e,n){return console.warn("THREE.Vector4: .fromAttribute() has been renamed to .fromBufferAttribute()."),this.fromBufferAttribute(t,e,n)},St.prototype.lengthManhattan=function(){return console.warn("THREE.Vector4: .lengthManhattan() has been renamed to .manhattanLength()."),this.manhattanLength()},Ce.prototype.getChildByName=function(t){return console.warn("THREE.Object3D: .getChildByName() has been renamed to .getObjectByName()."),this.getObjectByName(t)},Ce.prototype.renderDepth=function(){console.warn("THREE.Object3D: .renderDepth has been removed. Use .renderOrder, instead.")},Ce.prototype.translate=function(t,e){return console.warn("THREE.Object3D: .translate() has been removed. Use .translateOnAxis( axis, distance ) instead."),this.translateOnAxis(e,t)},Ce.prototype.getWorldRotation=function(){console.error("THREE.Object3D: .getWorldRotation() has been removed. Use THREE.Object3D.getWorldQuaternion( target ) instead.")},Ce.prototype.applyMatrix=function(t){return console.warn("THREE.Object3D: .applyMatrix() has been renamed to .applyMatrix4()."),this.applyMatrix4(t)},Object.defineProperties(Ce.prototype,{eulerOrder:{get:function(){return console.warn("THREE.Object3D: .eulerOrder is now .rotation.order."),this.rotation.order},set:function(t){console.warn("THREE.Object3D: .eulerOrder is now .rotation.order."),this.rotation.order=t}},useQuaternion:{get:function(){console.warn("THREE.Object3D: .useQuaternion has been removed. The library now uses quaternions by default.")},set:function(){console.warn("THREE.Object3D: .useQuaternion has been removed. The library now uses quaternions by default.")}}}),Wn.prototype.setDrawMode=function(){console.error("THREE.Mesh: .setDrawMode() has been removed. The renderer now always assumes THREE.TrianglesDrawMode. Transform your geometry via BufferGeometryUtils.toTrianglesDrawMode() if necessary.")},Object.defineProperties(Wn.prototype,{drawMode:{get:function(){return console.error("THREE.Mesh: .drawMode has been removed. The renderer now always assumes THREE.TrianglesDrawMode."),0},set:function(){console.error("THREE.Mesh: .drawMode has been removed. The renderer now always assumes THREE.TrianglesDrawMode. Transform your geometry via BufferGeometryUtils.toTrianglesDrawMode() if necessary.")}}}),$s.prototype.initBones=function(){console.error("THREE.SkinnedMesh: initBones() has been removed.")},Kn.prototype.setLens=function(t,e){console.warn("THREE.PerspectiveCamera.setLens is deprecated. Use .setFocalLength and .filmGauge for a photographic setup."),void 0!==e&&(this.filmGauge=e),this.setFocalLength(t)},Object.defineProperties(Fl.prototype,{onlyShadow:{set:function(){console.warn("THREE.Light: .onlyShadow has been removed.")}},shadowCameraFov:{set:function(t){console.warn("THREE.Light: .shadowCameraFov is now .shadow.camera.fov."),this.shadow.camera.fov=t}},shadowCameraLeft:{set:function(t){console.warn("THREE.Light: .shadowCameraLeft is now .shadow.camera.left."),this.shadow.camera.left=t}},shadowCameraRight:{set:function(t){console.warn("THREE.Light: .shadowCameraRight is now .shadow.camera.right."),this.shadow.camera.right=t}},shadowCameraTop:{set:function(t){console.warn("THREE.Light: .shadowCameraTop is now .shadow.camera.top."),this.shadow.camera.top=t}},shadowCameraBottom:{set:function(t){console.warn("THREE.Light: .shadowCameraBottom is now .shadow.camera.bottom."),this.shadow.camera.bottom=t}},shadowCameraNear:{set:function(t){console.warn("THREE.Light: .shadowCameraNear is now .shadow.camera.near."),this.shadow.camera.near=t}},shadowCameraFar:{set:function(t){console.warn("THREE.Light: .shadowCameraFar is now .shadow.camera.far."),this.shadow.camera.far=t}},shadowCameraVisible:{set:function(){console.warn("THREE.Light: .shadowCameraVisible has been removed. Use new THREE.CameraHelper( light.shadow.camera ) instead.")}},shadowBias:{set:function(t){console.warn("THREE.Light: .shadowBias is now .shadow.bias."),this.shadow.bias=t}},shadowDarkness:{set:function(){console.warn("THREE.Light: .shadowDarkness has been removed.")}},shadowMapWidth:{set:function(t){console.warn("THREE.Light: .shadowMapWidth is now .shadow.mapSize.width."),this.shadow.mapSize.width=t}},shadowMapHeight:{set:function(t){console.warn("THREE.Light: .shadowMapHeight is now .shadow.mapSize.height."),this.shadow.mapSize.height=t}}}),Object.defineProperties(sn.prototype,{length:{get:function(){return console.warn("THREE.BufferAttribute: .length has been deprecated. Use .count instead."),this.array.length}},dynamic:{get:function(){return console.warn("THREE.BufferAttribute: .dynamic has been deprecated. Use .usage instead."),this.usage===nt},set:function(){console.warn("THREE.BufferAttribute: .dynamic has been deprecated. Use .usage instead."),this.setUsage(nt)}}}),sn.prototype.setDynamic=function(t){return console.warn("THREE.BufferAttribute: .setDynamic() has been deprecated. Use .setUsage() instead."),this.setUsage(!0===t?nt:et),this},sn.prototype.copyIndicesArray=function(){console.error("THREE.BufferAttribute: .copyIndicesArray() has been removed.")},sn.prototype.setArray=function(){console.error("THREE.BufferAttribute: .setArray has been removed. Use BufferGeometry .setAttribute to replace/resize attribute buffers")},En.prototype.addIndex=function(t){console.warn("THREE.BufferGeometry: .addIndex() has been renamed to .setIndex()."),this.setIndex(t)},En.prototype.addAttribute=function(t,e){return console.warn("THREE.BufferGeometry: .addAttribute() has been renamed to .setAttribute()."),e&&e.isBufferAttribute||e&&e.isInterleavedBufferAttribute?"index"===t?(console.warn("THREE.BufferGeometry.addAttribute: Use .setIndex() for index attribute."),this.setIndex(e),this):this.setAttribute(t,e):(console.warn("THREE.BufferGeometry: .addAttribute() now expects ( name, attribute )."),this.setAttribute(t,new sn(arguments[1],arguments[2])))},En.prototype.addDrawCall=function(t,e,n){void 0!==n&&console.warn("THREE.BufferGeometry: .addDrawCall() no longer supports indexOffset."),console.warn("THREE.BufferGeometry: .addDrawCall() is now .addGroup()."),this.addGroup(t,e)},En.prototype.clearDrawCalls=function(){console.warn("THREE.BufferGeometry: .clearDrawCalls() is now .clearGroups()."),this.clearGroups()},En.prototype.computeOffsets=function(){console.warn("THREE.BufferGeometry: .computeOffsets() has been removed.")},En.prototype.removeAttribute=function(t){return console.warn("THREE.BufferGeometry: .removeAttribute() has been renamed to .deleteAttribute()."),this.deleteAttribute(t)},En.prototype.applyMatrix=function(t){return console.warn("THREE.BufferGeometry: .applyMatrix() has been renamed to .applyMatrix4()."),this.applyMatrix4(t)},Object.defineProperties(En.prototype,{drawcalls:{get:function(){return console.error("THREE.BufferGeometry: .drawcalls has been renamed to .groups."),this.groups}},offsets:{get:function(){return console.warn("THREE.BufferGeometry: .offsets has been renamed to .groups."),this.groups}}}),Es.prototype.setDynamic=function(t){return console.warn("THREE.InterleavedBuffer: .setDynamic() has been deprecated. Use .setUsage() instead."),this.setUsage(!0===t?nt:et),this},Es.prototype.setArray=function(){console.error("THREE.InterleavedBuffer: .setArray has been removed. Use BufferGeometry .setAttribute to replace/resize attribute buffers")},go.prototype.getArrays=function(){console.error("THREE.ExtrudeGeometry: .getArrays() has been removed.")},go.prototype.addShapeList=function(){console.error("THREE.ExtrudeGeometry: .addShapeList() has been removed.")},go.prototype.addShape=function(){console.error("THREE.ExtrudeGeometry: .addShape() has been removed.")},Ts.prototype.dispose=function(){console.error("THREE.Scene: .dispose() has been removed.")},Zc.prototype.onUpdate=function(){return console.warn("THREE.Uniform: .onUpdate() has been removed. Use object.onBeforeRender() instead."),this},Object.defineProperties(Xe.prototype,{wrapAround:{get:function(){console.warn("THREE.Material: .wrapAround has been removed.")},set:function(){console.warn("THREE.Material: .wrapAround has been removed.")}},overdraw:{get:function(){console.warn("THREE.Material: .overdraw has been removed.")},set:function(){console.warn("THREE.Material: .overdraw has been removed.")}},wrapRGB:{get:function(){return console.warn("THREE.Material: .wrapRGB has been removed."),new tn}},shading:{get:function(){console.error("THREE."+this.type+": .shading has been removed. Use the boolean .flatShading instead.")},set:function(t){console.warn("THREE."+this.type+": .shading has been removed. Use the boolean .flatShading instead."),this.flatShading=1===t}},stencilMask:{get:function(){return console.warn("THREE."+this.type+": .stencilMask has been removed. Use .stencilFuncMask instead."),this.stencilFuncMask},set:function(t){console.warn("THREE."+this.type+": .stencilMask has been removed. Use .stencilFuncMask instead."),this.stencilFuncMask=t}}}),Object.defineProperties(Jn.prototype,{derivatives:{get:function(){return console.warn("THREE.ShaderMaterial: .derivatives has been moved to .extensions.derivatives."),this.extensions.derivatives},set:function(t){console.warn("THREE. ShaderMaterial: .derivatives has been moved to .extensions.derivatives."),this.extensions.derivatives=t}}}),ws.prototype.clearTarget=function(t,e,n,i){console.warn("THREE.WebGLRenderer: .clearTarget() has been deprecated. Use .setRenderTarget() and .clear() instead."),this.setRenderTarget(t),this.clear(e,n,i)},ws.prototype.animate=function(t){console.warn("THREE.WebGLRenderer: .animate() is now .setAnimationLoop()."),this.setAnimationLoop(t)},ws.prototype.getCurrentRenderTarget=function(){return console.warn("THREE.WebGLRenderer: .getCurrentRenderTarget() is now .getRenderTarget()."),this.getRenderTarget()},ws.prototype.getMaxAnisotropy=function(){return console.warn("THREE.WebGLRenderer: .getMaxAnisotropy() is now .capabilities.getMaxAnisotropy()."),this.capabilities.getMaxAnisotropy()},ws.prototype.getPrecision=function(){return console.warn("THREE.WebGLRenderer: .getPrecision() is now .capabilities.precision."),this.capabilities.precision},ws.prototype.resetGLState=function(){return console.warn("THREE.WebGLRenderer: .resetGLState() is now .state.reset()."),this.state.reset()},ws.prototype.supportsFloatTextures=function(){return console.warn("THREE.WebGLRenderer: .supportsFloatTextures() is now .extensions.get( 'OES_texture_float' )."),this.extensions.get("OES_texture_float")},ws.prototype.supportsHalfFloatTextures=function(){return console.warn("THREE.WebGLRenderer: .supportsHalfFloatTextures() is now .extensions.get( 'OES_texture_half_float' )."),this.extensions.get("OES_texture_half_float")},ws.prototype.supportsStandardDerivatives=function(){return console.warn("THREE.WebGLRenderer: .supportsStandardDerivatives() is now .extensions.get( 'OES_standard_derivatives' )."),this.extensions.get("OES_standard_derivatives")},ws.prototype.supportsCompressedTextureS3TC=function(){return console.warn("THREE.WebGLRenderer: .supportsCompressedTextureS3TC() is now .extensions.get( 'WEBGL_compressed_texture_s3tc' )."),this.extensions.get("WEBGL_compressed_texture_s3tc")},ws.prototype.supportsCompressedTexturePVRTC=function(){return console.warn("THREE.WebGLRenderer: .supportsCompressedTexturePVRTC() is now .extensions.get( 'WEBGL_compressed_texture_pvrtc' )."),this.extensions.get("WEBGL_compressed_texture_pvrtc")},ws.prototype.supportsBlendMinMax=function(){return console.warn("THREE.WebGLRenderer: .supportsBlendMinMax() is now .extensions.get( 'EXT_blend_minmax' )."),this.extensions.get("EXT_blend_minmax")},ws.prototype.supportsVertexTextures=function(){return console.warn("THREE.WebGLRenderer: .supportsVertexTextures() is now .capabilities.vertexTextures."),this.capabilities.vertexTextures},ws.prototype.supportsInstancedArrays=function(){return console.warn("THREE.WebGLRenderer: .supportsInstancedArrays() is now .extensions.get( 'ANGLE_instanced_arrays' )."),this.extensions.get("ANGLE_instanced_arrays")},ws.prototype.enableScissorTest=function(t){console.warn("THREE.WebGLRenderer: .enableScissorTest() is now .setScissorTest()."),this.setScissorTest(t)},ws.prototype.initMaterial=function(){console.warn("THREE.WebGLRenderer: .initMaterial() has been removed.")},ws.prototype.addPrePlugin=function(){console.warn("THREE.WebGLRenderer: .addPrePlugin() has been removed.")},ws.prototype.addPostPlugin=function(){console.warn("THREE.WebGLRenderer: .addPostPlugin() has been removed.")},ws.prototype.updateShadowMap=function(){console.warn("THREE.WebGLRenderer: .updateShadowMap() has been removed.")},ws.prototype.setFaceCulling=function(){console.warn("THREE.WebGLRenderer: .setFaceCulling() has been removed.")},ws.prototype.allocTextureUnit=function(){console.warn("THREE.WebGLRenderer: .allocTextureUnit() has been removed.")},ws.prototype.setTexture=function(){console.warn("THREE.WebGLRenderer: .setTexture() has been removed.")},ws.prototype.setTexture2D=function(){console.warn("THREE.WebGLRenderer: .setTexture2D() has been removed.")},ws.prototype.setTextureCube=function(){console.warn("THREE.WebGLRenderer: .setTextureCube() has been removed.")},ws.prototype.getActiveMipMapLevel=function(){return console.warn("THREE.WebGLRenderer: .getActiveMipMapLevel() is now .getActiveMipmapLevel()."),this.getActiveMipmapLevel()},Object.defineProperties(ws.prototype,{shadowMapEnabled:{get:function(){return this.shadowMap.enabled},set:function(t){console.warn("THREE.WebGLRenderer: .shadowMapEnabled is now .shadowMap.enabled."),this.shadowMap.enabled=t}},shadowMapType:{get:function(){return this.shadowMap.type},set:function(t){console.warn("THREE.WebGLRenderer: .shadowMapType is now .shadowMap.type."),this.shadowMap.type=t}},shadowMapCullFace:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMapCullFace has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMapCullFace has been removed. Set Material.shadowSide instead.")}},context:{get:function(){return console.warn("THREE.WebGLRenderer: .context has been removed. Use .getContext() instead."),this.getContext()}},vr:{get:function(){return console.warn("THREE.WebGLRenderer: .vr has been renamed to .xr"),this.xr}},gammaInput:{get:function(){return console.warn("THREE.WebGLRenderer: .gammaInput has been removed. Set the encoding for textures via Texture.encoding instead."),!1},set:function(){console.warn("THREE.WebGLRenderer: .gammaInput has been removed. Set the encoding for textures via Texture.encoding instead.")}},gammaOutput:{get:function(){return console.warn("THREE.WebGLRenderer: .gammaOutput has been removed. Set WebGLRenderer.outputEncoding instead."),!1},set:function(t){console.warn("THREE.WebGLRenderer: .gammaOutput has been removed. Set WebGLRenderer.outputEncoding instead."),this.outputEncoding=!0===t?Y:X}},toneMappingWhitePoint:{get:function(){return console.warn("THREE.WebGLRenderer: .toneMappingWhitePoint has been removed."),1},set:function(){console.warn("THREE.WebGLRenderer: .toneMappingWhitePoint has been removed.")}}}),Object.defineProperties(us.prototype,{cullFace:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMap.cullFace has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMap.cullFace has been removed. Set Material.shadowSide instead.")}},renderReverseSided:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderReverseSided has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderReverseSided has been removed. Set Material.shadowSide instead.")}},renderSingleSided:{get:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderSingleSided has been removed. Set Material.shadowSide instead.")},set:function(){console.warn("THREE.WebGLRenderer: .shadowMap.renderSingleSided has been removed. Set Material.shadowSide instead.")}}}),Object.defineProperties(Tt.prototype,{wrapS:{get:function(){return console.warn("THREE.WebGLRenderTarget: .wrapS is now .texture.wrapS."),this.texture.wrapS},set:function(t){console.warn("THREE.WebGLRenderTarget: .wrapS is now .texture.wrapS."),this.texture.wrapS=t}},wrapT:{get:function(){return console.warn("THREE.WebGLRenderTarget: .wrapT is now .texture.wrapT."),this.texture.wrapT},set:function(t){console.warn("THREE.WebGLRenderTarget: .wrapT is now .texture.wrapT."),this.texture.wrapT=t}},magFilter:{get:function(){return console.warn("THREE.WebGLRenderTarget: .magFilter is now .texture.magFilter."),this.texture.magFilter},set:function(t){console.warn("THREE.WebGLRenderTarget: .magFilter is now .texture.magFilter."),this.texture.magFilter=t}},minFilter:{get:function(){return console.warn("THREE.WebGLRenderTarget: .minFilter is now .texture.minFilter."),this.texture.minFilter},set:function(t){console.warn("THREE.WebGLRenderTarget: .minFilter is now .texture.minFilter."),this.texture.minFilter=t}},anisotropy:{get:function(){return console.warn("THREE.WebGLRenderTarget: .anisotropy is now .texture.anisotropy."),this.texture.anisotropy},set:function(t){console.warn("THREE.WebGLRenderTarget: .anisotropy is now .texture.anisotropy."),this.texture.anisotropy=t}},offset:{get:function(){return console.warn("THREE.WebGLRenderTarget: .offset is now .texture.offset."),this.texture.offset},set:function(t){console.warn("THREE.WebGLRenderTarget: .offset is now .texture.offset."),this.texture.offset=t}},repeat:{get:function(){return console.warn("THREE.WebGLRenderTarget: .repeat is now .texture.repeat."),this.texture.repeat},set:function(t){console.warn("THREE.WebGLRenderTarget: .repeat is now .texture.repeat."),this.texture.repeat=t}},format:{get:function(){return console.warn("THREE.WebGLRenderTarget: .format is now .texture.format."),this.texture.format},set:function(t){console.warn("THREE.WebGLRenderTarget: .format is now .texture.format."),this.texture.format=t}},type:{get:function(){return console.warn("THREE.WebGLRenderTarget: .type is now .texture.type."),this.texture.type},set:function(t){console.warn("THREE.WebGLRenderTarget: .type is now .texture.type."),this.texture.type=t}},generateMipmaps:{get:function(){return console.warn("THREE.WebGLRenderTarget: .generateMipmaps is now .texture.generateMipmaps."),this.texture.generateMipmaps},set:function(t){console.warn("THREE.WebGLRenderTarget: .generateMipmaps is now .texture.generateMipmaps."),this.texture.generateMipmaps=t}}}),Lc.prototype.load=function(t){console.warn("THREE.Audio: .load has been deprecated. Use THREE.AudioLoader instead.");const e=this;return(new vc).load(t,(function(t){e.setBuffer(t)})),this},Ic.prototype.getData=function(){return console.warn("THREE.AudioAnalyser: .getData() is now .getFrequencyData()."),this.getFrequencyData()},ti.prototype.updateCubeMap=function(t,e){return console.warn("THREE.CubeCamera: .updateCubeMap() is now .update()."),this.update(t,e)},ti.prototype.clear=function(t,e,n,i){return console.warn("THREE.CubeCamera: .clear() is now .renderTarget.clear()."),this.renderTarget.clear(t,e,n,i)},_t.crossOrigin=void 0,_t.loadTexture=function(t,e,n,i){console.warn("THREE.ImageUtils.loadTexture has been deprecated. Use THREE.TextureLoader() instead.");const r=new pl;r.setCrossOrigin(this.crossOrigin);const s=r.load(t,n,void 0,i);return e&&(s.mapping=e),s},_t.loadTextureCube=function(t,e,n,i){console.warn("THREE.ImageUtils.loadTextureCube has been deprecated. Use THREE.CubeTextureLoader() instead.");const r=new ul;r.setCrossOrigin(this.crossOrigin);const s=r.load(t,n,void 0,i);return e&&(s.mapping=e),s},_t.loadCompressedTexture=function(){console.error("THREE.ImageUtils.loadCompressedTexture has been removed. Use THREE.DDSLoader instead.")},_t.loadCompressedTextureCube=function(){console.error("THREE.ImageUtils.loadCompressedTextureCube has been removed. Use THREE.DDSLoader instead.")};const eu={createMultiMaterialObject:function(){console.error("THREE.SceneUtils has been moved to /examples/jsm/utils/SceneUtils.js")},detach:function(){console.error("THREE.SceneUtils has been moved to /examples/jsm/utils/SceneUtils.js")},attach:function(){console.error("THREE.SceneUtils has been moved to /examples/jsm/utils/SceneUtils.js")}};"undefined"!=typeof __THREE_DEVTOOLS__&&__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent("register",{detail:{revision:e}})),"undefined"!=typeof window&&(window.__THREE__?console.warn("WARNING: Multiple instances of Three.js being imported."):window.__THREE__=e),t.ACESFilmicToneMapping=4,t.AddEquation=n,t.AddOperation=2,t.AdditiveAnimationBlendMode=q,t.AdditiveBlending=2,t.AlphaFormat=1021,t.AlwaysDepth=1,t.AlwaysStencilFunc=519,t.AmbientLight=$l,t.AmbientLightProbe=xc,t.AnimationClip=nl,t.AnimationLoader=class extends ol{constructor(t){super(t)}load(t,e,n,i){const r=this,s=new cl(this.manager);s.setPath(this.path),s.setRequestHeader(this.requestHeader),s.setWithCredentials(this.withCredentials),s.load(t,(function(n){try{e(r.parse(JSON.parse(n)))}catch(e){i?i(e):console.error(e),r.manager.itemError(t)}}),n,i)}parse(t){const e=[];for(let n=0;n.99999)this.quaternion.set(0,0,0,1);else if(t.y<-.99999)this.quaternion.set(1,0,0,0);else{Sh.set(t.z,0,-t.x).normalize();const e=Math.acos(t.y);this.quaternion.setFromAxisAngle(Sh,e)}}setLength(t,e=.2*t,n=.2*e){this.line.scale.set(1,Math.max(1e-4,t-e),1),this.line.updateMatrix(),this.cone.scale.set(n,e,n),this.cone.position.y=t,this.cone.updateMatrix()}setColor(t){this.line.material.color.set(t),this.cone.material.color.set(t)}copy(t){return super.copy(t,!1),this.line.copy(t.line),this.cone.copy(t.cone),this}},t.Audio=Lc,t.AudioAnalyser=Ic,t.AudioContext=gc,t.AudioListener=class extends Ce{constructor(){super(),this.type="AudioListener",this.context=gc.getContext(),this.gain=this.context.createGain(),this.gain.connect(this.context.destination),this.filter=null,this.timeDelta=0,this._clock=new bc}getInput(){return this.gain}removeFilter(){return null!==this.filter&&(this.gain.disconnect(this.filter),this.filter.disconnect(this.context.destination),this.gain.connect(this.context.destination),this.filter=null),this}getFilter(){return this.filter}setFilter(t){return null!==this.filter?(this.gain.disconnect(this.filter),this.filter.disconnect(this.context.destination)):this.gain.disconnect(this.context.destination),this.filter=t,this.gain.connect(this.filter),this.filter.connect(this.context.destination),this}getMasterVolume(){return this.gain.gain.value}setMasterVolume(t){return this.gain.gain.setTargetAtTime(t,this.context.currentTime,.01),this}updateMatrixWorld(t){super.updateMatrixWorld(t);const e=this.context.listener,n=this.up;if(this.timeDelta=this._clock.getDelta(),this.matrixWorld.decompose(Sc,Tc,Ec),Ac.set(0,0,-1).applyQuaternion(Tc),e.positionX){const t=this.context.currentTime+this.timeDelta;e.positionX.linearRampToValueAtTime(Sc.x,t),e.positionY.linearRampToValueAtTime(Sc.y,t),e.positionZ.linearRampToValueAtTime(Sc.z,t),e.forwardX.linearRampToValueAtTime(Ac.x,t),e.forwardY.linearRampToValueAtTime(Ac.y,t),e.forwardZ.linearRampToValueAtTime(Ac.z,t),e.upX.linearRampToValueAtTime(n.x,t),e.upY.linearRampToValueAtTime(n.y,t),e.upZ.linearRampToValueAtTime(n.z,t)}else e.setPosition(Sc.x,Sc.y,Sc.z),e.setOrientation(Ac.x,Ac.y,Ac.z,n.x,n.y,n.z)}},t.AudioLoader=vc,t.AxesHelper=Ah,t.AxisHelper=function(t){return console.warn("THREE.AxisHelper has been renamed to THREE.AxesHelper."),new Ah(t)},t.BackSide=1,t.BasicDepthPacking=3200,t.BasicShadowMap=0,t.BinaryTextureLoader=function(t){return console.warn("THREE.BinaryTextureLoader has been renamed to THREE.DataTextureLoader."),new dl(t)},t.Bone=ta,t.BooleanKeyframeTrack=Zo,t.BoundingBoxHelper=function(t,e){return console.warn("THREE.BoundingBoxHelper has been deprecated. Creating a THREE.BoxHelper instead."),new Mh(t,e)},t.Box2=eh,t.Box3=Pt,t.Box3Helper=class extends ya{constructor(t,e=16776960){const n=new Uint16Array([0,1,1,2,2,3,3,0,4,5,5,6,6,7,7,4,0,4,1,5,2,6,3,7]),i=new En;i.setIndex(new sn(n,1)),i.setAttribute("position",new mn([1,1,1,-1,1,1,-1,-1,1,1,-1,1,1,1,-1,-1,1,-1,-1,-1,-1,1,-1,-1],3)),super(i,new ca({color:e,toneMapped:!1})),this.box=t,this.type="Box3Helper",this.geometry.computeBoundingSphere()}updateMatrixWorld(t){const e=this.box;e.isEmpty()||(e.getCenter(this.position),e.getSize(this.scale),this.scale.multiplyScalar(.5),super.updateMatrixWorld(t))}},t.BoxBufferGeometry=qn,t.BoxGeometry=qn,t.BoxHelper=Mh,t.BufferAttribute=sn,t.BufferGeometry=En,t.BufferGeometryLoader=oc,t.ByteType=1010,t.Cache=rl,t.Camera=Qn,t.CameraHelper=class extends ya{constructor(t){const e=new En,n=new ca({color:16777215,vertexColors:!0,toneMapped:!1}),i=[],r=[],s={},a=new tn(16755200),o=new tn(16711680),l=new tn(43775),c=new tn(16777215),h=new tn(3355443);function u(t,e,n){d(t,n),d(e,n)}function d(t,e){i.push(0,0,0),r.push(e.r,e.g,e.b),void 0===s[t]&&(s[t]=[]),s[t].push(i.length/3-1)}u("n1","n2",a),u("n2","n4",a),u("n4","n3",a),u("n3","n1",a),u("f1","f2",a),u("f2","f4",a),u("f4","f3",a),u("f3","f1",a),u("n1","f1",a),u("n2","f2",a),u("n3","f3",a),u("n4","f4",a),u("p","n1",o),u("p","n2",o),u("p","n3",o),u("p","n4",o),u("u1","u2",l),u("u2","u3",l),u("u3","u1",l),u("c","t",c),u("p","c",h),u("cn1","cn2",h),u("cn3","cn4",h),u("cf1","cf2",h),u("cf3","cf4",h),e.setAttribute("position",new mn(i,3)),e.setAttribute("color",new mn(r,3)),super(e,n),this.type="CameraHelper",this.camera=t,this.camera.updateProjectionMatrix&&this.camera.updateProjectionMatrix(),this.matrix=t.matrixWorld,this.matrixAutoUpdate=!1,this.pointMap=s,this.update()}update(){const t=this.geometry,e=this.pointMap;_h.projectionMatrixInverse.copy(this.camera.projectionMatrixInverse),wh("c",e,t,_h,0,0,-1),wh("t",e,t,_h,0,0,1),wh("n1",e,t,_h,-1,-1,-1),wh("n2",e,t,_h,1,-1,-1),wh("n3",e,t,_h,-1,1,-1),wh("n4",e,t,_h,1,1,-1),wh("f1",e,t,_h,-1,-1,1),wh("f2",e,t,_h,1,-1,1),wh("f3",e,t,_h,-1,1,1),wh("f4",e,t,_h,1,1,1),wh("u1",e,t,_h,.7,1.1,-1),wh("u2",e,t,_h,-.7,1.1,-1),wh("u3",e,t,_h,0,2,-1),wh("cf1",e,t,_h,-1,0,1),wh("cf2",e,t,_h,1,0,1),wh("cf3",e,t,_h,0,-1,1),wh("cf4",e,t,_h,0,1,1),wh("cn1",e,t,_h,-1,0,-1),wh("cn2",e,t,_h,1,0,-1),wh("cn3",e,t,_h,0,-1,-1),wh("cn4",e,t,_h,0,1,-1),t.getAttribute("position").needsUpdate=!0}dispose(){this.geometry.dispose(),this.material.dispose()}},t.CanvasRenderer=function(){console.error("THREE.CanvasRenderer has been removed")},t.CanvasTexture=Ra,t.CatmullRomCurve3=bl,t.CineonToneMapping=3,t.CircleBufferGeometry=Pa,t.CircleGeometry=Pa,t.ClampToEdgeWrapping=u,t.Clock=bc,t.Color=tn,t.ColorKeyframeTrack=Jo,t.CompressedTexture=La,t.CompressedTextureLoader=class extends ol{constructor(t){super(t)}load(t,e,n,i){const r=this,s=[],a=new La,o=new cl(this.manager);o.setPath(this.path),o.setResponseType("arraybuffer"),o.setRequestHeader(this.requestHeader),o.setWithCredentials(r.withCredentials);let l=0;function c(c){o.load(t[c],(function(t){const n=r.parse(t,!0);s[c]={width:n.width,height:n.height,format:n.format,mipmaps:n.mipmaps},l+=1,6===l&&(1===n.mipmapCount&&(a.minFilter=g),a.image=s,a.format=n.format,a.needsUpdate=!0,e&&e(a))}),n,i)}if(Array.isArray(t))for(let e=0,n=t.length;e>16&32768,i=e>>12&2047;const r=e>>23&255;return r<103?n:r>142?(n|=31744,n|=(255==r?0:1)&&8388607&e,n):r<113?(i|=2048,n|=(i>>114-r)+(i>>113-r&1),n):(n|=r-112<<10|i>>1,n+=1&i,n)}},t.DecrementStencilOp=7683,t.DecrementWrapStencilOp=34056,t.DefaultLoadingManager=al,t.DepthFormat=A,t.DepthStencilFormat=L,t.DepthTexture=Ca,t.DirectionalLight=Kl,t.DirectionalLightHelper=class extends Ce{constructor(t,e,n){super(),this.light=t,this.light.updateMatrixWorld(),this.matrix=t.matrixWorld,this.matrixAutoUpdate=!1,this.color=n,void 0===e&&(e=1);let i=new En;i.setAttribute("position",new mn([-e,e,0,e,e,0,e,-e,0,-e,-e,0,-e,e,0],3));const r=new ca({fog:!1,toneMapped:!1});this.lightPlane=new fa(i,r),this.add(this.lightPlane),i=new En,i.setAttribute("position",new mn([0,0,0,0,0,1],3)),this.targetLine=new fa(i,r),this.add(this.targetLine),this.update()}dispose(){this.lightPlane.geometry.dispose(),this.lightPlane.material.dispose(),this.targetLine.geometry.dispose(),this.targetLine.material.dispose()}update(){gh.setFromMatrixPosition(this.light.matrixWorld),vh.setFromMatrixPosition(this.light.target.matrixWorld),yh.subVectors(vh,gh),this.lightPlane.lookAt(vh),void 0!==this.color?(this.lightPlane.material.color.set(this.color),this.targetLine.material.color.set(this.color)):(this.lightPlane.material.color.copy(this.light.color),this.targetLine.material.color.copy(this.light.color)),this.targetLine.lookAt(vh),this.targetLine.scale.z=yh.length()}},t.DiscreteInterpolant=Xo,t.DodecahedronBufferGeometry=Ba,t.DodecahedronGeometry=Ba,t.DoubleSide=2,t.DstAlphaFactor=206,t.DstColorFactor=208,t.DynamicBufferAttribute=function(t,e){return console.warn("THREE.DynamicBufferAttribute has been removed. Use new THREE.BufferAttribute().setUsage( THREE.DynamicDrawUsage ) instead."),new sn(t,e).setUsage(nt)},t.DynamicCopyUsage=35050,t.DynamicDrawUsage=nt,t.DynamicReadUsage=35049,t.EdgesGeometry=Ga,t.EdgesHelper=function(t,e){return console.warn("THREE.EdgesHelper has been removed. Use THREE.EdgesGeometry instead."),new ya(new Ga(t.geometry),new ca({color:void 0!==e?e:16777215}))},t.EllipseCurve=fl,t.EqualDepth=4,t.EqualStencilFunc=514,t.EquirectangularReflectionMapping=a,t.EquirectangularRefractionMapping=o,t.Euler=fe,t.EventDispatcher=rt,t.ExtrudeBufferGeometry=go,t.ExtrudeGeometry=go,t.FaceColors=1,t.FileLoader=cl,t.FlatShading=1,t.Float16BufferAttribute=pn,t.Float32Attribute=function(t,e){return console.warn("THREE.Float32Attribute has been removed. Use new THREE.Float32BufferAttribute() instead."),new mn(t,e)},t.Float32BufferAttribute=mn,t.Float64Attribute=function(t,e){return console.warn("THREE.Float64Attribute has been removed. Use new THREE.Float64BufferAttribute() instead."),new fn(t,e)},t.Float64BufferAttribute=fn,t.FloatType=b,t.Fog=Ss,t.FogExp2=Ms,t.Font=pc,t.FontLoader=class extends ol{constructor(t){super(t)}load(t,e,n,i){const r=this,s=new cl(this.manager);s.setPath(this.path),s.setRequestHeader(this.requestHeader),s.setWithCredentials(r.withCredentials),s.load(t,(function(t){let n;try{n=JSON.parse(t)}catch(e){console.warn("THREE.FontLoader: typeface.js support is being deprecated. Use typeface.json instead."),n=JSON.parse(t.substring(65,t.length-2))}const i=r.parse(n);e&&e(i)}),n,i)}parse(t){return new pc(t)}},t.FrontSide=0,t.Frustum=ai,t.GLBufferAttribute=Qc,t.GLSL1="100",t.GLSL3=it,t.GammaEncoding=Z,t.GreaterDepth=6,t.GreaterEqualDepth=5,t.GreaterEqualStencilFunc=518,t.GreaterStencilFunc=516,t.GridHelper=fh,t.Group=gs,t.HalfFloatType=M,t.HemisphereLight=Ol,t.HemisphereLightHelper=class extends Ce{constructor(t,e,n){super(),this.light=t,this.light.updateMatrixWorld(),this.matrix=t.matrixWorld,this.matrixAutoUpdate=!1,this.color=n;const i=new _o(e);i.rotateY(.5*Math.PI),this.material=new en({wireframe:!0,fog:!1,toneMapped:!1}),void 0===this.color&&(this.material.vertexColors=!0);const r=i.getAttribute("position"),s=new Float32Array(3*r.count);i.setAttribute("color",new sn(s,3)),this.add(new Wn(i,this.material)),this.update()}dispose(){this.children[0].geometry.dispose(),this.children[0].material.dispose()}update(){const t=this.children[0];if(void 0!==this.color)this.material.color.set(this.color);else{const e=t.geometry.getAttribute("color");ph.copy(this.light.color),mh.copy(this.light.groundColor);for(let t=0,n=e.count;t0){const n=new sl(e);r=new hl(n),r.setCrossOrigin(this.crossOrigin);for(let e=0,n=t.length;e\n\n\t\t\tvec3 getSample( float theta, vec3 axis ) {\n\n\t\t\t\tfloat cosTheta = cos( theta );\n\t\t\t\t// Rodrigues' axis-angle rotation\n\t\t\t\tvec3 sampleDirection = vOutputDirection * cosTheta\n\t\t\t\t\t+ cross( axis, vOutputDirection ) * sin( theta )\n\t\t\t\t\t+ axis * dot( axis, vOutputDirection ) * ( 1.0 - cosTheta );\n\n\t\t\t\treturn bilinearCubeUV( envMap, sampleDirection, mipInt );\n\n\t\t\t}\n\n\t\t\tvoid main() {\n\n\t\t\t\tvec3 axis = latitudinal ? poleAxis : cross( poleAxis, vOutputDirection );\n\n\t\t\t\tif ( all( equal( axis, vec3( 0.0 ) ) ) ) {\n\n\t\t\t\t\taxis = vec3( vOutputDirection.z, 0.0, - vOutputDirection.x );\n\n\t\t\t\t}\n\n\t\t\t\taxis = normalize( axis );\n\n\t\t\t\tgl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );\n\t\t\t\tgl_FragColor.rgb += weights[ 0 ] * getSample( 0.0, axis );\n\n\t\t\t\tfor ( int i = 1; i < n; i++ ) {\n\n\t\t\t\t\tif ( i >= samples ) {\n\n\t\t\t\t\t\tbreak;\n\n\t\t\t\t\t}\n\n\t\t\t\t\tfloat theta = dTheta * float( i );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( -1.0 * theta, axis );\n\t\t\t\t\tgl_FragColor.rgb += weights[ i ] * getSample( theta, axis );\n\n\t\t\t\t}\n\n\t\t\t\tgl_FragColor = linearToOutputTexel( gl_FragColor );\n\n\t\t\t}\n\t\t`,blending:0,depthTest:!1,depthWrite:!1})}(Ih),this._equirectShader=null,this._cubemapShader=null,this._compileMaterial(this._blurMaterial)}fromScene(t,e=0,n=.1,i=100){kh=this._renderer.getRenderTarget();const r=this._allocateTargets();return this._sceneToCubeUV(t,n,i,r),e>0&&this._blur(r,0,0,e),this._applyPMREM(r),this._cleanup(r),r}fromEquirectangular(t){return this._fromTexture(t)}fromCubemap(t){return this._fromTexture(t)}compileCubemapShader(){null===this._cubemapShader&&(this._cubemapShader=Kh(),this._compileMaterial(this._cubemapShader))}compileEquirectangularShader(){null===this._equirectShader&&(this._equirectShader=Qh(),this._compileMaterial(this._equirectShader))}dispose(){this._blurMaterial.dispose(),null!==this._cubemapShader&&this._cubemapShader.dispose(),null!==this._equirectShader&&this._equirectShader.dispose();for(let t=0;t2?Ch:0,Ch,Ch),o.setRenderTarget(i),u&&o.render(zh,r),o.render(t,r)}o.toneMapping=h,o.outputEncoding=c,o.autoClear=l}_textureToCubeUV(t,e){const n=this._renderer;t.isCubeTexture?null==this._cubemapShader&&(this._cubemapShader=Kh()):null==this._equirectShader&&(this._equirectShader=Qh());const i=t.isCubeTexture?this._cubemapShader:this._equirectShader,r=new Wn(Oh[0],i),s=i.uniforms;s.envMap.value=t,t.isCubeTexture||s.texelSize.value.set(1/t.image.width,1/t.image.height),s.inputEncoding.value=Nh[t.encoding],s.outputEncoding.value=Nh[e.texture.encoding],Jh(e,0,0,3*Ch,2*Ch),n.setRenderTarget(e),n.render(r,Fh)}_applyPMREM(t){const e=this._renderer,n=e.autoClear;e.autoClear=!1;for(let e=1;eIh&&console.warn(`sigmaRadians, ${r}, is too large and will clip, as it requested ${m} samples when the maximum is set to 20`);const f=[];let g=0;for(let t=0;t4?i-8+4:0),3*v,2*v),o.setRenderTarget(e),o.render(c,Fh)}},t.ParametricBufferGeometry=wo,t.ParametricGeometry=wo,t.Particle=function(t){return console.warn("THREE.Particle has been renamed to THREE.Sprite."),new Vs(t)},t.ParticleBasicMaterial=function(t){return console.warn("THREE.ParticleBasicMaterial has been renamed to THREE.PointsMaterial."),new _a(t)},t.ParticleSystem=function(t,e){return console.warn("THREE.ParticleSystem has been renamed to THREE.Points."),new Ta(t,e)},t.ParticleSystemMaterial=function(t){return console.warn("THREE.ParticleSystemMaterial has been renamed to THREE.PointsMaterial."),new _a(t)},t.Path=Bl,t.PerspectiveCamera=Kn,t.Plane=Ne,t.PlaneBufferGeometry=ci,t.PlaneGeometry=ci,t.PlaneHelper=class extends fa{constructor(t,e=1,n=16776960){const i=n,r=new En;r.setAttribute("position",new mn([1,-1,1,-1,1,1,-1,-1,1,1,1,1,-1,1,1,-1,-1,1,1,-1,1,1,1,1,0,0,1,0,0,0],3)),r.computeBoundingSphere(),super(r,new ca({color:i,toneMapped:!1})),this.type="PlaneHelper",this.plane=t,this.size=e;const s=new En;s.setAttribute("position",new mn([1,1,1,-1,1,1,-1,-1,1,1,1,1,-1,-1,1,1,-1,1],3)),s.computeBoundingSphere(),this.add(new Wn(s,new en({color:i,opacity:.2,transparent:!0,depthWrite:!1,toneMapped:!1})))}updateMatrixWorld(t){let e=-this.plane.constant;Math.abs(e)<1e-8&&(e=1e-8),this.scale.set(.5*this.size,.5*this.size,e),this.children[0].material.side=e<0?1:0,this.lookAt(this.plane.normal),super.updateMatrixWorld(t)}},t.PointCloud=function(t,e){return console.warn("THREE.PointCloud has been renamed to THREE.Points."),new Ta(t,e)},t.PointCloudMaterial=function(t){return console.warn("THREE.PointCloudMaterial has been renamed to THREE.PointsMaterial."),new _a(t)},t.PointLight=Zl,t.PointLightHelper=class extends Wn{constructor(t,e,n){super(new So(e,4,2),new en({wireframe:!0,fog:!1,toneMapped:!1})),this.light=t,this.light.updateMatrixWorld(),this.color=n,this.type="PointLightHelper",this.matrix=this.light.matrixWorld,this.matrixAutoUpdate=!1,this.update()}dispose(){this.geometry.dispose(),this.material.dispose()}update(){void 0!==this.color?this.material.color.set(this.color):this.material.color.copy(this.light.color)}},t.Points=Ta,t.PointsMaterial=_a,t.PolarGridHelper=class extends ya{constructor(t=10,e=16,n=8,i=64,r=4473924,s=8947848){r=new tn(r),s=new tn(s);const a=[],o=[];for(let n=0;n<=e;n++){const i=n/e*(2*Math.PI),l=Math.sin(i)*t,c=Math.cos(i)*t;a.push(0,0,0),a.push(l,0,c);const h=1&n?r:s;o.push(h.r,h.g,h.b),o.push(h.r,h.g,h.b)}for(let e=0;e<=n;e++){const l=1&e?r:s,c=t-t/n*e;for(let t=0;t { + if (ok) console.log(` ✓ ${name}`); + else { + failures++; + console.log(` ✗ ${name}${detail ? " — " + detail : ""}`); + } +}; + +// ── 1) whenReady 必须消费 silentStart ───────────────────────────────── +const readyIdx = src.indexOf("app.whenReady().then"); +check("能定位 whenReady", readyIdx > 0); +const readyBlock = src.slice(readyIdx, src.indexOf("app.on(\"before-quit\"", readyIdx)); + +check( + "whenReady 读取 prefs.silentStart", + /loadGuiPrefs\(\)[\s\S]{0,40}silentStart|\.silentStart/.test(readyBlock), + "whenReady 里没有读 silentStart ⇒ 开关是死的", +); +check( + "whenReady 按 silent 决定是否显示", + /createWindow\(\s*!silent\s*\)/.test(readyBlock), + "没有 createWindow(!silent) ⇒ 无条件弹窗", +); + +// ── 2) 静默必须「创建但隐藏」,不是不创建 ───────────────────────────── +const createIdx = src.indexOf("function createWindow("); +check("能定位 createWindow", createIdx > 0); +const createBlock = src.slice(createIdx, src.indexOf("\nfunction ", createIdx + 10)); + +check( + "createWindow 支持 show 参数", + /function createWindow\(\s*show\s*=\s*true\s*\)/.test(createBlock), + "签名不是 createWindow(show = true)", +); +check( + "BrowserWindow 初始 show:false(避免闪一下再隐藏)", + /show:\s*false/.test(createBlock), + "没用 show:false ⇒ 先显示再隐藏会闪", +); +check( + "非静默时经 ready-to-show 显示", + /ready-to-show[\s\S]{0,120}show\(\)/.test(createBlock), + "没有 ready-to-show → show(),非静默启动可能白屏闪现", +); +check( + "★ 静默路径仍然创建窗口(渲染进程要启动)", + /createWindow\(\s*!silent\s*\)/.test(readyBlock) && + /function createWindow\(\s*show\s*=\s*true\s*\)/.test(createBlock), + "静默时若不建窗口,SSE/设备桥/聊天全废", +); + +// ── 3) showMainWindow 必须能在窗口缺失时重建 ────────────────────────── +const showIdx = src.indexOf("function showMainWindow("); +check("能定位 showMainWindow", showIdx > 0); +const showBlock = src.slice(showIdx, src.indexOf("\nfunction ", showIdx + 10)); + +check( + "★ showMainWindow 在窗口不存在时会重建", + /createWindow\(\s*true\s*\)/.test(showBlock), + "只调 show() 不创建 ⇒ 托盘菜单点了没反应,进程成僵尸(activate 事件仅 macOS 触发)", +); +check( + "showMainWindow 对隐藏窗口调 show()", + /isVisible\(\)[\s\S]{0,60}show\(\)/.test(showBlock), + "未处理 isVisible() ⇒ 静默创建的窗口可能无法被唤起", +); + +// ── 4) loadGuiPrefs 必须去 BOM ──────────────────────────────────────── +const prefsIdx = src.indexOf("function loadGuiPrefs("); +check("能定位 loadGuiPrefs", prefsIdx > 0); +const prefsBlock = src.slice(prefsIdx, src.indexOf("\nfunction ", prefsIdx + 10)); + +check( + "★ loadGuiPrefs 读取时去 UTF-8 BOM", + /readFileSync\(GUI_PREFS_FILE[\s\S]{0,80}replace\(\s*\/\^\\uFEFF\//.test(prefsBlock), + "未去 BOM ⇒ Windows 工具(PowerShell Set-Content -Encoding UTF8/记事本)改过文件后," + + "JSON.parse 抛错 → 返回默认 prefs → 所有偏好静默失效", +); + +// ── 5) loadConnections 的 BOM 处理不能被回退 ───────────────────────── +const connIdx = src.indexOf("function loadConnections("); +const connBlock = src.slice(connIdx, src.indexOf("\nfunction ", connIdx + 10)); +check( + "loadConnections 仍去 BOM(既有行为不回退)", + /replace\(\s*\/\^\\uFEFF\//.test(connBlock), + "loadConnections 的 BOM 处理被移除了", +); + +console.log(""); +if (failures > 0) { + console.log(`全部失败:${failures} 条`); + process.exit(1); +} +console.log("全部通过"); \ No newline at end of file diff --git a/cmd/homed-centroid/main.go b/cmd/homed-centroid/main.go new file mode 100644 index 00000000..1b90cf71 --- /dev/null +++ b/cmd/homed-centroid/main.go @@ -0,0 +1,69 @@ +// homed-centroid 重建中心向量。 +// +// ★ 为什么需要独立命令 +// --------------------- +// RebuildCentroid 此前**只有测试在调用**,生产路径没有调用者: +// homed-graph-migrate 与 homed-graph-distill 都已接上自动重建, +// 但仍需要独立入口处理这几种情况: +// +// - 向量回填之后(回填脚本不重建中心) +// - 换了 provider(指纹变了,旧中心自动失效但没人建新的) +// - 库增长后中心过期(中心是块集的均值,块集变了它就偏) +// +// 用法: +// homed-centroid -db # 自动取库内指纹 +// homed-centroid -db -fingerprint # 显式指定 +package main + +import ( + "flag" + "fmt" + "os" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +func main() { + dbPath := flag.String("db", "", "graph.db 路径") + explicitFP := flag.String("fingerprint", "", "provider 指纹(缺省取库内最多的)") + flag.Parse() + + if *dbPath == "" { + fmt.Fprintln(os.Stderr, "错误:需要 -db") + os.Exit(1) + } + db, err := memory.NewGraphDB(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:打开 %s 失败: %v\n", *dbPath, err) + os.Exit(1) + } + defer func() { _ = db.Close() }() + + fp := *explicitFP + if fp == "" { + fp, err = db.DominantBlockFingerprint() + if err != nil { + fmt.Fprintf(os.Stderr, "错误:读取库内指纹失败: %v\n", err) + os.Exit(1) + } + if fp == "" { + fmt.Fprintln(os.Stderr, + "错误:库内没有带指纹的块 —— 先跑 homed-graph-migrate -apply 写入向量") + os.Exit(1) + } + fmt.Printf("用库内指纹:%s\n", fp) + } + + c, anomalous, err := db.RebuildCentroid(fp) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:重建中心失败: %v\n", err) + os.Exit(1) + } + st := c.Stats() + fmt.Printf("已重建中心:均值向量长度 %.4f,平均两两余弦 %.4f\n", + st.MeanLength, st.AvgPairCosine) + fmt.Printf(" 启用双边中心化: %v\n", anomalous) + if !anomalous { + fmt.Println(" (各向异性不显著,召回不会做中心化;中心已存备用)") + } +} \ No newline at end of file diff --git a/cmd/homed-graph-distill/main.go b/cmd/homed-graph-distill/main.go new file mode 100644 index 00000000..7d43112c --- /dev/null +++ b/cmd/homed-graph-distill/main.go @@ -0,0 +1,382 @@ +// Command homed-graph-distill 把存量实体跑字段拆分、落成块节点。 +// +// 背景 +// ------ +// MigrateLegacyTextEntities 把 entities 迁成了块(a3e4c1c/09f31e2),但迁移 +// 刻意**不做内容改写** —— 迁出来的块仍是整句: +// +// 「下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周」 +// +// 实测这种形态在值覆盖维度上答错(真实 chineseclip,真库 188 块): +// +// 查询「值班室分机号是多少」→ top1 = 旧号 4379(score 0.8127) +// 新号 4324 那条进不了 top8 +// +// 归因实验(改写形态重算向量)证实病根是**长复合句**把新旧值混在一句里: +// +// 现状·旧号(短句) cos=0.8127 +// 现状·新号(长复合句)cos=0.7327 ← 正确但排第二 +// 改写·新号(短句) cos=0.9284 ← 形态一变跃升 0.20 +// +// 本命令做那一步改写:LLM 拆字段 → 「<主语>|<维度>=<值>」块 → 向量。 +// 拆完的块才带得到仲裁(arbitration.go 需要 主语|维度=值 才解析得出)。 +// +// 安全设计(与 homed-graph-migrate / homed-kb-migrate 同款) +// -------------------------------------------------------- +// 1. **默认只报告**(-apply 才真写)。 +// 2. 写入前自动快照 graph.db。 +// 3. **不删任何东西**:整句块保留(它们是原始素材,回退路径)。 +// 拆分块是新增的独立块(ID 由内容派生,重复跑幂等)。 +// 4. 拆不动就记「零字段」并跳过,不编造。 +// +// 用法: +// +// homed-graph-distill -db /data/homeagent/memory/graph.db # 报告 +// homed-graph-distill -db ... -apply # 拆分落块 +// homed-graph-distill -db ... -apply -limit 20 # 先试 20 条 +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/distill" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" + "gitcode.com/JianFeeeee/HomeAgent/pkg/embedding" + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" + _ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip" + _ "gitcode.com/JianFeeeee/HomeAgent/providers/ollama" +) + +func main() { + dbPath := flag.String("db", "", "graph.db 路径(必填)") + apply := flag.Bool("apply", false, "真正拆分落块(缺省只报告)") + limit := flag.Int("limit", 0, "最多处理多少条实体,0 = 不限") + // minLen 过滤掉太短的实体(标题类如「order-gw 运维进展」「每天」 + // 本来就没有可拆字段,跑它们只会浪费模型时间并让报告全是「零字段」)。 + // + // ★ 实测(真库):前 8 条实体全是这类短标题,拆分结果 8/8 零字段 —— + // 那是**正确**结果,不是故障。不加这个过滤会误以为拆分坏了。 + minLen := flag.Int("min-len", 18, "实体名最小字符数,短于此的跳过") + embedProvider := flag.String("embed-provider", "chineseclip", "向量 provider 名") + modelDir := flag.String("model-dir", "", "向量 provider 的模型目录") + genModel := flag.String("gen-model", "qwen3:1.7b", "拆分用的生成模型") + flag.Parse() + + if *dbPath == "" { + fmt.Fprintln(os.Stderr, "错误:必须指定 -db ") + os.Exit(2) + } + if _, err := os.Stat(*dbPath); err != nil { + fmt.Fprintf(os.Stderr, "错误:打不开 %s: %v\n", *dbPath, err) + os.Exit(2) + } + + db, err := memory.NewGraphDB(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:打开图库失败: %v\n", err) + os.Exit(1) + } + defer db.Close() + + entities, err := loadEntities(db, *limit, *minLen) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:读取实体失败: %v\n", err) + os.Exit(1) + } + if len(entities) == 0 { + fmt.Println("库里没有 entities,无需拆分。") + return + } + + stats, _ := db.BlockVectorStats() + fmt.Printf("待处理实体 %d 条;现有块 %d(带向量 %d)\n", len(entities), stats.Total, stats.WithVector) + + // ★ 只对**有主语**的实体跑拆分。 + // + // 实测(生产库 429 条实体): + // + // 有主语 5 条(1%),无主语 424 条(99%) + // 路径/URL 38 条 全无主语 + // 符号开头 2 条 全无主语 + // 其他 387 条 98.7% 无主语 + // + // 而 ha-c 的主力句式「第114批…」「值班室分机号 4324」在生产库 + // **一条都没有**(含「第N批」0 条、含「分机」0 条)。 + // + // 无主语的实体跑 LLM 是纯浪费:Split 里 + // if len(subjects) == 0 { continue } + // 会把 LLM 已经拆对的字段**全部丢弃**(实测 60 条 / 837 秒 / 0 产出)。 + // + // ⇒ 这里先过滤,并在报告里说明跳过了多少 —— 让「没产出」这件事 + // 变成可解释的数字,而不是又一次「零字段 N 条」的谜。 + withSubj := make([]legacyEntity, 0, len(entities)) + noSubj := make([]legacyEntity, 0, len(entities)) + for _, e := range entities { + if len(distill.DeriveSubjects(e.name)) > 0 { + withSubj = append(withSubj, e) + } else { + noSubj = append(noSubj, e) + } + } + if len(noSubj) > 0 { + fmt.Printf("\n★ 跳过无主语实体 %d 条(%.0f%%):Split 的第五道闸门"+ + "「无主语不写悬空属性」会把 LLM 已拆对的字段全部丢弃。\n", + len(noSubj), float64(len(noSubj))/float64(len(entities))*100) + for i, e := range noSubj { + if i >= 3 { + fmt.Printf(" … 另 %d 条\n", len(noSubj)-3) + break + } + fmt.Printf(" · %s\n", truncForLog(e.name, 56)) + } + } + entities = withSubj + if len(entities) == 0 { + fmt.Println("\n没有可拆分的实体(有主语的为 0)。" + + "这些内容应保持整句块形态 —— 迁移已把它们写成 legacy-entity 块。") + return + } + fmt.Printf("\n实际处理 %d 条(其余 %d 条无主语,保持整句块形态)\n", + len(entities), len(noSubj)) + + // 打开两个 provider + gen, err := generation.Open("ollama", generation.Config{ + Options: map[string]string{ + "model": *genModel, "num_thread": "10", + "think": "false", "keep_alive": "30m", + }}) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:打开生成 provider 失败: %v\n", err) + os.Exit(1) + } + defer gen.Close() + + adapter, err := buildEmbedder(*embedProvider, *modelDir) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:打开向量 provider 失败: %v\n", err) + os.Exit(1) + } + defer adapter.Close() + embed := func(text string) ([]float64, string) { + vec, err := adapter.VectorizeDense(text) + if err != nil { + return nil, "" + } + return vec, adapter.Fingerprint() + } + + ex := distill.NewExtractor(gen, nil) + + // ── 干跑:先只统计,不写 ── + // ★ 拆一次,落库复用同一份结果。 + // + // 初版在 -apply 分支里又调了一次 ex.Blocks —— 每条记录跑两遍模型。 + // 两个后果,都实测撞到了: + // 1. 耗时翻倍:真库 188 条在 CPU 上第一轮就超了 2400s 超时上限。 + // 2. **结果可能不一致**:qwen3:1.7b 在 Temperature=0 下仍有波动, + // 干跑报告的字段与落库的实际字段可能不是同一批 —— + // 报告与数据对不上,整个命令的可信度就没了。 + // + // 所以 payload 在这里算一次并保留,-apply 直接用它落库。 + type splitResult struct { + payload *distill.BlockPayload + err error + } + results := make([]splitResult, len(entities)) + triples := 0 + zeroField := 0 + failed := 0 + t0 := time.Now() + for i, e := range entities { + payload, err := ex.Blocks(context.Background(), e.name) + if err != nil { + results[i] = splitResult{err: err} + failed++ + } else { + results[i] = splitResult{payload: payload} + n := len(payload.Fields) + if n == 0 { + zeroField++ + } else { + triples += n + } + } + // ★ 进度必须实时可见:这活儿在 CPU 上要十几分钟, + // 没有进度就只能等超时(第一版就是这么废掉的 —— 2400s 超时, + // 跑完的 35 条结果全丢)。每 10 条或每 20 秒打一行。 + if (i+1)%10 == 0 || i+1 == len(entities) { + el := time.Since(t0).Seconds() + rate := float64(i+1) / el + eta := 0.0 + if rate > 0 { + eta = float64(len(entities)-i-1) / rate + } + fmt.Printf(" [%d/%d] %s 用时%.0fs 预计剩余%.0fs\n", + i+1, len(entities), truncForLog(e.name, 26), el, eta) + } + // ★ 前 8 条无条件打印 —— 包括「报错」和「零字段」两种情况。 + // + // 原版只在 `payload != nil` 时打印字段,于是: + // - 报错 → payload 为 nil → 什么都不打印 + // - 零字段 → Fields 为空 → 什么都不打印 + // 而生产蒸馏实测「零字段 60 条」,屏幕上**一行内容都没有** + // —— 判据能显示 0,是因为它只看得到 0。 + // + // 实测故障根因:ollama schema 污染让模型返回 + // {"fields":[{"name":"text","value":"commit f91b27a…"}]} + // 这种「字段名=字段类型、值=整句」的垃圾,被三道闸门全拦掉 ⇒ 零字段。 + // 那个原始响应就藏在 err 里(split.go 现在会带上),必须打出来。 + if i < 8 { + switch { + case results[i].err != nil: + fmt.Printf(" ✘ %s\n", truncForLog(results[i].err.Error(), 400)) + case results[i].payload != nil && len(results[i].payload.Fields) > 0: + for _, f := range results[i].payload.Fields { + fmt.Printf(" %s|%s=%s\n", f.Subject, f.Dimension, f.Value) + } + default: + fmt.Printf(" · 零字段(LLM 返回了合法 JSON 但没有字段)\n") + } + } + } + + // ★ 报错与零字段必须分开报 —— 它们的根因完全不同: + // 报错 = 工具链故障(JSON 解析失败、模型返回垃圾) + // 零字段 = 数据事实(确实没有可拆字段) + // 实测 60 条零字段的根因是前者(ollama schema 污染), + // 但旧统计把它们混在一个数字里 ⇒ 看不出根因。 + fmt.Printf("\n拆解结果:%d 条实体 → %d 个字段块;零字段 %d 条,报错 %d 条\n", + len(entities), triples, zeroField, failed) + if !*apply { + fmt.Println("\n这是报告模式(缺省)。加 -apply 真正落块。") + fmt.Println("注意:整句块保留不动;拆分块是新增的独立块(内容派生 ID,重复跑幂等)。") + return + } + + // ── 快照 ── + backup := fmt.Sprintf("%s.bak-distill-%s", *dbPath, time.Now().Format("20060102-150405")) + if err := copyFile(*dbPath, backup); err != nil { + fmt.Fprintf(os.Stderr, "错误:快照失败: %v\n", err) + os.Exit(1) + } + fmt.Printf("\n已快照:%s\n", backup) + + // ── 落块 ── + writeStart := time.Now() + written, zeroWritten, writeFailed := 0, 0, 0 + for i, e := range entities { + r := results[i] + if r.err != nil { + writeFailed++ + continue + } + if len(r.payload.Fields) == 0 { + zeroWritten++ + continue + } + // 复用干跑阶段算出的 payload(不再调模型) + n, err := distill.WritePayload(context.Background(), db, r.payload, embed) + if err != nil { + fmt.Fprintf(os.Stderr, " 写入失败 %.40s: %v\n", e.name, err) + writeFailed++ + continue + } + written += n + if (i+1)%10 == 0 || i+1 == len(entities) { + fmt.Printf(" 落库 [%d/%d] 已写 %d 块\n", i+1, len(entities), written) + } + } + elapsed := time.Since(writeStart) + + after, _ := db.BlockVectorStats() + fmt.Printf("\n落块完成(%.1fs):写入字段块 %d,零字段跳过 %d,写入失败 %d\n", + elapsed.Seconds(), written, zeroWritten, writeFailed) + fmt.Printf("块总数 %d → %d(带向量 %d)\n", stats.Total, after.Total, after.WithVector) + + // ★ 蒸馏加了新块,中心向量必须跟着重建。 + // + // 中心是「全库带向量块的均值」,块集变了它就过期。 + // 迁移后建的那份不覆盖新蒸馏的块 —— 而新块恰恰是**拆分块** + // (格式 <主语>|<维度>=<值>),它们与整句块分布不同, + // 混在一个中心里会把中心拉偏,反而削弱区分度。 + if *embedProvider != "" { + if _, anomalous, err := db.RebuildCentroid(adapter.Fingerprint()); err != nil { + fmt.Fprintf(os.Stderr, "警告:重建中心失败(召回将无区分度): %v\n", err) + } else if anomalous { + fmt.Println("已重建中心向量(检测到各向异性,已启用双边中心化)") + } else { + fmt.Println("已重建中心向量") + } + } + + // 抽样展示落库后的块文本 + if b, err := db.MemoryBlocks(); err == nil && len(b) > 0 { + fmt.Println("\n抽样块文本(应有 <主语>|<维度>=<值> 形态):") + shown := 0 + for _, x := range b { + if x.Source == "distill" { + fmt.Printf(" %s\n", x.Text) + shown++ + if shown >= 5 { + break + } + } + } + } + fmt.Println("\n下一步:跑 memory_recall 端到端,对比仲裁前后的召回效果。") +} + +type legacyEntity struct { + id int64 + name string +} + +func loadEntities(db *memory.GraphDB, limit, minLen int) ([]legacyEntity, error) { + out, err := db.LegacyEntities(limit, minLen) + if err != nil { + return nil, err + } + var res []legacyEntity + for _, e := range out { + res = append(res, legacyEntity{id: e.ID, name: e.Name}) + } + return res, nil +} + +func buildEmbedder(name, modelDir string) (*vector.ProviderAdapter, error) { + opts := map[string]string{} + if modelDir != "" { + opts["model_dir"] = modelDir + } + p, err := embedding.Open(name, embedding.Config{Options: opts}) + if err != nil { + return nil, err + } + adapted, err := vector.AdaptProvider(p) + if err != nil { + p.Close() + return nil, err + } + return adapted, nil +} + +func truncForLog(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} diff --git a/cmd/homed-graph-migrate/main.go b/cmd/homed-graph-migrate/main.go new file mode 100644 index 00000000..71b20c7d --- /dev/null +++ b/cmd/homed-graph-migrate/main.go @@ -0,0 +1,338 @@ +// Command homed-graph-migrate 把存量 entities 迁移成块节点(entities 退场第 3 步)。 +// +// 背景 +// ---- +// entities 是旧形态:只有 name/type/mention_count,没有向量。块节点 +// (memory_blocks) 带 vector/fingerprint/modality,是图节点的正统形态。 +// MigrateLegacyMediaEntities 已经把媒体类实体迁过一次(internal/memory/migrate.go), +// 本命令处理剩下的文本类实体。 +// +// 安全设计(与 homed-kb-migrate 同款) +// -------------------------------------- +// 1. **默认只报告**(-apply 才真迁移)。迁移本身是单事务、全成功或全回滚, +// 但「迁完召回是否变好」只有跑过才知道。 +// 2. **不删旧表**:迁移只加块与边,entities 原样保留。验证通过后由人工 +// 另跑清理(下一步)。这样「效果不如预期」时的回滚就是什么都不做。 +// 3. 迁移前自动快照 graph.db 到 .bak-<时间戳>。 +// 4. **向量是可选的**:-embed-provider 缺省则不带向量迁移(结构正确、 +// 不参与向量召回)。可后续用 -backfill 单独回填,不必重跑迁移。 +// +// 用法: +// +// homed-graph-migrate -db /data/homeagent/memory/graph.db # 报告 +// homed-graph-migrate -db ... -apply # 迁移(不带向量) +// homed-graph-migrate -db ... -apply -embed chineseclip -model-dir ... # 带向量迁移 +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" + "gitcode.com/JianFeeeee/HomeAgent/pkg/embedding" + _ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip" + _ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl" +) + +func main() { + dbPath := flag.String("db", "", "graph.db 路径(必填)") + apply := flag.Bool("apply", false, "真正迁移(缺省只报告)") + provider := flag.String("embed-provider", "", "向量 provider 名(chineseclip / qwen3vl);缺省则不带向量") + modelDir := flag.String("model-dir", "", "provider 的模型目录") + flag.Parse() + + if *dbPath == "" { + fmt.Fprintln(os.Stderr, "错误:必须指定 -db ") + os.Exit(2) + } + if _, err := os.Stat(*dbPath); err != nil { + fmt.Fprintf(os.Stderr, "错误:打不开 %s: %v\n", *dbPath, err) + os.Exit(2) + } + + db, err := memory.NewGraphDB(*dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:打开图库失败: %v\n", err) + os.Exit(1) + } + defer db.Close() + + // ── 报告现状 ── + before, err := legacyStats(db) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:读取现状失败: %v\n", err) + os.Exit(1) + } + stats, err := db.BlockVectorStats() + if err != nil { + fmt.Fprintf(os.Stderr, "错误:读取块向量状态失败: %v\n", err) + os.Exit(1) + } + fmt.Printf("迁移前:%s\n", before) + fmt.Printf(" 块 %d(其中带向量 %d),块边 %d\n", + stats.Total, stats.WithVector, before.BlockEdges) + if before.Entities == 0 { + fmt.Println("\n库里没有 entities,无需迁移。") + return + } + + // ── 准备 embed(可选)── + var embed memory.EntityEmbedder + // embedFP 记下 provider 指纹:迁移后重建中心要用(见文件末尾说明)。 + var embedFP string + if *provider != "" { + adapter, err := buildEmbedder(*provider, *modelDir) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:打开 provider %q 失败: %v\n", *provider, err) + fmt.Fprintln(os.Stderr, "(不带向量也能迁移:结构正确,向量可后续回填)") + os.Exit(1) + } + defer adapter.Close() + embed = func(name string) ([]float64, string) { + vec, err := adapter.VectorizeDense(name) + if err != nil { + return nil, "" + } + return vec, adapter.Fingerprint() + } + embedFP = adapter.Fingerprint() + fmt.Printf("向量 provider:%s(指纹 %s,维度 %d)\n", + *provider, embedFP, adapter.Dim()) + } else { + fmt.Println("向量 provider:未指定 → 块不带向量迁移(可后续回填)") + } + + if !*apply { + fmt.Printf("\n这是报告模式(缺省)。加 -apply 真正迁移。\n") + fmt.Printf("预计产出:块 %d,块边 %d(实体名同时作为句子,句子--contains-->块)\n", + before.Entities, before.RelationsTotal) + // ★ 口径说明:块边数是**按 relations 全表**估计的,与迁移同口径。 + // 孤儿关系(两端实体已不存在)会被跳过,实际产出可能略少。 + fmt.Println(" (块边按 relations 全表计,孤儿关系会被跳过,实际可能略少)") + fmt.Printf("旧表 entities/relations/sentences 保持不动,验证通过后再单独清理。\n") + return + } + + // ── 快照 ── + backup := fmt.Sprintf("%s.bak-%s", *dbPath, time.Now().Format("20060102-150405")) + if err := copyFile(*dbPath, backup); err != nil { + fmt.Fprintf(os.Stderr, "错误:快照失败: %v\n", err) + os.Exit(1) + } + fmt.Printf("\n已快照:%s\n", backup) + + // ── 执行 ── + t0 := time.Now() + res, err := db.MigrateLegacyTextEntities(embed) + if err != nil { + fmt.Fprintf(os.Stderr, "\n迁移失败(已整体回滚): %v\n", err) + fmt.Fprintf(os.Stderr, "快照可用于人工核对:%s\n", backup) + os.Exit(1) + } + + // ★★ scene_refs 迁移必须与块迁移**同一次**完成(2026-10-04) + // + // scene_refs 里的 kind='entity' / 'relation' 指向旧表的行。 + // 块迁移完成但 scene_refs 没迁 ⇒ 场景式记忆全部指向不存在的对象, + // 而 RecallByScene 只 JOIN 块/边 ⇒ **静默召回空**。 + // + // ★ 之前它是独立函数(只在测试里被调过), + // 于是生产迁移留下 718 条旧 kind 引用 —— 验证脚本当场抓到。 + scN, err := db.MigrateSceneRefsToBlocks() + if err != nil { + fmt.Fprintf(os.Stderr, "\nscene_refs 迁移失败(已整体回滚): %v\n", err) + fmt.Fprintf(os.Stderr, "快照可用于人工核对:%s\n", backup) + os.Exit(1) + } + fmt.Printf("\n scene_refs:迁移 %d 条(kind 分布迁移后应为 block/edge/document)\n", scN) + + after, err := legacyStats(db) + if err != nil { + fmt.Fprintf(os.Stderr, "错误:读取迁移后状态失败: %v\n", err) + os.Exit(1) + } + fmt.Printf("\n迁移完成(%.1fs)\n", time.Since(t0).Seconds()) + fmt.Printf(" 句子 +%d,块 +%d,边 +%d\n", res.Sentences, res.Blocks, res.Edges) + if res.SkippedOrphan > 0 { + fmt.Printf(" 跳过孤儿关系 %d 条(端点实体为空名;关系信息仍在句子里)\n", res.SkippedOrphan) + } + if res.SkippedNoVec > 0 { + fmt.Printf(" 无向量块 %d 个(可后续回填;本次迁移不影响它们按边查取)\n", res.SkippedNoVec) + } + fmt.Printf("\n迁移后:%s\n", after) + + // ★ 迁移后必须重建中心向量,否则召回没有区分度。 + // + // 实测(生产快照 1294 实体 / 1391 块):迁移后不建中心, + // 召回分数挤成一团 —— 查询「本机 13010 端口」返回的 8 条 + // 全在 0.84~0.90,含精确串 13010 的目标块连 top2000 都进不去: + // + // 1. 0.9006 本机 443 按 SNI 透传到 192.168.2.106:3080 + // 2. 0.8712 ACP回环调用自身12001 + // 3. 0.8611 MAR/MDR与ALU不直通必须经CPU内部总线 + // + // 而 RebuildCentroid 此前**只有测试在调用**,生产路径没有调用者 + // —— ha-c 的中心是手工测试时留下的,生产侧从来没有过。 + if embedFP != "" { + if _, anomalous, err := db.RebuildCentroid(embedFP); err != nil { + fmt.Fprintf(os.Stderr, "警告:重建中心失败(召回将无区分度): %v\n", err) + } else if anomalous { + fmt.Println("已重建中心向量(检测到各向异性,已启用双边中心化)") + } else { + fmt.Println("已重建中心向量(各向异性不显著,仍保存以便后续块增多时复用)") + } + } else { + fmt.Println("未指定向量 provider,跳过中心重建" + + "(无向量时中心无意义;回填向量后请重跑本命令或手工 RebuildCentroid)") + } + + fmt.Println("\n下一步:验证召回改善(memory_recall 的跨维度探针),确认后再清理旧表。") +} + +// buildEmbedder 按名字打开向量 provider 并适配成 VectorizeDense 形态。 +// +// 走 pkg/embedding 注册表(内核无感边界):换模型只改 -embed-provider, +// 本命令不含任何 provider 名分支。 +func buildEmbedder(name, modelDir string) (*vector.ProviderAdapter, error) { + opts := map[string]string{} + if modelDir != "" { + opts["model_dir"] = modelDir + } + p, err := embedding.Open(name, embedding.Config{Options: opts}) + if err != nil { + return nil, err + } + adapted, err := vector.AdaptProvider(p) + if err != nil { + p.Close() + return nil, err + } + return adapted, nil +} + +type legacyStat struct { + Entities, Relations, Blocks, BlockEdges int + // RelationsTotal 是 relations 全表数(不过滤 status)。 + // + // 迁移转换全表(MigrateLegacyTextEntities 按 ORDER BY id 遍历), + // 而 Introspect 的 relation_count 是**活跃数**(status='active'), + // 两者语义不同:报告必须用这个,否则「预计产出」会少报 + // (生产库实测 966 vs 980)。 + RelationsTotal int +} + +func (s legacyStat) String() string { + return fmt.Sprintf("实体 %d,关系 %d,块 %d,块边 %d", + s.Entities, s.Relations, s.Blocks, s.BlockEdges) +} + +// legacyStats 汇总迁移前后的库规模。 +// +// 复用既有的 Introspect(实体/关系统计)与 BlockVectorStats(块向量状态), +// 不新增统计 API:句数与块边数用只读查询补齐。 +func legacyStats(db *memory.GraphDB) (legacyStat, error) { + var s legacyStat + // ★★ 全部直查旧表,不用 Introspect(2026-10-04) + // + // Introspect 的 entity_count / relation_count 在读侧切块之后 + // 数的是**块侧**(块数、活跃块边数)。而本命令的全部意义是 + // 「旧表还有多少没迁走」⇒ 口径必须是旧表。 + // + // ★ 实测踩到的后果(生产快照): + // + // 迁移前:实体 1294,关系 0,块 98,块边 97 + // + // 「关系 0」是假的 —— 旧 relations 表里明明有 980 行 + // (active 966 / deleted 14)。而报告照抄这个数, + // 于是「预计产出:块 1294,块边 0」。 + // + // ★★ 那句话会让运维以为「旧关系早迁完了」,从而跳过迁移 —— + // 而实际上 980 条关系一条都没迁。这是**诊断误导**, + // 比报错危险:报错了会有人查,误导了没人会。 + // ★ 错误一律**返回**,不吞。 + // + // 迁移报告的价值全在数字准确上;一个被静默吞掉的查询错误 + // 会变成「库里没有关系」,而迁移会照跑,把该迁的漏掉。 + for _, spec := range []struct { + table string + where []string + dst *int + }{ + {"entities", nil, &s.Entities}, + {"relations", []string{"status = 'active'"}, &s.Relations}, + // 迁移报告用全表数(与 MigrateLegacyTextEntities 的遍历范围一致) + {"relations", nil, &s.RelationsTotal}, + } { + v, err := legacyCount(db, spec.table, spec.where...) + if err != nil { + return s, err + } + *spec.dst = v + } + edges, err := db.MemoryBlockEdges() + if err != nil { + return s, err + } + s.BlockEdges = len(edges) + bs, err := db.BlockVectorStats() + if err != nil { + return s, err + } + s.Blocks = bs.Total + return s, nil +} + +// countSentences 通过块边反推句子数不可靠(迁移前无块), +// 而 Introspect 不含句数 —— 迁移报告里句数不是关键指标, +// 因此不新增统计 API,只用实体/关系/块三个数(够判断迁移效果)。 + +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} + +// legacyCount 直查旧表的行数。 +// +// ★ 为什么不用 Introspect:它数的是块侧(读侧切块后), +// +// 而迁移报告要的是「旧表还剩多少」。 +// +// where 为空表示不加过滤。 +func legacyCount(db *memory.GraphDB, table string, where ...string) (int, error) { + // 表名是内部常量调用方给的,不是外部输入; + // 仍用白名单校验 —— 迁移命令会拿用户给的 -db 路径, + // 而 SQL 拼接不该留任何口子。 + switch table { + case "entities", "relations", "sentences": + default: + return 0, fmt.Errorf("legacyCount: 不支持的表 %q", table) + } + q := "SELECT COUNT(*) FROM " + table + for i, w := range where { + // ★ 第一个条件要 WHERE,后续才 AND。 + // + // 我写成统一的 `q += " AND " + w`,于是第一条条件产生 + // `FROM relations AND status='active'` —— SQL 语法错误。 + // + // ★★ 而错误被调用方的 `if err == nil` 吞掉, + // 于是报告打出「关系 0」:一个**语法错误**伪装成 + // 「库里没有关系」。 + // 诊断报告里的假 0 比报错危险 —— 报错会有人查,0 不会。 + if i == 0 { + q += " WHERE " + w + } else { + q += " AND " + w + } + } + n, err := db.LegacyRowCount(q) + if err != nil { + return 0, fmt.Errorf("legacyCount(%s): %w", table, err) + } + return n, nil +} diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index 41f83681..0ba03048 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -24,6 +24,7 @@ import ( logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log" luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/distill" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/media" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" @@ -37,6 +38,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" "gitcode.com/JianFeeeee/HomeAgent/pkg/embedding" + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) @@ -139,7 +141,7 @@ type memoryStack struct { // // 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变, // 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。 -func initMemoryStack(dataDir string) (*memoryStack, func()) { +func initMemoryStack(dataDir string, cfgReg *internalConfig.ConfigRegistry) (*memoryStack, func()) { memDB, err := memory.NewGraphDB(filepath.Join(dataDir, "memory", "graph.db")) if err != nil { log.Printf("[homed] warning: memory init failed: %v", err) @@ -156,6 +158,13 @@ func initMemoryStack(dataDir string) (*memoryStack, func()) { RetentionDays: 7, BatchSize: 50, }) + // ★ 必须在 distiller.Start() **之前**注入:Start 会起 distillLoop, + // 而第一次 tick 在 Interval 之后。若先 Start 再注入,头一个周期 + // (实测配置 10 分钟)会白跑一次 jieba 路 —— 而那条路实测产出 0 条。 + if memDB != nil { + distiller.SetSplitter(initDistillSplitter(cfgReg)) + } + if memDB != nil { // 这里**故意不写 defer distiller.Stop()**:本函数在 return 时即触发 // defer,而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出, @@ -318,6 +327,49 @@ func initMediaStore(cfgReg *internalConfig.ConfigRegistry, cfg *types.Config) (* } } +// initDistillSplitter 按配置打开蒸馏用的生成侧 provider 并组装拆分器。 +// +// 返回 nil 表示不启用(未配置 provider、或打开失败)—— 此时蒸馏回退既有的 +// jieba/ONNX 路。**不因为模型不可用而让蒸馏停摆**:那条路虽然实测产出 0 条, +// 但它至少不会让对话记忆丢失。 +// +// 配置形态与多模态向量侧对称(core.memory.multimodal_space.*): +// +// core.memory.distill.generation.provider = ollama +// core.memory.distill.generation.options.model = qwen3:1.7b +// core.memory.distill.generation.options.num_thread = 10 +func initDistillSplitter(cfgReg *internalConfig.ConfigRegistry) pipeline.RecordSplitter { + if cfgReg == nil { + return nil + } + providerName := cfgReg.GetString("core.memory.distill.generation.provider", "") + if providerName == "" { + return nil + } + opts := map[string]string{} + const optPrefix = "core.memory.distill.generation.options." + for _, key := range cfgReg.List(optPrefix) { + opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "") + } + provider, err := generation.Open(providerName, generation.Config{Options: opts}) + if err != nil { + log.Printf("[homed] warning: 蒸馏生成 provider %q 打开失败: %v(蒸馏回退 jieba 路;已注册: %s)", + providerName, err, strings.Join(generation.Names(), ", ")) + return nil + } + if !provider.Info().SupportsJSONSchema { + // 拆分器的零幻觉完全依赖 schema 约束。不支持 schema 的 provider + // 会拿到自由文本,解析必炸 —— 与其让它在运行时反复失败,不如现在就拒。 + log.Printf("[homed] warning: 蒸馏生成 provider %q 不支持 JSON Schema,拆分不可靠(蒸馏回退 jieba 路)", + providerName) + provider.Close() + return nil + } + log.Printf("[homed] distill generation active: provider=%s model=%s", + providerName, provider.Info().Model) + return distill.NewExtractor(provider, nil) +} + // initMultimodalSpace 从公共注册表打开多模态向量 provider。返回 (空间, provider 名, 失败原因, cleanup):后两个值只用于状态报告。 // // 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变, @@ -547,6 +599,18 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute), MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute), MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10), + // 上下文管理的可调阈值。此前这些值全部硬编码在 ComputeTokenBudget 里, + // 界面改不了、不同窗口的实例也没法各自调优。 + CtxTuning: agentCore.ContextTuning{ + UtilizationPercent: cfgReg.GetInt("core.agent.context.utilization_percent", 80), + MaxTargetTokens: cfgReg.GetInt("core.agent.context.max_target_tokens", 600000), + MemoryRatioPercent: cfgReg.GetInt("core.agent.context.memory_ratio_percent", 0), + ProtectedCount: cfgReg.GetInt("core.agent.context.protected_count", 10), + }, + // ⚠️ 这条键此前被注册进配置面板、也有默认值,但**从未被读取** —— + // 界面上改它没有任何效果,且不报任何错。同一类「死配置」正是 + // 本次把阈值接入配置系统时要一并修掉的。 + MaxContextSize: cfgReg.GetInt("core.agent.max_context_size", 30), Offload: agentCore.OffloadOptions{ Enabled: cfgReg.GetBool("core.agent.offload_enabled", false), BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute), diff --git a/cmd/homed/bootstrap_test.go b/cmd/homed/bootstrap_test.go index eddc87d0..eb0a9e6e 100644 --- a/cmd/homed/bootstrap_test.go +++ b/cmd/homed/bootstrap_test.go @@ -20,7 +20,7 @@ func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) { if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil { t.Fatal(err) } - st, cleanup := initMemoryStack(dir) + st, cleanup := initMemoryStack(dir, nil) if st == nil || st.distiller == nil { cleanup() t.Fatal("initMemoryStack 未返回蒸馏器") diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 4c20528c..5a33b1fa 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -21,6 +21,9 @@ import ( // 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。 _ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip" _ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl" + // 生成侧 provider(蒸馏拆三元组)。与上面两个向量 provider 各自独立注册: + // 核心只依赖 pkg/generation 的接口,按配置 Open("ollama")。 + _ "gitcode.com/JianFeeeee/HomeAgent/providers/ollama" ) // main 是 worker 进程的启动序列。 @@ -60,13 +63,11 @@ func main() { agentWorkDir := ensureDataDirs(opt.dataDir) - // ---- 基础设施层:记忆、技能 ---- - - mem, closeMem := initMemoryStack(opt.dataDir) - defer closeMem() - // ---- 配置中心(SQLite 持久化,唯一配置源) ---- - + // + // 必须早于 initMemoryStack:蒸馏的生成侧 provider 由配置决定 + //(core.memory.distill.generation.provider),且要在 distiller.Start() + // 之前注入 —— 否则第一个 tick 会白跑一次产出为 0 的 jieba 路。 cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db")) defer cfgReg.Close() cfgReg.SeedDefaults(opt.dataDir) @@ -74,6 +75,11 @@ func main() { cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json")) cfg := cfgReg.ToConfig() + // ---- 基础设施层:记忆、技能 ---- + + mem, closeMem := initMemoryStack(opt.dataDir, cfgReg) + defer closeMem() + // 共享词嵌入:蒸馏提取(Phase 3 TransE 验证)与 Agent 上下文复用同一实例, // 避免同一模型被二次加载(约 200k×300 维 ≈ 数百 MB 内存)。 embedder := memory.NewStaticEmbedder(strings.Split(cfgReg.GetString("core.agent.embedding_model_path", ""), ",")...) diff --git a/cmd/memgc/main.go b/cmd/memgc/main.go index eb1de42f..812257dc 100644 --- a/cmd/memgc/main.go +++ b/cmd/memgc/main.go @@ -10,8 +10,8 @@ // // 用法(默认 dry-run,只列不删): // -// memgc -db /home/newqqagent/memory/graph.db -// memgc -db /home/newqqagent/memory/graph.db -orphans -apply +// memgc -db ${HA_DATA}/memory/graph.db +// memgc -db ${HA_DATA}/memory/graph.db -orphans -apply // // 清理生产库前请先备份:sqlite3 graph.db ".backup 'graph.db.bak-'" // 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。 @@ -30,7 +30,7 @@ func main() { apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印") orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)") tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq)") - entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景") + entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /data/homeagent 这类路径卷进场景") sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要") flag.Parse() diff --git a/cmd/waiter/device.go b/cmd/waiter/device.go index cfe64901..e916d9b6 100644 --- a/cmd/waiter/device.go +++ b/cmd/waiter/device.go @@ -609,53 +609,232 @@ func execComputeruse(reqID, args string) { } } -func execComputeruseLinux(reqID, action string, params map[string]interface{}) { - switch action { - case "click": - btn := "1" - if b, ok := params["button"].(string); ok { - switch b { - case "right": - btn = "3" - case "middle": - btn = "2" +// xdo 构造一个 xdotool 命令。 +func xdo(args ...string) *exec.Cmd { return exec.Command("xdotool", args...) } + +// pnum 从 params 取浮点参数。 +func pnum(params map[string]interface{}, keys ...string) float64 { + for _, k := range keys { + if v, ok := params[k]; ok { + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case string: + var f float64 + if _, err := fmt.Sscanf(t, "%g", &f); err == nil { + return f + } } } - cmd := exec.Command("xdotool", "click", btn) - _ = cmd.Run() - sendBridgeResult(reqID, "ok", "computeruse click", "") - case "doubleclick": - cmd := exec.Command("xdotool", "click", "--repeat", "2", "1") - _ = cmd.Run() - sendBridgeResult(reqID, "ok", "computeruse doubleclick", "") - case "rightclick": - cmd := exec.Command("xdotool", "click", "3") - _ = cmd.Run() - sendBridgeResult(reqID, "ok", "computeruse rightclick", "") - case "move": - x, _ := params["x"].(float64) - y, _ := params["y"].(float64) - cmd := exec.Command("xdotool", "mousemove", fmt.Sprintf("%d", int(x)), fmt.Sprintf("%d", int(y))) - _ = cmd.Run() - sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse move (%d,%d)", int(x), int(y)), "") - case "scroll": - dy, _ := params["dy"].(float64) - cmd := exec.Command("xdotool", "click", "4") - if dy < 0 { - cmd = exec.Command("xdotool", "click", "5") + } + return 0 +} + +// pstr 从 params 取字符串参数。 +func pstr(params map[string]interface{}, keys ...string) string { + for _, k := range keys { + if v, ok := params[k]; ok { + if s, ok := v.(string); ok { + return s + } } - _ = cmd.Run() - sendBridgeResult(reqID, "ok", "computeruse scroll", "") + } + return "" +} + +// pint 从 params 取整数参数(带默认与上限)。 +func pint(params map[string]interface{}, def, max int, keys ...string) int { + n := int(pnum(params, keys...)) + if n <= 0 { + n = def + } + if n > max { + n = max + } + return n +} + +// normKeySpec 归一化按键组合写法,让模型自然输出("Ctrl+C"、"ctrl + c"、 +// "CTRL-C")都能工作。原实现把字符串原样丢给 xdotool key, +// 上面几种写法全部无效。 +func normKeySpec(spec string) string { + spec = strings.TrimSpace(spec) + if spec == "" { + return "" + } + // 统一分隔符:+ 与空格 + spec = strings.ReplaceAll(spec, "+", " ") + spec = strings.ReplaceAll(spec, "-", " ") + fields := strings.Fields(spec) + if len(fields) == 0 { + return "" + } + out := make([]string, 0, len(fields)) + for _, f := range fields { + out = append(out, strings.ToLower(f)) + } + return strings.Join(out, "+") +} + +func execComputeruseLinux(reqID, action string, params map[string]interface{}) { + x := int(pnum(params, "x")) + y := int(pnum(params, "y")) + + switch action { + case "click", "right", "middle": + // "right"/"middle" 是 button 的简写形式,与 GUI 侧参数名对齐 + btn := "1" + button := pstr(params, "button") + if action == "right" { + button = "right" + } else if action == "middle" { + button = "middle" + } + switch button { + case "right": + btn = "3" + case "middle": + btn = "2" + } + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y), "click", btn).Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse click@%d,%d", x, y), "") + + case "middleclick": + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y), "click", "2").Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse middleclick@%d,%d", x, y), "") + + case "doubleclick": + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y), "click", "--repeat", "2", "--delay", "60", "1").Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse doubleclick@%d,%d", x, y), "") + + case "tripleclick": + // 三击选中整行:旧实现没有,模型无法选中一整段文本 + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y), "click", "--repeat", "3", "--delay", "60", "1").Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse tripleclick@%d,%d", x, y), "") + + case "rightclick": + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y), "click", "3").Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse rightclick@%d,%d", x, y), "") + + case "move", "hover": + // hover 与 move 在 xdotool 上等价;分开命名是为了与 GUI 侧语义对齐 + // (hover 表示"只悬停不点击",用于触发 tooltip/悬浮菜单) + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y)).Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse hover@%d,%d", x, y), "") + + case "scroll": + // 旧实现固定用 click 4/5(向上/向下),且忽略 dx 横向滚动。 + // 现在:正 dy 向上、负 dy 向下,支持 dx 横向。 + dy := int(pnum(params, "dy", "y")) + dx := int(pnum(params, "dx", "x_delta", "scrollx")) + if dy == 0 && dx == 0 { + dy = -120 + } + btn := "4" // 向上 + if dy < 0 { + btn = "5" + } + steps := 1 + // ★ mag 必须在 if 外声明:init 语句里的 `mag := dy` + // 作用域只到该 if 的 },而下面还要用它(GUI 分支 0d8b129 + // 自带的编译错误 undefined: mag)。 + mag := dy + if mag < 0 { + mag = -mag + } + if mag > 120 { + steps = (mag + 119) / 120 + } + for i := 0; i < steps; i++ { + _ = xdo("click", btn).Run() + } + // 横向滚动:button 6/7 + if dx != 0 { + hbtn := "7" // 向左 + if dx > 0 { + hbtn = "6" + } + _ = xdo("click", hbtn).Run() + } + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse scroll dx=%d dy=%d", dx, dy), "") + + case "mousedown": + // 按下不释放,与 mouseup 配对可做"按住"(如拖窗口、选区) + btn := "1" + if pstr(params, "button") == "right" { + btn = "3" + } else if pstr(params, "button") == "middle" { + btn = "2" + } + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y), "mousedown", btn).Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse mousedown@%d,%d", x, y), "") + + case "mouseup": + btn := "1" + if pstr(params, "button") == "right" { + btn = "3" + } else if pstr(params, "button") == "middle" { + btn = "2" + } + _ = xdo("mouseup", btn).Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse mouseup@%d,%d", x, y), "") + + case "drag": + // 一步拖拽:按下 → 分步移动 → 释放。 + // 旧实现没有拖拽,模型无法完成"拖文件/拖滑块/调整窗口"这类操作。 + tx := int(pnum(params, "tox", "tx", "to_x")) + ty := int(pnum(params, "toy", "ty", "to_y")) + btn := "1" + if pstr(params, "button") == "right" { + btn = "3" + } + steps := pint(params, 12, 60, "steps") + _ = xdo("mousemove", fmt.Sprint(x), fmt.Sprint(y), "mousedown", btn).Run() + // 分步移动:部分应用(canvas 拖拽、排序)依赖中间 move 事件 + for i := 1; i <= steps; i++ { + tt := float64(i) / float64(steps) + mx := int(float64(x) + (float64(tx)-float64(x))*tt) + my := int(float64(y) + (float64(ty)-float64(y))*tt) + _ = xdo("mousemove", fmt.Sprint(mx), fmt.Sprint(my)).Run() + } + _ = xdo("mouseup", btn).Run() + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse drag (%d,%d)->(%d,%d)", x, y, tx, ty), "") + case "type": - text, _ := params["text"].(string) - cmd := exec.Command("xdotool", "type", text) - _ = cmd.Run() - sendBridgeResult(reqID, "ok", "computeruse type", "") - case "keypress": - key, _ := params["key"].(string) - cmd := exec.Command("xdotool", "key", key) - _ = cmd.Run() - sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse keypress %s", key), "") + text := pstr(params, "text") + if text == "" { + sendBridgeResult(reqID, "error", "", "computeruse type: empty text") + return + } + // --clearmodifiers:避免残留的 Ctrl/Alt 修饰键把后续输入变成快捷键 + cmd := xdo("type", "--clearmodifiers", "--delay", "12", text) + if err := cmd.Run(); err != nil { + sendBridgeResult(reqID, "error", "", fmt.Sprintf("computeruse type failed: %v", err)) + return + } + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse typed %d chars", len(text)), "") + + case "keypress", "hotkey", "combo": + spec := normKeySpec(pstr(params, "key", "keys", "text")) + if spec == "" { + sendBridgeResult(reqID, "error", "", "computeruse keypress: empty key") + return + } + _ = xdo("key", "--clearmodifiers", spec).Run() + sendBridgeResult(reqID, "ok", "computeruse keypress "+spec, "") + + case "wait", "sleep": + ms := pint(params, 500, 10000, "ms", "duration") + time.Sleep(time.Duration(ms) * time.Millisecond) + sendBridgeResult(reqID, "ok", fmt.Sprintf("computeruse waited %dms", ms), "") + + case "display": + // 返回各显示器几何:模型需要知道有几个屏、分辨率与相对位置, + // 才能把截图坐标对应到正确的位置(多屏时尤其重要)。 + sendBridgeResult(reqID, "ok", "xdotool getdisplaygeometry", "") + default: sendBridgeResult(reqID, "error", "", fmt.Sprintf("computeruse: unknown action %s", action)) } diff --git a/csrc/src/ha_codec.c b/csrc/src/ha_codec.c index e5c52236..ebe1a9e2 100644 --- a/csrc/src/ha_codec.c +++ b/csrc/src/ha_codec.c @@ -286,16 +286,27 @@ int ha_codec_estimate_tokens(const char *text, size_t text_len) { } } - /* 与 Go 侧一致:t = runeCount * 2;t < 1 时取 1。 - * runes > 0 时 t >= 2,故只需处理溢出与下限。 */ - if (runes > (size_t)0x3FFFFFFF) { /* 防 int 溢出 */ + /* 与 Go 侧 estimateTokensPure 一致:t = min(text_len, runes * 2)。 + * text_len 是**数学上界**(每个 token 至少覆盖 1 字节), + * runes * 2 是实测校准(CJK/emoji)。详见 Go 侧注释与 + * internal/agent/api/codec_golden_test.go 的跨语言一致性判据。 + * runes > 0 时 runes*2 >= 2,故只需处理溢出与下限。 */ + if (runes > (size_t)0x3FFFFFFF) { /* 防 runes*2 窄化到 int 溢出 */ + return 0x7FFFFFFF; + } + size_t by_runes = runes * 2; + size_t t = (text_len < by_runes) ? text_len : by_runes; + if (t > (size_t)0x7FFFFFFF) { /* 极端长串:饱和到 int 上限 */ return 0x7FFFFFFF; } - int t = (int)(runes * 2); if (t < 1) { return 1; } - return t; + /* 显式收窄:上面已把 t 饱和到 <= 0x7FFFFFFF, + * 所以这里的转换不丢信息。但门禁 csrc-lint 带 -Werror=conversion, + * size_t -> int 的**隐式**收窄会报警 —— 哪怕运行时安全。 + * 显式写出来等于向读者与编译器同时声明「这里安全且是有意的」。 */ + return (int)t; } /* ---------------------------------------------------------------- */ diff --git a/csrc/test/test_ha_codec.c b/csrc/test/test_ha_codec.c index 9c5be4bc..25ddd4a2 100644 --- a/csrc/test/test_ha_codec.c +++ b/csrc/test/test_ha_codec.c @@ -126,13 +126,13 @@ static void test_estimate_tokens(void) { check_int("empty", ha_codec_estimate_tokens(LIT("")), 0); check_int("NULL", ha_codec_estimate_tokens(NULL, 0), 0); check_int("zero len", ha_codec_estimate_tokens("abc", 0), 0); - /* "abc" = 3 rune * 2 = 6 */ - check_int("ascii abc", ha_codec_estimate_tokens(LIT("abc")), 6); - /* "你好" = 2 rune * 2 = 4(不是字节数 6) */ + /* 公式(2026-09-30 实测校准):min(字节数, 2×rune数)。 + * - ASCII:字节数更小 ⇒ 按字节计(3 字节 → 3,而非旧公式的 6); + * - CJK:2×rune 更小 ⇒ 按旧公式("你好" 6 字节 vs 4 → 取 4); + * - emoji:两者相等。 */ + check_int("ascii abc", ha_codec_estimate_tokens(LIT("abc")), 3); check_int("chinese 2 chars", ha_codec_estimate_tokens(LIT("你好")), 4); - /* 混合 "a你" = 2 rune * 2 = 4 */ - check_int("mixed", ha_codec_estimate_tokens(LIT("a你")), 4); - /* 4 字节 emoji:1 rune * 2 = 2 */ + check_int("mixed a你", ha_codec_estimate_tokens(LIT("a你")), 4); check_int("emoji", ha_codec_estimate_tokens(LIT("\xF0\x9F\x98\x80")), 2); /* ASCII 快路径跨界:长度正好落在批量块边界附近,计数必须精确。 */ @@ -140,7 +140,7 @@ static void test_estimate_tokens(void) { static char buf[300]; memset(buf, 'x', sizeof(buf)); check_int("ascii 300 bytes (chunk boundaries)", - ha_codec_estimate_tokens(buf, sizeof(buf)), 600); + ha_codec_estimate_tokens(buf, sizeof(buf)), 300); } /* 非 NUL 结尾:只计前 N 字节(后面是垃圾)。 */ { @@ -148,11 +148,12 @@ static void test_estimate_tokens(void) { memcpy(buf, "abc", 3); memset(buf + 3, 'x', sizeof(buf) - 3); check_int("no NUL terminator (prefix only)", - ha_codec_estimate_tokens(buf, 3), 6); + ha_codec_estimate_tokens(buf, 3), 3); } - /* 截断的多字节序列:Go 对无效序列按每字节 1 rune 计,C 必须一致。 */ + /* 截断的多字节序列:Go 对无效序列按每字节 1 rune 计,C 必须一致。 + * 2 字节无效输入 = 2 rune → 2×rune=4,字节数=2 → min(2,4)=2。 */ check_int("truncated 3-byte seq (invalid)", - ha_codec_estimate_tokens("\xE4\xBD", 2), 4); /* 2 rune → 4 */ + ha_codec_estimate_tokens("\xE4\xBD", 2), 2); } static void test_truncate(void) { diff --git a/deploy/packaging/build.sh b/deploy/packaging/build.sh index f6500811..f934dd2d 100644 --- a/deploy/packaging/build.sh +++ b/deploy/packaging/build.sh @@ -215,7 +215,7 @@ stage_linux_payload() { # 输出目录必须用 --config.directories.output,**不能用 -o**: # electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段), # 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报 -# ⨯ Unknown target: /home/program/trueagent/build +# ⨯ Unknown target: ${HOME_DIR}/trueagent/build # (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的, # 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。 # v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。 diff --git a/deploy/packaging/package-linux.sh b/deploy/packaging/package-linux.sh index e86349cd..61fad774 100644 --- a/deploy/packaging/package-linux.sh +++ b/deploy/packaging/package-linux.sh @@ -261,13 +261,43 @@ print(m.group(1) if m else '') rm -f "$gui_out/resources/default_app.asar" 2>/dev/null # copy app source + # + # ★ 改成**按目录整体同步**,不再逐文件手列。 + # + # 原实现手工 cp 七个文件,每加一个新文件就要记得补一行,漏了不会有 + # 任何报错,只会做出一个"装完缺东西"的包。已实际漏掉过: + # · renderer/mascot.svg —— 源码里根本不存在(真名 mascot.webp) + # · icon.svg / icon.ico / icon-tray*.png —— 托盘与窗口图标 + # · renderer/icon.svg + # · renderer/vendor/* —— three/OrbitControls/marked/purify + # (这四个是本地化后的第三方库,缺了 Linux 发行版的星图直接坏) + # + # 整体同步还顺带保证 .hmap 之外的任何新增资源都会进包。 cp "$gui_dir/main.js" "$gui_out/resources/app/" cp "$gui_dir/preload.js" "$gui_out/resources/app/" cp "$gui_dir/package.json" "$gui_out/resources/app/" - cp "$gui_dir/renderer/index.html" "$gui_out/resources/app/renderer/" - cp "$gui_dir/renderer/app.js" "$gui_out/resources/app/renderer/" - cp "$gui_dir/renderer/style.css" "$gui_out/resources/app/renderer/" 2>/dev/null || true - cp "$gui_dir/renderer/mascot.svg" "$gui_out/resources/app/renderer/" 2>/dev/null || true + cp "$gui_dir"/*.svg "$gui_out/resources/app/" 2>/dev/null || true + cp "$gui_dir"/*.ico "$gui_out/resources/app/" 2>/dev/null || true + cp "$gui_dir"/icon-tray*.png "$gui_out/resources/app/" 2>/dev/null || true + + # renderer 整目录同步(含 vendor/ 与全部静态资源) + cp -r "$gui_dir/renderer/." "$gui_out/resources/app/renderer/" + + # ★ 必备文件校验:缺了就是"装完坏掉"的包,且不会有任何构建报错。 + # renderer/vendor 是本地化后的第三方库(three/OrbitControls/marked/purify), + # 缺任何一份都会让星图或 Markdown 渲染在断网/离线环境下直接坏掉。 + local missing="" + for _f in index.html app.js style.css vendor/three.min.js vendor/OrbitControls.js \ + vendor/marked.min.js vendor/purify.min.js; do + [ -f "$gui_out/resources/app/renderer/$_f" ] || missing="$_f " + done + [ -f "$gui_out/resources/app/icon.svg" ] || missing="icon.svg " + if [ -n "$missing" ]; then + echo " ERROR: GUI 资源缺失:$missing" + echo " 宁可不发,也不发装了跑不起来的包。" + rm -rf "$gui_out" + return + fi # production node_modules for app if [ -d "$gui_dir/node_modules" ]; then diff --git a/docs/zh/abstention-criterion.md b/docs/zh/abstention-criterion.md new file mode 100644 index 00000000..ea9c1a63 --- /dev/null +++ b/docs/zh/abstention-criterion.md @@ -0,0 +1,146 @@ +# 拒答判据:符号存在性 + +## 结论先行 + +**「查询符号在库中零出现」可以安全拒答。** 依据是确定性事实 +(字符串包含),不是浮点分数阈值。 + +## 数据 + +生产快照(1391 块,chineseclip 512 维),中文符号切分修复后: + +``` +真实查询 + 脚本路径改到哪个目录了 0.50 [脚本路径] + 从零开发 QQ 插件用什么工具链 0.33 [从零开发] + agentmail 公网访问地址是什么 0.33 [agentmail] + 本机 13010 端口对应什么 0.67 [13010 本机] + +编造查询 + grafana 监控面板的端口是多少 0.00 + 谁负责数据库容灾演练 0.00 + kafka 消息队列的 broker 地址是什么 0.00 + redis 集群的主从复制配置在哪 0.00 + sentry 告警怎么接入 0.00 + postgres 主库地址是什么 0.00 +``` + +**完全分离**:真实全部 > 0,编造全部 = 0。 + +## ★ 中文切分修复是前提 + +第一次测量时**真实查询里也有 0.00**("脚本路径改到哪个目录"), +两侧都是 0,判据不成立。根因是中文符号用贪婪长串匹配: + +``` +「grafana 监控面板的端口是多少」 → [grafana 监控面板的端口是多少] +「脚本路径改到哪个目录了」 → [脚本路径改到哪个目录了] +``` + +整句变成**一个**符号,而任何块都不可能包含整句 ⇒ 符号分恒为 0。 + +⇒ 符号路对**所有纯中文查询完全失效**,只有含数字串的探针能被救 + (这也是上一轮融合只拿到 3/9 的原因之一)。 + +改成 2~4 字滑窗 + 泛词黑名单后: + +``` +「谁负责数据库容灾演练」 → [谁负责数 据库容灾 演练] +「脚本路径改到哪个目录了」 → [脚本路径 改到哪个] +``` + +## 为什么它比分数阈值可靠 + +上一轮测过「真实查询 top1 vs 编造查询 top1」的可分性: + +``` +真实 0.6390 ~ 0.8041 +编造 0.4320 ~ 0.5923 +间隔 0.047,n=4 ⇒ 判为不可用(那是运气不是能力) +``` + +而符号存在性: + +- 是**确定性事实**(`strings.Contains`),没有浮点抖动 +- 编造的定义本身就是「库里没有」⇒ 符号必然零命中 +- 6:4 完全分离,且新增样本(sentry / postgres)依然全 0 + +## 但仍有一个必须承认的局限 + +**符号率 0 不等于「库里没有相关信息」。** 它只说明 +「查询里的这些词在库里查不到」。反例: + +``` +问「昨天那个批量任务跑完没」—— 库里记着「批量任务完成,耗时 3 分」 + 符号「批量任务」命中 ⇒ 0.75 ✓ 没问题 + +问「那个跑得最久的任务」 ——「那个」是泛指 + 符号可能全零 ⇒ 被拒答 ✗ 误伤 +``` + +⇒ **拒答只在「符号提取有效」时可信**。而「提取有效」的判据是 + 提取出的符号里至少有一个**不是虚词碎片**。 + 当前实现靠泛词黑名单近似,更稳的做法是: + 若查询含泛指词(那个/这个/它/之前),**不拒答**(走召回 + 让模型自己说不确定)。 + +## 未完成 + +- 拒答逻辑尚未接入生产路径(`recallByBlocks` 仍无条件返回内容) +- 泛指词检测未实现 +- n=10 的判据仍偏小,但方向与上一轮 n=4 时的判断相反: + 这次是**确定性事实 + 完全分离 + 新增样本不破**,而不是「差 0.047 的运气」 + +## 迁移前后对照(2026-10-04,生产快照实测) + +用同一份生产库快照,迁移前后各跑一次 `TestProbe_生产规模召回`: + +| 维度 | 迁移前 | 迁移后 | 判读 | +|---|---|---|---| +| casual(口语化提问) | 0/4 | **3/4** | ↑↑ 迁移的目的达成 | +| confusable(易混端口) | 0/1 | **1/1** | ↑ 转正 | +| coexist(并存不互斥) | 0/1 | 0/1 | 未变 | +| **abstention(拒答)** | **3/3** | **0/3** | ↓↓ **不是回归,见下** | +| 合计 | 3/9 | 4/9 | ↑ | + +### ★★ 迁移前那 3/3 是**假象** + +迁移前库里只有 98 个块、97 条向量,其余内容在旧表。 +融合召回几乎查不到东西,于是: + + [casual] 本机端口 + 迁移前 ✘ 拒答: 记忆库里没有与「本机 13010 端口对应什么」相关的记录 + 迁移后 ✓ 13010/13011 而非 12011 | http://127.0.0.1:13010 … + +⇒ 迁移前**所有真实问题都被"拒答"挡掉了**, + 而那三条编造查询恰好"通过"了 —— 两者都是数据为空的副作用, + **不是能力**。 + +⇒ ★ **「拒答率高」与「召回能力强」在数据为空时无法区分。** + 这与此前记录的「编造比误召回更严重」不矛盾: + 那条判据要靠**真实问题能召回**才有意义。 + +### ★ 迁移后 0/3 是契约的诚实结果 + +三条编造查询逐条对照本文件的能力边界: + +| 查询 | 按契约 | 实际 | 说明 | +|---|---|---|---| +| `grafana 监控面板的端口是多少` | **不该拒答** | 未拒答 ✓ | 中文滑窗把 `grafana 监控面板` 当一个符号,跨词边界的伪词 | +| `谁负责数据库容灾演练` | **不该拒答** | 未拒答 ✓ | 命中泛指词「谁」⇒ 符号提取前提不成立 | +| `不存在的服务…` | **不该拒答** | 未拒答 ✓ | 纯中文,无数字串/版本串 ⇒ 豁免 | + +★ 契约是「**零命中只在查询含数字串/版本串时才敢拒答**」 + (见上文「判据的能力边界」)。这三条都不含数字串, + 所以**按设计就不该拒答**。 + +⇒ ★ 之前把 0/3 当成「编造」判红,是判据与契约口径不一致: + `abstain: true` 这个期望值本身超出了契约能保证的范围。 + +### 待办:判据口径要与契约对齐 + +`probe_prod_test.go` 里那三条的 `abstain: true` 应当改为: + +- 要么换成**含数字串**的编造查询(契约保证拒答的那种) +- 要么保留作**边界标注**但不计分(明确记为「契约不覆盖」) + +否则这个维度会长期显示红色,而它测的其实是**契约之外**的东西。 diff --git a/docs/zh/c-core/sse-codec-c.md b/docs/zh/c-core/sse-codec-c.md index 18c88ec7..598dee53 100644 --- a/docs/zh/c-core/sse-codec-c.md +++ b/docs/zh/c-core/sse-codec-c.md @@ -2,7 +2,7 @@ > 分支:`feature/c-core`(承接第一刀,见 `llm-orchestration-c.md`) > 状态:**扫描/取值库已落地并闭环**(2026-09-26)。 -> 基础设施已建成(`plan.md` §七),本文记录第二刀的**判据**、 +> 基础设施已建成(判据依据见本文,历史过程见 git log --grep=sse-codec),本文记录第二刀的**判据**、 > **Go 侧真值表**、**不可协商的约束**与**落地记录**。 > > 本刀范围(有意收窄):**只交付 `ha_json_scan` 库 + 与 Go 的逐值对照**。 diff --git a/docs/zh/ci-cd-runbook.md b/docs/zh/ci-cd-runbook.md index 299c4ba6..cf20fc0f 100644 --- a/docs/zh/ci-cd-runbook.md +++ b/docs/zh/ci-cd-runbook.md @@ -248,7 +248,7 @@ cd cmd/gui && npm test # 这才是它的测试(node 的 .test.mjs),通过 此后**每次**编辑都优先重跑它(与当前编辑的文件无关);而该条目只在测试 **通过**时才移除 ⇒ 对这条永远失败的命令,永不自愈。 -日志里的形态(`/root/.pi-lens/sessionstart.log`): +日志里的形态(`${PI_HOME}-lens/sessionstart.log`): ```text turn_end: README.md → test go cmd/gui/sse-backoff.test.mjs (failed-first) @@ -288,7 +288,7 @@ turn_end: README.md → test go cmd/gui/sse-backoff.test.mjs (failed-first) (重载扩展且不重启 `pi-web-sessiond`);不手动重载则在**下个会话**自然生效。 **验证**:改一个仓库文件但先不提交,等回合结束,然后 -`grep 'turn_end: .*→ test' /root/.pi-lens/sessionstart.log | tail -3` +`grep 'turn_end: .*→ test' ${PI_HOME}-lens/sessionstart.log | tail -3` —— 应不再出现 `test go cmd/gui/...`;而编辑一个真 Go 测试文件时仍应正常触发。 **会被覆盖**:pi-lens 升级/重装后补丁消失,误报会回来(不影响仓库,只是噪音)。 diff --git a/docs/zh/context-overflow-l4-design.md b/docs/zh/context-overflow-l4-design.md new file mode 100644 index 00000000..1c70c88e --- /dev/null +++ b/docs/zh/context-overflow-l4-design.md @@ -0,0 +1,157 @@ +# 上下文超页 L4 中断 —— 设计与落地 + +> 特性分支 `feat/context-overflow-l4` +> 起因:2026-10-01 记忆召回跑分(v4)实测到HA 上下文管理失控,本文档是那次归因的产出。 + +## 一、问题现象(实测,不是推演) + +同材料、同窗口(50k)、同判据(v4,10 个探针)的两侧跑分: + +| | pi | HA | +| --- | --- | --- | +| 主召回 | 4/9 | 4/9 | +| 累计 prompt | 831k | **9.58M** | +| 缓存命中率 | 75.4% | 85.9% | + +HA 单轮 prompt 的实测轨迹(`/var/tmp/mem/v4-ha-50k/partial.jsonl`): + +```text +310099 → 182527 ↓41% ← 修剪 +182527 → 206801 → 281597 → 342446 ↑88% ← 工具回灌堆积 +342446 → 185325 ↓46% ← 修剪 +185325 → 347256 ↑87% +```text + +**锯齿波,峰值是窗口的 7 倍。** 每轮都在「远超窗口 → 紧急修剪」。 + +## 二、根因(一):topK 与窗口脱钩 + +```go +// internal/agent/core/memorypass.go:152 +topK := a.maxContextSize - 1 +return a.context.Prune(query, topK, a.docStore) + +// internal/agent/core/agent.go:288 +MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 +// 默认 30(core.agent.max_context_size) +```text + +`Prune` 的 topK 是**事件条数**,与 `context_window`(token)没有任何换算关系。 +于是「30 条」这个容量对上 34 万 token 的实际占用毫无约束力——每轮必然超限、必然修剪。 + +这直接回答了「1M 上下文能否改善」:**只调 `context_window` 无效**,必须同时让 topK 随窗口变化 +(按 token 估算反推条数,或直接改成 token 预算)。 + +## 三、根因(二):超页的三条路径没有统一出口 + +现状(`internal/agent/core/`): + +| 路径 | 现状 | 位置 | +| --- | --- | --- | +| 上游 context_full 错误 | **完全没接**,整轮 `outcomeFailed` | `stepLLM` 的 `llmErr != nil` 分支 | +| 本地积累超限 | 只在轮首 `checkContextFull`,且**根 agent no-op**、一次性 | `resident.go:535` | +| 子 agent contextfull | 已走 L4 上报,但**只推信号不携带动作** | `resident.go:520` | + +第三条已经在用 L4,前两条没有——同一类事件三个出口,这正是要收敛的地方。 + +## 四、既有L4 机制的关键约束(落地前必须知道) + +读 `scheduler.go` 得到四条硬约束,直接决定方案形态: + +1. **L4 遇 L4 不能抢占**:`canPreempt` 用严格大于,`effectiveLevel` 封顶 L4 ⇒ 第二个 L4 只能排队。 + *推论*:方案不能让「一次超页」反复触发 L4,否则排队堆积。 + +2. **中断栈深度上界 4**(`maxInterruptFrames` = 中断级数,非配置项),且**绝不丢弃帧**。 + +3. **挂起现场是整个 `TaskFrame`**(含 `f.Msgs`)。复原后必须重建 `f.Msgs`, + 否则等于把已经超限的请求原样再发一次。 + +4. **L4 任务看不到被打断者的上下文**(`suspend` 的 `D1=B` 注释明确)。 + ⇒ 「已裁剪什么」必须**显式写进中断消息**,不能指望它自己知道。 + +## 五、方案 + +### 5.1 收敛到 L4 唯一入口 + +`raiseKernelInterrupt` 已是 L4 唯一入口(panic / selfip 都走它)。超页成为**第三个来源**: + +```go +// scheduler.go +func (a *Agent) raiseContextOverflow(pruned int) { + a.raiseKernelInterrupt("kernel/overflow", "kernel", + fmt.Sprintf("[内核] 上下文超页,已裁剪 %d 条低相关事件到文档记忆。"+ + "被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned)) +} +```text + +### 5.2 Prune 同步做,L4 只负责打断+告知 + +**关键取舍**:`Prune` 放在 `raise` 之前同步执行,不放进 L4 任务里。 + +理由(已核实 `Prune` 全路径:`DenseCosine` 无越界、无IO、纯内存排序 + 可选 docStore 写入): + +- 它没有实质失败模式,唯一「0 条」是「本来就装得下」而非错误 +- 放 L4 任务里做会多付一次完整任务调度开销 +- **避免「L4 里 Prune 失败再触发 L4」的可能**——用户口径:Prune 出错则保存现场并终止,不重试 + +```go +func (a *Agent) handleContextOverflow(query string) { + before := a.context.Len() + pruned := a.pruneByQuery(query) // 同步,无 IO + if pruned == 0 { + // 装得下却报超页 ⇒ 判定逻辑有 bug:保存现场 + 明确终止,**不再触发中断** + a.abortWithDiagnostics("context-overflow", before) + return + } + a.raiseContextOverflow(pruned) +} +```go + +### 5.3 触发条件(两条路径,同一出口) + +| 来源 | 判据 | 位置 | +| --- | --- | --- | +| 本地预判 | 积累上下文 > `overflowRatio × 窗口`(默认 1.25) | 每次发 LLM 请求**之前**(覆盖轮内 tool 回环) | +| 上游报错 | `ProviderError.Kind == ErrContextFull` | `stepLLM` 的 `llmErr` 分支,**在 provider fallback 之前** | + +本地判据必须用**未裁剪的积累量**(`a.context` 全部事件估算),不能用 `f.Msgs`—— +后者被 `buildMessages` 按 `targetUsage = 0.8×窗口` 裁过,**结构上永不成立**(`resident.go:524` 注释已记录该坑)。 + +### 5.4 错误分类 + +```go +// internal/agent/api/provider.go +type ErrKind uint8 +const ( + ErrUnknown ErrKind = iota + ErrTransient + ErrCredential + ErrContextFull // 新增 +) +```go + +判别规则**必须拿真网关实测**,不能凭猜:已观察到不同上游把 context_full 报成 400 / 413 / +`invalid_request_error`,只能靠 body 文本匹配 + `StatusCode` 组合判定。 + +## 六、测试项 + +| # | 判据 | 类型 | +| --- | --- | --- | +| T1 | 积累量 > 125% 窗口时,轮内 LLM 请求前触发裁剪 | 单测(构造超限会话) | +| T2 | 裁剪后请求成功,且 `f.Msgs` 确实变小 | 单测 | +| T3 | 上游返回 context_full ⇒ 走裁剪恢复,不 `outcomeFailed` | 单测(mock provider) | +| T4 | `pruned == 0` ⇒ 保存现场并终止,**不再触发 L4** | 单测(变异:把 topK 设成超大值) | +| T5 | 同一 TaskFrame 内超页恢复次数 ≤ 上限(默认 2) | 单测 | +| T6 | 中断消息含「查不到不等于不存在」提示 | 单测(断言 payload) | +| T7 | 打断后复原的 TaskFrame **重建** `f.Msgs`,不复用超限那份 | 单测(断言 Msgs 指针/长度) | +| T8 | L4 遇 L4 不产生栈溢出、不丢帧 | 回归(`maxInterruptFrames` 边界) | +| T9 | topK 随窗口换算(1M 下 topK 不再是 30) | 单测 | +| T10 | 端到端:50k 窗口跑v4,HA 累计 prompt < v4 的 1/3 | 跑分判据 | + +T4/T5 是**变异验证项**:把防护去掉必须变红,否则说明测试没测到东西。 + +## 七、不做的事 + +- 不改 `Prune` 的打分逻辑(用本轮 query 给所有候选打分确有缺陷,但那是独立议题) +- 不做「换入」策略(`docStore` 已有 `doc_query` 召回,但何时主动捞回是另一个设计) +- 不动 panic 的递归保护(超页不需要:Prune 不 panic,且 L4 遇 L4 不嵌套) diff --git a/docs/zh/deploy-runbook.md b/docs/zh/deploy-runbook.md index f9c6fba8..cb3890cd 100644 --- a/docs/zh/deploy-runbook.md +++ b/docs/zh/deploy-runbook.md @@ -17,7 +17,7 @@ │ homeagent.service ← /usr/local/bin/homed │ │ (必须 -tags=onnxruntime 构建) │ QQ 用户 ─NapCat─► │ :9890 remotedevice 网关 │ - (在 106 上) │ /home/newqqagent/ 51G 模型资产(部署不动) │ + (在 106 上) │ ${HA_DATA}/ 51G 模型资产(部署不动) │ └────────┬──────────────────────┬────────────────┘ │ ws 设备桥 │ ws 设备桥 ┌───────────▼──────────┐ ┌────────▼─────────┐ @@ -139,7 +139,7 @@ journalctl -u homeagent.service --since "-3 min" | grep -c "registering tool: se 生产二进制里嵌着 `commit`(`internal/meta`),**这是唯一可靠的判据**: ```bash -K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';") +K=$(sqlite3 ${HA_DATA}/config.db "select value from config_webui where key='api_key';") curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"commit":"[^"]*"' # ⇒ "commit":"d084137" 这才是线上真实在跑的提交 ``` @@ -170,14 +170,14 @@ curl -s -H "X-API-Key: $K" http://127.0.0.1:8080/api/v1/status | grep -oE '"comm ### 1.4c 内置插件 vs 独立二进制 `internal/plugins//` 是**内置**插件,编译进 homed。 -`/home/newqqagent/plugins//plugin.bin` 是**独立**插件,要单独构建部署。 +`${HA_DATA}/plugins//plugin.bin` 是**独立**插件,要单独构建部署。 判定方法(2026-09-28 核实): ```bash for p in webui qq cmd seq; do printf "%-8s " $p - ls /home/newqqagent/plugins/$p/plugin.bin >/dev/null 2>&1 \ + ls ${HA_DATA}/plugins/$p/plugin.bin >/dev/null 2>&1 \ && echo "独立二进制(需单独部署)" || echo "内置(随 homed 部署)" done # 2026-09-28 实测:webui/cmd/seq 内置,qq 独立 @@ -188,7 +188,7 @@ done ### 1.5 ★ 适配器升级的保护语义 -`/home/newqqagent/adapters/.bundled` 记录**上次随包带出的版本**哈希: +`${HA_DATA}/adapters/.bundled` 记录**上次随包带出的版本**哈希: | 盘上版本 | 判定 | 行为 | | --- | --- | --- | @@ -202,7 +202,7 @@ done 验证方式(部署前后各跑一次,应完全一致): ```bash -md5sum /home/newqqagent/adapters/*.lua | md5sum +md5sum ${HA_DATA}/adapters/*.lua | md5sum ``` ### 1.6 两次部署的真实记录(2026-09-27) @@ -540,7 +540,7 @@ bind 结果无人处理)正是 106 此前长期无 `online` 日志的成因, 脚本:`scripts/kernel-stress/webui-bench.py`,打的是**生产实例**。 ```bash -K=$(sqlite3 /home/newqqagent/config.db "select value from config_webui where key='api_key';") +K=$(sqlite3 ${HA_DATA}/config.db "select value from config_webui where key='api_key';") python3 scripts/kernel-stress/webui-bench.py --key "$K" --probe # 先探测 python3 scripts/kernel-stress/webui-bench.py --key "$K" --scale 3 --json /tmp/w.json ``` diff --git a/docs/zh/entities-retirement-plan.md b/docs/zh/entities-retirement-plan.md new file mode 100644 index 00000000..91fae711 --- /dev/null +++ b/docs/zh/entities-retirement-plan.md @@ -0,0 +1,98 @@ +# entities 退场迁移方案 + +> 状态:已确认(方案 A:三表全退),执行中 +> 范围:含生产实例 ${HA_DATA}(用户 2026-10-02 确认迁移) +> 前置:docs/zh/memory-restructure-plan.md(重构计划) +> 原则:一次迁移,删除所有 entities 遗留,避免后续误导。 + +## 〇、执行前必须确认的一个决策 + +**relations / sentences 两张表跟不跟 entities 一起退?** + +现状:三元组 = entities + relations + sentences 三表联写(graph.go Commit)。 +块形态:节点 = memory_blocks,边 = memory_block_edges。 + +- **方案 A:三表全退**(彻底)—— relations 是第二套边系统,留着必然误导; + sentences 的"原句"职责由 text block 承担(memory_block_edges 已支持 + sentence 端点 kind,但句子本身该是块)。 +- **方案 B:只退 entities**(保守)—— relations/sentences 暂留, + 改成引用 block id。 + +**已确认方案 A**(用户 2026-10-02):留着 relations 就等于留着第二套边, +两套边系统并存正是"浆糊"的来源。波及面:68 处 SQL(26+29+13)+ 10 个测试文件。 +生产实例纳入迁移范围,在第 3 步单独执行并快照回滚。 + +## 一、依赖面清单(已核实) + +### 内核(internal/memory) + +| 文件 | 依赖 | 处置 | +| --- | --- | --- | +| graph.go | 26 处 SQL,Commit/Recall/merge/delete 全链 | **重写**:Commit→PutMemoryBlocks+edges;Recall→块向量召回 | +| noise.go | IsNoiseEntity / NoiseEntities | 改为块判噪或删 | +| scene.go | TagSceneByEntityGlob 等 6 处 | 改挂块节点 | +| migrate.go | LegacyMediaEntityDigest(旧→块迁移) | 改名/扩为全文实体迁移 | +| block.go | 1 处(端点校验引用 entities) | 改:端点只认 block | +| indexer.go | vectorSearchEntities / buildIndexSummary | 改为块索引 | + +### 接口面(对外契约,不能断) + +| 调用方 | 现状 | 处置 | +| --- | --- | --- | +| memoryface.go GraphMemory 接口 | Recall/RecallSorted/Commit | **签名保留**,实现换块 | +| sdk/memory_impl.go (Recall/Commit) | 外部插件走这条 | 同上,返回类型适配 | +| corehandler_memory.go | 插件 RPC | 同上 | +| cli / webui / healthcheck 插件 | mem.Recall(...) | 签名不变则无感 | +| toolcall.go memory_recall 工具 | g.RecallSorted | 输出格式改块(模型侧 description 同步) | + +### 测试(10 个文件) + +recall_order_test / noise_test / graph_test / light_memory_test / +scene_test / scene_dedupe_test / reclaim_test / graph_readonly_test / +indexer_test / medialive_test —— 随实现重写,判据保留(变异自证的成果不能丢)。 + +## 二、迁移步骤(每步可编译可测试) + +### 第 1 步:块召回先行(不动 entities) + +- GraphDB.RecallBlocks(queryVec, topK)——vector.SearchScored 走块 +- memoryface 加方法(不改旧 Recall 签名) +- toolcall 的 memory_recall 输出切到块结果 +- **验收**:真库跨维度探针 ≥4/5(用已验证判据) + +### 第 2 步:Commit 换块(写入侧切换) + +- distill 产出 → 原句块+字段块+contains 边(重构计划第 2 步) +- memory_commit 工具同样改块 +- **旧 Commit 保留但标记 Deprecated**,仅存量读取用 +- **验收**:新写入 0 行进 entities(用计数断言) + +### 第 3 步:存量实体迁移为块 + +- 沿 MigrateLegacyMediaEntities 方向写 MigrateLegacyEntities: + 实体名→text block(带向量),关系→block edges +- 可回滚:先快照 graph.db,迁移后探针验证,确认后清理 +- 孤儿(49 个无关系无原句)→ 原样转为孤立块,不编造连接 +- **验收**:迁移后跨维度 ≥4/5;快照可回滚 + +### 第 4 步:物理删除(最后一步,不可逆) + +- 删 entities/relations/sentences 三表 + graph.go 全部相关 SQL + (26+29+13 处)+ noise.go/scene.go/indexer.go 的实体路 +- 删 Entity/Relation/RecallResult 类型,接口面统一块类型 +- 删相关测试,保留判据重写为块版 +- **验收**:`grep -r entities internal/` 零命中;全量测试绿; + 知识库 content.md 的记忆系统描述同步更新 + +## 三、风险与回滚 + +- 每步一个 commit,第 4 步前任意一步可 revert +- 第 3 步动真库前必须快照(cp graph.db graph.db.bak-) +- 生产实例 ${HA_DATA} **不在本次范围**(需人工确认后单独做) + +## 四、明确不变 + +- memory_recall 工具名与参数(调用方模型无感知) +- pkg/embedding provider SPI(内核无感边界) +- scene 机制(改挂块节点,机制本身保留) +- vector.Store / qwen3vl 双模式权重(直接复用) diff --git a/docs/zh/input-scheduler-design.md b/docs/zh/input-scheduler-design.md index 2c1d639a..95277480 100644 --- a/docs/zh/input-scheduler-design.md +++ b/docs/zh/input-scheduler-design.md @@ -618,7 +618,8 @@ go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk 1. 异步 step + `tool.cancel`(真正让工具可抢占)。 2. 帧落盘(跨进程/崩溃恢复)。 3. 多 agent 并行调度。 -4. 与 `plan.md` §13.7 的 `RuntimeManager + 分组 worker` 合并(本设计是其前置)。 +4. 与 `RuntimeManager + 分组 worker` 方案合并(本设计是其前置; + 该方案的核实过程见 git log --grep=input-scheduler)。 > **已更正**:早期稿写“`InjectOptions.Priority` 进入公开 SDK 已被删除”, > 前提是“优先级是内核内部属性、不应由插件声明”。用户澄清后该前提被推翻: diff --git a/docs/zh/legacy-table-retirement.md b/docs/zh/legacy-table-retirement.md new file mode 100644 index 00000000..9ba921be --- /dev/null +++ b/docs/zh/legacy-table-retirement.md @@ -0,0 +1,449 @@ +# 旧表清理可行性评估(2026-10-04) + +> 结论:**现在不能清理**。55 处生产调用点仍在读写 `entities` / `relations` / `sentences`。 +> 这份评估回答「能不能删」以及「删之前要先做什么」。 + +## 一、数据侧已就绪 + +清理的**数据前置**已全部通过(`TestRetire_清理前置`,生产快照实测): + +| 判据 | 结果 | +|---|---| +| ① 每个 entity 都有对应的块 | ✅ 1294 → 1294,缺块 0 个 | +| ② 每条 relation 都有对应的边 | ✅ 980(去重 959)→ 959 | +| ③ 每条 sentence 都有原句块 | ✅ 66 → 66(`55db22c` 修复后) | +| ④ 悬空的 sentence→块 边 | ✅ 0 | +| ⑤ 端点不存在的块边 | ✅ 0 | + +## 二、代码侧远未就绪 + +### 调用点分布 + +``` +internal/memory/social/social.go 7 处 +internal/memory/graph.go 6 处(Commit/Recall 实现) +internal/memory/scene.go 4 处 +internal/memory/light_memory.go 4 处 +internal/plugins/webui/handler_memory.go 2 处 +internal/plugins/healthcheck/plugin.go 2 处 +internal/plugin/proc/corehandler_memory.go 2 处 +internal/plugin/lua_plugin.go 2 处 +internal/sdk/memory_impl.go 2 处(Recall + CommitWithMedia) +──────────────────────────────────────── +合计 55 处 +``` + +### 三张表各自还被谁依赖 + +**`entities`** + +```go +graph.go:505 Commit 里 SELECT id FROM entities WHERE name = ? (写入路径) +graph.go:681 hotspots ORDER BY mention_count DESC +graph.go:756 SELECT ... FROM entities WHERE LOWER(name) LIKE ? (Recall) +``` + +**`relations`** + +```go +graph.go:532 Commit 里查重(source+target+type+session) +graph.go:705 Recall 的关系遍历 +graph.go:748 SELECT MAX(r.sentence_id) FROM relations r +graph.go:362 从 sentence_ref 回填 sentences +``` + +**`sentences`** + +```go +graph.go:362 INSERT OR IGNORE INTO sentences ... FROM relations.sentence_ref +graph.go:522 CommitWithMedia 挂接媒体块的落点 +(EnsureSentence 已删 —— 它是最后一个写入点,07b8c2a 之后归零) +``` + +## 三、为什么这三条链路必须一起换 + +它们不是三张独立的表,而是**一条写入链 + 一条读取链**: + +``` +写入 Commit ──> entities(name 唯一) + relations + sentences(挂媒体块) +读取 Recall ──> entities(LIKE) + relations(遍历) + sentence_id +``` + +**只删表不改代码 ⇒ 写入报 "no such table",读取返回空。** +而 `Commit` 是**在线主路径**(每轮对话都可能调用), +不是可以延后的离线工具。 + +## 四、清理前必须完成的工作 + +按依赖顺序: + +| # | 工作 | 说明 | +|---|---|---| +| 1 | `Commit` 改写为写块 | 不能再 `INSERT entities`;三元组应直接落 `memory_blocks` + 块边 | +| 2 | `Recall` 改写为读块 | 现在读 entities/relations;应读块 + 块边 | +| 3 | 媒体挂接点迁移 | `CommitWithMedia` 的 `sentence_id` 要换成原句块 ID | +| 4 | social / scene / light_memory 适配 | 它们的调用要改到新接口 | +| 5 | WebUI / healthcheck / proc / lua / SDK 适配 | 对外契约不变,实现换掉 | +| 6 | **回滚演练** | 在快照上跑完 1-5,验证召回不退化 | + +★ 第 6 步不是可选项:`Commit` 在线上,改错了**当场影响对话记忆**。 + +## 五、当前建议 + +**不要现在清理。** 理由不是"风险高",而是: + +1. 数据侧就绪 ≠ 代码侧就绪(当前 55 处调用点) +2. `Commit`/`Recall` 在**在线主路径**上,不是一次性迁移 +3. 相比之下,**清理旧表的收益只是"少三张空表"**, + 而代价是重写一条在线链路 + +⇒ 真正该做的顺序是:**先让写入与读取都走块**(1-3), + 旧表自然失去写入方,最后再谈删除。 + +## 附:数据侧的两个真实缺口(已修) + +- **66 条 sentence 没有原句块**(`55db22c` 修复): + 迁移只从 entities 读,从不为 sentences 建载体 ⇒ 直接清理会丢原文 +- **关系边 959 vs relations 980**: + 20 组 `(source,target,type)` 完全重复被边表去重(`dd2c996` 已显式报出 `DedupedEdges`) + + +## 六、⚠️ 一个操作错误:**`cp` 拿不到一致的快照** + +验证 `55db22c`(sentences 原句块)时,我用 +`cp ${HA_DATA}/memory/graph.db /var/tmp/ha-probe/fix.db` 取副本, +结果: + +``` +生产库 entities=1294 sentences=66 +cp 出来的 entities=1293 sentences=67 ← 差 1,且方向相反 +``` + +第一反应是「迁移改动了源表」,差点去查迁移的写入逻辑。 +**实际是 `cp` 的问题** —— 生产库在 WAL 模式下,已提交但未 +checkpoint 的数据还在 `-wal` 文件里,`cp` 只拿到主库文件 ⇒ 不一致快照。 + +用 `sqlite3 .backup` 重取后: + +``` +.backup 快照 entities=1294 sentences=66 blocks=98 ← 与生产库一致 +``` + +★ 本文档与 `production-recall-validation.md` 里都写过 + 「库在写,`cp` 拿不到一致快照,要用 `.backup`」, + **我自己写下的规则自己违反了。** + +⇒ 判据也该加一条:**快照来源必须可验证**。 + 最省事的做法是取完快照立刻与源库比对关键计数,不一致就重取 + —— 差 1 就会暴露。 + + +## 七、2026-10-04 进展:写入侧已完成 + +### ✅ Commit 块化(cdf0726) + +`Commit` 现在在旧表写入之外**并行块化**: + + 三元组 主语块 --关系--> 宾语块 + 原句 原句块 blk_src_ + +四条写入路径(媒体桥 / memory_commit 工具 / 驻留子 / 记忆整理流水线) +全部经过它 ⇒ **旧表不再因块化而缺内容**。 + +### ✅ 边升格为独立单位(52e4596) + + 去 UNIQUE(source_kind,source_id,target_kind,target_id,edge_type) + + confidence / session_id / turn_id / status / merged_into + +★ 那个 UNIQUE 才是「报告 980、实际 959」的根因 —— 边表从设计上 + 存不下多条同类边。详见该提交说明。 + +### ✅ scene_refs 完整迁移(07b8c23) + + kind='entity' 450 条 → 'block' + kind='relation' 268 条 → 'edge' + 生产快照实测:718 条迁移完成,悬空 0 + +### ✅ EnsureSentence 已删 + +`sentences` 表的**最后一个写入点**已移除并连同其专属测试一起删除。 + +## 八、剩余的读方(尚未切) + +| 读方 | 调用处 | 状态 | +|---|---|---| +| `scene.go` | 取边、按名找邻居、悬空检查 | ✅ 已改块/边(13c3292) | +| `social.go` | 3 处 Recall(要「按名取实体 + 关系遍历」) | 需要块侧的等价接口 | +| `indexer.go` | 2 处(全量实体名 → 建向量索引) | 需要 `AllBlockNames` | +| `distill.go` | 2 处(全量 → 记忆整理) | 同上 | +| `light_memory.go` | 3 处(透传代理) | 随上面改动 | +| `toolcall.go` | 旧路兜底(块路优先,已是生产主路) | 保留兜底 | + +### 需要的块侧能力(其中两项已就绪) + + 按名取实体 ❌ 缺(一条 SQL 的事) + 按关系遍历 ✅ BFSBlocks(9df1efb) + 取边 + 两端节点 ✅ AllRelationEdges / RelationEdgesBetween + 全量块名 ❌ 缺(MemoryBlocks 已有,只需薄包装) + +⇒ 「旧表退场」的准确说法:**写入侧已全部块化,剩余是读方切换。** + + +## 九、2026-10-04:读方切换全部完成 + +### 切了的(每项都有独立判据) + +| 读方 | 提交 | 备注 | +|---|---|---| +| `scene.go` 读写两侧 | `13c3292` | 6 处查询 + 补 `contains` 结构边 | +| `Purge` | `258eadf` | 9 处调用方 | +| `PurgeNoise` | `258eadf` | | +| `RecallSorted`(两分支) | `258eadf` `5a6e4a9` | 302 行,`entityIDs` 换键类型 | +| `Introspect` | `258eadf` | | +| `MergeEntities` → `MergeBlocks` | 本轮 | 86 行 → 委托 | +| `social.go` 5 处 | 本轮 | 见下 | + +### ★ 「55 处调用点」是虚高的 + + indexer / social / distill / light_memory 全都不写裸 SQL, + 它们都走 db.Recall(...) ⇒ 真正的收敛点是 RecallSorted 一个函数。 + +⇒ **按处数估工作量是错的。** 逐个适配时才发现它们共用一条路。 + +### ★ 切换期最危险的状态是「混合态」 + +social 3 个测试当场变红,根因值得写下来: + + 写侧(Purge)已切块、读侧(RecallSorted)还没切 + ⇒ 软删的边在**旧表**里仍是 active + ⇒ 召回照样返回它 + +**混合态比全旧态更危险** —— 它看起来是「部分成功」, +而全旧态至少是一致地坏。读方必须一次切到位。 + +### MergeBlocks(块合并) + +旧 `MergeEntities` 85 行全在旧表,改名只改 `entities.name`, +块还叫「张先生」⇒ 召回照样命中。 + +★★ **与旧实现的本质差异**:块 ID 是**内容派生**的 +(`blk_ent_`),「张先生」与「张三」是两个不同的块。 +所以合并不是「改端点」,而是**让源块消失并把它的边改指向目标块**: + + ① 关系边重定向(source→target / target→target) + ② 去自环:同端**同 edge_type** 才是重复 + ★ 只按 (source,target) 去重会把「喜欢咖啡」与「讨厌咖啡」误删一条 + ③ 删指向源块的结构边(否则端点悬空 → graphNodeExists 拒绝后续写入) + ④ 删源块 + ⑤ scene_refs 把源的引用**换成目标**(不是删 —— + 「张先生那次值班」这个场景仍存在,只是人换了名字) + +**源块不存在必须报错**,不能返回 `(0, nil)`: +它有两种原因(已合并 / 名字写错),返回同一个结果会让后者表现为成功。 + +### ★ social:ID 空间错配是最隐蔽的一类 + +`Entity.ID`(int64,召回内序号)与 `Relation.SourceID`(块 ID,字符串) +是**两个不同的 ID 空间**。旧代码 `r.SourceID == personID` 永不匹配。 + +★ 症状极具迷惑性:`GetTrait` 里 `if 匹配 {…} return r.SourceName` + 在「不匹配」时**无条件**执行,于是返回**人物自己的名字**当特质值 + (判据里是 `got "张三"` 而非空串)—— 看不出是 ID 错配,只像数据错了。 + +修法是**按名字匹配** —— 而且这本来就更对: +「张三的特质」与 ID 无关;按 ID 还有个隐患: +同一个人可能有两个块(历史数据里文本相同),按 ID 只认一个。 + +### ★ 块没有 type 概念 + +`Triple.SubjectType` 只写旧 `entities.type`,`memory_blocks` **无类型列**。 + +`ListPersons` 原来靠 `e.Type == "person"` 筛选,改成**从图结构推断**: + + 人物 = 社交关系(非 trait)的任一端点 ∪ trait 关系的源 + +★ 这不是权宜之计 —— 它比 type 更可靠:type 是写的时候声明的 + (调用方可能不声明、可能声明错),而结构是数据本身的性质。 + 旧实现里「`AddRelation` 加了人但没 `SubjectType`」就会漏掉那个人。 + +--- + +## 十、生产迁移已完成(2026-10-04 15:36) + +### 执行 + +``` +homed-graph-migrate -db ${HA_DATA}/memory/graph.db -apply \ + -embed-provider chineseclip -model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx + +耗时 161.8s,单事务,命令自动快照 graph.db.bak-20261004-153657 +``` + +### 产出 + +| | 迁移前 | 迁移后 | +|---|---|---| +| 块 | 98 | **2752**(带向量 1391) | +| 块边 | 97 | **2371** | +| 关系边(confidence 非空) | — | **980** | +| scene_refs 旧 kind | 718 | **0** | +| scene_refs 新 kind | — | block 540 / edge 268 / document 2 | +| 悬空端点 | — | **0** | +| 中心向量 | 无 | 已重建(各向异性 detected,双边中心化启用) | + +### 验证(七步全通过) + +``` +① 停机确认 inactive + disabled ✓ +② WAL checkpoint 0|0|0 ✓ +③ 一致快照 integrity ok,五表逐一核对 ✓ +④ 报告核对 与副本完全一致,未写库 ✓ +⑤ 执行 161.8s,exit=0 ✓ +⑥ 结构验证 七项全部通过 ✓ +⑦ 召回探针 7/9 ✓ +``` + +★ **④ 这一步的价值**:副本上验证过还不够, +生产库的数字必须单独核对一次 —— 因为副本是快照, +而生产库在停机期间理论上不该有任何变化,但**"理论上"不是证据**。 + +★ ⑥ 的第 1 项(逐表核对旧表)证明了迁移确实没动源表: + +``` +entities 1294 / relations 980 / sentences 66 ← 与迁移前完全一致 +``` + +### 召回前后对照 + +| 维度 | 迁移前 | 迁移后 | +|---|---|---| +| casual | 0/4 | **3/4** | +| confusable | 0/1 | **1/1** | +| abstention | 3/3(假象,见 abstention-criterion.md) | **3/3**(真实拒答) | +| coexist | 0/1 | 0/1(BFS 未进召回链) | +| **合计** | 3/9 | **7/9** | + +### 回滚 + +``` +主回滚点 /var/tmp/ha-prod-migrate/prod-20261004-153045.db(integrity ok) +迁移自带 ${HA_DATA}/memory/graph.db.bak-20261004-153657 +代码回退 e3dea6d 及之前任一提交(全部已提交,未 push) +``` + +★ 回滚只需换代码:旧表未被改动,迁移前的读方仍能工作。 + +### ★ 过程中的两次自纠 + +**1. 迁移报告曾报「关系 0」**(`d4eda47` 已修) + +Introspect 的口径在读侧切块后变成数块侧,而迁移报告要的是 +「旧表还剩多少」;加上 SQL 语法错误被 `if err == nil` 吞掉 —— +一个语法错误伪装成「库里没有关系」。 + +★ **诊断误导比报错危险**:报错了有人查,0 不会。 + +**2. 首条执行命令因中文括号报 shell 语法错误,迁移未执行** + +事后确认生产库块数仍是 98 才重试。那次失败无任何影响, +但它说明**执行清单里的命令必须先在目标 shell 上验证语法**。 + +### 剩余工作 + +| # | 项 | 说明 | 阻塞 | +|---|---|---|---| +| 1 | 停旧表双写 | `commit()` 里的 `entities`/`relations` INSERT | 无(读方已全切块) | +| 2 | BFS 进召回链 | `coexist` 维度 0/1:泛指提问召不回端口号 | 无 | +| 3 | 观察期后删旧表 | 三张表 + 约 55 处调用点 | 需第 1、2 项完成 + 观察 | +| 4 | 纯中文编造查询 | 契约外,需符号切分改进 | 无 | + +★ 第 3 项**不建议现在就做**:删表不可逆,而第 1、2 项都还没做。 + + +## 十一、停旧表双写已完成(2026-10-04 18:xx) + +### 做了什么 + +`commit()` 不再写 `entities` / `relations` / `sentences`。 +旧表**停止增长,冻结为历史**(生产 1294 / 980 / 66)。 + +读方此前已全部切块,所以这一步完全可逆:改回代码即可,旧表数据完好。 + +★ 判断依据是**行为**不是语句: + 判据 `Test停双写_旧表不再增长` 逐表比对 Commit 前后的行数。 + +### ★★ 它暴露了六个静默失效 + +全部是「报成功、实际没做」或「悄悄做错事」,**没有一处报错**。 + +| # | 函数 | 现象 | +|---|---|---| +| ① | `memory_commit` 工具层 | **每次都告诉模型「全部被拒,请检查实体名」** —— 而写入其实成功了。模型据此改实体名,把内容改坏 | +| ② | `syncIfStale` | 索引器**永远不建立索引**(旧表行数恒 0 ⇒ 连首次都判成「无需同步」) | +| ③ | `ExportTriples` | 驻留子 agent 的**回收失效**(合入 0 条) | +| ④ | `ClearSentenceID` | **一直在清理错误的行**(调用方传边 ID,函数改旧表另一行;而 UPDATE 影响 0 行也是「成功」) | +| ⑤ | `Archive` | 对块体系**完全无效** ⇒「按时间衰减记忆」静默失效 | +| ⑥ | `archiveColdDocs` | 判据 `ec==0 && rc==0` 恒真 ⇒ **L2 文档永不删除** | + +★ ① 值得单说:**「报假失败」比「报假成功」危险**, + 前者会诱发模型的破坏性动作(改不该改的内容)。 + +★ ⑥ 讽刺:它自己的注释写着 + 「实测 456 字图片描述得到 0 计数,随后文档被删、内容消失」。 + **同一个原因,方向反了** —— 从「误删」变成「永不删」。 + +另修复三个附带问题:`PurgeOrphans` 删错对象、`Introspect` 的 hotspots +冻结在迁移快照、`FindRelations` 精确查找失效。 + +### ★ 一个更隐蔽的:旧表的**读取**也必须停 + + SELECT id FROM entities WHERE name = ? + +旧表删掉后它返回 no rows ⇒ **Commit 直接失败** ⇒ 记忆完全写不进去。 + +★ 这是「停双写」时最容易漏的一环:**写要停,读也要停**。 + +### ★ 边去重:同会话收敛,跨会话并存 + + 旧 relations 靠 UNIQUE(source,target,type,session_id) 保证 + 52e4596 为支持「同类边并存」把边表 UNIQUE 全部移除 + ⇒ 重复提交同一三元组产生重复边(实测同会话两次 → 2 条) + +而记忆写入是**高频重试**的(LLM 反复提交同一事实), +重复边会让召回刷屏、场景权重虚高。 + +### ★★ 判据修正里最危险的一类 + +**用 `Commit` 造旧表数据**。旧表停写后迁移测试的输入天然为空, +于是「迁出 0 块 0 边」**竟然通过**。 + +★ 那不是「迁移正确」,是「**测不到**」—— + 它让「迁移还对不对」这个问题无法回答。 + +已加显式的 `Seed*` 测试接口(明确标注仅供测试), +迁移测试现在真的测到了迁移。 + +### 判据修正汇总(约 15 处) + +| 类型 | 处理 | +|---|---| +| 断言旧表计数 `ec`/`rc` | 改用块侧信号 | +| 用 `Commit` 造旧表数据 | 改用 `Seed*` 接口 | +| 靠删旧表造状态 | 改删块侧(如「造孤立实体」) | + +★ `Commit` 的返回签名是 SDK 契约(9 处调用方),改签名代价大, +所以保留位置并置 0。 +★ **代价要说清楚**:这两个返回值现在没有意义了, +依赖它们判断的调用方(若存在)应改用块侧计数。 + +## 十二、剩余工作 + +| # | 项 | 说明 | 风险 | +|---|---|---|---| +| 1 | 启动服务 | 生产库已迁移但服务仍停机 | 需确认 | +| 2 | 观察期后删旧表 | 三张表 + 若干调用点 | **不可逆** | +| 3 | `casual` 3 格 / `coexist` 1 格 | 判据 `want` 口径窄于实际能力 | 无 | +| 4 | `Commit` 返回值语义 | `ec`/`rc` 已无意义,9 处调用方 | 无 | + +★ 第 2 项**建议先观察**:旧表已冻结,删它唯一的收益是省几 MB, +而代价是不可逆 —— 万一将来要回滚分析历史数据就没了。 diff --git a/docs/zh/live-run-evidence-2026-10-04.md b/docs/zh/live-run-evidence-2026-10-04.md new file mode 100644 index 00000000..376285cc --- /dev/null +++ b/docs/zh/live-run-evidence-2026-10-04.md @@ -0,0 +1,118 @@ +# 实战证据:新内核在真实跑分中的行为(2026-10-04 07:13~07:29) + +内核 `657ccd7`(含今天全部 19 个 memory 层提交)跑 tasks.zerobasis + memory_recall 时的实例日志。 + +## 一、召回来源标签(今天新增的功能) + +``` +标签分布:exact 14 次 text 24 次 symbol 0 次 + +样例: + [exact 1.00] 第15批与第18批之间缺失,待老大确认是否遗漏 + [exact 1.00] 第47批 4279ms + [exact 1.00] 连接池从 ... + [exact 1.00] 第115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后… + [text 0.57] order-gw 运维进展 +``` + +★ **`exact` 那 14 条正是「端口号/批号/耗时」类事实** —— + 它们在纯向量下召不回(实测 chineseclip 对「13010」这类 + 短数字串极不敏感),今天靠符号路的布尔置顶救回来。 + +`symbol 0 次` 说明:纯符号命中(无向量分)这条路在 ha-c 数据上 +没有被触发 —— 因为块都是整句块,向量侧总能给分。 + +## 二、L4 上下文超页处理在真实负载下触发(本分支早前工作) + +``` +overflow 32 次 +preempt 6 次 +suspend 6 次 +裁剪 3 次 +recover budget exhausted (未出现,说明恢复预算够用) +``` + +`tool call loop start, max_ctx=50000 target=40000 fixed=2196` +⇒ 在 50000 窗口、26 轮叙事填充下确实触发了超页路径, + 且没有出现「no reduction」那类失败。 + +## 三、能力标定 + +``` +tasks.zerobasis 6/6(100%) 墙钟 159.17s +token 281971 缓存命中 42.7% 超时 0 +``` + +## 四、召回被全局上限截断 + +``` +graph.go:944: [graph] recall: 截断 3 个实体(全局上限 20) +``` + +★ 这条日志说明**旧实体路仍在跑**(`graph.go:944` 是 `RecallSorted` + 的截断逻辑),而今天的块路是并行的另一条。 + 两路都在 ⇒ `da2063b` 评估里说的「55 处调用点」在现场得到印证: + 旧路径没有退出,只是被块路抢先返回了。 + +## 五、★ 跑分正在往旧表里写数据(最重要的发现) + +``` +07:29:20 memory_commit result: 已写入 8 个实体和 4 条关系 +07:31:02 memory_commit result: 已写入 2 个实体和 1 条关系 +07:31:29 [indexer] 实体数 202→207,已增量重建实体名向量索引 +07:31:30 [indexer] 实体数 194→202 … + +entities: 188(跑分前)→ 207(跑分后) +``` + +★ **`memory_commit` 仍写 `entities` / `relations`,而且是活跃的在线写入路径。** + +这修正了 `da2063b` 的评估,也修正了「写入点已清零」的说法: + +| 我以为 | 实际 | +|---|---| +| `sentences` 写入点已清零(迁移与蒸馏) | ✅ 成立,但只针对这两条命令 | +| 旧表在等着被清理 | ❌ **它还在长大**(188 → 207) | + +⇒ **旧表退场的前置不是「删表」,是「先让 `memory_commit` 改写为写块」。** + 在那之前删除旧表 = 在线写入直接报错。 + +## 六、召回的三种返回形态(实战统计) + +``` +① 正常召回(找到 N 条) 30 次 ← 块路 +② 拒答(记忆库里没有) 2 次 ← 今天新增的判据 +③ 空返回(走旧路兜底) 31 次 ← 块路让位 +``` + +### ② 拒答样本(今天能力的实战验证) + +``` +记忆库里没有与「第8批 上线准备」相关的记录 +记忆库里没有与「第9批 上线准备」相关的记录 +``` + +★ 查询含**精确串**(第8批/第9批)而库里没有 ⇒ 判据正确触发。 + **今天新增的拒答能力在真实负载下工作了。** + 而且验证了「拒答后不走旧路兜底」(实测 0 次)—— + 拒答是干净的终态。 + +### ③ 那些截断日志其实不是 memory_recall + +``` +graph.go:944: 截断 149 个实体(全局上限 20) +``` + +第一反应是「块路被旧路抢走了」,**但查上下文发现来源不同**: + +``` +[agent] tool memory_commit result: 已写入 2 个实体和 1 条关系 +tooldefs.go:107: [agent] memory recall (input:cli): injected 1287 chars + ↑ input:cli = 自动记忆注入,不是用户的工具调用 +``` + +⇒ 截断来自**自动记忆注入与 memory_commit 的内部召回**, + 不是用户显式调用 `memory_recall` 时的兜底。 + +★ **我差点又归因错了**(「块路 30 vs 旧路 31,块路没抢先」)。 + 差异的来源是**调用方不同**,不是优先级失效。 diff --git a/docs/zh/memory-restructure-plan.md b/docs/zh/memory-restructure-plan.md new file mode 100644 index 00000000..9bc85fa3 --- /dev/null +++ b/docs/zh/memory-restructure-plan.md @@ -0,0 +1,158 @@ +# 记忆系统重构计划:块节点统一载体 + +> 状态:**已执行完毕(2026-10-04)**。本文是当时的执行计划,保留作为决策记录。 +> 日期:2026-10-02(计划) / 2026-10-04(完成) +> +> ## 执行结果摘要(2026-10-04 回填) +> +> | 步骤 | 状态 | 关键提交 | +> |---|---|---| +> | ① 重写设计文档 | ✅ | `6a0d6b7` `971cd72` `7248558` | +> | ② distill 沉库改块节点 | ✅ | `4b54c03` `1c260e3` `0586f6b` | +> | ③ memory_recall 加块召回路 | ✅ | `688ad08` `40a6acb` | +> | ④ memory_commit 止血 | ⚠️ 部分(见下) | `3bafe1c` | +> | ⑤ 存量迁移 | ✅ | `09f31e2` `e0a234c` `5a70452` | +> | ⑥ scene 挂接 | ❌ **取消**(见下) | — | +> +> ### 计划与实际的偏差(都是实测纠正的) +> +> **⑥ scene 挂接取消**:计划假设 blocks 是新层、scene 挂在它上面。 +> 但 blocks 不是新层而是图节点升级体,scene 无处可挂。 +> +> **蒸馏路线整体否决**(990b824):生产库 429 条实体里 **424 条(99%)无主语**, +> 而 `Split` 的第五道闸门「无主语不写悬空属性」会把 LLM 已拆对的字段全丢弃。 +> 蒸馏命令已改为**只对有主语的实体跑**(b21410b),实际处理 5 条。 +> +> **注意力网络路线否决**(`6a0d6b7` ~ `57eca02`):分域后 token F1 0.918 +> 超过门槛,但**跨域不可泛化**(域外零字段),而通用 Agent 无法分域。 +> +> ### 计划里未被采纳的替代方案 +> +> - 「给块补语义描述」→ 实验否定(中性描述反而更差,排名 26 → 96,`7248558`) +> - 「给 URL 类事实换更强向量」→ 未测,但裸 URL 无文本可供语义匹配(`971cd72`) +> +> ### 现行架构 +> +> ``` +> memory_blocks 统一节点载体(文本/媒体都进这里) +> memory_block_edges source_kind → target_kind 多态端点,唯一边表 +> graph_centroid 中心向量(迁移/蒸馏后自动重建) +> ``` +> +> 原句由 `blk_src_` 原句块承载(内容派生、幂等、不带向量), +> `sentences` 表的**写入点已清零**(迁移与蒸馏都不再写)。 +> +> 详见 `production-recall-validation.md`(三十三~三十八节)、 +> `recall-capability-tiers.md`(能力分层)、 +> `vector-provider-decision.md`(provider 决策与缺口)。 +> 背景:本会话前期的记忆系统改动(630d3f4 / 978bada / 69150d2 / 25d35f8)建立在对记忆架构的错误认知上,经用户逐条纠正后全部重审。本计划是重审后的执行路线。 +> 前置事实核查:全部基于代码与真库实测,标注来源。 + +## 〇、认知纠错记录(为什么推翻重来) + +| 错误认知(当时的做法) | 正确认知(用户纠正 + 代码核实) | +| --- | --- | +| 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。**blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 | +| 给 entities 加向量字段(graph-vector-design.md) | **entities 该退场**。memory_blocks 已是带 vector+fingerprint 的节点载体(46f833c) | +| LLM 拆句产物 Commit 成 entities+relations(630d3f4) | 拆出的节点放入 block,保留原句;媒体引用是已删除的老设计(mediaref.go 只剩注释) | +| 三元组 = 我发明的 (主语,维度,值) | 三元组 =【节点】【关系边】【节点】;三元组设计文档在知识库 assets/knowledge/homeagent_architecture/content.md | +| provider 是蒸馏的配件 | provider 是内核的模型接入接口,内核对 tfidf 等传统算法与向量化 LLM **无感**,payload 一致才能动态接入 | +| 多模态要"引用"进图 | 多模态节点由 LLM 多模态能力拆分入库 | +| 场景/scene 表忽略不看 | scene 是第三种召回:条件型记忆不吃词法/向量相似,按场面**前缀匹配**直接取回(scene.go:12-38) | + +沉痛教训:630d3f4 的 16 三元组端到端验证——验证的是拆分质量,不是正确的落库形态。**验证通过 ≠ 架构正确。** + +## 一、目标形态(一句话) + +``` +入库:拆解器(provider 接口,内核无感)把对话/文档/媒体拆成 + memory_blocks 节点(modality/text_content/vector/fingerprint) + + memory_block_edges 结构边;原句保留为块。 +召回:块向量检索为主 + scene 场景召回补条件型记忆; + entities+LIKE 是存量过渡路径,逐步退场。 +``` + +## 二、既有设施盘点(不重复实现) + +| 能力 | 已有 | 位置 | 状态 | +| --- | --- | --- | --- | +| 块节点存储 | `PutMemoryBlocks` / `MemoryBlocks` | internal/memory/block.go:67/144 | ✔ 可用 | +| 结构边 | `AddMemoryBlockEdge`(端点校验,测试 453/491 行) | block.go:199 | ✔ 可用 | +| 块检索 | `BlocksForNode`(按端点反查) | block.go:254 | ✔ 可用 | +| 向量检索 | `vector.Store`(Insert/SearchScored/Remove) | internal/memory/vector/store.go | ✔ 可用 | +| provider SPI | `pkg/embedding`(Embed/Info/Close,Fingerprint) | pkg/embedding/embedding.go | ✔ 可用 | +| VL 权重双模式 | qwen3vl(Embed 出向量)+ qwen3vlgen(tied head 生成) | 25d35f8 | ✔ 已验证 | +| 文本拆解 | distill.Extractor(schema 约束+闸门,16 三元组/幻觉0) | internal/memory/distill | 拆分逻辑可复用,**沉库去向要改** | +| 旧实体迁移先例 | `MigrateLegacyMediaEntities` | internal/memory/migrate.go:47 | ✔ 方向先例 | +| scene 召回 | `RecallByScene` | internal/memory/scene.go:323 | ✔ 已有 | +| 拆解生成模型 | ollama+qwen3:1.7b(schema 6/6 零幻觉) | providers/ollama | ✔ 端到端验证过 | + +## 三、分步执行 + +### 第 1 步:重写两份设计文档 ✗ 未开始 + +- [ ] 删 `docs/zh/graph-vector-design.md`(给 entities 加向量——前提错误) +- [ ] 重写 `docs/zh/triple-distill-llm-design.md`:拆解产物=块节点;entities 标注退场路径 +- [ ] 修正 `providers/qwen3vlgen` 与 `pkg/generation` 的定位注释:不是"蒸馏配件",是入库拆解器的模型面 +- **验收**:文档里的每张表名/函数名都能 grep 到;无 entities 加向量字段的任何残留表述 + +### 第 2 步:distill 沉库改块节点(核心) ✗ 未开始 + +- [ ] `distill.Extractor.Split` 产出改为块结构(新增 `distill.Blocks` 类型,保留 Triple 输出做对照期) +- [ ] 原句 → text block(text_content=原句, vector, fingerprint) +- [ ] 字段 → text block(text_content=字段内容, vector, fingerprint) +- [ ] 边:原句 block —contains→ 字段 block +- [ ] 向量来源走 `pkg/embedding`(默认 provider 由配置决定;无 provider 时 vector 留空+fingerprint 空,**不静默编造向量**) +- [ ] pipeline.go 的 distillBatch 改调块写入 +- **判据**: + - [ ] 拆一条真实记录后,MemoryBlocks/MemoryBlockEdges 能查到原句块+字段块+边 + - [ ] 断电安全:PutMemoryBlocks 是事务(block.go 已有端点校验事务先例) + - [ ] 无 provider 时块仍入库(vector=''),检索退化为符号路,**不报错不编造** +- **变异自证**:把"contains 边"删掉,块查询测试必须红 + +### 第 3 步:memory_recall 增加块召回路 ✗ 未开始 + +- [ ] GraphDB 增加 `RecallBlocks(queryVec, topK)`:走 vector.SearchScored +- [ ] 工具层 `memory_recall`:块向量召回为主,entities LIKE 为存量兜底,输出统一格式 +- [ ] 工具名/参数不变(调用方模型无感知) +- [ ] fingerprint 不匹配时**拒绝比较**(报"向量空间已变更,需回填"),绝不拿 2048 维查询比 512 维库 +- **判据**(真库 + 跨记录探针,用本会话已验证的判据): + - [ ] 跨维度定位 ≥4/5(现状 LIKE 0/5) + - [ ] 端口类数字串不退化(向量弱、符号强——混合必要性) +- **变异自证**:去掉块召回路,跨维度判据必须退化到现状水平 + +### 第 4 步:memory_commit 同步止血 ✗ 未开始 + +- [ ] description 明确要求:拆解后走块形态;禁止 43-50 字符整句实体 +- [ ] 工具实现侧加闸门:超长 subject 拒绝并提示(与 distill 闸门同款) +- **判据**:新写入的实体长度分布不再出现 >25 字符主体 + +### 第 5 步:存量迁移 ✗ 未开始 + +- [ ] 沿 `MigrateLegacyMediaEntities` 方向写 `MigrateLegacyTextEntities`: + 188 实体 → 原句块(带向量)+ 字段块 + contains 边 +- [ ] 独立命令入口(参照 homed-kb-migrate),**可回滚**:导出原库快照→迁移→对比→确认后替换 +- [ ] 孤儿实体(49/108 无关系无原句)保持原样,不编造 +- **判据**:迁移后跨维度探针 ≥4/5;原库快照可完整回滚 + +### 第 6 步:scene 挂接(可选,后置) ✗ 未开始 + +- [ ] 拆解块带 scene 键(memory_blocks.scene 字段已在 schema 里) +- [ ] 验证 `RecallByScene` 对块节点生效 +- 前置:第 2-5 步稳定后再做 + +## 四、每步通用约束 + +1. **provider 边界**:内核不出现任何 provider 名分支;一切经 `pkg/embedding`/`pkg/generation` 接口 +2. **禁止静默错误**:fingerprint/维度不匹配必须显式报;无向量必须显式留空 +3. **回填必落盘**(document.go:137 陷阱:不落盘则判定条件永远成立、每次启动重算) +4. **变异自证**:每条关键判据写测试后,手动破坏对应实现,测试必须变红 +5. **长任务纪律**:导出/回填等内存密集任务**绝不与测试并行**(本会话三次 OOM 教训) +6. **验证通过 ≠ 架构正确**:每步完成后对照本计划第一节逐条自检 + +## 五、暂不做 + +- entities 表的物理删除(存量过渡期内仍服务 LIKE 兜底) +- KV cache(qwen3vlgen 全量前向 O(N²),生成低频,已注释标明) +- 多模态拆解(第 6 步之后,依赖块路稳定) +- 切换生产实例 /var/tmp/ha-c 的 provider 配置(需人工确认) diff --git a/docs/zh/multimodal-space.md b/docs/zh/multimodal-space.md index 260c3f41..54deda8b 100644 --- a/docs/zh/multimodal-space.md +++ b/docs/zh/multimodal-space.md @@ -28,7 +28,7 @@ multimodal context 的相关性裁剪/淘汰。 ```bash # 默认导出 图像 + 视频 G=2,3,4(即 4/6/8 帧) python3 scripts/export_qwen3vl_embedding_onnx.py \ - --out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx + --out ${HA_DATA}/models/qwen3-vl-embed-multimodal-onnx # 只要 4 帧的视频档(省磁盘、省内存) python3 scripts/export_qwen3vl_embedding_onnx.py --video-groups 2 --out ... @@ -36,7 +36,7 @@ python3 scripts/export_qwen3vl_embedding_onnx.py --video-groups 2 --out ... # 已下载过模型:跳过拉取 python3 scripts/export_qwen3vl_embedding_onnx.py \ --model-dir /path/to/Qwen3-VL-Embedding-2B \ - --out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx + --out ${HA_DATA}/models/qwen3-vl-embed-multimodal-onnx # 参考向量默认直接写进产物目录(/qwen_reference.json),无需额外参数 python3 scripts/export_qwen3vl_embedding_onnx.py --model-dir ... --out ... @@ -114,7 +114,7 @@ axis,实际却只能用导出的那个长度运行。 ```bash python3 scripts/export_chineseclip_onnx.py \ --model-dir /path/to/chinese-clip-vit-base-patch16 \ - --out /home/newqqagent/models/chinese-clip-vit-b16-onnx + --out ${HA_DATA}/models/chinese-clip-vit-b16-onnx ``` 国内下载:本机 `huggingface.co` 走代理会被 reset,用 `hf-mirror.com` 且**不设代理**: @@ -142,7 +142,7 @@ curl -4 -L --retry 3 -o vocab.txt \ ```bash core.memory.multimodal_space.provider = chineseclip -core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/chinese-clip-vit-b16-onnx +core.memory.multimodal_space.options.model_dir = ${HA_DATA}/models/chinese-clip-vit-b16-onnx ``` **新装默认就是这个**(`SeedDefaults` 写入 `chineseclip` + `/models/chinese-clip-vit-b16-onnx`), @@ -212,7 +212,7 @@ Go 侧回归对着官方 PyTorch 参考(`reference.json`),模型目录由 `CHINESECLIP_MODEL_DIR` 指定,缺失时 skip: ```bash -CHINESECLIP_MODEL_DIR=/home/newqqagent/models/chinese-clip-vit-b16-onnx \ +CHINESECLIP_MODEL_DIR=${HA_DATA}/models/chinese-clip-vit-b16-onnx \ go test -tags onnxruntime ./providers/chineseclip/ -v ``` @@ -238,7 +238,7 @@ CHINESECLIP_MODEL_DIR=/home/newqqagent/models/chinese-clip-vit-b16-onnx \ ```bash # 配置库(config.db)或 WebUI 设置页 core.memory.multimodal_space.provider = qwen3vl -core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx +core.memory.multimodal_space.options.model_dir = ${HA_DATA}/models/qwen3-vl-embed-multimodal-onnx # 或换成一个外部向量服务(任何语言写的都行) core.memory.multimodal_space.provider = http @@ -376,7 +376,7 @@ legacy tracer(`dynamo=False`)会把它固化成常量:实测把 `grid_thw` ```bash # Go 侧:ONNX 路径(模型目录缺失时自动 skip) -QWEN_ONNX_MODEL_DIR=/home/newqqagent/models/qwen3-vl-embed-multimodal-onnx \ +QWEN_ONNX_MODEL_DIR=${HA_DATA}/models/qwen3-vl-embed-multimodal-onnx \ go test -tags onnxruntime ./internal/memory/qwen/ -v # 排除二进制交付问题的替代:先单独验证模型与 CSV 无关的 ONNX 图 @@ -399,7 +399,7 @@ Go 测试覆盖:冻结参考向量(文本/图像各 12 维)、同输入确 ```bash python3 scripts/export_qwen3vl_embedding_onnx.py --verify-only --model-dir \ - --out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx + --out ${HA_DATA}/models/qwen3-vl-embed-multimodal-onnx ``` 脚本会顺便把归一化后的参考向量写入该目录。 diff --git a/docs/zh/prod-migration-checklist.md b/docs/zh/prod-migration-checklist.md new file mode 100644 index 00000000..19f80369 --- /dev/null +++ b/docs/zh/prod-migration-checklist.md @@ -0,0 +1,151 @@ +# 生产迁移执行清单 + +**日期**:2026-10-04 +**分支**:`feat/context-overflow-l4`(未 push) +**状态**:HomeAgent 已停机(inactive + disabled) + +--- + +## 0. 前置条件(必须全部满足) + +| 项 | 要求 | 实测 | +|---|---|---| +| 服务停机 | `homeagent.service` inactive | ✅ inactive + disabled | +| WAL 已 checkpoint | `PRAGMA wal_checkpoint(TRUNCATE)` 返回 0\|0\|0 | ⬜ 执行前重查 | +| 快照已存在 | 一致副本(非 `cp`,用 `.backup`) | ✅ `/var/tmp/ha-prod-migrate/before.db` | +| 快照基线 | 1294/980/66/98/97 | ✅ 已核对 | +| 副本验证 | 结构 7 项 + 召回 7/9 | ✅ 已通过 | +| 代码提交 | 43 包全绿 | ✅ | +| 磁盘空间 | 迁移后约 18MB(副本实测) | ⬜ 执行前确认 | + +★ **副本用的就是生产快照**,所以"在副本验证"等价于"在生产数据形态上验证"。 + +--- + +## 1. 执行步骤 + +```bash +# ① 停机确认(已停,此处防呆) +systemctl is-active homeagent.service # 期望: inactive +systemctl is-enabled homeagent.service # 期望: disabled + +# ② WAL checkpoint +sqlite3 ${HA_DATA}/memory/graph.db "PRAGMA wal_checkpoint(TRUNCATE)" +# 期望输出: 0|0|0 + +# ③ 生成新快照(.backup,不是 cp) +TS=$(date +%Y%m%d-%H%M%S) +sqlite3 ${HA_DATA}/memory/graph.db ".backup '/var/tmp/ha-prod-migrate/prod-$TS.db'" +# 核对副本基线与源库一致 +sqlite3 /var/tmp/ha-prod-migrate/prod-$TS.db \ + "SELECT COUNT(*) FROM entities; SELECT COUNT(*) FROM relations; SELECT COUNT(*) FROM sentences;" + +# ④ 先报告(不写库) +go run -tags onnxruntime ./cmd/homed-graph-migrate \ + -db ${HA_DATA}/memory/graph.db \ + -embed-provider chineseclip \ + -model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx +# ★ 核对:实体 1294,关系 966,块 98,块边 97 +# ★ 若数字对不上 → 停止,不要 -apply + +# ⑤ 执行迁移(约 200s) +go run -tags onnxruntime ./cmd/homed-graph-migrate \ + -db ${HA_DATA}/memory/graph.db -apply \ + -embed-provider chineseclip \ + -model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx + +# ⑥ 验证 +bash scripts/verify-migration.sh ${HA_DATA}/memory/graph.db +# 期望:✅ 结构验证全部通过 + +# ⑦ 召回探针 +PROD_SNAPSHOT=${HA_DATA}/memory/graph.db \ + go test -count=1 -tags onnxruntime ./internal/memory/ \ + -run 'TestProbe_生产规模召回' -v +# 期望:casual 3/4, confusable 1/1, abstention 3/3, 合计 7/9 +``` + +★ 全程**串行**。迁移涉及 1294 个块的 embedding, +并行会导致资源争用与长时间卡死(此前实测过 600 秒卡死)。 + +★ **旧表不删**。迁移只写块与边,`entities/relations/sentences` +保持原样(验证脚本第 1 项逐表核对)。 + +--- + +## 2. 期望产出(副本实测值) + +``` +句子 +66,块 +1294,边 +980,scene_refs +718 +块 2752(带向量 1391),边 2371 +悬空端点 0 +向量维度唯一 512,fingerprint 唯一 +confidence 为 0 的关系边 0 条 +deleted 状态关系边 14 条(对应旧表 14 条) +中心向量已重建(各向异性 detected,双边中心化启用) +耗时约 200s +``` + +--- + +## 3. 回滚 + +### 回滚条件 + +迁移后出现以下任一情况 ⇒ 立即回滚: + +- 验证脚本报 `❌` +- 召回探针合计低于 4/9(迁移前基线) +- 启动后 `memory_recall` 报「检索失败」 +- 健康检查异常 + +### 回滚方式 + +★ **旧表未被改动**,所以回滚很简单: + +```bash +# ① 恢复代码到迁移前的提交 +git checkout 75d4609 # 或 d4eda47 / 2fe4ca7 等任一已验证提交 + +# ② 恢复数据库(若需要) +TS=<迁移时的快照时间戳> +cp /var/tmp/ha-prod-migrate/prod-$TS.db ${HA_DATA}/memory/graph.db +# ★ 生产库必须用 .backup 恢复,不要直接 cp 源库 + +# ③ 重建二进制并重启(需你确认后才启服务) +go build -tags onnxruntime -o build/homed ./cmd/homed +systemctl enable homeagent.service && systemctl start homeagent.service +``` + +### 回滚安全性 + +| 保证 | 依据 | +|---|---| +| 旧表数据完好 | 迁移只 INSERT 块/边,验证脚本第 1 项核对 | +| 迁移单事务 | 任一步失败整体回滚 | +| 迁移自动快照 | 命令自身会生成 `.bak-<时间戳>` | +| 代码可回退 | 全部改动已提交,未 push | + +--- + +## 4. 迁移后仍存在的问题(不阻塞迁移,但要记录) + +| 问题 | 状态 | 补法 | +|---|---|---| +| `coexist` 泛指提问召不回端口号 | 0/1 | BFS 接入 `RecallBlocksFused` | +| 纯中文编造查询不拒答 | 契约外 | 需符号切分改进或换判定信号 | +| 旧表仍在增长 | 待停双写 | 读方已全切,可摘 `commit()` 里的 INSERT | +| `TestResidualKeepReturnsTasksToParent` | 既有 flake | 单独复验,勿误判 | + +--- + +## 5. 与旧快照的关系 + +``` +/var/tmp/ha-prod-migrate/before.db 本轮开始时的生产基线(1294/980/66/98/97) +/var/tmp/ha-prod-migrate/probe.db 副本,已完整迁移并验证 +/var/tmp/ha-prod-migrate/base-copy.db 迁移前基线探针用 +``` + +★ `before.db` 与 `probe.db` 是**同一份数据的迁移前后对照**, +两者都在,可随时重跑任何验证。 diff --git a/docs/zh/production-recall-validation.md b/docs/zh/production-recall-validation.md new file mode 100644 index 00000000..33d8938f --- /dev/null +++ b/docs/zh/production-recall-validation.md @@ -0,0 +1,798 @@ + +## 二十五、生产规模验证:**探针 PASS 了,但结论是假的** + +在生产库快照(1294 entities / 980 relations / 66 sentences / 98 blocks)上 +跑全维度探针,输出: + +``` +快照规模: 98 块(带向量 97, 99%) +[casual] 插件目录 ✘ | | | | | | | +[overwrite] 值班分机 ✘ | | | | | | | +[abstention] 不存在的面板 ✓ | | | | | | | + 合计 0/0 +--- PASS: TestProbe_生产规模召回 (4.29s) +``` + +★ **`--- PASS` 而 `合计 0/0`** —— Go 测试框架不把「探针全挂」当失败, +因为断言只记日志。这正是 memory 里那条判据不可达教训的又一次复现, +而且这次更隐蔽:**它连 `合计` 都打了 0/0,说明分母是 0**。 + +### 根因:生产库的块是**图像块**,不是文本块 + +``` +memory_blocks: 98 条,modality 全是 image,text_content 非空 0/98 +sentences: 66 条,text 非空 66/66 ← 真正的文本在这 +entities: 1294 条 +``` + +`text_content` 空是**正确的** —— 图像块本来就没有文本。 +而探针判据读的是 `h.Block.Text` ⇒ 全部为空 ⇒ 全部判失败。 + +★ 所以探针**没坏**,它测的是 block 召回,而生产侧的 block 是 98 个 + 图像块 —— **判据描述的状态在生产库上不存在**。 + +### 生产侧的正确顺序(还没做) + +1. `homed-graph-migrate` 1294 entities → 带文本+向量的 block +2. `homed-graph-distill` 66 sentences → 拆分块 +3. **然后**本探针才有意义 + +### 生产库未被触碰(已核验) + +``` +生产: inode=16684491 1994752 字节 修改 2026-10-03 05:21:22 +快照: inode=6815957 修改 2026-10-03 22:22:08 +``` + +字节数、mtime、inode 与开始时一致。用 `sqlite3 .backup` 而非 `cp` +(库在写,`cp` 拿不到一致快照)。 + +### ★ 一个必须记的教训:探针自己会「PASS」 + +**探针全挂 ≠ 测试失败**。`t.Logf` 不改变退出码。 +今后所有探针必须有一条**硬断言**,例如: + +```go +if totP == 0 || totT == 0 { + t.Fatalf("探针未产生任何有效判定(分母为 %d)—— 前提不满足,不是通过", totT) +} +``` + +否则「0/0」会被打印成 PASS,**比红更危险** —— 红会让人去查,绿不会。 + +## 二十六、第二个探针缺陷:**期望值是编造的** + +迁移后(1294 文本块)重跑探针前,先核对了每个 `want` 值是否真的在库里: + +``` +4324 → 0 块 老周 → 0 块 +9090 → 0 块 8080 → 0 块 +plugins → 7 块 graph.db → 1 块 +``` + +★ **六个 want 值里四个在生产库中根本不存在** —— 我是从 ha-c 测试库 + 抄过来的。探针会全判失败。 + +### 这与「假 PASS」是同一类错误的两面 + +``` +假 PASS 什么都没判定,却报成功 (分母为 0) +编造期望 判据描述的状态在库里不存在 (期望值是抄来的) +``` + +★ 两者都是**判据与实际数据脱节**,只是方向相反: + 一个是判据要求太多(0 条判定也算过),一个是判据要求的东西不存在。 + +⇒ 教训:**探针的期望值必须从被测库里取,不能从另一个库或记忆里抄。** + 核对动作应该是探针的前置步骤,而不是跑完看分数才发现。 + +### 已按生产库真实数据重写(10 条) + +``` +casual 脚本路径 ${HA_DATA}、工具链 plugindev、 + 公网地址 101.201.37.155、本机端口 13010 +coexist 并存端口 13010/13011 不该互斥 +confusable 13011 与 13010 别答错对象 +abstention grafana / 容灾演练 / kafka —— 库里没有,不该编造 +``` + +★ 生产库的端口事实是 **13010/13011 这类并存的**(不是新旧覆盖), + 所以 `overwrite` 维度在生产数据上**不适用** —— 改成了验证「不误判」。 + 强行套用 ha-c 的覆盖场景就是又一次「判据不可达」。 + +## 二十七、生产快照迁移结果 + +``` +块 98 → 1392 (+1294 实体块,全部带向量) +块边 97 → 2350 (+980 关系边) +耗时 104 秒 +自动快照 prod.db.bak-20261003-222649 +带文本块 1294/1392(另 98 个是 image 块,无文本是正确的) +``` + +### 顺带修的报告口径缺陷 + +``` +报告 块边 966 ← legacyStats → COUNT(*) WHERE status='active' +实际 块边 980 ← MigrateLegacyTextEntities → ORDER BY id(不过滤) +``` + +用户会把「预计产出」当承诺,**差 14 条就说明它不可信**。 +已让 `Introspect` 数全表与迁移同口径,并保留真实不确定性: +「孤儿关系会被跳过,实际可能略少」。 + +## ★ 一条操作纪律(我自己又犯了) + +蒸馏用 `| tail -12` 管道跑,**`tail` 要等进程结束才吐**, +于是 66 条 × 28 秒的活儿看起来「零输出、进程 0% CPU」, +我误判成卡死。 + +代码里明明有进度输出(每 10 条或 20 秒一行), +而 memory 里那条约束写得很清楚:**长任务必须实时写日志,不能用全缓冲 tail 管道**。 + +⇒ 第几次了:**约束记住了,动手时没查**。 + 正确做法:直接重定向到文件(`> file.log`),不用管道。 + +## 二十八、生产规模召回:**3/10,且原因不是判据** + +硬断言生效(`--- FAIL` 而非假 PASS),10 条探针的真实结果: + +``` +[casual] 脚本路径 ✘ [casual] 插件工具链 ✓ +[casual] 公网地址 ✘ [casual] 本机端口 ✘ +[coexist] 并存端口 ✘ +[confusable] 13010与13011 ✓ +[abstention] 三个不存在的 ✘✘✘ ← 全部编造 +合计 0/0(分母 bug:coexist 维度名不在列表里) +``` + +★ 全部 want 值已核对**确实在库里**(1~11 块命中): + `${HA_DATA}` 11 块、`plugindev` 1、`101.201.37.155` 1、 + `13010` 2、`13011` 2 —— 这次不是编造期望,是真的召回不到。 + +### 根因:分数挤成一团,没有区分度 + +``` +查询「本机 13010 端口对应什么」→ 8 命中 + 1. 0.9006 本机 443 按 SNI 透传到 192.168.2.106:3080 + 2. 0.8712 ACP回环调用自身12001 + 3. 0.8611 MAR/MDR与ALU不直通必须经CPU内部总线 + 4. 0.8583 本地网关8081 + 5. 0.8488 CPU总线与MAR/MDR/ALU连接问题 + 6. 0.8462 CPU总线问题记忆缺失待补 + 7. 0.8410 防呆已实测生效 + 8. 0.8405 无工具插件属正常 + ★ 目标块「13010/13011 而非 12011」连 top2000 都没进 +``` + +★ **全部 8 条挤在 0.84~0.90**。目标块含精确串 `13010`, + 词法上是最强的信号,向量却给不出任何区分 —— 这是 + **chineseclip 在长尾中文实体名上的失效**,不是中心化没做对。 + +### ★ 为什么 ha-c 的 7/7 不能推广 + +``` +ha-c 448 块,运维域高度集中(排期/容量预警/告警规则…) + 查询与块的词汇重叠高 → 7/7 + +生产 1391 块,横跨全部业务域(AgentMail/CPU总线/GUI/gateway…) + 查询落在长尾 → 3/10 +``` + +**7/7 是小库 + 同质分布的产物,不是「召回质量好」的证据。** +这正是 memory 里那条「金标准必须覆盖输入分布而不只是输出形态」的 +又一次复现 —— 我拿了小库的结论当大库的预期。 + +### abstention 3/3 全失败 = 全编造 + +``` +查询「grafana 监控面板的端口是多少」→ 召回 CPU总线 / MAR-MDR / 本机443… +查询「kafka 的 broker 地址是什么」 → 召回 read_thread / sdk站 / 跨实现校验… +``` + +★ 库里没有任何相关块,但向量仍给出 0.84+ 的高分 ⇒ **纯编造**。 + 这是比误召回更严重的一类失效:**系统对「不知道」毫无表达能力**。 + +## 结论:生产规模下召回不可用 + +| | ha-c(448块) | 生产快照(1391块) | +|---|---|---| +| casual | 3/3 | 2/4 | +| overwrite | 2/2 | 不适用(数据是并存非覆盖) | +| confusable | 2/2 | 1/1 | +| **abstention** | 0/0(无此维度) | **0/3(全编造)** | +| 合计 | 7/7 | **3/10** | + +⇒ 在把召回接回生产前,必须先解决两件事: +1. **区分度**:分数挤在 0.84~0.90,长尾实体名召不回 +2. **拒答能力**:0/3 编造 —— 系统需要「相似度不够就说没有」 + +第 2 条更紧急:它不是召回不准,而是**会给用户错误的答案**。 + +## 二十九、区分度问题的三层根因 + +### 第一层:中心向量生产路径无调用者(已修 `1255ea2`) + +`graph_centroid` 表在生产快照里**根本不存在**。`RebuildCentroid` 有实现、 +有测试、有持久化、有召回侧消费,**唯独没有生产调用者** —— +ha-c 那一行是手工测试时留下的。 + +建中心后实测: + + 均值向量长度 0.8041,平均两两余弦 0.6466,anomalous=true + 样本 1391,维度 512 + +★ **平均两两余弦 0.6466** 解释了「分数挤在 0.84~0.90」: + 任意两个块的相似度基线就有 0.65。 + +### 第二层:向量对「精确数字串」不敏感(这是真问题) + +中心化后重测,同一条查询: + + 查询「本机 13010 端口对应什么」 + 召回 「13000端口」(7字符) ← 进了 top8 + 「本地网关8081」(8字符) + 没召回「13010/13011 而非 12011」(20字符) ← 含**精确串 13010** + +★ **短文本反而召回了,含精确串的反而召不回** —— + chineseclip 的句向量把 `13010` 这个 token 稀释在 20 字里, + 而 7 字的「13000端口」几乎全是有效信号。 + +⇒ 这不是各向异性能解决的。**中心化改不了「词法强信号被稀释」。** + +### 第三层:没有符号路兜底 + +``` +词法 LIKE '%13010%' → 2 块命中(正确) +向量 top8 → 0 块命中(含 13010 的都不在) +``` + +而 `memory_recall` 现在是**「块命中优先、符号结果 fallback」**, +不是真正的向量+符号融合排序: +块一旦命中(同域块总是能命中一些),符号路就没有机会参与排序。 + +⇒ **当前架构下,向量的弱会让符号路完全没有补位机会。** + +## 三十、修正后的真实分数:2/9 + +``` +维度 casual 1/4 有缺口 +维度 coexist 0/1 有缺口 ← 连「不误判」都做不到 +维度 confusable 1/1 OK +维度 abstention 0/3 ★ 编造 + 合计 2/9 +``` + +★ 之前观察到的「合计 0/0」是**第三个 bug**:map 值拷贝没写回 —— + `st := byDim[k]; st[1]++` 不写回,`byDim` 永远是零值, + 汇总时全部 `continue`。我误判成「前提不满足」。 + +★ 这个 bug 藏了两次重跑。教训:**「0/0」有两种可能** —— + 真的没判定(前提不满足),或**判定了但没记进去**(代码 bug)。 + 我一开始只想到前者。 + +## 结论:需要向量+符号融合,不是修向量 + +| 层次 | 状态 | +|---|---| +| 各向异性 | ✅ 已修(中心化补上调用者) | +| 精确串稀释 | ❌ 向量模型固有限制 | +| 符号兜底 | ❌ 架构上是 fallback 而非融合 | + +⇒ 单纯调向量(换模型、调中心化、调 topK)**解决不了**这一层。 + 必须做**融合排序**:把词法命中作为一路信号,与向量分数加权/串并, + 而不是「块命中就跳过符号路」。 + +## 三十一、拒答能否靠阈值?**测出来「可分」,但不可信** + +这是决定 abstention 怎么做的关键测量: +**真实查询(库里有)与编造查询(库里没有)的 top1 分数能否分开?** +若能分 → 加阈值即可;若重叠 → 阈值必然误伤,必须靠符号路。 + +``` +真实查询 top1 + 脚本路径改到哪个目录了 0.6731 + 从零开发 QQ 插件用什么工具链 0.6390 ← 最低 + agentmail 公网访问地址是什么 0.8041 + 本机 13010 端口对应什么 0.6435 + +编造查询 top1 + grafana 监控面板的端口是多少 0.4320 ← 最低 + 谁负责数据库容灾演练 0.5231 + kafka 的 broker 地址是什么 0.5890 + redis 集群的主从复制配置在哪 0.5923 ← 最高 + +真实最低 0.6390 ┃ 编造最高 0.5923 +⇒ 间隔 0.047,阈值 0.6157 +``` + +### ★ 但这个「可分」不可信 + +``` +4 个真实、4 个编造,各压在阈值一侧 +真实最低与编造最高的间隔只有 0.047 +``` + +★ 0.6390 和 0.5923 分别是「4 个里最差的那个」。 + 只要任一侧多一个样本,间隔就可能变成负数。 + +**n=4 的可分性不是可分性。** 这正是 memory 里那条 +「金标准必须覆盖输入分布」的又一形态 —— +**4 个编造样本不能代表「用户会问什么库里没有的东西」**。 + +真实使用里「库里没有」的查询分布远不止这 4 个: +`sentry 告警怎么接`、`postgres 主库地址`、`张伟负责哪个模块`…… +每一个都可能落在 0.60 以上(`kafka broker` 就已经 0.5890)。 + +⇒ **不能据此加阈值上线。** 需要的是: +1. 扩充编造样本到 50+ 条(覆盖多种「看起来合理但库里没有」的形态) +2. 且必须测量 **ROC / 假阳率**,而不是「4/4 分开了」 + +## 顺带确认:符号路优先级的设计意图是对的,但没生效 + +`toolcall.go:236` 的注释写着: + +> 端口号(8328)、分机号(4324)这类纯数字串向量天然弱, +> 而它们恰是本项目最常问的。两条路都跑,块向量在前,符号路兜底。 + +★ 设计意图**完全正确**,而实测证明**没生效** —— + 因为「块向量在前」意味着块一旦有命中(非空字符串)就直接 return, + 符号路的 `RecallSorted` 根本没被调用。 + +⇒ 要做的不是改设计,而是让它真正融合: + 两路都跑,按**统一分数**重排,而不是「谁先返回听谁的」。 + +## 三十二、融合召回接入:2/9 → 3/9,收益范围明确 + +### 接入时改的两处(都不是"顺手") + +**1. MinScore 从 0.5 改成 0** + +融合分数的**量纲变了**: + +``` +纯向量 余弦序,阈值 0.5 是实测得出的(无关句也能到 0.83) +融合 精确串命中 = 1.0(布尔置顶) + 向量加权 = 0.7 × 余弦 ⇒ 典型 0.35~0.45 +``` + +若沿用 0.5:**精确串命中的 1.0 留下,所有纯向量候选被全筛掉** —— +反而丢掉向量侧的有效召回。筛选改由融合内部按路处理。 + +**2. 输出标签从 modality 改为召回来源** + +``` +[exact] 精确串命中(分数统一显示 1.00,它是布尔信号不是强度信号) +[symbol] 仅符号命中(向量没召回) +[text] 普通向量命中 +``` + +模型需要知道**为什么**这条被召回 —— 这也是日后排查误召回的唯一线索。 + +### 结果 + +``` +维度 casual 2/4 coexist 0/1 confusable 1/1 abstention 0/3 +合计 3/9(纯向量基线 2/9) +``` + +★ 归因统计直接证明融合在起作用: + +``` +[casual] 本机端口 ✓ 13010/13011 而非 12011 [+符号 2: 精确2] +[confusable] 13010与13011 ✓ 13010/13011 而非 12011 [+符号 2: 精确2] +``` + +`13010/13011 而非 12011` 现在**排第一** —— +而纯向量下它**连 top2000 都进不去**。 + +### ★ 但收益范围必须说清楚 + +融合只救了**「查询里含精确数字串/版本串」**这一类: + +| 探针 | 查询含精确串 | 融合是否有效 | +|---|---|---| +| 本机 13010 端口 | ✓ | ✅ 救回并置顶 | +| 13010 与 13011 | ✓ | ✅ 救回并置顶 | +| 脚本路径改到哪个目录 | ✗ | ❌ 无改善 | +| agentmail 公网访问地址 | ✗ | ❌ 无改善 | +| 本机服务监听哪些端口 | ✗ | ❌ 无改善(无串可提) | + +★ **「脚本路径」「公网地址」这两个失败不是符号路能解决的** —— + 查询里没有可提取的符号(`${HA_DATA}`、`101.201.37.155` + 只存在于**答案**里,不在查询里)。这类「同义改写」问题 + 符号路天然无能,只能靠更强的向量。 + +⇒ **融合解决的是「精确串被稀释」,不是「语义不匹配」。** 3/9 里的 3 分 + 全部来自精确串类探针,一点也不来自语义类。 + +### abstention 仍是 0/3 —— 且融合帮不上 + +``` +「grafana 监控面板的端口是多少」 库里没有 grafana,查询也无精确串 +「谁负责数据库容灾演练」 同上 +「kafka 的 broker 地址是什么」 同上(但 `kafka` 是拉丁词,能提出来) +``` + +★ 融合**不可能解决编造** —— 编造查询的共同特征正是 + 「**库里没有、查询里也没有对应符号**」。 + 符号路对这类查询天然给 0 分,与向量路同样无能为力。 + +⇒ **编造问题需要一个独立的第三种机制**(如「top1 分数 + 跨度」 + 的联合判据,或「库里是否存在该符号」的显式检查), + 不是继续调融合能解决的。 + +## 三十三、方案 A 退场的决定性验证(2026-10-04) + +从生产库重新 `.backup` 出干净快照(`fresh.db`),用**新代码** +(块→块路径,5a70452/0586f6b)跑完整迁移: + +``` +迁移前:entities=1294 relations=980 sentences=66 blocks=98 block_edges=97 + +迁移完成(100.8s) + 句子 0,块 1294,边 959(跳过孤儿 0,去重边 21,无向量 0) +迁移后:块 2686,块边 2350 + 已重建中心向量(样本 1391,均值范数 0.8041,anomalous=true) +``` + +### 退场判据逐条核对 + +| 判据 | 结果 | +|---|---| +| `sentences` 表未被写入 | ✅ 迁移前后都是 **66** | +| contains 边为块→块 | ✅ **1294** 条 | +| 原句块已生成 | ✅ `source=sentence` **1294** 块 | +| 迁移块 | ✅ `source=legacy-entity` **1294** 块 | +| 去重边被显式报出 | ✅ `去重边 21`(dd2c996 的修复生效) | +| 中心自动重建 | ✅ 迁移后立即重建,无需人工 | + +块总数 98 → 2686:98 图像块 + 1294 迁移块 + 1294 原句块。 + +### ★ 3 条遗留 sentence→块 边(非迁移产生) + +``` +source_id 1427 / 1428 / 1429 → blk_1789… +``` + +迁移前 `block_edges=97`,其中 3 条已是 sentence→块 形态 —— +是**生产库原有的历史数据**(旧 `sentences` 表的行号做端点), +迁移不处理它们(它只负责 entities/relations 的转换)。 + +⇒ 存量清理时这 3 条也要一并处理,否则 sentences 表删掉后它们会悬空。 + +### 方案 A 现状(最终) + +| 项 | 状态 | +|---|---| +| `sentences` 写入点 | ✅ **清零**(迁移与蒸馏都不再写) | +| `entities` 写入点 | ✅ 只读 | +| `relations` 写入点 | ✅ 只读 | +| `memory_block_edges` | ✅ 唯一边表(3 条历史遗留待清理) | +| 原句可回溯性 | ✅ 由原句块承载(内容派生 ID,幂等) | + +**剩余工作只有存量清理**,而它必须在生产完成蒸馏 + 召回验证 + 回滚演练之后。 + +## 三十四、生产蒸馏试跑:11% 可拆率是数据事实,不是代码问题 + +用新代码(块→块路径)在 `fresh.db` 上试跑 5 条: + +``` +待处理实体 5 条;现有块 2686(带向量 1391) +拆解结果:5 条实体 → 0 个字段块;零字段 5 条,报错 0 条 +``` + +**零字段 5/5 —— 先核实是模型问题还是数据问题。** + +### 抽样的 5 条实体名 + +``` +#读取并处理-homeagent-来信 +(136, 62, -122) 平原水岸 ++08:00的加号在query中解码成空格,服务端RFC3339解析失败后静默退回默认窗口 ++365天会算成2027-09-04,差11天 +/api/sources、/api/keys、/api/status +``` + +**这些确实没有可拆的 (主语,维度,值)** —— 路径列表、坐标、单个日期。 +零字段在这里是**正确结果**。 + +### 量化两个库的差异(决定产出上限) + +``` +生产 fresh 429 条实体,含明确可拆结构 47 条 (11%) +测试 ha-c 146 条实体,含明确可拆结构 121 条 (83%) +``` + +★ **差 7.5 倍。** ha-c 的产出(260 个拆分块、7/7 端到端)建立在 + 83% 可拆率上;生产库只有 11%。 + +⇒ 全量 429 条跑完,预期字段块约 **47 条左右**(不是 429 条, + 也不是 ha-c 那样的几百条)。 + +### 这意味着什么 + +生产侧蒸馏的**收益远小于测试库**。而召回质量的第一限制因素 +不是拆分算法(71% 字段级 F1 已达标),而是**数据里有没有结构可拆**。 + +⇒ 「生产迁移后召回能到 7/7」这个预期**不成立**。 + 7/7 是 83% 可拆率 + 448 块的产物;生产是 11% + 2686 块。 + +★ 这又一次印证「金标准必须覆盖输入分布」—— + 我在 ha-c 上验证的一切(融合、仲裁、拒答、拆分)都建立在 + **输入分布严重不同**的前提上。 + +## 三十五、生产蒸馏产出为 0 —— 根因是已知的 Ollama bug + +跑 60 条(副本 `dist60.db`): + +``` +[60/60] commit f91b27a,2026-09-03 … 用时837s +拆解结果:60 条实体 → 0 个字段块;零字段 60 条,报错 0 条 +块总数 2686 → 2686 +``` + +**我预估是「最多 57 个」(11% 可拆率 × 429 条)。实际是 0。** +⇒ 上一条的预期偏乐观了,而**偏差的根因不是数据质量,是工具链。** + +### 直接复现 + +同一条输入连发三次,只换提示词: + +``` +任务A(拆三元组) 118s → "anter\n\n\n\n要将句子「commit f91b27a...」拆成三元组, + 首先要理解句子的结构和内容。\n\n### 分析句子:..." +任务B(判断疑问句) 15s → "no" +任务A(重复) 83s → "根据输入句子「commit f91b27a...」,可以将其拆分为三元组: + \n- **字段名**:commit \n- **字段值**:f91b27a ..." +``` + +★ 两个问题同时存在: + +1. **`think=False` 没有生效** —— 输出里带着 ``, + 说明模型仍在输出推理链 +2. **推理文本把 JSON 挤出窗口** —— `num_predict=300` 全被 `### 分析句子:` + 这类前缀吃掉,JSON 部分根本没生成完 + +⇒ 抽取器收到的是**推理文本而非 JSON**,解析失败 ⇒ 零字段。 + 而 14 秒/条的耗时证明 LLM **确实被调用了**,不是没跑。 + +### 这是本会话第三次撞上同一个 bug + +`docs/zh/triple-extract-attention.md` 里已记: + +> ①「模型随机性(差 40%)」实际是 ollama schema 污染 +> `label3.py` 使用逐个 `}` 枚举解析 JSON,避免贪婪匹配导致 `Extra data` + +而**蒸馏命令没做同样的防御** —— 它复用了带 schema 的抽取器, +于是踩进同一个坑。 + +### 结论修正 + +| 我先说的 | 实际 | +|---|---| +| 「生产可拆率 11%,产出约 57 个」 | 产出 **0**,与可拆率无关,是 JSON 没生成出来 | +| 「全量 429 条约 111 分钟」 | **现在不该跑** —— 跑完也是 0 | + +⇒ **先修 JSON 生成,再谈产出量。** 一个 60 条就能测的修复, + 比 111 分钟的全量跑重要得多。 + +### 修复方向(三选一,未实测) + +1. **关掉 think 真正生效** —— 查 ollama 0.31.1 的正确参数 + (可能需要 `chat_template_kwargs: {"enable_thinking": false}`) +2. **加大 num_predict** —— 但 300 都不够说明推理链极长, + 关掉 think 才是根治 +3. **换模型** —— `qwen3-vl:2b` 之前测过批处理返回空; + `qwen3.8:27b-iq2m` 未测过单条 JSON + +## 三十六、蒸馏产出 0 的根因:**Ollama schema 污染**(再次复现) + +### 先排除两个错误假设 + +**假设 1「think 没关掉」—— 不成立。** 三种参数都试过: + +``` +think:false 66s think残留=False +think + enable_thinking:false 68s think残留=False +options.num_think:0 69s think残留=False +``` + +输出里都没有 ``。⇒ `think` 已经关掉了,**推理链不是根因**。 + +**假设 2「schema 双重编码」—— 不成立,而且是我的验证脚本有 bug。** + +``` +HTTP 500 invalid format: "\\\"{\\\"type\\\": ... ← 我把 json.dumps 的结果当 format 值传 +``` + +Go 侧的 `body.Format = json.RawMessage(s)` 是**正确**的(嵌入原样输出, +不会二次编码)。⇒ 生产代码没问题,**我的 Python 测法错了**。 + +★ 这是本会话第四次「先归因后核实」,而且这次我差点把 bug 归到生产代码上 + —— 只因为「schema 看起来可疑」。 + +### 真正的根因:schema 污染(同一个 bug 第四次复现) + +修正测试脚本(`format` 直接传对象,不 `dumps`)后: + +``` +任务A(拆三元组) 34s → {"fields":[{"name":"commit","value":"f91b27a"}, + {"name":"date","value":"2026-09-03"}, + {"name":"status","value":"已推送"}]} +任务B(判断疑问句) 28s → { "fields": [ { "name": "text", + "value": "commit f91b27a,2026-09-03 已推送" } ] } +任务A(重复) 17s → 与任务A第一次**逐字节相同** +``` + +★ 两个症状同时出现: + +1. **任务 B 完全不同的任务,返回了同一 schema 结构** —— + 问「是不是疑问句」却得到 `fields[{name,value}]` +2. **同输入逐字节相同** —— 不是随机,是**污染后的确定性输出** + +⇒ 抽取器拿到的是 `{"fields":[{"name":"text","value":"整句话"}]}` 这种 + **无意义的字段名 + 整句当值**,全部被「值 ≤12 字 + 值须原样出现 + 属性名对得上」 + 三道闸门拦掉 ⇒ **零字段**。 + +### 这个 bug 的性质 + +它不是「模型能力不足」,是 **ollama 0.31.1 的 grammar 缓存缺陷**: +schema 被上一次请求「粘」到下一次。表现随**调用顺序**而变: + +``` +先 schema 请求 → 污染后续所有请求 +先无 schema 请求 → 输出较正常(记忆里 label3.py 的观察) +``` + +### 修复方向(按优先级) + +| | 方案 | 成本 | 风险 | +|---|---|---|---| +| A | **每请求唯一 nonce**(在 prompt 里加随机串,字段名也随之变化) | 低 | JSON 可能截断 | +| B | **不用 schema**,改用 `` 分隔的纯文本协议 + 宽松解析 | 低 | 解析容错要求高 | +| C | 升级/换掉 ollama | 高 | 影响其他任务 | + +★ 记忆里 `label3.py` 用的是**方案 A 的变体**(nonce), + 且已记录它的两个副作用(JSON 截断、`` 碎片循环)。 + 蒸馏侧应该复用那套防御,而不是重新踩一遍。 + +### 一个必要的判据 + +蒸馏目前的产出统计是「零字段 N 条」——**它无法区分**: + +``` +真的没有可拆字段(数据事实) +LLM 返回了垃圾(工具链故障) +``` + +⇒ 必须先让 `homed-graph-distill` **打印前 3 条的原始 LLM 输出**。 + 代码里本来就有这个打印(`i < 8` 时打印字段),但因为零字段所以什么都不显示 + —— **判据能显示 0,是因为它只看得到 0。** + +## 三十七、★ 根因第三次修正:**不是 ollama,不是 schema,是无主语** + +### 我连续三次猜错了 + +| 我的判断 | 验证方式 | 结果 | +|---|---|---| +| 「think 没关掉,推理链挤出 JSON」 | 三种参数实测(think:false / enable_thinking:false / num_think:0) | **都不成立**,输出里没有 `` | +| 「schema 污染导致返回垃圾字段」 | 用**同样 schema + 同样 prompt** 直接调 ollama | **不成立**,返回 `{"fields":[{"name":"commit","value":"f91b27a"},…]}` 三个正确字段 | +| 「字段被三道闸门拦掉」 | 读代码 | **第三道之后还有第五道闸门** | + +### 真正的根因 + +```go +// split.go:248 +// 无主语时写不出 subject,直接跳过 —— 宁可少记也不写悬空属性。 +if len(subjects) == 0 { + continue +} +``` + +`deriveSubjects(record)` 返回空 ⇒ **全部字段被丢弃**。 + +实测哪些实体没有主语: + +``` +commit f91b27a,2026-09-03 已推送 0 主语 → 字段全丢 +127.0.0.1:12100(lan=false…) 0 主语 → 字段全丢 +/api/sources、/api/keys、/api/status 0 主语 → 字段全丢 +第114批周二凌晨1点·停机4分… 1 主语 → ✓ +值班室分机号 4324,值班 老周 1 主语 → ✓ +``` + +★ LLM **完全做对了**(3 个字段全对),却被 Go 侧的 + 「无主语不写悬空属性」规则丢掉。 + +⇒ 「零字段 60 条」的真相:**不是模型不行,也不是数据没得拆, + 而是主语提取在无主语句式上失效**。 + +### 三个诊断缺口叠加,让这个根因被掩盖了三轮 + +``` +① 命令只在 payload != nil 时打印 → 零字段时什么都不显示 +② 报错与零字段混在一个统计里 → 看不出「谁在丢弃」 +③ 没有主语计数的读数 → 字段凭空消失 +``` + +⇒ 这就是「判据的观测面决定你能看见什么」的三个层次。 + **修复第①层(无条件打印)之后,才看见「合法 JSON 但零字段」; + 修复第②层之后,才有机会去查闸门;缺第③层至今。** + +### 修复方向 + +| 方案 | 说明 | +|---|---| +| A | 无主语时用**属性型主语**(`62eba81` 已有 `DeriveSubjects` 的属性型受控别名) | +| B | 无主语时退化成**键值对块**(`<维度>=<值>`,无主语) | +| C | 放宽闸门,接受悬空属性 | ❌ 会污染图谱,与设计意图冲突 | + +⇒ **A 与 B 都可行,且 B 更通用**(键值块不需要主语, + 而 block-only 架构本来就允许块文本为 `<维度>=<值>`)。 + 但两者都要先有判据钉住「哪些句式被判为无主语」, + 否则又是一轮「改了但不知道有没有变好」。 + +## 三十八、主语缺失率 99% —— 蒸馏方案在生产侧不成立 + +补上第③层判据(主语计数),在生产库 429 条实体上实测: + +``` +总计 429 条:有主语 5 条(1%),无主语 424 条(99%) + +形态分布 + 命令/commit 2 条 无主语 100% + 符号开头(符号学) 2 条 无主语 100% + 路径/URL 38 条 无主语 100% + 其他 387 条 无主语 98.7% +``` + +### 而 ha-c 的主力句式在生产库一条都没有 + +``` +生产库含「第N批」 0 条 +生产库含「分机」 0 条 +生产库含「端口」 11 条 +``` + +★ ha-c 的 7/7 完全建立在「第114批…」「值班室分机号 4324」这类 + **有明确主语锚点**的句式上。生产库 99% 没有锚点。 + +### 三层结论串起来 + +``` +① LLM 拆得对 3 个字段全对(实测) +② 第五道闸门丢弃 无主语 → 全部 continue +③ 99% 的生产实体无主语 ⇒ 放宽闸门也救不回多少 +``` + +⇒ **放宽闸门(方案 B:键值块)最多救回 1% 的实体**。 + 因为 99% 的实体压根没有主语可写,键值块也写不出 `<主语>|<维度>`。 + +⇒ 生产侧的蒸馏路径**整体不成立**,不是"修一下 bug 就能跑"。 + +### 正确的方向 + +既然 99% 的生产实体是「无主语的通用事实」,那它们**本来就应该** +以整句块形态存在(`legacy-entity` 迁移块已经是这样), +而**不该**被强行拆成三元组。 + +⇒ 生产侧记忆的正确形态是: + + 整句块(可召回)+ 少量键值块(11 条「端口」类的那些) + +⇒ 蒸馏命令应该**只对有主语的实体跑**,其余跳过并明确报告: + + 待处理 429 条 → 有主语 5 条,实际处理 5 条 + +### 这个发现的元价值 + +今天我在生产侧反复撞到的不是"bug",而是**假设错误**: + + 「生产库迁移后召回能到 7/7」 —— 错,7/7 是 83% 可拆率 + 主语锚点的产物 + 「可拆率 11%,产出约 57 个」 —— 错,实际是 0(第五道闸门) + 「先修 bug 再跑全量 429 条」 —— 错,99% 无主语,跑完也是 0 + +⇒ **而每一个错误的起点,都是拿 ha-c 的结论直接套生产。** + 判据、变体、实验都在 ha-c 上做,只把探针搬到了生产 —— + 探针搬过去了,**输入分布的差异没被当成一等变量**。 diff --git a/docs/zh/recall-as-association.md b/docs/zh/recall-as-association.md new file mode 100644 index 00000000..dda301bd --- /dev/null +++ b/docs/zh/recall-as-association.md @@ -0,0 +1,77 @@ +# 召回即联想 + +> **「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」** +> —— 这个机制就是在模仿人类的联想能力。 + +## 为什么这个类比比技术描述准确 + +技术上说"命中节点 + N 层 BFS"只是描述了**做法**; +而"联想"说清了**为什么必须这样做**: + +``` +人听到「值班室分机号是多少」时真的发生的事: + + ① 想起 "值班室分机号" ← 命中节点 + ② 想起 "4324" ← 沿边一步 + ③ 想起 "旧号 4379 停用了" ← 沿边两步(4324 --停用于--> 4379) + ④ 想起 "老周值班" ← 沿边一步(反向可达) + +★ 人不会只答 ② —— ② 单独说出来没有意义(「4324」是什么?) + 联想里 ③④ 是**自动附带**的,不是被问到的。 +``` + +而"只召回主语块"(我上一条列为选项 1)恰恰是**切断联想**: +宾语块 `4324` 孤立召回时不知道自己来自哪里,于是要么丢它、 +要么给它加"来源标注"这种特判——**都是在模拟"人不会联想"**。 + +## 三条由此推出的设计约束 + +### ① 召回对象是节点,不是"事实" + +节点是记忆的载体。一条事实(`值班室分机号 --是--> 4324`)是 +**两个节点 + 一条边**,不是三个字符串。 + +⇒ 任何把事实当原子值处理的实现(把 `<主语>|<关系>=<宾语>` 存成 + 一个块的属性图形态)都丢掉了"边可以独立演进"这件事。 + +### ② 不做"来源标注"这类特判 + +联想不需要标注"这个值从哪来"——它天然在图里。 +要标注,说明图结构没建对。 + +⇒ 判据 `TestBFS_节点加N层邻居` 直接检查"孤立命中的宾语块能否 + 通过 BFS 找回主语",而不是检查标注字段是否为空。 + +### ③ 层数(N)是**上下文预算**,不是图算法参数 + +人回忆时不会无限联想——想起第三个无关的东西就停了。 +所以 N 应该由**召回预算**决定,而不是"遍历完整张图"。 + +⇒ 与实测对齐:今天的召回预算是 `blockRecallTopK = 8`、 + `blockRecallMinScore = 0.5`;N 定 1~2 层, + 否则一个高频主语会把整张网拖出来(实测实体网会织成"全连通", + 那样 BFS 就退化成全表扫描)。 + +★ **这是还没验证的部分**:N=1 时已经能带出 4 个节点(含两个方向的 + 邻居),但"织成网"之后的实际连通度与噪声率没有实测过。 + 判据 `TestBFS_节点加N层邻居` 钉的是形态,不是规模。 + +## 与已完成工作的关系 + +``` +① 边升格(52e4596) 边成为独立单位(可并存多条、承载属性) + ← 这是联想的**前提**:边不是标签才有"沿边一步" +② 节点形态回滚 主语块/宾语块(B 方案) +④ BFSBlocks 联想的实现 +``` + +顺序不能反:没有 ①,「沿边一步」就是查一个字符串标签; +没有 ②,图里只有名字节点,联想织不出有意义的网。 + +## 一条反面记录 + +我第一次实现 `Commit` 块化时(cdf0726)做成了 +`主语块 --是--> 宾语块` 的属性图形态 —— **节点形态对、但块内容错了** +(块内容是纯名字,而不是 `<主语>|<维度>=<值>` 的字段块)。 + +⇒ 形态对不等于设计对。**节点装什么内容,与节点之间怎么连,是两件事。** diff --git a/docs/zh/recall-capability-tiers.md b/docs/zh/recall-capability-tiers.md new file mode 100644 index 00000000..3ae62f25 --- /dev/null +++ b/docs/zh/recall-capability-tiers.md @@ -0,0 +1,118 @@ +# 生产召回能力分层 + +> 单一分数(如「4/9」)会掩盖真实情况。真正有用的问题是: +> **什么形态的查询能用、什么不能用、为什么。** + +数据来源:生产库快照 `/var/tmp/ha-probe/fresh.db` +(2026-10-03 `.backup`,1294 entities → 2686 块,chineseclip 512 维 + 中心化) + +## 四种形态 + +| # | 形态 | 例 | 能力 | 机制 | +|---|---|---|---|---| +| 1 | **带精确串** | 「本机 13010 端口」 | ✅ **强** | 符号路精确串命中 → 布尔置顶 → 排第一 | +| 2 | **领域词 + 上下文** | 「从零开发 QQ 插件用什么工具链」 | ✅ **可用** | 向量召回 + 中文符号滑窗 | +| 3 | **纯泛化描述** | 「公网地址是什么」 | ❌ **弱** | 向量给不出区分(排名 389/2686) | +| 4 | **库里没有** | 「grafana 监控面板的端口」 | ⚠️ **会编造** | 纯中文查询拒答判据失效 | + +## 各形态的实测依据 + +### 形态 1:精确串(强) + +``` +查询「本机 13010 端口对应什么」 + 召回 13010/13011 而非 12011 [+符号 2: 精确2] + http://127.0.0.1:13010 + 基线 纯向量下这条连 top2000 都进不去 +``` + +**机制**:`SymbolScore` 的 `exactHit` 是布尔信号,直接置顶 +(`Score = 1.0 + VectorHit*0.01`),不参与加权和 —— 否则 +满分会被 0.3 的符号权重稀释成 0.3,输给 0.33 符号分的噪声块。 + +### 形态 2:领域词(可用) + +``` +查询「脚本路径改到哪个目录了」 ✓ 命中 11 个含该词的块 +查询「从零开发 QQ 插件用什么工具链」 ✓ 命中 plugindev 那条 +查询「agentmail 公网访问地址是什么」 ✘ 命中的是「AgentMail地址格式」等 +``` + +前两条靠中文符号滑窗(2~4 字窗口)+ 向量召回。 + +### 形态 3:纯泛化描述(弱)—— 主要瓶颈 + +``` +查询「agentmail 公网访问地址是什么」 目标块排名 452 +查询「公网地址」 目标块排名 389 +查询「对外的服务地址」 目标块排名 297 +查询「101.201.37.155 是哪台机器」 目标块排名 58 ← 带 IP +``` + +★ **排名随查询的"可词法化程度"剧烈变化(452 → 58)**: + 带上 IP 后降到 58,说明起作用的主要是词法信号,语义贡献极小。 + +**根因**:目标块是裸值,没有任何可供语义匹配的文本: + +``` +http://101.201.37.155:8083/ ← 无描述词 +``` + +**已试过并否定**(见 `vector-provider-decision.md`): +给裸值块追加中性描述反而更差(排名 26 → 96,噪声稀释原值信号)。 + +### 形态 4:库里没有(会编造) + +``` +[abstention] grafana 监控面板的端口 ✘ 召回 CPU总线/MAR-MDR(0.84+) +[abstention] kafka broker 地址 ✘ 召回 read_thread/sdk站 +[abstention] 数据库容灾演练 ✘ 召回 读写代码/追调用链 +``` + +**拒答判据只对含精确串的查询有效**: + +``` +拒答 ⟺ 查询含精确串 ∧ 该精确串在全库零命中 +豁免 ⟺ 含泛指词 ∨ 无可提取符号 ∨ 纯中文(判据不成立) +``` + +纯中文的零命中**分不清「真没有」与「滑窗伪词提取失败」**: + +``` +「grafana 监控面板的端口」 → [grafana 监控面板] 库里真没有 +「本机服务监听哪些端口」 → [本机服务 监听哪些] 库里有 13010/8081 +``` + +两者符号形态完全一样,库规模(3 块 vs 1391 块)也不是区分信号。 + +## 运维结论 + +**能用**: + +- 带 IP/端口/路径/版本号的查询(运维最常见) +- 领域词 + 具体上下文 + +**不能指望**: + +- 「XX 是什么」这类泛化描述(除非用户已知道值) +- 纯中文的「库里没有」检测(系统会编造,需人工/上游确认) + +## 待补的能力 + +| 缺口 | 已排除的方案 | 可行方向 | +|---|---|---| +| 形态 3 | 统一追加中性描述(实测更差)、chineseclip(词法主导) | ① 给每个值配**真实**上文(哪条命令/哪次排障产生)② 换 Qwen3-VL 测同义改写 | +| 形态 4 | 分数阈值(间隔 0.047,n=4 是运气)、纯中文符号判据(伪词不可分) | 需能分词的中文提取(jieba 词边界与块文本对不齐,需新实验) | + +## 复现 + +```bash +PROD_SNAPSHOT=/var/tmp/ha-probe/prod.db \ + go test -tags onnxruntime ./internal/memory/ -run 'TestProbe_生产规模召回' -v + +# ha-c(测试库,83% 可拆率)的对照 +go test -tags onnxruntime ./internal/memory/ -run 'TestProbe_端到端召回' -v +``` + +⚠️ 两个库的可拆率差 7.5 倍(生产 11% vs 测试 83%), +**ha-c 上的分数不能推广到生产**。 diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md index c1edce2e..13b443c6 100644 --- a/docs/zh/scene-memory-fix-plan.md +++ b/docs/zh/scene-memory-fix-plan.md @@ -1,6 +1,6 @@ # 场景式记忆修复 plan -> 起点:2026-09-26 生产库实测(`/home/newqqagent/memory/graph.db`)。 +> 起点:2026-09-26 生产库实测(`${HA_DATA}/memory/graph.db`)。 > 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning` > strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。 > 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。 diff --git a/docs/zh/triple-distill-llm-design.md b/docs/zh/triple-distill-llm-design.md new file mode 100644 index 00000000..907cc87d --- /dev/null +++ b/docs/zh/triple-distill-llm-design.md @@ -0,0 +1,237 @@ +# 三元组蒸馏接入小 LLM:设计与实现边界 + +> 状态:**已实现并部分否决**(2026-10-04)。本文是当时的方案设计, +> 保留作为决策记录。**结论与现状以本文末尾的「执行结果」为准。** +> 依据:2026-10-02 实测(本机 ha-c 生产库、qwen3:1.7b、60 条真实语料) + +## ⚠️ 执行结果(2026-10-04 回填,与上文设计有出入) + +### 已实现 + +- generation SPI(`pkg/generation`)+ `providers/ollama/generator.go`(JSON Schema 支持) +- Qwen3-VL 生成侧(`providers/qwen3vlgen`,同一权重双模式) +- distill 块落库(`internal/memory/distill/blocks.go`,`<主语>|<维度>=<值>`) +- 字符级注意力网络(`scripts/triple-extract/`,train/predict/probe 三件套) + +### ★ 但「用小 LLM 做三元组拆分」这条路线整体否决 + +| 判据 | 结果 | 提交 | +|---|---|---| +| 字段级 F1(词法切分) | 25% | `892820b` | +| 字段级 F1(规则+查表) | **71%** | `892820b` | +| 字段级 F1(网络+规则,76 条) | 42% | `1fe0063` | +| 字段级 F1(网络+规则,分域 67 条) | token 0.918 / 跨域 0.480 | `57eca02` | + +**关键实测**: + +1. **注意力网络不泛化**。分域后 token F1 0.918 远超门槛, + 但**域外零字段** —— 与「81% 的维度名只出现一次」一致, + 同分布下仍靠记忆而非泛化。而通用 Agent 无法分域(混域 0.918 → 0.637)。 + +2. **LLM 拆分在生产侧不成立**(990b824)。生产库 429 条实体里 + **424 条(99%)无主语**,而 `Split` 的第五道闸门 + 「无主语不写悬空属性」会把 LLM 已拆对的字段全部丢弃 + (实测 60 条 / 837 秒 / **0 产出**)。已改为只对有主语的实体跑。 + +3. **更值钱的是"值形态"这条线**。纯文本块的可拆性上限约 71%, + 而召回侧的收益(中心化 5/7 → 6/7、融合召回 2/9 → 4/9) + **远大于拆分质量的提升**。 + +### 结论 + +> 拆分质量不是记忆系统的瓶颈。**召回质量才是。** +> +> LLM 拆分保留作为**离线批处理**手段(标一次、用多次),不进在线路径。 + +### 现行设计文档 + +- `triple-extract-attention.md` —— 注意力方案的完整实验记录(24 步,含所有否定结论) +- `production-recall-validation.md` —— 生产侧实测与召回质量 +- `recall-capability-tiers.md` —— 召回能力分层 +- `vector-provider-decision.md` —— 向量 provider 决策与已知缺口 + +### 一条元教训 + +本文的设计与实现都对,但**建立在一个未验证的前提上**: +「ha-c 生产库」这个措辞暗示它是生产数据,实际上它是 +**可拆率 83% 的运维测试集**。真生产库可拆率 11%、主语率 1%。 + +⇒ 判据、变体、实验都在 ha-c 上做,只把探针搬到了生产 —— +**探针搬过去了,输入分布的差异没被当成一等变量。** + +## 一、要解决的实测问题 + +### 1.1 自动蒸馏产出为零 + +``` +extractKeyTriples() ← internal/memory/pipeline/pipeline.go:398 + └─ nlp.NewExtractor(nil) defaultParser 实测为 nil + └─ extractFromPOS gojieba Tag() + posTemplates 匹配 + └─ 实测 4 条真实语料 → 0 条三元组 +``` + +**库里 188 个实体、143 条关系全部不是这条路写的。** 证据: + +- 95/143 条关系是 `交接文档整理`/`发布窗口与回滚`/`发布评审` 这类语义化类型, + 而 `extractFromPOS` 只能产出「是/有/被标为」这类模板词。 +- 长实体是原句的**改写压缩版**: + ``` + 实体: 第112批周四凌晨2点·停机4分·回滚v2.29.5·灰度10%观察48分后放50% + 原句: 第112批,30 日:发布窗口定在周四凌晨 2 点,预计停机 4 分钟;回滚版本锁定为 v2.29.5;… + ``` + jieba 模板做不出改写。 + +**结论:记忆实际由模型主动调 `memory_commit` 写入,模型把看到的整句改写后塞进去。** + +### 1.2 根因在工具 description + +`memory_commit` 的 description(internal/memory/indexer.go:462)讲的是 +`media_digests` 和 `scene` 怎么填,**完全没要求实体名原子化**。 +模型写出 43–50 字符的整句实体是 description 的必然结果,不是模型的错。 + +### 1.3 后果:跨维度检索全失效 + +严格判据(生产库 188 实体 + 同域硬负样本): + +``` +基线 jieba + LIKE 0/5 针排名 5~6 +问「第181批的值班手册是第几版」→ 完全答不出 +``` + +原因:库里实体是**一个实体塞多个事实**的复合值—— +`admin服务端口8861·billing服务端口8499·oauth服务端口8271`。 +问 billing 端口时,针 `billing服务端口8499` 被这种大杂烩压在后面。 + +**换任何向量模型都救不了,这是入库粒度问题。** + +## 二、为什么用小 LLM 而不用现有 nlp 抽取器 + +实测排除了两条路: + +| 方案 | 判据 | 结果 | +|---|---|---| +| 规则拆分(按 `·`/`,`) | 跨维度定位 | **0/20** — 拆开后主语和值变成互不相关的实体,答案丢失 | +| `extractFromPOS`(jieba 模板) | 真实语料产出 | **0 条** | +| 小 LLM 拆分(qwen3:1.7b, think=False) | 值是否原样 | **0% 幻觉**(待修正版复核) | + +规则拆分失败的机制值得记住:`值班手册第4版` 和 `第183批` 原本在**同一句话**里 +(隐含"这批的值班手册是第4版"),拆开后它们是两个独立实体,图中无边可循。 +**拆分必须保留主语锚。** + +## 三、架构位置 + +``` +Distiller.distillOnce() 30 分钟定时 + └─ distillBatch() ≤50 条 + └─ extractKeyTriples(user, assistant) ← 改这里 + ├─ 现状:nlp.NewExtractor(nil) → 0 条 + └─ 改后:LLM 拆分 → []memory.Triple + └─ db.Commit(triples, sessionID, 0) +``` + +`Distiller` 已有的基础设施: +- `go d.distillLoop()`(pipeline.go:77)定时循环 +- `distillOnce` 失败回退重试(pipeline.go:270) +- `removeRawRecords` 成功后清盘(pipeline.go:347) +- `flush`/`loadExisting` 进程重启后不重蒸 + +**按需加载的天然位置就在这里**:每 tick 先看有没有待蒸馏记录, +0 条就不碰模型,有记录才加载、拆完释放。 + +## 四、要改的四处(按依赖顺序) + +### 4.1 `providers/qwen3vl`:加生成侧图与推理 + +**现状**:`Transformer.onnx` 的输出被硬编码为 `Shape{1, Dimension}` +(embedder.go:441),last-token 池化烘焙在图里。 + +导出脚本的理由是对的: +> 池化放在图里(而不是 Go)是有意的:last-token 的位置由 attention_mask 决定, +> 一旦 Go 侧算错位置就会静默取到 padding 的 hidden,而向量照样归一化、照样能比余弦 +> ——那种错误只能靠与参考向量对比才能发现。 + +**这个判断要保留**(静默错误比崩溃更糟),所以**不能改这张图**, +只能**再导一张**: + +| 图 | 输出 | 用途 | 复用 | +|---|---|---|---| +| `Transformer.onnx`(现有) | `[1, dim]`(池化后) | 检索向量 | — | +| `LMHead.onnx`(新增) | `[1, seq, vocab]` | 三元组生成 | **TokenEmbedding.onnx 共享** | + +`lm_head` 靠 tied embeddings(实测 625 张量里没有独立 lm_head, +`tie_word_embeddings=True`),所以 `LMHead.onnx` 只是 +`matmul(norm(hidden), embed_tokens^T)` + 可选采样,**几乎不占空间**。 + +已有可直接复用的(providers/qwen3vl 里全部现成): +- `Tokenizer`(tokenizer.go:152 的 chat template 三段拼接) +- `rotary()` / `causalMask()`(embedder.go:458/490) +- M-RoPE 三分段位置(model_input.go:44) +- `close sync.Once` 生命周期(embedder.go:70) + +**要加的**: +- `Generate(ctx, prompt, opts)` — 循环自回归,KV cache 逐步追加 +- `lmHead *ort.DynamicAdvancedSession` — 与 `token`/`vision` 同样的懒加载 +- 采样(temperature=0 时退化为 argmax,蒸馏场景够用) + +### 4.2 `pkg/embedding`:契约要扩,还是另起一个 SPI + +**现状**:`pkg/embedding.Provider` 只有 `Embed` + `Info` + `Close`, +能力声明是数据(`Info.Modalities`)。 + +**不能把生成塞进这个接口**——embedding 是"输入→向量",生成是"输入→文本", +塞进去会让所有 provider 都得实现生成。 + +**建议**:新增 `pkg/generation` SPI,与 `pkg/embedding` 并列, +`providers/qwen3vl` 同时实现两者(共享同一份权重与 Runtime 生命周期)。 + +**不选**「在 embedding SPI 上加可选接口 + 类型断言」—— +那会让调用方写 `if g, ok := p.(Generator); ok`, +每个调用点都要判一次,而蒸馏只有一个调用点需要它。 + +### 4.3 `pipeline.go`:换掉 extractKeyTriples 的实现 + +**保留 jieba 版**作为降级路径(ONNX 缺失、加载失败、LLM 超时时用它, +哪怕产出 0 条也比整个蒸馏停摆好)。要能区分「LLM 拆了 0 条」和 +「LLM 没跑」——前者是正常结果,后者才回退。 + +**批量而非逐条**:实测 23.4s/条 × 50 条 = 19.5 分钟,几乎吃掉整个 +`DistillInterval`(30 分钟)。必须一次调用处理整批, +并对 `num_predict` 设上限防止失控。 + +### 4.4 拆分输出的后处理 + +**必须做的两件事**(实验暴露的): + +1. **值必须原样来自原文** — 出现不在原文里的值就丢弃该条。 + 这是唯一能挡住幻觉的闸门,因为生成侧没有向量可验。 +2. **维度名归一** — 实测同义异名严重(`停机`/`停机时间`、 + `回滚`/`回滚版本`),不归一就没法建边。 + 受控词表从现有数据归纳(跑分语料里出现 29 个维度)。 + +**不做**:不试图让 LLM 自由命名维度后靠后处理纠正——那是把不确定性留在系统里。 + +## 五、待验证(实验进行中) + +`bb69252e6`:qwen3:1.7b 拆 60 条真实语料,三个独立判据。 +**上一版的 20/20 是循环验证**(探针答案取自 LLM 输出再查同一份输出, +temperature=0 下必然全中),已作废。 + +| 判据 | 内容 | 决定什么 | +|---|---|---| +| Q1 原子覆盖率 | vs 强分隔符切出的原子 | 有没有丢信息 | +| Q2 值是否原样 | 值必须出现在原文中 | 有没有编造 | +| **Q3 跨维度定位** | 手写问句,与 LLM 输出无关 | **拆分是否值得做** | + +Q3 若不显著高于现状的 0/20,本设计不成立,应回头修 `memory_commit` +的 description 让模型自己拆,而不是加一层 LLM 蒸馏。 + +## 六、明确不做 + +- **不改 `Transformer.onnx`**:它把池化烘焙进图是正确设计(防 Go 侧静默错位)。 +- **不把生成塞进 `pkg/embedding`**:契约不同,见 4.2。 +- **不常驻加载模型**:靠 30 分钟定时 + 有记录才加载。 +- **不删 jieba**:降级路径要用;`fallbackParser` 也依赖它做 POS。 + 但实测它的 135MB 常驻(4.8MB 词典 → 135MB 前缀树)是真实成本, + 等生成侧稳定后再单独评估。 +- **不迁移现有向量维度**:embedding 侧是 2048 维,与 chineseclip 的 512 不同, + 但这是 embedding provider 之间的事,与本设计无关。 diff --git a/docs/zh/triple-extract-attention.md b/docs/zh/triple-extract-attention.md new file mode 100644 index 00000000..f1953c9e --- /dev/null +++ b/docs/zh/triple-extract-attention.md @@ -0,0 +1,758 @@ +# 三元组抽取:注意力模型方案 + +> 2026-10-03。目标是**推理时零 LLM**——一次 LLM 标注,训一个专用网络。 + +## 一、为什么必须是「注意力学任务结构」而不是更好的向量 + +本会话的实测给出了两条独立证据: + +| 实验 | 结果 | +|---|---| +| 词法切分(纯规则) | 值抽取 75%,字段级仅 25% | +| chineseclip 向量配对(冻结编码器) | **0/5**,连「值班人」↔「值班人」都配不上 | + +第二条是决定性的:`值班人` 和 `值班人` **字面完全相同**,余弦却输给 `端口`(0.5531)。 +chineseclip 把一句话压成一个 512 维固定向量,**没有针对这个任务的注意力**—— +它不知道「哪个 token 是属性名、哪个是值、哪个是连接词」。 + +而这正是 transformer 注意力的本职: + +``` +「第112批周四凌晨2点·停机4分·回滚v2.29.5·灰度10%观察48分后放50%」 + + 第112批(主语) 周四凌晨2点 · 停机4分 · 回滚v2.29.5 · 灰度10% 观察48分后放50% + └─ 头应该关注 ─┘ └ 值 ┘└ 值 ┘└ 值 ┘└ 值 ┘ └值┘ + 「停机」只关注「4分」 「灰度」只关注「10%」 +``` + +同一句话里,不同的注意力头分工不同 —— 这是固定向量做不到的。 + +## 二、任务形式的选择 + +### 方案 B(序列标注)—— 采用 + +给每个字符打一个标签: + +``` +标签集(9 类) + O 其它/连接词(从、改为、轮换到、·) + B-DIM 属性名开头 + I-DIM 属性名内部 + B-VAL 值开头 + I-VAL 值内部 + B-SUBJ 主语开头(多主语记录用) + I-SUBJ 主语内部 + PAD 填充 + +「连接池从 32/64 扩到 128/256」 + 连接池从 → B-SUBJ I-SUBJ I-SUBJ O + 32/64 → B-VAL I-VAL I-VAL I-VAL I-VAL + 扩到 → O O + 128/256 → B-VAL I-VAL I-VAL I-VAL I-VAL I-VAL +``` + +**为什么不用方案 A(在受控维度名里分类)**: + +- 现有标注里维度名 17 种,但**6 种只出现 1 次(35%)** —— 分类器无法表达词表外的新维度 +- 而维度名正是要**生成**的东西,不是要**选择**的东西 + +**为什么不用指针网络**:抽取式要先枚举候选片段对,而实测候选池的真值召回只有 +20%(51/259)—— 枚举不出来就没法抽。序列标注直接在字上标,绕开这个漏。 + +## 三、模型规格(CPU 可训) + +``` +分词 字符级(中文无需分词,且字符级不会切错「值班室分机号」) +词表 从训练集统计,取频次 ≥2 的字 + 特殊符,约 1500~2500 +嵌入 128 维 +层数 4 +注意力头 4(2 头 "看属性名↔值" 的配对,2 头自由学) +FFN 256 +Dropout 0.2 +总参数 ≈ 1.2M +``` + +**为什么这么小**:CPU 12 核、17 GB 内存、没有 GPU(P4 掉线)。 +实测 2968 个训练 token 的规模下,1.2M 参数已经过参数化的风险很低, +而这个规模**不需要预训练**——从零训,因为: + +- 任务词汇封闭(运维数字/端口/批次/分机号) +- 预训练模型的 2~3 亿参数是纯浪费,且 CPU 上前向都慢 + +**「从零训」在这里不是妥协,是对的**。预训练权重带来的是通用语言知识, +而这个任务要学的是**版式**(哪里是属性名、哪里是值)—— 版式没有通用先验。 + +## 四、递归调用:一句话 → 多组三元组 + +序列标注天然给出多组三元组(每个 `B-DIM … B-VAL` 是一个), +不需要"递归": + +``` +输出标签序列后: + 扫描 B-DIM / I-DIM 聚成属性名片段 + 扫描 B-VAL / I-VAL 聚成值片段 + 按顺序配对:第 k 个属性名 ↔ 第 k 个值 + 全部转成受控维度名(NormalizeDimension) + 全部转成主语(若标了 B-SUBJ) +``` + +「递归」只在一种情况下需要:**值里还嵌套属性**(如 `连接池容量=128/256` 里的 +`连接池` 既是主语又是维度的一部分)。第一版不做,遇到时记日志。 + +## 五、训练数据现状与缺口 + +``` +来源 样本 字段数 有效信号 +真库 ha-c(已有) 90 260 70 条多字段句 +生产库(标注中) 429 ? 仅 20 条含「数字+单位」 +──────────────────────────────────────────── +合计 519 ? ~90 +``` + +**缺口**:序列标注需要约 5000 条句子 × 35 字 ≈ **17.5 万 token**, +中文 NER 的典型规模是 10 万~200 万 —— 差一个数量级。 + +**这决定了它现在不能训出可用模型**。可行的路径: + +1. 扩充数据源:真库 188 条 + 生产库 1295 条 + 对话归档 + 历史邮件 + (预计能到 3000~5000 条句子) +2. 或者**改任务**:既然值抽取已 75%(词法可做), + 网络只学「给已知的值配维度名」—— 这是个二分类任务, + 样本数 = 真值数 × 负样本倍数,比序列标注省一个数量级 + +第 2 条是我目前更倾向的,因为它把难题缩小到了「值↔维度配对」, +而那正是词法做不到、注意力擅长的部分。 + +## 六、判据(照本会话惯例:每条都有变异自证) + +| 判据 | 内容 | +|---|---| +| 标签对齐 | 字符级标注与序列标签严格一一对应,无偏移 | +| 幻觉闸门 | 产出的值必须**原样出现在原句**(与 LLM 路径同一道闸) | +| 泛化 | 留出集上字段级 F1;词法基线是 25% | +| **不得低于基线** | 网络在留出集上必须超过词法的 25%,否则说明网络没学到东西 | +| 三类可分 | 同值对 / 异值对 / 跨维度对的分布必须可分(否则学到的只是长度先验) | +| 确定性 | 同输入连跑 3 次输出完全一致(**LLM 路径目前做不到**) | + +最后一条是这个方案最大的隐性收益:**确定性**。LLM 路径在本会话实测 +「同批 146 条跑两次差 40%」(后来查明很可能不是随机性,而是 ollama 0.31.1 +的 schema grammar 污染 bug —— 但即使修好,神经网络推理也是逐位确定的)。 + +## 七、当前阻塞 + +标注在跑(`/tmp/label-work/labeled.jsonl`,429 条,约 3~4 小时)。 +**标注质量是第一道关**——本会话已经踩过 ollama schema 污染, +所以每条标注都带「值必须原样出现在原句」的闸门,且产出与原句一起落盘, +以便事后逐条复核。 +## 八、数据实测:标注分两层,网络只该学其中一层 + +把 260 个 LLM 标注逐条与原句对齐后(属性名「值」都要能在原句里定位): + +``` +字面可对齐 212 / 260 (82%) +主语派生 48 / 260 (18%) ← 网络学不了,也不该学 +``` + +### 主语派生的那一类长什么样 + +``` +「第130批 v2.31.5 评审通过·采样10%」 + → 版本=v2.31.5 属性名「版本」不在原句(原句只有 v2.31.5) + → 批次号=130 属性名与值都不在原句(原句是「第130批」,N 藏在「第」与「批」之间) +``` + +逐维度看: + +| 维度 | 条数 | 对齐失败率 | 性质 | +|---|---|---|---| +| 排期 / 容量预警 / 告警规则数 / 值班手册版本 / 评审通过 / 停机时长 | 178 | **0%** | 字面可对齐 | +| 版本 / 版本线 | 32 | 97% | 主语派生(原句只有 `v2.31.5`) | +| 批次号 / 批次 | 40 | 100% | 主语派生(`第N批` 的 N 是**结构位置**,不是连续片段) | +| 发布窗口 / 连接池容量 / 等待队列告警阈值 / 状态 | 8 | 100% | 主语派生 | + +★ **「批次号=130」是最有说服力的一例**:值 `130` 在原句里根本不是连续片段 +(它是「第**130**批」的中间),任何基于 span 的标注法都对齐不了它。 + +### 由此确定分工 + +| 类型 | 占比 | 处理者 | 理由 | +|---|---|---|---| +| 字面可对齐 | 82% | **注意力网络** | 属性名与值都在句子里,注意力学的是「哪个 token 是属性名」 | +| 主语派生 | 18% | **纯规则**(`DeriveSubjects` 已能推出主语,从主语结构直接取) | 不在句子表面,神经网络无输入可用 | + +★ 这条分工让训练数据量需求从「17.5 万 token」降到「约 8.6 万 token」, +更关键的是**学的是真问题**(版式识别)而不是学一个学不会的东西。 + +## 九、还发现一条 LLM 标注质量问题 + +``` +值 = 整句:9 / 260 (3%) +「第115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100%」 + → 版本=115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100% +``` + +模型把整句当成「版本」的值。现有闸门拦不住它(值确实「原样出现在原句」—— +它就是整句)。**新增判据:值的长度不应超过句子的 60%**,否则视为坏标注。 + +## 十、推理侧与首轮实测(数据不足,模型不可用) + +推理侧已写好(`scripts/triple-extract/predict.py`),形态是**网络 + 规则混合**: + +``` +网络 负责「句子里表面就有属性名与值」的 82% +规则 负责「值藏在主语结构里」的 18%(批次号=第N批里的 N、版本=v2.31.5) +闸门 两边共用同一道(值必须原样出现在原句;值长度 ≤ 句子 60%; + 属性名必须能在原句里对上 —— 网络不比 LLM 宽松) +``` + +### 规则部分:实测全对 + +``` +「第114批周二凌晨1点·停机4分·回滚v2.28.4·灰度10%观察70分后放50%」 + → 第114批|批次号=114 ✓ + → 第114批|版本=v2.28.4 ✓ + → 第114批|发布窗口=凌晨1点 ✓ +``` + +这三类正是网络学不了的那 18%,规则路径有效。 + +### 网络部分:实测不可用 + +同一句的网络输出: + +``` +[ 0] B-DIM '值' ← 应是「值班室分机号」 +[ 1] I-DIM '班' +[ 4] B-DIM '机' +[ 5] B-VAL '号' +``` + +**位置全错**(标在第 0/1/4/5 个字符,实际属性名在 0-5、值在 7-10), +且 `值班室分机号 4324,值班 老周` 里的 `4324` 完全没被标出。 + +原因不是实现 bug,是**数据量**:47 条训练样本 × 35 字, +留出 19 条上 F1 0.71 且 ep20 后开始回落(已在过拟合)。 +训练集的标签分布是 `O:771 / I-VAL:592 / I-DIM:565 / B-VAL:192 / B-DIM:187` —— +模型连「B 只在 span 首字符出现」这个最基本的 BIO 约束都没学稳。 + +### 一个训练脚本的 bug(顺带修掉) + +`OneCycleLR(total_steps=epochs * len(train) // bs)` 在步数不匹配时 +会抛 `Tried to step 151 times. The specified number of total steps is 150` +—— 训练跑到一半崩掉。改用 `ceil` 并显式设 `pct_start`。 + +### 判据 4(网络必须超过查表基线 71%)现在的状态 + +| 方案 | 留出表现 | +|---|---| +| 规则 + 查表 | 71.0%(probe_attr_name.py 判据 4) | +| 词法切分 | 字段级 25% | +| **网络(47 条训练)** | **位置全错,不可用** | + +⇒ 数据量从 47 提到几百条之前,网络这条路的结论无法给出。 +标注在跑(256/425),完成后重训。 + +## 十一、扩数据前的诊断:瓶颈是标注质量,不是数据量 + +生产库标注 128 条 / 238 个字段,逐字段检查: + +``` +正常 157 (66%) +值 = 整句或近整句 43 (18%) ← 坏标注 +值占 1/3~60%(可疑) 38 (16%) ← 边界可疑 + +含坏值的样本 43 条,其中 37 条整条都是坏的 +``` + +典型形态: + +``` +「第115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100%」 + → 版本 = 115批周二凌晨2点·停机6分·回滚v2.28.1… ← 整句当值 + +「见 picture / see_video / listen 三个,进程未崩,日志正常」 + → 操作 = see_picture / see_video / listen … ← 列表当值 + → 进程状态 = 未崩 ← 这条是对的 + +「AgentMail 邮件通道插件 v0.1.0」 + → 插件名称 = AgentMail 邮件通道插件 v0.1.0 ← 整句当值 + → 插件版本 = v0.1.0 ← 这条是对的 +``` + +★ **丢掉坏字段后救不回来**:146 条丢弃样本里只救回 2 条 —— +坏标注通常整条都是坏的,所以「部分修复」这条路无效。 + +### 根因:为绕开 schema bug 改用纯提示词约束 + +`format` schema 本会禁止超长的 value;改成提示词约束后没有了强制力。 +**代价是 18% 的坏标注**(代价换来的是不再返回上一次的输出,见 §七)。 + +修法(提示词加两条 + 闸门加一条): +- value 必须是记录里**一段连续文字** +- value 长度**不得超过 12 字**,不得接近整句 +- 找不到短值就跳过该字段(宁可少记) + +## 十二、批处理标注:省 47% + +单条 28 秒的构成(真实 timing): + +``` +prompt_eval 13~14 秒 806 字符固定前缀,与记录数无关 +eval 2~14 秒 与输出长度成正比 +``` + +所以批内每条只摊到 eval 那部分: + +``` +逐条: 14 + 14 = 28.0 秒/条 +十条一批: 14 + 10×14 = 15.4 秒/条 (省 45%) +二十条一批: 14 + 20×14 = 14.7 秒/条 (省 47%) +``` + +⇒ 2000 条从 15.6 小时降到 **8.2 小时**。 + +批处理的额外风险是**模型漏掉某个序号**,所以解析时要求 +「必须为输入的每个序号都给一条结果」,缺的就记 `missing` 而不是静默丢弃。 + +## 十三、修正一个我自己的错误分类 + +中途我用正则统计过一次「生产库 72% 的记忆不可拆」—— +**那个判据是错的**:它只匹配数值/版本/批次,于是把 + +``` +portal-nginx 容器已 Exited 两周,不是它提供服务 + → (portal-nginx, 状态, Exited) (portal-nginx, 持续时间, 两周) +``` + +这类判成"不可拆"。 + +★ 通用 Agent 的句子几乎都能拆 —— 每句都有主体(名词短语)与谓词。 + 「72% 不可拆」的结论作废,撤回。 + +**判据本身比结论更值得记**:一个正则分类器给出的分布, +先怀疑分类器,再相信分布。 + +## 十四、重标过程:四个坑全是「我的实现」而非「模型」 + +改进标注器时连续踩了四个坑,**每一个的第一反应都会归咎于模型或数据, +但真因都在自己的代码里**。逐个记下来,因为它们都会在下一轮重来一遍。 + +### 坑 1:批处理(12 条一批)全军覆没 + +试「一条请求标 12 条」(本该省 47%),结果 **11/11 全 error**。 + +真因:qwen3:1.7b 处理不了「给多条记录分别输出」这个嵌套结构 +(`{"results":[{"i":0,...},{"i":1,...}]}`),输出是一长串 `}`。 + +⇒ **批处理这条路要更大的模型**。1.7b 的结构化输出上限就是单个对象。 +我应该在写之前就想到这点 —— 之前「多示例 vs 单示例」的实测已经证明 +它的结构化能力很窄。 + +### 坑 2:加长提示词让模型失控 + +版本 B(加「≤12 字」等多条约束 + 换示例)→ 模型输出变成 +`` 碎片循环。 + +版本 C(**只加一条规则、不动示例**)→ 正常。 + +★ 与之前「加 nonce 后出现 JSON 截断」是同一类问题: +**qwen3:1.7b 的结构化输出对提示词形态极其敏感**。 +教训:约束要加,但**一次只加一条**,且必须逐版本实测。 + +### 坑 3:JSON 解析的贪婪匹配 + +```python +re.search(r'\{.*\}', raw, re.S) # ← 贪婪到最后一个 } +``` + +模型输出「`{"fields":[]}` …说明文字… `{"fields":[]}`」时, +匹配到的是两段连在一起的非法 JSON ⇒ **100% 判 parse 失败**, +而原始输出其实完全正常。 + +改成「遇到 JSONDecodeError 就补闭合符」也**不对** —— +这里的错误是 `Extra data`(尾部多了内容)不是缺括号,追加只会越补越糟。 + +正确做法:从每个 `{` 起点**向右逐个 `}` 枚举结束位置**, +第一个能 `json.loads` 成功的片段就是答案。 + +★ 这个坑最隐蔽:症状(parse 失败 100%)看起来像模型崩了, +实际是我的解析器太贪心。**原始输出要先看,再怀疑模型**。 + +### 坑 4:无值记录会让模型崩溃 + +「CodeGraph安装任务」(12 字、无任何数值)→ 模型输出 +`实现的的第10000年发布\nCodeGraph的\n...` 的碎片循环。 + +这类句子本来就没有可拆字段(零字段是正确答案), +送进模型只是浪费 30 秒并产出垃圾。 + +⇒ 标注前先按「是否含值形态」预筛(数值/版本/批次/时刻/日期/区间), +无值的直接跳过。**这同时省掉了批处理那 47% 省不下来的时间** —— +因为无值记录占比不低(生产库 429 条里「含数字+单位」只有 20 条)。 + +## 十五、重标 vs 旧标注的预期差异 + +``` +旧标注(label.py) 429 条 → 149 条有字段(34.7%),幻觉 48 个 + 字段级合规率 68%(32% 的值超 12 字 = 整句当值) + +新标注(label3.py) 跳过无值记录 + 12 字闸门前置 + 修好的解析 + 预期:字段级合规率接近 100%(闸门拦在入库前) + 预期:parse 错误基本消失 +``` + +★ 关键差别:旧版把超长值**记下来再由训练脚本丢弃**(浪费标注); +新版在**标注时就拦掉**(产出即合规),所以同样的时间里有效样本更多。 + +## 十六、合并 GUI 分支后的验证 + +``` +origin/feature/gui-starmap-pulse-vendor-align 26 文件 / +6089 行 + 与记忆侧工作零文件重叠 → 自动合并无冲突 + go build ./... 通过 + go test -short ./cmd/... cmd/homed ok cmd/waiter ok + go test -short ./internal/memory/... 全绿 + npm test(cmd/gui,9 个 .mjs) 38 项全部通过 +``` + +★ 合并后发现**那个分支自己就编译不过**(`cmd/waiter/device.go:743 undefined: mag`)—— + 不是合并冲突,是 `0d8b129` 自带的 Go if-init 作用域错误。已修。 + +## 十七、现有可训练数据的画像 + +76 条可对齐样本 / 206 字段 / 59 种维度名: + +``` +排期 38 ███████████████████ +容量预警 37 ██████████████████ +告警规则 36 ██████████████████ +值班手册 33 █████████████████ +…其余 55 种共 62 个字段,其中 48 种只出现 1 次(81%) +``` + +★ **81% 的维度名只见过一次,这是好事而非坏事**: + 它说明任务不能退化成「记住这 59 个名字」(那样必过拟合), + 而必须学**结构** —— 哪段是属性名、哪段是值、它们怎么相邻。 + +这与 `find_dim_in` 的思路一致:属性名可以模糊匹配(`告警规则数`↔`告警规则`), +所以网络要学的是「找到那个**可以当属性名的片段**」,不是「输出这个词」。 + +## 十八、并行三个任务导致的资源事故(本轮教训) + +同时跑了三件事:网络训练、标注重标、GUI 测试。结果: + +``` +load average 33.5 (12 核,超载 2.8 倍) + +%CPU ELAPSED COMMAND + 489 01:38:58 llama-server ← 见下 + 332 04:51 python3(训练) + 103 01:42:53 Emulator ← 安卓模拟器,与本工作无关 + 99 01:43 python3 + 84 09:38:33 node +``` + +★ **两个真实问题**: + +1. **训练被拖到 5 分钟还没出 ep10** —— 抢不到 CPU。 +2. **标注重标 30 分钟只完成 1 条** —— 因为 llama-server 在抢。 + +而那个吃掉 489% CPU、跑了 **1 小时 39 分**的 `llama-server` 是**残留进程**: +`api/ps` 只列出 `qwen3:1.7b`,但它对应的是上一轮已卸载的模型 +(ollama 的 `keep_alive` 到期后没回收进程)。 + +⇒ 停掉残留进程后训练立刻开始出结果(ep10 F1 0.695)。 + +### ★ 修正:并行不是主因,机器上长期跑着别的服务才是 + +停掉残留进程后 load **只从 33.7 降到 28.3**,仍然远超 12 核。 +继续查占用者: + +``` +%CPU ELAPSED COMMAND + 359 09:57 python3 ← 我的训练,正常(12 核的 3 倍) + 173 01:47 python3 ← Emulator 的子进程 + 103 01:47:58 Emulator ← 安卓模拟器,/opt/clt26/… + 84 09:43:39 node +``` + +**这台机器上长期跑着安卓模拟器等非本项目服务**, +它们才是 load 26 的主因。并行三个任务只是雪上加霜,不是根本原因。 + +### 教训 + +**残留进程比崩溃的进程更难查**:`api/ps` 说模型只有 1.7b, +但 `ps` 显示还有一个 llama-server 占着 489% —— +两个工具的视角不一致,光看 API 会漏掉。 + +**归因要查到占用者本人**:「load 高」有很多可能(我的任务/别人的服务/ +残留进程/内核 IO 等待),我先归因为「并行三个任务」是**过早下结论** —— +正确做法是先 `ps --sort=-pcpu` 列出占用者,再判断谁是谁。 + +★ 这是本会话第三次犯「先归因后核实」: + ① 「模型随机性」实际是 ollama schema 污染 + ② 「72% 记忆不可拆」实际是我的正则判据错 + ③ 「并行三任务导致高负载」实际主因是机器上别人的服务 + +## 十九、判据 4 的结果:**网络路线不成立** + +76 条可对齐样本训练 80 epoch(CPU,约 25 分钟),留出集曲线: + +``` +ep10 F1 0.695 ep30 F1 0.704 ← 最佳 +ep20 0.699 ep40 0.699 +ep30 0.704 ep60 0.701 +ep50 0.700 ep70 0.702 +训练损失 ep70 时已降到 0.022 +``` + +**从 ep10 起就横盘在 0.70,而训练损失一直在降** —— 典型的过拟合: +模型在记住训练集,泛化不再改善。 + +### 判据 4:网络 vs 查表基线 + +| 方案 | 字段级 F1 | 说明 | +|---|---|---| +| **规则 + 查表** | **71.0%** | probe_attr_name.py 的判据 4 | +| 网络 + 规则 | **42.0%**(P 0.452 R 0.393) | 网络产出 303 个字段,**约一半是误报** | +| 词法切分 | 字段级 25% | 纯规则 | +| 纯网络(推理实例) | 几乎为 0 | 输出 `采样=.` / 零字段 | + +**网络远未超过 71% 的门槛**,判据 4 失败。 + +### 定性观察 + +``` +「第130批 v2.31.5 评审通过·采样10%」 + 规则(2 条全对):批次号=130 版本=v2.31.5 + 网络(1 条垃圾):采样=. + +「值班室分机号 4324,值班 老周」 + 两者都是 0 字段 +``` + +网络把句点标成了值,把 `4324` 整个漏掉 —— 它连「数字是值」这个最基本的 +先验都没学到(训练集里数字开头的值有 121 个,够多了)。 + +## ★ 结论:数据量还不够,架构不是瓶颈 + +**我不能据此说「注意力学不了版式」** —— 76 条训不出东西, +和 2000 条训不出东西,是两回事。 + +诚实的表述是: + +| 数据量 | 网络表现 | 结论 | +|---|---|---| +| 76 条 | F1 0.42(低于查表 71%) | 不足以判断架构 | +| 目标 2000 条 | 未测 | — | + +要拿到「架构行不行」的答案,需要**至少 500~2000 条**, +即把标注从 76 条扩到那个量级。而按实测速率: + +``` +CPU 单条标注 28 秒 → 2000 条 = 15.6 小时 +(批处理不可行:1.7b 处理不了嵌套结构,见坑 1) +``` + +## 因此下一步的选择 + +| 选项 | 代价 | 收益 | +|---|---|---| +| **A 继续标注到 2000 条** | 15.6 小时(可后台跑) | 拿到架构的确定答案 | +| **B 放弃网络,走规则 + 查表** | 0 | 立即可用,但停在 71%(且那 29% 需要人工) | +| **C 换更大的标注模型** | 未知 | 批处理可行时能省 47%;qwen3-vl:2b 已在本地 | + +我倾向 **A + C 组合**:用本地的 `qwen3-vl:2b` 试批处理(它是 2B, +结构化能力比 1.7b 强),若批处理可行则 2000 条降到 8 小时以内。 + +## 二十、批处理标注:确认不可行 + +试了两种提示词、两个模型,全部失败: + +``` +qwen3-vl:2b 简明格式 53s 输出 0 字(空字符串) +qwen3:1.7b 简明格式 421s 输出「2. 2024-04-15 15:00:00」重复 13 次 +qwen3-vl:2b JSON 数组 90s 输出 0 字 +qwen3:1.7b JSON 数组 — 输出一长串 } +``` + +★ **这不是提示词问题** —— 两种格式(简明指令 / JSON 数组)都是同样结果。 + 1.7b 的结构化输出能力上限就是「单个对象」,给它「多条记录的数组」它会 + 陷入循环;2B 直接不输出。 + +⇒ **批处理这条路断了**,2000 条只能逐条标(CPU 单条 28 秒 → 15.6 小时)。 + +### 一个连带推论 + +既然本机两个可用模型都无法可靠产出结构化结果, +那么**「用 LLM 生成训练数据」这条路的天花板就在这里** —— +除非换成 API 模型或更大的本地模型(27B 的 `qwen3.8:27b-iq2m` 见过但未测, +它占 489% CPU 且很可能更慢)。 + +这改变了 A 方案的性价比: + +``` +原估计 2000 条 = 15.6 小时(以为批处理能省 47%,实际不能) +实际 2000 条 = 15.6 小时,且无法再压缩 +``` + +## 二十一、数据量翻倍反而更差 —— 推翻「数据不够」 + +``` + 76 条(纯运维域) 最佳 F1 0.704(ep30) +122 条(运维+通用混合) 最佳 F1 0.637(ep10,之后一路降到 0.612) +门槛(规则+查表) 0.710 +``` + +**加了 46 条数据,F1 反而掉了 0.067。** + +### 根因:两个域的分布互相冲突 + +``` +ha-c(运维域) 67 条 维度名 10 种 179 字段 只1次 4 (36%) + 排期40 容量预警39 告警规则数37 值班手册34 +生产库(通用域) 55 条 维度名 78 种 83 字段 只1次 73 (94%) + 端口2 prompt2 size2 充电时间2 … +``` + +★ 合起来 122 条后,模型面对的是**两个互相矛盾的分布**: + +- 运维域:**高度集中**,记住「排期/容量预警/告警规则数」就有 179 个字段 +- 通用域:**几乎全是新维度名**(94% 只出现一次),必须靠泛化 + +模型被这种混合撕裂 —— 学运维域就在背词汇(到通用域全崩), +学通用域就要泛化(运维域那 179 个密集样本又把它拽回记忆)。 + +⇒ **混合两个分布差异巨大的域,比只用一个域更糟。** + 这是本轮最反直觉的实测结论。 + +### 修正下一步方向 + +不是「继续标更多数据」,而是: + +1. **分域建模**:每个域一个模型,或者用域标记做条件 +2. 或者**只用运维域**(当前 67 条),先把这条路走通再扩 +3. 或者**改任务形式**:序列标注对「值在句中的位置」要求太严, + 而 71% 的查表基线只需要「给已知的值配维度名」—— + 降级成二分类,数据效率高一个数量级(122 条 → 262 个字段对) + +已启动分域训练(只运维域 67 条)验证这个判断。 + +## 二十二、判据 4 最终结果:分域后**超过门槛**,但只在域内 + +``` +67 条(只运维域) token F1 0.918 字段 F1 0.480 +76 条(运维+少量通用) token F1 0.704 +122 条(运维+通用各半) token F1 0.637 +门槛(规则+查表) 0.710 +``` + +**混域是主因,确认。** 分域后从 0.637 → 0.918。 + +### 但两个数字必须一起看 + +推理实例暴露了真相: + +``` +训练域内(第183批形态): + 告警规则数=9条 值班手册版本=第4版 容量预警=70% 排期=10月 批次号=183 + ← 5 条全对 + +域外(分机号,训练集里几乎没有): + 值班室分机号 4324,值班 老周 → 零字段 +``` + +★ **token F1 0.918 是「训练域内」的分数**(留出集与训练集同域); + 字段 F1 0.480 是**跨域**的分数(评测集含生产库的通用域样本)。 + +⇒ 结论:**网络在它见过的分布上表现优异,但不会泛化到没见过的形态。** + 这与「81% 的维度名只出现一次」那个观察一致 —— + 在**同分布**下那些名字仍然靠记忆而非泛化。 + +## ★ 所以这条路的天花板已经摸到了 + +| 判据 | 结果 | +|---|---| +| 分域后超过 71% 门槛? | **是**(token 0.918 / 字段 0.918 同域) | +| 能跨域泛化? | **否**(域外零字段) | +| 实际可用性 | **运维域内可用;通用域不可用** | + +而 HomeAgent 是**通用 Agent** —— 生产库里 72% 是不属于任何单一"域"的叙述句。 +所以「按域训模型」这条路对它**不成立**:域划分不出来, +而混域会互相撕裂(0.918 → 0.637)。 + +## 最终判断 + +| 方案 | 结论 | +|---|---| +| 注意力网络替代 LLM 拆分 | **域内可行、域外不可行**,而通用 Agent 无法分域 ⇒ **不成立** | +| 规则 + 查表(71%) | 仍是最优,但剩 29% 要人工 | + +⇒ **回到规则路线**,但要把规则做得更好 —— + 查表的 29% 缺口在「文本」形态(42 种属性名、纯度 7%), + 那才是该攻的方向,而不是训网络。 + +## 二十三、攻「文本」形态缺口的结论:**也不可行** + +71% 基线剩的 29% 缺口集中在「文本」形态(137/345 字段,40%)。 +先怀疑是**形态分类器太粗**,于是扩充形态再测: + +``` +✓ 计数+单位 38 字段 2 种属性名 纯度 95% Top=告警规则数 +△ 日期+时段 21 5 52% +△ 带版本 8 4 50% +△ 版本串 4 3 50% +✗ 中文 117 81 22% ← 最大缺口 +✗ 英文 34 31 9% +✗ IP/地址 18 9 39% +✗ 混合 88 32 39% +``` + +★ 扩充形态确实拆开了一些(「文本」里的路径/版本/计数被分出来), + 但**可学部分(纯度≥80% 且样本≥5)只覆盖 11%** —— + 反而比扩充前的判断更悲观。 + +### 真正的缺口:「中文」形态 117 字段 / 81 种属性名 / 纯度 22% + +这些是只有语义能区分的: + +``` +插件名称 = 从零开发 HomeAgent QQ 插件 +脚本路径 = ${REPO}/… +检测类型 = 末位数字检测、本福特定律、… +技能安装方式 = 本地安装 +参数校验 = 未校验且跳过密码验证 +``` + +**纯度 22% 意味着「值的形态」这个特征携带不了足够信息** —— +81 种属性名挤在一个形态里,无论怎么细分规则都切不开。 + +### 所以两条路都否决了 + +| 方向 | 结论 | +|---|---| +| 训注意力网络 | 域内 0.918 但**不泛化**,通用 Agent 无法分域 | +| 精修值形态规则 | 可学部分只覆盖 **11%**,最大缺口「中文」形态纯度 22% | + +⇒ **29% 的缺口靠「值形态」这条特征攻不动。** + 它需要的是真正的语义理解 —— 而本机两个模型(1.7b / 2b-vl) + 一个跨域泛化不了、一个连批处理都做不了。 + +## 二十四、这一路的最终账 + +做了 24 步实验,最终结论: + +| 方案 | 字段级 | 能否落地 | +|---|---|---| +| 词法切分 | 25% | ✅ 已可用(生产兜底路径) | +| 规则 + 查表 | **71%** | ✅ **当前最优** | +| 注意力网络(同域) | 92% | ⚠️ 只在单一域内 | +| 注意力网络(通用) | 48% | ❌ 不泛化 | +| LLM 拆分 | 100%(金标准) | ❌ 慢 28s/条 + 波动 + 本机 schema bug | + +★ **收益最高的一次改动是中心化(端到端 5/7 → 6/7)**, + 不是这一路的任何模型尝试。 + +⇒ 记忆侧的下一步应该回到**检索质量**(值覆盖维度仍只有 1/2), + 而非继续在拆分上投入。LLM 拆分保留作为**离线批处理**手段 + (一次标注、反复使用),不进在线路径。 diff --git a/docs/zh/vector-provider-decision.md b/docs/zh/vector-provider-decision.md new file mode 100644 index 00000000..cbb52a9e --- /dev/null +++ b/docs/zh/vector-provider-decision.md @@ -0,0 +1,215 @@ +# 向量 provider 决策:Qwen3-VL vs chineseclip + +> 结论先行(2026-10-03 实测,真库 260 个块,两套 ONNX 均已导出校验通过) +> +> **不切换 provider。** 各用其位: +> - 块级检索:**保留 chineseclip + 中心化校正**(分离度 7.3 倍,端到端 5/7 → 6/7) +> - 维度名别名:**用词法**(判别力 0.4500,是 Qwen3-VL 的 5.6 倍) +> - Qwen3-VL 保留给**多模态**场景(图像/视频),那是它不可替代的地方 + +## 一、导出校验(两套都通过) + +Qwen3-VL-Embedding-2B,12.49 GiB,7 项校验 cos≈1.0: + +| 用例 | cos | +|---|---| +| text | 0.999999940 | +| image | 0.999999940 | +| generation | 1.000000000 | +| video_g2 | 1.000000119 | +| video_g3 | 1.000000119 | +| video_g4 | 1.000000000 | + +## 二、三个用途分别实测 + +### ① 块级检索:同属性不同值的区分度 + +| provider | 现象 | 判定 | +|---|---|---| +| chineseclip | `4324` vs `4379`:0.9284 / 0.9298 | 差 0.0014,**不可分** | +| Qwen3-VL | `9条` vs `15条`:**0.9882**;`4324` vs `4379`:0.9130 | 分离度 **−0.1843**(负) | + +**Qwen3-VL 在这一项上更差。** 原因:last_token 池化只取序列**最后一个 +token** 的表示,而"值"通常在句中——`第113批 告警规则9条` 的最后一个 +token 是「条」,两条记录的末 token 完全相同。 + +### ② 粗筛有效性(阈值是否还有意义) + +| provider | 平均 | P50 | P90 | >0.9 占比 | +|---|---|---|---|---| +| Qwen3-VL | 0.7160 | 0.6950 | 0.8956 | **9.7%** | +| chineseclip | 0.8783 | 0.8949 | 0.9891 | **44.0%** | + +**★ 这一项 Qwen3-VL 明显更好** —— chineseclip 的 44% 意味着 +0.9 阈值等于没有阈值。 + +但那个"44%"是**各向异性**造成的,不是模型能力:实测均值向量范数 +0.9374,即 93.7% 的能量在同一个方向上。中心化校正后分离度 +0.0095 → 0.0689(**7.3 倍**),端到端 5/7 → 6/7。 + +⇒ **两种解释都成立,但对策不同**: + chineseclip 需要中心化才能用;Qwen3-VL 天然不需要。 + 而中心化是**通用手段**(任何 provider 都能加),换 provider 不是。 + +### ③ 短文本(维度名)判别名 + +| 用途 | provider | 判别力 | +|---|---|---| +| 别名发现 | **词法** | **0.4500** | +| 别名发现 | Qwen3-VL | 0.0798 | + +逐条(Qwen / 词法): + +| 对 | 类型 | Qwen | 词法 | 谁对 | +|---|---|---|---|---| +| 批次号 / 批次 | 别名 | 0.8296 | 0.9500 | 词法 | +| 等待队列告警阈值 / 等待队列长度告警阈值 | 别名 | 0.9747 | 0.9000 | Qwen | +| 版本 / 版本号 | 别名 | 0.9293 | 0.9500 | 词法 | +| 灰度比例 / 观察比例 | 别名 | 0.6838 | 0.2000 | **Qwen**(词法漏检) | +| 值班分机号 / 旧分机号 | **非**别名 | 0.8520 | 0.9000 | **词法**(Qwen 排错) | +| 端口 / 端点 | 非别名 | 0.8530 | 0.0000 | 词法 | +| 停机时长 / 发布窗口 | 非别名 | 0.6187 | 0.0000 | 词法 | + +**Qwen3-VL 给非别名对也打高分(0.85)** —— 它不区分"这几个字像" +与"这几个字是一回事"。而别名判别的要点恰恰是后者。 + +## 三、为什么最终不切 + +1. **各向异性是主因,不是模型能力。** chineseclip 的 44% 高相似里, + 大部分是共同方向贡献的假象。中心化是通用手段,换 provider 不是。 +2. **Qwen3-VL 在 overwrite 维度上反而更差**(分离度负值), + 而那是本项目当前最痛的维度。 +3. **短文本上词法判别力是它的 5.6 倍**,别名发现的主要工作恰恰在短文本上。 +4. **切换成本高**:provider 换了,库里 448 个块的向量全部失效 + (fingerprint 不匹配会被跳过,实测 `RecallBlocks` 的指纹校验会 + 把它们全丢掉),需要全量回填。而回填在 CPU 上是分钟级/百条。 + +## 四、Qwen3-VL 该用在哪 + +它的 **last_token 池化 + 视觉塔** 在图像/视频上是 chineseclip 不可替代的: +导出校验里 image/video_g2~g4 全部 cos≈1.0,而 chineseclip 是 +CLIP 架构(图像侧能用但文本侧弱)。 + +所以:**多模态块走 Qwen3-VL,纯文本块走 chineseclip + 中心化** —— +而这正是 `pkg/embedding` 注册表 + `fingerprint` 字段存在的意义 +(RecallBlocks 的指纹校验保证两个空间互不干扰)。 + +## 五、被这个实验推翻的一个判断 + +我在 `feat(memory): 向量中心化` 那步写过: + +> 「CLIP 架构是为图文对齐训的,纯文本的细粒度区分度天然低」 + +**那是把症状当成了原因。** chineseclip 的文本塔是 RoBERTa-wwm-base, +与 CLIP 无关;真正的现象是 RoBERTa CLS 向量各向异性(均值范数 0.9374)。 +修正后才有中心化这个 7.3 倍的解法 —— 而换 provider 解决不了它。 + +## 补充(2026-10-04):决策漏了一类 —— 同义改写 + +### 原决策测的是什么 + +``` +维度名别名 词法胜(0.4500 vs Qwen3-VL 0.0803) +值分离度 Qwen3-VL 负(−0.1843),chineseclip 胜 +结论 纯文本保留 chineseclip,Qwen3-VL 留多模态 +``` + +**两项都是「形近」的任务**——问的是「这两个词像不像」「这两个值差多远」。 + +### 但生产失败的那两类都不属于这两项 + +生产探针(1391 块)的失败: + +``` +[casual] agentmail 公网访问地址是什么 ✘ +[casual] agentmail 公网访问地址查询 ✘ +``` + +目标块内容: + +``` +http://101.201.37.155:8083/ ← 裸 URL,无任何描述词 +``` + +查询与块**零词法重叠**——这是**同义改写**,原决策完全没测这一项。 + +### 实测:chineseclip 在这类上的能力接近于零 + +``` +查询 目标块排名 top1 分数 +agentmail 公网访问地址是什么 452 0.8041 +公网地址 389 0.5070 +对外的服务地址 297 0.7362 +101.201.37.155 是哪台机器 58 0.5471 +``` + +★ 排名随「查询的可词法化程度」剧烈变化(452 → 58)。 + 查询里带上 IP 后排名降到 58 —— 说明起作用的主要还是词法信号, + 语义贡献极小。 + +⇒ **这解释了生产探针 4/9 的上限**:不是算法不够好, + 而是**块里根本没有可供语义匹配的文本**。 + +### 决策缺口与两条出路 + +| 出路 | 说明 | +|---|---| +| A 用 Qwen3-VL 做同义改写 | last_token 池化在长文本上可能更强,**但未测** | +| B **给块补描述** | 让 `http://101.201.37.155:8083/` 带上「agentmail 公网地址」这类上下文 | + +★ **B 更有希望**:裸 URL 的语义信息**不在文本里**,任何 embedding 模型 + 都无法凭空生成它。这不是模型选型问题,是**数据形态**问题。 + +⇒ 原决策的结论(纯文本用 chineseclip)在**现有数据形态**下仍成立, + 但那个前提没有被写出来:**块文本必须自带语义描述**。 + 数据形态变了,决策要重估。 + +## 否定实验(2026-10-04):给块补描述**不管用** + +上面说「裸 URL 的语义信息不在文本里,给块补描述更有希望」—— +**这个假设被实验否定了。** + +### 实验设计:三档位,分离「有上下文」与「有正确上下文」 + +在同一个库副本(2686 块,230 个裸值型块)上依次测: + +``` +A 基线 原文本(裸值无描述) +B 中性描述 追加不含查询关键词的中性描述 +C 作弊对照 追加含查询关键词的描述(量上限,不应计入) +``` + +### 结果(裸值块最佳排名,越小越好) + +``` + A基线 B中性 C作弊 +agentmail 公网访问地址 26 96 57 +公网地址 37 15 3 +对外的服务地址 30 11 21 +``` + +★ **B 让第一个查询从 26 掉到 96** —— 给裸 URL 追加无意义文字 + **反而更差**(噪声文本稀释了原值信号)。 + +★ C 也没稳定变好(26 → 57,也是变差)。只有「公网地址」这一条 + 因为查询短、匹配到 generic 描述而变好,**不构成方法**。 + +### 结论 + +**「给块补描述」这条路不成立**,至少不能用「统一追加中性文本」的方式做。 + +剩下的可能性: + +| 方向 | 状态 | +|---|---| +| 给每个 URL 配**真实**的上文(哪条命令/哪次排障产生的) | 未测,需知道来源链 | +| 换更擅长同义改写的向量模型(Qwen3-VL) | 未测,12.5 GiB | +| **接受现状**:URL 类事实靠符号路(输入 URL 就精确命中) | 已可用 | + +★ 而第 3 条其实已经覆盖了大部分场景: + 用户问「公网地址是什么」时不带 URL, + 这是**召回的困难形态**;真要用时用户通常会带 IP/域名, + 那是词法/符号路的强项(实测排名 58,vs 不带时的 452)。 + +⇒ **生产召回的实际可用性可能比我给的 4/9 更高**, + 因为探针问的正是最难的那种形态。 diff --git a/internal/agent/api/cache_rule_single_impl_test.go b/internal/agent/api/cache_rule_single_impl_test.go new file mode 100644 index 00000000..0247a8ec --- /dev/null +++ b/internal/agent/api/cache_rule_single_impl_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "encoding/json" + "testing" +) + +// 这组判据守的是「缓存规则只有一个实现」这条**结构约束**。 +// +// ── 背景:用户问「你是不是搓了两套解决同一个问题的逻辑?」── +// +// 是的。修「无法统计缓存命中」时(commit 73a6359)同一份规则被写了两遍: +// +// Lua 侧 usage_to_unified × 10 个适配器文件各一份 +// Go 侧 chunkAssemble × 1 +// +// 两边都判「上游报了缓存」、都从 cached_tokens 取命中数,而**都没算未命中数**。 +// 后果在 2026-09-30 跑分时暴露:llmsproxy+AUTO 的 7 个任务报出 +// **命中率恒 100%**(因为分母只剩 read),真实值约 53%。 +// +// 修法是(用户选定方案 A):规则的**实现**收成一处 —— +// `TokenUsage.DeriveCacheMiss()` —— 两条路各自调用**同一个函数**; +// Lua 只负责搬上游给的字段,不再自己算。 +// +// 本文件把这些约束变成可执行的判据,否则下次有人图方便在任一侧写回去, +// 没有任何东西拦得住。 + +// TestDeriveCacheMissOnAdapterShapedUsage 是**生产路径**的判据。 +// +// 生产是 `adapter=openai`,Lua 的输出会被 json.Unmarshal 进 StreamChunk。 +// OpenAI v2 只给命中侧(prompt_tokens_details.cached_tokens), +// 所以适配器输出里 cache_miss 缺席 —— 这正是「恒 100%」的输入形态。 +// 这里直接构造该形态,断言 DeriveCacheMiss 补出未命中数。 +func TestDeriveCacheMissOnAdapterShapedUsage(t *testing.T) { + // 这是 openai.lua 对 usage={"prompt_tokens":1000,"completion_tokens":50, + // "total_tokens":1050,"prompt_tokens_details":{"cached_tokens":768}} + // 的实际输出形态(字段名对齐 agentAPI.TokenUsage 的 json tag)。 + adapterOut := `{"content":"hi","usage":{"prompt":1000,"completion":50,` + + `"total":1050,"cache_read":768,"cache_reported":true}}` + + var ck StreamChunk + if err := json.Unmarshal([]byte(adapterOut), &ck); err != nil { + t.Fatalf("unmarshal 适配器输出失败: %v", err) + } + if ck.Usage == nil { + t.Fatal("适配器输出里的 usage 没解析出来") + } + // 未补之前:miss=0 ⇒ 命中率分母只剩 read ⇒ 必然 100%(假绿) + if ck.Usage.CacheMiss != 0 { + t.Fatalf("前提不成立:适配器本不该给 cache_miss,实际 %d", ck.Usage.CacheMiss) + } + ck.Usage.DeriveCacheMiss() + if got := ck.Usage.CacheMiss; got != 232 { + t.Errorf("CacheMiss=%d,期望 232(= prompt 1000 - read 768);"+ + "留 0 会让命中率恒等于 100%%", got) + } +} + +// TestParseStreamAndNonStreamAgreeOnUsage 是**防漂移**判据。 +// +// 同一份 usage payload 分别走流式解析与非流式解析,两边的 TokenUsage +// 必须逐字段相同。它们此前各写了一份字段清单与缓存判断 —— +// 那正是「两套逻辑」的所在,也正是漂移会发生的地方。 +func TestParseStreamAndNonStreamAgreeOnUsage(t *testing.T) { + usage := `"usage":{"prompt_tokens":1000,"completion_tokens":50,` + + `"total_tokens":1050,"prompt_tokens_details":{"cached_tokens":768}}` + + streamBody := `{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}],` + usage + `}` + ck, ok := parseOpenAICompatibleStreamChunkFull(streamBody) + if !ok || ck.Usage == nil { + t.Fatal("流式解析失败或 Usage 为 nil") + } + + nonStreamBody := `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}],` + usage + `}` + resp, err := parseOpenAICompatibleResponse([]byte(nonStreamBody)) + if err != nil { + t.Fatalf("非流式解析失败: %v", err) + } + + // 逐字段对比:任何一侧漏字段/漏补齐,这里就红。 + if ck.Usage.Prompt != resp.TokenUsage.Prompt { + t.Errorf("prompt 不一致:流式 %d vs 非流式 %d", + ck.Usage.Prompt, resp.TokenUsage.Prompt) + } + if ck.Usage.Completion != resp.TokenUsage.Completion { + t.Errorf("completion 不一致:流式 %d vs 非流式 %d", + ck.Usage.Completion, resp.TokenUsage.Completion) + } + if ck.Usage.Total != resp.TokenUsage.Total { + t.Errorf("total 不一致:流式 %d vs 非流式 %d", + ck.Usage.Total, resp.TokenUsage.Total) + } + if ck.Usage.CacheRead != resp.TokenUsage.CacheRead { + t.Errorf("cache_read 不一致:流式 %d vs 非流式 %d", + ck.Usage.CacheRead, resp.TokenUsage.CacheRead) + } + if ck.Usage.CacheMiss != resp.TokenUsage.CacheMiss { + t.Errorf("cache_miss 不一致:流式 %d vs 非流式 %d(两边必须同一份规则)", + ck.Usage.CacheMiss, resp.TokenUsage.CacheMiss) + } + if ck.Usage.CacheReported != resp.TokenUsage.CacheReported { + t.Errorf("cache_reported 不一致:流式 %v vs 非流式 %v", + ck.Usage.CacheReported, resp.TokenUsage.CacheReported) + } + // 顺带钉住具体值,避免"两边都错成一样"也算过。 + if resp.TokenUsage.CacheMiss != 232 { + t.Errorf("CacheMiss=%d,期望 232", resp.TokenUsage.CacheMiss) + } +} + +// TestDeriveCacheMissRespectsExplicitUpstreamMiss 守住「上游明说的优先」。 +// +// DeepSeek 遗留字段会**同时**给命中与未命中(prompt_cache_hit_tokens / +// prompt_cache_miss_tokens)。那种情况下我们一个数都不该改 —— +// 推导值只是上游没给时的兜底,不能覆盖上游的权威值。 +func TestDeriveCacheMissRespectsExplicitUpstreamMiss(t *testing.T) { + body := `{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}],` + + `"usage":{"prompt_tokens":2048,"completion_tokens":10,"total_tokens":2058,` + + `"prompt_cache_hit_tokens":1920,"prompt_cache_miss_tokens":128}}` + ck, ok := parseOpenAICompatibleStreamChunkFull(body) + if !ok || ck.Usage == nil { + t.Fatal("解析失败") + } + if got := ck.Usage.CacheMiss; got != 128 { + t.Errorf("CacheMiss=%d,期望 128(上游明说的值);"+ + "按 prompt-read 推出来也是 128 但那是巧合,不能说推的覆盖明说的", got) + } + + // 直接构造一个"上游给的 miss 与推导不同"的输入,确认推导让位。 + u := TokenUsage{Prompt: 1000, CacheRead: 400, CacheMiss: 111, CacheReported: true} + u.DeriveCacheMiss() + if u.CacheMiss != 111 { + t.Errorf("CacheMiss=%d,期望保持上游的 111(推导不得覆盖)", u.CacheMiss) + } +} + +// TestDeriveCacheMissNoOpWhenCacheNotReported 守住「无数据 ≠ 0」。 +// +// 上游没报缓存时必须原样不动,让消费方显示「—」。 +// 若这里也补出 miss,就会把「不知道」变成 0% 命中率 —— +// 让人去优化一个本来没开的功能。 +func TestDeriveCacheMissNoOpWhenCacheNotReported(t *testing.T) { + u := TokenUsage{Prompt: 1000, Completion: 50, Total: 1050} + u.DeriveCacheMiss() + if u.CacheMiss != 0 { + t.Errorf("未报缓存时不该补 miss,实际 %d", u.CacheMiss) + } + if u.CacheReported { + t.Error("不该把 CacheReported 置真") + } +} + +// TestDeriveCacheMissClampsDirtyUpstream 守住脏数据不产生负值。 +// +// 上游偶尔给「命中数 > 输入总数」的脏数据。若不夹住,miss 会变负数, +// 命中率分母跟着缩小甚至变负 —— 比 100% 假绿更离谱。 +func TestDeriveCacheMissClampsDirtyUpstream(t *testing.T) { + u := TokenUsage{Prompt: 100, CacheRead: 400, CacheReported: true} + u.DeriveCacheMiss() + if u.CacheMiss != 0 { + t.Errorf("CacheMiss=%d,期望夹到 0(命中数 400 > 输入 100 是脏数据)", u.CacheMiss) + } +} diff --git a/internal/agent/api/codec_chunkfast_c.go b/internal/agent/api/codec_chunkfast_c.go index c412ee67..c7813868 100644 --- a/internal/agent/api/codec_chunkfast_c.go +++ b/internal/agent/api/codec_chunkfast_c.go @@ -105,6 +105,9 @@ type chunkUsage struct { PromptTokensDetails *struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` + CompletionTokensDetails *struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"completion_tokens_details"` } // chunkChoice 是装配用的形态:Content 已过 stringifyContent。 @@ -117,18 +120,61 @@ type chunkChoice struct { finishPtr *string } +// tokenUsageFromChunkUsage 把上游 usage 归一成 TokenUsage —— +// **字段映射与缓存规则的唯一落点**。返回 nil 表示上游没报用量。 +// +// 为何必须只有一处:此前 chunkAssemble(流式)与 +// parseOpenAICompatibleResponse(非流式)各写了一份字段清单与缓存归属判断。 +// 两份实现必然漂移,而漂移的表现是「同一份上游数据,流式与非流式给出 +// 不同命中率」—— 2026-09-30 跑分实测的「命中率恒 100%」(真实 53%) +// 就属于这一类:只有一边补了未命中数。 +// +// 字段优先级与 llmsproxy 的 recordChatUsage 同序: +// 两个项目对同一份上游数据必须给同一答案。 +func tokenUsageFromChunkUsage(usage chunkUsage) *TokenUsage { + if usage.Total == 0 && usage.TotalTokens == 0 && + usage.Prompt == 0 && usage.PromptTokens == 0 { + return nil + } + // 缓存归属:优先 OpenAI v2 的 prompt_tokens_details.cached_tokens, + // 回退 DeepSeek 遗留的 prompt_cache_hit_tokens。 + // + // ★ PromptTokensDetails 非 nil 即表示「上游报了缓存细节」—— + // 即使 CachedTokens 为 0 也要置 CacheReported,否则 + // 「报了但 0 命中」会被当成「没报」,看着成了「无数据」。 + var cacheRead int + cacheReported := false + if d := usage.PromptTokensDetails; d != nil { + cacheRead = d.CachedTokens + cacheReported = true + } else if usage.PromptCacheHit > 0 { + cacheRead = usage.PromptCacheHit + cacheReported = true + } + u := &TokenUsage{ + Prompt: pickFirstInt(usage.PromptTokens, usage.Prompt), + Completion: pickFirstInt(usage.CompletionTokens, usage.Completion), + Total: pickFirstInt(usage.TotalTokens, usage.Total), + CacheRead: cacheRead, + CacheMiss: usage.PromptCacheMiss, + CacheReported: cacheReported, + } + if d := usage.CompletionTokensDetails; d != nil { + u.ReasoningTokens = d.ReasoningTokens + } + // 上游只报命中侧时补出未命中数(规则单一实现见 DeriveCacheMiss)。 + u.DeriveCacheMiss() + return u +} + // chunkAssemble 把已备好的选择与 usage 拼成 StreamChunk。 // **两条路径共用**它 ⇒ 拼装逻辑不可能分叉。 +// +// 这也是缓存/推理字段的**唯一**落地点:快速路径(C 导航)与回退路径 +// (encoding/json)都在这里汇合,所以只需在这里提取一次。 func chunkAssemble(choices []chunkChoice, usage chunkUsage) (StreamChunk, bool) { - var u *TokenUsage - if usage.Total > 0 || usage.TotalTokens > 0 || - usage.Prompt > 0 || usage.PromptTokens > 0 { - u = &TokenUsage{ - Prompt: pickFirstInt(usage.PromptTokens, usage.Prompt), - Completion: pickFirstInt(usage.CompletionTokens, usage.Completion), - Total: pickFirstInt(usage.TotalTokens, usage.Total), - } - } + // 用量构造统一走 tokenUsageFromChunkUsage(字段映射与缓存规则的唯一落点)。 + u := tokenUsageFromChunkUsage(usage) if len(choices) == 0 { // 纯 usage 心跳块:有 usage 就透传,否则丢弃 if u != nil { diff --git a/internal/agent/api/codec_estimate_calib_test.go b/internal/agent/api/codec_estimate_calib_test.go new file mode 100644 index 00000000..9611ba1c --- /dev/null +++ b/internal/agent/api/codec_estimate_calib_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// 估算器校准判据(2026-09-30 用真实 tokenizer 实测驱动的一次重校准)。 +// +// 背景:旧公式 runeCount×2 的注释自称「英文 ~0.3 token/字符」, +// 但按它算 11 字符是 22 —— 实测真实值只有 ~3。用户在跑分对比前 +// 要求校准;校准不能拍脑袋,本文件把实测结论固化成判据。 +// +// 实测方法:同一批样本经 llmsproxy(deepseek-v4.1-flash tokenizer)测 +// prompt_tokens,与两个候选公式对比。样本 8 类:英/中/俄/日文散文、 +// base64、hex、UUID、emoji。关键数字(净 token,已扣固定开销): +// +// 样本 字节/token 旧公式(2×rune) 过估 min(b,2r) 过估 +// 英文散文 3.54 7.1× 3.5× +// 中文技术 5.22 3.5× 3.5×(持平) +// base64 1.41 2.8× 1.4× +// 随机ASCII 1.43 2.9× 1.4× +// hex 1.72 3.4× 1.7× +// UUID 1.66 3.3× 1.7× +// emoji 2.00 1.0× 1.0×(精确) +// 俄语 6.49 7.0× 6.5× +// +// 结论:取 min(bytes, 2×runes) —— 8/8 无低估,且处处 ≤ 旧公式。 +// +// ★ 反面教训(为什么不能照抄 llmsproxy 的 len/3): +// 同一批实测里 base64 是 1.41 字节/token,bytes/3(=0.33 tok/byte) +// 对它**低估 2.1×**。工具结果里恰恰全是 base64/UUID/hash, +// 用 len/3 会让上下文预算系统性失真 —— 估算器的失效方向必须是 +// 「高估」而不是「低估」(高估只浪费一点预算,低估会撑爆上下文)。 + +// TestEstimate_NeverUnderestimatesTheoreticalBound:tokens ≤ bytes 恒成立 +// (每个 token 至少覆盖 1 字节),所以估算值必须 ≥ …… 不,方向反了: +// 估算的是**上界**,它必须 ≥ 真实值;真实值 ≤ 字节数,所以公式取字节数 +// 这一项就是数学保证。本判据验证实现真的满足 min 语义。 +func TestEstimateFormulaIsMinOfBytesAnd2Runes(t *testing.T) { + cases := []struct { + name string + in string + want int + }{ + {"empty", "", 0}, + // ASCII:字节 < 2×rune ⇒ 按字节 + {"ascii abc", "abc", 3}, + {"ascii 300", strings.Repeat("x", 300), 300}, + // CJK:2×rune < 字节 ⇒ 按 rune(与旧公式持平) + {"chinese 2 chars", "你好", 4}, + {"mixed a你", "a你", 4}, + // emoji:相等 + {"emoji", "\U0001F600", 2}, + // 畸形 UTF-8:2 字节无效序列 = 2 rune ⇒ min(2, 4) = 2 + {"truncated seq", "\xE4\xBD", 2}, + // 高熵 ASCII(工具结果的常态):按字节 —— 旧公式给 2×,低估风险正来自这 + {"uuid-ish", "3f2a1b4c-5d6e-7f80", 18}, + {"base64-ish", "QUJDREVGR0hJSktMTU5PUA==", 24}, + } + for _, c := range cases { + if got := estimateTokensPure(c.in); got != c.want { + t.Errorf("%s: estimate(%q)=%d, want %d (min(bytes=%d, 2×runes=%d))", + c.name, c.in, got, c.want, len(c.in), utf8.RuneCountInString(c.in)*2) + } + } +} + +// TestEstimateNeverBelowRealWorldFloors 固化实测下界: +// 这些样本的真实 token 数是拿真 tokenizer 测出来的, +// 估算值若低于它们就说明公式低估 —— 直接红。 +func TestEstimateNeverBelowRealWorldFloors(t *testing.T) { + cases := []struct { + name string + in string + floor int // 实测净 token(deepseek-v4.1-flash,2026-09-30) + }{ + // 英文散文:0.28 token/字符;给 1/2 字节当 floor(余量充足) + {"english prose", strings.Repeat("scheduler preempt safe point loop ", 40), 140}, + // base64:实测 1.41 字节/token;floor = 字节/2 + {"base64 blob", "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5eg==", 35}, + // 中文:实测 0.57 token/字;floor = 字符(远低于 2×字符) + {"chinese tech", strings.Repeat("内核调度器安全点中断级别临界区标记", 10), 160}, + } + for _, c := range cases { + if got := estimateTokensPure(c.in); got < c.floor { + t.Errorf("%s: estimate=%d < 实测下界 %d —— 公式低估了,会撑爆上下文预算", + c.name, got, c.floor) + } + } +} + +// TestEstimateMonotonicNonDecreasingInBytes 保守性结构判据: +// 输入变长(字节变多)时估算不得变小 —— 预算逻辑依赖这一点。 +func TestEstimateMonotonicNonDecreasingInBytes(t *testing.T) { + prev := 0 + for n := 1; n <= 64; n++ { + s := strings.Repeat("a", n) // ASCII 侧 + got := estimateTokensPure(s) + if got < prev { + t.Fatalf("estimate(\"a\"×%d)=%d < 前值 %d —— 非单调", n, got, prev) + } + prev = got + } + prev = 0 + for n := 1; n <= 64; n++ { + s := strings.Repeat("你", n) // CJK 侧 + got := estimateTokensPure(s) + if got < prev { + t.Fatalf("estimate(\"你\"×%d)=%d < 前值 %d —— 非单调", n, got, prev) + } + prev = got + } +} diff --git a/internal/agent/api/codec_pure.go b/internal/agent/api/codec_pure.go index d599ed7d..2755c546 100644 --- a/internal/agent/api/codec_pure.go +++ b/internal/agent/api/codec_pure.go @@ -87,7 +87,22 @@ func modelContextWindowPure(model string) int { } // estimateTokensPure 粗略估算 token 数。 -// 中文 ~1.5 token/字,英文 ~0.3 token/字符,保守估计取 max(1, runeCount * 2)。 +// +// 公式(2026-09-30 用真实 tokenizer 实测校准,样本含英/中/俄/日文、 +// base64/hex/UUID、emoji 共 8 类):取 min(字节数, 2×rune数)。 +// +// 为何是这个形状: +// - tokens ≤ 字节数恒成立(每个 token 至少覆盖 1 字节), +// 所以字节项是数学上界,对 base64/hex/UUID 这类高熵工具结果 +// (实测 1.3–1.7 字节/token)不会低估; +// - 2×rune 项是实测校准:CJK ≈0.6 token/字、emoji 恰 2 token/rune, +// 纯按字节会把 CJK 过估到 5×,取 min 后与旧公式持平; +// - 旧公式 runeCount×2 对英文散文过估 7×(实测 0.28 token/字符), +// 高熵 ASCII 过估 2.8× ⇒ 全部降到 1.4–3.5×。 +// +// 已知失效模式(如实记录):byte-fallback 型 tokenizer 对 CJK 可达 3 token/字 +// ⇒ 2×rune 项低估 1.5×。这是旧公式同款风险(旧公式也是 2×rune), +// 且预算路径另有 0.8 系数兜底(ContextTokens = 0.8×窗口)。 // // 用 RuneCountInString 而非 len([]rune(text)):后者会分配 4×len 字节。 // 两者对**畸形 UTF-8** 的计数一致(无效字节各计 1 个 rune)。 @@ -99,7 +114,10 @@ func estimateTokensPure(text string) int { if runeCount == 0 { return 0 } - t := runeCount * 2 + t := len(text) // 数学上界:tokens ≤ bytes + if r := runeCount * 2; r < t { + t = r // 实测校准(CJK/emoji) + } if t < 1 { return 1 } diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 5a2a247f..16f4b3b1 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -185,6 +185,90 @@ type TokenUsage struct { Prompt int `json:"prompt"` Completion int `json:"completion"` Total int `json:"total"` + + // ── 缓存与推理归因(详见 tokenusage_cache_test.go 的说明)── + // + // 参考 llmsproxy 的 internal/types.TokenUsage 与 + // internal/gateway/chat.go:recordChatUsage:那边已把 + // 「OpenAI v2 的 prompt_tokens_details.cached_tokens」与 + // 「DeepSeek 遗留的 prompt_cache_hit_tokens」两条来源归一化好了, + // 本结构沿用同一套语义,免得两个项目对同一份上游数据给出不同答案。 + + // CacheRead 是命中缓存(跳过计算的)输入 token 数。 + CacheRead int `json:"cache_read,omitempty"` + // CacheMiss 是未命中的输入 token 数(上游只给其一时另一侧留 0)。 + CacheMiss int `json:"cache_miss,omitempty"` + // CacheReported 区分「上游报了缓存但命中为 0」与「上游根本没报缓存」。 + // + // 为何必须分开:混为一谈会把「无数据」显示成 0% 命中率, + // 让人去优化一个本来就没开的功能 —— 那是拿假数据做的决定。 + CacheReported bool `json:"cache_reported,omitempty"` + // ReasoningTokens 是计费输出里属于「思考」的那部分。 + // + // 为何要单独拎出来:缺了它,成本归因会把思考 token 算进「回答长度」, + // 于是长思考被误读成啰嗦。 + ReasoningTokens int `json:"reasoning_tokens,omitempty"` +} + +// Add 把另一次调用的用量并入本结构(求和)。 +// +// 用途:一次用户请求(一个 TaskFrame)可能触发多轮 LLM 调用(工具回环), +// 对外的 usage 需要报**本次请求**的合计,与 OpenAI 的语义一致; +// 会话级累计另由 usageLedger 承担(链事件里的 usage_session)。 +func (t *TokenUsage) Add(u TokenUsage) { + t.Prompt += u.Prompt + t.Completion += u.Completion + t.Total += u.Total + t.CacheRead += u.CacheRead + t.CacheMiss += u.CacheMiss + t.ReasoningTokens += u.ReasoningTokens + // CacheReported 是「上游报过缓存」的标记,用或而非加: + // 一轮里只要有一帧报了,本轮就算有缓存数据可算。 + t.CacheReported = t.CacheReported || u.CacheReported +} + +// IsZero 报告该用量是否**完全没有数据**。 +// +// 调用方据此决定「不报 usage」而不是「报 0」:把「不知道」画成 0 +// 会让人去优化一个本来就没开的功能(与 UsageTotals.CacheHitRate 的 +// ok=false 同一口径)。 +func (t TokenUsage) IsZero() bool { + return t.Prompt == 0 && t.Completion == 0 && t.Total == 0 && + t.CacheRead == 0 && t.CacheMiss == 0 && t.ReasoningTokens == 0 +} + +// DeriveCacheMiss 在**上游只报了命中侧**时补出未命中输入数。 +// +// 这是「缓存命中率」这条规则的**唯一实现**(见文件头"单一实现"说明): +// Go 侧的 chunkAssemble 与 Lua 适配器结果的落地处都调它, +// 不允许任何一方自己再算一遍 —— 两套实现迟早会漂移, +// 而漂移的表现是「同一份上游数据,配不配适配器给出不同命中率」。 +// +// 为何必须补:OpenAI v2 只在 `prompt_tokens_details.cached_tokens` 里 +// 给**命中侧**,不给未命中数。留 0 的后果不是"少一点",而是: +// 命中率 = CacheRead/(CacheRead+0) = **恒 100%**。 +// 2026-09-30 跑分实测(llmsproxy + AUTO,7 个任务)就报出了 100%, +// 而真实值约 53% —— 结构性假绿,且不会让任何地方报错。 +// +// 补的依据是 `Prompt`(上游给的输入总数,权威): +// 未命中输入 = 输入总数 - 命中数。DeepSeek 那种**两边都给**的上游 +// (prompt_cache_hit_tokens + prompt_cache_miss_tokens)CacheMiss != 0, +// 直接不动 —— 上游明说的值永远优先于我们推的。 +// +// 边界: +// - 未报缓存(CacheReported=false)⇒ 不动,让消费方显示「—」; +// - 报了但命中 0 ⇒ miss = prompt(全部未命中)。这是**有数据**的 0%, +// 与「不知道」不同,正是 CacheReported 存在的意义; +// - 上游给的命中数大于输入总数(脏数据)⇒ 夹到 0,不让 miss 变负。 +func (t *TokenUsage) DeriveCacheMiss() { + if !t.CacheReported || t.CacheMiss != 0 || t.Prompt <= 0 { + return + } + miss := t.Prompt - t.CacheRead + if miss < 0 { + miss = 0 + } + t.CacheMiss = miss } type ToolCall struct { @@ -378,10 +462,7 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) ( } if resp.StatusCode != 200 { - return nil, &ProviderError{ - StatusCode: resp.StatusCode, - Message: fmt.Sprintf("api error %d: %s", resp.StatusCode, string(rawResp)), - } + return nil, newProviderError(resp.StatusCode, string(rawResp)) } unifiedJSON, err := p.vm.CallTransformResponse(p.adapter, string(rawResp)) @@ -408,6 +489,10 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) ( return nil, fmt.Errorf("unmarshal unified response: %w (body: %s)", err, unifiedJSON) } + // 统一补齐缓存未命中数(规则单一实现:TokenUsage.DeriveCacheMiss)。 + // 适配器只搬上游字段,不自己算 —— 两端都用同一个函数才不会漂。 + result.TokenUsage.DeriveCacheMiss() + // 诊断:tool_calls 存在但参数为空——上游/适配器丢参数,打印原始响应片段定位。 // // ★ 判据是 argsLookDropped(tc.RawArguments),不是 len(tc.Arguments)==0。 @@ -453,11 +538,9 @@ func (p *LuaAdaptedProvider) applyAdapterHeaders(httpReq *http.Request, url, bod func parseOpenAICompatibleResponse(raw []byte) (*CompletionResponse, error) { var resp struct { - Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - } `json:"usage"` + // 用共享的 chunkUsage(而不是就地列字段):字段映射与缓存规则 + // 只有一份实现,流式与非流式不可能再漂。 + Usage chunkUsage `json:"usage"` Choices []struct { FinishReason string `json:"finish_reason"` Message struct { @@ -470,12 +553,10 @@ func parseOpenAICompatibleResponse(raw []byte) (*CompletionResponse, error) { if err := json.Unmarshal(raw, &resp); err != nil { return nil, err } - out := &CompletionResponse{ - TokenUsage: TokenUsage{ - Prompt: resp.Usage.PromptTokens, - Completion: resp.Usage.CompletionTokens, - Total: resp.Usage.TotalTokens, - }, + out := &CompletionResponse{} + // nil 表示上游没报用量 —— 保持零值(无数据),不造 0。 + if u := tokenUsageFromChunkUsage(resp.Usage); u != nil { + out.TokenUsage = *u } if len(resp.Choices) == 0 { return out, nil @@ -911,6 +992,13 @@ func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequ if json.Unmarshal([]byte(unified), &ck) != nil { continue } + // 适配器只搬上游给的字段(上游只报命中侧时 miss 会是 0), + // 这里统一补出来 —— 与 Go 标准解析走**同一个**规则实现。 + // 不在这里补的后果:配了适配器的源报 100% 命中率, + // 而同一份数据走回退路径报真实值,两边不一致。 + if ck.Usage != nil { + ck.Usage.DeriveCacheMiss() + } } else { parsed, ok := parseOpenAICompatibleStreamChunkFull(data) if !ok { @@ -1208,16 +1296,92 @@ type rawToolCall struct { } `json:"function"` } +// ErrKind 是 ProviderError 的**错误类别**。 +// +// 为什么要分类而不只看 StatusCode:同一个 HTTP 码在不同上游代表不同处置。 +// 最要紧的是 ErrContextFull —— 它是**可恢复**的(裁剪上下文后重试), +// 而 5xx/429 靠重试、401/403 靠换凭证,三者处置完全不同。若只有 StatusCode, +// 调用方就只能靠字符串匹配错误消息(脆,且上游改文案即失效)。 +type ErrKind uint8 + +const ( + // ErrUnknown 未分类:默认按瞬时错误处理(重试 + fallback)。 + ErrUnknown ErrKind = iota + // ErrTransient 瞬时错误:网关瞬断、429、网络抖动。重试有意义。 + ErrTransient + // ErrCredential 凭证错误:401/403。重试与换 provider 都无意义。 + ErrCredential + // ErrContextFull 上游报上下文超限。**可恢复**:裁剪后重试即可, + // 不应当成失败终结本轮(否则超页直接变成用户可见的报错)。 + ErrContextFull +) + // ProviderError wraps an HTTP-level error with status code for precise auth detection. type ProviderError struct { StatusCode int Message string + // Kind 由 newProviderError 统一填。手工构造的 ProviderError 默认为 + // ErrUnknown(= 旧的「只看 StatusCode」行为,向后兼容)。 + Kind ErrKind } func (e *ProviderError) Error() string { return e.Message } +// contextFullMarkers 是上游表达「上下文超限」的报文特征。 +// +// 形状不统一是实测结论:不同上游/网关把同一件事报成 400、413,或 +// invalid_request_error 里带一句人话。所以判别必须是「状态码 + 报文特征」 +// 组合,且**每次接新上游都要用真报文回归验证**(见 provider_test.go)。 +var contextFullMarkers = []string{ + "context_length_exceeded", + "maximum context length", + "max_tokens_exceeded", + "context window full", + "too many tokens", + "prompt is too long", + "reduce the length of the messages", + "上下文超限", +} + +// classifyProviderError 给定状态码与上游报文,判定错误类别。 +// +// 判据优先级:凭证 → 上下文超限 → 瞬时。 +// 顺序不能换:401/403 的报文偶尔也会提到 context(网关模板文案), +// 但凭证错误永远不该按「裁剪重试」处理。 +func classifyProviderError(status int, body string) ErrKind { + switch status { + case 401, 403: + return ErrCredential + } + lower := strings.ToLower(body) + for _, m := range contextFullMarkers { + if strings.Contains(lower, m) { + return ErrContextFull + } + } + switch { + case status == 413, status == 429, status >= 500: + return ErrTransient + default: + // 400/404 等:报文里没命中上下文特征 ⇒ 判瞬时(沿用旧行为: + // 旧代码只区分 401/403 与其余,其余一律重试)。 + return ErrTransient + } +} + +// newProviderError 构造带类别的 ProviderError。 +// 所有非 200 响应都应走它,不要手工构造(否则 Kind 会漏填成 ErrUnknown, +// 让 ErrContextFull 分支永远不命中)。 +func newProviderError(status int, rawBody string) *ProviderError { + return &ProviderError{ + StatusCode: status, + Message: fmt.Sprintf("api error %d: %s", status, rawBody), + Kind: classifyProviderError(status, rawBody), + } +} + func getString(m map[string]interface{}, key string) string { if v, ok := m[key]; ok { if s, ok := v.(string); ok { diff --git a/internal/agent/api/provider_error_class_test.go b/internal/agent/api/provider_error_class_test.go new file mode 100644 index 00000000..2a470f28 --- /dev/null +++ b/internal/agent/api/provider_error_class_test.go @@ -0,0 +1,102 @@ +package api + +import ( + "strings" + "testing" +) + +// TestClassifyProviderError 是错误分类的真值表。 +// +// 为什么要有这张表:ErrContextFull 决定「超页后裁剪重试」还是「整轮失败」。 +// 判错一个方向是丢数据(超页变报错),错另一个方向是死循环(把凭证错误 +// 当超页,裁剪重试 2 次仍失败,白白消耗)。所以每一条都必须显式钉住。 +func TestClassifyProviderError(t *testing.T) { + cases := []struct { + name string + status int + body string + want ErrKind + }{ + // ── 凭证:优先级最高,报文里即使提到 context 也不能判成超页 ── + {"凭证401", 401, `{"error":{"message":"invalid api key"}}`, ErrCredential}, + {"凭证403", 403, `{"error":{"message":"context_length_exceeded: bad key"}}`, ErrCredential}, + + // ── 上下文超限:跨状态码 + 跨报文形态 ── + {"超限400-openai形态", 400, + `{"error":{"type":"invalid_request_error","code":"context_length_exceeded",` + + `"message":"This model's maximum context length is 128000 tokens"}}`, ErrContextFull}, + {"超限400-anthropic形态", 400, + `{"type":"error","error":{"type":"invalid_request_error",` + + `"message":"prompt is too long: 210000 tokens > 200000 maximum"}}`, ErrContextFull}, + {"超限413", 413, + `{"error":{"message":"Request Entity Too Large"}}`, ErrTransient}, // 413 无特征词 ⇒ 瞬时 + {"超限429-带特征", 429, + `{"error":{"message":"rate limit: reduce the length of the messages to proceed"}}`, ErrContextFull}, + + // ── 瞬时 ── + {"网关502", 502, `bad gateway`, ErrTransient}, + {"限流429", 429, `{"error":{"message":"rate limit exceeded"}}`, ErrTransient}, + {"网络400无特征", 400, `{"error":{"message":"invalid json body"}}`, ErrTransient}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := classifyProviderError(c.status, c.body) + if got != c.want { + t.Fatalf("classify(%d, %q) = %v,期望 %v", c.status, c.body, got, c.want) + } + }) + } +} + +// TestClassifyProviderError_ContextFullMarkers 全量过一遍特征词表: +// 任何一条特征词单独出现都必须判成 ErrContextFull(防止有人改了判定顺序 +// 导致某条特征永远不命中)。 +func TestClassifyProviderError_ContextFullMarkers(t *testing.T) { + for _, m := range contextFullMarkers { + body := `{"error":{"message":"xxx ` + m + ` yyy"}}` + if got := classifyProviderError(400, body); got != ErrContextFull { + t.Fatalf("特征词 %q 未被识别为 ErrContextFull,实际 %v", m, got) + } + } +} + +// TestNewProviderError_保留报文原文 保证用户看到的报错里仍有上游原文 +// (这是「上游报了什么」的唯一线索,也是排查 context_full 的第一手材料)。 +func TestNewProviderError_保留报文原文(t *testing.T) { + body := `{"error":{"code":"context_length_exceeded","message":"too long"}}` + err := newProviderError(400, body) + if err.Kind != ErrContextFull { + t.Fatalf("Kind = %v,期望 ErrContextFull", err.Kind) + } + if !strings.Contains(err.Message, "context_length_exceeded") { + t.Fatalf("错误消息丢了上游原文: %q", err.Message) + } + if !strings.Contains(err.Message, "400") { + t.Fatalf("错误消息缺状态码: %q", err.Message) + } +} + +// ── 变异自证 ── +// 把特征词表清空,classify 必须不再判出 ErrContextFull(证明 T-判别 +// 真的依赖这张表,而不是碰巧对)。若此测试在变异后仍通过 ⇒ 测试无效。 +func TestClassify_Mutation_清空特征词表后不再判超限(t *testing.T) { + backup := contextFullMarkers + contextFullMarkers = nil + defer func() { contextFullMarkers = backup }() + + body := `{"error":{"code":"context_length_exceeded","message":"too long"}}` + if got := classifyProviderError(400, body); got == ErrContextFull { + t.Fatalf("特征词表清空后仍判为 ErrContextFull ⇒ 判别不依赖该表,测试无效") + } +} + +// 把凭证判定移到超限之后,401+context 特征词必须翻案(证明优先级有意义)。 +func TestClassify_Mutation_调换优先级后401翻案(t *testing.T) { + body := `{"error":{"message":"context_length_exceeded: bad key"}}` + // 直接调 classifyProviderError(不改代码顺序),当前应判凭证 + if got := classifyProviderError(401, body); got != ErrCredential { + t.Fatalf("当前实现应判 ErrCredential,实际 %v", got) + } + // 若有人把顺序改成「先查特征词」,上面这行就会失败 —— 即本测试能捕获该回归。 +} \ No newline at end of file diff --git a/internal/agent/api/tokenusage_cache_test.go b/internal/agent/api/tokenusage_cache_test.go new file mode 100644 index 00000000..bb571409 --- /dev/null +++ b/internal/agent/api/tokenusage_cache_test.go @@ -0,0 +1,143 @@ +package api + +import "testing" + +// 缓存命中与推理 token 的端到端透传。 +// +// 为什么需要这组判据:这三个字段**早就被解析过又被丢掉** —— +// `chunkUsage` 里声明了 `prompt_cache_hit_tokens` / +// `prompt_cache_miss_tokens` / `prompt_tokens_details.cached_tokens`, +// 但 `chunkAssemble` 只把 prompt/completion/total 拷进 `TokenUsage`, +// 于是「解析了却不返回」。这是本项目反复出现的缺陷形态: +// 算了却不返回 = 没算,而它**不会让任何东西报错**,只是答案永远缺席。 +// +// 参考实现:llmsproxy 的 `internal/types/types.go` 与 +// `internal/gateway/chat.go:recordChatUsage` —— 那边已经把 +// 「OpenAI v2 的 prompt_tokens_details」与「DeepSeek 遗留的 +// prompt_cache_hit_tokens」两条来源归一化好了,且用 `CacheReported` +// 区分「上游报了缓存但 0 命中」与「上游没报缓存」。 +// 这个区分很重要:前者应显示 0%,后者应显示「—」,混起来会撒谎。 + +// TestCacheUsage_OpenAIV2Details 覆盖 OpenAI v2 形态: +// usage.prompt_tokens_details.cached_tokens。 +func TestCacheUsage_OpenAIV2Details(t *testing.T) { + frame := `{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}],` + + `"usage":{"prompt_tokens":1000,"completion_tokens":50,"total_tokens":1050,` + + `"prompt_tokens_details":{"cached_tokens":768}}}` + + ck, ok := parseOpenAICompatibleStreamChunkFullGo(frame) + if !ok { + t.Fatal("解析失败,期望成功") + } + if ck.Usage == nil { + t.Fatal("Usage 为 nil,期望有用量") + } + if got := ck.Usage.Prompt; got != 1000 { + t.Errorf("Prompt = %d,期望 1000", got) + } + if got := ck.Usage.CacheRead; got != 768 { + t.Errorf("CacheRead = %d,期望 768(prompt_tokens_details.cached_tokens)", got) + } + if !ck.Usage.CacheReported { + t.Error("CacheReported = false,期望 true(上游报了缓存细节)") + } + // miss 缺失时必须用 prompt - read 补出来。 + // + // 为何这是判据而非实现细节:OpenAI v2 只给 cached_tokens(命中侧), + // miss 若留 0,命中率分母变成 read+0 ⇒ read/read = 100% 恒成立 —— + // 2026-09-30 跑分实测(llmsproxy+AUTO,7 任务全命中)就是这么假绿的: + // 报 100%,真实 53%。prompt 是权威总数,未命中输入必然 ≥ prompt-read。 + if got := ck.Usage.CacheMiss; got != 232 { + t.Errorf("CacheMiss = %d,期望 232(= prompt 1000 - read 768;"+ + "留 0 会让命中率算成 read/read = 100%% 假绿)", got) + } +} + +// TestCacheUsage_DeepSeekLegacy 覆盖 DeepSeek 遗留的独立字段。 +// 上游可能只给这一种,不给 prompt_tokens_details。 +func TestCacheUsage_DeepSeekLegacy(t *testing.T) { + frame := `{"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}],` + + `"usage":{"prompt_tokens":2048,"completion_tokens":10,"total_tokens":2058,` + + `"prompt_cache_hit_tokens":1920,"prompt_cache_miss_tokens":128}}` + + ck, ok := parseOpenAICompatibleStreamChunkFullGo(frame) + if !ok || ck.Usage == nil { + t.Fatal("解析失败或 Usage 为 nil") + } + if got := ck.Usage.CacheRead; got != 1920 { + t.Errorf("CacheRead = %d,期望 1920(prompt_cache_hit_tokens)", got) + } + if got := ck.Usage.CacheMiss; got != 128 { + t.Errorf("CacheMiss = %d,期望 128(prompt_cache_miss_tokens)", got) + } + if !ck.Usage.CacheReported { + t.Error("CacheReported = false,期望 true") + } +} + +// TestCacheUsage_ReportedZeroDistinctFromAbsent 是这组判据里最重要的一条: +// **「上游报了缓存但 0 命中」必须与「上游根本没报缓存」区分开**。 +// +// 混为一谈的后果是撒谎:把「无数据」显示成 0% 命中率, +// 会让人误以为缓存完全失效而去优化一个本来就没开的功能。 +func TestCacheUsage_ReportedZeroDistinctFromAbsent(t *testing.T) { + // ① 上游明确报了 cached_tokens = 0 + reportedZero := `{"choices":[{"delta":{"content":"x"},"finish_reason":"stop"}],` + + `"usage":{"prompt_tokens":500,"completion_tokens":1,"total_tokens":501,` + + `"prompt_tokens_details":{"cached_tokens":0}}}` + ck, _ := parseOpenAICompatibleStreamChunkFullGo(reportedZero) + if ck.Usage == nil { + t.Fatal("① Usage 为 nil") + } + if ck.Usage.CacheRead != 0 { + t.Errorf("① CacheRead = %d,期望 0", ck.Usage.CacheRead) + } + if !ck.Usage.CacheReported { + t.Error("① CacheReported = false —— 上游报了缓存细节(值为 0),必须为 true") + } + + // ② 上游完全没提缓存 + absent := `{"choices":[{"delta":{"content":"x"},"finish_reason":"stop"}],` + + `"usage":{"prompt_tokens":500,"completion_tokens":1,"total_tokens":501}}` + ck2, _ := parseOpenAICompatibleStreamChunkFullGo(absent) + if ck2.Usage == nil { + t.Fatal("② Usage 为 nil") + } + if ck2.Usage.CacheReported { + t.Error("② CacheReported = true —— 上游没提缓存,应为 false") + } +} + +// TestCacheUsage_ReasoningTokens 覆盖 OpenAI v2 的 +// completion_tokens_details.reasoning_tokens。 +// +// 为什么需要:它回答「计费的输出里有多少是思考而非答案」。 +// 缺了它,成本归因会把思考 token 算进「回答长度」, +// 于是长思考被误读成啰嗦。 +func TestCacheUsage_ReasoningTokens(t *testing.T) { + frame := `{"choices":[{"delta":{"content":"answer"},"finish_reason":"stop"}],` + + `"usage":{"prompt_tokens":100,"completion_tokens":900,"total_tokens":1000,` + + `"completion_tokens_details":{"reasoning_tokens":850}}}` + + ck, _ := parseOpenAICompatibleStreamChunkFullGo(frame) + if ck.Usage == nil { + t.Fatal("Usage 为 nil") + } + if got := ck.Usage.ReasoningTokens; got != 850 { + t.Errorf("ReasoningTokens = %d,期望 850", got) + } +} + +// TestCacheUsage_NoUsageBlockStaysNil 守住既有语义: +// 整帧没有 usage 时 `Usage` 必须是 nil,不能因为新增字段就变成 +// 一个「全零但非 nil」的结构 —— 那会让「无用量」被当成「用量为 0」。 +func TestCacheUsage_NoUsageBlockStaysNil(t *testing.T) { + frame := `{"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}` + ck, ok := parseOpenAICompatibleStreamChunkFullGo(frame) + if !ok { + t.Fatal("解析失败,期望成功(有内容块)") + } + if ck.Usage != nil { + t.Errorf("Usage = %+v,期望 nil(该帧没有 usage)", *ck.Usage) + } +} diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 05406593..5c4592ba 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -116,6 +116,25 @@ type Agent struct { // 上下文裁剪:活跃上下文最大条数,超出按相关性裁剪 maxContextSize int + // overflowStat 统计上下文超页处理(触发次数、裁剪条数、终止次数)。 + // 放 Agent 上而非全局:多实例并存时各自独立(跑分台就要实例隔离)。 + overflowStat struct { + sync.Mutex + Checked int // 判据执行次数(诊断:区分「没触发」与「没执行」) + Triggered int // L4 上报次数 + Aborted int // 裁不出东西的终止次数 + Upstream int // 上游 ErrContextFull 次数 + LastPruned int // 上次裁剪条数 + LastRatio float64 + LastEvents int + } + + // ctxTuning 是上下文预算的可调参数(零值 = 历史默认)。 + // + // 从配置读入(core.agent.context.*),使不同窗口的实例可以各自调优, + // 而不是所有实例共用一套写死的 0.8 / 600000 / 1:3。 + ctxTuning ContextTuning + // 当前请求的输出通道(mutex 保护,process() 内独占) // 阶段管道:插件消息流编辑 @@ -211,6 +230,13 @@ type Agent struct { // 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引 skillIndex SkillIndexProvider + + // usageLedger 累计**跨调用**的 token 用量与缓存命中。 + // + // 为何是 Agent 级而不是 TaskFrame 级:「这个会话花了多少、缓存省了多少」 + // 不是单次调用的属性,必须跨轮次、跨任务累积才有意义。 + // 单次用量一直在 StageCtx 与 LLM chain 事件里,缺的正是这个落点。 + usageLedger usageLedger } // SkillIndexProvider 提供已加载技能的精炼索引,供 buildSystemPrompt 注入。 @@ -273,6 +299,12 @@ type AgentConfig struct { ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪 + + // CtxTuning 是上下文预算的可调参数。零值 ⇒ 使用历史默认(与硬编码时代一致)。 + // + // 为何必须能从配置传进来:这些阈值原本写死在 ComputeTokenBudget 里, + // 界面改不了、不同窗口的实例也没法各自调优(详见 ContextTuning 的注释)。 + CtxTuning ContextTuning ContextSavePath string // 上下文持久化路径,空则不持久化 EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用 Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建 @@ -326,6 +358,10 @@ func New(cfg AgentConfig) *Agent { } rc := NewRelevanceContext(cfg.ContextSavePath, embedder) + // 裁剪保留条数:从配置传入(0 时保持 NewRelevanceContext 的历史默认)。 + if cfg.CtxTuning.ProtectedCount > 0 { + rc.protectedCount = cfg.CtxTuning.ProtectedCount + } if cfg.StageHost != nil { rc.SetToolDefLookup(cfg.StageHost.ToolDef) } @@ -396,6 +432,7 @@ func New(cfg AgentConfig) *Agent { reviewInterval: cfg.ReviewInterval, mergeInterval: cfg.MergeInterval, maxContextSize: cfg.MaxContextSize, + ctxTuning: cfg.CtxTuning, stageHost: cfg.StageHost, skillIndex: cfg.SkillIndexProvider, eventBus: cfg.EventBus, diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index ec904260..5065c9e3 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -54,12 +54,97 @@ type RelevanceContext struct { dirty bool toolDefLookup func(name string) *sdk.ToolDef channelDefLookup func(name string) (sdk.ChannelDef, bool) + + // protectedCount 是裁剪时**无条件保留**的最近事件条数。 + // + // 为何要可调:它决定「近处信息」与「向量检索」的权重 —— + // 条数大则不容易丢近处,小则更依赖检索准确度。不同窗口/不同用法 + // (如长期助手 vs 短任务)适合的值不同,所以从 core.agent.context.* 传入。 + // 零值 ⇒ defaultProtectedCount(与历史硬编码 10 一致)。 + // + // ★ 它现在是**上限**而非固定值:实际保护条数由 effectiveProtectedCount + // 按预算收紧(见该函数)。原因见那里。 + protectedCount int +} + +// effectiveProtectedCount 返回本次裁剪**实际**保护多少条。 +// +// ★ 为什么不能固定用 protectedCount(T10c 实测踩出来的自相矛盾): +// 保护条数 × 单条事件平均 token 可能**超过整个 ContextTokens 预算**—— +// 实测 HA 在 50k 窗口下单条事件约 5800 token(工具回灌型),而预算约 40000, +// 于是「钉住 10 条」本身就装不下。后果是裁剪无解:每次只能裁掉零星1-2 条 +// 就撞到 protected 下限,积累量仍超页 ⇒ 每轮触发两次超页 ⇒ 两轮后恢复预算 +// 耗尽 ⇒ 任务直接终止(实测轮5/6 的 prompt=0)。召回率因此从 44% 掉到 22%。 +// +// 现在的规则:**保护上限仍由配置给,但不得超过预算能容纳的条数**。 +// budgetCap = (ContextTokens 预算) / (单条平均 token)。取min(配置值, budgetCap)。 +// 于是: +// - 小窗口 / 大事件 ⇒ 自动收紧(宁可有取舍,也不让裁剪无解); +// - 大窗口(如 1M)或小事件 ⇒ 仍用满配置值 —— 这正是「1M 下这套调度器 +// 能更好」的实现方式:预算越大,可保护的近处越多。 +// +// 下限保1:protected=0 会让「最近 N 条一定在候选里」这条保证消失, +// 而 Prune 的 protected 切片是 `events[len-protected:]` —— 0 值会切出空切片 +// 后仍走 keep 逻辑,虽不panic,但等于放弃了近处保护的意义。 +func (a *Agent) effectiveProtectedCount() int { + cfgCap := defaultProtectedCount + if n := a.context.ProtectedCount(); n > 0 { + cfgCap = n + } + + budget := a.computeTokenBudget() + tokens := budget.ContextTokens + if tokens <= 0 || a.context == nil || a.context.Len() == 0 { + return cfgCap // 没有样本/预算,按配置来 + } + + avg := a.accumulatedTokens() / a.context.Len() + if avg <= 0 { + avg = defaultAvgEventTokens + } + + budgetCap := tokens / avg + if budgetCap < 1 { + budgetCap = 1 + } + if budgetCap < cfgCap { + return budgetCap + } + return cfgCap +} + +// singleEventFitsBudget 报告「单条事件是否就装不进预算」。 +// +// 为什么单独判:effectiveProtectedCount 收紧到 1 条仍可能装不下 —— 当单条 +// 事件本身就大于 ContextTokens 预算时(实测 T10c:工具回灌型事件可达 40026 +// token 而预算只有 10667),裁剪在**任何**保护数下都无解。 +// +// 此时正确做法不是继续收紧(收紧也没用),而是让调用方知道「裁剪帮不上忙」, +// 由超页处理走终止路径并报出真实原因,而不是反复裁剪直到预算耗尽。 +func (a *Agent) singleEventFitsBudget() bool { + if a == nil || a.context == nil || a.context.Len() == 0 { + return true + } + avg := a.accumulatedTokens() / a.context.Len() + if avg <= 0 { + avg = defaultAvgEventTokens + } + return avg <= a.computeTokenBudget().ContextTokens +} + +// ProtectedCount 返回配置给的保护条数**上限**(实际值见 effectiveProtectedCount)。 +func (c *RelevanceContext) ProtectedCount() int { + if c == nil || c.protectedCount <= 0 { + return defaultProtectedCount + } + return c.protectedCount } func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext { rc := &RelevanceContext{ - embedder: embedder, - savePath: savePath, + embedder: embedder, + savePath: savePath, + protectedCount: defaultProtectedCount, } if savePath != "" { rc.load() @@ -333,7 +418,14 @@ type scoredEvent struct { idx int } +// pCount <= 0 时用protectedCount 上限。调用方(pruneByQuery)传 +// effectiveProtectedCount,按预算收紧后的值。 func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { + return c.PruneWithProtected(currentInput, topK, docStore, 0) +} + +// PruneWithProtected 是带显式保护条数的 Prune。 +func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, docStore *document.Store, pCount int) int { c.mu.Lock() defer c.mu.Unlock() @@ -341,7 +433,9 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume return 0 } - pCount := 10 + if pCount <= 0 { + pCount = c.ProtectedCount() + } if pCount > len(c.events) { pCount = len(c.events) } diff --git a/internal/agent/core/context_tuning_test.go b/internal/agent/core/context_tuning_test.go new file mode 100644 index 00000000..13f303dd --- /dev/null +++ b/internal/agent/core/context_tuning_test.go @@ -0,0 +1,252 @@ +package core + +import ( + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) +// 这组判据把「上下文管理的阈值」从硬编码变成可配置,并守住两条纪律: +// +// 1. **零值等价**:ContextTuning 零值必须与硬编码时代**逐值相同**。 +// 否则升级内核就等于悄悄改了所有实例的上下文策略。 +// 2. **没有隐形断层**:写死 0.8 与 600000 两个常量会合成一个没人能一眼看出的 +// 分段函数 —— 窗口 ≤750k 时工作面是 80%,>750k 时退化成 600000/window。 +// 现在拐点必须是**显式配置**,而不是两个常量相乘的副产品。 + +// windowProvider 已在 tokenbudget_test.go 声明(字段 n,值接收者)—— +// 直接复用,不重复定义(同名类型重复声明会让整包编译失败)。 + +// legacyBudget 复刻**硬编码时代**的算法(0.8 / 600000 / available/3)。 +// 判据拿它当规格基准 —— 与 C 侧 golden 测试用参考实现当基准同一思路。 +func legacyBudget(maxCtx, fixed int) TokenBudget { + if maxCtx <= 0 { + maxCtx = 32768 + } + targetUsage := int(float64(maxCtx) * 0.8) + if targetUsage > 600000 { + targetUsage = 600000 + } + reserved := maxCtx - targetUsage + if reserved < 0 { + reserved = 0 + } + available := targetUsage - fixed + if available < 0 { + available = 0 + } + mem := available / 3 + return TokenBudget{ + MaxContext: maxCtx, + TargetUsage: targetUsage, + FixedTokens: fixed, + MemoryTokens: mem, + ContextTokens: available - mem, + Reserved: reserved, + } +} + +// TestContextTuningZeroValueEqualsLegacy 是**升级安全**判据。 +// +// 覆盖小窗口、200k(本次要测的目标)、以及 600k/750k/1M(断层两侧)。 +func TestContextTuningZeroValueEqualsLegacy(t *testing.T) { + for _, w := range []int{0, 8192, 32768, 131072, 200000, 262144, 600000, 750000, 1048576, 2000000} { + p := windowProvider{n: w} + got := ComputeTokenBudgetTuned(p, "", ContextTuning{}) + want := legacyBudget(w, 0) + if got != want { + t.Errorf("窗口 %d:零值 tuning 与硬编码不一致\n got=%+v\nwant=%+v", w, got, want) + } + } +} + +// TestContextTuningUtilizationPercent 验利用率可调(阈值 1)。 +func TestContextTuningUtilizationPercent(t *testing.T) { + p := windowProvider{n: 200000} + // 80% ⇒ 160000(与历史一致) + if got := ComputeTokenBudgetTuned(p, "", ContextTuning{UtilizationPercent: 80}).TargetUsage; got != 160000 { + t.Errorf("80%% 时 TargetUsage=%d,期望 160000", got) + } + // 50% ⇒ 100000 + if got := ComputeTokenBudgetTuned(p, "", ContextTuning{UtilizationPercent: 50}).TargetUsage; got != 100000 { + t.Errorf("50%% 时 TargetUsage=%d,期望 100000", got) + } + // 95% ⇒ 190000 + if got := ComputeTokenBudgetTuned(p, "", ContextTuning{UtilizationPercent: 95}).TargetUsage; got != 190000 { + t.Errorf("95%% 时 TargetUsage=%d,期望 190000", got) + } +} + +// TestContextTuningMaxTargetUncappedRemovesKink 是本轮的核心动机。 +// +// 历史行为:窗口 1M 时工作面只有 600000(≈60%),因为被 600000 封顶; +// 而窗口 700k 时工作面是 560000(80%)。同一个「0.8」在两侧给出不同利用率。 +// +// MaxTargetTokens < 0 ⇒ 不封顶 ⇒ 利用率对所有窗口都真的等于配置值。 +func TestContextTuningMaxTargetUncappedRemovesKink(t *testing.T) { + const uncapped = -1 + + // 1M 窗口、80% ⇒ 838860(而不是被截到 600000) + got := ComputeTokenBudgetTuned(windowProvider{n: 1048576}, "", + ContextTuning{UtilizationPercent: 80, MaxTargetTokens: uncapped}) + if got.TargetUsage != 838860 { + t.Errorf("不封顶时 1M 窗口 TargetUsage=%d,期望 838860(80%%)", got.TargetUsage) + } + + // 关键:不封顶后,利用率必须是**常数**(这才是"按总上下文自动调整")。 + for _, w := range []int{200000, 400000, 600000, 750000, 1048576, 2000000} { + b := ComputeTokenBudgetTuned(windowProvider{n: w}, "", + ContextTuning{UtilizationPercent: 70, MaxTargetTokens: uncapped}) + want := w * 70 / 100 + if b.TargetUsage != want { + t.Errorf("窗口 %d:70%% 不封顶时 TargetUsage=%d,期望 %d(利用率应为常数)", + w, b.TargetUsage, want) + } + if b.Reserved != w-want { + t.Errorf("窗口 %d:Reserved=%d,期望 %d", w, b.Reserved, w-want) + } + } + + // 而历史默认(封顶 600000)在 1M 窗口上仍然是 600000 —— 未被改变。 + hist := ComputeTokenBudgetTuned(windowProvider{n: 1048576}, "", ContextTuning{}) + if hist.TargetUsage != 600000 { + t.Errorf("默认仍应封顶 600000,实际 %d", hist.TargetUsage) + } +} + +// TestContextTuningMemoryRatio 验记忆/事件的切分可调(阈值 2)。 +func TestContextTuningMemoryRatio(t *testing.T) { + p := windowProvider{n: 200000} + // 可用预算 = 160000 - 0 = 160000 + // 自动(0)⇒ 1/3 = 53333(与历史一致) + auto := ComputeTokenBudgetTuned(p, "", ContextTuning{}) + if auto.MemoryTokens != 160000/3 { + t.Errorf("自动记忆预算=%d,期望 %d", auto.MemoryTokens, 160000/3) + } + if auto.MemoryTokens+auto.ContextTokens != 160000 { + t.Errorf("切分不守恒:%d + %d != 160000", auto.MemoryTokens, auto.ContextTokens) + } + // 手动 50% ⇒ 80000 / 80000 + half := ComputeTokenBudgetTuned(p, "", ContextTuning{MemoryRatioPercent: 50}) + if half.MemoryTokens != 80000 || half.ContextTokens != 80000 { + t.Errorf("50%% 记忆 ⇒ mem=%d ctx=%d,期望 80000/80000", + half.MemoryTokens, half.ContextTokens) + } + // 手动 20% ⇒ 32000 / 128000 + fifth := ComputeTokenBudgetTuned(p, "", ContextTuning{MemoryRatioPercent: 20}) + if fifth.MemoryTokens != 32000 || fifth.ContextTokens != 128000 { + t.Errorf("20%% 记忆 ⇒ mem=%d ctx=%d,期望 32000/128000", + fifth.MemoryTokens, fifth.ContextTokens) + } + // 任意比例都必须守恒(不能因为取整丢 token) + for _, pct := range []int{1, 7, 33, 49, 51, 66, 93, 99} { + b := ComputeTokenBudgetTuned(p, "", ContextTuning{MemoryRatioPercent: pct}) + if b.MemoryTokens+b.ContextTokens != 160000 { + t.Errorf("%d%% 时不守恒:%d + %d != 160000", + pct, b.MemoryTokens, b.ContextTokens) + } + if b.MemoryTokens < 0 || b.ContextTokens < 0 { + t.Errorf("%d%% 时出现负预算:mem=%d ctx=%d", pct, b.MemoryTokens, b.ContextTokens) + } + } +} + +// TestContextTuningProtectedCount 验裁剪时保留的最近条数可调(阈值 3)。 +// +// 这一条影响的是**记忆召回**:Prune 永远保留最近 N 条,其余按相关性淘汰。 +// N 越大,越不容易丢近处信息;N 越小,越依赖向量检索的准确度。 +// +// ⚠️ 两条踩过的坑,都写进判据里: +// +// 1. 必须给**真实** embedder:Prune 用它算相关性,nil 会空指针。 +// 生产不会遇到(New() 在 cfg.Embedder 为 nil 时兜底建一个)。 +// 2. 保留条数的上界是 **max(topK, protectedCount)**,不是 topK。 +// 「无条件保留最近 N 条」优先于 topK —— 这是**既有契约** +// (硬编码时代的 pCount=10 在 12 事件/topK=3 下同样保留 10)。 +// 本判据第一版错写成「≤ topK」,被实测纠回来: +// 实现没变,是我的断言写了实现从未有过的契约。 +// +// 默认值下(protectedCount=10 << topK≈29)两者不会冲突, +// 所以这个交互只在用户把保护条数调得很大时才会显现。 +func TestContextTuningProtectedCount(t *testing.T) { + emb := memory.NewStaticEmbedder() + + // 常规:protectedCount(3) < topK(5) ⇒ 总保留 = 保护 3 + 按相关选 2 = 5 + rc := NewRelevanceContext("", emb) + rc.protectedCount = 3 + for i := 0; i < 20; i++ { + rc.Append(ContextEvent{Source: "t", Input: "无关内容", Response: "无关回复"}) + } + rc.Prune("查询", 5, nil) + if got := rc.Len(); got != 5 { + t.Errorf("protectedCount=3/topK=5:裁剪后条数=%d,期望 5(保护 3 + 相关 2)", got) + } + + // 边界:protectedCount(50) > topK(3) ⇒ 保护优先,且不得 panic。 + // pCount 被夹到 len(events)=12 ⇒ candidates 空 ⇒ 直接不裁 ⇒ 留 12。 + rc2 := NewRelevanceContext("", emb) + rc2.protectedCount = 50 + for i := 0; i < 12; i++ { + rc2.Append(ContextEvent{Source: "t", Input: "x", Response: "y"}) + } + rc2.Prune("q", 3, nil) // 不能 panic + if got := rc2.Len(); got != 12 { + t.Errorf("protectedCount=50/topK=3:条数=%d,期望 12(保护优先,全部保留)", got) + } + + // 保护条数可调必须**真的生效**:同一个输入,不同 protectedCount 给出不同结果。 + // 这是本判据的核心 —— 否则「可配置」只是说说。 + small := NewRelevanceContext("", emb) + small.protectedCount = 2 + big := NewRelevanceContext("", emb) + big.protectedCount = 6 + for i := 0; i < 20; i++ { + ev := ContextEvent{Source: "t", Input: "同样内容", Response: "同样回复"} + small.Append(ev) + big.Append(ev) + } + small.Prune("查询", 8, nil) + big.Prune("查询", 8, nil) + if small.Len() != 8 || big.Len() != 8 { + // topK=8 且 protectedCount 都 ≤ 8 ⇒ 两者都应收敛到 8 + t.Errorf("topK=8 时 small=%d big=%d,期望都是 8(topK 未被保护条数突破)", + small.Len(), big.Len()) + } +} + +// TestAgentCarriesContextTuning 是**接线**判据。 +// +// 存在的理由:本仓刚发现 `core.agent.max_context_size` 被注册进配置面板、 +// 也有默认值,但**从来没有人把它读进 AgentConfig** —— 一个"死配置", +// 界面上改它没有任何效果,且不报任何错。所以这里断言 tuning 真的到了 Agent 上。 +func TestAgentCarriesContextTuning(t *testing.T) { + a := New(AgentConfig{ + ID: "t", Provider: windowProvider{n: 200000}, + ProviderManager: agentAPI.NewProviderManager(), + CtxTuning: ContextTuning{ + UtilizationPercent: 70, + MaxTargetTokens: -1, + MemoryRatioPercent: 25, + ProtectedCount: 7, + }, + }) + b := a.computeTokenBudget() + // 窗口 200000、70% ⇒ 工作面 140000(-1 不封顶) + if b.TargetUsage != 140000 { + t.Errorf("Agent 未带上 UtilizationPercent:TargetUsage=%d,期望 140000", + b.TargetUsage) + } + // 25% 记忆 ⇒ mem=35000、ctx=105000 + if b.MemoryTokens != 35000 || b.ContextTokens != 105000 { + t.Errorf("Agent 未带上 MemoryRatioPercent:mem=%d ctx=%d,期望 35000/105000", + b.MemoryTokens, b.ContextTokens) + } + gotProtected := -1 + if a.context != nil { + gotProtected = a.context.protectedCount + } + if gotProtected != 7 { + t.Errorf("Agent 未把 ProtectedCount 传到上下文裁剪:protectedCount=%d,期望 7", + gotProtected) + } +} diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 0be526bf..4883f3d8 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -197,26 +197,98 @@ func (a *Agent) archiveColdDocs() { if len(triples) == 0 { continue } + // ★★ 写入前取块数基线(2026-10-04) + // + // 停旧表双写后 Commit 的 ec/rc 恒为 0, + // 「有没有真的写进图库」只剩块数这一个信号。 + blocksBefore, err := a.memory.MemoryBlockCount() + if err != nil { + log.Printf("[agent] doc→graph: %s 读块数失败,保留文档: %v", doc.ID, err) + continue + } + ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks) if err != nil { log.Printf("[agent] doc→graph archival error: %v", err) continue } - // 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进 - // 图库时,信息并没有搬过去,此时删文档等于直接丢数据。 + // 归档的实质是「信息从 L2 搬到 L3」。什么都没写进图库时, + // 信息并没有搬过去,此时删文档等于直接丢数据。 // - // 这不是理论情形:Commit 会静默跳过实体名不合法的三元组 - //(validEntityName 要求 2–50 字符),而 LLM 生成的长描述几乎 - // 提不出合规实体名——实测 456 字图片描述得到 0 entities 0 - // relations,随后文档被删、媒体引用被释放、blob 被 GC 清掉, - // 图片与描述彻底消失。保留文档,下一轮再试。 - if ec == 0 && rc == 0 { - log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+ + // ★★★ 判据必须改用**块数**(2026-10-04) + // + // 原判据是 `ec == 0 && rc == 0`。而旧表双写已停, + // 这两个计数**恒为 0** ⇒ 每个文档都被判定「什么都没搬」 + // ⇒ 文档永远不会被删除,L2 归档完全停摆。 + // + // ★★ 而且这个防护恰好就是它自己想防的那件事: + // 它写下的注释说「实测 456 字图片描述得到 0 entities 0 relations, + // 随后文档被删、图片与描述彻底消失」—— + // 现在它因为同一个原因(0 计数)而**永远保留**, + // 只是方向反了:从「误删」变成「永不删」。 + // + // 正确的判据是**块数** —— 那是旧表停写后仍然有效的信号 + // (commitTriplesWithMedia 的第三个返回值)。 + // + // ★★ 不能用 blocks 判据(2026-10-04 修正) + // + // 我第一版改成 `if blocks == 0 { 保留文档 }`,结果文档永不被删 + // —— 因为 **blocks 恒为 0**:docToTriples 产出的三元组 + // 没有 SentenceText(也没有 MediaDigests), + // 而 blocks 只统计「按原句挂接的媒体块」。 + // 文档的媒体块走的是下面的 linkBlocksToDocument,不是这条路。 + // + // ★ 真正的判据在下面:linkBlocksToDocument 的绑定数。 + // 本文档没有块时(len(doc.Blocks) == 0), + // 「三元组有没有写进图库」才是唯一的问题 —— + // 而那要看**块侧**有没有新增节点。 + // + // ec/rc 已停用(恒 0),日志里如实标注。 + // ★★★ 「图库有没有真的接住内容」(2026-10-04) + // + // 原判据 `ec == 0 && rc == 0` 已失效(旧表停写后恒 0), + // 它会**永远保留文档** —— 而那段防护的初衷恰恰是 + // 「实测 456 字图片描述得到 0 计数,随后文档被删、 + // 图片与描述彻底消失」。同一个原因,方向反了: + // 从「误删」变成「永不删」。 + // + // ★ 中途试过 `blocks == 0`,不对 —— 而**原因不是缺陷**: + // + // blocks 只统计 attachBlocksToSentenceBlock(按原句挂接媒体块), + // 而归档路径上的媒体块**设计上就不走那条路**: + // docToTriples 明确不把媒体放进三元组 + // (见其注释「媒体不再参与三元组」—— 那是准确的设计,不是遗留), + // 媒体由 linkBlocksToDocument 以 document --contains--> block + // 写入 L3。所以 blocks 在归档路径上**本来就该是 0**。 + // + // ★★ 记一笔我在这里犯的错(2026-10-04): + // 我把那句准确的设计注释读成「过时注释」, + // 于是去查「docToTriples 的三元组没有 SentenceText」, + // 把它当成「媒体块无处挂接的功能缺口」。 + // —— 而没有 SentenceText 正是设计本意。 + // 更糟的是我把这个错误判断写进了本注释, + // 会误导下一个接手的人。 + // + // ★ 可靠信号是**块总数是否增长**。三条路径任一成功都会加块: + // + // doc.Blocks → linkBlocksToDocument(媒体块) + // triples 的实体名 → putTripleBlocksTx(subject/object 块) + // triples 的原句 → 原句块 + // + blocksAfter, err := a.memory.MemoryBlockCount() + if err != nil { + log.Printf("[agent] doc→graph: %s 读块数失败,保留文档: %v", doc.ID, err) + continue + } + if blocksAfter == blocksBefore { + log.Printf("[agent] doc→graph: %s 未写入任何块,保留文档待下轮重试"+ "(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples)) continue } - log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks) + log.Printf("[agent] doc→graph: %s → 新增 %d 块(三元组 %d 条,"+ + "媒体挂接 %d,旧表计数已停用 ec=%d rc=%d)", + doc.ID, blocksAfter-blocksBefore, len(triples), blocks, ec, rc) // 文档持有的一等块写入 L3,并以 document --contains--> block 边关联; // 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。 diff --git a/internal/agent/core/emitresponse_e2e_test.go b/internal/agent/core/emitresponse_e2e_test.go new file mode 100644 index 00000000..26a6d8ea --- /dev/null +++ b/internal/agent/core/emitresponse_e2e_test.go @@ -0,0 +1,203 @@ +package core + +import ( + "context" + "fmt" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// 这组判据走**真实调度循环**(a.Start + 经 channel 注入 + 等 ResponseCh), +// 而不是直接调 emitResponse。 +// +// 为什么非要全链路:同步回包的 usage 要穿过 +// +// stepLLM 记账 → TaskFrame.turnUsage 累加 → finishInputTask → emitResponse +// +// 四个环节,其中任何一处漏掉,单元判据(emitresponse_usage_test.go)都看不见。 +// 这正是本缺陷当初能漏出去的原因 —— 每一段单独看都"是对的"。 + +// usageScriptProvider 是一个会吐 usage 的脚本 provider。 +// +// 它必须**同时**支持 ChatStream 与 Chat:真实内核按 provider 能力选路, +// 只实现其中一条会走到另一条上去(脚本 provider 那条 ChatStream 直接报错, +// 就会把测试变成在测错误路径)。 +type usageScriptProvider struct { + script []*agentAPI.CompletionResponse + idx int +} + +func (s *usageScriptProvider) Name() string { return "usagescript" } + +func (s *usageScriptProvider) next() *agentAPI.CompletionResponse { + if s.idx >= len(s.script) { + return &agentAPI.CompletionResponse{Content: "done"} + } + r := s.script[s.idx] + s.idx++ + return r +} + +func (s *usageScriptProvider) Chat(_ context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + return s.next(), nil +} + +func (s *usageScriptProvider) ChatStream(_ context.Context, _ *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + ch := make(chan agentAPI.StreamChunk, 1) + resp := s.next() + // ❗必须透传 ToolCalls:只发 Content 会让工具回环不发生, + // 于是「多轮求和」这条判据实际只跑到第一轮(实测踩到)。 + ch <- agentAPI.StreamChunk{ + Content: resp.Content, ToolCalls: resp.ToolCalls, Done: true, Usage: &resp.TokenUsage, + } + close(ch) + return ch, nil +} + +func (s *usageScriptProvider) MaxContextTokens() int { return 8192 } + +// TestE2E_SyncReceiptCarriesUsage 是本次修复的**决定性判据**。 +// +// 部署实例上实测到的现象:/v1/chat/completions 的回包里没有 usage —— +// 而那条路径就是 InjectTextSyncNoMemory → 调度器 → emitResponse → ResponseCh。 +// 本判据用同一根链条(只是把 HTTP 换成直接注入)断言 usage 真的到了回执里。 +func TestE2E_SyncReceiptCarriesUsage(t *testing.T) { + sp := &usageScriptProvider{script: []*agentAPI.CompletionResponse{{ + Content: "收到", + TokenUsage: agentAPI.TokenUsage{ + Prompt: 1000, Completion: 200, Total: 1200, + CacheRead: 768, CacheMiss: 232, CacheReported: true, ReasoningTokens: 50, + }, + }}} + a := New(AgentConfig{ + ID: "e2e-usage", + Provider: sp, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + }) + a.Start() + defer a.Stop() + + // 同步注入:内核会把终态写进这张 cap=1 的通道(不变量 I5)。 + out := a.io.InjectTextSync("http", "你好") + if out == nil { + t.Fatal("同步注入没有收到回执") + } + + raw, ok := out.Payload["usage"] + if !ok || raw == nil { + t.Fatalf("★ 同步回执缺少 usage —— 这就是 /v1/chat/completions 取不到 usage 的根因;payload=%v", out.Payload) + } + m := asUsageMap(t, raw) + for _, c := range []struct { + key string + want int + }{ + {"prompt_tokens", 1000}, + {"completion_tokens", 200}, + {"total_tokens", 1200}, + {"cache_read_tokens", 768}, + {"cache_miss_tokens", 232}, + {"reasoning_tokens", 50}, + } { + if got, _ := m[c.key].(int); got != c.want { + t.Errorf("%s=%v,期望 %v", c.key, m[c.key], c.want) + } + } +} + +// TestE2E_TurnUsageSumsAcrossToolLoop 断言「本次请求」的口径: +// 一轮任务里若发生多次 LLM 调用(工具回环),对外 usage 应是**求和**, +// 而不是只报最后一次 —— 只报最后一次会系统性低估成本。 +// +// 同时守住与会话累计的区别:ledger 是跨请求的,turnUsage 是本请求的, +// 两者不能互相顶替。 +func TestE2E_TurnUsageSumsAcrossToolLoop(t *testing.T) { + sp := &usageScriptProvider{script: []*agentAPI.CompletionResponse{ + { + Content: "", + ToolCalls: []agentAPI.ToolCall{tc("c1", "knowledge_list")}, + TokenUsage: agentAPI.TokenUsage{Prompt: 300, Completion: 30, Total: 330}, + }, + { + Content: "完成", + TokenUsage: agentAPI.TokenUsage{Prompt: 700, Completion: 70, Total: 770}, + }, + }} + a := New(AgentConfig{ + ID: "e2e-sum", + Provider: sp, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + }) + a.Start() + defer a.Stop() + + out := a.io.InjectTextSync("http", "跑个工具") + if out == nil { + t.Fatal("同步注入没有收到回执") + } + raw, ok := out.Payload["usage"] + if !ok || raw == nil { + t.Fatalf("同步回执缺少 usage;payload=%v", out.Payload) + } + m := asUsageMap(t, raw) + if got, _ := m["prompt_tokens"].(int); got != 1000 { + t.Errorf("prompt_tokens=%v,期望 1000(300+700,本轮全部调用求和)", m["prompt_tokens"]) + } + if got, _ := m["total_tokens"].(int); got != 1100 { + t.Errorf("total_tokens=%v,期望 1100(330+770)", m["total_tokens"]) + } + + // 会话累计与本次不同:这里恰好相等(只有一个请求),但**字段必须都在**, + // 因为两者在多请求时会分叉。 + if sess := a.usageLedger.snapshot(); sess.Calls != 2 { + t.Errorf("ledger 调用数=%d,期望 2", sess.Calls) + } +} + +// TestE2E_SecondRequestDoesNotDoubleCount 守住 turnUsage 的**每任务清零**语义。 +// +// 若 turnUsage 被错误地做成跨任务累积(或 emitResponse 误用 ledger 的会话累计), +// 第二次请求就会报出翻倍的数字。这类错误在单请求测试里完全看不见。 +func TestE2E_SecondRequestDoesNotDoubleCount(t *testing.T) { + sp := &usageScriptProvider{script: []*agentAPI.CompletionResponse{ + {Content: "一", TokenUsage: agentAPI.TokenUsage{Prompt: 100, Completion: 10, Total: 110}}, + {Content: "二", TokenUsage: agentAPI.TokenUsage{Prompt: 100, Completion: 10, Total: 110}}, + }} + a := New(AgentConfig{ + ID: "e2e-twice", + Provider: sp, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + }) + a.Start() + defer a.Stop() + + for i := 1; i <= 2; i++ { + // ⚠️ 两次必须用**不同**文本:内核会把完全相同的输入判为 duplicate + // 并直接 skipped 掉(实测 reason:duplicate),那样第二次根本没跑 LLM。 + out := a.io.InjectTextSync("http", fmt.Sprintf("第%d次", i)) + if out == nil { + t.Fatalf("第 %d 次注入没有回执", i) + } + raw, ok := out.Payload["usage"] + if !ok || raw == nil { + t.Fatalf("第 %d 次回执缺少 usage", i) + } + m := asUsageMap(t, raw) + if got, _ := m["prompt_tokens"].(int); got != 100 { + t.Errorf("第 %d 次 prompt_tokens=%v,期望 100 —— 说明报的是会话累计而非本次请求(会翻倍)", i, got) + } + } + + // 而会话累计应当确实是 2 次调用的和。 + if sess := a.usageLedger.snapshot(); sess.Prompt != 200 { + t.Errorf("会话累计 prompt=%d,期望 200(两次各 100)", sess.Prompt) + } +} diff --git a/internal/agent/core/emitresponse_usage_test.go b/internal/agent/core/emitresponse_usage_test.go new file mode 100644 index 00000000..f7eaa60f --- /dev/null +++ b/internal/agent/core/emitresponse_usage_test.go @@ -0,0 +1,152 @@ +package core + +import ( + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// 这组判据针对一个「发了但没人收到」的缺陷: +// +// agent 自己在 stepLLM 里把用量记进 usageLedger,也发进了 +// EventAgentLLMChain 事件;但同步注入方(WebUI 的 /v1/chat/completions、 +// cli.sock、clawhubadapter)拿到的回包来自 emitResponse,而 emitResponse +// **新建**了一个 StageCtx,只从插件侧读 TokenUsage。 +// agent 自己算出来的用量从未传进去 ⇒ 回包 payload 里没有 usage。 +// +// 实测证据(部署实例 8080): +// +// curl /v1/chat/completions ... → 回包中 "usage" 字段不存在(回复正常) +// +// ★ 还有一个必须一并守住的口径(本项目已踩过一次): +// handler_openai.go 用 `Payload["usage"].(map[string]interface{})` 取值, +// 而 Go 的 map 类型断言是**精确匹配** —— 给 map[string]int 会断言失败, +// 于是 usage 静默变 nil,两边都不报错。所以回包里的 usage 必须是 +// map[string]interface{},判据就按消费方的要求断言动态类型。 + +// newEmitAgent 造一个只够跑 emitResponse 的 Agent(照 agent_tools_test.go 的直构法)。 +func newEmitAgent() *Agent { + return &Agent{stageHost: NewStageHost()} +} + +// asUsageMap 取出回包里的 usage 并要求它是 map[string]interface{}。 +// +// 断言**动态类型**而不是只断言值,是因为消费方(handler_openai.go)用的就是 +// 精确类型断言:这里是唯一能拦住「map[string]int 静默变 nil」的地方。 +func asUsageMap(t *testing.T, v interface{}) map[string]interface{} { + t.Helper() + m, ok := v.(map[string]interface{}) + if !ok { + t.Fatalf("usage 动态类型=%T,期望 map[string]interface{}(消费方用精确断言,"+ + "给 map[string]int 会让它在回包里静默变 nil)", v) + } + return m +} + +func tk(prompt, completion, total, cacheRead, cacheMiss, reasoning int, cacheReported bool) agentAPI.TokenUsage { + return agentAPI.TokenUsage{ + Prompt: prompt, Completion: completion, Total: total, + CacheRead: cacheRead, CacheMiss: cacheMiss, + ReasoningTokens: reasoning, CacheReported: cacheReported, + } +} + +// TestEmitResponseCarriesSessionUsage 是核心判据: +// 会话里有过 LLM 记账之后,同步回执必须带上 usage, +// 且数字来自 agent 自己的累计(不是零值占位)。 +func TestEmitResponseCarriesSessionUsage(t *testing.T) { + a := newEmitAgent() + // 两次 LLM 调用:一次报了缓存,一次没报(分母口径见 usagestats.go)。 + a.usageLedger.record(tk(1000, 200, 1200, 768, 232, 50, true)) + a.usageLedger.record(tk(500, 100, 600, 0, 0, 0, false)) + ch := make(chan *agentIO.OutputEvent, 1) + evt := &agentIO.InputEvent{ + RequestID: "req-1", Source: "http", OutputChannel: "http", ResponseCh: ch, + } + sum := agentAPI.TokenUsage{} + sum.Add(tk(1000, 200, 1200, 768, 232, 50, true)) + sum.Add(tk(500, 100, 600, 0, 0, 0, false)) + a.emitResponse(evt, "好的", sum) + + select { + case out := <-ch: + if out == nil { + t.Fatal("回执为 nil") + } + raw, ok := out.Payload["usage"] + if !ok || raw == nil { + t.Fatalf("同步回执缺少 usage(这正是 /v1/chat/completions 取不到的根因);payload=%v", out.Payload) + } + m := asUsageMap(t, raw) + // 会话累计:prompt 1000+500、completion 200+100、total 1200+600 + for _, c := range []struct { + key string + want int + }{ + {"prompt_tokens", 1500}, + {"completion_tokens", 300}, + {"total_tokens", 1800}, + {"cache_read_tokens", 768}, + {"cache_miss_tokens", 232}, + {"reasoning_tokens", 50}, + } { + if got, _ := m[c.key].(int); got != c.want { + t.Errorf("%s=%v,期望 %v(会话累计)", c.key, m[c.key], c.want) + } + } + default: + t.Fatal("emitResponse 没有写回执(不变量 I5 被破坏)") + } +} + +// TestEmitResponseOmitsUsageWhenNothingRecorded 是上一条的边界: +// 一次 LLM 都没跑过时不要凭空造 usage —— 否则消费方会把「没有数据」 +// 当成「用了 0 token」,与本项目既有的 CacheHitRate ok=false 口径一致。 +func TestEmitResponseOmitsUsageWhenNothingRecorded(t *testing.T) { + a := newEmitAgent() + ch := make(chan *agentIO.OutputEvent, 1) + evt := &agentIO.InputEvent{ + RequestID: "req-2", Source: "http", OutputChannel: "http", ResponseCh: ch, + } + a.emitResponse(evt, "你好", agentAPI.TokenUsage{}) + + select { + case out := <-ch: + if u, ok := out.Payload["usage"]; ok && u != nil { + t.Errorf("没有任何 LLM 调用时报了 usage=%v;应当缺省,让消费方显示「—」", u) + } + default: + t.Fatal("没有写回执") + } +} + +// TestEmitResponsePluginUsageStillHonored 保住既有契约: +// 插件(StageBeforeOutput)显式设置的 TokenUsage 必须仍然生效 —— +// 这是本次改动前唯一能拿到 usage 的路径,不能因为补了 agent 侧就丢掉。 +func TestEmitResponsePluginUsageStillHonored(t *testing.T) { + a := newEmitAgent() + a.stageHost.RegisterStage(sdk.StageBeforeOutput, func(ctx *sdk.StageContext) error { + ctx.TokenUsage = map[string]int{"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10} + return nil + }) + + ch := make(chan *agentIO.OutputEvent, 1) + evt := &agentIO.InputEvent{RequestID: "req-3", Source: "http", OutputChannel: "http", ResponseCh: ch} + a.emitResponse(evt, "hi", agentAPI.TokenUsage{}) + + select { + case out := <-ch: + raw, ok := out.Payload["usage"] + if !ok || raw == nil { + t.Fatalf("插件显式设置的 usage 丢了;payload=%v", out.Payload) + } + m := asUsageMap(t, raw) + if got, _ := m["prompt_tokens"].(int); got != 7 { + t.Errorf("prompt_tokens=%v,期望插件设置的 7", m["prompt_tokens"]) + } + default: + t.Fatal("没有写回执") + } +} diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index 48428d15..057173af 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -360,7 +360,19 @@ func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) { } } -func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) { +// emitResponse 发送终态回执(不变量 I5:每任务恰一次)。 +// +// turnUsage 是本轮任务内全部 LLM 调用的用量合计,由调用方从 TaskFrame 传入。 +// +// 为何要它:同步注入方(WebUI 的 /v1/chat/completions、cli.sock、 +// clawhubadapter)的回包就出自这里,而此前这里**只从插件侧**读 TokenUsage +// (下面新建的 stageCtx),agent 自己算出来的用量从未传进去 ⇒ 回包没有 usage。 +// 实测过:部署实例的 /v1/chat/completions 回包里 "usage" 字段根本不存在。 +// +// 口径:按 OpenAI 语义报**本次请求**(turnUsage),不是会话累计 —— +// 会话累计在链事件里的 usage_session(见 task.go 的记账处),两者不可互换, +// 否则第二次请求会报出翻倍的数字。 +func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string, turnUsage agentAPI.TokenUsage) { // 通道一律从**输入事件**推导(内核不持有"当前通道")。 ch := outputChannelOf(evt) stageCtx := &sdk.StageContext{ @@ -378,8 +390,20 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) { if stageCtx.ReasoningContent != "" { payload["reasoning_content"] = stageCtx.ReasoningContent } - if stageCtx.TokenUsage != nil { - payload["usage"] = stageCtx.TokenUsage + // 用量优先级:插件显式设置的赢(那是既有契约,也是此前唯一能拿到 usage 的路径), + // 插件没设则回落到 agent 自己的本轮记账。 + // + // ⚠️ 两条路都必须归一成 map[string]interface{}:消费方 handler_openai.go 用 + // `.(map[string]interface{})` 取值,而 Go 的 map 类型断言是**精确匹配** —— + // 插件给的 map[string]int 会断言失败,usage 在回包里静默变 nil,两边都不报错。 + // (这正是链事件里那个 "发了但没人收到" 的同款坑,已实证。) + // SDK 侧 TokenUsage 的类型仍是 map[string]int(公开接口不动), + // 转换只发生在这一个出口。 + switch { + case stageCtx.TokenUsage != nil: + payload["usage"] = usageMapFromInts(stageCtx.TokenUsage) + case !turnUsage.IsZero(): + payload["usage"] = turnUsageMap(turnUsage) } if evt.ResponseCh != nil { // 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5(每任务恰一次 diff --git a/internal/agent/core/graphmedia.go b/internal/agent/core/graphmedia.go index 81eb0b95..f3c0f3a5 100644 --- a/internal/agent/core/graphmedia.go +++ b/internal/agent/core/graphmedia.go @@ -3,7 +3,6 @@ package core import ( "fmt" "log" - "strconv" "strings" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" @@ -45,10 +44,14 @@ func (a *Agent) migrateLegacyGraphMedia() { } } -// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。 +// attachBlocksToSentenceBlock 把一组 digest 变成 L3 一等块并挂到**原句块**上。 // seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。 -func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock, scene string) int { - if a.mediaStore == nil || a.memory == nil || sentenceID == 0 { +// ★ sentenceBlockID 是**原句块 ID**(原为 sentences 表行号)。 +// +// 句子的承载者已从 sentences 表迁移到 blk_src_, +// 媒体边因此改挂「原句块 --contains--> 媒体块」。 +func (a *Agent) attachBlocksToSentenceBlock(sentenceBlockID string, digests []string, seed map[string]memory.MemoryBlock, scene string) int { + if a.mediaStore == nil || a.memory == nil || sentenceBlockID == "" { return 0 } bound := 0 @@ -73,7 +76,7 @@ func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err) continue } - if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil { + if err := a.memory.AddMemoryBlockEdge("block", sentenceBlockID, "block", b.ID, "contains"); err != nil { log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err) continue } @@ -151,20 +154,25 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string continue } sid := sentenceIDs[t.SentenceText] - if sid == 0 { + if sid == "" { continue } - blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest, t.Scene) + blocks += a.attachBlocksToSentenceBlock(sid, t.MediaDigests, byDigest, t.Scene) } return ec, rc, blocks, nil } // RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。 -func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) { +// RecallBlocksForSentence 按**原句块 ID**取回它承载的媒体块。 +// +// ★ 签名从 int64 改为 string:挂载点已从「sentences 表行号」 +// +// 改成「原句块 ID」(sentences 表退场后行号不存在)。 +func (a *Agent) RecallBlocksForSentence(sentenceBlockID string) ([]memory.MemoryBlock, error) { if a.memory == nil { return nil, nil } - return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10)) + return a.memory.BlocksForNode("block", sentenceBlockID) } // resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。 @@ -195,18 +203,33 @@ func (a *Agent) resolveMediaDigests(digests []string) []string { // // 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block // 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。 -func sentenceIDsFromRelations(relations []memory.Relation) []int64 { +// sentenceIDsFromRelations 从关系里取出其原句的**块 ID**。 +// +// ★ 从 Relation.SentenceID(旧 sentences 表行号)改为按 SentenceText 现算 +// +// blk_src_。 +// +// 原因:Commit 块化后原句由块承载,sentences 表退场, +// 行号不再是稳定的挂载点;而 SentenceText 一直在 Relation 上。 +// +// 这条链(mediaContextForSentences / mediaContextForRelations) +// 完全靠这个 ID 找回媒体块,所以改错了 = 媒体上下文整体失效。 +func sentenceIDsFromRelations(relations []memory.Relation) []string { if len(relations) == 0 { return nil } - seen := make(map[int64]bool, len(relations)) - var out []int64 + seen := make(map[string]bool, len(relations)) + var out []string for _, r := range relations { - if r.SentenceID == 0 || seen[r.SentenceID] { + id := "" + if r.SentenceText != "" { + id = memory.SentenceBlockID(r.SentenceText) + } + if id == "" || seen[id] { continue } - seen[r.SentenceID] = true - out = append(out, r.SentenceID) + seen[id] = true + out = append(out, id) } return out } @@ -279,13 +302,13 @@ func (a *Agent) blockLabelsForDoc(d *document.Doc) string { // // 标签只含 MIME 与短 digest:图片按向量检索,标签的作用是告诉模型 // "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。 -func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { - if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 { +func (a *Agent) mediaContextForSentences(sentenceBlockIDs []string) string { + if a.mediaStore == nil || a.memory == nil || len(sentenceBlockIDs) == 0 { return "" } var lines []string - for _, sid := range sentenceIDs { - blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) + for _, sid := range sentenceBlockIDs { + blocks, err := a.memory.BlocksForNode("block", sid) if err != nil || len(blocks) == 0 { continue } @@ -300,7 +323,7 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { } } if len(parts) > 0 { - lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";"))) + lines = append(lines, fmt.Sprintf("句子 %s 关联媒体:%s", shortID(sid), strings.Join(parts, ";"))) } } if len(lines) == 0 { @@ -308,3 +331,16 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string { } return strings.Join(lines, "\n") } + +// shortID 把块 ID 压成可读短码(blk_src_<24hex> → 前 8 位 hex)。 +// 媒体上下文是给模型看的,整串 hex 只会占 token 且不可读。 +func shortID(blockID string) string { + const pfx = "blk_src_" + if strings.HasPrefix(blockID, pfx) { + return blockID[len(pfx):] + } + if len(blockID) > 12 { + return blockID[:12] + } + return blockID +} diff --git a/internal/agent/core/graphmedia_test.go b/internal/agent/core/graphmedia_test.go index 9c126c6e..9925167f 100644 --- a/internal/agent/core/graphmedia_test.go +++ b/internal/agent/core/graphmedia_test.go @@ -24,9 +24,9 @@ import ( // 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃, // 因此这些测试也不存在任何按描述检索的断言。 -// attachBlockToSentence 提交一条句子并把媒体变成 L3 一等块。 +// attachBlockToSentenceBlock 提交一条句子并把媒体变成 L3 一等块。 // 必须走真实提交:边要求两端都是真实图节点。 -func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) { +func attachBlockToSentenceBlock(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (string, memory.MemoryBlock) { t.Helper() ids, _, _, err := g.CommitWithMedia([]memory.Triple{{ Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentenceText, @@ -34,16 +34,19 @@ func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sen if err != nil { t.Fatalf("CommitWithMedia: %v", err) } + // ★ 媒体的挂载点已从「sentences 表行号」改成「原句块 ID」 + // (CommitWithMedia 返回值由 map[string]int64 改为 map[string]string, + // 因为 sentences 表退场后行号不存在)。 sid := ids[sentenceText] - if sid == 0 { - t.Fatalf("拿不到句子 id: %q", sentenceText) + if sid == "" { + t.Fatalf("拿不到原句块 ID: %q", sentenceText) } it, err := ms.Stat(digest) if err != nil || it == nil { t.Fatalf("Stat(%s): %v", shortDigest(digest), err) } b := memory.MemoryBlock{ - ID: fmt.Sprintf("blk_test_%d_%s", sid, shortDigest(digest)), + ID: fmt.Sprintf("blk_test_%s_%s", shortDigest(sid), shortDigest(digest)), Modality: memory.BlockImage, PayloadDigest: it.Digest, MIME: it.MIME, @@ -56,7 +59,8 @@ func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sen if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { t.Fatalf("PutMemoryBlocks: %v", err) } - if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil { + // ★ 端点 kind 也从 "sentence" 改成 "block"(原句由块承载) + if err := g.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil { t.Fatalf("AddMemoryBlockEdge: %v", err) } return sid, b @@ -94,11 +98,19 @@ func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) { if err != nil { t.Fatal(err) } - if ec == 0 || rc == 0 { - t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc) + // ★ 旧表双写已停 ⇒ ec/rc 恒为 0(2026-10-04) + // + // 判据要验的是「写进了图库」,改用块侧信号。 + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) } - if ids[sentence] == 0 { - t.Fatalf("应返回句子 id,实际 %v", ids) + if len(blocks) < 3 { // 主体 + 宾语 + 原句块 + t.Fatalf("应写入 ≥3 个块(主体/宾语/原句),实际 %d", len(blocks)) + } + fmt.Printf(" 写入块 %d 个(旧表计数已停用 ec=%d rc=%d)\n", len(blocks), ec, rc) + if ids[sentence] == "" { + t.Fatalf("应返回原句块 ID,实际 %v", ids) } } @@ -114,9 +126,25 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) { if err != nil { t.Fatal(err) } - if ec != 4 || rc != 2 { - t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc) + // ★ 旧表双写已停(2026-10-04)⇒ ec/rc 恒为 0。 + // + // 它们数的是旧表 entities/relations 的行号,而那两张表不再增长。 + // Commit 的返回签名是 SDK 契约(9 处调用方),改签名代价大 —— + // 所以保留位置并置 0。 + // + // ★ 判据要验的是「Commit 写进去了」,改用**块侧**信号: + // 4 个实体名(张三/李四/编程/北京)+ 2 条关系边。 + if ec != 0 || rc != 0 { + t.Fatalf("旧表双写已停,ec/rc 应为 0,实际 ec=%d rc=%d", ec, rc) } + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + if len(blocks) < 4 { + t.Fatalf("Commit 应写入 ≥4 个实体块,实际 %d", len(blocks)) + } + fmt.Printf(" Commit 写入块 %d 个(旧表计数已停用 ec=%d rc=%d)\n", len(blocks), ec, rc) // 重复提交同一批:关系被唯一约束去重。 // @@ -125,15 +153,23 @@ func TestCommit_StillWorksAfterRefactor(t *testing.T) { // 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是 // 首次 ec=2 / 重复 ec=2,与 CommitWithMedia 重构无关。 // entitiesCreated 只用于日志,故此处记录现状而不改行为。 - ec2, rc2, err := g.Commit(triples, "s1", 0) + _, _, err = g.Commit(triples, "s1", 0) if err != nil { t.Fatal(err) } - if rc2 != 0 { - t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2) + // ★ 幂等的真正判据是「块数不增长」,不是计数返回值 —— + // 后者在旧表停写后恒为 0(2026-10-04)。 + // + // ★ 而且关系边**允许并存多条**(52e4596 移除了 UNIQUE), + // 所以「重复提交新建了关系边」不是缺陷,是设计。 + // 幂等体现在块侧:块 ID 是内容派生的,重写不产生新块。 + blocks2, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) } - if ec2 != 4 { - t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2) + if len(blocks2) != len(blocks) { + t.Errorf("重复提交不该新增块(块 ID 内容派生,幂等),%d → %d", + len(blocks), len(blocks2)) } } @@ -165,7 +201,7 @@ func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) { t.Fatal(err) } sid := ids[sentence] - if sid == 0 { + if sid == "" { t.Fatal("拿不到句子 id") } @@ -194,7 +230,8 @@ func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) { func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) { // digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。 a, g, _ := newGraphMediaAgent(t) - if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil, ""); n != 0 { + // ★ 空块 ID = 无效句柄(原来用 0 行号表示) + if n := a.attachBlocksToSentenceBlock("", []string{"deadbeefdead"}, nil, ""); n != 0 { t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n) } blocks, err := g.BlocksForNode("sentence", "42") @@ -208,10 +245,10 @@ func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) { func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) { a := &Agent{} - if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil, ""); n != 0 { + if n := a.attachBlocksToSentenceBlock("", []string{"aaaaaaaaaaaa"}, nil, ""); n != 0 { t.Fatalf("媒体关闭时应静默无操作,实际 %d", n) } - if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil { + if got, err := a.RecallBlocksForSentence(""); err != nil || got != nil { t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err) } } @@ -234,10 +271,10 @@ func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) { sid := ids["迁移测试句。"] byDigest := map[string]memory.MemoryBlock{digest: seedBlock} - if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest, ""); n != 1 { + if n := a.attachBlocksToSentenceBlock(sid, []string{digest}, byDigest, ""); n != 1 { t.Fatalf("应绑定 1 个块,实际 %d", n) } - blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) + blocks, err := g.BlocksForNode("block", sid) if err != nil { t.Fatal(err) } @@ -284,30 +321,38 @@ func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) { if err != nil { t.Fatal(err) } - if ec != 2 || rc != 1 { - t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc) + // ★ 旧表停写后 ec/rc 恒 0,改验块侧(2026-10-04) + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) } + if len(blocks) < 2 { + t.Fatalf("期望 ≥2 个块(主体 + 宾语),实际 %d", len(blocks)) + } + _ = ec + _ = rc } func TestMediaContextForSentences(t *testing.T) { a, g, ms := newGraphMediaAgent(t) digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"}) - sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest) + sid, _ := attachBlockToSentenceBlock(t, g, ms, "一张紫蓝红三色带图。", digest) - out := a.mediaContextForSentences([]int64{sid, sid + 100}) + out := a.mediaContextForSentences([]string{sid, sid + "-extra"}) if out == "" { t.Fatal("应产出媒体说明") } - if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, shortDigest(digest)) { - t.Fatalf("说明内容不对: %q", out) + // 说明里的句子标识是 shortID(块ID) = 去掉 blk_src_ 前缀的 hex + if !contains(out, fmt.Sprintf("句子 %s", shortID(sid))) || !contains(out, shortDigest(digest)) { + t.Fatalf("说明内容不对: %q(期望含 shortID=%s)", out, shortID(sid)) } // 说明只含 MIME 与短 digest,不含任何生成的描述 if contains(out, "紫蓝红") { t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out) } // 无引用的句子不该出现 - if contains(out, fmt.Sprintf("句子 #%d", sid+100)) { + if contains(out, fmt.Sprintf("句子 %s", shortID(sid+"-extra"))) { t.Fatalf("无引用的句子不该出现: %q", out) } } @@ -320,10 +365,11 @@ func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) { if err != nil { t.Fatal(err) } - sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest) + _, _ = attachBlockToSentenceBlock(t, g, ms, "一张紫蓝红三色带图。", digest) // 命中的关系挂着该句子 → 应产出媒体说明 - out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}}) + // ★ 挂载点改成 SentenceText 现算块 ID(sentenceIDsFromRelations 的新契约) + out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceText: "一张紫蓝红三色带图。"}}) if out == "" { t.Fatal("关系挂着有媒体的句子,却没产出媒体说明——L3 检索接线断了") } @@ -332,7 +378,7 @@ func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) { } // 没挂媒体的关系不该产出噪声 - if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" { + if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceText: "库中不存在的句子。"}}); out != "" { t.Errorf("无媒体的句子不该产出说明: %q", out) } if out := a.mediaContextForRelations(nil); out != "" { @@ -358,7 +404,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { t.Fatal(err) } sid := sids[sentence] - if sid == 0 { + if sid == "" { t.Fatal("拿不到句子 id") } if err := graph.PutMemoryBlocks([]memory.MemoryBlock{{ @@ -367,7 +413,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) { }}); err != nil { t.Fatal(err) } - if err := graph.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", "blk_auto_1", "contains"); err != nil { + if err := graph.AddMemoryBlockEdge("block", sid, "block", "blk_auto_1", "contains"); err != nil { t.Fatal(err) } @@ -581,21 +627,37 @@ func TestMigrateLegacyMediaEntities(t *testing.T) { digest, _ := ms.Put([]byte("legacy-img"), media.Item{MIME: "image/png"}) sentence := "老数据里的三色带图 [image/png " + digest[:12] + "]" - // 直接构造旧的实体/关系形态(不走已删除的 marker 代码)。 - ids, _, _, err := g.CommitWithMedia([]memory.Triple{{ - Subject: "图片 " + digest[:12], - SubjectType: "Media", - Relation: "内容", - Object: "三色带的描述文本", - ObjectType: "Description", - SentenceText: sentence, - }}, "legacy", 0) + // ★★ 直接写旧表,不用 CommitWithMedia 造(2026-10-04) + // + // 旧表双写已停 ⇒ Commit 不再写 entities/relations, + // 于是这个迁移测试的**输入天然为空**, + // 「迁出 0 条」竟然通过 —— 那不是「迁移正确」,是「测不到」。 + // + // 迁移的输入就是旧表,所以测试也该直接构造旧表。 + // (SeedLegacyMediaEntity 明确标注为测试专用。) + mediaName := "图片 " + digest[:12] + mediaEntID, err := g.SeedLegacyMediaEntity(mediaName, "Media") if err != nil { t.Fatal(err) } - sid := ids[sentence] - if sid == 0 { - t.Fatal("拿不到句子 id") + // ★ 迁移靠旧 relations 找到「该把媒体块挂到哪句上」, + // 所以旧表里必须有 sentence 行 + 关联行(生产上它们还在, + // 停双写只是让它们不再**增长**)。 + sentRowID, err := g.SeedLegacySentenceRow(sentence) + if err != nil { + t.Fatal(err) + } + if err := g.LinkLegacyEntityToSentence(mediaEntID, sentRowID); err != nil { + t.Fatal(err) + } + sid := memory.SentenceBlockID(sentence) + if _, _, _, err := g.CommitWithMedia([]memory.Triple{{ + Subject: "内容源", + Relation: "描述", + Object: "三色带的描述文本", + SentenceText: sentence, + }}, "legacy", 0); err != nil { + t.Fatal(err) } blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) { @@ -619,18 +681,35 @@ func TestMigrateLegacyMediaEntities(t *testing.T) { t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities) } - // 旧媒体实体与描述关系必须消失 + // ★ 旧媒体实体与描述关系必须消失。 + // + // ★ 判据口径变了(2026-10-04):原来断言「Recall 结果里没有 Type=='Media'」。 + // 但 media → legacy-entity 块的迁移会给那块打上 semantic_type='Media' + // (迁移保留源实体的 type,这是对的),而 Recall 切块后返回的正是**块** —— + // 于是判据把「块」误判成「残留的旧实体」。 + // + // 判据的**意图**是「旧表 entities 里不该还有它」,所以直接查旧表: + // 与实现无关,也不受迁移是否保留 type 影响。 res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "") if err != nil { t.Fatal(err) } + // 块侧可以存在同名同类型的块 —— 那是迁移的**成果**,不是残留。 + // 但必须至少确认召回没把旧实体当块带出来(blockKey 为空 = 旧表行)。 for _, e := range res.Entities { - if e.Type == "Media" { - t.Fatalf("旧媒体实体仍存在: %+v", e) + if e.Type == "Media" && e.IsLegacyRow() { + t.Fatalf("旧媒体实体仍存在(旧表行): %+v", e) } } + legacyMedia, err := g.CountLegacyEntitiesByType("Media") + if err != nil { + t.Fatalf("查旧媒体实体: %v", err) + } + if legacyMedia != 0 { + t.Errorf("旧表里仍有 %d 个媒体实体", legacyMedia) + } // 块必须挂回原句子 - got, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) + got, err := g.BlocksForNode("block", sid) if err != nil { t.Fatal(err) } @@ -670,7 +749,8 @@ func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) { if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { t.Fatal(err) } - if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil { + // ★ 端点 kind 也从 "sentence" 改成 "block"(原句由块承载) + if err := g.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil { t.Fatal(err) } @@ -687,7 +767,7 @@ func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) { if _, err := g.CleanupOrphanedSentences(); err != nil { t.Fatal(err) } - blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) + blocks, err := g.BlocksForNode("block", sid) if err != nil { t.Fatal(err) } @@ -697,20 +777,29 @@ func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) { } func TestSentenceIDsFromRelations(t *testing.T) { - // 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳, - // 去重与去零都不能少:sentence_id=0 表示该关系没有关联句子。 + // 关系行不持有媒体,媒体挂在句子上。这个函数负责「关系→句子」这一跳。 + // + // ★ 输入契约已变(Commit 块化后): + // 从 Relation.SentenceID(sentences 表行号)改为按 SentenceText + // 现算 blk_src_。行号不再是稳定挂载点 —— sentences 表退场。 + // 去重与「无句子」的剔除仍然必须。 rels := []memory.Relation{ - {ID: 1, SentenceID: 5}, - {ID: 2, SentenceID: 0}, // 无句子 - {ID: 3, SentenceID: 5}, // 重复 - {ID: 4, SentenceID: 7}, + {ID: 1, SentenceText: "句子甲。"}, + {ID: 2}, // 无句子 + {ID: 3, SentenceText: "句子甲。"}, // 重复 + {ID: 4, SentenceText: "句子乙。"}, } + want0 := memory.SentenceBlockID("句子甲。") + want1 := memory.SentenceBlockID("句子乙。") got := sentenceIDsFromRelations(rels) if len(got) != 2 { - t.Fatalf("应得 2 个去重后的句子 id,实际 %v", got) + t.Fatalf("应得 2 个去重后的原句块 ID,实际 %v", got) } - if got[0] != 5 || got[1] != 7 { - t.Fatalf("句子 id 或顺序不对: %v", got) + if got[0] != want0 || got[1] != want1 { + t.Fatalf("原句块 ID 或顺序不对: %v(期望 [%s %s])", got, want0, want1) + } + if !strings.HasPrefix(got[0], "blk_src_") { + t.Errorf("应是原句块 ID(blk_src_ 前缀),实际 %q", got[0]) } if n := sentenceIDsFromRelations(nil); n != nil { t.Fatalf("空输入应返回 nil,实际 %v", n) diff --git a/internal/agent/core/inputunify_test.go b/internal/agent/core/inputunify_test.go index 49e40794..acc9bc20 100644 --- a/internal/agent/core/inputunify_test.go +++ b/internal/agent/core/inputunify_test.go @@ -2,7 +2,6 @@ package core import ( "path/filepath" - "strconv" "strings" "testing" @@ -261,7 +260,10 @@ func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) { if res.Relations[0].SentenceText != sentence { t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText) } - blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10)) + // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 + // (CommitWithMedia 返回值随之改为 map[string]string) + blocks, err := a.memory.BlocksForNode("block", + memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatal(err) } @@ -459,10 +461,16 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) { if err != nil { t.Fatalf("Recall: %v", err) } - if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 { + // ★ 断言改用**原句文本**(2026-10-04): + // SentenceID 是旧 sentences 表行号,退场后恒为 0 —— + // 而它是这里唯一与「有没有句子落点」有关的字段,于是恒红。 + if len(res.Relations) == 0 || res.Relations[0].SentenceText == "" { t.Fatal("没有句子落点 —— 媒体引用无从挂起") } - blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10)) + // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 + // (CommitWithMedia 返回值随之改为 map[string]string) + blocks, err := a.memory.BlocksForNode("block", + memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatalf("BlocksForNode: %v", err) } diff --git a/internal/agent/core/memorycommit_count_test.go b/internal/agent/core/memorycommit_count_test.go new file mode 100644 index 00000000..43dcdd2f --- /dev/null +++ b/internal/agent/core/memorycommit_count_test.go @@ -0,0 +1,168 @@ +package core + +import ( + "path/filepath" + "strconv" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +// TestMemoryCommitReportsRealCounts 钉住「memory_commit 如实报告写入量」。 +// +// ★ 缺陷来源(2026-10-05 隔离实例实测): +// +// 一次对话实际写入 7 块 9 边,工具输出却是 +// 「已写入 0 个实体和 0 条关系」 +// 「提交了 1 条三元组但全部被拒(未写入)」 +// +// ⇒ 判据用 newBlocks(对),文案用 ec/rc(错)—— +// 同一份代码里两套口径打架。 +// +// ★ 后果比「数字难看」严重:模型收到「写不进去」就放弃。 +// 实测模型原话:"the write is being rejected, so let me check +// whether the store itself is working" —— 它开始怀疑存储坏了, +// 而其实一切正常。 +// +// ★ 判定方式:真调一次工具,看回文案里有没有 0, +// +// 同时看库里块数是否真的涨了。两者必须一致。 +func TestMemoryCommitReportsRealCounts(t *testing.T) { + g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { g.Close() }) + + a := New(AgentConfig{ + ID: types.AgentID("memcommit"), + SystemPrompt: "助手", + Provider: &scriptProvider{}, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + Memory: g, + StageHost: NewStageHost(), + }) + + before, err := g.MemoryBlockCount() + if err != nil { + t.Fatal(err) + } + + out := a.executeMemoryTool(agentAPI.ToolCall{ + ID: "c1", Name: "memory_commit", + Arguments: map[string]interface{}{ + "triples": []interface{}{ + map[string]interface{}{ + "subject": "值班室门禁密码", + "relation": "等于", + "object": "7788", + }, + }, + }, + }, nil) + + after, err := g.MemoryBlockCount() + if err != nil { + t.Fatal(err) + } + + t.Logf("工具回: %q", out) + t.Logf("块数 %d → %d(Δ%d)", before, after, after-before) + + if after == before { + t.Fatalf("块数没涨(Δ0),无法验证计数文案;工具回 %q", out) + } + + // ① 写入成功时,文案不能说「被拒」。 + if strings.Contains(out, "被拒") || strings.Contains(out, "未写入") { + t.Errorf("实际写入了 %d 个块,工具却说没写入:%q —— 模型会据此放弃并怀疑存储坏了", + after-before, out) + } + // ② 文案里的块数必须等于实际增量。 + if !strings.Contains(out, strconv.Itoa(after-before)) { + t.Errorf("实际新增 %d 块,文案里应含该数字;实际 %q", after-before, out) + } + // ③ ★ 不能出现「0 个」—— 那正是旧表口径的症状。 + if strings.Contains(out, "0 个") { + t.Errorf("文案含「0 个」:%q —— 又在用恒为 0 的 ec/rc", out) + } +} + +// TestMemoryCommitRejectsStillSaysRejected 钉住「真失败时仍然报失败」。 +// +// ★ 与上一条成对:修「谎报成功」最常见的副作用是把失败也报成成功。 +// +// 这条保证 0 写入时仍然明说「被拒」。 +func TestMemoryCommitRejectsStillSaysRejected(t *testing.T) { + g, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { g.Close() }) + + a := New(AgentConfig{ + ID: types.AgentID("memcommit2"), + SystemPrompt: "助手", + Provider: &scriptProvider{}, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + Memory: g, + StageHost: NewStageHost(), + }) + + // 空主语 ⇒ 实体名校验必然拒绝 ⇒ 真的什么都没写。 + out := a.executeMemoryTool(agentAPI.ToolCall{ + ID: "c2", Name: "memory_commit", + Arguments: map[string]interface{}{ + "triples": []interface{}{ + map[string]interface{}{"subject": "", "relation": "等于", "object": "x"}, + }, + }, + }, nil) + + t.Logf("工具回: %q", out) + + n, err := g.MemoryBlockCount() + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("前提不成立:空主语不该写入任何块,实际 %d", n) + } + // ★ 断言语义(「没写进去」)而不是字面(「被拒」): + // + // 实测 0 写入有两条拦截路径 —— + // ① 工具参数解析层:回「没有有效的三元组」 + // ② 实体名校验层:回「提交了 N 条三元组但全部被拒(未写入)」 + // 两条都是如实的失败报告,措辞不同而已。 + // 钉死字面会让 ① 路径下这条判据假红。 + if !strings.Contains(out, "被拒") && + !strings.Contains(out, "未写入") && + !strings.Contains(out, "没有有效") { + t.Errorf("0 写入时必须明说没写进去(否则模型当成成功而不重试),实际 %q", out) + } + // ★ 尤其不能出现「已写入」这种成功措辞。 + if strings.Contains(out, "已写入") { + t.Errorf("0 写入却回「已写入」:%q", out) + } +} + +// itoa 免引入 strconv(判据里只用到一处)。 +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} diff --git a/internal/agent/core/memoryface.go b/internal/agent/core/memoryface.go index 022e0db5..9624e533 100644 --- a/internal/agent/core/memoryface.go +++ b/internal/agent/core/memoryface.go @@ -51,6 +51,16 @@ func (a *Agent) contextTokenBudget(b TokenBudget) int { type GraphMemory interface { // Recall 按关键词/种子实体召回(子的实现是两空间并集)。 Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error) + // RecallSorted 是可指定呈现顺序的召回(相关性 / 时间倒序)。 + // + // 刻意与 Recall 并存而不是改签名:Recall 有 6 个调用方(webui / cli / + // healthcheck / sdk / 记忆通道注入),全部依赖默认相关性语义。 + // ★ fingerprint 是**向量空间隔离键**(2026-10-04)。 + // 它不在 SDK 的公开契约里,只在 core 内部这个接口上 —— + // 加它是因为「兜底召回路径绕过向量空间隔离」是真回归 + // (判据 TestMemoryRecall_指纹不匹配的块被跳过)。 + RecallSorted(keywords []string, seedEntities []string, depth int, sessionFilter, fingerprint string, mode memory.SortMode) (*memory.RecallResult, error) + // Commit 写入三元组(子的实现只落自己的 temp 空间)。 Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error) } diff --git a/internal/agent/core/memorypass.go b/internal/agent/core/memorypass.go index 84a299c7..e64aed85 100644 --- a/internal/agent/core/memorypass.go +++ b/internal/agent/core/memorypass.go @@ -149,13 +149,89 @@ func (a *Agent) pruneByQuery(query string) int { if a.isLightKernel() { return 0 } - topK := a.maxContextSize - 1 + topK := a.contextTopK() + return a.context.PruneWithProtected(query, topK, a.docStore, a.effectiveProtectedCount()) +} + +// contextTopK 推导裁剪后应保留的**条数**。 +// +// ★ 为什么不能直接用 maxContextSize(2026-10-01 跑分实测出的根因): +// max_context_size 是**条数**、与窗口 token 毫无换算关系。实测 50k 窗口下 +// prompt 峰值达窗口 7 倍、每轮都在「超限→修剪→再超限」——容量 30 条对上 +// 实际占用毫无约束力。而只调 context_window 无效:改窗口不会改这30。 +// +// 正确做法:用已按窗口算好的 ContextTokens 预算**反推条数**。 +// 一个事件平均占多少 token 由实际数据决定,不用常量猜: +// 取本 agent 当前积累的实际平均(accumulatedTokens / 事件数), +// 至少给 1 个 token 的下限(极短事件不会让除零或把预算除成 0)。 +// +// max_context_size 仍作为**上限**保留:它是运维给的「活跃条数」硬约束, +// 预算算出来的条数超过它时以它为准(否则改了配置不生效会让人困惑)。 +func (a *Agent) contextTopK() int { + hardCap := a.maxContextSize - 1 // 运维配置的上限(历史语义:-1 因为要留 protected) + if hardCap < 1 { + hardCap = 1 + } + + budget := a.computeTokenBudget() + tokens := budget.ContextTokens + if tokens <= 0 { + return hardCap + } + + // 平均事件 token:优先用实际积累,没有积累时退回保守值。 + avg := 0 + if a.context != nil { + if n := a.context.Len(); n > 0 { + avg = a.accumulatedTokens() / n + } + } + if avg <= 0 { + avg = defaultAvgEventTokens + } + + topK := tokens / avg if topK < 1 { topK = 1 } - return a.context.Prune(query, topK, a.docStore) + if topK > hardCap { + topK = hardCap + } + // ★ 下限必须是 protectedCount+1,否则裁剪会**无效**:Prune 里 + // keepCount = topK - len(protected),若 topK <= protected 则 keep 为空、 + // 全部事件被归档,裁完一轮上下文还是超页(实测 topK 被算成 1 时)。 + // + // 什么时候会算成 1:单条事件就超过整个 ContextTokens 预算(实测 v4 的 + // 工具大回执型事件平均 48000 token,而预算只有 40000)。此时正确的做法 + // 不是「只留 1 条」(等于清空记忆),而是至少留够 protected 条 —— + // 宁可暂时超页,等 budget 或事件尺寸回到正常区间再裁。 + if min := a.effectiveProtectedCount() + 1; topK < min { + topK = min + } + if topK > hardCap { + topK = hardCap + } + return topK } +// protectedContextCount 返回当前保护条数(最近 N 条永不换出)。 +func (a *Agent) protectedContextCount() int { + if a == nil || a.context == nil { + return defaultProtectedCount + } + if n := a.context.protectedCount; n > 0 { + return n + } + return defaultProtectedCount +} + +// defaultAvgEventTokens 是「没有积累样本时」用于反推的事件平均 token。 +// +// 取值依据:一条运维叙事事件通常 1500-3000 token(含工具回灌), +// 保守取 2000 —— 偏大意味着预算算出的条数偏少、裁得更狠, +// 宁可多裁也不要让积累量重新涨过窗口。 +const defaultAvgEventTokens = 2000 + // ────────────────────────────────────────────── // 场面指纹:场景**涌现**的原料 // diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index 1a363e39..11eee909 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -78,7 +78,7 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err) } // 通道可能回报「未确认」(已提交但超时未拿到发送确认)——此时不能对模型 - // 谎报「已发送」,否则模型不会重试/核实(plan.md 11.1)。 + // 谎报「已发送」,否则模型不会重试/核实。 if m, ok := result.(map[string]interface{}); ok { if status, _ := m["status"].(string); status == "unconfirmed" || status == "queued" { note, _ := m["note"].(string) diff --git a/internal/agent/core/overflow.go b/internal/agent/core/overflow.go new file mode 100644 index 00000000..4a23d9e7 --- /dev/null +++ b/internal/agent/core/overflow.go @@ -0,0 +1,270 @@ +package core + +import ( + "errors" + "fmt" + "log" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// 上下文超页(context overflow)处理。 +// +// 背景(2026-10-01 记忆召回跑分 v4 实测):HA 的 prompt 是锯齿波,峰值达 +// 窗口的 7 倍,每轮都在「远超窗口 → 紧急修剪」。根因之一是超页的三条触发 +// 路径没有统一出口: +// +// ① 上游 context_full 错误 —— 完全没接,整轮 outcomeFailed(用户在报错里看到它) +// ② 本地积累超限 —— 只在轮首 checkContextFull,且根 agent no-op、一次性 +// ③ 子 agent contextfull —— 已在用 L4 上报,但只推信号不携带动作 +// +// 本文件把三条路径收敛到 L4 唯一入口(raiseKernelInterrupt),并把「裁剪」 +// 做成同步动作 —— 详见 docs/zh/context-overflow-l4-design.md。 + +// overflowRatio 是触发超页的本地预判水位:积累上下文超过窗口的这个比例就裁剪。 +// +// 为什么是 1.25 而不是 1.0:请求体本身被 buildMessages 按 targetUsage=0.8×窗口 +// 裁过(见 buildMessages),所以「请求装不下」在本地永不会发生;能观测到的 +// 超限信号是**积累上下文**(a.context 里全部事件)。1.25 留了一点余量,避免 +// 在水位线附近反复触发。 +const overflowRatio = 1.25 + +// overflowRecoverBudget 是「同一 TaskFrame 内超页恢复次数」的上限。 +// +// 为什么需要上限:裁剪的目标是让积累量回到窗口以内,一次通常就够;但若 +// topK 与窗口脱钩(max_context_size 是**条数**不是 token,见 pruneByQuery), +// 一次裁剪可能仍不达标。没有上限就会变成「裁剪 → 复原 → 又超页 → 再裁剪」的 +// 循环,每次都付一轮任务调度开销。 +// +// 超限时**不做静默重试**:保存现场并明确终止,把问题暴露给用户与状态面。 +// +// 声明为 var 而非 const:测试要能变异它做判据自证(变异后超预算用例必须变红), +// 将来若要按窗口大小动态给额度(窗口越大越值得多试几次),也不用改结构。 +var overflowRecoverBudget = 2 + +// accumulatedTokens 估算**未裁剪**的积累上下文 token 数。 +// +// ⚠️ 判据必须用积累量,不能用 f.Msgs:后者已被 buildMessages 按 +// targetUsage = 0.8×窗口 裁过,结构上封顶 80%,对任何 >0.8 的阈值都**永远 +// 不成立**(这个坑已由 resident.go:checkContextFull 的注释记录过一次, +// 这里抽成函数是为了让两处共用同一口径,避免再次漂移)。 +func (a *Agent) accumulatedTokens() int { + if a == nil || a.context == nil { + return 0 + } + acc := 0 + for _, e := range a.context.Recent(0) { + acc += eventTokens(e) + } + return acc +} + +// eventTokens 估算单条上下文事件的 token 占用。 +// +// ★ ToolResults 必须计入 —— 这是实测踩过的坑:v4 与 T10 的 prompt 峰值 +// (17 万~40 万 token)几乎全部来自工具回灌,而 ContextEvent 里工具输出是 +// **独立字段**(ToolResults []ToolResultItem),不算它的话 accumulatedTokens +// 会返回 0,超页判据永远不触发(诊断日志 ratio=0.00/ events=1 就是这么来的)。 +// +// resident.go 的 checkContextFull 有同样缺陷,但它只对驻留子生效、一直没暴露; +// 现在两处共用本函数,口径不会再漂移。 +func eventTokens(e ContextEvent) int { + n := EstimateTokens(e.Input) + EstimateTokens(e.Response) + for _, tr := range e.ToolResults { + n += EstimateTokens(tr.Output) + } + return n +} + +// contextWindowTokens 取本 agent 的窗口上限(token)。 +func (a *Agent) contextWindowTokens() int { + if a != nil && a.provider != nil { + if max := a.provider.MaxContextTokens(); max > 0 { + return max + } + } + return defaultMaxContextTokens +} + +// overflowRatioNow 返回「积累量 / 窗口」。窗口拿不到时返回 0(表示不判定)。 +func (a *Agent) overflowRatioNow() float64 { + max := a.contextWindowTokens() + if max <= 0 { + return 0 + } + return float64(a.accumulatedTokens()) / float64(max) +} + +// maybeHandleContextOverflow 是本地预判的入口:**每次发 LLM 请求之前**调用, +// 覆盖轮内 tool 回环(现状只在轮首检查,轮内撑爆时才发现,浪费一整轮工具调用)。 +// +// 返回 true 表示已处理(本帧应当重跑),调用方需要重建 f.Msgs —— 因为裁剪 +// 之后挂起帧里那份超限请求不能复用(见设计文档约束三)。 +// +// 幂等:未超页时零开销(一次 token 估算 + 一次比较),可安全地在热路径调用。 +func (a *Agent) maybeHandleContextOverflow(f *TaskFrame, query string) (handled bool, pruned int) { + // 诊断留痕:超页判据在**每次 LLM 请求**都会走,而生产上从未见它触发过。 + // 没有这行日志时,「没触发」和「没执行」无法区分(2026-10-01 T10 排查 + // 花了 10 轮工具调用就卡在这里)。只记比值与判定,不记内容。 + light := a != nil && a.isLightKernel() + noCtx := a == nil || a.context == nil + var ratio float64 + if !noCtx { + ratio = a.overflowRatioNow() + } + if a != nil { + a.overflowStat.Lock() + checked := a.overflowStat.Checked + a.overflowStat.Checked++ + a.overflowStat.Unlock() + if checked%20 == 0 { + log.Printf("[agent] overflow check #%d: ratio=%.2f window=%d events=%d "+ + "(threshold=%.2f lightKernel=%v noContext=%v)", + checked+1, ratio, a.contextWindowTokens(), a.contextLenSafe(), + overflowRatio, light, noCtx) + } + } + if noCtx || light { + // 轻量内核(驻留子)不做按相关度裁剪,见 pruneByQuery 的同款理由。 + return false, 0 + } + if ratio < overflowRatio { + return false, 0 + } + // 恢复预算:同一帧内裁剪仍裁不下 ⇒ 说明 topK 与窗口脱钩(见 pruneByQuery), + // 再裁一次也是同样结果。超预算后**明确终止**,不静默重试。 + if f != nil { + if f.OverflowRecover >= overflowRecoverBudget { + log.Printf("[agent] context overflow: recover budget exhausted (%d/%d), "+ + "terminating this frame instead of looping", + f.OverflowRecover, overflowRecoverBudget) + a.markOverflowAborted(ratio, a.context.Len()) + f.Err = fmt.Errorf("上下文超限:已裁剪 %d 次仍超出窗口,"+ + "本轮终止(请检查 core.agent.max_context_size 与窗口是否匹配)", + f.OverflowRecover) + f.Terminal = terminalError + return true, 0 // 终止也是「已处理」:调用方不要再重跑 + } + f.OverflowRecover++ + } + return true, a.handleContextOverflow(query, ratio) +} + +// handleContextOverflow 执行「裁剪 + L4 打断」。 +// +// 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由: +// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 docStore), +// 把它放进 L4 任务里做只会多付一次完整任务调度开销; +// - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。 +// +// 出错处理(用户口径:保存退出,不重试):裁剪 0 条说明「本来装得下」 +// 却报超页 —— 这是判定逻辑的 bug,不是可恢复状态。此时保存现场并明确终止, +// 绝不再次触发中断。 +func (a *Agent) handleContextOverflow(query string, ratio float64) int { + // 单条事件本身就超预算 ⇒ 裁剪在任何保护数下都无解(实测 T10c: + // 事件 40026 token vs 预算 10667)。此时直接终止并报真实原因, + // 而不是裁剪两轮后 Budget 耗尽 —— 后者会丢失已经裁掉的记忆。 + if !a.singleEventFitsBudget() { + avg := a.accumulatedTokens() / a.context.Len() + log.Printf("[agent] context overflow: single event (%d tok) exceeds budget (%d tok) — "+ + "pruning cannot help, terminating", avg, a.computeTokenBudget().ContextTokens) + a.markOverflowAborted(ratio, a.context.Len()) + return 0 + } + + before := a.context.Len() + beforeTokens := a.accumulatedTokens() + // pruned 是**写进 docStore 的条数**(Prune 内部计数),不是「少了几条」。 + // 判据必须看上下文是否真的变小了 —— 因为 docStore == nil 时(轻量内核、 + // 文档记忆未初始化)pruned 恒为 0,但裁剪其实照常发生了。用 pruned 判会 + // 把正常裁剪误判成 bug 而终止。 + pruned := a.pruneByQuery(query) + after := a.context.Len() + afterTokens := a.accumulatedTokens() + + if after >= before || afterTokens >= beforeTokens { + // 超页了却裁不动 ⇒ 判定逻辑有问题(或 topK ≥ 实际条数)。 + // 按用户口径:保存现场 + 明确终止,**不重试不触发中断**。 + // 为什么不再试一次:L4 遇 L4 不能抢占(canPreempt 用严格大于), + // 第二个 L4 只会排队等,永远等不到能执行的时机。 + log.Printf("[agent] context overflow aborted: ratio=%.2f no reduction "+ + "(%d→%d events, %d→%d tokens) — 判定逻辑可能有 bug,保存现场并终止本轮,不重试", + ratio, before, after, beforeTokens, afterTokens) + a.markOverflowAborted(ratio, before) + return 0 + } + pruned = before - after + log.Printf("[agent] context overflow: ratio=%.2f pruned=%d (%d→%d events), raising L4", + ratio, pruned, before, after) + a.raiseContextOverflow(pruned) + return pruned +} + +// markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。 +func (a *Agent) markOverflowAborted(ratio float64, events int) { + a.overflowStat.Lock() + a.overflowStat.Aborted++ + a.overflowStat.LastRatio = ratio + a.overflowStat.LastEvents = events + a.overflowStat.Unlock() +} + +// raiseContextOverflow 通过 **L4 唯一入口**上报上下文超页。 +// +// 与 panic / selfip 并列,是 L4 的第三个来源。 +// +// 消息内容不是通知而是**必要的认知输入**:L4 任务按调度器设计拿不到被打断 +// 者的上下文(suspend 的 D1=B 注释明确「不把被打断任务的任何内容交给它」), +// 所以「已裁剪了什么、查不到不等于不存在」必须显式写进去。 +// +// 这条提示直接对应 v4 实测的一类失败:HA 对查不到的事实给出 +// 「库里根本没有 X,任何数字都是编的」——它不知道有内容被移走, +// 于是把「没检索到」推断成「不存在」。 +func (a *Agent) raiseContextOverflow(pruned int) { + a.overflowStat.Lock() + a.overflowStat.Triggered++ + a.overflowStat.LastPruned = pruned + a.overflowStat.Unlock() + + a.raiseKernelInterrupt("kernel/overflow", "kernel", overflowNotice(pruned)) +} + +// overflowNotice 构造超页中断消息。 +// +// 抽成纯函数是为了可测:这条消息的措辞不是装饰,它是 agent 唯一的认知输入 +//(L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句 +// 直接对应 v4 实测的一类真实失败 —— HA 对检索不到的事实回答 +// 「库里根本没有 X,任何数字都是编的」,因为它不知道有内容被移走了。 +func overflowNotice(pruned int) string { + return fmt.Sprintf("[内核] 上下文超页:已裁剪 %d 条低相关事件到文档记忆。"+ + "被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned) +} + +// handleUpstreamContextFull 处理上游返回的 ErrContextFull:与本地预判走**同一条** +// 路径(裁剪 → L4),避免两条路径行为不一致。 +// +// 必须在 provider fallback **之前**调用:fallback 会换 provider 重发同一个 +// 超限请求,换谁都一样超。 +// +// 返回 true 表示已裁剪,调用方应重试本轮;false 表示裁不出东西,应终止。 +func (a *Agent) handleUpstreamContextFull(llmErr error) bool { + var pe *agentAPI.ProviderError + if !errors.As(llmErr, &pe) || pe.Kind != agentAPI.ErrContextFull { + return false + } + pruned := a.handleContextOverflow("", 0) + a.overflowStat.Lock() + a.overflowStat.Upstream++ + a.overflowStat.Unlock() + return pruned > 0 +} + +// contextLenSafe 是 nil 安全的上下文条数(诊断日志用)。 +func (a *Agent) contextLenSafe() int { + if a == nil || a.context == nil { + return 0 + } + return a.context.Len() +} + +// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。 \ No newline at end of file diff --git a/internal/agent/core/overflow_test.go b/internal/agent/core/overflow_test.go new file mode 100644 index 00000000..0efd335c --- /dev/null +++ b/internal/agent/core/overflow_test.go @@ -0,0 +1,573 @@ +package core + +import ( + "fmt" + "strings" + "sync" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// ── 测试替身 ── + +// fakeOverflowProvider 只提供 MaxContextTokens(超页判据需要它), +// 其余 Provider 方法不参与本组测试。 +type fakeOverflowProvider struct { + agentAPI.Provider + window int +} + +func (p *fakeOverflowProvider) MaxContextTokens() int { return p.window } + +// newOverflowAgent 造一个够用���超页测试 Agent。 +func newOverflowAgent(window, maxContextSize int, events int, chunkChars int) *Agent { + a := &Agent{ + io: agentIO.NewIOManager(), + provider: &fakeOverflowProvider{window: window}, + maxContextSize: maxContextSize, + context: NewRelevanceContext("", nil), + } + // 固定向量,避免依赖稠密模型;也不进 docStore(nil ⇒ Prune 只重排不写盘)。 + for i := 0; i < events; i++ { + a.context.Append(ContextEvent{ + Input: fmt.Sprintf("第%d批输入", i) + strings.Repeat("x", chunkChars), + Response: fmt.Sprintf("第%d批回执", i) + strings.Repeat("y", chunkChars), + }) + } + return a +} + +// ── T1:积累量超限才触发裁剪 ── + +func TestOverflow_T1_积累超125触发(t *testing.T) { + // 窗口 1000 token;maxContextSize=2(条数),制造远超容量的积累。 + a := newOverflowAgent(2000, 2, 20, 400) // 单条827 < 预算~1067,但总量 16540 远超 + before := a.context.Len() + + ratio := a.overflowRatioNow() + if ratio < overflowRatio { + t.Fatalf("构造后应已超页,ratio=%.2f 阈值=%.2f", ratio, overflowRatio) + } + + handled, pruned := a.maybeHandleContextOverflow(nil, "退款导出") + if !handled { + t.Fatal("超限时 maybeHandleContextOverflow 应返回 handled=true") + } + if pruned <= 0 { + t.Fatal("应裁剪出至少一条") + } + if after := a.context.Len(); after >= before { + t.Fatalf("裁剪后条数未减少:%d → %d", before, after) + } +} + +// 未超页时必须零动作(这是热路径,每轮都调)。 +func TestOverflow_T1_未超页不动手(t *testing.T) { + // 窗口 10_000_000 token:任何合理积累都远低于 1.25× + a := newOverflowAgent(10_000_000, 2, 5, 100) + before := a.context.Len() + + handled, pruned := a.maybeHandleContextOverflow(nil, "查询") + if handled || pruned != 0 { + t.Fatalf("未超页不应处理:handled=%v pruned=%d", handled, pruned) + } + if after := a.context.Len(); after != before { + t.Fatalf("未超页时上下文不应变化:%d → %d", before, after) + } +} + +// 轻量内核(驻留子)走的是另一套策略,不做按相关度裁剪。 +func TestOverflow_T1_轻量内核跳过(t *testing.T) { + a := newOverflowAgent(1000, 2, 20, 400) + a.parentID = "parent-1" // 触发 isLightKernel + handled, pruned := a.maybeHandleContextOverflow(nil, "查询") + if handled || pruned != 0 { + t.Fatalf("轻量内核不应按相关度裁剪:handled=%v pruned=%d", handled, pruned) + } +} + +// ── T2:裁剪后积累量确实下降 ── + +func TestOverflow_T2_裁剪后累积下降(t *testing.T) { + a := newOverflowAgent(2000, 2, 20, 400) // 单条装得下,总量超页 + before := a.accumulatedTokens() + + a.maybeHandleContextOverflow(nil, "退款导出") + after := a.accumulatedTokens() + + if after >= before { + t.Fatalf("裁剪后积累 token 未下降:%d → %d", before, after) + } +} + +// ── T4(变异项):裁不出东西 ⇒ 终止且不触发 L4 ── +// +// 构造「装得下却超页」:maxContextSize 给得足够大,Prune 会返回 0。 +// 期望:记账 Aborted、不调 raiseKernelInterrupt(用 stat.Triggered 验证)。 +func TestOverflow_T4_裁不动则终止不中断(t *testing.T) { + // 「裁不动」的真正构造:**只有 1 条事件** —— 裁剪不可能减少任何东西 + // (Prune 里 protected 至少 1 条、keep 也至少要留 1 条)。 + // 旧构造(maxContextSize=9999 + 5 条)在自适应 protected 下反而能裁掉 3 条, + // 因为预算 534 token 装不下 10 条保护 ⇒ 自动收紧到 1 ⇒ topK=2。 + a := newOverflowAgent(1000, 9999, 1, 4000) + if a.overflowRatioNow() < overflowRatio { + t.Fatal("前置条件不成立:构造的会话应超页") + } + + a.maybeHandleContextOverflow(nil, "查询") + + a.overflowStat.Lock() + aborted, triggered := a.overflowStat.Aborted, a.overflowStat.Triggered + a.overflowStat.Unlock() + + if aborted != 1 { + t.Fatalf("应记一次 Aborted,实际 %d", aborted) + } + if triggered != 0 { + t.Fatalf("裁不出东西时**不应**触发 L4,实际 Triggered=%d", triggered) + } +} + +// ── T6:中断消息必须带「查不到不等于不存在」── + +func TestOverflow_T6_中断消息含认知提示(t *testing.T) { + got := overflowNotice(17) + if got == "" { + t.Fatal("超页通知不应为空") + } + if !strings.Contains(got, "查不到不等于不存在") { + t.Fatalf("中断消息缺少认知提示,agent 会把「没检索到」误判成「不存在」:%q", got) + } + if !strings.Contains(got, "文档记忆") { + t.Fatalf("中断消息应说明被裁内容去了哪里:%q", got) + } + // 来源标识在 raiseKernelInterrupt 的参数里("kernel/overflow"), + // 不在消息正文 —— 那部分留给可读性。这里只保证提示词自洽。 + if strings.Contains(got, "查不到等于存在") { + t.Fatalf("提示词语义写反了:%q", got) + } +} + +// ── 上游 ErrContextFull 走同一条裁剪路径 ── + +func TestOverflow_上游ErrContextFull走同路径(t *testing.T) { + a := newOverflowAgent(2000, 2, 20, 400) // 单条装得下,总量超页 + + // 累积不足以下次超页也没关系:上游已经明说装不下了 + if !a.handleUpstreamContextFull(&agentAPI.ProviderError{ + StatusCode: 400, + Kind: agentAPI.ErrContextFull, + Message: "context_length_exceeded", + }) { + t.Fatal("上游 ErrContextFull 应被接住并返回 true(可恢复)") + } + a.overflowStat.Lock() + upstream, triggered := a.overflowStat.Upstream, a.overflowStat.Triggered + a.overflowStat.Unlock() + if upstream != 1 { + t.Fatalf("应记一次上游超页,实际 %d", upstream) + } + if triggered != 1 { + t.Fatalf("上游超页也应经 L4 上报一次,实际 %d", triggered) + } +} + +// 非上下文类错误不得被误接。 +func TestOverflow_上游其他错误不误接(t *testing.T) { + a := newOverflowAgent(1000, 2, 20, 400) + for _, kind := range []agentAPI.ErrKind{agentAPI.ErrCredential, agentAPI.ErrTransient, agentAPI.ErrUnknown} { + if a.handleUpstreamContextFull(&agentAPI.ProviderError{ + StatusCode: 500, Kind: kind, Message: "boom", + }) { + t.Fatalf("kind=%v 不应被当作上下文超限接住", kind) + } + } +} + +// nil context / nil provider 不得 panic。 +func TestOverflow_零值不panic(t *testing.T) { + var a *Agent + if handled, _ := a.maybeHandleContextOverflow(nil, "q"); handled { + t.Fatal("nil agent 不应处理") + } + empty := &Agent{} + if handled, _ := empty.maybeHandleContextOverflow(nil, "q"); handled { + t.Fatal("零值 agent 不应处理") + } + _ = empty.overflowRatioNow() + _ = empty.accumulatedTokens() +} + +// ── T8(回归):并发调用安全 ── +// 超页判据在每轮 LLM 请求前调用,是热路径;AccumulatedTokens 会遍历事件, +// 必须与 Append 并发安全,否则数据竞争。 +func TestOverflow_并发安全(t *testing.T) { + a := newOverflowAgent(1000, 2, 50, 200) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < 50; j++ { + a.context.Append(ContextEvent{ + Input: fmt.Sprintf("并发%d-%d", i, j), + Response: strings.Repeat("z", 300), + }) + _ = a.overflowRatioNow() + } + }(i) + } + wg.Wait() +} +// ── T5(变异项):帧内恢复预算耗尽 ⇒ 终止而非死循环 ── +// +// 为什么要单独测:这个防护一度是**死代码**(TaskFrame.OverflowRecover 声明了 +// 但从没被读取)。没接线的防护等于没有防护 —— 变异测试正是抓这种。 +func TestOverflow_T5_恢复预算耗尽则终止(t *testing.T) { + // (4000,800) 实测:单条 1627 token 装得进预算 2134,但总量 ratio=8.13 超页 + a := newOverflowAgent(4000, 30, 20, 800) + f := &TaskFrame{} + + // 第一次超页:正常裁剪 + handled, pruned := a.maybeHandleContextOverflow(f, "退款导出") + if !handled || pruned <= 0 { + t.Fatalf("首次应正常裁剪:handled=%v pruned=%d", handled, pruned) + } + if f.OverflowRecover != 1 { + t.Fatalf("首次应记一次恢复,��际 %d", f.OverflowRecover) + } + + // 制造「裁不动」:每次检查前把上下文压到只剩 1 条大事件 + // (实测 ratio=8.03),裁剪无从下手。 + // 注:必须每次重新压 —— 第一次超页已把上下文裁小,不重压就不会再超页, + // 那样测的就不是「反复裁不动」而是「裁一次就好了」。 + collapse := func() { + for a.context.Len() > 1 { + a.context.events = a.context.events[:len(a.context.events)-1] + } + a.context.events[0] = &ContextEvent{ + Input: strings.Repeat("A", 8_000), Response: strings.Repeat("B", 8_000), + } + } + + // 预算内的第二次:仍会尝试 + collapse() + handled, _ = a.maybeHandleContextOverflow(f, "退款导出") + if !handled { + t.Fatal("预算内仍应处理") + } + if f.OverflowRecover != 2 { + t.Fatalf("预算内应累计到 2,实际 %d", f.OverflowRecover) + } + + // 第三次:超预算 ⇒ 终止,且写明原因 + collapse() + handled, _ = a.maybeHandleContextOverflow(f, "退款导出") + if !handled { + t.Fatal("超预算也应算「已处理」(终止),以免调用方再重跑") + } + if f.Terminal != terminalError { + t.Fatalf("超预算应置终止态,实际 terminal=%v", f.Terminal) + } + if f.Err == nil || !strings.Contains(f.Err.Error(), "max_context_size") { + t.Fatalf("终止原因应指向根因(max_context_size 与窗口不匹配):%v", f.Err) + } +} + +// 变异自证:把预算调到极大 ⇒ 上面那条必须变红(证明它真的在数)。 +func TestOverflow_T5_变异_预算极大则不终止(t *testing.T) { + backup := overflowRecoverBudget + overflowRecoverBudget = 1 << 30 + defer func() { overflowRecoverBudget = backup }() + + a := newOverflowAgent(1000, 2, 20, 400) + a.maxContextSize = 99999 // 裁不动 + f := &TaskFrame{} + for i := 0; i < 5; i++ { + for a.context.Len() > 1 { + a.context.events = a.context.events[:len(a.context.events)-1] + } + a.context.events[0] = &ContextEvent{ + Input: strings.Repeat("A", 8_000), Response: strings.Repeat("B", 8_000), + } + a.maybeHandleContextOverflow(f, "退款导出") + } + if f.Terminal == terminalError { + t.Fatalf("预算=2^30 时不应终止 ⇒ 预算判定可能没生效") + } +} + +// ── T9:topK 随窗口换算(不再与窗口脱钩)── +// +// 这是 v4 跑分归因的根因二:max_context_size 是条数、与窗口无关, +// 所以「只调 context_window 到 1M 就更好」不成立。 +func TestOverflow_T9_topK随窗口变化(t *testing.T) { + // 同样 20 条事件、同样的 max_context_size,只改窗口 ⇒ topK 必须跟着变 + build := func(window int) *Agent { + return newOverflowAgent(window, 30, 20, 400) // maxContextSize=30(默认) + } + + small := build(10_000) // 小窗口 + large := build(1_000_000) // 大窗口(用户关心的 1M 场景) + + topKSmall := small.contextTopK() + topKLarge := large.contextTopK() + + if topKLarge <= topKSmall { + t.Fatalf("窗口变大后 topK 应变大:%d → %d(说明仍与窗口脱钩)", topKSmall, topKLarge) + } + // 小窗口下预算只够留很少事件;大窗口下应更宽松 + if topKLarge < 10 { + t.Fatalf("1M 窗口下 topK=%d 过小,说明预算反推失效", topKLarge) + } +} + +// max_context_size 仍是硬上限(改了配置必须生效,否则运维会困惑)。 +func TestOverflow_T9_运维配置仍是上限(t *testing.T) { + a := newOverflowAgent(1_000_000, 5, 40, 200) // 窗口很大但硬上限 5 + if got := a.contextTopK(); got > 4 { + t.Fatalf("topK=%d 超过 max_context_size-1=4,运维配置失效", got) + } +} + +// 无积累样本时不得除零/返回 0。 +func TestOverflow_T9_空上下文topK至少为1(t *testing.T) { + a := newOverflowAgent(50_000, 30, 0, 200) + if got := a.contextTopK(); got < 1 { + t.Fatalf("空上下文时 topK=%d,至少应为 1", got) + } +} + +// 变异自证:把 contextTopK 退回「固定用 maxContextSize」, +// 上面那条「窗口变大 topK 变大」必须变红(证明该测试真能抓到脱钩)。 +func TestOverflow_T9_变异_固定topK则脱钩(t *testing.T) { + build := func(window int) *Agent { return newOverflowAgent(window, 30, 20, 400) } + + // 旧行为:topK 只看 max_context_size,与窗口无关 ⇒ 两个窗口得到同一个值。 + oldTopK := func(a *Agent) int { return a.maxContextSize - 1 } + if oldTopK(build(10_000)) != oldTopK(build(1_000_000)) { + t.Fatalf("前置不成立:旧行为本应与窗口无关") + } + + // 新实现必须与旧行为不同,否则「T9_topK随窗口变化」那条主测试是假绿。 + newSmall := build(10_000).contextTopK() + newLarge := build(1_000_000).contextTopK() + if newSmall == oldTopK(build(10_000)) && newLarge == oldTopK(build(1_000_000)) { + t.Fatalf("新实现与旧实现不可区分 ⇒ topK 实际仍与窗口脱钩,主测试是假绿") + } + if newLarge <= newSmall { + t.Fatalf("新实现下窗口变大 topK 未变大:%d → %d", newSmall, newLarge) + } +} + +// ── provider 未就绪时不得崩 ── +// +// 回归:contextTopK → computeTokenBudget → ComputeTokenBudgetTuned 里曾直接调 +// provider.MaxContextTokens(),provider 为 nil 时 SIGSEGV(实测由 +// TestMemoryPass_PruneAndRecallTogether 撞出来)。它同样会发生在 +// 「配置早于 provider 就绪」的启动序列上,不只是测试场景。 +func TestOverflow_零值Agent不崩(t *testing.T) { + // 完全零值 Agent(非 nil):无 provider、无 context、无配置 + empty := &Agent{} + if got := empty.contextTopK(); got < 1 { + t.Fatalf("零值 Agent 的 topK=%d,至少应为 1", got) + } + // 硬上限为 0 时也不能崩 + empty.maxContextSize = 0 + if got := empty.contextTopK(); got < 1 { + t.Fatalf("maxContextSize=0 时 topK=%d,至少应为 1", got) + } +} + +// ComputeTokenBudgetTuned 直接吃 nil provider(不经 Agent)也要安全。 +func TestComputeTokenBudgetTuned_nilProvider(t *testing.T) { + b := ComputeTokenBudgetTuned(nil, "", ContextTuning{}) + if b.MaxContext <= 0 { + t.Fatalf("nil provider 时应回退到默认窗口,实际 %d", b.MaxContext) + } + if b.ContextTokens < 0 { + t.Fatalf("ContextTokens 不应为负:%d", b.ContextTokens) + } +} + +// ── topK 下限:不能被算成 1(会让裁剪完全无效)── +// +// 实测缺陷:v4 的工具大回执型事件平均 48000 token,而 50k 窗口的 +// ContextTokens 预算只有 40000 ⇒ 预算反推的 topK = 0 ⇒ 钳到 1。 +// 而 Prune 里 keepCount = topK - protected = 1 - 10 < 0 ⇒ keep 为空 +// ⇒ **全部事件被归档**:裁完一轮上下文照样超页,超页处理空转。 +// +// 正确下限是 protectedCount+1:宁可暂时超页,等预算或事件尺寸回到正常区间。 +func TestOverflow_大事件时topK不塌到1(t *testing.T) { + // 窗口 50000 ⇒ ContextTokens 预算约 40000 + a := newOverflowAgent(50_000, 30, 30, 0) + // 造「单条就超预算」的事件:Response 48000 token + for i := 0; i < 30; i++ { + a.context.Append(ContextEvent{ + Input: strings.Repeat("A", 160_000), // 约 40000 token + Response: strings.Repeat("B", 192_000), // 约 48000 token + }) + } + + topK := a.contextTopK() + protected := a.effectiveProtectedCount() + if topK <= protected { + t.Fatalf("topK=%d <= protectedCount=%d ⇒ 裁剪会把全部事件归档(keep 为空),"+ + "超页处理空转", topK, protected) + } + if topK < protected+1 { + t.Fatalf("topK=%d 必须至少是 protectedCount+1=%d", topK, protected+1) + } +} + +// 真实形状验证:topK 下限生效时,Prune 确实还能裁掉东西。 +func TestOverflow_大事件时裁剪仍有效(t *testing.T) { + a := newOverflowAgent(50_000, 30, 30, 0) + for i := 0; i < 30; i++ { + a.context.Append(ContextEvent{ + Input: strings.Repeat("A", 8_000), + Response: strings.Repeat("B", 40_000), + }) + } + before := a.context.Len() + pruned := a.handleContextOverflow("退款导出", 10.0) + after := a.context.Len() + if pruned <= 0 && after >= before { + t.Fatalf("裁剪无效:%d → %d 条,pruned=%d", before, after, pruned) + } + // 裁剪后仍应留下 protected 条以上,不能清空 + if after < a.effectiveProtectedCount() { + t.Fatalf("裁剪后只剩 %d 条,少于有效保护数 %d(记忆被清空)", + after, a.effectiveProtectedCount()) + } +} + +// ── 工具回灌必须计入累积量 ── +// +// 实测缺陷(T10 诊断日志 ratio=0.00 / events=1 抓到):ContextEvent 里工具输出 +// 是**独立字段** ToolResults,accumulatedTokens 只算 Input+Response 时, +// 一个「用户说 10 字、模型回 20 字、调了 8 个工具各回 3 万 token」的轮次 +// 算出来是 30 token ⇒ 超页判据永远不触发。 +// +// 而 prompt 峰值的真正来源恰恰是工具回灌。 +func TestOverflow_工具回灌计入累积(t *testing.T) { + a := newOverflowAgent(50_000, 30, 0, 0) + a.context.Append(ContextEvent{ + Input: "查一下 order-gw 的端口", + Response: "好的,正在查", + ToolResults: []ToolResultItem{ + {Name: "files_read", Output: strings.Repeat("X", 120_000)}, // 约 30000 token + {Name: "cmd_run", Output: strings.Repeat("Y", 120_000)}, // 约 30000 token + }, + }) + + acc := a.accumulatedTokens() + if acc < 50_000 { + t.Fatalf("工具回灌未计入:accumulatedTokens=%d(两条工具各 3 万 token)", acc) + } + + // 且应能触发超页:2 轮同样的事件即远超窗口 + a.context.Append(ContextEvent{ + Input: "再查一次", + Response: "好", + ToolResults: a.context.Recent(1)[0].ToolResults, + }) + if ratio := a.overflowRatioNow(); ratio < overflowRatio { + t.Fatalf("两条工具回灌轮次应触发超页,实际 ratio=%.2f", ratio) + } +} + +// 反向判据:没有工具结果时,累积量应只等于 Input+Response。 +func TestOverflow_无工具时只算对话(t *testing.T) { + a := newOverflowAgent(50_000, 30, 0, 0) + a.context.Append(ContextEvent{Input: strings.Repeat("a", 400), Response: strings.Repeat("b", 400)}) + acc := a.accumulatedTokens() + // 800 字符 ≈ 400 token(EstimateTokens 是字符/2 量级),不应有额外放大 + if acc > 1000 { + t.Fatalf("无工具结果的短对话被算成 %d token(应≈400)", acc) + } + if acc < 100 { + t.Fatalf("无工具结果的短对话算成 %d token,明显漏算", acc) + } +} + +// ── B 方案:protected 按预算自适应(配置值是上限,不是固定值)── +// +// T10c 实测踩出的自相矛盾:protected=10 × 单条 5800 token = 58000 > +// ContextTokens 预算 40000 ⇒「钉住 10 条」本身就装不下 ⇒ 裁剪无解 ⇒ +// 每轮触发两次超页 → 预算耗尽 → 任务终止(prompt=0)。召回 44%→22%。 +// +// 现在:预算装不下时自动收紧,装得下时用满配置。 +func TestOverflow_protected按预算收紧(t *testing.T) { + // 小窗口 + 大事件 ⇒ 预算装不下 10 条 + small := newOverflowAgent(20_000, 30, 12, 20_000) + cfgCap := small.context.ProtectedCount() // 配置上限仍是 10 + eff := small.effectiveProtectedCount() + + if cfgCap != 10 { + t.Fatalf("配置上限应仍为 10,实际 %d", cfgCap) + } + if eff >= cfgCap { + t.Fatalf("预算装不下时应收紧,但 eff=%d >= cfg=%d", eff, cfgCap) + } + // 收紧后若仍装不下,必须能识别出「单条事件本身就超预算」这个真相, + // 让调用方走终止路径,而不是反复裁剪到预算耗尽。 + if eff*small.accumulatedTokens()/small.context.Len() > small.computeTokenBudget().ContextTokens { + if small.singleEventFitsBudget() { + t.Fatalf("收紧后装不下却报告单条装得下 ⇒ 判定自相矛盾") + } + } + + // 大窗口 ⇒ 用满配置值(这正是「1M 下这套调度器能更好」的机制) + big := newOverflowAgent(1_000_000, 30, 12, 200) + if got := big.effectiveProtectedCount(); got != big.context.ProtectedCount() { + t.Fatalf("大窗口下应��满配置值 %d,实际 %d", big.context.ProtectedCount(), got) + } +} + +// 自适应后裁剪必须真的有效(旧逻辑下裁不动)。 +func TestOverflow_自适应后裁剪有效(t *testing.T) { + // (4000,800):单条 1627 装得进预算 2134,总量 ratio=8.13 ⇒ 裁剪可帮上忙。 + // 对照组是「单条本身就超预算」——那种情况裁剪无解,走终止路径, + // 由TestOverflow_单条超预算则终止 覆盖。 + a := newOverflowAgent(4000, 30, 5, 800) + before := a.context.Len() + pruned := a.handleContextOverflow("退款导出", 1.3) + after := a.context.Len() + + if after >= before { + t.Fatalf("自适应后裁剪应有效:%d → %d(pruned=%d)", before, after, pruned) + } + if a.effectiveProtectedCount() >= before { + t.Fatalf("有效保护数 %d 不应 ≥ 裁剪前条数 %d", + a.effectiveProtectedCount(), before) + } +} + +// 单条事件本身就超预算 ⇒ 裁剪无解 ⇒ 直接终止并报真实原因 +// (而不是裁两轮后 Budget 耗尽,后者会丢失已经裁掉的记忆)。 +func TestOverflow_单条超预算则终止(t *testing.T) { + // (2000,800):单条 1627 > 预算 1067 ⇒ fits=false + a := newOverflowAgent(2000, 30, 5, 800) + if a.singleEventFitsBudget() { + t.Fatal("前置不成立:单条应装不下预算") + } + before := a.context.Len() + + pruned := a.handleContextOverflow("退款导出", 5.0) + + if pruned != 0 { + t.Fatalf("单条超预算时不应裁剪(裁了也装不下),实际 pruned=%d", pruned) + } + if after := a.context.Len(); after != before { + t.Fatalf("单条超预算时上下文不应被改动:%d → %d", before, after) + } + a.overflowStat.Lock() + aborted := a.overflowStat.Aborted + a.overflowStat.Unlock() + if aborted == 0 { + t.Fatal("应记一次 Aborted") + } +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index 289014d6..e4c7d869 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -372,7 +372,39 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag } } if ck.Usage != nil { - resp.TokenUsage = *ck.Usage + // ★ 合并而非覆盖。 + // + // 为何是缺陷而不是风格问题:上游会把用量**分帧**给。 + // Anthropic 就是典型 —— input/cache 在 message_start, + // output_tokens 在 message_delta。若后帧直接覆盖前帧, + // 最终只剩 completion,prompt 与缓存计数全归零。 + // 而这一切**不在任何地方报错**:只是数字变小了一个量级, + // 看上去像“这个模型的输入真的很短”。 + // + // 合并规则:新帧非零的字段赢,零值视为「本帧没携带」而不当“真的为 0”。 + // CacheReported 用或——它一旦为真就不该被后续帧抹掉。 + u := *ck.Usage + old := resp.TokenUsage + if u.Prompt == 0 { + u.Prompt = old.Prompt + } + if u.Completion == 0 { + u.Completion = old.Completion + } + if u.Total == 0 { + u.Total = old.Total + } + if u.CacheRead == 0 { + u.CacheRead = old.CacheRead + } + if u.CacheMiss == 0 { + u.CacheMiss = old.CacheMiss + } + if u.ReasoningTokens == 0 { + u.ReasoningTokens = old.ReasoningTokens + } + u.CacheReported = u.CacheReported || old.CacheReported + resp.TokenUsage = u } case <-ctx.Done(): diff --git a/internal/agent/core/status.go b/internal/agent/core/status.go index 912f0b31..12406574 100644 --- a/internal/agent/core/status.go +++ b/internal/agent/core/status.go @@ -281,10 +281,35 @@ func (a *Agent) GetKernelStatus() *KernelStatus { ks.ONNX = a.onnxStatus() ks.Scheduler = a.schedulerStatus() ks.Residents = residentStatuses(a.Residents()) + ks.Usage = a.usageStatus() return ks } +// usageStatus 把 agent 的累计用量账目转成对外状态视图。 +// +// 数据源就是 usageLedger(每次 LLM 调用后记账的那份),所以这里**不重算**: +// 重算等于给同一个事实造第二个来源,两边迟早会不一致。 +func (a *Agent) usageStatus() sdk.UsageStatus { + snap := a.usageLedger.snapshot() + out := sdk.UsageStatus{ + Calls: snap.Calls, + Prompt: snap.Prompt, + Completion: snap.Completion, + Total: snap.Total, + CacheRead: snap.CacheRead, + CacheMiss: snap.CacheMiss, + Reasoning: snap.Reasoning, + CacheReportedCalls: snap.CacheReportedCalls, + } + // ok=false(没有任何调用报过缓存)时**不带**这个字段, + // 让消费方显示「—」而不是把「不知道」画成 0%。 + if rate, ok := snap.CacheHitRate(); ok { + out.CacheHitRate = &rate + } + return out +} + // onnxStatus 汇总统一多模态向量空间(ONNX 模型)的启用状态。 // // 判据是 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」—— diff --git a/internal/agent/core/status_usage_test.go b/internal/agent/core/status_usage_test.go new file mode 100644 index 00000000..78aaaebf --- /dev/null +++ b/internal/agent/core/status_usage_test.go @@ -0,0 +1,83 @@ +package core + +import ( + "encoding/json" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// 用户实测「无法统计缓存命中与 token」时,除了回包 usage(已由 +// emitresponse_usage_test.go / emitresponse_e2e_test.go 覆盖),还有一处缺口: +// **运行态里看不到累计账目**。 +// +// 内核已有 GetKernelStatus()(/kernel、/api/v1/kernel、healthcheck_kernel 都走它), +// 里面有 Scheduler / ONNX / Residents 等运行时段落,唯独没有用量。 +// 于是「这个实例到底花了多少、缓存省了多少」只能靠翻日志。 + +// TestKernelStatusExposesUsage 断言用量进入内核状态快照。 +func TestKernelStatusExposesUsage(t *testing.T) { + a := &Agent{} + a.usageLedger.record(tk(1000, 200, 1200, 768, 232, 50, true)) + a.usageLedger.record(tk(500, 100, 600, 0, 0, 0, false)) + + ks := a.GetKernelStatus() + if ks == nil { + t.Fatal("GetKernelStatus 返回 nil") + } + if ks.Usage.Calls != 2 { + t.Errorf("usage.calls=%d,期望 2", ks.Usage.Calls) + } + if ks.Usage.Prompt != 1500 || ks.Usage.Total != 1800 { + t.Errorf("usage.prompt=%d total=%d,期望 1500 / 1800", ks.Usage.Prompt, ks.Usage.Total) + } + if ks.Usage.CacheRead != 768 { + t.Errorf("usage.cache_read=%d,期望 768", ks.Usage.CacheRead) + } + // 命中率:分母只算「上游报过缓存」的调用 ⇒ 768/(768+232)=0.768 + if ks.Usage.CacheHitRate == nil { + t.Fatal("有缓存数据时 cache_hit_rate 必须给出") + } + if got := *ks.Usage.CacheHitRate; got < 0.767 || got > 0.769 { + t.Errorf("cache_hit_rate=%v,期望 ≈0.768", got) + } +} + +// TestKernelStatusUsageOmitsHitRateWhenNothingReported 守住「无数据 ≠ 0%」。 +// +// 判据:上游一次都没报缓存时,命中率字段必须**缺席**(而非 0), +// 否则消费方会把「没开缓存」画成「命中率 0%」——那是拿假数据做优化决定。 +func TestKernelStatusUsageOmitsHitRateWhenNothingReported(t *testing.T) { + a := &Agent{} + a.usageLedger.record(tk(1000, 200, 1200, 0, 0, 0, false)) + + ks := a.GetKernelStatus() + if ks.Usage.CacheHitRate != nil { + t.Errorf("没有任何调用报过缓存时不该给命中率,实际 %v", *ks.Usage.CacheHitRate) + } + + // 序列化后也必须真的没有这个键(omitempty 语义)。 + b, err := json.Marshal(ks.Usage) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "cache_hit_rate") { + t.Errorf("JSON 里不该出现 cache_hit_rate:%s", b) + } +} + +// TestKernelStatusUsageZeroIsHonest 空实例不应凭空造数。 +func TestKernelStatusUsageZeroIsHonest(t *testing.T) { + a := &Agent{} + ks := a.GetKernelStatus() + if ks.Usage.Calls != 0 { + t.Errorf("空实例 calls=%d,期望 0", ks.Usage.Calls) + } + if ks.Usage.CacheHitRate != nil { + t.Error("空实例不该给命中率") + } +} + +// 保证测试里用到的类型确实来自 agentAPI(避免 tk() 帮忙函数类型漂移)。 +var _ = agentAPI.TokenUsage{} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 76a37bcb..5bee5b08 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -110,6 +110,13 @@ type TaskFrame struct { Turn int LastBatchReplyOnly bool + // OverflowRecover 是本帧内的超页恢复次数。 + // + // 为什么按**帧**而不是按 agent 计:恢复预算要防的是「一次裁剪没搞定 ⇒ + // 裁剪→复原→又超页→再裁剪」这个**单帧内**的循环;跨帧累计会让一个长会话 + // 在若干帧后突然失去超页处理能力(而那时恰恰最需要它)。 + OverflowRecover int + // 当前工具批 PendingTools []agentAPI.ToolCall ToolIdx int @@ -157,6 +164,14 @@ type TaskFrame struct { Response string Err error + // turnUsage 是本轮任务(一个 TaskFrame)内全部 LLM 调用的用量合计。 + // + // 为何要单独存:usageLedger 是**会话级**累计(跨用户请求), + // 而对外回包的 usage 按 OpenAI 语义应是**本次请求**。 + // 两者口径不同,不能互相顶替:把会话累计当本次报了, + // 第二次请求就会报出翻倍的数字。 + turnUsage agentAPI.TokenUsage + // ---- 任务层现场(原 processInput 的局部变量)---- // // 这些字段让帧覆盖 prepare → step… → finish 全生命周期:挂起发生在 run 段的 @@ -315,7 +330,7 @@ func (a *Agent) rebaseFramePrefix(f *TaskFrame) { } tail := append([]agentAPI.Message(nil), f.Msgs[f.PrefixLen:]...) - budget := ComputeTokenBudget(a.provider, a.systemPrompt) + budget := a.computeTokenBudget() memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens) sysPrompt := a.buildSystemPrompt(memContext, f.Input) prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget)) @@ -413,7 +428,8 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi a.injectSourceContext(stageCtx, evt) if a.runStage(sdk.StageOnInput, stageCtx) { - a.emitResponse(evt, *stageCtx.Response) + // 插件在 OnInput 短路:本轮一个 LLM 都没跑,用量为零是如实的。 + a.emitResponse(evt, *stageCtx.Response, agentAPI.TokenUsage{}) return nil, terminalStageShortCircuit } @@ -476,7 +492,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { if out == outcomeFailed { log.Printf("[agent] process %s error: %v", evt.Type, f.Err) resp := fmt.Sprintf("处理错误: %v", f.Err) - a.emitResponse(evt, resp) + a.emitResponse(evt, resp, f.turnUsage) a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: f.Input, Response: resp}) f.Terminal = terminalError return @@ -503,7 +519,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { a.bindEventMedia(&turnEvt, a.drainMediaDigests()) a.context.Append(turnEvt) - a.emitResponse(evt, f.Response) + a.emitResponse(evt, f.Response, f.turnUsage) if !f.StageCtx.NoMemory { a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed) @@ -536,7 +552,7 @@ func (a *Agent) step(f *TaskFrame) stepOutcome { // stepPrepare 构建本轮任务的初始帧。 func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome { - budget := ComputeTokenBudget(a.provider, a.systemPrompt) + budget := a.computeTokenBudget() memContext := a.buildTaskMemoryContext(f, f.Input, budget.MemoryTokens) sysPrompt := a.buildSystemPrompt(memContext, f.Input) @@ -602,6 +618,21 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome { // 取消(context.Canceled 且 agent 未退出)时**留在本 step 并 Turn++**——等价于 // 原实现的 `continue`:重新排空中断、补占位、重新请求。抢占挂起将在 M3 从这里接管。 func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { + // ★ 本地超页预判:每次发 LLM 请求**之前**检查积累上下文,覆盖轮内 tool 回环。 + // 轮内工具回灌会把积累量推到窗口数倍,而现状只在轮首检查 + // (checkContextFull,且根 agent no-op)—— 等轮首才发现时, + // 整轮工具调用的代价已经付掉了。 + // 必须在这里做而不能更早:f.Msgs 在上一行之前已经拼好, + // 而本次裁剪会让它过期。 + if handled, _ := a.maybeHandleContextOverflow(f, ""); handled { + if f.Terminal == terminalError { + return outcomeFailed // 恢复预算耗尽,f.Err 已写明 + } + // 已裁剪:f.Msgs 必须在 stepPrepare 重建,这里那份就是超限那份。 + f.Step = StepPrepare + return outcomeContinue + } + // zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验), // 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。 f.Msgs = dropContinuationPlaceholders(f.Msgs) @@ -624,6 +655,17 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { resp, llmErr := a.callLLMWithFallback(req, providers, f.OutputChannel) if llmErr != nil { + // ★ 上下文超页(上游 ErrContextFull)必须在 provider fallback **之前**处理: + // fallback 会换 provider 重发**同一个**超限请求,换谁都一样超。 + // 与本地预判(maybeHandleContextOverflow)走同一条裁剪路径。 + if a.handleUpstreamContextFull(llmErr) { + // 已裁剪。**必须退回 StepPrepare 重建 f.Msgs**: + // f.Msgs 只在 stepPrepare 里由 buildMessages 装配,stepLLM 不重建; + // 而挂起帧里那份就是刚刚超限的那份,复用它等于原样再发一次。 + f.Turn++ + f.Step = StepPrepare + return outcomeContinue + } if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil { if f.OutputChannel == channelConsolidation { f.Err = fmt.Errorf("interrupted by user input") @@ -650,10 +692,17 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { f.StageCtx.LLMText = resp.Content f.StageCtx.ReasoningContent = resp.ReasoningContent + // 用量同时给插件看(StageCtx)与记账(usageLedger)。 + // + // StageCtx.TokenUsage 是 map[string]int,放不了布尔, + // 所以「上游是否报了缓存」只在事件里给(见下面的 cache_reported)。 f.StageCtx.TokenUsage = map[string]int{ "prompt_tokens": resp.TokenUsage.Prompt, "completion_tokens": resp.TokenUsage.Completion, "total_tokens": resp.TokenUsage.Total, + "cache_read_tokens": resp.TokenUsage.CacheRead, + "cache_miss_tokens": resp.TokenUsage.CacheMiss, + "reasoning_tokens": resp.TokenUsage.ReasoningTokens, } f.StageCtx.ToolCalls = convertToolCalls(resp.ToolCalls) for i := range f.StageCtx.ToolCalls { @@ -675,13 +724,49 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { "phase": "intermediate", "turn": f.Turn, } + // 记账:先并入累计,再把「本次」与「会话累计」一起发出去。 + // + // 为何把累计也带上:要回答的是「这个会话花了多少、缓存省了多少」, + // 只有单次数字就得消费方自己一条条加。放在同一事件里,消费方无需另建状态。 + a.usageLedger.record(resp.TokenUsage) + // 本轮任务内的合计(一个 TaskFrame 可能多轮 LLM:工具回环)。 + // 它与 usageLedger 的口径不同(本次请求 vs 会话累计),两者都要留。 + f.turnUsage.Add(resp.TokenUsage) + if resp.TokenUsage.Total > 0 { - chainPayload["usage"] = map[string]int{ - "prompt": resp.TokenUsage.Prompt, - "completion": resp.TokenUsage.Completion, - "total": resp.TokenUsage.Total, + // ⚠️ 必须是 map[string]interface{}:WebUI 的 handler_openai.go + // 用 `Payload["usage"].(map[string]interface{})` 取它,而 Go 的 + // 类型断言对 map 是**精确匹配** —— 发 map[string]int 时断言为 false + // (已实证),于是在 /v1/chat/completions 回包里 usage 静默变 nil。 + // 这是“发了但没人收到”的典型形态:两边都不报错。 + chainPayload["usage"] = map[string]interface{}{ + "prompt": resp.TokenUsage.Prompt, + "completion": resp.TokenUsage.Completion, + "total": resp.TokenUsage.Total, + "cache_read": resp.TokenUsage.CacheRead, + "cache_miss": resp.TokenUsage.CacheMiss, + "cache_reported": resp.TokenUsage.CacheReported, + "reasoning_tokens": resp.TokenUsage.ReasoningTokens, } } + // 会话累计。命中率**不可算时不带该字段** —— + // 让消费方显示「—」而不是把「不知道」画成 0% 命中率。 + if sess := a.usageLedger.snapshot(); sess.Calls > 0 { + sessPayload := map[string]interface{}{ + "prompt": sess.Prompt, + "completion": sess.Completion, + "total": sess.Total, + "cache_read": sess.CacheRead, + "cache_miss": sess.CacheMiss, + "reasoning": sess.Reasoning, + "calls": sess.Calls, + "cache_reported_calls": sess.CacheReportedCalls, + } + if rate, ok := sess.CacheHitRate(); ok { + sessPayload["cache_hit_rate"] = rate + } + chainPayload["usage_session"] = sessPayload + } a.publishEvent(events.EventAgentLLMChain, chainPayload) if resp.ReasoningContent != "" { diff --git a/internal/agent/core/tokenbudget.go b/internal/agent/core/tokenbudget.go index e7855ec6..698c1d7e 100644 --- a/internal/agent/core/tokenbudget.go +++ b/internal/agent/core/tokenbudget.go @@ -27,33 +27,114 @@ func EstimateTokens(text string) int { return api.EstimateTokens(text) } // TruncateByTokens 截断字符串至不超过 maxTokens 估计值(转发到 api.TruncateByTokens)。 func TruncateByTokens(s string, maxTokens int) string { return api.TruncateByTokens(s, maxTokens) } -// ComputeTokenBudget 计算各部分的 token 预算。 +// ContextTuning 是上下文预算的**可调参数**(零值 = 历史默认)。 // -// maxTargetTokens 是**有效工作区间**的上限(不是模型窗口)。 +// 为何要有它:这些阈值原本全部硬编码在 ComputeTokenBudget 里, +// 配置面板上一个都没有 —— 用户无法为不同窗口(如 200k)调优, +// 也无法做「同窗口下不同策略」的对照实验。 // -// 为什么窗口 1M 却不能按 800K 干活:标称窗口 ≠ 有效窗口。接近满窗口时注意力 -// 明显涣散、成本与延迟也随 prompt 线性上升。该源(llmsproxy)实测 990,034 token -// 仍能返回,但 600K 才是它的最优工作区间 —— 超过这个量级,回答质量与延迟都不划算。 +// ★ 两个常量叠加会合成一个**没人看得见的分段函数**: // -// 因此把「窗口上限」(判断请求会不会被上游拒)与「工作区间」(分配记忆/历史预算) -// 分开:前者由 provider.MaxContextTokens() 给,后者封顶在这里。 -const maxTargetTokens = 600000 +// 窗口 ≤ 750000 → 工作面 = 窗口 × 0.8 (80%) +// 窗口 > 750000 → 工作面 = 600000 (利用率退化成 600000/窗口) +// +// 拐点位置由 0.8 与 600000 **共同**决定,改任一个都会挪动它。 +// 现在拐点由 MaxTargetTokens 显式表达,而 MaxTargetTokens < 0 表示不封顶 +// (利用率对所有窗口都真的等于 UtilizationPercent)。 +// +// 零值语义(升级安全):全部字段为 0 时,结果与硬编码时代**逐值相同**。 +// 详见 context_tuning_test.go 的 TestContextTuningZeroValueEqualsLegacy。 +type ContextTuning struct { + // UtilizationPercent 是目标窗口利用率(百分比,1-100)。 + // 0 ⇒ 用历史默认 80。 + UtilizationPercent int + // MaxTargetTokens 是**工作区间**的绝对上限(不是模型窗口)。 + // + // 0 ⇒ 用历史默认 600000 + // < 0 ⇒ 不封顶(工作面纯由 UtilizationPercent 跟随窗口) + // > 0 ⇒ 该值作为上限 + // + // 为何不是模型窗口本身:标称窗口 ≠ 有效窗口。接近满窗口时注意力涣散, + // 成本与延迟也随 prompt 线性上升。该源实测 990,034 token 仍能返回, + // 但 600K 才是它的最优工作区间。 + MaxTargetTokens int + // MemoryRatioPercent 是记忆上下文占「可用预算」的百分比(1-99)。 + // + // 0 ⇒ 用历史默认:available / 3(整数除法,逐值不变) + // 1-99 ⇒ available × pct / 100 + // + // ⚠️ 0 特意不换算成 33%:实测 available/3 与 available×33/100 **不等价** + //(1000 时是 333 vs 330),换算会让默认值不再等价。 + MemoryRatioPercent int + // ProtectedCount 是裁剪时**无条件保留**的最近事件条数。 + // + // 它决定「近处记忆」与「向量检索」的权:条数越大越不容易丢近处信息, + // 越小越依赖检索准确度。0 ⇒ 用历史默认 10。 + ProtectedCount int +} -// ComputeTokenBudget 计算各部分的 token 预算 -// utilizationRate 为目标窗口利用率(0.0-1.0),预留 1-utilizationRate 给 response -// 固定部分优先保障,剩余预算 1:2 分配给 memory context 和 context events +// 历史默认值(硬编码时代的常量,改这里等于改所有未配置实例的行为)。 +const ( + defaultUtilizationPercent = 80 + defaultMaxTargetTokens = 600000 + defaultProtectedCount = 10 + // legacyMemoryDivisor 是记忆预算的历史除数(available/3)。 + legacyMemoryDivisor = 3 +) + +// ComputeTokenBudget 计算各部分的 token 预算(零值 tuning = 历史行为)。 +// +// 保留这个签名是为了不改既有调用点;需要调参的调用方用 ComputeTokenBudgetTuned。 func ComputeTokenBudget(provider api.Provider, systemPromptBase string) TokenBudget { - maxCtx := provider.MaxContextTokens() + return ComputeTokenBudgetTuned(provider, systemPromptBase, ContextTuning{}) +} + +// computeTokenBudget 用本 agent 的配置调参计算上下文预算。 +// +// 存在理由:ComputeTokenBudget 是包级函数、拿不到 agent 的配置; +// 若调用点各自传参很容易漏(本仓刚发现 core.agent.max_context_size +// 被注册进面板却从未被读取 —— 一个不报错的死配置)。 +// 收成方法后,调用点只需 a.computeTokenBudget()。 +func (a *Agent) computeTokenBudget() TokenBudget { + return ComputeTokenBudgetTuned(a.provider, a.systemPrompt, a.ctxTuning) +} + +// ComputeTokenBudgetTuned 按配置计算各部分的 token 预算。 +// +// 固定部分(system prompt + tools + rules)优先保障,剩余按 MemoryRatioPercent +// 切给 memory context 与 context events。 +func ComputeTokenBudgetTuned(provider api.Provider, systemPromptBase string, t ContextTuning) TokenBudget { + // provider 可能为 nil(构造期、或 provider 尚未就绪/已被换掉)。 + // 之前直接调 provider.MaxContextTokens() 会 SIGSEGV —— 实测由contextTopK + // 在测试里撞出来,但它同样会在「配置早于 provider 就绪」的启动序列上发生。 + var maxCtx int + if provider != nil { + maxCtx = provider.MaxContextTokens() + } if maxCtx <= 0 { maxCtx = 32768 } - utilizationRate := 0.8 - targetUsage := int(float64(maxCtx) * utilizationRate) - // 窗口很大(如 1M)时不要把 80% 当成工作面:按 maxTargetTokens 封顶。 - if targetUsage > maxTargetTokens { - targetUsage = maxTargetTokens + utilPct := t.UtilizationPercent + if utilPct <= 0 { + utilPct = defaultUtilizationPercent } + if utilPct > 100 { + utilPct = 100 + } + // 整数运算:实测 maxCtx×80/100 与 int(float64(maxCtx)×0.8) + // 在 42 万个窗口值上逐值相同,且不受浮点表示误差影响。 + targetUsage := maxCtx * utilPct / 100 + + // 上限:0=历史默认(600000),负=不封顶。 + cap := t.MaxTargetTokens + if cap == 0 { + cap = defaultMaxTargetTokens + } + if cap > 0 && targetUsage > cap { + targetUsage = cap + } + reserved := maxCtx - targetUsage if reserved < 0 { reserved = 0 @@ -66,8 +147,15 @@ func ComputeTokenBudget(provider api.Provider, systemPromptBase string) TokenBud available = 0 } - // memory context 占 1/3,context events 占 2/3 - memTokens := available / 3 + memTokens := available / legacyMemoryDivisor + if t.MemoryRatioPercent > 0 { + pct := t.MemoryRatioPercent + if pct > 99 { + pct = 99 + } + memTokens = available * pct / 100 + } + // 余数归 context:保证 mem + ctx == available(不因取整丢 token)。 ctxTokens := available - memTokens return TokenBudget{ diff --git a/internal/agent/core/tokenusage_merge_test.go b/internal/agent/core/tokenusage_merge_test.go new file mode 100644 index 00000000..0717fab0 --- /dev/null +++ b/internal/agent/core/tokenusage_merge_test.go @@ -0,0 +1,131 @@ +package core + +import ( + "context" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// 用量**分帧到达**时必须合并,不能被后帧覆盖。 +// +// ── 为什么需要这条判据 ── +// +// accumulateStream 原先是 `resp.TokenUsage = *ck.Usage` —— 一句直接赋值。 +// 对「只在末帧报一次用量」的协议(OpenAI include_usage)它恰好正确, +// 所以长期看不出问题。但 Anthropic 是**分两帧**报的: +// +// message_start → input_tokens + cache_read_input_tokens + cache_creation +// message_delta → output_tokens +// +// 覆盖的后果:最终只剩 completion,prompt 与缓存计数全归零。 +// 而这一切**不报任何错**,只是数字小了一个量级 —— +// 看起来像「这个模型的输入真的很短」,于是缓存收益显得不存在。 +// +// 「恰好正确」不等于正确:它依赖上游把用量塞在同一帧里, +// 而这既不是协议保证,也不是我们的契约。 + +// TestTokenUsageMergesAcrossFrames 模拟 Anthropic 的两帧分离形态。 +func TestTokenUsageMergesAcrossFrames(t *testing.T) { + ch := make(chan agentAPI.StreamChunk, 4) + go func() { + // 首帧:只有输入侧与缓存(message_start 的形态) + ch <- agentAPI.StreamChunk{Usage: &agentAPI.TokenUsage{ + Prompt: 1896, + CacheRead: 768, + CacheMiss: 128, + CacheReported: true, + }} + // 次帧:只有输出侧(message_delta 的形态),其余字段为零值 + ch <- agentAPI.StreamChunk{ + Done: true, + FinishReason: "stop", + Usage: &agentAPI.TokenUsage{Completion: 250}, + } + close(ch) + }() + + resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096) + if err != nil { + t.Fatalf("accumulateStream 失败: %v", err) + } + u := resp.TokenUsage + + if u.Prompt != 1896 { + t.Errorf("Prompt = %d,期望 1896 —— 被后帧的零值覆盖了(后帧只带 completion)", + u.Prompt) + } + if u.CacheRead != 768 { + t.Errorf("CacheRead = %d,期望 768 —— 缓存命中数被后帧覆盖", u.CacheRead) + } + if u.CacheMiss != 128 { + t.Errorf("CacheMiss = %d,期望 128", u.CacheMiss) + } + if u.Completion != 250 { + t.Errorf("Completion = %d,期望 250(后帧的值必须生效)", u.Completion) + } + if !u.CacheReported { + t.Error("CacheReported = false —— 首帧已声明「上游报了缓存」," + + "后帧没提这件事不等于要撤销它") + } +} + +// TestTokenUsageMergeKeepsCacheReportedSticky 守住「一旦报过就一直是报过」。 +// +// 为什么单独一条:后续帧通常**不带**缓存字段(例如只有 output_tokens 的 +// message_delta)。若用「后帧为准」的实现,会把首帧刚立起来的 +// CacheReported 抹成 false ⇒ 界面从「命中 40%」退回「无数据」, +// 用户会以为监控坏了。 +func TestTokenUsageMergeKeepsCacheReportedSticky(t *testing.T) { + ch := make(chan agentAPI.StreamChunk, 3) + go func() { + ch <- agentAPI.StreamChunk{Usage: &agentAPI.TokenUsage{ + Prompt: 100, + CacheRead: 40, + CacheReported: true, + }} + ch <- agentAPI.StreamChunk{Usage: &agentAPI.TokenUsage{Completion: 10}} + ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"} + close(ch) + }() + + resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096) + if err != nil { + t.Fatalf("accumulateStream 失败: %v", err) + } + if !resp.TokenUsage.CacheReported { + t.Error("CacheReported 被后续不带缓存字段的帧抹掉了") + } + if resp.TokenUsage.CacheRead != 40 { + t.Errorf("CacheRead = %d,期望 40", resp.TokenUsage.CacheRead) + } +} + +// TestTokenUsageSingleFrameUnchanged 守住既有正确行为不被合并逻辑破坏: +// 单帧报全量(OpenAI include_usage 的形态)时结果必须与直接赋值一致。 +func TestTokenUsageSingleFrameUnchanged(t *testing.T) { + ch := make(chan agentAPI.StreamChunk, 2) + go func() { + ch <- agentAPI.StreamChunk{ + Done: true, + FinishReason: "stop", + Usage: &agentAPI.TokenUsage{ + Prompt: 1000, + Completion: 50, + Total: 1050, + CacheRead: 768, + CacheReported: true, + }, + } + close(ch) + }() + + resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096) + if err != nil { + t.Fatalf("accumulateStream 失败: %v", err) + } + u := resp.TokenUsage + if u.Prompt != 1000 || u.Completion != 50 || u.Total != 1050 || u.CacheRead != 768 { + t.Errorf("单帧全量用量被改动: %+v(期望 prompt=1000 completion=50 total=1050 cache_read=768)", u) + } +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index d83b9678..2ed0b3e0 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -216,7 +216,32 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str if len(keywords) == 1 { keywords = memory.ExtractKeywords(query) } - result, err := g.Recall(keywords, nil, int(depth), "") + // 排序模式:默认相关性;显式要"最近/最新"时用 recent。 + // + // ★ 为什么要分两种(实测 v4 跑分):overwrite 组考的是"新值覆盖旧值", + // 相关性排序下新旧同名实体的命中层级完全相同,只能靠时间分胜负; + // 而 casual 组问"某个服务端口是多少",要的是实词精确命中。 + // 用一个排序同时服务这两类问题,必然有一边错。 + sortMode := memory.ParseSortMode(fmt.Sprint(tc.Arguments["sort"])) + + // 块向量召回优先。 + // + // ★ 为什么要有它(这是这条路径缺失的直接后果):memory_blocks 是 + // 带 vector+fingerprint 的图节点载体,但此前**没有任何召回读它** —— + // BlocksForNode 只能按端点反查,得先知道 nodeID。于是「问一个具体 + // 问题」只能退到 entities 的 jieba+LIKE,实测跨维度定位 0/5 + //(问「第181批的值班手册是第几版」完全答不出)。 + // + // 混合而非替换:端口号(8328)、分机号(4324)这类纯数字串向量天然弱, + // 而它们恰是本项目最常问的。两条路都跑,块向量在前,符号路兜底。 + if blockOut := a.recallByBlocks(query); blockOut != "" { + return blockOut + } + + // ★ fingerprint 必须传:这是 recallByBlocks 失败后的兜底路, + // 不传就等于让旧向量空间的块混进结果 + // (判据 TestMemoryRecall_指纹不匹配的块被跳过)。 + result, err := g.RecallSorted(keywords, nil, int(depth), "", a.currentFingerprint(), sortMode) if err != nil { return fmt.Sprintf("记忆检索失败: %v", err) } @@ -231,9 +256,21 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str a.indexer.MarkRecalled(names...) } var parts []string - parts = append(parts, fmt.Sprintf("找到 %d 个相关实体:", len(result.Entities))) + parts = append(parts, fmt.Sprintf("找到 %d 个相关实体(按%s排序):", len(result.Entities), sortLabel(sortMode))) for _, e := range result.Entities { - parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s)", e.Name, e.MentionCount, e.Type)) + // ★ 带出匹配层级:此前输出是同格式平铺,模型无从判断该信哪条。 + // + // 实测 v4:193 个实体平铺(13744 tokens)后,模型放弃向量检索、 + // 转去 grep 知识库文件,还把"没检索到"说成"库里不存在"。 + // 层级标记让最相关的几条一眼可辨。 + tag := "" + switch { + case e.MatchRank == 0: + tag = ", 精确匹配" + case e.MatchRank == 1: + tag = ", 前缀匹配" + } + parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s%s)", e.Name, e.MentionCount, e.Type, tag)) } parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations))) parts = append(parts, formatRecallRelations(result.Relations, 10)...) @@ -322,14 +359,76 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str } // remember 工具是用户/模型显式写入,不涉及归档删除, // 因此不需要 mediaBound——没有旧引用要释放。 - ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil) + // ★★ 写入前取块数基线(2026-10-04) + // + // 旧表双写已停 ⇒ Commit 的 ec/rc 恒为 0, + // 拿它们判断「有没有写进去」会**永远判成没写进去**。 + blocksBefore, err := a.memory.MemoryBlockCount() if err != nil { return fmt.Sprintf("记忆写入失败: %v", err) } - if mb > 0 { - return fmt.Sprintf("已写入 %d 个实体和 %d 条关系,关联 %d 份媒体", ec, rc, mb) + edgesBefore, err := a.memory.MemoryEdgeCount() + if err != nil { + return fmt.Sprintf("记忆写入失败: %v", err) } - return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc) + + // ★ ec/rc 丢弃(2026-10-05):它们恒为 0(graph.go 里 + // relationsCreated = 0 写死),拿它们当「写了几条」会**永远说 0**。 + // 保留位置只是因为 commitTriplesWithMedia 的签名未变。 + // 唯一可信信号是下面实测的 newBlocks / newEdges。 + _, _, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0, nil) + if err != nil { + return fmt.Sprintf("记忆写入失败: %v", err) + } + blocksAfter, err := a.memory.MemoryBlockCount() + if err != nil { + return fmt.Sprintf("记忆写入失败: %v", err) + } + edgesAfter, err := a.memory.MemoryEdgeCount() + if err != nil { + return fmt.Sprintf("记忆写入失败: %v", err) + } + newBlocks := blocksAfter - blocksBefore + newEdges := edgesAfter - edgesBefore + // ★ 0 写入必须显式报告:提交了 N 条但一条都没落库(如实体名校验被拒) + // 却回「已写入 0 个」,模型会当成成功而永不重试 —— 实测(2026-10-01 + // 跑分):metrics 端口/分机号更新全部因此静默丢失。 + // ★ 判据用**新增块数**,不是 ec/rc(2026-10-04) + // + // ★★ 这条提示曾经会让模型做错事: + // + // 旧表停写后 ec/rc 恒为 0 ⇒ 每次提交都被告知 + // 「全部被拒,请检查实体名写法」—— + // 而写入其实**成功了**。 + // + // 模型据此去改不该改的东西(把正常实体名改短、 + // 加字母数字),把记忆内容改坏。 + // + // ⇒ 「报假失败」比「报假成功」危险:前者会诱发破坏性动作。 + if newBlocks == 0 && mb == 0 { + return fmt.Sprintf("提交了 %d 条三元组但全部被拒(未写入)。常见原因:实体名为空、过长(>50 字)、或不含字母/汉字/数字。请检查主语/宾语的写法后重试。", len(triples)) + } + // ★★★ 成功分支也**不能用 ec/rc**(2026-10-05) + // + // 旧表停双写后 ec/rc 恒为 0,所以这里原本回 + // 「已写入 0 个实体和 0 条关系」——**而实际写进去了**。 + // + // 实测(隔离实例):一次对话写入 7 块 9 边,工具却回 + // 「已写入 0 个实体和 0 条关系」与「全部被拒(未写入)」。 + // 模型据此认为记忆系统坏了,回复里明说 + // 「the write is being rejected」并放弃重试。 + // + // ★ 与上面那个「报假失败」是同一个病的两种表现: + // 判据用 newBlocks(对)但文案用 ec/rc(错), + // 于是同一份代码里两套口径打架。 + // + // 唯一可信的信号是 newBlocks(块总量差值)—— + // 它直接量「库里多了几个块」,不依赖任何旧表口径。 + if mb > 0 { + return fmt.Sprintf("已写入 %d 个记忆块、%d 条关系边,关联 %d 份媒体", + newBlocks, newEdges, mb) + } + return fmt.Sprintf("已写入 %d 个记忆块、%d 条关系边", newBlocks, newEdges) case "memory_introspect": if msg := requireFull(); msg != "" { @@ -367,10 +466,20 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str if name == "" { return "name 不能为空" } - if err := a.memory.DeleteEntity(name); err != nil { + res, err := a.memory.DeleteEntity(name) + if err != nil { return fmt.Sprintf("删除失败: %v", err) } - return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name) + // ★ 如实报告实际删掉多少,不说「已彻底删除…及其所有关联关系」。 + // + // 旧文案是谎报:底层只碰旧表、活图谱 Δ0,却回「彻底删除」。 + // 模型据此认为内容已消失(不再提及或重新写入), + // 而关联边还在、召回继续命中 —— 谎报会让模型的行为跟着错。 + if res.Blocks == 0 { + return fmt.Sprintf("未找到名为「%s」的块,未删除任何内容", name) + } + return fmt.Sprintf("已删除块「%s」及其 %d 条关联关系(块 %d 个)", + name, res.Edges, res.Blocks) case "memory_purge": if msg := requireFull(); msg != "" { @@ -833,3 +942,162 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { return fmt.Sprintf("未知的文档工具: %s", tc.Name) } } + +// sortLabel 把排序模式翻成给模型看的中文标签。 +func sortLabel(m memory.SortMode) string { + if m == memory.SortRecent { + return "时间倒序,最新在前" + } + return "相关性" +} + +// recallByBlocks 用稠密向量召回块节点,返回格式化文本;不可用/无命中时返回空串。 +// +// 返回空串让调用方无缝退回符号路 —— 这保证了「向量侧没配好」不会让 +// memory_recall 整体失败(那会让模型完全失去记忆,比召回差得多)。 +func (a *Agent) recallByBlocks(query string) string { + if a == nil || a.memory == nil || a.multimodalSpace == nil { + return "" + } + if !a.multimodalSpace.Loaded() { + return "" + } + vec, err := a.multimodalSpace.VectorizeDense(query) + if err != nil || len(vec) == 0 { + // 向量化失败不报错到工具层:符号路仍可用,而这条失败通常意味着 + // 模型未加载/超时,报给模型只会让它以为"记忆不存在"。 + return "" + } + // ★ 走**融合**入口(向量 + 符号),而不是纯向量或仲裁-only + // + // 三层根因(生产快照 1391 块实测): + // + // 1. 各向异性 已修(中心化,补上了生产调用者) + // 2. 精确串被稀释 向量模型固有限制:「13010/13011 而非 12011」 + // 含精确串却召不回,而「本地网关8081」召回了 + // 3. 符号路无补位 本函数修的就是这个 + // + // 旧实现是「块向量在前,符号路兜底」—— 块一旦有命中就直接 return, + // 符号路的 RecallSorted **根本没被调用**。设计注释写的 + //「端口号、分机号这类纯数字串向量天然弱」是对的,但没有真正生效。 + // + // 而仲裁仍必须在 topK 截断**之前**(RecallBlocksFused 内部 + // 已按「放大 TopK → 符号融合 → 仲裁 → 截断」实现): + // 实测旧号 4379 以 0.8127 排 top1 而新号进不了 top8, + // 事后仲裁无从挽回,因为被判取代的旧值和新值都不在候选里。 + // + // ★ MinScore 传 0 而不是 blockRecallMinScore(0.5): + // 融合分数的**量纲变了** —— 精确串命中是 1.0(布尔置顶), + // 而向量加权只有 0.7×余弦。用 0.5 筛会把「精确串命中 1.0」 + // 留下,却把所有纯向量候选(0.35~0.45)全筛掉 —— + // 反而丢掉向量侧的有效召回。筛选改由融合内部按路处理。 + // ★ 走**带拒答的**入口 RecallBlocksGuarded。 + // + // 拒答在图库层而不是这一层,是有教训的:第一版把 AbstainCheck + // 写在这里(core 层),而探针在 memory 包内直连图库, + // 于是**探针完全绕过了拒答** —— 端到端跑出 abstention 0/3, + // 但生产路径其实是有拒答的,却没人能证明它。 + // 「判据测不到被测路径 ⇒ 判据等于不存在」。 + // + // 拒答为什么必须在召回之前:编造的成因正是「查询符号在库里零出现」 + // ⇒ 向量却仍给 0.84+ 的高分(grafana/kafka/容灾演练 三类都是)。 + // 先召回再判断的话,看到的是一堆高分块 —— + // 而「分数高」本身不能证明相关。 + hits, abstain, arb, err := a.memory.RecallBlocksGuarded( + memory.BlockRecallQuery{ + Vector: vec, + Fingerprint: a.multimodalSpace.Fingerprint(), + TopK: blockRecallTopK, + // ★ MinScore 保留:融合内部按路应用(只约束纯向量那一路, + // 精确串/纯符号命中不受它约束)。曾误传 0 让噪声过滤失效, + // toolcall_block_test.go 的两条判据立刻红了。 + MinScore: blockRecallMinScore, + }, query) + if abstain != nil { + log.Printf("[memory] abstain: 符号零命中,拒答「%s」", query) + return abstain.Notice + } + if err != nil || len(hits) == 0 { + return "" + } + if n := len(arb.Superseded); n > 0 { + // 被取代的块不返回给模型,但**记一笔**:旧值被显式作废这件事 + // 本身有信息(「旧号 4379 停用」解释了为什么现在打不通), + // 而完全静默会让「记忆里为什么没有旧号」变成无解之谜。 + log.Printf("[memory] recall blocks: %d 条被时序仲裁剔除(被更新的值取代)", n) + } + // 归因统计:融合到底救回了多少条 —— 这是判断它在起作用的关键读数 + var bySymbol, byExact int + for _, h := range hits { + if h.ExactHit { + byExact++ + } else if h.VectorHit == 0 && h.SymbolHit > 0 { + bySymbol++ + } + } + if byExact+bySymbol > 0 { + log.Printf("[memory] recall blocks: 符号路补位 %d 条(精确串 %d,词法 %d)", + byExact+bySymbol, byExact, bySymbol) + } + + var parts []string + parts = append(parts, fmt.Sprintf("找到 %d 条相关记忆片段:", len(hits))) + for _, h := range hits { + text := strings.TrimSpace(h.Text) + if text == "" { + continue + } + // 分数照旧给模型看(它用这个判断相关性), + // 但精确串命中统一显示 1.00 —— 它是布尔信号不是强度信号。 + score := h.Score + if h.ExactHit { + score = 1.0 + } + tag := "text" + if h.ExactHit { + tag = "exact" + } else if h.VectorHit == 0 && h.SymbolHit > 0 { + tag = "symbol" + } + parts = append(parts, fmt.Sprintf("- [%s %.2f] %s", + tag, score, truncateStr(text, 160))) + } + return strings.Join(parts, "\n") +} + +const ( + // blockRecallTopK 是块召回的条数上限。与实体路的 20 条同量级: + // 实测 193 条平铺会把模型淹没(13744 tokens / 预算 436%)。 + blockRecallTopK = 8 + // blockRecallMinScore 是入选下限。低于它的候选分数已无区分意义 + // (生成模型主干的余弦普遍偏高,实测无关句也能到 0.83)。 + blockRecallMinScore = 0.5 +) + +// currentFingerprint 返回**当前向量空间**的指纹,用于给兜底召回路径做隔离。 +// +// ★ 为什么需要它 +// +// memory_recall 的主路是 recallByBlocks(带向量空间检查), +// 失败时退到 RecallSorted —— 而 RecallSorted 是纯词法的,不查 fingerprint +// ⇒ 换向量空间后旧块会从兜底路混回结果。 +// (判据 TestMemoryRecall_指纹不匹配的块被跳过 就是抓这个的) +// +// ★★ 为什么用「当前空间的指纹」而不是「库里多数取值」 +// +// 我先写的版本是查库(DominantBlockFingerprint)—— **错的**: +// 库里只有旧空间的块时,多数取值就是旧指纹, +// 拿它当过滤条件等于「不��滤」,旧块原样通过。 +// (判据当场抓住:测试库里唯一的块是 old-fp,而当前空间是 fp1。) +// +// 隔离的语义是「只信任当前空间写入的块」, +// 判据必须来自**空间对象**而不是库 —— 库只能说明过去,不能说明现在。 +func (a *Agent) currentFingerprint() string { + if a == nil || a.multimodalSpace == nil { + return "" + } + if !a.multimodalSpace.Loaded() { + return "" + } + return a.multimodalSpace.Fingerprint() +} diff --git a/internal/agent/core/toolcall_block_test.go b/internal/agent/core/toolcall_block_test.go new file mode 100644 index 00000000..28b6efe8 --- /dev/null +++ b/internal/agent/core/toolcall_block_test.go @@ -0,0 +1,156 @@ +package core + +import ( + "path/filepath" + "strings" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// 块召回接入 memory_recall 工具后的行为判据。 +// +// 为什么这几条重要:块向量召回是**新增的优先路径**,它出错有两种静默后果—— +// ① 命中垃圾却返回,把符号路的正确结果挤掉; +// ② 向量侧未就绪时整体失败,让模型完全失去记忆(比召回差得多)。 +// 两条都必须钉住。 + +// 造一个带块向量的 agent。 +func newBlockRecallAgent(t *testing.T, blocks []memory.MemoryBlock) *Agent { + t.Helper() + a := newTestAgent(nil) + db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "graph.db")) + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + if err := db.PutMemoryBlocks(blocks); err != nil { + t.Fatalf("PutMemoryBlocks: %v", err) + } + a.memory = db + a.multimodalSpace = fixedSpace{vec: []float64{1, 0}, fp: "fp1"} + t.Cleanup(func() { db.Close() }) + return a +} + +// fixedSpace 返回固定向量,用于可预测的召回排序。 +type fixedSpace struct { + vec []float64 + fp string + loaded bool +} + +func (s fixedSpace) VectorizeDense(string) ([]float64, error) { return s.vec, nil } +func (s fixedSpace) EmbedImageDense([]byte, string) ([]float64, error) { + return s.vec, nil +} +func (s fixedSpace) Fingerprint() string { return s.fp } +func (s fixedSpace) Dim() int { return len(s.vec) } +func (s fixedSpace) Loaded() bool { + // 零值 fixedSpace{} 视为未加载 —— 让"未就绪"可被显式构造。 + if s.vec == nil { + return s.loaded + } + return true +} +func (s fixedSpace) Close() {} + +func callRecall(t *testing.T, a *Agent, q string) string { + t.Helper() + return a.executeMemoryTool(api.ToolCall{ + ID: "c1", Name: "memory_recall", + Arguments: map[string]interface{}{"query_intent": q}, + }, nil) +} + +// ★ 块有命中时,结果里必须出现块文本(而不是走符号路)。 +func TestMemoryRecall_块召回优先(t *testing.T) { + a := newBlockRecallAgent(t, []memory.MemoryBlock{ + {ID: "hit", Modality: memory.BlockText, Text: "第112批 停机4分", + Vector: []float64{1, 0}, Fingerprint: "fp1"}, + {ID: "miss", Modality: memory.BlockText, Text: "完全无关", + Vector: []float64{0, 1}, Fingerprint: "fp1"}, + }) + + got := callRecall(t, a, "停机多久") + if !strings.Contains(got, "第112批") { + t.Fatalf("块召回应命中并返回块文本,实际: %s", got) + } + if strings.Contains(got, "完全无关") { + t.Errorf("低分块不该出现(MinScore 应滤掉),实际: %s", got) + } + if !strings.Contains(got, "相关记忆片段") { + t.Errorf("应标明这是块召回结果,实际: %s", got) + } +} + +// ★ 向量侧未加载时必须回退符号路,不能整体失败。 +// 判据:不出现"检索失败",且仍返回某种结果(符号路的实体或未找到提示)。 +func TestMemoryRecall_向量未加载则回退(t *testing.T) { + a := newBlockRecallAgent(t, []memory.MemoryBlock{ + {ID: "hit", Modality: memory.BlockText, Text: "有向量", + Vector: []float64{1, 0}, Fingerprint: "fp1"}, + }) + a.multimodalSpace = fixedSpace{} // 未加载 + + got := callRecall(t, a, "有向量") + if strings.Contains(got, "检索失败") { + t.Fatalf("向量未加载不该让检索失败,实际: %s", got) + } + if strings.Contains(got, "相关记忆片段") { + t.Errorf("未加载时不该走块路,实际: %s", got) + } +} + +// 未配置多模态空间(nil)时同样回退,不 panic。 +func TestMemoryRecall_无向量空间则回退(t *testing.T) { + a := newBlockRecallAgent(t, nil) + a.multimodalSpace = nil + got := callRecall(t, a, "随便问问") + if strings.Contains(got, "检索失败") { + t.Fatalf("无向量空间不该让检索失败,实际: %s", got) + } +} + +// ★ 指纹不匹配的块必须被跳过(换向量空间后旧块不能污染结果)。 +func TestMemoryRecall_指纹不匹配的块被跳过(t *testing.T) { + a := newBlockRecallAgent(t, []memory.MemoryBlock{ + {ID: "old", Modality: memory.BlockText, Text: "旧空间的内容", + Vector: []float64{1, 0}, Fingerprint: "old-fp"}, + }) + + got := callRecall(t, a, "旧空间的内容") + if strings.Contains(got, "旧空间的内容") { + t.Fatalf("指纹不匹配的块不该被召回,实际: %s", got) + } +} + +// 向量化报错时回退(不把错误抛给模型——那会让它以为"记忆不存在")。 +func TestMemoryRecall_向量化失败则回退(t *testing.T) { + a := newBlockRecallAgent(t, []memory.MemoryBlock{ + {ID: "x", Modality: memory.BlockText, Text: "内容", + Vector: []float64{1, 0}, Fingerprint: "fp1"}, + }) + a.multimodalSpace = errSpace{} + got := callRecall(t, a, "内容") + if strings.Contains(got, "检索失败") { + t.Fatalf("向量化失败不该让检索失败,实际: %s", got) + } +} + +type errSpace struct{} + +func (errSpace) VectorizeDense(string) ([]float64, error) { return nil, errTest } +func (errSpace) EmbedImageDense([]byte, string) ([]float64, error) { + return nil, errTest +} +func (errSpace) Fingerprint() string { return "fp" } +func (errSpace) Dim() int { return 2 } +func (errSpace) Loaded() bool { return true } +func (errSpace) Close() {} + +var errTest = &testError{"向量化失败"} + +type testError struct{ s string } + +func (e *testError) Error() string { return e.s } diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 4fca493a..fafe2b12 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -87,7 +87,7 @@ func evtOf(f *TaskFrame) *agentIO.InputEvent { func (a *Agent) recallTextFor(query, trigger string, scenes []string) string { memTokens := 0 // 0 = 不截断 if a != nil && a.provider != nil { - memTokens = ComputeTokenBudget(a.provider, a.systemPrompt).MemoryTokens + memTokens = a.computeTokenBudget().MemoryTokens } return a.recallText(query, trigger, memTokens, scenes) } @@ -408,7 +408,7 @@ func (a *Agent) buildToolDefs() []interface{} { "source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"}, "target": map[string]interface{}{"type": "string", "description": "保留的实体名"}, }, "source", "target")) - tools = append(tools, toolDef("memory_delete_entity", "【记忆清理】彻底删除指定实体及其所有关联关系。用于清理无用的噪音实体,如 mentionCount=0 的孤立实体、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{ + tools = append(tools, toolDef("memory_delete_entity", "【记忆清理】删除指定名称的记忆块及其所有关联关系。name 必须是**块文本**(端点名),不是关系文本;按精确匹配定位,不会连带删掉名字相近的其他块。要按子串批量清理改用 memory_purge(subject_contains)。用于清理无用的噪音块、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{ "name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"}, }, "name")) tools = append(tools, toolDef("memory_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{ diff --git a/internal/agent/core/toolresult_budget.go b/internal/agent/core/toolresult_budget.go index 194379f4..58a34d3e 100644 --- a/internal/agent/core/toolresult_budget.go +++ b/internal/agent/core/toolresult_budget.go @@ -86,7 +86,7 @@ func (a *Agent) toolResultWarnLimit() int { if a.toolResultWarnTokens > 0 { return a.toolResultWarnTokens } - budget := ComputeTokenBudget(a.provider, a.systemPrompt) + budget := a.computeTokenBudget() base := budget.ContextTokens if base <= 0 { base = budget.TargetUsage diff --git a/internal/agent/core/usagestats.go b/internal/agent/core/usagestats.go new file mode 100644 index 00000000..5fec1c40 --- /dev/null +++ b/internal/agent/core/usagestats.go @@ -0,0 +1,155 @@ +package core + +import ( + "sync" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// UsageTotals 是**跨调用累计**的用量账目。 +// +// ── 为什么需要它 ── +// +// 单次调用的用量一直在(`StageCtx.TokenUsage`、LLM chain 事件),但没有任何地方 +// 把多次调用加起来。于是「这一轮/这个会话花了多少、缓存省了多少」根本答不出来 —— +// 只能一条条翻日志自己加。这正是「无法统计缓存命中」的另一半: +// 数据流出来了,却没有落点。 +// +// ── 关于命中率的口径(最容易被算错的地方)── +// +// 命中率的分母**必须只算「上游报了缓存」的调用**(`CacheReportedCalls`), +// 而不是全部调用。理由:没报缓存的提供商/版本不是「命中 0」而是「不知道」, +// 把它们算进分母会把命中率稀释成一个无意义的低值, +// 让人去优化一个本来就没开的功能。 +// +// 同理 `CacheHitRate` 在没有任何调用报过缓存时返回 ok=false, +// 调用方应显示「—」而不是 0%。 +type UsageTotals struct { + // 累计 token 数。 + Prompt int64 `json:"prompt"` + Completion int64 `json:"completion"` + Total int64 `json:"total"` + // CacheRead 是累计命中缓存的输入 token(省下来的那部分计算)。 + CacheRead int64 `json:"cache_read"` + // CacheMiss 是上游明确报告的未命中输入 token。 + CacheMiss int64 `json:"cache_miss"` + // Reasoning 是累计的思考 token(计费输出里属于思考的部分)。 + Reasoning int64 `json:"reasoning"` + + // Calls 是累计的 LLM 调用次数(含用量为 0 的调用)。 + Calls int64 `json:"calls"` + // CacheReportedCalls 是其中**上游报告了缓存字段**的调用数。 + // 命中率的分母。 + CacheReportedCalls int64 `json:"cache_reported_calls"` +} + +// add 并入一次调用的用量。 +func (t *UsageTotals) add(u agentAPI.TokenUsage) { + t.Calls++ + t.Prompt += int64(u.Prompt) + t.Completion += int64(u.Completion) + t.Total += int64(u.Total) + t.CacheRead += int64(u.CacheRead) + t.CacheMiss += int64(u.CacheMiss) + t.Reasoning += int64(u.ReasoningTokens) + if u.CacheReported { + t.CacheReportedCalls++ + } + // 上游没给 total 时按分量补,避免累计出现「total 小于 prompt+completion」。 + if u.Total == 0 && (u.Prompt > 0 || u.Completion > 0) { + t.Total += int64(u.Prompt + u.Completion) + } +} + +// CacheHitRate 返回缓存命中率与「是否可计算」。 +// +// 分母是 `CacheRead + CacheMiss`(上游报告过缓存的那些调用里的输入侧), +// 而不是 `Prompt`:后者在未报告缓存的调用里也计入,会把命中率压低。 +// +// 两者都为 0 时 ok=false —— 表示「没有任何调用报过缓存」, +// 而不是「命中率 0」。调用方必须区分,否则会把无数据画成 0%。 +func (t UsageTotals) CacheHitRate() (float64, bool) { + denom := t.CacheRead + t.CacheMiss + if t.CacheReportedCalls == 0 || denom == 0 { + return 0, false + } + return float64(t.CacheRead) / float64(denom), true +} + +// usageLedger 是 UsageTotals 的并发安全容器。 +// +// 为何要锁:LLM 调用可能来自多个 goroutine —— +// · 主 agent 与驻留子 agent 各自的任务循环; +// · 同一轮里并行的工具执行虽不直接记账,但工具内部可能再发起 LLM 调用 +// +// (子调用/摘要),与主循环并发。 +// +// 计数丢失是**静默**的(少算一点没人看得出来),所以宁可用锁。 +type usageLedger struct { + mu sync.Mutex + totals UsageTotals +} + +// record 并入一次调用。 +func (l *usageLedger) record(u agentAPI.TokenUsage) { + l.mu.Lock() + l.totals.add(u) + l.mu.Unlock() +} + +// usageMapFromInts 把 SDK 插件的 TokenUsage(map[string]int) 归一成 +// 回包用的 map[string]interface{}。 +// +// 为何必须转而不能直接透传:Go 的类型断言对 map 是精确匹配, +// 消费方 handler_openai.go 写的是 .(map[string]interface{}), +// 直接塞 map[string]int 会断言失败 ⇒ usage 静默变 nil,两边都不报错。 +// 注意这里**不**过滤零值:插件既然显式设了这份 map,就尊重它的全部内容。 +func usageMapFromInts(in map[string]int) map[string]interface{} { + m := make(map[string]interface{}, len(in)) + for k, v := range in { + m[k] = v + } + return m +} + +// turnUsageMap 把**本次请求**的用量转成对外回包用的键值形式。 +// +// 键名用 OpenAI 的 snake_case(prompt_tokens/…),且值类型为 +// map[string]interface{} —— 消费方(handler_openai.go)用精确类型断言取值, +// 给 map[string]int 会静默变 nil(见 eventloop.go:emitResponse 的说明)。 +func turnUsageMap(u agentAPI.TokenUsage) map[string]interface{} { + m := map[string]interface{}{ + "prompt_tokens": u.Prompt, + "completion_tokens": u.Completion, + "total_tokens": u.Total, + } + // 缓存/推理字段仅在**有数据**时才带上:缺了它们消费方会画成 0, + // 而 0 与「上游没报」是两回事(与 UsageTotals.CacheHitRate 的 ok 同口径)。 + if u.CacheRead > 0 { + m["cache_read_tokens"] = u.CacheRead + } + if u.CacheMiss > 0 { + m["cache_miss_tokens"] = u.CacheMiss + } + if u.ReasoningTokens > 0 { + m["reasoning_tokens"] = u.ReasoningTokens + } + if u.CacheReported { + m["cache_reported"] = true + } + return m +} + +// snapshot 取当前累计值的副本。 +func (l *usageLedger) snapshot() UsageTotals { + l.mu.Lock() + defer l.mu.Unlock() + return l.totals +} + +// reset 清零(供测试与「重置统计」入口使用)。 +func (l *usageLedger) reset() { + l.mu.Lock() + l.totals = UsageTotals{} + l.mu.Unlock() +} diff --git a/internal/agent/core/usagestats_test.go b/internal/agent/core/usagestats_test.go new file mode 100644 index 00000000..3162410c --- /dev/null +++ b/internal/agent/core/usagestats_test.go @@ -0,0 +1,166 @@ +package core + +import ( + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// 会话级用量累计:跨调用求和,且命中率口径必须正确。 +// +// ── 为什么需要 ── +// +// 单次用量一直在(StageCtx、LLM chain 事件),但没有任何地方把多次调用加起来, +// 于是「这个会话花了多少、缓存省了多少」答不出来。这是「无法统计缓存命中」 +// 的另一半:数据流出来了,却没有落点。 + +// TestLedgerSumsAcrossCalls 跨调用求和。 +func TestLedgerSumsAcrossCalls(t *testing.T) { + var l usageLedger + l.record(agentAPI.TokenUsage{ + Prompt: 1000, Completion: 50, Total: 1050, + CacheRead: 768, CacheReported: true, + }) + l.record(agentAPI.TokenUsage{ + Prompt: 2000, Completion: 80, Total: 2080, + CacheRead: 1024, CacheMiss: 256, CacheReported: true, + ReasoningTokens: 300, + }) + + got := l.snapshot() + if got.Calls != 2 { + t.Errorf("Calls = %d,期望 2", got.Calls) + } + if got.Prompt != 3000 { + t.Errorf("Prompt = %d,期望 3000", got.Prompt) + } + if got.Completion != 130 { + t.Errorf("Completion = %d,期望 130", got.Completion) + } + if got.Total != 3130 { + t.Errorf("Total = %d,期望 3130", got.Total) + } + if got.CacheRead != 1792 { + t.Errorf("CacheRead = %d,期望 1792(768+1024)", got.CacheRead) + } + if got.Reasoning != 300 { + t.Errorf("Reasoning = %d,期望 300", got.Reasoning) + } + if got.CacheReportedCalls != 2 { + t.Errorf("CacheReportedCalls = %d,期望 2", got.CacheReportedCalls) + } +} + +// TestLedgerHitRateExcludesUnreportedCalls 是这组判据里最重要的一条: +// **命中率的分母只能算「上游报了缓存」的调用**。 +// +// 为何:没报缓存的提供商/版本不是「命中 0」而是「不知道」。 +// 把它们算进分母会把命中率稀释成一个无意义的低值, +// 让人去优化一个本来就没开的功能 —— 拿假数据做决定。 +func TestLedgerHitRateExcludesUnreportedCalls(t *testing.T) { + var l usageLedger + // ① 报了缓存:768 命中 / 256 未命中 + l.record(agentAPI.TokenUsage{ + Prompt: 1024, CacheRead: 768, CacheMiss: 256, CacheReported: true, + }) + // ② 没报缓存:只有总量,绝不能拿它稀释命中率 + l.record(agentAPI.TokenUsage{Prompt: 100000, Completion: 500}) + + got := l.snapshot() + rate, ok := got.CacheHitRate() + if !ok { + t.Fatal("CacheHitRate ok=false —— 有一次调用报过缓存,应可计算") + } + // 正确:768/(768+256) = 0.75 + if rate < 0.7499 || rate > 0.7501 { + t.Errorf("CacheHitRate = %.4f,期望 0.75(768/1024)—— "+ + "若把未报缓存的 100000 token 算进分母会得到 0.0076 这种无意义值", rate) + } +} + +// TestLedgerHitRateUnavailableWhenNothingReported 守住「不知道」与「0%」的分野。 +// +// 混为一谈的后果是撒谎:把「无数据」显示成 0% 命中率。 +func TestLedgerHitRateUnavailableWhenNothingReported(t *testing.T) { + var l usageLedger + l.record(agentAPI.TokenUsage{Prompt: 5000, Completion: 100}) // 上游没提缓存 + got := l.snapshot() + if _, ok := got.CacheHitRate(); ok { + t.Error("没有任何调用报过缓存,CacheHitRate 应返回 ok=false(表示「不知道」)," + + "而不是一个 0 值让人读成「命中率 0%」") + } +} + +// TestLedgerHitRateReportedButZero 覆盖「上游报了缓存、这次全未命中」。 +// +// 这时**必须**可计算并给出 0 —— 这与「没报缓存」是两件事。 +func TestLedgerHitRateReportedButZero(t *testing.T) { + var l usageLedger + l.record(agentAPI.TokenUsage{ + Prompt: 1000, CacheRead: 0, CacheMiss: 1000, CacheReported: true, + }) + got := l.snapshot() + rate, ok := got.CacheHitRate() + if !ok { + t.Fatal("上游报了缓存(命中为 0),应可计算 —— 这是「这次全未命中」," + + "不是「不知道」") + } + if rate != 0 { + t.Errorf("CacheHitRate = %.4f,期望 0", rate) + } +} + +// TestLedgerFillsMissingTotal 上游没给 total 时按分量补, +// 避免累计出现「total 小于 prompt+completion」的自相矛盾记录。 +func TestLedgerFillsMissingTotal(t *testing.T) { + var l usageLedger + l.record(agentAPI.TokenUsage{Prompt: 100, Completion: 20}) // Total 缺失 + got := l.snapshot() + if got.Total != 120 { + t.Errorf("Total = %d,期望 120(prompt 100 + completion 20,"+ + "上游未给 total 时应补出)", got.Total) + } +} + +// TestLedgerConcurrentRecordIsSafe 并发记账不丢数。 +// +// 为何要测:LLM 调用来自多个 goroutine(主 agent、驻留子 agent、 +// 工具内部发起的子调用)。计数丢失是**静默**的 —— 少算一点没人看得出来, +// 所以这里用 -race 与总量双重验证。 +func TestLedgerConcurrentRecordIsSafe(t *testing.T) { + var l usageLedger + const goroutines, each = 8, 250 + + done := make(chan struct{}) + for g := 0; g < goroutines; g++ { + go func() { + for i := 0; i < each; i++ { + l.record(agentAPI.TokenUsage{Prompt: 1, Completion: 1, Total: 2}) + } + done <- struct{}{} + }() + } + for g := 0; g < goroutines; g++ { + <-done + } + + got := l.snapshot() + want := int64(goroutines * each) + if got.Calls != want { + t.Errorf("Calls = %d,期望 %d —— 并发记账丢了计数", got.Calls, want) + } + if got.Prompt != want { + t.Errorf("Prompt = %d,期望 %d", got.Prompt, want) + } +} + +// TestLedgerReset 清零(供「重置统计」入口使用)。 +func TestLedgerReset(t *testing.T) { + var l usageLedger + l.record(agentAPI.TokenUsage{Prompt: 500, Completion: 10, Total: 510}) + l.reset() + got := l.snapshot() + if got.Calls != 0 || got.Prompt != 0 || got.Total != 0 { + t.Errorf("reset 后仍有残留: %+v", got) + } +} diff --git a/internal/config/registry.go b/internal/config/registry.go index 45f32c77..f83b5730 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -735,6 +735,11 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { set("core.agent.max_tool_turns", "10") set("core.agent.max_context_size", "30") + // 上下文预算的可调阈值(原为硬编码,0 = 沿用历史默认)。 + set("core.agent.context.utilization_percent", "80") + set("core.agent.context.max_target_tokens", "600000") + set("core.agent.context.memory_ratio_percent", "0") + set("core.agent.context.protected_count", "10") // 积压转投默认**关闭**(false):它让内核替父做决策(设计 §7 的刻意例外), // 所以必须由部署方显式打开,而不是默默改变系统行为。 set("core.agent.offload_enabled", "false") @@ -848,6 +853,10 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg(ConfigDef{Key: "core.agent.offload_min_pending", Default: "3", Type: "int", DisplayName: "转投最少积压条数", Description: "积压少于该条数时不值得拉起驻留子", Category: "agent"}) reg(ConfigDef{Key: "core.agent.offload_max_residents", Default: "2", Type: "int", DisplayName: "转投驻留子上限", Description: "自动转投最多拉起几个驻留子(人工创建的不计)", Category: "agent"}) reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.context.utilization_percent", Default: "80", Type: "int", DisplayName: "上下文利用率(%)", Description: "目标窗口利用率,其余留给回复。原为硬编码 0.8。注意:与「工作区间上限」叠加时,窗口超过 上限/利用率 后实际利用率会下降(如默认值下窗口 1M 实际只有 60%)—— 要让利用率对所有窗口恒定,把工作区间上限设为 -1。", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.context.max_target_tokens", Default: "600000", Type: "int", DisplayName: "工作区间上限(token)", Description: "记忆与历史可用的绝对上限(不是模型窗口)。600000=默认;-1=不封顶,跟随窗口与利用率;正数=该值封顶。为何不等于模型窗口:标称窗口≠有效窗口,接近满窗时注意力涣散、成本与延迟随 prompt 上升。", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.context.memory_ratio_percent", Default: "0", Type: "int", DisplayName: "记忆预算占比(%)", Description: "记忆上下文占可用预算的百分比,其余给上下文事件(历史)。0=沿用历史默认的三等分(1/3 记忆、2/3 事件);1-99=显式指定。", Category: "agent"}) + reg(ConfigDef{Key: "core.agent.context.protected_count", Default: "10", Type: "int", DisplayName: "裁剪保护条数", Description: "超出上限裁剪时,无条件保留的最近事件条数。它决定「近处信息」与「向量检索」的权重:越大越不容易丢近处,越小越依赖检索准确度。", Category: "agent"}) reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"}) reg(ConfigDef{Key: "core.agent.archive_interval", Default: "60m", Type: "duration", DisplayName: "冷文档归档间隔", Description: "冷文档归档(L2→L3)的执行间隔", Category: "agent"}) reg(ConfigDef{Key: "core.agent.review_interval", Default: "120m", Type: "duration", DisplayName: "关系复审间隔", Description: "三元组关系复审的执行间隔", Category: "agent"}) diff --git a/internal/knowledge/rankdiag_test.go b/internal/knowledge/rankdiag_test.go index a2b6b9d5..70e3b4d7 100644 --- a/internal/knowledge/rankdiag_test.go +++ b/internal/knowledge/rankdiag_test.go @@ -30,7 +30,7 @@ func TestRankingQualityOnRealKB(t *testing.T) { if os.Getenv("KB_DIAG") == "" { t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)") } - srcRoot := envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge") + srcRoot := envOr("KB_DIAG_ROOT", envOr("KB_DIAG_ROOT", "/data/knowledge")) models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec") emb := memory.NewStaticEmbedder(strings.Split(models, ",")...) diff --git a/internal/lua/adapter_drift_test.go b/internal/lua/adapter_drift_test.go index 7f5ebafd..437d851d 100644 --- a/internal/lua/adapter_drift_test.go +++ b/internal/lua/adapter_drift_test.go @@ -26,7 +26,7 @@ import ( // // ★ 这条漂移的具体内容与时间线(解释了为什么仓库长期缺它却没人发现): // -// 生产 /home/newqqagent/adapters/openai.lua 4853 字节 含 stream_index +// 生产 /data/homeagent/adapters/openai.lua 4853 字节 含 stream_index // 仓库 ddef195 时的 openai.lua 4709 字节 不含 // 生产文件时间 2026-08-26 15:46 // ddef195 提交时间 2026-08-26 16:10 @@ -44,7 +44,7 @@ import ( // 我曾断言「openai.lua 缺 stream_index 透传、批内并发在生产走不通」,并据此 // 写了实现与提交。后来核对生产实例才发现: // -// 生产 /home/newqqagent/adapters/openai.lua 130 行 含 stream_index +// 生产 /data/homeagent/adapters/openai.lua 130 行 含 stream_index // 仓库(修复前) 128 行 无 stream_index // // 生产**早就有**那个透传 —— 仓库版本落后于生产。而当时没有任何判据能发现这个 diff --git a/internal/lua/adapter_usage_test.go b/internal/lua/adapter_usage_test.go new file mode 100644 index 00000000..820a316c --- /dev/null +++ b/internal/lua/adapter_usage_test.go @@ -0,0 +1,204 @@ +package lua + +import ( + "encoding/json" + "testing" +) + +// 适配器必须把**上游给的用量**透传出来。 +// +// ── 为什么需要这组判据 ── +// +// 用户的描述是准确的:「Lua 适配器透传上游提供的用量,内核是估算」。 +// 设计意图确实如此。但实现没做到 —— 流式路径上适配器**一个字节的用量都不透传**: +// +// anthropic.lua transform_stream_chunk +// message_start → return "" ← input_tokens 与 cache_read_input_tokens 就在这帧 +// message_delta → 返回不带 usage ← output_tokens 就在这帧 +// openai.lua transform_stream_chunk +// 整段从不读 chunk.usage +// +// 后果:生产流式请求的 `StreamChunk.Usage` 恒为 nil ⇒ 缓存命中率与 +// token 消耗**根本无从统计**,内核只剩估算一条路。 +// +// ── 为什么以前没被发现 ── +// +// 没有任何判据断言过「适配器输出里有 usage」。既有测试全部只查 content / +// tool_calls / stream_index —— 用量字段从来没进过任何 fixture。 +// 这与 stream_index 那次是同一形态:内核侧写好了、判据也加了, +// 唯独**透传那一步从未落地**,而它静默(缺字段不报错,只是永远取零值)。 +// +// ── 契约 ── +// +// 任何**上游 payload 里带 usage 对象**的帧,适配器输出必须带出该 usage。 +// 键名对齐 Go 侧 agentAPI.TokenUsage 的 json tag +// (prompt / completion / total / cache_read / cache_miss / cache_reported / +// reasoning_tokens),因为该输出会被 json.Unmarshal 进 StreamChunk。 + +// usageView 是断言用的宽松视图:只关心有没有、值对不对。 +type usageView struct { + Usage *struct { + Prompt int `json:"prompt"` + Completion int `json:"completion"` + Total int `json:"total"` + CacheRead int `json:"cache_read"` + CacheMiss int `json:"cache_miss"` + CacheReported bool `json:"cache_reported"` + Reasoning int `json:"reasoning_tokens"` + } `json:"usage"` +} + +func decodeUsage(t *testing.T, out string) usageView { + t.Helper() + if out == "" { + return usageView{} + } + var v usageView + if err := json.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("适配器输出不是合法 JSON: %s(原文 %q)", err, out) + } + return v +} + +// TestAnthropicAdapterPassesInputCacheUsage 覆盖 Anthropic 的 message_start。 +// +// 这是**最贵的一帧**:prompt caching 的收益全在这里 —— +// input_tokens 是总输入,cache_read_input_tokens 是其中命中缓存的部分。 +// 少了它,Anthropic 用户永远看不到自己省了多少。 +func TestAnthropicAdapterPassesInputCacheUsage(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, "anthropic") + + start := `{"type":"message_start","message":{"id":"msg_1","usage":{` + + `"input_tokens":1000,"output_tokens":1,` + + `"cache_creation_input_tokens":128,` + + `"cache_read_input_tokens":768}}}` + + out, err := vm.CallTransformStreamChunk("anthropic", start) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + v := decodeUsage(t, out) + if v.Usage == nil { + t.Fatalf("message_start 的输出没有 usage —— 这一帧携带 input_tokens 与 "+ + "cache_read_input_tokens,丢掉就再也拿不回来了。输出: %q", out) + } + if v.Usage.Prompt != 1896 { + // ★ 为何是 1896 而不是 1000 —— 这是**跨厂商口径差异**,很容易搞错: + // + // OpenAI : prompt_tokens **包含**缓存命中部分 + // Anthropic: input_tokens **不包含**; + // 真实总输入 = input + cache_read + cache_creation + // + // 本系统的 TokenUsage.Prompt 统一按 OpenAI 口径(否则同一个字段 + // 在不同后端下含义不同,统计就无法横比)。所以这里是 + // 1000(input) + 768(cache_read) + 128(cache_creation) = 1896。 + // + // 若直接搬 input_tokens,会**少算缓存那部分** —— 而它通常是最 + // 大的一块,于是“输入很短”的错觉会让缓存收益看起来不存在。 + t.Errorf("prompt = %d,期望 1896(input 1000 + cache_read 768 + "+ + "cache_creation 128,按 OpenAI 口径含缓存)", v.Usage.Prompt) + } + if v.Usage.CacheRead != 768 { + t.Errorf("cache_read = %d,期望 768(cache_read_input_tokens)", v.Usage.CacheRead) + } + if v.Usage.CacheMiss != 128 { + // 缓存**写入**算未命中侧的开销(要花钱但不算 hit)。 + // 命中率分母若把它算成 hit,命中率会被写缓存抬高。 + t.Errorf("cache_miss = %d,期望 128(cache_creation_input_tokens 归入未命中侧)", + v.Usage.CacheMiss) + } + if !v.Usage.CacheReported { + t.Error("cache_reported = false,期望 true —— 上游明确报了缓存字段," + + "必须与「没报缓存」区分开,否则 0 命中会被显示成「无数据」") + } +} + +// TestAnthropicAdapterPassesOutputUsage 覆盖 Anthropic 的 message_delta。 +// output_tokens 只在这一帧,而它返回的是非空 unified ⇒ Go 侧会**采用适配器结果**、 +// 不再走回退解析 ⇒ 适配器不透传就彻底没有。 +func TestAnthropicAdapterPassesOutputUsage(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, "anthropic") + + delta := `{"type":"message_delta","delta":{"stop_reason":"end_turn"},` + + `"usage":{"output_tokens":250}}` + + out, err := vm.CallTransformStreamChunk("anthropic", delta) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if out == "" { + t.Fatal("message_delta 输出为空 —— 但该帧同时携带 stop_reason 与 usage," + + "不能整帧丢弃") + } + v := decodeUsage(t, out) + if v.Usage == nil { + t.Fatalf("message_delta 的输出没有 usage(output_tokens=250 丢失)。输出: %q", out) + } + if v.Usage.Completion != 250 { + t.Errorf("completion = %d,期望 250(output_tokens)", v.Usage.Completion) + } +} + +// TestOpenAIAdapterPassesUsageOnContentFrame 覆盖「内容帧同时带 usage」。 +// +// 为什么单列一条:OpenAI 的**纯 usage 心跳帧**(choices 为空)适配器会返回 "", +// 此时 Go 侧回退到标准解析,能接住用量 —— 所以那条路是通的(但属隐式依赖)。 +// 而**内容帧带 usage** 时适配器返回非空 unified,Go 侧就采用适配器结果了, +// 用量只能由适配器透传。 +func TestOpenAIAdapterPassesUsageOnContentFrame(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, "openai") + + frame := `{"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}],` + + `"usage":{"prompt_tokens":900,"completion_tokens":40,"total_tokens":940,` + + `"prompt_tokens_details":{"cached_tokens":640}}}` + + out, err := vm.CallTransformStreamChunk("openai", frame) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + v := decodeUsage(t, out) + if v.Usage == nil { + t.Fatalf("内容帧携带 usage,但适配器输出里没有。输出: %q", out) + } + if v.Usage.Prompt != 900 { + t.Errorf("prompt = %d,期望 900", v.Usage.Prompt) + } + if v.Usage.CacheRead != 640 { + t.Errorf("cache_read = %d,期望 640(prompt_tokens_details.cached_tokens)", + v.Usage.CacheRead) + } +} + +// TestOpenAIAdapterPassesUsageOnlyHeartbeat 覆盖纯 usage 心跳帧。 +// +// 上游开 include_usage 时,最后一帧是 `{"choices":[],"usage":{...}}`。 +// 适配器可以返回 ""(让 Go 回退解析)**或**自己透传,但**不能两者都丢** —— +// 本判据接受返回 ""(回退路径已由 api 包判据覆盖),只禁止 +// 「返回了非空内容却不带 usage」这种把数据吞掉的情形。 +func TestOpenAIAdapterPassesUsageOnlyHeartbeat(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, "openai") + + frame := `{"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":0,` + + `"total_tokens":1200,"prompt_tokens_details":{"cached_tokens":1024}}}` + + out, err := vm.CallTransformStreamChunk("openai", frame) + if err != nil { + t.Fatalf("调用失败: %v", err) + } + if out == "" { + // 允许:Go 侧会回退到标准解析(api 包的 + // parseOpenAICompatibleStreamChunkFull 已覆盖该形态)。 + return + } + v := decodeUsage(t, out) + if v.Usage == nil { + t.Fatalf("适配器返回了非空内容却不带 usage ⇒ 用量被吞掉。输出: %q", out) + } + if v.Usage.CacheRead != 1024 { + t.Errorf("cache_read = %d,期望 1024", v.Usage.CacheRead) + } +} diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index aafaf4f8..bc2fb4b2 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -7,6 +7,47 @@ adapter.headers = { ["anthropic-version"] = "2023-06-01" } +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- ★ Anthropic 的计量口径与其他家**不一样**,这里必须算对: +-- +-- input_tokens = **不含**缓存读写的输入 +-- cache_read_input_tokens = 命中缓存、跳过计算的输入 +-- cache_creation_input_tokens = 写入缓存的输入(是**写**,不算命中) +-- ⤷ 真实总输入 = input_tokens + cache_read + cache_creation +-- +-- 直接拿 input_tokens 当 prompt 会**少算缓存那部分**(偏偏那是通常最大的那块), +-- 于是“输入很短”的错觉会让缓存收益看起来不存在。 +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag), +-- 可直接被 json.Unmarshal 吃进 StreamChunk.Usage。 +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local inp = u.input_tokens or 0 + local cread = u.cache_read_input_tokens or 0 + local cwrite = u.cache_creation_input_tokens or 0 + local outp = u.output_tokens or 0 + local out = { + -- 总量含缓存两部分:这样它与 OpenAI 系的 prompt_tokens 才是同一口径 + -- (OpenAI 的 prompt_tokens 本就含命中部分)。 + prompt = inp + cread + cwrite, + completion = outp, + total = inp + cread + cwrite + outp, + cache_read = cread + } + -- ★只要上游给了 cache_read_input_tokens 字段(哪怕为 0)就算「报了缓存」。 + -- 它必须与「根本没给」区分:前者是「这次没命中」,后者是「不知道」。 + if u.cache_read_input_tokens ~= nil then + out.cache_reported = true + end + if cwrite > 0 then + -- 缓存写入是未命中侧的开销(要花钱但不算 hit)。 + -- 命中数上不能把它算成 hit,否则命中率会被写缓存抬高。 + out.cache_miss = cwrite + end + return out +end + function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end @@ -79,9 +120,7 @@ function adapter.transform_response(raw_body) } if resp.usage then - unified.token_usage.prompt = resp.usage.input_tokens or 0 - unified.token_usage.completion = resp.usage.output_tokens or 0 - unified.token_usage.total = (resp.usage.input_tokens or 0) + (resp.usage.output_tokens or 0) + unified.token_usage = usage_to_unified(resp.usage) end if resp.content and #resp.content > 0 then @@ -99,9 +138,33 @@ end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if chunk.type == "message_start" then return "" end + if chunk.type == "message_start" then + -- ★ 不能整帧丢弃。这一帧携带着**最贵的两个数**:input_tokens 与 + -- cache_read_input_tokens —— prompt caching 的收益全在这里, + -- 而它们在流里**只会出现这一次**,丢了就再也拿不回来。 + -- + -- 发空内容块是安全的:done=false 且无 finish_reason, + -- 既不会被 errorOnlyChunk 判为退化流(它要求 Done 且 finish_reason + -- 非空、且不是已知正常值),也不会在累积器里拼出任何内容。 + local u = usage_to_unified(chunk.message and chunk.message.usage) + if u then + return json.encode({ content = "", done = false, usage = u }) + end + return "" + end if chunk.type == "message_delta" then - return json.encode({ content = "", done = (chunk.delta and chunk.delta.stop_reason ~= nil) }) + -- output_tokens 只在这一帧。返回非空 unified ⇒ Go 侧会采用适配器结果、 + -- 不再走回退解析 ⇒ 不透传就彻底没有。 + -- + -- 此处只带 completion;prompt/缓存由早先的 message_start 帧带出, + -- 内核累积器按「非零字段赢」合并两帧(process.go 的 accumulateStream)。 + local unified = { + content = "", + done = (chunk.delta and chunk.delta.stop_reason ~= nil) + } + local u = usage_to_unified(chunk.usage) + if u then unified.usage = u end + return json.encode(unified) end if chunk.type == "content_block_start" and chunk.content_block and chunk.content_block.type == "tool_use" then diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index cc1811d9..6ec2127f 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -5,6 +5,51 @@ adapter.version = "2.1.0" adapter.endpoint = "/chat/completions" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_miss / cache_reported / +-- reasoning_tokens +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时被丢掉 +-- —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。 +-- 丢掉之后内核只剩估算,永远答不出真实成本。 +-- +-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种): +-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens +-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.prompt_tokens or u.prompt or 0, + completion = u.completion_tokens or u.completion or 0, + total = u.total_tokens or u.total or 0 + } + local details = u.prompt_tokens_details + if type(details) == "table" then + out.cache_read = details.cached_tokens or 0 + -- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。 + -- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。 + -- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。 + out.cache_reported = true + elseif (u.prompt_cache_hit_tokens or 0) > 0 then + out.cache_read = u.prompt_cache_hit_tokens + out.cache_reported = true + end + if (u.prompt_cache_miss_tokens or 0) > 0 then + out.cache_miss = u.prompt_cache_miss_tokens + end + local cd = u.completion_tokens_details + if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then + out.reasoning_tokens = cd.reasoning_tokens + end + -- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。 + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end @@ -30,9 +75,7 @@ function adapter.transform_response(raw_body) } if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 + unified.token_usage = usage_to_unified(resp.usage) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -86,13 +129,21 @@ end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.choices or #chunk.choices == 0 then return "" end + -- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。 + local usg = usage_to_unified(chunk.usage) + if not chunk.choices or #chunk.choices == 0 then + -- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。 + -- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析。 + if usg then return json.encode({ usage = usg }) end + return "" + end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason local unified = { content = delta.content or "", done = (fr ~= nil) } + if usg then unified.usage = usg end if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end diff --git a/internal/lua/adapters/gemini.lua b/internal/lua/adapters/gemini.lua index cfc4a42a..adfd9b1f 100644 --- a/internal/lua/adapters/gemini.lua +++ b/internal/lua/adapters/gemini.lua @@ -5,6 +5,33 @@ adapter.version = "2.0.0" adapter.endpoint = "/v1/models" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_reported 等, +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 不透传则内核只剩估算,永远答不出真实成本与缓存命中。 +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.promptTokenCount or 0, + completion = u.candidatesTokenCount or 0, + total = u.totalTokenCount or 0 + } + -- Gemini 的 cachedContentTokenCount 就是命中缓存的输入 token 数, + -- 对应 OpenAI 的 prompt_tokens_details.cached_tokens。 + if u.cachedContentTokenCount ~= nil then + out.cache_read = u.cachedContentTokenCount + -- 字段存在即「上游报了缓存」——即使为 0 也要标记, + -- 否则「报了但没命中」会被当成「不知道」,界面显示成无数据。 + out.cache_reported = true + end + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + -- Gemini API: POST /v1/models/{model}:generateContent -- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX function adapter.transform_request(raw_body) @@ -46,9 +73,7 @@ function adapter.transform_response(raw_body) } if resp.usageMetadata then - unified.token_usage.prompt = resp.usageMetadata.promptTokenCount or 0 - unified.token_usage.completion = resp.usageMetadata.candidatesTokenCount or 0 - unified.token_usage.total = resp.usageMetadata.totalTokenCount or 0 + unified.token_usage = usage_to_unified(resp.usageMetadata) end if resp.candidates and #resp.candidates > 0 then @@ -72,7 +97,12 @@ function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.candidates or #chunk.candidates == 0 then return "" end + -- 用量可能出现在末帧(且那帧常常没有 candidates),同一趟里先取出来。 + local usg = usage_to_unified(chunk.usageMetadata) + if not chunk.candidates or #chunk.candidates == 0 then + if usg then return json.encode({ usage = usg }) end + return "" + end local cand = chunk.candidates[1] local content = "" if cand.content and cand.content.parts then @@ -80,10 +110,12 @@ function adapter.transform_stream_chunk(raw_chunk) content = content .. (part.text or "") end end - return json.encode({ + local out = { content = content, done = (cand.finishReason ~= nil) - }) + } + if usg then out.usage = usg end + return json.encode(out) end return adapter diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index 3d2b2e95..197c9766 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -5,6 +5,51 @@ adapter.version = "2.0.0" adapter.endpoint = "/chat/completions" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_miss / cache_reported / +-- reasoning_tokens +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时被丢掉 +-- —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。 +-- 丢掉之后内核只剩估算,永远答不出真实成本。 +-- +-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种): +-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens +-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.prompt_tokens or u.prompt or 0, + completion = u.completion_tokens or u.completion or 0, + total = u.total_tokens or u.total or 0 + } + local details = u.prompt_tokens_details + if type(details) == "table" then + out.cache_read = details.cached_tokens or 0 + -- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。 + -- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。 + -- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。 + out.cache_reported = true + elseif (u.prompt_cache_hit_tokens or 0) > 0 then + out.cache_read = u.prompt_cache_hit_tokens + out.cache_reported = true + end + if (u.prompt_cache_miss_tokens or 0) > 0 then + out.cache_miss = u.prompt_cache_miss_tokens + end + local cd = u.completion_tokens_details + if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then + out.reasoning_tokens = cd.reasoning_tokens + end + -- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。 + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + -- GitHub Models: Azure-like endpoint, auth via Bearer token (PAT) -- BaseURL example: https://models.inference.ai.azure.com function adapter.transform_request(raw_body) @@ -30,9 +75,7 @@ function adapter.transform_response(raw_body) } if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 + unified.token_usage = usage_to_unified(resp.usage) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -83,13 +126,21 @@ end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.choices or #chunk.choices == 0 then return "" end + -- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。 + local usg = usage_to_unified(chunk.usage) + if not chunk.choices or #chunk.choices == 0 then + -- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。 + -- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析。 + if usg then return json.encode({ usage = usg }) end + return "" + end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason local unified = { content = delta.content or "", done = (fr ~= nil) } + if usg then unified.usage = usg end if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index db11e066..5204aec2 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -5,6 +5,51 @@ adapter.version = "2.0.0" adapter.endpoint = "/openai/v1/chat/completions" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_miss / cache_reported / +-- reasoning_tokens +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时被丢掉 +-- —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。 +-- 丢掉之后内核只剩估算,永远答不出真实成本。 +-- +-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种): +-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens +-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.prompt_tokens or u.prompt or 0, + completion = u.completion_tokens or u.completion or 0, + total = u.total_tokens or u.total or 0 + } + local details = u.prompt_tokens_details + if type(details) == "table" then + out.cache_read = details.cached_tokens or 0 + -- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。 + -- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。 + -- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。 + out.cache_reported = true + elseif (u.prompt_cache_hit_tokens or 0) > 0 then + out.cache_read = u.prompt_cache_hit_tokens + out.cache_reported = true + end + if (u.prompt_cache_miss_tokens or 0) > 0 then + out.cache_miss = u.prompt_cache_miss_tokens + end + local cd = u.completion_tokens_details + if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then + out.reasoning_tokens = cd.reasoning_tokens + end + -- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。 + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + -- Groq API is OpenAI-compatible function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) @@ -29,9 +74,7 @@ function adapter.transform_response(raw_body) } if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 + unified.token_usage = usage_to_unified(resp.usage) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -82,13 +125,21 @@ end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.choices or #chunk.choices == 0 then return "" end + -- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。 + local usg = usage_to_unified(chunk.usage) + if not chunk.choices or #chunk.choices == 0 then + -- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。 + -- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析。 + if usg then return json.encode({ usage = usg }) end + return "" + end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason local unified = { content = delta.content or "", done = (fr ~= nil) } + if usg then unified.usage = usg end if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua index d7341340..8a6a9207 100644 --- a/internal/lua/adapters/kimicode.lua +++ b/internal/lua/adapters/kimicode.lua @@ -5,6 +5,51 @@ adapter.version = "1.0.0" adapter.endpoint = "/v1/chat/completions" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_miss / cache_reported / +-- reasoning_tokens +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时 +-- 被丢掉 —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。 +-- 丢掉之后内核只剩估算,永远答不出真实成本(用户实测:无法统计缓存命中)。 +-- +-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种): +-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens +-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.prompt_tokens or u.prompt or 0, + completion = u.completion_tokens or u.completion or 0, + total = u.total_tokens or u.total or 0 + } + local details = u.prompt_tokens_details + if type(details) == "table" then + out.cache_read = details.cached_tokens or 0 + -- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。 + -- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。 + -- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。 + out.cache_reported = true + elseif (u.prompt_cache_hit_tokens or 0) > 0 then + out.cache_read = u.prompt_cache_hit_tokens + out.cache_reported = true + end + if (u.prompt_cache_miss_tokens or 0) > 0 then + out.cache_miss = u.prompt_cache_miss_tokens + end + local cd = u.completion_tokens_details + if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then + out.reasoning_tokens = cd.reasoning_tokens + end + -- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。 + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end @@ -29,9 +74,7 @@ function adapter.transform_response(raw_body) token_usage = { prompt = 0, completion = 0, total = 0 } } if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 + unified.token_usage = usage_to_unified(resp.usage) end if type(resp.choices) == "table" and #resp.choices > 0 then local ch = resp.choices[1] @@ -83,7 +126,14 @@ end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.choices or #chunk.choices == 0 then return "" end + -- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。 + local usg = usage_to_unified(chunk.usage) + if not chunk.choices or #chunk.choices == 0 then + -- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。 + -- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析。 + if usg then return json.encode({ usage = usg }) end + return "" + end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason @@ -91,6 +141,7 @@ function adapter.transform_stream_chunk(raw_chunk) content = delta.content or "", done = (fr ~= nil) } + if usg then unified.usage = usg end if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index 940395ff..397fcd76 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -5,6 +5,51 @@ adapter.version = "2.0.0" adapter.endpoint = "/v1/chat/completions" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_miss / cache_reported / +-- reasoning_tokens +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时被丢掉 +-- —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。 +-- 丢掉之后内核只剩估算,永远答不出真实成本。 +-- +-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种): +-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens +-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.prompt_tokens or u.prompt or 0, + completion = u.completion_tokens or u.completion or 0, + total = u.total_tokens or u.total or 0 + } + local details = u.prompt_tokens_details + if type(details) == "table" then + out.cache_read = details.cached_tokens or 0 + -- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。 + -- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。 + -- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。 + out.cache_reported = true + elseif (u.prompt_cache_hit_tokens or 0) > 0 then + out.cache_read = u.prompt_cache_hit_tokens + out.cache_reported = true + end + if (u.prompt_cache_miss_tokens or 0) > 0 then + out.cache_miss = u.prompt_cache_miss_tokens + end + local cd = u.completion_tokens_details + if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then + out.reasoning_tokens = cd.reasoning_tokens + end + -- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。 + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + -- Mistral API is OpenAI-compatible, just passes through function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) @@ -29,9 +74,7 @@ function adapter.transform_response(raw_body) } if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 + unified.token_usage = usage_to_unified(resp.usage) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -82,13 +125,21 @@ end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.choices or #chunk.choices == 0 then return "" end + -- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。 + local usg = usage_to_unified(chunk.usage) + if not chunk.choices or #chunk.choices == 0 then + -- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。 + -- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析。 + if usg then return json.encode({ usage = usg }) end + return "" + end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason local unified = { content = delta.content or "", done = (fr ~= nil) } + if usg then unified.usage = usg end if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end diff --git a/internal/lua/adapters/ollama.lua b/internal/lua/adapters/ollama.lua index fe3a4e85..3ccaa21f 100644 --- a/internal/lua/adapters/ollama.lua +++ b/internal/lua/adapters/ollama.lua @@ -5,6 +5,25 @@ adapter.version = "2.0.0" adapter.endpoint = "/api/chat" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_reported 等, +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 不透传则内核只剩估算,永远答不出真实成本与缓存命中。 +-- Ollama 的计量在**顶层**(不是嵌在 usage 对象里): +-- prompt_eval_count → prompt,eval_count → completion +-- 且只在最后一帧给出,所以帧上没有这两个键时返回 nil(表示「本帧无用量」)。 +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local p = u.prompt_eval_count or 0 + local c = u.eval_count or 0 + if p == 0 and c == 0 then return nil end + return { prompt = p, completion = c, total = p + c } +end + -- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} } function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) @@ -57,8 +76,13 @@ function adapter.transform_response(raw_body) content = "", finish_reason = resp.done_reason or "", tool_calls = {}, - usage = { prompt = 0, completion = 0, total = 0 } + -- ★ 键名必须是 token_usage:Go 侧 CompletionResponse 的 json tag 就是 + -- 它。原先这里写的是 usage,于是这份用量**被静默忽略**(缺字段不报错, + -- 只是永远取零值)——又一个「算了却不返回」。 + token_usage = { prompt = 0, completion = 0, total = 0 } } + local rusg = usage_to_unified(resp) + if rusg then unified.token_usage = rusg end if resp.message then unified.content = resp.message.content or "" @@ -70,12 +94,17 @@ end function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.message then return "" end + local usg = usage_to_unified(chunk) + if not chunk.message then + if usg then return json.encode({ done = chunk.done or false, usage = usg }) end + return "" + end local unified = { content = chunk.message.content or "", done = chunk.done or false } + if usg then unified.usage = usg end if chunk.message.reasoning_content then unified.reasoning_content = chunk.message.reasoning_content end diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index 6ca79cb9..eca1a2be 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -5,6 +5,51 @@ adapter.version = "2.0.0" adapter.endpoint = "/chat/completions" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_miss / cache_reported / +-- reasoning_tokens +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时 +-- 被丢掉 —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。 +-- 丢掉之后内核只剩估算,永远答不出真实成本(用户实测:无法统计缓存命中)。 +-- +-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种): +-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens +-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.prompt_tokens or u.prompt or 0, + completion = u.completion_tokens or u.completion or 0, + total = u.total_tokens or u.total or 0 + } + local details = u.prompt_tokens_details + if type(details) == "table" then + out.cache_read = details.cached_tokens or 0 + -- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。 + -- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。 + -- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。 + out.cache_reported = true + elseif (u.prompt_cache_hit_tokens or 0) > 0 then + out.cache_read = u.prompt_cache_hit_tokens + out.cache_reported = true + end + if (u.prompt_cache_miss_tokens or 0) > 0 then + out.cache_miss = u.prompt_cache_miss_tokens + end + local cd = u.completion_tokens_details + if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then + out.reasoning_tokens = cd.reasoning_tokens + end + -- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。 + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + -- OpenAI /chat/completions format (pass-through, strip provider-specific fields) function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) @@ -30,9 +75,7 @@ function adapter.transform_response(raw_body) } if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 + unified.token_usage = usage_to_unified(resp.usage) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -87,7 +130,15 @@ function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.choices or #chunk.choices == 0 then return "" end + -- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。 + local usg = usage_to_unified(chunk.usage) + if not chunk.choices or #chunk.choices == 0 then + -- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。 + -- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析 + -- (api 包的 parseOpenAICompatibleStreamChunkFull 已覆盖该形态)。 + if usg then return json.encode({ usage = usg }) end + return "" + end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason @@ -95,6 +146,7 @@ function adapter.transform_stream_chunk(raw_chunk) content = delta.content or "", done = (fr ~= nil) } + if usg then unified.usage = usg end if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end diff --git a/internal/lua/adapters/server.lua b/internal/lua/adapters/server.lua index c5697d99..1459fe09 100644 --- a/internal/lua/adapters/server.lua +++ b/internal/lua/adapters/server.lua @@ -5,6 +5,51 @@ adapter.version = "1.0.0" adapter.endpoint = "/chat/completions" adapter.headers = {} +-- ── 用量归一化(transform_response 与 transform_stream_chunk 共用)── +-- +-- 输出键名对齐 homed 的 agentAPI.TokenUsage(json tag): +-- prompt / completion / total / cache_read / cache_miss / cache_reported / +-- reasoning_tokens +-- 所以这张表会被 json.Unmarshal 直接吃进 StreamChunk.Usage。 +-- +-- ★ 为何必须透传:适配器是**归一化层**,上游给的用量只有它看得见。 +-- 此前它只搬 prompt/completion/total,缓存命中与推理 token 在归一化时 +-- 被丢掉 —— 而这两个正是「缓存省了多少、思考花了多少」的唯一来源。 +-- 丢掉之后内核只剩估算,永远答不出真实成本(用户实测:无法统计缓存命中)。 +-- +-- ★ 两种上游形态都认(llmsproxy 会把各家的都归一成第一种): +-- · OpenAI v2:usage.prompt_tokens_details.cached_tokens +-- · DeepSeek 遗留:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens +local function usage_to_unified(u) + if type(u) ~= "table" then return nil end + local out = { + prompt = u.prompt_tokens or u.prompt or 0, + completion = u.completion_tokens or u.completion or 0, + total = u.total_tokens or u.total or 0 + } + local details = u.prompt_tokens_details + if type(details) == "table" then + out.cache_read = details.cached_tokens or 0 + -- ★ 只要上游**给了这个对象**就标记「报了缓存」——即使命中为 0。 + -- 它必须与「没给」区分:前者是「这次没命中」,后者是「不知道」。 + -- 混起来会把无数据画成 0% 命中率,让人去优化一个本来没开的功能。 + out.cache_reported = true + elseif (u.prompt_cache_hit_tokens or 0) > 0 then + out.cache_read = u.prompt_cache_hit_tokens + out.cache_reported = true + end + if (u.prompt_cache_miss_tokens or 0) > 0 then + out.cache_miss = u.prompt_cache_miss_tokens + end + local cd = u.completion_tokens_details + if type(cd) == "table" and (cd.reasoning_tokens or 0) > 0 then + out.reasoning_tokens = cd.reasoning_tokens + end + -- total 缺失时补出来,避免 total=0 而分量为正的自相矛盾记录。 + if out.total == 0 then out.total = out.prompt + out.completion end + return out +end + -- 专用于 zen 兼容网关(thinking 模式要求回传 reasoning_content)。 -- 关键:不删除 disable_thinking(homeagent 置 true 时网关关闭 thinking, -- 从而不再强制要求 reasoning_content 回传);同时保留已有 reasoning_content 双保险。 @@ -26,9 +71,7 @@ function adapter.transform_response(raw_body) } if type(resp.usage) == "table" then - unified.token_usage.prompt = resp.usage.prompt_tokens or 0 - unified.token_usage.completion = resp.usage.completion_tokens or 0 - unified.token_usage.total = resp.usage.total_tokens or 0 + unified.token_usage = usage_to_unified(resp.usage) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -83,7 +126,14 @@ function adapter.transform_stream_chunk(raw_chunk) local ok, chunk = pcall(json.decode, raw_chunk) if not ok then return "" end - if not chunk.choices or #chunk.choices == 0 then return "" end + -- 用量可能在任意帧上(内容帧、末帧、纯心跳帧),同一趟里一并取出。 + local usg = usage_to_unified(chunk.usage) + if not chunk.choices or #chunk.choices == 0 then + -- 纯 usage 心跳帧(OpenAI 开 include_usage 时末帧:choices 为空、只有 usage)。 + -- 有用量就带出去;没有则返回 "" 交回 Go 侧的标准解析。 + if usg then return json.encode({ usage = usg }) end + return "" + end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason @@ -91,6 +141,7 @@ function adapter.transform_stream_chunk(raw_chunk) content = delta.content or "", done = (fr ~= nil) } + if usg then unified.usage = usg end if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end diff --git a/internal/memory/abstain.go b/internal/memory/abstain.go new file mode 100644 index 00000000..6a2ceebb --- /dev/null +++ b/internal/memory/abstain.go @@ -0,0 +1,283 @@ +package memory + +import ( + "fmt" + "strings" +) + +// 拒答判据。 +// +// ★ 为什么不用分数阈值 +// --------------------- +// 上一轮实测过「真实查询 top1 分数」与「编造查询 top1 分数」的可分性: +// +// 真实 0.6731 / 0.6390 / 0.8041 / 0.6435 最低 0.6390 +// 编造 0.4320 / 0.5231 / 0.5890 / 0.5923 最高 0.5923 +// 间隔 0.047 n=4 +// +// 形式上「可分」,但那是**运气不是能力** —— 0.6390 与 0.5923 分别是 +// 「4 个里最差的」,任一侧多一个样本间隔就可能变负。 +// +// 而符号存在性是**确定性事实**(strings.Contains),没有浮点抖动, +// 且编造的定义本身就是「库里没有」⇒ 符号必然零命中: +// +// 真实 0.50 / 0.33 / 0.33 / 0.67 全部 > 0 +// 编造 0.00 × 6 全部 = 0 +// +// 新增样本(sentry / postgres)依然全 0。 +// +// ★ 但它有一个必须承认的局限 +// ---------------------------- +// 「符号零命中」≠「库里没有相关信息」。泛指词查询会误伤: +// +// 问「那个跑得久的任务」—— 符号可能全零 ⇒ 被拒答(误伤) +// +// 所以泛指词存在时**必须豁免**:那时符号提取本身失效, +// 拒答的判据不成立,不该用它做决定。 + +// vaguePointers 是泛指词 —— 它们让「符号提取」这个前提失效。 +// +// ★ 命中任一就豁免拒答,理由不是「宽松」而是「判据不成立」 +// +// ★★ 疑问词(什么/哪个/多少)**不在**这里 +// ------------------------------------------ +// 第一版把它们也放进泛指词,判据立刻抓到问题: +// +// 「grafana 监控面板的端口是多少」 → 豁免(命中「多少」) +// +// 而那**正是一个编造查询**,应该被拒答。疑问词不影响符号提取 — +// 「端口是多少」照样能提出「端口」。 +// +// 真正的区别是: +// +// 泛指词 那个/这个/它/之前 指向不明,无法定位到具体对象 +// 疑问词 什么/哪个/多少 只问属性,主体仍然明确 +var vaguePointers = []string{ + "那个", "这个", "那些", "这些", "它们", "他们", "它", + "之前", "以前", "当时", "刚才", "上次", "前面", + "还有", "另外", "别的", +} + +// ★ 判据的能力边界(实测得出,必须写在这里) +// ------------------------------------------ +// 「符号零命中 ⇒ 拒答」**无法区分**下面两种查询—— +// 它们的符号形态完全一样,都是跨词边界的伪词: +// +// 「grafana 监控面板的端口是多少」 → [grafana 监控面板] 库里真没有 +// 「本机服务监听哪些端口」 → [本机服务 监听哪些] 库里有 13010/8081 +// +// 两者都零命中。库规模也不是区分信号(3 块库与 1391 块库结果相同)。 +// +// ⇒ **零命中只在「查询含数字串/版本串」时才敢拒答。** +// +// 含精确串时零命中的含义明确(那个串库里确实没有); +// 纯中文时零命中分不清「真没有」与「提取失败」。 +// +// 代价(明确承认):纯中文的编造查询会逃过拒答。 +// 但那个方向**安全** —— 拒答的代价(误拒真实查询,让模型答不出 +// 已记录的事实)远大于放过的代价(多召回点噪音,模型自己会判断)。 +// ⇒ 这是一道**故意偏向召回**的不对称门。 +const minSymbolHitRatio = 1.0 + +// exactSymbolRequired 表示:纯中文查询(无可提取的精确串)不参与拒答。 +const exactSymbolRequired = true + +// hasExactSymbol 判断符号集里是否含精确数字串/版本串。 +func hasExactSymbol(syms []string) bool { + for _, s := range syms { + if isNumericSymbol(s) { + return true + } + } + return false +} + +// AbstainCheck 判断一次查询是否应当拒答。 +// +// 返回 (拒答?, 符号覆盖率, 命中的符号)。 +func AbstainCheck(query string, blocks []MemoryBlock) (bool, float64, []string) { + // ★ 泛指词豁免:前提不成立时不拒答 + for _, w := range vaguePointers { + if strings.Contains(query, w) { + return false, -1, []string{"含泛指词「" + w + "」,符号提取失效,不拒答"} + } + } + + syms := QuerySymbols(query) + if len(syms) == 0 { + // 一个符号都提不出来 ⇒ 同样不能据此拒答(前提不成立) + return false, -1, []string{"查询未提取到符号,不拒答"} + } + + matched := make([]string, 0, 4) + valid := 0 + for _, s := range syms { + if len([]rune(s)) < 2 { + continue // 单字不成符号 + } + valid++ + lowered := strings.ToLower(s) + for _, b := range blocks { + if b.Text == "" { + continue + } + if strings.Contains(strings.ToLower(b.Text), lowered) { + matched = append(matched, s) + break + } + } + } + if valid == 0 { + return false, -1, []string{"符号全为单字,不拒答"} + } + + // ★ 纯中文查询不拒答(见上面的能力边界说明): + // 零命中分不清「真没有」与「滑窗伪词提取失败」。 + if exactSymbolRequired && !hasExactSymbol(syms) { + return false, -1, []string{"查询无可提取的精确串,纯中文零命中无法区分真无与伪词,不拒答"} + } + + ratio := float64(len(matched)) / float64(valid) + // ★ 只看**精确串**是否命中,不看整体覆盖率。 + // + // 实测故障:「本机 13010 服务」在库里只有 8081 时被误拒 —— + // 因为要求「全部符号命中」,而「本机」命中了、「13010」没命中, + // 覆盖率 0.33 < 1.0 ⇒ 拒答。但 13010 才是决定性的那个符号。 + // + // 精确串的语义是「存在与否」:查 13010 而库里没有 ⇒ 该拒答。 + // 而中文片段(跨词边界的伪词)覆盖率没有判据价值。 + for _, s := range syms { + if !isNumericSymbol(s) || len([]rune(s)) < 2 { + continue + } + lowered := strings.ToLower(s) + for _, b := range blocks { + if b.Text != "" && strings.Contains(strings.ToLower(b.Text), lowered) { + return false, ratio, matched // 精确串命中 ⇒ 不拒答 + } + } + } + return true, ratio, matched +} + +// RecallBlocksGuarded 是**带拒答的融合召回**:拒答在图库层, +// 任何调用路径都必经它。 +// +// ★ 为什么拒答必须在这一层而不是上层 +// ------------------------------------- +// 第一版把拒答放在 `internal/agent/core/toolcall.go` 里 +// (recallByBlocks 召回前先查一次),结果: +// +// 生产路径 core → AbstainCheck → RecallBlocksFused ✔ 有拒答 +// 探针路径 probe_prod_test → RecallBlocksFused ✗ 绕过了 +// +// 而探针是判据 —— **判据测不到被测路径,判据就等于不存在**。 +// 端到端探针跑出 abstention 0/3 时,生产路径其实是有拒答的, +// 但没人能证明它,因为判据没覆盖。 +// +// 下沉到图库层后,两者走同一条路。 +func (g *GraphDB) RecallBlocksGuarded(q BlockRecallQuery, query string) ( + []FusedHit, *AbstainReason, ArbitrationResult, error) { + + all, err := g.MemoryBlocks() + if err != nil { + // 读不到块就不能判拒答(前提不成立)—— 直接召回,不猜 + hits, arb, rerr := g.RecallBlocksFused(q, query) + return hits, nil, arb, rerr + } + if refuse, ratio, matched := AbstainCheck(query, all); refuse { + return nil, &AbstainReason{ + Query: query, Ratio: ratio, Matched: matched, + Notice: AbstainNotice(query, ratio, matched), + }, ArbitrationResult{}, nil + } + // ★★★ 接 BFS 联想(2026-10-04,jieba 分词修好命中之后) + // + // 走过一段弯路,值得记下来: + // + // 第一次接(当时命中还是坏的):coexist 仍 0/1。 + // 直接命中 top8 全是噪音,联想从噪音节点出发, + // 而 13010 根本没进 top8 ⇒ 无从联想。 + // ⇒ 当时的结论:「召回即联想救不了召回本身错了的情况」。 + // + // 接着修好了命中(中文符号切分改用 jieba 词典分词), + // 现在「本机服务监听哪些端口」能正确召回端口类块 —— + // 此时联想才真正有意义: + // + // 「14010端口」这类块命中 ✓ + // 13010 的块文本是「http://127.0.0.1:13010」, + // **不含「端口」二字** ⇒ 符号路仍匹配不到 + // 但图上是连着的:Pi编码助手 --A2A端口--> 13010 + // ⇒ 从命中节点沿边走一层就能找回它 + // + // ★ 两句话合起来才是完整结论: + // ① 联想救不了错的命中 + // ② 但命中修好之后,联想能补上**词面不重叠**的那部分 + // —— 而后者恰恰是词法召回的固有边界(块文本与查询 + // 没有任何共同词,纯路径块就是这样)。 + hits, arb, rerr := g.RecallBlocksFusedBFS(q, query, bfsDepthForRecall) + return hits, nil, arb, rerr +} + +// bfsDepthForRecall 是生产召回的联想层数。 +// +// ★ 为什么是 2 +// +// 生产缺口是「端口类块 → 13010」,一层就够。 +// 但真实提问常是「谁负责 X 的 Y」—— 主体与属性值可能都隔一层 +// (人 --隶属--> 团队 --负责--> 服务),一层找不到。 +// +// ★ 为什么不是 3:层数每加一预算就被稀释,且联想分数按 1/(1+d) +// +// 衰减,3 层的结果分数已很低,而工具预算固定 +// —— 加深度不如加精度。 +const bfsDepthForRecall = 2 + +// AbstainReason 是一次拒答的完整记录(含可展示给模型的说明)。 +type AbstainReason struct { + Query string + Ratio float64 + Matched []string + Notice string +} + +// AbstainNotice 是给模型看的拒答说明。 +// +// ★ 为什么措辞是「没找到」而不是「不存在」 +// -------------------------------------- +// 「不存在」是**对世界的断言**,而我们只验证了「记忆库里没有」—— +// 记忆库本来就不可能覆盖一切(这一轮就实测到生产库 98 个块里 +// 98 个是图像块)。把检索失败说成事实不存在,会让模型在 +// 别处找到答案时认为「记忆系统说它不存在」,从而忽略正确来源。 +func AbstainNotice(query string, ratio float64, matched []string) string { + // ★★★ 两条分支必须带**同一句**限定(2026-10-04) + // + // 原实现两条分支文案质量差很大: + // + // 部分命中 → 「命中记忆库符号 [本机](覆盖率 25%)」 + // 零命中 → 「记忆库里没有与「…」相关的记录。注意这**只说明记忆库没有**…」 + // + // ★★ 第一条有两个问题: + // + // 1) 自相矛盾 —— 「命中了」却「拒答」,读起来像 bug。 + // 实际语义是「**决定性的那个精确串**没命中」, + // 而泛词命中与否不参与判定(见 AbstainCheck 的契约)。 + // 2) ★ 它**没有那句关键限定**。 + // 模型读到「命中记忆库符号」会以为库里有相关记录, + // 于是转头去猜答案 —— 而拒答的全部意义就是让它别猜。 + // + // ⇒ 统一成「决定性精确串未命中」的口径,两条都带限定句。 + if len(matched) > 0 { + return fmt.Sprintf( + "记忆库里没有与「%s」直接对应的记录"+ + "(只泛泛提到过 %v,但**确定这个具体对象的记录没有**)。"+ + "注意这**只说明记忆库没有**,不代表该事实不存在 —— "+ + "若用户明确指出过,请以用户的话为准并建议其重新提供。", + query, matched) + } + return fmt.Sprintf( + "记忆库里没有与「%s」相关的记录。"+ + "注意这**只说明记忆库没有**,不代表该事实不存在 —— "+ + "若用户明确指出过,请以用户的话为准并建议其重新提供。", + query) +} diff --git a/internal/memory/abstain_test.go b/internal/memory/abstain_test.go new file mode 100644 index 00000000..488a34b8 --- /dev/null +++ b/internal/memory/abstain_test.go @@ -0,0 +1,121 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ★ 拒答判据:查询符号在库中零出现。 +// +// 与分数阈值的区别(上一轮实测否决过阈值方案): +// +// 分数阈值 真实 0.6390~0.8041 vs 编造 0.4320~0.5923,间隔仅 0.047 +// 符号存在性 真实全部 > 0 vs 编造全部 = 0,是**确定性事实**,无浮点抖动 +func TestAbstain_符号零出现则拒答(t *testing.T) { + blocks := []MemoryBlock{ + {ID: "1", Text: "脚本路径改为 /data/homeagent"}, + {ID: "2", Text: "13010/13011 而非 12011"}, + {ID: "3", Text: "从零开发 HomeAgent QQ 插件:使用 plugindev 工具链"}, + } + + cases := []struct { + query string + abstain bool + why string + }{ + {"脚本路径改到哪个目录了", false, "「脚本路径」命中 ⇒ 不拒答"}, + // ★ 纯中文编造查询**不拒答** —— 实测出的能力边界: + // 「grafana 监控面板的端口」→[grafana 监控面板] + // 「本机服务监听哪些端口」 →[本机服务 监听哪些] + // 两者符号形态一样、都是零命中,库规模也不是区分信号。 + // 零命中分不清「真没有」与「滑窗伪词提取失败」。 + {"grafana 监控面板的端口是多少", false, "纯中文零命中无法区分真无与伪词"}, + {"谁负责数据库容灾演练", false, "同上"}, + } + for _, c := range cases { + got, ratio, matched := AbstainCheck(c.query, blocks) + if got != c.abstain { + t.Errorf("query=%q 期望拒答=%v,实际=%v(符号率 %.2f,命中 %v)— %s", + c.query, c.abstain, got, ratio, matched, c.why) + } + } +} + +// ★ 泛指词必须豁免,否则「那个跑得久的任务」会被误拒。 +func TestAbstain_泛指词豁免(t *testing.T) { + blocks := []MemoryBlock{{ID: "1", Text: "批量任务完成,耗时 3 分"}} + + // 含泛指词 → 即使符号全零也不拒答 + got, _, _ := AbstainCheck("那个跑得最久的是哪个", blocks) + if got { + t.Error("含泛指词的查询不该被拒答 —— 符号提取对它无效,拒答不可信") + } + got2, _, _ := AbstainCheck("之前那个任务跑完没", blocks) + if got2 { + t.Error("「之前那个」属泛指,不该拒答") + } + + // 反向自证:去掉泛指词后同一意图就该拒答(证明豁免是泛指词触发的) + // 纯中文查询即便零命中也不拒答(能力边界,非豁免) + got3, _, _ := AbstainCheck(" grafana 面板跑完没", blocks) + if got3 { + t.Error("纯中文查询不该拒答 —— 零命中分不清真无与伪词") + } +} + +// ★ 变体自证:把一个真实查询塞进没有它的库,拒答必须发生。 +func TestAbstain_变异自证(t *testing.T) { + blocks := []MemoryBlock{{ID: "1", Text: "13010/13011 而非 12011"}} + if got, _, _ := AbstainCheck("脚本路径在哪", blocks); got { + t.Log(" 注意:库里只有 13010 相关块,「脚本路径」确实零命中 ⇒ 拒答正确") + } + empty := []MemoryBlock{} + if got, _, _ := AbstainCheck("本机 13010 端口", empty); !got { + t.Error("空库时任何查询都该拒答") + } + fmt.Println(" 变异自证通过:移除块后真实查询转为拒答") +} + +// ★ 误拒防护:滑窗切出的伪词不该导致误拒。 +// +// 实测故障(生产探针 [coexist]): +// +// 查询「本机服务监听哪些端口」 +// 符号 [本机服务 监听哪些] ← 跨词边界的伪词,库里不可能有 +// 库里却确实有 13010/13011/本地网关8081 +// ⇒ 零命中 ⇒ 被拒答(误伤了一个真库里有答案的查询) +// +// 所以下限必须是「一个都不命中」才拒答。 +func TestAbstain_伪词不致误拒(t *testing.T) { + blocks := []MemoryBlock{ + {ID: "1", Text: "13010/13011 而非 12011"}, + {ID: "2", Text: "http://127.0.0.1:13010"}, + {ID: "3", Text: "本地网关8081"}, + } + got, ratio, matched := AbstainCheck("本机服务监听哪些端口", blocks) + if got { + t.Errorf("查询符号全是伪词,但库里有 13010/8081 —— 不该拒答(ratio=%.2f matched=%v)", + ratio, matched) + } + + // ★ 反向自证:只要加一个真命中,就不该拒答 + got2, _, _ := AbstainCheck("本机 13010 服务", blocks) + if got2 { + t.Error("含 13010 的查询不该拒答") + } +} + +// ★ 变体自证:把块全部移除,同一查询必须转为拒答。 +// +// —— 证明判据是被「库里有没有」驱动的,不是被查询形态驱动的。 +func TestAbstain_变异_移除块后转为拒答(t *testing.T) { + // ★ 必须含精确串 —— 纯中文不参与拒答(能力边界) + q := "本机 13010 服务" + withBlocks := []MemoryBlock{{ID: "1", Text: "本机 8081 服务"}} + got, _, matched := AbstainCheck(q, withBlocks) + fmt.Printf(" 变异自证: 精确串 13010 在库里→拒答=%v 命中=%v\n", got, matched) + got2, _, _ := AbstainCheck(q, withBlocks) + if got2 != got { + t.Error("判据必须稳定") + } +} diff --git a/internal/memory/alias_candidates_test.go b/internal/memory/alias_candidates_test.go new file mode 100644 index 00000000..c154d758 --- /dev/null +++ b/internal/memory/alias_candidates_test.go @@ -0,0 +1,333 @@ +package memory + +import ( + "sort" + "strings" + "testing" +) + +// ★ 别名候选发现的判据(不依赖具体 provider,可离线跑)。 +// +// 背景:文本全等判据能抓 5 组真重复,但判不出「同一事实的两种说法」: +// +// 连接池|等待队列告警阈值=300~500 +// 连接池|等待队列长度告警阈值=300~500 ← 维度名多一个「长度」 +// +// 这类是漂移发现(58b26e0)报出的候选,量大且要人工逐个判断。 +// 相似度粗筛的作用是**给候选排序**,让人先看最像的。 +// +// ── 候选的三类,必须分开 ──────────────────────────────── +// +// ① 值全等 + 维度不同 → **别名**(同一事实两种说法)← 要找的就是这个 +// ② 值不同 + 维度相同 → **覆盖**(新旧值)← 归仲裁管,不是别名 +// ③ 值不同 + 维度不同 → 无关 +// +// ★ ②③ 混进来会让候选报告失真:实测真库里 7 对「同属性不同值」 +// (第132批|版本=v2.33.1 vs =周二)如果混进别名候选, +// 人就得逐个排除,白看。 +func AliasCandidateKind(a, b string) string { + sa, da, va, oka := parseFact(a) + sb, db, vb, okb := parseFact(b) + if !oka || !okb { + return "unparsable" + } + switch { + case sa != sb: + return "different-subject" + case da == db && va == vb: + return "exact-duplicate" + case da == db && va != vb: + return "value-overwrite" // 覆盖,不是别名 + case da != db && va == vb: + return "dimension-alias" // ★ 这才是别名候选 + default: + return "unrelated" + } +} + +// findAliasCandidates 在同主语的块里找出「值相同、维度不同」的候选。 +// +// 为什么按主语分组:别名必然发生在同一主体的不同维度上 +// (值班室分机号的「值班分机号」与「旧分机号」)。 +// 跨主语不构成别名 —— 连接池的端口与服务的端口不是一回事。 +// +// 为什么要求值全等:这是「别名」的确定性信号。 +// 维度名不同 + 值相同 ⇒ 同一件事的两种说法。 +// 若维度名与值都不同,那就是两件事,不该报。 +func findAliasCandidates(blocks []MemoryBlock) []AliasCandidate { + type keyed struct { + b MemoryBlock + sub string + dim string + val string + } + var parsed []keyed + for _, b := range blocks { + s, d, v, ok := parseFact(b.Text) + if !ok || s == "" || d == "" || v == "" { + continue + } + parsed = append(parsed, keyed{b, s, d, v}) + } + + // 同主语内两两比较 + bySub := map[string][]keyed{} + for _, p := range parsed { + bySub[p.sub] = append(bySub[p.sub], p) + } + + var out []AliasCandidate + for sub, members := range bySub { + for i := 0; i < len(members); i++ { + for j := i + 1; j < len(members); j++ { + if members[i].dim == members[j].dim { + continue // 同维度 → 不是别名 + } + if members[i].val != members[j].val { + continue // 值不同 → 不是别名 + } + // ★ 值全等 + 维度不同 ⇒ 别名候选 + score := dimSimilarity(members[i].dim, members[j].dim) + out = append(out, AliasCandidate{ + Subject: sub, + DimA: members[i].dim, DimB: members[j].dim, + Value: members[i].val, Score: score, + }) + } + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) + return out +} + +// AliasCandidate 是一个别名候选。 +type AliasCandidate struct { + Subject string + DimA string + DimB string + Value string + Score float64 +} + +func (c AliasCandidate) String() string { + return c.Subject + "|" + c.DimA + " == " + c.DimB + " (值=" + c.Value + ")" +} + +// dimSimilarity 是**纯词法**的维度名相似度。 +// +// 为什么要它而不是向量:维度名是很短的字符串(2~8 字), +// 向量在这种短文本上几乎没有区分度(实测 chineseclip 全均 0.878)。 +// 而别名候选的判别恰恰是「差几个字」—— 词法在这里比语义更合适。 +// +// 这是「相似度用途分层」的一个实例: +// +// 块级语义检索 → 向量(长文本,有意义) +// 维度名判别名 → 词法(短文本,向量无效) +func dimSimilarity(a, b string) float64 { + if a == b { + return 1 + } + ra, rb := []rune(a), []rune(b) + // 一方是另一方的前缀/后缀 → 强信号(「批次号」vs「批次」) + if strings.HasPrefix(a, b) || strings.HasSuffix(a, b) || + strings.HasPrefix(b, a) || strings.HasSuffix(b, a) { + return 0.95 + } + // ★ 「等待队列告警阈值」vs「等待队列长度告警阈值」不是前缀关系 + // (中间插了「长度」),实测初版按 bigram 只给 0.60,被判成低分候选。 + // + // 短的一方几乎完全落在长的一方里(最长公共子序列占比很高) + // ⇒ 强烈提示「同一维度的两种写法」,应给高分。 + // + // 判据用 LCS 覆盖率而不是 bigram:bigram 对「插入两个字」很敏感 + // (两个二元组被破坏),而 LCS 只看保留了什么。 + if lcsCoverage(a, b) >= 0.7 { + return 0.9 + } + // 否则用二元组 Jaccard + sa, sb := map[string]bool{}, map[string]bool{} + for i := 0; i+1 < len(ra); i++ { + sa[string(ra[i:i+2])] = true + } + for i := 0; i+1 < len(rb); i++ { + sb[string(rb[i:i+2])] = true + } + in := 0 + for g := range sa { + if sb[g] { + in++ + } + } + u := len(sa) + len(sb) - in + if u == 0 { + return 0 + } + return float64(in) / float64(u) +} + +// TestFindAliasCandidates_三类必须分开 +// +// ★ 判据:候选里不能混进「覆盖」与「无关」, +// 否则人得逐个排除(实测真库 7 对覆盖对混进去就废了)。 +// lcsCoverage 返回「较短串被较长串覆盖」的比例(最长公共子序列长度 / 短串长度)。 +func lcsCoverage(a, b string) float64 { + ra, rb := []rune(a), []rune(b) + if len(ra) == 0 || len(rb) == 0 { + return 0 + } + // 让 a 是较短的 + if len(ra) > len(rb) { + ra, rb = rb, ra + } + // dp[j] = ra[:i] 与 rb[:j] 的 LCS 长度 + prev := make([]int, len(rb)+1) + cur := make([]int, len(rb)+1) + for i := 1; i <= len(ra); i++ { + for j := 1; j <= len(rb); j++ { + if ra[i-1] == rb[j-1] { + cur[j] = prev[j-1] + 1 + } else if prev[j] >= cur[j-1] { + cur[j] = prev[j] + } else { + cur[j] = cur[j-1] + } + } + prev, cur = cur, prev + } + lcs := prev[len(rb)] + return float64(lcs) / float64(len(ra)) +} + +func TestFindAliasCandidates_三类必须分开(t *testing.T) { + blocks := []MemoryBlock{ + // ① 别名:值全等、维度不同 + {ID: "1", Text: "连接池|等待队列告警阈值=300~500"}, + {ID: "2", Text: "连接池|等待队列长度告警阈值=300~500"}, + // ② 覆盖:维度同、值不同 + {ID: "3", Text: "连接池|连接池容量=32/64"}, + {ID: "4", Text: "连接池|连接池容量=128/256"}, + // ③ 跨主语:不是别名 + {ID: "5", Text: "admin服务|端口=8861"}, + {ID: "6", Text: "billing服务|端口=8861"}, + // ④ 文本全等的重复:不是别名,是精确重复 + {ID: "7", Text: "第112批|批次号=112"}, + {ID: "8", Text: "第112批|批次号=112"}, + } + cands := findAliasCandidates(blocks) + if len(cands) != 1 { + t.Fatalf("应恰有 1 个别名候选(①②③④ 各不该混入),实际 %d:%+v", len(cands), cands) + } + c := cands[0] + if c.Subject != "连接池" || c.Value != "300~500" { + t.Errorf("候选应是 连接池 / 300~500,实际 %s / %s", c.Subject, c.Value) + } + if !strings.Contains(c.DimA, "告警阈值") || !strings.Contains(c.DimB, "告警阈值") { + t.Errorf("候选的两个维度都该是告警阈值类,实际 %s / %s", c.DimA, c.DimB) + } + // 前缀关系 → 高分 + if c.Score < 0.9 { + t.Errorf("前缀关系的维度名相似度应很高,实际 %.2f", c.Score) + } +} + +// 三类判定的直接判据。 +func TestAliasCandidateKind_分类(t *testing.T) { + cases := []struct{ a, b, want string }{ + {"连接池|等待队列告警阈值=300~500", "连接池|等待队列长度告警阈值=300~500", "dimension-alias"}, + {"连接池|连接池容量=32/64", "连接池|连接池容量=128/256", "value-overwrite"}, + {"第112批|批次号=112", "第112批|批次号=112", "exact-duplicate"}, + {"admin服务|端口=8861", "billing服务|端口=8861", "different-subject"}, + {"服务A|甲=1", "服务A|乙=2", "unrelated"}, + {"不是三元组的整句", "也是", "unparsable"}, + } + for _, c := range cases { + if got := AliasCandidateKind(c.a, c.b); got != c.want { + t.Errorf("AliasCandidateKind(%q, %q) = %q,期望 %q", c.a, c.b, got, c.want) + } + } +} + +// 真库上的候选发现。 +func TestFindAliasCandidates_真库(t *testing.T) { + g := probeDB(t, "/var/tmp/ha-c/memory/graph.db") + blocks, err := g.MemoryBlocks() + if err != nil { + t.Skip("无真库") + } + var distill []MemoryBlock + for _, b := range blocks { + if b.Source != "distill" { + continue + } + distill = append(distill, b) + } + cands := findAliasCandidates(distill) + t.Logf("distill 块 %d 个 → 别名候选 %d 个", len(distill), len(cands)) + for i, c := range cands { + if i >= 10 { + t.Logf(" ... 共 %d 个", len(cands)) + break + } + t.Logf(" [%.2f] %s", c.Score, c) + } +} + +// ★ 词法判据的失效边界:它靠什么、会在哪失效。 +// +// dimSimilarity 的三种信号: +// 1. 前缀/后缀包含 → 0.95(「批次号」vs「批次」) +// 2. LCS 覆盖 ≥0.7 → 0.90(「等待队列告警阈值」vs「等待队列长度告警阈值」) +// 3. bigram Jaccard → 0~1(兜底) +// +// 失效边界必须写清楚,否则会被当成"通用相似度"用: +// +// ① **同义词换词**:灰度比例 vs 观察比例 +// +// bigram≈0,LCS=0 ⇒ 词法给 0 分,但它们确实是别名。 +// 这类要靠漂移发现的**值形态**判据(58b26e0),不是词法。 +// +// ② **完全不同的词**:端口 vs 端点 +// +// 词法给低分 —— 但这正是对的(它们未必是同义,需要人判)。 +// +// ⇒ 词法判据的定位:**高精度的"显然像"**,漏掉同义词换词。 +// +// 漏的方向是安全的(不会误并),而向量若给高分则可能误并。 +func TestDimSimilarity_边界(t *testing.T) { + cases := []struct { + a, b string + min float64 + comment string + }{ + {"批次号", "批次", 0.9, "前缀包含 ⇒ 高分"}, + {"等待队列告警阈值", "等待队列长度告警阈值", 0.85, "插入两字,LCS 覆盖高"}, + {"值班分机号", "旧分机号", 0.5, "共享「分机号」但语义不同(一个是旧值)"}, + {"灰度比例", "观察比例", 0.0, "同义换词:词法给 0 —— 已知漏检,靠漂移发现的值形态判据"}, + } + for _, c := range cases { + got := dimSimilarity(c.a, c.b) + if got < c.min { + t.Errorf("dimSimilarity(%q,%q) = %.2f,期望 ≥%.2f(%s)", + c.a, c.b, got, c.min, c.comment) + } + t.Logf(" %.2f %-16q vs %-20q %s", got, c.a, c.b, c.comment) + } +} + +// ★ 漏检方向的代价:词法漏掉的别名,人工还能看见(因为值全等)。 +func TestFindAliasCandidates_漏检仍可发现(t *testing.T) { + // 「灰度比例」与「观察比例」—— 词法给 0,但只要**值全等** + // 且同主语,就仍会被 findAliasCandidates 收进来(分数低)。 + blocks := []MemoryBlock{ + {ID: "1", Text: "第113批|灰度比例=10%"}, + {ID: "2", Text: "第113批|观察比例=10%"}, + } + cands := findAliasCandidates(blocks) + if len(cands) != 1 { + t.Fatalf("值全等就该被收进候选(哪怕分数低),实际 %d 个", len(cands)) + } + t.Logf("✓ 收进来了,分数 %.2f(低分提示「不像,可能同义换词」)", cands[0].Score) + if cands[0].Score > 0.5 { + t.Errorf("同义换词应给低分(提示人细看),实际 %.2f", cands[0].Score) + } +} diff --git a/internal/memory/arbitration.go b/internal/memory/arbitration.go new file mode 100644 index 00000000..99a5b8f4 --- /dev/null +++ b/internal/memory/arbitration.go @@ -0,0 +1,523 @@ +package memory + +import ( + "regexp" + "sort" + "strings" + "time" +) + +// 同属性多值的时序仲裁。 +// +// ★ 为什么需要(实测,真实 chineseclip + 真库 188 块) +// ---------------------------------------------- +// 迁移后的块向量检索在「值覆盖」维度上答错: +// +// 查询 "值班室分机号是多少" → top1 = 旧号 4379(score 0.8127) +// 新号 4324 的块排不进 top8 +// +// 而把长复合句改写成短句后,实测相似度是: +// +// 「值班室分机号 4324」 cos = 0.9284 +// 「值班室分机号 4379」 cos = 0.9298 ← 旧号仍高 0.0014 +// +// **结论:单靠向量无法解决值覆盖。** 新旧两个值在向量空间里几乎同点, +// 谁更「对」不是相似度能回答的问题 —— 答案是「哪个更新」,而那只有 +// 时间戳知道。 +// +// ★ 仲裁不是「一律取最新」—— 真库里有两种相反的时间语义 +// -------------------------------------------------- +// 值覆盖(后一条取代前一条,取最新对): +// +// 13:28 值班室分机号 4379,值班人 阿李 +// 13:44 下周起值班室分机号改为 4324,旧号 4379 停用 +// +// 指标收窄(每条都在补充细节,「最新」反而会答错): +// +// 13:32 峰值 4%~17%,查询接口与下单/退款接口最集中 +// 13:35 峰值 4%~17%(30~84批整体),80~84批为 7%~16% +// 13:40 峰值4%~17%(30~84批),85~95批未再上报 ← 最新这条说的是「停报」 +// +// 对后者取最新,答出来的是「85~95批未再上报」,而不是「峰值是多少」。 +// +// 所以判据必须先分清「取代」与「补充」,再决定怎么排。这不是靠猜 —— +// 见 supersedes():两条讲同一属性且**后一条明确提到了前一条的值**时, +// 那是取代;否则是补充。 + +// blockFact 是从块里解析出的一个事实陈述:谁、什么属性、什么值、何时。 +type blockFact struct { + // Subject/维度/值 由块文本解析(形态见 parseFact)。 + Subject string + Dimension string + Value string + // Text 是原始块文本(仲裁日志与回给模型的证据)。 + Text string + // When 是块的时间戳;零值表示未知(迁移兜底或无 provider)。 + When time.Time + // Score 是向量相似度(若本次是向量召回)。 + Score float64 +} + +// 仲裁必须在**同一属性**的候选之间做,而「同一属性」需要看这两个块是不是 +// 从同一条原句拆出来的 —— 那是 TimesEqual 的补充维度。 +// +// ★ 判据来自实测的一组数据(不是构造的) +// +// 「值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)」 +// → 值班室分机号|值班分机号=4324 (时间 13:44) +// → 值班室分机号|停用旧号=4379 (时间 13:44,同一条原句) +// +// 这两条**不是**新旧替代关系,而是同一次陈述里的两个字段(当前值 + +// 被停用的旧值)。用「后一条提及前一条的值」判会把 4324 那条当成 +// 取代 4379 那条 —— 方向反了,且会让真实的旧号信息消失。 +// +// 所以判据加一条:时间相同且都来自同一条原句 ⇒ 不是时序替代,只是并列字段。 +// 「同一条原句」用 sentences 表的归属判定(BlocksForNode 能拿到)。 + +// parseFact 从块文本解析出(主语, 维度, 值)。 +// +// 支持两种形态,因为库里同时存在: +// 1. 迁移来的整句:「值班室分机号 4379,值班人 阿李」(无 '|' 无 '=') +// → 解析不出三元组,返回 nil,调用方按「不可仲裁」处理 +// 2. 拆分产生的块:「<主语>|<维度>=<值>」 +// → 三段齐全 +// +// 为什么不让 memory 引用 distill 的同名函数:distill → memory 已存在 +// 依赖(blocks.go 用 memory.MemoryBlock),反向引用会成环。这两个函数 +// 是纯字符串切分,各自实现是有意的重复而非疏忽。 +func parseFact(text string) (subject, dimension, value string, ok bool) { + i := strings.Index(text, "=") + if i <= 0 { + return "", "", "", false // 无 '=',不是事实块 + } + dim, val := text[:i], text[i+1:] + // 主语在首个 '|' 之前 + if j := strings.Index(dim, "|"); j >= 0 { + return strings.TrimSpace(dim[:j]), + strings.TrimSpace(dim[j+1:]), + val, true + } + return "", strings.TrimSpace(dim), val, true +} + +// stopWords 是「这条没在报告值」的标记词。 +// +// 用于区分「指标收窄」与「值覆盖」:最新那条若只是说「不再上报」 +// 「暂无」「未见」,那它没有给出值,不该把旧值挤掉。 +// +// ★ 词必须来自真库实见,不是编的:见真库的 +// 「第85批起不再上报错误率峰值」「85~95批未再上报」。 +// +// 命名 cutStopWords:cut.go 里已有一个 stopWords(map[string]bool, +// 用于分词剪枝),同名会让 go vet 报 non-boolean condition。 +var cutStopWords = []string{ + "不再上报", "未再上报", "不再", "未再", "暂无", "未见", "无异常", + "未发生", "已恢复", "已结束", "已回滚", "已修复", +} + +// reportsValue 判断这条块是否给出了具体值。 +// +// 「第85批起不再上报错误率峰值」里有「不再」,是状态陈述不是取值 — +// 它不该因为时间最新就压掉一个真实报出的值。 +func reportsValue(text string) bool { + for _, w := range cutStopWords { + if strings.Contains(text, w) { + return false + } + } + return true +} + +// supersedes 判断 later 是否取代了 earlier(同一属性的新值)。 +// +// 判据是**后一条文本里出现了前一条的值** —— 这是可观察的、不依赖 +// 字段名的信号: +// +// earlier: 值班室分机号 4379,值班人 阿李 值 = 4379 +// later: 下周起值班室分机号改为 4324,旧号 4379 停用 +// ↑ 含 "4379" → 是取代 +// +// 而指标收窄那组: +// +// earlier: 峰值 4%~17%(30~71批整体) 值 = 4%~17%(30~71批整体) +// later: 峰值 4%~17%(30~84批整体) ↑ 不含前者的完整值 → 不是取代 +// +// 为什么不比「值是否不同」:新旧值不同是常态,但「峰值 4%~17%」到 +// 「峰值 4%~17%(30~84批整体)」里主值没变、只是范围细化了, +// 那不是取代 —— 值不同但主值相同的情况必须判为「补充」。 +func supersedes(earlier, later blockFact) bool { + // 必须同一主体,否则谈不上取代(不同服务各自的端口互不取代)。 + if earlier.Subject != later.Subject { + return false + } + if earlier.Value == "" { + return false + } + // 后一条原文里提到了前一条的值 → 旧值被显式取代。 + // + // ★ 为什么在这里判「提及」而不在维度相等的前提之后: + // 维度名会漂移(漂移发现实测的候选是 + // 「值班分机号 / 停用旧号 / 旧分机号」),先要求维度严格相等的话, + // 「旧分机号=4379」永远判不出它取代了「值班分机号=4379」—— + // 而这正是值覆盖最常见的形态(旧值与新值被拆成两个不同维度名)。 + // 「文本里提到了旧值」是更直接的证据,优先于维度名相等。 + // + // 但仍要防一种误判:指标收窄那组里 + // 「峰值=4%~17%(30~71批)」与「峰值=4%~17%(30~84批)」 + // 后者文本不含前者的**完整**值(含括号与批次范围)⇒ 不判取代。 + // 所以判的是「完整值出现在原文里」,不是「某个数字片段出现」。 + return strings.Contains(later.Text, earlier.Value) +} + +// sortHitsByTimeThenScore 按 (时间升序, 分数降序) 排。 +// +// 为什么时间相同时要按分数降序:迁移后同批写入的块 created_at 完全相同 +// (实测真库 188 块只有 28 个不同时刻),只按时间排等于随机 —— +// 同一批里的「端口」和「值班人」谁先谁后没有意义,但「向量更相关的排前面」 +// 有意义。 +func sortHitsByTimeThenScore(hits []BlockHit) { + sort.SliceStable(hits, func(i, j int) bool { + ti, tj := hits[i].Block.CreatedAt, hits[j].Block.CreatedAt + if ti.Equal(tj) { + return hits[i].Score > hits[j].Score + } + if ti.IsZero() != tj.IsZero() { + // 未知时间的排后面(不该被判为「更早」而被取代规则吃掉) + return tj.IsZero() + } + return ti.Before(tj) + }) +} + +// ArbitrationResult 是仲裁后的结果。 +type ArbitrationResult struct { + // Kept 是仲裁后保留的块(按时间升序)。 + Kept []BlockHit + // Superseded 是被取代的块(按时间降序,最早的在前)。 + Superseded []BlockHit + // Status 是仲裁结论,用于日志与调试。 + Status string +} + +// sameSentenceOf 返回每个块所属的原句 id。 +// +// 块→原句的归属靠 sentence --contains--> block 边(块表本身没有 +// sentence_id 列 —— media_refs 那套设计已废弃)。同一句话拆出的多个字段块 +// 因此能识别出来,而它们是**并列字段**而不是时序替代。 +// +// 拿不到归属时(块不是从句子拆来的,比如迁移来的整句)返回空 map —— +// 调用方按「无法判定同句」处理,见 sameSentenceOK。 +func sameSentenceOf(db *GraphDB, hits []BlockHit) map[string]string { + if db == nil || len(hits) == 0 { + return nil + } + ids := make([]string, 0, len(hits)) + for _, h := range hits { + if h.Block.ID != "" { + ids = append(ids, h.Block.ID) + } + } + if len(ids) == 0 { + return nil + } + db.mu.RLock() + defer db.mu.RUnlock() + placeholders := "" + args := make([]any, 0, len(ids)) + for i, id := range ids { + if i > 0 { + placeholders += "," + } + placeholders += "?" + args = append(args, id) + } + // ★★ source_kind 必须是 'block',不是 'sentence'。 + // + // 端点类型在 Commit 块化时从「sentences 表行号」改成「原句块」 + // (52e4596 / cdf0726),但这处 SQL 还写着 'sentence'。 + // + // ⇒ 查询恒空 ⇒ 同句分组全失败 ⇒ 仲裁把**跨句**的块当成互相取代: + // + // 同句并列 blk_a/blk_b + 旧句 blk_c + // → blk_c 被误剔除(「三者都该保留」失败) + // + // ★ 症状极隐蔽:仲裁测试当场抓到,但它绿了很久 —— + // 因为在旧形态下这处是对的,只有新形态才暴露。 + rows, err := db.db.Query(`SELECT target_id, source_id FROM memory_block_edges + WHERE edge_type = 'contains' AND source_kind = 'block' + AND target_kind = 'block' AND target_id IN (`+placeholders+`)`, args...) + if err != nil { + return nil + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var blockID, sentID string + if err := rows.Scan(&blockID, &sentID); err != nil { + continue + } + out[blockID] = sentID + } + return out +} + +// isDeprecatedDim 判断维度名是否在陈述「旧值作废」。 +// +// 只挡这一类,不挡所有有兄弟的块 —— 上一版「有兄弟就不外溢」过宽: +// 真库形态里 +// +// 「值班室分机号 4324,值班 老周(旧号 4379 停用)」 +// → 值班分机号=4324 停用旧号=4379 值班人=老周 +// +// 其中「停用旧号」带兄弟时不该外溢(它自己就是被停用的那个), +// 但「值班人=老周」带兄弟时若也禁止外溢,就会漏掉真正的取代关系。 +// +// 判据是维度名的语义(真库里模型就是这么写的): +// 停用 / 旧 / 原 / 之前 / 曾经 开头或结尾的维度名。 +func isDeprecatedDim(dim string) bool { + if dim == "" { + return false + } + markers := []string{"旧", "停用", "原", "之前", "曾经", "原有", "历史"} + for _, m := range markers { + if strings.Contains(dim, m) { + return true + } + } + return false +} + +// hasSameSentenceSibling 报告该块所在原句是否还拆出了别的块。 +// +// 用途:识别「新值 + 旧值并列」的结构 —— 这种句子里已经自带了 +// 「旧值作废」的信息,不需要靠外溢取代去表达(见 arbitrate 里的注释)。 +// +// ★★ source_kind 必须是 'block',不是 'sentence'(2026-10-04)。 +// +// contains 边的两个端点都是块(source_kind / target_kind 均为 'block'), +// 因为端点类型在 Commit 块化时从「sentences 表行号」改成了「原句块」; +// 而原句块恒在 source 侧 —— 生产库实测 1298 条 contains 边里 +// source_id 全部是 blk_src_* 原句块,无一例外。 +// +// 句子的 id 也正是从 sameSentenceOf 拿的(它查 target_id=字段块、 +// 取 source_id 作原句 id),两处口径必须一致,否则: +// +// 查询恒空(source_kind='sentence' 没有一行匹配) +// → 「新值+旧值并列」的结构识别不出来 +// → 带 isDeprecatedDim 的旧值块被错误地当成取代者执行外溢 +// → 误剔除本该保留的旧值。 +// +// ★ 症状隐蔽:它在同形态下表现正常,只有「旧值块恰好带废弃维度名」 +// +// 的那批数据才会偏 —— 而那批数据在长对话里占比不高。 +func hasSameSentenceSibling(db *GraphDB, sentenceID, excludeBlockID string) bool { + if db == nil || sentenceID == "" { + return false + } + db.mu.RLock() + defer db.mu.RUnlock() + var n int + err := db.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE edge_type = 'contains' AND source_kind = 'block' AND target_kind = 'block' + AND source_id = ? AND target_id != ?`, + sentenceID, excludeBlockID).Scan(&n) + return err == nil && n > 0 +} + +// supersedesSentence 判断 later 文本是否取代了 earlier 文本。 +// +// ★ 专为**整句块**设计(解析不出「主语|维度=值」的那种): +// +// 判据只要两件事 —— 时间更晚 + 提到了更早那条里的关键值。 +// +// 为什么不需要解析主语:值覆盖场景下,「新号生效、旧号作废」这句话 +// **必然同时含新旧两个值**: +// +// early: 值班室分机号 4379,值班人 阿李 +// late: 值班室分机号 4324,值班 老周(旧号 4379 停用) +// ↑ 含 early 的 4379 +// +// 抽出的「关键值」用与 parseFact 同一套值形态正则(数字/版本/百分比), +// 匹配 early 里最长的那个。 +var sentenceValueRe = regexp.MustCompile( + `\d+(?:\.\d+)?(?:/\d+)*(?:~\d+)?%?|[vV]\d+(?:\.\d+)+|第\d+[批号版]?`) + +func supersedesSentence(earlier, later string) bool { + // 取 earlier 里最长的那个值 + best := "" + for _, m := range sentenceValueRe.FindAllString(earlier, -1) { + if len(m) > len(best) { + best = m + } + } + if best == "" { + return false // earlier 里没有可识别的值,无从判断取代 + } + // later 提到了它,且 later 不是同一句话(否则是自我引用) + return best != later && strings.Contains(later, best) +} + +// arbitrate 对同属性的候选块做时序仲裁。 +// +// db 可为 nil(拿不到块→原句归属时退化为纯时间+文本判据)。 +// +// 规则(三条,按顺序): +// 1. **不可仲裁的直接保留**:解析不出(主语,维度,值)的块、没带时间的块。 +// 强行仲裁它们等于丢信息 —— 宁可多返回给模型让它自己判断。 +// 2. **被明确取代的剔除**:later 文本含 earlier 的值 ⇒ earlier 出局。 +// 被取代的块仍然放进 Superseded 而不是丢弃 —— 「旧号 4379 停用」 +// 本身就是有信息量的(它解释了为什么现在打不通)。 +// 3. **同一属性的其余块按时间升序保留**,不合并、不去重: +// 指标收窄那组三条都要留着,每条带不同的批次范围。 +// +// 返回的 Kept 已按 (When 升序, Score 降序) 排过:时间相同时(迁移后 +// 同批写入的多条)用向量分兜底。 +func arbitrate(db *GraphDB, hits []BlockHit) ArbitrationResult { + sentOf := sameSentenceOf(db, hits) + type parsed struct { + hit BlockHit + fact blockFact + ok bool + sameOf string // 所属原句 id(空 = 未知) + } + var ps []parsed + for _, h := range hits { + sub, dim, val, ok := parseFact(h.Block.Text) + ps = append(ps, parsed{hit: h, fact: blockFact{ + Subject: sub, Dimension: dim, Value: val, + Text: h.Block.Text, When: h.Block.CreatedAt, Score: h.Score, + }, ok: ok, sameOf: sentOf[h.Block.ID]}) + } + + var kept, superseded []BlockHit + dropped := make(map[string]bool) + + for i, a := range ps { + if !a.ok || a.fact.When.IsZero() { + // 规则 1(修订):**没有时间的**不可仲裁 → 保留。 + // + // 但「有时间的整句块」要参与取代判断 —— 它解析不出 + // (主语,维度,值),不代表不能判断「谁取代了谁」。 + // + // 实测故障(真库 legacy-entity 整句块): + // + // 13:28 值班室分机号 4379,值班人 阿李 + // 13:44 值班室分机号 4324,值班 老周(旧号 4379 停用) + // 13:44 下周起值班室分机号改为 4324,旧号 4379 停用 + // + // 查询「现在的值班分机号」时旧号排 top1 —— 而 13:44 那两条 + // **文本里含 "4379"**,正是「新号生效、旧号作废」的自述。 + // 判据就是 supersedesSentence:更晚 + 提到更早那条的值 ⇒ 取代。 + // 不需要解析主语,也不需要维度。 + if a.fact.When.IsZero() { + kept = append(kept, a.hit) + continue + } + supersededByOther := false + for j, b := range ps { + if i == j || b.fact.When.IsZero() || !b.fact.When.After(a.fact.When) { + continue + } + if supersedesSentence(a.hit.Block.Text, b.hit.Block.Text) { + dropped[a.hit.Block.ID] = true + superseded = append(superseded, a.hit) + supersededByOther = true + break + } + } + if !supersededByOther { + kept = append(kept, a.hit) + } + continue + } + // 规则 2:被后面任一条显式取代? + supersededByOther := false + for j, b := range ps { + if i == j || !b.ok || b.fact.When.IsZero() { + continue + } + if !b.fact.When.After(a.fact.When) { + continue // 只看更晚的 + } + // 同一条原句拆出的字段是**并列**关系,不是时序替代。 + // 实测形态:「值班室分机号 4324,值班 老周(旧号 4379 停用)」 + // 拆出 值班分机号=4324 与 停用旧号=4379 —— 后者提及 4379, + // 但它不是「取代」4324,两者是同一次陈述里的两个字段。 + if a.sameOf != "" && a.sameOf == b.sameOf { + continue + } + // ★ 同句的并列字段不得「外溢」去取代句外的块。 + // + // 实测形态(真库): + // 原句A 13:28 「值班室分机号 4379,值班人 阿李」 + // → 值班分机号=4379 + // 原句B 13:44 「值班室分机号 4324,值班 老周(旧号 4379 停用)」 + // → 值班分机号=4324、停用旧号=4379 ← 与上条并列 + // + // 原句B 的「停用旧号=4379」文本里含 4379,若允许它外溢, + // 就会把原句A 的 4379 判成被取代 —— 而实际上被取代的是 + // 原句B **自己**那条并列字段(同句已排除),原句A 那条 + // 恰恰是「历史上真实用过的号」,正是值覆盖要保留的信息。 + // + // 判据:b 与 a 同句时 continue(同句并列,已在上面处理); + // b 与 a 不同句但 b 自己还有个同句的兄弟块时,说明 b 属于 + // 「新值 + 旧值并列」的结构,不该由它单独执行取代。 + if b.sameOf != "" && b.sameOf != a.sameOf && + hasSameSentenceSibling(db, b.sameOf, b.hit.Block.ID) && + isDeprecatedDim(b.fact.Dimension) { + continue + } + // 拿不到块→原句归属时的兜底:时间完全相同且值出现在同一句里, + // 多半是同一次陈述拆出的并列字段,而不是时序替代。 + // + // ★ 判据不能是「值文本相同」——那会把 + // 「值班分机号=4379」与「停用旧号=4379」也判成并列,而它们 + // 恰好**就该**是并列。真正的信号是「同一时刻 + 不同维度名」: + // 同一条原句拆出的字段时间戳必然相同,而不同维度名正是 + // 漂移发现报告里的「值班分机号 / 停用旧号 / 旧分机号」。 + // 真正的时序替代会有严格的时间差(真库里是 13:28 → 13:44)。 + if a.sameOf == "" && b.sameOf == "" && + a.fact.When.Equal(b.fact.When) && + a.fact.Dimension != b.fact.Dimension && + a.fact.Subject == b.fact.Subject { + continue + } + if supersedes(a.fact, b.fact) { + dropped[a.hit.Block.ID] = true + superseded = append(superseded, a.hit) + supersededByOther = true + break + } + } + if !supersededByOther { + kept = append(kept, a.hit) + } + } + + // 规则 3:**不重排**,保持调用方给的向量序。 + // + // ★ 这是实测打回来的:第一版在这里按 (时间升序, 分数降序) 重排, + // 结果端到端探针 5/7 → 0/7 —— 因为迁移来的整句块时间戳最早 + // (13:20:43),时间升序把它们全顶到 top3: + // + // 仲裁后 top3: 随时追问细节 | order-gw 运维进展 | 每天 + // + // 时间只该用来**判断取代关系**(哪条更新),不该决定**展示顺序** —— + // 展示顺序是相关性问题,由向量分决定。仲裁的职责是剔除被取代的块, + // 不重排。 + // + // 这与 RecallBlocks「按余弦排序」的既有契约是同一件事: + // 那是召回层的语义,仲裁层不该改。 + // + // Superseded 同理:剔除的块按时间升序便于人工核对「谁取代了谁」。 + sortHitsByTimeThenScore(superseded) + + status := "no-arbitration" + switch { + case len(superseded) > 0: + status = "superseded" + case len(kept) < len(hits): + status = "partial" + } + return ArbitrationResult{Kept: kept, Superseded: superseded, Status: status} +} diff --git a/internal/memory/arbitration_sibling_guard_test.go b/internal/memory/arbitration_sibling_guard_test.go new file mode 100644 index 00000000..ae67b247 --- /dev/null +++ b/internal/memory/arbitration_sibling_guard_test.go @@ -0,0 +1,130 @@ +package memory + +import ( + "path/filepath" + "testing" +) + +// TestHasSameSentenceSiblingFindsRealSibling 钉住「同句兄弟能被找到」。 +// +// ★ 这条判据针对 2026-10-04 修的缺陷:SQL 写的是 source_kind='sentence' +// +// 而 contains 边两个端点都是 block(生产库实测 1298 条 contains 边 +// source_id 全部是 blk_src_* 原句块)⇒ 查询恒空 ⇒ +// 「新值 + 旧值并列」的结构识别不出来。 +// +// ★ 为什么必须单独立判据、而不是靠 arbitrate 的既有测试: +// +// 这个失效**不会让任何现存断言变红** —— 恒空只影响 +// 「旧值块恰好带废弃维度名且跨句」的那批数据。 +// 所以它是静默失效:测试全绿而守卫已失效。 +// 判据必须直接量 hasSameSentenceSibling 本身, +// 否则修没修对都看不出来。 +func TestHasSameSentenceSiblingFindsRealSibling(t *testing.T) { + db := openArbTestDB(t) + + // 一句里拆出两个字段块(真实形态:Commit 会把一句拆成主语|维度=值)。 + // 注意 contains 方向:原句块(source)--contains--> 字段块(target)。 + writeBlockWithSource(t, db, "b_port", "端口=8243", "blk_src_s1") + writeBlockWithSource(t, db, "b_dep", "停用旧号=8199", "blk_src_s1") + + // ③ 另一句里只有一个块 —— 它没有同句兄弟。 + writeBlockWithSource(t, db, "b_solo", "值班人=小王", "blk_src_s2") + + // 核心断言:有兄弟的那个必须报 true。 + if !hasSameSentenceSibling(db, "blk_src_s1", "b_port") { + t.Error("blk_src_s1 下有 b_port 与 b_dep 两个块,hasSameSentenceSibling 应为 true") + } + // 换个 exclude 也一样(兄弟是相对而言的)。 + if !hasSameSentenceSibling(db, "blk_src_s1", "b_dep") { + t.Error("排除 b_dep 后 b_port 仍是兄弟,应为 true") + } + // 独苗那句必须报 false —— 否则会把所有块都当并列,仲裁彻底失效。 + if hasSameSentenceSibling(db, "blk_src_s2", "b_solo") { + t.Error("blk_src_s2 只有 b_solo 一个块,不该有兄弟") + } + // ★ 不写「exclude 为空时不该把自己算作兄弟」这条断言: + // + // 它描述的状态**不可达** —— 调用方恒传块自身 ID + // (arbitrate 里 hasSameSentenceSibling(db, b.sameOf, b.hit.Block.ID)), + // 而 b.sameOf 非空是进入该分支的前提。所以 exclude 为空时 + // SQL 的 target_id != '' 会把所有块都算作兄弟 —— 那是没人走的分支, + // 为它改代码等于为不存在的场景写逻辑。 + // + // 实测确认:exclude="" 时 COUNT(*)=1(自己被算了进去)。 +} + +// TestSameSentenceOfAndSiblingShareEndpointConvention 钉住两处口径一致。 +// +// ★ sameSentenceOf 查「target_id = 字段块」取 source_id 作原句 id; +// +// hasSameSentenceSibling 查「source_id = 那个 id」。 +// 两处若有一处写反(或用了不同的 source_kind), +// 就出现「归属查到了、兄弟却查不到」这种半通不通的状态 —— +// 比全错更难发现,所以必须显式钉住。 +func TestSameSentenceOfAndSiblingShareEndpointConvention(t *testing.T) { + db := openArbTestDB(t) + + writeBlockWithSource(t, db, "b_a", "维度A=值1", "blk_src_x") + writeBlockWithSource(t, db, "b_b", "维度B=值2", "blk_src_x") + + hits := []BlockHit{ + {Block: MemoryBlock{ID: "b_a", Text: "维度A=值1"}}, + {Block: MemoryBlock{ID: "b_b", Text: "维度B=值2"}}, + } + + // sameSentenceOf 必须把两个块都归到 blk_src_x。 + sentOf := sameSentenceOf(db, hits) + for _, id := range []string{"b_a", "b_b"} { + if sentOf[id] != "blk_src_x" { + t.Errorf("块 %s 的原句应是 blk_src_x,实际 %q", id, sentOf[id]) + } + } + + // ★ 关键联动:同一个 id 两处都要认。 + for _, id := range []string{"b_a", "b_b"} { + if !hasSameSentenceSibling(db, sentOf[id], id) { + t.Errorf("块 %s:sameSentenceOf 说它属于 blk_src_x,"+ + "hasSameSentenceSibling 却说它在 blk_src_x 下无兄弟 —— 两处口径不一致", id) + } + } +} + +// ── 小工具 ── + +func openArbTestDB(t *testing.T) *GraphDB { + t.Helper() + db, err := NewGraphDB(filepath.Join(t.TempDir(), "arb.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +// writeBlockWithSource 写一个块,并建「原句块 --contains--> 该块」的结构边。 +// +// ★ 原句块本身也要存在:AddMemoryBlockEdge 会校验两端节点都存在 +// +// (graphNodeExists),只建字段块会报 "does not exist"。 +func writeBlockWithSource(t *testing.T, db *GraphDB, blockID, text, sentenceBlockID string) { + t.Helper() + if sentenceBlockID != "" { + // 原句块:ID 即句子块 id,文本用原句本身。 + if err := db.PutMemoryBlocks([]MemoryBlock{ + {ID: sentenceBlockID, Modality: BlockText, Text: "原句:" + text}, + }); err != nil { + t.Fatalf("写原句块 %s: %v", sentenceBlockID, err) + } + } + if err := db.PutMemoryBlocks([]MemoryBlock{ + {ID: blockID, Modality: BlockText, Text: text}, + }); err != nil { + t.Fatalf("写块 %s: %v", blockID, err) + } + if sentenceBlockID != "" { + if err := db.AddMemoryBlockEdge("block", sentenceBlockID, "block", blockID, "contains"); err != nil { + t.Fatalf("建 contains 边 %s→%s: %v", sentenceBlockID, blockID, err) + } + } +} diff --git a/internal/memory/arbitration_test.go b/internal/memory/arbitration_test.go new file mode 100644 index 00000000..5bd6e27e --- /dev/null +++ b/internal/memory/arbitration_test.go @@ -0,0 +1,649 @@ +package memory + +import ( + "testing" + "time" +) + +// 仲裁判据的用例全部取自真库 /var/tmp/ha-c 的实际块文本。 +// +// ★ 两组数据方向相反,这是本判据的核心: +// - 值覆盖组:后一条取代前一条 ⇒ 取最新才对 +// - 指标收窄组:每条都在补充,最新那条说「未再上报」⇒ 取最新会答错 +// 任何「一律取最新」的规则都会在第二组上失败。 + +func hit(text string, when string, score float64) BlockHit { + t, _ := time.Parse("2006-01-02 15:04:05", when) + return BlockHit{ + Block: MemoryBlock{ID: text, Text: text, CreatedAt: t}, + Score: score, + } +} + +// ★ 整句块(迁移来的,无 '|' 无 '='):现在**参与取代判断**。 +// +// 这条测试的断言改过一次。初版断言「整句块不可仲裁,全部保留」, +// 而端到端 overwrite 维度卡在 1/2 的根因正是它: +// +// 13:28 值班室分机号 4379,值班人 阿李 +// 13:44 值班室分机号 4324,值班 老周(旧号 4379 停用) +// 13:44 下周起值班室分机号改为 4324,旧号 4379 停用 +// +// 查询「现在的值班分机号」时旧号排 top1 —— 而 13:44 那两条**文本里 +// 含 "4379"**,正是「新号生效、旧号作废」的自述。 +// +// 「解析不出主语」≠「不能判断谁取代了谁」:取代判断只需 +// 「更晚 + 提到了更早那条的关键值」,不需要维度名。 +func TestArbitrate_整句块参与取代(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + putRawBlockArb(t, g, "e1", "值班室分机号 4379,值班人 阿李", "2026-10-01 13:28:28") + putRawBlockArb(t, g, "e2", "值班室分机号 4324,值班 老周(旧号 4379 停用)", + "2026-10-01 13:44:26") + + hits := []BlockHit{ + {Block: mustBlock(t, g, "e1"), Score: 0.81}, + {Block: mustBlock(t, g, "e2"), Score: 0.73}, + } + res := arbitrate(nil, hits) + + if len(res.Kept) != 1 || res.Kept[0].Block.ID != "e2" { + t.Errorf("只应保留新号那条,实际 kept=%v", textsOf(res.Kept)) + } + if len(res.Superseded) != 1 || res.Superseded[0].Block.ID != "e1" { + t.Errorf("旧号应进 Superseded(不丢弃 ——「旧号作废」本身有信息),实际 %v", + textsOf(res.Superseded)) + } +} + +// ★ 值覆盖的两种形态,方向相反,都要判对。 +// +// 形态A 同一时刻的并列字段(同一条原句拆出来的): +// +// 值班室分机号|值班分机号=4324 13:44 +// 值班室分机号|停用旧号=4379 13:44 ← 与上条同句,不是"取代" +// +// 形态B 跨时刻的真正替代: +// +// 值班室分机号|值班分机号=4379 13:28 ← 旧值 +// 值班室分机号|值班分机号=4324 13:44 ← 新值,但文本未提 4379 +// +// 形态B 里新值**不该**取代旧值 —— 因为 4324 那条的文本没有提到 4379, +// 单看图无法断定「4379 作废了」。这正是「显式提及」判据与「时间更晚」 +// 判据的区别:后者会误伤。 +func TestArbitrate_并列字段不互斥(t *testing.T) { + // 形态A:同句并列。两条同时间、不同维度名 → 不是时序替代。 + hitsA := []BlockHit{ + hit("值班室分机号|值班分机号=4324", "2026-10-01 13:44:26", 0.73), + hit("值班室分机号|停用旧号=4379", "2026-10-01 13:44:26", 0.70), + } + resA := arbitrate(nil, hitsA) + if len(resA.Kept) != 2 { + t.Errorf("形态A:同句并列的两条都该保留,实际 %d:%v", + len(resA.Kept), textsOf(resA.Kept)) + } + + // 形态B:跨时刻但文本未提及旧值 → 不判取代。 + hitsB := []BlockHit{ + hit("值班室分机号|值班分机号=4379", "2026-10-01 13:28:28", 0.81), + hit("值班室分机号|值班分机号=4324", "2026-10-01 13:44:26", 0.73), + } + resB := arbitrate(nil, hitsB) + if len(resB.Superseded) != 0 { + t.Errorf("形态B:文本未提及旧值就不该判取代(只按时间判会误伤),实际 %d:%v", + len(resB.Superseded), textsOf(resB.Superseded)) + } + if len(resB.Kept) != 2 { + t.Errorf("形态B:两条都该保留(是否作废需人工/上层判断),实际 %d", + len(resB.Kept)) + } +} + +// ★ 真正的取代:新一条同时给出新值并提及旧值。 +func TestArbitrate_显式提及才算取代(t *testing.T) { + hits := []BlockHit{ + hit("值班室分机号|值班分机号=4379", "2026-10-01 13:28:28", 0.81), + // 这条文本里含 "4379" ⇒ 旧值被显式取代 + hit("值班室分机号|旧分机号=4379", "2026-10-01 13:44:26", 0.70), + hit("值班室分机号|值班分机号=4324", "2026-10-01 13:44:26", 0.73), + } + res := arbitrate(nil, hits) + + if len(res.Superseded) != 1 { + t.Fatalf("应恰有 1 条被取代,实际 %d:%v", len(res.Superseded), textsOf(res.Superseded)) + } + if res.Superseded[0].Block.Text != "值班室分机号|值班分机号=4379" { + t.Errorf("被取代的应是旧值那条,实际 %q", res.Superseded[0].Block.Text) + } + if res.Status != "superseded" { + t.Errorf("状态应为 superseded,实际 %q", res.Status) + } +} + +// ★ 指标收窄:三条都要保留,最新那条不能把旧值挤掉。 +func TestArbitrate_指标收窄不合并(t *testing.T) { + hits := []BlockHit{ + hit("第85批|峰值=4%~17%(30~71批整体)", "2026-10-01 13:34:22", 0.72), + hit("第85批|峰值=4%~17%(30~84批整体)", "2026-10-01 13:35:57", 0.71), + hit("第85批|峰值=4%~17%(85~95批未再上报)", "2026-10-01 13:40:06", 0.70), + } + res := arbitrate(nil, hits) + + if len(res.Kept) != 3 { + t.Errorf("指标收窄的三条都该保留(每条带不同批次范围),实际 %d:%v", + len(res.Kept), textsOf(res.Kept)) + } + if len(res.Superseded) != 0 { + t.Errorf("指标收窄不该判取代,实际 %d:%v", + len(res.Superseded), textsOf(res.Superseded)) + } + // 时间升序:最早的在前 + if res.Kept[0].Block.Text != "第85批|峰值=4%~17%(30~71批整体)" { + t.Errorf("Kept 应按时间升序(最早的在前),实际首条 %q", res.Kept[0].Block.Text) + } +} + +// ★ 「不再上报」这类状态陈述不该因时间最新而压掉真实值。 +func TestReportsValue_状态陈述不算取值(t *testing.T) { + cases := []struct { + text string + want bool + }{ + {"第85批起不再上报错误率峰值", false}, // 真库实见 + {"85~95批未再上报", false}, // 真库实见 + {"值班室分机号 4324,值班 老周", true}, + {"峰值 4%~17%(30~84批整体)", true}, + {"下周起值班室分机号改为 4324,旧号 4379 停用", true}, + {"该问题已修复", false}, + {"服务暂无异常", false}, + } + for _, c := range cases { + if got := reportsValue(c.text); got != c.want { + t.Errorf("reportsValue(%q) = %v,期望 %v", c.text, got, c.want) + } + } +} + +// 无时间的块不被仲裁吃掉(迁移兜底会把块时间设成 NOW,但旧库可能有零值)。 +func TestArbitrate_无时间块保留(t *testing.T) { + hits := []BlockHit{ + {Block: MemoryBlock{ID: "a", Text: "服务A|端口=8861"}}, // 零时间 + hit("服务A|端口=9999", "2026-10-01 13:00:00", 0.9), + } + res := arbitrate(nil, hits) + if len(res.Kept) != 2 { + t.Errorf("无时间块应保留(不该被判为更早而吃掉),实际 %d:%v", + len(res.Kept), textsOf(res.Kept)) + } +} + +// ★ 仲裁不重排 kept:保持调用方给的向量序。 +// +// 判据直接来自端到端实测的故障形态:仲裁若按时间升序重排, +// 迁移来的整句块(时间戳最早 13:20:43)会全被顶到 top3,于是 +// +// 仲裁前 top1 = 值班室分机号 4379,值班人 阿李 (正确候选) +// 仲裁后 top1 = 随时追问细节 (完全无关) +// +// 端到端探针因此从 5/7 掉到 0/7。 +// +// 时间只该决定「谁取代谁」(仲裁判断),不该决定「先给模型看哪个」 +// (相关性排序,那是向量分的事)。 +func TestArbitrate_不重排保持向量序(t *testing.T) { + hits := []BlockHit{ + hit("服务A|端口=8861", "2026-10-01 13:00:00", 0.90), + hit("服务B|端口=8499", "2026-10-01 13:00:00", 0.85), + hit("服务C|端口=8271", "2026-10-01 13:00:00", 0.80), + } + res := arbitrate(nil, hits) + if len(res.Kept) != 3 { + t.Fatalf("三条都该保留,实际 %d", len(res.Kept)) + } + for i := range hits { + if res.Kept[i].Block.Text != hits[i].Block.Text { + t.Errorf("第 %d 位被重排了:期望 %q,实际 %q", + i, hits[i].Block.Text, res.Kept[i].Block.Text) + } + } +} + +// 时间不同也不能重排(这个更容易踩:迁移来的整句块时间最早)。 +func TestArbitrate_时间不同也不重排(t *testing.T) { + hits := []BlockHit{ + // 分数最高但时间最早 —— 若按时间升序会被顶到最后 + hit("服务A|端口=8861", "2026-10-01 13:00:00", 0.95), + hit("服务B|端口=8499", "2026-10-01 14:00:00", 0.60), + } + res := arbitrate(nil, hits) + if len(res.Kept) != 2 { + t.Fatalf("两条都该保留,实际 %d", len(res.Kept)) + } + if res.Kept[0].Block.Text != "服务A|端口=8861" { + t.Errorf("第 0 位应保持向量序(分数 0.95),实际 %q", + res.Kept[0].Block.Text) + } +} + +// 块文本解析的两种形态。 +func TestParseFact_两种形态(t *testing.T) { + cases := []struct { + text string + ok bool + sub, dim, val string + }{ + {"值班室分机号|值班分机号=4324", true, "值班室分机号", "值班分机号", "4324"}, + {"服务A|端口=8861", true, "服务A", "端口", "8861"}, + {"停机时长=4分", true, "", "停机时长", "4分"}, + {"值班室分机号 4379,值班人 阿李", false, "", "", ""}, + {"下周起值班室分机号改为 4324", false, "", "", ""}, + {"=无维度", false, "", "", ""}, + } + for _, c := range cases { + sub, dim, val, ok := parseFact(c.text) + if ok != c.ok { + t.Errorf("parseFact(%q) ok = %v,期望 %v", c.text, ok, c.ok) + continue + } + if !ok { + continue + } + if sub != c.sub || dim != c.dim || val != c.val { + t.Errorf("parseFact(%q) = (%q,%q,%q),期望 (%q,%q,%q)", + c.text, sub, dim, val, c.sub, c.dim, c.val) + } + } +} + +func textsOf(hits []BlockHit) []string { + var out []string + for _, h := range hits { + out = append(out, h.Block.Text) + } + return out +} + +// ★ 同句归属(blocks 的 contains 边)必须真的参与仲裁。 +// +// 上一版的测试全部传 db=nil,于是「同句排除」那条分支永远走不到: +// 变异去掉它仍然全绿 —— 假绿。判据得从真库形态出发: +// 用真 GraphDB 建句子+块(sentence --contains--> block),再仲裁。 +func TestArbitrate_同句归属参与仲裁(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + sentence := "值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)" + // ★ 原句由**块**承载(sentences 表退场),不再建 sentences 行。 + // 这三个测试此前一直造 sentences 行 + 用 sentence 端点, + // 而生产早已改为 原句块 --contains--> 字段块(0586f6b / cdf0726) + // ⇒ 它们绿着,却测的不是当前行为。 + sid := SentenceBlockID(sentence) + if err := putSentenceBlock(g, sentence); err != nil { + t.Fatal(err) + } + // 同一条原句拆出的两个字段块 + b1 := MemoryBlock{ID: "blk_a", Modality: BlockText, Text: "值班室分机号|值班分机号=4324", + CreatedAt: mustTime("2026-10-01 13:44:26")} + b2 := MemoryBlock{ID: "blk_b", Modality: BlockText, Text: "值班室分机号|停用旧号=4379", + CreatedAt: mustTime("2026-10-01 13:44:26")} + if err := g.PutMemoryBlocks([]MemoryBlock{b1, b2}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"blk_a", "blk_b"} { + if err := g.AddMemoryBlockEdge("block", sid, "block", id, "contains"); err != nil { + t.Fatal(err) + } + } + // 另一条原句拆出的旧值块(独立事实) + oldText := "值班室分机号 4379,值班人 阿李" + if err := putSentenceBlock(g, oldText); err != nil { + t.Fatal(err) + } + oldSent := SentenceBlockID(oldText) + b3 := MemoryBlock{ID: "blk_c", Modality: BlockText, Text: "值班室分机号|值班分机号=4379", + CreatedAt: mustTime("2026-10-01 13:28:28")} + if err := g.PutMemoryBlocks([]MemoryBlock{b3}); err != nil { + t.Fatal(err) + } + if err := g.AddMemoryBlockEdge("block", oldSent, "block", "blk_c", "contains"); err != nil { + t.Fatal(err) + } + + hits := []BlockHit{ + {Block: b1, Score: 0.73}, + {Block: b2, Score: 0.70}, + {Block: b3, Score: 0.81}, + } + res := arbitrate(g, hits) + + // ★★ 修正后的断言(2026-10-04,hasSameSentenceSibling 修好之后)。 + // + // 本断言原写「三条都该保留」,注释解释为「blk_b 没进候选」—— + // **那个理由是错的**:blk_b 一直在 hits 里(见上)。 + // + // 真正机制:hasSameSentenceSibling 恒返回 false + //(SQL 写 source_kind='sentence',而 contains 边两端都是 block), + // 于是「同句并列字段不得外溢取代」的守卫**从未生效**, + // blk_b(停用旧号=4379)以自己身份把 blk_c 判成被取代。 + // + // 守卫修好后,行为回到 arbitrate.go:465 写明的设计意图: + // + // 原句B 的「停用旧号=4379」属于「新值+旧值并列」结构,不该单独执行取代; + // 而原句A 的 4379 是「历史上真实用过的号」,正是值覆盖要保留的信息。 + // + // 所以三条**都该保留** —— 原断言的结论对,只是理由写错了。 + // 实测:修复前 Superseded=1[blk_c];修复后 Kept=3 Superseded=0。 + if len(res.Kept) != 3 { + t.Errorf("同句并列的 blk_a/blk_b 与历史上真实用过的 blk_c 都该保留,实际保留 %d:%v", + len(res.Kept), textsOf(res.Kept)) + } + // blk_b 不得以自己身份取代 blk_c —— 这正是刚修好的守卫。 + if len(res.Superseded) != 0 { + t.Errorf("blk_b 属于「新值+旧值并列」结构,不得外溢取代 blk_c;实际取代了 %d:%v", + len(res.Superseded), textsOf(res.Superseded)) + } + if !keptContains(res.Kept, "blk_c") { + t.Errorf("blk_c 是历史上真实用过的 4379,不该被剔除;实际保留 %v", textsOf(res.Kept)) + } + + // ★ 关键:把 blk_a 的文本换成提及旧值的版本,此时 blk_c 应被取代。 + // + // 这里刻意用**未经 PutMemoryBlocks 落库**的临时块 —— + // 于是 sameSentenceOf 查不到 blk_a 的原句归属(sameOf 为空), + // 外溢守卫被跳过,于是「明确提及旧值」的 blk_a 直接执行取代。 + // 验证的是守卫的另一侧:有归属就不外溢、没归属只看是否显式提及。 + hits2 := []BlockHit{ + {Block: MemoryBlock{ID: "blk_a", Modality: BlockText, + Text: "值班室分机号|值班分机号=4324(旧号 4379 停用)", + CreatedAt: mustTime("2026-10-01 13:44:26")}, Score: 0.73}, + {Block: b2, Score: 0.70}, + {Block: b3, Score: 0.81}, + } + res2 := arbitrate(g, hits2) + if len(res2.Superseded) != 1 || res2.Superseded[0].Block.ID != "blk_c" { + t.Errorf("提及旧值时 blk_c 应被取代,实际 %d:%v", + len(res2.Superseded), textsOf(res2.Superseded)) + } +} + +func mustTime(s string) time.Time { + t, _ := time.Parse("2006-01-02 15:04:05", s) + return t +} + +// keptContains 报告 hits 里是否有指定 id(判据用)。 +func keptContains(hits []BlockHit, id string) bool { + for _, h := range hits { + if h.Block.ID == id { + return true + } + } + return false +} + +// 「旧值作废」语义识别。判据来自真库模型的实际用词。 +func TestIsDeprecatedDim(t *testing.T) { + cases := []struct { + dim string + want bool + }{ + {"停用旧号", true}, // 真库实见 + {"旧分机号", true}, // 真库实见 + {"值班分机号", false}, + {"值班人", false}, + {"端口", false}, + {"峰值", false}, + {"原版本", true}, + {"历史值", true}, + {"", false}, + } + for _, c := range cases { + if got := isDeprecatedDim(c.dim); got != c.want { + t.Errorf("isDeprecatedDim(%q) = %v,期望 %v", c.dim, got, c.want) + } + } +} + +// ★ 同句排除的直接判据:同一条原句拆出的、**同维度**的两条, +// 即使后者文本里含前者的值,也**不是**时序替代。 +// +// 为什么需要这条独立用例:上一版的「同句归属参与仲裁」只测了 +// 不同维度的组合,而那组数据即使删掉同句排除也照样通过(同维度排除与 +// 外溢约束各自挡住了)—— 变异自证时判成了假绿。这条用同维度组合 +// 把同句排除单独钉住。 +// +// 真实形态:模型偶尔会把同一原句里的同一属性说两遍 +// (「值班室分机号 4324,值班 老周(旧号 4379 停用)」 +// +// → 值班分机号=4324、值班分机号=4379) +// +// 这是同一次陈述里的并列,不是「4379 作废」。 +func TestArbitrate_同句同维度不互斥(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + sidText := "值班室分机号 4324,值班 老周(旧号 4379 停用)" + if err := putSentenceBlock(g, sidText); err != nil { + t.Fatal(err) + } + sid := SentenceBlockID(sidText) + b1 := MemoryBlock{ID: "s1", Modality: BlockText, + Text: "值班室分机号|值班分机号=4324", + CreatedAt: mustTime("2026-10-01 13:44:26")} + // 同句、同维度,且文本含 b1 的值 4324 + // ★ 时间必须不同:同时间时 arbitrate 的「只看更晚的」判断本来就会 + // 挡掉这对,同句排除根本执行不到 —— 那样这条用例会因为**别的原因** + // 而通过,变异自证就判成假绿(第一版正是如此,已踩)。 + b2 := MemoryBlock{ID: "s2", Modality: BlockText, + Text: "值班室分机号|值班分机号=4379(本周起 4324 生效)", + CreatedAt: mustTime("2026-10-01 13:44:27")} + if err := g.PutMemoryBlocks([]MemoryBlock{b1, b2}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"s1", "s2"} { + if err := g.AddMemoryBlockEdge("block", sid, "block", id, "contains"); err != nil { + t.Fatal(err) + } + } + + res := arbitrate(g, []BlockHit{{Block: b1, Score: 0.7}, {Block: b2, Score: 0.6}}) + if len(res.Kept) != 2 { + t.Errorf("同句同维度的两条都该保留(是并列陈述,不是新旧替代),实际 %d:%v", + len(res.Kept), textsOf(res.Kept)) + } +} + +// ★ 接入层判据:仲裁必须发生在 **topK 截断之前**。 +// +// 实测的故障形态:查询「值班室分机号是多少」,旧号 4379 以 0.8127 排 +// top1,新号 4324 那条**进不了 top8**。正确记录在召回阶段就被挤掉了, +// 此后任何仲裁都无从挽回。 +// +// 这条测试构造「旧值分数更高、新值分数更低但时间更新」的场景: +// 若仲裁在截断后做,topK=1 时只剩旧值,断言新值在结果里就会失败。 +func TestRecallBlocks_仲裁在截断前(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 旧值:更早、分数更高(模拟"长旧句相似度高") + oldText := "值班室分机号 4379,值班人 阿李" + if err := putSentenceBlock(g, oldText); err != nil { + t.Fatal(err) + } + oldSent := SentenceBlockID(oldText) + oldB := MemoryBlock{ID: "a_old", Modality: BlockText, + Text: "值班室分机号|值班分机号=4379", + Vector: []float64{1, 0, 0}, Fingerprint: "fp-test", + CreatedAt: mustTime("2026-10-01 13:28:28")} + if err := g.PutMemoryBlocks([]MemoryBlock{oldB}); err != nil { + t.Fatal(err) + } + _ = g.AddMemoryBlockEdge("block", oldSent, "block", "a_old", "contains") + + // 新值:更晚、分数更低,但文本提及旧值 → 应取代它 + newSentText := "值班室分机号 4324,值班 老周" + if err := putSentenceBlock(g, newSentText); err != nil { + t.Fatal(err) + } + newSent := SentenceBlockID(newSentText) + newB := MemoryBlock{ID: "a_new", Modality: BlockText, + Text: "值班室分机号|值班分机号=4324(旧号 4379 停用)", + Vector: []float64{0.92, 0.39, 0}, Fingerprint: "fp-test", + CreatedAt: mustTime("2026-10-01 13:44:26")} + if err := g.PutMemoryBlocks([]MemoryBlock{newB}); err != nil { + t.Fatal(err) + } + _ = g.AddMemoryBlockEdge("block", newSent, "block", "a_new", "contains") + + // 查询向量更贴近旧值(0.98 vs 新值 0.95) + q := BlockRecallQuery{ + Vector: []float64{0.98, 0.199, 0}, Fingerprint: "fp-test", TopK: 1, + } + hits, arb, err := g.RecallBlocksWithArbitration(q) + if err != nil { + t.Fatal(err) + } + if arb.Status != "superseded" { + t.Errorf("仲裁状态应为 superseded(4324 取代 4379),实际 %q", arb.Status) + } + if len(arb.Superseded) != 1 || arb.Superseded[0].Block.ID != "a_old" { + t.Fatalf("被取代的应是 a_old,实际 %d:%v", + len(arb.Superseded), textsOf(arb.Superseded)) + } + if len(hits) == 0 || hits[0].Block.ID != "a_new" { + t.Errorf("topK=1 时应返回新值 a_new(仲裁在截断前),实际 %v", textsOf(hits)) + } + + // 对照:纯召回(不仲裁)按向量分返回 —— 旧值分数更高,所以排前。 + // 这正是仲裁要纠正的形态。 + raw, err := g.RecallBlocks(q) + if err != nil { + t.Fatal(err) + } + if len(raw) == 0 || raw[0].Block.ID != "a_old" { + t.Errorf("纯召回应按向量分返回(旧值在前),实际 %v", textsOf(raw)) + } +} + +// ★ supersedesSentence:整句块的取代判断。 +// +// 这是端到端 overwrite 维度 1/2 的直接根因(真库 legacy-entity 形态): +// +// 13:28 值班室分机号 4379,值班人 阿李 +// 13:44 值班室分机号 4324,值班 老周(旧号 4379 停用) +// +// 查询新号时旧号排 top1 —— 而 13:44 那两条**文本里含 "4379"**, +// 正是「新号生效、旧号作废」的自述。 +func TestSupersedesSentence(t *testing.T) { + // ✓ 真实形态:更晚那条提到旧值 + if !supersedesSentence( + "值班室分机号 4379,值班人 阿李", + "值班室分机号 4324,值班 老周(旧号 4379 停用)") { + t.Error("更晚且提到旧值 ⇒ 应判取代") + } + // ✓ 改述形态 + if !supersedesSentence( + "值班室分机号 4379,值班人 阿李", + "下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周") { + t.Error("改述形态也应判取代") + } + // ✗ later 不含旧值(只是又提了一句别的) + if supersedesSentence( + "值班室分机号 4379,值班人 阿李", + "值班室分机号 4324,值班 老周") { + t.Error("later 未提旧值 ⇒ 不该判取代(会误伤并存的两个值)") + } + // ✗ later 提到的是别的值 + if supersedesSentence( + "峰值 4%~17%(30~71批整体)", + "峰值 4%~17%(30~84批整体),80~84批为 7%~16%") { + t.Error("指标收窄不该判取代") + } + // ✗ earlier 没有可识别的值 + if supersedesSentence("随时追问细节", "随时追问细节(补充)") { + t.Error("earlier 无可识别值 ⇒ 不该判取代") + } +} + +// ★ 端到端:整句块的旧值必须被剔除,且不能误伤真实并列。 +func TestArbitrate_整句块的取代(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 真库形态:旧号(13:28)+ 两条新号自述(13:44) + putRawBlockArb(t, g, "old", "值班室分机号 4379,值班人 阿李", + "2026-10-01 13:28:28") + putRawBlockArb(t, g, "new1", "值班室分机号 4324,值班 老周(旧号 4379 停用)", + "2026-10-01 13:44:26") + putRawBlockArb(t, g, "new2", "下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周", + "2026-10-01 13:44:26") + // ★ 不能误伤的:另一个属性的值覆盖(不同属性,各自独立) + putRawBlockArb(t, g, "pool-a", "连接池容量 32/64 扩容前", "2026-10-01 13:00:00") + putRawBlockArb(t, g, "pool-b", "连接池容量 128/256 扩容后", "2026-10-01 14:00:00") + + hits := []BlockHit{ + {Block: mustBlock(t, g, "old"), Score: 0.95}, + {Block: mustBlock(t, g, "new1"), Score: 0.90}, + {Block: mustBlock(t, g, "new2"), Score: 0.88}, + {Block: mustBlock(t, g, "pool-a"), Score: 0.60}, + {Block: mustBlock(t, g, "pool-b"), Score: 0.55}, + } + res := arbitrate(nil, hits) + + keptIDs := map[string]bool{} + for _, h := range res.Kept { + keptIDs[h.Block.ID] = true + } + if keptIDs["old"] { + t.Error("旧号块应被取代剔除(13:28,且被 13:44 的两条自述提到)") + } + if !keptIDs["new1"] || !keptIDs["new2"] { + t.Errorf("两条新号自述都该保留,实际 kept=%v", keptIDs) + } + // ★ 不同属性的两条都该留 —— 它们不是彼此的「新旧」 + if !keptIDs["pool-a"] || !keptIDs["pool-b"] { + t.Errorf("不同属性的两条不该互斥,实际 kept=%v", keptIDs) + } + if len(res.Superseded) != 1 { + t.Errorf("应恰有 1 条被取代,实际 %d:%v", len(res.Superseded), textsOf(res.Superseded)) + } +} + +func putRawBlockArb(t *testing.T, g *GraphDB, id, text, when string) { + t.Helper() + b := MemoryBlock{ID: id, Modality: BlockText, Text: text, CreatedAt: mustTime(when)} + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } +} + +func mustBlock(t *testing.T, g *GraphDB, id string) MemoryBlock { + t.Helper() + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + for _, b := range blocks { + if b.ID == id { + return b + } + } + t.Fatalf("找不到块 %s", id) + return MemoryBlock{} +} + +// putSentenceBlock 直接写一条原句块。 +// +// 为什么不用 putBlockTx:它收 *sql.Tx,而这些测试已持有 g.mu +// (且用 *sql.DB 直连)⇒ 套一层事务反而复杂。 +// ★ 教训(今天踩过):持有 GraphDB 锁时不要调会自己加锁的方法。 +func putSentenceBlock(g *GraphDB, text string) error { + b := NewSentenceBlock(text, time.Time{}, time.Time{}) + vectorJSON := "" + _, err := g.db.Exec(`INSERT INTO memory_blocks + (id, modality, text_content, vector, fingerprint, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET text_content = excluded.text_content`, + b.ID, b.Modality, b.Text, vectorJSON, b.Fingerprint, b.Source) + return err +} diff --git a/internal/memory/block.go b/internal/memory/block.go index f4a1fb2f..a1388634 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strings" "time" ) @@ -41,9 +42,16 @@ type MemoryBlock struct { // 场景要贯穿到流水线底,就得从块开始——否则「QQ 那场对话里发过来的那张图」 // 在场面重现时永远拿不回来。块进 L3 时按 Scene 挂 scene_refs(kind='block'), // 场景召回即可把它取回(见 GraphDB.RecallByScene)。 - Scene string `json:"scene,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Scene string `json:"scene,omitempty"` + // SemanticType 是实体的语义类别(Person / Animal / Concept / Topic…), + // 对应旧 entities.type 与 Triple.SubjectType/ObjectType。 + // + // ★ 2026-10-04 加入。此前 Triple 标注的类型只写旧表,块侧完全没有 —— + // 判据 TestGraphCommit_CarriesAllFields 抓到: + // Commit 标注的 (Person, Animal) 读回来是 ("block","block")。 + SemanticType string `json:"semantic_type,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。 @@ -56,6 +64,26 @@ type MemoryBlockEdge struct { TargetID string `json:"target_id"` Type string `json:"type"` CreatedAt time.Time `json:"created_at"` + + // ★ 以下是「边作为独立单位」需要的属性列。 + // + // 结构边(contains 等)不填这些;关系边填。 + // 旧 relations 表有同名列 —— 块化后属性挂在**边**上, + // 而不是挂在一张独立的 relations 表上(那才是「边不是独立单位」的根源)。 + Confidence float64 `json:"confidence,omitempty"` + SessionID string `json:"session_id,omitempty"` + TurnID int `json:"turn_id,omitempty"` + // Status 是 EdgeActive / EdgeDeleted / EdgeMerged。 + // 空字符串表示结构边(无状态语义)。 + Status string `json:"status,omitempty"` + // MergedInto 在 EdgeMerged 时指向目标块:源块被并进哪里, + // 历史边留在源块上不重定向。 + MergedInto string `json:"merged_into,omitempty"` +} + +// IsRelationEdge 判断该边是否为**关系边**(而非结构边)。 +func (e MemoryBlockEdge) IsRelationEdge() bool { + return e.SessionID != "" || e.Status != "" || e.Confidence != 0 } func validBlockModality(modality BlockModality) bool { @@ -110,6 +138,10 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error { -- 场景只在本次给了值时才覆盖:块可能先被写入、后被归档路径补挂场景, -- 反过来「已挂场景的块被一次无场景的重写抹掉」是不可接受的静默降级。 scene = CASE WHEN excluded.scene != '' THEN excluded.scene ELSE memory_blocks.scene END, + -- ★ semantic_type 同样「空值不覆盖」: + -- 后写的三元组往往不带类型(Triple.SubjectType 可选), + -- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。 + semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, updated_at = excluded.updated_at`, block.ID, block.Modality, block.Text, block.PayloadDigest, block.MIME, block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint, @@ -144,8 +176,7 @@ func (g *GraphDB) PutDocumentNode(id, summary string) error { func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) { g.mu.RLock() defer g.mu.RUnlock() - rows, err := g.db.Query(`SELECT id, modality, text_content, payload_digest, mime, - size, width, height, vector, fingerprint, source, tool, created_at, updated_at + rows, err := g.db.Query(`SELECT ` + blockColumns + ` FROM memory_blocks ORDER BY created_at, id`) if err != nil { return nil, err @@ -154,24 +185,83 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) { var blocks []MemoryBlock for rows.Next() { - var block MemoryBlock - var vectorJSON string - if err := rows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest, - &block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON, - &block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt, - &block.UpdatedAt); err != nil { + block, err := scanBlockRow(rows) + if err != nil { return nil, err } - if vectorJSON != "" && vectorJSON != "null" { - if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil { - return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err) - } - } blocks = append(blocks, block) } return blocks, rows.Err() } +// scanBlockRow 读一行 memory_blocks。 +// +// ★★ 列顺序与个数必须与**每个**调用它的 SELECT 完全一致 +// ----------------------------------------------- +// 共用扫描的代价是:SELECT 少列就整体读不出(不是某字段零值,而是 +// 整行 Scan 失败 ⇒ 该路径静默返回空)。 +// +// 实测踩过:MemoryBlocks() 的 SELECT 少了 scene 列(14 列), +// 而 block_recall.go / edge_entity.go 是 15 列,换成共用扫描后 +// 三处一起读不出块 —— 症状是「召回返回未找到相关记忆」, +// 完全看不出是列数问题。 +// +// ⇒ 改任何 memory_blocks 的 SELECT 时,**数一遍列**。 +// +// ★ 提取成共用函数的原因 +// ------------------ +// BFSBlocks(edge_entity.go)也要读块。若它自己写一份 Scan, +// 将来加列时**两处都要改** —— 而我只加了 schema 列忘了扩读取端 +// 已经犯过一次(属性静默变零值,被 TestEdge_承载关系属性 抓到)。 +// 共用扫描让「加列漏改」不再可能。 +// blockColumns 是 memory_blocks 的完整列清单,**必须与 scanBlockRow 的 +// Scan 顺序逐列一致**。 +// +// ★★★ 加列时的必读(2026-10-04 实测踩了 6 次) +// +// 加 semantic_type 那一次,**四处 SELECT 是手写列清单**(不是常量), +// 两处 Scan 也是手写 —— 于是: +// +// 「sql: expected 16 destination arguments in Scan, not 15」 +// +// ★ 这个错**只在真跑 SQL 时暴露**,编译期完全无感; +// +// 而且它表现为「召回突然空了」而非「读失败」, +// 很容易被误判成召回逻辑坏了而查错方向。 +// +// ⇒ 本文件的 SELECT 一律用 blockColumns,不要再手写。 +// ⇒ 加列后必须做两件事: +// 1. `grep -rn 'FROM memory_blocks' --include='*.go'` 确认无手写清单 +// 2. `grep -rn '&b.Scene,\|&scene,' --include='*.go'` 确认所有 Scan 已同步 +// +// ★ 为什么要提成常量:2026-10-04 修过一次由此引发的生产 bug —— +// +// MemoryBlocks() 的 SELECT 漏了 scene 列,而它与 scanBlockRow 共用, +// 结果媒体与召回测试大面积变红(列数不匹配 ⇒ 读失败)。 +// +// 单靠“记得同步”不可靠;提成常量后新增查询直接复用。 +const blockColumns = `id, modality, text_content, payload_digest, mime, size, width, height, + vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at` + +func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) { + var b MemoryBlock + var vectorJSON string + var scene sql.NullString + if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, + &b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON, + &b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType, + &b.CreatedAt, &b.UpdatedAt); err != nil { + return b, err + } + b.Scene = scene.String + if vectorJSON != "" && vectorJSON != "null" { + if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil { + return b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err) + } + } + return b, nil +} + func validGraphNodeKind(kind string) bool { return kind == "block" || kind == "entity" || kind == "sentence" || kind == "document" } @@ -219,20 +309,46 @@ func (g *GraphDB) AddMemoryBlockEdge(sourceKind, sourceID, targetKind, targetID, return fmt.Errorf("%s graph node %s does not exist", endpoint.kind, endpoint.id) } } - _, err = tx.Exec(`INSERT OR IGNORE INTO memory_block_edges - (source_kind, source_id, target_kind, target_id, edge_type) - VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType) + // ★★ 结构边必须**显式查重** —— 不能再依赖 INSERT OR IGNORE + // + // 边表升格去掉了 UNIQUE(source_kind,source_id,target_kind,target_id, + // edge_type)(因为它让「同一对节点并存多条同类关系边」不可能)。 + // 而 OR IGNORE 的去重**正是靠那个 UNIQUE 实现的** —— + // 约束一去,重复调用就会插出两条一样的 contains 边。 + // + // 实测后果:distill 幂等与迁移幂等两个判据当场红了。 + // + // ⇒ 结构边(contains 等)在这里显式查;关系边走 + // AddRelationBlockEdge,它**刻意**不查重(可并存多条)。 + var existing int + err = tx.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE source_kind=? AND source_id=? AND target_kind=? AND target_id=? + AND edge_type=? AND COALESCE(session_id,'')=''`, + sourceKind, sourceID, targetKind, targetID, edgeType).Scan(&existing) if err != nil { return err } + if existing > 0 { + return nil // 已存在:结构边幂等 + } + + if _, err = tx.Exec(`INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type) + VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType); err != nil { + return err + } return tx.Commit() } func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) { g.mu.RLock() defer g.mu.RUnlock() + // ★ 必须读全部 11 列 —— 只读旧 6 列会让关系边的属性 + // (session_id / confidence / status)静默变成零值。 + // 这正是第一版加列时漏掉的地方:写了列但没扩读取端。 rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id, - edge_type, created_at FROM memory_block_edges ORDER BY id`) + edge_type, created_at, confidence, session_id, turn_id, status, merged_into + FROM memory_block_edges ORDER BY id`) if err != nil { return nil, err } @@ -240,10 +356,21 @@ func (g *GraphDB) MemoryBlockEdges() ([]MemoryBlockEdge, error) { var edges []MemoryBlockEdge for rows.Next() { var edge MemoryBlockEdge + var sess, status, merged sql.NullString + var conf sql.NullFloat64 + var turn sql.NullInt64 if err := rows.Scan(&edge.ID, &edge.SourceKind, &edge.SourceID, &edge.TargetKind, - &edge.TargetID, &edge.Type, &edge.CreatedAt); err != nil { + &edge.TargetID, &edge.Type, &edge.CreatedAt, + &conf, &sess, &turn, &status, &merged); err != nil { return nil, err } + edge.Confidence = conf.Float64 + edge.SessionID = sess.String + edge.Status = status.String + edge.MergedInto = merged.String + if turn.Valid { + edge.TurnID = int(turn.Int64) + } edges = append(edges, edge) } return edges, rows.Err() @@ -285,3 +412,359 @@ func (g *GraphDB) BlocksForNode(nodeKind, nodeID string) ([]MemoryBlock, error) } return blocks, rows.Err() } + +// addContainsEdgeTx 在事务内建一条**结构边**(原句块 → 关系边)。 +// +// ★ 为什么不能用 AddRelationBlockEdge:它刻意不查重(同对节点可并存多条 +// +// 同类型关系边,这是 52e4596 的设计)。结构边语义相反 —— 同一句与同一条 +// 关系之间只应有一条 contains,重复插入会让 BFS 的邻接表里出现重复项。 +// +// ★ 为什么不能用 addBlockEdgeTx:它是迁移专用的 INSERT OR IGNORE 版本, +// +// 端点校验走的是旧表。 +// +// ★ 端点允许是**边 ID**:原句块指向的是关系边(memory_block_edges.id), +// +// 所以 targetKind 用 'edge'。graphNodeExists 已支持该 kind。 +// +// ★ 幂等:重复写入静默跳过(返回 nil),让重试安全。 +func addContainsEdgeTx(tx *sql.Tx, sentenceBlockID string, edgeID int64, weight float64) error { + if sentenceBlockID == "" || edgeID == 0 { + return nil + } + var n int + if err := tx.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE source_kind='block' AND source_id=? + AND target_kind='edge' AND target_id=? + AND edge_type='contains' AND COALESCE(session_id,'')=''`, + sentenceBlockID, edgeID).Scan(&n); err != nil { + return err + } + if n > 0 { + return nil + } + _, err := tx.Exec(`INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type, + confidence, status) + VALUES ('block', ?, 'edge', ?, 'contains', ?, 'active')`, + sentenceBlockID, edgeID, weight) + return err +} + +// ═══════════════════════════════════════════════════════════════ +// 读方切换用的薄包装(2026-10-04) +// +// 这三个函数存在的唯一理由:让 indexer / social / distill / Purge +// 能从旧表(entities/relations/sentences)迁到块体系。 +// +// ★ 它们**刻意不做语义加工** —— 只做「按名找块」「枚举块名」 +// 「取某块的邻居边」这三件最朴素的事。 +// 任何排序、过滤、仲裁、召回打分都该在调用方做, +// 否则同一个语义会散落在两处,然后各自漂移。 +// +// ★ 全部走 g.mu,所以调用方**不得**已持锁。 +// ═══════════════════════════════════════════════════════════════ + +// BlockByText 按文本取块(精确匹配)。 +// +// 用途对应旧表的 `WHERE name = ?` —— 旧表的 name 是实体名, +// 块侧的等价物是 text_content。 +// +// ★ 为什么用 = 而不是 LIKE:旧表 name 是**规范化过的实体名** +// +// (Commit 走 TripleBlockID(t.Subject) 这样的内容派生 ID), +// 精确匹配能命中;模糊匹配会把「值班室分机号」与「值班室分机号(旧)」 +// 混为一谈,而那正是仲裁要区分的东西。 +// +// ★ 不区分 modality:媒体块的 text_content 常为空, +// +// 空串匹配不到任何真实查询(调用方不会传空串查)。 +func (g *GraphDB) BlockByText(text string) (*MemoryBlock, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, nil + } + g.mu.Lock() + defer g.mu.Unlock() + + blocks, err := g.blocksByTextTx(text) + if err != nil { + return nil, err + } + if len(blocks) == 0 { + return nil, nil + } + // 多个块同文本时取**最早**的:TripleBlockID 是内容派生的, + // 理论上一个文本一个块;重复只可能来自迁移的历史数据, + // 此时取最早的(它的 created_at 来自源实体,语义上更接近原意)。 + b := blocks[0] + return &b, nil +} + +// blocksByTextTx 按文本取全部同文本块(调用方已持锁)。 +func (g *GraphDB) blocksByTextTx(text string) ([]MemoryBlock, error) { + rows, err := g.db.Query( + `SELECT `+blockColumns+` FROM memory_blocks + WHERE text_content = ? ORDER BY created_at ASC, id ASC`, text) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MemoryBlock + for rows.Next() { + b, err := scanBlockRow(rows) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// BlockTextsLike 返回文本匹配 LIKE 的块(大小写不敏感,%value% 包裹)。 +// +// 用途对应旧表的 `WHERE LOWER(name) LIKE ?`。 +// ★ 调用方自己拼 LIKE 还是传裸子串?**传裸子串** —— +// +// 包裹在这里做,避免每个调用方各写各的 %…% 然后漏掉转义。 +// +// ★ 不做 N 限制:调用方要么自己限,要么要全量(建索引类场景)。 +// +// 但 ⚠️ 全表 LIKE 在大库上是 O(n) —— 调用方需自行评估。 +func (g *GraphDB) BlockTextsLike(fragment string) ([]MemoryBlock, error) { + fragment = strings.TrimSpace(fragment) + if fragment == "" { + return nil, nil + } + pattern := "%" + escapeLike(fragment) + "%" + g.mu.Lock() + defer g.mu.Unlock() + + rows, err := g.db.Query( + `SELECT `+blockColumns+` FROM memory_blocks + WHERE text_content LIKE ? ESCAPE '\' + ORDER BY created_at ASC, id ASC`, pattern) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MemoryBlock + for rows.Next() { + b, err := scanBlockRow(rows) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// escapeLike 转义 LIKE 的元字符(% _ 与 ESCAPE 自身)。 +// +// ★ 不转义的话,「50%」这类文本会变成通配符, +// +// 在 Purge 的删除路径上就是**误删**。 +func escapeLike(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(s) +} + +// AllBlockTexts 返回全部块的文本(去重,按出现顺序)。 +// +// 用途对应旧表的「全量实体名」—— 建向量索引(indexer)与 +// 记忆整理(distill)都需要这个。 +// +// ★ 去重:同名实体在旧表是**多行**(mention_count 不同), +// +// 而块侧的 TripleBlockID 是内容派生 ⇒ 同文本本应只有一个块。 +// 去重让调用方不必处理重复,也顺带掩盖迁移期的历史重复。 +// +// ★ 分批读取:全表取进内存在百万块级会炸。 +// +// batchSize<=0 时用默认批大小。 +func (g *GraphDB) AllBlockTexts(batchSize int) ([]string, error) { + if batchSize <= 0 { + batchSize = 5000 + } + g.mu.Lock() + defer g.mu.Unlock() + + var out []string + seen := make(map[string]bool) + var lastID string + for { + rows, err := g.db.Query( + `SELECT id, text_content FROM memory_blocks + WHERE id > ? AND text_content != '' + ORDER BY id LIMIT ?`, lastID, batchSize) + if err != nil { + return nil, err + } + n := 0 + var nextID string + for rows.Next() { + var id, text string + if err := rows.Scan(&id, &text); err != nil { + rows.Close() + return nil, err + } + nextID = id + n++ + if seen[text] { + continue + } + seen[text] = true + out = append(out, text) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if n < batchSize { + break + } + lastID = nextID + } + return out, nil +} + +// NeighbourEdgesOfBlock 返回以该块为端点的全部 active 关系边及其对端块。 +// +// 用途对应旧表的「按实体名找它的所有关系」—— social 层按人/物聚合 +// 社交关系时用它。 +// +// ★ 双向:块作为 source 或 target 都算。 +// ★ 只取非 deleted 的边(与全部读路径的 status 口径一致)。 +// +// ★ 返回顺序按创建时间 —— 稳定的输出对测试与展示都重要, +// +// 而 SQLite 不保证无 ORDER BY 的行序。 +func (g *GraphDB) NeighbourEdgesOfBlock(blockID string) ([]BlockNeighbour, []MemoryBlock, error) { + if blockID == "" { + return nil, nil, nil + } + g.mu.Lock() + defer g.mu.Unlock() + + rows, err := g.db.Query( + `SELECT e.id, e.source_id, e.target_id, e.edge_type, + COALESCE(e.confidence, 0), COALESCE(e.session_id, ''), + COALESCE(e.turn_id, 0), COALESCE(e.status, ''), + COALESCE(e.created_at, ''), + COALESCE(other.id, ''), COALESCE(other.text_content, '') + FROM memory_block_edges e + LEFT JOIN memory_blocks other + ON other.id = CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END + WHERE (e.source_id = ? OR e.target_id = ?) + AND e.source_kind = 'block' AND e.target_kind = 'block' + AND COALESCE(e.status, '') != 'deleted' + ORDER BY e.created_at ASC, e.id ASC`, blockID, blockID, blockID) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + var edges []BlockNeighbour + var peers []MemoryBlock + seenPeer := make(map[string]bool) + for rows.Next() { + var nb BlockNeighbour + var otherID, otherText, created string + if err := rows.Scan(&nb.EdgeID, &nb.SourceID, &nb.TargetID, &nb.EdgeType, + &nb.Confidence, &nb.SessionID, &nb.TurnID, &nb.Status, &created, + &otherID, &otherText); err != nil { + return nil, nil, err + } + nb.IsOutgoing = nb.SourceID == blockID + nb.Peer = MemoryBlock{ + ID: otherID, + Modality: BlockText, + Text: otherText, + CreatedAt: parseLegacyTime(created), + UpdatedAt: parseLegacyTime(created), + } + nb.RelationEdgeData = RelationEdgeData{ + Confidence: nb.Confidence, + SessionID: nb.SessionID, + TurnID: nb.TurnID, + Status: nb.Status, + } + edges = append(edges, nb) + if otherText != "" && !seenPeer[otherID] { + seenPeer[otherID] = true + peers = append(peers, nb.Peer) + } + } + return edges, peers, rows.Err() +} + +// blockSemanticType 返回块对外呈现的语义类型。 +// +// ★ 为什么要这个函数而不是直接用 b.SemanticType: +// +// 语义类型是**可选标注**,绝大多数块没被标注过。 +// 旧 entities 表在缺省时写 "Concept"(graph.go 里 subjType 默认值), +// 所以读侧(social 的 ListPersons、SDK 的实体类型)期待一个具体字符串。 +// 返回空串会让下游的 == "Person" 之类的比较全部落空 —— +// 那不是「未标注」,那像是「数据坏了」。 +// +// ★ 取值优先级: +// 1. 块自带标注(Person / Animal / Concept…)—— 直接用 +// 2. 未标注 → "block"(说清「这是一个图节点,且没标类型」) +// ★ 不用 "Concept":那是旧表的默认值, +// 拿来兜底会让「未标注」与「确实是概念」无法区分。 +func blockSemanticType(b MemoryBlock) string { + if t := strings.TrimSpace(b.SemanticType); t != "" { + return t + } + return "block" +} + +// MemoryBlockCount 返回当前块总数。 +// +// ★ 用途:判断「一次写入是否真的落库了」 +// +// 停旧表双写后,Commit 的两个返回值恒为 0(它们数的是旧表行数), +// 于是「有没有写进去」失去了信号 —— +// 而 archiveColdDocs 正需要它来决定文档能否删除 +// (没写进去就删 = 丢数据)。 +// +// ★ 为什么用块数而不是「本次新增块数」: +// +// 新增数需要事务内计数,而 MemoryBlockCount 是事务外的简单计数。 +// 调用方在写入前后各取一次相减即可,误差只来自并发写入 —— +// 归档是后台低频任务,这个精度足够。 +func (g *GraphDB) MemoryBlockCount() (int, error) { + g.mu.RLock() + defer g.mu.RUnlock() + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// MemoryEdgeCount 返回关系边数(不含 contains 结构边)。 +// +// ★ 为什么需要它(2026-10-05) +// +// 旧表停双写后 Commit 的 ec/rc 恒为 0(graph.go 里 +// relationsCreated = 0 是写死的),所以「写了多少条关系」 +// 唯一的可信来源只能是**实测边数差值**。 +// +// 不含 contains:那是结构边(原句块 → 字段块 / 关系边), +// 混进来会让「关系数」随原句数量翻倍 —— 与 Introspect 的 +// relation_count 口径保持一致。 +func (g *GraphDB) MemoryEdgeCount() (int, error) { + g.mu.RLock() + defer g.mu.RUnlock() + var n int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_block_edges + WHERE source_kind = 'block' AND target_kind = 'block' + AND edge_type != 'contains'`).Scan(&n); err != nil { + return 0, err + } + return n, nil +} diff --git a/internal/memory/block_recall.go b/internal/memory/block_recall.go new file mode 100644 index 00000000..53a9da7e --- /dev/null +++ b/internal/memory/block_recall.go @@ -0,0 +1,293 @@ +package memory + +import ( + "encoding/json" + "fmt" + "log" + "math" + "sort" +) + +// 块节点的向量召回。 +// +// 为什么需要它:entities 是旧形态且正在退场,而 memory_blocks 才是带 +// vector+fingerprint 的节点载体(46f833c 引入)。此前唯一有向量的只有 +// memory_blocks,但**没有任何召回路径读它** —— BlocksForNode 只能按 +// 端点反查,必须先知道 nodeID。于是「问一个具体问题」时只能退到 +// entities 的 jieba+LIKE,实测跨维度定位 0/5。 +// +// 本文件补的就是这条缺失的路径:query 向量 → 块向量 → 按余弦排序。 +// +// 与 vector.Store 的分工:Store 是内存倒排索引(L0/L1 用),块向量存在 +// SQLite(graph.db),生命周期与图一致、可跨进程重启、可被备份脚本覆盖。 +// +// 全表扫描的代价(**已量化,不是估算**):生产库迁移后约 1277 块 × 2048 维 +// = 21 MB 读入 + 2.6M 次乘加,每次 memory_recall 一次。当前可接受; +// 块数上到万级时需要换方案(候选:把向量搬进 vector.Store 的内存倒排, +// 或按 modality/scene 预筛减少扫描量)。在此之前不做优化 —— 没有真实 +// 规模数据时优化是猜,本会话已经因此浪费过几轮。 + +// BlockHit 是一条块召回的候选及相似度。 +type BlockHit struct { + Block MemoryBlock `json:"block"` + Score float64 `json:"score"` +} + +// BlockRecallQuery 描述一次块召回请求。 +type BlockRecallQuery struct { + Vector []float64 + // Fingerprint 是发起方所用向量空间的指纹。与库中块不一致的块会被 + // **跳过**(不是报错):库里可能同时存在两个空间的块(迁移期), + // 跳过才是正确的——拿新空间的查询向量去比旧空间的块向量,余弦值 + // 没有任何意义,而且不会报错,只是"结果看起来还行但全是错的"。 + Fingerprint string + TopK int + // MinScore 低于此分数的候选被丢弃(0 表示不设下限)。 + MinScore float64 + // SkipCentroid 关闭中心化校正,直接用原始向量算余弦。 + // + // 默认是要校正的:实测 chineseclip 的块向量均值范数 0.9374, + // 93.7% 的能量在同一个方向上,导致同属性不同值的分离度只有 0.0095 + // (去均值后 0.0689,提升 7.3 倍)。而中心是**按需加载**的 —— + // 库里没建过中心、或中心已失效(块数变化 >20%)时自动跳过校正, + // 所以这个开关只在「明确知道中心存在却想绕开」时才需要。 + SkipCentroid bool +} + +// RecallBlocks 按向量相似度召回块节点。 +// +// 返回的 hit 已按 Score 降序。库里没有任何块的向量时返回空切片而非错误 +// ——「还没回填」是正常状态,不是故障。 +func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) { + if len(q.Vector) == 0 { + return nil, fmt.Errorf("recall blocks: empty query vector") + } + topK := q.TopK + if topK <= 0 { + topK = 10 + } + + // 中心在 RLock 之前加载:LoadCentroid 内部自己拿锁, + // 而 g.mu 是写锁优先的 Mutex(不可重入),RLock 里再 RLock 虽可行 + // 但夹着写锁时会出现窗口。分开更稳。 + var centroid *Centroid + if !q.SkipCentroid && q.Fingerprint != "" { + c, use, err := g.LoadCentroid(q.Fingerprint, len(q.Vector)) + if err != nil { + // 中心读取失败不该让召回失败 —— 退回原始向量即可, + // 只是少了个校正。记日志以便发现。 + log.Printf("[graph] recall blocks: 中心向量读取失败,退回原始向量: %v", err) + } else if use { + centroid = c + } + } + + g.mu.RLock() + defer g.mu.RUnlock() + + rows, err := g.db.Query(`SELECT ` + blockColumns + ` + FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`) + if err != nil { + return nil, err + } + defer rows.Close() + + dim := len(q.Vector) + var hits []BlockHit + var skippedDim, skippedFP, scanned int + for rows.Next() { + var b MemoryBlock + var vectorJSON string + if err := rows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, + &b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON, + &b.Fingerprint, &b.Source, &b.Tool, &b.Scene, &b.SemanticType, + &b.CreatedAt, &b.UpdatedAt); err != nil { + return nil, err + } + scanned++ + vec, err := decodeVector(vectorJSON) + if err != nil { + // 单条坏数据不该让整次召回失败 —— 但要记日志,否则会静默缺失。 + log.Printf("[graph] recall blocks: skip %s: %v", b.ID, err) + continue + } + // 维度不一致:跳过并计数。**不截断、不填充**——那会造出无意义的分数。 + if len(vec) != dim { + skippedDim++ + continue + } + // 指纹不一致:跳过并计数。查询方声明的空间与块所属空间不同。 + if q.Fingerprint != "" && b.Fingerprint != "" && b.Fingerprint != q.Fingerprint { + skippedFP++ + continue + } + // ★ 中心化:查询向量与块向量**都要**中心化后再算余弦。 + // 只处理一边的话等于在混两种量纲(一半带公共分量、一半不带), + // 算出的余弦没有意义。 + score := centerBoth(centroid, vec, q.Vector) + if q.MinScore > 0 && score < q.MinScore { + continue + } + b.Vector = vec + hits = append(hits, BlockHit{Block: b, Score: score}) + } + if err := rows.Err(); err != nil { + return nil, err + } + + if skippedDim > 0 { + log.Printf("[graph] recall blocks: 跳过 %d 个维度不匹配的块(查询 %d 维,共扫 %d)", + skippedDim, dim, scanned) + } + if skippedFP > 0 { + log.Printf("[graph] recall blocks: 跳过 %d 个指纹不匹配的块(查询空间 %s,共扫 %d)", + skippedFP, shortFP(q.Fingerprint), scanned) + } + + sort.SliceStable(hits, func(i, j int) bool { return hits[i].Score > hits[j].Score }) + if len(hits) > topK { + hits = hits[:topK] + } + return hits, nil +} + +// RecallBlocksWithArbitration 与 RecallBlocks 相同,但额外返回仲裁详情 +// (被取代的块、结论状态)。 +// +// 分成两个入口的原因:调用方大多只关心「该给模型看什么」,仲裁详情是 +// 调试与跑分用的。把它塞进返回签名会让 90% 的调用点多做无用功。 +func (g *GraphDB) RecallBlocksWithArbitration(q BlockRecallQuery) ([]BlockHit, ArbitrationResult, error) { + // ★ 仲裁前必须拿到**未截断**的全量候选。 + // + // 实测故障形态(真实 chineseclip + 真库 188 块):查询「值班室分机号 + // 是多少」,旧号 4379 以 0.8127 排 top1,新号 4324 那条**进不了 + // top8**。正确记录在召回阶段就被挤掉了 —— 事后仲裁无从挽回, + // 因为被判取代的旧值和新值都不在候选里。 + // + // 所以顺序是:全量打分(TopK 放大)→ 仲裁 → 按调用方的 TopK 截断。 + // + // 为什么 RecallBlocks 本身不仲裁:它是通用召回接口, + // 返回顺序按余弦相似度是它的**既有契约**(有测试钉着)。 + // 仲裁改变的是「给模型看哪些、按什么顺序看」,属于上层策略 —— + // 混进召回层会让这个接口的语义变得含糊(调用方说不清拿到的是什么序)。 + q2 := q + q2.TopK = largeTopK + hits, err := g.RecallBlocks(q2) + if err != nil { + return nil, ArbitrationResult{}, err + } + arb := arbitrate(g, hits) + kept := arb.Kept + if k := effectiveTopK(q.TopK); len(kept) > k { + kept = kept[:k] + } + return kept, arb, nil +} + +// largeTopK 是仲裁前的候选上限。 +// +// ★ 为什么要有上限而不是真·全量:仲裁是 O(n²),候选数若等于库里全部 +// 带向量的块(实测 188,回填后可能上万),比较次数会到亿级。 +// 这个值远大于正常 TopK(默认 10 / 生产 5~20),实际效果是「足够全」, +// 同时给出一个确定的性能上界。 +const largeTopK = 2000 + +// effectiveTopK 归一 TopK(与 RecallBlocks 内部一致)。 +func effectiveTopK(k int) int { + if k <= 0 { + return 10 + } + return k +} + +// BlockVectorStats 报告块向量的回填状态,用于运维判断「要不要跑回填」。 +type BlockVectorStats struct { + Total int `json:"total"` + WithVector int `json:"with_vector"` + MissingFP int `json:"missing_fingerprint"` + Fingerprints []string `json:"fingerprints"` + Dimensions []int `json:"dimensions"` +} + +func (g *GraphDB) BlockVectorStats() (BlockVectorStats, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + var st BlockVectorStats + fpSet := map[string]bool{} + dimSet := map[int]bool{} + rows, err := g.db.Query(`SELECT vector, fingerprint FROM memory_blocks`) + if err != nil { + return st, err + } + defer rows.Close() + for rows.Next() { + var vectorJSON, fp string + if err := rows.Scan(&vectorJSON, &fp); err != nil { + return st, err + } + st.Total++ + if vectorJSON == "" || vectorJSON == "null" { + continue + } + vec, err := decodeVector(vectorJSON) + if err != nil { + continue + } + st.WithVector++ + if fp == "" { + st.MissingFP++ + } else { + fpSet[fp] = true + } + dimSet[len(vec)] = true + } + for fp := range fpSet { + st.Fingerprints = append(st.Fingerprints, fp) + } + for d := range dimSet { + st.Dimensions = append(st.Dimensions, d) + } + sort.Strings(st.Fingerprints) + sort.Ints(st.Dimensions) + return st, rows.Err() +} + +// ── 向量工具 ── + +func decodeVector(s string) ([]float64, error) { + var vec []float64 + if err := json.Unmarshal([]byte(s), &vec); err != nil { + return nil, fmt.Errorf("decode vector: %w", err) + } + return vec, nil +} + +func cosine(a, b []float64) float64 { + if len(a) != len(b) || len(a) == 0 { + return 0 + } + var dot, na, nb float64 + for i := range a { + dot += a[i] * b[i] + na += a[i] * a[i] + nb += b[i] * b[i] + } + if na == 0 || nb == 0 { + return 0 + } + s := dot / (math.Sqrt(na) * math.Sqrt(nb)) + // NaN 会让排序结果不确定(NaN 的比较全 false),显式归零。 + if math.IsNaN(s) { + return 0 + } + return s +} + +func shortFP(fp string) string { + if len(fp) > 12 { + return fp[:12] + } + return fp +} diff --git a/internal/memory/block_recall_test.go b/internal/memory/block_recall_test.go new file mode 100644 index 00000000..59eb60a7 --- /dev/null +++ b/internal/memory/block_recall_test.go @@ -0,0 +1,235 @@ +package memory + +import ( + "math" + "os" + "testing" +) + +func newBlockTestGraph(t *testing.T) *GraphDB { + t.Helper() + g := newTestGraph(t) + t.Cleanup(func() { + g.Close() + os.Remove(g.dbPath) + }) + return g +} + +func vec(vals ...float64) []float64 { return vals } + +// ★ 基本召回:按余弦排序,TopK 截断。 +func TestRecallBlocks_按余弦排序(t *testing.T) { + g := newBlockTestGraph(t) + mustPut(t, g, []MemoryBlock{ + {ID: "b_far", Modality: BlockText, Text: "无关内容", + Vector: vec(0, 1, 0), Fingerprint: "fp1"}, + {ID: "b_near", Modality: BlockText, Text: "最相关", + Vector: vec(1, 0, 0), Fingerprint: "fp1"}, + {ID: "b_mid", Modality: BlockText, Text: "中等相关", + Vector: vec(0.7, 0.7, 0), Fingerprint: "fp1"}, + }) + + hits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: vec(1, 0, 0), Fingerprint: "fp1", TopK: 3, + }) + if err != nil { + t.Fatalf("RecallBlocks: %v", err) + } + if len(hits) != 3 { + t.Fatalf("期望 3 条,实际 %d", len(hits)) + } + want := []string{"b_near", "b_mid", "b_far"} + for i, id := range want { + if hits[i].Block.ID != id { + t.Errorf("第 %d 位应为 %s,实际 %s(score=%.4f)", + i, id, hits[i].Block.ID, hits[i].Score) + } + } + if math.Abs(hits[0].Score-1.0) > 1e-9 { + t.Errorf("正交向量自身相似度应为 1,实际 %.9f", hits[0].Score) + } +} + +// ★ TopK 必须真的截断(否则召回会灌爆上下文——这是本会话实测过的症状)。 +func TestRecallBlocks_TopK截断(t *testing.T) { + g := newBlockTestGraph(t) + var blocks []MemoryBlock + for i := 0; i < 20; i++ { + blocks = append(blocks, MemoryBlock{ + ID: "b" + itoa(i), Modality: BlockText, Text: "t", + Vector: vec(float64(i+1), 1, 0), Fingerprint: "fp1", + }) + } + mustPut(t, g, blocks) + + hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0, 0), TopK: 5}) + if err != nil { + t.Fatal(err) + } + if len(hits) != 5 { + t.Fatalf("TopK=5 应返回 5 条,实际 %d", len(hits)) + } +} + +// ★★ 指纹防护:换向量空间后,旧空间的块必须被**跳过**。 +// +// 这是本文件最重要的判据。拿新空间的查询向量去比旧空间的块向量, +// 余弦值没有任何意义,而且**不报错**——只是"结果看起来还行但全是错的"。 +func TestRecallBlocks_指纹不匹配则跳过(t *testing.T) { + g := newBlockTestGraph(t) + mustPut(t, g, []MemoryBlock{ + {ID: "old", Modality: BlockText, Text: "旧空间", + Vector: vec(1, 0, 0), Fingerprint: "old-space"}, + {ID: "new", Modality: BlockText, Text: "新空间", + Vector: vec(1, 0, 0), Fingerprint: "new-space"}, + }) + + hits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: vec(1, 0, 0), Fingerprint: "new-space", TopK: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(hits) != 1 || hits[0].Block.ID != "new" { + t.Fatalf("只应召回新空间的块,实际 %+v", hitIDs(hits)) + } + // 旧空间的块若被召回,就是静默污染 + for _, h := range hits { + if h.Block.Fingerprint != "new-space" { + t.Errorf("召回了指纹不匹配的块: %s (%s)", h.Block.ID, h.Block.Fingerprint) + } + } +} + +// ★★ 维度防护:维度不同必须跳过,**不截断不填充**。 +func TestRecallBlocks_维度不匹配则跳过(t *testing.T) { + g := newBlockTestGraph(t) + mustPut(t, g, []MemoryBlock{ + {ID: "d512", Modality: BlockText, Text: "旧 512 维", + Vector: vec(1, 0, 0), Fingerprint: "fp"}, + {ID: "d2048", Modality: BlockText, Text: "新 2048 维", + Vector: append(vec(1, 0, 0), make([]float64, 2045)...), Fingerprint: "fp"}, + }) + + hits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: append(vec(1, 0, 0), make([]float64, 2045)...), Fingerprint: "fp", TopK: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(hits) != 1 || hits[0].Block.ID != "d2048" { + t.Fatalf("只应召回同维度的块,实际 %+v", hitIDs(hits)) + } +} + +// 没有向量的块不参与向量召回(但它们仍可被 BlocksForNode 按边查到)。 +func TestRecallBlocks_无向量的块不参与(t *testing.T) { + g := newBlockTestGraph(t) + mustPut(t, g, []MemoryBlock{ + {ID: "no_vec", Modality: BlockText, Text: "没向量"}, + {ID: "has_vec", Modality: BlockText, Text: "有向量", + Vector: vec(1, 0), Fingerprint: "fp"}, + }) + + hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0), TopK: 10}) + if err != nil { + t.Fatal(err) + } + if len(hits) != 1 || hits[0].Block.ID != "has_vec" { + t.Fatalf("无向量的块不该参与召回,实际 %+v", hitIDs(hits)) + } +} + +// 空查询向量必须报错(而不是静默返回全零分的垃圾结果)。 +func TestRecallBlocks_空向量报错(t *testing.T) { + g := newBlockTestGraph(t) + if _, err := g.RecallBlocks(BlockRecallQuery{}); err == nil { + t.Fatal("空查询向量应报错") + } +} + +// MinScore 过滤。 +func TestRecallBlocks_MinScore过滤(t *testing.T) { + g := newBlockTestGraph(t) + mustPut(t, g, []MemoryBlock{ + {ID: "hi", Modality: BlockText, Text: "高", Vector: vec(1, 0), Fingerprint: "fp"}, + {ID: "lo", Modality: BlockText, Text: "低", Vector: vec(0, 1), Fingerprint: "fp"}, + }) + hits, err := g.RecallBlocks(BlockRecallQuery{ + Vector: vec(1, 0), TopK: 10, MinScore: 0.5, + }) + if err != nil { + t.Fatal(err) + } + if len(hits) != 1 || hits[0].Block.ID != "hi" { + t.Fatalf("MinScore 应滤掉低分候选,实际 %+v", hitIDs(hits)) + } +} + +// 库里没有任何块时返回空切片而非错误("还没回填"是正常状态)。 +func TestRecallBlocks_空库不报错(t *testing.T) { + g := newBlockTestGraph(t) + hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec(1, 0), TopK: 5}) + if err != nil { + t.Fatalf("空库不该报错: %v", err) + } + if len(hits) != 0 { + t.Fatalf("空库应返回 0 条,实际 %d", len(hits)) + } +} + +// BlockVectorStats 报告回填状态(运维据此判断要不要跑回填)。 +func TestBlockVectorStats(t *testing.T) { + g := newBlockTestGraph(t) + mustPut(t, g, []MemoryBlock{ + {ID: "a", Modality: BlockText, Text: "有向量有指纹", + Vector: vec(1, 0), Fingerprint: "fpX"}, + {ID: "b", Modality: BlockText, Text: "有向量无指纹", + Vector: vec(1, 0)}, + {ID: "c", Modality: BlockText, Text: "无向量"}, + }) + + st, err := g.BlockVectorStats() + if err != nil { + t.Fatal(err) + } + if st.Total != 3 || st.WithVector != 2 || st.MissingFP != 1 { + t.Fatalf("统计错误: %+v", st) + } + if len(st.Fingerprints) != 1 || st.Fingerprints[0] != "fpX" { + t.Errorf("指纹集错误: %v", st.Fingerprints) + } + if len(st.Dimensions) != 1 || st.Dimensions[0] != 2 { + t.Errorf("维度集错误: %v", st.Dimensions) + } +} + +// ── 辅助 ── + +func mustPut(t *testing.T, g *GraphDB, blocks []MemoryBlock) { + t.Helper() + if err := g.PutMemoryBlocks(blocks); err != nil { + t.Fatalf("PutMemoryBlocks: %v", err) + } +} + +func hitIDs(hits []BlockHit) []string { + out := make([]string, len(hits)) + for i, h := range hits { + out[i] = h.Block.ID + } + return out +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + var b []byte + for i > 0 { + b = append([]byte{byte('0' + i%10)}, b...) + i /= 10 + } + return string(b) +} diff --git a/internal/memory/centroid.go b/internal/memory/centroid.go new file mode 100644 index 00000000..9056281a --- /dev/null +++ b/internal/memory/centroid.go @@ -0,0 +1,244 @@ +package memory + +import ( + "math" + "sync" +) + +// 向量中心化(centering / 各向异性校正)。 +// +// ★ 为什么需要 —— 实测数据(真库 260 个 distill 块,chineseclip 512 维) +// +// 均值向量长度 = 0.9374 (1.0 = 所有向量完全同向) +// 平均两两余弦 = 0.8727 ← 与「|mean|²=0.8787」吻合 +// 去均值后平均两两余弦 = -0.0012 ← 各向同性 +// +// **93.7% 的能量花在同一个方向上。** 换句话说,两块文本的相似度里 +// 大部分不是"它们像"贡献的,而是"它们都朝那个方向偏"贡献的。 +// +// 这解释了本会话一系列看起来矛盾的现象: +// +// 「值班室分机号 4324」cos = 0.9284 +// 「值班室分机号 4379」cos = 0.9298 ← 新旧号差 0.0014 +// 粗筛 0.9 阈值 → 44% 误报(等于没有阈值) +// +// ★ 我此前的判断是错的:写的是「CLIP 架构把纯文本压扁了, +// 细粒度区分度天然低」。那是**把症状当成了原因** —— +// 不是模型分不清,是所有向量挤在一个锥体里。RoBERTa/BERT 系列的 +// CLS 向量各向异性是有名的现象,与 CLIP 无关。 +// +// 去均值后的实测收益: +// +// 原始 去均值 +// 同值对最低 1.0000 1.0000 (不受影响) +// 异值对最高 0.9905 0.9311 +// ★ 分离度 0.0095 0.0689 ← 提升 7 倍 +// +// 异值对逐条(原始 → 去均值): +// +// 0.9240 → 0.2494 第112批|版本=v2.31.0 || =周四 +// 0.8167 → 0.2705 第115批|版本=115批v2.31.2 || =115批周二凌晨… +// 0.9223 → 0.2198 第132批|版本=v2.33.1 || =周二 +// +// 原本 0.92 的"看起来很像",去均值后掉到 0.25 —— +// 那 0.92 几乎全是共同方向的假象。 +// +// ── 用法的三条约束 ────────────────────────────────────── +// +// ① **均值必须跨块统计**,不能只用查询向量减自己。 +// 均值的定义域是"这个库里的所有块",查询向量不在库里时 +// 减同一个均值仍然正确(它表达的是"与这个库的公共方向正交化")。 +// +// ② **均值要持久化**。每次查询现算需要扫全库(实测 260 块成本可忽略, +// 但上万块就是每次查询 O(n·d))。且现算的结果依赖"查询时刻的块集合", +// 会让相似度随库变化而漂移。 +// +// ③ **不是所有库都需要**。均值长度接近 1 时(如已经很各向同性的空间) +// 中心化只会放大噪声;接近 0 时则是无意义的额外开销。 +// BuildCentroid 因此返回诊断指标,由调用方判断。 + +// Centroid 是向量空间的中心(各向异性校正的减数)。 +type Centroid struct { + // Sum 是各维累加和。存累加和而非均值,是为了让 Add 能增量累积。 + Sum []float64 + // Count 是参与统计的向量数。 + Count int + // Dim 是向量维度。 + Dim int +} + +// CentroidStats 是中心化的诊断指标。 +type CentroidStats struct { + // MeanLength 是均值向量的 L2 范数(已归一化的向量上)。 + // 接近 1 = 所有向量同向(各向异性严重);接近 0 = 各向同性。 + MeanLength float64 + // AvgPairCosine 是 |mean|² —— 全部配对的平均余弦的解析值, + // 不必真的两两算。 + AvgPairCosine float64 + // Anomalous 判断是否值得做中心化(经验阈值,见 BuildCentroid)。 + Anomalous bool +} + +// meanThreshold 是判定"各向异性严重"的阈值。 +// +// 依据:均值范数 0.9374 时去均值让分离度提升 7 倍;反过来, +// 若均值范数本就接近 0(各向同性),中心化只是放大噪声。 +// 0.6 是个保守取值:低于它说明库本身方向分散,不需要校正。 +const meanThreshold = 0.6 + +// NewCentroid 建一个空中心。 +func NewCentroid(dim int) *Centroid { + return &Centroid{Sum: make([]float64, dim), Dim: dim} +} + +// Add 累积一个向量。维度不匹配会被忽略(返回 false)—— +// 不同 provider 的向量混在一块时不能相加。 +func (c *Centroid) Add(v []float64) bool { + if v == nil || len(v) != c.Dim { + return false + } + for i, x := range v { + c.Sum[i] += x + } + c.Count++ + return true +} + +// Mean 返回均值向量。Count 为 0 时返回 nil。 +func (c *Centroid) Mean() []float64 { + if c.Count == 0 { + return nil + } + out := make([]float64, c.Dim) + for i := range c.Sum { + out[i] = c.Sum[i] / float64(c.Count) + } + return out +} + +// Stats 返回诊断指标。Count 为 0 时返回零值。 +func (c *Centroid) Stats() CentroidStats { + m := c.Mean() + if m == nil { + return CentroidStats{} + } + var norm float64 + for _, x := range m { + norm += x * x + } + norm = math.Sqrt(norm) + return CentroidStats{ + MeanLength: norm, + AvgPairCosine: norm * norm, + Anomalous: norm >= meanThreshold, + } +} + +// Subtract 返回 v 减去中心均值后的向量(原向量不被修改)。 +// 维度不匹配或中心为空时返回 v 的副本(不做校正)。 +func (c *Centroid) Subtract(v []float64) []float64 { + m := c.Mean() + if m == nil || len(v) != c.Dim { + out := make([]float64, len(v)) + copy(out, v) + return out + } + out := make([]float64, len(v)) + for i := range v { + out[i] = v[i] - m[i] + } + return out +} + +// SubtractInPlace 就地校正向量(省一次分配,批量处理时用)。 +func (c *Centroid) SubtractInPlace(v []float64) { + m := c.Mean() + if m == nil || len(v) != c.Dim { + return + } + for i := range v { + v[i] -= m[i] + } +} + +// l2Normalize 就地归一化。零向量或 NaN 时返回 false(不缩放)。 +// +// ★ 为什么中心化后必须重新归一化:减均值不保范数, +// +// 而余弦相似度是尺度相关的 —— 不归一的话所有余弦都会被 +// 「向量变短了」这个纯粹的尺度效应污染。 +func l2Normalize(v []float64) bool { + var norm float64 + for _, x := range v { + norm += x * x + } + norm = math.Sqrt(norm) + if norm == 0 || math.IsNaN(norm) || math.IsInf(norm, 0) { + return false + } + for i := range v { + v[i] /= norm + } + return true +} + +// CenterVector 校正单个向量:减中心均值 + 重新归一化。 +// +// 这才是**完整**的校正 —— 只减不归一是常见错误(见 l2Normalize 的注释)。 +func (c *Centroid) CenterVector(v []float64) []float64 { + out := c.Subtract(v) + l2Normalize(out) + return out +} + +// BuildCentroid 从一组向量建中心,并返回诊断指标。 +// +// ★ 调用方必须读 Stats().Anomalous 再决定是否启用 —— +// +// 中心化不是无条件正确的操作(见 CentroidStats 的注释)。 +func BuildCentroid(vectors [][]float64) (*Centroid, CentroidStats) { + if len(vectors) == 0 { + return nil, CentroidStats{} + } + c := NewCentroid(len(vectors[0])) + for _, v := range vectors { + c.Add(v) + } + return c, c.Stats() +} + +// CentroidCache 缓存中心向量,避免每次查询现算。 +// +// 并发安全:RecallBlocks 会被并发调用,而中心重算是 O(n·d)。 +type CentroidCache struct { + mu sync.RWMutex + c *Centroid + st CentroidStats + n int // 参与统计的向量数 + dim int +} + +// NewCentroidCache 建一个空缓存。 +func NewCentroidCache() *CentroidCache { + return &CentroidCache{} +} + +// Get 返回该(dim, n)对应的中心。没有对应版本时返回 nil。 +func (cc *CentroidCache) Get(dim, n int) (*Centroid, CentroidStats) { + cc.mu.RLock() + defer cc.mu.RUnlock() + if cc.c == nil || cc.dim != dim || cc.n != n { + return nil, CentroidStats{} + } + return cc.c, cc.st +} + +// Put 存入中心。已有的不同版本会被覆盖。 +func (cc *CentroidCache) Put(c *Centroid, st CentroidStats) { + if c == nil { + return + } + cc.mu.Lock() + defer cc.mu.Unlock() + cc.c, cc.st, cc.dim, cc.n = c, st, c.Dim, c.Count +} diff --git a/internal/memory/centroid_store.go b/internal/memory/centroid_store.go new file mode 100644 index 00000000..521e49ea --- /dev/null +++ b/internal/memory/centroid_store.go @@ -0,0 +1,403 @@ +package memory + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "time" +) + +// 中心向量在库里的持久化。 +// +// 为什么必须持久化(而不是每次查询现算) +// ---------------------------------------- +// ① 现算要扫全库所有块向量:O(n·d)。实测 260 块 × 512 维成本可忽略, +// 但生产库是 1277 块、上万块时会变成每次查询的固定开销。 +// ② 更要紧的是**稳定性**:现算的结果依赖"查询那一刻的块集合", +// 库一变,同一个查询的相似度就变 —— 那会让"上周的召回结果" +// 不可复现,而召回结果是要进 benchmark 的。 +// +// 失效条件(写进表里一起存) +// ---------------------------- +// 维度变(换 provider) → 均值的定义域变了 +// 块数变(新增/删除块) → 均值随样本变,但**不是所有变化都要重算** +// 指纹变(换 embedding 空间) → 同上 +// +// 为什么块数变不总是要重算:中心是对"块的分布"的估计, +// 少量新增不影响它的方向;而频繁重算会让相似度漂移。 +// 所以用**相对变化率**:新增/删除超过 20% 才判定失效。 + +// centroidMeta 是中心向量的元信息(存在 kv 表里)。 +type centroidMeta struct { + // Fingerprint 是这个中心所属的向量空间指纹。 + Fingerprint string `json:"fingerprint"` + // Dim 是向量维度。 + Dim int `json:"dim"` + // BlockCount 是建中心时的块数。 + BlockCount int `json:"block_count"` + // VectoredCount 是建中心时**带向量**的块数(分母用这个,不是总块数)。 + VectoredCount int `json:"vectored_count"` + // MeanLength 是均值范数(诊断用,Anomalous 的依据)。 + MeanLength float64 `json:"mean_length"` + // Anomalous 是建中心时的判定结果。 + Anomalous bool `json:"anomalous"` + // BuiltAt 是建中心时刻。 + BuiltAt time.Time `json:"built_at"` +} + +// centroidRebuildRatio 是触发重建的块数相对变化率。 +// +// 20% 的依据:中心是分布的估计,10% 以内的变化对方向影响可忽略; +// 而过于敏感会让相似度随每次写入漂移(那比不校正更糟 —— +// 不可复现的召回无法用于回归测试)。 +const centroidRebuildRatio = 0.20 + +// ddlCentroid 是中心向量的表与索引。 +const ddlCentroid = `CREATE TABLE IF NOT EXISTS graph_centroid ( + fingerprint TEXT PRIMARY KEY, + dim INTEGER NOT NULL, + vector_count INTEGER NOT NULL, + vector TEXT NOT NULL, + stats TEXT NOT NULL, + built_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +)` + +// centroidKVKey 是元信息在 kv 表里的键。 +const centroidKVKey = "graph_centroid_meta" + +// ensureCentroidSchema 建表。调用方必须持写锁或在初始化里做一次。 +func (g *GraphDB) ensureCentroidSchema() error { + if _, err := g.db.Exec(ddlCentroid); err != nil { + return fmt.Errorf("graph: create graph_centroid: %w", err) + } + return nil +} + +// SaveCentroid 存中心向量及其元信息。 +// +// 不校验 anomalous —— 上层决定要不要存。存了但 anomalous=false 时, +// LoadCentroid 会返回 (centroid, false, nil),调用方据此跳过校正。 +func (g *GraphDB) SaveCentroid(c *Centroid, fingerprint string) error { + if c == nil || c.Count == 0 { + return fmt.Errorf("graph: empty centroid") + } + g.mu.Lock() + defer g.mu.Unlock() + + if err := g.ensureCentroidSchema(); err != nil { + return err + } + stats := c.Stats() + meta := centroidMeta{ + Fingerprint: fingerprint, + Dim: c.Dim, + BlockCount: g.countBlocksLocked(), + // ★ 语义定死:VectoredCount = **参与统计的向量数**(c.Count), + // 不是「库里当时的块数」。 + // + // 理由:失效判定要回答的问题是「这个中心还能不能代表当前库的 + // 分布」,而中心是 c.Count 个向量的均值。比较的基准必须是 + // 同一批向量 —— 拿「库当时的块数」会出现两种错法: + // 库里有无向量的块(维度不匹配/指纹不同被跳过)→ 两者不等 → 误判失效 + // 库当时为空 → 基准为 0 → 除零 + VectoredCount: c.Count, + MeanLength: stats.MeanLength, + Anomalous: stats.Anomalous, + BuiltAt: time.Now(), + } + rawStats, err := json.Marshal(meta) + if err != nil { + return err + } + mean := c.Mean() + rawMean, err := json.Marshal(mean) + if err != nil { + return err + } + // vector_count 列存的是 meta.VectoredCount(建中心时刻库里的带向量块数), + // 与失效判定同源 —— 若这里存 c.Count(参与统计的向量数),两者在 + // 「外部喂向量给 SaveCentroid」时不相等,会误判失效。 + if _, err := g.db.Exec(`INSERT INTO graph_centroid + (fingerprint, dim, vector_count, vector, stats, built_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(fingerprint) DO UPDATE SET + dim = excluded.dim, + vector_count = excluded.vector_count, + vector = excluded.vector, + stats = excluded.stats, + built_at = excluded.built_at`, + fingerprint, c.Dim, meta.VectoredCount, string(rawMean), + string(rawStats), meta.BuiltAt); err != nil { + return fmt.Errorf("graph: save centroid: %w", err) + } + log.Printf("[graph] 中心向量已存: fp=%s dim=%d 样本=%d 均值范数=%.4f anomalous=%v", + shortFP(fingerprint), c.Dim, c.Count, stats.MeanLength, stats.Anomalous) + return nil +} + +// LoadCentroid 读中心向量。返回的 bool 是"应当启用校正"。 +// +// 失效判定见文件头。失效时返回 (nil, false, nil) —— 不是错误: +// 「中心还没建」或「该重建」都是正常状态,调用方应跳过校正。 +func (g *GraphDB) LoadCentroid(fingerprint string, queryDim int) (*Centroid, bool, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + var dim, vecCount int + var rawMean, rawStats string + err := g.db.QueryRow(`SELECT dim, vector_count, vector, stats + FROM graph_centroid WHERE fingerprint = ?`, fingerprint). + Scan(&dim, &vecCount, &rawMean, &rawStats) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("graph: load centroid: %w", err) + } + var meta centroidMeta + if err := json.Unmarshal([]byte(rawStats), &meta); err != nil { + return nil, false, fmt.Errorf("graph: parse centroid meta: %w", err) + } + // 维度校验:与查询向量维度不符就不能用(中心化会按错误的维度截断/越界)。 + // queryDim <= 0 表示调用方不校验(罕见),此时只查库里的元数据是否自洽。 + if dim <= 0 { + return nil, false, fmt.Errorf("graph: centroid dim is %d (corrupted?)", dim) + } + if queryDim > 0 && dim != queryDim { + return nil, false, nil + } + // 库里的带向量块数 vs 建中心时的样本数 → 相对变化率 + // + // 两侧不完全相等是正常的(召回时会跳过维度/指纹不匹配的块), + // 所以判据是「变化率超过 20%」而不是「不相等」。 + // 20% 的依据:中心是分布的估计,10% 以内的变化对方向影响可忽略; + // 而过于敏感会让相似度随每次写入漂移 —— 那比不校正更糟, + // 因为不可复现的召回无法用于回归测试。 + curVectored := g.countVectoredBlocksLocked() + if vecCount <= 0 { + // 没有统计过任何向量:这不是一个可用的中心。 + if curVectored > 0 { + log.Printf("[graph] 中心向量不可用: 建中心时样本数为 0,现库内有 %d 个带向量块", curVectored) + } + return nil, false, nil + } + delta := float64(curVectored-vecCount) / float64(vecCount) + if delta < 0 { + delta = -delta + } + if delta > centroidRebuildRatio { + log.Printf("[graph] 中心向量已失效: 样本 %d → 库内 %d(变化 %.0f%% > %.0f%%)", + vecCount, curVectored, delta*100, centroidRebuildRatio*100) + return nil, false, nil + } + if !meta.Anomalous { + // 建的时候判定为各向同性 ⇒ 不启用 + return nil, false, nil + } + var mean []float64 + if err := json.Unmarshal([]byte(rawMean), &mean); err != nil { + return nil, false, fmt.Errorf("graph: parse centroid vector: %w", err) + } + if len(mean) != dim { + return nil, false, fmt.Errorf("graph: centroid dim mismatch: stored %d, meta %d", len(mean), dim) + } + // Count 复原:均值已归一化不成,Sum = mean(相对 Scale 无意义, + // 因此 CenterVector 只用 Mean(),不依赖 Count)。 + c := NewCentroid(dim) + copy(c.Sum, mean) + // 把 Sum 转成「均值恰好等于 mean」的 Sum:Mean() = Sum/Count, + // 所以令 Sum = mean * syntheticCount,Count = syntheticCount。 + // 取 syntheticCount=1 最直接:Sum=mean, Count=1。 + c.Count = 1 + return c, true, nil +} + +// countBlocksLocked 返回块总数。调用方必须持锁。 +func (g *GraphDB) countBlocksLocked() int { + var n int + _ = g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&n) + return n +} + +// countVectoredBlocksLocked 返回带向量的块数。调用方必须持锁。 +func (g *GraphDB) countVectoredBlocksLocked() int { + var n int + _ = g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`).Scan(&n) + return n +} + +// RebuildCentroid 从库里所有带向量的块重建中心。 +// +// fingerprint 为空时从块的 fingerprint 字段推断(取多数派)—— +// 库里混着多个空间时不能瞎猜,宁可要求调用方显式给出。 +func (g *GraphDB) RebuildCentroid(fingerprint string) (*Centroid, bool, error) { + g.mu.RLock() + rows, err := g.db.Query(`SELECT vector, fingerprint FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'`) + if err != nil { + g.mu.RUnlock() + return nil, false, err + } + type row struct { + vec []float64 + fp string + } + var all []row + var dim int + for rows.Next() { + var raw, fp string + if err := rows.Scan(&raw, &fp); err != nil { + rows.Close() + g.mu.RUnlock() + return nil, false, err + } + v, err := decodeVector(raw) + if err != nil { + continue + } + if dim == 0 { + dim = len(v) + } + if len(v) != dim { + continue // 维度不同(混了 provider)→ 跳过 + } + if fingerprint != "" && fp != "" && fp != fingerprint { + continue + } + all = append(all, row{vec: v, fp: fp}) + } + rows.Close() + g.mu.RUnlock() + + if len(all) == 0 { + return nil, false, fmt.Errorf("graph: no vectored blocks to build centroid") + } + c := NewCentroid(dim) + for _, r := range all { + c.Add(r.vec) + } + // fingerprint 为空且库里指纹不唯一 → 拒绝 + if fingerprint == "" { + uniq := map[string]bool{} + for _, r := range all { + uniq[r.fp] = true + } + if len(uniq) > 1 { + return c, false, fmt.Errorf( + "graph: centroid fingerprint ambiguous (%d spaces present); pass it explicitly", + len(uniq)) + } + for fp := range uniq { + fingerprint = fp + } + } + if err := g.SaveCentroid(c, fingerprint); err != nil { + return c, false, err + } + return c, c.Stats().Anomalous, nil +} + +// CentroidStatus 供运维/CLI 报告中心状态。 +type CentroidStatus struct { + Saved bool `json:"saved"` + Fingerprint string `json:"fingerprint"` + Dim int `json:"dim"` + VectoredCount int `json:"vectored_count"` + CurrentBlocks int `json:"current_vectored_blocks"` + MeanLength float64 `json:"mean_length"` + AvgPairCosine float64 `json:"avg_pair_cosine"` + Anomalous bool `json:"anomalous"` + Stale bool `json:"stale"` + BuiltAt string `json:"built_at,omitempty"` +} + +// DominantBlockFingerprint 返回库内带向量块中出现最多的 provider 指纹。 +// +// 独立重建中心时用它代替「用户手输指纹」—— 运维不该去翻配置找那串 hex。 +// 库内混了多个 provider 时(迁移期换过模型),取数量最多的那个, +// 并把实际取值打印出来让用户确认。 +func (g *GraphDB) DominantBlockFingerprint() (string, error) { + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT fingerprint, COUNT(*) AS n + FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND fingerprint IS NOT NULL + GROUP BY fingerprint ORDER BY n DESC LIMIT 1`) + if err != nil { + return "", err + } + defer func() { _ = rows.Close() }() + if !rows.Next() { + if err := rows.Err(); err != nil { + return "", err + } + return "", nil + } + var fp string + var n int + if err := rows.Scan(&fp, &n); err != nil { + return "", err + } + return fp, nil +} + +// CentroidStatusOf 报告中心状态,不启用校正。 +func (g *GraphDB) CentroidStatusOf(fingerprint string) (CentroidStatus, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + var st CentroidStatus + st.Fingerprint = fingerprint + st.CurrentBlocks = g.countVectoredBlocksLocked() + + var dim, vecCount int + var rawStats string + var builtAt time.Time + err := g.db.QueryRow(`SELECT dim, vector_count, stats, built_at + FROM graph_centroid WHERE fingerprint = ?`, fingerprint). + Scan(&dim, &vecCount, &rawStats, &builtAt) + if err == sql.ErrNoRows { + return st, nil // Saved=false + } + if err != nil { + return st, err + } + var meta centroidMeta + if err := json.Unmarshal([]byte(rawStats), &meta); err != nil { + return st, err + } + st.Saved = true + st.Dim = dim + st.VectoredCount = vecCount + st.MeanLength = meta.MeanLength + st.Anomalous = meta.Anomalous + st.AvgPairCosine = meta.MeanLength * meta.MeanLength + st.BuiltAt = builtAt.Format(time.RFC3339) + if vecCount > 0 { + delta := float64(st.CurrentBlocks-vecCount) / float64(vecCount) + if delta < 0 { + delta = -delta + } + st.Stale = delta > centroidRebuildRatio + } else { + st.Stale = st.CurrentBlocks > 0 + } + return st, nil +} + +// centerBoth 把查询向量与块向量都中心化后重算余弦。 +// +// ★ 两边都必须中心化:中心化是"相对于这个库的公共方向做正交化", +// +// 只处理一边的话结果没有意义 —— 一边带公共分量、另一边不带, +// 算出来的余弦是两种量纲的混合。 +func centerBoth(c *Centroid, blockVec, queryVec []float64) float64 { + if c == nil { + return cosine(blockVec, queryVec) + } + cb := c.CenterVector(blockVec) + cq := c.CenterVector(queryVec) + return cosine(cb, cq) +} diff --git a/internal/memory/centroid_store_test.go b/internal/memory/centroid_store_test.go new file mode 100644 index 00000000..1ec5316d --- /dev/null +++ b/internal/memory/centroid_store_test.go @@ -0,0 +1,383 @@ +package memory + +import ( + "math" + "testing" + "time" +) + +// ★ 持久化层的判据。三个要点: +// +// ① 往返无损(存进去读出来一致) +// ② 失效判定(块数变化过大 → 不启用,但不是错误) +// ③ 指纹歧义拒绝(库里混了多个向量空间时不能瞎猜) + +func TestCentroidStore_往返(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // ★ 走真实路径:先有块,再 RebuildCentroid。 + // + // 初版是「外部喂向量给 SaveCentroid」,但那时库里一个块都没有 —— + // 于是失效判定必然触发(样本 3 vs 库内 0),测试一直红。 + // ★ 教训:绕过了真实调用顺序的测试,测的是我想象的路径。 + const fp = "fp-test-1" + putVecBlocks(t, g, fp, "b", 6, 4) + c, anomalous, err := g.RebuildCentroid(fp) + if err != nil { + t.Fatal(err) + } + st := c.Stats() + if !anomalous || !st.Anomalous { + t.Fatalf("构造的数据应 anomalous,实际 %v / 均值范数 %.4f", anomalous, st.MeanLength) + } + loaded, use, err := g.LoadCentroid(fp, 4) + if err != nil { + t.Fatalf("LoadCentroid: %v", err) + } + if !use { + t.Fatalf("刚存的中心应可用(anomalous=%v)", st.Anomalous) + } + if loaded.Dim != 4 { + t.Errorf("维度应为 4,实际 %d", loaded.Dim) + } + // 往返:CenterVector 结果应一致 + v := []float64{1, 0.85, 0.75, 0.65} + a := c.CenterVector(v) + b := loaded.CenterVector(v) + if cosineOf(a, b) < 0.999999 { + t.Errorf("往返后 CenterVector 结果不一致:cos=%.9f", + cosineOf(a, b)) + } + + // 状态报告 + status, err := g.CentroidStatusOf(fp) + if err != nil { + t.Fatal(err) + } + if !status.Saved || status.Dim != 4 || !status.Anomalous { + t.Errorf("状态报告不对:%+v", status) + } + if status.MeanLength < 0.9 { + t.Errorf("均值范数应保留在状态里,实际 %.4f", status.MeanLength) + } + t.Logf("状态: dim=%d 样本=%d 均值范数=%.4f anomalous=%v 建于=%s", + status.Dim, status.VectoredCount, status.MeanLength, + status.Anomalous, status.BuiltAt) +} + +// ★ 维度不符时不启用(不是错误)——中心化会按错误维度越界。 +func TestCentroidStore_维度不符不启用(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + putVecBlocks(t, g, "fp-dim", "d", 5, 2) + if _, _, err := g.RebuildCentroid("fp-dim"); err != nil { + t.Fatal(err) + } + // 查询向量维度 512,中心维度 2 + _, use, err := g.LoadCentroid("fp-dim", 512) + if err != nil { + t.Errorf("维度不符不该是错误,应返回 nil 错误,实际 %v", err) + } + if use { + t.Error("维度不符时不该启用中心化") + } + // 维度对得上才启用 + _, use2, err := g.LoadCentroid("fp-dim", 2) + if err != nil || !use2 { + t.Errorf("维度一致时应启用,实际 use=%v err=%v", use2, err) + } +} + +// ★ 块数变化过大 → 失效(不是错误)。中心化会随库漂移,那比不校正更糟。 +func TestCentroidStore_块数变化失效(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 先放 3 块建中心,再加 20 块(3 → 23,变化 667% >> 20%) + putVecBlocks(t, g, "fp-stale", "a", 3, 2) + if _, _, err := g.RebuildCentroid("fp-stale"); err != nil { + t.Fatal(err) + } + putVecBlocks(t, g, "fp-stale", "z", 20, 2) + _, use, err := g.LoadCentroid("fp-stale", 2) + if err != nil { + t.Errorf("块数变化不该是错误,实际 %v", err) + } + if use { + t.Error("块数变化 567% 时中心应失效") + } + status, _ := g.CentroidStatusOf("fp-stale") + if !status.Stale { + t.Errorf("状态应标记 stale,实际 %+v", status) + } + t.Logf("状态: 建时样本=%d 现在=%d stale=%v", + status.VectoredCount, status.CurrentBlocks, status.Stale) +} + +// 少量变化不该失效(否则每次写入都让相似度漂移)。 +func TestCentroidStore_少量变化不失效(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 先放 100 个块建中心,再加 5 个(100 → 105,5% < 20%) + putVecBlocks(t, g, "fp-stable", "S", 100, 2) + if _, _, err := g.RebuildCentroid("fp-stable"); err != nil { + t.Fatal(err) + } + for i := 0; i < 5; i++ { + b := MemoryBlock{ID: string(rune('A' + i)), Modality: BlockText, + Text: string(rune('A' + i)), Vector: []float64{1, 0.5}, + Fingerprint: "fp-stable", CreatedAt: time.Now()} + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + } + _, use, err := g.LoadCentroid("fp-stable", 2) + if err != nil { + t.Fatal(err) + } + if !use { + t.Error("5% 变化不该让中心失效(否则召回结果随每次写入漂移)") + } +} + +// ★ 库里混多个向量空间时,fingerprint 为空必须拒绝而不是瞎猜。 +func TestRebuildCentroid_指纹歧义拒绝(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + for i := 0; i < 4; i++ { + fp := "fp-a" + if i >= 2 { + fp = "fp-b" + } + b := MemoryBlock{ID: string(rune('a' + i)), Modality: BlockText, + Text: string(rune('a' + i)), Vector: []float64{1, 0.5, 0.3}, + Fingerprint: fp, CreatedAt: time.Now()} + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + } + // 显式给指纹 → 只统计该空间的块 + c, _, err := g.RebuildCentroid("fp-a") + if err != nil { + t.Fatal(err) + } + if c.Count != 2 { + t.Errorf("显式给指纹时应只统计该空间,实际样本 %d(期望 2)", c.Count) + } + // 不给指纹 → 拒绝(两个空间都在) + _, _, err = g.RebuildCentroid("") + if err == nil { + t.Error("指纹歧义时不该默默选一个空间") + } + t.Logf("拒绝原因: %v", err) +} + +// putVecBlocks 写入 n 个「强共同分量 + 弱个体差异」的块(已归一化)。 +// +// 归一化是必须的:初版忘了归一,MeanLength 算出 1.1158 / +// AvgPairCosine 1.245 —— 两个数学上不可能的值,诊断指标本身就不成立。 +func putVecBlocks(t *testing.T, g *GraphDB, fp, prefix string, n, dim int) { + t.Helper() + for i := 0; i < n; i++ { + v := make([]float64, dim) + for k := range v { + v[k] = 1.0 // 强共同分量(造各向异性) + } + v[dim-1] += float64(i) / float64(n+1) // 弱个体差异 + if !l2Normalize(v) { + t.Fatalf("第 %d 个向量归一化失败", i) + } + b := MemoryBlock{ + ID: prefix + "-" + itoaTest(i), Modality: BlockText, + Text: prefix + "-块-" + itoaTest(i), Vector: v, + Fingerprint: fp, CreatedAt: time.Now(), + } + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + } +} + +func itoaTest(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +// ★ 接线判据:RecallBlocks 必须真的用中心化后的分数。 +// +// ★ 这条是补上的 —— 变异自证时发现两个变异都判成通过: +// +// 变异1 查询向量不中心化(只处理块向量)→ 端到端探针仍绿 +// 变异2 RecallBlocks 不加载中心 → 端到端探针仍绿 +// +// 原因:中心是在测试**运行前**手工建好的,探针跑的是"库里有没有中心" +// 而不是"打分路径有没有用中心"。所以判据必须直接盯分数。 +// +// 三个断言,逐层收紧: +// 1. 同一个块,开/关中心化的**分数不同**(路径真的走了) +// 2. SkipCentroid 时分数与不提供中心的基准**完全相同**(开关真的有效) +// 3. 中心化后**整体分数下降**(公共分量被减掉的必然结果) +// +// ★ 我最初写成「构造两个排序相反的块看 top1 是否翻转」,三次都失败: +// +// 手工设计让排序反转的几何关系很容易出错,而失败时无法区分 +// 「构造不对」与「代码没生效」。改成直接断言分数,判据就不再依赖构造。 +func TestRecallBlocks_接线用中心(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + const fp = "fp-wire" + const dim = 4 + + putRawBlock(t, g, fp, "bulk-0", []float64{1, 1, 0, 0}) + putRawBlock(t, g, fp, "bulk-1", []float64{1, 1, 0.01, 0}) + putRawBlock(t, g, fp, "bulk-2", []float64{1, 1, 0.02, 0}) + putRawBlock(t, g, fp, "target", []float64{0.35, 0.35, 0.9, 0.08}) + if _, _, err := g.RebuildCentroid(fp); err != nil { + t.Fatal(err) + } + qv := []float64{0.2, 0.2, 0.94, 0.1} + + raw, err := g.RecallBlocks(BlockRecallQuery{ + Vector: qv, Fingerprint: fp, TopK: 10, SkipCentroid: true}) + if err != nil { + t.Fatal(err) + } + cen, err := g.RecallBlocks(BlockRecallQuery{ + Vector: qv, Fingerprint: fp, TopK: 10}) + if err != nil { + t.Fatal(err) + } + if len(raw) == 0 || len(cen) == 0 { + t.Fatal("召回为空") + } + + // 断言 1:同一块的分数不同 + diffFound := false + for i := range raw { + if raw[i].Block.ID == cen[i].Block.ID && + raw[i].Score != cen[i].Score { + t.Logf(" %s: 原始 %.4f → 中心化 %.4f(差 %.4f)", + raw[i].Block.ID, raw[i].Score, cen[i].Score, + raw[i].Score-cen[i].Score) + diffFound = true + } + } + if !diffFound { + t.Error("开/关中心化的分数完全相同 —— 打分路径很可能没走中心化") + } + + // 断言 3:中心化后整体分数下降(公共分量被减掉的必然结果) + var rawSum, cenSum float64 + for _, h := range raw { + rawSum += h.Score + } + for _, h := range cen { + cenSum += h.Score + } + t.Logf(" 总分: 原始 %.4f → 中心化 %.4f", rawSum, cenSum) + if cenSum >= rawSum { + t.Errorf("中心化后总分应下降(公共分量被减掉),实际 %.4f → %.4f", + rawSum, cenSum) + } + + // 断言 2:SkipCentroid 的结果与「库里有中心但被跳过」完全一致 + again, err := g.RecallBlocks(BlockRecallQuery{ + Vector: qv, Fingerprint: fp, TopK: 10, SkipCentroid: true}) + if err != nil { + t.Fatal(err) + } + for i := range raw { + if again[i].Block.ID != raw[i].Block.ID || again[i].Score != raw[i].Score { + t.Errorf("SkipCentroid 应完全确定(可复现),第 %d 位不一致:%s/%v vs %s/%v", + i, again[i].Block.ID, again[i].Score, raw[i].Block.ID, raw[i].Score) + } + } + _ = dim +} + +// ★ 双边中心化:查询向量自己也必须中心化。 +// +// 这是最容易写错的一处 —— 只减块向量不减查询向量,等于在混两种量纲 +// (一半带公共分量、一半不带)。而它**不会报错、不会 panic**, +// 只是让相似度变得没有意义,所以必须专门判。 +// +// 构造:查询向量含大量公共分量 [1,1,0,0],块也含。 +// 只减块向量时,查询的公共分量原封不动地参与点积 ⇒ 分数虚高; +// 双边都减时公共分量被抵消 ⇒ 分数回落。 +func TestRecallBlocks_双边中心化(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + const fp = "fp-both" + + putRawBlock(t, g, fp, "b-0", []float64{1, 1, 0, 0}) + putRawBlock(t, g, fp, "b-1", []float64{1, 1, 0.01, 0}) + putRawBlock(t, g, fp, "b-2", []float64{1, 1, 0.02, 0}) + putRawBlock(t, g, fp, "b-3", []float64{1, 1, 0.03, 0}) + if _, _, err := g.RebuildCentroid(fp); err != nil { + t.Fatal(err) + } + + // 查询向量:公共分量占绝对主导(接近全部块的方向) + qv := []float64{1, 1, 0.05, 0} + + withCentroid, err := g.RecallBlocks(BlockRecallQuery{ + Vector: qv, Fingerprint: fp, TopK: 10}) + if err != nil { + t.Fatal(err) + } + if len(withCentroid) == 0 { + t.Fatal("召回为空") + } + + // 手工算三种算法,比对实现用的是哪一种 + cent, _, err := g.LoadCentroid(fp, 4) + if err != nil || cent == nil { + t.Fatalf("LoadCentroid: %v", err) + } + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + var refBoth, refBlockOnly, refNone float64 + for _, b := range blocks { + refNone += cosine(b.Vector, qv) + refBlockOnly += cosine(cent.CenterVector(b.Vector), qv) + refBoth += cosine(cent.CenterVector(b.Vector), cent.CenterVector(qv)) + } + var got float64 + for _, h := range withCentroid { + got += h.Score + } + t.Logf(" 实现总分 %.6f", got) + t.Logf(" 双边中心化参考 %.6f", refBoth) + t.Logf(" 只减块向量参考 %.6f", refBlockOnly) + t.Logf(" 完全不中心化 %.6f", refNone) + + if math.Abs(got-refBoth) > 1e-6 { + t.Errorf("实现应等于「双边中心化」,实际 %.6f(只减块=%.6f,不减=%.6f)", + got, refBlockOnly, refNone) + } +} + +func putRawBlock(t *testing.T, g *GraphDB, fp, id string, v []float64) { + t.Helper() + if !l2Normalize(v) { + t.Fatalf("%s 归一化失败", id) + } + b := MemoryBlock{ID: id, Modality: BlockText, Text: id, Vector: v, + Fingerprint: fp, CreatedAt: time.Now()} + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/memory/centroid_test.go b/internal/memory/centroid_test.go new file mode 100644 index 00000000..c83608f6 --- /dev/null +++ b/internal/memory/centroid_test.go @@ -0,0 +1,288 @@ +package memory + +import ( + "math" + "testing" +) + +// ★ 中心化的判据核心:把「所有向量都朝同一方向偏」这个共同分量去掉, +// 同值对的相似度要**保持**,异值对要**拉远**。 +// +// 依据实测(真库 260 个 distill 块,chineseclip 512 维): +// +// 均值向量长度 0.9374 → 去均值后平均两两余弦从 0.8727 降到 -0.0012 +// 分离度 0.0095 → 0.0689(提升 7 倍) +func TestCentroid_分离度提升(t *testing.T) { + // 构造:向量 = 强共同方向 + 弱个体差异 + const dim = 8 + mkVec := func(f1, f2 float64) []float64 { + // 共同分量恒为 1,个体差异放在末两维 + v := make([]float64, dim) + for i := 0; i < dim-2; i++ { + v[i] = 1.0 + } + v[dim-2] = f1 + v[dim-1] = f2 + return v + } + // 同值对:个体差异相同 + sameA := mkVec(0, 0) + sameB := mkVec(0, 0) + // 异值对:个体差异不同 + diffA := mkVec(0, 0) + diffB := mkVec(0.05, 0.05) + + norm := func(v []float64) []float64 { + l2Normalize(v) + return v + } + c, st := BuildCentroid([][]float64{ + norm(mkVec(0, 0)), norm(mkVec(0.05, 0.05)), norm(mkVec(0.1, 0.1)), + }) + if !st.Anomalous { + t.Fatalf("构造的数据应被判为各向常(均值范数 %.3f),实际 %+v", st.MeanLength, st) + } + t.Logf("均值范数 %.4f 平均两两余弦 %.4f anomalous=%v", + st.MeanLength, st.AvgPairCosine, st.Anomalous) + + rawSame := cosineOf(sameA, sameB) + rawDiff := cosineOf(diffA, diffB) + cSame := cosineOf(c.CenterVector(sameA), c.CenterVector(sameB)) + cDiff := cosineOf(c.CenterVector(diffA), c.CenterVector(diffB)) + + t.Logf(" 同值对: 原始 %.4f → 去均值 %.4f", rawSame, cSame) + t.Logf(" 异值对: 原始 %.4f → 去均值 %.4f", rawDiff, cDiff) + rawSep := rawSame - rawDiff + cSep := cSame - cDiff + t.Logf(" 分离度: 原始 %.4f → 去均值 %.4f", rawSep, cSep) + + if cSep <= rawSep { + t.Errorf("去均值后分离度应提升,实际 %.4f → %.4f", rawSep, cSep) + } + // 同值对必须保持高相似(中心化不该破坏真重复) + if cSame < 0.99 { + t.Errorf("同值对中心化后应仍几乎完全相同,实际 %.4f", cSame) + } +} + +// ★ 关键约束:中心化后必须重新归一化。 +// +// 只减不归一的话,所有余弦会被「向量变短了」这个纯粹的尺度效应污染。 +func TestCentroid_必须重新归一化(t *testing.T) { + const dim = 4 + v := []float64{1, 1, 1, 1} // 范数 2 + c := NewCentroid(dim) + c.Add([]float64{1, 0, 0, 0}) + c.Add([]float64{0, 1, 0, 0}) + + onlySub := c.Subtract(append([]float64{}, v...)) + onlySubNorm := math.Sqrt(dotOf(onlySub, onlySub)) + if onlySubNorm >= 2.0 { + t.Logf("减均值后范数 %.4f(原 2.0)—— 尺度确实变了", onlySubNorm) + } + // CenterVector 会重新归一化 + centered := c.CenterVector(v) + centeredNorm := math.Sqrt(dotOf(centered, centered)) + if math.Abs(centeredNorm-1.0) > 1e-9 { + t.Errorf("CenterVector 后范数应为 1,实际 %.6f", centeredNorm) + } + // 同向的两个向量中心化后仍应余弦为 1(若已归一化) + w := []float64{1, 0.9, 0.8, 0.7} + a := c.CenterVector(v) + b := c.CenterVector(w) + if got := cosineOf(a, b); got < 0.99 { + t.Errorf("同向向量中心化+归一化后余弦应≈1,实际 %.4f", got) + } +} + +// 各向同性的库不该做中心化(否则放大噪声)。 +func TestCentroid_各向同性时不报异常(t *testing.T) { + // 四个正交方向 → 均值接近 0 + vs := [][]float64{ + {1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 0, 1}, + } + _, st := BuildCentroid(vs) + if st.Anomalous { + t.Errorf("正交基应判为各向同性(均值范数 %.4f),实际 anomalous", st.MeanLength) + } + if st.MeanLength > meanThreshold { + t.Errorf("正交基的均值范数应接近 0,实际 %.4f", st.MeanLength) + } +} + +// 维度不匹配必须被拒绝(混维度会把均值算坏)。 +func TestCentroid_维度不匹配(t *testing.T) { + c := NewCentroid(3) + if c.Add([]float64{1, 2}) { // 维度 2 ≠ 3 + t.Error("维度不匹配的向量不该被接受") + } + if c.Add(nil) { + t.Error("nil 向量不该被接受") + } + if c.Count != 0 { + t.Errorf("Count 应仍为 0,实际 %d", c.Count) + } + // Subtract 遇到维度不匹配应原样返回 + out := c.Subtract([]float64{1, 2, 3}) + if len(out) != 3 || out[0] != 1 { + t.Errorf("空中心时 Subtract 应原样返回,实际 %v", out) + } +} + +// 空中心的行为。 +func TestCentroid_空中心(t *testing.T) { + c := NewCentroid(4) + if c.Mean() != nil { + t.Error("空中心的 Mean 应为 nil") + } + st := c.Stats() + if st.MeanLength != 0 || st.Anomalous { + t.Errorf("空中心的 Stats 应为零值,实际 %+v", st) + } + // Subtract 应原样返回(不 panic) + v := []float64{1, 2, 3, 4} + out := c.Subtract(v) + if len(out) != 4 || out[3] != 4 { + t.Errorf("空中心 Subtract 应原样返回,实际 %v", out) + } +} + +// 缓存的版本失效:块数或维度变化后要返回 nil(否则用旧均值算新查询)。 +func TestCentroidCache_版本失效(t *testing.T) { + cc := NewCentroidCache() + c1, _ := BuildCentroid([][]float64{{1, 0}, {0, 1}}) + st1 := c1.Stats() + cc.Put(c1, st1) + + if got, _ := cc.Get(2, 2); got == nil { + t.Error("相同 (dim,count) 应命中缓存") + } + if got, _ := cc.Get(2, 3); got != nil { + t.Error("count 变化应失效(块数变了,均值含义也变了)") + } + if got, _ := cc.Get(3, 2); got != nil { + t.Error("dim 变化应失效") + } + // 放 nil 不该清空已有缓存 + cc.Put(nil, CentroidStats{}) + if got, _ := cc.Get(2, 2); got == nil { + t.Error("Put(nil) 不该清空已有缓存") + } +} + +func dotOf(a, b []float64) float64 { + var s float64 + for i := range a { + if i < len(b) { + s += a[i] * b[i] + } + } + return s +} + +func cosineOf(a, b []float64) float64 { + na, nb := dotOf(a, a), dotOf(b, b) + if na == 0 || nb == 0 { + return 0 + } + return dotOf(a, b) / (math.Sqrt(na) * math.Sqrt(nb)) +} + +// ★ 真库上的中心化判据(本轮的核心实验)。 +// +// 合成数据只能验证机制正确,**收益量级必须在真库上量**。 +// 实测基线(这轮跑出来的): +// +// 均值范数 0.9374 / 平均两两余弦 0.8727 +// 分离度 0.0095 → 去均值后 0.0689(7.3 倍) +// +// 判据:真库上中心化必须显著提升分离度,且不破坏同值对。 +func TestCentroid_真库分离度(t *testing.T) { + g := probeDB(t, "/var/tmp/ha-c/memory/graph.db") + blocks, err := g.MemoryBlocks() + if err != nil { + t.Skip("无真库") + } + var vecs [][]float64 + type idx struct { + sub, dim, val string + } + var ids []idx + for _, b := range blocks { + if b.Source != "distill" || len(b.Vector) == 0 { + continue + } + s, d, v, ok := parseFact(b.Text) + if !ok || s == "" { + continue + } + vecs = append(vecs, b.Vector) + ids = append(ids, idx{s, d, v}) + } + if len(vecs) < 10 { + t.Skip("块太少") + } + t.Logf("真库 %d 个块,维度 %d", len(vecs), len(vecs[0])) + + c, st := BuildCentroid(vecs) + t.Logf("均值范数 %.4f 平均两两余弦 %.4f anomalous=%v", + st.MeanLength, st.AvgPairCosine, st.Anomalous) + if !st.Anomalous { + t.Fatalf("真库应判为各向异性严重,实际均值范数 %.4f", st.MeanLength) + } + + // 同属性对:分离度(原始 vs 中心化后) + var rawSame, rawDiff, cenSame, cenDiff []float64 + for i := 0; i < len(ids); i++ { + for j := i + 1; j < len(ids); j++ { + if ids[i].sub != ids[j].sub || ids[i].dim != ids[j].dim { + continue + } + r := cosineOf(vecs[i], vecs[j]) + cc := cosineOf(c.CenterVector(vecs[i]), c.CenterVector(vecs[j])) + if ids[i].val == ids[j].val { + rawSame = append(rawSame, r) + cenSame = append(cenSame, cc) + } else { + rawDiff = append(rawDiff, r) + cenDiff = append(cenDiff, cc) + } + } + } + if len(rawSame) == 0 || len(rawDiff) == 0 { + t.Skip("缺少同值对或异值对") + } + rawSep := minOf(rawSame) - maxOf(rawDiff) + cenSep := minOf(cenSame) - maxOf(cenDiff) + t.Logf("同值对 %d 个(最低 %.4f → %.4f)", len(rawSame), minOf(rawSame), minOf(cenSame)) + t.Logf("异值对 %d 个(最高 %.4f → %.4f)", len(rawDiff), maxOf(rawDiff), maxOf(cenDiff)) + t.Logf("★ 分离度 %.4f → %.4f(%.1f 倍)", + rawSep, cenSep, cenSep/rawSep) + + if cenSep <= rawSep { + t.Errorf("真库上中心化应提升分离度,实际 %.4f → %.4f", rawSep, cenSep) + } + // 同值对不能被破坏 + if minOf(cenSame) < 0.99 { + t.Errorf("同值对中心化后应仍几乎相同,实际 %.4f", minOf(cenSame)) + } +} + +func minOf(xs []float64) float64 { + m := xs[0] + for _, x := range xs { + if x < m { + m = x + } + } + return m +} +func maxOf(xs []float64) float64 { + m := xs[0] + for _, x := range xs { + if x > m { + m = x + } + } + return m +} diff --git a/internal/memory/commit_blocks_test.go b/internal/memory/commit_blocks_test.go new file mode 100644 index 00000000..0aaaa1d1 --- /dev/null +++ b/internal/memory/commit_blocks_test.go @@ -0,0 +1,142 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ★★ Commit 块化的目标判据。 +// +// 现状(graph.go:495~530):Commit 往三张旧表写 +// +// entities (三元组主语/宾语,name 唯一) +// sentences (SentenceText 原句) +// relations (三元组本体) +// +// 而 memory_blocks / memory_block_edges **完全没被写** +// ⇒ 旧表持续增长(跑分实测 entities 188 → 381)。 +// +// 块化后 Commit 必须: +// +// ① 不再写 entities / relations +// ② 三元组变成「主语块 --关系--> 宾语块」的块边 +// ③ 原句变成原句块(blk_src_),供 CommitWithMedia 挂媒体边 +// +// ★ 但 CommitWithMedia 必须**保持签名兼容** —— +// +// 媒体桥(graphmedia.go)靠它返回的 sentences.id 挂 +// sentence --contains--> block 边。那条边要改成 原句块 --contains--> 媒体块, +// 所以返回值得从「sentences 表行号」变成「原句块 ID」。 +func TestBlockCommit_不写旧表(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + triples := []Triple{ + {Subject: "值班室分机号", Relation: "是", Object: "4324", + SentenceText: "值班室分机号改为 4324,旧号 4379 停用"}, + {Subject: "admin服务", Relation: "端口", Object: "8080", + SentenceText: "admin 服务监听 8080"}, + } + sentIDs, ec, rc, err := g.CommitWithMedia(triples, "sess1", 0) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" CommitWithMedia: 实体 %d,关系 %d,句子 %d\n", ec, rc, len(sentIDs)) + + // ① 三张旧表必须仍是 0 + var nEnt, nRel, nSent int + count(t, g, `SELECT COUNT(*) FROM entities`, &nEnt) + count(t, g, `SELECT COUNT(*) FROM relations`, &nRel) + count(t, g, `SELECT COUNT(*) FROM sentences`, &nSent) + fmt.Printf(" ① 旧表: entities=%d relations=%d sentences=%d\n", nEnt, nRel, nSent) + // ★ 旧表写入是**有意保留**的,不是缺陷。 + // + // Recall / RecallSorted / social / scene / light_memory / WebUI / + // healthcheck / proc / lua / SDK 共 55 处仍读它们 + // (docs/zh/legacy-table-retirement.md)。 + // 退场顺序必须是: + // + // ① 读方切块 → ② 停写旧表 → ③ 删表 + // + // 在①完成前停写 = 在线读取直接断。所以本阶段只保证 + //「**块与块边完整**,旧表仍同步写」,不追求旧表为 0。 + // + // ⇒ 这里断言「旧表与块数一致」——保证块化没有漏写。 + // + // ★★ 2026-10-04:旧表**双写已停**(读方全部切块完成), + // 所以这三项现在恒为 0。原断言 `nEnt == 0 → 报错` + // 把「当时的状态」写成了「永久的契约」—— + // 阶段推进之后它自己过期了。 + // + // ★ 判据要验的是**块化没漏写**,不是旧表还在不在。 + // 所以改成「旧表为 0 是**预期**」并说明理由, + // 而真正的完整性由下面的②(块与块边)保证。 + fmt.Printf(" ① 旧表(已停双写,预期全 0): entities=%d relations=%d sentences=%d\n", + nEnt, nRel, nSent) + if nEnt != 0 { + t.Errorf("旧表双写已停,entities 应为 0,实际 %d —— 旧表写入可能被漏摘", nEnt) + } + + // ② 块与块边必须存在 + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + var sentBlocks, valBlocks int + for _, b := range blocks { + switch b.Source { + case SentenceBlockSource: + sentBlocks++ + default: + valBlocks++ + } + } + fmt.Printf(" ② 块: 原句块 %d,值块 %d\n", sentBlocks, valBlocks) + if sentBlocks == 0 || valBlocks == 0 { + t.Errorf("★ 块化未生效: 原句块 %d,值块 %d", sentBlocks, valBlocks) + } + + // ③ 三元组必须变成块边(主语块 --关系--> 宾语块) + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + relTypes := make([]string, 0, len(edges)) + var relEdges int + for _, e := range edges { + if e.Type != "contains" { + relTypes = append(relTypes, fmt.Sprintf("%s--%s-->%s", e.SourceID, e.Type, e.TargetID)) + relEdges++ + } + } + fmt.Printf(" 实际关系边: %v\n", relTypes) + fmt.Printf(" ③ 关系型块边: %d\n", relEdges) + if relEdges == 0 { + t.Error("★ 三元组没有变成块边") + } + + // ④ CommitWithMedia 的返回值必须仍可用于挂媒体边 + // + // ⚠️ 当前签名是 map[string]int64(sentences 表行号)。 + // 块化后必须改成 map[string]string(原句块 ID)—— + // 因为 sentences 表退场后行号不再存在,而媒体桥要靠它挂 + // 「原句块 --contains--> 媒体块」这条边。 + // + // 这条断言现在必然失败(int64 没有 blk_src 前缀), + // 它标记的是**签名变更点**,不是缺陷。 + for text, id := range sentIDs { + t.Logf(" ④ 句子 %q → 当前返回 %v(块化后应为 blk_src_* 字符串)", text, id) + if ec == 0 { + t.Log(" (实体数为 0,说明当前实现未块化)") + } + } +} + +func count(t *testing.T, g *GraphDB, q string, dst *int) { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + if err := g.db.QueryRow(q).Scan(dst); err != nil { + t.Fatal(err) + } +} diff --git a/internal/memory/delete_entity_blocks_test.go b/internal/memory/delete_entity_blocks_test.go new file mode 100644 index 00000000..f60520f0 --- /dev/null +++ b/internal/memory/delete_entity_blocks_test.go @@ -0,0 +1,270 @@ +package memory + +import ( + "path/filepath" + "testing" +) + +// TestDeleteEntityRemovesLiveBlocks 钉住「删除要作用在活图谱上」。 +// +// ★ 这条判据来自 2026-10-04 的实证缺陷: +// +// 旧实现在生产库副本上执行后 —— +// memory_blocks 2764 → 2764 (Δ0) +// memory_block_edges 2379 → 2379 (Δ0) +// legacy entities 1294 → 1293 (只删了旧表 1 行) +// 而工具回「已彻底删除实体…及其所有关联关系」。 +// +// 危害不是「删不干净」,是**谎报**:模型据此认为内容已消失, +// 而 40+ 条关联边还在,召回继续命中。 +// +// ★ 判据设计要点:只断言「块和边真的少了」会漏掉一半缺陷 +// +// (比如只删块不删边、或只删边不删块都能通过一半断言), +// 所以这里**同时**断言块没了、边没了、没有悬空边。 +func TestDeleteEntityRemovesLiveBlocks(t *testing.T) { + g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + // 建三块:被删的 + 两个幸存的邻居。 + putBlocks(t, g, + MemoryBlock{ID: "b_del", Text: "待删实体"}, + MemoryBlock{ID: "b_a", Text: "邻居甲"}, + MemoryBlock{ID: "b_b", Text: "邻居乙"}) + // 被删块 ↔ 两个邻居各一条关系边(双向都建,验证两条都被清)。 + // + // ★ 注意 Commit 会**顺带创建**主语/宾语块(与 PutMemoryBlocks 建的块 + // 可能同名而成为不同 ID),所以块数不是 3。判据用相对值, + // 并在末尾单独断言「文本还在」而不是数绝对个数。 + if _, _, err := g.Commit([]Triple{ + {Subject: "待删实体", Relation: "关联", Object: "邻居甲"}, + {Subject: "邻居乙", Relation: "关联", Object: "待删实体"}, + }, "s1", 1); err != nil { + t.Fatal(err) + } + + before := countRows(t, g, "memory_blocks") + // ★ 同名块数不写死:Commit 会为三元组端点**再建一个同名块** + // (PutMemoryBlocks 建的与 Commit 建的 ID 不同、文本相同)。 + // 真实库里也确实会同名共存,所以判据只要求「≥1 且删除后清零」。 + dupBefore := countBlocksByText(t, g, "待删实体") + if dupBefore < 1 { + t.Fatalf("「待删实体」应有块,实际 %d", dupBefore) + } + + res, err := g.DeleteEntity("待删实体") + if err != nil { + t.Fatalf("删除失败: %v", err) + } + + // ① 块真的少了(同名的都要清,所以减的是 dupBefore)。 + if got := countRows(t, g, "memory_blocks"); got != before-dupBefore { + t.Errorf("删除后块数应从 %d 降到 %d(同名 %d 块全清),实际 %d —— 删除没作用在活图谱上", + before, before-dupBefore, dupBefore, got) + } + // ② 关联边真的少了(两条都该走)。 + if res.Edges != 2 { + t.Errorf("应删掉 2 条关联边,实际 %d", res.Edges) + } + // ③ 结果要如实报告,不能报 0 块却返回成功。 + if res.Blocks != dupBefore { + t.Errorf("DeleteResult.Blocks 应为 %d,实际 %d", dupBefore, res.Blocks) + } + // ④ 被删块不能残留在库里。 + if n := countBlocksByText(t, g, "待删实体"); n != 0 { + t.Errorf("「待删实体」仍残留 %d 个块", n) + } + // ⑤ 邻居必须幸存 —— 删除不能误伤。 + for _, keep := range []string{"邻居甲", "邻居乙"} { + if n := countBlocksByText(t, g, keep); n == 0 { + t.Errorf("邻居 %q 全部消失 —— 删除误伤了无关块", keep) + } + } + // ⑥ ★ 不能留悬空边:指向已删块的边必须一并清掉。 + // + // 这条最容易被漏:只删块不删边的话,blocks 里查不到, + // 但 edges 里还挂着 —— 而召回/仲裁照样会命中那个不存在的 ID。 + var dangling int + g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges e + WHERE (e.source_kind='block' AND NOT EXISTS + (SELECT 1 FROM memory_blocks b WHERE b.id = e.source_id)) + OR (e.target_kind='block' AND NOT EXISTS + (SELECT 1 FROM memory_blocks b WHERE b.id = e.target_id)) + `).Scan(&dangling) + if dangling != 0 { + t.Errorf("删除后留了 %d 条悬空边 —— 召回会命中已删块的 ID", dangling) + } +} + +// TestDeleteEntityByExactTextNotSubstring 钉住「精确匹配,不是子串」。 +// +// ★ 真实库形态就是子串相撞的(今天的长对话库里同时存在): +// +// "admin"(端点块) ← 要删这个 +// "admin 8861、billing 8499、oauth 8271"(原句块) +// +// 用 LIKE 会把后者一起删掉 —— 那不是「删除这一个实体」,是数据丢失。 +func TestDeleteEntityByExactTextNotSubstring(t *testing.T) { + g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + putBlocks(t, g, + MemoryBlock{ID: "b_admin", Text: "admin"}, + MemoryBlock{ID: "b_sent", Text: "admin 8861、billing 8499、oauth 8271"}) + + if _, err := g.DeleteEntity("admin"); err != nil { + t.Fatalf("删除失败: %v", err) + } + + if n := countBlocksByText(t, g, "admin"); n != 0 { + t.Errorf("「admin」应被删,仍残留 %d 块", n) + } + if n := countBlocksByText(t, g, "admin 8861、billing 8499、oauth 8271"); n != 1 { + t.Errorf("含「admin」的原句块不该被连带删除,实际剩 %d 块 —— 用了子串匹配", n) + } +} + +// TestDeleteEntityMissingBlockIsError 钉住「找不到要报错,不能静默成功」。 +// +// ★ 静默成功比报错坏:模型收到「已删除」会认为内容已消失, +// +// 于是重新写入或不再提及 —— 而库里那块从未被动过。 +func TestDeleteEntityMissingBlockIsError(t *testing.T) { + g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + putBlocks(t, g, MemoryBlock{ID: "b_keep", Text: "存在的块"}) + + res, err := g.DeleteEntity("不存在的块") + if err == nil { + t.Fatalf("删除不存在的块必须报错;却返回成功(Blocks=%d Edges=%d)", res.Blocks, res.Edges) + } + // 报错不得牵连无关块。 + if n := countBlocksByText(t, g, "存在的块"); n != 1 { + t.Errorf("失败路径不该动任何块,实际 %q 剩 %d 块", "存在的块", n) + } +} + +// ── 小工具 ── + +// putBlocks 写入测试块(补齐 PutMemoryBlocks 要求的 ID/模态)。 +func putBlocks(t *testing.T, g *GraphDB, blocks ...MemoryBlock) { + t.Helper() + for i := range blocks { + blocks[i].Modality = BlockText + if err := g.PutMemoryBlocks([]MemoryBlock{blocks[i]}); err != nil { + t.Fatalf("写块 %q: %v", blocks[i].ID, err) + } + } +} + +func countRows(t *testing.T, g *GraphDB, table string) int { + t.Helper() + var n int + if err := g.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { + t.Fatalf("count %s: %v", table, err) + } + return n +} + +func countBlocksByText(t *testing.T, g *GraphDB, text string) int { + t.Helper() + var n int + if err := g.db.QueryRow( + "SELECT COUNT(*) FROM memory_blocks WHERE text_content = ?", text).Scan(&n); err != nil { + t.Fatalf("count blocks %q: %v", text, err) + } + return n +} + +// TestDeleteEntity_LegacyTableHasReferencingRelation 钉住**旧表被引用时也能删**。 +// +// ★ 这条判据是 2026-10-05 复核时补的,来源是一次真实的生产库副本实测: +// +// DeleteEntity("CodeGraph安装任务") → FOREIGN KEY constraint failed +// +// 根因是旧表清理写成了「跨表误用 id」: +// +// DELETE FROM relations WHERE id IN (SELECT id FROM entities WHERE name = ?) +// +// 子查询给的是 **entities.id**,外层匹配的是 **relations.id** —— 同名不同表。 +// 于是被引用的 relation 根本没删,紧接着 DELETE FROM entities 撞上 +// relations 的外键(source_id/target_id → entities.id)⇒ **整个事务回滚**。 +// +// ★★ 为什么原有的 3 条判据全绿(两次教训叠在一起): +// +// ① 它们只用 Commit/putBlocks 建**块**体系,旧表恒空 ⇒ 路径不可达。 +// ② 我第一版补的判据也只用了 SeedLegacyEntity/SeedLegacyRelation, +// 而那条 helper 返回的第二个值是 **target_id**、不是 relations.id; +// 在一个新库上两个 AUTOINCREMENT 序列**恰好对齐**(都是 1、都是 2…), +// 于是「拿 entity id 当 relation id」碰巧命中正确的那一行 ⇒ 变异测不出来。 +// +// ⇒ 必须**显式把两个 id 序列错开**,让 entity.id ≠ relations.id, +// 这才是生产的真实形态(生产 entities 1294 行 / relations 980 行)。 +// 「测不到」不等于「没问题」——这是本仓反复记的那条纪律。 +func TestDeleteEntity_LegacyTableHasReferencingRelation(t *testing.T) { + g := newTestGraph(t) + defer g.Close() + + // 块侧:一个同名块(DeleteEntity 以块为权威源)。 + putBlocks(t, g, MemoryBlock{ID: "b_del", Text: "待删实体"}) + + // ★ 旧表侧:先灌一批无关关系,把 relations 的 AUTOINCREMENT 推远, + // 再造目标实体与引用它的关系 —— 保证 entity.id ≠ relations.id。 + for i := 0; i < 20; i++ { + s := "pre" + string(rune('a'+i%26)) + string(rune('a'+i/26)) + if _, err := g.SeedLegacyEntity(s+"-A", "Concept"); err != nil { + t.Fatalf("SeedLegacyEntity(%s): %v", s, err) + } + if _, err := g.SeedLegacyEntity(s+"-B", "Concept"); err != nil { + t.Fatalf("SeedLegacyEntity(%s): %v", s, err) + } + if _, _, err := g.SeedLegacyRelation(s+"-A", s+"-B", "无关", 1.0, "", 0); err != nil { + t.Fatalf("SeedLegacyRelation(%s): %v", s, err) + } + } + eid, err := g.SeedLegacyEntity("待删实体", "Concept") + if err != nil { + t.Fatalf("SeedLegacyEntity: %v", err) + } + if _, _, err := g.SeedLegacyRelation("待删实体", "prea-A", "关联", 1.0, "", 0); err != nil { + t.Fatalf("SeedLegacyRelation: %v", err) + } + + // ★ 前提自检:两个 id 必须**不相等**,否则本判据是假绿。 + var relID int64 + if err := g.db.QueryRow( + `SELECT id FROM relations WHERE source_id = ?`, eid).Scan(&relID); err != nil { + t.Fatal(err) + } + if relID == eid { + t.Fatalf("前提不成立:entity id 与 relation id 相同(%d)——"+ + "「跨表误用 id」的变异会侥幸通过,本判据失去意义", eid) + } + + // ★ 原实现在这里报 FOREIGN KEY constraint failed。 + if _, err := g.DeleteEntity("待删实体"); err != nil { + t.Fatalf("旧表有引用关系时也必须能删(旧表清理跨表误用了 id):%v", err) + } + + // 旧表同步清干净。 + var ents, rels int + g.db.QueryRow(`SELECT count(*) FROM entities WHERE name = ?`, "待删实体").Scan(&ents) + g.db.QueryRow(`SELECT count(*) FROM relations + WHERE source_id = ? OR target_id = ?`, eid, eid).Scan(&rels) + if ents != 0 { + t.Errorf("旧表 entities 残留 %d 行", ents) + } + if rels != 0 { + t.Errorf("旧表 relations 残留 %d 行(会持续阻塞后续删除)", rels) + } +} diff --git a/internal/memory/distill/blocks.go b/internal/memory/distill/blocks.go new file mode 100644 index 00000000..28089f44 --- /dev/null +++ b/internal/memory/distill/blocks.go @@ -0,0 +1,239 @@ +package distill + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// 拆解产物的落库形态:句子 + 文本块 + 结构边。 +// +// 为什么不是 entities/relations(本会话最初的错误做法) +// -------------------------------------------------- +// 三元组在图里的正确形态是【节点】【关系边】【节点】,而节点已从纯文本实体 +// 升级为带 vector/fingerprint/modality 的 memory_blocks(46f833c)。把拆解 +// 结果 Commit 成 entities 是把新产出灌进正在退场的旧形态。 +// +// 端点 kind 的既有设计(block.go:175 validGraphNodeKind): +// +// block / entity / sentence / document +// +// 句子的职责由 sentences 表承担(它已有 id、text、created_at), +// 块的职责是**可向量化的最小子项目**。所以正确的挂接是: +// +// sentence(原句) --contains--> block(字段内容, 带向量) +// +// 与媒体块完全同构(graphmedia.go:73 的 sentence→block contains 边)。 +// 不把原句也做成块 —— 那会与 sentences 表重复,且原句不需要单独向量 +// (它的语义由它包含的字段块共同表达)。 + +// BlockPayload 是一次拆解的产物:原句 + 字段块 + 连接关系。 +type BlockPayload struct { + // Sentence 是原始记录文本(写入 sentences 表,作为结构边的源端点)。 + Sentence string + // Fields 是按维度拆出的字段块。 + Fields []FieldBlock +} + +// FieldBlock 是一个字段块:**所属主语** + 维度名 + 原样值。 +// +// ★ Subject 不能省(实测踩出来的):一句多主语时 +// 「admin服务端口8861·billing服务端口8499·oauth服务端口8271」 +// 会拆出三条同维度不同值的字段: +// +// {admin服务, 端口, 8861} {billing服务, 端口, 8499} {oauth服务, 端口, 8271} +// +// 少了 Subject 就只剩「端口=8861」,三个服务的端口落库后无法区分 —— +// 查「billing 的端口」时块文本里根本没有 billing。 +// 单主语时 Subject 是那条主语(如「值班室分机号」),它同样要进块文本, +// 否则「值班分机号=4324」查不到「值班室分机号是多少」。 +type FieldBlock struct { + // Subject 是这条字段所属的主语。空串表示无主语(不该出现 —— + // Split 的闸门会挡掉无主语的字段)。 + Subject string + Dimension string + Value string +} + +// Blocks 把一条记录拆成块形态。 +// +// 与 Split 的区别:Split 返回 Triple(旧形态,供对照期使用); +// Blocks 返回块形态(新形态)。两者共用同一套拆解逻辑与闸门, +// 不重复实现——闸门(值原样校验、维度归一、长度上限、自环过滤) +// 是拆解质量的全部保障,分两处实现必然漂移。 +func (e *Extractor) Blocks(ctx context.Context, record string) (*BlockPayload, error) { + triples, err := e.Split(ctx, record) + if err != nil { + return nil, err + } + if len(triples) == 0 { + return &BlockPayload{Sentence: record}, nil + } + payload := &BlockPayload{Sentence: record} + for _, t := range triples { + payload.Fields = append(payload.Fields, FieldBlock{ + Subject: t.Subject, + Dimension: t.Relation, + Value: t.Object, + }) + } + return payload, nil +} + +// BlockID 由句子与字段内容派生,保证同一记录重复拆解得到同一个块 ID。 +// +// 为什么用内容摘要而不是随机 ID:蒸馏是**可重试**的(distillOnce 失败会 +// 把记录写回队列下次重试),随机 ID 会让每次重试都产生一批新块, +// 同一句记忆在库里堆成 N 份。内容派生 ID 配合 PutMemoryBlocks 的 +// ON CONFLICT 语义天然幂等。 +// +// ★ subject 必须参与 ID 派生:三个服务的端口 +// 「admin服务端口8861·billing服务端口8499」若不含 subject, +// 「端口=8861」与另一个主语的「端口=8861」会撞成同一个块 —— +// 那是静默的数据丢失(后者被覆盖,且不会有任何报错)。 +func BlockID(sentence, subject, dimension, value string) string { + h := sha256.Sum256([]byte(sentence + "\x00" + subject + "\x00" + + dimension + "\x00" + value)) + return "blk_" + hex.EncodeToString(h[:12]) +} + +// SentenceBlockID 由原句文本派生原句块的 ID。 +// +// ★ 实现在 memory 包(memory.SentenceBlockID)—— +// +// 迁移与蒸馏两条路径都要用它,而 distill 已导入 memory, +// 放在 distill 会形成导入环。这里只做转发以便包内调用方便。 +func SentenceBlockID(sentence string) string { return memory.SentenceBlockID(sentence) } + +// SentenceBlock 构造承载原句的块(转发到 memory.NewSentenceBlock)。 +// +// 刻意不带向量:原句块是溯源锚点,长整句会稀释向量召回。 +func SentenceBlock(sentence string) memory.MemoryBlock { + return memory.NewSentenceBlock(sentence, time.Time{}, time.Time{}) +} + +// ToMemoryBlock 把一个字段块转成可入库的 MemoryBlock。 +// +// Vector/Fingerprint 由调用方填充(需要 embedding provider)。**provider +// 不可用时留空而不是编造** —— 无向量的块仍可被 BlocksForNode 按边查到, +// 只是不参与向量召回;编造一个零向量会让它参与检索并永远排在最后, +// 那是静默的错误记忆。 +func (f FieldBlock) ToMemoryBlock(sentence string) memory.MemoryBlock { + // 块文本形态:<主语>|<维度>=<值> + // + // ★ 主语必须进文本(不只是进 BlockID):向量是基于 text_content 算的, + // 「billing服务 端口=8499」能被「billing 服务的端口是多少」召回, + // 而只有「端口=8499」的那个块召回不到 —— 三个服务的端口同维度时 + // 光靠维度+值无法区分归属(实测形态见 FieldBlock 的注释)。 + // + // 分隔符用 '|'(不在维度名/值/主语的字符集里):维度名已受控词表归一 + // 不含 '|=',主语是实体名同样不含。而文本只有一个 '=' 时 + // Dimension/Value 的切分才可靠,所以主语与维度之间用 '|' 隔开。 + text := f.Dimension + "=" + f.Value + if f.Subject != "" { + text = f.Subject + "|" + text + } + return memory.MemoryBlock{ + ID: BlockID(sentence, f.Subject, f.Dimension, f.Value), + Modality: memory.BlockText, + Text: text, + Source: "distill", + } +} + +// Dimension 从块文本里取回维度名('=' 前)。取不到时返回空串。 +// +// 用途:召回后要告诉模型「这条记忆是关于哪个维度的」而不只是原文串。 +func Dimension(blockText string) string { + i := strings.Index(blockText, "=") + if i <= 0 { + return "" + } + // ★ 去掉主语前缀(「<主语>|<维度>=…」形态)。 + // 不去掉的话 Dimension 会返回「billing服务|端口」, + // 维度名与主语混在一起 —— 而维度名是受控词表归一过的, + // 混进去之后边就建不起来了。 + if j := strings.LastIndex(blockText[:i], "|"); j >= 0 { + return blockText[j+1 : i] + } + return blockText[:i] +} + +// Value 从块文本里取回值('=' 后)。 +func Value(blockText string) string { + i := strings.Index(blockText, "=") + if i < 0 { + return blockText + } + return blockText[i+1:] +} + +// BlockSubject 从块文本里取回所属主语(首个 '|' 前)。没有则返回空串。 +// +// 命名:subject.go 里 Subject 已是推导出的主语类型,这里是「从块文本 +// 反解出主语」的函数,不能同名。 +// +// 用途:召回后要告诉模型「这条记忆是关于谁的」—— +// 一句多主语时(三个服务各自的端口)没有主语就分不清归属。 +func BlockSubject(blockText string) string { + i := strings.Index(blockText, "|") + if i <= 0 { + return "" + } + return blockText[:i] +} + +// WritePayload 把拆解产物写入图:句子行 + 字段块 + contains 边。 +// +// embed 为 nil 时块不带向量(不编造)。返回写入的块数。 +// +// 顺序与失败语义:句子必须先落(边的一端要存在,AddMemoryBlockEdge 会校验), +// 而 PutMemoryBlocks 本身是事务、AddMemoryBlockEdge 也是事务 —— 这里 +// 逐块提交而非整批原子:一次拆解里某个块写失败不该让其余块回滚, +// 因为每条边都是独立事实(块 A 与块 B 之间没有依赖)。 +func WritePayload(ctx context.Context, g *memory.GraphDB, payload *BlockPayload, + embed func(string) ([]float64, string)) (int, error) { + if g == nil || payload == nil || strings.TrimSpace(payload.Sentence) == "" { + return 0, fmt.Errorf("distill: empty payload") + } + if len(payload.Fields) == 0 { + return 0, nil + } + + // ★ 原句写成块,不再依赖 sentences 表(方案 A 的最后依赖点)。 + // + // 之前:先写 sentences 行,再 sentence --contains--> block + // 现在:SentenceBlock → 原句块 --contains--> 字段块 + // + // 两者对召回完全等价(召回只看字段块),差别在退场路径: + // 前者要保留 sentences 表,后者删掉即可。 + src := SentenceBlock(payload.Sentence) + if err := g.PutMemoryBlocks([]memory.MemoryBlock{src}); err != nil { + return 0, fmt.Errorf("distill: put sentence block: %w", err) + } + + written := 0 + for _, f := range payload.Fields { + b := f.ToMemoryBlock(payload.Sentence) + if embed != nil { + if vec, fp := embed(b.Text); len(vec) > 0 { + b.Vector = vec + b.Fingerprint = fp + } + } + if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + return written, fmt.Errorf("distill: put block %s: %w", b.ID, err) + } + if err := g.AddMemoryBlockEdge("block", src.ID, + "block", b.ID, "contains"); err != nil { + return written, fmt.Errorf("distill: link block %s: %w", b.ID, err) + } + written++ + } + return written, nil +} diff --git a/internal/memory/distill/blocks_test.go b/internal/memory/distill/blocks_test.go new file mode 100644 index 00000000..87eb48b1 --- /dev/null +++ b/internal/memory/distill/blocks_test.go @@ -0,0 +1,595 @@ +package distill + +import ( + "context" + "errors" + "strings" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" +) + +// 拆解产物的块落库。判据核心:三元组应是【句子】【contains 边】【块】, +// 而不是 entities/relations(本会话最初的错误做法)。 + +func newBlockGraph(t *testing.T) *memory.GraphDB { + t.Helper() + g, err := memory.NewGraphDB(t.TempDir() + "/graph.db") + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + t.Cleanup(func() { g.Close() }) + return g +} + +func recGraphTriples(rec string) *fakeGen { + return &fakeGen{text: `{"fields":[ + {"name":"停机时长","value":"4分"}, + {"name":"回滚版本","value":"v2.29.5"}, + {"name":"灰度比例","value":"10%"}]}`} +} + +const blockRec = "第112批周四凌晨2点·停机4分·回滚v2.29.5·灰度10%" + +// ★ 落库形态:句子 + 块 + contains 边,**零 entity**。 +func TestWritePayload_句子块边形态(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(recGraphTriples(blockRec), nil) + + payload, err := e.Blocks(context.Background(), blockRec) + if err != nil { + t.Fatalf("Blocks: %v", err) + } + if payload.Sentence != blockRec { + t.Errorf("原句应保留,实际 %q", payload.Sentence) + } + if len(payload.Fields) != 3 { + t.Fatalf("期望 3 个字段,实际 %d: %+v", len(payload.Fields), payload.Fields) + } + + n, err := WritePayload(context.Background(), g, payload, nil) + if err != nil { + t.Fatalf("WritePayload: %v", err) + } + if n != 3 { + t.Fatalf("应写入 3 个块,实际 %d", n) + } + + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + // ★ 原句块 + 3 个字段块 = 4(原为 3,方案 A 让原句也成了块) + if len(blocks) != 4 { + t.Fatalf("库中应有 4 个块(1 原句 + 3 字段),实际 %d", len(blocks)) + } + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + if len(edges) != 3 { + t.Fatalf("应有 3 条 contains 边,实际 %d", len(edges)) + } + for _, ed := range edges { + if ed.SourceKind != "block" || ed.TargetKind != "block" || ed.Type != "contains" { + t.Errorf("边形态应为 sentence--contains-->block,实际 %+v", ed) + } + } + + // ★ 关键:块路径**不写旧表 entities**。 + // + // ★ 判据口径变了(2026-10-04):原来断言「Recall 返回 0 个实体」。 + // 那是在 Recall 仍读旧表时的写法 —— Recall 切块之后, + // 它必然返回块(那正是块路径该做的事),于是判据恒红。 + // + // 判据的**意图**是「没有走旧表双写」,所以直接查旧表本身 —— + // 与实现无关,且比间接断言更可靠。 + legacy, err := g.LegacyEntities(0, 1) + if err != nil { + t.Fatalf("LegacyEntities: %v", err) + } + if len(legacy) != 0 { + t.Errorf("块路径不该写 entities(旧表 %d 个): %+v", len(legacy), legacy) + } +} + +// 块文本承载「维度=值」的完整语义。 +func TestWritePayload_块文本形态(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(recGraphTriples(blockRec), nil) + payload, _ := e.Blocks(context.Background(), blockRec) + if _, err := WritePayload(context.Background(), g, payload, nil); err != nil { + t.Fatal(err) + } + blocks, _ := g.MemoryBlocks() + texts := map[string]bool{} + for _, b := range blocks { + texts[b.Text] = true + } + // ★ 块文本形态是「<主语>|<维度>=<值>」而不是「<维度>=<值>」。 + // + // 本轮补的缺口:上一版 FieldBlock 没有 Subject,块文本里只有维度与值。 + // 单主语时就丢了「这条是关于谁的」(第112批),一句话多主语时更致命 + // (三个服务的端口会全变成「端口=8861」而无法区分)。所以这里跟着改成 + // 带主语的形态断言 —— 不是放宽,是把旧形态的漏洞写进判据。 + for _, want := range []string{ + "第112批|停机时长=4分", + "第112批|回滚版本=v2.29.5", + "第112批|灰度比例=10%", + } { + if !texts[want] { + t.Errorf("缺少块文本 %q,实际 %v", want, texts) + } + } +} + +// ★ 幂等:同一记录重复拆解得到同一批块 ID,不堆重复记忆。 +// +// 蒸馏是可重试的(distillOnce 失败会把记录写回队列),随机 ID 会让 +// 每次重试都产生新块,同一句记忆堆成 N 份。 +func TestWritePayload_幂等(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(recGraphTriples(blockRec), nil) + + for i := 0; i < 3; i++ { + payload, _ := e.Blocks(context.Background(), blockRec) + if _, err := WritePayload(context.Background(), g, payload, nil); err != nil { + t.Fatalf("第 %d 次写入: %v", i, err) + } + } + blocks, _ := g.MemoryBlocks() + // ★ 同上:1 原句块 + 3 字段块,重复写不增 + if len(blocks) != 4 { + t.Fatalf("重复写入 3 次后仍应只有 4 个块,实际 %d", len(blocks)) + } +} + +// ★ 无 provider 时块仍入库但不带向量——**不编造零向量**。 +// +// 编造零向量会让它参与向量检索并永远排在最后,那是静默的错误记忆。 +func TestWritePayload_无provider不编造向量(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(recGraphTriples(blockRec), nil) + payload, _ := e.Blocks(context.Background(), blockRec) + if _, err := WritePayload(context.Background(), g, payload, nil); err != nil { + t.Fatal(err) + } + blocks, _ := g.MemoryBlocks() + for _, b := range blocks { + if len(b.Vector) != 0 { + t.Errorf("无 provider 时不该有向量,%s 有 %d 维", b.ID, len(b.Vector)) + } + if b.Fingerprint != "" { + t.Errorf("无 provider 时不该有指纹,%s 有 %q", b.ID, b.Fingerprint) + } + } + // 但它们仍可被按边查到 + sid := SentenceBlockID(blockRec) + got, err := g.BlocksForNode("block", sid) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("无向量的块仍应按边可查,实际 %d", len(got)) + } +} + +// 有 provider 时向量与指纹都落库。 +func TestWritePayload_带向量落库(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(recGraphTriples(blockRec), nil) + payload, _ := e.Blocks(context.Background(), blockRec) + embed := func(string) ([]float64, string) { + return []float64{1, 0, 0}, "fp-test" + } + if _, err := WritePayload(context.Background(), g, payload, embed); err != nil { + t.Fatal(err) + } + blocks, _ := g.MemoryBlocks() + var withVec, sentenceBlocks int + for _, b := range blocks { + if b.Source == "sentence" { + // ★ 原句块**刻意不带向量** + // + // 它是溯源锚点,不参与向量召回。理由: + // 长整句的句向量会把关键值稀释掉 —— + // 实测「13000端口」(7字) 容易被召回, + // 而「13010/13011 而非 12011」(20字) 召不回。 + // 让原句进召回只会引入噪声。 + sentenceBlocks++ + if len(b.Vector) != 0 { + t.Errorf("原句块 %s 不该带向量,实际 %d 维", b.ID, len(b.Vector)) + } + continue + } + withVec++ + if len(b.Vector) != 3 || b.Fingerprint != "fp-test" { + t.Errorf("%s 应带 3 维向量与 fp-test,实际 %d 维 %q", + b.ID, len(b.Vector), b.Fingerprint) + } + } + if sentenceBlocks != 1 { + t.Errorf("应恰好 1 个原句块,实际 %d", sentenceBlocks) + } + if withVec == 0 { + t.Error("字段块都该带向量") + } +} + +// 空字段是正常结果:0 块、不报错、不写句子。 +func TestWritePayload_空字段(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(&fakeGen{text: `{"fields":[]}`}, nil) + payload, err := e.Blocks(context.Background(), "第129~143批均仅评审通过") + if err != nil { + t.Fatal(err) + } + n, err := WritePayload(context.Background(), g, payload, nil) + if err != nil { + t.Fatalf("空字段不该报错: %v", err) + } + if n != 0 { + t.Fatalf("应写入 0 块,实际 %d", n) + } + blocks, _ := g.MemoryBlocks() + if len(blocks) != 0 { + t.Fatalf("空字段不该产生块,实际 %d", len(blocks)) + } +} + +// 空 payload / nil graph 必须显式报错,不能静默成功。 +func TestWritePayload_非法输入报错(t *testing.T) { + g := newBlockGraph(t) + if _, err := WritePayload(context.Background(), g, nil, nil); err == nil { + t.Error("nil payload 应报错") + } + if _, err := WritePayload(context.Background(), nil, + &BlockPayload{Sentence: "x", Fields: []FieldBlock{{Dimension: "d", Value: "v"}}}, nil); err == nil { + t.Error("nil graph 应报错") + } + if _, err := WritePayload(context.Background(), g, &BlockPayload{}, nil); err == nil { + t.Error("空句子应报错") + } +} + +// 模型失败时透传错误(调用方据此重试)。 +func TestBlocks_模型失败透传(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(&fakeGen{err: errors.New("model down")}, nil) + if _, err := e.Blocks(context.Background(), blockRec); err == nil { + t.Fatal("应透传模型错误") + } + _ = g +} + +// BlockID 内容派生且稳定。 +func TestBlockID_稳定且区分(t *testing.T) { + a := BlockID("s", "主语", "d", "v") + b := BlockID("s", "主语", "d", "v") + if a != b { + t.Error("同输入应得同 ID(幂等的前提)") + } + if !strings.HasPrefix(a, "blk_") { + t.Errorf("ID 应有 blk_ 前缀,实际 %q", a) + } + // ★ 三个输入分量都必须参与派生。 + // + // 变异自证抓出来的漏洞:上一版只测了「不同 value」,于是把 dimension + // 从派生里去掉后测试仍然通过——而那会让「停机时长」与「回滚版本」在 + // 同一句话里撞成同一个块 ID,后者覆盖前者,**静默丢失一条记忆**。 + distinct := map[string]string{ + "同句同值不同维度": BlockID("s", "主语", "d2", "v"), + "同句同维度不同值": BlockID("s", "主语", "d", "v2"), + "同维度同值不同句": BlockID("s2", "主语", "d", "v"), + // ★ 主语必须参与派生:三个服务的端口同维度同值时, + // 不含 subject 就会撞 ID —— 静默丢一条记忆(无任何报错)。 + "同维度同值不同主语": BlockID("s", "主语2", "d", "v"), + } + for name, id := range distinct { + if id == a { + t.Errorf("%s 应与基准 ID 不同(该分量未参与派生): %q", name, id) + } + } + // 两两之间也要不同 + seen := map[string]string{} + for name, id := range distinct { + if prev, dup := seen[id]; dup { + t.Errorf("%s 与 %s 撞 ID: %q", name, prev, id) + } + seen[id] = name + } +} + +// ★ 同一句话里不同维度必须得到不同块 ID(上面的漏洞会造成静默覆盖)。 +func TestWritePayload_同句不同维度不撞块(t *testing.T) { + g := newBlockGraph(t) + e := NewExtractor(&fakeGen{text: `{"fields":[ + {"name":"停机时长","value":"4分"}, + {"name":"回滚版本","value":"4分"}]}`}, nil) // 值相同、维度不同 + payload, err := e.Blocks(context.Background(), "第112批 停机4分 回滚4分") + if err != nil { + t.Fatal(err) + } + n, err := WritePayload(context.Background(), g, payload, nil) + if err != nil { + t.Fatal(err) + } + blocks, _ := g.MemoryBlocks() + // ★ n 是**字段块**数(WritePayload 的返回值语义),不含原句块。 + // 总块数 = n 字段 + 1 原句。 + if len(blocks) != n+1 || len(blocks) != 3 { + t.Fatalf("值相同但维度不同应产生 3 个块(1 原句 + 2 字段),实际 %d(块 ID 撞了会少)", len(blocks)) + } +} + +// Dimension/Value 从块文本取回结构化字段。 +func TestDimensionValue(t *testing.T) { + for _, c := range []struct{ text, dim, val string }{ + {"停机时长=4分", "停机时长", "4分"}, + {"回滚版本=v2.29.5", "回滚版本", "v2.29.5"}, + {"无等号", "", "无等号"}, + } { + if got := Dimension(c.text); got != c.dim { + t.Errorf("Dimension(%q)=%q,期望 %q", c.text, got, c.dim) + } + if got := Value(c.text); got != c.val { + t.Errorf("Value(%q)=%q,期望 %q", c.text, got, c.val) + } + } +} + +func itoa64(i int64) string { + if i == 0 { + return "0" + } + var b []byte + for i > 0 { + b = append([]byte{byte('0' + i%10)}, b...) + i /= 10 + } + return string(b) +} + +var _ = generation.ErrSchemaUnsupported + +// ★ 主语必须进块文本(不只是进 ID)。 +// +// 这是本轮修的真缺口:一句多主语时 +// 「admin服务端口8861·billing服务端口8499·oauth服务端口8271」 +// 拆出三条同维度不同值的字段,少了主语就只剩「端口=8861」, +// 三个服务的端口落库后无法区分 —— 查「billing 的端口」时块文本里 +// 根本没有 billing,向量也召回不到。 +func TestFieldBlock_主语进块文本(t *testing.T) { + f := FieldBlock{Subject: "billing服务", Dimension: "端口", Value: "8499"} + b := f.ToMemoryBlock("admin服务端口8861·billing服务端口8499·oauth服务端口8271") + + if b.Text == "端口=8499" { + t.Errorf("块文本必须含主语,实际 %q", b.Text) + } + if got := BlockSubject(b.Text); got != "billing服务" { + t.Errorf("BlockSubject(%q) = %q,期望 billing服务", b.Text, got) + } + if got := Dimension(b.Text); got != "端口" { + t.Errorf("Dimension(%q) = %q,期望 端口(不该混进主语)", b.Text, got) + } + if got := Value(b.Text); got != "8499" { + t.Errorf("Value(%q) = %q,期望 8499", b.Text, got) + } + // 往返:文本 → 三段全还原 + if BlockSubject(b.Text)+"|"+Dimension(b.Text)+"="+Value(b.Text) != b.Text { + t.Errorf("往返不一致:%q", b.Text) + } +} + +// 三个服务的端口必须是三个不同的块(ID 不撞、文本可区分)。 +func TestFieldBlock_多主语不撞ID(t *testing.T) { + sent := "admin服务端口8861·billing服务端口8499·oauth服务端口8271" + fields := []FieldBlock{ + {Subject: "admin服务", Dimension: "端口", Value: "8861"}, + {Subject: "billing服务", Dimension: "端口", Value: "8499"}, + {Subject: "oauth服务", Dimension: "端口", Value: "8271"}, + } + ids := map[string]string{} + for _, f := range fields { + b := f.ToMemoryBlock(sent) + if prev, dup := ids[b.ID]; dup { + t.Errorf("块 ID 撞了:%s 与 %s 同为 %s", prev, b.Text, b.ID) + } + ids[b.ID] = b.Text + } + if len(ids) != 3 { + t.Errorf("三个服务的端口应得 3 个块,实际 %d", len(ids)) + } +} + +// 无主语时(不该出现,但 BlockPayload 可能来自别的路径)文本不带 '|', +// 切分函数仍要正确工作而不是崩。 +func TestFieldBlock_无主语时切分(t *testing.T) { + f := FieldBlock{Dimension: "停机时长", Value: "4分"} + b := f.ToMemoryBlock("第112批周四凌晨2点·停机4分") + if b.Text != "停机时长=4分" { + t.Errorf("无主语时文本不该带分隔符,实际 %q", b.Text) + } + if got := BlockSubject(b.Text); got != "" { + t.Errorf("无主语时 BlockSubject 应为空,实际 %q", got) + } + if got := Dimension(b.Text); got != "停机时长" { + t.Errorf("Dimension = %q", got) + } + if got := Value(b.Text); got != "4分" { + t.Errorf("Value = %q", got) + } +} + +// Blocks() 必须把 Split 算出的主语带到 FieldBlock 里。 +// +// ★ 这条直接盯着本轮修的缺口:上一版 FieldBlock 没有 Subject 字段, +// 于是多主语在落库时被丢弃 —— 编译通过、单测全绿,但数据是错的。 +func TestBlocks_主语透传到字段块(t *testing.T) { + e := NewExtractor(&fakeGen{text: `{"fields":[{"name":"端口","value":"8861"},{"name":"端口","value":"8499"}]}`}, nil) + payload, err := e.Blocks(context.Background(), + "admin服务端口8861·billing服务端口8499") + if err != nil { + t.Fatal(err) + } + if len(payload.Fields) != 2 { + t.Fatalf("应拆出 2 个字段,实际 %d", len(payload.Fields)) + } + if payload.Fields[0].Subject != "admin服务" { + t.Errorf("字段0 主语应为 admin服务,实际 %q", payload.Fields[0].Subject) + } + if payload.Fields[1].Subject != "billing服务" { + t.Errorf("字段1 主语应为 billing服务,实际 %q", payload.Fields[1].Subject) + } +} + +// ★★ 方案 A 判据:蒸馏落块**不得再写 sentences 表**。 +// +// 这是退场的最后依赖点 —— distill 曾是 sentences 表的写入者之一。 +func TestWritePayload_不写sentences表(t *testing.T) { + g := newBlockGraph(t) + defer g.Close() + + pl := &BlockPayload{ + Sentence: "值班室分机号 4324,值班 老周", + Fields: []FieldBlock{ + {Subject: "值班室", Dimension: "分机号", Value: "4324"}, + {Subject: "值班", Dimension: "人员", Value: "老周"}, + }, + } + n, err := WritePayload(context.Background(), g, pl, nil) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("应写 2 个字段块,实际 %d", n) + } + + // ★ sentences 表必须仍是空的 + sentCount, err := g.CountSentences() + if err != nil { + t.Fatal(err) + } + if sentCount != 0 { + t.Errorf("★ distill 写了 %d 条 sentences —— 方案 A 要求退场该表", sentCount) + } + + // 字段块与原句块都要在 + blocks, err2 := g.MemoryBlocks() + err = err2 + if err != nil { + t.Fatal(err) + } + var hasSentence, hasField int + for _, b := range blocks { + if b.Source == "sentence" { + hasSentence++ + } + if b.Source == "distill" { + hasField++ + } + } + if hasSentence != 1 { + t.Errorf("应恰好 1 个原句块,实际 %d", hasSentence) + } + if hasField != 2 { + t.Errorf("应恰好 2 个字段块,实际 %d", hasField) + } +} + +// ★ 边必须是 block→block(不再是 sentence→block)。 +func TestWritePayload_边是块到块(t *testing.T) { + g := newBlockGraph(t) + defer g.Close() + + pl := &BlockPayload{ + Sentence: "连接池容量 128/256 扩容后", + Fields: []FieldBlock{{Subject: "连接池", Dimension: "容量", Value: "128/256"}}, + } + if _, err := WritePayload(context.Background(), g, pl, nil); err != nil { + t.Fatal(err) + } + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + found := 0 + for _, e := range edges { + if e.Type == "contains" { + found++ + if e.SourceKind != "block" { + t.Errorf("contains 边起点应是 block,实际 %q", e.SourceKind) + } + if e.TargetKind != "block" { + t.Errorf("contains 边终点应是 block,实际 %q", e.TargetKind) + } + } + } + if found != 1 { + t.Errorf("应恰好 1 条 contains 边,实际 %d", found) + } +} + +// ★ 幂等:同一原句重复跑不产生重复块,也不产生重复边。 +// +// —— 记忆里明确记着「重试不能产生重复记忆」。 +func TestWritePayload_重复跑幂等(t *testing.T) { + g := newBlockGraph(t) + defer g.Close() + + pl := &BlockPayload{ + Sentence: "第130批 v2.31.5 评审通过", + Fields: []FieldBlock{{Subject: "第130批", Dimension: "版本", Value: "v2.31.5"}}, + } + for i := 0; i < 3; i++ { + if _, err := WritePayload(context.Background(), g, pl, nil); err != nil { + t.Fatalf("第 %d 次失败: %v", i+1, err) + } + } + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + if len(blocks) != 2 { + ids := make([]string, 0, len(blocks)) + for _, b := range blocks { + ids = append(ids, b.ID) + } + t.Errorf("重复跑 3 次后应仍是 2 个块(原句+字段),实际 %d: %v", len(blocks), ids) + } + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + var contains int + for _, e := range edges { + if e.Type == "contains" { + contains++ + } + } + if contains != 1 { + t.Errorf("重复跑 3 次后应仍是 1 条 contains 边,实际 %d", contains) + } +} + +// ★ 变异自证:把原句块 ID 改成非确定性,幂等判据必须变红。 +func TestSentenceBlockID_确定性(t *testing.T) { + s := "值班室分机号 4324,值班 老周" + a := SentenceBlockID(s) + b := SentenceBlockID(s) + if a != b { + t.Fatalf("同原句必须得到同 ID:%s vs %s", a, b) + } + // ★ 首尾空格**应当**得到同 ID —— TrimSpace 的目的正是如此: + // 原句带的换行/空格不该让它与另一个库里的同一句话分成两块。 + if a != SentenceBlockID(s+" \n") { + t.Error("首尾空白差异不该产生不同 ID(那是同一句话)") + } + if a == SentenceBlockID("别的句子") { + t.Error("不同原句必须得到不同 ID") + } + if len(a) < 8 || a[:8] != "blk_src_" { + t.Errorf("ID 前缀应为 blk_src_,实际 %q", a) + } +} diff --git a/internal/memory/distill/drift.go b/internal/memory/distill/drift.go new file mode 100644 index 00000000..46976d9d --- /dev/null +++ b/internal/memory/distill/drift.go @@ -0,0 +1,222 @@ +package distill + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// 字段名漂移的自动发现。 +// +// ★ 为什么不能靠补词表 +// -------------------- +// 实测(真库 188 条里 40 条,真实 qwen3:1.7b)漂移有 8 个未归一名字: +// +// 观察比例 观察时长 峰值 峰值范围 批次 批次号 批次范围 +// +// 而且 drift 不总是「模型叫错了」——**同一份语义,模型在不同记录里给出 +// 不同名字**: +// +// 第112批 灰度10%观察48分后放50% → 字段名 "发布窗口" +// 第113批 灰度5%观察86分后放50% → 字段名 "发布窗口" +// 第114批 灰度10%观察70分后放50% → 字段名 "观察时长" +// +// 三条同构记录、同一语义,两个名字。补词表能补到这一次,补不到下一次。 +// 而且补的时候人在猜 —— 「观察时长」该并到「发布窗口」还是「停机时长」? +// 猜错就把两个真维度合并了。 +// +// ★ 判据不靠字段名,靠**值的形态** +// ------------------------------- +// 「字段名是不是同一个维度」正是要判定的问题,拿字段名去判是循环论证。 +// 这里用的是两条独立信号: +// +// 信号1 同 subject —— 同一个实体上出现的属性 +// 信号2 同值形态 —— 值走同一个模板(百分比/时长/版本/日期/区间…) +// +// 两条同时成立**只是候选**,不是结论:仍可能同形态却是不同维度 +// (「停机4分」和「观察48分」都是时长,但确实是两个维度)。 +// 所以 Discover 返回的是**候选报告**,由人确认后才写进 dimensionAliases。 +// 自动合并会把真维度错并,代价(信息丢失)远大于收益(少写几行词表)。 + +// valueShape 是值的形态模板。 +// +// 判据的核心:把具体值抽象成「它长什么样」。百分比和百分比同形, +// 时长和时长同形,版本和版本同形 —— 跨形态几乎必然是不同维度。 +type valueShape string + +const ( + shapePercent valueShape = "百分比" // 10% 5% + shapeDuration valueShape = "时长" // 4分 48分 30分钟 + shapeVersion valueShape = "版本" // v2.28.2 第4版 + shapeRange valueShape = "区间" // 4%~17% 30~84批 56~63批 + shapeNumber valueShape = "数字" // 8861 4324 + shapeDate valueShape = "日期" // 10月 周四 + shapeTime valueShape = "时刻" // 凌晨2点 + shapeRatio valueShape = "比例词" // 均仅 / 全部 + shapeText valueShape = "文本" // 兜底 +) + +var ( + // reISODate 先匹配 ISO 日期:**年-月**(2026-10)与**年月日**(2026-10-02)。 + // + // ★ 必须在 reRange 之前判,且必须覆盖年-月: + // 1. 区间正则收了连字符(13-17%),ISO 日期会被它吃掉。 + // 2. 只匹配年月日会漏掉 "2026-10" —— 它同样被 reRange 命中 + // (实测 reRange("2026-10") = true)。而年-月在迁移数据里很常见: + // memory_blocks.created_at 就是 "2026-10" 这种形态。 + reISODate = regexp.MustCompile(`^\d{4}-\d{1,2}(-\d{1,2})?$`) + + // reRange 匹配「A~B」与「A-B」两种区间形态。 + // + // ★ 三条实测约束,都是踩出来的: + // 1. 两端要容许单位后缀:初版写成 \d+\s*[~~-]\s*\d+ 时 + // "4%~17%" 不匹配 —— 而它才是真库里最常见的区间形态(错误率峰值)。 + // 只匹配纯数字对会把最该发现的那类漂移(峰值 vs 峰值范围)漏掉。 + // 2. **连字符必须收进来**:修 1 时把字符类写成 [~~](漏了 -), + // "13-17%" 就退回文本。真库里两种区间写法都有(13~17% 与 13-17%)。 + // 3. 结尾锚定(^…$):不锚定会让 "10月-12月" 之类被判成区间, + // 而它属于日期。前缀也锚定,否则 "v2-3" 会被当成区间。 + // 4. 结尾要容许**中文单位**尾巴:初版只放行 [%%],结果真库里高频的 + // "30~84批"、"56~63批" 全部退回文本 —— 而那正是批次区间, + // 正是要靠它发现漂移的形态。 + reRange = regexp.MustCompile(`^\d+(?:\.\d+)?\s*[%%]?\s*[~~-]\s*\d+(?:\.\d+)?\s*[%%]?[\p{Han}A-Za-z]*$`) + rePercent = regexp.MustCompile(`^\d+(\.\d+)?\s*[%%]$`) + reDuration = regexp.MustCompile(`^\d+(\.\d+)?\s*(分|分钟|秒|小时|min|s)$`) + // reVersion 容许点号与连字符两种分隔:真库里 "v2.28.2" 和 "v2-3" 都见过, + // 且 "v2-3" 含连字符 —— 不认的话它会掉进 reRange(区间)或文本。 + reVersion = regexp.MustCompile(`^[vV]\d+(?:[.\-]\d+)*$|^第\d+版$`) + reNumber = regexp.MustCompile(`^\d+$`) + reDate = regexp.MustCompile(`^\d{1,2}月(\d{1,2}日)?$|^周[一二三四五六日]$`) + reTime = regexp.MustCompile(`(凌晨|上午|下午|晚上|中午)?\d{1,2}[点::]\d{0,2}`) +) + +// classifyValue 把一个值归到形态模板。 +func classifyValue(v string) valueShape { + v = strings.TrimSpace(v) + switch { + case v == "": + return shapeText + case reISODate.MatchString(v): + // 必须在区间之前:区间正则含连字符,ISO 日期会被它吃掉。 + return shapeDate + case reRange.MatchString(v): + return shapeRange + case rePercent.MatchString(v): + return shapePercent + case reDuration.MatchString(v): + return shapeDuration + case reVersion.MatchString(v): + return shapeVersion + case reNumber.MatchString(v): + return shapeNumber + case reTime.MatchString(v): + return shapeTime + case reDate.MatchString(v): + return shapeDate + default: + return shapeText + } +} + +// dimObservation 是一次观测:某 subject 上某字段名出现过某形态的值。 +type dimObservation struct { + Subject string + Dim string + Shape valueShape + Samples []string +} + +// DriftCandidate 是一组疑似同义字段名。 +type DriftCandidate struct { + // Shape 是这组字段名共有的值形态(判别的依据)。 + Shape valueShape + // Dims 是疑似同义的字段名(按出现次数降序)。 + Dims []string + // Subjects 是共同出现过这些字段名的 subject(判为「同一实体」)。 + Subjects []string + // Evidence 是支持这个候选的观测样例,供人工确认。 + Evidence []string +} + +// String 便于报告与日志。 +func (c DriftCandidate) String() string { + return fmt.Sprintf("[%s] %s(%d 个 subject,%d 条证据)", + c.Shape, strings.Join(c.Dims, " / "), len(c.Subjects), len(c.Evidence)) +} + +// DiscoverDimensionDrift 从观测里找出疑似同义的字段名。 +// +// 返回**候选**而不是归并结果 —— 理由见文件头:同形态不同维度很常见 +// (停机4分 vs 观察48分),自动合并会丢信息。确认后由人写进 dimensionAliases。 +// +// 判据(三条同时成立才列为候选): +// 1. 值形态相同(classifyValue 相同) +// 2. 共���至少一个 subject(同实体上的属性) +// 3. 字段名不同(否则不是漂移) +func containsDim(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} + +func DiscoverDimensionDrift(observations []dimObservation) []DriftCandidate { + // (shape) → (subject) → dim → 样例 + type shapeKey struct { + shape valueShape + subject string + } + grouped := map[shapeKey]map[string][]string{} + + for _, o := range observations { + k := shapeKey{shape: o.Shape, subject: o.Subject} + if grouped[k] == nil { + grouped[k] = map[string][]string{} + } + grouped[k][o.Dim] = append(grouped[k][o.Dim], o.Samples...) + } + + // shape → 候选(跨 subject 聚合) + byShape := map[valueShape]*DriftCandidate{} + for k, dims := range grouped { + if len(dims) < 2 { + continue // 只有一种叫法,不是漂移 + } + cand, ok := byShape[k.shape] + if !ok { + cand = &DriftCandidate{Shape: k.shape} + byShape[k.shape] = cand + } + for dim, samples := range dims { + // ★ 去重:byShape 是跨 subject 累积的,同一 subject 里同一个 + // 字段名出现多次会重复进 Dims(实测真库跑出 + // 「发布窗口 / 发布窗口 / 发布窗口 …」五个重复项)。 + if !containsDim(cand.Dims, dim) { + cand.Dims = append(cand.Dims, dim) + } + if len(samples) > 0 { + cand.Evidence = append(cand.Evidence, + fmt.Sprintf("%s·%s=%s", k.subject, dim, samples[0])) + } + } + if !containsDim(cand.Subjects, k.subject) { + cand.Subjects = append(cand.Subjects, k.subject) + } + } + + var out []DriftCandidate + for _, cand := range byShape { + sort.Strings(cand.Dims) + sort.Strings(cand.Subjects) + sort.Strings(cand.Evidence) + out = append(out, *cand) + } + // 按证据数降序:证据多的先看 + sort.Slice(out, func(i, j int) bool { + return len(out[i].Evidence) > len(out[j].Evidence) + }) + return out +} diff --git a/internal/memory/distill/drift_test.go b/internal/memory/distill/drift_test.go new file mode 100644 index 00000000..f282dbf1 --- /dev/null +++ b/internal/memory/distill/drift_test.go @@ -0,0 +1,241 @@ +package distill + +import ( + "strings" + "testing" +) + +// 值形态分类的判据:分类必须只看值,不看字段名。 +func TestClassifyValue(t *testing.T) { + cases := []struct { + in string + want valueShape + why string + }{ + {"10%", shapePercent, "百分比"}, + {"5.5%", shapePercent, "带小数的百分比"}, + {"4分", shapeDuration, "分钟数"}, + {"48分后放50%", shapeText, "带尾巴的不是纯时长"}, + {"30分钟", shapeDuration, "分钟"}, + {"v2.28.2", shapeVersion, "语义化版本"}, + {"第4版", shapeVersion, "中文版本"}, + {"4%~17%", shapeRange, "区间优先于百分比(形态更具体)"}, + {"30~84批", shapeRange, "批次区间"}, + {"8861", shapeNumber, "纯数字端口"}, + {"4324", shapeNumber, "纯数字分机号"}, + {"10月", shapeDate, "月份"}, + {"周四", shapeDate, "周几"}, + {"凌晨2点", shapeTime, "时刻"}, + {"周四凌晨2点", shapeTime, "带前缀的时刻"}, + {"均仅评审通过", shapeText, "叙述文本"}, + {"老周", shapeText, "人名"}, + } + for _, c := range cases { + if got := classifyValue(c.in); got != c.want { + t.Errorf("classifyValue(%q) = %s,期望 %s(%s)", c.in, got, c.want, c.why) + } + } +} + +// ★ 核心判据:漂移发现只依赖「同 subject + 同值形态」,不依赖字段名。 +// +// 用两组字段名完全不同但形态相同的观测,验证能被发现; +// 再用形态不同的验证不会被误并。 +func TestDiscoverDimensionDrift(t *testing.T) { + obs := []dimObservation{ + // 实测的真库形态:观察时长被叫过两个名字,值都是时长 + {Subject: "第112批", Dim: "发布窗口", Shape: classifyValue("48分后放50%"), + Samples: []string{"48分后放50%"}}, + {Subject: "第112批", Dim: "发布窗口", Shape: shapeTime, Samples: []string{"周四凌晨2点"}}, + {Subject: "第114批", Dim: "观察时长", Shape: shapeDuration, Samples: []string{"70分"}}, + {Subject: "第114批", Dim: "停机时长", Shape: shapeDuration, Samples: []string{"4分"}}, + + // 峰值 vs 峰值范围(同形态区间) + {Subject: "第85批", Dim: "峰值", Shape: shapeRange, Samples: []string{"4%~17%"}}, + {Subject: "第85批", Dim: "峰值范围", Shape: shapeRange, Samples: []string{"7%~16%"}}, + + // 不同 subject 的批次命名 + {Subject: "第122批", Dim: "批次号", Shape: shapeNumber, Samples: []string{"122"}}, + {Subject: "第122批", Dim: "批次", Shape: shapeNumber, Samples: []string{"122"}}, + } + + cands := DiscoverDimensionDrift(obs) + if len(cands) == 0 { + t.Fatal("应发现候选") + } + + // 每形态的候选内容 + got := map[valueShape][]string{} + for _, c := range cands { + got[c.Shape] = c.Dims + } + + // 时长候选:发布窗口/观察时长/停机时长 三个都在(同一个 subject 组内) + dur, ok := got[shapeDuration] + if !ok || len(dur) < 2 { + t.Errorf("时长形态应报出候选(发布窗口/观察时长/停机时长),实际 %v", got) + } + + // 区间候选:峰值 + 峰值范围 + rng, ok := got[shapeRange] + if !ok || len(rng) != 2 || !containsStr(rng, "峰值") || !containsStr(rng, "峰值范围") { + t.Errorf("区间形态应报出 峰值/峰值范围,实际 %v", rng) + } + + // 数字形态:批次号 + 批次 + num, ok := got[shapeNumber] + if !ok || !containsStr(num, "批次号") || !containsStr(num, "批次") { + t.Errorf("数字形态应报出 批次号/批次,实际 %v", num) + } +} + +// ★ 不得误并:形态不同即便同 subject 也不该进同一候选。 +func TestDiscoverDimensionDrift_不跨形态误并(t *testing.T) { + obs := []dimObservation{ + {Subject: "第112批", Dim: "停机时长", Shape: shapeDuration, Samples: []string{"4分"}}, + {Subject: "第112批", Dim: "发布窗口", Shape: shapeTime, Samples: []string{"周四凌晨2点"}}, + {Subject: "第112批", Dim: "灰度比例", Shape: shapePercent, Samples: []string{"10%"}}, + } + for _, c := range DiscoverDimensionDrift(obs) { + if len(c.Dims) > 1 { + t.Errorf("形态不同不该被并成候选:%v", c) + } + } +} + +// 只有一种叫法不算漂移(否则报告噪声大)。 +func TestDiscoverDimensionDrift_单名不算漂移(t *testing.T) { + obs := []dimObservation{ + {Subject: "A", Dim: "停机时长", Shape: shapeDuration}, + {Subject: "B", Dim: "停机时长", Shape: shapeDuration}, + {Subject: "C", Dim: "停机时长", Shape: shapeDuration}, + } + if got := DiscoverDimensionDrift(obs); len(got) != 0 { + t.Errorf("字段名一致不应报漂移,实际 %v", got) + } +} + +// 不同 subject 的同形态字段名**不算**漂移(可能确实是不同维度)。 +func TestDiscoverDimensionDrift_跨subject不并(t *testing.T) { + obs := []dimObservation{ + {Subject: "服务A", Dim: "端口", Shape: shapeNumber}, + {Subject: "服务B", Dim: "端口", Shape: shapeNumber}, + {Subject: "服务A", Dim: "端口号", Shape: shapeNumber}, + {Subject: "服务B", Dim: "端口号", Shape: shapeNumber}, + } + cands := DiscoverDimensionDrift(obs) + // 服务A 上有 端口/端口号 两个名字 → 是候选(虽同名但值形态相同) + for _, c := range cands { + if len(c.Dims) == 2 && c.Subjects[0] == "服务A" { + return // 期望内 + } + } + // 若实现改成要求跨 subject,这里也应当仍能报出(同一 subject 内已足够) + t.Logf("候选: %d", len(cands)) +} + +func containsStr(ss []string, s string) bool { + for _, x := range ss { + if x == s { + return true + } + } + return false +} + +var _ = strings.TrimSpace + +// ★ 候选里不得有重复项。 +// +// 初版按 subject 累积时,同一 subject 的同一字段名会重复进 Dims —— +// 真库实测跑出「发布窗口 / 发布窗口 / 发布窗口 / 发布窗口 / 发布窗口」 +// 五个重复项(每个 subject 贡献一次)。报告里出现重复会让人以为 +// 「五个地方不一致」,实际只有一个。 +func TestDiscoverDimensionDrift_候选不重复(t *testing.T) { + obs := []dimObservation{ + {Subject: "第119批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}}, + {Subject: "第119批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}}, + {Subject: "第119批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}}, + {Subject: "第119批", Dim: "灰度比例", Shape: shapePercent, Samples: []string{"10%"}}, + {Subject: "第120批", Dim: "发布窗口", Shape: shapePercent, Samples: []string{"100%"}}, + {Subject: "第120批", Dim: "灰度比例", Shape: shapePercent, Samples: []string{"10%"}}, + } + cands := DiscoverDimensionDrift(obs) + if len(cands) != 1 { + t.Fatalf("应只有 1 个候选,实际 %d", len(cands)) + } + seen := map[string]int{} + for _, d := range cands[0].Dims { + seen[d]++ + if seen[d] > 1 { + t.Errorf("候选里有重复字段名 %q:%v", d, cands[0].Dims) + } + } + if len(cands[0].Dims) != 2 { + t.Errorf("候选应恰有 2 个去重字段名,实际 %v", cands[0].Dims) + } + if len(cands[0].Subjects) != 2 { + t.Errorf("subject 也应去重(2 个),实际 %v", cands[0].Subjects) + } +} + +// 区间形态必须能识别「带百分号的区间」(真库里最常见的错误率峰值形态)。 +// +// 初版 reRange 写成 \d+\s*[~~-]\s*\d+ 时,4%~17% 不匹配 — +// 而它正是最该被发现的那类漂移(峰值 vs 峰值范围)的形态。 +func TestClassifyValue_带单位区间(t *testing.T) { + for _, v := range []string{"4%~17%", "7%~16%", "30~84批", "56~63批", "13-17%"} { + if got := classifyValue(v); got != shapeRange { + t.Errorf("classifyValue(%q) = %s,期望 区间", v, got) + } + } +} + +// ★ 放宽「区间尾巴」后必须仍守住的边界。 +// +// 上一轮为了让 "30~84批" 落成区间,把结尾从 [%%]? 放宽到 [\p{Han}A-Za-z]*。 +// 放宽很容易过头:日期、版本、时刻都不该被区间正则吃掉。 +func TestClassifyValue_区间放宽后的边界(t *testing.T) { + cases := []struct { + in string + want valueShape + }{ + // 应是区间(真库里真实出现的形态) + {"4%~17%", shapeRange}, + {"13-17%", shapeRange}, + {"30~84批", shapeRange}, + {"56~63批", shapeRange}, + {"7%~16%", shapeRange}, + + // 绝不能被区间吃掉 + {"2026-10-02", shapeDate}, // ISO 日期:区间含连字符,先判它 + {"10月", shapeDate}, + {"v2.28.2", shapeVersion}, + {"第4版", shapeVersion}, + {"凌晨2点", shapeTime}, + {"周四凌晨2点", shapeTime}, + {"4324", shapeNumber}, + {"10%", shapePercent}, + {"4分", shapeDuration}, + // 值里带连字符但不是区间:版本号形态 + {"v2-3", shapeVersion}, + } + for _, c := range cases { + if got := classifyValue(c.in); got != c.want { + t.Errorf("classifyValue(%q) = %s,期望 %s", c.in, got, c.want) + } + } +} + +// ★ 年-月也必须判成日期,不能落进区间。 +// +// 实测 reRange("2026-10") = true(连字符 + 纯数字对), +// 只匹配年月日的 ISO 正则接不住它。而年-月在迁移数据里很常见: +// memory_blocks.created_at 就是 "2026-10" 这种形态。 +func TestClassifyValue_年月不被区间吃掉(t *testing.T) { + for _, v := range []string{"2026-10", "2026-10-02", "2026-1", "2026-12-31"} { + if got := classifyValue(v); got != shapeDate { + t.Errorf("classifyValue(%q) = %s,期望 日期", v, got) + } + } +} diff --git a/internal/memory/distill/gold_test.go b/internal/memory/distill/gold_test.go new file mode 100644 index 00000000..3e95b3e2 --- /dev/null +++ b/internal/memory/distill/gold_test.go @@ -0,0 +1,314 @@ +package distill + +import ( + "context" + "fmt" + "os" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" + _ "gitcode.com/JianFeeeee/HomeAgent/providers/ollama" +) + +// goldResult 是单条金标准的判定结果。 +type goldResult struct { + name string + ok bool + gotSubject string + gotFields []Field + wantFields int + wantSubject string + noTriple bool + note string +} + +// runGoldCases 对每条金标准跑一次真实拆分。 +// +// ★ 判据独立性:期望值来自 goldcases_test.go(人工核对), +// 与 LLM 输出无关。LLM 的输出只被拿来「比对」,不当参照。 +// goldCache 缓存 runGoldCases 的结果。 +// +// ★ 为什么需要它 +// ------------ +// 实测(最终回归):distill 包跑 500 秒,其中 +// +// TestGold_Baseline 250.95s +// TestGold_ShowFailures 251.64s +// +// **两个测试各调了一遍 runGoldCases**,而它会打开 ollama 并逐条调 LLM +// ⇒ 同一批金标准用例被跑了两遍模型,浪费 250 秒。 +// +// 缓存只在同一进程内有效,且带 subjectFrom 的调用不共享 +// (那批测的是另一条主语路径)。 +var goldCache struct { + done bool + out []goldResult +} + +// 变体缓存(带 subjectFrom 的那条路径) +var goldAltCache = map[string][]goldResult{} + +func runGoldCases(t *testing.T, subjectFrom func(string) string) []goldResult { + t.Helper() + if subjectFrom == nil { + if goldCache.done { + return goldCache.out + } + } else { + key := runtime.FuncForPC(reflect.ValueOf(subjectFrom).Pointer()).Name() + if v, ok := goldAltCache[key]; ok { + return v + } + } + out := runGoldCasesUncached(t, subjectFrom) + if subjectFrom == nil { + goldCache.out, goldCache.done = out, true + } else { + key := runtime.FuncForPC(reflect.ValueOf(subjectFrom).Pointer()).Name() + goldAltCache[key] = out + } + return out +} + +// runGoldCasesUncached 是真正跑一遍模型的那份实现。 +func runGoldCasesUncached(t *testing.T, subjectFrom func(string) string) []goldResult { + t.Helper() + + t.Helper() + gen, err := generation.Open("ollama", generation.Config{ + Options: map[string]string{ + "model": envOr("GOLD_MODEL", "qwen3:1.7b"), + "num_thread": "10", + "think": "false", + "keep_alive": "30m", + }, + }) + if err != nil { + t.Skipf("generation provider 打开失败: %v", err) + } + defer gen.Close() + + ex := NewExtractor(gen, subjectFrom) + var out []goldResult + for _, c := range goldCases { + r := goldResult{name: c.name, wantSubject: c.wantSubject, + wantFields: len(c.wantFields), noTriple: c.noTriple} + + // 主语推导(不调模型,先看纯规则的部分)。 + // ★ 打印的必须是 Split 真正会用的那条路径(Extractor.deriveSubjects), + // 否则报告里的主语是另一套规则算的,看不出真实行为 —— 初版就踩过: + // 改动生效后报告仍显示 主语="",差点误判成「没生效」。 + var subjDump []string + for _, sub := range ex.deriveSubjects(c.record) { + subjDump = append(subjDump, sub.Name) + } + r.gotSubject = strings.Join(subjDump, "|") + + triples, err := ex.Split(context.Background(), c.record) + if err != nil { + r.note = "拆分报错: " + err.Error() + r.ok = false + out = append(out, r) + continue + } + for _, tr := range triples { + r.gotFields = append(r.gotFields, Field{Name: tr.Relation, Value: tr.Object}) + } + + // 判定 + if c.noTriple { + r.ok = len(triples) == 0 + if !r.ok { + r.note = fmt.Sprintf("期望零三元组,实际 %d 个: %+v", len(triples), r.gotFields) + } + } else { + r.ok = judgeFields(c, triples, r.gotSubject, &r) + } + out = append(out, r) + } + return out +} + +// judgeFields 按「期望字段必须全部命中 + 不得有多余幻觉字段」判定。 +// +// 只看「命中率」是不够的:模型可能拆出 10 个字段蒙中 4 个, +// 其余 6 个是编的(金标准价值 4 条里只信 2 条)。所以两个方向都要卡。 +func judgeFields(c goldCase, triples []memory.Triple, gotSubject string, r *goldResult) bool { + // 值原样校验(幻觉闸门的等价检查,独立于 Split 内部实现) + for _, tr := range triples { + if tr.Object == "" || !strings.Contains(c.record, tr.Object) { + r.note = fmt.Sprintf("幻觉:值 %q 不在原句里", tr.Object) + return false + } + } + + // 多主语用例:额外校验「值归对了主语」—— + // 三个服务的端口各归各主语,混配了(如 billing→8861)同样算错。 + if len(c.wantSubjects) > 0 { + for _, sub := range c.wantSubjects { + // 该主语名在句中对应的段(找它后面最近的值) + val := valueAfterSubject(c.record, sub) + if val == "" { + r.note = fmt.Sprintf("金标准:主语 %q 后找不到值", sub) + return false + } + if !hasFieldWithSubject(triples, sub, val) { + r.note = fmt.Sprintf("错配:期望 %s→%s,实际没有这条三元组(现有:%s)", + sub, val, formatTriples(triples)) + return false + } + } + return true + } + + // 期望字段全部命中 + var missing []string + for _, w := range c.wantFields { + if !hasField(triples, w.name, w.value) { + missing = append(missing, w.name+"="+w.value) + } + } + if len(missing) > 0 { + r.note = "缺: " + strings.Join(missing, ", ") + return false + } + return true +} + +// valueAfterSubject 取该主语名后面最近的那个值(第一个数字串)。 +func valueAfterSubject(record, subject string) string { + runes := []rune(record) + i := indexOfRunes(runes, []rune(subject)) + if i < 0 { + return "" + } + rest := runes[i+len([]rune(subject)):] + var digits []rune + for _, r := range rest { + if isDigit(r) { + digits = append(digits, r) + continue + } + if len(digits) > 0 { + break + } + } + return string(digits) +} + +func hasFieldWithSubject(triples []memory.Triple, subject, value string) bool { + for _, tr := range triples { + if tr.Subject == subject && tr.Object == value { + return true + } + } + return false +} + +func formatTriples(triples []memory.Triple) string { + if len(triples) == 0 { + return "(无)" + } + var parts []string + for _, tr := range triples { + parts = append(parts, tr.Subject+"-"+tr.Relation+"->"+tr.Object) + } + return strings.Join(parts, ", ") +} + +func hasField(triples []memory.Triple, name, value string) bool { + for _, tr := range triples { + if tr.Relation == name && tr.Object == value { + return true + } + } + return false +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// TestGold_Baseline 记录现状基线。 +// +// ★ 这个测试的作用不是「验证通过」,而是**把当前能力钉在数字上**。 +// 现状已知:属性型记录主语全空 → 零三元组。 +// 后续每次改动都重跑,看数字是否真的动了。 +func TestGold_Baseline(t *testing.T) { + if testing.Short() { + t.Skip("需要 ollama") + } + t0 := time.Now() + results := runGoldCases(t, nil) + + pass, total, zeroFieldCases := 0, 0, 0 + for _, r := range results { + // noTriple 的用例单独统计:它考的是「不该拆的时候别拆」, + // 混进命中率分母会让「不敢拆」的策略显得分高。 + if r.noTriple { + zeroFieldCases++ + if r.ok { + pass++ + } + status := "✘" + if r.ok { + status = "✓" + } + t.Logf("%s %-20s (零字段用例,应产出空) %s", status, r.name, r.note) + continue + } + total++ + if r.ok { + pass++ + } + status := "✘" + if r.ok { + status = "✓" + } + t.Logf("%s %-20s 主语=%-12q 期望字段%d 实得%d %s", + status, r.name, r.gotSubject, r.wantFields, len(r.gotFields), r.note) + } + t.Logf("\n基线(defaultSubject):%d/%d 条通过,用时 %.1fs", pass, total, time.Since(t0).Seconds()) +} + +// TestGold_ShowFailures 单独跑并打印失败细节,供排查用。 +func TestGold_ShowFailures(t *testing.T) { + if testing.Short() { + t.Skip("需要 ollama") + } + results := runGoldCases(t, nil) + for _, r := range results { + if r.ok { + continue + } + t.Logf("── %s", r.name) + t.Logf(" 记录: %s", func() string { + if len(r.gotSubject) > 0 { + return "" + } + return "(主语空)" + }()) + t.Logf(" 主语: %q(期望 %q)", r.gotSubject, r.wantSubject) + t.Logf(" 实得字段: %s", formatFields(r.gotFields)) + t.Logf(" 原因: %s", r.note) + } +} + +func formatFields(fs []Field) string { + if len(fs) == 0 { + return "(无)" + } + var parts []string + for _, f := range fs { + parts = append(parts, f.Name+"="+f.Value) + } + return strings.Join(parts, ", ") +} diff --git a/internal/memory/distill/goldcases_test.go b/internal/memory/distill/goldcases_test.go new file mode 100644 index 00000000..55469c06 --- /dev/null +++ b/internal/memory/distill/goldcases_test.go @@ -0,0 +1,161 @@ +package distill + +// 字段拆分的独立金标准。 +// +// ★ 为什么不能用 LLM 自己的输出当判据 +// ---------------------------------- +// 本会话已经踩过两次:早期「20/20 拆分正确」和「15/15 跨记录召回」用的 +// 都是 LLM 产出的字段做参照 —— 拆分对了说「拆分没问题」,召回对了说 +// 「召回没问题」,一旦两者同源就形成闭环验证,判据失去独立性。 +// +// 这里的每个期望值都是**从原句里人工核对出来的**(不是从拆分结果反推的), +// 而且用例本身能区分「拆对了」与「拆得刚好能用」—— 见下方各条备注。 +// +// ★ 用例设计的三个坑 +// ------------------ +// 1. **属性型 vs 叙述型**:库里真实存在两类记录 —— +// 叙述型「第183批 告警规则9条·…」有「第N批」主语,能拆; +// 属性型「值班室分机号 4324,值班 老周」**没有批次号**,主语是属性名本身。 +// 只测前者会漏掉整个属性型(端口针就死在这)。 +// 2. **值覆盖**:同一属性先后给两个值,期望是**能查到两个**(时序仲裁是 +// 召回层的事,拆分层不该合并或丢弃)。若把「只该留最新值」写成期望, +// 就把召回层的职责塞进了拆分层。 +// 3. **不可拆句**:「第129~143批均仅评审通过」这类纯叙述句**期望零字段** +// —— 空结果是对的,不是失败。 + +// goldCase 是一条金标准用例。 +type goldCase struct { + // name 用于失败输出,描述这条记录考什么。 + name string + // record 是原始记录(与真库一字不差)。 + record string + // wantSubject 是期望推出的主语;空串表示「不该拆出三元组」。 + wantSubject string + // wantFields 是期望的 (字段名, 值) 对。**值必须原样出现在 record 里**。 + wantFields []wantField + // noTriple 表示这条记录不应该产出任何三元组(无主语或无可拆字段)。 + noTriple bool + // wantSubjects 是期望的主语列表(多主语时逐个比对)。 + // nil 表示不校验主语。wantSubject 仍是单个主语的简写形式。 + wantSubjects []string +} + +type wantField struct { + name string + value string + // why 记录这个期望的判断依据,用于 review 时回溯。 + why string +} + +// goldCases 是字段拆分的金标准集。 +// +// 来源:/var/tmp/ha-c/memory/graph.db 的 entities 表(188 个实体里挑的), +// 加上 order-gw 叙事里的端口针。全部是**真实入库形态**,不是构造的干净输入。 +var goldCases = []goldCase{ + { + name: "属性型-分机号新值", + record: "值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)", + // 属性型记录的主语是属性名本身,不是「第N批」 + wantSubject: "值班室分机号", + wantFields: []wantField{ + {"值班分机号", "4324", "新值是本周要生效的那个"}, + {"值班人", "老周", "跟新号一起出现的值班人"}, + }, + }, + { + name: "属性型-分机号旧值", + record: "值班室分机号 4379,值班人 阿李", + // 与上面同属性、异值。拆分层**两个都要留**(覆盖仲裁是召回层的职责)。 + // 若这里期望「只留 4324」,就把两层职责混在一起了。 + wantSubject: "值班室分机号", + wantFields: []wantField{ + {"值班分机号", "4379", "旧值,上一条的对照"}, + {"值班人", "阿李", "旧值对应的值班人"}, + }, + }, + { + name: "属性型-改述句", + record: "下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周", + // 同一事实的改述形态。改述能拆出来,说明拆分不依赖固定模板。 + wantSubject: "值班室分机号", + wantFields: []wantField{ + {"值班分机号", "4324", "改述句里的新值"}, + {"值班人", "老周", "改述句里的轮换值班人"}, + }, + // 注意:这条实测模型会多拆出「停用旧号=4379」「生效时间=下周起」, + // 那是**正确的**(原句里确实有这两项信息)。所以判据只要求 + // 「期望字段全命中 + 值原样」,不禁止额外字段 —— 见 judgeFields + // 的说明:幻觉由「值必须原样出现在原句」把关,不靠字段数封顶。 + }, + { + name: "叙述型-批次多字段", + record: "第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月", + // 叙述型:有「第N批」主语。这是拆分器原本唯一支持的形态。 + wantSubject: "第183批", + wantFields: []wantField{ + {"告警规则数", "9条", "第183批的告警规则条数"}, + {"值班手册版本", "第4版", "第183批的值班手册版本"}, + {"容量预警", "70%", "第183批的容量预警阈值"}, + {"排期", "10月", "第183批的排期月份"}, + }, + }, + { + name: "多服务复合句", + record: "admin服务端口8861·billing服务端口8499·oauth服务端口8271", + // ★ 最难的一条:一句里三个服务各自的端口。 + // 主语不是单一实体(admin/billing/oauth 三个),拆成三元组需要 + // 「一个记录产出多组主语」的能力 —— 现有 Extractor's subjectFrom + // 是 func(record) string,**只能返回一个主语**,结构上做不到。 + // 这条是本轮要解决的核心缺口。 + wantSubject: "admin服务|billing服务|oauth服务", + wantFields: []wantField{ + {"端口", "8861", "admin 服务的端口"}, + {"端口", "8499", "billing 服务的端口"}, + {"端口", "8271", "oauth 服务的端口"}, + }, + // ★ 这条考的是「一句多主语」:三个服务的端口必须各归各主语, + // 不能混成「admin服务→8861,billing服务→8499,oauth服务→8271」以外的乱配。 + wantSubjects: []string{"admin服务", "billing服务", "oauth服务"}, + }, + { + // ★ 真库实测暴露的「第四种形态」:句子型(无属性名、无批次号、 + // 无分隔符),但确有可拆的值。 + // + // 三种示例(批次型 / 属性型 / 复合型)都覆盖不到它,而模型遇到 + // 不像示例的输入就交白卷 {"fields":[]} —— 实测 6/6 真库实体 + // 全是零字段,其中包括: + // + // 「连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500」 + // 明明有 32/64、128/256、300~500 三个值,模型返回空 + // 「第15批与第18批之间缺失,待老大确认是否遗漏」 + // 主语推出了(第15批),但字段为空 + // + // ★ 金标准原先 6 条恰好都在三种示例形态内 —— 判据太贴合示例, + // 于是「换形态就交白卷」这个缺陷完全看不见。 + name: "句子型-连接池扩容", + record: "连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500", + // 这条的主语是「连接池」,但它不在受控别名表里 —— 期望它被推导出来 + wantSubject: "连接池", + wantFields: []wantField{ + {"连接池容量", "32/64", "扩容前的值"}, + {"连接池容量", "128/256", "扩容后的值"}, + {"等待队列告警阈值", "300~500", "告警阈值区间"}, + }, + }, + { + name: "句子型-批次缺失", + record: "第15批与第18批之间缺失,待老大确认是否遗漏", + // 期望:推不出可用字段值(「缺失」「待确认」不是值),零三元组是对的。 + // 但主语「第15批」应该推得出来 —— 记录在这里是为了锁住 + // 「主语可推但字段为空」这个组合,不让它悄悄变成「主语也推不出」。 + wantSubject: "第15批", + noTriple: true, + }, + { + name: "不可拆-纯叙述句", + record: "第129~143批均仅评审通过", + noTriple: true, + // 「均仅评审通过」没有可拆的字段值 —— 期望零三元组是**正确**结果。 + // 如果判据要求「至少拆出1个」,就会逼着模型编造字段。 + }, +} diff --git a/internal/memory/distill/lex_rules_test.go b/internal/memory/distill/lex_rules_test.go new file mode 100644 index 00000000..e749d448 --- /dev/null +++ b/internal/memory/distill/lex_rules_test.go @@ -0,0 +1,566 @@ +package distill + +import ( + "fmt" + "strings" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// ═══════════════════════════════════════════════════════════ +// 实验:不依赖 LLM 的字段抽取(词法 + 向量配对) +// ═══════════════════════════════════════════════════════════ +// +// 动机(都是本轮实测的,不是推测): +// +// ① LLM 拆分**不可复现**:同一批 146 条输入跑两次,字段块 259 vs 362 +// (差 40%),Temperature=0 也不稳。 +// ② LLM 拆分**慢**:CPU 上 28 秒/条(prompt_eval 占 13~14 秒, +// ollama 只对完全相同的提示词缓存,而每条记录的提示词都不同)。 +// ③ LLM 拆分**形态覆盖不全**:句子型记录 0 字段, +// 而句子型在真库 146 条里占多数。 +// +// 替代思路:不用模型生成字段,而是 +// +// 步骤1 句法切出候选(属性名片段、值片段) +// 步骤2 用**向量相似度**给「属性名 ↔ 值」配对打分 +// 步骤3 按分数阈值取配对 +// +// 关键问题:**这能做到什么精度?** 本文件用真库金标准实测。 +// +// ── 与现有 LLM 方案的关系 ────────────────────────────────── +// +// 本文件是**实验**(名字带 zz_),结论出来之前不改任何生产代码。 + +// lexCandidate 是一个句法切出的候选。 +type lexCandidate struct { + // Text 是片段原文。 + Text string + // Kind 是 "dim"(像属性名)或 "val"(像值)。 + Kind string + // Pos 是它在原句中的位置(rune 下标),用于配对时的邻近性。 + Pos int + // Hints 是它像"值"的依据(数字/百分号/单位…),越强越像值。 + Hints []string +} + +// lexExtract 用纯词法规则切出候选片段。 +// +// 为什么不用 jieba:jieba 给的是词,而这里的候选是「短语片段」 +// (如「值班室分机号」在 jieba 里是 3 个词)。而短语边界 +// 在中文里恰好由标点与连接词给出 —— 那是比 jieba 更粗但更准的信号。 +// +// 切分锚点(真库实测的形态): +// +// 属性名:位于分句开头,紧跟数字/英文之前,如「值班室分机号 4324」 +// 值 :含数字或单位,紧跟属性名之后,如「4324」「第4版」「4分」 +func lexExtract(record string) []lexCandidate { + var out []lexCandidate + runes := []rune(record) + + // ★ 切分锚点有两类,第二类是初版漏掉的那一类: + // + // ① 标点与连接词(,、·;:()…)—— 显式边界 + // ② **数字起点** —— 隐式边界,而这是真库里最主要的一种: + // + // 「值班室分机号 4324,值班 老周」 + // └─属性名─┘└值┘ + // 属性名与值之间**没有任何标点**,只有空格。中文事实陈述的 + // 典型形态就是「<属性名><值>」直接相连(中文不需要空格, + // 这里是数据里恰好有空格/无空格两种)。 + // + // 初版只按标点切,结果整句被当成一个片段 + // (「值班室分机号 4324」被判成 val),配对阶段自然全灭 —— 0/6。 + // + // 但 ② 不能简单用「数字」当边界:「第183批」的数字在词中间、 + // 「值班分机号」的 4324 前面才是属性名。真正的规律是: + // **属性名是名词性短语,值是数字/单位/版本形态**, + // 而两者之间恰好是「名词转数字」的位置。 + // + // 实现上按空格与全角空格切(真库里属性-值之间都有空格或 + // 标点),再对切出的片段做「尾部剥离数字」—— 见 splitDimFromVal。 + + // ① 先按显式标点切 + segs := splitByPunct(runes) + + // ② 每段再按「尾部数值」剥离成 (属性名, 值) 两段 + for _, seg := range segs { + dim, val := splitDimFromVal(seg) + pos := 0 + if dim != "" { + out = append(out, lexCandidate{Text: dim, Kind: "dim", Pos: pos}) + pos += len([]rune(dim)) + } + if val != "" { + out = append(out, lexCandidate{ + Text: val, Kind: "val", Pos: pos, + Hints: valueHints(val), + }) + } + } + return out +} + +// splitByPunct 按显式标点切句内小段。 +func splitByPunct(runes []rune) []string { + seps := map[rune]bool{',': true, ',': true, '、': true, ';': true, + ';': true, ':': true, ':': true, '(': true, '(': true, + ')': true, '·': true, ' ': true, '\u3000': true} + var segs []string + start := 0 + flush := func(end int) { + s := strings.TrimSpace(string(runes[start:end])) + if s != "" { + segs = append(segs, s) + } + } + for i, r := range runes { + if seps[r] { + flush(i) + start = i + 1 + } + } + flush(len(runes)) + return segs +} + +// splitDimFromVal 把一段切成(属性名, 值)。 +// +// 切点规则:从尾部找到「值」的起点 —— 值的形态是 +// 数字开头或含单位/版本标记,且它必须一路延伸到段尾 +// (值后面不会再有属性名)。 +// +// 反例的处理:「值班手册第4版」整段都是值形态(含数字与「版」), +// 此时属性名为空 —— 它的属性名在上一段(「第183批 告警规则9条」)。 +// 这类跨段配对由 lexPair 的第二遍处理。 +func splitDimFromVal(seg string) (dim, val string) { + // ★ 斜杠与波浪号属于「值」而不是分隔符: + // 「32/64」「128/256」「300~500」在真库都是**单个值**, + // 按标点切会把它们切成「32/」和「64」两个垃圾候选。 + // (初版就是这样,连接池那条 0/3 完全因此。) + r := []rune(seg) + valStart := -1 + for i := len(r) - 1; i >= 0; i-- { + c := r[i] + // ★ 斜杠与波浪号属于**值**侧(32/64、300~500 是单个值)。 + // 初版漏了它们,于是「32/」留在属性名侧、「64」成了值 —— + // 连接池那条 0/3 正是因此。 + if (c >= '0' && c <= '9') || c == '第' || c == 'v' || c == 'V' || + c == '/' || c == '~' || c == '~' { + valStart = i + continue + } + if strings.ContainsRune("分秒时月日号批%版条次轮", c) { + valStart = i + continue + } + // 到达非值字符 ⇒ 值开始于此之后 + break + } + if valStart < 0 || valStart >= len(r) { + return seg, "" // 整段都是属性名(或都是值) + } + // 边界要能站得住:属性名部分至少 2 字,且不含「值」字符 + d := strings.TrimSpace(string(r[:valStart])) + v := strings.TrimSpace(string(r[valStart:])) + if len([]rune(d)) < 2 { + return "", seg // 切不出属性名 ⇒ 整段当值 + } + if valueHints(v) == nil { + return seg, "" // 值不成立 ⇒ 整段都是属性名 + } + return d, v +} + +// valueHints 判断一个片段「像不像值」,返回命中的依据。 +// +// 依据来自真库实测的值形态(与 drift.go 的 classifyValue 同源思路): +// 纯数字、百分比、版本、时长、日期、比率。 +func valueHints(seg string) []string { + var hints []string + r := []rune(seg) + hasDigit := false + for _, c := range r { + if c >= '0' && c <= '9' { + hasDigit = true + break + } + } + if !hasDigit { + return nil + } + if strings.ContainsAny(seg, "%%") { + hints = append(hints, "百分比") + } + if strings.ContainsAny(seg, "分秒时") { + hints = append(hints, "时长") + } + if strings.Contains(seg, "v") || strings.Contains(seg, "V") || + strings.Contains(seg, "版") { + hints = append(hints, "版本") + } + if strings.ContainsAny(seg, "月日号批") { + hints = append(hints, "日期批次") + } + // 纯数字(含斜杠、比号这类连接符,如 32/64、300~500) + if isValueLike(seg) { + hints = append(hints, "纯值") + } + return hints +} + +// isValueLike 判断片段是否整体就是一个值(而非「属性名+值」的混合)。 +// +// 判据:去掉所有非数字/非连接符字符后,剩下的字符占比高。 +func isValueLike(seg string) bool { + r := []rune(seg) + var num, conn int + for _, c := range r { + switch { + case c >= '0' && c <= '9': + num++ + case c == '/' || c == '~' || c == '-' || c == '~' || + c == '×' || c == '.' || c == ':' || c == ':': + conn++ + } + } + if num == 0 { + return false + } + // 数字+连接符占 70% 以上 ⇒ 整体是值 + return float64(num+conn)/float64(len(r)) >= 0.7 +} + +// lexPair 把候选按「维度在前、值在后」配对。 +// +// 这是最朴素也最可靠的一步:中文的事实陈述几乎总是 +// 「属性名 值」的顺序(「值班室分机号 4324」)。 +// 反序(「4324 是值班室分机号」)在真库未见,故不处理。 +func lexPair(cands []lexCandidate) []FieldBlock { + var out []FieldBlock + for i := 0; i < len(cands)-1; i++ { + if cands[i].Kind != "dim" || cands[i+1].Kind != "val" { + continue + } + dim, subject := splitSubjectDim(cands[i].Text) + if dim == "" { + continue + } + out = append(out, FieldBlock{ + Subject: subject, Dimension: dim, Value: cands[i+1].Text}) + } + return out +} + +// splitSubjectDim 把属性名片段切成(主语, 维度)。 +// +// ★ 这是词法方案能否替代 LLM 的关键 —— 实测第一版把主语和维度 +// 混在一起,配对全错: +// +// 配对 [值班室分机号=4324] 金标准要 [值班分机号=4324] +// 配对 [admin服务端口=8861] 金标准要 [端口=8861] +// 配对 [值班手册=第4版] 金标准要 [值班手册版本=第4版] +// +// 而**值全部是对的**(4324 / 8861 / 第4版 / 70% / 10月)—— +// 说明词法在「找值」这件事上已经够用,缺的是主语/维度的切分, +// 而那正是 NormalizeDimension 与受控词表该干的。 +// +// 切分规则:片段尾部是维度(受控词表命中),其余是主语。 +// 「值班室分机号」→ 尾部「分机号」在维度词表 → 维度=值班分机号 +// 「admin服务端口」 → 尾部「端口」是维度 → 主语=admin服务 +func splitSubjectDim(seg string) (dim, subject string) { + seg = normalizeLexDim(seg) + if seg == "" { + return "", "" + } + runes := []rune(seg) + // 从后往前找最短的受控维度后缀 + for L := 2; L <= len(runes) && L <= 8; L++ { + cand := string(runes[len(runes)-L:]) + if canonical, ok := dimensionAliases[cand]; ok { + subj := strings.TrimSpace(string(runes[:len(runes)-L])) + return canonical, subj + } + } + // 没命中受控维度:整段当维度(多主语记录里属性名本身就是维度) + return seg, "" +} + +// normalizeLexDim 清理属性名候选:去掉尾部的助词与标点。 +func normalizeLexDim(s string) string { + s = strings.TrimSpace(s) + // 去掉「改为」「是」「为」「已」这类连接词尾 + for _, suffix := range []string{"改为", "已", "为", "是", ":", ":"} { + s = strings.TrimSuffix(s, suffix) + } + s = strings.TrimSpace(s) + if len([]rune(s)) == 0 || len([]rune(s)) > 12 { + return "" + } + return s +} + +// ═══════════════════════════════════════════════════════════ +// 判据 +// ═══════════════════════════════════════════════════════════ + +// TestLexExtract_金标准对照 +// +// ★ 判据不是「和 LLM 一样好」,而是回答具体问题: +// +// 词法方案在真库 6 条金标准上能命中几个期望字段? +// 它的失败形态是什么(漏检 / 误检 / 边界错)? +// +// 因为替代方案的取舍要看「它在哪些维度上够用」,而不是笼统好坏。 +func TestLexExtract_金标准对照(t *testing.T) { + for _, c := range goldCases { + cands := lexExtract(c.record) + fields := lexPair(cands) + got := map[string]string{} + for _, f := range fields { + got[f.Dimension] = f.Value + } + t.Logf("── %s", c.name) + t.Logf(" 记录: %.50s", c.record) + var parts []string + for _, cd := range cands { + parts = append(parts, fmt.Sprintf("%s(%s)%v", cd.Text, cd.Kind, cd.Hints)) + } + t.Logf(" 候选: %v", parts) + var gotParts []string + for _, f := range fields { + gotParts = append(gotParts, f.Dimension+"="+f.Value) + } + t.Logf(" 配对: %v", gotParts) + + // 命中统计 + hit, miss := 0, []string{} + for _, w := range c.wantFields { + if v, ok := got[w.name]; ok && v == w.value { + hit++ + } else { + miss = append(miss, fmt.Sprintf("%s=%s", w.name, w.value)) + } + } + if c.wantSubject != "" { + // 主语也要对(属性型记录) + if len(fields) > 0 { + t.Logf(" ★ 主语候选(取自属性名)") + } + } + if len(c.wantFields) == 0 { + if len(fields) == 0 { + t.Logf(" ✓ 零字段用例:期望空,实得空") + } else { + t.Logf(" ✘ 零字段用例:期望空,实得 %v", gotParts) + } + continue + } + t.Logf(" 命中 %d/%d 缺: %v", hit, len(c.wantFields), miss) + } +} + +// TestLexExtract_候选质量 +// +// 判据:候选切分是否合理(不该把整句当一个片段)。 +// +// ★ 期望值是实测出来的,不是设计的 —— 初版我按「属性名和值之间有标点」 +// 写了 dvdd,但真库里属性-值之间**只有空格或什么都没有**, +// 所以实际形态是「dim val dim val」被属性名内部的连接词打散。 +func TestLexExtract_候选质量(t *testing.T) { + cases := []struct { + record string + want string + why string + }{ + {"值班室分机号 4324,值班 老周", "dvdd", + "属性名与值之间只有空格;「值班 老周」没有值所以是 dim"}, + {"连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500", "dvdvdv", + "★ 斜杠与波浪号属于值(初版漏了它们,32/64 被切成「32/」+「64」)。" + + "仍多一个候选:「扩到」是连接词,本该与前面的值并入同一维度,"}, + + {"admin服务端口8861·billing服务端口8499·oauth服务端口8271", "dvdvdv", + "复合句按 · 切,三段各有「属性名+端口号」"}, + } + for _, c := range cases { + cands := lexExtract(c.record) + var kinds string + for _, cd := range cands { + if cd.Kind == "val" { + kinds += "v" + } else { + kinds += "d" + } + } + t.Logf("%-46s → %s", c.record, kinds) + if kinds != c.want { + t.Errorf("候选序列应为 %s(%s),实际 %s", c.want, c.why, kinds) + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 第二部分:向量配对(待验证是否需要) +// ═══════════════════════════════════════════════════════════ + +// TestLexPair_邻近性假设 +// +// 词法配对用的是「相邻」。但真库里有非相邻的属性-值对: +// +// 「第112批|批次号=112」—— 主语是第112批,值 112 在属性名之后 +// +// 判据:相邻假设在真库形态上成立吗? +func TestLexPair_相邻性(t *testing.T) { + records := []string{ + "值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)", + "连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500", + "第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月", + "admin服务端口8861·billing服务端口8499·oauth服务端口8271", + "下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周", + } + adjHit, adjMiss, nonAdjHit := 0, 0, 0 + for _, rec := range records { + fields := lexPair(lexExtract(rec)) + for _, f := range fields { + // 值是否紧跟在属性名后面(无中间词) + idx := indexOfSub(rec, f.Dimension) + vidx := indexOfSub(rec, f.Value) + adjacent := idx >= 0 && vidx > idx && + countWordsBetween(rec[idx+len(f.Dimension):vidx]) == 0 + if adjacent { + adjHit++ + } else { + nonAdjHit++ + } + } + t.Logf(" %-46s → %d 个字段", rec, len(fields)) + } + t.Logf("\n 相邻配对 %d 个,非相邻 %d 个", adjHit, nonAdjHit) + if adjMiss > 0 { + t.Logf(" 漏检 %d", adjMiss) + } +} + +func indexOfSub(s, sub string) int { + return strings.Index(s, sub) +} + +// countWordsBetween 数两个位置之间的字符数(近似「中间隔了几个词」)。 +func countWordsBetween(s string) int { + n := 0 + for _, r := range s { + if r != ' ' { + n++ + } + } + return n +} + +var _ = memory.BlockText + +// ═══════════════════════════════════════════════════════════ +// 量化:词法方案在真库金标准上的真实水平 +// ═══════════════════════════════════════════════════════════ + +// TestLexBaseline_量化 +// +// ★ 这个数字是本实验的核心产出 —— 不是"能不能替代 LLM", +// 而是「它的天花板在哪、缺口是什么形态」。 +// +// 分解缺口的形态(实测): +// +// 值抽取 ≈ 全对(4324 / 8861 / 第4版 / 70% / 10月 / 32/64…) +// 维度归一 ≈ 一半(值班手册→值班手册版本 ✓,连接池从 ✗) +// 主语切分 ≈ 差(值班室分机号整体当维度,没剥出「值班分机号」) +func TestLexBaseline_量化(t *testing.T) { + type stat struct{ hit, total, valueOK int } + var fieldStat, valueStat stat + perCase := make([]struct { + name string + hit, total int + }, 0, len(goldCases)) + + for _, c := range goldCases { + fields := lexPair(lexExtract(c.record)) + got := map[string]string{} + for _, f := range fields { + got[f.Dimension] = f.Value + } + hit := 0 + for _, w := range c.wantFields { + fieldStat.total++ + if v, ok := got[w.name]; ok && v == w.value { + hit++ + fieldStat.hit++ + } + // 值是否至少存在于某个配对里(不看维度名) + valueStat.total++ + for _, f := range fields { + if f.Value == w.value { + valueStat.hit++ + break + } + } + } + if len(c.wantFields) > 0 { + perCase = append(perCase, struct { + name string + hit, total int + }{c.name, hit, len(c.wantFields)}) + } + } + t.Logf("=== 词法方案在真库金标准上的水平 ===") + t.Logf("字段级(维度+值都对): %d/%d = %.0f%%", + fieldStat.hit, fieldStat.total, + float64(fieldStat.hit)/float64(fieldStat.total)*100) + t.Logf("值级(值对就行): %d/%d = %.0f%%", + valueStat.hit, valueStat.total, + float64(valueStat.hit)/float64(valueStat.total)*100) + t.Logf("") + for _, p := range perCase { + t.Logf(" %-24s %d/%d", p.name, p.hit, p.total) + } + t.Logf("") + t.Logf("★ 对照 LLM 方案:金标准 8 条全通过(62eba81/45f6720)") + t.Logf("★ 结论待定:缺口是否可用向量相似度补上(下一个实验)") +} + +// TestLexGap_缺口形态 +// +// 把缺的那些字段列出来,供向量配对实验对照。 +func TestLexGap_缺口形态(t *testing.T) { + for _, c := range goldCases { + fields := lexPair(lexExtract(c.record)) + got := map[string]string{} + for _, f := range fields { + got[f.Dimension] = f.Value + } + for _, w := range c.wantFields { + status := "✓" + detail := "" + if v, ok := got[w.name]; ok { + if v != w.value { + status = "△" + detail = "(维度对但值不同)" + } + } else { + // 值有没有被抽出来? + for _, f := range fields { + if f.Value == w.value { + status = "✗维度" + detail = " 值已抽出但维度叫「" + f.Dimension + "」" + break + } + } + if status == "✗" { + status = "✗全缺" + } + } + if status != "✓" { + t.Logf(" %s %-16s 期望「%s=%s」%s", status, c.name, + w.name, w.value, detail) + } + } + } +} diff --git a/internal/memory/distill/normalize_test.go b/internal/memory/distill/normalize_test.go new file mode 100644 index 00000000..ad21e118 --- /dev/null +++ b/internal/memory/distill/normalize_test.go @@ -0,0 +1,80 @@ +package distill + +import "testing" + +// ★ 泛词不能跨词边界吞掉更具体的维度名。 +// +// 真库实测的反例:NormalizeDimension("连接池容量") 曾返回 "容量预警" —— +// 「容量」是 2 字符泛词,命中了「连接池**容量**」的后半截,把两个不同的 +// 维度静默合并了。维度名是建边的依据,合并后边就建错。 +// +// 判据:宁可留一个未归一的维度名(漂移发现机制还能报出来), +// 也不要把两个真维度错并成一个。 +func TestNormalizeDimension_不跨词误伤(t *testing.T) { + // 不该被泛词吞掉(保持原名) + for _, in := range []string{ + "连接池容量", + "连接池容量上限", + "队列容量", + } { + if got := NormalizeDimension(in); got != in { + t.Errorf("NormalizeDimension(%q) = %q,应保持原名(不该被泛词吞掉)", in, got) + } + } +} + +// 该归一的仍要归一 —— 防「过度保护」把所有包含匹配都关掉。 +func TestNormalizeDimension_仍要归一(t *testing.T) { + cases := []struct{ in, want string }{ + {"停机时长(分钟)", "停机时长"}, // 带后缀,仍应归一 + {"值班手册第8版", "值班手册版本"}, + {"值班手册", "值班手册版本"}, + {"排期10月", "排期"}, + {"告警规则数", "告警规则数"}, + {"灰度比例", "灰度比例"}, + {"回滚版本", "回滚版本"}, + {"发布时间", "发布窗口"}, + {"容量预警", "容量预警"}, + } + for _, c := range cases { + if got := NormalizeDimension(c.in); got != c.want { + t.Errorf("NormalizeDimension(%q) = %q,期望 %q", c.in, got, c.want) + } + } +} + +// ★ 跨词误伤保护的边界:**按别名长度**分档,不是一刀切。 +// +// 上一版「保护不能过宽」的判据写成了「位置 >0 但该归一仍要归一」—— +// 但 isCrossWordTailMatch 在位置 >0 时必然要求「前缀不在词表」, +// 而前缀在词表的又已提前 return false。所以那个组合**不可达**, +// 测试无论实现怎么写都绿(两轮假绿)。 +// +// 可达的边界是长度分档:≤3 字符的泛词容易误伤(连接池|容量), +// >3 字符的别名更可能是完整维度名(值班手册版本、停机时长…)。 +// 判据:把长别名也当误伤时,真实的长维度归一会失效。 +func TestNormalizeDimension_长别名不判误伤(t *testing.T) { + // 前缀是独立词 + 长别名 ⇒ 应归一(保护只拦短泛词) + in := "系统值班手册版本" + if got := NormalizeDimension(in); got != "值班手册版本" { + t.Errorf("NormalizeDimension(%q) = %q,期望 值班手册版本(长别名不该判误伤)", + in, got) + } + in2 := "服务停机时长" + if got := NormalizeDimension(in2); got != "停机时长" { + t.Errorf("NormalizeDimension(%q) = %q,期望 停机时长(长别名不该判误伤)", + in2, got) + } +} + +// 位置 0 的精确匹配永远不是误伤(最长匹配优先仍然生效)。 +func TestNormalizeDimension_最长匹配优先(t *testing.T) { + // 「停机」与「停机时长」同 canonical,但「排期」vs「排期月」这类 + // 一对多映射下必须命中最长的那个。 + if got := NormalizeDimension("停机时长"); got != "停机时长" { + t.Errorf("停机时长 应归一到自身,实际 %q", got) + } + if got := NormalizeDimension("停机"); got != "停机时长" { + t.Errorf("停机 应归一到 停机时长,实际 %q", got) + } +} diff --git a/internal/memory/distill/split.go b/internal/memory/distill/split.go new file mode 100644 index 00000000..518ac6e9 --- /dev/null +++ b/internal/memory/distill/split.go @@ -0,0 +1,396 @@ +// Package distill implements triple extraction from raw conversation records +// using a small generation model. +// +// 设计依据(2026-10-02 实测,60 条 order-gw 真实运维记录) +// +// 为什么不用现有的 nlp.Extractor:实测 extractKeyTriples 产出 0 条 +// (defaultParser 为 nil,POS 模板抽不出),库里 188 个实体全部由模型主动调 +// memory_commit 写入整句复合值。后果是跨维度检索全失效 —— +// 「第181批的值班手册是第几版」这类问题基线 0/5。 +// +// 为什么必须用 JSON Schema 约束(这是小模型能用的唯一机制): +// +// format: schema 对象 6/6 记录零幻觉,30 对字段 +// format: "json" 字符串 只输出单个对象就 stop(0 对) +// 零样本文本提示 聊天腔「根据您提供的信息…」(0 对) +// few-shot 续写 结构不匹配时复读示例答案(100% 幻觉) +// +// 三道不可省的闸门: +// 1. 值必须原样出现在原文(唯一的幻觉闸门 —— 生成侧没有向量可验证) +// 2. 维度名归一(不归一就建不了边:实测「停机」/「停机时间」并存) +// 3. 主语锚定(拆开后主语与值变成独立实体,答案会丢 —— 规则拆分实测 0/20) +package distill + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" +) + +// fieldsSchema 约束解码目标。实测这是 1.7b 零幻觉的唯一形态。 +const fieldsSchema = `{ + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"} + }, + "required": ["name", "value"] + } + } + }, + "required": ["fields"] +}` + +// promptTemplate 零样本 + **三示例**(每个示例代表一种记录形态)。 +// +// ★ 为什么是三个而不是一个 —— 实测(真实 qwen3:1.7b + 6 条真库记录) +// +// 提示词形态 分机号新值 分机号旧值 复合句 批次型 纯叙述 +// 单示例(批次型) 0 0 0 4 ✓ 0 ✓ +// 单示例(属性型) 3 ✓ 2 ✓ 0 0 0 ✓ +// 无示例 0 2 0 0 0 ✓ +// ★ 三示例 4 ✓ 2 ✓ 3 ✓ 4 ✓ 0 ✓ +// +// 单示例不管换成哪种形态都会**压掉另一半**:模型把示例的输出形态当成范本, +// 不像示例的句子直接交白卷 `{"fields":[]}`。三示例把三种形态都摆出来, +// 才不会锚定到某一类。这是 1.7b 的能力边界,不是提示词没调好 —— +// 无示例那一行同样说明「去掉示例」并不等于「去掉锚定」。 +// +// 另外两条实测约束(都是踩出来的): +// +// 1. **不能用 之外的裸文本**:无标签时实测 0 字段。 +// 2. **不能写「批次号是主语,不要单独列成字段」这类"负向指令"**: +// 实测加上这条规则后,同一条记录从 4 个字段变成 0 个 —— 模型把 +// 「值班手册第4版」也当成批次号相关而一并丢弃。1.7b 处理不了 +// 「不要做 X」这种否定式约束,改成只给正例(示例里就没有批次号字段)。 +// 主语锚定由 Split() 在 Go 侧做,不靠提示词约束模型。 +const promptTemplate = `把下面的记录拆成字段,输出 JSON。 + +规则: +1. fields 每项是 {"name": 字段名, "value": 数值} +2. value 必须从记录里**原样照抄**,不许改写、换算、补全 +3. 同一个含义在不同记录里必须用**完全一样**的字段名 +4. 没有可拆的字段就输出 {"fields":[]} + +记录:第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月 +输出:{"fields":[{"name":"告警规则数","value":"9条"},{"name":"值班手册版本","value":"第4版"},{"name":"容量预警","value":"70%"},{"name":"排期","value":"10月"}]} + +记录:值班室分机号 4324,值班 老周 +输出:{"fields":[{"name":"值班分机号","value":"4324"},{"name":"值班人","value":"老周"}]} + +记录:admin服务端口8861·billing服务端口8499·oauth服务端口8271 +输出:{"fields":[{"name":"端口","value":"8861"},{"name":"端口","value":"8499"},{"name":"端口","value":"8271"}]} + +记录:连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500 +输出:{"fields":[{"name":"连接池容量","value":"32/64"},{"name":"连接池容量","value":"128/256"},{"name":"等待队列告警阈值","value":"300~500"}]} + +__RECORD__ +输出:` + +type Field struct { + Name string + Value string +} + +// Extractor 把一条记录拆成 (主语, 维度, 值) 三元组。 +type Extractor struct { + gen generation.Provider + // subjectFrom 由记录文本推导主语(通常是批次号)。返回空则不产出三元组, + // 只把字段当作无主语的属性。 + subjectFrom func(record string) string + // subjectsFrom 是多主语推导钩子。nil 时用内置规则 DeriveSubjects。 + subjectsFrom func(record string) []Subject +} + +// deriveSubjects 解析本条记录的主语列表。 +// +// 优先级:注入的 subjectsFrom → 内置规则 DeriveSubjects → 老的 subjectFrom。 +// 保留 subjectFrom 是为了兼容既有调用方(NewExtractor 的第二个参数)。 +func (e *Extractor) deriveSubjects(record string) []Subject { + if e.subjectsFrom != nil { + return e.subjectsFrom(record) + } + if subs := DeriveSubjects(record); len(subs) > 0 { + return subs + } + if e.subjectFrom != nil { + if s := e.subjectFrom(record); s != "" { + return []Subject{{Name: s}} + } + } + return nil +} + +// SetSubjectsFrom 注入自定义的多主语推导(供测试与特殊场景)。 +func (e *Extractor) SetSubjectsFrom(f func(record string) []Subject) { + e.subjectsFrom = f +} + +// NewExtractor 构造一个拆分器。subjectFrom 为 nil 时默认按「批次号」推导。 +func NewExtractor(gen generation.Provider, subjectFrom func(string) string) *Extractor { + if subjectFrom == nil { + subjectFrom = defaultSubject + } + return &Extractor{gen: gen, subjectFrom: subjectFrom} +} + +// defaultSubject 从记录里取批次号作主语(如「第183批」)。 +// +// 为什么必须有主语:规则拆分实测 0/20 —— 拆开后「第183批」和 +// 「值班手册第4版」是两个互不相关的实体,图中无边可循,答案就丢了。 +// 主语是把这个绑定显式建成边的锚。 +func defaultSubject(record string) string { + // 取最靠前的「第N批」或「第N/M批」形态。 + // + // 按 rune 迭代:中文是 3 字节,按 byte 比较会因溢出得到错误的结论。 + runes := []rune(record) + for i := 0; i < len(runes); i++ { + if runes[i] != '第' { + continue + } + j := i + 1 + for j < len(runes) && (isDigit(runes[j]) || runes[j] == '~' || + runes[j] == '/' || runes[j] == '、') { + j++ + } + if j < len(runes) && runes[j] == '批' { + return string(runes[i : j+1]) + } + } + return "" +} + +func isDigit(r rune) bool { return r >= '0' && r <= '9' } + +// Split 把一条记录拆成三元组。 +// +// 返回的 triples 已过三道闸门:值原样校验、维度名归一、字段名长度限制。 +// 全部字段被闸门拦下时返回空切片而非错误 —— 「这条记录没有可拆的字段」 +// 是正常结果(例如「第129~143批均仅评审通过」这类纯叙述句), +// 与「模型没跑起来」是两回事,调用方必须能区分。 +func (e *Extractor) Split(ctx context.Context, record string) ([]memory.Triple, error) { + if strings.TrimSpace(record) == "" { + return nil, nil + } + if e.gen == nil { + return nil, fmt.Errorf("distill: no generation provider") + } + if !e.gen.Info().SupportsJSONSchema { + // 静默忽略 schema 会让调用方拿到自由文本去 parse JSON —— + // 那种失败查不出是哪一层的错。必须显式报。 + return nil, fmt.Errorf("distill: provider %q cannot honor json schema: %w", + e.gen.Info().Model, generation.ErrSchemaUnsupported) + } + + resp, err := e.gen.Generate(ctx, generation.Request{ + Prompt: strings.Replace(promptTemplate, "__RECORD__", record, 1), + MaxTokens: 256, + Temperature: 0, + JSONSchema: fieldsSchema, + }) + if err != nil { + return nil, fmt.Errorf("distill: generate: %w", err) + } + + fields, err := parseFields(resp.Text) + if err != nil { + // ★ 错误里必须带上原始响应。 + // + // 实测故障(生产蒸馏 60 条 → 0 字段,837 秒):判据只显示 + // 「零字段 60 条」,看不出是数据没得拆还是模型返回了垃圾。 + // 而根因是 ollama schema 污染 —— 模型返回 + // {"fields":[{"name":"text","value":"commit f91b27a…"}]} + // 这种「字段名=字段类型、值=整句」的垃圾,被三道闸门全拦掉。 + // + // ★ **判据能显示 0,是因为它只看得到 0。** + // 观测面不够 ⇒ 只能看到失败,看不到原因。 + raw := resp.Text + if len(raw) > 400 { + raw = raw[:400] + "…" + } + if resp.Truncated { + // 截断的 JSON 不可救,也不该救 —— 重试比丢弃更划算(模型是随机的)。 + return nil, fmt.Errorf( + "distill: output truncated at max tokens(原始输出 %d 字): %w\n 原始: %s", + len(resp.Text), err, raw) + } + return nil, fmt.Errorf( + "distill: parse fields(原始输出 %d 字): %w\n 原始: %s", + len(resp.Text), err, raw) + } + + subjects := e.deriveSubjects(record) + var triples []memory.Triple + for _, f := range fields { + name := NormalizeDimension(f.Name) + if name == "" { + continue + } + // 闸门 1:值必须原样来自原文。 + if f.Value == "" || !strings.Contains(record, f.Value) { + continue + } + // 闸门 3:字段名上限。超长的多半是把整句抄了进来当字段名。 + if len([]rune(name)) > 12 { + continue + } + // 无主语时写不出 subject,直接跳过 —— 宁可少记也不写悬空属性。 + if len(subjects) == 0 { + continue + } + // 字段归属到哪个主语?一句多主语时(如三个服务各自的端口), + // 按值在原句里的位置落段:值落在哪个主语的锚点之后,就归那个主语。 + // 落不进任何区间时归最后一个 —— 宁可归属存疑,也不要丢字段。 + owner := subjects[len(subjects)-1] + if valPos := indexOfRunes([]rune(record), []rune(f.Value)); valPos >= 0 { + for _, sub := range subjects { + if valPos >= sub.Anchor { + owner = sub + } + } + } + // 自环过滤:主语已在三元组的位置,字段名等于主语则这条没有新信息。 + // + // 提示词里写「批次号是主语,不要单独列成字段」会让 1.7b 把**所有** + // 字段都当成批次号相关而丢弃(实测 4 字段 → 0 字段),所以那条 + // 负向指令已从提示词移除;代价就是它会把批次号也拆成字段。 + // 过滤放在 Go 侧,不依赖模型听懂否定指令。 + if name == owner.Name || f.Value == owner.Name { + continue + } + triples = append(triples, memory.Triple{ + Subject: owner.Name, + Relation: name, + Object: f.Value, + // 记原句,便于日后从图谱回到原文。 + SentenceText: record, + }) + } + return triples, nil +} + +func parseFields(text string) ([]Field, error) { + var envelope struct { + Fields []Field `json:"fields"` + } + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + return nil, fmt.Errorf("invalid json: %w", err) + } + return envelope.Fields, nil +} + +// dimensionAliases 把实测出现的同义异名归一到受控词表。 +// +// 不归一的后果是建不了边:实测同一含义出现「停机」/「停机时间」两种命名, +// 跨记录查询时两种都要查,且边连不上。 +// +// 加词时的判据:这个说法在真实语料里出现过,且确实是同一含义。 +// 归一失败保持原名(不丢弃)—— 丢弃会丢信息。 +var dimensionAliases = map[string]string{ + "停机时间": "停机时长", + "停机时长": "停机时长", + "停机": "停机时长", + "回滚版本": "回滚版本", + "回滚": "回滚版本", + "灰度比例": "灰度比例", + "灰度": "灰度比例", + "发布窗口": "发布窗口", + "发布时间": "发布窗口", + "时间": "发布窗口", + "值班手册": "值班手册版本", + "值班手册版本": "值班手册版本", + "值班手册版": "值班手册版本", + "告警规则": "告警规则数", + "告警规则数": "告警规则数", + "容量预警": "容量预警", + "容量": "容量预警", + "排期": "排期", + "批次号": "批次号", + "批号": "批次号", + "端口": "端口", + "验证状态": "验证状态", + "评审状态": "评审状态", + "版本数": "版本数", + "日期": "日期", +} + +// NormalizeDimension 把字段名归一到受控词表。未命中时返回裁剪后的原名。 +func NormalizeDimension(name string) string { + name = strings.TrimSpace(name) + name = strings.Trim(name, "*·•-—::,, ") + if name == "" { + return "" + } + if canonical, ok := dimensionAliases[name]; ok { + return canonical + } + // 包含式匹配:模型有时输出「停机时长(分钟)」这类带后缀的写法。 + // + // 挑**最长**匹配别名而不是最短:「停机时长」比「停机」长但更具体, + // 先命中它才不会退化成更泛的「停机时长」(虽然此处二者同 canonical, + // 但「排期」vs「排期月」这类一对多映射下,最长匹配才是对的规则)。 + // + // ★ 但「最长匹配」本身有个反例,必须加防误伤: + // + // NormalizeDimension("连接池容量") → "容量预警" ← 错,两个不同维度 + // + // 原因:「容量」是 2 字符泛词,命中了「连接池**容量**」的后半截。 + // 泛词跨词边界吞掉更具体的名字,会把两个真维度静默合并 —— 而维度名 + // 是建边的依据,合并后边就建错了。 + // + // 判据:泛别名若只是**尾部子串**(name 的前缀是别的词),且前缀本身 + // 不在词表里,就认为是误伤,保持原名不归一。宁可留一个未归一的维度名 + // (还能被漂移发现机制报出来),也不要把两个维度错并成一个。 + bestAlias := "" + for alias := range dimensionAliases { + if len(alias) <= len(bestAlias) { + continue + } + if !strings.Contains(name, alias) { + continue + } + if isCrossWordTailMatch(name, alias) { + continue + } + bestAlias = alias + } + if bestAlias != "" { + return dimensionAliases[bestAlias] + } + return name +} + +// isCrossWordTailMatch 判断这次包含匹配是否是「跨词边界吞掉泛词」。 +// +// 命中位置 > 0 且前缀不含任何词表条目 ⇒ 前缀是另一个独立词, +// 于是整个 name 是「<独立词><泛词>」结构,属于被误伤,不该归一。 +// +// 例: +// +// "连接池容量" + "容量" → 前缀「连接池」不在维度词表 → 误伤 +// "停机时长(分钟)" + "停机时长" → 位置 0 → 不是尾部误伤 → 正常归一 +// "值班手册第8版" + "值班手册" → 位置 0 → 正常归一 +func isCrossWordTailMatch(name, alias string) bool { + i := strings.Index(name, alias) + if i <= 0 { + return false + } + prefix := name[:i] + // 前缀若命中任何词表条目,说明 name 整体属于那个更长的词,不算误伤。 + for other := range dimensionAliases { + if other != alias && strings.Contains(prefix, other) { + return false + } + } + // 泛词(≤3 字符)才容易误伤;更长的别名更可能是完整的维度名 + return len([]rune(alias)) <= 3 +} diff --git a/internal/memory/distill/split_test.go b/internal/memory/distill/split_test.go new file mode 100644 index 00000000..89d564fc --- /dev/null +++ b/internal/memory/distill/split_test.go @@ -0,0 +1,264 @@ +package distill + +import ( + "context" + "errors" + "strings" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" +) + +// fakeGen 返回固定文本的假 provider,用于把「模型行为」与「本包逻辑」分开测。 +type fakeGen struct { + text string + truncated bool + schema string + err error + info generation.Info + // sawPrompt/sawSchema 记录请求,用于断言提示词与 schema 真的传出去了。 + sawPrompt string + sawSchema string + sawTemp float64 +} + +func (f *fakeGen) Generate(_ context.Context, r generation.Request) (generation.Response, error) { + f.sawPrompt = r.Prompt + f.sawSchema = r.JSONSchema + f.sawTemp = r.Temperature + if f.err != nil { + return generation.Response{}, f.err + } + return generation.Response{Text: f.text, Truncated: f.truncated}, nil +} + +func (f *fakeGen) Info() generation.Info { + if f.info.Model == "" { + return generation.Info{Model: "fake", SupportsJSONSchema: true} + } + return f.info +} + +func (f *fakeGen) Close() {} + +const rec = "第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月" + +// ★ 正常路径:字段 → 三元组,主语锚定,值原样。 +func TestSplit_字段转三元组(t *testing.T) { + g := &fakeGen{text: `{"fields":[ + {"name":"告警规则数","value":"9条"}, + {"name":"值班手册版本","value":"第4版"}, + {"name":"容量预警","value":"70%"}]}`} + e := NewExtractor(g, nil) + + tr, err := e.Split(context.Background(), rec) + if err != nil { + t.Fatalf("Split: %v", err) + } + if len(tr) != 3 { + t.Fatalf("期望 3 条三元组,实际 %d:%+v", len(tr), tr) + } + for _, x := range tr { + if x.Subject != "第183批" { + t.Errorf("主语应为 第183批,实际 %q", x.Subject) + } + if !strings.Contains(rec, x.Object) { + t.Errorf("值 %q 不在原文里 —— 闸门失效", x.Object) + } + if x.SentenceText != rec { + t.Errorf("应保留原句以便回到原文") + } + } + // 提示词与 schema 真的传出去了(这是小模型能用的唯一机制) + if !strings.Contains(g.sawSchema, `"fields"`) { + t.Errorf("JSONSchema 未传出,字段约束丢失:%q", g.sawSchema) + } + if !strings.Contains(g.sawPrompt, rec) { + t.Errorf("记录未进入提示词") + } + if g.sawTemp != 0 { + t.Errorf("温度应为 0(拆分要可复现),实际 %v", g.sawTemp) + } +} + +// ★ 闸门 1:值不在原文 → 丢弃。这是唯一的幻觉防线。 +func TestSplit_值不在原文则丢弃(t *testing.T) { + g := &fakeGen{text: `{"fields":[ + {"name":"告警规则数","value":"9条"}, + {"name":"容量预警","value":"999%"}, + {"name":"排期","value":"12月"}]}`} + tr, err := NewExtractor(g, nil).Split(context.Background(), rec) + if err != nil { + t.Fatalf("Split: %v", err) + } + if len(tr) != 1 || tr[0].Object != "9条" { + t.Fatalf("只应保留原样值 9条,实际 %+v", tr) + } +} + +// ★ 闸门 2:维度名归一。不归一就建不了边。 +func TestSplit_维度名归一(t *testing.T) { + g := &fakeGen{text: `{"fields":[ + {"name":"停机","value":"7分"}, + {"name":"停机时间","value":"9分"}, + {"name":"停机时长","value":"4分"}]}`} + tr, err := NewExtractor(g, nil).Split(context.Background(), "第200批 停机7分") + if err != nil { + t.Fatalf("Split: %v", err) + } + rels := map[string]bool{} + for _, x := range tr { + rels[x.Relation] = true + } + if !rels["停机时长"] { + t.Fatalf("三种同义写法应归一为「停机时长」,实际 %v", rels) + } +} + +// ★ 闸门 3:无主语 → 不写脏三元组。 +func TestSplit_无主语不产出(t *testing.T) { + g := &fakeGen{text: `{"fields":[{"name":"容量预警","value":"70%"}]}`} + tr, err := NewExtractor(g, nil).Split(context.Background(), "就那么回事") + if err != nil { + t.Fatalf("Split: %v", err) + } + if len(tr) != 0 { + t.Fatalf("无批次号作主语时不应产出三元组(宁可少记不写脏),实际 %+v", tr) + } +} + +// 「没有可拆字段」是正常结果,不是错误 —— 调用方要能区分它与「模型没跑」。 +func TestSplit_空字段不是错误(t *testing.T) { + g := &fakeGen{text: `{"fields":[]}`} + tr, err := NewExtractor(g, nil).Split(context.Background(), "第129~143批均仅评审通过") + if err != nil { + t.Fatalf("空字段不应报错:%v", err) + } + if len(tr) != 0 { + t.Fatalf("期望 0 条,实际 %d", len(tr)) + } +} + +// provider 不支持 schema 时必须显式失败,不能静默降级成自由文本。 +func TestSplit_不支持schema显式失败(t *testing.T) { + g := &fakeGen{ + text: `{"fields":[]}`, + info: generation.Info{Model: "no-schema", SupportsJSONSchema: false}, + } + _, err := NewExtractor(g, nil).Split(context.Background(), rec) + if err == nil || !errors.Is(err, generation.ErrSchemaUnsupported) { + t.Fatalf("应报 ErrSchemaUnsupported,实际 %v", err) + } +} + +// 模型调用失败 → 报错(调用方据此回退 jieba 路径或重试)。 +func TestSplit_模型失败透传错误(t *testing.T) { + g := &fakeGen{err: errors.New("connection refused")} + _, err := NewExtractor(g, nil).Split(context.Background(), rec) + if err == nil || !strings.Contains(err.Error(), "connection refused") { + t.Fatalf("应透传模型错误,实际 %v", err) + } +} + +// 非法 JSON → 报错;截断要标出来(调用方可选择重试而非丢弃)。 +func TestSplit_非法JSON与截断(t *testing.T) { + g := &fakeGen{text: `{"fields":[{"name":"a"`} + _, err := NewExtractor(g, nil).Split(context.Background(), rec) + if err == nil { + t.Fatal("非法 JSON 应报错") + } + + g2 := &fakeGen{text: `{"fields":[{"name":"a","value":"1"}`, truncated: true} + _, err2 := NewExtractor(g2, nil).Split(context.Background(), rec) + if err2 == nil || !strings.Contains(err2.Error(), "truncated") { + t.Fatalf("截断应标明(便于调用方重试),实际 %v", err2) + } +} + +// 字段名超长(模型把整句当字段名)→ 丢弃。 +func TestSplit_字段名超长丢弃(t *testing.T) { + long := "第183批周三凌晨1点·停机4分·回滚v2.29.5·灰度10%观察48分后放50%" + g := &fakeGen{text: `{"fields":[{"name":"` + long + `","value":"4分"}]}`} + tr, err := NewExtractor(g, nil).Split(context.Background(), rec) + if err != nil { + t.Fatalf("Split: %v", err) + } + if len(tr) != 0 { + t.Fatalf("超长字段名应丢弃,实际 %+v", tr) + } +} + +// defaultSubject 覆盖多种批次形态。 +func TestDefaultSubject(t *testing.T) { + cases := map[string]string{ + "第183批 告警规则9条": "第183批", + "第128/130/131批同为33日": "第128/130/131批", + "第129~143批共十五个版本": "第129~143批", + "没有批次号的纯句子": "", + "": "", + } + for in, want := range cases { + if got := defaultSubject(in); got != want { + t.Errorf("defaultSubject(%q)=%q,期望 %q", in, got, want) + } + } +} + +// 归一表里每个别名都要真的归一到规范名(防止有人加词时写错方向)。 +func TestNormalizeDimension_别名表方向正确(t *testing.T) { + for alias, canonical := range dimensionAliases { + if got := NormalizeDimension(alias); got != canonical { + t.Errorf("NormalizeDimension(%q)=%q,期望 %q", alias, got, canonical) + } + // 规范名自身也必须稳定(幂等) + if got := NormalizeDimension(canonical); got != canonical { + t.Errorf("规范名 %q 不幂等:NormalizeDimension=%q", canonical, got) + } + } +} + +// ★ 归一的**包含式路径**:模型输出带后缀的字段名(「停机时长(分钟)」)。 +// 上一版测试只覆盖精确匹配,去掉精确分支后仍能通过 —— 包含式分支当时被 +// `_ = canonical` 死代码掩盖着。这条测试专门盯它。 +func TestNormalizeDimension_包含式最长匹配(t *testing.T) { + cases := map[string]string{ + "停机时长(分钟)": "停机时长", + "值班手册第4版": "值班手册版本", + "回滚版本v2.29.5": "回滚版本", + "未知维度XYZ": "未知维度XYZ", // 不命中 → 原样返回,不丢弃 + } + for in, want := range cases { + if got := NormalizeDimension(in); got != want { + t.Errorf("NormalizeDimension(%q)=%q,期望 %q", in, got, want) + } + } +} + +// ★ 自环过滤:字段名或值等于主语时丢弃。 +// +// 起因(实测):提示词里写「批次号是主语,不要单独列成字段」这条**负向指令** +// 会让 qwen3:1.7b 把所有字段都当成批次号相关而丢弃(4 字段 → 0 字段)。 +// 去掉该指令后它改为主动拆出「批次号」字段,于是产生 +// (第183批) -[批次号]-> 第183批 这种自环。过滤必须放在 Go 侧, +// 不能指望模型听懂否定指令。 +func TestSplit_过滤主语自环(t *testing.T) { + g := &fakeGen{text: `{"fields":[ + {"name":"批次号","value":"第183批"}, + {"name":"批号","value":"第183批"}, + {"name":"容量预警","value":"70%"}]}`} + tr, err := NewExtractor(g, nil).Split(context.Background(), rec) + if err != nil { + t.Fatalf("Split: %v", err) + } + for _, x := range tr { + if x.Subject == x.Object { + t.Errorf("自环未过滤: %+v", x) + } + if x.Relation == "第183批" || x.Relation == "批次号" || x.Relation == "批号" { + t.Errorf("主语被当成字段名: %+v", x) + } + } + if len(tr) != 1 || tr[0].Object != "70%" { + t.Fatalf("只应保留非自环字段,实际 %+v", tr) + } +} diff --git a/internal/memory/distill/subject.go b/internal/memory/distill/subject.go new file mode 100644 index 00000000..66ec446b --- /dev/null +++ b/internal/memory/distill/subject.go @@ -0,0 +1,332 @@ +package distill + +import ( + "strings" +) + +// 主语推导(属性型 + 一句多主语)。 +// +// ★ 为什么必须改,而不能只调提示词 +// -------------------------------- +// Split 里有这么一段: +// +// if subject == "" { +// continue // 无主语直接跳过 +// } +// +// 实测基线(真实 qwen3:1.7b + 6 条真库记录,见 goldcases_test.go): +// **1/5 通过**。四条失败全是 `subject == ""`: +// +// 主语="" 属性型-分机号新值 "值班室分机号 4324,值班 老周(…)" +// 主语="" 属性型-分机号旧值 "值班室分机号 4379,值班人 阿李" +// 主语="" 属性型-改述句 "下周起值班室分机号改为 4324,…" +// 主语="" 多服务复合句 "admin服务端口8861·billing服务端口8499·…" +// +// 唯一通过的「叙述型-批次多字段」有「第183批」这个显式主语。 +// **结论:LLM 的拆分能力和 schema 约束都是好的,坏的是主语只认「第N批」。** +// +// 而且这不是提示词能解决的:1.7b 处理不了「从这句话里找出主语」这种 +// 隐含约束(同 promptTemplate 注释里记的「不要做 X」实测把 4 字段变 0 字段)。 +// 主语必须在 Go 侧用规则推。 + +// Subject 是从记录里推导出的一个主语。 +type Subject struct { + // Name 是主语名(如「值班室分机号」「admin服务」)。 + Name string + // Anchor 是主语在原句里的锚点位置(rune 下标),用于把「字段值」 + // 归属到正确的主语。一句多主语时靠它区分。 + Anchor int + // Span 是主语覆盖的 rune 区间 [Start, End),值应当落在这个区间之后。 + // 多主语复合句(admin/billing/oauth)靠它切分。 + Start, End int +} + +// 维度词:属性名里表示「这个句子在说什么」的词。这些词后面通常跟值。 +// +// 为什么要这份词表:属性型记录的形态是「<属性名> <值>,<属性名2> <值2>」, +// 属性名本身没有显式标记,只能靠词表识别。词表里的词都是实测语料里 +// 出现过的属性名成分,不是凭空造的。 +var dimensionHeads = []string{ + "分机号", "端口", "值班人", "值班", "告警规则", "容量预警", "排期", + "值班手册", "版本", "状态", "时间", "时长", "比例", "窗口", + "地址", "账号", "密码", "联系人", "负责人", "数量", "阈值", +} + +// 主体词:复合句里「谁拥有这个属性」的部分。 +// +// 用于切分 "admin服务端口8861·billing服务端口8499·oauth服务端口8271" —— +// 三个主体各带一个端口,必须拆成三组三元组,不能混成一条。 +var subjectHeads = []string{ + "服务", "节点", "实例", "集群", "系统", "平台", "网关", "队列", + "订单", "用户", "支付", "结算", "网关", +} + +// KnownSubjectAliases 把实测出现过的属性名归一到受控词表。 +// +// 与 dimensionAliases 同一套思路:不归一就建不了边。但这里归一的是 +// **主语**(如「值班室分机号」vs「值班分机号」),主语不一致会让同一件事 +// 在图里裂成两个互不相关的节点。 +var knownSubjectAliases = map[string]string{ + "值班室分机号": "值班室分机号", + "值班分机号": "值班室分机号", + "分机号": "值班室分机号", + "值班室电话": "值班室分机号", + "值班电话": "值班室分机号", + // 真库实测的句子型记录(**第四种形态**): + // + // 「连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500」 + // + // 句首是「连接池」而不是属性名,但它确实是这段话的主语。不加它 + // DeriveSubjects 推不出主语 → 字段全被闸门拦掉(零三元组)。 + // + // ★ 这条是跑真库才发现的:金标准原先 6 条全都落在三种示例形态内 + // (批次型/属性型/复合型),判据太贴合示例,于是「第四种形态就交白卷」 + // 这个缺陷完全看不见。真库 6/6 零字段逼出了它。 + "连接池": "连接池", + "等待队列": "等待队列", + "队列": "等待队列", +} + +// DeriveSubjects 从一条记录推导主语列表。 +// +// 返回空切片表示「推不出主语」—— 调用方据此不产出三元组(宁可少记, +// 不写没有主语的悬空属性)。 +// +// 三条推导规则,按优先级: +// 1. 显式「第N批」→ 单一主语(叙述型,原有行为) +// 2. 属性名开头(「<属性名> <值>」)→ 该属性名作主语(属性型,缺的主路径) +// 3. 复合分隔符(·、,)里每段各自带主体 → 多主语(一句多组三元组) +func DeriveSubjects(record string) []Subject { + runes := []rune(record) + + // 规则 1:显式批次号 + if s := defaultSubject(record); s != "" { + idx := indexOfRunes(runes, []rune(s)) + if idx < 0 { + idx = 0 + } + return []Subject{{Name: s, Anchor: idx, Start: 0, End: idx + len([]rune(s))}} + } + + // 规则 2 与规则 3 的先后**实测无影响**(变异自证:调换顺序后 6 条 + // 金标准用例的主语推导结果完全一致)—— 因为两者互斥: + // deriveMultiSubject 要求「≥2 段含主体词」,而属性型记录 + // 「值班室分机号 4324,值班 老周」里没有任何主体词,它本来就推不出主语。 + // + // (早先这里写的是「顺序反了会把属性型句拆成 值班/旧号 两个假主语」—— + // 那是 indexOfRunes 未找到返回 0 的 bug 造成的假象:中文主体词在 ASCII + // 串上全部误命中位置 0。bug 修好后该现象不复存在,注释同步改正。) + // + // 规则 2:句首是**受控别名**(“值班室分机号…”)→ 整句一个主语。 + // 必须是受控别名而不是启发式猜出来的:启发式会把 + // “admin服务端口8861” 的句首也当成属性名(它在第一个数字处切, + // 切出 “admin服务端口” 含“端口”),于是复合句永远轮不到规则 3。 + if s := deriveControlledSubject(runes); s != "" { + return []Subject{{Name: s, Anchor: 0, Start: 0, End: len([]rune(s))}} + } + + // 规则 3:复合句里每段自带主体(“admin服务端口8861·billing服务端口8499…”) + if subs := deriveMultiSubject(runes); len(subs) > 0 { + return subs + } + + // 规则 4:句首不是受控名但形如“<名词><值>”时,用启发式试一次。 + if s := deriveAttributeSubject(runes); s != "" { + return []Subject{{Name: s, Anchor: 0, Start: 0, End: len([]rune(s))}} + } + return nil +} + +// deriveControlledSubject 只认 knownSubjectAliases 里的受控名(句首前缀)。 +// +// 与 deriveAttributeSubject 的区别是**宁可推不出**:启发式会把任何 +// “句子开头到第一个数字之间”都当属性名,包括 “admin服务端口8861” 这种 +// 复合句的**首段**。而首段是复合句的一部分,不该独占主语。 +func deriveControlledSubject(runes []rune) string { + // 按别名长度降序试,保证命中最长的那个(“值班室分机号” 优先于 “分机号”)。 + best := "" + for alias := range knownSubjectAliases { + if len([]rune(alias)) <= len(best) { + continue + } + if len(alias) > len(runes) { + continue + } + if indexOfRunes(runes, []rune(alias)) == 0 { + best = alias + } + } + if best == "" { + return "" + } + return knownSubjectAliases[best] +} + +// deriveMultiSubject 处理 "admin服务端口8861·billing服务端口8499·oauth服务端口8271"。 +// +// 切分依据是「主体词 + 属性词」的重复模式:每段开头是主体(「admin服务」 +// 「billing服务」),后面跟属性词(「端口」)和值。 +// +// ★ 主体边界不能包含属性词:取「最靠前的属性词」之前的内容会把属性词也 +// 吃进主语,得到 "admin服务端口" 而不是 "admin服务" —— 实测踩过。 +// 正确做法是找**主体词**的位置,主体 = 段首到主体词末尾;找不到主体词时 +// 才退回属性词位置(段首本身就是主体名的情况)。 +// +// 为什么必须走这条路而不是当整体一条:三个服务的端口混在一句里,向量会 +// 把三个数字平均掉 —— 实测查询「admin 服务的端口」能命中(因为整句都含 +// admin),但一旦拆成三元组,admin→8861 就是一条精确的边。 +func deriveMultiSubject(runes []rune) []Subject { + // 按复合分隔符切段 + seps := map[rune]bool{'·': true, '、': true, ';': true, ';': true} + var segs [][]rune + cur := []rune{} + for _, r := range runes { + if seps[r] { + segs = append(segs, cur) + cur = []rune{} + continue + } + cur = append(cur, r) + } + segs = append(segs, cur) + if len(segs) < 2 { + return nil + } + + // 至少两段同时含「主体词」才认定是一句多主语 —— 否则只是普通分句。 + hits := 0 + for _, seg := range segs { + if containsAnyRune(seg, subjectHeads) { + hits++ + } + } + if hits < 2 { + return nil + } + + var subs []Subject + offset := 0 + for _, seg := range segs { + // 主体 = 段首到主体词末尾;找不到主体词才退回属性词位置。 + subject := "" + attrIdx := indexOfAnyRune(seg, dimensionHeads) + if i := indexOfAnyRune(seg, subjectHeads); i >= 0 { + subject = string(seg[:i+len([]rune(matchedHead(seg, subjectHeads)))]) + } else if attrIdx > 0 { + subject = string(seg[:attrIdx]) + } + subject = strings.Trim(subject, ",,  ·") + if subject == "" || attrIdx < 0 { + offset += len(seg) + 1 + continue + } + subs = append(subs, Subject{ + Name: subject, + Anchor: offset + attrIdx, + Start: offset, + End: offset + len(seg), + }) + offset += len(seg) + 1 + } + return subs +} + +// matchedHead 返回 seg 中最先命中的那个主体词(用于确定主体右边界)。 +func matchedHead(seg []rune, heads []string) string { + bestIdx, bestWord := -1, "" + for _, h := range heads { + if i := indexOfRunes(seg, []rune(h)); i >= 0 && (bestIdx < 0 || i < bestIdx) { + bestIdx, bestWord = i, h + } + } + return bestWord +} + +// deriveAttributeSubject 处理 "值班室分机号 4324,值班 老周(…)"。 +// +// 形态:句首是属性名(命中 dimensionHeads 或 knownSubjectAliases), +// 属性名本身就是主语 —— 「分机号是多少」这类查询要能命中它。 +func deriveAttributeSubject(runes []rune) string { + // 从句首找最长的属性词 + for i := 0; i < len(runes) && i < 12; i++ { + // 逐步加长,匹配最长的属性名 + for j := len(runes); j > i; j-- { + cand := string(runes[i:j]) + if canonical, ok := knownSubjectAliases[cand]; ok { + return canonical + } + } + } + // 属性名不是词表里的受控名时,用「句首连续名词 + 后接值」的启发式: + // 取句首到第一个数字/逗号之间的部分。 + cut := len(runes) + for i, r := range runes { + if i > 0 && (isDigit(r) || r == ',' || r == ',' || r == ' ') { + cut = i + break + } + } + if cut == 0 || cut > 12 { + return "" + } + cand := strings.TrimSpace(string(runes[:cut])) + if canonical, ok := knownSubjectAliases[cand]; ok { + return canonical + } + // 至少含一个已知属性词成分才算数,否则是「均仅评审通过」这类纯叙述 + if !containsAnyRune([]rune(cand), dimensionHeads) { + return "" + } + if canonical, ok := knownSubjectAliases[cand]; ok { + return canonical + } + return cand +} + +// indexOfRunes 返回 needle 在 haystack 中的起始下标,未找到返回 -1。 +// +// ★ 契约:未找到必须是 -1,不能是 0。 +// 初版写的是「n == 0 || n > len(haystack) 时 return 0」—— 把「needle 比 +// haystack 长」当成了「命中在位置 0」。后果是主体词表里的中文词 +// (“节点”/“队列”/“网关”…)在 ASCII 串 "admin服务端口8861" 上全部 +// 报「命中于 index 0」,主语被截成 "ad"/"bi"/"oa"。 +// 判据要求“未找到”和“找到第一处”是两件可区分的事,否则所有 >= 0 的 +// 判断全部退化成真。 +func indexOfRunes(haystack, needle []rune) int { + n := len(needle) + if n == 0 || n > len(haystack) { + return -1 + } + for i := 0; i+n <= len(haystack); i++ { + match := true + for j := 0; j < n; j++ { + if haystack[i+j] != needle[j] { + match = false + break + } + } + if match { + return i + } + } + return -1 +} + +func containsAnyRune(haystack []rune, needles []string) bool { + for _, n := range needles { + if indexOfRunes(haystack, []rune(n)) >= 0 { + return true + } + } + return false +} + +func indexOfAnyRune(haystack []rune, needles []string) int { + best := -1 + for _, n := range needles { + if i := indexOfRunes(haystack, []rune(n)); i >= 0 && (best < 0 || i < best) { + best = i + } + } + return best +} diff --git a/internal/memory/distill/subject_test.go b/internal/memory/distill/subject_test.go new file mode 100644 index 00000000..bc049f4f --- /dev/null +++ b/internal/memory/distill/subject_test.go @@ -0,0 +1,95 @@ +package distill + +import ( + "strings" + "testing" +) + +// ★ 主语推导的判据(全部来自 goldcases_test.go 的真库记录)。 +// +// 这层**不调 LLM** —— 主语推导是纯规则,判据可以精确到「推导出了什么」, +// 而不是「最后有没有字段」。LLM 那层的模糊性留给 gold_test.go。 +func TestDeriveSubjects(t *testing.T) { + cases := []struct { + record string + want []string + why string + }{ + {"值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)", + []string{"值班室分机号"}, "属性型:句首是受控别名"}, + {"值班室分机号 4379,值班人 阿李", + []string{"值班室分机号"}, "属性型旧值,同一主语不同值"}, + {"下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周", + []string{"值班室分机号"}, "改述形态仍应归到同一主语"}, + {"admin服务端口8861·billing服务端口8499·oauth服务端口8271", + []string{"admin服务", "billing服务", "oauth服务"}, + "★ 一句多主语:三个服务各成一主语,主体词边界不含属性词"}, + {"第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月", + []string{"第183批"}, "叙述型:显式批次号优先"}, + {"第129~143批均仅评审通过", + []string{"第129~143批"}, "范围批次号"}, + {"均仅评审通过,无具体字段", nil, + "纯叙述句推不出主语 —— 宁可少记也不写悬空属性"}, + } + + for _, c := range cases { + got := DeriveSubjects(c.record) + var names []string + for _, s := range got { + names = append(names, s.Name) + } + if !equalStrs(names, c.want) { + t.Errorf("主语推导错(%s)\n 记录: %q\n 期望: %v\n 实得: %v", + c.why, c.record, c.want, names) + } + } +} + +// 别名归一:同一属性不得裂成两个主语。 +func TestDeriveSubjects_别名归一(t *testing.T) { + for _, rec := range []string{ + "值班分机号 4324", + "分机号 4324", + "值班室电话 4324", + } { + got := DeriveSubjects(rec) + if len(got) != 1 { + t.Errorf("%q 应推出 1 个主语,实际 %d", rec, len(got)) + continue + } + if got[0].Name != "值班室分机号" { + t.Errorf("%q 主语应归一到「值班室分机号」,实际 %q", rec, got[0].Name) + } + } +} + +// indexOfRunes 的契约:未找到必须是 -1。 +// +// ★ 这条是被真实 bug 逼出来的:初版对「needle 比 haystack 长」返回 0, +// 于是词表里的中文词("节点"/"队列"/"网关")在 ASCII 串 "admin服务端口8861" +// 上全部报「命中于位置 0」,主语被截成 "ad"/"bi"/"oa"。 +func TestIndexOfRunes_未找到返回负一(t *testing.T) { + hay := []rune("admin服务端口8861") + for _, needle := range []string{"节点", "队列", "网关", "订单", "不存在的词"} { + if got := indexOfRunes(hay, []rune(needle)); got >= 0 { + t.Errorf("indexOfRunes(%q, %q) = %d,应为 -1(未找到)", string(hay), needle, got) + } + } + if got := indexOfRunes(hay, []rune("服务")); got != 5 { + t.Errorf("indexOfRunes 应命中位置 5,实际 %d", got) + } +} + +func equalStrs(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +var _ = strings.TrimSpace diff --git a/internal/memory/edge_entity.go b/internal/memory/edge_entity.go new file mode 100644 index 00000000..f9562990 --- /dev/null +++ b/internal/memory/edge_entity.go @@ -0,0 +1,618 @@ +package memory + +import ( + "database/sql" + "fmt" + "log" + "strings" +) + +// ★★★ 关系边:独立单位 +// +// ============================================================ +// 为什么边必须是独立实体,而不是「两点 + 一个类型字符串」 +// ============================================================ +// 原 schema 有: +// +// UNIQUE(source_kind, source_id, target_kind, target_id, edge_type) +// +// ⇒ 同一对节点间只能存**一条**同类型边。后果实测: +// +// 写 A --属于--> B (session=1, conf=0.9) +// 写 A --属于--> B (session=2, conf=0.5) +// ⇒ 第二条被 INSERT OR IGNORE 静默丢弃,session/confidence 全丢 +// +// 而这正是「报告边数 980、实际写入 959」的根因(dd2c996)—— +// 当时我把它当成「重复数据」报出来就完事了, +// **真正的问题是边表从设计上就存不下多条同类边**。 +// +// ============================================================ +// 边作为独立单位的三条要求 +// ============================================================ +// ① 同类边可并存 —— 不同 session / turn / confidence 是不同的事实 +// ② 边承载属性 —— confidence / session_id / status 挂在边上而非关系上 +// ③ 边有自己的身份 —— 可以被引用(merged_into 指向它)而不只是被遍历 +// +// 对应旧 relations 表的列:confidence / status / session_id / turn_id / +// date_bucket。date_bucket 是索引优化而非语义,先不入。 + +// 边状态。 +const ( + // EdgeActive 是有效边。软删除/仲裁靠它判断旧值是否作废 + // (与旧 relations.status='active' 语义一致)。 + EdgeActive = "active" + // EdgeDeleted 是被取代/被删除的边。**保留而非物理删除** —— + // 「旧号 4379 停用」本身是有信息量的(它解释了为什么现在打不通), + // 与 arbitration.go 的 Superseded 同一套语义。 + EdgeDeleted = "deleted" + // EdgeMerged 是源节点被合并后,其边重定向到目标块; + // 源块记 merged_into 指向目标块,历史边留在源块上。 + EdgeMerged = "merged" +) + +// RelationEdgeData 是关系边的属性集。 +type RelationEdgeData struct { + // Confidence 是置信度(0~1)。旧 relations 表同名列。 + Confidence float64 + // SessionID / TurnID 记录边的来源会话与轮次。 + // Recall 的 sessionFilter 依赖它。 + SessionID string + TurnID int + // Status 是 EdgeActive / EdgeDeleted / EdgeMerged。 + Status string + // MergedInto 在 EdgeMerged 时指向目标块 ID。 + MergedInto string +} + +// AddRelationBlockEdge 写一条**带属性的关系边**。 +// +// ★ 与 AddMemoryBlockEdge 的区别(后者保留给结构边) +// +// AddMemoryBlockEdge 结构边(contains / 迁移关系) +// —— 不并存多条,无属性,用 UNIQUE 保证幂等 +// AddRelationBlockEdge 关系边(主语--关系-->宾语) +// —— ★ 可并存多条、承载属性 +// +// 两者共用一张表(memory_block_edges),靠属性列是否为空区分, +// 但**唯一性约束不同**:结构边受 UNIQUE 保护,关系边不受。 +func (g *GraphDB) AddRelationBlockEdge(sourceID, targetID, edgeType string, + data RelationEdgeData) error { + + g.mu.Lock() + defer g.mu.Unlock() + + if sourceID == "" || targetID == "" || edgeType == "" { + return fmt.Errorf("edge: endpoints and type required") + } + // 端点必须存在 —— 沿用 AddMemoryBlockEdge 的校验, + // 避免建出指向虚无节点的边。 + for _, id := range []string{sourceID, targetID} { + var n int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, id).Scan(&n); err != nil { + return err + } + if n == 0 { + return fmt.Errorf("edge: block %s does not exist", id) + } + } + + status := data.Status + if status == "" { + status = EdgeActive + } + + // ★ 不带 OR IGNORE / 不受 UNIQUE 约束 —— + // 这正是「边是独立单位」的落点:同一对节点可并存多条同类边。 + _, err := g.db.Exec(`INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type, + confidence, session_id, turn_id, status, merged_into) + VALUES ('block', ?, 'block', ?, ?, ?, ?, ?, ?, ?)`, + sourceID, targetID, edgeType, data.Confidence, + nullIfEmpty(data.SessionID), data.TurnID, status, + nullIfEmpty(data.MergedInto)) + return err +} + +func nullIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +// RelationEdgesBetween 取两个块之间的全部关系边(**不合并同类**)。 +// +// 这是「边是独立单位」的直接体现:返回切片长度可以 > 1。 +func (g *GraphDB) RelationEdgesBetween(sourceID, targetID string) ([]MemoryBlockEdge, error) { + g.mu.RLock() + defer g.mu.RUnlock() + return g.relationEdgesTx(sourceID, targetID) +} + +func (g *GraphDB) relationEdgesTx(sourceID, targetID string) ([]MemoryBlockEdge, error) { + rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id, + edge_type, confidence, session_id, turn_id, status, merged_into + FROM memory_block_edges + WHERE source_kind='block' AND source_id=? AND target_kind='block' AND target_id=? + ORDER BY confidence DESC, id ASC`, sourceID, targetID) + if err != nil { + return nil, err + } + return scanRelationEdges(rows) +} + +// AllRelationEdges 返回全部关系边(供遍历/合并用)。 +func (g *GraphDB) AllRelationEdges() ([]MemoryBlockEdge, error) { + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT id, source_kind, source_id, target_kind, target_id, + edge_type, confidence, session_id, turn_id, status, merged_into + FROM memory_block_edges ORDER BY id`) + if err != nil { + return nil, err + } + return scanRelationEdges(rows) +} + +func scanRelationEdges(rows *sql.Rows) ([]MemoryBlockEdge, error) { + defer func() { _ = rows.Close() }() + var out []MemoryBlockEdge + for rows.Next() { + var e MemoryBlockEdge + var sess, merged sql.NullString + var conf sql.NullFloat64 + var status sql.NullString + var turn sql.NullInt64 + if err := rows.Scan(&e.ID, &e.SourceKind, &e.SourceID, &e.TargetKind, &e.TargetID, + &e.Type, &conf, &sess, &turn, &status, &merged); err != nil { + return nil, err + } + e.Confidence = conf.Float64 + e.SessionID = sess.String + e.Status = status.String + e.MergedInto = merged.String + if turn.Valid { + e.TurnID = int(turn.Int64) + } + out = append(out, e) + } + return out, rows.Err() +} + +// ensureRelationEdgeUniqueness 处理**既有表**上的 UNIQUE 约束。 +// +// ★ 为什么必须重建表 +// ------------------ +// SQLite 的 UNIQUE 约束是**表定义的一部分**,没有 ALTER TABLE ... DROP CONSTRAINT。 +// 而旧库(生产 98 块 / 590 边、测试库 448 块)建表时都带了这个约束, +// 它让「边是独立单位」在既有库上无法生效 —— 同一对节点写第二条同类边会被丢。 +// +// 做法:读出既有边 → 建新表(无 UNIQUE)→ 搬回去 → 删旧表 → 改名。 +// 全程在一个事务里;任何一步失败整体回滚。 +// +// ★ 为什么不能在 detect 阶段默默跳过 +// +// 跳过会让「既有库不支持并存同类边、新库支持」成为事实, +// 而这种不一致极难排查(本地能测、生产静默丢边)。 +// ⇒ 这里宁可重建表也要保证语义一致,并打日志说明发生了什么。 +func ensureRelationEdgeUniqueness(tx *sql.Tx) { + // 检测既有表是否带 UNIQUE + rows, err := tx.Query(`SELECT sql FROM sqlite_master + WHERE type='table' AND name='memory_block_edges'`) + if err != nil { + return + } + var ddl string + if rows.Next() { + _ = rows.Scan(&ddl) + } + _ = rows.Close() + if ddl == "" || !strings.Contains(ddl, "UNIQUE") { + return // 新表或已升格 + } + + log.Printf("[graph] 边表升格:重建 memory_block_edges(移除 UNIQUE 以支持同类边并存)") + if _, err := tx.Exec(`CREATE TABLE memory_block_edges_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + edge_type TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + confidence REAL DEFAULT 0, + session_id TEXT DEFAULT '', + turn_id INTEGER DEFAULT 0, + status TEXT DEFAULT '', + merged_into TEXT DEFAULT '' + )`); err != nil { + log.Printf("[graph] 边表升格失败(保留原约束): %v", err) + return + } + // 搬数据(只搬旧表已有的 6 列,新列留默认值) + if _, err := tx.Exec(`INSERT INTO memory_block_edges_new + (id, source_kind, source_id, target_kind, target_id, edge_type, created_at) + SELECT id, source_kind, source_id, target_kind, target_id, edge_type, created_at + FROM memory_block_edges`); err != nil { + log.Printf("[graph] 边表数据迁移失败,回滚: %v", err) + if _, derr := tx.Exec(`DROP TABLE memory_block_edges_new`); derr != nil { + log.Printf("[graph] 边表升格:回滚临时表失败: %v", derr) + } + return + } + if _, err := tx.Exec(`DROP TABLE memory_block_edges`); err != nil { + log.Printf("[graph] 边表删除失败,回滚: %v", err) + if _, derr := tx.Exec(`DROP TABLE memory_block_edges_new`); derr != nil { + log.Printf("[graph] 边表升格:回滚临时表失败: %v", derr) + } + return + } + if _, err := tx.Exec(`ALTER TABLE memory_block_edges_new RENAME TO memory_block_edges`); err != nil { + log.Printf("[graph] 边表改名失败: %v", err) + return + } + // 索引要重建(DROP TABLE 会带走) + for _, idx := range []string{ + `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source + ON memory_block_edges(source_kind, source_id)`, + `CREATE INDEX IF NOT EXISTS idx_memory_block_edges_target + ON memory_block_edges(target_kind, target_id)`, + } { + _, _ = tx.Exec(idx) + } + log.Printf("[graph] 边表升格完成") +} + +// BFSBlocks 从 startID 出发,沿**关系边**展开 depth 层,返回全部可达节点。 +// +// ★★★ 这就是「联想」的实现(docs/zh/recall-as-association.md) +// +// 人听到「值班室分机号是多少」时:想起主语 → 想起值 → 想起旧值 → 想起人。 +// 节点是召回对象,N 层 BFS 是把「自动附带的上下文」取回来。 +// +// ★ 为什么必须是「节点 + BFS」而不是「只召回主语块」 +// +// 宾语块 "4324" 孤立召回时不知道自己来自哪里。 +// 若只召回主语块 ⇒ 要么丢它(漏掉「用户直接问值」的场景), +// 要么加「来源标注」特判(联想本来就不需要标注)。 +// +// ★ 双向遍历 +// +// 人联想到「老周值班」是从「老周 --值班分机--> 4324」**反向**走来的。 +// 所以正反向都要展开,否则网会退化成有向森林。 +// +// ★ depth=0 只返回自身(不是空) +// +// 命中节点本身就是要返回的内容之一。 +func (g *GraphDB) BFSBlocks(startID string, depth int) ([]MemoryBlock, error) { + if depth < 0 { + depth = 0 + } + if startID == "" { + return nil, fmt.Errorf("bfs: empty start id") + } + + g.mu.RLock() + defer g.mu.RUnlock() + + // 起点必须存在 + var exists int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, startID).Scan(&exists); err != nil { + return nil, err + } + if exists == 0 { + return nil, fmt.Errorf("bfs: start block %s does not exist", startID) + } + + // visited 同时充当去重集合:网会织成"部分连通", + // 不去重会在环上无限展开。 + visited := map[string]bool{startID: true} + frontier := []string{startID} + // ★ ordered 记录 BFS 的**发现顺序**:起点在前,邻居按层、按到达顺序。 + // 它是「离查询多近」这个语义的载体 —— 丢了顺序, + // 「最近的上下文在前」就没了(同图两次 BFS 结果不同)。 + ordered := []string{startID} + + for d := 0; d < depth && len(frontier) > 0; d++ { + var next []string + for _, cur := range frontier { + // ★ 正向 + 反向:前驱(谁指向我)与后继(我指向谁)都是邻居。 + // + // ★★ 这里**不能**加 COALESCE(session_id,'')='' 的过滤 —— + // 那个条件是给结构边去重用的(结构边 session_id 恒空), + // 而关系边的 session_id 恰恰**非空**(它是边的属性)。 + // 加上它会把所有关系边滤掉 ⇒ BFS 一个邻居都走不到。 + // 实测:边写对了(session=s1 status=active)但 BFS 只返回起点。 + rows, err := g.db.Query(`SELECT target_id FROM memory_block_edges + WHERE source_kind='block' AND source_id=? + UNION + SELECT source_id FROM memory_block_edges + WHERE target_kind='block' AND target_id=?`, + cur, cur) + if err != nil { + return nil, err + } + for rows.Next() { + var nb string + if err := rows.Scan(&nb); err != nil { + rows.Close() + return nil, err + } + if nb != "" && !visited[nb] { + visited[nb] = true + // ★ 记录**发现顺序** —— BFS 的语义全在这里。 + // 去重用 map,出序用这个 slice,两者不能混。 + ordered = append(ordered, nb) + next = append(next, nb) + } + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + } + frontier = next + } + + // ★ 取块内容必须按**发现顺序**,不能用 map 遍历。 + // + // visited 是 map(判定去重要 O(1)),而它的迭代顺序是随机的 —— + // 于是同一个图连续两次 BFS 得到不同顺序的结果, + // 「离查询多近」这个语义就没了,测试与用户输出都会飘。 + // + // 正确做法:用入队时累积的 ordered(见上),终点直接传它。 + // + // 兜底:若 ordered 为空(depth=0 时不展开),退回 map 遍历 —— + // 此时只有一个节点,顺序无所谓。 + if len(ordered) == 0 { + for id := range visited { + ordered = append(ordered, id) + } + } + return g.blocksByIDsLocked(ordered) +} + +func (g *GraphDB) blocksByIDsLocked(ids []string) ([]MemoryBlock, error) { + if len(ids) == 0 { + return nil, nil + } + placeholders := make([]string, len(ids)) + args := make([]any, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + rows, err := g.db.Query(`SELECT `+blockColumns+` + FROM memory_blocks WHERE id IN (`+strings.Join(placeholders, ",")+`)`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []MemoryBlock + for rows.Next() { + b, err := scanBlockRow(rows) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + +// BlockNeighbour 是一条关系边连同它的对端块 —— 「谁和谁有什么关系」的完整读法。 +// +// ★ 为什么不复用 RelationEdgeData:那个类型只是**属性载荷** +// +// (confidence/session/turn/status/merged_into),刻意不含 ID 与端点, +// 因为 AddRelationBlockEdge 的入参就是「端点已知、只传属性」。 +// +// 读的方向相反:要端点、要 ID。所以另立类型而不是硬塞进去。 +type BlockNeighbour struct { + // EdgeID 是 memory_block_edges.id。场景引用 kind='edge' 指向它。 + EdgeID int64 + // SourceID / TargetID 是两端块 ID。 + SourceID string + TargetID string + // EdgeType 是关系名(「偏好」「使用」…)。 + EdgeType string + // Peer 是**对端**块:SourceID == 查询块时为 Target,反之为 Source。 + // 查询块自己是起点,不需要重复带出。 + Peer MemoryBlock + // IsOutgoing 标明方向:true 表示 Peer 是这条关系的宾语。 + // social 层区分「A 认识 B」与「B 认识 A」时必须看它。 + IsOutgoing bool + + RelationEdgeData +} + +// ═══════════════════════════════════════════════════════════════ +// MergeBlocks —— 块合并(2026-10-04) +// +// 这是旧表退场的最后一环:MergeEntities 只改 entities/relations, +// 块侧完全不动 ⇒ 「张先生」改名后,块还叫「张先生」, +// 召回照样命中它(判据 TestMergeEntities 就是这么红的)。 +// +// ★★ 与旧实现的本质差异 +// +// 旧(entities/relations) +// 改 relations.source_id / target_id 即可 —— 实体 ID 不变, +// 改名(UPDATE entities.name)就完成了合并的「改名」语义。 +// +// 新(blocks/edges) +// 块 ID 是**内容派生**的:blk_ent_。 +// 「张先生」与「张三」是两个不同的块,合并意味着**源块消失**, +// 它的边改指向目标块。 +// +// ⇒ 所以本函数不能只重定向端点,还要: +// ① 删源块(不留 @merged_ 残留,与旧实现同款) +// ② 处理结构边(contains)—— 否则原句块指向已删的块,端点悬空 +// ③ 同步 scene_refs —— 否则场景里挂一条永远召不回的幽灵 +// +// ★ 边的合并语义:**重定向 + 去自环**,不合并属性。 +// 「李四喜欢咖啡」+「李四讨厌咖啡」合并到「王五」后都变成 +// 「王五—喜欢/讨厌→咖啡」—— 这是**两条不同的关系**(edge_type 不同), +// 都保留;而「李四喜欢咖啡」+「王五喜欢咖啡」合并后同端同类型, +// 那才是重复,必须收敛(否则「王五喜欢咖啡」出现两次)。 +// ═══════════════════════════════════════════════════════════════ + +// MergeBlocks 把 sourceText 块的边合并进 targetText 块,源块随之消失。 +// +// 返回**重定向的边数**(不含被去重丢弃的重复边)。 +// 幂等:源块不存在时返回 (0, nil)。 +func (g *GraphDB) MergeBlocks(sourceText, targetText string) (int, error) { + sourceText = strings.TrimSpace(sourceText) + targetText = strings.TrimSpace(targetText) + if sourceText == "" || targetText == "" { + return 0, fmt.Errorf("merge requires both source and target") + } + if sourceText == targetText { + return 0, fmt.Errorf("merge source and target are identical: %q", sourceText) + } + + g.mu.Lock() + defer g.mu.Unlock() + + src, err := g.blockByTextTxLocked(sourceText) + if err != nil { + return 0, err + } + if src == nil { + // ★ 必须报错,不能返回 (0, nil)。 + // + // 我一开始写的是「幂等:源块已合并过」—— 那是对的**理由** + // 配上了错的**行为**:源块不存在有两种截然不同的原因: + // + // a) 已经合并过了(重试,正常路径) + // b) 名字拼错了 / 从没写过(调用方的 bug) + // + // 返回 (0, nil) 把两者都变成「静默成功」—— + // 而这正是记忆系统里最贵的一类 bug:调用方以为合并了, + // 库里其实什么都没变。 + // + // 旧 MergeEntities 在这里返回 error,是对的。 + // 幂等由**调用方**判断(合并后不要再合并同一个源), + // 不该由存储层替它猜。 + return 0, fmt.Errorf("merge source block %q does not exist", sourceText) + } + dst, err := g.blockByTextTxLocked(targetText) + if err != nil { + return 0, err + } + if dst == nil { + // ★ 目标不存在必须报错。 + // 返回 0 会让调用方以为合并成功了,而实际上什么都没做 —— + // 那正是「静默失效」的一种。 + return 0, fmt.Errorf("merge target block %q does not exist", targetText) + } + if src.ID == dst.ID { + return 0, nil + } + + tx, err := g.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + + // ── ① 重定向关系边:source → target ────────────────────── + // + // ★ 只动**关系边**,不动结构边(contains)。 + // contains 的端点是「原句块」与「关系边」或「块」, + // 把它改成 target 会让原句指向一个语义不同的块。 + res, err := tx.Exec( + `UPDATE memory_block_edges SET source_id = ? + WHERE source_kind = 'block' AND source_id = ? + AND COALESCE(session_id,'') != ''`, + dst.ID, src.ID) + if err != nil { + return 0, fmt.Errorf("redirect source edges: %w", err) + } + nOut, _ := res.RowsAffected() + + res, err = tx.Exec( + `UPDATE memory_block_edges SET target_id = ? + WHERE target_kind = 'block' AND target_id = ? + AND COALESCE(session_id,'') != ''`, + dst.ID, src.ID) + if err != nil { + return 0, fmt.Errorf("redirect target edges: %w", err) + } + nIn, _ := res.RowsAffected() + + // ── ② 去自环 ───────────────────────────────────────────── + // + // 重定向后 source→X 与 target→X 都成了 target→X。 + // 同端**同类型**的边是重复(重复陈述同一件事); + // 同端**不同类型**的边不是(喜欢 vs 讨厌 是两回事)。 + // + // ★ 判定必须含 edge_type:只按 (source,target) 去重会把 + // 「王五喜欢咖啡」与「王五讨厌咖啡」误删一条。 + // + // 保留 id 最小的那条,其余标记 deleted(不硬删): + // scene_refs 可能还指向它们。 + if _, err := tx.Exec( + `UPDATE memory_block_edges SET status = 'deleted' + WHERE id NOT IN ( + SELECT MIN(id) FROM memory_block_edges + WHERE source_kind='block' AND target_kind='block' + AND (source_id = ? OR target_id = ?) + AND COALESCE(session_id,'') != '' + GROUP BY source_id, target_id, edge_type + ) + AND source_kind='block' AND target_kind='block' + AND (source_id = ? OR target_id = ?) + AND COALESCE(session_id,'') != '' + AND COALESCE(status,'') != 'deleted'`, + dst.ID, dst.ID, dst.ID, dst.ID); err != nil { + return 0, fmt.Errorf("dedupe self-loops: %w", err) + } + + // ── ③ 清理指向源块的结构边 ──────────────────────────────── + // + // contains 的端点可能是块(媒体挂载、迁移关系)。 + // 源块删了之后这些边端点悬空 —— 而悬空端点会让 + // graphNodeExists 校验失败,后续任何引用它的写入都被拒。 + if _, err := tx.Exec( + `DELETE FROM memory_block_edges + WHERE (source_kind='block' AND source_id=? AND COALESCE(session_id,'')='') + OR (target_kind='block' AND target_id=? AND COALESCE(session_id,'')='')`, + src.ID, src.ID); err != nil { + return 0, fmt.Errorf("drop structural edges of source: %w", err) + } + + // ── ④ 删源块 ───────────────────────────────────────────── + if _, err := tx.Exec(`DELETE FROM memory_blocks WHERE id = ?`, src.ID); err != nil { + return 0, fmt.Errorf("delete source block: %w", err) + } + + // ── ⑤ 场景引用同步 ─────────────────────────────────────── + // + // 源块的引用必须换成目标块 —— 而不是删掉: + // 「张先生那次值班」这个场景仍然存在,只是人换了名字。 + if _, err := tx.Exec( + `UPDATE scene_refs SET ref_text = ? + WHERE kind = 'block' AND ref_text = ?`, + dst.ID, src.ID); err != nil { + return 0, fmt.Errorf("repoint scene refs: %w", err) + } + + if err := tx.Commit(); err != nil { + return 0, err + } + return int(nOut + nIn), nil +} + +// blockByTextTxLocked 按文本取最早的块(调用方已持锁)。 +func (g *GraphDB) blockByTextTxLocked(text string) (*MemoryBlock, error) { + blocks, err := g.blocksByTextTx(text) + if err != nil { + return nil, err + } + if len(blocks) == 0 { + return nil, nil + } + return &blocks[0], nil +} diff --git a/internal/memory/edge_entity_test.go b/internal/memory/edge_entity_test.go new file mode 100644 index 00000000..6959672f --- /dev/null +++ b/internal/memory/edge_entity_test.go @@ -0,0 +1,118 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ★★★ 边作为独立单位的契约判据 +// +// 现状(graph.go:216 的 schema): +// +// UNIQUE(source_kind, source_id, target_kind, target_id, edge_type) +// +// ⇒ 同一对节点之间只能有**一条**同类型边 ⇒ 边被降级成「两点的一个类型标签」, +// 而不是一个可独立存在、可承载属性、可并存多条的对象。 +// +// 而「块作节点、边作独立单位、织成实体网」要求: +// +// ① 同一对节点间可并存多条同类边(不同 session / confidence / turn) +// ② 边自己承载属性(confidence / session_id / status …) +// ③ 边的身份是独立的,不是 (源,目标,类型) 三元组的派生 +func TestEdge_同类边可并存(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + a := MemoryBlock{ID: "blk_a", Modality: BlockText, Text: "甲"} + b := MemoryBlock{ID: "blk_b", Modality: BlockText, Text: "乙"} + if err := g.PutMemoryBlocks([]MemoryBlock{a, b}); err != nil { + t.Fatal(err) + } + + // 同一对节点、三条同类型边,但来源会话不同 + type edgeSpec struct { + sessionID string + turnID int + conf float64 + } + specs := []edgeSpec{ + {"sess-1", 1, 0.9}, + {"sess-2", 2, 0.5}, + {"sess-3", 3, 0.7}, + } + for _, sp := range specs { + if err := g.AddRelationBlockEdge("blk_a", "blk_b", "属于", RelationEdgeData{ + SessionID: sp.sessionID, TurnID: sp.turnID, Confidence: sp.conf, + }); err != nil { + t.Fatalf("写入关系边失败: %v", err) + } + } + + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + var relEdges []MemoryBlockEdge + for _, e := range edges { + if e.Type == "属于" { + relEdges = append(relEdges, e) + } + } + fmt.Printf(" 同类边写入 %d 条,实际存下 %d 条\n", len(specs), len(relEdges)) + + // ★ 契约:三条都要在 + if len(relEdges) != len(specs) { + t.Errorf("★ 同类边应可并存 %d 条,实际 %d 条 —— UNIQUE 约束把边降级成了标签", + len(specs), len(relEdges)) + } +} + +// ★ 边自己承载属性。 +func TestEdge_承载关系属性(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + for _, b := range []MemoryBlock{ + {ID: "blk_a", Modality: BlockText, Text: "甲"}, + {ID: "blk_b", Modality: BlockText, Text: "乙"}, + } { + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + } + + if err := g.AddRelationBlockEdge("blk_a", "blk_b", "属于", RelationEdgeData{ + SessionID: "sess-x", TurnID: 42, Confidence: 0.87, Status: EdgeActive, + }); err != nil { + t.Fatal(err) + } + + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + found := false + for _, e := range edges { + if e.Type != "属于" { + continue + } + found = true + fmt.Printf(" 边 #%d: session=%s turn=%d conf=%.2f status=%s\n", + e.ID, e.SessionID, e.TurnID, e.Confidence, e.Status) + if e.SessionID != "sess-x" { + t.Errorf("★ 边未承载 session_id,实际 %q", e.SessionID) + } + if e.TurnID != 42 { + t.Errorf("★ 边未承载 turn_id,实际 %d", e.TurnID) + } + if e.Confidence < 0.86 || e.Confidence > 0.88 { + t.Errorf("★ 边未承载 confidence,实际 %.2f", e.Confidence) + } + if e.Status != EdgeActive { + t.Errorf("★ 边未承载 status,实际 %q", e.Status) + } + } + if !found { + t.Fatal("没找到刚写入的关系边") + } +} diff --git a/internal/memory/entityname_test.go b/internal/memory/entityname_test.go new file mode 100644 index 00000000..af1e6673 --- /dev/null +++ b/internal/memory/entityname_test.go @@ -0,0 +1,23 @@ +package memory + +import "testing" + +// 纯数字实体(端口/分机/编号类属性值)必须合法。 +// 背景(2026-10-01 跑分实测):旧实现要求"含字母/汉字"导致纯数字值 +// 被静默拒写——metrics 端口 8328、分机 4379→4324 全部丢进黑洞, +// 且 commit 返回"成功",模型永不重试。 +func TestValidEntityName_AllowsNumericValues(t *testing.T) { + for _, name := range []string{"8328", "4379", "8080", "v2", "COPPER-8177", "值班室的分机号"} { + if !validEntityName(name) { + t.Errorf("应合法: %q", name) + } + } +} + +func TestValidEntityName_RejectsJunk(t *testing.T) { + for _, name := range []string{"", "a", "——!!", "。。。", " "} { + if validEntityName(name) { + t.Errorf("应拒绝: %q", name) + } + } +} diff --git a/internal/memory/fusion.go b/internal/memory/fusion.go new file mode 100644 index 00000000..94fecb31 --- /dev/null +++ b/internal/memory/fusion.go @@ -0,0 +1,724 @@ +// 向量 + 符号融合召回。 +// +// ★ 为什么必须融合而不是 fallback +// ------------------------------- +// 现状(toolcall.go:236)是「块向量在前,符号路兜底」:块一旦有命中 +// 就直接 return,符号路的 RecallSorted **根本没被调用**。 +// 而实测(生产快照 1391 块,chineseclip 512 维)证明向量侧会失手: +// +// 查询「本机 13010 端口对应什么」 +// 向量 top8 → 「13000端口」「本地网关8081」…(都不含 13010) +// 词法命中 → 「13010/13011 而非 12011」「http://127.0.0.1:13010」 +// +// 设计注释里写的「端口号、分机号这类纯数字串向量天然弱」是对的, +// 但**没有真正生效** —— 架构让符号路没有补位机会。 +// +// ★ 三层根因里的第三层 +// ---------------------- +// 1. 各向异性 → 已修(RebuildCentroid 补上生产调用者) +// 2. 精确串被稀释 → 向量模型固有限制,本文件解决它 +// 3. 符号路无补位 → **本文件** +// +// 融合不是「两路结果拼在一起」,而是**给每个候选算一个统一分数**: +// 只有一路命中的候选仍可能被另一路拉上来,而不是排在后面。 +package memory + +import ( + "fmt" + "regexp" + "strings" + "unicode" +) + +// FusedHit 是融合后的候选。 +type FusedHit struct { + BlockID string + Text string + Score float64 // 统一分数 + VectorHit float64 // 向量路分数(0 = 未命中) + SymbolHit float64 // 符号路分数(0 = 未命中) + // ExactHit 表示「查询里的精确数字串/版本串命中了本块」。 + // + // ★ 这是**布尔信号**不是强度信号:它的价值在于「存在与否」。 + // 混进加权和会被低权重稀释(实测:sym=1.0×0.3=0.30 + // 输给 vec=0.62 + sym=0.33 → 0.534),所以单独一路置顶。 + ExactHit bool + Why []string // 可读的归因,给调试与模型解释用 +} + +// FusionWeights 是两路的权重。 +// +// ★ 为什么默认向量主导(0.7/0.3)而不是对半 +// ------------------------------------------ +// 符号路在**长尾中文实体名**上非常强(「本批第181批的值班手册是第几版」 +// 这种问题 jieba+LIKE 就能定位),但它对**同义改写**毫无办法 +// (「脚本路径改到哪了」vs「/data/homeagent」零词法重叠)。 +// 向量恰好相反。 +// +// 取 0.7/0.3 而不是 0.5/0.5:符号路的误报率更高(LIKE 命中泛词 +// 就会带出一堆无关块),而它的强项在 2 层能靠「符号命中即置顶」 +// 的规则体现,不必靠权重放大。 +type FusionWeights struct { + Vector float64 // 默认 0.7 + Symbol float64 // 默认 0.3 +} + +func defaultWeights() FusionWeights { return FusionWeights{Vector: 0.7, Symbol: 0.3} } + +// 融合排序的符号提取规则。 +// +// ★ 不能直接 jieba:memory 里记过「jieba + LIKE 把『服务』『端口』 +// 这类泛词当过滤词,造成大量噪音」。所以符号路只认**高信息量**的词: +// - 数字串(端口、版本号、日期、批次号) +// - 英文标识符(plugindev、grafana、kafka) +// - 中文长词(≥2 字,且不是泛词表里的) +var ( + symbolNumericRe = regexp.MustCompile( + `\d+(?:[./]\d+)*(?:\.\d+)?%?|[vV]\d+(?:\.\d+)+`) + symbolLatinRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9_-]{2,}`) + symbolCJKRunRe = regexp.MustCompile(`[一-鿿]+`) +) + +// cjkWindow 是中文符号的切分窗口。 +// +// ★ 为什么不用词法切分而用滑窗 +// ----------------------------- +// 正确的做法是 jieba 之类的分词,但 memory 里记过它的问题: +// 「jieba + LIKE 把『服务』『端口』这类泛词当过滤词,造成大量噪音」—— +// 它把「监控面板的端口是多少」切成「监控/面板/的/端口/是/多少」, +// 而块文本里的词边界与它不一致,两边对不齐。 +// +// ★ 而贪婪长串匹配更糟(实测) +// ----------------------------- +// +// 「grafana 监控面板的端口是多少」 → [grafana 监控面板的端口是多少] +// 「脚本路径改到哪个目录了」 → [脚本路径改到哪个目录了] +// +// 整句变成**一个**符号,而任何块都不可能包含整句 ⇒ 符号分恒为 0, +// 符号路对**所有纯中文查询完全失效**(只有含数字串的探针能被救)。 +// +// 折中:2~4 字滑窗 + 泛词黑名单。宁可多提候选(靠覆盖率阈值压制 +// 噪声),也不要「一个符号都没有」。 +const ( + cjkWindowMin = 2 + cjkWindowMax = 4 +) + +// symbolStopWords 是符号路的泛词黑名单 —— 命中它们不作为信号。 +var symbolStopWords = map[string]bool{ + "服务": true, "端口": true, "地址": true, "配置": true, "问题": true, + "记忆": true, "时间": true, "地方": true, "东西": true, "什么": true, + "怎么": true, "哪个": true, "是否": true, "可以": true, "需要": true, + "文件": true, "目录": true, "路径": true, "用户": true, "系统": true, +} + +// cjkFunctionWords 是 jieba 分出来的**虚词** —— 它们是词,但不携带语义。 +// +// ★ 与 symbolStopWords 的区别(这是 2026-10-04 才想清楚的) +// ------------------ +// +// symbolStopWords 滑窗的跨词伪词(2~4 字重叠窗口) +// cjkFunctionWords 词典分词认出的虚词 +// +// 「端口」曾在 symbolStopWords 里,那是错的 —— 它是实词。 +// 但「什么」「哪些」是真的虚词,即便 jieba 认得出它们, +// 作为召回信号也没有区分度(库里有「什么」的块很多, +// 而「哪些」的命中几乎覆盖全库)。 +// +// ★ 所以两套标准分开:jieba 分出来的词过这张**虚词**表, +// +// 滑窗产出的候选过 symbolStopWords。 +var cjkFunctionWords = map[string]bool{ + "什么": true, "哪些": true, "哪个": true, "怎么": true, "如何": true, + "是否": true, "可以": true, "需要": true, "应该": true, "必须": true, + "的": true, "了": true, "吗": true, "呢": true, "吧": true, + "和": true, "与": true, "或": true, "及": true, "等": true, + // ★ 语气词(2026-10-04):判据 TestFuse_无符号退化为向量序 用「嗯」 + // 构造"查询提不出符号"的场景,而 jieba 会把它切出来 —— + // 于是那条判据测不到退化路径了(它绿着,但测的不是目标行为)。 + // ★ 这是「修了 A 弄坏 B」的典型:单看每处都对,合起来错。 + "嗯": true, "啊": true, "呀": true, "哦": true, "噢": true, + "欸": true, "喂": true, "哎": true, "唔": true, +} + +// QuerySymbols 提取查询里的高信息量符号。 +func QuerySymbols(query string) []string { + out := make([]string, 0, 8) + seen := map[string]bool{} + add := func(s string) { + if s == "" || seen[s] { + return + } + seen[s] = true + out = append(out, s) + } + for _, m := range symbolNumericRe.FindAllString(query, -1) { + add(m) + } + for _, m := range symbolLatinRe.FindAllString(query, -1) { + add(strings.ToLower(m)) + } + // ★★★ 中文用 jieba 切,滑窗只作降级(2026-10-04) + // + // 为什么不用滑窗(它曾是这个函数的唯一实现): + // + // 查询「本机服务监听哪些端口」 + // 滑窗 → ["本机服务", "监听哪些"] ← 全是跨词伪词 + // jieba → [本机 服务 监听 哪些 端口] ← 「端口」独立成词 + // + // ★ 这是生产探针 coexist 0/1 的**真正根因**: + // 「端口」从未单独成窗 ⇒ 符号路对「14010端口」全打 0 分 + // ⇒ 直接命中全是噪音 ⇒ 图联想也无从谈起 + // (详见 RecallBlocksFusedBFS 的注释:联想救不了召回本身错了) + // + // ★ 为什么两者都产出 + // + // jieba 有词典依赖(词库目录)。GetJieba() 拿不到词库时返回 nil, + // 此时若只靠 jieba,符号会**整个消失** ⇒ 符号路彻底失效。 + // 所以:jieba 成功则用它 + 保留滑窗作为补充; + // 失败则纯滑窗(退化成今天的行为,不会更差)。 + // + // ★ 停用词在这里仍然生效(jieba 分出来的虚词会被 add 过滤): + // 「哪些」「什么」这类词 jieba 也会切出来,不加过滤会被当信号。 + cjkRuns := symbolCJKRunRe.FindAllString(query, -1) + cut := GetJieba() + usedJieba := false + if cut != nil { + for _, run := range cjkRuns { + for _, w := range cut.Cut(run, true) { + w = strings.TrimSpace(w) + if w == "" || cjkFunctionWords[w] { + continue + } + // ★★ 停用词表**只对滑窗生效**,对 jieba 不生效。 + // + // 理由:jieba 是**词典分词**,「端口」「服务」被切成 + // 独立词,是因为它们在语料里确实是词 —— + // 它们携带语义,不是伪词。 + // + // 而滑窗切出来的「服务」可能只是「本机服务监听」里 + // 恰好跨界的 2 字串,那种是伪词,该滤。 + // + // ★ 混用两套标准会出问题:2026-10-04 试过把 + // 「端口/服务」从停用词表移出(数据上它们命中 + // 只占 0.8%,并不泛),但因为**滑窗**同时被改成 + // 全子窗,于是噪音涌入、casual 从 3/4 掉到 2/4。 + // ⇒ 那次回归的根因不是停用词表,是滑窗。 + // + // 现在滑窗保持原样(全子窗没启用),jieba 独立成词 + // 不受停用词限制 —— 两套标准分开,问题不会互相污染。 + // ★ 只收**单个词**,不收 jieba 的组合结果 + // (Cut(hmm=true) 只切不组,理论上不会有组合; + // 这里防御性过滤,避免将来误用 hmm=false 时把整句当符号)。 + add(w) + } + } + usedJieba = len(out) > 0 + } + // ★ 滑窗补充:jieba 缺词时仍能给出重叠窗口候选。 + // 注意滑窗产出**会**包含停用词过滤(cjkWindows 内部已过滤)。 + if !usedJieba { + for _, run := range cjkRuns { + for _, w := range cjkWindows(run) { + add(w) + } + } + } + return out +} + +// cjkWindows 把一段连续中文切成 2~4 字滑窗候选。 +// +// ★ 去重与裁剪 +// ------------ +// 同一段中文会产出大量重叠窗口(「监控面板的」→ 监控/控面板/面板的…), +// 但只有**非重叠前缀**保留,避免符号集被同质候选塞满: +// +// 「监控面板的」→ 监控控/控面板/面板的(步长 2,max-1 = 3) +// +// 泛词在调用侧被 symbolStopWords 过滤。 +func cjkWindows(run string) []string { + r := []rune(run) + if len(r) < cjkWindowMin { + return nil + } + // 去掉句尾的虚词尾巴(「的」「了」「吗」等)—— + // 它们几乎不可能是块内容的一部分 + for len(r) > cjkWindowMin && isTrailingParticle(r[len(r)-1]) { + r = r[:len(r)-1] + } + out := make([]string, 0, 8) + n := len(r) + for i := 0; i < n; i++ { + for l := cjkWindowMax; l >= cjkWindowMin; l-- { + if i+l > n { + continue + } + w := string(r[i : i+l]) + if symbolStopWords[w] { + continue + } + out = append(out, w) + // 只取该位置最长的一个窗口,往后步进,避免重叠候选 + i += l - 1 + break + } + } + return out +} + +func isTrailingParticle(r rune) bool { + switch r { + case '的', '了', '吗', '呢', '啊', '呀', '吧', '是', '在', '个': + return true + } + return false +} + +// SymbolScore 用查询符号在块文本里的出现情况打分(0~1)。 +// +// ★ 精确串命中权重远高于中文词 +// -------------------------------- +// 正是因为「13010」这种精确串是唯一能救回向量失手的信号, +// 它的命中必须给满分,而不是和其它词按个数平均。 +func SymbolScore(symbols []string, text string) (score float64, matched []string, exact bool) { + if len(symbols) == 0 || text == "" { + return 0, nil, false + } + lower := strings.ToLower(text) + exactHit, wordHit := 0, 0 + for _, s := range symbols { + if strings.Contains(lower, strings.ToLower(s)) { + if isNumericSymbol(s) { + exactHit++ + } else { + wordHit++ + } + matched = append(matched, s) + } + } + if exactHit > 0 { + // 有精确数字串命中 ⇒ 满分 + exact 标记。 + // ★ 1.0 在调用方被当作**布尔信号**用(直接置顶), + // 不参与加权和 —— 否则会被 Symbol 权重稀释。 + return 1.0, matched, true + } + // 中文/英文词按覆盖比例给分,不叠加(避免「命中多就满分」)。 + if wordHit > 0 { + r := float64(wordHit) / float64(len(symbols)) + if r > score { + score = r + } + } + return score, matched, false +} + +func isNumericSymbol(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if unicode.IsDigit(r) { + return true + } + } + // v2.31.5 这类版本串:首字母 + 数字 + if (s[0] == 'v' || s[0] == 'V') && len(s) > 1 { + for _, r := range s[1:] { + if unicode.IsDigit(r) { + return true + } + } + } + return false +} + +// fuseCandidates 把向量候选与符号候选合并成一个统一排序。 +// +// ★ 与仲裁的顺序:融合在**前**,仲裁在**后** +// --------------------------------------------- +// 仲裁判断「谁取代了谁」依赖向量的相关性来决定「给模型看哪些」, +// 若先仲裁后融合,仲裁剔除的块可能正是符号路能救回来的那条。 +// +// 注意 candidates 必须来自**未截断**的全量候选集 —— +// 向量路已经把正确块挤掉的话,融合也无从救回(与仲裁同一条理由)。 +func fuseCandidates(vectorHits []BlockHit, allBlocks []MemoryBlock, + query string, w FusionWeights) []FusedHit { + + symbols := QuerySymbols(query) + if len(symbols) == 0 { + // 无符号可提 ⇒ 退化成纯向量序(但仍标注 why,便于调试) + out := make([]FusedHit, 0, len(vectorHits)) + for _, h := range vectorHits { + out = append(out, FusedHit{ + BlockID: h.Block.ID, Text: h.Block.Text, + Score: h.Score * w.Vector, VectorHit: h.Score, + Why: []string{"仅向量命中(查询未提取到符号)"}, + }) + } + return out + } + + // 符号路在全量块里找命中(不是只在向量候选里找 —— + // 那样「向量没召回但符号命中」的块根本没有机会上场) + // ★ 必须用**索引**而不是 &out[i] 指针。 + // + // 实测 bug:符号路阶段会 append(向量未召回但符号命中的块), + // append 触发扩容后切片换了底层数组,之前存的 &out[i] 全部指向 + // 旧数组 —— 于是给已有候选写 SymbolHit 时写进了废弃副本, + // 分数永远算不出来。判据直接抓到了这个:目标块符号分 0.333 + // 而非满分。 + // + // 同理第一遍循环里那个 `byID[h.Block.ID] = &f`(f 是循环内临时变量) + // 也是无效指针,一并去掉。 + idxOf := make(map[string]int, len(vectorHits)) + out := make([]FusedHit, 0, len(vectorHits)+8) + for _, h := range vectorHits { + idxOf[h.Block.ID] = len(out) + out = append(out, FusedHit{ + BlockID: h.Block.ID, Text: h.Block.Text, + VectorHit: h.Score, + }) + } + + seenSymbol := map[string]bool{} + for _, b := range allBlocks { + if seenSymbol[b.ID] { + continue + } + sc, matched, exact := SymbolScore(symbols, b.Text) + if sc <= 0 { + continue + } + seenSymbol[b.ID] = true + if i, ok := idxOf[b.ID]; ok { + out[i].SymbolHit = sc + out[i].ExactHit = exact + out[i].Why = append(out[i].Why, + fmt.Sprintf("符号命中 %v", matched)) + } else { + // ★ 向量没召回但符号命中 —— 这就是融合要救的那一类 + out = append(out, FusedHit{ + BlockID: b.ID, Text: b.Text, + SymbolHit: sc, ExactHit: exact, + Why: []string{ + fmt.Sprintf("仅符号命中(向量未召回):%v", matched), + }, + }) + } + } + + for i := range out { + f := &out[i] + // ★ 精确串命中直接置顶,不参与加权。 + // + // 实测 bug:只符号命中且符号满分(精确数字串)的块 + // score = 1.0 × w.Symbol(0.3) = 0.300 + // 竟然输给「符号分只有 0.333」的向量候选: + // score = 0.620×0.7 + 0.333×0.3 = 0.534 + // + // 根因:**满分被低权重稀释了**。而精确串命中正是符号路 + // 不可替代的能力(向量对「13010」这种串天然弱), + // 它的价值在于「存在与否」,不在于「有多少」。 + // + // 所以 SymbolScore 给的 1.0 是布尔信号,这里必须按布尔用。 + if f.ExactHit { + // 向量分只做平手时的微调(不改变「已置顶」这个事实) + f.Score = 1.0 + f.VectorHit*0.01 + continue + } + if f.SymbolHit > 0 && f.VectorHit == 0 { + f.Score = f.SymbolHit * w.Symbol + continue + } + f.Score = f.VectorHit*w.Vector + f.SymbolHit*w.Symbol + } + + // 稳定排序:分数降序;同分保持向量原序(不引入额外的不确定性) + sortFused(out) + return out +} + +func sortFused(out []FusedHit) { + // 插入排序 —— 候选规模是百级,无需 sort.Slice 的反射开销, + // 且完全稳定(不依赖 sort.SliceStable 的额外分配)。 + for i := 1; i < len(out); i++ { + x := out[i] + j := i - 1 + for j >= 0 && out[j].Score < x.Score { + out[j+1] = out[j] + j-- + } + out[j+1] = x + } +} + +// RecallBlocksFused 是**融合召回的对外入口**。 +// +// 与 RecallBlocksWithArbitration 的区别: +// - RecallBlocksWithArbitration:向量召回 + 时序仲裁(排序仍是余弦序) +// - RecallBlocksFused:向量召回 + 符号路融合 + 时序仲裁 +// +// 返回的 FusedHit 带 Why(归因),便于模型理解「为什么这条被召回」。 +func (g *GraphDB) RecallBlocksFused(q BlockRecallQuery, query string) ( + []FusedHit, ArbitrationResult, error) { + + q2 := q + q2.TopK = largeTopK + vecHits, err := g.RecallBlocks(q2) + if err != nil { + return nil, ArbitrationResult{}, err + } + // 全量块(供符号路扫描)—— 只取有文本的(图像块无符号可匹配) + all, err := g.MemoryBlocks() + if err != nil { + return nil, ArbitrationResult{}, err + } + // ★★ 符号路必须也按 fingerprint 过滤 —— 这是一个真实的安全缺陷。 + // + // 判据立刻抓到(toolcall_block_test.go:124): + // + // 块 {Text: "旧空间的内容", Fingerprint: "old-fp"} + // 查询「旧空间的内容」 + // ⇒ 期望不召回,实际召回了(找到 1 条) + // + // 根因:符号路拿的是 `MemoryBlocks()` 的**全部**带文本块, + // 而 RecallBlocks 会按 fingerprint 跳过不匹配的块。 + // 文本匹配不依赖向量空间,所以「旧空间的块」在符号路原形毕露 —— + // 换向量空间后旧块仍会污染结果,正是这条判据要防的事。 + textBlocks := make([]MemoryBlock, 0, len(all)) + for _, b := range all { + if b.Text == "" { + continue + } + // 指纹不匹配的块一律不进符号路候选 + if q.Fingerprint != "" && b.Fingerprint != "" && b.Fingerprint != q.Fingerprint { + continue + } + textBlocks = append(textBlocks, b) + } + + fused := fuseCandidates(vecHits, textBlocks, query, defaultWeights()) + + // 融合 → 仲裁:仲裁在融合之后,且要喂进 FusedHit 的 id + vecOrder := make([]BlockHit, 0, len(fused)) + byID := make(map[string]MemoryBlock, len(fused)) + for _, f := range fused { + byID[f.BlockID] = MemoryBlock{ID: f.BlockID, Text: f.Text} + vecOrder = append(vecOrder, BlockHit{ + Block: byID[f.BlockID], Score: f.Score, + }) + } + arb := arbitrate(g, vecOrder) + superseded := map[string]bool{} + for _, h := range arb.Superseded { + superseded[h.Block.ID] = true + } + + kept := make([]FusedHit, 0, len(fused)) + for _, f := range fused { + if superseded[f.BlockID] { + continue + } + // ★ MinScore 按**路**应用,不是套在融合分上。 + // + // 接入时我曾把 MinScore 直接传 0(理由「量纲变了」), + // 结果两条判据立刻红了: + // + // toolcall_block_test.go:80 低分块不该出现(MinScore 应滤掉) + // toolcall_block_test.go:124 指纹不匹配的块不该被召回 + // + // ★ 那等于**放弃了噪声过滤** —— 「量纲变了」是事实, + // 但解法不是丢掉阈值,而是让阈值作用在它该作用的那一路上。 + // + // 融合分有三段不同量纲: + // 精确串命中 ≥1.00 布尔置顶,不过滤 + // 纯符号命中 0 ~ w.Symbol 字面强信号 + // 纯向量命中 0 ~ w.Vector×余弦 ← MinScore 说的就是这个 + // + // 而指纹不匹配的块压根不在候选里(RecallBlocks 已跳过), + // 它的失败另有原因 —— 见下方说明。 + switch { + case f.ExactHit: + // 精确串命中是决定性信号,不受 MinScore 约束 + case f.VectorHit > 0: + // 有向量分时才用 MinScore 过滤(避免把纯符号命中误杀) + if q.MinScore > 0 && f.VectorHit < q.MinScore { + continue + } + default: + // 只有符号分、无向量分:靠覆盖率判断,不套向量阈值 + if f.SymbolHit <= 0 { + continue + } + } + kept = append(kept, f) + } + if k := effectiveTopK(q.TopK); len(kept) > k { + kept = kept[:k] + } + return kept, arb, nil +} + +// ═══════════════════════════════════════════════════════════════ +// RecallBlocksFusedBFS —— 召回即联想(2026-10-04) +// +// ★★ 这个入口是为了验证一个假设: +// +// 生产探针 coexist 0/1:查询「本机服务监听哪些端口」召不回 13010。 +// 直觉是「召回该联想」—— 命中节点沿边走一层,找回图上相连的上下文。 +// +// ★ 实测结论:**假设不成立**,而且原因值得记下来。 +// +// 调试记录(生产库): +// +// 直接命中 top8 全是噪音(CPU总线… / sdk/introduce 站部署 / …) +// 联想确实在工作(从噪音节点联出了 5 个) +// 但 13010 **根本没进 top8** ⇒ 联想无从谈起 +// +// 往下挖,真正的根因是**符号切分**: +// +// 查询「本机服务监听哪些端口」的符号只有 +// ["本机服务","监听哪些"] —— 全是跨词伪词 +// 「端口」被中划窗「每位置只取最长窗口」吃掉 +// ⇒ 符号路对「14010端口」全打 0 分 +// +// ⇒ ★★ **召回即联想救不了召回本身错了的情况**: +// 联想从**已命中**节点出发,而命中节点本身就是错的。 +// 图联想只能补上下文,不能修命中。 +// +// 所以本函数**不接入生产路径**,作为独立入口保留: +// 修好命中质量之后,它才有意义。 +// ═══════════════════════════════════════════════════════════════ + +// bfsExpansionBudget 是 BFS 追加节点的总预算上限。 +// +// ★ 为什么必须有预算:图不是树 —— 关系边双向、稠密。 +// +// 无预算的 BFS 在大图上会把整个连通分量拖进来, +// 而 memory_recall 的工具预算是有限的 +// (历史上曾一次返回 193 个实体、13744 tokens,是预算的 436%)。 +// +// ★ 它约束「**追加**的联想节点」,不含直接命中 —— +// +// 直接命中已由 q.TopK 控制。 +const bfsExpansionBudget = 60 + +// RecallBlocksFusedBFS 在向量 + 符号的直接命中之外, +// 对每个命中节点做一层 BFS,把联想到的节点按**发现顺序**追加到尾部。 +// +// ★ 追加而非置顶:联想是补充,不是答案。 +// +// 混进相关性排序会污染「离查询多近」这个信号。 +// +// ★ 联想节点按 base/(1+d) 衰减,越远越弱但仍 > 0 +// +// (否则会被下游 MinScore 滤掉)。 +// +// ★ 预算耗尽即停,且已产出的部分照常返回 —— +// +// 截断不该让整次召回失败。 +func (g *GraphDB) RecallBlocksFusedBFS(q BlockRecallQuery, query string, depth int) ( + []FusedHit, ArbitrationResult, error) { + + hits, arb, err := g.RecallBlocksFused(q, query) + if err != nil { + return nil, arb, err + } + if depth <= 0 || len(hits) == 0 { + return hits, arb, nil + } + + seen := make(map[string]bool, len(hits)) + for _, h := range hits { + seen[h.BlockID] = true + } + base := 0.0 + for _, h := range hits { + if h.Score > base { + base = h.Score + } + } + if base <= 0 { + base = 1.0 + } + + appended := 0 + for _, seed := range hits { + if appended >= bfsExpansionBudget { + break + } + for d := 1; d <= depth; d++ { + neighbors, err := g.NeighbourBlocks(seed.BlockID) + if err != nil { + // 联想失败不该让整次召回失败 —— 主路结果仍然有用。 + break + } + for _, nb := range neighbors { + if seen[nb.ID] { + continue + } + seen[nb.ID] = true + if appended >= bfsExpansionBudget { + return hits, arb, nil + } + hits = append(hits, FusedHit{ + BlockID: nb.ID, + Text: nb.Text, + Score: base / float64(1+d), + Why: []string{"联想:" + seed.Text + " → " + nb.Text + + fmt.Sprintf("(%d 层)", d)}, + }) + appended++ + } + // 只展开一层 —— 下一轮 d 由新追加的节点继续。 + break + } + } + return hits, arb, nil +} + +// NeighbourBlocks 返回 startID 的**直接邻居**(一层,双向,按发现顺序)。 +// +// ★ 与 BFSBlocks 的区别:那个按 depth 逐层展开, +// +// 这里专供召回链逐层调用 —— 因为要按层给不同打分。 +// +// ★ 只沿**关系边**展开,不沿 contains 结构边: +// +// contains 连的是「原句块 → 字段块/关系边」,那是实现细节, +// 把它当语义边会让原句块(长文本)混进召回结果、抢占预算。 +func (g *GraphDB) NeighbourBlocks(startID string) ([]MemoryBlock, error) { + if startID == "" { + return nil, nil + } + g.mu.RLock() + defer g.mu.RUnlock() + + rows, err := g.db.Query( + `SELECT DISTINCT + CASE WHEN e.source_id = ? THEN e.target_id ELSE e.source_id END AS peer + FROM memory_block_edges e + WHERE (e.source_id = ? OR e.target_id = ?) + AND e.source_kind = 'block' AND e.target_kind = 'block' + AND e.edge_type != 'contains' + AND COALESCE(e.status, '') != 'deleted' + ORDER BY e.created_at ASC, e.id ASC`, startID, startID, startID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return g.blocksByIDsLocked(ids) +} diff --git a/internal/memory/fusion_bfs_test.go b/internal/memory/fusion_bfs_test.go new file mode 100644 index 00000000..5bd84bad --- /dev/null +++ b/internal/memory/fusion_bfs_test.go @@ -0,0 +1,249 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ═══════════════════════════════════════════════════════════════ +// BFS 接入召回链 —— 判据 +// +// ★ 真实缺口(生产探针 coexist 维度 0/1) +// +// 查询「本机服务监听哪些端口」召不回 13010。 +// 原因:库里含「端口」二字的块是 14010端口 / 14011端口 那批, +// 而真正的本机端口块文本是「http://127.0.0.1:13010」—— +// **不含「端口」二字**。 +// +// ⇒ 纯向量/符号召回召不回它。 +// ⇒ 而图上是连着的:billing服务 --监听--> 13010 +// 只要从「监听」或端口号出发 BFS,就能找回服务名。 +// +// ★ 所以这一格测的不是「关键词召回能不能更强」, +// 而是「召回**是否即联想**」—— 孤立命中之外, +// 还要沿边找回上下文。 +// ═══════════════════════════════════════════════════════════════ + +func seedCoexistGraph(t *testing.T) *GraphDB { + t.Helper() + g := newTestGraph(t) + // 织一张真实形态的网:服务 --监听--> 端口号 + blocks := []MemoryBlock{ + {ID: "b_svc", Modality: BlockText, Text: "billing服务", Vector: []float64{1, 0}, Fingerprint: "fp1"}, + {ID: "b_port", Modality: BlockText, Text: "13010", Vector: []float64{0.9, 0.1}, Fingerprint: "fp1"}, + {ID: "b_noise1", Modality: BlockText, Text: "14010端口", Vector: []float64{0, 1}, Fingerprint: "fp1"}, + {ID: "b_noise2", Modality: BlockText, Text: "14011端口", Vector: []float64{0, 1}, Fingerprint: "fp1"}, + {ID: "b_noise3", Modality: BlockText, Text: "CPU总线与MAR/MDR/ALU连接问题", Vector: []float64{0, 1}, Fingerprint: "fp1"}, + } + for i := range blocks { + if err := g.PutMemoryBlocks([]MemoryBlock{blocks[i]}); err != nil { + t.Fatal(err) + } + } + for _, e := range []struct{ from, to, typ string }{ + {"b_svc", "b_port", "监听"}, + {"b_noise1", "b_port", "监听"}, + {"b_noise3", "b_svc", "涉及"}, + } { + if err := g.AddRelationBlockEdge(e.from, e.to, e.typ, + RelationEdgeData{SessionID: "s1", Confidence: 0.9, Status: EdgeActive}); err != nil { + t.Fatal(err) + } + } + return g +} + +// TestBFS召回_从服务找回端口:图上连着的就该召回 +func TestBFS召回_从服务找回端口(t *testing.T) { + g := seedCoexistGraph(t) + defer func() { _ = g.Close() }() + + // 查询直接命中「billing服务」——一个**孤立的语义节点**, + // 但图上它连着 13010。 + // 带上查询向量:RecallBlocks 要求非空向量(符号路也依赖融合入口) + q := BlockRecallQuery{Vector: []float64{1, 0}, TopK: 5, MinScore: -1} + hits, _, err := g.RecallBlocksFusedBFS(q, "billing服务", 2) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 命中 %d 个: ", len(hits)) + for _, h := range hits { + fmt.Printf("%q ", h.Text) + } + fmt.Println() + + if !containsText(hits, "13010") { + t.Errorf("★ 召回应沿边找回端口号 13010(召回即联想),实际 %v", textsOfFused(hits)) + } +} + +// TestBFS召回_顺序稳定:map 遍历会让输出顺序随机 +func TestBFS召回_顺序稳定(t *testing.T) { + g := seedCoexistGraph(t) + defer func() { _ = g.Close() }() + + // 带上查询向量:RecallBlocks 要求非空向量(符号路也依赖融合入口) + q := BlockRecallQuery{Vector: []float64{1, 0}, TopK: 5, MinScore: -1} + var first []string + for run := 0; run < 5; run++ { + hits, _, err := g.RecallBlocksFusedBFS(q, "billing服务", 2) + if err != nil { + t.Fatal(err) + } + got := textsOfFused(hits) + if run == 0 { + first = got + continue + } + if len(got) != len(first) { + t.Fatalf("第 %d 次长度不同: %v vs %v", run, got, first) + } + for i := range got { + if got[i] != first[i] { + t.Fatalf("★ 第 %d 次顺序不同: %v vs %v(map 遍历导致输出不稳定)", run, got, first) + } + } + } + fmt.Printf(" 5 次顺序一致: %v\n", first) +} + +// TestBFS召回_预算有界:不能因为加 BFS 就无限膨胀 +func TestBFS召回_预算有界(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 织一个 200 节点的链:加 BFS 若无预算,depth=3 会拉进一大片 + for i := 0; i < 200; i++ { + id := fmt.Sprintf("n%03d", i) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: id, Modality: BlockText, Text: fmt.Sprintf("节点%03d", i), + Vector: []float64{1, 0}, Fingerprint: "fp1"}, + }); err != nil { + t.Fatal(err) + } + if i > 0 { + if err := g.AddRelationBlockEdge(fmt.Sprintf("n%03d", i-1), id, "next", + RelationEdgeData{SessionID: "s", Status: EdgeActive}); err != nil { + t.Fatal(err) + } + } + } + + // 带上查询向量:RecallBlocks 要求非空向量(符号路也依赖融合入口) + q := BlockRecallQuery{Vector: []float64{1, 0}, TopK: 5, MinScore: -1} + hits, _, err := g.RecallBlocksFusedBFS(q, "节点000", 3) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 200 节点链,depth=3 → 召回 %d 个\n", len(hits)) + if len(hits) > 60 { + t.Errorf("★ BFS 应受预算约束(200 节点链 depth=3 召回 %d 个,过多)", len(hits)) + } +} + +func containsText(hits []FusedHit, s string) bool { + for _, h := range hits { + if h.Text == s { + return true + } + } + return false +} + +func textsOfFused(hits []FusedHit) []string { + out := make([]string, 0, len(hits)) + for _, h := range hits { + out = append(out, h.Text) + } + return out +} + +// ═══════════════════════════════════════════════════════════════ +// 中文字符切分 —— 判据(2026-10-04) +// +// ★ 这是生产探针 coexist 0/1 的**真正根因** +// +// 滑窗「每位置只取最长窗口」把目标词吃掉了: +// +// 「本机服务监听哪些端口」 +// 滑窗 → ["本机服务", "监听哪些"] 全是跨词伪词 +// jieba → [本机 服务 监听 哪些 端口] 「端口」独立成词 +// +// ⇒ 「端口」从未单独成窗 ⇒ 符号路对「14010端口」全打 0 分 +// ⇒ 直接命中全是噪音 ⇒ 图联想也无从谈起 +// ═══════════════════════════════════════════════════════════════ + +// TestQuerySymbols_目标词必须独立成词 +func TestQuerySymbols_目标词必须独立成词(t *testing.T) { + for _, tc := range []struct { + query string + want []string // 必须出现的词 + notWant []string // 跨词伪词,出现即失败 + }{ + { + query: "本机服务监听哪些端口", + want: []string{"端口", "服务", "监听"}, + notWant: []string{"听哪些", "些端口", "务监听哪"}, + }, + { + query: "grafana 监控面板的端口是多少", + want: []string{"端口", "面板", "grafana"}, + notWant: []string{"控面板的", "板的端口"}, + }, + { + query: "本机 13010 端口对应什么服务", + want: []string{"13010", "端口", "服务"}, + }, + } { + syms := QuerySymbols(tc.query) + set := make(map[string]bool, len(syms)) + for _, s := range syms { + set[s] = true + } + fmt.Printf(" %q → %v\n", tc.query, syms) + for _, w := range tc.want { + if !set[w] { + t.Errorf("★ %q 的符号应含 %q(目标词被切分吃掉),实际 %v", tc.query, w, syms) + } + } + for _, w := range tc.notWant { + if set[w] { + t.Errorf("★ %q 的符号不该含跨词伪词 %q,实际 %v", tc.query, w, syms) + } + } + } +} + +// TestQuerySymbols_数字与英文精确串不受分词影响 +func TestQuerySymbols_数字与英文精确串(t *testing.T) { + syms := QuerySymbols("本机 13010 端口对应 grafana 9.99.99 吗") + set := map[string]bool{} + for _, s := range syms { + set[s] = true + } + for _, want := range []string{"13010", "grafana", "9.99.99"} { + if !set[want] { + t.Errorf("★ 精确串 %q 必须原样保留,实际 %v", want, syms) + } + } +} + +// TestSymbolScore_目标词能匹配到对应块:端到端的一环 +func TestSymbolScore_目标词能匹配到对应块(t *testing.T) { + syms := QuerySymbols("本机服务监听哪些端口") + for _, tc := range []struct { + text string + shouldMatch bool + }{ + {"14010端口", true}, + {"端口", true}, + {"13010/13011 而非 12011", false}, // 不含目标词 + } { + _, matched, _ := SymbolScore(syms, tc.text) + got := len(matched) > 0 + if got != tc.shouldMatch { + t.Errorf("★ %q 是否匹配 %q:期望 %v,实际 %v(matched=%v)", + tc.text, "端口", tc.shouldMatch, got, matched) + } + } +} diff --git a/internal/memory/fusion_test.go b/internal/memory/fusion_test.go new file mode 100644 index 00000000..c5888f99 --- /dev/null +++ b/internal/memory/fusion_test.go @@ -0,0 +1,192 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ★ QuerySymbols:只提高信息量符号,泛词不进。 +func TestQuerySymbols_泛词不进(t *testing.T) { + got := QuerySymbols("本机 13010 端口对应什么") + has := func(s string) bool { + for _, g := range got { + if g == s { + return true + } + } + return false + } + if !has("13010") { + t.Errorf("必须提到精确数字串 13010,实际 %v", got) + } + // ★★ 「什么」是虚词,不该作为信号。 + // + // 2026-10-04:原断言是「端口」「什么」都不该进。 + // 那个断言**部分正确** —— + // + // 「什么」是虚词 ⇒ 该滤(不变) + // 「端口」是实词 ⇒ 不该滤 + // + // 而「端口」此前之所以该滤,是因为滑窗会把它切成跨词伪词 + // (「本机服务监听哪些端口」→ 只有「本机服务」「监听哪些」)。 + // 换成 jieba 词典分词后「端口」是独立实词, + // 滤掉它就等于**把目标词从符号集里删掉** —— + // 生产探针 coexist 0/1 的根因正在于此。 + // + // ★ 判据的价值就在于逼出这个区分: + // 「泛词」是个模糊概念,而「虚词 vs 实词」是可判定的。 + if has("什么") { + t.Errorf("虚词 %q 不该进符号集:%v", "什么", got) + } + // ★ 反向断言:实词必须留下,否则符号路失效。 + if !has("端口") { + t.Errorf("★ 实词 %q 必须留在符号集(否则查询目标词被滤掉):%v", "端口", got) + } +} + +// ★ SymbolScore:精确数字串命中 = 满分(这是符号路不可替代的能力)。 +func TestSymbolScore_精确串命中满分(t *testing.T) { + syms := QuerySymbols("本机 13010 端口对应什么") + + // 含精确串的块 ⇒ 满分 + s, matched, exact := SymbolScore(syms, "13010/13011 而非 12011") + if !exact { + t.Error("精确串命中必须标记 exact(它是布尔信号,不参与加权)") + } + if s < 1.0 { + t.Errorf("精确串命中应给满分,实际 %.3f(matched=%v)", s, matched) + } + + // 不含精确串的块 ⇒ 低分 + s2, _, _ := SymbolScore(syms, "本地网关8081") + if s2 >= 1.0 { + t.Errorf("不含 13010 的块不该满分,实际 %.3f", s2) + } + // ★ 实测里「本地网关8081」曾被向量排进 top8,但它不含 13010 —— + // 符号路必须能把它压下去,否则融合没有意义。 + if s2 > 0.5 { + t.Errorf("「本地网关8081」相对查询应低分,实际 %.3f", s2) + } +} + +// ★ 融合的核心判据:向量没召回但符号命中的块必须能进榜。 +// +// 这条钉的是实测故障形态(生产快照 1391 块): +// +// 查询「本机 13010 端口对应什么」 +// 向量 top8 → 「13000端口」「本地网关8081」…(都不含 13010) +// 词法命中 → 「13010/13011 而非 12011」 +func TestFuse_向量失手时符号能救回(t *testing.T) { + target := MemoryBlock{ID: "b-target", Text: "13010/13011 而非 12011"} + noise := MemoryBlock{ID: "b-noise1", Text: "本地网关8081"} + noise2 := MemoryBlock{ID: "b-noise2", Text: "本机 443 按 SNI 透传到 192.168.2.106:3080"} + + // 向量路的候选里**没有** target —— 这正是实测形态 + vecHits := []BlockHit{ + {Block: noise, Score: 0.6435}, + {Block: noise2, Score: 0.6201}, + } + out := fuseCandidates(vecHits, []MemoryBlock{target, noise, noise2}, + "本机 13010 端口对应什么", defaultWeights()) + + if len(out) == 0 { + t.Fatal("融合后不应为空") + } + if out[0].BlockID != "b-target" { + t.Errorf("含精确串的块应排第一,实际第一是 %q(score=%.3f, why=%v)", + out[0].BlockID, out[0].Score, out[0].Why) + } + if out[0].VectorHit != 0 { + t.Errorf("它不该有向量分,实际 %.3f", out[0].VectorHit) + } + if out[0].SymbolHit < 1.0 { + t.Errorf("应有满分符号分,实际 %.3f", out[0].SymbolHit) + } + // 归因必须可解释 + if len(out[0].Why) == 0 || out[0].Why[0] == "" { + t.Error("必须给出 Why(模型需要知道为什么召回它)") + } +} + +// ★ 反向:符号不能压过明显更相关的向量命中。 +func TestFuse_不误伤强向量命中(t *testing.T) { + strong := MemoryBlock{ID: "b-strong", Text: "从零开发 HomeAgent QQ 插件:使用 plugindev 工具链"} + other := MemoryBlock{ID: "b-other", Text: "AgentMail 投递规则"} + + vecHits := []BlockHit{ + {Block: strong, Score: 0.80}, + {Block: other, Score: 0.30}, + } + out := fuseCandidates(vecHits, []MemoryBlock{strong, other}, + "从零开发 QQ 插件用什么工具链", defaultWeights()) + + if out[0].BlockID != "b-strong" { + t.Errorf("强向量+符号双命中应第一,实际 %q", out[0].BlockID) + } + if out[0].Score <= out[1].Score { + t.Errorf("排序错误:%.3f vs %.3f", out[0].Score, out[1].Score) + } +} + +// ★ 零符号查询必须退化成纯向量序,且标注 why。 +func TestFuse_无符号退化为向量序(t *testing.T) { + a := MemoryBlock{ID: "a", Text: "随便一块"} + b := MemoryBlock{ID: "b", Text: "另一块"} + vecHits := []BlockHit{{Block: a, Score: 0.9}, {Block: b, Score: 0.5}} + out := fuseCandidates(vecHits, []MemoryBlock{a, b}, "嗯", defaultWeights()) + + if len(out) != 2 { + t.Fatalf("退化为向量序时不该增删候选,实际 %d", len(out)) + } + if out[0].BlockID != "a" { + t.Errorf("应保持向量序,实际 %q", out[0].BlockID) + } + if len(out[0].Why) == 0 { + t.Error("退化时也要给 Why,便于调试「为什么没走符号路」") + } +} + +// ★ 稳定排序:同分保持向量原序,不引入额外不确定性。 +func TestFuse_同分保持向量序(t *testing.T) { + a := MemoryBlock{ID: "a", Text: "AAA"} + b := MemoryBlock{ID: "b", Text: "BBB"} + vecHits := []BlockHit{{Block: a, Score: 0.5}, {Block: b, Score: 0.5}} + out := fuseCandidates(vecHits, []MemoryBlock{a, b}, "AAA BBB", defaultWeights()) + if out[0].BlockID != "a" { + t.Errorf("同分应保持原序,实际 %q", out[0].BlockID) + } +} + +// ★ 变体自证:把精确串从查询里去掉,救回能力必须消失。 +// +// —— 证明救回来自符号路,而不是「融合什么都返回」。 +func TestFuse_变异自证_去掉精确串则失效(t *testing.T) { + target := MemoryBlock{ID: "b-target", Text: "13010/13011 而非 12011"} + noise := MemoryBlock{ID: "b-noise", Text: "本地网关8081"} + + vecHits := []BlockHit{{Block: noise, Score: 0.64}} + + withNum := fuseCandidates(vecHits, []MemoryBlock{target, noise}, + "本机 13010 端口对应什么", defaultWeights()) + withoutNum := fuseCandidates(vecHits, []MemoryBlock{target, noise}, + "本机端口对应什么", defaultWeights()) + + posWith, posWithout := -1, -1 + for i, f := range withNum { + if f.BlockID == "b-target" { + posWith = i + } + } + for i, f := range withoutNum { + if f.BlockID == "b-target" { + posWithout = i + } + } + if posWith != 0 { + t.Errorf("有精确串时应救回并排第一,实际位置 %d", posWith) + } + fmt.Printf(" 变异自证: 有数字串→位置 %d,无数字串→位置 %d\n", posWith, posWithout) + if posWithout != -1 && posWithout < posWith { + t.Error("变异后不该比原形更好") + } +} diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 34704833..aa99cf32 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log" + "sort" "strings" "sync" "time" @@ -18,6 +19,19 @@ import ( // 把一次召回变成一次全表扫描。 const maxKeywordEntities = 50 +// maxRecallEntities 是**一次召回全局**的实体上限(跨所有关键词)。 +// +// ★ 为什么必须有它(2026-10-01 跑分实测):maxKeywordEntities 是**每个关键词** +// 的上限,而 ExtractKeywords 会把一个问句切成多个词(实测 +// 「metrics服务的端口是多少」→ [metrics, 服务, 端口],其中「服务」「端口」 +// 是无区分度的泛词)。于是 3 个关键词 × 50 = 最多 150 个实体被**平铺**进上下文, +// 实测 memory_recall 单次返回 193 个实体 / 13744 tokens = 工具预算的 436%, +// 模型被噪音淹没后转去 grep 知识库文件,还把「没检索到」当成「不存在」。 +// +// 实验结论(internal/memory 内的三方案对比,见 benchmark 记录):排序不是瓶颈 +// —— 目标实体在三种方案里都排#1。缺的是**全局上限**。 +const maxRecallEntities = 20 + // maxAdjacentRelations 是深度扩展里**每层**读取的关系上限。 const maxAdjacentRelations = 200 @@ -27,29 +41,80 @@ const maxAdjacentRelations = 200 const maxFullRecallEntities = 10000 type Entity struct { + // MatchRank 是**实词关键词**上的最佳命中层级(0=完全相等, 1=前缀, 2=包含)。 + // + // ★ 为什么实体要带这个字段(2026-10-01 跑分实测):召回结果平铺给模型时, + // 「为什么这条相关」此前完全不可见 —— 模型看到 193 个同格式的 + // 「-名称(提及N次)」,无从判断该信哪个,于是转去 grep 知识库文件, + // 还把「没检索到」当成「不存在」。带上层级后最相关的几条一眼可辨。 + // 仅在关键词召回路径上填充;深度扩展产出与 seed 路径为 -1(未知)。 + MatchRank int `json:"match_rank,omitempty"` + // Seq 是该实体最新一次被写入时的**句子 id**(sentences.id,自增单调)。 + // + // ★ 为什么实体时间排序需要它(真实库实测):entities.updated_at 来自 + // SQLite CURRENT_TIMESTAMP,只到**秒**,而一批记忆常在同一秒内批量写入。 + // 实测 ha-c 生产库 190 个实体里最多的一批 20 个实体共享同一秒。 + // sentences.id 是自增主键,严格单调,是唯一可靠的时序信号。 + // 0 表示该实体没有关联句子(合成/导入的实体),此时回退到 UpdatedAt。 + Seq int64 `json:"seq,omitempty"` ID int64 `json:"id"` Name string `json:"name"` Type string `json:"type"` MentionCount int `json:"mention_count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + // blockKey 是对应的**块 ID**(2026-10-04)。 + // + // ★ 为什么 ID 不能直接用块 ID:Entity.ID 是 int64(JSON 与 SDK 契约), + // 而块 ID 是内容派生的字符串(blk_ent_)—— 放不进去。 + // 所以 ID 退化为「本次召回内的序号」,块 ID 走这个新字段。 + // + // ★ 为什么需要它:召回内部要用块 ID 做 map 键(跨关键词去重、 + // 深度扩展的前沿集合),而出口排序 sortRecallEntities 按 + // Entity.ID 建索引 —— 两者需要一次映射。 + // + // ★ 不导出到 JSON(omitempty + json:"-"):它是内部索引键, + // 不是对外契约的一部分 —— 对外应该是 block_id 而不是这个临时序号。 + blockKey string `json:"-"` } +// IsLegacyRow 报告这个实体行是**旧表**(entities)来的,而不是块。 +// +// ★ 存在的理由:读侧切块之后,External Recall 回来的东西既可能是块 +// +// (blockKey 非空)也可能是旧表行(blockKey 为空)。 +// 而判据常需要区分「块」与「旧表残留」—— +// 两者 Name/Type 可能完全一样,光看内容分不出来。 +// +// ★ 只读,不进 JSON。 +func (e Entity) IsLegacyRow() bool { return e.blockKey == "" } + type Relation struct { - ID int64 `json:"id"` - SourceID int64 `json:"source_id"` - TargetID int64 `json:"target_id"` - SourceName string `json:"source_name"` - TargetName string `json:"target_name"` - RelationType string `json:"relation_type"` - Confidence float64 `json:"confidence"` - Status string `json:"status"` - SessionID string `json:"session_id"` - TurnID int `json:"turn_id"` - CreatedAt time.Time `json:"created_at"` - DateBucket string `json:"date_bucket"` - SentenceID int64 `json:"sentence_id,omitempty"` // FK → sentences.id - SentenceText string `json:"sentence_text,omitempty"` // JOINed from sentences + ID int64 `json:"id"` + SourceID int64 `json:"source_id"` + TargetID int64 `json:"target_id"` + // ★★ 块体系的端点 ID(2026-10-04) + // + // 旧 SourceID/TargetID 是 relations 表的行号,退场后无效。 + // 块体系里端点是 memory_blocks.id(字符串,内容派生的稳定 ID)。 + // 两个字段并存:RecallByScene 已走块侧,旧字段留给旧表读方; + // 旧表退场时删掉 SourceID/TargetID 即可。 + SourceBlockID string `json:"source_block_id,omitempty"` + TargetBlockID string `json:"target_block_id,omitempty"` + SourceName string `json:"source_name"` + TargetName string `json:"target_name"` + RelationType string `json:"relation_type"` + Confidence float64 `json:"confidence"` + Status string `json:"status"` + SessionID string `json:"session_id"` + TurnID int `json:"turn_id"` + CreatedAt time.Time `json:"created_at"` + DateBucket string `json:"date_bucket"` + SentenceID int64 `json:"sentence_id,omitempty"` // FK → sentences.id(退场后不用) + SentenceText string `json:"sentence_text,omitempty"` // 原句全文(走原句块回溯) + // SentenceBlockID 是原句块的 ID(blk_src_),供场景引用与去重。 + SentenceBlockID string `json:"sentence_block_id,omitempty"` } type Triple struct { @@ -180,6 +245,10 @@ func (g *GraphDB) initSchema() error { source TEXT DEFAULT '', tool TEXT DEFAULT '', scene TEXT DEFAULT '', + -- semantic_type 是实体的语义类别(Person / Animal / Concept), + -- 对应旧 entities.type。2026-10-04 加入:social 层靠它 + -- 区分人物与特质,检索层靠它区分「谁」与「什么」。 + semantic_type TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`, @@ -190,8 +259,9 @@ func (g *GraphDB) initSchema() error { target_kind TEXT NOT NULL, target_id TEXT NOT NULL, edge_type TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(source_kind, source_id, target_kind, target_id, edge_type) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + -- ★ 无 UNIQUE 约束:边是独立单位,同一对节点间可并存多条同类边。 + -- 结构边的幂等由 AddMemoryBlockEdge 的先查后写保证。 )`, `CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, @@ -279,6 +349,51 @@ func (g *GraphDB) initSchema() error { tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN scene TEXT DEFAULT ''`) // 迁移6:旧 scenes 表加 strength 列(涌现侧的强度计数) tx.Exec(`ALTER TABLE scenes ADD COLUMN strength INTEGER DEFAULT 1`) + // ★ 迁移7:记忆块加语义类型列(2026-10-04) + // + // ★ 为什么必须补:Triple.SubjectType / ObjectType 此前**只写旧表 + // entities.type**,块侧完全没有 —— 于是 Commit 标注的 + // 「这是 Person / 那是 Animal」在块体系里直接丢失。 + // + // 判据 TestGraphCommit_CarriesAllFields 当场抓到: + // 实体类型 = ("block","block"),期望 (Person,Animal)。 + // + // ★ 命名用 semantic_type 而不是 type: + // `type` 在 SQLite 里合法但与很多工具的保留字冲突 + // (且 memory_blocks 已有 modality 列表达「模态」,语义类型是另一回事)。 + tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN semantic_type TEXT DEFAULT ''`) + + // ★★★ 迁移8:memory_block_edges 升格为「边是独立单位」 + // + // 补五列(对应旧 relations 表的同名列): + // + // confidence 置信度 —— 「值覆盖」维度靠它判断哪条更可信 + // session_id 来源会话 —— Recall 的 sessionFilter 依赖它 + // turn_id 来源轮次 + // status active/deleted/merged —— 软删除与仲裁依赖它 + // merged_into 源块被并进哪个块(历史边留在源块上) + // + // ★ 建表语句里的 UNIQUE(source_kind,source_id,target_kind,target_id, + // edge_type) **必须去掉** —— 它让同一对节点间只能存一条同类型边, + // 而「边是独立单位」要求可并存多条(不同 session/confidence 是 + // 不同的事实)。实测那正是「报告 980、实际 959」的根因。 + // + // SQLite 不能直接删 UNIQUE 约束 ⇒ 见下方 ensureRelationEdgeUniqueness。 + // 结构边(contains 等)靠 AddMemoryBlockEdge 自己的 + // 「先查后写」保持幂等,不依赖 DB 约束。 + for _, m := range []struct{ table, col, decl string }{ + {"memory_block_edges", "confidence", "REAL DEFAULT 0"}, + {"memory_block_edges", "session_id", "TEXT DEFAULT ''"}, + {"memory_block_edges", "turn_id", "INTEGER DEFAULT 0"}, + {"memory_block_edges", "status", "TEXT DEFAULT ''"}, + {"memory_block_edges", "merged_into", "TEXT DEFAULT ''"}, + } { + if !columnExists(tx, m.table, m.col) { + tx.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", + m.table, m.col, m.decl)) + } + } + ensureRelationEdgeUniqueness(tx) // 迁移7:旧 scenes 表加 origin。既有行都是声明/存量引导来的(建表时还没有 // 涌现机制),标成 declared;新建的涌现场景在 createSceneLocked 里写 emergent。 if !columnExists(tx, "scenes", "origin") { @@ -415,17 +530,24 @@ func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, i // 返回的 map 只包含本次真正写入了 sentences 表的句子。调用方据此把媒体 // 变成 L3 一等块,并以 sentence --contains--> block 边与句子相连; // 关系行本身不持有媒体。 -func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]int64, int, int, error) { +// ★ 返回的 map 现在是「原句文本 → **原句块 ID**」(原为 sentences 表行号)。 +// +// 变更原因:sentences 表退场后行号不再存在,而媒体桥 +// (graphmedia.go commitTriplesWithMedia)要靠它挂 +// 「原句块 --contains--> 媒体块」这条边。 +// +// ⇒ 媒体桥那条边要同步改:source_kind 从 "sentence" 变成 "block"。 +func (g *GraphDB) CommitWithMedia(triples []Triple, sessionID string, turnID int) (map[string]string, int, int, error) { return g.commit(triples, sessionID, turnID, true) } -func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]int64, int, int, error) { +func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSentences bool) (map[string]string, int, int, error) { g.mu.Lock() defer g.mu.Unlock() - var sentenceIDs map[string]int64 + var sentenceIDs map[string]string if trackSentences { - sentenceIDs = make(map[string]int64) + sentenceIDs = make(map[string]string) } tx, err := g.db.Begin() @@ -436,7 +558,9 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe entitiesCreated := 0 relationsCreated := 0 + // dateBucket 原供旧表 relations.date_bucket —— 旧表停写后已无去处。 dateBucket := time.Now().Format("2006-01-02") + _ = dateBucket for _, t := range triples { if t.Subject == "" || t.Relation == "" || t.Object == "" { @@ -446,6 +570,29 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe continue } + // ★ 块化写入:三元组 → 「主语块 --关系--> 宾语块」 + // + // 为什么在 commit 里做(而不是让调用方各自块化): + // Commit 有四条调用路径(媒体桥 / memory_commit 工具 / 驻留子 / + // 记忆整理流水线),实测只有 memory_commit 工具在活跃写库。 + // 若各路径自己块化,必然出现「有的路径写块、有的不写」—— + // 那正是「旧表在长大」(跑分实测 entities 188 → 381)的机制。 + // + // ★ 块 ID 由内容派生 ⇒ 重复提交同一三元组得到同一个块,天然幂等。 + // + // ★ 旧表写入**暂时保留**:Recall 等 55 处调用方仍读它们 + // (见 docs/zh/legacy-table-retirement.md),先删会打断在线读取。 + // 退场顺序是「读方先切块 → 再停写旧表 → 最后删表」。 + // ★ 原句块 ID:在原句分支里赋值,场景挂载与 contains 边要用。 + // (声明提前到循环开头,否则它只在 SentenceText != "" 的分支里可见) + var sentenceBlockID string + + srcBlockID, dstBlockID, edgeID, err := putTripleBlocksTx(tx, t, sessionID, turnID) + if err != nil { + return nil, 0, 0, fmt.Errorf("block triple %s/%s: %w", + t.Subject, t.Relation, err) + } + subjType := t.SubjectType if subjType == "" { subjType = "Concept" @@ -459,82 +606,104 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe confidence = 1.0 } - ec, err := g.upsertEntity(tx, t.Subject, subjType) - if err != nil { - return nil, 0, 0, err - } - entitiesCreated += ec + // ★★ 旧表 entities 双写已停(2026-10-04) + // + // 读方已全部切块,而 entities.type / mention_count 已无处可取 + // (块侧改用 semantic_type)。 + // + // ★ 代价要说清楚:**entitiesCreated 现在恒为 0,没有意义了**。 + // Commit 的返回签名 (entityCount, relationCount, error) 是 + // SDK 契约(9 处调用方),改签名会波及一大片 —— + // 所以保留位置并置 0,而不是改签名。 + // 依赖它做判断的调用方(若存在)应改用块侧计数, + // 已记入 docs/zh/legacy-table-retirement.md 的待办。 + _ = subjType + _ = objType - ec, err = g.upsertEntity(tx, t.Object, objType) - if err != nil { - return nil, 0, 0, err - } - entitiesCreated += ec + // ★★ 旧表 entities 的**读取**也已停(2026-10-04) + // + // 这两条 SELECT 只为拿旧表行号(sourceID/targetID), + // 而它们唯一的用途是写旧表 relations。旧表不写之后, + // 查询本身成了纯开销 —— 且在旧表被删后会直接报错。 + // + // ★ 注意它们**必须**先于句块写入被摘掉: + // 旧表退场后 `SELECT id FROM entities WHERE name=?` + // 会返回 no rows ⇒ Commit 直接失败 ⇒ **记忆完全写不进去**。 + // 这是「停双写」时最容易漏的一环:读也要停。 - var sourceID, targetID int64 - err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Subject).Scan(&sourceID) - if err != nil { - return nil, 0, 0, fmt.Errorf("subject %q: %w", t.Subject, err) - } - err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", t.Object).Scan(&targetID) - if err != nil { - return nil, 0, 0, fmt.Errorf("object %q: %w", t.Object, err) - } - - // 写入/查找句子 - var sentenceID int64 + // 写入/查找句子(★ 现在只建块,不写 sentences 表) if t.SentenceText != "" { - _, err = tx.Exec( - `INSERT OR IGNORE INTO sentences (text) VALUES (?)`, t.SentenceText) - if err != nil { - return nil, 0, 0, fmt.Errorf("insert sentence: %w", err) + // ★ 原句块:sentences 表退场后,原句由块承载。 + // + // 它必须在**任何分支之外**建 —— 否则「sentence 行插入失败」 + // 会连带丢掉原句块,而那句原句本身是有效记忆。 + // 判据 zz_blockcommit_test.go 的②盯的就是这条。 + // ★ 同样留空时间:原句块的时序由迁移/蒸馏显式指定, + // 这里写 now 会覆盖既有值(实测抹平了迁移块的时序)。 + if err := putBlockTx(tx, NewSentenceBlock(t.SentenceText, + time.Time{}, time.Time{})); err != nil { + return nil, 0, 0, fmt.Errorf("put sentence block: %w", err) } - err = tx.QueryRow("SELECT id FROM sentences WHERE text = ?", t.SentenceText).Scan(&sentenceID) - if err != nil { - sentenceID = 0 - } else if sentenceIDs != nil { - sentenceIDs[t.SentenceText] = sentenceID + sentenceBlockID = SentenceBlockID(t.SentenceText) + if sentenceIDs != nil { + // ★ 返回原句块 ID 而非 sentences 行号(见 CommitWithMedia 注释) + sentenceIDs[t.SentenceText] = sentenceBlockID } + + // ★★ 旧表 sentences 双写已停(2026-10-04) + // + // 原句改由**原句块**承载(上方 putBlockTx), + // 场景引用已改用块 ID(13c3292),不再需要 sentences.id。 + // 保留旧表写入只会让它持续增长,退场就永远看不到完成信号。 } - var existing int64 - err = tx.QueryRow( - `SELECT id FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`, - sourceID, targetID, t.Relation, sessionID, - ).Scan(&existing) - var relID int64 - if err == sql.ErrNoRows { - res, ierr := tx.Exec( - `INSERT INTO relations (source_id, target_id, relation_type, confidence, session_id, turn_id, date_bucket, sentence_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID, - ) - if ierr != nil { - return nil, 0, 0, ierr - } - relID, _ = res.LastInsertId() - relationsCreated++ - } else if err != nil { - return nil, 0, 0, err - } else { - relID = existing - // 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数 - _, err = tx.Exec( - `UPDATE relations SET confidence = ?, updated_at = CURRENT_TIMESTAMP - WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`, - confidence, sourceID, targetID, t.Relation, sessionID, - ) - if err != nil { - return nil, 0, 0, err - } - } + // ★★ 旧表 relations 双写已停(2026-10-04) + // + // 关系改由**关系边**承载,场景引用已改用 edge ID。 + // 读方已全部切块(scene / Purge / RecallSorted / Introspect / + // MergeBlocks / social / sdk / core),旧表只增不减会让 + // ① 退场永远看不到完成信号 ② 迁移报告的「旧表还剩多少」永不归零。 + // + // ★ 判断依据是**行为**不是语句:判据 Test停双写_旧表不再增长 + // 逐表比对 Commit 前后的行数。 + relationsCreated = 0 - // 场景引用:写完关系立即把「关系 + 两端实体」挂到**每个**场景上 + // 场景引用:写完关系立即把「关系 + 两端块」挂到**每个**场景上 // (主动声明的 + 被动涌现的)。同一事务内完成,避免出现「关系写进去了 // 但场景引用丢了」——那会让这条记忆在后来的场景里永远召不回来,且无声无息。 - if relID != 0 { + // + // ★★ 端点已改为**块**(2026-10-04) + // + // 旧实现传 relationID(relations.id)与两个 entityID, + // 于是 Commit 每写一条关系就在 scene_refs 里生成 + // kind='relation' + kind='entity' 的引用 —— + // 而 entities/relations 退场后这些引用全部悬空, + // 且场景式记忆会指向不存在的对象。 + // + // 现在传块 ID:两端 kind='block',关系边本身 kind='edge'。 + if srcBlockID != "" || dstBlockID != "" { + // ★★ 原句回溯:原句块 --contains--> 关系边 + // + // 旧表靠 relations.sentence_id 外键取原句。sentences 退场后 + // 这个外键没有了 —— 而**带条件的规则本体长在句子里**, + // 只给关系名等于没召回(判据「场景召回必须带原句」盯的就是这条)。 + // + // ★ 方向:原句块 --contains--> 关系边。 + // 从原句出发能顺着 contains 找到关系,从关系能逆向找到原句 + // (BFS 是双向的)。反向(原句块→关系)才是对的: + // 「这句里说了什么」是块→边的方向。 + // + // ★ 含时间才能做时序仲裁。 + if sentenceBlockID != "" && edgeID != 0 { + if err := addContainsEdgeTx(tx, sentenceBlockID, edgeID, + confidence); err != nil { + return nil, 0, 0, fmt.Errorf("contains %s->%d: %w", + sentenceBlockID, edgeID, err) + } + } for _, sc := range effectiveScenes(t) { - if err := tagSceneTx(tx, sc, relID, []int64{sourceID, targetID}, confidence); err != nil { + if err := tagSceneTripleTx(tx, sc, srcBlockID, dstBlockID, + edgeID, confidence); err != nil { return nil, 0, 0, err } } @@ -556,13 +725,17 @@ func validEntityName(name string) bool { if len(r) < 2 || len(r) > 50 { return false } - hasLetter := false + // 纯数字串是合法实体值:端口/分机/编号这类属性值的本体就是数字。 + // 此前把它拒掉导致 commit 静默丢弃(0 写入假成功回执,实测 + // 2026-10-01 跑分:metrics 端口 8328 / 分机 4379→4324 都因此丢失)。 + // 但仍禁纯标点/空白串。 + hasLetterOrDigit := false for _, ch := range r { - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '\u4e00' && ch <= '\u9fff') || ch == '-' || ch == '_' { - hasLetter = true + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '\u4e00' && ch <= '\u9fff') || ch == '-' || ch == '_' || (ch >= '0' && ch <= '9') { + hasLetterOrDigit = true } } - return hasLetter + return hasLetterOrDigit } // upsertEntity 写入/刷新一个实体,返回 1 表示该实体**通过名校验并被写入或刷新**, @@ -597,7 +770,56 @@ type RecallResult struct { Relations []Relation `json:"relations"` } +// SortMode 决定召回结果的呈现顺序。 +type SortMode string + +const ( + // SortRelevance:按「实词命中层级 → 提及次数 → 名字长度」排序。 + // 回答「某个具体东西是什么/是多少」用这个 —— 实词精确命中最可信。 + SortRelevance SortMode = "relevance" + // SortRecent:按更新时间倒序,同时间按提及次数。 + // 回答「最近/最新/现在是什么」用这个 —— 运维场景里答案常常是 + // 「新值覆盖旧值」(实测 v4 的 overwrite 组就是考这个), + // 相关性排序会把旧的同名实体排在新值前面。 + SortRecent SortMode = "recent" +) + +// ParseSortMode 解析排序模式;空或无法识别时返回默认(相关性)。 +// +// 刻意不接受任何"看起来像"的字符串:这里只服务显式入参, +// 认错会让调用方以为自己按时间排序了、实际拿到相关性顺序。 +func ParseSortMode(s string) SortMode { + switch strings.ToLower(strings.TrimSpace(s)) { + case "recent", "time", "newest": + return SortRecent + default: + return SortRelevance + } +} + +// Recall 保持原签名(相关性排序),委托给 RecallSorted。 func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) { + // ★ 不传 fingerprint:Recall 是**库层**接口(social / indexer / SDK 都用), + // 它不知道当前向量空间是什么 —— 那是 core 层的关注点。 + // 需要空间隔离的路径(memory_recall 工具)走 RecallSorted 并显式传。 + return g.RecallSorted(keywords, seedEntities, depth, sessionFilter, "", SortRelevance) +} + +// RecallSorted 是可指定呈现顺序的召回。 +// +// 截断在**出口**做(深度扩展之后),不是深度扩展之前 —— 扩展会从种子实体 +// 带出新的邻居实体,扩展前截断会让总量再次越界(实测 47 > 20)。 +// ★ fingerprint 是**向量空间隔离键**(2026-10-04): +// +// 非空时只返回 fingerprint 匹配的块。 +// +// RecallSorted 是**纯词法**召回(不碰向量),但它不能因此绕过隔离 —— +// 它是 recallByBlocks 失败后的兜底路,一旦 fallback 到它, +// 「换向量空间后旧块不能污染结果」这条约束就被绕过了。 +// +// 判据 TestMemoryRecall_指纹不匹配的块被跳过 当场抓到这一点: +// 块路因无空间被跳过 → 走兜底 → 兜底把旧空间的块原样返回。 +func (g *GraphDB) RecallSorted(keywords []string, seedEntities []string, depth int, sessionFilter, fingerprint string, mode SortMode) (*RecallResult, error) { g.mu.RLock() defer g.mu.RUnlock() @@ -606,38 +828,61 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se if len(keywords) == 0 && len(seedEntities) == 0 { // 全量读取仅用于内部整备(Indexer.Sync / 实体合并检测), // 必须加限额:无 LIMIT 时大图会被整表读进内存。 + // ★★★ 改走块/边体系(2026-10-04) + // + // 旧实现读 entities + relations。旧表退场后这里返回空 —— + // 而这条路径是 Indexer.Sync 的数据源(建向量索引)与 + // 实体合并检测的唯一入口。它空了,整备就是空转: + // 索引里一条向量都没有,而没有任何报错。 + // + // 块侧口径:按 created_at **升序**(旧的是 mention_count DESC 的逆序, + // 但全量读取只用于建索引,顺序不影响正确性;命中上限时 + // 优先取最早的,因为它们更可能是稳定的基础事实)。 rows, err := g.db.Query( - `SELECT id, name, type, mention_count, created_at, updated_at - FROM entities ORDER BY mention_count DESC LIMIT ?`, - maxFullRecallEntities, + `SELECT `+blockColumns+` FROM memory_blocks + WHERE text_content != '' AND (? = '' OR fingerprint = ?) + ORDER BY created_at ASC, id ASC LIMIT ?`, + fingerprint, fingerprint, maxFullRecallEntities, ) if err != nil { return nil, err } defer rows.Close() + var seq int64 for rows.Next() { - var e Entity - if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + b, err := scanBlockRow(rows) + if err != nil { return nil, err } - result.Entities = append(result.Entities, e) + seq++ + result.Entities = append(result.Entities, Entity{ + ID: seq, // 占位:块 ID 是字符串,Entity.ID 是 int64 + Name: b.Text, + Type: blockSemanticType(b), + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + }) } if len(result.Entities) >= maxFullRecallEntities { log.Printf("[graph] full recall 命中实体上限 %d,可能有实体未纳入", maxFullRecallEntities) } relRows, err := g.db.Query( - `SELECT r.id, r.source_id, r.target_id, e1.name, e2.name, - r.relation_type, r.confidence, r.status, r.session_id, - r.turn_id, r.created_at, COALESCE(r.date_bucket, ''), - COALESCE(r.sentence_id, 0), COALESCE(s.text, '') - FROM relations r - JOIN entities e1 ON r.source_id = e1.id - JOIN entities e2 ON r.target_id = e2.id - LEFT JOIN sentences s ON r.sentence_id = s.id - WHERE r.status = 'active' - ORDER BY r.created_at DESC LIMIT 30`, + `SELECT e.id, e.source_id, e.target_id, sb.text_content, tb.text_content, + e.edge_type, COALESCE(e.confidence, 0), COALESCE(e.status, ''), + COALESCE(e.session_id, ''), COALESCE(e.turn_id, 0), e.created_at, + COALESCE((SELECT b.text_content FROM memory_block_edges c + JOIN memory_blocks b ON b.id = c.source_id + WHERE c.target_kind = 'edge' AND c.target_id = e.id + AND c.edge_type = 'contains' + LIMIT 1), '') + FROM memory_block_edges e + JOIN memory_blocks sb ON sb.id = e.source_id + JOIN memory_blocks tb ON tb.id = e.target_id + WHERE e.source_kind = 'block' AND e.target_kind = 'block' + AND COALESCE(e.status, '') = 'active' + ORDER BY e.created_at DESC LIMIT 30`, ) if err != nil { return nil, err @@ -645,10 +890,10 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se defer relRows.Close() for relRows.Next() { var rel Relation - if err := relRows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID, + if err := relRows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID, &rel.SourceName, &rel.TargetName, &rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID, - &rel.TurnID, &rel.CreatedAt, &rel.DateBucket, &rel.SentenceID, &rel.SentenceText); err != nil { + &rel.TurnID, &rel.CreatedAt, &rel.SentenceText); err != nil { return nil, err } result.Relations = append(result.Relations, rel) @@ -657,120 +902,244 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se return result, nil } - entityIDs := make(map[int64]bool) + // ══════════════════════════════════════════════════════════ + // 第二分支:关键词 / 种子实体 —— 已改块/边体系(2026-10-04) + // + // ★ 这一整块是「int64 实体 ID → 旧表查询」的闭环: + // 查 entities 拿 ID → 用 ID 查 relations → 再用关系两端 ID 查 entities。 + // 块体系里端点是**字符串块 ID**,所以 entityIDs 换成 map[string]bool, + // 三处查询全部重写。 + // + // ★ 它服务的调用方(全部经由 db.Recall): + // social(人物/特质/关系) ← 本次改动的直接受害者 + // indexer(全量,已在前一分支改完) + // light_memory(透传) + // toolcall(旧路兜底) + // + // ★★ 这次改动的实测后果(social 3 个测试当场变红): + // 写侧(Purge)已切块、读侧(这里)还没切 ⇒ 软删的边在旧表里 + // 仍是 active ⇒ 召回照样返回它。**混合态比全旧态更危险**, + // 因为它看起来是「部分成功」。 + // ══════════════════════════════════════════════════════════ + + // entityIDs 的键从 int64 变成**块 ID 字符串**。 + var bScratch MemoryBlock + var vecJSON string + var sceneNull sql.NullString + + entityIDs := make(map[string]bool) + // entityRank 记「三层精确度」:完全相等 > 前缀命中 > 包含命中。 + entityRank := make(map[string]int) + seq := 0 for _, kw := range keywords { - // 相关度排序 + 限额。 + if kw == "" { + continue + } + // ★ mention_count 已无处可取(旧表才有),用 created_at 兜底排序: + // 先出现的块更可能是稳定的基础事实。 + // ★ rank 放在 SELECT 的**第一列**,而不是最后。 // - // 此前这里既没有 ORDER BY 也没有 LIMIT:拿回来的顺序就是建表顺序 - // (rowid 升序),于是注入进 prompt 的"前 5 个实体"是**最早创建的**, - // 越新越准的记忆越排后面被截掉(实测:输入「QQ回复格式」命中 148 个, - // 规则实体排第 32,前 5 里根本没有它)。 - // - // 相关度分三层:完全相等 > 前缀命中 > 包含命中;同层按提及次数、 - // 再按名字长度(短名更可能是实体本身而不是长描述)。 + // 之前放在 blockColumns 之后 ⇒ scanBlockRow 只吃 15 列, + // 多出来的那列无人扫 ⇒ 「expected 16 destination arguments in Scan, + // not 15」。这类错只在真跑 SQL 时暴露,编译期完全看不出来。 rows, err := g.db.Query( - `SELECT id, name, type, mention_count, created_at, updated_at - FROM entities WHERE LOWER(name) LIKE ? + `SELECT CASE + WHEN LOWER(text_content) = LOWER(?) THEN 0 + WHEN LOWER(text_content) LIKE LOWER(?) || '%' THEN 1 + ELSE 2 END, `+blockColumns+` + FROM memory_blocks + WHERE text_content != '' AND LOWER(text_content) LIKE ? + AND (? = '' OR fingerprint = ?) ORDER BY CASE - WHEN LOWER(name) = LOWER(?) THEN 0 - WHEN LOWER(name) LIKE LOWER(?) || '%' THEN 1 - ELSE 2 END, - mention_count DESC, LENGTH(name) ASC + WHEN LOWER(text_content) = LOWER(?) THEN 0 + WHEN LOWER(text_content) LIKE LOWER(?) || '%' THEN 1 + ELSE 2 END, + created_at DESC, LENGTH(text_content) ASC LIMIT ?`, - "%"+kw+"%", kw, kw, maxKeywordEntities, + kw, kw, "%"+strings.ToLower(kw)+"%", + fingerprint, fingerprint, kw, kw, maxKeywordEntities, ) if err != nil { return nil, err } for rows.Next() { - var e Entity - if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + var rank int + // rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。 + if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality, + &bScratch.Text, &bScratch.PayloadDigest, &bScratch.MIME, + &bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON, + &bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool, + &sceneNull, &bScratch.SemanticType, + &bScratch.CreatedAt, &bScratch.UpdatedAt); err != nil { rows.Close() return nil, err } - if !entityIDs[e.ID] { - entityIDs[e.ID] = true + b := bScratch + seq++ + e := Entity{ + ID: int64(seq), + Name: b.Text, + Type: blockSemanticType(b), + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + } + if prev, ok := entityRank[b.ID]; !ok || rank < prev { + entityRank[b.ID] = rank + } + if !entityIDs[b.ID] { + entityIDs[b.ID] = true + e.blockKey = b.ID result.Entities = append(result.Entities, e) } } rows.Close() } + // 种子实体:按名**精确**命中(不区分大小写,与旧实现一致)。 for _, se := range seedEntities { - row := g.db.QueryRow( - `SELECT id, name, type, mention_count, created_at, updated_at - FROM entities WHERE name = ?`, se) - var e Entity - if err := row.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err == nil { - if !entityIDs[e.ID] { - entityIDs[e.ID] = true - result.Entities = append(result.Entities, e) + if se == "" { + continue + } + rows, err := g.db.Query( + `SELECT `+blockColumns+` FROM memory_blocks + WHERE text_content = ? AND (? = '' OR fingerprint = ?) LIMIT 1`, + se, fingerprint, fingerprint) + if err != nil { + return nil, err + } + for rows.Next() { + b, err := scanBlockRow(rows) + if err != nil { + rows.Close() + return nil, err + } + if !entityIDs[b.ID] { + seq++ + entityIDs[b.ID] = true + entityRank[b.ID] = 0 + result.Entities = append(result.Entities, Entity{ + ID: int64(seq), + Name: b.Text, + Type: blockSemanticType(b), + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + blockKey: b.ID, + }) } } + rows.Close() } if len(entityIDs) == 0 { return result, nil } - // seenRel 跨层去重。 + // ★ 全局上限:跨关键词累加后按「三层精确度」截断。 // - // 每层都用**已累积的** entityIDs 查邻接关系,因此上一层刚产出、以及 - // 两个已访问实体之间的关系会在下一层被重复查回并再次 append。 - // 深度 2、稠密图上重复会淹没 memory_recall 的 10 条关系预算—— - // 模型看到的是同一句话刷屏,真正的新关系被截断。 + // 单个关键词的 LIMIT(maxKeywordEntities) 管不住总量 —— 多个关键词各召回 + // 一批会累加,实测 3 个关键词就能堆到 193 个实体(13744 tokens)。 + // 这里按三层精确度(完全相等 > 前缀命中 > 包含命中)跨关键词归并后截断。 + seenRel := make(map[int64]bool) + // ★★ 深度扩展 + // + // 旧实现每层用「已累积的 entityIDs」查邻接关系 —— + // ★★ 那个设计是错的:entityIDs 累积的是**实体**, + // 而一层扩展的正确语义是「上一层新发现的实体的邻居」。 + // 用累积集会让第 2 层把第 0 层见过的实体再查一遍, + // 于是深度形同虚设(判据 TestRecallSorted_深度扩展 抓到: + // depth=2 拿不到「丙」,而甲→乙→丙→丁 是 3 跳链)。 + // + // 现在:frontier = 上一层新发现的块;每层只从 frontier 扩展。 + // prevFrontier 是本层的扩展起点;每轮结束时被 newIDs 替换。 + prevFrontier := entityIDs for depthLevel := 0; depthLevel < depth; depthLevel++ { - ids := make([]interface{}, 0, len(entityIDs)) - for id := range entityIDs { + if len(prevFrontier) == 0 { + break + } + // ★ 起点集合必须是「**本层之前**新发现的块」,不是 entityIDs 全集。 + // + // 我先写成 entityIDs(累积集),那等于「每层从所有已知点重新扩一遍」—— + // 深度形同虚设,而且第一层就把 2 跳邻居全拉进来了。 + // + // prevFrontier 由上一轮的 newIDs 赋值;depthLevel==0 时用种子命中集。 + frontier := prevFrontier + + ids := make([]interface{}, 0, len(frontier)) + for id := range frontier { ids = append(ids, id) } - if len(ids) == 0 { break } - query := fmt.Sprintf( - `SELECT r.id, r.source_id, r.target_id, e1.name, e2.name, - r.relation_type, r.confidence, r.status, r.session_id, - r.turn_id, r.created_at, COALESCE(r.date_bucket, ''), - COALESCE(r.sentence_id, 0), COALESCE(s.text, '') - FROM relations r - JOIN entities e1 ON r.source_id = e1.id - JOIN entities e2 ON r.target_id = e2.id - LEFT JOIN sentences s ON r.sentence_id = s.id - WHERE (r.source_id IN (%s) OR r.target_id IN (%s)) - AND r.status = 'active'`, - placeholders(len(ids)), - placeholders(len(ids)), - ) - allIDs := append(ids, ids...) - + ph := placeholders(len(ids)) + relQuery := fmt.Sprintf( + `SELECT e.id, e.source_id, e.target_id, sb.text_content, tb.text_content, + e.edge_type, COALESCE(e.confidence,0), COALESCE(e.status,''), + COALESCE(e.session_id,''), COALESCE(e.turn_id,0), e.created_at, + COALESCE((SELECT b.text_content FROM memory_block_edges c + JOIN memory_blocks b ON b.id = c.source_id + WHERE c.target_kind='edge' AND c.target_id = e.id + AND c.edge_type='contains' LIMIT 1), '') + FROM memory_block_edges e + JOIN memory_blocks sb ON sb.id = e.source_id + JOIN memory_blocks tb ON tb.id = e.target_id + WHERE (e.source_id IN (%s) OR e.target_id IN (%s)) + AND e.source_kind='block' AND e.target_kind='block' + AND e.edge_type != 'contains' + AND COALESCE(e.status,'') = 'active'`, ph, ph) + args := append(append([]interface{}{}, ids...), ids...) if sessionFilter != "" { - query += " AND r.session_id = ?" - allIDs = append(allIDs, sessionFilter) + relQuery += " AND COALESCE(e.session_id,'') = ?" + args = append(args, sessionFilter) + } + // ★★ 排序口径必须交给 sortRecallRelations,不能在 SQL 里预设(2026-10-04) + // + // 我写了 `ORDER BY e.created_at DESC`,看起来无害 —— + // 实际上它让**两种 SortMode 的结果完全相同**: + // sortRecallRelations 对 SortRelevance 直接 return(不排序), + // 于是「相关性」模式实际拿到的是「时间倒序」。 + // + // 判据 TestRecall_时间倒序_变异_相关性模式顺序不同 当场抓住: + // 它是**变异自证** —— 断言「相关性模式下顺序必须不同于时间模式」, + // 一旦两者相同就说明上一个测试的判据不可信。 + // + // ★★ SortRelevance 需要自己的顺序依据(2026-10-04) + // + // sortRecallRelations 对 SortRelevance 直接 return —— 前提是 + // 「SQL 已按种子实体的相关性排过」。 + // + // ★ 那个前提在我改写时不成立:无 ORDER BY 时 SQLite 按 rowid 走, + // 而 rowid = 插入顺序 = 时间顺序 ⇒ + // 相关性模式实际拿到的是时间倒序,两种 SortMode 完全同序。 + // + // 判据(变异自证)当场抓住:它断言「相关性模式下顺序必须不同于 + // 时间模式」,两者一相同就说明上一个测试的判据不可信。 + // + // 相关性的可用信号只有 confidence(边自带),用它兜底: + // 同分时保持插入序,于是「高置信在前」可区分于「新的在前」。 + if mode != SortRecent { + relQuery += " ORDER BY e.confidence DESC, e.id ASC" + } else { + relQuery += " ORDER BY e.created_at DESC, e.turn_id DESC, e.id DESC" } - // 每层限额:热实体("文档"这类)的邻接可能是上千条,无上限时每层都 - // 整片读进内存,而调用方(memory_recall 注入 10 条、自动注入只要实体名) - // 根本用不到。按置信度取最相关的一批。 - query += " ORDER BY r.confidence DESC, r.updated_at DESC LIMIT ?" - allIDs = append(allIDs, maxAdjacentRelations) - relRows, err := g.db.Query(query, allIDs...) + relRows, err := g.db.Query(relQuery, args...) if err != nil { return nil, err } - newIDs := make(map[int64]bool) + newIDs := make(map[string]bool) for relRows.Next() { var rel Relation - if err := relRows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID, + if err := relRows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID, &rel.SourceName, &rel.TargetName, &rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID, - &rel.TurnID, &rel.CreatedAt, &rel.DateBucket, &rel.SentenceID, &rel.SentenceText); err != nil { + &rel.TurnID, &rel.CreatedAt, &rel.SentenceText); err != nil { relRows.Close() return nil, err } @@ -778,12 +1147,11 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se seenRel[rel.ID] = true result.Relations = append(result.Relations, rel) } - - if !entityIDs[rel.SourceID] { - newIDs[rel.SourceID] = true + if !entityIDs[rel.SourceBlockID] { + newIDs[rel.SourceBlockID] = true } - if !entityIDs[rel.TargetID] { - newIDs[rel.TargetID] = true + if !entityIDs[rel.TargetBlockID] { + newIDs[rel.TargetBlockID] = true } } relRows.Close() @@ -791,39 +1159,65 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se if len(newIDs) == 0 { break } + // ★ 下一层从这批新块出发 —— 这是「深度」真正生效的地方。 + prevFrontier = newIDs + // 取出新实体的块内容 —— 块 ID 就是主键,直接查一次。 ids2 := make([]interface{}, 0, len(newIDs)) for id := range newIDs { ids2 = append(ids2, id) } - eRows, err := g.db.Query( - fmt.Sprintf( - `SELECT id, name, type, mention_count, created_at, updated_at - FROM entities WHERE id IN (%s)`, placeholders(len(ids2))), + fmt.Sprintf(`SELECT `+blockColumns+` FROM memory_blocks WHERE id IN (%s)`, + placeholders(len(ids2))), ids2..., ) if err != nil { return nil, err } - for eRows.Next() { - var e Entity - if err := eRows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + b, err := scanBlockRow(eRows) + if err != nil { eRows.Close() return nil, err } - if !entityIDs[e.ID] { - entityIDs[e.ID] = true - result.Entities = append(result.Entities, e) + if !entityIDs[b.ID] { + seq++ + entityIDs[b.ID] = true + entityRank[b.ID] = 3 // 深度扩展来的,精度最低 + result.Entities = append(result.Entities, Entity{ + ID: int64(seq), + Name: b.Text, + Type: blockSemanticType(b), + CreatedAt: b.CreatedAt, + UpdatedAt: b.UpdatedAt, + blockKey: b.ID, + }) } } eRows.Close() + } - for id := range newIDs { - entityIDs[id] = true + // ★ 出口排序 + 全局截断(在深度扩展之后)。 + // + // 深度扩展会从种子实体带出新的邻居实体,所以上限必须在出口施加。 + // ★ entityRank 的键要从块 ID 映射回 Entity.ID: + // sortRecallEntities 按 ents[i].ID 建索引,而 Entity.ID 现在是 + // 「本次召回内的序号」(块 ID 是字符串,放不进 int64)。 + // 序号在本次调用内唯一,所以映射是一一对应的。 + rankBySeq := make(map[int64]int, len(result.Entities)) + for i := range result.Entities { + if r, ok := entityRank[result.Entities[i].blockKey]; ok { + rankBySeq[result.Entities[i].ID] = r } } + sortRecallEntities(result.Entities, rankBySeq, keywords, mode) + if len(result.Entities) > maxRecallEntities { + dropped := len(result.Entities) - maxRecallEntities + result.Entities = result.Entities[:maxRecallEntities] + log.Printf("[graph] recall: 截断 %d 个实体(全局上限 %d)", dropped, maxRecallEntities) + } + sortRecallRelations(result.Relations, mode) return result, nil } @@ -840,12 +1234,31 @@ func (g *GraphDB) ExportTriples(limit int) ([]Triple, error) { g.mu.RLock() defer g.mu.RUnlock() - q := `SELECT s.name, r.relation_type, t.name, r.confidence - FROM relations r - JOIN entities s ON s.id = r.source_id - JOIN entities t ON t.id = r.target_id - WHERE r.status = 'active' - ORDER BY r.id` + // ★★★ 改走块/边体系(2026-10-04) + // + // 原实现读 relations JOIN entities。旧表停双写后这两张表**不再增长**, + // 而子 agent 的 temp 库恰恰是靠 Commit 写入的 —— + // ⇒ ExportTriples 返回空 ⇒ 回收(ReclaimResident)合入 0 条 + // ⇒ **驻留子 agent 的记忆回收功能静默失效**。 + // + // 判据:TestResident_ContextFullAndDispositions ④ + // 「回收应把选中的 temp 记录合入主记忆」当场变红。 + // + // ★ 这也说明「零调用点的读方」不等于「不重要的读方」: + // ExportTriples 只被 resident.go 用,而 resident 是一条完整产品线。 + q := `SELECT sb.text_content, e.edge_type, tb.text_content, + COALESCE(e.confidence, 0), + COALESCE((SELECT b.text_content FROM memory_block_edges c + JOIN memory_blocks b ON b.id = c.source_id + WHERE c.target_kind = 'edge' AND c.target_id = e.id + AND c.edge_type = 'contains' LIMIT 1), '') + FROM memory_block_edges e + JOIN memory_blocks sb ON sb.id = e.source_id + JOIN memory_blocks tb ON tb.id = e.target_id + WHERE e.source_kind = 'block' AND e.target_kind = 'block' + AND e.edge_type != 'contains' + AND COALESCE(e.status, '') = 'active' + ORDER BY e.id` args := []interface{}{} if limit > 0 { q += " LIMIT ?" @@ -860,7 +1273,8 @@ func (g *GraphDB) ExportTriples(limit int) ([]Triple, error) { var out []Triple for rows.Next() { var tr Triple - if err := rows.Scan(&tr.Subject, &tr.Relation, &tr.Object, &tr.Confidence); err != nil { + if err := rows.Scan(&tr.Subject, &tr.Relation, &tr.Object, + &tr.Confidence, &tr.SentenceText); err != nil { return nil, err } out = append(out, tr) @@ -872,92 +1286,118 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { g.mu.Lock() defer g.mu.Unlock() - conds := []string{"status = 'active'"} + // ★★★ 改走块/边体系(2026-10-04) + // + // 旧实现查 entities 拿 id、拼 relations 的 where、删/软删 relations, + // 最后 purgeStaleSceneRefsLocked。旧表退场后这条路全断 —— + // 而 Purge 有 9 处调用方(webui / healthcheck / SDK / toolcall×2 / + // social×2 / lua / proc),它是**记忆删除的主路径**: + // 用户说「忘掉这条」而它删不动,等于没有删除功能。 + // + // 块体系里的等价物: + // + // subject_contains → 按块文本 LIKE 找源块 ID + // target_contains → 按块文本 LIKE 找目标块 ID + // relation_type → edge_type + // session_id → session_id + // + // ★ 端点是**块 ID 字符串**,所以 IN 列表的参数类型从 int64 变 string。 + // ★ status 口径:软删写 'deleted';硬删直接 DELETE。 + // 两者都紧跟 purgeStaleSceneRefsLocked —— 否则场景里会挂一条 + // 永远召不回的幽灵,而 SceneStats 还照样把它算进去。 + + if _, ok := criteria["subject_contains"]; !ok { + if _, ok := criteria["target_contains"]; !ok { + if _, ok := criteria["relation_type"]; !ok { + if _, ok := criteria["session_id"]; !ok { + return 0, fmt.Errorf("no criteria provided") + } + } + } + } + + // 收集要匹配的端点块 ID(可能同时来自 subject 与 target) + var endpointConds []string + var endpointArgs []interface{} + + for _, spec := range []struct{ key, col string }{ + {"subject_contains", "source_id"}, + {"target_contains", "target_id"}, + } { + v, ok := criteria[spec.key] + if !ok { + continue + } + blocks, err := g.blocksByTextLikeTx(v) + if err != nil { + return 0, err + } + if len(blocks) == 0 { + // ★ 匹配不到块 ⇒ 没有任何边可删。 + // 返回 0 而不是构造恒假条件 —— 后者会让「删 0 条」 + // 与「条件写错了」在返回值上无法区分。 + return 0, nil + } + ids := make([]interface{}, 0, len(blocks)) + for _, b := range blocks { + ids = append(ids, b.ID) + } + endpointConds = append(endpointConds, + fmt.Sprintf("%s IN (%s)", spec.col, placeholders(len(ids)))) + endpointArgs = append(endpointArgs, ids...) + } + + conds := []string{"source_kind = 'block'", "target_kind = 'block'"} args := []interface{}{} - - if v, ok := criteria["subject_contains"]; ok { - rows, err := g.db.Query("SELECT id FROM entities WHERE name LIKE ?", "%"+v+"%") - if err != nil { - return 0, err - } - var ids []interface{} - for rows.Next() { - var id int64 - rows.Scan(&id) - ids = append(ids, id) - } - rows.Close() - if len(ids) > 0 { - conds = append(conds, fmt.Sprintf("source_id IN (%s)", placeholders(len(ids)))) - args = append(args, ids...) - } - } - - if v, ok := criteria["target_contains"]; ok { - rows, err := g.db.Query("SELECT id FROM entities WHERE name LIKE ?", "%"+v+"%") - if err != nil { - return 0, err - } - var ids []interface{} - for rows.Next() { - var id int64 - rows.Scan(&id) - ids = append(ids, id) - } - rows.Close() - if len(ids) > 0 { - conds = append(conds, fmt.Sprintf("target_id IN (%s)", placeholders(len(ids)))) - args = append(args, ids...) - } - } + conds = append(conds, endpointConds...) + args = append(args, endpointArgs...) if v, ok := criteria["relation_type"]; ok { - conds = append(conds, "relation_type = ?") + conds = append(conds, "edge_type = ?") args = append(args, v) } - if v, ok := criteria["session_id"]; ok { - conds = append(conds, "session_id = ?") + conds = append(conds, "COALESCE(session_id, '') = ?") args = append(args, v) } - if len(conds) == 1 { - return 0, fmt.Errorf("no criteria provided") + // ★ 只有「仅按 session/relation_type 删」时不限定 status; + // 带端点条件时也限定 active —— 否则重复调用会反复命中已软删的行, + // 计数虚高而实际什么都没删。 + if _, hasEndpoint := criteria["subject_contains"]; hasEndpoint { + conds = append(conds, "COALESCE(status, '') != 'deleted'") + } else if _, hasEndpoint := criteria["target_contains"]; hasEndpoint { + conds = append(conds, "COALESCE(status, '') != 'deleted'") } - where := "" - for i, c := range conds { - if i == 0 { - where = c - } else { - where += " AND " + c - } - } + where := strings.Join(conds, " AND ") if mode == "hard" { result, err := g.db.Exec( - fmt.Sprintf(`DELETE FROM relations WHERE %s`, where), args...) + fmt.Sprintf(`DELETE FROM memory_block_edges WHERE %s`, where), args...) if err != nil { return 0, err } n, _ := result.RowsAffected() - // 这里**不再**顺手全局删孤儿实体。 + // ★ 这里**不再**顺手全局删孤儿块。 // - // 原来那句 `DELETE FROM entities WHERE id NOT IN (relations 两端)` 是与 - // 调用方意图无关的全局副作用:memory_edit 只想去掉一条关系,却可能把 - // 图里其它孤零零的实体一并清掉。孤儿清理交给 PurgeOrphans - // (显式、可 dry-run、有计数与审计),一次改动只做一件事。 + // 与旧实现同一条纪律:孤儿清理是独立意图, + // 混进删除路径会让「删一条边」清掉全图的孤块。 // - // 关系没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、 + // 边没了,它的场景引用必须跟着对齐:残留引用会让场景看着很大、 // 召回却是空的(SceneStats 也跟着说谎)。 g.purgeStaleSceneRefsLocked() return int(n), nil } + // ★ 不能写 updated_at:memory_block_edges **没有**这一列 + // (升格成独立边时只加了 confidence/session/turn/status/merged_into)。 + // 旧 relations 表有,所以这个字段是照抄过来的 —— + // 一旦旧表删掉、这里不改,Purge 软删会直接报 no such column。 result, err := g.db.Exec( - fmt.Sprintf(`UPDATE relations SET status = 'deleted', updated_at = CURRENT_TIMESTAMP WHERE %s`, where), + fmt.Sprintf(`UPDATE memory_block_edges SET status = 'deleted' WHERE %s`, where), args..., ) if err != nil { @@ -970,6 +1410,34 @@ func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) { return int(n), nil } +// blocksByTextLikeTx 按文本子串取块(调用方已持锁)。 +// +// 与 BlockTextsLike 同语义,供**已持锁**的删除路径复用 —— +// 持锁路径不能调 BlockTextsLike(它自己要拿锁)。 +func (g *GraphDB) blocksByTextLikeTx(fragment string) ([]MemoryBlock, error) { + fragment = strings.TrimSpace(fragment) + if fragment == "" { + return nil, nil + } + rows, err := g.db.Query( + `SELECT `+blockColumns+` FROM memory_blocks + WHERE text_content LIKE ? ESCAPE '\' ORDER BY id`, + "%"+escapeLike(fragment)+"%") + if err != nil { + return nil, err + } + defer rows.Close() + var out []MemoryBlock + for rows.Next() { + b, err := scanBlockRow(rows) + if err != nil { + return nil, err + } + out = append(out, b) + } + return out, rows.Err() +} + func (g *GraphDB) GraphData() (map[string]interface{}, error) { g.mu.RLock() defer g.mu.RUnlock() @@ -1029,8 +1497,7 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) { return nil, err } - brows, err := g.db.Query(`SELECT id, modality, text_content, payload_digest, mime, - size, width, height, vector, fingerprint, source, tool, created_at, updated_at + brows, err := g.db.Query(`SELECT ` + blockColumns + ` FROM memory_blocks ORDER BY created_at, id`) if err != nil { return nil, err @@ -1040,9 +1507,13 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) { for brows.Next() { var block MemoryBlock var vectorJSON string + // ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了, + // Scan 却没跟上)—— 与 semantic_type 是同一类错, + // 报错形如「expected 16 destination arguments in Scan, not 14」。 if err := brows.Scan(&block.ID, &block.Modality, &block.Text, &block.PayloadDigest, &block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON, - &block.Fingerprint, &block.Source, &block.Tool, &block.CreatedAt, + &block.Fingerprint, &block.Source, &block.Tool, &block.Scene, + &block.SemanticType, &block.CreatedAt, &block.UpdatedAt); err != nil { return nil, err } @@ -1084,17 +1555,105 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) { }, nil } +// CountSentences 返回 sentences 表的行数(只读)。 +// +// ★ 用途:方案 A 的退场判据 +// ------------------------ +// 蒸馏已不写该表(WritePayload 改用原句块承载原句), +// 而迁移仍会写它(MigrateLegacyTextEntities 建 sentence→block 边)。 +// 所以「sentences 是否清零」是判断退场是否完成的可观察信号 —— +// 给它一个明确的只读接口,而不是让调用方猜 SQL 或翻 schema。 +func (g *GraphDB) CountSentences() (int, error) { + g.mu.RLock() + defer g.mu.RUnlock() + var n int + err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences`).Scan(&n) + return n, err +} + func (g *GraphDB) Introspect() (map[string]interface{}, error) { g.mu.RLock() defer g.mu.RUnlock() var entityCount, relationCount int - g.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&entityCount) - g.db.QueryRow("SELECT COUNT(*) FROM relations WHERE status = 'active'").Scan(&relationCount) + // ★★★ entity_count 必须数**块**而不是旧表 entities(2026-10-05) + // + // 旧表停双写 ⇒ 这句恒为 0 ⇒ Introspect 报告「库里没有记忆」 + // 而实际有几千条。实测(隔离实例,380 块)工具回: + // + // 记忆统计: map[entity_count:0 memory_hotspots:[map[count:15 name:order-gw ...]] + // + // ★ 同一个输出里 entity_count=0 而 hotspots 有真实度数 —— + // **半个旧表半个块表**,模型看到的是自相矛盾的统计, + // 于是会得出「记忆是空的」而放弃写入。 + // + // ★ 口径对齐下面两处(relation_count / hotspots 都已数块)。 + // + // ★ 为什么不数全表块:块表含**原句块**(blk_src_*)与媒体块, + // 它们不是「实体」。entity_count 的语义是端点块数 + // (即三元组的主语/宾语),所以排除原句块与 contains 结构边。 + g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks + WHERE text_content != '' + AND COALESCE(modality,'') = 'text'`).Scan(&entityCount) + // ★ relation_count 保持**活跃数**语义(status='active')。 + // + // 第一版为对齐迁移口径改成数全表(因为 MigrateLegacyTextEntities + // 按 `ORDER BY id` 遍历全表、不按 status 过滤,而报告只数 active, + // 导致「预计产出 块边 966」实际写入 980)。 + // + // ★ 但那个修法是错的:relation_count 是**通用统计字段**, + // Purge("soft") 会把 status 置为 'deleted',而 TestPurgeSoft + // 断言软删除后 relation_count == 0。改成数全表后软删除失效, + // 该测试失败 —— 它在我这次改动之前是绿的。 + // + // 正确修法:**两处口径分开**。 + // - Introspect 的 relation_count = 活跃数(语义不变,各调用方依赖) + // - 迁移报告要的是「会转换多少条」= 全表数,另开一个字段 + // ★★★ 改数块/边(2026-10-04) + // + // 旧表退场后这两句恒为 0 ⇒ Introspect 报告「库里没有记忆」 + // 而实际有几千条。它是 healthcheck 与 WebUI 状态页的数据源 —— + // **报告说谎比报错更坏**(没人会去查)。 + // + // ★ 口径对齐(沿用此前定的语义分离): + // relation_count = 活跃关系边(status='active',不含 contains 结构边) + // relations_total = 全部关系边(含 soft-deleted) + // + // ★ 排除 contains:那是结构边(原句块 → 关系边), + // 计数它会让「关系数」随原句数量翻倍。 + g.db.QueryRow( + `SELECT COUNT(*) FROM memory_block_edges + WHERE source_kind='block' AND target_kind='block' + AND edge_type != 'contains' AND COALESCE(status,'')='active'`).Scan(&relationCount) + // relations_total 是**全表**关系数(不过滤 status)—— + // 迁移报告用它,因为 MigrateLegacyTextEntities 会转换全表。 + var relationsTotal int + g.db.QueryRow( + `SELECT COUNT(*) FROM memory_block_edges + WHERE source_kind='block' AND target_kind='block' + AND edge_type != 'contains'`).Scan(&relationsTotal) hotspots := []map[string]interface{}{} + // ★★ hotspots 改数**块**(2026-10-04) + // + // 旧实现读 entities 并按 mention_count 排序 —— + // 而旧表双写已停,那张表不再增长 ⇒ hotspots 永远是迁移时的快照, + // 而真实热点(哪些块被引用最多)已经变了。 + // + // ★ 排序口径换成**关系边度数**:一个块被越多关系边指向, + // 它在图里越重要 —— 这正是 hotspots 想回答的问题。 + // mention_count 已无处可取(旧表专有)。 rows, err := g.db.Query( - `SELECT name, mention_count, type FROM entities ORDER BY mention_count DESC LIMIT 10`, + `SELECT b.text_content, + COALESCE((SELECT COUNT(*) FROM memory_block_edges e + WHERE (e.source_id = b.id OR e.target_id = b.id) + AND COALESCE(e.status,'') != 'deleted'), 0) AS deg, + COALESCE(b.semantic_type, '') AS typ + FROM memory_blocks b + WHERE b.text_content != '' + ORDER BY deg DESC, b.created_at ASC + LIMIT 10`, ) if err == nil { defer rows.Close() @@ -1110,8 +1669,11 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) { } return map[string]interface{}{ - "entity_count": entityCount, - "relation_count": relationCount, + "entity_count": entityCount, + "relation_count": relationCount, + // relations_total 不过滤 status。★ 迁移报告必须用它 —— + // MigrateLegacyTextEntities 转换全表,用 active 会少报。 + "relations_total": relationsTotal, "memory_hotspots": hotspots, }, nil } @@ -1122,129 +1684,214 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) { // 3. sourceName 彻底删除(不再残留 @merged_ 实体) // 返回 (关系的重定向数, error) func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) { + // ★ 委托给 MergeBlocks(2026-10-04) + // + // 旧实现在这里直接操作 entities/relations(85 行),块侧完全不动。 + // 后果:改名后「张先生」的块还叫「张先生」,召回照样命中它 —— + // 判据 TestMergeEntities 就是这么红的(「should be merged and hidden」)。 + // + // ★ 对外签名保持不变(source, target string)→ (int, error): + // 它是 SDK 公开接口(sdk/memory.go 的 Memory 接口), + // 有 5 个调用方(sdk / toolcall / lua / proc / mocksdk)。 + // 改名会连带改 SDK 契约,那是另一次变更。 + // + // ★ 返回值语义也保持:重定向的边数。 + return g.MergeBlocks(sourceName, targetName) +} + +// ★★★ 改走块/边体系(2026-10-04,删块口径由用户明定) +// +// 旧实现三句 SQL 全在 entities/relations 上: +// +// SELECT id FROM entities WHERE name = ? +// DELETE FROM relations WHERE source_id = ? OR target_id = ? +// DELETE FROM entities WHERE id = ? +// +// 而旧表在停双写(c2bf963)后**不再增长** —— 于是这个函数是**纯空转**: +// +// memory_blocks Δ0 +// memory_block_edges Δ0 +// +// ★ 危害不是「删不干净」,是**谎报**:工具层回「已彻底删除实体…及其所有关联关系」, +// +// 模型据此认为内容已消失(不再提及、或重新写入), +// 而 40+ 条关联边还在,后续召回继续命中它。 +// 这比留残迹严重一级:残迹只是脏,谎报会让模型的行为跟着错。 +// +// ★ 删块口径(用户明定「删除块」):按**块文本精确匹配**定位, +// +// 删掉这些块 + 它们的全部关联边 + 指向它们的 contains 结构边, +// 再摘掉场景引用 —— 与 Purge 的收尾纪律一致 +// (否则场景里挂一条永远召不回的幽灵,而 SceneStats 照样把它算进去)。 +// +// ★ 为什么按文本而不按 legacy entities.id: +// +// 旧表只是历史对照,不再是权威源;块才是活图谱的节点。 +// 按 name 查 entities.id 会把「旧表里叫这个名字的行」 +// 与「图里文本相同的块」当成两回事 —— 那正是本缺陷的成因。 +func (g *GraphDB) DeleteEntity(name string) (DeleteResult, error) { g.mu.Lock() defer g.mu.Unlock() tx, err := g.db.Begin() if err != nil { - return 0, err + return DeleteResult{}, err } defer tx.Rollback() - var sourceID, targetID int64 - var sourceCount, targetCount int - - err = tx.QueryRow("SELECT id, mention_count FROM entities WHERE name = ?", sourceName).Scan(&sourceID, &sourceCount) - if err != nil { - return 0, fmt.Errorf("source entity '%s' not found: %w", sourceName, err) - } - err = tx.QueryRow("SELECT id, mention_count FROM entities WHERE name = ?", targetName).Scan(&targetID, &targetCount) - if err != nil { - return 0, fmt.Errorf("target entity '%s' not found: %w", targetName, err) + name = strings.TrimSpace(name) + if name == "" { + return DeleteResult{}, fmt.Errorf("name 不能为空") } - if sourceID == targetID { - return 0, fmt.Errorf("cannot merge entity with itself") + // ★ 按块文本精确匹配(不是 LIKE):DeleteEntity 的承诺是「删除这一个实体」, + // 用子串会把「admin」连带「admin 8861、billing 8499」一起删掉。 + // 要批量按子串清理走 Purge(subject_contains=…),两者语义本就不同。 + rows, err := tx.Query( + `SELECT id FROM memory_blocks WHERE text_content = ? ORDER BY id`, name) + if err != nil { + return DeleteResult{}, err + } + var blockIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return DeleteResult{}, err + } + blockIDs = append(blockIDs, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return DeleteResult{}, err + } + rows.Close() + + if len(blockIDs) == 0 { + // ★ 明确报「找不到」,不静默返回成功。 + // + // 旧实现在这里返回 "not found" 错误,是对的 —— 但它查的是旧表, + // 于是「旧表有、块里没有」会报成功,而「块里有、旧表没有」会报错。 + // 两种都说不清真相。现在以块为准:块里没有就是没有。 + // + // ★ 返回 0 而不是 error 也可以,但那样工具层只能回「已删除 0 个」—— + // 模型分不清「删了但本来就没有」和「条件写错了」, + // 于是会重试或改口径乱猜。明确报错更有用。 + return DeleteResult{}, fmt.Errorf("块 %q 不存在,删除未执行(可能已删除,或这个名字是关系文本而非端点块)", name) } - // 重定向 source → target 的活跃关系(作为 source) - res, err := tx.Exec( - `UPDATE relations SET source_id = ?, updated_at = CURRENT_TIMESTAMP - WHERE source_id = ? AND status = 'active'`, - targetID, sourceID, - ) - if err != nil { - return 0, err - } - redirectedSource, _ := res.RowsAffected() - - // 重定向 source → target 的活跃关系(作为 target) - res, err = tx.Exec( - `UPDATE relations SET target_id = ?, updated_at = CURRENT_TIMESTAMP - WHERE target_id = ? AND status = 'active'`, - targetID, sourceID, - ) - if err != nil { - return 0, err - } - redirectedTarget, _ := res.RowsAffected() - - // 删除可能产生的自引用关系 - _, err = tx.Exec( - `DELETE FROM relations - WHERE source_id = target_id AND source_id = ?`, - targetID, - ) - if err != nil { - return 0, err + ph := placeholders(len(blockIDs)) + args := make([]interface{}, len(blockIDs)) + for i, id := range blockIDs { + args[i] = id } - // 清理 source 残留的非活跃关系(archived/deleted),否则外键约束阻止删除实体 - _, err = tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, sourceID, sourceID) + // ① 删全部关联边(关系边 + 指向这些块的 contains 结构边)。 + // + // ★ 不区分 edge_type:承诺是「所有关联关系」, + // 而 contains 边指向已删块就是悬空边 —— 留着会让 + // sameSentenceOf / hasSameSentenceSibling 的分组落到不存在的块上。 + endpointCond := fmt.Sprintf( + `(source_kind = 'block' AND source_id IN (%s)) OR (target_kind = 'block' AND target_id IN (%s))`, + ph, ph) + edgeArgs := append(append([]interface{}{}, args...), args...) + edgeRes, err := tx.Exec( + `DELETE FROM memory_block_edges WHERE `+endpointCond, edgeArgs...) if err != nil { - return 0, err + return DeleteResult{}, err } + edgesDeleted, _ := edgeRes.RowsAffected() - // 更新 target 的 mention_count - _, err = tx.Exec( - `UPDATE entities SET mention_count = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - targetCount+sourceCount, targetID, - ) + // ② 删块本身。 + blockRes, err := tx.Exec( + `DELETE FROM memory_blocks WHERE id IN (`+ph+`)`, args...) if err != nil { - return 0, err + return DeleteResult{}, err } + blocksDeleted, _ := blockRes.RowsAffected() - // 彻底删除 source 实体(所有关系已重定向,自引用已删除) - _, err = tx.Exec(`DELETE FROM entities WHERE id = ?`, sourceID) - if err != nil { - return 0, err + // ③ 旧表同步删(若那一行还在)。 + // + // 不是本函数的主职责 —— 旧表已冻结、不再增长, + // 留着它只是不让「块已删、旧表还在」这种对不上的状态继续存在。 + // 查不到就跳过:旧表**不是权威源**,它的缺行不代表删除失败。 + // + // ★★★ 2026-10-05 复核修正(生产库副本实测暴露) + // + // 原写法: + // + // DELETE FROM relations WHERE id IN (SELECT id FROM entities WHERE name = ?) + // DELETE FROM entities WHERE name = ? + // + // 第一句是**跨表误用 id**:子查询返回的是 **entities.id**, + // 而外层匹配的是 **relations.id**。两者同名不同表。 + // 生产实测:实体 201 被 relation 101 引用(201≠101), + // 于是 relation 101 根本没被删,紧接着 + // DELETE FROM entities 因 relations 的外键(source_id/target_id → entities.id) + // 而报 `FOREIGN KEY constraint failed` —— **整个删除事务回滚**。 + // + // ⇒ DeleteEntity 在任何「旧表里存在、且被关系引用」的实体上直接失败。 + // 判据抓不到:delete_entity_blocks_test.go 只用 Commit/putBlocks 建**块**, + // 旧表是空的 ⇒ 那条路径不可达(这是「测不到」而非「没问题」)。 + // + // 正确写法:按**外键列**删,而不是拿 id 去撞另一个表的 id。 + if _, err := tx.Exec( + `DELETE FROM relations WHERE source_id IN (SELECT id FROM entities WHERE name = ?) + OR target_id IN (SELECT id FROM entities WHERE name = ?)`, name, name); err != nil { + return DeleteResult{}, err + } + if _, err := tx.Exec(`DELETE FROM entities WHERE name = ?`, name); err != nil { + return DeleteResult{}, err } if err := tx.Commit(); err != nil { - return 0, err + return DeleteResult{}, err } - total := int(redirectedSource + redirectedTarget) - return total, nil + // ④ 摘场景引用。必须在 Commit 之后 —— purgeStaleSceneRefsLocked 走 g.db, + // 而这里的事务还没提交。 + g.purgeStaleSceneRefsLocked() + + return DeleteResult{Blocks: int(blocksDeleted), Edges: int(edgesDeleted)}, nil } -// DeleteEntity 彻底删除一个实体及其所有关联关系。 -func (g *GraphDB) DeleteEntity(name string) error { - g.mu.Lock() - defer g.mu.Unlock() - - tx, err := g.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - var id int64 - err = tx.QueryRow("SELECT id FROM entities WHERE name = ?", name).Scan(&id) - if err != nil { - return fmt.Errorf("entity '%s' not found: %w", name, err) - } - - _, err = tx.Exec(`DELETE FROM relations WHERE source_id = ? OR target_id = ?`, id, id) - if err != nil { - return err - } - - _, err = tx.Exec(`DELETE FROM entities WHERE id = ?`, id) - if err != nil { - return err - } - - return tx.Commit() +// DeleteResult 报告一次删除实际删掉了什么。 +// +// ★ 不返回单一数字,是因为「删了 1 个块」和「删了 0 个块 40 条边」 +// +// 是**两种不同的事实**,压成一个 int 就会让工具层只能说谎 +// —— 那正是本缺陷的成因(回「已彻底删除…及其所有关联关系」而实际 Δ0)。 +type DeleteResult struct { + Blocks int `json:"blocks"` + Edges int `json:"edges"` } +// Archive 把 days 天前的**关系边**标记为 archived。 +// +// ★★★ 改走块/边体系(2026-10-04) +// +// 原实现只 UPDATE relations 表 —— 而旧表在停双写后**不再增长**, +// 所以归档对块体系完全无效:关系边永远不会被归档, +// 于是「按时间衰减记忆」这个能力静默失效了。 +// +// ★ 它当时零调用方(只有测试),所以这个缺陷从未被发现。 +// +// ★★ 停双写是把它照出来的原因:旧表冻结 ⇒ 任何只碰旧表的 +// 维护操作都变成空转。 +// +// ★ archived 与 deleted 的区别:archived 保留在库里(可回溯), +// +// 但不参与召回(读路径按 status='active' 过滤)。 func (g *GraphDB) Archive(days int) (int, error) { g.mu.Lock() defer g.mu.Unlock() result, err := g.db.Exec( - `UPDATE relations SET status = 'archived', updated_at = CURRENT_TIMESTAMP - WHERE status = 'active' AND created_at < datetime('now', ?)`, + `UPDATE memory_block_edges + SET status = 'archived' + WHERE COALESCE(status, '') = 'active' + AND edge_type != 'contains' + AND created_at < datetime('now', ?)`, fmt.Sprintf("-%d days", days), ) if err != nil { @@ -1254,14 +1901,35 @@ func (g *GraphDB) Archive(days int) (int, error) { return int(n), nil } -// ClearSentenceID 清除指定关系的 sentence_id(LLM复审后解除句子引用) -func (g *GraphDB) ClearSentenceID(relationID int64) error { +// ClearSentenceID 解除「关系 → 原句」的引用(LLM 复审后不再信任那句话)。 +// +// ★★★ 改走块/边体系(2026-10-04) +// +// 原实现 `UPDATE relations SET sentence_id = 0 WHERE id = ?` —— +// 而调用方(internal/agent/core/distill.go)传的 rel.ID 来自 +// RecallSorted 的返回,那里 Relation.ID 现在是**关系边 ID**。 +// +// ⇒ ★★ 也就是说:它一直在清理**错误的行**。 +// +// 旧表那一行的 sentence_id 纹丝不动,而边侧什么都没发生。 +// 而这**没有任何报错** —— UPDATE 影响 0 行也是成功。 +// +// ★ 正确做法:删掉「关系边 --contains--> 原句块」这条结构边。 +// +// sentence_id=0 的语义是「这条关系不再挂在那句话上」, +// 而在块体系里那个挂接就是 contains 边。 +// +// ★ 幂等:边不存在时返回 nil(复审流程会重复调用)。 +func (g *GraphDB) ClearSentenceID(edgeID int64) error { + if edgeID == 0 { + return nil + } g.mu.Lock() defer g.mu.Unlock() _, err := g.db.Exec( - `UPDATE relations SET sentence_id = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - relationID, - ) + `DELETE FROM memory_block_edges + WHERE target_kind = 'edge' AND target_id = ? AND edge_type = 'contains'`, + edgeID) return err } @@ -1334,31 +2002,45 @@ func placeholders(n int) string { // 中间那一步会把旧关系的附加信息(置信度、场景、原句)一起丢掉。 // 编辑前先精确取回这条关系,才能把这些信息带过去。 func (g *GraphDB) FindRelations(subject, relationType, object string) ([]Relation, error) { - g.mu.RLock() - defer g.mu.RUnlock() + // ★★★ 改走块/边体系(2026-10-04) + // + // 原实现 JOIN relations + entities + sentences。旧表停双写后 + // 这条查询返回空 ⇒ 精确查找功能失效(判据 TestFindRelationsAndScenesOfRelation)。 + // + // 按**块文本**精确匹配两端块 —— 语义与旧表的 name = ? 一致。 rows, err := g.db.Query( - `SELECT r.id, r.source_id, r.target_id, e1.name, e2.name, - r.relation_type, r.confidence, r.status, r.session_id, - r.turn_id, r.created_at, COALESCE(r.date_bucket, ''), - COALESCE(r.sentence_id, 0), COALESCE(sn.text, '') - FROM relations r - JOIN entities e1 ON r.source_id = e1.id - JOIN entities e2 ON r.target_id = e2.id - LEFT JOIN sentences sn ON r.sentence_id = sn.id - WHERE r.status = 'active' AND e1.name = ? AND r.relation_type = ? AND e2.name = ? - ORDER BY r.id DESC`, subject, relationType, object) + `SELECT e.id, e.source_id, e.target_id, sb.text_content, tb.text_content, + e.edge_type, COALESCE(e.confidence,0), COALESCE(e.status,''), + COALESCE(e.session_id,''), COALESCE(e.turn_id,0), e.created_at, + COALESCE((SELECT b.text_content FROM memory_block_edges c + JOIN memory_blocks b ON b.id = c.source_id + WHERE c.target_kind='edge' AND c.target_id = e.id + AND c.edge_type='contains' LIMIT 1), '') + FROM memory_block_edges e + JOIN memory_blocks sb ON sb.id = e.source_id + JOIN memory_blocks tb ON tb.id = e.target_id + WHERE e.source_kind='block' AND e.target_kind='block' + AND e.edge_type != 'contains' + AND COALESCE(e.status,'') = 'active' + AND sb.text_content = ? AND e.edge_type = ? AND tb.text_content = ?`, + subject, relationType, object) if err != nil { return nil, err } defer rows.Close() + var out []Relation for rows.Next() { var rel Relation - if err := rows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID, &rel.SourceName, &rel.TargetName, - &rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID, - &rel.TurnID, &rel.CreatedAt, &rel.DateBucket, &rel.SentenceID, &rel.SentenceText); err != nil { + if err := rows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID, + &rel.SourceName, &rel.TargetName, &rel.RelationType, + &rel.Confidence, &rel.Status, &rel.SessionID, + &rel.TurnID, &rel.CreatedAt, &rel.SentenceText); err != nil { return nil, err } + if rel.DateBucket == "" && !rel.CreatedAt.IsZero() { + rel.DateBucket = rel.CreatedAt.Format("2006-01-02") + } out = append(out, rel) } return out, rows.Err() @@ -1407,3 +2089,169 @@ func effectiveScenes(t Triple) []string { add(t.Scene) return out } + +// sortRecallEntities 按模式给实体排序(原地),并回填 MatchRank。 +// +// 相关性模式的三层键(从强到弱): +// 1. 实词命中层级:完全相等(0) > 前缀(1) > 包含(2)。**最关键的一层**—— +// 问 metrics 时「metrics服务端口8328」是前缀命中,而「metrics服务端口」类 +// 实体只是包含命中,前者必须在前。 +// 2. 提及次数降序:提及多的更可能是常用实体。 +// 3. 名字长度升序:短名更可能是实体本身而非长描述(沿用 SQL 原口径)。 +// +// 时间模式:UpdatedAt 倒序 → 提及次数降序 → 名字长度升序。 +// 运维场景里「现在的值」通常是最新的那次写入(实测 v4 overwrite 组 +// 就是「新分机覆盖旧分机」),相关性排序会把旧值排在新值前面。 +// +// 稳定排序保证同层内顺序可复现,不会两次调用结果跳动。 +func sortRecallEntities(ents []Entity, rank map[int64]int, keywords []string, mode SortMode) { + spec := make(map[int64]int, len(ents)) + for i := range ents { + // 先算实词层级:只命中泛词时退回整体 rank(仍应召回,只是靠后)。 + spec[ents[i].ID] = bestSpecificRank(rank[ents[i].ID], ents[i].Name, keywords) + } + // 回填 MatchRank,供上层展示「为什么这条相关」。 + for i := range ents { + ents[i].MatchRank = spec[ents[i].ID] + } + + sort.SliceStable(ents, func(i, j int) bool { + if mode == SortRecent { + // Seq 优先:严格单调,不受秒级精度影响。 + if ents[i].Seq != ents[j].Seq { + return ents[i].Seq > ents[j].Seq + } + if !ents[i].UpdatedAt.Equal(ents[j].UpdatedAt) { + return ents[i].UpdatedAt.After(ents[j].UpdatedAt) + } + } else if spec[ents[i].ID] != spec[ents[j].ID] { + return spec[ents[i].ID] < spec[ents[j].ID] + } + if ents[i].MentionCount != ents[j].MentionCount { + return ents[i].MentionCount > ents[j].MentionCount + } + return len(ents[i].Name) < len(ents[j].Name) + }) +} + +// sortRecallRelations 按同一模式给关系排序(原地)。 +// +// 时间模式:CreatedAt 倒序 → TurnID 倒序 → ID 倒序。 +// +// ★ 为什么必须带 TurnID/ID 两级兜底(实测抓到的坑):SQLite 的 CURRENT_TIMESTAMP +// **只到秒**。同一秒内写入的两次覆盖(实测 turn 1 写 4379、turn 2 写 4324 +// 落在同一秒),CreatedAt 完全相等,稳定排序会保留原顺序 —— 而原顺序来自 +// `ORDER BY confidence DESC`,置信度相同时退化到 rowid 升序,也就是**旧值在前**。 +// 这与「记忆只能按毫秒时间戳排序」是同一类问题(SQLite 秒精度)。 +// TurnID 在一次会话内单调递增,RelationID 是自增主键,两者都严格单调, +// 因此即使同秒也能分出先后。 +// +// 相关性模式下关系**保持召回顺序**(源头已按种子实体的相关性排过), +// 刻意不打乱 —— 邻接扩展的顺序本身带语义(从种子实体出发由近及远)。 +func sortRecallRelations(rels []Relation, mode SortMode) { + if mode != SortRecent { + return + } + sort.SliceStable(rels, func(i, j int) bool { + a, b := rels[i], rels[j] + if !a.CreatedAt.Equal(b.CreatedAt) { + return a.CreatedAt.After(b.CreatedAt) + } + if a.TurnID != b.TurnID { + return a.TurnID > b.TurnID + } + return a.ID > b.ID + }) +} + +// rankEntity 按「精确度层级 → 提及次数 → 名字长度」给实体排序(原地)。 +// +// 为什么需要跨关键词归并(2026-10-01 跑分实测):SQL 里已经算出了三层精确度 +// (完全相等 > 前缀命中 > 包含命中),但那只是**单个关键词内**的排序。 +// ExtractKeywords 会把问句切成多个词(实测「metrics服务的端口是多少」 +// → [metrics, 服务, 端口]),各关键词的结果被依次 append —— 精确度层级 +// 在拼接过程中被冲淡,第一个泛词召回的噪音会排在精确命中之前。 +// +// 排序键(从强到弱): +// 1. rank:完全相等(0) > 前缀(1) > 包含(2)。**这是最关键的一层**—— +// 问 metrics 时,「metrics服务端口8328」是前缀命中,而「metrics服务端口」 +// 类实体是包含命中,前者必须在前。 +// 2. mention_count 降序:提及多的更可能是常用实体。 +// 3. 名字长度升序:短名更可能是实体本身而非长描述(沿用 SQL 的口径)。 +// +// 稳定排序:rank 相同的实体保持原顺序(SQL 已在各关键词内排过), +// 避免同层内因合并而随机跳动 —— 那会让「第一次调用结果」与「第二次」不一致。 +func rankEntity(ents []Entity, rank map[int64]int, keywords []string) { + // specificity 为每个实体算「最有区分度的那个关键词」上的命中层级。 + // + // ★ 为什么要区分「泛词」与「实词」(实测抓到):关键词里有「端口」「服务」 + // 这类泛词,实体名恰好就叫「端口」时它对泛词是**完全相等命中(rank=0)**, + // 于是把真正的答案「metrics服务端口8328」(前缀命中 rank=1)压到了第二。 + // 「完全相等」只在**实词**上才算强信号。 + spec := make(map[int64]int, len(ents)) + for _, e := range ents { + spec[e.ID] = bestSpecificRank(rank[e.ID], e.Name, keywords) + } + sort.SliceStable(ents, func(i, j int) bool { + ri, rj := spec[ents[i].ID], spec[ents[j].ID] + if ri != rj { + return ri < rj + } + if ents[i].MentionCount != ents[j].MentionCount { + return ents[i].MentionCount > ents[j].MentionCount + } + return len(ents[i].Name) < len(ents[j].Name) + }) +} + +// rankOf 取实体的精确度层级;未记录时按最差处理(包含命中)。 +func rankOf(rank map[int64]int, e Entity) int { + if r, ok := rank[e.ID]; ok { + return r + } + return 2 +} + +// genericKeywords 是区分不出实体的泛词:对它们做「完全相等」匹配没有意义。 +// +// 判据:出现在大量实体名里的短词。实测「端口」「服务」这类词在 order-gw +// 的实体表里遍布(每个「xx服务端口」都含它们),而「metrics」只出现一次。 +// 用固定表而非动态统计,是为了召回路径可预测、不引入额外查询; +// 代价是新领域需要补这张表(补漏了也只是排序略差,不会召回不到)。 +var genericKeywords = map[string]bool{ + "端口": true, "服务": true, "版本": true, "接口": true, "任务": true, + "状态": true, "类型": true, "时间": true, "配置": true, "数量": true, + "名称": true, "结果": true, "内容": true, "问题": true, "记录": true, + "数据": true, "信息": true, "文件": true, "系统": true, "功能": true, +} + +// bestSpecificRank 返回实体在**实词**关键词上的最佳命中层级。 +// +// 若实体只命中泛词(全都不算实词),退回用整体 rank —— 它仍然该被召回, +// 只是排在命中实词的实体之后。 +func bestSpecificRank(rank int, name string, keywords []string) int { + best := -1 + lower := strings.ToLower(name) + for _, kw := range keywords { + if genericKeywords[strings.ToLower(kw)] { + continue + } + lkw := strings.ToLower(kw) + var r int + switch { + case lower == lkw: + r = 0 + case strings.HasPrefix(lower, lkw): + r = 1 + default: + r = 2 + } + if best < 0 || r < best { + best = r + } + } + if best < 0 { + return rank // 只命中泛词 + } + return best +} diff --git a/internal/memory/graph_test.go b/internal/memory/graph_test.go index 117843eb..932b72f6 100644 --- a/internal/memory/graph_test.go +++ b/internal/memory/graph_test.go @@ -2,6 +2,7 @@ package memory import ( "database/sql" + "fmt" "os" "path/filepath" "testing" @@ -40,6 +41,15 @@ func TestNewGraphDB(t *testing.T) { } } +// ★★ Commit 的返回值语义已变(2026-10-04) +// +// 旧表双写已停 ⇒ ec/rc 恒为 0(它们数的是旧表 entities/relations 行号)。 +// Commit 的返回签名是 SDK 契约(9 处调用方),改签名代价大, +// 所以保留位置并置 0。 +// +// ⇒ 断言「写进了几条」必须改用**块侧**信号 +// +// (MemoryBlocks 的长度 / MemoryBlockCount)。 func TestCommitTriples(t *testing.T) { g := newTestGraph(t) defer os.Remove(g.dbPath) @@ -54,10 +64,10 @@ func TestCommitTriples(t *testing.T) { if err != nil { t.Fatal(err) } - if ec != 4 { + if ec != 0 { // 旧表停写后恒 0 t.Errorf("expected 4 entity ops (张三×2, 编程, 北京), got %d", ec) } - if rc != 2 { + if rc != 0 { // 旧表停写后恒 0 t.Errorf("expected 2 relations, got %d", rc) } } @@ -73,8 +83,9 @@ func TestCommitDedupSameSession(t *testing.T) { if err != nil { t.Fatal(err) } - if ec != 2 || rc != 1 { - t.Fatalf("first commit: want 2/1, got %d/%d", ec, rc) + if ec != 0 || rc != 0 { + // 旧表停写后 ec/rc 恒 0;真正要验的是块侧写入。 + t.Fatalf("旧表停写后计数应为 0,实际 %d/%d", ec, rc) } // 同一会话重复 commit 同一三元组:关系不再新增 @@ -87,11 +98,19 @@ func TestCommitDedupSameSession(t *testing.T) { } var cnt int - if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil { + // ★ 改查**关系边**(2026-10-04) + // + // 旧表 relations 不再增长 ⇒ 查它恒为 0, + // 而这条判据要验的是「重复提交不产生重复边」。 + // + // ★ 块侧去重口径:同 (source, target, edge_type, session_id) 收敛; + // 跨会话并存(关系边按设计允许多条同类型边,52e4596)。 + if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE edge_type != 'contains' AND source_kind='block'`).Scan(&cnt); err != nil { t.Fatal(err) } if cnt != 1 { - t.Errorf("expected exactly 1 relation after duplicate commit, got %d", cnt) + t.Errorf("expected exactly 1 relation edge after duplicate commit, got %d", cnt) } } @@ -108,11 +127,13 @@ func TestCommitDedupDifferentSession(t *testing.T) { } } var cnt int - if err := g.db.QueryRow(`SELECT COUNT(*) FROM relations`).Scan(&cnt); err != nil { + // ★ 跨会话允许并存(关系边设计),查边而非旧表。 + if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE edge_type != 'contains' AND source_kind='block'`).Scan(&cnt); err != nil { t.Fatal(err) } if cnt != 2 { - t.Errorf("different sessions may repeat a triple, expected 2 relations, got %d", cnt) + t.Errorf("different sessions may repeat a triple, expected 2 relation edges, got %d", cnt) } } @@ -329,17 +350,33 @@ func TestArchive(t *testing.T) { defer os.Remove(g.dbPath) defer g.Close() - // 直接插入一条旧记录 - g.db.Exec(`INSERT INTO entities (id, name, type) VALUES (1, '旧数据', 'Concept')`) - g.db.Exec(`INSERT INTO relations (source_id, target_id, relation_type, created_at) - VALUES (1, 1, '包含', datetime('now', '-1 day'))`) + // ★ 改造**关系边**而不是旧表行(2026-10-04) + // + // Archive 已改走块/边体系(旧表停写后,归档旧表等于什么都不做)。 + // 测试若还造旧表数据,就变成「测一个已不再执行的路径」。 + g.db.Exec(`INSERT INTO memory_blocks (id, modality, text_content) + VALUES ('b_old', 'text', '旧数据')`) + g.db.Exec(`INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type, status, created_at) + VALUES ('block','b_old','block','b_old','包含','active', datetime('now','-1 day'))`) n, err := g.Archive(0) if err != nil { t.Fatal(err) } if n != 1 { - t.Errorf("expected 1 archived relation, got %d", n) + t.Errorf("expected 1 archived relation edge, got %d", n) + } + + // ★ 归档后应不再参与召回(读路径按 status='active' 过滤) + var status string + if err := g.db.QueryRow( + `SELECT COALESCE(status,'') FROM memory_block_edges + WHERE edge_type='包含'`).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "archived" { + t.Errorf("★ 边状态应为 archived,实际 %q", status) } } @@ -376,9 +413,22 @@ func TestMergeEntities(t *testing.T) { }, "session", 0) // 合并前:两个实体各有关联 - stats, _ := g.Introspect() - if stats["entity_count"].(int) != 5 { - t.Fatalf("expected 5 entities (张三, 编程, 北京, 张先生, 字节跳动), got %d", stats["entity_count"]) + // + // ★ 断言改用**块数**(2026-10-04) + // + // stats["entity_count"] 数的是旧表 entities 行 —— 旧表双写已停, + // 它恒为 0。真正要验的是「三元组写进了 5 个实体块」。 + blocksBefore, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + if len(blocksBefore) != 5 { + names := make([]string, 0, len(blocksBefore)) + for _, b := range blocksBefore { + names = append(names, b.Text) + } + t.Fatalf("expected 5 entity blocks (张三, 编程, 北京, 张先生, 字节跳动), got %d: %v", + len(blocksBefore), names) } // 先增加张先生的 mention_count @@ -403,22 +453,35 @@ func TestMergeEntities(t *testing.T) { t.Error("张先生 should be merged and hidden") } - // target 的 mention_count 应合并 - // 验证 target 还存在(seedEntities 精确查找) + // ★ 判据从「mention_count 相加」改成「信息没丢」(2026-10-04) + // + // 块侧**没有 mention_count 这个概念** —— 它是旧 entities 表的列, + // 而 Recall 从不填它(块是内容派生的,不存在「被提及几次」)。 + // + // 原断言 `mention_count >= 2` 在块体系下无意义,且它衡量的 + // 只是「计数」这个代理指标,不是「信息还在」这件真事。 + // + // 块侧的真实验证:**合并后「张三」应当同时承载两人的关系**。 + // 合并前张先生有「喜欢→Go」,张三有「负责→billing服务」等; + // 合并后两组都必须还在 —— 那才是「没丢信息」。 result2, err := g.Recall([]string{"张三"}, nil, 1, "") if err != nil { t.Fatal(err) } found := false + targets := map[string]bool{} for _, e := range result2.Entities { if e.Name == "张三" { found = true - if e.MentionCount < 2 { - t.Errorf("expected 张三 mention_count >= 2 after merge, got %d", e.MentionCount) - } - break } } + for _, r := range result2.Relations { + targets[r.TargetName] = true + } + fmt.Printf(" 合并后「张三」继承 %d 条关系: %v\n", len(result2.Relations), targets) + if !targets["Go"] { + t.Error("★ 合并丢失了源块的关系「喜欢→Go」(信息丢失,不是计数问题)") + } if !found { t.Error("张三 should still exist after merge") } @@ -539,3 +602,121 @@ func TestPlaceholders(t *testing.T) { t.Errorf("expected '?,?,?' for n=3, got %s", placeholders(3)) } } + +// ★ Introspect 的两个关系计数必须**语义分离**。 +// +// 这是一个被同一个字段承担两种语义踩出来的坑: +// +// relation_count 活跃数(status='active')—— TestPurgeSoft 依赖它 +// Purge("soft") 把 status 置 'deleted',软删除后应为 0 +// relations_total 全表数 —— 迁移报告依赖它 +// MigrateLegacyTextEntities 按 ORDER BY id 转换全表 +// +// 曾为对齐迁移口径把 relation_count 改成数全表,结果 TestPurgeSoft +// 从绿变红(expected 0 active relations, got 1)—— +// 那次修改为了让一个报告数字准确,破坏了一个真实功能的断言。 +func TestIntrospect_关系计数语义分离(t *testing.T) { + g := newTestGraph(t) + defer os.Remove(g.dbPath) + defer g.Close() + + g.Commit([]Triple{ + {Subject: "活跃方", Relation: "属于", Object: "测试"}, + {Subject: "待删方", Relation: "属于", Object: "测试"}, + }, "session-split", 0) + + before, err := g.Introspect() + if err != nil { + t.Fatal(err) + } + activeBefore := before["relation_count"].(int) + totalBefore, ok := before["relations_total"].(int) + if !ok { + t.Fatalf("Introspect 必须返回 relations_total,实际 keys=%v", keysOf(before)) + } + if activeBefore != totalBefore { + t.Errorf("初始应相等:active=%d total=%d", activeBefore, totalBefore) + } + + // 软删一条 + if _, err := g.Purge(map[string]string{"subject_contains": "待删方"}, "soft"); err != nil { + t.Fatal(err) + } + + after, err := g.Introspect() + if err != nil { + t.Fatal(err) + } + activeAfter := after["relation_count"].(int) + totalAfter := after["relations_total"].(int) + + if activeAfter != activeBefore-1 { + t.Errorf("软删除后活跃数应减 1:%d → %d", activeBefore, activeAfter) + } + // ★ 全表数**不变** —— 软删除只是打标记,不是物理删除 + if totalAfter != totalBefore { + t.Errorf("软删除后全表数应不变(status 只是标记):%d → %d", totalBefore, totalAfter) + } + if activeAfter >= totalAfter { + t.Errorf("活跃数(%d) 必须小于全表数(%d) —— 否则 status 过滤没生效", + activeAfter, totalAfter) + } +} + +func keysOf(m map[string]interface{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// ★ Introspect 的 relation_count / hotspots 已改数块侧(2026-10-04) +// +// 旧实现数旧表,而旧表双写已停 ⇒ 两个字段都恒为 0 / 冻结。 +// 它是 healthcheck 与 WebUI 状态页的数据源,报告说谎比报错更坏。 +func TestIntrospect_关系计数与热点走块侧(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + if _, _, err := g.Commit([]Triple{ + {Subject: "热点甲", Relation: "指向", Object: "中心乙", Confidence: 1.0}, + {Subject: "热点丙", Relation: "指向", Object: "中心乙", Confidence: 1.0}, + }, "s", 0); err != nil { + t.Fatal(err) + } + + stats, err := g.Introspect() + if err != nil { + t.Fatal(err) + } + // 2 条关系边(都是 active) + if rc, _ := stats["relation_count"].(int); rc != 2 { + t.Errorf("★ relation_count 应数活跃关系边(2),实际 %v —— "+ + "数旧表会恒为 0", stats["relation_count"]) + } + if tot, _ := stats["relations_total"].(int); tot < 2 { + t.Errorf("★ relations_total(≥2)实际 %v", stats["relations_total"]) + } + + // hotspots 必须非空且含块文本(不再读旧表 name) + hotspots, _ := stats["memory_hotspots"].([]map[string]interface{}) + if len(hotspots) == 0 { + t.Fatal("★ hotspots 为空(旧表不增长 ⇒ 永远是迁移时的快照)") + } + top, _ := hotspots[0]["name"].(string) + fmt.Printf(" 最热块: %q\n", top) + if top == "" { + t.Error("★ hotspot 的 name 应是块文本,不该是旧表的 name") + } + // ★ 中心乙被两条边指向 ⇒ 应当是度数最高的块之一 + found := false + for _, h := range hotspots { + if n, _ := h["name"].(string); n == "中心乙" { + found = true + } + } + if !found { + t.Errorf("★ 「中心乙」被两条边指向,应进 hotspots,实际 %v", hotspots) + } +} diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index f9283e9b..86fec32a 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -126,13 +126,25 @@ func (idx *Indexer) syncIfStale() bool { if idx.db == nil { return false } + // ★★ 基线改数**块**(2026-10-04) + // + // 原来数旧表 entities —— 而旧表双写已停,那张表不再增长 + // ⇒ count 恒为 0 ⇒ expected 恒为 0 ⇒ + // **首次 syncIfStale 也判成「无需同步」** + // ⇒ 索引永远不建立,而没有任何报错。 + // + // 判据 TestSyncIfStaleBaseline 的「首次应建立索引」当场抓住。 + // + // ★ 口径与 Sync 保持一致:Sync 走「无关键词全量召回」, + // 实体数被 maxFullRecallEntities 封顶。 + // ★ 用 COUNT(*) 而非走召回:Sync 内部也用这个封顶口径, + // 两边一致才不会「永远不相等」—— + // 那会导致每 30s 全量重训,把一条读路径变成写放大热点。 var count int - if err := idx.db.db.QueryRow(`SELECT COUNT(*) FROM entities`).Scan(&count); err != nil { + if err := idx.db.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks WHERE text_content != ''`).Scan(&count); err != nil { return false } - // 基线口径必须与 Sync 一致:Sync 走的是「无关键词全量召回」,实体数被 - // maxFullRecallEntities 封顶。直接拿 COUNT(*) 比会在实体数超过上限的大图上 - // 永远不相等——每 30s 全量重训一次,把一条读路径变成写放大热点。 expected := count if expected > maxFullRecallEntities { expected = maxFullRecallEntities @@ -288,6 +300,8 @@ func (idx *Indexer) BuildToolPrompt() string { 参数: - query_intent: 查询关键词,逗号分隔 - depth: 遍历深度(默认2) +- sort: 排序方式。relevance=相关性(默认);recent=时间倒序最新在前 + (问"最近/最新/现在是什么"时必须用 —— 答案常是新值覆盖旧值) ### memory_commit 将三元组写入图记忆。 @@ -425,8 +439,11 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { { "type": "function", "function": map[string]interface{}{ - "name": "memory_recall", - "description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。", + "name": "memory_recall", + "description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。" + + "问『某个具体东西是什么/是多少』用默认相关性排序;" + + "问『最近/最新/现在是什么』必须传 sort=recent —— " + + "运维场景里答案常是新值覆盖旧值,相关性排序分不出新旧。", "parameters": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -439,6 +456,12 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} { "description": "遍历深度,默认2", "default": 2, }, + "sort": map[string]interface{}{ + "type": "string", + "description": "排序方式。relevance=相关性(默认,问具体是什么/多少);" + + "recent=时间倒序最新在前(问最近/最新/现在)。", + "enum": []string{"relevance", "recent"}, + }, }, "required": []string{"query_intent"}, }, diff --git a/internal/memory/indexer_test.go b/internal/memory/indexer_test.go index 564a6f47..0126fdf7 100644 --- a/internal/memory/indexer_test.go +++ b/internal/memory/indexer_test.go @@ -44,9 +44,22 @@ func TestIndexerSync(t *testing.T) { if !idx.trained { t.Error("expected indexer to be trained after sync") } - if idx.vec.Size() != ec { - t.Errorf("expected %d vectors, got %d", ec, idx.vec.Size()) + // ★ 期望值改用**块数**(2026-10-04) + // + // 原来用 Commit 返回的 ec(旧表实体计数)——旧表双写已停, + // ec 恒为 0,于是判据说「应有 0 个向量」而索引器建了 4 个。 + // + // ★★ 这是**判据与实现都错**的组合: + // 判据错在用了一个已失效的信号;实现没错(索引器照块建向量)。 + blocks, err := db.MemoryBlocks() + if err != nil { + t.Fatal(err) } + if idx.vec.Size() != len(blocks) { + t.Errorf("expected %d vectors (one per block), got %d", + len(blocks), idx.vec.Size()) + } + _ = ec } // TestSyncIfStaleBaseline 钉住增量同步的基线口径: diff --git a/internal/memory/introspect_count_truth_test.go b/internal/memory/introspect_count_truth_test.go new file mode 100644 index 00000000..b71be7f3 --- /dev/null +++ b/internal/memory/introspect_count_truth_test.go @@ -0,0 +1,130 @@ +package memory + +import ( + "path/filepath" + "testing" +) + +// TestIntrospectEntityCountIsNotZeroAfterWrites 钉住「Introspect 不再报告空库」。 +// +// ★ 缺陷来源(2026-10-05 隔离实例实测): +// +// 一次对话写入 7 块 9 边,工具输出却是: +// memory_commit → 已写入 0 个实体和 0 条关系 +// memory_introspect → map[entity_count:0 memory_hotspots:[map[count:15 name:order-gw]]] +// +// ⇒ entity_count 读的是**旧表 entities**(停双写后恒 0), +// 而 hotspots 已改数块(真实度数 15)。 +// 同一份输出里自相矛盾,模型据此认为「记忆库是空的」并放弃写入 +// (实测模型回复:"the write is being rejected")。 +func TestIntrospectEntityCountIsNotZeroAfterWrites(t *testing.T) { + g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + if _, _, err := g.Commit([]Triple{ + {Subject: "张三", Relation: "负责", Object: "订单网关"}, + {Subject: "李四", Relation: "负责", Object: "支付网关"}, + }, "s1", 1); err != nil { + t.Fatal(err) + } + + st, err := g.Introspect() + if err != nil { + t.Fatal(err) + } + + ec, ok := st["entity_count"].(int) + if !ok { + t.Fatalf("entity_count 类型异常: %T", st["entity_count"]) + } + // ★ 旧表是空的(停双写),所以旧表口径必然给 0。 + if ec == 0 { + var blocks, legacy int + g.db.QueryRow("SELECT COUNT(*) FROM memory_blocks WHERE text_content != ''").Scan(&blocks) + g.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&legacy) + t.Fatalf("entity_count=0,但库里已有 %d 个块(旧表 %d 行)—— "+ + "它还在读旧表,模型会据此认为记忆库是空的", blocks, legacy) + } + // relation_count 已数块,同一份输出里两个字段口径必须一致。 + rc, _ := st["relation_count"].(int) + if rc == 0 { + t.Errorf("relation_count=0 与 entity_count=%d 矛盾 —— 同一份输出里两个字段口径不一致", ec) + } +} + +// TestIntrospectCountsBlocksNotLegacyTable 钉住「口径切块」的判据本身。 +// +// ★ 为什么单独立一条:上面那条在「块数为 0」时会假绿 +// +// (新建空库时 entity_count=0 是正确的)。 +// 这条显式构造「旧表空、块有内容」,把口径问题与数据量分离。 +func TestIntrospectCountsBlocksNotLegacyTable(t *testing.T) { + g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db")) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + // 只写块,不碰旧表 —— 这正是停双写后的真实形态。 + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "b1", Modality: BlockText, Text: "节点甲"}, + {ID: "b2", Modality: BlockText, Text: "节点乙"}, + {ID: "b3", Modality: BlockText, Text: "节点丙"}, + }); err != nil { + t.Fatal(err) + } + + var legacy int + if err := g.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&legacy); err != nil { + t.Fatal(err) + } + if legacy != 0 { + t.Fatalf("测试前提不成立:旧表不该有行,实际 %d", legacy) + } + + st, err := g.Introspect() + if err != nil { + t.Fatal(err) + } + if ec, _ := st["entity_count"].(int); ec != 3 { + t.Errorf("写了 3 个块,entity_count 应为 3,实际 %d —— 仍在读旧表", ec) + } +} + +// TestMemoryEdgeCountExcludesContains 钉住边数口径。 +// +// memory_commit 的文案用 newEdges 报告「写了几条关系」, +// 若把 contains 结构边算进去,数字会随原句数量翻倍 —— +// 同一个数在不同批次之间不可比,模型也会误读为「写多了」。 +func TestMemoryEdgeCountExcludesContains(t *testing.T) { + g := openArbTestDB(t) + + // 一句原句 + 一个字段块 = 1 条 contains 结构边、0 条关系边。 + writeBlockWithSource(t, g, "b_f", "维度=值", "blk_src_1") + + n, err := g.MemoryEdgeCount() + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("只有 contains 结构边时 MemoryEdgeCount 应为 0,实际 %d", n) + } + + // 加一条真关系边。 + if err := g.AddMemoryBlockEdge("block", "blk_src_1", "block", "b_f", "关联"); err != nil { + t.Fatal(err) + } + n2, err := g.MemoryEdgeCount() + if err != nil { + t.Fatal(err) + } + if n2 != 1 { + t.Errorf("加了 1 条关系边后应为 1,实际 %d", n2) + } +} + +// db2Alias 只是为了让上面那行读起来明确(openArbTestDB 已返回 *GraphDB)。 +func db2Alias(g *GraphDB) *GraphDB { return g } diff --git a/internal/memory/legacy_retire_precondition_test.go b/internal/memory/legacy_retire_precondition_test.go new file mode 100644 index 00000000..dedefe64 --- /dev/null +++ b/internal/memory/legacy_retire_precondition_test.go @@ -0,0 +1,412 @@ +package memory + +import ( + "fmt" + "os" + "testing" +) + +// ★★ 存量清理的前置判据:清理**之前**必须能证明块侧数据完整。 +// +// 清理是不可逆动作(删表),所以判据必须回答: +// +// ① 每个 entity 都有对应的块吗? +// ② 每条 relation 都有对应的边吗?(去重口径) +// ③ 每条 sentence 都有对应的原句块吗? +// ④ 有没有边指向将被删除的节点?(悬空边) +func TestRetire_清理前置_块侧覆盖完整(t *testing.T) { + path := os.Getenv("RETIRE_DB") + if path == "" { + t.Skip("需要 RETIRE_DB(生产库快照副本)") + } + g, err := NewGraphDB(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = g.Close() }() + + // ① entities → legacy-entity 块 + var entCount, entBlocks int + mustScalar(t, g, `SELECT COUNT(*) FROM entities`, &entCount) + mustScalar(t, g, `SELECT COUNT(*) FROM memory_blocks WHERE source='legacy-entity'`, &entBlocks) + fmt.Printf(" ① entities %d → legacy-entity 块 %d %s\n", + entCount, entBlocks, markEq(entCount, entBlocks)) + + // 每个 entity 的名字都能找到一个块(不只数数量) + missing := missingBlocks(t, g) + fmt.Printf(" 缺块的 entity: %d 个%s\n", len(missing), + excerpt(missing)) + if len(missing) > 0 { + t.Errorf("★ 有 %d 个 entity 没有对应块 —— 清理会丢数据", len(missing)) + } + + // ② relations → 关系边 + var relCount, relDistinct int + mustScalar(t, g, `SELECT COUNT(*) FROM relations`, &relCount) + mustScalar(t, g, `SELECT COUNT(*) FROM ( + SELECT DISTINCT source_id, target_id, COALESCE(relation_type,'') FROM relations)`, + &relDistinct) + mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges WHERE edge_type<>'contains'`, &relBlocks) + fmt.Printf(" ② relations %d(去重后 %d)→ 关系边 %d %s\n", + relCount, relDistinct, relBlocks, markEq(relDistinct, relBlocks)) + if relDistinct != relBlocks { + t.Errorf("★ 关系边 %d ≠ 去重后关系数 %d —— 清理会丢边", relBlocks, relDistinct) + } + + // ③ sentences → 原句块 + // + // ★ 块 ID 是 sha256(Text[:12]),**SQL 里算不了** ⇒ 在 Go 侧比对。 + // (SQLite 没有 sha256 函数,尝试 `SentenceBlockID(...)` 会报 + // "no such function" —— 判据要自己算,别指望 SQL 能做哈希。) + allBlocks := map[string]bool{} + bs, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + for _, b := range bs { + allBlocks[b.ID] = true + } + sents := g.sentenceTexts(t) + var sentWithBlock int + var noBlock []string + for _, txt := range sents { + if allBlocks[SentenceBlockID(txt)] { + sentWithBlock++ + } else { + noBlock = append(noBlock, txt) + } + } + fmt.Printf(" ③ sentences %d → 有原句块的 %d %s%s\n", + len(sents), sentWithBlock, markEq(len(sents), sentWithBlock), + excerpt(noBlock)) + if len(noBlock) > 0 { + t.Errorf("★ %d 条 sentence 没有原句块 —— 清理会丢原句", len(noBlock)) + } + + // ④ 悬空边:指向 sentence 表的边 + var dangling int + mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges e + WHERE e.source_kind='sentence' + AND NOT EXISTS (SELECT 1 FROM sentences s WHERE CAST(s.id AS TEXT)=e.source_id)`, + &dangling) + fmt.Printf(" ④ 悬空的 sentence→块 边: %d\n", dangling) + if dangling > 0 { + t.Errorf("★ %d 条边指向不存在的 sentence —— 清理后会成为悬空边", dangling) + } + + // ⑤ 块边的两端都必须存在(通用悬空检查) + var badEnds int + mustScalar(t, g, `SELECT COUNT(*) FROM memory_block_edges e + WHERE (e.source_kind='block' AND NOT EXISTS + (SELECT 1 FROM memory_blocks b WHERE b.id=e.source_id)) + OR (e.target_kind='block' AND NOT EXISTS + (SELECT 1 FROM memory_blocks b WHERE b.id=e.target_id))`, &badEnds) + fmt.Printf(" ⑤ 端点不存在的块边: %d\n", badEnds) + if badEnds > 0 { + t.Errorf("★ %d 条块边的一端不存在", badEnds) + } +} + +var relBlocks int + +func mustScalar(t *testing.T, g *GraphDB, q string, dst *int) { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + if err := g.db.QueryRow(q).Scan(dst); err != nil { + t.Fatalf("%s: %v", q, err) + } +} + +// sentenceTexts 读出 sentences 表的原文。 +func (g *GraphDB) sentenceTexts(t *testing.T) []string { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT text FROM sentences`) + if err != nil { + t.Fatal(err) + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var s string + _ = rows.Scan(&s) + out = append(out, s) + } + return out +} + +// missingBlocks 找出没有对应块的 entity +func missingBlocks(t *testing.T, g *GraphDB) []string { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT e.name FROM entities e + WHERE NOT EXISTS (SELECT 1 FROM memory_blocks b + WHERE b.source='legacy-entity' AND b.text_content = e.name)`) + if err != nil { + t.Fatal(err) + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var n string + _ = rows.Scan(&n) + out = append(out, n) + } + return out +} + +func markEq(a, b int) string { + if a == b { + return "✓" + } + return "✘" +} + +func excerpt(ss []string) string { + if len(ss) == 0 { + return "" + } + s := " 例: " + ss[0] + if len(ss) > 1 { + s += fmt.Sprintf(" …(共 %d)", len(ss)) + } + return s +} + +// ★★ scene 读方适配:TagSceneByEntityGlob 改走块体系 +// +// 现状(scene.go:260):按实体名 GLOB 查 relations: +// +// SELECT r.id, r.source_id, r.target_id, r.confidence +// FROM relations r JOIN entities e1 ... JOIN entities e2 ... +// WHERE r.status='active' AND (e1.name GLOB ? OR e2.name GLOB ?) +// +// 块体系里等价于:按**块文本** GLOB 找关系边的两端, +// 再把两端块都挂到场景上(边本身也挂,kind='edge')。 +// +// ★ 这条路径有真实调用方(cmd/memgc),所以必须有判据。 +func TestSceneAdapt_ByEntityGlob走块体系(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 织一张网:值班室分机号 --是--> 4324 + for _, b := range []MemoryBlock{ + {ID: "blk_subject", Modality: BlockText, Text: "值班室分机号"}, + {ID: "blk_value", Modality: BlockText, Text: "4324"}, + {ID: "blk_other", Modality: BlockText, Text: "billing服务"}, + } { + if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil { + t.Fatal(err) + } + } + if err := g.AddRelationBlockEdge("blk_subject", "blk_value", "是", + RelationEdgeData{SessionID: "s1", Confidence: 0.9, Status: EdgeActive}); err != nil { + t.Fatal(err) + } + + // 按名字 GLOB 找关系(dryRun,不真写) + n, err := g.TagSceneByEntityGlob("值班场景", "*分机号*", true) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" GLOB '*分机号*' 匹配 %d 条关系\n", n) + if n != 1 { + t.Errorf("★ 应匹配 1 条(值班室分机号 --是--> 4324),实际 %d", n) + } + + // 不匹配的名字不该匹配 + n2, err := g.TagSceneByEntityGlob("值班场景", "*不存在的名字*", true) + if err != nil { + t.Fatal(err) + } + if n2 != 0 { + t.Errorf("★ 不该匹配任何关系,实际 %d", n2) + } + + // ★ 真写(dryRun=false)后,场景应挂到块上 + if _, err := g.TagSceneByEntityGlob("值班场景", "*分机号*", false); err != nil { + t.Fatal(err) + } + counts := sceneRefCounts(t, g) + fmt.Printf(" 写入后 scene_refs: %v\n", counts) + if counts["block"] == 0 { + t.Error("★ 场景应挂到块上(kind='block'),实际无") + } + if counts["edge"] == 0 { + t.Error("★ 场景应挂到边上(kind='edge'),实际无") + } + // ★ 旧表读方不应再被使用 —— 但引用不应退回 entity/relation + if counts["entity"] != 0 || counts["relation"] != 0 { + t.Errorf("★ 不该产生旧表引用,实际 %v", counts) + } +} + +// ★★ 悬空引用清理要认 kind='edge' +// +// 旧实现只清 kind='relation'/'entity'/'block'/'document', +// 而 scene_refs 现在多了一种:**kind='edge'**(指向 memory_block_edges.id)。 +// +// ★ 不加这个分支的后果:边被删后那批 edge 引用永远悬空, +// +// 而且是**静默**的 —— purgeStaleSceneRefs 看着跑成功了, +// 实际没清掉任何东西。 +func TestSceneAdapt_悬空清理认edge(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_a", Modality: BlockText, Text: "甲"}, + {ID: "blk_b", Modality: BlockText, Text: "乙"}, + }); err != nil { + t.Fatal(err) + } + if err := g.AddRelationBlockEdge("blk_a", "blk_b", "是", + RelationEdgeData{SessionID: "s1", Confidence: 0.9}); err != nil { + t.Fatal(err) + } + + // 挂三条引用:两个有效、一个指向不存在的块 + g.mu.Lock() + for _, ref := range []struct{ kind, text, id string }{ + {"block", "blk_a", "0"}, + {"edge", "0", "99999"}, // 指向不存在的边 + {"block", "blk_gone", "0"}, + } { + if _, err := g.db.Exec( + `INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) + VALUES (1, ?, CAST(? AS INTEGER), ?, 1.0)`, + ref.kind, ref.id, ref.text); err != nil { + g.mu.Unlock() + t.Fatal(err) + } + } + g.mu.Unlock() + + n, err := g.PurgeStaleSceneRefs() + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 清理 %d 条悬空引用\n", n) + if n != 2 { + t.Errorf("★ 应清理 2 条(悬空的 edge 与 block),实际 %d", n) + } + + counts := sceneRefCounts(t, g) + fmt.Printf(" 清理后: %v\n", counts) + if counts["edge"] != 0 { + t.Errorf("★ 悬空的 kind='edge' 未被清理(缺该分支)") + } + if counts["block"] != 1 { + t.Errorf("★ 有效引用应保留,实际 %v", counts) + } +} + +// ═══════════════════════════════════════════════════════════════ +// 停旧表双写 —— 判据(2026-10-04) +// +// ★ 目标不是「旧表被删」,而是「旧表停止增长、冻结为历史」��� +// 删表是不可逆的,且读方虽已全切块,仍需要一段时间观察。 +// +// ★ 判据的核心是**增长量**,不是「有没有 INSERT 语句」—— +// 后者是文本检查,前者才是行为检查。 +// ═══════════════════════════════════════════════════════════════ + +// Test停双写_旧表不再增长 +func Test停双写_旧表不再增长(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 先写一批,让旧表有内容 + if _, _, err := g.Commit([]Triple{ + {Subject: "初始甲", Relation: "属于", Object: "初始乙", Confidence: 1.0}, + }, "sess-0", 0); err != nil { + t.Fatal(err) + } + snap := legacyCounts(t, g) + fmt.Printf(" 首批后(旧表应已冻结): %v\n", snap) + + // ★ 首批之后旧表就应该是 0 —— 双写已停(2026-10-04)。 + // 原断言是「首批应写入旧表」,那是**停双写之前**的前提, + // 改完之后它必然失败 ⇒ 判据自己抓出了自己过期。 + // + // ★ 这正是「行为判据优于文本判据」的又一例: + // 我们不是在查「有没有 INSERT 语句」, + // 而是在看 Commit 之后旧表**实际长没长**。 + for _, table := range []string{"entities", "relations", "sentences"} { + if snap[table] != 0 { + t.Fatalf("★ 首批 Commit 后旧表 %s 仍有 %d 行(双写未停)", + table, snap[table]) + } + } + + // 再写一批 —— 旧表**不应**再增长 + for i := 0; i < 5; i++ { + if _, _, err := g.Commit([]Triple{ + {Subject: fmt.Sprintf("新主体%d", i), Relation: "属性", + Object: fmt.Sprintf("值%d", i), Confidence: 1.0, + SentenceText: fmt.Sprintf("第 %d 句测试", i)}, + }, fmt.Sprintf("sess-%d", i+1), i+1); err != nil { + t.Fatal(err) + } + } + + after := legacyCounts(t, g) + fmt.Printf(" 五批后: %v\n", after) + for _, table := range []string{"entities", "relations", "sentences"} { + if after[table] != snap[table] { + t.Errorf("★ 旧表 %s 仍在增长: %d → %d(应冻结)", + table, snap[table], after[table]) + } + } + + // ★ 但块侧**必须**照常增长 —— 双写停了,单写不能停 + if blk, err := g.MemoryBlocks(); err != nil { + t.Fatal(err) + } else { + fmt.Printf(" 块数 %d(应 ≥ 12:首批 2 + 新批 10)\n", len(blk)) + if len(blk) < 12 { + t.Errorf("★ 块侧写入被误伤,只剩 %d 个块", len(blk)) + } + } +} + +// Test停双写_召回不受影响:旧表冻结后读方仍要工作 +func Test停双写_召回不受影响(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + for i := 0; i < 3; i++ { + if _, _, err := g.Commit([]Triple{ + {Subject: "召回主体", Relation: "属性", Object: fmt.Sprintf("召回值%d", i), + Confidence: 1.0}, + }, "sess", i); err != nil { + t.Fatal(err) + } + } + res, err := g.Recall([]string{"召回主体"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 召回实体 %d,关系 %d\n", len(res.Entities), len(res.Relations)) + if len(res.Entities) == 0 { + t.Error("★ 旧表冻结后召回不该失效(读方已全切块)") + } + if len(res.Relations) == 0 { + t.Error("★ 旧表冻结后关系召回不该失效") + } +} + +func legacyCounts(t *testing.T, g *GraphDB) map[string]int { + t.Helper() + out := map[string]int{} + for _, tbl := range []string{"entities", "relations", "sentences"} { + n, err := g.LegacyRowCount("SELECT COUNT(*) FROM " + tbl) + if err != nil { + t.Fatalf("count %s: %v", tbl, err) + } + out[tbl] = n + } + return out +} diff --git a/internal/memory/light_memory.go b/internal/memory/light_memory.go index faba50f6..4fa4f0ca 100644 --- a/internal/memory/light_memory.go +++ b/internal/memory/light_memory.go @@ -86,6 +86,56 @@ func (m *LightMemory) Commit(triples []Triple, sessionID string, turnID int) (in // // 任一侧出错都不影响另一侧的结果:单侧失败只在两侧都失败时返回错误 // (主库是只读句柄,任何"查询即失败"都说明是真实故障)。 +// RecallSorted 是可指定排序的召回:两个空间各自排序后**再合并排序**。 +// +// 不能只对其中一个空间排序 —— temp(子写的)与 main(主图)都会被召回, +// 只排其一就会让另一个空间的实体以任意顺序混进前 N。 +// +// ★ 去重必须按**名字**而不是 ID(第一版写成按 ID,是个真 bug): +// temp 与 main 是**两个独立数据库**,各自的 id 都从 1 自增, +// 同一个 id 在两边是完全无关的实体。按 ID 去重会让 +// 「主库的第 3 号实体」和「temp 的第 3 号实体」互相顶掉 —— +// 实测子代理因此**看不到主记忆**(TestLightProfile_MemoryFaceWiring 变红)。 +// 正确做法与旧路径一致:复用 mergeRecall,它按名字/三元组去重。 +// ★ fingerprint 透传(2026-10-04):LightMemory 是薄代理, +// +// 不加这个参数就会在代理这一层把向量空间隔离悄悄丢掉。 +func (m *LightMemory) RecallSorted(keywords []string, seedEntities []string, depth int, sessionFilter, fingerprint string, mode SortMode) (*RecallResult, error) { + m.mu.RLock() + temp := m.temp + main := m.main + m.mu.RUnlock() + + var ( + parts []*RecallResult + lastErr error + okAny bool + ) + for _, g := range []*GraphDB{temp, main} { + if g == nil { + continue + } + r, err := g.RecallSorted(keywords, seedEntities, depth, sessionFilter, fingerprint, mode) + if err != nil { + lastErr = err + continue + } + okAny = true + parts = append(parts, r) + } + if !okAny { + if lastErr == nil { + lastErr = fmt.Errorf("没有可用的图记忆实例") + } + return nil, lastErr + } + merged := mergeRecall(parts...) + // 跨空间合并后做全局重排:各空间内部已排好,这里只兜底。 + mergeSortEntities(merged.Entities, keywords, mode) + sortRecallRelations(merged.Relations, mode) + return merged, nil +} + func (m *LightMemory) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) { m.mu.RLock() temp := m.temp @@ -189,3 +239,34 @@ func (m *LightMemory) Close() error { } return nil } + +// mergeSortEntities 对多空间合并后的实体做全局重排(原地)。 +// +// 只处理相关性模式的层级键;时间模式在 mergeSortEntities 里退化为 +// 不动 —— 各空间的 UpdatedAt 都是真实时间,合并后仍需全局比时间, +// 故时间键同样在此重算。 +func mergeSortEntities(ents []Entity, keywords []string, mode SortMode) { + if len(ents) <= 1 { + return + } + spec := make(map[int64]int, len(ents)) + for i := range ents { + // MatchRank 已由各空间的 RecallSorted 回填;只命中泛词时它退回 2, + // 这里用实体名再算一次实词层级,让跨空间同分实体可比。 + spec[ents[i].ID] = bestSpecificRank(ents[i].MatchRank, ents[i].Name, keywords) + } + sort.SliceStable(ents, func(i, j int) bool { + a, b := ents[i], ents[j] + if mode == SortRecent { + if !a.UpdatedAt.Equal(b.UpdatedAt) { + return a.UpdatedAt.After(b.UpdatedAt) + } + } else if spec[a.ID] != spec[b.ID] { + return spec[a.ID] < spec[b.ID] + } + if a.MentionCount != b.MentionCount { + return a.MentionCount > b.MentionCount + } + return len(a.Name) < len(b.Name) + }) +} diff --git a/internal/memory/merge_blocks_test.go b/internal/memory/merge_blocks_test.go new file mode 100644 index 00000000..27b887f2 --- /dev/null +++ b/internal/memory/merge_blocks_test.go @@ -0,0 +1,224 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ═══════════════════════════════════════════════════════════════ +// MergeBlocks —— 块合并判据 +// +// ★ 与旧 MergeEntities 的语义对齐,但按块的特性调整: +// +// 旧(entities/relations,85 行) +// 1. source 的关系重定向到 target +// 2. mention_count 相加 +// 3. 删自引用关系 +// 4. 删 source 实体 +// +// 新(blocks/edges) +// 1. source 块的**关系边**重定向到 target 块 +// 2. 删合并产生的**自环边**(source→X 与 target→X 重定向后同端) +// 3. **删 source 块本身**(不留 @merged_ 残留) +// 4. 结构边(contains)也要处理 —— 否则原句块会指向已删的块 +// 5. 场景引用同步 +// +// ★★ 与旧实现的一个本质差异 +// 块 ID 是**内容派生**的(blk_ent_),所以 +// 「张先生」和「张三」是两个不同的块。合并不是改端点, +// 而是**让源块消失并把它的边改指向目标块**。 +// 这意味着边的端点值会变 —— 而旧实现在这一点上反而更简单 +// (改 relations.source_id 即可,实体 ID 不变)。 +// ═══════════════════════════════════════════════════════════════ + +// TestMergeBlocks_基础合并:边重定向 + 源块消失 +func TestMergeBlocks_基础合并(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "张先生", Relation: "身份", Object: "值班长", Confidence: 1.0}, + {Subject: "张三", Relation: "负责", Object: "billing服务", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + src, err := g.BlockByText("张先生") + if err != nil || src == nil { + t.Fatalf("块源不存在: %v", err) + } + dst, err := g.BlockByText("张三") + if err != nil || dst == nil { + t.Fatalf("块目标不存在: %v", err) + } + + n, err := g.MergeBlocks("张先生", "张三") + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 合并重定向 %d 条边\n", n) + if n != 1 { + t.Errorf("★ 应重定向 1 条边,实际 %d", n) + } + + // ★ 源块必须彻底消失 + after, err := g.BlockByText("张先生") + if err != nil { + t.Fatal(err) + } + if after != nil { + t.Errorf("★ 源块应被删除,仍存在: %+v", after) + } + + // ★ 目标块继承源块的关系 + res, err := g.RecallSorted([]string{"张三"}, nil, 1, "", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, r := range res.Relations { + names[r.TargetName] = true + } + fmt.Printf(" 合并后「张三」的关系目标: %v\n", names) + if !names["值班长"] { + t.Errorf("★ 「张三」应继承源块的关系「值班长」,实际 %v", names) + } + if !names["billing服务"] { + t.Errorf("★ 「张三」自己的关系被误删,实际 %v", names) + } +} + +// TestMergeBlocks_自环清理:两条边重定向后同端,必须去重 +func TestMergeBlocks_自环清理(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + // 同一人对同一对象说了两件事 —— 合并后都会指向 target→X + if _, _, err := g.Commit([]Triple{ + {Subject: "李四", Relation: "喜欢", Object: "咖啡", Confidence: 1.0}, + {Subject: "李四", Relation: "讨厌", Object: "咖啡", Confidence: 1.0}, + {Subject: "王五", Relation: "评价", Object: "咖啡", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + n, err := g.MergeBlocks("李四", "王五") + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 合并「李四」→「王五」重定向 %d 条边\n", n) + // ★ 王五→咖啡 与 李四→咖啡 重定向后同端 ⇒ 自环,必须被删 + // 保留它们会让王五「喜欢咖啡」「讨厌咖啡」自相矛盾。 + edges, _, err := g.NeighbourEdgesOfBlock(mustBlockID(t, g, "王五")) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 「王五」合并后有 %d 条边\n", len(edges)) + for _, e := range edges { + if e.Peer.Text == "咖啡" && e.SourceID == e.TargetID { + t.Error("★ 自环边未被清理") + } + } +} + +// TestMergeBlocks_结构边处理:contains 不能悬空 +func TestMergeBlocks_结构边不被误删(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "赵六", Relation: "状态", Object: "在线", Confidence: 1.0, + SentenceText: "赵六现在在线"}, + // ★ 合并需要**两端都存在**(目标不存在时报错,见「不存在的块」判据) + {Subject: "钱七", Relation: "状态", Object: "离线", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + // 先确认原句块与 contains 结构边存在 + srcBlkID := mustBlockID(t, g, "赵六") + edges, _, err := g.NeighbourEdgesOfBlock(srcBlkID) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 合并前「赵六」边数 %d\n", len(edges)) + + if _, err := g.MergeBlocks("赵六", "钱七"); err != nil { + t.Fatal(err) + } + + // ★ 源块消失了 ⇒ 指向它的 contains 边(source=原句块,target=源块) + // 必须一并消失,否则块边表出现悬空端点。 + // ★ 关键:源块被删后,任何指向它的边都不能悬空。 + // 悬空端点会让 graphNodeExists 校验失败,后续引用它的写入全被拒。 + g.mu.RLock() + var dangling int + if err := g.db.QueryRow(` + SELECT COUNT(*) FROM memory_block_edges + WHERE (source_kind='block' AND source_id = ?) + OR (target_kind='block' AND target_id = ?)`, + srcBlkID, srcBlkID).Scan(&dangling); err != nil { + g.mu.RUnlock() + t.Fatalf("查悬空边: %v", err) + } + g.mu.RUnlock() + if dangling != 0 { + t.Errorf("★ 源块删除后仍有 %d 条边指向它(端点悬空)", dangling) + } + + // ★ 原句块仍可解析(内容派生的,合并不该删原句) + if blk, err := g.BlockByText("赵六现在在线"); err != nil || blk == nil { + t.Errorf("★ 原句块不该被合并删除: %v", err) + } +} + +// TestMergeBlocks_重复合并要报错:源块不存在不等于成功 +func TestMergeBlocks_幂等(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "甲一", Relation: "属性", Object: "值一", Confidence: 1.0}, + {Subject: "乙一", Relation: "属性", Object: "值二", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + if _, err := g.MergeBlocks("甲一", "乙一"); err != nil { + t.Fatal(err) + } + // 再合并一次:源块已被删除 ⇒ 必须报错。 + // + // ★ 不做成幂等(0, nil):源块不存在可能是「已合并」也可能是 + // 「名字写错了」,两者返回同一个结果会让调用方无法区分 —— + // 而后者是 bug,却表现为成功。 + if n, err := g.MergeBlocks("甲一", "乙一"); err == nil { + t.Errorf("★ 重复合并应报错(源块已不存在),实际返回 n=%d", n) + } +} + +// TestMergeBlocks_不存在的块要明确报错 +func TestMergeBlocks_不存在的块(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "真实主体", Relation: "属性", Object: "某值", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + if _, err := g.MergeBlocks("不存在的人", "真实主体"); err == nil { + t.Error("★ 合并不存在的源块应报错(而不是静默成功)") + } + if _, err := g.MergeBlocks("真实主体", "也不存在的人"); err == nil { + t.Error("★ 合并到不存在的目标块应报错") + } +} + +func mustBlockID(t *testing.T, g *GraphDB, text string) string { + t.Helper() + b, err := g.BlockByText(text) + if err != nil { + t.Fatalf("BlockByText(%q): %v", text, err) + } + if b == nil { + t.Fatalf("块 %q 不存在", text) + } + return b.ID +} diff --git a/internal/memory/migrate.go b/internal/memory/migrate.go index 4f205705..d4036595 100644 --- a/internal/memory/migrate.go +++ b/internal/memory/migrate.go @@ -158,20 +158,26 @@ func insertMigratedBlock(tx *sql.Tx, block MemoryBlock) error { // attachBlockToLegacySentences 把迁移出的块挂到该旧实体当时所属的句子上, // 并保留那些句子(它们可能只有媒体关系,删实体后就再无关系引用)。 func attachBlockToLegacySentences(tx *sql.Tx, entityID int64, blockID string) (int, error) { - rows, err := tx.Query(`SELECT DISTINCT s.id FROM sentences s + // ★ 取 sentences.**text** 而不是 id —— 挂载点已从 sentences 表行号 + // 改成原句块 ID(Commit 块化,sentences 表退场)。 + // 行号只在这一行里当作 JOIN 键用,不作为边的端点。 + rows, err := tx.Query(`SELECT DISTINCT s.text FROM sentences s JOIN relations r ON r.sentence_id = s.id WHERE r.source_id = ? OR r.target_id = ?`, entityID, entityID) if err != nil { return 0, err } - var sids []int64 + var sentTexts []string for rows.Next() { - var sid int64 - if err := rows.Scan(&sid); err != nil { + var text string + if err := rows.Scan(&text); err != nil { rows.Close() return 0, err } - sids = append(sids, sid) + if text == "" { + continue + } + sentTexts = append(sentTexts, text) } if err := rows.Err(); err != nil { rows.Close() @@ -180,11 +186,12 @@ func attachBlockToLegacySentences(tx *sql.Tx, entityID int64, blockID string) (i rows.Close() n := 0 - for _, sid := range sids { + for _, text := range sentTexts { + // 端点 kind 从 'sentence' 改成 'block'(原句由块承载) if _, err := tx.Exec(`INSERT OR IGNORE INTO memory_block_edges (source_kind, source_id, target_kind, target_id, edge_type) - VALUES ('sentence', ?, 'block', ?, 'contains')`, - fmt.Sprintf("%d", sid), blockID); err != nil { + VALUES ('block', ?, 'block', ?, 'contains')`, + SentenceBlockID(text), blockID); err != nil { return n, err } n++ diff --git a/internal/memory/migrate_entities.go b/internal/memory/migrate_entities.go new file mode 100644 index 00000000..afcc2869 --- /dev/null +++ b/internal/memory/migrate_entities.go @@ -0,0 +1,864 @@ +package memory + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "strings" + "time" +) + +// 存量实体迁移为块节点(entities 退场第 3 步)。 +// +// 为什么需要 +// -------- +// entities 是旧形态:只有 name/type/mention_count,没有向量;而块节点 +// (memory_blocks) 带 vector+fingerprint/modality,是节点的正统形态。 +// MigrateLegacyMediaEntities 已经把「媒体类实体」迁过一次(见 migrate.go), +// 本函数处理剩下的**文本类实体**——生产库实测 1277 个。 +// +// 迁成什么形态 +// ------------ +// sentence(实体名作为一句「陈述」) --contains--> block(实体名, 带向量) +// +// 关系则转成 block 边: +// block(主语) -[关系类型]-> block(宾语) +// +// 为什么不拆字段:拆(LLM 三元组化)是**提升召回**的动作,与「换存储格式」 +// 是两件事。混在一起做会让这次迁移既不可回滚也不可验证 —— 迁完不知道 +// 召回变好还是变坏。拆分留给迁移后单独跑(有独立的探针判据)。 +// +// 回滚 +// ---- +// 全程单事务,任何一步失败整体回滚:半途中断会留下既没有实体也没有块的 +// 关系,信息静默消失(与 MigrateLegacyMediaEntities 同款考量)。 +// 另:调用方应在迁移前自行快照 graph.db —— 本函数**不删数据**, +// 只在全部成功后由调用方决定是否清理旧表。 + +// MigrateResult 是存量迁移的统计。 +type MigrateResult struct { + Sentences int `json:"sentences"` + Blocks int `json:"blocks"` + Edges int `json:"edges"` + SkippedOrphan int `json:"skipped_orphan"` + // DedupedEdges 是**被去重**的边数:relations 表里有完全重复的 + // (source,target,type) 三元组,边表按唯一键存 ⇒ 实际写入少于遍历数。 + // 必须显式报出来,否则「报告数 ≠ 实际写入数」会被当成数据丢失。 + DedupedEdges int `json:"deduped_edges"` + SkippedNoVec int `json:"skipped_no_vector"` +} + +// EntityEmbedder 为一个实体名算向量与空间指纹。 +// +// 由调用方注入(持有 embedding provider 的一方)。返回 nil 向量表示 +// 「这个实体算不出向量」——迁移**继续但不计为成功块**,不编造零向量。 +type EntityEmbedder func(entityName string) (vec []float64, fingerprint string) + +// MigrateLegacyTextEntities 把文本类实体迁移成块节点。 +// +// embed 为 nil 时仍迁移(块不带向量,只是不参与向量召回)—— 这是可接受的 +// 中间态:结构对了,向量可以后续回填(RecallBlocks 对无向量的块直接跳过)。// MigrateLegacyTextEntities 把文本类实体迁移成块节点。 +// +// embed 为 nil 时仍迁移(块不带向量,只是不参与向量召回)—— 这是可接受的 +// 中间态:结构对了,向量可以后续回填(RecallBlocks 对无向量的块直接跳过)。 +// +// ★ 三个阶段,慢操作不进事务 +// ---------------------------- +// +// 阶段一 读快照(RLock,短) 取出全部实体与关系到内存 +// 阶段二 算向量(无锁) embed 回调可能很慢 +// 阶段三 写事务落库(Lock) 句子 + 块 + 边 +// +// 分阶段的原因:embed 是 ONNX 前向,实测 0.3~1s/条,生产库 1277 实体 +// = 6~21 分钟。若在写事务内调用 embed,graph.db 会被锁住那么久 —— +// 它是单文件、所有记忆操作共用一把 g.mu,期间召回与写入全部阻塞。 +// 第一版就是这么写的,症状是测试直接卡死 600s(测试在 embed 回调里 +// 反过来拿锁,构造出真实场景的等价死锁)。 +func (g *GraphDB) MigrateLegacyTextEntities(embed EntityEmbedder) (MigrateResult, error) { + var res MigrateResult + + ents, rels, err := g.readLegacySnapshot() + if err != nil { + return res, err + } + + // ── 阶段二:锁外算向量 ── + vectors := make(map[int64][]float64, len(ents)) + fps := make(map[int64]string, len(ents)) + if embed != nil { + for _, e := range ents { + if vec, fp := embed(e.name); len(vec) > 0 { + vectors[e.id] = vec + fps[e.id] = fp + } else { + res.SkippedNoVec++ + } + } + } else { + res.SkippedNoVec = len(ents) + } + + // ── 阶段三:写事务落库 ── + g.mu.Lock() + defer g.mu.Unlock() + + tx, err := g.db.Begin() + if err != nil { + return res, err + } + defer tx.Rollback() + + entToBlock := make(map[int64]string, len(ents)) + for _, e := range ents { + // 实体名同时充当「句子」(陈述)与「块文本」:迁移期 1:1 对应, + // 不做内容改写 —— 改写属于拆分,不属于迁移。 + // ★ 不再建 sentences 行(方案 A 退场)。 + // + // 原来的 sentence 节点**没有独立价值**:它的 text 就是实体名, + // 而下面那个块的 Text 也是 e.name —— 两者完全重复, + // 而 sentences 表一退场这个节点就悬空了。 + // + // 现在:原句块(blk_src_)--contains--> 迁移块 + // 与蒸馏路径(0586f6b)用同一套原句块形态。 + blockID := legacyEntityBlockID(e.id, e.name) + src := NewSentenceBlock(e.name, e.createdAt, e.updatedAt) + srcBlockID := src.ID + if err := putBlockTx(tx, src); err != nil { + return res, fmt.Errorf("put sentence block for entity %d: %w", e.id, err) + } + if err := putBlockTx(tx, MemoryBlock{ + ID: blockID, + Modality: BlockText, + Text: e.name, + Vector: vectors[e.id], + Fingerprint: fps[e.id], + Source: "legacy-entity", + CreatedAt: e.createdAt, + UpdatedAt: e.updatedAt, + }); err != nil { + return res, fmt.Errorf("put block for entity %d: %w", e.id, err) + } + if err, _ := addBlockEdgeTx(tx, "block", srcBlockID, + "block", blockID, "contains"); err != nil { + return res, fmt.Errorf("link block %s: %w", blockID, err) + } + entToBlock[e.id] = blockID + // 注意:res.Sentences 不再递增 —— 统计口径改为「原句块」 + res.Blocks++ + } + + for _, r := range rels { + src, ok1 := entToBlock[r.src] + tgt, ok2 := entToBlock[r.tgt] + if !ok1 || !ok2 { + res.SkippedOrphan++ + continue + } + edgeType := strings.TrimSpace(r.typ) + if edgeType == "" { + // 空类型会被 addBlockEdgeTx 拒绝(端点与类型都必填)。 + // 用明确占位名而不是跳过 —— 关系的**存在**本身是信息。 + edgeType = "related_to" + } + // ★ 只在**真的新增**时计数 —— 否则报告会多算被去重的那些。 + // 实测:980 行 relations(含 21 组重复三元组)报告 980 而实际 959。 + // + // ★★ 用 addMigratedRelationTx 而不是 addBlockEdgeTx(2026-10-04): + // 后者是**结构边**写入器,去重条件带 `session_id=''` + // ⇒ 每一条带 session 的要迁移的关系都会被判成「已存在」而跳过。 + // 而且它不写 confidence —— 生产迁移实测 959 条边 confidence 全 0。 + // ★★ status 要与**块侧**取交集(2026-10-04) + // + // 读侧切块之后 Purge 只改块侧,旧 relations 行**仍停在 active**。 + // 于是迁移看到 active 的旧行会当成活跃关系搬过去 —— + // 而它在块侧早就被软删了 ⇒ **已删除的记忆复活**。 + // + // 生产库实测有 14 条 deleted 关系;若迁移前跑过 memory_purge + // 工具(它走 Purge),这批会被全部复活。 + // + // ⇒ 迁移时额外查一次块侧:块侧已 deleted 的旧行不迁成 active。 + // ★★ 判据:**精确**匹配块侧那条边(2026-10-04) + // + // 第一版写成 `source_id IN (src,tgt) AND target_id IN (src,tgt)` + // —— 那是**交叉匹配**:任一端相同就算命中。 + // 于是「A→B 被删」会让「A→C」「B→A」都被误判成 deleted。 + // + // 而且它掩盖了真正的问题:块侧被删的边是 + // `blk_ent_` → `blk_ent_`(块化时写的), + // 而迁移用的是旧表行号映射出的块 ID —— + // 两套 ID 不同,只有**按块文本**才能对上。 + effectiveStatus := r.status + if effectiveStatus == "" || effectiveStatus == "active" { + var blockDeleted int + if err := tx.QueryRow(` + SELECT COUNT(*) FROM memory_block_edges e + JOIN memory_blocks sb ON sb.id = e.source_id + JOIN memory_blocks tb ON tb.id = e.target_id + WHERE e.source_kind='block' AND e.target_kind='block' + AND e.edge_type = ? + AND COALESCE(sb.text_content,'') = (SELECT name FROM entities WHERE id = ?) + AND COALESCE(tb.text_content,'') = (SELECT name FROM entities WHERE id = ?) + AND COALESCE(e.session_id,'') = ? + AND COALESCE(e.status,'') = 'deleted'`, + edgeType, r.src, r.tgt, r.sessionID).Scan(&blockDeleted); err != nil { + return res, fmt.Errorf("check block-side status: %w", err) + } + if blockDeleted > 0 { + effectiveStatus = EdgeDeleted + } + } + err, added := addMigratedRelationTx(tx, src, tgt, edgeType, + r.confidence, r.sessionID, r.turnID, effectiveStatus) + if err != nil { + return res, fmt.Errorf("migrate relation %d: %w", r.id, err) + } + if !added { + res.DedupedEdges++ + continue + } + res.Edges++ + } + + // ★ 为 sentences 表的原文补建原句块。 + // + // 实测(清理前置判据,生产快照):sentences 66 条,原句块 **0 个**。 + // 而迁移只从 `entities` 读(readLegacySnapshot 里只有 entities 查询), + // 从没为 sentences 建过载体 ⇒ 直接清理该表会**丢 66 条原句**。 + // + // 而 sentences 表的价值就在原文本身(entities 是提炼后的名字), + // 所以原句块是它唯一的迁移出口 —— 不补这一段,清理就是数据丢失。 + // + // 形态与迁移块的父节点一致:blk_src_,无向量。 + // 已有原句块(entities 迁移建的)会被 putBlockTx 的 ON CONFLICT 跳过, + // 所以重复跑是幂等的。 + sentRows, err := tx.Query(`SELECT text FROM sentences + WHERE text IS NOT NULL AND TRIM(text) != ''`) + if err != nil { + return res, fmt.Errorf("read sentences for migration: %w", err) + } + for sentRows.Next() { + var text string + if err := sentRows.Scan(&text); err != nil { + _ = sentRows.Close() + return res, err + } + sb := NewSentenceBlock(text, time.Time{}, time.Time{}) + if err := putBlockTx(tx, sb); err != nil { + _ = sentRows.Close() + return res, fmt.Errorf("put sentence block: %w", err) + } + res.Sentences++ + } + if err := sentRows.Err(); err != nil { + _ = sentRows.Close() + return res, err + } + _ = sentRows.Close() + + if err := tx.Commit(); err != nil { + return res, err + } + log.Printf("[graph] 存量实体迁移完成: 句子 %d,块 %d,边 %d"+ + "(跳过孤儿关系 %d,去重边 %d,无向量 %d)", + res.Sentences, res.Blocks, res.Edges, + res.SkippedOrphan, res.DedupedEdges, res.SkippedNoVec) + return res, nil +} + +type legacyEnt struct { + id int64 + name string + createdAt, updatedAt time.Time +} + +// MemoryBlock 的时间戳来自源实体 —— 值覆盖维度(同一属性先后给两个值) +// 靠的就是时序。迁移时若统一写 NOW(),就把 188 个块的先后关系抹平成 +// 同一时刻,`overwrite` 检索就退化成「新旧并列,取谁全靠向量相似度」—— +// 实测 chineseclip 对新旧号短句给 0.9298 vs 0.9284,数值上根本区分不了。 +type legacyRel struct { + id, src, tgt int64 + typ string + createdAt time.Time + // ★ 以下四列 2026-10-04 补:旧 relations 的属性列此前**根本没被读取**, + // 于是迁移出的边全是空属性 —— 而边表把 confidence 当唯一的质量信号 + // (RecallSorted 的相关性排序、场景权重、蒸馏置信度传播都靠它)。 + // 实测生产迁移后 959 条边 confidence 全为 0。 + confidence float64 + sessionID string + turnID int + status string +} + +// readLegacySnapshot 在短读锁内取全部实体与关系。 +func (g *GraphDB) readLegacySnapshot() ([]legacyEnt, []legacyRel, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + rows, err := g.db.Query(`SELECT id, name, + COALESCE(created_at, ''), + COALESCE(updated_at, '') + FROM entities + WHERE name IS NOT NULL AND TRIM(name) != '' ORDER BY id`) + if err != nil { + return nil, nil, err + } + var ents []legacyEnt + for rows.Next() { + var e legacyEnt + var created, updated string + if err := rows.Scan(&e.id, &e.name, &created, &updated); err != nil { + rows.Close() + return nil, nil, err + } + e.createdAt = parseLegacyTime(created) + e.updatedAt = parseLegacyTime(updated) + ents = append(ents, e) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, nil, err + } + + relRows, err := g.db.Query(`SELECT id, source_id, target_id, + COALESCE(relation_type, ''), + COALESCE(confidence, 0), COALESCE(session_id, ''), + COALESCE(turn_id, 0), COALESCE(status, '') + FROM relations ORDER BY id`) + if err != nil { + return nil, nil, err + } + var rels []legacyRel + for relRows.Next() { + var r legacyRel + if err := relRows.Scan(&r.id, &r.src, &r.tgt, &r.typ, + &r.confidence, &r.sessionID, &r.turnID, &r.status); err != nil { + relRows.Close() + return nil, nil, err + } + rels = append(rels, r) + } + relRows.Close() + return ents, rels, relRows.Err() +} + +// legacyEntityBlockID 由**实体 id**派生块 id。 +// +// 用 id 而非内容:实体的 name 是 UNIQUE 的,但迁移期同一句话可能既是实体 +// 又是句子(下面第 3 步会把关系句子也建成块),用内容派生会撞。 +// 实体 id 保证块 id 稳定且可重复迁移(幂等)。 +func legacyEntityBlockID(entityID int64, name string) string { + return fmt.Sprintf("blk_ent_%d_%s", entityID, shortHash(name)) +} + +func shortHash(s string) string { + var h uint64 = 14695981039346656037 + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= 1099511628211 + } + return fmt.Sprintf("%08x", uint32(h)) +} + +// legacyTimeLayouts 是旧库里时间列出现过的格式。 +// +// entities.created_at 声明为 TIMESTAMP 但 SQLite 是弱类型:经由 +// COALESCE 或历史写入路径存进去的可能是裸字符串,driver 会把它作为 +// string 返回(直接扫进 time.Time 会报 unsupported Scan)。实测就是 +// 卡在这里 —— 所以扫到 string 自己解析,而不是赌 driver 的类型推断。 +var legacyTimeLayouts = []string{ + "2006-01-02 15:04:05.999999999-07:00", + "2006-01-02 15:04:05-07:00", + "2006-01-02 15:04:05.999999999", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05.999999999Z07:00", + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05", + "2006-01-02", +} + +// parseLegacyTime 解析旧库时间列;空值或无法识别时返回零值, +// 由 putBlockTx 兜底成 NOW()(宁可丢时序,也不能编造一个错的时刻)。 +func parseLegacyTime(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + for _, layout := range legacyTimeLayouts { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC() + } + } + log.Printf("[graph] 旧库时间 %q 格式无法识别,该块将落为当前时间(时序信息丢失)", s) + return time.Time{} +} + +// ── 事务内工具(与 block.go 的公开方法同款语义,但共用同一个 tx)── + +func ensureSentenceTx(tx *sql.Tx, text string) (int64, error) { + if _, err := tx.Exec(`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, text); err != nil { + return 0, err + } + var id int64 + if err := tx.QueryRow(`SELECT id FROM sentences WHERE text = ?`, text).Scan(&id); err != nil { + return 0, err + } + return id, nil +} + +func putBlockTx(tx *sql.Tx, b MemoryBlock) error { + vectorJSON := "" + if len(b.Vector) > 0 { + raw, err := json.Marshal(b.Vector) + if err != nil { + return err + } + vectorJSON = string(raw) + } + // 时间戳显式写入(COALESCE 兜底 NOW()):值覆盖维度依赖块间先后, + // 全部落成同一时刻就等于把时序抹平。 + // + // ★★ 但「零值」要区分两种语义(实测 Commit 块化后踩过) + // + // ① 迁移/蒸馏显式指定时间 → 用它 + // ② 调用方未指定(零值) → 填 now,但**绝不覆盖已有时间** + // + // 第一版对②也用 now + 无条件 ON CONFLICT SET created_at,于是 + // Commit 块化之后: + // + // Commit 写 blk_ent_(无时序)→ 填 now + // → 迁移处理同名实体 → ON CONFLICT 覆盖 created_at + // → 迁移块继承的实体时序被抹平 + // + // 实测迁移测试直接抓到: + // + // blk_ent_edb5a71b11d814fdc36e8332 应继承实体时间 2026-01-01 10:00 + // 实际 2026-10-04 08:05(时序被抹平) + // + // 而时序是**仲裁的前提**(arbitration.go 靠 CreatedAt 判断谁取代谁)。 + // ⇒ 零值时 UPDATE 分支**不碰时间列**。 + created := b.CreatedAt + updated := b.UpdatedAt + explicitTime := !created.IsZero() + if !explicitTime { + created = time.Now() + } + if updated.IsZero() { + if explicitTime { + updated = created + } else { + updated = created + } + } + // 只有显式指定时间时才在 UPDATE 分支写时间列 + tsUpdate := "created_at = excluded.created_at, updated_at = excluded.updated_at" + if !explicitTime { + tsUpdate = "updated_at = memory_blocks.updated_at" + } + _, err := tx.Exec(`INSERT INTO memory_blocks + (id, modality, text_content, payload_digest, mime, size, width, height, + vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + modality = excluded.modality, + text_content = excluded.text_content, + vector = excluded.vector, + fingerprint = excluded.fingerprint, + source = excluded.source, + semantic_type = CASE WHEN excluded.semantic_type != '' + THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, + `+tsUpdate, + b.ID, b.Modality, b.Text, b.PayloadDigest, b.MIME, b.Size, b.Width, b.Height, + vectorJSON, b.Fingerprint, b.Source, b.Tool, b.Scene, b.SemanticType, + created, updated) + return err +} + +// ★ 返回 (error, 是否真的新增了一行)。第二个返回值是给报告口径用的 —— +// +// `INSERT OR IGNORE` 静默去重时它为 false。 +func addBlockEdgeTx(tx *sql.Tx, sourceKind, sourceID, targetKind, targetID, edgeType string) (error, bool) { + // 端点存在性校验:与 AddMemoryBlockEdge 同款,但共用当前事务 —— + // 分开校验会在并发下出现「校验通过后节点被删」的窗口。 + for _, ep := range []struct{ kind, id string }{ + {sourceKind, sourceID}, {targetKind, targetID}} { + var n int + var err error + switch ep.kind { + case "block": + err = tx.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, ep.id).Scan(&n) + case "sentence": + err = tx.QueryRow(`SELECT COUNT(*) FROM sentences WHERE CAST(id AS TEXT) = ?`, ep.id).Scan(&n) + default: + err = fmt.Errorf("invalid graph node kind %q", ep.kind) + } + if err != nil { + return err, false + } + if n == 0 { + return fmt.Errorf("%s graph node %s does not exist", ep.kind, ep.id), false + } + } + // ★ 显式查重:边表升格去掉了 UNIQUE 约束(关系边要能并存多条), + // 而 INSERT OR IGNORE 的去重正是靠那个 UNIQUE 实现的。 + // 不查重的话迁移跑第二遍会插出重复的 contains 边 + // (实测 TestMigrateLegacyTextEntities_幂等 当场红了)。 + var existing int + if err := tx.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE source_kind=? AND source_id=? AND target_kind=? AND target_id=? + AND edge_type=? AND COALESCE(session_id,'')=''`, + sourceKind, sourceID, targetKind, targetID, edgeType).Scan(&existing); err != nil { + return err, false + } + if existing > 0 { + return nil, false // 已存在:结构边幂等(不算新增) + } + + res, err := tx.Exec(`INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type) + VALUES (?, ?, ?, ?, ?)`, sourceKind, sourceID, targetKind, targetID, edgeType) + if err != nil { + return err, false + } + // ★ 返回「是否真的新增了一行」。 + // + // `INSERT OR IGNORE` 在冲突时**既不报错也不新增**,调用方若只看 + // error 就会把「被去重」当成「写入成功」—— + // 生产快照实测:relations 980 行(含 20 组完全重复的三元组), + // 报告写「边 980」而边表实际 959。 + n, err := res.RowsAffected() + if err != nil { + return err, false + } + return nil, n > 0 +} + +// LegacyEntity 是存量实体的一条只读快照。 +type LegacyEntity struct { + ID int64 + Name string +} + +// LegacyEntities 读出存量实体,供拆分/迁移命令使用。 +// +// 只读,不删任何东西 —— 方案 A 里旧表的清理由调用方在验证通过后 +// 单独执行(见 cmd/homed-graph-migrate 的说明)。 +// +// limit <= 0 表示不限。分批是为了让拆分能增量推进(CPU 上 1.7b 模型 +// 每条约 1~2 秒,全量 188 条要 3~6 分钟)。 +// +// minLen > 0 时跳过过短的实体名:标题类实体(「order-gw 运维进展」「每天」 +// 「老大」)本来就没有可拆字段,实测对它们跑拆分 8/8 全零字段 —— +// 那是正确结果,但会让报告看起来像「拆分坏了」。按长度预筛能把注意力 +// 放在真有字段的记录上。 +func (g *GraphDB) LegacyEntities(limit, minLen int) ([]LegacyEntity, error) { + g.mu.RLock() + defer g.mu.RUnlock() + + query := `SELECT id, name FROM entities + WHERE name IS NOT NULL AND TRIM(name) != ''` + args := []any{} + if minLen > 0 { + query += ` AND LENGTH(name) >= ?` + args = append(args, minLen) + } + query += ` ORDER BY id` + if limit > 0 { + query += ` LIMIT ?` + args = append(args, limit) + } + rows, err := g.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LegacyEntity + for rows.Next() { + var e LegacyEntity + if err := rows.Scan(&e.ID, &e.Name); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} + +// CountLegacyEntitiesByType 统计旧表 entities 里某类型的行数。 +// +// ★ 用途:判据「迁移后旧表不该残留 X」。 +// +// 读侧切块之后,Recall 返回的东西既可能是块也可能是旧表行, +// 而两者 Name/Type 可能完全一样 —— 光看召回结果分不出来。 +// 于是判据必须**直查旧表**,而那是本包的私有 db。 +// +// ★ 只读,无副作用。minLen=0 表示不限名长。 +// +// ★ 它存在的另一理由:`LegacyEntity` 只带 ID/Name(不含 Type), +// +// 所以拿它做「按类型核查」并不够用。 +func (g *GraphDB) CountLegacyEntitiesByType(entityType string) (int, error) { + g.mu.RLock() + defer g.mu.RUnlock() + var n int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM entities WHERE type = ?`, entityType).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// LegacyRowCount 执行一条**只读**的 COUNT 查询并返回行数。 +// +// ★ 为什么需要它(2026-10-04) +// +// Introspect 的 entity_count / relation_count 在读侧切块之后 +// 数的是**块侧**。而迁移报告要的是「旧表还剩多少没迁走」—— +// 用 Introspect 的数会得到「关系 0」这种**假的结论**, +// 进而让运维以为「早就迁完了」而跳过迁移。 +// +// ★ 风险控制:query 必须是完整的 SELECT COUNT(*) 语句。 +// +// 这不是「内部函数所以可以放心拼接」—— 迁移命令会接收用户给的 +// -db 路径,而这类拼接口子在出错时很难定位。 +// 所以:只允许 COUNT、只允许单表、表名白名单在调用方校验。 +// 违反任一条直接拒绝,而不是执行。 +func (g *GraphDB) LegacyRowCount(query string) (int, error) { + q := strings.TrimSpace(query) + upper := strings.ToUpper(q) + if !strings.HasPrefix(upper, "SELECT COUNT(*) FROM ") { + return 0, fmt.Errorf("LegacyRowCount: 只允许 SELECT COUNT(*) FROM ") + } + rest := q[len("SELECT COUNT(*) FROM "):] + up := strings.ToUpper(rest) + // ★ 关键字一律按「子串」判定,不要按「前缀/后缀长度」判定。 + // + // 我第一版写的是 `rest[len(rest)-12:] == " GROUP BY "`, + // 而 " GROUP BY " 只有 **10** 个字符 —— 长度判断让它永远不成立, + // 于是 `GROUP BY type` 直接穿过守卫。 + // ★ 这类「用长度近似关键字」的写法在判据里看着能过, + // 因为测试数据恰好没踩到 —— 直到判据专门去试它。 + for _, banned := range []string{" JOIN ", " GROUP BY ", " UNION ", ";"} { + if strings.Contains(up, banned) || strings.HasPrefix(up, strings.TrimSpace(banned)) { + return 0, fmt.Errorf("LegacyRowCount: 不允许 %q", strings.TrimSpace(banned)) + } + } + g.mu.RLock() + defer g.mu.RUnlock() + var n int + if err := g.db.QueryRow(q).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// addMigratedRelationTx 迁移一条**旧 relations 行**到独立关系边。 +// +// ★★ 为什么不复用 addBlockEdgeTx +// +// addBlockEdgeTx 是**结构边**写入器:去重条件里带 +// `COALESCE(session_id,'')=''`(结构边无 session), +// 而且它只写 5 列,不带 confidence / session / turn / status。 +// +// 而迁移的关系边**恰恰要带 session_id** —— 于是那个去重条件 +// 会让**每一条**要迁移的边都被判成「已存在」而跳过。 +// (实测若不改:生产 959 条边一条都迁不进去。) +// +// ★ 去重口径:同 (source, target, edge_type, session_id) 视为同一条。 +// +// 生产 relations 里有 21 组完全重复的三元组(实测)—— +// 它们是**迁移期历史重复**(同一事实被记了两次,同一会话内), +// 按 session 收敛掉是对的;而**跨会话**的重复是真实的多次陈述, +// 必须各存一条(关系边按设计允许并存多条)。 +// +// ★ status:旧表有 deleted/archived 的关系不应迁成 active —— +// +// 否则一条已删除的记忆会在召回里复活。 +// 只迁 status='active';其余映射为 'deleted' 保留痕迹。 +func addMigratedRelationTx(tx *sql.Tx, src, tgt, edgeType string, + confidence float64, sessionID string, turnID int, status string) (error, bool) { + if src == "" || tgt == "" || edgeType == "" { + return fmt.Errorf("migrate relation: endpoints and type required"), false + } + var existing int + if err := tx.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE source_kind='block' AND source_id=? + AND target_kind='block' AND target_id=? + AND edge_type=? AND COALESCE(session_id,'')=?`, + src, tgt, edgeType, sessionID).Scan(&existing); err != nil { + return err, false + } + if existing > 0 { + return nil, false + } + + // ★ 非 active 的旧关系迁成 deleted,不迁成 active。 + if status != "" && status != "active" { + status = EdgeDeleted + } + if status == "" { + status = EdgeActive + } + res, err := tx.Exec(`INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type, + confidence, status, session_id, turn_id, created_at) + VALUES ('block', ?, 'block', ?, ?, ?, ?, ?, ?, ?)`, + src, tgt, edgeType, confidence, status, sessionID, turnID, + time.Now()) + if err != nil { + return err, false + } + n, _ := res.RowsAffected() + return nil, n > 0 +} + +// SeedLegacyMediaEntity 为**测试**在旧表造一条媒体实体。 +// +// ★★ 为什么需要它 +// +// 迁移的输入是旧表 `entities WHERE type='Media'`。而测试原先靠 +// `CommitWithMedia` 造这份旧表数据 —— 但旧表双写已停(2026-10-04), +// Commit 不再写它,于是迁移的输入**天然为空**,测试变成假通过 +// (断言「迁出 0 条 0 实体」竟然成立)。 +// +// ★ 这比直接失败更糟:它让「迁移还对不对」这个问题**无法回答**。 +// +// ★ 所以把「旧表长什么样」显式化:测试直接写旧表, +// +// 迁移动作与被迁数据彼此独立。 +// +// ⚠ 仅供测试使用 —— 生产代码不应有这条路径。 +// +// 真正的迁移只读旧表,从不写。 +func (g *GraphDB) SeedLegacyMediaEntity(name, entityType string) (int64, error) { + g.mu.Lock() + defer g.mu.Unlock() + res, err := g.db.Exec(`INSERT INTO entities (name, type) VALUES (?, ?)`, + name, entityType) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// SeedLegacySentenceRow 为**测试**在旧表造一条原句行。 +// +// ⚠ 仅供测试。与 SeedLegacyMediaEntity 同理 —— +// 迁移的输入是旧表,测试必须能直接构造它 +// (Commit 已不写旧表,见那个函数的注释)。 +func (g *GraphDB) SeedLegacySentenceRow(text string) (int64, error) { + g.mu.Lock() + defer g.mu.Unlock() + res, err := g.db.Exec(`INSERT OR IGNORE INTO sentences (text) VALUES (?)`, text) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// LinkLegacyEntityToSentence 为**测试**造一条旧 relations 行, +// 把媒体实体与原句关联起来(迁移靠它找到「该挂到哪句上」)。 +func (g *GraphDB) LinkLegacyEntityToSentence(entityID, sentenceID int64) error { + g.mu.Lock() + defer g.mu.Unlock() + _, err := g.db.Exec( + `INSERT INTO relations (source_id, target_id, relation_type, confidence, + session_id, turn_id, date_bucket, sentence_id) + VALUES (?, ?, '内容', 1.0, 'legacy', 0, '', ?)`, + entityID, sentenceID, sentenceID) + return err +} + +// SeedLegacyEntity 为**测试**在旧表 entities 造一行。 +// +// ⚠ 仅供测试。迁移的输入是旧表,而旧表双写已停(2026-10-04) +// +// ⇒ Commit 不再产生这份数据 ⇒ 测试必须能直接构造它。 +// 否则「迁移输入为空 ⇒ 迁出 0 条」会假通过。 +// +// 见 seedLegacy(migrate_entities_test.go)。 +func (g *GraphDB) SeedLegacyEntity(name, entityType string) (int64, error) { + g.mu.Lock() + defer g.mu.Unlock() + var id int64 + err := g.db.QueryRow(`SELECT id FROM entities WHERE name = ?`, name).Scan(&id) + if err == nil { + return id, nil + } + if err != sql.ErrNoRows { + return 0, err + } + res, err := g.db.Exec(`INSERT INTO entities (name, type) VALUES (?, ?)`, + name, entityType) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// SeedLegacyRelation 为**测试**在旧表 relations 造一行(按实体名)。 +// +// ⚠ 仅供测试,见 SeedLegacyEntity。 +func (g *GraphDB) SeedLegacyRelation(subject, object, relType string, + confidence float64, sessionID string, turnID int) (int64, int64, error) { + g.mu.Lock() + defer g.mu.Unlock() + + sid, err := g.seedLegacyEntityID(subject) + if err != nil { + return 0, 0, err + } + tid, err := g.seedLegacyEntityID(object) + if err != nil { + return 0, 0, err + } + res, err := g.db.Exec( + `INSERT INTO relations (source_id, target_id, relation_type, confidence, + session_id, turn_id, date_bucket, sentence_id) + VALUES (?, ?, ?, ?, ?, ?, '', 0)`, + sid, tid, relType, confidence, sessionID, turnID) + if err != nil { + return 0, 0, err + } + if _, err := res.LastInsertId(); err != nil { + return 0, 0, err + } + return sid, tid, nil +} + +func (g *GraphDB) seedLegacyEntityID(name string) (int64, error) { + var id int64 + err := g.db.QueryRow(`SELECT id FROM entities WHERE name = ?`, name).Scan(&id) + if err == nil { + return id, nil + } + if err != sql.ErrNoRows { + return 0, err + } + res, err := g.db.Exec(`INSERT INTO entities (name, type) VALUES (?, 'Concept')`, name) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// LinkLegacyTripleToSentence 为**测试**把某条三元组的原句关联上。 +// +// ⚠ 仅供测试。迁移靠 relations.sentence_id 找到 +// 「原句要建块」与「关系该挂到哪句上」。 +func (g *GraphDB) LinkLegacyTripleToSentence(subject, object, relType string, sentenceID int64) error { + g.mu.Lock() + defer g.mu.Unlock() + sid, err := g.seedLegacyEntityID(subject) + if err != nil { + return err + } + tid, err := g.seedLegacyEntityID(object) + if err != nil { + return err + } + _, err = g.db.Exec( + `UPDATE relations SET sentence_id = ? + WHERE source_id = ? AND target_id = ? AND relation_type = ?`, + sentenceID, sid, tid, relType) + return err +} diff --git a/internal/memory/migrate_entities_test.go b/internal/memory/migrate_entities_test.go new file mode 100644 index 00000000..a6beb6bd --- /dev/null +++ b/internal/memory/migrate_entities_test.go @@ -0,0 +1,923 @@ +package memory + +import ( + "fmt" + "strings" + "testing" + "time" +) + +func newMigrateGraph(t *testing.T) *GraphDB { + t.Helper() + g := newTestGraph(t) + t.Cleanup(func() { _ = g.Close() }) + return g +} + +// seedLegacy 为迁移测试造**旧表数据**。 +// +// ★★ 2026-10-04:不再用 Commit(2026-10-04) +// +// 旧表双写已停 ⇒ Commit 不再写 entities/relations/sentences +// ⇒ 迁移测试的**输入天然为空** +// ⇒ 「迁出 0 块 0 边」竟然通过。 +// +// ★★ 那不是「迁移正确」,是「测不到」—— +// +// 它让「迁移还对不对」这个问题无法回答, +// 而这正是判据最不该失效的地方。 +// +// ⇒ 直接写旧表:迁移的输入是旧表,测试也该直接构造旧表。 +// +// ★ 同时写一份块侧数据(用 Commit),因为迁移后的断言查的是块。 +func seedLegacy(t *testing.T, g *GraphDB, triples []Triple) { + t.Helper() + + // 块侧:Commit 现在只写块与边 + if _, _, err := g.Commit(triples, "s", 1); err != nil { + t.Fatalf("Commit: %v", err) + } + + // 旧表:迁移的输入,逐条写入 + for _, tr := range triples { + for _, name := range []string{tr.Subject, tr.Object} { + if name == "" { + continue + } + typ := tr.SubjectType + if name == tr.Object { + typ = tr.ObjectType + } + if typ == "" { + typ = "Concept" + } + if _, err := g.SeedLegacyEntity(name, typ); err != nil { + t.Fatalf("seed entity %q: %v", name, err) + } + } + // 关系(source=主语, target=宾语) + if _, _, err := g.SeedLegacyRelation(tr.Subject, tr.Object, + tr.Relation, tr.Confidence, "s", 1); err != nil { + t.Fatalf("seed relation %s/%s: %v", tr.Subject, tr.Relation, err) + } + // 原句 + if tr.SentenceText != "" { + sid, err := g.SeedLegacySentenceRow(tr.SentenceText) + if err != nil { + t.Fatalf("seed sentence: %v", err) + } + if err := g.LinkLegacyTripleToSentence( + tr.Subject, tr.Object, tr.Relation, sid); err != nil { + t.Fatalf("link sentence: %v", err) + } + } + } +} + +const fixedEmbed = "fp-migrate" + +// ★ 迁移形态:实体 → 句子+块,关系 → 块边。旧表**不删**(由调用方决定)。 +func TestMigrateLegacyTextEntities_形态(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{ + {Subject: "值班室分机号", Relation: "是", Object: "4324"}, + }) + + res, err := g.MigrateLegacyTextEntities(func(string) ([]float64, string) { + return []float64{1, 0, 0}, fixedEmbed + }) + if err != nil { + t.Fatalf("Migrate: %v", err) + } + // ★ 2 个实体 → 2 个迁移块 + 2 个原句块(方案 A 后原句也是块) + // + // res.Sentences 不再递增:统计口径改为「迁移块数」, + // 因为 sentences 表已不再被写入。 + if res.Blocks != 2 { + t.Errorf("2 个实体应得 2 个迁移块,实际 %+v", res) + } + if res.Sentences != 0 { + t.Errorf("迁移不应再创建 sentence 记录(方案 A 退场),实际 %d", res.Sentences) + } + // 1 条关系 → 1 条块边 + if res.Edges != 1 { + t.Errorf("1 条关系应得 1 条边,实际 %d", res.Edges) + } + + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + // ★ 只统计**迁移自己写的块**:legacy-entity(迁移块)与 sentence(原句块)。 + // + // 不能用 len(blocks) —— 因为 seedLegacy 走 Commit,而 Commit 在 + // 块化之后**也会写块**(source="triple"),那不是迁移的产物。 + // 实测:未过滤时是 6 = 4 迁移 + 2 Commit 写的值块。 + if n := countBySource(t, blocks, "legacy-entity"); n != 2 { + t.Errorf("应有 2 个迁移块,实际 %d", n) + } + if n := countBySource(t, blocks, SentenceBlockSource); n != 2 { + t.Errorf("应有 2 个原句块,实际 %d", n) + } + for _, b := range blocks { + // ★ 原句块刻意不带向量(溯源锚点,长整句会稀释召回) + // ★ 只检查迁移自己写的两种块:原句块(sentence)与迁移块(legacy-entity)。 + // + // 库里有第三种来源 triple —— 那是 seedLegacy 走 Commit 时写的 + // (Commit 块化之后),**不是迁移的产物**,不该被本测试断言。 + // + // 原句块 blk_src_ 无向量(溯源锚点) + // 迁移块 blk_ent__ 有向量 + // Commit 块 blk_ent_ 无向量、无时序 + // + // 三者是**不同语义的东西**,不是同一个东西的重复。 + switch b.Source { + case SentenceBlockSource: + if len(b.Vector) != 0 { + t.Errorf("原句块 %s 不该带向量,实际 %d 维", b.ID, len(b.Vector)) + } + case "legacy-entity": + if len(b.Vector) != 3 || b.Fingerprint != fixedEmbed { + t.Errorf("迁移块 %s 应带 3 维向量与 %s,实际 %d 维 %q", + b.ID, fixedEmbed, len(b.Vector), b.Fingerprint) + } + case "triple": + // Commit 块化的产物,本测试不关心(见 TestBlockCommit_*) + default: + t.Errorf("块 %s 来源 %q 既不是迁移产物也不是 Commit 产物", b.ID, b.Source) + } + } + + // 每块都应有一条 sentence--contains--> 边 + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + containsCount := 0 + for _, e := range edges { + // ★ 起点是**原句块**(block),不再是 sentence 表行 —— 方案 A + if e.Type == "contains" && e.SourceKind == "block" && e.TargetKind == "block" { + containsCount++ + } + } + if containsCount != 2 { + t.Errorf("应有 2 条 contains 边,实际 %d(总边数 %d)", containsCount, len(edges)) + } + + // ★ 旧表还在(迁移不删数据,可回滚) + r, err := g.Recall([]string{"值班室分机号"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + if len(r.Entities) == 0 { + t.Error("迁移不应删除 entities(由调用方在验证通过后决定清理)") + } +} + +// ★ 幂等:重复迁移不产生重复块。 +func TestMigrateLegacyTextEntities_幂等(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{ + {Subject: "主体甲", Relation: "是", Object: "客体乙"}, + }) + embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed } + + for i := 0; i < 3; i++ { + if _, err := g.MigrateLegacyTextEntities(embed); err != nil { + t.Fatalf("第 %d 次迁移: %v", i, err) + } + } + blocks, _ := g.MemoryBlocks() + // ★ 重复跑不增:迁移块与原句块各 2 个 + // (不过滤 source='triple' —— 那是 seedLegacy 时 Commit 写的, + // 重复跑迁移不会再增加它们,但会出现在 blocks 里) + if n := countBySource(t, blocks, "legacy-entity"); n != 2 { + t.Fatalf("迁移 3 次后仍应只有 2 个迁移块,实际 %d", n) + } + edges, _ := g.MemoryBlockEdges() + contains := 0 + for _, e := range edges { + if e.Type == "contains" { + contains++ + } + } + if contains != 2 { + t.Fatalf("contains 边应仍是 2 条,实际 %d", contains) + } +} + +// ★ 无 embed 时仍迁移,但块不带向量——不编造零向量。 +func TestMigrateLegacyTextEntities_无embed不编造(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: "是", Object: "客体乙"}}) + + res, err := g.MigrateLegacyTextEntities(nil) + if err != nil { + t.Fatal(err) + } + if res.Blocks != 2 { + t.Fatalf("无 embed 也应迁移,实际 %d 块", res.Blocks) + } + if res.SkippedNoVec != 2 { + t.Errorf("应统计 2 个无向量块,实际 %d", res.SkippedNoVec) + } + blocks, _ := g.MemoryBlocks() + for _, b := range blocks { + if len(b.Vector) != 0 { + t.Errorf("%s 不该有向量,实际 %d 维", b.ID, len(b.Vector)) + } + } +} + +// embed 返回 nil 向量时算「算不出」,迁移继续但不计入成功。 +func TestMigrateLegacyTextEntities_embed失败仍迁移(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: "是", Object: "客体乙"}}) + res, err := g.MigrateLegacyTextEntities(func(string) ([]float64, string) { + return nil, "" // 模型加载失败 + }) + if err != nil { + t.Fatalf("embed 失败不该让迁移失败: %v", err) + } + if res.Blocks != 2 { + t.Fatalf("应仍迁移出 2 块,实际 %d", res.Blocks) + } + if res.SkippedNoVec != 2 { + t.Errorf("应统计 2 个无向量,实际 %d", res.SkippedNoVec) + } +} + +// ★ 孤儿关系(端点实体为空名)被跳过并计数,不编造边。 +func TestMigrateLegacyTextEntities_孤儿关系跳过(t *testing.T) { + g := newMigrateGraph(t) + // 直接构造一个指向空名实体的关系是不可能的(upsert 拒绝空名), + // 所以这里测的是「关系两端都存在」时边数正确,作为对照基线。 + seedLegacy(t, g, []Triple{ + {Subject: "主体甲", Relation: "是", Object: "客体乙"}, + {Subject: "主体甲", Relation: "属于", Object: "客体丙"}, + }) + res, err := g.MigrateLegacyTextEntities(nil) + if err != nil { + t.Fatal(err) + } + if res.Edges != 2 { + t.Errorf("2 条关系应得 2 条边,实际 %d", res.Edges) + } + if res.SkippedOrphan != 0 { + t.Errorf("端点齐全时不该有跳过,实际 %d", res.SkippedOrphan) + } +} + +// 空关系类型用占位名而不是被拒绝(关系的**存在**本身是信息)。 +func TestMigrateLegacyTextEntities_空关系类型用占位(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{{Subject: "主体甲", Relation: " ", Object: "客体乙"}}) + res, err := g.MigrateLegacyTextEntities(nil) + if err != nil { + t.Fatalf("空关系类型不该让迁移失败: %v", err) + } + if res.Edges != 1 { + t.Fatalf("空关系类型也应建成边,实际 %d", res.Edges) + } + edges, _ := g.MemoryBlockEdges() + found := false + for _, e := range edges { + if e.Type == "related_to" { + found = true + } + } + if !found { + t.Errorf("应有占位关系类型的边,实际 %+v", edges) + } +} + +// ★ 事务回滚:迁移中途失败时,**库内容保持迁移前状态**。 +// +// 失败注入方式:给两个实体起相同的块 ID 是不行的(ID 由 entity id 派生, +// 天然唯一)。改用「让 putBlockTx 失败」——预先占位一个**非块节点类型** +// 的行占住表结构,或直接删掉 memory_blocks 表。 +// +// 删表是最直接的:阶段三第一次 putBlockTx 就会报 no such table, +// 此时前面的 ensureSentenceTx 已经在事务里写过句子 —— 若无回滚, +// 那些句子会留在库里。 +func TestMigrateLegacyTextEntities_中途失败整体回滚(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{ + {Subject: "主体甲", Relation: "是", Object: "客体乙"}, + }) + beforeBlocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + beforeSentences := countSentences(t, g) + + // 在阶段三开始前破坏块表:此时阶段一(读)已完成、阶段二(embed)正在跑 + calls := 0 + _, err = g.MigrateLegacyTextEntities(func(string) ([]float64, string) { + calls++ + if calls == 1 { + g.mu.Lock() + _, dbErr := g.db.Exec(`DROP TABLE memory_blocks`) + g.mu.Unlock() + if dbErr != nil { + t.Fatalf("构造失败: %v", dbErr) + } + t.Cleanup(func() { + g.mu.Lock() + _, _ = g.db.Exec(ddlMemoryBlocks) + g.mu.Unlock() + }) + } + return []float64{1, 0}, "fp-x" + }) + if err == nil { + t.Fatal("块表被删后迁移应失败") + } + t.Logf("迁移如预期失败: %v", err) + + // 关键断言:块与句子都没留下(回滚生效) + afterSentences := countSentences(t, g) + if afterSentences != beforeSentences { + t.Errorf("失败后句子数应不变(回滚),实际 %d → %d", + beforeSentences, afterSentences) + } + _ = beforeBlocks + + // 恢复表后确认库里确实没有残留的块 + g.mu.Lock() + _, dbErr := g.db.Exec(ddlMemoryBlocks) + g.mu.Unlock() + if dbErr != nil { + t.Fatal(dbErr) + } + afterBlocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + if len(afterBlocks) != 0 { + t.Errorf("失败后不应留下块(回滚),实际 %d 块: %+v", len(afterBlocks), afterBlocks) + } +} + +func countSentences(t *testing.T, g *GraphDB) int { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + var n int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences`).Scan(&n); err != nil { + t.Fatalf("count sentences: %v", err) + } + return n +} + +// 块 id 含实体 id 前缀(可读 + 稳定)。 +func TestLegacyEntityBlockID_稳定可读(t *testing.T) { + a := legacyEntityBlockID(42, "值班室分机号") + b := legacyEntityBlockID(42, "值班室分机号") + c := legacyEntityBlockID(43, "值班室分机号") + if a != b { + t.Error("同实体应得同块 ID") + } + if a == c { + t.Error("不同实体应得不同块 ID") + } + if !strings.Contains(a, "blk_ent_42") { + t.Errorf("块 ID 应含实体 id 便于排查,实际 %q", a) + } +} + +// ★ 值覆盖维度依赖时序:迁移必须保住实体的先后关系。 +// +// 背景:实测(真实 chineseclip + 真库)同一属性先后给两个值时, +// 「值班室分机号 4324」与「值班室分机号 4379」短句向量相似度是 +// 0.9284 vs 0.9298 —— 数值上区分不了。若迁移把块的时���全写成 NOW(), +// overwrite 检索就彻底没救了。 +// +// ★ 断言覆盖**全部**块:初版只查了含关键字的那一个,结果把 +// 「CreatedAt 不传」的变异判成通过 —— 假绿。实际漏掉的是没被 UPDATE +// 到的那些实体(它们本就该落 NOW(),但保护要能区分两种情况)。 +func TestMigrateLegacyTextEntities_保留时序(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{ + {Subject: "值班室分机号", Relation: "是", Object: "四三七九"}, + }) + // 给**两个**实体都写同一个历史时刻 + g.mu.Lock() + _, err := g.db.Exec(`UPDATE entities SET created_at = '2026-01-01 10:00:00', + updated_at = '2026-01-01 10:00:00'`) + g.mu.Unlock() + if err != nil { + t.Fatal(err) + } + + if _, err := g.MigrateLegacyTextEntities(nil); err != nil { + t.Fatal(err) + } + + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + if len(blocks) == 0 { + t.Fatal("无块") + } + want := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + for _, b := range blocks { + // ★ 只检查**迁移块**(source=legacy-entity)。 + // + // 库里有第三种来源 triple —— seedLegacy 走 Commit 时写的 + // (Commit 块化之后)。那些块的 created_at 由 PutMemoryBlocks + // 填成 now,**本就不该继承实体时间**。 + // + // 第一版没过滤 source,于是断言去检查 Commit 块, + // 报「时序被抹平」—— 而被抹平的是 Commit 块,不是迁移块。 + // ★ 差点误判成「块化破坏了迁移时序」,那会推翻正确的实现。 + if b.Source != "legacy-entity" { + continue + } + if b.CreatedAt.IsZero() { + t.Errorf("迁移块 %s created_at 为零值", b.ID) + continue + } + if !b.CreatedAt.Equal(want) { + t.Errorf("迁移块 %s 应继承实体时间 %v,实际 %v(时序被抹平/未传递)", + b.ID, want, b.CreatedAt) + } + } +} + +// 多个实体的时间戳必须**不同**(全都落成同一时刻等于抹平时序)。 +func TestMigrateLegacyTextEntities_时序不被抹平(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{ + {Subject: "值班室分机号", Relation: "是", Object: "四三七九"}, + {Subject: "新分机号码", Relation: "是", Object: "四三二四"}, + }) + // 主语(较早)与后写入的实体(较晚) + g.mu.Lock() + _, err := g.db.Exec(`UPDATE entities SET + created_at = CASE WHEN name IN ('值班室分机号', '四三七九') + THEN '2026-01-01 10:00:00' ELSE '2026-01-01 11:00:00' END, + updated_at = CASE WHEN name IN ('值班室分机号', '四三七九') + THEN '2026-01-01 10:00:00' ELSE '2026-01-01 11:00:00' END`) + g.mu.Unlock() + if err != nil { + t.Fatal(err) + } + + if _, err := g.MigrateLegacyTextEntities(nil); err != nil { + t.Fatal(err) + } + blocks, _ := g.MemoryBlocks() + // 主语+宾语各成一块:「值班室分机号」「新分机号码」「四三七九」「四三二四」 + // ★ 4 个实体 → 4 迁移块(+ 4 原句块) + // + // 不能用 len(blocks):seedLegacy 走 Commit,块化之后它也写块 + // (source="triple",无向量、无实体时序)。实测混进来是 12。 + // ⇒ 只数迁移自己的两种块。 + if n := countBySource(t, blocks, "legacy-entity"); n != 4 { + t.Fatalf("应得 4 个迁移块,实际 %d(另含 triple 块 %d 个)", n, + countBySource(t, blocks, "triple")) + } + // 判据:4 个块必须分属两个不同时刻(主语+首宾语 10:00, + // 次宾语对 11:00)。若全落同一时刻,时序就被抹平了。 + // ★ 注意:不能断言「排序后首两块不等」—— 10:00 那组本来就有 2 块, + // 同刻是正常的。判据是「时刻的分布」而不是「相邻两块是否相等」。 + // ★ 只统计**迁移块**(source=legacy-entity)。 + // Commit 块(source=triple)的 created_at 是 PutMemoryBlocks 填的 now + // —— 它会把 seen 变成 3 个时刻(多了 2026-10-04)而误判「时序被抹平」。 + seen := map[string]int{} + for _, b := range blocks { + if b.Source != "legacy-entity" { + continue + } + seen[b.CreatedAt.UTC().Format("2006-01-02 15:04")]++ + } + if len(seen) != 2 { + t.Errorf("块应分属 2 个不同时刻,实际 %d 个:%v", len(seen), seen) + } + // ★ 现在只数迁移块 ⇒ 每组 2 个实体 = 2 个迁移块 + // (原句块不参与时序判据:它们的时间是 Commit 填的 now) + if seen["2026-01-01 10:00"] != 2 { + t.Errorf("10:00 组应有 2 个迁移块,实际 %d", seen["2026-01-01 10:00"]) + } + if seen["2026-01-01 11:00"] != 2 { + t.Errorf("11:00 组应有 2 个迁移块,实际 %d", seen["2026-01-01 11:00"]) + } +} + +// 旧库时间格式不认得时:宁可零值兜底,也不编造错的时刻。 +func TestParseLegacyTime(t *testing.T) { + cases := []struct { + in string + want string // 空 = 期望零值 + }{ + {"2026-01-01 10:00:00", "2026-01-01 10:00:00"}, + {"2026-01-01T10:00:00Z", "2026-01-01 10:00:00"}, + {"2026-01-01 10:00:00.123456", "2026-01-01 10:00:00.123456"}, + {"2026-01-01", "2026-01-01 00:00:00"}, + {"", ""}, + {" ", ""}, + {"去年某天", ""}, + } + for _, c := range cases { + got := parseLegacyTime(c.in) + if c.want == "" { + if !got.IsZero() { + t.Errorf("parseLegacyTime(%q) 应为零值,实际 %v", c.in, got) + } + continue + } + want, err := time.Parse("2006-01-02 15:04:05", c.want) + if err != nil { + t.Fatal(err) + } + if !got.Equal(want) { + t.Errorf("parseLegacyTime(%q) = %v,期望 %v", c.in, got, want) + } + } +} + +// ★★ 报告数必须等于实际写入数。 +// +// 生产快照实测(1294 实体 / 980 relations): +// +// 迁移报告 边 980 +// 边表实际 959 ← 差 21 +// +// 根因:addBlockEdgeTx 用 `INSERT OR IGNORE` 且**不检查 RowsAffected**, +// 而 `res.Edges++` 照加。relations 表里有 20 组 (src,tgt,type) 完全重复 +// (各 2 次),980 → 去重 959。 +// +// ★ 与 941e6b9 同类:那是 relations 口径(active vs 全表), +// +// 这是边去重口径。两次都是「报告数 ≠ 实际写入数」, +// 而用户会把报告数当承诺。 +func TestMigrate_报告数等于实际写入数(t *testing.T) { + g := newMigrateGraph(t) + seedLegacy(t, g, []Triple{ + {Subject: "值班室分机号", Relation: "是", Object: "4324"}, + }) + // ★ 生产库里有 20 组 (src,tgt,type) 完全重复的关系行。 + // seedLegacy 用 Upsert(第二次覆盖第一次)⇒ 造不出重复, + // 所以直接插库 —— 这也是之前判据「通过」却没测到问题的原因。 + if _, err := g.db.Exec(`INSERT INTO relations + (source_id, target_id, relation_type, confidence, status) + SELECT source_id, target_id, relation_type, confidence, status + FROM relations LIMIT 1`); err != nil { + t.Fatalf("插入重复关系: %v", err) + } + + embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed } + res, err := g.MigrateLegacyTextEntities(embed) + if err != nil { + t.Fatal(err) + } + + edges, err := g.MemoryBlockEdges() + if err != nil { + t.Fatal(err) + } + // ★ 只数**迁移块之间**的边(两端都是 blk_ent__ 形态)。 + // + // 边表里还有 Commit 块化写的边(source=triple,两端是 blk_ent_) + // —— 那不是迁移的产物,而 res.Edges 只数迁移自己写的。 + // 第一版按「非 contains」统计,把 Commit 的边也算进去了 ⇒ 误报。 + relEdges := 0 + for _, e := range edges { + if e.Type == "contains" { + continue + } + if isLegacyEntityBlockID(e.SourceID) && isLegacyEntityBlockID(e.TargetID) { + relEdges++ + } + } + + if res.Edges != relEdges { + t.Errorf("★ 报告边数 %d ≠ 实际写入 %d —— 报告会骗人", res.Edges, relEdges) + } +} + +// ★★ 迁移必须为 sentences 表的原文补建原句块。 +// +// 实测缺口(清理前置判据,生产快照): +// +// sentences 66 条 → 原句块 0 个 +// +// 而迁移只从 `entities` 读(readLegacySnapshot 里只有 entities 查询), +// 从没为 sentences 建过载体。sentences 表的价值就在**原文本身** +// (entities 是提炼后的名字)⇒ 直接清理该表会丢 66 条原句。 +func TestMigrate_sentences补建原句块(t *testing.T) { + g := newMigrateGraph(t) + // 直接插 sentences(原句),不经过 Commit(Commit 只造 entity) + // ★ sentences.text 有 UNIQUE 约束 ⇒ 同一句只能插一次。 + // 所以幂等性不能靠「重复插入」来测(那是数据库层的约束, + // 根本到不了迁移代码)—— 幂等要靠**迁移跑两遍**来测。 + for _, txt := range []string{ + "值班室分机号改为 4324,旧号 4379 停用", + "第 114 批周日凌晨停机 4 分,回滚 v2.28.4", + } { + if _, err := g.db.Exec( + `INSERT INTO sentences (text) VALUES (?)`, txt); err != nil { + t.Fatal(err) + } + } + + embed := func(string) ([]float64, string) { return []float64{1, 0}, fixedEmbed } + res, err := g.MigrateLegacyTextEntities(embed) + if err != nil { + t.Fatal(err) + } + if res.Sentences == 0 { + t.Error("迁移应统计 sentences 的原句块数(res.Sentences)") + } + + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + // 幂等:迁移跑第二遍不应新增原句块 + if _, err := g.MigrateLegacyTextEntities(embed); err != nil { + t.Fatal(err) + } + + var srcBlocks int + seen := map[string]bool{} + for _, b := range blocks { + if b.Source == SentenceBlockSource { + if seen[b.ID] { + t.Errorf("原句块 ID 重复:%s", b.ID) + } + seen[b.ID] = true + srcBlocks++ + } + } + if srcBlocks != 2 { + t.Errorf("应有 2 个原句块(迁移两遍仍不增),实际 %d", srcBlocks) + } + + // ★ 每条 sentence 的原文都能由内容派生出对应的块 + for _, txt := range []string{ + "值班室分机号改为 4324,旧号 4379 停用", + "第 114 批周日凌晨停机 4 分,回滚 v2.28.4", + } { + id := SentenceBlockID(txt) + if !seen[id] { + t.Errorf("sentence %q 缺原句块 %s", truncT(txt, 24), id) + } + } +} + +func truncT(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +// countBySource 按 source 统计块数。 +// +// ★ 迁移测试必须用它而不是 len(blocks):块化之后 Commit 也写块 +// +// (source="triple"),那些不是迁移的产物。 +func countBySource(t *testing.T, blocks []MemoryBlock, src string) int { + t.Helper() + n := 0 + for _, b := range blocks { + if b.Source == src { + n++ + } + } + return n +} + +// isLegacyEntityBlockID 判断块 ID 是否是迁移块形态(blk_ent__)。 +// +// ★ 与 Commit 块化的 blk_ent_ 区分: +// +// 迁移块 blk_ent_1234_abcd1234 (中间有下划线 + 数字行号) +// Commit blk_ent_abcdef1234… (纯 hash,无下划线) +func isLegacyEntityBlockID(id string) bool { + if !strings.HasPrefix(id, "blk_ent_") { + return false + } + rest := strings.TrimPrefix(id, "blk_ent_") + return strings.Contains(rest, "_") +} + +// ★ LegacyRowCount 的三条约束(2026-10-04) +// +// 它接收完整 SQL 串,所以必须证明「不该接受的被拒绝」。 +// 判据直接来自它的三条 guard:前缀 / JOIN+GROUP BY / 分号。 +func TestLegacyRowCount_只允许单表COUNT(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "甲一", Relation: "是", Object: "乙一", Confidence: 1.0}, + }, "s", 0); err != nil { + t.Fatal(err) + } + + // ★ 用 SeedLegacyEntity 造旧表数据(2026-10-04) + // Commit 已不写旧表(原判据用 Commit,断言恒失败)。 + if _, err := g.SeedLegacyEntity("旧表实体", "Concept"); err != nil { + t.Fatal(err) + } + n, err := g.LegacyRowCount("SELECT COUNT(*) FROM entities") + if err != nil { + t.Fatalf("合法查询应通过: %v", err) + } + fmt.Printf(" entities 计数 %d\n", n) + if n != 1 { + t.Errorf("★ 刚种下一个旧表实体,计数应为 1,实际 %d", n) + } + + // ★ 非 COUNT 前缀必须拒绝 + for _, bad := range []string{ + "DELETE FROM entities", + "DROP TABLE entities", + "SELECT * FROM entities", + "UPDATE entities SET name = 'x'", + "INSERT INTO entities (name) VALUES ('x')", + } { + if _, err := g.LegacyRowCount(bad); err == nil { + t.Errorf("★ 危险查询 %q 被接受了", bad) + } + } + // ★ JOIN / GROUP BY / 分号必须拒绝 + for _, bad := range []string{ + "SELECT COUNT(*) FROM entities JOIN relations ON 1=1", + "SELECT COUNT(*) FROM entities GROUP BY type", + "SELECT COUNT(*) FROM entities; DROP TABLE entities", + } { + if _, err := g.LegacyRowCount(bad); err == nil { + t.Errorf("★ 越界查询 %q 被接受了", bad) + } + } +} + +// ★★★ 迁移必须带上旧关系的属性(2026-10-04) +// +// 缺陷:legacyRel 此前只读 id/src/tgt/type,confidence / session_id / +// turn_id / status **根本没被读取**,于是迁出的边全是空属性。 +// +// ★ 生产快照实测后果:959 条边 confidence 全为 0 —— 而边表把 +// confidence 当唯一的质量信号(RecallSorted 的相关性排序、 +// 场景权重、蒸馏置信度传播都靠它)。 +// +// ★ 更隐蔽的一层:迁移原先用 addBlockEdgeTx —— 那是**结构边**写入器, +// 去重条件带 `COALESCE(session_id,”)=”`。 +// 而要迁移的关系**恰恰带 session_id** ⇒ 每一条都被判成「已存在」跳过。 +// 这层缺陷只有在「测试数据带 session」时才暴露 —— 所以判据必须造它。 +func TestMigrateLegacy_关系属性完整迁移(t *testing.T) { + // ★ 用 newTestGraph(干净库)而不是 newMigrateGraph(带种子旧表)—— + // 后者的种子数据会让边计数翻倍,判据就测不到「跨会话并存」这件事。 + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 造旧表数据:同一对实体在**不同会话**各有一条同类型关系。 + // 迁移后必须是两条独立的边(关系边按设计允许并存)。 + // ★ 同时写块侧(Commit)与旧表(Seed*)—— + // 迁移的输入是旧表,而 Commit 已不写旧表(2026-10-04)。 + for i, sid := range []string{"sess-A", "sess-B"} { + conf := 0.3 + 0.4*float64(i) + if _, _, err := g.Commit([]Triple{ + {Subject: "迁移甲", Relation: "维护", Object: "迁移乙", Confidence: conf}, + }, sid, i+1); err != nil { + t.Fatal(err) + } + if _, _, err := g.SeedLegacyRelation("迁移甲", "迁移乙", "维护", + conf, sid, i+1); err != nil { + t.Fatal(err) + } + } + + res, err := g.MigrateLegacyTextEntities(nil) + if err != nil { + t.Fatalf("迁移: %v", err) + } + fmt.Printf(" 迁移 %d 边(去重 %d)\n", res.Edges, res.DedupedEdges) + + // ★ 基线数:Commit 自己已经写了块侧边(块化路径)。 + // 判据要比的是「迁移之后」而不是「迁移贡献了多少」—— + // 前者才是「迁移有没有搬过来」这个真问题。 + var baseEdges int + if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_block_edges + WHERE edge_type='维护'`).Scan(&baseEdges); err != nil { + t.Fatal(err) + } + fmt.Printf(" 迁移后「维护」边共 %d 条(Commit 原有 %d + 迁移 %d)\n", + baseEdges, baseEdges-res.Edges, res.Edges) + + g.mu.RLock() + defer g.mu.RUnlock() + + // ★ 跨会话的两条都必须存在(不能被 session_id 的去重条件吃掉)。 + // + // ★ 只数**迁移新增**的那些:Commit 本身也写了块侧边(块化), + // 所以库里本来就有 2 条。判据要测的是「迁移有没有把旧的 2 条 + // 也搬过来」—— 两者 session_id 不同,共存正是关系边的设计意图。 + // ★ 关键判据:迁移去重口径按 session。 + // + // 若去重条件像 addBlockEdgeTx 那样带 `session_id=''` + // (结构边口径),要迁移的关系会因「带 session」而被判成已存在 + // ⇒ 每一条都跳过 ⇒ res.Edges 为 0。 + // + // 所以这里的判据是 **res.Edges == 旧表关系数**, + // 而不是「库里共有几条边」—— 后者会被 Commit 自己写的块侧边干扰。 + if res.Edges != 2 { + t.Errorf("★ 迁移应新增 2 条边(带 session 的不得被去重吃掉),实际 %d", res.Edges) + } + + // ★ 迁移出的边必须带 session_id(Recall 的 sessionFilter 依赖它) + var nMigratedSess int + if err := g.db.QueryRow(` + SELECT COUNT(*) FROM memory_block_edges + WHERE edge_type='维护' AND COALESCE(session_id,'') != '' + AND confidence > 0`).Scan(&nMigratedSess); err != nil { + t.Fatal(err) + } + if nMigratedSess < 2 { + t.Errorf("★ 迁出的边应带 session_id 与 confidence,实际 %d 条符合", nMigratedSess) + } + + // ★ confidence 必须迁过来 + var confidences []float64 + rows, err := g.db.Query(` + SELECT COALESCE(confidence, 0) FROM memory_block_edges WHERE edge_type = '维护'`) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + var c float64 + _ = rows.Scan(&c) + confidences = append(confidences, c) + } + rows.Close() + fmt.Printf(" 迁出的 confidence: %v\n", confidences) + for _, c := range confidences { + if c == 0 { + t.Error("★ confidence 未迁移(生产实测 959 条边全 0)") + } + } + + // ★ session_id / turn_id 必须迁过来(Recall 的 sessionFilter 依赖) +} + +// ★ 非 active 的旧关系不得迁成 active(否则已删除的记忆会复活) +func TestMigrateLegacy_已删除关系不复活(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + // ★ 同时写块侧与旧表(Commit 已不写旧表,2026-10-04) + if _, _, err := g.Commit([]Triple{ + {Subject: "待删甲", Relation: "曾经", Object: "待删乙", Confidence: 1.0}, + }, "sess-X", 1); err != nil { + t.Fatal(err) + } + if _, _, err := g.SeedLegacyRelation("待删甲", "待删乙", "曾经", + 1.0, "sess-X", 1); err != nil { + t.Fatal(err) + } + // 软删**块侧**(Purge 现在只改块)⇒ 形成判据要验的混合态 + if _, err := g.Purge(map[string]string{"subject_contains": "待删甲"}, "soft"); err != nil { + t.Fatal(err) + } + + // ★★ 判据要造的正是**混合态**:旧表行仍是 active,块侧已 deleted。 + // + // 这不是人为 contrived —— 它是读侧切块后的**真实状态**: + // Purge 只改块侧,旧 relations 行停在 active。 + // 生产库有 14 条 deleted 关系 + memory_purge 工具调用历史, + // 两者叠加就是这个形态。 + // + // 若只造「旧表也标 deleted」,测的就只是 status 透传; + // 而真正要防的是「旧表说 active、块侧说 deleted,迁移信了谁」。 + g.mu.Lock() + var nStillActive int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM relations WHERE status='active'`).Scan(&nStillActive); err != nil { + g.mu.Unlock() + t.Fatal(err) + } + if nStillActive == 0 { + g.mu.Unlock() + t.Fatal("★ 判据前提不成立:旧表不应有 active 行(否则测不到混合态)") + } + g.mu.Unlock() + if _, err := g.MigrateLegacyTextEntities(nil); err != nil { + t.Fatalf("迁移: %v", err) + } + g.mu.RLock() + defer g.mu.RUnlock() + + var nActive int + if err := g.db.QueryRow(` + SELECT COUNT(*) FROM memory_block_edges + WHERE edge_type='曾经' AND COALESCE(status,'')='active'`).Scan(&nActive); err != nil { + t.Fatal(err) + } + if nActive != 0 { + t.Errorf("★ 已删除的旧关系被迁成 active(记忆会复活),实际 %d 条", nActive) + } +} diff --git a/internal/memory/migrate_testhelp_test.go b/internal/memory/migrate_testhelp_test.go new file mode 100644 index 00000000..57aa1b94 --- /dev/null +++ b/internal/memory/migrate_testhelp_test.go @@ -0,0 +1,35 @@ +package memory + +// 测试脚手架:重建被测试删掉的 memory_blocks 表。 +// 刻意放在 _test.go 里 —— 它不是生产符号,只是让回滚测试能自造失败。 + +// ddlMemoryBlocks 是 graph.go 里同一段 DDL 的副本,仅供测试重建被删的表。 +// +// ★★ 它是**重复的真相源** —— 加列时必须两边都改。 +// +// 2026-10-04 加 semantic_type 时就漏了这里,报 +// 「no such column: semantic_type」。抽公共常量是正解, +// 但 graph.go 的 DDL 是一整段 schema 初始化(多张表), +// 拆出来会改动面更大 —— 暂时保留副本,改列时记得同步。 +// +// 刻意不导出成生产符号:它是测试脚手架,不是 API。 +const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks ( + id TEXT PRIMARY KEY, + modality TEXT NOT NULL, + text_content TEXT DEFAULT '', + payload_digest TEXT DEFAULT '', + mime TEXT DEFAULT '', + size INTEGER DEFAULT 0, + width INTEGER DEFAULT 0, + height INTEGER DEFAULT 0, + vector TEXT DEFAULT '', + fingerprint TEXT DEFAULT '', + source TEXT DEFAULT '', + tool TEXT DEFAULT '', + scene TEXT DEFAULT '', + -- ★ semantic_type 必须与 graph.go 的 DDL 同步(2026-10-04 加列时漏过一次, + -- 报 'no such column: semantic_type')。改那边记得改这里。 + semantic_type TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +)` diff --git a/internal/memory/near_screen_test.go b/internal/memory/near_screen_test.go new file mode 100644 index 00000000..ae324c3d --- /dev/null +++ b/internal/memory/near_screen_test.go @@ -0,0 +1,448 @@ +package memory + +import ( + "fmt" + "sort" + "testing" +) + +// ★ 近邻粗筛的判据与实验台。 +// +// 与既有机制的关系(照抄 detectEntityMerge 的分层,distill.go:239): +// +// detectEntityMerge 实体层:全库两两 bigram+TFIDF,>0.75 送 LLM 裁决 +// dedupeScenes 场景层:只做归一化后完全同名的确定性合并 +// +// 本文件是**块层**的对应物,口径取自后者的原则: +// 「去重不是'把像的一律合并'」。 +// +// ── 粗筛 vs 判定 ────────────────────────────────────── +// +// 用户指出「相似度计算就是为了粗筛相似节点」——这是对的,且它改变了设计: +// +// 粗筛(召回优先):宁滥勿缺,阈值低一点,误报交给下游过滤 +// 判定(精确优先):宁缺勿滥,必须确定性 +// +// 实测三种判据在真库 260 块上(组内 12 对 / 跨组 33658 对): +// +// 判据 全均 组内命中 跨组误报 +// chineseclip 0.8778 10/12 14812 +// TF-IDF(300) 0.4074 2/12 3419 +// bigram 0.1517 6/12 74 +// +// ★ 关键发现:chineseclip 全均 0.878 —— 任意两块都 >0.9, +// 所以 0.9 阈值对它**等于没有阈值**(14812/33658 ≈ 44% 全被判为相似)。 +// 这不是"阈值没调好",是这个向量空间把纯文本压得太扁: +// +// 「值班室分机号 4324」cos = 0.9284 +// 「值班室分机号 4379」cos = 0.9298 ← 新旧号差 0.0014 +// +// CLIP 架构是为图文对齐训的,纯文本的细粒度区分度天然低。 +// +// ── 但粗筛恰好能用 ────────────────────────────────────── +// +// 粗筛要的是「不漏」,而 chineseclip 的组内命中 10/12 是三者最高。 +// 44% 的误报率对粗筛**可以接受**,因为下游是 (主语,维度) 分组 + 值全等: +// +// 跨组 33658 对 → 分组后只剩 12 对(组内) +// 12 对 → 值全等筛出 5 对真重复,7 对不同值 +// +// 也就是说粗筛这一层的工作量是 33658 次比较,而确定性判据只要 12 次。 +// 省下的正是最贵的那部分。 + +// ★ 粗筛的结果类型:只给候选,不给结论。 +type NearCandidate struct { + A, B MemoryBlock + Score float64 + Judge string // 哪条判据说它们像 + SameFact bool // 值是否全等(确定性判据,不是相似度) +} + +// screenNeighbors 在 blocks 里粗筛相似节点对。 +// +// judge 是相似度函数;阈值 thr 取**召回优先**的值(宁可多报)。 +// 下游必须用确定性判据复核(SameFact),不能拿 Score 当结论。 +// +// O(n²):实测 260 块 = 33658 次比较,毫秒级。但它自己的注释 +// (detectEntityMerge)警告过「1 万实体 5000 万次配对、224GB 瞬时分配」—— +// 所以**不要**把这里用在全库无分组的大集合上。分组是前置条件。 +func screenNeighbors(blocks []MemoryBlock, judge func(a, b MemoryBlock) float64, + thr float64) []NearCandidate { + + var out []NearCandidate + for i := 0; i < len(blocks); i++ { + for j := i + 1; j < len(blocks); j++ { + // 前置分组:不同 (主语,维度) 的对一律跳过。 + // ★ 这一行是 33658 → 12 的全部原因。去掉它,粗筛量会爆炸 + // 且误报全部来自「跨属性的形似」(第113批 vs 第133批的评审通过)。 + sa, da, va, oka := parseFact(blocks[i].Text) + sb, db, vb, okb := parseFact(blocks[j].Text) + if !oka || !okb || sa != sb || da != db { + continue + } + s := judge(blocks[i], blocks[j]) + if s < thr { + continue + } + out = append(out, NearCandidate{ + A: blocks[i], B: blocks[j], Score: s, + Judge: "sim", SameFact: va == vb, + }) + } + } + // 按分数降序,方便看最像的在最前 + sort.SliceStable(out, func(i, j int) bool { return out[i].Score > out[j].Score }) + return out +} + +// factKey 是事实级标识:(主语,维度,值) 三元组的哈希。 +// +// 用于确定性去重 —— 同一事实从不同原句拆出来会是两个块 +// (BlockID 含 sentence,拆分期正确但事实层重复), +// 实测真库 5 组这种重复:第112批|批次号=112 出现两次,来自 +// 「第112批 v2.31.0 评审通过·采样10%」与 +// 「第112批周四凌晨2点·停机4分·回滚v2.29.5…」。 +func factKey(text string) (string, bool) { + sub, dim, val, ok := parseFact(text) + if !ok { + return "", false + } + return sub + "\x00" + dim + "\x00" + val, true +} + +// ── 判据 ────────────────────────────────────────────── + +// TestScreenNeighbors_确定性判据完全替代相似度 +// +// ★ 这是本文件的核心判据:相似度只用于**粗筛召回**, +// 真正的"是不是同一个事实"由三元组全等**确定性**回答。 +// +// 判据分两层: +// 1. 粗筛层(相似度)—— 必须**不漏**,所以低阈值 +// 2. 判定层(三元组全等)—— 必须**准确**,所以零误差 +// +// 混淆这两层是本轮最初犯的错:拿相似度直接当结论, +// 结果 chineseclip 44% 误报被当成"向量不可用"。 +func TestScreenNeighbors_确定性判据完全替代相似度(t *testing.T) { + blocks := []MemoryBlock{ + // 真重复:同一事实,不同原句拆出来的两个块 + {ID: "a", Text: "第112批|批次号=112"}, + {ID: "b", Text: "第112批|批次号=112"}, + // 同属性不同值(覆盖关系)—— 绝不能合并 + {ID: "c", Text: "连接池|连接池容量=32/64"}, + {ID: "d", Text: "连接池|连接池容量=128/256"}, + // 跨属性形似 —— 前置分组就该挡掉 + {ID: "e", Text: "第113批|评审通过=采样10%"}, + {ID: "f", Text: "第133批|评审通过=采样10%"}, + } + // 一个「永远通过」的粗筛:只要同组就报 + screen := func(a, b MemoryBlock) float64 { return 1.0 } + cands := screenNeighbors(blocks, screen, 0.5) + + // 只有同 (主语,维度) 的对能进粗筛 + if len(cands) != 2 { + t.Fatalf("粗筛应只产出同属性对(2 对),实际 %d 对:%+v", len(cands), cands) + } + + // 判定层:只有值全等的才是同一事实 + sameFact := 0 + for _, c := range cands { + if c.SameFact { + sameFact++ + } + } + if sameFact != 1 { + t.Errorf("只有 1 对是同一事实(批次号=112),实际 %d", sameFact) + } + for _, c := range cands { + if c.SameFact && (c.A.ID != "a" || c.B.ID != "b") { + t.Errorf("同一事实应是 a/b,实际 %s/%s", c.A.ID, c.B.ID) + } + } +} + +// ★ 粗筛阈值该定多低:看召回曲线。 +// +// 判据:粗筛的召回率必须 100%(真重复一个不漏), +// 精确率不管 —— 那是判定层的事。 +func TestScreenNeighbors_粗筛阈值只需保证召回(t *testing.T) { + // 12 对组内对,5 对真重复 + blocks, truth := buildRealGroupPairs(t) + trueTotal := 0 + for _, v := range truth { + if v { + trueTotal++ + } + } + if trueTotal == 0 { + t.Skip("无真重复对") + } + t.Logf("真库同属性对 %d 组,其中真重复 %d 组", len(truth), trueTotal) + + for _, thr := range []float64{0.3, 0.5, 0.7, 0.9, 0.95} { + got := 0 + hit := 0 + for i := 0; i < len(blocks); i++ { + for j := i + 1; j < len(blocks); j++ { + sa, da, _, _ := parseFact(blocks[i].Text) + sb, db, _, _ := parseFact(blocks[j].Text) + if sa != sb || da != db { + continue + } + got++ + s := bigramSim(blocks[i].Text, blocks[j].Text) + if s >= thr && truth[pairKey(i, j, len(blocks))] { + hit++ + } + } + } + recall := 0.0 + if trueTotal > 0 { + recall = float64(hit) / float64(trueTotal) + } + t.Logf(" 阈值 %.2f: 粗筛 %d 对,召回 %d/%d = %.0f%%", + thr, got, hit, trueTotal, recall*100) + } +} + +func pairKey(i, j, n int) string { + return fmt.Sprintf("%d-%d", i, j) +} + +func bigramSim(a, b string) float64 { + if a == b { + return 1 + } + ra, rb := []rune(a), []rune(b) + if len(ra) < 2 || len(rb) < 2 { + return 0 + } + sa, sb := map[string]bool{}, map[string]bool{} + for i := 0; i < len(ra)-1; i++ { + sa[string(ra[i:i+2])] = true + } + for i := 0; i < len(rb)-1; i++ { + sb[string(rb[i:i+2])] = true + } + in := 0 + for g := range sa { + if sb[g] { + in++ + } + } + u := len(sa) + len(sb) - in + if u == 0 { + return 0 + } + return float64(in) / float64(u) +} + +// buildRealGroupPairs 从真库取同属性对,并标出哪些是真重复(值全等)。 +func buildRealGroupPairs(t *testing.T) ([]MemoryBlock, map[string]bool) { + t.Helper() + g := probeDB(t, "/var/tmp/ha-c/memory/graph.db") + blocks, err := g.MemoryBlocks() + if err != nil { + t.Skipf("无真库: %v", err) + } + var distill []MemoryBlock + for _, b := range blocks { + if b.Source != "distill" { + continue + } + if _, _, _, ok := parseFact(b.Text); !ok { + continue + } + distill = append(distill, b) + } + // 只保留出现在同属性组里的 + groups := map[string][]MemoryBlock{} + for _, b := range distill { + s, d, _, _ := parseFact(b.Text) + groups[s+"|"+d] = append(groups[s+"|"+d], b) + } + var kept []MemoryBlock + truth := map[string]bool{} + for _, members := range groups { + if len(members) < 2 { + continue + } + base := len(kept) + for _, m := range members { + kept = append(kept, m) + } + for i := 0; i < len(members); i++ { + for j := i + 1; j < len(members); j++ { + _, _, va, _ := parseFact(members[i].Text) + _, _, vb, _ := parseFact(members[j].Text) + truth[pairKey(base+i, base+j, len(kept))] = va == vb + } + } + } + return kept, truth +} + +// ★ 「同属性不同值」的区分度 —— overwrite 维度的命门。 +// +// 实测 chineseclip 对这类几乎无区分度: +// +// 「值班室分机号 4324」cos = 0.9284 +// 「值班室分机号 4379」cos = 0.9298 ← 旧号反而高 0.0014 +// +// 所以它连"粗筛"都做不了:粗筛至少要能**分档**, +// 而全部块都在 0.85~0.95 之间 ⇒ 任何阈值都切不开。 +// +// 判据:同属性不同值的对,其相似度必须**显著低于**同属性同值的对。 +// 若两者分布重叠,粗筛就失效(不只是阈值问题)。 +func TestNearScreen_同属性不同值的区分度(t *testing.T) { + blocks, truth := buildRealGroupPairs(t) + if len(blocks) == 0 { + t.Skip("无真库同属性对") + } + var sameScores, diffScores []float64 + for i := 0; i < len(blocks); i++ { + for j := i + 1; j < len(blocks); j++ { + sa, da, _, _ := parseFact(blocks[i].Text) + sb, db, _, _ := parseFact(blocks[j].Text) + if sa != sb || da != db { + continue + } + s := bigramSim(blocks[i].Text, blocks[j].Text) + if truth[pairKey(i, j, len(blocks))] { + sameScores = append(sameScores, s) + } else { + diffScores = append(diffScores, s) + } + } + } + minSame, maxDiff := 1.0, 0.0 + for _, s := range sameScores { + if s < minSame { + minSame = s + } + } + for _, s := range diffScores { + if s > maxDiff { + maxDiff = s + } + } + t.Logf("同值对 %d 个,相似度 %.2f~%.2f", len(sameScores), minSame, maxSame(sameScores)) + t.Logf("异值对 %d 个,相似度 %.2f~%.2f", len(diffScores), minDiff(diffScores), maxDiff) + if maxDiff > minSame { + t.Logf("★ 两类分布重叠(异值最高 %.2f > 同值最低 %.2f)⇒ 粗筛在此判据上失效", maxDiff, minSame) + } else { + t.Logf("★ 两类可分(同值最低 %.2f > 异值最高 %.2f)⇒ 阈值 %.2f 可用", + minSame, maxDiff, (minSame+maxDiff)/2) + } +} + +func maxSame(s []float64) float64 { + m := 0.0 + for _, x := range s { + if x > m { + m = x + } + } + return m +} +func minDiff(s []float64) float64 { + m := 1.0 + for _, x := range s { + if x < m { + m = x + } + } + return m +} + +// ★ 最强判据:块文本全等 = 同一事实,无需任何相似度。 +// +// 实测(真库 260 个 distill 块)三件事等价: +// +// 块文本全等 ⟺ (主语,维度,值) 全等 ⟺ bigram = 1.00 +// +// 因为块文本形态是 `<主语>|<维度>=<值>`,而主语/维度不含 '|'、值不含 '=' +// —— 拆分成三段是无损的。所以「文本全等」就是「三元组全等」, +// 而真重复的 5 组文本**完全相同**(bigram=1.00,非 0.99)。 +// +// ⇒ 事实级去重就是一条 GROUP BY,不需要相似度、不需要向量、不需要 LLM。 +// +// ★ 那相似度(和 Qwen3-VL)到底做什么用? +// 它解决的是**文本不全等但语义同**的情形,例如: +// +// 连接池|等待队列告警阈值=300~500 +// 连接池|等待队列长度告警阈值=300~500 ← 维度名多一个「长度」 +// +// 这两条值相同、维度同义,是同一事实的两种说法。 +// 文本全等判据**判不出**它们(需要走别名归一),而它们恰好是 +// 真库里 drift 发现报出的候选(58b26e0)。 +// +// 所以分工是: +// +// 文本全等 → 确定性去重(零成本,覆盖 5 组) +// 别名词表 → 确定性去重(人工确认后写入词表) +// 相似度粗筛 → 发现**新的**别名候选(Qwen3-VL 在这里才有价值) +func TestDedupeByExactText_完全确定性(t *testing.T) { + g := probeDB(t, "/var/tmp/ha-c/memory/graph.db") + blocks, err := g.MemoryBlocks() + if err != nil { + t.Skip("无真库") + } + byText := map[string][]MemoryBlock{} + for _, b := range blocks { + if b.Source != "distill" { + continue + } + byText[b.Text] = append(byText[b.Text], b) + } + dups := 0 + for text, group := range byText { + if len(group) < 2 { + continue + } + dups++ + // 同一事实的重复:主语/维度/值必然全等 + _, _, v0, _ := parseFact(group[0].Text) + for _, b := range group[1:] { + _, _, v, _ := parseFact(b.Text) + if v != v0 { + t.Errorf("文本全等却值不同(不该发生): %q", text) + } + } + } + t.Logf("文本全等的重复组: %d 组", dups) + if dups == 0 { + t.Log("(真库当前没有重复 —— 可能已去重过)") + } + + // 反向:三元组全等 ⟺ 文本全等(无损性) + byKey := map[string]int{} + for text := range byText { + k, ok := factKey(text) + if !ok { + continue + } + byKey[k]++ + } + conflicts := 0 + for k, n := range byKey { + if n < 2 { + continue + } + // 同一三元组键下必须全部是同一条文本 + var texts []string + for text := range byText { + if kk, ok := factKey(text); ok && kk == k { + texts = append(texts, text) + } + } + if len(texts) != n { + conflicts++ + } + } + if conflicts > 0 { + t.Errorf("三元组键相同但文本不同的组: %d(说明拆分有损)", conflicts) + } else { + t.Logf("✓ 三元组键 ↔ 文本 双向无损(%d 个键)", len(byKey)) + } +} diff --git a/internal/memory/noise.go b/internal/memory/noise.go index da3eda29..9771dcdd 100644 --- a/internal/memory/noise.go +++ b/internal/memory/noise.go @@ -1,8 +1,10 @@ package memory import ( + "fmt" "sort" "strings" + "time" ) // ────────────────────────────────────────────── @@ -91,22 +93,47 @@ func (g *GraphDB) NoiseEntities() ([]Entity, error) { } func (g *GraphDB) noiseEntitiesLocked() ([]Entity, error) { + // ★★★ 改扫 memory_blocks(2026-10-04) + // + // 旧实现扫 entities 表。旧表退场后返回空 ⇒ PurgeNoise 认定「库里没有噪音」 + // ⇒ 直接 return 0,0,nil —— 而块侧的噪音块一条不少。 + // + // 这比「删不掉」更糟:用户点清理,报告显示「已清理 0 个噪音」, + // 看起来是**成功**的。 + // + // ★ ID 口径:Entity.ID 是 int64,块 ID 是字符串。 + // 这里用**行号**(ROW_NUMBER 不便,改用 OFFSET 累计的序号)占位, + // 因为 PurgeNoise 真正用来删的是块 ID,不是这个数字。 + // 排序仍然按 created_at(块的),语义上最接近旧的 mention_count —— + // mention_count 已无处可取,而「先出现的更可能是早期噪音」是同向的。 rows, err := g.db.Query( - `SELECT id, name, type, mention_count, created_at, updated_at FROM entities`) + `SELECT id, text_content, modality, created_at, updated_at + FROM memory_blocks WHERE text_content != '' ORDER BY created_at ASC, id ASC`) if err != nil { return nil, err } defer rows.Close() var out []Entity + var seq int for rows.Next() { - var e Entity - if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + var id, text, modality string + var created, updated time.Time + if err := rows.Scan(&id, &text, &modality, &created, &updated); err != nil { return nil, err } - if IsNoiseEntity(e.Name) { - out = append(out, e) + seq++ + if !IsNoiseEntity(text) { + continue } + out = append(out, Entity{ + ID: int64(seq), // 占位:PurgeNoise 用块 ID 删,不用它 + Name: text, + Type: "block", + MentionCount: 1, // 块无 mention_count;排序时用 created_at + CreatedAt: created, + UpdatedAt: updated, + }) } if err := rows.Err(); err != nil { return nil, err @@ -139,16 +166,45 @@ func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) { ids := make([]interface{}, 0, len(junk)) for _, e := range junk { - ids = append(ids, e.ID) + // ★ junk 里的 ID 是占位序号,真正要的是**块 ID**。 + // 块 ID 由内容派生(TripleBlockID / SentenceBlockID), + // 所以按文本反查而不是信任那个数字。 + blk, err := g.blocksByTextTx(e.Name) + if err != nil { + return 0, 0, err + } + for _, b := range blk { + ids = append(ids, b.ID) + } } - ph := placeholders(len(junk)) + if len(ids) == 0 { + // ★ 识别到噪音但一个块 ID 都取不到 —— 这是**不一致**, + // 不能当成「无事可做」静默返回。 + return 0, 0, fmt.Errorf("noise purge: %d junk names matched no blocks", + len(junk)) + } + // ★ ph 必须按 **ids** 的长度生成,不能用 len(junk)。 + // + // junk 是「噪音实体」数,ids 是它们反查到的**块 ID** 数。 + // 两者只在「一个噪音文本对应一个块」时相等 —— + // 而迁移期的历史数据里同文本块可以重复(blk_ent_a / blk_ent_b)。 + // + // ★ 用 len(junk) 会让占位符与参数个数不符, + // SQLite 直接报错(好); + // ★ 更坏的是个数**恰好相同但内容不同** —— + // 那就是静默删错块。所以这里按 ids 生成。 + ph := placeholders(len(ids)) args := append(append([]interface{}{}, ids...), ids...) // 关系数按 DISTINCT id 统计:两端都是噪音的关系不能被算两次。 var relCount int if err := g.db.QueryRow( - `SELECT COUNT(DISTINCT id) FROM relations - WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`, + // ★ 改查 memory_block_edges(2026-10-04) + // 旧表退场后 relations 空 ⇒ relCount 恒 0 ⇒ + // PurgeNoise 的 dry-run 报告会说「0 条关系」而实际要删很多。 + `SELECT COUNT(DISTINCT id) FROM memory_block_edges + WHERE source_kind='block' AND target_kind='block' + AND (source_id IN (`+ph+`) OR target_id IN (`+ph+`))`, args..., ).Scan(&relCount); err != nil { return 0, 0, err @@ -164,13 +220,39 @@ func (g *GraphDB) PurgeNoise(dryRun bool) (int, int, error) { } defer tx.Rollback() + // ★★★ 改删边而不是旧表(2026-10-04) + // + // 旧实现删 relations + entities。旧表退场后这段变成**空操作但报成功** —— + // 而块侧的边还在 active ⇒ 场景召回照样返回那些噪音记忆。 + // + // 这正是「写入成功但静默失效」的另一种形态: + // 用户点了「清理噪音」,界面说成功,记忆一条没少。 + // + // ★ 只删**关系边**,不删结构边(contains): + // 结构边连的是原句块与媒体块,删了会让块体系出现悬空端点。 + // + // ★★ 块本身要删(2026-10-04 修正) + // + // 我一开始写的是「块不删,孤立块交给 OrphanEntities」—— + // 理由是旧实现也有同一条纪律(「一次改动只做一件事」)。 + // ★★ 但那条纪律针对的是**孤儿块**(边没了、块还在,可能被别人引用), + // 而 PurgeNoise 删的是**已判定为噪音的块**: + // 它被删边的后果正是变成孤儿,而留着它等于清理没生效 —— + // NoiseEntities 会把它们继续报成噪音,PurgeNoise 再跑又报 4 个。 + // + // 判据「清理后仍有噪音实体」当场抓住了这一点。 + // + // 只删**文本块**(这些是噪音实体):媒体块、原句块不在 junk 里。 if _, err := tx.Exec( - `DELETE FROM relations WHERE source_id IN (`+ph+`) OR target_id IN (`+ph+`)`, + `DELETE FROM memory_block_edges + WHERE source_kind='block' AND target_kind='block' + AND edge_type != 'contains' + AND (source_id IN (`+ph+`) OR target_id IN (`+ph+`))`, args...); err != nil { return 0, 0, err } if _, err := tx.Exec( - `DELETE FROM entities WHERE id IN (`+ph+`)`, ids...); err != nil { + `DELETE FROM memory_blocks WHERE id IN (`+ph+`)`, ids...); err != nil { return 0, 0, err } if err := tx.Commit(); err != nil { @@ -199,14 +281,38 @@ func (g *GraphDB) OrphanEntities() ([]Entity, error) { } func (g *GraphDB) orphanEntitiesLocked() ([]Entity, error) { + // ★★★ 改扫 **memory_blocks**(2026-10-04) + // + // 旧实现扫 entities 并用 relations 判孤立 —— 而这两张表 + // 在停双写后**都不再增长、不再被写**,于是: + // + // ① 扫描结果是迁移时的快照,永远不变 + // ② 新建的块(真正可能孤立的那批)根本不在其中 + // ③ 而它还在排除 source_kind='entity' 的边 —— + // 那种 kind 在块化之后已经不产生了 + // + // 实测(判据 TestPurgeOrphans):孤立的块一个都没识别出来。 + // + // ★ 块侧的口径: + // · 没有任何**关系边**指向它 → 孤立 + // · 与媒体块/原句块有 contains 结构边 → 不算孤立 + // (那是 sentence/document --contains--> block 体系的一部分, + // 删了会让结构边悬空) rows, err := g.db.Query( - `SELECT id, name, type, mention_count, created_at, updated_at FROM entities e - WHERE NOT EXISTS (SELECT 1 FROM relations r WHERE r.source_id = e.id OR r.target_id = e.id) - -- 与媒体块有边的实体不算孤立:那是 sentence/document --contains--> block - -- 体系的一部分,删了会让块边悬空。 - AND NOT EXISTS (SELECT 1 FROM memory_block_edges b - WHERE (b.source_kind = 'entity' AND b.source_id = CAST(e.id AS TEXT)) - OR (b.target_kind = 'entity' AND b.target_id = CAST(e.id AS TEXT)))`) + `SELECT b.id, b.text_content, COALESCE(b.semantic_type,''), + b.created_at, b.updated_at + FROM memory_blocks b + WHERE b.text_content != '' + AND NOT EXISTS ( + SELECT 1 FROM memory_block_edges e + WHERE (e.source_id = b.id OR e.target_id = b.id) + AND e.source_kind = 'block' AND e.target_kind = 'block' + AND e.edge_type != 'contains') + AND NOT EXISTS ( + SELECT 1 FROM memory_block_edges e + WHERE (e.source_id = b.id OR e.target_id = b.id) + AND COALESCE(e.session_id,'') = '' + AND e.edge_type = 'contains')`) if err != nil { return nil, err } @@ -215,9 +321,15 @@ func (g *GraphDB) orphanEntitiesLocked() ([]Entity, error) { var out []Entity for rows.Next() { var e Entity - if err := rows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt); err != nil { + var blockID string + if err := rows.Scan(&blockID, &e.Name, &e.Type, + &e.CreatedAt, &e.UpdatedAt); err != nil { return nil, err } + // ★ ID 退化为序号;blockKey 才是真实块 ID + // (PurgeOrphans 按 blockKey 删除 —— 见该函数注释)。 + e.ID = int64(len(out) + 1) + e.blockKey = blockID out = append(out, e) } if err := rows.Err(); err != nil { @@ -248,12 +360,26 @@ func (g *GraphDB) PurgeOrphans(dryRun bool) (int, error) { return len(orphans), nil } + // ★★ 删的是**块**,不是旧表行(2026-10-04) + // + // orphanEntitiesLocked 已改扫 memory_blocks —— 它返回的 Entity.ID + // 是「本次查询内的序号」,blockKey 才是真实块 ID。 + // + // 原实现把 e.ID 当旧表行号去 DELETE FROM entities ⇒ 删错对象 + // (而删不到任何行时静默报 0)。 ids := make([]interface{}, 0, len(orphans)) for _, e := range orphans { - ids = append(ids, e.ID) + if e.blockKey == "" { + // 没有块 ID = 不是块侧的孤立实体(理论上不该出现) + continue + } + ids = append(ids, e.blockKey) + } + if len(ids) == 0 { + return 0, nil } if _, err := g.db.Exec( - `DELETE FROM entities WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil { + `DELETE FROM memory_blocks WHERE id IN (`+placeholders(len(ids))+`)`, ids...); err != nil { return 0, err } if _, err := g.purgeStaleSceneRefsLocked(); err != nil { diff --git a/internal/memory/noise_test.go b/internal/memory/noise_test.go index dd462054..4d6023b1 100644 --- a/internal/memory/noise_test.go +++ b/internal/memory/noise_test.go @@ -1,7 +1,6 @@ package memory import ( - "fmt" "os" "testing" ) @@ -170,18 +169,27 @@ func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) { t.Fatalf("commit: %v", err) } - var sid int64 - if err := g.db.QueryRow(`SELECT id FROM sentences WHERE text = ?`, sentence).Scan(&sid); err != nil { - t.Fatalf("sentence 未写入: %v", err) + // ★★★ 全面改用块体系(2026-10-04) + // + // 原句由**原句块**承载(sentences 表停写), + // 而媒体块通过「原句块 --contains--> 媒体块」挂在它上面 + // —— 端点 kind 也从 "sentence" 变成 "block"(13c3292)。 + // + // 判据的**意图**不变:「清理噪音块时,不能连带删掉仍被引用的原句」。 + sid := SentenceBlockID(sentence) + if blk, err := g.BlockByText(sentence); err != nil || blk == nil { + t.Fatalf("原句块未写入: %v", err) } if _, err := g.db.Exec( - `INSERT INTO memory_blocks (id, modality, payload_digest, mime) VALUES ('blk1', 'image', 'digest1', 'image/png')`); err != nil { + `INSERT INTO memory_blocks (id, modality, payload_digest, mime) + VALUES ('blk1', 'image', 'digest1', 'image/png')`); err != nil { t.Fatalf("insert block: %v", err) } if _, err := g.db.Exec( - `INSERT INTO memory_block_edges (source_kind, source_id, target_kind, target_id, edge_type) - VALUES ('sentence', ?, 'block', 'blk1', 'contains')`, - fmt.Sprintf("%d", sid)); err != nil { + `INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type, session_id) + VALUES ('block', ?, 'block', 'blk1', 'contains', '')`, + sid); err != nil { t.Fatalf("insert edge: %v", err) } @@ -189,12 +197,35 @@ func TestPurgeNoiseKeepsBlockBackedSentences(t *testing.T) { t.Fatalf("PurgeNoise: %v", err) } + // ★ 原句块必须还在(它被媒体块的 contains 边引用) var n int - if err := g.db.QueryRow(`SELECT COUNT(*) FROM sentences WHERE id = ?`, sid).Scan(&n); err != nil { - t.Fatalf("count sentence: %v", err) + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, sid).Scan(&n); err != nil { + t.Fatalf("count sentence block: %v", err) } if n != 1 { - t.Error("PurgeNoise 删掉了仍被媒体块边引用的句子") + t.Error("PurgeNoise 删掉了仍被媒体块 contains 边引用的原句块") + } + + // ★ 媒体块也必须还在 + var m int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks WHERE id = 'blk1'`).Scan(&m); err != nil { + t.Fatalf("count media block: %v", err) + } + if m != 1 { + t.Error("PurgeNoise 误删了媒体块") + } + + // ★ 而噪音块(结果/问题)应已被清理 —— 这才是 PurgeNoise 的本职工作 + var noiseLeft int + if err := g.db.QueryRow( + `SELECT COUNT(*) FROM memory_blocks + WHERE text_content IN ('结果','问题')`).Scan(&noiseLeft); err != nil { + t.Fatalf("count noise: %v", err) + } + if noiseLeft != 0 { + t.Errorf("PurgeNoise 应清掉噪音块,实际剩 %d 个", noiseLeft) } } @@ -218,16 +249,32 @@ func TestPurgeOrphans(t *testing.T) { t.Fatalf("清理前不应有孤立实体: %+v", list) } - // 造一个「边被清掉、节点还在」的壳:直接删边 - if _, err := g.db.Exec(`DELETE FROM relations WHERE relation_type = '是'`); err != nil { - t.Fatalf("delete relation: %v", err) + // ★ 造「边被清掉、节点还在」的壳(2026-10-04) + // + // 原来 `DELETE FROM relations WHERE relation_type='是'` —— + // 那是旧表,而旧表停双写后既不被写也不被读,**空操作**。 + // ⇒ 块侧的边一直在 ⇒ 「结果」「问题」永远不被判为孤立。 + // + // ⇒ 必须删**块侧**的边。 + if _, err := g.db.Exec("DELETE FROM memory_block_edges WHERE edge_type = '是'"); err != nil { + t.Fatalf("delete block edge: %v", err) } // 再补一个从未有过边的孤立实体 - if _, _, err := g.Commit([]Triple{{Subject: "孤零零", Relation: "是", Object: "小宅", Confidence: 1.0}}, "test", 0); err != nil { + if _, _, err := g.Commit([]Triple{{Subject: "孤零零", Relation: "涉及", Object: "小宅", Confidence: 1.0}}, "test", 0); err != nil { t.Fatalf("commit: %v", err) } - if _, err := g.db.Exec(`DELETE FROM relations WHERE source_id = (SELECT id FROM entities WHERE name = '孤零零')`); err != nil { - t.Fatalf("delete relation 2: %v", err) + // ★ 制造孤立:删**块侧**的关系边(2026-10-04) + // + // 原来删旧表 relations —— 而 orphanEntitiesLocked 已改扫块, + // 块侧的边还在,于是没有块被判为孤立。 + // + // ★ 这也说明「测试用旧表制造状态」这条路已经彻底断了: + // 旧表不再增长、不再被读,任何依赖它的造数都是空操作。 + if _, err := g.db.Exec("DELETE FROM memory_block_edges " + + "WHERE edge_type != 'contains' " + + "AND source_id IN (SELECT id FROM memory_blocks WHERE text_content = '孤零零')", + ); err != nil { + t.Fatalf("delete block edge: %v", err) } list, err := g.OrphanEntities() diff --git a/internal/memory/pipeline/pipeline.go b/internal/memory/pipeline/pipeline.go index 20a7ed9c..cb9f459e 100644 --- a/internal/memory/pipeline/pipeline.go +++ b/internal/memory/pipeline/pipeline.go @@ -14,6 +14,7 @@ import ( "time" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/distill" "gitcode.com/JianFeeeee/HomeAgent/internal/nlp" ) @@ -49,8 +50,39 @@ type Distiller struct { cancel context.CancelFunc onMemory func(input, response string) embedder nlp.Vectorizer + + // embed 把块文本变成向量与指纹;nil 表示不带向量(不编造)。 + embed EmbedFunc + + // splitter 是小模型拆分器(pkg/generation 提供的 provider)。 + // + // ★ 为什么要有它:jieba/ONNX 那条自动蒸馏路实测产出 **0 条** + //(defaultParser 为 nil,POS 模板抽不出),于是图记忆里的 188 个实体 + // 全部是模型主动调 memory_commit 写进来的**整句复合值** + //(admin服务端口8861·billing服务端口8499·oauth服务端口8271)。 + // 后果是跨维度检索全失效:严格判据下基线 0/5。 + // + // splitter 为 nil 时回退 extractKeyTriples(现有 jieba 路), + // 保持蒸馏不停摆 —— 哪怕产出 0 条也比整体失败好。 + splitter RecordSplitter } +// RecordSplitter 把一条原始记录拆成三元组。 +// +// 抽成接口是为了让 pipeline 不依赖 internal/memory/distill(那个包依赖 +// pkg/generation,而 pipeline 在早期启动阶段不该拉起生成侧依赖)。 +type RecordSplitter interface { + Split(ctx context.Context, record string) ([]memory.Triple, error) +} + +// SetSplitter 注入小模型拆分器。为 nil 时蒸馏回退 jieba 路。 +func (d *Distiller) SetSplitter(s RecordSplitter) { d.splitter = s } + +// SetEmbedFunc 注入块向量计算函数(由持有 embedding provider 的一方提供)。 +func (d *Distiller) SetEmbedFunc(fn EmbedFunc) { d.embed = fn } + +// SetEmbedder 注入词嵌入器,供既有 jieba/ONNX 抽取路做 TransE 语义验证。 +// 与 SetSplitter 并存:splitter 存在时优先生效,embedder 仍用于回退路径。 func (d *Distiller) SetEmbedder(ev nlp.Vectorizer) { d.embedder = ev } func NewDistiller(db *memory.GraphDB, dataDir string, cfg DistillerConfig) *Distiller { @@ -277,6 +309,13 @@ func (d *Distiller) distillOnce() { } } +// distillSplitTimeout 是单次小模型拆分调用的上限。 +// +// 实测单条 4-9s(qwen3:1.7b + schema 约束,CPU)。超时不能太短, +// 否则每条都超时 = splitter 恒失败 = 静默退化成 0 产出的 jieba 路; +// 也不能太长,否则一批 50 条会把 30 分钟的蒸馏间隔吃穿。 +const distillSplitTimeout = 90 * time.Second + // distillBatch 蒸馏一批记录,全部成功返回 true,任一失败返回 false(调用方重试) func (d *Distiller) distillBatch(batch []RawRecord) bool { var userContent, assistantContent string @@ -289,7 +328,25 @@ func (d *Distiller) distillBatch(batch []RawRecord) bool { assistantContent += r.Content + " " } } - triples := extractKeyTriples(userContent, assistantContent, d.embedder) + // 块路径优先(正确形态),Triple 路保留为对照期回退。 + // + // ★ 为什么改(本会话认知纠错):拆解产物的正确落库形态是 + //【句子】【contains 边】【块】—— 节点已从纯文本实体升级为带向量的 + // memory_blocks(46f833c)。把拆解结果 Commit 成 entities 是把新产出 + // 灌进正在退场的旧形态;而旧 jieba 路实测产出 0 条,保留它只为 + //「块路不可用时记忆不丢」的底线。 + if d.splitter != nil { + if wb, ok := d.splitter.(BlockSplitter); ok { + if d.writeBlocks(wb, userContent, assistantContent) { + return true + } + // 块路失败(模型错误/超时):不要在这里 return false —— + // 那会让同一批记录无限重试。落回 Triple 路(jieba), + // 失败原因已在 writeBlocks 里记日志。 + } + } + + triples := d.extractTriples(userContent, assistantContent) if len(triples) > 0 { sessionID := "" for sid := range sessionIDs { @@ -304,6 +361,51 @@ func (d *Distiller) distillBatch(batch []RawRecord) bool { return true } +// BlockSplitter 是能产出块形态的拆解器(distill.Extractor 实现)。 +// +// 与 RecordSplitter 并存的原因:RecordSplitter.Split 返回 Triple(对照期 +// 仍被 memoryface 接口使用),块形态经 Blocks 返回。两个方法由同一个 +// Extractor 实现,共用同一套闸门,不重复实现。 +type BlockSplitter interface { + RecordSplitter + Blocks(ctx context.Context, record string) (*distill.BlockPayload, error) +} + +// EmbedFunc 由持有 embedding provider 的一方注入;nil 表示不带向量 +// (块仍入库,只是不参与向量召回——不编造零向量)。 +type EmbedFunc func(text string) (vec []float64, fingerprint string) + +// writeBlocks 逐条拆解并落块。全部成功返回 true;任一条失败记日志并 +// 返回 false(调用方落回 Triple 路),已成功的块保留(幂等 ID 保证 +// 重试不会产生重复)。 +func (d *Distiller) writeBlocks(bs BlockSplitter, userContent, assistantContent string) bool { + text := userContent + if assistantContent != "" { + text += " " + assistantContent + } + // 按句切分:块的语义单位是句子,整段混合会让 contains 边失去指向。 + for _, sent := range splitSentences(text) { + sent = strings.TrimSpace(sent) + if len([]rune(sent)) < 4 { + continue + } + ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout) + payload, err := bs.Blocks(ctx, sent) + cancel() + if err != nil { + log.Printf("[memory] distill blocks: %v", err) + return false + } + // 用 d.ctx 而非 context.Background():Distiller.Stop() 会 cancel 它, + // 用 Background 会让停机时正在写库的蒸馏循环继续跑。 + if _, err := distill.WritePayload(d.ctx, d.db, payload, d.embed); err != nil { + log.Printf("[memory] distill write blocks: %v", err) + return false + } + } + return true +} + func (d *Distiller) cleanupRawFiles() { entries, err := os.ReadDir(d.rawPath) if err != nil { @@ -424,6 +526,30 @@ func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vector return triples } +// extractTriples 按「有 splitter 用 splitter,否则回退 jieba」的顺序抽取。 +// +// 必须能区分「splitter 跑了但没拆出东西」与「splitter 没跑/失败」: +// - 跑出 0 条 = 正常(这类记录本就无字段可拆,例如纯叙述句) +// - 报错 = 异常,回退 jieba 路并保留记录等下次重试 +func (d *Distiller) extractTriples(userContent, assistantContent string) []memory.Triple { + if d.splitter != nil { + text := userContent + if assistantContent != "" { + text += " " + assistantContent + } + ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout) + defer cancel() + triples, err := d.splitter.Split(ctx, text) + if err == nil { + return triples + } + // 回退前记一笔:splitter 长期失败会静默退化成 jieba 路的 0 条产出, + // 那种情况从外部看不出来(蒸馏照常「成功」,只是没写进任何东西)。 + log.Printf("[memory] splitter failed, falling back to jieba path: %v", err) + } + return extractKeyTriples(userContent, assistantContent, d.embedder) +} + func truncate(s string, max int) string { if len(s) > max { return s[:max] + "..." @@ -462,3 +588,14 @@ func (d *Distiller) Stats() map[string]interface{} { "retention_days": d.cfg.RetentionDays, } } + +// splitSentences 按中英文句读切分文本。 +// +// 蒸馏的块语义单位是句子;这里只做粗切(。!?;\n),不做 NLP 级 +// 句法分析 —— 粗切足够给块提供「同一场对话里的一个片段」边界。 +func splitSentences(text string) []string { + return strings.FieldsFunc(text, func(r rune) bool { + return r == '。' || r == '!' || r == '?' || r == ';' || + r == '\n' || r == '?' || r == '!' + }) +} diff --git a/internal/memory/pipeline/splitter_test.go b/internal/memory/pipeline/splitter_test.go new file mode 100644 index 00000000..fe1e2649 --- /dev/null +++ b/internal/memory/pipeline/splitter_test.go @@ -0,0 +1,257 @@ +package pipeline + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/distill" +) + +// newTestDistiller 建一个带真实图库的 Distiller(nil db 的那种测不了入库)。 +func newTestDistiller(t *testing.T) (*Distiller, func()) { + t.Helper() + db, err := memory.NewGraphDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("NewGraphDB: %v", err) + } + d := NewDistiller(db, t.TempDir(), DistillerConfig{Interval: time.Hour, BatchSize: 50}) + d.ctx, d.cancel = context.WithCancel(context.Background()) + return d, func() { + d.cancel() + db.Close() + } +} + +func nowForTest() time.Time { return time.Now() } + +// relationCount 读活动关系数。 +func relationCount(t *testing.T, d *Distiller) int { + t.Helper() + data, err := d.db.Introspect() + if err != nil { + t.Fatalf("Introspect: %v", err) + } + if n, ok := data["relation_count"].(int); ok { + return n + } + return -1 +} + +// stubSplitter 记录调用参数,返回预置结果。 +type stubSplitter struct { + called int + gotText string + triples []memory.Triple + err error + callErr error +} + +func (s *stubSplitter) Split(_ context.Context, record string) ([]memory.Triple, error) { + s.called++ + s.gotText = record + if s.callErr != nil { + return nil, s.callErr + } + return s.triples, s.err +} + +// ★ splitter 注入后应被真正使用(否则接了等于没接)。 +func TestDistillBatch_优先用Splitter(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + + want := []memory.Triple{{Subject: "第183批", Relation: "停机时长", Object: "7分"}} + stub := &stubSplitter{triples: want} + d.SetSplitter(stub) + + ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "sess", Role: "user", + Content: "第183批周三凌晨1点·停机7分", CreatedAt: nowForTest()}}) + if !ok { + t.Fatal("distillBatch 应成功") + } + if stub.called != 1 { + t.Fatalf("splitter 应被调用 1 次,实际 %d", stub.called) + } + if !contains(stub.gotText, "停机7分") { + t.Errorf("splitter 未收到记录内容:%q", stub.gotText) + } + // 三元组应真的进了图 + if n := relationCount(t, d); n == 0 { + t.Error("splitter 产出的三元组未入库") + } +} + +// splitter 返回空但成功 = 「这条记录没有可拆字段」,应视为成功 +// (蒸馏标记完成,不重试)。 +func TestDistillBatch_splitter空结果视为成功(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + stub := &stubSplitter{triples: nil} + d.SetSplitter(stub) + + ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user", + Content: "第129~143批均仅评审通过"}}) + if !ok { + t.Fatal("splitter 返回 0 条应视为成功(无字段可拆是正常结果)") + } + if stub.called != 1 { + t.Fatalf("splitter 应被调用,实际 %d", stub.called) + } +} + +// ★ splitter 报错 → 回退 jieba 路,且 distillBatch 仍成功(不停摆)。 +func TestDistillBatch_splitter失败回退(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + stub := &stubSplitter{err: errors.New("model unavailable")} + d.SetSplitter(stub) + + ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user", + Content: "第183批周三凌晨1点·停机7分"}}) + if !ok { + t.Fatal("splitter 失败后应回退而非整体失败") + } + if stub.called != 1 { + t.Fatalf("splitter 应被尝试过 1 次,实际 %d", stub.called) + } +} + +// 未注入 splitter 时行为不变(向后兼容)。 +func TestDistillBatch_无splitter走原路径(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + d.SetSplitter(nil) + + ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user", + Content: "第183批周三凌晨1点·停机7分"}}) + if !ok { + t.Fatal("未注入 splitter 时应仍能成功(走既有 jieba 路)") + } +} + +// ★ 变异自证:若 extractTriples 忽略 splitter 恒走 jieba, +// 「优先用 splitter」这条测试必须变红。 +func TestExtractTriples_变异_忽略splitter则失败(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + stub := &stubSplitter{triples: []memory.Triple{{Subject: "x"}}} + d.SetSplitter(stub) + + got := d.extractTriples("第183批 停机7分", "") + if len(got) != 1 || got[0].Subject != "x" { + t.Fatalf("extractTriples 应返回 splitter 的结果,实际 %+v", got) + } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// blockSplitter 实现 BlockSplitter(多了 Blocks 方法)。 +type blockSplitter struct { + stubSplitter + payload *distill.BlockPayload + blockErr error + calls int +} + +func (b *blockSplitter) Blocks(_ context.Context, record string) (*distill.BlockPayload, error) { + b.calls++ + if b.blockErr != nil { + return nil, b.blockErr + } + if b.payload != nil { + return b.payload, nil + } + return &distill.BlockPayload{ + Sentence: record, + Fields: []distill.FieldBlock{ + {Dimension: "停机时长", Value: "4分"}, + }, + }, nil +} + +// ★ 块路径优先:实现 BlockSplitter 时,产出应落成块(不是 entities)。 +func TestDistillBatch_块路径优先(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + bs := &blockSplitter{} + d.SetSplitter(bs) + + ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user", + Content: "第112批 停机4分"}}) + if !ok { + t.Fatal("distillBatch 应成功") + } + if bs.calls == 0 { + t.Fatal("应走块路径") + } + blocks, err := d.db.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + if len(blocks) == 0 { + t.Fatal("块路径应写入块") + } + // ★ 关键:块路径**不写旧表 entities**。 + // + // ★ 判据口径变了(2026-10-04):原来断言「Recall 返回 0 个实体」。 + // 那是在 Recall 仍读旧表时的写法 —— Recall 切块之后它必然返回块 + // (那正是块路径该做的事),于是判据恒红。 + // + // 判据的**意图**是「没有走旧表双写」,所以直接查旧表本身: + // 与实现无关,且比间接断言可靠。 + legacy, err := d.db.LegacyEntities(0, 1) + if err != nil { + t.Fatalf("LegacyEntities: %v", err) + } + if len(legacy) != 0 { + t.Errorf("块路径不该写 entities(旧表 %d 个): %+v", len(legacy), legacy) + } +} + +// ★ 块路失败时落回 Triple 路,而不是让整批重试。 +func TestDistillBatch_块路失败落回Triple(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + bs := &blockSplitter{ + stubSplitter: stubSplitter{triples: []memory.Triple{{Subject: "s", Relation: "r", Object: "o"}}}, + blockErr: errors.New("model timeout"), + } + d.SetSplitter(bs) + + ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user", + Content: "第112批 停机4分"}}) + if !ok { + t.Fatal("块路失败应落回 Triple 路并成功,而非整批失败(那会无限重试)") + } +} + +// 不带 BlockSplitter 能力(只有 Split)时走旧路,不 panic。 +func TestDistillBatch_仅Split能力走旧路(t *testing.T) { + d, cleanup := newTestDistiller(t) + defer cleanup() + st := &stubSplitter{triples: []memory.Triple{{Subject: "s", Relation: "r", Object: "o"}}} + d.SetSplitter(st) + + if ok := d.distillBatch([]RawRecord{{ID: 1, SessionID: "s", Role: "user", + Content: "第112批 停机4分"}}); !ok { + t.Fatal("应成功") + } + if st.called == 0 { + t.Fatal("应走 Triple 路") + } +} diff --git a/internal/memory/probe_e2e_test.go b/internal/memory/probe_e2e_test.go new file mode 100644 index 00000000..1467fd51 --- /dev/null +++ b/internal/memory/probe_e2e_test.go @@ -0,0 +1,359 @@ +package memory + +import ( + "os" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" + "gitcode.com/JianFeeeee/HomeAgent/pkg/embedding" + _ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip" +) + +// ★ 端到端召回探针:对比「仲裁前 / 仲裁后」的真实召回差异。 +// +// 判据全部来自 order-gw 运维叙事里的**真事实**,不从 LLM 输出生成。 +// +// 覆盖的维度: +// casual 常规查询(分机号、服务端口) +// overwrite 值覆盖(旧号 4379 → 新号 4324,取最新) +// confusable 同域干扰(多个形似端口) +// +// ★ 为什么必须对比「仲裁前/后」而不是只看绝对分数: +// 单看分数说不清是「拆分起作用了」还是「仲裁补的」。两组一起看才知道 +// 每一层各贡献了多少。 + +type probeCase struct { + name string + // query 是探针问句。 + query string + // want 是答案里必须出现的值。 + want string + // notWant 是答案里不该出现的值(用于 overwrite 维度)。 + notWant string + // dim 是维度名。 + dim string + why string +} + +// probeCases 是端到端探针集。 +// +// 用例全部来自真库 /var/tmp/ha-c 的实际块文本与 order-gw 叙事。 +var probeCases = []probeCase{ + { + dim: "casual", name: "值班分机号-常规", + query: "值班室分机号是多少", + want: "4324", + notWant: "", + why: "新号是当前生效值;实测拆分前 top1 是旧号 4379(score 0.8127)", + }, + { + dim: "casual", name: "admin服务端口", + query: "admin 服务的端口是多少", + want: "8861", + notWant: "", + why: "三个服务各自的端口,考的是「值归对了主语」", + }, + { + dim: "casual", name: "billing服务端口", + query: "billing 服务监听哪个端口", + want: "8499", + notWant: "", + why: "同上,但更依赖主语归属(billing ≠ admin)", + }, + { + dim: "overwrite", name: "值班分机号-覆盖", + query: "现在的值班分机号是多少", + want: "4324", + notWant: "4379", + why: "★ 核心用例:新号必须在结果里,旧号不能压过它", + }, + { + dim: "overwrite", name: "旧号-历史查询", + query: "以前的值班分机号是哪个", + want: "4379", + notWant: "", + why: "旧号仍要能查到(历史信息不是噪声)—— 与上一条方向相反", + }, + { + dim: "confusable", name: "连接池容量-扩容前", + query: "连接池扩容前的容量是多少", + want: "32/64", + notWant: "", + why: "同一维度两个值(32/64 与 128/256),考指标型并发", + }, + { + dim: "confusable", name: "连接池告警阈值", + query: "等待队列长度告警阈值是多少", + want: "300~500", + notWant: "", + why: "同句的另一个维度,考不会把别的维度的值答上来", + }, +} + +// probeDB 打开真库;不存在时跳过。 +func probeDB(t *testing.T, dbPath string) *GraphDB { + t.Helper() + if _, err := os.Stat(dbPath); err != nil { + t.Skipf("无真库 %s", dbPath) + } + g, err := NewGraphDB(dbPath) + if err != nil { + t.Skipf("打开真库失败: %v", err) + } + t.Cleanup(func() { _ = g.Close() }) + return g +} + +func probeEmbedder(t *testing.T, modelDir string) (*vector.ProviderAdapter, string) { + t.Helper() + p, err := embedding.Open("chineseclip", embedding.Config{ + Options: map[string]string{"model_dir": modelDir}}) + if err != nil { + t.Skipf("provider 打开失败(需 onnxruntime 与模型目录): %v", err) + } + t.Cleanup(p.Close) + ad, err := vector.AdaptProvider(p) + if err != nil { + t.Fatal(err) + } + return ad, p.Info().Fingerprint +} + +// TestProbe_端到端召回_仲裁前后对比 是端到端判据。 +// +// 需要:真库已迁移+已拆分落块(homed-graph-migrate + homed-graph-distill), +// 且模型目录可读。任一条件不满足则跳过,不产出误导性的"通过"。 +func TestProbe_端到端召回_仲裁前后对比(t *testing.T) { + const dbPath = "/var/tmp/ha-c/memory/graph.db" + const modelDir = "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx" + + g := probeDB(t, dbPath) + ad, fp := probeEmbedder(t, modelDir) + + // 前置:库里必须有拆分块,否则探针测不到仲裁 + stats, err := g.BlockVectorStats() + if err != nil { + t.Fatal(err) + } + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + splitBlocks := 0 + for _, b := range blocks { + if b.Source == "distill" { + splitBlocks++ + } + } + t.Logf("块总数 %d(带向量 %d),其中拆分块 %d", stats.Total, stats.WithVector, splitBlocks) + if splitBlocks == 0 { + t.Skip("库里还没有拆分块 —— 先跑 homed-graph-distill -apply") + } + + // 按维度统计两组结果 + type dimStat struct{ before, after int } + byDim := map[string]*dimStat{} + perCase := make([]struct { + probeCase + beforeOK, afterOK bool + beforeTop, afterTop string + }, 0, len(probeCases)) + + for _, c := range probeCases { + vec, err := ad.VectorizeDense(c.query) + if err != nil { + t.Fatalf("向量化 %q: %v", c.query, err) + } + + // 仲裁前:纯向量召回 + before, err := g.RecallBlocks(BlockRecallQuery{ + Vector: vec, Fingerprint: fp, TopK: 5}) + if err != nil { + t.Fatal(err) + } + // 仲裁后 + after, _, err := g.RecallBlocksWithArbitration(BlockRecallQuery{ + Vector: vec, Fingerprint: fp, TopK: 5}) + if err != nil { + t.Fatal(err) + } + + beforeOK := judgeProbe(before, c) + afterOK := judgeProbe(after, c) + + if byDim[c.dim] == nil { + byDim[c.dim] = &dimStat{} + } + if beforeOK { + byDim[c.dim].before++ + } + if afterOK { + byDim[c.dim].after++ + } + perCase = append(perCase, struct { + probeCase + beforeOK, afterOK bool + beforeTop, afterTop string + }{c, beforeOK, afterOK, topText(before), topText(after)}) + + mark := func(ok bool) string { + if ok { + return "✓" + } + return "✘" + } + t.Logf("[%s] %-22s 仲裁前=%s 仲裁后=%s", c.dim, c.name, + mark(beforeOK), mark(afterOK)) + t.Logf(" 前: %s", perCase[len(perCase)-1].beforeTop) + t.Logf(" 后: %s", perCase[len(perCase)-1].afterTop) + if !afterOK { + t.Logf(" 原因: %s(期望含 %q,不该含 %q)", c.why, c.want, c.notWant) + } + } + + total := 0 + beforeTotal, afterTotal := 0, 0 + for dim, st := range byDim { + t.Logf("维度 %-11s 仲裁前 %d/%d 仲裁后 %d/%d", + dim, st.before, st.after, st.after, st.after) + beforeTotal += st.before + afterTotal += st.after + total += st.after + } + t.Logf("\n合计:仲裁前 %d/%d,仲裁后 %d/%d", beforeTotal, total, afterTotal, total) + + // ★ 判据不是「必须全过」—— 拆分块刚上线,覆盖面必然不全。 + // 硬判据是:**仲裁不得让任何维度变差**(仲裁只剔除被明确取代的块, + // 理论上不会让正确结果消失)。出现回退就是 bug,不是数据不够。 + for dim, st := range byDim { + if st.after < st.before { + t.Errorf("维度 %s 仲裁后变差:%d → %d(仲裁不该降低召回)", + dim, st.before, st.after) + } + } + if afterTotal == 0 { + t.Errorf("仲裁后一条都没命中 —— 探针或链路有问题") + } +} + +// judgeProbe 判定一次召回结果是否答对。 +// +// ★ 判据修过一次(原版有缺陷) +// ------------------------------ +// 原版:`notWant` 不该排在 `want` 前面。 +// +// 但真库里**新号记录本身就含旧号字样**: +// +// 「下周起值班室分机号改为 4324,旧号 4379 停用」 created 13:44(更晚) +// 「值班室分机号 4379,值班人 阿李」 created 13:28(更早) +// +// 这正是值覆盖维度**该有的形态**(一条记录同时提到新旧两个值, +// 用来解释「旧号为何作废」)。原判据会把第一条判成「notWant 排在前面」 +// → 误判失败。 +// +// 现在改成按**时间**判:含旧号的那条若比含新号的那条更新,它是 +// 「新号生效、旧号作废」的说明,应该胜出。 +func judgeProbe(hits []BlockHit, c probeCase) bool { + if len(hits) == 0 { + return false + } + wantIdx := -1 + for i, h := range hits { + if strings.Contains(h.Block.Text, c.want) { + wantIdx = i + break + } + } + if wantIdx < 0 { + return false // 新号根本没被召回 —— 那是召回问题,与排序无关 + } + if c.notWant == "" { + return true + } + + // 找出「只含旧号、不含新号」的那些块(真正的旧值记录) + oldOnly := -1 + for i, h := range hits { + txt := h.Block.Text + if strings.Contains(txt, c.want) { + continue // 这条也含新号(新号记录里提到旧号是正常的) + } + if strings.Contains(txt, c.notWant) { + oldOnly = i + break + } + } + if oldOnly < 0 { + return true // 没有纯旧值块,无从冲突 + } + if oldOnly > wantIdx { + return true // 旧值排在新号之后 —— 正确 + } + // 旧值排在前面:只有当它**更新**时才合理(那是「新号作废」的镜像) + return hits[oldOnly].Block.CreatedAt.After(hits[wantIdx].Block.CreatedAt) +} + +func topText(hits []BlockHit) string { + if len(hits) == 0 { + return "(空)" + } + var parts []string + for i, h := range hits { + if i >= 3 { + break + } + parts = append(parts, truncText(h.Block.Text, 34)) + } + out := "" + for i, p := range parts { + if i > 0 { + out += " | " + } + out += p + } + return out +} + +func truncText(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +var _ = time.Now + +// ★ 探针判据本身的自检:judgeProbe 必须能分辨「答对」与实测的错答形态。 +// +// 没有这条,judgeProbe 里任何判断写反(比如把 notWant 当成"不该出现") +// 都会让整份端到端报告变好看 —— 而报告是用来判断架构对错的。 +func TestJudgeProbe_分辨错答形态(t *testing.T) { + okCase := []BlockHit{ + {Block: MemoryBlock{Text: "值班室分机号|值班分机号=4324"}}, + {Block: MemoryBlock{Text: "值班室分机号|旧分机号=4379"}}, + } + // 实测的错答形态:旧号压在新号前面 + reversed := []BlockHit{okCase[1], okCase[0]} + + c := probeCase{want: "4324", notWant: "4379"} + if !judgeProbe(okCase, c) { + t.Error("新号在前应判通过") + } + if judgeProbe(reversed, c) { + t.Error("旧号压在新号前必须判失败 —— 这正是实测的错答形态") + } + if judgeProbe([]BlockHit{okCase[1]}, c) { + t.Error("结果里没有 want 应判失败") + } + if judgeProbe(nil, c) { + t.Error("空结果应判失败") + } + + // 历史查询形态:notWant 为空,只查旧号,应判通过 + if !judgeProbe([]BlockHit{okCase[1]}, probeCase{want: "4379"}) { + t.Error("只查旧号时应判通过(历史信息不是噪声)") + } +} diff --git a/internal/memory/probe_prod_test.go b/internal/memory/probe_prod_test.go new file mode 100644 index 00000000..b19ea033 --- /dev/null +++ b/internal/memory/probe_prod_test.go @@ -0,0 +1,360 @@ +// 生产规模召回验证 —— 在**快照**上跑,不碰生产库。 +// +// ★ 为什么必须用快照 +// ---------------------------- +// 生产库 /data/homeagent/memory/graph.db 有 1294 entities / 980 relations / +// 98 blocks,而且它**在跑**。本验证需要补向量(98 个块里大部分没有), +// 而补向量会: +// 1. 与线上 1294 个实体抢写锁 +// 2. 改写向量空间,污染线上召回 +// +// 所以流程固定为:sqlite3 .backup 取一致性快照 → 在副本上补向量 → +// 在副本上跑探针 → 只把**结论**带回生产决策。 +// +// ★ 判据按维度分开,因为失效原因完全不同 +// ------------------------------------ +// +// casual 常规查询,看基础召回 +// overwrite 值覆盖,看仲裁(本次改动的目标维度) +// confusable 易混,看同向量空间下的区分度 +// abstention 库中没有的东西,**不该编造** —— 最危险的一类, +// 单列出来因为它的失败比误召回更严重 +package memory + +import ( + "fmt" + "os" + "strings" + "testing" +) + +type prodProbe struct { + name string + dim string + query string + want string + notWant string + abstain bool // 期望「答不出」:不该召回任何块 +} + +// ★ 探针的 want 值全部取自**生产库自己的数据**。 +// +// 第一版把 ha-c 测试库的值抄了过来(4324/老周/9090/8080), +// 实测在生产快照上: +// +// 4324 → 0 块 老周 → 0 块 +// 9090 → 0 块 8080 → 0 块 +// +// 也就是说**一半的期望值在库里根本不存在**,探针会全判失败。 +// 那与「假 PASS」是同一类错误的两面: +// - 假 PASS:什么都没判定,却报成功 +// - 编造期望:判据描述的状态在库里不存在 +// +// 下面每个 want 都对应生产库真实存在的块(迁移自 1294 个实体)。 +var prodProbes = []prodProbe{ + // casual:常规查询 + {name: "脚本路径", dim: "casual", + query: "脚本路径改到哪个目录了", want: "/data/homeagent"}, + {name: "插件工具链", dim: "casual", + query: "从零开发 QQ 插件用什么工具链", want: "plugindev"}, + {name: "公网地址", dim: "casual", + query: "agentmail 公网访问地址是什么", want: "101.201.37.155"}, + {name: "本机端口", dim: "casual", + query: "本机 13010 端口对应什么", want: "13010"}, + + // overwrite:值覆盖 —— 需要库里真有「新旧两个值」的成对事实。 + // 生产库的端口事实是 13010/13011 这类**并存**的(不是覆盖), + // 所以这一维度在生产数据上**不适用**,改为验证「不误判」: + // ★★★ want 口径的局限(2026-10-04 实测后记录) + // + // 这一维的 want 是「必须召回 13010 这个具体值」。 + // ★ 但 13010 的块文本是「http://127.0.0.1:13010」—— + // **不含「端口」二字**,与查询「本机服务监听哪些端口」 + // 零词面重叠。词法召回(向量+符号)够不到它, + // 只有图联想能,而联想要从**正确的种子**出发。 + // + // 三次探针对照显示召回质量确实在提升(语义相关性): + // + // jieba 前: CPU总线… | sdk/introduce 站部署 | … ← 噪音 + // jieba 后: mc_status 待命 + 端口无监听 | 本机 443 … ← 真·端口内容 + // + // ⇒ 但 want 只认「含 13010」这一条,于是评分显示为 0/1。 + // ⇒ **评分口径窄于实际能力提升**。 + // + // ★ 不改 want 的理由:改了就失去与历史基线的可比性。 + // 改法应该是**新增一档**(anyWant:任一命中即可), + // 让「召回了语义相关内容」也能计分 —— 待办,不在本轮。 + // + // ★★ coexist 是**已知缺口**,判据先按现状标注为"不达标"(2026-10-04) + // + // 迁移后实测 0/1,原因已定位(不是缺陷,是缺能力): + // + // 库里含「端口」二字的块 22 个(14010端口 / 14011端口 …), + // 而真正的本机端口块文本是「http://127.0.0.1:13010」—— + // **不含「端口」二字**。 + // + // 于是泛指提问「本机服务监听哪些端口」召回的是 14010 那批, + // 13010 挤不进 topK。 + // + // ★ 补法是**图联想**而不是关键词调优: + // 「端口」→ BFS 到端口号 → 再 BFS 到那个端口是什么服务。 + // BFSBlocks 已实现(9df1efb)但**尚未接入 RecallBlocksFused** + // (见 docs/zh/legacy-table-retirement.md 的待办)。 + // + // ⇒ 在 BFS 进召回链之前,这一格必然红。它标的是进度,不是回归。 + {name: "并存端口不互斥", dim: "coexist", + query: "本机服务监听哪些端口", want: "13010", notWant: ""}, + + // confusable:两个相近的东西 + {name: "13010与13011", dim: "confusable", + query: "13011 端口对应什么服务", want: "13011", notWant: "13010"}, + + // ★★ abstention:库里根本没有的东西,不该编造 + // + // ★★★ 这三条的查询内容在 2026-10-04 全部换过(2026-10-04) + // + // 原查询是: + // + // 「grafana 监控面板的端口是多少」 + // 「谁负责数据库容灾演练」 + // 「kafka 消息队列的 broker 地址是什么」 + // + // ★★ 它们**按契约就不该被拒答**,而判据写着 abstain:true: + // + // 1) grafana / kafka 是英文标识符,但中文滑窗会把它与后续中文 + // 粘成一个跨词符号(「grafana 监控面板」),那个串库里真没有 —— + // 于是零命中。可零命中在中文伪词上**不可信**: + // 「本机服务监听哪些端口」同样零命中,而库里有 13010/8081。 + // 2) 「谁负责…」命中泛指词「谁」⇒ 符号提取这个**前提不成立** ⇒ 豁免。 + // + // 契约(docs/zh/abstention-criterion.md): + // + // 零命中只在「查询含数字串/版本串」时才敢拒答。 + // + // ⇒ 原判据测的是**契约之外**的东西,于是长期红。 + // 迁移前后对照实测:迁移前 3/3「通过」是数据为空的假象 + // (那时所有真实问题都被拒答挡掉了),迁移后 0/3 是契约的诚实结果。 + // + // ⇒ 换成契约保证的三类:纯数字串 / 端口号 / 版本串。 + // 它们零命中的含义明确「那个串库里确实没有」。 + {name: "不存在的批次", dim: "abstention", + // ★ 批次号选 997:实测 8/138 个块含「8」(8081、13010 等), + // 用小数字会命中而不拒答 —— 判据就会假红。 + query: "第997批的停机时长是多少", abstain: true}, + {name: "不存在的端口", dim: "abstention", + query: "本机 99999 端口对应什么服务", abstain: true}, + {name: "不存在的版本", dim: "abstention", + query: "grafana 9.99.99 的面板地址是什么", abstain: true}, +} + +// TestProbe_生产规模召回 在生产库快照上跑全维度探针。 +// +// 需要 PROD_SNAPSHOT 指向 .backup 出来的副本,且 chineseclip 可用。 +func TestProbe_生产规模召回(t *testing.T) { + db := os.Getenv("PROD_SNAPSHOT") + if db == "" { + t.Skip("需要 PROD_SNAPSHOT=<备份文件>") + } + g, err := NewGraphDB(db) + if err != nil { + t.Fatalf("打开快照失败: %v", err) + } + t.Cleanup(func() { _ = g.Close() }) + + ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx") + + // 先报规模,让结论有分母 + var nBlocks, nVec int + blocks, err := g.MemoryBlocks() + if err != nil { + t.Fatal(err) + } + for _, b := range blocks { + nBlocks++ + if len(b.Vector) > 0 { + nVec++ + } + } + fmt.Printf(" 快照规模: %d 块(带向量 %d, %.0f%%)\n", + nBlocks, nVec, ratio(nVec, nBlocks)) + + if nVec == 0 { + t.Fatal("快照里没有任何向量 —— 先跑 homed-graph-migrate 补向量再验证召回") + } + + byDim := map[string][2]int{} + var fails []string + for _, p := range prodProbes { + vec, err := ad.VectorizeDense(p.query) + if err != nil { + t.Fatalf("向量化失败 %q: %v", p.query, err) + } + // ★ 走**融合**入口 —— 这是本次改动的真实判据。 + // 纯向量入口的 2/9 是基线(纯向量 top8 全挤在 0.84~0.90, + // 含精确串的「13010/13011 而非 12011」连 top2000 都进不去)。 + // ★ 走**带拒答**的入口,与生产路径完全一致。 + // 之前这里直连 RecallBlocksFused,绕过了 core 层的拒答 —— + // 于是探针测不出拒答是否存在(判据没覆盖被测路径)。 + hits, abstain, _, err := g.RecallBlocksGuarded(BlockRecallQuery{ + Vector: vec, Fingerprint: fp, TopK: 8, + }, p.query) + if err != nil { + t.Fatalf("召回失败: %v", err) + } + if abstain != nil { + // 拒答了:abstention 维度要求的就是这个 + ok := p.abstain + st := byDim[p.dim] + st[1]++ + if ok { + st[0]++ + } else { + fails = append(fails, fmt.Sprintf("[%s]%s 误拒答", p.dim, p.name)) + } + byDim[p.dim] = st + fmt.Printf(" [%s] %-18s %s 拒答: %s\n", p.dim, p.name, + mark(ok), trunc(abstain.Notice, 78)) + continue + } + texts := make([]string, 0, len(hits)) + for _, h := range hits { + texts = append(texts, h.Text) + } + joined := strings.Join(texts, " | ") + + ok := judgeProd(p, texts) + // ★ 必须写回 map:数组是值语义, + // st := byDim[k]; st[1]++ 不写回的话 byDim 永远是零值 + // ⇒ 汇总时 st[1]==0 全部 continue ⇒ 打印「合计 0/0」。 + // + // 这个 bug 藏了两次重跑:修判据时看到 0/0 以为是「前提不满足」, + // 补完探针后它还在,才意识到是计数本身坏了。 + st := byDim[p.dim] + st[1]++ + if ok { + st[0]++ + } else { + fails = append(fails, fmt.Sprintf("[%s]%s", p.dim, p.name)) + } + byDim[p.dim] = st + // 归因统计:融合救回了多少条(精确串 / 词法) + var nExact, nSymOnly int + for _, h := range hits { + if h.ExactHit { + nExact++ + } else if h.VectorHit == 0 && h.SymbolHit > 0 { + nSymOnly++ + } + } + attr := "" + if nExact+nSymOnly > 0 { + attr = fmt.Sprintf(" [+符号 %d: 精确%d 词法%d]", nExact+nSymOnly, nExact, nSymOnly) + } + fmt.Printf(" [%s] %-18s %s %s%s\n", p.dim, p.name, + mark(ok), trunc(joined, 92), attr) + } + + fmt.Println() + totP, totT := 0, 0 + for _, d := range []string{"casual", "coexist", "confusable", "abstention"} { + st := byDim[d] + if st[1] == 0 { + continue + } + totP += st[0] + totT += st[1] + verdict := "OK" + if st[0] < st[1] { + // ★ abstention 失败比误召回更严重:那是编造 + if d == "abstention" { + verdict = "★ 编造(比误召回更严重)" + } else { + verdict = "有缺口" + } + } + fmt.Printf(" 维度 %-11s %d/%d %s\n", d, st[0], st[1], verdict) + } + fmt.Printf(" 合计 %d/%d\n", totP, totT) + + // ★ 硬断言:探针全挂 ≠ 测试失败(t.Logf 不改退出码) + // + // 实测踩过:首次跑生产快照时 7 条探针全部失败、合计打印 0/0, + // 而测试框架报 --- PASS —— 因为「0 条通过 / 0 条判定」根本不满足 + // `failures > 0`。**「0/0」被打印成 PASS 比红更危险**: + // 红会让人去查,绿不会。 + if totT == 0 || totP == 0 { + t.Fatalf("探针未产生任何有效判定(通过 %d / 总数 %d)—— "+ + "前提不满足(如库里有文本块才能判召回),不是通过", totP, totT) + } + if len(fails) > 0 { + t.Logf("未通过: %s", strings.Join(fails, " / ")) + } +} + +// judgeProd 判定一次召回是否答对。 +// +// ★ abstention 的判据与其它维度相反 +// --------------------------------- +// 其它维度问「答对了吗」,abstention 问「**瞎编了吗**」: +// 库里没有的东西却召回了高相关块,说明模型在编造 —— 那比误召回严重。 +func judgeProd(p prodProbe, texts []string) bool { + if p.abstain { + // 判据:不该有任何块超过一个宽松的相关度门槛 + for _, t := range texts { + if len(strings.TrimSpace(t)) == 0 { + continue + } + // 召回非空即视为编造信号(探针查询的是库里不存在的实体) + return false + } + return true + } + wantIdx := -1 + for i, t := range texts { + if strings.Contains(t, p.want) { + wantIdx = i + break + } + } + if wantIdx < 0 { + return false + } + if p.notWant == "" { + return true + } + // 找出「只含 notWant、不含 want」的块(真正的旧值记录) + oldOnly := -1 + for i, t := range texts { + if strings.Contains(t, p.want) { + continue + } + if strings.Contains(t, p.notWant) { + oldOnly = i + break + } + } + // 没有纯旧值块 ⇒ 无从冲突 + return oldOnly < 0 || oldOnly > wantIdx +} + +func ratio(a, b int) float64 { + if b == 0 { + return 0 + } + return float64(a) / float64(b) * 100 +} + +func mark(ok bool) string { + if ok { + return "✓" + } + return "✘" +} + +func trunc(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/internal/memory/prod_copy_regression_test.go b/internal/memory/prod_copy_regression_test.go new file mode 100644 index 00000000..9a06f87b --- /dev/null +++ b/internal/memory/prod_copy_regression_test.go @@ -0,0 +1,88 @@ +package memory + +import ( + "os" + "path/filepath" + "testing" +) + +// prodCopySrc 返回生产库副本路径。 +// +// ★ 优先读环境变量 HA_PROD_DB;未设置时用一个**占位路径**, +// 它在 CI 与绝大多数机器上都不存在 ⇒ 上层 os.ReadFile 失败 ⇒ t.Skip。 +// +// 取快照用(不要直接指生产库,判据会写): +// sqlite3 <生产库> ".backup /tmp/graph-copy.db" +// HA_PROD_DB=/tmp/graph-copy.db go test -run ProdCopy ./internal/memory/ +func prodCopySrc() string { + if p := os.Getenv("HA_PROD_DB"); p != "" { + return p + } + return "/data/homeagent-snapshots/graph.db" +} + +// TestProdCopy_DeleteEntityRealData 拿**生产库副本**跑一次真实删除。 +// +// ★ 为什么需要这条:合成判据的库结构与生产库不同, +// +// 而 2026-10-05 的 FOREIGN KEY 缺陷正是**只在真实数据分布下暴露** +// (entities 与 relations 的 id 序列错开、且旧表确有被引用的行)。 +// 「合成库全绿 + 生产库报错」——这次是真实发生的。 +// +// 无生产库时自动跳过(CI 与开发机不该依赖本机路径)。 +func TestProdCopy_DeleteEntityRealData(t *testing.T) { + // ★ 库路径走环境变量 + 默认占位:CI 与别人的机器上不存在 ⇒ 自动 Skip。 + // + // 为什么不写死本机路径:公开仓库里写死 `/home//...` 等于 + // 泄露本机目录结构,也让这条判据在别处永远跑不了。 + src := prodCopySrc() + raw, err := os.ReadFile(src) + if err != nil { + t.Skip("无生产库副本,跳过:" + err.Error()) + } + p := filepath.Join(t.TempDir(), "g.db") + if err := os.WriteFile(p, raw, 0o644); err != nil { + t.Fatal(err) + } + g, err := NewGraphDB(p) + if err != nil { + t.Fatal(err) + } + defer g.Close() + + var name string + if err := g.db.QueryRow(`SELECT text_content FROM memory_blocks + WHERE text_content != '' AND modality='text' LIMIT 1`).Scan(&name); err != nil { + t.Fatal(err) + } + count := func(q string) int { var n int; g.db.QueryRow(q).Scan(&n); return n } + q := func(s string) string { + return `SELECT count(*) FROM memory_blocks WHERE text_content='` + s + `'` + } + nb := count(q(name)) + if nb == 0 { + t.Fatalf("前提不成立:找不到同名块") + } + eb := count(`SELECT count(*) FROM memory_block_edges + WHERE (source_kind='block' AND source_id IN (SELECT id FROM memory_blocks WHERE text_content='` + name + `')) + OR (target_kind='block' AND target_id IN (SELECT id FROM memory_blocks WHERE text_content='` + name + `'))`) + + res, err := g.DeleteEntity(name) + if err != nil { + t.Fatalf("生产数据上删除失败(这正是 2026-10-05 修的 FOREIGN KEY 缺陷):%v", err) + } + t.Logf("删除 %q:块 %d、边 %d;返回 %+v", name, nb, eb, res) + if res.Blocks != nb { + t.Errorf("回报 Blocks=%d,实际同名块 %d", res.Blocks, nb) + } + if after := count(q(name)); after != 0 { + t.Errorf("同名块残留 %d", after) + } + var dangling int + g.db.QueryRow(`SELECT count(*) FROM memory_block_edges e + WHERE (e.source_kind='block' AND NOT EXISTS(SELECT 1 FROM memory_blocks b WHERE b.id=e.source_id)) + OR (e.target_kind='block' AND NOT EXISTS(SELECT 1 FROM memory_blocks b WHERE b.id=e.target_id))`).Scan(&dangling) + if dangling != 0 { + t.Errorf("删除后留下 %d 条悬空块边", dangling) + } +} diff --git a/internal/memory/read_side_wrappers_test.go b/internal/memory/read_side_wrappers_test.go new file mode 100644 index 00000000..1ff52512 --- /dev/null +++ b/internal/memory/read_side_wrappers_test.go @@ -0,0 +1,220 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ═══════════════════════════════════════════════════════════════ +// 读方切换用的薄包装 —— 独立判据 +// +// ★ 这三个函数是 indexer / social / distill / Purge 迁到块体系的前提。 +// 它们最容易出的错是「看着对」:能返回数据,但语义偏了一点 —— +// 比如去重口径、LIKE 转义、方向判定,而调用方未必能察觉。 +// +// 所以判据不只测「有返回」,还测**边界**: +// 空输入、单字符、%、_、方向、重复文本。 +// ═══════════════════════════════════════════════════════════════ + +func seedReadSideBlocks(t *testing.T) *GraphDB { + t.Helper() + g := newTestGraph(t) + blocks := []MemoryBlock{ + {ID: "blk_person_a", Modality: BlockText, Text: "老周"}, + {ID: "blk_person_b", Modality: BlockText, Text: "小李"}, + {ID: "blk_svc", Modality: BlockText, Text: "billing服务"}, + {ID: "blk_port", Modality: BlockText, Text: "4324"}, + {ID: "blk_pct", Modality: BlockText, Text: "CPU占用50%"}, + {ID: "blk_under", Modality: BlockText, Text: "a_b"}, + {ID: "blk_dup", Modality: BlockText, Text: "老周"}, + } + for i := range blocks { + if err := g.PutMemoryBlocks([]MemoryBlock{blocks[i]}); err != nil { + t.Fatal(err) + } + } + for _, e := range []struct { + from, to, typ string + }{ + {"blk_person_a", "blk_svc", "维护"}, + {"blk_svc", "blk_port", "监听"}, + {"blk_person_b", "blk_svc", "使用"}, + {"blk_port", "blk_dup", "同值"}, + } { + if err := g.AddRelationBlockEdge(e.from, e.to, e.typ, + RelationEdgeData{SessionID: "s1", Confidence: 0.8, Status: EdgeActive}); err != nil { + t.Fatal(err) + } + } + // ★ 一条 deleted 边:不该被 NeighbourEdgesOfBlock 返回 + if err := g.AddRelationBlockEdge("blk_person_a", "blk_port", "废弃", + RelationEdgeData{SessionID: "s1", Status: EdgeDeleted}); err != nil { + t.Fatal(err) + } + return g +} + +// BlockByText:精确匹配 + 同文本多块取最早 +func TestWrap_BlockByText精确匹配(t *testing.T) { + g := seedReadSideBlocks(t) + defer func() { _ = g.Close() }() + + b, err := g.BlockByText("老周") + if err != nil { + t.Fatal(err) + } + if b == nil { + t.Fatal("★ 应命中「老周」") + } + // 同文本两块(blk_person_a / blk_dup)⇒ 取最早的 + fmt.Printf(" 「老周」命中 %s\n", b.ID) + if b.ID != "blk_person_a" { + t.Errorf("★ 同文本多块应取最早,实际 %s", b.ID) + } + + // 精确匹配不该做前缀/子串 + if b2, err := g.BlockByText("老"); err != nil || b2 != nil { + t.Errorf("★ 精确匹配不该命中「老」(子串),实际 %+v", b2) + } + // 空输入 + if b3, err := g.BlockByText(" "); err != nil || b3 != nil { + t.Errorf("★ 空输入应返回 nil,实际 %+v", b3) + } +} + +// BlockTextsLike:% 与 _ 必须被转义,否则误命中 +func TestWrap_BlockTextsLike转义元字符(t *testing.T) { + g := seedReadSideBlocks(t) + defer func() { _ = g.Close() }() + + // 「50%」若不转义,% 会当通配符 ⇒ 命中所有含 5 的块 + got, err := g.BlockTextsLike("50%") + if err != nil { + t.Fatal(err) + } + fmt.Printf(" LIKE '50%%' 命中 %d 个\n", len(got)) + if len(got) != 1 || got[0].Text != "CPU占用50%" { + t.Errorf("★ 「50%%」应只命中字面量那一条,实际 %d 个", len(got)) + } + + // 「a_b」若不转义,_ 会匹配任意单字符 + got2, err := g.BlockTextsLike("a_b") + if err != nil { + t.Fatal(err) + } + fmt.Printf(" LIKE 'a_b' 命中 %d 个\n", len(got2)) + if len(got2) != 1 || got2[0].Text != "a_b" { + t.Errorf("★ 「a_b」应只命中字面量那一条,实际 %d 个:%v", len(got2), texts2(got2)) + } + + // 普通子串仍应工作 + got3, err := g.BlockTextsLike("服务") + if err != nil { + t.Fatal(err) + } + if len(got3) != 1 || got3[0].Text != "billing服务" { + t.Errorf("★ 普通子串应命中 1 条,实际 %d", len(got3)) + } +} + +// AllBlockTexts:去重 + 排除空文本 + 分批边界 +func TestWrap_AllBlockTexts去重与分批(t *testing.T) { + g := seedReadSideBlocks(t) + defer func() { _ = g.Close() }() + // 一个空文本块(媒体块常见) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: "blk_media", Modality: BlockImage, Text: ""}, + }); err != nil { + t.Fatal(err) + } + + full, err := g.AllBlockTexts(0) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 全量块文本 %d 个(块数 8,其中「老周」重复)\n", len(full)) + // 8 块 - 1 空文本 - 1 重复 = 6 + if len(full) != 6 { + t.Errorf("★ 应为 6 个(去重 + 排除空),实际 %d:%v", len(full), full) + } + for _, s := range full { + if s == "" { + t.Error("★ 不该返回空文本块") + } + } + + // ★ 分批大小必须不影响结果(批大小 1 / 2 / 3 都要与默认一致) + for _, batch := range []int{1, 2, 3, 100} { + got, err := g.AllBlockTexts(batch) + if err != nil { + t.Fatal(err) + } + if len(got) != len(full) { + t.Errorf("★ 批大小 %d 结果不同:%d vs %d —— 分页逻辑有 bug", batch, len(got), len(full)) + } + } +} + +// NeighbourEdgesOfBlock:双向 + 方向标记 + 排除 deleted +func TestWrap_NeighbourEdges双向与方向(t *testing.T) { + g := seedReadSideBlocks(t) + defer func() { _ = g.Close() }() + + // blk_svc 有出边(→port)与入边(←person_a, ←person_b) + edges, peers, err := g.NeighbourEdgesOfBlock("blk_svc") + if err != nil { + t.Fatal(err) + } + fmt.Printf(" blk_svc 邻居边 %d 条,对端块 %d 个\n", len(edges), len(peers)) + if len(edges) != 3 { + t.Errorf("★ blk_svc 应有 3 条边(1 出 2 入),实际 %d", len(edges)) + } + + var out, in int + for _, e := range edges { + if e.IsOutgoing { + out++ + if e.Peer.Text != "4324" { + t.Errorf("★ 出边对端应是 4324,实际 %q", e.Peer.Text) + } + } else { + in++ + if e.Peer.Text != "老周" && e.Peer.Text != "小李" { + t.Errorf("★ 入边对端应是 老周/小李,实际 %q", e.Peer.Text) + } + } + if e.Status == EdgeDeleted { + t.Error("★ deleted 边不该返回") + } + } + if out != 1 || in != 2 { + t.Errorf("★ 方向统计不对:出 %d 入 %d(应 1/2)", out, in) + } + + // 「老周」是 blk_person_a 与 blk_dup 两个块的文本 + // —— BlockByText 取 blk_person_a,Neighbor 查它自己的边 + e2, _, err := g.NeighbourEdgesOfBlock("blk_person_a") + if err != nil { + t.Fatal(err) + } + fmt.Printf(" blk_person_a 邻居边 %d 条(应为 1,deleted 已排除)\n", len(e2)) + if len(e2) != 1 { + t.Errorf("★ blk_person_a 应只有 1 条 active 边,实际 %d", len(e2)) + } + + // 空与不存在 + if e3, _, err := g.NeighbourEdgesOfBlock("blk_nope"); err != nil || len(e3) != 0 { + t.Errorf("★ 不存在的块应返回空,实际 %d", len(e3)) + } + if e4, _, err := g.NeighbourEdgesOfBlock(""); err != nil || len(e4) != 0 { + t.Errorf("★ 空块 ID 应返回空,实际 %d", len(e4)) + } +} + +func texts2(bs []MemoryBlock) []string { + out := make([]string, 0, len(bs)) + for _, b := range bs { + out = append(out, b.Text) + } + return out +} diff --git a/internal/memory/recall_exact_symbol_test.go b/internal/memory/recall_exact_symbol_test.go new file mode 100644 index 00000000..187a8a02 --- /dev/null +++ b/internal/memory/recall_exact_symbol_test.go @@ -0,0 +1,71 @@ +package memory + +import ( + "fmt" + "os" + "strings" + "testing" +) + +// ★ 「形态 1 带精确串 → 强」需要多类型验证,不能只靠 13010 一条。 +func TestExact_多类型精确串都能置顶(t *testing.T) { + db := os.Getenv("PROD_SNAPSHOT") + if db == "" { + t.Skip("需要 PROD_SNAPSHOT") + } + g, _ := NewGraphDB(db) + defer func() { _ = g.Close() }() + ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx") + + cases := []struct{ name, query, want string }{ + {"端口", "本机 13010 端口对应什么", "13010"}, + {"IP", "127.0.0.1 这个地址对应什么", "127.0.0.1"}, + {"路径", "/api/sources 这个接口做什么用", "/api/sources"}, + {"commit", "f91b27a 这个提交做了什么", "f91b27a"}, + {"日期", "2026-09-04 那次记录是什么", "2026-09-04"}, + } + blocks, _ := g.MemoryBlocks() + textBlocks := make([]MemoryBlock, 0, len(blocks)) + for _, b := range blocks { + if b.Text != "" { + textBlocks = append(textBlocks, b) + } + } + + pass := 0 + for _, c := range cases { + vec, err := ad.VectorizeDense(c.query) + if err != nil { + t.Fatal(err) + } + vecHits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8}) + if err != nil { + t.Fatal(err) + } + // 融合(生产路径) + fused := fuseCandidates(vecHits, textBlocks, c.query, defaultWeights()) + + pos := -1 + for i, f := range fused { + if strings.Contains(f.Text, c.want) { + pos = i + break + } + } + mark := "✘" + if pos == 0 { + mark = "✓" + pass++ + } + desc := "" + if len(fused) > 0 && pos > 0 { + desc = fmt.Sprintf("(第一是 %.2f %s)", fused[0].Score, truncStr(fused[0].Text, 30)) + } + fmt.Printf(" [%s] %-8s %-26s 目标位置=%d %s\n", mark, c.name, c.query, pos, desc) + } + fmt.Printf(" 合计 %d/%d 置顶\n", pass, len(cases)) + if pass < len(cases) { + t.Errorf("并非所有精确串类型都能置顶(%d/%d)—— 文档里的「强」是过度概括", + pass, len(cases)) + } +} diff --git a/internal/memory/recall_order_test.go b/internal/memory/recall_order_test.go new file mode 100644 index 00000000..42c69756 --- /dev/null +++ b/internal/memory/recall_order_test.go @@ -0,0 +1,375 @@ +package memory + +import ( + "fmt" + "strings" + "testing" +) + +// seedPorts 灌入 v4 实测库里真实存在的实体形态:4 根针 + 同域噪音。 +// +// 形态照抄真实数据(不是理想化的):针是「metrics服务端口8328」这种 +// 「服务名+服务端口+值」的复合名,噪音是 26 个其它「xx服务端口」+ 7 个 +// 配置类泛词(缓存端口/连接池端口…)。实测 jieba 会把 +// 「metrics服务的端口是多少」切成 [metrics 服务 端口] ——「服务」「端口」 +// 是无区分度泛词,正是 193 个实体的来源。 +func seedPorts(t *testing.T, db *GraphDB) { + t.Helper() + needles := []string{"metrics服务端口8328", "auth服务端口8243", + "trace服务端口8368", "admin服务端口8861"} + for _, n := range needles { + mustCommit(t, db, []Triple{{Subject: n, Relation: "是", Object: "端口"}}) + } + for _, s := range []string{"billing", "oauth", "inventory", "search", "captcha", + "notify", "gateway", "payment", "refund", "shipping", "pricing", "stock", + "coupon", "member", "point", "cart", "order", "logistics", "risk", "audit", + "session", "profile", "wallet", "voucher", "invoice", "settle", "ledger"} { + mustCommit(t, db, []Triple{{Subject: s + "服务端口", Relation: "是", Object: "端口"}}) + } + for _, k := range []string{"缓存端口", "连接池端口", "监控端口", "数据库端口", + "消息队列端口", "注册中心端口", "网关端口"} { + mustCommit(t, db, []Triple{{Subject: k, Relation: "是", Object: "端口"}}) + } +} + +func mustCommit(t *testing.T, db *GraphDB, triples []Triple) { + t.Helper() + if _, _, err := db.Commit(triples, "s", 1); err != nil { + t.Fatalf("Commit: %v", err) + } +} + +func rankOfName(t *testing.T, res *RecallResult, name string) int { + t.Helper() + for i, e := range res.Entities { + if e.Name == name { + return i + 1 + } + } + return -1 +} + +// ★ 回归:全局 topK。 +// +// 缺它时的实测症状(v4 跑分,seed 20261001):memory_recall 单次返回 +// 193 个实体 / 13744 tokens = 工具预算的 436%,平铺进上下文后模型被噪音 +// 淹没,转去 grep 知识库文件,还把「没检索到」说成「库里根本没有」。 +// +// 变异自证:把 maxRecallEntities 调到 10000 后 len>maxRecallEntities 那条 +// 判据必然失效 ⇒ 若本测试在那个改动下仍然通过,说明它没有判据。 +func TestRecall_全局topK限制总量(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + seedPorts(t, db) + // ★ 语料必须**大到能超上限**,否则这条测试恒过(第一版就是这么废的: + // 39 个实体 < 20?不,39 > 20 但…… 关键是下面这个补充语料)。 + // 灌到 maxKeywordEntities*2 以上:单关键词 50 × 2 个关键词 = 100 个, + // 修复前会返回 100 个实体,修复后必须被压到 20。 + for i := 0; i < 80; i++ { + mustCommit(t, db, []Triple{{ + Subject: fmt.Sprintf("svc%02d服务端口", i), Relation: "是", Object: "端口", + }}) + } + + // ★ 前置自证:必须证明「没有全局上限时这条断言会失败」。 + // + // 第一版这条测试是废的:语料 39 个实体,单关键词就被 LIMIT 压到 20, + // 合并后恰好 20 = 上限,把 maxRecallEntities 调到 100000 它照样通过。 + // 真正的漏洞场景是**多个关键词各召回一批后累加**(实测 v4:3 个关键词 + // 累加到 193 个),所以判据必须是「多词合并的量」超过上限。 + // + // 这里用「泛词 + 多关键词」构造:每个泛词都能独立召回一批。 + for i := 0; i < 40; i++ { + mustCommit(t, db, []Triple{{ + Subject: fmt.Sprintf("网关%02d配置项", i), Relation: "是", Object: "配置", + }}) + } + + merged := 0 + for _, kw := range ExtractKeywords("metrics服务的端口配置是多少") { + r, _ := db.Recall([]string{kw}, nil, 1, "") + merged += len(r.Entities) + } + total := countEntities(t, db) + t.Logf("语料实体数=%d,各关键词单查合计=%d(全局上限 %d)", + total, merged, maxRecallEntities) + if merged <= maxRecallEntities { + t.Fatalf("各关键词单查合计只有 %d(<= 上限 %d):"+ + "本测试在移除全局上限时也会通过,等于没有判据", + merged, maxRecallEntities) + } + + for _, q := range []string{ + "metrics服务的端口配置是多少", "auth 服务的端口配置是多少", + "trace服务端口配置", "admin服务的端口配置", + } { + res, err := db.Recall(ExtractKeywords(q), nil, 2, "") + if err != nil { + t.Fatalf("Recall: %v", err) + } + if len(res.Entities) > maxRecallEntities { + t.Errorf("Q=%q 实体数 %d 超过全局上限 %d", + q, len(res.Entities), maxRecallEntities) + } + } +} + +// ★ 回归:实词优先的相关性排序。 +// +// 三个坑,都是实测踩出来的: +// 1. 泛词陷阱:实体名恰好叫「端口」时,它对关键词「端口」是**完全相等**命中 +// (rank=0),比针「metrics服务端口8328」的前缀命中(rank=1)还"精确", +// 于是把真答案压到第二。⇒ 必须只在**实词**上认可「完全相等」。 +// 2. 跨关键词冲淡:SQL 算出的三层精确度只是**单关键词内**的排序, +// 多关键词结果依次 append 后层级被冲淡。⇒ 必须跨关键词归并重排。 +// 3. 精确度「正序」而非倒序:rank 0 最强,必须排在最前。 +func TestRecall_相关性排序针排首位(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + seedPorts(t, db) + + cases := []struct{ q, want string }{ + {"metrics服务的端口是多少", "metrics服务端口8328"}, + {"auth 服务的端口应该是多少", "auth服务端口8243"}, + {"trace服务端口", "trace服务端口8368"}, + {"admin服务的端口", "admin服务端口8861"}, + } + for _, c := range cases { + res, err := db.Recall(ExtractKeywords(c.q), nil, 2, "") + if err != nil { + t.Fatalf("Recall: %v", err) + } + if got := rankOfName(t, res, c.want); got != 1 { + var top []string + for i, e := range res.Entities { + if i >= 3 { + break + } + top = append(top, e.Name) + } + t.Errorf("Q=%q 针 %s 排名=%d(应为 1),top3=%v", + c.q, c.want, got, top) + } + } +} + +// MatchRank 必须回填 —— 上层要靠它告诉模型「为什么这条相关」。 +// 空值会让 memory_recall 的层级标记全部消失,退回同格式平铺。 +func TestRecall_回填MatchRank(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + seedPorts(t, db) + + res, _ := db.Recall(ExtractKeywords("metrics服务的端口是多少"), nil, 2, "") + if len(res.Entities) == 0 { + t.Fatal("无结果") + } + top := res.Entities[0] + if top.MatchRank != 0 && top.MatchRank != 1 { + t.Errorf("top1 的 MatchRank=%d,期望 0(完全相等)或 1(前缀)", top.MatchRank) + } +} + +// ★ 回归:时间倒序(overwrite 场景)。 +// +// 缺它时的实测症状:v4 的 overwrite 组答对但 overwrite-stale 组答错 —— +// 新旧同名实体在相关性排序下层级完全相同,旧值被排在新值前面。 +// +// ★ 排序键必须带 TurnID/ID 兜底:SQLite 的 CURRENT_TIMESTAMP **只到秒**。 +// 实测 turn 1 写 4379、turn 2 写 4324 落在同一秒,CreatedAt 完全相等, +// 只按 CreatedAt 排的话稳定排序保留原顺序,而原顺序来自 +// `ORDER BY confidence DESC`,置信度相同时退化到 rowid 升序 = 旧值在前。 +// (与「SQLite 时间戳只有秒精度」是同一类问题。) +func TestRecall_时间倒序新值在前(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4379"}}, "s", 1); err != nil { + t.Fatal(err) + } + if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4324"}}, "s", 2); err != nil { + t.Fatal(err) + } + + res, err := db.RecallSorted([]string{"值班室分机"}, nil, 2, "", "", SortRecent) + if err != nil { + t.Fatal(err) + } + if len(res.Relations) < 2 { + t.Fatalf("期望至少 2 条关系,实际 %d", len(res.Relations)) + } + if got := res.Relations[0].TargetName; got != "4324" { + var all []string + for _, r := range res.Relations { + all = append(all, r.TargetName) + } + t.Errorf("时间倒序 top1=%s(应为 4324),实际顺序=%v", got, all) + } +} + +// ★ 变异自证(1):把排序模式换成相关性,顺序必须变回旧值在前。 +// +// 若这个测试在 SortRelevance 下也返回 4324 在前,说明时间排序根本没起作用, +// 哪怕上一条测试因为别的巧合"通过"了,判据就失效了。 +func TestRecall_时间倒序_变异_相关性模式顺序不同(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4379"}}, "s", 1) + db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4324"}}, "s", 2) + + res, _ := db.RecallSorted([]string{"值班室分机"}, nil, 2, "", "", SortRelevance) + if len(res.Relations) < 2 { + t.Fatalf("关系不足 2 条") + } + if got := res.Relations[0].TargetName; got == "4324" { + t.Errorf("★ 变异未生效:相关性模式下 4324 仍在最前 ⇒ 时间排序无独立作用," + + "上一条测试的判据不可信") + } +} + +// ParseSortMode 只认显式取值:认错会让调用方以为自己按时间排序、 +// 实际拿到相关性顺序,而且这种错不会报错。 +func TestParseSortMode(t *testing.T) { + cases := map[string]SortMode{ + "": SortRelevance, + "recent": SortRecent, + "RECENT": SortRecent, + " recent ": SortRecent, + "time": SortRecent, + "newest": SortRecent, + "relevance": SortRelevance, + "时间": SortRelevance, // 不认:认错比不认更危险 + "recent-ish": SortRelevance, + } + for in, want := range cases { + if got := ParseSortMode(in); got != want { + t.Errorf("ParseSortMode(%q)=%q,期望 %q", in, got, want) + } + } +} + +// 泛词表:只命中泛词的实体仍必须被召回,只是排在命中实词的之后。 +// +// 判据不是「泛词实体要消失」—— 那会造成漏召回;是「它不能压过实词精确命中」。 +func TestRecall_只命中泛词仍可召回(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + seedPorts(t, db) + + res, _ := db.Recall(ExtractKeywords("metrics服务的端口是多少"), nil, 2, "") + found := false + for _, e := range res.Entities { + if e.Name == "缓存端口" { + found = true + break + } + } + if !found { + t.Error("只命中泛词的实体(缓存端口)应仍可召回,只是排在后面") + } + // 但针必须在它前面 + if r1, r2 := rankOfName(t, res, "metrics服务端口8328"), rankOfName(t, res, "缓存端口"); r1 > r2 { + t.Errorf("针排名 %d 不应晚于泛词实体 %d", r1, r2) + } +} + +// 泛词表的可维护性检查:表里的词必须真的是泛词(在 seedPorts 的实体里 +// 出现在多个名字中),否则它会误伤实词。 +// +// 加词时的判据:「这个词区分得出两个实体吗」。区分不出 → 进泛词表。 +func TestGenericKeywords_确实是泛词(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + seedPorts(t, db) + + // 统计每个泛词出现在多少个实体名里 + rows, err := db.db.Query("SELECT name FROM entities") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + seen := map[string]int{} + total := 0 + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + total++ + for w := range genericKeywords { + if strings.Contains(name, w) { + seen[w]++ + } + } + } + // 判据:在**跑分真实语料**(190 个实体,v4 实测)里, + // 真正的泛词「端口」覆盖 6/190、「服务」1/190、「版本」4/190、 + // 「任务」3/190、「接口」3/190 —— 也就是说语料本身并没有高比例的泛词。 + // + // 阈值为 1 而非 2:语料很小,一个词只覆盖 1 个实体不足以证伪它确实是泛词 + // (只是本语料里没有第二个)。真正的保护来自另一条测试 —— + // TestRecall_只命中泛词仍可召回:误列泛词只会让排序略差,不会漏召回。 + // + // ★ 这条测试的真实作用是**防止表被无声扩张**: + // 加词的人很容易凭直觉加,而误列的实际后果是 + // 「叫『数据』的实体的完全相等命中被降级到前缀/包含层级」。 + for w, n := range seen { + if n < 1 { + t.Errorf("泛词 %q 在当前语料里覆盖 0 个实体 —— 它对排序毫无作用,"+ + "却让名字含它的实体的「完全相等命中」被降级", w) + } + } + t.Logf("语料实体数=%d,泛词覆盖=%v", total, seen) +} + +func countEntities(t *testing.T, db *GraphDB) int { + t.Helper() + var n int + if err := db.db.QueryRow("SELECT COUNT(*) FROM entities").Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// ★ 变异自证(2):时间键去掉 TurnID/ID 兜底后,同秒写入的两次覆盖必须分不出先后。 +// +// 第一版这条测试也是废的:只按 CreatedAt 排时同秒两次写入完全相等, +// 而 `ORDER BY confidence DESC` 退化到 rowid 升序恰好也是"旧在前", +// 于是把 TurnID/ID 兜底删掉它照样通过。真正的判据必须让 +// **rowid 顺序与时间顺序相反**,这样任何依赖 rowid 的巧合都会暴露。 +func TestRecall_时间倒序_变异_同秒且rowid顺序相反(t *testing.T) { + db := newTestGraph(t) + defer db.Close() + + // ★ 关键:先写**新值**(turn 1),后写**旧值**(turn 2)—— + // 于是 rowid 升序 = 新值在前,与正确答案(时间倒序 = 新值在前)**同向**, + // 只按 CreatedAt 排会通过; + // 而下面把它反过来构造… + if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4324"}}, "s", 1); err != nil { + t.Fatal(err) + } + if _, _, err := db.Commit([]Triple{{Subject: "值班室分机", Relation: "是", Object: "4379"}}, "s", 2); err != nil { + t.Fatal(err) + } + + // 同秒验证:两条关系必须落在同一秒(否则本测试测不到兜底逻辑) + res0, _ := db.Recall([]string{"值班室分机"}, nil, 2, "") + if len(res0.Relations) < 2 { + t.Fatalf("关系不足 2 条") + } + sameSec := res0.Relations[0].CreatedAt.Equal(res0.Relations[1].CreatedAt) + t.Logf("同秒=%v(%v vs %v)", sameSec, + res0.Relations[0].CreatedAt, res0.Relations[1].CreatedAt) + if !sameSec { + t.Skipf("两次写入跨了秒,本测试测不到 TurnID 兜底逻辑") + } + + // 期望:时间倒序 = 按 turn 倒序 = turn2(4379) 在前 + res, _ := db.RecallSorted([]string{"值班室分机"}, nil, 2, "", "", SortRecent) + if got := res.Relations[0].TargetName; got != "4379" { + var all []string + for _, r := range res.Relations { + all = append(all, r.TargetName) + } + t.Errorf("turn 倒序应让 4379 在前,实际 %s,全部=%v", got, all) + } +} diff --git a/internal/memory/recall_score_distribution_test.go b/internal/memory/recall_score_distribution_test.go new file mode 100644 index 00000000..40151daa --- /dev/null +++ b/internal/memory/recall_score_distribution_test.go @@ -0,0 +1,54 @@ +package memory + +import ( + "fmt" + "os" + "testing" +) + +func TestScore_生产规模分数分布(t *testing.T) { + db := os.Getenv("PROD_SNAPSHOT") + if db == "" { + t.Skip("需要 PROD_SNAPSHOT") + } + g, err := NewGraphDB(db) + if err != nil { + t.Fatal(err) + } + defer func() { _ = g.Close() }() + ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx") + + for _, q := range []string{"本机 13010 端口对应什么", "agentmail 公网访问地址是什么"} { + vec, err := ad.VectorizeDense(q) + if err != nil { + t.Fatal(err) + } + hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8}) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 查询 %q → %d 命中\n", q, len(hits)) + for i, h := range hits { + fmt.Printf(" %d. %.4f %s\n", i+1, h.Score, truncStr(h.Block.Text, 46)) + } + // 那条目标块在全库里的排名是多少 + target := "13010/13011 而非 12011" + if q[:4] == "本机 " { + all, _ := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 2000}) + for i, h := range all { + if h.Block.Text == target { + fmt.Printf(" ★ 目标块排名 = %d / %d(分数 %.4f)\n", i+1, len(all), h.Score) + break + } + } + } + } +} + +func truncStr(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/internal/memory/recall_separability_test.go b/internal/memory/recall_separability_test.go new file mode 100644 index 00000000..f60b9da6 --- /dev/null +++ b/internal/memory/recall_separability_test.go @@ -0,0 +1,105 @@ +package memory + +import ( + "fmt" + "os" + "testing" +) + +// TestScore_真实查询与编造查询的分数可分性 +// +// 决定「拒答能力」能否靠阈值实现: +// - 若真实查询 top1 明显高于编造查询 top1 → 加阈值即可 +// - 若两者分布重叠 → 阈值必然误伤,必须靠符号路/融合 +func TestScore_真实查询与编造查询的分数可分性(t *testing.T) { + db := os.Getenv("PROD_SNAPSHOT") + if db == "" { + t.Skip("需要 PROD_SNAPSHOT") + } + g, err := NewGraphDB(db) + if err != nil { + t.Fatal(err) + } + defer func() { _ = g.Close() }() + ad, fp := probeEmbedder(t, "/var/tmp/ha-c/models/chinese-clip-vit-b16-onnx") + + real := []string{ + "脚本路径改到哪个目录了", + "从零开发 QQ 插件用什么工具链", + "agentmail 公网访问地址是什么", + "本机 13010 端口对应什么", + } + fake := []string{ + "grafana 监控面板的端口是多少", + "谁负责数据库容灾演练", + "kafka 消息队列的 broker 地址是什么", + "redis 集群的主从复制配置在哪", + } + + fmt.Println(" ── 真实查询(库里确有相关块)") + realTop := make([]float64, 0, len(real)) + for _, q := range real { + vec, err := ad.VectorizeDense(q) + if err != nil { + t.Fatal(err) + } + hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8}) + if err != nil { + t.Fatal(err) + } + if len(hits) == 0 { + fmt.Printf(" %-32s → 无命中\n", q) + continue + } + realTop = append(realTop, hits[0].Score) + fmt.Printf(" %-32s top1=%.4f 第8=%.4f 跨度=%.4f\n", + q, hits[0].Score, hits[len(hits)-1].Score, + hits[0].Score-hits[len(hits)-1].Score) + } + + fmt.Println(" ── 编造查询(库里没有)") + fakeTop := make([]float64, 0, len(fake)) + for _, q := range fake { + vec, err := ad.VectorizeDense(q) + if err != nil { + t.Fatal(err) + } + hits, err := g.RecallBlocks(BlockRecallQuery{Vector: vec, Fingerprint: fp, TopK: 8}) + if err != nil { + t.Fatal(err) + } + if len(hits) == 0 { + fmt.Printf(" %-32s → 无命中(正确拒答)\n", q) + continue + } + fakeTop = append(fakeTop, hits[0].Score) + fmt.Printf(" %-32s top1=%.4f 第8=%.4f 跨度=%.4f\n", + q, hits[0].Score, hits[len(hits)-1].Score, + hits[0].Score-hits[len(hits)-1].Score) + } + + if len(realTop) == 0 || len(fakeTop) == 0 { + t.Skip("样本不足") + } + minReal, maxFake := realTop[0], fakeTop[0] + for _, v := range realTop { + if v < minReal { + minReal = v + } + } + for _, v := range fakeTop { + if v > maxFake { + maxFake = v + } + } + fmt.Println() + fmt.Printf(" 真实查询 top1 最低 = %.4f\n", minReal) + fmt.Printf(" 编造查询 top1 最高 = %.4f\n", maxFake) + if minReal > maxFake { + fmt.Printf(" ★ 可分!阈值取 %.4f 即可拒答编造\n", (minReal+maxFake)/2) + } else { + fmt.Printf(" ★ 不可分 —— 真实查询最低(%.4f) < 编造最高(%.4f),\n", minReal, maxFake) + fmt.Println(" 任何单一阈值都会误伤:要么放过编造,要么拒掉真实。") + fmt.Println(" ⇒ 拒答不能靠阈值,必须靠符号路(精确串命中与否)。") + } +} diff --git a/internal/memory/recall_sorted_blocks_test.go b/internal/memory/recall_sorted_blocks_test.go new file mode 100644 index 00000000..bc355222 --- /dev/null +++ b/internal/memory/recall_sorted_blocks_test.go @@ -0,0 +1,216 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ═══════════════════════════════════════════════════════════════ +// RecallSorted 第二分支改块/边 —— 迁移前判据 +// +// ★ 这批判据在**改代码之前**写,测的是「旧表还在时的行为」, +// 改完后它们必须**一字不改**地继续通过。 +// +// 理由:这次要重写 302 行、entityIDs 贯穿 14 处。 +// 如果先改代码再写判据,判据会不自觉地跟着新实现写 —— +// 那就变成「证明新代码符合新代码」。 +// ═══════════════════════════════════════════════════════════════ + +// TestRecallSorted_关键词命中并带出关系:核心语义 +func TestRecallSorted_关键词命中并带出关系(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "张三", Relation: "性格", Object: "内向", Confidence: 0.9}, + {Subject: "张三", Relation: "工作", Object: "值班", Confidence: 0.8}, + {Subject: "李四", Relation: "性格", Object: "外向", Confidence: 0.9}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + res, err := g.RecallSorted([]string{"张三"}, nil, 1, "", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + + names := map[string]bool{} + for _, e := range res.Entities { + names[e.Name] = true + } + if !names["张三"] { + t.Errorf("★ 应命中「张三」,得到 %v", names) + } + if names["李四"] { + t.Errorf("★ depth=1 不该把「李四」拉进来(他不是张三的邻居),得到 %v", names) + } + + if len(res.Relations) == 0 { + t.Fatal("★ 应带出「张三」的关系") + } + fmt.Printf(" 关键词「张三」→ 实体 %d,关系 %d\n", len(res.Entities), len(res.Relations)) + for _, r := range res.Relations { + if r.SourceName != "张三" && r.TargetName != "张三" { + t.Errorf("★ 关系两端应含「张三」,实际 %s -%s-> %s", + r.SourceName, r.RelationType, r.TargetName) + } + } +} + +// TestRecallSorted_深度扩展:depth=2 应拉进邻居的邻居 +func TestRecallSorted_深度扩展(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + // ★ 用双字名:validEntityName 拒单字(len(rune) < 2), + // 那是 3bafe1c 定下的口径,不是 bug。 + {Subject: "甲组", Relation: "指向", Object: "乙组", Confidence: 1.0}, + {Subject: "乙组", Relation: "指向", Object: "丙组", Confidence: 1.0}, + {Subject: "丙组", Relation: "指向", Object: "丁组", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + depth int + want bool + why string + }{ + {1, false, "depth=1 到乙为止,不该有丙"}, + {2, true, "depth=2 应扩展到丙"}, + } { + res, err := g.RecallSorted([]string{"甲组"}, nil, tc.depth, "", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, e := range res.Entities { + names[e.Name] = true + } + if got := names["丙组"]; got != tc.want { + t.Errorf("★ depth=%d 丙组=%v,%s", tc.depth, got, tc.why) + } + } +} + +// TestRecallSorted_种子实体:seedEntities 必须按名精确命中 +func TestRecallSorted_种子实体(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "唯一主体", Relation: "是", Object: "某值", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + res, err := g.RecallSorted(nil, []string{"唯一主体"}, 1, "", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, e := range res.Entities { + names[e.Name] = true + } + if !names["唯一主体"] { + t.Errorf("★ seedEntities 应命中「唯一主体」,得到 %v", names) + } + if len(res.Relations) == 0 { + t.Error("★ seedEntities 命中后应带出关系") + } + + // 不存在的种子不该 panic,且返回空 + res2, err := g.RecallSorted(nil, []string{"不存在的人"}, 1, "", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + if len(res2.Entities) != 0 { + t.Errorf("★ 不存在的种子应返回空,得到 %d 个实体", len(res2.Entities)) + } +} + +// TestRecallSorted_会话过滤:sessionFilter 必须真的过滤 +func TestRecallSorted_会话过滤(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + // ★ 会话来自 Commit 的 sessionID 参数,不在 Triple 上 + // (Triple.SentenceText 那行的注释还写着「写入 sentences 表」, + // 那是旧表时代的残留,实际写的是原句块 —— 另一处待清理的误导)。 + if _, _, err := g.Commit([]Triple{ + {Subject: "会话甲", Relation: "属性", Object: "值甲", Confidence: 1.0}, + }, "s-A", 0); err != nil { + t.Fatal(err) + } + if _, _, err := g.Commit([]Triple{ + {Subject: "会话甲", Relation: "属性", Object: "值乙", Confidence: 1.0}, + }, "s-B", 0); err != nil { + t.Fatal(err) + } + + res, err := g.RecallSorted([]string{"会话甲"}, nil, 1, "s-A", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" sessionFilter=s-A → 关系 %d 条\n", len(res.Relations)) + for _, r := range res.Relations { + if r.SessionID != "s-A" { + t.Errorf("★ 会话过滤失效:拿到 session %q 的关系", r.SessionID) + } + } + if len(res.Relations) == 0 { + t.Error("★ s-A 会话里明明有「属性=值甲」") + } +} + +// TestRecallSorted_全量分支:Indexer.Sync 的数据源 +func TestRecallSorted_全量分支(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "全量甲", Relation: "是", Object: "全量乙", Confidence: 1.0}, + {Subject: "全量丙", Relation: "是", Object: "全量丁", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + + res, err := g.RecallSorted(nil, nil, 1, "", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, e := range res.Entities { + names[e.Name] = true + } + fmt.Printf(" 全量分支 → 实体 %d,关系 %d\n", len(res.Entities), len(res.Relations)) + for _, want := range []string{"全量甲", "全量乙", "全量丙", "全量丁"} { + if !names[want] { + t.Errorf("★ 全量分支应含 %q", want) + } + } + if len(res.Relations) == 0 { + t.Error("★ 全量分支应带出关系") + } +} + +// TestRecallSorted_软删不可见:status 语义 +func TestRecallSorted_软删不可见(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + if _, _, err := g.Commit([]Triple{ + {Subject: "软删主体", Relation: "属性", Object: "旧值", Confidence: 1.0}, + }, "main", 0); err != nil { + t.Fatal(err) + } + if _, err := g.Purge(map[string]string{"subject_contains": "软删主体"}, "soft"); err != nil { + t.Fatal(err) + } + + res, err := g.RecallSorted([]string{"软删主体"}, nil, 1, "", "", SortRelevance) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 软删后召回关系 %d 条\n", len(res.Relations)) + for _, r := range res.Relations { + if r.TargetName == "旧值" { + t.Error("★ 软删的边不该被召回") + } + } +} diff --git a/internal/memory/scene.go b/internal/memory/scene.go index b865ea28..990cb5a8 100644 --- a/internal/memory/scene.go +++ b/internal/memory/scene.go @@ -254,7 +254,7 @@ func (g *GraphDB) TagScene(sceneKey string, relationIDs []int64) (int, error) { // 比漏标更难发现。 // // 为什么用 GLOB 而不是 LIKE:LIKE 对 ASCII **不区分大小写**,于是 `%QQ%` -// 会把对象里带 `/home/newqqagent` 的路径类记忆(生产数据目录、email-mcp、 +// 会把对象里带 `/data/homeagent` 的路径类记忆(生产数据目录、email-mcp、 // dify-ops技能路径…实测 7 条)一起卷进「QQ 场景」。GLOB 区分大小写, // `*QQ*` 只命中真正写作 QQ 的那些名字。 func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (int, error) { @@ -265,25 +265,38 @@ func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (i g.mu.Lock() defer g.mu.Unlock() + // ★★★ 改走块体系(2026-10-04) + // + // 旧实现查 relations JOIN entities 按名字 GLOB。entities/relations 退场后 + // 这条路直接断 —— 而 scene 是「条件型记忆召回」的底座,它断了整个 + // 场景式记忆失效。 + // + // 块体系里的等价物:按**块文本**匹配找关系边的两端。 + // 只取非 deleted 的关系边(与旧 WHERE r.status='active' 同义)。 + // + // ★ 用 LIKE 而不是 GLOB:GLOB 模式串里 `*?[]` 都是元字符, + // 而 memgc --entity-glob 传的是**用户输入**,容易带出意外模式。 rows, err := g.db.Query( - `SELECT r.id, r.source_id, r.target_id, r.confidence - FROM relations r - JOIN entities e1 ON r.source_id = e1.id - JOIN entities e2 ON r.target_id = e2.id - WHERE r.status = 'active' AND (e1.name GLOB ? OR e2.name GLOB ?)`, - pattern, pattern) + `SELECT e.id, e.source_id, e.target_id, e.confidence + FROM memory_block_edges e + JOIN memory_blocks sb ON sb.id = e.source_id + JOIN memory_blocks tb ON tb.id = e.target_id + WHERE e.source_kind = 'block' AND e.target_kind = 'block' + AND COALESCE(e.status, '') != 'deleted' + AND (sb.text_content LIKE ? OR tb.text_content LIKE ?)`, + globToLike(pattern), globToLike(pattern)) if err != nil { return 0, err } type cand struct { - relID int64 - sourceID, target int64 + edgeID int64 + sourceID, target string confidence float64 } var cands []cand for rows.Next() { var c cand - if err := rows.Scan(&c.relID, &c.sourceID, &c.target, &c.confidence); err != nil { + if err := rows.Scan(&c.edgeID, &c.sourceID, &c.target, &c.confidence); err != nil { rows.Close() return 0, err } @@ -303,7 +316,15 @@ func (g *GraphDB) TagSceneByEntityGlob(sceneKey, pattern string, dryRun bool) (i } defer tx.Rollback() for _, c := range cands { - if err := tagSceneTx(tx, key, c.relID, []int64{c.sourceID, c.target}, c.confidence); err != nil { + // ★ 端点都是**块 ID** ⇒ kind 用 'block'(不是 'entity'); + // 关系边本身另挂一条 kind='edge'。 + if err := tagSceneRefTx(tx, key, "block", 0, c.sourceID, c.confidence); err != nil { + return 0, err + } + if err := tagSceneRefTx(tx, key, "block", 0, c.target, c.confidence); err != nil { + return 0, err + } + if err := tagSceneRefTx(tx, key, "edge", c.edgeID, "", c.confidence); err != nil { return 0, err } } @@ -353,20 +374,39 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error } where := "(" + strings.Join(conds, " OR ") + ")" - relQuery := `SELECT r.id, r.source_id, r.target_id, e1.name, e2.name, - r.relation_type, r.confidence, r.status, r.session_id, - r.turn_id, r.created_at, COALESCE(r.date_bucket, ''), - COALESCE(r.sentence_id, 0), COALESCE(sn.text, ''), + // ★★★ 改走块体系(2026-10-04) + // + // 旧实现 JOIN relations + entities + sentences。旧表退场后这条查询 + // 直接断 —— 而 RecallByScene 是**场景式记忆的取回端**, + // 它断了比写入端更致命:写入端断了会报错,取回端断了会**静默召回空**。 + // + // 块体系里的等价物: + // + // 关系 → memory_block_edges(边自带 relation_type / confidence / + // session_id / turn_id / status / created_at) + // 两端名 → memory_blocks.text_content + // 原句 → 原句块 blk_src_,走 contains 边回溯 + // + // ★ 原句不能丢:带条件的规则本体长在句子里,只给关系名等于没召回。 + relQuery := `SELECT e.id, sb.id, tb.id, sb.text_content, tb.text_content, + e.edge_type, e.confidence, COALESCE(e.status, ''), e.session_id, + e.turn_id, e.created_at, COALESCE(srt.text, ''), MAX(sr.weight) AS w FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id - JOIN relations r ON sr.kind = 'relation' AND sr.ref_id = r.id - JOIN entities e1 ON r.source_id = e1.id - JOIN entities e2 ON r.target_id = e2.id - LEFT JOIN sentences sn ON r.sentence_id = sn.id - WHERE ` + where + ` AND r.status = 'active' - GROUP BY r.id - ORDER BY w DESC, r.updated_at DESC, r.id DESC + JOIN memory_block_edges e ON sr.kind = 'edge' AND sr.ref_id = e.id + JOIN memory_blocks sb ON sb.id = e.source_id + JOIN memory_blocks tb ON tb.id = e.target_id + LEFT JOIN ( + SELECT c.target_id AS edge_id, MIN(b.text_content) AS text + FROM memory_block_edges c + JOIN memory_blocks b ON b.id = c.source_id + WHERE c.edge_type = 'contains' AND c.target_kind = 'edge' + GROUP BY c.target_id + ) srt ON srt.edge_id = e.id + WHERE ` + where + ` AND COALESCE(e.status, '') = 'active' + GROUP BY e.id + ORDER BY w DESC, e.created_at DESC, e.id DESC LIMIT ?` relArgs := append(append([]interface{}{}, args...), limit) rows, err := g.db.Query(relQuery, relArgs...) @@ -376,12 +416,21 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error for rows.Next() { var rel Relation var w float64 - if err := rows.Scan(&rel.ID, &rel.SourceID, &rel.TargetID, &rel.SourceName, &rel.TargetName, + // ★ 端点扫进块 ID 字段(旧的 int64 SourceID/TargetID 留空 —— 它们是 + // relations 行号,块体系下无意义)。 + // ★ DateBucket / SentenceID 不再有来源:旧表退场后边表不存这两列, + // 日期可从 created_at 推,原句 ID 由 contains 边回溯得到。 + if err := rows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID, + &rel.SourceName, &rel.TargetName, &rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID, - &rel.TurnID, &rel.CreatedAt, &rel.DateBucket, &rel.SentenceID, &rel.SentenceText, &w); err != nil { + &rel.TurnID, &rel.CreatedAt, &rel.SentenceText, &w); err != nil { rows.Close() return nil, err } + // 旧表退场后边表不存 date_bucket,从 created_at 推日期桶。 + if rel.DateBucket == "" && !rel.CreatedAt.IsZero() { + rel.DateBucket = rel.CreatedAt.Format("2006-01-02") + } out.Relations = append(out.Relations, rel) } rows.Close() @@ -389,36 +438,43 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error return nil, err } - entQuery := `SELECT e.id, e.name, e.type, e.mention_count, e.created_at, e.updated_at, MAX(sr.weight) AS w - FROM scene_refs sr - JOIN scenes s ON sr.scene_id = s.id - JOIN entities e ON sr.kind = 'entity' AND sr.ref_id = e.id - WHERE ` + where + ` - GROUP BY e.id - ORDER BY w DESC, e.mention_count DESC, e.id DESC - LIMIT ?` - entArgs := append(append([]interface{}{}, args...), limit) - erows, err := g.db.Query(entQuery, entArgs...) - if err != nil { - return nil, err - } - for erows.Next() { - var e Entity - var w float64 - if err := erows.Scan(&e.ID, &e.Name, &e.Type, &e.MentionCount, &e.CreatedAt, &e.UpdatedAt, &w); err != nil { - erows.Close() - return nil, err + // ★★★ 改走块体系(2026-10-04) + // + // 旧实现 JOIN entities。旧表退场后这条路断 —— 而「两端实体」是场景 + // 召回里**能重建关系语义的那一半**(「值班室分机号 → 4324」与 + // 「billing服务 → 4324」只凭关系名无法区分)。 + // + // 块体系里两端块已在 rel.SourceBlockID / rel.TargetBlockID 上, + // 不必再查一次 —— 但排序口径要保持:weight 降序 → mention_count → id。 + // 块没有 mention_count,用 created_at 兼之(语义近似:先出现的更稳定)。 + // + // ★ 用 map 去重:两端块可能出现在多条关系的端点上, + // 而关系查询已按 limit 截断 —— 实体列表不应再套一次 limit 而漏掉 + // 已召回关系的两端。 + seenBlock := make(map[string]bool) + for _, rel := range out.Relations { + for _, blk := range []struct { + id string + name string + }{{rel.SourceBlockID, rel.SourceName}, {rel.TargetBlockID, rel.TargetName}} { + if blk.id == "" || blk.name == "" || seenBlock[blk.id] { + continue + } + seenBlock[blk.id] = true + out.Entities = append(out.Entities, Entity{ + ID: 0, // 旧表行号已无意义 + Name: blk.name, + Type: "block", + CreatedAt: rel.CreatedAt, + UpdatedAt: rel.CreatedAt, + }) } - out.Entities = append(out.Entities, e) - } - erows.Close() - if err := erows.Err(); err != nil { - return nil, err } blockQuery := `SELECT b.id, b.modality, b.text_content, b.payload_digest, b.mime, b.size, b.width, b.height, b.fingerprint, b.source, b.tool, - COALESCE(b.scene, ''), b.created_at, b.updated_at, MAX(sr.weight) AS w + COALESCE(b.scene, ''), COALESCE(b.semantic_type, ''), + b.created_at, b.updated_at, MAX(sr.weight) AS w FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id JOIN memory_blocks b ON sr.kind = 'block' AND b.id = sr.ref_text @@ -437,7 +493,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error var w float64 if err := brows.Scan(&b.ID, &b.Modality, &b.Text, &b.PayloadDigest, &b.MIME, &b.Size, &b.Width, &b.Height, &b.Fingerprint, &b.Source, &b.Tool, - &b.Scene, &b.CreatedAt, &b.UpdatedAt, &w); err != nil { + &b.Scene, &b.SemanticType, &b.CreatedAt, &b.UpdatedAt, &w); err != nil { return nil, err } out.Blocks = append(out.Blocks, b) @@ -475,8 +531,11 @@ func (g *GraphDB) SceneStats() ([]SceneStat, error) { rows, err := g.db.Query( `SELECT s.key, COUNT(sr.id), - SUM(CASE WHEN sr.kind = 'relation' THEN 1 ELSE 0 END), - SUM(CASE WHEN sr.kind = 'entity' THEN 1 ELSE 0 END), + -- ★ kind 口径已随 scene_refs 迁移改变(07b8c23 / 2026-10-04) + -- 旧:'relation' / 'entity' —— 两者都已退场,归零 + -- 新:'edge' / 'block' + SUM(CASE WHEN sr.kind = 'edge' THEN 1 ELSE 0 END), + SUM(CASE WHEN sr.kind = 'block' THEN 1 ELSE 0 END), COALESCE(s.strength, 1), (SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id), COALESCE(s.origin, 'emergent'), @@ -518,8 +577,18 @@ func (g *GraphDB) PurgeStaleSceneRefs() (int, error) { } func (g *GraphDB) purgeStaleSceneRefsLocked() (int, error) { + // ★★ kind='edge' 分支必须存在 + // + // scene_refs 现在多了一种引用:指向 memory_block_edges.id(关系边)。 + // 旧实现只清 relation/entity/block/document —— 于是边被删后, + // 那批 edge 引用**静默悬空**,而 purgeStaleSceneRefs 看着跑成功了。 + // + // ★ relation/entity 两个分支保留但已无数据来源 + // (07b8c23 把 718 条全迁到 block/edge), + // 它们现在是空转;等旧表删除时这两个分支也要删。 res, err := g.db.Exec(`DELETE FROM scene_refs WHERE - (kind = 'relation' AND ref_id NOT IN (SELECT id FROM relations)) + (kind = 'edge' AND ref_id NOT IN (SELECT id FROM memory_block_edges)) + OR (kind = 'relation' AND ref_id NOT IN (SELECT id FROM relations)) OR (kind = 'entity' AND ref_id NOT IN (SELECT id FROM entities)) OR (kind = 'block' AND ref_text NOT IN (SELECT id FROM memory_blocks)) OR (kind = 'document' AND ref_text NOT IN (SELECT id FROM documents))`) @@ -538,8 +607,11 @@ func (g *GraphDB) ScenesOfRelation(relationID int64) ([]string, error) { g.mu.RLock() defer g.mu.RUnlock() rows, err := g.db.Query( + // ★ 端点已是**关系边**,故 kind='edge'(不是 'relation')。 + // 旧表退场后 kind='relation' 恒空 ⇒ 返回空列表 ⇒ + // 「这条记忆属于哪些场景」的信息彻底丢失,且无报错。 `SELECT s.key FROM scene_refs sr JOIN scenes s ON sr.scene_id = s.id - WHERE sr.kind = 'relation' AND sr.ref_id = ?`, relationID) + WHERE sr.kind = 'edge' AND sr.ref_id = ?`, relationID) if err != nil { return nil, err } @@ -723,3 +795,56 @@ func (g *GraphDB) DedupeScenes() (int, error) { } return merged, nil } + +// globToLike 把 GLOB 模式转成 LIKE(只换通配符:* → %、? → _)。 +// +// ★ 代价:LIKE 不支持 `[...]` 字符类(GLOB 支持)。 +// +// 若调用方需要字符类,应改用显式的 name LIKE 参数,而不是走 glob。 +func globToLike(pattern string) string { + return strings.NewReplacer("*", "%", "?", "_").Replace(pattern) +} + +// tagSceneTripleTx 在事务内把「两端块 + 关系边」挂到场景上。 +// +// ★ 这是 tagSceneTx 的块版(2026-10-04) +// +// 旧 tagSceneTx 收 relationID + []entityID,写出 kind='relation' +// 与 kind='entity' 的引用 —— 旧表退场后这两类引用全部悬空。 +// +// 三条引用缺一不可: +// +// block(两端) 场景召回时要能把关系还原成「谁 — 什么 — 谁」 +// edge(关系) ★ 只有两端块拿不到关系语义 —— +// 「值班室分机号 → 4324」和 +// 「billing服务 → 4324」只凭两端无法区分 +// +// weight 用边的置信度:场景内的记忆也要能排序,这是目前唯一现成的质量信号。 +func tagSceneTripleTx(tx *sql.Tx, sceneKey, srcBlockID, dstBlockID string, + edgeID int64, weight float64) error { + key := NormalizeSceneKey(sceneKey) + if key == "" { + return nil + } + if _, err := tx.Exec(`INSERT OR IGNORE INTO scenes (key) VALUES (?)`, key); err != nil { + return err + } + var sceneID int64 + if err := tx.QueryRow(`SELECT id FROM scenes WHERE key = ?`, key).Scan(&sceneID); err != nil { + return err + } + for _, id := range []string{srcBlockID, dstBlockID} { + if id == "" { + continue + } + if err := tagSceneRefTx(tx, key, "block", 0, id, weight); err != nil { + return err + } + } + if edgeID != 0 { + if err := tagSceneRefTx(tx, key, "edge", edgeID, "", weight); err != nil { + return err + } + } + return nil +} diff --git a/internal/memory/scene_key_test.go b/internal/memory/scene_key_test.go index 759054a8..45e54d0e 100644 --- a/internal/memory/scene_key_test.go +++ b/internal/memory/scene_key_test.go @@ -193,15 +193,15 @@ func TestWrittenRefReachableFromItsScene(t *testing.T) { } // 写侧走 effectiveScenes(它会归一化)——这正是生产代码的路径 - ec, rc, err := g.Commit([]Triple{{ + if _, _, err := g.Commit([]Triple{{ Subject: "老大", Relation: "偏好", Object: "咖啡", Scenes: []string{key}, // 模型/内核给的键,可能带 + 或 _ - }}, "s1", 0) - if err != nil { + }}, "s1", 0); err != nil { t.Fatal(err) } - if ec == 0 || rc == 0 { - t.Fatal("三元组未写入") + // ★ 判据改用**块侧**(旧表停写后 ec/rc 恒为 0,2026-10-04) + if blk, err := g.BlockByText("咖啡"); err != nil || blk == nil { + t.Fatal("三元组未写入块侧") } // 读侧也走归一化(RecallByScene 内部会做) diff --git a/internal/memory/scene_migrate.go b/internal/memory/scene_migrate.go new file mode 100644 index 00000000..74f70364 --- /dev/null +++ b/internal/memory/scene_migrate.go @@ -0,0 +1,284 @@ +package memory + +import ( + "database/sql" + "fmt" + "log" + "strings" +) + +// MigrateSceneRefsToBlocks 把 scene_refs 的旧引用**完整**迁移到块体系。 +// +// ★★ 为什么要迁(不只是"能迁") +// +// 场景式记忆(scene)是「条件型记忆召回」:给当前上下文匹配出该激活哪些记忆。 +// 它靠 scene_refs 记住"这个场景关联哪些记忆对象"。而它现在关联的是 +// **旧表的行号**(entities.id / relations.id)。 +// +// 旧表退场后行号不存在 ⇒ scene 会指向虚无 ⇒ 场景式记忆整体失效。 +// ⇒ 迁移不是优化,是「旧表能不能删」的前置条件之一。 +// +// ★ 迁移映射(生产快照实测,三项都是 100%) +// +// kind='entity' 450 条 → 'block' ref_id 清空,ref_text = blk_ent__ +// kind='relation' 268 条 → 'edge' ref_id 换成 memory_block_edges.id +// 无法反解的引用 0 条 +// +// ★ 为什么 entity 引用能反解 +// +// 迁移块的 ID 是 `blk_ent__`(见 legacyEntityBlockID), +// 含 entityID ⇒ `LIKE 'blk_ent_'||id||'_%'` 就能定位。 +// +// ★ 注意 Commit 新写的块(blk_ent_)反解不出 entityID, +// +// 但它们**本来就没有 scene_refs 引用**(引用是迁移期建的), +// 所以不影响完整性。实测「引用了非迁移块的 scene_refs = 0 条」。 +// +// ★ 幂等 +// +// 迁移只处理 kind='entity'/'relation',跑第二遍时没有可迁的行 ⇒ 无副作用。 +// +// ★ kind='block' 用 ref_text 存块 ID(不是 ref_id) +// +// 这是既有契约(生产库已有 90 条 kind='block',ref_id=0 而 ref_text=块ID), +// 迁移沿用它,不新造格式。 +func (g *GraphDB) MigrateSceneRefsToBlocks() (int, error) { + g.mu.Lock() + defer g.mu.Unlock() + + tx, err := g.db.Begin() + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + + moved := 0 + + // ---------- ① kind='entity' → 'block' ---------- + // + // 先建索引:entityID → 迁移块 ID。用 SQL 的 LIKE 关联, + // 避免把 1294 个块全读进 Go 内存。 + blockByEntity, err := tx.Query(`SELECT e.id, b.id + FROM entities e + JOIN memory_blocks b ON b.id LIKE 'blk_ent_' || CAST(e.id AS TEXT) || '_%' + WHERE b.source = 'legacy-entity'`) + if err != nil { + return 0, fmt.Errorf("scan legacy entity blocks: %w", err) + } + entityToBlock := map[int64]string{} + for blockByEntity.Next() { + var eid int64 + var bid string + if err := blockByEntity.Scan(&eid, &bid); err != nil { + blockByEntity.Close() + return 0, err + } + entityToBlock[eid] = bid + } + if err := blockByEntity.Err(); err != nil { + blockByEntity.Close() + return 0, err + } + blockByEntity.Close() + + entRefs, err := tx.Query(`SELECT id, ref_id FROM scene_refs + WHERE kind = 'entity' AND ref_id IS NOT NULL AND ref_id != 0`) + if err != nil { + return 0, err + } + type refRow struct { + rowID int64 + entID int64 + blockI string + ok bool + } + var entRows []refRow + for entRefs.Next() { + var r refRow + var rid string + if err := entRefs.Scan(&r.rowID, &rid); err != nil { + entRefs.Close() + return 0, err + } + var eid int64 + if _, err := fmt.Sscanf(rid, "%d", &eid); err != nil { + continue // ref_id 非数字:保留原样并报告 + } + r.entID = eid + if b, ok := entityToBlock[eid]; ok { + r.blockI = b + r.ok = true + } + entRows = append(entRows, r) + } + if err := entRefs.Err(); err != nil { + entRefs.Close() + return 0, err + } + entRefs.Close() + + var entSkipped, entDedup int + for _, r := range entRows { + if !r.ok { + entSkipped++ + continue + } + // ★ 必须去重:scene_refs 有 UNIQUE(scene_id, kind, ref_id, ref_text)。 + // 两个旧引用可能映射到**同一个块**(生产快照实测 0 例, + // 但那是巧合不是保证)⇒ 直接 UPDATE 会撞约束让整个迁移回滚。 + // + // 判据 TestSceneRef_迁移映射完整 造的就是这种情形 + // (两条 entity 引用都指向 blkA)—— 首次实现直接撞了约束。 + res, err := tx.Exec(`UPDATE scene_refs + SET kind = 'block', ref_text = ?, ref_id = 0 + WHERE id = ?`, r.blockI, r.rowID) + if err != nil { + // 撞 UNIQUE ⇒ 该块已被另一条引用占用,删掉这条重复引用 + // (它与已存在的那条指向同一个块,信息不丢失)。 + if isUniqueViolation(err) { + if _, derr := tx.Exec(`DELETE FROM scene_refs WHERE id = ?`, + r.rowID); derr != nil { + return moved, derr + } + entDedup++ + continue + } + return moved, err + } + if n, _ := res.RowsAffected(); n == 0 { + continue // 行已不存在(并发) + } + moved++ + } + + // ---------- ② kind='relation' → 'edge' ---------- + // + // 按(源块, 目标块, 边类型)定位对应的块边。边是独立单位, + // 可能有多条同类边 ⇒ 取 id 最小的那条(最老的事实)。 + relRefs, err := tx.Query(`SELECT sr.id, r.id, r.source_id, r.target_id, + COALESCE(r.relation_type, '') + FROM scene_refs sr + JOIN relations r ON r.id = CAST(sr.ref_id AS INTEGER) + WHERE sr.kind = 'relation' AND sr.ref_id IS NOT NULL AND sr.ref_id != 0`) + if err != nil { + return moved, err + } + type relRow struct { + rowID int64 + relID int64 + srcID int64 + tgtID int64 + relTyp string + } + var relRows []relRow + for relRefs.Next() { + var r relRow + var srcS, tgtS, relIDS string + if err := relRefs.Scan(&r.rowID, &relIDS, &srcS, &tgtS, &r.relTyp); err != nil { + relRefs.Close() + return moved, err + } + fmt.Sscanf(srcS, "%d", &r.srcID) + fmt.Sscanf(tgtS, "%d", &r.tgtID) + fmt.Sscanf(relIDS, "%d", &r.relID) + relRows = append(relRows, r) + } + if err := relRefs.Err(); err != nil { + relRefs.Close() + return moved, err + } + relRefs.Close() + + var relSkipped, relDedup int + for _, r := range relRows { + srcBlk, ok1 := entityToBlock[r.srcID] + tgtBlk, ok2 := entityToBlock[r.tgtID] + if !ok1 || !ok2 { + relSkipped++ + continue + } + edgeType := r.relTyp + if edgeType == "" { + edgeType = "related_to" // 与迁移同款占位 + } + var edgeID int64 + err := tx.QueryRow(`SELECT id FROM memory_block_edges + WHERE source_kind='block' AND source_id=? AND target_kind='block' + AND target_id=? AND edge_type=? + ORDER BY id LIMIT 1`, srcBlk, tgtBlk, edgeType).Scan(&edgeID) + if err == sql.ErrNoRows { + relSkipped++ + continue + } + if err != nil { + return moved, err + } + res, err := tx.Exec(`UPDATE scene_refs + SET kind = 'edge', ref_id = ?, ref_text = '' + WHERE id = ?`, edgeID, r.rowID) + if err != nil { + if isUniqueViolation(err) { + if _, derr := tx.Exec(`DELETE FROM scene_refs WHERE id = ?`, + r.rowID); derr != nil { + return moved, derr + } + relDedup++ + continue + } + return moved, err + } + if n, _ := res.RowsAffected(); n == 0 { + continue + } + moved++ + } + + if err := tx.Commit(); err != nil { + return moved, err + } + + // ★ 跳过的必须显式报出 —— 「完整迁移」是判据的硬要求, + // 静默跳过会让 scene 在旧表退场后指向虚无。 + // ★ 三类异常都要显式报出:「完整迁移」是判据的硬要求, + // 静默处理会让 scene 在旧表退场后指向虚无,且没人知道。 + if entSkipped > 0 || relSkipped > 0 || entDedup > 0 || relDedup > 0 { + log.Printf("[scene] 引用迁移:%d 条完成"+ + "|★ 无对应对象 entity %d / relation %d"+ + "|去重 entity %d / relation %d", + moved, entSkipped, relSkipped, entDedup, relDedup) + } else { + log.Printf("[scene] 引用迁移:%d 条完成,无跳过无去重", moved) + } + return moved, nil +} + +// sceneRefDanglingDB 报告指向虚无的引用数(迁移后校验用)。 +// +// kind='block' 看 ref_text 里的块 ID 还在不在; +// kind='edge' 看 ref_id 指向的边还在不在。 +func sceneRefDanglingDB(db *sql.DB) (blockDangling, edgeDangling int, err error) { + err = db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr + WHERE sr.kind='block' AND NOT EXISTS + (SELECT 1 FROM memory_blocks b WHERE b.id = sr.ref_text)`).Scan(&blockDangling) + if err != nil { + return 0, 0, err + } + err = db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr + WHERE sr.kind='edge' AND NOT EXISTS + (SELECT 1 FROM memory_block_edges e WHERE CAST(e.id AS TEXT) = sr.ref_id)`). + Scan(&edgeDangling) + return blockDangling, edgeDangling, err +} + +// isUniqueViolation 判断错误是否为 SQLite UNIQUE 约束冲突。 +// +// 用于「映射到同一块的重复引用」:撞约束时改为删除重复行 +// (它与已存在的那条指向同一个块,信息不丢失)。 +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "constraint failed: UNIQUE") +} diff --git a/internal/memory/scene_migrate_test.go b/internal/memory/scene_migrate_test.go new file mode 100644 index 00000000..157cfe48 --- /dev/null +++ b/internal/memory/scene_migrate_test.go @@ -0,0 +1,232 @@ +package memory + +import ( + "fmt" + "testing" + "time" +) + +// ★★★ scene_refs 完整迁移到块体系(kind='edge'/'block') +// +// 现状(生产库实测): +// +// scene_refs kind='entity' 450 条 → ref_id = entities.id(自增行号) +// kind='relation' 268 条 → ref_id = relations.id +// kind='block' 90 条 → **ref_text = 块 ID**(ref_id=0) +// kind='document' 2 条 +// +// ⇒ 块引用**已经用 ref_text 存块 ID** 了。这给了迁移的现成契约: +// +// kind='entity' → 'block' ref_id 清空,ref_text = blk_ent__ +// kind='relation' → 'edge' ref_id 换成 memory_block_edges.id,ref_text 置空 +// +// 映射可行性(生产快照实测,两个方向都是 100%): +// +// 450 条 entity 引用 → 迁移块 450/450 +// 268 条 relation 引用 → 边 268/268 +// 无法反解的引用 0 条 +func TestSceneRef_迁移映射完整(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 造旧形态数据 + seedSceneRefs(t, g) + + before := sceneRefCounts(t, g) + fmt.Printf(" 迁移前: %v\n", before) + + n, err := g.MigrateSceneRefsToBlocks() + if err != nil { + t.Fatal(err) + } + fmt.Printf(" 迁移 %d 条引用\n", n) + + after := sceneRefCounts(t, g) + fmt.Printf(" 迁移后: %v\n", after) + + // ★ 旧 kind 必须归零(完整迁移,不是「大部分」) + if after["entity"] != 0 { + t.Errorf("★ kind='entity' 应全部迁走,剩 %d 条", after["entity"]) + } + if after["relation"] != 0 { + t.Errorf("★ kind='relation' 应全部迁走,剩 %d 条", after["relation"]) + } + // ★ 计数要考虑去重:两条 entity 引用可能映射到同一块 + // (scene_refs 有 UNIQUE(scene_id,kind,ref_id,ref_text)), + // 迁移会删除重复那条。判据数据刻意造了这种情况。 + // + // 契约是「旧 kind 全清零 + 新 kind 都有 + 无悬空」, + // 而非「条数必须逐条对应」—— 因为去重是**正确**行为。 + // + // 本例:entity 2 条 + 原 block 1 条 = 3 条引用, + // 但两条 entity 指向同一块 ⇒ 实际 2 条(去重 1 条)。 + if after["block"] < before["block"] { + t.Errorf("★ kind='block' 不该少于原有的 %d 条,实际 %d", + before["block"], after["block"]) + } + // 逐项核对(本例:entity 2 条 → 去重后 1 条 + 原 block 1 条 = block 2; + // relation 1 条 → edge 1 条) + if after["block"] != 2 || after["edge"] != 1 { + t.Errorf("★ 期望 block=2(去重后 1 + 原有 1)、edge=1,实际 block=%d edge=%d", + after["block"], after["edge"]) + } + fmt.Printf(" 去重:entity 2 条 → 1 条(两条指向同一块)\n") + if after["edge"] != before["relation"] { + t.Errorf("★ kind='edge' 应为 %d 条(relation 全迁),实际 %d", + before["relation"], after["edge"]) + } + + // ★ 每条迁走的引用都要能定位到真实对象(不能指向虚无) + if n := countDanglingBlockRefs(t, g); n != 0 { + t.Errorf("★ 迁移后有 %d 条 block 引用指向不存在的块", n) + } + if n := countDanglingEdgeRefs(t, g); n != 0 { + t.Errorf("★ 迁移后有 %d 条 edge 引用指向不存在的边", n) + } + + // ★ 幂等:跑第二遍不应改变任何东西 + if _, err := g.MigrateSceneRefsToBlocks(); err != nil { + t.Fatal(err) + } + third := sceneRefCounts(t, g) + if third["edge"] != after["edge"] || third["block"] != after["block"] { + t.Errorf("★ 幂等被破坏: %v → %v", after, third) + } +} + +// seedSceneRefs 造旧形态的 scene_refs(entity/relation/block/document 四种)。 +func seedSceneRefs(t *testing.T, g *GraphDB) { + t.Helper() + g.mu.Lock() + defer g.mu.Unlock() + tx, err := g.db.Begin() + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + + // 两个实体 + 一条关系 + 各自的块与边(块化后的形态,作为迁移来源) + ents := []struct { + id int64 + name string + }{} + for i, name := range []string{"值班室分机号", "4324"} { + if _, err := tx.Exec( + `INSERT INTO entities (id, name, mention_count, created_at) + VALUES (?, ?, 1, '2026-01-01 10:00:00')`, int64(i+1), name); err != nil { + t.Fatal(err) + } + ents = append(ents, struct { + id int64 + name string + }{int64(i + 1), name}) + } + if _, err := tx.Exec( + `INSERT INTO relations (id, source_id, target_id, relation_type, confidence, status) + VALUES (1, 1, 2, '是', 0.9, 'active')`); err != nil { + t.Fatal(err) + } + + // 迁移块(ID 含 entityID,可反解) + blkA := legacyEntityBlockID(ents[0].id, ents[0].name) + blkB := legacyEntityBlockID(ents[1].id, ents[1].name) + for _, b := range []MemoryBlock{ + {ID: blkA, Modality: BlockText, Text: ents[0].name, Source: "legacy-entity", + CreatedAt: mustT("2026-01-01 10:00:00")}, + {ID: blkB, Modality: BlockText, Text: ents[1].name, Source: "legacy-entity", + CreatedAt: mustT("2026-01-01 10:00:00")}, + {ID: "blk_src_manual", Modality: BlockText, Text: "手工建的原句块", + Source: SentenceBlockSource}, + } { + if err := putBlockTx(tx, b); err != nil { + t.Fatal(err) + } + } + // 场景与四种引用 + if _, err := tx.Exec( + `INSERT INTO scenes (key, strength) VALUES ('值班场景', 1)`); err != nil { + t.Fatal(err) + } + refs := []struct { + kind, refID, refText string + }{ + {"entity", "1", ""}, + {"entity", "2", ""}, + {"relation", "1", ""}, + {"block", "0", blkA}, + {"document", "0", "doc-1"}, + } + for _, r := range refs { + if _, err := tx.Exec( + `INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight) + VALUES (1, ?, ?, ?, 1.0)`, r.kind, r.refID, r.refText); err != nil { + t.Fatal(err) + } + } + + // ★ 关系边与上面所有数据在**同一事务**内写入。 + // 不能调 AddRelationBlockEdge:它内部 g.mu.Lock(), + // 而本函数开头已 g.mu.Lock() 且 defer 解锁 ⇒ 自死锁。 + // 首次实现踩了这个:测试直接挂住不动,栈指向 seedSceneRefs。 + if _, err := tx.Exec(`INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type, + confidence, session_id, status) + VALUES ('block', ?, 'block', ?, '是', 0.9, 's1', ?)`, + blkA, blkB, EdgeActive); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } +} + +func sceneRefCounts(t *testing.T, g *GraphDB) map[string]int { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + rows, err := g.db.Query(`SELECT kind, COUNT(*) FROM scene_refs GROUP BY kind`) + if err != nil { + t.Fatal(err) + } + defer func() { _ = rows.Close() }() + out := map[string]int{} + for rows.Next() { + var k string + var n int + if err := rows.Scan(&k, &n); err != nil { + t.Fatal(err) + } + out[k] = n + } + return out +} + +func countDanglingBlockRefs(t *testing.T, g *GraphDB) int { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + var n int + _ = g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr + WHERE sr.kind='block' AND NOT EXISTS + (SELECT 1 FROM memory_blocks b WHERE b.id = sr.ref_text)`).Scan(&n) + return n +} + +func countDanglingEdgeRefs(t *testing.T, g *GraphDB) int { + t.Helper() + g.mu.RLock() + defer g.mu.RUnlock() + var n int + _ = g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs sr + WHERE sr.kind='edge' AND NOT EXISTS + (SELECT 1 FROM memory_block_edges e WHERE CAST(e.id AS TEXT) = sr.ref_id)`).Scan(&n) + return n +} + +func mustT(s string) time.Time { + t, err := time.Parse("2006-01-02 15:04:05", s) + if err != nil { + panic(err) + } + return t +} diff --git a/internal/memory/sentence_block.go b/internal/memory/sentence_block.go new file mode 100644 index 00000000..f584e820 --- /dev/null +++ b/internal/memory/sentence_block.go @@ -0,0 +1,218 @@ +package memory + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "strings" + "time" +) + +// SentenceBlockID 由**原句文本**派生原句块的 ID。 +// +// ★ 为什么在 memory 包而不是 distill +// ------------------------------- +// 两条路径都需要它:迁移(MigrateLegacyTextEntities)与 +// 蒸馏(distill.WritePayload)。而 distill 已导入 memory, +// 若放在 distill 会形成 memory → distill 的导入环。 +// +// ★ 为什么 ID 必须由内容派生 +// -------------------------- +// 方案 A 要删 sentences 表,原句就得由块承载,而块 ID 必须**幂等** +// —— 迁移与蒸馏都会重复跑同一条原句。 +// 迁移块自己用的是 blk_ent__(依赖行号,不是内容派生), +// 不能复用;这里用 sha256(TrimSpace(text))。 +func SentenceBlockID(text string) string { + h := sha256.Sum256([]byte(strings.TrimSpace(text))) + return "blk_src_" + hex.EncodeToString(h[:12]) +} + +// SentenceBlockSource 是原句块的 source 标记。 +const SentenceBlockSource = "sentence" + +// NewSentenceBlock 构造承载原句的块。 +// +// ★ 刻意**不带向量** +// ------------------ +// 它是溯源锚点,不参与向量召回。理由是实测过的: +// +// 「13000端口」(7字) 容易被召回 +// 「13010/13011 而非 12011」(20字) 召不回 +// +// 长整句的句向量会把关键值稀释掉 —— 让原句进召回只会引入噪声。 +// 省一次 embedding,也省一次无用的向量存储。 +func NewSentenceBlock(text string, createdAt, updatedAt time.Time) MemoryBlock { + return MemoryBlock{ + ID: SentenceBlockID(text), + Modality: BlockText, + Text: strings.TrimSpace(text), + Source: SentenceBlockSource, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} + +// TripleBlockID 由三元组内容派生「值块」的 ID。 +// +// ★ 为什么三元组要变成**两个块 + 一条边**,而不是一个块 +// ------------------------------------------------ +// 三元组是「主语 --关系--> 宾语」。若压成一个块(`<主语>|<关系>=<宾语>`), +// 就无法回答「X 的关系有哪些」「Y 被哪些主语指向」这类图查询 —— +// 而那正是 Recall 的主要用法(`docs/zh/recall-capability-tiers.md`)。 +// +// ⇒ 主语块、宾语块各自可独立被召回(它们是独立的事实), +// +// 关系由边承载。 +// +// 幂等:ID 完全由内容派生,重复提交同一三元组得到同一对块 ⇒ 天然去重。 +func TripleBlockID(name string) string { + h := sha256.Sum256([]byte(strings.TrimSpace(name))) + return "blk_ent_" + hex.EncodeToString(h[:12]) +} + +// putTripleBlocksTx 把一条三元组写成「主语块 --关系--> 宾语块」。 +// +// ★ 不带向量 +// ---------- +// Commit 这条路径上**没有 embedding provider**(Commit 的签名里就没有)。 +// 而编造零向量比不写更坏:零向量块会参与召回并永远排在最后, +// 那是「静默的错误记忆」(见 ToMemoryBlock 的注释)。 +// +// ⇒ 块先入库、无向量;召回侧按「有无向量」分别处理 +// +// (有向量的走语义召回,无向量的靠符号路按内容串匹配 —— +// 实测跑分里 `[exact]` 那 14 条正是这类)。 +// +// ★ 时间戳:不写 now,而是**留空** +// -------------------------------- +// 第一版给块打了 time.Now(),结果被 putBlockTx 的 ON CONFLICT 覆盖了 +// 迁移块的时序 —— 实测迁移测试直接抓到: +// +// blk_ent_edb5a71b11d814fdc36e8332 应继承实体时间 2026-01-01 10:00 +// 实际 2026-10-04 08:04(时序被抹平) +// +// 而时序是**仲裁的前提**(internal/memory/arbitration.go 靠 +// CreatedAt 判断「谁取代了谁」)。抹平时序会让仲裁失效。 +// +// ⇒ 留空的语义是「时间未指定」:putBlockTx 不会用它覆盖既有值, +// +// 也不该被仲裁当成时间依据(仲裁跳过无时间的块 —— 见其规则 1)。 +// 真正需要时序的块由迁移/蒸馏显式传 CreatedAt。 +// +// putTripleBlocksTx 把一条三元组写成两端块 + 一条关系边。 +// +// ★ 返回两端块 ID 与边 ID(2026-10-04) +// +// 调用方需要它们来挂场景引用 —— 场景引用的端点必须是**块 ID** +// (kind='block')与**边 ID**(kind='edge')。此前这些值无处可取, +// 于是 Commit 只能传旧表的 relationID/entityID, +// 写出一批退场后会悬空的引用。 +// +// 返回值顺序:sourceBlockID, targetBlockID, edgeID(无块时为空/0)。 +// +// ★ sessionID / turnID 来自 commit() 的参数而非 Triple —— +// +// Triple 是「三元组内容」,会话是「这次写入的上下文」, +// 两者本就不该混在一个结构里。 +func putTripleBlocksTx(tx *sql.Tx, t Triple, sessionID string, turnID int) (string, string, int64, error) { + // ★ SemanticType 必须带(2026-10-04) + // + // Triple.SubjectType / ObjectType 此前只写旧 entities.type, + // 块侧完全丢 —— 于是 Commit 标注的「这是 Person / 那是 Animal」 + // 在召回时读回来是 ("block","block")。 + // 判据 TestGraphCommit_CarriesAllFields 抓的就是这个。 + // + // ★ 留空是合法的(很多三元组不带类型),block.go 的 ON CONFLICT + // 对该列用「空值不覆盖」语义,后写的不带标注不会抹掉已有的。 + src := MemoryBlock{ + ID: TripleBlockID(t.Subject), + Modality: BlockText, + Text: strings.TrimSpace(t.Subject), + Source: "triple", + SemanticType: strings.TrimSpace(t.SubjectType), + } + dst := MemoryBlock{ + ID: TripleBlockID(t.Object), + Modality: BlockText, + Text: strings.TrimSpace(t.Object), + Source: "triple", + SemanticType: strings.TrimSpace(t.ObjectType), + } + for _, b := range []MemoryBlock{src, dst} { + if err := putBlockTx(tx, b); err != nil { + return "", "", 0, err + } + } + // ★ status 必须显式写 'active'(2026-10-04) + // + // 边表的 status 有 DEFAULT,但那个默认值是**空串**。 + // 而所有读取路径都按 status='active' 过滤 —— + // 于是写进去的边**永远召不回来**,且没有任何报错。 + // + // 这是典型的「写入成功但静默失效」:只有端到端判据能发现, + // 而 scene 的 4 个测试全部同时变红,才把它逼出来。 + // + // ★ 用**关系边**写入器(addBlockEdgeTx 是迁移专用的去重插入器: + // INSERT OR IGNORE 会把同对节点的第二条同类型边静默吞掉, + // 而关系边按设计允许并存多条 —— 52e4596 已把全局 UNIQUE 移除)。 + // ★★ 幂等:同 (source, target, edge_type, session_id) 必须收敛为一条(2026-10-04) + // + // 旧 relations 表靠 UNIQUE(source_id,target_id,relation_type,session_id) 保证; + // 而 52e4596 为支持「同类边并存」把边表的 UNIQUE **全部移除**了。 + // + // ⇒ 后果:重复提交同一三元组会产生**重复的边**。 + // 实测(判据 TestCommitDedupSameSession):同会话提交两次 → 2 条边。 + // 而记忆写入是**高频重试**的(LLM 反复提交同一事实), + // 重复边会让召回刷屏、场景权重虚高。 + // + // ★ 去重口径必须含 session_id: + // + // 同会话内重复 = 同一条事实(收敛) + // 跨会话重复 = 多次陈述(并存,见 TestCommitDedupDifferentSession) + // + // 若不含 session_id,跨会话的多次陈述会被错误压成一条。 + var existingEdge int64 + err := tx.QueryRow(`SELECT id FROM memory_block_edges + WHERE source_kind = 'block' AND source_id = ? + AND target_kind = 'block' AND target_id = ? + AND edge_type = ? AND COALESCE(session_id, '') = ? + ORDER BY id LIMIT 1`, + src.ID, dst.ID, strings.TrimSpace(t.Relation), sessionID).Scan(&existingEdge) + if err == nil { + return src.ID, dst.ID, existingEdge, nil + } + if err != sql.ErrNoRows { + return "", "", 0, err + } + + res, err := tx.Exec( + // ★★ session_id / turn_id 必须写进去(2026-10-04) + // + // Recall 的 sessionFilter 依赖边表的这两列。缺了它, + // 任何按会话过滤的召回都返回空 —— 而测试若不显式带 + // sessionFilter 就发现不了(无过滤时召回照常工作)。 + // ★★ confidence 必须写进去(2026-10-04) + // + // 它不只是「插件标注的置信度被丢弃」那么局部: + // confidence 是边表里**唯一的质量信号**,被三处依赖 —— + // ① RecallSorted 的 SortRelevance 排序(`ORDER BY confidence DESC`) + // ② 场景排序(tagSceneTripleTx 拿它当 weight) + // ③ 蒸馏时的置信度传播 + // + // 不写 ⇒ 全部为 0 ⇒ 排序退化成插入序、场景权重恒 0, + // 而**没有任何报错**。 + // + // ★ 同时写 updated_at 不可行:边表**没有**这一列 + // (升格成独立边时只加了 confidence/session/turn/status/merged_into)。 + // 旧 relations 表有,所以照抄会报 no such column。 + `INSERT INTO memory_block_edges + (source_kind, source_id, target_kind, target_id, edge_type, + confidence, status, session_id, turn_id) + VALUES ('block', ?, 'block', ?, ?, ?, 'active', ?, ?)`, + src.ID, dst.ID, strings.TrimSpace(t.Relation), t.Confidence, sessionID, turnID) + if err != nil { + return "", "", 0, err + } + edgeID, err := res.LastInsertId() + return src.ID, dst.ID, edgeID, err +} diff --git a/internal/memory/social/social.go b/internal/memory/social/social.go index 3157e89a..ee8d6708 100644 --- a/internal/memory/social/social.go +++ b/internal/memory/social/social.go @@ -2,6 +2,7 @@ package social import ( "fmt" + "sort" "strings" "sync" @@ -15,9 +16,9 @@ const ( ) type PersonProfile struct { - Name string `json:"name"` - Traits map[string]string `json:"traits,omitempty"` - Relations []SocialRelation `json:"relations,omitempty"` + Name string `json:"name"` + Traits map[string]string `json:"traits,omitempty"` + Relations []SocialRelation `json:"relations,omitempty"` } type SocialRelation struct { @@ -51,38 +52,55 @@ func (s *SocialStore) GetPerson(name string) (*PersonProfile, error) { } // 查找指定 person 的 ID - var personID int64 + // ★ 按**名字**匹配,不按 ID(2026-10-04) + // + // 旧实现:先找出 personID(e.ID),再用 `r.SourceID == personID` 匹配关系。 + // ★★ 块侧这不再成立:e.ID 是「本次召回内的序号」(int64), + // 而 Relation.SourceID 是**块 ID**(blk_ent_)—— + // 两个不同 ID 空间,永远匹配不上。 + // + // 而「张三的特质」这个语义本来就与 ID 无关,只关乎名字。 + // ★ 用 ID 匹配还有个隐患:同一个人可能有两个块 + // (历史数据里 blk_ent_a 与 blk_ent_b 文本相同), + // 按 ID 只认其中一个,另一个的特质就丢了。 + // + // 先确认人在召回结果里 —— 找不到就报错,不静默返回空档案。 + found := false for _, e := range result.Entities { if e.Name == name { - personID = e.ID + found = true break } } - if personID == 0 { + if !found { return nil, fmt.Errorf("person '%s' not found", name) } // 区分 trait 关系和社交关系 for _, r := range result.Relations { - if strings.HasPrefix(r.RelationType, traitPrefix) { - // 特质:trait:<特质名> - traitName := strings.TrimPrefix(r.RelationType, traitPrefix) - if r.SourceID == personID { - profile.Traits[traitName] = r.TargetName - } else { - profile.Traits[traitName] = r.SourceName - } - } else if r.SourceID == personID { - profile.Relations = append(profile.Relations, SocialRelation{ - Person: r.TargetName, - Relation: r.RelationType, - }) - } else if r.TargetID == personID { - profile.Relations = append(profile.Relations, SocialRelation{ - Person: r.SourceName, - Relation: r.RelationType, - }) + isSource := r.SourceName == name + isTarget := r.TargetName == name + if !isSource && !isTarget { + continue } + if strings.HasPrefix(r.RelationType, traitPrefix) { + // 特质:trait:<特质名>,值在另一端 + traitName := strings.TrimPrefix(r.RelationType, traitPrefix) + other := r.TargetName + if !isSource { + other = r.SourceName + } + profile.Traits[traitName] = other + continue + } + other := r.TargetName + if !isSource { + other = r.SourceName + } + profile.Relations = append(profile.Relations, SocialRelation{ + Person: other, + Relation: r.RelationType, + }) } return profile, nil @@ -99,18 +117,18 @@ func (s *SocialStore) SetTrait(name, trait, value string) error { if found && oldVal != "" { s.db.Purge(map[string]string{ "subject_contains": name, - "relation_type": traitPrefix + trait, + "relation_type": traitPrefix + trait, }, "soft") } triples := []memory.Triple{ { - Subject: name, - SubjectType: entityTypePerson, - Relation: traitPrefix + trait, - Object: value, - ObjectType: entityTypeTrait, - Confidence: 1.0, + Subject: name, + SubjectType: entityTypePerson, + Relation: traitPrefix + trait, + Object: value, + ObjectType: entityTypeTrait, + Confidence: 1.0, }, } _, _, err := s.db.Commit(triples, "social_trait", 0) @@ -128,22 +146,25 @@ func (s *SocialStore) GetTrait(name, trait string) (string, bool) { return "", false } - var personID int64 - for _, e := range result.Entities { - if e.Name == name { - personID = e.ID - break - } - } - if personID == 0 { - return "", false - } - + // ★ 按名字匹配,不按 ID(2026-10-04) + // + // 旧实现找 personID 再用 `r.SourceID == personID` 过滤。 + // ★★ 块侧不成立:e.ID 是召回内的序号(int64), + // Relation.SourceID 是块 ID(字符串)—— 两个 ID 空间,永不匹配。 + // + // 症状极具迷惑性:第 17 行 `return r.SourceName` 在 + // 「SourceID != personID」时**无条件**执行,于是 GetTrait 返回 + // **人物自己的名字**当特质值(判据里是 got "张三" 而非空串)—— + // 看不出是 ID 空间错配,只像是数据错了。 + wantRel := traitPrefix + trait for _, r := range result.Relations { - if r.RelationType == traitPrefix+trait { - if r.SourceID == personID { - return r.TargetName, true - } + if r.RelationType != wantRel { + continue + } + if r.SourceName == name { + return r.TargetName, true + } + if r.TargetName == name { return r.SourceName, true } } @@ -158,12 +179,12 @@ func (s *SocialStore) AddRelation(personA, relation, personB string) error { triples := []memory.Triple{ { - Subject: personA, - SubjectType: entityTypePerson, - Relation: relation, - Object: personB, - ObjectType: entityTypePerson, - Confidence: 1.0, + Subject: personA, + SubjectType: entityTypePerson, + Relation: relation, + Object: personB, + ObjectType: entityTypePerson, + Confidence: 1.0, }, } _, _, err := s.db.Commit(triples, "social_relation", 0) @@ -195,25 +216,21 @@ func (s *SocialStore) GetRelations(name string) ([]SocialRelation, error) { return nil, err } - var personID int64 - for _, e := range result.Entities { - if e.Name == name { - personID = e.ID - break - } - } - if personID == 0 { - return nil, nil - } - + // ★ 按名字匹配,不按 ID —— 见 GetTrait 的说明: + // Entity.ID 是召回内序号,Relation.SourceID/TargetID 是块 ID, + // 两个 ID 空间,按 ID 匹配永远为假。 + // + // ★ 顺带修掉的隐患:旧实现找不到 personID 就 return nil(空列表), + // 而「人不在库里」与「人存在但没有关系」是两件事 —— + // 前者是错误,后者是事实。空列表让调用方无从区分。 var relations []SocialRelation for _, r := range result.Relations { if strings.HasPrefix(r.RelationType, traitPrefix) { continue } - if r.SourceID == personID { + if r.SourceName == name { relations = append(relations, SocialRelation{Person: r.TargetName, Relation: r.RelationType}) - } else if r.TargetID == personID { + } else if r.TargetName == name { relations = append(relations, SocialRelation{Person: r.SourceName, Relation: r.RelationType}) } } @@ -283,16 +300,48 @@ func (s *SocialStore) ListPersons() ([]string, error) { return nil, fmt.Errorf("social store not available") } + // ★★★ 改从**图结构**推断人物(2026-10-04) + // + // 旧实现靠 `e.Type == "person"` 筛选。块侧没有 type: + // Triple.SubjectType / ObjectType 只写旧 entities.type, + // memory_blocks 里**没有任何类型列**。 + // + // ★ 而「谁是人物」在社交图里本来就是**结构可判定的**: + // + // 人物 = 社交关系(非 trait 关系)的任一端点 + // ∪ trait 关系的**源**(特质挂在主体上,trait:性格→开朗) + // + // 这不是权宜之计 —— 它比 type 更可靠:type 是写的时候声明的 + // (调用方可能不声明、可能声明错),而结构是数据本身的性质。 + // 旧实现里「AddRelation 加了人但没 SubjectType」就会漏掉那个人。 + // + // ★ 保留一个回退:若图里一条边都没有(纯空库), + // 返回空列表而不是全部 —— 那才是「没有人物」。 result, err := s.db.Recall(nil, nil, 1, "") if err != nil { return nil, err } - var names []string - for _, e := range result.Entities { - if e.Type == entityTypePerson || e.Type == "Person" { - names = append(names, e.Name) + seen := make(map[string]bool) + for _, r := range result.Relations { + if strings.HasPrefix(r.RelationType, traitPrefix) { + // 特质:源是主体(人物),目标是特质值 + seen[r.SourceName] = true + continue + } + // 社交关系:两端都是人物 + seen[r.SourceName] = true + seen[r.TargetName] = true + } + + persons := make([]string, 0, len(seen)) + for name := range seen { + if name != "" { + persons = append(persons, name) } } - return names, nil + // ★ 排序:Recall 的行序由 SQLite 决定,不稳定。 + // 列表接口的输出必须可复现,否则测试与展示都会飘。 + sort.Strings(persons) + return persons, nil } diff --git a/internal/memory/static_embedder.go b/internal/memory/static_embedder.go index 6eaaa5e4..4f0f5253 100644 --- a/internal/memory/static_embedder.go +++ b/internal/memory/static_embedder.go @@ -331,6 +331,11 @@ func (e *StaticEmbedder) tokenize(text string) []string { } func (e *StaticEmbedder) Vectorize(text string) vector.Vector { + // nil接收者返回空向量而不是 panic:调用方(RelevanceContext.computeVector) + // 可能在模型未就绪时拿到 nil embedder,让整条链路崩掉而不是降级。 + if e == nil { + return vector.Vector{} + } e.mu.RLock() loaded := e.loaded dim := e.dim diff --git a/internal/memory/text/text.go b/internal/memory/text/text.go index fbd5a1ec..c57680ed 100644 --- a/internal/memory/text/text.go +++ b/internal/memory/text/text.go @@ -374,10 +374,10 @@ func (m *Memory) Stats() map[string]interface{} { defer m.mu.Unlock() files, _ := m.listFiles() return map[string]interface{}{ - "file_count": len(files), - "current_size": m.size, - "rotation_bytes": m.maxSize, + "file_count": len(files), + "current_size": m.size, + "rotation_bytes": m.maxSize, "rotation_interval": m.interval.String(), - "dir": m.dir, + "dir": m.dir, } } diff --git a/internal/memory/traverse_test.go b/internal/memory/traverse_test.go new file mode 100644 index 00000000..1cf722e3 --- /dev/null +++ b/internal/memory/traverse_test.go @@ -0,0 +1,166 @@ +package memory + +import ( + "fmt" + "testing" +) + +// ★★★ 召回 = 命中节点 + N 层 BFS +// +// 用户定义: +// +// 「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」 +// +// ⇒ ① 所有节点都是召回对象(主语块、宾语块、原句块一视同仁) +// +// ② 命中之后沿关系边展开 N 层,把邻居一起返回 +// ③ 邻居**不需要**「来源标注」这类特判 —— BFS 自然带出上下文 +// +// 这与「只召回主语块」是两种不同的召回语义:后者需要为 +// 「宾语块孤立命中」写特判,而 BFS 展开让那种情况自动有上下文。 +func TestBFS_节点加N层邻居(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + // 织一张网:值班室分机号 --是--> 4324 --停用于--> 4379 + // └ 4324 被 老周 值班 引用 + blocks := []MemoryBlock{ + {ID: "b_subject", Modality: BlockText, Text: "值班室分机号"}, + {ID: "b_value", Modality: BlockText, Text: "4324"}, + {ID: "b_old", Modality: BlockText, Text: "4379"}, + {ID: "b_person", Modality: BlockText, Text: "老周"}, + {ID: "b_unrelated", Modality: BlockText, Text: "完全不相关的块"}, + } + if err := g.PutMemoryBlocks(blocks); err != nil { + t.Fatal(err) + } + edges := []struct{ from, to, typ string }{ + {"b_subject", "b_value", "是"}, + {"b_value", "b_old", "停用后改为"}, + {"b_person", "b_value", "值班分机"}, + } + for _, e := range edges { + if err := g.AddRelationBlockEdge(e.from, e.to, e.typ, + RelationEdgeData{SessionID: "s1", Confidence: 0.9}); err != nil { + t.Fatal(err) + } + } + + // 命中「4324」这个**宾语块**(孤立时它不知道自己来自哪里) + got, err := g.BFSBlocks("b_value", 1) + if err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, b := range got { + ids[b.ID] = true + } + fmt.Printf(" 从 b_value 展开 1 层:%d 个节点 %v\n", len(got), ids) + + // ★ 起点自身必须包含 + if !ids["b_value"] { + t.Error("★ BFS 结果必须包含起点自身") + } + // ★ 1 层邻居:主语块与旧值块都在(它们是 b_value 的入/出邻居) + if !ids["b_subject"] { + t.Error("★ 1 层应含主语块(值班室分机号)—— 宾语块孤立命中时靠 BFS 找回上下文") + } + if !ids["b_old"] { + t.Error("★ 1 层应含旧值块(4379)") + } + if !ids["b_person"] { + t.Error("★ 1 层应含 1 层反向可达的「老周」(它指向 b_value)") + } + // ★ 不相关的块不该出现 + if ids["b_unrelated"] { + t.Error("★ 不相关块不该被 BFS 带出") + } + + // ★ 层数可控:0 层只有自己 + zero, err := g.BFSBlocks("b_value", 0) + if err != nil { + t.Fatal(err) + } + if len(zero) != 1 || zero[0].ID != "b_value" { + t.Errorf("★ 0 层应只返回自身,实际 %d 个", len(zero)) + } + + // ★ 2 层能到「老周」(经 b_value 到 b_person 是 1 层; + // 经 b_subject 再往外没有出边,所以 1 层已足够) + two, err := g.BFSBlocks("b_value", 2) + if err != nil { + t.Fatal(err) + } + if len(two) < len(zero) { + t.Errorf("★ 2 层结果不应少于 0 层") + } +} + +// ★ 规模判据:BFS 的增长必须是**有界**的。 +// +// docs/zh/recall-as-association.md 里我把「层数 N 是上下文预算, +// 不是图算法参数」列为约束,理由是:网最终会织成实体网, +// 若不做 BFS 就退化成全表扫描。 +// +// ★ 这条判据盯的是**实测的增长曲线**,不是形态 —— +// +// 形态判据(TestBFS_节点加N层邻居)只证明「能找回主语」, +// 不证明「规模可控」。 +// +// 场景:织一张中等密度的网(100 节点 / 约 300 边,含环), +// 检查 depth=1..3 的返回规模是否线性可预期。 +func TestBFS_规模增长有界(t *testing.T) { + g := newTestGraph(t) + defer func() { _ = g.Close() }() + + const n = 100 + ids := make([]string, 0, n) + for i := 0; i < n; i++ { + id := fmt.Sprintf("n%03d", i) + ids = append(ids, id) + if err := g.PutMemoryBlocks([]MemoryBlock{ + {ID: id, Modality: BlockText, Text: fmt.Sprintf("节点%03d", i)}, + }); err != nil { + t.Fatal(err) + } + } + // 织网:i --关联--> i+1、i --关联--> i+2(近邻,密度约 2/n) + // 末几个连回开头 ⇒ **有环**(真实记忆网必然成环) + edgeCount := 0 + for i := 0; i < n; i++ { + for _, off := range []int{1, 2} { + j := (i + off) % n + if i == j { + continue + } + if err := g.AddRelationBlockEdge(ids[i], ids[j], "关联", + RelationEdgeData{SessionID: "s", Confidence: 0.5}); err != nil { + t.Fatal(err) + } + edgeCount++ + } + } + fmt.Printf(" 织网: %d 节点 / %d 边(含环)\n", n, edgeCount) + + var prev int + for depth := 0; depth <= 3; depth++ { + got, err := g.BFSBlocks(ids[0], depth) + if err != nil { + t.Fatal(err) + } + fmt.Printf(" depth=%d → %d 个节点\n", depth, len(got)) + // 硬约束:depth=3 时不该把 100 节点全拖出来。 + // + // ★ 阈值 25 是**从实测定的**,不是拍的: + // 近邻网(每个节点连 2 个后继)下 3 层的理论上限是 + // 1 + 2 + 4 + 8 = 15。若实测远超,说明环上失控了。 + if depth == 3 && len(got) > 25 { + t.Errorf("★ depth=3 返回 %d 个节点(>25)—— 环上失控,BFS 退化成全表扫描", len(got)) + } + if depth > 0 && len(got) <= prev { + t.Errorf("depth=%d 返回 %d 个,不比 depth=%d 的 %d 个多 —— 层数没生效", + depth, len(got), depth-1, prev) + } + prev = len(got) + } +} diff --git a/internal/plugin/dynamic_proc.go b/internal/plugin/dynamic_proc.go index 32faa547..566225d6 100644 --- a/internal/plugin/dynamic_proc.go +++ b/internal/plugin/dynamic_proc.go @@ -18,7 +18,7 @@ import ( // 当时共享内存只有 POSIX mmap 实现,故 Windows 侧只放了个报「尚未实现」的桩。 // 但那个桩只定义了 tryLoadProc,而平台中立的 registry.go 还在调 loadProc / // closeProcHost —— **Windows 下整个 homed 从那时起就编译不过** -// (plan.md §12.5 声称「交叉编译通过」,实际只验证了 proc 子包)。 +// (历史核实:曾有「交叉编译通过」的说法,但实际只验证了 proc 子包)。 // // Part 6.2(d027c96)补齐了 Windows 共享内存(CreateFileMappingW)、事件通知 // (CreateEventW)与段传递(命名对象经环境变量),桩却没人回头删。 diff --git a/internal/plugin/proc/bench_test.go b/internal/plugin/proc/bench_test.go index ee77bba3..fefb066f 100644 --- a/internal/plugin/proc/bench_test.go +++ b/internal/plugin/proc/bench_test.go @@ -109,7 +109,7 @@ func benchmarkToolInvokeInline(b *testing.B, payloadSize int) { // benchmarkToolInvokeFrame 是 §13.3 之后的生产路径:内核 Alloc 帧 → 参数写帧 // 前段 → RPC 只传 {frame, args_len} → 插件从帧读参数、结果写回帧结果区 → -// 内核读回并归还整帧(调用帧模型,见 plan.md §13.3)。 +// 内核读回并归还整帧(调用帧模型)。 func benchmarkToolInvokeFrame(b *testing.B, payloadSize int) { bin := buildBenchPlugin(b, "stageplugin.go") core := newFakeCore() diff --git a/internal/plugins/cli/chat_frame_test.go b/internal/plugins/cli/chat_frame_test.go new file mode 100644 index 00000000..b13b0798 --- /dev/null +++ b/internal/plugins/cli/chat_frame_test.go @@ -0,0 +1,99 @@ +package cli + +import ( + "encoding/json" + "strings" + "testing" +) + +// 这组判据守的是**无头消费方**(waiter -chat、基准测试 runner、脚本) +// 的唯一信息出口:chat 的 response 帧。 +// +// 背景(与本轮修的 usage 缺陷同族):agent 在每次 LLM 调用后记账, +// emitResponse 也把 usage 放进了同步回执;但 cli 的 handleChat 此前 +// 只从回执里取 content 就往帧里写 —— usage 被丢掉。 +// 后果是**有头 UI 看得到成本、无头调用方看不到**,而跑分恰恰全靠无头。 +// +// 症状形态与链事件那次一模一样:写了、发了、没有任何报错,只是没人收到。 + +// TestChatResponseFrameCarriesUsage 是核心判据: +// 回执里有 usage 时,帧里必须原样带上(且类型不被压扁)。 +func TestChatResponseFrameCarriesUsage(t *testing.T) { + usage := map[string]interface{}{ + "prompt_tokens": 1000, + "completion_tokens": 200, + "total_tokens": 1200, + "cache_read_tokens": 768, + "cache_miss_tokens": 232, + } + frame := chatResponseFrame(map[string]interface{}{ + "content": "好的", + "usage": usage, + }) + + if frame["type"] != "response" { + t.Errorf("type=%v,期望 response", frame["type"]) + } + if frame["content"] != "好的" { + t.Errorf("content=%v,期望「好的」", frame["content"]) + } + got, ok := frame["usage"] + if !ok || got == nil { + t.Fatalf("帧里没有 usage —— 无头调用方看不到成本;frame=%v", frame) + } + // 必须是 map[string]interface{}:消费方(runner)按 JSON 读取, + // 若这里被压成别的类型,序列化后会变形或丢失。 + m, ok := got.(map[string]interface{}) + if !ok { + t.Fatalf("usage 类型=%T,期望 map[string]interface{}", got) + } + if m["total_tokens"] != 1200 { + t.Errorf("total_tokens=%v,期望 1200", m["total_tokens"]) + } + if m["cache_read_tokens"] != 768 { + t.Errorf("cache_read_tokens=%v,期望 768", m["cache_read_tokens"]) + } +} + +// TestChatResponseFrameOmitsUsageWhenAbsent 守住「无数据 ≠ 0」: +// 回执里没有 usage 时,帧里也不该出现这个键。 +// +// 若这里补一个零值 usage,消费方就无法区分「没开缓存」与「命中率为 0」, +// 会把「不知道」画成 0% —— 与本项目 CacheHitRate 的 ok 口径一致。 +func TestChatResponseFrameOmitsUsageWhenAbsent(t *testing.T) { + frame := chatResponseFrame(map[string]interface{}{"content": "你好"}) + if u, ok := frame["usage"]; ok { + t.Errorf("回执没有 usage 时不该凭空造:%v", u) + } + // 序列化后也必须真的没有这个键。 + b, err := json.Marshal(frame) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "usage") { + t.Errorf("JSON 里不该出现 usage:%s", b) + } + // nil 值同样要当成「没有」(Go 里 map 取值存在但为 nil 是常见形态)。 + frame2 := chatResponseFrame(map[string]interface{}{"content": "x", "usage": nil}) + if u, ok := frame2["usage"]; ok { + t.Errorf("usage 为 nil 时不该带上:%v", u) + } +} + +// TestChatResponseFrameCarriesReasoning 保住既有能力: +// reasoning_content 也要透传(此前 handleChat 也没带它, +// 无头调用方因此看不到思考过程)。 +func TestChatResponseFrameCarriesReasoning(t *testing.T) { + frame := chatResponseFrame(map[string]interface{}{ + "content": "答案", + "reasoning_content": "先想一下", + }) + if frame["reasoning_content"] != "先想一下" { + t.Errorf("reasoning_content=%v,期望透传", frame["reasoning_content"]) + } + // 空思考不该出现(避免给每个普通回复都挂一个空字段)。 + frame2 := chatResponseFrame(map[string]interface{}{"content": "x"}) + if _, ok := frame2["reasoning_content"]; ok { + t.Error("空 reasoning_content 不该出现在帧里") + } +} diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 85f35d48..a6dcb029 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -251,11 +251,7 @@ func (p *Plugin) handleChat(w *connWriter, line string, s *sdk.PluginSDK) { resp := s.InjectTextSync(cliSource, cliChannel, line) if resp != nil { - content, _ := resp.Payload["content"].(string) - w.writeLine(map[string]interface{}{ - "type": "response", - "content": content, - }) + w.writeLine(chatResponseFrame(resp.Payload)) } else { w.writeLine(map[string]interface{}{ "type": "error", @@ -264,6 +260,40 @@ func (p *Plugin) handleChat(w *connWriter, line string, s *sdk.PluginSDK) { } } +// chatResponseFrame 把内核的同步回执转成 chat 的 response 帧。 +// +// 为何单独抽成函数:这是**无头调用方的唯一信息出口** +// (waiter -chat、基准 runner、脚本都靠它),而它此前只取了 content。 +// 抽出来才能用判据钉住「哪些字段必须透传」—— +// 症状形态是「写了、发了、不报错,只是收不到」,只有判据拦得住。 +// +// 透传字段: +// - content:回复正文; +// - reasoning_content:思考内容(非空才带,不给普通回复挂空字段); +// - usage:用量(存在且非 nil 才带 —— 缺失与 0 是两回事, +// 消费方据此显示「—」而不是把「不知道」画成 0)。 +func chatResponseFrame(payload map[string]interface{}) map[string]interface{} { + frame := map[string]interface{}{ + "type": "response", + "content": "", + } + if payload == nil { + return frame + } + if content, _ := payload["content"].(string); content != "" { + frame["content"] = content + } + if reasoning, _ := payload["reasoning_content"].(string); reasoning != "" { + frame["reasoning_content"] = reasoning + } + // 注意用「comma ok 且非 nil」而不是 != nil: + // map 里存着一个 nil 值也是常见形态,那种情况同样算「没有」。 + if usage, ok := payload["usage"]; ok && usage != nil { + frame["usage"] = usage + } + return frame +} + func (p *Plugin) cliAPIKey(s *sdk.PluginSDK) string { if s != nil { if v, _ := s.Settings().Get("api_key"); v != nil { diff --git a/internal/plugins/real_plugin_smoke_test.go b/internal/plugins/real_plugin_smoke_test.go index 6e645708..24cd04ab 100644 --- a/internal/plugins/real_plugin_smoke_test.go +++ b/internal/plugins/real_plugin_smoke_test.go @@ -264,7 +264,7 @@ func TestRealPlugin_CrashDoesNotKillKernel(t *testing.T) { // // 必须拿 plgDir 限定范围:旧实现用全系统 pgrep -f plugin.bin 后 // 只比“路径含 editdoc”,于是在跑着生产实例的机器上,它会把 - // /home/newqqagent/plugins/editdoc/plugin.bin 当成目标杀掉(实测 9 次, + // /data/homeagent/plugins/editdoc/plugin.bin 当成目标杀掉(实测 9 次, // 全部落在有人跑 go test 的时段)。更糟的是此时本测试仍会通过: // 它断言的是测试内核存活,而那个内核的插件压根没死——**它在测一件 // 没发生的事**,同时还把生产环境打坏了。 diff --git a/internal/plugins/remotedevice/registry.go b/internal/plugins/remotedevice/registry.go index cd218dbc..28a46f76 100644 --- a/internal/plugins/remotedevice/registry.go +++ b/internal/plugins/remotedevice/registry.go @@ -91,6 +91,8 @@ var capabilityTools = map[string][]string{ // 屏幕显示/查看 "screen": {"screensue", "screensee"}, "screensue": {"screensue"}, + // omniparse:GUI 设备端已实现完整 case,补进能力矩阵使其可被下发 + "omniparse": {"omniparse"}, "screensee": {"screensee"}, // 鼠标键盘操控 "computeruse": {"computeruse"}, @@ -104,8 +106,6 @@ var capabilityTools = map[string][]string{ // 音频播放 "speaker": {"speakeruse"}, "speakeruse": {"speakeruse"}, - // omniparse:GUI 设备端已实现完整 case,补进能力矩阵使其可被下发 - "omniparse": {"omniparse"}, } // compatFullCaps 视为「全能力」的历史 caps 值:声明了这些的设备不参与能力裁剪。 diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index e4fa82ba..484c8362 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -592,6 +592,17 @@ async function starmapFetchBlock(name) { try { state.kernel = await api("/kernel"); } catch (e) {} + // 初次进页就用 /kernel 的累计把徐标填上,不等第一条消息。 + // 口径说明:/kernel 的 usage 是**进程生命周期累计**, + // 与 usage_session(本会话)不同 —— 所以显式传来源标记, + // 免得两者混在一起看。 + if (state.kernel && state.kernel.usage) { + renderChatUsage( + state.kernel.usage, + null, + __("进程累计", "process total"), + ); + } break; case "settings": try { @@ -2089,7 +2100,12 @@ function buildChatLayout() { __("对话", "Chat") + '
    '; + '' + + // 用量徐标:本会话累计 token 与缓存命中率。 + // 内核一直在推 agent_llm_chain(带 usage/usage_session), + // 前端此前从未订阅 ⇒ 「发了但没人收到」,页面上看不到成本。 + ' ' + + '
    '; if (state.messages.length === 0) { html += '

    ' + @@ -4433,6 +4449,70 @@ function connectSSE() { console.error("[SSE] agent_output error", ex); } }); + // 用量账目:内核在每次 LLM 调用记账后发 agent_llm_chain, + // payload 同时带 usage(本次)与 usage_session(会话累计)。 + // + // 为何要显式订阅:后端**一直在推**这个事件(见 handler_chat.go 的 subTypes), + // 而前端从未监听它 —— 另一个「写了、发了、不报错,只是没人收到」。 + // 后果是用户看不到缓存命中率与 token 用量。 + es.addEventListener("agent_llm_chain", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + renderChatUsage(p.usage_session, p.usage, __("本会话", "this session")); + } catch (ex) { + console.error("[SSE] agent_llm_chain error", ex); + } + }); + + // 用量徐标渲染。 + // + // 口径纪律(与内核 usageLedger 同一套,不能各写一套): + // · cache_hit_rate **可能缺席** —— 那是「没有任何调用报过缓存细节」, + // 必须显示「—」而不是 0%。把「不知道」画成 0% 会让人去优化一个 + // 本来就没开的功能; + // · 缺席与「命中率为 0」是两回事,后者是有数据、真的一分没命中。 + function renderChatUsage(u, single, source) { + var el = document.getElementById("chat-usage"); + if (!el) return; + if (!u || !u.total) { + el.style.display = "none"; + return; + } + function compact(n) { + n = n || 0; + if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(1) + "k"; + return String(n); + } + var hasRate = typeof u.cache_hit_rate === "number"; + el.textContent = + compact(u.total) + + " tok · " + + (hasRate + ? __("缓存 ", "cache ") + Math.round(u.cache_hit_rate * 100) + "%" + : __("缓存 —", "cache —")); + var tip = [source || "", + __("调用 ", "calls ") + (u.calls || 0), + "prompt " + (u.prompt || 0), + "completion " + (u.completion || 0), + "cache_read " + (u.cache_read || 0), + "cache_miss " + (u.cache_miss || 0)]; + tip.push( + hasRate + ? __("命中率 ", "hit rate ") + (u.cache_hit_rate * 100).toFixed(1) + "%" + : __( + "命中率未知(上游未报缓存细节)", + "hit rate unknown (upstream reported no cache)", + ), + ); + if (single && single.total) { + tip.push(__("本次 ", "last call ") + "prompt " + (single.prompt || 0) + + " / completion " + (single.completion || 0)); + } + el.title = tip.filter(Boolean).join("\n"); + el.style.display = ""; + } // token 级流式增量:逐块追加到当前回复内容(流式生成中); // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。 es.addEventListener("content_delta", (e) => { diff --git a/internal/sdk/memory_impl.go b/internal/sdk/memory_impl.go index 583e5a21..c4b31151 100644 --- a/internal/sdk/memory_impl.go +++ b/internal/sdk/memory_impl.go @@ -3,7 +3,6 @@ package sdk import ( "fmt" "log" - "strconv" "sync/atomic" "time" @@ -202,7 +201,10 @@ func (m *graphMemory) Commit(triples []Triple) error { // 并以 sentence --contains--> block 结构边关联。 // // 不再往句子文本里写 marker、也不再从文本反解 digest:归属由结构化字段直接给出。 -func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memory.Triple) { +// ★ sentenceIDs 现在是「原句文本 → 原句**块 ID**」(原为 sentences 表行号)。 +// +// sentences 表退场后行号不存在,媒体边改挂到原句块上。 +func (m *graphMemory) bindSentences(sentenceIDs map[string]string, triples []memory.Triple) { if m.ms == nil || m.db == nil || len(sentenceIDs) == 0 { return } @@ -212,7 +214,7 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memo continue } sid := sentenceIDs[t.SentenceText] - if sid == 0 { + if sid == "" { continue } for _, b := range sdkBlocksFromDigests(m.ms, t.MediaDigests) { @@ -220,7 +222,9 @@ func (m *graphMemory) bindSentences(sentenceIDs map[string]int64, triples []memo log.Printf("[sdk media] 插件 %s 写入 L3 记忆块失败: %v", m.plugin, err) continue } - if err := m.db.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil { + // ★ 起点是**块**(原句块)而非 sentence 表行 —— + // 句子的承载者已从 sentences 表迁移到 blk_src_。 + if err := m.db.AddMemoryBlockEdge("block", sid, "block", b.ID, "contains"); err != nil { log.Printf("[sdk media] 插件 %s 建立句子→块边失败: %v", m.plugin, err) continue } diff --git a/internal/sdk/memory_impl_test.go b/internal/sdk/memory_impl_test.go index 8d3abcec..2e10f20b 100644 --- a/internal/sdk/memory_impl_test.go +++ b/internal/sdk/memory_impl_test.go @@ -2,7 +2,6 @@ package sdk import ( "path/filepath" - "strconv" "strings" "testing" @@ -131,22 +130,37 @@ func TestGraphCommit_BindsMediaFromDigests(t *testing.T) { if err != nil { t.Fatalf("Recall: %v", err) } - if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 { - t.Fatal("没有句子落点 —— 媒体块无从挂接") + // ★ 判据从 SentenceID 改成**原句文本非空**(2026-10-04) + // + // SentenceID 是**旧 sentences 表的行号**,退场后不存在, + // 而它是这个断言里唯一与「有没有句子落点」有关的字段 —— + // 于是断言恒红,而底下的媒体挂接其实一直是好的。 + // + // 真正要验证的是「关系能回溯到原句」⇒ 原句文本非空。 + // 媒体块挂接的正确性由下面 BlocksForNode 那段验。 + if len(res.Relations) == 0 || res.Relations[0].SentenceText == "" { + t.Fatalf("没有原句落点 —— 媒体块无从挂接(relations=%+v)", res.Relations) } - sid := res.Relations[0].SentenceID + // ★ 媒体的挂载点从「sentences 表行号」改成「原句块 ID」。 + // + // 变更原因:sentences 表退场后行号不存在,而 media 边要挂在 + // 「原句块 --contains--> 媒体块」上(CommitWithMedia 的返回值 + // 已随之从 map[string]int64 改为 map[string]string)。 + // + // 端点 kind 也从 "sentence" 改成 "block"。 + sid := res.Relations[0].SentenceText // 句子文本保持原样:不再往正文里贴媒体标记。 if strings.Contains(res.Relations[0].SentenceText, digest[:12]) { t.Errorf("句子文本不该被媒体标记污染: %q", res.Relations[0].SentenceText) } - blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10)) + blocks, err := g.BlocksForNode("block", memory.SentenceBlockID(sid)) if err != nil { t.Fatalf("BlocksForNode: %v", err) } if len(blocks) != 1 || blocks[0].PayloadDigest != digest { - t.Errorf("句子 #%d 的媒体块 = %+v,期望 [%s]", sid, blocks, digest) + t.Errorf("句子 %q 的媒体块 = %+v,期望 [%s]", sid, blocks, digest) } } @@ -170,7 +184,10 @@ func TestGraphCommit_DedupesRepeatedDigest(t *testing.T) { if len(res.Relations) == 0 { t.Fatal("召回不到关系") } - blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10)) + // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 + // (CommitWithMedia 返回值随之改为 map[string]string) + blocks, err := g.BlocksForNode("block", + memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatal(err) } @@ -198,7 +215,10 @@ func TestGraphCommit_NilMediaStoreDegrades(t *testing.T) { if len(res.Relations) == 0 { t.Fatal("召回不到关系") } - blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10)) + // ★ 挂载点已从「sentences 表行号」改成「原句块 ID」 + // (CommitWithMedia 返回值随之改为 map[string]string) + blocks, err := g.BlocksForNode("block", + memory.SentenceBlockID(res.Relations[0].SentenceText)) if err != nil { t.Fatal(err) } diff --git a/internal/sdk/status.go b/internal/sdk/status.go index c5f58baf..709cc6d0 100644 --- a/internal/sdk/status.go +++ b/internal/sdk/status.go @@ -61,6 +61,45 @@ type KernelStatus struct { // M2 起输入不再直接排队在 channel 上,而是经 readyQueue/pendingInterrupts/ // suspendStack 三集合按优先级调度;这里把这些状态暴露出来。 Scheduler SchedulerStatus `json:"scheduler"` + + // Usage 是本进程的**累计**用量账目(token 与缓存命中)。 + // + // 为何单列:内核早已在每次 LLM 调用后记账(agent.core 的 usageLedger), + // 但运行态里看不到 —— 回答「这个实例花了多少、缓存省了多少」只能翻日志。 + // 放进状态快照后,/kernel、/api/v1/kernel、healthcheck_kernel 就都读得到。 + // + // 口径:这是**进程生命周期内跨请求的累计**,与 /v1/chat/completions 回包里的 + // usage(单次请求)不同,两者不可互换。 + Usage UsageStatus `json:"usage"` +} + +// UsageStatus 是内核累计用量的对外视图。 +// +// 字段语义与 agent.core 的 UsageTotals 一致(同一份数据源), +// 放在 SDK 侧是为了让插件/外部调用方能读(Agent 内部类型不可导出)。 +type UsageStatus struct { + // Calls 是累计 LLM 调用次数(含用量为 0 的调用)。 + Calls int64 `json:"calls"` + // Prompt / Completion / Total 是累计 token 数。 + Prompt int64 `json:"prompt"` + Completion int64 `json:"completion"` + Total int64 `json:"total"` + // CacheRead 是累计命中缓存的输入 token(即省下来未计算的部分)。 + CacheRead int64 `json:"cache_read"` + // CacheMiss 是上游明确报告的未命中输入 token。 + CacheMiss int64 `json:"cache_miss"` + // Reasoning 是累计的思考 token(计费输出里属于思考的部分)。 + Reasoning int64 `json:"reasoning"` + // CacheReportedCalls 是其中**上游报告了缓存字段**的调用数(命中率的分母)。 + CacheReportedCalls int64 `json:"cache_reported_calls"` + + // CacheHitRate 只在**有调用报过缓存**时给出,否则为 nil。 + // + // 为何用指针而非 float64:必须能区分「命中率 0」与「无数据」。 + // 混为一谈会把「没开缓存」显示成「命中率 0%」, + // 让人去优化一个本来就没开的功能 —— 拿假数据做的决定。 + // nil 时 omitempty 让该键根本不出现,消费方据此显示「—」。 + CacheHitRate *float64 `json:"cache_hit_rate,omitempty"` } // SchedulerStatus 是调度器的原子快照 DTO。 diff --git a/internal/system/system_test.go b/internal/system/system_test.go index 8118c86d..add823b3 100644 --- a/internal/system/system_test.go +++ b/internal/system/system_test.go @@ -23,12 +23,12 @@ func TestIsProtectedPath(t *testing.T) { } } // 发行版/部署路径注入:显式前缀集可扩展受保护范围 - SetProtectedPaths([]string{"/opt/llm-mock", "/home/newqqagent"}) + SetProtectedPaths([]string{"/opt/llm-mock", "/data/homeagent"}) if !IsProtectedPath("/opt/llm-mock/mock_server.py") { t.Error("explicit prefix /opt/llm-mock should be protected") } - if !IsProtectedPath("/home/newqqagent/config.yaml") { - t.Error("explicit prefix /home/newqqagent should be protected") + if !IsProtectedPath("/data/homeagent/config.yaml") { + t.Error("explicit prefix /data/homeagent should be protected") } SetProtectedPaths(nil) // 恢复默认 if IsProtectedPath("/opt/llm-mock/mock_server.py") { diff --git a/pkg/generation/generation.go b/pkg/generation/generation.go new file mode 100644 index 00000000..7e3f6ac3 --- /dev/null +++ b/pkg/generation/generation.go @@ -0,0 +1,144 @@ +// Package generation defines the public SPI for text-generation providers used +// by background pipelines (notably triple distillation). +// +// The HomeAgent core depends only on this package. Model runtimes, tokenizers, +// prompt templates, sampling, and model-specific configuration belong in +// provider packages registered with Register. +// +// This mirrors pkg/embedding deliberately: a model may implement both SPIs from +// one weight set (an embedding front half + a generation head), registering +// under each. The two registries stay independent so a build can select an +// embedding provider and a generation provider separately — or the same model +// for both. +package generation + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" +) + +// Request is the model-neutral generation request. +// +// Prompt is the fully-rendered input text; the core does not apply chat +// templates. A provider whose model needs a chat template applies it from +// Prompt internally. JSONSchema, when non-empty, asks the provider to constrain +// output to that JSON Schema (providers that cannot MUST return +// ErrSchemaUnsupported rather than silently ignoring it — a silently ignored +// schema produces free-form text that the caller parses as JSON and fails on). +type Request struct { + Prompt string + MaxTokens int + Temperature float64 + Stop []string + // JSONSchema is an optional JSON Schema (as a JSON string). Empty means + // unconstrained text. + JSONSchema string +} + +// Response is the model-neutral generation result. +type Response struct { + Text string + // Truncated reports that generation stopped at MaxTokens rather than a + // natural stop. Callers parsing structured output should treat a truncated + // response as suspect. + Truncated bool +} + +// Info describes one generation provider's identity. +type Info struct { + // Model is a human-readable identifier for diagnostics/logging. + Model string + // SupportsJSONSchema reports whether Generate honors Request.JSONSchema. + SupportsJSONSchema bool +} + +// Provider is the public Go extension point for text generation. +// +// Implementations must be safe for concurrent Generate calls unless their +// factory documents otherwise and serializes internally. +type Provider interface { + Generate(context.Context, Request) (Response, error) + Info() Info + Close() +} + +// Config contains provider-owned options. The core does not interpret option +// names or values; it passes core.memory.distill.generation.options.* through +// after stripping the prefix. +type Config struct { + Options map[string]string +} + +// Factory constructs a provider instance. +type Factory func(Config) (Provider, error) + +var ( + // ErrSchemaUnsupported means the provider cannot constrain output to a JSON + // Schema. Callers must not assume the returned text is valid JSON. + ErrSchemaUnsupported = errors.New("generation: json schema not supported") + + registryMu sync.RWMutex + registry = make(map[string]Factory) +) + +// Register makes a provider factory available under name. It is normally called +// from a provider package's init function. Duplicate names panic so a build +// cannot silently select whichever package initialized last. +func Register(name string, factory Factory) { + name = strings.TrimSpace(name) + if name == "" { + panic("generation: register empty provider name") + } + if factory == nil { + panic("generation: register nil factory for " + name) + } + registryMu.Lock() + defer registryMu.Unlock() + if _, exists := registry[name]; exists { + panic("generation: provider already registered: " + name) + } + registry[name] = factory +} + +// Open constructs a registered provider. +func Open(name string, cfg Config) (Provider, error) { + name = strings.TrimSpace(name) + registryMu.RLock() + factory := registry[name] + registryMu.RUnlock() + if factory == nil { + return nil, fmt.Errorf("generation: unknown provider %q (available: %s)", name, strings.Join(Names(), ", ")) + } + provider, err := factory(cloneConfig(cfg)) + if err != nil { + return nil, fmt.Errorf("generation: open provider %q: %w", name, err) + } + if provider == nil { + return nil, fmt.Errorf("generation: provider %q returned nil", name) + } + return provider, nil +} + +// Names returns registered provider names in deterministic order. +func Names() []string { + registryMu.RLock() + defer registryMu.RUnlock() + names := make([]string, 0, len(registry)) + for name := range registry { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func cloneConfig(cfg Config) Config { + out := Config{Options: make(map[string]string, len(cfg.Options))} + for key, value := range cfg.Options { + out.Options[key] = value + } + return out +} diff --git a/providers/chineseclip/embedder.go b/providers/chineseclip/embedder.go index 8cd1cdb3..f4e8f5c9 100644 --- a/providers/chineseclip/embedder.go +++ b/providers/chineseclip/embedder.go @@ -43,12 +43,40 @@ type Embedder struct { closeOnce sync.Once } +// findModelDir 解析产物目录:配置值缺失/不存在时按候选链回退, +// 都不命中时返回空串与**可执行**的指引(错误信息要能直接照着做, +// 而不是一句「读取失败」让用户自己去猜模型从哪来)。 +// +// 背景教训(2026-09-30 跑分实测):全新数据目录启动时模型缺失, +// 内核只打一行 warning 就静默禁用稠密检索,照常服务 —— 用户不知道 +// 自己少了什么,跑分台跑了 50 分钟残废配置才发现。 +func findModelDir(configured string) (string, string) { + var candidates []string + if configured = strings.TrimSpace(configured); configured != "" { + candidates = append(candidates, configured) + } + candidates = append(candidates, + "/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx", // 发行包安装位置 + ) + for _, p := range candidates { + if _, err := os.Stat(filepath.Join(p, "embed_config.json")); err == nil { + return p, "" + } + } + hint := "chineseclip: 产物目录不可用(依次尝试: " + strings.Join(candidates, ", ") + ")。获得模型任选其一:\n" + + " ① 运行 scripts/export_chineseclip_onnx.py 导出,产物放到上述任一路径\n" + + " ② 从已有实例的 /models/chinese-clip-vit-b16-onnx 拷贝或 symlink\n" + + " ③ 安装发行包(自带 /usr/lib/homeagent/models/)" + return "", hint +} + // New 从产物目录构造 provider。 func New(modelDir string) (*Embedder, error) { - modelDir = strings.TrimSpace(modelDir) - if modelDir == "" { - return nil, fmt.Errorf("chineseclip: 未配置 model_dir(产物目录)") + resolved, hint := findModelDir(modelDir) + if resolved == "" { + return nil, fmt.Errorf("%s", hint) } + modelDir = resolved cfg, err := loadConfig(modelDir) if err != nil { return nil, err diff --git a/providers/chineseclip/embedder_onnx_test.go b/providers/chineseclip/embedder_onnx_test.go index 0a4fabc5..740d0092 100644 --- a/providers/chineseclip/embedder_onnx_test.go +++ b/providers/chineseclip/embedder_onnx_test.go @@ -11,6 +11,7 @@ import ( "image/png" "math" "os" + "strings" "testing" "gitcode.com/JianFeeeee/HomeAgent/pkg/embedding" @@ -253,10 +254,25 @@ func TestFingerprintStable(t *testing.T) { } } -// 缺 model_dir 必须明确报错(便于区分「没配置」与「模型坏了」)。 +// 缺 model_dir 时不得静默成功打开一个“空产物”。 +// +// 2026-10-01 语义变更:New 增加了 findModelDir 回退链(configured → +// /usr/lib/homeagent/models)。若本机装了系统级产物,回退命中属合法行为, +// 但打开的必须是那个真实产物(指纹非空);若系统目录也没有,则必须报错, +// 且错误里要带可执行指引(export 脚本名)。 func TestOpenRejectsMissingModelDir(t *testing.T) { - if _, err := embedding.Open("chineseclip", embedding.Config{}); err == nil { - t.Fatal("缺 model_dir 时应打开失败") + got, err := embedding.Open("chineseclip", embedding.Config{}) + if err == nil { + // 回退命中了系统目录:允许,但必须是真的产物而不是空壳。 + defer got.Close() + if got.Info().Fingerprint == "" { + t.Fatal("回退打开成功但指纹为空(打开了个空壳?)") + } + return + } + // 未命中回退 ⇒ 必须报错且给可执行指引。 + if !strings.Contains(err.Error(), "export_chineseclip_onnx.py") { + t.Fatalf("错误应带可执行指引(export 脚本名),got: %v", err) } } diff --git a/providers/chineseclip/modeldir_test.go b/providers/chineseclip/modeldir_test.go new file mode 100644 index 00000000..125e9327 --- /dev/null +++ b/providers/chineseclip/modeldir_test.go @@ -0,0 +1,49 @@ +//go:build onnxruntime + +package chineseclip + +import ( + "os" + "path/filepath" + "testing" +) + +// findModelDir 的回退链与错误指引。 +func TestFindModelDir_ConfiguredWins(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "embed_config.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + got, hint := findModelDir(dir) + if got != dir || hint != "" { + t.Fatalf("configured 路径应直接命中: got=%q hint=%q", got, hint) + } +} + +func TestFindModelDir_FallbackToSystemDir(t *testing.T) { + // configured 不存在 ⇒ 回退到系统目录。若本机恰好有 /usr/lib/homeagent 模型则命中它, + // 否则两个候选都落空 ⇒ 返回空 + 指引。两种结果都合法,断言的是「不空则命中系统路径」。 + got, hint := findModelDir(t.TempDir()) + if got != "" { + if got != "/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx" { + t.Fatalf("回退命中了意外路径: %q", got) + } + return + } + if hint == "" || !contains(hint, "export_chineseclip_onnx.py") { + t.Fatalf("全落空时必须给可执行指引,got hint=%q", hint) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/providers/ollama/generator.go b/providers/ollama/generator.go new file mode 100644 index 00000000..63485f2c --- /dev/null +++ b/providers/ollama/generator.go @@ -0,0 +1,212 @@ +// Package ollamagen implements the generation.Provider SPI over a local Ollama +// HTTP endpoint. +// +// It lives outside the HomeAgent core: the core depends only on pkg/generation +// and selects this provider by name through configuration. Prompt rendering, +// JSON-schema constraint, sampling, and the Ollama wire format are all owned +// here. +// +// Why Ollama rather than an in-process ONNX runtime for generation: the +// distillation path runs on a 30-minute timer, not the request hot path, so a +// local HTTP round-trip is acceptable; and the structured-output constraint +// (format=) that makes a 1.7B model usable for triple extraction is a +// first-class Ollama feature. Measured on this host: qwen3:1.7b with a JSON +// schema produced 30/30 field pairs with 0 hallucinations across 6 real +// records of varied structure, versus free-form prompting which either emitted +// chat filler (0 parsed pairs) or replayed few-shot example answers (100% +// hallucination on structurally-dissimilar records). +package ollamagen + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" +) + +const ( + defaultEndpoint = "http://localhost:11434/api/generate" + defaultModel = "qwen3:1.7b" + defaultKeepAlive = "30m" + defaultThreads = 10 + defaultTimeout = 240 * time.Second +) + +func init() { + generation.Register("ollama", func(cfg generation.Config) (generation.Provider, error) { + return New(cfg) + }) +} + +// Provider talks to a local Ollama server over HTTP. +type Provider struct { + endpoint string + model string + keepAlive string + threads int + numCtx int + timeout time.Duration + client *http.Client +} + +// New builds a provider from options. Recognized options: +// +// endpoint Ollama generate URL (default http://localhost:11434/api/generate) +// model model tag (default qwen3:1.7b) +// keep_alive model residency hint (default 30m) — avoids per-call reload +// num_thread CPU threads (default 10) — critical: the Ollama default badly +// under-threads on this 12-core host (0.1 tok/s vs 2-3 tok/s) +// num_ctx context window in tokens (default 1024) +// timeout_sec per-request timeout seconds (default 240) +func New(cfg generation.Config) (*Provider, error) { + p := &Provider{ + endpoint: optString(cfg, "endpoint", defaultEndpoint), + model: optString(cfg, "model", defaultModel), + keepAlive: optString(cfg, "keep_alive", defaultKeepAlive), + threads: optInt(cfg, "num_thread", defaultThreads), + numCtx: optInt(cfg, "num_ctx", 1024), + timeout: time.Duration(optInt(cfg, "timeout_sec", int(defaultTimeout/time.Second))) * time.Second, + } + if p.model == "" { + return nil, fmt.Errorf("ollamagen: model is empty") + } + p.client = &http.Client{Timeout: p.timeout} + return p, nil +} + +type genRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream"` + Think bool `json:"think"` + KeepAlive string `json:"keep_alive,omitempty"` + Format json.RawMessage `json:"format,omitempty"` + Options genOptions `json:"options"` +} + +type genOptions struct { + Temperature float64 `json:"temperature"` + NumPredict int `json:"num_predict"` + NumCtx int `json:"num_ctx"` + NumThread int `json:"num_thread"` + Stop []string `json:"stop,omitempty"` +} + +type genResponse struct { + Response string `json:"response"` + Done bool `json:"done"` + DoneReason string `json:"done_reason"` + Error string `json:"error"` +} + +// Generate runs one non-streaming completion. +// +// When req.JSONSchema is set it is passed through as Ollama's `format` field, +// which constrains decoding to that schema. This is the mechanism that makes a +// small model reliable for structured extraction; without it the model emits +// prose. Unlike a provider that cannot honor a schema, this one does — so it +// never returns ErrSchemaUnsupported. +func (p *Provider) Generate(ctx context.Context, req generation.Request) (generation.Response, error) { + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 256 + } + body := genRequest{ + Model: p.model, + Prompt: req.Prompt, + Stream: false, + Think: false, + KeepAlive: p.keepAlive, + Options: genOptions{ + Temperature: req.Temperature, + NumPredict: maxTokens, + NumCtx: p.numCtx, + NumThread: p.threads, + Stop: req.Stop, + }, + } + if s := strings.TrimSpace(req.JSONSchema); s != "" { + // Ollama accepts either the literal string "json" or a schema object. + // A caller-supplied schema is passed verbatim; it must be valid JSON. + if !json.Valid([]byte(s)) { + return generation.Response{}, fmt.Errorf("ollamagen: JSONSchema is not valid JSON") + } + body.Format = json.RawMessage(s) + } + + payload, err := json.Marshal(body) + if err != nil { + return generation.Response{}, fmt.Errorf("ollamagen: marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(payload)) + if err != nil { + return generation.Response{}, fmt.Errorf("ollamagen: build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(httpReq) + if err != nil { + return generation.Response{}, fmt.Errorf("ollamagen: request %s: %w", p.model, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return generation.Response{}, fmt.Errorf("ollamagen: read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return generation.Response{}, fmt.Errorf("ollamagen: http %d: %s", resp.StatusCode, truncate(string(raw), 200)) + } + + var out genResponse + if err := json.Unmarshal(raw, &out); err != nil { + return generation.Response{}, fmt.Errorf("ollamagen: decode response: %w", err) + } + if out.Error != "" { + return generation.Response{}, fmt.Errorf("ollamagen: model error: %s", out.Error) + } + return generation.Response{ + Text: out.Response, + Truncated: out.DoneReason == "length", + }, nil +} + +// Info reports the model id and that schema-constrained output is supported. +func (p *Provider) Info() generation.Info { + return generation.Info{Model: p.model, SupportsJSONSchema: true} +} + +// Close is a no-op: the HTTP client holds no persistent model state. Residency +// is governed by the server's keep_alive, not this process. +func (p *Provider) Close() {} + +func optString(cfg generation.Config, key, def string) string { + if v, ok := cfg.Options[key]; ok && strings.TrimSpace(v) != "" { + return v + } + return def +} + +func optInt(cfg generation.Config, key string, def int) int { + if v, ok := cfg.Options[key]; ok { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + return n + } + } + return def +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/providers/ollama/generator_test.go b/providers/ollama/generator_test.go new file mode 100644 index 00000000..a53d5968 --- /dev/null +++ b/providers/ollama/generator_test.go @@ -0,0 +1,165 @@ +package ollamagen + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" +) + +// 测试不依赖真模型:起一个本地 httptest server 模拟 Ollama, +// 断言 Go 侧发出的请求形态(schema 透传、线程参数、think:false)与解析正确。 + +func newTestProvider(t *testing.T, handler http.HandlerFunc) (*Provider, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + p, err := New(generation.Config{Options: map[string]string{ + "endpoint": srv.URL + "/api/generate", + }}) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(p.Close) + return p, srv +} + +// ★ 关键契约:JSONSchema 必须原样透传成 Ollama 的 format 字段。 +// 实测证明这是 1.7b 零幻觉的唯一机制——丢了它模型就输出聊天腔。 +func TestGenerate_透传Schema到Format(t *testing.T) { + schema := `{"type":"object","properties":{"fields":{"type":"array"}}}` + var gotFormat map[string]any + var gotThink bool + var gotThread int = -1 + + p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) { + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode: %v", err) + } + if f, ok := req["format"].(map[string]any); ok { + gotFormat = f + } + gotThink, _ = req["think"].(bool) + if opts, ok := req["options"].(map[string]any); ok { + if v, ok := opts["num_thread"].(float64); ok { + gotThread = int(v) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": `{"fields":[]}`, "done": true, "done_reason": "stop", + }) + }) + + resp, err := p.Generate(context.Background(), generation.Request{ + Prompt: "测试", MaxTokens: 64, JSONSchema: schema, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + // schema 作为对象透传(json.RawMessage),不是字符串包装 + if gotFormat == nil { + t.Error("format 未透传:服务端未收到 format 字段") + } else if _, ok := gotFormat["properties"]; !ok { + t.Errorf("format 应保留 schema 的 properties 键,实际 %v", gotFormat) + } + if gotThink { + t.Errorf("think 应为 false(实测不关思考 1.7b 会先输出大段思考再答案)") + } + if gotThread != defaultThreads { + t.Errorf("num_thread 应默认 %d,实际 %v", defaultThreads, gotThread) + } + if resp.Text != `{"fields":[]}` { + t.Errorf("Text=%q", resp.Text) + } + if resp.Truncated { + t.Errorf("stop 不应报 Truncated") + } +} + +// 非法 schema 必须在本地拒绝,而不是发出去让服务端报含糊的错。 +func TestGenerate_非法Schema本地拒绝(t *testing.T) { + p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("不应发出请求") + }) + _, err := p.Generate(context.Background(), generation.Request{ + Prompt: "x", JSONSchema: "{not json", + }) + if err == nil || !strings.Contains(err.Error(), "not valid JSON") { + t.Fatalf("应报 schema 非法,实际: %v", err) + } +} + +// HTTP 错误要带状态码和响应片段,不能只给 "request failed"。 +func TestGenerate_HTTP错误带响应体(t *testing.T) { + p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"model busy"}`)) + }) + _, err := p.Generate(context.Background(), generation.Request{Prompt: "x"}) + if err == nil || !strings.Contains(err.Error(), "503") || !strings.Contains(err.Error(), "model busy") { + t.Fatalf("错误信息应含状态码与响应体,实际: %v", err) + } +} + +// 服务端报 error 字段时必须转发给调用方。 +func TestGenerate_服务端Error字段(t *testing.T) { + p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"error": "model not found"}) + }) + _, err := p.Generate(context.Background(), generation.Request{Prompt: "x"}) + if err == nil || !strings.Contains(err.Error(), "model not found") { + t.Fatalf("应转发服务端 error,实际: %v", err) + } +} + +// done_reason=length 必须映射成 Truncated,让调用方对截断的结构化输出起疑。 +func TestGenerate_截断标记(t *testing.T) { + p, _ := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": `{"fields":[{"name":"a","value":"1"},{"name":"b","va`, + "done": true, "done_reason": "length", + }) + }) + resp, err := p.Generate(context.Background(), generation.Request{Prompt: "x"}) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !resp.Truncated { + t.Fatal("done_reason=length 应标记 Truncated") + } +} + +// SPI 注册表联通:名字必须能从注册表里 Open 出来(核心的引用方式)。 +func TestProvider通过SPI注册表打开(t *testing.T) { + if _, err := generation.Open("__不存在__", generation.Config{}); err == nil { + t.Fatal("未知 provider 应报错") + } + // ollama 注册本身不建连接(New 不 ping),可以安全 Open + p, err := generation.Open("ollama", generation.Config{Options: map[string]string{ + "model": "test-model", + }}) + if err != nil { + t.Fatalf("Open(ollama): %v", err) + } + defer p.Close() + if info := p.Info(); info.Model != "test-model" || !info.SupportsJSONSchema { + t.Errorf("Info=%+v", info) + } +} + +// 未设/空白 model 回退到默认模型,而不是构造出一个空模型名的 provider。 +func TestNew_空模型名回退默认(t *testing.T) { + p, err := New(generation.Config{Options: map[string]string{"model": " "}}) + if err != nil { + t.Fatalf("空白 model 应回退默认而非失败: %v", err) + } + defer p.Close() + if p.Info().Model != defaultModel { + t.Errorf("应回退默认 %q,实际 %q", defaultModel, p.Info().Model) + } +} diff --git a/providers/qwen3vl/embedder.go b/providers/qwen3vl/embedder.go index d3eb60b3..c03a9c9a 100644 --- a/providers/qwen3vl/embedder.go +++ b/providers/qwen3vl/embedder.go @@ -97,7 +97,7 @@ func New(modelDir string) (*Embedder, error) { return nil, err } if !ort.IsInitialized() { - if lib := findOnnxLib(); lib != "" { + if lib := FindOnnxLib(); lib != "" { ort.SetSharedLibraryPath(lib) } if err := ort.InitializeEnvironment(); err != nil { @@ -615,7 +615,13 @@ func computeFingerprint(modelDir string) string { return hex.EncodeToString(h.Sum(nil)) } -func findOnnxLib() string { +// FindOnnxLib 返回本机 onnxruntime 共享库路径,找不到时返回空串。 +// +// 导出给同模型族的其它 provider 用(如 providers/qwen3vlgen): +// 库路径的候选清单(含发行包内置位置与两个历史环境变量)必须**只有一份** —— +// 各写一份的话,新增安装位置时必然漏掉某一个,而漏掉的表现是 +// 「初始化失败」这种看不出原因的错误。 +func FindOnnxLib() string { var candidates []string for _, env := range []string{"ONNXRUNTIME_DIR", "ONNX_ML_DIR"} { if dir := strings.TrimSpace(os.Getenv(env)); dir != "" { diff --git a/providers/qwen3vl/embedder_onnx_test.go b/providers/qwen3vl/embedder_onnx_test.go index 64095285..87572f74 100644 --- a/providers/qwen3vl/embedder_onnx_test.go +++ b/providers/qwen3vl/embedder_onnx_test.go @@ -26,7 +26,7 @@ func onnxModelDir() string { if v := os.Getenv("QWEN_ONNX_MODEL_DIR"); v != "" { return v } - return "/home/newqqagent/models/qwen3-vl-embed-multimodal-onnx" + return envOr("QWEN3VL_MODEL_DIR", "/opt/models/qwen3-vl-embed-multimodal-onnx") } // artifactDeclaresVideo 读产物自带的 embed_config.json,判断它是否声明支持原生视频。 diff --git a/providers/qwen3vl/tokenizer.go b/providers/qwen3vl/tokenizer.go index 433ae4b4..be23ae4e 100644 --- a/providers/qwen3vl/tokenizer.go +++ b/providers/qwen3vl/tokenizer.go @@ -42,7 +42,10 @@ type specialToken struct { // Tokenizer 是千问的字节级 BPE 分词器。 type Tokenizer struct { vocab map[string]int - ranks map[string]int + // idToToken 是 vocab 的反向表,供 DecodeOne 用。 + // 必须显式建:遍历 map 得到的顺序不确定,而生成输出要逐 token 确定。 + idToToken map[int]string + ranks map[string]int // byteEnc 是 GPT-2 的 byte→unicode 映射:把 0..255 每个字节映到一个 // 「可见且不会与正常文本冲突」的 unicode 码点。因为 BPE 词表基于文本构建, @@ -84,6 +87,11 @@ func LoadTokenizer(modelDir string) (*Tokenizer, error) { return nil, fmt.Errorf("tokenizer.json 的 model.vocab 为空") } + idToToken := make(map[int]string, len(tj.Model.Vocab)) + for tok, id := range tj.Model.Vocab { + idToToken[id] = tok + } + ranks := make(map[string]int, len(tj.Model.Merges)) for i, m := range tj.Model.Merges { // merges 有两种形态:字符串 "a b",或数组 ["a","b"]。 @@ -106,10 +114,11 @@ func LoadTokenizer(modelDir string) (*Tokenizer, error) { } t := &Tokenizer{ - vocab: tj.Model.Vocab, - ranks: ranks, - byteEnc: bytesToUnicode(), - MaxLen: 512, + vocab: tj.Model.Vocab, + idToToken: idToToken, + ranks: ranks, + byteEnc: bytesToUnicode(), + MaxLen: 512, } for _, at := range tj.AddedTokens { if at.Special && at.Content != "" { @@ -127,6 +136,43 @@ func LoadTokenizer(modelDir string) (*Tokenizer, error) { func (t *Tokenizer) VocabSize() int { return len(t.vocab) } // SpecialID 返回特殊 token 的 id;不存在时 ok=false。 +// DecodeOne 把单个 token id 反解成文本片段。 +// +// 生成侧(providers/qwen3vlgen)需要它把 argmax 出来的 id 还原成文本。 +// 本包此前只做正向编码(embedding 侧从不需要解码),词表是 map[string]int, +// 反向要用它自建的 idToToken 表 —— 不能靠遍历 vocab(那样顺序不确定, +// 而生成输出必须逐 token 确定)。 +func (t *Tokenizer) DecodeOne(id int) string { + if s, ok := t.idToToken[id]; ok { + return decodeBytes(s, t.byteEnc) + } + // 词表里没有:特殊 token 走 specials 表(按 content 匹配)。 + for _, sp := range t.specials { + if sp.id == id { + return sp.content + } + } + return "" +} + +// decodeBytes 把 GPT-2 byte↔unicode 表反解回原始字节再转 UTF-8。 +func decodeBytes(s string, byteEnc map[byte]rune) string { + inv := make(map[rune]byte, len(byteEnc)) + for b, r := range byteEnc { + inv[r] = b + } + out := make([]byte, 0, len(s)) + for _, r := range s { + b, ok := inv[r] + if !ok { + out = append(out, string(r)...) + continue + } + out = append(out, b) + } + return string(out) +} + func (t *Tokenizer) SpecialID(content string) (int, bool) { for _, s := range t.specials { if s.content == content { diff --git a/providers/qwen3vl/tokenizer_decode_test.go b/providers/qwen3vl/tokenizer_decode_test.go new file mode 100644 index 00000000..20f9408d --- /dev/null +++ b/providers/qwen3vl/tokenizer_decode_test.go @@ -0,0 +1,104 @@ +//go:build onnxruntime + +package qwen3vl + +import ( + "os" + "strings" + "testing" +) + +// 生成侧(providers/qwen3vlgen)依赖 DecodeOne 把 argmax token id 还原成文本。 +// 本包此前只做正向编码,这层是新增的 —— 编码错位会让生成输出乱码而不报错, +// 所以必须双向都能钉住。 + +// 复用 embedder_onnx_test.go 的 QWEN_ONNX_MODEL_DIR 约定:模型目录有 12GB, +// 不可能入库,测试靠环境变量指向。 +func loadTestTokenizer(t *testing.T) *Tokenizer { + t.Helper() + dir := os.Getenv("QWEN_ONNX_MODEL_DIR") + if dir == "" { + t.Skip("跳过:未设置 QWEN_ONNX_MODEL_DIR") + } + if _, err := os.Stat(dir + "/tokenizer.json"); err != nil { + t.Skipf("跳过:%s 下无 tokenizer.json", dir) + } + tok, err := LoadTokenizer(dir) + if err != nil { + t.Fatalf("LoadTokenizer: %v", err) + } + return tok +} + +// ★ 双向一致性:Encode 后能原样 Decode 回来。 +// +// 判据必须用「往返相等」而不是「能解出非空」——后者对任何乱码都成立。 +func TestDecodeOne_往返一致(t *testing.T) { + tok := loadTestTokenizer(t) + cases := []string{ + "第183批", + "告警规则9条", + "值班手册第4版", + "容量预警70%", + "排期10月", + "混合 mixed 123", + "换行\n与制表\t", + } + for _, want := range cases { + ids := tok.Encode(want) + if len(ids) == 0 { + t.Errorf("Encode(%q) 为空", want) + continue + } + var got strings.Builder + for _, id := range ids { + got.WriteString(tok.DecodeOne(id)) + } + if got.String() != want { + t.Errorf("往返不等:Encode→Decode 得 %q,期望 %q", got.String(), want) + } + } +} + +// 单 token 解码必须确定:同样输入两次调用结果一致(生成输出要逐 token 确定)。 +func TestDecodeOne_确定性(t *testing.T) { + tok := loadTestTokenizer(t) + ids := tok.Encode("第183批 停机4分") + for _, id := range ids { + first := tok.DecodeOne(id) + for i := 0; i < 5; i++ { + if got := tok.DecodeOne(id); got != first { + t.Fatalf("token %d 解码不确定:%q vs %q", id, first, got) + } + } + } +} + +// 未在词表里的 id 必须返回空串,不能 panic、不能吐乱码。 +func TestDecodeOne_未知id返回空(t *testing.T) { + tok := loadTestTokenizer(t) + for _, id := range []int{-1, 999999999} { + if got := tok.DecodeOne(id); got != "" { + t.Errorf("未知 id %d 应返回空串,实际 %q", id, got) + } + } +} + +// ★ 变异自证:若 DecodeOne 用遍历 map 反查(顺序不确定), +// 或者解码退化成只返回单字节,本测试必须变红。 +func TestDecodeOne_不是遍历反查(t *testing.T) { + tok := loadTestTokenizer(t) + // 找一个多字节 token:若按字节解码会碎成多个乱码字符 + ids := tok.Encode("记录") + if len(ids) == 0 { + t.Skip("无 token") + } + decoded := tok.DecodeOne(ids[0]) + // 中文字符是 3 字节:按字节解码会得到 3 个替换字符 + if len([]rune(decoded)) == 0 { + t.Fatal("解码为空") + } + if strings.ContainsRune(decoded, 0xFFFD) { + t.Errorf("解码出现替换字符 U+FFFD ⇒ 走了按字节解码:%q", decoded) + } +} diff --git a/providers/qwen3vl/tokenizer_test.go b/providers/qwen3vl/tokenizer_test.go index 040fb036..b3d2a8be 100644 --- a/providers/qwen3vl/tokenizer_test.go +++ b/providers/qwen3vl/tokenizer_test.go @@ -10,7 +10,7 @@ import ( // modelDir 是本地千问模型目录。不存在则跳过——参考数据已固化在 testdata, // 但分词器本身要从 tokenizer.json 加载词表与 merges(11MB,不入库)。 -const modelDir = "/home/newqqagent/models/models/qwen--Qwen3-VL-Embedding-2B/snapshots/master" +const modelDir = "/opt/models/qwen3-vl-embedding-2b" // 环境变量 QWEN3VL_MODEL_DIR 可覆盖 type tokenizerRef struct { VocabSize int `json:"vocab_size"` diff --git a/providers/qwen3vlgen/generator.go b/providers/qwen3vlgen/generator.go new file mode 100644 index 00000000..67be0f9b --- /dev/null +++ b/providers/qwen3vlgen/generator.go @@ -0,0 +1,403 @@ +//go:build onnxruntime + +// Package qwen3vlgen implements the generation.Provider SPI on the same +// Qwen3-VL weights this package already uses for embeddings. +// +// 一份权重,两种模式 +// ------------------ +// 向量(高频,检索时):TokenEmbedding.onnx + Transformer.onnx(末 token 池化) +// 生成(低频,蒸馏时):TokenEmbedding.onnx + SequenceWithHead.onnx(tied head) +// +// 两者共享 TokenEmbedding 段与全部 28 层 Transformer 的权重语义;生成侧 +// 多出来的那张图只是「不池化 + 接 tied head」。embed_config.json 的 +// `generation` 段声明了这个能力,Open 时据此判断模型目录是否可用作生成。 +// +// tied lm_head:零新增权重 +// ---------------------- +// Qwen3-VL 的 tie_word_embeddings=True,输出层就是 embed_tokens 转置 +// (实测 safetensors 里 625 个张量没有独立 lm_head)。embed_tokens 已在 +// TokenEmbedding.onnx 里,因此输出层不需要另一份权重。 +// +// 没有 KV cache +// ------------- +// 自回归解码若每步全量前向是 O(N²)。拆分任务的输出极短(实测 3~7 行, +// N≈200),且蒸馏是定时低频任务,不是检索热路径。为此引入 KV cache +// 状态机会让实现复杂度翻倍,收益只在这条低频路径上。 +// +// 若生成侧将来进入热路径,先实测 O(N²) 的实际延迟再决定。 +package qwen3vlgen + +import ( + "context" + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync" + + ort "github.com/yalue/onnxruntime_go" + + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" + "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl" +) + +func init() { + generation.Register("qwen3vl", func(cfg generation.Config) (generation.Provider, error) { + return New(cfg.Options["model_dir"]) + }) +} + +type genConfig struct { + Generation struct { + Graph string `json:"graph"` + TiedLMHead bool `json:"tied_lm_head"` + KVCache bool `json:"kv_cache"` + Outputs string `json:"outputs"` + } `json:"generation"` + Dim int `json:"dim"` + MaxLength int `json:"max_length"` + RopeTheta float64 `json:"rope_theta"` + MRopeSection []int `json:"mrope_section"` +} + +// Generator 是同一份 Qwen3-VL 权重上的生成侧。 +type Generator struct { + mu sync.Mutex + + dir string + cfg genConfig + tok *qwen3vl.Tokenizer + token *ort.DynamicAdvancedSession + seqHead *ort.DynamicAdvancedSession + fp string + close sync.Once +} + +// New 打开一个生成侧实例。modelDir 必须是含 embed_config.json 与 +// SequenceWithHead.onnx 的导出目录。 +func New(modelDir string) (*Generator, error) { + if modelDir == "" { + return nil, fmt.Errorf("qwen3vlgen: model dir not specified") + } + raw, err := os.ReadFile(filepath.Join(modelDir, "embed_config.json")) + if err != nil { + return nil, fmt.Errorf("qwen3vlgen: read embed_config.json: %w", err) + } + var cfg genConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("qwen3vlgen: parse embed_config.json: %w", err) + } + if cfg.Generation.Graph == "" { + return nil, fmt.Errorf("qwen3vlgen: 该模型目录未导出生成侧图(embed_config.json 缺 generation.graph);" + + "用 scripts/export_qwen3vl_embedding_onnx.py 重新导出") + } + if !cfg.Generation.TiedLMHead { + return nil, fmt.Errorf("qwen3vlgen: 只支持 tied lm_head(该模型声明 tied_lm_head=false)") + } + if cfg.Generation.KVCache { + return nil, fmt.Errorf("qwen3vlgen: 该导出带 KV cache,本实现尚未支持(会把生成跑成错的)") + } + if cfg.Dim <= 0 || len(cfg.MRopeSection) != 3 { + return nil, fmt.Errorf("qwen3vlgen: incompatible config dim=%d mrope=%v", cfg.Dim, cfg.MRopeSection) + } + + tok, err := qwen3vl.LoadTokenizer(modelDir) + if err != nil { + return nil, fmt.Errorf("qwen3vlgen: %w", err) + } + if !ort.IsInitialized() { + if lib := qwen3vl.FindOnnxLib(); lib != "" { + ort.SetSharedLibraryPath(lib) + } + if err := ort.InitializeEnvironment(); err != nil { + return nil, fmt.Errorf("qwen3vlgen: init onnx env: %w", err) + } + } + + token, err := ort.NewDynamicAdvancedSession( + filepath.Join(modelDir, "TokenEmbedding.onnx"), + []string{"input_ids"}, []string{"hidden"}, nil, + ) + if err != nil { + return nil, fmt.Errorf("qwen3vlgen: create token session: %w", err) + } + seqHead, err := ort.NewDynamicAdvancedSession( + filepath.Join(modelDir, cfg.Generation.Graph), + []string{"hidden", "deepstack_0", "deepstack_1", "deepstack_2", + "rotary_cos", "rotary_sin", "causal_mask"}, + []string{"logits"}, nil, + ) + if err != nil { + token.Destroy() + return nil, fmt.Errorf("qwen3vlgen: create %s session: %w", cfg.Generation.Graph, err) + } + + return &Generator{ + dir: modelDir, cfg: cfg, tok: tok, token: token, seqHead: seqHead, + fp: fingerprint(modelDir), + }, nil +} + +// Generate 跑一次自回归解码。 +// +// 文本侧走纯文本输入:生成蒸馏拆分的输入永远是文本(对话记录),不进 +// 视觉塔 —— 那条路的 DeepStack 相加是为视觉 token 准备的,纯文本时三项全零。 +func (g *Generator) Generate(ctx context.Context, req generation.Request) (generation.Response, error) { + g.mu.Lock() + defer g.mu.Unlock() + + if req.JSONSchema != "" { + // 这条路径的约束靠解码时的语法/采样实现,不能靠 ONNX 图 —— + // 图只到 logits。schema 约束由 provider 之外的机制(如结构化采样) + // 完成;本 provider 报不支持而不是静默忽略。 + return generation.Response{}, fmt.Errorf( + "qwen3vlgen: 不支持 json schema 约束(返回 ErrSchemaUnsupported 会让调用方改用自由文本,"+ + "那正是拆分场景要避免的):%w", generation.ErrSchemaUnsupported) + } + + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 256 + } + ids := g.tok.Encode(req.Prompt) + if len(ids) == 0 { + return generation.Response{}, fmt.Errorf("qwen3vlgen: prompt 为空") + } + maxLen := g.cfg.MaxLength + if maxLen <= 0 { + maxLen = 2560 + } + if len(ids) > maxLen-1 { + ids = ids[:maxLen-1] + } + + var out strings.Builder + truncated := true + for step := 0; step < maxTokens; step++ { + if err := ctx.Err(); err != nil { + return generation.Response{}, err + } + next, err := g.forward(ids) + if err != nil { + return generation.Response{}, err + } + tokenText, isStop := g.decodeToken(next) + if isStop { + truncated = false + break + } + out.WriteString(tokenText) + ids = append(ids, next) + if len(ids) >= maxLen-1 { + truncated = false + break + } + } + return generation.Response{Text: out.String(), Truncated: truncated}, nil +} + +// forward 跑一次前向,返回 argmax token id。 +// +// 全量前向:没有 KV cache,每步重算全序列。蒸馏输出短(N≈200), +// 这个代价是可接受的 —— 见包注释。 +func (g *Generator) forward(ids []int) (int, error) { + seq := len(ids) + hidden, err := g.runTokenEmbedding(ids) + if err != nil { + return 0, err + } + cos, sin := rotary(g.cfg.RopeTheta, g.cfg.MRopeSection, seq) + causal := causalMask(seq) + zero := make([]float32, len(hidden)) + + var deepTensors []*ort.Tensor[float32] + defer func() { + for _, t := range deepTensors { + t.Destroy() + } + }() + tensors := make([]ort.Value, 0, 7) + hT, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(g.cfg.Dim)}, hidden) + if err != nil { + return 0, fmt.Errorf("qwen3vlgen: hidden tensor: %w", err) + } + tensors = append(tensors, hT) + for i := 0; i < 3; i++ { + t, err := ort.NewTensor(ort.Shape{1, int64(seq), int64(g.cfg.Dim)}, zero) + if err != nil { + return 0, fmt.Errorf("qwen3vlgen: deepstack tensor: %w", err) + } + deepTensors = append(deepTensors, t) + tensors = append(tensors, t) + } + cosT, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, cos) + if err != nil { + return 0, err + } + tensors = append(tensors, cosT) + sinT, err := ort.NewTensor(ort.Shape{1, int64(seq), qwenRotaryDim}, sin) + if err != nil { + return 0, err + } + tensors = append(tensors, sinT) + maskT, err := ort.NewTensor(ort.Shape{1, 1, int64(seq), int64(seq)}, causal) + if err != nil { + return 0, err + } + tensors = append(tensors, maskT) + defer func() { + hT.Destroy() + cosT.Destroy() + sinT.Destroy() + maskT.Destroy() + }() + + outs := make([]ort.Value, 1) + if err := g.seqHead.Run(tensors, outs); err != nil { + return 0, fmt.Errorf("qwen3vlgen: run: %w", err) + } + defer outs[0].Destroy() + lg, ok := outs[0].(*ort.Tensor[float32]) + if !ok { + return 0, fmt.Errorf("qwen3vlgen: logits type %T", outs[0]) + } + data := lg.GetData() + shape := lg.GetShape() + if len(shape) != 3 || shape[0] != 1 || shape[1] != int64(seq) { + return 0, fmt.Errorf("qwen3vlgen: logits shape %v(期望 [1 %d vocab])", shape, seq) + } + vocab := int(shape[2]) + // argmax over last position + last := data[(seq-1)*vocab : seq*vocab] + best, bestVal := 0, float32(math.Inf(-1)) + for i, v := range last { + if v > bestVal { + best, bestVal = i, v + } + } + return best, nil +} + +func (g *Generator) runTokenEmbedding(ids []int) ([]float32, error) { + in := make([]int64, len(ids)) + for i, id := range ids { + in[i] = int64(id) + } + t, err := ort.NewTensor(ort.Shape{1, int64(len(ids))}, in) + if err != nil { + return nil, err + } + defer t.Destroy() + outs := make([]ort.Value, 1) + if err := g.token.Run([]ort.Value{t}, outs); err != nil { + return nil, fmt.Errorf("qwen3vlgen: token run: %w", err) + } + defer outs[0].Destroy() + tensor, ok := outs[0].(*ort.Tensor[float32]) + if !ok { + return nil, fmt.Errorf("qwen3vlgen: token output %T", outs[0]) + } + return append([]float32(nil), tensor.GetData()...), nil +} + +// decodeToken 判定是否为停止 token,并返回其文本。 +func (g *Generator) decodeToken(id int) (string, bool) { + for _, stopID := range []int{151645, 151643} { // <|im_end|>, <|endoftext|> + if id == stopID { + return "", true + } + } + return g.tok.DecodeOne(id), false +} + +func (g *Generator) Info() generation.Info { + return generation.Info{ + Model: "qwen3vl/" + filepath.Base(g.dir), + SupportsJSONSchema: false, // 图只到 logits;schema 约束未实现 + } +} + +// Close 释放两张图的 session。close sync.Once 保证重复调用安全。 +func (g *Generator) Close() { + g.close.Do(func() { + g.mu.Lock() + defer g.mu.Unlock() + if g.seqHead != nil { + g.seqHead.Destroy() + } + if g.token != nil { + g.token.Destroy() + } + }) +} + +const ( + qwenRotaryHalfDim = 64 + qwenRotaryDim = 128 +) + +func rotary(theta float64, section []int, seq int) ([]float32, []float32) { + cos := make([]float32, seq*qwenRotaryDim) + sin := make([]float32, seq*qwenRotaryDim) + inv := make([]float64, qwenRotaryHalfDim) + for i := range inv { + inv[i] = 1 / math.Pow(theta, float64(2*i)/qwenRotaryDim) + } + for token := 0; token < seq; token++ { + freq := make([]float64, qwenRotaryHalfDim) + for i := range freq { + freq[i] = float64(token) * inv[i] + } + // M-RoPE:三个分段共用同一 base 频率,替换各自区间。 + // 纯文本输入下三段位置相同(文本视觉混合才不同),但仍按 + // embedder.go 的同一算法算,保持与向量侧逐位一致。 + for _, dim := range []int{1, 2} { + if dim >= len(section) { + continue + } + limit := section[dim] * 3 + for i := dim; i < limit && i < qwenRotaryHalfDim; i += 3 { + freq[i] = float64(token) * inv[i] + } + } + for i, f := range freq { + c, s := float32(math.Cos(f)), float32(math.Sin(f)) + cos[token*qwenRotaryDim+i] = c + cos[token*qwenRotaryDim+qwenRotaryHalfDim+i] = c + sin[token*qwenRotaryDim+i] = s + sin[token*qwenRotaryDim+qwenRotaryHalfDim+i] = s + } + } + return cos, sin +} + +func causalMask(seq int) []float32 { + m := make([]float32, seq*seq) + min := float32(math.Inf(-1)) + for i := 0; i < seq; i++ { + for j := 0; j < seq; j++ { + if j > i { + m[i*seq+j] = min + } + } + } + return m +} + +func fingerprint(dir string) string { + // 指纹只用 embed_config.json:它声明了 arch/dim/generation, + // 足以区分「同一份权重能否跨版本互比向量」。 + p := filepath.Join(dir, "embed_config.json") + data, err := os.ReadFile(p) + if err != nil { + return "unreadable" + } + var h uint64 = 14695981039346656037 + for _, b := range data { + h ^= uint64(b) + h *= 1099511628211 + } + return fmt.Sprintf("%016x", h) +} diff --git a/providers/qwen3vlgen/generator_test.go b/providers/qwen3vlgen/generator_test.go new file mode 100644 index 00000000..de9a9e92 --- /dev/null +++ b/providers/qwen3vlgen/generator_test.go @@ -0,0 +1,158 @@ +//go:build onnxruntime + +package qwen3vlgen + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/pkg/generation" +) + +// 端到端:Go + onnxruntime 用同一份 Qwen3-VL 权重跑生成。 +// +// 需要真实的模型目录(12GB),通过 QWEN3VL_GEN_MODEL_DIR 指向。 +// 没设置就跳过 —— 不能让 CI 因为缺 12GB 权重而红。 +func genModelDir(t *testing.T) string { + t.Helper() + dir := os.Getenv("QWEN3VL_GEN_MODEL_DIR") + if dir == "" { + t.Skip("跳过:未设置 QWEN3VL_GEN_MODEL_DIR") + } + if _, err := os.Stat(dir + "/embed_config.json"); err != nil { + t.Skipf("跳过:%s 下无 embed_config.json", dir) + } + return dir +} + +func newGen(t *testing.T, dir string) *Generator { + t.Helper() + g, err := New(dir) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(g.Close) + return g +} + +const askCapital = "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n" + + "<|im_start|>user\n中国的首都是哪里?<|im_end|>\n<|im_start|>assistant\n" + +// ★ 判据:必须生成**连贯中文**且能自然结束。 +// +// 这条测试的全部价值在于「不能退化」: +// - 复读("首都北京是中国首都,首都北京是中国首都…")会被重复检测抓住 +// - 空输出 / 乱码 / 未收尾的截断都会被下面三条断言抓住 +// +// 为什么必须有它:Embedding 变体用**同一份实现**就会复读 +// (实测已确认是变体性质),而那种退化不报错、不断言、只是模型安静地变差。 +func TestGenerate_中文连贯且自然结束(t *testing.T) { + dir := genModelDir(t) + g := newGen(t, dir) + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) + defer cancel() + resp, err := g.Generate(ctx, generation.Request{ + Prompt: askCapital, MaxTokens: 32, Temperature: 0, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + t.Logf("输出(%v, truncated=%v): %q", g.dir, resp.Truncated, resp.Text) + + if strings.TrimSpace(resp.Text) == "" { + t.Fatal("输出为空") + } + // ① 非乱码:至少要有几个中文 rune,且不是替换字符 + var han int + for _, r := range resp.Text { + if r >= 0x4E00 && r <= 0x9FFF { + han++ + } + if r == 0xFFFD { + t.Fatalf("输出含 U+FFFD 替换字符(解码错位): %q", resp.Text) + } + } + if han < 3 { + t.Errorf("中文字符仅 %d 个,输出可能不是正常中文: %q", han, resp.Text) + } + // ② 不复读:任意 6 字窗口在全文中出现不得超过 3 次。 + // 复读的表现是同一片段反复出现(实测 Embedding 变体会这样)。 + if dup := maxRepeatWindow(resp.Text, 6); dup > 3 { + t.Errorf("片段重复 %d 次 ⇒ 复读退化: %q", dup, resp.Text) + } + // ③ 自然结束:不该被 MaxTokens 截断(答案很短) + if resp.Truncated { + t.Errorf("输出被截断(%d token 不够用?): %q", 32, resp.Text) + } +} + +// maxRepeatWindow 返回最长的「长度 n 窗口」在全文里出现的最多次数。 +func maxRepeatWindow(s string, n int) int { + r := []rune(s) + if len(r) < n { + return 1 + } + counts := map[string]int{} + best := 0 + for i := 0; i+n <= len(r); i++ { + w := string(r[i : i+n]) + counts[w]++ + if counts[w] > best { + best = counts[w] + } + } + return best +} + +// 停止符必须被识别:否则解码会一直跑到 MaxTokens(表现为 truncated=true)。 +func TestGenerate_遇停止符即停(t *testing.T) { + dir := genModelDir(t) + g := newGen(t, dir) + + // 预填答案主体,让模型立刻该收尾了 + prompt := askCapital + "中国的首都是北京。" + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) + defer cancel() + resp, err := g.Generate(ctx, generation.Request{ + Prompt: prompt, MaxTokens: 32, Temperature: 0, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if resp.Truncated { + t.Errorf("已给出完整答案却跑到 MaxTokens ⇒ 停止符没被识别: %q", resp.Text) + } +} + +// 未导出生成侧图的目录必须在构造期失败,不能等到第一次 Generate。 +func TestNew_未导出生成侧图则失败(t *testing.T) { + dir := os.Getenv("QWEN3VL_EMBED_ONLY_DIR") + if dir == "" { + t.Skip("跳过:未设置 QWEN3VL_EMBED_ONLY_DIR(需要一个只有向量图的目录)") + } + g, err := New(dir) + if err == nil { + g.Close() + t.Fatal("只有向量图的目录应构造失败") + } + if !strings.Contains(err.Error(), "generation") { + t.Errorf("错误信息应指向 generation 段,实际: %v", err) + } +} + +// Config 声明的图名必须真的存在(embed_config 写错路径时立刻暴露)。 +func TestNew_embedConfig声明的图必须存在(t *testing.T) { + dir := genModelDir(t) + g := newGen(t, dir) + if g.cfg.Generation.Graph == "" { + t.Fatal("embed_config 未声明 graph") + } + p := dir + "/" + g.cfg.Generation.Graph + if _, err := os.Stat(p); err != nil { + t.Errorf("声明的图不存在: %s (%v)", p, err) + } +} diff --git a/scripts/capability-bench/PLAN.md b/scripts/capability-bench/PLAN.md new file mode 100644 index 00000000..3e4544ec --- /dev/null +++ b/scripts/capability-bench/PLAN.md @@ -0,0 +1,252 @@ +# 跑分计划 + +> 本文原为根目录 `benchmark.md`,随仓库清理归位到此处 +> (与同目录 `README.md`「能力标定台」是两份不同文档,勿混)。 +> +> **本机路径已脱敏**:脚本里出现的 `/home/`、`/var/tmp/...` 均为占位示例, +> 实际跑请按本机环境替换,或改用 `spawn-instance.sh` 的参数。 + +> 交接文档:供新会话执行。旧会话的测试**已全部停止**,本文件是从头开始的唯一依据。 +> 新会话开始时:**先验证本文件每一条「前置事实」再执行**,不要凭记忆或信任旧会话输出。 + +## 0. 前置事实(新会话必须先逐条重验) + +| # | 事实 | 验证命令 | 期望 | +| --- | --- | --- | --- | +| 1 | 跑分工具已就位 | `ls scripts/capability-bench/` | bench.py pi_bench.py compare.py memory_recall.py tasks.example.json tasks.zerobasis.json spawn-instance.sh README.md | +| 2 | 代码提交已完成 | `git log --oneline -8` | 见 §5 提交清单 | +| 3 | 工作区干净 | `git status --short` | 空 | +| 4 | 生产实例未受影响 | `ps -p $(pgrep -f 'homed -data ${HA_DATA}' \| head -1) -o pid,etime` | 存活 | +| 5 | 测试进程已清 | `pgrep -f 'memory_recall\|pi_bench\|bench.py'` | 空 | +| 6 | llmsproxy 可用 | `curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $(sqlite3 ${HA_DATA}/config.db \"SELECT value FROM config WHERE key='core.llm.api_key'\")" http://127.0.0.1:8081/v1/models` | 200 | +| 7 | pi 可用且版本 | `pi --version`(如 EADDRINUSE 用 `env -u PI_A2A_PORT -u PI_ACP_PORT`) | 0.85.1 | +| 8 | pi 的 llmsproxy provider | `python3 -c "import json;d=json.load(open('${PI_MODELS}'));print(d['providers']['llmsproxy']['baseUrl'])"` | | + +## 1. 已知的关键教训(不重犯) + +1. **两侧 usage 同名不同义**(已归一,勿改回去): + - HomeAgent:`prompt_tokens` **含**缓存(OpenAI 口径) + - pi:`input` 是**未命中侧**,`totalTokens = input + cacheRead + output`(Anthropic 口径,受控实验实证) + - ⇒ 两侧各自过归一函数(pi_bench.py `_usage_of` / memory_recall.py `_norm_ha_usage`+`_norm_pi_usage`),汇总只认归一后的键 +2. **缓存命中率恒 100% 的假绿已修**(`59c0689`):上游只报命中侧时 `miss = prompt - read`。修复前 100.0% → 修复后 87.6%。 +3. **cli.sock 按行读**:消息含换行 = 拆成多条独立消息(历史全乱,表现为 BrokenPipe)。memory_recall.py 已有 `assert "\n" not in text`。 +4. **内核会 dedupe 完全相同的输入**(`skipped:true`):多轮/重跑必须让每条文本不同。 +5. **pi 必须隔离配置目录 + 端口**:`PI_CODING_AGENT_DIR=${PI_ISO}` + `PI_A2A_PORT`/`PI_ACP_PORT` 给空闲端口。隔离后无扩展 ⇒ `pi -p` 跑完自己退出。 +6. **pi 的工具事件**:事件名 `tool_execution_start`、字段 `toolName`(写错只是静默为空)。 +7. **★ 超窗校验是判据的前提,不是可选项**:旧 200k 跑分因实例窗口(HomeAgent 1M / pi 600k)大于灌入量 253k,实际测的是「窗口内召回」—— 跑 19 分钟、数字漂亮、完全无效。memory_recall.py 已加 `--expect-window` 校验(对无效配置直接拒跑);新会话用它,并先实测两侧真实窗口。 +8. **改 LLM 配置必须重启实例**:provider 启动时构建,`/settings set` 落库但不生效。 +9. **不要用 `pkill -f`**:会匹配到自己这条 shell 命令行。用 pid 文件或 `pgrep` 精确 pid。 +10. **面板懒渲染**:WebUI 的 `#chat-panel-chat` 只在 `switchTab('chat')` 后存在;浏览器验证先切页。 +11. **本机 diff 是坏的**(PATH 首位对任何输入返回 0):用 `/usr/bin/diff` 或 Python difflib。 + +## 2. 测试 A:零基础认知对比(质量,可多实例并行) + +**目的**:同模型、同一份材料,只变 harness,测认知而非"翻自己的库"。 +(旧版硬伤:knowledge-stats 让 pi 读 HomeAgent 自己的 graph.db ⇒ pi 97s/317k token 全是权限差异,不是能力差异。) + +材料:`${BENCH_MATERIAL}/{incident-2026-08.md, config-notes.md}`(若不存在,按 git 历史里的任务集重建;材料必须含:512 连接池上限、4200 峰值、v2.30.4、87 订单、"600" **不在**材料里须自己算 200+400、ttl=0=永不过期、N+1、对账批处理)。 + +任务集:`scripts/capability-bench/tasks.zerobasis.json`(6 任务:检索/算术/推断/抗干扰/综合/多跳,全部带显式 check)。 + +执行: + +```bash +# ① 起隔离实例(窗口两侧都配 200000) +BIN=${REPO}/build/homed \ + bash scripts/capability-bench/spawn-instance.sh a 18082 200000 +# ② HomeAgent 侧 +python3 scripts/capability-bench/bench.py --socket ${INSTANCE_A}/cli.sock \ + --api-key "$(cat "${INSTANCE_A}/apikey")" # 由 spawn-instance.sh 写入 \ + --tasks scripts/capability-bench/tasks.zerobasis.json --out ${OUT_DIR}/ha --timeout 300 +# ③ pi 侧(同一任务文件 ⇒ 提示词逐字相同) +python3 scripts/capability-bench/pi_bench.py \ + --tasks scripts/capability-bench/tasks.zerobasis.json --out ${OUT_DIR}/pi --timeout 300 +# ④ 对比 +python3 scripts/capability-bench/compare.py --a ${OUT_DIR}/ha --b ${OUT_DIR}/pi \ + --label-a HomeAgent --label-b pi --out ${OUT_DIR} +``` + +验收:两侧任务集一致(compare.py 会检查);逐任务看通过与 token;失败原因必须留在 compare.md。 + +## 3. 测试 B:200k 超窗记忆召回(性能敏感 ⇒ 独占实例,串行跑) + +**目的**:多轮对话超出窗口后,早期内容的召回率(HomeAgent 向量裁剪 vs pi 压缩)。 + +**★ v3 填充模型(`95224eb`,当前唯一有效版本)** + +| 版本 | 填充 | 结论 | +| --- | --- | --- | +| v1 | 显式强调针 + 语义空转 | 两例 4/4 满分零区分度 | +| v2 | 端口同构干扰 + **废话流**(天气/树叶/墨盒) | **作废**:废话被压缩直接丢弃 ⇒ 偶发针成填充里唯一的价值内容 ⇒ pi 跑分虚高 | +| v3 | **高密度叙事**(order-gw 运维主线) | 当前版本 | + +v3 主线:`上线准备 → 灰度事故 → 修复验证 → 版本发布 → 交接收尾`, +每轮 5 片段、每片段 2-4 条有信息增量的工作项(数值、因果、决策、变更)、 +含跨轮引用。针全部嵌在价值信息流里: + +- **偶发针** = 服务依赖清单条目(同构干扰 = 其它服务的**真实**端口,同样有意义,只是不是针) +- **覆盖针** = 值班安排改号(`4379 → 4324`,真实变更场景;反向哨兵验证不塌回) +- **多跳** = 交接流程本身(团队→门禁→申请表) + +实测证据支持「废话填充虚高」:v3 第 1 轮 HA 模型就同时调用 +`memory_recall / doc_query / config_list_plugins / cmd_run` 四个工具, +而 v2 废话填充下只调 `doc_commit` —— 高密度负载才考得出 harness 的工具选择与 +循环管理能力。 + +**★ 踩坑:锚点 frac 必须落在对应 phase 区间内**(生成器自证时抓到) +`narrative_block` 的 phase 由 `frac` 切分,而针锚点也用 `frac`。写 +`i == int(total*0.50)` 想在 release 阶段插针,但 `0.50 < 0.61` 实际落在 +verify 分支 ⇒ **永不触发**(表现为「覆盖新值找不到」,不报错)。同理 +`0.63` 距边界 `0.61` 太近,浮点除法下可能仍在相邻区间。改法:把每个锚点 +推到所属区间**内部**(0.52/0.66/0.70/0.76/0.85/0.92),并写落位自证。 + +**★ 先把两侧窗口真实配成一致(这是上次翻车点)**: + +- HomeAgent:`core.llm.sources.deepseek.context_window = 200000`(spawn-instance.sh 第 3 参传 200000),起后 `sqlite3 ... "SELECT value FROM config WHERE key LIKE '%context_window%'"` 验证 = 200000 +- pi:把隔离目录 `${PI_ISO}/models.json` 的 AUTO 模型 `contextWindow` 改成 200000,并用 python3 读回验证 + +执行(串行,先 A 后 pi;v3 填充实测 `overshoot` 需给 1.45 才能达到 1.23×): + +```bash +python3 scripts/capability-bench/memory_recall.py --harness homeagent \ + --socket ${INSTANCE_C}/cli.sock --api-key "$(cat ${INSTANCE_C}/apikey)" \ + --window 200000 --expect-window 200000 --overshoot 1.45 \ + --out ${OUT_DIR}/v3-ha +python3 scripts/capability-bench/memory_recall.py --harness pi \ + --window 200000 --expect-window 200000 --overshoot 1.45 \ + --out ${OUT_DIR}/v3-pi +``` + +验收:工具打印「✓ 超窗校验通过」(否则结果无效,删掉重跑);报告 recall_rate 按针分层;两侧灌入量同级(v3 = 143 轮 / 245k / 1.23×)。 + +注意:143 轮/侧,高密度叙事下单轮 ~25-50s,单侧约 1.5-2 小时;用 bg_run 跑且**不要** `| tail`(会缓冲到看不见进度),输出落文件。实时轮数看实例 `run.log` 的 `grep -ac 'text from cli'`(脚本 stdout 重定向后块缓冲滞后)。 + +## 4. 测试 C:多实例并行吞吐(可选,性能) + +spawn-instance.sh 可起多实例(18082/18083/…;pluginmgr 9876 / remotedevice 9890 / kbtree 9892 写死会 bind 失败属预期降级,不影响 cli.sock 标定)。质量类测试可并行;性能类必须独占。 + +## 5. 提交清单(已在本地 main,未推送) + +``` +9ca7e34 feat(bench): 能力标定台 —— 经 cli.sock 驱动实例,记录结果与账目 +59c0689 fix(usage): 缓存规则收成单一实现 —— 修掉「命中率恒 100%」的结构性假绿 +07e15dc feat(context): 上下文阈值接入配置系统 —— 消除 0.8 与 600000 合成的隐形断层 +52c2aae feat(webui): 对话页展示缓存命中率与 token 用量 +(更早:73a6359 54ba3ff 2f898a6 6d537ca afeba70 a8ed3a8 —— usage 全链路 + 估算器校准) +``` + +待提交:本文件(benchmark.md)+ capability-bench 新增的 pi_bench.py / compare.py / memory_recall.py / tasks.zerobasis.json / spawn-instance.sh / README 增补。 + +## 6. 推送与部署(人工确认后才做) + +- 推送:`git push origin main`(8+ 提交在本地) +- 部署:`deploy-plan.sh check`(已预检过:onnxruntime ✓ libonnxruntime ✓)→ backup → deploy;deploy 前需人工确认 +- 生产适配器:`${HA_DATA}/adapters/` 有 9/10 与 .bundled 清单不符(openai.lua 实测仅版本落后、无用户修改);部署前逐个核对后清掉让内核重解包(backup 会带走) + +## v4 跑分(2026-10-04,新内核 657ccd7) + +### ★ 先说一个必须记的操作错误 + +跑之前先核对了内核版本: + +``` +build/homed Oct 1 21:18 ← 3 天前的二进制 +今天的 memory 层提交 19 个 +``` + +**如果直接跑,测的就是三天前的内核。** 而今天改的 +全部是 memory 层(融合召回 / 拒答判据 / 仲裁 / schema 退场)—— +跑分会完全测不到今天的工作。 + +⇒ **跑分前必须核对 `vcs.revision` == `git rev-parse HEAD`。** + 这一条应该进 `spawn-instance.sh`,让它自动拒绝版本不符的实例。 + +(另外两次操作失误:`cp` 取快照在 WAL 模式下不一致; +`${VAR:0:8}` 在 `/bin/sh` 里报 Bad substitution。) + +### 能力标定(tasks.zerobasis,6 任务) + +| 指标 | 值 | +|---|---| +| 任务数 / 通过 | **6 / 6(100%)** | +| 总墙钟 | 159.17s | +| 工具调用 | 8 | +| 合计 token | 281971 | +| 缓存命中率 | 42.7% | +| 超时任务 | 0 | + +分项:检索 3.85s / 算术 43.44s(算出 600)/ 推断 42.66s / +抗干扰 6.57s(答出「永不过期」)/ 综合 5.36s(命中 4200)/ 多跳 57.30s + +### 与今天的记忆侧改动的关系 + +这一轮 6/6 **验证的是「今天没把主干跑坏」**,不是「今天的改动带来了提升」。 + +记忆召回质量另有专项(`memory_recall.py`,36 探针), +它的结论已经单独记录在 `production-recall-validation.md`: + +| | 测试库 ha-c | 生产快照 1391 块 | +|---|---|---| +| 端到端 | 7/7 | 4/9 | +| abstention | — | 0/3(纯中文编造) | + +★ **两个库的可拆率差 7.5 倍**(83% vs 11%), + ha-c 的分数不能推广到生产。 + +## v4 跑分结果汇总(2026-10-04) + +### 能力标定 + +``` +tasks.zerobasis 6/6(100%) 墙钟 159.17s +token 281971 缓存命中 42.7% 超时 0 +``` + +### 端到端记忆召回 + +``` +仲裁前 6/7 → 仲裁后 7/7 + casual 3/3 overwrite 2/2 confusable 2/2 +``` + +### ★ `memory_recall.py` 专项跑不完(不是慢,是必然失败) + +36 轮 = 填充 26 + 探针 10。填充轮耗时实测: + +``` +[1/36] 30.9s +[2/36] 131.5s +[3/36] 392.0s ← 递增比 2.98× +``` + +外推:**第 6 轮就超过单轮超时(900s)**,26 轮累计 12.9 小时。 + +★ 慢的原因是 `memory_commit` 在填充期间持续写库 + (entities 188 → 220),每轮检索的数据量与召回量都在涨。 + +⇒ **改走等价路径**:用探针判据(`TestProbe_端到端召回`)直接测, + 数据仍是真实内核跑过的迁移库,结果 7/7。 + +⇒ **这个专项需要重新设计**:26 轮逐条填充的做法在 + 「写入会改变检索集」的系统上不成立。 + 正确做法应是**批量预填**(一次写入全部填充内容), + 再跑探针轮 —— 否则填充本身就是被测对象的一部分。 + +### 本轮跑分的三个操作失误(已在 `421c1d7` 固化防护) + +``` +① 未核对内核版本 → 差点测三天前的二进制(已加自动拒绝) +② ${VAR:0:8} → /bin/sh 报 Bad substitution +③ 改错二进制路径 → ${INSTANCE_C}/homed 不存在,实际是 build/homed +``` + +### 实战数据印证的三件事 + +``` +exact 14 / text 24 / symbol 0 标签分布(symbol 是今天新增的) +拒答 2 次(第8批/第9批,精确串库里没有 ⇒ 判据正确触发) +overflow 32 / preempt 6 / 裁剪 3 L4 超页处理真实触发 +entities 188 → 220 memory_commit 仍在写旧表 +``` + +详见 `live-run-evidence-2026-10-04.md`。 diff --git a/scripts/capability-bench/README.md b/scripts/capability-bench/README.md new file mode 100644 index 00000000..59287b02 --- /dev/null +++ b/scripts/capability-bench/README.md @@ -0,0 +1,154 @@ +# 能力标定台(capability-bench) + +经 `cli.sock` 驱动一个 HomeAgent 实例跑任务集,记录**结果 + 账目**,产出 +`report.md` / `results.json`。 + +存在的理由:标定「能力」需要一个**可复现的驱动 + 记账 + 判据**, +而不是手工敲几句看回复。三件事各对应一个曾经缺失的环节: + +| 环节 | 做法 | 依赖 | +|---|---|---| +| 驱动 | 连 `cli.sock`,发任务,收帧到终态 | cli 插件(无头唯一入口)| +| 记账 | `/kernel` 取累计用量,做**任务前后差分** | `/kernel` 的 `usage` 段(2026-09-30 补)| +| 判定 | 任务自带的 `check` | 无 check 一律判失败(防空绿)| + +## 用法 + +```bash +# 不连实例,先看会跑什么 +python3 bench.py --data ${HA_DATA} \ + --tasks tasks.example.json --out /var/tmp/bench --dry-run + +# 真跑 +HOMEAGENT_CLI_KEY= \ +python3 bench.py --data ${HA_DATA} \ + --tasks tasks.example.json --out /var/tmp/bench + +# 只跑其中几个 / 放大超时(长时任务) +python3 bench.py --socket /path/cli.sock --api-key KEY \ + --tasks tasks.example.json --only long-soak --timeout 7200 --out /var/tmp/bench +``` + +退出码:`0` 全过,`1` 有失败,`2` 参数/任务集/实例问题,`3` 驱动层失败。 + +## 计费口径(重要) + +**用 `/kernel` 差分,不用单次回包的 usage。** 一个任务常触发多轮 LLM 调用 +(工具回环),单次回包只反映最后一段。两者都记录在 `results.json` 里便于对照。 + +命中率的分母只算**报过缓存的调用**(`cache_read + cache_miss`)。 +一次都没报过时报告写「—」而不是 0% —— 把「没数据」画成 0% 会让人去优化 +一个本来就没开的功能。 + +## 任务集格式 + +```jsonc +{ + "tasks": [ + { + "id": "read-single-file", + "dimension": "readonly", // 维度,便于分维度看退化 + "prompt": "…", + "timeout_s": 180, // 可选,默认取 --timeout + "check": { "kind": "output_contains", "value": "1.4" } + } + ] +} +``` + +支持的 `check.kind`: + +| kind | 字段 | 判据 | +| --- | --- | --- | +| `output_contains` | `value` | 回复含该子串 | +| `output_regex` | `value` | 回复匹配该正则 | +| `file_exists` | `value` | 该路径存在 | +| `file_contains` | `value` / `needle` | 该文件含 needle | +| `tool_used` | `value` | 过程帧里用过该工具 | +| `completed` | — | 没超时、没报错(用于「跑得完」类长时任务) | + +**期望值必须实测核对过再写进去**(例如 `Version=1.4.0` 来自 +`internal/meta/meta.go:33`)。写错的期望值会让标定结果整体失去意义 —— +它会把「模型答对了」判成失败。 + +## 踩过的坑(写任务集/起实例前先看) + +1. **提示词不能重复。** 内核把完全相同的输入判为 `duplicate` 直接跳过 + (`skipped:true`),第二次根本没跑 LLM。任务集里每条都不一样,若同一 + 任务要重复跑,得在提示词里加变化(时间戳/序号)。 +2. **改 LLM 配置必须重启实例。** provider 在启动时构建;经 `/settings set` + 改 `core.llm.base_url` 会落库但**不生效**(实测仍打原地址,报 401)。 +3. **隔离实例要在固定插件端口上撞车。** `-webui` 能换,但 + `pluginmgr(9876)` / `remotedevice(9890)` / `kbtree(9892)` 是写死的, + 同机第二个实例会 bind 失败(那几个插件降级,内核其余部分照常)。 + 这对经 cli.sock 标定无影响,但别以为实例「完全隔离」。 +4. **`cli.sock` 的认证 key 回落到 webui 的 `api_key`**(`config_webui.api_key`); + `config_cli.api_key` 为空时用后者。认证失败会直接断开连接。 +5. **不要用 `pkill -f`** 清理 mock/实例 —— 它会匹配到自己这条 shell 命令行, + 把当前 shell 一起杀掉(本项目已踩过)。用 PID 文件。 + +## 本地验证过什么(2026-09-30) + +用 `build/homed` + `scripts/kernel-stress/usage_mock_llm.py` 起隔离实例 +(`-webui 127.0.0.1:18080`),实测确认账目链路端到端贯通: + +```text +mock 上报 prompt=300 completion=30 cache_read=200 cache_miss=100 +回包 usage prompt_tokens=300 completion_tokens=30 total_tokens=330 + cache_read_tokens=200 cache_miss_tokens=100 cache_reported=true +/kernel 累计 calls=1 prompt=300 total=330 cache_read=200 cache_miss=100 + cache_hit_rate=0.667 (= 200/(200+100)) +``` + +标定台本身跑 3 个任务:差分 330 token/任务、命中率 66.7%、PASS/FAIL 判定正常、 +报告与 JSON 落盘正常。 + +## 其他三个工具 + +| 文件 | 作用 | 关键点 | +| --- | --- | --- | +| `pi_bench.py` | **pi 基线**(同任务、同模型) | `pi -p --mode json`;必须隔离配置目录与端口 | +| `compare.py` | 两侧并排对比 | 校验任务集一致;命中率无数据写「—」 | +| `memory_recall.py` | 多轮**超出窗口后**的记忆召回 | 埋针 + 填充 + 提问;单行消息(协议按行读) | + +### pi 基线的三个坑(都实测踩过) + +1. **必须隔离配置目录**:`PI_CODING_AGENT_DIR` 指向独立目录,否则子 pi 会 + 读写你的会话状态。 +2. **必须隔离端口**:本机会话守护进程占着 `PI_A2A_PORT=14010`,另一个 pi 占默认 + `12010` ⇒ 子进程继承环境后直接 `EADDRINUSE` 崩。 + 顺带的好事:隔离配置里没有扩展 ⇒ 不起 A2A/ACP 服务 ⇒ `pi -p` **跑完自己退出** + (有扩展时它跑完不退,必须自己 kill)。 +3. **工具名是 `toolName`、事件名是 `tool_execution_start`**: + 写错不会报错,只是工具名恒为空(`tool_calls=30` 而 `tools=[]`)。 + +### ★ 同名不同义:两侧 usage 口径相反 + +| | 输入总量 | 未命中侧 | +| --- | --- | --- | +| HomeAgent(OpenAI 口径) | `prompt_tokens` **含**缓存 | `cache_miss_tokens` | +| pi(Anthropic 口径) | `input + cacheRead` | `input` | + +实测实证(pi):`totalTokens == input + cacheRead + output`。 +不归一就直接比,会给 pi 系统性低估,且命中率会算成 `read/read` = **恒 100%**。 +两侧都过各自的归一函数,汇总只认归一后的键。 + +### 记忆召回测试怎么用 + +```bash +# 小窗口先验仪器(快) +python3 memory_recall.py --harness homeagent --socket --api-key KEY \ + --window 20000 --overshoot 1.3 --needles 3 --out /var/tmp/mem/probe + +# 正式:200k +python3 memory_recall.py --harness pi --window 200000 --overshoot 1.25 \ + --needles 4 --out /var/tmp/mem/pi-200k +``` + +**两侧必须把窗口配成同一个值**,否则比的不是策略而是配置: + +- HomeAgent:`core.llm.sources..context_window` +- pi:隔离目录 `models.json` 里的 `ctx` + +⚠️ 消息**不能含换行**:`cli.sock` 按行读,一条多行消息会被拆成几十条独立消息 +(历史全乱,表现为 BrokenPipe)。脚本内有 `assert "\n" not in text` 兜底。 diff --git a/scripts/capability-bench/bench.py b/scripts/capability-bench/bench.py new file mode 100644 index 00000000..c56d7515 --- /dev/null +++ b/scripts/capability-bench/bench.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +"""HomeAgent 能力标定台 —— 经 cli.sock 驱动部署实例,记录账目与结果。 + +为什么要它 +========== + +「全面标定能力」需要一个**可复现的驱动 + 记账**,而不是手工敲几句看回复。 +本台子只做三件事,每件都对应一个曾经缺失的环节: + +1. **驱动**:连 cli.sock(唯一无头入口),发任务,收帧直到终态。 +2. **记账**:从 `/kernel` 读累计用量,取**任务前后差值** ⇒ 单任务真实成本。 + 这一步依赖内核把 usage 放进 /kernel(本轮刚补)与 cli 帧 + (本轮刚补)—— 没有它们本台子只能报「跑了多久」。 +3. **判定**:按任务自带的 checker 判成功,不靠肉眼看回复。 + +计费口径 +======== + +用 /kernel 的**差分**而不是单次回包的 usage,原因: +一个任务往往触发多轮 LLM 调用(工具回环),单次回包只反映最后一段。 +差分是任务的真实总账。两者都会记录,便于对照。 + +用法 +==== + + # 对一个实例跑一套任务(该实例的 data 目录决定 socket 路径) + python3 bench.py --data "${HA_DATA}" --tasks tasks.example.json --out /var/tmp/bench + + # 长时任务:把超时放大(默认 600s) + python3 bench.py --data ... --tasks tasks.long.json --timeout 7200 + +安全 +==== + +本台子**只发提示词**。任务是否写盘/发邮件由提示词与实例授权决定, +所以内置任务集刻意是只读或写入临时目录的。请勿把破坏性提示词放进来。 +""" +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +# 帧类型(与 internal/plugins/cli/plugin.go 的 writeLine 一一对应) +FRAME_RESPONSE = "response" +FRAME_ERROR = "error" +FRAME_TOOL_CALL = "tool_call" +FRAME_REASONING = "reasoning" +FRAME_CONTENT_DELTA = "content_delta" + + +class BenchError(RuntimeError): + """驱动层错误(连不上、认证失败、内核没起来)。""" + + +class CliSession: + """一条 cli.sock 连接。 + + 协议(读 internal/plugins/cli/plugin.go 的 handleConn 得到): + 1. 连上后先发 ``/auth ``,等一条 ``{"type":"response","content":"authenticated"}``; + 2. 之后每发一行就是一个请求,收帧直到出现 response / error 终态。 + 3. 以 ``/`` 开头的是内置命令(/kernel、/status 等),同样以 response 终结。 + """ + + def __init__(self, sock_path: str, api_key: str, timeout: float): + self.timeout = timeout + try: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(timeout) + self.sock.connect(sock_path) + except OSError as exc: + raise BenchError(f"连接 {sock_path} 失败: {exc}") from exc + self.buf = b"" + if api_key: + self._write(f"/auth {api_key}") + reply = self._read_until_terminal(collect=None) + if reply.get("type") == FRAME_ERROR: + raise BenchError(f"认证失败: {reply.get('error')}") + + # ---- 低层读写 ---- + + def _write(self, line: str) -> None: + self.sock.sendall((line + "\n").encode("utf-8")) + + def _read_line(self) -> dict: + """读一条 JSON 帧。非 JSON 行按旧版协议包成 response。""" + while b"\n" not in self.buf: + chunk = self.sock.recv(65536) + if not chunk: + raise BenchError("连接被对端关闭") + self.buf += chunk + raw, self.buf = self.buf.split(b"\n", 1) + text = raw.decode("utf-8", "replace").strip() + if not text: + return {} + try: + obj = json.loads(text) + return obj if isinstance(obj, dict) else {"type": "response", "content": text} + except json.JSONDecodeError: + # 旧版服务器会直接回文本行 + return {"type": FRAME_RESPONSE, "content": text} + + # ---- 高层语义 ---- + + def _read_until_terminal(self, collect: list | None) -> dict: + """收帧直到终态(response/error),过程帧交给 collect 收集。""" + while True: + frame = self._read_line() + if not frame: + continue + ftype = frame.get("type") + if ftype in (FRAME_RESPONSE, FRAME_ERROR): + return frame + if collect is not None: + collect.append(frame) + + def send(self, text: str, timeout: float | None = None) -> dict: + """发一条消息,收全部过程帧与终态。 + + 返回 ``{"terminal":帧, "frames":[...], "wall_s":秒, "timed_out":bool}``。 + """ + self.sock.settimeout(timeout or self.timeout) + frames: list = [] + started = time.monotonic() + self._write(text) + timed_out = False + try: + terminal = self._read_until_terminal(collect=frames) + except TimeoutError: + # 超时是长时任务的常态,不能当崩溃:如实标记并返回已有过程帧。 + timed_out = True + terminal = {"type": FRAME_ERROR, "error": f"timeout after {timeout or self.timeout}s"} + except BenchError as exc: + timed_out = True + terminal = {"type": FRAME_ERROR, "error": str(exc)} + return { + "terminal": terminal, + "frames": frames, + "wall_s": time.monotonic() - started, + "timed_out": timed_out, + } + + def close(self) -> None: + try: + self.sock.close() + except OSError: + pass + + +def read_usage(session: CliSession) -> dict: + """用 /kernel 读内核累计用量。取不到就返回空 dict(不编数字)。""" + try: + res = session.send("/kernel") + except BenchError: + return {} + content = (res.get("terminal") or {}).get("content") or "" + try: + status = json.loads(content) + except (json.JSONDecodeError, TypeError): + return {} + usage = status.get("usage") + return usage if isinstance(usage, dict) else {} + + +def usage_delta(before: dict, after: dict) -> dict: + """任务前后差分。任何一侧缺数就返回 {}(宁可不报,不编)。""" + if not before or not after: + return {} + keys = ("calls", "prompt", "completion", "total", "cache_read", "cache_miss", "reasoning") + out = {} + for k in keys: + b, a = before.get(k), after.get(k) + if isinstance(b, int) and isinstance(a, int): + out[k] = a - b + # 命中率按差分重算:分母只算差分里报过缓存的调用。 + read, miss = out.get("cache_read", 0), out.get("cache_miss", 0) + if read or miss: + out["cache_hit_rate"] = read / (read + miss) + return out + + +def fmt_rate(rate: float | None) -> str: + """把命中率格式化成可读文本。None(无分母)显示「—」而不是 0%。""" + return "—" if rate is None else f"{rate * 100:.1f}%" + + +def check(task: dict, terminal: dict, frames: list, wall_s: float) -> tuple[bool, str]: + """按任务自带的 checker 判成功。返回 (是否成功, 说明)。 + + checker 是**显式**的:没有 checker 的任务一律判 False —— + 缺判据时「看起来答对了」不算成功,那正是假绿的来源。 + """ + chk = task.get("check") + if not chk: + return False, "任务没有 check 判据" + kind = chk.get("kind") + text = (terminal.get("content") or "") + if kind == "output_contains": + needle = chk.get("value", "") + return (needle in text), f"输出{'含' if needle in text else '不含'} {needle!r}" + if kind == "output_regex": + import re + m = re.search(chk.get("value", ""), text) + return (m is not None), f"正则 {chk.get('value')!r} {'匹配' if m else '不匹配'}" + if kind == "file_exists": + p = Path(os.path.expanduser(chk.get("value", ""))) + return p.exists(), f"{p} {'存在' if p.exists() else '不存在'}" + if kind == "file_contains": + p = Path(os.path.expanduser(chk.get("value", ""))) + needle = chk.get("needle", "") + if not p.exists(): + return False, f"{p} 不存在" + body = p.read_text(encoding="utf-8", errors="replace") + return (needle in body), f"{p} {'含' if needle in body else '不含'} {needle!r}" + if kind == "tool_used": + used = {f.get("tool") for f in frames if f.get("type") == FRAME_TOOL_CALL} + want = chk.get("value", "") + return (want in used), f"工具 {want!r} {'用过' if want in used else '未用'}(实际: {sorted(x for x in used if x)})" + if kind == "completed": + # 只要求没超时、没报错 —— 用于「跑得完」这类长时任务。 + ok = not terminal.get("error") + return ok, "正常结束" if ok else f"异常: {terminal.get('error')}" + return False, f"未知 checker 种类: {kind}" + + +def run_task(session: CliSession, task: dict, default_timeout: float) -> dict: + """跑一个任务并返回它的完整记录。""" + try: + timeout = float(task.get("timeout_s") or default_timeout) + except (TypeError, ValueError): + # 任务集里写了非法超时就退回默认值,而不是让整个跑批崩掉。 + timeout = default_timeout + before = read_usage(session) + started = time.monotonic() + res = session.send(task["prompt"], timeout=timeout) + after = read_usage(session) + wall_s = time.monotonic() - started + + frames = res["frames"] + tool_calls = [f for f in frames if f.get("type") == FRAME_TOOL_CALL] + terminal = res["terminal"] or {} + ok, why = check(task, terminal, frames, wall_s) + + return { + "id": task.get("id"), + "dimension": task.get("dimension", "uncategorized"), + "ok": ok, + "why": why, + "wall_s": round(wall_s, 3), + "timed_out": res["timed_out"], + "error": terminal.get("error"), + "tool_calls": len(tool_calls), + "tools": sorted({f.get("tool") for f in tool_calls if f.get("tool")}), + # 单次回包的用量:仅最后一段 LLM 调用(口径与 /kernel 差分不同) + "usage_single": (terminal.get("usage") or {}), + # 任务真实总账:/kernel 差分(含全部工具回环) + "usage_task": usage_delta(before, after), + "reply": (terminal.get("content") or "")[:2000], + } + + +def summarize(records: list[dict]) -> dict: + """汇总。缓存命中率只在**真的有分母**时给(否则 None,不画成 0)。 + + 全部走 .get():汇总不能因为某条记录缺一个键就整个崩掉 —— + 那会把「跑了 20 个任务、第 20 个异常终止」变成「什么都没跑成」。 + """ + n = len(records) + ok_n = sum(1 for r in records if r.get("ok")) + read = sum((r.get("usage_task") or {}).get("cache_read", 0) for r in records) + miss = sum((r.get("usage_task") or {}).get("cache_miss", 0) for r in records) + report_calls = sum(1 for r in records + if (r.get("usage_task") or {}).get("cache_read") + or (r.get("usage_task") or {}).get("cache_miss")) + return { + "tasks": n, + "passed": ok_n, + "pass_rate": (ok_n / n) if n else None, + "wall_s_total": round(sum(r.get("wall_s", 0.0) for r in records), 2), + "tool_calls_total": sum(r.get("tool_calls", 0) for r in records), + "prompt_total": sum((r.get("usage_task") or {}).get("prompt", 0) for r in records), + "completion_total": sum((r.get("usage_task") or {}).get("completion", 0) for r in records), + "total_tokens": sum((r.get("usage_task") or {}).get("total", 0) for r in records), + "cache_read_total": read, + "cache_miss_total": miss, + "cache_hit_rate": (read / (read + miss)) if (read or miss) else None, + "tasks_with_cache_data": report_calls, + "timed_out": sum(1 for r in records if r.get("timed_out")), + "usage_available": any(r.get("usage_task") for r in records), + } + + +def render_markdown(meta: dict, summary: dict, records: list[dict]) -> str: + """人读的报告。数字缺失时写「—」而不是 0。""" + + def fmt(v, suffix=""): + return "—" if v is None else f"{v}{suffix}" + + rate = fmt_rate(summary["cache_hit_rate"]) + pr = "—" if summary["pass_rate"] is None else f"{summary['pass_rate'] * 100:.0f}%" + lines = [ + "# HomeAgent 能力标定报告", + "", + f"- 时间:{meta['started_at']}", + f"- socket:`{meta['socket']}`", + f"- 任务集:`{meta['tasks_file']}`", + f"- 单任务默认超时:{meta['timeout_s']}s", + "", + "## 汇总", + "", + "| 指标 | 值 |", + "|---|---|", + f"| 任务数 / 通过 | {summary['tasks']} / {summary['passed']}({pr})|", + f"| 总墙钟 | {summary['wall_s_total']}s |", + f"| 工具调用总数 | {summary['tool_calls_total']} |", + f"| 输入 token(差分合计)| {fmt(summary['prompt_total'])} |", + f"| 输出 token | {fmt(summary['completion_total'])} |", + f"| 合计 token | {fmt(summary['total_tokens'])} |", + f"| 缓存命中输入 | {fmt(summary['cache_read_total'])} |", + f"| 缓存未命中输入 | {fmt(summary['cache_miss_total'])} |", + f"| **缓存命中率** | **{rate}**({summary['tasks_with_cache_data']} 个任务有缓存数据)|", + f"| 超时任务 | {summary['timed_out']} |", + "", + "## 逐任务", + "", + "| 任务 | 维度 | 结果 | 墙钟 | 工具 | token | 命中率 | 说明 |", + "|---|---|---|---|---|---|---|---|", + ] + for r in records: + u = r["usage_task"] + lines.append( + f"| {r['id']} | {r['dimension']} | {'✅' if r['ok'] else '❌'} | {r['wall_s']}s | " + f"{r['tool_calls']} | {u.get('total', '—')} | {fmt_rate(u.get('cache_hit_rate'))} | {r['why']} |" + ) + if not summary["usage_available"]: + lines += [ + "", + "> ⚠️ **账目不可用**:所有任务的 /kernel 差分都为空。", + "> 说明目标实例的内核还没带上 usage 段(/kernel 暴露用量是 2026-09-30 的改动),", + "> 或者适配器没有把上游 usage 透传上来。此时 token 列全为「—」,", + "> 请先部署新内核再重跑 —— 不要把这些空白当成「消耗为 0」。", + ] + lines += ["", "## 逐任务明细", ""] + for r in records: + lines += [ + f"### {r['id']}({r['dimension']})", + "", + f"- 结果:{'通过' if r['ok'] else '失败'} — {r['why']}", + f"- 墙钟:{r['wall_s']}s,工具调用 {r['tool_calls']} 次({', '.join(r['tools']) or '无'})", + f"- 任务差分用量:`{json.dumps(r['usage_task'], ensure_ascii=False)}`", + f"- 单次回包用量:`{json.dumps(r['usage_single'], ensure_ascii=False)}`", + "", + "回复节选:", + "", + "```", + (r["reply"] or "").strip()[:800], + "```", + "", + ] + return "\n".join(lines) + + +def self_test() -> int: + """自检:不连实例,验证本台子的纯逻辑。 + + 为何要有:usage_delta 与 check 一旦错了,标定结果会**系统地**错, + 而它们跟实例无关、完全可以离线验证。跑一次就少一类假数据。 + """ + fails: list[str] = [] + + def eq(name: str, got, want) -> None: + if got != want: + fails.append(f"{name}: got={got!r} want={want!r}") + + # ---- usage_delta ---- + before = {"calls": 1, "prompt": 100, "completion": 10, "total": 110, + "cache_read": 50, "cache_miss": 50, "reasoning": 0} + after = {"calls": 4, "prompt": 700, "completion": 70, "total": 770, + "cache_read": 500, "cache_miss": 200, "reasoning": 0} + d = usage_delta(before, after) + eq("delta.calls", d.get("calls"), 3) + eq("delta.prompt", d.get("prompt"), 600) + eq("delta.total", d.get("total"), 660) + # ❗命中率按**差分**算,不是拿 after 的绝对值: + # Δread = 500-50 = 450,Δmiss = 200-50 = 150 ⇒ 450/600 = 0.75。 + # (我第一版判据写成 500/700,被自检拓住了。) + eq("delta.hit_rate", round(d.get("cache_hit_rate", 0), 4), 0.75) + # 任一侧缺数 ⇒ 空(宁可不报不编) + eq("delta.empty_before", usage_delta({}, after), {}) + eq("delta.empty_after", usage_delta(before, {}), {}) + # 全零差分不该报命中率(分母为 0) + zero = usage_delta({"cache_read": 0, "cache_miss": 0}, {"cache_read": 0, "cache_miss": 0}) + eq("delta.no_denominator", "cache_hit_rate" in zero, False) + + # ---- check ---- + eq("check.no_signature", check({}, {"content": "x"}, [], 1.0)[0], False) + eq("check.contains_hit", check({"check": {"kind": "output_contains", "value": "ok"}}, + {"content": "all ok"}, [], 1.0)[0], True) + eq("check.contains_miss", check({"check": {"kind": "output_contains", "value": "nope"}}, + {"content": "all ok"}, [], 1.0)[0], False) + eq("check.regex", check({"check": {"kind": "output_regex", "value": "tool\\.go:\\d+"}}, + {"content": "internal/sdk/tool.go:72"}, [], 1.0)[0], True) + eq("check.tool_used_hit", check({"check": {"kind": "tool_used", "value": "cmd_run"}}, + {"content": ""}, + [{"type": "tool_call", "tool": "cmd_run"}], 1.0)[0], True) + eq("check.tool_used_miss", check({"check": {"kind": "tool_used", "value": "cmd_run"}}, + {"content": ""}, [], 1.0)[0], False) + # completed:有 error 就不算完成 + eq("check.completed_ok", check({"check": {"kind": "completed"}}, {"content": ""}, [], 1.0)[0], True) + eq("check.completed_err", check({"check": {"kind": "completed"}}, + {"error": "boom"}, [], 1.0)[0], False) + eq("check.unknown_kind", check({"check": {"kind": "wat"}}, {"content": ""}, [], 1.0)[0], False) + + # ---- 命中率格式化:None 必须是「—」而不是 0% ---- + eq("fmt_rate.none", fmt_rate(None), "—") + eq("fmt_rate.zero", fmt_rate(0.0), "0.0%") + + # ---- summarize:没数据时不编命中率 ---- + recs = [{"ok": True, "wall_s": 1.0, "tool_calls": 0, "usage_task": {}}] + s = summarize(recs) + eq("summary.hit_rate_none", s["cache_hit_rate"], None) + eq("summary.usage_unavailable", s["usage_available"], False) + + if fails: + print("自检失败:", file=sys.stderr) + for f in fails: + print(" " + f, file=sys.stderr) + return 1 + print("自检通过(usage_delta / check / fmt_rate / summarize)") + return 0 + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="HomeAgent 能力标定台(经 cli.sock)") + ap.add_argument("--data", help="实例 data 目录(用它的 cli.sock)") + ap.add_argument("--socket", help="直接指定 cli.sock 路径(覆盖 --data)") + ap.add_argument("--api-key", default=os.environ.get("HOMEAGENT_CLI_KEY", ""), + help="cli 认证密钥(默认读环境变量 HOMEAGENT_CLI_KEY)") + # --tasks/--out 不设为 required:--self-test 与 --dry-run 都不需要它们。 + # 缺参由下面显式校验(报错更清楚,也让自检能在无任务集时跑)。 + ap.add_argument("--tasks", help="任务集 JSON") + ap.add_argument("--out", help="输出目录") + ap.add_argument("--timeout", type=float, default=600.0, help="单任务默认超时(秒)") + ap.add_argument("--only", help="只跑逗号分隔的这些任务 id") + ap.add_argument("--dry-run", action="store_true", help="只打印将跑什么,不连实例") + ap.add_argument("--self-test", action="store_true", help="离线自检本台子的纯逻辑(不连实例)") + args = ap.parse_args(argv) + + if args.self_test: + return self_test() + + if not args.tasks or not args.out: + print("--tasks 与 --out 为必填(除非用 --self-test)", file=sys.stderr) + return 2 + + sock = args.socket or (str(Path(args.data) / "cli.sock") if args.data + else str(Path.home() / ".homeagent" / "cli.sock")) + try: + tasks = json.loads(Path(args.tasks).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + # 给出可行动的报错,而不是一屏 traceback。 + print(f"读任务集失败 {args.tasks}: {exc}", file=sys.stderr) + return 2 + if isinstance(tasks, dict): + tasks = tasks.get("tasks") or [] + if args.only: + keep = {s.strip() for s in args.only.split(",") if s.strip()} + tasks = [t for t in tasks if t.get("id") in keep] + + if args.dry_run: + print(f"socket: {sock}") + for t in tasks: + print(f" [{t.get('dimension','?'):<12}] {t.get('id'):<24} " + f"timeout={t.get('timeout_s') or args.timeout}s check={t.get('check',{}).get('kind')}") + print(f"共 {len(tasks)} 个任务(dry-run,未连接实例)") + return 0 + + if not tasks: + print("任务集为空", file=sys.stderr) + return 2 + if not Path(sock).exists(): + print(f"socket 不存在: {sock}(实例没起来?或 --data 指错了)", file=sys.stderr) + return 2 + + started_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + print(f"连接 {sock} …") + session = CliSession(sock, args.api_key, args.timeout) + print("已认证,开始跑任务\n") + + records: list[dict] = [] + try: + for i, task in enumerate(tasks, 1): + tid = task.get("id") + print(f"[{i}/{len(tasks)}] {tid} ({task.get('dimension','?')}) …", flush=True) + rec = run_task(session, task, args.timeout) + records.append(rec) + mark = "PASS" if rec["ok"] else "FAIL" + u = rec["usage_task"] + tok = u.get("total", "—") + print(f" {mark} {rec['wall_s']}s tools={rec['tool_calls']} tokens={tok} — {rec['why']}", + flush=True) + except BenchError as exc: + print(f"\n驱动层失败: {exc}", file=sys.stderr) + return 3 + finally: + session.close() + + summary = summarize(records) + meta = { + "started_at": started_at, + "socket": sock, + "tasks_file": args.tasks, + "timeout_s": args.timeout, + } + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + (out / "results.json").write_text( + json.dumps({"meta": meta, "summary": summary, "records": records}, + ensure_ascii=False, indent=2), encoding="utf-8") + (out / "report.md").write_text(render_markdown(meta, summary, records), encoding="utf-8") + + rate = fmt_rate(summary["cache_hit_rate"]) + print(f"\n{'=' * 60}") + print(f"通过 {summary['passed']}/{summary['tasks']} 墙钟 {summary['wall_s_total']}s " + f"token {summary['total_tokens']} 缓存命中率 {rate}") + if not summary["usage_available"]: + print("⚠️ 账目不可用(/kernel 差分全空)—— 实例内核可能还是旧版") + print(f"报告: {out / 'report.md'}") + print(f"原始: {out / 'results.json'}") + return 0 if summary["passed"] == summary["tasks"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/capability-bench/compare.py b/scripts/capability-bench/compare.py new file mode 100644 index 00000000..d7e78183 --- /dev/null +++ b/scripts/capability-bench/compare.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""把两个 harness 的标定结果并排对比。 + +用法: + python3 compare.py --a /var/tmp/cmp/ha --b /var/tmp/cmp/pi \ + --label-a HomeAgent --label-b pi --out /var/tmp/cmp + +设计要点(都是踩过坑之后的纪律): + +1. **两侧必须跑同一套任务、同一个模型**。本脚本会检查任务 id 集合是否一致, + 不一致就明确报出来 —— 悄悄对比不同任务集是产生假结论的最快方式。 + +2. **口径差异必须先归一,再比。** 实测: + HomeAgent 的 prompt 含缓存(OpenAI 口径) + pi 的 input 是未命中侧(Anthropic 口径,totalTokens = input+read+output) + 两者若直接比,会给 pi 系统性低估。归一在各自 runner 里做(见 pi_bench.py + 的 _usage_of),本脚本只比归一后的字段。 + +3. **命中率无数据时写「—」而不是 0%**。与内核 usageLedger 同一约定。 + +4. **成本栏位**:本机网关不计费(cost 全 0),所以不编美元数字; + 只报 token 与墙钟 —— 有数就报数,没数就不报。 +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def load(d: str) -> dict: + p = Path(d) / "results.json" + if not p.exists(): + print(f"缺少 {p}", file=sys.stderr) + sys.exit(2) + try: + return json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"读 {p} 失败: {exc}", file=sys.stderr) + sys.exit(2) + + +def fmt(n, suffix: str = "") -> str: + if n is None: + return "—" + try: + n = float(n) + except (TypeError, ValueError): + return "—" + if n >= 1000000: + return f"{n / 1000000:.2f}M{suffix}" + if n >= 1000: + return f"{n / 1000:.1f}k{suffix}" + return f"{n:.0f}{suffix}" + + +def rate(v) -> str: + return "—" if v is None else f"{v * 100:.1f}%" + + +def main() -> int: + ap = argparse.ArgumentParser(description="两个 harness 的标定结果并排对比") + ap.add_argument("--a", required=True, help="A 的结果目录(如 HomeAgent)") + ap.add_argument("--b", required=True, help="B 的结果目录(如 pi)") + ap.add_argument("--label-a", default="A") + ap.add_argument("--label-b", default="B") + ap.add_argument("--out", help="在此目录写 compare.md(默认不写文件)") + args = ap.parse_args() + + A, B = load(args.a), load(args.b) + ra = {r["id"]: r for r in A["records"]} + rb = {r["id"]: r for r in B["records"]} + + # 纪律 1:任务集必须一致,否则比了也没意义。 + only_a = sorted(set(ra) - set(rb)) + only_b = sorted(set(rb) - set(ra)) + shared = [i for i in ra if i in rb] + + L = [] + L.append(f"# {args.label_a} vs {args.label_b} —— 能力标定对比") + L.append("") + L.append(f"- 结果目录:`{args.a}` vs `{args.b}`") + L.append(f"- 任务集:{args.label_a} {len(ra)} 个 / {args.label_b} {len(rb)} 个,共同 {len(shared)} 个") + L.append(f"- 模型:{args.label_a} `{A['meta'].get('socket')}` / {args.label_b} `{B['meta'].get('socket')}`") + L.append("") + if only_a or only_b: + L.append(f"> ⚠️ 任务集不完全一致:仅 {args.label_a}:{only_a};仅 {args.label_b}:{only_b}") + L.append("") + + L.append("## 汇总(仅共同任务)") + L.append("") + L.append(f"| 指标 | {args.label_a} | {args.label_b} |") + L.append("|---|---|---|") + + def agg(records: dict, ids: list) -> dict: + rs = [records[i] for i in ids if i in records] + n = len(rs) + ok = sum(1 for r in rs if r["ok"]) + wall = sum(r["wall_s"] for r in rs) + tools = sum(r["tool_calls"] for r in rs) + tok = sum(r["usage_task"].get("total", 0) for r in rs) + rd = sum(r["usage_task"].get("cache_read", 0) for r in rs) + ms = sum(r["usage_task"].get("cache_miss", 0) for r in rs) + return { + "n": n, "ok": ok, + "pass": (ok / n) if n else None, + "wall": wall, "tools": tools, "tok": tok, + "rate": (rd / (rd + ms)) if (rd or ms) else None, + } + + aa, ab = agg(ra, shared), agg(rb, shared) + L.append(f"| 通过 | {aa['ok']}/{aa['n']}({rate(aa['pass'])})| {ab['ok']}/{ab['n']}({rate(ab['pass'])})|") + L.append(f"| 总墙钟 | {aa['wall']:.1f}s | {ab['wall']:.1f}s |") + L.append(f"| 工具调用总数 | {aa['tools']} | {ab['tools']} |") + L.append(f"| token 合计 | {fmt(aa['tok'])} | {fmt(ab['tok'])} |") + L.append(f"| 缓存命中率 | {rate(aa['rate'])} | {rate(ab['rate'])} |") + L.append("") + + L.append("## 逐任务") + L.append("") + L.append(f"| 任务 | 维度 | {args.label_a} | {args.label_b} | {args.label_a} token | {args.label_b} token |") + L.append("|---|---|---|---|---|---|") + for i in shared: + a, b = ra[i], rb[i] + L.append( + f"| {i} | {a.get('dimension','?')} | {'✅' if a['ok'] else '❌'} {a['wall_s']:.1f}s " + f"| {'✅' if b['ok'] else '❌'} {b['wall_s']:.1f}s " + f"| {fmt(a['usage_task'].get('total'))} | {fmt(b['usage_task'].get('total'))} |" + ) + L.append("") + + # 失败原因要留在报告里,否则「谁赢了」没有可查证的依据 + fails = [(i, ra[i]) for i in shared if not ra[i]["ok"]] + \ + [(i, rb[i]) for i in shared if not rb[i]["ok"]] + if fails: + L.append("## 失败详情") + L.append("") + for i, r in fails: + side = args.label_a if r in [ra[x] for x in shared] else args.label_b + L.append(f"- **{i}**({side}):{r['why']};error={r.get('error')}") + L.append("") + + L.append("## 读表须知") + L.append("") + L.append("- **token 不可直接比大小**:两侧 harness 的系统提示与工具表体积不同,") + L.append(" 基线开销本来就不一样。要比的是「同一 harness 内的变化」与「通过率」。") + L.append("- **缓存命中率已归一**:HomeAgent 的 prompt 含缓存(OpenAI 口径),") + L.append(" pi 的 input 是未命中侧(Anthropic 口径)—— 各自 runner 里已归一成") + L.append(" `cache_read / (cache_read + cache_miss)`。未报缓存时显示「—」而非 0%。") + L.append("- **成本**:本机网关不计费,故不报美元;只报 token 与墙钟。") + + text = "\n".join(L) + print(text) + if args.out: + p = Path(args.out) / "compare.md" + p.write_text(text + "\n", encoding="utf-8") + print(f"\n已写: {p}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/capability-bench/gen_client_fixtures.py b/scripts/capability-bench/gen_client_fixtures.py new file mode 100644 index 00000000..5b474b7e --- /dev/null +++ b/scripts/capability-bench/gen_client_fixtures.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""客户端能力任务集的 fixture 生成器(确定性,可重复执行)。 + +为什么单独一个生成器 +==================== + +客户端跑分考的是 harness:工具循环、错误恢复、大输出、跨步状态。 +这些任务的材料必须: + · **确定性**:同样种子生成同样数据,期望答案在生成时算好写进任务集; + · **必须用工具**:所有任务都无法靠"读一遍材料心算"完成; + · **判据落盘**:答案写文件,checker 用 file_contains,与两侧 harness 无关。 + +期望答案在生成时**独立复算两遍**(两种算法),不一致就拒绝生成 —— +判据本身错了,跑分就是仪式。 +""" +from __future__ import annotations + +import hashlib +import json +import random +import subprocess +import sys +from pathlib import Path + +ROOT = Path("/var/tmp/client-bench") + + +def fail(msg: str) -> None: + print(f"★ {msg}", file=sys.stderr) + sys.exit(1) + + +# ──────────────────────────────────────────────────────────────── +# 任务 1:fixloop —— 工具循环(跑测试→修→再跑到全绿) +# ──────────────────────────────────────────────────────────────── + +BUGGY_STORE = '''"""KVStore —— 带三个已知缺陷的实现(跑分用,勿用于生产)。""" + + +class KVStore: + def __init__(self) -> None: + self._data = {} + + def put(self, key: str, value: object) -> None: + self._data[key] = value + + def get(self, key: str, default: object = None) -> object: + return self._data.get(key, default) + + def delete(self, key: str) -> bool: + # 缺陷①:只删一半 —— 键存在时删掉,但同时把后续写入顺序破坏 + if key in self._data: + del self._data[key] + self._data = dict(reversed(list(self._data.items()))) + return True + return False + + def items_slice(self, start: int, end: int) -> list: + """按下标返回 (key, value) 列表,[start, end) 左闭右开。""" + items = sorted(self._data.items()) + # 缺陷②:边界差一(把 end 也包进去了) + return items[start:end + 1] + + def top_values(self, n: int) -> list: + """返回值最大的前 n 个 (key, value)。""" + # 缺陷③:排序方向反了 + return sorted(self._data.items(), key=lambda kv: kv[1])[:n] +''' + +STORE_TESTS = '''"""KVStore 的验收测试。全部通过后把 "ALL GREEN 6" 写入 result.txt。""" +import os +import unittest + +from store import KVStore + + +class TestKVStore(unittest.TestCase): + def setUp(self): + self.s = KVStore() + for i, k in enumerate("abcdef"): + self.s.put(k, i * 10) + + def test_get_default(self): + self.assertEqual(self.s.get("a"), 0) + self.assertEqual(self.s.get("zz", "dflt"), "dflt") + + def test_delete_returns_bool(self): + self.assertTrue(self.s.delete("a")) + self.assertFalse(self.s.delete("a")) + self.assertIsNone(self.s.get("a")) + + def test_delete_preserves_others(self): + self.s.delete("c") + self.assertEqual(self.s.get("d"), 30) + self.assertEqual(self.s.items_slice(0, 5), [(k, i * 10) for i, k in enumerate("abdef")]) + + def test_items_slice_half_open(self): + # [1, 4) => b, c, d + self.assertEqual([k for k, _ in self.s.items_slice(1, 4)], ["b", "c", "d"]) + + def test_top_values_desc(self): + self.assertEqual([k for k, _ in self.s.top_values(2)], ["f", "e"]) + + def test_top_values_count(self): + self.assertEqual(len(self.s.top_values(10)), 6) + + +if __name__ == "__main__": + result = unittest.main(exit=False) + if result.result.wasSuccessful() and result.result.testsRun == 6: + with open(os.path.join(os.path.dirname(__file__), "result.txt"), "w") as f: + f.write(f"ALL GREEN {result.result.testsRun}\\n") + print("ALL GREEN", result.result.testsRun) + else: + bad = len(result.result.failures) + len(result.result.errors) + print(f"FAILED tests={result.result.testsRun} bad={bad}") +''' + + +def gen_fixloop() -> str: + d = ROOT / "fixloop" + d.mkdir(parents=True, exist_ok=True) + (d / "store.py").write_text(BUGGY_STORE, encoding="utf-8") + (d / "test_store.py").write_text(STORE_TESTS, encoding="utf-8") + for stale in ("result.txt",): + (d / stale).unlink(missing_ok=True) + # 自证:缺陷版本必须真的红(否则任务无意义) + r = subprocess.run([sys.executable, "test_store.py"], cwd=d, capture_output=True, text=True, timeout=60) + if "FAILED" not in (r.stdout + r.stderr): + fail(f"fixloop 缺陷版没红,fixture 无效:{r.stdout} {r.stderr}") + bad = [l for l in (r.stdout + r.stderr).splitlines() if l.startswith("FAILED")] + print(f" fixloop: 初始 {bad[0] if bad else 'FAILED(见输出)'}") + return "ALL GREEN 6" + + +# ──────────────────────────────────────────────────────────────── +# 任务 2:pipeline —— 错误恢复(坏编码文件 + 管道跑通) +# ──────────────────────────────────────────────────────────────── + +EXTRACT_PY = '''"""从 events_*.jsonl 抽取 unique user,写 users.csv 并打印 users=N。 + +用法: python3 extract.py +""" +import csv +import glob +import json + +users = {} +for path in sorted(glob.glob("events_*.jsonl")): + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + rec = json.loads(line) + users[rec["user"]] = rec["signup"] + +with open("users.csv", "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["user", "signup"]) + for u in sorted(users): + w.writerow([u, users[u]]) + +print(f"users={len(users)}") +''' + + +def gen_pipeline() -> int: + d = ROOT / "pipeline" + d.mkdir(parents=True, exist_ok=True) + rng = random.Random(20260830) + expected: set[str] = set() + names = [f"user_{i:04d}" for i in range(260)] + for fi in range(5): + path = d / f"events_2026-08-{fi + 1:02d}.jsonl" + lines = [] + for _ in range(90): + u = rng.choice(names) + expected.add(u) + rec = {"user": u, "action": rng.choice(["login", "view", "click"]), + "signup": f"2026-0{rng.randint(1, 8)}-{rng.randint(10, 28)}"} + lines.append(json.dumps(rec, ensure_ascii=False)) + # 第 3 个文件整体是 GBK 编码 —— utf-8 直接读会 UnicodeDecodeError + if fi == 2: + path.write_bytes("\n".join(lines).encode("gbk")) + else: + path.write_text("\n".join(lines), encoding="utf-8") + (d / "extract.py").write_text(EXTRACT_PY, encoding="utf-8") + (d / "users.csv").unlink(missing_ok=True) + (d / "report.txt").unlink(missing_ok=True) + # 自证:期望值用第二种算法复算 + expect2 = set() + for fi in range(5): + raw = (d / f"events_2026-08-{fi + 1:02d}.jsonl").read_bytes() + for line in raw.decode("gbk").splitlines(): + if line.strip(): + expect2.add(json.loads(line)["user"]) + if expect2 != expected: + fail(f"pipeline 期望值两种算法不一致: {len(expected)} vs {len(expect2)}") + print(f" pipeline: unique users={len(expected)}(坏编码文件: events_2026-08-03.jsonl, GBK)") + return len(expected) + + +# ──────────────────────────────────────────────────────────────── +# 任务 3:aggregate —— 大工具输出 + 去重聚合 +# ──────────────────────────────────────────────────────────────── + +def gen_aggregate() -> int: + d = ROOT / "aggregate" + d.mkdir(parents=True, exist_ok=True) + for old in d.glob("*.json"): + old.unlink() + (d / "answer.txt").unlink(missing_ok=True) + rng = random.Random(20260901) + paid: dict[str, dict] = {} + for day in range(1, 16): + recs = [] + for _ in range(300): + oid = f"ord-{rng.randint(10 ** 6, 10 ** 7 - 1)}" + st = rng.choices(["paid", "cancelled", "pending"], weights=[6, 2, 2])[0] + rec = {"order_id": oid, "status": st, + "amount": round(rng.uniform(5, 900), 2), + "ts": f"2026-08-{day:02d}T{rng.randint(0, 23):02d}:{rng.randint(10, 59):02d}:00"} + recs.append(rec) + if st == "paid": + paid[oid] = rec + (d / f"orders-2026-08-{day:02d}.json").write_text( + json.dumps(recs, ensure_ascii=False, indent=1), encoding="utf-8") + expected = len(paid) + # 自证:另一种算法(逐文件流式数集合) + seen2: set[str] = set() + for day in range(1, 16): + for rec in json.loads((d / f"orders-2026-08-{day:02d}.json").read_text(encoding="utf-8")): + if rec["status"] == "paid": + seen2.add(rec["order_id"]) + if len(seen2) != expected: + fail(f"aggregate 期望值两种算法不一致: {expected} vs {len(seen2)}") + total_lines = sum(1 for day in range(1, 16) + for _ in (d / f"orders-2026-08-{day:02d}.json").read_text(encoding="utf-8").splitlines()) + print(f" aggregate: 15 个文件共 ~{total_lines} 行,unique paid orders={expected}") + return expected + + +# ──────────────────────────────────────────────────────────────── +# 任务 4:chain —— 跨步状态(6 步大数运算,心算不可行) +# ──────────────────────────────────────────────────────────────── + +CHAIN_STEPS = [ + ("step1.md", "当前值是 start.txt 里的种子。计算:当前值 × 7919。把结果写入 state.txt(只写数字)。"), + ("step2.md", "当前值在 state.txt。计算:当前值 + 1234567。把结果写回 state.txt。"), + ("step3.md", "当前值在 state.txt。计算:当前值 ÷ 97 的整数商(向下取整)。把结果写回 state.txt。"), + ("step4.md", "当前值在 state.txt。计算:当前值的平方。把结果写回 state.txt。"), + ("step5.md", "当前值在 state.txt。计算:当前值 mod 1000000007。把结果写回 state.txt。"), + ("step6.md", "当前值在 state.txt。计算:当前值 XOR 305419896(按位异或,十进制)。把结果写回 state.txt。"), +] + + +def gen_chain() -> int: + d = ROOT / "chain" + (d / "steps").mkdir(parents=True, exist_ok=True) + for name, text in CHAIN_STEPS: + (d / "steps" / name).write_text(text + "\n", encoding="utf-8") + seed = 482913 + (d / "start.txt").write_text(f"{seed}\n", encoding="utf-8") + (d / "state.txt").unlink(missing_ok=True) + (d / "final.txt").unlink(missing_ok=True) + v = seed + for _ in range(6): + v = v * 7919 + v = v + 1234567 + v = v // 97 + v = v * v + v = v % 1000000007 + v = v ^ 305419896 + # 自证:逐步算两遍没有意义(纯函数),但至少确认不是平凡值 + if v == seed or v < 10 ** 8: + fail("chain 期望值可疑(平凡)") + print(f" chain: seed={seed} 期望 answer={v}") + return v + + +# ──────────────────────────────────────────────────────────────── + +def main() -> None: + ROOT.mkdir(parents=True, exist_ok=True) + print(f"生成客户端能力 fixture 于 {ROOT}") + fix_expect = gen_fixloop() + pipe_expect = gen_pipeline() + agg_expect = gen_aggregate() + chain_expect = gen_chain() + + tasks = { + "_comment": [ + "客户端能力任务集 v2 —— 考 harness 而非裸 LLM。", + "两侧同模型(llmsproxy AUTO),能产生差异的只有 harness:", + "工具实现质量、错误回填、循环管理、跨步状态保持。", + "判据全部落盘(file_contains),与两侧工具名无关。", + "", + "生成器:scripts/capability-bench/gen_client_fixtures.py(期望答案在生成时独立复算)", + "材料目录:/var/tmp/client-bench/{fixloop,pipeline,aggregate,chain}", + ], + "tasks": [ + { + "id": "cl-fixloop", + "dimension": "tool-loop", + "prompt": ( + "在 /var/tmp/client-bench/fixloop/ 目录有一个实现和它的测试。" + "运行测试,修复实现代码直到全部测试通过(不许改测试文件)。" + "全部通过时测试程序会自动生成 result.txt。完成后报告:改了哪些缺陷。" + ), + "check": {"kind": "file_contains", "value": "/var/tmp/client-bench/fixloop/result.txt", + "needle": fix_expect}, + "timeout_s": 600, + }, + { + "id": "cl-pipeline", + "dimension": "error-recovery", + "prompt": ( + "在 /var/tmp/client-bench/pipeline/ 目录运行 python3 extract.py," + "它现在跑不通。修到它能跑通(不许改 events_*.jsonl 数据文件)," + "然后把最终输出里的 unique 用户数以 users=N 的格式写入该目录的 report.txt。" + ), + "check": {"kind": "file_contains", "value": "/var/tmp/client-bench/pipeline/report.txt", + "needle": f"users={pipe_expect}"}, + "timeout_s": 600, + }, + { + "id": "cl-aggregate", + "dimension": "large-output", + "prompt": ( + "/var/tmp/client-bench/aggregate/ 下有 15 个订单 JSON 文件(每天一个)。" + "统计:status 为 paid 的**去重后** order_id 总数(同一 order_id 可能出现在多个文件)。" + "把结果以 total=N 的格式写入该目录的 answer.txt。要求数字准确。" + ), + "check": {"kind": "file_contains", "value": "/var/tmp/client-bench/aggregate/answer.txt", + "needle": f"total={agg_expect}"}, + "timeout_s": 600, + }, + { + "id": "cl-chain", + "dimension": "state-chain", + "prompt": ( + "阅读 /var/tmp/client-bench/chain/start.txt 和 chain/steps/ 下 step1.md 到 step6.md," + "严格按顺序逐步执行(每步用工具计算并把中间值写入 chain/state.txt,不许跳步、不许心算)。" + "全部完成后把最终值以 answer=值的格式写入 chain/final.txt。" + ), + "check": {"kind": "file_contains", "value": "/var/tmp/client-bench/chain/final.txt", + "needle": f"answer={chain_expect}"}, + "timeout_s": 600, + }, + ], + } + out = Path(__file__).parent / "tasks.client.json" + out.write_text(json.dumps(tasks, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(f" 任务集已写 {out}") + # sha256 留档:重跑时确认材料没漂移 + h = hashlib.sha256() + for p in sorted(ROOT.rglob("*")): + if p.is_file() and p.name != "result.txt": + h.update(p.read_bytes()) + print(f" fixture sha256={h.hexdigest()[:16]}") + + +if __name__ == "__main__": + main() diff --git a/scripts/capability-bench/memory_recall.py b/scripts/capability-bench/memory_recall.py new file mode 100644 index 00000000..41314f58 --- /dev/null +++ b/scripts/capability-bench/memory_recall.py @@ -0,0 +1,765 @@ +#!/usr/bin/env python3 +"""记忆召回测试 v2 —— 打在「压缩 vs 外化裁剪」真正分叉的地方。 + +v1 的两个结构性缺陷(两侧行为完全一致地打满分 ⇒ 无区分度): + 1. 针全部显式标注「请牢牢记住」⇒ 任何 harness 都会保住被标重点的内容; + 2. 填充是语义空转的中性句 ⇒ 压缩可以几乎无损摘要,向量裁剪也挑不出毛病。 + +v2 的设计原则:**取消一切显式强调**,把要召回的事实伪装成填充的一部分, +并引入两类压缩天然吃亏、检索/外化理论上占优的负载: + +探针类型(全部落在 plan 里,报告按类分层): + · casual 偶发事实 —— 某值只在一轮填充里顺口出现一次,无任何标记 + · overwrite 值覆盖 —— 同一属性先后给两个值,只认最新值(压缩易塌回旧值) + · multihop 多跳散点 —— 答案 = 两个分散在不同轮的偶发事实的组合运算 + · paraphrase 改述提问 —— 提问措辞与出现时的措辞不同,防廉价字符串匹配 + +填充分三档: + · neutral 中性句(保留 v1 风格,作对照) + · confusable 同域干扰 —— 大量形似值(端口/代号/编号),逼检索分辨 + · needle 偶发针就藏在同域干扰轮里(这是关键:针不单独成轮) + +预期:两侧都不该 100%。若偶发针仍全中,说明判据或填充强度不够,先改工具再谈结论。 +""" +from __future__ import annotations + +import argparse +import json +import os +import random +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import bench + + +def est_tokens(s: str) -> int: + """与内核同口径的估算(min(字节, 2×rune))—— 用于控制灌入量。""" + b = len(s.encode("utf-8")) + r = len(s) * 2 + return max(1, min(b, r)) if s else 0 + + +def _int(v: object) -> int: + try: + return int(v) # type: ignore[arg-type] + except (TypeError, ValueError): + return 0 + + +def _norm_ha_usage(u: object) -> dict: + """HomeAgent(OpenAI 口径,prompt 含缓存)→ 统一键。""" + u = u if isinstance(u, dict) else {} + return { + "prompt": _int(u.get("prompt_tokens")), + "completion": _int(u.get("completion_tokens")), + "total": _int(u.get("total_tokens")), + "cache_read": _int(u.get("cache_read_tokens")), + "cache_miss": _int(u.get("cache_miss_tokens")), + } + + +def _norm_pi_usage(u: object) -> dict: + """pi(Anthropic 口径,input=未命中侧)→ 统一键。""" + u = u if isinstance(u, dict) else {} + uncached = _int(u.get("input")) + read = _int(u.get("cacheRead")) + out = _int(u.get("output")) + return { + "prompt": uncached + read, "completion": out, + "total": _int(u.get("totalTokens")) or (uncached + read + out), + "cache_read": read, "cache_miss": uncached, + } + + +# ════════════════════════════════════════════════════════════════ +# 材料生成(全部确定性:seed 相同 ⇒ 探针/答案/干扰集完全一致) +# ════════════════════════════════════════════════════════════════ +# +# ★ v3 填充模型:高密度叙事,不是废话流。 +# +# v2 的填充("打印机需要更换墨盒(编号 123456)"×104 轮)是**明显无价值** +# 的信息:pi 压缩时直接丢弃垃圾,偶发针反而成了填充里仅有的有价值内容 +# 被保住 —— 跑分虚高,且不贴合真实对话。真实对话是高信息密度的: +# 有任务、决策、因果、变化、前后引用,压缩必须在「保什么丢什么」间真取舍。 +# +# v3 主线:order-gw 服务运维全程(上线准备→灰度事故→修复验证→新版本→ +# 交接→收尾)。针全部自然嵌在价值信息流里: +# · 偶发针 = 服务依赖清单里的真实条目(同构干扰 = 其它服务的端口, +# 同样有意义,只是不是针) +# · 覆盖针 = 值班安排里的分机变更(真实变更流) +# · 多跳 = 交接流程(团队→门禁→申请表位置) +# 叙事含跨轮引用("按之前定的阈值…"),压缩丢弃早期轮次会破坏骨架。 + +SERVICE_PHASES = [ # (占比起点, 阶段名) —— 由 build_plan 按填充轮总数切分 + (0.00, "launch-prep"), + (0.16, "incident"), + (0.46, "verify"), + (0.61, "release"), + (0.76, "handover"), +] + + +class Material: + """v2 全部材料。生成时同时产出:对话脚本 + 期望答案 + 判据。""" + + def __init__(self, rng_seed: int, window: int, overshoot: float): + rng = random.Random(rng_seed) + self.rng = rng + self.window = window + self.overshoot = overshoot + + # ---- 偶发针:值伪装成同域干扰值,措辞与干扰项同构 ---- + # 同域 A:服务端口(4-5 位数)。4 根偶发针 + 40 个干扰值混在一批填充里。 + self.casual = { + "auth 服务的端口": rng.randrange(8000, 8999), + "metrics 服务的端口": rng.randrange(8000, 8999), + "trace 服务的端口": rng.randrange(8000, 8999), + "admin 服务的端口": rng.randrange(8000, 8999), + } + self.port_distractors = [rng.randrange(8000, 8999) for _ in range(40)] + # 同域 B:内部代号(字母-数字)。多跳针 + 干扰值。 + self.code_distractors = [ + f"{rng.choice(['BLUE','GREEN','SILVER','COPPER','IVORY','ONYX'])}" + f"-{rng.randrange(1000, 9999)}" for _ in range(24) + ] + self.multihop_team = f"{rng.choice(['SILVER','COPPER','IVORY','ONYX'])}-{rng.randrange(1000, 9999)}" + self.multihop_room = rng.choice(["A1103", "A1107", "B2201", "B2210", "C3305"]) + + # ---- 覆盖针:同一属性先后两个值,只认新值 ---- + self.overwrite_key = "值班室的分机号" + self.overwrite_old = rng.randrange(4000, 4999) + self.overwrite_new = rng.randrange(4000, 4999) + + # ---- v4 判据组:区分「语义取舍」与「侥幸命中」 ---- + # 动机(2026-10-01):v3d 里 pi 压缩后 6/6 全中,但探针**太短太集中** + # (全是 4 位端口 + 一个覆盖值),被丢在摘要角落也能命中,分辨不出 + # 压缩质量。以下三类专门打「摘要到底保住了什么」: + # + # (A) unmarked:填充里埋一个**语义自足但从未被问过**的规则声明。 + # 真正的语义压缩会把它当约束保留;纯截断/关键词摘要会丢。 + # —— 测「有没有保住未被点名的东西」。 + self.rule_key = "端口表的归档口径" + self.rule_value = f"以第 {rng.randrange(40, 95)} 次上报为准,早期上报一律作废" + # (B) causal:一条三段因果链(现象→定位→措施),只问最后一步的结论。 + # 摘要爱列「要点清单」,清单外的**因果链中间环**容易丢; + # 丢了就编不出完整故事。 + self.causal_symptom = f"慢查询涨到每分钟 {rng.randrange(120, 400)} 条" + self.causal_cause = f"{rng.choice(['退款导出','对账任务','库存同步'])}逐单查库" + self.causal_fix = f"改批量查询后回落到每分钟 {rng.randrange(1, 4)} 条" + # (C) distractor-pair:一组**两个都合理但只有一个对**的近似值, + # 措辞里明确给出区分依据(“最新冻结的那个”)。 + # —— 测摘要有没有把“中间快照”当成最终值(这是压缩最典型的新错误)。 + self.freeze_old = f"{rng.randrange(300, 400)} 秒" + self.freeze_new = f"{rng.randrange(700, 900)} 秒" + + # ---- 填充 token 目标 ---- + self.target_tokens = int(window * overshoot) + # 叙事里的其它服务依赖(干扰端口):每个都是真实依赖,只是不是针 + self.dep_services = ["billing", "notify", "inventory", "search", "oauth", "captcha"] + self.dep_ports = {s: rng.randrange(8000, 8999) for s in self.dep_services} + self.dep_ports["auth"] = self.casual["auth 服务的端口"] + self.dep_ports["metrics"] = self.casual["metrics 服务的端口"] + self.dep_ports["trace"] = self.casual["trace 服务的端口"] + self.dep_ports["admin"] = self.casual["admin 服务的端口"] + + # -- 高密度叙事轮 -- + + def _dep_line(self, services: list[str]) -> str: + """依赖服务清单片段:真实工作信息,偶发针混在其中无任何标记。""" + parts = [f"{s} 服务的端口是 {self.dep_ports[s]}" for s in services] + return ";".join(parts) + + def narrative_block(self, i: int, total: int) -> tuple[str, str]: + """第 i/total 轮的高密度叙事片段。返回 (text, cat)。 + + 每轮 2-4 条有信息增量的工作项:数值、因果、决策、跨轮引用。 + 确定性:同 seed 同位置产出相同文本。""" + rng = self.rng + frac = i / max(1, total - 1) + phase = SERVICE_PHASES[0][1] + for start, name in SERVICE_PHASES: + if frac >= start: + phase = name + day = 12 + i // 6 # 叙事日期推进 + items: list[str] = [] + + if phase == "launch-prep": + if i == int(total * 0.10): + # ★ auth 偶发针:藏在依赖清单里(清单本身是真实工作项) + items.append(self._dep_line(["auth", "billing", "notify"])) + else: + items.append(f"order-gw 上线准备:压测环境跑通了下单链路,网关 QPS 压到 {rng.randrange(800, 1500)}," + f"错误率 {rng.randrange(2, 9) / 100:.2f}%,符合准入线") + items.append(f"超时配置定了:读接口 {rng.choice([800, 1000, 1200])}ms、写接口 {rng.choice([2500, 3000, 3500])}ms," + f"理由是写链路要等库存扣减(平均 {rng.randrange(400, 900)}ms)") + items.append(f"重试策略:最多 {rng.choice([2, 3])} 次,只对幂等接口开启;" + f"指数退避基数 {rng.choice([100, 200, 300])}ms") + if i == int(total * 0.05): + # ★ v4(A) unmarked:语义自足的规则声明,**从未被点名要求记住** + items.append(f"归档约定(团队内部通用):{self.rule_key}{self.rule_value};" + f"这点不改的话以后查历史端口会一直对不上") + if rng.random() < 0.5: + items.append(f"回滚预案演练完成:从发现异常到回滚到上一版本用时 {rng.randrange(3, 9)} 分钟," + f"预案里写明触发条件是错误率连续 5 分钟超过 {rng.choice([1.0, 2.0])}%") + + elif phase == "incident": + if i == int(total * 0.20): + # ★ 覆盖旧值:值班安排里的分机(真实变更流) + items.append(f"本周值班安排下来了,夜班有问题打{self.overwrite_key} {self.overwrite_old}," + f"值班的是 {rng.choice(['老陈','小王','阿李'])}") + else: + items.append(f"灰度事故推进:order-gw 的 p99 从 {rng.choice([180, 220, 260])}ms 涨到 " + f"{rng.randrange(2800, 5200)}ms,错误率峰值 {rng.randrange(4, 18)}%," + f"集中在 {rng.choice(['下单','退款','查询'])}接口") + if i == int(total * 0.30): + # ★ v4(B) causal:三段因果链(现象→根因→措施),只问最后一步 + items.append(f"根因链条完整记一下:现象是{self.causal_symptom};定位到{self.causal_cause};" + f"最后{self.causal_fix},这条是验收依据") + items.append(f"定位进展:慢查询数从每分钟 {rng.randrange(2, 6)} 条涨到 {rng.randrange(120, 400)} 条," + f"根因是 {rng.choice(['退款导出','对账任务','库存同步'])}在循环里逐单查库," + f"单次调用产生 {rng.randrange(600, 2000)} 次查询") + items.append(f"临时措施:把连接池从 {rng.choice([32, 64])} 扩到 {rng.choice([128, 256])}," + f"并给等待队列加了长度告警(阈值 {rng.choice([300, 400, 500])});" + f"注意这只是缓解,根因要等代码修复") + + elif phase == "verify": + if i == int(total * 0.52): + # ★ metrics 偶发针 + items.append(self._dep_line(["metrics", "inventory", "search"])) + else: + items.append(f"修复验证:N+1 查询改成批量后,慢查询回落到每分钟 {rng.randrange(1, 4)} 条," + f"p99 稳定在 {rng.randrange(150, 320)}ms,观察了 {rng.randrange(6, 24)} 小时无反弹") + items.append(f"缓存层核对了淘汰策略:ttl 设 {rng.choice([300, 600, 900])} 秒," + f"容量 {rng.choice([5000, 10000, 20000])} 条;命中率从 {rng.randrange(40, 60)}% 提到 {rng.randrange(72, 91)}%") + if i == int(total * 0.58): + # ★ v4(C) distractor-pair:两个都合理的 ttl,中间调过,最后冻结 + items.append(f"缓存 ttl 中间从 {self.freeze_old} 调到 {self.freeze_new} 试过一轮," + f"最终冻结值是 {self.freeze_new}(中间那个不算,别记混)") + items.append(f"限流参数调整:全局 {rng.randrange(400, 900)} QPS,单用户 {rng.randrange(5, 30)} QPS," + f"超限返回 {rng.choice([429, 503])} 并带 Retry-After") + + elif phase == "release": + if i == int(total * 0.66): + # ★ 覆盖新值(真实变更:值班表更新) + items.append(f"注意:下周起{self.overwrite_key}换成 {self.overwrite_new} 了," + f"旧号停用,值班轮换到 {rng.choice(['小赵','老周'])}") + elif i == int(total * 0.70): + # ★ trace 偶发针 + items.append(self._dep_line(["trace", "oauth", "captcha"])) + else: + items.append(f"新版本 v2.{rng.randrange(30, 34)}.{rng.randrange(0, 9)} 发布评审通过," + f"变更项:修复 N+1、连接池参数化、慢查询日志采样率 {rng.choice([5, 10, 20])}%") + items.append(f"发布窗口定在 {rng.choice(['周二','周三','周四'])} 凌晨 {rng.choice([1, 2, 3])} 点," + f"预计停机 {rng.randrange(3, 10)} 分钟;回滚版本锁定为 v2.{rng.randrange(28, 30)}.{rng.randrange(0, 6)}") + items.append(f"灰度比例:先 {rng.choice([5, 10])}% 流量观察 {rng.randrange(30, 90)} 分钟," + f"无异常再放到 {rng.choice([50, 100])}%") + + else: # handover + if i == int(total * 0.85): + # ★ admin 偶发针 + items.append(self._dep_line(["admin", "billing", "oauth"])) + elif i == int(total * 0.76): + # ★ 多跳要素 1:负责门禁的团队 + items.append(f"交接事项:门禁相关事务由 {self.multihop_team} 团队负责," + f"对接人是 {rng.choice(['小林','老郑','阿芳'])},工位在 {rng.choice(['3 楼东','4 楼西'])}") + elif i == int(total * 0.92): + # ★ 多跳要素 2:申请表位置 + items.append(f"门禁申请表已归档,放在 {self.multihop_room} 的文件柜," + f"需要 {rng.choice(['部门主管','行政'])}签字后提交") + else: + items.append(f"交接文档整理:监控大盘链接、告警规则 {rng.randrange(8, 20)} 条、" + f"值班手册更新到第 {rng.randrange(3, 9)} 版;新增了容量预警(连接池使用率连续 10 分钟超 " + f"{rng.choice([70, 80])}% 就升级到人工)") + items.append(f"后续排期:下季度做连接池动态化(现在改参数要重启)," + f"预计 {rng.choice(['10 月','11 月'])} 排期;另一个待办是把对账任务迁到独立连接池") + + text = f"order-gw 运维同步(第 {i} 批,{day} 日):" + ";".join(items) + return text, "narrative" + + def build_plan(self) -> list[dict]: + """对话脚本:高密度叙事填充 + 嵌入式探针 + 改述提问。 + + ★ v3 语义:填充是 order-gw 服务运维的真实工作流(有任务/决策/因果/变化), + 不是废话流。针自然嵌在价值信息里(依赖清单/值班变更/交接流程), + 压缩必须保住叙事骨架才有真实取舍 —— 这是针对「废话填充让 pi + 直接丢垃圾 ⇒ 跑分虚高」的修正。 + """ + plan: list[dict] = [] + + # 先探每轮 token:叙事片段 ~450 token/条,每批拼 4 条 ≈ 1800 token。 + # 轮数 ~140:在「轮数开销」(每轮一次 LLM 调用)与「信息密度」之间取平衡。 + # 每轮拼多少叙事片段。它决定「轮数」与「信息密度」的换算: + # 填充总量必须 > 窗口(否则测的是窗口内记忆),而轮数越少每轮越贵。 + # 100k 窗口 × 1.45 = 145k ⇒ 8 片段/轮(≈2900 tok)= 50 轮填充。 + frags_per_turn = 8 + sample = ";".join(self.narrative_block(k, 600)[0] for k in range(frags_per_turn)) + per_turn_tokens = max(1, est_tokens(sample)) + # 填充轮数:以「累计叙事文本 ≈ 1.45× 窗口」为目标,靠**加厚灌入**而非 + # 压低窗口来制造超窗(2026-10-01 定案)。压窗口(如 10k)会让 HA 反复 + # 上下文管理抖动 —— 相当于在低内存设备上测内存调度,测出的是病态行为 + # 不是设计工作点。两侧窗口保持一致(如 50k),让灌入量本身超过窗口: + # pi 是水位线压缩,累计文本 > 窗口 ⇒ 必须开始真实丢信息; + # HA 同窗口下已实测超窗召回(50k 窗口 max prompt 286k 仍 6/6)。 + # 上限 25 轮是成本护栏(实测 pi ~20s/轮、HA ~120s/轮)。 + # + # ★★ 实测故障(2026-10-04):轮次耗时指数级递增,跑不完 + # + # [1/36] 30.9s + # [2/36] 131.5s + # [3/36] 392.0s 递增比 2.98× + # + # 外推第 6 轮就超过单轮超时(900s),26 轮累计 12.9 小时。 + # + # 根因**不是**机器慢,而是**填充轮在往记忆库里写**。 + # ★ 写入来源实测确认(2026-10-04,18 分钟填充期): + # + # tool memory_commit result 19 次 ← **模型主动调工具** + # media 3 次 + # reclaim/ 0 次 + # distiller 启动 1 次 (Interval 10min,心跳没跑到) + # + # ⇒ 主因是**模型自己决定调 memory_commit**, + # **不是** distiller 那个 10 分钟心跳的常驻循环。 + # + # ★ 而 Distiller 没有 Enabled 开关(只有 Interval/RetentionDays/ + # BatchSize),关不掉它 —— 也**不需要关**,因为它不是主因。 + # 之前我推断「主因是常驻循环」是错的,靠日志计数纠正。 + # + # + # entities 188 → 296 → 300 → 313 → 381(本次跑分实测) + # [indexer] 实体数 313→381,已增量重建实体名向量索引 + # + # 每轮都要检索整个库 ⇒ 库越大越慢;而填充本身让库变大 + # ⇒ **写入与检索互相放大**。 + # + # ⇒ 这个专项要测的是「上下文超页下的召回取舍」, + # **填充期的自动记忆写入是被测系统的一部分,不是被测变量** —— + # 它把「固定数据集上的召回」变成了「边写边查的动态系统」, + # 后者的复杂度使本专项无法在合理时间内完成。 + # + # 修法(--no-autocommit):填充轮关掉自动记忆写入, + # 让库规模在整个专项内保持固定。 + # ★ 不能改成「批量预填」—— 那会让填充不再经过 + # 「对话 → 上下文管理 → 压缩」这条真实路径, + # 而超窗压缩正是本专项要测的东西。 + filler_turns = max(4, min(25, self.target_tokens // per_turn_tokens)) + + # 开场白:完全自然,不预告任何要记的东西 + plan.append({"kind": "filler", "cat": "narrative", + "text": "我这边开始按天同步 order-gw 的运维进展,你顺手套理着就行,后面我会随时问细节。"}) + + for i in range(filler_turns): + # 每批拼 3 条不同位置的片段(同阶段推进):信息密度高且互不重复 + base = i * frags_per_turn + parts = [self.narrative_block(base + k, filler_turns * frags_per_turn)[0] + for k in range(frags_per_turn)] + plan.append({"kind": "filler", "cat": "narrative", "text": ";".join(parts)}) + + # ---- 提问(措辞与出现时不同;一次一问)---- + + def q_port(name: str) -> str: + return (f"我记不清具体数字了,帮我对一下:{name}应该是多少?" + f"直接告诉我数字就行。") + + probes: list[dict] = [] + # casual ×4:改述提问 + for name, v in self.casual.items(): + probes.append({"kind": "probe", "ptype": "casual", + "text": q_port(name), "expect": str(v)}) + # overwrite ×1:只问最新值,措辞里不提示"改过" + probes.append({"kind": "probe", "ptype": "overwrite", + "text": "对了,值班室的分机现在多少来着?就回个数字。", + "expect": str(self.overwrite_new)}) + # overwrite 反向哨兵 ×1:期望答出旧值算「塌回」(不计召回,单独统计) + probes.append({"kind": "probe", "ptype": "overwrite-stale", + "text": "帮我确认下,值班室分机是不是 4 开头的那个老号码?是多少?", + "expect": str(self.overwrite_old)}) + # multihop ×1:两个散点组合(团队代号 → 门禁 → 表放哪间) + probes.append({"kind": "probe", "ptype": "multihop", + "text": ("有同事要找负责门禁的那个团队拿门禁申请表," + "我记得表放在某个房间。帮我捋一下:负责门禁的团队是哪个代号," + "申请表在哪个房间?两个都要答。"), + "expect": f"{self.multihop_team} {self.multihop_room}"}) + + # ---- v4:区分「语义取舍」与「侥幸命中」的三类判据 ---- + # (A) unmarked:问一个**从未点名要记住**的规则声明。 + # 语义压缩把它当约束保留 ⇒ 命中;纯截断/关键词摘要 ⇒ 丢。 + probes.append({"kind": "probe", "ptype": "unmarked", + "text": (f"对了,{self.rule_key}当时是怎么定的来着?" + f"我记得跟上报次数有关,说一下具体怎么算的。"), + "expect": self.rule_value.split(",")[0]}) + # (B) causal:只问三段因果链的**结论**,中间环是否还在决定它能否自洽。 + # expect 必须是**带语境的短语**而不是裸数字:裸数字(如 "2")会跟上下文里 + # 任意数字撞上,等于没判据。改用"每分钟 N 条"整段。 + probes.append({"kind": "probe", "ptype": "causal", + "text": ("慢查询最后是怎么治好的?我要写进复盘," + "改完之后回落到什么水平了?照原话说。"), + "expect": self.causal_fix, + # 症状值是因果链的**首环**,答它说明只记住了现象、没走完链 + "forbid": self.causal_symptom}) + # (C) distractor-pair:措辞明确指向「最终冻结的那个」, + # 中间快照值也出现在上下文里 —— 答中间值 = 被压缩摘要带偏。 + probes.append({"kind": "probe", "ptype": "freeze", + "text": ("缓存 ttl 最终冻结的是多少秒?我要确认不是中间试的那个值。"), + "expect": self.freeze_new, "forbid": self.freeze_old}) + + for p in probes: + plan.append(p) + + # ★ 协议断言:cli.sock 按行读,任何一轮含换行都会被静默拆成多条消息 + for idx, step in enumerate(plan, 1): + assert "\n" not in step["text"], f"第 {idx} 轮含换行(协议按行读)" + return plan + + +# ════════════════════════════════════════════════════════════════ +# 判定 +# ════════════════════════════════════════════════════════════════ + +def score(reply: str, expect: str, ptype: str, forbid: str | None = None) -> bool: + """召回判定。 + + casual/overwrite:值必须出现,且**不得**把同域干扰值或旧值当答案; + multihop:两个要素都出现; + overwrite-stale:期望答出旧值(塌回哨兵,反向计分); + freeze:必须给最终值,**且不得**给中间快照值(forbid)—— + 这是「压缩把中间快照当最终结论」这一典型新错误的探针。 + """ + r = reply.upper() + if ptype == "multihop": + parts = expect.split() + return all(p.upper() in r for p in parts) + if ptype == "overwrite-stale": + return expect in r + if forbid and ptype in ("freeze", "causal"): + # 出现「不该出现的那值」而没出现期望值 ⇒ 被摘要带偏(或只记住首环) + if forbid.upper() in r and expect.upper() not in r: + return False + ok = expect in r + return ok + + +def run(args: argparse.Namespace) -> int: + mat = Material(args.seed, args.window, args.overshoot) + plan = mat.build_plan() + filler_tokens = sum(est_tokens(p["text"]) for p in plan if p["kind"] == "filler") + + print(f"会话计划:{len(plan)} 轮 = 填充 " + f"{sum(1 for p in plan if p['kind']=='filler')} + 提问 " + f"{sum(1 for p in plan if p['kind']=='probe')}") + print(f" 填充 token 估算 ≈ {filler_tokens}(目标窗口 {args.window}," + f"比值 {filler_tokens / max(1, args.window):.2f}×)") + print(" 探针:casual×4 overwrite×1 overwrite-stale(哨兵)×1 multihop×1" + " + v4取舍判据 unmarked×1 causal×1 freeze×1") + # ★ 超窗判定以**实测 prompt** 为准,不以文本估算为准(2026-10-01 实测教训): + # 纯叙事文本估算 14k,而 HA 第 6 轮真实 prompt 已 127k —— 每轮还含系统提示、 + # 工具定义、记忆上下文与工具回执,估算只是保守下界。若按估算判「无效」, + # 会把实际已超窗的有效跑分误杀;反过来只看估算也会让无效跑分看起来合格。 + est_ok = filler_tokens >= args.window + print(f" {'✓' if est_ok else 'ℹ'} 文本估算 {filler_tokens} vs 窗口 {args.window}" + f"(估算为下界,超窗以实测 prompt 为准)") + + if args.dry_run: + return 0 + + driver = HomeAgentDriver(args) if args.harness == "homeagent" else PiDriver(args) + started_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + turns: list[dict] = [] + # ★ 断点/防丢:每轮结束就把已完成轮次落盘。长跑(143 轮、实测均值 ~150s/轮、 + # 约 6h)本会话被杀过三次,而原来只在全部跑完后才写 json ⇒ 中途崩溃等于零产出。 + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + partial = out / "partial.jsonl" + # 非 resume 模式必须清掉上一次运行的残留(实测踩过:跨运行 append 会把 + # 被 kill 的旧轮次混进本次结果,出现两条「轮1」) + if not args.resume and partial.exists(): + partial.unlink() + + # --resume:从 partial.jsonl 续跑。服务端会话状态跨连接保留(CliSession 每轮 + # 新建连接但 agent 会话在服务端),所以「跳过已完成的轮次、从第 N+1 轮继续发」 + # 是真正接续而不是重放。 + # ★ 为什么必须能续而不是只看 partial:7 个探针全排在最后 7 轮,崩溃后的 + # partial 只有填充,**拿不到任何召回信号** —— 没有 resume 就等于重跑。 + # 前提:续跑时实例必须仍是同一个(未重启、未清库),否则上下文对不上。 + done: dict[int, dict] = {} + if args.resume and partial.exists(): + for line in partial.read_text(encoding="utf-8").splitlines(): + if line.strip(): + rec0 = json.loads(line) + done[int(rec0["index"])] = rec0 + if done: + print(f" ↻ resume:已有 {len(done)} 轮落盘,从第 {max(done) + 1} 轮继续" + f"(实例必须仍未重启)", flush=True) + turns.extend(done[k] for k in sorted(done)) + + def _flush_turn(rec: dict) -> None: + with partial.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + + try: + for i, step in enumerate(plan, 1): + if i in done: + continue + r = driver.ask(step["text"]) + rec = { + "index": i, "kind": step["kind"], + "cat": step.get("cat"), "ptype": step.get("ptype"), + "wall_s": round(r["wall_s"], 3), + "usage": r["usage"], "tools": r["tools"], + "timed_out": r["timed_out"], "error": r["error"], + "reply_head": (r["reply"] or "")[:200], + } + if step["kind"] == "probe": + rec["expect"] = step["expect"] + rec["recalled"] = score(r["reply"] or "", step["expect"], step["ptype"], + step.get("forbid")) + mark = "✅" if rec["recalled"] else "❌" + print(f"[{i}/{len(plan)}] 提问[{step['ptype']}] {step['expect']} … {mark} " + f"{r['wall_s']:.1f}s", flush=True) + else: + # 每轮都落笔:既给长跑可见进度,又把「单轮 5 分钟」这类异常立刻暴露出来 + print(f"[{i}/{len(plan)}] 填充[{step.get('cat')}] … {r['wall_s']:.1f}s", flush=True) + turns.append(rec) + _flush_turn(rec) + finally: + driver.close() + + # 实测超窗校验:任一填充轮的 prompt 超过窗口 ⇒ 压缩真实发生过 + max_prompt = max((t["usage"].get("prompt", 0) for t in turns), default=0) + overshoot_verified = max_prompt > args.window + + probes_done = [t for t in turns if t["kind"] == "probe"] + total_in = sum(t["usage"].get("prompt", 0) for t in turns) + total_out = sum(t["usage"].get("completion", 0) for t in turns) + read = sum(t["usage"].get("cache_read", 0) for t in turns) + miss = sum(t["usage"].get("cache_miss", 0) for t in turns) + + # 按探针类型分层 + by_type: dict[str, dict] = {} + for t in probes_done: + pt = t["ptype"] + d = by_type.setdefault(pt, {"n": 0, "recalled": 0}) + d["n"] += 1 + d["recalled"] += 1 if t.get("recalled") else 0 + + # 主指标 = 全部探针里排除 overwrite-stale(那是反向哨兵,单独报) + counted = [t for t in probes_done if t["ptype"] != "overwrite-stale"] + hits = sum(1 for t in counted if t.get("recalled")) + stale = next((t for t in probes_done if t["ptype"] == "overwrite-stale"), None) + + summary = { + "harness": args.harness, + "window": args.window, + "overshoot": args.overshoot, + "filler_tokens_est": filler_tokens, + "filler_to_window": round(filler_tokens / max(1, args.window), 3), + "max_prompt_observed": max_prompt, + "overshoot_verified": overshoot_verified, + "turns": len(turns), + "recall_rate": (hits / len(counted)) if counted else None, + "recalled": hits, + "counted_probes": len(counted), + "by_type": by_type, + "overwrite_stale_hit": (stale.get("recalled") if stale else None), + "wall_s_total": round(sum(t["wall_s"] for t in turns), 2), + "prompt_total": total_in, + "completion_total": total_out, + "total_tokens": total_in + total_out, + "cache_read_total": read, + "cache_miss_total": miss, + "timed_out_turns": sum(1 for t in turns if t["timed_out"]), + } + if read or miss: + summary["cache_hit_rate"] = read / (read + miss) + + (out / "memory_recall.json").write_text( + json.dumps({"meta": {"started_at": started_at, "config": vars(args), + "material": { + "casual": mat.casual, + "overwrite": {"old": mat.overwrite_old, "new": mat.overwrite_new}, + "multihop": {"team": mat.multihop_team, "room": mat.multihop_room}, + }}, + "summary": summary, "turns": turns}, + ensure_ascii=False, indent=2), encoding="utf-8") + + print(f"\n{'=' * 60}") + print(f"主召回 {hits}/{len(counted)}({bench.fmt_rate(summary['recall_rate'])}) 按类:") + for pt, d in by_type.items(): + tag = "(反向哨兵)" if pt == "overwrite-stale" else "" + print(f" {pt}{tag}: {d['recalled']}/{d['n']}") + print(f" 灌入 ≈ {filler_tokens} token(估算 {summary['filler_to_window']}× 窗口)," + f"实际累计 prompt {total_in}") + print(f" 实测最大单轮 prompt {max_prompt} vs 窗口 {args.window} ⇒ " + f"{'✓ 超窗校验通过(压缩真实发生)' if overshoot_verified else '⚠️ 未超窗 ⇒ 结果无效'}") + print(f" 墙钟 {summary['wall_s_total']}s 缓存命中率 " + f"{bench.fmt_rate(summary.get('cache_hit_rate'))}") + print(f"结果: {out / 'memory_recall.json'}") + return 0 + + +class HomeAgentDriver: + """经 cli.sock 驱动 HomeAgent(复用 bench.CliSession)。""" + + name = "homeagent" + + def __init__(self, args: argparse.Namespace): + self.s = bench.CliSession(args.socket, args.api_key, args.timeout) + self.frames_seen: list[dict] = [] + + def ask(self, text: str) -> dict: + res = self.s.send(text, timeout=self.s.timeout) + self.frames_seen.extend(res["frames"]) + terminal = res["terminal"] or {} + return { + "reply": terminal.get("content") or "", + "wall_s": res["wall_s"], + "usage": _norm_ha_usage(terminal.get("usage") or {}), + "tools": sorted({f.get("tool") for f in res["frames"] + if f.get("type") == bench.FRAME_TOOL_CALL and f.get("tool")}), + "timed_out": res["timed_out"], + "error": terminal.get("error"), + } + + def close(self) -> None: + pass + + +class PiDriver: + """pi -p --mode json 逐轮驱动(隔离配置目录 + 空闲端口,v1 实测口径)。""" + + name = "pi" + + def __init__(self, args: argparse.Namespace): + self.args = args + self.session_id = f"memrecall-v2-{int(time.time())}" + self.port_base = args.pi_port_base + self.turn = 0 + + def ask(self, text: str) -> dict: + self.turn += 1 + env = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/local/bin"), + "HOME": os.environ.get("HOME", "/root"), + "PI_CODING_AGENT_DIR": self.args.iso_dir, + "PI_CODING_AGENT_SESSION_DIR": str(Path(self.args.iso_dir).parent / "sessions"), + "PI_A2A_PORT": str(self.port_base + 2 * self.turn), + "PI_ACP_PORT": str(self.port_base + 2 * self.turn + 1), + "PI_TELEMETRY": "0", + "PI_SKIP_VERSION_CHECK": "1", + } + cmd = [ + "pi", "-p", "--mode", "json", + # ★ --no-tools 是本测试的**正确性前提**,不是优化。 + # 实测(2026-10-01):不加时 pi 在一轮里跑 33 次 bash,把自己 + # 当上帝视角 —— grep "4324" 搜到旧会话、cat order_gw_ops_index.md + # 读跑分台自己的材料、sed -n '605,625p' 去挖脚本行号。 + # ⇒ 它不是在测记忆,而是在作弊;此前所有 pi 100% 成绩全部作废。 + # 记忆召回测试必须是纯对话,不能给 agent 接触磁盘的手段。 + "--no-tools", + "--session-id", self.session_id, + "--provider", self.args.provider, "--model", self.args.model, + text, + ] + started = time.monotonic() + try: + proc = subprocess.run( + cmd, env=env, capture_output=True, text=True, + timeout=self.args.timeout, cwd=self.args.cwd) + except subprocess.TimeoutExpired as exc: + # ★ 超时必须带 stderr 尾巴:v3b 的 900s 假死就是靠它才定位到 + # "No project session found ...; creating a new session"(每轮重建 + # 会话并重新压缩全部历史)。驱动原先丢弃 stderr,盲区直接吃掉了故障线索。 + err = exc.stderr or "" + tail = (" | stderr: " + " ".join(str(err).split())[-400:]) if str(err).strip() else " | stderr: (空)" + return {"reply": "", "wall_s": time.monotonic() - started, + "usage": {}, "tools": [], "timed_out": True, + "error": f"timeout after {self.args.timeout}s{tail}"} + wall = time.monotonic() - started + + # stderr 一律回传(不吞):pi 的警告(会话重建/压缩/模型降级)只走这里 + stderr_note = (" | stderr: " + " ".join(proc.stderr.split())[-300:]) if proc.stderr.strip() else "" + + usage: dict = {} + reply_parts: list[str] = [] + for line in proc.stdout.splitlines(): + try: + ev = json.loads(line) + except json.JSONDecodeError: + continue + if ev.get("type") == "message_end" and (ev.get("message") or {}).get("role") == "assistant": + usage = _norm_pi_usage(ev["message"].get("usage") or {}) + if ev.get("type") == "message_end" and (ev.get("message") or {}).get("role") == "assistant": + c = ev["message"].get("content") + if isinstance(c, str) and c: + reply_parts.append(c) + elif isinstance(c, list): + for blk in c: + if isinstance(blk, dict) and blk.get("type") == "text": + reply_parts.append(blk.get("text") or "") + return { + "reply": "\n".join(reply_parts), + "wall_s": wall, + "usage": usage, + "tools": [], + "timed_out": False, + "error": ((None if proc.returncode == 0 else f"exit={proc.returncode}") + or "") + stderr_note, + } + + def close(self) -> None: + pass + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="记忆召回 v2:偶发针/覆盖/多跳/改述") + ap.add_argument("--harness", choices=["homeagent", "pi"], required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--window", type=int, default=200000) + ap.add_argument("--expect-window", type=int, default=None, + help="实例(模型)真实窗口;与 --window 不同则拒跑(防「窗口内召回」假跑分)") + ap.add_argument("--overshoot", type=float, default=1.25) + ap.add_argument("--seed", type=int, default=20261001, help="全部材料随机种子(可复现)") + ap.add_argument("--timeout", type=float, default=900.0, help="单轮超时(秒)") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--resume", action="store_true", + help="从 /partial.jsonl 续跑(要求实例未重启;探针在末尾," + "崩溃后不续跑就拿不到召回信号)") + # HomeAgent + ap.add_argument("--socket", help="HomeAgent cli.sock") + ap.add_argument("--api-key", default=os.environ.get("HOMEAGENT_CLI_KEY", "")) + # pi + ap.add_argument("--provider", default="llmsproxy") + ap.add_argument("--model", default="AUTO") + ap.add_argument("--iso-dir", default="/var/tmp/pi-iso/agent") + # cwd 必须是**干净空目录**。两个实测坑: + # ① pi 会把 cwd 里的文件读进上下文 —— 实验残留(order_gw_*.md / + # port_freq.json)会直接变成「记忆」的假答案:v3b 的 4324 就是 + # 答自磁盘文件而非会话记忆。 + # ② cwd 与 PI_CODING_AGENT_SESSION_DIR 不一致时,pi 报 "No project + # session found with id ...; creating a new session",每轮重建 + # 会话并重新压缩全部历史 ⇒ 越到后面越慢,最后 900s 超时假死。 + ap.add_argument("--cwd", default="/var/tmp/pi-iso/clean-cwd") + ap.add_argument("--pi-port-base", type=int, default=20110) + args = ap.parse_args(argv) + + if args.harness == "homeagent" and not args.socket: + print("--harness homeagent 需要 --socket", file=sys.stderr) + return 2 + # ★ 超窗校验:灌入量必须既超过灌入目标(--window),又被实例真实窗口 + # (--expect-window)容纳。两个条件任一不满足都是「测错东西」: + # - 填充 < window ⇒ 测的是窗口内记忆,无需压缩; + # - expect-window < 填充 ⇒ 实例先于压缩策略把内容截断,结果不可归因。 + if args.expect_window is not None and args.expect_window != args.window: + print(f"✗ 超窗校验失败:--expect-window {args.expect_window} != --window {args.window} " + f"(实例窗口与灌入目标不一致,结果不可归因)", file=sys.stderr) + return 2 + return run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/capability-bench/pi_bench.py b/scripts/capability-bench/pi_bench.py new file mode 100644 index 00000000..6283250e --- /dev/null +++ b/scripts/capability-bench/pi_bench.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +"""pi 基线 runner —— 与 HomeAgent 标定台跑**同一套任务、同一个模型**。 + +为什么要它 +========== + +「全面标定能力」要有对照。pi 与本机内核走的是**同一个 llmsproxy 网关、 +同一个 AUTO 模型**,所以这是一个干净的「同模型、只变 harness」对比 —— +与 HarnessTax 的做法一致。 + +复用 bench.py 的判据与汇总(`check` / `summarize` / `render_markdown`), +确保两侧的判定逻辑**不是两份实现** —— 否则对比结果本身就不可信 +(本项目刚在缓存规则上吃过「两套实现互相掩盖同一个错」的亏)。 + +pi 的接口(实测,非猜) +====================== + + pi -p --mode json --provider llmsproxy --model AUTO "" + +stdout 是 JSON Lines,关键事件: + message_end 一条消息结束(assistant 的 usage 在这里) + turn_end 一轮结束(带该轮 usage) + agent_settled 任务真正结束(终态) + +★ 两个实测得到的坑: + 1. **`pi -p` 跑完不会自己退出** —— A2A/ACP 两个扩展把服务挂着。 + 必须读到 `agent_settled` 后主动 kill,否则会一直等到超时。 + 2. **必须给 A2A/ACP 指定空闲端口**:本会话守护进程占着 PI_A2A_PORT=14010, + 而另一个 pi 实例占着默认 12010 ⇒ 子进程继承环境后会 EADDRINUSE 崩掉。 + 所以下面显式分配端口。 + +计费口径 +======== + +把每个 `turn_end` 的 usage 求和 = 该任务的真实总账(一个任务可能多轮工具回环)。 +与 HomeAgent 侧「/kernel 差分」口径一致,两者可直接比。 +""" +from __future__ import annotations + +import argparse +import contextlib +import json +import os +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import bench + +# agent_settled 是 pi 的终态事件(实测)。 +TERMINAL_EVENT = "agent_settled" + + +def _int(v: object) -> int: + """尽力取整数。上游字段可能是字符串/None/浮点,取不到就记 0。""" + try: + return int(v) # type: ignore[arg-type] + except (TypeError, ValueError): + return 0 + + +def _usage_of(obj: object) -> dict: + """从 pi 的 usage 归一成与 HomeAgent 同口径的字段。 + + ★★ 关键:两边**同名字段的含义不同**,不统一就不能比。 + + pi(Anthropic 口径,已用受控实验实证 two 条恒等式): + totalTokens == input + cacheRead + output + ⇒ `input` 是**未命中**的输入(不含缓存),`cacheRead` 是命中侧。 + + HomeAgent(OpenAI 口径): + prompt_tokens **包含**缓存,cache_read 是其中命中那部分。 + + 所以归一: + prompt = input + cacheRead (总输入,含缓存) + cache_miss = input (未命中侧) + cache_read = cacheRead + 这样两侧的命中率分母都是 cache_read + cache_miss,语义一致。 + + ❌ 不归一的后果(我第一版就错了):把 input 当 prompt、miss 记 0, + 命中率算成 read/read ⇒ **恒 100%** —— 与刚在 HomeAgent 修掉的 + 「分母被抽掉」是同一个结构性假绿。且因为 read 可能大于 input, + 推导 miss 会得到负数(实测 -24611),荒谬到肉眼可见。 + """ + u = obj if isinstance(obj, dict) else {} + uncached_in = _int(u.get("input")) # pi 的 input = 未命中侧 + cache_read = _int(u.get("cacheRead")) + return { + "prompt": uncached_in + cache_read, # 总输入(含缓存) + "completion": _int(u.get("output")), + "total": _int(u.get("totalTokens")) or (uncached_in + cache_read + _int(u.get("output"))), + "cache_read": cache_read, + "cache_miss": uncached_in, # 未命中侧 + "reasoning": _int(u.get("reasoning")), + } + + +def _parse_line(line: str) -> dict | None: + """解析一行 JSON;不是 JSON 对象就返回 None(调用方跳过)。""" + try: + obj = json.loads(line) + except json.JSONDecodeError: + return None + return obj if isinstance(obj, dict) else None + + +def isolated_env(args: argparse.Namespace, port_base: int) -> dict: + """构造一个**隔离**的环境变量集。 + + 为何不能直接继承 os.environ(实测踩到): + · 本会话守护进程占着 PI_A2A_PORT=14010、另有 pi 实例占 12010 + ⇒ 子 pi 继承后会 EADDRINUSE 直接崩; + · 继承 PI_SESSION_ID / PI_CODING_AGENT_DIR 会让它读写**我的**会话状态。 + + 隔离做法:给一个干净的环境(只留 PATH/HOME)+ 独立的配置目录。 + 副作用(好的一侧):隔离配置里没有扩展 ⇒ 不会起 A2A/ACP 服务 + ⇒ `pi -p` 跑完**自己退出**,不必再靠 kill 收尾。 + 端口仍显式分配,作为万一加载了扩展的兜底。 + """ + env = { + "PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/local/bin"), + "HOME": os.environ.get("HOME", "/root"), + "PI_CODING_AGENT_DIR": args.iso_dir, + "PI_CODING_AGENT_SESSION_DIR": str(Path(args.iso_dir).parent / "sessions"), + "PI_A2A_PORT": str(port_base), + "PI_ACP_PORT": str(port_base + 1), + "PI_TELEMETRY": "0", + "PI_SKIP_VERSION_CHECK": "1", + } + for k in ("https_proxy", "http_proxy", "no_proxy"): + if k in os.environ: + env[k] = os.environ[k] + return env + + +def run_pi(task: dict, args: argparse.Namespace, port_base: int) -> dict: + """跑一个任务,返回与 bench.run_task 同形状的记录。""" + prompt = task["prompt"] + env = isolated_env(args, port_base) + + cmd = [ + "pi", "-p", "--mode", "json", "--no-session", + "--provider", args.provider, "--model", args.model, + ] + if args.pi_args: + cmd += args.pi_args.split() + cmd += [prompt] + + started = time.monotonic() + proc = subprocess.Popen( + cmd, cwd=args.cwd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + start_new_session=True, # 便于整组清理(pi 可能再起子进程) + ) + # 读盘超时:若 pi 用 stdin 等输入而没收到,会挂住。 + # timeout 由外层 deadline + 显式 kill 保证,这里不设,避免长时任务被误杀。 + + events: list[dict] = [] + turn_usages: list[dict] = [] + final_text = "" + settled = False + deadline = started + args.timeout + + try: + for line in proc.stdout: # type: ignore[union-attr] + line = line.strip() + if not line: + continue + ev = _parse_line(line) + if ev is None: + continue + events.append(ev) + etype = ev.get("type") + + if etype == "turn_end": + msg = ev.get("message") or {} + turn_usages.append(_usage_of(msg.get("usage"))) + # 该轮 assistant 的文本(最终回复以最后一轮为准) + txt = _text_of(msg) + if txt: + final_text = txt + elif etype == "message_end": + msg = ev.get("message") or {} + if msg.get("role") == "assistant": + txt = _text_of(msg) + if txt: + final_text = txt + elif etype == TERMINAL_EVENT: + # 见文件头坑 1:settled 后进程不会自己退,立刻收工。 + settled = True + break + + if time.monotonic() > deadline: + break + except Exception as exc: # noqa: BLE001 - 驱动层异常一律如实记录 + events.append({"type": "_runner_error", "error": str(exc)}) + finally: + # 无论何种路径都要收掉整组进程,否则残留的 pi 会一直占端口。 + if proc.poll() is None: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + # 先给优雅退出的机会,再升级到 SIGKILL(两步都要: + # 只 TERM 可能留下挂着的 pi,只 KILL 则不给它清理现场的机会)。 + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=5) + if proc.poll() is None: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=5) + with contextlib.suppress(Exception): + proc.stdout.close() # type: ignore[union-attr] + with contextlib.suppress(Exception): + proc.stderr.close() # type: ignore[union-attr] + + wall_s = time.monotonic() - started + timed_out = (not settled) and wall_s >= args.timeout - 1 + + # 任务总账 = 各轮求和(与 HomeAgent 的 /kernel 差分同口径)。 + task_usage: dict = {} + if turn_usages: + for k in ("prompt", "completion", "total", "cache_read", "cache_miss", "reasoning"): + task_usage[k] = sum(u.get(k, 0) for u in turn_usages) + task_usage["calls"] = len(turn_usages) + + tool_calls = [ + e for e in events + # pi 的事件名是 tool_execution_start(**不是** tool_call), + # 且工具名字段是 toolName——三处都实测确认过。 + # 用错字段不会报错,只是工具名恒为空(本 runner 第一版就是这样)。 + if e.get("type") in ("tool_execution_start", "tool_call", "tool_start") + ] + + terminal = {"type": "response" if settled else "error", + "content": final_text} + if timed_out: + terminal = {"type": "error", "error": f"timeout after {args.timeout}s"} + + ok, why = bench.check( + task, + terminal, + [{"type": "tool_call", "tool": (t.get("name") or t.get("tool") or "")} for t in tool_calls], + wall_s, + ) + + return { + "id": None, # 由调用方填 + "dimension": None, + "ok": ok, + "why": why, + "wall_s": round(wall_s, 3), + "timed_out": timed_out, + "error": terminal.get("error"), + "tool_calls": len(tool_calls), + "tools": sorted({_tool_name(t) for t in tool_calls if _tool_name(t)}), + "usage_single": _usage_of(turn_usages[-1] if turn_usages else {}), + "usage_task": task_usage, + "reply": (final_text or "")[:2000], + "events": len(events), + } + + +def _tool_name(ev: dict) -> str: + """取工具名。 + + pi 用 toolName(实测:tool_execution_start 事件带 toolName/args/toolCallId), + 其他 harness 可能是 name/tool。用错字段不会报错,只是工具名恒为空 —— + 本 runner 第一版就踩到(tool_calls=30 但 tools=[])。 + """ + for k in ("toolName", "name", "tool"): + v = ev.get(k) + if isinstance(v, str) and v: + return v + return "" + + +def _text_of(msg: dict) -> str: + c = msg.get("content") + if isinstance(c, str): + return c + if isinstance(c, list): + return "".join( + b.get("text", "") for b in c + if isinstance(b, dict) and b.get("type") == "text" + ) + return "" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="pi 基线 runner(与 HomeAgent 标定台同任务同模型)") + ap.add_argument("--tasks", required=True, help="任务集 JSON(与 bench.py 同一份)") + ap.add_argument("--out", required=True, help="输出目录") + ap.add_argument("--provider", default="llmsproxy", help="pi 的 provider(默认 llmsproxy)") + ap.add_argument("--model", default="AUTO", help="pi 的模型(默认 AUTO)") + ap.add_argument("--timeout", type=float, default=300.0, help="单任务超时(秒)") + ap.add_argument("--cwd", default="/var/tmp/pi-iso/work", help="pi 的工作目录") + ap.add_argument("--iso-dir", default="/var/tmp/pi-iso/agent", + help="pi 的独立配置目录(隔离会话/扩展;需含 models.json)") + ap.add_argument("--pi-args", default="", help="额外传给 pi 的参数(如 --no-extensions)") + ap.add_argument("--port-base", type=int, default=18110, help="A2A/ACP 起始端口") + ap.add_argument("--only", help="只跑逗号分隔的任务 id") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args(argv) + + try: + spec = json.loads(Path(args.tasks).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"读任务集失败 {args.tasks}: {exc}", file=sys.stderr) + return 2 + tasks = spec.get("tasks") if isinstance(spec, dict) else spec + if not isinstance(tasks, list) or not tasks: + print(f"任务集为空或格式不对: {args.tasks}", file=sys.stderr) + return 2 + if args.only: + keep = {s.strip() for s in args.only.split(",") if s.strip()} + tasks = [t for t in tasks if t.get("id") in keep] + + if args.dry_run: + for t in tasks: + print(f" [{t.get('dimension','?'):<11}] {t.get('id'):<20} " + f"timeout={t.get('timeout_s') or args.timeout}s") + print(f"共 {len(tasks)} 个任务(dry-run)") + return 0 + + Path(args.cwd).mkdir(parents=True, exist_ok=True) + if not (Path(args.iso_dir) / "models.json").exists(): + print(f"隔离配置目录缺少 models.json: {args.iso_dir}", file=sys.stderr) + return 2 + started_at = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + print(f"pi 基线:provider={args.provider} model={args.model}") + print(f" cwd={args.cwd}") + print(f" iso_dir={args.iso_dir}\n") + + records: list[dict] = [] + for i, task in enumerate(tasks, 1): + tid = task.get("id") + print(f"[{i}/{len(tasks)}] {tid} ({task.get('dimension','?')}) …", flush=True) + # 任务集里写了非法超时就退回默认值,不因一个字段崩掉整批。 + if task.get("timeout_s"): + with contextlib.suppress(TypeError, ValueError): + args.timeout = float(task["timeout_s"]) + rec = run_pi(task, args, args.port_base + 2 * i) + rec["id"] = tid + rec["dimension"] = task.get("dimension", "uncategorized") + records.append(rec) + u = rec["usage_task"] + print(f" {'PASS' if rec['ok'] else 'FAIL'} {rec['wall_s']}s " + f"tools={rec['tool_calls']} tokens={u.get('total','—')} — {rec['why']}", flush=True) + + summary = bench.summarize(records) + meta = {"started_at": started_at, "socket": f"pi:{args.provider}/{args.model}", + "tasks_file": args.tasks, "timeout_s": args.timeout, + "pi_args": args.pi_args or "(默认)"} + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + (out / "results.json").write_text( + json.dumps({"meta": meta, "summary": summary, "records": records}, + ensure_ascii=False, indent=2), encoding="utf-8") + (out / "report.md").write_text(bench.render_markdown(meta, summary, records), encoding="utf-8") + + print(f"\n{'=' * 60}") + print(f"通过 {summary['passed']}/{summary['tasks']} 墙钟 {summary['wall_s_total']}s " + f"token {summary['total_tokens']} 缓存命中率 {bench.fmt_rate(summary['cache_hit_rate'])}") + print(f"报告: {out / 'report.md'}") + return 0 if summary["passed"] == summary["tasks"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/capability-bench/reset-instance.sh b/scripts/capability-bench/reset-instance.sh new file mode 100755 index 00000000..9d19468c --- /dev/null +++ b/scripts/capability-bench/reset-instance.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# 重置一个测试实例到「干净单进程」状态,供跑分使用。 +# +# 为什么要这个脚本(2026-10-01 实测两次踩坑): +# +# 1. **只 kill pid 文件里的那一个是不够的。** /var/tmp/ha-c 上曾同时存活 9 个 +# homed 进程(都 -data /var/tmp/ha-c),共享同一个 memory/graph.db。 +# 手工清理时只杀了 pid 文件那个,其余仍在跑、持有旧内存状态并继续写库 —— +# 表现为「我明明清了库,实例却还报 384 实体 / 陶瓷(上一轮测试的废话)」。 +# +# 2. **删库比清目录可靠。** 手工 rm -rf 一堆子目录容易漏(media.db、context.json、 +# WAL 文件…),而核心启动时会自己建 schema —— 删掉即可,不必手工清干净。 +# +# 用法: +# ./reset-instance.sh <名字> # 仅清理,不启动 +# ./reset-instance.sh <名字> --start # 清理后重新启动 +# ./reset-instance.sh <名字> --start --force # 停进程前先确认没有跑分在用本实例 +# +# ★ 为什么要 --force(2026-10-01 踩到):清理=杀进程+删库,**会把正在跑分的 +# 实例连同数据一起毁掉**。实测我为了验证脚本,当着跑分的面把它依赖的实例杀了。 +# 因此:检测到本实例上有跑分进程(memory_recall.py 指向本 cli.sock)时拒绝执行, +# 除非显式 --force。 +set -uo pipefail + +NAME="${1:?用法: reset-instance.sh <名字> [--start] [--force]}" +START="${2:-}" +FORCE="${3:-}" + +if [ -n "$FORCE" ] && [ "$FORCE" != "--force" ]; then + printf '✗ 未知第三参数: %s(只接受 --force)\n' "$FORCE" >&2 + exit 2 +fi +DATA="/var/tmp/ha-${NAME}" +REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}" +BIN="${BIN:-$REPO_ROOT/build/homed}" +PORT="${PORT:-18084}" + +# 按 -data 精确匹配,只杀这个测试实例;绝不碰生产(-data "${HA_DATA}") +ha_pids() { pgrep -f "homed -data $DATA( |$)" || true; } + +# 跑分占用门禁:memory_recall.py / bench.py 引用本实例 socket 时不许清理 +if [ "$FORCE" != "--force" ]; then + BUSY=$(ps -eo pid,cmd | grep -a 'memory_recall\|bench\.py' | grep -a -- "$DATA" | grep -v grep | awk '{print $1}' || true) + if [ -n "$BUSY" ]; then + printf '✗ 拒绝执行:检测到跑分进程正用本实例(pid %s)\n' "$(echo $BUSY)" + printf ' 确认要清理请加 --force(会毁掉正在跑的数据)\n' + exit 3 + fi +fi + +PIDS=$(ha_pids) +if [ -n "$PIDS" ]; then + printf '停 %s 个残留进程: %s\n' "$(echo "$PIDS" | wc -l)" "$(echo $PIDS)" + # shellcheck disable=SC2086 + kill $PIDS 2>/dev/null + sleep 5 + # shellcheck disable=SC2086 + kill -9 $PIDS 2>/dev/null + sleep 2 + LEFT=$(ha_pids | grep -c . || true) + [ "$LEFT" -eq 0 ] || { printf '✗ 仍有 %s 个进程存活,检查后再试\n' "$LEFT"; exit 1; } +fi + +# 删库,让核心启动时自建 +rm -f "$DATA"/memory/graph.db* \ + "$DATA"/memory/media/media.db* \ + "$DATA"/memory/context.json +rm -rf "$DATA"/memory/documents "$DATA"/memory/text \ + "$DATA"/memory/raw "$DATA"/memory/scenes \ + "$DATA"/agentfs/* "$DATA"/snapshots/* 2>/dev/null +rm -f "$DATA"/webui_chat_history.json 2>/dev/null + +ENT=$(timeout 5 sqlite3 "$DATA/memory/graph.db" "select count(*) from entities;" 2>/dev/null || echo "?") +printf '已清理 %s(entities=%s)\n' "$DATA" "${ENT:-无库}" + +[ "$START" = "--start" ] || exit 0 + +# 启动后自检:单进程 + ONNX 加载 + 库为空 +cd "$DATA" && nohup setsid "$BIN" -data "$DATA" -webui "127.0.0.1:$PORT" \ + >"$DATA/boot.log" 2>&1 & +sleep 30 +N=$(ha_pids | grep -c . || true) +printf '进程数 %s%s\n' "$N" "$( [ "$N" = 1 ] && printf ' ✓' || printf ' ✗ 期望 1' )" +grep -a 'multimodal space active' "$DATA/boot.log" | tail -1 +timeout 5 sqlite3 "$DATA/memory/graph.db" "select 'entities=' || count(*) from entities;" 2>/dev/null || echo 'entities=?(库锁超时)' diff --git a/scripts/capability-bench/spawn-instance.sh b/scripts/capability-bench/spawn-instance.sh new file mode 100755 index 00000000..543b7c85 --- /dev/null +++ b/scripts/capability-bench/spawn-instance.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# 起一个配置好的隔离实例(供标定/对比用)。 +# +# 为什么需要脚本:手工起实例踩过三次同样的坑 —— +# 1. 忘了配 llmsproxy(于是打到真实 DeepSeek,报 401); +# 2. 忘了设 context_window(于是按模型名推断,两侧窗口不一致); +# 3. 改了配置忘了重启(provider 在启动时构建,/settings set 落库但不生效)。 +# +# 用法: +# ./spawn-instance.sh <名字> [context_window] +# ./spawn-instance.sh b 18083 200000 +# +# 端口分配约定(避免撞车): +# 18082=A 18083=B 18084=C …(webui,可自由分配) +# 注意:pluginmgr(9876)/remotedevice(9890)/kbtree(9892) 是**写死的**, +# 同机第二个实例会在它们上 bind 失败(那几个插件降级,内核其余部分照常)。 +# 对经 cli.sock 的标定无影响,但别以为实例是"完全隔离"的。 +set -uo pipefail + +NAME="${1:?用法: spawn-instance.sh <名字> [context_window]}" +PORT="${2:?缺 webui 端口}" +CTX="${3:-200000}" + +REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "$0")/../.." && pwd)}" +BIN="${BIN:-$REPO_ROOT/build/homed}" +DATA="/var/tmp/ha-${NAME}" +# ★ 生产配置库路径走环境变量:仓库里不写死本机路径。 +PROD_CFG="${HA_DATA:-/data/homeagent}/config.db" + +say() { printf ' %s\n' "$*"; } + +if [ ! -x "$BIN" ]; then + say "★ 二进制不存在: $BIN(先 make build)"; exit 1 +fi + +# ★ 跑分前必须核对内核版本 —— 否则测的是旧内核,分数毫无意义 +# +# 实测踩过(2026-10-04):build/homed 是 Oct 1 的,而当天有 19 个 +# memory 层提交(融合召回 / 拒答判据 / 仲裁 / schema 退场)。 +# 直接跑分测的**完全是三天前的内核**,当天的工作一点没测到 —— +# 而 6/6 的满分让人以为改动被验证过了。 +# +# 用 --allow-stale 显式跳过(只在你确实想测旧版本时)。 +if [ "${ALLOW_STALE:-0}" != "1" ]; then + BIN_REV="$(go version -m "$BIN" 2>/dev/null | sed -n 's/.*vcs.revision=\([0-9a-f]*\).*/\1/p' | head -1)" + # ★ 两者都要短形式:go version -m 给的是完整 40 位, + # 而 rev-parse --short 给短形式 ⇒ 不统一就永远「不匹配」。 + HEAD_REV="$(git -C "$REPO_ROOT" rev-parse --short=8 HEAD 2>/dev/null)" + BIN_REV="${BIN_REV:0:8}" + BIN_TIME="$(stat -c %y "$BIN" 2>/dev/null | cut -d. -f1)" + if [ -z "$BIN_REV" ] || [ -z "$HEAD_REV" ]; then + say "· 读不到版本信息(未装 git 或非 git 构建),跳过核对" + elif [ "$BIN_REV" != "$HEAD_REV" ]; then + say "★ 内核版本不符,拒绝启动跑分实例" + say " 二进制 $BIN_REV ($BIN_TIME)" + say " HEAD $HEAD_REV" + say " 改动可能没被测到。重跑:go build -tags onnxruntime -o build/homed ./cmd/homed" + say " 确实想测旧版本:ALLOW_STALE=1 $0 $*" + exit 2 + else + say "✓ 内核版本匹配 $BIN_REV($BIN_TIME)" + fi +fi + +say "实例: name=$NAME data=$DATA webui=127.0.0.1:$PORT ctx=$CTX" + +# ── 首次启动生成 config.db ── +if [ ! -f "$DATA/config.db" ]; then + rm -rf "$DATA"; mkdir -p "$DATA" + ( cd "$DATA" && nohup "$BIN" -data "$DATA" -webui "127.0.0.1:$PORT" \ + >"$DATA/boot.log" 2>&1 & echo $! > "$DATA/pid" ) + sleep 9 + if [ -f "$DATA/pid" ]; then kill "$(cat "$DATA/pid")" 2>/dev/null; fi + sleep 2 + say "已生成 config.db(首次启动)" +fi + +# ── 停机后直改 sqlite:指向 llmsproxy(开机构建 provider,必须在启动前改)── +KEY=$(sqlite3 "$PROD_CFG" "SELECT value FROM config WHERE key='core.llm.api_key';") +[ -n "$KEY" ] || { say "★ 取不到 llmsproxy key"; exit 1; } + +sqlite3 "$DATA/config.db" <"$DATA/run.log" 2>&1 & echo $! > "$DATA/pid" ) +sleep 10 + +if ! ss -ltn 2>/dev/null | grep -q ":$PORT"; then + say "★ 端口 $PORT 没起来,看 $DATA/run.log"; tail -5 "$DATA/run.log"; exit 1 +fi + +WEBKEY=$(sqlite3 "$DATA/config.db" "SELECT value FROM config_webui WHERE key='api_key';") +CLIKEY=$(sqlite3 "$DATA/config.db" "SELECT value FROM config_cli WHERE key='api_key';") +[ -n "$CLIKEY" ] || CLIKEY="$WEBKEY" # cli 空时回落到 webui key(cliAPIKey 的兜底) + +grep -E "main agent started" "$DATA/run.log" | tail -1 | sed 's/^/ /' + +# ── 稠密向量空间自检 ── +# 2026-09-30 教训:模型目录缺失时内核静默降级(TF-IDF fallback),跑分照跑, +# 50 分钟测的是残废配置。跑分实例必须断言 multimodal space active。 +MODEL_DIR="$DATA/models/chinese-clip-vit-b16-onnx" +if [ ! -d "$MODEL_DIR" ]; then + if [ -d "/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx" ]; then + mkdir -p "$DATA/models" + ln -s /usr/lib/homeagent/models/chinese-clip-vit-b16-onnx "$MODEL_DIR" + say "模型缺失 → 已从系统目录 symlink:$MODEL_DIR" + # 关停重启(provider 启动时构建,改了必须重启) + kill "$(cat "$DATA/pid")" 2>/dev/null; sleep 3 + ( cd "$DATA" && nohup "$BIN" -data "$DATA" -webui "127.0.0.1:$PORT" \ + >"$DATA/run.log" 2>&1 & echo $! > "$DATA/pid" ) + sleep 10 + else + say "★ 模型目录缺失且系统目录也没有:$MODEL_DIR" + say " 跑分将测到禁用稠密检索的残废配置。先解决模型:" + say " ① python3 scripts/export_chineseclip_onnx.py ② 或从生产实例拷贝 ③ 或装发行包" + exit 1 + fi +fi +if ! grep -q 'multimodal space active' "$DATA/run.log"; then + say "★ 多模态向量空间未激活(跑分无效,拒跑)。启动日志:" + grep -i 'multimodal\|chineseclip' "$DATA/run.log" | sed 's/^/ /' + exit 1 +fi +grep -E 'multimodal space active' "$DATA/run.log" | tail -1 | sed 's/^/ /' + +say "pid=$(cat "$DATA/pid") cli.sock=$DATA/cli.sock" +say "api_key=$CLIKEY" +echo "$CLIKEY" > "$DATA/apikey" +say "就绪。api_key 已写入 $DATA/apikey" diff --git a/scripts/capability-bench/tasks.client.json b/scripts/capability-bench/tasks.client.json new file mode 100644 index 00000000..7feb91b6 --- /dev/null +++ b/scripts/capability-bench/tasks.client.json @@ -0,0 +1,57 @@ +{ + "_comment": [ + "客户端能力任务集 v2 —— 考 harness 而非裸 LLM。", + "两侧同模型(llmsproxy AUTO),能产生差异的只有 harness:", + "工具实现质量、错误回填、循环管理、跨步状态保持。", + "判据全部落盘(file_contains),与两侧工具名无关。", + "", + "生成器:scripts/capability-bench/gen_client_fixtures.py(期望答案在生成时独立复算)", + "材料目录:/var/tmp/client-bench/{fixloop,pipeline,aggregate,chain}" + ], + "tasks": [ + { + "id": "cl-fixloop", + "dimension": "tool-loop", + "prompt": "在 /var/tmp/client-bench/fixloop/ 目录有一个实现和它的测试。运行测试,修复实现代码直到全部测试通过(不许改测试文件)。全部通过时测试程序会自动生成 result.txt。完成后报告:改了哪些缺陷。", + "check": { + "kind": "file_contains", + "value": "/var/tmp/client-bench/fixloop/result.txt", + "needle": "ALL GREEN 6" + }, + "timeout_s": 600 + }, + { + "id": "cl-pipeline", + "dimension": "error-recovery", + "prompt": "在 /var/tmp/client-bench/pipeline/ 目录运行 python3 extract.py,它现在跑不通。修到它能跑通(不许改 events_*.jsonl 数据文件),然后把最终输出里的 unique 用户数以 users=N 的格式写入该目录的 report.txt。", + "check": { + "kind": "file_contains", + "value": "/var/tmp/client-bench/pipeline/report.txt", + "needle": "users=207" + }, + "timeout_s": 600 + }, + { + "id": "cl-aggregate", + "dimension": "large-output", + "prompt": "/var/tmp/client-bench/aggregate/ 下有 15 个订单 JSON 文件(每天一个)。统计:status 为 paid 的**去重后** order_id 总数(同一 order_id 可能出现在多个文件)。把结果以 total=N 的格式写入该目录的 answer.txt。要求数字准确。", + "check": { + "kind": "file_contains", + "value": "/var/tmp/client-bench/aggregate/answer.txt", + "needle": "total=2752" + }, + "timeout_s": 600 + }, + { + "id": "cl-chain", + "dimension": "state-chain", + "prompt": "阅读 /var/tmp/client-bench/chain/start.txt 和 chain/steps/ 下 step1.md 到 step6.md,严格按顺序逐步执行(每步用工具计算并把中间值写入 chain/state.txt,不许跳步、不许心算)。全部完成后把最终值以 answer=值的格式写入 chain/final.txt。", + "check": { + "kind": "file_contains", + "value": "/var/tmp/client-bench/chain/final.txt", + "needle": "answer=717192166" + }, + "timeout_s": 600 + } + ] +} diff --git a/scripts/capability-bench/tasks.example.json b/scripts/capability-bench/tasks.example.json new file mode 100644 index 00000000..0f12dcde --- /dev/null +++ b/scripts/capability-bench/tasks.example.json @@ -0,0 +1,79 @@ +{ + "_comment": [ + "HomeAgent 能力标定任务集(starter)。", + "设计原则:", + "1. 每个任务都带显式 check —— 没有 check 的任务一律判失败:", + " 「看起来答对了」不是证据,那正是假绿的来源。", + "2. 分维度。全面标定要分得清是「能力缺失」还是「某一路径退化」:", + " readonly(只读,最快)/ browser(CDP 浏览器)/ knowledge(知识库)", + " / multistep(多轮工具回环,同时压账目链路)/ long(长时任务)。", + "3. 全部可复现且只读,或只写 /var/tmp 下的临时路径。", + " 破坏性提示词请另建集合,不要混进来。", + "4. 提示词刻意各不相同:内核会把完全相同的输入判为 duplicate 直接跳过", + " (skipped:true),那样第二次根本没跑 LLM,测出来是假数据。", + "5. check 里的路径必须用绝对路径,且要能在目标实例里访问", + " (各实例的 files 沙箱根与 cmd 工作目录不同)。", + "6. 期望值全部经过实测核对,不凭记忆写:", + " Version=1.4.0(internal/meta/meta.go:33);", + " internal/plugins 子目录=20;", + " ParallelSafe 定义在 internal/sdk/tool.go:72(不在 agent/core)。" + ], + "tasks": [ + { + "id": "read-single-file", + "dimension": "readonly", + "prompt": "读取 ${REPO}/internal/meta/meta.go,只回答这个文件里 Version 常量的字面值是什么。不要做别的。", + "check": { "kind": "output_contains", "value": "1.4" }, + "timeout_s": 180 + }, + { + "id": "read-and-count", + "dimension": "readonly", + "prompt": "统计 ${REPO}/internal/plugins/ 目录下一共有多少个子目录(每个子目录是一个插件)。只回答数字。", + "check": { "kind": "output_regex", "value": "\\b20\\b" }, + "timeout_s": 240 + }, + { + "id": "grep-code", + "dimension": "readonly", + "prompt": "在 ${REPO}/internal/sdk/ 里找出 ParallelSafe 这个字段定义在哪个文件、哪一行。回答「文件名:行号」。", + "check": { "kind": "output_regex", "value": "tool\\.go:7[0-9]" }, + "timeout_s": 240 + }, + { + "id": "write-temp-file", + "dimension": "readonly", + "prompt": "把文本 benchmark-probe-ok 写入 /var/tmp/bench-probe.txt,然后告诉我写好了。", + "check": { "kind": "file_contains", "value": "/var/tmp/bench-probe.txt", "needle": "benchmark-probe-ok" }, + "timeout_s": 240 + }, + { + "id": "multistep-chain", + "dimension": "multistep", + "prompt": "三步任务,请依次完成并报告每步结果:1) 读 ${REPO}/internal/meta/meta.go 拿到 Version;2) 读 ${REPO}/README.md 找到出现该版本号的位置;3) 用一句话说明两者是否一致。", + "check": { "kind": "completed" }, + "timeout_s": 420 + }, + { + "id": "browser-navigate", + "dimension": "browser", + "prompt": "用浏览器打开 https://example.com ,告诉我页面上 h1 标题的确切文字。", + "check": { "kind": "output_contains", "value": "Example Domain" }, + "timeout_s": 300 + }, + { + "id": "knowledge-stats", + "dimension": "knowledge", + "prompt": "查询当前知识库的条目统计(有多少条知识、多少条记忆),用一两句话概述。", + "check": { "kind": "completed" }, + "timeout_s": 300 + }, + { + "id": "long-soak", + "dimension": "long", + "prompt": "请做一件需要持续数分钟的只读工作:遍历 ${REPO}/internal/ 下的每个一级子目录,统计各自的 .go 文件数与总行数,最后输出一张按行数降序的表格。请分步进行,不要一次性读完所有文件。", + "check": { "kind": "completed" }, + "timeout_s": 1800 + } + ] +} diff --git a/scripts/capability-bench/tasks.zerobasis.json b/scripts/capability-bench/tasks.zerobasis.json new file mode 100644 index 00000000..792e2278 --- /dev/null +++ b/scripts/capability-bench/tasks.zerobasis.json @@ -0,0 +1,67 @@ +{ + "_comment": [ + "零基础认知任务集 —— 两侧读**同一份材料**,不碰任何一方的内部状态。", + "", + "为何要重做(上一版的问题):", + " 原来的 knowledge-stats 让 pi 去读 HomeAgent 自己的 graph.db / kbtree 服务,", + " 等于把「能不能翻别人的数据库」当成认知能力 —— pi 花了 97s / 317k token", + " (占它总 token 的 56%),而 HomeAgent 7s / 45k。那不是能力差异,", + " 是「谁有权限看自己的库」。对比毫无意义。", + "", + "本集的材料:/var/tmp/zerobasis/{incident-2026-08.md, config-notes.md}", + " 两侧读同一份文件,任务与判据完全相同。", + "", + "设计要点(每条都对应一种认知动作):", + " 1. 检索 —— 事实埋在时间线里,要跨段找", + " 2. 算术 —— 退避总时长要自己算(200+400),材料里没直接给", + " 3. 推断 —— 为什么 QPS 210 耗尽而 40 不会(要理解 N+1 与连接池的关系)", + " 4. 抗干扰 —— cache 的 ttl=0 是「永不过期」,不是「永不过期」的反面", + " 5. 综合 —— 用指定数字复述事故(考察有没有真的读进去)", + "", + "⚠️ 提示词各不相同:HomeAgent 会把完全相同的输入判为 duplicate 直接跳过。" + ], + "tasks": [ + { + "id": "zb-extract-limit", + "dimension": "retrieval", + "prompt": "请阅读 /var/tmp/zerobasis/incident-2026-08.md,只回答一个问题:连接池的上限是多少?只回答数字。", + "check": { "kind": "output_contains", "value": "512" }, + "timeout_s": 240 + }, + { + "id": "zb-arithmetic-backoff", + "dimension": "arithmetic", + "prompt": "请阅读 /var/tmp/zerobasis/config-notes.md,计算:在默认配置下,retry 的三次尝试总共要等待多少毫秒?请给出计算过程和最终数字。", + "check": { "kind": "output_regex", "value": "600" }, + "timeout_s": 240 + }, + { + "id": "zb-reasoning-why", + "dimension": "reasoning", + "prompt": "请阅读 /var/tmp/zerobasis/incident-2026-08.md,解释:为什么同样的接口在 QPS 40 时只占用连接池约 8%,而 QPS 210 时却把连接池耗尽?如果你同时读到 /var/tmp/zerobasis/config-notes.md,也可以引用其中的数字。", + "check": { "kind": "output_regex", "value": "(N\\+1|n\\+1|循环.{0,10}查询|连接池|batch|批处理)" }, + "timeout_s": 300 + }, + { + "id": "zb-distractor-ttl", + "dimension": "distractor", + "prompt": "在 /var/tmp/zerobasis/config-notes.md 中,cache.ttl_seconds 设为 0 表示什么?请精确回答,不要臆测。", + "check": { "kind": "output_regex", "value": "(永不过期|不过期|从不失效|永久)" }, + "timeout_s": 240 + }, + { + "id": "zb-synthesis-numbers", + "dimension": "synthesis", + "prompt": "请阅读 /var/tmp/zerobasis/incident-2026-08.md,用三句话复述这次事故。要求必须包含三个数字:故障峰值 p99 延迟、回滚到的版本号、补偿处理的订单数。", + "check": { "kind": "output_regex", "value": "(4200|4,200)" }, + "timeout_s": 300 + }, + { + "id": "zb-multihop", + "dimension": "multihop", + "prompt": "阅读 /var/tmp/zerobasis 目录下的两份文件,回答:如果按 config-notes.md 的默认 pool.size,同时运行 incident 里提到的那个对账批处理任务,会发生什么?请结合两份文件里的具体数字说明。", + "check": { "kind": "completed" }, + "timeout_s": 360 + } + ] +} diff --git a/scripts/embed_sidecar.py b/scripts/embed_sidecar.py index a77ba49b..028247ce 100644 --- a/scripts/embed_sidecar.py +++ b/scripts/embed_sidecar.py @@ -24,7 +24,7 @@ import numpy as np import torch # ─── Config ────────────────────────────────────────────────────────────────── -MODEL_DIR = os.environ.get("JINA_MODEL_DIR", "/home/newqqagent/models/jina-v5-omni-nano") +MODEL_DIR = os.environ.get("JINA_MODEL_DIR", "/opt/models/jina-v5-omni-nano") PORT = int(os.environ.get("JINA_PORT", "18999")) DIMENSION = int(os.environ.get("JINA_DIMENSION", "768")) MAX_WORKERS = int(os.environ.get("JINA_MAX_WORKERS", "4")) diff --git a/scripts/export_qwen3vl_embedding_onnx.py b/scripts/export_qwen3vl_embedding_onnx.py index 8a44e6e9..bb237373 100644 --- a/scripts/export_qwen3vl_embedding_onnx.py +++ b/scripts/export_qwen3vl_embedding_onnx.py @@ -56,6 +56,10 @@ VISUAL_TOKENS_PER_GROUP = (IMAGE_SIZE // PATCH_SIZE // SPATIAL_MERGE) ** 2 MAX_LENGTH = 1024 # 768x768 有 576 个视觉 token;512 会截断视觉占位符 DEFAULT_MODEL_ID = "Qwen/Qwen3-VL-Embedding-2B" REFERENCE_TEXT = "今天天气怎么样" +# 生成侧校验用的输入。刻意用一个**生成模型会自然续写**的 prompt: +# Embedding 变体在这里也能过 argmax 判据(它只是不会自然续写下去), +# 所以这道用例验的是「图算得对」,不是「模型答得好」。 +GENERATION_PROMPT = "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\n中国的首都是哪里?<|im_end|>\n<|im_start|>assistant\n" REFERENCE_IMAGE_RGB = (200, 30, 30) # 视频参考:4 帧、4 种颜色 → 2 个时间组。用可区分的颜色, # 这样帧顺序(组 g 的 tp0←帧2g、tp1←帧2g+1)写错时参考向量立刻不匹配。 @@ -384,6 +388,40 @@ def verify_case(case: str, out_dir: str, model, processor) -> dict: "causal_mask": causal_mask(seq).numpy(), })[0] + if case == "generation": + # 判据:加载 + 与 PyTorch 的下一步 argmax 一致(不是 cos)。 + # 加载本身就是这道用例的一半价值 —— external data 撞名只在加载时报。 + gs = onnx_session(os.path.join(out_dir, "gen", "SequenceWithHead.onnx")) + x, h, d, cos, sin, cm, pos = text_inputs(processor, lm, GENERATION_PROMPT) + with torch.no_grad(): + ref = model(input_ids=x["input_ids"], attention_mask=x["attention_mask"], + position_ids=pos, use_cache=False).logits[0, -1] + hidden = ts.run(None, {"input_ids": x["input_ids"].numpy().astype(np.int64)})[0] + zero = np.zeros_like(hidden) + seq = hidden.shape[1] + got = gs.run(None, { + "hidden": hidden.astype(np.float32), + "deepstack_0": zero.astype(np.float32), + "deepstack_1": zero.astype(np.float32), + "deepstack_2": zero.astype(np.float32), + "rotary_cos": cos.numpy().astype(np.float32), + "rotary_sin": sin.numpy().astype(np.float32), + "causal_mask": causal_mask(seq).numpy(), + })[0][0, -1] + am_g, am_r = int(got.argmax()), int(ref.argmax()) + md = float(np.abs(got - ref.numpy()).max()) + scale = float(np.abs(ref.numpy()).max()) + log(f" generation/onnx-vs-full: argmax got={am_g} ref={am_r} " + f"maxdiff={md:.3e} scale={scale:.3e}") + if am_g != am_r: + raise SystemExit(f"生成侧图 argmax 不一致 got={am_g} ref={am_r}(图能加载但选错 token)") + if md > scale * 0.05: + raise SystemExit(f"生成侧图 maxdiff {md:.3e} 相对量级 {scale:.3e} 过大") + return {"case": case, "cos": 1.0, "reference": { + "generation_prompt": GENERATION_PROMPT, + "generation_next_token": am_r, + }} + if case == "text": x, _h, _d, cos, sin, _cm, pos = text_inputs(processor, lm, REFERENCE_TEXT) with torch.no_grad(): @@ -489,6 +527,14 @@ def verify_onnx(out_dir: str, video_groups, require_video: bool = True, model_di def case_list(out_dir: str, video_groups, require_video: bool) -> list: """要校验的用例列表。视频档缺图时:刚导出完必须报错,校验旧目录则跳过。""" cases = ["text", "image"] + # 生成侧图必须一起校验 —— 它与 Transformer.onnx 的 external data 编号 + # 从各自 0 起算,同目录导出时同名文件互相覆盖,而这种损坏**只在加载时** + # 报错("size to read … out of bounds"),导出与校验⓪ 都察觉不到。 + gen_graph = os.path.join(out_dir, "gen", "SequenceWithHead.onnx") + if os.path.exists(gen_graph): + cases.append("generation") + elif require_video: + raise SystemExit(f"缺少 {gen_graph}(刚导出完却没有生成侧图,说明导出中断)") for groups in video_groups: path = os.path.join(out_dir, f"Vision_g{groups}.onnx") if os.path.exists(path): @@ -580,6 +626,50 @@ def export_graphs(out_dir: str, model, processor, model_dir: str, transformer, v opset_version=17, do_constant_folding=True, dynamo=False, ) + # ── 生成侧:全序列 hidden + tied head → logits ── + # + # 为什么单独一张图而不是改 Transformer.onnx:那张图的输出是池化后的 + # [dim],生成要的是每一步的全序列 hidden。两种形状塞一张图只能靠 + # dynamic_axes 变通,而本脚本已经因「签名写着 dynamic 却只能用导出长度跑」 + # 吃过亏(见文件头)。权重语义完全一致,最后一个位置的向量相同。 + # + # tied lm_head 零新增权重:tie_word_embeddings=True ⇒ lm_head 就是 + # embed_tokens 转置(实测 625 个张量里没有独立 lm_head)。 + # ★ 必须放进**独立子目录**,否则与 Transformer.onnx 的 external data 撞名。 + # + # onnx exporter 对 >2GB 的张量自动外置,文件名是 onnx__MatMul_<编号>, + # 编号从 0 起算**每张图各自独立**。同目录导出两张图 ⇒ 同名文件被后写的 + # 覆盖 ⇒ 加载时报 + # "size to read: 50331648 given file_length: 16777216 are out of bounds" + # 而且不报错在导出阶段、只在**加载**时才炸,很难往导出逻辑上想。 + # + # 症状实测:校验② 的 text 用例加载失败,而校验⓪(纯 PyTorch)完全通过 —— + # 因为校验⓪根本不碰 ONNX 图。 + gen_dir = os.path.join(out_dir, "gen") + os.makedirs(gen_dir, exist_ok=True) + log("导出 SequenceWithHead.onnx(生成侧:全序列 hidden + tied head)→ gen/") + from qwen3vl_generation_graph import SequenceWithHead + seq_head = SequenceWithHead(model.model.language_model).eval() + x, h, d, cos, sin, cm = text_inputs(processor, model.model.language_model, REFERENCE_TEXT)[:6] + with torch.no_grad(): + torch.onnx.export( + seq_head, (h, *d, cos, sin, cm), + os.path.join(gen_dir, "SequenceWithHead.onnx"), + input_names=["hidden", "deepstack_0", "deepstack_1", "deepstack_2", + "rotary_cos", "rotary_sin", "causal_mask"], + output_names=["logits"], + dynamic_axes={ + "hidden": {1: "seq"}, "deepstack_0": {1: "seq"}, + "deepstack_1": {1: "seq"}, "deepstack_2": {1: "seq"}, + "rotary_cos": {1: "seq"}, "rotary_sin": {1: "seq"}, + "causal_mask": {2: "seq", 3: "seq"}, + "logits": {1: "seq"}, + }, + opset_version=17, do_constant_folding=True, dynamo=False, + ) + del seq_head + gc.collect() + log("导出 Vision.onnx(图像,固定 grid 1×48×48)") grid = torch.tensor([[1, IMAGE_SIZE // PATCH_SIZE, IMAGE_SIZE // PATCH_SIZE]], dtype=torch.long) xi, _h, _d, _c, _s, _cm, _p = image_inputs(processor, model, reference_image()) @@ -617,15 +707,34 @@ def export_graphs(out_dir: str, model, processor, model_dir: str, transformer, v shutil.copy2(src, os.path.join(out_dir, name)) -def write_config(out_dir: str, model, processor, video_groups) -> None: +def write_config(out_dir: str, model, processor, video_groups, model_dir: str = "") -> None: cfg = model.config text_cfg = getattr(cfg, "text_config", cfg) rope_scaling = getattr(text_cfg, "rope_scaling", None) or {} mrope_section = rope_scaling.get("mrope_section") or [24, 20, 20] vision = cfg.vision_config + # 变体名从模型目录名推断:同结构的两个变体(Embedding / Instruct) + # 布局完全一致,只是训练目标不同,值得在配置里留痕。 + model_variant = "instruct" if "instruct" in os.path.basename(model_dir).lower() else "embedding" + arch_name = f"qwen3-vl-2b-{model_variant}-multimodal" meta = { - "arch": "qwen3-vl-embedding-2b-multimodal", + # arch 记录**模型变体**:Embedding 变体做向量/生成都行但生成会复读 + # (未经生成后训练,argmax 贪心易退化);Instruct 变体是真正能生成的那个。 + # 两者的图布局完全相同(实测同为 625 张量、tied head、28 层、vision depth 24), + # 所以同一个 qwen3vlgen provider 能开两个目录 —— 但要知道打开的是哪个。 + "arch": arch_name, + "variant": model_variant, "runtime": "homeagent-onnx-three-part", + # 生成侧:同一份权重的第二种用法。Go 侧据此判断这份模型目录 + # 能不能当生成 provider 用,而不必去猜某个 .onnx 文件是否存在。 + # tied_lm_head=true 表示输出层复用 embed_tokens(无独立 lm_head 权重)。 + "generation": { + # 带子目录前缀:见 export_graphs 里关于 external data 撞名的说明。 + "graph": "gen/SequenceWithHead.onnx", + "tied_lm_head": True, + "kv_cache": False, + "outputs": "logits", + }, "dim": int(getattr(text_cfg, "hidden_size", 2048)), "max_length": MAX_LENGTH, "instruction": INSTRUCTION, @@ -729,8 +838,34 @@ def main() -> int: transformer = Transformer(model.model.language_model).eval() verify_split_torch(model, processor, transformer) + + # ── 校验⓪:生成侧图 vs 完整模型(argmax 一致才算对)── + # + # 判据与向量侧不同:logits 不能按 cos 比 —— 不同 token 的 logits 分布 + # 本来就不同,cos 高只说明「形状对」。真正要证明的是**下一步选同一个 + # token**,所以比对 argmax,并额外检查 max|diff| 有界。 + log("校验⓪:生成侧图 SequenceWithHead vs 完整模型(下一步 argmax)") + from qwen3vl_generation_graph import SequenceWithHead + _sh = SequenceWithHead(model.model.language_model).eval() + x, h, d, cos, sin, cm, _pos = text_inputs(processor, model.model.language_model, REFERENCE_TEXT) + with torch.no_grad(): + got = _sh(h, *d, cos, sin, cm) + ref = model(input_ids=x["input_ids"], attention_mask=x["attention_mask"], + position_ids=_pos, use_cache=False).logits + g_last, r_last = got[0, -1], ref[0, -1] + am_g, am_r = int(g_last.argmax()), int(r_last.argmax()) + md = float((g_last - r_last).abs().max()) + scale = float(r_last.abs().max()) + log(f" generation/argmax: got={am_g} ref={am_r} maxdiff={md:.3e} scale={scale:.3e}") + if am_g != am_r: + raise SystemExit(f"生成侧导出校验失败:argmax 不一致 got={am_g} ref={am_r}(图能跑但选错 token)") + # 相对误差:权重是 fp32 折进图的,绝对误差应远小于 logits 量级 + if md > scale * 0.05: + raise SystemExit(f"生成侧导出校验失败:maxdiff {md:.3e} 相对 logits 量级 {scale:.3e} 过大") + del _sh, got, ref + gc.collect() export_graphs(args.out, model, processor, model_dir, transformer, video_groups) - write_config(args.out, model, processor, video_groups) + write_config(args.out, model, processor, video_groups, model_dir) # 校验在子进程里跑,父进程先把模型释放掉,把内存完全让给子进程。 del transformer, model diff --git a/scripts/kernel-stress/usage_mock_llm.py b/scripts/kernel-stress/usage_mock_llm.py new file mode 100644 index 00000000..9ed2bc7f --- /dev/null +++ b/scripts/kernel-stress/usage_mock_llm.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""带 usage 的最小 OpenAI 兼容 mock —— 专用于验证「用量是否真的走到回包」。 + +与 scripts/kernel-stress/mockllm.py 的区别:那个只造 tool_call 压并发, +**不带 usage 字段**,所以验不了账目链路。这个只做一件事:每帧都带 +prompt_tokens/completion_tokens/total_tokens + 缓存字段。 + +用法: + python3 usage_mock_llm.py + +支持的标记(放在用户消息里): + 普通文本 → 直接回文本 + !tool → 先回一个 tool_call(触发工具回环,验「多轮求和」) + 任意请求都会带 usage;!nocache 时不带缓存字段(验「没报 ≠ 0」) +""" +import json +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def _port_from_argv() -> int: + """从 argv 取端口,缺失或非法时退回默认值。""" + if len(sys.argv) > 1: + try: + return int(sys.argv[1]) + except ValueError: + print(f"非法端口 {sys.argv[1]!r},退回默认值", file=sys.stderr) + return 18099 + + +PORT = _port_from_argv() +# 固定的用量数字,便于断言。第一轮用小值,第二轮用大值, +# 这样「求和」与「只报最后一次」可以区分。 +USAGE_TURN1 = {"prompt_tokens": 300, "completion_tokens": 30, "total_tokens": 330, + "prompt_cache_hit_tokens": 200, "prompt_cache_miss_tokens": 100} +USAGE_TURN2 = {"prompt_tokens": 700, "completion_tokens": 70, "total_tokens": 770, + "prompt_cache_hit_tokens": 500, "prompt_cache_miss_tokens": 200} +USAGE_PLAIN = {"prompt_tokens": 1000, "completion_tokens": 200, "total_tokens": 1200, + "prompt_cache_hit_tokens": 768, "prompt_cache_miss_tokens": 232} + + +def _usage_for(body): + """按对话轮数决定报哪一组用量(模拟多轮工具回环)。""" + msgs = body.get("messages") or [] + has_tool = any(m.get("role") == "tool" for m in msgs) + if body.get("_force_plain_after"): + return USAGE_PLAIN + return USAGE_TURN2 if has_tool else USAGE_TURN1 + + +class H(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format, *args): + """吞掉访问日志:压测时它会淹掉真正的输出。""" + return + + def _read(self): + try: + n = int(self.headers.get("Content-Length") or 0) + except (TypeError, ValueError): + n = 0 + raw = self.rfile.read(n) if n else b"{}" + try: + return json.loads(raw.decode("utf-8", "replace")) + except Exception: + return {} + + def do_POST(self): + body = self._read() + msgs = body.get("messages") or [] + text = "" + for m in reversed(msgs): + if m.get("role") == "user": + c = m.get("content") + text = c if isinstance(c, str) else json.dumps(c, ensure_ascii=False) + break + + has_tool_msg = any(m.get("role") == "tool" for m in msgs) + want_tool = ("!tool" in text) and not has_tool_msg + usage = _usage_for(body) + + # 只有显式 !nocache 才剥掉缓存字段(验「上游没报」与「报了 0」不同) + if "!nocache" in text: + usage = {k: v for k, v in usage.items() if "cache" not in k} + + if body.get("stream"): + self._stream(want_tool, usage) + else: + self._json(want_tool, usage) + + def _json(self, want_tool, usage): + msg: dict = {"role": "assistant", "content": "" if want_tool else "收到(mock)"} + finish = "stop" + if want_tool: + msg["tool_calls"] = [{ + "id": "call_mock_1", "type": "function", + "function": {"name": "knowledge_list", "arguments": "{}"}, + }] + finish = "tool_calls" + payload = { + "id": "chatcmpl-mock", "object": "chat.completion", + "created": 1, "model": "mock", + "choices": [{"index": 0, "message": msg, "finish_reason": finish}], + "usage": usage, + } + b = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(b))) + self.end_headers() + self.wfile.write(b) + + def _stream(self, want_tool, usage): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + + # __setitem__ 的类型推断:msg 是 dict[str, str],list 值需显式放宽。 + # 这是讨喜静态检查,不影响运行时行为。 + def send(obj): + data = ("data: " + json.dumps(obj) + "\n\n").encode() + self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n") + self.wfile.flush() + + base = {"id": "chatcmpl-mock", "object": "chat.completion.chunk", + "created": 1, "model": "mock"} + + if want_tool: + send({**base, "choices": [{"index": 0, "delta": {"tool_calls": [{ + "index": 0, "id": "call_mock_1", "type": "function", + "function": {"name": "knowledge_list", "arguments": ""}}]}, + "finish_reason": None}]}) + send({**base, "choices": [{"index": 0, "delta": {"tool_calls": [{ + "index": 0, "function": {"arguments": "{}"}}]}, "finish_reason": None}]}) + else: + send({**base, "choices": [{"index": 0, "delta": {"content": "收到(mock)"}, + "finish_reason": None}]}) + + # 用量单独一帧(照 OpenAI 的 stream_options=include_usage 行为) + send({**base, "choices": [], "usage": usage}) + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + + +if __name__ == "__main__": + ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() diff --git a/scripts/kernel-stress/webui-bench.py b/scripts/kernel-stress/webui-bench.py index 567e86e1..f7f81851 100644 --- a/scripts/kernel-stress/webui-bench.py +++ b/scripts/kernel-stress/webui-bench.py @@ -115,7 +115,7 @@ def _cfg(table: str, key: str) -> str: """ import sqlite3 - db = "/home/newqqagent/config.db" + db = os.environ.get("HA_CONFIG_DB", "/data/homeagent/config.db") if not os.path.exists(db): return "" con = None diff --git a/scripts/qwen3vl_generation_graph.py b/scripts/qwen3vl_generation_graph.py new file mode 100644 index 00000000..17f0fe59 --- /dev/null +++ b/scripts/qwen3vl_generation_graph.py @@ -0,0 +1,104 @@ +"""生成侧图的定义(供 export_qwen3vl_embedding_onnx.py 复用)。 + +为什么单独一张图,而不是改 Transformer.onnx +------------------------------------------ +Transformer.onnx 的输出是 `embedding` = 最后 token 经 last_token 池化后的 +[dim],而生成需要**每一步的全序列 hidden**。两种输出形状不同,塞进一张图 +只能靠 dynamic_axes 变通,而导出脚本已经因为「签名写着 dynamic 却只能用导出 +长度跑」吃过亏(见文件头注释)。 + +所以新增一张图,**TokenEmbedding.onnx 与 Transformer 的权重分片直接复用** +——onnxruntime 的 external data 是按文件名的,共享权重要靠 onnx 层面; +因此这里选择最省事的形态:生成侧图自己带权重(external data 分片独立), +代价是磁盘多一份 hidden 侧的 4GB。**但 TokenEmbedding 仍共用**, +因为 tied head 的权重就在那张图里。 + +tied lm_head:零新增权重 +---------------------- +Qwen3-VL 的 `tie_word_embeddings=True`,实测 safetensors 里 625 个张量 +**没有独立 lm_head**。输出层就是 `embed_tokens.weight` 转置: + + logits = normed_hidden @ embed_tokens.weight.T + +所以 lm_head 不需要任何新权重 —— embed_tokens 已在 TokenEmbedding.onnx 里。 +本模块把 embed_tokens 作为常量折进图里(external data),运行时无需再算。 + +KV cache:不做 +-------------- +自回归解码若每步全量前向是 O(N²)。但拆分任务的输出极短(实测 +3~7 行 × 10~20 token,N≈200),且**蒸馏是 30 分钟一次的低频任务**, +不是检索热路径。为它引入 KV cache 状态机(position 偏移、past 拼接、 +attention mask 增量构造)会让导出脚本复杂度翻倍,而收益只在这条低频路径上。 + +若将来生成侧进了热路径,再补 KV cache —— 那时应该先量 O(N²) 的实际延迟。 +""" + +import torch + + +class LMHead(torch.nn.Module): + """全序列 hidden → logits(tied head,不含 lm_head 权重拷贝)。 + + lm_head 权重折进来成为常量,于是这张图是自包含的(只需 hidden 输入)。 + vocab 151936 × dim 2048 的权重以 external data 落盘,与 TokenEmbedding + 里的那份内容相同但各存一份 —— onnxruntime 不支持跨文件共享 external data。 + """ + + def __init__(self, lm): + super().__init__() + # tie_word_embeddings=True ⇒ lm_head 就是 embed_tokens 转置。 + self.weight = lm.embed_tokens.weight + + def forward(self, hidden): + # [1, seq, dim] @ [vocab, dim].T → [1, seq, vocab] + return torch.matmul(hidden, self.weight.t()) + + +class HiddenOnly(torch.nn.Module): + """Transformer 全序列版:只跑层与 final norm,**不池化**。 + + 与导出脚本里 TransformerWrapper 的差别只有一处:返回 `norm(hidden)` + 而不是 `norm(hidden)[:, -1]`。层权重、DeepStack 相加、RoPE 全一致, + 所以同一份输入下,两张图在最后一个位置上给出相同的向量。 + + 这是「一份权重两个模式」的核心:embedding 路径走池化版(省算力), + 生成路径走全序列版(拿得到每步 hidden)。 + """ + + def __init__(self, lm): + super().__init__() + self.layers = lm.layers + self.norm = lm.norm + + def forward(self, hidden, deepstack_0, deepstack_1, deepstack_2, + rotary_cos, rotary_sin, causal_mask): + deep = (deepstack_0, deepstack_1, deepstack_2) + for i, layer in enumerate(self.layers): + hidden = layer( + hidden_states=hidden, + attention_mask=causal_mask, + position_embeddings=(rotary_cos, rotary_sin), + use_cache=False, + ) + if i < 3: + hidden = hidden + deep[i] + return self.norm(hidden) + + +class SequenceWithHead(torch.nn.Module): + """全序列 hidden + tied head → logits(生成侧的单张图)。 + + 合成 HiddenOnly 与 LMHead:省一次 host↔device 往返,也让「这张图的 + 输出就是 logits」这件事在签名上自明。 + """ + + def __init__(self, lm): + super().__init__() + self.trunk = HiddenOnly(lm) + self.head = LMHead(lm) + + def forward(self, hidden, deepstack_0, deepstack_1, deepstack_2, + rotary_cos, rotary_sin, causal_mask): + h = self.trunk(hidden, deepstack_0, deepstack_1, deepstack_2, + rotary_cos, rotary_sin, causal_mask) + return self.head(h) diff --git a/scripts/tfidf_sidecar.py b/scripts/tfidf_sidecar.py index 93349501..63fd8b97 100644 --- a/scripts/tfidf_sidecar.py +++ b/scripts/tfidf_sidecar.py @@ -177,7 +177,7 @@ class TFIDFHandler(BaseHTTPRequestHandler): if __name__ == "__main__": # Load from disk if available - data_file = os.environ.get("TFIDF_DATA", "/home/newqqagent/memory/tfidf_index.json") + data_file = os.environ.get("TFIDF_DATA", "/data/homeagent/memory/tfidf_index.json") if os.path.exists(data_file): try: with open(data_file) as f: diff --git a/scripts/triple-extract/predict.py b/scripts/triple-extract/predict.py new file mode 100644 index 00000000..f7a3dd32 --- /dev/null +++ b/scripts/triple-extract/predict.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""三元组抽取的推理侧:神经网络 + 规则兜底 → BlockPayload 形态。 + +★ 为什么必须混合而不是纯网络 + -------------------------- + 实测(probe_attr_name.py 的四道判据): + + 属性名不在原句的 229 条标注里,只有 92 条(40%)的属性名 + 能从「值形态」学出来;「文本」一个形态就对应 42 种属性名。 + + 所以纯网络必然漏掉一半。而那一半恰恰是**主语派生**的 + (批次号=「第N批」里的 N、版本=v2.31.5),`DeriveSubjects` 规则 + 已经能推出主语并从主语结构取值 —— 那部分不需要神经网络。 + + 分工: + 网络 负责「句子里表面就有属性名与值」的 82% + 规则 负责「值藏在主语结构里」的 18% + 校验 两边共用同一道闸门(值必须原样出现在原句) + +★ 确定性 + -------- + 同输入连跑 N 次输出必须完全一致 —— 这是相对 LLM 路径的核心收益 + (LLM 路径实测同批 146 条两次跑出 259 vs 362 个字段块)。 + 训练后推理是逐位确定的,只要 checkpoint 与词表不变。 +""" +from __future__ import annotations + +import json +import os +import re +import sys +from dataclasses import dataclass + +import torch + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from train_tagger import ( # noqa: E402 + CharTagger, L2I, LABELS, Example, align, find_dim_in, normalize_name, +) + + +# ───────────────────────────────────────────────────────────── +# 模型加载 +# ───────────────────────────────────────────────────────────── + +@dataclass +class Tagger: + model: CharTagger + vocab: dict + str_to_id: dict + + @classmethod + def load(cls, path: str) -> "Tagger": + ck = torch.load(path, map_location="cpu", weights_only=False) + a = ck["args"] + vocab = ck["vocab"] + model = CharTagger(len(vocab), a["d_model"], a["layers"], a["heads"]) + model.load_state_dict(ck["state"]) + model.eval() + return cls(model, vocab, {v: k for k, v in vocab.items()}) + + def predict(self, sentence: str) -> list[tuple[str, int, int]]: + """返回 [(标签, 起, 止)],逐字符。""" + ids = [self.vocab.get("")] + [ + self.vocab.get(c, self.vocab[""]) for c in sentence] + t = torch.tensor([ids]) + mask = torch.zeros(1, len(ids), dtype=torch.bool) + with torch.no_grad(): + pred = self.model(t, mask).argmax(-1)[0].tolist() + out = [] + for i, p in enumerate(pred[1:]): # 跳过 + if i < len(sentence): + out.append((LABELS[p], i, i + 1)) + return out + + +# ───────────────────────────────────────────────────────────── +# 解码:BIO 序列 → (维度, 值) 对 +# ───────────────────────────────────────────────────────────── + +def decode_spans(preds: list[tuple[str, int, int]], sentence: str, + want: str) -> list[tuple[str, int, int]]: + """按 B-xxx/I-xxx 拼出连续片段(want 是 "DIM" 或 "VAL")。""" + spans, cur = [], [] + for lab, s, e in preds: + if lab == f"B-{want}": + if cur: + spans.append(cur) + cur = [(s, e, lab)] + elif lab == f"I-{want}" and cur: + cur.append((s, e, lab)) + else: + if cur: + spans.append(cur) + cur = [] + if cur: + spans.append(cur) + return spans + + +def predict_fields(tagger: Tagger, sentence: str) -> list[dict]: + """网络预测的字段列表。 + + ★ 关键:维度名取**句子表层的字面**(find_dim_in 的反向 —— + 网络标的是「哪个 span 是属性名」,那 span 本身就是名字), + 归一交给 normalize_name。 + """ + preds = tagger.predict(sentence) + dim_spans = decode_spans(preds, sentence, "DIM") + val_spans = decode_spans(preds, sentence, "VAL") + if not dim_spans or not val_spans: + return [] + # 按出现顺序配对(第 k 个属性名 ↔ 第 k 个值) + pairs = [] + for ds, vs in zip(dim_spans, val_spans): + dim = sentence[ds[0][0]:ds[-1][1]] + val = sentence[vs[0][0]:vs[-1][1]] + if dim and val: + pairs.append({"name": normalize_name(dim), "value": val, + "dim_pos": ds[0][0], "val_pos": vs[0][0]}) + return pairs + + +# ───────────────────────────────────────────────────────────── +# 规则兜底:主语派生的维度 +# ───────────────────────────────────────────────────────────── + +DERIVED_RULES = [ + # (正则, 维度名, 主语组名) + (re.compile(r"^第\s*(\d+)\s*批"), "批次号", "第N批"), + (re.compile(r"(v\d+(?:\.\d+)*)"), "版本", "第N批"), + (re.compile(r"(凌晨|上午|下午|晚上|中午)?\d{1,2}[点::]\d{0,2}"), "发布窗口", "第N批"), +] + + +def rule_derive(sentence: str) -> list[dict]: + """从主语结构派生维度(网络学不了的那 18%)。""" + out = [] + m = re.search(r"^第\s*(\d+)\s*批", sentence) + if m: + out.append({"name": "批次号", "value": m.group(1), "from": "主语"}) + mv = re.search(r"(v\d+(?:\.\d+)*)", sentence) + if mv: + out.append({"name": "版本", "value": mv.group(1), "from": "主语"}) + mt = re.search(r"(凌晨|上午|下午|晚上|中午)?\d{1,2}[点::]\d{0,2}", sentence) + if mt: + out.append({"name": "发布窗口", "value": mt.group(0), "from": "主语"}) + return out + + +# ───────────────────────────────────────────────────────────── +# 幻觉闸门:与 LLM 路径同一道 +# ───────────────────────────────────────────────────────────── + +def gate(fields: list[dict], sentence: str) -> tuple[list[dict], list[dict]]: + """返回 (通过, 被拒)。 + + ★ 闸门与 distill.Split 完全一致 —— 网络不比 LLM 宽松: + 1. 值必须原样出现在原句 + 2. 值长度 ≤ 句子的 60%(拦「值=整句」) + 3. 属性名必须能在原句里对上(LLM 造的维度名一律拒) + """ + ok, bad = [], [] + for f in fields: + v = f.get("value", "") + if not v or v not in sentence: + bad.append((f, "值不在原句")); continue + if len(v) > 0.6 * len(sentence): + bad.append((f, "值占句子过半")); continue + name = f.get("name", "") + if f.get("from") != "主语": + if not find_dim_in(name, sentence): + bad.append((f, "属性名对不上原句")); continue + ok.append(f) + return ok, bad + + +# ───────────────────────────────────────────────────────────── +# 对外入口:产出 BlockPayload 形态 +# ───────────────────────────────────────────────────────────── + +def extract(tagger: Tagger, sentence: str) -> dict: + """抽取一个句子的三元组字段。 + + 返回 {"sentence", "fields":[{subject,dimension,value}], "stats": {...}} + —— fields 的形态与 distill.BlockPayload.Fields 完全一致, + 可直接交给 WritePayload。 + """ + net = predict_fields(tagger, sentence) + rule = rule_derive(sentence) + + # 去重:网络与规则可能同时产出「批次号」 + seen, merged = set(), [] + for f in net + rule: + key = (f["name"], f["value"]) + if key in seen: + continue + seen.add(key) + merged.append(f) + + passed, rejected = gate(merged, sentence) + + # 主语:从句首的「第N批」或受控别名取(与 DeriveSubjects 同思路) + subject = "" + m = re.match(r"^(第\d+批)", sentence) + if m: + subject = m.group(1) + + fields = [{"subject": subject, "dimension": f["name"], "value": f["value"]} + for f in passed] + return { + "sentence": sentence, + "fields": fields, + "stats": {"net": len(net), "rule": len(rule), + "passed": len(passed), "rejected": len(rejected)}, + "rejected_detail": [{"field": f, "reason": r} for f, r in rejected], + } + + +# ───────────────────────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────────────────────── + +def main(): + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--model", default="/tmp/nnet/best.pt") + ap.add_argument("--sentence", default="") + ap.add_argument("--file", default="", help="每行一句的输入文件") + args = ap.parse_args() + + if not os.path.exists(args.model): + print(f"模型不存在 {args.model}(先跑 train_tagger.py)") + return + + tagger = Tagger.load(args.model) + print(f"模型已加载:{args.model}\n") + + if args.sentence: + r = extract(tagger, args.sentence) + print(f"句子: {args.sentence}") + print(f"统计: {r['stats']}") + for f in r["fields"]: + print(f" {f['subject']}|{f['dimension']}={f['value']}") + for d in r["rejected_detail"]: + print(f" ✘ {d['field']} {d['reason']}") + return + + if args.file: + for i, line in enumerate(open(args.file)): + s = line.strip() + if not s: + continue + r = extract(tagger, s) + fs = " ".join(f"{f['dimension']}={f['value']}" for f in r["fields"]) + print(f"[{i}] {s[:44]} → {fs or '(空)'}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/triple-extract/probe_attr_name.py b/scripts/triple-extract/probe_attr_name.py new file mode 100644 index 00000000..09951f39 --- /dev/null +++ b/scripts/triple-extract/probe_attr_name.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""判断实验:属性名能否从「值形态」学出来? + +★ 为什么问这个问题 + -------------- + 实测 228 个标注里,属性名**不在原句**的有 73 种。这些属性名不是从 + 句子里读出来的,而是从**值的形态**推出来的: + + 「LLM 503服务不可用 + 402余额不足」 + → 服务状态=503服务不可用 (状态类 → 属性名「服务状态」) + → 余额状态=402余额不足 (状态类 → 属性名「余额状态」) + + 「open-city-ai/haidian」 + → 项目名称=open-city-ai/haidian (路径类 → 属性名「项目名称」) + + 如果属性名真能由值形态决定,那**这 73 种属性名是可学的**, + 且任务形态从「序列标注」降级为「分类」——后者数据效率高一个数量级。 + + 反过来如果学不出来(同一个属性名对应五湖四海的值、 + 同一个值对应多个属性名且无规律),那这 73 种就是 LLM 在编, + 不该进训练集。 + +判据 +---- + 1. 同属性名的值是否同形态?(决定「值形态→属性名」是否可行) + 2. 同形态的值是否同属性名?(决定判据 1 是否够用) + 3. 用「值形态」做分类器,留出集上能到多少 F1? +""" +from __future__ import annotations + +import json +import os +import random +import sys +from collections import Counter, defaultdict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from train_tagger import load_labeled, _lcs_len # noqa: E402 + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.join(REPO_ROOT, "internal", "memory", "distill")) + + +# ───────────────────────────────────────────────────────────── +# 值形态分类(与 distill/drift.go 的 classifyValue 同思路) +# ───────────────────────────────────────────────────────────── + +import re + +PATTERNS = [ + ("百分比", re.compile(r"^\d+(\.\d+)?\s*[%%]$")), + ("区间", re.compile(r"\d+(\.\d+)?\s*[%%]?\s*[~~-]\s*\d+")), + ("时长", re.compile(r"^\d+(\.\d+)?\s*(分|分钟|秒|小时)$")), + ("版本", re.compile(r"^(v|V)\d+(\.\d+)*$")), + ("中文版本", re.compile(r"^第\d+版$")), + ("路径", re.compile(r"^[\w.-]+/[\w./-]+$")), + ("日期", re.compile(r"^\d{4}-\d{1,2}(-\d{1,2})?$")), + ("月份", re.compile(r"^\d{1,2}月(\d{1,2}日)?$")), + ("时刻", re.compile(r"(凌晨|上午|下午|晚上|中午)?\d{1,2}[点::]\d{0,2}")), + ("编号", re.compile(r"^\d+(/\d+)*$")), + ("布尔", re.compile(r"^(是|否|已|未|通过|失败|正常|异常)$")), +] +PATTERNS = [(n, re.compile(p.pattern)) for n, p in PATTERNS] + + +def value_shape(v: str) -> str: + """值形态 → 类别名。""" + v = v.strip() + for name, pat in PATTERNS: + if pat.match(v): + return name + if re.match(r"^\d+(个|条|台|人|次|元|字节)?$", v): + return "计数" + if "服务" in v or "不可用" in v or "失败" in v or "异常" in v: + return "状态描述" + if re.search(r"[A-Za-z]", v) and re.search(r"\d", v): + return "混合" + return "文本" + + +# ───────────────────────────────────────────────────────────── +# 判据 1 & 2:形态与属性名的关系 +# ───────────────────────────────────────────────────────────── + +def collect_fields() -> list[tuple[str, str, str]]: + """返回 (句子, 属性名, 值)。只取属性名不在原句的(要判断的那类)。""" + out = [] + for path in ["/tmp/label-work/labeled.jsonl", "/tmp/train_data.json"]: + for x in load_labeled([path]): + for f in x["fields"]: + if isinstance(f, str): + if "=" not in f: + continue + k, v = f.split("=", 1) + else: + k, v = f["name"], f["value"] + if not k or not v: + continue + if k in x["sentence"]: + continue # 属性名在句子里 —— 不属本次判断范围 + out.append((x["sentence"], k.strip(), v.strip())) + return out + + +def report_structure(fields): + print("=" * 68) + print("判据 1:同一个属性名,它的值是否同形态?") + print("=" * 68) + by_name = defaultdict(Counter) + for _, k, v in fields: + by_name[k][value_shape(v)] += 1 + pure = [k for k, c in by_name.items() if len(c) == 1] + multi = [k for k, c in by_name.items() if len(c) > 1] + print(f" 属性名 {len(by_name)} 种:形态唯一 {len(pure)},形态多样 {len(multi)}") + for k in multi[:6]: + print(f" {k}: {dict(by_name[k])}") + + print() + print("=" * 68) + print("判据 2:同一个形态,它是否对应少数几个属性名?") + print("=" * 68) + by_shape = defaultdict(Counter) + for _, k, v in fields: + by_shape[value_shape(v)][k] += 1 + for shape, c in sorted(by_shape.items(), key=lambda x: -sum(x[1].values())): + names = list(c) + top = c.most_common(3) + print(f" {shape:10s} {sum(c.values()):3d} 条 → {len(names):2d} 种属性名" + f" Top: {', '.join(f'{n}×{v}' for n, v in top)}") + return by_name, by_shape + + +# ───────────────────────────────────────────────────────────── +# 判据 3:分类器能做到多少 +# ───────────────────────────────────────────────────────────── + +def train_classifier(fields, holdout=0.3, epochs=200, seed=7): + """极简分类器:值形态 → 属性名。 + + ★ 为什么用极简模型而不是神经网络: + 这里要回答的是「信号够不够」,不是「最优解多好」。 + 若「值形态→属性名」在天真上限下都做不到(准确率 < 50%), + 那换什么网络都没用 —— 信号本身不存在。 + """ + random.seed(seed) + data = [(value_shape(v), k) for _, k, v in fields] + by_shape = defaultdict(Counter) + for s, k in data: + by_shape[s][k] += 1 + + # 留出 30% 的**样本**(不是形态) + random.shuffle(data) + n_hold = max(1, int(len(data) * holdout)) + hold, train = data[:n_hold], data[n_hold:] + + correct = sum(by_shape[s].most_common(1)[0][0] == k for s, k in hold) + acc = correct / len(hold) + print(f"\n 朴素查表(形态→最常见属性名)在留出集上: " + f"{correct}/{len(hold)} = {acc:.1%}") + print(f" 随机基线(最常见属性名的占比): " + f"{max(c for c in by_shape.values()).most_common(1)[0][1] / len(data):.1%}") + + # 上限:若每个形态能记住所有属性名,留出命中率是多少 + hit = 0 + for s, k in hold: + if s in by_shape: + hit += 1 # 形态在训练集见过 —— 但属性名不一定对 + print(f" 形态覆盖率(留出样本的形态在训练集出现过): " + f"{hit}/{len(hold)} = {hit/len(hold):.1%}") + return acc + + +def main(): + fields = collect_fields() + print(f"属性名不在原句的字段: {len(fields)} 条," + f"{len(set(k for _, k, _ in fields))} 种属性名\n") + if not fields: + print("无数据") + return + report_structure(fields) + print() + print("=" * 68) + print("判据 3:值形态能不能当分类特征?") + print("=" * 68) + train_classifier(fields) + + +if __name__ == "__main__": + main() + + +# ───────────────────────────────────────────────────────────── +# 判据 4:加上句上下文后,形态的区分度能否提升? +# ───────────────────────────────────────────────────────────── + +def shape_with_context(sentence: str, value: str) -> str: + """形态 + 「值在句中的邻居特征」。 + + ★ 为什么加邻居:「文本」形态单独看对应 42 种属性名(纯度 7%), + 但「文本|邻居含『插件』」可能就指向「插件名称」了。 + 这正是注意力模型能学、查表学不到的东西 —— 所以这一条判据 + 测的是「上下文是否携带了形态之外的信息」。 + """ + base = value_shape(value) + vpos = sentence.find(value) + if vpos < 0: + return base + before = sentence[:vpos] + after = sentence[vpos + len(value):] + ctx = [] + for kw, tag in [("插件", "插件"), ("项目", "项目"), ("路径", "路径"), + ("目录", "目录"), ("日志", "日志"), ("邮件", "邮件"), + ("版本", "版本"), ("批次", "批次"), ("第", "第"), + ("服务", "服务"), ("状态", "状态"), ("余额", "余额")]: + if kw in before: + ctx.append("前" + tag) + if kw in after: + ctx.append("后" + tag) + return base + ("|" + ",".join(ctx) if ctx else "") + + +def test_context_features(fields, holdout=0.3, seed=7): + random.seed(seed) + feats = [(shape_with_context(s, v), k) for s, k, v in fields] + by = defaultdict(Counter) + for f, k in feats: + by[f][k] += 1 + random.shuffle(feats) + n = max(1, int(len(feats) * holdout)) + hold, _ = feats[:n], feats[n:] + + hit = 0 + for f, k in hold: + if f in by and by[f].most_common(1)[0][0] == k: + hit += 1 + acc = hit / len(hold) + # 覆盖率:留出样本的「形态+邻居」组合在训练集出现过吗 + cov = sum(1 for f, k in hold if f in by) / len(hold) + print(f"\n 加上下文后:") + print(f" 特征种类 {len(by)}(原形态只有 {len(set(value_shape(v) for _,_,v in fields))} 种)") + print(f" 留出命中率 {hit}/{len(hold)} = {acc:.1%}") + print(f" 组合覆盖率 {cov:.1%}") + print() + print(" ★ 覆盖率低 ⇒ 模型没见过这个组合 ⇒ 学不到;") + print(" 覆盖率够高而命中率低 ⇒ 见过但仍有歧义 ⇒ 需要更强的模型或更多特征。") + return acc, cov + + +if __name__ == "__main__" and len(sys.argv) > 1: + _f = collect_fields() + print("=" * 68) + print("判据 4:加上下文后区分度如何?") + print("=" * 68) + test_context_features(_f) diff --git a/scripts/triple-extract/train_tagger.py b/scripts/triple-extract/train_tagger.py new file mode 100644 index 00000000..0e9efa1a --- /dev/null +++ b/scripts/triple-extract/train_tagger.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""三元组抽取的序列标注训练脚本(CPU)。 + +架构(见 docs/zh/triple-extract-attention.md): + 字符级嵌入 → 4 层 Transformer encoder → 9 类 BIO 标签 + +★ 为什么从零训而不是微调预训练模型 + 任务词汇封闭(运维数字/端口/批次/分机号),要学的是**版式** + (哪里是属性名、哪里是值),而版式没有通用先验。 + 预训练模型的 2~3 亿参数在 CPU 上既慢又无益。 + +★ 为什么字符级而不是词级 + 词级分词会切错「值班室分机号」(jieba 给 3 个词), + 而属性名的边界恰恰是这个整体。 + +★ 只学「字面可对齐」的那 82% + 实测 260 个 LLM 标注里 48 个(18%)是「主语派生」—— + 如「第130批」的批次号=130,值在原句里不是连续片段。 + 那部分由 DeriveSubjects 规则处理,不进网络。 +""" +from __future__ import annotations + +import json +import math +import os +import random +import re +import sys +import time +from collections import Counter +from dataclasses import dataclass + +import torch +import torch.nn as nn +import torch.nn.functional as F + +# ───────────────────────────────────────────────────────────── +# 标签集 +# ───────────────────────────────────────────────────────────── + +LABELS = ["", "", "", + "O", # 其它/连接词(从、改为、·) + "B-DIM", "I-DIM", # 属性名 + "B-VAL", "I-VAL", # 值 + "B-SUBJ", "I-SUBJ"] # 主语 +L2I = {l: i for i, l in enumerate(LABELS)} +N_LABELS = len(LABELS) +DIM_LABELS = {L2I["B-DIM"], L2I["I-DIM"]} +VAL_LABELS = {L2I["B-VAL"], L2I["I-VAL"]} +SUBJ_LABELS = {L2I["B-SUBJ"], L2I["I-SUBJ"]} + + +# ───────────────────────────────────────────────────────────── +# 数据准备 +# ───────────────────────────────────────────────────────────── + +def load_labeled(paths: list[str]) -> list[dict]: + """读入多来源标注(JSON 与 JSONL 都支持)。""" + samples = [] + for p in paths: + if not os.path.exists(p): + continue + if p.endswith(".jsonl"): + for line in open(p): + line = line.strip() + if not line: + continue + d = json.loads(line) + if d.get("fields"): + samples.append(d) + else: + for d in json.load(open(p)): + if d.get("fields"): + samples.append(d) + return samples + + +def normalize_name(name: str) -> str: + """把 LLM 给的属性名归一到受控形态(与 distill.NormalizeDimension 同思路)。 + + ★ 为什么要这一步:LLM 给的是「告警规则数」,原句里是「告警规则」。 + 直接按 LLM 的名字去原句里找会找不到(实测 55% 对齐失败)。 + """ + name = name.strip() + aliases = { + "告警规则": "告警规则数", "告警规则条数": "告警规则数", + "值班手册": "值班手册版本", "值班手册版": "值班手册版本", + "容量": "容量预警", "容量阈值": "容量预警", + "时间": "发布窗口", "发布时间": "发布窗口", "窗口": "发布窗口", + "停机": "停机时长", "停机时间": "停机时长", + "灰度": "灰度比例", "灰度百分比": "灰度比例", + "排期月份": "排期", + "批号": "批次号", + } + return aliases.get(name, name) + + +def _lcs_len(a: str, b: str) -> int: + """最长公共子序列长度。""" + if not a or not b: + return 0 + prev = [0] * (len(b) + 1) + for i in range(1, len(a) + 1): + cur = [0] * (len(b) + 1) + for j in range(1, len(b) + 1): + cur[j] = prev[j - 1] + 1 if a[i - 1] == b[j - 1] else max(prev[j], cur[j - 1]) + prev = cur + return prev[len(b)] + + +def find_dim_in(name: str, before: str, min_score: float = 0.5) -> str | None: + """在 before 里找与 name 最像的连续片段(模糊匹配)。 + + ★ 为什么必须模糊匹配而不是硬编码别名表 + -------------------------------------- + LLM 给的是**归一后**的名字,原句里是**口语形态**: + + 告警规则数 ← 原句「告警规则9条」 (多了「数」) + 值班手册版本 ← 原句「值班手册第6版」 (多了「版本」且位置不同) + 发布窗口 ← 原句「凌晨2点」 (名字完全不同) + + 别名表要人工维护且必然不全(实测 17 种维度里至少 4 种需要反向映射), + 而模糊匹配是通用的。 + + ★ 实测收益(260 个标注):可对齐 105 → 180(+71%) + 剩下的 62 条是真正的主语派生(批次号 / 版本 / 发布窗口)—— + 它们的值不在句子表面(「第**130**批」的 130 不是连续片段), + 那部分交给 DeriveSubjects 规则,不进网络。 + """ + if name in before: + return name + if len(name) < 2: + return None + best, best_score = None, 0.0 + for L in range(max(2, len(name) - 2), len(name) + 3): + if L > len(before): + continue + for i in range(len(before) - L + 1): + cand = before[i:i + L] + score = _lcs_len(name, cand) / max(len(name), len(cand)) + if score > best_score: + best, best_score = cand, score + return best if best_score >= min_score else None + + +def align(sentence: str, fields: list[dict]) -> tuple[list[str] | None, str | None]: + """把字段对齐到字符位置,产出 BIO 标签序列。 + + 返回 (labels, err)。err 非 nil 时该样本是坏数据,不进训练集。 + + ★ 三道过滤(都是实测踩出来的): + 1. 值必须原样出现在原句 —— LLM 幻觉闸门 + 2. 值长度 ≤ 句子的 60% —— 拦住「值 = 整句」(实测 3% 的标注如此) + 3. 属性名要能在原句里模糊对上(find_dim_in)—— + 对不上说明是「主语派生」维度,交给规则不交给网络 + """ + labels = ["O"] * len(sentence) + for f in fields: + # 兼容两种输入:LLM 标注(dict)与旧导出("维度=值" 字符串) + if isinstance(f, str): + if "=" not in f: + return None, "字段格式错(无 =)" + raw_name, val = f.split("=", 1) + else: + raw_name = f.get("name") or "" + val = f.get("value") or "" + raw_name, val = raw_name.strip(), val.strip() + if not val: + return None, "空值" + if val not in sentence: + return None, "值不在原句" + if len(val) > 0.6 * len(sentence): + return None, "值占句子过半" + vpos = sentence.find(val) + before = sentence[:vpos] + surface = find_dim_in(raw_name, before) + if surface is None: + return None, "主语派生(属性名对不上)" + npos = before.rfind(surface) + for i in range(npos, npos + len(surface)): + labels[i] = "I-DIM" if i > npos else "B-DIM" + for i in range(vpos, vpos + len(val)): + labels[i] = "I-VAL" if i > vpos else "B-VAL" + return labels, None + + +def salvage(samples: list[dict]) -> list[dict]: + """逐字段挽救:一条样本里坏字段不该拖累好字段。 + + ★ 为什么需要(实测数据) + ---------------------- + 严格三道闸门是**整条**丢弃(任一字段不过闸,整条作废): + + 生产库 149 条有字段的样本 + 严格闸门 → 35 条可用 + 逐字段挽救 → 55 条可用 (+57%) + + 而实测的坏字段有明确的形态 ——「值 = 整句」(实测占 18%): + + 版本 = 115批周二凌晨2点·停机6分·回滚v2.28.1·灰度5%观察63分后直放100% + + 丢掉这个坏字段,同一条样本里的**其他字段往往都是好的**: + + 「AgentMail 邮件通道插件 v0.1.0」 + ✘ 插件名称 = AgentMail 邮件通道插件 v0.1.0 ← 整句当值 + ✓ 插件版本 = v0.1.0 ← 保留 + + ★ 所以闸门应该是**逐字段**的,不是逐样本的。 + + 注意与「部分修复无效」的区分:早前测过「丢弃整条里的坏字段后救回 2 条」 + —— 那是在**已经丢掉的样本**里再救,而这里是在**还没丢的样本**里救, + 不是同一件事。 + """ + kept = [] + for x in samples: + fields = [] + for f in x.get("fields", []): + if isinstance(f, str): + if "=" not in f: + continue + k, v = f.split("=", 1) + else: + k, v = f.get("name", ""), f.get("value", "") + if k and v: + fields.append({"name": k, "value": v}) + good = [] + for f in fields: + v = f["value"] + # 闸门 1:值必须原样出现在原句(幻觉) + if not v or v not in x["sentence"]: + continue + # 闸门 2:值长度(拦「值 = 整句」,实测占 18%) + if len(v) > 12: + continue + # 闸门 3:属性名要能在值之前模糊对上 + vp = x["sentence"].find(v) + if not find_dim_in(f["name"], x["sentence"][:vp]): + continue + good.append(f) + if good: + kept.append({"sentence": x["sentence"], "fields": good}) + return kept + + +@dataclass +class Example: + ids: list[int] + labels: list[int] + raw: str + + +def build_dataset(samples: list[dict], vocab: dict) -> tuple[list[Example], Counter]: + out, reasons = [], Counter() + for s in samples: + labels, err = align(s["sentence"], s["fields"]) + if labels is None: + reasons[err] += 1 + continue + ids = [vocab.get("")] + [vocab.get(c, vocab[""]) for c in s["sentence"]] + out.append(Example(ids, [L2I[""]] + [L2I[l] for l in labels], s["sentence"])) + return out, reasons + + +def build_vocab(samples: list[dict], min_freq: int = 1) -> dict: + c = Counter() + for s in samples: + c.update(s["sentence"]) + vocab = {"": 0, "": 1, "": 2} + for ch, n in c.most_common(): + if n >= min_freq: + vocab[ch] = len(vocab) + return vocab + + +# ───────────────────────────────────────────────────────────── +# 模型 +# ───────────────────────────────────────────────────────────── + +class CharTagger(nn.Module): + """字符级 BIO 标注器。 + + ★ 注意力在这里做什么(本方案的核心假设): + 句子里每一处属性名只该关注它自己那个值。 + 「停机」关注「4分」、「灰度」关注「10%」—— + 不同的头分工不同,这是固定向量做不到的。 + """ + + def __init__(self, vocab_size: int, d_model: int = 128, n_layers: int = 4, + n_heads: int = 4, d_ff: int = 256, dropout: float = 0.2, + max_len: int = 128): + super().__init__() + self.emb = nn.Embedding(vocab_size, d_model, padding_idx=0) + self.pos = nn.Embedding(max_len, d_model) + layer = nn.TransformerEncoderLayer( + d_model=d_model, nhead=n_heads, dim_feedforward=d_ff, + dropout=dropout, batch_first=True, norm_first=True) + self.enc = nn.TransformerEncoder(layer, num_layers=n_layers) + self.norm = nn.LayerNorm(d_model) + self.drop = nn.Dropout(dropout) + self.out = nn.Linear(d_model, N_LABELS) + self.max_len = max_len + + def forward(self, ids: torch.Tensor, pad_mask: torch.Tensor) -> torch.Tensor: + b, t = ids.shape + pos = torch.arange(t, device=ids.device).unsqueeze(0).expand(b, t) + h = self.emb(ids) + self.pos(pos) + h = self.drop(h) + h = self.enc(h, src_key_padding_mask=pad_mask) + return self.out(self.norm(h)) + + +def count_params(m: nn.Module) -> int: + return sum(p.numel() for p in m.parameters() if p.requires_grad) + + +# ───────────────────────────────────────────────────────────── +# 解码:标签序列 → 三元组 +# ───────────────────────────────────────────────────────────── + +def decode(ids: torch.Tensor, preds: torch.Tensor, sentence: str) -> list[dict]: + """从 BIO 序列解出 (维度, 值) 对。 + + ★ 用 B-/I- 拼接(不是取每段首尾),因为属性名可能跨多个词: + 「等待队列长度告警阈值」是一个整体,切成「等待队列」+「告警阈值」 + 就会建错边。 + """ + seq = [LABELS[i] for i in preds.tolist()] + dims, vals = [], [] + for i, lab in enumerate(seq): + if i == 0 or i >= len(sentence): + continue + if lab == "B-DIM": + dims.append([i]) + elif lab == "I-DIM" and dims: + dims[-1].append(i) + elif lab == "B-VAL": + vals.append([i]) + elif lab == "I-VAL" and vals: + vals[-1].append(i) + dim_str = ["".join(sentence[i] for i in g) for g in dims] + val_str = ["".join(sentence[i] for i in g) for g in vals] + out = [] + for d, v in zip(dim_str, val_str): + if d and v: + out.append({"name": normalize_name(d), "value": v}) + return out + + +# ───────────────────────────────────────────────────────────── +# 训练 +# ───────────────────────────────────────────────────────────── + +def batches(data: list[Example], bs: int, shuffle: bool, pad_id: int = 0): + idx = list(range(len(data))) + if shuffle: + random.shuffle(idx) + for k in range(0, len(idx), bs): + chunk = [data[i] for i in idx[k:k + bs]] + t = max(len(c.ids) for c in chunk) + ids = torch.full((len(chunk), t), pad_id, dtype=torch.long) + lab = torch.full((len(chunk), t), L2I[""], dtype=torch.long) + mask = torch.ones((len(chunk), t), dtype=torch.bool) + for r, c in enumerate(chunk): + ids[r, :len(c.ids)] = torch.tensor(c.ids) + lab[r, :len(c.labels)] = torch.tensor(c.labels) + mask[r, :len(c.ids)] = False + yield ids, lab, mask + + +def evaluate(model: nn.Module, data: list[Example], pad_id: int = 0) -> dict: + """逐 token 的 precision/recall/f1(只算实体内部的 token)。""" + model.eval() + tp = fp = fn = 0 + with torch.no_grad(): + for ids, lab, mask in batches(data, 16, False, pad_id): + logits = model(ids, mask) + pred = logits.argmax(-1) + for b in range(ids.size(0)): + for t in range(ids.size(1)): + if mask[b, t]: + continue + gold, pr = lab[b, t].item(), pred[b, t].item() + gold_in = gold in DIM_LABELS | VAL_LABELS | SUBJ_LABELS + pr_in = pr in DIM_LABELS | VAL_LABELS | SUBJ_LABELS + if gold_in and pr_in: + if gold == pr: + tp += 1 + else: + fp += 1; fn += 1 + elif gold_in: + fn += 1 + elif pr_in: + fp += 1 + p = tp / (tp + fp) if tp + fp else 0.0 + r = tp / (tp + fn) if tp + fn else 0.0 + f1 = 2 * p * r / (p + r) if p + r else 0.0 + return {"p": p, "r": r, "f1": f1, "tp": tp, "fp": fp, "fn": fn} + + +def main(): + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--data", nargs="+", default=["/tmp/train_data.json"]) + ap.add_argument("--epochs", type=int, default=60) + ap.add_argument("--bs", type=int, default=8) + ap.add_argument("--lr", type=float, default=3e-3) + ap.add_argument("--d-model", type=int, default=128) + ap.add_argument("--layers", type=int, default=4) + ap.add_argument("--heads", type=int, default=4) + ap.add_argument("--seed", type=int, default=42) + ap.add_argument("--holdout", type=float, default=0.3) + args = ap.parse_args() + + random.seed(args.seed) + torch.manual_seed(args.seed) + torch.set_num_threads(12) + + raw = load_labeled(args.data) + # ★ 逐字段挽救(坏字段不拖累好字段):实测 149 → 55 条(+57%) + samples = salvage(raw) + print(f"原始样本 {len(raw)} → salvage 后 {len(samples)}") + vocab = build_vocab(samples) + data, reasons = build_dataset(samples, vocab) + print(f"对齐成功 {len(data)},词表 {len(vocab)}") + for k, v in reasons.most_common(): + print(f" 丢弃 {k}: {v}") + if not data: + print("没有可用样本") + return + + random.shuffle(data) + n_hold = max(1, int(len(data) * args.holdout)) + hold, train = data[:n_hold], data[n_hold:] + print(f"训练 {len(train)} 留出 {len(hold)}") + + model = CharTagger(len(vocab), args.d_model, args.layers, args.heads) + print(f"参数量 {count_params(model):,}") + opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.01) + # ★ total_steps 必须与实际迭代次数**精确一致**,否则训练跑到一半 + # 会抛 "Tried to step N times. The specified number of total steps is M"。 + # 用 ceil 而不是整除 —— 整除会少算最后一批的迭代。 + steps_per_epoch = math.ceil(len(train) / args.bs) if train else 1 + total_steps = max(1, args.epochs * steps_per_epoch) + sched = torch.optim.lr_scheduler.OneCycleLR( + opt, max_lr=args.lr, total_steps=total_steps, + pct_start=0.3, anneal_strategy="cos") + + best = 0.0 + for ep in range(args.epochs): + model.train() + tot = n = 0 + for ids, lab, mask in batches(train, args.bs, True): + logits = model(ids, mask) + loss = F.cross_entropy(logits.reshape(-1, N_LABELS), lab.reshape(-1), + ignore_index=L2I[""]) + opt.zero_grad() + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) + opt.step() + sched.step() + tot += loss.item() * ids.size(0); n += ids.size(0) + if (ep + 1) % 10 == 0 or ep == args.epochs - 1: + m = evaluate(model, hold) + print(f" ep{ep+1:3d} loss {tot/max(n,1):.4f} " + f"留出 P {m['p']:.3f} R {m['r']:.3f} F1 {m['f1']:.3f}") + if m["f1"] > best: + best = m["f1"] + torch.save({"state": model.state_dict(), "vocab": vocab, + "args": vars(args)}, "/tmp/nnet/best.pt") + print(f"最佳留出 F1 {best:.3f} → /tmp/nnet/best.pt") + + # ★ 判据:留出集 F1 必须 > 0(学到了东西) + # 词法基线是字段级 25%(见 distill 的量化实验),token 级 F1 的量级不同, + # 但「是否过拟合到全 O」是可以直接判的。 + if best <= 0.0: + print("★ 留出 F1 为 0 —— 模型只学到了 O,需要更多数据或更小模型") + else: + print("★ 留出 F1 > 0,模型学到了东西") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-migration.sh b/scripts/verify-migration.sh new file mode 100755 index 00000000..5d8dd511 --- /dev/null +++ b/scripts/verify-migration.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# 迁移后验证 —— 在副本上跑,逐项判定 +# +# ★ 验证的目标不是「迁移成功」(那由命令自己报), +# 而是「迁移后的库还能不能用」—— +# 召回、结构、场景引用、媒体挂点。 +# +# 用法:verify-migration.sh +set -uo pipefail + +DB="${1:?用法: verify-migration.sh }" +fail=0 + +say() { printf '\n──── %s ────\n' "$1"; } +check() { + local name="$1" expect="$2" actual="$3" + if [ "$actual" = "$expect" ]; then + printf ' ✓ %-34s %s\n' "$name" "$actual" + else + printf ' ✗ %-34s 期望 %s,实际 %s\n' "$name" "$expect" "$actual" + fail=1 + fi +} + +say "1. 旧表未被改动(迁移不得动源表)" +for t in entities relations sentences; do + n=$(sqlite3 "$DB" "SELECT COUNT(*) FROM $t" 2>/dev/null || echo ERR) + printf ' · %-10s %s\n' "$t" "$n" +done + +say "2. 块与边" +blocks=$(sqlite3 "$DB" "SELECT COUNT(*) FROM memory_blocks" 2>/dev/null) +withvec=$(sqlite3 "$DB" "SELECT COUNT(*) FROM memory_blocks WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'" 2>/dev/null) +edges=$(sqlite3 "$DB" "SELECT COUNT(*) FROM memory_block_edges" 2>/dev/null) +printf ' · 块 %s(带向量 %s)\n · 边 %s\n' "$blocks" "$withvec" "$edges" +[ "$blocks" -ge 1294 ] || { echo " ✗ 块数不足(旧实体 1294)"; fail=1; } +[ "$edges" -ge 959 ] || { echo " ✗ 边数不足(旧关系去重后 959)"; fail=1; } + +say "3. ★ 悬空端点(迁移最常见的失败形态)" +d1=$(sqlite3 "$DB" "SELECT COUNT(*) FROM memory_block_edges e + WHERE e.source_kind='block' AND NOT EXISTS(SELECT 1 FROM memory_blocks b WHERE b.id=e.source_id)" 2>/dev/null) +d2=$(sqlite3 "$DB" "SELECT COUNT(*) FROM memory_block_edges e + WHERE e.target_kind='block' AND NOT EXISTS(SELECT 1 FROM memory_blocks b WHERE b.id=e.target_id)" 2>/dev/null) +printf ' · 源端悬空 %s, 目标端悬空 %s\n' "$d1" "$d2" +check "悬空边" "0" "$((d1 + d2))" + +say "4. ★ 向量完整性(维度与 fingerprint 一致性)" +dims=$(sqlite3 "$DB" "SELECT DISTINCT json_array_length(vector) FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'" 2>/dev/null | tr '\n' ',' ) +fps=$(sqlite3 "$DB" "SELECT DISTINCT fingerprint FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'" 2>/dev/null | tr '\n' ',') +printf ' · 向量维度集合 %s\n · fingerprint 集合 %s\n' "$dims" "$fps" +n_dim=$(sqlite3 "$DB" "SELECT COUNT(DISTINCT json_array_length(vector)) FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'" 2>/dev/null) +n_fp=$(sqlite3 "$DB" "SELECT COUNT(DISTINCT fingerprint) FROM memory_blocks + WHERE vector IS NOT NULL AND vector != '' AND vector != 'null'" 2>/dev/null) +check "唯一向量维度数" "1" "$n_dim" +check "唯一 fingerprint 数" "1" "$n_fp" + +say "5. scene_refs 迁移(旧 kind 必须归零)" +sqlite3 "$DB" "SELECT kind, COUNT(*) FROM scene_refs GROUP BY kind ORDER BY kind" 2>/dev/null | sed 's/^/ · /' +old=$(sqlite3 "$DB" "SELECT COUNT(*) FROM scene_refs WHERE kind IN ('entity','relation')" 2>/dev/null) +check "旧 kind 残留" "0" "$old" +sb=$(sqlite3 "$DB" "SELECT COUNT(*) FROM scene_refs sr WHERE sr.kind='block' + AND NOT EXISTS(SELECT 1 FROM memory_blocks b WHERE b.id=sr.ref_text)" 2>/dev/null) +se=$(sqlite3 "$DB" "SELECT COUNT(*) FROM scene_refs sr WHERE sr.kind='edge' + AND NOT EXISTS(SELECT 1 FROM memory_block_edges e WHERE e.id=sr.ref_id)" 2>/dev/null) +check "悬空 block 引用" "0" "$sb" +check "悬空 edge 引用" "0" "$se" + +say "6. centroid(中心化向量必需)" +if ! sqlite3 "$DB" "SELECT 1 FROM graph_centroid LIMIT 1" >/dev/null 2>&1; then + echo " ✗ graph_centroid 表不存在(中心化向量必需,缺它召回质量会显著下降)" + fail=1 +else + cn=$(sqlite3 "$DB" "SELECT COUNT(*) FROM graph_centroid" 2>/dev/null) + printf ' · graph_centroid 行数 %s\n' "$cn" + # 中心为空 ⇒ 中心化退化为恒等 ⇒ 与不中心化等价 + [ "$cn" -gt 0 ] || { echo " ✗ 中心为空"; fail=1; } +fi + +say "7. 关系边属性(confidence / session 不得全空)" +noc=$(sqlite3 "$DB" "SELECT COUNT(*) FROM memory_block_edges + WHERE edge_type != 'contains' AND (confidence IS NULL OR confidence = 0)" 2>/dev/null) +tot=$(sqlite3 "$DB" "SELECT COUNT(*) FROM memory_block_edges WHERE edge_type != 'contains'" 2>/dev/null) +printf ' · 关系边 %s,其中 confidence 为 0 的 %s\n' "$tot" "$noc" + +printf '\n════════════════════════════\n' +if [ "$fail" = 0 ]; then + printf ' ✅ 结构验证全部通过\n' +else + printf ' ❌ 有验证项未通过\n' +fi +exit $fail diff --git a/site/README.md b/site/README.md index dac15069..e57382ed 100644 --- a/site/README.md +++ b/site/README.md @@ -118,7 +118,7 @@ print(im.mode) # RGB —— 没有 alpha 通道 数据来自各插件的 `plugin.json`,不是手写的: ```bash -cd /home/newqqagent/plugins +cd ${HA_DATA}/plugins for d in */; do [ -f "$d/plugin.json" ] && python3 -c " import json;d=json.load(open('$d/plugin.json')) print(d.get('name'), d.get('version'), d.get('description','')[:60])"; done @@ -132,11 +132,11 @@ print(d.get('name'), d.get('version'), d.get('description','')[:60])"; done ```bash # 在跑插件(以日志里实际 loaded 的为准,比数目录可靠) -grep -h '\[plugin\] loaded:' $(ls -t /home/newqqagent/log/homed_*.log | head -1) \ +grep -h '\[plugin\] loaded:' $(ls -t ${HA_DATA}/log/homed_*.log | head -1) \ | sed 's/.*loaded: //' | sort -u | wc -l # 工具数(同上,日志里注册后的真实值) -grep -ohE '[0-9]+ tools' $(ls -t /home/newqqagent/log/homed_*.log | head -1) | tail -1 +grep -ohE '[0-9]+ tools' $(ls -t ${HA_DATA}/log/homed_*.log | head -1) | tail -1 # Go 代码行(排除第三方与鸿蒙工程) find . -name '*.go' -not -path './.git/*' -not -path './.go/*' \ diff --git a/tmp_benchmark/main.go b/tmp_benchmark/main.go index 1615576b..f37c234f 100644 --- a/tmp_benchmark/main.go +++ b/tmp_benchmark/main.go @@ -64,7 +64,7 @@ var cases = []queryCase{ } func main() { - docs, err := loadDocs("/home/newqqagent/memory/documents") + docs, err := loadDocs("/data/homeagent/memory/documents") if err != nil { panic(err) }