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>
124 lines
5.9 KiB
TypeScript
124 lines
5.9 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
|
|
const root = process.cwd()
|
|
const productsRoot = join(root, 'data', 'content', 'products')
|
|
const workflowRoot = join(root, 'data', 'content', 'workflow')
|
|
const intelRoot = join(root, 'data', 'intelligence')
|
|
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 compact(value: any) {
|
|
if (!value) return ''
|
|
if (Array.isArray(value)) return value
|
|
.filter(Boolean)
|
|
.slice(0, 6)
|
|
.map(item => {
|
|
if (typeof item !== 'object') return String(item)
|
|
return item.aspect || item.theme || item.motivation || item.profile || item.summary || item.extractionTask || JSON.stringify(item)
|
|
})
|
|
.join(' | ')
|
|
if (typeof value === 'object') {
|
|
return Object.values(value).flat().filter(Boolean).slice(0, 6).map(item => {
|
|
if (typeof item !== 'object') return String(item)
|
|
return item.aspect || item.theme || item.motivation || item.profile || item.summary || item.extractionTask || JSON.stringify(item)
|
|
}).join(' | ')
|
|
}
|
|
return String(value)
|
|
}
|
|
|
|
const rows: any[] = []
|
|
|
|
for (const file of readdirSync(productsRoot).filter(file => file.endsWith('.json')).sort()) {
|
|
const product = readJson(join(productsRoot, file), {})
|
|
const sku = product.sku || file.replace(/\.json$/, '')
|
|
const mappedRef = product.metafields?.pdpClinicallyShownTo || ''
|
|
const claimsEvidence = readJson(join(intelRoot, sku, 'claims-evidence.json'), {})
|
|
const audience = readJson(join(intelRoot, sku, 'audience-profile.json'), {})
|
|
const aspects = readJson(join(intelRoot, sku, 'review-aspects.json'), {})
|
|
const workflow = readJson(join(workflowRoot, `${sku}.json`), {})
|
|
const clinicalField = workflow.fields?.pdpClinicallyShownTo || {}
|
|
|
|
if (mappedRef) continue
|
|
|
|
rows.push({
|
|
SKU: sku,
|
|
ProductName: product.name || '',
|
|
Handle: product.handle || '',
|
|
CurrentClinicalRef: mappedRef,
|
|
ComplianceStatus: product.approval?.compliance || workflow.approval?.compliance || '',
|
|
FieldStatus: clinicalField.status || '',
|
|
EvidenceGate: claimsEvidence.complianceGate || 'missing_source_backed_claim',
|
|
CandidateThemes: compact(claimsEvidence.candidateNonClinicalThemes || claimsEvidence.themes || claimsEvidence.claimThemes || claimsEvidence.claimsNeeded),
|
|
AudienceSignals: compact(audience.profiles || audience.motivations || audience.audiences || audience.summary),
|
|
ReviewSignals: compact(aspects.aspects || aspects.topAspects || aspects.summary),
|
|
RequiredDecision: 'Compliance owner must either map a source-backed authorised claim from data/sources/claims/claims-library.json or explicitly approve leaving clinically_shown_to blank.',
|
|
SafeDefault: 'blank_no_fake_claim',
|
|
SuggestedNextAction: 'Review product label/PDP, identify authorised nutrient/function claim if applicable, add cited claim to claims library, then rerun bun run claims:sync-content.',
|
|
SourceFiles: [`data/intelligence/${sku}/claims-evidence.json`, `data/content/products/${sku}.json`]
|
|
})
|
|
}
|
|
|
|
const manifest = {
|
|
generatedAt,
|
|
ready: rows.length === 0,
|
|
purpose: 'Actionable compliance review packet for SKUs intentionally left blank because no source-backed clinically_shown_to claim exists.',
|
|
rule: 'This packet must not be used to auto-generate clinical claims. It exists to route the remaining compliance decisions to a human/regulatory owner.',
|
|
counts: {
|
|
unmatchedProducts: rows.length,
|
|
totalProducts: readdirSync(productsRoot).filter(file => file.endsWith('.json')).length
|
|
},
|
|
rows
|
|
}
|
|
|
|
const headers = ['SKU', 'ProductName', 'Handle', 'CurrentClinicalRef', 'ComplianceStatus', 'FieldStatus', 'EvidenceGate', 'CandidateThemes', 'AudienceSignals', 'ReviewSignals', 'RequiredDecision', 'SafeDefault', 'SuggestedNextAction', 'SourceFiles']
|
|
writeFileSync(join(outRoot, 'unmatched_claims_review_packet.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8')
|
|
writeFileSync(join(outRoot, 'unmatched_claims_review_packet.csv'), csv(rows, headers), 'utf8')
|
|
|
|
const md = [
|
|
'# Unmatched clinically_shown_to review packet',
|
|
'',
|
|
`Generated: ${generatedAt}`,
|
|
'',
|
|
'This packet lists every SKU where `pdp.clinically_shown_to` is deliberately blank because the dashboard does not have source-backed claim evidence. Do not fill these with generic/fake claims.',
|
|
'',
|
|
`Unmatched products: ${rows.length}`,
|
|
'',
|
|
'## Required decision',
|
|
'For each SKU, the compliance owner must either map a cited authorised claim into `data/sources/claims/claims-library.json` or approve leaving `clinically_shown_to` blank.',
|
|
'',
|
|
'## Rows',
|
|
...rows.map(row => [
|
|
`### ${row.SKU} - ${row.ProductName}`,
|
|
`- Evidence gate: ${row.EvidenceGate}`,
|
|
`- Current field status: ${row.FieldStatus || 'unknown'}`,
|
|
`- Candidate themes/signals: ${row.CandidateThemes || row.AudienceSignals || row.ReviewSignals || 'none captured'}`,
|
|
`- Required decision: ${row.RequiredDecision}`,
|
|
`- Safe default: ${row.SafeDefault}`,
|
|
''
|
|
].join('\n')),
|
|
'## Regenerate after review',
|
|
'After claims are added or blank decisions are approved, run `bun run claims:sync-content && bun run claims:unmatched-review && bun run export:content && bun run audit:content`.',
|
|
''
|
|
].join('\n')
|
|
writeFileSync(join(outRoot, 'unmatched_claims_review_packet.md'), md, 'utf8')
|
|
|
|
console.log(`Unmatched clinically_shown_to products: ${rows.length}`)
|
|
if (!manifest.ready) process.exitCode = 1
|