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>
198 lines
7.9 KiB
TypeScript
198 lines
7.9 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const contentRoot = join(root, 'data', 'content')
|
|
const productsRoot = join(contentRoot, 'products')
|
|
const decisionRoot = join(contentRoot, 'compliance')
|
|
const decisionPath = join(decisionRoot, 'clinical-blank-decisions.json')
|
|
const decisionCsvPath = join(decisionRoot, 'clinical-blank-decisions.csv')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const generatedAt = new Date().toISOString()
|
|
|
|
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 (Array.isArray(value)) value = value.join(' | ')
|
|
if (value === undefined || value === null) value = ''
|
|
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'
|
|
}
|
|
function parseCsv(text: string) {
|
|
const rows: string[][] = []
|
|
let row: string[] = []
|
|
let cell = ''
|
|
let quoted = false
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text[i]
|
|
const next = text[i + 1]
|
|
if (quoted && ch === '"' && next === '"') {
|
|
cell += '"'
|
|
i += 1
|
|
} else if (ch === '"') {
|
|
quoted = !quoted
|
|
} else if (!quoted && ch === ',') {
|
|
row.push(cell)
|
|
cell = ''
|
|
} else if (!quoted && (ch === '\n' || ch === '\r')) {
|
|
if (ch === '\r' && next === '\n') i += 1
|
|
row.push(cell)
|
|
if (row.some(value => value !== '')) rows.push(row)
|
|
row = []
|
|
cell = ''
|
|
} else {
|
|
cell += ch
|
|
}
|
|
}
|
|
if (cell || row.length) {
|
|
row.push(cell)
|
|
if (row.some(value => value !== '')) rows.push(row)
|
|
}
|
|
const headers = rows.shift() || []
|
|
return rows.map(values => Object.fromEntries(headers.map((header, index) => [header.replace(/^\uFEFF/, ''), values[index] ?? ''])))
|
|
}
|
|
function bool(value: any) {
|
|
return value === true || String(value || '').toLowerCase() === 'true' || String(value || '').toLowerCase() === 'yes'
|
|
}
|
|
|
|
mkdirSync(decisionRoot, { recursive: true })
|
|
mkdirSync(outRoot, { recursive: true })
|
|
|
|
const existing = readJson(decisionPath, { decisions: [] })
|
|
const csvExisting = existsSync(decisionCsvPath)
|
|
? parseCsv(readFileSync(decisionCsvPath, 'utf8')).map((row: any) => ({
|
|
sku: row.sku || row.SKU,
|
|
decision: row.decision || row.Decision,
|
|
approvedBy: row.approvedBy || row.ApprovedBy,
|
|
approvedAt: row.approvedAt || row.ApprovedAt,
|
|
rationale: row.rationale || row.Rationale,
|
|
evidencePath: row.evidencePath || row.EvidencePath,
|
|
constraintsAcknowledged: bool(row.constraintsAcknowledged || row.ConstraintsAcknowledged),
|
|
note: row.note || row.Note
|
|
}))
|
|
: []
|
|
const existingBySku = new Map([...(existing.decisions || []), ...csvExisting].map((row: any) => [row.sku, row]))
|
|
const products = readdirSync(productsRoot)
|
|
.filter(file => file.endsWith('.json'))
|
|
.sort()
|
|
.map(file => readJson(join(productsRoot, file), {}))
|
|
|
|
const decisions = products
|
|
.filter(product => !product.metafields?.pdpClinicallyShownTo)
|
|
.map(product => {
|
|
const previous: any = existingBySku.get(product.sku) || {}
|
|
return {
|
|
sku: product.sku,
|
|
productName: product.name || '',
|
|
handle: product.handle || '',
|
|
decision: previous.decision || 'pending',
|
|
approvedBy: previous.approvedBy || '',
|
|
approvedAt: previous.approvedAt || '',
|
|
rationale: previous.rationale || '',
|
|
evidencePath: previous.evidencePath || `data/intelligence/${product.sku}/claims-evidence.json`,
|
|
constraintsAcknowledged: Boolean(previous.constraintsAcknowledged),
|
|
note: previous.note || 'Use decision=approved_blank only when compliance explicitly approves that this SKU should ship without pdp.clinically_shown_to. Do not use this to bypass missing evidence.'
|
|
}
|
|
})
|
|
|
|
const validBlankApprovals = decisions.filter(row =>
|
|
row.decision === 'approved_blank' &&
|
|
row.approvedBy &&
|
|
row.approvedAt &&
|
|
row.rationale &&
|
|
row.constraintsAcknowledged === true
|
|
)
|
|
const invalidApprovedBlank = decisions.filter(row =>
|
|
row.decision === 'approved_blank' &&
|
|
!validBlankApprovals.some(valid => valid.sku === row.sku)
|
|
)
|
|
|
|
const decisionFile = {
|
|
generatedAt,
|
|
purpose: 'Compliance-owner decisions for SKUs where clinically_shown_to is intentionally blank.',
|
|
allowedDecisions: ['pending', 'approved_blank', 'requires_source_backed_claim'],
|
|
guardrail: 'A blank clinical-claim field is export-safe only when decision=approved_blank, approvedBy/approvedAt/rationale are filled, and constraintsAcknowledged=true.',
|
|
decisions
|
|
}
|
|
writeFileSync(decisionPath, JSON.stringify(decisionFile, null, 2) + '\n', 'utf8')
|
|
|
|
const rows = decisions.map(row => ({
|
|
SKU: row.sku,
|
|
ProductName: row.productName,
|
|
Decision: row.decision,
|
|
ApprovedBy: row.approvedBy,
|
|
ApprovedAt: row.approvedAt,
|
|
ConstraintsAcknowledged: row.constraintsAcknowledged ? 'yes' : 'no',
|
|
ValidBlankApproval: validBlankApprovals.some(valid => valid.sku === row.sku) ? 'yes' : 'no',
|
|
Rationale: row.rationale,
|
|
EvidencePath: row.evidencePath,
|
|
Note: row.note
|
|
}))
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: decisions.length > 0 && validBlankApprovals.length === decisions.length,
|
|
decisionFile: 'data/content/compliance/clinical-blank-decisions.json',
|
|
counts: {
|
|
decisions: decisions.length,
|
|
pending: decisions.filter(row => row.decision === 'pending').length,
|
|
requiresSourceBackedClaim: decisions.filter(row => row.decision === 'requires_source_backed_claim').length,
|
|
validBlankApprovals: validBlankApprovals.length,
|
|
invalidApprovedBlank: invalidApprovedBlank.length
|
|
},
|
|
guardrail: decisionFile.guardrail,
|
|
rows
|
|
}
|
|
|
|
writeFileSync(join(outRoot, 'clinical_blank_decision_template.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'clinical_blank_decision_template.csv'), csv(rows, ['SKU', 'ProductName', 'Decision', 'ApprovedBy', 'ApprovedAt', 'ConstraintsAcknowledged', 'ValidBlankApproval', 'Rationale', 'EvidencePath', 'Note']), 'utf8')
|
|
writeFileSync(decisionCsvPath, csv(rows.map(row => ({
|
|
sku: row.SKU,
|
|
productName: row.ProductName,
|
|
decision: row.Decision,
|
|
approvedBy: row.ApprovedBy,
|
|
approvedAt: row.ApprovedAt,
|
|
rationale: row.Rationale,
|
|
evidencePath: row.EvidencePath,
|
|
constraintsAcknowledged: row.ConstraintsAcknowledged,
|
|
note: row.Note
|
|
})), ['sku', 'productName', 'decision', 'approvedBy', 'approvedAt', 'rationale', 'evidencePath', 'constraintsAcknowledged', 'note']), 'utf8')
|
|
writeFileSync(join(outRoot, 'clinical_blank_decision_template.md'), [
|
|
'# Clinical blank-decision template',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
'This is the approval template for SKUs that should intentionally ship with a blank `pdp.clinically_shown_to` field. It does not approve anything by itself.',
|
|
'',
|
|
`Decision file: \`${manifest.decisionFile}\``,
|
|
`Editable CSV: \`data/content/compliance/clinical-blank-decisions.csv\``,
|
|
'',
|
|
'## Guardrail',
|
|
manifest.guardrail,
|
|
'',
|
|
'## Counts',
|
|
`- Decisions: ${manifest.counts.decisions}`,
|
|
`- Pending: ${manifest.counts.pending}`,
|
|
`- Requires source-backed claim: ${manifest.counts.requiresSourceBackedClaim}`,
|
|
`- Valid blank approvals: ${manifest.counts.validBlankApprovals}`,
|
|
`- Invalid approved_blank rows: ${manifest.counts.invalidApprovedBlank}`,
|
|
'',
|
|
'## How to use',
|
|
'1. Compliance owner reviews each SKU.',
|
|
'2. Set `decision` to `approved_blank` only if the clinical-claim module should be omitted for that SKU.',
|
|
'3. Fill `approvedBy`, `approvedAt`, `rationale`, and set `constraintsAcknowledged` to `true`.',
|
|
'4. Re-run `bun run compliance:blank-decisions && bun run export:content`.',
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
console.log(`Clinical blank decisions: ${decisions.length}`)
|
|
console.log(`Valid blank approvals: ${validBlankApprovals.length}`)
|
|
if (!manifest.ready) process.exitCode = 1
|