DarkJyn/CAG_HomeDevice

simple text-to-intent mechanism

TypeScript

0

55 commits

updated Aug 18, 2026

See the code

README

Lumi Smart Home CAG — pipeline text → intent + Web Playground

Hệ thống nhận một câu lệnh tiếng Việt và biến nó thành intent điều khiển thiết bị, qua 3 bước:

  1. Parser (LLM) — câu lệnh tự do → intent JSON có cấu trúc (action, filter, routing).
  2. Filter (local, không tốn tiền) — lọc cứng theo floor_id / room_id / device_type, rồi chấm điểm mềm theo tên và cắt top-K.
  3. Final extract (LLM) + execute — gán alias số cho các ứng viên, LLM chọn đúng 1 alias (hoặc trả 0 = mơ hồ), guard kiểm tra rồi gateway thực thi.

Kèm theo là một Web Playground để nhìn thấy cả 3 bước, tự tạo dataset thiết bị, và kéo trọng số của công thức tính điểm.


Web Playground

Chạy nhanh (production, một origin duy nhất)

npm install
npm run web:install     # cài dependency của frontend (web/)
npm run web:build       # build frontend ra web/dist
npm run build           # build backend ra dist/
npm start               # server ở http://localhost:3000

Mở http://localhost:3000 — backend phục vụ luôn web/dist, nên frontend và API cùng một origin, không cần CORS, không cần chạy hai process.

Nếu chưa chạy npm run web:build, server vẫn khởi động và API vẫn hoạt động bình thường; vào / sẽ nhận HTTP 503 kèm đúng câu lệnh cần chạy để có bản build.

Chạy khi phát triển (hai process)

npm run dev     # backend, http://localhost:3000
npm run web     # frontend Vite, http://localhost:5173 (proxy /v1 và /healthz sang :3000)

Mở http://localhost:5173. Vite lo hot-reload; các request /v1/... được proxy sang backend (xem web/vite.config.ts).

Bốn yêu cầu, và chỗ nào trong app đáp ứng

#Yêu cầuỞ đâuCode
1Ô nhập text, đầu ra là intent điều khiển thiết bịÔ "Câu lệnh" ở tab Playground (kèm dropdown tầng/phòng lấy từ chính dataset đang dùng). Bấm Chạy → step ① hiện intent JSON parser trả về, step ③ hiện lệnh thực thi + deviceIdsweb/src/components/CommandBar.tsx, POST /v1/parse
2Upload CSV/XLSX floor_id, room_id, device_type, hoặc UI tự tạo dữ liệuTab Dataset: nút chọn file (.csv, .xlsx, .xls) bảng tự thêm/sửa/xoá từng dòng. Cả hai đường đều đi qua nút Áp dụngPOST /v1/dataset → server trả datasetId + danh sách warningweb/src/components/DatasetTab.tsx, web/src/lib/parseSpreadsheet.ts, src/data/datasetStore.ts
3Nhìn thấy cả 3 bước của thuật toánBa card ①②③ trên tab Playground: intent của parser; bảng ứng viên với score và thanh phân rã 4 thành phần điểm, số thiết bị vào/ra mỗi tầng lọc, thời gian chạy; kết quả final extract kèm alias được chọn, reason, và các guard (topScore, scoreGap)web/src/components/StepParser.tsx, StepFilter.tsx, StepFinal.tsx
4Chỉnh được trọng số trong công thức tính điểmThanh trọng số dưới cùng: 4 slider trọng số + 4 ngưỡng (semanticThreshold, topK, finalMinScore, ambiguityGap). Kéo → chỉ step ② chạy lại (local, miễn phí); step ③ bị đánh dấu "cũ" và chỉ chạy lại LLM khi bạn tự bấmweb/src/components/WeightsBar.tsx, src/scoring/tuning.ts

Thanh trọng số hiển thị đúng con số server báo là đã dùng (effectiveTuning trong response của /v1/filter), không phải vị trí slider — hai thứ này có thể lệch nhau trong lúc request đang bay.


Công thức tính điểm và trọng số

Mỗi thiết bị còn lại sau lọc cứng được chấm 4 điểm thành phần trong [0, 1] (src/filter/semantic.ts):

