Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5388f40c03 | |||
| 83aa1f6bb2 | |||
| 9344562258 |
@@ -415,6 +415,18 @@ if (!window.ARCRUN_API_BASE) {
|
||||
文件整理成知識卡的部分,請在<b style="color:var(--ink)">同步小幫手</b>(電腦上的托盤圖示)的「AI 設定…」填一把 Gemini API Key。
|
||||
</div>
|
||||
</div>
|
||||
<!-- 檢修孔(2026-08-07 leo 直接指令):「一顆按鈕在設定裡,按鈕下載一個檔案,
|
||||
把檔案發給我,你看那個檔」。用戶只做兩件事:按一下、把檔案傳出去——不需要
|
||||
理解裡面是什麼。檔案內容只有統計/狀態數字(GET /portal/data/diagnostics),
|
||||
不含任何知識卡內容。 -->
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600">疑難排解</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">搜尋或同步有問題時,可以匯出一份狀態檔給我們,幫你更快找到問題(只有統計數字,不含你的任何文件內容)。</div>
|
||||
<div style="margin-top:12px">
|
||||
<button class="btn3" id="st-diag-export" style="padding:11px 18px;border-radius:10px">匯出診斷檔給我們看</button>
|
||||
<div id="st-diag-status" style="margin-top:8px;font-size:13px;min-height:1.2em;color:rgba(var(--ink-rgb),.5)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -924,6 +936,39 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
|
||||
dropSession();
|
||||
});
|
||||
|
||||
// ── 檢修孔(2026-08-07 leo 直接指令)──────────────────────────────────────
|
||||
// 「按鈕下載一個檔案,把檔案發給我」:按下去打 /portal/data/diagnostics,
|
||||
// 存成單一 JSON 檔(不是要解壓的一包)直接觸發瀏覽器下載。不彈視窗、不要求
|
||||
// 用戶理解內容——他只需要按一下、把跳出來的檔案傳給我們。
|
||||
(function () {
|
||||
var btn = $('st-diag-export');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var st = $('st-diag-status');
|
||||
btn.disabled = true;
|
||||
st.textContent = '匯出中…';
|
||||
fetch(API_BASE + '/portal/data/diagnostics', { headers: authHeaders() })
|
||||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
btn.disabled = false;
|
||||
if (guard401(x.status)) return;
|
||||
if (!x.ok) { st.textContent = '匯出失敗,請稍後再試'; return; }
|
||||
var blob = new Blob([JSON.stringify(x.d, null, 2)], { type: 'application/json' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
var stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
a.href = url;
|
||||
a.download = 'arcrun-diagnostics-' + stamp + '.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
|
||||
st.textContent = '已下載——把這個檔案傳給我們就可以了';
|
||||
})
|
||||
.catch(function (e) { btn.disabled = false; st.textContent = friendlyErr(e); });
|
||||
});
|
||||
})();
|
||||
|
||||
// t87 07-28 leo:知識庫網址 helper(只有 origin,不含 /portal/# 後綴),兩處 UI 共用
|
||||
function copyOriginUrl(btn) {
|
||||
var url = location.origin;
|
||||
|
||||
@@ -584,3 +584,92 @@ portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||||
//
|
||||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||||
//
|
||||
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。leo 把檔轉給
|
||||
// 我方時,我方要能只靠這個檔判斷病因,不必再回頭問封測者任何問題。
|
||||
//
|
||||
// 🔴 兩條紅線(規格原文):
|
||||
// ① 不准把內部概念暴露給用戶——本端點只回統計/狀態,前端按鈕文案不提 KBDB/Vectorize/
|
||||
// owner_id 這類詞。
|
||||
// ② 不准洩漏知識卡內容本體——以下每一個欄位都只挑「數字」或「布林」,即使背後的 KBDB
|
||||
// 端點回應含 content(如 /map 的 top_entities、triplet 的 subject/object 名稱),
|
||||
// 本端點一律只讀出用得到的數字後就丟掉那個回應,不把原始內容往前端送。
|
||||
//
|
||||
// 涵蓋「這次一定要涵蓋」的向量/embedding 健康狀態:embed 模組是否開(index 存在的前提)、
|
||||
// 已嵌入/待嵌入卡片數、以及 embedSelfTest(KBDB #12)—— 這是唯一能分辨「從沒嵌過」與
|
||||
// 「嵌了但 index 查不到自己」兩種故障模式的方法(Arcrun#11 的真實案例正是後者,光看
|
||||
// 計數看不出來)。
|
||||
//
|
||||
// 認證:與其餘 /portal/data/* 同一道 requirePortalUser session 閘(不開放無登入存取——
|
||||
// 統計數字仍是這個實例的營運資訊,不對外公開)。
|
||||
portalDataRouter.get('/portal/data/diagnostics', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const tenant = portalTenant(c.env);
|
||||
const notes: string[] = [];
|
||||
|
||||
// ① embed 模組健康狀態(backfillStatus + selfTest,兩支都活在 KBDB 那面牆內)。
|
||||
let embedding: Record<string, unknown> = { checked: false };
|
||||
try {
|
||||
const [statusRes, selftestRes] = await Promise.all([
|
||||
kbdbFetch(c.env, `/embed/backfill/status?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
||||
kbdbFetch(c.env, `/embed/selftest?${new URLSearchParams({ owner_id: tenant }).toString()}`),
|
||||
]);
|
||||
const statusBody = (await statusRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
|
||||
| null;
|
||||
const selftestBody = (await selftestRes.json().catch(() => null)) as
|
||||
| { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string }
|
||||
| null;
|
||||
embedding = {
|
||||
checked: true,
|
||||
module_enabled: statusBody?.enabled ?? false, // Vectorize+AI binding 都在,才有「index」這回事
|
||||
cards_embedded: statusBody?.embedded ?? 0,
|
||||
cards_pending: statusBody?.pending ?? 0,
|
||||
self_test: {
|
||||
ran: selftestBody?.tested ?? false,
|
||||
// 三態:true=能搜到自己 / false=搜不到自己(index 收錄有缺)/ null=還沒東西可測或模組未開
|
||||
found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null,
|
||||
note: selftestBody?.note ?? '',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
notes.push(`embed 健康狀態查詢失敗:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// ② 卡片與知識圖譜規模(只取數字,不取 /map 回應裡的 narrative/top_entities 這些內容欄位)。
|
||||
let library_count = 0;
|
||||
let triplet_count = 0;
|
||||
try {
|
||||
const mapRes = await kbdbFetch(c.env, `/map?${new URLSearchParams({ owner_id: tenant }).toString()}`);
|
||||
const mapBody = (await mapRes.json().catch(() => null)) as
|
||||
| { success?: boolean; libraries?: { triplet_count?: number }[] }
|
||||
| null;
|
||||
const libs = Array.isArray(mapBody?.libraries) ? mapBody!.libraries! : [];
|
||||
library_count = libs.length;
|
||||
triplet_count = libs.reduce((sum, l) => sum + (Number(l.triplet_count) || 0), 0);
|
||||
} catch (e) {
|
||||
notes.push(`知識庫規模查詢失敗:${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// ③ 最近一次萃取(daemon → /portal/daemon/extract)成功與否:目前沒有雲端側的失敗歷史
|
||||
// 記錄可讀(該端點是同步請求/回應,失敗只回給呼叫當下的 daemon,雲端不落地保存)——
|
||||
// 誠實列出這個缺口,不假裝有數字(mindset §7 禁假綠)。
|
||||
notes.push('目前雲端沒有保存「萃取/上傳失敗」的歷史紀錄,只能看到目前的聚合計數(上面 cards_pending/cards_embedded);若要查某一次失敗的當下原因,需在失敗當下由封測者截圖同步小幫手視窗。');
|
||||
|
||||
return c.json({
|
||||
generated_at: new Date().toISOString(),
|
||||
instance_url: new URL(c.req.url).origin,
|
||||
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
|
||||
library_count,
|
||||
triplet_count,
|
||||
embedding,
|
||||
notes,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -711,3 +711,81 @@ describe('dedupeSourcesByPage(t129 出處去重)', () => {
|
||||
expect(out.length).toBe(1); // 同 page_name → 合為一筆
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════ 7. GET /portal/data/diagnostics(檢修孔,2026-08-07) ═══════════════
|
||||
|
||||
describe('GET /portal/data/diagnostics', () => {
|
||||
it('未登入 → 401,不碰 KBDB', async () => {
|
||||
const res = await get('/portal/data/diagnostics');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('登入 → 200,聚合 embed 健康狀態+規模統計+版本;只含數字/布林/字串狀態', async () => {
|
||||
await seedSession('tok-diag1', 'rec_diag1');
|
||||
mockGetRecord('rec_diag1', userValues());
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: true, pending: 3, embedded: 80 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' })
|
||||
.reply(200, {
|
||||
success: true,
|
||||
libraries: [
|
||||
{ name: 'general', triplet_count: 67, narrative: '不該出現在診斷檔', top_entities: ['密卡', '內容'] },
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
|
||||
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
library_count: number;
|
||||
triplet_count: number;
|
||||
embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } };
|
||||
instance_url: string;
|
||||
bundle_version: string | null;
|
||||
};
|
||||
expect(body.library_count).toBe(1);
|
||||
expect(body.triplet_count).toBe(67);
|
||||
expect(body.embedding.module_enabled).toBe(true);
|
||||
expect(body.embedding.cards_embedded).toBe(80);
|
||||
expect(body.embedding.cards_pending).toBe(3);
|
||||
expect(body.embedding.self_test.ran).toBe(true);
|
||||
expect(body.embedding.self_test.found_itself).toBe(false);
|
||||
expect(body.instance_url).toBe('http://localhost');
|
||||
// 紅線斷言:整份回應不含知識卡內容本體(/map 回應裡的 narrative/top_entities 沒被轉發)
|
||||
const raw = JSON.stringify(body);
|
||||
expect(raw).not.toContain('不該出現在診斷檔');
|
||||
expect(raw).not.toContain('密卡');
|
||||
});
|
||||
|
||||
it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index', async () => {
|
||||
await seedSession('tok-diag2', 'rec_diag2');
|
||||
mockGetRecord('rec_diag2', userValues());
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
|
||||
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
|
||||
fetchMock
|
||||
.get(KBDB)
|
||||
.intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' })
|
||||
.reply(200, { success: true, libraries: [], count: 0 });
|
||||
|
||||
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } } };
|
||||
expect(body.embedding.module_enabled).toBe(false);
|
||||
expect(body.embedding.self_test.ran).toBe(false);
|
||||
expect(body.embedding.self_test.found_itself).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,6 +283,63 @@ export async function backfillStatus(
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
|
||||
export interface SelfTestResult {
|
||||
enabled: boolean; // embed 模組是否開(binding 都在)
|
||||
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
|
||||
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
|
||||
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed 自我檢查(檢修孔用,2026-08-07 leo 直接指令:「先把檢修孔做出來發版」)。
|
||||
*
|
||||
* 為什麼需要這個,不只是 backfillStatus 的 pending/embedded 計數:08-05 撞過的真實故障
|
||||
* 是「is_embedded=1(已嵌入)但語義搜尋還是搜不到」——metadata index 事後才建,既有向量
|
||||
* 沒被收錄(Arcrun#11)。計數看不出這種病,因為計數只問「有沒有嵌」,不問「嵌完查得到嗎」。
|
||||
* 本函式挑一筆「已標記已嵌入」的既有 entry,拿它自己的內容做一次真實語義查詢,檢查
|
||||
* 「自己是否搜得到自己」——這是唯一能端到端驗證 index 真的可用的方法。
|
||||
*
|
||||
* 隱私邊界(檢修孔規格紅線:診斷檔不准帶卡片內容本體):本函式只回布林 + 一句話 note,
|
||||
* 不回傳卡片內容、不回傳 entry id。取樣內容只在函式內部這一次查詢中用過即丟。
|
||||
*/
|
||||
export async function embedSelfTest(
|
||||
env: Bindings,
|
||||
opts: { owner_id?: string } = {},
|
||||
): Promise<SelfTestResult> {
|
||||
if (!embedEnabled(env)) {
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
|
||||
}
|
||||
const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
|
||||
const params: unknown[] = [];
|
||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
||||
const where = conds.join(' AND ');
|
||||
const row = await env.DB
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY updated_at DESC LIMIT 1`)
|
||||
.bind(...params)
|
||||
.first<Entry>();
|
||||
if (!row) {
|
||||
return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
|
||||
}
|
||||
const sample = (row.content ?? '').trim().slice(0, 200);
|
||||
if (!sample) {
|
||||
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
|
||||
}
|
||||
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
|
||||
const hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
|
||||
if (hits === null) {
|
||||
return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
|
||||
}
|
||||
const passed = hits.some((h) => h.id === row.id);
|
||||
return {
|
||||
enabled: true,
|
||||
tested: true,
|
||||
passed,
|
||||
note: passed
|
||||
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
|
||||
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
|
||||
};
|
||||
}
|
||||
|
||||
export interface SemanticHit {
|
||||
id: string;
|
||||
score: number;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus } from '../embed';
|
||||
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
|
||||
|
||||
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -56,4 +56,14 @@ embedRoutes.get('/backfill/status', async (c) => {
|
||||
return c.json({ success: true, ...status });
|
||||
});
|
||||
|
||||
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
|
||||
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
|
||||
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
|
||||
// 本端點端到端驗證 index 真的可用。模組未開仍誠實回 enabled:false(不 409,讓檢修孔
|
||||
// 永遠能拿到一個可解讀的結論,不必先判斷該不該打這支)。
|
||||
embedRoutes.get('/selftest', async (c) => {
|
||||
const result = await embedSelfTest(c.env, { owner_id: c.req.query('owner_id') || undefined });
|
||||
return c.json({ success: true, ...result });
|
||||
});
|
||||
|
||||
export default embedRoutes;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { embedSelfTest } from '../src/embed';
|
||||
import type { Bindings, Entry } from '../src/types';
|
||||
|
||||
// ── Minimal in-memory fakes ───────────────────────────────────────────────
|
||||
// embedSelfTest issues exactly one DB statement:
|
||||
// SELECT * FROM entries WHERE is_embedded = 1 AND content <> '' [AND owner_id = ?]
|
||||
// ORDER BY updated_at DESC LIMIT 1
|
||||
// The fake's `first()` filters the in-memory store accordingly and returns the
|
||||
// last match (proxy for "ORDER BY updated_at DESC LIMIT 1" given store insertion order).
|
||||
function mkEntry(id: string, content: string, ownerId = 'leo', is_embedded = 1): Entry {
|
||||
return {
|
||||
id, content, entry_type: 'block', owner_id: ownerId, parent_id: null, page_name: null,
|
||||
refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded,
|
||||
confidence: null, metadata_json: JSON.stringify({ embed: true }), created_at: 1, updated_at: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFakeDB(store: Entry[]) {
|
||||
const prepare = (_sql: string) => {
|
||||
let bound: unknown[] = [];
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { bound = args; return stmt; },
|
||||
async first<T>() {
|
||||
const ownerId = bound.length > 0 ? String(bound[0]) : undefined;
|
||||
const rows = store.filter(
|
||||
(e) => e.is_embedded === 1 && (e.content ?? '').trim() !== '' && (!ownerId || e.owner_id === ownerId),
|
||||
);
|
||||
return (rows.length > 0 ? rows[rows.length - 1] : null) as unknown as T;
|
||||
},
|
||||
async all<T>() { return { results: [] as T[] }; },
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
}
|
||||
|
||||
function makeEnv(
|
||||
store: Entry[],
|
||||
opts: { withBindings?: boolean; matches?: { id: string; score: number }[] } = {},
|
||||
): Bindings {
|
||||
const withBindings = opts.withBindings ?? true;
|
||||
return {
|
||||
DB: makeFakeDB(store),
|
||||
ENVIRONMENT: 'test',
|
||||
...(withBindings
|
||||
? {
|
||||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||||
VECTORIZE: { async query() { return { matches: opts.matches ?? [] }; } },
|
||||
}
|
||||
: {}),
|
||||
} as unknown as Bindings;
|
||||
}
|
||||
|
||||
describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的可用)', () => {
|
||||
it('module off → enabled:false, tested:false, passed:null(誠實不假綠)', async () => {
|
||||
const env = makeEnv([mkEntry('e1', 'hello')], { withBindings: false });
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(false);
|
||||
expect(r.tested).toBe(false);
|
||||
expect(r.passed).toBeNull();
|
||||
expect(typeof r.note).toBe('string');
|
||||
});
|
||||
|
||||
it('沒有任何已嵌入卡片 → tested:false, passed:null(非失敗,只是還沒東西可測)', async () => {
|
||||
const env = makeEnv([]);
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(false);
|
||||
expect(r.passed).toBeNull();
|
||||
});
|
||||
|
||||
it('自我查詢能搜到自己 → passed:true', async () => {
|
||||
const store = [mkEntry('e1', 'doorbell workflow content')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(true);
|
||||
expect(r.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('自我查詢搜不到自己 → passed:false(Arcrun#11 那種「嵌了但查不到」故障模式)', async () => {
|
||||
const store = [mkEntry('e1', 'doorbell workflow content')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'some-other-id', score: 0.5 }] });
|
||||
const r = await embedSelfTest(env);
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(true);
|
||||
expect(r.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('依 owner_id 隔離:別的租戶的已嵌入卡片不會被拿來測', async () => {
|
||||
const store = [mkEntry('e1', 'content', 'other-tenant')];
|
||||
const env = makeEnv(store, { matches: [] });
|
||||
const r = await embedSelfTest(env, { owner_id: 'leo' });
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.tested).toBe(false);
|
||||
expect(r.passed).toBeNull();
|
||||
});
|
||||
|
||||
it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => {
|
||||
const store = [mkEntry('e1', 'this is the secret card body, must never leak')];
|
||||
const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] });
|
||||
const r = await embedSelfTest(env);
|
||||
const json = JSON.stringify(r);
|
||||
expect(json).not.toContain('e1');
|
||||
expect(json).not.toContain('secret card body');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user