Spaces:
Sleeping
Sleeping
feat: 添加Docker配置、依赖管理和测试框架
Browse files- 添加Dockerfile支持容器化部署
- 完善requirements.txt包含所有必要依赖
- 创建完整的测试框架和单元测试
- 支持HuggingFace Spaces自动构建和部署
- 添加健康检查和测试覆盖
- Dockerfile +32 -0
- requirements.txt +29 -0
- tests/__init__.py +7 -0
- tests/test_ai_model.py +56 -0
- tests/test_config_loader.py +57 -0
- tests/test_travel_assistant.py +52 -0
Dockerfile
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# 设置工作目录
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 设置环境变量
|
| 8 |
+
ENV PYTHONPATH=/app
|
| 9 |
+
ENV PYTHONUNBUFFERED=1
|
| 10 |
+
|
| 11 |
+
# 安装系统依赖
|
| 12 |
+
RUN apt-get update && apt-get install -y \
|
| 13 |
+
git \
|
| 14 |
+
curl \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# 复制requirements文件并安装Python依赖
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 20 |
+
|
| 21 |
+
# 复制项目文件
|
| 22 |
+
COPY . .
|
| 23 |
+
|
| 24 |
+
# 暴露端口
|
| 25 |
+
EXPOSE 7860
|
| 26 |
+
|
| 27 |
+
# 健康检查
|
| 28 |
+
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
| 29 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 30 |
+
|
| 31 |
+
# 启动命令
|
| 32 |
+
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
requirements.txt
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# requirements.txt
|
| 2 |
+
# FastAPI 和 Web 服务
|
| 3 |
+
fastapi==0.104.1
|
| 4 |
+
uvicorn[standard]==0.24.0
|
| 5 |
+
pydantic==2.5.0
|
| 6 |
+
python-multipart==0.0.6
|
| 7 |
+
|
| 8 |
+
# AI 模型和机器学习
|
| 9 |
+
torch==2.1.0
|
| 10 |
+
transformers==4.54.1
|
| 11 |
+
accelerate==0.25.0
|
| 12 |
+
safetensors==0.4.1
|
| 13 |
+
|
| 14 |
+
# 图像处理
|
| 15 |
+
Pillow==10.1.0
|
| 16 |
+
requests==2.31.0
|
| 17 |
+
|
| 18 |
+
# 音频处理(为将来扩展准备)
|
| 19 |
+
# librosa==0.10.1
|
| 20 |
+
# soundfile==0.12.1
|
| 21 |
+
|
| 22 |
+
# 工具库
|
| 23 |
+
numpy==1.24.3
|
| 24 |
+
typing-extensions==4.8.0
|
| 25 |
+
|
| 26 |
+
# 测试框架
|
| 27 |
+
pytest==7.4.3
|
| 28 |
+
pytest-asyncio==0.21.1
|
| 29 |
+
httpx==0.25.2
|
tests/__init__.py
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/__init__.py
|
| 2 |
+
"""
|
| 3 |
+
测试模块包
|
| 4 |
+
包含所有组件的单元测试和集成测试
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
__version__ = '1.0.0'
|
tests/test_ai_model.py
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/test_ai_model.py
|
| 2 |
+
import pytest
|
| 3 |
+
from unittest.mock import Mock, patch
|
| 4 |
+
from modules.ai_model import AIModel
|
| 5 |
+
|
| 6 |
+
class TestAIModel:
|
| 7 |
+
|
| 8 |
+
@pytest.fixture
|
| 9 |
+
def mock_ai_model(self):
|
| 10 |
+
"""创建模拟的AI模型"""
|
| 11 |
+
with patch('modules.ai_model.Gemma3nForConditionalGeneration') as mock_model, \
|
| 12 |
+
patch('modules.ai_model.AutoProcessor') as mock_processor:
|
| 13 |
+
|
| 14 |
+
mock_model.from_pretrained.return_value = Mock()
|
| 15 |
+
mock_processor.from_pretrained.return_value = Mock()
|
| 16 |
+
|
| 17 |
+
ai_model = AIModel()
|
| 18 |
+
yield ai_model
|
| 19 |
+
|
| 20 |
+
def test_detect_input_type_text(self, mock_ai_model):
|
| 21 |
+
"""测试文本输入检测"""
|
| 22 |
+
assert mock_ai_model.detect_input_type("我想去巴黎") == "text"
|
| 23 |
+
|
| 24 |
+
def test_detect_input_type_image_url(self, mock_ai_model):
|
| 25 |
+
"""测试图片URL检测"""
|
| 26 |
+
assert mock_ai_model.detect_input_type("https://example.com/image.jpg") == "image"
|
| 27 |
+
|
| 28 |
+
def test_detect_input_type_image_path(self, mock_ai_model):
|
| 29 |
+
"""测试图片路径检测"""
|
| 30 |
+
assert mock_ai_model.detect_input_type("./images/paris.png") == "image"
|
| 31 |
+
|
| 32 |
+
def test_detect_input_type_audio(self, mock_ai_model):
|
| 33 |
+
"""测试音频文件检测"""
|
| 34 |
+
assert mock_ai_model.detect_input_type("audio.mp3") == "audio"
|
| 35 |
+
|
| 36 |
+
def test_is_available(self, mock_ai_model):
|
| 37 |
+
"""测试模型可用性检查"""
|
| 38 |
+
assert mock_ai_model.is_available() == True
|
| 39 |
+
|
| 40 |
+
@patch('modules.ai_model.Image')
|
| 41 |
+
@patch('modules.ai_model.requests')
|
| 42 |
+
def test_format_input_image_url(self, mock_requests, mock_image, mock_ai_model):
|
| 43 |
+
"""测试图片URL格式化"""
|
| 44 |
+
mock_response = Mock()
|
| 45 |
+
mock_response.content = b"fake_image_data"
|
| 46 |
+
mock_requests.get.return_value = mock_response
|
| 47 |
+
mock_image.open.return_value.convert.return_value = "processed_image"
|
| 48 |
+
|
| 49 |
+
input_type, formatted_data, processed_text = mock_ai_model.format_input(
|
| 50 |
+
"image",
|
| 51 |
+
"https://example.com/image.jpg"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
assert input_type == "image"
|
| 55 |
+
assert formatted_data == "processed_image"
|
| 56 |
+
assert "请描述这张图片" in processed_text
|
tests/test_config_loader.py
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/test_config_loader.py
|
| 2 |
+
import pytest
|
| 3 |
+
import json
|
| 4 |
+
import tempfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from modules.config_loader import ConfigLoader
|
| 7 |
+
|
| 8 |
+
class TestConfigLoader:
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def temp_config_dir(self):
|
| 12 |
+
"""创建临时配置目录和文件"""
|
| 13 |
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 14 |
+
config_dir = Path(tmp_dir)
|
| 15 |
+
|
| 16 |
+
# 创建测试配置文件
|
| 17 |
+
cities_data = {
|
| 18 |
+
"cities": [
|
| 19 |
+
{"name": "巴黎", "country": "法国", "aliases": ["paris"]}
|
| 20 |
+
]
|
| 21 |
+
}
|
| 22 |
+
with open(config_dir / "cities.json", 'w', encoding='utf-8') as f:
|
| 23 |
+
json.dump(cities_data, f, ensure_ascii=False)
|
| 24 |
+
|
| 25 |
+
personas_data = {
|
| 26 |
+
"personas": {
|
| 27 |
+
"planner": {"name": "规划型", "keywords": ["规划", "安排"]}
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
with open(config_dir / "personas.json", 'w', encoding='utf-8') as f:
|
| 31 |
+
json.dump(personas_data, f, ensure_ascii=False)
|
| 32 |
+
|
| 33 |
+
interests_data = {
|
| 34 |
+
"interests": {"美食": ["美食", "餐厅"]}
|
| 35 |
+
}
|
| 36 |
+
with open(config_dir / "interests.json", 'w', encoding='utf-8') as f:
|
| 37 |
+
json.dump(interests_data, f, ensure_ascii=False)
|
| 38 |
+
|
| 39 |
+
yield config_dir
|
| 40 |
+
|
| 41 |
+
def test_load_cities(self, temp_config_dir):
|
| 42 |
+
"""测试城市配置加载"""
|
| 43 |
+
loader = ConfigLoader(temp_config_dir)
|
| 44 |
+
assert "巴黎" in loader.cities
|
| 45 |
+
assert "paris" in loader.cities
|
| 46 |
+
assert loader.cities["巴黎"]["name"] == "巴黎"
|
| 47 |
+
|
| 48 |
+
def test_load_personas(self, temp_config_dir):
|
| 49 |
+
"""测试人格配置加载"""
|
| 50 |
+
loader = ConfigLoader(temp_config_dir)
|
| 51 |
+
assert "planner" in loader.personas
|
| 52 |
+
assert loader.personas["planner"]["name"] == "规划型"
|
| 53 |
+
|
| 54 |
+
def test_load_interests(self, temp_config_dir):
|
| 55 |
+
"""测试兴趣配置加载"""
|
| 56 |
+
loader = ConfigLoader(temp_config_dir)
|
| 57 |
+
assert "美食" in loader.interests
|
tests/test_travel_assistant.py
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tests/test_travel_assistant.py
|
| 2 |
+
import pytest
|
| 3 |
+
from unittest.mock import Mock, patch
|
| 4 |
+
from modules.travel_assistant import TravelAssistant
|
| 5 |
+
|
| 6 |
+
class TestTravelAssistant:
|
| 7 |
+
|
| 8 |
+
@pytest.fixture
|
| 9 |
+
def mock_travel_assistant(self):
|
| 10 |
+
"""创建模拟的旅游助手"""
|
| 11 |
+
with patch('modules.travel_assistant.ConfigLoader') as mock_config, \
|
| 12 |
+
patch('modules.travel_assistant.KnowledgeBase') as mock_kb, \
|
| 13 |
+
patch('modules.travel_assistant.AIModel') as mock_ai:
|
| 14 |
+
|
| 15 |
+
# 设置模拟对象
|
| 16 |
+
mock_config.return_value.cities = {"巴黎": {"name": "巴黎", "country": "法国"}}
|
| 17 |
+
mock_config.return_value.personas = {"planner": {"name": "规划型"}}
|
| 18 |
+
|
| 19 |
+
assistant = TravelAssistant()
|
| 20 |
+
yield assistant
|
| 21 |
+
|
| 22 |
+
def test_chat_basic_flow(self, mock_travel_assistant):
|
| 23 |
+
"""测试基本聊天流程"""
|
| 24 |
+
reply, session_id, status_info, history = mock_travel_assistant.chat(
|
| 25 |
+
message="我想去巴黎旅游",
|
| 26 |
+
session_id=None,
|
| 27 |
+
history=[]
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
assert isinstance(reply, str)
|
| 31 |
+
assert len(session_id) == 8 # UUID前8位
|
| 32 |
+
assert isinstance(status_info, str)
|
| 33 |
+
assert len(history) == 1
|
| 34 |
+
assert history[0][0] == "我想去巴黎旅游"
|
| 35 |
+
|
| 36 |
+
def test_session_persistence(self, mock_travel_assistant):
|
| 37 |
+
"""测试会话持久性"""
|
| 38 |
+
# 第一次对话
|
| 39 |
+
_, session_id, _, _ = mock_travel_assistant.chat(
|
| 40 |
+
message="我想去巴黎",
|
| 41 |
+
session_id=None,
|
| 42 |
+
history=[]
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# 第二次对话使用相同session_id
|
| 46 |
+
reply, same_session_id, _, _ = mock_travel_assistant.chat(
|
| 47 |
+
message="3天行程",
|
| 48 |
+
session_id=session_id,
|
| 49 |
+
history=[]
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
assert session_id == same_session_id
|