Thành phầnNghĩaTrọng số mặc định
semanticcosine similarity giữa name_query"<device_name> <tags>" (char n-gram trên text đã chuẩn hoá tiếng Việt)0.58
numerickhớp gợi ý số ("đèn 2", "điều hoà số 1")0.18
typename_query có nhắc đúng loại thiết bị không0.14
roomname_query có nhắc đúng tên phòng không0.10
score = Σ (component × weight_normalized)   với   weight_normalized = weight / Σ weights

Trọng số được server chuẩn hoá về tổng = 1 (src/scoring/weights.ts). Nghĩa là chỉ tỉ lệ giữa 4 trọng số có ý nghĩa, không phải độ lớn tuyệt đối: {4, 1, 1, 1}{0.4, 0.1, 0.1, 0.1} cho ra kết quả y hệt. Nhờ vậy score luôn nằm trong [0, 1] và các ngưỡng semanticThreshold / finalMinScore giữ nguyên ý nghĩa khi bạn kéo slider.

Lưu ý trung thực: nếu parser không trả name_query (ví dụ "bật đèn phòng khách" đã đủ rõ bằng lọc cứng), soft filter bị bỏ qua — mọi thiết bị nhận score = 1trọng số không ảnh hưởng gì. Lệnh batch (batchAll, ví dụ "bật tất cả đèn") cũng không đọc một con số tuning nào: nó thực thi thẳng kết quả lọc cứng và bỏ qua final extract.

Kéo một slider không bao giờ gọi LLM: playground chỉ gọi lại POST /v1/filter (chạy local). Step ③ chuyển sang trạng thái "cũ — trọng số đã đổi" và chỉ chạy lại khi bạn bấm Chạy lại step ③.


Định dạng dataset và server làm gì với dữ liệu chưa chuẩn

Cột bắt buộc: floor_id, room_id, device_type. Cột tuỳ chọn: device_id, device_name, room_name, tags (chuỗi ngăn cách bằng dấu phẩy, ví dụ ceiling,left).

device_type hợp lệ: light, air_conditioner, fan, sensor, appliance, tv, curtain, door, other.

Server (src/data/datasetStore.ts) không im lặng — mọi can thiệp đều trả về trong danh sách warnings và playground in ra ngay tại tab Dataset:

Dữ liệu vàoServer làm gì
Thiếu floor_id, room_id hoặc device_typeBỏ dòng đó, warning ghi rõ số dòng
device_type lạ (đèn, lamp, ...)Ép về other + warning. Thiết bị vẫn nằm trong dataset, nhưng lọc cứng theo loại sẽ không còn tìm ra nó như một cái đèn
Thiếu device_idTự sinh "<type>_<room_id>_<n>", đảm bảo không trùng
device_id do bạn khai bị trùng nhauBỏ dòng trùng (giữ dòng đầu) + warning
Thiếu device_nameTự sinh "<Nhãn loại> <n> <room_id>" + warning: soft filter sẽ kém chính xác — nó chấm điểm trên chính tên thiết bị, nên tên máy sinh làm thành phần semantic gần như vô nghĩa
Thiếu room_nameDùng room_id thay thế + warning (thành phần room vì thế cũng yếu đi)
Một room_id xuất hiện ở nhiều floor_idGiữ floor_id gặp đầu tiên làm chính thức + warning
Không còn dòng hợp lệ nàoHTTP 400, không tạo dataset
Quá 5000 dòngHTTP 400 — cả file bị từ chối, không cắt bớt

Dataset upload sống trong bộ nhớ của process (ds_1, ds_2, ...) và mất khi restart server. Dataset mẫu (sample, hơn 1000 thiết bị) luôn có sẵn.


API

Server phục vụ cả API và frontend trên cùng cổng PORT (mặc định 3000).

RouteMô tả
GET /healthz{"ok":true}
GET /v1/config/defaultstrọng số + ngưỡng mặc định, danh sách device_type, provider đang dùng, có đang mock không
GET /v1/dataset/:idthiết bị + staticContext của một dataset
POST /v1/dataset{ rows: [...] }{ datasetId, deviceCount, warnings, staticContext }
POST /v1/parsestep ① — LLM
POST /v1/filterstep ② — local, nhận tuning để chạy lại với trọng số khác
POST /v1/selectstep ③ — LLM + execute
POST /v1/commandchạy thẳng cả 3 bước (API gốc, không qua playground)
mọi path khácfile trong web/dist, hoặc index.html cho route SPA

