Files
duocthu/docs/v1-delivery-plan.md
T

24 KiB

Kế hoạch giao bản v1 chạy được — 2 tuần

Lập ngày 2026-08-03. Hạn: ~2026-08-17.

Cập nhật bắt buộc 2026-08-04 — gate trước embedding

Phần hiện trạng ngày 2026-08-03 bên dưới được giữ làm lịch sử, nhưng không còn được dùng để quyết định chạy embedding. Candidate schema v4 đã được tạo và đo trên toàn corpus: 15.100 chunks (14.949 prose + 151 block descriptor), 4.105.382 token cl100k_base, 0 chunk quá 800 token. Candidate chưa phải artifact canonical cho đến khi vượt toàn bộ gate và thay thế data/processed/chunks.jsonl.

Thứ tự bắt buộc từ đây:

  1. khóa an toàn nội dung: label/liều không tách rời; source_text ghép lại đúng section; toàn bộ header bảng chưa kiểm chứng bị embargo khỏi text embedding;
  2. khóa provenance: range vật lý và range trang in phải chính xác theo từng chunk; attachment phải mang block_id, bbox, trang vật lý và trang in;
  3. khóa consumer: loader chỉ nhận đúng schema v4, từ chối schema cũ/mới và metadata sai kiểu hoặc sai miền;
  4. chạy test + chunk-ready trên candidate; chỉ khi mọi gate bằng 0 mới tái sinh artifact canonical và ghi SHA-256;
  5. smoke-test local bằng vector giả để kiểm plumbing/idempotency; xóa collection test;
  6. chỉ sau phê duyệt riêng của chủ dự án mới gọi provider có chi phí hoặc chạy embedding toàn corpus. Bedrock chỉ dùng để tìm hiểu/benchmark, không phải runtime dependency.

Định nghĩa READY TO EMBED: canonical là schema v4; toàn bộ readiness gate bằng 0; test ingestion và AI service liên quan đều pass; SHA corpus đã ghi; Qdrant không còn collection test; không có header/cell chưa kiểm chứng trong embedding text. Trạng thái này chỉ cho phép bước chuẩn bị kỹ thuật, không tự động cấp phép phát sinh chi phí.

Quy ước của tài liệu này, theo đúng luật trong CLAUDE.md:

  • (đo) = đã chạy thật trong phiên 2026-08-03, lệnh và kết quả ghi trong docs/progress-log.md.
  • (ước lượng) = phỏng đoán, chưa đo, có thể sai. Mọi con số thời gian trong tài liệu này đều là ước lượng — không có ngoại lệ.
  • [chờ xác nhận] = phụ thuộc quyết định của người chủ dự án, không được tự chọn thay.

Ước lượng thời gian giả định 1 người, ~6 giờ làm việc hiệu quả/ngày, 10 ngày công. Nếu thực tế là bán thời gian thì mục §8 (ngoài phạm vi) phải dài thêm, chứ không phải ép các mục còn lại chạy nhanh hơn.


0. Hiện trạng — đo, không phải nhớ

Thành phần Trạng thái
ingestion/ extract → segment → chunk Baseline 2026-08-03 đã hoàn thành; candidate schema v4 ngày 2026-08-04 có 15.100 chunks và đang chờ gate cuối trước khi trở thành canonical
ingestion/embed/ Đã có provider ports, cache, local BGE-M3 và adapter Bedrock; chưa được phép chạy provider trả phí/full corpus
ingestion/load/ Đã có validation fail-closed schema v4, manifest/hash, upsert idempotent và adapter Qdrant; còn nghiệm thu artifact canonical mới
apps/ai-service/ Đã có FastAPI/RAG, adapter Qdrant/Postgres, guardrails và citation theo region; còn nghiệm thu tích hợp trên corpus canonical mới
apps/api-gateway, auth-service, chat-service, user-service 0 file .ts mỗi service
apps/web/ 18 file, chat UI + PDF split-view chạy được, backend là mock (sendChatMessage = setTimeout(400ms) + fixture)
packages/shared-types DTO ChatMessage / Citation đã có
Dockerfile 0 cái trong toàn repo
infra/helm/medical-chatbot/templates/ rỗng, chỉ có .gitkeep; values.yaml chỉ có 2 dòng comment
infra/argocd/applications/{dev,staging,prod}/app.yaml Có sẵn, trỏ path: infra/helm/medical-chatbot, targetRevision: master; còn 3 TODO (project, repoURL, destination cluster)
infra/docker/docker-compose.yml Chỉ có postgres, qdrant, redis — không có service ứng dụng
CI Chỉ có infra/ci/github-actions/README.md
Tracing Không có gì

