Commit b24b562c586cf59bb922b6272450e49f95d46b28

Authored by yibo
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 → 下次读取改进行为
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,
... ...