Estimator Tools
Internal · team VN

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.

Anthony · 15 Sep 2026 · tiền đề: Blueprint "Odoo là bộ não, mọi thứ khác là biên"
  1. 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.
  2. 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 fetch tay.
  3. 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ở.
  4. AI v1 chỉ trích xuất. Tin nhắn thành Trip có 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.
  5. 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

Sequence: trích xuất, tính giá, rồi người submit Giao diện gọi BFF để trích xuất tin nhắn bằng AI, người sửa rồi gọi BFF tính giá qua Odoo và nhận link báo giá; chỉ khi người bấm đặt chỗ BFF mới gọi submit sang Odoo. AI chỉ nói chuyện với BFF. OPT [người bấm Đặt chỗ · Idempotency-Key] POST /v1/extract {text} prompt + schema (cached) fields · state · evidence fields + câu hỏi thiếu người sửa và xác nhận trên form POST /v1/estimates {trip} POST compute · key theo vai model + rates_version estimate_id + url · đã snapshot POST /estimates/:id/submit POST booking/submit folio_id · order_ids folio_id · khóa nháp Người · trang UIstaff · agent · khách BFFHono · Vercel Odooestimate-api v1 Claudemessages · structured output AI chỉ nói với BFF. Không có mũi tên nào từ Claude tới Odoo. CHÚ GIẢI gọi trả về ra ngoài, có khóa riêng kết quả chính đang xử lý chỉ khi người bấm
Hai nửa: nửa trên là đọc, làm bao nhiêu lần cũng được. Khung OPT là lần ghi duy nhất, mở bằng tay người.

BFFStack và lý do

LớpChọnVì saoĐã cân nhắc, không chọn
HTTP frameworkHono + @hono/zod-openapiVà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 ở đâuVercel Functions, Node runtimeCù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 Odooopenapi-typescript + openapi-fetch sinh từ spec của PhillipSai 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.
DatabaseSupabase Postgres + DrizzleMigration 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.
AuthSupabase Auth, vai trong app_metadataMagic 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 limitBảng Postgres rate_bucket ở v1Casa 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.
TestVitest + Prism mock từ OpenAPI + differential testMock 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ổngAi gọiBFF làm gìGọi OdooGhi
POST /v1/extractUI, adapter WhatsAppGọ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ếukhôngextraction
POST /v1/estimatesUI, adapterValidate Trip, chọn key theo vai, compute, lưu snapshot, trả estimate_id + urlcomputescenario, revision, snapshot
PATCH /v1/estimates/:idUINhận thay đổi từng field, merge vào revision mới, compute lạicomputerevision, snapshot
GET /v1/estimates/:idUI, link trong WhatsAppTrả revision mới nhất theo quyền của vaikhông—
POST /v1/estimates/:id/submitchỉ phiên ngườiKiểm Idempotency-Key, gửi contact + trip + guests, khóa scenariobooking/submitsubmission
GET /v1/ratesUICache 60 giây, trả rate card theo vairates—
POST /internal/whatsappHermes webhook, v2Xác minh chữ ký, extract, estimates, trả lời bằng urlqua hai cổng trên—

Xử lý lỗi và độ bền

ViệcQuy tắcLý do
Timeout gọi Odoo8 giây cho compute, 20 giây cho submitSky đ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.
Retrycompute: 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ạiCompute không ghi gì nên retry vô hại. Submit ghi folio.
Circuit breaker5 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.
IdempotencyBảng idempotency khóa theo (scenario_id, key), giữ 24 giờ, trả lại response cũ nếu trùngThay cho hack gộp request bên Odoo.
Logpino JSON, mỗi dòng có request-id, vai, route, thời gian Odoo, thời gian Claude, tokenSau một tuần in ra p50 và p95 thật.
Feature flagPRICING_SOURCE=local|odoo đọc từ env, UI hiện cả hai khi bật chế độ so sánhChuyển từ calc() sang Odoo có đường lui.

Hình BSáu cổng kiểm soát, ở đâu, ai giữ

Catalog sáu cổng kiểm soát theo nơi thực thi Sáu lớp kiểm soát xếp theo đường đi của một request: rìa mạng, danh tính, hợp đồng, ánh xạ vai sang khóa, hành động của người, và Odoo; lớp hành động của người là lớp trọng tâm và không có ngoại lệ. REQUEST ĐI TỪ TRÊN XUỐNG AI THỰC THI · KHI NÀO · AI GIỮ · NGOẠI LỆ G1 Rìa mạng rate limit IP + phiên · body ≤ 64 KB · CORS · request-id Vercel · run · pod Edge ngoại lệ: allowlist IP văn phòng G2 Danh tính Supabase Auth cho staff, agent · khách = cookie ký · vai nằm trong JWT platform · run · pod Edge ngoại lệ: không G3 Hợp đồng zod sinh từ OpenAPI · cap 60 khách, 1 năm · field lạ = 422 code · run · pod Contract ngoại lệ: không G4 Vai → khóa chọn ODOO_KEY_{guest,agent,staff} · không chuyển token người dùng sang Odoo code · run · pod Edge ngoại lệ: không G5 Hành động của người submit chỉ với phiên người thật + Idempotency-Key · token AI = 403 code + người · run · Anthony ngoại lệ: KHÔNG CÓ G6 Odoo sanitize_trip · role gating theo key · dedupe request mở code · run · Phillip ngoại lệ: staff override assumptions CHÚ GIẢI cổng không ai được đi vòng, kể cả admin code = kiểm trong mã · platform = dịch vụ kiểm · người = phải có người bấm
Mỗi request đi qua đủ sáu lớp. Lớp G5 là lý do AI không bao giờ tạo được folio dù có lỗi ở bốn lớp trên.

