Files
Arcrun/cypher-executor/src/lib/console-triage-model.ts
T
Leo e90688c608 feat(console): 分流台勾掉/還原(雙向銷案:console 勾掉=終局 checked_via:console,萃取端絕不復活)
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>
2026-07-08 23:30:13 +08:00

167 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* console「分流台」純函式層(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08 總管派工;
* 頂層 SDD kb-ingest-architecture R7 消費端)
*
* 為什麼獨立成 lib(比照 console-dashboard-model.ts):route 只做 IOfetch KBDB entries),
* 「兩種 entry 契約 → 統一分流項」的解析/分欄/分項判定全在這裡,vitest 直接餵資料驗證。
*
* 資料來源二合一(R7):
* - entry_type=todoLogseq 萃取灌入(Arcrun#8 ingest 線),content JSON 契約(已定)=
* {"text","marker","owner_tier":"ai|collab|leo","project","source","status":"new|done"}
* - entry_type=inboxTelegram 指令(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;
/** trueowner_tier 缺席/看不懂(triage engine 未判),暫歸 collab 欄+UI 標「未分流」 */
unclassified: boolean;
/** '' = 未分項(UI 過濾 chips 用「未分項」承接) */
project: string;
/** todo 來源=content.source(如 notes);inbox 一律 telegram(可帶 from */
source: string;
/** Logseq markerTODO/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 };
}