diff --git a/Makefile b/Makefile index 6f5de7a..da78f87 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 2947efe..a77ee15 100644 --- a/README.md +++ b/README.md @@ -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 `; 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 +``` + +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 diff --git a/scripts/fixture_webhook.py b/scripts/fixture_webhook.py new file mode 100644 index 0000000..b7b9338 --- /dev/null +++ b/scripts/fixture_webhook.py @@ -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() diff --git a/scripts/generate_secret.py b/scripts/generate_secret.py new file mode 100644 index 0000000..90afe6f --- /dev/null +++ b/scripts/generate_secret.py @@ -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() diff --git a/tests/test_dev_commands.py b/tests/test_dev_commands.py new file mode 100644 index 0000000..75d75f5 --- /dev/null +++ b/tests/test_dev_commands.py @@ -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 diff --git a/tests/test_generate_secret.py b/tests/test_generate_secret.py new file mode 100644 index 0000000..a6addbf --- /dev/null +++ b/tests/test_generate_secret.py @@ -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 == ""