From 2c2cde8542c0d97aaba9ddcc2c5bb8522ba98d16 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 25 Mar 2026 17:46:10 +0800 Subject: [PATCH] feat(company): implement Phase 2 - Hiring Pipeline with training & benchmark --- data/templates/_registry.json | 4 ++-- server/routes/company.js | 25 +++++++++++++++++++++---- server/scripts/test-company-e2e.js | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------- server/services/company-service.js | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+), 26 deletions(-) diff --git a/data/templates/_registry.json b/data/templates/_registry.json index 46edf0e..588192a 100644 --- a/data/templates/_registry.json +++ b/data/templates/_registry.json @@ -6,7 +6,7 @@ "description": "公司战略规划、团队管理、OKR 制定、任务委派", "benchmarkScore": 97, "trainedAt": "2026-03-20", - "usedCount": 1 + "usedCount": 2 }, { "id": "qa-judge", @@ -15,6 +15,6 @@ "description": "质量验收、OKR 进度审查、标准化仲裁", "benchmarkScore": 95, "trainedAt": "2026-03-25", - "usedCount": 1 + "usedCount": 2 } ] \ No newline at end of file diff --git a/server/routes/company.js b/server/routes/company.js index b4ba348..7580a31 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -68,13 +68,13 @@ router.post('/:companyId/shutdown', (req, res) => { // ═══════════════════════════════════════ // POST /api/company/:companyId/hire -router.post('/:companyId/hire', (req, res) => { +router.post('/:companyId/hire', async (req, res) => { try { - const { role, customAgent, customSoul, customUser } = req.body; + const { role, type, name, description, customAgent, customSoul, customUser } = req.body; if (!role) return res.status(400).json({ error: 'role 为必填项' }); - const result = companyService.hire(req.params.companyId, role, { - customAgent, customSoul, customUser, + const result = await companyService.hireWithTraining(req.params.companyId, role, { + type, name: name || role, description, customAgent, customSoul, customUser, }); res.json(result); } catch (err) { @@ -92,6 +92,23 @@ router.post('/:companyId/fire', (req, res) => { res.json(result); }); +// POST /api/company/:companyId/deposit-template — 训练合格后沉淀为模板 +router.post('/:companyId/deposit-template', (req, res) => { + try { + const { agentId, benchmarkScore } = req.body; + if (!agentId || benchmarkScore === undefined) { + return res.status(400).json({ error: 'agentId 和 benchmarkScore 为必填项' }); + } + + const result = companyService.depositTemplate( + req.params.companyId, agentId, Number(benchmarkScore) + ); + res.json(result); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + // GET /api/company/:companyId/agents router.get('/:companyId/agents', (req, res) => { const agents = companyService.listAgents(req.params.companyId); diff --git a/server/scripts/test-company-e2e.js b/server/scripts/test-company-e2e.js index 6080fa1..7c07d70 100644 --- a/server/scripts/test-company-e2e.js +++ b/server/scripts/test-company-e2e.js @@ -118,26 +118,96 @@ async function main() { const hireQa = svc.hire(TEST_COMPANY_ID, 'qa-judge'); assert(hireQa.status === 'hired_from_template', 'qa-judge 从模板招聘成功'); - // ─── 6. 招聘不存在的角色 (需要培养) ─── - console.log('\n🎓 Phase 6: 招聘不存在的角色 (需培养)'); - const hireNew = svc.hire(TEST_COMPANY_ID, 'hot-topic-hunter'); - assert(hireNew.status === 'needs_training', '返回 needs_training'); - assert(hireNew.needsTraining === true, 'needsTraining 标志为 true'); - - // ─── 7. 重复招聘检测 ─── - console.log('\n🔄 Phase 7: 重复招聘检测'); + // ─── 6. Phase 2: hireWithTraining — 模板不存在, 自动创建骨架 ─── + console.log('\n🎓 Phase 6: hireWithTraining (模板不存在 → 自动创建骨架)'); + const hireNew = await svc.hireWithTraining(TEST_COMPANY_ID, 'hot-topic-hunter', { + type: 'skill', + name: '热点搜索员', + description: '全网热点监控、选题建议', + }); + assert(hireNew.status === 'training', '返回 training 状态'); + + const hthDir = path.join(TEST_COMPANY_DIR, 'agents', 'hot-topic-hunter'); + assert(fs.existsSync(hthDir), '热点搜索员骨架目录已创建'); + assert(fs.existsSync(path.join(hthDir, 'agent.md')), 'agent.md 已生成'); + assert(fs.existsSync(path.join(hthDir, 'soul.md')), 'soul.md 已生成'); + assert(fs.existsSync(path.join(hthDir, 'config.json')), 'config.json 已生成'); + assert(fs.existsSync(path.join(hthDir, 'todo.md')), 'todo.md 已生成'); + assert(fs.existsSync(path.join(hthDir, 'memory', 'memory.md')), 'memory/ 已初始化'); + + const hthConfig = JSON.parse(fs.readFileSync(path.join(hthDir, 'config.json'), 'utf-8')); + assert(hthConfig.status === 'training', 'config 状态为 training'); + assert(hthConfig.companyId === TEST_COMPANY_ID, 'config 含 companyId'); + + // 检查公司 agents 列表已包含 + const companyWithHth = svc.get(TEST_COMPANY_ID); + assert(companyWithHth.agents.includes('hot-topic-hunter'), 'company.agents 包含 hot-topic-hunter'); + + // ─── 7. Phase 2: depositTemplate — Benchmark 门禁 ─── + console.log('\n🚧 Phase 7: depositTemplate — Benchmark 质量门禁'); + + // 7a: 评分不够 → 拒绝 + const rejectResult = svc.depositTemplate(TEST_COMPANY_ID, 'hot-topic-hunter', 80); + assert(rejectResult.status === 'rejected', '评分 80 被拒绝'); + assert(rejectResult.message.includes('80'), '拒绝消息包含分数'); + + // 7b: 评分 ≥ 95 → 通过 + const depositResult = svc.depositTemplate(TEST_COMPANY_ID, 'hot-topic-hunter', 97); + assert(depositResult.status === 'deposited', '评分 97 成功沉淀'); + assert(depositResult.templateId === 'hot-topic-hunter', '模板 ID 正确'); + + // 检查模板已创建 + const tplDir = path.join(DATA_DIR, 'templates', 'hot-topic-hunter'); + assert(fs.existsSync(tplDir), '模板目录已创建'); + assert(fs.existsSync(path.join(tplDir, 'config.json')), '模板 config.json 存在'); + + const tplConfig = JSON.parse(fs.readFileSync(path.join(tplDir, 'config.json'), 'utf-8')); + assert(tplConfig.benchmarkScore === 97, '模板 config 含 benchmarkScore'); + assert(tplConfig.companyId === undefined, '模板 config 不含 companyId (已清除)'); + assert(tplConfig.status === 'ready', '模板 config 状态为 ready'); + + // 检查 Agent 状态从 training → active + const hthConfigAfter = JSON.parse(fs.readFileSync(path.join(hthDir, 'config.json'), 'utf-8')); + assert(hthConfigAfter.status === 'active', 'Agent 状态从 training → active'); + + // 检查模板注册表更新 + const updatedRegistry = svc.listTemplates(); + const hthTemplate = updatedRegistry.find(t => t.id === 'hot-topic-hunter'); + assert(hthTemplate !== undefined, '注册表包含新模板'); + assert(hthTemplate && hthTemplate.benchmarkScore === 97, '注册表分数正确'); + + // ─── 8. Phase 2: 新模板可被第二家公司复用 ─── + console.log('\n🔄 Phase 8: 验证新模板可被第二家公司复用'); + const SECOND_COMPANY = 'test-manga-co'; + const secondDir = path.join(DATA_DIR, SECOND_COMPANY); + try { + svc.create({ id: SECOND_COMPANY, name: '测试漫剧公司', description: '漫剧测试' }); + const hireFromNewTpl = svc.hire(SECOND_COMPANY, 'hot-topic-hunter'); + assert(hireFromNewTpl.status === 'hired_from_template', '第二家公司从新模板招聘成功'); + + const secondHthConfig = JSON.parse(fs.readFileSync( + path.join(secondDir, 'agents', 'hot-topic-hunter', 'config.json'), 'utf-8' + )); + assert(secondHthConfig.companyId === SECOND_COMPANY, '第二家公司 companyId 正确注入'); + } finally { + if (fs.existsSync(secondDir)) fs.rmSync(secondDir, { recursive: true, force: true }); + } + + // ─── 9. 重复招聘检测 ─── + console.log('\n🔁 Phase 9: 重复招聘检测'); const hireDup = svc.hire(TEST_COMPANY_ID, 'ceo'); assert(hireDup.status === 'already_exists', '重复招聘返回 already_exists'); - // ─── 8. 公司 Agent 列表 ─── - console.log('\n📋 Phase 8: 公司 Agent 列表'); + // ─── 10. 公司 Agent 列表 ─── + console.log('\n📋 Phase 10: 公司 Agent 列表'); const agents = svc.listAgents(TEST_COMPANY_ID); - assert(agents.length === 2, `Agent 数量 = 2 (实际: ${agents.length})`); + assert(agents.length === 3, `Agent 数量 = 3 (实际: ${agents.length})`); assert(agents.some(a => a.id === 'ceo'), '列表包含 CEO'); assert(agents.some(a => a.id === 'qa-judge'), '列表包含 qa-judge'); + assert(agents.some(a => a.id === 'hot-topic-hunter'), '列表包含 hot-topic-hunter'); - // ─── 9. resolveAgentDir ─── - console.log('\n📍 Phase 9: resolveAgentDir 统一寻址'); + // ─── 11. resolveAgentDir ─── + console.log('\n📍 Phase 11: resolveAgentDir 统一寻址'); const companyAgentDir = CompanyService.resolveAgentDir(TEST_COMPANY_ID, 'ceo'); assert(companyAgentDir !== null, '公司内 CEO 可定位'); assert(companyAgentDir.includes(TEST_COMPANY_ID), '路径包含公司 ID'); @@ -149,8 +219,8 @@ async function main() { const notExist = CompanyService.resolveAgentDir(TEST_COMPANY_ID, 'nonexistent-agent'); assert(notExist === null, '不存在的 Agent 返回 null'); - // ─── 10. 解雇 Agent ─── - console.log('\n🚪 Phase 10: 解雇 Agent'); + // ─── 12. 解雇 Agent ─── + console.log('\n🚪 Phase 12: 解雇 Agent'); const fireResult = svc.fire(TEST_COMPANY_ID, 'qa-judge'); assert(fireResult.status === 'suspended', '解雇返回 suspended'); @@ -160,8 +230,8 @@ async function main() { const firedConfig = JSON.parse(fs.readFileSync(path.join(TEST_COMPANY_DIR, 'agents', 'qa-judge', 'config.json'), 'utf-8')); assert(firedConfig.status === 'suspended', 'qa-judge config 状态为 suspended'); - // ─── 11. 更新公司 ─── - console.log('\n✏️ Phase 11: 更新公司'); + // ─── 13. 更新公司 ─── + console.log('\n✏️ Phase 13: 更新公司'); const updated = svc.update(TEST_COMPANY_ID, { status: 'running', heartbeat: { enabled: true }, @@ -169,20 +239,30 @@ async function main() { assert(updated.status === 'running', '状态更新为 running'); assert(updated.heartbeat.enabled === true, '心跳已开启'); - // ─── 12. 关停公司 ─── - console.log('\n🛑 Phase 12: 关停公司'); + // ─── 14. 关停公司 ─── + console.log('\n🛑 Phase 14: 关停公司'); const shutdown = svc.shutdown(TEST_COMPANY_ID); assert(shutdown.status === 'shutdown', '状态变为 shutdown'); assert(shutdown.heartbeat.enabled === false, '心跳已关闭'); // ─── 清理 ─── cleanup(); + // 清理测试生成的模板 + const testTplDir = path.join(DATA_DIR, 'templates', 'hot-topic-hunter'); + if (fs.existsSync(testTplDir)) fs.rmSync(testTplDir, { recursive: true, force: true }); + // 恢复 registry (移除测试模板) + const regPath = path.join(DATA_DIR, 'templates', '_registry.json'); + if (fs.existsSync(regPath)) { + const reg = JSON.parse(fs.readFileSync(regPath, 'utf-8')); + const cleaned = reg.filter(t => t.id !== 'hot-topic-hunter'); + fs.writeFileSync(regPath, JSON.stringify(cleaned, null, 2), 'utf-8'); + } // ─── 结果 ─── console.log(`\n${'═'.repeat(50)}`); console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); if (failed === 0) { - console.log(' 🎉 ALL PASSED — Phase 1 生产就绪!\n'); + console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 生产就绪!\n'); } else { console.log(` ⚠️ ${failed} 个测试未通过\n`); process.exit(1); @@ -194,3 +274,4 @@ main().catch(err => { cleanup(); process.exit(1); }); + diff --git a/server/services/company-service.js b/server/services/company-service.js index 6f85d69..8a223f5 100644 --- a/server/services/company-service.js +++ b/server/services/company-service.js @@ -197,6 +197,182 @@ class CompanyService { } /** + * 用 AgentScaffold 直接创建 Agent 骨架到公司 + * (Phase 2: 不走 HTTP,直接调用脚手架) + * + * @param {string} companyId + * @param {string} roleName + * @param {object} opts - { type, name, description, parentId } + * @returns {Promise} { agentDir, agentId } + */ + async scaffoldNewAgent(companyId, roleName, opts = {}) { + const company = this.get(companyId); + if (!company) throw new Error(`公司 ${companyId} 不存在`); + + const companyAgentsDir = path.join(DATA_DIR, companyId, 'agents'); + if (!fs.existsSync(companyAgentsDir)) { + fs.mkdirSync(companyAgentsDir, { recursive: true }); + } + + // 使用 ESM 导入 AgentScaffold + const { AgentScaffold } = await import('../../packages/agent-core/AgentScaffold.js'); + const scaffold = new AgentScaffold(companyAgentsDir); + + const agentDir = await scaffold.create({ + id: roleName, + type: opts.type || 'skill', + name: opts.name || roleName, + parentId: opts.parentId || company.ceo || 'ceo', + goalId: opts.goalId || null, + }); + + // 注入公司上下文 + this._injectCompanyContext(companyId, roleName, opts); + + // 标记为 training 状态 + const configPath = path.join(agentDir, 'config.json'); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + config.status = 'training'; + config.companyId = companyId; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + } + + // 更新公司 + if (!company.agents.includes(roleName)) { + company.agents.push(roleName); + } + company.updatedAt = new Date().toISOString(); + this._saveCompany(company); + + console.log(`[Company] 🎓 ${companyId} 创建新 Agent 骨架: ${roleName} (training)`); + return { agentDir, agentId: roleName }; + } + + /** + * 将训练合格的 Agent 沉淀为模板 + * (Phase 2: Benchmark ≥ 95 后调用) + * + * @param {string} companyId + * @param {string} agentId + * @param {number} benchmarkScore + * @returns {object} { status, templateId } + */ + depositTemplate(companyId, agentId, benchmarkScore) { + if (benchmarkScore < 95) { + return { + status: 'rejected', + message: `评分 ${benchmarkScore} 未达标 (需 ≥ 95)`, + agentId, + }; + } + + const agentDir = path.join(DATA_DIR, companyId, 'agents', agentId); + if (!fs.existsSync(agentDir)) { + throw new Error(`Agent ${agentId} 在 ${companyId} 中不存在`); + } + + const templateDir = path.join(TEMPLATES_DIR, agentId); + // 如果模板已存在且分数更低,覆盖 + if (fs.existsSync(templateDir)) { + fs.rmSync(templateDir, { recursive: true, force: true }); + } + + // 只复制骨架文件到模板 (不含 memory/workspace/conversations) + fs.mkdirSync(templateDir, { recursive: true }); + for (const file of TEMPLATE_FILES) { + const srcFile = path.join(agentDir, file); + if (fs.existsSync(srcFile)) { + // 复制模板时去掉公司特定信息 (对 config.json 特殊处理) + if (file === 'config.json') { + const config = JSON.parse(fs.readFileSync(srcFile, 'utf-8')); + delete config.companyId; + config.status = 'ready'; + config.benchmarkScore = benchmarkScore; + config.trainedAt = new Date().toISOString().slice(0, 10); + fs.writeFileSync(path.join(templateDir, file), JSON.stringify(config, null, 2), 'utf-8'); + } else { + fs.copyFileSync(srcFile, path.join(templateDir, file)); + } + } + } + + // 复制 skills/ 目录 + const srcSkills = path.join(agentDir, 'skills'); + if (fs.existsSync(srcSkills)) { + this._copyDirRecursive(srcSkills, path.join(templateDir, 'skills')); + } + + // 更新模板注册表 + const registryPath = path.join(TEMPLATES_DIR, '_registry.json'); + let registry = []; + if (fs.existsSync(registryPath)) { + registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + } + // 移除旧条目 (如果存在) + registry = registry.filter(t => t.id !== agentId); + const agentConfig = this._readAgentConfig(templateDir); + registry.push({ + id: agentId, + name: agentConfig?.name || agentId, + type: agentConfig?.type || 'skill', + description: agentConfig?.description || '', + benchmarkScore, + trainedAt: new Date().toISOString().slice(0, 10), + usedCount: 0, + }); + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8'); + + // 将 Agent 状态从 training → active + const configPath = path.join(agentDir, 'config.json'); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + config.status = 'active'; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + } + + console.log(`[Company] 📦 Agent ${agentId} 沉淀为模板 (benchmark: ${benchmarkScore})`); + return { status: 'deposited', templateId: agentId, benchmarkScore }; + } + + /** + * 完整招聘流程 (Phase 2: 模板优先 → 骨架创建兜底) + * 如果模板不存在,会创建骨架并返回 training 状态 + * 上层(路由)负责异步触发 agent-creator + innovator 实际训练 + * + * @param {string} companyId + * @param {string} roleName + * @param {object} opts - { type, name, description, customAgent, customSoul, customUser } + * @returns {Promise} + */ + async hireWithTraining(companyId, roleName, opts = {}) { + // 先走标准招聘流程 + const result = this.hire(companyId, roleName, opts); + + if (result.status !== 'needs_training') { + return result; + } + + // 模板不存在 → 创建骨架 (Phase 2) + const scaffold = await this.scaffoldNewAgent(companyId, roleName, { + type: opts.type || 'skill', + name: opts.name || roleName, + description: opts.description || '', + parentId: opts.parentId, + customAgent: opts.customAgent, + customSoul: opts.customSoul, + customUser: opts.customUser, + }); + + return { + status: 'training', + agentId: roleName, + agentDir: scaffold.agentDir, + message: `Agent "${roleName}" 骨架已创建,状态: training。需要 agent-creator 定义 + innovator 训练至 ≥95 后才可上岗。`, + }; + } + + /** * 解雇 (suspend) 公司内的 Agent */ fire(companyId, agentId) { -- libgit2 0.21.4