Hai luật của phần phục vụ file tĩnh (src/api/staticFiles.ts), đều để tránh nói dối client:

  • Path API không tồn tại (/v1/..., /healthz) luôn trả JSON 404 — không bao giờ trả HTML. Client gọi API mà nhận về HTML sẽ chết bằng một lỗi parse JSON vô nghĩa.
  • Path có đuôi file mà không tìm thấy (/assets/index-cu.js) là 404 thật, không fallback về index.html. Trả index.html với mã 200 cho một file .js thiếu sẽ khiến trình duyệt chạy HTML như JavaScript và báo Unexpected token '<' — trong khi nguyên nhân thật (build cũ / chưa build) bị giấu đi. Chỉ path không có đuôi file mới là route SPA.

Ví dụ POST /v1/command:

{ "text": "Bật đèn trần góc trái", "context": { "room_id": "living_room_01", "floor_id": "floor_1" } }
{
  "correlationId": "...",
  "status": "executed",
  "action": { "command": "turn_on", "params": {} },
  "deviceIds": ["light_lv_ceiling_left"],
  "trace": { "parser": { ... }, "filter": { ... }, "finalExtract": { ... } }
}

Biến môi trường

Copy .env.example sang .env rồi sửa. Không commit .env.

Runtime hỗ trợ các provider tương thích OpenAI: Cerebras, OpenRouter và OpenAI. Khuyến nghị dùng Cerebras với gpt-oss-120b để có chi phí thấp và tốc độ suy luận cao. Nếu provider được chọn không có API key, hoặc GEMINI_USE_MOCK=true, hệ thống fallback sang mock để chạy local.

LLM runtime

  • MODEL_PROVIDER: cerebras, openrouter hoặc openai. Nếu không đặt, hệ thống tự chọn Cerebras/OpenRouter khi có đúng API key tương ứng; còn lại là OpenAI.
  • CEREBRAS_API_KEY: API key Cerebras. Đặt MODEL_PROVIDER=cerebras; model parser/final mặc định là gpt-oss-120b. Có thể ghi đè chung bằng CEREBRAS_MODEL, hoặc riêng bằng CEREBRAS_PARSER_MODELCEREBRAS_FINAL_MODEL. CEREBRAS_BASE_URL mặc định là https://api.cerebras.ai/v1.
  • OPENROUTER_API_KEY: API key OpenRouter. Model được cấu hình bởi OPENROUTER_MODEL hoặc hai biến OPENROUTER_PARSER_MODEL / OPENROUTER_FINAL_MODEL.
  • OPENAI_API_KEY: API key OpenAI khi đặt MODEL_PROVIDER=openai.
  • OPENAI_PARSER_MODEL (mặc định gpt-4o-mini), OPENAI_FINAL_MODEL (gpt-4o-mini), OPENAI_USE_STRUCTURED_OUTPUTS (mặc định false), OPENAI_PARSER_TIMEOUT_MS (20000), OPENAI_FINAL_TIMEOUT_MS (15000).
  • CAG_ENABLED (mặc định true), CAG_TTL_MS (mặc định 300000) và CAG_MAX_ENTRIES (mặc định 1000): cache kết quả parse/chọn thiết bị cho cùng câu lệnh, prompt, dataset và danh sách ứng viên. Cache được fingerprint theo toàn bộ metadata nhà nên dataset upload không thể dùng nhầm kết quả của nhà khác.
  • GEMINI_USE_MOCK: cờ bật/tắt mock. Mặc định true; muốn gọi provider thật thì đặt GEMINI_USE_MOCK=false và cung cấp API key tương ứng.
  • GEMINI_API_KEY, GEMINI_PARSER_MODEL, GEMINI_FINAL_MODEL, GEMINI_CONTEXT_CACHE_NAME: legacy/test compatibility.

Badge trên góc phải playground hiển thị mock hay tên provider — nó đọc từ GET /v1/config/defaults, tức là từ chính config server đang chạy.

Trọng số và ngưỡng (giá trị khởi tạo; slider trong playground ghi đè cho từng request, không sửa .env)

  • SMART_HOME_WEIGHT_SEMANTIC (0.58), SMART_HOME_WEIGHT_NUMERIC (0.18), SMART_HOME_WEIGHT_TYPE (0.14), SMART_HOME_WEIGHT_ROOM (0.10) — cũng được chuẩn hoá về tổng 1.
  • SMART_HOME_SEMANTIC_THRESHOLD (0.35): score dưới ngưỡng bị loại ở soft filter.
  • SMART_HOME_TOP_K (5): số ứng viên giữ lại sau soft filter.
  • SMART_HOME_FINAL_EXTRACT_MAX_CANDIDATES (20): trần số ứng viên đưa vào prompt final extract.
  • SMART_HOME_FINAL_MIN_SCORE (0.45): score tối thiểu để guard cho phép thực thi.
  • SMART_HOME_AMBIGUITY_GAP (0.08): khoảng cách tối thiểu giữa top-1 và top-2; hẹp hơn mức này là mơ hồ → yêu cầu làm rõ.
  • PORT (3000).

