diff --git a/activities/quote-intake/__tests__/activity.test.ts b/activities/quote-intake/__tests__/activity.test.ts new file mode 100644 index 0000000..f0896e8 --- /dev/null +++ b/activities/quote-intake/__tests__/activity.test.ts @@ -0,0 +1,159 @@ +/** + * 活动包的自测 —— **只准 import @sdk / @contracts 与白名单纯库**(编码规范 §5.1 / ADR-004)。 + * 安装两审那一侧的验收在 `apps/api/.../activity-registry/__tests__` —— 二者之间只有安装通道, + * 没有代码通道,depcruise 双向隔离在守着。 + */ +import { describe, expect, it } from 'vitest'; +import { activityManifestSchema } from '@contracts'; +import { evaluate, evaluateUnaryTest } from '@sfeel'; +import { createMockHost } from '@sdk'; +import { manifest, P03_DMN } from '../src/manifest.ts'; +import activity from '../src/index.ts'; + +describe('清单本身合规', () => { + it('过 @contracts 的 schema', () => { + expect(() => activityManifestSchema.parse(manifest)).not.toThrow(); + }); + + it('监听时限已配 —— 无悬空监听(AM 4.4)', () => { + expect(manifest.declarations.listeners.every((l) => /^P/.test(l.deadline))).toBe(true); + }); + + it('L1/L2 判据都给了可机检的谓词 —— 验收等级不得谎报', () => { + for (const c of manifest.contract.acceptanceCriteria) { + if (c.level === 'L1' || c.level === 'L2') expect(c.predicate ?? c.modelRef).toBeDefined(); + } + }); + + it('禁区短而硬,授权边界给了调人预算', () => { + expect(manifest.contract.prohibitions.length).toBeGreaterThan(0); + expect(manifest.contract.authorization.humanCallBudget).toBeGreaterThan(0); + }); +}); + +describe('步序声明(AM 12.3)', () => { + const model = manifest.assets.businessModel; + if (model.paradigm !== 'flow') throw new Error('A1 应是 flow 范式'); + + it('末步是验收工单 —— 完成只出自放行通过', () => { + expect(model.steps.at(-1)).toMatchObject({ kind: 'human', workOrder: 'acceptance' }); + }); + + it('每步六件齐备:步型 / 入参映射 / 出参写入 / 完成条件 / 失败策略', () => { + for (const step of model.steps) { + expect(step.name).not.toBe(''); + expect(step.failure.retry.times).toBeGreaterThanOrEqual(0); + expect(step.failure.fallback.kind).toBeDefined(); + } + }); + + it('条件跳步是任务数据上的确定性谓词,由引擎求值', () => { + const askBack = model.steps.find((s) => s.name === 'S2_ask_back'); + expect(askBack?.when).toBe('missingKeyFields > 0'); + expect(evaluate(askBack!.when!, { missingKeyFields: 2 })).toBe(true); + expect(evaluate(askBack!.when!, { missingKeyFields: 0 })).toBe(false); + }); + + it('人类工具步都配了时限 —— 无死等', () => { + for (const step of model.steps) { + if (step.kind === 'human') expect(step.deadline).toMatch(/^P/); + } + }); + + it('LLM 步的模板与出参 schema 随声明版本冻结', () => { + const llm = model.steps.find((s) => s.kind === 'llm'); + expect(llm).toBeDefined(); + if (llm?.kind === 'llm') { + expect(llm.template).toContain('{{rfqRef}}'); + expect(llm.outputSchema['type']).toBe('object'); + } + }); +}); + +describe('三分类规则 P-03 —— 能算的不问 LLM', () => { + const table = P03_DMN.tables[0]!; + + /** 按 DMN 语义逐行求值:FIRST 命中即定,单元格谓词复用 @sfeel */ + const decide = (scope: Record) => { + for (const rule of table.rules) { + const hit = table.inputs.every((input, i) => evaluateUnaryTest(rule.when[i] ?? '-', scope[input.expression], scope)); + if (hit) return { type: rule.then[0], play: rule.then[1], rule: rule.id }; + } + return undefined; + }; + + it('历史价 ≤90 天且行情稳 → A · 直接快答', () => { + expect(decide({ historyDays: 30, marketStable: true, lifecycle: '在产' })).toEqual({ + type: 'A', + play: '直接快答', + rule: 'p03-a', + }); + }); + + it('无历史 / 过期 / 波动 → B · 重估后报', () => { + expect(decide({ historyDays: 200, marketStable: false, lifecycle: '在产' })?.type).toBe('B'); + }); + + it('EOL / 缺货 / 要求替代 → C · 转案处理(优先于 A)', () => { + expect(decide({ historyDays: 10, marketStable: true, lifecycle: 'EOL' })).toMatchObject({ type: 'C', rule: 'p03-c' }); + }); + + it('同输入必同结论 —— 零方差、零 token', () => { + const scope = { historyDays: 30, marketStable: true, lifecycle: '在产' }; + expect(decide(scope)).toEqual(decide(scope)); + }); +}); + +describe('代码入口 —— 零环境能力,一切经基座六件', () => { + const contract = { + activityId: 'quote-intake', + version: '0.1.0', + data: { customerCode: 'C001', rfqRef: 'mail:m-1001' }, + message: undefined, + outputPorts: { mandatory: ['intake'], selective: [] }, + prohibitions: [], + discretion: [], + } as const; + + it('全部打标后提交放行', async () => { + const host = createMockHost({ + contract, + ledger: { + 'quote_intake.record': [ + { customer_code: 'C001', pn: 'LM358DR', brand: 'TI', eau: 5000, type_tag: 'A' }, + { customer_code: 'C001', pn: 'NE555', brand: 'TI', eau: 2000, type_tag: 'B' }, + ], + 'quote_intake.type_table': [{ pn: 'LM358DR', type_tag: 'A' }], + }, + }); + const outcome = await host.run(activity); + expect(outcome).toMatchObject({ kind: 'submit', deliverable: 'intake-record' }); + expect(host.recording.views).toHaveLength(1); + expect(host.recording.says.join()).toContain('A 1 条直通定价'); + }); + + it('尚有未打标即等待,不提交放行', async () => { + const host = createMockHost({ + contract, + ledger: { 'quote_intake.record': [{ customer_code: 'C001', pn: 'LM358DR' }], 'quote_intake.type_table': [] }, + }); + expect(await host.run(activity)).toMatchObject({ kind: 'await' }); + }); + + it('视图模版含脚本时宿主拒推 —— 容器无脚本执行权', async () => { + const host = createMockHost({ contract }); + await expect(host.base.interaction.pushHtml('
')).rejects.toThrow(/无脚本执行权/); + }); + + it('裸调用被基座拒发 —— 调用必附上下文', async () => { + const host = createMockHost({ contract }); + await expect( + host.base.interaction.issueWorkOrder({ mode: 'execution', title: 'x', basis: [], ask: 'y', fields: [] }), + ).rejects.toThrow(/裸调用/); + }); + + it('监听时限必配 —— 无死等', async () => { + const host = createMockHost({ contract }); + await expect(host.base.tools.listen({ eventType: 'data', keyFilter: {}, deadline: '' })).rejects.toThrow(/时限必配/); + }); +}); diff --git a/activities/quote-intake/package.json b/activities/quote-intake/package.json new file mode 100644 index 0000000..3567a45 --- /dev/null +++ b/activities/quote-intake/package.json @@ -0,0 +1,7 @@ +{ + "name": "@activities/quote-intake", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" } +} diff --git a/activities/quote-intake/src/index.ts b/activities/quote-intake/src/index.ts new file mode 100644 index 0000000..0223d82 --- /dev/null +++ b/activities/quote-intake/src/index.ts @@ -0,0 +1,63 @@ +/** + * 统一报价 A1 · RFQ 需求分析 —— 活动包代码入口(ADR-004:默认导出 defineActivity 的返回值)。 + * + * 这是 **flow** 活动:下一步由步序声明给定,引擎照谱推进; + * 本文件承担声明装不下的那部分 —— 视图流控与纯工具(打标)。 + * **零环境能力**:一切能力经基座六件注入。 + */ +import { defineActivity } from '@sdk'; +import type { ActivityBase, ActivityOutcome } from '@sdk'; + +const INTAKE = 'quote_intake.record'; +const TYPE_TABLE = 'quote_intake.type_table'; + +export default defineActivity({ + name: 'quote-intake', + paradigm: 'flow', + + async run(base: ActivityBase): Promise { + const records = await base.ledger.read(INTAKE, { customer_code: String(base.contract.data['customerCode'] ?? '') }); + const typed = await base.ledger.read(TYPE_TABLE); + + // 视图流控:按局面推结构化块 —— 内容归活动,容器归系统 + await base.interaction.pushView([ + { + t: 'kv', + title: '受理概览', + items: [ + { k: '客户', v: String(base.contract.data['customerCode'] ?? '') }, + { k: '已归一', v: `${records.length} 条` }, + { k: '已打标', v: `${typed.length} 条` }, + ], + }, + { + t: 'table', + title: '统一受理记录', + cols: ['PN', '厂牌', 'EAU', 'Type'], + rows: records.map((r) => [ + String(r['pn'] ?? ''), + String(r['brand'] ?? ''), + String(r['eau'] ?? ''), + String(r['type_tag'] ?? '未打标'), + ]), + }, + ]); + + const untyped = records.filter((r) => r['type_tag'] === undefined || r['type_tag'] === null); + if (untyped.length > 0) { + await base.interaction.say(`还有 ${untyped.length} 条未打标,继续按三分类规则 P-03 判定。`, 'conclusion'); + return { kind: 'await', reason: '等待打标步完成' }; + } + + await base.interaction.say( + `${records.length} 条已全部打标:` + + `A ${count(records, 'A')} 条直通定价,B ${count(records, 'B')} 条转寻源,C ${count(records, 'C')} 条转选型。`, + 'criterion', + ); + return { kind: 'submit', deliverable: 'intake-record', rationale: '逐条已打标,必交付物齐备' }; + }, +}); + +function count(rows: ReadonlyArray>, tag: string): number { + return rows.filter((r) => r['type_tag'] === tag).length; +} diff --git a/activities/quote-intake/src/manifest.ts b/activities/quote-intake/src/manifest.ts new file mode 100644 index 0000000..6bcfd3b --- /dev/null +++ b/activities/quote-intake/src/manifest.ts @@ -0,0 +1,268 @@ +/** + * 活动包清单 —— 统一报价 A1 · RFQ 需求分析(受理归一)。 + * + * 业务素材出处:docs/MVP示例工作流.html §1.1;按 README《归一化对照表》映射为: + * SOP 节点 → **标准活动(flow)**;作业指导书 S1…S6 → **步序声明**(四种步型); + * 步内判断 D1 完整度 → **条件谓词**;三分类规则 P-03 → **DMN 决策表**; + * SLA(受理<15min、打标<45min)→ Harness 时限与预算。 + */ +import type { ActivityManifest, DmnModel } from '@contracts'; +import { activityIdSchema } from '@contracts'; + +const INTAKE_DOMAIN = 'quote_intake'; +const ACTIVITY_ID = activityIdSchema.parse('quote-intake'); + +/** + * 三分类规则 P-03 —— 把「装得下的判断」从 LLM 手里拿走。 + * 由纯工具步 `quote-intake.classify` 逐条求值,命中规则行留痕。 + */ +export const P03_DMN: DmnModel = { + drd: [{ decision: 'p03-type', requires: [], inputs: ['historyDays', 'marketStable', 'lifecycle'] }], + tables: [{ + id: 'p03-type', + label: '三分类规则 P-03', + hitPolicy: 'FIRST' as const, + inputs: [ + { name: 'historyDays', expression: 'historyDays' }, + { name: 'marketStable', expression: 'marketStable' }, + { name: 'lifecycle', expression: 'lifecycle' }, + ], + outputs: [{ name: 'type' }, { name: 'play' }], + rules: [ + { id: 'p03-c', when: ['-', '-', '"EOL","缺货","要求替代"'], then: ['C', '转案处理'], annotation: 'EOL/缺货/要替代 → C' }, + { id: 'p03-a', when: ['<= 90', 'true', '-'], then: ['A', '直接快答'], annotation: '历史价≤90天且行情稳 → A' }, + { id: 'p03-b', when: ['-', '-', '-'], then: ['B', '重估后报'], annotation: '无历史/过期/波动 → B' }, + ], + }], +}; + +export const manifest: ActivityManifest = { + identity: { + activityId: ACTIVITY_ID, + name: 'A1 · RFQ 需求分析', + packageVersion: '0.1.0', + category: '统一报价', + paradigm: 'flow', + sdkVersion: '0.1.0-m5', + checksum: 'dev-checksum-quote-intake', + }, + + /* 契约七件 */ + contract: { + activityId: ACTIVITY_ID, + version: '0.1.0', + input: { + data: { + type: 'object', + required: ['customerCode', 'rfqRef'], + properties: { + customerCode: { type: 'string' }, + rfqRef: { type: 'string' }, + subject: { type: 'string' }, + }, + }, + messageAllowed: true, + messageHint: '客户口头急单等特殊性写这里', + }, + output: { + mandatory: [ + { + name: 'intake', + payload: { + type: 'object', + required: ['records'], + properties: { records: { type: 'array' } }, + }, + description: '统一受理记录(已打标)', + }, + ], + selective: [], + }, + requiredDeliverables: [ + { key: 'intake-record', label: '统一受理记录(打标)' }, + { key: 'type-table', label: '料号分类表' }, + ], + acceptanceCriteria: [ + { key: 'fields-complete', label: '八字段齐(PN/EAU/交期为必要三要素)', level: 'L1', predicate: 'missingKeyFields = 0' }, + { key: 'typed', label: '每条都已打标 A/B/C', level: 'L2', predicate: 'untyped = 0' }, + { key: 'downgrade-safe', label: '吃不准降 B,不许臆断 A', level: 'L3' }, + { key: 'owner-release', label: 'BD 放行', level: 'L5' }, + ], + prohibitions: [ + { key: 'no-guess-a', statement: '吃不准的一律降 B,不得臆断为 A' }, + { key: 'no-external-write', statement: '不得在放行前写任何外部系统' }, + ], + authorization: { + discretion: [{ key: 'ask-back', statement: '缺次要字段可受理并追问,缺 PN/数量/交期必须退回补全' }], + preauthorizedActions: [], + humanCallBudget: 3, + }, + resources: { + tools: [{ name: 'crm.history-price-read' }], + skills: [], + models: ['quote-extractor'], + roles: ['BD'], + budget: { steps: 40, tokens: 120_000, activeMinutes: 60, humanCalls: 3 }, + }, + }, + + /* 声明 */ + declarations: { + models: ['quote-extractor'], + tools: [{ name: 'crm.history-price-read' }], + ledger: [ + { + domain: INTAKE_DOMAIN, + object: 'record', + label: '统一受理记录', + fields: [ + { name: 'customer_code', type: 'text', primaryKey: true, masked: false, label: '客户码' }, + { name: 'pn', type: 'text', primaryKey: true, masked: false, label: 'PN' }, + { name: 'brand', type: 'text', primaryKey: false, masked: false, label: '厂牌' }, + { name: 'eau', type: 'integer', primaryKey: false, masked: false, label: 'EAU' }, + { name: 'type_tag', type: 'text', primaryKey: false, masked: false, label: 'Type 标' }, + { name: 'target_price', type: 'number', primaryKey: false, masked: false, label: '目标价' }, + { name: 'lead_time', type: 'text', primaryKey: false, masked: false, label: '交期' }, + { name: 'status', type: 'text', primaryKey: false, masked: false, label: '状态' }, + ], + }, + { + domain: INTAKE_DOMAIN, + object: 'type_table', + label: '料号分类表', + fields: [ + { name: 'pn', type: 'text', primaryKey: true, masked: false }, + { name: 'type_tag', type: 'text', primaryKey: false, masked: false }, + { name: 'basis', type: 'text', primaryKey: false, masked: false, label: '分级依据' }, + { name: 'history_hit', type: 'boolean', primaryKey: false, masked: false, label: '历史价命中' }, + ], + }, + ], + listeners: [ + { + origin: 'static', + eventType: 'human', + keyFilter: { kind: 'elicitation' }, + semantic: 'complete-step', + // 时限必配 —— 悬空监听安装时拒绝 + deadline: 'P1D', + escalation: ['nudge', 'escalate'], + }, + ], + harness: { + memoryWindow: 20, + // SLA:受理<15min、打标<45min —— 停滞检测按此设 + stallAfter: 'PT45M', + nudge: [{ after: 'PT15M', action: 'nudge' }], + retry: { times: 2, backoff: 'PT30S' }, + }, + }, + + /* 资产初始版:flow = 步序声明 */ + assets: { + businessModel: { + paradigm: 'flow', + steps: [ + { + kind: 'llm', + name: 'S3_normalize', + label: 'S3 逐条解析归一', + input: { rfqRef: 'rfqRef', customerCode: 'customerCode' }, + output: { domain: INTAKE_DOMAIN, object: 'record', fields: { pn: 'pn', brand: 'brand', eau: 'eau' } }, + model: 'quote-extractor', + template: + '按《询价受理规范》把这份 RFQ 拆解为逐条需求,抄录 PN/数量/EAU/厂牌/目标价/交期。' + + '只输出 JSON。RFQ:{{rfqRef}}', + outputSchema: { + type: 'object', + required: ['records'], + properties: { + records: { + type: 'array', + items: { + type: 'object', + required: ['pn'], + properties: { + pn: { type: 'string' }, + brand: { type: 'string' }, + eau: { type: 'integer' }, + targetPrice: { type: 'number' }, + leadTime: { type: 'string' }, + }, + }, + }, + missingKeyFields: { type: 'integer' }, + }, + }, + failure: { retry: { times: 2, backoff: 'PT30S' }, fallback: { kind: 'human', workOrder: 'execution' } }, + }, + { + kind: 'human', + name: 'S2_ask_back', + label: 'S2 追问补全信息', + // D1 判断完整度:缺 PN/数量/交期三要素才走补全 —— 条件必谓词,由引擎求值 + when: 'missingKeyFields > 0', + input: {}, + workOrder: 'execution', + card: { title: '缺关键字段,请向客户追问补全', fields: ['pn', 'eau', 'leadTime'] }, + deadline: 'P1D', + failure: { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' } }, + }, + { + kind: 'tool', + name: 'S4_history', + label: 'S4 查历史价', + input: { pn: 'pn' }, + output: { domain: INTAKE_DOMAIN, object: 'type_table', fields: { history_hit: 'hit' } }, + tool: 'crm.history-price-read', + failure: { retry: { times: 2, backoff: 'PT1M' }, fallback: { kind: 'human', workOrder: 'execution' } }, + }, + { + kind: 'pure-tool', + name: 'S5_classify', + label: 'S5 逐条打标(DMN · 三分类规则 P-03)', + input: { historyDays: 'historyDays', marketStable: 'marketStable', lifecycle: 'lifecycle' }, + output: { domain: INTAKE_DOMAIN, object: 'type_table', fields: { type_tag: 'type', basis: 'play' } }, + tool: 'quote-intake.classify', + failure: { retry: { times: 1, backoff: 'PT10S' }, fallback: { kind: 'blocked' } }, + }, + { + kind: 'human', + name: 'S6_release', + label: 'S6 分流复核与派单 · 验收放行', + input: {}, + workOrder: 'acceptance', + card: { title: '统一受理记录(打标)请验收' }, + deadline: 'P1D', + failure: { retry: { times: 0, backoff: 'PT30S' }, fallback: { kind: 'blocked' } }, + }, + ], + parallelGroups: [], + }, + skills: [], + viewTemplates: [ + { + name: 'intake-summary', + html: '

统一受理记录

已归一 条

', + }, + ], + cardSchemas: {}, + distillHooks: [ + { + on: 'terminal', + prompt: '归纳本单受理归一的经验:字段缺口形态、打标依据、被驳回的理由。', + }, + ], + }, + + /* 样例集 —— 首装审以静态审 + 样例集替代回放审 */ + samples: [ + { + name: '车规 Analog 询价', + input: { customerCode: 'C001', rfqRef: 'mail:m-1001', subject: '询价 RFQ - 车规 Analog' }, + expect: { ports: ['intake'] }, + }, + ], + + entry: { file: 'index.js', checksum: 'dev-entry-quote-intake' }, +}; diff --git a/api-report/packages/activity-sdk/src/index.d.ts b/api-report/packages/activity-sdk/src/index.d.ts index 418b91d..a80d724 100644 --- a/api-report/packages/activity-sdk/src/index.d.ts +++ b/api-report/packages/activity-sdk/src/index.d.ts @@ -1,5 +1,13 @@ /** - * @sdk —— 活动开发面(编码规范 §8.2):基座六件接口 + defineActivity() + 本地开发宿主 + 回放夹具。 - * 活动包 ABI 见 ADR-004。 + * @sdk —— 活动开发面(编码规范 §8.2)。 + * + * 活动包只准 import `@sdk` / `@contracts` 与白名单纯库; + * **不得触任何环境能力**(`process.env` / `fs` / `fetch`…)—— 静态审扫描产物(ADR-004)。 */ -export declare const SDK_VERSION = "0.0.0-m0"; +export type { ActivityBase, ContractSpec, InteractionHandle, LedgerHandle, LlmHandle, MemoryHandle, ToolInvokerHandle, } from './base.ts'; +export type { ActivityDefinition, ActivityOutcome } from './define.ts'; +export { defineActivity } from './define.ts'; +export type { ActivityMemoryLayer, ChatCardDraft, FormFieldDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft, } from './types.ts'; +export type { HostOptions, HostRecording, MockHost } from './host.ts'; +export { createMockHost } from './host.ts'; +export declare const SDK_VERSION = "0.1.0-m5"; diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ccf5826..f396e0b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -12,10 +12,10 @@ | M2 | `memory`、`toolbox`、`channel`、`message` | ✅ 完成 | | M3 | `interaction`、`human-tool`、`release`、`activity-registry`、`@sfeel` | ✅ 完成 | | M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ✅ 完成(模块与 domain;app 装配随 M5) | -| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | 🟡 `process` 的 domain 已落 | +| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ✅ 完成(app 装配待接) | | M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 | -**当前:325 个测试通过(含 1 组对真实 PG 的集成测试),门禁五道全绿。** +**当前:358 个测试通过(含 1 组对真实 PG 的集成测试),门禁五道全绿。** ## 常用命令 @@ -62,21 +62,23 @@ Enabled ≠ Active;前提求值与语义性前提交裁量;DMN 确定性求值 `task`(终态五名裁定 / Harness 预算与停滞熔断 / 控制三语义 / 改派 / 断点续跑三份持久状态 / 封卷触发蒸馏)。 -**M5(进行中)** — `process` 的 domain:边映射闭合校验(七类违规)、负责人四策略确定性解析、 -工作流状态聚合投影、失败走向消化判定、节点实例重开。 +**M5** — `process`:闭合校验(七类违规)+ 四策略解析 + 状态聚合 + 确定性流转 +(选边 / 装配 / AND 回齐 / 失败走向四选一 / 审批驳回重开新实例)+ 三表持久; +`trigger`:三类触发器 + 起单幂等 + 数据日历型逐行扫描; +`@activity-sdk`:基座六件接口 + `defineActivity()` + 本地开发宿主(mock 工具表 / 模拟工位 / 录制); +`activities/quote-intake`:统一报价 A1 的首个活动包 —— 契约七件 + 步序声明(五步)+ +DMN 三分类规则 P-03 + 台账声明 + 样例集。depcruise 已实测拦下活动包越界 import。 ## 下一步(按序) -1. **M5 · `process` 余下**:`port.ts` + usecase(流程包存取与发布、工作流实例起单与流转)、 - `internal/adapter/workflow-compiler.ts`(Mastra `createWorkflow`/`createStep`,编码规范 §6.1: - XOR → `.branch()`,AND → `.parallel()`,审批点与活动节点 `suspend()`,终态事件到达 `resume()`)。 -2. **M5 · `trigger`**:三类触发器(周期两型 / 事件 / 登记)、判定与经调用接口起单; - 接上 `container.ts` 里的 `TriggerMatcher` 占位;pg-boss 承接周期规则与监听时限扫描。 -3. **M5 · `@activity-sdk`**:基座六件接口 + `defineActivity()` + 本地开发宿主 + 回放夹具 + - tsup 编译预设(§8.2 / ADR-004);随后在 `activities/` 下按《MVP示例工作流》做首个活动 - (建议先 A1 受理归一 = flow,再 A3 渠道寻源 = loop)。 -4. **M4 收尾**:把 loop / flow / skill / task 接入 `container.ts` - —— 需要 `loop-engine/internal/adapter/agent-runner.ts` 的 Mastra Agent(§6.1)。 +1. **app 装配收尾(打通端到端的关键一步)**:把 M4/M5 的九个模块接入 `container.ts` —— + `loop-engine` 需要 `internal/adapter/agent-runner.ts` 的 Mastra Agent(§6.1); + 派发器注册任务与触发器两个消费者;`TriggerMatcher` / `InFlightWorkProbe` 占位换真身; + pg-boss 承接周期扫描与监听时限。 +2. **活动装载通道**:`activity-registry` 装载 .JS 产物并把基座六件句柄注入活动 + (tsup 单文件 ESM 编译预设,§8.2 / ADR-004)。 +3. **余下活动包**:A4 成本定价(flow · DMN P-01/P-02)、A6 报价议价(flow)、 + A7 情报归档(flow)、A3 渠道寻源(loop · CMMN)、A2 替代选型(loop · CMMN)。 5. **M6 · 前端**:按 README《前端 UI · 原型 1:1 平移》逐屏平移 `~/coding/JCAI_PRD/workbench`(约 8k 行)与 `~/coding/JCAI_PRD/governance`(约 5k 行); CSS 与 DOM 原样搬,组件按 §9 重排,mock 数据换真实 API;`minute`、`report` 两模块随之。 diff --git a/packages/activity-sdk/src/base.ts b/packages/activity-sdk/src/base.ts new file mode 100644 index 0000000..fb0677f --- /dev/null +++ b/packages/activity-sdk/src/base.ts @@ -0,0 +1,99 @@ +/** + * 活动基座六件 —— 活动开发者看到的**全部世界**(编码规范 §8.2;语义出处 OS 4.1)。 + * + * 「基座即笼子:契约限承诺、工具限能力、交互限入口、记忆与留痕限事实、数据限归属、 + * LLM 调用限模型 —— **内容归活动,笼子归系统**。 + * 活动代码**无环境能力**,一切能力经调用契约注入,越权在结构上不可能。」 + */ +import type { + ActivityMemoryLayer, + ChatCardDraft, + LedgerKey, + LedgerRow, + ViewBlockLike, + WorkOrderDraft, +} from './types.ts'; + +/* ① 契约 —— 只读:活动启动前冻结,运行中不得变更 */ +export type ContractSpec = { + readonly activityId: string; + readonly version: string; + /** 输入:data(已过 schema 校验)+ message(委托消息,可为空) */ + readonly data: Readonly>; + readonly message: string | undefined; + readonly outputPorts: { readonly mandatory: readonly string[]; readonly selective: readonly string[] }; + readonly prohibitions: readonly string[]; + /** AI 可自决的范围;超出必须调「人 · 决策」 */ + readonly discretion: readonly string[]; +}; + +/* ② 交互 —— 对话流(唯一回环输入口,只出不进)+ 视图区容器(只读呈现) */ +export type InteractionHandle = { + /** 视图流控:按局面推结构化块 */ + pushView(blocks: readonly ViewBlockLike[]): Promise; + /** 视图模版 —— 过净化门:含脚本 / 外部资源即被拒 */ + pushHtml(html: string): Promise; + /** 对话区输出(AI 发言) */ + say(text: string, tag?: 'conclusion' | 'guidance' | 'criterion'): Promise; + /** 调人:工单两模式 */ + issueWorkOrder(order: WorkOrderDraft): Promise<{ id: string }>; + /** 调人:对话卡族(决策 / 补全 / 评价) */ + issueCard(card: ChatCardDraft): Promise<{ id: string }>; +}; + +/* ③ 记忆 —— 任务记忆只有**窗口配置**一个可配项;活动记忆只有存与取 */ +export type MemoryHandle = { + /** 每次 LLM 调用加载多少轮会话历史 —— 活动侧唯一记忆接口 */ + window(size: number): Promise>; + /** 活动记忆按定位选摘(渐进披露);**语义引擎不读** */ + recall(options?: { layers?: readonly ActivityMemoryLayer[]; limit?: number }): Promise< + ReadonlyArray<{ layer: ActivityMemoryLayer; statement: string }> + >; +}; + +/* ④ 数据(台账)—— 三级寻址 + 通用事务接口;放行前一切写入落草稿层 */ +export type LedgerHandle = { + /** 任务视图 = 正式版 + 本任务草稿层(读己之写) */ + read(address: string, where?: Readonly>): Promise; + /** 草稿写 —— **不产生数据事件**;放行时合并入正式版 */ + write(address: string, key: LedgerKey, patch: LedgerRow): Promise; +}; + +/* ⑤ 工具(调用契约)—— 授权在白名单与契约,不在活动代码 */ +export type ToolInvokerHandle = { + invoke(tool: string, input: Readonly>): Promise<{ + ok: boolean; + output?: Record; + error?: { code: string; message: string }; + }>; + /** 写类动作:登记为动作意向(入参冻结),放行后由系统执行落地 */ + intend(tool: string, input: Readonly>, label: string): Promise; + /** 立监听:AI 对世界只有触达(动作)与等待(监听)两种姿势;**时限必配** */ + listen(input: { + eventType: 'data' | 'time'; + keyFilter: Readonly>; + deadline: string; + }): Promise<{ id: string }>; +}; + +/* ⑥ LLM 调用 —— 活动只声明**自定义模型名**,不触厂商与密钥 */ +export type LlmHandle = { + generate(input: { + /** 自定义模型名(逻辑名) */ + model: string; + prompt: string; + system?: string; + /** 给定即要求结构化出参并校验 */ + outputSchema?: Record; + }): Promise<{ text: string; object?: unknown }>; +}; + +/** 六件合体 —— 活动运行时拿到的全部句柄 */ +export type ActivityBase = { + readonly contract: ContractSpec; + readonly interaction: InteractionHandle; + readonly memory: MemoryHandle; + readonly ledger: LedgerHandle; + readonly tools: ToolInvokerHandle; + readonly llm: LlmHandle; +}; diff --git a/packages/activity-sdk/src/define.ts b/packages/activity-sdk/src/define.ts new file mode 100644 index 0000000..97613ad --- /dev/null +++ b/packages/activity-sdk/src/define.ts @@ -0,0 +1,32 @@ +/** + * `defineActivity()` —— 活动包的 ABI 入口(ADR-004)。 + * + * 「单文件 ESM,**默认导出** `defineActivity()` 的返回值;能力全部注入(基座六件句柄), + * **零环境能力**;静态审扫描产物 import 与全局引用。」 + */ +import type { ActivityBase } from './base.ts'; + +/** 活动的运行结论 —— 提交放行是唯一的成功出路 */ +export type ActivityOutcome = + /** 判定成果已齐,提交放行(必交付物齐备、判据全绿只是可提交) */ + | { readonly kind: 'submit'; readonly deliverable: string; readonly rationale: string } + /** 等外界:已立监听,回合到此为止 */ + | { readonly kind: 'await'; readonly reason: string } + /** 无法推进,申报受阻待人 */ + | { readonly kind: 'blocked'; readonly reason: string }; + +export type ActivityDefinition = { + readonly name: string; + /** 范式只存在于引擎层;此处声明是给装载器看的 */ + readonly paradigm: 'loop' | 'flow'; + /** + * 一个回合的推进。由**引擎**驱动调用 —— 活动不自转、不自设 maxSteps; + * 预算与熔断由基座在环外强制。 + */ + run(base: ActivityBase): Promise; +}; + +export function defineActivity(definition: ActivityDefinition): ActivityDefinition { + if (definition.name.trim() === '') throw new Error('活动必须有名字'); + return Object.freeze(definition); +} diff --git a/packages/activity-sdk/src/host.ts b/packages/activity-sdk/src/host.ts new file mode 100644 index 0000000..629f7da --- /dev/null +++ b/packages/activity-sdk/src/host.ts @@ -0,0 +1,132 @@ +/** + * 本地开发宿主 —— mock 工具表 + 模拟工位(工单 / 卡)+ 回放夹具(编码规范 §8.2)。 + * + * 「系统外对着声明开发(本地 mock)」:开发者不必起系统就能跑活动; + * 夹具录下的出入参可直接喂给回放审(§7.3 两缝的同一副夹具)。 + */ +import type { ActivityBase, ContractSpec } from './base.ts'; +import type { ActivityDefinition, ActivityOutcome } from './define.ts'; +import type { ChatCardDraft, LedgerKey, LedgerRow, ViewBlockLike, WorkOrderDraft } from './types.ts'; + +export type HostRecording = { + readonly views: ViewBlockLike[][]; + readonly htmls: string[]; + readonly says: string[]; + readonly workOrders: WorkOrderDraft[]; + readonly cards: ChatCardDraft[]; + readonly toolCalls: Array<{ tool: string; input: Record }>; + readonly intents: Array<{ tool: string; input: Record; label: string }>; + readonly listeners: Array<{ eventType: string; keyFilter: Record; deadline: string }>; + readonly llmCalls: Array<{ model: string; prompt: string }>; + readonly writes: Array<{ address: string; key: LedgerKey; patch: LedgerRow }>; +}; + +export type HostOptions = { + readonly contract: ContractSpec; + /** mock 工具表:工具名 → 固定出参 */ + readonly tools?: Readonly) => Record>>; + /** mock 模型:模型名 → 固定出参 */ + readonly models?: Readonly { text: string; object?: unknown }>>; + /** 台账初值:地址 → 行 */ + readonly ledger?: Readonly>; + readonly memory?: ReadonlyArray<{ layer: 'fact' | 'pattern' | 'verdict' | 'exception'; statement: string }>; +}; + +export type MockHost = { + readonly base: ActivityBase; + readonly recording: HostRecording; + run(definition: ActivityDefinition): Promise; +}; + +export function createMockHost(options: HostOptions): MockHost { + const recording: HostRecording = { + views: [], + htmls: [], + says: [], + workOrders: [], + cards: [], + toolCalls: [], + intents: [], + listeners: [], + llmCalls: [], + writes: [], + }; + const ledger = new Map(Object.entries(options.ledger ?? {})); + let cardSeq = 0; + + const base: ActivityBase = { + contract: options.contract, + interaction: { + async pushView(blocks) { + recording.views.push([...blocks]); + }, + async pushHtml(html) { + if (/<\s*script\b/i.test(html)) throw new Error('视图模版不得含