Merge dev authority protocol fixes

This commit is contained in:
youzini
2026-06-09 11:39:51 +00:00
10 changed files with 327 additions and 19 deletions

View File

@@ -51,6 +51,10 @@ HTTP 错误400/401/403/429/502 等)会带状态码和响应体抛出,而
> BME 在 payload 里发送 `vectorSpaceId` 和 `observedDim`(顶层 + 每项元数据。DOA 按批校验 vectorSpaceId/observedDim 一致性,拒绝混合维度,返回带类型的校验错误。失败/404/旧 DOA 时回退到旧 Authority Trivium 路径或本地。
协议身份字段有严格分工BME 节点 id 只作为 `externalId` / `nodeId` / `payload.nodeId` 发送;`id` 保留给 Trivium 内部 numeric idBME 不会把字符串 node id 塞进顶层 `id`。边关系使用 Trivium public reference 形状:`src/dst = { externalId, namespace }`,由 Authority 解析到内部 id。
搜索请求也必须携带 `namespace` / `collectionId` / `chatId`。如果 Authority 返回带 namespace 的命中BME 会保守过滤掉不属于当前 namespace 的结果;老后端不返回 namespace 时结果仍保留,以避免过度破坏兼容性。
## 连接测试
`testVectorConnection()` 测的是**真实批量 embedding 路径**(走 `embedBatch`),而不是单条短文本——因为"测试通过但实际 embedding 失败"的根因就是测试只测了单条短文本而运行时用的是批量长文本。

View File

@@ -65,6 +65,12 @@ Authority 的后台 job 系统只支持特定 job 类型。ST-BME 不能盲目
> 该端点按批校验 `vectorSpaceId` / `observedDim` 一致性,拒绝混合维度,返回带类型的校验错误。
BME → Authority 的向量协议约定:
- 节点身份使用 `externalId` / `nodeId`;顶层 `id` 是 Trivium 内部 numeric idBME 不发送字符串 node id 到该字段。
- link 使用 `{ src: { externalId, namespace }, dst: { externalId, namespace }, label, weight }`,由 Authority 在服务端解析成 Trivium 内部 id。
- search 请求携带 `namespace` / `collectionId` / `chatId`;返回结果若带 namespaceBME 会过滤掉非当前 namespace 的命中,避免多聊天/多集合污染。
向量空间身份和维度门禁的算法见 [`../algorithms/vector-and-embedding.md`](../algorithms/vector-and-embedding.md)。
## Authority SQL 图谱存储选择

View File

@@ -1145,7 +1145,11 @@ function getChatVariables() {
}
function buildEjsContext() {
const vars = getChatVariables();
return createEnaPlannerEjsContext(getChatVariables());
}
export function createEnaPlannerEjsContext(varsInput = {}) {
const vars = varsInput && typeof varsInput === 'object' ? { ...varsInput } : {};
// getvar: read a chat variable (supports dot-path for nested objects)
function getvar(name) {
@@ -1195,7 +1199,7 @@ function shouldSkipSyncEjsPreRender(template) {
return false;
}
function renderEjsTemplate(template, ctx, templateLabel = '') {
export function renderEjsTemplate(template, ctx, templateLabel = '') {
const labelSuffix = templateLabel ? ` (${templateLabel})` : '';
if (shouldSkipSyncEjsPreRender(template)) {
@@ -1205,7 +1209,15 @@ function renderEjsTemplate(template, ctx, templateLabel = '') {
// Try window.ejs first (ST loads this library)
if (window.ejs?.render) {
try {
return window.ejs.render(template, ctx, { async: false });
const renderCtx = ctx && typeof ctx === 'object' ? { ...ctx } : ctx;
if (renderCtx && typeof renderCtx === 'object') {
delete renderCtx.__append;
delete renderCtx.print;
}
return window.ejs.render(template, renderCtx, {
async: false,
outputFunctionName: 'print',
});
} catch (e) {
console.warn(`[EnaPlanner] EJS render failed${labelSuffix}, template returned as-is:`, e?.message);
return template;
@@ -2017,4 +2029,3 @@ export function cleanupEnaPlanner() {
delete window.stBmeEnaPlanner;
_bmeRuntime = null;
}

View File

@@ -34,25 +34,28 @@ function isTavernHelperPromptViewerSyntheticGeneration(runtime) {
}
export function registerBeforeCombinePromptsController(runtime, listener) {
const makeFirst = runtime.getEventMakeFirst();
const eventName = runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS;
const eventSourceMakeFirst = runtime.eventSource?.makeFirst;
const makeFirst =
typeof eventSourceMakeFirst === "function"
? eventSourceMakeFirst.bind(runtime.eventSource)
: runtime.getEventMakeFirst?.();
if (typeof makeFirst === "function") {
return makeFirst(
runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS,
listener,
);
return makeFirst(eventName, listener);
}
runtime.console.warn("[ST-BME] eventMakeFirst 不可用,回退到普通事件注册");
runtime.eventSource.on(
runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS,
listener,
);
runtime.eventSource.on(eventName, listener);
return null;
}
export function registerGenerationAfterCommandsController(runtime, listener) {
const makeFirst = runtime.getEventMakeFirst();
const eventName = runtime.eventTypes.GENERATION_AFTER_COMMANDS;
const eventSourceMakeFirst = runtime.eventSource?.makeFirst;
const makeFirst =
typeof eventSourceMakeFirst === "function"
? eventSourceMakeFirst.bind(runtime.eventSource)
: runtime.getEventMakeFirst?.();
if (typeof makeFirst === "function") {
const cleanup = makeFirst(eventName, listener);
return cleanup;

View File

@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
"version": "7.5.1",
"version": "7.5.4",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}

View File

@@ -33,6 +33,7 @@ const {
isAuthorityVectorConfig,
normalizeAuthorityVectorConfig,
queryAuthorityTriviumNeighbors,
searchAuthorityTriviumNodes,
applyAuthorityBmeVectorManifest,
} = await import("../vector/authority-vector-primary-adapter.js");
const {
@@ -125,6 +126,7 @@ function createMockTriviumClient({
}
return {
results: [
{ nodeId: "node-a", namespace: "other-chat", score: 0.95 },
{ nodeId: "node-b", score: 0.91 },
{ nodeId: "node-outside", score: 0.88 },
],
@@ -169,6 +171,10 @@ function createMockTriviumClient({
path: "/bme/vector-apply",
});
}
const itemWithTopLevelId = payload.items?.find((item) => item?.id !== undefined);
if (itemWithTopLevelId) {
throw new Error("bmeVectorApply items must not send top-level Trivium id");
}
return {
ok: true,
database: payload.database || "st_bme_vectors",
@@ -261,11 +267,26 @@ assert.equal(isAuthorityVectorConfig(config), true);
const applyCall = triviumClient.calls.find(([name]) => name === "bmeVectorApply")?.[1];
assert.equal(applyCall.items.length, 2);
assert.equal(applyCall.links.length, 1);
assert.deepEqual(applyCall.links[0].src, {
externalId: "node-a",
namespace: "st-bme::chat-authority-vector",
});
assert.deepEqual(applyCall.links[0].dst, {
externalId: "node-b",
namespace: "st-bme::chat-authority-vector",
});
assert.equal(applyCall.links[0].label, "related");
assert.equal(applyCall.links[0].weight, 0.75);
assert.equal(applyCall.observedDim, 2);
assert.equal(String(applyCall.vectorSpaceId || "").startsWith("vs_"), true);
assert.equal(applyCall.items.every((item) => item.payload?.vectorSpaceId === applyCall.vectorSpaceId), true);
assert.equal(applyCall.items.every((item) => item.payload?.observedDim === 2), true);
assert.equal(applyCall.items.every((item) => Array.isArray(item.vector) && item.vector.length > 0), true);
assert.equal(applyCall.items[0].id, undefined);
assert.equal(applyCall.items[0].externalId, "node-a");
assert.equal(applyCall.items[0].nodeId, "node-a");
assert.equal(applyCall.items[0].payload.nodeId, "node-a");
assert.equal(applyCall.items[0].payload.externalId, "node-a");
assert.equal(result.timings.authorityDiagnostics.upsert.operation, "bmeVectorApply");
}
@@ -369,6 +390,7 @@ assert.equal(isAuthorityVectorConfig(config), true);
assert.deepEqual(results, [{ nodeId: "node-b", score: 0.91 }]);
const searchCall = triviumClient.calls.find(([name]) => name === "search");
assert.deepEqual(searchCall?.[1]?.candidateIds.sort(), ["node-a", "node-b"]);
assert.equal(searchCall?.[1]?.namespace, "st-bme::chat-authority-vector");
assert.equal(Array.isArray(searchCall?.[1]?.queryVector), true);
assert.ok(searchCall?.[1]?.queryVector.length > 0);
assert.equal(graph.vectorIndexState.lastSearchTimings.mode, "authority");
@@ -436,6 +458,50 @@ assert.equal(isAuthorityVectorConfig(config), true);
}
}
{
const { graph } = createAuthorityVectorGraph();
const fetchCalls = [];
const fetchImpl = async (url, options = {}) => {
const body = JSON.parse(String(options.body || "{}"));
fetchCalls.push({ url: String(url), body });
if (String(url).endsWith("/session/init")) {
return {
ok: true,
status: 200,
json: async () => ({ sessionToken: "test-session" }),
};
}
return {
ok: true,
status: 200,
json: async () => ({
results: [
{ externalId: "node-a", namespace: "other-chat", score: 0.99 },
{ externalId: "node-b", namespace: "st-bme::chat-authority-vector", score: 0.93 },
{ externalId: "node-c", score: 0.72 },
],
}),
};
};
const results = await searchAuthorityTriviumNodes(graph, "archive door", config, {
namespace: "st-bme::chat-authority-vector",
collectionId: "st-bme::chat-authority-vector",
chatId: "chat-authority-vector",
queryVector: [1, 0, 0],
topK: 5,
fetchImpl,
});
const searchCall = fetchCalls.find((call) => call.url.endsWith("/trivium/search-hybrid"));
assert.equal(searchCall?.body?.namespace, "st-bme::chat-authority-vector");
assert.equal(searchCall?.body?.collectionId, "st-bme::chat-authority-vector");
assert.equal(searchCall?.body?.chatId, "chat-authority-vector");
assert.deepEqual(
results.map((entry) => entry.nodeId),
["node-b", "node-c"],
);
}
{
const { graph, first, second } = createAuthorityVectorGraph();
const triviumClient = createMockTriviumClient({ failSearch: true });

View File

@@ -158,6 +158,7 @@ await runAuthorityE2eStep("trivium", async () => {
headerProvider,
});
assert.equal(linkResult.linked, graph.edges.length);
assert.ok(linkResult.linked >= 1, "Authority Trivium link sync should create at least one edge");
const searchResults = await searchAuthorityTriviumNodes(graph, "Authority E2E Alpha", config, {
namespace,
@@ -169,6 +170,10 @@ await runAuthorityE2eStep("trivium", async () => {
headerProvider,
});
assert.ok(Array.isArray(searchResults));
assert.ok(
searchResults.every((result) => typeof result?.nodeId === "string" && result.nodeId.trim()),
"Authority Trivium search results should expose external node ids",
);
const filteredIds = await filterAuthorityTriviumNodes(config, {
namespace,
@@ -191,6 +196,10 @@ await runAuthorityE2eStep("trivium", async () => {
headerProvider,
});
assert.ok(Array.isArray(neighborIds));
assert.ok(
neighborIds.includes(graph.nodes[1].id),
"Authority Trivium neighbors should resolve links through external ids",
);
return {
upserted: upsertResult.upserted,

70
tests/ena-planner-ejs.mjs Normal file
View File

@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import {
installResolveHooks,
toDataModuleUrl,
} from "./helpers/register-hooks-compat.mjs";
const extensionsShimSource = [
"export const extension_settings = {};",
].join("\n");
const scriptShimSource = [
"export function getRequestHeaders() { return {}; }",
"export function saveSettingsDebounced() {}",
"export function substituteParamsExtended(text = '') { return String(text ?? ''); }",
].join("\n");
installResolveHooks([
{
specifiers: ["../../../../extensions.js"],
url: toDataModuleUrl(extensionsShimSource),
},
{
specifiers: ["../../../../../script.js"],
url: toDataModuleUrl(scriptShimSource),
},
]);
const require = createRequire(import.meta.url);
const ejs = require("../vendor/ejs.js");
const originalWindow = globalThis.window;
const originalWarn = console.warn;
const warnings = [];
try {
globalThis.window = { ...(originalWindow || {}), ejs };
console.warn = (...args) => warnings.push(args);
const { createEnaPlannerEjsContext, renderEjsTemplate } = await import(
"../ena-planner/ena-planner.js"
);
const ctx = createEnaPlannerEjsContext({ x: "alpha" });
assert.equal(renderEjsTemplate("<%= getvar('x') %>", ctx), "alpha");
assert.equal(renderEjsTemplate("<% print(getvar('x')) %>", ctx), "alpha");
const pollutedCtx = {
...createEnaPlannerEjsContext({ x: "safe" }),
__append() {
throw new Error("locals __append should not shadow EJS output");
},
print() {
throw new Error("locals print should not shadow EJS output function");
},
};
assert.equal(renderEjsTemplate("<%= getvar('x') %>", pollutedCtx), "safe");
assert.equal(renderEjsTemplate("<% print(getvar('x')) %>", pollutedCtx), "safe");
const invalidTemplate = "before <% if ( %> after";
assert.equal(renderEjsTemplate(invalidTemplate, ctx, "invalid"), invalidTemplate);
assert.ok(warnings.some((args) => String(args[0]).includes("EJS render failed")));
} finally {
console.warn = originalWarn;
if (originalWindow === undefined) {
delete globalThis.window;
} else {
globalThis.window = originalWindow;
}
}
console.log("ena-planner-ejs tests passed");

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import {
registerBeforeCombinePromptsController,
registerGenerationAfterCommandsController,
} from "../host/event-binding.js";
function createRuntime(eventSource, overrides = {}) {
return {
console: { warn() {} },
eventSource,
eventTypes: {
GENERATE_BEFORE_COMBINE_PROMPTS: "before-combine",
GENERATION_AFTER_COMMANDS: "after-commands",
},
getEventMakeFirst: () => undefined,
...overrides,
};
}
function testEventSourceMakeFirstWinsAndIsBound() {
const calls = [];
const fallbackCalls = [];
const eventSource = {
marker: "event-source",
makeFirst(eventName, listener) {
assert.equal(this, eventSource);
calls.push({ eventName, listener });
return () => calls.push({ cleanup: eventName });
},
on() {
throw new Error("ordinary .on should not be used when makeFirst exists");
},
};
const runtime = createRuntime(eventSource, {
getEventMakeFirst: () => (...args) => fallbackCalls.push(args),
});
const beforeListener = () => {};
const afterListener = () => {};
const beforeCleanup = registerBeforeCombinePromptsController(
runtime,
beforeListener,
);
const afterCleanup = registerGenerationAfterCommandsController(
runtime,
afterListener,
);
assert.equal(typeof beforeCleanup, "function");
assert.equal(typeof afterCleanup, "function");
assert.deepEqual(calls, [
{ eventName: "before-combine", listener: beforeListener },
{ eventName: "after-commands", listener: afterListener },
]);
assert.deepEqual(fallbackCalls, []);
}
function testRuntimeMakeFirstFallback() {
const calls = [];
const eventSource = {
on() {
throw new Error("ordinary .on should not be used when fallback exists");
},
};
const runtime = createRuntime(eventSource, {
getEventMakeFirst: () => (eventName, listener) => {
calls.push({ eventName, listener });
return `cleanup:${eventName}`;
},
});
const beforeListener = () => {};
const afterListener = () => {};
assert.equal(
registerBeforeCombinePromptsController(runtime, beforeListener),
"cleanup:before-combine",
);
assert.equal(
registerGenerationAfterCommandsController(runtime, afterListener),
"cleanup:after-commands",
);
assert.deepEqual(calls, [
{ eventName: "before-combine", listener: beforeListener },
{ eventName: "after-commands", listener: afterListener },
]);
}
function testOrdinaryOnFallback() {
const calls = [];
const eventSource = {
on(eventName, listener) {
calls.push({ eventName, listener });
},
};
const runtime = createRuntime(eventSource);
const beforeListener = () => {};
const afterListener = () => {};
assert.equal(
registerBeforeCombinePromptsController(runtime, beforeListener),
null,
);
assert.equal(
registerGenerationAfterCommandsController(runtime, afterListener),
null,
);
assert.deepEqual(calls, [
{ eventName: "before-combine", listener: beforeListener },
{ eventName: "after-commands", listener: afterListener },
]);
}
testEventSourceMakeFirstWinsAndIsBound();
testRuntimeMakeFirstFallback();
testOrdinaryOnFallback();
console.log("event-binding-priority tests passed");

View File

@@ -140,6 +140,15 @@ function normalizeNodeResultId(item = null) {
);
}
function normalizeSearchResultNamespace(item = null) {
return normalizeRecordId(
item?.namespace ||
item?.collectionId ||
readNestedValue(item, ["payload", "namespace"]) ||
readNestedValue(item, ["payload", "collectionId"]),
);
}
function readResultRows(payload = null) {
if (Array.isArray(payload)) return payload;
if (!payload || typeof payload !== "object") return [];
@@ -211,12 +220,17 @@ function getNodeFieldText(node = {}, keys = []) {
return "";
}
function normalizeSearchResults(payload = null) {
function normalizeSearchResults(payload = null, { namespace = "" } = {}) {
const rows = readResultRows(payload);
const expectedNamespace = normalizeRecordId(namespace);
return rows
.map((item, index) => {
const nodeId = normalizeNodeResultId(item);
if (!nodeId) return null;
const resultNamespace = normalizeSearchResultNamespace(item);
if (expectedNamespace && resultNamespace && resultNamespace !== expectedNamespace) {
return null;
}
const rawScore = Number(item?.score ?? item?.similarity ?? item?.rankScore);
const distance = Number(item?.distance);
const score = Number.isFinite(rawScore)
@@ -224,7 +238,11 @@ function normalizeSearchResults(payload = null) {
: Number.isFinite(distance)
? 1 / (1 + Math.max(0, distance))
: Math.max(0.01, 1 - index / Math.max(1, rows.length));
return { nodeId, score };
return {
nodeId,
score,
...(resultNamespace ? { namespace: resultNamespace } : {}),
};
})
.filter(Boolean);
}
@@ -261,6 +279,7 @@ function buildAuthorityNodePayload(node = {}, entry = {}, { chatId = "", modelSc
return {
chatId,
nodeId: normalizeRecordId(node?.id || entry?.nodeId),
externalId: normalizeRecordId(node?.id || entry?.nodeId),
type: String(node?.type || ""),
archived: Boolean(node?.archived),
seqStart: Number(seqRange[0] ?? node?.seq ?? 0) || 0,
@@ -295,7 +314,6 @@ function buildAuthorityVectorItems(graph, entries = [], options = {}) {
if (!node) return null;
const payload = buildAuthorityNodePayload(node, entry, options);
return {
id: nodeId,
externalId: nodeId,
nodeId,
text: String(entry?.text || ""),
@@ -597,8 +615,12 @@ export class AuthorityTriviumHttpClient {
throw new Error("Authority Trivium v0.6 search requires vector");
}
const queryText = String(payload.queryText || payload.text || payload.searchText || payload.query || "");
const namespace = getNamespace(payload);
const body = {
...this.buildOpenOptions(payload),
...(namespace ? { namespace } : {}),
...(payload.collectionId ? { collectionId: String(payload.collectionId) } : {}),
...(payload.chatId ? { chatId: String(payload.chatId) } : {}),
vector,
topK: Number(payload.topK || payload.limit || 0) || undefined,
expandDepth: Number(payload.expandDepth || payload.depth || 0) || undefined,
@@ -1045,7 +1067,7 @@ export async function searchAuthorityTriviumNodes(graph, text, config = {}, opti
topK: Math.max(1, Math.floor(Number(options.topK) || 1)),
candidateIds: toArray(options.candidateIds).map(normalizeRecordId).filter(Boolean),
});
return normalizeSearchResults(payload);
return normalizeSearchResults(payload, { namespace: options.namespace });
}
export async function testAuthorityTriviumConnection(config = {}, options = {}) {