Kiểm thử

npm test                # backend (vitest)
npm --prefix web test   # frontend (vitest + testing-library)
npm run build           # tsc backend
npm run web:build       # build frontend

Chạy không cần API key: mọi test dùng mock adapter.

CLI

npm run demo    # 3 kịch bản end-to-end
npm run trace   # nhập lệnh, in từng bước input → parser → filter → final_extract → execute

Bản đồ code

Backend (src/)

  • src/api/routes.ts — router HTTP: các endpoint theo từng step + POST /v1/command.
  • src/api/staticFiles.ts — phục vụ web/dist (guard traversal bằng path.relative, không fallback HTML cho asset thiếu).
  • src/api/errors.ts — mã lỗi chuẩn (invalid_request, dataset_not_found, upstream_error, internal_error; router trả thêm not_found).
  • src/pipeline/smartHomePipeline.ts — orchestrator 3 bước, validate, trace, metrics.
  • src/parser/openaiParser.ts, geminiParser.ts, mockParser.ts, nameQueryRepair.ts (chung interface parserInterface.ts).
  • src/filter/deterministic.ts (lọc cứng), semantic.ts (4 điểm thành phần + blend), index.ts (ghép + top-K + batch bypass).
  • src/finalExtract/openaiFinalExtractor.ts, geminiFinalExtractor.ts, mockFinalExtractor.ts.
  • src/scoring/weights.ts — trọng số mặc định + chuẩn hoá tổng = 1; src/scoring/tuning.ts — áp tuning cho từng request (không mutate config chung).
  • src/data/datasetStore.ts — dựng dataset từ các dòng CSV/XLSX, sinh warning, store in-memory; src/data/sampleHome.ts — dataset mẫu.
  • src/clients/ — client OpenAI / Gemini / Gemini context cache.
  • src/gateway/mockGateway.ts — gateway giả để chạy local không cần phần cứng.
  • src/config.ts, src/env.ts, src/schemas.ts, src/types.ts, src/logger.ts, src/utils/.

Frontend (web/src/)

  • App.tsx — hai tab (Playground / Dataset), cả hai luôn mount để đổi tab không xoá dữ liệu đang gõ.
  • state/playgroundStore.ts + state/usePlayground.ts — reducer 3 step, epoch chống race, debounce slider.
  • components/CommandBar, StepParser, StepFilter, StepFinal, StepCard, WeightsBar, DatasetTab, DeviceTable, ScoreBreakdownBar.
  • lib/parseSpreadsheet.ts — đọc CSV/XLSX ở client; lib/deviceRows.ts — validate dòng trước khi gửi.
  • api/client.ts — gọi các endpoint của backend, dịch mã lỗi server thành thông báo tiếng Việt.

Điểm chính về thiết kế

  • Playground tồn tại để nói đúng sự thật về thuật toán: mọi con số hiển thị (trọng số hiệu lực, thời gian chạy, số ứng viên, guard) đều lấy từ response của server cho đúng lần chạy đó, không phải từ state của UI. Kết quả cũ được đánh dấu "cũ" chứ không được trưng ra như kết quả hiện tại.
  • Kéo trọng số chỉ chạy lại bước local — không đốt token LLM.
  • Không dùng cache Gemini legacy trong runtime hiện tại. Mỗi dataset có fingerprint riêng; vì vậy dataset upload chỉ dùng context và kết quả cache của chính nó.
  • CAG runtime cache cả Step 1 và Step 3 theo content fingerprint; metadata nhà tĩnh được đưa vào prefix ổn định của Step 1 để OpenAI có thể áp dụng prompt caching khi model hỗ trợ. Trace/Playground hiển thị CAG cache hit hoặc số prompt token OpenAI đã cache.
  • Chạy được toàn bộ mà không cần API key nhờ mock adapter.

Contributors

DarkJyn

55 commits

DarkJyn/CAG_HomeDevice

