diff --git a/Docs/Proposals/2026-10-16-reviewer-graph-phase2.html b/Docs/Proposals/2026-10-16-reviewer-graph-phase2.html new file mode 100644 index 00000000..fb8df274 --- /dev/null +++ b/Docs/Proposals/2026-10-16-reviewer-graph-phase2.html @@ -0,0 +1,173 @@ + + + + + + + Đề xuất Phase 2: Lưu reviewer và đồ thị reviewer + + + +
+ Cần JOY duyệt + Migration DOS.Me +

Phase 2: Lưu reviewer và dựng đồ thị reviewer

+

16/10/2026 · Agent: Core · Tiếp nối DOS/Rate#113 và DOS/Rate-Services#17

+ +
+

Tóm tắt

+ +
+ +

1. Hiện trạng sau Phase 1

+

Mỗi sàn công khai một phần khác nhau về người viết. Bảng dưới là những gì scraper đang lấy:

+
+ + + + + + +
SànĐịnh danh ổn địnhHồ sơNgày tham giaSố reviewLượt hữu íchĐã muaMua lại
Shopeeuserid (trừ review ẩn danh)trang shop của người muakhôngkhônglike trên reviewluôn đúng (chỉ người mua được đánh giá)có
Tikicreated_by.idkhôngcócócócó, kèm ngày muakhông
Lazada, TikTok Shopkhông công khaikhôngkhôngkhôngkhôngluôn đúngkhông
Chuỗi bán lẻ (TGDĐ, CellphoneS, FPT Shop, Hoàng Hà, Di Động Việt, Phong Vũ)khôngkhôngkhôngkhôngkhôngnhãn "Đã mua tại" ở phần đầu bình luậnkhông
+
+

API của Shopee và Tiki không có tài liệu chính thức. Workflow probe-reviewers trên GitHub Actions sẽ chạy thử trên dữ liệu thật để xác nhận tên trường trước khi dựa vào chúng.

+ +

Nguyên tắc riêng tư đang áp dụng

+ + +

2. Đề xuất migration (DOS.Me)

+

Viết theo quy tắc của DOS.Me: idempotent, chạy qua CI preview, beta rồi prod. Không chạy DDL tay lên prod.

+ +

A. Bảng rate.reviewers (riêng tư)

+
CREATE TABLE IF NOT EXISTS rate.reviewers (
+  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  platform       TEXT NOT NULL,
+  reviewer_key   TEXT NOT NULL CHECK (reviewer_key ~ '^[0-9a-f]{32}$'),
+  profile_url    TEXT,
+  joined_at      TIMESTAMPTZ,
+  total_reviews  INTEGER CHECK (total_reviews IS NULL OR total_reviews >= 0),
+  helpful_votes  INTEGER CHECK (helpful_votes IS NULL OR helpful_votes >= 0),
+  first_seen_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
+  last_seen_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
+  UNIQUE (platform, reviewer_key)
+);
+ +

B. Bảng rate.review_reviewer (riêng tư)

+

Gắn mỗi review với người viết, kèm các dữ kiện theo từng review. reviewer_id được phép rỗng vì Lazada, TikTok Shop và review ẩn danh không có định danh nhưng vẫn có lượt hữu ích hoặc cờ mua lại.

+
CREATE TABLE IF NOT EXISTS rate.review_reviewer (
+  review_id        UUID PRIMARY KEY REFERENCES rate.reviews(id) ON DELETE CASCADE,
+  reviewer_id      UUID REFERENCES rate.reviewers(id) ON DELETE SET NULL,
+  purchased_at     TIMESTAMPTZ,
+  repeat_purchase  BOOLEAN,
+  like_count       INTEGER CHECK (like_count IS NULL OR like_count >= 0)
+);
+CREATE INDEX IF NOT EXISTS idx_rate_review_reviewer_reviewer
+  ON rate.review_reviewer (reviewer_id) WHERE reviewer_id IS NOT NULL;
+
+ALTER TABLE rate.reviewers       ENABLE ROW LEVEL SECURITY;
+ALTER TABLE rate.review_reviewer ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON rate.reviewers, rate.review_reviewer FROM PUBLIC, anon, authenticated;
+GRANT SELECT, INSERT, UPDATE, DELETE ON rate.reviewers, rate.review_reviewer TO service_role;
+

