b3a57d95f6
leo 實撞:搜「火星座標」20 筆只有前 8 筆相關,後面全是 n8n 版本比較表、Leo 填答。
kbdb 早支援 min_score(embed.ts:225 / issue #67),portal 從來沒傳 ⇒ 等同無門檻,
Vectorize 硬湊滿 topK 就把低分尾巴全端上來。
0.75 取自實測分數分布(youlin 實例,非猜測):
0.908…0.787 全是火星座標(真相關)
── 斷崖 ──
0.742 姨媽說故事/0.740 ax-academy/0.739×8 n8n版本比較表(雜訊)
與 leo 實測「前 8 個相關」完全吻合。
前端可傳 min_score 覆寫,但不收 0/負數(那等於關掉閾值=本次要修的病本身)。
⚠️ 這治不了『同一份檔案重複灌 N 次』(那些分數 0.91 比正解還高)——另立 issue #14。
581 lines
30 KiB
TypeScript
581 lines
30 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 } from './portal';
|
||
import { graphBase } 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)。
|
||
*/
|
||
const INTERNAL_ENTRY_TYPES = new Set(['value', 'workflow']);
|
||
|
||
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 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/負數**
|
||
// ——那等於關掉閾值,正是這次要修的病本身。
|
||
const msRaw = Number(c.req.query('min_score'));
|
||
const minScore = Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1 ? msRaw : 0.75;
|
||
params.set('min_score', String(minScore));
|
||
}
|
||
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: Record<string, string> = {};
|
||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||
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 = await kbdbFetch(c.env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`);
|
||
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;
|
||
const records = body && Array.isArray(body.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 }));
|
||
return c.json({ nodes, edges, node_count: nodes.length, edge_count: edges.length, truncated });
|
||
}),
|
||
);
|
||
|
||
// 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 誠實留空 */
|
||
}
|
||
}
|
||
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp,
|
||
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
|
||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||
if (stats.keys.length > 0) {
|
||
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
|
||
const ts = latest.name.split(':').pop() ?? '';
|
||
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
|
||
let verdict: string | undefined;
|
||
if (rawStat) {
|
||
try {
|
||
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
|
||
} catch {
|
||
/* 壞 record 誠實留空 */
|
||
}
|
||
}
|
||
last_execution = { timestamp: ts, verdict };
|
||
}
|
||
return { name, description, created_at, cron_expr, last_execution };
|
||
}),
|
||
);
|
||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||
}),
|
||
);
|