#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const DEFAULT_TRANSCRIPTS_DIR = path.resolve(ROOT, '..', 'docs', '提号经验'); const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'experience-transcript-index-latest'); const RULES = [ { id: 'reference-type-tone', title: '参考账号拆类型和调性', terms: ['参考账号', '类型', '调性'], practicalRule: '参考账号只能拆成账号类型、内容调性、场景和表达方式等锚点;多参考账号冲突时不能强行合并成单一标准。', usableFor: '校准 referenceUsability、referenceStyleFingerprints 和强推荐解释。' }, { id: 'xhs-pgy-similar-chain', title: '小红书/蒲公英相似账号链路', terms: ['小红书', '蒲公英', '相似', '推荐'], practicalRule: '小红书和蒲公英优先用参考账号相似推荐链路,再按类型和调性二次筛选。', usableFor: '校准小红书召回路径和平台策略。' }, { id: 'douyin-xingtu-fallback', title: '抖音/星图只作入口或兜底', terms: ['抖音', '星图', '招募', '关键词'], practicalRule: '抖音相似推荐和星图标签不能直接当适配证明;找不到时用招募、APP 关键词或人工补找兜底。', usableFor: '校准抖音召回路径和低置信复核边界。' }, { id: 'homepage-recent-posts', title: '主页近 10/20 篇优先判断', terms: ['主页', '前十', '10', '20', '封面', '首图'], practicalRule: '人工通常先扫主页近 10 篇左右封面/首图,必要时扩到 20 篇或点进图文/视频深看。', usableFor: '校准 homepage evidence readiness、recentContentFitScore、visualQualityScore 和 toneConsistencyScore。' }, { id: 'quant-metric-first-filter', title: '先用量化指标做第一轮过滤', terms: ['CPE', 'CP1', 'CPM', '粉丝', '预算', '城市', '女粉'], practicalRule: '先用关键词、预算、CPE/CP1/CPM、粉丝量、男女粉占比和城市等硬指标缩小候选,再做主页风格复核。', usableFor: '校准 parseBrief.performanceRequirements、requirementLayers.hardConstraints 和 live 小规模召回参数。' }, { id: 'visual-tone-quality', title: '视觉质感和调性是核心难点', terms: ['质感', '画面清晰', '像素', '排版', '下沉', '风格'], practicalRule: '提号难点不只是数据维度,而是账号风格、画面质感、内容垂类和品牌调性是否匹配。', usableFor: '校准强推荐门槛、风险提示和人工复核标签。' }, { id: 'price-visual-threshold', title: '报价 300 元以上提高视觉质感要求', terms: ['300', '三百', '画面清晰', '背景', '封面统一', '精致'], practicalRule: '低价账号可相对放宽,但报价 300 元以上应优先选择封面清晰、背景统一、画面精致、风格稳定的账号。', usableFor: '校准 visualQualityScore、homepageQualityRisks、manualReviewFields 和强推荐门槛。' }, { id: 'recent-data-authenticity', title: '近期数据和评论真实性复核', terms: ['近一个月', '近30天', '平均点赞', '百赞', '重复评论', '同一个人评论', '数据真实性', '停更'], practicalRule: '复核近一个月是否更新、近作平均点赞是否过低,以及多篇笔记是否出现同一批评论者;疑似刷评或停更不要强推。', usableFor: '校准 homepageEvidence.dataRiskSignals、dataQualityScore、riskNote 和人工复核标签。' }, { id: 'implicit-product-audience', title: '产品隐性人群规则', terms: ['脱毛仪', '美容仪', '女性', '男性账号', '受众'], practicalRule: '美容仪、脱毛仪等产品即使 Brief 未写明,也要默认复核女性使用场景和女性博主语境。', usableFor: '校准品类隐性规则和硬性降级。' }, { id: 'multi-input-brief-product-chat', title: 'Brief + 产品 + 聊天记录多输入解析', terms: ['Brief', '产品', '聊天记录', '客户聊天', '参考账号', '客户说'], practicalRule: '最佳输入不只是 Brief,还应合并产品介绍、参考账号和客户聊天记录;聊天里常有隐藏偏好、禁区和子类方向。', usableFor: '校准 parseBrief.contextSources、productIntro/chatRecords schema 和下一轮校准问题。' }, { id: 'category-subtype-refinement', title: '宽类目必须拆到可执行子类型', terms: ['时尚穿搭', '港风', '成熟风', '美妆', '护肤', '妆教', '测评', '产品合集'], practicalRule: '精致生活、时尚穿搭、美妆护肤等宽类目不能只按平台标签筛,必须结合产品和参考账号拆到港风/成熟风、护肤/彩妆/妆教/测评等子方向。', usableFor: '校准 inferCategory、关键词扩展、categoryRules 和召回关键词。' }, { id: 'video-deep-check', title: '封面不确定再深看视频内容', terms: ['视频', '内容', '点进去', '每一帧', '解析'], practicalRule: '封面或主页判断不确定时,再进入图文正文、视频内容、ASR 或帧图分析;没有真实资源时只能标待补证。', usableFor: '校准视频资源收集包和视频 A/B 前置门禁。' }, { id: 'customer-effect-goal', title: '最终目标是客户选中率和效率', terms: ['客户', '选中率', '降本增效', '补号', '效果'], practicalRule: '提号优化最终要用客户选中、拒绝归因和人工补号量变化证明;经验判断本身不能替代客户效果审计。', usableFor: '校准 proof-gap、customer-effect:audit 和禁止误宣称边界。' } ]; function main() { const args = parseArgs(process.argv.slice(2)); const transcriptsDir = path.resolve(args.transcriptsDir || DEFAULT_TRANSCRIPTS_DIR); const outputDir = path.resolve(args.output || DEFAULT_OUTPUT); const summary = buildExperienceTranscriptIndex({ root: ROOT, transcriptsDir }); fs.mkdirSync(outputDir, { recursive: true }); const summaryPath = path.join(outputDir, 'experience-transcript-index-summary.json'); const reportPath = path.join(outputDir, 'experience-transcript-index.md'); const csvPath = path.join(outputDir, 'experience-transcript-index.csv'); fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8'); fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8'); fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8'); console.log(JSON.stringify({ outputDir, summary: summaryPath, report: reportPath, csv: csvPath, transcriptCount: summary.transcriptCount, coveredRuleCount: summary.coveredRuleCount, canCloseProofGap: summary.canCloseProofGap }, null, 2)); if (args.strict && summary.directCustomerProof !== false) process.exitCode = 1; } function buildExperienceTranscriptIndex({ root, transcriptsDir }) { const transcripts = inspectTranscripts({ root, transcriptsDir }); const coverage = RULES.map(rule => inspectRuleCoverage(rule, transcripts)); const coveredRuleCount = coverage.filter(item => item.status === 'covered').length; const transcriptCount = transcripts.filter(item => item.exists).length; return { generatedAt: new Date().toISOString(), root, transcriptsDir, proofLevel: 'not_business_proof', materialType: 'experience_seed', directCustomerProof: false, canCloseProofGap: false, complete: false, transcriptCount, missingTranscriptCount: transcripts.filter(item => !item.exists).length, ruleCount: RULES.length, coveredRuleCount, transcripts, ruleCoverage: coverage, guardrails: [ '逐字稿只证明团队经验来源可追踪,不能证明提号率提升、客户效果达标或人工补号量下降。', '逐字稿里的经验规则必须进入真实候选、真实复核和客户效果审计后,才可能参与业务证明。', '不要把经验判断、样例项目、PoC 推荐名单或本索引本身当作 customer-effect:audit 证据。', '本索引只输出规则覆盖、文件路径和行号,不输出原文长段内容,避免带出敏感信息或噪声。' ], nextActions: [ { owner: '技术/AI', title: '对照规则实现', action: '把 ruleCoverage 与 experience-rules、ranker、homepage readiness、video readiness 和 skill references 对齐。', acceptance: '规则实现继续覆盖参考账号、主页近作、品类隐性规则、抖音兜底和视频待补证边界。' }, { owner: '商务/投放', title: '把经验种子转成真实复核数据', action: '用逐字稿经验指导真实历史 Brief、候选视频、人工复核标签、客户选择和拒绝归因填写。', acceptance: 'intake:readiness failureCount=0,history:audit 与 customer-effect:audit 通过。' } ] }; } function inspectTranscripts({ root, transcriptsDir }) { if (!fs.existsSync(transcriptsDir)) { return [{ id: 'transcripts-dir', title: '提号经验逐字稿目录', path: rel(root, transcriptsDir), exists: false, proofLevel: 'not_business_proof', directCustomerProof: false, canCloseProofGap: false, lineCount: 0, matchedRuleIds: [], status: 'missing', summary: '未发现逐字稿目录;不影响真实业务证明缺口判断。' }]; } return fs.readdirSync(transcriptsDir, { withFileTypes: true }) .filter(entry => entry.isFile() && entry.name.endsWith('.md')) .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) .map(entry => inspectTranscript({ root, file: path.join(transcriptsDir, entry.name) })); } function inspectTranscript({ root, file }) { const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); const lines = text.split(/\r?\n/); const matchedRuleIds = RULES .filter(rule => findRuleMatches(lines, rule).length > 0) .map(rule => rule.id); return { id: slug(path.basename(file, '.md')), title: path.basename(file, '.md'), path: rel(root, file), exists: true, proofLevel: 'not_business_proof', materialType: 'experience_seed', directCustomerProof: false, canCloseProofGap: false, lineCount: lines.length, matchedRuleIds, status: 'available_experience_seed', summary: `覆盖经验规则 ${matchedRuleIds.length}/${RULES.length} 条;只能作为规则来源和填表提示,不是业务证明。` }; } function inspectRuleCoverage(rule, transcripts) { const sources = transcripts .filter(item => item.exists) .map(item => { const file = path.resolve(ROOT, item.path); const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); const lines = text.split(/\r?\n/); const matches = findRuleMatches(lines, rule); return { transcriptId: item.id, path: item.path, hitCount: matches.length, lineNumbers: matches.slice(0, 12).map(match => match.lineNumber) }; }) .filter(item => item.hitCount > 0); const hitCount = sources.reduce((sum, item) => sum + item.hitCount, 0); return { id: rule.id, title: rule.title, status: hitCount > 0 ? 'covered' : 'not_found', proofLevel: 'not_business_proof', directCustomerProof: false, canCloseProofGap: false, hitCount, practicalRule: rule.practicalRule, usableFor: rule.usableFor, sources, missingForProof: [ '真实历史 Brief 数据集', '真实候选视频资源', '商务复核标签和客户选择', 'customer-effect:audit 通过' ] }; } function findRuleMatches(lines, rule) { return lines.flatMap((line, index) => { const text = String(line || ''); const hitTerms = rule.terms.filter(term => text.includes(term)); return hitTerms.length ? [{ lineNumber: index + 1, hitTerms }] : []; }); } function renderReport(summary) { return [ '# 提号经验逐字稿索引', '', `- 生成时间:${summary.generatedAt}`, `- proofLevel:${summary.proofLevel}`, `- materialType:${summary.materialType}`, `- directCustomerProof:${summary.directCustomerProof}`, `- canCloseProofGap:${summary.canCloseProofGap}`, `- 逐字稿:${summary.transcriptCount}`, `- 覆盖经验规则:${summary.coveredRuleCount}/${summary.ruleCount}`, '', '## 边界', '', ...summary.guardrails.map(item => `- ${item}`), '', '## 逐字稿', '', '| ID | 状态 | 路径 | 行数 | 覆盖规则 | 摘要 |', '| --- | --- | --- | ---: | --- | --- |', ...summary.transcripts.map(item => tableRow([ item.id, item.status, item.path, item.lineCount, item.matchedRuleIds.join(';'), item.summary ])), '', '## 经验规则覆盖', '', '| 规则 | 状态 | 命中数 | 可用于 | 来源行号 | 缺失证明 |', '| --- | --- | ---: | --- | --- | --- |', ...summary.ruleCoverage.map(item => tableRow([ item.title, item.status, item.hitCount, item.usableFor, item.sources.map(source => `${source.path}:${source.lineNumbers.join('/')}`).join(';'), item.missingForProof.join(';') ])), '', '## 下一步', '', '| 负责人 | 动作 | 验收 |', '| --- | --- | --- |', ...summary.nextActions.map(item => tableRow([item.owner, `${item.title}:${item.action}`, item.acceptance])), '' ].join('\n'); } function renderCsv(summary) { const rows = [ ['类型', 'ID', '状态', 'proofLevel', 'directCustomerProof', 'canCloseProofGap', '路径/来源', '摘要/规则', '缺失证明'], ...summary.transcripts.map(item => [ 'experience-transcript', item.id, item.status, item.proofLevel, item.directCustomerProof, item.canCloseProofGap, item.path, item.summary, '客户选择;人工补号量;customer-effect:audit' ]), ...summary.ruleCoverage.map(item => [ 'experience-rule', item.id, item.status, item.proofLevel, item.directCustomerProof, item.canCloseProofGap, item.sources.map(source => `${source.path}:${source.lineNumbers.join('/')}`).join(';'), item.practicalRule, item.missingForProof.join(';') ]) ]; return rows.map(row => row.map(csvCell).join(',')).join('\n'); } function parseArgs(argv) { const result = {}; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (!arg.startsWith('--')) continue; const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); const next = argv[index + 1]; if (!next || next.startsWith('--')) result[key] = true; else { result[key] = next; index += 1; } } return result; } function slug(value) { return String(value || '') .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, '-') .replace(/^-+|-+$/g, '') || 'transcript'; } function rel(root, file) { const relative = path.relative(root, file).replace(/\\/g, '/'); return relative || '.'; } function withBom(text) { return `\uFEFF${text}`; } function tableRow(values) { return `| ${values.map(value => escapeCell(value)).join(' | ')} |`; } function escapeCell(value) { return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '
'); } function csvCell(value) { return `"${String(value ?? '').replace(/"/g, '""')}"`; } if (require.main === module) main(); module.exports = { buildExperienceTranscriptIndex, inspectRuleCoverage, RULES };