3d3973ecbc
leo 08-12:「我要的是寫進 KBDB,不是 KV,他的 Recipes、Cypher 是一段話,文字,
數據,一個 entry」「如果零件和工作流的 recipe 不見了,是很可怕的事情」。
同日實害:一次例行更新讓九支工作流在畫面上全部消失。#97 已修掉直接原因
(別再照名字猜使用者的資源、別再擅自新建一顆空的綁上去);這裡修更下面那一句——
**資產本來就不該只存在於一個會被換掉的暫存層裡**。
做法(換 binding,不改四十幾處呼叫端):
- lib/asset-keys.ts 哪些 KV key 是資產、對應 KBDB 哪一列。**唯一**要人看懂的那張表。
- lib/durable-store.ts 讀=KV 先行、miss 回源 KBDB 並補快取;寫=先 KBDB 再 KV,
KBDB 失敗就拋錯(禁假綠);**列舉一律回源**——空 KV 列出來是
「零筆」而不是「查不到」,那正是東西消失的形狀。
- index.ts 入口把 WEBHOOKS/RECIPES 換成上面那層。逐處改寫一定會漏,
而漏掉的那一處就是下一次「東西不見了」的入口。
- routes/storage.ts /storage/audit(搬前搬後各數一次)+ /storage/migrate-to-kbdb
(只增不刪、冪等、逐筆回報成敗)。
- kbdb migration 0005 seed 四列 template(零 schema 異動,手法同 0003/0004)
+ PUT /entries/:id 指定 id 的整列 upsert(通用原語,不是為誰開特例)。
- 衍生資料(idx:*、cron-idx:_all)不進 KBDB,讀不到就從資產重算。
⚠️ 狀態=◐ 半通,**別因為程式碼看起來完整就先合併**。
已實測:5 份 migration 在本機 D1 全數套用(含 0005);兩顆 worker 都能以改動後的
程式碼在本機開起來;tsc 錯誤數 7→7(既有,未新增)。
**沒跑到**:「砍掉 KV、資產還在」那一次端到端驗證——本次施工環境的權限閘不放行
執行 vitest/node/curl。那一次已寫成 scripts/verify-kv-retirement.sh,
在能執行的機器上跑一次就是證據。建議順序:先跑腳本、綠了再合併。
規格層依 D35 走 pending-changes.md「P-KV」提案,等 leo confirm(現行 active SDD
是 workflow-discovery,本案不在它的 tasks 內,故不自建 SDD、不改 rules 那張儲存表)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
694 lines
37 KiB
TypeScript
694 lines
37 KiB
TypeScript
/**
|
||
* RAG Portal 查詢面 — P3:/portal/data/* server-side enforce(portal-auth design §3.3/§3.4/§5,
|
||
* Gitea #24/#25)。本檔是本 SDD 的**安全核心**。
|
||
*
|
||
* 安全模型(design §3.3,與 console 的關鍵差異):
|
||
* - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/*;**portal 前端絕不持有租戶字串**,
|
||
* 只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search,
|
||
* 所以 enforce 全在 server:session → 回讀 user record(唯一真相源)→ 取 libraries →
|
||
* server 注入 owner_id+library 後轉發 KBDB。
|
||
* - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成
|
||
* 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。
|
||
* - ["*"]=全庫:只注 owner_id、不注 library(design §3.3)。
|
||
*
|
||
* 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**,
|
||
* 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403(D-4)。
|
||
*
|
||
* 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB base(P1 的 library filter),
|
||
* graph 真身在 kbdb-graph-plugin,工作流真身在 WEBHOOKS/ANALYTICS KV(與 /webhooks/named、
|
||
* /workflows/:name/executions 同一資料源)。這裡只做「權限注入+轉發/讀取」。
|
||
*
|
||
* log 紅線:本檔不 log 任何 token / 密碼 / 查詢內容。
|
||
*/
|
||
import { Hono } from 'hono';
|
||
import type { Context } from 'hono';
|
||
import type { Bindings } from '../types';
|
||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled, buildDiagnostics } from './portal';
|
||
import { graphBase, graphHeaders } from './kbdb-proxy';
|
||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||
|
||
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||
|
||
// ── tenant workflow in-process 執行(portal-demo-suite)───────────────────────
|
||
//
|
||
// 為什麼:RAG self-hosted 實例不部署 kbdb-graph-plugin worker(graphBase 直連會 1042,
|
||
// Arcrun#57);graph/chat 這類查詢能力在該實例是以 tenant 的 named workflow 形式存在
|
||
//(WEBHOOKS KV `{tenant}:wf:{name}`,與 /webhooks/named 同一資料源)。
|
||
// 怎麼接:直接 import executeWebhookGraph(webhooks-named.ts trigger/query 端點同一個
|
||
// 執行入口)在 **本 worker 進程內** 執行——絕不 fetch 自己的 hostname
|
||
//(global_fetch_strictly_public 下打自己=self-loop,見 /portal/data/workflows 同款註解)。
|
||
|
||
/** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */
|
||
async function getTenantWorkflowGraph(env: Bindings, name: string): Promise<Record<string, unknown> | null> {
|
||
const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text');
|
||
if (!raw) return null;
|
||
try {
|
||
const rec = JSON.parse(raw) as { graph?: Record<string, unknown> };
|
||
return rec.graph && typeof rec.graph === 'object' ? rec.graph : null;
|
||
} catch {
|
||
return null; // 壞 record 視同不存在(呼叫端各自誠實回報)
|
||
}
|
||
}
|
||
|
||
/** workflow 最終節點輸出常見兩形:本體即結果,或再包一層 data(http_request 類零件慣例)。取有目標欄位的那層。 */
|
||
function unwrapWorkflowData(data: unknown, key: string): Record<string, unknown> {
|
||
const outer = data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||
if (key in outer) return outer;
|
||
const inner = outer.data;
|
||
if (inner && typeof inner === 'object' && key in (inner as Record<string, unknown>)) {
|
||
return inner as Record<string, unknown>;
|
||
}
|
||
return outer;
|
||
}
|
||
|
||
/**
|
||
* graph_neighbors workflow 輸出 → 與 kbdb-graph-plugin 相同的回應形狀 {neighbors, edges, count}
|
||
*(前端 doGraphSearch 讀 neighbors/edges;count=neighbors 數,重算不信 workflow 自報)。
|
||
* 純函式(單測用 export)。
|
||
*/
|
||
export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; edges: unknown[]; count: number } {
|
||
const layer = unwrapWorkflowData(data, 'neighbors');
|
||
const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : [];
|
||
const edges = Array.isArray(layer.edges) ? layer.edges : [];
|
||
return { neighbors, edges, count: neighbors.length };
|
||
}
|
||
|
||
/**
|
||
* 出處清單按 page_name 去重(t129):
|
||
* rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。
|
||
* 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。
|
||
* page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。
|
||
* 純函式,單測用 export。
|
||
*/
|
||
export function dedupeSourcesByPage(sources: unknown[]): unknown[] {
|
||
const seen = new Map<string, { item: Record<string, unknown>; count: number }>();
|
||
for (const s of sources) {
|
||
if (!s || typeof s !== 'object') continue;
|
||
const item = s as Record<string, unknown>;
|
||
const page = typeof item.page_name === 'string' ? item.page_name :
|
||
typeof item.page === 'string' ? item.page : '';
|
||
const existing = seen.get(page);
|
||
if (existing) {
|
||
existing.count += 1;
|
||
} else {
|
||
seen.set(page, { item, count: 1 });
|
||
}
|
||
}
|
||
return [...seen.values()].map(({ item, count }) =>
|
||
count > 1 ? { ...item, hit_count: count } : item,
|
||
);
|
||
}
|
||
|
||
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
|
||
function notFound(c: Context<{ Bindings: Bindings }>): Response {
|
||
return c.json({ error: '找不到這筆資料' }, 404);
|
||
}
|
||
|
||
/** entry 的庫歸屬:metadata_json.$.library,未標記 → 'general'(design §3.2 fallback,與 KBDB P1 同語意)。 */
|
||
export function entryLibrary(entry: { metadata_json?: string | null }): string {
|
||
try {
|
||
const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null;
|
||
if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library;
|
||
} catch {
|
||
/* metadata 壞掉 → 視同未標記 */
|
||
}
|
||
return 'general';
|
||
}
|
||
|
||
/** 用戶庫集合是否覆蓋某庫(["*"]=全庫)。 */
|
||
function canReadLibrary(userLibraries: string[], library: string): boolean {
|
||
return userLibraries.includes('*') || userLibraries.includes(library);
|
||
}
|
||
|
||
/**
|
||
* 搜尋殘影過濾(**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在
|
||
* KBDB 裡污染搜尋結果;根治=上游清資料/重建索引,那修好後這段可整段拔掉)。
|
||
* 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry、
|
||
* 內部型別 entry(value=slot 值外漏的無標題雜項列、workflow=工作流定義——都是系統內部件,
|
||
* 不是給搜尋用戶看的內容;2026-07-18 leo 客戶測試回饋「搜尋結果偶見無標題雜項列」)。
|
||
* metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。
|
||
* 純函式(單測用 export)。
|
||
*/
|
||
// execution_log/execution_log_usage(KV 額度事故修復,2026-08-07):workflow 執行紀錄與其內部
|
||
// 用量計數器,entry_type 與既有 value/workflow 同層級的內部型別——一併排除,避免用戶搜尋知識時
|
||
// 混進執行 log(同層防線:本模組也從不設 metadata_json.embed=true,永不進語意搜尋索引)。
|
||
// KV 退休(#16/#17)新增四型:資產定義本體住進 KBDB 之後,它們是「系統的東西」而不是
|
||
// 使用者的知識卡——知識瀏覽/搜尋要跟 execution_log 一樣排除,否則 portal 會冒出
|
||
// 一堆 workflow_def / api_recipe 汙染結果。
|
||
const INTERNAL_ENTRY_TYPES = new Set([
|
||
'value', 'workflow', 'execution_log', 'execution_log_usage',
|
||
'workflow_def', 'api_recipe', 'auth_recipe', 'prompt_recipe',
|
||
]);
|
||
|
||
export function filterDeprecatedEntries<T extends { metadata_json?: string | null; content?: string | null; entry_type?: string | null }>(
|
||
entries: T[],
|
||
): T[] {
|
||
return entries.filter((e) => {
|
||
if (e.entry_type && INTERNAL_ENTRY_TYPES.has(e.entry_type)) return false;
|
||
try {
|
||
const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null;
|
||
if (meta && meta.status === 'deprecated') return false;
|
||
} catch {
|
||
/* parse 失敗 → 保留 */
|
||
}
|
||
if (String(e.content ?? '').startsWith('(舊管線產物')) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* CJK/ASCII 邊界插空白正規化(t95):
|
||
* 「AI協作」→「AI 協作」;「協作AI」→「協作 AI」;已有空白不重複插。
|
||
* 只動查詢端,不動索引端。純函式,單測用 export。
|
||
*/
|
||
export function normalizeCjkQuery(q: string): string {
|
||
// U+3040-U+9FFF: Hiragana/Katakana/CJK Ext.A/CJK main; U+F900-U+FAFF: CJK Compat.
|
||
const isCjk = (c: string) => /[-鿿豈-]/.test(c);
|
||
const isAsciiAlnum = (c: string) => /[-鿿豈-]/.test(c);
|
||
let result = '';
|
||
for (let i = 0; i < q.length; i++) {
|
||
const ch = q[i];
|
||
if (result.length > 0) {
|
||
const prev = result[result.length - 1];
|
||
if (prev !== ' ' && ch !== ' ' &&
|
||
((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) {
|
||
result += ' ';
|
||
}
|
||
}
|
||
result += ch;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用):
|
||
* 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。
|
||
*/
|
||
export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null {
|
||
const term = normalizeCjkQuery(searchTerm).toLowerCase();
|
||
if (!term) return null;
|
||
const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term));
|
||
if (hits.length === 0) return null;
|
||
return hits.reduce((a, b) => a.length <= b.length ? a : b);
|
||
}
|
||
|
||
/**
|
||
* 三元組條數(KBDB `/records/triplet-stats` 真 SQL COUNT)。owner 傳 '' =不限租戶(KBDB 端
|
||
* `?1 = '' OR e.owner_id = ?1`)。null=讀不到——caller 據此不敢宣稱 0。
|
||
*/
|
||
async function tripletCount(env: Bindings, owner: string): Promise<number | null> {
|
||
try {
|
||
const res = await kbdbFetch(env, `/records/triplet-stats?owner_id=${encodeURIComponent(owner)}`);
|
||
if (!res.ok) return null;
|
||
const body = (await res.json().catch(() => null)) as { stats?: { triplet_count?: unknown }[] } | null;
|
||
if (!body || !Array.isArray(body.stats)) return null;
|
||
let total = 0;
|
||
for (const row of body.stats) {
|
||
if (typeof row?.triplet_count !== 'number') return null;
|
||
total += row.triplet_count;
|
||
}
|
||
return total;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 三元組普查(Arcrun#100)——回答總圖那句「知識庫還沒有任何關聯」到底能不能講。
|
||
*
|
||
* leo 的原則(已寫在 portal.ts §②.5 daemon diagnostics):**「不要讓『查不到』和『沒有』
|
||
* 長得一樣」**。t161 前科:手補的 record owner_id 存成 None ⇒ 全量查得到、按 owner_id 過濾
|
||
* 的畫面永遠空——比真的沒資料更難查。所以本租戶數為 0 時**再花一次查詢換一條路徑**
|
||
* (同一支端點但不帶 owner),問「這個庫到底有沒有三元組」:
|
||
* owned>0 → 有資料
|
||
* owned=0 且 any=0 → 真的空(此時、也只有此時,畫面才准印 0)
|
||
* owned=0 但 any>0 → owner_id / 範圍對不上,不是空庫 → 畫面說讀不到
|
||
* owned=null → 讀不到 → 畫面說讀不到
|
||
*/
|
||
async function tripletCensus(env: Bindings, tenant: string): Promise<{ owned: number | null; any: number | null }> {
|
||
const owned = await tripletCount(env, tenant);
|
||
if (owned !== 0) return { owned, any: null }; // 非 0(含 null)不必多問一次
|
||
return { owned, any: await tripletCount(env, '') };
|
||
}
|
||
|
||
/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */
|
||
async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise<string | null> {
|
||
try {
|
||
const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||
if (!res.ok) return null;
|
||
const body = (await res.json().catch(() => null)) as { records?: { values?: Record<string, unknown> }[] } | null;
|
||
if (!body || !Array.isArray(body.records)) return null;
|
||
const nodeNames = new Set<string>();
|
||
for (const r of body.records) {
|
||
const v = r?.values;
|
||
if (!v || typeof v !== 'object') continue;
|
||
if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim());
|
||
if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim());
|
||
}
|
||
return findBestNodeMatch(searchTerm, [...nodeNames]);
|
||
} catch {
|
||
return null; // fallback 失敗靜默略過,原本 0 結果直接回
|
||
}
|
||
}
|
||
|
||
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
|
||
//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形
|
||
//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
|
||
// semantic 未開的降級行為沿 KBDB 既有,P1 已保 library 照 enforce)。
|
||
portalDataRouter.get('/portal/data/search', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const qRaw = c.req.query('q');
|
||
if (!qRaw) return c.json({ error: 'q 必填' }, 400);
|
||
const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端)
|
||
|
||
const libraries = parseLibraries(auth.user.values.libraries);
|
||
if (libraries.length === 0) {
|
||
// 帳號沒被授權任何庫:誠實空結果(不打 KBDB——沒有可查範圍就沒有查詢)
|
||
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
||
}
|
||
|
||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||
if (c.req.query('mode') === 'semantic') {
|
||
params.set('mode', 'semantic');
|
||
// 🔴 t183(leo 08-04 實撞:「語義搜尋搜到一大堆不相關的內容」
|
||
// ——搜「火星座標」卻跑出 n8n 版本比較表、Leo 填答):
|
||
// Vectorize 會**硬湊滿 topK 筆**,湊不到就把低分的塞進來 ⇒ 尾巴全是無關內容。
|
||
// kbdb 早就支援 min_score(`kbdb/src/embed.ts:225`,issue #67),
|
||
// 但 portal **從來沒傳** ⇒ 等同沒有閾值,低分尾全端到用戶面前。
|
||
//
|
||
// 0.75 怎麼來的(**實測分數分布,不是猜的**;youlin 實例搜「火星座標 奧林帕斯山」):
|
||
// 0.908 / 0.881 / 0.881 / 0.880 / 0.870 / 0.815 / 0.798 / 0.787 ← 全是火星座標,真相關
|
||
// ─────────────────────── 斷崖 ───────────────────────
|
||
// 0.742 姨媽說故事 / 0.740 ax-academy / 0.739×8 n8n 版本比較表 ← 全是雜訊
|
||
// 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。
|
||
//
|
||
// 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數**
|
||
// ——那等於關掉閾值,正是 t183 要修的病本身。
|
||
//
|
||
// 🔴 2026-08-05 修正(leo 實撞「語義搜尋 0 命中」):**這裡不再硬寫預設值**。
|
||
// 上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的;08-05 換 bge-m3 後
|
||
// 分數尺度整體下移,0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。
|
||
// 根因=**閾值是模型的性質,卻被複製到呼叫端**,換模型時沒人想到要回來改這行。
|
||
// ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`(緊鄰 DEFAULT_EMBED_MODEL),
|
||
// portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。**
|
||
const msRaw = Number(c.req.query('min_score'));
|
||
if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw));
|
||
}
|
||
const entryType = c.req.query('entry_type');
|
||
if (entryType) params.set('entry_type', entryType);
|
||
const limit = c.req.query('limit');
|
||
if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit);
|
||
|
||
const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`);
|
||
if (!res.ok) {
|
||
// 錯誤回應照原樣透傳(誠實,不加工)
|
||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
// Arcrun#46 治標:server-side 濾掉舊管線 deprecated 殘影再回(count 重算)。
|
||
// 上游修好(清資料/重建索引)後,這段連同 filterDeprecatedEntries 一起拔掉。
|
||
const body = (await res.json().catch(() => null)) as
|
||
| { entries?: { metadata_json?: string | null; content?: string | null }[]; count?: number }
|
||
| null;
|
||
if (!body || !Array.isArray(body.entries)) {
|
||
// 回應不是預期形狀 → 照原樣回(不因治標把正常錯誤形狀吃掉)
|
||
return c.json(body ?? { error: 'KBDB 回應不是 JSON' }, body ? 200 : 502);
|
||
}
|
||
const entries = filterDeprecatedEntries(body.entries);
|
||
return c.json({ ...body, entries, count: entries.length });
|
||
}),
|
||
);
|
||
|
||
// GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**(design §5):
|
||
// ① entry 必須屬於本實例租戶(owner_id=CONSOLE_TENANT)——防拿別租戶 id 直讀;
|
||
// ② entry 的 library(NULL→general)必須在用戶庫集合內——防拿越庫 id 直讀。
|
||
// 兩者不符與不存在同回 404(不洩存在性)。
|
||
portalDataRouter.get('/portal/data/entries/:id', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const libraries = parseLibraries(auth.user.values.libraries);
|
||
if (libraries.length === 0) return notFound(c);
|
||
|
||
const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`);
|
||
if (res.status === 404) return notFound(c);
|
||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
|
||
const entry = body.entry;
|
||
if (!entry) return notFound(c);
|
||
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
|
||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||
return c.json({ success: true, entry });
|
||
}),
|
||
);
|
||
|
||
// GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘):
|
||
// 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。
|
||
// 放行後的資料源二選一(Arcrun#57):
|
||
// ① tenant 有 `{tenant}:wf:graph_neighbors` workflow → in-process 執行它(RAG self-hosted
|
||
// 實例不部署 kbdb-graph-plugin,直連會 1042),輸出映射成與 plugin 相同形狀;
|
||
// ② 沒有 → fallback 原本 graphBase 直連 plugin(Mira/leo21c 實例相容,行為一字不變)。
|
||
portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const libraries = parseLibraries(auth.user.values.libraries);
|
||
if (!(await hasGraphAccess(c.env, libraries))) {
|
||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||
}
|
||
|
||
// t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點)
|
||
const nodeName = normalizeCjkQuery(c.req.param('name'));
|
||
|
||
// ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant)
|
||
const tenant = portalTenant(c.env);
|
||
const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors');
|
||
if (wfGraph) {
|
||
const depthRaw = c.req.query('depth') ?? '';
|
||
const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2;
|
||
const result = await executeWebhookGraph(
|
||
c.env,
|
||
wfGraph,
|
||
// t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}})
|
||
{ node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' },
|
||
'graph_neighbors',
|
||
tenant,
|
||
c.executionCtx,
|
||
);
|
||
if (!result.success) {
|
||
// workflow 執行失敗 → 誠實 502(不假裝無關聯)
|
||
return c.json({ error: `graph_neighbors workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
|
||
}
|
||
return c.json(mapGraphWorkflowOutput(result.data));
|
||
}
|
||
|
||
// ② plugin fallback(Mira/leo21c 相容)
|
||
const base = graphBase(c.env);
|
||
const headers = graphHeaders(c.env);
|
||
try {
|
||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers });
|
||
if (!res.ok) {
|
||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
// t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」)
|
||
const resText = await res.text().catch(() => '');
|
||
let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null;
|
||
try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ }
|
||
if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 &&
|
||
Array.isArray(data.edges) && data.edges.length === 0) {
|
||
const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName);
|
||
if (fallbackName && fallbackName !== nodeName) {
|
||
const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers });
|
||
return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
}
|
||
return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||
} catch (e) {
|
||
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
|
||
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||
}
|
||
}),
|
||
);
|
||
|
||
// GET /portal/data/graph/overview — 書庫總圖(Arcrun#39 藏書地圖的 GUI 資料切片):
|
||
// 全租戶 active 三元組 → {nodes:[{name,degree}], edges:[{subject,predicate,object}]}。
|
||
// 權限=與 neighbors 同一道 D-4 graph 粗閘;資料直讀 KBDB records(與 search 同 kbdbFetch 路徑,
|
||
// 不經 graph plugin、不撞 1042)。邊數上限 500(demo 量級遠低於此;超限誠實截斷並回 truncated)。
|
||
portalDataRouter.get('/portal/data/graph/overview', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const libraries = parseLibraries(auth.user.values.libraries);
|
||
if (!(await hasGraphAccess(c.env, libraries))) {
|
||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||
}
|
||
const tenant = portalTenant(c.env);
|
||
const [res, census] = await Promise.all([
|
||
kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}&limit=500`),
|
||
tripletCensus(c.env, tenant),
|
||
]);
|
||
const tripletsTotal = census.owned;
|
||
if (!res.ok) {
|
||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||
}
|
||
const body = (await res.json().catch(() => null)) as
|
||
| { records?: { values?: Record<string, unknown> }[] }
|
||
| null;
|
||
// #100:形狀不對 ≠ 沒有資料。原本 `: []` 會把「讀不出來」變成一張空圖,
|
||
// 前端照著印「0 個實體・0 條關聯」——那是畫面在說謊。讀不出來就誠實 502。
|
||
if (!body || !Array.isArray(body.records)) {
|
||
return c.json({ error: '三元組讀取失敗:KBDB 回應不是預期的 records 清單' }, 502);
|
||
}
|
||
const records = body.records;
|
||
const EDGE_CAP = 500;
|
||
const seen = new Set<string>();
|
||
const edges: { subject: string; predicate: string; object: string }[] = [];
|
||
const degree = new Map<string, number>();
|
||
let truncated = false;
|
||
for (const r of records) {
|
||
const v = r && typeof r.values === 'object' && r.values ? r.values : null;
|
||
if (!v) continue;
|
||
if (v.status === 'deprecated') continue;
|
||
const s = typeof v.subject === 'string' ? v.subject.trim() : '';
|
||
const o = typeof v.object === 'string' ? v.object.trim() : '';
|
||
if (!s || !o) continue;
|
||
const p = typeof v.predicate === 'string' ? v.predicate : '';
|
||
const key = `${s}${p}${o}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
if (edges.length >= EDGE_CAP) { truncated = true; break; }
|
||
edges.push({ subject: s, predicate: p, object: o });
|
||
degree.set(s, (degree.get(s) ?? 0) + 1);
|
||
degree.set(o, (degree.get(o) ?? 0) + 1);
|
||
}
|
||
const nodes = [...degree.entries()].map(([name, d]) => ({ name, degree: d }));
|
||
// #100:一張空圖有三種成因,前端必須分得出來(判準留在 server,不留給前端猜)——
|
||
// confirmed_empty :本租戶真的一條都沒有,全庫也沒有 → 才准印「0 個實體・0 條關聯」
|
||
// scope_mismatch :全庫有、本租戶查不到 → owner_id/範圍對不上,不是空庫(t161 前科)
|
||
// unreadable :連條數都讀不到 → 只能說讀不到
|
||
let emptyReason: 'confirmed_empty' | 'scope_mismatch' | 'unreadable' | null = null;
|
||
if (nodes.length === 0) {
|
||
if (census.owned === null) emptyReason = 'unreadable';
|
||
else if (census.owned > 0) emptyReason = 'scope_mismatch'; // 有條數卻抽不出邊
|
||
else if (census.any === null) emptyReason = 'unreadable';
|
||
else emptyReason = census.any > 0 ? 'scope_mismatch' : 'confirmed_empty';
|
||
}
|
||
return c.json({
|
||
nodes,
|
||
edges,
|
||
node_count: nodes.length,
|
||
edge_count: edges.length,
|
||
// 取到的 record 已達 KBDB 單頁上限 → 這張圖只是全庫的一部分,別讓 meta 看起來像全部
|
||
truncated: truncated || records.length >= 500,
|
||
triplets_total: tripletsTotal,
|
||
empty_confirmed: nodes.length > 0 || emptyReason === 'confirmed_empty',
|
||
empty_reason: emptyReason,
|
||
});
|
||
}),
|
||
);
|
||
|
||
// GET /portal/data/chat?question=... — AI 問答(portal-demo-suite)。
|
||
// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面,
|
||
// 只是把「人下關鍵字」換成「workflow 代查再作答」;前端不因走 AI 多拿任何權限。
|
||
// 機制同 graph_neighbors:in-process 執行 tenant 的 rag_chat workflow(executeWebhookGraph,
|
||
// 絕不 fetch 自己 hostname);workflow 不存在 → 誠實 404,不假裝這實例有問答能力。
|
||
portalDataRouter.get('/portal/data/chat', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const question = c.req.query('question');
|
||
if (!question) return c.json({ error: 'question 必填' }, 400);
|
||
|
||
const wfGraph = await getTenantWorkflowGraph(c.env, 'rag_chat');
|
||
if (!wfGraph) return c.json({ error: '此實例未安裝問答 workflow' }, 404);
|
||
|
||
const result = await executeWebhookGraph(
|
||
c.env,
|
||
wfGraph,
|
||
{ question },
|
||
'rag_chat',
|
||
portalTenant(c.env),
|
||
c.executionCtx,
|
||
);
|
||
if (!result.success) {
|
||
// workflow 執行失敗 → 誠實 502(不把錯誤編成答案)
|
||
return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502);
|
||
}
|
||
// 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造)
|
||
// t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。
|
||
const inner = unwrapWorkflowData(result.data, 'answer');
|
||
const rawSources = Array.isArray(inner.sources) ? inner.sources : [];
|
||
return c.json({
|
||
answer: typeof inner.answer === 'string' ? inner.answer : '',
|
||
sources: dedupeSourcesByPage(rawSources),
|
||
graph_facts: inner.graph_facts ?? null,
|
||
});
|
||
}),
|
||
);
|
||
|
||
// ── 上傳(portal-demo-suite)────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 上傳檔名驗證(純函式,單測用 export):
|
||
* - 去路徑分隔(/ \)只取最後一段(擋 ../ 穿越)、去控制字元;
|
||
* - 空名/以 . 開頭(隱藏檔/./..)→ 無效(null);
|
||
* - 強制 .md 結尾(.txt 改副檔名、其餘直接補 .md——上傳面收的是知識文件,一律當 markdown 收件);
|
||
* - 最終長度限 100(含副檔名),超過 → 無效。
|
||
*/
|
||
export function sanitizeUploadFilename(raw: unknown): string | null {
|
||
if (typeof raw !== 'string') return null;
|
||
let name = (raw.split(/[/\\]/).pop() ?? '').trim();
|
||
// eslint-disable-next-line no-control-regex
|
||
name = name.replace(/[\u0000-\u001f\u007f]/g, '');
|
||
if (!name || name.startsWith('.')) return null;
|
||
if (/\.txt$/i.test(name)) name = name.replace(/\.txt$/i, '.md');
|
||
if (!/\.md$/i.test(name)) name = `${name}.md`;
|
||
if (name.length > 100) return null;
|
||
return name;
|
||
}
|
||
|
||
// base64 內容上限(收 .md/.txt 知識文件,2 MiB 原文 ≈ 2.7 MB base64 已綽綽有餘;防拿上傳面塞大檔)
|
||
const MAX_UPLOAD_B64_CHARS = 3 * 1024 * 1024;
|
||
|
||
// POST /portal/data/upload — body {filename, content_b64}(portal-demo-suite)。
|
||
// requirePortalUser 閘;server-side POST Gitea contents API 寫進 PORTAL_UPLOAD_REPO 的
|
||
// docs/{filename}——token 只在 server 側,前端永遠拿不到(同 kbdb-proxy token 慣例)。
|
||
// 未設 upload bindings(三者任一缺)→ 404「未啟用上傳」(Mira 零影響:功能不存在)。
|
||
portalDataRouter.post('/portal/data/upload', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
if (!uploadEnabled(c.env)) return c.json({ error: '此實例未啟用上傳' }, 404);
|
||
|
||
const body = await c.req.json().catch(() => null) as { filename?: unknown; content_b64?: unknown } | null;
|
||
const filename = sanitizeUploadFilename(body?.filename);
|
||
if (!filename) return c.json({ error: 'filename 無效(不可含路徑、不可空、長度限 100)' }, 400);
|
||
const contentB64 = body?.content_b64;
|
||
if (typeof contentB64 !== 'string' || !contentB64) return c.json({ error: 'content_b64 必填' }, 400);
|
||
if (contentB64.length > MAX_UPLOAD_B64_CHARS) return c.json({ error: '檔案過大(上限約 2 MB)' }, 413);
|
||
|
||
const base = (c.env.PORTAL_UPLOAD_GITEA ?? '').replace(/\/$/, '');
|
||
const repo = c.env.PORTAL_UPLOAD_REPO ?? '';
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`${base}/api/v1/repos/${repo}/contents/docs/${encodeURIComponent(filename)}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `token ${c.env.PORTAL_UPLOAD_TOKEN}`,
|
||
},
|
||
body: JSON.stringify({
|
||
content: contentB64,
|
||
// commit 訊息帶上傳者(display_name 非機密),收件溯源用;不進任何內容
|
||
message: `portal 上傳:docs/${filename}(${auth.user.values.display_name ?? 'portal user'})`,
|
||
}),
|
||
});
|
||
} catch (e) {
|
||
return c.json({ error: `知識庫收件服務不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||
}
|
||
if (res.status === 409 || res.status === 422) {
|
||
// Gitea 同 path 已存在(版本差異回 409 或 422)→ 統一誠實回 409
|
||
return c.json({ error: '同名文件已存在,請改檔名後重傳' }, 409);
|
||
}
|
||
if (!res.ok) {
|
||
return c.json({ error: `知識庫收件失敗(HTTP ${res.status})` }, 502);
|
||
}
|
||
return c.json({ success: true, filename, path: `docs/${filename}` });
|
||
}),
|
||
);
|
||
|
||
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
|
||
//(trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
|
||
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403)/ all / off(整頁不存在 → 404)。
|
||
portalDataRouter.get('/portal/data/workflows', (c) =>
|
||
run(c, async () => {
|
||
const auth = await requirePortalUser(c);
|
||
if (!auth.ok) return auth.res;
|
||
const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
|
||
if (setting === 'off') return notFound(c);
|
||
if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) {
|
||
return c.json({ error: '需要 admin 權限' }, 403);
|
||
}
|
||
|
||
// 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
|
||
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
|
||
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
|
||
const tenant = portalTenant(c.env);
|
||
const prefix = `${tenant}:wf:`;
|
||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||
const workflows = await Promise.all(
|
||
list.keys.map(async (k) => {
|
||
const name = k.name.slice(prefix.length);
|
||
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
|
||
let description = '';
|
||
let created_at = '';
|
||
let cron_expr: string | undefined;
|
||
if (raw) {
|
||
try {
|
||
const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string };
|
||
description = rec.description ?? '';
|
||
created_at = rec.created_at ?? '';
|
||
cron_expr = rec.cron_expr;
|
||
} catch {
|
||
/* 壞 record 誠實留空 */
|
||
}
|
||
}
|
||
// 最近一次執行:KV 額度事故修復(2026-08-07)改打 KBDB GET /execution-log/latest
|
||
// (原走 ANALYTICS_KV stats:{name}:* list,免費層 list 也是 1,000/日)。KBDB=
|
||
// API-as-Wall:不直連 D1,走既有 kbdbFetch(本檔已在用,見上方 import)。
|
||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||
const execRes = await kbdbFetch(
|
||
c.env,
|
||
`/execution-log/latest?${new URLSearchParams({ workflow_id: name, owner_id: tenant }).toString()}`,
|
||
);
|
||
const execBody = await execRes.json().catch(() => null) as {
|
||
success?: boolean;
|
||
execution?: { verdict: string; recorded_at: number } | null;
|
||
} | null;
|
||
if (execRes.ok && execBody?.success && execBody.execution) {
|
||
last_execution = { timestamp: String(execBody.execution.recorded_at), verdict: execBody.execution.verdict };
|
||
}
|
||
return { name, description, created_at, cron_expr, last_execution };
|
||
}),
|
||
);
|
||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||
}),
|
||
);
|
||
|
||
// GET /portal/data/diagnostics — 檢修孔(2026-08-07 leo 直接指令):
|
||
//
|
||
// 「可以很簡單,就是一顆按鈕在設定裡,他按鈕下載一個檔案,把檔案發給我,你看那個檔。」
|
||
//
|
||
// 設定頁「匯出診斷檔給我們看」按鈕打這支,前端把回應存成單一 JSON 檔下載。
|
||
//
|
||
// 🔴 t213(2026-08-08,InkStoneCo 總管交辦):leo 實測拿真檔驗四個真實問題,只答得出一題
|
||
// (雲端這半的 bundle_version)——其餘三題(本機檔案總量、失敗分類統計、daemon 版本/
|
||
// 自我更新狀態)需要本機資料,雲端這支端點天生構不到(封測者的瀏覽器與他電腦上的
|
||
// daemon 是兩個獨立行程)。核准方案:本機那半改由 arcrun-app(daemon 桌面殼)匯出時
|
||
// 直接讀本機檔案,並改打**新增的** `GET /portal/daemon/diagnostics`(X-Arcrun-API-Key
|
||
// 認證,免帳密)取雲端這半,兩者合併成一份完整診斷檔——arcrun-app 那半見
|
||
// products/arcrun-rag repo t213 phase 2。本端點(portal 網頁版)保留當退路(daemon
|
||
// 完全掛掉時仍按得到),文案需誠實講清楚自己只有一半,完整診斷請去 daemon 匯出
|
||
// (portal 前端文案改動不在本次 matrix/arcrun 範圍內,由 arcrun-rag 那邊處理)。
|
||
//
|
||
// 兩條紅線、embedding 健康檢查涵蓋範圍、認證機制皆不變,核心邏輯已抽成 buildDiagnostics()
|
||
// (portal.ts)——與新的 daemon 版共用同一份查詢邏輯(薄殼原則)。
|
||
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 core = await buildDiagnostics(c.env, tenant);
|
||
return c.json({
|
||
generated_at: new Date().toISOString(),
|
||
instance_url: new URL(c.req.url).origin,
|
||
bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
|
||
...core,
|
||
});
|
||
}),
|
||
);
|