simple text-to-intent mechanism

TypeScript

0

55 commits

updated Aug 18, 2026

See the code

README

Lumi Smart Home CAG — pipeline text → intent + Web Playground

Hệ thống nhận một câu lệnh tiếng Việt và biến nó thành intent điều khiển thiết bị, qua 3 bước:

  1. Parser (LLM) — câu lệnh tự do → intent JSON có cấu trúc (action, filter, routing).
  2. Filter (local, không tốn tiền) — lọc cứng theo floor_id / room_id / device_type, rồi chấm điểm mềm theo tên và cắt top-K.
  3. Final extract (LLM) + execute — gán alias số cho các ứng viên, LLM chọn đúng 1 alias (hoặc trả 0 = mơ hồ), guard kiểm tra rồi gateway thực thi.

Kèm theo là một Web Playground để nhìn thấy cả 3 bước, tự tạo dataset thiết bị, và kéo trọng số của công thức tính điểm.


Web Playground

Chạy nhanh (production, một origin duy nhất)

npm install
npm run web:install     # cài dependency của frontend (web/)
npm run web:build       # build frontend ra web/dist
npm run build           # build backend ra dist/
npm start               # server ở http://localhost:3000

Mở http://localhost:3000 — backend phục vụ luôn web/dist, nên frontend và API cùng một origin, không cần CORS, không cần chạy hai process.

Nếu chưa chạy npm run web:build, server vẫn khởi động và API vẫn hoạt động bình thường; vào / sẽ nhận HTTP 503 kèm đúng câu lệnh cần chạy để có bản build.

Chạy khi phát triển (hai process)

npm run dev     # backend, http://localhost:3000
npm run web     # frontend Vite, http://localhost:5173 (proxy /v1 và /healthz sang :3000)

Mở http://localhost:5173. Vite lo hot-reload; các request /v1/... được proxy sang backend (xem web/vite.config.ts).

Bốn yêu cầu, và chỗ nào trong app đáp ứng

#Yêu cầuỞ đâuCode
1Ô nhập text, đầu ra là intent điều khiển thiết bịÔ "Câu lệnh" ở tab Playground (kèm dropdown tầng/phòng lấy từ chính dataset đang dùng). Bấm Chạy → step ① hiện intent JSON parser trả về, step ③ hiện lệnh thực thi + deviceIdsweb/src/components/CommandBar.tsx, POST /v1/parse
2Upload CSV/XLSX floor_id, room_id, device_type, hoặc UI tự tạo dữ liệuTab Dataset: nút chọn file (.csv, .xlsx, .xls) bảng tự thêm/sửa/xoá từng dòng. Cả hai đường đều đi qua nút Áp dụngPOST /v1/dataset → server trả datasetId + danh sách warningweb/src/components/DatasetTab.tsx, web/src/lib/parseSpreadsheet.ts, src/data/datasetStore.ts
3Nhìn thấy cả 3 bước của thuật toánBa card ①②③ trên tab Playground: intent của parser; bảng ứng viên với score và thanh phân rã 4 thành phần điểm, số thiết bị vào/ra mỗi tầng lọc, thời gian chạy; kết quả final extract kèm alias được chọn, reason, và các guard (topScore, scoreGap)web/src/components/StepParser.tsx, StepFilter.tsx, StepFinal.tsx
4Chỉnh được trọng số trong công thức tính điểmThanh trọng số dưới cùng: 4 slider trọng số + 4 ngưỡng (semanticThreshold, topK, finalMinScore, ambiguityGap). Kéo → chỉ step ② chạy lại (local, miễn phí); step ③ bị đánh dấu "cũ" và chỉ chạy lại LLM khi bạn tự bấmweb/src/components/WeightsBar.tsx, src/scoring/tuning.ts

Thanh trọng số hiển thị đúng con số server báo là đã dùng (effectiveTuning trong response của /v1/filter), không phải vị trí slider — hai thứ này có thể lệch nhau trong lúc request đang bay.


Công thức tính điểm và trọng số

Mỗi thiết bị còn lại sau lọc cứng được chấm 4 điểm thành phần trong [0, 1] (src/filter/semantic.ts):

Thành phầnNghĩaTrọng số mặc định
semanticcosine similarity giữa name_query"<device_name> <tags>" (char n-gram trên text đã chuẩn hoá tiếng Việt)0.58
numerickhớp gợi ý số ("đèn 2", "điều hoà số 1")0.18
typename_query có nhắc đúng loại thiết bị không0.14
roomname_query có nhắc đúng tên phòng không0.10
score = Σ (component × weight_normalized)   với   weight_normalized = weight / Σ weights

