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