Hình CDraft store

Mô hình dữ liệu Draft store Năm thực thể: scenario là gốc, có nhiều revision, mỗi revision có một snapshot giá; scenario có nhiều extraction từ AI và tối đa một submission trỏ tới snapshot đã gửi sang Odoo. 1N 11 1N 10..1 1N có các vòng sửa được tính giá gửi đúng bản này ROOT scenario # id owner_role · owner_ref source form | ai | whatsapp status draft | submitted | expired expires_at (+90 ngày) created_at ENTITY revision # id → scenario_id seq 1, 2, 3… trip jsonb (đúng schema Odoo) author human | ai created_at ENTITY snapshot # id → revision_id model jsonb (response compute) rates_version role guest | agent | staff computed_at · odoo_ms ENTITY extraction # id → scenario_id raw_text (xóa sau 30 ngày) fields jsonb + state + evidence model · prompt_version tokens_in · tokens_out · ms ENTITY submission # id → scenario_id · → snapshot_id idempotency_key (unique) folio_id · order_ids state sent | paid | failed submitted_by (user id thật) CHÚ GIẢI # khóa chính · → khóa ngoại · jsonb giữ nguyên payload của Odoo, không tách cột gốc, RLS gắn vào bảng này
Không tách cột từ payload của Odoo. Odoo đổi schema thì Draft store không cần migration.

RLS và vòng đời

VaiĐọcGhiCách nhận diện
Khách ẩn danhscenario có owner_ref = cookie của mìnhtạo scenario, revision, extraction của mìnhcookie ký HMAC, sống 30 ngày
Agentscenario mình tạonhư khách, cộng submitSupabase Auth, role=agent
Stafftất cảtất cả, kể cả sửa scenario của kháchSupabase Auth, role=staff
BFF servicetất cảchỉ qua các cổng; service key không bao giờ xuống clientservice role key trong env

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èm evidence là 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

  1. 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.
  2. Gọi Claude với output_config.format là 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.
  3. Hậu xử lý bằng code: đổi ngày tương đối, suy checkOut từ số đêm, áp house norm cho đúng bốn field được phép default.
  4. 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.
  5. Sinh câu hỏi cho field missing và default theo 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.
  6. Ghi extraction vớ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ệcModelVào $/1MRa $/1MGhi chú
Trích xuất, mặc địnhclaude-opus-55.0025.00PoC đ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ó đoclaude-sonnet-52.0010.00Chỉ đổ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-51.005.00Chỉ 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-55.0025.00Chạy trong CI khi prompt đổi, không chạy trên production.
Điểm đánh đổiChọnĐổi lại
Gọi Anthropic trực tiếp hay qua relay WokuAnthropic trực tiếp cho productionCầ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 frameworkSDK thẳng + zodTự 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 JSONoutput_config.formatKhô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 cacheSystem prompt + schema cố định đặt trước, cache_control một điểmNgà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.
Thinkingadaptive, effort low cho trích xuấtTrí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ố đoNgưỡngVì sao
Field bịa (giá trị có mà tin nhắn không nói và không có house norm)0Mộ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ăn100%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âyNgườ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Ở đâuGhi chú
ODOO_BASE_URL, ODOO_KEY_GUEST, ODOO_KEY_AGENT, ODOO_KEY_STAFFVercel env, production và preview tách nhauPreview trỏ staging của Phillip. Chưa có key thì preview dùng Prism mock.
SUPABASE_URL, SUPABASE_SERVICE_KEY, SUPABASE_JWT_SECRETVercel envService key chỉ trong BFF. Trang HTML chỉ có anon key và chỉ để đăng nhập.
ANTHROPIC_API_KEYVercel envKey riêng của TechNext, giới hạn chi tiêu tháng bật trong console.
PRICING_SOURCE, GUEST_COOKIE_SECRETVercel envFlag và bí mật ký cookie.

Ba podAi làm gì tuần này

Pod Contract & Test

  • Kéo spec của Phillip vào contracts/, sinh odoo-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_SOURCE trong trang estimator, hiện hai số cạnh nhau.