Trọng số được server chuẩn hoá về tổng = 1 (src/scoring/weights.ts). Nghĩa là chỉ tỉ lệ giữa 4 trọng số có ý nghĩa, không phải độ lớn tuyệt đối: {4, 1, 1, 1}{0.4, 0.1, 0.1, 0.1} cho ra kết quả y hệt. Nhờ vậy score luôn nằm trong [0, 1] và các ngưỡng semanticThreshold / finalMinScore giữ nguyên ý nghĩa khi bạn kéo slider.

Lưu ý trung thực: nếu parser không trả name_query (ví dụ "bật đèn phòng khách" đã đủ rõ bằng lọc cứng), soft filter bị bỏ qua — mọi thiết bị nhận score = 1trọng số không ảnh hưởng gì. Lệnh batch (batchAll, ví dụ "bật tất cả đèn") cũng không đọc một con số tuning nào: nó thực thi thẳng kết quả lọc cứng và bỏ qua final extract.

Kéo một slider không bao giờ gọi LLM: playground chỉ gọi lại POST /v1/filter (chạy local). Step ③ chuyển sang trạng thái "cũ — trọng số đã đổi" và chỉ chạy lại khi bạn bấm Chạy lại step ③.


Định dạng dataset và server làm gì với dữ liệu chưa chuẩn

Cột bắt buộc: floor_id, room_id, device_type. Cột tuỳ chọn: device_id, device_name, room_name, tags (chuỗi ngăn cách bằng dấu phẩy, ví dụ ceiling,left).

device_type hợp lệ: light, air_conditioner, fan, sensor, appliance, tv, curtain, door, other.

Server (src/data/datasetStore.ts) không im lặng — mọi can thiệp đều trả về trong danh sách warnings và playground in ra ngay tại tab Dataset:

Dữ liệu vàoServer làm gì
Thiếu floor_id, room_id hoặc device_typeBỏ dòng đó, warning ghi rõ số dòng
device_type lạ (đèn, lamp, ...)Ép về other + warning. Thiết bị vẫn nằm trong dataset, nhưng lọc cứng theo loại sẽ không còn tìm ra nó như một cái đèn
Thiếu device_idTự sinh "<type>_<room_id>_<n>", đảm bảo không trùng
device_id do bạn khai bị trùng nhauBỏ dòng trùng (giữ dòng đầu) + warning
Thiếu device_nameTự sinh "<Nhãn loại> <n> <room_id>" + warning: soft filter sẽ kém chính xác — nó chấm điểm trên chính tên thiết bị, nên tên máy sinh làm thành phần semantic gần như vô nghĩa
Thiếu room_nameDùng room_id thay thế + warning (thành phần room vì thế cũng yếu đi)
Một room_id xuất hiện ở nhiều floor_idGiữ floor_id gặp đầu tiên làm chính thức + warning
Không còn dòng hợp lệ nàoHTTP 400, không tạo dataset
Quá 5000 dòngHTTP 400 — cả file bị từ chối, không cắt bớt

Dataset upload sống trong bộ nhớ của process (ds_1, ds_2, ...) và mất khi restart server. Dataset mẫu (sample, hơn 1000 thiết bị) luôn có sẵn.


API

Server phục vụ cả API và frontend trên cùng cổng PORT (mặc định 3000).

RouteMô tả
GET /healthz{"ok":true}
GET /v1/config/defaultstrọng số + ngưỡng mặc định, danh sách device_type, provider đang dùng, có đang mock không
GET /v1/dataset/:idthiết bị + staticContext của một dataset
POST /v1/dataset{ rows: [...] }{ datasetId, deviceCount, warnings, staticContext }
POST /v1/parsestep ① — LLM
POST /v1/filterstep ② — local, nhận tuning để chạy lại với trọng số khác
POST /v1/selectstep ③ — LLM + execute
POST /v1/commandchạy thẳng cả 3 bước (API gốc, không qua playground)
mọi path khácfile trong web/dist, hoặc index.html cho route SPA

