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 | 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 | 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 | 721 | // 更新 memory.md 的"已验证模式"和"失败教训"段 |
| 722 | 722 | updateMemoryMdSections(agentId, reflection); |
| 723 | 723 | |
| 724 | + // ── Know-how 自动更新: 将经验教训写回 SKILL.md ── | |
| 725 | + updateAgentKnowhow(agentId, reflection); | |
| 726 | + | |
| 724 | 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 | 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 | 1059 | module.exports = { |
| 990 | 1060 | // Phase 1 |
| 991 | 1061 | saveSessionSummary, | ... | ... |