devperf/backend/src/services/roi/ai-driver-writer.ts
zyc 5af612e3fd feat(roi): ROI 动态规则引擎 v1 + 业务体系归属
后端:
- 事件流模型(project_cost_events / project_revenue_events)+ launchedAt 截断
- 3 大业务体系归属(airhubs/airflow/aircore) + 项目类型(hw/sw) + identifier 自动生成
- AI 三件套推荐(category + bizSystem + projectType)
- 营收 mock API + 外部对接规范 + 资产摊销 cron
- 5 个 migration(0003 ROI 引擎 / 0004 driver factors / 0005 biz system)
- 单测 11/11 过

前端:
- 项目级 ROI 看板:4 KPI 卡片 + 折线图(周/月/年)+ 成本/产出事件流并排
- 全公司决策罗盘:3 大 ROI 指标 + 业务线堆叠 + 分类筛选 chip
- 项目列表 + 侧边栏:按产品线分组(可折叠 + localStorage 持久化)
- Admin: ROI 策略配置 + 项目映射 + 未映射收容

数据:
- 23 项目全部 AI 自动分类 + 自动 identifier(airhubs-hw-001 这种)
- launchedAt 按各项目首次 commit 时间设置

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:20:22 +08:00

168 lines
6.2 KiB
TypeScript

