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 @@ + + + + + + + Sao theo tiêu chí và ưu nhược + + + +
+ Đề 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

+ + +

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 reviewForm 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ẩmcomputeJReviewsCriteriaScores 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ổngTrang 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ố reviewTrọ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ượcAI 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 íchCó, 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ùngChư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àngTiêu chí (trọng số %)
Điện thoạiHiệ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ảngMà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
LaptopHiệ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 minhTheo 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 ngheChấ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
+ + +

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)

+ + +

2.4 Giải quyết #1470 (một con số điểm)

+ + +

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 đổiCó sẵn hay mớiMigration
rate.reviews.criteria_ratingsGiữ dạng {khoá: sao}, thêm khoá mới theo danh sách ngành hàng.Có sẵnKhô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ớiverdict 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ớiCó, 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ớiCó, 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/consCó, DOS-Me
rate.master_products cột mớirating_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ớiCó, DOS-Me; hàm làm mới ghi các cột này
rate.submit_community_reviewBả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ửaCó, 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

+
    +
  1. 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".
  2. +
  3. 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.
  4. +
  5. Ư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').
  6. +
  7. Ư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.
  8. +
  9. Nội dung dài và ảnh (không bắt buộc, như hiện nay).
  10. +
+

4.2 Kiểm tra (ở cả API và RPC, không chỉ ở trình duyệt)

+ +

4.3 Review lấy từ nguồn 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

+
    +
  1. 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.
  2. +
  3. 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".
  4. +
  5. 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.
  6. +
  7. Đế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.
  8. +
  9. 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.
  10. +
  11. 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.
  12. +
+

5.2 AI làm phần còn lại

+ +

5.3 Độ mới

+ + +

6. Điểm giá trị review

+

6.1 Thành phần (thang 100)

+
+ + + + + + +
Thành phầnTối đaCách tính
Đầy đủ35Chấ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ể20Theo 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.
Đã mua15Có xác nhận đã mua (sàn, hoặc sau này hoá đơn). Chưa xác nhận: 0.
Vote hữu ích20Từ 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ết10Trụ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ũ
+ + +

6.3 Dùng điểm giá trị ở đâu

+ + +

6.4 Rủi ro lạm dụng và cách chặn

