diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d298b5358..0b4f6967c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,6 +85,9 @@ jobs:
- name: Replay the DOS-Me review value proposal (Docker)
run: pnpm --filter @repo/shared-data build && node scripts/test-review-value-proposal.mjs
+ - name: Replay the DOS-Me review AI points proposal (Docker)
+ run: node scripts/test-review-ai-points-proposal.mjs
+
- name: Check generated rate schema types match the migrations
run: pnpm check:db-types
diff --git a/.gitignore b/.gitignore
index 1e2109638..be909f563 100644
--- a/.gitignore
+++ b/.gitignore
@@ -161,6 +161,9 @@ playwright-report-item-model/
!Docs/Proposals/2026-11-02-dos-me-review-value-score.sql
!Docs/Proposals/2026-11-02-dos-me-review-value-score-rollback.sql
!Docs/Proposals/2026-11-02-dos-me-review-value-score-verify.sql
+!Docs/Proposals/2026-11-03-dos-me-review-ai-points.sql
+!Docs/Proposals/2026-11-03-dos-me-review-ai-points-rollback.sql
+!Docs/Proposals/2026-11-03-dos-me-review-ai-points-verify.sql
* - Copy*
* - Bản sao*
*.dump
diff --git a/Docs/Proposals/2026-11-03-ai-review-points.html b/Docs/Proposals/2026-11-03-ai-review-points.html
new file mode 100644
index 000000000..cca943450
--- /dev/null
+++ b/Docs/Proposals/2026-11-03-ai-review-points.html
@@ -0,0 +1,289 @@
+
+
+
+
+
Đề xuất thiết kế
+
Cần JOY quyết
+
AI trích ưu nhược từ review trên sàn
+
Ngày 03/11/2026. Người soạn: Core (Rate). Thuộc #1531 (sao theo tiêu chí, ưu nhược, điểm giá trị review), mục 4.3 của đề xuất ngày 31/10. Đây chỉ là thiết kế: chưa có code gọi AI, chưa tốn tiền, chưa đụng database.
+
+
0. Tóm tắt trong một phút
+
+ - Việc cần làm. Review lấy từ sàn (Shopee) chỉ có số sao và một đoạn chữ. Bước AI đọc đoạn chữ đó, rút ra vài dòng ưu nhược, gắn mỗi dòng với một tiêu chí của ngành hàng. Kết quả lưu vào
rate.review_points với method = 'ai'.
+ - Mục đích. Các dòng này chỉ dùng cho bước gom ưu nhược của sản phẩm (P5). Chúng không bao giờ hiện như lời người viết. Chúng cũng không sinh sao tiêu chí và không tính vào điểm giá trị review.
+ - Model. Đề xuất Claude Haiku 5.5, rẻ nhất trong các model Claude mới. Trước khi bật, chạy thử trên một bộ 100 review đã chấm tay: nếu Haiku không đạt ngưỡng chất lượng thì chuyển lên Claude Sonnet 5.5.
+ - Chi phí. Khoảng 0,05 đến 0,15 USD cho mỗi 1.000 review với Haiku (dùng Batch API giảm 50%). Kho hiện tại khoảng 250 review: dưới 0,05 USD, một lần. Sonnet 5.5 đắt gấp 20 lần mỗi token: khoảng 1 đến 2,5 USD cho 1.000 review với Batch API, 2 đến 5 USD nếu không dùng Batch.
+ - Mỗi review chỉ xử lý một lần. Mỗi review có một dấu đã xử lý, kèm mã băm nội dung và phiên bản prompt. Chỉ khi chữ của review đổi, hoặc khi đổi prompt, review mới được xử lý lại.
+ - Nơi chạy. Code (prompt, schema, kiểm tra) nằm trong
apps/importer của DOS/Rate, công khai giống job product-points. Rate-Services chỉ lên lịch chạy và giữ khoá API. Job chạy bằng tài khoản rate_worker.
+ - Cần migration DOS-Me nhỏ. Hiện
rate_worker chỉ được đọc review_points. Cần một bảng đánh dấu đã xử lý và một hàm ghi riêng cho dòng 'ai'.
+ - An toàn. Mỗi dòng AI phải kèm câu trích nguyên văn có thật trong review, nếu không thì bị loại. Có bộ kiểm tra tự động, giới hạn tiền mỗi tháng, công tắc tắt ngay, và một lệnh xoá sạch mọi dòng
'ai'.
+
+
Từ ngữ: "token" là đơn vị AI dùng để đếm chữ; tiền tính theo số token đọc vào và viết ra. "Batch API" là cách gửi nhiều yêu cầu một lúc, trả kết quả trong vòng 24 giờ, rẻ hơn 50%. "Prompt caching" là cách để AI nhớ phần hướng dẫn giống nhau giữa các lần gọi; phần nhớ được tính 10% giá.
+
+
1. Review nào được xử lý
+
Chỉ xử lý review có đủ chữ để rút ra một ý thật. Review nào không qua các điều kiện dưới đây thì bỏ qua, không gọi AI và không tốn tiền.
+
+ | Điều kiện | Giá trị đề xuất | Lý do |
+ | Nguồn | origin = 'crawl', reviewer_type = 'user' | Review viết trên Rate đã có ưu nhược do người viết ghi. Review chuyên gia đi theo đường riêng (mục 4.3 của đề xuất gốc). |
+ | Chưa có dòng ưu nhược | Không có dòng method = 'writer' nào | Không ghi đè lời người viết. |
+ | Qua cổng chặn | Có review_flags, include_in_rating không phải false, trọng số lớn hơn 0 (cổng của job product-points, jobs/product-points.ts), và độ sâu không phải "viết cho có" (kiểm tra của job review-values, jobs/review-values.ts) | Review giả, seeding hoặc viết cho có không được tạo ưu nhược. |
+ | Độ dài | Từ 40 ký tự và 8 từ trở lên, sau khi bỏ emoji và ký tự lặp | "Hàng đẹp, giao nhanh" không có ý gì về sản phẩm. Ngưỡng này loại phần lớn review một câu. |
+ | Ngôn ngữ | Tiếng Việt hoặc tiếng Anh (đoán bằng dấu tiếng Việt và từ thông dụng, không gọi AI) | Các dòng ưu nhược hiện bằng tiếng Việt. Review ngôn ngữ khác rất hiếm, bỏ qua để giữ prompt đơn giản. |
+ | Ngành hàng | Listing đã gắn với một sản phẩm gốc, và ngành hàng có danh sách tiêu chí | Phải biết danh sách tiêu chí để gắn dòng. Ngành "dịch vụ" chưa mở. |
+
+
Các ý chỉ nói về giao hàng, đóng gói, thái độ shop thì không lấy: đó là chuyện của người bán, đã có mô hình tin cậy người bán lo.
+
+
2. Prompt và dạng kết quả
+
2.1 Gửi gì cho AI
+
+ - Phần hướng dẫn cố định (được cache): vai trò, luật trích, danh sách tiêu chí của ngành hàng (khoá, nhãn tiếng Việt, một dòng giải thích mỗi tiêu chí, lấy từ
packages/shared-data/src/rules/rating-criteria.ts), và 4 đến 6 ví dụ mẫu. Mỗi ngành hàng một bản, có số phiên bản.
+ - Phần thay đổi: tối đa 20 review của cùng một ngành hàng. Mỗi review gồm một số thứ tự tạm và đoạn chữ. Không gửi tên, ảnh đại diện, mã người dùng, mã đơn hàng hay đường link.
+
+
2.2 Luật trích (tóm tắt prompt)
+
+ - Chỉ lấy điều review nói ra. Không suy ra từ số sao, không đoán.
+ - Mỗi review tối đa 3 ưu và 3 nhược. Không có gì rõ ràng thì trả danh sách rỗng; danh sách rỗng là kết quả bình thường.
+ - Mỗi dòng dài 5 đến 150 ký tự, viết lại gọn bằng tiếng Việt, giữ đúng ý và đúng mức độ ("pin hơi yếu" không thành "pin rất tệ").
+ - Mỗi dòng có
criterion_key thuộc danh sách của ngành hàng, hoặc null nếu là ý chung.
+ - Mỗi dòng có
evidence: một đoạn trích nguyên văn ngắn từ review chứa ý đó.
+ - Bỏ qua chuyện giao hàng, đóng gói, người bán, khuyến mãi.
+
+
2.3 Dạng kết quả (structured output, JSON có schema)
+
Dùng tính năng structured outputs của Claude API: kết quả luôn đúng schema, không cần đoán cách đọc chữ.
+
{
+ "reviews": [
+ {
+ "ref": 3,
+ "points": [
+ { "kind": "pro", "criterion_key": "battery",
+ "body": "Pin dùng hết ngày với nhu cầu vừa phải",
+ "evidence": "pin xài cả ngày vẫn còn" },
+ { "kind": "con", "criterion_key": "screen",
+ "body": "Màn hình khó nhìn ngoài nắng",
+ "evidence": "ra nắng hơi khó nhìn" }
+ ]
+ }
+ ]
+}
+
criterion_key trong schema là một danh sách liệt kê đúng các khoá của ngành hàng, nên AI không thể trả một khoá lạ.
+
+
3. Chọn model
+
+ | Model | Giá đọc vào / viết ra (USD mỗi 1 triệu token) | Nhận xét |
+ Claude Haiku 5.5 (claude-haiku-5-5) | 0,10 / 0,50 | Rẻ nhất. Việc này đơn giản: đọc một đoạn ngắn, rút ý có trích dẫn. Đề xuất dùng mặc định. |
+ Claude Sonnet 5.5 (claude-sonnet-5-5) | 2 / 10 | Dự phòng nếu Haiku không đạt ngưỡng ở mục 7 (ví dụ hiểu sai tiếng lóng, viết tắt). |
+ Claude Opus 5.5 (claude-opus-5-5) | 4 / 20 | Không cần cho việc này. |
+
+
Giá theo bảng giá API của Anthropic. Batch API giảm 50% cả hai chiều. Phần hướng dẫn cache được tính khoảng 10% giá đọc vào; lần ghi cache đầu tính 125%. Haiku 5.5 mặc định có suy nghĩ ở mức medium. Đề xuất đặt mức low: việc này không cần suy luận sâu, và token suy nghĩ cũng tính tiền như token viết ra.
+
Gọi qua đâu. Importer hiện gọi AI qua chuẩn OpenAI (biến AI_PROVIDER, cổng dos-ai). Đề xuất bước này gọi thẳng Claude API bằng SDK chính thức của Anthropic, vì cần structured outputs, Batch API và prompt caching. Khoá API chỉ nằm trong secret của Rate-Services. Câu hỏi này để JOY quyết ở D2.
+
+
4. Gửi theo lô và chỉ xử lý một lần
+
+ - Gom lô: mỗi yêu cầu chứa tối đa 20 review cùng ngành hàng. Phần hướng dẫn của ngành hàng (khoảng 1.500 token) dùng chung, nên được cache: các yêu cầu sau chỉ trả 10% cho phần này. Tối thiểu để cache trên Haiku 5.5 là 512 token, phần hướng dẫn vượt mức này.
+ - Batch API: mỗi lần chạy gửi một batch, chờ kết quả, rồi mới ghi. Việc này không cần trả lời ngay.
+ - Dấu đã xử lý: một bảng mới
rate.review_ai_extractions lưu cho mỗi review:
+
+ - mã băm của đoạn chữ đã gửi;
+ - phiên bản prompt và tên model;
+ - kết quả: đã ghi N dòng, không có ý nào, hoặc bị loại ở bước kiểm tra;
+ - thời điểm xử lý.
+
+ Review có dấu trùng mã băm, trùng phiên bản prompt và trùng phiên bản danh sách tiêu chí thì bỏ qua. Nhờ dấu này, review "không có ý nào" cũng không bị gửi lại.
+ - Không trả tiền hai lần:
+
+ - Chữ được chuẩn hoá Unicode (NFC) trước khi băm.
+ - Hàm ghi lưu các dòng và dấu đã xử lý trong cùng một giao dịch.
+ - Mã batch được lưu lại ngay khi gửi. Job chết giữa chừng thì lần sau lấy kết quả của batch cũ, không gửi lại.
+ - Kết quả bị cắt (
max_tokens) hoặc bị từ chối (refusal) không bao giờ được ghi là "không có ý nào". Review đó được thử lại lần sau, tối đa 2 lần.
+
+
+ - Với Batch API, cache chỉ trúng khi có thể, không chắc chắn. Phần hướng dẫn nhỏ nên chi phí gần như không đổi dù trúng hay trượt.
+ - Khi nào xử lý lại: chữ của review đổi (mã băm khác), hoặc nâng phiên bản prompt. Nâng phiên bản là việc có chủ đích: chạy thử trên bộ mẫu trước, rồi mới xử lý lại cả kho.
+
+
+
5. Ước tính chi phí
+
Giả định:
+
+ - Một review sàn qua điều kiện ở mục 1 dài trung bình khoảng 200 token. Tiếng Việt có dấu tốn token hơn tiếng Anh nên ước rộng tay.
+ - Mỗi review ra khoảng 120 token kết quả, gồm cả câu trích dẫn.
+ - Cộng thêm token suy nghĩ ở mức
low.
+ - Mỗi yêu cầu 20 review, phần hướng dẫn 1.500 token được cache.
+
+
+ | Model | 1.000 review, giá thường | 1.000 review, Batch API | Kho hôm nay (~250 review) | 5.000 review mới mỗi tháng |
+ | Haiku 5.5 | ~0,10 đến 0,30 USD | ~0,05 đến 0,15 USD | dưới 0,05 USD | ~0,25 đến 0,75 USD/tháng |
+ | Sonnet 5.5 | ~2 đến 5 USD | ~1 đến 2,5 USD | ~0,25 đến 0,6 USD | ~5 đến 12,5 USD/tháng |
+
+
Cách tính, với Haiku 5.5, cho 1.000 review (50 yêu cầu):
+
+ - Đọc vào: khoảng 200.000 token, tức 0,02 USD.
+ - Phần cache: khoảng 75.000 token đọc lại cache, gần như không đáng kể.
+ - Viết ra: khoảng 120.000 token, tức 0,06 USD, cộng phần suy nghĩ.
+
+
Mức tăng hằng tháng phụ thuộc tốc độ crawl. 5.000 review mỗi tháng là giả định rộng tay; phần lớn review sàn bị loại ở mục 1 nên số thật gửi đi sẽ ít hơn.
+
Chi phí thật nhỏ hơn rủi ro chất lượng, nên giới hạn tiền ở mục 8 chủ yếu để chặn lỗi vòng lặp, không phải để tiết kiệm.
+
+
6. Nơi chạy và ranh giới open-core
+
+ - DOS/Rate (công khai), trong
apps/importer:
+
+ - job
review-ai-points, cùng kiểu với product-points và review-values: chạy thử mặc định, cờ --apply mới ghi;
+ - prompt và schema theo ngành hàng;
+ - bộ lọc review ở mục 1 và bộ kiểm tra ở mục 7;
+ - test chạy bằng dữ liệu giả và một client AI giả. Không commit chữ review thật từ sàn vào repo.
+
+
+ - DOS/Rate-Services (riêng tư): chỉ thêm một lịch chạy, giữ khoá
ANTHROPIC_API_KEY và tài khoản rate_worker. Thứ tự mỗi đêm: crawl xong, rồi review-ai-points, rồi product-points (để gom luôn các dòng mới), rồi review-values. Không chạy trùng giờ crawl. Không có gì của Rate-Services (proxy, scraper, API nội bộ của sàn) đi vào DOS/Rate.
+ - Database:
+
+ - Job đọc bằng
rate_worker.
+ - Job ghi qua một hàm mới
rate.store_review_ai_points. Hàm này chỉ nhận dòng method = 'ai', chỉ cho review từ sàn chưa có dòng của người viết, và kiểm lại độ dài, khoá tiêu chí, số dòng.
+ - Không mở quyền ghi thẳng bảng
review_points cho rate_worker.
+ - Cần migration DOS-Me cho hàm này và bảng
review_ai_extractions. Core soạn đề xuất SQL; JOY duyệt và chạy.
+
+
+ - Gửi gì ra ngoài: chỉ đoạn chữ review công khai trên sàn và danh sách tiêu chí. Không gửi dữ liệu người dùng Rate, không gửi định danh người viết.
+
+
+
7. Kiểm tra chất lượng
+
7.1 Trước khi bật (một lần, trên Beta)
+
+ - Bộ mẫu: 100 review sàn thật, lấy trên Beta, trải đều các ngành hàng. Core chấm tay đáp án: mỗi review có những ưu nhược nào, thuộc tiêu chí nào. Bộ mẫu lưu trong repo riêng tư DOS/Rate-Services, không vào repo công khai, vì là chữ của người khác.
+ - Ngưỡng để bật:
+
+ - ít nhất 95% số dòng AI đúng với review (không bịa);
+ - ít nhất 85% gắn đúng tiêu chí;
+ - ít nhất 70% ý trong đáp án được AI tìm ra;
+ - 0 dòng sinh ra từ review không có ý gì.
+
+
+ - Chạy Haiku trước. Không đạt thì chạy Sonnet. Hai lượt thử tốn dưới 1 USD.
+
+
7.2 Mỗi lần chạy (tự động, trong code)
+
+ evidence phải là một đoạn có thật trong chính review mang số ref đó, không phải trong review khác cùng lô. Cả hai bên được chuẩn hoá Unicode (NFC), đưa về chữ thường, gộp khoảng trắng. Đoạn trích dài ít nhất 2 từ. Không khớp thì loại dòng đó.
+ - Review sàn hay viết không dấu, và AI có thể thêm dấu vào câu trích. Nếu chỉ khớp sau khi bỏ dấu thì vẫn nhận dòng đó, nhưng đếm riêng, không tính vào ngưỡng dừng 10% bên dưới.
+ - Câu trích được lưu cùng dấu đã xử lý (bảng
review_ai_extractions), để lần đọc lại hằng tuần kiểm được từng dòng.
+ - Độ dài 5 đến 150 ký tự. Khoá tiêu chí thuộc ngành hàng. Không trùng dòng. Không có dòng ở cả ưu lẫn nhược.
+ - Dùng lại danh sách câu né của form review ("không có", "chưa thấy") để loại nhược điểm rỗng.
+ - Dừng cả lần chạy nếu hơn 10% số dòng bị loại vì trích dẫn không khớp, hoặc nếu tỷ lệ review có kết quả nhảy quá xa so với lần trước. Hai dấu hiệu này thường do prompt hoặc model đổi.
+
+
7.3 Sau khi chạy
+
+ - Mỗi tuần lấy ngẫu nhiên 20 dòng mới để Core đọc lại. Ghi số dòng sai vào issue theo dõi.
+ - Log của job chỉ có số đếm: đã gửi, đã ghi, đã loại và lý do, token đã dùng. Không ghi chữ review vào log.
+
+
+
8. Công tắc tắt và giới hạn
+
+ - Công tắc dừng gọi AI: biến
REVIEW_AI_POINTS_ENABLED trong lịch chạy của Rate-Services. Đặt false thì job thoát ngay, không gọi AI, và huỷ batch đang chạy dở.
+ - Công tắc ẩn dòng đã ghi: job product-points có tuỳ chọn bỏ qua dòng
'ai'. Bật nó thì lần gom kế tiếp không còn ưu nhược từ AI, mà chưa cần xoá gì.
+ - Giới hạn mỗi lần chạy: tối đa 2.000 review mỗi đêm. Trước khi gửi, job ước tính chi phí tối đa (số yêu cầu nhân giới hạn token viết ra); vượt phần tiền còn lại của tháng thì không gửi.
+ - Giới hạn tiền: theo model: 5 USD mỗi tháng với Haiku 5.5, 15 USD với Sonnet 5.5. Tiền đã tiêu đếm từ số token thật API trả về và lưu trong database (cùng bảng dấu đã xử lý), để mọi lần chạy cùng thấy. Chạm giới hạn thì job dừng và báo.
+ - Xoá sạch: một lệnh của job (
--purge) gọi hàm ghi để xoá mọi dòng 'ai', hoặc chỉ các dòng của một phiên bản prompt, cùng dấu đã xử lý tương ứng. Lần chạy product-points kế tiếp gom lại mà không còn chúng. Không mất lời của người viết nào.
+
+
+
9. Dòng AI trong ngưỡng D9
+
D9 hiện tại (trong packages/shared-data/src/rules/product-points.ts): một ưu hoặc nhược của sản phẩm hiện khi có ít nhất 2 người khác nhau nhắc và tổng trọng số từ 1,5 trở lên, hoặc 1 chuyên gia; luôn giữ ít nhất 1 nhược điểm nếu có. Câu hỏi là dòng AI được tính bao nhiêu.
+
+ | Phương án | Cách tính | Hệ quả |
+ | (a) Như lời người viết | Dòng AI tính đủ trọng số của review | Hiện nhiều ý nhất. Nhưng một lỗi trích của AI có sức nặng bằng lời người thật viết. |
+ | (b) Nửa trọng số | Mỗi review vẫn tính là 1 người viết, nhưng dòng AI chỉ góp 0,5 × min(trọng số sau khi giảm theo tuổi, 1) vào tổng. Nhân 0,5 sau mức trần 1 mỗi người. | Một ý chỉ từ review sàn cần ít nhất 3 review mới đạt 1,5. Nếu nhân trước mức trần, 2 review trọng số cao đã đủ. Lỗi lẻ của AI khó tự tạo thành một ưu nhược của sản phẩm. |
+ | (c) Chỉ để phụ hoạ | Dòng AI chỉ cộng vào ý đã có ít nhất 1 dòng của người viết | An toàn nhất. Nhưng hôm nay gần như không có review Rate nào, nên trang sản phẩm sẽ vẫn trống. |
+
+
Phương án (a) hoặc (b) cần sửa job product-points: bỏ bộ lọc chỉ đọc dòng 'writer', và thêm method vào mỗi dòng đem gom. Câu hiển thị của một cụm hiện lấy từ dòng được nhắc nhiều nhất, nên có thể là câu AI viết. Khi đó khối ưu nhược phải ghi nguồn như dưới đây.
+
Các điều giữ nguyên dù chọn phương án nào:
+
+ - Dòng AI không sinh sao tiêu chí. Ngưỡng 3 người viết cho điểm tiêu chí chỉ đếm sao do người viết chấm.
+ - Dòng AI không tính vào điểm giá trị review: code của P6 đã chỉ đọc dòng
'writer'.
+ - Thẻ review không hiện dòng AI như lời người viết.
+ - Khối ưu nhược của sản phẩm ghi rõ nguồn, ví dụ "Tóm tắt từ 40 review, trong đó 35 review trên sàn".
+
+
+
10. Các quyết định cần JOY
+
D1. Model. Haiku 5.5 làm mặc định, chuyển lên Sonnet 5.5 nếu không đạt ngưỡng ở 7.1.
+
Đề xuất: duyệt. Chi phí cả hai đều nhỏ; chất lượng quyết định bằng bộ mẫu, không bằng đoán.
+
D2. Gọi AI qua đâu. (a) Thẳng Claude API bằng SDK Anthropic, khoá trong Rate-Services; (b) qua cổng
dos-ai kiểu OpenAI như importer đang làm.
+
Đề xuất: (a). Cần structured outputs, Batch API và prompt caching. Qua cổng kiểu OpenAI thì mất hoặc phải tự làm lại các tính năng này.
+
D3. Điều kiện chọn review. Các ngưỡng ở mục 1: từ 40 ký tự và 8 từ, tiếng Việt hoặc tiếng Anh, qua cổng chặn, chưa có dòng của người viết.
+
Đề xuất: duyệt. Chỉnh lại sau lần chạy thử đầu nếu bỏ sót quá nhiều.
+
D4. Trọng số dòng AI trong D9 (mục 9).
+
Đề xuất: (b) nửa trọng số, chốt sau một lần gom thử trên Beta với kho ~250 review: xem (b) và (c) mỗi cách hiện bao nhiêu ưu nhược. Trang sản phẩm có ưu nhược thật từ dữ liệu sàn, nhưng một lỗi lẻ của AI không tự thành ưu nhược của sản phẩm.
+
D5. Giới hạn. 5 USD mỗi tháng với Haiku 5.5 (15 USD nếu phải dùng Sonnet 5.5), 2.000 review mỗi đêm, hai công tắc (dừng gọi AI, ẩn dòng đã ghi) và lệnh xoá sạch.
+
Đề xuất: duyệt.
+
D6. Migration DOS-Me. Bảng
rate.review_ai_extractions và hàm
rate.store_review_ai_points cho
rate_worker. Core soạn SQL đề xuất kèm file kiểm tra, file hoàn tác và replay.
+
Đề xuất: duyệt để Core soạn. Không áp dụng gì khi chưa có lệnh của JOY.
+
+
11. Thứ tự làm
+
+ - Sau khi JOY duyệt: Core soạn đề xuất migration ở D6 (chưa áp dụng).
+ - Viết job trong
apps/importer, test bằng client AI giả. Chưa tốn tiền.
+ - Chạy thử bộ mẫu 100 review trên Beta (dưới 1 USD), báo kết quả theo ngưỡng ở 7.1. Bước này cần JOY cho phép tiêu tiền và cấp khoá API.
+ - Áp dụng migration lên Beta, chạy job một lần ở chế độ chạy thử, rồi
--apply trên kho ~250 review.
+ - Production và lịch chạy hằng đêm trong Rate-Services.
+
+
+
12. Không nằm trong đề xuất này
+
+ - Không có code gọi AI, không tốn tiền, không đổi dữ liệu nào.
+ - Bước AI viết lại cụm ưu nhược của sản phẩm (mục 5.2 của đề xuất gốc) là một việc riêng.
+ - Bỏ phần ưu nhược cũ ở cấp listing (
review_analysis.pros/cons, do ai-review-analyzer.ts sinh) là việc dọn dẹp sau, khi khối mới đã hiện đủ.
+ - Review chuyên gia bên ngoài: chưa có nguồn được phép.
+
+
+
+
diff --git a/Docs/Proposals/2026-11-03-dos-me-review-ai-points-rollback.sql b/Docs/Proposals/2026-11-03-dos-me-review-ai-points-rollback.sql
new file mode 100644
index 000000000..7ef3fd4f0
--- /dev/null
+++ b/Docs/Proposals/2026-11-03-dos-me-review-ai-points-rollback.sql
@@ -0,0 +1,20 @@
+-- ROLLBACK the PROPOSAL 2026-11-03-dos-me-review-ai-points.sql (DOS/Rate#1531, AI design D6).
+-- Safe to run twice. Stop the review AI points job first (REVIEW_AI_POINTS_ENABLED=false in the
+-- Rate-Services schedule), and cancel any open batch, so nothing writes while this runs.
+--
+-- It deletes every 'ai' line of rate.review_points (the job can extract them again after a
+-- re-apply, at the cost of the model calls), then the two functions and the two tables. Writer
+-- lines are not touched. The next product-points run aggregates without the 'ai' lines.
+-- The batches table holds the month's spend: copy it first if the record is wanted.
+
+BEGIN;
+
+DELETE FROM rate.review_points WHERE method = 'ai';
+
+DROP FUNCTION IF EXISTS rate.store_review_ai_points(jsonb);
+DROP FUNCTION IF EXISTS rate.purge_review_ai_points(text);
+DROP FUNCTION IF EXISTS rate.review_ai_text_hash(text);
+DROP TABLE IF EXISTS rate.review_ai_extractions;
+DROP TABLE IF EXISTS rate.review_ai_batches;
+
+COMMIT;
diff --git a/Docs/Proposals/2026-11-03-dos-me-review-ai-points-verify.sql b/Docs/Proposals/2026-11-03-dos-me-review-ai-points-verify.sql
new file mode 100644
index 000000000..d8c51f800
--- /dev/null
+++ b/Docs/Proposals/2026-11-03-dos-me-review-ai-points-verify.sql
@@ -0,0 +1,108 @@
+-- VERIFY the PROPOSAL 2026-11-03-dos-me-review-ai-points.sql (DOS/Rate#1531, AI design D6).
+-- Read-only. Run after the migration on preview, beta and production, and after each run of
+-- the review AI points job. V1 to V7 return 0; V8 is information. Run as postgres or
+-- service_role (V1 calls a function only service_role and rate_worker may execute).
+
+-- V1 the text hash is sha256 of the NFC text: a decomposed and a composed "Pin tốt" hash the
+-- same, and the empty text is sha256 of nothing; expect 0
+SELECT count(*) FROM (VALUES
+ (rate.review_ai_text_hash(NULL), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'),
+ (rate.review_ai_text_hash(E'Pin tốt'), rate.review_ai_text_hash(E'Pin tốt'))
+) AS t(got, expected)
+WHERE t.got IS DISTINCT FROM t.expected;
+
+-- V2 the objects and grants: anon and authenticated cannot read the two tables or call the
+-- functions; rate_worker reads both tables, writes batches but not markers, and still has no
+-- write on rate.review_points; the functions are security definer with an empty search_path;
+-- expect 0
+SELECT count(*) FROM (
+ SELECT c.relname::text AS problem FROM pg_class c
+ WHERE c.oid IN ('rate.review_ai_batches'::regclass, 'rate.review_ai_extractions'::regclass)
+ AND NOT c.relrowsecurity
+ UNION ALL
+ SELECT p.proname::text FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
+ WHERE n.nspname = 'rate' AND p.proname IN ('store_review_ai_points', 'purge_review_ai_points')
+ AND NOT (p.prosecdef AND p.proconfig @> ARRAY['search_path=""'])
+ UNION ALL
+ SELECT r.role || ' ' || t.tab FROM unnest(ARRAY['anon', 'authenticated']) AS r(role)
+ CROSS JOIN unnest(ARRAY['rate.review_ai_batches', 'rate.review_ai_extractions']) AS t(tab)
+ WHERE has_table_privilege(r.role, t.tab, 'SELECT')
+ OR has_table_privilege(r.role, t.tab, 'INSERT')
+ OR has_table_privilege(r.role, t.tab, 'UPDATE')
+ OR has_table_privilege(r.role, t.tab, 'DELETE')
+ UNION ALL
+ SELECT r.role || ' ' || f.fn FROM unnest(ARRAY['anon', 'authenticated']) AS r(role)
+ CROSS JOIN unnest(ARRAY['rate.store_review_ai_points(jsonb)', 'rate.purge_review_ai_points(text)',
+ 'rate.review_ai_text_hash(text)']) AS f(fn)
+ WHERE has_function_privilege(r.role, f.fn, 'EXECUTE')
+ UNION ALL
+ SELECT 'rate_worker grants' WHERE NOT (
+ has_table_privilege('rate_worker', 'rate.review_ai_batches', 'SELECT')
+ AND has_table_privilege('rate_worker', 'rate.review_ai_batches', 'INSERT')
+ AND has_table_privilege('rate_worker', 'rate.review_ai_batches', 'UPDATE')
+ AND NOT has_table_privilege('rate_worker', 'rate.review_ai_batches', 'DELETE')
+ AND has_table_privilege('rate_worker', 'rate.review_ai_extractions', 'SELECT')
+ AND NOT has_table_privilege('rate_worker', 'rate.review_ai_extractions', 'INSERT')
+ AND NOT has_table_privilege('rate_worker', 'rate.review_ai_extractions', 'UPDATE')
+ AND NOT has_table_privilege('rate_worker', 'rate.review_ai_extractions', 'DELETE')
+ AND NOT has_table_privilege('rate_worker', 'rate.review_points', 'INSERT')
+ AND NOT has_table_privilege('rate_worker', 'rate.review_points', 'UPDATE')
+ AND NOT has_table_privilege('rate_worker', 'rate.review_points', 'DELETE')
+ AND has_function_privilege('rate_worker', 'rate.store_review_ai_points(jsonb)', 'EXECUTE')
+ AND has_function_privilege('rate_worker', 'rate.purge_review_ai_points(text)', 'EXECUTE'))
+) s;
+
+-- V3 every 'ai' line is on a crawled user review with a marker, and no review has both writer
+-- and 'ai' lines; expect 0
+SELECT count(*) FROM rate.review_points p
+JOIN rate.reviews r ON r.id = p.review_id
+LEFT JOIN rate.review_ai_extractions x ON x.review_id = p.review_id
+WHERE p.method = 'ai'
+ AND (x.review_id IS NULL
+ OR r.origin IS DISTINCT FROM 'crawl' OR r.reviewer_type IS DISTINCT FROM 'user'
+ OR EXISTS (SELECT 1 FROM rate.review_points w
+ WHERE w.review_id = p.review_id AND w.method = 'writer'));
+
+-- V4 each marker counts the review's 'ai' lines and holds one evidence quote per line; expect 0
+SELECT count(*) FROM rate.review_ai_extractions x
+LEFT JOIN (SELECT review_id, count(*) AS n FROM rate.review_points WHERE method = 'ai'
+ GROUP BY review_id) l ON l.review_id = x.review_id
+WHERE x.point_count <> COALESCE(l.n, 0)
+ OR jsonb_array_length(x.evidence) <> x.point_count;
+
+-- V5 at most 3 pros and 3 cons per review, and every criterion key in the criteria list the
+-- marker was processed with (its version and category; a review whose master moved keeps its
+-- keys until the job processes it again); expect 0
+SELECT count(*) FROM (
+ SELECT review_id FROM rate.review_points WHERE method = 'ai'
+ GROUP BY review_id, kind HAVING count(*) > 3
+ UNION ALL
+ SELECT p.review_id FROM rate.review_points p
+ JOIN rate.review_ai_extractions x ON x.review_id = p.review_id
+ WHERE p.method = 'ai' AND p.criterion_key IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM rate.rating_criteria k
+ WHERE k.criteria_version = x.criteria_version
+ AND k.category_slug = x.category_slug AND k.key = p.criterion_key)
+) s;
+
+-- V6 no stored batch without its reported cost, and no batch left open for more than 48 hours
+-- (the Batch API ends every batch within 24); expect 0
+SELECT count(*) FROM rate.review_ai_batches
+WHERE (status = 'stored' AND cost_usd IS NULL)
+ OR (status IN ('submitted', 'ended') AND submitted_at < now() - interval '48 hours');
+
+-- V7 no marker points at a batch of another model or prompt version; expect 0
+SELECT count(*) FROM rate.review_ai_extractions x
+JOIN rate.review_ai_batches b ON b.id = x.batch_id
+WHERE b.model <> x.model OR b.prompt_version <> x.prompt_version;
+
+-- V8 information: markers by prompt version and outcome, and this month's spend per model
+SELECT 'marker' AS what, prompt_version AS key, outcome AS detail, count(*)::numeric AS n
+FROM rate.review_ai_extractions GROUP BY prompt_version, outcome
+UNION ALL
+SELECT 'spend', model, to_char(date_trunc('month', now()), 'YYYY-MM'),
+ sum(COALESCE(cost_usd, reserved_usd))
+FROM rate.review_ai_batches
+WHERE submitted_at >= date_trunc('month', now())
+GROUP BY model
+ORDER BY 1, 2, 3;
diff --git a/Docs/Proposals/2026-11-03-dos-me-review-ai-points.md b/Docs/Proposals/2026-11-03-dos-me-review-ai-points.md
new file mode 100644
index 000000000..e0d1b77aa
--- /dev/null
+++ b/Docs/Proposals/2026-11-03-dos-me-review-ai-points.md
@@ -0,0 +1,172 @@
+# DOS-Me proposal: review AI points, the marker and the writer (D6)
+
+DOS/Rate#1531, D6 of `Docs/Proposals/2026-11-03-ai-review-points.html` (all six decisions
+approved by JOY on 2026-11-03: "Duyệt theo khuyến nghị"). Status: **proposal, nothing
+applied.** It goes through DOS-Me like P2, P3 and P6.
+
+| File | What |
+| ------------------------------------------------- | ------------------------------------------------------- |
+| `2026-11-03-dos-me-review-ai-points.sql` | the migration, one transaction |
+| `2026-11-03-dos-me-review-ai-points-verify.sql` | read-only checks, V1 to V7 return 0 (V8 is information) |
+| `2026-11-03-dos-me-review-ai-points-rollback.sql` | removes the 'ai' lines, the functions and tables |
+| `scripts/test-review-ai-points-proposal.mjs` | replays it all on PostgreSQL in Docker |
+
+## What it does
+
+- **`rate.review_ai_batches`** (new): one row per Batch API batch the job sends.
+ - It holds the batch id, written the moment the batch is created. A run that crashes
+ collects that batch next time instead of sending the reviews again.
+ - The manifest maps each request to its reviews: review ids, refs and text hashes. It
+ never holds review text.
+ - The cost: the ceiling the job reserved before sending, then the cost from the usage the
+ API reported. The month's spend per model is read from here (design section 8).
+ - `rate_worker` reads and inserts it, and updates a batch until it is stored. A stored
+ batch's cost, which the monthly cap reads, is closed to it. No role but `service_role`
+ deletes.
+- **`rate.review_ai_extractions`** (new): the process-once marker, one row per review.
+ - The key: the NFC text hash, prompt version, criteria version and category it was
+ processed with, plus the model. A review is due again when any part of the key changes,
+ including a move of its master to another category.
+ - The outcome: `stored`, `empty` (the model found nothing), `rejected` (every line it
+ returned failed the job's checks) or `failed` (cut off, refused or errored).
+ - A failed result is never recorded as `empty`: the writer requires the `failed` flag on
+ every row. It counts attempts; the writer refuses a fourth attempt with the same key, and
+ the table holds at most 3.
+ - The prompt version that wrote the review's lines. A failed retry under a new version keeps
+ the old lines, and the purge of the old version still finds them.
+ - The evidence quote of each line, for the weekly read-back (design 7.2).
+ - `rate_worker` reads it. Only the two functions write it.
+- **`rate.review_ai_text_hash(text)`**: sha256 hex of the text in NFC. The job computes the
+ same hash; the replay checks they agree, decomposed diacritics included.
+- **`rate.store_review_ai_points(p_rows)`**, for `rate_worker` and `service_role`.
+ - It writes a review's 'ai' lines and its marker in one transaction.
+ - Up to 500 rows and 2 MB per call. One malformed row refuses the whole call (22023, with
+ the row's index), before anything is written.
+ - Each line is checked:
+ - at most 3 pros and 3 cons;
+ - 5 to 150 characters with words in it, stored as P3 stores a line;
+ - a con that only dodges ("không có gì để chê") is refused;
+ - no line twice, including as both a pro and a con;
+ - the criterion key is null or in the category's criteria list of that version;
+ - an evidence quote of 2 to 300 characters and at least two words.
+ - A batch id must be a row of `rate.review_ai_batches`; an unknown one fails with 23503.
+ - Some reviews are skipped and counted, not refused, so one odd review never stops a night:
+ - `missing`: the review no longer exists;
+ - `not_crawl`: not a crawled user review (a community or expert review);
+ - `writer`: it has a writer line; 'ai' and writer lines never mix;
+ - `stale`: its comment changed since it was sent (the hash differs), or its master is in
+ another category now. The job sends it again;
+ - `unquoted`: a quote marked `exact` is not in the comment, compared as P3 compares lines.
+ Nothing is written and the job reports it. A quote marked `unaccented` (found only
+ without diacritics) is checked by the job alone;
+ - `done`: a failed result for a review that already has a result with the same key. A late
+ or duplicate failure never undoes a result;
+ - `exhausted`: a fourth failed attempt with the same key.
+ - The reviews are locked in id order first, so two calls never interleave on one review.
+- **`rate.purge_review_ai_points(p_prompt_version)`**: the job's `--purge`.
+ - `'all'` removes every 'ai' line and marker. A version removes the markers of that version
+ or holding lines it wrote, with all the 'ai' lines of those reviews; they are processed
+ again on the next run.
+ - There is no default: a bare call does not exist, so nothing is purged by accident. No
+ prompt version may be called `all`.
+ - It locks its reviews in the writer's order, so a purge and a store never leave a line
+ without its marker.
+ - Batches stay, since they hold the month's spend. Writer lines are never touched.
+
+`rate_worker` still has only SELECT on `rate.review_points`. 'ai' lines come only through the
+writer, as writer lines come only through the review RPC.
+
+**Rating impact at apply time: none.** Nothing reads 'ai' lines yet:
+
+- the P2 rating never reads `review_points`;
+- P6 counts only 'writer' lines;
+- product-points reads only 'writer' lines until its D4 change ships.
+
+## Order and dependencies
+
+- **Needs:** P2 (`20261101120000`) and P3 (`20261102090000`, for `rate.rating_criteria` and
+ the line helpers). Both are in production.
+- **After the apply:**
+ - The job (`apps/importer`, a follow-up PR) runs dry by default. Only with `--apply` does
+ it send batches and call the writer, as `rate_worker`.
+ - Rate-Services schedules it after a dry run and holds the API key. Nothing calls a model
+ before that.
+ - Run one job at a time. The batches table is how a second run would see the first one's
+ open batch.
+
+## Decisions
+
+The design's D6 named one table and one function. This proposal adds three things to it:
+
+1. **A second table for batches**, rather than keeping the spend in the marker. One request
+ carries up to 20 reviews, so its cost belongs to the batch, not to each review. A batch also
+ exists before any review has a result, and that is when its id must be kept.
+2. **A purge function**, rather than a purge mode in the writer. Each function then does one
+ thing, and its grant says so.
+3. **`rate_worker` writes the batches table directly.** It is the job's own bookkeeping: it
+ holds no review text, and no page or rating reads it. Its CHECKs keep it well-formed, and a
+ stored batch is closed to it. Until a batch is stored, the worker could lower its reserved
+ cost and so loosen the cap; the cap guards against a looping job, not against the worker's
+ own credentials. The lines and the markers, which pages and the aggregation read, stay
+ behind the writer.
+
+Known limits:
+
+- If the job dies between creating a batch and inserting its row, that batch is paid for but
+ not collected, and its reviews are sent again. The window is one HTTP call, and the cost is
+ at most one night's batch (under $0.20 with Haiku).
+- The database checks an `exact` quote against the comment but not an `unaccented` one: it
+ has no diacritic folding. The job checks both, and counts `unaccented` lines apart.
+- The replay runs one session, so it does not exercise two calls blocking on each other's
+ locks; the lock order is the same as P6's writer.
+
+## Apply
+
+1. Copy the `.sql` into DOS-Me `supabase/migrations/` with a new timestamp, and the verify and
+ rollback files into `scripts/ops/`, as for P6.
+2. After the beta and production applies, run the verify file read-only as `postgres` or
+ `service_role`. V1 to V7 must return 0. Run it again after each job run.
+3. **Rollback:** the rollback file, safe to run twice. Stop the job first
+ (`REVIEW_AI_POINTS_ENABLED=false`) and cancel any open batch.
+ - It deletes every 'ai' line, then the functions and both tables. Writer lines stay.
+ - The batch records, and so the month's spend, are lost: copy them first if wanted.
+ - The job can extract the lines again after a re-apply, at the cost of the model calls.
+
+## Tests (local, Docker)
+
+`node scripts/test-review-ai-points-proposal.mjs`:
+
+- the proposal applied twice; no rating moves;
+- the hash equal to the job's, for decomposed and composed text, emoji and the empty string;
+- a batch row inserted and updated as `rate_worker`;
+- lines and markers stored as `rate_worker`:
+ - positions per kind, the evidence and its match kind;
+ - a review listed twice (the last row wins);
+ - a re-store that replaces the lines, and an empty result that clears them;
+- the skips: a missing review, a community and an expert review, a review with a writer line,
+ a changed comment, a made-up exact quote, a master moved to another category (V5 still 0);
+- failed results:
+ - a late failure over a result of the same key changes nothing (`done`);
+ - under a new key, attempts counted and the old lines kept with their version;
+ - a fourth attempt refused (`exhausted`);
+ - the purge of the old version finding the kept lines;
+- the refusals, where nothing is written:
+ - not an array, too many rows;
+ - a bad id, hash, version (`all` included), model, batch id, flag, count or key;
+ - a row without its `failed` flag, or with a null one;
+ - an unknown criteria list, a key of another category;
+ - a failed row with points, seven lines, four pros;
+ - a short, long, letterless or evasive line, a line both pro and con;
+ - one word of evidence, or too much;
+ - an unknown batch id (23503);
+ - `anon` and `authenticated` calling the writer;
+- direct writes stopped:
+ - `rate_worker` on `rate.review_points` and on the markers;
+ - deleting batches, and updating a stored one;
+ - a stored batch without its cost;
+- the public reading neither table, and reading 'ai' lines like writer lines;
+- the purge of one prompt version, then of everything (`'all'`); a bare call and a bad version
+ refused; writer lines stay;
+- V1 to V7 after each step, and V8;
+- the rollback twice: every object and every 'ai' line gone, writer lines and the rating kept;
+- the proposal again, with a store on it.
diff --git a/Docs/Proposals/2026-11-03-dos-me-review-ai-points.sql b/Docs/Proposals/2026-11-03-dos-me-review-ai-points.sql
new file mode 100644
index 000000000..5c9290237
--- /dev/null
+++ b/Docs/Proposals/2026-11-03-dos-me-review-ai-points.sql
@@ -0,0 +1,496 @@
+-- =============================================================================
+-- PROPOSAL (DOS/Rate#1531, D6 of Docs/Proposals/2026-11-03-ai-review-points.html; all six
+-- decisions approved by JOY on 2026-11-03 as recommended): the database side of the AI
+-- extraction of pros and cons from marketplace reviews (rate.review_points method 'ai'). A
+-- DOS-Me migration to review and apply; nothing here is applied by DOS/Rate.
+-- Verify: 2026-11-03-dos-me-review-ai-points-verify.sql (V1 to V7 return 0).
+-- Rollback: 2026-11-03-dos-me-review-ai-points-rollback.sql.
+--
+-- What it adds:
+-- rate.review_ai_batches one row per Batch API batch the job sends: its id (kept the
+-- moment the batch is created, so a crashed run collects it
+-- instead of paying twice), the manifest (review ids, refs and
+-- text hashes; never review text), the cost reserved before
+-- sending and the cost the API reported. The month's spend per
+-- model is read from here (design section 8). rate_worker reads,
+-- inserts and updates it until it is stored; nobody deletes
+-- through the API.
+-- rate.review_ai_extractions the process-once marker, one row per review: the NFC text hash,
+-- prompt version, criteria version, category and model it was
+-- processed with, the outcome (stored, empty, rejected, failed),
+-- the attempts of a failed result (at most 3), the version that
+-- wrote its lines, and the evidence quote of each line.
+-- rate_worker reads it; only the functions write.
+-- rate.review_ai_text_hash(text) sha256 hex of the text in Unicode NFC; the job hashes the
+-- same way (the replay compares them).
+-- rate.store_review_ai_points(p_rows) the writer for the job (rate_worker) and service_role:
+-- the 'ai' lines of a review and its marker in one transaction.
+-- Only crawled user reviews with no writer lines; at most 3 pros
+-- and 3 cons, lines 5 to 150 characters with words, no evasive
+-- con, no line twice, criterion keys of the review's category
+-- list, an evidence quote per line found in the comment.
+-- rate.purge_review_ai_points(p_prompt_version) removes every 'ai' line and marker ('all'),
+-- or those of one prompt version (the job's --purge).
+--
+-- rate_worker still has only SELECT on rate.review_points: 'ai' lines come only through the
+-- writer, as writer lines come only through the review RPC.
+--
+-- Depends on P2 (20261101120000) and P3 (20261102090000, rate.rating_criteria and the line
+-- helpers); both are in production.
+--
+-- Rating impact at apply time: none. Nothing reads 'ai' lines yet: the P2 rating never read
+-- review_points, P6 counts only 'writer' lines, and product-points reads 'writer' lines until
+-- its 'ai' change ships (D4).
+--
+-- Idempotent: IF NOT EXISTS for tables and indexes, CREATE OR REPLACE for functions, DROP ...
+-- IF EXISTS before each policy. One transaction.
+-- =============================================================================
+
+BEGIN;
+
+-- 1. rate.review_ai_batches --------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS rate.review_ai_batches (
+ -- The Batch API's id (msgbatch_...).
+ id text PRIMARY KEY CHECK (id ~ '^[A-Za-z0-9_-]{8,128}$'),
+ model text NOT NULL CHECK (model ~ '^[a-z0-9][a-z0-9.-]{1,63}$'),
+ prompt_version text NOT NULL CHECK (prompt_version ~ '^[a-z0-9][a-z0-9._-]{0,31}$'),
+ status text NOT NULL DEFAULT 'submitted'
+ CHECK (status IN ('submitted', 'ended', 'stored', 'canceled', 'expired')),
+ request_count integer NOT NULL CHECK (request_count BETWEEN 1 AND 10000),
+ review_count integer NOT NULL CHECK (review_count BETWEEN 1 AND 100000),
+ -- [{custom_id, category, criteria_version, reviews: [{ref, id, text_hash}]}]: what each
+ -- request carried, so results map back to reviews after a crash. No review text.
+ manifest jsonb NOT NULL
+ CHECK (jsonb_typeof(manifest) = 'array' AND pg_column_size(manifest) <= 1048576),
+ -- The ceiling the job reserved before sending (requests x max output, at list price), then
+ -- the cost from the usage the API reported. The month's spend counts cost, else the ceiling.
+ reserved_usd numeric(10, 4) NOT NULL CHECK (reserved_usd >= 0),
+ cost_usd numeric(10, 4) CHECK (cost_usd >= 0),
+ input_tokens bigint NOT NULL DEFAULT 0 CHECK (input_tokens >= 0),
+ output_tokens bigint NOT NULL DEFAULT 0 CHECK (output_tokens >= 0),
+ cache_read_tokens bigint NOT NULL DEFAULT 0 CHECK (cache_read_tokens >= 0),
+ cache_write_tokens bigint NOT NULL DEFAULT 0 CHECK (cache_write_tokens >= 0),
+ submitted_at timestamptz NOT NULL DEFAULT now(),
+ ended_at timestamptz,
+ stored_at timestamptz,
+ CONSTRAINT review_ai_batches_stored_cost CHECK (status <> 'stored' OR cost_usd IS NOT NULL)
+);
+CREATE INDEX IF NOT EXISTS review_ai_batches_month_idx
+ ON rate.review_ai_batches (model, submitted_at);
+CREATE INDEX IF NOT EXISTS review_ai_batches_open_idx
+ ON rate.review_ai_batches (submitted_at) WHERE status IN ('submitted', 'ended');
+
+COMMENT ON TABLE rate.review_ai_batches IS
+ 'Batches the review AI points job sent (DOS/Rate#1531, AI design D6): id, manifest, reserved and reported cost. No review text.';
+
+ALTER TABLE rate.review_ai_batches ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON rate.review_ai_batches FROM PUBLIC, anon, authenticated, rate_worker;
+GRANT ALL ON rate.review_ai_batches TO service_role;
+GRANT SELECT, INSERT, UPDATE ON rate.review_ai_batches TO rate_worker;
+DROP POLICY IF EXISTS "Service role access review_ai_batches" ON rate.review_ai_batches;
+CREATE POLICY "Service role access review_ai_batches" ON rate.review_ai_batches
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
+DROP POLICY IF EXISTS "Rate worker read review_ai_batches" ON rate.review_ai_batches;
+CREATE POLICY "Rate worker read review_ai_batches" ON rate.review_ai_batches
+ FOR SELECT TO rate_worker USING (true);
+DROP POLICY IF EXISTS "Rate worker insert review_ai_batches" ON rate.review_ai_batches;
+CREATE POLICY "Rate worker insert review_ai_batches" ON rate.review_ai_batches
+ FOR INSERT TO rate_worker WITH CHECK (true);
+DROP POLICY IF EXISTS "Rate worker update review_ai_batches" ON rate.review_ai_batches;
+-- A stored batch is closed: its cost, which the month's cap reads, cannot be rewritten.
+CREATE POLICY "Rate worker update review_ai_batches" ON rate.review_ai_batches
+ FOR UPDATE TO rate_worker USING (status <> 'stored') WITH CHECK (true);
+
+-- 2. rate.review_ai_extractions ----------------------------------------------------------------
+-- A review is done while its marker has the same text hash, prompt version, criteria version
+-- and category, unless the outcome is 'failed' with fewer than 3 attempts (a cut-off or refused
+-- result is never recorded as 'empty'; it is retried at most twice, and the writer refuses a
+-- fourth attempt).
+
+CREATE TABLE IF NOT EXISTS rate.review_ai_extractions (
+ review_id uuid PRIMARY KEY REFERENCES rate.reviews(id) ON DELETE CASCADE,
+ text_hash text NOT NULL CHECK (text_hash ~ '^[0-9a-f]{64}$'),
+ -- 'all' is the purge's word for every version, so no version is called that.
+ prompt_version text NOT NULL CHECK (prompt_version ~ '^[a-z0-9][a-z0-9._-]{0,31}$'
+ AND prompt_version <> 'all'),
+ criteria_version integer NOT NULL CHECK (criteria_version >= 1),
+ -- The category of the review's master when it was processed: a move to another category
+ -- makes the review due again.
+ category_slug text NOT NULL CHECK (category_slug ~ '^[a-z_-]{2,64}$'),
+ model text NOT NULL CHECK (model ~ '^[a-z0-9][a-z0-9.-]{1,63}$'),
+ -- stored: lines written; empty: the model found nothing; rejected: every line it returned
+ -- failed the job's checks; failed: cut off (max_tokens), refused or errored, to retry.
+ outcome text NOT NULL CHECK (outcome IN ('stored', 'empty', 'rejected', 'failed')),
+ -- The 'ai' lines of the review now, and the prompt version that wrote them (a failed retry
+ -- under a new version keeps the lines of the old one; the purge reads this).
+ point_count smallint NOT NULL DEFAULT 0 CHECK (point_count BETWEEN 0 AND 6),
+ lines_prompt_version text,
+ -- Lines the job dropped (evidence not found, too short, a key of another category...).
+ rejected_count smallint NOT NULL DEFAULT 0 CHECK (rejected_count BETWEEN 0 AND 20),
+ attempts smallint NOT NULL DEFAULT 1 CHECK (attempts BETWEEN 1 AND 3),
+ -- [{kind, criterion_key, position, evidence, match}] of the review's lines; match is
+ -- 'exact' or 'unaccented' (found only without diacritics, counted apart by the job).
+ evidence jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(evidence) = 'array'),
+ batch_id text REFERENCES rate.review_ai_batches(id) ON DELETE SET NULL,
+ processed_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT review_ai_extractions_stored CHECK (outcome <> 'stored' OR point_count > 0),
+ CONSTRAINT review_ai_extractions_failed CHECK (outcome = 'failed' OR attempts = 1),
+ CONSTRAINT review_ai_extractions_lines CHECK ((point_count > 0) = (lines_prompt_version IS NOT NULL))
+);
+CREATE INDEX IF NOT EXISTS review_ai_extractions_version_idx
+ ON rate.review_ai_extractions (prompt_version);
+CREATE INDEX IF NOT EXISTS review_ai_extractions_lines_version_idx
+ ON rate.review_ai_extractions (lines_prompt_version) WHERE lines_prompt_version IS NOT NULL;
+CREATE INDEX IF NOT EXISTS review_ai_extractions_batch_idx
+ ON rate.review_ai_extractions (batch_id) WHERE batch_id IS NOT NULL;
+
+COMMENT ON TABLE rate.review_ai_extractions IS
+ 'Process-once marker of the review AI points job (DOS/Rate#1531, AI design D6). Written only by rate.store_review_ai_points and rate.purge_review_ai_points.';
+
+ALTER TABLE rate.review_ai_extractions ENABLE ROW LEVEL SECURITY;
+REVOKE ALL ON rate.review_ai_extractions FROM PUBLIC, anon, authenticated, rate_worker;
+GRANT ALL ON rate.review_ai_extractions TO service_role;
+GRANT SELECT ON rate.review_ai_extractions TO rate_worker;
+DROP POLICY IF EXISTS "Service role access review_ai_extractions" ON rate.review_ai_extractions;
+CREATE POLICY "Service role access review_ai_extractions" ON rate.review_ai_extractions
+ FOR ALL TO service_role USING (true) WITH CHECK (true);
+DROP POLICY IF EXISTS "Rate worker read review_ai_extractions" ON rate.review_ai_extractions;
+CREATE POLICY "Rate worker read review_ai_extractions" ON rate.review_ai_extractions
+ FOR SELECT TO rate_worker USING (true);
+
+-- 3. The text hash -----------------------------------------------------------------------------
+-- The review's comment as the job sent it: Unicode NFC, UTF-8, sha256, lower-case hex. The job
+-- computes createHash('sha256').update(comment.normalize('NFC'), 'utf8').
+
+CREATE OR REPLACE FUNCTION rate.review_ai_text_hash(p_text text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+PARALLEL SAFE
+SET search_path = ''
+AS $$
+ SELECT pg_catalog.encode(pg_catalog.sha256(
+ pg_catalog.convert_to(normalize(COALESCE(p_text, ''), NFC), 'UTF8')), 'hex')
+$$;
+
+REVOKE ALL ON FUNCTION rate.review_ai_text_hash(text) FROM PUBLIC, anon, authenticated;
+GRANT EXECUTE ON FUNCTION rate.review_ai_text_hash(text) TO service_role, rate_worker;
+
+-- 4. The writer --------------------------------------------------------------------------------
+-- p_rows: a JSON array of at most 500 rows and 2 MB, each
+-- {"id": review uuid, "text_hash": hex, "prompt_version": "...", "criteria_version": n,
+-- "category": slug, "model": "...", "batch_id": "..." | null, "failed": true | false,
+-- "rejected": 0-20 (optional),
+-- "points": [{"kind": "pro" | "con", "criterion_key": key | null, "body": "...",
+-- "evidence": "...", "match": "exact" | "unaccented"}]}
+-- "failed" is required, so a cut-off or refused result can never pass as an empty one.
+-- Checked for every row before anything is written; one malformed row fails the call (22023,
+-- with its index):
+-- the shape and types above; a failed row carries no points;
+-- (category, criteria_version) a list in rate.rating_criteria, every criterion key in it;
+-- at most 3 pros and 3 cons; each body 5 to 150 characters as stored (review_text_stored)
+-- with words in it, a con not evasive, no line twice across pros and cons (compared form);
+-- each evidence 2 to 300 characters as stored, at least two words.
+-- A batch_id must be a row of rate.review_ai_batches (else 23503, from its foreign key).
+-- Then, per review (a review listed twice: the last row wins), in id order, after the reviews
+-- are locked FOR NO KEY UPDATE in id order (two calls never interleave on one review), each
+-- row is one of:
+-- 'missing' the review no longer exists;
+-- 'not_crawl' it is not a crawled user review (origin 'crawl', reviewer_type 'user');
+-- 'writer' it has a writer line: writer lines are never mixed with 'ai';
+-- 'stale' its comment no longer hashes to text_hash, or its master is in another
+-- category now: the job sends it again with what it is now;
+-- 'unquoted' an 'exact' evidence quote is not in the comment (compared form, as P3
+-- compares lines): nothing is written, and the job reports it;
+-- 'done' a failed row for a review whose marker already holds a result of the same
+-- text, prompt version, criteria version and category: the result stays;
+-- 'exhausted' a failed row for a review already failed 3 times with the same key: refused
+-- as a fourth attempt;
+-- 'failed' the marker says failed and counts the attempt (1 under a new key); the
+-- review's lines and their evidence stay, with the version that wrote them;
+-- 'stored', 'rejected', 'empty' the review's 'ai' lines are replaced by its points
+-- (position 0 to 2 per kind), and the marker says which.
+-- Returns the count of each.
+CREATE OR REPLACE FUNCTION rate.store_review_ai_points(p_rows jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_row jsonb;
+ v_point jsonb;
+ v_index integer := 0;
+ v_pros integer;
+ v_cons integer;
+ v_seen text[];
+ v_compared text;
+ v_body text;
+ v_len integer;
+ v_evidence text;
+ v_review record;
+ v_marker rate.review_ai_extractions%ROWTYPE;
+ v_same_key boolean;
+ v_outcome text;
+ v_count integer;
+ v_result jsonb := '{"stored": 0, "empty": 0, "rejected": 0, "failed": 0, "missing": 0, "not_crawl": 0, "writer": 0, "stale": 0, "unquoted": 0, "done": 0, "exhausted": 0}'::jsonb;
+BEGIN
+ IF jsonb_typeof(p_rows) IS DISTINCT FROM 'array' THEN
+ RAISE EXCEPTION 'p_rows must be a JSON array' USING ERRCODE = '22023';
+ END IF;
+ IF jsonb_array_length(p_rows) > 500 OR octet_length(p_rows::text) > 2097152 THEN
+ RAISE EXCEPTION 'at most 500 rows and 2 MB per call' USING ERRCODE = '22023';
+ END IF;
+
+ FOR v_row IN SELECT value FROM jsonb_array_elements(p_rows) LOOP
+ IF jsonb_typeof(v_row) IS DISTINCT FROM 'object'
+ OR v_row - ARRAY['id', 'text_hash', 'prompt_version', 'criteria_version', 'category',
+ 'model', 'batch_id', 'failed', 'rejected', 'points'] <> '{}'::jsonb
+ OR NOT v_row ?& ARRAY['id', 'text_hash', 'prompt_version', 'criteria_version', 'category',
+ 'model', 'failed', 'points']
+ OR jsonb_typeof(v_row -> 'id') IS DISTINCT FROM 'string'
+ OR (v_row ->> 'id') !~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
+ OR jsonb_typeof(v_row -> 'text_hash') IS DISTINCT FROM 'string'
+ OR (v_row ->> 'text_hash') !~ '^[0-9a-f]{64}$'
+ OR jsonb_typeof(v_row -> 'prompt_version') IS DISTINCT FROM 'string'
+ OR (v_row ->> 'prompt_version') !~ '^[a-z0-9][a-z0-9._-]{0,31}$'
+ OR v_row ->> 'prompt_version' = 'all'
+ OR jsonb_typeof(v_row -> 'criteria_version') IS DISTINCT FROM 'number'
+ OR (v_row ->> 'criteria_version') !~ '^[1-9][0-9]{0,5}$'
+ OR jsonb_typeof(v_row -> 'category') IS DISTINCT FROM 'string'
+ OR (v_row ->> 'category') !~ '^[a-z_-]{2,64}$'
+ OR jsonb_typeof(v_row -> 'model') IS DISTINCT FROM 'string'
+ OR (v_row ->> 'model') !~ '^[a-z0-9][a-z0-9.-]{1,63}$'
+ OR jsonb_typeof(v_row -> 'batch_id') NOT IN ('string', 'null')
+ OR (jsonb_typeof(v_row -> 'batch_id') = 'string'
+ AND (v_row ->> 'batch_id') !~ '^[A-Za-z0-9_-]{8,128}$')
+ OR jsonb_typeof(v_row -> 'failed') IS DISTINCT FROM 'boolean'
+ OR jsonb_typeof(v_row -> 'rejected') NOT IN ('number', 'null')
+ OR (jsonb_typeof(v_row -> 'rejected') = 'number'
+ AND (v_row ->> 'rejected') !~ '^([0-9]|1[0-9]|20)$')
+ OR jsonb_typeof(v_row -> 'points') IS DISTINCT FROM 'array'
+ OR jsonb_array_length(v_row -> 'points') > 6
+ OR ((v_row ->> 'failed')::boolean AND jsonb_array_length(v_row -> 'points') > 0) THEN
+ RAISE EXCEPTION 'row % is not a review AI result', v_index USING ERRCODE = '22023';
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM rate.rating_criteria
+ WHERE criteria_version = (v_row ->> 'criteria_version')::integer
+ AND category_slug = v_row ->> 'category') THEN
+ RAISE EXCEPTION 'row % has no criteria list %/%', v_index, v_row ->> 'category',
+ v_row ->> 'criteria_version' USING ERRCODE = '22023';
+ END IF;
+
+ v_pros := 0;
+ v_cons := 0;
+ v_seen := '{}';
+ FOR v_point IN SELECT value FROM jsonb_array_elements(v_row -> 'points') LOOP
+ IF jsonb_typeof(v_point) IS DISTINCT FROM 'object'
+ OR v_point - ARRAY['kind', 'criterion_key', 'body', 'evidence', 'match'] <> '{}'::jsonb
+ OR NOT v_point ?& ARRAY['kind', 'criterion_key', 'body', 'evidence', 'match']
+ OR jsonb_typeof(v_point -> 'kind') IS DISTINCT FROM 'string'
+ OR (v_point ->> 'kind') NOT IN ('pro', 'con')
+ OR jsonb_typeof(v_point -> 'criterion_key') NOT IN ('string', 'null')
+ OR jsonb_typeof(v_point -> 'body') IS DISTINCT FROM 'string'
+ OR jsonb_typeof(v_point -> 'evidence') IS DISTINCT FROM 'string'
+ OR jsonb_typeof(v_point -> 'match') IS DISTINCT FROM 'string'
+ OR (v_point ->> 'match') NOT IN ('exact', 'unaccented') THEN
+ RAISE EXCEPTION 'row % has a line that is not a review AI line', v_index
+ USING ERRCODE = '22023';
+ END IF;
+ IF v_point ->> 'kind' = 'pro' THEN
+ v_pros := v_pros + 1;
+ ELSE
+ v_cons := v_cons + 1;
+ END IF;
+ v_body := rate.review_text_stored(v_point ->> 'body');
+ v_len := rate.review_text_length(v_body);
+ v_evidence := rate.review_text_stored(v_point ->> 'evidence');
+ v_compared := rate.review_point_compare(v_body);
+ IF v_pros > 3 OR v_cons > 3
+ OR v_len < 5 OR v_len > 150
+ OR NOT rate.review_point_has_content(v_body)
+ OR (v_point ->> 'kind' = 'con' AND rate.review_point_is_evasive_con(v_body))
+ OR v_compared = ANY (v_seen)
+ OR rate.review_text_length(v_evidence) NOT BETWEEN 2 AND 300
+ OR rate.review_point_compare(v_evidence) !~ ' '
+ OR (jsonb_typeof(v_point -> 'criterion_key') = 'string'
+ AND NOT EXISTS (SELECT 1 FROM rate.rating_criteria
+ WHERE criteria_version = (v_row ->> 'criteria_version')::integer
+ AND category_slug = v_row ->> 'category'
+ AND key = v_point ->> 'criterion_key')) THEN
+ RAISE EXCEPTION 'row % has a line out of range', v_index USING ERRCODE = '22023';
+ END IF;
+ v_seen := v_seen || v_compared;
+ END LOOP;
+ v_index := v_index + 1;
+ END LOOP;
+
+ PERFORM 1 FROM rate.reviews r
+ WHERE r.id IN (SELECT (e ->> 'id')::uuid FROM jsonb_array_elements(p_rows) e)
+ ORDER BY r.id
+ FOR NO KEY UPDATE;
+
+ FOR v_row IN
+ SELECT DISTINCT ON ((e ->> 'id')::uuid) e
+ FROM jsonb_array_elements(p_rows) WITH ORDINALITY AS a(e, n)
+ ORDER BY (e ->> 'id')::uuid, n DESC
+ LOOP
+ SELECT r.id, r.comment, r.origin, r.reviewer_type, c.slug AS category,
+ EXISTS (SELECT 1 FROM rate.review_points rp
+ WHERE rp.review_id = r.id AND rp.method = 'writer') AS has_writer
+ INTO v_review
+ FROM rate.reviews r
+ LEFT JOIN rate.products p ON p.id = r.product_id AND p.origin = 'crawl'
+ LEFT JOIN rate.master_products m ON m.id = p.cluster_id
+ LEFT JOIN rate.categories c ON c.id = m.category_id
+ WHERE r.id = (v_row ->> 'id')::uuid;
+ v_outcome := CASE
+ WHEN NOT FOUND THEN 'missing'
+ WHEN v_review.origin IS DISTINCT FROM 'crawl'
+ OR v_review.reviewer_type IS DISTINCT FROM 'user' THEN 'not_crawl'
+ WHEN v_review.has_writer THEN 'writer'
+ WHEN rate.review_ai_text_hash(v_review.comment) <> v_row ->> 'text_hash'
+ OR v_review.category IS DISTINCT FROM v_row ->> 'category' THEN 'stale'
+ WHEN EXISTS (SELECT 1 FROM jsonb_array_elements(v_row -> 'points') p
+ WHERE p ->> 'match' = 'exact'
+ AND strpos(rate.review_point_compare(v_review.comment),
+ rate.review_point_compare(p ->> 'evidence')) = 0) THEN 'unquoted'
+ END;
+ IF v_outcome IS NOT NULL THEN
+ v_result := jsonb_set(v_result, ARRAY[v_outcome], to_jsonb((v_result ->> v_outcome)::integer + 1));
+ CONTINUE;
+ END IF;
+
+ SELECT * INTO v_marker FROM rate.review_ai_extractions WHERE review_id = v_review.id;
+ v_same_key := FOUND
+ AND v_marker.text_hash = v_row ->> 'text_hash'
+ AND v_marker.prompt_version = v_row ->> 'prompt_version'
+ AND v_marker.criteria_version = (v_row ->> 'criteria_version')::integer
+ AND v_marker.category_slug = v_row ->> 'category';
+
+ IF (v_row ->> 'failed')::boolean THEN
+ v_outcome := CASE WHEN v_same_key AND v_marker.outcome <> 'failed' THEN 'done'
+ WHEN v_same_key AND v_marker.attempts >= 3 THEN 'exhausted'
+ ELSE 'failed' END;
+ IF v_outcome = 'failed' THEN
+ INSERT INTO rate.review_ai_extractions AS x (
+ review_id, text_hash, prompt_version, criteria_version, category_slug, model, outcome,
+ point_count, lines_prompt_version, rejected_count, attempts, evidence, batch_id,
+ processed_at)
+ VALUES (v_review.id, v_row ->> 'text_hash', v_row ->> 'prompt_version',
+ (v_row ->> 'criteria_version')::integer, v_row ->> 'category', v_row ->> 'model',
+ 'failed', 0, NULL, 0, 1, '[]'::jsonb, v_row ->> 'batch_id', now())
+ ON CONFLICT (review_id) DO UPDATE
+ SET attempts = CASE WHEN v_same_key THEN x.attempts + 1 ELSE 1 END,
+ text_hash = EXCLUDED.text_hash,
+ prompt_version = EXCLUDED.prompt_version,
+ criteria_version = EXCLUDED.criteria_version,
+ category_slug = EXCLUDED.category_slug,
+ model = EXCLUDED.model,
+ outcome = 'failed',
+ rejected_count = 0,
+ batch_id = EXCLUDED.batch_id,
+ processed_at = EXCLUDED.processed_at;
+ END IF;
+ v_result := jsonb_set(v_result, ARRAY[v_outcome], to_jsonb((v_result ->> v_outcome)::integer + 1));
+ CONTINUE;
+ END IF;
+
+ DELETE FROM rate.review_points WHERE review_id = v_review.id AND method = 'ai';
+ INSERT INTO rate.review_points (review_id, criterion_key, kind, body, position, method)
+ SELECT v_review.id, p ->> 'criterion_key', p ->> 'kind', rate.review_text_stored(p ->> 'body'),
+ (row_number() OVER (PARTITION BY p ->> 'kind' ORDER BY n) - 1)::smallint, 'ai'
+ FROM jsonb_array_elements(v_row -> 'points') WITH ORDINALITY AS t(p, n);
+ GET DIAGNOSTICS v_count = ROW_COUNT;
+ v_outcome := CASE WHEN v_count > 0 THEN 'stored'
+ WHEN COALESCE((v_row ->> 'rejected')::integer, 0) > 0 THEN 'rejected'
+ ELSE 'empty' END;
+ INSERT INTO rate.review_ai_extractions AS x (
+ review_id, text_hash, prompt_version, criteria_version, category_slug, model, outcome,
+ point_count, lines_prompt_version, rejected_count, attempts, evidence, batch_id,
+ processed_at)
+ VALUES (v_review.id, v_row ->> 'text_hash', v_row ->> 'prompt_version',
+ (v_row ->> 'criteria_version')::integer, v_row ->> 'category', v_row ->> 'model',
+ v_outcome, v_count, CASE WHEN v_count > 0 THEN v_row ->> 'prompt_version' END,
+ COALESCE((v_row ->> 'rejected')::integer, 0), 1,
+ COALESCE((SELECT jsonb_agg(s.line ORDER BY s.n)
+ FROM (SELECT n, jsonb_build_object(
+ 'kind', p ->> 'kind', 'criterion_key', p -> 'criterion_key',
+ 'position', row_number() OVER (PARTITION BY p ->> 'kind' ORDER BY n) - 1,
+ 'evidence', rate.review_text_stored(p ->> 'evidence'),
+ 'match', p ->> 'match') AS line
+ FROM jsonb_array_elements(v_row -> 'points') WITH ORDINALITY AS t(p, n)) s),
+ '[]'::jsonb),
+ v_row ->> 'batch_id', now())
+ ON CONFLICT (review_id) DO UPDATE
+ SET text_hash = EXCLUDED.text_hash,
+ prompt_version = EXCLUDED.prompt_version,
+ criteria_version = EXCLUDED.criteria_version,
+ category_slug = EXCLUDED.category_slug,
+ model = EXCLUDED.model,
+ outcome = EXCLUDED.outcome,
+ point_count = EXCLUDED.point_count,
+ lines_prompt_version = EXCLUDED.lines_prompt_version,
+ rejected_count = EXCLUDED.rejected_count,
+ attempts = 1,
+ evidence = EXCLUDED.evidence,
+ batch_id = EXCLUDED.batch_id,
+ processed_at = EXCLUDED.processed_at;
+ v_result := jsonb_set(v_result, ARRAY[v_outcome], to_jsonb((v_result ->> v_outcome)::integer + 1));
+ END LOOP;
+
+ RETURN v_result;
+END $$;
+
+REVOKE EXECUTE ON FUNCTION rate.store_review_ai_points(jsonb) FROM PUBLIC, anon, authenticated;
+GRANT EXECUTE ON FUNCTION rate.store_review_ai_points(jsonb) TO service_role, rate_worker;
+
+-- 5. The purge ---------------------------------------------------------------------------------
+-- 'all': every 'ai' line and every marker. A version: every marker of that version or holding
+-- lines it wrote (lines_prompt_version, so lines kept through a failed retry under a later
+-- version go too), with all the 'ai' lines of those reviews; they are processed again on the
+-- next run. There is no default: a bare call is refused. The reviews concerned are locked in
+-- id order first, as the writer locks them, so a purge and a store never leave a line without
+-- its marker. Batches stay (they hold the month's spend). The next product-points run
+-- aggregates without the lines; no writer line is touched. Returns {"lines": n, "markers": n}.
+CREATE OR REPLACE FUNCTION rate.purge_review_ai_points(p_prompt_version text)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_all boolean := p_prompt_version = 'all';
+ v_lines integer;
+ v_markers integer;
+BEGIN
+ IF p_prompt_version IS NULL
+ OR (NOT v_all AND p_prompt_version !~ '^[a-z0-9][a-z0-9._-]{0,31}$') THEN
+ RAISE EXCEPTION 'p_prompt_version must be ''all'' or a prompt version' USING ERRCODE = '22023';
+ END IF;
+ PERFORM 1 FROM rate.reviews r
+ WHERE r.id IN (SELECT x.review_id FROM rate.review_ai_extractions x
+ WHERE v_all OR x.prompt_version = p_prompt_version
+ OR x.lines_prompt_version = p_prompt_version
+ UNION
+ SELECT rp.review_id FROM rate.review_points rp WHERE v_all AND rp.method = 'ai')
+ ORDER BY r.id
+ FOR NO KEY UPDATE;
+
+ DELETE FROM rate.review_points rp
+ WHERE rp.method = 'ai'
+ AND (v_all OR rp.review_id IN (SELECT x.review_id FROM rate.review_ai_extractions x
+ WHERE x.prompt_version = p_prompt_version
+ OR x.lines_prompt_version = p_prompt_version));
+ GET DIAGNOSTICS v_lines = ROW_COUNT;
+ DELETE FROM rate.review_ai_extractions x
+ WHERE v_all OR x.prompt_version = p_prompt_version OR x.lines_prompt_version = p_prompt_version;
+ GET DIAGNOSTICS v_markers = ROW_COUNT;
+ RETURN jsonb_build_object('lines', v_lines, 'markers', v_markers);
+END $$;
+
+REVOKE EXECUTE ON FUNCTION rate.purge_review_ai_points(text) FROM PUBLIC, anon, authenticated;
+GRANT EXECUTE ON FUNCTION rate.purge_review_ai_points(text) TO service_role, rate_worker;
+
+COMMIT;
diff --git a/apps/extension/CHROMEWEBSTORE.md b/apps/extension/CHROMEWEBSTORE.md
index 6824333bb..5f79ca19b 100644
--- a/apps/extension/CHROMEWEBSTORE.md
+++ b/apps/extension/CHROMEWEBSTORE.md
@@ -1,6 +1,6 @@
# Chrome Web Store Listing & Compliance - Rate.vn Browser Extension (MV3)
-- **Last Updated:** 2026-10-14
+- **Last Updated:** 2026-11-04
- **Current Version:** 2.0.0
- **Category:** Shopping
@@ -18,7 +18,7 @@ Xem lịch sử giá 90 ngày, phát hiện giảm giá ảo, tìm nơi bán r
### Detailed Description
-Rate.vn giúp người tiêu dùng mua sắm đồ công nghệ minh bạch và tiết kiệm hơn ngay khi đang xem sản phẩm trên Shopee, Lazada, Tiki, CellphoneS, Thế Giới Di Động và FPT Shop:
+Rate.vn giúp người tiêu dùng mua sắm đồ công nghệ minh bạch và tiết kiệm hơn ngay khi đang xem sản phẩm trên Shopee, Lazada, Tiki, TikTok Shop, CellphoneS, Thế Giới Di Động, FPT Shop, Hoàng Hà Mobile, Di Động Việt và Phong Vũ:
- Phát hiện Giảm Giá Ảo: Tự động đối chiếu mức giá hiện tại và mức giá gạch ngang với lịch sử giá đã xác minh để nhận diện khuyến mãi thật hay nâng giá gốc rồi giảm ảo.
- So Sánh Giá Đa Sàn Tức Thì: Hiển thị ngay các gian hàng chính hãng khác đang bán cùng mẫu điện thoại, máy tính bảng, laptop với mức giá thấp hơn.
@@ -32,23 +32,24 @@ Rate.vn giúp người tiêu dùng mua sắm đồ công nghệ minh bạch và
This table lists exactly what `manifest.json` requests; `test-extension-pipeline.ts` fails when the two disagree (DOS/Rate#1231).
-| Permission / Host Permission | Plain-English Justification |
-| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `storage` | Saves the user's preferred Rate.vn server endpoint, a temporary product lookup cache for the active session, and the user's explicit opt-in preference for contributing product price signals. |
-| `sidePanel` | Displays the price history chart, cross-merchant price comparison table and AI review authenticity breakdown beside the shopping page when the user clicks the extension badge or floating pill. |
-| `https://shopee.vn/*`, `https://*.lazada.vn/*`, `https://tiki.vn/*`, `https://www.tiktok.com/*`, `https://shop.tiktok.com/*`, `https://cellphones.com.vn/*`, `https://*.thegioididong.com/*`, `https://fptshop.com.vn/*`, `https://hoanghamobile.com/*`, `https://didongviet.vn/*`, `https://phongvu.vn/*` | Runs the content script on the 10 supported Vietnamese stores. It reads the public product title, selected variant, listed price, original price and rating of a product page to show the floating price-check badge. The script loads on every page of these sites because they navigate in-page, but it reads and sends nothing on pages it does not recognise as a product page. TikTok is limited to the two hosts its product pages use (DOS/Rate#1232). |
-| `https://rate.vn/*`, `https://www.rate.vn/*` | Fetches verified price history, cross-merchant offers and review authenticity scores from the Rate.vn API, and sends the opt-in crowd signal. |
+| Permission / Host Permission | Plain-English Justification |
+| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `storage` | Saves the user's preferred Rate.vn server endpoint, a temporary product lookup cache for the active session, and the user's explicit opt-in preference for contributing product price signals. |
+| `sidePanel` | Displays the price history chart, cross-merchant price comparison table and AI review authenticity breakdown beside the shopping page when the user clicks the extension badge or floating pill. |
+| `https://shopee.vn/*`, `https://*.lazada.vn/*`, `https://tiki.vn/*`, `https://www.tiktok.com/*`, `https://shop.tiktok.com/*`, `https://cellphones.com.vn/*`, `https://*.thegioididong.com/*`, `https://fptshop.com.vn/*`, `https://hoanghamobile.com/*`, `https://didongviet.vn/*`, `https://phongvu.vn/*` | Runs the content script on the 10 supported Vietnamese stores. It reads the public product title, selected variant, listed price, original price and rating of a product page to show the floating price-check badge. The script loads on every page of these sites because they navigate in-page, but it reads and sends nothing on pages it does not recognise as a product page. On `www.tiktok.com` it loads only on product page paths (`/view/product/`, `/@shop/product/`, `/shop/.../pdp/`), not on the video feed or video pages (DOS/Rate#1601). A product page reached by in-page navigation from the feed gets the badge after a reload; `shop.tiktok.com` serves only shop pages (DOS/Rate#1232). |
+| `https://rate.vn/*`, `https://www.rate.vn/*` | Fetches verified price history, cross-merchant offers and review authenticity scores from the Rate.vn API, and sends the opt-in crowd signal. |
---
## 3. Privacy & User Data Disclosure
-1. **Where it runs:** the content script loads on the 10 supported stores listed above (`shopee.vn`, `lazada.vn`, `tiki.vn`, `www.tiktok.com`, `shop.tiktok.com`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`, `hoanghamobile.com`, `didongviet.vn`, `phongvu.vn`). It acts only on pages it recognises as a product page; it never opens or fetches pages the user did not open.
-2. **Lookup (on by default, no opt-in):** on a recognised product page the extension sends `GET /api/v1/rate/extension/lookup` with the store, the product URL with query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) stripped locally, the product title, the store's item id and shop id, the selected variant, the listed price and the original price. The request is read-only and carries no account or personal data.
-3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)" in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue and never change public prices directly.
-4. **Stored in the browser only:** the server address and the opt-in choice (`chrome.storage.local`), and a session-only cache of the products viewed in each tab, the latest side panel search and their lookup results (`chrome.storage.session`).
-5. **Side panel requests:** text the user types into the side panel search is sent to the same lookup endpoint; the side panel also reads public deals and merchant lists from the Rate.vn API.
-6. **Privacy policy:** https://rate.vn/privacy-policy has a section on the extension.
+1. **Where it runs:** the content script loads on the 10 supported stores listed above (`shopee.vn`, `lazada.vn`, `tiki.vn`, TikTok Shop product pages on `www.tiktok.com` and `shop.tiktok.com`, `cellphones.com.vn`, `thegioididong.com`, `fptshop.com.vn`, `hoanghamobile.com`, `didongviet.vn`, `phongvu.vn`). It acts only on pages it recognises as a product page. The extension itself never opens or fetches pages the user did not open, never works around a store's bot checks and never solves captchas.
+2. **Lookup (on by default, no opt-in):** on a recognised product page the extension sends `GET /api/v1/rate/extension/lookup` with the store, the product URL with query parameters and tracking tokens (`utm_*`, `sp_atk`, `xptdk`, `fbclid`, `gclid`) stripped locally, the product title, the store's item id and shop id, the selected variant, the listed price and the original price. The request is read-only and carries no account, name, email or cookies.
+3. **Opt-in crowd signal (off by default):** only when the user turns on "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật)" (English: "Help find new products (only when you turn it on)") in the Side Panel, the extension also sends `POST /api/v1/rate/extension/ingest` with the store, item id, shop id, cleaned URL, title, selected variant, price, original price, rating and rating count. The toggle's label lists these same fields (DOS/Rate#1230). Submissions go to a moderation queue, stored with the time the server received them; Rate.vn's servers may re-check the listing on the store. They never change public prices directly.
+4. **Stored in the browser only:** the server address and the opt-in choice (`chrome.storage.local`), and a session-only cache of the products viewed in each tab, the latest side panel search, their lookup results and a lookup wait time after a rate limit (`chrome.storage.session`).
+5. **IP address:** like any web request, the Rate.vn server receives the user's IP address. The lookup and opt-in routes use it only for a per-minute request limit held in memory, and Rate.vn's code does not store it; the hosting provider keeps standard access logs for a short time.
+6. **Side panel requests:** text the user types into the side panel search is sent to the same lookup endpoint; the side panel also reads public deals and merchant lists from the Rate.vn API.
+7. **Privacy policy:** https://rate.vn/privacy-policy has a section on the extension.
---
diff --git a/apps/extension/RELEASE.md b/apps/extension/RELEASE.md
index 68468b0a6..3d41739bd 100644
--- a/apps/extension/RELEASE.md
+++ b/apps/extension/RELEASE.md
@@ -1,21 +1,21 @@
# Chrome Web Store release: checklist and changelog
-Release-readiness check of the extension built from `dev` at `fc9b820d` on 2026-10-30. The check was read-only: nothing was published, and the live API was used with a few GET requests.
+Release-readiness check of the extension built from `dev` at `fc9b820d` on 2026-10-30, with the TikTok content-script row updated on 2026-11-04 (#1601). The check was read-only: nothing was published, and the live API was used with a few GET requests.
## 1. Readiness check (2026-10-30)
-| Check | Result | Evidence |
-| :--------------------------------------------- | :------------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Build (`pnpm --filter @repo/extension build`) | Pass | `dist/` holds 11 files, 936 KB. The manifest is MV3, version `2.0.0`. |
-| Extension tests, typecheck, lint | Pass | `test-extension-pipeline.ts` passes, including #1302, #1304 and #1326; `eslint --max-warnings 0` is clean. |
-| Manifest permissions match `CHROMEWEBSTORE.md` | Pass | `storage` and `sidePanel`; 11 store host patterns (10 stores, TikTok on two hosts) plus `rate.vn` and `www.rate.vn`. The content-script matches equal the store hosts. The pipeline test enforces this (#1231). |
-| `review_risk_score: null` shown as no data | Pass | The live lookup returns `null` for a seller with no analysed review, and the side panel shows `reviewRiskNoData` (#1266). |
-| `dosafe_risk_score: null` | Fixed in #1465 (PR #1476) | A seller with no DOSafe score answers null from the API and reads "chưa đủ dữ liệu" in the side panel, never "0". |
-| Verdict `messageKey` and the locale catalogue | Pass | All 10 server keys (`apps/web/src/server/rate/extension.ts`) map in `VERDICT_MESSAGES`. Every label and explanation exists in vi, en and cs with the right placeholder count. The live lookup returns `genuine_deal` and `fair_market` with their params. |
-| Lookup ranking (#1379) | Pass | Live lookup of "Apple iPhone 16 128GB" matches `apple-iphone-16-128gb`, not the 16e. |
-| 429 handling | Fixed in #1466 | After a 429 the extension sends no lookup until `Retry-After` (default 60 s, cap 600 s), the panel says how many seconds to wait, and the pill looks the product up again once the wait is over. |
-| No locale sent | Pass | The lookup sends platform, URL, title, mode, ids, variant and prices, but no locale. The verdict is rendered from `messageKey` in the extension's language. |
-| Version header | Fixed in #1466 | `X-Rate-Extension-Version` reads `chrome.runtime.getManifest().version`. |
+| Check | Result | Evidence |
+| :--------------------------------------------- | :------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Build (`pnpm --filter @repo/extension build`) | Pass | `dist/` holds 11 files, 936 KB. The manifest is MV3, version `2.0.0`. |
+| Extension tests, typecheck, lint | Pass | `test-extension-pipeline.ts` passes, including #1302, #1304 and #1326; `eslint --max-warnings 0` is clean. |
+| Manifest permissions match `CHROMEWEBSTORE.md` | Pass | `storage` and `sidePanel`; 11 store host patterns (10 stores, TikTok on two hosts) plus `rate.vn` and `www.rate.vn`. The content-script matches cover the same origins; on `www.tiktok.com` they are limited to product page paths (#1601). The pipeline test enforces this (#1231). |
+| `review_risk_score: null` shown as no data | Pass | The live lookup returns `null` for a seller with no analysed review, and the side panel shows `reviewRiskNoData` (#1266). |
+| `dosafe_risk_score: null` | Fixed in #1465 (PR #1476) | A seller with no DOSafe score answers null from the API and reads "chưa đủ dữ liệu" in the side panel, never "0". |
+| Verdict `messageKey` and the locale catalogue | Pass | All 10 server keys (`apps/web/src/server/rate/extension.ts`) map in `VERDICT_MESSAGES`. Every label and explanation exists in vi, en and cs with the right placeholder count. The live lookup returns `genuine_deal` and `fair_market` with their params. |
+| Lookup ranking (#1379) | Pass | Live lookup of "Apple iPhone 16 128GB" matches `apple-iphone-16-128gb`, not the 16e. |
+| 429 handling | Fixed in #1466 | After a 429 the extension sends no lookup until `Retry-After` (default 60 s, cap 600 s), the panel says how many seconds to wait, and the pill looks the product up again once the wait is over. |
+| No locale sent | Pass | The lookup sends platform, URL, title, mode, ids, variant and prices, but no locale. The verdict is rendered from `messageKey` in the extension's language. |
+| Version header | Fixed in #1466 | `X-Rate-Extension-Version` reads `chrome.runtime.getManifest().version`. |
## 2. Release checklist (JOY publishes)
@@ -55,4 +55,4 @@ Release-readiness check of the extension built from `dev` at `fc9b820d` on 2026-
- The toolbar badge shows at a glance whether the price you see is a good one. Sold-out listings no longer count towards the price comparison.
- More accurate product matching: "iPhone 16" no longer matches the iPhone 16e. Category and brand pages are no longer mistaken for product pages.
- Sellers with no analysed reviews show "No data yet" instead of a risk number.
-- The "Help detect new products" option names exactly what it sends and is still off by default. On TikTok, the extension runs only on product pages.
+- The "Help find new products" option names exactly what it sends and is still off by default. On TikTok, the extension runs only on product pages.
diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json
index 47399927f..3b0c863dd 100644
--- a/apps/extension/manifest.json
+++ b/apps/extension/manifest.json
@@ -47,7 +47,9 @@
"https://shopee.vn/*",
"https://*.lazada.vn/*",
"https://tiki.vn/*",
- "https://www.tiktok.com/*",
+ "https://www.tiktok.com/view/product/*",
+ "https://www.tiktok.com/@*/product/*",
+ "https://www.tiktok.com/shop/*pdp/*",
"https://shop.tiktok.com/*",
"https://cellphones.com.vn/*",
"https://*.thegioididong.com/*",
@@ -56,6 +58,7 @@
"https://didongviet.vn/*",
"https://phongvu.vn/*"
],
+ "exclude_matches": ["https://www.tiktok.com/@*/video/*"],
"js": ["content/content-script.js"],
"run_at": "document_idle"
}
diff --git a/apps/extension/src/_locales/cs/messages.json b/apps/extension/src/_locales/cs/messages.json
index fb8eb7a79..6d2564ce0 100644
--- a/apps/extension/src/_locales/cs/messages.json
+++ b/apps/extension/src/_locales/cs/messages.json
@@ -53,7 +53,7 @@
"message": "Pomoc s hledáním nových produktů (jen když ji zapnete):"
},
"optInBody": {
- "message": "Když je zapnuto, každá nabídka, kterou otevřete, se také odešle do nezávislé fronty ke kontrole Rate.vn s těmito údaji: obchod, ID produktu, ID obchodu, URL bez sledovacích parametrů, název produktu, zvolená varianta, cena, původní cena, počet hvězdiček a počet recenzí. Bez účtu a osobních údajů."
+ "message": "Když je zapnuto, každá nabídka, kterou otevřete, se také odešle do nezávislé fronty ke kontrole Rate.vn s těmito údaji: obchod, ID produktu, ID obchodu, URL bez sledovacích parametrů, název produktu, zvolená varianta, cena, původní cena, počet hvězdiček a počet recenzí. Bez účtu, jména, e-mailu a cookies."
},
"crowdFieldPlatform": {
"message": "obchod"
diff --git a/apps/extension/src/_locales/en/messages.json b/apps/extension/src/_locales/en/messages.json
index a524177e7..f69af4fcd 100644
--- a/apps/extension/src/_locales/en/messages.json
+++ b/apps/extension/src/_locales/en/messages.json
@@ -53,7 +53,7 @@
"message": "Help find new products (only when you turn it on):"
},
"optInBody": {
- "message": "When on, every listing you open is also sent to the independent Rate.vn review queue with: store, product ID, shop ID, URL with tracking parameters removed, product name, selected variant, price, original price, star rating and number of reviews. No account or personal data is included."
+ "message": "When on, every listing you open is also sent to the independent Rate.vn review queue with: store, product ID, shop ID, URL with tracking parameters removed, product name, selected variant, price, original price, star rating and number of reviews. No account, name, email or cookies are included."
},
"crowdFieldPlatform": {
"message": "store"
diff --git a/apps/extension/src/_locales/vi/messages.json b/apps/extension/src/_locales/vi/messages.json
index dcde804d6..188394535 100644
--- a/apps/extension/src/_locales/vi/messages.json
+++ b/apps/extension/src/_locales/vi/messages.json
@@ -53,7 +53,7 @@
"message": "Đóng góp phát hiện sản phẩm mới (chỉ khi bạn bật):"
},
"optInBody": {
- "message": "Khi bật, mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt độc lập của Rate.vn với: sàn, mã sản phẩm, mã gian hàng, URL đã cắt tham số theo dõi, tên sản phẩm, phiên bản đang chọn, giá bán, giá gốc, số sao và số lượt đánh giá. Không kèm tài khoản hay thông tin cá nhân."
+ "message": "Khi bật, mỗi tin bán bạn mở còn được gửi vào hàng đợi kiểm duyệt độc lập của Rate.vn với: sàn, mã sản phẩm, mã gian hàng, URL đã cắt tham số theo dõi, tên sản phẩm, phiên bản đang chọn, giá bán, giá gốc, số sao và số lượt đánh giá. Không kèm tài khoản, tên, email hay cookie."
},
"crowdFieldPlatform": {
"message": "sàn"
diff --git a/apps/extension/src/test-extension-pipeline.ts b/apps/extension/src/test-extension-pipeline.ts
index 1267c51bb..c641cfe40 100644
--- a/apps/extension/src/test-extension-pipeline.ts
+++ b/apps/extension/src/test-extension-pipeline.ts
@@ -485,7 +485,10 @@ async function runTests() {
) as {
permissions?: string[];
host_permissions?: string[];
- content_scripts?: Array<{ matches?: string[] }>;
+ content_scripts?: Array<{
+ matches?: string[];
+ exclude_matches?: string[];
+ }>;
};
assert.deepEqual(
manifestJson.permissions,
@@ -499,22 +502,31 @@ async function runTests() {
for (const pattern of [...hosts, ...matches]) {
assert.ok(!pattern.includes('*.tiktok.com'), pattern);
}
- const matchesPattern = (pattern: string, url: string) =>
- new RegExp(
+ // Chrome matches a pattern's path against the URL's path and query,
+ // never its fragment.
+ const matchesPattern = (pattern: string, url: string) => {
+ const u = new URL(url);
+ return new RegExp(
`^${pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace('://*\\.', '://(?:[^/]+\\.)?')
.replace(/\*/g, '.*')}$`
- ).test(url);
+ ).test(`${u.origin}${u.pathname}${u.search}`);
+ };
+ const excludes = manifestJson.content_scripts?.[0]?.exclude_matches ?? [];
+ const injectedOn = (url: string) =>
+ matches.some((p) => matchesPattern(p, url)) &&
+ !excludes.some((p) => matchesPattern(p, url));
for (const url of [
'https://www.tiktok.com/@appleflagship/product/1729384756',
'https://www.tiktok.com/view/product/1729384756',
+ 'https://www.tiktok.com/shop/pdp/iphone-16/1729384756',
+ 'https://www.tiktok.com/shop/vn/pdp/iphone-16/1729384756?src=x',
'https://shop.tiktok.com/view/product/1729401928401',
]) {
- assert.ok(
- matches.some((p) => matchesPattern(p, url)),
- `content script must run on ${url}`
- );
+ assert.ok(injectedOn(url), `content script must run on ${url}`);
+ // ...and the extractor reads these as products, so the two agree.
+ assert.ok(extractIdsFromUrl('tiktok', url).externalItemId, url);
assert.ok(
hosts.some((p) => matchesPattern(p, url)),
url
@@ -539,8 +551,23 @@ async function runTests() {
[...(manifestJson.permissions ?? []), ...hosts].sort(),
'CHROMEWEBSTORE.md permission table must match manifest.json'
);
+ // On www.tiktok.com the content script runs only on product pages, not
+ // on the video feed (DOS/Rate#1601); host permissions are per origin.
+ for (const url of [
+ 'https://www.tiktok.com/',
+ 'https://www.tiktok.com/foryou',
+ 'https://www.tiktok.com/@appleflagship/video/7291234567890',
+ 'https://www.tiktok.com/search?q=iphone',
+ 'https://www.tiktok.com/@appleflagship',
+ 'https://www.tiktok.com/@appleflagship/video/7291234567890?ref=/product/1',
+ 'https://www.tiktok.com/foryou#/view/product/1',
+ ]) {
+ assert.ok(!injectedOn(url), `content script must not run on ${url}`);
+ }
+ const originOf = (pattern: string) =>
+ pattern.replace(/^(https:\/\/[^/]+)\/.*$/, '$1/*');
assert.deepEqual(
- [...matches].sort(),
+ [...new Set(matches.map(originOf))].sort(),
hosts.filter((h) => !/rate\.vn/.test(h)).sort()
);
diff --git a/apps/importer/package.json b/apps/importer/package.json
index 4885316b0..fe8fa907a 100644
--- a/apps/importer/package.json
+++ b/apps/importer/package.json
@@ -37,11 +37,12 @@
"lint": "eslint . --max-warnings 0",
"format": "prettier --write \"src/**/*.ts\"",
"backfill:specs": "tsx src/shopee/jobs/backfill-specs.ts",
- "test": "tsx src/shopee/__tests__/open-core-intelligence.test.ts && tsx src/shopee/__tests__/sourced-seeds.test.ts && tsx src/shopee/__tests__/entity-matcher.test.ts && tsx src/shopee/__tests__/item-matcher.test.ts && tsx src/shopee/__tests__/shopee-to-supabase.test.ts && tsx src/shopee/__tests__/paging.test.ts && tsx src/shopee/__tests__/backfill-review-risk.test.ts && tsx src/shopee/__tests__/item-inventory-report.test.ts && tsx src/shopee/__tests__/arena-audit.test.ts && tsx src/shopee/__tests__/item-aware-jobs.test.ts && tsx src/shopee/__tests__/product-points.test.ts && tsx src/shopee/__tests__/review-values.test.ts && tsx src/shopee/test-ai-enricher.ts && tsx src/shopee/test-review-analyzer.ts",
+ "test": "tsx src/shopee/__tests__/open-core-intelligence.test.ts && tsx src/shopee/__tests__/sourced-seeds.test.ts && tsx src/shopee/__tests__/entity-matcher.test.ts && tsx src/shopee/__tests__/item-matcher.test.ts && tsx src/shopee/__tests__/shopee-to-supabase.test.ts && tsx src/shopee/__tests__/paging.test.ts && tsx src/shopee/__tests__/backfill-review-risk.test.ts && tsx src/shopee/__tests__/item-inventory-report.test.ts && tsx src/shopee/__tests__/arena-audit.test.ts && tsx src/shopee/__tests__/item-aware-jobs.test.ts && tsx src/shopee/__tests__/product-points.test.ts && tsx src/shopee/__tests__/review-values.test.ts && tsx src/shopee/__tests__/review-ai-points.test.ts && tsx src/shopee/test-ai-enricher.ts && tsx src/shopee/test-review-analyzer.ts",
"test:dist": "node src/shopee/__tests__/dist-exports.smoke.mjs",
"backfill:review-risk": "tsx src/shopee/jobs/backfill-review-risk.ts",
"product-points": "tsx src/shopee/jobs/product-points.ts",
- "review-values": "tsx src/shopee/jobs/review-values.ts"
+ "review-values": "tsx src/shopee/jobs/review-values.ts",
+ "review-ai-points": "tsx src/shopee/jobs/review-ai-points.ts"
},
"keywords": [
"product-normalizer",
@@ -57,13 +58,14 @@
"node": ">=22.0.0"
},
"dependencies": {
+ "@anthropic-ai/sdk": "1.3.0",
"@repo/shared-data": "workspace:*",
"@supabase/supabase-js": "^2.117.2",
"dotenv": "^18.0.8"
},
"devDependencies": {
- "@types/node": "^26.6.5",
"@repo/eslint-config": "workspace:*",
+ "@types/node": "^26.6.5",
"eslint": "^9.39.5",
"prettier": "^3.9.6",
"tsx": "^4.23.15",
diff --git a/apps/importer/src/shopee/__tests__/product-points.test.ts b/apps/importer/src/shopee/__tests__/product-points.test.ts
index 300628437..e3e4f799d 100644
--- a/apps/importer/src/shopee/__tests__/product-points.test.ts
+++ b/apps/importer/src/shopee/__tests__/product-points.test.ts
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import {
buildProductPoints,
+ hideAiLinesFrom,
reviewPointWeight,
runProductPointsJob,
type PointReviewRow,
@@ -164,7 +165,10 @@ async function run() {
});
const dry = await runProductPointsJob(fake.client, { now: NOW });
assert.deepEqual(dry, { apply: false, masters: 3, points: 3, inserted: 0, deleted: 0 });
- assert.ok(fake.filters.includes('review_points:method=writer'), "only the writers' lines");
+ assert.ok(
+ fake.filters.includes('review_points:method in writer,ai'),
+ "the writers' lines and AI's"
+ );
assert.equal(fake.writes.length, 0, 'a dry run writes nothing');
const applied = await runProductPointsJob(fake.client, { apply: true, now: NOW });
@@ -192,7 +196,86 @@ async function run() {
);
await assert.rejects(runProductPointsJob(one.client, { masterId: 'not-a-uuid' }), /UUID/);
+ // Lines AI extracted from marketplace reviews (DOS/Rate#1531): each review is
+ // one writer at 0.5 x min(weight, 1), so three agreeing reviews show a point
+ // and two do not; --hide-ai leaves them all out.
+ const aiReviews = [
+ review('m1', 'l-quiet', { helpfulness_weight: 3 }),
+ review('m2', 'l-quiet', { helpfulness_weight: 3 }),
+ review('m3', 'l-quiet'),
+ ];
+ const aiPoint = (review_id: string, body: string, method = 'ai'): ReviewPointRow => ({
+ ...point(review_id, 'pro', body),
+ method,
+ });
+ const aiInput = (lines: ReviewPointRow[]) => ({
+ points: lines,
+ reviews: aiReviews,
+ listings,
+ masters,
+ categories,
+ });
+ const screen = 'Màn hình sáng rõ ngoài trời';
+ const twoAi = buildProductPoints(aiInput([aiPoint('m1', screen), aiPoint('m2', screen)]), NOW);
+ assert.deepEqual(twoAi.get(QUIET), [], 'two AI lines (0.5 + 0.5) do not show');
+ const threeAi = buildProductPoints(
+ aiInput([aiPoint('m1', screen), aiPoint('m2', screen), aiPoint('m3', screen)]),
+ NOW
+ );
+ assert.deepEqual(
+ threeAi.get(QUIET)!.map(p => [p.label, p.support, p.reviewer_count, p.has_expert]),
+ [[screen, 1.5, 3, false]]
+ );
+ assert.deepEqual(
+ buildProductPoints(
+ aiInput([aiPoint('m1', screen), aiPoint('m2', screen), aiPoint('m3', screen)]),
+ NOW,
+ { hideAiLines: true }
+ ).get(QUIET),
+ undefined,
+ 'hidden AI lines are not read at all'
+ );
+ // A line of another method (a reworded label, say) is not a review line.
+ assert.deepEqual(
+ buildProductPoints(
+ aiInput([
+ aiPoint('m1', screen, 'code'),
+ aiPoint('m2', screen, 'code'),
+ aiPoint('m3', screen, 'code'),
+ ]),
+ NOW
+ ).get(QUIET),
+ undefined
+ );
+ // The switch: --hide-ai or PRODUCT_POINTS_HIDE_AI.
+ assert.equal(hideAiLinesFrom(['node', 'job', '--hide-ai'], {}), true);
+ assert.equal(hideAiLinesFrom(['node', 'job'], { PRODUCT_POINTS_HIDE_AI: 'true' }), true);
+ assert.equal(hideAiLinesFrom(['node', 'job'], { PRODUCT_POINTS_HIDE_AI: ' 1 ' }), true);
+ assert.equal(hideAiLinesFrom(['node', 'job'], { PRODUCT_POINTS_HIDE_AI: 'false' }), false);
+ assert.equal(hideAiLinesFrom(['node', 'job'], {}), false);
+ assert.equal(hideAiLinesFrom(['node', 'job', '--hide-ai=yes'], {}), true);
+ assert.equal(hideAiLinesFrom(['node', 'job', '--hide-ai=false'], {}), false);
+ assert.equal(hideAiLinesFrom(['node', 'job'], { PRODUCT_POINTS_HIDE_AI: 'on' }), true);
+ // A value it does not know fails, never shows lines meant to be hidden.
+ assert.throws(
+ () => hideAiLinesFrom(['node', 'job'], { PRODUCT_POINTS_HIDE_AI: 'ye' }),
+ /true or false/
+ );
+ assert.throws(() => hideAiLinesFrom(['node', 'job', '--hide-ai=maybe'], {}), /true or false/);
+ // The job reads only the writers' lines when AI lines are hidden.
+ const hiddenRun = fakeSupabase({
+ review_points: points,
+ reviews,
+ products: listings,
+ master_products: masters,
+ categories,
+ product_points: [],
+ });
+ await runProductPointsJob(hiddenRun.client, { now: NOW, hideAiLines: true });
+ assert.ok(hiddenRun.filters.includes('review_points:method in writer'));
+
console.log(' ✅ weights, aggregation per master and category, dry run and apply verified');
+ console.log(' ✅ AI lines at half weight, and the switch that hides them (#1531)');
}
type Row = Record