chore: streamline local development and fixture tooling

Run backend and web with a shared configurable port, add local webhook and secret-generation helpers with tests, and document current app-auth and fixture workflows.
This commit is contained in:
hungtran24 2026-10-02 10:58:09 +07:00
parent ec9f0f911e
commit 8082a9f968
6 changed files with 198 additions and 15 deletions

View File

@ -1,7 +1,10 @@
SHELL := /bin/sh
.DEFAULT_GOAL := help
.PHONY: help install db-up db-down migrate backend-dev web-dev contract check-fast check test build
# Parse only the shared port; importing .env as Make syntax would misread secrets and JSON.
BACKEND_PORT ?= $(shell uv run --no-sync python -c 'from dotenv import dotenv_values; print(dotenv_values(".env").get("BACKEND_PORT") or "8000")')
.PHONY: help install dev db-up db-down migrate backend-dev web-dev contract check-fast check test build
help: ## List repository commands
@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk -F ':.*## ' '{printf "%-16s %s\n", $$1, $$2}'
@ -10,8 +13,11 @@ install: ## Sync project-local dependencies from both lockfiles
uv sync --locked --all-groups
cd apps/web && bun install --frozen-lockfile
db-up: ## Start only the new local PostgreSQL volume
docker compose up -d postgres
dev: ## Start PostgreSQL and run the native backend and web together
$(MAKE) --jobs=2 backend-dev web-dev
db-up: ## Start local PostgreSQL and wait until ready
docker compose up -d --wait postgres
db-down: ## Stop local services without deleting the database
docker compose down
@ -19,14 +25,15 @@ db-down: ## Stop local services without deleting the database
migrate: ## Apply migrations to the configured database
uv run --no-sync alembic upgrade head
backend-dev: ## Run a single backend process bound to localhost
uv run --no-sync uvicorn ai_platform.main:app --reload --host 127.0.0.1 --port 8000
backend-dev: db-up ## Ensure PostgreSQL is ready, then run backend on the shared port
uv run --no-sync uvicorn ai_platform.main:app --reload --host 127.0.0.1 --port $(BACKEND_PORT)
web-dev: ## Run SvelteKit bound to localhost
cd apps/web && bun run dev -- --host 127.0.0.1
web-dev: ## Run SvelteKit with the shared backend address
cd apps/web && BACKEND_INTERNAL_URL=http://127.0.0.1:$(BACKEND_PORT) bun run dev -- --host 127.0.0.1
contract: ## Generate the backend OpenAPI consumed by Admin Web
uv run --no-sync ai-platform export-openapi contracts/lead-scoring/v2/openapi.json
uv run --no-sync python scripts/export_contract_fixtures.py
check-fast: ## Check staged/all tracked source with opt-in pre-commit rules
uv run --no-sync pre-commit run --all-files

View File