Tài sản không nằm trong repo nhưng có thật: quyền truy cập k3s của team, ArgoCD (admin), kubeconfig đã hoạt động. Đây là lý do phần deploy không bắt đầu từ số 0.


1. Phạm vi v1 — cắt gì, và vì sao đó không phải "ăn bớt"

Cắt khỏi v1: api-gateway, auth-service, user-service, chat-service

Bốn service này cộng lại đang là 0 dòng code (đo). Viết cả bốn bằng NestJS trong 2 tuần, song song với mọi việc khác, là thứ giết deadline — và không service nào trong bốn cái đó thêm năng lực cho bản chạy được: gateway là định tuyến, auth là đăng nhập, user là hồ sơ, chat là lịch sử.

Thay thế trong v1:

Nhu cầu Cách làm trong v1 Nợ kỹ thuật để lại
Chặn người ngoài Basic-auth ở ingress (hoặc header token dùng chung) Không có tài khoản cá nhân, không phân quyền
Lịch sử hội thoại ai-service ghi thẳng Postgres, bảng conversation / message Không có service riêng, không có sync đa thiết bị
Hồ sơ người dùng Không có Toàn bộ
Định tuyến web gọi thẳng ai-service Không có rate-limit/gateway policy tập trung

Điều này không mâu thuẫn với Clean Architecture đã ghi trong CLAUDE.md: domain là retrieval + grounding, còn auth/history/profile là hạ tầng. Tách chúng ra service riêng sau này không phải sửa domain — nếu domain được viết đúng ngay từ đầu (xem §4.C).

Ba thứ tuyệt đối không cắt, dù trễ

  1. Vector không bao giờ được chọn thuốc. Danh tính thuốc resolve tất định. Lý do đo được: cặp PANTOPRAZOL ↔ OMEPRAZOL có cosine chống chỉ định 1,000, DIGOXIN ↔ DIGITOXIN 0,891/0,911, NATRI NITRIT ↔ NATRI THIOSULFAT 0,631 ở phần liều. Để cosine chọn thuốc là chấp nhận rủi ro trả nhầm liều của thuốc khác.
  2. Trả về cả section, không phải top-k mảnh. Trả 2/5 chống chỉ định nguy hiểm hơn trả 0, vì thiếu sẽ bị đọc thành "không có chống chỉ định". Đã có bảo chứng: gate section_not_reassemblable_from_chunks = 0.
  3. Không đọc số liều từ 167 block quarantine (129 block = 77% nằm trong lieu_luong_va_cach_dung) — phải hiện ảnh crop trang gốc.

2. Giả định phải xác nhận trước khi bắt đầu

# Giả định mặc định của kế hoạch này Nếu khác thì đổi gì
GĐ-1 Đích deploy là k3s của team qua ArgoCD Nếu chỉ cần docker-compose demo: bỏ §4.E5-E8, tiết kiệm ~2 ngày (ước lượng)
GĐ-2 "Tracing" = trace LLM/RAG (câu hỏi → thực thể resolve → chunk lấy ra → prompt → câu trả lời → latency/token) Nếu là distributed tracing OTel giữa các service: v1 chỉ có 2 service nên giá trị thấp; xem §4.F
GĐ-3 Runtime giữ provider-agnostic; Bedrock chỉ để benchmark embedding, không là dependency bắt buộc Không gọi Bedrock/full corpus hoặc tạo chi phí nếu chưa có phê duyệt riêng; local smoke vector chỉ kiểm tra plumbing, không dùng làm số đo retrieval
GĐ-4 Dùng bản 2018 đang có Chuyển sang bản 2022 = chạy lại toàn bộ ingestion + validate lại từ đầu; không khả thi trong 2 tuần
GĐ-5 Câu hỏi runtime có thể chứa thông tin bệnh nhân Nội dung sách là tài liệu công khai nên embedding offline không rò rỉ gì; nhưng câu hỏi của bác sĩ thì có thể — cần quyết định chính sách trước khi mở cho người thật dùng [chờ xác nhận]

