Files
justvitamin/scripts/extract-review-time-mentions.ts
Omair Saleh 056c47581f feat: editorial review dashboard + elite-grade pilot batch (5 SKUs)
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>
2026-06-02 18:50:09 +08:00

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()