e90688c608
leo 2026-07-08 拍板,PR#41 分流台的後續:
- lib/console-triage-model.ts:applyTriageCheck 純函式(check→status:done+
checked_via:console+checked_at;restore→status:new+移除 checked_via/checked_at;
content 非 JSON 殘資料包成 {"text":原文} 不丟字)。雙向銷案語意寫死在註解:
console 勾掉=終局,即使 Logseq 原文還是 TODO 萃取端也絕不復活;Logseq 改 DONE
由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。
- console-dashboard.ts:POST /console/triage-check(沿 triage-data 同款 session 驗證;
瀏覽器沒 KBDB token 故 server 端做 PATCH——先 GET 原 entry 整串回寫防蓋掉別的欄位;
守 owner_id=CONSOLE_TENANT 與 entry_type∈{todo,inbox} 兩道邊界)。
- console.ts:每筆待辦 44px 圓形勾掉鈕(手機好按)+done 清單「還原」鈕(誤勾救濟);
後端回寫成功才改本地狀態移出三欄(done 預設隱藏既有機制),失敗誠實 toast 可重試。
- 測試:applyTriageCheck 7 例(先紅後綠)+ route 層 8 例(fetchMock 假 host
kbdb.test + disableNetConnect,絕不外連;驗 session 閘/整串回寫/邊界/restore)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
7.1 KiB
TypeScript
167 lines
7.1 KiB
TypeScript
/**
|
||
* 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<string, TriageTier> = {
|
||
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<string, unknown>;
|
||
try {
|
||
const v = JSON.parse(content ?? '');
|
||
obj = v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : { 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 };
|
||
}
|