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>
156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Extract review fragments that mention onset/duration ("after 3 weeks", "took a month
|
|
// or so") from _raw_reviews.json for each pilot SKU. Bucket by time-window so the
|
|
// Timeline generator can quote real customer language per phase.
|
|
//
|
|
// Output: data/intelligence/<sku>/review-time-mentions.json
|
|
//
|
|
// Usage: bun scripts/extract-review-time-mentions.ts [SKU1 SKU2 ...]
|
|
|
|
import { join } from 'path'
|
|
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
|
|
|
const ROOT = join(import.meta.dir, '..')
|
|
const PILOT_FILE = join(ROOT, 'data', 'pipeline', 'pilot-skus.json')
|
|
const INTEL = join(ROOT, 'data', 'intelligence')
|
|
|
|
type Review = { comment: string; stars: number }
|
|
type Pilot = { sku: string; name: string; category: string; timelineCadence: 'weeks' | 'months' | string; phases: string[] }
|
|
type Pilots = { skus: Pilot[] }
|
|
|
|
// Patterns that capture an onset duration. Each match is normalised to "days".
|
|
const PATTERNS: { re: RegExp; toDays: (m: RegExpExecArray) => number }[] = [
|
|
{ re: /\b(?:after|in|within|takes?|took|by)\s+(?:about\s+|around\s+|nearly\s+)?(\d{1,3})\s*(day|week|month|year)s?\b/gi, toDays: m => Number(m[1]) * unitDays(m[2]) },
|
|
{ re: /\b(?:a\s+couple|couple)\s+of\s+(week|month|day)s?\b/gi, toDays: m => 2 * unitDays(m[1]) },
|
|
{ re: /\bfew\s+(week|month|day)s?\b/gi, toDays: m => 3 * unitDays(m[1]) },
|
|
{ re: /\b(?:first|second|third|fourth|fifth|sixth)\s+(week|month)\b/gi, toDays: m => 1 * unitDays(m[1]) },
|
|
{ re: /\bover (?:the\s+)?(?:past|last)\s+(\d{1,3})\s*(day|week|month|year)s?\b/gi, toDays: m => Number(m[1]) * unitDays(m[2]) },
|
|
{ re: /\b(\d{1,3})\s*[-/]\s*(\d{1,3})\s*(week|month|day)s?\b/gi, toDays: m => ((Number(m[1]) + Number(m[2])) / 2) * unitDays(m[3]) },
|
|
{ re: /\bday\s*one\b/gi, toDays: () => 1 },
|
|
{ re: /\bovernight\b/gi, toDays: () => 1 },
|
|
{ re: /\bstraight\s*away\b|\bimmediately\b/gi, toDays: () => 1 }
|
|
]
|
|
function unitDays(unit: string): number {
|
|
const u = unit.toLowerCase()
|
|
if (u.startsWith('day')) return 1
|
|
if (u.startsWith('week')) return 7
|
|
if (u.startsWith('month')) return 30
|
|
if (u.startsWith('year')) return 365
|
|
return 7
|
|
}
|
|
|
|
// Map a phase label (e.g. "Week 1-2", "Month 3", "Month 4+") to a [minDays, maxDays] window.
|
|
function phaseToDays(label: string): [number, number] {
|
|
const l = label.toLowerCase()
|
|
const rangeMatch = l.match(/(week|month|day)\s*(\d{1,3})\s*[-/]\s*(\d{1,3})/)
|
|
if (rangeMatch) {
|
|
const u = unitDays(rangeMatch[1])
|
|
return [Number(rangeMatch[2]) * u, Number(rangeMatch[3]) * u]
|
|
}
|
|
const plusMatch = l.match(/(week|month|day)\s*(\d{1,3})\s*\+/)
|
|
if (plusMatch) {
|
|
const u = unitDays(plusMatch[1])
|
|
return [Number(plusMatch[2]) * u, 99 * u]
|
|
}
|
|
const singleMatch = l.match(/(week|month|day)\s*(\d{1,3})/)
|
|
if (singleMatch) {
|
|
const u = unitDays(singleMatch[1])
|
|
const n = Number(singleMatch[2])
|
|
return [(n - 1) * u + 1, n * u]
|
|
}
|
|
return [1, 365]
|
|
}
|
|
|
|
function extractMentions(reviews: Review[]) {
|
|
type Mention = { reviewIdx: number; stars: number; days: number; matchedPhrase: string; sentence: string }
|
|
const out: Mention[] = []
|
|
for (let i = 0; i < reviews.length; i++) {
|
|
const text = reviews[i]?.comment || ''
|
|
if (!text) continue
|
|
// Split into rough sentences for context windowing
|
|
const sentences = text.split(/(?<=[.!?])\s+/)
|
|
for (const sentence of sentences) {
|
|
for (const { re, toDays } of PATTERNS) {
|
|
re.lastIndex = 0
|
|
let m: RegExpExecArray | null
|
|
while ((m = re.exec(sentence)) !== null) {
|
|
const days = toDays(m)
|
|
if (!Number.isFinite(days) || days <= 0 || days > 730) continue
|
|
out.push({
|
|
reviewIdx: i,
|
|
stars: reviews[i]?.stars ?? 0,
|
|
days,
|
|
matchedPhrase: m[0],
|
|
sentence: sentence.trim().slice(0, 280)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Dedupe: per (reviewIdx, sentence) keep the smallest day-value mention
|
|
const seen = new Map<string, Mention>()
|
|
for (const m of out) {
|
|
const k = `${m.reviewIdx}::${m.sentence}`
|
|
const cur = seen.get(k)
|
|
if (!cur || m.days < cur.days) seen.set(k, m)
|
|
}
|
|
return [...seen.values()].sort((a, b) => a.days - b.days)
|
|
}
|
|
|
|
function bucketByPhase(mentions: ReturnType<typeof extractMentions>, phases: string[]) {
|
|
const buckets: Record<string, typeof mentions> = {}
|
|
for (const p of phases) buckets[p] = []
|
|
// Build phase windows in order
|
|
const windows = phases.map(p => ({ label: p, range: phaseToDays(p) }))
|
|
for (const m of mentions) {
|
|
const w = windows.find(w => m.days >= w.range[0] && m.days <= w.range[1])
|
|
if (w) buckets[w.label].push(m)
|
|
}
|
|
return { buckets, windows }
|
|
}
|
|
|
|
function processSku(skuInfo: Pilot) {
|
|
const reviewsPath = join(INTEL, skuInfo.sku, '_raw_reviews.json')
|
|
if (!existsSync(reviewsPath)) {
|
|
console.log(`[${skuInfo.sku}] no _raw_reviews.json — skipping`)
|
|
return null
|
|
}
|
|
const reviews: Review[] = JSON.parse(readFileSync(reviewsPath, 'utf8'))
|
|
const mentions = extractMentions(reviews)
|
|
const { buckets, windows } = bucketByPhase(mentions, skuInfo.phases)
|
|
const out = {
|
|
sku: skuInfo.sku,
|
|
name: skuInfo.name,
|
|
category: skuInfo.category,
|
|
generatedAt: new Date().toISOString(),
|
|
generatedBy: 'scripts/extract-review-time-mentions.ts',
|
|
sourceReviews: reviewsPath,
|
|
totalReviews: reviews.length,
|
|
totalMentions: mentions.length,
|
|
phases: windows.map(w => ({
|
|
label: w.label,
|
|
windowDays: w.range,
|
|
mentionCount: buckets[w.label].length,
|
|
topMentions: buckets[w.label]
|
|
.slice()
|
|
.sort((a, b) => (b.stars || 0) - (a.stars || 0) || a.days - b.days)
|
|
.slice(0, 5)
|
|
}))
|
|
}
|
|
const outFile = join(INTEL, skuInfo.sku, 'review-time-mentions.json')
|
|
writeFileSync(outFile, JSON.stringify(out, null, 2))
|
|
console.log(`[${skuInfo.sku}] ${mentions.length} time-mentions across ${reviews.length} reviews`)
|
|
for (const p of out.phases) console.log(` ${p.label}: ${p.mentionCount}`)
|
|
return out
|
|
}
|
|
|
|
async function main() {
|
|
const pilot: Pilots = JSON.parse(readFileSync(PILOT_FILE, 'utf8'))
|
|
const requested = process.argv.slice(2)
|
|
const targets = requested.length ? pilot.skus.filter(s => requested.includes(s.sku)) : pilot.skus
|
|
for (const s of targets) processSku(s)
|
|
console.log('\nDone.')
|
|
}
|
|
|
|
await main()
|