3. Kiến trúc v1

[ web (Next.js) ] ──HTTP──> [ ai-service (FastAPI) ] ──> Qdrant   (chunk + vector)
                                     │                └─> Postgres (hội thoại + trace)
                                     └──> provider cấu hình (không bắt buộc Bedrock)

[ ingestion CLI ] (offline, chạy tay) ──> Qdrant

Luồng trả lời, chế độ A (biết tên thuốc — chiếm phần lớn câu hỏi):

câu hỏi
  → resolve thực thể (khớp chính xác dài nhất trên bảng tên+alias)  → drug_id
  → phân loại ý định → section_key  (+ population nếu là câu hỏi liều)
  → LẤY TẤT CẢ chunk của (drug_id, section_key) từ Qdrant bằng FILTER, không phải bằng vector
  → ghép lại thành section đầy đủ
  → nếu section có attachment quarantine → kèm ảnh crop, và cấm mô hình đọc số từ đó
  → LLM soạn câu trả lời, bắt buộc trích: tên thuốc + tên mục + số trang IN

Luồng chế độ B (biết khái niệm, không biết thuốc — "thuốc nào trị tăng huyết áp"):

câu hỏi → embedding → vector search CHỈ trên section_key ∈ {chi_dinh, duoc_ly}
        → gom theo drug_id → trả DANH SÁCH thuốc ứng viên, không trả một thuốc
        → người dùng chọn → quay về chế độ A

Lý do chế độ B trả danh sách chứ không trả một thuốc: chi_dinh là field có độ giống chéo cao nhất (median 0,408, 27,5% số thuốc có hàng xóm > 0,5). Với nhóm PPI thì omeprazol và pantoprazol trùng chỉ định là đúng y học — trả cả nhóm mới đúng.


4. Công việc chi tiết

Ký hiệu kích thước (ước lượng): S ≈ nửa buổi · M ≈ 1 buổi · L ≈ 1 ngày · XL ≈ 2 ngày.

A. ingestion/embed/ + ingestion/load/

Chunk record candidate là schema v4. text là văn bản retrieval có thể lặp nhãn ngữ cảnh an toàn; source_text là đoạn nguồn liên tục dùng cho kiểm chứng và reassembly. Payload còn có context_labels, source_page_range, printed_page_range; mỗi attachment mang trang vật lý, trang in, block_id, bbox và crop nếu có. Loader không tự suy luận provenance và từ chối mọi schema khác v4.

# Việc File Nghiệm thu Size
A1 Cổng embedding (interface) + adapter OpenAI, batch + retry + backoff ingestion/embed/ports.py, embed/openai_provider.py Test với provider giả, không gọi mạng M
A2 Cache embedding ra đĩa theo chunk_id + sha256(text) embed/cache.py, data/processed/embeddings.jsonl Chạy lần 2 không gọi lại API; đếm cache-hit = 100% M
A3 cli embed ingestion/cli.py In: số chunk, số token thật, số call, chi phí; ghi file S
A4 Schema collection Qdrant + adapter load/qdrant_repo.py Tạo collection, index payload cho drug_id, section_key, atc_codes, chunk_kind M
A5 cli load — upsert idempotent, point id sinh tất định từ chunk_id ingestion/cli.py, load/upsert.py Chạy 2 lần → số point không đổi M
A6 Gắn corpus vào collection: lưu sha256 của chunks.jsonl vào metadata collection load/qdrant_repo.py Gate: sha256 lệch → cli load từ chối chạy, không upsert lẫn lộn hai đời corpus S

Khối lượng candidate: 4.105.382 token (đo bằng cl100k_base). Đơn giá phải tra bảng giá hiện hành trước khi chạy — không trích từ trí nhớ. Đây là hạng mục phải có phê duyệt riêng dù ước tính nhỏ.

Hai thiếu hụt từng chặn embedding — trang in và ngữ cảnh đối tượng/đường dùng — đã được xử lý trong schema v4. Chỉ được coi là xong khi audit toàn corpus trên artifact canonical xác nhận range chính xác và mọi chunk continuation giữ đủ nhãn ngữ cảnh.