+
+ + + + + + + + +
Rủi roChặ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ẩmSo 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ơnKhô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ếtTrụ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
+
Hiệu năng 20%
4.7
+
Camera 20%
4.5
+
Pin và sạc 20%
4.0
+
Màn hình 15%
4.6
+
Thiết kế và độ bền 10%
Chưa đủ
+
Đáng tiền 15%
3.8
+
+
Ư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
+
👍 Hữu ích (24)👎 (1)Báo cáo
+
+
+
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ạnViệcCỡCần database?
P0Sử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.SKhông
P1Danh 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.MKhông
P2Migration 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.LCó, JOY áp dụng
P3Form 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.MSau P2
P4Một con số điểm mọi nơi (#1470): mọi trang và API đọc rating_score.SSau P2
P5Gom ư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 LSau 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.MSau 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.LCó
+

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

+ +
+ + 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 = [ - 'screen', - 'performance', - 'camera', - 'battery', - 'value', -]; - export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); @@ -226,7 +222,7 @@ export async function GET(req: NextRequest) { return Number.isFinite(num) ? Math.max(0, Math.min(100, num)) : fallback; }; - const sanitizedWeights: LeaderboardCriteriaBreakdown = { + const sanitizedWeights: LeaderboardCriteriaWeights = { screen: parseWeight('w_screen', matchedPersona.weights.screen), performance: parseWeight( 'w_performance', @@ -244,7 +240,7 @@ export async function GET(req: NextRequest) { sanitizedWeights.battery + sanitizedWeights.value; - const normalizedWeights: LeaderboardCriteriaBreakdown = + const normalizedWeights: LeaderboardCriteriaWeights = weightTotal > 0 ? { screen: Number( @@ -271,33 +267,26 @@ export async function GET(req: NextRequest) { value: 20, }; - const computeWeighted5 = (c: LeaderboardCriteriaBreakdown): number => { - const wSum = - normalizedWeights.screen + - normalizedWeights.performance + - normalizedWeights.camera + - normalizedWeights.battery + - normalizedWeights.value; - if (wSum <= 0) { - return Number( - ( - (c.screen + c.performance + c.camera + c.battery + c.value) / - 5 - ).toFixed(2) - ); - } - const weighted = - (c.screen * normalizedWeights.screen + - c.performance * normalizedWeights.performance + - c.camera * normalizedWeights.camera + - c.battery * normalizedWeights.battery + - c.value * normalizedWeights.value) / - wSum; - return Number(weighted.toFixed(2)); - }; - - const customScoreA5 = computeWeighted5(scoreA.criteria); - const customScoreB5 = computeWeighted5(scoreB.criteria); + // The weights apply to the criteria both products have a real score + // for; with none in common, both sides use their main score, and no + // criterion counts as won or lost (DOS/Rate#1143). + const comparedCriteria = sharedScoredCriteria( + scoreA.criteria, + scoreB.criteria + ); + const weightedA5 = weightedCriteriaScore5( + scoreA.criteria, + normalizedWeights, + comparedCriteria + ); + const weightedB5 = weightedCriteriaScore5( + scoreB.criteria, + normalizedWeights, + comparedCriteria + ); + const byCriteria = weightedA5 != null && weightedB5 != null; + const customScoreA5 = byCriteria ? weightedA5 : scoreA.combinedScore5; + const customScoreB5 = byCriteria ? weightedB5 : scoreB.combinedScore5; const customScoreA10 = Number((customScoreA5 * 2).toFixed(1)); const customScoreB10 = Number((customScoreB5 * 2).toFixed(1)); const priceSide = comparePositiveMinPrices( @@ -305,18 +294,16 @@ export async function GET(req: NextRequest) { scoreB.minPrice ); - const winnerSide: 'A' | 'B' = - customScoreA5 > customScoreB5 - ? 'A' - : customScoreB5 > customScoreA5 - ? 'B' - : scoreA.combinedScore5 > scoreB.combinedScore5 - ? 'A' - : scoreB.combinedScore5 > scoreA.combinedScore5 - ? 'B' - : priceSide === 'B' - ? 'B' - : 'A'; + // The winner and the tie read the scores as the response shows them, at + // one decimal; the composite and then the price only break a tie + // (DOS/Rate#1545, D3). + const { tie: isTie, side: winnerSide } = versusApiWinner( + customScoreA10, + customScoreB10, + scoreA.combinedScore5, + scoreB.combinedScore5, + priceSide + ); const winnerScore = winnerSide === 'A' ? scoreA : scoreB; const winnerCustom10 = winnerSide === 'A' ? customScoreA10 : customScoreB10; @@ -324,11 +311,16 @@ export async function GET(req: NextRequest) { Math.abs(customScoreA10 - customScoreB10).toFixed(1) ); - const criteriaHeadToHead = CRITERIA_KEYS.map((key) => { + // A criterion without a real score on both sides has no winner: its + // scores, delta and winner are null (DOS/Rate#1143). + const criteriaHeadToHead = leaderboardCriteriaForCategory( + scoreA.categorySlug + ).map((key) => { const a5 = scoreA.criteria[key]; const b5 = scoreB.criteria[key]; - const a10 = Number((a5 * 2).toFixed(1)); - const b10 = Number((b5 * 2).toFixed(1)); + const a10 = a5 != null ? Number((a5 * 2).toFixed(1)) : null; + const b10 = b5 != null ? Number((b5 * 2).toFixed(1)) : null; + const compared = a10 != null && b10 != null; return { criterion: key, weight_pct: normalizedWeights[key], @@ -336,8 +328,14 @@ export async function GET(req: NextRequest) { score_a_10: a10, score_b_5: b5, score_b_10: b10, - delta_10: Number((a10 - b10).toFixed(1)), - winner_slug: a5 > b5 ? scoreA.slug : b5 > a5 ? scoreB.slug : 'tie', + delta_10: compared ? Number((a10 - b10).toFixed(1)) : null, + winner_slug: !compared + ? null + : a10 > b10 + ? scoreA.slug + : b10 > a10 + ? scoreB.slug + : 'tie', }; }); @@ -395,15 +393,17 @@ export async function GET(req: NextRequest) { compare_matrix_url: `/compare?cat=${category}&slugs=${scoreA.slug},${scoreB.slug}`, persona: matchedPersona.id, weights: normalizedWeights, + // What the custom weighted scores weigh: the criteria both products + // have a real score for, or their main scores when they share none. + custom_weighted_basis: byCriteria ? 'criteria' : 'combined', + compared_criteria: comparedCriteria, deal_radar: { productA: radarSignalA, productB: radarSignalB, }, - // Level at two decimals: overall_winner is then only the tie-break - // (DOS/Rate#1209). - is_tie: - customScoreA5.toFixed(2) === customScoreB5.toFixed(2) && - scoreA.combinedScore5.toFixed(2) === scoreB.combinedScore5.toFixed(2), + // Level at the shown precision: overall_winner is then only the + // tie-break (DOS/Rate#1209, DOS/Rate#1545). + is_tie: isTie, overall_winner: { side: winnerSide, slug: winnerScore.slug, diff --git a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx index 9bf985c08..ca014f74b 100644 --- a/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx +++ b/apps/web/src/components/compare/VersusInteractiveScoreboard.tsx @@ -8,6 +8,11 @@ import { comparePositiveMinPrices, type ScoredLeaderboardItem, } from '@/lib/rate-domain'; +import { + leaderboardCriteriaForCategory, + sharedScoredCriteria, + weightedCriteriaScore5, +} from '@/lib/leaderboard-engine'; import { getRadarSignal, RADAR_CSV_HEADERS, @@ -84,6 +89,13 @@ export function sanitizeVersusScore5(value: unknown): number { return Number(Math.min(5, num).toFixed(2)); } +/** A criterion score out of 5, or null when there is no real one. */ +export function versusCriterionScore5(value: unknown): number | null { + if (value == null) return null; + const score = sanitizeVersusScore5(value); + return score > 0 ? score : null; +} + export function sanitizeVersusExportSlug(value: unknown): string { if (typeof value !== 'string') return 'device'; const cleaned = value @@ -123,25 +135,32 @@ export function computeVersusWeightedResult( const wVal = sanitizeWeight(weights.value); const rawSumW = wScreen + wPerf + wCam + wBat + wVal; + const shared = sharedScoredCriteria(scoreA.criteria, scoreB.criteria); const calcWeighted = (item: ScoredVersusItem): number => { - const baseScore5 = sanitizeVersusScore5(item.combinedScore5); + // The product's rating as every page shows it (DOS/Rate#1470); a model + // with no buyer rating yet keeps its composite (expert or criteria). + const baseScore5 = sanitizeVersusScore5( + item.userScore5 > 0 ? item.userScore5 : item.combinedScore5 + ); if (persona === 'default') { return baseScore5; } - const cScreen = sanitizeVersusScore5(item.criteria?.screen); - const cPerf = sanitizeVersusScore5(item.criteria?.performance); - const cCam = sanitizeVersusScore5(item.criteria?.camera); - const cBat = sanitizeVersusScore5(item.criteria?.battery); - const cVal = sanitizeVersusScore5(item.criteria?.value); - const wAvg = + // Only the criteria both models have a real score for are weighed; with + // none in common the weights change nothing (DOS/Rate#1143). + const wAvg = weightedCriteriaScore5( + item.criteria, rawSumW > 0 - ? (cScreen * wScreen + - cPerf * wPerf + - cCam * wCam + - cBat * wBat + - cVal * wVal) / - rawSumW - : (cScreen + cPerf + cCam + cBat + cVal) / 5; + ? { + screen: wScreen, + performance: wPerf, + camera: wCam, + battery: wBat, + value: wVal, + } + : { screen: 1, performance: 1, camera: 1, battery: 1, value: 1 }, + shared + ); + if (wAvg == null) return baseScore5; return sanitizeVersusScore5(baseScore5 * 0.35 + wAvg * 0.65); }; @@ -163,7 +182,10 @@ export function computeVersusWeightedResult( return { finalA, finalB, - delta: Math.abs(finalA - finalB).toFixed(2), + // The gap between the scores as shown, at one decimal. + delta: Math.abs( + Number(finalA.toFixed(1)) - Number(finalB.toFixed(1)) + ).toFixed(1), winner, // Level at the shown precision: no lead for either (DOS/Rate#1209). tie: isVersusScoreTie(finalA, finalB), @@ -180,6 +202,10 @@ export default function VersusInteractiveScoreboard({ const scoreA = swapped ? secondScore : firstScore; const scoreB = swapped ? firstScore : secondScore; const t = useTranslations('compare'); + const tStars = useTranslations('starRating'); + // A side with no score reads like its stars, not "0.0" (DOS/Rate#1545). + const summaryScore = (score: number): string => + score > 0 ? formatDecimal(score, locale, 1) : tStars('empty'); const locale = useLocale(); const useCaseLabelId = useId(); const slidersPanelId = useId(); @@ -220,38 +246,35 @@ export default function VersusInteractiveScoreboard({ [scoreA, scoreB, weights, persona] ); - const criteriaRows = [ - { - key: 'screen' as const, - label: t('criteriaScreen'), - aVal: sanitizeVersusScore5(scoreA.criteria?.screen), - bVal: sanitizeVersusScore5(scoreB.criteria?.screen), - }, - { - key: 'performance' as const, - label: t('criteriaPerformance'), - aVal: sanitizeVersusScore5(scoreA.criteria?.performance), - bVal: sanitizeVersusScore5(scoreB.criteria?.performance), - }, - { - key: 'camera' as const, - label: t('criteriaCamera'), - aVal: sanitizeVersusScore5(scoreA.criteria?.camera), - bVal: sanitizeVersusScore5(scoreB.criteria?.camera), - }, - { - key: 'battery' as const, - label: t('criteriaBattery'), - aVal: sanitizeVersusScore5(scoreA.criteria?.battery), - bVal: sanitizeVersusScore5(scoreB.criteria?.battery), - }, - { - key: 'value' as const, - label: t('criteriaValue'), - aVal: sanitizeVersusScore5(scoreA.criteria?.value), - bVal: sanitizeVersusScore5(scoreB.criteria?.value), - }, - ]; + // The criteria of the pair's category (no screen for earbuds); a side + // without a real score reads "Chưa đủ dữ liệu", and a row wins only when + // both sides have one (DOS/Rate#1143). + const criterionLabels = { + screen: t('criteriaScreen'), + performance: t('criteriaPerformance'), + camera: t('criteriaCamera'), + battery: t('criteriaBattery'), + value: t('criteriaValue'), + }; + const noData = t('criterionNoData'); + const criteriaRows = leaderboardCriteriaForCategory(scoreA.categorySlug).map( + (key) => { + const aVal = versusCriterionScore5(scoreA.criteria?.[key]); + const bVal = versusCriterionScore5(scoreB.criteria?.[key]); + const compared = aVal != null && bVal != null; + return { + key, + label: criterionLabels[key], + aVal, + bVal, + aWins: compared && aVal > bVal, + bWins: compared && bVal > aVal, + compared, + }; + } + ); + const criterionCell = (v: number | null) => + v != null ? `${v.toFixed(2)}/5.0` : noData; const handleCopy1v1Markdown = async () => { if (typeof window === 'undefined') return; @@ -266,8 +289,8 @@ export default function VersusInteractiveScoreboard({ const safePriceAdv = sanitizeVersusMarkdownCell(priceAdvantageLabel); const leadScore = weightedResult.finalA >= weightedResult.finalB - ? weightedResult.finalA.toFixed(2) - : weightedResult.finalB.toFixed(2); + ? weightedResult.finalA.toFixed(1) + : weightedResult.finalB.toFixed(1); const lines = [ `### ${t('md1v1Heading', { nameA: safeNameA, nameB: safeNameB })}`, weightedResult.tie @@ -277,15 +300,16 @@ export default function VersusInteractiveScoreboard({ '', `| ${t('mdColCriterion')} | ${safeNameA} | ${safeNameB} | ${t('mdColAdvantage')} |`, `| --- | --- | --- | --- |`, - `| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(2)}/5.0** | **${weightedResult.finalB.toFixed(2)}/5.0** | **${weightedResult.tie ? sanitizeVersusMarkdownCell(t('mdRowTie')) : safeWinnerName}** |`, + `| **${t('mdRowWeightedScore')}** | **${weightedResult.finalA.toFixed(1)}/5.0** | **${weightedResult.finalB.toFixed(1)}/5.0** | **${weightedResult.tie ? sanitizeVersusMarkdownCell(t('mdRowTie')) : safeWinnerName}** |`, ...criteriaRows.map((r) => { - const winName = - r.aVal > r.bVal + const winName = !r.compared + ? '-' + : r.aWins ? safeNameA - : r.bVal > r.aVal + : r.bWins ? safeNameB : sanitizeVersusMarkdownCell(t('mdRowTie')); - return `| ${sanitizeVersusMarkdownCell(r.label)} | ${r.aVal.toFixed(2)}/5.0 | ${r.bVal.toFixed(2)}/5.0 | ${winName} |`; + return `| ${sanitizeVersusMarkdownCell(r.label)} | ${sanitizeVersusMarkdownCell(criterionCell(r.aVal))} | ${sanitizeVersusMarkdownCell(criterionCell(r.bVal))} | ${winName} |`; }), `| **${t('mdRowMinPrice')}** | ${scoreA.minPrice > 0 ? `${scoreA.minPrice.toLocaleString('vi-VN')} ₫` : '-'} | ${scoreB.minPrice > 0 ? `${scoreB.minPrice.toLocaleString('vi-VN')} ₫` : '-'} | ${safePriceAdv} |`, `| **${t('mdRowDealRadar')}** | ${radarMarkdownCell(sigA, locale)} | ${radarMarkdownCell(sigB, locale)} | ${sigA.verdict === 'genuine_deal' ? safeNameA : sigB.verdict === 'genuine_deal' ? safeNameB : '-'} |`, @@ -315,20 +339,22 @@ export default function VersusInteractiveScoreboard({ [ t('csvRowWeightedScore5'), 100, - weightedResult.finalA.toFixed(2), - weightedResult.finalB.toFixed(2), + weightedResult.finalA.toFixed(1), + weightedResult.finalB.toFixed(1), weightedResult.tie ? t('mdRowTie') : weightedResult.winner.name, ], ...criteriaRows.map((r) => [ r.label, weights[r.key], - r.aVal.toFixed(2), - r.bVal.toFixed(2), - r.aVal > r.bVal - ? scoreA.name - : r.bVal > r.aVal - ? scoreB.name - : t('mdRowTie'), + r.aVal != null ? r.aVal.toFixed(2) : '', + r.bVal != null ? r.bVal.toFixed(2) : '', + !r.compared + ? '' + : r.aWins + ? scoreA.name + : r.bWins + ? scoreB.name + : t('mdRowTie'), ]), [ t('csvRowExpertScore10'), @@ -410,7 +436,7 @@ export default function VersusInteractiveScoreboard({ slug: safeSlugA, name: scoreA.name, brand: scoreA.brand, - weightedScore5: weightedResult.finalA, + weightedScore5: Number(weightedResult.finalA.toFixed(1)), expertScore10: scoreA.expertScore10, minPriceVnd: scoreA.minPrice, dealRadar: radarExportFields(sigA), @@ -419,7 +445,7 @@ export default function VersusInteractiveScoreboard({ slug: safeSlugB, name: scoreB.name, brand: scoreB.brand, - weightedScore5: weightedResult.finalB, + weightedScore5: Number(weightedResult.finalB.toFixed(1)), expertScore10: scoreB.expertScore10, minPriceVnd: scoreB.minPrice, dealRadar: radarExportFields(sigB), @@ -472,9 +498,11 @@ export default function VersusInteractiveScoreboard({ aria-live="polite" > {/* Narrows the values to each key's own placeholders. */} - {verdict.key === 'versusAutoVerdictTie' - ? t(verdict.key, verdict.values) - : t(verdict.key, verdict.values)} + {verdict.key === 'versusAutoVerdictNoScore' + ? t(verdict.key) + : verdict.key === 'versusAutoVerdictTie' + ? t(verdict.key, verdict.values) + : t(verdict.key, verdict.values)} {verdict.weightsLabel && ` ${t('versusVerdictWeights', { weights: verdict.weightsLabel })}`}

@@ -635,7 +663,7 @@ export default function VersusInteractiveScoreboard({ delta: formatDecimal( Number(weightedResult.delta), locale, - 2 + 1 ), })} @@ -643,8 +671,8 @@ export default function VersusInteractiveScoreboard({ )}
- {scoreA.brand}: {formatDecimal(weightedResult.finalA, locale, 2)} •{' '} - {scoreB.brand}: {formatDecimal(weightedResult.finalB, locale, 2)} + {scoreA.brand}: {summaryScore(weightedResult.finalA)} •{' '} + {scoreB.brand}: {summaryScore(weightedResult.finalB)}
@@ -658,7 +686,7 @@ export default function VersusInteractiveScoreboard({ {scoreA.name}
- + {scoreA.expertScore10 != null ? `Lab ${formatDecimal(scoreA.expertScore10, locale, 1)}/10` @@ -679,7 +707,7 @@ export default function VersusInteractiveScoreboard({ {scoreB.name}
- + {scoreB.expertScore10 != null ? `Lab ${formatDecimal(scoreB.expertScore10, locale, 1)}/10` @@ -690,11 +718,13 @@ export default function VersusInteractiveScoreboard({
{/* 5-Criteria Dual Progress Bar Comparison Rows */} +
+ {t('criteriaSourceExpert')} +
{criteriaRows.map((row) => { - const aWins = row.aVal > row.bVal; - const bWins = row.bVal > row.aVal; - const aPct = Math.min(100, Math.round((row.aVal / 5) * 100)); - const bPct = Math.min(100, Math.round((row.bVal / 5) * 100)); + const { aWins, bWins } = row; + const aPct = Math.min(100, Math.round(((row.aVal ?? 0) / 5) * 100)); + const bPct = Math.min(100, Math.round(((row.bVal ?? 0) / 5) * 100)); return (
- {formatDecimal(row.aVal, locale, 2)}/5 {aWins ? '✓' : ''} + {row.aVal != null + ? `${formatDecimal(row.aVal, locale, 2)}/5` + : noData}{' '} + {aWins ? '✓' : ''}
@@ -738,7 +774,10 @@ export default function VersusInteractiveScoreboard({ className={`progress-bar ${ bWins ? 'bg-success' : 'bg-secondary' }`} - style={{ width: `${bPct}%` }} + style={{ + width: `${bPct}%`, + visibility: row.bVal != null ? undefined : 'hidden', + }} />
{bWins ? '✓ ' : ''} - {formatDecimal(row.bVal, locale, 2)}/5 + {row.bVal != null + ? `${formatDecimal(row.bVal, locale, 2)}/5` + : noData} diff --git a/apps/web/src/components/deals/DealFinderWorkspace.tsx b/apps/web/src/components/deals/DealFinderWorkspace.tsx index fe486a201..bb97f00de 100644 --- a/apps/web/src/components/deals/DealFinderWorkspace.tsx +++ b/apps/web/src/components/deals/DealFinderWorkspace.tsx @@ -124,23 +124,21 @@ export function rankProductsForDealFinder( const item = scoreMasterProductForLeaderboard(p); const c = item.criteria; + // A persona leans on one criterion only where the product has a real + // score for it; otherwise the combined score stands (DOS/Rate#1143). + const lean = (score: number | null, share: number) => + score != null && score > 0 + ? Number((item.combinedScore5 * (1 - share) + score * share).toFixed(2)) + : item.combinedScore5; let personaScore5 = item.combinedScore5; if (persona === 'value_hunter') { - personaScore5 = Number( - (item.combinedScore5 * 0.45 + c.value * 0.55).toFixed(2) - ); + personaScore5 = lean(c.value, 0.55); } else if (persona === 'photography') { - personaScore5 = Number( - (item.combinedScore5 * 0.4 + c.camera * 0.6).toFixed(2) - ); + personaScore5 = lean(c.camera, 0.6); } else if (persona === 'gaming') { - personaScore5 = Number( - (item.combinedScore5 * 0.4 + c.performance * 0.6).toFixed(2) - ); + personaScore5 = lean(c.performance, 0.6); } else if (persona === 'endurance') { - personaScore5 = Number( - (item.combinedScore5 * 0.4 + c.battery * 0.6).toFixed(2) - ); + personaScore5 = lean(c.battery, 0.6); } const rawMaxP = Number(p.max_price); diff --git a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx index a706ba3e8..74a75f197 100644 --- a/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx +++ b/apps/web/src/components/leaderboard/LeaderboardExplorer.tsx @@ -24,6 +24,12 @@ import { type LeaderboardPriceSegment, type MasterProduct, } from '@/lib/rate-domain'; +import { + LEADERBOARD_SCORE_CRITERIA, + leaderboardCriteriaForCategory, + weightedCriteriaScore5, + type LeaderboardCriteriaBreakdown, +} from '@/lib/leaderboard-engine'; import { getRadarSignal, radarCsvCells, @@ -140,16 +146,23 @@ interface RankedProductEntry { combinedScore5: number; authenticCount: number; ignoredCount: number; - criteria: { - screen: number; - performance: number; - camera: number; - battery: number; - value: number; - }; + criteria: LeaderboardCriteriaBreakdown; activeScore5: number; } +/** The criterion a category can rank by: combined when it lacks it. */ +function criterionForCategory( + cat: CanonicalCategorySlug, + crit: LeaderboardCriterionKey +): LeaderboardCriterionKey { + return crit === 'combined' || + crit === 'user' || + crit === 'expert' || + leaderboardCriteriaForCategory(cat).includes(crit) + ? crit + : 'combined'; +} + type TranslateFn = ReturnType>; function buildCategoryTabs(t: TranslateFn): ReadonlyArray<{ @@ -478,12 +491,13 @@ export default function LeaderboardExplorer({ return true; }); - const totalWeight = - customWeights.screen + - customWeights.performance + - customWeights.camera + - customWeights.battery + - customWeights.value; + // Only the weights of the category's own criteria count: a screen + // weight left over from phones does not empty the earbuds list. + const weightedCriteria = leaderboardCriteriaForCategory(activeCategory); + const totalWeight = weightedCriteria.reduce( + (sum, c) => sum + customWeights[c], + 0 + ); const scored = filtered .map((item) => { @@ -492,20 +506,22 @@ export default function LeaderboardExplorer({ activeCriterion, filteredReviewCounts ); - if (activePersona !== 'none' && totalWeight > 0) { - const weighted5 = Number( - ( - (base.criteria.screen * customWeights.screen + - base.criteria.performance * customWeights.performance + - base.criteria.camera * customWeights.camera + - base.criteria.battery * customWeights.battery + - base.criteria.value * customWeights.value) / - totalWeight - ).toFixed(2) + // A product the active tab has no score for stays out, weights or + // not; one with no real score on the weighted criteria drops out of + // the weighted ranking, like a criterion tab (DOS/Rate#1143). + if ( + activePersona !== 'none' && + totalWeight > 0 && + base.activeScore5 > 0 + ) { + const weighted5 = weightedCriteriaScore5( + base.criteria, + customWeights, + weightedCriteria ); return { ...base, - activeScore5: weighted5, + activeScore5: weighted5 ?? 0, }; } return base; @@ -544,6 +560,7 @@ export default function LeaderboardExplorer({ return a.product.slug.localeCompare(b.product.slug); }); }, [ + activeCategory, categoryProducts, selectedBrand, priceSegment, @@ -664,13 +681,10 @@ export default function LeaderboardExplorer({ entry.activeScore5.toFixed(2), entry.userScore5 > 0 ? entry.userScore5.toFixed(1) : '', entry.expertScore10 != null ? entry.expertScore10.toFixed(1) : '', - entry.criteria.screen > 0 ? entry.criteria.screen.toFixed(1) : '', - entry.criteria.performance > 0 - ? entry.criteria.performance.toFixed(1) - : '', - entry.criteria.camera > 0 ? entry.criteria.camera.toFixed(1) : '', - entry.criteria.battery > 0 ? entry.criteria.battery.toFixed(1) : '', - entry.criteria.value > 0 ? entry.criteria.value.toFixed(1) : '', + ...LEADERBOARD_SCORE_CRITERIA.map((c) => { + const score = entry.criteria[c]; + return score != null && score > 0 ? score.toFixed(1) : ''; + }), String(sanitizeLeaderboardPriceVnd(entry.product.min_price)), ...radarCsvCells(radar), ]; @@ -729,9 +743,11 @@ export default function LeaderboardExplorer({ }; const handleSelectCategory = (cat: CanonicalCategorySlug) => { + const crit = criterionForCategory(cat, activeCriterion); setActiveCategory(cat); + setActiveCriterion(crit); setSelectedBrand('all'); - replaceTabUrl(cat, activeCriterion); + replaceTabUrl(cat, crit); }; const handleSelectCriterion = (crit: LeaderboardCriterionKey) => { @@ -749,8 +765,17 @@ export default function LeaderboardExplorer({ const applyUrlCriterion = useCallback((crit: LeaderboardCriterionKey) => { setActiveCriterion(crit); }, []); - - const criterionOptions: Array<{ + // A URL such as ?cat=earbuds&criterion=screen opens on the combined tab. + useEffect(() => { + const crit = criterionForCategory(activeCategory, activeCriterion); + if (crit !== activeCriterion) setActiveCriterion(crit); + }, [activeCategory, activeCriterion]); + + // The criteria the active category has: no screen tab for earbuds + // (DOS/Rate#1143). + const categoryCriteria: readonly LeaderboardCriterionKey[] = + leaderboardCriteriaForCategory(activeCategory); + const allCriterionOptions: Array<{ key: LeaderboardCriterionKey; label: string; icon: React.ReactNode; @@ -796,6 +821,13 @@ export default function LeaderboardExplorer({ icon: , }, ]; + const criterionOptions = allCriterionOptions.filter( + (opt) => + opt.key === 'combined' || + opt.key === 'user' || + opt.key === 'expert' || + categoryCriteria.includes(opt.key) + ); return (
@@ -1016,28 +1048,30 @@ export default function LeaderboardExplorer({ { key: 'battery', label: copy.critLabels.battery }, { key: 'value', label: copy.critLabels.value }, ] as const - ).map((slider) => ( -
-
- {slider.label} - - {formatPercent(customWeights[slider.key], locale)} - + ) + .filter((slider) => categoryCriteria.includes(slider.key)) + .map((slider) => ( +
+
+ {slider.label} + + {formatPercent(customWeights[slider.key], locale)} + +
+ + updateSingleWeight(slider.key, Number(e.target.value)) + } + className="form-range" + aria-label={slider.label} + />
- - updateSingleWeight(slider.key, Number(e.target.value)) - } - className="form-range" - aria-label={slider.label} - /> -
- ))} + ))}
)} @@ -1326,6 +1360,12 @@ export default function LeaderboardExplorer({ className="rounded-3 mb-3 p-2 px-3" style={{ background: 'var(--rate-glass-bg)' }} > +
+ {t('criteriaSourceExpert')} +
{( [ { key: 'screen', label: copy.critLabels.screen }, @@ -1337,35 +1377,41 @@ export default function LeaderboardExplorer({ { key: 'battery', label: copy.critLabels.battery }, { key: 'value', label: copy.critLabels.value }, ] as const - ).map((c) => { - const val = entry.criteria[c.key]; - const pct = sanitizeLeaderboardBarPercentage(val); - return ( -
-
- {c.label} - - {formatDecimal(val, locale, 2)} - -
-
+ ) + .filter((c) => categoryCriteria.includes(c.key)) + .map((c) => { + const val = entry.criteria[c.key]; + const pct = sanitizeLeaderboardBarPercentage(val); + return ( +
+ className="d-flex justify-content-between text-white-50" + style={{ fontSize: '0.8125rem' }} + > + {c.label} + + {val != null + ? formatDecimal(val, locale, 2) + : t('criterionNoData')} + +
+ {val != null && ( +
+
+
+ )}
-
- ); - })} + ); + })}
@@ -1663,31 +1709,37 @@ export default function LeaderboardExplorer({ { key: 'battery', short: t('shortBattery') }, { key: 'value', short: t('shortValue') }, ] as const - ).map((c) => ( -
- categoryCriteria.includes(c.key)) + .map((c) => ( +
- {c.short}: - - - {entry.criteria[c.key] > 0 - ? formatDecimal(entry.criteria[c.key], locale, 2) - : t('unratedShort')} - -
- ))} + + {c.short}: + + + {(entry.criteria[c.key] ?? 0) > 0 + ? formatDecimal( + entry.criteria[c.key] ?? 0, + locale, + 2 + ) + : t('criterionNoData')} + +
+ ))}
diff --git a/apps/web/src/lib/__tests__/hero-products.test.ts b/apps/web/src/lib/__tests__/hero-products.test.ts index 2bdae3ed0..32b00323e 100644 --- a/apps/web/src/lib/__tests__/hero-products.test.ts +++ b/apps/web/src/lib/__tests__/hero-products.test.ts @@ -175,8 +175,8 @@ for (const category of ['smartphones', 'tablets'] as const) { `popular pairs in ${category}` ); } -// Every spec field the scoring file reads is in the kept list, so a new one -// cannot silently score as missing on the leaderboard. +// The scores read no spec field: a criterion score is never derived from +// the specs (DOS/Rate#1143). Any spec a later change reads must be kept. const engineSrc = readFileSync( new URL('../leaderboard-engine.ts', import.meta.url), 'utf8' @@ -184,7 +184,7 @@ const engineSrc = readFileSync( const readSpecs = [ ...engineSrc.matchAll(/\bspecs\??\.([a-z_]+)\??\.([a-z_]+)/g), ]; -assert.ok(readSpecs.length >= 15, 'the scoring file reads spec fields'); +assert.equal(readSpecs.length, 0, 'the scoring file reads no spec field'); for (const [, group, field] of readSpecs) { const kept = (LEADERBOARD_SPEC_FIELDS as Record)[ group! diff --git a/apps/web/src/lib/__tests__/leaderboard-criteria.test.ts b/apps/web/src/lib/__tests__/leaderboard-criteria.test.ts new file mode 100644 index 000000000..6b8dbe34c --- /dev/null +++ b/apps/web/src/lib/__tests__/leaderboard-criteria.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { + computeJReviewsCriteriaScores, + computeProductStandingInCategory, + rankMasterProductsForLeaderboard, + scoreMasterProductForLeaderboard, + sharedScoredCriteria, + weightedCriteriaScore5, +} from '../leaderboard-engine'; +import type { MasterProduct } from '../rate-api'; + +// Per-criterion scores are real or null, never derived from the star rating +// (DOS/Rate#1143): a well-rated product used to score 5.00 on every +// criterion, so rankings tied and "#1 Màn hình" chips were invented. + +const phone = ( + slug: string, + rating: number, + criteria?: Record +): MasterProduct => + ({ + id: `id-${slug}`, + slug, + name: slug, + brand: 'Brand', + model: slug, + user_rating_weighted: rating, + min_price: 20_000_000, + specifications: { + display: { refresh_rate_hz: 120 }, + hardware: { ram_gb: 16 }, + battery: { capacity_mah: 6000 }, + camera: { second_camera: '50 MP' }, + }, + metadata: { category_slug: 'smartphones' }, + ...(criteria ? { expert_criteria_scores: criteria } : {}), + }) as unknown as MasterProduct; + +const NONE = { + screen: null, + performance: null, + camera: null, + battery: null, + value: null, +}; + +// A 4.9-star phone with strong specs and no criterion scores has none. +const rated = scoreMasterProductForLeaderboard(phone('rated-a', 4.9)); +assert.deepEqual(rated.criteria, NONE); +// Its combined score is its user score alone, not lifted by invented +// criteria. +assert.equal(rated.combinedScore5, 4.9); + +// Stored criterion scores are real: they show (a 10-point one is halved), and +// they join the combined score. +const scored = scoreMasterProductForLeaderboard( + phone('scored-b', 4.5, { screen: 9, battery: 4 }) +); +assert.equal(scored.criteria.screen, 4.5); +assert.equal(scored.criteria.battery, 4); +assert.equal(scored.criteria.camera, null); +assert.equal( + scored.combinedScore5, + Number((4.5 * 0.8 + 4.25 * 0.2).toFixed(2)) +); + +// Earbuds have no screen criterion, even with a stored score for it. +const buds = computeJReviewsCriteriaScores({ + ...phone('buds', 4.8, { screen: 4.9, sound: 4.7 }), + metadata: { category_slug: 'earbuds' }, +} as unknown as MasterProduct); +assert.equal(buds.screen, null); +assert.equal(buds.camera, 4.7); + +// A criterion ranking lists only products with a real score on it. +const peers = [ + phone('rated-a', 4.9), + phone('rated-c', 4.95), + phone('scored-b', 4.5, { screen: 9, battery: 4 }), +]; +assert.deepEqual( + rankMasterProductsForLeaderboard(peers, { + category: 'smartphones', + criterion: 'screen', + }).map((r) => r.slug), + ['scored-b'] +); +assert.deepEqual( + rankMasterProductsForLeaderboard(peers, { + category: 'smartphones', + criterion: 'camera', + }), + [] +); + +// Rank chips: a product without criterion data gets only its overall rank, +// and a criterion with one scored product ranks nothing ("#1 of 1"). +const badgesFor = (slug: string, list: MasterProduct[]) => + computeProductStandingInCategory( + list.find((p) => p.slug === slug)!, + list + ).map((b) => `${b.criterion}#${b.rank}/${b.totalInCategory}`); +assert.deepEqual(badgesFor('rated-a', peers), ['combined#2/3']); +assert.deepEqual(badgesFor('scored-b', peers), ['combined#3/3']); +// With two scored products, the criterion ranks among those two only. +const twoScored = [...peers, phone('scored-d', 4.4, { screen: 8 })]; +assert.deepEqual(badgesFor('scored-b', twoScored), [ + 'combined#3/4', + 'screen#1/2', +]); + +// Weighted criteria: only scored, weighted criteria count; none gives null. +const w = { screen: 20, performance: 20, camera: 20, battery: 20, value: 20 }; +assert.equal(weightedCriteriaScore5(NONE, w), null); +assert.equal( + weightedCriteriaScore5({ ...NONE, screen: 4, battery: 5 }, w), + 4.5 +); +assert.equal( + weightedCriteriaScore5({ ...NONE, screen: 4, battery: 5 }, w, ['battery']), + 5 +); +assert.deepEqual( + sharedScoredCriteria( + { ...NONE, screen: 4, battery: 5 }, + { ...NONE, battery: 3, value: 4 } + ), + ['battery'] +); + +console.log('leaderboard-criteria: criterion scores are real or null'); diff --git a/apps/web/src/lib/__tests__/locale-format.test.ts b/apps/web/src/lib/__tests__/locale-format.test.ts index 308c6ff99..6b53579de 100644 --- a/apps/web/src/lib/__tests__/locale-format.test.ts +++ b/apps/web/src/lib/__tests__/locale-format.test.ts @@ -7,7 +7,7 @@ import { radarExplanation } from '../product-detail-copy'; import { weightExplanationCopy } from '../review-display'; import { fitTitle } from '../seo'; import { localizeSpecValue } from '../spec-localizer'; -import { versusVerdictCopy } from '../versus-verdict'; +import { versusApiWinner, versusVerdictCopy } from '../versus-verdict'; import { NBSP, collapseWhitespace, @@ -300,18 +300,43 @@ assert.deepEqual( ); assert.deepEqual(versusVerdictCopy(versusInput, 'vi').values, { winner: 'A', - score: '4.92', + // One decimal, like every rating on the site (DOS/Rate#1470). + score: '4.9', expertSuffix: ' (9.1/10 Lab)', }); assert.deepEqual(versusVerdictCopy(versusInput, 'cs').values, { winner: 'A', - score: '4,92', + score: '4,9', expertSuffix: ' (9,1/10 Lab)', }); assert.deepEqual( versusVerdictCopy({ ...versusInput, tie: true }, 'cs').values, - { score: '4,92' } + { score: '4,9' } ); +// Neither model has a score: no "0.0" tie (DOS/Rate#1545). +assert.equal( + versusVerdictCopy({ ...versusInput, tie: true, score: 0 }).key, + 'versusAutoVerdictNoScore' +); + +// The 1v1 API decides on the /10 scores at one decimal; the composite and +// then the lower price only break a tie (DOS/Rate#1545). +assert.deepEqual(versusApiWinner(8.9, 8.7, 4.0, 4.9, 'B'), { + tie: false, + side: 'A', +}); +assert.deepEqual(versusApiWinner(9.04, 8.96, 4.0, 4.9, 'A'), { + tie: true, + side: 'B', +}); +assert.deepEqual(versusApiWinner(9.0, 9.0, 4.5, 4.5, 'B'), { + tie: true, + side: 'B', +}); +assert.deepEqual(versusApiWinner(9.0, 9.0, 4.5, 4.5, 'equal'), { + tie: true, + side: 'A', +}); // cs.json: a placeholder before % or ₫ is joined by one U+00A0, and no // rating scale is written `/ 5.0`. diff --git a/apps/web/src/lib/leaderboard-engine.ts b/apps/web/src/lib/leaderboard-engine.ts index ba1f303c8..13988eeba 100644 --- a/apps/web/src/lib/leaderboard-engine.ts +++ b/apps/web/src/lib/leaderboard-engine.ts @@ -22,13 +22,32 @@ export type LeaderboardCriterionKey = export type LeaderboardPriceSegment = 'all' | 'under15m' | '15to25m' | 'over25m'; -export interface LeaderboardCriteriaBreakdown { - screen: number; - performance: number; - camera: number; - battery: number; - value: number; -} +/** The criteria a product can be scored on, besides its overall scores. */ +export const LEADERBOARD_SCORE_CRITERIA = [ + 'screen', + 'performance', + 'camera', + 'battery', + 'value', +] as const; + +export type LeaderboardScoreCriterion = + (typeof LEADERBOARD_SCORE_CRITERIA)[number]; + +/** + * A product's score on each criterion, or null when there is no real score + * for it (DOS/Rate#1143): pages show "Chưa đủ dữ liệu" and no rank. + */ +export type LeaderboardCriteriaBreakdown = Record< + LeaderboardScoreCriterion, + number | null +>; + +/** A weight per criterion, as the persona and custom weights set them. */ +export type LeaderboardCriteriaWeights = Record< + LeaderboardScoreCriterion, + number +>; export interface ScoredLeaderboardItem { rank: number; @@ -186,10 +205,10 @@ function toSafeNonNegativeNumber(raw: unknown): number { } /** - * The spec fields the criteria scores read (computeJReviewsCriteriaScores). A - * client that ranks or pairs products, such as the leaderboard and the compare - * picker's popular pairs, needs only these, not the whole spec sheet - * (DOS/Rate#1515). A test checks every spec this file reads is listed. + * The spec fields a client that ranks or pairs products, such as the + * leaderboard and the compare picker's popular pairs, keeps instead of the + * whole spec sheet (DOS/Rate#1515). The scores no longer read specs + * (DOS/Rate#1143); a test checks this file reads none. */ export const LEADERBOARD_SPEC_FIELDS = { battery: ['capacity_mah', 'charging', 'fast_charging_w'], @@ -224,10 +243,37 @@ export function leaderboardSpecsOnly(specs: unknown): ProductSpecifications { return out as ProductSpecifications; } +/** + * The criteria that apply to a category: earbuds have no screen + * (DOS/Rate#1143), and score noise cancelling as their "camera" criterion + * (DOS/Rate#1250). + */ +export function leaderboardCriteriaForCategory( + categorySlug: CanonicalCategorySlug +): readonly LeaderboardScoreCriterion[] { + return categorySlug === 'earbuds' + ? LEADERBOARD_SCORE_CRITERIA.filter((c) => c !== 'screen') + : LEADERBOARD_SCORE_CRITERIA; +} + +// The stored keys each criterion reads from expert_criteria_scores. +const CRITERION_SCORE_KEYS: Record = { + screen: ['screen', 'display', 'man_hinh', 'design', 'comfort'], + performance: ['performance', 'hardware', 'hieu_nang', 'connectivity'], + camera: ['camera', 'audio', 'multimedia', 'sound', 'sensors'], + battery: ['battery', 'pin', 'endurance'], + value: ['value', 'price_value', 'dang_tien'], +}; + +/** + * A product's per-criterion scores, out of 5, from the scores stored for each + * criterion only. A criterion with no stored score, or one that does not + * apply to the category, is null: it is never derived from the star rating, + * the specs or the price, which made every well-rated product score 5.00 + * and tie (DOS/Rate#1143). + */ export function computeJReviewsCriteriaScores( - item: MasterProduct, - userScore5: number, - expertScore5: number | null + item: MasterProduct ): LeaderboardCriteriaBreakdown { const safeItem = ( item && typeof item === 'object' && !Array.isArray(item) ? item : {} @@ -239,193 +285,69 @@ export function computeJReviewsCriteriaScores( ? safeItem.expert_criteria_scores : {} ) as Record; - const specs = ( - safeItem.specifications && - typeof safeItem.specifications === 'object' && - !Array.isArray(safeItem.specifications) - ? safeItem.specifications - : {} - ) as ProductSpecifications; - const categorySlug = getMasterProductCategorySlug(safeItem); - const safeUserScore5 = - typeof userScore5 === 'number' && - Number.isFinite(userScore5) && - userScore5 > 0 - ? clampScore5(userScore5) - : 0; - const safeExpertScore5 = - typeof expertScore5 === 'number' && - Number.isFinite(expertScore5) && - expertScore5 > 0 - ? clampScore5(expertScore5) - : null; - const base5 = - safeUserScore5 > 0 && safeExpertScore5 != null - ? Number((safeUserScore5 * 0.55 + safeExpertScore5 * 0.45).toFixed(2)) - : safeUserScore5 > 0 - ? safeUserScore5 - : (safeExpertScore5 ?? 0); - - const parseRaw = (keys: string[], fallbackDelta: number): number => { - for (const k of keys) { + const applies = leaderboardCriteriaForCategory( + getMasterProductCategorySlug(safeItem) + ); + + const read = (criterion: LeaderboardScoreCriterion): number | null => { + if (!applies.includes(criterion)) return null; + for (const k of CRITERION_SCORE_KEYS[criterion]) { const v = toSafePositiveNumber(rawCriteria[k]); - if (v > 0 && v <= 10) { - return clampScore5(v > 5 ? v / 2 : v); - } + if (v > 0 && v <= 10) return clampScore5(v > 5 ? v / 2 : v); } - if (base5 <= 0) return 0; - return clampScore5(base5 + fallbackDelta); + return null; }; - const refreshHz = toSafePositiveNumber(specs.display?.refresh_rate_hz); - const ramGb = - toSafePositiveNumber(safeItem.ram_gb) || - toSafePositiveNumber(specs.hardware?.ram_gb); - const batteryMah = - toSafePositiveNumber(safeItem.battery_mah) || - toSafePositiveNumber(specs.battery?.capacity_mah); - const fastChargingW = toSafePositiveNumber(specs.battery?.fast_charging_w); - const rawMinPrice = toSafePositiveNumber(safeItem.min_price); - const minPrice = - Number.isFinite(rawMinPrice) && - rawMinPrice > 0 && - rawMinPrice <= 1_000_000_000 - ? rawMinPrice - : 0; - const displayType = String(specs.display?.type || '').toLowerCase(); - const displayProtection = String( - specs.display?.protection || '' - ).toLowerCase(); - const resistance = String(specs.design?.resistance || '').toLowerCase(); - const audioFeatures = String( - specs.multimedia?.audio_features || '' - ).toLowerCase(); - const sensors = String(specs.connectivity?.sensors || '').toLowerCase(); - const location = String(specs.connectivity?.location || '').toLowerCase(); - - let screenDelta: number; - let perfDelta: number; - let camDelta: number; - let batDelta: number; - let valDelta: number; - - if (categorySlug === 'earbuds') { - screenDelta = - resistance.includes('ip') || Number(specs.design?.weight_g || 0) > 0 - ? 0.12 - : 0.04; - perfDelta = - String(specs.connectivity?.bluetooth || '').includes('5.') || - Boolean(specs.hardware?.chipset) - ? 0.14 - : 0.04; - camDelta = - audioFeatures.includes('anc') || - audioFeatures.includes('ldac') || - audioFeatures.includes('spatial') || - audioFeatures.includes('dolby') || - Boolean(specs.multimedia?.audio_features) - ? 0.16 - : 0.06; - batDelta = - batteryMah >= 500 - ? 0.16 - : batteryMah >= 300 || Boolean(specs.battery?.charging) - ? 0.09 - : 0.03; - valDelta = - minPrice > 0 && minPrice <= 3_500_000 - ? 0.22 - : minPrice > 0 && minPrice <= 6_500_000 - ? 0.1 - : minPrice > 0 - ? -0.03 - : 0; - } else if (categorySlug === 'smartwatches') { - screenDelta = - refreshHz >= 60 || - displayType.includes('oled') || - displayType.includes('amoled') || - displayProtection.includes('sapphire') - ? 0.12 - : 0.03; - perfDelta = Boolean(specs.hardware?.chipset) || ramGb >= 2 ? 0.12 : 0.03; - camDelta = - sensors.includes('ecg') || - sensors.includes('spo2') || - location.includes('dual') || - location.includes('l1') || - Boolean(specs.connectivity?.sensors) - ? 0.14 - : 0.04; - batDelta = batteryMah >= 500 ? 0.18 : batteryMah >= 300 ? 0.09 : 0.02; - valDelta = - minPrice > 0 && minPrice <= 7_000_000 - ? 0.2 - : minPrice > 0 && minPrice <= 15_000_000 - ? 0.08 - : minPrice > 0 - ? -0.04 - : 0; - } else if (categorySlug === 'laptops') { - screenDelta = - refreshHz >= 120 - ? 0.15 - : refreshHz >= 90 || - displayType.includes('oled') || - displayType.includes('mini-led') - ? 0.09 - : 0.02; - perfDelta = ramGb >= 32 ? 0.18 : ramGb >= 16 ? 0.12 : 0.02; - camDelta = - specs.camera?.front_camera || specs.multimedia?.loudspeaker ? 0.08 : 0.02; - batDelta = - batteryMah >= 7000 || fastChargingW >= 90 - ? 0.16 - : batteryMah >= 4500 || fastChargingW >= 65 - ? 0.09 - : 0.02; - valDelta = - minPrice > 0 && minPrice <= 25_000_000 - ? 0.2 - : minPrice > 0 && minPrice <= 42_000_000 - ? 0.08 - : minPrice > 0 - ? -0.04 - : 0; - } else { - screenDelta = refreshHz >= 120 ? 0.14 : refreshHz >= 90 ? 0.05 : -0.04; - perfDelta = ramGb >= 16 ? 0.16 : ramGb >= 12 ? 0.08 : 0.02; - camDelta = specs.camera?.second_camera ? 0.12 : 0.03; - batDelta = batteryMah >= 5500 ? 0.18 : batteryMah >= 4800 ? 0.09 : 0.02; - valDelta = - minPrice > 0 && minPrice <= 18_000_000 - ? 0.22 - : minPrice > 0 && minPrice <= 27_000_000 - ? 0.08 - : minPrice > 0 - ? -0.05 - : 0; - } - return { - screen: parseRaw( - ['screen', 'display', 'man_hinh', 'design', 'comfort'], - screenDelta - ), - performance: parseRaw( - ['performance', 'hardware', 'hieu_nang', 'connectivity'], - perfDelta - ), - camera: parseRaw( - ['camera', 'audio', 'multimedia', 'sound', 'sensors'], - camDelta - ), - battery: parseRaw(['battery', 'pin', 'endurance'], batDelta), - value: parseRaw(['value', 'price_value', 'dang_tien'], valDelta), + screen: read('screen'), + performance: read('performance'), + camera: read('camera'), + battery: read('battery'), + value: read('value'), }; } +/** + * The weighted average of the criteria that have a score, out of 5, or null + * when none of the weighted ones has one. `only` limits it to the criteria + * both sides of a 1v1 have, so neither wins on a criterion the other lacks. + */ +export function weightedCriteriaScore5( + criteria: LeaderboardCriteriaBreakdown, + weights: LeaderboardCriteriaWeights, + only: readonly LeaderboardScoreCriterion[] = LEADERBOARD_SCORE_CRITERIA +): number | null { + let sum = 0; + let weightSum = 0; + for (const c of only) { + const score = criteria?.[c]; + const weight = weights?.[c]; + if ( + typeof score !== 'number' || + !Number.isFinite(score) || + score <= 0 || + typeof weight !== 'number' || + !Number.isFinite(weight) || + weight <= 0 + ) { + continue; + } + sum += score * weight; + weightSum += weight; + } + return weightSum > 0 ? Number((sum / weightSum).toFixed(2)) : null; +} + +/** The criteria both products have a score for. */ +export function sharedScoredCriteria( + a: LeaderboardCriteriaBreakdown, + b: LeaderboardCriteriaBreakdown +): LeaderboardScoreCriterion[] { + return LEADERBOARD_SCORE_CRITERIA.filter( + (c) => (a?.[c] ?? 0) > 0 && (b?.[c] ?? 0) > 0 + ); +} + /** Filtered-review counts per master id, as read from stored reviews. */ export type FilteredReviewCountsByMaster = Readonly>; @@ -474,18 +396,12 @@ export function scoreMasterProductForLeaderboard( const expertScore5: number | null = expertScore10 != null ? Number((expertScore10 / 2).toFixed(2)) : null; - const criteria = computeJReviewsCriteriaScores( - safeItem, - userScore5, - expertScore5 - ); - const criteriaValues = [ - criteria.screen, - criteria.performance, - criteria.camera, - criteria.battery, - criteria.value, - ].filter((v) => v > 0); + // Only real criterion scores join the composite; a product without them + // ranks on its user and expert scores alone (DOS/Rate#1143). + const criteria = computeJReviewsCriteriaScores(safeItem); + const criteriaValues = LEADERBOARD_SCORE_CRITERIA.map( + (c) => criteria[c] + ).filter((v): v is number => v != null && v > 0); const criteriaAvg = criteriaValues.length > 0 ? criteriaValues.reduce((a, b) => a + b, 0) / criteriaValues.length @@ -516,7 +432,9 @@ export function scoreMasterProductForLeaderboard( let activeScore5 = combinedScore5; if (safeCriterion === 'user') activeScore5 = userScore5; else if (safeCriterion === 'expert') activeScore5 = expertScore5 ?? 0; - else if (safeCriterion !== 'combined') activeScore5 = criteria[safeCriterion]; + else if (safeCriterion !== 'combined') { + activeScore5 = criteria[safeCriterion] ?? 0; + } const rawAuthentic = toSafeNonNegativeNumber(safeItem.authentic_review_count); const rawTotal = toSafePositiveNumber(safeItem.total_reviews); @@ -781,13 +699,16 @@ export function computeProductStandingInCategory( }); const found = ranked.find((r) => r.slug === targetSlug); if (!found) continue; + // A criterion ranks only the products with a real score on it, and needs + // at least two of them: "#1 of 1" is not a ranking (DOS/Rate#1143). + if (crit !== 'combined' && ranked.length < 2) continue; // Always include overall combined rank; also include top-3 sub-criteria ranks if (crit === 'combined' || found.rank <= 3) { badges.push({ criterion: crit, rank: found.rank, - totalInCategory, + totalInCategory: crit === 'combined' ? totalInCategory : ranked.length, score5: found.activeScore5, score10: found.activeScore10, categorySlug, diff --git a/apps/web/src/lib/versus-verdict.ts b/apps/web/src/lib/versus-verdict.ts index 0e0864f99..8ce1659d3 100644 --- a/apps/web/src/lib/versus-verdict.ts +++ b/apps/web/src/lib/versus-verdict.ts @@ -1,12 +1,39 @@ import { formatDecimal } from './locale-format'; /** - * Whether two 1v1 scores are level as the page shows them, at two decimals - * (DOS/Rate#1209): 5.00 vs 5.00 is a tie, not a lead for either model. + * Whether two 1v1 scores are level as the page shows them (DOS/Rate#1209): + * at one decimal, like the comparison matrix and every rating on the site + * (DOS/Rate#1470). 5.0 vs 5.0 is a tie, not a lead for either model. */ export function isVersusScoreTie(scoreA: number, scoreB: number): boolean { if (!Number.isFinite(scoreA) || !Number.isFinite(scoreB)) return false; - return scoreA.toFixed(2) === scoreB.toFixed(2); + return scoreA.toFixed(1) === scoreB.toFixed(1); +} + +/** + * The 1v1 API's winner and tie (DOS/Rate#1545): the persona scores on 10, at + * one decimal as the response shows them, decide; on a tie the composite, + * then the lower price, then side A only pick `overall_winner`. + */ +export function versusApiWinner( + score10A: number, + score10B: number, + combined5A: number, + combined5B: number, + lowerPriceSide: string | null | undefined +): { tie: boolean; side: 'A' | 'B' } { + const a = Number.isFinite(score10A) ? Number(score10A.toFixed(1)) : 0; + const b = Number.isFinite(score10B) ? Number(score10B.toFixed(1)) : 0; + if (a !== b) return { tie: false, side: a > b ? 'A' : 'B' }; + const side: 'A' | 'B' = + combined5A > combined5B + ? 'A' + : combined5B > combined5A + ? 'B' + : lowerPriceSide === 'B' + ? 'B' + : 'A'; + return { tie: true, side }; } export interface VersusVerdictInput { @@ -20,6 +47,11 @@ export interface VersusVerdictInput { } export type VersusVerdictCopy = + | { + key: 'versusAutoVerdictNoScore'; + values: Record; + weightsLabel: string | null; + } | { key: 'versusAutoVerdictTie'; values: { score: string }; @@ -40,7 +72,16 @@ export function versusVerdictCopy( input: VersusVerdictInput, locale?: string | null ): VersusVerdictCopy { - const score = formatDecimal(input.score, locale, 2); + // Level because neither model has a score yet: say so, as the stars do, + // instead of a "0.0" tie (DOS/Rate#1545). + if (input.tie && !(input.score > 0)) { + return { + key: 'versusAutoVerdictNoScore', + values: {}, + weightsLabel: input.weightsLabel, + }; + } + const score = formatDecimal(input.score, locale, 1); if (input.tie) { return { key: 'versusAutoVerdictTie', 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 62cf4040c..8703d4ca2 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 @@ -5462,19 +5462,19 @@ async function runCoreArchitectureSuite() { ); assert.equal(scoredEarbud.categorySlug, 'earbuds'); - // 68.2 Category-aware criteria fallback deltas for earbuds (no -0.04 penalty for missing refresh_rate_hz) - // base5 = 4.6 * 0.55 + 4.5 * 0.45 = 2.53 + 2.025 = 4.56 - assert.ok( - scoredEarbud.criteria.screen > 4.56, - `Earbuds with IPX4 design must receive positive screen/design delta (${scoredEarbud.criteria.screen} > 4.56) instead of -0.04 smartphone refresh rate penalty (#399)` - ); - assert.ok( - scoredEarbud.criteria.camera >= 4.7, - `Earbuds with ANC + LDAC must receive positive audio criterion delta (${scoredEarbud.criteria.camera}) (#399)` - ); - assert.ok( - scoredEarbud.criteria.battery >= 4.7, - `Earbuds with 500mAh case must receive positive battery delta (${scoredEarbud.criteria.battery}) (#399)` + // 68.2 No criterion score is derived from the star rating, specs or + // price: an earbud without stored criterion scores has none, and its + // combined score is its user and expert scores alone (DOS/Rate#1143). + assert.deepEqual(scoredEarbud.criteria, { + screen: null, + performance: null, + camera: null, + battery: null, + value: null, + }); + assert.equal( + scoredEarbud.combinedScore5, + Number((4.6 * 0.6 + 4.5 * 0.4).toFixed(2)) ); // 68.3 Unpriced (min_price = 0) products excluded from non-'all' priceSegments & sorted after priced ties @@ -5486,7 +5486,7 @@ async function runCoreArchitectureSuite() { min_price: 0, max_price: 0, // Give unpriced clone identical criteria via explicit expert_criteria_scores so scores tie - expert_criteria_scores: { ...scoredEarbud.criteria }, + expert_criteria_scores: { camera: 4.8, battery: 4.7, value: 4.6 }, }; const pricedClone = { ...tenPointUserProduct, @@ -5495,7 +5495,7 @@ async function runCoreArchitectureSuite() { name: 'ZZZ Priced Earbud', min_price: 5_490_000, max_price: 5_990_000, - expert_criteria_scores: { ...scoredEarbud.criteria }, + expert_criteria_scores: { camera: 4.8, battery: 4.7, value: 4.6 }, }; const under15mRanked = rankFromDomain([unpricedClone, pricedClone], { @@ -11415,10 +11415,29 @@ async function runCoreArchitectureSuite() { 'custom' ); assert.equal(selfRes.tie, true); - assert.equal(selfRes.delta, '0.00'); + assert.equal(selfRes.delta, '0.0'); const { isVersusScoreTie } = await import('../../../lib/versus-verdict'); - assert.equal(isVersusScoreTie(5, 4.996), true, 'equal at two decimals'); - assert.equal(isVersusScoreTie(4.98, 4.97), false); + // One decimal, as the matrix and every rating show it (DOS/Rate#1470). + assert.equal(isVersusScoreTie(5, 4.96), true, 'equal at one decimal'); + assert.equal(isVersusScoreTie(4.98, 5), true, '5.0 against 5.0'); + assert.equal(isVersusScoreTie(4.9, 4.8), false); + // A model with no buyer rating keeps its composite as the base, and the + // gap is the one between the scores as shown (DOS/Rate#1470). + const expertOnly = computeVersusWeightedResult( + { ...scoreItemA, userScore5: 0, combinedScore5: 4.5 }, + { ...scoreItemB, userScore5: 4.2, combinedScore5: 4.2 }, + { screen: 20, performance: 20, camera: 20, battery: 20, value: 20 }, + 'default' + ); + assert.equal(expertOnly.finalA, 4.5); + assert.equal(expertOnly.delta, '0.3'); + const shownGap = computeVersusWeightedResult( + { ...scoreItemA, userScore5: 4.46 }, + { ...scoreItemB, userScore5: 4.34 }, + { screen: 20, performance: 20, camera: 20, battery: 20, value: 20 }, + 'default' + ); + assert.equal(shownGap.delta, '0.2', '4.5 against 4.3'); assert.equal(isVersusScoreTie(Number.NaN, 5), false); // The verdict follows the persona: A leads on the default weights, B on // the camera persona, and the verdict names each in turn with its @@ -11430,6 +11449,7 @@ async function runCoreArchitectureSuite() { slug: 'cam-a', name: 'Model A', combinedScore5: 4.9, + userScore5: 4.9, expertScore10: null, criteria: { screen: 5, performance: 5, camera: 3, battery: 5, value: 5 }, }; @@ -11438,6 +11458,7 @@ async function runCoreArchitectureSuite() { slug: 'cam-b', name: 'Model B', combinedScore5: 4.6, + userScore5: 4.6, expertScore10: null, criteria: { screen: 4, performance: 4, camera: 5, battery: 4, value: 4 }, }; @@ -11468,9 +11489,9 @@ async function runCoreArchitectureSuite() { byCamera.key === 'versusAutoVerdict' ) { assert.equal(byDefault.values.winner, 'Model A'); - assert.equal(byDefault.values.score, '4.90'); + assert.equal(byDefault.values.score, '4.9'); assert.equal(byCamera.values.winner, 'Model B'); - assert.equal(byCamera.values.score, '4.50'); + assert.equal(byCamera.values.score, '4.5'); } assert.equal(byDefault.weightsLabel, null); assert.equal(byCamera.weightsLabel, 'Chụp ảnh / Âm thanh'); @@ -11485,7 +11506,7 @@ async function runCoreArchitectureSuite() { }), { key: 'versusAutoVerdictTie', - values: { score: '5.00' }, + values: { score: '5.0' }, weightsLabel: null, } ); @@ -12065,10 +12086,7 @@ async function runCoreArchitectureSuite() { assert.equal(competitors983[0]?.slug, 'galaxy-s24-ultra'); assert.equal(buildKeySpecLine983([] as never), ''); - assert.equal( - computeJReviewsCriteriaScores983([] as never, 0, null).screen, - 0 - ); + assert.equal(computeJReviewsCriteriaScores983([] as never).screen, null); assert.equal(scoreMasterProductForLeaderboard983([] as never).slug, ''); const sampleProducts983 = [ diff --git a/apps/web/src/server/rate/openapi.ts b/apps/web/src/server/rate/openapi.ts index 457893dcc..ddf8894b9 100644 --- a/apps/web/src/server/rate/openapi.ts +++ b/apps/web/src/server/rate/openapi.ts @@ -99,11 +99,29 @@ function inspectRateLimitedResponse(): OpenApiResponse { ); } +/** + * A product's score out of 5 on each criterion, null when there is no real + * score for it (never derived from the star rating; DOS/Rate#1143). Earbuds + * have no `screen` criterion, so theirs is always null. + */ +const CRITERIA_SCORES = { + type: 'object', + description: + 'Score out of 5 per criterion, from the scores stored for that criterion only. `null` means not enough data ("Chưa đủ dữ liệu"): it is never derived from the star rating, and a null criterion is left out of the combined score and of criterion rankings. Earbuds have no `screen` criterion.', + properties: Object.fromEntries( + ['screen', 'performance', 'camera', 'battery', 'value'].map((c) => [ + c, + { type: ['number', 'null'], minimum: 0, maximum: 5 }, + ]) + ), +} as const; + const LEADERBOARD_ENTRY = { type: 'object', additionalProperties: true, properties: { slug: { type: 'string' }, + criteria_scores: CRITERIA_SCORES, deal_radar: RADAR_REF, radar_signal: RADAR_REF, }, @@ -627,7 +645,7 @@ function buildBaseRateOpenApiSpec( summary: 'Get programmatic 1v1 Head-to-Head JReviews verdict, 5-criteria scoreboard & price delta', description: - 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner (with `is_tie` true when both scores are level at two decimals; `overall_winner` is then only the price or side-A tie-break), per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.', + 'Evaluates two same-category master products (`?pair=-vs-` or `?slugA=...&slugB=...`) with optional persona/custom 5-criteria weights (`persona`, `w_screen`, `w_performance`, `w_camera`, `w_battery`, `w_value`) and returns the overall winner (with `is_tie` true when both `custom_weighted_score_10` values are equal at one decimal; `overall_winner` is then only the tie-break: the combined score, then the lower price, then side A), per-criterion head-to-head breakdown, price delta, Deal Radar signals (`deal_radar`), and 10-group specs.', tags: ['Comparison & Watchlist'], parameters: [ { @@ -727,7 +745,39 @@ function buildBaseRateOpenApiSpec( is_tie: { type: 'boolean' }, overall_winner: { type: 'object' }, price_comparison: { type: 'object' }, - criteria_head_to_head: { type: 'array' }, + custom_weighted_basis: { + type: 'string', + enum: ['criteria', 'combined'], + description: + 'What `custom_weighted_score_*` weighs: the criteria both products have a real score for (`compared_criteria`), or their combined scores when they share none.', + }, + compared_criteria: { + type: 'array', + items: { type: 'string' }, + }, + criteria_head_to_head: { + type: 'array', + description: + 'One row per criterion of the category (earbuds have no `screen` row). A side with no real score for a criterion has null `score_*` for it; when either side is null, `delta_10` and `winner_slug` are null too: the criterion is not won or lost.', + items: { + type: 'object', + additionalProperties: true, + properties: { + criterion: { type: 'string' }, + weight_pct: { type: 'number' }, + score_a_5: { type: ['number', 'null'] }, + score_a_10: { type: ['number', 'null'] }, + score_b_5: { type: ['number', 'null'] }, + score_b_10: { type: ['number', 'null'] }, + delta_10: { type: ['number', 'null'] }, + winner_slug: { + type: ['string', 'null'], + description: + "The winner's slug, `tie`, or null when the criterion was not compared.", + }, + }, + }, + }, deal_radar: { type: 'object', properties: { @@ -740,6 +790,7 @@ function buildBaseRateOpenApiSpec( additionalProperties: true, properties: { slug: { type: 'string' }, + criteria_scores: CRITERIA_SCORES, deal_radar: RADAR_REF, radar_signal: RADAR_REF, }, @@ -749,6 +800,7 @@ function buildBaseRateOpenApiSpec( additionalProperties: true, properties: { slug: { type: 'string' }, + criteria_scores: CRITERIA_SCORES, deal_radar: RADAR_REF, radar_signal: RADAR_REF, }, diff --git a/package.json b/package.json index a10392aaa..a4b6839c1 100644 --- a/package.json +++ b/package.json @@ -86,10 +86,12 @@ "lodash@>=4.0.0 <4.18.0": "^4.18.1", "tmp@<0.2.6": "^0.2.7", "source-map-js@>=1.0.0 <1.2.2": "^1.2.2", - "fast-uri@>=3.0.0 <3.1.8": "^3.1.8", + "fast-uri@>=3.0.0 <3.1.9": "^3.1.9", "ajv@>=6.0.0 <6.14.0": "^6.14.0", "postcss-selector-parser@<7.1.6": "^7.1.6", - "js-yaml@<4.1.0": "^4.1.1" + "js-yaml@<4.1.0": "^4.1.1", + "minimatch@^10": "^11.0.1", + "glob@^13": "^14.0.3" } }, "dependencies": { diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 79cfd9015..d09917e50 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -1381,6 +1381,334 @@ function testLlmReviewSummaryScope(): void { ); } +function testRatingCriteriaAndReviewForm(): void { + // D1: every list is weighted to 100, with unique keys, both labels and + // exactly one required criterion. + for (const slug of rules.RATING_CRITERIA_CATEGORIES) { + const criteria = rules.getRatingCriteria(slug); + assert.equal( + criteria.reduce((sum, criterion) => sum + criterion.weight, 0), + 100, + slug + ); + assert.equal(new Set(criteria.map((cr) => cr.key)).size, criteria.length); + for (const criterion of criteria) { + assert.ok(criterion.label.vi && criterion.label.en, criterion.key); + assert.ok(criterion.weight > 0, criterion.key); + } + assert.equal(criteria.filter((cr) => cr.required).length, 1, slug); + } + assert.equal(rules.RATING_CRITERIA_VERSION, 1); + // Earbuds rate sound, not a display; an unknown category rates as a phone. + const earbudKeys = rules.getRatingCriteria('earbuds').map((cr) => cr.key); + assert.ok(earbudKeys.includes('sound') && !earbudKeys.includes('display')); + assert.deepEqual( + rules.getRatingCriteria('cameras'), + rules.getRatingCriteria('smartphones') + ); + assert.deepEqual( + rules.getRatingCriteria(null), + rules.getRatingCriteria('smartphones') + ); + + // The review star is the weighted average, at one decimal: a phone with + // performance 5 (20), camera 4 (20), battery 3 (20), display 5 (15), + // design 4 (10) and value 4 (15) is 415 / 100 = 4.15, shown as 4.2. + const phone = { + performance: 5, + camera: 4, + battery: 3, + display: 5, + design: 4, + value: 4, + }; + assert.equal(rules.weightedReviewStar('smartphones', phone), 4.2); + // Skipped criteria leave the weights of the rated ones: (5*20 + 4*15) / 35. + assert.equal( + rules.weightedReviewStar('smartphones', { performance: 5, value: 4 }), + 4.6 + ); + // Unknown keys and bad stars are ignored; nothing valid gives null. + assert.equal( + rules.weightedReviewStar('smartphones', { screen: 5, value: 7 }), + null + ); + assert.equal(rules.weightedReviewStar('earbuds', {}), null); + + // D5: up to two criteria skipped, value never; stars 1-5 in half steps. + assert.deepEqual(rules.validateCriteriaRatings('smartphones', phone), []); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + performance: 4.5, + camera: 4, + battery: 3, + display: 5, + value: 4, + }), + [] + ); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + performance: 5, + camera: 4, + battery: 3, + display: 5, + design: 4, + }), + [{ kind: 'missing_required', key: 'value' }] + ); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + performance: 5, + camera: 4, + value: 4, + }), + [{ kind: 'too_many_skipped', skipped: 3 }] + ); + assert.deepEqual( + rules.validateCriteriaRatings('earbuds', { + sound: 5, + anc: 4.3, + battery: 0, + comfort: 5, + screen: 4, + value: 4, + }), + [ + { kind: 'bad_star', key: 'anc' }, + { kind: 'bad_star', key: 'battery' }, + { kind: 'unknown_key', key: 'screen' }, + ] + ); + assert.deepEqual( + rules.validateCriteriaRatings('services', { + service_quality: 5, + attitude: 4, + speed: 4, + }), + [{ kind: 'missing_required', key: 'price' }] + ); + + // Legacy five keys map per category; a key with no counterpart is dropped + // and nothing is inferred for the new criteria. + const legacy = { + screen: 4.3, + performance: 5, + camera: 4, + battery: 3, + value: 4, + }; + assert.deepEqual(rules.mapLegacyCriteriaRatings('smartphones', legacy), { + display: 4.3, + performance: 5, + camera: 4, + battery: 3, + value: 4, + }); + assert.deepEqual(rules.mapLegacyCriteriaRatings('earbuds', legacy), { + sound: 4.3, + anc: 4, + battery: 3, + value: 4, + }); + assert.deepEqual(rules.mapLegacyCriteriaRatings('smartwatches', legacy), { + display: 4.3, + battery: 3, + value: 4, + }); + assert.deepEqual( + rules.mapLegacyCriteriaRatings('laptops', { screen: 9, value: 'x' }), + {} + ); + + // D5 text: verdict, pros and cons. + const review = { + verdict: 'Hợp người chơi game, ai cần sạc nhanh nên cân nhắc.', + pros: ['Chơi game mượt, ít nóng', 'Ảnh đêm rõ'], + cons: ['Sạc chậm so với máy cùng giá'], + }; + assert.deepEqual(rules.validateReviewText(review), []); + assert.deepEqual( + rules.validateReviewText({ verdict: 'Tốt', pros: [], cons: [] }), + [ + { kind: 'verdict_length', length: 3 }, + { kind: 'points_count', list: 'pros', count: 0 }, + { kind: 'points_count', list: 'cons', count: 0 }, + ] + ); + for (const dodge of [ + 'Không có', + 'không có gì để chê', + 'Chưa thấy', + 'Hiện tại chưa thấy nhược điểm nào', + 'ko co', + 'Không có điểm trừ', + 'None.', + '...', + // Reviews of #1548. + 'Không có nhược điểm nào cả', + 'chua co nhuoc diem nao het', + 'Không có gì đáng chê', + 'không chê được gì', + 'Ko có gì để chê nha', + 'không có ạ', + 'Chả có gì', + 'Khum có', + 'kcj', + 'Chưa phát hiện ra', + 'Chưa gặp vấn đề gì', + 'Không có gì để phàn nàn', + 'Mọi thứ đều ổn', + 'no complaints', + '10/10', + 'aaaaa', + 'Khô​ng có', + 'KHÔNG CÓ', + // Round 2 of #1548. + 'Mình không thấy gì để chê', + 'Em chưa thấy gì', + 'Không có gì chê', + 'Không có điểm gì để chê', + 'Không có gì để nói', + 'Không có ý kiến', + 'Rất tốt', + 'Everything is fine', + 'hong co', + ]) { + assert.ok(rules.isEvasiveCon(dodge), dodge); + } + for (const real of [ + 'Giá hơi cao', + 'Không có củ sạc trong hộp', + 'Chưa có bản màu xanh', + 'Không chống nước', + 'Không có jack 3.5mm', + 'Chưa hỗ trợ eSIM', + 'Pin không trâu', + 'Không có NFC', + 'Chưa có gì nổi bật', + 'K có sạc', + // Round 2 of #1548: other diacritics are other words. + 'Hỏng hết', + 'Hỏng luôn', + 'Vẫn hỏng', + 'Không gập', + 'Chưa có lời', + 'Không có lõi', + 'Mọi thứ đều ồn', + 'Chả cá', + ]) { + assert.ok(!rules.isEvasiveCon(real), real); + } + assert.deepEqual( + rules.validateReviewText({ + ...review, + pros: ['Màn đẹp, sáng', 'màn đẹp sáng'], + cons: ['Không có gì', 'Màn đẹp, sáng!'], + }), + [ + { kind: 'duplicate_point', list: 'pros', index: 1 }, + { kind: 'evasive_con', index: 0 }, + { kind: 'pro_and_con', index: 1 }, + ] + ); + assert.deepEqual( + rules.validateReviewText({ + ...review, + pros: ['Tốt', 'x'.repeat(151), 'a', 'b1234', 'c1234', 'd1234'], + }), + [ + { kind: 'points_count', list: 'pros', count: 6 }, + { kind: 'point_length', list: 'pros', index: 0 }, + { kind: 'point_length', list: 'pros', index: 1 }, + { kind: 'point_length', list: 'pros', index: 2 }, + ] + ); + // Reviews of #1548: lines with no words, lengths as the writer sees them + // (three emoji are 3, a decomposed "Pin ổn" is 6), + // diacritics kept when comparing, and malformed bodies that never throw. + assert.deepEqual( + rules.validateReviewText({ + verdict: '.'.repeat(25), + pros: ['!!!!!!', '🔥🔥🔥🔥🔥', 'Màn hình sắc nét'], + cons: ['Màn hình sạc nét'], + }), + [ + { kind: 'no_content', list: 'verdict', index: 0 }, + { kind: 'no_content', list: 'pros', index: 0 }, + { kind: 'no_content', list: 'pros', index: 1 }, + ] + ); + assert.deepEqual( + rules.validateReviewText({ + ...review, + pros: ['😀😀😀', 'Pin ổn'.normalize('NFD')], + }), + [{ kind: 'point_length', list: 'pros', index: 0 }] + ); + assert.deepEqual( + rules.validateReviewText( + null as unknown as Parameters[0] + ), + [ + { kind: 'verdict_length', length: 0 }, + { kind: 'points_count', list: 'pros', count: 0 }, + { kind: 'points_count', list: 'cons', count: 0 }, + ] + ); + + // Reviews of #1548: prototype keys are not categories or ratings, null is a + // skipped criterion, and legacy 0.1-step stars count in the review star. + for (const slug of ['toString', '__proto__', 'constructor']) { + assert.deepEqual( + rules.getRatingCriteria(slug), + rules.getRatingCriteria('smartphones'), + slug + ); + assert.equal(rules.hasRatingCriteria(slug), false, slug); + assert.deepEqual( + rules.validateCriteriaRatings(slug, phone), + [], + `validate ${slug}` + ); + assert.deepEqual( + rules.mapLegacyCriteriaRatings(slug, { value: 5 }), + { value: 5 }, + `legacy ${slug}` + ); + } + assert.equal(rules.hasRatingCriteria('earbuds'), true); + assert.equal(rules.hasRatingCriteria('service'), false); + assert.equal( + rules.weightedReviewStar( + 'smartphones', + Object.create({ value: 5 }) as Record + ), + null + ); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + ...phone, + design: null, + camera: null, + }), + [] + ); + const legacyStars = rules.mapLegacyCriteriaRatings('smartphones', { + screen: 1.3, + performance: 5, + camera: 5, + battery: 5, + value: 5, + }); + // (1.3*15 + 5*20 + 5*20 + 5*20 + 5*15) / 90 = 394.5 / 90 = 4.38. + assert.equal(rules.weightedReviewStar('smartphones', legacyStars), 4.4); + // An oversized con is never a dodge and is checked in linear time. + const started = Date.now(); + assert.ok(!rules.isEvasiveCon('khong ' + 'gi '.repeat(50_000) + 'x')); + assert.ok(Date.now() - started < 1000, 'linear on an oversized con'); +} + function main(): void { console.log('🧪 Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); @@ -1413,6 +1741,10 @@ function main(): void { console.log(' ✅ [9/9] Category spec templates (DOS/Rate#1142) verified'); testLlmReviewSummaryScope(); console.log(' ✅ [10/10] LLM review summary scope (DOS/Rate#1244) verified'); + testRatingCriteriaAndReviewForm(); + console.log( + ' ✅ [11/11] Rating criteria and review form rules (DOS/Rate#1542) 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 ed684a623..3abdc626f 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -11,3 +11,5 @@ export * from './listing-price-floor.js'; export * from './spec-template.js'; export * from './model-key.js'; export * from './review-summary.js'; +export * from './rating-criteria.js'; +export * from './review-form.js'; diff --git a/packages/shared-data/src/rules/rating-criteria.ts b/packages/shared-data/src/rules/rating-criteria.ts new file mode 100644 index 000000000..4baecc992 --- /dev/null +++ b/packages/shared-data/src/rules/rating-criteria.ts @@ -0,0 +1,298 @@ +/** + * Rating criteria per category, with weights (DOS/Rate#1542, design #1531, D1). + * + * A Rate review rates each criterion of its category in stars, and its own + * star is the weighted average of the criteria it rated, as in JReviews. + * The lists are versioned: a review keeps the version it was written with, so + * a later change of weights never rewrites stored reviews. Plain data and + * functions only, like the rest of `rules`. + */ +import type { SpecGroupKey } from './spec-template.js'; + +/** Bump whenever a list, a key or a weight changes. */ +export const RATING_CRITERIA_VERSION = 1; + +export type RatingCriterionKey = + | 'performance' + | 'camera' + | 'battery' + | 'display' + | 'design' + | 'value' + | 'sound' + | 'software' + | 'keyboard' + | 'thermals' + | 'health' + | 'apps' + | 'anc' + | 'comfort' + | 'calls' + | 'service_quality' + | 'price' + | 'attitude' + | 'speed'; + +export interface RatingCriterion { + key: RatingCriterionKey; + label: { vi: string; en: string }; + /** Share of the review star, in percent; a category's weights sum to 100. */ + weight: number; + /** The spec group shown beside the score, when one fits. */ + specGroup: SpecGroupKey | null; + /** A criterion every review must rate ("Đáng tiền", D5). */ + required: boolean; +} + +/** The most criteria a review may leave unrated ("Chưa dùng tới", D5). */ +export const MAX_SKIPPED_CRITERIA = 2; + +const c = ( + key: RatingCriterionKey, + vi: string, + en: string, + weight: number, + specGroup: SpecGroupKey | null, + required = false +): RatingCriterion => ({ key, label: { vi, en }, weight, specGroup, required }); + +const VALUE = c('value', 'Đáng tiền', 'Value for money', 15, null, true); + +const RATING_CRITERIA: Readonly> = { + smartphones: [ + c('performance', 'Hiệu năng', 'Performance', 20, 'hardware'), + c('camera', 'Camera', 'Camera', 20, 'camera'), + c('battery', 'Pin và sạc', 'Battery and charging', 20, 'battery'), + c('display', 'Màn hình', 'Display', 15, 'display'), + c('design', 'Thiết kế và độ bền', 'Design and build', 10, 'design'), + VALUE, + ], + tablets: [ + c('display', 'Màn hình', 'Display', 20, 'display'), + c('performance', 'Hiệu năng', 'Performance', 20, 'hardware'), + c('battery', 'Pin', 'Battery', 15, 'battery'), + c('sound', 'Loa và giải trí', 'Speakers and media', 10, 'multimedia'), + c('software', 'Phần mềm và phụ kiện', 'Software and accessories', 10, null), + c('design', 'Thiết kế', 'Design', 10, 'design'), + VALUE, + ], + laptops: [ + c('performance', 'Hiệu năng', 'Performance', 25, 'hardware'), + c('display', 'Màn hình', 'Display', 15, 'display'), + c('battery', 'Pin', 'Battery', 15, 'battery'), + c('keyboard', 'Bàn phím và touchpad', 'Keyboard and touchpad', 10, null), + c('thermals', 'Tản nhiệt và độ ồn', 'Thermals and noise', 10, 'hardware'), + c('design', 'Thiết kế và độ bền', 'Design and build', 10, 'design'), + VALUE, + ], + smartwatches: [ + c('health', 'Theo dõi sức khoẻ', 'Health tracking', 25, null), + c('battery', 'Pin', 'Battery', 20, 'battery'), + c('design', 'Thiết kế và đeo', 'Design and fit', 15, 'design'), + c( + 'apps', + 'Ứng dụng và kết nối', + 'Apps and connectivity', + 15, + 'connectivity' + ), + c('display', 'Màn hình', 'Display', 10, 'display'), + VALUE, + ], + earbuds: [ + c('sound', 'Chất âm', 'Sound quality', 30, 'multimedia'), + c('anc', 'Chống ồn', 'Noise cancelling', 15, 'multimedia'), + c('battery', 'Pin', 'Battery', 15, 'battery'), + c('comfort', 'Đeo thoải mái', 'Comfort', 15, 'design'), + c('calls', 'Mic và gọi', 'Microphone and calls', 10, 'multimedia'), + VALUE, + ], + // Services (entity_type = 'service'): price is what every review must rate. + services: [ + c('service_quality', 'Chất lượng dịch vụ', 'Service quality', 35, null), + c('price', 'Giá cả', 'Price', 25, null, true), + c('attitude', 'Thái độ phục vụ', 'Staff attitude', 20, null), + c('speed', 'Tốc độ', 'Speed', 20, null), + ], +}; + +/** Categories with their own criteria list. */ +export const RATING_CRITERIA_CATEGORIES: readonly string[] = + Object.keys(RATING_CRITERIA); + +/** + * The criteria of a category, in display order. A category without its own + * list (or none given) rates as a smartphone, the catalog's default category. + */ +export function getRatingCriteria( + categorySlug: string | null | undefined +): readonly RatingCriterion[] { + return ( + ownValue(RATING_CRITERIA, categorySlug) ?? RATING_CRITERIA.smartphones! + ); +} + +/** + * Whether a category has its own criteria list. An API that must not rate a + * new category ("tvs") or a typo ("service") as a phone checks this first. + */ +export function hasRatingCriteria( + categorySlug: string | null | undefined +): boolean { + return ownValue(RATING_CRITERIA, categorySlug) !== undefined; +} + +/** + * A record's own entry: "toString" or "__proto__" as a category slug or a + * rating key never reaches Object.prototype. + */ +function ownValue( + record: Readonly> | null | undefined, + key: string | null | undefined +): T | undefined { + return record && key && Object.prototype.hasOwnProperty.call(record, key) + ? record[key] + : undefined; +} + +/** + * Criterion stars as stored in `rate.reviews.criteria_ratings`. A criterion + * the writer left out ("Chưa dùng tới") is missing, undefined or null. + */ +export type CriteriaRatings = Partial>; + +export type CriteriaRatingsIssue = + | { kind: 'unknown_key'; key: string } + | { kind: 'bad_star'; key: string } + | { kind: 'missing_required'; key: RatingCriterionKey } + | { kind: 'too_many_skipped'; skipped: number }; + +/** A stored star: any number from 1 to 5 (legacy reviews kept 0.1 steps). */ +function isStoredStar(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= 1 && + value <= 5 + ); +} + +/** A star the form gives: 1 to 5 in half steps. */ +function isStar(value: unknown): value is number { + return isStoredStar(value) && Number.isInteger(value * 2); +} + +/** + * What is wrong with a review's criterion stars under D5: a key the category + * does not have, a star outside 1-5 in half steps, a required criterion left + * out, or more than MAX_SKIPPED_CRITERIA criteria left out. An empty list is + * a valid review. + */ +export function validateCriteriaRatings( + categorySlug: string | null | undefined, + ratings: CriteriaRatings +): CriteriaRatingsIssue[] { + const criteria = getRatingCriteria(categorySlug); + const known = new Set(criteria.map((criterion) => criterion.key)); + const issues: CriteriaRatingsIssue[] = []; + const given = ratings && typeof ratings === 'object' ? ratings : {}; + for (const [key, star] of Object.entries(given)) { + if (!known.has(key)) issues.push({ kind: 'unknown_key', key }); + else if (star != null && !isStar(star)) + issues.push({ kind: 'bad_star', key }); + } + let skipped = 0; + for (const criterion of criteria) { + if (ownValue(given, criterion.key) != null) continue; + if (criterion.required) + issues.push({ kind: 'missing_required', key: criterion.key }); + else skipped += 1; + } + if (skipped > MAX_SKIPPED_CRITERIA) + issues.push({ kind: 'too_many_skipped', skipped }); + return issues; +} + +/** + * A review's own star: the weighted average of the criteria it rated, at one + * decimal (design 2.2). Keys the category does not have and stars outside + * 1-5 are ignored; null when nothing valid is rated. Any star from 1 to 5 + * counts, so a legacy review's 0.1-step stars do too: half steps are a rule + * of the form (validateCriteriaRatings), not of stored data. + */ +export function weightedReviewStar( + categorySlug: string | null | undefined, + ratings: CriteriaRatings +): number | null { + let sum = 0; + let weights = 0; + for (const criterion of getRatingCriteria(categorySlug)) { + const star = ownValue(ratings, criterion.key); + if (!isStoredStar(star)) continue; + sum += star * criterion.weight; + weights += criterion.weight; + } + if (weights === 0) return null; + return Math.round((sum * 10) / weights) / 10; +} + +/** The five keys of the first review form, before the per-category lists. */ +export type LegacyCriterionKey = + 'screen' | 'performance' | 'camera' | 'battery' | 'value'; + +// What each legacy key rated per category. The product page labels an +// earbud's "screen" score soundstage and its "camera" score ANC / mic, so +// they map to sound and anc. A key with no counterpart (a tablet's, laptop's +// or watch's camera, a watch's or earbud's performance) maps to nothing and +// its star is dropped. A mapped legacy review can rate fewer criteria than +// validateCriteriaRatings asks of a new one: that check is for new reviews. +const LEGACY_MAP: Readonly< + Record>> +> = { + smartphones: { + screen: 'display', + performance: 'performance', + camera: 'camera', + battery: 'battery', + value: 'value', + }, + tablets: { + screen: 'display', + performance: 'performance', + battery: 'battery', + value: 'value', + }, + laptops: { + screen: 'display', + performance: 'performance', + battery: 'battery', + value: 'value', + }, + smartwatches: { screen: 'display', battery: 'battery', value: 'value' }, + earbuds: { + screen: 'sound', + camera: 'anc', + battery: 'battery', + value: 'value', + }, + services: {}, +}; + +/** + * A review written with the five legacy keys, in the category's keys. A + * criterion the legacy form did not ask stays unrated: nothing is inferred. + * The review API stored legacy stars at 0.1, so any star from 1 to 5 + * carries over as it is. + */ +export function mapLegacyCriteriaRatings( + categorySlug: string | null | undefined, + legacy: Partial> +): CriteriaRatings { + const map = ownValue(LEGACY_MAP, categorySlug) ?? LEGACY_MAP.smartphones!; + const out: CriteriaRatings = {}; + for (const [legacyKey, key] of Object.entries(map)) { + const star = ownValue(legacy, legacyKey); + if (key && isStoredStar(star)) out[key] = star; + } + return out; +} diff --git a/packages/shared-data/src/rules/review-form.ts b/packages/shared-data/src/rules/review-form.ts new file mode 100644 index 000000000..3a4453c36 --- /dev/null +++ b/packages/shared-data/src/rules/review-form.ts @@ -0,0 +1,176 @@ +/** + * The text a Rate review must carry (DOS/Rate#1542, design #1531, D5): a + * verdict, at least one pro and at least one con. The web form, the review + * API and the importer's checks share these rules, so a review rejected in + * one place is rejected everywhere. Plain data and functions only. + */ + +export const REVIEW_VERDICT_MIN = 20; +export const REVIEW_VERDICT_MAX = 300; +export const REVIEW_POINT_MIN = 5; +export const REVIEW_POINT_MAX = 150; +/** Pros and cons each: at least one, at most five. */ +export const REVIEW_POINTS_MAX = 5; +/** Pros and cons of one criterion: at most three each, none required. */ +export const REVIEW_CRITERION_POINTS_MAX = 3; + +export interface ReviewText { + verdict: string; + pros: readonly string[]; + cons: readonly string[]; +} + +export type ReviewTextIssue = + | { kind: 'verdict_length'; length: number } + | { kind: 'points_count'; list: 'pros' | 'cons'; count: number } + | { kind: 'point_length'; list: 'pros' | 'cons'; index: number } + | { kind: 'no_content'; list: 'verdict' | 'pros' | 'cons'; index: number } + | { kind: 'evasive_con'; index: number } + | { kind: 'duplicate_point'; list: 'pros' | 'cons'; index: number } + | { kind: 'pro_and_con'; index: number }; + +const EMPTY = ''; +// Compatibility composition: fullwidth letters ("KHÔNG") fold to plain +// ones and Vietnamese letters stay whole. +const COMPOSED = 'NFKC'; +// Compatibility decomposition, so diacritics split off as marks. +const DECOMPOSED = 'NFKD'; + +/** + * The form two lines are compared in: lower case, no zero-width characters, + * punctuation and symbols as spaces, spaces collapsed. Diacritics stay + * ("sạc" and "sắc" differ, design 5.1). Only for comparing lines; what is + * stored and shown keeps the writer's text. + */ +export function comparablePoint(text: string): string { + const composed = String(text ?? EMPTY).normalize(COMPOSED); + return composed + .replace(/\p{Cf}+/gu, '') + .toLowerCase() + .replace(/[\p{P}\p{S}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** A compared line without diacritics, "đ" as "d": for spotting dodges. */ +function plainPoint(text: string): string { + const decomposed = comparablePoint(text).normalize(DECOMPOSED); + return decomposed.replace(/\p{M}+/gu, '').replace(/đ/g, 'd'); +} + +/** Length in characters as the writer sees them (code points, composed). */ +function visibleLength(text: string): number { + const composed = String(text ?? EMPTY).normalize(COMPOSED); + return [...composed.replace(/\p{Cf}+/gu, '').trim()].length; +} + +/** + * Whether a line has words in it: at least three letters or digits, and + * not one character repeated ("aaaaa", "....", "🔥🔥🔥🔥🔥"). + */ +function hasContent(text: string): boolean { + const letters = comparablePoint(text).match(/[\p{L}\p{N}]/gu) ?? []; + return letters.length >= 3 && new Set(letters).size >= 2; +} + +// Each word of a dodge as typed with its diacritics or without any ("không" or +// "khong"), but never with other ones: "hỏng" (broken), "gập", "lời" and "ồn" +// are real words, not "hông", "gặp", "lỗi" and "ổn" typed loosely. +const anyOf = (words: readonly string[]): string => + `(?:${[...new Set(words.flatMap((word) => [word, plainPoint(word)]))].join('|')})`; + +// A con that dodges the question: "không có", "chưa thấy ạ", "mình không thấy +// gì để chê", "Không có nhược điểm nào cả", "chả có gì", "khum có", "không chê +// được gì", "chưa gặp vấn đề gì", "không có ý kiến", "mọi thứ đều ổn", "none", +// "10/10". A real con, however small ("giá hơi cao", "không có jack 3.5mm", +// "chưa có gì nổi bật", "hỏng luôn"), never matches: the whole line must be +// the dodge. +const EVASIVE_CON = new RegExp( + [ + `^(?:${anyOf(['hiện tại', 'tạm thời', 'đến giờ', 'vẫn', 'thì', 'thực sự', 'thật sự', 'mình', 'em', 'tôi', 'tui', 'anh', 'chị', 'e', 'm'])} )*`, + anyOf([ + 'không', + 'ko', + 'k', + 'kh', + 'khum', + 'chưa', + 'chẳng', + 'chả', + 'hok', + 'hông', + 'hem', + ]), + `(?: ${anyOf(['có', 'thấy', 'gặp', 'phát hiện', 'phát hiện ra', 'chê được', 'chê'])})*`, + `(?: ${anyOf(['gì', 'j', 'nhược điểm', 'điểm trừ', 'khuyết điểm', 'điểm yếu', 'vấn đề', 'lỗi', 'điểm', 'ý kiến'])})*`, + `(?:(?: ${anyOf(['để', 'mà', 'đáng', 'phải', 'cần'])})? ${anyOf(['chê', 'phàn nàn', 'nói', 'kể'])})?`, + `(?: ${anyOf(['cả', 'hết', 'nào', 'luôn', 'ạ', 'nhé', 'nha', 'nhen', 'đâu'])})*$`, + `|^(?:${anyOf(['mọi thứ', 'tất cả', 'all'])} (?:${anyOf(['đều'])} )?${anyOf(['ổn', 'tốt', 'ok', 'good', 'fine'])}`, + `|${anyOf(['kcj', 'no', 'none', 'nothing', 'nothing to complain', 'nothing to complain about', 'no con', 'no cons', 'no complaint', 'no complaints', 'everything is fine', 'n a', '0', 'ok', 'ổn', 'tốt', 'rất tốt', 'tuyệt vời', 'hoàn hảo', 'perfect', '10 10', '5 sao'])})$`, + ].join('') +); + +/** + * Whether a con line says only that there is no con. A dodge is short: a + * line longer than a con may be is never one, which also keeps the check + * linear on an oversized request. + */ +export function isEvasiveCon(text: string): boolean { + const compared = comparablePoint(text); + if (compared.length > REVIEW_POINT_MAX) return false; + return !hasContent(text) || EVASIVE_CON.test(compared); +} + +function checkList( + list: 'pros' | 'cons', + points: readonly string[], + issues: ReviewTextIssue[] +): void { + if (points.length < 1 || points.length > REVIEW_POINTS_MAX) + issues.push({ kind: 'points_count', list, count: points.length }); + const seen = new Set(); + points.forEach((point, index) => { + const length = visibleLength(point); + if (length < REVIEW_POINT_MIN || length > REVIEW_POINT_MAX) + issues.push({ kind: 'point_length', list, index }); + else if (list === 'pros' && !hasContent(point)) + issues.push({ kind: 'no_content', list, index }); + const compared = comparablePoint(point); + if (compared && seen.has(compared)) + issues.push({ kind: 'duplicate_point', list, index }); + seen.add(compared); + }); +} + +function asLines(value: unknown): string[] { + return Array.isArray(value) ? value.map((line) => String(line ?? '')) : []; +} + +/** + * What is wrong with a Rate review's text under D5 (design 4.2): verdict + * length, the number and length of pros and cons, a line with no words, a + * con that dodges, the same line twice in a list, and a line that is both a + * pro and a con. Lengths count characters as the writer sees them, like + * Postgres char_length. An empty result is a valid review; a malformed body + * gives issues and never throws. + */ +export function validateReviewText(review: ReviewText): ReviewTextIssue[] { + const issues: ReviewTextIssue[] = []; + const verdict = String(review?.verdict ?? ''); + const pros = asLines(review?.pros); + const cons = asLines(review?.cons); + const verdictLength = visibleLength(verdict); + if (verdictLength < REVIEW_VERDICT_MIN || verdictLength > REVIEW_VERDICT_MAX) + issues.push({ kind: 'verdict_length', length: verdictLength }); + else if (!hasContent(verdict)) + issues.push({ kind: 'no_content', list: 'verdict', index: 0 }); + checkList('pros', pros, issues); + checkList('cons', cons, issues); + const proLines = new Set(pros.map(comparablePoint)); + cons.forEach((con, index) => { + if (isEvasiveCon(con)) issues.push({ kind: 'evasive_con', index }); + else if (proLines.has(comparablePoint(con))) + issues.push({ kind: 'pro_and_con', index }); + }); + return issues; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ae6d637f..f91e3db04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,10 +16,12 @@ overrides: lodash@>=4.0.0 <4.18.0: ^4.18.1 tmp@<0.2.6: ^0.2.7 source-map-js@>=1.0.0 <1.2.2: ^1.2.2 - fast-uri@>=3.0.0 <3.1.8: ^3.1.8 + fast-uri@>=3.0.0 <3.1.9: ^3.1.9 ajv@>=6.0.0 <6.14.0: ^6.14.0 postcss-selector-parser@<7.1.6: ^7.1.6 js-yaml@<4.1.0: ^4.1.1 + minimatch@^10: ^11.0.1 + glob@^13: ^14.0.3 pnpmfileChecksum: sha256-k+Zvh3fP7lc7vGzTwYKSXCsxpRec9HXBwanW0X8RJO0= @@ -976,6 +978,9 @@ packages: '@types/node': optional: true + '@isaacs/nonrecursive-glob-match@1.0.3': + resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2705,8 +2710,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.8: - resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -2818,8 +2823,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + glob@14.0.3: + resolution: {integrity: sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==} engines: {node: 18 || 20 || >=22} glob@7.2.3: @@ -3317,8 +3322,8 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - minimatch@10.2.6: - resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + minimatch@11.0.3: + resolution: {integrity: sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -3542,8 +3547,8 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + path-scurry@3.0.0: + resolution: {integrity: sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==} engines: {node: 18 || 20 || >=22} picocolors@1.1.1: @@ -4737,6 +4742,8 @@ snapshots: optionalDependencies: '@types/node': 26.6.5 + '@isaacs/nonrecursive-glob-match@1.0.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5115,7 +5122,7 @@ snapshots: dependencies: '@sentry/core': 11.1.0 dotenv: 17.4.2 - glob: 13.0.6 + glob: 14.0.3 magic-string: 0.30.21 oxc-parser: 0.143.0 sentry: 0.44.1 @@ -5493,7 +5500,7 @@ snapshots: '@typescript-eslint/types': 8.71.2 '@typescript-eslint/visitor-keys': 8.71.2 debug: 4.4.3 - minimatch: 10.2.6 + minimatch: 11.0.3 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0 @@ -5704,7 +5711,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.8 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6416,7 +6423,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.8: {} + fast-uri@3.1.9: {} fastq@1.20.3: dependencies: @@ -6538,11 +6545,11 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@13.0.6: + glob@14.0.3: dependencies: - minimatch: 10.2.6 + minimatch: 11.0.3 minipass: 7.1.3 - path-scurry: 2.0.2 + path-scurry: 3.0.0 glob@7.2.3: dependencies: @@ -7002,8 +7009,9 @@ snapshots: mimic-fn@2.1.0: {} - minimatch@10.2.6: + minimatch@11.0.3: dependencies: + '@isaacs/nonrecursive-glob-match': 1.0.3 brace-expansion: 5.0.12 minimatch@3.1.5: @@ -7229,7 +7237,7 @@ snapshots: path-parse@1.0.7: {} - path-scurry@2.0.2: + path-scurry@3.0.0: dependencies: lru-cache: 11.5.3 minipass: 7.1.3 diff --git a/scripts/test-criteria-ratings-proposal.mjs b/scripts/test-criteria-ratings-proposal.mjs new file mode 100644 index 000000000..6288df75a --- /dev/null +++ b/scripts/test-criteria-ratings-proposal.mjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node +// Replays the DOS-Me criteria ratings PROPOSAL (DOS/Rate#1543, phase P2 of #1531) on the +// migration replay stack: a small catalog with criteria reviews, the proposal twice, the rating +// and criteria scores its triggers keep current (review writes, a weight change, a flag that +// leaves the rating), every verification query, the grants, the rollback twice; then the +// proposal BEFORE M0 and M1+M2 (an Item pools its variants' reviews), the M1+M2 rollback under +// it, and the proposal applied again AFTER M1+M2. Nothing touches a real database. +// +// Usage: node scripts/test-criteria-ratings-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 { repoRoot, startRateDb } from './lib/rate-db.mjs'; + +const read = (name) => + readFileSync(path.join(repoRoot, 'Docs', 'Proposals', name), 'utf8'); +const proposal = read('2026-10-31-dos-me-criteria-ratings.sql'); +const rollback = read('2026-10-31-dos-me-criteria-ratings-rollback.sql'); +const verify = read('2026-10-31-dos-me-criteria-ratings-verify.sql'); +const m0 = read('2026-10-29-dos-me-m0-seed-rekey.sql'); +const m1m2 = read('2026-10-28-dos-me-m1-m2-items.sql'); +const m1m2Rollback = read('2026-10-28-dos-me-m1-m2-items-rollback.sql'); + +const container = `rate-criteria-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 expectZero = (file) => + [ + ...file.matchAll( + /^-- (V\d+) [^\n]*expect 0\n(?:--[^\n]*\n)*(SELECT[\s\S]*?;)/gm + ), + ].map(([, id, query]) => ({ id, query })); +const checks = expectZero(verify); +assert.equal(checks.length, 6, 'the verification lists its expect-0 queries'); + +const PHONES = 'c0000000-0000-4000-8000-000000000001'; +const MERCHANT = 'e0000000-0000-4000-8000-000000000001'; +const x128 = 'a0000000-0000-4000-8000-000000000128'; +const x256 = 'a0000000-0000-4000-8000-000000000256'; +const empty = 'a0000000-0000-4000-8000-0000000000ee'; +const listing128 = 'b0000000-0000-4000-8000-000000000128'; +const listing256 = 'b0000000-0000-4000-8000-000000000256'; +const review = (n) => `d0000000-0000-4000-8000-00000000000${n}`; + +try { + const { sql, asRole } = startRateDb({ name: container }); + const checkAll = (when) => { + for (const { id, query } of checks) + assert.equal(sql(query), '0', `${id} ${when}`); + }; + const row = (id) => + sql( + `SELECT coalesce(rating_score::text, '-') || '|' || rating_weight_sum || '|' || criteria_scores::text + FROM rate.master_products WHERE id = '${id}'` + ); + const weighted = (id) => + sql( + `SELECT user_rating_weighted FROM rate.master_products WHERE id = '${id}'` + ); + + 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 + ('${x128}', 'Acme', 'Phone X', 'acme:phone-x:128gb', 'Acme Phone X 128GB', + 'acme-phone-x-128gb', '${PHONES}', 'published', 128, 10000000, 10000000), + ('${x256}', 'Acme', 'Phone X', 'acme:phone-x:256gb', 'Acme Phone X 256GB', + 'acme-phone-x-256gb', '${PHONES}', 'published', 256, 12000000, 12000000), + ('${empty}', 'Acme', 'Phone Y', 'acme:phone-y:128gb', 'Acme Phone Y 128GB', + 'acme-phone-y-128gb', '${PHONES}', 'published', 128, 9000000, 9000000); + INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, price_current, url, cluster_id) + VALUES + ('${listing128}', '${MERCHANT}', 'shopee', 'x128', 'Acme Phone X 128GB', 10000000, + 'https://shopee.vn/x128', '${x128}'), + ('${listing256}', '${MERCHANT}', 'shopee', 'x256', 'Acme Phone X 256GB', 12000000, + 'https://shopee.vn/x256', '${x256}'); + -- On the 128GB: three criteria reviews, one excluded from the rating, one marketplace + -- review with no criteria and weight 2. On the 256GB: one criteria review. + INSERT INTO rate.reviews (id, product_id, rating_star, comment, reviewer_type, criteria_ratings) + VALUES + ('${review(1)}', '${listing128}', 5, 'Màn hình đẹp, pin ổn.', 'user', + '{"display": 5, "value": 4, "Bad Key!": 5, "camera": "5"}'), + ('${review(2)}', '${listing128}', 4, 'Dùng tốt trong tầm giá.', 'user', '{"display": 4, "value": 4}'), + ('${review(3)}', '${listing128}', 3, 'Màn hơi tối ngoài nắng.', 'user', '{"display": 3}'), + ('${review(4)}', '${listing128}', 1, 'Giao sai màu.', 'user', '{"display": 1, "value": 1}'), + ('${review(5)}', '${listing128}', 5, 'Máy chạy mượt.', 'user', '{}'), + ('${review(6)}', '${listing256}', 2, 'Màn ám vàng.', 'user', '{"display": 2}'); + INSERT INTO rate.review_flags (review_id, verdict, include_in_rating, detector, detector_version, input_hash) + VALUES + ('${review(1)}', 'authentic', true, 'replay', 'v1', 'h1'), + ('${review(2)}', 'authentic', true, 'replay', 'v1', 'h2'), + ('${review(3)}', 'authentic', true, 'replay', 'v1', 'h3'), + ('${review(4)}', 'suspicious', false, 'replay', 'v1', 'h4'), + ('${review(5)}', 'authentic', true, 'replay', 'v1', 'h5'), + ('${review(6)}', 'authentic', true, 'replay', 'v1', 'h6'); + UPDATE rate.reviews SET helpfulness_weight = 1 WHERE id <> '${review(5)}'; + UPDATE rate.reviews SET helpfulness_weight = 2 WHERE id = '${review(5)}'; + SELECT rate.refresh_master_product_rating('${x128}'); + SELECT rate.refresh_master_product_rating('${x256}'); + `); + + // Per master (no Items yet). 128GB: weights 1 + 1 + 1 + 2 = 5 (review 4 is excluded), stars + // 5, 4.3 (exact), 3, 5: (3 * 4.2 + 22.3) / 8 = 4.3625, stored 4.363. display: 3 writers, weight 3: + // (12.6 + 12) / 6 = 4.100. value has 2 writers (hidden); a bad key and a string star are not + // criteria. 256GB: (12.6 + 2) / 4 = 3.650, one writer, no criteria. + const per128 = + '4.363|5.00|{"display": {"score": 4.100, "writers": 3, "weight_sum": 3.00}}'; + const per256 = '3.650|1.00|{}'; + + const applyAndCheckPerMaster = (when) => { + sql(proposal); + sql(proposal); + // A criteria review gets its exact star: the reviews trigger refreshes the master. + asRole( + 'rate_worker', + `UPDATE rate.reviews SET rating_exact = 4.3 WHERE id = '${review(2)}'` + ); + assert.equal(row(x128), per128, `128GB ${when}`); + assert.equal(row(x256), per256, `256GB ${when}`); + assert.equal(row(empty), '-|0.00|{}', `a master with no reviews ${when}`); + assert.equal( + weighted(x128), + '4.40', + `user_rating_weighted untouched ${when}` + ); + checkAll(`after applying ${when}`); + + // A flag that takes a review out of the rating refreshes it, and back. + sql( + `UPDATE rate.review_flags SET include_in_rating = false WHERE review_id = '${review(3)}'` + ); + assert.equal( + row(x128), + '4.557|4.00|{}', + `review 3 out of the rating ${when}` + ); + sql( + `UPDATE rate.review_flags SET include_in_rating = true WHERE review_id = '${review(3)}'` + ); + assert.equal(row(x128), per128, `review 3 back ${when}`); + + for (const fn of [ + `rate.refresh_master_rating_extras('${x128}')`, + `rate.master_rating_extras('${x128}')`, + ]) { + assert.throws( + () => asRole('anon', `SELECT * FROM ${fn}`), + /permission denied/, + `anon ${fn}` + ); + assert.throws( + () => asRole('rate_worker', `SELECT * FROM ${fn}`), + /permission denied/, + `worker ${fn}` + ); + } + assert.equal( + asRole('anon', 'SELECT count(*) FROM rate.review_points'), + '0', + `anon reads review_points ${when}` + ); + for (const role of ['anon', 'authenticated', 'rate_worker']) + assert.throws( + () => + asRole( + role, + `INSERT INTO rate.review_points (review_id, kind, body) VALUES ('${review(1)}', 'pro', 'Pin trâu')` + ), + /permission denied|row-level security/, + `${role} cannot write review_points ${when}` + ); + assert.throws( + () => + asRole( + 'anon', + `INSERT INTO rate.product_points (master_id, kind, label) VALUES ('${x128}', 'pro', 'Pin tốt')` + ), + /permission denied|row-level security/, + `anon cannot write product_points ${when}` + ); + asRole( + 'rate_worker', + `INSERT INTO rate.product_points (master_id, kind, label, support, reviewer_count) + VALUES ('${x128}', 'pro', 'Màn hình đẹp', 2, 2)` + ); + assert.throws( + () => + sql( + `INSERT INTO rate.review_points (review_id, kind, body) VALUES ('${review(1)}', 'pro', 'ok')` + ), + /review_points_body_check/, + `a pro line under 5 characters is refused ${when}` + ); + // A weight above 3 counts as 3: review 5 at weight 9 equals review 5 at weight 3. + sql( + `UPDATE rate.reviews SET helpfulness_weight = 9 WHERE id = '${review(5)}'` + ); + const capped = row(x128); + sql( + `UPDATE rate.reviews SET helpfulness_weight = 3 WHERE id = '${review(5)}'` + ); + assert.equal(row(x128), capped, `weight capped at 3 ${when}`); + sql( + `UPDATE rate.reviews SET helpfulness_weight = 2 WHERE id = '${review(5)}'` + ); + assert.equal(row(x128), per128, `weight restored ${when}`); + }; + + const rollBackAndCheck = (when) => { + sql(rollback); + sql(rollback); + assert.equal( + sql(`SELECT count(*) FROM information_schema.columns + WHERE table_schema = 'rate' + AND column_name IN ('rating_exact', 'rating_score', 'criteria_scores')`), + '0', + `columns dropped ${when}` + ); + assert.equal( + sql( + `SELECT count(*) FROM pg_trigger WHERE tgname LIKE 'trg_%rating_extras%'` + ), + '0', + `triggers dropped ${when}` + ); + assert.equal( + sql(`SELECT to_regclass('rate.review_points') IS NULL AND to_regclass('rate.product_points') IS NULL + AND to_regprocedure('rate.refresh_master_rating_extras(uuid)') IS NULL`), + 't', + `tables and functions dropped ${when}` + ); + asRole( + 'rate_worker', + `SELECT rate.refresh_master_product_rating('${x128}')` + ); + sql(`INSERT INTO rate.reviews (id, product_id, rating_star, comment, reviewer_type) + VALUES ('d0000000-0000-4000-8000-0000000000ff', '${listing256}', 4, 'Ổn.', 'user'); + DELETE FROM rate.reviews WHERE id = 'd0000000-0000-4000-8000-0000000000ff';`); + }; + + // 1. On the reference stack. + applyAndCheckPerMaster('on the reference stack'); + rollBackAndCheck('on the reference stack'); + + // 2. The proposal first, then M0 and M1+M2: the two sizes become one Item, and both + // variants show the Item's pooled rating. Weights 5 + 1 = 6, stars 22.3 + 2: + // (12.6 + 24.3) / 9 = 4.100. display: 4 writers, weight 4: (12.6 + 14) / 7 = 3.800. + applyAndCheckPerMaster('before M1+M2'); + sql(m0); + sql(m1m2); + const pooled = + '4.100|6.00|{"display": {"score": 3.800, "writers": 4, "weight_sum": 4.00}}'; + assert.equal( + sql( + `SELECT count(DISTINCT item_id) FROM rate.master_products WHERE id IN ('${x128}', '${x256}')` + ), + '1', + 'the two sizes share one Item' + ); + assert.equal(row(x128), pooled, '128GB shows the Item rating'); + assert.equal(row(x256), pooled, '256GB shows the Item rating'); + checkAll('after M1+M2 on top'); + // A vote's weight change on the Item path (refresh_item_rating, not the master refresh): + // review 5 at weight 1: weights 5, stars 5 + 4.3 + 3 + 5 + 2 = 19.3, (12.6 + 19.3) / 8 = 3.9875, stored 3.988. + sql( + `UPDATE rate.reviews SET helpfulness_weight = 1 WHERE id = '${review(5)}'` + ); + asRole('rate_worker', `SELECT rate.refresh_master_product_rating('${x256}')`); + const pooledAfterVote = + '3.988|5.00|{"display": {"score": 3.800, "writers": 4, "weight_sum": 4.00}}'; + assert.equal(row(x128), pooledAfterVote, '128GB after the vote'); + assert.equal(row(x256), pooledAfterVote, '256GB after the vote'); + checkAll('after a vote on the Item path'); + // A review on an Item listing with no variant (cluster_id NULL) refreshes the Item too: + // weights 5 + 1 = 6, stars 19.3 + 4: (12.6 + 23.3) / 9 = 3.989. + const noVariant = 'b0000000-0000-4000-8000-0000000000a0'; + const noVariantReview = 'd0000000-0000-4000-8000-0000000000a0'; + sql(` + INSERT INTO rate.products (id, merchant_id, platform, platform_item_id, title, price_current, url, + cluster_id, item_id) + SELECT '${noVariant}', '${MERCHANT}', 'shopee', 'xnv', 'Acme Phone X', 11000000, + 'https://shopee.vn/xnv', NULL, item_id + FROM rate.master_products WHERE id = '${x128}'; + INSERT INTO rate.reviews (id, product_id, rating_star, comment, reviewer_type) + VALUES ('${noVariantReview}', '${noVariant}', 4, 'Máy ổn.', 'user'); + INSERT INTO rate.review_flags (review_id, verdict, include_in_rating, detector, detector_version, input_hash) + VALUES ('${noVariantReview}', 'authentic', true, 'replay', 'v1', 'hnv'); + UPDATE rate.reviews SET helpfulness_weight = 1 WHERE id = '${noVariantReview}'; + `); + assert.equal( + sql( + `SELECT cluster_id IS NULL FROM rate.products WHERE id = '${noVariant}'` + ), + 't', + 'the listing has no variant' + ); + const withNoVariant = + '3.989|6.00|{"display": {"score": 3.800, "writers": 4, "weight_sum": 4.00}}'; + assert.equal(row(x128), withNoVariant, '128GB counts the no-variant review'); + assert.equal(row(x256), withNoVariant, '256GB counts the no-variant review'); + // A re-crawl that rewrites the review unchanged refreshes nothing; V6 stays 0 either way. + sql( + `UPDATE rate.reviews SET comment = comment WHERE id = '${noVariantReview}'` + ); + checkAll('with a no-variant review'); + sql(`DELETE FROM rate.reviews WHERE id = '${noVariantReview}'; + DELETE FROM rate.products WHERE id = '${noVariant}';`); + assert.equal(row(x128), pooledAfterVote, '128GB after the review is deleted'); + + // 3. The M1+M2 rollback under the proposal: the triggers stay and the 128GB, whose review + // changes, goes back to its own rating. The 256GB keeps the pooled values until something + // touches it: V6 shows it, and the runbook's step after an M1+M2 rollback (the backfill of + // section 8 again) brings it back. + sql(m1m2Rollback); + sql( + `UPDATE rate.reviews SET helpfulness_weight = 2 WHERE id = '${review(5)}'` + ); + assert.equal(row(x128), per128, '128GB after the M1+M2 rollback'); + const stale = checks.find(({ id }) => id === 'V6'); + assert.equal(sql(stale.query), '1', 'V6 shows the 256GB as out of date'); + const backfill = proposal.slice(proposal.lastIndexOf('BEGIN;')); + assert.match(backfill, /refresh_master_rating_extras/, 'the backfill step'); + sql(backfill); + assert.equal(row(x256), per256, '256GB after the M1+M2 rollback'); + checkAll('after the M1+M2 rollback'); + + // 4. M1+M2 again, then the proposal applied (again) after it: same pooled values. + sql(m1m2); + sql(proposal); + assert.equal(row(x128), pooled, '128GB, proposal after M1+M2'); + assert.equal(row(x256), pooled, '256GB, proposal after M1+M2'); + checkAll('with the proposal applied after M1+M2'); + rollBackAndCheck('after M1+M2'); + + console.log( + 'criteria ratings proposal: apply, triggers, Items, verify and rollback replayed' + ); +} finally { + removeContainer(); +}