Commit b24b562c586cf59bb922b6272450e49f95d46b28
1 parent
67b54882
feat(p1): 运营数据接入 + 归因分析 + Know-how自动更新
Phase 3 反馈进化闭环: 1. DataSourceService (运营数据接入): - 新增 datasource-service.js: 抽象适配器模式 - MockAdapter: 模拟运营数据 (开发调试) - FileAdapter: 从本地 JSON 文件读取数据 - 可扩展: 预留 WechatAdapter/DouyinAdapter 接口 - API: GET /data/metrics, /data/trending, /data/attribution 2. 归因分析引擎: - analyzeAttribution(): 自动对比两个时间段指标 - 计算变化百分比和趋势方向 - 自动提取显著变化维度并生成洞察文本 - API: GET /api/company/:id/data/attribution 3. Know-how 自动更新 (memory-service.js): - 新增 updateAgentKnowhow() 函数 - autoReflect 产出的 patterns/lessons 自动写回 SKILL.md - 在 SKILL.md 末尾维护'经验教训(自动沉淀)'段落 - 保留最近 20 条记录,自动清理过旧条目 - 闭环: Agent执行 → 反思 → 经验写入SKILL → 下次读取改进行为
Showing
3 changed files
with
362 additions
and
0 deletions
server/routes/company.js
| @@ -339,4 +339,56 @@ router.delete('/:companyId/schedule/slots/:slotId', (req, res) => { | @@ -339,4 +339,56 @@ router.delete('/:companyId/schedule/slots/:slotId', (req, res) => { | ||
| 339 | res.json({ success: true }); | 339 | res.json({ success: true }); |
| 340 | }); | 340 | }); |
| 341 | 341 | ||
| 342 | +// ═══════════════════════════════════════ | ||
| 343 | +// DataSource (运营数据接入) | ||
| 344 | +// ═══════════════════════════════════════ | ||
| 345 | + | ||
| 346 | +const { DataSourceService } = require('../services/datasource-service'); | ||
| 347 | +const dataSourceService = new DataSourceService(); | ||
| 348 | + | ||
| 349 | +// GET /api/company/:companyId/data/metrics — 获取运营指标 | ||
| 350 | +router.get('/:companyId/data/metrics', async (req, res) => { | ||
| 351 | + try { | ||
| 352 | + const { source = 'mock', startDate, endDate } = req.query; | ||
| 353 | + const sd = startDate || new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10); | ||
| 354 | + const ed = endDate || new Date().toISOString().slice(0, 10); | ||
| 355 | + const data = await dataSourceService.fetchMetrics(source, { startDate: sd, endDate: ed }); | ||
| 356 | + res.json(data); | ||
| 357 | + } catch (err) { | ||
| 358 | + res.status(400).json({ error: err.message }); | ||
| 359 | + } | ||
| 360 | +}); | ||
| 361 | + | ||
| 362 | +// GET /api/company/:companyId/data/trending — 获取热点趋势 | ||
| 363 | +router.get('/:companyId/data/trending', async (req, res) => { | ||
| 364 | + try { | ||
| 365 | + const { source = 'mock', platform = 'weibo', count = '10' } = req.query; | ||
| 366 | + const data = await dataSourceService.fetchTrending(source, { platform, count: parseInt(count, 10) }); | ||
| 367 | + res.json(data); | ||
| 368 | + } catch (err) { | ||
| 369 | + res.status(400).json({ error: err.message }); | ||
| 370 | + } | ||
| 371 | +}); | ||
| 372 | + | ||
| 373 | +// GET /api/company/:companyId/data/attribution — 归因分析 | ||
| 374 | +router.get('/:companyId/data/attribution', async (req, res) => { | ||
| 375 | + try { | ||
| 376 | + const { source = 'mock' } = req.query; | ||
| 377 | + const now = new Date(); | ||
| 378 | + const currentEnd = now.toISOString().slice(0, 10); | ||
| 379 | + const currentStart = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10); | ||
| 380 | + const previousEnd = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10); | ||
| 381 | + const previousStart = new Date(now.getTime() - 14 * 86400000).toISOString().slice(0, 10); | ||
| 382 | + | ||
| 383 | + const analysis = await dataSourceService.analyzeAttribution( | ||
| 384 | + source, | ||
| 385 | + { startDate: currentStart, endDate: currentEnd }, | ||
| 386 | + { startDate: previousStart, endDate: previousEnd } | ||
| 387 | + ); | ||
| 388 | + res.json(analysis); | ||
| 389 | + } catch (err) { | ||
| 390 | + res.status(400).json({ error: err.message }); | ||
| 391 | + } | ||
| 392 | +}); | ||
| 393 | + | ||
| 342 | module.exports = router; | 394 | module.exports = router; |
server/services/datasource-service.js
0 → 100644
| 1 | +/** | ||
| 2 | + * DataSourceService — 运营数据接入抽象层 | ||
| 3 | + * | ||
| 4 | + * 提供标准化的数据源接口,支持多平台运营数据接入。 | ||
| 5 | + * 每个数据源实现 DataSourceAdapter 接口: | ||
| 6 | + * - fetchMetrics(dateRange) → 获取核心指标 | ||
| 7 | + * - fetchContent(contentId) → 获取内容详情 | ||
| 8 | + * - fetchTrending(params) → 获取热点/趋势 | ||
| 9 | + * | ||
| 10 | + * 已实现的 Adapter: | ||
| 11 | + * - MockAdapter: 本地模拟数据 (开发调试用) | ||
| 12 | + * - FileAdapter: 从本地文件读取数据 (CSV/JSON) | ||
| 13 | + * | ||
| 14 | + * 可扩展: | ||
| 15 | + * - WechatAdapter: 微信公众号后台数据 | ||
| 16 | + * - DouyinAdapter: 抖音数据 | ||
| 17 | + * - CustomAPIAdapter: 自定义 REST API | ||
| 18 | + */ | ||
| 19 | + | ||
| 20 | +const fs = require('fs'); | ||
| 21 | +const path = require('path'); | ||
| 22 | + | ||
| 23 | +const DATA_DIR = path.resolve(__dirname, '../../data'); | ||
| 24 | + | ||
| 25 | +// ════════════════════════════════════════ | ||
| 26 | +// DataSource Adapter 基类 | ||
| 27 | +// ════════════════════════════════════════ | ||
| 28 | + | ||
| 29 | +class DataSourceAdapter { | ||
| 30 | + constructor(config = {}) { | ||
| 31 | + this.config = config; | ||
| 32 | + this.name = 'base'; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + /** @returns {Promise<any>} */ | ||
| 36 | + async fetchMetrics(dateRange) { return null; } | ||
| 37 | + /** @returns {Promise<any>} */ | ||
| 38 | + async fetchContent(contentId) { return null; } | ||
| 39 | + /** @returns {Promise<any>} */ | ||
| 40 | + async fetchTrending(params) { return null; } | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +// ════════════════════════════════════════ | ||
| 44 | +// Mock Adapter — 模拟数据 (开发调试用) | ||
| 45 | +// ════════════════════════════════════════ | ||
| 46 | + | ||
| 47 | +class MockAdapter extends DataSourceAdapter { | ||
| 48 | + constructor(config) { | ||
| 49 | + super(config); | ||
| 50 | + this.name = 'mock'; | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + async fetchMetrics({ startDate, endDate }) { | ||
| 54 | + const days = []; | ||
| 55 | + const start = new Date(startDate); | ||
| 56 | + const end = new Date(endDate); | ||
| 57 | + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { | ||
| 58 | + days.push({ | ||
| 59 | + date: d.toISOString().slice(0, 10), | ||
| 60 | + views: Math.floor(Math.random() * 10000 + 500), | ||
| 61 | + likes: Math.floor(Math.random() * 500 + 20), | ||
| 62 | + shares: Math.floor(Math.random() * 100 + 5), | ||
| 63 | + comments: Math.floor(Math.random() * 200 + 10), | ||
| 64 | + followers: Math.floor(Math.random() * 50 + 1), | ||
| 65 | + revenue: Math.round(Math.random() * 5000) / 100, | ||
| 66 | + }); | ||
| 67 | + } | ||
| 68 | + return { | ||
| 69 | + source: 'mock', | ||
| 70 | + period: { startDate, endDate }, | ||
| 71 | + dailyMetrics: days, | ||
| 72 | + summary: { | ||
| 73 | + totalViews: days.reduce((s, d) => s + d.views, 0), | ||
| 74 | + totalLikes: days.reduce((s, d) => s + d.likes, 0), | ||
| 75 | + avgDailyViews: Math.round(days.reduce((s, d) => s + d.views, 0) / days.length), | ||
| 76 | + }, | ||
| 77 | + }; | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + async fetchTrending({ platform = 'weibo', count = 10 }) { | ||
| 81 | + const topics = [ | ||
| 82 | + 'AI 革命新进展', '年轻人消费降级', '职场新趋势', '健康科技创新', | ||
| 83 | + '城市生活方式', '教育改革方向', '新能源汽车竞争', '社交媒体新玩法', | ||
| 84 | + '房市最新政策', '投资理财策略', '远程办公生态', '文化IP跨界', | ||
| 85 | + ]; | ||
| 86 | + return { | ||
| 87 | + source: 'mock', | ||
| 88 | + platform, | ||
| 89 | + timestamp: new Date().toISOString(), | ||
| 90 | + topics: topics.slice(0, count).map((t, i) => ({ | ||
| 91 | + rank: i + 1, | ||
| 92 | + title: t, | ||
| 93 | + hotness: Math.floor(Math.random() * 1000000 + 100000), | ||
| 94 | + category: ['tech', 'lifestyle', 'finance', 'culture'][i % 4], | ||
| 95 | + })), | ||
| 96 | + }; | ||
| 97 | + } | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +// ════════════════════════════════════════ | ||
| 101 | +// File Adapter — 从本地文件读取数据 | ||
| 102 | +// ════════════════════════════════════════ | ||
| 103 | + | ||
| 104 | +class FileAdapter extends DataSourceAdapter { | ||
| 105 | + constructor(config) { | ||
| 106 | + super(config); | ||
| 107 | + this.name = 'file'; | ||
| 108 | + this.dataDir = config.dataDir || path.join(DATA_DIR, '_datasources'); | ||
| 109 | + } | ||
| 110 | + | ||
| 111 | + async fetchMetrics({ startDate, endDate }) { | ||
| 112 | + const metricsPath = path.join(this.dataDir, 'metrics.json'); | ||
| 113 | + if (!fs.existsSync(metricsPath)) return { source: 'file', error: 'metrics.json not found' }; | ||
| 114 | + const allData = JSON.parse(fs.readFileSync(metricsPath, 'utf-8')); | ||
| 115 | + | ||
| 116 | + // 按日期过滤 | ||
| 117 | + const filtered = (allData.dailyMetrics || []) | ||
| 118 | + .filter(d => d.date >= startDate && d.date <= endDate); | ||
| 119 | + | ||
| 120 | + return { | ||
| 121 | + source: 'file', | ||
| 122 | + period: { startDate, endDate }, | ||
| 123 | + dailyMetrics: filtered, | ||
| 124 | + summary: { | ||
| 125 | + totalViews: filtered.reduce((s, d) => s + (d.views || 0), 0), | ||
| 126 | + totalLikes: filtered.reduce((s, d) => s + (d.likes || 0), 0), | ||
| 127 | + avgDailyViews: filtered.length > 0 | ||
| 128 | + ? Math.round(filtered.reduce((s, d) => s + (d.views || 0), 0) / filtered.length) | ||
| 129 | + : 0, | ||
| 130 | + }, | ||
| 131 | + }; | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + async fetchTrending({ platform = 'local', count = 10 }) { | ||
| 135 | + const trendingPath = path.join(this.dataDir, `trending_${platform}.json`); | ||
| 136 | + if (!fs.existsSync(trendingPath)) { | ||
| 137 | + // fallback to generic trending.json | ||
| 138 | + const genericPath = path.join(this.dataDir, 'trending.json'); | ||
| 139 | + if (!fs.existsSync(genericPath)) return { source: 'file', error: 'trending data not found' }; | ||
| 140 | + return JSON.parse(fs.readFileSync(genericPath, 'utf-8')); | ||
| 141 | + } | ||
| 142 | + return JSON.parse(fs.readFileSync(trendingPath, 'utf-8')); | ||
| 143 | + } | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// ════════════════════════════════════════ | ||
| 147 | +// DataSourceService — 统一管理入口 | ||
| 148 | +// ════════════════════════════════════════ | ||
| 149 | + | ||
| 150 | +class DataSourceService { | ||
| 151 | + constructor() { | ||
| 152 | + this.adapters = new Map(); | ||
| 153 | + // 默认注册 Mock 适配器 | ||
| 154 | + this.register('mock', new MockAdapter()); | ||
| 155 | + this.register('file', new FileAdapter()); | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + /** | ||
| 159 | + * 注册数据源适配器 | ||
| 160 | + */ | ||
| 161 | + register(name, adapter) { | ||
| 162 | + this.adapters.set(name, adapter); | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + /** | ||
| 166 | + * 获取数据源 | ||
| 167 | + */ | ||
| 168 | + getAdapter(name) { | ||
| 169 | + return this.adapters.get(name); | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + /** | ||
| 173 | + * 获取指标数据 (自动选择适配器) | ||
| 174 | + * @param {string} source - 'mock' | 'file' | 'wechat' | ... | ||
| 175 | + * @param {object} params - { startDate, endDate } | ||
| 176 | + */ | ||
| 177 | + async fetchMetrics(source, params) { | ||
| 178 | + const adapter = this.adapters.get(source); | ||
| 179 | + if (!adapter) throw new Error(`DataSource adapter "${source}" not found`); | ||
| 180 | + return adapter.fetchMetrics(params); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + /** | ||
| 184 | + * 获取热点趋势 | ||
| 185 | + */ | ||
| 186 | + async fetchTrending(source, params) { | ||
| 187 | + const adapter = this.adapters.get(source); | ||
| 188 | + if (!adapter) throw new Error(`DataSource adapter "${source}" not found`); | ||
| 189 | + return adapter.fetchTrending(params); | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + /** | ||
| 193 | + * 归因分析 — 对比两个时间段的指标变化 | ||
| 194 | + * | ||
| 195 | + * @param {string} source | ||
| 196 | + * @param {object} current - { startDate, endDate } 当前周期 | ||
| 197 | + * @param {object} previous - { startDate, endDate } 上一周期 | ||
| 198 | + * @returns {object} 归因分析结果 | ||
| 199 | + */ | ||
| 200 | + async analyzeAttribution(source, current, previous) { | ||
| 201 | + const currentData = await this.fetchMetrics(source, current); | ||
| 202 | + const previousData = await this.fetchMetrics(source, previous); | ||
| 203 | + | ||
| 204 | + const cs = currentData.summary; | ||
| 205 | + const ps = previousData.summary; | ||
| 206 | + | ||
| 207 | + const changes = {}; | ||
| 208 | + for (const key of Object.keys(cs)) { | ||
| 209 | + const curr = cs[key] || 0; | ||
| 210 | + const prev = ps[key] || 0; | ||
| 211 | + const delta = prev > 0 ? ((curr - prev) / prev * 100).toFixed(1) : 'N/A'; | ||
| 212 | + changes[key] = { | ||
| 213 | + current: curr, | ||
| 214 | + previous: prev, | ||
| 215 | + changePercent: delta, | ||
| 216 | + trend: curr > prev ? '📈 上升' : curr < prev ? '📉 下降' : '➡️ 持平', | ||
| 217 | + }; | ||
| 218 | + } | ||
| 219 | + | ||
| 220 | + // 找出最显著的变化维度 | ||
| 221 | + const significantChanges = Object.entries(changes) | ||
| 222 | + .filter(([_, v]) => v.changePercent !== 'N/A' && Math.abs(parseFloat(v.changePercent)) > 10) | ||
| 223 | + .sort((a, b) => Math.abs(parseFloat(b[1].changePercent)) - Math.abs(parseFloat(a[1].changePercent))); | ||
| 224 | + | ||
| 225 | + return { | ||
| 226 | + source, | ||
| 227 | + currentPeriod: current, | ||
| 228 | + previousPeriod: previous, | ||
| 229 | + changes, | ||
| 230 | + insights: significantChanges.map(([key, val]) => | ||
| 231 | + `${key}: ${val.trend} ${val.changePercent}% (${val.previous} → ${val.current})` | ||
| 232 | + ), | ||
| 233 | + recommendation: significantChanges.length > 0 | ||
| 234 | + ? `主要变化维度: ${significantChanges[0][0]} (${significantChanges[0][1].changePercent}%)` | ||
| 235 | + : '各项指标基本持平,无显著变化', | ||
| 236 | + }; | ||
| 237 | + } | ||
| 238 | +} | ||
| 239 | + | ||
| 240 | +module.exports = { DataSourceService, DataSourceAdapter, MockAdapter, FileAdapter }; |
server/services/memory-service.js
| @@ -721,6 +721,9 @@ function autoReflect(agentId, convId, meta, output) { | @@ -721,6 +721,9 @@ function autoReflect(agentId, convId, meta, output) { | ||
| 721 | // 更新 memory.md 的"已验证模式"和"失败教训"段 | 721 | // 更新 memory.md 的"已验证模式"和"失败教训"段 |
| 722 | updateMemoryMdSections(agentId, reflection); | 722 | updateMemoryMdSections(agentId, reflection); |
| 723 | 723 | ||
| 724 | + // ── Know-how 自动更新: 将经验教训写回 SKILL.md ── | ||
| 725 | + updateAgentKnowhow(agentId, reflection); | ||
| 726 | + | ||
| 724 | console.log(`[Memory] 🪞 反思完成: ${agentId} (${reflection.patterns.length} 模式, ${reflection.lessons.length} 教训${reflection.plan ? ', +1 方案固化' : ''})`); | 727 | console.log(`[Memory] 🪞 反思完成: ${agentId} (${reflection.patterns.length} 模式, ${reflection.lessons.length} 教训${reflection.plan ? ', +1 方案固化' : ''})`); |
| 725 | } | 728 | } |
| 726 | 729 | ||
| @@ -986,6 +989,73 @@ function generateDailyReport(agentId) { | @@ -986,6 +989,73 @@ function generateDailyReport(agentId) { | ||
| 986 | } | 989 | } |
| 987 | } | 990 | } |
| 988 | 991 | ||
| 992 | +// ════════════════════════════════════════ | ||
| 993 | +// 9. Phase 3: Know-how 自动更新 | ||
| 994 | +// ════════════════════════════════════════ | ||
| 995 | + | ||
| 996 | +/** | ||
| 997 | + * 将反思产出的经验教训写回 Agent 的 SKILL.md | ||
| 998 | + * 在 SKILL.md 末尾维护一个 "## 经验教训 (自动沉淀)" 段落 | ||
| 999 | + * | ||
| 1000 | + * @param {string} agentId | ||
| 1001 | + * @param {object} reflection - { patterns: string[], lessons: string[] } | ||
| 1002 | + */ | ||
| 1003 | +function updateAgentKnowhow(agentId, reflection) { | ||
| 1004 | + try { | ||
| 1005 | + if (!reflection.patterns.length && !reflection.lessons.length) return; | ||
| 1006 | + | ||
| 1007 | + const agentDir = getAgentDir(agentId); | ||
| 1008 | + | ||
| 1009 | + // 查找 SKILL.md(可能在多个路径) | ||
| 1010 | + const skillPaths = [ | ||
| 1011 | + path.join(agentDir, '.claude', 'skills', 'main_skill', 'SKILL.md'), | ||
| 1012 | + path.join(agentDir, 'SKILL.md'), | ||
| 1013 | + ]; | ||
| 1014 | + let skillPath = null; | ||
| 1015 | + for (const sp of skillPaths) { | ||
| 1016 | + if (fs.existsSync(sp)) { skillPath = sp; break; } | ||
| 1017 | + } | ||
| 1018 | + if (!skillPath) return; // 没有 SKILL.md,跳过 | ||
| 1019 | + | ||
| 1020 | + let content = fs.readFileSync(skillPath, 'utf-8'); | ||
| 1021 | + const date = new Date().toISOString().substring(0, 10); | ||
| 1022 | + | ||
| 1023 | + // 构建新条目 | ||
| 1024 | + const entries = []; | ||
| 1025 | + for (const p of reflection.patterns) { | ||
| 1026 | + entries.push(`- ✅ [${date}] ${p}`); | ||
| 1027 | + } | ||
| 1028 | + for (const l of reflection.lessons) { | ||
| 1029 | + entries.push(`- ⚠️ [${date}] ${l}`); | ||
| 1030 | + } | ||
| 1031 | + const newBlock = entries.join('\n'); | ||
| 1032 | + | ||
| 1033 | + // 查找或创建 "经验教训" 段 | ||
| 1034 | + const SECTION_HEADER = '## 经验教训 (自动沉淀)'; | ||
| 1035 | + if (content.includes(SECTION_HEADER)) { | ||
| 1036 | + // 追加到已有段落 | ||
| 1037 | + content = content.replace( | ||
| 1038 | + new RegExp(`(${SECTION_HEADER.replace(/[()]/g, '\\$&')}\\n)([\\s\\S]*?)(?=\\n## |$)`), | ||
| 1039 | + (match, header, existing) => { | ||
| 1040 | + const trimmed = existing.trim(); | ||
| 1041 | + // 限制最大条目数(保留最近 20 条) | ||
| 1042 | + const lines = (trimmed + '\n' + newBlock).split('\n').filter(l => l.startsWith('- ')); | ||
| 1043 | + const limited = lines.slice(-20).join('\n'); | ||
| 1044 | + return `${header}${limited}\n`; | ||
| 1045 | + } | ||
| 1046 | + ); | ||
| 1047 | + } else { | ||
| 1048 | + // 新增段落 | ||
| 1049 | + content += `\n\n${SECTION_HEADER}\n\n> 以下经验由系统自动从历史对话中提取沉淀,帮助你避免重复犯错。\n\n${newBlock}\n`; | ||
| 1050 | + } | ||
| 1051 | + | ||
| 1052 | + fs.writeFileSync(skillPath, content, 'utf-8'); | ||
| 1053 | + console.log(`[Memory] 📚 Know-how 已更新: ${agentId} SKILL.md (+${entries.length} 条经验)`); | ||
| 1054 | + } catch (err) { | ||
| 1055 | + console.error(`[Memory] ⚠️ Know-how 更新失败:`, err.message); | ||
| 1056 | + } | ||
| 1057 | +} | ||
| 1058 | + | ||
| 989 | module.exports = { | 1059 | module.exports = { |
| 990 | // Phase 1 | 1060 | // Phase 1 |
| 991 | saveSessionSummary, | 1061 | saveSessionSummary, |