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>
130 lines
6.2 KiB
TypeScript
130 lines
6.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const sourcePath = join(root, 'data', 'sources', 'claims', 'claims-library.json')
|
|
const outRoot = join(root, 'content_population_exports')
|
|
const clinicalRequestPath = join(outRoot, 'clinical_claims_request.csv')
|
|
mkdirSync(outRoot, { recursive: true })
|
|
|
|
const requiredFields = [
|
|
'handle', 'skus', 'name_internal', 'heading', 'description',
|
|
'stat_1_value', 'stat_1_name', 'stat_2_value', 'stat_2_name',
|
|
'stat_3_value', 'stat_3_name', 'disclaimer', 'citation', 'approval_owner', 'approval_date'
|
|
]
|
|
|
|
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 parseClinicalRequestRows() {
|
|
if (!existsSync(clinicalRequestPath)) return []
|
|
const lines = readFileSync(clinicalRequestPath, 'utf8').trim().split(/\r?\n/)
|
|
const headers = lines.shift()?.split(',') || []
|
|
// This is a lightweight hint reader only; values containing commas are not needed for template validity.
|
|
return lines.map(line => {
|
|
const cells = line.split(',')
|
|
return Object.fromEntries(headers.map((header, index) => [header, cells[index] || '']))
|
|
})
|
|
}
|
|
function slug(value: string) {
|
|
return String(value || '').toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)
|
|
}
|
|
|
|
const requestRows = parseClinicalRequestRows()
|
|
const templateClaims = requestRows.slice(0, 6).map((row: any) => ({
|
|
handle: slug(`${row.SKU}-${row.CandidateTheme}`),
|
|
skus: [row.SKU].filter(Boolean),
|
|
name_internal: row.CandidateTheme || 'Internal claim name',
|
|
heading: 'Clinically shown to...',
|
|
description: 'Replace with source-backed approved claim copy. Do not use Feefo/customer language here.',
|
|
stat_1_value: '',
|
|
stat_1_name: '',
|
|
stat_2_value: '',
|
|
stat_2_name: '',
|
|
stat_3_value: '',
|
|
stat_3_name: '',
|
|
disclaimer: '*Replace with approved disclaimer and citation context.',
|
|
citation: {
|
|
source_title: '',
|
|
source_url: '',
|
|
study_or_regulatory_reference: '',
|
|
evidence_type: ''
|
|
},
|
|
approval_owner: '',
|
|
approval_date: '',
|
|
status: 'template_only_not_approved'
|
|
}))
|
|
const template = {
|
|
generatedAt: new Date().toISOString(),
|
|
instructions: [
|
|
'Save the approved file as data/sources/claims/claims-library.json.',
|
|
'Each claim must be source-backed and approved by the compliance/regulatory owner.',
|
|
'Customer/Feefo language can inform benefit copy but must not be used as clinical evidence.'
|
|
],
|
|
claims: templateClaims.length ? templateClaims : [{
|
|
handle: 'example-claim-handle', skus: ['JV-EXAMPLE'], name_internal: 'Internal claim name', heading: 'Clinically shown to...', description: '',
|
|
stat_1_value: '', stat_1_name: '', stat_2_value: '', stat_2_name: '', stat_3_value: '', stat_3_name: '', disclaimer: '',
|
|
citation: { source_title: '', source_url: '', study_or_regulatory_reference: '', evidence_type: '' }, approval_owner: '', approval_date: '', status: 'template_only_not_approved'
|
|
}]
|
|
}
|
|
writeFileSync(join(outRoot, 'claims_library_template.json'), JSON.stringify(template, null, 2) + '\n', 'utf8')
|
|
|
|
const library = readJson(sourcePath, null)
|
|
const issues: any[] = []
|
|
let claims: any[] = []
|
|
if (!library) {
|
|
issues.push({ severity: 'blocked', claim: '', field: 'claims-library.json', issue: `Missing file at ${sourcePath}` })
|
|
} else if (!Array.isArray(library.claims)) {
|
|
issues.push({ severity: 'error', claim: '', field: 'claims', issue: 'claims must be an array' })
|
|
} else {
|
|
claims = library.claims
|
|
const handles = new Set<string>()
|
|
for (const claim of claims) {
|
|
const claimName = claim.handle || claim.name_internal || '(missing handle)'
|
|
for (const field of requiredFields) {
|
|
const value = claim[field]
|
|
if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) {
|
|
issues.push({ severity: 'error', claim: claimName, field, issue: 'required field missing/empty' })
|
|
}
|
|
}
|
|
if (claim.handle && handles.has(claim.handle)) issues.push({ severity: 'error', claim: claimName, field: 'handle', issue: 'duplicate handle' })
|
|
if (claim.handle) handles.add(claim.handle)
|
|
if (claim.skus && !Array.isArray(claim.skus)) issues.push({ severity: 'error', claim: claimName, field: 'skus', issue: 'skus must be an array' })
|
|
if (claim.citation && typeof claim.citation === 'object') {
|
|
for (const field of ['source_title', 'source_url', 'study_or_regulatory_reference', 'evidence_type']) {
|
|
if (!claim.citation[field]) issues.push({ severity: 'error', claim: claimName, field: `citation.${field}`, issue: 'citation field missing/empty' })
|
|
}
|
|
} else {
|
|
issues.push({ severity: 'error', claim: claimName, field: 'citation', issue: 'citation must be an object' })
|
|
}
|
|
if (String(claim.status || '').includes('template')) issues.push({ severity: 'error', claim: claimName, field: 'status', issue: 'template claim cannot be imported as approved evidence' })
|
|
}
|
|
}
|
|
|
|
const manifest = {
|
|
generatedAt: new Date().toISOString(),
|
|
source: sourcePath,
|
|
template: join(outRoot, 'claims_library_template.json'),
|
|
claimCount: claims.length,
|
|
issueCount: issues.length,
|
|
ready: claims.length > 0 && issues.length === 0,
|
|
issues
|
|
}
|
|
writeFileSync(join(outRoot, 'claims_library_validation.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'claims_library_validation.csv'), csv(issues.length ? issues : [{ severity: 'pass', claim: '', field: '', issue: 'No issues' }], ['severity', 'claim', 'field', 'issue']), 'utf8')
|
|
console.log(`Claims library ready: ${manifest.ready}`)
|
|
console.log(`Claims: ${manifest.claimCount}`)
|
|
console.log(`Issues: ${manifest.issueCount}`)
|
|
if (!manifest.ready) process.exitCode = 1
|