@ -1,6 +1,6 @@
# AI Platform
New AI Platform repository scaffold. The backend and SvelteKit implementation are being developed concurrently; this document records the intended local workflow, not a claim that every command already works.
AI Platform provides Lead Scoring with a Python backend and internal SvelteKit Admin Web. Local app-auth verification and integration limitations are recorded in `docs/contracts/app-auth-provider-handoff.md`; local checks do not establish production readiness.
## Runtime layout
@ -22,15 +22,21 @@ cp apps/web/.env.example apps/web/.env
make install
```
Start the database and run the backend and web servers in separate terminals:
For a new database, prepare its schema once before starting the application:
```sh
make db-up
make migrate
make backend-dev
make web-dev
```
Then start PostgreSQL and both native servers from one terminal:
```sh
make dev
```
Set `BACKEND_PORT` once in the root `.env` (default `8000`; use `8002` to avoid a local conflict). `make backend-dev` uses that port, and `make web-dev` passes the matching server-only `BACKEND_INTERNAL_URL` to Web, overriding any old URL in `apps/web/.env`. An exported `BACKEND_PORT` or `make dev BACKEND_PORT=8002` can override the root value. Other root secrets are not passed to Web. The web development server normally listens at `127.0.0.1:5173` (see its terminal output). `Ctrl+C` stops the native servers; PostgreSQL stays running. `make dev` does not run migrations or start Omni/AIHub. To run the servers in separate terminals instead, use `make backend-dev` and `make web-dev`.
The local web server should call the backend through server-only configuration. Do not expose `BACKEND_INTERNAL_URL`, database credentials, or provider keys as browser-visible `PUBLIC_*` variables.
Compose can instead build and start all services, including the one-shot migration step:
@ -60,15 +66,37 @@ AI_PLATFORM_OPENAI_BASE_URL=http://127.0.0.1:8317
# AI_PLATFORM_OPENAI_API_KEY=... # chỉ cần nếu CLIProxy yêu cầu xác thực
```
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web, tạo và kích hoạt **một phiên bản Cấu hình AI mới**, chọn `OpenAI-compatible`, nhập URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, prompt và timeout. `AI_PLATFORM_OPENAI_BASE_URL` là URL mặc định cho profile cũ và là địa chỉ duy nhất nhận `AI_PLATFORM_OPENAI_API_KEY`; URL khác nhập trong Web không được gửi key đó. Profile cũ mặc định AIHub; URL AIHub vẫn cấu hình bằng `AI_PLATFORM_AIHUB_BASE_URL`. Lựa chọn này áp dụng cho các run mới của Lead Scoring, không đổi các run đã nhận trước đó. Gửi `POST /v1/lead-scoring/runs` với `ai_profile=medium`, `ai_weight>0` và tenant key; OpenAI-compatible không dùng `X-Tenant-VKey`. Nút “Chạy mẫu” trên Web hiện chỉ gửi mẫu quy tắc (`ai_weight=0`), nên hãy dùng API để thử AI.
Chạy `make backend-dev` trên máy host: `127.0.0.1:8317` bên trong Docker không trỏ tới CLIProxy trên host. Trong Admin Web, tạo và kích hoạt **một phiên bản Cấu hình AI mới**, chọn `OpenAI-compatible`, nhập URL `http://127.0.0.1:8317`, model `gemini-3.5-flash-lite`, prompt và timeout. `AI_PLATFORM_OPENAI_BASE_URL` là URL mặc định cho profile cũ và là địa chỉ duy nhất nhận `AI_PLATFORM_OPENAI_API_KEY`; URL khác nhập trong Web không được gửi key đó. Profile cũ mặc định AIHub; URL AIHub vẫn cấu hình bằng `AI_PLATFORM_AIHUB_BASE_URL`. Lựa chọn này áp dụng cho các run mới của Lead Scoring, không đổi các run đã nhận trước đó. Gửi `POST /v1/lead-scoring/runs` với `ai_profile=medium`, `ai_weight>0` và app key; OpenAI-compatible không dùng `X-Tenant-VKey`. Trang “Thử chấm điểm” trên Web cho xem trước hội thoại và quy tắc mẫu. Mặc định chỉ chấm quy tắc (`ai_weight=0`); bật “Chấm thêm bằng AI” để gửi `ai_profile=medium` với trọng số quy tắc/AI 0.5/0.5. Chế độ này dùng provider đang active và có thể phát sinh chi phí. Mẫu không gửi V-Key nên với AIHub sẽ dự phòng bằng điểm quy tắc; xem trang chi tiết run để kiểm tra kết quả thực tế.
Với endpoint OpenAI-compatible chạy ở production, URL phải dùng HTTPS. Key được cấu hình trên server chỉ gửi tới URL được gắn với key đó; thay URL trên Web không tự chuyển key. Không đặt key vào Web hoặc file versioned. CLIProxy local chỉ xác minh được đường gọi thử; chưa chứng minh các điều kiện phát hành production.
## Terminal webhook và retention
## App credentials và terminal webhook
Khi cấu hình đồng thời `AI_PLATFORM_WEBHOOK_URL` và `AI_PLATFORM_WEBHOOK_SIGNING_SECRET` (ít nhất 32 ký tự), backend gửi thông báo kết thúc run tới một URL Omni cố định. URL HTTP chỉ hợp lệ trên loopback khi phát triển. Body chỉ có `event_id`, `run_id`, `tenant_id`, `status`, `occurred_at`; header `X-Webhook-Timestamp` và `X-Webhook-Signature` chứa chữ ký HMAC-SHA256 trên `timestamp + "." + raw body`. Phản hồi 2xx xác nhận giao; lỗi được thử lại tối đa 5 lần rồi giữ bản ghi đánh dấu `dead`. Chỉ bật gửi thực tế sau khi Omni xác nhận fixture chữ ký, chống replay và GET/writeback.
Mỗi installation/môi trường Omni dùng một `app_id` ổn định và một backend app key chung cho các account. Vận hành cấp/rotate/revoke qua CLI; raw key chỉ được xuất khi cấp/rotate, chuyển an toàn sang backend Omni, không qua browser/log/Git:
Backend dọn run quá 90 ngày (gồm snapshot, fingerprint và thông báo liên quan) khi khởi động và mỗi ngày. Không áp dụng chính sách này cho backup; cần chốt vòng đời backup và tenant offboarding trước production.
```sh
uv run --no-sync ai-platform create-app omni-local
uv run --no-sync ai-platform rotate-app-key omni-local
uv run --no-sync ai-platform revoke-app-key omni-local
```
`create-app --no-lead-scoring` cấp app chưa có quyền Lead Scoring. Admin Web `/platform/apps` chỉ xem trạng thái và thu hồi, không hiển thị hay cấp/rotate key. POST/GET dùng `Authorization: Bearer <app_api_key>`; POST có thêm `account_id` từ Omni. Omni dùng `LEAD_SCORING_API_URL`, `AI_PLATFORM_APP_ID`, `AI_PLATFORM_APP_API_KEY` ở backend. Đã bỏ tenant provisioning API/secret; không đăng ký account ở AI Platform.
Tạo secret webhook bằng `uv run --no-sync python scripts/generate_secret.py LEAD_SCORING_WEBHOOK_SIGNING_SECRET`. Script chỉ in `TEN_BIEN=SECRET` với secret ngẫu nhiên 64 ký tự, không sửa file hay tạo app key. Lưu cùng giá trị secret vào cấu hình Omni và `signing_secret` của app tương ứng ở AI Platform; không đưa output vào Git/chat/log dùng chung. Mỗi lần chạy tạo secret mới.
Cấu hình `AI_PLATFORM_APP_WEBHOOKS` bằng JSON mapping app ID tới URL/signing secret, ví dụ trong `.env.example`. Mẫu localhost chỉ áp dụng khi backend/Omni chạy trực tiếp cùng host; với Compose phải dùng HTTPS endpoint Omni truy cập được từ container, không dùng localhost để trỏ tới host. Thiếu mapping chặn run mới với 503, nhưng replay vẫn trả run cũ. URL HTTPS, chỉ HTTP loopback development; secret ít nhất 32 ký tự. Webhook body có đúng `event_id`, `app_id`, `account_id`, `request_id`, `run_id`, `status`, `occurred_at`. Chữ ký HMAC-SHA256 trên `timestamp + "." + raw body` trong `X-Webhook-Signature`, timestamp mới mỗi attempt trong `X-Webhook-Timestamp`; không follow redirect. Retry tối đa 5 attempts rồi dead. Vận hành redeliver cùng event, không rescore:
```sh
uv run --no-sync ai-platform redeliver-notification <event_id>
```
Generated OpenAPI và các fixture tại `contracts/lead-scoring/v2/`; chạy `make contract` để tái sinh và `uv run --no-sync python scripts/export_contract_fixtures.py --check` để kiểm tra drift fixture mà không sửa file. Omni cần đối chiếu callback/GET/source request trước writeback; provider checks chưa chứng minh tích hợp Omni/EMS.
Schema chưa phát hành hiện có một migration `0001_initial`, tạo thẳng app credentials, admin, AI profiles, runs và outbox. Database mới chạy `make migrate`. Database local còn revision cũ không tự upgrade sang baseline: dùng database mới, hoặc xác nhận phạm vi dữ liệu cần giữ/bỏ trước khi dựng lại hay stamp sau kiểm tra schema; không chạy `alembic stamp head` để che schema khác biệt. Startup vẫn kiểm tra revision và không tự migrate. Lịch sử migrations sau lần phát hành đầu tiên phải được giữ.
`scripts/seed_demo.py` yêu cầu app key đã cấp qua `AI_PLATFORM_DEMO_APP_API_KEY`, không tự provisioning. Fixture runner nội bộ dùng `LEAD_SCORING_FIXTURE_APP_API_KEY` server-only, ưu tiên app test riêng có webhook mock.
Backend dọn run quá 90 ngày (gồm snapshot, fingerprint và thông báo liên quan) khi khởi động và mỗi ngày. Backup và app/account offboarding cần chính sách riêng trước production.
## Quality checks