B. Tầng thực thể / alias — làm sớm nhất, zero-regret

# Việc File Nghiệm thu Size
B1 Trích 344 dòng X - xem Y từ back index thành bảng alias ingestion/validation/back_index.py (thêm hàm mới, không đổi parse_back_index đang dùng cho validate) Đếm ra đúng 344 (đo); test hồi quy M
B2 Gom tên biệt dược từ 492 mục ten_thuong_mai ingestion/segment/ hoặc module mới entities/ Đếm được số alias thu thêm M
B3 Xuất data/verified/drug_entities.json: 683 tên chuẩn + alias + 1.043 mã ATC → drug_id mới Mọi drug_id phải tồn tại trong monographs.jsonl; 0 alias mồ côi M
B4 Bộ resolve khớp chính xác dài nhất, có test cho 19 cái bẫy substring entities/resolver.py HYDROCLOROTHIAZID không ra CLOROTHIAZID; PSEUDOEPHEDRIN không ra EPHEDRIN; DESLORATADIN không ra LORATADIN; HOMATROPIN HYDROBROMID không ra ATROPIN L

C. apps/ai-service

Cấu trúc theo Clean Architecture (CLAUDE.md): domain không import SDK.

# Việc File Nghiệm thu Size
C1 Khung FastAPI + /health + config qua env main.py, config.py curl /health S
C2 Cổng (interface): VectorStore, Embedder, Chat, PageRenderer domain/ports.py Domain test chạy không cần dịch vụ sống M
C3 Adapter Qdrant / OpenAI embed / OpenAI chat / PyMuPDF render adapters/ Test tích hợp riêng, đánh dấu @pytest.mark.integration L
C4 Hiểu truy vấn: tách thực thể thuốc (B4) + phân loại section_key + nhận diện đối tượng rag/understand.py Bộ test câu hỏi mẫu; ca không resolve được phải trả "không chắc", không đoán L
C5 Chế độ A: lấy theo filter, ghép section đầy đủ rag/retrieve.py Ghép lại đúng text section (so với monographs.jsonl) M
C6 Chế độ B: vector search giới hạn section_key, gom theo thuốc, trả danh sách rag/discover.py Trả ≥1 ứng viên cho câu hỏi chỉ định mẫu M
C7 Soạn câu trả lời + trích dẫn bắt buộc + từ chối khi không có căn cứ rag/answer.py Không có chunk → trả "không tìm thấy trong Dược thư", không để LLM tự bịa L
C8 Xử lý block quarantine: trả attachment + endpoint /crop?page=&bbox= render ảnh routers/crop.py Crop đúng vùng của p109_t0 (ACETAZOLAMID, trang vật lý 109) M
C9 Lưu hội thoại + trace vào Postgres adapters/pg.py, migration Hỏi 1 câu → 1 hàng trace đọc lại được M

D. apps/web

# Việc Nghiệm thu Size
D1 Bỏ mock, gọi thật ai-service (giữ nguyên DTO trong shared-types) Chat trả lời thật M
D2 Mở rộng Citation: thêm printedPage, chunkId, attachment? Type check pass S
D3 Click trích dẫn → nhảy đúng trang PDF (trang in, không phải trang vật lý) Kiểm bằng mắt 5 ca M
D4 Hiện ảnh crop cho block quarantine + nhãn cảnh báo "không trích số từ bảng này" Kiểm bằng mắt trên 1 ca có bảng liều M

E. Deploy

# Việc Nghiệm thu Size
E1 Dockerfile cho ai-service Build + chạy local M
E2 Dockerfile cho web (Next.js standalone) Build + chạy local M
E3 Bổ sung 2 service vào docker-compose.yml docker compose up ra bản chạy đầy đủ local M
E4 Nạp dữ liệu Qdrant: chạy cli embed + cli load qua port-forward, viết runbook docs/runbooks/load-qdrant.md (thư mục đang rỗng) M
E5 Helm templates: deployment/service/ingress cho 2 app + Qdrant (statefulset + PVC) helm template render sạch XL
E6 values-dev.yaml thật + Secret cho OpenAI key (không commit key) Secret tạo bằng tay hoặc sealed-secret M
E7 Gỡ 3 TODO trong ArgoCD Application (project, repoURL, destination) ArgoCD sync xanh M
E8 CI: build + test + push image + bump tag trong values 1 lần chạy thật xanh L

