/** * 任务日志:任务记忆的两级投影 —— 纯函数,零 IO(编码规范 §5.3;语义出处 OS 8.1)。 * * 「做事与留痕是同一个动作,不存在补写日志」—— * 结构化日志(零 LLM)以引擎事件流为骨架,按事件类型筛选、模板措辞; * **任何时刻可重算,口径与源恒一致**;归侧标签使其天然分栏。 * 叙述性小结(LLM,可选)是视图不是事实,不入事实库 —— 由 usecase 经系统用途模型生成。 */ import type { AttributionSide } from '@contracts'; import { ATTRIBUTION_SIDE_LABEL } from '@contracts'; import type { EntryKind, MemoryEntry } from './task-memory.ts'; export type LogLine = { readonly id: string; readonly at: string; readonly side: AttributionSide; readonly sideLabel: string; /** * **这一条是谁干的** —— 日志只认三档(2026-08-02 用户定): * `owner` 负责人(绿)· `agent` 智能体(蓝)· `system` 系统(灰)。 * * 归侧 `side` 有五种(machine / human / human-verdict / world-wait / trigger), * 那是**度量**的分法(自主率要把「等外面」与「机器干的」分开); * 而人扫这一屏时只问一件事:**这一步是我办的、AI 办的,还是流程自己走到的**。 * 拿五种去上色,屏上就是五种深浅,而那五种对读的人不构成区别。 */ readonly who: 'owner' | 'agent' | 'system'; /** * **类型章:四字短语**,与对话区同词同粒度(2026-07-31 用户定)。 * * 从前这一格摆的是归侧(机器产出 / 系统等待 …)—— 那是**度量的口径**, * 答的是「这一步该算谁的工时」;人翻日志想知道的是「这一步是什么动作」。 * 两者不是一回事:同一个「系统等待」下面既有派工单也有发录入卡。 * 归侧仍在(`side` / `sideLabel`),度量与配色照旧靠它,只是不再占着这一格。 */ readonly action: string; /** 主语:任务助手 / 系统;人做的那些留空,由上层照 `actorId` 换成名字 */ readonly actor: string; /** 人做的那一条是谁做的 —— 域里不认识人名(纯函数零 IO),只交出 id */ readonly actorId: string | undefined; /** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */ readonly title: string; readonly summary: string | undefined; /** * **展开才看的细节 —— 一段话说完,不摆二级条目**(2026-08-03 用户两次定调: * 先是「细节展开不够好」,再是「不要写二级条目,直接正文描述完」)。 * * 从前这一屏一条只有一行标题:凭什么算这一步办完了、那一笔写了哪几行、 * 行动被拒到底拒在哪儿 —— 全躺在留痕的 `detail` 里,一个字都没露面。 * 中间试过摘成「键:值」两栏,那是**把留痕的字段名搬到人面前**, * 人还得自己在脑子里把它们连成句子。 * * 所以这里出的是**一句句人话**:每条自带主语与语境,单独拎出来也读得通 * (与 `title` 同一条纪律:不摆系统内的英文名、认不出的宁可不摆)。 * 说得完就一句,真有两三样要说的就两三句 —— 摘不出就是空数组。 */ readonly detail: readonly string[]; readonly eventRef: string | undefined; readonly position: string | undefined; }; /** * 骨架:哪些种类进结构化日志(OS 8.1 列举)。 * * **工作日志是给人看的**(2026-07-31 用户定)——「装配技能 xxx@2.0.0」 * 「装载案例模型(6 个计划项)」这类是引擎自己的机械动作,人看了不知道该拿它怎么办; * 工具调用的过程同理:**人要的是产出结果,不是调用细节**。 * 所以骨架之外还有一道 `machinery()` 过滤(见下),两道一起才干净。 * * `observation` 是**新进来的**:系统事件(一封信触发了这条流、邮件确认、 * 人把来件标了已读)本来一条都不在日志里 —— 而那恰恰是人最想回看的 * 「这单怎么起来的、谁在什么时候看过」。 * * `action` **整类退出**(2026-07-31 第二轮,用户定)。它只有三个来源,全是机制: * 调工具、写草稿、技能结晶。「写入草稿:quote_intake.record 3 行(3 行有变化)」 * 对人说不出任何事 —— 写了什么在工作成果与台账里看得见,「几行有变化」是引擎的账。 * 从前只滤掉带 `tool` 的那一半,留着写草稿的另一半,理由是「它本身就是产出」; * 可产出是**台账里那几行**,不是「我写了一笔」这个动作。 * 「为什么不行」不在这一类里:行动被拒、提交被拒、换画面失败都记在 `result`,照进。 */ const SKELETON: ReadonlySet = new Set([ 'trigger', 'observation', 'engine-transition', 'card-sent', 'card-reply', 'narration', 'ledger-write', 'action-intent', 'release-verdict', 'terminal', 'human-input', 'control', ]); /** * 内容取原文 —— **不加前缀**。 * * 从前每种各挂一个前缀(`发出:` `回传:` `写入:` `终态:` …),那是在用前缀 * 兼职「这是什么动作」。现在动作有了自己的一格(`action`),前缀就成了重复: * 「[数据录入] 发出:这是位新客户…」——「发出」两个字白占地方。 * 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。 */ const TEMPLATE: Readonly string>> = { // 带交代的内部痕:标题摆交代的**首句**(原标题是「这一拍在往下办事…」那句机器话; // 全文归抽屉 —— 一行摆全文会把这一屏撑成散文集,而这一屏的用法是扫) observation: (e) => (typeof e.detail?.['say'] === 'string' ? firstSentence(e.detail['say']) : e.title), thought: (e) => e.title, action: (e) => actionEntryOf(e).body, result: (e) => e.title, 'engine-transition': (e) => plain(transitionOf(e.title).body), evaluation: (e) => e.title, 'card-sent': (e) => e.title, 'card-reply': (e) => e.title, 'ledger-write': (e) => plain(e.title), 'action-intent': (e) => e.title, 'release-verdict': (e) => plain(e.title), terminal: (e) => plain(e.title), 'human-input': (e) => e.title, // 交代一律「首句上行、全文进抽屉」—— 进没进对话区都一样,这一屏的用法是扫 narration: (e) => firstSentence(e.title), control: (e) => e.title, trigger: (e) => e.title, }; /** * 四字动作 —— **对话区说什么词,这里就是什么词**。 * * 对齐的出处是 `chat-bubbles.tsx` 的三张表:AI 的类型章(`TAG_UI`)、 * 系统气泡的章(`SYS_TAG`)、各卡卡头的类型名。同一件事在两屏用两个说法, * 人得在脑子里做一次翻译 —— 而这本来是同一件事。 */ const CARD_ACTION: Readonly> = { elicitation: '请你补全', decision: '需要你定', entry: '数据录入', rating: '任务评价', execution: '派出工单', acceptance: '请你验收', }; /** 人回传:一律「员工 X X」句式 —— 点确认那一下就是**员工确认**(用户定) */ const REPLY_ACTION: Readonly> = { entry: '员工确认', elicitation: '员工补全', decision: '员工决策', rating: '员工评价', execution: '员工办结', acceptance: '放行裁决', }; /** 叙述的三种签,与对话区 `TAG_UI` / `SAY_UI` 同词 */ const NARRATION_ACTION: Readonly> = { conclusion: '阶段结论', guidance: '交代进展', criterion: '请你验收', }; /** * **日志里不许出现系统内的英文名**(2026-08-02 用户定)。 * * 工作日志是给人看的。而引擎留痕时顺手把内部标识写了进去: * `manualStart:S3_confirm`、`终态:released`、`发射输出口:intake、to-alternative`—— * 那是给排障的人看的机器话,摆在人的日志上等于没写。 * 这里逐条换成人话;认不出的原样留着(宁可笼统,不可编)。 */ const PLAIN: ReadonlyArray<{ readonly re: RegExp; readonly to: string }> = [ /* * **计划项号对人是天书**:`manualStart:S3_confirm`、`开始办:T_release` —— * 那一格里唯一的信息是「引擎开始办某一步了」,而哪一步下一行的「完成一项」会说清。 * 与其摆一个 id,不如只说这件事本身。 */ // 老留痕里还有 `manualStart:S3_confirm` 这种(新的已经写名字了)—— 认不出名字就只说这件事 { re: /^manualStart[::]\s*[A-Za-z0-9_]+$/, to: '开始办这一步' }, { re: /^manualStart[::]\s*/, to: '开始办:' }, // 去向那一条:口名与判据表达式都是给排障看的;人要知道的是「转到哪儿」 { re: /^去向已算出[::].*→\s*([a-z-]+).*$/, to: '去向已算出,随放行转入下游' }, // 终态有两种写法:带「终态:」前缀的,与裸状态名 —— 两种都得认,漏一种就露一个英文 { re: /^(终态[::]\s*)?released$/, to: '这一单已放行完成' }, { re: /^(终态[::]\s*)?blocked$/, to: '这一单卡住了,没能落地' }, { re: /^(终态[::]\s*)?rejected$/, to: '这一单被退回了' }, { re: /^(终态[::]\s*)?exhausted$/, to: '这一单预算用尽,停下了' }, { re: /^裁决[::]\s*pass$/, to: '验收通过' }, { re: /^裁决[::]\s*redo$/, to: '验收退回重做' }, { re: /^放行写合并\s*/, to: '成果落账,共 ' }, { re: /^人工修订[::]\s*/, to: '你改的那一笔已落账:' }, ]; /** 把一行里的系统名换成人话 —— 认不出就原样留着 */ function plain(title: string): string { for (const p of PLAIN) if (p.re.test(title)) return title.replace(p.re, p.to); return title; } /** * 引擎流转的措辞由引擎自己拼(`起单:xxx` / `完成:xxx` …)。 * 认出前缀是为了两件事:动作那一格说得具体,以及**把前缀从内容里摘掉** —— * 章上已经写着「任务起单」,内容再来一遍「起单:」就是同一句话说两遍。 * 认不出的一律「状态推进」,内容原样留着:宁可笼统,不可编。 */ const TRANSITION: ReadonlyArray<{ readonly prefix: string; readonly action: string }> = [ { prefix: '起单:', action: '任务起单' }, { prefix: '完成:', action: '完成一项' }, { prefix: '自动收步:', action: '自动收步' }, { prefix: '立监听:', action: '立起监听' }, { prefix: '驳回重入:', action: '驳回重入' }, ]; function transitionOf(title: string): { action: string; body: string } { for (const t of TRANSITION) { if (title.startsWith(t.prefix)) return { action: t.action, body: title.slice(t.prefix.length) }; } return { action: '状态推进', body: title }; } /** action 条目有两种:写草稿与调工具 —— 靠 detail 上的 `ledger` 记号分,不靠措辞 */ function actionEntryOf(e: MemoryEntry): { action: string; body: string } { if (e.detail?.['ledger'] === undefined) return { action: '调用工具', body: e.title }; const p = '写草稿:'; return { action: '写入草稿', body: e.title.startsWith(p) ? e.title.slice(p.length) : e.title }; } function actionOf(e: MemoryEntry): string { const d = e.detail ?? {}; const cardKey = String(d['k'] ?? d['mode'] ?? ''); switch (e.kind) { case 'trigger': return '触发事件'; case 'observation': // 带交代的内部痕 = AI 这一拍的交代(没进对话区),章与对话区同词 return typeof d['say'] === 'string' ? '交代进展' : '系统事件'; case 'engine-transition': return transitionOf(e.title).action; case 'action': return actionEntryOf(e).action; case 'narration': return NARRATION_ACTION[String(d['tag'] ?? '')] ?? '阶段结论'; case 'card-sent': return CARD_ACTION[cardKey] ?? '发出卡片'; case 'card-reply': return REPLY_ACTION[cardKey] ?? '员工回传'; // 人在界面上的动作:点确认 = 员工确认,看来件 = 员工阅件 case 'human-input': return d['act'] === 'read-comm' ? '员工阅件' : '员工确认'; case 'ledger-write': return '写入台账'; case 'result': return '工具产出'; case 'action-intent': return '动作意向'; case 'release-verdict': return '放行裁决'; case 'terminal': return '任务收尾'; case 'control': return '治理干预'; case 'thought': return '模型思考'; case 'evaluation': return '判据求值'; } } /** * 主语 —— 日志每一行都要有主语,不然是一串没头脑的流水账。 * * 三种:**任务助手**(AI 干的)、**系统**(基座干的:起单、事件到达、收尾)、 * 以及**具体某个人**。人名要查库,域里查不了,所以这里只交出 `actorId`, * 由上层换成名字;换不到就退回「员工」,不编名字。 */ function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; who: 'owner' | 'agent' | 'system' } { /* * **配方做的事归系统**(2026-08-29,ADR-014)。自主率的分法要认第三档: * 它既不是 AI 判出来的,也不是人办的 —— 是系统按声明做的。 * 不加这一条的话,`kind: 'result'` 会掉进 `default` 分支被记在「任务助手」名下, * 日志上看着像 AI 干的 —— **界面不撒谎**。 */ if (typeof e.detail?.['auto'] === 'string') return { actor: '系统', actorId: undefined, who: 'system' as const }; const by = e.detail?.['by']; const id = typeof by === 'string' && by !== '' ? by : undefined; switch (e.kind) { case 'observation': // 带交代的那种是 **AI 说的话**,记在它名下;其余系统事件照旧署系统 return typeof e.detail?.['say'] === 'string' ? { actor: '任务助手', actorId: undefined, who: 'agent' as const } : { actor: '系统', actorId: undefined, who: 'system' as const }; case 'trigger': case 'terminal': return { actor: '系统', actorId: undefined, who: 'system' }; /* * **AI 办完的事要记在 AI 名下**(2026-08-02 用户:日志「要能看出人和 AI 分别干了啥」)。 * * 引擎流转从前一律署「系统」,可这一类里混着两种事: * · 「完成一项」「开始办」——**AI 报的**(它 finish / start,引擎照办); * · 「起单」「自动收步」「驳回重入」—— 引擎自己做主的。 * 全署系统,人就看不出这一单里 AI 到底干了什么、自己干了什么 —— * 而那正是他翻这一屏想知道的事。留痕上带 `by: 'agent'` 的从这里认。 */ case 'engine-transition': return e.detail?.['by'] === 'agent' || /^(完成|开始办)/.test(e.title) ? { actor: '任务助手', actorId: undefined, who: 'agent' } : { actor: '系统', actorId: undefined, who: 'system' }; case 'card-reply': case 'human-input': case 'release-verdict': case 'control': return { actor: '', actorId: id, who: 'owner' }; /* * 台账写入有两种,主语不同:**放行合并**是基座在人签完字之后做的一次落账 * (记在 AI 名下会让人以为是它自作主张写了正式表);**人工修订**是人自己改的 * (留痕带上了他的 id)。认 id,不认措辞。 */ case 'ledger-write': return id === undefined ? { actor: '系统', actorId: undefined, who: 'system' } : { actor: '', actorId: id, who: 'owner' }; default: return { actor: '任务助手', actorId: undefined, who: 'agent' }; } } /** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */ /** * **机械动作** —— 进得了骨架,但人看了没有用处的那些。 * * 判据是「这一条能不能让人做出判断」: * · 装配技能 / 装载案例模型 —— 引擎每单必做,一模一样,说不出这一单的任何事; * · `internal` 标的观察(已在进行中 / 同拍余下作废)—— 引擎给自己看的踪迹; * · 人事件的到达 —— 一串卡号,而这件事上一行已经用人话说过了。 * * (动作那一整类 `action` 不在这儿滤 —— 它压根不进骨架,见上面 SKELETON。) * * 不用「白名单标题」那一路:标题措辞随时会改,改一次过滤就失灵而没人发现。 * 这里认的是 kind + detail 上的结构化标记,措辞怎么变都不影响。 */ /** * **同一件事的复述** —— 人签完字那一刻,基座与引擎各留了一条痕,说的是同一件事。 * * 实测一单的日志尾巴是这样的(用户 2026-08-02 提的「日志是给人看的」): * · 成果落账,共 10 行(试跑:另有 1 行只留在活动缓存) ← 放行写合并,**带得住实情** * · 成果已落账,交给下游 ← 发射输出口,同一件事 * · 成果落账,共 10 行,已交下游 ← 引擎流转,还是同一件事 * · 这一单已放行完成 ← 终态 * 四行里有三行在说「落账了」。留痕那一侧三条都该在(排障要分得清是谁做的), * 但**日志这一侧只该说一遍**:留合并那一条(它是唯一说得出「几行没进正式表」的), * 另外两条的信息它全包含。 */ const RESTATED: ReadonlyArray = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/]; function machinery(e: MemoryEntry): boolean { /* * **被压下的每拍交代要留在工作日志里**(2026-08-03 用户:「细节展开不够好」)。 * * 对话区一轮一句:只有交出控制权那一拍的交代摆到人面前,埋头办事那几拍的 * 「留进工作日志」—— 这是 2026-08-01 立的口径,快照里也是这么写的。 * 可实现是 `internal === true` 一律滤掉:那几句**哪儿都看不到**, * 白烧的 token 不说,人想回看「这一拍它在干什么」时一片空白。 * 现在带 `say` 的放行(它是人话,正是要留的那样东西); * 其余内部痕(重问原文这类给排障用的)照旧不摆。 */ /* * **`internal` 是两个投影共用的一个旗**(2026-08-29 终审 I5,控制端裁定 F2): * `machinery()` 拿它决定「进不进工作日志」,`chat-projection` 拿同一个键决定「进不进对话区」。 * 上一轮为了让配方留痕进工作日志,把它整个去掉了 —— 副作用是「芯片主数据:写下 2 行」 * 「已把 8 条参数交给模型逐条判定」这类**机械痕跑进了对话区**,一笔一个气泡, * 而这个仓花了很大代价立起「对话区一轮一句,不是一拍一句」。 * 治法与 `say` 同款:给 `detail.auto` 开一个例外 —— 配方那几笔照旧标 `internal` * (对话区据此挡住),工作日志这一侧按 `auto` 放行。**两件事各有各的开关。** */ if ( e.detail?.['internal'] === true && typeof e.detail?.['say'] !== 'string' && typeof e.detail?.['auto'] !== 'string' ) { return true; } if (RESTATED.some((re) => re.test(e.title))) return true; if (e.kind === 'thought') return true; if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true; /* * **人事件的到达**:对人它只是「事件到达(human):d455688a-…」一串卡号, * 而这件事上一行已经用人话说过了(「员工确认:写入 customer.profile」)。 * 同一次点击记两行,后一行还是 UUID —— 对话区早就滤掉了它,日志该同一口径。 * 数据/时间事件的到达留着:那些是外面来的,人确实要知道什么时候到的。 */ if (e.kind === 'observation' && e.detail?.['kind'] === 'human') return true; return false; } /** * 首句 —— 交代常有三四句,一行只摆得下第一句(其余进抽屉)。 * 断在句号/分号/换行,断不出就按字数截;**截了才加省略号**,没截不加 * (加了等于告诉人「后面还有」,而后面没有)。 */ export function firstSentence(text: string, cap = 42): string { const clean = text.trim().replace(/\s+/g, ' '); const cut = clean.search(/[。;;\n!!??]/); const head = cut > 0 && cut + 1 <= cap ? clean.slice(0, cut + 1) : clean.slice(0, cap); return head.length < clean.length ? `${head}…` : head; } /** 卡的种类:发的是哪一种人类工具 —— 摆人读名,不摆 `execution` 这类内部词 */ const CARD_KIND_LABEL: Readonly> = { execution: '执行工单', acceptance: '验收工单', decision: '决策卡', entry: '数据录入卡', rating: '评价卡', }; /** 送到哪儿了 —— 同上,内部路由名换人话 */ const ARRIVAL_LABEL: Readonly> = { 'task-chat': '这一单的对话区', inbox: '收件箱', 'cross-task': '跨任务征答', }; /** 值摆到日志上的样子:太长截断(细节是给人扫一眼的,不是把留痕抄一遍) */ function short(value: unknown, cap = 300): string { const text = typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value); const clean = (text ?? '').trim(); return clean.length > cap ? `${clean.slice(0, cap)}…` : clean; } /** 主键名单摘成一句:`PN=A、PN=B` —— 「写了哪几行」是人回看时最想知道的一件事 */ function keyList(keys: unknown): string | undefined { if (!Array.isArray(keys) || keys.length === 0) return undefined; const rows = keys.slice(0, 8).map((one) => typeof one === 'object' && one !== null ? Object.values(one as Record) .map((v) => String(v)) .join(' / ') : String(one), ); return keys.length > 8 ? `${rows.join('、')}…(共 ${keys.length} 行)` : rows.join('、'); } /** * **展开看的那几句** —— 按 kind 各摘各的,认不出的一概不摆。 * * 出的是**人话**不是字段:每句自带主语与语境(「发的是执行工单,送到这一单的对话区」), * 单拎出来也读得通。纪律三条:①不摆系统内的英文名(卡种/送达位置都换人话) * ②不把留痕整段抄上去(长值截断)③摘不出就返回空数组 —— * 前端据此不给展开钮,**没有细节就别摆一个空抽屉**。 */ export function detailOf(e: MemoryEntry): readonly string[] { const d = e.detail ?? {}; const out: string[] = []; const say = (text: string | undefined): void => { if (text !== undefined && text.trim() !== '') out.push(text.trim()); }; const has = (v: unknown): v is string | number | boolean => v !== undefined && v !== null && v !== '' && (typeof v === 'string' ? v.trim() !== '' : true); switch (e.kind) { case 'engine-transition': // 「凭什么算办完了」—— 收步那一下的理由,人回看时最想核的就是它 if (has(d['rationale'])) say(`算办完的依据:${short(d['rationale'], 400)}`); break; case 'result': // 被拒/失败:理由与下一步(拒回的话本来就是写给下一拍的) if (has(d['reason'] ?? d['error'])) say(`没通过的缘由:${short(d['reason'] ?? d['error'], 500)}`); if (has(d['hint'])) say(`接下来该:${short(d['hint'], 300)}`); break; case 'card-sent': { const kind = CARD_KIND_LABEL[String(d['mode'] ?? d['k'] ?? '')]; const arrival = ARRIVAL_LABEL[String(d['arrival'] ?? '')]; if (kind !== undefined) say(arrival === undefined ? `发的是${kind}。` : `发的是${kind},送到${arrival}。`); break; } case 'card-reply': if (has(d['summary'])) say(`他填的是:${short(d['summary'], 400)}`); if (has(d['value'] ?? d['chosen'])) say(`他选的是:${short(d['value'] ?? d['chosen'], 200)}`); break; case 'ledger-write': { if (has(d['from'])) say(`把${short(d['label'] ?? '这一格', 40)}从「${short(d['from'], 80)}」改成「${short(d['to'], 80)}」。`); const rows = keyList(d['keys']); if (rows !== undefined) say(`落下这几行:${rows}`); else if (has(d['rows'])) say(`落下 ${short(d['rows'])} 行。`); break; } case 'release-verdict': { const verdict = d['verdict']; if (verdict === 'pass') say('裁决:通过放行。'); else if (verdict === 'redo') say('裁决:打回重做。'); if (has(d['comment'])) say(`他的意见:${short(d['comment'], 300)}`); break; } case 'narration': { // 首句已经上行了,这儿摆全文;一句话说完的不重复摆(点开发现一模一样等于白点) const full = e.title.trim().replace(/\s+/g, ' '); if (firstSentence(e.title) !== full) say(short(e.title, 600)); say(d['internal'] === true ? '这句只留在工作日志,没进对话区 —— 那一拍没有交出控制权。' : '这句已进对话区。'); break; } case 'observation': { // 被压下的每拍交代:全文在这儿(标题只摆得下第一句) const text = d['say']; if (typeof text === 'string' && firstSentence(text) !== text.trim().replace(/\s+/g, ' ')) say(short(text, 600)); if (has(d['raw'])) say(`留档原文:${short(d['raw'], 400)}`); break; } default: break; } return out; } export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] { return entries .filter((e) => SKELETON.has(e.kind) && !machinery(e)) .map((e) => ({ id: e.id, at: e.at, side: e.side, sideLabel: ATTRIBUTION_SIDE_LABEL[e.side], action: actionOf(e), ...actorOf(e), title: TEMPLATE[e.kind](e), summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined, detail: detailOf(e), eventRef: e.eventRef, position: e.stamp.position, })); } /** 归侧标签使日志天然分栏:机器做的 / 人做的 / 人裁的 / 在等谁 */ export function splitBySide(lines: readonly LogLine[]): Record { const out: Record = { machine: [], human: [], 'human-verdict': [], 'world-wait': [], trigger: [], }; for (const line of lines) out[line.side].push(line); return out; } /* ── 度量八指标(OS 8.3):只读投影,零埋点,统计即查询 ─────────────── */ export type Metrics = { /** 自主率 = 机器产出 ÷(机器产出 + 员工动作);裁决与等待不进分母 */ readonly autonomy: number; /** 纠正率 = 被纠正批次 ÷ 提交批次 */ readonly correction: number; /** 有效自主率 = 自主率 ×(1 − 纠正率) */ readonly effectiveAutonomy: number; /** 打扰率 = 每单任务调用人类工具的次数 */ readonly interruption: number; /** DMN 覆盖率 = 确定性求值占判断次数的比例 */ readonly dmnCoverage: number; }; export type MetricsInput = { readonly entries: readonly MemoryEntry[]; /** 提交批次数:每次放行提交 = 一个产出批次 */ readonly submissions: number; /** 被纠正批次:驳回重做 / 驳回迭代 / 叫停 / 裁决推翻 */ readonly corrections: number; readonly taskCount: number; /** 判断次数中由 DMN / 机检确定性求值的次数 */ readonly deterministicJudgements: number; readonly totalJudgements: number; }; export function computeMetrics(input: MetricsInput): Metrics { const machine = input.entries.filter((e) => e.side === 'machine').length; const human = input.entries.filter((e) => e.side === 'human').length; const denominator = machine + human; const autonomy = denominator === 0 ? 0 : machine / denominator; const correction = input.submissions === 0 ? 0 : input.corrections / input.submissions; const cards = input.entries.filter((e) => e.kind === 'card-sent').length; return { autonomy, correction, effectiveAutonomy: autonomy * (1 - correction), interruption: input.taskCount === 0 ? 0 : cards / input.taskCount, dmnCoverage: input.totalJudgements === 0 ? 0 : input.deterministicJudgements / input.totalJudgements, }; }