experience-transcript-index.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const ROOT = path.resolve(__dirname, '..');
  5. const DEFAULT_TRANSCRIPTS_DIR = path.resolve(ROOT, '..', 'docs', '提号经验');
  6. const DEFAULT_OUTPUT = path.join(ROOT, 'outputs', 'experience-transcript-index-latest');
  7. const RULES = [
  8. {
  9. id: 'reference-type-tone',
  10. title: '参考账号拆类型和调性',
  11. terms: ['参考账号', '类型', '调性'],
  12. practicalRule: '参考账号只能拆成账号类型、内容调性、场景和表达方式等锚点;多参考账号冲突时不能强行合并成单一标准。',
  13. usableFor: '校准 referenceUsability、referenceStyleFingerprints 和强推荐解释。'
  14. },
  15. {
  16. id: 'xhs-pgy-similar-chain',
  17. title: '小红书/蒲公英相似账号链路',
  18. terms: ['小红书', '蒲公英', '相似', '推荐'],
  19. practicalRule: '小红书和蒲公英优先用参考账号相似推荐链路,再按类型和调性二次筛选。',
  20. usableFor: '校准小红书召回路径和平台策略。'
  21. },
  22. {
  23. id: 'douyin-xingtu-fallback',
  24. title: '抖音/星图只作入口或兜底',
  25. terms: ['抖音', '星图', '招募', '关键词'],
  26. practicalRule: '抖音相似推荐和星图标签不能直接当适配证明;找不到时用招募、APP 关键词或人工补找兜底。',
  27. usableFor: '校准抖音召回路径和低置信复核边界。'
  28. },
  29. {
  30. id: 'homepage-recent-posts',
  31. title: '主页近 10/20 篇优先判断',
  32. terms: ['主页', '前十', '10', '20', '封面', '首图'],
  33. practicalRule: '人工通常先扫主页近 10 篇左右封面/首图,必要时扩到 20 篇或点进图文/视频深看。',
  34. usableFor: '校准 homepage evidence readiness、recentContentFitScore、visualQualityScore 和 toneConsistencyScore。'
  35. },
  36. {
  37. id: 'quant-metric-first-filter',
  38. title: '先用量化指标做第一轮过滤',
  39. terms: ['CPE', 'CP1', 'CPM', '粉丝', '预算', '城市', '女粉'],
  40. practicalRule: '先用关键词、预算、CPE/CP1/CPM、粉丝量、男女粉占比和城市等硬指标缩小候选,再做主页风格复核。',
  41. usableFor: '校准 parseBrief.performanceRequirements、requirementLayers.hardConstraints 和 live 小规模召回参数。'
  42. },
  43. {
  44. id: 'visual-tone-quality',
  45. title: '视觉质感和调性是核心难点',
  46. terms: ['质感', '画面清晰', '像素', '排版', '下沉', '风格'],
  47. practicalRule: '提号难点不只是数据维度,而是账号风格、画面质感、内容垂类和品牌调性是否匹配。',
  48. usableFor: '校准强推荐门槛、风险提示和人工复核标签。'
  49. },
  50. {
  51. id: 'price-visual-threshold',
  52. title: '报价 300 元以上提高视觉质感要求',
  53. terms: ['300', '三百', '画面清晰', '背景', '封面统一', '精致'],
  54. practicalRule: '低价账号可相对放宽,但报价 300 元以上应优先选择封面清晰、背景统一、画面精致、风格稳定的账号。',
  55. usableFor: '校准 visualQualityScore、homepageQualityRisks、manualReviewFields 和强推荐门槛。'
  56. },
  57. {
  58. id: 'recent-data-authenticity',
  59. title: '近期数据和评论真实性复核',
  60. terms: ['近一个月', '近30天', '平均点赞', '百赞', '重复评论', '同一个人评论', '数据真实性', '停更'],
  61. practicalRule: '复核近一个月是否更新、近作平均点赞是否过低,以及多篇笔记是否出现同一批评论者;疑似刷评或停更不要强推。',
  62. usableFor: '校准 homepageEvidence.dataRiskSignals、dataQualityScore、riskNote 和人工复核标签。'
  63. },
  64. {
  65. id: 'implicit-product-audience',
  66. title: '产品隐性人群规则',
  67. terms: ['脱毛仪', '美容仪', '女性', '男性账号', '受众'],
  68. practicalRule: '美容仪、脱毛仪等产品即使 Brief 未写明,也要默认复核女性使用场景和女性博主语境。',
  69. usableFor: '校准品类隐性规则和硬性降级。'
  70. },
  71. {
  72. id: 'multi-input-brief-product-chat',
  73. title: 'Brief + 产品 + 聊天记录多输入解析',
  74. terms: ['Brief', '产品', '聊天记录', '客户聊天', '参考账号', '客户说'],
  75. practicalRule: '最佳输入不只是 Brief,还应合并产品介绍、参考账号和客户聊天记录;聊天里常有隐藏偏好、禁区和子类方向。',
  76. usableFor: '校准 parseBrief.contextSources、productIntro/chatRecords schema 和下一轮校准问题。'
  77. },
  78. {
  79. id: 'category-subtype-refinement',
  80. title: '宽类目必须拆到可执行子类型',
  81. terms: ['时尚穿搭', '港风', '成熟风', '美妆', '护肤', '妆教', '测评', '产品合集'],
  82. practicalRule: '精致生活、时尚穿搭、美妆护肤等宽类目不能只按平台标签筛,必须结合产品和参考账号拆到港风/成熟风、护肤/彩妆/妆教/测评等子方向。',
  83. usableFor: '校准 inferCategory、关键词扩展、categoryRules 和召回关键词。'
  84. },
  85. {
  86. id: 'video-deep-check',
  87. title: '封面不确定再深看视频内容',
  88. terms: ['视频', '内容', '点进去', '每一帧', '解析'],
  89. practicalRule: '封面或主页判断不确定时,再进入图文正文、视频内容、ASR 或帧图分析;没有真实资源时只能标待补证。',
  90. usableFor: '校准视频资源收集包和视频 A/B 前置门禁。'
  91. },
  92. {
  93. id: 'customer-effect-goal',
  94. title: '最终目标是客户选中率和效率',
  95. terms: ['客户', '选中率', '降本增效', '补号', '效果'],
  96. practicalRule: '提号优化最终要用客户选中、拒绝归因和人工补号量变化证明;经验判断本身不能替代客户效果审计。',
  97. usableFor: '校准 proof-gap、customer-effect:audit 和禁止误宣称边界。'
  98. }
  99. ];
  100. function main() {
  101. const args = parseArgs(process.argv.slice(2));
  102. const transcriptsDir = path.resolve(args.transcriptsDir || DEFAULT_TRANSCRIPTS_DIR);
  103. const outputDir = path.resolve(args.output || DEFAULT_OUTPUT);
  104. const summary = buildExperienceTranscriptIndex({ root: ROOT, transcriptsDir });
  105. fs.mkdirSync(outputDir, { recursive: true });
  106. const summaryPath = path.join(outputDir, 'experience-transcript-index-summary.json');
  107. const reportPath = path.join(outputDir, 'experience-transcript-index.md');
  108. const csvPath = path.join(outputDir, 'experience-transcript-index.csv');
  109. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  110. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  111. fs.writeFileSync(csvPath, withBom(renderCsv(summary)), 'utf8');
  112. console.log(JSON.stringify({
  113. outputDir,
  114. summary: summaryPath,
  115. report: reportPath,
  116. csv: csvPath,
  117. transcriptCount: summary.transcriptCount,
  118. coveredRuleCount: summary.coveredRuleCount,
  119. canCloseProofGap: summary.canCloseProofGap
  120. }, null, 2));
  121. if (args.strict && summary.directCustomerProof !== false) process.exitCode = 1;
  122. }
  123. function buildExperienceTranscriptIndex({ root, transcriptsDir }) {
  124. const transcripts = inspectTranscripts({ root, transcriptsDir });
  125. const coverage = RULES.map(rule => inspectRuleCoverage(rule, transcripts));
  126. const coveredRuleCount = coverage.filter(item => item.status === 'covered').length;
  127. const transcriptCount = transcripts.filter(item => item.exists).length;
  128. return {
  129. generatedAt: new Date().toISOString(),
  130. root,
  131. transcriptsDir,
  132. proofLevel: 'not_business_proof',
  133. materialType: 'experience_seed',
  134. directCustomerProof: false,
  135. canCloseProofGap: false,
  136. complete: false,
  137. transcriptCount,
  138. missingTranscriptCount: transcripts.filter(item => !item.exists).length,
  139. ruleCount: RULES.length,
  140. coveredRuleCount,
  141. transcripts,
  142. ruleCoverage: coverage,
  143. guardrails: [
  144. '逐字稿只证明团队经验来源可追踪,不能证明提号率提升、客户效果达标或人工补号量下降。',
  145. '逐字稿里的经验规则必须进入真实候选、真实复核和客户效果审计后,才可能参与业务证明。',
  146. '不要把经验判断、样例项目、PoC 推荐名单或本索引本身当作 customer-effect:audit 证据。',
  147. '本索引只输出规则覆盖、文件路径和行号,不输出原文长段内容,避免带出敏感信息或噪声。'
  148. ],
  149. nextActions: [
  150. {
  151. owner: '技术/AI',
  152. title: '对照规则实现',
  153. action: '把 ruleCoverage 与 experience-rules、ranker、homepage readiness、video readiness 和 skill references 对齐。',
  154. acceptance: '规则实现继续覆盖参考账号、主页近作、品类隐性规则、抖音兜底和视频待补证边界。'
  155. },
  156. {
  157. owner: '商务/投放',
  158. title: '把经验种子转成真实复核数据',
  159. action: '用逐字稿经验指导真实历史 Brief、候选视频、人工复核标签、客户选择和拒绝归因填写。',
  160. acceptance: 'intake:readiness failureCount=0,history:audit 与 customer-effect:audit 通过。'
  161. }
  162. ]
  163. };
  164. }
  165. function inspectTranscripts({ root, transcriptsDir }) {
  166. if (!fs.existsSync(transcriptsDir)) {
  167. return [{
  168. id: 'transcripts-dir',
  169. title: '提号经验逐字稿目录',
  170. path: rel(root, transcriptsDir),
  171. exists: false,
  172. proofLevel: 'not_business_proof',
  173. directCustomerProof: false,
  174. canCloseProofGap: false,
  175. lineCount: 0,
  176. matchedRuleIds: [],
  177. status: 'missing',
  178. summary: '未发现逐字稿目录;不影响真实业务证明缺口判断。'
  179. }];
  180. }
  181. return fs.readdirSync(transcriptsDir, { withFileTypes: true })
  182. .filter(entry => entry.isFile() && entry.name.endsWith('.md'))
  183. .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
  184. .map(entry => inspectTranscript({ root, file: path.join(transcriptsDir, entry.name) }));
  185. }
  186. function inspectTranscript({ root, file }) {
  187. const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
  188. const lines = text.split(/\r?\n/);
  189. const matchedRuleIds = RULES
  190. .filter(rule => findRuleMatches(lines, rule).length > 0)
  191. .map(rule => rule.id);
  192. return {
  193. id: slug(path.basename(file, '.md')),
  194. title: path.basename(file, '.md'),
  195. path: rel(root, file),
  196. exists: true,
  197. proofLevel: 'not_business_proof',
  198. materialType: 'experience_seed',
  199. directCustomerProof: false,
  200. canCloseProofGap: false,
  201. lineCount: lines.length,
  202. matchedRuleIds,
  203. status: 'available_experience_seed',
  204. summary: `覆盖经验规则 ${matchedRuleIds.length}/${RULES.length} 条;只能作为规则来源和填表提示,不是业务证明。`
  205. };
  206. }
  207. function inspectRuleCoverage(rule, transcripts) {
  208. const sources = transcripts
  209. .filter(item => item.exists)
  210. .map(item => {
  211. const file = path.resolve(ROOT, item.path);
  212. const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
  213. const lines = text.split(/\r?\n/);
  214. const matches = findRuleMatches(lines, rule);
  215. return {
  216. transcriptId: item.id,
  217. path: item.path,
  218. hitCount: matches.length,
  219. lineNumbers: matches.slice(0, 12).map(match => match.lineNumber)
  220. };
  221. })
  222. .filter(item => item.hitCount > 0);
  223. const hitCount = sources.reduce((sum, item) => sum + item.hitCount, 0);
  224. return {
  225. id: rule.id,
  226. title: rule.title,
  227. status: hitCount > 0 ? 'covered' : 'not_found',
  228. proofLevel: 'not_business_proof',
  229. directCustomerProof: false,
  230. canCloseProofGap: false,
  231. hitCount,
  232. practicalRule: rule.practicalRule,
  233. usableFor: rule.usableFor,
  234. sources,
  235. missingForProof: [
  236. '真实历史 Brief 数据集',
  237. '真实候选视频资源',
  238. '商务复核标签和客户选择',
  239. 'customer-effect:audit 通过'
  240. ]
  241. };
  242. }
  243. function findRuleMatches(lines, rule) {
  244. return lines.flatMap((line, index) => {
  245. const text = String(line || '');
  246. const hitTerms = rule.terms.filter(term => text.includes(term));
  247. return hitTerms.length ? [{ lineNumber: index + 1, hitTerms }] : [];
  248. });
  249. }
  250. function renderReport(summary) {
  251. return [
  252. '# 提号经验逐字稿索引',
  253. '',
  254. `- 生成时间:${summary.generatedAt}`,
  255. `- proofLevel:${summary.proofLevel}`,
  256. `- materialType:${summary.materialType}`,
  257. `- directCustomerProof:${summary.directCustomerProof}`,
  258. `- canCloseProofGap:${summary.canCloseProofGap}`,
  259. `- 逐字稿:${summary.transcriptCount}`,
  260. `- 覆盖经验规则:${summary.coveredRuleCount}/${summary.ruleCount}`,
  261. '',
  262. '## 边界',
  263. '',
  264. ...summary.guardrails.map(item => `- ${item}`),
  265. '',
  266. '## 逐字稿',
  267. '',
  268. '| ID | 状态 | 路径 | 行数 | 覆盖规则 | 摘要 |',
  269. '| --- | --- | --- | ---: | --- | --- |',
  270. ...summary.transcripts.map(item => tableRow([
  271. item.id,
  272. item.status,
  273. item.path,
  274. item.lineCount,
  275. item.matchedRuleIds.join(';'),
  276. item.summary
  277. ])),
  278. '',
  279. '## 经验规则覆盖',
  280. '',
  281. '| 规则 | 状态 | 命中数 | 可用于 | 来源行号 | 缺失证明 |',
  282. '| --- | --- | ---: | --- | --- | --- |',
  283. ...summary.ruleCoverage.map(item => tableRow([
  284. item.title,
  285. item.status,
  286. item.hitCount,
  287. item.usableFor,
  288. item.sources.map(source => `${source.path}:${source.lineNumbers.join('/')}`).join(';'),
  289. item.missingForProof.join(';')
  290. ])),
  291. '',
  292. '## 下一步',
  293. '',
  294. '| 负责人 | 动作 | 验收 |',
  295. '| --- | --- | --- |',
  296. ...summary.nextActions.map(item => tableRow([item.owner, `${item.title}:${item.action}`, item.acceptance])),
  297. ''
  298. ].join('\n');
  299. }
  300. function renderCsv(summary) {
  301. const rows = [
  302. ['类型', 'ID', '状态', 'proofLevel', 'directCustomerProof', 'canCloseProofGap', '路径/来源', '摘要/规则', '缺失证明'],
  303. ...summary.transcripts.map(item => [
  304. 'experience-transcript',
  305. item.id,
  306. item.status,
  307. item.proofLevel,
  308. item.directCustomerProof,
  309. item.canCloseProofGap,
  310. item.path,
  311. item.summary,
  312. '客户选择;人工补号量;customer-effect:audit'
  313. ]),
  314. ...summary.ruleCoverage.map(item => [
  315. 'experience-rule',
  316. item.id,
  317. item.status,
  318. item.proofLevel,
  319. item.directCustomerProof,
  320. item.canCloseProofGap,
  321. item.sources.map(source => `${source.path}:${source.lineNumbers.join('/')}`).join(';'),
  322. item.practicalRule,
  323. item.missingForProof.join(';')
  324. ])
  325. ];
  326. return rows.map(row => row.map(csvCell).join(',')).join('\n');
  327. }
  328. function parseArgs(argv) {
  329. const result = {};
  330. for (let index = 0; index < argv.length; index += 1) {
  331. const arg = argv[index];
  332. if (!arg.startsWith('--')) continue;
  333. const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  334. const next = argv[index + 1];
  335. if (!next || next.startsWith('--')) result[key] = true;
  336. else {
  337. result[key] = next;
  338. index += 1;
  339. }
  340. }
  341. return result;
  342. }
  343. function slug(value) {
  344. return String(value || '')
  345. .toLowerCase()
  346. .replace(/[^\p{L}\p{N}]+/gu, '-')
  347. .replace(/^-+|-+$/g, '') || 'transcript';
  348. }
  349. function rel(root, file) {
  350. const relative = path.relative(root, file).replace(/\\/g, '/');
  351. return relative || '.';
  352. }
  353. function withBom(text) {
  354. return `\uFEFF${text}`;
  355. }
  356. function tableRow(values) {
  357. return `| ${values.map(value => escapeCell(value)).join(' | ')} |`;
  358. }
  359. function escapeCell(value) {
  360. return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
  361. }
  362. function csvCell(value) {
  363. return `"${String(value ?? '').replace(/"/g, '""')}"`;
  364. }
  365. if (require.main === module) main();
  366. module.exports = {
  367. buildExperienceTranscriptIndex,
  368. inspectRuleCoverage,
  369. RULES
  370. };