feat(complexity): 新增 AUTO·由模型判断 复杂度档位 (tsk_zacmwH3chZpG)

新建任务时除手选 hard/medium/easy 外,新增 AUTO 档:先以 medium 落库
占位,建任务后异步用 sonnet 起一次轻量分类调用回填三档结果并记录理由,
不阻塞建任务返回;模型不可用/解析失败一律兜底 medium,绝不报错。

- web 看板:复杂度 seg 增加第四档 AUTO(默认选中,cyan 样式),提交 complexity='auto'
- src/api/server.ts:POST tasks 接受 'auto' → medium 占位 + 异步 classify 回填(可注入 classify 测试)
- src/executor/classify.ts(新增):buildClassifyPrompt/parseClassification/classifyComplexity
  复用 cc.ts/models.ts,sonnet 省额度,无工具、限轮限时
- src/executor/models.ts:pickClassifierModel(复用 executor easy 档 + MAESTRO_MODEL_EASY 覆盖)
- src/store/store.ts:applyAutoComplexity 回填复杂度+重置状态+理由入产出字段+广播 task.updated
- test/classify.test.ts:解析映射/兜底、prompt、回填、建任务集成(13 例)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 10:46:37 +08:00
parent 872354cf7c
commit 13572a5416
7 changed files with 348 additions and 4 deletions
+17 -3
View File
@@ -7,6 +7,7 @@ import type { TaskStatus } from '../model/status.js';
import type { Project, Autonomy } from '../model/types.js';
import { syncProject, hasTodoJson } from '../sync/todo-sync.js';
import { resolvedExecutorModels } from '../executor/models.js';
import { classifyComplexity, type ClassifierFn } from '../executor/classify.js';
import { mergeBranch } from '../executor/merge.js';
import { git, removeWorktree } from '../executor/worktree.js';
import { resolveLogo, LOGO_MIME } from './logo.js';
@@ -23,6 +24,8 @@ export interface ApiOptions {
logger?: boolean;
/** Claude 订阅额度查询(测试注入;默认直连 OAuth usage API60s 缓存) */
getUsage?: () => Promise<UsageInfo | null>;
/** AUTO 复杂度分类器(测试注入;默认 classifyComplexity,用 sonnet 起一次轻量调用) */
classify?: ClassifierFn;
}
/**
@@ -31,6 +34,7 @@ export interface ApiOptions {
*/
export function buildServer(opts: ApiOptions): FastifyInstance {
const { store } = opts;
const classify = opts.classify ?? ((title, description, cwd) => classifyComplexity(title, description, { cwd }));
const app = Fastify({ logger: opts.logger ?? false });
// Store 错误 → 400(业务校验),其余 → 500
@@ -152,13 +156,23 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
const { id } = req.params as { id: string };
const b = req.body as Record<string, unknown>;
if (!b?.title) throw new StoreError('title 必填');
if (!isComplexity(b.complexity)) throw new StoreError('complexity 必须是 hard|medium|easy');
return store.createTask({
projectId: id, title: String(b.title), complexity: b.complexity as Complexity,
// 'auto' = 由模型判定:先以 medium 落库占位,建任务后异步回填(不阻塞返回)
const auto = b.complexity === 'auto';
if (!auto && !isComplexity(b.complexity)) throw new StoreError('complexity 必须是 auto|hard|medium|easy');
const task = store.createTask({
projectId: id, title: String(b.title), complexity: auto ? 'medium' : (b.complexity as Complexity),
parentId: b.parentId ? String(b.parentId) : null,
priority: b.priority === undefined ? undefined : Number(b.priority),
deps: Array.isArray(b.deps) ? (b.deps as string[]) : undefined,
});
if (auto) {
// 异步分类回填:classifyComplexity 自身永不抛错(失败兜底 medium),外层再兜一层防御
const cwd = store.getProject(id)?.repoPath ?? process.cwd();
void classify(task.title, undefined, cwd)
.then(({ complexity, reason }) => store.applyAutoComplexity(task.id, complexity, reason))
.catch((e: unknown) => app.log.error(`任务 ${task.id} AUTO 复杂度分类失败(保留占位 medium):${(e as Error).message}`));
}
return task;
});
app.get('/api/tasks/:id', (req) => {
+97
View File
@@ -0,0 +1,97 @@
import { nanoid } from 'nanoid';
import type { Complexity } from '../model/complexity.js';
import { isComplexity } from '../model/complexity.js';
import { runClaude } from './cc.js';
import { pickClassifierModel } from './models.js';
/** AUTO 复杂度判定结果:三档之一 + 一句理由(供用户复核) */
export interface Classification {
complexity: Complexity;
reason: string;
}
/**
* 分类器函数签名(server 注入点;测试传 mock,生产传 classifyComplexity 封装)。
* 永不抛错:一切失败折叠成兜底 medium。
*/
export type ClassifierFn = (title: string, description: string | undefined, cwd: string) => Promise<Classification>;
/** 解析失败 / 模型不可用时的兜底档 */
const FALLBACK: Complexity = 'medium';
const CLASSIFY_MAX_TURNS = 2;
const CLASSIFY_TIMEOUT_MS = 60_000; // 60s:一次轻量文本判定,超时即兜底
/** 组装分类提示词:给出三档判据,要求只回 COMPLEXITY + REASON 两行。 */
export function buildClassifyPrompt(title: string, description?: string): string {
return [
'你是任务复杂度分级助手。请根据下述任务,判定它属于 hard / medium / easy 中的哪一档。',
'',
'## 三档判据',
'- easy:单文件或少量机械改动,无需设计、无未知项(如改文案、调参数、加日志)。',
'- medium:需要先写改动方案、会跨几处文件,但范围清晰、无需拆解成多个子任务。',
'- hard:需要拆解成多个子任务,跨模块/跨子系统,或存在明显未知项与设计取舍。',
'',
'## 任务',
`标题:${title}`,
`说明:${description?.trim() || '(无)'}`,
'',
'## 输出格式(严格两行,不要任何多余内容、不要使用工具)',
'COMPLEXITY: <hard|medium|easy>',
'REASON: <一句话理由>',
].join('\n');
}
/**
* 解析模型输出为三档 + 理由:
* - 取最后一个匹配 `COMPLEXITY: hard|medium|easy` 的值(大小写/空白宽容);
* - REASON 取第一行匹配;
* - 解析不到合法档位 → 兜底 medium(理由说明原因,绝不抛错)。
*/
export function parseClassification(text: string): Classification {
let complexity: Complexity | null = null;
const re = /COMPLEXITY:\s*(hard|medium|easy)/gi;
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
complexity = m[1].toLowerCase() as Complexity;
}
const rm = text.match(/REASON:\s*(.+)/i);
const reason = rm ? rm[1].trim() : '';
if (!complexity || !isComplexity(complexity)) {
return {
complexity: FALLBACK,
reason: reason
? `无法解析复杂度档位,兜底 ${FALLBACK}(模型原话:${reason}`
: `无法解析模型判定,兜底 ${FALLBACK}`,
};
}
return { complexity, reason: reason || '(模型未给出理由)' };
}
/**
* 生产分类器:复用 cc.ts 起一次轻量 headless CC(sonnet 省额度),无工具、限轮限时。
* 解析失败 / 超时 / 模型不可用 → 兜底 medium,永不抛错。
*/
export async function classifyComplexity(
title: string,
description: string | undefined,
opts: { cwd: string },
): Promise<Classification> {
try {
const cc = await runClaude({
prompt: buildClassifyPrompt(title, description),
cwd: opts.cwd,
model: pickClassifierModel(),
runId: `classify_${nanoid(10)}`,
maxTurns: CLASSIFY_MAX_TURNS,
timeoutMs: CLASSIFY_TIMEOUT_MS,
permissionMode: 'default',
allowedTools: [], // 纯文本判定,不需要任何工具
});
if (!cc.ok || !cc.finalText.trim()) {
return { complexity: FALLBACK, reason: `模型未产出有效判定,兜底 ${FALLBACK}${cc.error ? `${cc.error}` : ''}` };
}
return parseClassification(cc.finalText);
} catch (e) {
return { complexity: FALLBACK, reason: `分类调用异常,兜底 ${FALLBACK}${(e as Error).message}` };
}
}
+9
View File
@@ -45,6 +45,15 @@ export function resolvedExecutorModels(project: Project): Record<Complexity, str
return out;
}
/**
* AUTO 复杂度分类器用的模型:复用 executor easy 档(claude-sonnet,省额度),
* env MAESTRO_MODEL_EASY 可覆盖。单次轻量调用,不走 project.model。
*/
export function pickClassifierModel(): string {
const [env, fallback] = MODEL_TABLE.executor.easy;
return process.env[env]?.trim() || fallback;
}
/** 错误信息是否像“模型不可用”(not_found / invalid / permission 等模式 + 提到 model */
export function isModelError(msg: string): boolean {
if (!/model/i.test(msg)) return false;
+34
View File
@@ -375,6 +375,40 @@ export class Store {
return this.getTask(taskId)!;
}
/**
* AUTO 复杂度回填:模型判定后设置任务复杂度并记录判定理由(建任务时占位 medium,此处异步回填)。
* - 判定档与占位不同且仍可改(COMPLEXITY_EDITABLE)→ 改复杂度 + 重置状态(同 patchTask 落位逻辑,含依赖);
* - 理由写入该复杂度对应产出字段(hard→plan / medium→spec / easy→operations)的备注,仅在该字段为空时写,避免覆盖;
* - 始终广播 task.updatedpayload 带判定档与理由),便于看板/用户复核。
*/
applyAutoComplexity(taskId: string, complexity: Complexity, reason: string): Task {
const row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const cur = row.status as TaskStatus;
let statusChange: { from: TaskStatus; to: TaskStatus } | null = null;
if (complexity !== row.complexity && COMPLEXITY_EDITABLE.has(cur)) {
const to = this.resolveReady(row, initialNextStatus(complexity));
this.db.prepare(`UPDATE tasks SET complexity = ?, status = ?, updated_at = ? WHERE id = ?`)
.run(complexity, to, now(), taskId);
if (to !== cur) statusChange = { from: cur, to };
}
// 理由写入对应产出字段(仅当为空),便于用户与执行 agent 复核
const field = complexity === 'hard' ? 'plan' : complexity === 'medium' ? 'spec' : 'operations';
const fresh = this.getTaskRow(taskId)!;
if (!fresh[field]) {
const note = `> AUTO·模型判定复杂度:${complexity}\n> 理由:${reason}`;
this.db.prepare(`UPDATE tasks SET ${field} = ?, updated_at = ? WHERE id = ?`).run(note, now(), taskId);
}
this.emit(row.project_id, taskId, 'task.updated', { auto: 'classify', complexity, reason });
if (statusChange) {
this.emit(row.project_id, taskId, 'status.changed', { ...statusChange, reason: 'complexity.auto-classified' });
}
return this.getTask(taskId)!;
}
// ---------- 旧 todo 来源映射(sync 引擎用) ----------
setSourceRef(taskId: string, ref: string): void {
const row = this.getTaskRow(taskId);
+188
View File
@@ -0,0 +1,188 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildClassifyPrompt, parseClassification, type ClassifierFn } from '../src/executor/classify.js';
import { Store } from '../src/store/index.js';
import { buildServer } from '../src/api/server.js';
import type { Event } from '../src/model/types.js';
function freshStore(): Store {
return new Store(':memory:');
}
// ---------- parseClassification(映射 + 兜底) ----------
test('parseClassification:三档正常映射 + 取理由', () => {
for (const c of ['hard', 'medium', 'easy'] as const) {
const r = parseClassification(`COMPLEXITY: ${c}\nREASON: 因为如此`);
assert.equal(r.complexity, c);
assert.equal(r.reason, '因为如此');
}
});
test('parseClassification:大小写/空白宽容', () => {
const r = parseClassification(' complexity: HARD \nreason: 跨模块 ');
assert.equal(r.complexity, 'hard');
assert.equal(r.reason, '跨模块');
});
test('parseClassification:多个 COMPLEXITY 取最后一个', () => {
const r = parseClassification('COMPLEXITY: easy\n改判\nCOMPLEXITY: hard\nREASON: 复查后升级');
assert.equal(r.complexity, 'hard');
});
test('parseClassification:噪声/非法 → 兜底 medium', () => {
for (const noise of ['', '我也不知道', 'COMPLEXITY: trivial\nREASON: 乱写', '随便聊聊']) {
const r = parseClassification(noise);
assert.equal(r.complexity, 'medium', `输入「${noise}」应兜底 medium`);
assert.ok(r.reason.length > 0);
}
});
test('parseClassification:有档无理由 → 给占位理由', () => {
const r = parseClassification('COMPLEXITY: easy');
assert.equal(r.complexity, 'easy');
assert.ok(r.reason.includes('未给出'));
});
// ---------- buildClassifyPrompt ----------
test('buildClassifyPrompt:含三档判据 + 标题/说明 + 输出格式', () => {
const p = buildClassifyPrompt('改个文案', '把按钮文字改一下');
assert.match(p, /easy/);
assert.match(p, /medium/);
assert.match(p, /hard/);
assert.match(p, /改个文案/);
assert.match(p, /把按钮文字改一下/);
assert.match(p, /COMPLEXITY:/);
assert.match(p, /REASON:/);
});
test('buildClassifyPrompt:无说明时占位', () => {
const p = buildClassifyPrompt('只有标题');
assert.match(p, /说明:(无)/);
});
// ---------- Store.applyAutoComplexity(回填) ----------
test('applyAutoComplexitymedium 占位 → 回填 hard,状态重置 analyzing + 理由入 plan', () => {
const s = freshStore();
const events: Event[] = [];
s.subscribe((e) => events.push(e));
const p = s.createProject({ name: 'a', repoPath: '/tmp/a-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'medium' }); // speccing
assert.equal(t.status, 'speccing');
const u = s.applyAutoComplexity(t.id, 'hard', '需要跨模块拆解');
assert.equal(u.complexity, 'hard');
assert.equal(u.status, 'analyzing');
assert.match(u.plan ?? '', /需要跨模块拆解/);
// 广播了带理由的 task.updated + status.changed
const upd = events.filter((e) => e.type === 'task.updated' && e.payload.auto === 'classify').at(-1);
assert.equal(upd?.payload.complexity, 'hard');
assert.equal(upd?.payload.reason, '需要跨模块拆解');
assert.ok(events.some((e) => e.type === 'status.changed' && e.payload.to === 'analyzing'));
s.close();
});
test('applyAutoComplexity:回填 easy → 状态重置 ready + 理由入 operations', () => {
const s = freshStore();
const p = s.createProject({ name: 'b', repoPath: '/tmp/b-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'y', complexity: 'medium' });
const u = s.applyAutoComplexity(t.id, 'easy', '单文件机械改动');
assert.equal(u.complexity, 'easy');
assert.equal(u.status, 'ready');
assert.match(u.operations ?? '', /单文件机械改动/);
s.close();
});
test('applyAutoComplexity:判定仍是 medium → 不变状态但记录理由 + 广播', () => {
const s = freshStore();
const events: Event[] = [];
s.subscribe((e) => events.push(e));
const p = s.createProject({ name: 'c', repoPath: '/tmp/c-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'z', complexity: 'medium' });
const u = s.applyAutoComplexity(t.id, 'medium', '范围清晰、跨几处');
assert.equal(u.complexity, 'medium');
assert.equal(u.status, 'speccing'); // 未变
assert.match(u.spec ?? '', /范围清晰/);
assert.ok(events.some((e) => e.type === 'task.updated' && e.payload.auto === 'classify'));
// medium===medium,不应产生 status.changed
assert.ok(!events.some((e) => e.type === 'status.changed'));
s.close();
});
// ---------- 建任务集成(complexity='auto' ----------
/** 等待一次满足条件的事件(带超时,避免测试挂死) */
function waitFor(store: Store, pred: (e: Event) => boolean, ms = 2000): Promise<Event> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { off(); reject(new Error('等待事件超时')); }, ms);
const off = store.subscribe((e) => {
if (pred(e)) { clearTimeout(timer); off(); resolve(e); }
});
});
}
test('POST tasks complexity=auto:先 medium 落库,模型判定后异步回填 hard', async () => {
const s = freshStore();
const p = s.createProject({ name: 'srv', repoPath: '/tmp/srv-' + Math.random() });
const classify: ClassifierFn = async (title) => {
assert.equal(title, '拆个大功能');
return { complexity: 'hard', reason: '跨子系统、有未知项' };
};
const app = buildServer({ store: s, classify });
const backfilled = waitFor(s, (e) => e.type === 'task.updated' && e.payload.auto === 'classify');
const res = await app.inject({
method: 'POST', url: `/api/projects/${p.id}/tasks`,
payload: { title: '拆个大功能', complexity: 'auto' },
});
assert.equal(res.statusCode, 200);
const created = res.json();
assert.equal(created.complexity, 'medium'); // 占位
assert.equal(created.status, 'speccing');
await backfilled;
const after = s.getTask(created.id);
assert.equal(after?.complexity, 'hard'); // 已回填
assert.equal(after?.status, 'analyzing');
assert.match(after?.plan ?? '', /跨子系统/);
await app.close();
s.close();
});
test('POST tasks complexity=auto:模型不可用(兜底 medium)仍建成功且为 medium', async () => {
const s = freshStore();
const p = s.createProject({ name: 'srv2', repoPath: '/tmp/srv2-' + Math.random() });
// 模拟 classifyComplexity 内部兜底:返回 medium
const classify: ClassifierFn = async () => ({ complexity: 'medium', reason: '模型不可用,兜底 medium' });
const app = buildServer({ store: s, classify });
const backfilled = waitFor(s, (e) => e.type === 'task.updated' && e.payload.auto === 'classify');
const res = await app.inject({
method: 'POST', url: `/api/projects/${p.id}/tasks`,
payload: { title: '不确定的任务', complexity: 'auto' },
});
assert.equal(res.statusCode, 200);
assert.equal(res.json().complexity, 'medium');
await backfilled;
const after = s.getTask(res.json().id);
assert.equal(after?.complexity, 'medium');
assert.match(after?.spec ?? '', /兜底 medium/);
await app.close();
s.close();
});
test('POST tasks:非法 complexity(非 auto/三档)被拒 400', async () => {
const s = freshStore();
const p = s.createProject({ name: 'srv3', repoPath: '/tmp/srv3-' + Math.random() });
const app = buildServer({ store: s, classify: async () => ({ complexity: 'medium', reason: '' }) });
const res = await app.inject({
method: 'POST', url: `/api/projects/${p.id}/tasks`,
payload: { title: 't', complexity: 'trivial' },
});
assert.equal(res.statusCode, 400);
await app.close();
s.close();
});
+2 -1
View File
@@ -122,8 +122,9 @@
<div class="form-row">
<label>复杂度 <span class="req">*</span>
<span class="seg" id="cplxSeg">
<input type="radio" name="complexity" value="auto" id="cx-a" checked><label for="cx-a" class="seg-a" title="由模型判断复杂度">AUTO</label>
<input type="radio" name="complexity" value="hard" id="cx-h"><label for="cx-h" class="seg-h">HARD</label>
<input type="radio" name="complexity" value="medium" id="cx-m" checked><label for="cx-m" class="seg-m">MEDIUM</label>
<input type="radio" name="complexity" value="medium" id="cx-m"><label for="cx-m" class="seg-m">MEDIUM</label>
<input type="radio" name="complexity" value="easy" id="cx-e"><label for="cx-e" class="seg-e">EASY</label>
</span>
</label>
+1
View File
@@ -369,6 +369,7 @@ textarea { resize: vertical; min-height: 72px; width: 100%; }
}
.seg label + input + label, .seg input + label { border-left: 0; }
.seg label:not(:first-of-type) { border-left: 1px solid var(--line); }
.seg input:checked + label.seg-a { background: var(--cyan-dim); color: var(--cyan); }
.seg input:checked + label.seg-h { background: var(--red-dim); color: var(--red); }
.seg input:checked + label.seg-m { background: var(--amber-dim); color: var(--amber); }
.seg input:checked + label.seg-e { background: var(--green-dim); color: var(--green); }