diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 5ad51e2f..b61ad9e7 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -535,10 +535,28 @@ dsh plugin --profile web add "C:/path/to/modusensus-dsh-mneme-0.9.0.tgz" | `PUT` | `/memories/:id` | 局部更新 `{title?,content?,type?,importance?,tags?,reason?,agent_scope?,workspace_scope?}`;content 改写按 human_override 入档 | | `DELETE` | `/memories/:id` | 删除记忆 | | `GET` | `/search?q&mode=keyword\|vector\|hybrid\|auto&topK&occurred_from&occurred_to&agent_scope&workspace_scope` | 搜索(关键词 / 向量 / hybrid=向量领位关键词补位 / 自动)。scope 两参声明调用方身份(不传 = 不解析,行为与旧版一致;任一给出则 strictScope 硬过滤与 A2 软加权随之生效) | -| `GET` | `/context?q&topK&threshold&agent_scope&workspace_scope` | 注入候选一站式聚合(issue #370,#363 承诺):宿主注入管线 `injectCandidates` 全语义(优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置)+ 画像 + 规则,返回 `{profile, rules, items, pinnedCount}` 结构化条目,不含渲染文本。`threshold` 是 **importance 阈值**(默认 3,与 `/search` 的相似度阈值同名不同义);`topK` 默认 5(对齐宿主 `maxInjectedItems`);scope 语义与 `/search` 同口径;跨轮轮换 / 热记忆 / 时间前缀不含(调用方自管);recall_runs 注入分账与宿主注入同口径(#217 `mode='inject'`) | +| `GET` | `/context?q&topK&threshold&agent_scope&workspace_scope` | 注入候选一站式聚合(issue #370,#363 承诺):宿主注入管线 `injectCandidates` 全语义(优先级分层 / 编码门控 / heat / scope 软加权 / hybrid 语义路 / pin 前置)+ 画像 + 规则,返回 `{profile, rules, items, pinnedCount, tier}` 结构化条目,不含渲染文本。`threshold` 是 **importance 阈值**(默认 3,与 `/search` 的相似度阈值同名不同义);`topK` 默认 5(对齐宿主 `maxInjectedItems`);scope 语义与 `/search` 同口径;跨轮轮换 / 热记忆 / 时间前缀不含(调用方自管);recall_runs 注入分账与宿主注入同口径(#217 `mode='inject'`) | | `POST` | `/bootstrap` | 冷启动(v0.8.4):从仓库目录反向构建初始记忆,body `{dir}` 必填;幂等((type,title,scope) 去重 + `_overwrite` 原地刷新),零 LLM、必有产出 | | `POST` | `/maintenance/reclaim` | 无损回收(#275 / #411,与面板同口径):**只有 POST**(独立面没有 GET 预估分支)。字段只从 JSON body 读;`dryRun` 默认 `true`,只有 `dryRun:false` 且 `confirm:true` 才真删;`vacuum` 只允许显式指定 | +#### `/context` 的 `tier` 档位信号(#383) + +`GET /context` 的响应多一个 `tier` 字段,回答「这次注入跑在哪一档」。嵌入降级在调用方视角原本完全静默——`items` 看着正常,只是召回质量悄悄变差,外部集成方无从感知自己长期跑在降级档上。 + +| `tier` | 含义 | +|--------|------| +| `full` | 语义档:查询嵌入成功,且与库里的向量维度可比 | +| `degraded` | 规则档:语义路本次不可用(原因见日志) | +| `unknown` | 语义路未启用(`q` 为空),或判不出来(维度探针没跑成 / 一条向量都读不出)——那不是降级,但也不宣称 `full` | + +**两套消费者的分工:** `tier` 是**响应读数**,给调用方——三态、fail-closed(判不出来报 `unknown`,不报 `full`),用于在集成侧告警或切换策略。降级的**具体原因**是**日志读数**,给运维——走 daemon 的 stderr(`dsh-mneme: /context degraded to the rule tier ()`),不进响应体。原因枚举六值,互不吞并(成例是「修法不同就分开」):`embed-unavailable`(无可用嵌入接口:embedder 对象在,上面没有 `embedSingle` / `embed`)/ `embed-empty`(返回了空或非数组向量)/ `embed-timeout`(接口挂起,被超时护栏按缺失处理)/ `embed-error`(接口抛错)/ `embed-dimension`(接口成功了,但库里没有一条维度相符的活跃向量)/ `no-embedder`(压根没装配 embedder——daemon `--embed off`、lightMode、宿主未装配;**这是预期态,不是故障**)。六值的唯一真源是 `src/api-standalone.js` 导出的 `CONTEXT_DEGRADE_REASONS`,契约测试从它派生断言集合。 + +`embed-dimension` 值得单说:它是「拿到了向量却不可比」——`searchVector` 对维度不符的行直接跳过,不抛错、只是命中为零,于是召回静默退回关键词/规则档,而嵌入侧一切正常。修法是重建索引,不是查端点,所以它必须和前四个分开。`no-embedder` 与前四个也分开:前者(`embed-unavailable`)是适配器坏了、要修代码,后者是语义路没开、要把嵌装扮上或开起来,主体与动作都不同;并成一个值会让按 `embed-unavailable` 告警的面板对每个轻量模式安装报警。用词则与宿主面 `/api/dsh-mneme/semantic` 的 `reason` 对齐——同一个物理条件在两个面叫同一个名字(那边是另一套枚举、另一个消费者,别顺手统一)。 + +**`unknown` 之下的判档故障另有一条 warn:** `unknown` 有三个生产者(空 `q` / 维度探针没跑成 / 有行却一条向量都读不出来),响应体只有 `tier` 一个键、分不出它们,而调用方对 `unknown` 的策略常常是「不告警」——所以判不出来的情形会另打一行 `dsh-mneme: /context tier left unknown: `(探针抛错 / 没有可读的向量可比)。**它不是上面那六值的一员**(`tier` 不是 `degraded`,没有降级原因可记),运维程序不要按原因枚举解析它。 + +**预算:** 判档不是免费的。`q` 非空且装配了 embedder 时,一次 `/context` 真的会起一次查询嵌入(一次网络往返 + `embedTimeoutMs` 的超时窗口)——档位要回答的正是「这次嵌入成不成」。这里没有复用 `/health` 的数据源:standalone 的 `/health` 只回 `{ok:true}`,而 `embeddingDimensionBreakdown` 是宿主面的一次全表扫(每行 JSON.parse 一个向量),按请求跑会把语义路成本翻倍。维度探针因此走 `service.vectorDimensionState()`——**按匹配短路的存在性判据**(有没有一条活跃向量的维度等于查询维度),不是「读一行的维度」:混合维度库里 `LIMIT 1` 会抽错维(明明有可比的向量却报 `degraded`),首条恰好解析失败时又会漏报成 `full`。成本是一次无 `LIMIT` 的全 id 扫描 + 按匹配短路的解析:全库同维度(常态)时循环第一行就返回,只解析 1 个向量;只有一条都不匹配时才走完整个循环,而那正是要报 `degraded` 的情形,值得。解析复用 `getParsedEmbedding` 的缓存,缓存热不热取决于 `injectCandidates` 是否真的走了 vectorIndex 分支。 + ### curl 示例 ```bash diff --git a/dsh-mneme/docs/DAEMON.md b/dsh-mneme/docs/DAEMON.md index fe90ec67..5e2056ca 100644 --- a/dsh-mneme/docs/DAEMON.md +++ b/dsh-mneme/docs/DAEMON.md @@ -15,6 +15,8 @@ daemon 是**数据面**,不是第二个宿主: 路由面 = `src/api-standalone.js` 全表(health/status/profile/rules/memories 读写/search/context/maintenance/bootstrap),鉴权同源(Bearer + timingSafeEqual,`GET /health` 免鉴权)。daemon 自身零新路由;#370 的 `/context` 落在 api-standalone 工厂里,宿主外部访问与 daemon 自动同享(参数、scope 语义与 recall_runs 注入分账见包 README 路由表)。daemon 侧唯一自有选项是 `strictPort`(见 §4)。 +`/context` 的档位信号(#383):响应多一个 `tier`(`full` 语义档 / `degraded` 规则档 / `unknown` 语义路未启用或判不出来,fail-closed),**具体降级原因只在 stderr**——`dsh-mneme: /context degraded to the rule tier ()`,不进响应体。原因是给运维的读数,六值互不吞并(成例「修法不同就分开」):`embed-unavailable` 无可用接口(embedder 对象在、缺 `embedSingle`/`embed`,修适配器)/ `embed-empty` 空向量 / `embed-timeout` 挂起 / `embed-error` 抛错 / `embed-dimension` 维度不符(有向量但没有一条与查询同维,修法是重建索引)/ `no-embedder` 压根没装配 embedder(`--embed off` / lightMode / 宿主未装配,**预期态不是故障**,修法是把嵌装扮上或开起来)。`tier` 是给调用方的读数,两套消费者分开;六值的唯一真源是 `src/api-standalone.js` 的 `CONTEXT_DEGRADE_REASONS`,契约测试从它派生。`unknown` 之下(维度探针没跑成 / 有行却一条向量都读不出来)另有一行 `dsh-mneme: /context tier left unknown: `,**不是**那六值的一员(`tier` 不是 degraded,没有降级原因可记),运维程序不要按原因枚举解析它——没有它,调用方对 unknown 的策略是「不告警」时,一次真实的判档故障会整个消失。`no-embedder` 不并进 `embed-unavailable` 是故意的:那两者共用「语义路没通」的结果,但一个是适配器坏了(异常)、一个是语义路没开(设计内常态),并成一个值会让按 `embed-unavailable` 告警的面板对每个轻量模式安装报警;用词则与宿主面 `/api/dsh-mneme/semantic` 的 `reason` 对齐(那边是另一套枚举、另一个消费者,别顺手统一)。这里最值得盯的是 `embed-dimension`:嵌入接口一切正常,但库里是旧维度向量,`searchVector` 直接跳过、不抛错,召回静默退回关键词档——修法是重建索引,不是查端点。判档有成本:`q` 非空且装了 embedder 时一次 `/context` 会真起一次查询嵌入(一次往返 + `embedTimeoutMs` 窗口),维度探针本身是**按匹配短路的存在性判据**(有没有一条活跃向量与查询同维,不是 `LIMIT 1` 读一行——混合维度库里那会抽错维误报 degraded、首条损坏又会漏报 full):一次无 `LIMIT` 的全 id 扫描 + 短路解析,同维度健康库只解析 1 个向量,只有要报 degraded 时才走完全表;不复用 `/health`(它只回 `{ok:true}`)也不做全表扫。 + CLI: ```bash diff --git a/dsh-mneme/lib/api-standalone.js b/dsh-mneme/lib/api-standalone.js index 267f33aa..6662239a 100644 --- a/dsh-mneme/lib/api-standalone.js +++ b/dsh-mneme/lib/api-standalone.js @@ -30,6 +30,46 @@ const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024; const MAX_PAGE_LIMIT = 1000; const MAX_SEARCH_TOP_K = 200; +/** + * /context 降级原因枚举(#383,issue #508 评审后定稿)——**日志读数**的唯一真源。 + * 它不进响应体:响应只多 `tier` 一个键,具体原因走 daemon stderr 那行 warn。 + * + * 成例是「修法不同就分开」,于是六值互不吞并: + * embed-unavailable embedder 对象在,上面没有 embedSingle / embed → 修适配器 + * embed-empty 接口返回了,但向量是空数组 / 非数组 → 修接口 + * embed-timeout 接口挂起,被 embedTimeoutMs 护栏按缺失处理 → 修端点响应 + * embed-error 接口抛错(HTTP 4xx/5xx、网络、解析失败……)→ 查端点 + * embed-dimension 接口成功了,但库里没有一条维度相符的活跃向量 → 重建索引 + * no-embedder 压根没装配 embedder(daemon `--embed off` / lightMode / + * 宿主未装配)→ 把嵌入装上或开起来。这是**预期态,不是故障** + * 最后一个是 #508 Minor 的修正:此前这条路不写 diag.reason,靠日志行的兜底表达式 + * 临时拼一个值出来,结果那个值不在任何文档声明的枚举里,按枚举解析日志的运维程序 + * 会整条漏掉。现在它在决策点赋值,与其余五值并列同源。 + * + * 不并进 embed-unavailable 是有意的:那两个读数今天共用「语义路没通」的结果,但 + * 主体与动作都不同——一个是适配器坏了(异常),一个是语义路没开(设计内常态, + * src/index.js 的 lightMode 注释与 src/serve.js 的 `embedder ?? semantic?.embedder + * ?? null` 都明写它)。并过去会让按 embed-unavailable 告警的面板对每个轻量模式 + * 安装报警。用词则与宿主面 /api/dsh-mneme/semantic 的 reason 对齐(src/api.js), + * 同一个物理条件在两个面叫同一个名字,运维 grep 时不会分裂。 + * + * **两套同名枚举的分界**:src/api.js 那套是 `no-embedder / not-configured / + * initializing / null`,免鉴权状态面板消费,test/api.test.js 锁着。两套同名不同源, + * 这里只借词、不改那边;清扫文档时不要顺手统一(会打红那条测试)。 + * + * 这一个常量同时是代码写入方、两份文档(README 外部 API 段 / docs/DAEMON.md §2)与 + * 契约测试的同一来源:新增降级路径必须在这里加值并同步那两处,否则 + * test/standalone-api.test.js 的枚举契约测试会红。 + */ +export const CONTEXT_DEGRADE_REASONS = Object.freeze([ + "embed-unavailable", + "embed-empty", + "embed-timeout", + "embed-error", + "embed-dimension", + "no-embedder" +]); + /** * Listen with automatic EADDRINUSE recovery. Tries the configured port, then * the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the @@ -226,16 +266,29 @@ async function handlePutBody(res, service, logger, id, text) { * /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款 * (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。 * 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。 + * + * `diag`(optional,issue #383):拿不到向量时把原因写进去,供 daemon 侧日志枚举 + * 降级原因。枚举是上面那个 CONTEXT_DEGRADE_REASONS(六值,互不吞并),本函数只负 + * 责其中两个「没拿到向量」的值:无可用接口 → embed-unavailable,空/非数组 → + * embed-empty。其余由超时护栏、查询嵌入 .catch 与维度判据写入。不传则行为与既往 + * 逐字节一致。 */ -async function embedQueryVector(embedder, q) { +async function embedQueryVector(embedder, q, diag) { const embedSingle = typeof embedder.embedSingle === "function" ? embedder.embedSingle.bind(embedder) : typeof embedder.embed === "function" ? embedder.embed.bind(embedder) : null; - if (!embedSingle) return undefined; + if (!embedSingle) { + if (diag) diag.reason = "embed-unavailable"; + return undefined; + } const vector = await embedSingle(q); - return Array.isArray(vector) && vector.length ? vector : undefined; + if (!Array.isArray(vector) || !vector.length) { + if (diag) diag.reason = "embed-empty"; + return undefined; + } + return vector; } /** @@ -244,11 +297,17 @@ async function embedQueryVector(embedder, q) { * 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃, * 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器 * (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。 + * + * `diag`(optional,issue #383):超时写 `embed-timeout`,与上面几个原因同一套枚举 + * (CONTEXT_DEGRADE_REASONS)。 */ -function withEmbedTimeout(promise, timeoutMs) { +function withEmbedTimeout(promise, timeoutMs, diag) { let timer; const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), timeoutMs); + timer = setTimeout(() => { + if (diag) diag.reason = "embed-timeout"; + resolve(undefined); + }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } @@ -671,16 +730,123 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3); const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3; const scope = scopeFromSearchParams(url); - void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q), embedTimeoutMs) : undefined) - .catch(() => undefined) // 查询嵌入失败降级规则档(与 searchMemories 同口径) + // #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到 + // 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符 / + // 没装配 embedder)在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个 + // 键,不加第二个字段。 + const diag = {}; + // 记账的唯一决策点:q 非空但没装配 embedder。#508 Minor 的成因正是这条路不写 + // reason,靠日志行的兜底表达式临时拼一个值(no-embedder),而那个值不在文档 + // 声明的枚举里;同一表达式里还躺着第二个越界字面量 embed-failed。兜底表达式的 + // 问题不只是越界:它把「默认值」编码在两处,且 embedder 存在时那个 embed-error + // 分支一次都走不到(10 种降级场景实测:只要 embedder 为真,embedQueryVector / + // withEmbedTimeout / 下面的 .catch 三者必居其一地写好原因),却会把未来任何新增 + // 的未记账失败一律谎报成「接口抛错」,把运维引去查 HTTP 端点。现在默认值只有 + // 这一个来源,日志行不再兜底。 + if (q && !embedder) diag.reason = "no-embedder"; + void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined) + .catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径) .then((queryVector) => { const pinnedStats = {}; + // items 本身失败(injectCandidates 抛错,典型如库被关)回 500 是正当的: + // 没有可返回的内容。items 一旦算好,后面的档位判定与降级日志就只是诊断, + // 任何一步出错都不该把已经算好的 items 丢掉——所以下面每一处 logger 调用 + // 都各自包了 try/catch(logger 来自宿主,stdout 管道断裂时 warn 自身抛错是 + // 真实可发生的,而 `logger?.warn?.()` 只防 logger 缺失,防不了这个)。 const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats }); + // #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是 + // 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。 + // 三态 fail-closed——判不出来报 unknown,不报 full: + // full 语义档,查询嵌入真的成功了且与库里的向量可比 + // degraded 规则档:没装配 embedder / 嵌入超时 / 不可用向量 / 抛错 / + // 维度不符(拿到了向量但语义召回整个不可用) + // unknown 语义路未启用(空 q),或判不出来(维度探针没跑成 / 读不出 + // 任何向量)——那不是降级,但也不能宣称 full + // 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报 + // keyword,这里能分开(空 q 直接可观测)。 + // + // 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询 + // 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的 + // 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源: + // standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown + // 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会 + // 把语义路成本翻倍,且它根本不在 standalone 的装配里。维度探针因此走 + // service.vectorDimensionState()——存在性判据(有没有一条活跃向量与查询 + // 同维),成本模型见 store.js 那段注释:常态一次全 id 扫描 + 解析 1 个 + // 向量,只有要报 degraded 时才走完全表。 + let tier = "unknown"; + if (q && queryVector) { + // #508 Major:探针判据从「读一行的维度」换成存在性。LIMIT 1 拿任意一条 + // 代表全库,在混合维度库里两个方向都错——抽到与查询不符的那条会误报 + // degraded(明明有可比的向量),首条解析失败又会漏报成 full。现在问的是 + // 「有没有一条活跃向量的维度等于查询维度」,与 searchVector 的行集同源。 + // + // 探针失败(库被锁 / 半开 / 旧装配没有这个方法)判不出来:tier 保持 + // unknown,不宣称 full,也不把已经算好的 items 丢掉。sentinel 不能用 + // null / undefined——那正是「库里没有向量」的合法读数,混起来就会把一次 + // 真实的探针故障谎报成 full(fail-open)。隔离只在调用侧:store 与 service + // 透传都保持抛出口,不把异常吞成某个 state。 + let state; + // 判不出来时的留痕。unknown 现在有三个生产者(空 q / 探针没跑成 / 有行却 + // 一条都读不出来),而响应体只有 tier 一个键、分不出它们,调用方对 unknown + // 的策略又常常是「不告警」——不留一条 warn,一次真实的判档故障就从运维面上 + // 消失了。这条 warn **不是** diag.reason 枚举的一员(tier 不是 degraded, + // 没有降级原因可记),运维程序不要按降级原因枚举解析它;消息也与 + // 「degraded to the rule tier ()」分开,因为语义路这次是真的跑了, + // 只是没判成,两种故障的后续动作不同。 + let judgeFailure; + try { + state = typeof service.vectorDimensionState === "function" + ? service.vectorDimensionState(queryVector.length) + : undefined; + } catch (error) { + state = undefined; + judgeFailure = `dimension probe failed (${String(error)})`; + } + if (state?.state === "match" || state?.state === "none") { + // match:有维度相符的活跃向量,语义路通(通 ≠ 有命中,四道门仍可能让 + // 语义档贡献为 0)。none:行集本身为空——没有东西可比,不是维度不符。 + tier = "full"; + } else if (state?.state === "mismatch") { + // 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的 + // distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默 + // 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种 + // 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。 + diag.reason = "embed-dimension"; + tier = "degraded"; + } else if (state?.state === "unreadable" || state?.state === "invalid") { + // 有行却一条都读不出来(比维度不符更严重:语义召回 100% 死亡),或查询 + // 维度本身不是正整数。判不出来,留 unknown。 + judgeFailure = `no comparable vector readable (${state.state})`; + } + // undefined(探针方法缺失 / 返回垃圾 / 异步漂移)同样留 unknown。 + if (judgeFailure) { + try { + logger?.warn?.(`dsh-mneme: /context tier left unknown: ${judgeFailure}`); + } catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ } + } + } else if (q) { + tier = "degraded"; + } + if (tier === "degraded") { + // reason 由唯一决策点(q && !embedder)+ 三个写入方(embedQueryVector / + // withEmbedTimeout / 查询嵌入 .catch)+ 维度判据保证必已赋值,所以这里不再 + // 写第二份默认值——#508 之前的那份兜底表达式(`embedder ? "embed-failed" : + // "no-embedder"`)两个默认值互相矛盾、且都在文档枚举之外,其中 embed-error + // 分支在 embedder 存在时一次都走不到,却会把未来任何新增的未记账失败一律 + // 谎报成「接口抛错」。现在只把枚举内的值写进日志:越界或漏记账都露出 + // (undefined),那是看得见的缺陷信号,不是一个编出来的因。 + const reason = CONTEXT_DEGRADE_REASONS.includes(diag.reason) ? diag.reason : undefined; + try { + logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${reason})`); + } catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ } + } sendJson(res, 200, { profile: (settings?.getProfile?.() ?? "").trim(), rules: settings?.getRules?.() ?? [], items: service.toApiList(items), - pinnedCount: pinnedStats.shown ?? 0 + pinnedCount: pinnedStats.shown ?? 0, + tier }); }) .catch(() => { diff --git a/dsh-mneme/lib/service.js b/dsh-mneme/lib/service.js index 94b264a6..7ef882ea 100644 --- a/dsh-mneme/lib/service.js +++ b/dsh-mneme/lib/service.js @@ -2670,6 +2670,14 @@ export function createService({ store, mirror, config, onWrite, logger, document // passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。 embeddingModelBreakdown: () => store.embeddingModelBreakdown(), embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(), + // #383:维度探针,供 /context 判「库里有没有一条活跃向量与查询同维」。注意 + // 它**不再是**「读一行的维度」(那个判据在混合维度库里两个方向都错,见 store.js + // vectorDimensionState 的注释):换成存在性判据后成本是「一次全 id 扫描 + 按匹配 + // 短路的解析」,同维度健康库只解析 1 个向量,只有要报 degraded 时才走完全表。 + // 与上面那个全表扫的 breakdown 仍是两件事:一个是状态页的完整分布,一个是每请求 + // 用得起的判据。透传而非让 api 层摸 store,与同组其他条目一致;api 侧负责把探针 + // 异常隔离成 unknown,不在这里吞。 + vectorDimensionState: (dimension) => store.vectorDimensionState(dimension), list: (o) => store.list(o), all: () => store.all(), count: (type, opts) => store.count(type, opts), diff --git a/dsh-mneme/lib/store.js b/dsh-mneme/lib/store.js index 45aca177..02577b11 100644 --- a/dsh-mneme/lib/store.js +++ b/dsh-mneme/lib/store.js @@ -1801,6 +1801,71 @@ export function createStore(path) { }, new Map()); } + /** + * 维度探针(#383):**存在性判据**——库里有没有一条活跃向量的维度等于查询维度。 + * + * #508 评审把判据从「读一行的维度」换成了存在性,因为 LIMIT 1 拿任意一条代表全库, + * 在混合维度库里两个方向都错:抽到与查询不符的那条 → 明明有可比的 3 维向量却报 + * degraded;抽到的那条恰好解析失败 → 其余有效向量都不匹配时又漏报成 full。一行 + * 读数回答不了「这个维度在这库里能不能比」这个问题。 + * + * 行集与 searchVector **必须同源**:下面这段 WHERE 的谓词与 searchVector(本文件 + * vector search 段)逐条相同(只是书写顺序不同,语义等价),改一处必须同步改另一处 + * ——否则 tier 的核心断言「searchVector 会不会找到可比向量」会悄悄失真。活跃 = 未 + * 遗忘未归档、embedding 非空;/context 只经 searchVector 触达检索,从不传 + * includeArchived。 + * + * 状态语义(后两个是 fail-closed 兜底态,不是正常读数): + * match 有维度相符的活跃向量 → 语义路通。注意「通」≠「有命中」:type / + * importance / codingGate / scope 四道门(service.js 的 inject 段) + * 仍可能让语义档贡献为 0,这是旧探针就有的边界。 + * mismatch 有向量,但没有一条维度相符 → 语义召回实际不可用 → degraded + * none 行集本身为空(空库 / 从未建索引)→ 没有东西可比,**不是**维度 + * 不符 → full + * unreadable 有行,但一条都读不出来(embedding 列损坏)→ 与 none 是**两种空**: + * 前者是还没有内容,后者是内容在那儿却读不出来。判不出来 → unknown, + * 绝不因为「读不到」就宣称 full。 + * invalid 查询维度本身不是正整数 → 同样判不出来 → unknown。当前 /context + * 不可达(embedQueryVector 保证返回非空数组),防御性留着:别让 + * 「判不了」落到 full 上。 + * + * 成本模型(取代原来「只读一行,O(1)」的说法——那句话对现在的实现是假的): + * · SQL 是一次**无 ORDER BY 无 LIMIT 的全 id 扫描**,每次调用都有,不免费 + * (只拖 id 列,约 40 KB/千行,比 SELECT * 的全表 TEXT 便宜得多)。 + * · 按匹配短路:全库同维度(常态)时 JS 循环第一行就 return,只解析 1 个向量。 + * · 一条都不匹配时才走完整个循环,而那正是要报 degraded 的情形,值得。此时每个 + * 缓存未命中的行都要点查一次 embedding TEXT(冷缓存下 = N+1 条语句); + * getParsedEmbedding 不缓存否定结果,所以读不出来的行每次请求都会重解析一次。 + * · 缓存热不热是有条件的:/context 里让 embeddingCache 变热的只有 injectCandidates + * 内部两个 vectorIndex 分支(service.js:1696 / :1768),需要 hybridInject 与 + * selectiveInject 都没被关掉、行数在 4000 条 FIFO 上限内;standalone 装配不设 + * vectorIndex 时缓存是冷的。判档跑在 injectCandidates 之后,装配齐时缓存已热。 + * + * @param {number} dimension 查询向量维度 + * @returns {{state: "match"|"mismatch"|"none"|"unreadable"|"invalid"}} + */ + function vectorDimensionState(dimension) { + if (!Number.isInteger(dimension) || dimension <= 0) return { state: "invalid" }; + // 与 searchVector 的 WHERE 谓词逐条相同(顺序不同、语义等价)——同源行集,同步改。 + const rows = db.prepare( + `SELECT id FROM memories + WHERE embedding IS NOT NULL AND embedding != '' + AND forgotten = 0 AND archived = 0` + ).all(); + if (!rows.length) return { state: "none" }; + let sawVector = false; + for (const row of rows) { + // 走 getParsedEmbedding 的解析缓存,不自己 JSON.parse——与 searchVector / + // getEmbedding(s) 同一条读路径,缓存热时零解析。 + const v = getParsedEmbedding(row.id); + if (!v || !v.length) continue; + sawVector = true; + if (v.length === dimension) return { state: "match" }; + } + // 有行却零条可解析:不是「没有向量」,是「有向量读不出来」。 + return { state: sawVector ? "mismatch" : "unreadable" }; + } + function search(query, { limit = 20, includeArchived = false } = {}) { const q = String(query).trim(); if (!q) return []; @@ -3367,6 +3432,7 @@ export function createStore(path) { hasStaleEmbedding, embeddingModelBreakdown, embeddingDimensionBreakdown, + vectorDimensionState, searchVector, saveDreamRun, lastDreamRunAt, diff --git a/dsh-mneme/src/api-standalone.js b/dsh-mneme/src/api-standalone.js index 267f33aa..6662239a 100644 --- a/dsh-mneme/src/api-standalone.js +++ b/dsh-mneme/src/api-standalone.js @@ -30,6 +30,46 @@ const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024; const MAX_PAGE_LIMIT = 1000; const MAX_SEARCH_TOP_K = 200; +/** + * /context 降级原因枚举(#383,issue #508 评审后定稿)——**日志读数**的唯一真源。 + * 它不进响应体:响应只多 `tier` 一个键,具体原因走 daemon stderr 那行 warn。 + * + * 成例是「修法不同就分开」,于是六值互不吞并: + * embed-unavailable embedder 对象在,上面没有 embedSingle / embed → 修适配器 + * embed-empty 接口返回了,但向量是空数组 / 非数组 → 修接口 + * embed-timeout 接口挂起,被 embedTimeoutMs 护栏按缺失处理 → 修端点响应 + * embed-error 接口抛错(HTTP 4xx/5xx、网络、解析失败……)→ 查端点 + * embed-dimension 接口成功了,但库里没有一条维度相符的活跃向量 → 重建索引 + * no-embedder 压根没装配 embedder(daemon `--embed off` / lightMode / + * 宿主未装配)→ 把嵌入装上或开起来。这是**预期态,不是故障** + * 最后一个是 #508 Minor 的修正:此前这条路不写 diag.reason,靠日志行的兜底表达式 + * 临时拼一个值出来,结果那个值不在任何文档声明的枚举里,按枚举解析日志的运维程序 + * 会整条漏掉。现在它在决策点赋值,与其余五值并列同源。 + * + * 不并进 embed-unavailable 是有意的:那两个读数今天共用「语义路没通」的结果,但 + * 主体与动作都不同——一个是适配器坏了(异常),一个是语义路没开(设计内常态, + * src/index.js 的 lightMode 注释与 src/serve.js 的 `embedder ?? semantic?.embedder + * ?? null` 都明写它)。并过去会让按 embed-unavailable 告警的面板对每个轻量模式 + * 安装报警。用词则与宿主面 /api/dsh-mneme/semantic 的 reason 对齐(src/api.js), + * 同一个物理条件在两个面叫同一个名字,运维 grep 时不会分裂。 + * + * **两套同名枚举的分界**:src/api.js 那套是 `no-embedder / not-configured / + * initializing / null`,免鉴权状态面板消费,test/api.test.js 锁着。两套同名不同源, + * 这里只借词、不改那边;清扫文档时不要顺手统一(会打红那条测试)。 + * + * 这一个常量同时是代码写入方、两份文档(README 外部 API 段 / docs/DAEMON.md §2)与 + * 契约测试的同一来源:新增降级路径必须在这里加值并同步那两处,否则 + * test/standalone-api.test.js 的枚举契约测试会红。 + */ +export const CONTEXT_DEGRADE_REASONS = Object.freeze([ + "embed-unavailable", + "embed-empty", + "embed-timeout", + "embed-error", + "embed-dimension", + "no-embedder" +]); + /** * Listen with automatic EADDRINUSE recovery. Tries the configured port, then * the next MAX_PORT_ATTEMPTS-1 ports, and finally falls back to port 0 so the @@ -226,16 +266,29 @@ async function handlePutBody(res, service, logger, id, text) { * /context 的查询嵌入:best-effort,embedder 接口宽容与 searchMemories 同款 * (embedSingle 优先,兼容 embed-only 的 OpenAI 兼容客户端,issue #10)。 * 调用方负责 catch——嵌入失败降级规则档,绝不因此 500。 + * + * `diag`(optional,issue #383):拿不到向量时把原因写进去,供 daemon 侧日志枚举 + * 降级原因。枚举是上面那个 CONTEXT_DEGRADE_REASONS(六值,互不吞并),本函数只负 + * 责其中两个「没拿到向量」的值:无可用接口 → embed-unavailable,空/非数组 → + * embed-empty。其余由超时护栏、查询嵌入 .catch 与维度判据写入。不传则行为与既往 + * 逐字节一致。 */ -async function embedQueryVector(embedder, q) { +async function embedQueryVector(embedder, q, diag) { const embedSingle = typeof embedder.embedSingle === "function" ? embedder.embedSingle.bind(embedder) : typeof embedder.embed === "function" ? embedder.embed.bind(embedder) : null; - if (!embedSingle) return undefined; + if (!embedSingle) { + if (diag) diag.reason = "embed-unavailable"; + return undefined; + } const vector = await embedSingle(q); - return Array.isArray(vector) && vector.length ? vector : undefined; + if (!Array.isArray(vector) || !vector.length) { + if (diag) diag.reason = "embed-empty"; + return undefined; + } + return vector; } /** @@ -244,11 +297,17 @@ async function embedQueryVector(embedder, q) { * 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃, * 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器 * (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。 + * + * `diag`(optional,issue #383):超时写 `embed-timeout`,与上面几个原因同一套枚举 + * (CONTEXT_DEGRADE_REASONS)。 */ -function withEmbedTimeout(promise, timeoutMs) { +function withEmbedTimeout(promise, timeoutMs, diag) { let timer; const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), timeoutMs); + timer = setTimeout(() => { + if (diag) diag.reason = "embed-timeout"; + resolve(undefined); + }, timeoutMs); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } @@ -671,16 +730,123 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti const thresholdRaw = Number(url.searchParams.get("threshold") ?? 3); const threshold = Number.isFinite(thresholdRaw) ? thresholdRaw : 3; const scope = scopeFromSearchParams(url); - void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q), embedTimeoutMs) : undefined) - .catch(() => undefined) // 查询嵌入失败降级规则档(与 searchMemories 同口径) + // #383:降级原因记账用。embedQueryVector / withEmbedTimeout 只负责「拿不到 + // 向量时返回 undefined」,原因(无可用接口 / 空向量 / 超时 / 抛错 / 维度不符 / + // 没装配 embedder)在这里回收,供 daemon 侧日志枚举——响应体只多 tier 一个 + // 键,不加第二个字段。 + const diag = {}; + // 记账的唯一决策点:q 非空但没装配 embedder。#508 Minor 的成因正是这条路不写 + // reason,靠日志行的兜底表达式临时拼一个值(no-embedder),而那个值不在文档 + // 声明的枚举里;同一表达式里还躺着第二个越界字面量 embed-failed。兜底表达式的 + // 问题不只是越界:它把「默认值」编码在两处,且 embedder 存在时那个 embed-error + // 分支一次都走不到(10 种降级场景实测:只要 embedder 为真,embedQueryVector / + // withEmbedTimeout / 下面的 .catch 三者必居其一地写好原因),却会把未来任何新增 + // 的未记账失败一律谎报成「接口抛错」,把运维引去查 HTTP 端点。现在默认值只有 + // 这一个来源,日志行不再兜底。 + if (q && !embedder) diag.reason = "no-embedder"; + void Promise.resolve(q && embedder ? withEmbedTimeout(embedQueryVector(embedder, q, diag), embedTimeoutMs, diag) : undefined) + .catch((err) => { diag.reason = "embed-error"; return undefined; }) // 查询嵌入失败降级规则档(与 searchMemories 同口径) .then((queryVector) => { const pinnedStats = {}; + // items 本身失败(injectCandidates 抛错,典型如库被关)回 500 是正当的: + // 没有可返回的内容。items 一旦算好,后面的档位判定与降级日志就只是诊断, + // 任何一步出错都不该把已经算好的 items 丢掉——所以下面每一处 logger 调用 + // 都各自包了 try/catch(logger 来自宿主,stdout 管道断裂时 warn 自身抛错是 + // 真实可发生的,而 `logger?.warn?.()` 只防 logger 缺失,防不了这个)。 const items = service.injectCandidates({ query: q, maxItems, threshold, queryVector, scope, pinnedStats }); + // #383 档位信号:嵌入降级在调用方视角原本完全静默(items 看着正常,只是 + // 召回质量悄悄变差),外部集成方无从感知自己长期跑在降级档上。 + // 三态 fail-closed——判不出来报 unknown,不报 full: + // full 语义档,查询嵌入真的成功了且与库里的向量可比 + // degraded 规则档:没装配 embedder / 嵌入超时 / 不可用向量 / 抛错 / + // 维度不符(拿到了向量但语义召回整个不可用) + // unknown 语义路未启用(空 q),或判不出来(维度探针没跑成 / 读不出 + // 任何向量)——那不是降级,但也不能宣称 full + // 比 /search 的 mode 更精确:/search 把「空查询」和「向量失败」都报 + // keyword,这里能分开(空 q 直接可观测)。 + // + // 预算:判档不是免费的——q 非空且配了 embedder 时,它真的会起一次查询 + // 嵌入(一次网络往返 + embedTimeoutMs 的超时窗口),因为档位要回答的 + // 正是「这次嵌入成不成」。这里**没有**复用宿主 /health 那份现成数据源: + // standalone 的 /health 只回 {ok:true},而 embeddingDimensionBreakdown + // 是 src/api.js 里的一次全表扫(每行 JSON.parse 一个向量),按请求跑会 + // 把语义路成本翻倍,且它根本不在 standalone 的装配里。维度探针因此走 + // service.vectorDimensionState()——存在性判据(有没有一条活跃向量与查询 + // 同维),成本模型见 store.js 那段注释:常态一次全 id 扫描 + 解析 1 个 + // 向量,只有要报 degraded 时才走完全表。 + let tier = "unknown"; + if (q && queryVector) { + // #508 Major:探针判据从「读一行的维度」换成存在性。LIMIT 1 拿任意一条 + // 代表全库,在混合维度库里两个方向都错——抽到与查询不符的那条会误报 + // degraded(明明有可比的向量),首条解析失败又会漏报成 full。现在问的是 + // 「有没有一条活跃向量的维度等于查询维度」,与 searchVector 的行集同源。 + // + // 探针失败(库被锁 / 半开 / 旧装配没有这个方法)判不出来:tier 保持 + // unknown,不宣称 full,也不把已经算好的 items 丢掉。sentinel 不能用 + // null / undefined——那正是「库里没有向量」的合法读数,混起来就会把一次 + // 真实的探针故障谎报成 full(fail-open)。隔离只在调用侧:store 与 service + // 透传都保持抛出口,不把异常吞成某个 state。 + let state; + // 判不出来时的留痕。unknown 现在有三个生产者(空 q / 探针没跑成 / 有行却 + // 一条都读不出来),而响应体只有 tier 一个键、分不出它们,调用方对 unknown + // 的策略又常常是「不告警」——不留一条 warn,一次真实的判档故障就从运维面上 + // 消失了。这条 warn **不是** diag.reason 枚举的一员(tier 不是 degraded, + // 没有降级原因可记),运维程序不要按降级原因枚举解析它;消息也与 + // 「degraded to the rule tier ()」分开,因为语义路这次是真的跑了, + // 只是没判成,两种故障的后续动作不同。 + let judgeFailure; + try { + state = typeof service.vectorDimensionState === "function" + ? service.vectorDimensionState(queryVector.length) + : undefined; + } catch (error) { + state = undefined; + judgeFailure = `dimension probe failed (${String(error)})`; + } + if (state?.state === "match" || state?.state === "none") { + // match:有维度相符的活跃向量,语义路通(通 ≠ 有命中,四道门仍可能让 + // 语义档贡献为 0)。none:行集本身为空——没有东西可比,不是维度不符。 + tier = "full"; + } else if (state?.state === "mismatch") { + // 维度不符:searchVector 对不等长的行直接跳过(见 store.js 的 + // distill-tx-embed-gap 注释),不抛错、只是命中为零——于是召回静默 + // 退回 BM25/规则档,而嵌入侧一切正常。这是 issue #383 点名的那一种 + // 「悄悄退化」,必须有自己的名字:修法是重建索引,不是查端点。 + diag.reason = "embed-dimension"; + tier = "degraded"; + } else if (state?.state === "unreadable" || state?.state === "invalid") { + // 有行却一条都读不出来(比维度不符更严重:语义召回 100% 死亡),或查询 + // 维度本身不是正整数。判不出来,留 unknown。 + judgeFailure = `no comparable vector readable (${state.state})`; + } + // undefined(探针方法缺失 / 返回垃圾 / 异步漂移)同样留 unknown。 + if (judgeFailure) { + try { + logger?.warn?.(`dsh-mneme: /context tier left unknown: ${judgeFailure}`); + } catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ } + } + } else if (q) { + tier = "degraded"; + } + if (tier === "degraded") { + // reason 由唯一决策点(q && !embedder)+ 三个写入方(embedQueryVector / + // withEmbedTimeout / 查询嵌入 .catch)+ 维度判据保证必已赋值,所以这里不再 + // 写第二份默认值——#508 之前的那份兜底表达式(`embedder ? "embed-failed" : + // "no-embedder"`)两个默认值互相矛盾、且都在文档枚举之外,其中 embed-error + // 分支在 embedder 存在时一次都走不到,却会把未来任何新增的未记账失败一律 + // 谎报成「接口抛错」。现在只把枚举内的值写进日志:越界或漏记账都露出 + // (undefined),那是看得见的缺陷信号,不是一个编出来的因。 + const reason = CONTEXT_DEGRADE_REASONS.includes(diag.reason) ? diag.reason : undefined; + try { + logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${reason})`); + } catch { /* 日志通道坏了也不是把 items 丢掉的理由 */ } + } sendJson(res, 200, { profile: (settings?.getProfile?.() ?? "").trim(), rules: settings?.getRules?.() ?? [], items: service.toApiList(items), - pinnedCount: pinnedStats.shown ?? 0 + pinnedCount: pinnedStats.shown ?? 0, + tier }); }) .catch(() => { diff --git a/dsh-mneme/src/service.js b/dsh-mneme/src/service.js index 94b264a6..7ef882ea 100644 --- a/dsh-mneme/src/service.js +++ b/dsh-mneme/src/service.js @@ -2670,6 +2670,14 @@ export function createService({ store, mirror, config, onWrite, logger, document // passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。 embeddingModelBreakdown: () => store.embeddingModelBreakdown(), embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(), + // #383:维度探针,供 /context 判「库里有没有一条活跃向量与查询同维」。注意 + // 它**不再是**「读一行的维度」(那个判据在混合维度库里两个方向都错,见 store.js + // vectorDimensionState 的注释):换成存在性判据后成本是「一次全 id 扫描 + 按匹配 + // 短路的解析」,同维度健康库只解析 1 个向量,只有要报 degraded 时才走完全表。 + // 与上面那个全表扫的 breakdown 仍是两件事:一个是状态页的完整分布,一个是每请求 + // 用得起的判据。透传而非让 api 层摸 store,与同组其他条目一致;api 侧负责把探针 + // 异常隔离成 unknown,不在这里吞。 + vectorDimensionState: (dimension) => store.vectorDimensionState(dimension), list: (o) => store.list(o), all: () => store.all(), count: (type, opts) => store.count(type, opts), diff --git a/dsh-mneme/src/store.js b/dsh-mneme/src/store.js index 45aca177..02577b11 100644 --- a/dsh-mneme/src/store.js +++ b/dsh-mneme/src/store.js @@ -1801,6 +1801,71 @@ export function createStore(path) { }, new Map()); } + /** + * 维度探针(#383):**存在性判据**——库里有没有一条活跃向量的维度等于查询维度。 + * + * #508 评审把判据从「读一行的维度」换成了存在性,因为 LIMIT 1 拿任意一条代表全库, + * 在混合维度库里两个方向都错:抽到与查询不符的那条 → 明明有可比的 3 维向量却报 + * degraded;抽到的那条恰好解析失败 → 其余有效向量都不匹配时又漏报成 full。一行 + * 读数回答不了「这个维度在这库里能不能比」这个问题。 + * + * 行集与 searchVector **必须同源**:下面这段 WHERE 的谓词与 searchVector(本文件 + * vector search 段)逐条相同(只是书写顺序不同,语义等价),改一处必须同步改另一处 + * ——否则 tier 的核心断言「searchVector 会不会找到可比向量」会悄悄失真。活跃 = 未 + * 遗忘未归档、embedding 非空;/context 只经 searchVector 触达检索,从不传 + * includeArchived。 + * + * 状态语义(后两个是 fail-closed 兜底态,不是正常读数): + * match 有维度相符的活跃向量 → 语义路通。注意「通」≠「有命中」:type / + * importance / codingGate / scope 四道门(service.js 的 inject 段) + * 仍可能让语义档贡献为 0,这是旧探针就有的边界。 + * mismatch 有向量,但没有一条维度相符 → 语义召回实际不可用 → degraded + * none 行集本身为空(空库 / 从未建索引)→ 没有东西可比,**不是**维度 + * 不符 → full + * unreadable 有行,但一条都读不出来(embedding 列损坏)→ 与 none 是**两种空**: + * 前者是还没有内容,后者是内容在那儿却读不出来。判不出来 → unknown, + * 绝不因为「读不到」就宣称 full。 + * invalid 查询维度本身不是正整数 → 同样判不出来 → unknown。当前 /context + * 不可达(embedQueryVector 保证返回非空数组),防御性留着:别让 + * 「判不了」落到 full 上。 + * + * 成本模型(取代原来「只读一行,O(1)」的说法——那句话对现在的实现是假的): + * · SQL 是一次**无 ORDER BY 无 LIMIT 的全 id 扫描**,每次调用都有,不免费 + * (只拖 id 列,约 40 KB/千行,比 SELECT * 的全表 TEXT 便宜得多)。 + * · 按匹配短路:全库同维度(常态)时 JS 循环第一行就 return,只解析 1 个向量。 + * · 一条都不匹配时才走完整个循环,而那正是要报 degraded 的情形,值得。此时每个 + * 缓存未命中的行都要点查一次 embedding TEXT(冷缓存下 = N+1 条语句); + * getParsedEmbedding 不缓存否定结果,所以读不出来的行每次请求都会重解析一次。 + * · 缓存热不热是有条件的:/context 里让 embeddingCache 变热的只有 injectCandidates + * 内部两个 vectorIndex 分支(service.js:1696 / :1768),需要 hybridInject 与 + * selectiveInject 都没被关掉、行数在 4000 条 FIFO 上限内;standalone 装配不设 + * vectorIndex 时缓存是冷的。判档跑在 injectCandidates 之后,装配齐时缓存已热。 + * + * @param {number} dimension 查询向量维度 + * @returns {{state: "match"|"mismatch"|"none"|"unreadable"|"invalid"}} + */ + function vectorDimensionState(dimension) { + if (!Number.isInteger(dimension) || dimension <= 0) return { state: "invalid" }; + // 与 searchVector 的 WHERE 谓词逐条相同(顺序不同、语义等价)——同源行集,同步改。 + const rows = db.prepare( + `SELECT id FROM memories + WHERE embedding IS NOT NULL AND embedding != '' + AND forgotten = 0 AND archived = 0` + ).all(); + if (!rows.length) return { state: "none" }; + let sawVector = false; + for (const row of rows) { + // 走 getParsedEmbedding 的解析缓存,不自己 JSON.parse——与 searchVector / + // getEmbedding(s) 同一条读路径,缓存热时零解析。 + const v = getParsedEmbedding(row.id); + if (!v || !v.length) continue; + sawVector = true; + if (v.length === dimension) return { state: "match" }; + } + // 有行却零条可解析:不是「没有向量」,是「有向量读不出来」。 + return { state: sawVector ? "mismatch" : "unreadable" }; + } + function search(query, { limit = 20, includeArchived = false } = {}) { const q = String(query).trim(); if (!q) return []; @@ -3367,6 +3432,7 @@ export function createStore(path) { hasStaleEmbedding, embeddingModelBreakdown, embeddingDimensionBreakdown, + vectorDimensionState, searchVector, saveDreamRun, lastDreamRunAt, diff --git a/dsh-mneme/test/standalone-api.test.js b/dsh-mneme/test/standalone-api.test.js index 3a419d00..a9251a72 100644 --- a/dsh-mneme/test/standalone-api.test.js +++ b/dsh-mneme/test/standalone-api.test.js @@ -1,18 +1,22 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { createStore } from "../src/store.js"; import { createService } from "../src/service.js"; import { createSettings } from "../src/settings.js"; -import { createStandaloneApi } from "../src/api-standalone.js"; +import { createStandaloneApi, CONTEXT_DEGRADE_REASONS } from "../src/api-standalone.js"; import { PACKAGE_VERSION } from "../src/version-check.js"; import { Config, applyLightModePreset } from "../src/config.js"; // Real HTTP server on an OS-assigned port (port: 0), driven with fetch. -async function setup({ config = {}, embedder = null, embedTimeoutMs } = {}) { +// `logger` 可传(默认 null):#383 的降级原因只进 daemon stderr 那行 warn、不进响应体, +// 不接住 logger 就观测不到它——而「日志值 ∈ 枚举」正是 #508 Minor 唯一的可观测效应, +// 也是它当初能溜进来的原因(没有任何测试看过那行日志)。 +async function setup({ config = {}, embedder = null, embedTimeoutMs, logger = null } = {}) { const store = createStore(":memory:"); const service = createService({ store, mirror: null, config }); const settings = createSettings(store.db); - const api = createStandaloneApi({ service, store, config, settings, logger: null, port: 0, embedder, embedTimeoutMs }); + const api = createStandaloneApi({ service, store, config, settings, logger, port: 0, embedder, embedTimeoutMs }); await api.ready; const base = `http://127.0.0.1:${api.port}`; const auth = { authorization: `Bearer ${api.token}` }; @@ -588,8 +592,13 @@ test("GET /context returns the injection-shaped aggregate; empty q still serves const res = await fetch(`${base}/context`, { headers: auth }); assert.equal(res.status, 200); const body = await res.json(); - // 形状锁:四键响应——bridge 等调用方按这四个键渲染,漂移(加塞/漏带)在此变红。 - assert.deepEqual(Object.keys(body).sort(), ["items", "pinnedCount", "profile", "rules"]); + // 形状锁:五键响应——bridge 等调用方按这些键渲染,漂移(加塞/漏带)在此变红。 + // #383 新增 tier(档位信号);空 q 时语义路未启用,报 unknown 而不是 full。 + // 注意 unknown **不只**是空 q:#508 之后「维度探针没跑成」与「有行但一条向量都读 + // 不出来」也落这里(判不出来就不宣称 full)——三态不变,unknown 的生产者多了两个。 + assert.deepEqual(Object.keys(body).sort(), ["items", "pinnedCount", "profile", "rules", "tier"]); + assert.equal(typeof body.tier, "string", "tier 必填"); + assert.equal(body.tier, "unknown", "空 q = 语义路未启用,不宣称档位(fail-closed,不报 full)"); assert.equal(body.profile, "我是后端工程师"); assert.deepEqual(body.rules, ["先验证再修改"]); assert.equal(body.pinnedCount, 0, "pinnedInjectBudget 默认 0(opt-in 纪律,issue #370 取舍 3)"); @@ -749,3 +758,390 @@ test("strictPort: busy port rejects instead of hopping; default path still hops" store.close(); } }); + +// --- GET /context 的档位信号(issue #383)--------------------------------------- +// 嵌入降级在调用方视角是完全静默的:items 看起来正常,只是召回质量悄悄变差。 +// 外部集成方(Mneme Bridge 的 remote 模式)无法据此降级自己的期望或打日志。 +// 三态 fail-closed:判不出来报 unknown,不报 full。 + +/** 可用的 embedder:任何查询都回一个固定向量。 */ +const okEmbedder = { embedSingle: async () => [1, 0, 0], embed: async () => [1, 0, 0] }; + +test("GET /context tier=full when the query embedding succeeds", async () => { + const { base, auth, close } = await setup({ embedder: okEmbedder }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "full", "嵌入成功 → 语义档"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when no embedder is injected", async () => { + // 缺省(不传 embedder):daemon 与宿主都可能走到这条——embedder 未装配。 + const { base, auth, close } = await setup(); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "无 embedder → 降级到规则档,且必须说出来"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when the query embedding times out", async () => { + // embedder 挂起(不抛错)时由 withEmbedTimeout 兜底——这是最难自查的一种降级, + // 因为接口是好的、只是没有响应。 + const hanging = { embedSingle: () => new Promise(() => {}), embed: () => new Promise(() => {}) }; + const { base, auth, close } = await setup({ embedder: hanging, embedTimeoutMs: 30 }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "嵌入超时 → 降级到规则档"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when the embedder returns an unusable vector", async () => { + // 空数组 / 非数组同样让语义路走不通(embedQueryVector 的既有兜底),一并报降级。 + const empty = { embedSingle: async () => [], embed: async () => [] }; + const { base, auth, close } = await setup({ embedder: empty }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "不可用向量 → 降级到规则档"); + } finally { + close(); + } +}); + +test("GET /context tier=degraded when the query vector's dimension differs from the stored one", async () => { + // #383 点名的那一种「悄悄退化」,也是 #508 评审里问「维度不符去哪了」的那一档: + // 嵌入接口一切正常(真的返回了向量),但库里躺着旧维度的向量——searchVector 对 + // `v.length !== vector.length` 的行直接跳过,不抛错、只是命中为零,于是召回静默 + // 退回 BM25/规则档。没有这个读数时,调用方看到的是 tier=full 加一份看着正常的 + // items,而语义召回其实整条是死的。 + const { base, auth, close, store, service } = await setup({ embedder: okEmbedder }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + // 手工塞一个 4 维向量:模拟「换了嵌入模型,旧向量还没重建索引」。okEmbedder 出 + // 来的是 3 维,所以这一行的维度与查询向量不可比。 + const memory = service.all().find((m) => m.title === "核心决策"); + store.setEmbedding(memory.id, [1, 0, 0, 0]); + assert.deepEqual(store.vectorDimensionState(3), { state: "mismatch" }, "夹具前置:库里有向量,但没有一条与 3 维查询同维"); + + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "维度不符 → 语义召回实际不可用,报降级而不是 full"); + } finally { + close(); + } +}); + +test("GET /context:库里还没有向量时不算维度不符(两种空的读数必须分开)", async () => { + // 「没有东西可比」与「维度对不上」是两件事:前者是全新库(或还没建索引), + // 语义路只是还没有内容,不是坏了。探针回 none 时必须继续报 full,否则每个新装 + // 用户的第一次 /context 都是 degraded。 + const { base, auth, close, store } = await setup({ embedder: okEmbedder }); + try { + assert.deepEqual(store.vectorDimensionState(3), { state: "none" }, "夹具前置:行集为空,库里没有任何向量"); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "full", "没有已存向量 → 不是维度不符"); + } finally { + close(); + } +}); + +// --- 降级原因契约(issue #508 Minor #1)---------------------------------------- +// 缺陷本身:非空 q 但没有装配 embedder 时 diag.reason 不赋值,日志靠兜底表达式拼出 +// `no-embedder`,而文档声明的枚举里没有这个值;同一表达式里还躺着第二个越界字面量 +// `embed-failed`。按枚举解析日志的运维程序会整条漏掉这一种**已测试**的降级路径。 +// 根因是「日志契约没有测试锁」:setup() 曾把 logger 硬编码成 null,全仓没有任何一条 +// 测试观测过那行 warn 的内容。下面把三侧钉在一起——代码导出的枚举、六条降级路径的实吐 +// 值、两份文档声明的集合。 + +const DEGRADE_LOG_PREFIX = "dsh-mneme: /context degraded to the rule tier ("; + +/** 从抓到的 warn 行里抽出括号内的 reason 令牌,顺带锁前缀字面量(两份文档都写了它)。 */ +function degradedReason(logs) { + const line = logs.find((l) => l.startsWith(DEGRADE_LOG_PREFIX)); + assert.ok(line, `必须打出降级 warn(前缀 ${DEGRADE_LOG_PREFIX}),实际:${JSON.stringify(logs)}`); + const token = line.slice(DEGRADE_LOG_PREFIX.length, -1); + assert.ok(token.length > 0, "reason 令牌不许为空(兜底默认值已删,漏记账会在这里露出 (undefined))"); + return token; +} + +test("降级原因枚举:代码导出、README、docs/DAEMON 三侧同一个集合", () => { + // 枚举从常量派生,不把字符串再抄一遍——新增第七个值时必须改这一个常量 + 两份文档, + // 漏一边这里就红。 + assert.ok(Object.isFrozen(CONTEXT_DEGRADE_REASONS), "枚举必须是 frozen 常量"); + const docTokens = new Set(); + for (const file of ["README.md", "docs/DAEMON.md"]) { + const text = readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); + // 只收降级原因形状的反引号令牌:`embed-` 或 `no-embedder`。bare `embed`、 + // `--embed off` 不是枚举成员,别收进来。注意「embedder」是一个词,所以匹配必须是 + // `^(?:embed-|no-embed)`——写成 `embed-[a-z]+` 会把 no-embedder 整条漏掉,而那正是 + // #508 Minor 漏掉它的同一种手法。 + for (const m of text.matchAll(/`([a-z][a-z-]*)`/g)) { + if (/^(?:embed-|no-embed)/.test(m[1])) docTokens.add(m[1]); + } + } + assert.deepEqual( + [...docTokens].sort(), + [...CONTEXT_DEGRADE_REASONS].sort(), + "两份文档声明的降级原因必须与代码导出的枚举同集合(改一边忘另一边会红)" + ); +}); + +test("每条降级路径写进日志的 reason 都是枚举成员(#508 的日志契约)", async () => { + // 六条路径逐个抓 warn 行抽 reason。embed-dimension 那条要夹具(库里得有旧维度向量), + // 其余五条纯 embedder 形状。每条都断言令牌 ∈ CONTEXT_DEGRADE_REASONS——枚举外的值 + // 在这里变红,这正是当初漏掉 no-embedder 的那道门。 + const paths = [ + { + name: "没装配 embedder(daemon --embed off / lightMode)", + embedder: null, + reason: "no-embedder", + note: "预期态不是故障,所以它必须是枚举里一个有自己的名字,而不是并进 embed-unavailable" + }, + { + name: "embedder 对象上没有 embedSingle / embed", + embedder: {}, + reason: "embed-unavailable" + }, + { + name: "接口返回空数组", + embedder: { embedSingle: async () => [], embed: async () => [] }, + reason: "embed-empty" + }, + { + name: "接口挂起,被超时护栏按缺失处理", + embedder: { embedSingle: () => new Promise(() => {}), embed: () => new Promise(() => {}) }, + embedTimeoutMs: 30, + reason: "embed-timeout" + }, + { + name: "接口抛错", + embedder: { embedSingle: async () => { throw new Error("model offline"); } }, + reason: "embed-error" + } + ]; + for (const path of paths) { + const logs = []; + const { base, auth, close } = await setup({ + embedder: path.embedder, + embedTimeoutMs: path.embedTimeoutMs, + logger: { info: () => {}, warn: (m) => logs.push(String(m)) } + }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", `${path.name} → 降级档`); + const reason = degradedReason(logs); + assert.equal(reason, path.reason, `${path.name} → reason${path.note ? `(${path.note})` : ""}`); + assert.ok(CONTEXT_DEGRADE_REASONS.includes(reason), `${reason} 必须 ∈ 导出的枚举`); + } finally { + close(); + } + } +}); + +test("维度不符写进日志的 reason 是 embed-dimension(枚举成员)", async () => { + const logs = []; + const { base, auth, close, store, service } = await setup({ + embedder: okEmbedder, + logger: { info: () => {}, warn: (m) => logs.push(String(m)) } + }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + const memory = service.all().find((m) => m.title === "核心决策"); + store.setEmbedding(memory.id, [1, 0, 0, 0]); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded"); + assert.equal(degradedReason(logs), "embed-dimension"); + } finally { + close(); + } +}); + +// --- 维度探针(issue #508 Major + Minor #2)------------------------------------ +// Major:LIMIT 1 拿任意一条向量代表全库,混合维度库里两个方向都错。抽到与查询不符的那条 +// → 明明有可比的向量却误报 degraded;首条恰好解析失败 → 其余有效向量都不匹配时又漏报成 +// full。判据换成存在性:有没有一条活跃向量的维度等于查询维度。 +// Minor #2:探针自身抛异常(库被锁 / 半开)会掉进最终 .catch(),把已经算好的 items 丢掉 +// 并回 500——违反 fail-closed。隔离在调用侧,tier 留 unknown。 + +/** 库内插入序的 id(探针的行集顺序即插入序,无 ORDER BY 的全表扫)。 */ +function insertedIds(store) { + return store.db.prepare("SELECT id FROM memories ORDER BY rowid").all().map((r) => r.id); +} + +test("混合维度库:有一条与查询同维的活跃向量 → full,不误报 degraded(#508 Major 场景一)", async () => { + // LIMIT 1 时代这条是红的:先插入的旧维度行被抽中 → 判「维度不符」,而库里明明躺着 + // 一条可比的 3 维向量,语义召回并没有死。 + const { base, auth, close, store } = await setup({ embedder: okEmbedder }); + try { + await saveMemory(base, auth, { type: "decision", title: "旧维度行", content: "四维向量", importance: 5 }); + await saveMemory(base, auth, { type: "decision", title: "新维度行", content: "三维向量", importance: 5 }); + const [oldId, newId] = insertedIds(store); + store.setEmbedding(oldId, [1, 0, 0, 0]); // 先插入 = 旧维度 + store.setEmbedding(newId, [1, 0, 0]); // 后插入 = 与查询同维 + assert.deepEqual(store.vectorDimensionState(3), { state: "match" }, "夹具前置:库里有一条 3 维活跃向量"); + + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "full", "有可比向量 → 语义路通,不能因为抽到 4 维那条就误报降级"); + } finally { + close(); + } +}); + +test("首条向量损坏、其余有效向量都不匹配 → degraded,不误报 full(#508 Major 场景二)", async () => { + // LIMIT 1 时代这条也是红的:首条解析失败 → 探针回 null → 走「库里没有向量」分支 → full, + // 而语义召回其实整条是死的。 + const { base, auth, close, store } = await setup({ embedder: okEmbedder }); + try { + await saveMemory(base, auth, { type: "decision", title: "损坏行", content: "向量文本损坏", importance: 5 }); + await saveMemory(base, auth, { type: "decision", title: "旧维度行", content: "四维向量", importance: 5 }); + const [brokenId, oldDimId] = insertedIds(store); + // 直接改列,不走 setEmbedding(那条路会归一成 JSON.stringify(array),造不出损坏文本)。 + store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("{not json", brokenId); + store.setEmbedding(oldDimId, [1, 0, 0, 0]); + assert.deepEqual(store.vectorDimensionState(3), { state: "mismatch" }, "夹具前置:有向量、没有一条与 3 维查询同维"); + + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "degraded", "首条损坏不该把 mismatch 洗成 full"); + } finally { + close(); + } +}); + +test("有行但一条向量都读不出来 → unknown,不是 full(两种空必须分开)", async () => { + // 「还没有内容」(none → full)与「内容在那儿却读不出来」(unreadable)是两种空。 + // 后者判不出来,按 fail-closed 报 unknown,绝不因为读不到就宣称 full。 + const logs = []; + const { base, auth, close, store } = await setup({ + embedder: okEmbedder, + logger: { info: () => {}, warn: (m) => logs.push(String(m)) } + }); + try { + await saveMemory(base, auth, { type: "decision", title: "损坏行", content: "向量文本损坏", importance: 5 }); + const [brokenId] = insertedIds(store); + store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("{not json", brokenId); + assert.deepEqual(store.vectorDimensionState(3), { state: "unreadable" }, "夹具前置:行集非空但零条可解析"); + + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "unknown", "读不出任何向量 → 判不出来,不宣称 full"); + assert.ok(body.items.length > 0, "items 照常返回"); + // unknown 在响应体里只有一个键,分不出「空 q」与「判不出来」;留痕只能走日志, + // 否则调用方对 unknown 的策略是「不告警」时,这条故障就从运维面上整个消失。 + assert.ok( + logs.some((l) => l.startsWith("dsh-mneme: /context tier left unknown: no comparable vector readable (unreadable)")), + `判不出来必须留痕(不是降级原因枚举的一员),实际:${JSON.stringify(logs)}` + ); + } finally { + close(); + } +}); + +test("维度探针抛异常 → tier=unknown 且 items 不丢,绝不 500(#508 Minor #2)", async () => { + const logs = []; + const { base, auth, close, service } = await setup({ + embedder: okEmbedder, + logger: { info: () => {}, warn: (m) => logs.push(String(m)) } + }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + // 仓内惯用的故障注入(同 test/audit.test.js / test/conflict-freeze.test.js)。 + service.vectorDimensionState = () => { throw new Error("database is locked"); }; + + const res = await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth }); + assert.equal(res.status, 200, "探针异常不许把已经算好的 items 变成 500"); + const body = await res.json(); + assert.equal(body.tier, "unknown", "判不出来 → unknown,不宣称 full"); + assert.ok(body.items.length > 0, "已经算好的 items 不许被丢掉"); + assert.ok( + logs.some((l) => l.startsWith("dsh-mneme: /context tier left unknown: dimension probe failed (Error: database is locked)")), + `探针故障必须留痕,实际:${JSON.stringify(logs)}` + ); + assert.ok(!logs.some((l) => l.startsWith(DEGRADE_LOG_PREFIX)), "tier 不是 degraded,不该打降级 warn"); + } finally { + close(); + } +}); + +test("探针没跑成(方法缺失 / 返回垃圾 / 异步漂移)一律 unknown,不宣称 full", async () => { + // typeof 守卫与 sentinel 都要 fail-closed:旧实现里这三种都落到「不是 number」分支 + // 报 full。异步漂移那条是纪律锁——try/catch 只能同步包住一个同步调用,探针一旦被 + // 改成异步,抛错就重新变成 rejection 落回最终 .catch()。 + const shapes = [ + { name: "方法缺失(旧装配)", patch: (s) => { delete s.vectorDimensionState; } }, + { name: "返回字符串", patch: (s) => { s.vectorDimensionState = () => "3"; } }, + { name: "返回 undefined", patch: (s) => { s.vectorDimensionState = () => undefined; } }, + { name: "返回 Promise(异步漂移)", patch: (s) => { s.vectorDimensionState = () => Promise.resolve({ state: "match" }); } } + ]; + for (const shape of shapes) { + const { base, auth, close, service } = await setup({ embedder: okEmbedder }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + shape.patch(service); + const body = await (await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth })).json(); + assert.equal(body.tier, "unknown", `${shape.name} → 判不出来,不宣称 full`); + } finally { + close(); + } + } +}); + +test("探针失败与降级 warn 自身抛错也不许把响应变成 500(logger 来自宿主,warn 会抛)", async () => { + // `logger?.warn?.()` 只防 logger 缺失,防不了 warn 自己抛错(宿主 stdout 管道断裂时 + // 真实可发生)。items 算好之后,日志通道坏了不是丢掉它的理由。 + const logs = []; + const logger = { info: () => {}, warn: (m) => { logs.push(String(m)); throw new Error("stderr broken"); } }; + const { base, auth, close, service } = await setup({ embedder: okEmbedder, logger }); + try { + await saveMemory(base, auth, { type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 }); + service.vectorDimensionState = () => { throw new Error("boom"); }; + const res = await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth }); + assert.equal(res.status, 200, "warn 自身抛错 + 探针抛错 → 仍然 200"); + assert.equal((await res.json()).tier, "unknown"); + + // 降级路径同样:warn 抛错被自己的 try/catch 吃掉,tier 与 items 不受影响。 + service.vectorDimensionState = () => ({ state: "mismatch" }); + const degraded = await fetch(`${base}/context?q=${encodeURIComponent("数据库选型")}`, { headers: auth }); + assert.equal(degraded.status, 200, "降级 warn 抛错也不许 500"); + assert.equal((await degraded.json()).tier, "degraded"); + } finally { + close(); + } +}); + +test("维度探针状态机:五种读数各自归位(含 invalid 的 fail-closed 兜底)", async () => { + // 直接锁 store 的状态机:match / mismatch / none / unreadable / invalid。 + // invalid 当前 /context 不可达(embedQueryVector 保证返回非空数组),但它不许落到 + // full 上——「判不了」与「没有东西可比」不是同一个读数。 + const { store, close } = await setup(); + try { + assert.deepEqual(store.vectorDimensionState(3), { state: "none" }, "空库 → 行集为空"); + assert.deepEqual(store.vectorDimensionState(0), { state: "invalid" }); + assert.deepEqual(store.vectorDimensionState(-1), { state: "invalid" }); + assert.deepEqual(store.vectorDimensionState(2.5), { state: "invalid" }); + assert.deepEqual(store.vectorDimensionState("3"), { state: "invalid" }); + + const a = store.save({ type: "decision", title: "同维", content: "c", importance: 4 }); + const b = store.save({ type: "decision", title: "异维", content: "c", importance: 4 }); + store.setEmbedding(a.id, [1, 0, 0]); + store.setEmbedding(b.id, [1, 0, 0, 0]); + assert.deepEqual(store.vectorDimensionState(3), { state: "match" }, "有一条同维 → match(短路,不看完)"); + assert.deepEqual(store.vectorDimensionState(5), { state: "mismatch" }, "有向量但没有一条同维 → mismatch"); + + // 清掉解析缓存再写坏列(getParsedEmbedding 只缓存成功解析,缓存在的话直接改列看不见)。 + store.setEmbedding(a.id, null); + store.setEmbedding(b.id, null); + store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("{oops", a.id); + store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("[]", b.id); + assert.deepEqual(store.vectorDimensionState(3), { state: "unreadable" }, "有行但零条可解析 → unreadable(不是 none)"); + } finally { + close(); + } +});