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 @@ + + +
+ + + +16/10/2026 · Agent: Core · Tiếp nối DOS/Rate#113 và DOS/Rate-Services#17
+ +DOS.Me thêm 2 bảng riêng tư (chỉ service_role đọc được) và 1 RPC tổng hợp cho trang /merchants.REVIEWER_KEY_SECRET cho repo Rate-Services.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 định | Hồ sơ | Ngày tham gia | Số review | Lượt hữu ích | Đã mua | Mua lại |
|---|---|---|---|---|---|---|---|
| Shopee | userid (trừ review ẩn danh) | trang shop của người mua | không | không | like trên review | luôn đúng (chỉ người mua được đánh giá) | có |
| Tiki | created_by.id | không | có | có | có | có, kèm ngày mua | không |
| Lazada, TikTok Shop | không công khai | không | không | không | không | luôn đúng | không |
| Chuỗi bán lẻ (TGDĐ, CellphoneS, FPT Shop, Hoàng Hà, Di Động Việt, Phong Vũ) | không | không | không | không | không | nhãn "Đã mua tại" ở phần đầu bình luận | khô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.
REVIEWER_KEY_SECRET thì scraper không tạo khóa. Lý do: id trên sàn là số nguyên, băm bằng hằng số công khai thì dò ngược được.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.
+ +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)
+);
+
+ 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).
/merchantsHiệ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.
| Tín hiệu | Cách tính | Bắ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ày | Nhóm tài khoản seeding chạy theo đợt |
| Độ tập trung vào shop | Tỉ lệ review của một người dồn vào một shop | Tài khoản chỉ sống để khen một gian hàng |
| Lệch điểm | Tỉ lệ 5 sao trong toàn bộ review của người đó, so với mặt bằng ngành hàng | Tà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ày | Tuổi tài khoản lúc viết thấp nhưng số review cao | Tà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.
DOS.Me (nhánh dev): migration A, B, C. Kiểm tra bằng Supabase Preview trên PR.DOS/Rate: pipeline ghi reviewers và review_reviewer sau khi lưu review; /merchants chuyển sang gọi RPC.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.max(...) nên điểm chỉ tăng, không giảm khi có bằng chứng xấu mới.