Vì sao tách bảng thay vì thêm cột vào rate.reviews: bảng rate.reviews đang cho anon đọc toàn bộ cột. Thêm khóa reviewer vào đó thì ai cũng gom được mọi review của một người qua nhiều sản phẩm. Bên ngoài chỉ nên thấy kết quả đã tổng hợp (điểm tín hiệu trong review_flags).

+ +

C. RPC tổng hợp gian hàng cho /merchants

+

Hiện getMerchantsList đọc phân trang toàn bộ products và reviews rồi tính trong Node. Cách này đúng (đã sửa lỗi cắt 1000 dòng ở #113) nhưng tốn băng thông khi bảng lớn, và nếu một trang lỗi giữa chừng thì kết quả chỉ có một phần. Đề xuất một hàm SQL rate.merchant_list_stats() trả về mỗi gian hàng: số sản phẩm, giá min/max, số review theo flag_category và include_in_rating. Một truy vấn, thành công hoặc thất bại trọn vẹn. Việc này giải quyết luôn 2 góp ý CodeRabbit còn mở trên #113.

+ +

3. Thuật toán dùng được sau khi có dữ liệu (Phase 3)

+
+ + + + + + +
Tín hiệuCách tínhBắt được gì
Đồng xuất hiện (co-review)Cặp reviewer cùng đánh giá từ 3 sản phẩm trở lên của cùng một shop trong 14 ngàyNhóm tài khoản seeding chạy theo đợt
Độ tập trung vào shopTỉ lệ review của một người dồn vào một shopTài khoản chỉ sống để khen một gian hàng
Lệch điểmTỉ lệ 5 sao trong toàn bộ review của người đó, so với mặt bằng ngành hàngTài khoản khen mọi thứ (dùng kèm tín hiệu khác, không dùng riêng)
Tài khoản mới viết dàyTuổi tài khoản lúc viết thấp nhưng số review caoTài khoản nuôi để đánh giá
+
+

Các ngưỡng trên (3 sản phẩm, 14 ngày) chỉ là điểm xuất phát. Trước khi cho tín hiệu mới ảnh hưởng tới điểm, cần một tập khoảng 200 review được gán nhãn tay để đo độ chính xác, giống nguyên tắc đã áp dụng ở Phase 1: không thêm hình phạt khi chưa có dữ liệu đánh giá. Tín hiệu mới cũng cần mở rộng danh sách khóa hợp lệ trong rate.signals_valid, làm trong migration riêng của Phase 3.

+ +

4. Thứ tự triển khai nếu anh duyệt

+
    +
  1. PR vào DOS.Me (nhánh dev): migration A, B, C. Kiểm tra bằng Supabase Preview trên PR.
  2. +
  3. PR vào DOS/Rate: pipeline ghi reviewers và review_reviewer sau khi lưu review; /merchants chuyển sang gọi RPC.
  4. +
  5. Rate-Services nâng submodule, chạy worker vài chu kỳ để tích dữ liệu.
  6. +
  7. Phase 3: tính tín hiệu đồ thị, gán nhãn tập đánh giá, hiệu chỉnh ngưỡng.
  8. +
+ +

5. Anh cần quyết

+
+
    +
  1. Duyệt migration A, B, C ở mục 2. Đây là thay đổi schema dùng chung nên em chưa tự làm.
  2. +
  3. Tạo secret REVIEWER_KEY_SECRET trong repo DOS/Rate-Services (Settings, Secrets and variables, Actions): một chuỗi ngẫu nhiên dài, đặt một lần và không đổi. Đổi secret sẽ làm mất liên kết giữa các lần crawl. Khi chưa có secret, scraper vẫn chạy bình thường nhưng không tạo khóa reviewer.
  4. +
  5. Hai quyết định sản phẩm còn treo từ trước: +
      +
    • Công thức điểm tin cậy dùng max(...) nên điểm chỉ tăng, không giảm khi có bằng chứng xấu mới.
    • +
    • Ngưỡng chênh giá của PriceSpreadAuditor (12% / 2,5 triệu và 5% / 800 nghìn) đang đặt theo cảm tính, nên tính lại từ phân phối giá thật.
    • +
    +
  6. +
+
+
+ +