seed.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. #!/usr/bin/env node
  2. /**
  3. * scripts/seed.mjs —— 机会卡示例数据(幂等)。
  4. *
  5. * 客户是 30–40 人共用的组织资产系统,示例数据必须**一眼能区分**:
  6. * - 新造的卡带 seedKey + 「示例」徽标(卡片右上角)
  7. * - 不覆盖任何人改过的 status / decision
  8. * - 可重复执行:按 seedKey 找已有行,只补缺失字段 / 缺失的示例评分
  9. *
  10. * 用法:
  11. * PARSE_MASTER_KEY=... node scripts/seed.mjs # 写入
  12. * PARSE_MASTER_KEY=... node scripts/seed.mjs --dry-run # 只打印
  13. * PARSE_MASTER_KEY=... node scripts/seed.mjs --check # 只读校验:每张示例卡是否有「谁/Agent/方式/时间」四要素
  14. *
  15. * 凭据只从环境变量读;前端代码里没有任何密钥。
  16. */
  17. const BASE = process.env.PARSE_BASE || 'https://kangshifu.fmode.cn/parse';
  18. const APP_ID = process.env.PARSE_APP_ID || 'FKieRbIoGt_main';
  19. const MK = process.env.PARSE_MASTER_KEY || '';
  20. const DRY = process.argv.includes('--dry-run');
  21. const CHECK = process.argv.includes('--check');
  22. const H = { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' };
  23. if (MK) H['X-Parse-Master-Key'] = MK;
  24. async function call(method, path, body) {
  25. const r = await fetch(BASE + path, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
  26. const j = await r.json().catch(() => ({}));
  27. if (!r.ok) return { _error: r.status, _body: j };
  28. return j;
  29. }
  30. const all = async (cls) => (await call('GET', `/classes/${cls}?limit=1000&order=createdAt`)).results || [];
  31. const D = (iso) => ({ __type: 'Date', iso });
  32. const hoursAgo = (h) => new Date(Date.now() - h * 3600000).toISOString();
  33. const AGENTS = {
  34. main: ['康师傅产品创新助手', 'agent-main-01'],
  35. research: ['品类调研 Agent', 'agent-research-02'],
  36. check: ['机会评估 Agent', 'agent-check-03'],
  37. design: ['概念设计 Agent', 'agent-design-04'],
  38. };
  39. /** 示例卡:seedKey 唯一;scores 里带 same person 多条时以 anchor=seed 标记(只补不删) */
  40. /** 旧版 v4 的种子卡(seed-opp-*):标题与新版一一对应。存在时**沿用其 objectId**,不重复造卡 */
  41. const LEGACY_BY_TITLE = {
  42. '夜宵场景低卡高蛋白拌面': 'seed-opp-1',
  43. '办公室 3 分钟速食拌面': 'seed-opp-2',
  44. '地域风味麻酱拌面': 'seed-opp-3',
  45. '露营便携免煮拌面': 'seed-opp-4',
  46. '健身人群高蛋白面': 'seed-opp-5',
  47. '银发低钠汤面': 'seed-opp-6',
  48. };
  49. const CARDS = [
  50. {
  51. seedKey: 'v5-demo-1', title: '夜宵场景低卡高蛋白拌面', category: '拌面', stage: 'S3 机会识别', priority: 'S', status: 'candidate',
  52. oneLiner: '加班到十点想吃饱又怕胖:低卡高蛋白 + 1 分钟即食,5 个夜宵场景里 3 个需求未被满足',
  53. evidenceLevel: 'high', quoteCount: 12, citeCount: 8,
  54. upload: { uploadedBy: '张伟', uploadedByKey: 'sess-01', agent: 'main', uploadMethod: 'session', hours: 6 },
  55. scores: [['李娜', 86, '场景真实,VOC 密度高'], ['吴敏', 90, '和现有夜宵 SKU 不冲突'], ['评价 Agent', 88, '人群规模 × 需求强度 × 可行性 综合 88']],
  56. decision: { type: 'concept', reason: 'S3 阶段 5 位成员独立提到「饱腹 + 低卡」矛盾;已有 12 条带原文的 VOC 支撑。', by: '张伟', hours: 4 },
  57. },
  58. {
  59. seedKey: 'v5-demo-2', title: '办公室 3 分钟速食拌面', category: '拌面', stage: 'S4 验证评估', priority: 'A', status: 'approved',
  60. oneLiner: '午休只有 10 分钟、不想吃汤汤水水:免煮、不冒汤、可带去办公室的拌面',
  61. evidenceLevel: 'high', quoteCount: 9, citeCount: 5,
  62. upload: { uploadedBy: '李娜', uploadedByKey: 'sess-02', agent: 'main', uploadMethod: 'session', hours: 30 },
  63. scores: [['刘洋', 84, '验证数据齐'], ['王芳', 80, '价格带要卡住 8 元'], ['评价 Agent', 82, '需求 85 / 竞争 78 / 可行性 83']],
  64. decision: { type: 'approve', reason: 'S4 验证完成:三家竞品均无免汤方案;评分模型 82 分,过线。', by: '刘洋', hours: 20 },
  65. },
  66. {
  67. seedKey: 'v5-demo-3', title: '地域风味麻酱拌面', category: '拌面', stage: 'S4 验证评估', priority: 'A', status: 'candidate',
  68. oneLiner: '现有拌面酱包集中在麻酱 / 香辣两类;西北油泼、西南麻辣是搜索热但 SKU 空档',
  69. evidenceLevel: 'medium', quoteCount: 6, citeCount: 3,
  70. upload: { uploadedBy: '刘洋', uploadedByKey: 'sess-04', agent: 'research', uploadMethod: 'skill', hours: 52 },
  71. scores: [['赵磊', 72, '方向对,证据不够'], ['陈静', 78, '可以和夜宵低卡合并做双口味'], ['评价 Agent', 75, '证据强度拖后腿']],
  72. decision: { type: 'hold', reason: '方向认可,但 6 条记录里只有 3 条带原文,销量数据待回传。', by: '赵磊', hours: 40 },
  73. },
  74. {
  75. seedKey: 'v5-demo-4', title: '露营便携免煮拌面', category: '杯面', stage: 'S5 场景与卖点设计', priority: 'S', status: 'candidate',
  76. oneLiner: '露营带的都是泡面:冷水泡 8 分钟、自带拌酱的免煮方案',
  77. evidenceLevel: 'medium', quoteCount: 7, citeCount: 4,
  78. upload: { uploadedBy: '陈静', uploadedByKey: 'sess-05', agent: 'design', uploadMethod: 'skill', hours: 2 },
  79. scores: [['孙悦', 79, '量可能不大'], ['郑凯', 81, '卖点清晰,可以做包装测试']],
  80. decision: { type: 'concept', reason: '露营搜索量环比 +41%;已有卖点抓手「不烧水也能拌」。', by: '陈静', hours: 1.5 },
  81. },
  82. {
  83. seedKey: 'v5-demo-5', title: '健身人群高蛋白面', category: '汤面', stage: 'S2 用户需求与痛点', priority: 'B', status: 'rejected',
  84. oneLiner: '健身人群要每份 20g 以上蛋白、低脂、能算进宏量',
  85. evidenceLevel: 'low', quoteCount: 2, citeCount: 1,
  86. upload: { uploadedBy: '孙悦', uploadedByKey: 'sess-07', agent: 'main', uploadMethod: 'session', hours: 76 },
  87. scores: [['吴敏', 58, '成本算不过来'], ['周涛', 62, '需求真实但规模小'], ['评价 Agent', 55, '人群规模 ★★']],
  88. decision: { type: 'reject', reason: '蛋白成本会把单价推到 15 元以上,和现有价格带冲突。留档,不做。', by: '吴敏', hours: 60 },
  89. },
  90. {
  91. seedKey: 'v5-demo-6', title: '银发低钠汤面', category: '汤面', stage: 'S1 品类市场分析', priority: 'B', status: 'candidate',
  92. oneLiner: '50 岁以上控盐诉求明确,现有方便面钠含量普遍 2000mg 以上,无低钠 SKU',
  93. evidenceLevel: 'medium', quoteCount: 4, citeCount: 2,
  94. upload: { uploadedBy: '赵磊', uploadedByKey: 'sess-06', agent: 'main', uploadMethod: 'manual', hours: 50 },
  95. scores: [['赵磊', 70, '合规是卖点,工艺要改']],
  96. decision: null,
  97. },
  98. ];
  99. function bodyOf(card, artifacts) {
  100. const [agentName, agentId] = AGENTS[card.upload.agent];
  101. const dec = card.decision
  102. ? { type: card.decision.type, label: { concept: '进入概念验证', approve: '立项', reject: '否决', hold: '暂缓' }[card.decision.type],
  103. reason: card.decision.reason, by: card.decision.by, via: 'seed', at: hoursAgo(card.decision.hours) }
  104. : null;
  105. return {
  106. title: card.title, oneLiner: card.oneLiner, category: card.category, stage: card.stage, priority: card.priority,
  107. status: card.status, evidenceLevel: card.evidenceLevel, quoteCount: card.quoteCount, citeCount: card.citeCount,
  108. uploadedBy: card.upload.uploadedBy, uploadedByKey: card.upload.uploadedByKey, agentName, agentId,
  109. uploadMethod: card.upload.uploadMethod, uploadedAt: D(hoursAgo(card.upload.hours)), sourceSession: card.upload.uploadedByKey,
  110. seedKey: card.seedKey, searchText: `${card.title} ${card.oneLiner} ${card.category} ${card.stage} ${card.priority} ${card.upload.uploadedBy} ${agentName}`,
  111. linkedProject: '', linkedArtifacts: artifacts.slice(0, 3),
  112. decision: dec, decisionLog: dec ? [dec] : [],
  113. };
  114. }
  115. const scoreRows = (card) => card.scores
  116. .filter(([, , , anchor]) => anchor !== true)
  117. .map(([user, score, comment], i) => ({ user, score, comment, seed: card.seedKey, at: hoursAgo(card.upload.hours - 1 - i) }));
  118. async function main() {
  119. if (!MK && !CHECK) throw new Error('PARSE_MASTER_KEY 未设置');
  120. const arts = await all('KfArtifact');
  121. const artifactIds = arts.map((a) => a.objectId);
  122. const opps = await all('KfOpportunity');
  123. const bySeed = new Map(opps.filter((o) => o.seedKey).map((o) => [o.seedKey, o]));
  124. // 旧版种子卡按标题认领(客户看到的是同一张卡,不该在池子里出现两张同名卡)
  125. for (const card of CARDS) {
  126. if (bySeed.has(card.seedKey)) continue;
  127. const legacyKey = LEGACY_BY_TITLE[card.title];
  128. const old = opps.find((o) => o.seedKey === legacyKey);
  129. if (old) bySeed.set(card.seedKey, { ...old, __legacy: legacyKey });
  130. }
  131. let created = 0; let patched = 0; let kept = 0;
  132. if (CHECK) {
  133. let bad = 0;
  134. for (const card of CARDS) {
  135. const o = bySeed.get(card.seedKey);
  136. const missing = !o ? ['整张卡'] : ['uploadedBy', 'uploadedByKey', 'agentName', 'agentId', 'uploadMethod', 'uploadedAt'].filter((k) => !o[k]);
  137. console.log(`${missing.length ? '✗' : '✓'} ${card.seedKey} ${card.title}${missing.length ? ` 缺 ${missing.join(',')}` : ''}`);
  138. if (missing.length) bad++;
  139. }
  140. console.log(bad ? `SEED CHECK FAILED(${bad})` : `SEED CHECK OK(${CARDS.length} 张示例卡四要素齐全)`);
  141. process.exit(bad ? 1 : 0);
  142. }
  143. for (const card of CARDS) {
  144. const body = bodyOf(card, artifactIds);
  145. const cur = bySeed.get(card.seedKey);
  146. if (!cur) {
  147. body.scores = scoreRows(card);
  148. if (DRY) { console.log('[dry] create', card.title); continue; }
  149. const r = await call('POST', '/classes/KfOpportunity', body);
  150. console.log(r.objectId ? `create ${card.title} → ${r.objectId}` : `FAIL ${card.title} ${JSON.stringify(r)}`);
  151. created++;
  152. continue;
  153. }
  154. // 已存在:只补空字段;绝不覆盖 status / decision / priority / 已有评分
  155. const patch = {};
  156. for (const k of ['uploadedBy', 'uploadedByKey', 'agentName', 'agentId', 'uploadMethod', 'uploadedAt', 'oneLiner', 'evidenceLevel', 'quoteCount', 'citeCount']) {
  157. if (!cur[k] && body[k] != null && body[k] !== '') patch[k] = body[k];
  158. }
  159. if (!cur.decision && body.decision) { patch.decision = body.decision; patch.decisionLog = body.decisionLog; }
  160. const have = new Set((cur.scores || []).map((s) => `${s.user}|${s.seed || ''}`));
  161. const missing = scoreRows(card).filter((s) => !have.has(`${s.user}|${s.seed}`));
  162. if (missing.length) patch.scores = [...(cur.scores || []), ...missing];
  163. if (Object.keys(patch).length) {
  164. if (DRY) { console.log('[dry] patch', card.title, cur.__legacy ? `(沿用旧卡 ${cur.__legacy})` : '', Object.keys(patch)); continue; }
  165. const r = await call('PUT', `/classes/KfOpportunity/${cur.objectId}`, patch);
  166. console.log(r.updatedAt ? `patch ${card.title} (${Object.keys(patch).join(',')})` : `FAIL ${card.title} ${JSON.stringify(r)}`);
  167. patched++;
  168. } else {
  169. kept++;
  170. }
  171. }
  172. const after = await all('KfOpportunity');
  173. console.log(`\nseed 完成:新建 ${created} · 补字段 ${patched} · 原样保留 ${kept} · 库里共 ${after.length} 张(其中示例 ${after.filter((o) => o.seedKey).length} 张)`);
  174. if (DRY) console.log('(dry-run:没有写入)');
  175. }
  176. main().catch((e) => { console.error(e?.stack || e); process.exit(1); });