intake-field-checklist.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const ROOT = path.resolve(__dirname, '..');
  5. const OUTPUTS = path.join(ROOT, 'outputs');
  6. const HISTORY_FIELDS = [
  7. ['客户原始Brief', '填写真实客户原始 Brief,至少能看出产品、人群、投放诉求。'],
  8. ['历史人工补号量基线', '填写该 Brief 历史人工补号量数字,用于后续计算人工补号减少率。'],
  9. ['参考账号或视频', '填写真实参考账号主页或参考视频 URL。'],
  10. ['平台', '填写小红书、抖音等真实平台。'],
  11. ['博主名称', '填写人工最终名单里的真实博主名称。'],
  12. ['主页链接', '填写人工最终名单里的真实主页 URL。'],
  13. ['人工复核标签', '填写可直接发客户、商务复核、跑偏、调性不符等复核标签。'],
  14. ['客户选择', '填写客户选中或客户拒绝,不能停留在待反馈。']
  15. ];
  16. const REVIEW_FIELDS = [
  17. ['策略', '填写候选来源策略,如 baseline-live、reference-account、homepage-evidence、video-enhanced。'],
  18. ['排名', '填写同一 brief 内连续排名。'],
  19. ['平台', '填写候选平台。'],
  20. ['博主名称', '填写候选博主真实名称。'],
  21. ['主页链接', '填写候选主页 URL。'],
  22. ['人工复核标签', '填写商务复核结论。'],
  23. ['客户选择', '填写客户选中或客户拒绝;缺失时不能做客户效果审计。'],
  24. ['归因类型', '负样本必须填写归因类型。'],
  25. ['反馈原因', '客户拒绝或商务负样本必须填写具体原因。'],
  26. ['本轮人工补号量', '填写本轮额外人工补号量数字。']
  27. ];
  28. const VIDEO_FIELDS = [
  29. ['资源角色', '填写参考视频或候选视频。'],
  30. ['博主名称', '填写视频所属博主真实名称。'],
  31. ['主页链接', '填写视频所属博主主页 URL。'],
  32. ['视频链接', '填写真实视频 URL。'],
  33. ['标题', '填写视频标题。'],
  34. ['内容摘要', '填写视频内容摘要。'],
  35. ['风格调性标签', '填写画面、表达、场景、内容结构等调性标签。'],
  36. ['是否真实资源', '真实资源填“是”;模板、示例、占位不能填“是”。']
  37. ];
  38. function main() {
  39. const args = parseArgs(process.argv.slice(2));
  40. const dataPack = path.resolve(args.dataPack || path.join(OUTPUTS, 'data-intake-pack-latest'));
  41. const videoPack = path.resolve(args.videoPack || path.join(OUTPUTS, 'video-intake-pack-latest'));
  42. const outputDir = path.resolve(args.output || path.join(OUTPUTS, 'intake-field-checklist-latest'));
  43. const minBriefs = Number(args.minBriefs || 5);
  44. const summary = buildChecklist({
  45. historyCsv: path.resolve(args.historyCsv || path.join(dataPack, 'history-data-template.csv')),
  46. reviewCsv: path.resolve(args.reviewCsv || path.join(dataPack, 'manual-review-template.csv')),
  47. videoCsv: path.resolve(args.videoCsv || path.join(videoPack, 'video-resource-template.csv')),
  48. minBriefs
  49. });
  50. fs.mkdirSync(outputDir, { recursive: true });
  51. const summaryPath = path.join(outputDir, 'intake-field-checklist-summary.json');
  52. const reportPath = path.join(outputDir, 'intake-field-checklist.md');
  53. const csvPath = path.join(outputDir, 'intake-field-checklist.csv');
  54. fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
  55. fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
  56. fs.writeFileSync(csvPath, withBom(renderCsv(summary.items)), 'utf8');
  57. console.log(JSON.stringify({
  58. outputDir,
  59. summary: summaryPath,
  60. report: reportPath,
  61. csv: csvPath,
  62. passed: summary.passed,
  63. itemCount: summary.itemCount,
  64. missingRequiredCount: summary.missingRequiredCount,
  65. placeholderCount: summary.placeholderCount
  66. }, null, 2));
  67. if (args.strict && !summary.passed) process.exitCode = 1;
  68. }
  69. function buildChecklist({ historyCsv, reviewCsv, videoCsv, minBriefs }) {
  70. const history = readCsvFile(historyCsv);
  71. const review = readCsvFile(reviewCsv);
  72. const video = readCsvFile(videoCsv);
  73. const items = [
  74. ...historyItems(history, historyCsv, minBriefs),
  75. ...reviewItems(review, reviewCsv),
  76. ...videoItems(video, videoCsv)
  77. ].map((item, index) => ({ id: `FIELD-${String(index + 1).padStart(3, '0')}`, ...item }));
  78. const missingRequiredCount = items.filter(item => item.issueType === 'missing_required' || item.issueType === 'missing_minimum_row').length;
  79. const placeholderCount = items.filter(item => item.issueType === 'placeholder').length;
  80. const ownerGroups = buildOwnerGroups(items);
  81. const recheckCommands = buildRecheckCommands();
  82. return {
  83. generatedAt: new Date().toISOString(),
  84. purpose: '给商务/投放逐字段补齐真实历史 Brief、人工复核、客户选择和视频资源;本表不证明客户效果。',
  85. proofLevel: 'smoke_or_local',
  86. directCustomerProof: false,
  87. passed: items.length > 0,
  88. minBriefs,
  89. files: { historyCsv, reviewCsv, videoCsv },
  90. counts: {
  91. historyRows: history.rows.length,
  92. reviewRows: review.rows.length,
  93. videoRows: video.rows.length
  94. },
  95. itemCount: items.length,
  96. missingRequiredCount,
  97. placeholderCount,
  98. ownerGroups,
  99. recheckCommands,
  100. items,
  101. guardrails: [
  102. '本表只用于补齐真实材料,不证明命中率、客户选中率或人工补号减少率。',
  103. 'example.com、示例、候选、待补、待客户反馈、填写说明等内容必须替换为真实客户材料。',
  104. '补齐后必须重跑 intake:readiness、round:refresh 和 customer-effect:audit/acceptance:video-ab 的对应真实验收。'
  105. ]
  106. };
  107. }
  108. function historyItems(table, file, minBriefs) {
  109. const rows = table.rows;
  110. const items = [];
  111. const briefIds = new Set(rows.map(row => clean(row['brief编号'])).filter(Boolean));
  112. for (let index = briefIds.size + 1; index <= minBriefs; index += 1) {
  113. items.push(item('商务', 'history', 'missing_minimum_row', file, index, '整行', '', `补第 ${index} 个真实历史 Brief,覆盖不同项目或客户需求。`, '真实历史 Brief 数达到最少要求。'));
  114. }
  115. rows.forEach((row, index) => {
  116. addFieldItems(items, '商务', 'history', file, index + 2, row, HISTORY_FIELDS);
  117. const decision = clean(row['客户选择']);
  118. if (/拒绝|淘汰|未选/i.test(decision) && isBlankOrPlaceholder(row['拒绝原因'])) {
  119. items.push(item('商务', 'history', 'missing_required', file, index + 2, '拒绝原因', row['拒绝原因'], '客户拒绝样本必须补拒绝原因。', '负样本归因覆盖率=100%。'));
  120. }
  121. });
  122. return items;
  123. }
  124. function reviewItems(table, file) {
  125. const items = [];
  126. table.rows.forEach((row, index) => {
  127. addFieldItems(items, '商务', 'review', file, index + 2, row, REVIEW_FIELDS);
  128. const label = clean(row['人工复核标签']);
  129. const decision = clean(row['客户选择']);
  130. if ((/拒绝|淘汰|跑偏|不符|负样本/i.test(`${label} ${decision}`)) && isBlankOrPlaceholder(row['反馈原因'])) {
  131. items.push(item('商务', 'review', 'missing_required', file, index + 2, '反馈原因', row['反馈原因'], '补商务或客户拒绝的具体原因。', '负样本归因覆盖率=100%。'));
  132. }
  133. });
  134. return items;
  135. }
  136. function videoItems(table, file) {
  137. const items = [];
  138. table.rows.forEach((row, index) => addFieldItems(items, '商务/投放', 'video', file, index + 2, row, VIDEO_FIELDS));
  139. const realCandidates = table.rows.filter(row => clean(row['资源角色']) === '候选视频' && clean(row['是否真实资源']) === '是');
  140. if (!realCandidates.length) {
  141. items.push(item('商务/投放', 'video', 'missing_minimum_row', file, table.rows.length + 2, '整行', '', '补至少 1 条真实候选视频资源。', 'videoReady=true,真实候选视频行数 >= 1。'));
  142. }
  143. return items;
  144. }
  145. function addFieldItems(items, owner, section, file, rowNumber, row, fields) {
  146. for (const [field, fixAction] of fields) {
  147. const current = row[field];
  148. if (isBlankOrPlaceholder(current)) {
  149. items.push(item(owner, section, isPlaceholder(current) ? 'placeholder' : 'missing_required', file, rowNumber, field, current, fixAction, acceptanceFor(section, field)));
  150. }
  151. }
  152. }
  153. function item(owner, section, issueType, file, rowNumber, field, currentValue, fixAction, acceptance) {
  154. return {
  155. owner,
  156. section,
  157. issueType,
  158. file,
  159. rowNumber,
  160. field,
  161. currentValue: clean(currentValue),
  162. fixAction,
  163. acceptance,
  164. boundary: '补齐材料,不证明客户效果'
  165. };
  166. }
  167. function buildOwnerGroups(items) {
  168. const groups = new Map();
  169. for (const item of items) {
  170. const key = `${item.owner}|${item.section}`;
  171. if (!groups.has(key)) {
  172. groups.set(key, {
  173. owner: item.owner,
  174. section: item.section,
  175. itemCount: 0,
  176. missingRequiredCount: 0,
  177. placeholderCount: 0,
  178. files: new Set(),
  179. firstItemIds: []
  180. });
  181. }
  182. const group = groups.get(key);
  183. group.itemCount += 1;
  184. if (item.issueType === 'missing_required' || item.issueType === 'missing_minimum_row') group.missingRequiredCount += 1;
  185. if (item.issueType === 'placeholder') group.placeholderCount += 1;
  186. group.files.add(item.file);
  187. if (group.firstItemIds.length < 5) group.firstItemIds.push(item.id);
  188. }
  189. return Array.from(groups.values())
  190. .map(group => ({
  191. ...group,
  192. files: Array.from(group.files),
  193. nextAction: nextActionForGroup(group.section),
  194. acceptance: groupAcceptanceFor(group.section),
  195. boundary: '补齐材料,不证明客户效果'
  196. }))
  197. .sort((a, b) => groupPriority(a.section) - groupPriority(b.section) || a.owner.localeCompare(b.owner, 'zh-Hans-CN'));
  198. }
  199. function buildRecheckCommands() {
  200. return [
  201. {
  202. stage: '真实材料预审',
  203. owner: '技术/AI',
  204. command: 'npm run intake:readiness -- --data-pack outputs\\data-intake-pack-latest --video-pack outputs\\video-intake-pack-latest --output outputs\\intake-readiness-latest',
  205. acceptance: 'overallReady=true 且 failureCount=0',
  206. boundary: '预审通过只代表材料齐全,不证明客户效果'
  207. },
  208. {
  209. stage: '视频资源就绪审计',
  210. owner: '技术/AI',
  211. command: 'npm run video:resource-readiness -- --input outputs\\video-intake-pack-latest\\video-resource-template.csv --output outputs\\video-resource-readiness-latest --strict',
  212. acceptance: 'readyForVideoAbPreflight=true 且 failureCount=0',
  213. boundary: '只证明视频 A/B 前置资源齐备'
  214. },
  215. {
  216. stage: '刷新本轮交接',
  217. owner: '技术/AI',
  218. command: 'npm run round:refresh -- --output-root outputs --strict',
  219. acceptance: 'round-deposition.passed=true 且 failCount=0',
  220. boundary: '刷新交接不证明业务效果'
  221. },
  222. {
  223. stage: '客户效果审计',
  224. owner: '技术/AI',
  225. command: 'npm run customer-effect:audit -- --review-csv <已标注CSV> --history-audit <historical-dataset-audit.json> --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict',
  226. acceptance: 'customer-effect:audit overallPass=true',
  227. boundary: '缺客户选择、历史人工补号基线或本轮人工补号量时不得运行或宣称效果'
  228. },
  229. {
  230. stage: '视频 A/B 验收',
  231. owner: '技术/AI',
  232. command: 'npm run acceptance:video-ab',
  233. acceptance: '真实 provider、真实参考视频和真实候选视频下通过',
  234. boundary: '接口 200 或 sample 不证明视频提升提号率'
  235. }
  236. ];
  237. }
  238. function nextActionForGroup(section) {
  239. if (section === 'history') return '先补真实历史 Brief、参考账号或视频、人工最终名单、客户选择和历史人工补号量基线。';
  240. if (section === 'review') return '补候选人工复核标签、客户选择、负样本归因、反馈原因和本轮人工补号量。';
  241. if (section === 'video') return '补真实参考视频和真实候选视频 URL,并补封面、ASR、帧图、标题或内容摘要证据。';
  242. return '按字段核对表补齐真实材料。';
  243. }
  244. function groupAcceptanceFor(section) {
  245. if (section === 'history') return 'historyReady=true,真实历史 Brief 数 >= minBriefs,且无模板占位。';
  246. if (section === 'review') return 'reviewMetricsReady=true,customerEffectReady=true 所需字段齐全。';
  247. if (section === 'video') return 'videoReady=true,至少有真实参考视频和真实候选视频。';
  248. return '对应 readiness failureCount=0。';
  249. }
  250. function groupPriority(section) {
  251. if (section === 'history') return 1;
  252. if (section === 'video') return 2;
  253. if (section === 'review') return 3;
  254. return 99;
  255. }
  256. function acceptanceFor(section, field) {
  257. if (section === 'history') return 'historyReady=true,且无模板占位。';
  258. if (section === 'review') return field === '客户选择' ? 'customerEffectReady=true 所需字段齐全。' : 'reviewMetricsReady=true。';
  259. return 'videoReady=true,真实视频资源可进入视频 A/B 前置验收。';
  260. }
  261. function isBlankOrPlaceholder(value) {
  262. return !clean(value) || isPlaceholder(value);
  263. }
  264. function isPlaceholder(value) {
  265. return /example\.com|示例|候选|待补|待确认|待客户反馈|填写|人工待补/i.test(clean(value));
  266. }
  267. function clean(value) {
  268. return String(value ?? '').trim();
  269. }
  270. function readCsvFile(file) {
  271. if (!fs.existsSync(file)) return { header: [], rows: [] };
  272. const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
  273. const records = parseCsv(text);
  274. const header = records[0] || [];
  275. return {
  276. header,
  277. rows: records.slice(1).filter(row => row.some(cell => clean(cell))).map(row => Object.fromEntries(header.map((name, index) => [name, row[index] || ''])))
  278. };
  279. }
  280. function parseCsv(text) {
  281. const rows = [];
  282. let row = [];
  283. let cell = '';
  284. let quoted = false;
  285. for (let index = 0; index < text.length; index += 1) {
  286. const char = text[index];
  287. const next = text[index + 1];
  288. if (quoted) {
  289. if (char === '"' && next === '"') {
  290. cell += '"';
  291. index += 1;
  292. } else if (char === '"') {
  293. quoted = false;
  294. } else {
  295. cell += char;
  296. }
  297. continue;
  298. }
  299. if (char === '"') quoted = true;
  300. else if (char === ',') {
  301. row.push(cell);
  302. cell = '';
  303. } else if (char === '\n') {
  304. row.push(cell.replace(/\r$/, ''));
  305. rows.push(row);
  306. row = [];
  307. cell = '';
  308. } else {
  309. cell += char;
  310. }
  311. }
  312. if (cell || row.length) {
  313. row.push(cell.replace(/\r$/, ''));
  314. rows.push(row);
  315. }
  316. return rows;
  317. }
  318. function renderReport(summary) {
  319. const lines = [
  320. '# 真实材料字段级补齐核对表',
  321. '',
  322. `- 生成时间:${summary.generatedAt}`,
  323. `- 是否通过:${summary.passed ? '是' : '否'}`,
  324. `- 待补字段/行数:${summary.itemCount}`,
  325. `- 缺必填:${summary.missingRequiredCount}`,
  326. `- 占位内容:${summary.placeholderCount}`,
  327. `- directCustomerProof:${summary.directCustomerProof ? 'true' : 'false'}`,
  328. '',
  329. '## 文件',
  330. '',
  331. `- 历史数据:${summary.files.historyCsv}`,
  332. `- 人工复核:${summary.files.reviewCsv}`,
  333. `- 视频资源:${summary.files.videoCsv}`,
  334. '',
  335. '## 按负责人补齐顺序',
  336. '',
  337. '| 负责人 | 区域 | 问题数 | 缺必填 | 占位 | 文件 | 优先处理项 | 修复动作 | 验收 |',
  338. '| --- | --- | ---: | ---: | ---: | --- | --- | --- | --- |',
  339. ...summary.ownerGroups.map(row => `| ${escapeCell(row.owner)} | ${escapeCell(row.section)} | ${row.itemCount} | ${row.missingRequiredCount} | ${row.placeholderCount} | ${escapeCell(row.files.join(';'))} | ${escapeCell(row.firstItemIds.join('、'))} | ${escapeCell(row.nextAction)} | ${escapeCell(row.acceptance)} |`),
  340. '',
  341. '## 补齐后复验命令',
  342. '',
  343. '| 阶段 | 负责人 | 命令 | 通过标准 | 边界 |',
  344. '| --- | --- | --- | --- | --- |',
  345. ...summary.recheckCommands.map(row => `| ${escapeCell(row.stage)} | ${escapeCell(row.owner)} | \`${escapeCell(row.command)}\` | ${escapeCell(row.acceptance)} | ${escapeCell(row.boundary)} |`),
  346. '',
  347. '## 核对表',
  348. '',
  349. '| ID | 负责人 | 区域 | 行号 | 字段 | 问题 | 当前值 | 修复动作 | 验收 |',
  350. '| --- | --- | --- | ---: | --- | --- | --- | --- | --- |',
  351. ...summary.items.map(row => `| ${escapeCell(row.id)} | ${escapeCell(row.owner)} | ${escapeCell(row.section)} | ${row.rowNumber} | ${escapeCell(row.field)} | ${escapeCell(row.issueType)} | ${escapeCell(row.currentValue)} | ${escapeCell(row.fixAction)} | ${escapeCell(row.acceptance)} |`),
  352. '',
  353. '## 边界',
  354. '',
  355. ...summary.guardrails.map(item => `- ${item}`)
  356. ];
  357. return lines.join('\n');
  358. }
  359. function renderCsv(items) {
  360. const header = ['ID', '负责人', '区域', '问题类型', '文件', '行号', '字段', '当前值', '修复动作', '验收标准', '边界'];
  361. const rows = items.map(item => [
  362. item.id,
  363. item.owner,
  364. item.section,
  365. item.issueType,
  366. item.file,
  367. item.rowNumber,
  368. item.field,
  369. item.currentValue,
  370. item.fixAction,
  371. item.acceptance,
  372. item.boundary
  373. ]);
  374. return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n');
  375. }
  376. function csvCell(value) {
  377. const text = String(value ?? '');
  378. return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
  379. }
  380. function escapeCell(value) {
  381. return String(value ?? '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
  382. }
  383. function parseArgs(argv) {
  384. const args = {};
  385. for (let index = 0; index < argv.length; index += 1) {
  386. const raw = argv[index];
  387. if (!raw.startsWith('--')) continue;
  388. const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
  389. const next = argv[index + 1];
  390. if (!next || next.startsWith('--')) args[key] = true;
  391. else {
  392. args[key] = next;
  393. index += 1;
  394. }
  395. }
  396. return args;
  397. }
  398. function withBom(text) {
  399. return `\uFEFF${text}`;
  400. }
  401. if (require.main === module) main();
  402. module.exports = { buildChecklist };