From 98040b9a331006948290d21016bd2a5169e8103c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:12:50 +0700 Subject: [PATCH 1/3] feat(shared-data): review value score, tier and completeness weight (P6) (#1579) * feat(shared-data): review value score, tier and completeness weight factor Refs #1547. Design #1531 section 6, D6 and D7: a 0-100 value score from completeness (35), specificity (20), a verified purchase (15), helpful votes through the Wilson lower bound (20) and author trust (10). Gated reviews score 0. A public tier (basic, complete, excellent) and the gaps the writer can fill. Completeness lifts the rating weight by x1.00 to x1.25, capped at 3. Lines count once however often they are repeated, dodging cons earn nothing and a superficial text earns no completeness. * fix(shared-data): stricter value score lines, photo-free weight for imports Refs #1547. From two fresh-context reviews: lines are deduplicated without diacritics or spaces; praise alone, dodges and AI-extracted lines earn nothing; a category without its own criteria list has none to complete; a verdict that repeats a line or is too long earns nothing. The new weightCompleteness drops the photo of a review without pros and cons, so a marketplace review keeps its weight (design 6.2). Malformed input reads as an empty review. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/shared-data.test.ts | 325 ++++++++++++ packages/shared-data/src/rules/index.ts | 1 + .../shared-data/src/rules/review-value.ts | 490 ++++++++++++++++++ 3 files changed, 816 insertions(+) create mode 100644 packages/shared-data/src/rules/review-value.ts diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 0a072de8..e729369c 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -2037,6 +2037,327 @@ function testProductPoints(): void { ); } +function testReviewValue(): void { + const phone = rules.getRatingCriteria('smartphones'); + const allRated: Record = Object.fromEntries( + phone.map((criterion) => [criterion.key, 4]) + ); + const generalLines: rules.ReviewValuePoint[] = [ + { kind: 'pro', criterionKey: null, body: 'Pin dùng cả ngày' }, + { kind: 'con', criterionKey: null, body: 'Sạc hơi chậm' }, + ]; + const criterionLines: rules.ReviewValuePoint[] = [ + { kind: 'pro', criterionKey: 'camera', body: 'Chụp đêm rõ nét' }, + { kind: 'con', criterionKey: 'display', body: 'Màn hơi tối ngoài nắng' }, + ]; + const base: rules.ReviewValueInput = { + categorySlug: 'smartphones', + gated: false, + depth: 'detailed', + criteriaRatings: allRated, + verdict: 'Máy mượt, pin dùng hết ngày, đáng tiền trong tầm giá.', + points: generalLines, + hasRealPhoto: false, + verifiedPurchase: false, + helpfulVotes: 0, + unhelpfulVotes: 0, + authorTrust: null, + }; + const value = (extra: Partial) => + rules.reviewValue({ ...base, ...extra }); + const completeness = (extra: Partial) => + value(extra).completeness; + + // The parts sum to 100, the completeness items to 35. + const max = rules.REVIEW_VALUE_MAX; + assert.equal( + max.completeness + max.specificity + max.verified + max.helpful + max.trust, + 100 + ); + assert.equal( + Object.values(rules.REVIEW_COMPLETENESS_POINTS).reduce((a, b) => a + b, 0), + max.completeness + ); + + // A standard Rate review: criteria, verdict, a pro and a con make 25. + const standard = value({}); + assert.equal(standard.completeness, 25); + assert.equal(standard.specificity, 17); + assert.equal(standard.trust, 5, 'unknown trust counts as 0.5'); + assert.equal(standard.score, 47); + assert.equal(standard.tier, 'basic'); + assert.deepEqual(standard.gaps, ['criterion_points', 'photo', 'verified']); + + // Lines for two criteria and a photo: everything, and the "Đầy đủ" tier. + const full = value({ + hasRealPhoto: true, + points: [...generalLines, ...criterionLines], + }); + assert.equal(full.completeness, 35); + assert.equal(full.tier, 'complete'); + assert.deepEqual(full.gaps, ['verified']); + // Verified, expert depth and a trusted writer: "Xuất sắc". + const excellent = value({ + hasRealPhoto: true, + points: [...generalLines, ...criterionLines], + depth: 'expert_depth', + verifiedPurchase: true, + authorTrust: 1, + }); + assert.equal(excellent.score, 80); + assert.equal(excellent.tier, 'excellent'); + assert.deepEqual(excellent.gaps, []); + // A high score without completeness stays "Cơ bản". + assert.equal( + value({ + depth: 'expert_depth', + verifiedPurchase: true, + authorTrust: 1, + helpfulVotes: 500, + verdict: null, + }).tier, + 'basic' + ); + + // One skipped criterion loses the 10 points (6.4). + const skipped = { ...allRated }; + delete skipped[phone.find((criterion) => !criterion.required)!.key]; + assert.equal(completeness({ criteriaRatings: skipped }), 15); + assert.equal(completeness({ criteriaRatings: 'junk' }), 15); + assert.equal(completeness({ criteriaRatings: [4, 4, 4] }), 15); + assert.equal( + completeness({ criteriaRatings: { ...allRated, value: 0 } }), + 15, + 'a star outside 1-5 is not a rating' + ); + + // A verdict shorter than the form's minimum, or with no words, does not count. + assert.equal(completeness({ verdict: 'Tốt' }), 20); + assert.equal(completeness({ verdict: '!'.repeat(30) }), 20); + assert.equal(completeness({ verdict: null }), 20); + + // "Ưu: tốt. Nhược: không": a dodging con earns nothing (6.4). + assert.equal( + completeness({ + points: [ + generalLines[0]!, + { kind: 'con', criterionKey: null, body: 'không có gì để chê' }, + ], + }), + 15 + ); + // Copy-pasted lines count once: the same words as a pro and a con, a line + // repeated under two criteria, a general line repeated under a criterion. + assert.equal( + completeness({ + points: [ + generalLines[0]!, + { kind: 'con', criterionKey: null, body: 'Pin dùng cả ngày!' }, + ], + }), + 15 + ); + assert.equal( + completeness({ + points: [ + ...generalLines, + { kind: 'pro', criterionKey: 'battery', body: 'Pin dùng cả ngày' }, + { kind: 'pro', criterionKey: 'camera', body: 'PIN dùng cả ngày.' }, + ], + }), + 25 + ); + assert.equal( + completeness({ + points: [ + { kind: 'pro', criterionKey: 'camera', body: 'Chụp đêm rõ nét' }, + { kind: 'pro', criterionKey: 'battery', body: 'Chụp đêm rõ nét' }, + ...generalLines, + ], + }), + 25, + 'the same line under two criteria is one criterion' + ); + assert.equal( + completeness({ + points: [ + ...generalLines, + { kind: 'pro', criterionKey: 'camera', body: 'Chụp đêm rõ nét' }, + { kind: 'pro', criterionKey: 'camera', body: 'Quay video ổn định' }, + ], + }), + 25, + 'two lines of one criterion are not two criteria' + ); + assert.equal( + completeness({ + points: [ + ...generalLines, + { kind: 'pro', criterionKey: 'anc', body: 'Chống ồn rất tốt' }, + { kind: 'pro', criterionKey: 'toString', body: 'Chụp đêm rõ nét' }, + ], + }), + 25, + 'keys the category does not have count for nothing' + ); + // Junk lines and lines outside the form's lengths count for nothing. + assert.equal( + completeness({ + points: [ + { kind: 'pro', criterionKey: null, body: 'aaaaaaaa' }, + { kind: 'con', criterionKey: null, body: 'Sạc chậm '.repeat(30) }, + ], + }), + 15 + ); + assert.equal(completeness({ points: 'junk' as never }), 15); + assert.equal(completeness({ points: [null as never] }), 15); + // A copy typed without diacritics or spaces is the same line. + assert.equal( + completeness({ + points: [ + generalLines[0]!, + { kind: 'con', criterionKey: null, body: 'Pin dung ca ngay' }, + ], + }), + 15 + ); + assert.equal( + completeness({ + points: [ + ...generalLines, + { kind: 'pro', criterionKey: 'battery', body: 'Pindung ca ngay' }, + { kind: 'con', criterionKey: 'display', body: 'SAC HOI CHAM' }, + ], + }), + 25 + ); + // Praise alone and dodges earn nothing, as a pro or under a criterion. + assert.equal( + completeness({ + points: [ + { kind: 'pro', criterionKey: null, body: 'Rất tốt' }, + generalLines[1]!, + ], + }), + 15 + ); + assert.equal( + completeness({ + points: [ + ...generalLines, + { kind: 'pro', criterionKey: 'battery', body: 'rất tốt' }, + { kind: 'pro', criterionKey: 'camera', body: 'sản phẩm ok lắm' }, + { kind: 'con', criterionKey: 'display', body: 'không có gì để chê' }, + ], + }), + 25 + ); + // Lines AI extracted from a marketplace review earn nothing. + assert.equal( + completeness({ + points: [...generalLines, ...criterionLines].map((point) => ({ + ...point, + method: 'ai' as const, + })), + }), + 15 + ); + // A category without its own criteria list has no criteria to complete. + assert.equal(completeness({ categorySlug: null }), 15); + assert.equal(completeness({ categorySlug: 'tvs' }), 15); + // A verdict that repeats a line, or longer than the form allows, earns nothing. + const longLine = 'Pin dùng cả ngày với nhu cầu vừa phải'; + assert.equal( + completeness({ + verdict: longLine, + points: [ + { kind: 'pro', criterionKey: null, body: longLine }, + generalLines[1]!, + ], + }), + 20 + ); + assert.equal(completeness({ verdict: 'Máy dùng tốt. '.repeat(30) }), 20); + + // Filling the boxes on a superficial text earns no completeness, and + // padding cannot raise specificity past its depth. + const lowEffort = value({ depth: 'low_effort' }); + assert.equal(lowEffort.completeness, 0); + assert.equal(lowEffort.gaps[0], 'detail'); + assert.equal(value({ depth: 'filler' }).specificity, 0); + assert.equal(value({ depth: 'expert_depth' }).specificity, max.specificity); + assert.equal(value({ depth: 'bogus' as never }).specificity, 0); + + // Gated: 0 however complete, and no tier. + const gated = value({ gated: true, verifiedPurchase: true }); + assert.equal(gated.score, 0); + assert.equal(gated.completeness, 0); + assert.equal(gated.tier, null); + + // An imported review: no lines, no criteria, verified, a few votes. + const imported = value({ + criteriaRatings: null, + verdict: null, + points: [], + depth: 'specific_short', + verifiedPurchase: true, + helpfulVotes: 3, + }); + assert.equal(imported.completeness, 0); + assert.equal(imported.verified, 15); + assert.equal(imported.tier, 'basic'); + // With a photo it earns completeness, but not weight: no pros and cons (6.2). + const importedPhoto = value({ + criteriaRatings: null, + verdict: null, + points: [], + depth: 'specific_short', + hasRealPhoto: true, + }); + assert.equal(importedPhoto.completeness, 5); + assert.equal(importedPhoto.weightCompleteness, 0); + assert.equal(full.weightCompleteness, 35); + assert.equal(standard.weightCompleteness, 25); + assert.equal(value({ hasRealPhoto: true }).weightCompleteness, 30); + assert.equal(gated.weightCompleteness, 0); + // Malformed input never throws. + assert.equal(rules.reviewValue(null as never).score, 5); + assert.equal(rules.reviewValue(null as never).tier, 'basic'); + + // Helpful votes through the Wilson bound: a few votes do not jump. + assert.equal(rules.wilsonLowerBound(0, 0), 0); + assert.equal(rules.wilsonLowerBound(Number.NaN, -1), 0); + assert.ok(rules.wilsonLowerBound(90, 10) > rules.wilsonLowerBound(2, 0)); + assert.equal(value({ helpfulVotes: 2 }).helpful, 6.85); + assert.ok(value({ helpfulVotes: 1e12 }).helpful <= max.helpful); + assert.equal(value({ authorTrust: 7 }).trust, 10); + assert.equal(value({ authorTrust: Number.NaN }).trust, 5); + assert.equal(value({ authorTrust: -1 }).trust, 0); + + // D6: x1.00 to x1.25 on the stored weight, capped at 3. + assert.equal(rules.completenessWeightFactor(0), 1); + assert.equal(rules.completenessWeightFactor(35), 1.25); + assert.equal(rules.completenessWeightFactor(25), 1.1786); + assert.equal(rules.completenessWeightFactor(99), 1.25); + assert.equal(rules.completenessWeightFactor(-4), 1); + assert.equal(rules.completenessWeightFactor('35'), 1); + assert.equal(rules.completenessWeightFactor(undefined), 1); + assert.equal(rules.reviewRatingWeight(1, 35), 1.25); + assert.equal(rules.reviewRatingWeight(2.8, 35), 3); + assert.equal(rules.reviewRatingWeight(0, 35), 0); + assert.equal(rules.reviewRatingWeight(-1, 35), 0); + assert.equal(rules.reviewRatingWeight(Number.NaN, 35), 0); + assert.equal(rules.reviewRatingWeight(Infinity, 0), 3); + // Acceptance: a complete review weighs at most 25% more than the same + // review with nothing; an imported one without pros and cons keeps its weight. + assert.ok( + rules.reviewRatingWeight(1, full.completeness) <= + 1.25 * rules.reviewRatingWeight(1, 0) + ); + assert.equal(rules.reviewRatingWeight(1.4, imported.completeness), 1.4); +} + function main(): void { console.log('🧪 Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); @@ -2075,6 +2396,10 @@ function main(): void { ); testProductPoints(); console.log(' ✅ [12/12] Product pros and cons (DOS/Rate#1546) verified'); + testReviewValue(); + console.log( + ' ✅ [13/13] Review value score and weight factor (DOS/Rate#1547) verified' + ); console.log('🎉 All @repo/shared-data tests passed!'); } diff --git a/packages/shared-data/src/rules/index.ts b/packages/shared-data/src/rules/index.ts index 042f81d1..af19a3f6 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -14,3 +14,4 @@ export * from './review-summary.js'; export * from './rating-criteria.js'; export * from './review-form.js'; export * from './product-points.js'; +export * from './review-value.js'; diff --git a/packages/shared-data/src/rules/review-value.ts b/packages/shared-data/src/rules/review-value.ts new file mode 100644 index 00000000..59ae565a --- /dev/null +++ b/packages/shared-data/src/rules/review-value.ts @@ -0,0 +1,490 @@ +/** + * A review's value score (DOS/Rate#1547, design #1531 section 6, D6, D7): 0 + * to 100 from completeness, specificity, a verified purchase, helpful votes + * and the writer's trust. It sorts reviews and gives a public tier; only the + * writer sees the number (D7). Of the five parts only completeness touches + * the product rating, through a weight factor from x1.00 to x1.25 (D6). + * + * Plain data and functions only, like the rest of `rules`, and no database: + * the importer job and the community path read a review into a + * ReviewValueInput and store the result in `rate.reviews.value_score` and + * `value_breakdown`. The DOS-Me rating refresh reads `weightCompleteness` from + * `value_breakdown` and applies completenessWeightFactor, so the two stay + * equal. + */ +import { getRatingCriteria, hasRatingCriteria } from './rating-criteria.js'; +import { + REVIEW_POINT_MAX, + REVIEW_POINT_MIN, + REVIEW_VERDICT_MAX, + REVIEW_VERDICT_MIN, + comparablePoint, + hasContent, + isEvasiveCon, + storedPoint, +} from './review-form.js'; + +/** The `value_breakdown` shape; bump when a part's rule changes. */ +export const REVIEW_VALUE_VERSION = 1; + +/** Each part's maximum, design 6.1; they sum to 100. */ +export const REVIEW_VALUE_MAX = { + completeness: 35, + specificity: 20, + verified: 15, + helpful: 20, + trust: 10, +} as const; + +/** The completeness items, design 6.1; they sum to 35. */ +export const REVIEW_COMPLETENESS_POINTS = { + /** Every criterion of the category rated. */ + criteria: 10, + /** A verdict. */ + verdict: 5, + /** At least one pro and one con of the whole review. */ + prosCons: 10, + /** Pros or cons for at least REVIEW_CRITERION_POINTS_MIN_CRITERIA criteria. */ + criterionPoints: 5, + /** A real photo. */ + photo: 5, +} as const; + +/** The criteria with their own pros or cons that earn `criterionPoints`. */ +export const REVIEW_CRITERION_POINTS_MIN_CRITERIA = 2; + +/** D6: a complete review weighs at most this much more (x1.25). */ +export const REVIEW_COMPLETENESS_WEIGHT_LIFT = 0.25; +/** A review's rating weight never exceeds this, as in the product rating. */ +export const REVIEW_RATING_WEIGHT_CAP = 3; + +/** The completeness a review needs for the "Đầy đủ" tier. */ +export const REVIEW_TIER_COMPLETE_MIN = 30; +/** The value score a complete review needs for the "Xuất sắc" tier. */ +export const REVIEW_TIER_EXCELLENT_MIN = 70; + +/** + * Axis C of the review trust model (`Docs/Platform/review-trust-model.html`), + * as `evaluateReviewTrustAssessment` in apps/web classifies it. + */ +export type ReviewDepth = + | 'stars_only' + | 'platform_label' + | 'filler' + | 'low_effort' + | 'specific_short' + | 'detailed' + | 'expert_depth'; + +/** + * Specificity points per depth. Length counts only through the depth, so + * padding past a depth's threshold adds nothing. Padding up to a threshold + * is axis C's to catch: as the web classifies it today, 30, 90 and 240 + * characters with a product word move a text up a depth. + */ +export const REVIEW_SPECIFICITY_POINTS: Readonly> = + { + stars_only: 0, + platform_label: 0, + filler: 0, + low_effort: 5, + specific_short: 12, + detailed: 17, + expert_depth: 20, + }; + +// An unknown depth counts as no text. A constant, because the rules import +// check reads a quote on an export line as a module path. +const NO_TEXT: ReviewDepth = 'stars_only'; + +/** Depths that earn no completeness: filling the boxes is not enough (6.4). */ +const SUPERFICIAL: ReadonlySet = new Set([ + 'stars_only', + 'platform_label', + 'filler', + 'low_effort', +]); + +export interface ReviewValuePoint { + kind: 'pro' | 'con'; + /** The line's own criterion, or null for a pro or con of the whole review. */ + criterionKey: string | null; + body: string; + /** + * `rate.review_points.method`: 'ai' lines are extracted from a marketplace + * review for clustering (design 4.3) and earn nothing here. + */ + method?: 'writer' | 'ai'; +} + +export interface ReviewValueInput { + categorySlug: string | null; + /** + * Blocked by a gate (fake, copied, filler, excluded from the rating): the + * value score is 0 however complete the review is (6.1). + */ + gated: boolean; + /** Axis C, measured on all the review's text: comment, verdict and lines. */ + depth: ReviewDepth; + /** `rate.reviews.criteria_ratings`. */ + criteriaRatings: unknown; + verdict: string | null; + points: readonly ReviewValuePoint[]; + /** At least one photo of the product itself, not a stock image. */ + hasRealPhoto: boolean; + /** A marketplace's purchase mark, later an invoice. */ + verifiedPurchase: boolean; + /** Helpful and unhelpful votes, weighted as the review sort counts them. */ + helpfulVotes: number; + unhelpfulVotes: number; + /** + * Axis D, the writer's trust from 0 to 1 (account age, review history, + * category standing); null when unknown, which counts as 0.5. + */ + authorTrust: number | null; +} + +/** A completeness item or another step that would raise the score. */ +export type ReviewValueGap = + | 'detail' + | 'criteria' + | 'verdict' + | 'pros_cons' + | 'criterion_points' + | 'photo' + | 'verified'; + +export type ReviewValueTier = 'basic' | 'complete' | 'excellent'; + +/** What `rate.reviews.value_breakdown` holds; `score` is `value_score`. */ +export interface ReviewValue { + version: number; + gated: boolean; + score: number; + completeness: number; + /** + * The completeness the rating weight reads (D6): completeness, less the + * photo of a review without pros and cons, so a marketplace review keeps + * its weight (6.2). The DOS-Me refresh reads this key. + */ + weightCompleteness: number; + specificity: number; + verified: number; + helpful: number; + trust: number; + /** The public tier (D7); null for a gated review, which shows none. */ + tier: ReviewValueTier | null; + /** For the writer only: what would raise the score, in form order. */ + gaps: ReviewValueGap[]; +} + +const round2 = (value: number): number => Math.round(value * 100) / 100; + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)); + +function finiteOr(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +/** + * The 95% Wilson lower bound of the helpful share, at four decimals: two + * votes out of two do not beat ninety out of a hundred. The same formula as + * computeWilsonLowerBound in apps/web, which sorts reviews today. + */ +export function wilsonLowerBound(helpful: number, unhelpful: number): number { + const pos = clamp(finiteOr(helpful, 0), 0, 10_000_000); + const neg = clamp(finiteOr(unhelpful, 0), 0, 10_000_000); + const n = pos + neg; + if (n <= 0) return 0; + const z = 1.96; + const pHat = pos / n; + const z2 = z * z; + const radicand = Math.max(0, (pHat * (1 - pHat) + z2 / (4 * n)) / n); + const bound = (pHat + z2 / (2 * n) - z * Math.sqrt(radicand)) / (1 + z2 / n); + return Number(clamp(bound, 0, 1).toFixed(4)); +} + +function isStar(value: unknown): boolean { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= 1 && + value <= 5 + ); +} + +function ownValue(record: unknown, key: string): unknown { + return record && + typeof record === 'object' && + !Array.isArray(record) && + Object.prototype.hasOwnProperty.call(record, key) + ? (record as Record)[key] + : undefined; +} + +/** + * The words a line is scored on: comparablePoint without diacritics, so a + * copy typed without them ("Pin dung ca ngay") is the same line. Stricter + * than the form, which keeps "sạc" and "sắc" apart: a false merge here only + * drops one line's credit. + */ +function scoringWords(text: string): string[] { + return comparablePoint(text) + .normalize('NFD') + .replace(/\p{M}+/gu, '') + .replace(/đ/g, 'd') + .split(' ') + .filter(Boolean); +} + +// Words of praise that say nothing about the product ("rất tốt", "sản phẩm +// ok", "dùng ổn"), without diacritics. A line made only of them earns +// nothing (6.4). +const GENERIC_WORDS: ReadonlySet = new Set([ + 'a', + 'bt', + 'binh', + 'chat', + 'cuc', + 'cung', + 'dep', + 'duoc', + 'dung', + 'em', + 'good', + 'great', + 'hai', + 'hang', + 'kha', + 'ky', + 'lam', + 'long', + 'luong', + 'may', + 'minh', + 'ngon', + 'nha', + 'nhe', + 'nice', + 'ok', + 'oke', + 'on', + 'pham', + 'qua', + 'rat', + 'san', + 'sp', + 'thich', + 'thuong', + 'tot', + 'tuyet', + 'ung', + 'very', + 'voi', + 'xai', + 'xin', + 'y', +]); + +/** + * A writer's line that counts: the form's length, words in it, not a dodge, + * not praise alone. Lines extracted by AI and malformed lines count for + * nothing. + */ +function countedLine(point: ReviewValuePoint): boolean { + if (!point || typeof point !== 'object') return false; + if (point.kind !== 'pro' && point.kind !== 'con') return false; + if (point.method === 'ai') return false; + const body = String(point.body ?? ''); + const length = [...storedPoint(body)].length; + if (length < REVIEW_POINT_MIN || length > REVIEW_POINT_MAX) return false; + if (!hasContent(body) || isEvasiveCon(body)) return false; + return !scoringWords(body).every((word) => GENERIC_WORDS.has(word)); +} + +interface CompletenessParts { + criteria: boolean; + verdict: boolean; + prosCons: boolean; + criterionPoints: boolean; + photo: boolean; +} + +function completenessParts(input: ReviewValueInput): CompletenessParts { + // A category without its own list has no criteria to complete: never the + // smartphone fallback of getRatingCriteria. + const criteria = hasRatingCriteria(input.categorySlug) + ? getRatingCriteria(input.categorySlug) + : []; + const keys = new Set(criteria.map((criterion) => criterion.key)); + const verdict = String(input.verdict ?? ''); + // Each line counts once, wherever it is repeated: the same words as a pro + // and a con, twice in a list, again under a criterion, or with other + // diacritics, case, punctuation or spacing. + const seen = new Set(); + let pro = false; + let con = false; + const criteriaWithLines = new Set(); + const points = Array.isArray(input.points) ? input.points : []; + // Lines of the whole review first, so a criterion line copied from one is + // the one dropped. + const ordered = [ + ...points.filter((point) => point?.criterionKey == null), + ...points.filter((point) => point?.criterionKey != null), + ]; + for (const point of ordered) { + if (!countedLine(point)) continue; + const compared = scoringWords(point.body).join(''); + if (seen.has(compared)) continue; + seen.add(compared); + if (point.criterionKey == null) { + if (point.kind === 'pro') pro = true; + else con = true; + } else if (keys.has(point.criterionKey)) { + criteriaWithLines.add(point.criterionKey); + } + } + return { + criteria: + criteria.length > 0 && + criteria.every((criterion) => + isStar(ownValue(input.criteriaRatings, criterion.key)) + ), + // The form's verdict, and not one of the lines again. + verdict: + [...storedPoint(verdict)].length >= REVIEW_VERDICT_MIN && + [...storedPoint(verdict)].length <= REVIEW_VERDICT_MAX && + hasContent(verdict) && + !seen.has(scoringWords(verdict).join('')), + prosCons: pro && con, + criterionPoints: + criteriaWithLines.size >= REVIEW_CRITERION_POINTS_MIN_CRITERIA, + photo: input.hasRealPhoto === true, + }; +} + +// A malformed input reads as an empty review. A function of its own, because +// the rules import check reads a quote on an export line as a module path. +function asInput(raw: unknown): ReviewValueInput { + return (raw && typeof raw === 'object' ? raw : {}) as ReviewValueInput; +} + +/** + * The value score of one review and its parts (design 6.1). A gated review + * scores 0. Completeness counts only when the text is more than superficial, + * each pro or con line counts once however often or however it is retyped, + * and length counts only through the depth. + */ +export function reviewValue(raw: ReviewValueInput): ReviewValue { + const input = asInput(raw); + const depth: ReviewDepth = Object.prototype.hasOwnProperty.call( + REVIEW_SPECIFICITY_POINTS, + input?.depth + ) + ? input.depth + : NO_TEXT; + const parts = completenessParts(input); + const superficial = SUPERFICIAL.has(depth); + const P = REVIEW_COMPLETENESS_POINTS; + const completeness = superficial + ? 0 + : (parts.criteria ? P.criteria : 0) + + (parts.verdict ? P.verdict : 0) + + (parts.prosCons ? P.prosCons : 0) + + (parts.criterionPoints ? P.criterionPoints : 0) + + (parts.photo ? P.photo : 0); + const weightCompleteness = + completeness - + (parts.photo && !parts.prosCons && completeness ? P.photo : 0); + const specificity = REVIEW_SPECIFICITY_POINTS[depth]; + const verified = + input.verifiedPurchase === true ? REVIEW_VALUE_MAX.verified : 0; + const helpful = round2( + REVIEW_VALUE_MAX.helpful * + wilsonLowerBound(input.helpfulVotes, input.unhelpfulVotes) + ); + const trust = round2( + REVIEW_VALUE_MAX.trust * clamp(finiteOr(input.authorTrust, 0.5), 0, 1) + ); + + const gaps: ReviewValueGap[] = []; + if (superficial) gaps.push('detail'); + if (!parts.criteria) gaps.push('criteria'); + if (!parts.verdict) gaps.push('verdict'); + if (!parts.prosCons) gaps.push('pros_cons'); + if (!parts.criterionPoints) gaps.push('criterion_points'); + if (!parts.photo) gaps.push('photo'); + if (!verified) gaps.push('verified'); + + if (input.gated === true) { + return { + version: REVIEW_VALUE_VERSION, + gated: true, + score: 0, + completeness: 0, + weightCompleteness: 0, + specificity: 0, + verified: 0, + helpful: 0, + trust: 0, + tier: null, + gaps: [], + }; + } + const score = round2( + clamp(completeness + specificity + verified + helpful + trust, 0, 100) + ); + const tier: ReviewValueTier = + completeness < REVIEW_TIER_COMPLETE_MIN + ? 'basic' + : score >= REVIEW_TIER_EXCELLENT_MIN + ? 'excellent' + : 'complete'; + return { + version: REVIEW_VALUE_VERSION, + gated: false, + score, + completeness, + weightCompleteness, + specificity, + verified, + helpful, + trust, + tier, + gaps, + }; +} + +/** + * D6: the rating weight factor of a review's `weightCompleteness`, from 1 + * (nothing) to 1.25 (everything), at four decimals. A number outside 0 to + * 35 counts as its nearest bound; anything else, a numeric string included, + * as 0, so a review the job has not scored yet keeps its weight. Read the + * stored value as a number (jsonb `->`, not `->>`). + */ +export function completenessWeightFactor(completeness: unknown): number { + const c = clamp(finiteOr(completeness, 0), 0, REVIEW_VALUE_MAX.completeness); + return Number( + ( + 1 + + (REVIEW_COMPLETENESS_WEIGHT_LIFT * c) / REVIEW_VALUE_MAX.completeness + ).toFixed(4) + ); +} + +/** + * A review's weight in the product rating: the stored helpfulness weight + * times the factor of its weightCompleteness, capped at + * REVIEW_RATING_WEIGHT_CAP (6.2). + */ +export function reviewRatingWeight( + helpfulnessWeight: number, + weightCompleteness: unknown +): number { + if (Number.isNaN(helpfulnessWeight) || !(helpfulnessWeight > 0)) return 0; + return Math.min( + REVIEW_RATING_WEIGHT_CAP, + helpfulnessWeight * completenessWeightFactor(weightCompleteness) + ); +} From 40d2f44518220ee7dc600013e482c12827b35646 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:39:34 +0700 Subject: [PATCH 2/3] docs(proposals): dos-me review value score and completeness weight (P6) (#1580) * feat(shared-data): review value score, tier and completeness weight factor Refs #1547. Design #1531 section 6, D6 and D7: a 0-100 value score from completeness (35), specificity (20), a verified purchase (15), helpful votes through the Wilson lower bound (20) and author trust (10). Gated reviews score 0. A public tier (basic, complete, excellent) and the gaps the writer can fill. Completeness lifts the rating weight by x1.00 to x1.25, capped at 3. Lines count once however often they are repeated, dodging cons earn nothing and a superficial text earns no completeness. * fix(shared-data): stricter value score lines, photo-free weight for imports Refs #1547. From two fresh-context reviews: lines are deduplicated without diacritics or spaces; praise alone, dodges and AI-extracted lines earn nothing; a category without its own criteria list has none to complete; a verdict that repeats a line or is too long earns nothing. The new weightCompleteness drops the photo of a review without pros and cons, so a marketplace review keeps its weight (design 6.2). Malformed input reads as an empty review. * docs(proposals): dos-me review value score and completeness weight (P6) Refs #1547. A DOS-Me migration proposal, nothing applied: the D6 factor from the stored weightCompleteness, the rating weight LEAST(3, weight x factor), the trigger on the factor, and store_review_values as the one checked writer for the scoring job. Verify V1-V7, a rollback that puts P2's functions back, and a Docker replay in CI. * fix(proposals): stricter p6 writer, lock-safe rollback, refresh on re-apply Refs #1547. From two fresh-context reviews: the writer accepts only a breakdown the shared module could produce (keys, tier, gaps, parts, the photo rule both ways, 2 MB) and locks rows in id order; the CHECK ties weightCompleteness to completeness; lock_timeout on the ALTERs; a refresh after the transaction re-lifts kept values; the rollback refreshes before it drops under a short lock; V2, V6 and V7 check more; the replay stores a 448-input grid of module output. The md lists the decisions honestly. * fix(proposals): p6 keeps the value number private (D7) Refs #1547. The PO held decision 1 to the approved D7: the tier is public, the number only for the writer. Values move to rate.review_values (RLS, no anon or authenticated access, written only by store_review_values); the public rate.reviews gets value_tier, and a CHECK keeps P2's number columns empty; rate.my_review_values lets a writer read their own through rate.review_authors. The rating joins the private table and its own triggers refresh on a factor change; P2's reviews trigger is untouched. * fix(proposals): p6 writer without temp table, safer locks, pre-apply guard Refs #1547. From two fresh-context reviews of the D7 redesign: the writer reads its batch straight from p_values (a caller-made temp table could hijack it), locks FOR NO KEY UPDATE; the apply refuses a review that already holds a public number and alters rate.reviews before creating the table; the rollback keeps reviews_value_private and refreshes only lifted masters; V3 checks DELETE and TRUNCATE; the md states D7's limits and the job's retry and V5 duties. The module header points at the writer. * Merge remote-tracking branch 'origin/dev' into docs/1547-dos-me-review-value-proposal # Conflicts: # packages/shared-data/src/rules/review-value.ts Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .gitignore | 3 + ...-02-dos-me-review-value-score-rollback.sql | 109 ++++ ...11-02-dos-me-review-value-score-verify.sql | 97 +++ .../2026-11-02-dos-me-review-value-score.md | 203 ++++++ .../2026-11-02-dos-me-review-value-score.sql | 432 +++++++++++++ .../shared-data/src/rules/review-value.ts | 9 +- scripts/test-review-value-proposal.mjs | 585 ++++++++++++++++++ 8 files changed, 1437 insertions(+), 4 deletions(-) create mode 100644 Docs/Proposals/2026-11-02-dos-me-review-value-score-rollback.sql create mode 100644 Docs/Proposals/2026-11-02-dos-me-review-value-score-verify.sql create mode 100644 Docs/Proposals/2026-11-02-dos-me-review-value-score.md create mode 100644 Docs/Proposals/2026-11-02-dos-me-review-value-score.sql create mode 100644 scripts/test-review-value-proposal.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87f05e54..d298b535 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,9 @@ jobs: - name: Replay the DOS-Me search rating proposal (Docker) run: node scripts/test-search-rating-score-proposal.mjs + - name: Replay the DOS-Me review value proposal (Docker) + run: pnpm --filter @repo/shared-data build && node scripts/test-review-value-proposal.mjs + - name: Check generated rate schema types match the migrations run: pnpm check:db-types diff --git a/.gitignore b/.gitignore index 1fc7202c..1e210963 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,9 @@ playwright-report-item-model/ !Docs/Proposals/2026-11-02-dos-me-search-rating-score.sql !Docs/Proposals/2026-11-02-dos-me-search-rating-score-rollback.sql !Docs/Proposals/2026-11-02-dos-me-search-rating-score-verify.sql +!Docs/Proposals/2026-11-02-dos-me-review-value-score.sql +!Docs/Proposals/2026-11-02-dos-me-review-value-score-rollback.sql +!Docs/Proposals/2026-11-02-dos-me-review-value-score-verify.sql * - Copy* * - Bản sao* *.dump diff --git a/Docs/Proposals/2026-11-02-dos-me-review-value-score-rollback.sql b/Docs/Proposals/2026-11-02-dos-me-review-value-score-rollback.sql new file mode 100644 index 00000000..e28cbe3f --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-review-value-score-rollback.sql @@ -0,0 +1,109 @@ +-- ROLLBACK of the PROPOSAL 2026-11-02-dos-me-review-value-score.sql (DOS/Rate#1547). +-- Two transactions: +-- 1. puts back P2's rating function (byte for byte from 20261101120000_rate_criteria_ratings.sql), +-- drops the triggers on rate.review_values, and refreshes every master a stored value could +-- have lifted; no table lock beyond the rows it updates; +-- 2. drops the writer, the reader, the factor, rate.review_values (its values are lost: the +-- scoring job recomputes them), the tier column and its CHECK, under a short ACCESS +-- EXCLUSIVE lock with a 5 s timeout (re-run the file if it times out). The CHECK keeping +-- P2's public number columns empty stays (D7). +-- The refresh skips a master another transaction holds at that moment (FOR UPDATE SKIP LOCKED in +-- P2), so stop the scoring job first and afterwards run V5 of the verify file: any master it +-- counts needs SELECT rate.refresh_master_rating_extras(id). +-- Safe to run twice. + +BEGIN; + +CREATE OR REPLACE FUNCTION rate.master_rating_extras(p_master_id uuid) +RETURNS TABLE (rating_score numeric, rating_weight_sum numeric, criteria_scores jsonb) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_item uuid := rate.master_rating_item(p_master_id); +BEGIN + RETURN QUERY EXECUTE format($q$ + WITH eligible AS ( + SELECT LEAST(3.0, r.helpfulness_weight)::numeric AS w, + COALESCE(r.rating_exact, r.rating_star)::numeric AS star, + r.criteria_ratings + FROM rate.products p + JOIN rate.reviews r ON r.product_id = p.id + JOIN rate.review_flags f ON f.review_id = r.id + WHERE %s = $1 + AND p.origin = 'crawl' + AND r.origin IN ('crawl', 'community') + AND r.reviewer_type = 'user' + AND COALESCE(f.include_in_rating, true) + AND r.helpfulness_weight > 0 + ), + overall AS ( + SELECT round(COALESCE(sum(w), 0), 2) AS weight_sum, + CASE WHEN sum(w) > 0 THEN round((3 * 4.2 + sum(w * star)) / (3 + sum(w)), 3) END AS score + FROM eligible + WHERE star BETWEEN 1 AND 5 + ), + per_criterion AS ( + SELECT c.key, + count(*) AS writers, + round(sum(e.w), 2) AS weight_sum, + sum(LEAST(1.0, e.w)) AS shown_sum, + round((3 * 4.2 + sum(e.w * (c.value #>> '{}')::numeric)) / (3 + sum(e.w)), 3) AS score + FROM eligible e + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(e.criteria_ratings) = 'object' THEN e.criteria_ratings + ELSE '{}'::jsonb END + ) AS c(key, value) + WHERE c.key ~ '^[a-z_]{2,32}$' + AND jsonb_typeof(c.value) = 'number' + AND (c.value #>> '{}')::numeric BETWEEN 1 AND 5 + GROUP BY c.key + ) + SELECT o.score, + o.weight_sum, + COALESCE( + (SELECT jsonb_object_agg(pc.key, jsonb_build_object( + 'score', pc.score, 'weight_sum', pc.weight_sum, 'writers', pc.writers)) + FROM per_criterion pc + WHERE pc.writers >= 3 AND pc.shown_sum >= 1.5), + '{}'::jsonb) + FROM overall o + $q$, CASE WHEN v_item IS NULL THEN 'p.cluster_id' ELSE 'p.item_id' END) + USING COALESCE(v_item, p_master_id); +END $$; + +DROP TRIGGER IF EXISTS trg_review_values_rating_extras_ins ON rate.review_values; +DROP TRIGGER IF EXISTS trg_review_values_rating_extras_upd ON rate.review_values; +DROP TRIGGER IF EXISTS trg_review_values_rating_extras_del ON rate.review_values; +DROP FUNCTION IF EXISTS rate.review_values_refresh_rating_extras(); + +DO $$ +BEGIN + IF to_regclass('rate.review_values') IS NOT NULL THEN + -- Only reviews whose value lifted them (the factor still exists until transaction 2). + PERFORM rate.refresh_rating_extras_of_products(ARRAY( + SELECT DISTINCT r.product_id + FROM rate.review_values v JOIN rate.reviews r ON r.id = v.review_id + WHERE rate.review_completeness_factor(v.value_breakdown) > 1)); + END IF; +END $$; + +COMMIT; + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +DROP FUNCTION IF EXISTS rate.store_review_values(jsonb); +DROP FUNCTION IF EXISTS rate.my_review_values(uuid[]); +DROP FUNCTION IF EXISTS rate.review_completeness_factor(jsonb); +DROP TABLE IF EXISTS rate.review_values; + +-- reviews_value_private stays: P2's public number columns must remain empty after a rollback +-- too (D7), and rate_worker can still write rate.reviews. +ALTER TABLE rate.reviews DROP CONSTRAINT IF EXISTS reviews_value_tier_known; +ALTER TABLE rate.reviews DROP COLUMN IF EXISTS value_tier; + +COMMIT; diff --git a/Docs/Proposals/2026-11-02-dos-me-review-value-score-verify.sql b/Docs/Proposals/2026-11-02-dos-me-review-value-score-verify.sql new file mode 100644 index 00000000..bacc4140 --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-review-value-score-verify.sql @@ -0,0 +1,97 @@ +-- VERIFY the PROPOSAL 2026-11-02-dos-me-review-value-score.sql (DOS/Rate#1547). Read-only. +-- Run after the migration on preview, beta and production. V1 to V7 return 0. +-- Run as postgres or service_role: V1, V5 and V8 call functions only service_role may execute +-- (a role without them fails loudly, never passes). V5 recomputes every master: run it off-peak. + +-- V1 the factor is the D6 formula on weightCompleteness +-- (packages/shared-data completenessWeightFactor); expect 0 +SELECT count(*) FROM (VALUES + (NULL::jsonb, 1.0000), ('{}', 1.0000), ('[]', 1.0000), ('{"weightCompleteness": "35"}', 1.0000), + ('{"weightCompleteness": 0}', 1.0000), ('{"weightCompleteness": 25}', 1.1786), + ('{"weightCompleteness": 35}', 1.2500), ('{"weightCompleteness": 99}', 1.2500), + ('{"weightCompleteness": -4}', 1.0000) +) AS t(breakdown, expected) +WHERE rate.review_completeness_factor(t.breakdown) IS DISTINCT FROM t.expected; + +-- V2 the rating reads the private values' factor, and the three triggers on them exist; expect 0 +SELECT 4 - ( + SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'rate' AND p.proname = 'master_rating_extras' + AND p.prosrc LIKE '%r.helpfulness_weight * rate.review_completeness_factor(v.value_breakdown)%' + AND p.prosrc LIKE '%LEFT JOIN rate.review_values v ON v.review_id = r.id%' +) - ( + SELECT count(*) FROM pg_trigger + WHERE tgrelid = 'rate.review_values'::regclass AND NOT tgisinternal + AND tgname IN ('trg_review_values_rating_extras_ins', 'trg_review_values_rating_extras_upd', + 'trg_review_values_rating_extras_del') +); + +-- V3 D7: anon and authenticated cannot read rate.review_values or call the writer, and anon +-- cannot call the reader; rate_worker reads the table but writes only through the writer; the +-- functions are security definer with an empty search_path; expect 0 +SELECT count(*) FROM ( + SELECT p.proname::text AS problem FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'rate' AND p.proname IN ('store_review_values', 'my_review_values') + AND NOT (p.prosecdef AND p.proconfig @> ARRAY['search_path=""']) + UNION ALL + SELECT r.role FROM unnest(ARRAY['anon', 'authenticated']) AS r(role) + WHERE has_table_privilege(r.role, 'rate.review_values', 'SELECT') + OR has_table_privilege(r.role, 'rate.review_values', 'INSERT') + OR has_table_privilege(r.role, 'rate.review_values', 'UPDATE') + OR has_table_privilege(r.role, 'rate.review_values', 'DELETE') + OR has_table_privilege(r.role, 'rate.review_values', 'TRUNCATE') + OR has_function_privilege(r.role, 'rate.store_review_values(jsonb)', 'EXECUTE') + OR has_function_privilege(r.role, 'rate.review_completeness_factor(jsonb)', 'EXECUTE') + UNION ALL + SELECT 'anon reader' WHERE has_function_privilege('anon', 'rate.my_review_values(uuid[])', 'EXECUTE') + UNION ALL + SELECT 'authenticated reader' + WHERE NOT has_function_privilege('authenticated', 'rate.my_review_values(uuid[])', 'EXECUTE') + UNION ALL + SELECT 'rate_worker' WHERE NOT has_table_privilege('rate_worker', 'rate.review_values', 'SELECT') + OR has_table_privilege('rate_worker', 'rate.review_values', 'INSERT') + OR has_table_privilege('rate_worker', 'rate.review_values', 'UPDATE') + OR has_table_privilege('rate_worker', 'rate.review_values', 'DELETE') + OR has_table_privilege('rate_worker', 'rate.review_values', 'TRUNCATE') + OR NOT has_function_privilege('rate_worker', 'rate.store_review_values(jsonb)', 'EXECUTE') + UNION ALL + SELECT 'rls' FROM pg_class WHERE oid = 'rate.review_values'::regclass AND NOT relrowsecurity +) x; + +-- V4 the CHECKs exist; expect 0 +SELECT 3 - count(*) FROM pg_constraint +WHERE (conrelid = 'rate.reviews'::regclass + AND conname IN ('reviews_value_tier_known', 'reviews_value_private')) + OR (conrelid = 'rate.review_values'::regclass AND conname = 'review_values_completeness'); + +-- V5 every master's stored rating equals what the functions compute; expect 0 +SELECT count(*) FROM rate.master_products m +CROSS JOIN LATERAL rate.master_rating_extras(m.id) x +WHERE (m.rating_score, m.rating_weight_sum, m.criteria_scores) + IS DISTINCT FROM (x.rating_score, x.rating_weight_sum, x.criteria_scores); + +-- V6 the number never reaches the public table: P2's columns are empty on every row (the CHECK +-- is NOT VALID), and every tier is known; expect 0 +SELECT count(*) FROM rate.reviews r +WHERE r.value_score IS NOT NULL OR r.value_breakdown IS NOT NULL + OR (r.value_tier IS NOT NULL AND r.value_tier NOT IN ('basic', 'complete', 'excellent')); + +-- V7 the public tier is the stored breakdown's, and follows the module's rule: none on a gated +-- review, else basic under completeness 30, excellent from score 70, complete between; a tier +-- only with a value; expect 0 +SELECT count(*) FROM rate.reviews r +LEFT JOIN rate.review_values v ON v.review_id = r.id +WHERE (v.review_id IS NULL AND r.value_tier IS NOT NULL) + OR (v.review_id IS NOT NULL AND ( + r.value_tier IS DISTINCT FROM (v.value_breakdown ->> 'tier') + OR (v.value_breakdown ->> 'tier') IS DISTINCT FROM CASE + WHEN v.value_breakdown -> 'gated' = 'true'::jsonb THEN NULL + WHEN (v.value_breakdown ->> 'completeness')::numeric < 30 THEN 'basic' + WHEN v.value_score >= 70 THEN 'excellent' + ELSE 'complete' END)); + +-- V8 (information) reviews scored, their mean score and how many are lifted +SELECT count(*) AS scored, + round(avg(value_score), 2) AS mean_score, + count(*) FILTER (WHERE rate.review_completeness_factor(value_breakdown) > 1) AS lifted +FROM rate.review_values; diff --git a/Docs/Proposals/2026-11-02-dos-me-review-value-score.md b/Docs/Proposals/2026-11-02-dos-me-review-value-score.md new file mode 100644 index 00000000..745bc3dc --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-review-value-score.md @@ -0,0 +1,203 @@ +# DOS-Me proposal: review value score and the completeness weight (P6) + +DOS/Rate#1547, phase P6 of the criteria ratings design (DOS/Rate#1531, +`Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html` section 6; D6 and D7 +approved by JOY on 2026-10-31). Status: **proposal, nothing applied.** It goes +through DOS-Me like P2 and P3, after JOY approves it. + +| File | What | +| --------------------------------------------------- | ------------------------------------------------------- | +| `2026-11-02-dos-me-review-value-score.sql` | the migration: one transaction, then a refresh | +| `2026-11-02-dos-me-review-value-score-verify.sql` | read-only checks, V1 to V7 return 0 (V8 is information) | +| `2026-11-02-dos-me-review-value-score-rollback.sql` | puts P2's function back and removes the lift | +| `scripts/test-review-value-proposal.mjs` | replays it all on PostgreSQL in Docker | + +## What it does + +### D7: the number is private, the tier is public + +- **`rate.review_values`** (new) holds each review's value score and breakdown. + - Row level security is on. `anon` and `authenticated` have no access at + all. + - `service_role` has full access; `rate_worker` can read it. + - Only `rate.store_review_values` writes it. +- **`rate.reviews.value_tier`** (new, nullable) is the public tier: `basic`, + `complete` or `excellent`. It is NULL when a review is not scored, or is + gated. +- **P2's `rate.reviews.value_score` and `value_breakdown` stay empty.** These + columns are on the publicly readable table, so a CHECK + (`reviews_value_private`) keeps any number from landing there. They can be + dropped in a later clean-up. +- **`rate.my_review_values(review_ids)`**, for `authenticated` users: the + writer reads the number and breakdown of their own reviews. It matches the + caller through `rate.review_authors` and takes at most 100 ids per call. + - Another user gets nothing; `anon` cannot call it. + - The web uses it for the writer's hint ("Thêm ưu nhược cho Pin để lên bậc + Đầy đủ"). +- **Sorting reviews by value** runs on the server with `service_role`. Pages + receive the order and the tier, never the number. + +### D6: the weight + +- **`rate.review_completeness_factor(breakdown)`** computes + `1 + 0.25 x weightCompleteness / 35`, from x1.00 to x1.25, at four decimals. + - It equals `completenessWeightFactor` in + `packages/shared-data/src/rules/review-value.ts` (DOS/Rate#1579); the replay + compares the two for every value. + - `weightCompleteness` drops the photo of a review without pros and cons, so + a marketplace review keeps its weight (design 6.2). +- **`rate.master_rating_extras`** joins the private values. Each review's + weight becomes `LEAST(3, helpfulness_weight x factor)`; nothing else in P2's + body changes. +- **Statement-level triggers on `rate.review_values`** refresh a master when a + review's factor changes. A rescore with the same completeness refreshes + nothing. P2's triggers on `rate.reviews` are not touched. + +### The writer: `rate.store_review_values(p_values)` + +- Callable by `rate_worker` (the scoring job) and `service_role`. +- Takes up to 1000 `{id, score, breakdown}` rows and 2 MB per call. +- It accepts only a breakdown the shared module could produce: + - exactly its eleven keys; + - each part one of its possible values; + - completeness a step of 5; + - `weightCompleteness` 5 less exactly for a photo without pros and cons; + - the score equal to the parts' sum; + - `tier` by the module's rule, and `gaps` from the known list; + - a gated review all 0, with no tier. +- It writes the number to `rate.review_values` and the tier to + `rate.reviews.value_tier`. It locks the reviews in id order first. +- One bad row refuses the whole call (22023, with the row's index). +- `rate_worker` has no direct write to `rate.review_values`. The old direct + path, its table grant on `rate.reviews`, cannot carry a number either, + because of the CHECK. + +### Locks and refresh + +- The ALTER TABLEs on `rate.reviews` (one nullable column, two NOT VALID + CHECKs) wait at most 5 s for their lock. Re-run the file if it times out. +- After the transaction, a refresh brings the masters of already stored values + back in line. It changes nothing on the first apply, because the table is + new. + +**Rating impact at apply time: none.** The values table starts empty, and a +review without a row has factor 1. Ratings move only as the scoring job stores +values, each review by at most +25% weight. + +## Order and dependencies + +- **Needs:** P2 (`20261101120000`) and P3 (`20261102090000`). Both are in + production. +- **Shared module:** DOS/Rate#1579 must be merged first, because the replay + test builds the shared module. +- **After the apply:** + - The scoring job in the importer (a follow-up) computes `reviewValue` for + each review and calls `store_review_values` as `rate_worker`, one worker + at a time. + - It retries a batch that a deadlock (40P01) aborts. Batches lock their + reviews in id order, but another writer may not. + - After each run, it runs V5 and refreshes any master V5 counts. P2's + refresh skips a master another transaction holds, so a master can be + left stale until its next change. + - The web sorts on the server and shows the tier publicly. It shows the + writer their own number through `my_review_values`. +- **The SQL repeats the module's constants**: 35, 0.25, the steps, the parts' + values, the tier thresholds, the gap names and the keys. + - A change to the module that changes its output needs a DOS-Me migration + first, with a `REVIEW_VALUE_VERSION` bump. + - Until then the writer refuses the new rows: it fails closed. + - The replay stores the module's output over a grid of 448 inputs, so CI + catches a drift. + +## Decisions + +1. **D7 is applied as approved:** the tier is public and the number is only + for the writer. No role but `service_role`, `rate_worker` and the review's + writer can read a stored number. Its limits, for information: + - **Most of the number can be rebuilt from public data.** The formula is + open, and its inputs are public: the text, the lines, the criteria, the + purchase mark, the votes and the tier. That gives the number to within + the trust part, about 5 points. D7 keeps the number off pages; it does + not make it secret against a determined observer. + - **The public `rating_weight_sum` can reveal a review's factor.** On a + master with a single counted review, or by comparing the sum before and + after a scoring batch, it gives `weightCompleteness`. Only that part is + exposed, and it is mostly computable from the public content anyway. + - **Sorting on the server** shows how reviews rank by value, but not the + numbers. + - **`rate_worker` can set `value_tier` directly on crawled reviews**, + through its table grant on `rate.reviews`. The CHECK limits it to the + three labels, so the tier can drift from the number but cannot carry the + number. V7 catches that drift. +2. **Who can lift a review**, accepted by the PO as recommended. + - The database cannot check a claimed completeness against the review's + text, lines and criteria. + - So a caller with `rate_worker` can, through the writer, give any review + up to +25% weight. + - `rate_worker` can already set `helpfulness_weight` up to 3 on crawled + reviews, which moves a rating much further. +3. **Side effects of the lift**, for information: + - A lifted review also counts toward D9's criterion threshold + (`LEAST(1, w)`), so a lift can make a criterion score appear sooner. + - `rating_weight_sum`, which is public, grows by the same amount. + - `user_rating_weighted`, the fallback rating, stays on the plain weight. + +## Apply + +1. **Before the apply**, run V5 of the verify file alone as a baseline, so + drift that P2 already has is not mistaken for P6. + - The migration refuses to run if any review already holds a number in + P2's public `value_score` or `value_breakdown`. Under the new CHECK such + a row could never be updated again. + - Nothing writes them today. If a writer appeared, move its values out + first. +2. Copy the `.sql` into DOS-Me `supabase/migrations/` with a new timestamp, + and the verify and rollback files into `scripts/ops/`, as for P3. +3. After the beta and production applies, run the verify file read-only as + `postgres` or `service_role`. V1 to V7 must return 0. V5 recomputes every + master, so run it off-peak. +4. **Rollback:** the rollback file, safe to run twice. Stop the scoring job + first. It runs in two transactions: + - first, P2's rating function goes back byte for byte, the triggers are + dropped, and the masters the values touched are refreshed; + - then the functions, `rate.review_values`, the tier column and its CHECK + are dropped, under a 5 s lock timeout. The CHECK that keeps P2's public + number columns empty stays. + + The stored values are lost; the scoring job recomputes them after a + re-apply. Afterwards, run V5. A master it counts was held by another + transaction during the refresh and needs + `SELECT rate.refresh_master_rating_extras(id)`. + +## Tests (local, Docker) + +`node scripts/test-review-value-proposal.mjs` (after building +`@repo/shared-data`): + +- the proposal applied twice with no rating change; +- the factor's parity with the shared module for 0 to 35, and for junk; +- the writer accepting the module's output over a grid of 448 inputs; +- the module's values stored as `rate_worker` (a community review included), + and the lifted rating equal to the module's formula, cap included; +- D7: + - `anon` reads the tier; + - `anon` and `authenticated` are refused on `rate.review_values`; + - P2's number columns stay empty; + - the writer reads their own number and nobody else's; + - `anon` cannot call the reader, and the id cap holds; +- a re-store that changes nothing, and a rescore with the same completeness + that refreshes nothing; +- the refusals, where nothing is written: + - not an array, too many rows or bytes; + - a bad id, version, key, type, tier, gap or part; + - a score that is not the sum; + - a gated review not scored 0; + - the photo rule broken in either direction; +- direct writes stopped: + - `rate_worker` on `rate.review_values`; + - a number on `rate.reviews`; + - the CHECKs; +- V1 to V7; +- the rollback twice: the lift and every object are gone, and P2 still + refreshes; +- the proposal again: no change until values are stored, then the lift. diff --git a/Docs/Proposals/2026-11-02-dos-me-review-value-score.sql b/Docs/Proposals/2026-11-02-dos-me-review-value-score.sql new file mode 100644 index 00000000..ce6f1557 --- /dev/null +++ b/Docs/Proposals/2026-11-02-dos-me-review-value-score.sql @@ -0,0 +1,432 @@ +-- ============================================================================= +-- PROPOSAL (DOS/Rate#1547): phase P6 of the DOS/Rate criteria ratings design (DOS/Rate#1531, +-- Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html, section 6; D6 and D7 approved by +-- JOY on 2026-10-31). A DOS-Me migration to review and apply; nothing here is applied by +-- DOS/Rate. Verify: 2026-11-02-dos-me-review-value-score-verify.sql (V1 to V7 return 0). +-- Rollback: 2026-11-02-dos-me-review-value-score-rollback.sql. +-- +-- What it does: +-- rate.review_values a review's value score and breakdown, PRIVATE (D7: the number only for +-- the writer). No anon or authenticated access; service_role and +-- rate_worker read it; only rate.store_review_values writes it. +-- rate.reviews.value_tier the public tier (D7): basic, complete or excellent; NULL when not +-- scored or gated. P2's rate.reviews.value_score and value_breakdown stay +-- empty, held there by a CHECK, so the number never reaches the public +-- table. +-- rate.review_completeness_factor(breakdown) the D6 factor, 1 to 1.25, from the breakdown's +-- weightCompleteness (completeness less the photo of a review without +-- pros and cons, so a marketplace review keeps its weight). Equal to +-- completenessWeightFactor in packages/shared-data/src/rules/review-value.ts. +-- rate.master_rating_extras each review's weight becomes LEAST(3, helpfulness_weight x +-- factor); nothing else in P2's body changes. +-- triggers on rate.review_values a change of a review's factor refreshes its master. +-- rate.store_review_values(p_values) the writer for the scoring job (rate_worker) and +-- service_role: only a breakdown the shared module could produce. +-- rate.my_review_values(p_review_ids) the writer of a review reads its own number and +-- breakdown (authenticated, through rate.review_authors). +-- +-- Depends on P2 (20261101120000) and P3 (20261102090000); both are in production. +-- +-- Rating impact at apply time: none. rate.review_values is new and empty, and a review without +-- a row has factor 1. Ratings move only as the job stores values, each review by at most +25%. +-- +-- Idempotent: IF NOT EXISTS for the table and column, constraints guarded by pg_constraint, +-- CREATE OR REPLACE for functions, DROP ... IF EXISTS before each trigger and policy. Section +-- 7, after the transaction, refreshes masters whose reviews already hold values (a re-apply); +-- on the first apply it changes nothing. +-- ============================================================================= + +BEGIN; + +-- The ALTER TABLEs on rate.reviews take a short ACCESS EXCLUSIVE lock: give up rather than +-- queue every reader behind a long query. Re-run the file if it times out. +SET LOCAL lock_timeout = '5s'; + +-- 1. The public tier, and P2's columns kept empty ----------------------------------------------- +-- First, so rate.reviews goes straight to ACCESS EXCLUSIVE (the CREATE TABLE below would +-- otherwise take SHARE ROW EXCLUSIVE first and upgrade). NOT VALID: no scan under the lock. + +-- A review that already holds a number in P2's public columns could never be updated again +-- under reviews_value_private: refuse the apply instead (move such values out first). One scan +-- of rate.reviews, before any strong lock. +DO $$ +BEGIN + IF to_regclass('rate.review_values') IS NULL + AND EXISTS (SELECT 1 FROM rate.reviews + WHERE value_score IS NOT NULL OR value_breakdown IS NOT NULL) THEN + RAISE EXCEPTION 'rate.reviews has value_score or value_breakdown set; P6 keeps them empty (D7)' + USING ERRCODE = '55000'; + END IF; +END $$; + +ALTER TABLE rate.reviews ADD COLUMN IF NOT EXISTS value_tier text; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'rate.reviews'::regclass + AND conname = 'reviews_value_tier_known') THEN + ALTER TABLE rate.reviews ADD CONSTRAINT reviews_value_tier_known + CHECK (value_tier IS NULL OR value_tier IN ('basic', 'complete', 'excellent')) NOT VALID; + END IF; + -- P2 put the number on the public table; it lives in rate.review_values instead. + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'rate.reviews'::regclass + AND conname = 'reviews_value_private') THEN + ALTER TABLE rate.reviews ADD CONSTRAINT reviews_value_private + CHECK (value_score IS NULL AND value_breakdown IS NULL) NOT VALID; + END IF; +END $$; + +-- 2. The private values ------------------------------------------------------------------------ +-- Writers: only rate.store_review_values (and service_role by hand). The refresh triggers +-- cover INSERT, UPDATE of the breakdown and DELETE; a TRUNCATE or a change of review_id (only +-- service_role can) needs a manual refresh of the masters involved. + +CREATE TABLE IF NOT EXISTS rate.review_values ( + review_id uuid PRIMARY KEY REFERENCES rate.reviews(id) ON DELETE CASCADE, + value_score numeric(5, 2) NOT NULL CHECK (value_score BETWEEN 0 AND 100), + value_breakdown jsonb NOT NULL CHECK (jsonb_typeof(value_breakdown) = 'object'), + updated_at timestamptz NOT NULL DEFAULT now(), + -- Both completeness keys numbers, completeness a step of 5, the weight one equal to it or + -- the 5 of a photo less; the breakdown's score is value_score. + CONSTRAINT review_values_completeness CHECK ( + CASE WHEN jsonb_typeof(value_breakdown -> 'completeness') = 'number' + AND jsonb_typeof(value_breakdown -> 'weightCompleteness') = 'number' + AND jsonb_typeof(value_breakdown -> 'score') = 'number' + THEN (value_breakdown ->> 'completeness')::numeric IN (0, 5, 10, 15, 20, 25, 30, 35) + AND (value_breakdown ->> 'completeness')::numeric + - (value_breakdown ->> 'weightCompleteness')::numeric IN (0, 5) + AND (value_breakdown ->> 'weightCompleteness')::numeric >= 0 + AND round((value_breakdown ->> 'score')::numeric, 2) = value_score + ELSE false END) +); + +COMMENT ON TABLE rate.review_values IS + 'Value score of a review (DOS/Rate#1547). Private (D7): the public sees only rate.reviews.value_tier; the writer reads its own through rate.my_review_values.'; + +ALTER TABLE rate.review_values ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON rate.review_values FROM PUBLIC, anon, authenticated, rate_worker; +GRANT ALL ON rate.review_values TO service_role; +GRANT SELECT ON rate.review_values TO rate_worker; +DROP POLICY IF EXISTS "Service role access review_values" ON rate.review_values; +CREATE POLICY "Service role access review_values" ON rate.review_values + FOR ALL TO service_role USING (true) WITH CHECK (true); +DROP POLICY IF EXISTS "Rate worker read review_values" ON rate.review_values; +CREATE POLICY "Rate worker read review_values" ON rate.review_values + FOR SELECT TO rate_worker USING (true); + + +-- 3. The D6 factor ----------------------------------------------------------------------------- +-- 1 + 0.25 x weightCompleteness / 35 at four decimals. A missing breakdown, a missing or +-- non-number weightCompleteness count as 0 (factor 1); a number outside 0 to 35 as its nearest +-- bound. + +CREATE OR REPLACE FUNCTION rate.review_completeness_factor(p_breakdown jsonb) +RETURNS numeric +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +SET search_path = '' +AS $$ + SELECT round(1 + 0.25 * LEAST(35, GREATEST(0, + CASE WHEN jsonb_typeof(p_breakdown) = 'object' + AND jsonb_typeof(p_breakdown -> 'weightCompleteness') = 'number' + THEN (p_breakdown ->> 'weightCompleteness')::numeric + ELSE 0 END)) / 35, 4) +$$; + +REVOKE EXECUTE ON FUNCTION rate.review_completeness_factor(jsonb) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION rate.review_completeness_factor(jsonb) TO service_role; + +-- 4. The rating reads it ----------------------------------------------------------------------- +-- P2's body with two changes, marked P6: the private values joined, and the weight. + +CREATE OR REPLACE FUNCTION rate.master_rating_extras(p_master_id uuid) +RETURNS TABLE (rating_score numeric, rating_weight_sum numeric, criteria_scores jsonb) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_item uuid := rate.master_rating_item(p_master_id); +BEGIN + RETURN QUERY EXECUTE format($q$ + WITH eligible AS ( + -- P6: the stored weight times the completeness factor (D6), capped at 3. + SELECT LEAST(3.0, r.helpfulness_weight * rate.review_completeness_factor(v.value_breakdown))::numeric AS w, + COALESCE(r.rating_exact, r.rating_star)::numeric AS star, + r.criteria_ratings + FROM rate.products p + JOIN rate.reviews r ON r.product_id = p.id + JOIN rate.review_flags f ON f.review_id = r.id + LEFT JOIN rate.review_values v ON v.review_id = r.id -- P6 + WHERE %s = $1 + AND p.origin = 'crawl' + AND r.origin IN ('crawl', 'community') + AND r.reviewer_type = 'user' + AND COALESCE(f.include_in_rating, true) + AND r.helpfulness_weight > 0 + ), + overall AS ( + SELECT round(COALESCE(sum(w), 0), 2) AS weight_sum, + CASE WHEN sum(w) > 0 THEN round((3 * 4.2 + sum(w * star)) / (3 + sum(w)), 3) END AS score + FROM eligible + WHERE star BETWEEN 1 AND 5 + ), + per_criterion AS ( + SELECT c.key, + count(*) AS writers, + round(sum(e.w), 2) AS weight_sum, + sum(LEAST(1.0, e.w)) AS shown_sum, + round((3 * 4.2 + sum(e.w * (c.value #>> '{}')::numeric)) / (3 + sum(e.w)), 3) AS score + FROM eligible e + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(e.criteria_ratings) = 'object' THEN e.criteria_ratings + ELSE '{}'::jsonb END + ) AS c(key, value) + WHERE c.key ~ '^[a-z_]{2,32}$' + AND jsonb_typeof(c.value) = 'number' + AND (c.value #>> '{}')::numeric BETWEEN 1 AND 5 + GROUP BY c.key + ) + SELECT o.score, + o.weight_sum, + COALESCE( + (SELECT jsonb_object_agg(pc.key, jsonb_build_object( + 'score', pc.score, 'weight_sum', pc.weight_sum, 'writers', pc.writers)) + FROM per_criterion pc + WHERE pc.writers >= 3 AND pc.shown_sum >= 1.5), + '{}'::jsonb) + FROM overall o + $q$, CASE WHEN v_item IS NULL THEN 'p.cluster_id' ELSE 'p.item_id' END) + USING COALESCE(v_item, p_master_id); +END $$; + +-- CREATE OR REPLACE keeps P2's grants (service_role only). + +-- A change of a review's factor refreshes its master. Statement-level, as P2's triggers; only +-- rows whose factor changed count, so a rescore with the same completeness refreshes nothing. +CREATE OR REPLACE FUNCTION rate.review_values_refresh_rating_extras() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + PERFORM rate.refresh_rating_extras_of_products(ARRAY( + SELECT r.product_id FROM changed_new n JOIN rate.reviews r ON r.id = n.review_id + WHERE rate.review_completeness_factor(n.value_breakdown) <> 1)); + ELSIF TG_OP = 'UPDATE' THEN + PERFORM rate.refresh_rating_extras_of_products(ARRAY( + SELECT r.product_id + FROM changed_new n JOIN changed_old o ON o.review_id = n.review_id + JOIN rate.reviews r ON r.id = n.review_id + WHERE rate.review_completeness_factor(n.value_breakdown) + IS DISTINCT FROM rate.review_completeness_factor(o.value_breakdown))); + ELSE + -- A row deleted with its review (ON DELETE CASCADE) finds no review here; the reviews + -- trigger refreshes that master. + PERFORM rate.refresh_rating_extras_of_products(ARRAY( + SELECT r.product_id FROM changed_old o JOIN rate.reviews r ON r.id = o.review_id + WHERE rate.review_completeness_factor(o.value_breakdown) <> 1)); + END IF; + RETURN NULL; +END $$; + +REVOKE EXECUTE ON FUNCTION rate.review_values_refresh_rating_extras() FROM PUBLIC, anon, authenticated; + +DROP TRIGGER IF EXISTS trg_review_values_rating_extras_ins ON rate.review_values; +CREATE TRIGGER trg_review_values_rating_extras_ins AFTER INSERT ON rate.review_values + REFERENCING NEW TABLE AS changed_new + FOR EACH STATEMENT EXECUTE FUNCTION rate.review_values_refresh_rating_extras(); +DROP TRIGGER IF EXISTS trg_review_values_rating_extras_upd ON rate.review_values; +CREATE TRIGGER trg_review_values_rating_extras_upd AFTER UPDATE ON rate.review_values + REFERENCING OLD TABLE AS changed_old NEW TABLE AS changed_new + FOR EACH STATEMENT EXECUTE FUNCTION rate.review_values_refresh_rating_extras(); +DROP TRIGGER IF EXISTS trg_review_values_rating_extras_del ON rate.review_values; +CREATE TRIGGER trg_review_values_rating_extras_del AFTER DELETE ON rate.review_values + REFERENCING OLD TABLE AS changed_old + FOR EACH STATEMENT EXECUTE FUNCTION rate.review_values_refresh_rating_extras(); + +-- 5. The writer -------------------------------------------------------------------------------- +-- p_values: a JSON array of {"id": review uuid, "score": 0-100, "breakdown": {...}}, at most +-- 1000 rows and 2 MB. Each breakdown must be one the shared module could produce: +-- exactly its eleven keys; "version" 1; "gated" a boolean; +-- "completeness" a step of 5 up to 35; "weightCompleteness" 5 less exactly for a photo +-- without pros and cons ("gaps" has "pros_cons" and not "photo"), else equal to it; +-- "specificity" one of the depth points; "verified" 0 or 15; "helpful" 0 to 20; "trust" 0 to +-- 10; "score" the row's score and the parts' sum; +-- "gaps" distinct known gaps; "tier" the module's rule (basic under completeness 30, +-- excellent from score 70, else complete); +-- a gated row: every part 0, tier null, no gaps. +-- A malformed row fails the whole call (22023) and names its index; nothing is written. Rows +-- of reviews that do not exist are skipped. It writes rate.review_values (the number, private) +-- and rate.reviews.value_tier (the tier, public). The reviews are locked FOR NO KEY UPDATE in id +-- order first: two batches never deadlock, and inserts that reference those reviews (flags, +-- lines, votes) are not blocked. Another writer that updates several reviews out of id order +-- can still deadlock with a batch; PostgreSQL aborts one side (40P01), so the job retries. +-- Returns the number of reviews whose stored value changed. Run one job worker at a time; a +-- batch refreshes its masters inside the call. +CREATE OR REPLACE FUNCTION rate.store_review_values(p_values jsonb) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_row jsonb; + v_index integer := 0; + v_b jsonb; + v_score numeric; + v_sum numeric; + v_c numeric; + v_gaps text[]; + v_tier text; + v_changed integer; +BEGIN + IF jsonb_typeof(p_values) IS DISTINCT FROM 'array' THEN + RAISE EXCEPTION 'p_values must be a JSON array' USING ERRCODE = '22023'; + END IF; + IF jsonb_array_length(p_values) > 1000 OR octet_length(p_values::text) > 2097152 THEN + RAISE EXCEPTION 'at most 1000 rows and 2 MB per call' USING ERRCODE = '22023'; + END IF; + FOR v_row IN SELECT value FROM jsonb_array_elements(p_values) LOOP + v_b := v_row -> 'breakdown'; + IF jsonb_typeof(v_row) IS DISTINCT FROM 'object' + OR jsonb_typeof(v_row -> 'id') IS DISTINCT FROM 'string' + OR (v_row ->> 'id') !~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + OR jsonb_typeof(v_row -> 'score') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b) IS DISTINCT FROM 'object' + OR v_b - ARRAY['version', 'gated', 'score', 'completeness', 'weightCompleteness', + 'specificity', 'verified', 'helpful', 'trust', 'tier', 'gaps'] <> '{}'::jsonb + OR NOT v_b ?& ARRAY['version', 'gated', 'score', 'completeness', 'weightCompleteness', + 'specificity', 'verified', 'helpful', 'trust', 'tier', 'gaps'] + OR v_b -> 'version' IS DISTINCT FROM '1'::jsonb + OR jsonb_typeof(v_b -> 'gated') IS DISTINCT FROM 'boolean' + OR jsonb_typeof(v_b -> 'score') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b -> 'completeness') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b -> 'weightCompleteness') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b -> 'specificity') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b -> 'verified') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b -> 'helpful') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b -> 'trust') IS DISTINCT FROM 'number' + OR jsonb_typeof(v_b -> 'tier') NOT IN ('string', 'null') + OR jsonb_typeof(v_b -> 'gaps') IS DISTINCT FROM 'array' + OR EXISTS (SELECT 1 FROM jsonb_array_elements(v_b -> 'gaps') g + WHERE jsonb_typeof(g) IS DISTINCT FROM 'string') THEN + RAISE EXCEPTION 'row % is not a review value', v_index USING ERRCODE = '22023'; + END IF; + v_score := (v_row ->> 'score')::numeric; + v_c := (v_b ->> 'completeness')::numeric; + v_sum := v_c + (v_b ->> 'specificity')::numeric + + (v_b ->> 'verified')::numeric + (v_b ->> 'helpful')::numeric + + (v_b ->> 'trust')::numeric; + v_gaps := ARRAY(SELECT jsonb_array_elements_text(v_b -> 'gaps')); + v_tier := v_b ->> 'tier'; + IF v_c NOT IN (0, 5, 10, 15, 20, 25, 30, 35) + OR v_c - (v_b ->> 'weightCompleteness')::numeric NOT IN (0, 5) + -- 5 off exactly when a review has a photo, no pros and cons, and some completeness + OR (v_c - (v_b ->> 'weightCompleteness')::numeric = 5) + IS DISTINCT FROM ('pros_cons' = ANY (v_gaps) AND NOT 'photo' = ANY (v_gaps) AND v_c > 0) + OR (v_b ->> 'specificity')::numeric NOT IN (0, 5, 12, 17, 20) + OR (v_b ->> 'verified')::numeric NOT IN (0, 15) + OR (v_b ->> 'helpful')::numeric NOT BETWEEN 0 AND 20 + OR (v_b ->> 'trust')::numeric NOT BETWEEN 0 AND 10 + OR v_score NOT BETWEEN 0 AND 100 + OR (v_b ->> 'score')::numeric <> v_score + OR abs(v_sum - v_score) > 0.01 + OR cardinality(v_gaps) <> (SELECT count(DISTINCT g) FROM unnest(v_gaps) g) + OR NOT v_gaps <@ ARRAY['detail', 'criteria', 'verdict', 'pros_cons', 'criterion_points', + 'photo', 'verified'] + -- in parentheses: plpgsql ends an IF condition at the first bare THEN + OR (CASE WHEN (v_b ->> 'gated')::boolean + THEN v_sum <> 0 OR v_score <> 0 OR v_tier IS NOT NULL OR cardinality(v_gaps) <> 0 + ELSE v_tier IS DISTINCT FROM CASE WHEN v_c < 30 THEN 'basic' + WHEN v_score >= 70 THEN 'excellent' + ELSE 'complete' END + END) THEN + RAISE EXCEPTION 'row % has values out of range', v_index USING ERRCODE = '22023'; + END IF; + v_index := v_index + 1; + END LOOP; + + -- The batch is read straight from p_values in each statement, with no temp table a caller's + -- session could have prepared: a review listed twice, the last row wins; reviews that do not + -- exist are skipped by the joins. + PERFORM 1 FROM rate.reviews r + WHERE r.id IN (SELECT (e ->> 'id')::uuid FROM jsonb_array_elements(p_values) e) + ORDER BY r.id + FOR NO KEY UPDATE; + + INSERT INTO rate.review_values AS rv (review_id, value_score, value_breakdown, updated_at) + SELECT b.review_id, b.value_score, b.value_breakdown, now() + FROM (SELECT DISTINCT ON ((e ->> 'id')::uuid) + (e ->> 'id')::uuid AS review_id, round((e ->> 'score')::numeric, 2) AS value_score, + e -> 'breakdown' AS value_breakdown + FROM jsonb_array_elements(p_values) WITH ORDINALITY AS a(e, n) + ORDER BY (e ->> 'id')::uuid, n DESC) b + JOIN rate.reviews r ON r.id = b.review_id + ORDER BY b.review_id + ON CONFLICT (review_id) DO UPDATE + SET value_score = EXCLUDED.value_score, + value_breakdown = EXCLUDED.value_breakdown, + updated_at = EXCLUDED.updated_at + WHERE (rv.value_score, rv.value_breakdown) + IS DISTINCT FROM (EXCLUDED.value_score, EXCLUDED.value_breakdown); + GET DIAGNOSTICS v_changed = ROW_COUNT; + + UPDATE rate.reviews r + SET value_tier = b.value_breakdown ->> 'tier' + FROM (SELECT DISTINCT ON ((e ->> 'id')::uuid) + (e ->> 'id')::uuid AS review_id, e -> 'breakdown' AS value_breakdown + FROM jsonb_array_elements(p_values) WITH ORDINALITY AS a(e, n) + ORDER BY (e ->> 'id')::uuid, n DESC) b + WHERE r.id = b.review_id + AND r.value_tier IS DISTINCT FROM (b.value_breakdown ->> 'tier'); + + RETURN v_changed; +END $$; + +REVOKE EXECUTE ON FUNCTION rate.store_review_values(jsonb) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION rate.store_review_values(jsonb) TO service_role, rate_worker; + +-- 6. The writer of a review reads its number --------------------------------------------------- +-- Up to 100 review ids; returns the ones the caller wrote (rate.review_authors) that have a +-- value. Anyone else's reviews, and anon, get nothing. +CREATE OR REPLACE FUNCTION rate.my_review_values(p_review_ids uuid[]) +RETURNS TABLE (review_id uuid, value_score numeric, value_breakdown jsonb) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_uid uuid := auth.uid(); +BEGIN + IF v_uid IS NULL THEN + RETURN; + END IF; + IF cardinality(p_review_ids) > 100 THEN + RAISE EXCEPTION 'at most 100 review ids per call' USING ERRCODE = '22023'; + END IF; + RETURN QUERY + SELECT v.review_id, v.value_score, v.value_breakdown + FROM rate.review_values v + JOIN rate.review_authors a ON a.review_id = v.review_id + WHERE v.review_id = ANY (p_review_ids) + AND a.author_user_id = v_uid; +END $$; + +REVOKE EXECUTE ON FUNCTION rate.my_review_values(uuid[]) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION rate.my_review_values(uuid[]) TO authenticated, service_role; + +COMMIT; + +-- 7. Masters with values stored before this apply ---------------------------------------------- +-- A no-op on the first apply (the table is new). On a re-apply over kept values it brings their +-- masters back in line. Outside the transaction, so it holds no ALTER TABLE lock. +SELECT rate.refresh_rating_extras_of_products(ARRAY( + SELECT DISTINCT r.product_id + FROM rate.review_values v JOIN rate.reviews r ON r.id = v.review_id + WHERE rate.review_completeness_factor(v.value_breakdown) > 1)); diff --git a/packages/shared-data/src/rules/review-value.ts b/packages/shared-data/src/rules/review-value.ts index 59ae565a..a464f442 100644 --- a/packages/shared-data/src/rules/review-value.ts +++ b/packages/shared-data/src/rules/review-value.ts @@ -6,10 +6,11 @@ * the product rating, through a weight factor from x1.00 to x1.25 (D6). * * Plain data and functions only, like the rest of `rules`, and no database: - * the importer job and the community path read a review into a - * ReviewValueInput and store the result in `rate.reviews.value_score` and - * `value_breakdown`. The DOS-Me rating refresh reads `weightCompleteness` from - * `value_breakdown` and applies completenessWeightFactor, so the two stay + * the scoring job reads a review into a ReviewValueInput and stores the + * result through `rate.store_review_values`, which keeps the number in the + * private `rate.review_values` and the tier in `rate.reviews.value_tier` + * (DOS/Rate#1580). The DOS-Me rating refresh reads `weightCompleteness` from + * the stored breakdown and applies completenessWeightFactor, so the two stay * equal. */ import { getRatingCriteria, hasRatingCriteria } from './rating-criteria.js'; diff --git a/scripts/test-review-value-proposal.mjs b/scripts/test-review-value-proposal.mjs new file mode 100644 index 00000000..51c66369 --- /dev/null +++ b/scripts/test-review-value-proposal.mjs @@ -0,0 +1,585 @@ +#!/usr/bin/env node +// Replays the DOS-Me review value PROPOSAL (DOS/Rate#1547, phase P6 of #1531) on the migration +// replay stack (P2 and P3 included): a master with crawled and community reviews, the proposal +// twice with no rating change, the factor equal to the shared module's for every completeness, +// values from the shared module stored as rate_worker and the rating they lift, D7 (the number +// private, the tier public, the writer reading their own), the writer's +// refusals and grants, every verification query, the rollback twice (the lift is gone) and the +// proposal again. Nothing touches a real database. +// +// Usage: pnpm --filter @repo/shared-data build && node scripts/test-review-value-proposal.mjs +// (needs Docker) + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +import { repoRoot, startRateDb } from './lib/rate-db.mjs'; + +const rules = await import( + pathToFileURL( + path.join(repoRoot, 'packages', 'shared-data', 'dist', 'rules', 'index.js') + ).href +); +const read = (name) => + readFileSync(path.join(repoRoot, 'Docs', 'Proposals', name), 'utf8'); +const proposal = read('2026-11-02-dos-me-review-value-score.sql'); +const rollback = read('2026-11-02-dos-me-review-value-score-rollback.sql'); +const verify = read('2026-11-02-dos-me-review-value-score-verify.sql'); + +const container = `rate-review-value-proposal-${process.pid}`; +const removeContainer = () => + spawnSync('docker', ['rm', '--force', container], { timeout: 30_000 }); +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + removeContainer(); + process.exit(130); + }); +} + +const checks = [ + ...verify.matchAll( + /^-- (V\d+) [^\n]*(?:\n--[^\n]*)*expect 0\n((?:SELECT|WITH)[\s\S]*?;)/gm + ), +].map(([, id, query]) => ({ id, query })); +assert.deepEqual( + checks.map((c) => c.id), + ['V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'V7'], + 'the verification lists its expect-0 queries' +); + +const PHONES = 'c0000000-0000-4000-8000-000000000001'; +const MERCHANT = 'e0000000-0000-4000-8000-000000000001'; +const MASTER = 'a0000000-0000-4000-8000-000000000001'; +const LISTING = 'b0000000-0000-4000-8000-000000000001'; +const review = (n) => `d0000000-0000-4000-8000-00000000000${n}`; +// review: [star, helpfulness_weight, community] +const REVIEWS = { + 1: [5, 1, false], + 2: [4, 1, false], + 3: [2, 1, false], + 4: [4, 2.8, false], + 5: [3, 1, true], +}; + +const phone = rules.getRatingCriteria('smartphones'); +const allRated = Object.fromEntries(phone.map((c) => [c.key, 4])); +const general = [ + { kind: 'pro', criterionKey: null, body: 'Pin dùng cả ngày' }, + { kind: 'con', criterionKey: null, body: 'Sạc hơi chậm' }, +]; +const base = { + categorySlug: 'smartphones', + gated: false, + depth: 'detailed', + criteriaRatings: allRated, + verdict: 'Máy mượt, pin dùng hết ngày, đáng tiền trong tầm giá.', + points: general, + hasRealPhoto: false, + verifiedPurchase: false, + helpfulVotes: 0, + unhelpfulVotes: 0, + authorTrust: null, +}; +// Review 1 complete (35), 4 complete (35, its weight hits the cap), 5 a standard Rate review +// (25), 2 an imported review with a photo (5, but weight 0), 3 gated. +const values = { + 1: rules.reviewValue({ + ...base, + hasRealPhoto: true, + points: [ + ...general, + { kind: 'pro', criterionKey: 'camera', body: 'Chụp đêm rõ nét' }, + { kind: 'con', criterionKey: 'display', body: 'Màn hơi tối ngoài nắng' }, + ], + }), + 2: rules.reviewValue({ + ...base, + criteriaRatings: null, + verdict: null, + points: [], + depth: 'specific_short', + verifiedPurchase: true, + helpfulVotes: 3, + hasRealPhoto: true, + }), + 3: rules.reviewValue({ ...base, gated: true }), + 5: rules.reviewValue(base), +}; +values[4] = values[1]; +assert.equal(values[1].completeness, 35); +assert.equal(values[5].completeness, 25); +assert.equal(values[2].completeness, 5, 'a photo'); +assert.equal(values[2].weightCompleteness, 0, 'no pros and cons: no lift'); +const payload = (ids) => + JSON.stringify( + ids.map((n) => ({ + id: review(n), + score: values[n].score, + breakdown: values[n], + })) + ); + +// The rating as master_rating_extras computes it, from the shared module's factor. +const expectedScore = (lifted) => { + let sum = 0; + let weights = 0; + for (const [n, [star, weight]] of Object.entries(REVIEWS)) { + const factor = lifted + ? rules.completenessWeightFactor(values[n]?.weightCompleteness) + : 1; + const w = Math.min(3, weight * factor); + sum += w * star; + weights += w; + } + return ((3 * 4.2 + sum) / (3 + weights)).toFixed(3); +}; + +try { + const { sql, asRole } = startRateDb({ name: container }); + const checkAll = (when) => { + for (const { id, query } of checks) + assert.equal(sql(query), '0', `${id} ${when}`); + }; + const score = () => + sql(`SELECT rating_score FROM rate.master_products WHERE id = '${MASTER}'`); + const stored = () => sql(`SELECT count(*) FROM rate.review_values`); + const tier = (n) => + sql( + `SELECT coalesce(value_tier, '-') FROM rate.reviews WHERE id = '${review(n)}'` + ); + const asUser = (sub, query) => + sql( + `SET ROLE authenticated; SET request.jwt.claims = '${JSON.stringify({ sub, role: 'authenticated' })}'; ${query}; RESET ROLE;` + ); + const store = (json, role = 'rate_worker') => + asRole(role, `SELECT rate.store_review_values($v$${json}$v$::jsonb)`); + + const rows = Object.entries(REVIEWS) + .map( + ([n, [star, , community]]) => + `('${review(n)}', '${LISTING}', ${star}, 'Máy dùng ổn định, pin tốt.', 'user', ` + + `${community ? `'rate_comm_${n}'` : 'NULL'})` + ) + .join(',\n'); + sql(` + INSERT INTO rate.categories (id, name, slug) VALUES ('${PHONES}', 'Điện thoại', 'smartphones'); + INSERT INTO rate.merchants (id, platform, platform_shop_id, name, slug) + VALUES ('${MERCHANT}', 'shopee', 's1', 'Shop 1', 'shop-1'); + INSERT INTO rate.master_products (id, brand, model, model_key, name, slug, category_id, status, + storage_gb, min_price, max_price) + VALUES ('${MASTER}', 'Acme', 'Phone X', 'acme:phone-x:128gb', 'Acme Phone X 128GB', + 'acme-phone-x-128gb', '${PHONES}', 'published', 128, 10000000, 10000000); + INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, price_current, url, cluster_id) + VALUES ('${LISTING}', '${MERCHANT}', 'shopee', 'x128', 'Acme Phone X 128GB', 10000000, + 'https://shopee.vn/x128', '${MASTER}'); + INSERT INTO rate.reviews (id, product_id, rating_star, comment, reviewer_type, platform_review_id) + VALUES ${rows}; + INSERT INTO rate.review_flags (review_id, verdict, include_in_rating, detector, detector_version, input_hash) + SELECT id, 'authentic', true, 'replay', 'v1', id::text FROM rate.reviews; + ${Object.entries(REVIEWS) + .map( + ([n, [, weight]]) => + `UPDATE rate.reviews SET helpfulness_weight = ${weight} WHERE id = '${review(n)}';` + ) + .join('\n')} + `); + assert.equal( + sql(`SELECT count(*) FROM rate.reviews WHERE origin = 'community'`), + '1' + ); + const before = score(); + assert.equal(before, expectedScore(false), 'P2 rates the master'); + + // A number already in P2's public columns refuses the apply, which then leaves nothing. + sql(`UPDATE rate.reviews SET value_score = 50 WHERE id = '${review(1)}'`); + assert.throws( + () => sql(proposal), + /rate.reviews has value_score or value_breakdown set/ + ); + assert.equal( + sql( + `SELECT (to_regclass('rate.review_values') IS NULL)::text || ',' || count(*) + FROM information_schema.columns + WHERE table_schema = 'rate' AND table_name = 'reviews' AND column_name = 'value_tier'` + ), + 'true,0' + ); + sql(`UPDATE rate.reviews SET value_score = NULL WHERE id = '${review(1)}'`); + + // Applied twice: no stored rating changes, every check passes. + sql(proposal); + sql(proposal); + assert.equal(score(), before, 'applying moves no rating'); + checkAll('after the proposal'); + + // The factor equals the shared module's for every completeness and for junk. + const samples = [ + ...Array.from({ length: 36 }, (_, c) => [ + `{"weightCompleteness": ${c}}`, + c, + ]), + ['{"weightCompleteness": 35.5}', 35.5], + ['{"weightCompleteness": -1}', -1], + ['{"weightCompleteness": 1e9}', 1e9], + ['{"weightCompleteness": "35"}', '35'], + ['{"weightCompleteness": null}', null], + ['{}', undefined], + ['[35]', undefined], + ]; + for (const [json, completeness] of samples) + assert.equal( + Number(sql(`SELECT rate.review_completeness_factor('${json}'::jsonb)`)), + rules.completenessWeightFactor(completeness), + `factor of ${json}` + ); + assert.equal(Number(sql(`SELECT rate.review_completeness_factor(NULL)`)), 1); + + // The writer accepts every output of the module over a grid of its inputs (each completeness + // item on or off, every depth, gated, verified, votes, trust), so a module change the SQL + // does not follow fails here, not in production. Reviews that do not exist are only checked. + const grid = []; + const extraLines = [ + { kind: 'pro', criterionKey: 'camera', body: 'Chụp đêm rõ nét' }, + { kind: 'con', criterionKey: 'display', body: 'Màn hơi tối ngoài nắng' }, + ]; + for (const depth of Object.keys(rules.REVIEW_SPECIFICITY_POINTS)) + for (let bits = 0; bits < 64; bits += 1) + grid.push( + rules.reviewValue({ + ...base, + depth, + criteriaRatings: bits & 1 ? allRated : {}, + verdict: bits & 2 ? base.verdict : null, + points: [ + ...(bits & 4 ? general : []), + ...(bits & 8 ? extraLines : []), + ], + hasRealPhoto: Boolean(bits & 16), + verifiedPurchase: Boolean(bits & 32), + gated: depth === 'detailed' && bits % 7 === 0, + helpfulVotes: bits % 9, + unhelpfulVotes: bits % 4, + authorTrust: bits % 5 === 0 ? null : (bits % 11) / 10, + }) + ); + assert.ok(grid.some((v) => v.weightCompleteness < v.completeness)); + assert.ok(new Set(grid.map((v) => v.tier)).size === 4, 'every tier and null'); + for (let i = 0; i < grid.length; i += 400) + assert.equal( + store( + JSON.stringify( + grid.slice(i, i + 400).map((v, j) => ({ + id: `f0000000-0000-4000-8000-${String(i + j).padStart(12, '0')}`, + score: v.score, + breakdown: v, + })) + ) + ), + '0', + 'grid rows are accepted (and no such review exists)' + ); + + // Stored as rate_worker, community review included: the rating lifts as the module says. + assert.equal(store(payload([1, 2, 3, 4, 5])), '5'); + assert.equal(stored(), '5'); + const lifted = score(); + assert.equal(lifted, expectedScore(true), 'the lifted rating'); + assert.notEqual(lifted, before); + assert.equal( + sql( + `SELECT rating_weight_sum FROM rate.master_products WHERE id = '${MASTER}'` + ), + // 1.25 + 1 + 1 + 3 (2.8 x 1.25 capped) + 1.1786 + '7.43' + ); + assert.equal( + sql( + `SELECT value_score FROM rate.review_values WHERE review_id = '${review(1)}'` + ), + values[1].score.toFixed(2) + ); + // The public table gets the tier only (D7); P2's number columns stay empty. + assert.equal(tier(1), values[1].tier); + assert.equal(tier(2), 'basic'); + assert.equal(tier(3), '-', 'a gated review shows no tier'); + assert.equal( + sql( + `SELECT count(*) FROM rate.reviews WHERE value_score IS NOT NULL OR value_breakdown IS NOT NULL` + ), + '0' + ); + // The same values again change nothing; a new score with the same completeness refreshes + // nothing either (the rating stays). + assert.equal(store(payload([1, 2, 3, 4, 5])), '0'); + const rescored = { ...values[5], helpful: 4.1, score: values[5].score + 4.1 }; + assert.equal( + store( + JSON.stringify([ + { id: review(5), score: rescored.score, breakdown: rescored }, + ]) + ), + '1' + ); + assert.equal(score(), lifted); + assert.equal(store(payload([5])), '1'); + checkAll('after storing values'); + + // D7: the public reads the tier and never the number; the writer reads their own number. + assert.equal( + asRole( + 'anon', + `SELECT value_tier FROM rate.reviews WHERE id = '${review(1)}'` + ), + values[1].tier + ); + for (const role of ['anon', 'authenticated']) + assert.throws( + () => asRole(role, `SELECT count(*) FROM rate.review_values`), + /permission denied for table review_values/ + ); + const WRITER = '11111111-1111-4111-8111-111111111111'; + const OTHER = '22222222-2222-4222-8222-222222222222'; + sql(` + INSERT INTO auth.users (id, email) VALUES ('${WRITER}', 'w@example.test'), ('${OTHER}', 'o@example.test'); + INSERT INTO rate.review_authors (review_id, author_user_id) VALUES ('${review(5)}', '${WRITER}'); + `); + const mine = (sub) => + asUser( + sub, + `SELECT string_agg(review_id || ':' || value_score || ':' || (value_breakdown ->> 'tier'), ',') + FROM rate.my_review_values(ARRAY['${review(5)}', '${review(1)}']::uuid[])` + ); + assert.equal( + mine(WRITER), + `${review(5)}:${values[5].score.toFixed(2)}:${values[5].tier}`, + 'the writer sees their own number, and only their own' + ); + assert.equal(mine(OTHER), '', 'another user sees nothing'); + assert.throws( + () => + asRole( + 'anon', + `SELECT * FROM rate.my_review_values(ARRAY['${review(5)}']::uuid[])` + ), + /permission denied for function my_review_values/ + ); + assert.throws( + () => + asUser( + WRITER, + `SELECT * FROM rate.my_review_values(ARRAY(SELECT gen_random_uuid() FROM generate_series(1, 101)))` + ), + /at most 100 review ids/ + ); + + // Refusals: nothing is written by a call with one bad row. + const refuse = (json, pattern, role = 'rate_worker') => + assert.throws(() => store(json, role), pattern); + const good = { id: review(2), score: values[2].score, breakdown: values[2] }; + const bad = (change) => + JSON.stringify([ + good, + { + ...good, + ...change, + breakdown: { ...good.breakdown, ...change.breakdown }, + }, + ]); + sql(`DELETE FROM rate.review_values WHERE review_id = '${review(2)}'`); + refuse('{"id": 1}', /p_values must be a JSON array/); + refuse( + JSON.stringify(Array.from({ length: 1001 }, () => good)), + /at most 1000 rows/ + ); + refuse(bad({ id: 'not-a-uuid' }), /row 1 is not a review value/); + refuse(bad({ breakdown: { version: 2 } }), /row 1 is not a review value/); + refuse( + bad({ breakdown: { completeness: '35' } }), + /row 1 is not a review value/ + ); + refuse( + bad({ breakdown: { completeness: 7 } }), + /row 1 has values out of range/ + ); + refuse( + bad({ breakdown: { completeness: 40 } }), + /row 1 has values out of range/ + ); + refuse(bad({ score: 99 }), /row 1 has values out of range/); + refuse(bad({ breakdown: { gated: true } }), /row 1 has values out of range/); + refuse( + bad({ + score: 101, + breakdown: { score: 101, trust: 10 + 101 - values[2].score }, + }), + /row 1 has values out of range/ + ); + // Only a shape the module could produce: its keys, its tier, its gaps, its parts. + const shape = /row 1 is not a review value/; + const range = /row 1 has values out of range/; + refuse(bad({ breakdown: { note: 'x' } }), shape); + refuse(bad({ breakdown: { gated: 'true' } }), shape); + refuse(bad({ breakdown: { gaps: 'photo' } }), shape); + refuse(bad({ breakdown: { gaps: [1] } }), shape); + refuse(bad({ score: '20' }), shape); + refuse('[1]', /row 0 is not a review value/); + const noTier = { ...good.breakdown }; + delete noTier.tier; + refuse(JSON.stringify([good, { ...good, breakdown: noTier }]), shape); + refuse(bad({ breakdown: { tier: 'excellent' } }), range); + refuse(bad({ breakdown: { gaps: ['photo', 'photo'] } }), range); + refuse(bad({ breakdown: { gaps: ['bribe'] } }), range); + refuse(bad({ breakdown: { verified: 7 } }), range); + refuse(bad({ breakdown: { helpful: -1 } }), range); + // Specificity 13 is no depth's points (keep the sum right so only the part fails). + refuse( + bad({ + score: values[2].score + 1, + breakdown: { score: values[2].score + 1, specificity: 13 }, + }), + range + ); + // A photo's 5 off the weight needs a photo and no pros and cons, and never 10. + refuse(bad({ breakdown: { weightCompleteness: -5 } }), range); + // ...and a photo without pros and cons must take its 5 off. + refuse(bad({ breakdown: { weightCompleteness: 5 } }), range); + refuse( + JSON.stringify([ + { + ...good, + breakdown: { ...values[1], weightCompleteness: 30 }, + id: review(1), + score: values[1].score, + }, + ]), + /row 0 has values out of range/ + ); + refuse( + JSON.stringify( + Array.from({ length: 900 }, () => ({ ...good, pad: 'x'.repeat(3000) })) + ), + /at most 1000 rows and 2 MB/ + ); + // rate_worker cannot write the values directly, nor put the number on the public table. + assert.throws( + () => + asRole( + 'rate_worker', + `INSERT INTO rate.review_values (review_id, value_score, value_breakdown) + VALUES ('${review(2)}', 0, '{"completeness": 0, "weightCompleteness": 0, "score": 0}')` + ), + /permission denied for table review_values/ + ); + assert.throws( + () => + asRole( + 'rate_worker', + `UPDATE rate.reviews SET value_score = 90 WHERE id = '${review(2)}'` + ), + /reviews_value_private/ + ); + assert.equal( + sql( + `SELECT count(*) FROM rate.review_values WHERE review_id = '${review(2)}'` + ), + '0', + 'a refused call writes nothing' + ); + for (const role of ['anon', 'authenticated']) + refuse( + payload([2]), + /permission denied for function store_review_values/, + role + ); + // The CHECKs hold against a direct write (service_role bypasses RLS, not CHECKs). + assert.throws( + () => + sql( + `INSERT INTO rate.review_values (review_id, value_score, value_breakdown) + VALUES ('${review(2)}', 0, '{"completeness": 0, "weightCompleteness": 35, "score": 0}')` + ), + /review_values_completeness/ + ); + assert.throws( + () => + sql( + `UPDATE rate.reviews SET value_tier = 'gold' WHERE id = '${review(2)}'` + ), + /reviews_value_tier_known/ + ); + // A review listed twice: the last row wins; an unknown review is skipped. + assert.equal( + store( + JSON.stringify([ + { id: review(2), score: values[5].score, breakdown: values[5] }, + good, + { ...good, id: 'd0000000-0000-4000-8000-0000000000ff' }, + ]) + ), + '1' + ); + assert.equal( + sql( + `SELECT value_breakdown ->> 'completeness' FROM rate.review_values WHERE review_id = '${review(2)}'` + ), + '5', + 'the last row, not the first' + ); + assert.equal(score(), lifted); + checkAll('after the refusals'); + + // Rollback twice: the lift is gone, and so are the values, the tier, the functions and CHECKs. + sql(rollback); + sql(rollback); + assert.equal(score(), before, 'the rollback removes the lift'); + assert.equal( + sql( + `SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'rate' AND p.proname IN ('store_review_values', 'my_review_values', + 'review_completeness_factor', 'review_values_refresh_rating_extras')` + ), + '0' + ); + assert.equal( + sql( + `SELECT (to_regclass('rate.review_values') IS NULL)::text || ',' || count(*) + FROM information_schema.columns + WHERE table_schema = 'rate' AND table_name = 'reviews' AND column_name = 'value_tier'` + ), + 'true,0' + ); + assert.equal( + sql( + `SELECT string_agg(conname, ',') FROM pg_constraint + WHERE conname IN ('reviews_value_private', 'reviews_value_tier_known')` + ), + 'reviews_value_private', + 'only the CHECK keeping the number off the public table stays' + ); + // P2's rating still works: a weight change refreshes the master. + sql( + `UPDATE rate.reviews SET helpfulness_weight = 1 WHERE id = '${review(4)}'` + ); + REVIEWS[4][1] = 1; + assert.equal(score(), expectedScore(false)); + + // Applied again: nothing moves until the job stores values again; then they lift. + sql(proposal); + assert.equal(score(), expectedScore(false)); + checkAll('after the re-apply'); + assert.equal(store(payload([1, 2, 3, 4, 5])), '5'); + assert.equal(score(), expectedScore(true)); + // A re-apply over kept values changes nothing either. + sql(proposal); + assert.equal(score(), expectedScore(true)); + checkAll('after the re-store'); + + console.log( + 'review value proposal: shared-module parity, values stored and refused, rating lift, verify and rollback replayed' + ); +} finally { + removeContainer(); +} From 0b2b46c7fefaba95ee47e81847954174e0585d97 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:04:27 +0700 Subject: [PATCH 3/3] feat(shared-data): review depth on the whole review text, shared with the web (#1582) * feat(shared-data): review depth on the whole review text, shared with the web (#1581) - reviewDepth in @repo/shared-data/rules classifies axis C over the comment, verdict and writer lines (AI-extracted lines skipped), each part matched alone. - Repeated clauses and runs of a word or phrase (3+) leave the length, so padding cannot reach the 30/90/240 thresholds. - evaluateReviewTrustAssessment uses it and the shared regexes; a comment without repeats classifies as before. review-value takes ReviewDepth from it. Closes #1581 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/rate-domain-core.ts | 58 ++---- .../__tests__/core-rate-architecture.test.ts | 8 +- .../src/__tests__/shared-data.test.ts | 119 +++++++++++ packages/shared-data/src/rules/index.ts | 1 + .../shared-data/src/rules/review-depth.ts | 189 ++++++++++++++++++ .../shared-data/src/rules/review-value.ts | 25 +-- 6 files changed, 340 insertions(+), 60 deletions(-) create mode 100644 packages/shared-data/src/rules/review-depth.ts diff --git a/apps/web/src/lib/rate-domain-core.ts b/apps/web/src/lib/rate-domain-core.ts index a22847c3..c06108d7 100644 --- a/apps/web/src/lib/rate-domain-core.ts +++ b/apps/web/src/lib/rate-domain-core.ts @@ -5,6 +5,12 @@ * them (DOS/Rate#756). */ import type { MasterProduct } from '@repo/shared-data'; +import { + REVIEW_PLATFORM_LABEL_RE, + REVIEW_PRODUCT_ASPECT_RE, + reviewDepth, + type ReviewDepth, +} from '@repo/shared-data/rules'; /** A finite number from a number or a numeric string, else NaN. */ export function parseFiniteNumericSpec(raw: unknown): number { @@ -82,14 +88,8 @@ export type ReviewIndependenceAxis = | 'undisclosed_paid' | 'conflict_of_interest'; -export type ReviewDepthAxis = - | 'stars_only' - | 'platform_label' - | 'filler' - | 'low_effort' - | 'specific_short' - | 'detailed' - | 'expert_depth'; +/** Axis C, classified by `reviewDepth` in `@repo/shared-data/rules`. */ +export type ReviewDepthAxis = ReviewDepth; export interface ReviewWeightBreakdown { gate: 0 | 1; @@ -204,9 +204,6 @@ export function computeWilsonLowerBound( const WEB_SHIPPING_ASPECT_RE = /(?:giao\s*hàng|ship(?:per)?|vận\s*chuyển|đóng\s*gói|bọc\s*hàng|hộp\s*móp|giao\s*nhanh|giao\s*chậm|tư\s*vấn|chăm\s*sóc\s*khách)/i; -const WEB_PRODUCT_ASPECT_RE = - /(?:\bpin\b|sạc|mah|màn(?:\s*hình)?|tần\s*số\s*quét|nit|hiển\s*thị|cam(?:era)?(?!\s*kết)|chụp|quay\s*video|zoom|loa|mic|chất\s*âm|chống\s*ồn|bass|treble|hiệu\s*năng|chip|mượt|lag|giật|nóng|nhiệt\s*độ|fps|game|thiết\s*kế|cầm\s*nắm|vân\s*tay|face\s*id|wifi|5g|bluetooth|phím|bàn\s*phím|touchpad)/i; - // Any mention of the product or of using it, based on PRODUCT_ASPECT_RE in // apps/importer/src/shopee/ai-review-analyzer.ts (isShippingOnlyComment) plus // Shopee's structured product fields. Matched as whole words so "hư" in "như" @@ -215,13 +212,10 @@ const WEB_PRODUCT_ASPECT_RE = // đóng gói", "đẹp" but not "đóng gói rất đẹp"); see PRODUCT_MENTION_RE in // the importer, which must stay identical (its tests compare the two). Only used // to decide that a review is about shipping alone; depth still uses the -// stricter WEB_PRODUCT_ASPECT_RE. +// stricter REVIEW_PRODUCT_ASPECT_RE. const WEB_PRODUCT_MENTION_RE = /(?= 240 && hasProduct) - ) { - depth = 'expert_depth'; - } else if (cleanText.length >= 90 && hasProduct) { - depth = 'detailed'; - } else if (hasProduct && cleanText.length >= 30) { - depth = 'specific_short'; - } else { - depth = 'low_effort'; - } + // 3. Axis C: Depth, shared with the review value score (DOS/Rate#1581). + const depth: ReviewDepthAxis = reviewDepth({ + comment, + reviewerType: review.reviewer_type, + flagCategory: review.flag_category, + }); const A = authenticity === 'genuine' ? 1.0 : authenticity === 'doubtful' ? 0.5 : 0; @@ -625,7 +601,7 @@ export function detectStarDistributionAnomaly( fiveStarCount += 1; if ( text.length < 25 || - WEB_PLATFORM_LABEL_RE.test(text) || + REVIEW_PLATFORM_LABEL_RE.test(text) || r.flag_category !== 'authentic' ) { shallowOrFlaggedFiveStar += 1; diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index e31221d6..0f27a127 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -8417,13 +8417,17 @@ async function runCoreArchitectureSuite() { path.join(libDir, 'rate-domain-core.ts'), 'utf8' ); + // Values only from `@repo/shared-data/rules`, plain functions that pull + // in neither zod nor Supabase (its header; DOS/Rate#1581). const valueImports = [ ...coreSrc.matchAll(/^import\s+(?!type\b)[^;]*?from\s+'([^']+)'/gm), - ].map((m) => m[1]); + ] + .map((m) => m[1]) + .filter((specifier) => specifier !== '@repo/shared-data/rules'); assert.deepEqual( valueImports, [], - 'rate-domain-core.ts must only use type imports (#756)' + 'rate-domain-core.ts must only use type imports, or values from @repo/shared-data/rules (#756)' ); for (const file of [ 'rate-domain.ts', diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index e729369c..c4e34935 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -2037,6 +2037,124 @@ function testProductPoints(): void { ); } +function testReviewDepth(): void { + const depth = (input: rules.ReviewDepthInput) => rules.reviewDepth(input); + // A comment alone classifies as the web did before DOS/Rate#1581. + assert.equal(depth({}), 'stars_only'); + assert.equal(depth({ comment: ' ' }), 'stars_only'); + assert.equal(depth({ comment: 'Rất hài lòng!' }), 'platform_label'); + assert.equal( + depth({ comment: 'Máy ổn', flagCategory: 'low_value_coin_spam' }), + 'filler' + ); + assert.equal( + depth({ comment: 'ok', reviewerType: 'expert' }), + 'expert_depth' + ); + assert.equal(depth({ comment: 'Pin 5000mAh' }), 'expert_depth'); + assert.equal(depth({ comment: 'Giao nhanh, đóng gói kỹ' }), 'low_effort'); + assert.equal(depth({ comment: 'Pin trâu' }), 'low_effort'); + // 30, 90 and 240 characters with a product word. + const short = 'Pin dùng được cả ngày, màn đẹp.'; + assert.ok(short.length >= 30); + assert.equal(depth({ comment: short }), 'specific_short'); + const detailed = + 'Pin dùng được cả ngày với nhu cầu lướt web, màn hình sáng rõ ngoài trời, ' + + 'chơi game nhẹ không nóng.'; + assert.ok(detailed.length >= 90); + assert.equal(depth({ comment: detailed }), 'detailed'); + const long = + detailed + + ' Camera chụp ban ngày đẹp, ban đêm hơi nhiễu nhưng chấp nhận được, loa ngoài to và rõ,' + + ' vân tay nhạy, thiết kế cầm chắc tay, sạc nhanh đầy trong khoảng một giờ.'; + assert.ok(long.length >= 240); + assert.equal(depth({ comment: long }), 'expert_depth'); + + // A form v2 review with no comment: its verdict and lines are its text. + const v2 = { + verdict: 'Máy mượt, đáng tiền trong tầm giá.', + points: [ + { kind: 'pro' as const, criterionKey: null, body: 'Pin dùng cả ngày' }, + { kind: 'con' as const, criterionKey: null, body: 'Sạc hơi chậm' }, + ], + }; + assert.equal(depth({ comment: '', verdict: v2.verdict }), 'specific_short'); + assert.equal(depth(v2), 'specific_short'); + assert.equal( + rules.reviewDepthText(v2), + 'Máy mượt, đáng tiền trong tầm giá.\nPin dùng cả ngày\nSạc hơi chậm' + ); + // A number ending one part and a unit starting the next are no measurement. + assert.equal(depth({ comment: 'Đặt 2', verdict: 'gọn nhẹ' }), 'low_effort'); + // Lines extracted by AI from a marketplace comment add nothing. + assert.equal( + depth({ + points: [{ body: 'Pin trâu, màn đẹp, chơi game mượt', method: 'ai' }], + }), + 'stars_only' + ); + // Bare strings count as lines too; non-text parts are ignored. + assert.equal(depth({ points: ['Pin dùng cả ngày', 42, null] }), 'low_effort'); + + // Padding does not reach a threshold (design 6.4). + for (const times of [6, 20, 40]) { + const padded = Array(times).fill('pin ok').join(' '); + assert.equal(depth({ comment: padded }), 'low_effort', `${times} x pin ok`); + const sentences = Array(times).fill('Pin ok.').join(' '); + assert.equal( + depth({ comment: sentences }), + 'low_effort', + `${times} sentences` + ); + } + assert.ok(Array(40).fill('pin ok').join(' ').length >= 240); + assert.equal( + depth({ comment: Array(30).fill('tốt').join(' ') }), + 'low_effort' + ); + assert.equal( + depth({ comment: Array(30).fill('tốt').join(', ') }), + 'low_effort' + ); + // A verdict repeated as a pro is not counted twice. + assert.equal( + depth({ + verdict: 'Pin dùng được cả ngày', + points: ['Pin dùng được cả ngày', 'pin dùng được cả ngày'], + }), + 'low_effort' + ); + assert.equal(rules.reviewPaddingLength(short), 0); + assert.equal(rules.reviewPaddingLength(detailed), 0); + assert.equal(rules.reviewPaddingLength(''), 0); + assert.equal(rules.reviewPaddingLength(null as never), 0); + // Two copies in a row are not a padding run. + assert.equal(rules.reviewPaddingLength('rất rất tốt'), 0); + assert.equal(depth(null as never), 'stars_only'); + + // reviewValue on the classified depth: a v2 review without a comment + // reaches completeness 25 (criteria, verdict, pros and cons). + const phone = rules.getRatingCriteria('smartphones'); + const value = rules.reviewValue({ + categorySlug: 'smartphones', + gated: false, + depth: depth(v2), + criteriaRatings: Object.fromEntries(phone.map((c) => [c.key, 4])), + verdict: v2.verdict, + points: v2.points, + hasRealPhoto: false, + verifiedPurchase: false, + helpfulVotes: 0, + unhelpfulVotes: 0, + authorTrust: null, + }); + assert.equal(value.completeness, 25); + assert.ok( + value.specificity >= rules.REVIEW_SPECIFICITY_POINTS.specific_short + ); + console.log(' ✅ review depth (axis C) over the whole review text (#1581)'); +} + function testReviewValue(): void { const phone = rules.getRatingCriteria('smartphones'); const allRated: Record = Object.fromEntries( @@ -2396,6 +2514,7 @@ function main(): void { ); testProductPoints(); console.log(' ✅ [12/12] Product pros and cons (DOS/Rate#1546) verified'); + testReviewDepth(); testReviewValue(); console.log( ' ✅ [13/13] Review value score and weight factor (DOS/Rate#1547) verified' diff --git a/packages/shared-data/src/rules/index.ts b/packages/shared-data/src/rules/index.ts index af19a3f6..b99154c9 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -14,4 +14,5 @@ export * from './review-summary.js'; export * from './rating-criteria.js'; export * from './review-form.js'; export * from './product-points.js'; +export * from './review-depth.js'; export * from './review-value.js'; diff --git a/packages/shared-data/src/rules/review-depth.ts b/packages/shared-data/src/rules/review-depth.ts new file mode 100644 index 00000000..86bb5202 --- /dev/null +++ b/packages/shared-data/src/rules/review-depth.ts @@ -0,0 +1,189 @@ +/** + * Axis C of the review trust model (`Docs/Platform/review-trust-model.html`): + * how deep a review's text goes, from no text to expert depth + * (DOS/Rate#1581). It feeds the web's trust weight and the review value + * score's specificity and completeness gate (`review-value.ts`), so the web, + * the importer and the scoring job classify a review the same way. + * + * The text is the whole review: the comment, the verdict and the writer's + * pros and cons, whole-review and per-criterion. A form v2 review often has no + * comment, and read alone it would count as stars only. + * + * Padding does not count toward the length thresholds: a clause said again + * and a word or phrase repeated three times or more in a row are left out of + * the length, so "pin ok" repeated to 240 characters stays low effort + * (design #1531, 6.4). A text without repeats is measured exactly as before. + */ + +/** Axis C's levels, from no text to expert depth. */ +export type ReviewDepth = + | 'stars_only' + | 'platform_label' + | 'filler' + | 'low_effort' + | 'specific_short' + | 'detailed' + | 'expert_depth'; + +/** A product aspect a review can talk about; depth needs one. */ +export const REVIEW_PRODUCT_ASPECT_RE = + /(?:\bpin\b|sạc|mah|màn(?:\s*hình)?|tần\s*số\s*quét|nit|hiển\s*thị|cam(?:era)?(?!\s*kết)|chụp|quay\s*video|zoom|loa|mic|chất\s*âm|chống\s*ồn|bass|treble|hiệu\s*năng|chip|mượt|lag|giật|nóng|nhiệt\s*độ|fps|game|thiết\s*kế|cầm\s*nắm|vân\s*tay|face\s*id|wifi|5g|bluetooth|phím|bàn\s*phím|touchpad)/i; + +/** A measurement or a benchmark: expert depth on its own. */ +export const REVIEW_TECH_MEASURE_RE = + /(?:\b\d+\s*(?:mah|wh|w|hz|nit|nits|fps|gb|tb|mp|mm|gram|g|db|ms|°c|độ\s*c)\b|geekbench|antutu|3dmark|cinebench|pcmag|gsmarena|phonearena)/i; + +/** A marketplace's canned satisfaction label, the whole text. */ +export const REVIEW_PLATFORM_LABEL_RE = + /^(?:cực\s*k[ìỳ]\s*hài\s*lòng|rất\s*hài\s*lòng|hài\s*lòng|bình\s*thường|không\s*hài\s*lòng|rất\s*không\s*hài\s*lòng|tuyệt\s*vời|tốt|khá|tệ|rất\s*tệ)$/iu; + +/** Length thresholds, in characters, for a text that names the product. */ +export const REVIEW_DEPTH_LENGTH = { + specificShort: 30, + detailed: 90, + expert: 240, +} as const; + +/** A run of the same word or phrase this long or longer is padding. */ +const PADDING_RUN = 3; +/** Parts of the review's text, one per line. */ +const PART_SEPARATOR = '\n'; +/** The longest phrase, in words, that a padding run repeats. */ +const PADDING_PHRASE_WORDS = 4; + +export interface ReviewDepthInput { + comment?: unknown; + /** The review's own verdict line (form v2), not the analyzer's verdict. */ + verdict?: unknown; + /** + * The writer's pros and cons, whole-review and per-criterion. A line + * extracted by AI from a marketplace comment (`method: 'ai'`) is that + * comment again and is left out. + */ + points?: readonly unknown[]; + reviewerType?: unknown; + /** `rate.reviews.flag_category`: 'low_value_coin_spam' is filler. */ + flagCategory?: unknown; +} + +// The rules import check reads a quote after `export` as a module path, so +// the exported functions start with these helpers. +function asInput(input: unknown): ReviewDepthInput { + return input && typeof input === 'object' ? (input as ReviewDepthInput) : {}; +} + +function isText(value: unknown): value is string { + return typeof value === 'string'; +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.normalize('NFC').trim() : ''; +} + +function pointText(point: unknown): string { + if (typeof point === 'string') return text(point); + if (!point || typeof point !== 'object') return ''; + const p = point as { body?: unknown; method?: unknown }; + return p.method === 'ai' ? '' : text(p.body); +} + +/** The review's whole text, one part per line, in a fixed order. */ +export function reviewDepthText(input: ReviewDepthInput): string { + return reviewParts(asInput(input)).join(PART_SEPARATOR); +} + +function reviewParts(safe: ReviewDepthInput): string[] { + const points = Array.isArray(safe.points) ? safe.points : []; + return [ + text(safe.comment), + text(safe.verdict), + ...points.map(pointText), + ].filter((part) => part.length > 0); +} + +const words = (clause: string): string[] => + clause.toLocaleLowerCase('vi').split(/\s+/u).filter(Boolean); + +/** + * Characters of `text` that are padding: a clause said again (compared in + * lower case, by its words), and the extra copies in a run of the same word + * or phrase repeated PADDING_RUN times or more. A repeated clause counts + * with its punctuation, a repeated word or phrase with one space. + */ +export function reviewPaddingLength(textValue: string): number { + if (!isText(textValue) || textValue.length === 0) return 0; + let padding = 0; + const seen = new Set(); + // Each clause with the punctuation and spaces after it. + for (const [whole, clause = ''] of textValue.matchAll( + /([^.!?;,\n]*)([.!?;,\n]+\s*|$)/gu + )) { + const tokens = words(clause); + if (tokens.length === 0) continue; + const key = tokens.join(' '); + if (seen.has(key)) { + padding += whole.length; + continue; + } + seen.add(key); + // A run of one phrase, repeated back to back: its copies past the first + // are padding. + let i = 0; + while (i < tokens.length) { + let best = { size: 0, copies: 1 }; + for (let size = 1; size <= PADDING_PHRASE_WORDS; size++) { + let copies = 1; + while ( + i + (copies + 1) * size <= tokens.length && + tokens.slice(i + copies * size, i + (copies + 1) * size).join(' ') === + tokens.slice(i, i + size).join(' ') + ) { + copies++; + } + if (copies >= PADDING_RUN && copies * size > best.copies * best.size) { + best = { size, copies }; + } + } + if (best.size > 0) { + const phrase = tokens.slice(i, i + best.size).join(' '); + padding += (best.copies - 1) * (phrase.length + 1); + i += best.copies * best.size; + } else { + i++; + } + } + } + return padding; +} + +/** + * Axis C for a review's whole text. For a comment alone, with no repeats, + * this is the web's classification before DOS/Rate#1581. + */ +export function reviewDepth(input: ReviewDepthInput): ReviewDepth { + const safe = asInput(input); + const parts = reviewParts(safe); + const cleanText = parts + .join(PART_SEPARATOR) + .replace(/[\s.!,]+$/u, '') + .trim(); + if (cleanText.length === 0) return 'stars_only'; + if (REVIEW_PLATFORM_LABEL_RE.test(cleanText)) return 'platform_label'; + if (safe.flagCategory === 'low_value_coin_spam') return 'filler'; + // Each part is matched alone: a number ending one part and a unit starting + // the next ("Đặt 2" and "gọn nhẹ") are not a measurement. + const hasProduct = parts.some((part) => REVIEW_PRODUCT_ASPECT_RE.test(part)); + const length = cleanText.length - reviewPaddingLength(cleanText); + if ( + safe.reviewerType === 'expert' || + parts.some((part) => REVIEW_TECH_MEASURE_RE.test(part)) || + (length >= REVIEW_DEPTH_LENGTH.expert && hasProduct) + ) { + return 'expert_depth'; + } + if (length >= REVIEW_DEPTH_LENGTH.detailed && hasProduct) return 'detailed'; + if (hasProduct && length >= REVIEW_DEPTH_LENGTH.specificShort) { + return 'specific_short'; + } + return 'low_effort'; +} diff --git a/packages/shared-data/src/rules/review-value.ts b/packages/shared-data/src/rules/review-value.ts index a464f442..58ed17ae 100644 --- a/packages/shared-data/src/rules/review-value.ts +++ b/packages/shared-data/src/rules/review-value.ts @@ -14,6 +14,7 @@ * equal. */ import { getRatingCriteria, hasRatingCriteria } from './rating-criteria.js'; +import type { ReviewDepth } from './review-depth.js'; import { REVIEW_POINT_MAX, REVIEW_POINT_MIN, @@ -64,24 +65,11 @@ export const REVIEW_TIER_COMPLETE_MIN = 30; /** The value score a complete review needs for the "Xuất sắc" tier. */ export const REVIEW_TIER_EXCELLENT_MIN = 70; -/** - * Axis C of the review trust model (`Docs/Platform/review-trust-model.html`), - * as `evaluateReviewTrustAssessment` in apps/web classifies it. - */ -export type ReviewDepth = - | 'stars_only' - | 'platform_label' - | 'filler' - | 'low_effort' - | 'specific_short' - | 'detailed' - | 'expert_depth'; - /** * Specificity points per depth. Length counts only through the depth, so - * padding past a depth's threshold adds nothing. Padding up to a threshold - * is axis C's to catch: as the web classifies it today, 30, 90 and 240 - * characters with a product word move a text up a depth. + * padding past a depth's threshold adds nothing, and `reviewDepth` leaves + * repeats out of the length, so padding up to a threshold adds nothing + * either. */ export const REVIEW_SPECIFICITY_POINTS: Readonly> = { @@ -125,7 +113,10 @@ export interface ReviewValueInput { * value score is 0 however complete the review is (6.1). */ gated: boolean; - /** Axis C, measured on all the review's text: comment, verdict and lines. */ + /** + * Axis C, measured on all the review's text: comment, verdict and lines; + * `reviewDepth` (`review-depth.ts`) classifies it. + */ depth: ReviewDepth; /** `rate.reviews.criteria_ratings`. */ criteriaRatings: unknown;