Hai luật của phần phục vụ file tĩnh (src/api/staticFiles.ts), đều để tránh nói dối client:

  • Path API không tồn tại (/v1/..., /healthz) luôn trả JSON 404 — không bao giờ trả HTML. Client gọi API mà nhận về HTML sẽ chết bằng một lỗi parse JSON vô nghĩa.
  • Path có đuôi file mà không tìm thấy (/assets/index-cu.js) là 404 thật, không fallback về index.html. Trả index.html với mã 200 cho một file .js thiếu sẽ khiến trình duyệt chạy HTML như JavaScript và báo Unexpected token '<' — trong khi nguyên nhân thật (build cũ / chưa build) bị giấu đi. Chỉ path không có đuôi file mới là route SPA.

Ví dụ POST /v1/command:

{ "text": "Bật đèn trần góc trái", "context": { "room_id": "living_room_01", "floor_id": "floor_1" } }
{
  "correlationId": "...",
  "status": "executed",
  "action": { "command": "turn_on", "params": {} },
  "deviceIds": ["light_lv_ceiling_left"],
  "trace": { "parser": { ... }, "filter": { ... }, "finalExtract": { ... } }
}

Biến môi trường

Copy .env.example sang .env rồi sửa. Không commit .env.

Runtime hỗ trợ các provider tương thích OpenAI: Cerebras, OpenRouter và OpenAI. Khuyến nghị dùng Cerebras với gpt-oss-120b để có chi phí thấp và tốc độ suy luận cao. Nếu provider được chọn không có API key, hoặc GEMINI_USE_MOCK=true, hệ thống fallback sang mock để chạy local.

LLM runtime

  • MODEL_PROVIDER: cerebras, openrouter hoặc openai. Nếu không đặt, hệ thống tự chọn Cerebras/OpenRouter khi có đúng API key tương ứng; còn lại là OpenAI.
  • CEREBRAS_API_KEY: API key Cerebras. Đặt MODEL_PROVIDER=cerebras; model parser/final mặc định là gpt-oss-120b. Có thể ghi đè chung bằng CEREBRAS_MODEL, hoặc riêng bằng CEREBRAS_PARSER_MODELCEREBRAS_FINAL_MODEL. CEREBRAS_BASE_URL mặc định là https://api.cerebras.ai/v1.
  • OPENROUTER_API_KEY: API key OpenRouter. Model được cấu hình bởi OPENROUTER_MODEL hoặc hai biến OPENROUTER_PARSER_MODEL / OPENROUTER_FINAL_MODEL.
  • OPENAI_API_KEY: API key OpenAI khi đặt MODEL_PROVIDER=openai.
  • OPENAI_PARSER_MODEL (mặc định gpt-4o-mini), OPENAI_FINAL_MODEL (gpt-4o-mini), OPENAI_USE_STRUCTURED_OUTPUTS (mặc định false), OPENAI_PARSER_TIMEOUT_MS (20000), OPENAI_FINAL_TIMEOUT_MS (15000).
  • CAG_ENABLED (mặc định true), CAG_TTL_MS (mặc định 300000) và CAG_MAX_ENTRIES (mặc định 1000): cache kết quả parse/chọn thiết bị cho cùng câu lệnh, prompt, dataset và danh sách ứng viên. Cache được fingerprint theo toàn bộ metadata nhà nên dataset upload không thể dùng nhầm kết quả của nhà khác.
  • GEMINI_USE_MOCK: cờ bật/tắt mock. Mặc định true; muốn gọi provider thật thì đặt GEMINI_USE_MOCK=false và cung cấp API key tương ứng.
  • GEMINI_API_KEY, GEMINI_PARSER_MODEL, GEMINI_FINAL_MODEL, GEMINI_CONTEXT_CACHE_NAME: legacy/test compatibility.

Badge trên góc phải playground hiển thị mock hay tên provider — nó đọc từ GET /v1/config/defaults, tức là từ chính config server đang chạy.

Trọng số và ngưỡng (giá trị khởi tạo; slider trong playground ghi đè cho từng request, không sửa .env)

  • SMART_HOME_WEIGHT_SEMANTIC (0.58), SMART_HOME_WEIGHT_NUMERIC (0.18), SMART_HOME_WEIGHT_TYPE (0.14), SMART_HOME_WEIGHT_ROOM (0.10) — cũng được chuẩn hoá về tổng 1.
  • SMART_HOME_SEMANTIC_THRESHOLD (0.35): score dưới ngưỡng bị loại ở soft filter.
  • SMART_HOME_TOP_K (5): số ứng viên giữ lại sau soft filter.
  • SMART_HOME_FINAL_EXTRACT_MAX_CANDIDATES (20): trần số ứng viên đưa vào prompt final extract.
  • SMART_HOME_FINAL_MIN_SCORE (0.45): score tối thiểu để guard cho phép thực thi.
  • SMART_HOME_AMBIGUITY_GAP (0.08): khoảng cách tối thiểu giữa top-1 và top-2; hẹp hơn mức này là mơ hồ → yêu cầu làm rõ.
  • PORT (3000).

