|
|
@@ -9,7 +9,7 @@ const opsImport = require('../../ops-import.js');
|
|
|
const { createOpsStore } = require('../../ops-store.js');
|
|
|
const { loadSchools } = require('../../schools-store.js');
|
|
|
const { makeChatCfg } = require('../context.js');
|
|
|
-const { chatCompletion } = require('../agent/llm.js');
|
|
|
+const { chatCompletion, extractJson } = require('../agent/llm.js');
|
|
|
|
|
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
|
const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
|
|
|
@@ -759,6 +759,85 @@ function buildReport(data, meta = {}) {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+const REPORT_AI_PROMPT = [
|
|
|
+ '你是连锁餐饮公司的经营分析师。用户会给一段已经计算好的经营数据 JSON(含 kpis/trend/ranking/bottom/funnel/structure/zeroOrder)。',
|
|
|
+ '数值全部由后端计算,你不得修改、杜撰或重算;只做解释、诊断与建议。',
|
|
|
+ '只输出一个 JSON 对象,不要代码块、不要前后缀,结构如下:',
|
|
|
+ '{"overall_assessment":"一段客观判断",',
|
|
|
+ ' "key_findings":[{"severity":"positive|neutral|warning|critical","finding":"结论","evidence":["指标:数值"],"interpretation":"解释","confidence":"high|medium|low"}],',
|
|
|
+ ' "sections":[{"id":"overview|stores|funnel|finance","summary":"本节结论","paragraphs":["事实与解释"]}],',
|
|
|
+ ' "alerts":[{"severity":"P0|P1|P2","title":"告警标题","evidence":["数据证据"],"hypotheses":["待验证原因"],"next_checks":["核查步骤"]}],',
|
|
|
+ ' "action_plan":[{"priority":"P0|P1|P2","action":"具体行动","owner_role":"负责人角色","time_horizon":"时间窗","success_metric":"可衡量指标"}]}',
|
|
|
+ '要求:3~5 条 findings、2~4 条 alerts、1~4 条 action;金额保留两位小数,比率用百分比;',
|
|
|
+ 'zeroOrder 非空时必须按原因归类(未开业/闭店/无曝光/无进店/无转化各多少店次)并点明哪类需要运营介入;',
|
|
|
+ '没有上期数据时明确说明不可做趋势判断;不输出「赋能」「抓手」等套话。',
|
|
|
+].join('\n');
|
|
|
+
|
|
|
+/** 把模型返回的叙述字段覆盖到规则版报告上;结构不合法就返回 null(保留规则版) */
|
|
|
+function overlayAiReport(base, ai, meta = {}) {
|
|
|
+ if (!ai || typeof ai !== 'object') return null;
|
|
|
+ const report = JSON.parse(JSON.stringify(base));
|
|
|
+ let touched = false;
|
|
|
+
|
|
|
+ if (typeof ai.overall_assessment === 'string' && ai.overall_assessment.trim()) {
|
|
|
+ report.executive_summary.overall_assessment = ai.overall_assessment.trim();
|
|
|
+ touched = true;
|
|
|
+ }
|
|
|
+ if (Array.isArray(ai.key_findings) && ai.key_findings.length) {
|
|
|
+ const findings = ai.key_findings
|
|
|
+ .filter((f) => f && f.finding)
|
|
|
+ .slice(0, 6)
|
|
|
+ .map((f) => ({
|
|
|
+ severity: ['positive', 'neutral', 'warning', 'critical'].includes(f.severity) ? f.severity : 'neutral',
|
|
|
+ finding: String(f.finding).slice(0, 120),
|
|
|
+ evidence: Array.isArray(f.evidence) ? f.evidence.slice(0, 5).map(String) : [],
|
|
|
+ interpretation: f.interpretation ? String(f.interpretation).slice(0, 200) : '',
|
|
|
+ confidence: ['high', 'medium', 'low'].includes(f.confidence) ? f.confidence : 'medium',
|
|
|
+ }));
|
|
|
+ if (findings.length) {
|
|
|
+ report.executive_summary.key_findings = findings;
|
|
|
+ touched = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (Array.isArray(ai.sections)) {
|
|
|
+ const byId = new Map(report.sections.map((sec) => [sec.id, sec]));
|
|
|
+ for (const sec of ai.sections) {
|
|
|
+ const target = sec && byId.get(sec.id);
|
|
|
+ if (!target) continue;
|
|
|
+ if (sec.summary) target.summary = String(sec.summary).slice(0, 200);
|
|
|
+ if (Array.isArray(sec.paragraphs) && sec.paragraphs.length) {
|
|
|
+ target.paragraphs = sec.paragraphs.slice(0, 4).map((t) => String(t).slice(0, 400));
|
|
|
+ }
|
|
|
+ touched = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (Array.isArray(ai.alerts) && ai.alerts.length) {
|
|
|
+ report.alerts = ai.alerts.slice(0, 5).map((a) => ({
|
|
|
+ severity: ['P0', 'P1', 'P2'].includes(a.severity) ? a.severity : 'P2',
|
|
|
+ store_id: a.store_id || null,
|
|
|
+ title: String(a.title || '').slice(0, 80),
|
|
|
+ evidence: Array.isArray(a.evidence) ? a.evidence.slice(0, 6).map(String) : [],
|
|
|
+ hypotheses: Array.isArray(a.hypotheses) ? a.hypotheses.slice(0, 4).map(String) : [],
|
|
|
+ next_checks: Array.isArray(a.next_checks) ? a.next_checks.slice(0, 4).map(String) : [],
|
|
|
+ }));
|
|
|
+ touched = true;
|
|
|
+ }
|
|
|
+ if (Array.isArray(ai.action_plan) && ai.action_plan.length) {
|
|
|
+ report.action_plan = ai.action_plan.slice(0, 4).map((p) => ({
|
|
|
+ priority: ['P0', 'P1', 'P2'].includes(p.priority) ? p.priority : 'P2',
|
|
|
+ action: String(p.action || '').slice(0, 160),
|
|
|
+ owner_role: String(p.owner_role || '').slice(0, 40),
|
|
|
+ time_horizon: String(p.time_horizon || '').slice(0, 40),
|
|
|
+ success_metric: String(p.success_metric || '').slice(0, 80),
|
|
|
+ related_alerts: Array.isArray(p.related_alerts) ? p.related_alerts : [],
|
|
|
+ }));
|
|
|
+ touched = true;
|
|
|
+ }
|
|
|
+ if (!touched) return null;
|
|
|
+ report.ai = { source: 'llm', model: meta.model || '', generatedAt: new Date().toISOString() };
|
|
|
+ return report;
|
|
|
+}
|
|
|
+
|
|
|
const REASON_LABEL = { 'not-open': '未开业', closed: '当日闭店', 'no-traffic': '无曝光', 'no-visit': '无进店', 'no-conversion': '有进店无下单', unknown: '数据不足' };
|
|
|
|
|
|
function formatYmd(ymd) {
|
|
|
@@ -1028,15 +1107,69 @@ function register(app, ctx) {
|
|
|
}
|
|
|
});
|
|
|
|
|
|
- const insightCache = new Map();
|
|
|
- const INSIGHT_TTL_MS = 10 * 60 * 1000;
|
|
|
+ // ---------- 统一报告生成(经营解读与经营报告共用一份 JSON) ----------
|
|
|
+ const REPORT_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
|
+ const reportCache = new Map();
|
|
|
|
|
|
- app.post('/api/ops/insights', async ({ req, res }) => {
|
|
|
- if (!(await requireHealthy(store, res))) return;
|
|
|
+ async function getReportPayload(params, { ai = false, force = false } = {}) {
|
|
|
+ const key = JSON.stringify(params);
|
|
|
+ const hit = reportCache.get(key);
|
|
|
+ const fresh = hit && Date.now() - hit.at < REPORT_CACHE_TTL_MS;
|
|
|
+ if (!force && fresh && (!ai || hit.source === 'llm')) {
|
|
|
+ return { ...hit.payload, cached: true };
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await buildDashboard(store, params);
|
|
|
+ if (!data.kpis) return { empty: true };
|
|
|
+
|
|
|
+ const uploads = await store.listUploads().catch(() => []);
|
|
|
+ const base = buildReport(data, {
|
|
|
+ organization: (params.schools && params.schools[0]) || '',
|
|
|
+ sourceFiles: uploads.slice(0, 3).map((u) => u.originalName),
|
|
|
+ });
|
|
|
+ base.ai = { source: 'rules', model: '', generatedAt: new Date().toISOString() };
|
|
|
+
|
|
|
+ let payload = base;
|
|
|
+ let source = 'rules';
|
|
|
+ let aiError = '';
|
|
|
const chatCfg = makeChatCfg(ctx);
|
|
|
- if (!chatCfg.chatApiKey || chatCfg.chatApiKey === 'your-api-key-here') {
|
|
|
- return sendJson(res, 200, { enabled: false, reason: 'chat-not-configured' });
|
|
|
+ const configured = chatCfg.chatApiKey && chatCfg.chatApiKey !== 'your-api-key-here';
|
|
|
+
|
|
|
+ if (ai && configured) {
|
|
|
+ try {
|
|
|
+ const text = await chatCompletion({
|
|
|
+ apiBase: chatCfg.chatApiBase,
|
|
|
+ apiKey: chatCfg.chatApiKey,
|
|
|
+ model: chatCfg.chatModel,
|
|
|
+ temperature: 0.3,
|
|
|
+ timeoutMs: 60000,
|
|
|
+ messages: [
|
|
|
+ { role: 'system', content: REPORT_AI_PROMPT },
|
|
|
+ { role: 'user', content: JSON.stringify(insightContext(data)) },
|
|
|
+ ],
|
|
|
+ });
|
|
|
+ const merged = overlayAiReport(base, extractJson(text), { model: chatCfg.chatModel });
|
|
|
+ if (merged) {
|
|
|
+ payload = merged;
|
|
|
+ source = 'llm';
|
|
|
+ } else {
|
|
|
+ aiError = '模型返回结构不合法,已退回规则版';
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ aiError = e.message || String(e);
|
|
|
+ }
|
|
|
+ } else if (ai && !configured) {
|
|
|
+ aiError = '未配置对话模型,当前为规则版';
|
|
|
}
|
|
|
+ if (aiError) payload.aiError = aiError;
|
|
|
+
|
|
|
+ reportCache.set(key, { at: Date.now(), source, payload });
|
|
|
+ return { ...payload, cached: false };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 经营解读 = 报告 JSON 的摘要视图
|
|
|
+ app.post('/api/ops/insights', async ({ req, res }) => {
|
|
|
+ if (!(await requireHealthy(store, res))) return;
|
|
|
const body = await readBody(req);
|
|
|
const params = {
|
|
|
from: parseYmd(body.from) || undefined,
|
|
|
@@ -1044,64 +1177,41 @@ function register(app, ctx) {
|
|
|
schools: splitList(body.schools),
|
|
|
shops: splitList(body.shops),
|
|
|
};
|
|
|
- const cacheKey = JSON.stringify(params);
|
|
|
- const cached = insightCache.get(cacheKey);
|
|
|
- if (!body.refresh && cached && Date.now() - cached.at < INSIGHT_TTL_MS) {
|
|
|
- return sendJson(res, 200, { enabled: true, ...cached.value, cached: true });
|
|
|
- }
|
|
|
-
|
|
|
- let data;
|
|
|
- try {
|
|
|
- data = await buildDashboard(store, params);
|
|
|
- } catch (e) {
|
|
|
- return sendJson(res, 503, { error: '聚合失败', detail: e.message || String(e) });
|
|
|
- }
|
|
|
- if (!data.kpis) {
|
|
|
- return sendJson(res, 200, { enabled: true, points: [], note: '所选范围没有数据' });
|
|
|
- }
|
|
|
-
|
|
|
- const context = insightContext(data);
|
|
|
- let text;
|
|
|
try {
|
|
|
- text = await chatCompletion({
|
|
|
- apiBase: chatCfg.chatApiBase,
|
|
|
- apiKey: chatCfg.chatApiKey,
|
|
|
- model: chatCfg.chatModel,
|
|
|
- temperature: 0.3,
|
|
|
- timeoutMs: 45000,
|
|
|
- messages: [
|
|
|
- { role: 'system', content: INSIGHT_SYSTEM_PROMPT },
|
|
|
- { role: 'user', content: JSON.stringify(context) },
|
|
|
- ],
|
|
|
+ const report = await getReportPayload(params, { ai: true, force: !!body.refresh });
|
|
|
+ if (report.empty || !report.executive_summary) {
|
|
|
+ return sendJson(res, 200, { enabled: true, points: [], note: '所选范围没有数据' });
|
|
|
+ }
|
|
|
+ const findings = report.executive_summary.key_findings || [];
|
|
|
+ const points = findings.slice(0, 5).map((f) => f.finding);
|
|
|
+ const alerts = (report.alerts || []).slice(0, 3).map((a) => ({ severity: a.severity, title: a.title }));
|
|
|
+ sendJson(res, 200, {
|
|
|
+ enabled: true,
|
|
|
+ points,
|
|
|
+ alerts,
|
|
|
+ source: (report.ai && report.ai.source) || 'rules',
|
|
|
+ model: (report.ai && report.ai.model) || '',
|
|
|
+ generatedAt: (report.ai && report.ai.generatedAt) || new Date().toISOString(),
|
|
|
+ aiError: report.aiError || '',
|
|
|
+ range: { from: report.meta.period_start, to: report.meta.period_end },
|
|
|
+ cached: !!report.cached,
|
|
|
});
|
|
|
} catch (e) {
|
|
|
- return sendJson(res, 502, { enabled: true, error: '模型调用失败', detail: e.message || String(e) });
|
|
|
+ sendJson(res, 502, { enabled: true, error: '生成失败', detail: e.message || String(e) });
|
|
|
}
|
|
|
-
|
|
|
- const value = {
|
|
|
- points: parseInsightPoints(text),
|
|
|
- model: chatCfg.chatModel,
|
|
|
- generatedAt: new Date().toISOString(),
|
|
|
- digest: context,
|
|
|
- };
|
|
|
- insightCache.set(cacheKey, { at: Date.now(), value });
|
|
|
- sendJson(res, 200, { enabled: true, ...value });
|
|
|
});
|
|
|
|
|
|
app.get('/api/ops/report', async ({ res, query }) => {
|
|
|
if (!(await requireHealthy(store, res))) return;
|
|
|
- const from = parseYmd(query.from);
|
|
|
- const to = parseYmd(query.to);
|
|
|
- const schools = splitList(query.schools);
|
|
|
- const shops = splitList(query.shops);
|
|
|
+ const params = {
|
|
|
+ from: parseYmd(query.from) || undefined,
|
|
|
+ to: parseYmd(query.to) || undefined,
|
|
|
+ schools: splitList(query.schools),
|
|
|
+ shops: splitList(query.shops),
|
|
|
+ };
|
|
|
try {
|
|
|
- const data = await buildDashboard(store, { from: from || undefined, to: to || undefined, schools, shops });
|
|
|
- if (!data.kpis) return sendJson(res, 200, { empty: true });
|
|
|
- const uploads = await store.listUploads().catch(() => []);
|
|
|
- const report = buildReport(data, {
|
|
|
- organization: schools.length === 1 ? schools[0] : (schools[0] || ''),
|
|
|
- sourceFiles: uploads.slice(0, 3).map((u) => u.originalName),
|
|
|
- });
|
|
|
+ const report = await getReportPayload(params, { ai: query.ai === '1', force: query.refresh === '1' });
|
|
|
+ if (report.empty) return sendJson(res, 200, { empty: true });
|
|
|
sendJson(res, 200, { report });
|
|
|
} catch (e) {
|
|
|
sendJson(res, 503, { error: '报告生成失败', detail: e.message || String(e) });
|