| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- #!/usr/bin/env node
- const fs = require('fs');
- const path = require('path');
- const { readCsv: readHistoryCsv } = require('./history-dataset-from-csv');
- const { readCsv: readVideoCsv } = require('./video-resource-readiness-audit');
- const { readCsv: readReviewCsv } = require('./review-metrics');
- const HISTORY_REQUIRED = [
- 'brief编号',
- '项目名称',
- '类目',
- '客户原始Brief',
- '历史人工补号量基线',
- '参考账号或视频',
- '平台',
- '博主名称',
- '主页链接',
- '人工复核标签',
- '客户选择',
- '拒绝原因'
- ];
- const REVIEW_REQUIRED = [
- 'brief编号',
- '策略',
- '排名',
- '平台',
- '博主名称',
- '综合分',
- 'brief匹配分',
- '参考风格分',
- '主页证据分',
- '视觉质感分',
- '调性一致分',
- '证据加分',
- '证据风险扣分',
- '推荐理由',
- '风险提示',
- '主页链接',
- '人工复核标签',
- '客户选择',
- '归因类型',
- '反馈原因',
- '本轮人工补号量'
- ];
- const VIDEO_REQUIRED = [
- 'brief编号',
- '项目名称',
- '平台',
- '资源角色',
- '博主名称',
- '主页链接',
- '视频链接',
- '封面链接',
- '字幕或ASR文本',
- '帧图链接',
- '标题',
- '发布时间',
- '内容摘要',
- '风格调性标签',
- '画面人设场景信号',
- '风险提示',
- '来源接口或备注',
- '是否真实资源'
- ];
- const FINAL_CUSTOMER_DECISIONS = new Set(['客户选中', '客户拒绝', '已选中', '已拒绝', '选中', '拒绝', '客户通过', '客户淘汰']);
- const NEGATIVE_REVIEW_LABELS = new Set(['跑偏', '硬性规则违规', '硬性规则违约', '调性不符', '主页质感不符', '参考账号不像']);
- function main() {
- const args = parseArgs(process.argv.slice(2));
- const dataPack = args.dataPack ? path.resolve(args.dataPack) : '';
- const videoPack = args.videoPack ? path.resolve(args.videoPack) : '';
- const historyCsv = path.resolve(args.historyCsv || args.history || (dataPack ? path.join(dataPack, 'history-data-template.csv') : ''));
- const reviewCsv = path.resolve(args.reviewCsv || args.review || (dataPack ? path.join(dataPack, 'manual-review-template.csv') : ''));
- const videoCsv = path.resolve(args.videoCsv || args.video || (videoPack ? path.join(videoPack, 'video-resource-template.csv') : ''));
- if (!historyCsv || !reviewCsv || !videoCsv) {
- throw new Error('Usage: node scripts/intake-readiness-audit.js --data-pack <dir> --video-pack <dir> [--output <dir>] [--strict]');
- }
- const outputDir = path.resolve(args.output || process.env.TIHAO_INTAKE_READINESS_OUTPUT || path.join(path.dirname(historyCsv), 'intake-readiness'));
- const minBriefs = Number(args.minBriefs || process.env.TIHAO_INTAKE_MIN_BRIEFS || 5);
- const summary = buildIntakeReadinessSummary({
- historyCsv,
- reviewCsv,
- videoCsv,
- minBriefs
- });
- fs.mkdirSync(outputDir, { recursive: true });
- const jsonPath = path.join(outputDir, 'intake-readiness-summary.json');
- const reportPath = path.join(outputDir, 'intake-readiness-report.md');
- const repairCsvPath = path.join(outputDir, 'intake-readiness-repair-actions.csv');
- fs.writeFileSync(jsonPath, JSON.stringify(summary, null, 2), 'utf8');
- fs.writeFileSync(reportPath, withBom(renderReport(summary)), 'utf8');
- fs.writeFileSync(repairCsvPath, withBom(renderRepairCsv(summary.repairActions)), 'utf8');
- console.log(JSON.stringify({
- outputDir,
- json: jsonPath,
- report: reportPath,
- readyForHistoryAudit: summary.history.ready,
- readyForVideoResourceAudit: summary.video.ready,
- readyForReviewMetrics: summary.review.readyForReviewMetrics,
- readyForCustomerEffectAudit: summary.review.readyForCustomerEffectAudit,
- overallReady: summary.acceptance.overallReady,
- failureCount: summary.failureCount,
- repairActionCount: summary.repairActions.length
- }, null, 2));
- if (args.strict && !summary.acceptance.overallReady) process.exitCode = 2;
- }
- function buildIntakeReadinessSummary({ historyCsv, reviewCsv, videoCsv, minBriefs }) {
- const historyRows = safeReadCsv(readHistoryCsv, historyCsv);
- const reviewRows = safeReadCsv(readReviewCsv, reviewCsv);
- const videoRows = safeReadCsv(readVideoCsv, videoCsv);
- const history = auditHistory({ file: historyCsv, rows: historyRows, minBriefs });
- const review = auditReview({ file: reviewCsv, rows: reviewRows });
- const video = auditVideo({ file: videoCsv, rows: videoRows });
- const sections = [history, review, video];
- const issues = sections.flatMap(section => section.issues.map(issue => ({ section: section.id, ...issue })));
- const acceptance = {
- historyReady: history.ready,
- videoReady: video.ready,
- reviewMetricsReady: review.readyForReviewMetrics,
- customerEffectReady: review.readyForCustomerEffectAudit
- };
- acceptance.overallReady = acceptance.historyReady &&
- acceptance.videoReady &&
- acceptance.reviewMetricsReady &&
- acceptance.customerEffectReady;
- const files = { historyCsv, reviewCsv, videoCsv };
- const repairActions = buildRepairActions({ issues, files });
- return {
- generatedAt: new Date().toISOString(),
- minBriefs,
- files,
- acceptance,
- history,
- review,
- video,
- failureCount: issues.length,
- issues,
- repairActions,
- guardrails: [
- 'intake readiness 只判断模板是否已被真实材料替换,不替代 history:audit、video:resource-readiness、review:metrics 或 customer-effect:audit。',
- 'example.com、示例、候选博主、待客户反馈、人工待补等模板占位内容不得进入真实审计。',
- '没有客户最终选择和本轮人工补号量时,不得声明客户效果证明完成。',
- '没有真实参考视频和真实候选视频时,不得声明视频 A/B 可以开始。'
- ]
- };
- }
- function auditHistory({ file, rows, minBriefs }) {
- const issues = [];
- const headerOk = headerStartsWith(rows.header, HISTORY_REQUIRED);
- if (!rows.ok) issues.push(issue('read-error', rows.error));
- if (!headerOk) issues.push(issue('header', '历史数据 CSV 表头不符合 data-intake-pack 模板。'));
- const body = rows.body || [];
- const briefIds = unique(body.map(row => value(row['brief编号'])));
- if (briefIds.length < minBriefs) issues.push(issue('min-briefs', `真实历史 Brief 数不足:${briefIds.length}/${minBriefs}。`));
- if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '历史数据表仍包含 example.com、示例、候选或待补等模板占位内容。'));
- if (!body.every(row => value(row['客户原始Brief']).length >= 20)) issues.push(issue('brief-text', '每行必须有足够具体的客户原始 Brief。'));
- if (!body.every(row => toNumberOrNull(row['历史人工补号量基线']) !== null)) issues.push(issue('manual-baseline', '每行必须填写历史人工补号量基线。'));
- if (!body.every(row => isLikelyRealReference(row['参考账号或视频']))) issues.push(issue('reference', '每行必须填写真实参考账号或参考视频链接。'));
- if (!body.every(row => value(row['博主名称']) && isLikelyRealUrl(row['主页链接']))) issues.push(issue('manual-list', '每行必须填写人工最终名单中的博主名称和主页链接。'));
- if (!body.every(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择'])))) issues.push(issue('customer-decision', '每行必须填写客户最终选中或客户拒绝,不能只写待客户反馈。'));
- const rejectedRows = body.filter(row => /拒绝|淘汰|未选/i.test(value(row['客户选择'])));
- if (!rejectedRows.every(row => value(row['拒绝原因']).length >= 2)) issues.push(issue('reject-reason', '客户拒绝样本必须填写拒绝原因。'));
- return {
- id: 'history',
- file,
- ready: issues.length === 0,
- counts: {
- rows: body.length,
- briefCount: briefIds.length,
- rejectedRows: rejectedRows.length
- },
- issues
- };
- }
- function auditReview({ file, rows }) {
- const issues = [];
- const headerOk = headerStartsWith(rows.header, REVIEW_REQUIRED);
- if (!rows.ok) issues.push(issue('read-error', rows.error));
- if (!headerOk) issues.push(issue('header', '人工复核 CSV 表头不符合 data-intake-pack 模板。'));
- const body = rows.body || [];
- if (!body.length) issues.push(issue('empty', '人工复核表没有候选行。'));
- if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '人工复核表仍包含 example.com、示例、候选或待补等模板占位内容。'));
- if (!body.every(row => value(row['人工复核标签']))) issues.push(issue('review-label', '每行必须填写人工复核标签。'));
- const negativeRows = body.filter(row => NEGATIVE_REVIEW_LABELS.has(value(row['人工复核标签'])));
- if (!negativeRows.every(row => value(row['归因类型']))) issues.push(issue('attribution', '负样本必须填写归因类型。'));
- const hasCustomerDecision = body.some(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择'])));
- if (!hasCustomerDecision) issues.push(issue('customer-decision', '人工复核表至少需要客户最终选中/拒绝记录,才能进入客户效果审计。'));
- const hasCurrentManualSupplement = body.some(row => toNumberOrNull(row['本轮人工补号量']) !== null);
- if (!hasCurrentManualSupplement) issues.push(issue('current-manual-supplement', '缺少本轮人工补号量。'));
- const readyForReviewMetrics = headerOk && body.length > 0 && !hasPlaceholderRows(body) && body.every(row => value(row['人工复核标签'])) && negativeRows.every(row => value(row['归因类型']));
- const readyForCustomerEffectAudit = readyForReviewMetrics && hasCustomerDecision && hasCurrentManualSupplement;
- return {
- id: 'review',
- file,
- readyForReviewMetrics,
- readyForCustomerEffectAudit,
- counts: {
- rows: body.length,
- negativeRows: negativeRows.length,
- rowsWithCustomerDecision: body.filter(row => FINAL_CUSTOMER_DECISIONS.has(value(row['客户选择']))).length,
- rowsWithCurrentManualSupplement: body.filter(row => toNumberOrNull(row['本轮人工补号量']) !== null).length
- },
- issues
- };
- }
- function auditVideo({ file, rows }) {
- const issues = [];
- const headerOk = headerStartsWith(rows.header, VIDEO_REQUIRED);
- if (!rows.ok) issues.push(issue('read-error', rows.error));
- if (!headerOk) issues.push(issue('header', '视频资源 CSV 表头不符合 video-intake-pack 模板。'));
- const body = rows.body || [];
- if (!body.length) issues.push(issue('empty', '视频资源表没有资源行。'));
- if (hasPlaceholderRows(body)) issues.push(issue('placeholder', '视频资源表仍包含 example.com、示例、候选、待确认或人工待补等模板占位内容。'));
- const realRows = body.filter(row => isYes(row['是否真实资源']));
- const realReferences = realRows.filter(row => value(row['资源角色']) === '参考视频');
- const realCandidates = realRows.filter(row => value(row['资源角色']) === '候选视频');
- if (!realReferences.length) issues.push(issue('real-reference', '缺少真实参考视频资源。'));
- if (!realCandidates.length) issues.push(issue('real-candidate', '缺少真实候选视频资源。'));
- if (!realRows.some(row => isLikelyRealUrl(row['视频链接']))) issues.push(issue('video-url', '缺少真实视频 URL。'));
- if (!realRows.every(row => hasVideoEvidence(row))) issues.push(issue('video-evidence', '真实视频资源必须至少有视频 URL、封面、ASR、帧图或正文证据之一。'));
- return {
- id: 'video',
- file,
- ready: issues.length === 0,
- counts: {
- rows: body.length,
- realRows: realRows.length,
- realReferenceRows: realReferences.length,
- realCandidateRows: realCandidates.length,
- realVideoUrlRows: realRows.filter(row => isLikelyRealUrl(row['视频链接'])).length
- },
- issues
- };
- }
- function renderReport(summary) {
- const lines = [
- '# 真实材料 Intake Readiness 审计',
- '',
- `- 生成时间:${summary.generatedAt}`,
- `- 是否可进入后续真实审计:${summary.acceptance.overallReady ? '是' : '否'}`,
- `- failureCount:${summary.failureCount}`,
- '',
- '## 文件',
- '',
- `- 历史数据:${summary.files.historyCsv}`,
- `- 人工复核:${summary.files.reviewCsv}`,
- `- 视频资源:${summary.files.videoCsv}`,
- '',
- '## 门禁',
- '',
- '| 门禁 | 状态 |',
- '| --- | --- |',
- `| 历史数据可进入 history:from-csv/history:audit | ${passFail(summary.history.ready)} |`,
- `| 视频资源可进入 video:resource-readiness | ${passFail(summary.video.ready)} |`,
- `| 人工复核可进入 review:metrics | ${passFail(summary.review.readyForReviewMetrics)} |`,
- `| 人工复核可进入 customer-effect:audit | ${passFail(summary.review.readyForCustomerEffectAudit)} |`,
- '',
- '## 统计',
- '',
- '| 区域 | 指标 | 数量 |',
- '| --- | --- | ---: |',
- `| 历史数据 | 行数 | ${summary.history.counts.rows} |`,
- `| 历史数据 | Brief 数 | ${summary.history.counts.briefCount} |`,
- `| 人工复核 | 行数 | ${summary.review.counts.rows} |`,
- `| 人工复核 | 有客户选择行 | ${summary.review.counts.rowsWithCustomerDecision} |`,
- `| 视频资源 | 真实参考视频 | ${summary.video.counts.realReferenceRows} |`,
- `| 视频资源 | 真实候选视频 | ${summary.video.counts.realCandidateRows} |`,
- '',
- '## 问题',
- '',
- summary.issues.length
- ? '| 区域 | 类型 | 说明 |\n| --- | --- | --- |\n' + summary.issues.map(item => `| ${item.section} | ${item.type} | ${escapeCell(item.message)} |`).join('\n')
- : '- 暂无问题。',
- '',
- '## 修复清单',
- '',
- summary.repairActions.length
- ? '| 优先级 | 负责人 | 文件 | 字段 | 修复动作 | 通过标准 |\n| ---: | --- | --- | --- | --- | --- |\n' + summary.repairActions.map(item => `| ${item.priority} | ${item.owner} | ${escapeCell(item.file)} | ${escapeCell(item.field)} | ${escapeCell(item.action)} | ${escapeCell(item.acceptance)} |`).join('\n')
- : '- 暂无修复动作。',
- '',
- '## 后续命令',
- '',
- '```powershell',
- 'npm run history:from-csv -- --input <history-data-template.csv> --output <history-dataset目录>',
- 'npm run history:audit -- --input <history-dataset目录> --output <历史审计输出目录> --strict',
- 'npm run video:resource-readiness -- --input <video-resource-template.csv> --output <视频资源审计输出目录> --strict',
- 'npm run review:metrics -- --input <manual-review-template.csv> --output <复核指标输出目录> --strict',
- 'npm run customer-effect:audit -- --review-csv <manual-review-template.csv> --history-audit <historical-dataset-audit.json> --current-manual-supplement-count <本轮人工补号量> --output <客户效果输出目录> --strict',
- '```',
- '',
- '## 边界',
- '',
- ...summary.guardrails.map(item => `- ${item}`)
- ];
- return lines.join('\n');
- }
- function buildRepairActions({ issues, files }) {
- return issues.map((item, index) => {
- const spec = repairSpec(item);
- return {
- priority: index + 1,
- section: item.section,
- type: item.type,
- owner: spec.owner,
- file: spec.file === 'history' ? files.historyCsv : spec.file === 'review' ? files.reviewCsv : files.videoCsv,
- field: spec.field,
- action: spec.action,
- acceptance: spec.acceptance
- };
- });
- }
- function repairSpec(issueItem) {
- const key = `${issueItem.section}:${issueItem.type}`;
- const specs = {
- 'history:min-briefs': ['商务', 'history', 'brief编号', '补齐至少 5 个真实历史 Brief,覆盖不同项目或不同客户需求。', 'historyReady=true,真实历史 Brief 数 >= 5。'],
- 'history:placeholder': ['商务', 'history', '全表', '删除 example.com、示例、候选、待补等占位内容,替换为真实客户材料。', '预审不再出现 placeholder。'],
- 'history:brief-text': ['商务', 'history', '客户原始Brief', '填写足够具体的客户原始需求,至少覆盖类目、人群、平台、内容方向或禁投项。', '每行客户原始 Brief 长度和信息量达标。'],
- 'history:manual-baseline': ['商务', 'history', '历史人工补号量基线', '填写数字,表示历史同类需求人工额外补号数量。', '每行都能解析为非负数字。'],
- 'history:reference': ['商务', 'history', '参考账号或视频', '填写真实参考账号主页或参考视频链接,不只写昵称或截图说明。', '每行都有真实可识别 URL。'],
- 'history:manual-list': ['商务', 'history', '博主名称/主页链接', '填写人工最终名单中的真实博主名称和主页链接。', '每行博主名称非空且主页链接为真实 URL。'],
- 'history:customer-decision': ['商务', 'history', '客户选择', '把待反馈替换为客户最终选中或客户拒绝。', '每行客户选择为客户选中或客户拒绝。'],
- 'history:reject-reason': ['商务', 'history', '拒绝原因', '客户拒绝样本填写具体拒绝原因。', '所有拒绝样本拒绝原因非空。'],
- 'review:empty': ['商务', 'review', '全表', '把本轮 AI 候选名单粘贴到人工复核模板。', '人工复核表至少有一行候选。'],
- 'review:placeholder': ['商务', 'review', '全表', '删除 example.com、示例、候选、待补等占位内容,替换为真实候选和复核结果。', '预审不再出现 placeholder。'],
- 'review:review-label': ['商务', 'review', '人工复核标签', '为每个候选填写人工复核标签。', '每行人工复核标签非空。'],
- 'review:attribution': ['商务', 'review', '归因类型', '负样本填写需求解析错、隐性规则漏、召回关键词错、主页证据不足、视频证据误判、排序权重错或输出解释错。', '负样本归因覆盖率=100%。'],
- 'review:customer-decision': ['商务', 'review', '客户选择', '补客户最终选中或客户拒绝记录。', '至少有客户最终选择,客户效果审计字段齐全。'],
- 'review:current-manual-supplement': ['商务', 'review', '本轮人工补号量', '填写本轮 AI 输出后仍需人工补号的数量。', '至少一行本轮人工补号量为非负数字。'],
- 'video:empty': ['商务/投放', 'video', '全表', '填写真实参考视频和真实候选视频资源。', '视频资源表至少有参考视频和候选视频。'],
- 'video:placeholder': ['商务/投放', 'video', '全表', '删除 example.com、示例、候选、待确认或人工待补等占位内容。', '预审不再出现 placeholder。'],
- 'video:real-reference': ['商务/投放', 'video', '资源角色/是否真实资源', '补至少一个真实参考视频,并将是否真实资源标为是。', '真实参考视频行数 >= 1。'],
- 'video:real-candidate': ['商务/投放', 'video', '资源角色/是否真实资源', '补至少一个真实候选视频,并将是否真实资源标为是。', '真实候选视频行数 >= 1。'],
- 'video:video-url': ['商务/投放', 'video', '视频链接', '补真实视频 URL;只有主页链接或接口 200 不算视频资源。', '真实资源中至少一行有真实视频 URL。'],
- 'video:video-evidence': ['商务/投放', 'video', '封面链接/字幕或ASR文本/帧图链接/内容摘要', '为真实视频补封面、ASR、帧图或正文证据。', '真实资源至少有一类可审计证据。']
- };
- const fallback = specs[key] || ['技术/AI', issueItem.section === 'video' ? 'video' : issueItem.section === 'review' ? 'review' : 'history', issueItem.type, issueItem.message, '对应 issue 消失。'];
- return {
- owner: fallback[0],
- file: fallback[1],
- field: fallback[2],
- action: fallback[3],
- acceptance: fallback[4]
- };
- }
- function renderRepairCsv(actions) {
- const header = ['优先级', '负责人', '区域', '问题类型', '文件', '字段', '修复动作', '通过标准'];
- const rows = actions.map(item => [
- item.priority,
- item.owner,
- item.section,
- item.type,
- item.file,
- item.field,
- item.action,
- item.acceptance
- ]);
- return [header, ...rows].map(row => row.map(csvCell).join(',')).join('\n');
- }
- function csvCell(value) {
- const text = String(value ?? '');
- return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
- }
- function safeReadCsv(reader, file) {
- try {
- if (!file || !fs.existsSync(file)) return { ok: false, header: [], body: [], error: `文件不存在:${file}` };
- const rows = reader(file);
- return { ok: true, header: rows.header || [], body: rows.body || [] };
- } catch (error) {
- return { ok: false, header: [], body: [], error: error.message };
- }
- }
- function headerStartsWith(header, required) {
- return required.every((key, index) => header[index] === key);
- }
- function hasPlaceholderRows(rows) {
- return rows.some(row => hasPlaceholderText(Object.values(row).join(' ')));
- }
- function hasPlaceholderText(text) {
- return /(example\.com|示例|候选博主|候选视频标题|待确认|人工待补|填写客户原始 Brief|暂时只有封面|内部鉴权|密钥)/i.test(String(text || ''));
- }
- function isLikelyRealReference(input) {
- return isLikelyRealUrl(input) && !hasPlaceholderText(input);
- }
- function isLikelyRealUrl(input) {
- const text = value(input);
- return /^https?:\/\/\S+/i.test(text) && !/example\.com/i.test(text);
- }
- function hasVideoEvidence(row) {
- return isLikelyRealUrl(row['视频链接']) ||
- isLikelyRealUrl(row['封面链接']) ||
- isLikelyRealUrl(row['帧图链接']) ||
- value(row['字幕或ASR文本']).length >= 12 ||
- value(row['内容摘要']).length >= 12;
- }
- function isYes(input) {
- return ['是', 'true', 'yes', '1'].includes(value(input).toLowerCase());
- }
- function issue(type, message) {
- return { type, message };
- }
- function value(input) {
- return String(input || '').trim();
- }
- function toNumberOrNull(input) {
- const text = value(input);
- if (!text) return null;
- const numeric = Number(text);
- return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
- }
- function unique(values) {
- return [...new Set(values.map(value).filter(Boolean))];
- }
- function passFail(value) {
- return value ? '通过' : '未通过';
- }
- function escapeCell(input) {
- return String(input || '').replace(/\|/g, '/').replace(/\r?\n/g, ' ');
- }
- function parseArgs(argv) {
- const args = {};
- for (let index = 0; index < argv.length; index += 1) {
- const raw = argv[index];
- if (!raw.startsWith('--')) continue;
- const key = raw.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase());
- const next = argv[index + 1];
- if (!next || next.startsWith('--')) args[key] = true;
- else {
- args[key] = next;
- index += 1;
- }
- }
- return args;
- }
- function withBom(text) {
- return `\uFEFF${text}`;
- }
- if (require.main === module) {
- try {
- main();
- } catch (error) {
- console.error(error && error.stack ? error.stack : String(error));
- process.exit(1);
- }
- }
- module.exports = {
- buildIntakeReadinessSummary,
- renderReport
- };
|