diff --git a/dsh-mneme/lib/api-standalone.js b/dsh-mneme/lib/api-standalone.js index 267f33a..7da1c6c 100644 --- a/dsh-mneme/lib/api-standalone.js +++ b/dsh-mneme/lib/api-standalone.js @@ -226,16 +226,27 @@ 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):拿不到向量时把原因写进去(`embed-unavailable` + * 无可用接口 / `embed-empty` 空或非数组向量),供 daemon 侧日志枚举降级原因。 + * 不传则行为与既往逐字节一致。 */ -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 +255,16 @@ async function embedQueryVector(embedder, q) { * 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃, * 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器 * (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。 + * + * `diag`(optional,issue #383):超时写 `embed-timeout`,与上面两个原因同一套枚举。 */ -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 +687,35 @@ 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」,原因(无可用接口 / 空向量 / 超时)在这里回收, + // 供 daemon 侧日志枚举——响应体只多 tier 一个键,不加第二个字段。 + const diag = {}; + 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 = {}; 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 直接可观测)。 + let tier = "unknown"; + if (q && queryVector) tier = "full"; + else if (q) { + tier = "degraded"; + logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${diag.reason ?? (embedder ? "embed-failed" : "no-embedder")})`); + } 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/api-standalone.js b/dsh-mneme/src/api-standalone.js index 267f33a..7da1c6c 100644 --- a/dsh-mneme/src/api-standalone.js +++ b/dsh-mneme/src/api-standalone.js @@ -226,16 +226,27 @@ 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):拿不到向量时把原因写进去(`embed-unavailable` + * 无可用接口 / `embed-empty` 空或非数组向量),供 daemon 侧日志枚举降级原因。 + * 不传则行为与既往逐字节一致。 */ -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 +255,16 @@ async function embedQueryVector(embedder, q) { * 按嵌入缺失处理,降级规则档。输掉的 embed promise 无法取消、自行结束后被丢弃, * 无害;挂起的主要成本是一个泄漏的挂起任务,可接受。嵌入先完成时清掉定时器 * (CodeRabbit 同轮 Minor:别让每个请求都留一枚要等满超时的计时器)。 + * + * `diag`(optional,issue #383):超时写 `embed-timeout`,与上面两个原因同一套枚举。 */ -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 +687,35 @@ 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」,原因(无可用接口 / 空向量 / 超时)在这里回收, + // 供 daemon 侧日志枚举——响应体只多 tier 一个键,不加第二个字段。 + const diag = {}; + 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 = {}; 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 直接可观测)。 + let tier = "unknown"; + if (q && queryVector) tier = "full"; + else if (q) { + tier = "degraded"; + logger?.warn?.(`dsh-mneme: /context degraded to the rule tier (${diag.reason ?? (embedder ? "embed-failed" : "no-embedder")})`); + } 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/test/standalone-api.test.js b/dsh-mneme/test/standalone-api.test.js index 3a419d0..3061adb 100644 --- a/dsh-mneme/test/standalone-api.test.js +++ b/dsh-mneme/test/standalone-api.test.js @@ -588,8 +588,11 @@ 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。 + 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 +752,61 @@ 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(); + } +});