Kiểm thử

npm test                # backend (vitest)
npm --prefix web test   # frontend (vitest + testing-library)
npm run build           # tsc backend
npm run web:build       # build frontend

Chạy không cần API key: mọi test dùng mock adapter.

CLI

npm run demo    # 3 kịch bản end-to-end
npm run trace   # nhập lệnh, in từng bước input → parser → filter → final_extract → execute

Bản đồ code

Backend (src/)

  • src/api/routes.ts — router HTTP: các endpoint theo từng step + POST /v1/command.
  • src/api/staticFiles.ts — phục vụ web/dist (guard traversal bằng path.relative, không fallback HTML cho asset thiếu).
  • src/api/errors.ts — mã lỗi chuẩn (invalid_request, dataset_not_found, upstream_error, internal_error; router trả thêm not_found).
  • src/pipeline/smartHomePipeline.ts — orchestrator 3 bước, validate, trace, metrics.
  • src/parser/openaiParser.ts, geminiParser.ts, mockParser.ts, nameQueryRepair.ts (chung interface parserInterface.ts).
  • src/filter/deterministic.ts (lọc cứng), semantic.ts (4 điểm thành phần + blend), index.ts (ghép + top-K + batch bypass).
  • src/finalExtract/openaiFinalExtractor.ts, geminiFinalExtractor.ts, mockFinalExtractor.ts.
  • src/scoring/weights.ts — trọng số mặc định + chuẩn hoá tổng = 1; src/scoring/tuning.ts — áp tuning cho từng request (không mutate config chung).
  • src/data/datasetStore.ts — dựng dataset từ các dòng CSV/XLSX, sinh warning, store in-memory; src/data/sampleHome.ts — dataset mẫu.
  • src/clients/ — client OpenAI / Gemini / Gemini context cache.
  • src/gateway/mockGateway.ts — gateway giả để chạy local không cần phần cứng.
  • src/config.ts, src/env.ts, src/schemas.ts, src/types.ts, src/logger.ts, src/utils/.

Frontend (web/src/)

  • App.tsx — hai tab (Playground / Dataset), cả hai luôn mount để đổi tab không xoá dữ liệu đang gõ.
  • state/playgroundStore.ts + state/usePlayground.ts — reducer 3 step, epoch chống race, debounce slider.
  • components/CommandBar, StepParser, StepFilter, StepFinal, StepCard, WeightsBar, DatasetTab, DeviceTable, ScoreBreakdownBar.
  • lib/parseSpreadsheet.ts — đọc CSV/XLSX ở client; lib/deviceRows.ts — validate dòng trước khi gửi.
  • api/client.ts — gọi các endpoint của backend, dịch mã lỗi server thành thông báo tiếng Việt.

Điểm chính về thiết kế

  • Playground tồn tại để nói đúng sự thật về thuật toán: mọi con số hiển thị (trọng số hiệu lực, thời gian chạy, số ứng viên, guard) đều lấy từ response của server cho đúng lần chạy đó, không phải từ state của UI. Kết quả cũ được đánh dấu "cũ" chứ không được trưng ra như kết quả hiện tại.
  • Kéo trọng số chỉ chạy lại bước local — không đốt token LLM.
  • Không dùng cache Gemini legacy trong runtime hiện tại. Mỗi dataset có fingerprint riêng; vì vậy dataset upload chỉ dùng context và kết quả cache của chính nó.
  • CAG runtime cache cả Step 1 và Step 3 theo content fingerprint; metadata nhà tĩnh được đưa vào prefix ổn định của Step 1 để OpenAI có thể áp dụng prompt caching khi model hỗ trợ. Trace/Playground hiển thị CAG cache hit hoặc số prompt token OpenAI đã cache.
  • Chạy được toàn bộ mà không cần API key nhờ mock adapter.

Contributors

DarkJyn

55 commits

Languages

TypeScript

97.9%

CSS

2.0%