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/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 0a072de8..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,445 @@ 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( + 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 +2514,11 @@ 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' + ); 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..b99154c9 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -14,3 +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 new file mode 100644 index 00000000..58ed17ae --- /dev/null +++ b/packages/shared-data/src/rules/review-value.ts @@ -0,0 +1,482 @@ +/** + * 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 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'; +import type { ReviewDepth } from './review-depth.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; + +/** + * Specificity points per depth. Length counts only through the depth, so + * 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> = + { + 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; + * `reviewDepth` (`review-depth.ts`) classifies it. + */ + 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) + ); +} 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(); +}