diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c918251 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +DJANGO_SECRET_KEY=replace-with-a-local-secret-key +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=* + +# OpenAI-compatible LLM API +LLM_API_KEY=your_llm_api_key +LLM_BASE_URL=https://api.openai.com/v1 +LLM_MODEL=gpt-4.1-mini + +# Embedding model for RAG +# Leave EMBEDDING_API_KEY empty to reuse LLM_API_KEY if desired. +EMBEDDING_API_KEY= +EMBEDDING_BASE_URL= +EMBEDDING_MODEL=text-embedding-3-small + +SCENARIO_CONFIG_DIR=configs +UPLOAD_ROOT=data/uploads +CHROMA_PATH=data/chroma diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac87143 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/ + +EXPOSE 8000 + +CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"] diff --git a/README.md b/README.md index a6c5b91..93da295 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,41 @@ docker compose up --build 当前文档目标已统一为完整 V1 闭环:真实 Chroma RAG、OpenAI 兼容 LLM、OpenAI 兼容 Embedding、工具注册和审计日志。开发阶段可以用测试桩验证页面和边界,但不作为 V1 验收结果。 +## 环境变量 + +项目当前通过 `os.environ` 读取配置,核心变量如下: + +```env +DJANGO_SECRET_KEY=replace-with-a-local-secret-key +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=* + +LLM_API_KEY=your_llm_api_key +LLM_BASE_URL=https://api.openai.com/v1 +LLM_MODEL=gpt-4.1-mini + +EMBEDDING_API_KEY= +EMBEDDING_BASE_URL= +EMBEDDING_MODEL=text-embedding-3-small + +SCENARIO_CONFIG_DIR=configs +UPLOAD_ROOT=data/uploads +CHROMA_PATH=data/chroma +``` + +说明: + +- `EMBEDDING_API_KEY` 为空时,代码会自动复用 `LLM_API_KEY`。 +- `EMBEDDING_BASE_URL` 为空时,代码会自动复用 `LLM_BASE_URL`。 +- `.env.example` 只作为模板,不应填写真实密钥并提交到仓库。 +- 当前代码会在 Django settings 初始化时自动加载根目录 `.env`,本地 `python manage.py runserver`、`pytest` 和 Docker Compose 可以复用同一套配置。 +- Docker Compose 当前在 `docker-compose.yml` 中通过 `env_file` 读取 `.env`。 + +常见做法: + +- 本地开发:复制 `.env.example` 为 `.env`,填入真实参数后运行。 +- Docker 演示:确认 `.env` 已配置后,再执行 `docker compose up --build`。 + ## 文档入口 - [V1 总需求文档](docs/需求分析/1.V1总需求文档.md) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ + diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..a713a97 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.asgi import get_asgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..2000dd6 --- /dev/null +++ b/config/settings.py @@ -0,0 +1,108 @@ +import os +from pathlib import Path + + +BASE_DIR = Path(__file__).resolve().parent.parent + + +def load_dotenv(dotenv_path: Path) -> None: + if not dotenv_path.exists(): + return + for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +load_dotenv(BASE_DIR / ".env") + + +def env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-secret-key") +DEBUG = env_bool("DJANGO_DEBUG", True) +ALLOWED_HOSTS = [ + host.strip() + for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") + if host.strip() +] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "apps.scenarios", + "apps.documents", + "apps.chat", + "apps.audit", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "data" / "db.sqlite3", + } +} + +LANGUAGE_CODE = "zh-hans" +TIME_ZONE = "Asia/Shanghai" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATICFILES_DIRS = [BASE_DIR / "static"] +MEDIA_URL = "media/" +MEDIA_ROOT = Path(os.environ.get("UPLOAD_ROOT", BASE_DIR / "data" / "uploads")) + +SCENARIO_CONFIG_DIR = Path(os.environ.get("SCENARIO_CONFIG_DIR", BASE_DIR / "configs")) +CHROMA_PATH = Path(os.environ.get("CHROMA_PATH", BASE_DIR / "data" / "chroma")) + +LLM_API_KEY = os.environ.get("LLM_API_KEY", "") +LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1") +LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-4.1-mini") +EMBEDDING_API_KEY = os.environ.get("EMBEDDING_API_KEY", LLM_API_KEY) +EMBEDDING_BASE_URL = os.environ.get("EMBEDDING_BASE_URL", LLM_BASE_URL) +EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "text-embedding-3-small") + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..d73e81b --- /dev/null +++ b/config/urls.py @@ -0,0 +1,16 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("apps.scenarios.urls")), + path("chat/", include("apps.chat.urls")), + path("documents/", include("apps.documents.urls")), + path("audit/", include("apps.audit.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..3f41533 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,8 @@ +import os + +from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6ddb3b7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + web: + build: . + env_file: + - .env + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - ./configs:/app/configs diff --git a/docs/设计文档/5.部署设计.md b/docs/设计文档/5.部署设计.md index dfaeb22..06327f0 100644 --- a/docs/设计文档/5.部署设计.md +++ b/docs/设计文档/5.部署设计.md @@ -18,6 +18,8 @@ python manage.py runserver 本地运行使用 SQLite、`data/uploads` 和 `data/chroma`。 +当前本地方式会在启动时自动读取根目录 `.env`,因此 `runserver`、`pytest` 和日常脚本可以共享同一套配置。 + ## 3. Docker 运行方式 建议命令: @@ -47,6 +49,12 @@ V1 Docker Compose 只需要一个 Django Web 服务。Chroma 使用本地持久 `.env.example` 应提供这些变量的样例,不写真实密钥。 +当前实现说明: + +- 本地 Python 方式启动时,会先加载根目录 `.env`,再读取进程环境中的覆盖值。 +- Docker Compose 方式可通过 `env_file` 向容器注入环境变量;当前仓库默认读取 `.env`。 +- 因此本地运行和容器运行可以默认共用一份 `.env`,但演示前仍应确认密钥和模型参数是否正确。 + ## 5. 目录挂载设计 Docker 需要持久化以下目录: diff --git a/docs/设计文档/模块设计/1.配置模块详细设计.md b/docs/设计文档/模块设计/1.配置模块详细设计.md index 4d4724e..2be15f2 100644 --- a/docs/设计文档/模块设计/1.配置模块详细设计.md +++ b/docs/设计文档/模块设计/1.配置模块详细设计.md @@ -97,6 +97,12 @@ V1 可使用标准库 `os.environ.get()`,不强制引入复杂配置库。 `DJANGO_ALLOWED_HOSTS` 使用逗号分隔,空值时默认 `["*"]`。 +当前实现约束: + +- 本地直接运行 Django 命令时,会先尝试解析根目录 `.env` 文件,再读取进程环境中的覆盖值。 +- Docker Compose 方式可以通过 `env_file` 传入同一批变量;当前仓库默认读取 `.env`。 +- `.env.example` 只保留占位符示例,不保存真实 API Key。 + ## 8. 验收标准 - `python manage.py check` 通过。 diff --git a/docs/需求分析/1.V1总需求文档.md b/docs/需求分析/1.V1总需求文档.md index 88454b9..39c77d3 100644 --- a/docs/需求分析/1.V1总需求文档.md +++ b/docs/需求分析/1.V1总需求文档.md @@ -362,12 +362,28 @@ V1 模型适配器需要支持: 环境变量示例: ```env +DJANGO_SECRET_KEY=replace-with-a-local-secret-key +DJANGO_DEBUG=true +DJANGO_ALLOWED_HOSTS=* + LLM_API_KEY=your_api_key LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4.1-mini +EMBEDDING_API_KEY= +EMBEDDING_BASE_URL= EMBEDDING_MODEL=text-embedding-3-small +SCENARIO_CONFIG_DIR=configs +UPLOAD_ROOT=data/uploads +CHROMA_PATH=data/chroma ``` +补充说明: + +- `EMBEDDING_API_KEY` 为空时可复用 `LLM_API_KEY`。 +- `EMBEDDING_BASE_URL` 为空时可复用 `LLM_BASE_URL`。 +- `.env.example` 仅作为模板,不允许放真实密钥。 +- 当前 V1 代码会在 settings 初始化时自动读取根目录 `.env`,本地运行与 `pytest` 可复用同一套配置;当前 Docker Compose 配置也通过 `env_file` 读取 `.env`。 + ## 10. Dify 集成策略 V1 不把 Dify 作为核心依赖。 diff --git a/docs/需求分析/3.配置模块需求.md b/docs/需求分析/3.配置模块需求.md index 79a819f..bb4a671 100644 --- a/docs/需求分析/3.配置模块需求.md +++ b/docs/需求分析/3.配置模块需求.md @@ -55,6 +55,12 @@ Config 模块是 Django 项目的基础配置模块,负责系统启动、路 | `UPLOAD_ROOT` | `data/uploads` | 上传文件目录 | | `SCENARIO_CONFIG_DIR` | `configs` | 场景配置目录 | +补充要求: + +- `.env.example` 仅作为模板文件,不得写入真实密钥。 +- 本地直接执行 `python manage.py runserver` 时,应自动读取根目录 `.env`。 +- Docker 运行时可通过 `env_file` 或容器环境变量注入同一组配置;当前仓库默认由 Compose 读取 `.env`。 + ## 5. 目录需求 系统启动时需要保证以下目录存在: @@ -91,6 +97,8 @@ python manage.py migrate python manage.py runserver ``` +说明:上述命令执行前,应先准备好根目录 `.env`;当前 V1 代码会在启动时自动加载该文件。 + Docker 启动: ```bash diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..f90c4b8 --- /dev/null +++ b/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7a4fb9b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings +python_files = tests.py test_*.py *_tests.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dc850f3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Django>=5.1,<6.0 +PyYAML>=6.0,<7.0 +chromadb>=0.5,<1.0 +pytest>=8.0,<9.0 +pytest-django>=4.9,<5.0 diff --git a/tests/test_project_configuration.py b/tests/test_project_configuration.py new file mode 100644 index 0000000..9fe8208 --- /dev/null +++ b/tests/test_project_configuration.py @@ -0,0 +1,21 @@ +import os + +from django.conf import settings +from django.urls import reverse + + +def test_core_settings_expose_documented_paths(): + assert settings.SCENARIO_CONFIG_DIR.name == "configs" + assert settings.CHROMA_PATH.name == "chroma" + assert settings.MEDIA_ROOT.name == "uploads" + assert settings.EMBEDDING_MODEL == os.environ.get( + "EMBEDDING_MODEL", + "text-embedding-3-small", + ) + assert settings.EMBEDDING_BASE_URL == settings.LLM_BASE_URL + assert settings.EMBEDDING_API_KEY == settings.LLM_API_KEY + + +def test_home_url_is_registered(client): + response = client.get(reverse("scenarios:index")) + assert response.status_code == 200