Files
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

144 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
// Tag every raw review with the aspects it supports.
// Usage: bun scripts/tag-reviews.ts [SKU1 SKU2 ...] (defaults to all SKUs)
// Output: data/intelligence/<SKU>/review-aspects-supporting.json
import { join } from 'path'
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs'
const ROOT = join(import.meta.dir, '..')
const INTEL = join(ROOT, 'data', 'intelligence')
const API_KEY = process.env.GEMINI_API_KEY
const MODEL = 'gemini-2.5-flash'
const BATCH_SIZE = 40
const CONCURRENCY = 4
if (!API_KEY) { console.error('GEMINI_API_KEY not set'); process.exit(1) }
type Aspect = { rank: number; aspect: string; sentiment: string; exampleQuotes?: string[] }
type Review = { comment: string; stars: number }
const SYSTEM = `You label customer reviews against a fixed list of aspects.
For each review, return ALL aspect ranks it clearly supports (the review states or strongly implies that aspect).
Match paraphrases (e.g. "achy joints" supports "joint pain"). Do NOT match weak/tangential mentions.
A review may match 0, 1, or many aspects. A short generic review like "great product" matches none.
Output strict JSON only: an array where index i corresponds to review i in the batch.
Each element is an array of integer aspect ranks.`
async function tagBatch(aspects: Aspect[], batch: { idx: number; r: Review }[]): Promise<number[][]> {
const aspectList = aspects.map(a => `${a.rank}. [${a.sentiment}] ${a.aspect}`).join('\n')
const reviewList = batch.map((b, i) => `[${i}] (${b.r.stars}★) ${b.r.comment.replace(/\s+/g, ' ').slice(0, 500)}`).join('\n')
const userMsg = `ASPECTS:\n${aspectList}\n\nREVIEWS (${batch.length}):\n${reviewList}\n\nReturn JSON array of length ${batch.length}, each element an array of matching aspect ranks. Example: [[1,3],[],[2]]`
for (let attempt = 0; attempt < 3; attempt++) {
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
systemInstruction: { parts: [{ text: SYSTEM }] },
contents: [{ role: 'user', parts: [{ text: userMsg }] }],
generationConfig: {
temperature: 0,
responseMimeType: 'application/json',
maxOutputTokens: 8000
}
})
})
if (!res.ok) {
const txt = await res.text()
if (res.status === 429 || res.status >= 500) {
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)))
continue
}
throw new Error(`API ${res.status}: ${txt.slice(0, 200)}`)
}
const json: any = await res.json()
const text = json.candidates?.[0]?.content?.parts?.[0]?.text || ''
const match = text.match(/\[[\s\S]*\]/)
if (!match) throw new Error('No JSON array in response')
const arr = JSON.parse(match[0])
if (!Array.isArray(arr) || arr.length !== batch.length) {
throw new Error(`Expected length ${batch.length}, got ${Array.isArray(arr) ? arr.length : 'non-array'}`)
}
return arr.map((x: any) => Array.isArray(x) ? x.filter((n: any) => Number.isInteger(n)) : [])
} catch (e: any) {
if (attempt === 2) throw e
await new Promise(r => setTimeout(r, 1500 * (attempt + 1)))
}
}
return batch.map(() => [])
}
async function tagSku(sku: string) {
const dir = join(INTEL, sku)
const aspectsFile = join(dir, 'review-aspects.json')
const reviewsFile = join(dir, '_raw_reviews.json')
if (!existsSync(aspectsFile) || !existsSync(reviewsFile)) {
console.log(`[${sku}] skip — missing files`); return
}
const aspectsDoc = JSON.parse(readFileSync(aspectsFile, 'utf8'))
const reviews: Review[] = JSON.parse(readFileSync(reviewsFile, 'utf8'))
const aspects: Aspect[] = aspectsDoc.aspects
console.log(`[${sku}] ${reviews.length} reviews × ${aspects.length} aspects`)
// Build batches with original indices
const indexed = reviews.map((r, idx) => ({ idx, r })).filter(x => x.r.comment && x.r.comment.trim().length > 0)
const batches: { idx: number; r: Review }[][] = []
for (let i = 0; i < indexed.length; i += BATCH_SIZE) batches.push(indexed.slice(i, i + BATCH_SIZE))
const supportingByRank: Record<number, number[]> = {}
for (const a of aspects) supportingByRank[a.rank] = []
let done = 0
// Run with limited concurrency
for (let i = 0; i < batches.length; i += CONCURRENCY) {
const slice = batches.slice(i, i + CONCURRENCY)
const results = await Promise.all(slice.map(b => tagBatch(aspects, b).catch(e => {
console.error(` batch err: ${e.message}`); return b.map(() => [])
})))
results.forEach((labels, k) => {
const batch = slice[k]
labels.forEach((ranks, j) => {
const reviewIdx = batch[j].idx
for (const rank of ranks) {
if (supportingByRank[rank] !== undefined) supportingByRank[rank].push(reviewIdx)
}
})
})
done += slice.reduce((s, b) => s + b.length, 0)
process.stdout.write(`\r[${sku}] tagged ${done}/${indexed.length}`)
}
process.stdout.write('\n')
const out = {
sku,
generatedAt: new Date().toISOString().slice(0, 10),
model: MODEL,
totalReviews: reviews.length,
supportingReviews: aspects.map(a => ({
rank: a.rank,
aspect: a.aspect,
matchCount: supportingByRank[a.rank].length,
reviewIndices: supportingByRank[a.rank].sort((x, y) => x - y)
}))
}
const outFile = join(dir, 'review-aspects-supporting.json')
writeFileSync(outFile, JSON.stringify(out, null, 2))
console.log(`[${sku}] wrote ${outFile}`)
for (const s of out.supportingReviews) {
const stated = aspects.find(a => a.rank === s.rank)
console.log(` rank ${s.rank}: matched ${s.matchCount} reviews (stated: ${(aspectsDoc.aspects.find((a: any) => a.rank === s.rank) as any)?.reviews ?? '?'})`)
}
}
const targets = process.argv.slice(2).length
? process.argv.slice(2)
: readdirSync(INTEL).filter(d => existsSync(join(INTEL, d, 'review-aspects.json')))
for (const sku of targets) {
try { await tagSku(sku) } catch (e: any) { console.error(`[${sku}] FAILED: ${e.message}`) }
}