diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e8a171cae..81ceff8a7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,6 +73,9 @@ jobs:
- name: Replay the DOS-Me Item RPC and M3/M4 proposals (Docker)
run: node scripts/test-m3-m4-item-rpcs-proposal.mjs
+ - name: Replay the DOS-Me criteria ratings proposal (Docker)
+ run: node scripts/test-criteria-ratings-proposal.mjs
+
- name: Check generated rate schema types match the migrations
run: pnpm check:db-types
diff --git a/.gitignore b/.gitignore
index 48f5c2c3e..eb9fd6efb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -149,6 +149,9 @@ playwright-report-item-model/
!Docs/Proposals/2026-10-29-dos-me-m3-m4.sql
!Docs/Proposals/2026-10-29-dos-me-m3-m4-rollback.sql
!Docs/Proposals/2026-10-29-dos-me-m3-m4-verify.sql
+!Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql
+!Docs/Proposals/2026-10-31-dos-me-criteria-ratings-rollback.sql
+!Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql
* - Copy*
* - Bản sao*
*.dump
diff --git a/Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html b/Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html
new file mode 100644
index 000000000..f8a371f85
--- /dev/null
+++ b/Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html
@@ -0,0 +1,394 @@
+
+
+
+
+
Đề xuất thiết kế
+
Cần JOY quyết
+
Sao theo từng tiêu chí, ưu nhược điểm cho mỗi review, và điểm giá trị review
+
Ngày 31/10/2026. Người soạn: Core (Rate). Issue #1531 (thiết kế sao theo tiêu chí, ưu nhược điểm, điểm giá trị review). Đồng thời giải quyết #1143 (điểm tiêu chí suy ra từ số sao nên dồn về 5.00) và câu hỏi "một con số điểm" của #1470 (PR #1478 đang giữ). Đây chỉ là thiết kế: chưa sửa code, chưa đụng database.
+
+
0. Tóm tắt trong một phút
+
+ - Tiêu chí theo ngành hàng. Mỗi ngành hàng có một danh sách tiêu chí riêng (điện thoại: Màn hình, Hiệu năng, Camera, Pin, Thiết kế, Đáng tiền; tai nghe: Chất âm, Chống ồn, Mic...), mỗi tiêu chí có một trọng số. Danh sách nằm trong code ở
packages/shared-data, có số phiên bản.
+ - Review trên Rate. Người viết chấm sao từng tiêu chí. Số sao tổng của review được tính từ các tiêu chí theo trọng số, giống JReviews. Người viết bắt buộc ghi một câu kết luận, ít nhất 1 ưu điểm và 1 nhược điểm; ưu nhược riêng cho từng tiêu chí thì không bắt buộc.
+ - Review lấy từ nguồn khác (Shopee, chuyên gia bên ngoài sau này): chỉ có một số sao tổng, ưu nhược không bắt buộc.
+ - Một con số điểm cho mọi trang (#1470): điểm Bayes (ước lượng có "kéo về trung bình" khi ít review) tính trên số sao tổng của mọi review hợp lệ, lưu sẵn trên
master_products, làm tròn 1 chữ số. Điểm chuyên gia hiện riêng, có nhãn.
+ - Điểm tiêu chí thật (#1143): chỉ tính từ review có chấm tiêu chí đó. Không đủ dữ liệu thì hiện "Chưa đủ dữ liệu", không bịa số. Tiêu chí không thuộc ngành hàng thì ẩn.
+ - Ưu nhược của sản phẩm: code gom các dòng ưu nhược của mọi review theo tiêu chí, bỏ trùng, đếm số người nhắc. AI chỉ viết lại mỗi cụm thành một câu gọn. Một nhược điểm cần ít nhất 2 người khác nhau nhắc mới hiện.
+ - Điểm giá trị review (0 đến 100): đo mức đầy đủ và hữu ích của một review. Dùng để xếp review, gắn nhãn chất lượng và tính điểm thưởng sau này. Nó chỉ làm trọng số của review trong điểm sản phẩm tăng nhẹ: review không có gì giữ nguyên, review đầy đủ nhất nặng hơn 25%.
+ - Cần migration DOS-Me cho phần lưu trữ (bảng dòng ưu nhược, cột điểm trên review và sản phẩm). Repo này không chạy DDL. Có một bước làm ngay không cần database: sửa #1143 trên web.
+
+
+
Quy ước và từ ngữ. Công thức và bảng dùng dấu phẩy thập phân (4,2); con số hiện cho người mua dùng dấu chấm như trên rate.vn hiện nay (4.6). "Seeding": review được trả tiền hoặc dàn dựng để khen. "Cổng chặn": điều kiện đưa trọng số review về 0 (giả, chép, viết cho có). "Cận dưới Wilson": cách tính tỷ lệ vote hữu ích có tính đến việc ít vote thì kém chắc chắn, nên 2 vote trên 2 không thắng 90 vote trên 100. "Pipeline": chuỗi bước xử lý chạy tự động theo đợt.
+
+
1. Hiện trạng (đọc code, chỉ đọc)
+
+ | Phần | Đang có | Thiếu hoặc sai |
+ | Chấm tiêu chí khi viết review | Form trong JReviewsSection.tsx có 5 hàng nút chọn 1 đến 5 sao (screen, performance, camera, battery, value), mặc định 5 sao. Số sao của review là trung bình cộng, làm tròn thành số nguyên. API POST /api/v1/rate/reviews lọc khoá tiêu chí rồi gọi RPC rate.submit_community_review, lưu vào cột rate.reviews.criteria_ratings (jsonb). RPC không kiểm khoá. | Cùng 5 tiêu chí cho mọi ngành hàng (tai nghe chỉ đổi nhãn). Không có trọng số. Tiêu chí người viết không bấm vẫn tính 5 sao. API tin số sao tổng trình duyệt gửi lên, chỉ tự tính khi thiếu. Cột rating_star là số nguyên 1 đến 5. Không có ưu nhược trên review. |
+ | Điểm tiêu chí của sản phẩm | computeJReviewsCriteriaScores trong leaderboard-engine.ts: đọc expert_criteria_scores, nếu không có thì lấy số sao cộng một độ lệch tự đặt theo thông số và giá. | Chính là lỗi #1143: điểm bịa, dồn về 5.00, hoà và thứ hạng giả. Điểm tiêu chí bịa này còn chiếm 20% điểm ghép của bảng xếp hạng. Trong repo này không có code nào ghi expert_criteria_scores; 36 sản phẩm có điểm chuyên gia từ file seed (đề xuất kiểm tra nguồn gốc catalog ngày 16/10). |
+ | Điểm tổng | Trang sản phẩm dùng điểm Bayes (#1144: k = 3, trung bình mặc định 4,2), web tính từ danh sách review. Database chỉ lưu user_rating_weighted: trung bình có trọng số, không Bayes. Bảng xếp hạng dùng một điểm ghép (người dùng, chuyên gia, tiêu chí). | Lỗi #1470: cùng một máy hiện 4.8, 4.98, 5.0 ở các chỗ khác nhau. |
+ | Trọng số review | Trọng số đang lưu (helpfulness_weight, 0 đến 3) do rate.compute_review_weight tính: gốc 1 (0,25 nếu đáng ngờ, 0 nếu giả), cộng trừ theo vote và báo cáo. Mô hình tin cậy review (Docs/Platform/review-trust-model.html) đã thiết kế công thức sáu trục w = G × A × B × C × D × E × F, nhưng chưa được dùng cho trọng số đang lưu. | Chưa có gì đo "review có đủ ưu nhược, đủ tiêu chí không". |
+ | Ưu nhược | AI trong importer (ai-review-analyzer.ts) sinh tối đa 6 ưu và 6 nhược cho cả một listing, lưu ở rate.review_analysis.pros/cons; web gộp theo sản phẩm và hiện. | Không theo tiêu chí, không đếm số người nhắc, không gắn với review nào. Schema zod review.schema.ts có trường pros/cons nhưng không dùng. |
+ | Vote hữu ích | Có, kèm điều kiện tài khoản 7 ngày, đã xác minh email hoặc số điện thoại, giới hạn 60 lượt mỗi giờ. | Ổn, dùng lại được. |
+ | Điểm thưởng cho người dùng | Chưa có hệ thống nào. | Cần quyết riêng (mục 9, D8). |
+ | "Tiêu chuẩn 10 nhóm" | 10 nhóm thông số (màn hình, phần cứng, pin, camera...) trong specs.schema.ts và spec-template.ts. | Là nhóm thông số kỹ thuật, không phải tiêu chí chấm sao. Tiêu chí chấm sao sẽ tham chiếu tới nhóm thông số liên quan, nhưng là danh sách riêng. |
+
+
+
2. Mô hình tiêu chí
+
2.1 Danh sách tiêu chí và trọng số theo ngành hàng (bản nháp)
+
Trọng số mỗi ngành cộng lại bằng 100. Đây là bản nháp để JOY sửa; con số chỉ là điểm xuất phát.
+
+ | Ngành hàng | Tiêu chí (trọng số %) |
+ | Điện thoại | Hiệu năng 20, Camera 20, Pin và sạc 20, Màn hình 15, Thiết kế và độ bền 10, Đáng tiền 15 |
+ | Máy tính bảng | Màn hình 20, Hiệu năng 20, Pin 15, Loa và giải trí 10, Phần mềm và phụ kiện 10, Thiết kế 10, Đáng tiền 15 |
+ | Laptop | Hiệu năng 25, Màn hình 15, Pin 15, Bàn phím và touchpad 10, Tản nhiệt và độ ồn 10, Thiết kế và độ bền 10, Đáng tiền 15 |
+ | Đồng hồ thông minh | Theo dõi sức khoẻ 25, Pin 20, Thiết kế và đeo 15, Ứng dụng và kết nối 15, Màn hình 10, Đáng tiền 15 |
+ | Tai nghe | Chất âm 30, Chống ồn 15, Pin 15, Đeo thoải mái 15, Mic và gọi 10, Đáng tiền 15 |
+ Dịch vụ (khi mở, entity_type = 'service') | Chất lượng dịch vụ 35, Giá cả 25, Thái độ phục vụ 20, Tốc độ 20 |
+
+
+ - Chỗ lưu: một file trong
packages/shared-data/src/rules/ (ví dụ rating-criteria.ts), cạnh spec-template.ts. Mỗi tiêu chí có khoá cố định (performance, camera, sound...), nhãn tiếng Việt và tiếng Anh, trọng số, và nhóm thông số liên quan (để trang sản phẩm hiện thông số cạnh điểm).
+ - Phiên bản: mỗi lần đổi danh sách hay trọng số tăng số phiên bản. Review lưu phiên bản lúc viết.
+ - Vì sao để trong code, không trong database: đổi trọng số làm đổi điểm người dùng thấy, nên nên đi qua PR và review như mọi thay đổi khác. Không cần migration mỗi lần chỉnh. Web và importer đọc cùng một file. API (chạy trên server, đọc file này) kiểm khoá và tự tính số sao tổng, không tin con số trình duyệt gửi; RPC chỉ kiểm số sao nằm trong 1 đến 5.
+ - Đổi trọng số không tính lại review cũ. Số sao tổng của một review được giữ theo phiên bản lúc viết (cột
criteria_version), nên không có lệnh cập nhật hàng loạt trên database dùng chung. Điểm từng tiêu chí của sản phẩm không phụ thuộc trọng số nên không đổi.
+ - 5 khoá cũ trong
criteria_ratings của review đã viết: screen thành Màn hình, performance thành Hiệu năng, camera thành Camera (tai nghe: Chất âm), battery thành Pin, value thành Đáng tiền. Tiêu chí mới mà review cũ không có thì để trống, không suy ra.
+ - Điểm theo nhu cầu (giống điểm "theo cách dùng" của RTINGS: chơi game, chụp ảnh, văn phòng): mỗi nhu cầu là một bộ trọng số khác trên cùng các điểm tiêu chí, ví dụ "Chơi game" = Hiệu năng 40, Pin 25, Màn hình 20, Đáng tiền 15. Hiện thành một hàng nhỏ dưới điểm chính, có nhãn rõ, chỉ khi các tiêu chí liên quan đủ dữ liệu. Bộ chọn nhu cầu ở trang Deals hiện có dùng lại các bộ này. Con số điểm chính không đổi.
+
+
+
2.2 Cách tính
+
Điểm tổng của một review trên Rate, tính như JReviews, người viết không tự chọn số sao tổng:
+
sao_review = Σ (trọng_số_i × sao_i) / Σ trọng_số_i chỉ trên các tiêu chí người viết đã chấm
+làm tròn 0,1; người viết thấy con số này ngay khi kéo sao
+
Người viết được bỏ qua tối đa 2 tiêu chí ("Chưa dùng tới", ví dụ chưa thử chống ồn), không được bỏ "Đáng tiền". Không còn mặc định 5 sao: tiêu chí chưa bấm thì form báo thiếu, chứ không tự tính là 5.
+
Điểm từng tiêu chí của sản phẩm (Bayes theo từng tiêu chí, cùng cách #1144 đang dùng cho điểm tổng):
+
điểm_tiêu_chí_i = (k × μ_i + Σ w_r × sao_r,i) / (k + Σ w_r)
+k = 3; μ_i = trung bình tiêu chí i của cả ngành hàng (chưa có thì 4,2)
+w_r = trọng số tin cậy của review r (mô hình tin cậy review, 0 đến 3)
+chỉ tính review có chấm tiêu chí i và có trọng số lớn hơn 0
+hiện khi có ít nhất 3 người viết khác nhau và Σ w_r ≥ 1,5; còn lại "Chưa đủ dữ liệu"
+dùng để xếp hạng theo tiêu chí (chip "#1 Màn hình") khi có ít nhất 5 người viết khác nhau
+
Ngưỡng theo số người viết là để một review được vote cao không tự tạo ra một thứ hạng.
+
Điểm tổng của sản phẩm (con số duy nhất hiện mọi nơi):
+
điểm_sản_phẩm = (k × μ + Σ w_r × sao_review_r) / (k + Σ w_r) k = 3, μ = 4,2 (như #1144)
+mọi review hợp lệ: review Shopee dùng số sao gốc, review Rate dùng sao_review tính ở trên
+
Vì sao không tính điểm sản phẩm thẳng từ các điểm tiêu chí: review lấy từ sàn (phần lớn dữ liệu hiện nay) chỉ có một số sao tổng. Tính từ tiêu chí sẽ bỏ hết số review đó, và sản phẩm chưa có review Rate nào sẽ không có điểm. Trọng số tiêu chí vẫn quyết định điểm tổng, nhưng ở bên trong từng review Rate. Khi review Rate đủ nhiều, hai cách cho kết quả gần nhau; Core sẽ theo dõi chênh lệch này.
+
Hệ quả cần nói rõ trên trang: điểm chính (tính từ mọi review) có thể khác trung bình có trọng số của các điểm tiêu chí (chỉ từ review Rate). Vì vậy khối điểm tiêu chí ghi "Từ N review trên Rate" và có link "cách tính điểm". Đây là hai con số đo hai thứ khác nhau, có nhãn, không phải lỗi #1470.
+
+
2.3 Giải quyết #1143 (điểm tiêu chí dồn về 5.00)
+
+ - Bỏ hẳn nhánh "số sao cộng độ lệch". Không có dữ liệu thì trả
null, hiện "Chưa đủ dữ liệu" ở bảng xếp hạng, trang so kè, chip thứ hạng và API (ghi rõ trong OpenAPI).
+ - Bỏ phần trung bình tiêu chí ra khỏi điểm ghép của bảng xếp hạng khi không có dữ liệu thật, để số bịa không còn lọt vào thứ hạng.
+ - Thẻ so kè 1v1 ở trang chủ: tiêu chí không có dữ liệu thì không tính thắng thua, nên hết cảnh cả ba trận "Ngang điểm"; trận nào không có tiêu chí nào để so thì so theo điểm chính.
+ - Sắp theo một tiêu chí chỉ liệt kê sản phẩm có điểm tiêu chí đó. Chip "#1 Màn hình" chỉ hiện khi có điểm thật.
+ - Tiêu chí không thuộc ngành hàng thì ẩn (tai nghe không có "Màn hình").
+ - Điểm chuyên gia theo tiêu chí (
expert_criteria_scores) vẫn hiện được, nhưng có nhãn "Chuyên gia", không trộn vào điểm người dùng. 36 sản phẩm có điểm này từ seed; quyết định giữ hay bỏ đang mở ở đề xuất nguồn gốc dữ liệu catalog (16/10).
+ - Phần này làm được ngay trên web, không cần database (giai đoạn P0).
+
+
+
2.4 Giải quyết #1470 (một con số điểm)
+
+ - Con số duy nhất là điểm Bayes ở 2.2, tức phương án (2) của PR #1478. Trang sản phẩm, thẻ sản phẩm, so sánh, so kè, danh sách theo dõi, bảng xếp hạng và API đều đọc cùng cột, cùng làm tròn 1 chữ số.
+ - Hàm làm mới
rate.refresh_master_product_rating ghi thêm điểm Bayes và tổng trọng số vào master_products (migration DOS-Me). Nói thẳng: #1470 chỉ được giải quyết trọn khi có cột này (P2, P4). Điểm Bayes cần tổng trọng số chứ không phải số review, nên các danh sách hiện không tự tính được. Trong lúc chờ, giữ cách của PR #1478 (mọi chỗ ngoài trang sản phẩm dùng điểm đã lưu, 1 chữ số) và chấp nhận trang sản phẩm còn lệch ở một số máy.
+ - Điểm chuyên gia là con số thứ hai, luôn có nhãn "Chuyên gia", không ghép vào điểm chính. Điểm ghép trên bảng xếp hạng (người dùng, chuyên gia, tiêu chí) chỉ còn dùng để phá hoà khi xếp hạng, không hiện ra như một "điểm".
+ - PR #1478 merge được độc lập: nó đã đưa bảng so kè về điểm của sản phẩm và 1 chữ số. Khi có cột Bayes, chỉ cần đổi nguồn đọc.
+
+
+
3. Mô hình dữ liệu
+
Mọi thay đổi bảng dưới đây là DDL trên Supabase dùng chung, nên đi qua migration DOS-Me do JOY duyệt và chạy. Repo này chỉ soạn đề xuất SQL, không áp dụng.
+
+ | Chỗ | Thay đổi | Có sẵn hay mới | Migration |
+ rate.reviews.criteria_ratings | Giữ dạng {khoá: sao}, thêm khoá mới theo danh sách ngành hàng. | Có sẵn | Không (API kiểm khoá theo danh sách mới) |
+ rate.reviews.rating_star | Đang là số nguyên 1 đến 5 (SMALLINT), RPC cũng nhận số nguyên. Số sao tính từ tiêu chí là số lẻ (4,3), nên thêm cột rating_exact numeric(2,1); rating_star giữ số làm tròn cho code cũ đọc. Các hàm tính điểm đọc rating_exact khi có. | Có sẵn, thêm cột cạnh nó | Có, DOS-Me |
+ rate.reviews cột mới | verdict text (câu kết luận, tối đa 300 ký tự); criteria_version int; value_score numeric(5,2) và value_breakdown jsonb (mục 6). | Mới | Có, DOS-Me |
+ rate.review_points (bảng mới) | Mỗi dòng là một ưu hoặc nhược: review_id, criterion_key (rỗng nghĩa là ưu nhược chung của review), kind ('pro' hoặc 'con'), body (5 đến 150 ký tự), position, cluster_id (do bước gom gán). | Mới | Có, DOS-Me |
+ rate.product_points (bảng mới) | Kết quả gom cho mỗi sản phẩm: master_id, criterion_key, kind, label (câu hiển thị), support (tổng trọng số người nhắc), reviewer_count, has_expert, first_seen, last_seen, method ('code' hoặc 'ai'). | Mới; thay dần review_analysis.pros/cons | Có, DOS-Me |
+ rate.master_products cột mới | rating_score numeric(3,2) (điểm Bayes), rating_weight_sum numeric, criteria_scores jsonb ({khoá: {score, weight_sum}}, chỉ tiêu chí đủ dữ liệu). | Mới | Có, DOS-Me; hàm làm mới ghi các cột này |
+ rate.submit_community_review | Bản 2: nhận verdict, danh sách ưu nhược và số sao do API đã tính; kiểm lại các điều kiện bắt buộc (mục 4) để không ai gọi thẳng RPC mà lách được. | Có sẵn, sửa | Có, DOS-Me |
+
+
Vì sao tách ưu nhược ra bảng riêng thay vì cột mảng: bước gom cần gán từng dòng vào một cụm, đếm theo tiêu chí và truy ngược "ai đã nhắc ý này". Bảng riêng làm việc đó bằng SQL thường và đánh chỉ mục được; mảng trong jsonb thì phải bung ra mỗi lần.
+
+
4. Luồng viết review
+
4.1 Form cho người dùng Rate
+
+ - Chấm sao từng tiêu chí của ngành hàng, theo nửa sao. Điểm tổng tính sẵn hiện ngay bên cạnh. Được bỏ qua tối đa 2 tiêu chí, trừ "Đáng tiền".
+ - Câu kết luận (bắt buộc, 20 đến 300 ký tự): "Ai nên mua, ai không nên". Đây là đoạn mở đầu kiểu RTINGS.
+ - Ưu điểm và nhược điểm chung (bắt buộc): mỗi bên ít nhất 1, nhiều nhất 5 dòng, mỗi dòng 5 đến 150 ký tự. Áp dụng cả cho review chuyên gia do Rate đăng (
reviewer_type = 'expert').
+ - Ưu nhược theo tiêu chí (không bắt buộc): bấm vào một tiêu chí đã chấm để thêm 1 đến 3 dòng. Dòng ưu nhược chung mà người viết kéo vào một tiêu chí cũng tính.
+ - Nội dung dài và ảnh (không bắt buộc, như hiện nay).
+
+
4.2 Kiểm tra (ở cả API và RPC, không chỉ ở trình duyệt)
+
+ - Nhược điểm không được là câu né: "không có", "chưa thấy", "không có gì để chê", ".", lặp ký tự. Danh sách cụm từ nằm trong code, có test. Người dùng thật sự chưa thấy nhược điểm thì có thể ghi điều nhỏ nhất (giá, phụ kiện, cân nặng); form gợi ý sẵn vài ý theo ngành hàng.
+ - Không có hai dòng trùng nhau, không có dòng xuất hiện ở cả ưu lẫn nhược.
+ - Không chép nguyên văn từ review khác của chính người đó ở sản phẩm khác (so sánh gần giống, mục chống lạm dụng ở 6.4).
+ - Sao và ưu nhược trái nhau (5 sao Pin nhưng nhược "pin rất tệ") không chặn, nhưng gắn cờ cho bước phân tích như mô hình tin cậy (loại 15).
+ - Thông báo lỗi bằng tiếng Việt, chỉ đúng ô sai; bản nháp giữ ở trình duyệt để không mất chữ khi lỗi.
+
+
4.3 Review lấy từ nguồn khác
+
+ - Shopee và các sàn: chỉ có số sao tổng và chữ. Ưu nhược không bắt buộc. Không sinh sao tiêu chí giả từ chữ. Bước AI có thể (không bắt buộc, giai đoạn sau) trích từ chữ ra vài dòng ưu nhược gắn tiêu chí, ghi
method = 'ai' và chỉ dùng cho bước gom, không hiện như lời người viết.
+ - Chuyên gia bên ngoài (khi có nguồn được phép): lưu điểm tổng, điểm tiêu chí quy về thang 5 theo một bảng chuyển khoá cho từng nguồn, kèm link bài gốc. Ưu nhược của họ chỉ lưu dạng tóm tắt ngắn do Rate viết lại, không chép nguyên văn bài viết của người khác.
+
+
+
5. Gom ưu nhược thành ưu nhược của sản phẩm
+
5.1 Code làm trước
+
+ - Chuẩn hoá chữ: chữ thường, bỏ từ đệm ("khá", "rất", "hơi"), giữ dấu khi so hai dòng (bỏ dấu thì "sạc" và "sắc" thành một) và giữ từ phủ định ("không", "chưa", "chẳng"). Bản bỏ dấu chỉ dùng để dò từ khoá ở bước 2.
+ - Gán tiêu chí: dòng đã gắn tiêu chí thì giữ; dòng chung thì dò từ khoá theo ngành hàng (pin, sạc, mAh cho Pin; màn, sáng, chói cho Màn hình...). Bộ từ khoá dùng lại phần trích khía cạnh đã có trong analyzer. Không khớp thì vào nhóm "Chung".
+ - Gom cụm: hai dòng cùng tiêu chí, cùng loại, giống nhau từ 60% số từ trở lên thì vào một cụm. Đơn giản, chạy được trong SQL hoặc Node, không tốn tiền AI.
+ - Đếm: mỗi cụm cộng trọng số tin cậy của người nhắc (mỗi người tính một lần). Review bị cổng chặn (trọng số 0) không được đếm. Dòng từ chuyên gia đánh dấu riêng.
+ - Lọc để hiện: ưu điểm cần ít nhất 2 người khác nhau và tổng trọng số từ 1,5 trở lên, hoặc 1 chuyên gia; nhược điểm cũng vậy. Ngưỡng trọng số chặn kiểu dùng vài tài khoản mới (trọng số thấp) để dựng một ý; ngưỡng người chặn một người ghét sản phẩm tự tạo ra nhược điểm của cả sản phẩm. Mỗi tiêu chí hiện tối đa 3 ưu, 3 nhược; phần tổng của sản phẩm hiện tối đa 5 mỗi bên.
+ - Luôn giữ nhược điểm: nếu có nhược điểm đạt ngưỡng thì phần tổng phải hiện ít nhất 1, kể cả khi ưu điểm áp đảo. Trang toàn ưu điểm là dấu hiệu seeding, và người mua cần thấy mặt yếu.
+
+
5.2 AI làm phần còn lại
+
+ - Viết lại mỗi cụm thành một câu gọn, đúng chính tả ("Pin dùng hết ngày với nhu cầu vừa phải"). Câu AI viết phải dựa trên các dòng trong cụm; nếu không khớp thì dùng nguyên dòng được nhắc nhiều nhất.
+ - Gộp hai cụm cùng ý mà code không nhận ra vì cách nói khác nhau.
+ - Viết đoạn kết luận chung của sản phẩm từ các cụm hàng đầu, có ghi "Tóm tắt từ N review".
+ - Chạy theo đợt (khi có review mới, hoặc hằng đêm), dùng lại pipeline AI của importer, không gọi AI khi người dùng mở trang.
+
+
5.3 Độ mới
+
+ - Trọng số của một dòng giảm một nửa sau mỗi 12 tháng, nên cụm cũ dần nhường chỗ cho cụm mới (sản phẩm được cập nhật phần mềm, giá đổi).
+ - Cụm không có ai nhắc trong 18 tháng thì ẩn.
+ - Khối ưu nhược ghi "Cập nhật ngày ..." và số review đã dùng.
+
+
+
6. Điểm giá trị review
+
6.1 Thành phần (thang 100)
+
+ | Thành phần | Tối đa | Cách tính |
+ | Đầy đủ | 35 | Chấm đủ tiêu chí 10; có câu kết luận 5; có ưu và nhược chung 10; có ưu nhược theo ít nhất 2 tiêu chí 5; có ảnh thật 5. |
+ | Nội dung cụ thể | 20 | Theo trục C của mô hình tin cậy (hời hợt, ngắn nhưng cụ thể, chi tiết, chuyên sâu). Độ dài có trần: viết dài thêm sau mức đủ không được thêm điểm. |
+ | Đã mua | 15 | Có xác nhận đã mua (sàn, hoặc sau này hoá đơn). Chưa xác nhận: 0. |
+ | Vote hữu ích | 20 | Từ cận dưới Wilson của vote hữu ích (đang dùng để sắp review), nên vài vote đầu không làm điểm nhảy vọt. |
+ | Uy tín người viết | 10 | Trục D của mô hình tin cậy: tuổi tài khoản, lịch sử review, uy tín theo ngành hàng. |
+
+
Review bị cổng chặn (giả, chép, viết cho có để lấy xu...) có điểm giá trị bằng 0, dù đầy đủ đến đâu. Review lấy từ sàn cũng có điểm này (thường thấp vì thiếu ưu nhược), nên một thang dùng chung cho mọi review.
+
+
6.2 Ảnh hưởng tới điểm sản phẩm
+
JOY muốn review đầy đủ "cao hơn một tí". Chỉ phần "Đầy đủ" ở 6.1 chạm vào trọng số, qua một hệ số nhân:
+
hệ_số_đầy_đủ = 1 + 0,25 × đầy_đủ / 35 từ ×1,00 (không có gì) đến ×1,25 (đủ hết)
+hôm nay: w = helpfulness_weight × hệ_số_đầy_đủ, trần 3 như cũ
+
+ - Hiệu quả thật: review từ sàn không có ưu nhược giữ nguyên trọng số (không bị phạt vì nguồn không cho phép ghi ưu nhược). Review Rate luôn có kết luận và ưu nhược nên ít nhất khoảng ×1,18; đủ hết là ×1,25. Tức review Rate nặng hơn review sàn tương đương từ 18% đến 25%.
+ - Phụ thuộc cần biết: trọng số đang lưu hôm nay là
helpfulness_weight (gốc cộng vote), chưa phải công thức sáu trục của mô hình tin cậy. Hệ số trên nhân vào trọng số đang lưu, nên làm được ngay ở P6. Khi nào mô hình sáu trục được áp dụng (một việc riêng, làm đổi điểm mọi sản phẩm), phần ưu nhược và ảnh trong "Đầy đủ" được bỏ khỏi hệ số này, vì trục C đã tính chúng (mức "chi tiết" đòi có cả ưu lẫn nhược, ảnh thật được cộng 0,1). Khi đó chỉ còn phần chấm đủ tiêu chí và kết luận nâng trọng số, để không tính hai lần.
+ - Vote và uy tín người viết không nằm trong hệ số này, nên không bị tính hai lần với vote đã có trong
helpfulness_weight.
+
+
+
6.3 Dùng điểm giá trị ở đâu
+
+ - Có: sắp review ở trang sản phẩm (cách sắp mặc định "Hữu ích nhất" dùng điểm giá trị, vote vẫn là thành phần lớn); nhãn công khai theo bậc ("Cơ bản", "Đầy đủ", "Xuất sắc"); chọn review hiện đầu tiên trên thẻ sản phẩm; tính điểm thưởng khi có hệ thống điểm.
+ - Không: không dùng điểm giá trị để xếp hạng sản phẩm trực tiếp. Nó chỉ chạm điểm sản phẩm qua mức nâng nhẹ ở 6.2.
+ - Hiện con số hay không: người xem thấy bậc, người viết thấy con số của chính mình và cách tăng nó ("Thêm ưu nhược cho Pin để lên bậc Đầy đủ"). Hiện con số công khai dễ khiến người ta tối ưu con số thay vì viết thật.
+
+
+
6.4 Rủi ro lạm dụng và cách chặn
+
+ | Rủi ro | Chặn bằng |
+ | Điền cho đủ ô để lấy điểm ("Ưu: tốt. Nhược: không") | Kiểm ở 4.2; phần "Nội dung cụ thể" chấm theo chi tiết, không theo số ô; phần "Đầy đủ" chỉ cộng khi trục C không phải hời hợt. |
+ | Viết dài, đệm chữ | Độ dài có trần; chữ đệm và lặp vào nhóm "viết cho có", cổng chặn. |
+ | Chép một bộ ưu nhược cho nhiều sản phẩm | So gần giống giữa các review của cùng người và giữa các tài khoản (trục A, loại "chép từ nơi khác"). |
+ | Nhóm vote cho nhau | Điều kiện vote đã có (tuổi tài khoản, xác minh, giới hạn mỗi giờ); trọng số người vote ở mục 9 của mô hình tin cậy; Wilson thay vì đếm thô. |
+ | Săn điểm thưởng hàng loạt | Điểm thưởng chỉ trao sau khi review qua bước phân tích và một thời gian giữ (ví dụ 7 ngày); điểm giảm dần theo số review trong ngày; mỗi người một review mỗi sản phẩm (đã có). |
+ | Bỏ qua 2 tiêu chí yếu nhất để số sao tổng của review cao hơn | Không chặn được hoàn toàn. Giới hạn 2 tiêu chí, không được bỏ "Đáng tiền"; điểm tiêu chí của sản phẩm không bị ảnh hưởng vì chỉ đọc tiêu chí đã chấm; phần "Đầy đủ" mất 10 điểm khi bỏ qua tiêu chí. |
+ | Người bán tự viết hoặc thuê viết | Trục B (xung đột lợi ích, tài trợ không công bố) đã có trong mô hình tin cậy. |
+
+
+
7. Phác thảo giao diện (bố cục kiểu RTINGS)
+
7.1 Khối đánh giá trên trang sản phẩm
+
+
4.6★★★★★trên 5 · 128 review đã kiểm · Độ tin cậy cao
+
Chuyên gia: 4.4 (2 bài) · cách tính điểm
+
Kết luận: Máy mạnh, camera tốt cả ngày lẫn đêm, pin đủ một ngày dùng nhiều. Hợp người chụp ảnh và chơi game. Không hợp người cần sạc rất nhanh. Tóm tắt từ 128 review, cập nhật 31/10/2026
+
Điểm theo tiêu chí, từ 23 review trên Rate · cách tính điểm
+
+
+
+
+
Thiết kế và độ bền 10%
Chưa đủ
+
+
+
Ưu điểm
+ - Chơi game nặng mượt, ít nóng · 41 người
+ - Ảnh đêm rõ, ít nhiễu · 33 người
+ - Màn hình sáng, dễ nhìn ngoài nắng · 27 người
+
+
Nhược điểm
+ - Sạc chậm so với máy cùng giá · 22 người
+ - Giá cao, ít khuyến mãi · 15 người
+
+
+
Bấm vào một tiêu chí để xem ưu nhược riêng của tiêu chí đó.
+
+
+
7.2 Một thẻ review
+
+
+
Minh T. Đã muaXuất sắc· dùng 4 tháng · 12/10/2026
+
★★★★☆ 4.3 (tính từ 6 tiêu chí)
+
"Hợp người chơi game và chụp đêm; ai cần sạc nhanh nên cân nhắc."
+
+
Ưu điểm
- Genshin max setting giữ 55 đến 60 fps
- Chụp đêm không cần chân máy
+
Nhược điểm
- Sạc đầy mất gần 90 phút
- Hộp không kèm củ sạc
+
+
+ Hiệu năng ★ 5.0Camera ★ 4.5Pin và sạc ★ 3.5Màn hình ★ 4.5Thiết kế ★ 4.0Đáng tiền ★ 4.0
+
+
▸ Pin và sạc: + dùng 1 ngày rưỡi · − sạc chậm
+
+
+
+
Người mua trên Shopee Đã muaCơ bản· nguồn: Shopee
+
★★★★★ 5.0
+
Máy đẹp, giao nhanh, dùng mượt.
+
Review từ sàn: không có ưu nhược theo tiêu chí.
+
+
+
+
8. Kế hoạch theo giai đoạn
+
+ | Giai đoạn | Việc | Cỡ | Cần database? |
+ | P0 | Sửa #1143 trên web: bỏ điểm tiêu chí suy ra, trả null, "Chưa đủ dữ liệu", bỏ chúng khỏi điểm ghép bảng xếp hạng, so kè 1v1 không tính tiêu chí trống, ẩn tiêu chí không thuộc ngành hàng, test và OpenAPI. Merge PR #1478. | S | Không |
+ | P1 | Danh sách tiêu chí theo ngành hàng trong shared-data (khoá, nhãn, trọng số, phiên bản) và bảng chuyển 5 khoá cũ. Web đọc danh sách này thay cho 5 khoá cố định. | M | Không |
+ | P2 | Migration DOS-Me: cột mới trên reviews và master_products, bảng review_points, product_points; hàm làm mới ghi điểm Bayes và điểm tiêu chí. Core soạn SQL đề xuất kèm file kiểm tra và hoàn tác; JOY chạy. | L | Có, JOY áp dụng |
+ | P3 | Form review bản 2 và RPC submit_community_review bản 2: sao theo tiêu chí, kết luận, ưu nhược bắt buộc, kiểm tra ở 4.2. | M | Sau P2 |
+ | P4 | Một con số điểm mọi nơi (#1470): mọi trang và API đọc rating_score. | S | Sau P2 |
+ | P5 | Gom ưu nhược: code (5.1) rồi AI (5.2), chạy trong importer theo đợt, ghi product_points; khối 7.1 trên trang sản phẩm. | M đến L | Sau P2 |
+ | P6 | Điểm giá trị review: tính, lưu, sắp review, nhãn bậc, hệ số đầy đủ trên trọng số đang lưu (6.2). Áp dụng mô hình sáu trục là việc riêng, không nằm trong P6. | M | Sau P2 |
+ | Sau | Điểm thưởng cho người viết (cần hệ thống điểm, thuộc DOS-Me); nhập review chuyên gia bên ngoài; AI trích ưu nhược từ review sàn. | L | Có |
+
+
P0 và P1 bắt đầu được ngay sau khi JOY duyệt. P3 đến P6 chờ P2.
+
+
9. Các quyết định cần JOY
+
+
D1. Danh sách tiêu chí và trọng số ở 2.1. Duyệt bản nháp, hay sửa trọng số trước?
+
Đề xuất: duyệt bản nháp làm phiên bản 1; Core chỉnh sau khi có khoảng 200 review Rate có chấm tiêu chí.
+
+
D2. Điểm tổng của sản phẩm tính thế nào. (a) Bayes trên số sao tổng của mọi review, trong đó review Rate có số sao tính từ tiêu chí theo trọng số; (b) tính thẳng từ điểm các tiêu chí của sản phẩm.
+
Đề xuất: (a). (b) bỏ mất review từ sàn, và sản phẩm chưa có review Rate sẽ không có điểm.
+
+
D3. Một con số điểm cho mọi trang (#1470). Điểm Bayes lưu trên master_products (phương án 2 của PR #1478), điểm chuyên gia hiện riêng có nhãn, điểm ghép chỉ dùng để phá hoà.
+
Đề xuất: duyệt, và merge PR #1478 ngay.
+
+
D4. Sửa #1143 bây giờ (P0). Bỏ điểm tiêu chí suy ra; trang và API hiện "Chưa đủ dữ liệu". Một số chip "#1" và bảng xếp hạng theo tiêu chí sẽ vắng đi vì đa số sản phẩm chưa có dữ liệu tiêu chí.
+
Đề xuất: làm ngay. Số ít mà thật tốt hơn số nhiều mà bịa.
+
+
D5. Mức bắt buộc của form. Bắt buộc câu kết luận, ít nhất 1 ưu và 1 nhược chung, chấm đủ tiêu chí (bỏ qua tối đa 2, trừ "Đáng tiền"); ưu nhược theo tiêu chí không bắt buộc.
+
Đề xuất: duyệt như trên. Điểm này khác ý ban đầu của JOY (ưu nhược cho cả từng tiêu chí): bắt buộc ưu nhược cho từng tiêu chí làm form quá dài, người dùng bỏ giữa chừng. Ưu nhược theo tiêu chí vẫn có, được cộng điểm giá trị, và dòng chung được tự gán tiêu chí ở bước gom.
+
+
D6. Điểm giá trị review ảnh hưởng điểm sản phẩm bao nhiêu. (a) Chỉ dùng để sắp review và gắn nhãn; (b) nâng nhẹ trọng số bằng hệ số đầy đủ, từ ×1,00 đến ×1,25 (6.2); (c) nhân cả điểm giá trị 0 đến 100 vào trọng số.
+
Đề xuất: (b). Đúng ý "cao hơn một tí", không tính vote hai lần, không mở đường cho người tối ưu con số để kéo điểm sản phẩm.
+
+
D7. Hiện điểm giá trị ra sao. Con số công khai, hay chỉ bậc công khai và con số cho riêng người viết.
+
Đề xuất: bậc công khai, con số chỉ người viết thấy.
+
+
D8. Điểm thưởng cho người viết. Rate chưa có hệ thống điểm. Điểm thưởng nằm ở Rate hay ở DOS-Me (tài khoản chung của hệ sinh thái), và quy đổi ra gì.
+
Đề xuất: tách thành một quyết định riêng sau P6; khi làm thì đặt ở DOS-Me, trao sau 7 ngày giữ và sau bước phân tích. Điểm giá trị review là đầu vào sẵn sàng cho việc đó.
+
+
D9. Ngưỡng hiển thị. Một ưu hoặc nhược của sản phẩm: ít nhất 2 người khác nhau và tổng trọng số từ 1,5, hoặc 1 chuyên gia; luôn giữ ít nhất 1 nhược điểm nếu có. Một điểm tiêu chí: ít nhất 3 người viết; xếp hạng theo tiêu chí: ít nhất 5.
+
Đề xuất: duyệt.
+
+
D10. Migration DOS-Me cho P2. Core soạn SQL đề xuất (cột, bảng, hàm làm mới, file kiểm tra, file hoàn tác) để JOY xem và áp dụng qua DOS-Me.
+
Đề xuất: duyệt để Core soạn; không có gì được áp dụng khi chưa có lệnh của JOY.
+
+
10. Không nằm trong đề xuất này
+
+ - Không sửa code, không chạy SQL, không đổi dữ liệu nào. Các issue thực hiện sẽ được mở sau khi JOY duyệt.
+ - Không bàn cách lấy review chuyên gia từ các trang bên ngoài (quyền sử dụng nội dung là một quyết định riêng).
+ - Điểm người bán (review chỉ nói về giao hàng, đóng gói) đi theo mô hình tin cậy review, không thuộc tiêu chí sản phẩm.
+
+
+
+
diff --git a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-rollback.sql b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-rollback.sql
new file mode 100644
index 000000000..2e84748f6
--- /dev/null
+++ b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-rollback.sql
@@ -0,0 +1,46 @@
+-- PROPOSAL, NOT APPLIED. Rollback of 2026-10-31-dos-me-criteria-ratings.sql (DOS/Rate#1543).
+-- One transaction, re-runnable, safe before or after M1+M2 or its rollback: the migration
+-- replaces no existing function, so this only drops what it added. Data written only into the
+-- new columns and tables (exact stars, verdicts, pro and con lines, product points,
+-- rating_score, criteria_scores) is lost; nothing older depends on it. Run it only while no
+-- phase reads or writes them: before P3 (#1544), P4 (#1545) and P5 (#1546), or after they are
+-- reverted.
+
+BEGIN;
+
+-- 1. Triggers first, so the drops below fire nothing.
+DROP TRIGGER IF EXISTS trg_reviews_rating_extras_ins ON rate.reviews;
+DROP TRIGGER IF EXISTS trg_reviews_rating_extras_upd ON rate.reviews;
+DROP TRIGGER IF EXISTS trg_reviews_rating_extras_del ON rate.reviews;
+DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_ins ON rate.review_flags;
+DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_upd ON rate.review_flags;
+DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_del ON rate.review_flags;
+DROP TRIGGER IF EXISTS trg_master_products_rating_extras ON rate.master_products;
+
+-- 2. Functions.
+DROP FUNCTION IF EXISTS rate.master_products_refresh_rating_extras();
+DROP FUNCTION IF EXISTS rate.review_flags_refresh_rating_extras();
+DROP FUNCTION IF EXISTS rate.reviews_refresh_rating_extras();
+DROP FUNCTION IF EXISTS rate.refresh_rating_extras_of_products(uuid[]);
+DROP FUNCTION IF EXISTS rate.refresh_master_rating_extras(uuid);
+DROP FUNCTION IF EXISTS rate.master_rating_extras(uuid);
+DROP FUNCTION IF EXISTS rate.master_rating_item(uuid);
+
+-- 3. Tables (their policies and indexes go with them).
+DROP TABLE IF EXISTS rate.product_points;
+DROP TABLE IF EXISTS rate.review_points;
+
+-- 4. Columns (their CHECK constraints go with them).
+ALTER TABLE rate.master_products
+ DROP COLUMN IF EXISTS criteria_scores,
+ DROP COLUMN IF EXISTS rating_weight_sum,
+ DROP COLUMN IF EXISTS rating_score;
+
+ALTER TABLE rate.reviews
+ DROP COLUMN IF EXISTS value_breakdown,
+ DROP COLUMN IF EXISTS value_score,
+ DROP COLUMN IF EXISTS criteria_version,
+ DROP COLUMN IF EXISTS verdict,
+ DROP COLUMN IF EXISTS rating_exact;
+
+COMMIT;
diff --git a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql
new file mode 100644
index 000000000..9fe8ff517
--- /dev/null
+++ b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings-verify.sql
@@ -0,0 +1,68 @@
+-- PROPOSAL. Read-only checks for 2026-10-31-dos-me-criteria-ratings.sql (DOS/Rate#1543). Run
+-- after it. `scripts/test-criteria-ratings-proposal.mjs` runs the expect-0 ones on the replay
+-- stack.
+
+-- V1 Every new column exists. expect 0
+SELECT 10 - count(*) AS missing_columns
+FROM information_schema.columns
+WHERE table_schema = 'rate'
+ AND ((table_name = 'reviews' AND column_name IN ('rating_exact', 'verdict', 'criteria_version',
+ 'value_score', 'value_breakdown'))
+ OR (table_name = 'master_products' AND column_name IN ('rating_score', 'rating_weight_sum',
+ 'criteria_scores'))
+ OR (table_name = 'review_points' AND column_name = 'body')
+ OR (table_name = 'product_points' AND column_name = 'label'));
+
+-- V2 Both new tables have row level security on. expect 0
+SELECT 2 - count(*) AS tables_without_rls
+FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
+WHERE n.nspname = 'rate' AND c.relname IN ('review_points', 'product_points')
+ AND c.relrowsecurity;
+
+-- V3 Each new function exists once, and no client role can run it. expect 0
+-- Client roles: PUBLIC, anon, authenticated.
+SELECT count(*) FILTER (WHERE to_regprocedure(w.signature) IS NULL)
+ + count(*) FILTER (WHERE to_regprocedure(w.signature) IS NOT NULL AND (
+ has_function_privilege('anon', to_regprocedure(w.signature), 'EXECUTE')
+ OR has_function_privilege('authenticated', to_regprocedure(w.signature), 'EXECUTE')
+ OR EXISTS (SELECT 1 FROM pg_proc p, aclexplode(p.proacl) a
+ WHERE p.oid = to_regprocedure(w.signature) AND a.grantee = 0)))
+ AS missing_or_open_functions
+FROM (VALUES ('rate.master_rating_item(uuid)'), ('rate.master_rating_extras(uuid)'),
+ ('rate.refresh_master_rating_extras(uuid)'),
+ ('rate.refresh_rating_extras_of_products(uuid[])'),
+ ('rate.reviews_refresh_rating_extras()'),
+ ('rate.review_flags_refresh_rating_extras()'),
+ ('rate.master_products_refresh_rating_extras()')) w(signature);
+
+-- V4 All seven triggers exist and are enabled. expect 0
+SELECT 7 - count(*) AS missing_triggers
+FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace
+WHERE n.nspname = 'rate' AND NOT t.tgisinternal AND t.tgenabled <> 'D'
+ AND t.tgname IN ('trg_reviews_rating_extras_ins', 'trg_reviews_rating_extras_upd',
+ 'trg_reviews_rating_extras_del', 'trg_review_flags_rating_extras_ins',
+ 'trg_review_flags_rating_extras_upd', 'trg_review_flags_rating_extras_del',
+ 'trg_master_products_rating_extras');
+
+-- V5 Every criterion score meets the display thresholds of D9. expect 0
+SELECT count(*) AS criteria_below_threshold
+FROM rate.master_products m, jsonb_each(m.criteria_scores) c(key, v)
+WHERE (c.v ->> 'writers')::int < 3
+ OR (c.v ->> 'weight_sum')::numeric < 1.5
+ OR (c.v ->> 'score')::numeric NOT BETWEEN 1 AND 5;
+
+-- V6 Every master holds what its reviews give today. expect 0
+-- No stale, missing or wrong rating or criteria: catches a skipped backfill and a missed trigger.
+SELECT count(*) AS masters_out_of_date
+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);
+
+-- V7 Information: the rating each page will read, next to today's weighted mean.
+SELECT m.slug, m.user_rating_weighted, m.rating_score, m.rating_weight_sum,
+ (SELECT count(*) FROM jsonb_object_keys(m.criteria_scores)) AS criteria_with_scores
+FROM rate.master_products m
+WHERE m.authentic_review_count > 0
+ORDER BY m.authentic_review_count DESC
+LIMIT 20;
diff --git a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md
new file mode 100644
index 000000000..b8c8bbe12
--- /dev/null
+++ b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.md
@@ -0,0 +1,86 @@
+# Proposal: DOS-Me migration for criteria ratings, pros and cons (DOS/Rate#1543)
+
+**Status:** proposal for JOY, **not applied**. A schema change on the shared Supabase: JOY approves and applies it through DOS-Me (preview, then beta, then prod). Nothing runs from DOS/Rate.
+
+Design: [`2026-10-31-criteria-stars-pros-cons.html`](./2026-10-31-criteria-stars-pros-cons.html), section 3; phase P2 of #1531, decisions D2, D3, D9 and D10 approved by JOY on 2026-10-31.
+
+## Files
+
+- [`2026-10-31-dos-me-criteria-ratings.sql`](./2026-10-31-dos-me-criteria-ratings.sql): the migration. Idempotent, in two transactions: the schema change first, then a backfill of the reviewed masters.
+- [`2026-10-31-dos-me-criteria-ratings-verify.sql`](./2026-10-31-dos-me-criteria-ratings-verify.sql): read-only checks. V1 to V6 must return 0 after applying:
+ - V3: function grants for PUBLIC, anon and authenticated.
+ - V4: the triggers.
+ - V6: recomputes every master and compares it with what is stored.
+ - V7: lists `rating_score` next to today's `user_rating_weighted` for the 20 most-reviewed masters.
+- [`2026-10-31-dos-me-criteria-ratings-rollback.sql`](./2026-10-31-dos-me-criteria-ratings-rollback.sql): drops everything the migration added. One transaction, re-runnable.
+- [`scripts/test-criteria-ratings-proposal.mjs`](../../scripts/test-criteria-ratings-proposal.mjs): the CI replay in Docker, in four steps:
+ 1. **Reference stack:** apply twice; check the ratings, the triggers (exact star, a flag that leaves the rating, the weight cap) and the grants; roll back twice.
+ 2. **Before M1+M2:** apply, then run M0 and M1+M2. Two sizes become one Item, both variants show the pooled rating, and a vote on the Item path updates it.
+ 3. **M1+M2 rollback** with this migration in place.
+ 4. **After M1+M2:** M1+M2 again, then this migration on top, then the rollback.
+
+## What it adds
+
+- **`rate.reviews`:**
+ - `rating_exact numeric(2,1)`: the 0.1-step star a criteria review computes. `rating_star` stays the rounded integer that existing code reads.
+ - `verdict` (300 characters at most).
+ - `criteria_version`.
+ - `value_score` (0 to 100) and `value_breakdown`, for P6.
+- **`rate.review_points`:** one pro or con line of a review, for the whole review or one criterion.
+ - Each line is 5 to 150 characters.
+ - `method` is `writer` or `ai`.
+ - `cluster_id` is filled by P5.
+ - Public read; only the service role writes.
+- **`rate.product_points`:** the aggregated lines of a master, with support, writer count, an expert flag, and first and last seen dates. Public read; the service role and the worker (P5) write.
+- **`rate.master_products`:**
+ - `rating_score`: the one rating of D3. It is a Bayesian average with k = 3 and mu = 4.2 (the same formula as the web's `computeBayesianRatingSummary`). Each review weight is capped at 3, and `rating_exact` is used when present. It is stored as `numeric(4,3)`, and criterion scores at 3 decimals too, so a page rounds once, to 1 decimal (4.249 shows 4.2).
+ - `rating_weight_sum`.
+ - `criteria_scores`: `{key: {score, weight_sum, writers}}`. A criterion appears only when all of these hold (D9):
+ - 3 or more reviews rated it;
+ - each rating is a number from 1 to 5, under a key shaped like the shared-data keys;
+ - the reviews' weight sum is 1.5 or more.
+
+ Readers apply the ranking threshold of 5 from `writers`.
+- **Which reviews count:** the same reviews `refresh_master_product_rating` counts.
+ - Before M1+M2: the master's own listings.
+ - Once the master belongs to an Item: every listing of the Item, and the result is written to every variant, as `refresh_item_rating` does.
+ - The `item_id` columns are read dynamically, so the same functions work with or without M1+M2.
+- **Functions:**
+ - `rate.master_rating_extras(master)` computes the values. It is read-only; the verify file uses it.
+ - `rate.refresh_master_rating_extras(master)` writes them, only where they changed.
+ - Only the service role can run them.
+- **Triggers keep the values current.**
+ - On `rate.reviews` (a change of a rating input: star, exact star, criteria, weight, type, origin or listing) and `rate.review_flags` (`include_in_rating`), for every insert, update and delete. They run once per statement; see Known limits for bulk flag imports.
+ - On `rate.master_products`: a change of `user_rating_weighted` or `authentic_review_count`, for example after a listing moves.
+ - **No existing function is replaced or renamed.** A later `CREATE OR REPLACE` of a refresh function (M1+M2, its rollback, a hotfix) cannot stop these updates.
+
+## Order of operations
+
+1. **Apply order:** before or after M0 and M1+M2. The replay covers both orders and the M1+M2 rollback.
+2. **Apply on preview:**
+ - The schema transaction takes ACCESS EXCLUSIVE locks on `rate.reviews` and `rate.master_products` only for the `ALTER TABLE` statements. The new CHECKs are added NOT VALID, so nothing is scanned under that lock.
+ - The backfill transaction validates the CHECKs (a scan that lets reads and writes go on) and refreshes the reviewed masters, locking only those rows.
+ - Run the verify file: V1 to V6 must return 0.
+ - Compare V7: `rating_score` sits a little below `user_rating_weighted` for masters with few reviews, because of the prior. That is intended (D3).
+3. **Then beta, then prod**, running the verify file after each.
+4. **If M1+M2 is ever rolled back while this is in place:** run the backfill again (section 8 of the migration, its last transaction), then V6. Variants whose reviews did not change keep the Item's pooled values until then; V6 lists them.
+5. **Next phases:**
+ - P4 (#1545) switches the web to `rating_score`.
+ - P3 (#1544) writes the new review columns and lines.
+ - P5 (#1546) writes `product_points`.
+
+## Rollback
+
+`2026-10-31-dos-me-criteria-ratings-rollback.sql` drops the triggers, the functions, both tables and the new columns. It is safe before or after M1+M2, because the migration replaced nothing. Data written only into these objects is lost; nothing older depends on it.
+
+Roll back only while no phase reads or writes the new columns: before P3, P4 and P5, or after reverting them.
+
+## Known limits
+
+- **Bulk flag imports.** The existing `trg_review_flags_sync_weight` updates `rate.reviews` one row at a time. A bulk flag import therefore refreshes a master once per flag row, not once per import. Re-crawls that rewrite reviews unchanged refresh nothing: only changes to rating inputs count.
+- **Concurrent writers.** A variant locked by another transaction is skipped, as `refresh_item_rating` does. This avoids deadlocks between sibling variants. The other transaction writes its own values when it reaches that variant. Like the live refresh, two writers on the same master can leave the older values until the next write; V6 shows any master out of date.
+- **Deleting a listing with its reviews** leaves the master's values in place until its next review write or refresh, the same as `user_rating_weighted` today. V6 shows it. Re-run the backfill to fix it.
+
+- **`rate_worker` can write the new columns directly.** It keeps its table-level UPDATE on `master_products` and on crawled reviews (20261018183000). The next trigger run overwrites such a write, and V6 shows it. Column-level grants are a separate hardening step.
+- **Per-category prior means:** version 1 uses 4.2 for every criterion.
+- **Not in this migration:** `submit_community_review` v2 (P3) and the value score weight (P6). Each comes with its own phase.
diff --git a/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql
new file mode 100644
index 000000000..c783187c9
--- /dev/null
+++ b/Docs/Proposals/2026-10-31-dos-me-criteria-ratings.sql
@@ -0,0 +1,469 @@
+-- PROPOSAL, NOT APPLIED. DOS-Me migration for phase P2 of the criteria ratings design
+-- (DOS/Rate#1543; design Docs/Proposals/2026-10-31-criteria-stars-pros-cons.html, section 3;
+-- decisions D2, D3, D9, D10 approved by JOY on 2026-10-31). JOY reviews and applies it through
+-- DOS-Me (preview, then beta, then prod). Zero DDL on prod from DOS/Rate.
+-- Rollback: 2026-10-31-dos-me-criteria-ratings-rollback.sql.
+-- Verification: 2026-10-31-dos-me-criteria-ratings-verify.sql.
+-- Replay test: scripts/test-criteria-ratings-proposal.mjs (Docker, never a real database).
+--
+-- What it adds:
+-- rate.reviews rating_exact (the 0.1 star a criteria review computes), verdict,
+-- criteria_version, value_score, value_breakdown
+-- rate.review_points one pro or con line of a review, optionally for one criterion
+-- rate.product_points the aggregated pro and con lines of a master (written by P5)
+-- rate.master_products rating_score (the one rating of D3), rating_weight_sum,
+-- criteria_scores (per criterion, only when D9's thresholds are met)
+-- rate.master_rating_extras(p_master_id) computes the three master columns (read-only);
+-- rate.refresh_master_rating_extras(p_master_id) writes them, to the master or, once M1+M2
+-- is applied, to every variant of its Item (the pooled rating, like refresh_item_rating).
+-- Triggers on rate.reviews, rate.review_flags and rate.master_products keep them current.
+-- No existing function is replaced or renamed.
+--
+-- Order: before or after M0 and M1+M2; the replay test applies it both ways, and through the
+-- M1+M2 rollback.
+--
+-- Idempotent: IF NOT EXISTS everywhere, constraints guarded by pg_constraint, CREATE OR
+-- REPLACE for functions, DROP ... IF EXISTS before each policy and trigger. Two transactions:
+-- the schema change, then the backfill (section 8), which writes only the three new master
+-- columns of reviewed masters.
+
+BEGIN;
+
+-- 1. rate.reviews ----------------------------------------------------------------------------
+
+ALTER TABLE rate.reviews
+ ADD COLUMN IF NOT EXISTS rating_exact numeric(2, 1),
+ ADD COLUMN IF NOT EXISTS verdict text,
+ ADD COLUMN IF NOT EXISTS criteria_version integer,
+ ADD COLUMN IF NOT EXISTS value_score numeric(5, 2),
+ ADD COLUMN IF NOT EXISTS value_breakdown jsonb;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reviews_rating_exact_range') THEN
+ ALTER TABLE rate.reviews ADD CONSTRAINT reviews_rating_exact_range
+ CHECK (rating_exact IS NULL OR (rating_exact >= 1 AND rating_exact <= 5)) NOT VALID;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reviews_verdict_length') THEN
+ ALTER TABLE rate.reviews ADD CONSTRAINT reviews_verdict_length
+ CHECK (verdict IS NULL OR char_length(verdict) <= 300) NOT VALID;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reviews_criteria_version_positive') THEN
+ ALTER TABLE rate.reviews ADD CONSTRAINT reviews_criteria_version_positive
+ CHECK (criteria_version IS NULL OR criteria_version >= 1) NOT VALID;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'reviews_value_score_range') THEN
+ ALTER TABLE rate.reviews ADD CONSTRAINT reviews_value_score_range
+ CHECK (value_score IS NULL OR (value_score >= 0 AND value_score <= 100)) NOT VALID;
+ END IF;
+END $$;
+
+-- 2. rate.review_points ------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS rate.review_points (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ review_id uuid NOT NULL REFERENCES rate.reviews(id) ON DELETE CASCADE,
+ -- NULL: a pro or con of the whole review; otherwise a key of
+ -- packages/shared-data/src/rules/rating-criteria.ts.
+ criterion_key text CHECK (criterion_key IS NULL OR criterion_key ~ '^[a-z_]{2,32}$'),
+ kind text NOT NULL CHECK (kind IN ('pro', 'con')),
+ body text NOT NULL CHECK (char_length(btrim(body)) BETWEEN 5 AND 150),
+ position smallint NOT NULL DEFAULT 0 CHECK (position BETWEEN 0 AND 19),
+ -- Set by the P5 aggregation; a line belongs to at most one cluster.
+ cluster_id uuid,
+ -- 'writer' for the review's own text, 'ai' for a line P5 extracts from a marketplace review.
+ method text NOT NULL DEFAULT 'writer' CHECK (method IN ('writer', 'ai')),
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS review_points_review_idx ON rate.review_points (review_id);
+CREATE INDEX IF NOT EXISTS review_points_cluster_idx ON rate.review_points (cluster_id)
+ WHERE cluster_id IS NOT NULL;
+
+-- 3. rate.product_points -----------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS rate.product_points (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ master_id uuid NOT NULL REFERENCES rate.master_products(id) ON DELETE CASCADE,
+ criterion_key text CHECK (criterion_key IS NULL OR criterion_key ~ '^[a-z_]{2,32}$'),
+ kind text NOT NULL CHECK (kind IN ('pro', 'con')),
+ label text NOT NULL CHECK (char_length(btrim(label)) BETWEEN 3 AND 200),
+ -- Sum of the trust weights of the distinct writers behind the line, decayed by age.
+ support numeric(8, 2) NOT NULL DEFAULT 0 CHECK (support >= 0),
+ reviewer_count integer NOT NULL DEFAULT 0 CHECK (reviewer_count >= 0),
+ has_expert boolean NOT NULL DEFAULT false,
+ first_seen timestamptz NOT NULL DEFAULT now(),
+ last_seen timestamptz NOT NULL DEFAULT now(),
+ method text NOT NULL DEFAULT 'code' CHECK (method IN ('code', 'ai')),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS product_points_master_idx
+ ON rate.product_points (master_id, kind, support DESC);
+
+-- 4. RLS: public read; service role writes both, the worker writes the aggregate --------------
+
+ALTER TABLE rate.review_points ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rate.product_points ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS "Public can view review_points" ON rate.review_points;
+CREATE POLICY "Public can view review_points" ON rate.review_points FOR SELECT USING (true);
+DROP POLICY IF EXISTS "Service role write review_points" ON rate.review_points;
+CREATE POLICY "Service role write review_points" ON rate.review_points
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
+
+DROP POLICY IF EXISTS "Public can view product_points" ON rate.product_points;
+CREATE POLICY "Public can view product_points" ON rate.product_points FOR SELECT USING (true);
+DROP POLICY IF EXISTS "Service role write product_points" ON rate.product_points;
+CREATE POLICY "Service role write product_points" ON rate.product_points
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
+DROP POLICY IF EXISTS "Worker writes product_points" ON rate.product_points;
+CREATE POLICY "Worker writes product_points" ON rate.product_points
+ FOR ALL TO rate_worker USING (true) WITH CHECK (true);
+
+GRANT SELECT ON rate.review_points, rate.product_points TO anon, authenticated;
+GRANT ALL ON rate.review_points, rate.product_points TO service_role;
+-- The importer's P5 job writes the aggregate; review lines come only through the review RPC.
+GRANT SELECT, INSERT, UPDATE, DELETE ON rate.product_points TO rate_worker;
+GRANT SELECT ON rate.review_points TO rate_worker;
+
+-- 5. rate.master_products ----------------------------------------------------------------------
+
+ALTER TABLE rate.master_products
+ ADD COLUMN IF NOT EXISTS rating_score numeric(4, 3),
+ ADD COLUMN IF NOT EXISTS rating_weight_sum numeric(10, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS criteria_scores jsonb NOT NULL DEFAULT '{}'::jsonb;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'master_products_rating_score_range') THEN
+ ALTER TABLE rate.master_products ADD CONSTRAINT master_products_rating_score_range
+ CHECK (rating_score IS NULL OR (rating_score >= 0 AND rating_score <= 5)) NOT VALID;
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'master_products_rating_weight_sum_nonneg') THEN
+ ALTER TABLE rate.master_products ADD CONSTRAINT master_products_rating_weight_sum_nonneg
+ CHECK (rating_weight_sum >= 0) NOT VALID;
+ END IF;
+END $$;
+
+-- 6. The rating and criteria scores of a master (D2, D3, D9) -----------------------------------
+--
+-- The reviews rate.refresh_master_product_rating counts: user reviews of crawled listings,
+-- crawl or community, included in the rating, with a weight above 0. Their scope follows the
+-- live refresh: the master's own listings (cluster_id) before M1+M2, the whole Item's listings
+-- (item_id) once the master belongs to an Item, so every variant shows the Item's pooled
+-- rating, as refresh_item_rating does for user_rating_weighted. The item_id columns are read
+-- dynamically, so the same functions work with and without M1+M2.
+--
+-- rating_score = (k * mu + sum(w * star)) / (k + sum(w)), k = 3, mu = 4.2, each weight capped
+-- at 3 and the star = rating_exact when a criteria review set it, else
+-- rating_star (apps/web/src/lib/rate-domain-core.ts computeBayesianRatingSummary).
+-- NULL with no weight, so a page shows "no rating yet" instead of the prior.
+-- Stored at 3 decimals (scores too), so a page rounds once, to 1 decimal.
+-- criteria = the same estimate per criterion of criteria_ratings, over the reviews that
+-- rated it with a number from 1 to 5 under a key shaped like the shared-data
+-- keys (^[a-z_]{2,32}$); kept only with 3 or more reviews and a weight sum of
+-- 1.5 or more (D9). "writers" lets a reader apply the ranking threshold (5). A
+-- Rate user writes one review per product, so a review is a writer. The prior
+-- mean is 4.2 for every criterion until per-category means exist.
+
+-- The Item a master belongs to, or NULL (always NULL before M1+M2).
+CREATE OR REPLACE FUNCTION rate.master_rating_item(p_master_id uuid)
+RETURNS uuid
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_item uuid;
+BEGIN
+ IF EXISTS (SELECT 1 FROM pg_catalog.pg_attribute
+ WHERE attrelid = 'rate.master_products'::pg_catalog.regclass
+ AND attname = 'item_id' AND NOT attisdropped) THEN
+ EXECUTE 'SELECT item_id FROM rate.master_products WHERE id = $1' INTO v_item USING p_master_id;
+ END IF;
+ RETURN v_item;
+END $$;
+
+-- What the master's rating columns should hold. Read-only: the verify file compares it with
+-- the stored columns.
+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,
+ 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.weight_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 $$;
+
+-- Writes them, to the master or to every variant of its Item, only where they changed.
+CREATE OR REPLACE FUNCTION rate.refresh_master_rating_extras(p_master_id uuid)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_item uuid := rate.master_rating_item(p_master_id);
+ v_score numeric;
+ v_weight numeric;
+ v_criteria jsonb;
+BEGIN
+ SELECT x.rating_score, x.rating_weight_sum, x.criteria_scores
+ INTO v_score, v_weight, v_criteria
+ FROM rate.master_rating_extras(p_master_id) x;
+ -- Variants in id order, skipping one another transaction holds, as refresh_item_rating
+ -- does: that transaction is refreshing the same Item and writes when it gets there, so two
+ -- refreshes of sibling variants never wait on each other (no deadlock).
+ EXECUTE format($q$
+ UPDATE rate.master_products m
+ SET rating_score = $2, rating_weight_sum = $3, criteria_scores = $4
+ WHERE m.id IN (
+ SELECT mp.id FROM rate.master_products mp
+ WHERE %s = $1
+ AND (mp.rating_score, mp.rating_weight_sum, mp.criteria_scores)
+ IS DISTINCT FROM ($2, $3, $4)
+ ORDER BY mp.id
+ FOR UPDATE SKIP LOCKED)
+ $q$, CASE WHEN v_item IS NULL THEN 'mp.id' ELSE 'mp.item_id' END)
+ USING COALESCE(v_item, p_master_id), v_score, v_weight, v_criteria;
+END $$;
+
+REVOKE EXECUTE ON FUNCTION rate.master_rating_item(uuid) FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION rate.master_rating_extras(uuid) FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION rate.refresh_master_rating_extras(uuid) FROM PUBLIC, anon, authenticated;
+GRANT EXECUTE ON FUNCTION rate.master_rating_item(uuid) TO service_role;
+GRANT EXECUTE ON FUNCTION rate.master_rating_extras(uuid) TO service_role;
+GRANT EXECUTE ON FUNCTION rate.refresh_master_rating_extras(uuid) TO service_role;
+
+-- 7. Keep them current: triggers on what they read --------------------------------------------
+--
+-- No refresh function is replaced or renamed, so a later CREATE OR REPLACE of
+-- refresh_master_product_rating or refresh_item_rating (M1+M2, its rollback, a hotfix) cannot
+-- drop these columns' updates. The triggers fire on:
+-- rate.reviews every insert, update and delete (star, exact star, criteria, weight)
+-- rate.review_flags every insert, update and delete (include_in_rating)
+-- rate.master_products a change of user_rating_weighted or authentic_review_count, which the
+-- live refresh writes after a listing moves between masters or Items
+-- Statement-level with transition tables for the two review tables, and only rows whose rating
+-- inputs changed count, so a re-crawl that rewrites reviews unchanged refreshes nothing. A bulk
+-- flag import still refreshes a master once per flag row: the existing trg_review_flags_sync_weight
+-- updates rate.reviews one row at a time (20261014033000).
+
+-- The masters whose ratings a set of listings feeds: each listing's master, and once M1+M2 is
+-- in, one variant of the Item of a listing with no variant (cluster_id NULL, item_id set).
+-- Each Item is refreshed once, whichever of its variants comes first.
+CREATE OR REPLACE FUNCTION rate.refresh_rating_extras_of_products(p_products uuid[])
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_masters uuid[];
+ v_extra uuid[];
+ v_master uuid;
+ v_item uuid;
+ v_done uuid[] := '{}';
+BEGIN
+ IF p_products IS NULL OR cardinality(p_products) = 0 THEN
+ RETURN;
+ END IF;
+ v_masters := ARRAY(SELECT DISTINCT p.cluster_id FROM rate.products p
+ WHERE p.id = ANY (p_products) AND p.cluster_id IS NOT NULL);
+ IF EXISTS (SELECT 1 FROM pg_catalog.pg_attribute
+ WHERE attrelid = 'rate.products'::pg_catalog.regclass
+ AND attname = 'item_id' AND NOT attisdropped) THEN
+ EXECUTE $q$
+ SELECT ARRAY(
+ SELECT DISTINCT ON (p.item_id) m.id
+ FROM rate.products p
+ JOIN rate.master_products m ON m.item_id = p.item_id
+ WHERE p.id = ANY ($1) AND p.cluster_id IS NULL AND p.item_id IS NOT NULL
+ ORDER BY p.item_id, m.id)
+ $q$ INTO v_extra USING p_products;
+ v_masters := v_masters || v_extra;
+ END IF;
+ FOR v_master IN SELECT DISTINCT unnest(v_masters) ORDER BY 1 LOOP
+ v_item := rate.master_rating_item(v_master);
+ IF v_item IS NOT NULL THEN
+ CONTINUE WHEN v_item = ANY (v_done);
+ v_done := v_done || v_item;
+ END IF;
+ PERFORM rate.refresh_master_rating_extras(v_master);
+ END LOOP;
+END $$;
+
+-- Only rows whose rating inputs changed count, so a re-crawl that rewrites a review unchanged
+-- (ON CONFLICT DO UPDATE) refreshes nothing.
+CREATE OR REPLACE FUNCTION rate.reviews_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 product_id FROM changed_new));
+ ELSIF TG_OP = 'UPDATE' THEN
+ PERFORM rate.refresh_rating_extras_of_products(ARRAY(
+ SELECT x.product_id
+ FROM changed_new n JOIN changed_old o ON o.id = n.id,
+ LATERAL (VALUES (n.product_id), (o.product_id)) x(product_id)
+ WHERE (n.product_id, n.rating_star, n.rating_exact, n.criteria_ratings,
+ n.helpfulness_weight, n.reviewer_type, n.origin)
+ IS DISTINCT FROM
+ (o.product_id, o.rating_star, o.rating_exact, o.criteria_ratings,
+ o.helpfulness_weight, o.reviewer_type, o.origin)));
+ ELSE
+ PERFORM rate.refresh_rating_extras_of_products(ARRAY(SELECT product_id FROM changed_old));
+ END IF;
+ RETURN NULL;
+END $$;
+
+CREATE OR REPLACE FUNCTION rate.review_flags_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 f JOIN rate.reviews r ON r.id = f.review_id));
+ 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 n.include_in_rating IS DISTINCT FROM o.include_in_rating));
+ ELSE
+ -- A flag 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 f JOIN rate.reviews r ON r.id = f.review_id));
+ END IF;
+ RETURN NULL;
+END $$;
+
+CREATE OR REPLACE FUNCTION rate.master_products_refresh_rating_extras()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+BEGIN
+ PERFORM rate.refresh_master_rating_extras(NEW.id);
+ RETURN NULL;
+END $$;
+
+REVOKE EXECUTE ON FUNCTION rate.refresh_rating_extras_of_products(uuid[]) FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION rate.reviews_refresh_rating_extras() FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION rate.review_flags_refresh_rating_extras() FROM PUBLIC, anon, authenticated;
+REVOKE EXECUTE ON FUNCTION rate.master_products_refresh_rating_extras() FROM PUBLIC, anon, authenticated;
+
+DROP TRIGGER IF EXISTS trg_reviews_rating_extras_ins ON rate.reviews;
+CREATE TRIGGER trg_reviews_rating_extras_ins AFTER INSERT ON rate.reviews
+ REFERENCING NEW TABLE AS changed_new
+ FOR EACH STATEMENT EXECUTE FUNCTION rate.reviews_refresh_rating_extras();
+DROP TRIGGER IF EXISTS trg_reviews_rating_extras_upd ON rate.reviews;
+CREATE TRIGGER trg_reviews_rating_extras_upd AFTER UPDATE ON rate.reviews
+ REFERENCING OLD TABLE AS changed_old NEW TABLE AS changed_new
+ FOR EACH STATEMENT EXECUTE FUNCTION rate.reviews_refresh_rating_extras();
+DROP TRIGGER IF EXISTS trg_reviews_rating_extras_del ON rate.reviews;
+CREATE TRIGGER trg_reviews_rating_extras_del AFTER DELETE ON rate.reviews
+ REFERENCING OLD TABLE AS changed_old
+ FOR EACH STATEMENT EXECUTE FUNCTION rate.reviews_refresh_rating_extras();
+
+DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_ins ON rate.review_flags;
+CREATE TRIGGER trg_review_flags_rating_extras_ins AFTER INSERT ON rate.review_flags
+ REFERENCING NEW TABLE AS changed_new
+ FOR EACH STATEMENT EXECUTE FUNCTION rate.review_flags_refresh_rating_extras();
+DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_upd ON rate.review_flags;
+CREATE TRIGGER trg_review_flags_rating_extras_upd AFTER UPDATE ON rate.review_flags
+ REFERENCING OLD TABLE AS changed_old NEW TABLE AS changed_new
+ FOR EACH STATEMENT EXECUTE FUNCTION rate.review_flags_refresh_rating_extras();
+DROP TRIGGER IF EXISTS trg_review_flags_rating_extras_del ON rate.review_flags;
+CREATE TRIGGER trg_review_flags_rating_extras_del AFTER DELETE ON rate.review_flags
+ REFERENCING OLD TABLE AS changed_old
+ FOR EACH STATEMENT EXECUTE FUNCTION rate.review_flags_refresh_rating_extras();
+
+DROP TRIGGER IF EXISTS trg_master_products_rating_extras ON rate.master_products;
+CREATE TRIGGER trg_master_products_rating_extras
+ AFTER UPDATE OF user_rating_weighted, authentic_review_count ON rate.master_products
+ FOR EACH ROW
+ WHEN ((OLD.user_rating_weighted, OLD.authentic_review_count)
+ IS DISTINCT FROM (NEW.user_rating_weighted, NEW.authentic_review_count))
+ EXECUTE FUNCTION rate.master_products_refresh_rating_extras();
+
+COMMIT;
+
+-- 8. Backfill, in its own transaction after the schema change commits, so the ALTER TABLE
+-- locks are already released and only the touched master rows are locked. Masters with no
+-- counted review already hold the right values (NULL, 0, {}) from the column defaults.
+-- Re-runnable; it writes only rows whose values differ. A catalog of many thousand reviewed
+-- masters can run it in id ranges instead.
+
+-- The CHECKs above were added NOT VALID (no scan under the ACCESS EXCLUSIVE lock); validating
+-- them here scans each table under a lock that lets reads and writes go on.
+BEGIN;
+ALTER TABLE rate.reviews VALIDATE CONSTRAINT reviews_rating_exact_range;
+ALTER TABLE rate.reviews VALIDATE CONSTRAINT reviews_verdict_length;
+ALTER TABLE rate.reviews VALIDATE CONSTRAINT reviews_criteria_version_positive;
+ALTER TABLE rate.reviews VALIDATE CONSTRAINT reviews_value_score_range;
+ALTER TABLE rate.master_products VALIDATE CONSTRAINT master_products_rating_score_range;
+ALTER TABLE rate.master_products VALIDATE CONSTRAINT master_products_rating_weight_sum_nonneg;
+SELECT rate.refresh_master_rating_extras(m.id)
+FROM rate.master_products m
+WHERE m.authentic_review_count > 0 OR m.user_rating_weighted > 0
+ORDER BY m.id;
+COMMIT;
diff --git a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts
index b437c999b..c97c40a21 100644
--- a/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts
+++ b/apps/importer/src/shopee/__tests__/open-core-intelligence.test.ts
@@ -714,6 +714,25 @@ async function runSuite() {
'Dây Apple Watch S/M 42mm',
'Dây Apple Watch Series 10 46mm tặng ốp',
'🔥 [Chính hãng] Ốp lưng iPhone 16 Pro Max',
+ // DOS/Rate#1539: a folio case stays a case; only HP's Folio line is a device.
+ 'Folio case for iPad Pro 11',
+ 'Folio 11 inch cho iPad Pro',
+ 'Folio iPad 10',
+ 'Folio 13 cho HP Pavilion',
+ 'Folio 13 HP sleeve chống sốc',
+ 'Folio 13 Samsung Galaxy Tab S9',
+ // Review of #1541: an accessory or fit word right after the brand, and a brand that is
+ // not right after the model.
+ 'Folio 13 HP op lung',
+ 'Folio 13 HP tui chong soc',
+ 'Folio 13 HP cường lực',
+ 'Folio 13 HP adapter 65W',
+ 'Folio 13 HP tuong thich',
+ 'Folio 12 iPad Pro HP',
+ // Codex round 2 on #1541.
+ 'Folio 13 HP sac 45W dau kim',
+ 'Folio 13 HP ban phim US thay the',
+ 'Folio 13 HP - sleeve da màu nâu',
];
for (const title of accessoryTitles1505) {
const accessory = normalizer.normalize(createDummyShopeeProduct(title));
@@ -734,9 +753,29 @@ async function runSuite() {
['Bút cảm ứng cao cấp chính hãng Apple Pencil Pro', 'apple:pencil-pro:unknown'],
// A gift that opens the title is not what it sells.
['Ốp lưng tặng kèm khi mua iPhone 16 Pro Max 256GB', 'apple:iphone-16-pro-max:256gb'],
+ // DOS/Rate#1539: HP's Folio laptops open with the line word and keep a key. The
+ // brand after the model stays in it, as for any laptop line typed that way.
+ ['Folio 13 HP Core i5 8GB 256GB', 'hp:folio-13-hp-core:256gb'],
+ ['[Chính hãng] Folio 13 HP i5 8GB', 'hp:folio-13-hp-i5:8gb'],
+ ['HP EliteBook Folio 1040 G3 i7 16GB', 'hp:elitebook-folio-1040-g3:16gb'],
] as const) {
assert.equal(keyOf(title), expectedKey, title);
}
+ // Review of #1541: promo words before the line, older "G1" models, and the laptop's own
+ // text later in the title ("bàn phím có đèn", "dùng cho văn phòng") keep the device.
+ for (const title of [
+ '[Hàng chính hãng] Folio 13 HP i5 8GB',
+ 'Combo Folio 13 HP i5 8GB',
+ 'Folio G1 HP Core m5 8GB',
+ 'Folio 1040 G3 HP i5 8GB',
+ 'Folio 13 (HP) i5 8GB',
+ 'Folio 13 HP Core i5 bàn phím có đèn',
+ 'Folio 13 HP i5 dùng cho văn phòng',
+ 'Folio 13 HP pin 8 tiếng',
+ 'Folio 13 HP cho sinh viên Core i5 4GB RAM SSD 128GB',
+ ]) {
+ assert.equal(normalizer.normalize(createDummyShopeeProduct(title)).keyable, true, title);
+ }
// DOS/Rate#1124: a size outside a band phrase is the case's; a band's size counts only
// when the title names no other.
const watchBandSizeKeys1124: Array<[string, string]> = [
diff --git a/apps/importer/src/shopee/product-normalizer.ts b/apps/importer/src/shopee/product-normalizer.ts
index 713209d4f..d5392bda9 100644
--- a/apps/importer/src/shopee/product-normalizer.ts
+++ b/apps/importer/src/shopee/product-normalizer.ts
@@ -411,6 +411,38 @@ const ACCESSORY_OPENER =
const PENCIL_FIT_WORD =
/(? {
+ const head = line.exec(title);
+ if (!head) return false;
+ // "Folio 13 HP - sleeve da" and "Folio 13 HP (sleeve)" read the word after the dash.
+ const rest = title.slice(head[0].length).replace(/^[\s\p{P}\p{S}]+/u, '');
+ return !ACCESSORY_OPENER.test(rest) && !LAPTOP_ACCESSORY_OPENER.test(rest);
+ });
+}
+
/**
* A pen sold as itself: "Bút cảm ứng (chính hãng) Apple Pencil Pro" is the Apple Pencil,
* its own product. Only the pen word and promo words may come before "Apple Pencil"; a
@@ -455,7 +487,13 @@ export function isAccessoryTitle(title: string): boolean {
// What the title sells, not a gift it opens with: "Ốp lưng tặng kèm khi mua iPhone 16 Pro
// Max" sells the iPhone (DOS/Rate#1407).
const opening = openingWords(ownProductPart(t));
- if (!ACCESSORY_OPENER.test(opening) || opensWithApplePencil(opening)) return false;
+ if (
+ !ACCESSORY_OPENER.test(opening) ||
+ opensWithApplePencil(opening) ||
+ opensWithDeviceLine(opening)
+ ) {
+ return false;
+ }
// The part before a gift stops at the "+", so the bundle is read on the whole title.
const whole = openingWords(t);
const plus = whole.indexOf('+');
diff --git a/apps/web/locales/cs.json b/apps/web/locales/cs.json
index f3188c6e0..afe70d077 100644
--- a/apps/web/locales/cs.json
+++ b/apps/web/locales/cs.json
@@ -124,6 +124,8 @@
}
},
"compare": {
+ "criteriaSourceExpert": "Hodnocení kritérií od expertů",
+ "criterionNoData": "Nedostatek dat",
"versusMetaTitle": "{nameA} vs {nameB}: 10 skupin parametrů a hodnocení | Rate.vn",
"versusMetaTitleShort": "{nameA} vs {nameB} | Rate.vn",
"versusMetaDescription": "Přímé porovnání {nameA} a {nameB}: hodnocení v 5 kritériích, 10 skupin parametrů a skutečné ceny v obchodech.",
@@ -134,6 +136,7 @@
"tieDeltaNote": "(stejné skóre při aktuálních vahách)",
"tie1v1Badge": "Remíza 1v1",
"versusAutoVerdictTie": "Automatický verdikt: oba modely mají stejné celkové skóre ({score}); rozhodněte podle ceny a parametrů, které potřebujete.",
+ "versusAutoVerdictNoScore": "Automatický verdikt: ani jeden model zatím nemá hodnocení; rozhodněte podle ceny a parametrů, které potřebujete.",
"versusVerdictWeights": "(Váhy: {weights}.)",
"compareMetaTitleWithNames": "Porovnání {names} - Hodnocení a 10 skupin parametrů | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
@@ -343,6 +346,8 @@
"quickToggleShortLimit": "Max. 4"
},
"leaderboard": {
+ "criteriaSourceExpert": "Hodnocení kritérií od expertů",
+ "criterionNoData": "Nedostatek dat",
"metaTitle": "Žebříček technologií | Rate.vn",
"metaDescription": "Nezávislý žebříček podle kategorií: hodnocení expertů a ověřených kupujících v 5 kritériích, bez reklam a affiliate odkazů.",
"kicker": "Nezávislý žebříček zařízení",
diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json
index e290e6683..1e56646d7 100644
--- a/apps/web/locales/en.json
+++ b/apps/web/locales/en.json
@@ -124,6 +124,8 @@
}
},
"compare": {
+ "criteriaSourceExpert": "Criterion scores from experts",
+ "criterionNoData": "Not enough data",
"versusMetaTitle": "{nameA} vs {nameB}: 10 spec groups and review scores | Rate.vn",
"versusMetaTitleShort": "{nameA} vs {nameB} | Rate.vn",
"versusMetaDescription": "Compare {nameA} and {nameB} head to head: review scores across 5 criteria, 10 spec groups and real store prices.",
@@ -134,6 +136,7 @@
"tieDeltaNote": "(same score with the current weights)",
"tie1v1Badge": "1v1 Tie",
"versusAutoVerdictTie": "Automatic verdict: the two models are level on overall score ({score}); choose on price and the specs you need.",
+ "versusAutoVerdictNoScore": "Automatic verdict: neither model has a rating yet; choose on price and the specs you need.",
"versusVerdictWeights": "(Weights: {weights}.)",
"compareMetaTitleWithNames": "Compare {names} - Ratings and 10 spec groups | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
@@ -343,6 +346,8 @@
"quickToggleShortLimit": "Max 4"
},
"leaderboard": {
+ "criteriaSourceExpert": "Criterion scores from experts",
+ "criterionNoData": "Not enough data",
"metaTitle": "Tech device leaderboard | Rate.vn",
"metaDescription": "Independent rankings by category: expert and verified buyer scores across 5 criteria, with no ads or affiliate bias.",
"kicker": "Independent device rankings",
diff --git a/apps/web/locales/vi.json b/apps/web/locales/vi.json
index fec693473..8fcb5d835 100644
--- a/apps/web/locales/vi.json
+++ b/apps/web/locales/vi.json
@@ -124,6 +124,8 @@
}
},
"compare": {
+ "criteriaSourceExpert": "Điểm tiêu chí do chuyên gia chấm",
+ "criterionNoData": "Chưa đủ dữ liệu",
"versusMetaTitle": "So sánh {nameA} và {nameB}: 10 nhóm thông số và điểm đánh giá | Rate.vn",
"versusMetaTitleShort": "{nameA} vs {nameB} | Rate.vn",
"versusMetaDescription": "So sánh trực tiếp {nameA} và {nameB}: điểm đánh giá theo 5 tiêu chí, 10 nhóm thông số kỹ thuật và giá thật trên các sàn.",
@@ -134,6 +136,7 @@
"tieDeltaNote": "(cùng điểm theo trọng số hiện tại)",
"tie1v1Badge": "Ngang điểm 1v1",
"versusAutoVerdictTie": "Kết luận tự động: hai mẫu ngang điểm tổng hợp ({score}), chọn theo giá và thông số bạn cần.",
+ "versusAutoVerdictNoScore": "Kết luận tự động: cả hai mẫu chưa có đánh giá, chọn theo giá và thông số bạn cần.",
"versusVerdictWeights": "(Theo trọng số: {weights}.)",
"compareMetaTitleWithNames": "So sánh {names} - Điểm đánh giá và 10 nhóm thông số | Rate.vn",
"compareMetaTitleWithNamesShort": "{names} | Rate.vn",
@@ -343,6 +346,8 @@
"quickToggleShortLimit": "Tối đa 4"
},
"leaderboard": {
+ "criteriaSourceExpert": "Điểm tiêu chí do chuyên gia chấm",
+ "criterionNoData": "Chưa đủ dữ liệu",
"metaTitle": "Bảng xếp hạng thiết bị công nghệ | Rate.vn",
"metaDescription": "Bảng xếp hạng độc lập theo danh mục: điểm chuyên gia và điểm người mua thật theo 5 tiêu chí, không bị chi phối bởi quảng cáo hay link affiliate.",
"kicker": "Bảng xếp hạng thiết bị độc lập",
diff --git a/apps/web/package.json b/apps/web/package.json
index 078da370f..4921f9508 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,7 +8,7 @@
"lint": "eslint . --max-warnings 0",
"preview": "next build && next start",
"start": "next start",
- "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-variant-links.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/rating-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/device-images.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/render-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/model-match.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/developers-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/plural-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/locale-format.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/paged-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/server-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/hero-products.test.ts",
+ "test": "pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/core-rate-architecture.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-model.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/server/rate/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/write-paths.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/spec-scope.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/app/api/v1/rate/__tests__/item-fields.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/logger.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/review-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/concurrency.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-profile-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/merchant-review-risk.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-directory.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/watchlist-sizes.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/item-compare.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-variant-links.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/compare-params.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/catalog-listing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/discount-verdict-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-detail-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-competitors.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/analyzer-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sold-count.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/content-lang.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/image-url.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/client-messages.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/seo.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/offer-plausibility.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-market-stats.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sitemap-lastmod.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-badge-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/leaderboard-criteria.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/rating-display.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/focus-trap.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/display-truth.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/spec-template.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/product-spec-rows.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/sale-state.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/csp.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/device-images.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/render-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/model-match.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/developers-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/plural-copy.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/locale-format.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/paged-reads.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/server-timing.test.ts && pnpm --filter @repo/crawlers exec tsx --tsconfig ../web/tsconfig.test.json ../web/src/lib/__tests__/hero-products.test.ts",
"typecheck": "tsc --noEmit"
},
"browserslist": [
diff --git a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
index 7ee4e5a3f..ca8039bae 100644
--- a/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
+++ b/apps/web/src/app/[locale]/smartphones/[slug]/page.tsx
@@ -1525,7 +1525,7 @@ export async function ProductDetailView({
maxPriceVnd={marketStats.maxPrice}
userScore5={lbScored.userScore5}
expertScore10={lbScored.expertScore10}
- valueCriterionScore5={lbScored.criteria.value}
+ valueCriterionScore5={lbScored.criteria.value ?? 0}
radarSignal={radarSignal}
/>
);
diff --git a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
index 84993d111..1a05d83ef 100644
--- a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
+++ b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
@@ -883,7 +883,20 @@ async function testSpecMatchesBodies() {
get(`/api/v1/rate/versus?pair=${PHONE.slug}-vs-${PHONE_2.slug}`)
);
assert.equal(vs.status, 200);
- expectPaths('/api/v1/rate/versus', await vs.json(), [
+ const vsBody = await vs.json();
+ // The tie and the winner read the /10 scores as shown, at one decimal
+ // (#1545): a tie only when they are equal, else the higher one wins.
+ const vsA10 = vsBody.product_a.custom_weighted_score_10;
+ const vsB10 = vsBody.product_b.custom_weighted_score_10;
+ assert.equal(vsBody.is_tie, vsA10 === vsB10);
+ if (!vsBody.is_tie) {
+ assert.equal(vsBody.overall_winner.side, vsA10 > vsB10 ? 'A' : 'B');
+ assert.equal(
+ vsBody.overall_winner.margin_10,
+ Number(Math.abs(vsA10 - vsB10).toFixed(1))
+ );
+ }
+ expectPaths('/api/v1/rate/versus', vsBody, [
'is_tie',
'versus_pair',
...under('deal_radar.productA', radar),
diff --git a/apps/web/src/app/api/v1/rate/versus/route.ts b/apps/web/src/app/api/v1/rate/versus/route.ts
index 76aeba948..c11b8df90 100644
--- a/apps/web/src/app/api/v1/rate/versus/route.ts
+++ b/apps/web/src/app/api/v1/rate/versus/route.ts
@@ -7,10 +7,14 @@ import {
getMasterProductOffersBatch,
} from '@/server/rate';
import { radarExportFields } from '@/lib/deal-radar';
+import { versusApiWinner } from '@/lib/versus-verdict';
import {
comparePositiveMinPrices,
+ leaderboardCriteriaForCategory,
scoreMasterProductForLeaderboard,
- type LeaderboardCriteriaBreakdown,
+ sharedScoredCriteria,
+ weightedCriteriaScore5,
+ type LeaderboardCriteriaWeights,
} from '@/lib/leaderboard-engine';
import {
PUBLIC_API_CORS_HEADERS,
@@ -33,7 +37,7 @@ export async function OPTIONS() {
interface VersusPersonaConfig {
id: string;
- weights: LeaderboardCriteriaBreakdown;
+ weights: LeaderboardCriteriaWeights;
}
const VERSUS_PERSONAS: VersusPersonaConfig[] = [
@@ -149,14 +153,6 @@ const VERSUS_PERSONAS: VersusPersonaConfig[] = [
},
];
-const CRITERIA_KEYS: Array