forked from ChaiHao/BPOS-MVP
M6(3/n): minute 与 report —— L8 两个模块补齐
minute(纪要):工作台五要素里唯一的主动动作。 纪要本身不产生责任,唯一通往责任的路是决议经登记触发起单 —— 登记人即来源。 正文可续、决议只增;同一决议不重复起单(起单一刻已有一条带来源的流在跑)。 report(汇报):日/周/月/年对任务日志的聚合投影,零手工汇报。 未提交的草案每次取回都重算 —— 汇报是投影不是账本,提交那刻才固定为事实快照。 度量八指标落在这里(§4 注:度量是只读投影,不设独立模块): 自主率 / 纠正率 / 有效自主率 / 打扰率 / 断点率 / DMN 覆盖率 / 单位成本 / 终态分布。 口径全部挂归侧标签与事件流,零埋点;单位成本的分母只认放行通过。 周窗口以周一为首日,按 UTC 切分(单组织单时区)。 FactSource 只读 memory 与 task,一行都不另记 —— 五要素同源。 gates: tsc / eslint / depcruise(305 模块)/ api-report / 377 测试 全绿 日报已在真实系统上由 A1 任务日志聚合出来,含治理干预两行 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -53,8 +53,11 @@ import { createIntentStore, createReleaseService } from '@modules/release';
|
||||
import type { TaskService } from '@modules/task';
|
||||
import type { ProcessService } from '@modules/process';
|
||||
import type { TriggerService } from '@modules/trigger';
|
||||
import type { MinuteService } from '@modules/minute';
|
||||
import type { ReportService } from '@modules/report';
|
||||
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
||||
import { wireEngines } from './wiring/engines.ts';
|
||||
import { wireDeskElements } from './wiring/desk-elements.ts';
|
||||
import type { ChannelService } from '@modules/channel';
|
||||
import {
|
||||
createChannelRepository,
|
||||
@@ -92,6 +95,8 @@ export type Container = {
|
||||
readonly task: TaskService;
|
||||
readonly process: ProcessService;
|
||||
readonly trigger: TriggerService;
|
||||
readonly minute: MinuteService;
|
||||
readonly report: ReportService;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -498,6 +503,17 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
}),
|
||||
});
|
||||
|
||||
// L8:工作台后两个要素 —— 只读 memory 与 task 的投影,一行都不另记
|
||||
const deskElements = wireDeskElements({
|
||||
db,
|
||||
memory,
|
||||
task: engines.task,
|
||||
org,
|
||||
process: engines.process,
|
||||
ids,
|
||||
clock: systemClock,
|
||||
});
|
||||
|
||||
return {
|
||||
config,
|
||||
broadcaster: createBroadcaster(),
|
||||
@@ -520,6 +536,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
task: engines.task,
|
||||
process: engines.process,
|
||||
trigger: engines.trigger,
|
||||
minute: deskElements.minute,
|
||||
report: deskElements.report,
|
||||
async close() {
|
||||
await pool.end();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 工作台后两个要素的路由:纪要与汇报(编码规范 §3)。
|
||||
*
|
||||
* 「五要素同源 —— 都是同一份过程事实在员工视角下的投影。」
|
||||
* 纪要是唯一的主动动作;汇报是系统代劳的自动产出,员工只确认。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, MinuteView, ReportView } from '@contracts';
|
||||
import {
|
||||
appendResolutionRoute,
|
||||
createMinuteRoute,
|
||||
draftReportRoute,
|
||||
listMinutesRoute,
|
||||
listReportsRoute,
|
||||
registerResolutionRoute,
|
||||
reportPeriodSchema,
|
||||
staffIdSchema,
|
||||
submitReportRoute,
|
||||
} from '@contracts';
|
||||
import type { Minute } from '@modules/minute';
|
||||
import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/minute';
|
||||
import type { Report } from '@modules/report';
|
||||
import { ReportAlreadySubmittedError, ReportNotFoundError } from '@modules/report';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerDeskElementRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
async function me(account: string) {
|
||||
return container.org.findByAccount(account);
|
||||
}
|
||||
|
||||
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
|
||||
/** 汇报投影:草案内容平铺,前端不必知道 draft 的嵌套 */
|
||||
const toReportView = (report: Report): ReportView => ({
|
||||
id: report.id,
|
||||
period: report.period,
|
||||
title: report.title,
|
||||
window: report.window,
|
||||
sections: report.draft.sections.map((s) => ({
|
||||
taskId: s.taskId,
|
||||
title: s.title,
|
||||
activityId: s.activityId,
|
||||
...(s.terminalLabel === undefined ? {} : { terminalLabel: s.terminalLabel }),
|
||||
lines: [...s.lines],
|
||||
})),
|
||||
metrics: report.draft.metrics,
|
||||
counts: report.draft.counts,
|
||||
...(report.note === undefined ? {} : { note: report.note }),
|
||||
...(report.submittedAt === undefined ? {} : { submittedAt: report.submittedAt }),
|
||||
generatedAt: report.generatedAt,
|
||||
});
|
||||
|
||||
/* ── 纪要 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
async function toMinuteView(minute: Minute): Promise<MinuteView> {
|
||||
const author = await container.org.getStaff(staffIdSchema.parse(minute.authorId)).catch(() => undefined);
|
||||
return {
|
||||
id: minute.id,
|
||||
title: minute.title,
|
||||
body: minute.body,
|
||||
authorId: staffIdSchema.parse(minute.authorId),
|
||||
authorName: author?.name ?? '—',
|
||||
participants: [...minute.participants],
|
||||
occurredOn: minute.occurredOn,
|
||||
resolutions: minute.resolutions.map((r) => ({
|
||||
id: r.id,
|
||||
text: r.text,
|
||||
...(r.ownerId === undefined ? {} : { ownerId: staffIdSchema.parse(r.ownerId) }),
|
||||
state: r.state,
|
||||
...(r.workflowId === undefined ? {} : { workflowId: r.workflowId }),
|
||||
...(r.registeredById === undefined ? {} : { registeredById: staffIdSchema.parse(r.registeredById) }),
|
||||
...(r.registeredAt === undefined ? {} : { registeredAt: r.registeredAt }),
|
||||
})),
|
||||
createdAt: minute.createdAt,
|
||||
updatedAt: minute.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
app.get(listMinutesRoute.path, async (c) => {
|
||||
// 纪要是团队沉淀:全员可见,不按负责人过滤
|
||||
const minutes = await container.minute.list();
|
||||
return c.json({ minutes: await Promise.all(minutes.map(toMinuteView)) });
|
||||
});
|
||||
|
||||
app.post(createMinuteRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = createMinuteRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '纪要不合协议', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const minute = await container.minute.create({
|
||||
title: parsed.data.title,
|
||||
body: parsed.data.body,
|
||||
authorId: staff.id,
|
||||
occurredOn: parsed.data.occurredOn,
|
||||
participants: parsed.data.participants,
|
||||
resolutions: parsed.data.resolutions,
|
||||
});
|
||||
return c.json(await toMinuteView(minute));
|
||||
});
|
||||
|
||||
app.post(appendResolutionRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = appendResolutionRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '决议不合协议' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
try {
|
||||
const minute = await container.minute.appendResolution(
|
||||
c.req.param('minuteId'),
|
||||
parsed.data.text,
|
||||
parsed.data.ownerId,
|
||||
);
|
||||
return c.json(await toMinuteView(minute));
|
||||
} catch (error) {
|
||||
if (error instanceof MinuteNotFoundError) {
|
||||
const body: ApiError = { code: 'not-found', message: error.message };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/** 决议 → 登记起单:登记人即来源(OS 3.1) */
|
||||
app.post(registerResolutionRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = registerResolutionRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '登记入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
try {
|
||||
return c.json(
|
||||
await container.minute.register({
|
||||
resolutionId: c.req.param('resolutionId'),
|
||||
byId: staff.id,
|
||||
processPackageId: parsed.data.processPackageId,
|
||||
data: parsed.data.data,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResolutionAlreadyRegisteredError) {
|
||||
// 同一决议不重复起单 —— 已经有一条带来源的流在跑了
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
if (error instanceof MinuteNotFoundError) {
|
||||
const body: ApiError = { code: 'not-found', message: error.message };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 汇报 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listReportsRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const reports = await container.report.list(staff.id);
|
||||
return c.json({ reports: reports.map(toReportView) });
|
||||
});
|
||||
|
||||
app.get(draftReportRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const period = reportPeriodSchema.safeParse(c.req.param('period'));
|
||||
if (!period.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '周期只有日 / 周 / 月 / 年四种' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
// 未提交的每次取回都重算 —— 汇报是投影,不是账本
|
||||
return c.json(toReportView(await container.report.draft(staff.id, period.data)));
|
||||
});
|
||||
|
||||
app.post(submitReportRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = submitReportRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
||||
try {
|
||||
const report = await container.report.submit(
|
||||
c.req.param('reportId'),
|
||||
parsed.success ? parsed.data.note : undefined,
|
||||
);
|
||||
return c.json(toReportView(report));
|
||||
} catch (error) {
|
||||
if (error instanceof ReportAlreadySubmittedError) {
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
if (error instanceof ReportNotFoundError) {
|
||||
const body: ApiError = { code: 'not-found', message: error.message };
|
||||
return c.json(body, 404);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
|
||||
import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { registerDeskRoutes } from './routes/desk.ts';
|
||||
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
@@ -44,6 +45,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerDeskRoutes(app, container);
|
||||
registerDeskElementRoutes(app, container);
|
||||
registerGovernanceOpsRoutes(app, container);
|
||||
registerIngestRoutes(app, container);
|
||||
registerStreamRoute(app, container.broadcaster);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 工作台后两个要素的装配:纪要(minute)与汇报(report)—— L8。
|
||||
*
|
||||
* 「五要素同源 —— 做事与留痕是同一个动作:五要素都是同一份过程事实
|
||||
* (任务记忆与工作流事件)在员工视角下的投影。」(OS 2.1)
|
||||
* 所以这里的 `FactSource` **只读** memory 与 task,一行都不另记。
|
||||
*/
|
||||
import type { StaffId } from '@contracts';
|
||||
import type { MemoryService } from '@modules/memory';
|
||||
import type { MinuteService } from '@modules/minute';
|
||||
import { createMinuteService, createMinuteStore } from '@modules/minute';
|
||||
import type { OrgService } from '@modules/org';
|
||||
import type { ProcessService } from '@modules/process';
|
||||
import type { LogFact, ReportService, TaskFact } from '@modules/report';
|
||||
import { createReportService, createReportStore } from '@modules/report';
|
||||
import type { TaskService } from '@modules/task';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
|
||||
export type DeskElementDeps = {
|
||||
readonly db: Db;
|
||||
readonly memory: MemoryService;
|
||||
readonly task: TaskService;
|
||||
readonly org: OrgService;
|
||||
readonly process: ProcessService;
|
||||
readonly ids: { next(): string };
|
||||
readonly clock: { now(): Date };
|
||||
};
|
||||
|
||||
export function wireDeskElements(deps: DeskElementDeps): { minute: MinuteService; report: ReportService } {
|
||||
const minute = createMinuteService({
|
||||
store: createMinuteStore(deps.db),
|
||||
registration: {
|
||||
// 登记触发:凡单必有来源 —— 纪要起单的来源就是登记人
|
||||
start: async (input) => {
|
||||
const started = await deps.process.start({
|
||||
packageId: input.processPackageId,
|
||||
subject: input.subject,
|
||||
data: input.data,
|
||||
source: { kind: 'registration', ref: input.processPackageId, by: input.byId },
|
||||
});
|
||||
return { workflowId: started.id };
|
||||
},
|
||||
},
|
||||
ids: deps.ids,
|
||||
clock: deps.clock,
|
||||
});
|
||||
|
||||
/** 归侧日志行 → 度量事实。判断类与断点由日志标题的确定形态识别,不另设埋点 */
|
||||
const toLogFact = (taskId: string, line: { at: string; side: LogFact['side']; title: string }): LogFact => ({
|
||||
taskId,
|
||||
at: line.at,
|
||||
side: line.side,
|
||||
title: line.title,
|
||||
isHumanToolCall: line.title.startsWith('发出:'),
|
||||
// DMN / 机检的确定性求值在日志里是「求值:」;LLM 的判断落在「调用:」
|
||||
isDeterministicJudgement: line.title.startsWith('求值:'),
|
||||
isJudgement: line.title.startsWith('求值:') || line.title.startsWith('调用:'),
|
||||
isBreakpoint: line.title.includes('断点') || line.title.includes('交集为空'),
|
||||
});
|
||||
|
||||
const report = createReportService({
|
||||
store: createReportStore(deps.db),
|
||||
facts: {
|
||||
async logsOf(staffId, from, to) {
|
||||
const owners = staffId === undefined ? (await deps.org.listStaff()).staff.map((s) => s.id) : [staffId];
|
||||
const nested = await Promise.all(owners.map((id) => deps.task.listByOwner(id)));
|
||||
const tasks = nested.flat();
|
||||
const perTask = await Promise.all(
|
||||
tasks.map(async (t) => (await deps.memory.taskLog(t.id)).map((l) => toLogFact(t.id, l))),
|
||||
);
|
||||
return perTask.flat().filter((l) => l.at >= from && l.at < to);
|
||||
},
|
||||
async tasksOf(staffId) {
|
||||
const owners = staffId === undefined ? (await deps.org.listStaff()).staff.map((s) => s.id) : [staffId];
|
||||
const nested = await Promise.all(owners.map((id) => deps.task.listByOwner(id)));
|
||||
return nested.flat().map<TaskFact>((t) => ({
|
||||
taskId: t.id,
|
||||
title: t.title,
|
||||
activityId: t.activityId,
|
||||
startedAt: t.startedAt,
|
||||
finishedAt: t.finishedAt,
|
||||
terminal: t.terminal,
|
||||
// 提交批次 = 放行提交次数;放行完成必有一次提交,未终态的以已发生的放行计
|
||||
submissions: t.terminal === 'released' ? 1 : 0,
|
||||
// 被纠正批次:驳回重做 / 驳回迭代 / 叫停 / 裁决推翻 —— 非放行终态即计一次
|
||||
corrections: t.terminal !== undefined && t.terminal !== 'released' ? 1 : 0,
|
||||
tokens: t.used.tokens,
|
||||
activeMs: t.used.activeMs,
|
||||
humanCalls: t.used.humanCalls,
|
||||
}));
|
||||
},
|
||||
},
|
||||
ids: deps.ids,
|
||||
clock: deps.clock,
|
||||
});
|
||||
|
||||
return { minute, report };
|
||||
}
|
||||
|
||||
export type { StaffId };
|
||||
Reference in New Issue
Block a user