Answer Template — Danh sách API
Answer template thành kind thứ 5 trong version-svc. Mô hình v0-mutable (đổi immutability invariant): gộp draft + version vào MỘT bảng version.components — Draft = version_no=0 (v0, mutable), published = version_no≥1 (frozen + ký Ed25519). DSL luôn ở version-svc, project-svc chỉ giữ metadata entity (GET detail/builder lấy DSL v0 qua S2S resolve). Tầng gọi: FE → web-bff → project-svc (authz + metadata) → S2S → version-svc (DSL + versioning). version-svc /v1/internal/* KHÔNG lộ FE (FR-BFFWS-011); agent-runtime chỉ render. Đổi hard invariant + re-platform version-svc → cần RFC + ADR. Chi tiết: documents/ANSWER_TEMPLATE_VERSION_INTEGRATION.md.
Danh mục API RIÊNG của chức năng Answer Template — 7 route FE-facing (5 CRUD template project-svc + 2 versioning draft/publish) + 4 S2S internal (upsert-v0 / resolve-v0 / publish / resolve-version) + 1 S2S delete-gate + 1 đặc tả transport (card đi trên stream chat, KHÔNG endpoint riêng). Publish riêng của template đã bỏ — publish theo Workspace Version. Mỗi mục có đặc tả (thiết kế đích) — bấm "Đặc tả" trong từng card. POST/GET-only per OPTIMAL §1.8.8. Chat / follow-up chips / transcript / config env là API phân hệ khác, không liệt kê ở đây.
Nguyên tắc chung
6 quy tắc chi phối toàn bộ bề mặt API bên dưới.
Mọi thao tác Studio: FE → web-bff (public /v1, JWT) → project-svc (authz + orchestrate) → S2S → version-svc / agent-runtime. version-svc /v1/internal/* (draft/publish/resolve) KHÔNG lộ FE (FR-BFFWS-011). project-svc own template CRUD + gọi clients/version.py cho draft/publish. agent-runtime chỉ render.
Mutation → POST sub-resource (.../update, .../{id}/delete). Cấm PUT/PATCH/DELETE.
POST side-effect require header Idempotency-Key — ULID 26 ký tự hoặc UUID v4. Key trùng, body khác → IDEMPOTENCY_REPLAY.
Draft + version chung MỘT bảng version.components. Sửa DSL = UPSERT row version_no=0 (v0, mutable). Publish = copy v0 đổi hash → INSERT row version_no=next (freeze DSL + ký Ed25519); không đổi → carry-forward version_no cũ. Row version_no≥1 INSERT-only immutable (enforce partial theo version_no). v0 vẫn mutable cho version sau. Đây là đổi immutability invariant — merge draft_components + workspace_version_components, cần RFC.
Filter card_action_url_allowlist chạy ở agent-runtime lúc render (Seam 1). Strip là log-only — không sinh mã lỗi, không fail turn. conversation-svc persist bản đã strip, web-bff forward nguyên trạng.
Card đi trên chính stream chat: SSE message_end.metadata.card — always-present, turn không card trả null. Gắn card vào agent cũng KHÔNG có API riêng — ref nằm trong agent definition, đóng băng theo version.
{"error": {code, message, trace_id, ts, details}} — chi tiết ở Bảng mã lỗi.
Luồng tổng quan
Từ thiết kế đến replay — 5 chặng, mỗi chặng map vào một nhóm API bên dưới.
Design-time — Answer Template đích: project-svc
Thiết kế đích: owner CRUD chuyển sang project-svc (như agent/workflow entity), public qua web-bff path /v1/projects/{project_id}/answer-templates/*, auth JWT user. As-built (TKCT nhúng): owner agent-runtime schema card, path /v1/answer-templates, CG-004. RLS app.project_id.
8 khối dựng card (Form · Text · Image/Media · List · Selector · Button/CTA · Carousel · Confirmation) do Visual Builder GrapesJS định nghĩa ở FE (js/builder.js blockManager) — KHÔNG gọi API. Không đưa GET /v1/card-components vào danh mục answer-card (cùng loại FE-only với Visual Builder / Live Preview). Chặn block sai lúc publish do agent-runtime validate bằng danh sách 8 type cố định phía server. TKCT 3.9.2 từng đề xuất endpoint catalog để builder + validator dùng chung props_schema — không cần khi builder là GrapesJS.
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
actor Dev as Dev/PO (Studio)
participant Web as web-bff (public /v1)
participant PS as project-svc (owner + authz)
Dev->>Web: GET /v1/projects/{pid}/answer-templates?q=&status=&cursor= (JWT)
Web->>PS: forward (verified JWT, X-Project-Id)
Note over PS: verify project-member + quyền viewer<br/>SET LOCAL app.project_id (RLS)
Note over PS: SELECT project.answer_templates WHERE deleted_at IS NULL (filter + cursor)
PS-->>Web: 200 {data, next_cursor}
Web-->>Dev: Render danh sách + trạng thái published
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | Dev/PO | Mở màn Answer Template | Studio gọi list khi mount + đổi filter/search. FE → web-bff → project-svc. |
| 2 | project-svc | Verify JWT + quyền | JWT sai → 401; thiếu quyền viewer → 403. RLS SET LOCAL app.project_id. |
| 3 | project-svc | Query danh sách | SELECT project.answer_templates (loại soft-deleted), filter q (ILIKE), status, cursor keyset. |
| 4 | project-svc | Trả kết quả | Mỗi item kèm current_version (version_no đã publish gần nhất, null nếu chưa). |
| 5 | FE | Hiển thị | Render bảng template; lỗi → toast theo error.code. |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| GET | /v1/projects/{pid}/answer-templates | List answer template của project (RLS), cursor pagination, filter + search. Owner project-svc. |
API Request
Header: Authorization Bearer JWT (Y).
Query: q (search ILIKE), status (published/draft_only), cursor, limit (≤100).
API Response
200 — {data[], next_cursor}; mỗi item: id, name, description, current_version, updated_by, updated_at.
Bảng mã lỗi
| HTTP | code | message |
|---|---|---|
| 400 | VALIDATION_FAILED | Body/param sai schema hoặc vượt giới hạn |
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST side-effect thiếu Idempotency-Key hoặc sai format ULID/UUID v4 |
| 401 | JWT_MISSING · JWT_INVALID | Thiếu Authorization / signature-aud-exp không hợp lệ |
| 403 | FORBIDDEN | Token thiếu claim project_id/sub hoặc thiếu quyền editor/viewer |
| 404 | NOT_FOUND | answer_template_id không tồn tại trong project scope |
| 409 | NAME_CONFLICT | Trùng (project_id, name) với template chưa xóa |
| 409 | TEMPLATE_IN_USE | Xóa khi còn Workspace Version tham chiếu |
| 500 | INTERNAL_ERROR | Lỗi không phân loại — response không lộ stacktrace |
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
actor Dev as Dev/PO
participant Web as web-bff
participant PS as project-svc
participant VS as version-svc
Dev->>Web: GET /v1/projects/{pid}/answer-templates/{id} (JWT)
Web->>PS: forward
Note over PS: SELECT metadata entity (id, name, description, RLS project)
alt không có / đã xóa mềm
PS-->>Web: 404 NOT_FOUND
else tồn tại
PS->>VS: S2S GET /v1/internal/components/{ws}/resolve (lấy DSL v0)
VS-->>PS: DSL v0 + version history (version_no≥1)
PS-->>Web: 200 {metadata + dsl + versions[]}
end
Web-->>Dev: Mở Visual Builder với DSL v0
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | Dev/PO | Click template | FE gọi detail để mở builder + panel version. |
| 2 | project-svc | Verify + metadata | 401/403 chuẩn; id không trong project (RLS) → 404. project-svc CHỈ giữ metadata entity, KHÔNG có DSL. |
| 3 | project-svc → version-svc | Lấy DSL v0 (S2S) | DSL sống ở version-svc: S2S GET /v1/internal/components/{ws}/resolve trả row version_no=0 (draft đang sửa) + version history version_no≥1. |
| 4 | FE | Hiển thị | Mở Visual Builder (GrapesJS) với DSL v0; so v0 vs current_version để badge 'chưa publish'. |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| GET | /v1/projects/{pid}/answer-templates/{id} | Chi tiết: project-svc metadata + S2S version-svc lấy DSL v0 + version history. |
API Response
200 — {id, name, description, dsl, current_version_id, versions[]}. 404 NOT_FOUND nếu không thuộc project.
Bảng mã lỗi
| HTTP | code | message |
|---|---|---|
| 400 | VALIDATION_FAILED | Body/param sai schema hoặc vượt giới hạn |
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST side-effect thiếu Idempotency-Key hoặc sai format ULID/UUID v4 |
| 401 | JWT_MISSING · JWT_INVALID | Thiếu Authorization / signature-aud-exp không hợp lệ |
| 403 | FORBIDDEN | Token thiếu claim project_id/sub hoặc thiếu quyền editor/viewer |
| 404 | NOT_FOUND | answer_template_id không tồn tại trong project scope |
| 409 | NAME_CONFLICT | Trùng (project_id, name) với template chưa xóa |
| 409 | TEMPLATE_IN_USE | Xóa khi còn Workspace Version tham chiếu |
| 500 | INTERNAL_ERROR | Lỗi không phân loại — response không lộ stacktrace |
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
actor Dev as Dev/PO
participant Web as web-bff
participant PS as project-svc
Dev->>Web: POST /v1/projects/{pid}/answer-templates (JWT + Idempotency-Key)
Web->>PS: forward
Note over PS: verify quyền editor + validate (name 3-100, description ≤ 500)
Note over PS: INSERT project.answer_templates
alt tên chưa dùng
PS-->>Web: 201 template (draft)
else trùng (project_id, name)
PS-->>Web: 409 NAME_CONFLICT
end
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | Dev/PO | Tạo template | FE gửi name + description (+ layout khởi tạo). |
| 2 | project-svc | Validate + INSERT | Verify editor; name unique trong project (bản chưa xóa) → 409 NAME_CONFLICT nếu trùng. |
| 3 | FE | Hiển thị | 201 → mở builder với template rỗng. |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| POST | /v1/projects/{pid}/answer-templates | Tạo answer template mới (draft, chưa publish). Owner project-svc. |
API Request
Header: Authorization (Y), Idempotency-Key (Y). Body: {name, description?}.
API Response
201 — {id, name, description, current_version_id: null, created_at}.
Bảng mã lỗi
| HTTP | code | message |
|---|---|---|
| 400 | VALIDATION_FAILED | Body/param sai schema hoặc vượt giới hạn |
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST side-effect thiếu Idempotency-Key hoặc sai format ULID/UUID v4 |
| 401 | JWT_MISSING · JWT_INVALID | Thiếu Authorization / signature-aud-exp không hợp lệ |
| 403 | FORBIDDEN | Token thiếu claim project_id/sub hoặc thiếu quyền editor/viewer |
| 404 | NOT_FOUND | answer_template_id không tồn tại trong project scope |
| 409 | NAME_CONFLICT | Trùng (project_id, name) với template chưa xóa |
| 409 | TEMPLATE_IN_USE | Xóa khi còn Workspace Version tham chiếu |
| 500 | INTERNAL_ERROR | Lỗi không phân loại — response không lộ stacktrace |
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
actor Dev as Dev/PO
participant Web as web-bff
participant PS as project-svc
Dev->>Web: POST /v1/projects/{pid}/answer-templates/{id}/update (JWT + Idempotency-Key)
Web->>PS: forward
Note over PS: load template (404 nếu không có)
Note over PS: validate field đổi (name/description/layout/variables)
Note over PS: UPDATE bản draft
PS-->>Web: 200 template đã cập nhật
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | Dev/PO | Sửa metadata/layout | Visual Builder hoặc JSON editor; nút Lưu enable khi có diff. |
| 2 | project-svc | Validate + UPDATE | id không thuộc project → 404; field sai → 400 VALIDATION_FAILED; đổi name trùng → 409. UPDATE bản draft (version đã publish bất biến). |
| 3 | FE | Hiển thị | 200 → tắt badge diff; lỗi → highlight details.field. |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| POST | /v1/projects/{pid}/answer-templates/{id}/update | Cập nhật metadata + layout bản draft. Không đụng version đã publish. |
API Request
Header: Authorization (Y), Idempotency-Key (Y). Body: {name?, description?, layout?, variables?} (full-replace phần gửi).
API Response
200 — template đã cập nhật. 404 nếu không thuộc project.
Bảng mã lỗi
| HTTP | code | message |
|---|---|---|
| 400 | VALIDATION_FAILED | Body/param sai schema hoặc vượt giới hạn |
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST side-effect thiếu Idempotency-Key hoặc sai format ULID/UUID v4 |
| 401 | JWT_MISSING · JWT_INVALID | Thiếu Authorization / signature-aud-exp không hợp lệ |
| 403 | FORBIDDEN | Token thiếu claim project_id/sub hoặc thiếu quyền editor/viewer |
| 404 | NOT_FOUND | answer_template_id không tồn tại trong project scope |
| 409 | NAME_CONFLICT | Trùng (project_id, name) với template chưa xóa |
| 409 | TEMPLATE_IN_USE | Xóa khi còn Workspace Version tham chiếu |
| 500 | INTERNAL_ERROR | Lỗi không phân loại — response không lộ stacktrace |
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
actor Dev as Dev/PO
participant Web as web-bff
participant PS as project-svc
participant VS as version-svc
Dev->>Web: POST /v1/projects/{pid}/answer-templates/{id}/delete (JWT + Idempotency-Key)
Web->>PS: forward
PS->>VS: S2S GET /v1/internal/answer-templates/{id}/references
alt còn Workspace Version tham chiếu
VS-->>PS: references[...]
PS-->>Web: 409 TEMPLATE_IN_USE + details.referenced_by[]
else không còn ref
Note over PS: UPDATE deleted_at = now() (xóa mềm)
PS-->>Web: 200 {deleted: true}
end
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | Dev/PO | Xóa + confirm | Modal cảnh báo xóa mềm; version đã publish vẫn giữ để replay. |
| 2 | project-svc | Check ref (delete gate) | S2S hỏi version-svc: còn Workspace Version nào ref template? Còn → 409 TEMPLATE_IN_USE + referenced_by[]. |
| 3 | project-svc | Soft delete | Không còn ref → UPDATE deleted_at = now(). KHÔNG hard-delete version (immutable, audit). |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| POST | /v1/projects/{pid}/answer-templates/{id}/delete | Xóa mềm template; gate TEMPLATE_IN_USE nếu còn Workspace Version tham chiếu. |
API Request
Header: Authorization (Y), Idempotency-Key (Y).
API Response
200 — {deleted: true} (idempotent). 409 TEMPLATE_IN_USE + details.referenced_by[].
Bảng mã lỗi
| HTTP | code | message |
|---|---|---|
| 400 | VALIDATION_FAILED | Body/param sai schema hoặc vượt giới hạn |
| 400 | IDEMPOTENCY_KEY_REQUIRED | POST side-effect thiếu Idempotency-Key hoặc sai format ULID/UUID v4 |
| 401 | JWT_MISSING · JWT_INVALID | Thiếu Authorization / signature-aud-exp không hợp lệ |
| 403 | FORBIDDEN | Token thiếu claim project_id/sub hoặc thiếu quyền editor/viewer |
| 404 | NOT_FOUND | answer_template_id không tồn tại trong project scope |
| 409 | NAME_CONFLICT | Trùng (project_id, name) với template chưa xóa |
| 409 | TEMPLATE_IN_USE | Xóa khi còn Workspace Version tham chiếu |
| 500 | INTERNAL_ERROR | Lỗi không phân loại — response không lộ stacktrace |
Versioning — Draft & Workspace Version thiết kế đích
Answer template thành kind='answer_template' trong version-svc, mô hình v0-mutable: Draft = row version_no=0 mutable trong bảng version.components, published = version_no≥1 frozen + ký. DSL luôn ở version-svc. FE → web-bff → project-svc → S2S → version-svc; /v1/internal/* KHÔNG lộ FE. Chi tiết: documents/ANSWER_TEMPLATE_VERSION_INTEGRATION.md.
FE-facing (public — web-bff → project-svc)
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
actor Dev as Dev/PO
participant Web as web-bff (public /v1)
participant PS as project-svc (authz)
participant VS as version-svc (S2S)
Dev->>Web: POST /v1/projects/{pid}/answer-templates/{id}/draft (JWT + Idempotency-Key)
Web->>PS: forward (verified JWT, X-Project-Id)
Note over PS: verify quyền editor
PS->>VS: S2S POST /v1/internal/components/{ws}/upsert {kind:'answer_template', component_id, dsl}
Note over VS: content_hash JCS RFC 8785
Note over VS: UPSERT components version_no=0 (v0, mutable) — KHÔNG mint
alt expected_content_hash lệch
VS-->>PS: 409 DRAFT_CONFLICT
else ok
VS-->>PS: 200 {content_hash, updated_at}
end
PS-->>Web: 200
Web-->>Dev: Đã lưu nháp
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | Dev/PO | Lưu nháp | FE gửi answer-template DSL. web-bff → project-svc. |
| 2 | project-svc | Authz + orchestrate | Verify quyền editor rồi S2S xuống version-svc (clients/version.py) — KHÔNG lộ route internal ra FE. |
| 3 | version-svc | UPSERT Draft | content_hash JCS server-side, UPSERT components version_no=0 (v0, mutable). content_hash JCS. KHÔNG mint, KHÔNG ký. |
| 4 | version-svc | Optimistic lock | expected_content_hash lệch → 409 DRAFT_CONFLICT (fetch lại rồi retry). |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| POST | /v1/projects/{pid}/answer-templates/{id}/draft | Lưu answer-template DSL vào Draft (mutable) qua project-svc → S2S version-svc. Chưa sinh version. |
API Request
Header: Authorization (Y), Idempotency-Key (Y). Body: {dsl, expected_content_hash?}.
API Response
200 — {content_hash, updated_at}. 409 DRAFT_CONFLICT khi hash lệch.
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
actor Dev as Dev/PO
participant Web as web-bff (public /v1)
participant PS as project-svc (authz)
participant VS as version-svc (S2S)
participant OB as OpenBao Transit
Dev->>Web: POST /v1/projects/{pid}/versions/publish (JWT + Idempotency-Key)
Web->>PS: forward
PS->>VS: S2S POST /v1/internal/versions/publish {workspace_id}
Note over VS: [1 tx] lock ws → đọc v0 → diff TỪNG component (gồm answer_template)
loop mỗi answer_template đổi hash
VS->>OB: sign Ed25519 (content_hash)
OB-->>VS: signature
Note over VS: INSERT components version_no=next (freeze DSL, immutable)
end
Note over VS: INSERT workspace_versions header trỏ các version_no chốt
VS-->>PS: 201 {workspace_version_id, version_no, minted[]}
PS-->>Web: 201
Web-->>Dev: Version mới + badge published
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | Dev/PO | Publish | FE gọi publish Workspace Version. web-bff → project-svc. |
| 2 | project-svc | Orchestrate | S2S POST /v1/internal/versions/publish {workspace_id} (route chung agent/workflow). |
| 3 | version-svc | Diff + mint | 1 tx: v0 đổi hash → INSERT components version_no=next (freeze + ký Ed25519 OpenBao); không đổi → carry-forward version_no cũ. |
| 4 | version-svc | Ghi components | INSERT workspace_versions header trỏ version_no vừa chốt. minted[] gồm answer template. |
| 5 | version-svc | Trả | 201 {workspace_version_id, version_no, minted[]}. Không phát event; vkey agent provision sau commit. |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| POST | /v1/projects/{pid}/versions/publish | Snapshot Draft → Workspace Version, mint mọi component đổi gồm answer_template. |
API Request
Header: Authorization (Y), Idempotency-Key (Y). Body: {workspace_id}.
API Response
201 — {workspace_version_id, version_no, minted[]}.
S2S internal (project-svc → version-svc — KHÔNG lộ FE, FR-BFFWS-011)
S2S nội bộ — delete gate
Reverse lookup refs phục vụ gate xóa TEMPLATE_IN_USE. As-built: refs materialize vào agent_version_answer_template_refs lúc mint. Thiết kế đích: gate đọc ref từ components (kind='answer_template', version_no≥1) mà Workspace Version trỏ tới — TEMPLATE_IN_USE khi card còn trong Workspace Version chưa retire.
▶Đặc tả (thiết kế đích) — sequence · luồng · request/response · mã lỗi
Sequence diagram
sequenceDiagram
participant PS as project-svc
participant VS as version-svc
PS->>VS: GET /v1/internal/answer-templates/{id}/references (S2S Ed25519, aud=version-svc)
Note over VS: query index (project_id, component_id)<br/>trên components (kind=answer_template, version_no≥1)
alt còn version tham chiếu
VS-->>PS: 200 references[...]
else không còn ref
VS-->>PS: 200 references []
end
Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | project-svc | Gọi reverse lookup | Trước khi xóa mềm, hỏi version-svc version nào còn ref template. |
| 2 | version-svc | Trả refs từ index | Đọc components (kind=answer_template, version_no≥1) mà Workspace Version trỏ tới — không cross-schema. Không còn ref trả [] (KHÔNG 404). |
| 3 | project-svc | Quyết định gate | Còn ref → 409 TEMPLATE_IN_USE; rỗng → cho xóa mềm. |
Mô tả API
| Method type | Path | Mô tả |
|---|---|---|
| GET | /v1/internal/answer-templates/{template_id}/references | Reverse refs của answer template trong project scope — phục vụ delete gate. S2S nội bộ. |
API Request
Header: S2S bearer Ed25519, aud=version-svc; tenant từ claim.
API Response
200 — {template_id, references[]}; mỗi ref: workspace_version_id, version_no. Rỗng → [] (không 404).
Bảng mã lỗi
| HTTP | code | message |
|---|---|---|
| 401 | S2S_INVALID | S2S bearer thiếu / sai aud / hết hạn |
| 500 | INTERNAL_ERROR | An unexpected error occurred. |
Transport — card trên stream chat (không có endpoint riêng)
Answer Card KHÔNG thêm API runtime nào: card là field trên stream POST /v1/chat-messages của phân hệ Conversation, persist conv.messages.card_payload, replay đọc qua transcript API. Đặc tả canonical = TKCT 3.12.6 (nhúng dưới).
▶Đặc tả — TKCT 3.12.6 (sequence diagram · luồng nghiệp vụ · request/response · mã lỗi)
card/card_payload_renderer.py, Jinja sandbox; authoring/versioning đã có per CG-004: schema card, card.templates/card.template_versions, migration 0010_card_schema.py) rồi đi về Chat UI trên CHÍNH stream của POST /v1/chat-messages (3.12.3.1). OpenAPI 04 v1.5.0 đã định nghĩa field card (ChatMessageMetadata.card, always-present nullable) + x-sse-event-message-end.metadata.card + cột card_payload (DDD 04 §4.1). Cơ chế đã chốt:1. As-built (T-CONV-09): card đi trên
message_end.metadata.card (kênh parity engine có sẵn, KHÔNG thêm event mới) — KHÔNG phải done. Contract always-present-nullable: key metadata.card LUÔN có mặt trên frame message_end, null khi turn không card. OpenAPI 04 v1.5.0 x-sse-event-message-end.metadata.card (nullable object).2. As-built (T-CONV-09): cột
conv.messages.card_payload JSONB (migration 0017_message_card_payload) lưu bản POST-FILTER để transcript replay re-render được card.3.
card_payload lưu bản POST-FILTER — conversation-svc apply CÙNG allowlist filter Answer Card với web-bff trước khi persist, để transcript replay (3.12.2.2, không đi qua output filter) không "sống lại" action đã strip.4. Allowlist action URL phải ENFORCE trong code — OPTIMAL §13 filter 5 hiện chỉ là spec, chưa enforce. Three-layer defense-in-depth: agent-runtime (render-time) → conversation-svc (pre-proxy + pre-persist) → web-bff (output filter).
open_url ngoài allowlist → strip action, log warning (card_action_url_blocked), KHÔNG fail turn (degrade log-only — KHÔNG mã lỗi mới). Allowlist cấu hình per-env ở environment-svc (card_action_url_allowlist, FR-ENV-007), bao gồm cả scheme deeplink thương mại (vd viettelmoney://, https://vm.viettel.vn/...) cho luồng bán hàng 3.12.9. Cả ba lớp gọi CÙNG một shared helper vds_platform_common.card.filter_card_actions(card, allowlist) (KHÔNG copy-paste, T-CONV-07).- Config surface (environment-svc):
card_action_url_allowlist: string[] (list literal scheme://host) nằm trong body POST /v1/environments/{env_id}/config ngang hàng 4 binding type; đọc qua GET .../config (KHÔNG mask — không phải secret) + resolve façade /v1/internal/resolve trả key card_action_url_allowlist theo env. Env chưa set → [].- Match rule: literal
scheme + host (urlsplit → so scheme + netloc); path/query KHÔNG vào match (viettelmoney://pay?order=X khớp entry viettelmoney://pay). Allowlist rỗng → strip MỌI action có URL. Action postback (không có URL) luôn giữ.- Injection guard: query param client-tô (
ctx=/order=) bị loại TRƯỚC khi so host (không lách host-match); param nhạy cảm server-sourced (conversation_id từ turn ctx, order_id từ webhook) do caller seam inject SAU filter, helper KHÔNG tự sinh.5. Naming action: action kind là
postback (gửi turn mới về LLM) / open_url (URL/deeplink trong allowlist) — card_templates/catalog.py:139.6. Agent-runtime emit card trên RUN path: hiện run path trả
final_answer: string (executor message.done), catalog 8-component (card_templates/) chỉ là authoring CRUD, không vào graph/executor/invoke. Cần: khi DSL node trả card, executor render rồi emit object card trong done.metadata.card (mục 1). Đây là khâu net-new, không phải authoring có sẵn.7. As-built (T-CONV-11, Seam 4) — FE renderer Answer Card (studio-svc): thêm component net-new
app/components/base/chat/chat/answer/answer-card.tsx render 8 catalog type (form/text/image_media/list/selector/button_cta/carousel/confirmation) sau khối Citation trong answer/index.tsx; đọc card từ message_end.metadata ở hooks.ts onMessageEnd (mirror responseItem.citation), null khi vắng card. Action: postback→onSend(payload) (gửi turn mới, reuse hạ tầng chat engine), open_url→window.open(url, '_blank', 'noopener,noreferrer') sau scheme-guard (https/http; scheme lạ → console.warn, KHÔNG mở), confirmation yes/no→onSend(yes_payload/no_payload). Schema version lạ (!= "1.0") → fallback text "phiên bản card không hỗ trợ", bubble không vỡ. FE chỉ render — KHÔNG re-validate schema (server validate trước wire), KHÔNG dựng allowlist riêng (server strip ở Seam 1+2). Chi tiết §4.2.8. As-built (T-CONV-10, Seam 3): web-bff là MAPPER thuần — forward
metadata.card nguyên trạng ở CẢ HAI điểm emit message_end trong compat router sse.py (site streaming terminal + site advanced_chat done/workflow-fallback); always-present-nullable giữ nguyên (card = null khi vắng). web-bff KHÔNG re-filter, KHÔNG validate card schema, KHÔNG import allowlist ở scope này: card đã được strip ở agent-runtime (render-time) + conversation-svc (pre-persist), nên lớp web-bff là no-op defense-in-depth (§N2 point 3) — không phải output-filter thật. Nếu sau này bật lớp filter web-bff thì là task riêng.Owner end-to-end: agent-runtime (emit card trên run path + render-time filter) → conversation-svc (SSE contract + persistence + filter) → web-bff (output filter) → studio-svc FE (Answer Card renderer). Authoring/versioning card thuộc TKCT 3.9 Card Template (CG-004 done). Card là tính năng net-new ở MỌI tầng, không phải wiring nhỏ.
Bảng ghi nhận thay đổi tài liệu
_Loại thay đổi: A – Tạo mới, M – Sửa đổi, D – Xóa bỏ_
| Ngày thay đổi | Vị trí thay đổi | Loại thay đổi | Nguồn gốc | Phiên bản cũ | Mô tả thay đổi | Phiên bản mới | Người approve |
|---|---|---|---|---|---|---|---|
| 11/06/2026 | Toàn bộ | A | OPTIMAL §18.3.6/§13 + DDD 08 Card folder + DDD 17 | - | 1.0 | - | |
| 17/06/2026 | Blockquote + §4.1.3 kind + §4.1.4 | M | Đối soát gap CONV-GAP-003/004/005 + ADR-DRAFT sales-integration | 1.0 | Chốt-theo-thiết-kế: card transport done.metadata.card + card_payload persist; ENFORCE allowlist 3 lớp (gồm scheme deeplink thương mại); align naming postback/open_url | 1.1 | - |
| 22/06/2026 | Blockquote §1-2 + §1 service | M | T-CONV-09 Seam 2 as-built (conversation-svc) | 1.1 | Card vận chuyển trên message_end.metadata.card (always-present nullable, KHÔNG phải done) + persist conv.messages.card_payload (migration 0017); OpenAPI 04 v1.5.0; allowlist-strip log-only (không error code mới) | 1.2 | - |
| 22/06/2026 | Blockquote §8 + §1 service | M | T-CONV-10 Seam 3 as-built (web-bff) | 1.2 | web-bff forward metadata.card ở CẢ HAI site message_end (compat router sse.py); MAPPER thuần — no-op output filter (đã strip ở producer/persist), KHÔNG re-filter/validate schema | 1.3 | - |
| 22/06/2026 | §1 service + §38 note + §4.2 (mới) | M | T-CONV-11 Seam 4 as-built (studio-svc FE) | 1.3 | FE Answer Card renderer answer/answer-card.tsx render 8 catalog type sau Citation; đọc card ở hooks.ts onMessageEnd (mirror citation); postback→onSend, open_url→window.open scheme-guard, confirmation→yes/no payload; schema-version lạ → fallback text | 1.4 | - |
| 22/06/2026 | Blockquote + §2 + mermaid + §4.1.2/3/4 + Link swagger | M | T-CONV-15 doc-sync (gỡ framing "đề xuất") | 1.4 | Gỡ framing "đề xuất"/"chốt theo thiết kế (đưa vào regen khi triển khai)" → card delivery trình bày là ĐÃ LAND; OpenAPI 04 version v1.3.0 → v1.5.0 (field card + x-sse-event-message-end.metadata.card); rename card/renderer.py → card/card_payload_renderer.py ở luồng answer-card | 1.5 | - |
| 05/07/2026 | §4.3 (mới) | M | KBNV 2.2.10 Conversation v2 (2026-06-16) §2.2.10.3 | 1.5 | Bổ sung đặc tả control màn Chat end-user theo KBNV 2.2.10.3 — ràng buộc control-level Answer Template Card (maxlen từng field, banner + badge, "Xem thêm", Meta Row ≤3, Primary vs Secondary Button) | 1.6 | - |
1. Tên service
agent-runtime (:50056) — render card từ resolved_dsl.json (Jinja sandbox, JCS hash verify) trên run path = ĐÃ BUILD (Seam 1 producer, T-CONV-08); web-bff (:4001) — MAPPER thuần forward message_end.metadata.card (engine-parity) = ĐÃ BUILD (Seam 3, T-CONV-10), no-op output filter (card đã strip ở producer/persist — §N2 point 3); conversation-svc (:50067) — vật mang SSE (message_end.metadata.card) + persistence (card_payload) = ĐÃ BUILD (Seam 2, T-CONV-09); studio-svc (:3000) — FE Answer Card renderer (answer/answer-card.tsx) render 8 catalog type + dispatch postback/open_url = ĐÃ BUILD (Seam 4, T-CONV-11).
card/renderer.py Jinja sandbox render system prompt (agent-card DSL), KHÔNG phải card UI; answer-card render path mới là card/card_payload_renderer.py (T-CONV-08) — reuse card_templates/schema_tools.validate_schema + compute_schema_hash + sandbox chung. JCS hash + Ed25519 sig là publish-time trên card.template_versions (migration 0010_card_schema); run path verify lại schema_hash pin trước khi emit. Run path đã gắn card lên frame done (executor.py _done_payload, producer-native done.payload.card) khi DSL node trả card hợp lệ; degrade hash-fail/schema-invalid → bỏ card, trả final_answer text. Authoring template (CRUD + versions + catalog 8-component catalog.py:139) ĐÃ CÓ. Cả 4 seam ĐÃ BUILD: SSE transport + persist (Seam 2 T-CONV-09) → web-bff passthrough (Seam 3 T-CONV-10) → FE renderer (Seam 4 T-CONV-11, answer/answer-card.tsx).2. Mô tả chung
Khi agent trả lời dạng có cấu trúc (form, danh sách lựa chọn, nút CTA, carousel...), answer không phải text thuần mà là một Answer Card — layout + field binding + action định nghĩa trong agent DSL, dựng bằng Visual Builder (TKCT 3.9). Runtime flow: agent-runtime load card definition từ resolved_dsl.json (verify JCS hash trước khi nạp), render qua Jinja sandbox (block __import__, missing variable defaults) + filter action URL allowlist (render-time), conversation-svc filter lại + persist bản POST-FILTER, web-bff forward metadata.card nguyên trạng (mapper — Seam 3), Chat UI render component tương ứng.
Khâu vận chuyển — card đã render đi trong SSE stream (message_end.metadata.card) và lưu vào message (card_payload) ra sao để xem lại transcript — đã land (T-CONV-08..11). Trang này đặc tả phần mở rộng đó.
3. Tính năng
End-user thấy answer dạng card tương tác trong chat (thay vì text); xem lại transcript vẫn render đúng card (từ card_payload đã persist); action trên card (button/selector) gửi turn chat mới hoặc mở URL trong allowlist.
4. Mô tả luồng xử lý
4.1. Hiển thị Card Template
4.1.1. Luồng tổng quan
sequenceDiagram
autonumber
actor U as End User
participant FE as Chat UI
participant CV as conversation-svc (:50067)
participant AR as agent-runtime (:50056)
participant B as web-bff (:4001)
participant PG as PostgreSQL (schema conv)
U->>FE: Gửi câu hỏi (turn chat thường)
FE->>B: POST /v1/chat-messages (SSE)
B->>CV: proxy request
Note over CV: Crisis check in-process trước (p99 < 5ms)<br/>— crisis bypass KHÔNG render card
CV->>AR: invoke (non-crisis)
Note over AR: Agent quyết định trả lời bằng card<br/>card/card_payload_renderer.py: Jinja sandbox render<br/>resolved_dsl.json (JCS hash verify)
AR-->>CV: SSE chunk* + done.payload.card (producer-native)
Note over CV: Apply allowlist filter Answer Card<br/>(schema + length cap + action URL —<br/>CÙNG cấu hình với web-bff) trước khi proxy
CV-->>B: proxy SSE 1:1 (card đã filter)
Note over B: Output filter (OPTIMAL §13 filter 5):<br/>validate lại schema + allowlist —<br/>defense-in-depth, no-op khi CV đã strip
B-->>FE: message_end.metadata.card
CV->>PG: INSERT assistant msg + card_payload POST-FILTER<br/>+ outbox conv.message.appended.v1 (1 transaction, sau done)
FE-->>U: Render card component (form/list/button/carousel)
4.1.2. Danh sách API và tiến trình
| STT | Name | Type | URL | Method | Service | Tài liệu tham khảo |
|---|---|---|---|---|---|---|
| 1 | Gửi chat message — field card trên message_end | API | /v1/chat-messages | POST | conversation-svc | (file này + 3.12.3.1) |
4.1.3. Mô tả API
API 1: Field card trên message_end.metadata của stream chat
| Method type | Path | Mô tả |
|---|---|---|
| POST | /v1/chat-messages | Như 3.12.3.1; trang này chỉ đặc tả delta card |
API Request
Không đổi so với 3.12.3.1 — card là quyết định phía agent, client không gửi gì thêm.
_Permission yêu cầu:_ như 3.12.3.1.
API Response
_Body — field card trong message_end.metadata (always-present nullable):_
| Tên tham số | Kiểu dữ liệu | Mô tả |
|---|---|---|
card | Object (nullable) | Rendered Answer Card — null với answer text thuần |
card.template_id | String | Card template trong agent DSL |
card.template_version | String | Semver card definition đã render |
card.layout | Object | Cây component đã render (đã thay binding bằng giá trị) |
card.layout.type | Enum | form, text, image, list, selector, button, carousel, confirmation |
card.layout.children | Array | Component con (đệ quy cùng shape) |
card.actions | Array | Action khả dụng — URL đã qua allowlist filter (conversation-svc, web-bff validate lại) |
card.actions[].id | String | Action id |
card.actions[].kind | Enum | postback (gửi turn mới về LLM), open_url (URL/deeplink trong allowlist — gồm deeplink thương mại Viettel Money cho 3.12.9) |
card.actions[].label | String | Nhãn nút |
card.actions[].payload | String (nullable) | Nội dung gửi khi send_message, hoặc URL đích (trong allowlist) khi open_url |
_Example response 200 (trích frame message_end — field card):_
{
"conversation_id": "b3f1c2ae-8d4f-4c2a-9b7e-5a6d3e8f1c2b",
"message_id": "a8d4f7c2-3e6b-4f9d-8b2a-1c5e9d7f3a48",
"answer": "Anh/chị tham khảo 2 gói cước phù hợp dưới đây.",
"metadata": {
"is_crisis": false,
"served_by": "agent_runtime",
"tokens_in": 410,
"tokens_out": 188,
"ttft_ms": 580,
"latency_ms": 2310,
"card": {
"template_id": "goi-cuoc-compare",
"template_version": "1.2.0",
"layout": {
"type": "list",
"children": [
{ "type": "text", "children": [], "props": { "value": "C90 — 4GB/ngày, 90.000đ/tháng" } },
{ "type": "text", "children": [], "props": { "value": "SD135 — 5GB/ngày, 135.000đ/tháng" } }
]
},
"actions": [
{ "id": "act-1", "kind": "open_url", "label": "Đăng ký 5G Gia Đình", "payload": "viettelmoney://pay?order=5G-GIADINH&ctx={conversation_id}" },
{ "id": "act-2", "kind": "postback", "label": "So sánh thêm gói", "payload": "So sánh các gói 5G khác" }
]
}
}
}
_Link swagger:_ /openapi.json → POST /v1/chat-messages (field card = ChatMessageMetadata.card + x-sse-event-message-end.metadata.card, OpenAPI 04 v1.5.0).
4.1.4. Mô tả luồng nghiệp vụ
| Bước | Đối tượng | Hành động | Mô tả |
|---|---|---|---|
| 1 | End User | Gửi câu hỏi | Turn chat thường — không khác 3.12.3.1. |
| 2 | agent-runtime | Render card | DSL node trả card → card/card_payload_renderer.py render Jinja sandbox; biến thiếu áp missing variable defaults; hash verify fail → bỏ card, trả text thuần (degrade). Emit done.payload.card (producer-native). |
| 3 | conversation-svc | Filter card | Apply CÙNG allowlist filter Answer Card với web-bff (validate schema + length cap; action URL ngoài allowlist → strip action đó, log warning, KHÔNG fail cả turn) trước khi proxy SSE. |
| 4 | web-bff | Forward (mapper) | As-built T-CONV-10: forward metadata.card nguyên trạng ở cả hai site message_end (compat router sse.py) — KHÔNG re-filter/validate schema trong scope này (card đã strip ở bước 2-3); lớp output filter web-bff là no-op defense-in-depth (§N2 point 3), bật thật là task riêng. |
| 5 | conversation-svc | Persist | SAU done (DDD 04 §2.3), lưu card_payload bản POST-FILTER (bước 3) cùng message trong transaction outbox — transcript replay render thẳng bản đã lưu, không cần re-filter vì stored = filtered (action strip không sống lại). |
| 6 | Chat UI | Render | Component theo layout.type; postback → gửi turn mới; open_url deeplink thương mại (vd "Đăng ký 5G Gia Đình") → mở app Viettel Money, xác nhận thanh toán đi luồng 3.12.9; transcript xem lại render từ card_payload. |
4.2. Xử lý FE Answer Card (studio-svc — Seam 4, T-CONV-11)
Tầng FE cuối của chuỗi delivery: render card trong bubble trả lời và điều phối action. Reuse hạ tầng chat của engine (useChatContext().onSend, IChatItem, parse SSE message_end ở service/base.ts) — chỉ thêm một component mới + một dòng đọc metadata, KHÔNG đụng pipeline SSE/persist (đã do Seam 1-3 lo).
| Vị trí | File | Hành vi | ||
|---|---|---|---|---|
| Đọc card | app/components/base/chat/chat/hooks.ts (onMessageEnd) | `responseItem.card = messageEnd.metadata?.card | null — mirror responseItem.citation; null` khi turn không card (bám contract always-present-nullable Seam 2). | |
| Wire render | app/components/base/chat/chat/answer/index.tsx | Sau khối Citation: {!!item.card && !responding && <AnswerCard data={item.card} />}. | ||
| Component | app/components/base/chat/chat/answer/answer-card.tsx (net-new) | FC<{ data: CardPayload; onAction?: (a: CardAction) => void }> — render theo data.components[], mỗi phần tử có type ∈ 8 enum. | ||
| Type | app/components/base/chat/chat/type.ts | Thêm `card?: CardPayload \ | null vào IChatItem + Metadata; khai báo CardPayload/CardComponent/CardAction mirror catalog (card_templates/catalog.py`), KHÔNG import từ Python. |
Render 8 type: form (fields + submit_label), text (variant heading/body/caption), image_media (image/video/audio), list (items), selector (single/multi options), button_cta (label + action), carousel (cards), confirmation (prompt + yes/no label). Action điều phối qua useChatContext():
- postback → onSend(payload) (gửi turn mới, reuse cơ chế SuggestedQuestions); no-op khi readonly. - open_url → window.open(url, '_blank', 'noopener,noreferrer') CHỈ khi url qua scheme-guard FE (https/http); scheme lạ (vd javascript:) → console.warn, KHÔNG mở. FE chỉ guard scheme cuối — KHÔNG dựng allowlist riêng (server đã strip action ngoài allowlist ở Seam 1+2). - confirmation → yes: onSend(yes_payload), no: onSend(no_payload).
Degrade: card_schema_version != "1.0" → render fallback text (chat.card.unsupportedVersion), KHÔNG throw — bubble vẫn sống. FE render plain/escaped text (KHÔNG dangerouslySetInnerHTML), KHÔNG re-validate schema (server validate trước wire).
4.3. Ràng buộc control-level Answer Template Card (KBNV §2.2.10.3)
Trên màn Chat end-user, Answer Template Card render dưới dạng control tổng hợp (control #14, 3.12.3.3) gồm các thành phần con #15–20. Ràng buộc hiển thị theo KBNV:
| Thành phần con | Control | Require | Max Length | Ràng buộc hiển thị |
|---|---|---|---|---|
| Banner | Template Banner (#15) | No | — | Ảnh minh hoạ/icon lớn phía trên card, có badge label ở góc trên trái (vd "Dịch vụ 5G", "Khuyến mãi"); không có ảnh cấu hình → icon mặc định theo loại nội dung |
| Tiêu đề | Template Title (#16) | Yes | 100 ký tự | Quá dài → hiển thị tối đa 3 dòng kèm nút "Xem thêm" |
| Mô tả | Template Description (#17) | No | 300 ký tự | Đặt dưới Title |
| Meta | Template Meta Row (#18) | No | — | Hàng icon + text, tối đa 3 item (vd tốc độ, giá, thời hạn hợp đồng) |
| Nút chính | Template Primary Button (#19) | No | 50 ký tự (label) | Nền đỏ chữ trắng; action ánh xạ card.actions[] — open_url (deeplink/URL trong allowlist) hoặc postback (gửi turn mới) |
| Nút phụ | Template Secondary Button (#20) | No | 50 ký tự (label) | Nền đỏ nhạt chữ đỏ; độc lập với Primary Button, có thể vắng nếu template không cần action phụ |
Các ràng buộc trên là tầng render FE — không đổi contract wire card ở §4.1.3; label nút map field card.actions[].label, độ dài cắt/ellipsis do FE xử lý. Use case tham chiếu: card so sánh gói với Title "5G Gia Đình", Primary "Đăng ký 5G Gia Đình", Secondary "Kiểm tra vùng phủ 5G".
Bảng mã lỗi
Không có mã lỗi riêng — card đi trên stream chat, mã lỗi dùng chung 3.12.3.1 → Bảng mã lỗi. Lỗi render card phía agent-runtime degrade về text thuần (không fail turn); action URL ngoài allowlist bị strip + log ở agent-runtime (render-time) + conversation-svc (pre-persist), answer text vẫn về client. web-bff (Seam 3, T-CONV-10) chỉ forward metadata.card — không phát mã lỗi card.
Bảng mã lỗi — Answer Template API
Envelope {"error": {code, message, trace_id, ts, details}}. Bảng mã lỗi đầy đủ per API nằm trong đặc tả TKCT từng card.
Lưu ý & GAP
Canvas kéo-thả + preview substitute biến chạy trên Studio với definition từ API chi tiết. Không có REST render-preview riêng (đúng tiền lệ TKCT 3.9.3 / 3.9.4 — không bịa endpoint).
Cả 8 route design-time có trong 08-agent-runtime.openapi.yaml, kèm ChatMessageMetadata.card — không DRIFT contract.
Chat (/v1/chat-messages public · /v1/projects/{pid}/environments/{env}/agents/{aid}/chat Studio), follow-up chips (TKCT 3.12.5), transcript replay (GET /v1/conversations/{id}/messages) là API phân hệ Conversation — card chỉ là field trên đó (xem mục Transport). DRIFT TKCT 3.12.5 vs as-built (route + cơ chế đã đổi sang GET /v1/conversations/{conv_id}/suggested-questions, conversation-svc, LLM-free) ghi nhận xử lý ở track T-958.