后端: - 事件流模型(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>
168 lines
6.2 KiB
TypeScript
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(() => {});
|
|
}
|