056c47581f
Ships the second dashboard surface — a Pattern Library + Preview Theatre — that presents the 4-section PDP pilot batch back to Umar, compliance, and the board in an editorial format. Adds the full data layer that drives it: 5 source-backed per-SKU drafts at QA 100/100, 15 competitor PDP semantic extracts, PubMed evidence packs, EFSA claims library extension, JV brand voice guide, hand-curated product FAQs, and the Matrixify-ready CSV exports for Lewis. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
9.5 KiB
TypeScript
190 lines
9.5 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const approvalRoot = join(root, 'data', 'sources', 'approvals')
|
|
mkdirSync(outRoot, { recursive: true })
|
|
mkdirSync(approvalRoot, { recursive: true })
|
|
|
|
function readJson(path: string, fallback: any = null) {
|
|
if (!existsSync(path)) return fallback
|
|
return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''))
|
|
}
|
|
function csv(rows: any[], headers: string[]) {
|
|
const esc = (value: any) => {
|
|
if (value === undefined || value === null) value = ''
|
|
if (Array.isArray(value)) value = value.join(' | ')
|
|
value = String(value)
|
|
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
|
}
|
|
return [headers.join(','), ...rows.map(row => headers.map(header => esc(row[header])).join(','))].join('\n') + '\n'
|
|
}
|
|
|
|
const generatedAt = new Date().toISOString()
|
|
const budgetGate = readJson(join(outRoot, 'source_acquisition_budget_gate.json'), {})
|
|
const costPlan = readJson(join(outRoot, 'source_acquisition_cost_plan.json'), {})
|
|
const dataforseoPilot = readJson(join(outRoot, 'dataforseo_pilot_manifest.json'), {})
|
|
const apifyGapfill = readJson(join(outRoot, 'apify_gapfill_manifest.json'), {})
|
|
const dataforseoReview = readJson(join(outRoot, 'dataforseo_pilot_review.json'), {})
|
|
const budget = budgetGate.budget || {}
|
|
const currency = budget.currency || 'GBP'
|
|
|
|
const lanes = [
|
|
{
|
|
key: 'dataforseoStage1',
|
|
provider: 'DataForSEO',
|
|
stage: 1,
|
|
approvalFile: 'dataforseo-stage1-approval.json',
|
|
budgetLane: budget.dataforseoStage1 || {},
|
|
expectedScope: 'DataForSEO Stage 1 Amazon Reviews pilot only',
|
|
expectedMaxTargets: Number(dataforseoPilot.counts?.stageOneTargets || budget.dataforseoStage1?.maxTasks || 9),
|
|
expectedRunLimit: 1,
|
|
expectedDepthOrRequests: Number(budget.dataforseoStage1?.maxReviewsPerAsin || 10),
|
|
decisionRef: 'content_population_exports/dataforseo_task_preflight.json'
|
|
},
|
|
{
|
|
key: 'apifyStage2',
|
|
provider: 'Apify',
|
|
stage: 2,
|
|
approvalFile: 'apify-stage2-approval.json',
|
|
budgetLane: budget.apifyStage2 || {},
|
|
expectedScope: 'Apify Stage 2 named gap-fill only after DataForSEO pilot review',
|
|
expectedMaxTargets: Number(apifyGapfill.counts?.firstBatchTargets || budget.apifyStage2?.maxTargets || 3),
|
|
expectedRunLimit: Number(budget.apifyStage2?.maxActorRuns || 1),
|
|
expectedDepthOrRequests: Number(budget.apifyStage2?.maxRequestsPerBrand || 3),
|
|
decisionRef: 'data/sources/reviews/dataforseo-stage1-review.json'
|
|
}
|
|
]
|
|
|
|
function approvalTemplate(lane: any, existing: any = {}) {
|
|
const maxSpend = Number(lane.budgetLane?.maxSpend || 0)
|
|
return {
|
|
provider: lane.provider,
|
|
stage: lane.stage,
|
|
approved: existing.approved === true,
|
|
approvedBy: existing.approvedBy || '',
|
|
approvedAt: existing.approvedAt || '',
|
|
approvalScope: existing.approvalScope || existing.scope || lane.expectedScope,
|
|
budgetRef: existing.budgetRef || 'data/sources/budgets/source-acquisition-budget.json',
|
|
decisionRef: existing.decisionRef || lane.decisionRef,
|
|
currency,
|
|
maxSpendApproved: Number(existing.maxSpendApproved || 0),
|
|
expectedMaxSpend: maxSpend,
|
|
targetCapApproved: Number(existing.targetCapApproved || 0),
|
|
expectedTargetCap: lane.expectedMaxTargets,
|
|
runLimitApproved: Number(existing.runLimitApproved || 0),
|
|
expectedRunLimit: lane.expectedRunLimit,
|
|
depthOrRequestCapApproved: Number(existing.depthOrRequestCapApproved || 0),
|
|
expectedDepthOrRequestCap: lane.expectedDepthOrRequests,
|
|
hardStopAcknowledged: existing.hardStopAcknowledged === true,
|
|
noCredentialStorageAcknowledged: existing.noCredentialStorageAcknowledged === true,
|
|
postRunReviewRequired: existing.postRunReviewRequired !== false,
|
|
namedGapForApify: existing.namedGapForApify || dataforseoReview.review?.namedGapForApify || '',
|
|
notes: existing.notes || ''
|
|
}
|
|
}
|
|
|
|
function validateApproval(lane: any, approval: any) {
|
|
const checks = []
|
|
const maxSpend = Number(lane.budgetLane?.maxSpend || 0)
|
|
checks.push(['Approved flag true', approval.approved === true, approval.approved ? 'approved=true' : 'approved=false'])
|
|
checks.push(['Approver recorded', Boolean(approval.approvedBy), approval.approvedBy || 'missing approver'])
|
|
checks.push(['Approval timestamp recorded', Boolean(approval.approvedAt), approval.approvedAt || 'missing timestamp'])
|
|
checks.push(['Budget lane enabled', lane.budgetLane?.enabled === true, `enabled=${lane.budgetLane?.enabled === true}`])
|
|
checks.push(['Cost plan ready', costPlan.costReady?.[lane.key] === true, costPlan.costReady?.[lane.key] === true ? 'cost plan ready' : `source_acquisition_cost_plan.json not ready for ${lane.provider}`])
|
|
checks.push(['Max spend matches budget cap', maxSpend > 0 && Number(approval.maxSpendApproved || 0) === maxSpend, `${approval.currency || currency} ${Number(approval.maxSpendApproved || 0)} approved / ${maxSpend} budget`])
|
|
checks.push(['Target cap matches staged lane', Number(approval.targetCapApproved || 0) === lane.expectedMaxTargets, `${Number(approval.targetCapApproved || 0)} approved / ${lane.expectedMaxTargets} expected`])
|
|
checks.push(['Run limit matches staged lane', Number(approval.runLimitApproved || 0) === lane.expectedRunLimit, `${Number(approval.runLimitApproved || 0)} approved / ${lane.expectedRunLimit} expected`])
|
|
checks.push(['Depth/request cap matches staged lane', Number(approval.depthOrRequestCapApproved || 0) === lane.expectedDepthOrRequests, `${Number(approval.depthOrRequestCapApproved || 0)} approved / ${lane.expectedDepthOrRequests} expected`])
|
|
checks.push(['Hard stop acknowledged', approval.hardStopAcknowledged === true, `hardStopAcknowledged=${approval.hardStopAcknowledged === true}`])
|
|
checks.push(['No credential storage acknowledged', approval.noCredentialStorageAcknowledged === true, `noCredentialStorageAcknowledged=${approval.noCredentialStorageAcknowledged === true}`])
|
|
if (lane.key === 'apifyStage2') {
|
|
checks.push(['Named Apify gap recorded', Boolean(String(approval.namedGapForApify || '').trim()), approval.namedGapForApify || 'missing named Apify gap'])
|
|
}
|
|
return checks.map(([Check, pass, Evidence]) => ({
|
|
Provider: lane.provider,
|
|
Stage: String(lane.stage),
|
|
Check,
|
|
Status: pass ? 'pass' : 'blocked',
|
|
Blocking: 'yes',
|
|
Evidence
|
|
}))
|
|
}
|
|
|
|
const approvals: any[] = []
|
|
const rows: any[] = []
|
|
for (const lane of lanes) {
|
|
const path = join(approvalRoot, lane.approvalFile)
|
|
const existing = readJson(path, {})
|
|
const approval = approvalTemplate(lane, existing)
|
|
writeFileSync(path, JSON.stringify(approval, null, 2) + '\n', 'utf8')
|
|
const checks = validateApproval(lane, approval)
|
|
approvals.push({
|
|
key: lane.key,
|
|
provider: lane.provider,
|
|
stage: lane.stage,
|
|
file: `data/sources/approvals/${lane.approvalFile}`,
|
|
approved: approval.approved,
|
|
valid: checks.every(row => row.Status === 'pass'),
|
|
approval
|
|
})
|
|
rows.push(...checks)
|
|
}
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: approvals.every(row => row.valid),
|
|
counts: {
|
|
providers: approvals.length,
|
|
approvalsValid: approvals.filter(row => row.valid).length,
|
|
checks: rows.length,
|
|
passing: rows.filter(row => row.Status === 'pass').length,
|
|
blocked: rows.filter(row => row.Status !== 'pass').length
|
|
},
|
|
files: {
|
|
dataforseoApproval: 'data/sources/approvals/dataforseo-stage1-approval.json',
|
|
apifyApproval: 'data/sources/approvals/apify-stage2-approval.json',
|
|
reportMd: 'content_population_exports/source_acquisition_approval_gate.md',
|
|
reportCsv: 'content_population_exports/source_acquisition_approval_gate.csv',
|
|
reportJson: 'content_population_exports/source_acquisition_approval_gate.json'
|
|
},
|
|
guardrails: [
|
|
'Approval is local and explicit; it does not store provider credentials.',
|
|
'Approval must match the budget cap and staged run limits exactly.',
|
|
'A human approval cannot bypass source, budget, provider preflight, or post-run review gates.',
|
|
'Apify approval also requires a named gap from the DataForSEO pilot review.'
|
|
],
|
|
approvals,
|
|
checks: rows,
|
|
nextActions: rows.filter(row => row.Status !== 'pass').map(row => `${row.Provider} Stage ${row.Stage}: ${row.Check} - ${row.Evidence}`)
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'source_acquisition_approval_gate.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'source_acquisition_approval_gate.csv'), csv(rows, ['Provider', 'Stage', 'Check', 'Status', 'Blocking', 'Evidence']), 'utf8')
|
|
writeFileSync(join(outRoot, 'source_acquisition_approval_gate.md'), [
|
|
'# Source acquisition approval gate',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
`Approvals valid: ${manifest.counts.approvalsValid}/${manifest.counts.providers}`,
|
|
`Checks passing: ${manifest.counts.passing}/${manifest.counts.checks}`,
|
|
'',
|
|
'This gate validates the local approval files that must be filled before any paid provider run. It does not call DataForSEO, Apify, or any paid API.',
|
|
'',
|
|
'## Guardrails',
|
|
...manifest.guardrails.map(item => `- ${item}`),
|
|
'',
|
|
'## Checks',
|
|
...rows.map(row => `- ${row.Status === 'pass' ? '[pass]' : '[blocked]'} ${row.Provider} Stage ${row.Stage}: ${row.Check} — ${row.Evidence}`),
|
|
'',
|
|
'## Approval files',
|
|
...approvals.map(row => `- ${row.provider}: \`${row.file}\` (${row.valid ? 'valid' : 'blocked'})`),
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`Source acquisition approval gate ready: ${manifest.ready}`)
|
|
console.log(`Approvals valid: ${manifest.counts.approvalsValid}/${manifest.counts.providers}`)
|
|
console.log(`Checks passing: ${manifest.counts.passing}/${manifest.counts.checks}`)
|