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/web/src/features/gov-memory/internal/panel.tsx
T
ChaiHaoandClaude Opus 5 b2f5f69d6a 流程包去掉发布机制 —— 严格检查 + 保存即生效,生效由运行时决定
用户要的是运行时机制:新建 + 编辑,存下去就生效,旧任务走完旧版本,
新任务调用时才加载新编排。所以「草稿 → 发布」两态整套去掉。

## 闸从「发布」挪到「保存」

`savePackage` 现在先跑闭合校验,不闭合抛 `NotClosedError`(路由 412 带报告)——
**一个字节都不落库**。存进来的每一版都能起单,所以不需要「已发布」这个标记。
`publish` 用例、路由、`publishPackage` 存储方法一并删掉。

实测:把进 A6 的边映射清空后保存 → 412,逐条点名「边 e-g3-a6 未完成载荷映射;
节点「报价议价」的必填字段未映射:rows」。

## 保存恒新增版本号,服务端说了算

这不是可选项,是「旧任务走完旧版本」的前提:工作流按 (包id, 版本) 去读定义,
就地覆盖 v1 就等于改掉所有在途 v1 任务正在读的编排 —— 它们会中途换了规则却毫不知情。

版本号由用例层算(当前最大 + 1),**不采信入参**。实测:入参故意填 version: 999,
落的是 v2。store 层因此改成**只插不改**:撞键宁可让唯一约束报错,也不静默覆盖。

起单取版从「最新已发布」改为「最新一版」(retired 除外);触发器的 requiredInputs 同口径。
实测:改完描述存成 v2 后,新起的单用 v2,早先那单仍挂 v1。

## 迁移:旧状态值不归一就是一读就炸

状态枚举从 draft/published/retired 收成 active/retired。列上和 definition 这个 jsonb 里
各存着一份 state,两处都要改 —— 不改,`processPackageSchema.parse` 读一行就抛。
这不是开发期小麻烦,任何已部署实例都会中招。加了迁移 0012。

## 界面:恒为 0 的假指标撤掉,不换个说法留着

- 编排面板:「已发布/草稿」芯片 → 「生效中/历史版本」(真正的区别是哪一版会被新起单取到);
  发布按钮撤掉,保存按钮改成「保存并生效(存为 vN)」并在确认卡里说清影响面
- 历史版本只读,但就地指路:「去 v(N-1) 改(生效中)」,不让人自己找
- 蓝图面:「草稿 N · 未闭合 M」两个计数在新机制下**结构上恒为 0**(不闭合存不进来),
  换成「共 N 个版本,保存即生效」
- 驾驶舱:「未闭合草稿」卡整张撤掉 —— 同理恒为 0。天然的第三张是「死路」
  (route-dead-end 事件),但那需要一条按事件类型聚合的路由,没有之前不摆(同死信卡的规矩)

## 顺手修:种子的幂等判断本来是坏的

比对用 `JSON.stringify` 认键序,而 jsonb 从 PG 读回来键是重排过的 —— 永远判不等,
每播一次种就多存一版一模一样的定义(实测一口气堆到 v3)。改成递归按键名排序后比对。
连播三次,只第一次存。

## 契约变更(破坏性,一步到位)

`publish` 删除、`NotPublishableError` → `NotClosedError`、`publishable` → `closed`、
状态枚举收窄。按规矩破坏性变更该走三步弃用 —— 这里一步改完,因为:
① depcruise 反查下游全在仓内(374 模块无违规),同批改完;
② 要弃用的是一个**产品决定不再存在**的机制,留弃用窗口没有意义。
CONTRACT.md 已同步,快照单独说明。

typecheck ✓ lint ✓ depcruise 374 模块 ✓ 495 测试 ✓

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
2026-07-28 12:49:24 +08:00