Ràng buộc đã ghi trong bộ nhớ dự án: repo gitops nội bộ (git.vinmec.tech/ai-team/gitops) là chỉ-đọc, không đẩy gì lên đó. ArgoCD Application trong repo này trỏ về chính repo này.

F. Tracing

Theo GĐ-2 (trace LLM/RAG). Đề xuất làm theo 2 mức, mức 1 trước:

Mức Nội dung Size
1 — bắt buộc Mỗi request sinh trace_id; ghi Postgres: câu hỏi, thực thể resolve được, section_key, danh sách chunk_id lấy ra, prompt gửi đi, câu trả lời, token in/out, latency từng bước, có/không dùng block quarantine. Kèm endpoint nội bộ /traces/{id} đọc lại L
2 — nếu còn thời gian Self-host Langfuse hoặc export OTel sang stack sẵn có của team XL

Nói thẳng: mức 2 không phải một buổi chiều. Langfuse bản mới cần thêm Clickhouse + Redis + object storage — đó là một hạng mục triển khai riêng. Mức 1 phục vụ đúng mục đích thật (debug một câu trả lời y khoa sai thì truy ngược được tới chunk và trang nào), và nó là thứ hợp với văn hoá provenance của dự án này.

G. Eval + gate

Tách đôi, không gộp:

# Việc Nghiệm thu Size
G1 Eval định tuyến — sinh tự động từ chính corpus: với mỗi (thuốc, field) tạo truy vấn mẫu, kiểm hệ có trả đúng drug_id + section_key. Ground truth suy ra từ dữ liệu, không bịa một câu nào Báo cáo % đúng; không đặt mục tiêu giả L
G2 Tập đối kháng — các cặp confusable đã đo (PPI, penicilin, digoxin/digitoxin, estriol/estron, contrast media, nitrit/thiosulfat) Gate wrong_drug_returned = 0 M
G3 Truy vấn bằng tên biệt dược trên 344 alias Gate brand_name_query_unresolved = 0 M
G4 Eval nội dung — cần dược sĩ/bác sĩ chấm Không tự làm được. Xem §8

5. Lịch 2 tuần (ước lượng, không phải cam kết)

Nguyên tắc xếp lịch: sau mỗi ngày phải luôn có thứ demo được, để nếu trễ thì trễ ở phần đuôi chứ không phải mất trắng.

Ngày Nội dung Cuối ngày có gì
1 Gate chunk v4: seam label/liều, embargo descriptor, provenance, schema fail-closed Mọi readiness gate bằng 0; artifact canonical + SHA được chốt
2 B1-B4 (thực thể/alias) + smoke A4-A6 bằng vector giả Gõ "Panadol" ra paracetamol; local Qdrant load đủ 15.100 point, idempotent, rồi dọn collection test
3 C1-C3 (khung + cổng + adapter) /health, gọi được Qdrant + OpenAI
4 C4-C5 (hiểu truy vấn + chế độ A) Hỏi "chống chỉ định metformin" ra đúng section qua HTTP
5 C7 + C9 (soạn câu trả lời + trace mức 1) Câu trả lời có trích dẫn, có trace đọc lại được
6 D1-D3 (web nối thật) Demo đầu tiên end-to-end trên máy local
7 C6 + C8 + D4 (chế độ B + crop bảng) Hỏi theo chỉ định ra danh sách; bảng liều hiện ảnh
8 G1-G3 (eval + 3 gate) Có số thật về độ đúng định tuyến
9 E1-E4 docker compose up ra bản đầy đủ; runbook nạp dữ liệu
10 E5-E7 Chạy trên k3s qua ArgoCD
Dự phòng E8 (CI), F mức 2, vá lỗi

Embedding thật không được gắn cứng vào “ngày 2”: chỉ chạy sau khi gate ngày 1 đã pass và chủ dự án phê duyệt provider, model, phạm vi và chi phí.

Không có ngày trống trong 10 ngày. Đây là rủi ro số 1 của kế hoạch: mọi sự cố đều ăn thẳng vào phần đuôi (CI, tracing mức 2).


6. Gate nghiệm thu v1

