/** * console「分流台」純函式層(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08 總管派工; * 頂層 SDD kb-ingest-architecture R7 消費端) * * 為什麼獨立成 lib(比照 console-dashboard-model.ts):route 只做 IO(fetch KBDB entries), * 「兩種 entry 契約 → 統一分流項」的解析/分欄/分項判定全在這裡,vitest 直接餵資料驗證。 * * 資料來源二合一(R7): * - entry_type=todo:Logseq 萃取灌入(Arcrun#8 ingest 線),content JSON 契約(已定)= * {"text","marker","owner_tier":"ai|collab|leo","project","source","status":"new|done"} * - entry_type=inbox:Telegram 指令(inbox_receive),沿用 {"text","from","status"} * * 三欄分流(80/15/5,北極星「減 leo 負」): * ai = AI 會幫我搞定(80%) * collab = AI 要我做完它接手(15%) * leo = 非 leo 不可(5%) * 無 owner_tier / 看不懂的值(triage engine 還沒判到)→ 歸 collab 欄但標 unclassified, * UI 顯「未分流」徽章——不冒領「AI 會搞定」、也不誤判成「非 leo 不可」,且欄位維持三欄 * 不另開第四欄(80/15/5 的故事不破)。issue 原文的 mira80/collab15/leo5 詞彙當同義吃進來 * (ingest 端若照 issue 舊詞灌,UI 不至於整欄蒸發)。 */ import { type KbdbEntry, parseCreatedAtMs, parseJsonContent } from './console-dashboard-model'; export type TriageTier = 'ai' | 'collab' | 'leo'; /** 契約值 → 三欄。issue 原文舊詞(mira80/collab15/leo5)當同義收;其餘=未分流。 */ const TIER_ALIASES: Record = { ai: 'ai', collab: 'collab', leo: 'leo', mira80: 'ai', collab15: 'collab', leo5: 'leo', }; export interface TriageItem { id: string; text: string; tier: TriageTier; /** true=owner_tier 缺席/看不懂(triage engine 未判),暫歸 collab 欄+UI 標「未分流」 */ unclassified: boolean; /** '' = 未分項(UI 過濾 chips 用「未分項」承接) */ project: string; /** todo 來源=content.source(如 notes);inbox 一律 telegram(可帶 from) */ source: string; /** Logseq marker(TODO/DOING/LATER/NOW…);inbox 無='' */ marker: string; status: 'new' | 'done'; origin: 'todo' | 'inbox'; at: string | number; at_ms: number | null; } export interface TriageModel { /** 全部項(created_at 新→舊);分欄/過濾在 UI 端做(資料一次給齊,chips 切換零請求) */ items: TriageItem[]; /** 出現過的 project(去重、字典序;不含 ''——「未分項」由 UI 依 items 判斷要不要出 chip) */ projects: string[]; /** 各欄未完成數(status=new)+整體計數,供欄位標頭/收件計數用 */ counts: { ai: number; collab: number; leo: number; unclassified: number; done: number; total: number }; } function normalizeStatus(v: unknown): 'new' | 'done' { return v === 'done' ? 'done' : 'new'; } function normalizeTier(v: unknown): { tier: TriageTier; unclassified: boolean } { const mapped = typeof v === 'string' ? TIER_ALIASES[v.trim()] : undefined; return mapped ? { tier: mapped, unclassified: false } : { tier: 'collab', unclassified: true }; } function str(v: unknown): string { return typeof v === 'string' ? v.trim() : ''; } /** todo entry → 分流項。content 非 JSON(不合契約的殘資料)→ 原文當 text 誠實顯示,不丟棄。 */ export function todoToTriageItem(e: KbdbEntry): TriageItem { const j = parseJsonContent(e); const { tier, unclassified } = normalizeTier(j?.owner_tier); return { id: e.id, text: str(j?.text) || (e.content ?? ''), tier, unclassified, project: str(j?.project), source: str(j?.source), marker: str(j?.marker), status: normalizeStatus(j?.status), origin: 'todo', at: e.created_at, at_ms: parseCreatedAtMs(e.created_at), }; } /** inbox entry → 分流項(沿用 {"text","from","status"} 契約)。無 tier =未分流歸 collab。 */ export function inboxToTriageItem(e: KbdbEntry): TriageItem { const j = parseJsonContent(e); const from = str(j?.from); return { id: e.id, text: str(j?.text) || (e.content ?? ''), tier: 'collab', unclassified: true, project: '', source: from ? `telegram・${from}` : 'telegram', marker: '', status: normalizeStatus(j?.status), origin: 'inbox', at: e.created_at, at_ms: parseCreatedAtMs(e.created_at), }; } export type TriageCheckAction = 'check' | 'restore'; /** * console 勾掉/還原 → 新 content JSON 字串(純函式;route 先 GET 原 entry 再整串回寫, * 防 PATCH 蓋掉別的欄位)。 * * ── 雙向銷案語意(leo 2026-07-08 拍板,死循環防呆——萃取端會配合這套規約)── * 1. console 勾掉=**終局**:status:done + checked_via:"console" + checked_at。 * 即使 Logseq 原文還是 TODO,萃取端(Arcrun#8 ingest 線)看到 checked_via:console * 也**絕不復活它**(不改回 new、不重灌覆蓋)——否則「console 勾掉→下輪萃取又出現」 * 的死循環。 * 2. Logseq 那邊改 DONE 的,萃取端來 PATCH status:done + checked_via:"logseq"—— * 同一個 status slot、兩個來源靠 checked_via 溯源,console 只需忠實顯示非 done 項。 * 3. 還原(誤勾救濟)=status 回 new、**移除 checked_via/checked_at**(回到「未銷案」 * 的乾淨狀態,讓兩邊規約重新適用;不留殘欄位讓萃取端誤讀成已銷案)。 * * 防禦:content 非 JSON(不合契約殘資料,與 todoToTriageItem 同一情境)→ 包成 * {"text":原文} 再上狀態欄——原文不丟、從此畢業成契約 JSON(status 才有地方放)。 */ export function applyTriageCheck(content: string | null | undefined, action: TriageCheckAction, nowIso: string): string { let obj: Record; try { const v = JSON.parse(content ?? ''); obj = v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : { text: content ?? '' }; } catch { obj = { text: content ?? '' }; } if (action === 'restore') { const { checked_via: _via, checked_at: _at, ...rest } = obj; return JSON.stringify({ ...rest, status: 'new' }); } return JSON.stringify({ ...obj, status: 'done', checked_via: 'console', checked_at: nowIso }); } /** 二源合一 → 分流台模型。排序=created_at 新→舊(時間不明沉底);分欄計數只算未完成。 */ export function buildTriageModel(todoEntries: KbdbEntry[], inboxEntries: KbdbEntry[]): TriageModel { const items = [ ...todoEntries.map(todoToTriageItem), ...inboxEntries.map(inboxToTriageItem), ].sort((a, b) => (b.at_ms ?? -1) - (a.at_ms ?? -1)); const projects = [...new Set(items.map((i) => i.project).filter(Boolean))].sort(); const counts = { ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: items.length }; for (const it of items) { if (it.status === 'done') { counts.done++; continue; } counts[it.tier]++; if (it.unclassified) counts.unclassified++; } return { items, projects, counts }; }