245 lines
9.7 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.
/**
* feature `gov-memory` —— 运营中心的两处:**经营驾驶舱**(度量八指标)与**知识库**(活动记忆四层)。
*
* 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移:
* DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。
*
* 原本这两件同挂在「记忆管理」一区,是两种心智硬挤在一起:
* 看指标问的是「今天跑得怎样」,管沉淀问的是「学到了什么、放不放行」。三面拆分后各自成页。
*/
import { useState } from 'react';
import { TERMINAL_STATE_LABEL } from '@contracts';
import type { TerminalState } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Chip, Clear, Kpi, NList, NRow, Note, PageHead, QueryGate, Todo } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import {
useGovActivities,
useGovMemory,
useGovMetrics,
useGovTasks,
} from '@web-shared/gov/gov-api.ts';
export type HomeProps = { tab: string; nav: Nav };
const pct = (v: number | undefined) => Math.round((v ?? 0) * 100);
/** 终态五名的呈现色 —— 放行绿、驳回琥珀、停滞灰、耗尽红、终止墨,与状态章同族 */
const TERMINAL_TONE: Readonly<Record<string, string>> = {
released: 'var(--ok-accent)',
blocked: 'var(--warn-accent)',
stalled: '#8f959e',
exhausted: 'var(--danger-accent)',
terminated: '#41464e',
};
/* ---------------- 经营驾驶舱(运营首页) ---------------- */
/**
* 看企业今天跑得怎样 —— 先看**要不要管**(异常三卡),再看**跑得好不好**(度量八指标)。
* 异常在上:开屏第一眼该回答的是「有没有事要我处置」,不是「自主率几个点」。
*/
export function CockpitHome({ nav }: HomeProps) {
const metrics = useGovMetrics();
const tasks = useGovTasks();
const m = metrics.data;
const workflows = tasks.data?.workflows ?? [];
const taskRows = tasks.data?.tasks ?? [];
const abnormal = workflows.filter((w) => w.state === 'abnormal').length;
const running = taskRows.filter((t) => t.terminal === undefined).length;
const mix = m?.terminalMix ?? {};
const mixTop = Object.entries(mix)
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1]);
return (
<QueryGate query={metrics}>
<>
<div className="g-kpis">
<Kpi
ico="alert-triangle"
tone="danger"
label="异常工作流"
value={abnormal}
sub="失败走向未消化,待人工处置"
/>
{/*
* 「未闭合草稿」这张卡撤了 —— 严格检查 + 保存即生效之后,不闭合的包**存不进来**,
* 这个数结构上恒为 0。摆一个永远是 0 的卡位就是在占地方装样子。
* 天然的第三张是「死路」(网关一条边都判不中而卡住的流,已有 route-dead-end 事件),
* 但那需要一条按事件类型聚合的查询路由 —— 没有之前不摆(同死信卡的规矩)。
*/}
<Kpi ico="activity" tone="brand" label="在途任务" value={running} sub="尚未落终态的任务" />
</div>
{abnormal > 0 ? (
<div className="g-todo">
<Todo
tone="danger"
ico="alert-triangle"
title={`${abnormal} 条工作流异常`}
desc="失败走向未消化 —— 去需处置逐条看它卡在哪个节点、要不要改派或叫停。"
acts={[{ label: '去处置', ico: 'flag', onClick: () => nav.jump('tasks', 'todo') }]}
/>
</div>
) : (
<Clear>没有异常工作流 —— 在途的都还在正常走。</Clear>
)}
<div className="g-kpis">
<Kpi
ico="bot"
tone="auto"
label="自主率"
value={pct(m?.autonomy)}
unit="%"
dial={pct(m?.autonomy)}
sub="机器产出 ÷(机器 + 人)"
/>
<Kpi
ico="rotate-ccw"
tone="warn"
label="纠正率"
value={pct(m?.correction)}
unit="%"
dial={pct(m?.correction)}
sub="被纠正批次 ÷ 提交批次"
/>
<Kpi
ico="gauge"
tone="brand"
label="有效自主率"
value={pct(m?.effectiveAutonomy)}
unit="%"
dial={pct(m?.effectiveAutonomy)}
sub="自主率 ×(1 − 纠正率)"
/>
<Kpi ico="hand" label="打扰率" value={(m?.interruption ?? 0).toFixed(1)} sub="每单任务调用人类工具次数" />
</div>
<div className="g-kpis">
<Kpi ico="unplug" tone="danger" label="断点率" value={pct(m?.breakpoint)} unit="%" sub="当下可为 ∩ 目标所需 = 空" />
<Kpi ico="git-commit" tone="ok" label="DMN 覆盖率" value={pct(m?.dmnCoverage)} unit="%" sub="确定性求值占判断次数" />
<Kpi
ico="coins"
label="单位成本"
value={Math.round(m?.unitCost.tokens ?? 0)}
unit="tok"
sub={`每放行一单:${(m?.unitCost.minutes ?? 0).toFixed(1)} 分钟 · 调人 ${(m?.unitCost.humanCalls ?? 0).toFixed(1)} 次`}
/>
{/* 终态分布:比例即信息 —— 五终态五色一条,不把五个百分比塞进一行小字 */}
<div className="g-kpi">
<span className="g-kpi-l">
<Ico name="flag" s={12} />
终态分布
</span>
{mixTop.length === 0 ? (
<>
<span className="g-kpi-v">—</span>
<span className="g-kpi-s">还没有任务落终态</span>
</>
) : (
<>
<span className="g-mixbar">
{mixTop.map(([k, v]) => (
<i key={k} style={{ width: `${pct(v)}%`, background: TERMINAL_TONE[k] ?? '#c9d2dd' }} />
))}
</span>
<span className="g-mixbar-legend">
{mixTop.map(([k, v]) => (
<span key={k}>
<i style={{ background: TERMINAL_TONE[k] ?? '#c9d2dd' }} />
{TERMINAL_STATE_LABEL[k as TerminalState] ?? k} <b>{pct(v)}%</b>
</span>
))}
</span>
</>
)}
</div>
</div>
<Note>
自主率与纠正率<b>成对读</b>,复合为有效自主率——单看自主率会奖励「猛冲后返工」。度量只是仪表,不载权力。
八指标与工作台的汇报<b>同源同口径</b>,统计窗口:近一{m?.period === 'day' ? '日' : m?.period === 'week' ? '周' : m?.period === 'year' ? '年' : '月'}。
</Note>
</>
</QueryGate>
);
}
/* ---------------- 知识库 ---------------- */
/** 活动记忆四层 + 候选增量的放行口 —— 记忆的终点是成为模型,那道门在这里 */
export function KnowledgeHome({ nav }: HomeProps) {
const activities = useGovActivities();
const [picked, setPicked] = useState<string | undefined>(undefined);
const memory = useGovMemory(picked);
return (
<>
<Note>
活动记忆四层:<b>事实层 / 模式层 / 裁决层 / 例外层</b>。写入唯蒸馏,条目必挂证据链;
记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。
</Note>
<div className="g-picker">
{(activities.data?.activities ?? []).map((a) => (
<button
key={a.activityId}
className={'g-pick' + (picked === a.activityId ? ' on' : '')}
onClick={() => setPicked(a.activityId)}
>
{a.name}
</button>
))}
</div>
<NList>
{(memory.data?.entries ?? []).map((e) => (
<NRow
key={e.id}
tone={e.incrementState === undefined ? 'brand' : 'warn'}
ico="layers"
title={e.statement}
sub={`${e.layer} · ${e.createdAt.slice(0, 16).replace('T', ' ')}`}
cols={[{ k: '证据', v: e.evidenceCount }]}
right={
e.incrementState !== undefined ? (
<Chip tone="warn">候选 · {e.incrementState}</Chip>
) : (
<Chip tone="ok">已成条目</Chip>
)
}
onClick={() => nav.push({ k: 'mem', id: e.id })}
/>
))}
{picked === undefined && <Clear>选一个活动,看它这些年学到了什么。</Clear>}
{picked !== undefined && (memory.data?.entries ?? []).length === 0 && (
<Clear>这个活动还没有记忆条目 —— 任务落终态封卷、蒸馏钩子跑过之后,沉淀会出现在这里。</Clear>
)}
</NList>
</>
);
}
/* ---------------- 记忆条目详情 ---------------- */
export function MemPage({ id }: { id: string }) {
const activities = useGovActivities();
const first = (activities.data?.activities ?? [])[0]?.activityId;
const memory = useGovMemory(first);
const e = (memory.data?.entries ?? []).find((x) => x.id === id);
if (e === undefined) return <Note>记忆条目需从对应活动进入。</Note>;
return (
<PageHead
ico="layers"
kicker={`活动记忆 · ${e.layer}`}
title={e.statement}
chips={
<>
<Chip ico="link">{e.evidenceCount} 条证据</Chip>
{e.incrementState !== undefined && <Chip tone="warn">候选增量 · {e.incrementState}</Chip>}
</>
}
desc="写入唯蒸馏,条目必挂证据链;记忆不载权力——强制力只经候选增量 + 回放 + 人放行进入业务模型。"
/>
);
}