Theo phong cách sẵn có của dự án — có tên, có mục tiêu bằng 0.

Gate Mục tiêu Đo bằng
wrong_drug_returned (tập đối kháng) 0 G2
answer_without_citation 0 G1
dose_stated_from_quarantined_block 0 rà tay trên các ca có attachment
citation_uses_physical_page (phải là trang in) 0 G1
brand_name_query_unresolved (344 alias) 0 G3
qdrant_point_count ≠ chunk_count 0 A5
collection_corpus_sha_mismatch 0 A6
Độ đúng định tuyến (thuốc, field) báo số thật, không đặt ngưỡng giả G1
p95 latency đo rồi báo, không hứa trước tracing mức 1

7. Rủi ro, xếp theo mức độ

  1. Segmentation đang bị viết lại (Codex, ngay lúc này). Nếu assembler/ detector/vocab đổi thì chunks.jsonl đổi, và mọi embedding đã trả tiền phải tính lại. → Không chạy cli embed cho tới khi bản mới qua đủ: 164 test, 18/18 gate, cli validate ≥ 96,0%/99,1%, và so sha256 output với mốc đã lưu. Mốc: monographs 84f41d96…, chunks 63472db4….
  2. Helm viết từ trống (E5). Không có gì để copy trong repo. Đây là hạng mục dễ vỡ tiến độ nhất sau #1.
  3. Tracing mức 2 phình ra. → Chốt cứng: mức 1 là bắt buộc, mức 2 chỉ làm nếu ngày dự phòng còn trống.
  4. Một người, 10 ngày, không có slack. → Thứ tự trong §5 đã xếp sao cho ngày 6 đã có demo; nếu trễ thì trễ ở CI/tracing chứ không mất demo.
  5. Chưa có ai chấm nội dung y khoa. Gate ở §6 chứng minh hệ lấy đúng mục của đúng thuốckhông chứng minh câu trả lời đúng về y học.

8. Ngoài phạm vi v1 — nói thẳng, không giấu

  • api-gateway, auth-service, user-service, chat-service (§1).
  • Các chương tổng quát (in tr. 37-98) và phụ lục (in tr. 1497-1528) vẫn chưa vào corpus. Hỏi "Kê đơn thuốc", "Ngộ độc và thuốc giải độc" sẽ không ra gì. Cần nói trước với người dùng thử.
  • Benchmark chọn embedding model (bge-m3 vs multilingual-e5 vs provider khác). Runtime vẫn provider-agnostic; chưa chọn provider/model cho full corpus và không được gọi dịch vụ có chi phí khi chưa có phê duyệt riêng.
  • Tái dựng bảng 2D và nomogram — vẫn quarantine, chỉ hiện ảnh.
  • Đánh giá nội dung y khoa (G4): bắt buộc có dược sĩ/bác sĩ chấm. Tôi tự viết câu hỏi rồi tự chấm thì chỉ đo được trí tưởng tượng của mình, không đo được thực tế lâm sàng — đúng loại bằng chứng giả mà CLAUDE.md cấm.
  • Bản Dược thư 2022 (xuất bản lần 3). Bản đang dùng là 2018.
  • Mobile app.

9. Số nào đo, số nào đoán

Baseline lịch sử đã đo (2026-08-03, không dùng để load/embedding): toàn bộ bảng §0; 15.076 chunk; 4.072.725 token cl100k_base; 683/11.966/8.212.880; recall 96,0% (677/705), precision 99,1%; 18/18 gate; 164 test; 167 block quarantine (129 trong phần liều); 344 alias - xem; 401 cụm cross-reference; 492 mục ten_thuong_mai; 1.043 mã ATC; 19 tên thuốc là substring của tên khác; bảng cosine chéo giữa các thuốc; schema chunk v2. Các số này đã bị candidate schema v4 ở đầu tài liệu thay thế và chỉ còn giá trị đối chiếu lịch sử.

Chưa đo, là phỏng đoán: mọi ước lượng thời gian ở §4 và §5; chi phí embedding; p95 latency; độ khó thật của E5 (Helm) và F mức 2 (Langfuse); tỷ lệ câu hỏi rơi vào chế độ A so với chế độ B.

Chưa biết, chờ người quyết: GĐ-1, GĐ-2, GĐ-5 ở §2.