This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/apps/api/src/modules/memory/internal/domain/task-log.ts
T
xcsandClaude Opus 5 2d20202b1d fix(memory): 配方留痕恢复 internal,工作日志按 detail.auto 另开例外
`internal` 是 `machinery()`(工作日志)与 `chat-projection`(对话区)**共用的一个旗**。
上一轮为了让配方留痕进工作日志,把 project / analyze 那两笔的 `internal` 整个去掉了 ——
等于同时对两个投影开门:「芯片主数据:写下 2 行」「已把 8 条参数交给模型逐条判定」
这类机械痕一笔一个气泡跑进了对话区,而这个仓花了很大代价立起
「对话区一轮一句,不是一拍一句」「引擎内部观察进日志不进对话」。

治法与 `detail.say` 同款(控制端裁定 F2):配方那几笔照旧标 `internal`(对话区据此挡住),
`machinery()` 这一侧给 `typeof detail.auto === 'string'` 开一道例外 ——
**两件事各有各的开关**。`say` 与「办不下去」那两笔本来就该进对话区,保持不标 internal。

既有那几条「留痕真的走到工作日志」的用例全绿(它们现在喂的正是 internal:true 的形状,
恰好验这条新例外);另补 8 条:auto-runner 侧断言四类痕各自标不标 internal、
chat-projection 侧断言机械痕一个气泡不冒 / say 与失败照进、
task-log 侧断言例外只认结构化的 auto 键(不是字符串就不算)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
2026-08-29 08:36:09 +08:00

585 lines
27 KiB
TypeScript

/**
* 任务日志:任务记忆的两级投影 —— 纯函数,零 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<EntryKind> = new Set<EntryKind>([
'trigger',
'observation',
'engine-transition',
'card-sent',
'card-reply',
'narration',
'ledger-write',
'action-intent',
'release-verdict',
'terminal',
'human-input',
'control',
]);
/**
* 内容取原文 —— **不加前缀**。
*
* 从前每种各挂一个前缀(`发出:` `回传:` `写入:` `终态:` …),那是在用前缀
* 兼职「这是什么动作」。现在动作有了自己的一格(`action`),前缀就成了重复:
* 「[数据录入] 发出:这是位新客户…」——「发出」两个字白占地方。
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
*/
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => 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<Record<string, string>> = {
elicitation: '请你补全',
decision: '需要你定',
entry: '数据录入',
rating: '任务评价',
execution: '派出工单',
acceptance: '请你验收',
};
/** 人回传:一律「员工 X X」句式 —— 点确认那一下就是**员工确认**(用户定) */
const REPLY_ACTION: Readonly<Record<string, string>> = {
entry: '员工确认',
elicitation: '员工补全',
decision: '员工决策',
rating: '员工评价',
execution: '员工办结',
acceptance: '放行裁决',
};
/** 叙述的三种签,与对话区 `TAG_UI` / `SAY_UI` 同词 */
const NARRATION_ACTION: Readonly<Record<string, string>> = {
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<RegExp> = [/^落地收敛[::]\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<Record<string, string>> = {
execution: '执行工单',
acceptance: '验收工单',
decision: '决策卡',
entry: '数据录入卡',
rating: '评价卡',
};
/** 送到哪儿了 —— 同上,内部路由名换人话 */
const ARRIVAL_LABEL: Readonly<Record<string, string>> = {
'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<string, unknown>)
.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<AttributionSide, LogLine[]> {
const out: Record<AttributionSide, LogLine[]> = {
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,
};
}