BFF và AI service cho Casa: làm gì, làm thế nào, đánh đổi gì.
Playbook cho ba pod. Đọc phần TL;DR và ba hình là đủ để bắt đầu; bảng bên dưới là để tra khi code.
- BFF = Hono + TypeScript, chạy Vercel Functions. Nhỏ, chạy local bằng một lệnh, dời sang Cloudflare hay VPS không phải viết lại.
- Hợp đồng hai chiều, đều sinh code. Spec của Phillip sinh client gọi Odoo; zod trong BFF sinh spec cho UI và AI. Không ai viết
fetchtay. - Dữ liệu nháp ở Supabase Postgres, RLS đóng, truy cập qua Drizzle. Auth cũng của Supabase. Không dùng lại project có RLS mở.
- AI v1 chỉ trích xuất. Tin nhắn thành
Tripcó trạng thái từng field. Không tính giá, không submit, không multi-agent. SDK Anthropic thẳng, structured outputs, không framework. - Sáu cổng kiểm soát, một cổng không có ngoại lệ: submit chỉ đi với phiên người thật và Idempotency-Key. Token của AI gọi submit nhận 403.
Hình AAPI đi qua BFF và qua AI như thế nào
BFFStack và lý do
| Lớp | Chọn | Vì sao | Đã cân nhắc, không chọn |
|---|---|---|---|
| HTTP framework | Hono + @hono/zod-openapi | Vài KB, chạy Node, Vercel, Cloudflare, Bun như nhau. Route nào cũng có schema zod nên tự sinh OpenAPI cho UI và AI. | Next.js API routes: kéo cả framework UI vào chỉ để có endpoint, trong khi estimator là một file HTML. NestJS: nhiều nghi thức, fresher mất tuần đầu vào decorator. |
| Chạy ở đâu | Vercel Functions, Node runtime | Cùng chỗ với trang estimator, preview theo PR, log sẵn. Node runtime để dùng SDK Anthropic và Postgres driver bình thường. | Supabase Edge Functions (Deno): DX local kém hơn, buộc chặt vào Supabase. Giữ Supabase cho DB và Auth thôi. |
| Gọi Odoo | openapi-typescript + openapi-fetch sinh từ spec của Phillip | Sai kiểu là không compile. Phillip đổi spec, CI đỏ ngay chứ không phải Evane phát hiện. | Viết fetch tay: nhanh ngày đầu, lệch hợp đồng tuần thứ hai. |
| Database | Supabase Postgres + Drizzle | Migration là SQL thật, RLS viết được trong migration, kiểu TypeScript sinh từ schema. Project mới dưới tổ chức TechNext. | Prisma: migration khó sống chung với RLS. Project Supabase cũ của Sky: RLS đang mở, không dùng. |
| Auth | Supabase Auth, vai trong app_metadata | Magic link cho staff và agent. Khách ẩn danh nhận cookie ký, không cần tài khoản. | Tự viết session: không có lý do. Odoo portal user cho staff: đụng giới hạn 7 user và không gắn được với trang ngoài. |
| Rate limit | Bảng Postgres rate_bucket ở v1 | Casa vài chục request một giờ. Một bảng và một hàm SQL là đủ, không thêm dịch vụ. | Upstash Redis: đúng khi lên WhatsApp công khai. Để dành v2. |
| Test | Vitest + Prism mock từ OpenAPI + differential test | Mock server từ spec để hai pod kia không chờ Odoo staging. Differential test tìm lệch giá. | Postman collection tay: không chạy trong CI. |
Các cổng của BFF
| Cổng | Ai gọi | BFF làm gì | Gọi Odoo | Ghi |
|---|---|---|---|---|
POST /v1/extract | UI, adapter WhatsApp | Gọi Claude với schema Trip có trạng thái, hậu xử lý ngày tháng, trả câu hỏi cho field thiếu | không | extraction |
POST /v1/estimates | UI, adapter | Validate Trip, chọn key theo vai, compute, lưu snapshot, trả estimate_id + url | compute | scenario, revision, snapshot |
PATCH /v1/estimates/:id | UI | Nhận thay đổi từng field, merge vào revision mới, compute lại | compute | revision, snapshot |
GET /v1/estimates/:id | UI, link trong WhatsApp | Trả revision mới nhất theo quyền của vai | không | — |
POST /v1/estimates/:id/submit | chỉ phiên người | Kiểm Idempotency-Key, gửi contact + trip + guests, khóa scenario | booking/submit | submission |
GET /v1/rates | UI | Cache 60 giây, trả rate card theo vai | rates | — |
POST /internal/whatsapp | Hermes webhook, v2 | Xác minh chữ ký, extract, estimates, trả lời bằng url | qua hai cổng trên | — |
Xử lý lỗi và độ bền
| Việc | Quy tắc | Lý do |
|---|---|---|
| Timeout gọi Odoo | 8 giây cho compute, 20 giây cho submit | Sky đo route chậm nhất Hirsh là 80 giây. Không để người dùng chờ vô hạn rồi bấm đúp. |
| Retry | compute: 1 lần khi 502 hoặc 503. submit: không retry tự động, chỉ retry với cùng Idempotency-Key khi người bấm lại | Compute không ghi gì nên retry vô hại. Submit ghi folio. |
| Circuit breaker | 5 lỗi trong 60 giây thì mở 30 giây, trả 503 kèm thông điệp "Odoo đang bận" | Không dồn thêm request vào worker Odoo đang nghẹt. |
| Idempotency | Bảng idempotency khóa theo (scenario_id, key), giữ 24 giờ, trả lại response cũ nếu trùng | Thay cho hack gộp request bên Odoo. |
| Log | pino JSON, mỗi dòng có request-id, vai, route, thời gian Odoo, thời gian Claude, token | Sau một tuần in ra p50 và p95 thật. |
| Feature flag | PRICING_SOURCE=local|odoo đọc từ env, UI hiện cả hai khi bật chế độ so sánh | Chuyển từ calc() sang Odoo có đường lui. |
Hình BSáu cổng kiểm soát, ở đâu, ai giữ
Hình CDraft store
RLS và vòng đời
| Vai | Đọc | Ghi | Cách nhận diện |
|---|---|---|---|
| Khách ẩn danh | scenario có owner_ref = cookie của mình | tạo scenario, revision, extraction của mình | cookie ký HMAC, sống 30 ngày |
| Agent | scenario mình tạo | như khách, cộng submit | Supabase Auth, role=agent |
| Staff | tất cả | tất cả, kể cả sửa scenario của khách | Supabase Auth, role=staff |
| BFF service | tất cả | chỉ qua các cổng; service key không bao giờ xuống client | service role key trong env |
- TTL:
pg_cronmỗi đêm. Scenariodraftquá 90 ngày sangexpiredrồi xóa sau 7 ngày nữa.raw_textxóa sau 30 ngày dù scenario còn. - Không xóa scenario đã
submitted: nó là bằng chứng báo giá gửi theorates_versionnào. - PII: email, số điện thoại được che trước khi ghi log. Dữ liệu eval lấy từ
extractionsau khi che tên.
AI serviceLàm gì, làm thế nào
Làm ở v1
- Tin nhắn Việt, Anh, Trung thành
Tripđúng schema của Phillip. - Mỗi field một trạng thái:
stated,inferred,default,derived,missing, kèmevidencelà chuỗi cắt nguyên văn. - Phát câu hỏi cho field thiếu. Backend quyết hỏi field nào, model chỉ đặt câu.
- Ngày tương đối tính bằng code theo giờ Manila, không tin model.
Không làm ở v1
- Không tính giá, không gọi
submit, không tự gọi Odoo. - Không multi-agent, không "agent tự lập kế hoạch".
- Không fine-tune, không LoRA. Chưa có dữ liệu để làm.
- Không lưu tin nhắn gốc quá 30 ngày.
Đường ống trong POST /v1/extract
- Chuẩn hóa: cắt khoảng trắng, phát hiện ngôn ngữ bằng heuristic, che email và số điện thoại trước khi log.
- Gọi Claude với
output_config.formatlà JSON Schema sinh từ zod của Trip, system prompt cố định đặt trước để hưởng prompt cache, ngày hôm nay đặt trong user message chứ không trong system. - Hậu xử lý bằng code: đổi ngày tương đối, suy
checkOuttừ số đêm, áp house norm cho đúng bốn field được phépdefault. - Validate bằng zod. Sai schema thì gọi lại một lần với lỗi đính kèm, lần hai vẫn sai thì trả 422 và ghi lại mẫu để đưa vào eval.
- Sinh câu hỏi cho field
missingvàdefaulttheo danh sách ưu tiên của backend: ngày, số khách, phòng, rồi mới tới ăn uống và xe. - Ghi
extractionvới model, prompt_version, token, thời gian. Trả về UI. Không có cổng nào từ đây đi tiếp sang Odoo.
Model và chi phí
| Việc | Model | Vào $/1M | Ra $/1M | Ghi chú |
|---|---|---|---|---|
| Trích xuất, mặc định | claude-opus-5 | 5.00 | 25.00 | PoC đang dùng. Một tin nhắn khoảng 2k token vào, 800 ra: dưới 3 cent. Với vài chục enquiry một ngày, chi phí không phải biến số. |
| Trích xuất, bước hạ có đo | claude-sonnet-5 | 2.00 | 10.00 | Chỉ đổi sang khi eval cho cùng điểm trên bộ 30 tin nhắn thật. Không hạ vì đoán rẻ hơn. |
| Phát hiện ngôn ngữ, phân loại "có phải enquiry không" | claude-haiku-4-5 | 1.00 | 5.00 | Chỉ khi WhatsApp lên, để lọc tin nhắn không phải yêu cầu báo giá. |
| Chấm eval (LLM judge) | claude-opus-5 | 5.00 | 25.00 | Chạy trong CI khi prompt đổi, không chạy trên production. |
| Điểm đánh đổi | Chọn | Đổi lại |
|---|---|---|
| Gọi Anthropic trực tiếp hay qua relay Woku | Anthropic trực tiếp cho production | Cần key riêng của TechNext. Relay chỉ giữ cho thử nghiệm vì không đảm bảo structured outputs và prompt cache. |
| SDK thẳng hay LangChain / agent framework | SDK thẳng + zod | Tự viết khoảng 150 dòng gọi và validate. Đổi lại đọc được toàn bộ, không có lớp trừu tượng nào che lỗi. |
| Structured output hay tool-use để lấy JSON | output_config.format | Không cần vòng lặp tool. Khi cần model tự quyết gọi extract hay estimates (WhatsApp v2) mới dùng tool-use. |
| Prompt cache | System prompt + schema cố định đặt trước, cache_control một điểm | Ngày hôm nay và tin nhắn đi sau điểm cache. Kiểm cache_read_input_tokens khác 0 trong log tuần đầu. |
| Thinking | adaptive, effort low cho trích xuất | Trích xuất không cần suy luận dài. Đo lại khi thấy field inferred sai nhiều. |
Eval, số phải đạt trước khi cho staff dùng
| Số đo | Ngưỡng | Vì sao |
|---|---|---|
| Field bịa (giá trị có mà tin nhắn không nói và không có house norm) | 0 | Một số sai đeo nhãn xanh đi thẳng tới khách. Đây là lỗi duy nhất không được phép. |
| Field bắt buộc đúng (ngày, số khách, số phòng) khi tin nhắn có nói | ≥ 95% | Dưới mức này staff sửa nhiều hơn tự gõ. |
evidence là chuỗi con nguyên văn | 100% | Kiểm bằng code, không cần judge. |
| Câu hỏi thiếu đúng field | ≥ 90% | Judge chấm, chạy khi prompt đổi. |
| p95 thời gian extract | ≤ 8 giây | Người đang chờ trên form. |
Bộ eval là 30 tin nhắn thật đã che tên từ Eloa. Mười mẫu tự bịa trong PoC chỉ để chạy thử giao diện, không được dùng để báo con số.
Cấu trúcRepo, env, CI
technext-edge/
contracts/casa/estimate-api.v1.yaml ← Phillip export; CI diff, breaking = major
packages/
odoo-client/ ← sinh từ contracts, không sửa tay
bff-core/ ← auth, vai→key, rate limit, idempotency, log, breaker
extractor/ ← schema Trip có trạng thái, prompt (versioned), hậu xử lý, eval runner
draft-store/ ← drizzle schema, migration SQL kèm RLS, TTL job
apps/
casa-bff/ ← Hono app, deploy Vercel, đọc env
casa-estimator/ ← trang HTML hiện tại, gọi casa-bff
casa-whatsapp/ ← adapter Hermes, v2
evals/casa/ ← 30 tin nhắn thật đã che tên + kỳ vọng, chạy trong CI
docs/adr/ ← ADR-001..003 từ Blueprint, thêm ADR-004 (stack biên) ADR-005 (AI scope)
| Biến env | Ở đâu | Ghi chú |
|---|---|---|
ODOO_BASE_URL, ODOO_KEY_GUEST, ODOO_KEY_AGENT, ODOO_KEY_STAFF | Vercel env, production và preview tách nhau | Preview trỏ staging của Phillip. Chưa có key thì preview dùng Prism mock. |
SUPABASE_URL, SUPABASE_SERVICE_KEY, SUPABASE_JWT_SECRET | Vercel env | Service key chỉ trong BFF. Trang HTML chỉ có anon key và chỉ để đăng nhập. |
ANTHROPIC_API_KEY | Vercel env | Key riêng của TechNext, giới hạn chi tiêu tháng bật trong console. |
PRICING_SOURCE, GUEST_COOKIE_SECRET | Vercel env | Flag và bí mật ký cookie. |
- CI mỗi PR: typecheck, vitest, contract test với Prism, eval extractor nếu thư mục
extractor/đổi, deploy preview Vercel. - CI đêm: differential test 200 chuyến giữa
calc()của Sky vàcomputestaging, báo diff vào nhóm chat. - Không ai push thẳng main. Phillip review mọi PR chạm
contracts/; Anthony review mọi PR chạmbff-core/.
Ba podAi làm gì tuần này
Pod Contract & Test
- Kéo spec của Phillip vào
contracts/, sinhodoo-client, dựng Prism mock. - Differential test 200 chuyến, in bảng lệch giá.
- Contract test: BFF khởi động thất bại nếu spec đổi kiểu.
Pod Extractor
- Đổi output PoC sang schema Trip của Phillip, giữ trạng thái từng field.
- Viết hậu xử lý ngày và house norm bằng code, có test.
- Nhận 30 tin nhắn thật, dựng eval runner, báo năm số đo.
Pod Edge UI & BFF
- Hono skeleton với G1, G2, G4, log, breaker. Deploy preview.
- Drizzle schema + migration RLS trên Supabase project mới.
- Flag
PRICING_SOURCEtrong trang estimator, hiện hai số cạnh nhau.