import { v4 as uuid } from 'uuid';
import { and, desc, eq, gte } from 'drizzle-orm';
import dayjs from 'dayjs';
import { db } from '../../db/index';
import {
projects, projectRepos, gitCommits, objectives,
roiDriverFactors, syncLogs,
} from '../../db/schema';
import { callLLM, parseLLMJson } from '../llm-client';
import { aggregate } from './aggregator';
export interface DriverFactor {
type: '现金流驱动' | '降本增效驱动' | '技术资产驱动';
text: string;
}
const SYSTEM_PROMPT = `你是软件项目价值分析师。给定项目本月 ROI 数据和近期工作内容,生成 1-3 条"价值驱动因子"文案,告诉管理者这个项目的价值来源是什么。
三种驱动因子类型:
- 现金流驱动: 项目直接产生营收,推动现金流入
- 降本增效驱动: 项目通过工具化/自动化节省了内部成本
- 技术资产驱动: 项目沉淀了技术能力/数据/算法,形成长期资产
请基于真实数据判断该项目最显著的 1-3 个驱动因子。回复严格 JSON 格式:
{
"factors": [
{ "type": "现金流驱动" | "降本增效驱动" | "技术资产驱动", "text": "60字内的简短说明" }
]
}
不要添加其他字段或文本。每条 text 必须 ≤60 字,客观陈述,不要使用营销语言。`;
const ALLOWED_TYPES = new Set(['现金流驱动', '降本增效驱动', '技术资产驱动']);
/**
* 为单个项目生成"上月"的驱动因子,写入 roi_driver_factors 表。
* periodKey = 上月 YYYY-MM
*/
export async function generateDriverFactorsForProject(projectId: string, asOf: Date = new Date()): Promise<DriverFactor[]> {
const lastMonth = dayjs(asOf).subtract(1, 'month');
const periodKey = lastMonth.format('YYYY-MM');
// 上月 ROI
const monthStart = lastMonth.startOf('month').toDate();
const monthEnd = lastMonth.endOf('month').toDate();
const monthAgg = await aggregate(projectId, monthStart, monthEnd);
// 累计 ROI(从 launchedAt 起)
const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1);
if (!project) throw new Error(`Project not found: ${projectId}`);
const launchedAt = project.launchedAt ? new Date(project.launchedAt) : monthStart;
const lifetimeAgg = await aggregate(projectId, launchedAt, monthEnd);
// 近期 commits 摘要
const repos = await db.select().from(projectRepos).where(eq(projectRepos.projectId, projectId));
const repoNames = new Set(repos.map(r => r.repoName));
let commitSummary = '(无近期提交)';
if (repoNames.size > 0) {
const recent = await db.select().from(gitCommits)
.where(gte(gitCommits.committedAt, monthStart))
.orderBy(desc(gitCommits.committedAt))
.limit(30);
const projCommits = recent.filter(c => repoNames.has(c.repoName));
if (projCommits.length > 0) {
commitSummary = projCommits
.map(c => `- ${(c.message || '').split('\n')[0].slice(0, 80)}`)
.slice(0, 15)
.join('\n');
}
}
// OKR 进展
const objs = await db.select().from(objectives).where(eq(objectives.projectId, projectId)).limit(5);
const okrSummary = objs.length > 0
? objs.map(o => `- ${o.title} (进度 ${Math.round((o.progress || 0) * 100)}%)`).join('\n')
: '(无 OKR)';
const userPrompt = `项目: ${project.name} (定位: ${project.category || '未打标'})
上月 ROI 数据 (${periodKey}):
- 总成本: ¥${monthAgg.totalCost.toLocaleString()}
- 总产出: ¥${monthAgg.totalRevenue.toLocaleString()}
- 月 ROI: ${monthAgg.roiValue === null ? 'N/A' : monthAgg.roiValue + '%'}
- 直接营收占比: ¥${monthAgg.revenueBreakdown.directRevenue}
- 节约成本占比: ¥${monthAgg.revenueBreakdown.savedCost}
- 资产增值占比: ¥${monthAgg.revenueBreakdown.assetValueAdd}
累计 ROI (立项至今): ${lifetimeAgg.roiValue === null ? 'N/A' : lifetimeAgg.roiValue + '%'}
上月关键 commits:
${commitSummary}
OKR 进展:
${okrSummary}
请输出 JSON 驱动因子。`;
const raw = await callLLM(SYSTEM_PROMPT, userPrompt);
const parsed = parseLLMJson<{ factors: DriverFactor[] }>(raw);
// 校验
if (!Array.isArray(parsed.factors)) throw new Error('LLM response missing factors array');
const validFactors = parsed.factors
.filter(f => f && ALLOWED_TYPES.has(f.type) && typeof f.text === 'string')
.map(f => ({ type: f.type, text: f.text.slice(0, 80) }))
.slice(0, 3);
if (validFactors.length === 0) throw new Error('LLM returned no valid factors');
// upsert: 先 delete 旧的(同 project + period),再 insert 新的
await db.delete(roiDriverFactors).where(and(
eq(roiDriverFactors.projectId, projectId),
eq(roiDriverFactors.periodKey, periodKey),
));
await db.insert(roiDriverFactors).values({
id: uuid(),
projectId,
periodKey,
factors: validFactors,
context: {
monthRoi: monthAgg.roiValue,
lifetimeRoi: lifetimeAgg.roiValue,
monthCost: monthAgg.totalCost,
monthRevenue: monthAgg.totalRevenue,
},
generatedAt: new Date(),
});
return validFactors;
}
/**
* 月度 cron:为所有打标项目生成驱动因子。失败的项目记录但不中断。
*/
export async function runMonthlyDriverFactorsGeneration(): Promise<void> {
const startedAt = Date.now();
let okCount = 0, failCount = 0;
const errors: string[] = [];
// 仅为已打标的项目生成
const allProjects = await db.select().from(projects);
const candidates = allProjects.filter(p => p.category !== null);
for (const p of candidates) {
try {
await generateDriverFactorsForProject(p.id);
okCount += 1;
} catch (e) {
failCount += 1;
const msg = `${p.identifier || p.id}: ${(e as Error).message}`;
errors.push(msg);
console.warn('[ROI-AI-DRIVER]', msg);
}
// 简单速率控制:每项目间隔 1 秒,避免 LLM 限流
await new Promise(r => setTimeout(r, 1000));
}
const elapsed = Date.now() - startedAt;
await db.insert(syncLogs).values({
id: uuid(),
source: 'roi_ai_driver',
status: failCount === 0 ? 'success' : 'error',
message: `driver factors: ok=${okCount} fail=${failCount} elapsed=${elapsed}ms${errors.length > 0 ? ' errors=' + errors.slice(0, 3).join('|') : ''}`,
recordsProcessed: okCount,
syncedAt: new Date(),
}).catch(() => {});
}