View File

@ -0,0 +1,26 @@
"""Local fixture webhook receiver: ACK only, no signature or integration verification."""
from http.server import BaseHTTPRequestHandler, HTTPServer
class FixtureWebhookHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
if self.path != "/notifications":
self.send_error(404)
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self.send_error(400)
return
if not 0 <= length <= 8192:
self.send_error(413)
return
self.rfile.read(length)
self.send_response(202)
self.end_headers()
if __name__ == "__main__":
print("Fixture webhook receiver: http://127.0.0.1:9001/notifications", flush=True)
HTTPServer(("127.0.0.1", 9001), FixtureWebhookHandler).serve_forever()

View File

@ -0,0 +1,20 @@
"""Print a random secret as an environment assignment without changing any files."""
import argparse
import re
import secrets
def main() -> None:
parser = argparse.ArgumentParser(description="Generate a secret as NAME=VALUE.")
parser.add_argument(
"name", help="Environment variable name, e.g. LEAD_SCORING_WEBHOOK_SIGNING_SECRET"
)
args = parser.parse_args()
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", args.name) is None:
parser.error("name must be a valid environment variable name")
print(f"{args.name}={secrets.token_hex(32)}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,64 @@
import os
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.parametrize(
("configured_port", "environment_port", "argument_port", "expected_port"),
[
(None, None, None, "8000"),
("8002", None, None, "8002"),
("8123", None, None, "8123"),
("8002", "8124", None, "8124"),
("8002", "8124", "8125", "8125"),
],
)
def test_dev_uses_the_same_backend_port_for_both_servers(
tmp_path: Path,
configured_port: str | None,
environment_port: str | None,
argument_port: str | None,
expected_port: str,
):
shutil.copyfile(ROOT / "Makefile", tmp_path / "Makefile")
secret = "fixture-secret-must-not-be-exported-or-printed"
config = f"AI_PLATFORM_OPENAI_API_KEY={secret}\n"
if configured_port is not None:
config += f'BACKEND_PORT="{configured_port}"\n'
(tmp_path / ".env").write_text(config)
# Only the port lookup executes during dry-run; bypass uv project discovery in this fixture.
uv = tmp_path / "uv"
uv.write_text(f'#!/bin/sh\nshift 3\nexec {shlex.quote(sys.executable)} "$@"\n')
uv.chmod(0o700)
environment = os.environ.copy()
for name in ("BACKEND_PORT", "MAKEFLAGS", "MFLAGS", "MAKEOVERRIDES"):
environment.pop(name, None)
environment["PATH"] = f"{tmp_path}{os.pathsep}{environment['PATH']}"
if environment_port is not None:
environment["BACKEND_PORT"] = environment_port
command = ["make", "--dry-run", "dev"]
if argument_port is not None:
command.append(f"BACKEND_PORT={argument_port}")
result = subprocess.run(
command,
cwd=tmp_path,
env=environment,
text=True,
capture_output=True,
check=False,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert f"--port {expected_port}" in result.stdout
assert f"BACKEND_INTERNAL_URL=http://127.0.0.1:{expected_port} bun" in result.stdout
assert "docker compose up -d --wait postgres" in result.stdout
assert "alembic" not in result.stdout
assert secret not in result.stdout + result.stderr
assert "AI_PLATFORM_OPENAI_API_KEY" not in result.stdout + result.stderr

View File

@ -0,0 +1,38 @@
import re
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "generate_secret.py"
def run_script(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), *args], capture_output=True, text=True, check=False
)
def test_generates_named_secret():
name = "LEAD_SCORING_WEBHOOK_SIGNING_SECRET"
first = run_script(name)
second = run_script(name)
assert first.returncode == second.returncode == 0
assert first.stderr == second.stderr == ""
for result in (first, second):
assert re.fullmatch(rf"{name}=[0-9a-f]{{64}}\n", result.stdout)
assert first.stdout != second.stdout
@pytest.mark.parametrize("name", ["", "1SECRET", "SECRET=value", "BAD-NAME", "A\nB"])
def test_rejects_invalid_variable_names(name: str):
result = run_script(name)
assert result.returncode == 2
assert result.stdout == ""
def test_requires_variable_name():
result = run_script()
assert result.returncode == 2
assert result.stdout == ""