Archived
R1:TurnRunnerDeps 加 analyze 会让 turn-runner 测试脚手架的 deps 字面量少一个必填属性,
typecheck 在 Task 4 提交时就红 —— 桩前移到 Task 4,Task 7 那一步退化成验证。
R2:auto-source 的 matches() 在生产域代码里用了 as never,形参直接标 AutoExpr。
R3:showView 第二参不可选,传 undefined 编不过 —— 改成「声明了画面才推」。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
3183 lines
131 KiB
Markdown
3183 lines
131 KiB
Markdown
# 确定性步骤(auto 配方)平台机制 实施计划
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** 给 `PlanStep` 加一个声明式配方 `auto`,让活动的一步可以由引擎按声明**确定性地**做完(投影台账 / 发卡 / 一次结构化 AI 调用 / 提交放行 / 说一句话),不问模型。
|
|
|
|
**Architecture:** 配方是**纯数据**(装进来仍然只是数据,可静态审、可回放),不执行活动包代码(ADR-004)。求值分三层:表达式(纯函数)→ 数据源展开(纯函数)→ 执行器(拿端口)。引擎在每一拍拍首跑当前这一步的配方;发了卡就收拍,没发卡就重求 `doneWhen` 收步。有 `guide` 的老活动一个字不改。
|
|
|
|
**Tech Stack:** TypeScript(`exactOptionalPropertyTypes`)· zod(契约)· vitest · Hono(路由)· 现有 `@sfeel` 求值器 · 现有 `ModelGateway`(结构化出参)
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md`(本计划只实现该 spec 的 §5;§3/§4/§6/§7 归第二份计划)
|
|
|
|
## Global Constraints
|
|
|
|
- **五道门禁以退出码验**,不许以 echo 验:`pnpm gates`(= `gen:tiers` → `typecheck` → `lint` → `depcruise` → `api:check` → `test`)。
|
|
- **契约变更四步**:depcruise 反查下游 → 同步对应模块的 `CONTRACT.md` → `pnpm api:check --update && git add api-report` → 破坏性变更走三步弃用。
|
|
- **禁止清单**:不许 `any` / `as unknown as` / `@ts-ignore`;domain 层零 IO;不删测试;不动门禁配置。
|
|
- **mock 只许出现在外界**(邮件通道、外部系统替身);系统内部机制不得 mock —— 单测里用**手写的假端口对象**(普通对象字面量)是允许的,那是依赖注入不是 mock 框架。
|
|
- **模块分层**:`internal/domain` 只准纯函数(零 IO、零端口);`internal/usecase` 可拿端口;`@mastra` / 数据库只准出现在 `internal/adapter` 与 `apps/api/src/app`。
|
|
- **别动稳定版**:不要跑 `./stable.sh update`,不要改库里的数据。稳定版与开发共用 `jcai_wos` 库。
|
|
- 本计划**不改任何现有活动包**;E04 / I01 / W05 的行为必须逐字不变(回归靠 `pnpm test`)。
|
|
- 提交信息用中文,句式照仓里现有的(`feat(loop-engine): …`),并在末尾附上:
|
|
|
|
```
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
```
|
|
|
|
---
|
|
|
|
## 前置:先开一条 feature 分支
|
|
|
|
当前 HEAD 在 **主支 `mvp-foundation`** 上。这份计划有十三次提交,不许直接堆在主支上。
|
|
|
|
```bash
|
|
git switch -c feat/deterministic-steps
|
|
```
|
|
|
|
后面每一条「提交」都落在这条分支上;全部做完之后**由主支发起合并**(切回 `mvp-foundation`
|
|
再 merge 这条分支),不要反过来把主支合进 feature。
|
|
|
|
---
|
|
|
|
## 文件结构
|
|
|
|
| 文件 | 职责 |
|
|
|---|---|
|
|
| `packages/contracts/src/auto-recipe.ts`(新) | 配方的 zod schema 与类型 —— 唯一真源 |
|
|
| `packages/contracts/src/manifest.ts`(改) | `planStepSchema` 加可选 `auto` |
|
|
| `packages/contracts/src/index.ts`(改) | 转出 auto-recipe |
|
|
| `apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts`(新) | 表达式求值 + 地址取行(纯函数) |
|
|
| `apps/api/src/modules/loop-engine/internal/domain/auto-source.ts`(新) | 数据源展开成行(纯函数,两层封顶) |
|
|
| `apps/api/src/modules/loop-engine/internal/domain/live-step.ts`(新) | 「这一单此刻在办哪一步」(从 turn-runner 提纯出来的纯函数) |
|
|
| `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(新) | 配方执行器 —— 逐笔执行、刷读数、留痕 |
|
|
| `apps/api/src/modules/loop-engine/port.ts`(改) | 新增 `AnalyzeGateway`;`CardGateway.decide` 已够用 |
|
|
| `apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts`(改) | 两处插入配方段 + 卡答复即收步 |
|
|
| `apps/api/src/modules/loop-engine/CONTRACT.md`(改) | 记不变量 |
|
|
| `apps/api/src/modules/human-tool/internal/domain/card-rules.ts`(改) | 审核卡放宽到单选项 |
|
|
| `apps/api/src/modules/activity-registry/internal/domain/static-audit.ts`(改) | `autoRecipeFindings` |
|
|
| `apps/api/src/modules/interaction/internal/domain/view-render.ts`(改) | `data-options` 下拉选项从台账现读 |
|
|
| `apps/api/src/app/wiring/engines.ts`(改) | 装配 `AnalyzeGateway`(走 `ModelGateway`) |
|
|
| `apps/api/src/app/routes/desk.ts`(改) | 件上保存后 `wake` |
|
|
|
|
---
|
|
|
|
### Task 1: 配方契约
|
|
|
|
**Files:**
|
|
- Create: `packages/contracts/src/auto-recipe.ts`
|
|
- Create: `packages/contracts/src/__tests__/auto-recipe.test.ts`
|
|
- Modify: `packages/contracts/src/manifest.ts`(`planStepSchema` 末尾加 `auto`)
|
|
- Modify: `packages/contracts/src/index.ts`(加一行 `export * from './auto-recipe.ts';`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: 无
|
|
- Produces: `AutoExpr` · `AutoSource` · `AutoAction` · `AutoRecipe` 四个类型;`autoRecipeSchema`;`PlanStep.auto?: AutoRecipe`
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
```ts
|
|
// packages/contracts/src/__tests__/auto-recipe.test.ts
|
|
import { describe, expect, it } from 'vitest';
|
|
import { autoRecipeSchema } from '../auto-recipe.ts';
|
|
|
|
describe('auto 配方契约', () => {
|
|
it('收下一份用全了五个动词的配方', () => {
|
|
const parsed = autoRecipeSchema.safeParse({
|
|
let: { evalRef: { cat: ['W05-', { fmt: { in: 'case:openedAt' }, as: 'digits' }] } },
|
|
do: [
|
|
{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: { in: 'originalInternalPn' } }] }, fields: { internal_pn: { col: 'pn' } } },
|
|
{ kind: 'analyze', model: 'deepseek-v4-flash', into: 'items', instruction: '逐条判四态', input: { from: { each: 'parts.type_param' }, fields: { name: { col: 'param_name' } } }, output: { type: 'object' } },
|
|
{ kind: 'card', as: 'execution', variant: 'data', title: '补数据', basis: [{ k: '原 PN', v: { in: 'originalInternalPn' } }], gateLedgers: ['parts.profile'] },
|
|
{ kind: 'submit', rationale: '结论已备好' },
|
|
{ kind: 'say', text: '这一步办完了' },
|
|
],
|
|
});
|
|
expect(parsed.success).toBe(true);
|
|
});
|
|
|
|
it('不认识的动词当场拒 —— 没实现的形状不许装进来', () => {
|
|
const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'drop', into: 'a.b' }] });
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it('嵌套只许两层:第三层当场拒', () => {
|
|
const parsed = autoRecipeSchema.safeParse({
|
|
do: [{ kind: 'project', into: 'a.b', from: { each: 'a.b', then: { each: 'c.d', then: { each: 'e.f' } } }, fields: {} }],
|
|
});
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
|
|
it('prune 只许挂在 project 上', () => {
|
|
const parsed = autoRecipeSchema.safeParse({ do: [{ kind: 'say', text: 'x', prune: true }] });
|
|
expect(parsed.success).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts`
|
|
Expected: FAIL —— `Failed to resolve import "../auto-recipe.ts"`
|
|
|
|
- [ ] **Step 3: 写契约**
|
|
|
|
```ts
|
|
// packages/contracts/src/auto-recipe.ts
|
|
/**
|
|
* 确定性步骤的**声明式配方**(ADR-014 / spec 2026-08-29 §5.1)。
|
|
*
|
|
* 配方是**数据不是代码**:装进来仍然只是数据,可静态审、可回放、可在治理台上摆出
|
|
* 「这一步系统会做什么」。执行第三方代码则相反 —— ADR-004 的立场是「不执行包代码即知包为何物」。
|
|
*
|
|
* **只声明真实现了的那几式。** 表达力有上限是刻意的:上限之外的不要硬塞,
|
|
* 退回模型,或者把那一段判断从这一步里切出去。
|
|
*/
|
|
import { z } from 'zod';
|
|
|
|
/** 表达式 —— 递归,`z.lazy` 是唯一写法(zod 的自引用) */
|
|
export type AutoExpr =
|
|
| string
|
|
| number
|
|
| boolean
|
|
/** 入参 / 基座读数 / `let` 里算好的值 —— 三者同一个命名空间,`let` 优先 */
|
|
| { readonly in: string }
|
|
/** 本行的列 */
|
|
| { readonly col: string }
|
|
/** 外层那一行的列(两层嵌套时) */
|
|
| { readonly outer: string }
|
|
/** 拼接,一律按字符串 */
|
|
| { readonly cat: readonly AutoExpr[] }
|
|
/** 只留数字(铸号:`case:openedAt` → `20260829215103`) */
|
|
| { readonly fmt: AutoExpr; readonly as: 'digits' }
|
|
/** 查另一张表的一列;查不到给 `else`(缺省空串) */
|
|
| {
|
|
readonly lookup: string;
|
|
readonly by: Readonly<Record<string, AutoExpr>>;
|
|
readonly take: string;
|
|
readonly else?: AutoExpr;
|
|
}
|
|
/** S-FEEL 条件(可嵌套)—— 已算出的同行字段进它的作用域 */
|
|
| { readonly when: string; readonly then: AutoExpr; readonly else: AutoExpr };
|
|
|
|
export const autoExprSchema: z.ZodType<AutoExpr> = z.lazy(() =>
|
|
z.union([
|
|
z.string(),
|
|
z.number(),
|
|
z.boolean(),
|
|
z.object({ in: z.string().min(1) }).strict(),
|
|
z.object({ col: z.string().min(1) }).strict(),
|
|
z.object({ outer: z.string().min(1) }).strict(),
|
|
z.object({ cat: z.array(autoExprSchema).min(1) }).strict(),
|
|
z.object({ fmt: autoExprSchema, as: z.literal('digits') }).strict(),
|
|
z
|
|
.object({
|
|
lookup: z.string().min(1),
|
|
by: z.record(z.string(), autoExprSchema),
|
|
take: z.string().min(1),
|
|
else: autoExprSchema.optional(),
|
|
})
|
|
.strict(),
|
|
z.object({ when: z.string().min(1), then: autoExprSchema, else: autoExprSchema }).strict(),
|
|
]),
|
|
);
|
|
|
|
/**
|
|
* 数据源 —— 这一笔要对着几行办。
|
|
*
|
|
* 地址只有四种(spec §5.1):`域.对象`(**任务视图 = 正式表 + 本单草稿叠加**,默认该用的那一种)·
|
|
* `mine:域.对象`(只有本单草稿写下的行)· `in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
|
|
* **没有「纯正式表」那一种** —— 要区分「本单写的」与「别人放行的」,`mine:` 就够了。
|
|
*/
|
|
export type AutoSource =
|
|
| { readonly once: true }
|
|
| { readonly rows: readonly Readonly<Record<string, AutoExpr>>[] }
|
|
| {
|
|
readonly each: string;
|
|
readonly where?: Readonly<Record<string, AutoExpr>>;
|
|
/** 第二层,**到此为止** —— 三层以上就别 auto 了 */
|
|
readonly then?: { readonly each: string; readonly where?: Readonly<Record<string, AutoExpr>> };
|
|
};
|
|
|
|
export const autoSourceSchema: z.ZodType<AutoSource> = z.union([
|
|
z.object({ once: z.literal(true) }).strict(),
|
|
z.object({ rows: z.array(z.record(z.string(), autoExprSchema)).min(1) }).strict(),
|
|
z
|
|
.object({
|
|
each: z.string().min(1),
|
|
where: z.record(z.string(), autoExprSchema).optional(),
|
|
then: z
|
|
.object({ each: z.string().min(1), where: z.record(z.string(), autoExprSchema).optional() })
|
|
.strict()
|
|
.optional(),
|
|
})
|
|
.strict(),
|
|
]);
|
|
|
|
/** 五个动词 —— `when` 为假就跳过这一笔 */
|
|
export type AutoAction =
|
|
| {
|
|
readonly kind: 'project';
|
|
readonly when?: string;
|
|
/** 台账地址「域.对象」—— 必须在 `declarations.ledger` 里(静态审) */
|
|
readonly into: string;
|
|
/** 这一笔投影出来的行**就是这张表在本单草稿里的全部**,多出来的撤掉。只动草稿层 */
|
|
readonly prune?: boolean;
|
|
readonly from: AutoSource;
|
|
readonly fields: Readonly<Record<string, AutoExpr>>;
|
|
}
|
|
| {
|
|
readonly kind: 'analyze';
|
|
readonly when?: string;
|
|
/** 活动声明过的模型名 */
|
|
readonly model: string;
|
|
/** 出参落进拍内作用域 `result:<into>`,供后面的 project 用 `each`/`lookup` 消费 */
|
|
readonly into: string;
|
|
/** 给模型的 system 段 —— 说清判什么、四态各是什么意思 */
|
|
readonly instruction: string;
|
|
/** 入参由声明组装:一行一条,`fields` 决定每条摆哪几个键 */
|
|
readonly input: { readonly from: AutoSource; readonly fields: Readonly<Record<string, AutoExpr>> };
|
|
/** 出参 JSON Schema —— 顶层必须是 object */
|
|
readonly output: Readonly<Record<string, unknown>>;
|
|
}
|
|
| {
|
|
readonly kind: 'card';
|
|
readonly when?: string;
|
|
readonly as: 'execution' | 'decide';
|
|
readonly variant?: 'data' | 'review';
|
|
readonly title: AutoExpr;
|
|
/** 决策卡问的那句话 */
|
|
readonly ask?: AutoExpr;
|
|
/** 作业指导书 —— 一条一句,静态文本 */
|
|
readonly guide?: readonly string[];
|
|
readonly standard?: AutoExpr;
|
|
/** 调用必附上下文 —— **基座拒发 basis 为空的工单**,别指望兜底(它摆的是内部家具) */
|
|
readonly basis?: readonly { readonly k: string; readonly v: AutoExpr }[];
|
|
/** 这张卡的提交闸盯哪几张表 */
|
|
readonly gateLedgers?: readonly string[];
|
|
/** 决策卡的选项;`variant:'review'` 时允许只有一个 */
|
|
readonly options?: readonly { readonly value: string; readonly label: string; readonly detail?: string }[];
|
|
}
|
|
| { readonly kind: 'submit'; readonly when?: string; readonly rationale: AutoExpr }
|
|
| { readonly kind: 'say'; readonly when?: string; readonly text: AutoExpr };
|
|
|
|
export const autoActionSchema: z.ZodType<AutoAction> = z.discriminatedUnion('kind', [
|
|
z
|
|
.object({
|
|
kind: z.literal('project'),
|
|
when: z.string().min(1).optional(),
|
|
into: z.string().regex(/^[a-z_]+\.[a-z_]+$/, '台账地址写成「域.对象」'),
|
|
prune: z.boolean().optional(),
|
|
from: autoSourceSchema,
|
|
fields: z.record(z.string(), autoExprSchema),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
kind: z.literal('analyze'),
|
|
when: z.string().min(1).optional(),
|
|
model: z.string().min(1),
|
|
into: z.string().min(1),
|
|
instruction: z.string().min(1),
|
|
input: z.object({ from: autoSourceSchema, fields: z.record(z.string(), autoExprSchema) }).strict(),
|
|
output: z.record(z.string(), z.unknown()),
|
|
})
|
|
.strict(),
|
|
z
|
|
.object({
|
|
kind: z.literal('card'),
|
|
when: z.string().min(1).optional(),
|
|
as: z.enum(['execution', 'decide']),
|
|
variant: z.enum(['data', 'review']).optional(),
|
|
title: autoExprSchema,
|
|
ask: autoExprSchema.optional(),
|
|
guide: z.array(z.string().min(1)).optional(),
|
|
standard: autoExprSchema.optional(),
|
|
basis: z.array(z.object({ k: z.string().min(1), v: autoExprSchema }).strict()).optional(),
|
|
gateLedgers: z.array(z.string().min(1)).optional(),
|
|
options: z
|
|
.array(z.object({ value: z.string().min(1), label: z.string().min(1), detail: z.string().optional() }).strict())
|
|
.optional(),
|
|
})
|
|
.strict(),
|
|
z.object({ kind: z.literal('submit'), when: z.string().min(1).optional(), rationale: autoExprSchema }).strict(),
|
|
z.object({ kind: z.literal('say'), when: z.string().min(1).optional(), text: autoExprSchema }).strict(),
|
|
]);
|
|
|
|
export type AutoRecipe = {
|
|
/** 配方级局部值:先算一遍,后面所有表达式都能 `{in:'名'}` 引用 —— 整单只有一个案号,靠它 */
|
|
readonly let?: Readonly<Record<string, AutoExpr>>;
|
|
/** 按序执行 */
|
|
readonly do: readonly AutoAction[];
|
|
};
|
|
|
|
export const autoRecipeSchema: z.ZodType<AutoRecipe> = z
|
|
.object({
|
|
let: z.record(z.string(), autoExprSchema).optional(),
|
|
do: z.array(autoActionSchema).min(1),
|
|
})
|
|
.strict();
|
|
```
|
|
|
|
- [ ] **Step 4: 挂到 `PlanStep` 上**
|
|
|
|
在 `packages/contracts/src/manifest.ts` 里,`planStepSchema` 的 `humanWrites` 那一行**之后**、`});` 之前插入:
|
|
|
|
```ts
|
|
/**
|
|
* **这一步系统按声明自己做**(2026-08-29,ADR-014 / spec §5)。
|
|
*
|
|
* 引擎在这一步的每一拍拍首跑配方:发了卡就收拍(零 token);没发卡且这一步没有 `guide`
|
|
* 就重求 `doneWhen` 收步;有 `guide` 的照常问模型(半 auto)。
|
|
* **老活动一个字不用改** —— 不写就是不写,不是缺件。
|
|
*/
|
|
auto: autoRecipeSchema.optional(),
|
|
```
|
|
|
|
并在该文件顶部的 import 段加:
|
|
|
|
```ts
|
|
import { autoRecipeSchema } from './auto-recipe.ts';
|
|
```
|
|
|
|
- [ ] **Step 5: 转出契约**
|
|
|
|
在 `packages/contracts/src/index.ts` 里加一行(按文件里既有的字母序位置放):
|
|
|
|
```ts
|
|
export * from './auto-recipe.ts';
|
|
```
|
|
|
|
- [ ] **Step 6: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run packages/contracts/src/__tests__/auto-recipe.test.ts`
|
|
Expected: PASS(4 个用例全绿)
|
|
|
|
- [ ] **Step 7: 刷 api-report 并跑全量门禁**
|
|
|
|
Run:
|
|
```bash
|
|
pnpm api:check --update && git add api-report
|
|
pnpm gates
|
|
```
|
|
Expected: 全绿。`api:check` 若报「契约变了没入库」,说明上一条没跑或没 `git add`。
|
|
|
|
- [ ] **Step 8: 提交**
|
|
|
|
```bash
|
|
git add packages/contracts api-report
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(contracts): auto 配方契约 —— 确定的活由引擎按声明做,五个动词四种地址
|
|
|
|
配方是数据不是代码(ADR-004:不执行包代码即知包为何物),装进来可静态审、可回放。
|
|
只声明真实现了的那几式;嵌套两层封顶,没实现的形状 zod 当场拒。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: 表达式求值器(纯函数)
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts`
|
|
- Create: `apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1 的 `AutoExpr`
|
|
- Produces:
|
|
- `type Row = Readonly<Record<string, unknown>>`
|
|
- `type ExprCtx = { scope: Readonly<Record<string, unknown>>; lets: Readonly<Record<string, unknown>>; row?: Row; outer?: Row; computed?: Row; condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean }`
|
|
- `function rowsAt(scope: Readonly<Record<string, unknown>>, address: string): readonly Row[]`
|
|
- `function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown`
|
|
- `function evalText(expr: AutoExpr, ctx: ExprCtx): string`
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts
|
|
import { describe, expect, it } from 'vitest';
|
|
import { evalExpr, evalText, rowsAt } from '../internal/domain/auto-expr.ts';
|
|
|
|
/** 假的 S-FEEL:只认这份测试用得到的两句,够验证「条件走的是注入进来的求值器」 */
|
|
const condition = (expr: string, scope: Readonly<Record<string, unknown>>): boolean => {
|
|
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
|
|
if (expr === 'n > 0') return Number(scope['n'] ?? 0) > 0;
|
|
throw new Error(`没教过这句:${expr}`);
|
|
};
|
|
const base = { scope: {}, lets: {}, condition };
|
|
|
|
describe('rowsAt:地址取行', () => {
|
|
const scope = {
|
|
'parts.profile': [{ internal_pn: 'A', chip_type_id: 1 }],
|
|
'mine:sub_eval.part': [{ internal_pn: 'B' }],
|
|
pns: ['X', 'Y'],
|
|
'result:items': [{ param_name: 'p' }],
|
|
};
|
|
it('域.对象 取任务视图整表', () => {
|
|
expect(rowsAt(scope, 'parts.profile')).toEqual([{ internal_pn: 'A', chip_type_id: 1 }]);
|
|
});
|
|
it('mine: 只取本单草稿', () => {
|
|
expect(rowsAt(scope, 'mine:sub_eval.part')).toEqual([{ internal_pn: 'B' }]);
|
|
});
|
|
it('in: 取入参数组;字符串元素归一成 { value }', () => {
|
|
expect(rowsAt(scope, 'in:pns')).toEqual([{ value: 'X' }, { value: 'Y' }]);
|
|
});
|
|
it('result: 取本拍 AI 出参', () => {
|
|
expect(rowsAt(scope, 'result:items')).toEqual([{ param_name: 'p' }]);
|
|
});
|
|
it('取不到就是空数组,不抛 —— 首拍很多表本来就还没有行', () => {
|
|
expect(rowsAt(scope, 'nope.nope')).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('evalExpr:八种形状', () => {
|
|
it('常量原样', () => {
|
|
expect(evalExpr('W05', base)).toBe('W05');
|
|
expect(evalExpr(7, base)).toBe(7);
|
|
expect(evalExpr(true, base)).toBe(true);
|
|
});
|
|
it('{in} 先看 let,再看作用域', () => {
|
|
const ctx = { ...base, scope: { pn: 'FROM-SCOPE', other: 'S' }, lets: { pn: 'FROM-LET' } };
|
|
expect(evalExpr({ in: 'pn' }, ctx)).toBe('FROM-LET');
|
|
expect(evalExpr({ in: 'other' }, ctx)).toBe('S');
|
|
});
|
|
it('{col} 取本行,{outer} 取外层那一行', () => {
|
|
const ctx = { ...base, row: { a: 1 }, outer: { b: 2 } };
|
|
expect(evalExpr({ col: 'a' }, ctx)).toBe(1);
|
|
expect(evalExpr({ outer: 'b' }, ctx)).toBe(2);
|
|
});
|
|
it('取不到的列是空串,不是 undefined —— 空串写进草稿层等于「这一列没写」', () => {
|
|
expect(evalExpr({ col: 'nope' }, { ...base, row: {} })).toBe('');
|
|
});
|
|
it('{cat} 按字符串拼', () => {
|
|
expect(evalExpr({ cat: ['W05-', { in: 'd' }] }, { ...base, lets: { d: 20260829 } })).toBe('W05-20260829');
|
|
});
|
|
it('{fmt as digits} 只留数字', () => {
|
|
expect(evalExpr({ fmt: { in: 't' }, as: 'digits' }, { ...base, lets: { t: '2026-08-29T21:51:03Z' } })).toBe('20260829215103');
|
|
});
|
|
it('{lookup} 查另一张表的一列', () => {
|
|
const ctx = { ...base, scope: { 'parts.profile': [{ internal_pn: 'A', chip_type_id: 3 }] }, row: { pn: 'A' } };
|
|
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe(3);
|
|
});
|
|
it('{lookup} 查不到给 else,缺省空串', () => {
|
|
const ctx = { ...base, scope: { 'parts.profile': [] }, row: { pn: 'A' } };
|
|
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'chip_type_id' }, ctx)).toBe('');
|
|
expect(evalExpr({ lookup: 'parts.profile', by: { internal_pn: { col: 'pn' } }, take: 'x', else: 'absent' }, ctx)).toBe('absent');
|
|
});
|
|
it('{lookup} 比值按字符串 —— 台账里数字与文本混着存', () => {
|
|
const ctx = { ...base, scope: { 'a.b': [{ k: 1, v: 'hit' }] }, row: { k: '1' } };
|
|
expect(evalExpr({ lookup: 'a.b', by: { k: { col: 'k' } }, take: 'v' }, ctx)).toBe('hit');
|
|
});
|
|
it('{when} 走注入的 S-FEEL,已算出的同行字段进它的作用域', () => {
|
|
const ctx = { ...base, computed: { found_ref: 'LM2596S-5.0' } };
|
|
expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, ctx)).toBe('found');
|
|
expect(evalExpr({ when: 'found_ref != ""', then: 'found', else: 'absent' }, { ...base, computed: { found_ref: '' } })).toBe('absent');
|
|
});
|
|
it('{when} 谓词算不出来时走 else,不把整拍拖垮', () => {
|
|
expect(evalExpr({ when: '这句没教过', then: 'a', else: 'b' }, base)).toBe('b');
|
|
});
|
|
});
|
|
|
|
describe('evalText:一律出字符串', () => {
|
|
it('数字与布尔照实转', () => {
|
|
expect(evalText(3, base)).toBe('3');
|
|
expect(evalText(true, base)).toBe('true');
|
|
});
|
|
it('空与 undefined 都是空串', () => {
|
|
expect(evalText({ col: 'nope' }, { ...base, row: {} })).toBe('');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts`
|
|
Expected: FAIL —— `Failed to resolve import "../internal/domain/auto-expr.ts"`
|
|
|
|
- [ ] **Step 3: 写实现**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts
|
|
/**
|
|
* 配方表达式求值 —— **纯函数,零 IO**(编码规范:domain 层不碰端口)。
|
|
*
|
|
* S-FEEL 不在这里实现:`condition` 由调用方注入(与哨兵、`doneWhen` 同一个求值器)。
|
|
* **不发明第二套求值** 是 ADR-014 的一条硬约束。
|
|
*/
|
|
import type { AutoExpr } from '@contracts';
|
|
|
|
export type Row = Readonly<Record<string, unknown>>;
|
|
|
|
export type ExprCtx = {
|
|
/** 任务视图(正式 + 本单草稿叠加)+ 基座读数 + 起单入参,外加本拍的 `result:*` */
|
|
readonly scope: Readonly<Record<string, unknown>>;
|
|
/** 配方级 `let` 算好的值 —— 与 scope 同一命名空间,它优先 */
|
|
readonly lets: Readonly<Record<string, unknown>>;
|
|
readonly row?: Row;
|
|
readonly outer?: Row;
|
|
/** 同一行里**已经算出来**的字段 —— 「先 lookup 出依据,再按依据判 found/absent」靠它 */
|
|
readonly computed?: Row;
|
|
readonly condition: (expr: string, scope: Readonly<Record<string, unknown>>) => boolean;
|
|
};
|
|
|
|
/**
|
|
* 地址取行 —— 四种地址(spec §5.1),取不到一律空数组。
|
|
*
|
|
* **取不到不抛**:首拍很多表本来就一行都还没有,抛了等于每一单开局就交人。
|
|
*/
|
|
export function rowsAt(scope: Readonly<Record<string, unknown>>, address: string): readonly Row[] {
|
|
const raw = address.startsWith('in:') ? scope[address.slice('in:'.length)] : scope[address];
|
|
if (!Array.isArray(raw)) return [];
|
|
return raw.map((item) =>
|
|
typeof item === 'object' && item !== null ? (item as Row) : ({ value: item } satisfies Row),
|
|
);
|
|
}
|
|
|
|
/** 一个值参与比较时的归一:台账里数字与文本混着存,一律按字符串比 */
|
|
function asKey(value: unknown): string {
|
|
return value === null || value === undefined ? '' : String(value);
|
|
}
|
|
|
|
export function evalExpr(expr: AutoExpr, ctx: ExprCtx): unknown {
|
|
if (typeof expr === 'string' || typeof expr === 'number' || typeof expr === 'boolean') return expr;
|
|
|
|
if ('in' in expr) {
|
|
const fromLet = ctx.lets[expr.in];
|
|
return fromLet !== undefined ? fromLet : (ctx.scope[expr.in] ?? '');
|
|
}
|
|
if ('col' in expr) return ctx.row?.[expr.col] ?? '';
|
|
if ('outer' in expr) return ctx.outer?.[expr.outer] ?? '';
|
|
if ('cat' in expr) return expr.cat.map((part) => evalText(part, ctx)).join('');
|
|
if ('fmt' in expr) return evalText(expr.fmt, ctx).replace(/\D/g, '');
|
|
|
|
if ('lookup' in expr) {
|
|
const pairs = Object.entries(expr.by).map(([column, valueExpr]) => [column, asKey(evalExpr(valueExpr, ctx))] as const);
|
|
const hit = rowsAt(ctx.scope, expr.lookup).find((row) => pairs.every(([column, want]) => asKey(row[column]) === want));
|
|
if (hit === undefined) return expr.else === undefined ? '' : evalExpr(expr.else, ctx);
|
|
return hit[expr.take] ?? (expr.else === undefined ? '' : evalExpr(expr.else, ctx));
|
|
}
|
|
|
|
/*
|
|
* 条件走注入的 S-FEEL。谓词写坏了**不该拖垮整拍** —— 与 `doneWhen` 同一条处置:
|
|
* 当作「没算出来」走 else。真写错了,静态审在装包时就该拦下(Task 9)。
|
|
*/
|
|
const scope = { ...ctx.scope, ...ctx.lets, ...(ctx.outer ?? {}), ...(ctx.row ?? {}), ...(ctx.computed ?? {}) };
|
|
let hit = false;
|
|
try {
|
|
hit = ctx.condition(expr.when, scope);
|
|
} catch {
|
|
hit = false;
|
|
}
|
|
return evalExpr(hit ? expr.then : expr.else, ctx);
|
|
}
|
|
|
|
/** 出字符串的那一路 —— 拼接、标题、留痕都要它 */
|
|
export function evalText(expr: AutoExpr, ctx: ExprCtx): string {
|
|
const value = evalExpr(expr, ctx);
|
|
return value === null || value === undefined ? '' : String(value);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts`
|
|
Expected: PASS(18 个用例)
|
|
|
|
- [ ] **Step 5: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/loop-engine/internal/domain/auto-expr.ts apps/api/src/modules/loop-engine/__tests__/auto-expr.test.ts
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(loop-engine): 配方表达式求值 —— 八种形状,S-FEEL 仍由外面注入
|
|
|
|
地址四种、取不到一律空数组(首拍很多表本来就没行,抛了等于开局交人);
|
|
比值一律按字符串(台账里数字与文本混着存);谓词算不出来走 else,不拖垮整拍。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: 数据源展开(纯函数)
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/modules/loop-engine/internal/domain/auto-source.ts`
|
|
- Create: `apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1 的 `AutoSource`;Task 2 的 `ExprCtx` / `Row` / `evalExpr` / `rowsAt`
|
|
- Produces: `type ExpandedRow = { readonly row: Row; readonly outer?: Row }`;`function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[]`
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts
|
|
import { describe, expect, it } from 'vitest';
|
|
import { expandSource } from '../internal/domain/auto-source.ts';
|
|
|
|
const condition = (): boolean => false;
|
|
const ctxWith = (scope: Record<string, unknown>) => ({ scope, lets: {}, condition });
|
|
|
|
describe('expandSource', () => {
|
|
it('once:恰一行,行是空的', () => {
|
|
expect(expandSource({ once: true }, ctxWith({}))).toEqual([{ row: {} }]);
|
|
});
|
|
|
|
it('rows:字面几行,每格现算', () => {
|
|
const out = expandSource(
|
|
{ rows: [{ pn: { in: 'a' }, role: 'original' }, { pn: { in: 'b' }, role: 'candidate' }] },
|
|
{ scope: { a: 'LM2596S-5.0', b: 'XL2596S-5.0' }, lets: {}, condition },
|
|
);
|
|
expect(out).toEqual([
|
|
{ row: { pn: 'LM2596S-5.0', role: 'original' } },
|
|
{ row: { pn: 'XL2596S-5.0', role: 'candidate' } },
|
|
]);
|
|
});
|
|
|
|
it('each:按地址逐行', () => {
|
|
const out = expandSource({ each: 'mine:sub_eval.part' }, ctxWith({ 'mine:sub_eval.part': [{ pn: 'A' }, { pn: 'B' }] }));
|
|
expect(out.map((e) => e.row)).toEqual([{ pn: 'A' }, { pn: 'B' }]);
|
|
});
|
|
|
|
it('each + where:等值过滤,比值按字符串', () => {
|
|
const out = expandSource(
|
|
{ each: 'mine:sub_eval.part', where: { exists_status: 'absent' } },
|
|
ctxWith({ 'mine:sub_eval.part': [{ pn: 'A', exists_status: 'found' }, { pn: 'B', exists_status: 'absent' }] }),
|
|
);
|
|
expect(out.map((e) => e.row['pn'])).toEqual(['B']);
|
|
});
|
|
|
|
it('两层:里层的 where 认得出外层那一行', () => {
|
|
const out = expandSource(
|
|
{ each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
|
ctxWith({
|
|
'mine:sub_eval.part': [{ pn: 'A', chip_type_id: 1 }, { pn: 'B', chip_type_id: 2 }],
|
|
'parts.type_param': [
|
|
{ type_id: 1, param_name: '输入电压范围' },
|
|
{ type_id: 1, param_name: '输出电流' },
|
|
{ type_id: 2, param_name: '压差' },
|
|
],
|
|
}),
|
|
);
|
|
expect(out).toEqual([
|
|
{ outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输入电压范围' } },
|
|
{ outer: { pn: 'A', chip_type_id: 1 }, row: { type_id: 1, param_name: '输出电流' } },
|
|
{ outer: { pn: 'B', chip_type_id: 2 }, row: { type_id: 2, param_name: '压差' } },
|
|
]);
|
|
});
|
|
|
|
it('外层的 chip_type_id 还是空的时候,里层一行都不命中 —— 「保存即铺格」靠的就是这一条', () => {
|
|
const out = expandSource(
|
|
{ each: 'mine:sub_eval.part', then: { each: 'parts.type_param', where: { type_id: { outer: 'chip_type_id' } } } },
|
|
ctxWith({
|
|
'mine:sub_eval.part': [{ pn: 'A', chip_type_id: '' }],
|
|
'parts.type_param': [{ type_id: 1, param_name: '输入电压范围' }],
|
|
}),
|
|
);
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts`
|
|
Expected: FAIL —— `Failed to resolve import "../internal/domain/auto-source.ts"`
|
|
|
|
- [ ] **Step 3: 写实现**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/internal/domain/auto-source.ts
|
|
/**
|
|
* 配方数据源展开 —— **纯函数,零 IO**。
|
|
*
|
|
* 嵌套**两层封顶**:两层(某几颗料 × 该类型的参数字段)是已知业务的上限;
|
|
* 超出就别 auto —— 退回模型,或者把那一段切出去。
|
|
*/
|
|
import type { AutoExpr, AutoSource } from '@contracts';
|
|
import { evalExpr, rowsAt, type ExprCtx, type Row } from './auto-expr.ts';
|
|
|
|
export type ExpandedRow = { readonly row: Row; readonly outer?: Row };
|
|
|
|
/** where 是等值过滤;比值按字符串(台账里数字与文本混着存) */
|
|
function matches(
|
|
row: Row,
|
|
where: Readonly<Record<string, AutoExpr>> | undefined,
|
|
ctx: ExprCtx,
|
|
outer?: Row,
|
|
): boolean {
|
|
if (where === undefined) return true;
|
|
return Object.entries(where).every(([column, valueExpr]) => {
|
|
const want = evalExpr(valueExpr, { ...ctx, row, ...(outer === undefined ? {} : { outer }) });
|
|
const got = row[column];
|
|
return (got === null || got === undefined ? '' : String(got)) === (want === null || want === undefined ? '' : String(want));
|
|
});
|
|
}
|
|
|
|
export function expandSource(source: AutoSource, ctx: ExprCtx): readonly ExpandedRow[] {
|
|
if ('once' in source) return [{ row: {} }];
|
|
|
|
if ('rows' in source) {
|
|
return source.rows.map((literal) => {
|
|
const row: Record<string, unknown> = {};
|
|
for (const [name, expr] of Object.entries(literal)) row[name] = evalExpr(expr, { ...ctx, row });
|
|
return { row };
|
|
});
|
|
}
|
|
|
|
const outers = rowsAt(ctx.scope, source.each).filter((row) => matches(row, source.where, ctx));
|
|
if (source.then === undefined) return outers.map((row) => ({ row }));
|
|
|
|
const inner = source.then;
|
|
const out: ExpandedRow[] = [];
|
|
for (const outer of outers) {
|
|
for (const row of rowsAt(ctx.scope, inner.each)) {
|
|
if (matches(row, inner.where, ctx, outer)) out.push({ row, outer });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts`
|
|
Expected: PASS(6 个用例)
|
|
|
|
- [ ] **Step 5: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/loop-engine/internal/domain/auto-source.ts apps/api/src/modules/loop-engine/__tests__/auto-source.test.ts
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(loop-engine): 配方数据源展开 —— once / rows / each,嵌套两层封顶
|
|
|
|
里层的 where 认得出外层那一行:外层字段还是空的时候一行都不命中 ——
|
|
「人挑了类型、按了保存,参数格才出来」不是特判,是这一条的自然结果。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: 配方执行器 —— `project` / `prune` / `say`
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`
|
|
- Create: `apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`
|
|
- Modify: `apps/api/src/modules/loop-engine/port.ts`(新增 `AnalyzeGateway` 类型;实现留到 Task 5)
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 2 的 `evalExpr` / `evalText` / `rowsAt`;Task 3 的 `expandSource`;现有 `TaskDataGateway`(`view` / `writable` / `draftWrite` / `draftDrop`)、`CardGateway`、`MemoryRecorder`、`Sfeel`
|
|
- Produces:
|
|
- `class AutoRecipeError extends Error`(带 `actionIndex`)
|
|
- `type AnalyzeGateway = { run(input: { taskId: TaskId; model: string; instruction: string; payload: readonly unknown[]; outputSchema: Readonly<Record<string, unknown>>; label: string }): Promise<{ readonly object: Readonly<Record<string, unknown>> }> }`
|
|
- `type AutoContext = { taskId: TaskId; stepId: string; ownerId: StaffId; humanCallCap: number; quiet: boolean; carded: boolean }`
|
|
- `type AutoOutcome = { wrote: number; dropped: number; issuedCardId?: string; submit?: { rationale: string }; failedAt?: { index: number; message: string } }`
|
|
- `function createAutoRunner(deps: AutoRunnerDeps): { run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome> }`
|
|
|
|
- [ ] **Step 1: 先把 `AnalyzeGateway` 加进 port.ts**
|
|
|
|
在 `apps/api/src/modules/loop-engine/port.ts` 末尾(`ViewGateway` 之后)加:
|
|
|
|
```ts
|
|
/**
|
|
* **一次结构化 AI 调用** —— 配方的 `analyze` 动词(spec §5.2)。
|
|
*
|
|
* 与 `AgentPort` 的分工是死的:`AgentPort` 是「问它下一步干什么」,它能调工具、能写台账;
|
|
* 这一件**只是一个纯函数**:系统组装好入参,它出一份结构化判断,系统把结果落表。
|
|
* **不给工具、不给记忆、不多轮。** 出参不合 schema 由实现方就地重问一次,再不成就抛。
|
|
*/
|
|
export type AnalyzeGateway = {
|
|
run(input: {
|
|
readonly taskId: TaskId;
|
|
/** 活动声明过的模型名 */
|
|
readonly model: string;
|
|
readonly instruction: string;
|
|
/** 系统组装好的入参 —— 一行一条 */
|
|
readonly payload: readonly unknown[];
|
|
readonly outputSchema: Readonly<Record<string, unknown>>;
|
|
/** 归因:`<步名>#<第几笔>` */
|
|
readonly label: string;
|
|
}): Promise<{ readonly object: Readonly<Record<string, unknown>> }>;
|
|
};
|
|
```
|
|
|
|
并把 `AnalyzeGateway` 加进 `TurnRunnerDeps`(`apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts` 第 43 行起的那个类型)最后一行之前:
|
|
|
|
```ts
|
|
readonly analyze: AnalyzeGateway;
|
|
```
|
|
|
|
以及在该文件顶部的 import 里补上 `AnalyzeGateway`(与 `AgentPort` 同一行 import)。
|
|
|
|
这一步会让**两个地方**少一个必填属性,两处都要在本任务里补上,否则 `typecheck` 当场红:
|
|
|
|
**① 装配层占位**(`apps/api/src/app/wiring/engines.ts`,Task 5 换成真实现):
|
|
|
|
```ts
|
|
analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) },
|
|
```
|
|
|
|
**② 测试脚手架的桩**(`apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts`,
|
|
在 `const deps: TurnRunnerDeps = {` 里、`agent: { … } as never,` 之后加一行):
|
|
|
|
```ts
|
|
analyze: { run: async () => ({ object: {} }) } as never,
|
|
```
|
|
|
|
补完先跑一遍确认既有用例没被碰坏:
|
|
`pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts`
|
|
|
|
- [ ] **Step 2: 写失败的测试**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { AutoRecipe } from '@contracts';
|
|
import { taskIdSchema, staffIdSchema } from '@contracts';
|
|
import { createAutoRunner } from '../internal/usecase/auto-runner.ts';
|
|
|
|
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
|
const OWNER = staffIdSchema.parse('22222222-2222-4222-8222-222222222222');
|
|
|
|
/** 假端口:一个内存草稿层 + 一本台账声明。**不是 mock 框架,是手写的依赖注入替身。** */
|
|
function harness(seed: Record<string, unknown> = {}) {
|
|
const drafts = new Map<string, { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }>();
|
|
const notes: string[] = [];
|
|
const view = (): Record<string, unknown> => {
|
|
const mine: Record<string, unknown[]> = {};
|
|
for (const d of drafts.values()) (mine[`mine:${d.domain}.${d.object}`] ??= []).push({ ...d.key, ...d.patch });
|
|
return { ...seed, ...mine };
|
|
};
|
|
const deps = {
|
|
data: {
|
|
view: () => Promise.resolve(view()),
|
|
writable: () =>
|
|
Promise.resolve([
|
|
{ domain: 'sub_eval', object: 'part', label: '本单两颗料', keys: ['eval_ref', 'internal_pn'], fields: [] },
|
|
{ domain: 'parts', object: 'parameter', label: '参数', keys: ['internal_pn', 'param_name'], fields: [] },
|
|
]),
|
|
draftWrite: (i: { domain: string; object: string; key: Record<string, string>; patch: Record<string, unknown> }) => {
|
|
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
|
const before = JSON.stringify(drafts.get(id)?.patch ?? null);
|
|
drafts.set(id, { domain: i.domain, object: i.object, key: i.key, patch: i.patch });
|
|
return Promise.resolve({ changed: before !== JSON.stringify(i.patch) });
|
|
},
|
|
draftDrop: (i: { domain: string; object: string; key: Record<string, string> }) => {
|
|
const id = `${i.domain}.${i.object}|${Object.values(i.key).join('|')}`;
|
|
return Promise.resolve({ dropped: drafts.delete(id) });
|
|
},
|
|
},
|
|
cards: {
|
|
issue: () => Promise.resolve({ id: 'card-1' }),
|
|
decide: () => Promise.resolve({ id: 'card-2' }),
|
|
entry: () => Promise.resolve({ id: 'card-3' }),
|
|
ask: () => Promise.resolve({ id: 'card-4' }),
|
|
unanswered: () => Promise.resolve([]),
|
|
},
|
|
memory: { append: (m: { title: string }) => { notes.push(m.title); return Promise.resolve(); } },
|
|
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
|
sfeel: {
|
|
condition: (expr: string, scope: Readonly<Record<string, unknown>>) => {
|
|
if (expr === 'never') return false;
|
|
if (expr === 'always') return true;
|
|
if (expr === 'found_ref != ""') return String(scope['found_ref'] ?? '') !== '';
|
|
throw new Error(`没教过这句:${expr}`);
|
|
},
|
|
expression: () => undefined,
|
|
unaryTest: () => false,
|
|
},
|
|
};
|
|
const ctx = { taskId: TASK, stepId: 'S1', ownerId: OWNER, humanCallCap: 8, quiet: false, carded: false };
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 假端口只实现用得到的那几个方法
|
|
return { runner: createAutoRunner(deps as never), drafts, notes, ctx };
|
|
}
|
|
|
|
describe('配方执行器', () => {
|
|
it('project once:写一行,主键与其余列分开', async () => {
|
|
const { runner, drafts, ctx } = harness({ pn: 'LM2596S-5.0' });
|
|
const recipe: AutoRecipe = {
|
|
let: { evalRef: 'W05-1' },
|
|
do: [
|
|
{
|
|
kind: 'project',
|
|
into: 'sub_eval.part',
|
|
from: { once: true },
|
|
fields: { eval_ref: { in: 'evalRef' }, internal_pn: { in: 'pn' }, role: 'original' },
|
|
},
|
|
],
|
|
};
|
|
const out = await runner.run(recipe, ctx);
|
|
expect(out.wrote).toBe(1);
|
|
expect([...drafts.values()][0]).toEqual({
|
|
domain: 'sub_eval',
|
|
object: 'part',
|
|
key: { eval_ref: 'W05-1', internal_pn: 'LM2596S-5.0' },
|
|
patch: { role: 'original' },
|
|
});
|
|
});
|
|
|
|
it('主键算不出来的那一行**先不写** —— 空主键写下去就是一行永远对不上的垃圾', async () => {
|
|
const { runner, drafts, ctx } = harness({});
|
|
const out = await runner.run(
|
|
{ do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'W05-1', internal_pn: { in: 'nope' }, role: 'x' } }] },
|
|
ctx,
|
|
);
|
|
expect(out.wrote).toBe(0);
|
|
expect(drafts.size).toBe(0);
|
|
});
|
|
|
|
it('when 为假就跳过这一笔', async () => {
|
|
const { runner, drafts, ctx } = harness({});
|
|
await runner.run({ do: [{ kind: 'project', when: 'never', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } }] }, ctx);
|
|
expect(drafts.size).toBe(0);
|
|
});
|
|
|
|
it('**每一笔之前刷读数**:第二笔 each 得看得见第一笔刚写下的行', async () => {
|
|
const { runner, drafts, ctx } = harness({});
|
|
await runner.run(
|
|
{
|
|
do: [
|
|
{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } },
|
|
{ kind: 'project', into: 'parts.parameter', from: { each: 'mine:sub_eval.part' }, fields: { internal_pn: { col: 'internal_pn' }, param_name: '输入电压范围', param_value: '' } },
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
const params = [...drafts.values()].filter((d) => d.object === 'parameter');
|
|
expect(params.map((p) => p.key['internal_pn']).sort()).toEqual(['A', 'B']);
|
|
});
|
|
|
|
it('值没变的写是空转:**不留痕、不计数**(配方不是模型,原地打转不必被看见)', async () => {
|
|
const { runner, ctx } = harness({});
|
|
const recipe: AutoRecipe = { do: [{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b', role: 'original' } }] };
|
|
await runner.run(recipe, ctx);
|
|
const second = await runner.run(recipe, ctx);
|
|
expect(second.wrote).toBe(0);
|
|
});
|
|
|
|
it('prune:这一笔没投影出来的行,从草稿层撤掉', async () => {
|
|
const { runner, drafts, ctx } = harness({});
|
|
await runner.run({ do: [{ kind: 'project', into: 'sub_eval.part', from: { rows: [{ pn: 'A' }, { pn: 'B' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] }, ctx);
|
|
expect(drafts.size).toBe(2);
|
|
const out = await runner.run(
|
|
{ do: [{ kind: 'project', into: 'sub_eval.part', prune: true, from: { rows: [{ pn: 'A' }] }, fields: { eval_ref: 'W05-1', internal_pn: { col: 'pn' } } }] },
|
|
ctx,
|
|
);
|
|
expect(out.dropped).toBe(1);
|
|
expect([...drafts.values()].map((d) => d.key['internal_pn'])).toEqual(['A']);
|
|
});
|
|
|
|
it('say:进工作日志,归系统', async () => {
|
|
const { runner, notes, ctx } = harness({ n: 2 });
|
|
await runner.run({ do: [{ kind: 'say', text: { cat: ['已核对 ', { in: 'n' }, ' 颗料'] } }] }, ctx);
|
|
expect(notes).toContain('已核对 2 颗料');
|
|
});
|
|
|
|
it('地址不在台账声明里 → 当场交人,不重试', async () => {
|
|
const { runner, ctx } = harness({});
|
|
const out = await runner.run({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }, ctx);
|
|
expect(out.failedAt?.index).toBe(0);
|
|
expect(out.failedAt?.message).toContain('nope.nope');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`
|
|
Expected: FAIL —— `Failed to resolve import "../internal/usecase/auto-runner.ts"`
|
|
|
|
- [ ] **Step 4: 写执行器(本任务只实现 project / prune / say;analyze / card / submit 留占位抛错)**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts
|
|
/**
|
|
* 配方执行器 —— 逐笔执行 `PlanStep.auto`(spec §5.1)。
|
|
*
|
|
* 三条硬规矩:
|
|
* ① **每一笔之前刷读数**:后一笔常常要数前一笔刚写下的行。不刷新就恒为 0 ——
|
|
* 推荐等级会永远算成「全 MATCH」,而屏上明明摆着两条不符。
|
|
* ② **幂等**:`draftWrite` 同 PK 覆盖,值没变**静默跳过**(不留痕、不计数)——
|
|
* 「写了等于没写」那条判据是拦模型空转的,配方不是模型;
|
|
* 发卡先看 `carded`(worldTouched),发过就不再发。
|
|
* ③ **出错 = 交人,不重试、不退回模型**:配方抛错是活动包的 bug。
|
|
* 退回模型就是让它去凑,那正是这套机制要消灭的东西。
|
|
*/
|
|
import type { AutoAction, AutoRecipe, StaffId, TaskId } from '@contracts';
|
|
import { evalExpr, evalText, rowsAt, type ExprCtx, type Row } from '../domain/auto-expr.ts';
|
|
import { expandSource } from '../domain/auto-source.ts';
|
|
import type { AnalyzeGateway, CardGateway, MemoryRecorder, Sfeel, TaskDataGateway } from '../../port.ts';
|
|
|
|
export class AutoRecipeError extends Error {
|
|
constructor(
|
|
readonly actionIndex: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'AutoRecipeError';
|
|
}
|
|
}
|
|
|
|
export type AutoRunnerDeps = {
|
|
readonly data: TaskDataGateway;
|
|
readonly cards: CardGateway;
|
|
readonly memory: MemoryRecorder;
|
|
readonly analyze: AnalyzeGateway;
|
|
readonly sfeel: Sfeel;
|
|
};
|
|
|
|
export type AutoContext = {
|
|
readonly taskId: TaskId;
|
|
readonly stepId: string;
|
|
readonly ownerId: StaffId;
|
|
readonly humanCallCap: number;
|
|
/** 有未答的卡在场:**只跑 project / say** —— 只算、只写,不发新卡、不 submit、不 analyze */
|
|
readonly quiet: boolean;
|
|
/** 这一步已经发过卡(worldTouched) */
|
|
readonly carded: boolean;
|
|
};
|
|
|
|
export type AutoOutcome = {
|
|
readonly wrote: number;
|
|
readonly dropped: number;
|
|
readonly issuedCardId?: string;
|
|
readonly submit?: { readonly rationale: string };
|
|
readonly failedAt?: { readonly index: number; readonly message: string };
|
|
};
|
|
|
|
const keyText = (key: Readonly<Record<string, string>>): string => Object.values(key).join('|');
|
|
|
|
export function createAutoRunner(deps: AutoRunnerDeps): {
|
|
run(recipe: AutoRecipe, ctx: AutoContext): Promise<AutoOutcome>;
|
|
} {
|
|
return {
|
|
async run(recipe, ctx) {
|
|
const writable = await deps.data.writable(ctx.taskId);
|
|
const results: Record<string, unknown[]> = {};
|
|
let scope: Record<string, unknown> = { ...(await deps.data.view(ctx.taskId)), ...results };
|
|
let dirty = false;
|
|
let wrote = 0;
|
|
let dropped = 0;
|
|
let issuedCardId: string | undefined;
|
|
let submit: { rationale: string } | undefined;
|
|
|
|
const condition = deps.sfeel.condition;
|
|
const lets: Record<string, unknown> = {};
|
|
for (const [name, expr] of Object.entries(recipe.let ?? {})) {
|
|
lets[name] = evalExpr(expr, { scope, lets, condition });
|
|
}
|
|
|
|
async function runAction(
|
|
action: AutoAction,
|
|
index: number,
|
|
base: ExprCtx,
|
|
context: AutoContext,
|
|
): Promise<{ wrote: number; dropped: number; cardId?: string; submit?: { rationale: string }; stop?: boolean }> {
|
|
if (action.kind === 'say') {
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'result',
|
|
title: evalText(action.text, base),
|
|
detail: { auto: `${context.stepId}#${index + 1}` },
|
|
});
|
|
return { wrote: 0, dropped: 0 };
|
|
}
|
|
|
|
if (action.kind === 'project') {
|
|
const [domain, object] = action.into.split('.');
|
|
const decl = writable.find((w) => w.domain === domain && w.object === object);
|
|
if (decl === undefined) {
|
|
throw new AutoRecipeError(index, `台账地址不在本活动的声明里:${action.into}(写的授权面是台账声明)`);
|
|
}
|
|
const before = rowsAt(base.scope, `mine:${action.into}`);
|
|
const kept = new Set<string>();
|
|
let localWrote = 0;
|
|
for (const { row, outer } of expandSource(action.from, base)) {
|
|
const computed: Record<string, unknown> = {};
|
|
for (const [name, expr] of Object.entries(action.fields)) {
|
|
computed[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed });
|
|
}
|
|
const key: Record<string, string> = {};
|
|
for (const column of decl.keys) key[column] = String(computed[column] ?? '');
|
|
/* 主键算不出来就先不写 —— 空主键写下去是一行永远对不上的垃圾 */
|
|
if (decl.keys.some((column) => key[column] === '')) continue;
|
|
const patch: Record<string, unknown> = {};
|
|
for (const [name, value] of Object.entries(computed)) if (!decl.keys.includes(name)) patch[name] = value;
|
|
const { changed } = await deps.data.draftWrite({ taskId: context.taskId, domain: decl.domain, object: decl.object, key, patch });
|
|
if (changed) localWrote += 1;
|
|
kept.add(keyText(key));
|
|
}
|
|
let localDropped = 0;
|
|
if (action.prune === true) {
|
|
for (const stale of before) {
|
|
const key: Record<string, string> = {};
|
|
for (const column of decl.keys) key[column] = String(stale[column] ?? '');
|
|
if (kept.has(keyText(key))) continue;
|
|
const { dropped: gone } = await deps.data.draftDrop({ taskId: context.taskId, domain: decl.domain, object: decl.object, key });
|
|
if (gone) localDropped += 1;
|
|
}
|
|
}
|
|
if (localWrote > 0 || localDropped > 0) {
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'result',
|
|
title: `${decl.label}:写下 ${localWrote} 行${localDropped === 0 ? '' : `,撤掉 ${localDropped} 行`}`,
|
|
detail: { auto: `${context.stepId}#${index + 1}`, internal: true, ledger: action.into },
|
|
});
|
|
}
|
|
return { wrote: localWrote, dropped: localDropped };
|
|
}
|
|
|
|
throw new AutoRecipeError(index, `动词尚未实现:${action.kind}`);
|
|
}
|
|
|
|
for (const [index, action] of recipe.do.entries()) {
|
|
try {
|
|
if (dirty) {
|
|
scope = { ...(await deps.data.view(ctx.taskId)), ...results };
|
|
dirty = false;
|
|
}
|
|
const base: ExprCtx = { scope, lets, condition };
|
|
if (action.when !== undefined && !condition(action.when, { ...scope, ...lets })) continue;
|
|
/* 有卡在等人交回:只算、只写 —— 不发新卡、不 submit、不 analyze */
|
|
if (ctx.quiet && action.kind !== 'project' && action.kind !== 'say') continue;
|
|
|
|
const done = await runAction(action, index, base, ctx);
|
|
wrote += done.wrote;
|
|
dropped += done.dropped;
|
|
if (done.wrote > 0 || done.dropped > 0) dirty = true;
|
|
if (done.cardId !== undefined) issuedCardId = done.cardId;
|
|
if (done.submit !== undefined) submit = done.submit;
|
|
if (done.stop) break;
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await deps.memory.append({
|
|
taskId: ctx.taskId,
|
|
kind: 'result',
|
|
title: `配方第 ${index + 1} 笔办不下去:${message} —— 交给你处理`,
|
|
detail: { auto: `${ctx.stepId}#${index + 1}`, internal: false },
|
|
});
|
|
return { wrote, dropped, ...(issuedCardId === undefined ? {} : { issuedCardId }), failedAt: { index, message } };
|
|
}
|
|
}
|
|
return {
|
|
wrote,
|
|
dropped,
|
|
...(issuedCardId === undefined ? {} : { issuedCardId }),
|
|
...(submit === undefined ? {} : { submit }),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`
|
|
Expected: PASS(8 个用例)
|
|
|
|
- [ ] **Step 6: 跑全量,确认没碰坏别人**
|
|
|
|
Run: `pnpm exec vitest run`
|
|
Expected: 全绿(现有 1600+ 用例)
|
|
|
|
- [ ] **Step 7: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/loop-engine
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(loop-engine): 配方执行器 —— project / prune / say,每一笔之前刷读数
|
|
|
|
三条硬规矩:每一笔之前刷读数(后一笔要数前一笔刚写下的行,不刷新就恒为 0);
|
|
值没变的写静默跳过(配方不是模型,原地打转不必被看见);
|
|
出错交人不重试不退回模型(配方抛错是包的 bug,退回模型就是让它去凑)。
|
|
主键算不出来的那一行先不写 —— 空主键写下去是一行永远对不上的垃圾。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: `analyze` 动词 —— 一次结构化 AI 调用
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(补 `analyze` 分支)
|
|
- Modify: `apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`(补 3 个用例)
|
|
- Create: `apps/api/src/app/wiring/analyze-gateway.ts`
|
|
- Create: `apps/api/src/app/__tests__/analyze-gateway.test.ts`
|
|
- Modify: `apps/api/src/app/wiring/engines.ts`(把 Task 4 的占位换成真实现)
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4 的 `AnalyzeGateway` 类型;现有 `ModelGateway.generate(call: ModelCall): Promise<ModelResult>`(`ModelCall` 有 `model` / `system` / `prompt` / `temperature` / `reasoning` / `outputSchema` / `taskId` / `label`;`ModelResult.object` 是 schema 给定时的解析结果)
|
|
- Produces: `function createAnalyzeGateway(llm: ModelGateway): AnalyzeGateway`
|
|
|
|
- [ ] **Step 1: 先把 `harness()` 改成可以换端口**
|
|
|
|
`analyze` 的用例要能换掉那个端口,所以先改 Task 4 写的 `harness()`:签名加第二个参数,
|
|
`deps.analyze` 那一行改成读它。
|
|
|
|
把 `auto-runner.test.ts` 里这一行:
|
|
|
|
```ts
|
|
function harness(seed: Record<string, unknown> = {}) {
|
|
```
|
|
|
|
改成:
|
|
|
|
```ts
|
|
type AnalyzeStub = { run: (i: { payload: readonly unknown[]; model: string; instruction: string }) => Promise<{ object: Record<string, unknown> }> };
|
|
|
|
function harness(seed: Record<string, unknown> = {}, override: { analyze?: AnalyzeStub } = {}) {
|
|
```
|
|
|
|
并把 `const deps = { ... }` 里的这一行:
|
|
|
|
```ts
|
|
analyze: { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
|
```
|
|
|
|
改成:
|
|
|
|
```ts
|
|
analyze: override.analyze ?? { run: () => Promise.reject(new Error('本用例不该调 analyze')) },
|
|
```
|
|
|
|
- [ ] **Step 2: 写 analyze 的三个失败用例**
|
|
|
|
在 `auto-runner.test.ts` 末尾追加:
|
|
|
|
```ts
|
|
describe('analyze 动词', () => {
|
|
it('入参由声明组装,出参落进 result: 供后一笔 each 消费', async () => {
|
|
let seen: readonly unknown[] = [];
|
|
const { runner, drafts, ctx } = harness(
|
|
{
|
|
'parts.type_param': [
|
|
{ type_id: 1, param_name: '输入电压范围', constraint_type: 'hard' },
|
|
{ type_id: 1, param_name: '输出电流', constraint_type: 'soft' },
|
|
],
|
|
typeId: 1,
|
|
},
|
|
{
|
|
analyze: {
|
|
run: (i) => {
|
|
seen = i.payload;
|
|
return Promise.resolve({
|
|
object: {
|
|
items: [
|
|
{ param_name: '输入电压范围', difference_status: 'match' },
|
|
{ param_name: '输出电流', difference_status: 'unknown' },
|
|
],
|
|
},
|
|
});
|
|
},
|
|
},
|
|
},
|
|
);
|
|
await runner.run(
|
|
{
|
|
do: [
|
|
{
|
|
kind: 'analyze',
|
|
model: 'deepseek-v4-flash',
|
|
into: 'items',
|
|
instruction: '逐条判四态',
|
|
input: {
|
|
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
|
|
fields: { param_name: { col: 'param_name' }, constraint_type: { col: 'constraint_type' } },
|
|
},
|
|
output: { type: 'object' },
|
|
},
|
|
{
|
|
kind: 'project',
|
|
into: 'parts.parameter',
|
|
from: { each: 'parts.type_param', where: { type_id: { in: 'typeId' } } },
|
|
fields: {
|
|
internal_pn: 'XL2596S-5.0',
|
|
param_name: { col: 'param_name' },
|
|
/* **行由字典决定,AI 出参只是查表** —— 它漏一条,那一格就是空 */
|
|
param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
expect(seen).toEqual([
|
|
{ param_name: '输入电压范围', constraint_type: 'hard' },
|
|
{ param_name: '输出电流', constraint_type: 'soft' },
|
|
]);
|
|
const values = [...drafts.values()].filter((d) => d.object === 'parameter').map((d) => d.patch['param_value']);
|
|
expect(values.sort()).toEqual(['match', 'unknown']);
|
|
});
|
|
|
|
it('AI 漏了一条:那一行的格子是空的 —— 行数不由它决定', async () => {
|
|
const { runner, drafts, ctx } = harness(
|
|
{ 'parts.type_param': [{ type_id: 1, param_name: 'A' }, { type_id: 1, param_name: 'B' }], typeId: 1 },
|
|
{ analyze: { run: () => Promise.resolve({ object: { items: [{ param_name: 'A', difference_status: 'match' }] } }) } },
|
|
);
|
|
await runner.run(
|
|
{
|
|
do: [
|
|
{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { param_name: { col: 'param_name' } } }, output: { type: 'object' } },
|
|
{ kind: 'project', into: 'parts.parameter', from: { each: 'parts.type_param' }, fields: { internal_pn: 'P', param_name: { col: 'param_name' }, param_value: { lookup: 'result:items', by: { param_name: { col: 'param_name' } }, take: 'difference_status' } } },
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
const rows = [...drafts.values()].filter((d) => d.object === 'parameter');
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.find((r) => r.key['param_name'] === 'B')?.patch['param_value']).toBe('');
|
|
});
|
|
|
|
it('有卡在等人交回时不调 AI —— 只算、只写', async () => {
|
|
let called = 0;
|
|
const { runner, ctx } = harness(
|
|
{ 'parts.type_param': [{ type_id: 1, param_name: 'A' }] },
|
|
{ analyze: { run: () => { called += 1; return Promise.resolve({ object: { items: [] } }); } } },
|
|
);
|
|
await runner.run(
|
|
{ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { each: 'parts.type_param' }, fields: { p: { col: 'param_name' } } }, output: { type: 'object' } }] },
|
|
{ ...ctx, quiet: true },
|
|
);
|
|
expect(called).toBe(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 3: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts -t analyze`
|
|
Expected: FAIL —— `动词尚未实现:analyze`
|
|
|
|
- [ ] **Step 4: 在执行器里实现 analyze 分支**
|
|
|
|
在 `auto-runner.ts` 的 `runAction` 里,`if (action.kind === 'project') { … }` 之后、最后那句 `throw` 之前插入:
|
|
|
|
```ts
|
|
if (action.kind === 'analyze') {
|
|
const payload = expandSource(action.input.from, base).map(({ row, outer }) => {
|
|
const item: Record<string, unknown> = {};
|
|
for (const [name, expr] of Object.entries(action.input.fields)) {
|
|
item[name] = evalExpr(expr, { ...base, row, ...(outer === undefined ? {} : { outer }), computed: item });
|
|
}
|
|
return item;
|
|
});
|
|
const { object } = await deps.analyze.run({
|
|
taskId: context.taskId,
|
|
model: action.model,
|
|
instruction: action.instruction,
|
|
payload,
|
|
outputSchema: action.output,
|
|
label: `${context.stepId}#${index + 1}`,
|
|
});
|
|
/* `into` 既是出参里那个数组字段的名字,也是拍内作用域的键 —— 一处声明,两处认 */
|
|
const rows = object[action.into];
|
|
results[`result:${action.into}`] = Array.isArray(rows) ? rows : [];
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'result',
|
|
title: `已把 ${payload.length} 条参数交给模型逐条判定,拿回 ${results[`result:${action.into}`]?.length ?? 0} 条结论`,
|
|
detail: { auto: `${context.stepId}#${index + 1}`, internal: true, model: action.model },
|
|
});
|
|
/* 出参只活在拍内作用域里,不进草稿层 —— 下一笔要能看见它,所以这里也算「脏」 */
|
|
scope = { ...scope, ...results };
|
|
return { wrote: 0, dropped: 0 };
|
|
}
|
|
```
|
|
|
|
> 注意:`results` 与 `scope` 是闭包里的变量;`analyze` 之后**不要**把 `dirty` 置真
|
|
> (它没写草稿层,刷新反而会把 `result:*` 冲掉;上面那句 `scope = { ...scope, ...results }` 已经够了)。
|
|
|
|
- [ ] **Step 5: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`
|
|
Expected: PASS(11 个用例)
|
|
|
|
- [ ] **Step 6: 写网关的失败测试**
|
|
|
|
```ts
|
|
// apps/api/src/app/__tests__/analyze-gateway.test.ts
|
|
import { describe, expect, it } from 'vitest';
|
|
import { taskIdSchema } from '@contracts';
|
|
import { createAnalyzeGateway } from '../wiring/analyze-gateway.ts';
|
|
|
|
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
|
const SCHEMA = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } };
|
|
const call = { taskId: TASK, model: 'deepseek-v4-flash', instruction: '逐条判四态', payload: [{ p: 'A' }], outputSchema: SCHEMA, label: 'S2#1' };
|
|
|
|
describe('analyze 网关', () => {
|
|
it('出参是对象就直接回,入参 JSON 进 prompt、instruction 进 system', async () => {
|
|
const seen: Array<Record<string, unknown>> = [];
|
|
const gateway = createAnalyzeGateway({
|
|
generate: (c: Record<string, unknown>) => {
|
|
seen.push(c);
|
|
return Promise.resolve({ text: '', object: { items: [{ p: 'A' }] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
|
},
|
|
} as never);
|
|
const out = await gateway.run(call);
|
|
expect(out.object).toEqual({ items: [{ p: 'A' }] });
|
|
expect(seen[0]?.['system']).toBe('逐条判四态');
|
|
expect(String(seen[0]?.['prompt'])).toContain('"p": "A"');
|
|
expect(seen[0]?.['reasoning']).toBe('off');
|
|
});
|
|
|
|
it('出参不成形 → **就地重问一次**,第二次成了就用它', async () => {
|
|
let n = 0;
|
|
const gateway = createAnalyzeGateway({
|
|
generate: () => {
|
|
n += 1;
|
|
return Promise.resolve(
|
|
n === 1
|
|
? { text: 'not json', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' }
|
|
: { text: '', object: { items: [] }, usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' },
|
|
);
|
|
},
|
|
} as never);
|
|
const out = await gateway.run(call);
|
|
expect(n).toBe(2);
|
|
expect(out.object).toEqual({ items: [] });
|
|
});
|
|
|
|
it('重问了还是不成形 → 抛,交人;**不重问第三次**', async () => {
|
|
let n = 0;
|
|
const gateway = createAnalyzeGateway({
|
|
generate: () => {
|
|
n += 1;
|
|
return Promise.resolve({ text: 'nope', usage: { inputTokens: 1, outputTokens: 1, latencyMs: 1 }, binding: { name: 'x', provider: 'deepseek', model: 'y' }, mode: 'live' });
|
|
},
|
|
} as never);
|
|
await expect(gateway.run(call)).rejects.toThrow(/出参不成形/);
|
|
expect(n).toBe(2);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 7: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/app/__tests__/analyze-gateway.test.ts`
|
|
Expected: FAIL —— `Failed to resolve import "../wiring/analyze-gateway.ts"`
|
|
|
|
- [ ] **Step 8: 写网关**
|
|
|
|
```ts
|
|
// apps/api/src/app/wiring/analyze-gateway.ts
|
|
/**
|
|
* `analyze` 动词的实现 —— **一次结构化调用,没有第二次自由**(spec §5.2)。
|
|
*
|
|
* 与开环 Agent 的分工是死的:那一件能调工具、能写台账;这一件只是个纯函数。
|
|
* 出参不合 schema 就地重问一次(平台在 agent-runner 上早有这条路),再不成就抛 ——
|
|
* 抛出去由配方执行器留痕交人,**不重试五遍、不退回模型自由发挥**。
|
|
*/
|
|
import type { ModelGateway } from '@modules/llm';
|
|
import type { AnalyzeGateway } from '@modules/loop-engine';
|
|
|
|
const RETRY_HINT =
|
|
'上一次的回答没有按要求给出 JSON。只输出一个 JSON 对象,不要任何解释、不要 markdown 代码围栏。';
|
|
|
|
export function createAnalyzeGateway(llm: Pick<ModelGateway, 'generate'>): AnalyzeGateway {
|
|
return {
|
|
async run(input) {
|
|
const prompt = JSON.stringify(input.payload, null, 2);
|
|
const call = {
|
|
model: input.model,
|
|
system: input.instruction,
|
|
prompt,
|
|
/* 判断归判定表与规则,模型只做归类 —— 隐藏推理按秒计,这一档一律关 */
|
|
reasoning: 'off' as const,
|
|
temperature: 0.2,
|
|
outputSchema: input.outputSchema as Record<string, unknown>,
|
|
taskId: input.taskId,
|
|
label: input.label,
|
|
};
|
|
const first = await llm.generate(call);
|
|
if (typeof first.object === 'object' && first.object !== null) {
|
|
return { object: first.object as Record<string, unknown> };
|
|
}
|
|
const second = await llm.generate({ ...call, system: `${input.instruction}\n\n${RETRY_HINT}` });
|
|
if (typeof second.object === 'object' && second.object !== null) {
|
|
return { object: second.object as Record<string, unknown> };
|
|
}
|
|
throw new Error(`${input.label}:模型出参不成形,重问一次仍不成 —— 交给你处理`);
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 9: 换掉 engines.ts 里的占位**
|
|
|
|
在 `apps/api/src/app/wiring/engines.ts` 里把 Task 4 加的那行占位
|
|
|
|
```ts
|
|
analyze: { run: () => Promise.reject(new Error('analyze 尚未装配(Task 5)')) },
|
|
```
|
|
|
|
换成:
|
|
|
|
```ts
|
|
analyze: createAnalyzeGateway(deps.llm),
|
|
```
|
|
|
|
并在该文件顶部 import 段加:
|
|
|
|
```ts
|
|
import { createAnalyzeGateway } from './analyze-gateway.ts';
|
|
```
|
|
|
|
> `deps.llm` 就是装配层已有的 `ModelGateway`。若该变量在你手上叫别的名字,
|
|
> 用 `grep -n "ModelGateway" apps/api/src/app/wiring/engines.ts` 找它。
|
|
|
|
- [ ] **Step 10: 跑测试与全量门禁**
|
|
|
|
Run:
|
|
```bash
|
|
pnpm exec vitest run apps/api/src/app/__tests__/analyze-gateway.test.ts
|
|
pnpm gates
|
|
```
|
|
Expected: 前者 3 个用例 PASS;`pnpm gates` 全绿。
|
|
|
|
- [ ] **Step 11: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/loop-engine apps/api/src/app/wiring/analyze-gateway.ts apps/api/src/app/wiring/engines.ts apps/api/src/app/__tests__/analyze-gateway.test.ts
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(loop-engine): analyze 动词 —— 系统组装入参、声明出参 schema、系统把结果落表
|
|
|
|
AI 在这里只是一个纯函数:不给工具、不给记忆、不多轮。
|
|
行数不由它决定 —— 出参是拿主键去 lookup 的一张查表,它漏一条那一格就是空,
|
|
required 立刻把它变成 _missing > 0,提交闸与放行门当场拦住。
|
|
出参不成形就地重问一次,再不成抛出去交人。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: `card` 与 `submit` 两个动词
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(补两个分支)
|
|
- Modify: `apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`(补 5 个用例)
|
|
- Modify: `apps/api/src/modules/human-tool/internal/domain/card-rules.ts:92`(审核卡放宽到单选项)
|
|
- Modify: `apps/api/src/modules/human-tool/__tests__/`(该目录下测 `card-rules` 的那个文件,补 2 个用例)
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4 的 `runAction` / `AutoOutcome`;现有 `CardGateway.issue` / `CardGateway.decide`
|
|
- Produces: `AutoOutcome.issuedCardId` 与 `AutoOutcome.submit` 两个字段真的会被填上(Task 7 的 turn-runner 读它们)
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
在 `auto-runner.test.ts` 末尾追加:
|
|
|
|
```ts
|
|
describe('card 与 submit', () => {
|
|
it('execution:发一张数据工单,itemId 挂在这一步上,basis 现算', async () => {
|
|
const { runner, cards, ctx } = harness({ pn: 'XL2596S-5.0' });
|
|
const out = await runner.run(
|
|
{
|
|
do: [
|
|
{
|
|
kind: 'card',
|
|
as: 'execution',
|
|
variant: 'data',
|
|
title: { cat: ['数据工单 · 补齐 ', { in: 'pn' }] },
|
|
guide: ['选一个芯片类型', '把参数值填齐'],
|
|
standard: '必填格全部填上,红带消失',
|
|
basis: [{ k: '候选料号', v: { in: 'pn' } }],
|
|
gateLedgers: ['parts.profile', 'parts.parameter'],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
expect(out.issuedCardId).toBe('card-1');
|
|
expect(cards.issued[0]).toMatchObject({
|
|
workOrder: 'execution',
|
|
itemId: 'S1',
|
|
card: {
|
|
mode: 'execution',
|
|
title: '数据工单 · 补齐 XL2596S-5.0',
|
|
variant: 'data',
|
|
steps: ['选一个芯片类型', '把参数值填齐'],
|
|
standard: '必填格全部填上,红带消失',
|
|
basis: [{ k: '候选料号', v: 'XL2596S-5.0' }],
|
|
gateLedgers: ['parts.profile', 'parts.parameter'],
|
|
fields: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('这一步已经发过卡:**不再发第二张**,如实留一条观察', async () => {
|
|
const { runner, notes, ctx } = harness({});
|
|
const out = await runner.run(
|
|
{ do: [{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] }] },
|
|
{ ...ctx, carded: true },
|
|
);
|
|
expect(out.issuedCardId).toBeUndefined();
|
|
expect(notes.some((n) => n.includes('已经发过'))).toBe(true);
|
|
});
|
|
|
|
it('decide + review:摆成验收工单,单一选项也发得出去', async () => {
|
|
const { runner, ctx } = harness({});
|
|
const out = await runner.run(
|
|
{
|
|
do: [
|
|
{
|
|
kind: 'card',
|
|
as: 'decide',
|
|
variant: 'review',
|
|
title: '差异评估结论审核',
|
|
ask: '逐条核对过了吗?',
|
|
options: [{ value: 'approve', label: '确认通过' }],
|
|
basis: [{ k: '候选', v: 'XL2596S-5.0' }],
|
|
},
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
expect(out.issuedCardId).toBe('card-2');
|
|
});
|
|
|
|
it('发了卡就**收拍**:排在后面的笔一律不跑', async () => {
|
|
const { runner, drafts, ctx } = harness({});
|
|
await runner.run(
|
|
{
|
|
do: [
|
|
{ kind: 'card', as: 'execution', title: 'x', basis: [{ k: 'a', v: 'b' }] },
|
|
{ kind: 'project', into: 'sub_eval.part', from: { once: true }, fields: { eval_ref: 'a', internal_pn: 'b' } },
|
|
],
|
|
},
|
|
ctx,
|
|
);
|
|
expect(drafts.size).toBe(0);
|
|
});
|
|
|
|
it('submit:回给 turn-runner,理由现算;有卡在等人时不 submit', async () => {
|
|
const { runner, ctx } = harness({ n: 3 });
|
|
const out = await runner.run({ do: [{ kind: 'submit', rationale: { cat: ['已判定 ', { in: 'n' }, ' 条参数'] } }] }, ctx);
|
|
expect(out.submit).toEqual({ rationale: '已判定 3 条参数' });
|
|
|
|
const quiet = await runner.run({ do: [{ kind: 'submit', rationale: 'x' }] }, { ...ctx, quiet: true });
|
|
expect(quiet.submit).toBeUndefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
上面第一个用例要断言卡骨架,所以先把 Task 4 写的 `harness()` 里那两个发卡端口改成**记录入参**:
|
|
|
|
```ts
|
|
const cards = { issued: [] as Array<Record<string, unknown>> };
|
|
```
|
|
|
|
(放在 `const notes: string[] = [];` 旁边),并把 `deps.cards` 里这两行:
|
|
|
|
```ts
|
|
issue: () => Promise.resolve({ id: 'card-1' }),
|
|
decide: () => Promise.resolve({ id: 'card-2' }),
|
|
```
|
|
|
|
改成:
|
|
|
|
```ts
|
|
issue: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'issue' }); return Promise.resolve({ id: 'card-1' }); },
|
|
decide: (i: Record<string, unknown>) => { cards.issued.push({ ...i, kind: 'decide' }); return Promise.resolve({ id: 'card-2' }); },
|
|
```
|
|
|
|
最后把 `harness` 的返回值加上 `cards`:
|
|
|
|
```ts
|
|
return { runner: createAutoRunner(deps as never), drafts, notes, cards, ctx };
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts -t "card 与 submit"`
|
|
Expected: FAIL —— `动词尚未实现:card`
|
|
|
|
- [ ] **Step 3: 实现两个分支**
|
|
|
|
在 `auto-runner.ts` 的 `runAction` 里,`analyze` 分支之后、最后那句 `throw` 之前插入:
|
|
|
|
```ts
|
|
if (action.kind === 'card') {
|
|
/*
|
|
* **发过就不再发**(幂等)。人工激活的 auto 步,**配方发卡就是启动** ——
|
|
* 不必再有一个「先 start」的礼仪:学不稳的礼仪就是死法(2026-08-02 已拆过一次)。
|
|
*/
|
|
if (context.carded) {
|
|
await deps.memory.append({
|
|
taskId: context.taskId,
|
|
kind: 'observation',
|
|
title: `这一步已经发过卡,不再发第二张`,
|
|
detail: { auto: `${context.stepId}#${index + 1}`, internal: true },
|
|
});
|
|
return { wrote: 0, dropped: 0, stop: true };
|
|
}
|
|
const basis = (action.basis ?? []).map((b) => ({ k: b.k, v: evalText(b.v, base) }));
|
|
if (action.as === 'decide') {
|
|
const options = action.options ?? [];
|
|
if (options.length === 0) throw new AutoRecipeError(index, '决策卡一个选项都没有');
|
|
const { id } = await deps.cards.decide({
|
|
taskId: context.taskId,
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
ask: evalText(action.ask ?? action.title, base),
|
|
options: options.map((o) => ({ value: o.value, label: o.label, ...(o.detail === undefined ? {} : { detail: o.detail }) })),
|
|
basis,
|
|
itemId: context.stepId,
|
|
...(action.variant === undefined ? {} : { variant: action.variant }),
|
|
});
|
|
return { wrote: 0, dropped: 0, cardId: id, stop: true };
|
|
}
|
|
const { id } = await deps.cards.issue({
|
|
taskId: context.taskId,
|
|
ownerId: context.ownerId,
|
|
humanCallCap: context.humanCallCap,
|
|
workOrder: 'execution',
|
|
itemId: context.stepId,
|
|
card: {
|
|
mode: 'execution',
|
|
title: evalText(action.title, base),
|
|
ask: evalText(action.title, base),
|
|
basis,
|
|
/* 零输入:填报全在工作区的件上(2026-08-02 用户定) */
|
|
fields: [],
|
|
...(action.variant === 'data' ? { variant: 'data' } : {}),
|
|
...(action.guide === undefined ? {} : { steps: [...action.guide] }),
|
|
...(action.standard === undefined ? {} : { standard: evalText(action.standard, base) }),
|
|
...(action.gateLedgers === undefined ? {} : { gateLedgers: [...action.gateLedgers] }),
|
|
},
|
|
});
|
|
return { wrote: 0, dropped: 0, cardId: id, stop: true };
|
|
}
|
|
|
|
if (action.kind === 'submit') {
|
|
return { wrote: 0, dropped: 0, submit: { rationale: evalText(action.rationale, base) }, stop: true };
|
|
}
|
|
```
|
|
|
|
> `variant` 只在 `CardGateway.decide` 上是显式参数;执行工单的 `variant:'data'` 是**卡骨架里的一个键**
|
|
> (见 `executionWorkOrderSchema.variant`),所以走 `card` 那个对象。这两处不对称是既有契约的样子,别改它。
|
|
|
|
- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/auto-runner.test.ts`
|
|
Expected: PASS(16 个用例)
|
|
|
|
- [ ] **Step 5: 给审核卡放宽单选项写失败的测试**
|
|
|
|
先找到测 `card-rules` 的那个文件:
|
|
|
|
Run: `grep -rln "options-missing" apps/api/src/modules/human-tool/__tests__/`
|
|
|
|
在它末尾追加(`describe` 的名字照该文件既有的风格):
|
|
|
|
```ts
|
|
describe('审核卡的选项数', () => {
|
|
it('普通决策卡仍要两个分支 —— 一个选项的岔路口不是岔路口', () => {
|
|
const findings = validateCard({ k: 'decision', ask: '往哪走', options: [{ value: 'a', label: 'A' }] } as never);
|
|
expect(findings.some((f) => f.code === 'options-missing')).toBe(true);
|
|
});
|
|
|
|
it('审核卡(variant:review)只有「确认通过」一个选项也发得出去 —— 改在件上,卡上只签字', () => {
|
|
const findings = validateCard({ k: 'decision', variant: 'review', ask: '核对过了吗', options: [{ value: 'approve', label: '确认通过' }] } as never);
|
|
expect(findings.some((f) => f.code === 'options-missing')).toBe(false);
|
|
});
|
|
});
|
|
```
|
|
|
|
> `validateCard` 是那个文件里已经在用的入口函数名;若它叫别的名字,
|
|
> 用 `grep -n "options-missing" apps/api/src/modules/human-tool/internal/domain/card-rules.ts` 往上找到它所在的导出函数。
|
|
|
|
- [ ] **Step 6: 跑一遍,确认第二条失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/human-tool/__tests__/ -t "审核卡的选项数"`
|
|
Expected: 第一条 PASS,第二条 FAIL(`options-missing` 仍被报出来)
|
|
|
|
- [ ] **Step 7: 放宽**
|
|
|
|
把 `apps/api/src/modules/human-tool/internal/domain/card-rules.ts:92` 那一行:
|
|
|
|
```ts
|
|
if (card.options.length < 2) out.push({ code: 'options-missing', message: '决策卡至少两个分支' });
|
|
```
|
|
|
|
改成:
|
|
|
|
```ts
|
|
/*
|
|
* **审核卡是例外**(2026-08-29):它不是岔路口,是一道签字关 ——
|
|
* 改全在件上就地做,卡上只有「确认通过」一个动作。
|
|
* 普通决策卡照旧要两个分支:一个选项的岔路口不是岔路口。
|
|
*/
|
|
const floor = card.variant === 'review' ? 1 : 2;
|
|
if (card.options.length < floor) {
|
|
out.push({ code: 'options-missing', message: floor === 1 ? '审核卡至少一个选项' : '决策卡至少两个分支' });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 8: 跑测试与全量门禁**
|
|
|
|
Run:
|
|
```bash
|
|
pnpm exec vitest run apps/api/src/modules/human-tool/__tests__/
|
|
pnpm gates
|
|
```
|
|
Expected: 全绿。
|
|
|
|
- [ ] **Step 9: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/loop-engine apps/api/src/modules/human-tool
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(loop-engine): card 与 submit 两个动词;审核卡放宽到单选项
|
|
|
|
ADR-014 §8 原写「不发决策卡、不 submit」—— 那是假设还有 agent 在场时的保守话。
|
|
全程无 agent 的活动没有别人来干这两件事,不开口最后两步就没人做。
|
|
护栏没松:submit 仍要过提交门(判据全绿),系统提交不比模型提交宽松。
|
|
发过的卡不再发第二张;发了卡就收拍,排在后面的笔一律不跑。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: turn-runner 接线 —— 拍首跑配方
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/modules/loop-engine/internal/domain/live-step.ts`
|
|
- Create: `apps/api/src/modules/loop-engine/__tests__/live-step.test.ts`
|
|
- Modify: `apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts`(三处)
|
|
- Modify: `apps/api/src/modules/loop-engine/CONTRACT.md`
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4-6 的 `createAutoRunner` / `AutoOutcome`
|
|
- Produces: `function liveStepOf<S extends { item: string }>(steps: readonly S[], settledIds: ReadonlySet<string>, onStage: readonly { id: string }[]): S | undefined`
|
|
|
|
- [ ] **Step 1: 写 `liveStepOf` 的失败测试**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/__tests__/live-step.test.ts
|
|
import { describe, expect, it } from 'vitest';
|
|
import { liveStepOf } from '../internal/domain/live-step.ts';
|
|
|
|
const steps = [
|
|
{ item: 'S1', guide: '' },
|
|
{ item: 'S2', guide: '' },
|
|
{ item: 'T_release', guide: '' },
|
|
];
|
|
|
|
describe('liveStepOf:这一单此刻在办哪一步', () => {
|
|
it('在场的第一个还没收掉的项', () => {
|
|
expect(liveStepOf(steps, new Set(), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S1');
|
|
});
|
|
|
|
it('**收掉的项要跳过** —— 带 repetitionRule 的项收了之后仍是 Enabled,不跳就整单钉在第一步', () => {
|
|
expect(liveStepOf(steps, new Set(['S1']), [{ id: 'S1' }, { id: 'S2' }])?.item).toBe('S2');
|
|
});
|
|
|
|
it('在场的都收完了 → undefined', () => {
|
|
expect(liveStepOf(steps, new Set(['S1', 'S2']), [{ id: 'S1' }, { id: 'S2' }])).toBeUndefined();
|
|
});
|
|
|
|
it('在场的项没有剧本 → undefined(没写剧本的活动照旧跑)', () => {
|
|
expect(liveStepOf(steps, new Set(), [{ id: 'X' }])).toBeUndefined();
|
|
});
|
|
|
|
it('次序按 onStage 给的次序,不按 steps 的次序', () => {
|
|
expect(liveStepOf(steps, new Set(), [{ id: 'S2' }, { id: 'S1' }])?.item).toBe('S2');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/live-step.test.ts`
|
|
Expected: FAIL —— `Failed to resolve import "../internal/domain/live-step.ts"`
|
|
|
|
- [ ] **Step 3: 提纯出 `liveStepOf`**
|
|
|
|
```ts
|
|
// apps/api/src/modules/loop-engine/internal/domain/live-step.ts
|
|
/**
|
|
* **这一单此刻在办哪一步** —— 从 turn-runner 里提纯出来的一句话(纯函数,好测)。
|
|
*
|
|
* 为什么要跳过已收掉的项:带 `repetitionRule` 的项被收掉之后**仍是 Enabled**,
|
|
* 「在场的第一个有剧本的项」于是永远是第一步 —— 实测:跑到判定去向了,
|
|
* 工作区还停在阶段二那份件,喂给模型的做法也还是第一步的。
|
|
*/
|
|
export function liveStepOf<S extends { readonly item: string }>(
|
|
steps: readonly S[],
|
|
settledIds: ReadonlySet<string>,
|
|
onStage: readonly { readonly id: string }[],
|
|
): S | undefined {
|
|
for (const entry of onStage) {
|
|
if (settledIds.has(entry.id)) continue;
|
|
const step = steps.find((s) => s.item === entry.id);
|
|
if (step !== undefined) return step;
|
|
}
|
|
return undefined;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/live-step.test.ts`
|
|
Expected: PASS(5 个用例)
|
|
|
|
- [ ] **Step 5: 让 turn-runner 用它(等价替换,行为不变)**
|
|
|
|
在 `turn-runner.ts` 里找到这一段(约 1927-1935 行):
|
|
|
|
```ts
|
|
const liveStep = [...stateEnabled.inProgress, ...stateEnabled.entries]
|
|
.filter((e) => !settledIds.has(e.id))
|
|
.map((e) => stepOf(e.id))
|
|
.find((x) => x !== undefined);
|
|
```
|
|
|
|
替换为:
|
|
|
|
```ts
|
|
const liveStep = liveStepOf(steps, settledIds, [...stateEnabled.inProgress, ...stateEnabled.entries]);
|
|
```
|
|
|
|
并在文件顶部 import 段加:
|
|
|
|
```ts
|
|
import { liveStepOf } from '../domain/live-step.ts';
|
|
```
|
|
|
|
- [ ] **Step 6: 跑全量确认等价**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine`
|
|
Expected: 全绿(这一步只是提纯,行为一个字不变)
|
|
|
|
- [ ] **Step 7: 在「有卡在等人」那条路上插入静默配方段**
|
|
|
|
在 `turn-runner.ts` 里找到这一段(约 1682 行):
|
|
|
|
```ts
|
|
const waiting = await deps.cards.unanswered(taskId);
|
|
if (waiting.length > 0) {
|
|
await deps.memory.append({
|
|
```
|
|
|
|
在 `if (waiting.length > 0) {` 之后、`await deps.memory.append({` 之前插入:
|
|
|
|
```ts
|
|
/*
|
|
* **有卡在等人,配方照样跑 —— 只算、只写**(spec §5.4)。
|
|
* 人在件上按了保存,系统就该按最新的草稿把该铺的行铺出来(「保存即铺格」);
|
|
* 但不发新卡、不 submit、不调 AI —— 叫了人就等人。
|
|
*/
|
|
/* `steps` 要到第 1842 行才 const 出来,这儿够不着 —— 从 model 上现取 */
|
|
const quietStep = liveStepOf(
|
|
model.steps ?? [],
|
|
new Set(runtime.items.filter((i) => i.state === 'Completed' || i.state === 'Terminated').map((i) => i.id)),
|
|
[...enabled.inProgress, ...enabled.entries],
|
|
);
|
|
if (quietStep?.auto !== undefined) {
|
|
await autoRunner.run(quietStep.auto, {
|
|
taskId,
|
|
stepId: quietStep.item,
|
|
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
|
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
|
quiet: true,
|
|
carded: true,
|
|
});
|
|
}
|
|
```
|
|
|
|
`deps.tasks.of(taskId)` 回的是 `{ taskId, activityId, ownerId, humanCallCap }` —— 该文件第 675 / 1535 行
|
|
已经这么取过两次(`const context = await deps.tasks.of(taskId);`)。若这一段所在的作用域里已经有
|
|
`context` 这个变量,直接用 `context.ownerId` / `context.humanCallCap`,别再取一遍。
|
|
|
|
- [ ] **Step 8: 在正常那条路上插入完整配方段**
|
|
|
|
在 `turn-runner.ts` 里找到 `const owesCard =` 那一段(约 1948 行)**之前**插入:
|
|
|
|
```ts
|
|
/*
|
|
* **配方段**(spec §5.8 ③④⑤):当前这一步声明了 `auto` 就先把确定的事做完。
|
|
* · 发了卡 / submit → 这一拍到此为止(零 token,不问模型);
|
|
* · 没发卡且这一步没有 `guide` → 重求 doneWhen 收步(下面的 sweepRipe 会做);
|
|
* · 有 `guide` → 照常往下问模型(半 auto)。
|
|
* 出错 → 留痕交人,不重试、不退回模型。
|
|
*/
|
|
if (current?.auto !== undefined) {
|
|
const auto = await autoRunner.run(current.auto, {
|
|
taskId,
|
|
stepId: current.item,
|
|
ownerId: (await deps.tasks.of(taskId)).ownerId,
|
|
humanCallCap: (await deps.tasks.of(taskId)).humanCallCap,
|
|
quiet: false,
|
|
carded: stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true,
|
|
});
|
|
if (auto.failedAt !== undefined) {
|
|
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
if (auto.submit !== undefined) {
|
|
const raised = await raiseAcceptance(taskId, stateRuntime, auto.submit.rationale);
|
|
return { ...raised, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
if (auto.issuedCardId !== undefined) {
|
|
/* 卡发出去了 = 这一步真动过世界 —— 标 worldTouched,自动收步那道门才诚实 */
|
|
const touched = markWorldTouched(stateRuntime, current.item);
|
|
await deps.state.save(taskId, model, parsed, touched);
|
|
/* 这一步声明了画面才推 —— `showView` 的第二参不可选,没声明就沿用上一帧(与既有行为一致) */
|
|
if (current.view !== undefined && current.view !== '') {
|
|
await showView(taskId, current.view, touched, current.view);
|
|
}
|
|
return { runtime: touched, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
if (auto.wrote > 0 || auto.dropped > 0) {
|
|
/* 写过东西:重新求一遍 doneWhen —— 这一步可能已经办完了 */
|
|
const freshScope = await deps.data.view(taskId);
|
|
const reswept = await sweepRipe(stateRuntime, stateEnabled, freshScope);
|
|
if (reswept.runtime !== stateRuntime) {
|
|
await deps.state.save(taskId, model, parsed, reswept.runtime);
|
|
return { runtime: reswept.runtime, enabled: reswept.enabled, navigation: { kind: 'progressed' }, status: 'running' };
|
|
}
|
|
}
|
|
if (current.guide.trim() === '') {
|
|
/* 没有 guide、没发卡、doneWhen 也不成立 → 配方没做到位,是包的 bug,交人 */
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'result',
|
|
title: `「${current.item}」的配方跑完了,可这一步还没算办完 —— 交给你看一眼`,
|
|
detail: { auto: current.item },
|
|
});
|
|
return { runtime: stateRuntime, enabled: stateEnabled, navigation: { kind: 'converged' }, status: 'awaiting' };
|
|
}
|
|
}
|
|
```
|
|
|
|
> 三个你要照该文件既有写法对齐的名字:
|
|
> - `markWorldTouched` —— 用 `grep -n "worldTouched" apps/api/src/modules/loop-engine/internal/domain/lifecycle.ts` 找那个把项标成「动过世界」的函数;它已存在(`human`/`decide`/`entry` 三条路都在用)。
|
|
> - `showView` —— 第 559 行定义,签名 `(taskId, template: string, runtime, preferred?)`;**第二参不可选**。
|
|
> - `raiseAcceptance` —— 第 1500 行定义,签名 `(taskId, runtime, rationale, fields?)`。
|
|
|
|
- [ ] **Step 9: 在 `createTurnRunner` 里造出 `autoRunner`**
|
|
|
|
在 `createTurnRunner(deps)` 函数体开头(`return { ... }` 之前)加:
|
|
|
|
```ts
|
|
const autoRunner = createAutoRunner({
|
|
data: deps.data,
|
|
cards: deps.cards,
|
|
memory: deps.memory,
|
|
analyze: deps.analyze,
|
|
sfeel: deps.sfeel,
|
|
});
|
|
```
|
|
|
|
并在文件顶部 import 段加:
|
|
|
|
```ts
|
|
import { createAutoRunner } from './auto-runner.ts';
|
|
```
|
|
|
|
- [ ] **Step 10: 确认测试脚手架的 `analyze` 桩已经在**
|
|
|
|
Task 4 已经在 `turn-runner-acceptance.test.ts` 的 `const deps: TurnRunnerDeps = {` 里加过这一行:
|
|
|
|
```ts
|
|
analyze: { run: async () => ({ object: {} }) } as never,
|
|
```
|
|
|
|
Run: `grep -n "analyze:" apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts`
|
|
Expected: 有这一行。**没有就现在补上** —— 少了它下面的用例连编译都过不去。
|
|
|
|
- [ ] **Step 11: 写接线的五个失败用例**
|
|
|
|
追加到 `turn-runner-acceptance.test.ts` 末尾:
|
|
|
|
```ts
|
|
describe('auto 步:配方在拍首跑', () => {
|
|
/** 一步的活动:那一步声明 auto */
|
|
const AUTO_MODEL: CaseModel = {
|
|
name: '一步的活动',
|
|
autoComplete: false,
|
|
items: [{ id: 'S1', name: '补数据', type: 'humanTask', discretionary: false, entryCriteria: [], exitCriteria: [] }],
|
|
sentries: [],
|
|
} as never;
|
|
const AUTO_RUNTIME: CaseRuntime = { items: [{ id: 'S1', state: 'Available' }] } as never;
|
|
|
|
it('配方发了卡 → 这一拍一次都不问模型,状态 awaiting', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'card', as: 'execution', title: '补数据', basis: [{ k: '料号', v: 'XL2596S-5.0' }] }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.decided).toHaveLength(0);
|
|
expect(out.status).toBe('awaiting');
|
|
expect(h.issued.map((i) => i.workOrder)).toEqual(['execution']);
|
|
});
|
|
|
|
it('配方只写不发卡、这一步因此办完 → 收步,还是不问模型', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: (_expr, scope) => Number(scope['quote_intake_record_rows'] ?? 0) > 0,
|
|
viewFn: (written) => ({ quote_intake_record_rows: written.length }),
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
doneWhen: 'quote_intake_record_rows > 0',
|
|
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'LM2596S-5.0', eau: 1000 } }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.decided).toHaveLength(0);
|
|
expect(h.written.map((w) => w.key)).toEqual([{ pn: 'LM2596S-5.0' }]);
|
|
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
|
});
|
|
|
|
it('有卡在等人交回:配方照样写,但一张新卡都不发', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
unanswered: [{ id: 'card-9', title: '补数据' }],
|
|
sfeelCondition: () => false,
|
|
viewFn: (written) => ({ n: written.length }),
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: {
|
|
do: [
|
|
{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } },
|
|
{ kind: 'card', as: 'execution', title: '再来一张', basis: [{ k: 'a', v: 'b' }] },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
await h.runner.runTurn(TASK);
|
|
expect(h.written).toHaveLength(1);
|
|
expect(h.issued).toHaveLength(0);
|
|
});
|
|
|
|
it('配方抛错(地址不在台账声明里)→ 交人,不问模型、不重试', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.decided).toHaveLength(0);
|
|
expect(out.status).toBe('awaiting');
|
|
expect(h.appended.some((a) => a.title.includes('nope.nope'))).toBe(true);
|
|
});
|
|
|
|
it('这一步有 guide:配方跑完照常问模型(半 auto)', async () => {
|
|
const h = harness({
|
|
parsed: OPEN_MODEL,
|
|
runtime: OPEN_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
viewFn: (written) => ({ n: written.length }),
|
|
steps: [
|
|
{
|
|
item: 'i1',
|
|
guide: '把结论写实',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { pn: 'A', eau: 1 } }] },
|
|
},
|
|
],
|
|
action: { kind: 'idle', rationale: '看过了' },
|
|
});
|
|
await h.runner.runTurn(TASK);
|
|
expect(h.written).toHaveLength(1);
|
|
expect(h.decided).toHaveLength(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 12: 跑这五条,确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts -t "auto 步"`
|
|
Expected: 5 个用例全 PASS。若第二条报「S1 还是 Available」,多半是配方段插在了 `sweepRipe` 之前 ——
|
|
它必须在 `const swept = await sweepRipe(runtime, enabled, scope);` **之后**(那时才有 `stateRuntime` / `current`)。
|
|
|
|
- [ ] **Step 13: 跑全量门禁**
|
|
|
|
Run: `pnpm gates`
|
|
Expected: 全绿。**E04 / I01 / W05 的既有用例必须一条不改就过** —— 它们的剧本里没有 `auto`,这一段整个不进。
|
|
|
|
- [ ] **Step 14: 补 CONTRACT.md**
|
|
|
|
在 `apps/api/src/modules/loop-engine/CONTRACT.md` 的不变量清单里加三条:
|
|
|
|
```markdown
|
|
- **auto 步不问模型**:`PlanStep.auto` 存在时,拍首先跑配方;发了卡 / submit 就收拍(零 token)。
|
|
有 `guide` 的照常问模型(半 auto);两者都没有且 `doneWhen` 不成立 → 交人(配方没做到位,是包的 bug)。
|
|
- **有未答的卡在场时,配方只跑 `project` / `say`** —— 只算、只写,不发新卡、不 submit、不调 AI。
|
|
- **配方每一笔之前刷新任务视图**:后一笔常要数前一笔刚写下的行,不刷新就恒为 0。
|
|
```
|
|
|
|
- [ ] **Step 15: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/loop-engine
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(loop-engine): 拍首跑配方 —— 确定的活由引擎做完,剩下的才问模型
|
|
|
|
liveStepOf 从 turn-runner 提纯成纯函数(带 repetitionRule 的项收了仍 Enabled,
|
|
不跳过已收掉的就整单钉在第一步 —— 这条实测栽过)。
|
|
有卡在等人时配方照样跑但只算只写:人在件上按了保存,该铺的行就铺出来。
|
|
老活动一个字不用改:剧本里没有 auto,这一段整个不进。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: 卡答复即收步
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/modules/loop-engine/internal/usecase/auto-runner.ts`(导出一个纯函数)
|
|
- Modify: `apps/api/src/modules/loop-engine/internal/usecase/turn-runner.ts`(配方段末尾加一段)
|
|
- Modify: `apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts`(补 2 个用例)
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 7 的配方段
|
|
- Produces: `function recipeIssuesCard(recipe: AutoRecipe): boolean`
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
追加到 `turn-runner-acceptance.test.ts` 的 `describe('auto 步:配方在拍首跑', …)` 里:
|
|
|
|
```ts
|
|
it('卡答过了、这一步又没有 doneWhen → 收步(人已经签了,收步是照实记录)', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: { items: [{ id: 'S1', state: 'Active', worldTouched: true }] } as never,
|
|
unanswered: [],
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.issued).toHaveLength(0);
|
|
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).toBe('Completed');
|
|
});
|
|
|
|
it('卡还没发过就不收 —— 那才是替人签字', async () => {
|
|
const h = harness({
|
|
parsed: AUTO_MODEL,
|
|
runtime: AUTO_RUNTIME,
|
|
sfeelCondition: () => false,
|
|
steps: [
|
|
{
|
|
item: 'S1',
|
|
guide: '',
|
|
writes: [],
|
|
reads: [],
|
|
tools: [],
|
|
auto: { do: [{ kind: 'card', as: 'decide', variant: 'review', title: '结论审核', options: [{ value: 'approve', label: '确认通过' }], basis: [{ k: 'a', v: 'b' }] }] },
|
|
},
|
|
],
|
|
});
|
|
const out = await h.runner.runTurn(TASK);
|
|
expect(h.issued).toHaveLength(1);
|
|
expect(out.runtime.items.find((i) => i.id === 'S1')?.state).not.toBe('Completed');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认第一条失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts -t "卡答过了"`
|
|
Expected: FAIL —— S1 仍是 `Active`
|
|
|
|
- [ ] **Step 3: 导出纯函数**
|
|
|
|
在 `auto-runner.ts` 的 `createAutoRunner` **之前**加:
|
|
|
|
```ts
|
|
/** 这份配方会不会发卡 —— 「卡答过了就收步」那道门要问它 */
|
|
export function recipeIssuesCard(recipe: AutoRecipe): boolean {
|
|
return recipe.do.some((action) => action.kind === 'card');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 在 turn-runner 的配方段末尾加收步**
|
|
|
|
在 Task 7 Step 8 插入的那一段里,`if (current.guide.trim() === '') { … }` **之前**插入:
|
|
|
|
```ts
|
|
/*
|
|
* **卡答过了就收步**(spec §5.5)。走到这儿的四个前提都成立:
|
|
* 这一步声明了 auto、卡发过(worldTouched)、这一拍没有未答的卡(前面 unanswered 把住)、
|
|
* 配方这一拍也没再发新卡。**人已经签了字,收步是照实记录,不是替人签字** ——
|
|
* 「人工激活项不给 doneWhen」那条纪律防的是引擎抢在人前面,这里人在前面。
|
|
*/
|
|
const noDoneWhen = current.doneWhen === undefined || current.doneWhen.trim() === '';
|
|
const carded = stateRuntime.items.find((i) => i.id === current.item)?.worldTouched === true;
|
|
if (noDoneWhen && carded && recipeIssuesCard(current.auto)) {
|
|
const active = readyToWork(parsed, stateRuntime, current.item);
|
|
const settled = transitionItem(active, current.item, 'Completed');
|
|
await deps.memory.append({
|
|
taskId,
|
|
kind: 'engine-transition',
|
|
/* 标题别以「完成」开头 —— `task-log.actorOf` 按 /^(完成|开始办)/ 认 AI,而这一步是引擎收的 */
|
|
title: `自动收步:${current.item} —— 卡已交回,这一步到此为止`,
|
|
detail: { internal: true, item: current.item, auto: current.item },
|
|
});
|
|
await deps.state.save(taskId, model, parsed, settled);
|
|
return { runtime: settled, enabled: stateEnabled, navigation: { kind: 'progressed' }, status: 'running' };
|
|
}
|
|
```
|
|
|
|
并在 `auto-runner.ts` 的 import 那一行加上 `recipeIssuesCard`:
|
|
|
|
```ts
|
|
import { createAutoRunner, recipeIssuesCard } from './auto-runner.ts';
|
|
```
|
|
|
|
> `readyToWork` 与 `transitionItem` 是 `sweepRipe` 里已经在用的两个函数(同文件已 import)。
|
|
|
|
- [ ] **Step 5: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/loop-engine/__tests__/turn-runner-acceptance.test.ts`
|
|
Expected: 全绿(含新加的 7 条 auto 用例)
|
|
|
|
- [ ] **Step 6: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/loop-engine
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(loop-engine): auto 步的卡答过了就收步
|
|
|
|
四个前提都成立才收:声明了 auto、卡发过、这一拍没有未答的卡、配方也没再发新卡。
|
|
卡还没发过就不收 —— 那才是替人签字。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: 静态审 —— 配方写错在装包时就拒
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/modules/activity-registry/internal/domain/static-audit.ts`
|
|
- Modify: 该模块 `__tests__/` 下测 `staticAudit` 的那个文件(用 `grep -rln "staticAudit" apps/api/src/modules/activity-registry/__tests__/` 找它)
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1 的 `AutoRecipe`
|
|
- Produces: `function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[]`,并由 `staticAudit` 汇总
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
追加到 static-audit 的测试文件末尾(`manifestFixture()` 用该文件里既有的那个造清单的 helper;
|
|
若它叫别的名字,`grep -n "ActivityManifest = " ` 找它):
|
|
|
|
```ts
|
|
describe('配方静态审:写错地址 = 每一单都错,装的时候就拒', () => {
|
|
const withRecipe = (auto: unknown) => {
|
|
const base = manifestFixture();
|
|
return {
|
|
...base,
|
|
assets: {
|
|
...base.assets,
|
|
businessModel: {
|
|
...base.assets.businessModel,
|
|
steps: [{ item: 'S1', guide: '', writes: [], reads: [], tools: [], auto }],
|
|
},
|
|
},
|
|
} as never;
|
|
};
|
|
|
|
it('台账地址不在 declarations.ledger 里 → error', () => {
|
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'project', into: 'nope.nope', from: { once: true }, fields: { a: 'b' } }] }));
|
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('nope.nope'))).toBe(true);
|
|
});
|
|
|
|
it('列名不在该对象的声明里 → error(列名写错 = 那一格永远是空的)', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { once: true }, fields: { 不存在的列: 'x' } }] }),
|
|
);
|
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('不存在的列'))).toBe(true);
|
|
});
|
|
|
|
it('each / lookup 的地址同样要在声明里', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { each: 'mine:nope.nope' }, fields: {} }] }),
|
|
);
|
|
expect(findings.some((f) => f.message.includes('nope.nope'))).toBe(true);
|
|
});
|
|
|
|
it('in: 与 result: 不是台账地址,不该被当成台账来审', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'project', into: 'quote_intake.record', from: { each: 'in:pns' }, fields: {} }] }),
|
|
);
|
|
expect(findings).toEqual([]);
|
|
});
|
|
|
|
it('analyze 的出参没人消费 → warning(白烧一次模型调用)', () => {
|
|
const findings = autoRecipeFindings(
|
|
withRecipe({ do: [{ kind: 'analyze', model: 'm', into: 'items', instruction: 'x', input: { from: { once: true }, fields: {} }, output: { type: 'object' } }] }),
|
|
);
|
|
expect(findings.some((f) => f.severity === 'warning' && f.message.includes('items'))).toBe(true);
|
|
});
|
|
|
|
it('submit 那一笔所在的步没有人工验收清单 → error(系统提交不比模型提交宽松)', () => {
|
|
const findings = autoRecipeFindings(withRecipe({ do: [{ kind: 'submit', rationale: 'x' }] }));
|
|
expect(findings.some((f) => f.severity === 'error' && f.message.includes('验收清单'))).toBe(true);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/activity-registry/__tests__/ -t "配方静态审"`
|
|
Expected: FAIL —— `autoRecipeFindings is not defined`
|
|
|
|
- [ ] **Step 3: 写审计**
|
|
|
|
在 `static-audit.ts` 的 `loopFindings` **之后**插入:
|
|
|
|
```ts
|
|
/**
|
|
* **配方静态审**(2026-08-29)—— 与 `refersTo` 目标检查同款:**写错地址 = 每一单都错**。
|
|
*
|
|
* 代码错是必然的错,模型错是概率的错。必然的错好查 —— 前提是它在装包那一刻就被拦下,
|
|
* 而不是等到第一单跑起来才现形。
|
|
*/
|
|
export function autoRecipeFindings(manifest: ActivityManifest): StaticAuditFinding[] {
|
|
const out: StaticAuditFinding[] = [];
|
|
const ledgers = new Map(
|
|
manifest.declarations.ledger.map((l) => [`${l.domain}.${l.object}`, new Set(l.fields.map((f) => f.name))]),
|
|
);
|
|
const templates = new Set(manifest.assets.viewTemplates.map((t) => t.name));
|
|
|
|
/** 台账地址:剥掉 `mine:` 前缀;`in:` / `result:` 不是台账,跳过 */
|
|
const checkAddress = (raw: string, at: string): void => {
|
|
if (raw.startsWith('in:') || raw.startsWith('result:')) return;
|
|
const address = raw.startsWith('mine:') ? raw.slice('mine:'.length) : raw;
|
|
if (!ledgers.has(address)) {
|
|
out.push({
|
|
code: 'schema-invalid',
|
|
severity: 'error',
|
|
message: `配方引的台账地址不在声明里:${address}(写的授权面是台账声明)`,
|
|
at,
|
|
});
|
|
}
|
|
};
|
|
|
|
/** 表达式里也可能藏着 lookup 地址,递归找一遍 */
|
|
const walkExpr = (expr: unknown, at: string): void => {
|
|
if (typeof expr !== 'object' || expr === null) return;
|
|
const node = expr as Record<string, unknown>;
|
|
if (typeof node['lookup'] === 'string') {
|
|
checkAddress(node['lookup'], at);
|
|
for (const value of Object.values(node['by'] ?? {})) walkExpr(value, at);
|
|
if (node['else'] !== undefined) walkExpr(node['else'], at);
|
|
}
|
|
if (Array.isArray(node['cat'])) for (const part of node['cat']) walkExpr(part, at);
|
|
if (node['fmt'] !== undefined) walkExpr(node['fmt'], at);
|
|
if (node['then'] !== undefined) walkExpr(node['then'], at);
|
|
if (typeof node['when'] === 'string' && node['else'] !== undefined) walkExpr(node['else'], at);
|
|
};
|
|
|
|
const walkSource = (source: unknown, at: string): void => {
|
|
if (typeof source !== 'object' || source === null) return;
|
|
const node = source as Record<string, unknown>;
|
|
if (typeof node['each'] === 'string') checkAddress(node['each'], at);
|
|
for (const value of Object.values(node['where'] ?? {})) walkExpr(value, at);
|
|
if (typeof node['then'] === 'object' && node['then'] !== null) walkSource(node['then'], at);
|
|
if (Array.isArray(node['rows'])) {
|
|
for (const row of node['rows']) for (const value of Object.values(row as Record<string, unknown>)) walkExpr(value, at);
|
|
}
|
|
};
|
|
|
|
for (const step of manifest.assets.businessModel.steps ?? []) {
|
|
const recipe = step.auto;
|
|
if (recipe === undefined) continue;
|
|
const at = `assets.businessModel.steps[${step.item}].auto`;
|
|
const consumed = new Set<string>();
|
|
|
|
for (const [index, action] of recipe.do.entries()) {
|
|
const where = `${at}.do[${index}]`;
|
|
if (action.kind === 'project') {
|
|
checkAddress(action.into, where);
|
|
walkSource(action.from, where);
|
|
const columns = ledgers.get(action.into);
|
|
for (const [name, expr] of Object.entries(action.fields)) {
|
|
walkExpr(expr, where);
|
|
if (columns !== undefined && !columns.has(name)) {
|
|
out.push({ code: 'schema-invalid', severity: 'error', message: `配方写的列不在 ${action.into} 的声明里:${name}`, at: where });
|
|
}
|
|
}
|
|
}
|
|
if (action.kind === 'analyze') {
|
|
walkSource(action.input.from, where);
|
|
for (const expr of Object.values(action.input.fields)) walkExpr(expr, where);
|
|
if (!manifest.declarations.models.includes(action.model)) {
|
|
out.push({ code: 'schema-invalid', severity: 'error', message: `配方用的模型没在 declarations.models 里声明:${action.model}`, at: where });
|
|
}
|
|
}
|
|
if (action.kind === 'card') {
|
|
for (const address of action.gateLedgers ?? []) checkAddress(address, where);
|
|
if (action.as === 'decide' && (action.options ?? []).length === 0) {
|
|
out.push({ code: 'schema-invalid', severity: 'error', message: '决策卡一个选项都没有', at: where });
|
|
}
|
|
}
|
|
if (action.kind === 'submit' && (step.acceptanceChecklist ?? []).length === 0) {
|
|
out.push({
|
|
code: 'schema-invalid',
|
|
severity: 'error',
|
|
message: 'submit 那一步必须声明人工验收清单(acceptanceChecklist)—— 系统提交不比模型提交宽松',
|
|
at: where,
|
|
});
|
|
}
|
|
/* 找出 `result:xxx` 被谁消费了 —— analyze 的出参没人用,就是白烧一次模型调用 */
|
|
const text = JSON.stringify(action);
|
|
for (const hit of text.matchAll(/"result:([A-Za-z0-9_]+)"/g)) consumed.add(hit[1] ?? '');
|
|
}
|
|
|
|
for (const action of recipe.do) {
|
|
if (action.kind !== 'analyze') continue;
|
|
if (!consumed.has(action.into)) {
|
|
out.push({
|
|
code: 'schema-invalid',
|
|
severity: 'warning',
|
|
message: `analyze 的出参 result:${action.into} 没有任何一笔消费它 —— 白烧一次模型调用`,
|
|
at,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (step.view !== undefined && step.view !== '' && !templates.has(step.view)) {
|
|
out.push({ code: 'schema-invalid', severity: 'error', message: `剧本引的视图模版不存在:${step.view}`, at });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 挂进 `staticAudit`**
|
|
|
|
在 `staticAudit(manifest, external)` 函数体里,找到它 `out.push(...loopFindings(manifest))` 那一行(或等价写法),
|
|
在它之后加一行:
|
|
|
|
```ts
|
|
out.push(...autoRecipeFindings(manifest));
|
|
```
|
|
|
|
- [ ] **Step 5: 跑测试与全量门禁**
|
|
|
|
Run:
|
|
```bash
|
|
pnpm exec vitest run apps/api/src/modules/activity-registry
|
|
pnpm gates
|
|
```
|
|
Expected: 全绿。**现有三个活动包(E04 / I01 / W05)必须照装不误** —— 它们没有 `auto`,这一段整个不进。
|
|
|
|
- [ ] **Step 6: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/activity-registry
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(activity-registry): 配方静态审 —— 写错地址在装包那一刻就拒
|
|
|
|
与 refersTo 目标检查同款:代码错是必然的错,而必然的错只有在装的时候被拦下才算好查。
|
|
台账地址 / 列名 / 模型名 / 视图模版 / submit 的验收清单逐条核;
|
|
analyze 的出参没人消费报 warning —— 那是白烧一次模型调用。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: `data-options` —— 下拉的选项从台账现读
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/modules/interaction/internal/domain/view-render.ts`
|
|
- Modify: `apps/api/src/modules/interaction/__tests__/view-render.test.ts`
|
|
- Modify: `docs/HTML报告生成规范.md`(§4 绑定全表加一行)
|
|
|
|
**Interfaces:**
|
|
- Consumes: 现有 `resolveSlot` / `escapeHtml` / `displayValue`
|
|
- Produces: `function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string`,并由 `renderView` 在**最前面**调用
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
追加到 `apps/api/src/modules/interaction/__tests__/view-render.test.ts` 末尾:
|
|
|
|
```ts
|
|
describe('data-options:下拉的选项从台账现读', () => {
|
|
const scope = {
|
|
'parts.chip_type': [
|
|
{ type_id: 1, type_name: 'DC-DC 降压稳压器' },
|
|
{ type_id: 2, type_name: 'LDO 线性稳压器' },
|
|
],
|
|
chip_type_id: 2,
|
|
};
|
|
|
|
it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => {
|
|
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
|
|
const out = renderView(html, scope);
|
|
expect(out).toContain('<option value=""></option>');
|
|
expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
|
|
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
|
|
});
|
|
|
|
it('选项文字要转义 —— 台账里的值是人填的', () => {
|
|
const out = renderView(
|
|
'<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
|
|
{ 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
|
|
);
|
|
expect(out).toContain('<b>坏</b>');
|
|
expect(out).not.toContain('<b>坏</b>');
|
|
});
|
|
|
|
it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
|
|
const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
|
|
expect(out).toContain('<option value=""></option>');
|
|
expect(out).not.toContain('undefined');
|
|
});
|
|
|
|
it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
|
|
const html =
|
|
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
|
|
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
|
|
'</tbody>';
|
|
const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
|
|
expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
|
expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/interaction/__tests__/view-render.test.ts -t "data-options"`
|
|
Expected: FAIL —— 出来的还是空 `<select>`
|
|
|
|
- [ ] **Step 3: 写实现**
|
|
|
|
在 `view-render.ts` 里,`renderView` **之前**插入:
|
|
|
|
```ts
|
|
/** `<select … data-options="域.对象" data-option-value="列" data-option-label="列">` */
|
|
const OPTIONS_SELECT = /<select\b([^>]*?)\bdata-options\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/select>/g;
|
|
|
|
/**
|
|
* **下拉的选项从台账现读**(2026-08-29,spec §5.3)。
|
|
*
|
|
* 与 `data-rows` 同一层机制:**只取值,不判断**。地址写法也一致
|
|
* (`域.对象` = 任务视图,`mine:域.对象` = 本单草稿)。
|
|
*
|
|
* 为什么必须是现读而不是写死在模版里:芯片类型是**人在件上现造**的,
|
|
* 写死等于「只能选我出包那天就有的那几种」。
|
|
*
|
|
* **在行展开之前跑一遍**(`renderView` 的最内层):选项与行无关,一次铺好,
|
|
* 行展开时整段复制进每一行;哪一个 `selected` 由 `selectCurrent` 按各行自己的值定。
|
|
* 空选项一律留着 ——「还没选」得说得出来。
|
|
*/
|
|
export function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string {
|
|
return html.replace(OPTIONS_SELECT, (_all, before: string, address: string, after: string) => {
|
|
const raw = `${before}${after}`;
|
|
const valueCol = /\bdata-option-value\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? 'value';
|
|
const labelCol = /\bdata-option-label\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? valueCol;
|
|
const attrs = raw
|
|
.replace(/\s*\bdata-option-value\s*=\s*"[^"]*"/, '')
|
|
.replace(/\s*\bdata-option-label\s*=\s*"[^"]*"/, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
const rows = resolveSlot(scope, address);
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
const options = list
|
|
.map((row) => {
|
|
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
|
const value = escapeHtml(displayValue(one[valueCol]));
|
|
const label = escapeHtml(displayValue(one[labelCol]));
|
|
return `<option value="${value}">${label}</option>`;
|
|
})
|
|
.join('');
|
|
return `<select${attrs === '' ? '' : ` ${attrs}`}><option value=""></option>${options}</select>`;
|
|
});
|
|
}
|
|
```
|
|
|
|
并把 `renderView` 改成:
|
|
|
|
```ts
|
|
/** 先铺下拉选项,再展开行,最后填标量槽 —— 顺序不能反 */
|
|
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
|
return renderTemplate(renderRows(fillOptions(html, scope), scope), scope);
|
|
}
|
|
```
|
|
|
|
在 `apps/api/src/modules/interaction/index.ts` 的导出那一行加上 `fillOptions`:
|
|
|
|
```ts
|
|
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
|
```
|
|
|
|
- [ ] **Step 4: 跑测试确认通过**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/interaction`
|
|
Expected: 全绿(含新加的 4 条)
|
|
|
|
- [ ] **Step 5: 补规范**
|
|
|
|
在 `docs/HTML报告生成规范.md` §4「模版语言参考(绑定全表)」的表格里,`data-col` 那一行之后加一行:
|
|
|
|
```markdown
|
|
| `data-options="域.对象"`(配 `data-option-value` / `data-option-label`) | `<select>` | **选项从台账现读**:一行一个 `<option>`,`value` 取值列、显示取名列,前面固定留一个空选项(「还没选」得说得出来)。地址写法与 `data-rows` 一致。选中哪一个仍由 `selectCurrent` 按这一行的值定 —— 模版这一侧照旧只取值不判断 |
|
|
```
|
|
|
|
- [ ] **Step 6: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/interaction docs/HTML报告生成规范.md
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(interaction): data-options —— 下拉的选项从台账现读
|
|
|
|
芯片类型是人在件上现造的,写死在模版里等于「只能选出包那天就有的那几种」。
|
|
与 data-rows 同一层机制:只取值不判断,选中哪一个仍由 selectCurrent 按行内的值定。
|
|
空选项一律留着 ——「还没选」得说得出来。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: 件上「保存」之后 tick 一次
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/app/save-wake.ts`
|
|
- Create: `apps/api/src/app/__tests__/save-wake.test.ts`
|
|
- Modify: `apps/api/src/app/routes/desk.ts:1364-1365`(`saveTableRoute` 处理器里那两行)
|
|
|
|
**Interfaces:**
|
|
- Consumes: 现有 `container.views.repush(taskId)` 与 `container.task.wake(taskId)`
|
|
- Produces: `function afterSave(deps: { views: { repush(taskId: TaskId): Promise<unknown> }; task: { wake(taskId: TaskId): Promise<unknown> } }, taskId: TaskId, rows: number): Promise<void>`
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
```ts
|
|
// apps/api/src/app/__tests__/save-wake.test.ts
|
|
import { describe, expect, it } from 'vitest';
|
|
import { taskIdSchema } from '@contracts';
|
|
import { afterSave } from '../save-wake.ts';
|
|
|
|
const TASK = taskIdSchema.parse('11111111-1111-4111-8111-111111111111');
|
|
|
|
function spy() {
|
|
const calls: string[] = [];
|
|
return {
|
|
calls,
|
|
deps: {
|
|
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
|
task: { wake: () => { calls.push('wake'); return Promise.resolve(true); } },
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('件上保存之后', () => {
|
|
it('真落下了行:先重推件,再叫醒任务 —— 配方这才有机会按最新的草稿铺格', async () => {
|
|
const { calls, deps } = spy();
|
|
await afterSave(deps, TASK, 3);
|
|
expect(calls).toEqual(['repush', 'wake']);
|
|
});
|
|
|
|
it('一行都没落下:什么也不做 —— 没变的东西不值得叫醒谁', async () => {
|
|
const { calls, deps } = spy();
|
|
await afterSave(deps, TASK, 0);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it('叫醒失败不抬手:保存本身是成功的,别因为叫不醒把 200 变成 500', async () => {
|
|
const calls: string[] = [];
|
|
const deps = {
|
|
views: { repush: () => { calls.push('repush'); return Promise.resolve(true); } },
|
|
task: { wake: () => Promise.reject(new Error('库连不上')) },
|
|
};
|
|
await expect(afterSave(deps, TASK, 1)).resolves.toBeUndefined();
|
|
expect(calls).toEqual(['repush']);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: 跑一遍,确认它失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/app/__tests__/save-wake.test.ts`
|
|
Expected: FAIL —— `Failed to resolve import "../save-wake.ts"`
|
|
|
|
- [ ] **Step 3: 写实现**
|
|
|
|
```ts
|
|
// apps/api/src/app/save-wake.ts
|
|
/**
|
|
* **件上按了保存之后**(2026-08-29,spec §5.4)。
|
|
*
|
|
* 两件事按序:
|
|
* ① 重推件 —— 数据变了,算出来的那些(红带缺几格 / 分布条 / 去向)得重新求值;
|
|
* ② **叫醒任务** —— auto 步的配方这才有机会按最新的草稿把该铺的行铺出来。
|
|
* 人挑了芯片类型、按了保存,参数格就在这一下之后出现。
|
|
*
|
|
* 从前只有两条回传路由调 `wake`;保存不是回传,于是「保存即铺格」永远等不到那一拍。
|
|
* **叫不醒不抬手**:保存本身是成功的,不该因为叫不醒把 200 变成 500 ——
|
|
* 下一次推进器扫到它照样会跑。
|
|
*/
|
|
import type { TaskId } from '@contracts';
|
|
|
|
export async function afterSave(
|
|
deps: {
|
|
readonly views: { repush(taskId: TaskId): Promise<unknown> };
|
|
readonly task: { wake(taskId: TaskId): Promise<unknown> };
|
|
},
|
|
taskId: TaskId,
|
|
rows: number,
|
|
): Promise<void> {
|
|
if (rows <= 0) return;
|
|
await deps.views.repush(taskId);
|
|
await deps.task.wake(taskId).catch(() => false);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 接进路由**
|
|
|
|
在 `apps/api/src/app/routes/desk.ts` 的 `saveTableRoute` 处理器里,把这两行:
|
|
|
|
```ts
|
|
// 数据变了,件跟着变 —— 算出来的那些(类徽 / 分布条 / 去向)由这一次重渲重新求值
|
|
if (saved.rows > 0) await container.views.repush(taskId);
|
|
```
|
|
|
|
换成:
|
|
|
|
```ts
|
|
// 数据变了,件跟着变;并叫醒任务 —— auto 步的配方这才有机会按最新的草稿铺格
|
|
await afterSave(container, taskId, saved.rows);
|
|
```
|
|
|
|
并在该文件顶部 import 段加:
|
|
|
|
```ts
|
|
import { afterSave } from '../save-wake.ts';
|
|
```
|
|
|
|
- [ ] **Step 5: 跑测试与全量门禁**
|
|
|
|
Run:
|
|
```bash
|
|
pnpm exec vitest run apps/api/src/app/__tests__/save-wake.test.ts
|
|
pnpm gates
|
|
```
|
|
Expected: 全绿。
|
|
|
|
- [ ] **Step 6: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/app/save-wake.ts apps/api/src/app/__tests__/save-wake.test.ts apps/api/src/app/routes/desk.ts
|
|
git commit -m "$(cat <<'EOF'
|
|
feat(desk): 件上保存之后叫醒任务 —— auto 步的配方才有机会按最新草稿铺格
|
|
|
|
从前只有两条回传路由调 wake;保存不是回传,于是「保存即铺格」永远等不到那一拍。
|
|
叫不醒不抬手:保存本身是成功的,不该因为叫不醒把 200 变成 500。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: 工作日志把配方做的事归到系统名下
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/modules/memory/internal/domain/task-log.ts`(`actorOf` 开头加一条)
|
|
- Modify: `apps/api/src/modules/memory/__tests__/`(测 `task-log` 的那个文件,用 `grep -rln "actorOf\|taskLog" apps/api/src/modules/memory/__tests__/` 找它)
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 4 起每一笔配方留痕都带的 `detail.auto = '<步名>#<第几笔>'`
|
|
- Produces: 无新导出 —— 改的是 `actorOf` 的判定
|
|
|
|
- [ ] **Step 1: 写失败的测试**
|
|
|
|
追加到 task-log 的测试文件末尾:
|
|
|
|
```ts
|
|
describe('日志三色:配方做的事归系统', () => {
|
|
it('带 detail.auto 的那几行是**系统**做的 —— 既不是 AI 判的,也不是人办的', () => {
|
|
const lines = toLogLines([
|
|
{ kind: 'result', title: '芯片主数据:写下 2 行', detail: { auto: 'S1#3' }, at: '2026-08-29T00:00:00Z' },
|
|
] as never);
|
|
expect(lines[0]?.who).toBe('system');
|
|
expect(lines[0]?.actor).toBe('系统');
|
|
});
|
|
|
|
it('没有 detail.auto 的 result 照旧记在 AI 名下 —— 老行为一个字不变', () => {
|
|
const lines = toLogLines([{ kind: 'result', title: '我判齐了', detail: {}, at: '2026-08-29T00:00:00Z' }] as never);
|
|
expect(lines[0]?.who).toBe('agent');
|
|
});
|
|
});
|
|
```
|
|
|
|
> `toLogLines` 是该文件里已经在用的入口函数名;若它叫别的,
|
|
> 用 `grep -n "export function" apps/api/src/modules/memory/internal/domain/task-log.ts` 找那个把留痕转成日志行的导出。
|
|
|
|
- [ ] **Step 2: 跑一遍,确认第一条失败**
|
|
|
|
Run: `pnpm exec vitest run apps/api/src/modules/memory -t "配方做的事归系统"`
|
|
Expected: 第一条 FAIL(`who` 是 `agent`),第二条 PASS
|
|
|
|
- [ ] **Step 3: 在 `actorOf` 开头加一条**
|
|
|
|
在 `task-log.ts` 的 `function actorOf(e: MemoryEntry)` 函数体**第一行**(`const by = …` 之前)插入:
|
|
|
|
```ts
|
|
/*
|
|
* **配方做的事归系统**(2026-08-29,ADR-014)。自主率的分法要认第三档:
|
|
* 它既不是 AI 判出来的,也不是人办的 —— 是系统按声明做的。
|
|
* 不加这一条的话,`kind: 'result'` 会掉进 `default` 分支被记在「任务助手」名下,
|
|
* 日志上看着像 AI 干的 —— **界面不撒谎**。
|
|
*/
|
|
if (typeof e.detail?.['auto'] === 'string') return { actor: '系统', actorId: undefined, who: 'system' as const };
|
|
```
|
|
|
|
- [ ] **Step 4: 跑测试与全量门禁**
|
|
|
|
Run:
|
|
```bash
|
|
pnpm exec vitest run apps/api/src/modules/memory
|
|
pnpm gates
|
|
```
|
|
Expected: 全绿。
|
|
|
|
- [ ] **Step 5: 提交**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/memory
|
|
git commit -m "$(cat <<'EOF'
|
|
fix(memory): 工作日志把配方做的事归系统 —— 它不是 AI 判的,也不是人办的
|
|
|
|
不加这一条,配方的留痕会掉进 default 分支记在「任务助手」名下,日志上看着像 AI 干的。
|
|
自主率的分法要认第三档:确定的事交系统,判断交 AI,责任交人。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: 收口 —— 文档、欠账、全量回归
|
|
|
|
**Files:**
|
|
- Modify: `docs/活动开发规范.md`(新增一节:确定性步骤怎么写)
|
|
- Modify: `docs/adr/ADR-014-deterministic-steps.md`(状态 提案 → 已实现,并记两处改口)
|
|
- Modify: `docs/debt.md`(记两笔新欠账)
|
|
- Modify: `CLAUDE.md`(现状快照加一条)
|
|
|
|
**Interfaces:**
|
|
- Consumes: Task 1-11 全部
|
|
- Produces: 无代码产物 —— 这一条的交付物是「下一个人看得懂怎么用它」
|
|
|
|
- [ ] **Step 1: 跑一遍完整门禁,拿到真实的绿**
|
|
|
|
Run: `pnpm gates`
|
|
Expected: 六道全绿。**这是本计划的验收线**:`typecheck` / `lint` / `depcruise` / `api:check` / `test` 一道不许跳。
|
|
|
|
- [ ] **Step 2: 确认三个现有活动包一个字没改**
|
|
|
|
Run: `git diff --stat mvp-foundation...HEAD -- activities/`
|
|
Expected: **空**(三点写法 = 从两支的分叉点比起)。机制是加法,老活动的行为必须逐字不变。
|
|
若这条命令回了东西,说明你改到了活动包 —— 回去看是哪一步改的。
|
|
|
|
- [ ] **Step 3: 在活动开发规范里补一节**
|
|
|
|
在 `docs/活动开发规范.md` 的 §4(剧本 steps)之后新增 §4.6,正文写这些(照该文档既有的语气):
|
|
|
|
```markdown
|
|
## 4.6 确定性步骤:这一步系统自己做(2026-08-29)
|
|
|
|
一步要么确定,要么不是。**不做「一半 auto」** —— 判断点先从那一步里切出去。
|
|
|
|
`PlanStep.auto` 是一份**声明式配方**,五个动词按序执行:
|
|
|
|
| 动词 | 做什么 |
|
|
|---|---|
|
|
| `project` | 从某个来源的每一行投影出台账行,落草稿层。带 `prune` 时,这一笔投影出来的行**就是这张表在本单草稿里的全部** |
|
|
| `analyze` | **一次结构化 AI 调用**:系统组装入参、声明出参 schema、系统把结果落表。不给工具、不给记忆、不多轮 |
|
|
| `card` | 发一张卡(执行工单 / 数据工单 / 审核卡)。**至多一张**,发过就不再发 |
|
|
| `submit` | 提交放行(只许出现在声明了人工验收清单的那一步) |
|
|
| `say` | 一句给人看的交代 |
|
|
|
|
地址只有四种:`域.对象`(任务视图 = 正式 + 本单草稿叠加,**默认该用的那一种**)· `mine:域.对象` ·
|
|
`in:入参数组` · `result:xxx`(本拍 analyze 的出参)。
|
|
|
|
**四条纪律**(每一条都是踩出来的):
|
|
|
|
1. **行由确定的那一侧决定,不由 AI 决定。** 明细表要按类型字典 `each` 出来,AI 的出参只是拿主键
|
|
`lookup` 的一张查表 —— 它漏一条,那一格就是空,`required` 立刻变成 `_missing > 0`,闸与放行门当场拦住。
|
|
写成 `from each(AI 出参)` 的话,它少给两条就少两行,而每一行看着都合规。
|
|
2. **判据不许编一个数不出来的数。** 「本类型有几个字段」不是基座数得出来的数(`_rows` 数的是**本单写下的**行);
|
|
行数对账交给 `prune` 的结构保证,别写进 `doneWhen`。
|
|
3. **每一笔之前刷读数。** 后一笔常要数前一笔刚写下的行。
|
|
4. **出错交人,不重试、不退回模型。** 配方抛错是包的 bug;退回模型就是让它去凑。
|
|
```
|
|
|
|
- [ ] **Step 4: 把 ADR-014 的状态改掉**
|
|
|
|
把 `docs/adr/ADR-014-deterministic-steps.md` 第 3 行:
|
|
|
|
```markdown
|
|
状态:**提案**(2026-08-28,待用户定)
|
|
```
|
|
|
|
改成:
|
|
|
|
```markdown
|
|
状态:**已实现**(2026-08-29;设计见 `docs/superpowers/specs/2026-08-29-act-work-05-rewrite-design.md` §5)
|
|
```
|
|
|
|
并在 §8「不做什么」那一节末尾加一段:
|
|
|
|
```markdown
|
|
> **两处改口**(2026-08-29,ACT-WORK-05 重写时用户定):`submit` 与**发决策卡**这两条禁令撤掉。
|
|
> 它们原本是**假设还有 agent 在场**时的保守话;而全程无 agent 的活动**没有别人来干这两件事**,
|
|
> 不开口最后两步就没人做。护栏没松:`submit` 只许出现在声明了人工验收清单的那一步,
|
|
> 提交门(判据全绿)照旧把关 —— **系统提交不比模型提交宽松**;决策卡只在 `variant:'review'` 时
|
|
> 放宽到单选项(它不是岔路口,是一道签字关)。
|
|
```
|
|
|
|
- [ ] **Step 5: 记两笔欠账**
|
|
|
|
在 `docs/debt.md` 末尾加:
|
|
|
|
```markdown
|
|
- **配方没有 `drop` 动词**(2026-08-29):撤行只能靠 `project` 的 `prune`(「这一笔投影出来的就是全部」)。
|
|
要撤一行而不重投整张表的场合,现在写不出来。等真出现第二个用例再加。
|
|
- **配方的 `analyze` 不计入 `stalledTurns`,也不进单拍工具缝**(2026-08-29):它烧 token、计预算,
|
|
但不算「被拒」——熔断看不见它。一份写坏的 `instruction` 会每拍烧一次而不触发熔断;
|
|
目前靠「auto 步发了卡就收拍」封住(analyze 那一步不发卡,`doneWhen` 不成立就交人),
|
|
真出现空转再补一道计数。
|
|
- **治理台的活动详情还摆不出「这一步系统会做什么」**(2026-08-29):配方是数据,渲染得出来 ——
|
|
这是相对提示词白赚的一件,但要动前端,不在这一轮(后端已经把 `auto` 随清单存进库了)。
|
|
```
|
|
|
|
- [ ] **Step 6: 在 CLAUDE.md 的现状快照里加一条**
|
|
|
|
在 `## 现状快照` 列表末尾加(照该文件既有的行文):
|
|
|
|
```markdown
|
|
- **确定性步骤已落地**(2026-08-29,ADR-014):`PlanStep.auto` 声明式配方,五个动词
|
|
(`project` / `analyze` / `card` / `submit` / `say`)、四种地址、嵌套两层封顶;引擎在拍首跑,
|
|
**发了卡就收拍(零 token)**,有 `guide` 的照常问模型。`analyze` 是**一次结构化 AI 调用** ——
|
|
系统组装入参、声明出参 schema、系统落表,**不给工具不给记忆不多轮**。
|
|
三条纪律进了规范 §4.6:**行由确定的那一侧决定不由 AI 决定** / 判据不许编数不出来的数 /
|
|
每一笔之前刷读数。静态审在装包时把地址、列名、模型名、视图、submit 的验收清单逐条核死。
|
|
配套四件:`data-options`(下拉选项从台账现读)· 件上保存后 `wake`(「保存即铺格」)·
|
|
审核卡放宽到单选项 · 卡答复即收步。**E04 / I01 / W05 一个字没改** —— 它们的剧本里没有 `auto`。
|
|
```
|
|
|
|
- [ ] **Step 7: 最后一遍门禁并提交**
|
|
|
|
```bash
|
|
pnpm gates
|
|
git add docs CLAUDE.md
|
|
git commit -m "$(cat <<'EOF'
|
|
docs: 确定性步骤落地 —— 规范 §4.6、ADR-014 转已实现、两笔欠账
|
|
|
|
ADR-014 §8 的两条禁令(不 submit、不发决策卡)撤掉并写明理由:
|
|
那是假设还有 agent 在场时的保守话,全程无 agent 的活动没有别人来干这两件事。
|
|
|
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
|
|
EOF
|
|
)"
|
|
```
|
|
|
|
---
|
|
|
|
## 验收线
|
|
|
|
这份计划做完,下面每一条都要**以退出码或真实输出**验过,不许以 echo 验:
|
|
|
|
1. `pnpm gates` 六道全绿。
|
|
2. `git diff --stat mvp-foundation -- activities/` 为空 —— 三个现有活动包一个字没改。
|
|
3. 新增单测:契约 4 + 表达式 18 + 数据源 6 + 执行器 16 + analyze 网关 3 + liveStepOf 5 + turn-runner 接线 7 + 静态审 6 + `data-options` 4 + 保存唤醒 3 + 日志归属 2 = **74 条**。
|
|
4. `PlanStep.auto` 写坏地址 / 列名 / 模型名 / 视图名 / 缺验收清单的 submit,`pnpm activities:install` 时**装不进去**。
|
|
|
|
**这份计划不产出任何可跑的业务活动** —— 机制的第一个真实用户是第二份计划(W05 重写)。
|
|
它是刻意的:机制先在纯函数与假端口上钉死,再去接真活动;两件事搅在一起,出了问题分不清是机制还是活动。
|