From 13572a541680028d7a57c9a7ddc51289d04bd7ab Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 10:46:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(complexity):=20=E6=96=B0=E5=A2=9E=20AUTO?= =?UTF-8?q?=C2=B7=E7=94=B1=E6=A8=A1=E5=9E=8B=E5=88=A4=E6=96=AD=20=E5=A4=8D?= =?UTF-8?q?=E6=9D=82=E5=BA=A6=E6=A1=A3=E4=BD=8D=20(tsk=5FzacmwH3chZpG)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建任务时除手选 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 --- src/api/server.ts | 20 ++++- src/executor/classify.ts | 97 ++++++++++++++++++++ src/executor/models.ts | 9 ++ src/store/store.ts | 34 +++++++ test/classify.test.ts | 188 +++++++++++++++++++++++++++++++++++++++ web/index.html | 3 +- web/style.css | 1 + 7 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 src/executor/classify.ts create mode 100644 test/classify.test.ts diff --git a/src/api/server.ts b/src/api/server.ts index 91284f5..d6bb84c 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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 API,60s 缓存) */ getUsage?: () => Promise; + /** 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; 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) => { diff --git a/src/executor/classify.ts b/src/executor/classify.ts new file mode 100644 index 0000000..3002624 --- /dev/null +++ b/src/executor/classify.ts @@ -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; + +/** 解析失败 / 模型不可用时的兜底档 */ +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: ', + '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 { + 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}` }; + } +} diff --git a/src/executor/models.ts b/src/executor/models.ts index c372a6d..8c30730 100644 --- a/src/executor/models.ts +++ b/src/executor/models.ts @@ -45,6 +45,15 @@ export function resolvedExecutorModels(project: Project): Record 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); diff --git a/test/classify.test.ts b/test/classify.test.ts new file mode 100644 index 0000000..ba6bbbb --- /dev/null +++ b/test/classify.test.ts @@ -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('applyAutoComplexity:medium 占位 → 回填 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 { + 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(); +}); diff --git a/web/index.html b/web/index.html index ab81c94..6e3e502 100644 --- a/web/index.html +++ b/web/index.html @@ -122,8 +122,9 @@
diff --git a/web/style.css b/web/style.css index 497a893..11d67b1 100644 --- a/web/style.css +++ b/web/style.css @@ -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); }