From c1cf808e68e5773da40b92c2cd686b74ecf12e4b Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Tue, 17 Feb 2026 14:34:45 +0330 Subject: [PATCH] Add initial project structure with Docker support, environment configurations, and basic application setup --- .dockerignore | 86 ++ .env.example | 22 + .gitignore | 162 ++ DOCKER_TESTING_README.md | 114 ++ Dockerfile | 42 + Jenkinsfile | 34 + PRODUCTION_READINESS_REPORT.md | 1534 +++++++++++++++++++ README.md | 117 ++ config/development.env | 27 + config/embeddings.yaml | 24 + config/models.yaml | 32 + config/production.env | 43 + config/rerankers.yaml | 11 + docker-compose.yml | 46 + docker/Dockerfile | 42 + docker/Dockerfile.dev | 28 + docker/docker-compose.dev.yml | 45 + docker/docker-compose.yml | 46 + docs/CULTURE_SYSTEM.md | 208 +++ docs/DYNAMIC_SYSTEM_PROMPT.md | 373 +++++ docs/EMBEDDING_MANAGEMENT.md | 616 ++++++++ docs/GUARDRAILS.md | 213 +++ docs/LANGFUSE_TRACING.md | 395 +++++ docs/LLM_MODEL_MANAGEMENT.md | 508 ++++++ docs/QDRANT_CONNECTION_TEST.md | 307 ++++ docs/RAG_IMPLEMENTATION_GUIDE.md | 406 +++++ docs/RERANKING_PIPELINE.md | 358 +++++ entrypoint.sh | 7 + langfuse/docker-compose.langfuse.yml | 32 + out.md | 14 + pytest.ini | 18 + requirements-dev.txt | 21 + requirements.txt | Bin 0 -> 7320 bytes runner.sh | 33 + scripts/ingest_excel.py | 127 ++ src/__init__.py | 0 src/agents/__init__.py | 0 src/agents/base_agent.py | 68 + src/agents/islamic_scholar_agent.py | 11 + src/agents/tracing_agent.py | 98 ++ src/api/__init__.py | 0 src/api/dependencies.py | 14 + src/api/routes.py | 18 + src/core/__init__.py | 0 src/core/config.py | 32 + src/core/culture.py | 55 + src/core/logging.py | 31 + src/core/settings.py | 32 + src/guardrails/__init__.py | 0 src/guardrails/limit.py | 60 + src/knowledge/__init__.py | 0 src/knowledge/embedding_factory.py | 67 + src/knowledge/manual_cultures.py | 46 + src/knowledge/rag_pipeline.py | 11 + src/knowledge/vector_store.py | 34 + src/main.py | 95 ++ src/models/__init__.py | 0 src/models/base_model.py | 18 + src/models/factory.py | 86 ++ src/utils/__init__.py | 0 src/utils/hooks.py | 44 + src/utils/load_settings.py | 76 + src/utils/reranker.py | 189 +++ src/utils/search_knowledge.py | 91 ++ src/utils/shared_context.py | 5 + tests/conftest.py | 31 + tests/integration/test_api.py | 61 + tests/integration/test_pipeline.py | 78 + tests/integration/test_qdrant_connection.py | 244 +++ tests/unit/test_agent.py | 47 + tests/unit/test_models.py | 86 ++ tests/unit/test_rag.py | 73 + 72 files changed, 7892 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DOCKER_TESTING_README.md create mode 100644 Dockerfile create mode 100644 Jenkinsfile create mode 100644 PRODUCTION_READINESS_REPORT.md create mode 100644 config/development.env create mode 100644 config/embeddings.yaml create mode 100644 config/models.yaml create mode 100644 config/production.env create mode 100644 config/rerankers.yaml create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile create mode 100644 docker/Dockerfile.dev create mode 100644 docker/docker-compose.dev.yml create mode 100644 docker/docker-compose.yml create mode 100644 docs/CULTURE_SYSTEM.md create mode 100644 docs/DYNAMIC_SYSTEM_PROMPT.md create mode 100644 docs/EMBEDDING_MANAGEMENT.md create mode 100644 docs/GUARDRAILS.md create mode 100644 docs/LANGFUSE_TRACING.md create mode 100644 docs/LLM_MODEL_MANAGEMENT.md create mode 100644 docs/QDRANT_CONNECTION_TEST.md create mode 100644 docs/RAG_IMPLEMENTATION_GUIDE.md create mode 100644 docs/RERANKING_PIPELINE.md create mode 100644 entrypoint.sh create mode 100644 langfuse/docker-compose.langfuse.yml create mode 100644 out.md create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 runner.sh create mode 100644 scripts/ingest_excel.py create mode 100644 src/__init__.py create mode 100644 src/agents/__init__.py create mode 100644 src/agents/base_agent.py create mode 100644 src/agents/islamic_scholar_agent.py create mode 100644 src/agents/tracing_agent.py create mode 100644 src/api/__init__.py create mode 100644 src/api/dependencies.py create mode 100644 src/api/routes.py create mode 100644 src/core/__init__.py create mode 100644 src/core/config.py create mode 100644 src/core/culture.py create mode 100644 src/core/logging.py create mode 100644 src/core/settings.py create mode 100644 src/guardrails/__init__.py create mode 100644 src/guardrails/limit.py create mode 100644 src/knowledge/__init__.py create mode 100644 src/knowledge/embedding_factory.py create mode 100644 src/knowledge/manual_cultures.py create mode 100644 src/knowledge/rag_pipeline.py create mode 100644 src/knowledge/vector_store.py create mode 100644 src/main.py create mode 100644 src/models/__init__.py create mode 100644 src/models/base_model.py create mode 100644 src/models/factory.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/hooks.py create mode 100644 src/utils/load_settings.py create mode 100644 src/utils/reranker.py create mode 100644 src/utils/search_knowledge.py create mode 100644 src/utils/shared_context.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/test_api.py create mode 100644 tests/integration/test_pipeline.py create mode 100644 tests/integration/test_qdrant_connection.py create mode 100644 tests/unit/test_agent.py create mode 100644 tests/unit/test_models.py create mode 100644 tests/unit/test_rag.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d1fbbf8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,86 @@ +# Git +.git +.gitignore +README.md +*.md + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.venv/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp + +# Environment files +.env +.env.local +.env.production +.env.staging +.env.prod + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Documentation +docs/ + +# CI/CD +Jenkinsfile +.jenkins/ + +# Development tools +.mypy_cache/ +.dmypy.json +dmypy.json + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Data files (if they should be mounted as volumes instead) +# Uncomment if you want to exclude data files: +# *.xlsx +# *.csv + +# Node modules (if any) +node_modules/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e8cf0fc --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Environment Variables Template +# Copy this file to .env and fill in your actual values + +# Application Settings +DEBUG_MODE=true +HOST=0.0.0.0 +PORT=8081 + +# Model Settings (choose one) +MODEL_ID=deepseek-ai/deepseek-v3.1 +API_URL=https://gpt.nwhco.ir +MEGALLM_API_KEY=your_megallm_api_key_here +# OPENROUTER_API_KEY=your_openrouter_api_key_here + +# Database Settings +DATABASE_URL=postgresql+psycopg://ai:ai@localhost:5432/ai +QDRANT_URL=http://localhost:6333 + +# Vector DB Settings +COLLECTION_NAME=dovoodi_collection +EMBEDDER_MODEL=all-MiniLM-L6-v2 +EMBEDDER_DIMENSIONS=384 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9119059 --- /dev/null +++ b/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.env.local +.env.production +.env.staging +.env.prod +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Docker files (developers should use docker/ folder instead) +# Dockerfile +# docker-compose.yml +# .dockerignore + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +*.log +logs/ + +# Temporary files +*.tmp +*.temp + +# Database files (local development) +*.db +*.sqlite +*.sqlite3 + +# Old app structure (no longer needed with new src/ structure) +app/ +langfuse_test/ + +# Data files that might contain sensitive information or be large +# Uncomment if needed: +# dovodi_articles.xlsx +# hadiths_data.xlsx + +# Node modules (if any frontend components) +node_modules/ +meow.txt \ No newline at end of file diff --git a/DOCKER_TESTING_README.md b/DOCKER_TESTING_README.md new file mode 100644 index 0000000..ee97a30 --- /dev/null +++ b/DOCKER_TESTING_README.md @@ -0,0 +1,114 @@ +# Docker Testing Guide + +This guide shows how to run the various test files in Docker containers for debugging the Islamic Scholar RAG application. + +## Available Test Services + +### 1. Connection Test +Tests basic OpenRouter API connectivity +```bash +docker-compose run --rm test-connection +``` + +### 2. OpenRouter in App Environment +Tests OpenRouter connection with the same setup as app.py (including Qdrant) +```bash +docker-compose run --rm test-openrouter +``` + +### 3. AgentOS Simple Test +Tests AgentOS without RAG pipeline to isolate AgentOS issues +```bash +docker-compose run --rm test-agentos +``` + +### 4. RAG Agent Test +Tests the custom IslamicScholarAgent with RAG pipeline +```bash +docker-compose run --rm test-rag +``` + +## Quick Test Commands + +### Run All Tests Sequentially +```bash +# Test 1: Basic connection +docker-compose run --rm test-connection + +# Test 2: OpenRouter with app environment +docker-compose run --rm test-openrouter + +# Test 3: AgentOS without RAG +docker-compose run --rm test-agentos + +# Test 4: Full RAG pipeline +docker-compose run --rm test-rag +``` + +### Run Tests in Running Container +If you have the main app container running: +```bash +# Enter the container +docker exec -it islamic-scholar-agent bash + +# Run tests inside container +python app/connection_test.py +python app/test_openrouter_in_app.py +python app/test_rag_agent.py +# Note: test_agentos_simple.py needs a different port +``` + +### Using the Test Runner +```bash +# In container +python run_test.py connection_test +python run_test.py openrouter_in_app +python run_test.py rag_agent +python run_test.py agentos_simple +``` + +## Debugging Network Issues + +### 1. Check Container Logs +```bash +docker-compose logs test-connection +docker-compose logs test-openrouter +``` + +### 2. Test Network Connectivity +```bash +# Test internet access from container +docker run --rm --network imam-javad_backend_imam-javad python:3.9 curl -I https://openrouter.ai/api/v1/models + +# Test DNS resolution +docker exec islamic-scholar-agent nslookup openrouter.ai +``` + +### 3. Inspect Network +```bash +docker network inspect imam-javad_backend_imam-javad +``` + +## Common Issues + +### Network Connection Lost +- Check if container has internet access +- Verify DNS settings (8.8.8.8 and 1.1.1.1 are configured) +- Test with different OpenRouter endpoints + +### Qdrant Connection Issues +- Ensure Qdrant container is running: `docker-compose ps` +- Check Qdrant logs: `docker-compose logs qdrant` + +### Database Connection Issues +- PostgreSQL connection failures are now handled gracefully +- App will run without database but some AgentOS features may not work + +## Expected Results + +- ✅ **Connection Test**: Should always work (like local testing) +- ✅ **OpenRouter in App**: May fail if Qdrant interferes with network +- ✅ **AgentOS Simple**: May fail if AgentOS has network issues +- ✅ **RAG Agent**: May fail if RAG pipeline has issues + +Use these tests to isolate where exactly the network issue occurs! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d47b899 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.9 + +# Environment Variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app +# Setting pip timeout globally via environment variable is often more reliable +ENV PIP_DEFAULT_TIMEOUT=1000 + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +# Adding --retries helps if the connection drops during the 800MB download +RUN pip install --upgrade pip && \ + pip install --no-cache-dir --timeout=1000 --retries 10 -r requirements.txt + +# Copy code +COPY src/ ./src/ + +COPY config/ ./config/ + +COPY scripts/ ./scripts/ + +COPY data/ ./data/ + +COPY tests/ ./tests/ + +# Port FastAPI +EXPOSE 8081 + +# Copy the entrypoint script +COPY entrypoint.sh /app/entrypoint.sh + +# Make it executable (Crucial!) +RUN chmod +x /app/entrypoint.sh + +# Set the Entrypoint +ENTRYPOINT ["/app/entrypoint.sh"] + +# Default command - can be overridden +CMD ["python", "src/main.py"] \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..1d08d50 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,34 @@ +pipeline { + environment { + develop_server_ip = '' + develop_server_name = '' + production_server_ip = "88.99.212.243" + production_server_name = "newhorizon_germany_001_server" + project_path = "/projects/dovodi/agent" + version = "master" + gitBranch = "origin/master" + } + agent any + stages { + stage('deploy'){ + steps{ + script{ + if(gitBranch=="origin/master"){ + withCredentials([usernamePassword(credentialsId: production_server_name, usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) { + sh 'sshpass -p $PASSWORD ssh -p 1782 $USERNAME@$production_server_ip -o StrictHostKeyChecking=no "cd $project_path && ./runner.sh"' + + def lastCommit = sh(script: 'git log -1 --pretty=format:"%h - %s (%an)"', returnStdout: true).trim() + sh """ + curl -F chat_id=1457670318 \ + -F message_thread_id=6 \ + -F document=@/var/jenkins_home/jobs/${env.JOB_NAME}/builds/${env.BUILD_NUMBER}/log \ + -F caption='Project name: #${env.JOB_NAME} \nBuild status is ${currentBuild.currentResult} \nBuild url: ${BUILD_URL} \nLast Commit: ${lastCommit}' \ + https://api.telegram.org/bot7207581748:AAFeymryw7S44D86LYfWqYK-tSNeV3TOwBs/sendDocument + """ + } + } + } + } + } + } +} diff --git a/PRODUCTION_READINESS_REPORT.md b/PRODUCTION_READINESS_REPORT.md new file mode 100644 index 0000000..4491b6e --- /dev/null +++ b/PRODUCTION_READINESS_REPORT.md @@ -0,0 +1,1534 @@ +# گزارش بررسی آمادگی پروداکشن - Islamic Scholar Agent + +## خلاصه اجرایی + +این پروژه یک چت‌بات RAG (Retrieval-Augmented Generation) برای پاسخ به سوالات اسلامی است که با استفاده از Agno Framework، FastAPI و Vector Databases پیاده‌سازی شده است. با وجود عملکرد اولیه، این پروژه **فاقد استانداردهای لازم برای محیط پروداکشن** است و بیشتر شبیه یک پروژه تمرینی/آزمایشی می‌باشد. + +### امتیاز کلی آمادگی پروداکشن: **3/10** + +--- + +## 🔴 مشکلات بحرانی (Critical Issues) + +### 1. ساختار پروژه نامنظم و غیرحرفه‌ای + +#### مشکل فعلی: +``` +agent/ +├── app/ # همه چیز در یک فولدر! +│ ├── app.py # Production code +│ ├── test_*.py # Test files mixed with production +│ ├── scholar_agent.py # Agent implementation +│ ├── connection_test.py # Test file +│ ├── README_connection_test.md # Documentation mixed in +│ └── ... +├── hadiths_data.xlsx # Data files in root +├── dovodi_articles.xlsx # Data files in root +└── requirements.txt +``` + +#### ساختار پیشنهادی برای پروداکشن: +``` +agent/ +├── src/ # Production source code +│ ├── agents/ # Agent implementations +│ │ ├── __init__.py +│ │ ├── base_agent.py +│ │ └── islamic_scholar_agent.py +│ ├── knowledge/ # Knowledge base & RAG pipeline +│ │ ├── __init__.py +│ │ ├── embeddings.py +│ │ ├── vector_store.py +│ │ └── rag_pipeline.py +│ ├── models/ # LLM integrations +│ │ ├── __init__.py +│ │ ├── base_model.py +│ │ ├── openrouter.py +│ │ └── openai.py +│ ├── api/ # FastAPI routes +│ │ ├── __init__.py +│ │ ├── routes.py +│ │ └── dependencies.py +│ ├── core/ # Core configurations +│ │ ├── __init__.py +│ │ ├── config.py +│ │ ├── settings.py +│ │ └── logging.py +│ ├── utils/ # Utility functions +│ │ ├── __init__.py +│ │ └── helpers.py +│ └── main.py # Application entry point +├── data/ # Data files +│ ├── raw/ # Original data files +│ │ ├── hadiths_data.xlsx +│ │ └── dovodi_articles.xlsx +│ ├── processed/ # Processed/cleaned data +│ └── embeddings/ # Pre-computed embeddings (optional) +├── scripts/ # Utility scripts +│ ├── ingest_data.py # Data ingestion pipeline +│ ├── setup_vectordb.py # Vector DB initialization +│ └── health_check.py # Health check script +├── tests/ # All test files +│ ├── unit/ +│ │ ├── test_agent.py +│ │ ├── test_rag.py +│ │ └── test_models.py +│ ├── integration/ +│ │ ├── test_api.py +│ │ └── test_pipeline.py +│ └── conftest.py # Pytest configuration +├── docs/ # Documentation +│ ├── README.md # Main documentation +│ ├── API.md # API documentation +│ ├── DEPLOYMENT.md # Deployment guide +│ └── ARCHITECTURE.md # Architecture overview +├── config/ # Configuration files +│ ├── development.env +│ ├── production.env +│ └── models.yaml # LLM model configurations +├── .github/ # GitHub workflows (if using GitHub) +│ └── workflows/ +│ └── ci.yml +├── docker/ # Docker files +│ ├── Dockerfile.dev +│ ├── Dockerfile.prod +│ └── docker-compose.dev.yml +├── .env.example # Example environment file +├── .gitignore +├── requirements.txt # Base requirements +├── requirements-dev.txt # Development requirements +├── pytest.ini # Pytest configuration +├── docker-compose.yml # Production compose +└── README.md # Project overview +``` + +**تاثیر:** ⭐⭐⭐⭐⭐ (بسیار بحرانی) +**تلاش رفع:** 2-3 روز برای بازسازی کامل + +--- + +### 2. عدم وجود لایه انتزاع برای مدل‌های LLM + +#### مشکل: +```python +# در app.py - hardcoded +agent = Agent( + model=OpenRouter(id="deepseek/deepseek-r1-0528:free"), # ❌ Hardcoded + ... +) + +# در scholar_agent.py - مدل متفاوت +scholar = Agent( + model=OpenAIChat(id="gpt-4o"), # ❌ مدل دیگر، هیچ consistency نیست + ... +) + +# در scholar_rag.py - باز مدل دیگر +agent = Agent( + model=OpenRouter(id="xiaomi/mimo-v2-flash:free"), # ❌ سومین مدل! + ... +) +``` + +#### راه‌حل پیشنهادی: + +**فایل: `config/models.yaml`** +```yaml +models: + default: deepseek_r1 + + providers: + openrouter: + api_key: ${OPENROUTER_API_KEY} + base_url: https://openrouter.ai/api/v1 + models: + deepseek_r1: + id: "deepseek/deepseek-r1-0528:free" + temperature: 0.7 + max_tokens: 4096 + supports_streaming: true + supports_tools: false + mimo_v2: + id: "xiaomi/mimo-v2-flash:free" + temperature: 0.7 + max_tokens: 2048 + supports_streaming: true + + openai: + api_key: ${OPENAI_API_KEY} + base_url: https://api.openai.com/v1 + models: + gpt4: + id: "gpt-4o" + temperature: 0.7 + max_tokens: 4096 + supports_streaming: true + supports_tools: true + gpt4_mini: + id: "gpt-4o-mini" + temperature: 0.7 + max_tokens: 4096 +``` + +**فایل: `src/models/base_model.py`** +```python +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional +from pydantic import BaseModel + +class LLMConfig(BaseModel): + """Configuration for LLM models""" + id: str + temperature: float = 0.7 + max_tokens: int = 4096 + supports_streaming: bool = True + supports_tools: bool = False + extra_params: Dict[str, Any] = {} + +class BaseLLMProvider(ABC): + """Abstract base class for LLM providers""" + + def __init__(self, api_key: str, base_url: Optional[str] = None): + self.api_key = api_key + self.base_url = base_url + + @abstractmethod + def get_model(self, config: LLMConfig): + """Return configured model instance""" + pass +``` + +**فایل: `src/models/factory.py`** +```python +from typing import Optional +import yaml +from pathlib import Path +from agno.models.openrouter import OpenRouter +from agno.models.openai import OpenAIChat +from .base_model import LLMConfig, BaseLLMProvider + +class ModelFactory: + """Factory for creating LLM instances from configuration""" + + def __init__(self, config_path: str = "config/models.yaml"): + with open(config_path) as f: + self.config = yaml.safe_load(f) + self._providers = {} + + def get_model(self, model_name: Optional[str] = None): + """ + Get model instance by name. + + Args: + model_name: Name of the model (e.g., 'deepseek_r1', 'gpt4') + If None, uses default from config + + Returns: + Configured model instance + + Example: + >>> factory = ModelFactory() + >>> model = factory.get_model('deepseek_r1') + >>> # Switch to GPT-4 with single line change: + >>> model = factory.get_model('gpt4') + """ + if model_name is None: + model_name = self.config['models']['default'] + + # Find which provider has this model + for provider_name, provider_config in self.config['models']['providers'].items(): + if model_name in provider_config['models']: + model_config = provider_config['models'][model_name] + + if provider_name == 'openrouter': + return OpenRouter( + id=model_config['id'], + api_key=provider_config['api_key'], + temperature=model_config.get('temperature', 0.7), + max_tokens=model_config.get('max_tokens', 4096), + ) + + elif provider_name == 'openai': + return OpenAIChat( + id=model_config['id'], + api_key=provider_config['api_key'], + temperature=model_config.get('temperature', 0.7), + max_tokens=model_config.get('max_tokens', 4096), + ) + + raise ValueError(f"Model '{model_name}' not found in configuration") + + def list_available_models(self): + """List all available models""" + models = [] + for provider_name, provider_config in self.config['models']['providers'].items(): + for model_name in provider_config['models'].keys(): + models.append({ + 'name': model_name, + 'provider': provider_name, + 'id': provider_config['models'][model_name]['id'] + }) + return models + +# Usage in application: +# model = ModelFactory().get_model() # Uses default +# model = ModelFactory().get_model('gpt4') # Uses GPT-4 +``` + +**استفاده در کد:** +```python +# قبل (❌ Bad): +from agno.models.openrouter import OpenRouter +agent = Agent( + model=OpenRouter(id="deepseek/deepseek-r1-0528:free"), + ... +) + +# بعد (✅ Good): +from src.models.factory import ModelFactory +model_factory = ModelFactory() +agent = Agent( + model=model_factory.get_model(), # Uses default from config + # OR + model=model_factory.get_model('gpt4'), # Easy switching! + ... +) +``` + +**مزایا:** +- ✅ تغییر مدل با یک خط کد +- ✅ مدیریت متمرکز تنظیمات +- ✅ قابلیت A/B testing ساده +- ✅ مدیریت آسان API keys +- ✅ امکان افزودن provider جدید بدون تغییر کد + +**تاثیر:** ⭐⭐⭐⭐⭐ (بحرانی) +**تلاش رفع:** 1 روز + +--- + +### 3. پایپلاین Data Ingestion نامنظم و غیرقابل اطمینان + +#### مشکلات موجود: + +```python +# در ingest_excel.py - ❌ Problems: +def ingest_hadiths(file_path: str): + df = pd.read_excel(file_path) # ❌ No validation + count = 0 + for _, row in df.iterrows(): # ❌ No error handling per row + content = f"HADITH TYPE: HADITH\n..." # ❌ Hardcoded format + knowledge_base.add_content(text_content=content) # ❌ No retry logic + count += 1 + print(f"✅ {count} Hadiths") # ❌ Only print, no logging + +# ❌ Hardcoded paths +qdrant_url = "http://localhost:6333" + +# ❌ No progress tracking +# ❌ No duplicate detection +# ❌ No data validation +# ❌ Can't resume if failed midway +``` + +#### راه‌حل پیشنهادی: + +**فایل: `src/core/config.py`** +```python +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + """Application settings loaded from environment""" + + # Database + DATABASE_URL: str + + # Vector Database + VECTOR_DB_TYPE: str = "qdrant" # or "pgvector" + QDRANT_URL: str = "http://qdrant:6333" + QDRANT_COLLECTION: str = "islamic_knowledge" + PGVECTOR_TABLE: str = "islamic_knowledge" + + # Embeddings + EMBEDDING_MODEL: str = "all-MiniLM-L6-v2" + EMBEDDING_BATCH_SIZE: int = 100 + EMBEDDING_DIMENSIONS: int = 384 + + # LLM + LLM_MODEL_NAME: str = "deepseek_r1" + + # Ingestion + DATA_DIR: str = "data/raw" + PROCESSED_DATA_DIR: str = "data/processed" + BATCH_SIZE: int = 50 + MAX_RETRIES: int = 3 + + # Monitoring + LANGFUSE_PUBLIC_KEY: Optional[str] = None + LANGFUSE_SECRET_KEY: Optional[str] = None + LANGFUSE_HOST: str = "https://cloud.langfuse.com" + + class Config: + env_file = ".env" + case_sensitive = True + +settings = Settings() +``` + +**فایل: `scripts/ingest_data.py`** +```python +import logging +from pathlib import Path +from typing import List, Dict, Any +import pandas as pd +from tqdm import tqdm +from pydantic import BaseModel, field_validator +from src.core.config import settings +from src.knowledge.vector_store import get_vector_store + +# Setup logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('logs/ingestion.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +class HadithRecord(BaseModel): + """Validated Hadith record""" + title: str + arabic_text: str + translation: str + source_info: str + + @field_validator('title', 'arabic_text', 'translation') + @classmethod + def not_empty(cls, v): + if not v or not v.strip(): + raise ValueError('Field cannot be empty') + return v.strip() + +class ArticleRecord(BaseModel): + """Validated Article record""" + title: str + author: str + content: str + url: str + + @field_validator('title', 'content') + @classmethod + def not_empty(cls, v): + if not v or not v.strip(): + raise ValueError('Field cannot be empty') + return v.strip() + +class DataIngestionPipeline: + """Production-ready data ingestion pipeline""" + + def __init__(self): + self.vector_store = get_vector_store() + self.processed_ids = self._load_processed_ids() + self.stats = { + 'total': 0, + 'success': 0, + 'failed': 0, + 'skipped': 0, + 'errors': [] + } + + def _load_processed_ids(self) -> set: + """Load IDs of already processed documents (for resumability)""" + processed_file = Path(settings.PROCESSED_DATA_DIR) / "processed_ids.txt" + if processed_file.exists(): + with open(processed_file) as f: + return set(line.strip() for line in f) + return set() + + def _save_processed_id(self, doc_id: str): + """Save processed document ID""" + processed_file = Path(settings.PROCESSED_DATA_DIR) / "processed_ids.txt" + processed_file.parent.mkdir(parents=True, exist_ok=True) + with open(processed_file, 'a') as f: + f.write(f"{doc_id}\n") + + def ingest_hadiths(self, file_path: str): + """ + Ingest Hadiths with validation, error handling, and progress tracking + + Features: + - ✅ Data validation with Pydantic + - ✅ Row-level error handling + - ✅ Progress tracking + - ✅ Duplicate detection + - ✅ Resumable (tracks processed IDs) + - ✅ Comprehensive logging + - ✅ Batch processing for efficiency + """ + logger.info(f"Starting Hadith ingestion from {file_path}") + + try: + df = pd.read_excel(file_path) + logger.info(f"Loaded {len(df)} records from Excel") + except Exception as e: + logger.error(f"Failed to load Excel file: {e}") + raise + + # Validate required columns + required_cols = {'Title', 'Arabic Text', 'Translation', 'Source Info'} + missing_cols = required_cols - set(df.columns) + if missing_cols: + raise ValueError(f"Missing required columns: {missing_cols}") + + batch = [] + for idx, row in tqdm(df.iterrows(), total=len(df), desc="Processing Hadiths"): + self.stats['total'] += 1 + + # Generate unique ID + doc_id = f"hadith_{idx}" + + # Skip if already processed + if doc_id in self.processed_ids: + logger.debug(f"Skipping already processed: {doc_id}") + self.stats['skipped'] += 1 + continue + + try: + # Validate data + hadith = HadithRecord( + title=row.get('Title', ''), + arabic_text=row.get('Arabic Text', ''), + translation=row.get('Translation', ''), + source_info=row.get('Source Info', '') + ) + + # Format content + content = ( + f"TYPE: HADITH\n" + f"TITLE: {hadith.title}\n" + f"ARABIC: {hadith.arabic_text}\n" + f"TRANSLATION: {hadith.translation}\n" + f"SOURCE: {hadith.source_info}" + ) + + # Add to batch + batch.append({ + 'id': doc_id, + 'content': content, + 'metadata': { + 'type': 'hadith', + 'title': hadith.title, + 'source': hadith.source_info + } + }) + + # Process batch when full + if len(batch) >= settings.BATCH_SIZE: + self._process_batch(batch) + batch = [] + + except Exception as e: + logger.error(f"Error processing row {idx}: {e}") + self.stats['failed'] += 1 + self.stats['errors'].append({ + 'row': idx, + 'error': str(e) + }) + continue + + # Process remaining batch + if batch: + self._process_batch(batch) + + logger.info(f"Hadith ingestion completed: {self.stats}") + + def _process_batch(self, batch: List[Dict[str, Any]]): + """Process a batch of documents with retry logic""" + for attempt in range(settings.MAX_RETRIES): + try: + # Add to vector store + for doc in batch: + self.vector_store.add_content( + text_content=doc['content'], + metadata=doc['metadata'] + ) + self._save_processed_id(doc['id']) + self.processed_ids.add(doc['id']) + self.stats['success'] += 1 + + logger.info(f"Successfully processed batch of {len(batch)} documents") + break + + except Exception as e: + if attempt < settings.MAX_RETRIES - 1: + logger.warning(f"Batch processing failed (attempt {attempt + 1}): {e}") + continue + else: + logger.error(f"Batch processing failed after {settings.MAX_RETRIES} attempts: {e}") + for doc in batch: + self.stats['failed'] += 1 + self.stats['errors'].append({ + 'doc_id': doc['id'], + 'error': str(e) + }) + + def generate_report(self) -> str: + """Generate ingestion summary report""" + report = f""" +╔══════════════════════════════════════════╗ +║ Data Ingestion Summary Report ║ +╚══════════════════════════════════════════╝ + +Total Records: {self.stats['total']} +✅ Successfully Processed: {self.stats['success']} +⏭️ Skipped (Already Processed): {self.stats['skipped']} +❌ Failed: {self.stats['failed']} + +Success Rate: {(self.stats['success'] / self.stats['total'] * 100):.2f}% +""" + + if self.stats['errors']: + report += "\n❌ Errors:\n" + for error in self.stats['errors'][:10]: # Show first 10 + report += f" - {error}\n" + if len(self.stats['errors']) > 10: + report += f" ... and {len(self.stats['errors']) - 10} more errors\n" + + return report + +# CLI interface +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description='Ingest data into vector database') + parser.add_argument('--hadiths', type=str, help='Path to hadiths Excel file') + parser.add_argument('--articles', type=str, help='Path to articles Excel file') + parser.add_argument('--reset', action='store_true', help='Reset processed IDs (re-ingest all)') + + args = parser.parse_args() + + # Reset if requested + if args.reset: + processed_file = Path(settings.PROCESSED_DATA_DIR) / "processed_ids.txt" + if processed_file.exists(): + processed_file.unlink() + logger.info("Reset processed IDs") + + pipeline = DataIngestionPipeline() + + if args.hadiths: + pipeline.ingest_hadiths(args.hadiths) + + # Similar implementation for articles... + + print(pipeline.generate_report()) +``` + +**استفاده:** +```bash +# Basic ingestion +python scripts/ingest_data.py --hadiths data/raw/hadiths_data.xlsx + +# Reset and re-ingest all +python scripts/ingest_data.py --hadiths data/raw/hadiths_data.xlsx --reset + +# Ingest both +python scripts/ingest_data.py \ + --hadiths data/raw/hadiths_data.xlsx \ + --articles data/raw/dovodi_articles.xlsx +``` + +**تاثیر:** ⭐⭐⭐⭐⭐ (بحرانی) +**تلاش رفع:** 2-3 روز + +--- + +### 4. عدم وجود Observability و Monitoring (Langfuse) + +#### مشکل: +- هیچ logging framework ندارد +- هیچ tracing ندارد +- نمی‌توان performance را track کرد +- دیباگ مشکلات بسیار سخت است +- نمی‌توان user feedback را جمع‌آوری کرد + +#### راه‌حل: ادغام Langfuse + +**فایل: `src/core/monitoring.py`** +```python +from typing import Optional, Dict, Any +from functools import wraps +import time +from langfuse import Langfuse +from langfuse.decorators import observe, langfuse_context +from src.core.config import settings +import logging + +logger = logging.getLogger(__name__) + +# Initialize Langfuse +langfuse = None +if settings.LANGFUSE_PUBLIC_KEY and settings.LANGFUSE_SECRET_KEY: + langfuse = Langfuse( + public_key=settings.LANGFUSE_PUBLIC_KEY, + secret_key=settings.LANGFUSE_SECRET_KEY, + host=settings.LANGFUSE_HOST + ) + logger.info("✅ Langfuse monitoring enabled") +else: + logger.warning("⚠️ Langfuse not configured - monitoring disabled") + +def trace_agent_run(func): + """Decorator to trace agent runs with Langfuse""" + @wraps(func) + def wrapper(*args, **kwargs): + if not langfuse: + return func(*args, **kwargs) + + # Create trace + trace = langfuse.trace( + name=f"agent_run_{func.__name__}", + metadata={ + "function": func.__name__, + "timestamp": time.time() + } + ) + + try: + start_time = time.time() + result = func(*args, **kwargs) + duration = time.time() - start_time + + # Log success + trace.update( + output=str(result)[:1000], # Limit output size + metadata={ + "duration_seconds": duration, + "status": "success" + } + ) + + return result + + except Exception as e: + # Log error + trace.update( + metadata={ + "status": "error", + "error": str(e) + } + ) + raise + + return wrapper + +class RAGMonitor: + """Monitor RAG pipeline with Langfuse""" + + @staticmethod + @observe(name="rag_search") + def track_search(query: str, results: list, duration: float): + """Track vector search operation""" + langfuse_context.update_current_observation( + input=query, + output={ + "num_results": len(results), + "results": [r.content[:100] for r in results] # Preview only + }, + metadata={ + "duration_seconds": duration, + "search_type": "vector_similarity" + } + ) + + @staticmethod + @observe(name="rag_generation") + def track_generation( + prompt: str, + response: str, + model: str, + duration: float, + tokens_used: Optional[int] = None + ): + """Track LLM generation""" + langfuse_context.update_current_observation( + input=prompt[:500], # Limit size + output=response, + model=model, + metadata={ + "duration_seconds": duration, + "tokens_used": tokens_used + } + ) + + @staticmethod + @observe(name="rag_pipeline") + def track_full_pipeline( + user_question: str, + context: str, + final_answer: str, + metadata: Dict[str, Any] + ): + """Track complete RAG pipeline""" + langfuse_context.update_current_observation( + input=user_question, + output=final_answer, + metadata={ + **metadata, + "context_length": len(context), + "answer_length": len(final_answer) + } + ) + +# Usage in RAG pipeline: +@observe(name="build_rag_prompt") +def build_rag_prompt(user_question: str) -> str: + """RAG pipeline with Langfuse tracking""" + import time + + # Search phase + search_start = time.time() + relevant_docs = knowledge_base.search(query=user_question, max_results=3) + search_duration = time.time() - search_start + + RAGMonitor.track_search( + query=user_question, + results=relevant_docs, + duration=search_duration + ) + + # Build context + context_str = "\n\n".join([doc.content for doc in relevant_docs]) + + # Build final prompt + final_prompt = f"Context:\n{context_str}\n\nQuestion: {user_question}" + + return final_prompt +``` + +**استفاده در Agent:** +```python +from src.core.monitoring import trace_agent_run, RAGMonitor, observe + +class IslamicScholarAgent(Agent): + + @observe(name="agent_run") + def run(self, message, **kwargs): + """Override run with monitoring""" + rag_prompt = build_rag_prompt(message) + + # Track generation + import time + gen_start = time.time() + result = super().run(rag_prompt, **kwargs) + gen_duration = time.time() - gen_start + + RAGMonitor.track_generation( + prompt=rag_prompt, + response=result.content, + model=self.model.id, + duration=gen_duration + ) + + return result +``` + +**Dashboard در Langfuse:** +- 📊 تعداد queries در روز/هفته/ماه +- ⏱️ متوسط response time +- 💰 Token usage و هزینه‌ها +- 🔍 کیفیت پاسخ‌ها (با user feedback) +- 🐛 Error tracking +- 📈 Performance trends + +**تاثیر:** ⭐⭐⭐⭐⭐ (بحرانی برای پروداکشن) +**تلاش رفع:** 1-2 روز + +--- + +## 🟡 مشکلات مهم (Major Issues) + +### 5. کد تکراری و غیراستاندارد + +#### مثال‌های کد تکراری: + +```python +# فایل scholar_rag_pipelined.py - خطوط 1-73 و 74-146 دقیقاً یکسان! +# این کل کد را دوبار copy-paste کرده + +# همچنین: +# - app.py و app_local.py - کدهای مشابه با تفاوت‌های جزئی +# - ingest_knowledge.py و ingest_excel.py - منطق مشابه +# - چندین فایل test که هیچ consistency ندارند +``` + +**راه‌حل:** +- حذف فایل‌های تکراری +- استفاده از inheritance و composition +- ایجاد base classes +- DRY principle + +**تاثیر:** ⭐⭐⭐ +**تلاش رفع:** 1 روز + +--- + +### 6. مدیریت Configuration ضعیف + +#### مشکلات: +```python +# Hardcoded در جاهای مختلف: +db_url = "postgresql+psycopg://ai:ai@localhost:5532/ai" # ❌ +qdrant_url = "http://localhost:6333" # ❌ +qdrant_url = "http://127.0.0.1:6333" # ❌ مقدار متفاوت! +qdrant_url = os.getenv("QDRANT_URL", "http://qdrant:6333") # ❌ سومین مقدار! +``` + +**راه‌حل:** +- استفاده از Pydantic Settings +- فایل‌های .env مجزا برای هر environment +- validation و type checking +- مستندسازی همه environment variables + +**تاثیر:** ⭐⭐⭐⭐ +**تلاش رفع:** 1 روز + +--- + +### 7. عدم وجود Test Framework مناسب + +#### مشکل فعلی: +```python +# test files are just scripts: +if __name__ == "__main__": + test_agent_rag() # ❌ No assertions + # ❌ No test discovery + # ❌ No fixtures + # ❌ No coverage reports +``` + +**راه‌حل: استفاده از pytest** + +**فایل: `tests/conftest.py`** +```python +import pytest +from src.core.config import settings +from src.models.factory import ModelFactory +from src.knowledge.vector_store import get_vector_store + +@pytest.fixture +def model_factory(): + """Provide model factory for tests""" + return ModelFactory(config_path="config/models.test.yaml") + +@pytest.fixture +def vector_store(): + """Provide test vector store""" + # Use in-memory or test database + return get_vector_store(collection_name="test_collection") + +@pytest.fixture +def sample_hadith(): + """Sample hadith for testing""" + return { + 'title': 'Test Hadith', + 'arabic_text': 'نص عربي', + 'translation': 'English translation', + 'source_info': 'Test Source' + } +``` + +**فایل: `tests/unit/test_rag.py`** +```python +import pytest +from src.knowledge.rag_pipeline import build_rag_prompt + +def test_build_rag_prompt_with_results(vector_store, sample_hadith): + """Test RAG prompt building when results are found""" + # Setup + vector_store.add_content(sample_hadith) + + # Execute + prompt = build_rag_prompt("What is this hadith about?") + + # Assert + assert "Context:" in prompt + assert sample_hadith['translation'] in prompt + assert "Question:" in prompt + +def test_build_rag_prompt_no_results(vector_store): + """Test RAG prompt building when no results found""" + prompt = build_rag_prompt("Something not in database") + + assert "No information found" in prompt + +@pytest.mark.parametrize("query,expected_in_response", [ + ("prayer", "salah"), + ("fasting", "ramadan"), + ("charity", "zakat"), +]) +def test_rag_responses(query, expected_in_response, agent): + """Test various query types""" + response = agent.run(query) + assert expected_in_response.lower() in response.content.lower() +``` + +**فایل: `pytest.ini`** +```ini +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --cov=src + --cov-report=html + --cov-report=term-missing +markers = + unit: Unit tests + integration: Integration tests + slow: Slow tests +``` + +**اجرا:** +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=src --cov-report=html + +# Run specific test file +pytest tests/unit/test_rag.py + +# Run tests matching pattern +pytest -k "test_rag" + +# Run only unit tests +pytest -m unit +``` + +**تاثیر:** ⭐⭐⭐⭐ +**تلاش رفع:** 2 روز + +--- + +### 8. Docker Configuration برای Production مناسب نیست + +#### مشکلات: + +```dockerfile +# Dockerfile - Current issues: +FROM python:3.9 # ❌ Not pinned version, security risk + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt # ❌ No layer caching optimization + +COPY app/ ./app/ # ❌ Copies everything including tests + +EXPOSE 7777 +CMD ["python", "app/test_agentos_simple.py"] # ❌❌ Running TEST file in production! +``` + +**راه‌حل:** + +**فایل: `docker/Dockerfile.prod`** +```dockerfile +# Multi-stage build for production +FROM python:3.11-slim as builder + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /build + +# Install dependencies in separate layer for caching +COPY requirements.txt . +RUN pip install --user -r requirements.txt + +# Production stage +FROM python:3.11-slim + +# Create non-root user for security +RUN useradd -m -u 1000 appuser && \ + mkdir -p /app /app/logs /app/data && \ + chown -R appuser:appuser /app + +# Copy dependencies from builder +COPY --from=builder --chown=appuser:appuser /root/.local /home/appuser/.local + +# Set working directory +WORKDIR /app + +# Copy only production code (no tests) +COPY --chown=appuser:appuser src/ ./src/ +COPY --chown=appuser:appuser config/ ./config/ +COPY --chown=appuser:appuser scripts/health_check.py ./scripts/ + +# Switch to non-root user +USER appuser + +# Add local bin to PATH +ENV PATH=/home/appuser/.local/bin:$PATH + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD python scripts/health_check.py || exit 1 + +# Expose port +EXPOSE 7777 + +# Run application +CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "7777"] +``` + +**فایل: `docker/Dockerfile.dev`** +```dockerfile +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Install dev dependencies +COPY requirements.txt requirements-dev.txt ./ +RUN pip install -r requirements.txt -r requirements-dev.txt + +# Copy all code (including tests) for development +COPY . . + +# Development with hot reload +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "7777", "--reload"] +``` + +**فایل: `scripts/health_check.py`** +```python +#!/usr/bin/env python3 +"""Health check script for Docker""" +import sys +import requests + +try: + response = requests.get("http://localhost:7777/health", timeout=5) + if response.status_code == 200: + sys.exit(0) + else: + sys.exit(1) +except Exception as e: + print(f"Health check failed: {e}") + sys.exit(1) +``` + +**تاثیر:** ⭐⭐⭐⭐ +**تلاش رفع:** 1 روز + +--- + +### 9. عدم وجود CI/CD Pipeline مناسب + +#### مشکل فعلی: +```groovy +// Jenkinsfile - فقط deploy می‌کند: +sh 'git pull origin master' # ❌ No tests +sh 'docker compose up -d --build' # ❌ No validation +// ❌ No rollback strategy +// ❌ No smoke tests after deployment +``` + +**راه‌حل:** + +**فایل: `.github/workflows/ci.yml`** (اگر GitHub استفاده می‌کنید) +```yaml +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: testdb + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + qdrant: + image: qdrant/qdrant:latest + ports: + - 6333:6333 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run linting + run: | + black --check src/ + flake8 src/ + mypy src/ + + - name: Run tests + env: + DATABASE_URL: postgresql://postgres:test@localhost/testdb + QDRANT_URL: http://localhost:6333 + run: | + pytest tests/ -v --cov=src --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + + build: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - uses: actions/checkout@v3 + + - name: Build Docker image + run: | + docker build -f docker/Dockerfile.prod -t islamic-scholar-agent:${{ github.sha }} . + + - name: Run security scan + uses: aquasecurity/trivy-action@master + with: + image-ref: islamic-scholar-agent:${{ github.sha }} + format: 'sarif' + output: 'trivy-results.sarif' + + deploy: + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + steps: + - name: Deploy to production + run: | + # Your deployment logic here + echo "Deploying..." +``` + + +``` + +**تاثیر:** ⭐⭐⭐⭐ +**تلاش رفع:** 2 روز + +--- + +## 🟢 مشکلات جزئی (Minor Issues) + +### 10. عدم وجود Logging مناسب + +**راه‌حل:** + +**فایل: `src/core/logging.py`** +```python +import logging +import sys +from pathlib import Path +from logging.handlers import RotatingFileHandler +from src.core.config import settings + +def setup_logging(): + """Configure application logging""" + + # Create logs directory + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + + # Root logger + logger = logging.getLogger() + logger.setLevel(logging.INFO) + + # Format + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s' + ) + + # Console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # File handler with rotation + file_handler = RotatingFileHandler( + log_dir / "app.log", + maxBytes=10*1024*1024, # 10MB + backupCount=5 + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # Error file handler + error_handler = RotatingFileHandler( + log_dir / "error.log", + maxBytes=10*1024*1024, + backupCount=5 + ) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(formatter) + logger.addHandler(error_handler) + + return logger +``` + +**تاثیر:** ⭐⭐⭐ +**تلاش رفع:** 4 ساعت + +--- + +### 11. عدم وجود Documentation مناسب + +**فایل‌های مورد نیاز:** + +1. **README.md** - نمای کلی پروژه +2. **docs/ARCHITECTURE.md** - معماری سیستم +3. **docs/API.md** - مستندات API +4. **docs/DEPLOYMENT.md** - راهنمای دیپلویمنت +5. **docs/DEVELOPMENT.md** - راهنمای توسعه +6. **docs/TROUBLESHOOTING.md** - رفع مشکلات رایج + +**تاثیر:** ⭐⭐⭐ +**تلاش رفع:** 2 روز + +--- + +### 12. عدم وجود Environment Variables Documentation + +**فایل: `.env.example`** +```bash +# Database Configuration +DATABASE_URL=postgresql://user:password@localhost:5432/dbname + +# Vector Database - Choose one +VECTOR_DB_TYPE=qdrant # or "pgvector" + +# Qdrant Configuration (if using Qdrant) +QDRANT_URL=http://localhost:6333 +QDRANT_COLLECTION=islamic_knowledge + +# PgVector Configuration (if using PgVector) +PGVECTOR_TABLE=islamic_knowledge + +# LLM Configuration +LLM_MODEL_NAME=deepseek_r1 # See config/models.yaml for options +OPENROUTER_API_KEY=your_openrouter_key_here +OPENAI_API_KEY=your_openai_key_here # Optional + +# Embedding Configuration +EMBEDDING_MODEL=all-MiniLM-L6-v2 +EMBEDDING_BATCH_SIZE=100 +EMBEDDING_DIMENSIONS=384 + +# Monitoring (Optional but recommended for production) +LANGFUSE_PUBLIC_KEY=your_public_key +LANGFUSE_SECRET_KEY=your_secret_key +LANGFUSE_HOST=https://cloud.langfuse.com + +# Application +PORT=7777 +LOG_LEVEL=INFO +ENVIRONMENT=production # or "development", "staging" + +# Data Ingestion +DATA_DIR=data/raw +BATCH_SIZE=50 +MAX_RETRIES=3 +``` + +**تاثیر:** ⭐⭐ +**تلاش رفع:** 2 ساعت + +--- + +## 📊 خلاصه اولویت‌بندی + +### اولویت 1 (فوری - هفته اول): +1. ✅ بازسازی ساختار پروژه +2. ✅ ایجاد لایه انتزاع LLM (Model Factory) +3. ✅ راه‌اندازی Langfuse monitoring +4. ✅ اصلاح Dockerfile و docker-compose + +### اولویت 2 (مهم - هفته دوم): +5. ✅ بازنویسی Data Ingestion Pipeline +6. ✅ راه‌اندازی pytest و نوشتن tests +7. ✅ اصلاح CI/CD pipeline +8. ✅ Configuration management با Pydantic + +### اولویت 3 (بهبود - هفته سوم): +9. ✅ Logging framework +10. ✅ مستندسازی کامل +11. ✅ Security improvements +12. ✅ Performance optimization + +--- + +## 🎯 Checklist آمادگی Production + +```markdown +### Infrastructure +- [ ] ساختار فولدرها اصلاح شده +- [ ] Dockerfile production-ready +- [ ] docker-compose.yml بهینه‌سازی شده +- [ ] Health checks فعال +- [ ] Resource limits تنظیم شده + +### Code Quality +- [ ] هیچ کد تکراری وجود ندارد +- [ ] همه فایل‌های test حذف/منتقل شدند +- [ ] Code style consistent است (black, flake8) +- [ ] Type hints اضافه شده (mypy) +- [ ] Docstrings برای همه functions + +### Configuration +- [ ] همه configs از environment variables می‌آیند +- [ ] .env.example به‌روز است +- [ ] model configs در YAML مجزا +- [ ] secrets در environment variables (نه hardcoded) + +### Testing +- [ ] pytest راه‌اندازی شده +- [ ] Unit tests نوشته شده (coverage > 80%) +- [ ] Integration tests موجود +- [ ] CI/CD تست‌ها را اجرا می‌کند + +### Monitoring +- [ ] Langfuse integrate شده +- [ ] Logging framework فعال +- [ ] Error tracking راه‌اندازی شده +- [ ] Performance metrics جمع‌آوری می‌شوند + +### Data Pipeline +- [ ] Data validation پیاده‌سازی شده +- [ ] Error handling برای هر row +- [ ] Progress tracking موجود +- [ ] Resumable pipeline (tracking processed IDs) +- [ ] Comprehensive logging + + + +### Security +- [ ] No secrets in code +- [ ] Non-root user در Docker +- [ ] Security scanning در CI/CD +- [ ] Input validation everywhere +- [ ] Rate limiting (if public API) + +### Deployment +- [ ] CI/CD pipeline تست می‌کند +- [ ] Rollback strategy موجود +- [ ] Smoke tests بعد از deploy +- [ ] Monitoring alerts تنظیم شده +- [ ] Backup strategy +``` + +--- + +## 💡 توصیه‌های اضافی + +### 1. Rate Limiting برای API +```python +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +@app.post("/agui") +@limiter.limit("10/minute") # 10 requests per minute per IP +async def agui_endpoint(request: Request): + ... +``` + +### 2. Caching برای Embeddings +```python +from functools import lru_cache + +@lru_cache(maxsize=1000) +def get_embedding(text: str): + """Cache embeddings to avoid recomputation""" + return embedder.encode(text) +``` + +### 3. Async Processing برای بهتر شدن Performance +```python +import asyncio +from typing import List + +async def process_documents_async(documents: List[str]): + """Process multiple documents in parallel""" + tasks = [process_single_doc(doc) for doc in documents] + return await asyncio.gather(*tasks) +``` + +### 4. Database Connection Pooling +```python +from sqlalchemy import create_engine +from sqlalchemy.pool import QueuePool + +engine = create_engine( + DATABASE_URL, + poolclass=QueuePool, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, # Verify connections before use +) +``` + + +## 🚨 نتیجه‌گیری + +این پروژه در حال حاضر **فاقد استانداردهای پروداکشن** است و نیازمند بازسازی قابل توجه می‌باشد. مشکلات عمده شامل: + +1. **ساختار نامنظم** - فایل‌ها به صورت غیرحرفه‌ای سازماندهی شده‌اند +2. **Hardcoded configs** - عدم انعطاف‌پذیری برای تغییرات +3. **عدم monitoring** - غیرقابل debug در production +4. **Pipeline نامطمئن** - data ingestion fragile است +5. **عدم testing** - خطر بالای bugs در production + +با وجود این، **معماری اصلی RAG خوب است** و با 3-4 هفته کار می‌توان آن را به یک سیستم production-ready تبدیل کرد. + +--- diff --git a/README.md b/README.md index e69de29..c2b6533 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,117 @@ +# Islamic Scholar Agent + +A production-ready Islamic knowledge agent built with Agno framework, featuring RAG (Retrieval-Augmented Generation) capabilities for answering questions based on Islamic texts and knowledge. + +## Project Structure + +``` +├── src/ # Production source code +│ ├── agents/ # Agent implementations +│ ├── knowledge/ # Knowledge base & RAG pipeline +│ ├── models/ # LLM integrations +│ ├── api/ # FastAPI routes +│ ├── core/ # Core configurations +│ └── utils/ # Utility functions +├── data/ # Data files +│ ├── raw/ # Original data files +│ ├── processed/ # Processed/cleaned data +│ └── embeddings/ # Pre-computed embeddings +├── scripts/ # Utility scripts +├── tests/ # Test files +├── config/ # Configuration files +├── docker/ # Docker files +└── docs/ # Documentation +``` + +## Features + +- **Islamic Knowledge Focus**: Specialized agent for Islamic knowledge queries +- **RAG Pipeline**: Retrieval-augmented generation using vector databases +- **Multiple Vector Stores**: Support for Qdrant and PostgreSQL with PgVector +- **Modular Architecture**: Clean separation of concerns +- **Production Ready**: Docker support, health checks, logging +- **Comprehensive Testing**: Unit and integration tests + +## Quick Start + +1. **Setup Environment**: + ```bash + cp .env.example .env + # Edit .env with your API keys + ``` + +2. **Install Dependencies**: + ```bash + pip install -r requirements.txt + ``` + +3. **Setup Vector Database**: + ```bash + python scripts/setup_vectordb.py + ``` + +4. **Ingest Knowledge Data**: + ```bash + python scripts/ingest_excel.py + ``` + +5. **Run Application**: + ```bash + python src/main.py + ``` + +## Development + +### Running Tests +```bash +pytest tests/ +``` + +### Qdrant Connection Test +Test Qdrant vector database connectivity: +```bash +python test_qdrant_connection.py +``` + +For detailed documentation see: [Qdrant Connection Test Guide](docs/QDRANT_CONNECTION_TEST.md) + +### Health Check +```bash +python scripts/health_check.py +``` + +### Docker Development +```bash +cd docker +docker-compose -f docker-compose.dev.yml up +``` + +## API Usage + +### Health Check +```bash +curl http://localhost:8081/health +``` + +### Chat Endpoint +```bash +curl -X POST "http://localhost:8081/chat" \ + -H "Content-Type: application/json" \ + -d '{"message": "What is the importance of Islamic knowledge?"}' +``` + +## Configuration + +- **Development**: `config/development.env` +- **Production**: `config/production.env` +- **Models**: `config/models.yaml` + +## Documentation + +- [Production Readiness Report](PRODUCTION_READINESS_REPORT.md) +- [API Documentation](docs/API.md) +- [Deployment Guide](docs/DEPLOYMENT.md) + +## License + +This project is licensed under the MIT License. diff --git a/config/development.env b/config/development.env new file mode 100644 index 0000000..a718abc --- /dev/null +++ b/config/development.env @@ -0,0 +1,27 @@ +# Development Environment Configuration + +# Application Settings +DEBUG_MODE=true +HOST=0.0.0.0 +PORT=8081 + +# Model Settings +MODEL_ID=deepseek-ai/deepseek-v3.1 +API_URL=https://gpt.nwhco.ir +OPENROUTER_API_KEY=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 +MEGALLM_API_KEY=sk-mega-7bc75715897fcb91a7965f0d32347d44bc4bbd7f75225d7ca4c775059576843e + +# Database Settings +QDRANT_URL=http://127.0.0.1:6333 +QDRANT_COLLECTION=islamic_knowledge + +DB_USER=pg-user +DB_NAME=imam-javad +DB_PASSWORD=f1hd484fgsfddsdaf5@4d392js1jnx92 +DB_PORT=5575 +DB_HOST=88.99.212.243 + +# Vector DB Settings +COLLECTION_NAME=test_collection +EMBEDDER_MODEL=all-MiniLM-L6-v2 +EMBEDDER_DIMENSIONS=384 diff --git a/config/embeddings.yaml b/config/embeddings.yaml new file mode 100644 index 0000000..d9d8c5c --- /dev/null +++ b/config/embeddings.yaml @@ -0,0 +1,24 @@ +embeddings: + default: jina_AI + + models: + # # 1. Local / HuggingFace (Your current one) + # minilm_l6: + # provider: "local" + # id: "all-MiniLM-L6-v2" + # dimensions: 384 + # batch_size: 100 + + # 2. OpenAI (API Based) + openai_small: + provider: "openai" + id: "text-embedding-3-small" + dimensions: 1536 + api_key: ${OPENAI_API_KEY} + + # 3. Jina AI (Can be Local or API - assuming API here for speed) + jina_AI: + provider: "jinaai" # Jina uses OpenAI-style API + id: "jina-embeddings-v4" # or v4 + dimensions: 1024 + api_key: ${JINA_API_KEY} \ No newline at end of file diff --git a/config/models.yaml b/config/models.yaml new file mode 100644 index 0000000..e2ceba2 --- /dev/null +++ b/config/models.yaml @@ -0,0 +1,32 @@ +# LLM Model Configurations + +models: + # The "Master Switch" + default: deepseek_v3 + + providers: + # Provider 1: MegaLLM (OpenAI-Like) + openai_like: + api_key: ${MEGALLM_API_KEY} + base_url: ${API_URL} + models: + deepseek_v3: + id: "deepseek-ai/deepseek-v3.1" + temperature: 0.7 + max_tokens: 4096 + supports_streaming: true + + # Provider 2: OpenRouter + openrouter: + api_key: ${OPENROUTER_API_KEY} + base_url: ${OPENROUTER_BASE_URL} + models: + deepseek_r1: + id: "deepseek/deepseek-r1-0528:free" + temperature: 0.6 + max_tokens: 4096 + +# Rate limiting +rate_limits: + requests_per_minute: 60 + tokens_per_minute: 100000 \ No newline at end of file diff --git a/config/production.env b/config/production.env new file mode 100644 index 0000000..726933c --- /dev/null +++ b/config/production.env @@ -0,0 +1,43 @@ +# Production Environment Configuration + +# Application Settings +DEBUG_MODE=false +HOST=0.0.0.0 +PORT=8081 + +# ---------------- Model Settings ---------------- +# BASE URLS +API_URL=https://gpt.nwhco.ir +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 + +# API KEYS +MEGALLM_API_KEY=sk-mega-7bc75715897fcb91a7965f0d32347d44bc4bbd7f75225d7ca4c775059576843e +OPENROUTER_API_KEY=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 + +# ---------------- Vector DB Settings ---------------- +QDRANT_HOST=88.99.212.243 +QDRANT_PORT=6333 +QDRANT_API_KEY=e9432295b3541bb2d50593zbsacas222xzk + +# ---------------- Database Settings ---------------- +DB_USER=pg-user +DB_NAME=imam-javad +DB_PASSWORD=f1hd484fgsfddsdaf5@4d392js1jnx92 +DB_PORT=5575 +DB_HOST=88.99.212.243 + + +# ---------------- Enbeddings Settings ---------------- +BASE_COLLECTION_NAME=dovoodi_collection +EMBEDDER_MODEL=all-MiniLM-L6-v2 +RERANKER_MODEL=jina-reranker-v3 +EMBEDDER_DIMENSIONS=384 +JINA_API_KEY=jina_04dfa26cdf724e2dacee1be256f93afbWBUebOzdFcDBWErlq16xwWQVwkOM +OPENAI_API_KEY=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 + + +# ---------------- LANGFUSE Settings ---------------- +LANGFUSE_SECRET_KEY=sk-lf-a0ffa718-1de5-42e5-bebc-b7cc59fc1d70 +LANGFUSE_PUBLIC_KEY=pk-lf-9d769bac-1443-439f-9398-4384768614eb +LANGFUSE_BASE_URL=http://langfuse-server:3000 +LANGFUSE_DB_URL=postgresql://${DB_USER}:f1hd484fgsfddsdaf5%404d392js1jnx92@${DB_HOST}:${DB_PORT}/langfuse diff --git a/config/rerankers.yaml b/config/rerankers.yaml new file mode 100644 index 0000000..5ffef92 --- /dev/null +++ b/config/rerankers.yaml @@ -0,0 +1,11 @@ +rerankers: + default: jina_global + + models: + # 1. Jina AI (API Based) + jina_global: + provider: "jinaai" + model: "jina-reranker-v3" + api_key: "${JINA_API_KEY}" + base_url: "https://api.jina.ai/v1/rerank" + top_n: 3 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f5e0439 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.9' + +services: + # 1️⃣ Main App + app: + build: + context: . + dockerfile: Dockerfile + container_name: islamic-scholar-agent + env_file: + - config/production.env + ports: + - "8098:8081" + restart: unless-stopped + networks: + - imam-javad_backend_imam-javad # Use the reference name defined below + dns: + - 8.8.8.8 + - 1.1.1.1 + +# ⚠️ IMPORTANT ARCHITECTURE NOTE +# +# This project does NOT run its own Qdrant instance anymore. +# Instead, this service connects to the centralized Qdrant instance +# defined in the `najm/agent` project. + + # # 2️⃣ Qdrant Vector Database + # qdrant: + # image: qdrant/qdrant:latest + # container_name: qdrant-vector-db + # ports: + # - "5586:6333" + # environment: + # - QDRANT__SERVICE__API_KEY=qs-8d9f-4j2k-secret-key-99awdawdsdsvvfdfvvfd4v51f6d5 + # volumes: + # - qdrant_storage:/qdrant/storage + # restart: unless-stopped + # networks: + # - imam-javad_backend_imam-javad + +networks: + imam-javad_backend_imam-javad: + external: true + +# volumes: +# qdrant_storage: \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..d47b899 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,42 @@ +FROM python:3.9 + +# Environment Variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app +# Setting pip timeout globally via environment variable is often more reliable +ENV PIP_DEFAULT_TIMEOUT=1000 + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +# Adding --retries helps if the connection drops during the 800MB download +RUN pip install --upgrade pip && \ + pip install --no-cache-dir --timeout=1000 --retries 10 -r requirements.txt + +# Copy code +COPY src/ ./src/ + +COPY config/ ./config/ + +COPY scripts/ ./scripts/ + +COPY data/ ./data/ + +COPY tests/ ./tests/ + +# Port FastAPI +EXPOSE 8081 + +# Copy the entrypoint script +COPY entrypoint.sh /app/entrypoint.sh + +# Make it executable (Crucial!) +RUN chmod +x /app/entrypoint.sh + +# Set the Entrypoint +ENTRYPOINT ["/app/entrypoint.sh"] + +# Default command - can be overridden +CMD ["python", "src/main.py"] \ No newline at end of file diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..5541520 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,28 @@ +# Development Dockerfile +FROM python:3.9 + +# Set environment variables +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt requirements-dev.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements-dev.txt + +# Copy source code +COPY src/ ./src/ + +# Expose port +EXPOSE 8081 + +# Run the application +CMD ["python", "src/main.py"] diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..6e22fc9 --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,45 @@ +version: '3.9' + +services: + app: + build: + context: .. + dockerfile: docker/Dockerfile.dev + ports: + - "8081:8081" + environment: + - DEBUG_MODE=true + env_file: + - ../config/development.env + volumes: + - ..:/app + - /app/__pycache__ + depends_on: + - qdrant + - postgres + command: python src/main.py + + qdrant: + image: qdrant/qdrant:v1.7.4 + ports: + - "6333:6333" + - "6334:6334" + environment: + - QDRANT__SERVICE__API_KEY=qs-8d9f-4j2k-secret-key-99awdawdsdsvvfdfvvfd4v51f6d5 + volumes: + - qdrant_data:/qdrant/storage + + postgres: + image: postgres:15 + environment: + POSTGRES_USER: ai + POSTGRES_PASSWORD: ai + POSTGRES_DB: ai + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + qdrant_data: + postgres_data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..f5e0439 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.9' + +services: + # 1️⃣ Main App + app: + build: + context: . + dockerfile: Dockerfile + container_name: islamic-scholar-agent + env_file: + - config/production.env + ports: + - "8098:8081" + restart: unless-stopped + networks: + - imam-javad_backend_imam-javad # Use the reference name defined below + dns: + - 8.8.8.8 + - 1.1.1.1 + +# ⚠️ IMPORTANT ARCHITECTURE NOTE +# +# This project does NOT run its own Qdrant instance anymore. +# Instead, this service connects to the centralized Qdrant instance +# defined in the `najm/agent` project. + + # # 2️⃣ Qdrant Vector Database + # qdrant: + # image: qdrant/qdrant:latest + # container_name: qdrant-vector-db + # ports: + # - "5586:6333" + # environment: + # - QDRANT__SERVICE__API_KEY=qs-8d9f-4j2k-secret-key-99awdawdsdsvvfdfvvfd4v51f6d5 + # volumes: + # - qdrant_storage:/qdrant/storage + # restart: unless-stopped + # networks: + # - imam-javad_backend_imam-javad + +networks: + imam-javad_backend_imam-javad: + external: true + +# volumes: +# qdrant_storage: \ No newline at end of file diff --git a/docs/CULTURE_SYSTEM.md b/docs/CULTURE_SYSTEM.md new file mode 100644 index 0000000..3cb6064 --- /dev/null +++ b/docs/CULTURE_SYSTEM.md @@ -0,0 +1,208 @@ +# سیستم کالچر (Culture System) + +## مقدمه + +سیستم کالچر یک لایه رفتاری است که بالای ایجنت اصلی قرار می‌گیرد و **قوانین ادب، لحن، فرمت و نحوه پاسخ‌دهی** هوش مصنوعی را کنترل می‌کند. هدف این سیستم این است که پاسخ‌های ایجنت همیشه مطابق با اصول اسلامی و ادب علمی باشد. + +--- + +## معماری کلی + +``` +┌─────────────────────────────────────┐ +│ manual_cultures.py │ ← تعریف دستی قوانین کالچر (Master Copy) +│ CulturalKnowledge objects × 3 │ +└──────────────┬──────────────────────┘ + │ import + ▼ +┌─────────────────────────────────────┐ +│ culture.py │ ← همگام‌سازی با دیتابیس + ساخت CultureManager +│ get_culture_manager() │ +└──────────────┬──────────────────────┘ + │ return CultureManager + ▼ +┌─────────────────────────────────────┐ +│ base_agent.py │ ← اتصال کالچر به ایجنت +│ IslamicScholarAgent │ +│ ├─ culture_manager=... │ +│ ├─ add_culture_to_context=True │ +│ └─ description=culture_text │ +└─────────────────────────────────────┘ +``` + +--- + +## فایل‌ها و نقش هر کدام + +### ۱. `src/knowledge/manual_cultures.py` — تعریف کالچرها + +این فایل **نسخه اصلی (Master Copy)** قوانین رفتاری ایجنت است. سه آبجکت `CulturalKnowledge` در آن تعریف شده: + +| کالچر | نام | هدف | +|--------|-----|------| +| **A** | `adab_culture` — ادب (Etiquette) | رعایت آداب اسلامی: بسم‌الله، صلوات، رضی‌الله‌عنه، الله اعلم | +| **B** | `nuance_culture` — ظرافت (Handling Conflict) | مدیریت اختلاف روایات بدون گفتن «تناقض»، پرهیز از اظهار نظر شخصی | +| **C** | `formatting_culture` — فرمت‌دهی (Formatting) | بولد کردن نام منابع، پاسخ به زبان کاربر | + +هر آبجکت شامل فیلدهای زیر است: + +```python +CulturalKnowledge( + name="...", # نام کالچر (کلید یکتا برای sync) + summary="...", # خلاصه یک‌خطی + categories=[...], # دسته‌بندی‌ها + content="...", # متن اصلی قوانین (این متن به LLM ارسال می‌شود) + notes=[...], # یادداشت‌های داخلی +) +``` + +در انتهای فایل، لیست `ALL_CULTURES` همه کالچرها را جمع می‌کند: + +```python +ALL_CULTURES = [adab_culture, nuance_culture, formatting_culture] +``` + +--- + +### ۲. `src/core/culture.py` — همگام‌سازی و مدیریت + +تابع `get_culture_manager()` سه کار انجام می‌دهد: + +#### مرحله ۱: اتصال به دیتابیس +با استفاده از متغیرهای محیطی (`DB_USER`, `DB_PASSWORD`, ...) یک `PostgresDb` می‌سازد که جدول `agent_culture` را مدیریت می‌کند. + +#### مرحله ۲: ساخت CultureManager +یک نمونه از `CultureManager` (از کتابخانه `agno`) ایجاد می‌شود که به مدل LLM و دیتابیس متصل است. + +#### مرحله ۳: همگام‌سازی (Sync) +**کد پایتون به عنوان Master Copy عمل می‌کند.** منطق sync به این شکل است: + +``` +برای هر کالچر در ALL_CULTURES: + اگر نامش در دیتابیس وجود دارد → رد شو (skip) + اگر نامش وجود ندارد → آن را به دیتابیس اضافه کن (seed) +``` + +این یعنی: +- ✅ کالچرهای جدید **خودکار** به دیتابیس اضافه می‌شوند +- ✅ کالچرهای موجود دوباره اضافه **نمی‌شوند** +- ⚠️ اگر متن یک کالچر موجود تغییر کند، فعلاً آپدیت نمی‌شود (قابل توسعه در آینده) + +--- + +### ۳. `src/agents/base_agent.py` — اتصال به ایجنت + +در کلاس `IslamicScholarAgent` کالچر از **دو مسیر** به ایجنت تزریق می‌شود: + +#### مسیر ۱: پارامترهای مستقیم Agno + +```python +self.agent = ContextAwareAgent( + ... + culture_manager=self.culture_manager, # ← CultureManager تزریق شده + add_culture_to_context=True, # ← فعال‌سازی خودکار agno + ... +) +``` + +با فعال بودن `add_culture_to_context=True`، فریمورک `agno` به‌صورت خودکار محتوای کالچرها را به context ایجنت اضافه می‌کند. + +#### مسیر ۲: توضیحات (Description) ایجنت + +```python +description=self._build_culture_description() +``` + +متد `_build_culture_description()` تمام کالچرها را از `CultureManager` می‌خواند و یک متن Markdown تولید می‌کند: + +```markdown +### Behavioral Guidelines (Culture) +**Adab (Etiquette)**: +- Greeting: Always begin formal responses with ... +- Honorifics: ... + +**Nuance (Handling Conflict)**: +- Ikhtilaf: ... + +**Formatting**: +- Citations: ... +``` + +این متن در `description` ایجنت قرار می‌گیرد و به عنوان بخشی از system prompt به LLM ارسال می‌شود. + +--- + +## جریان کامل اجرا (Execution Flow) + +``` +1. اپلیکیشن شروع می‌شود + │ +2. IslamicScholarAgent.__init__() فراخوانی می‌شود + │ +3. get_culture_manager() اجرا می‌شود: + │ ├─ اتصال به PostgreSQL + │ ├─ ساخت CultureManager + │ └─ Sync: کالچرهای manual_cultures.py → جدول agent_culture + │ +4. _build_culture_description() متن کالچرها را به Markdown تبدیل می‌کند + │ +5. ContextAwareAgent با پارامترهای زیر ساخته می‌شود: + │ ├─ culture_manager → CultureManager آماده + │ ├─ add_culture_to_context=True → agno خودکار کالچر را inject می‌کند + │ └─ description → متن Markdown کالچرها + │ +6. کاربر سؤال می‌پرسد + │ +7. ایجنت با آگاهی از قوانین کالچر پاسخ می‌دهد: + ├─ بسم‌الله در ابتدا (ادب) + ├─ صلوات بعد از نام پیامبر (ادب) + ├─ «روایات مختلفی وجود دارد» به جای «تناقض» (ظرافت) + ├─ نام منابع بولد (فرمت) + └─ پاسخ به زبان کاربر (فرمت) +``` + +--- + +## نحوه اضافه کردن کالچر جدید + +۱. یک آبجکت `CulturalKnowledge` جدید در `src/knowledge/manual_cultures.py` بسازید: + +```python +new_culture = CulturalKnowledge( + name="نام یکتا", + summary="خلاصه", + categories=["دسته‌بندی"], + content="- قانون ۱\n- قانون ۲", + notes=["یادداشت"], +) +``` + +۲. آن را به لیست `ALL_CULTURES` اضافه کنید: + +```python +ALL_CULTURES = [adab_culture, nuance_culture, formatting_culture, new_culture] +``` + +۳. اپلیکیشن را ری‌استارت کنید. سیستم sync خودکار کالچر جدید را در دیتابیس seed می‌کند. + +--- + +## ذخیره‌سازی در دیتابیس + +| آیتم | مقدار | +|------|-------| +| **جدول** | `agent_culture` | +| **دیتابیس** | PostgreSQL (همان DB اصلی اپلیکیشن) | +| **کتابخانه** | `agno.db.postgres.PostgresDb` | +| **Sync** | یک‌طرفه — کد Python → دیتابیس | + +--- + +## خلاصه + +| فایل | مسئولیت | +|------|---------| +| `manual_cultures.py` | تعریف قوانین رفتاری (Master Copy) | +| `culture.py` | Sync با دیتابیس + ساخت `CultureManager` | +| `base_agent.py` | تزریق کالچر به ایجنت از طریق `culture_manager` و `description` | + diff --git a/docs/DYNAMIC_SYSTEM_PROMPT.md b/docs/DYNAMIC_SYSTEM_PROMPT.md new file mode 100644 index 0000000..c97d061 --- /dev/null +++ b/docs/DYNAMIC_SYSTEM_PROMPT.md @@ -0,0 +1,373 @@ +# سیستم پرامپت داینامیک: مدیریت رفتار Agent از طریق دیتابیس + +## مقدمه + +یکی از چالش‌های اصلی در توسعه و تنظیم Agent ها، نیاز به تغییر مکرر System Prompt است. در حالت معمول، برای تغییر رفتار Agent باید کد را تغییر داد، ریدپلوی کرد و منتظر ماند. این فرایند برای تست و بهینه‌سازی رفتار Agent بسیار کُند و وقت‌گیر است. + +ما این مشکل را با یک **سیستم پرامپت داینامیک** حل کرده‌ایم: System Prompt ها در دیتابیس (PostgreSQL) ذخیره می‌شوند و از طریق **پنل ادمین Django** قابل مدیریت هستند. یک **Pre-Hook** در هر درخواست کاربر، آخرین پرامپت‌ها را از دیتابیس می‌خواند و روی Agent اعمال می‌کند. + +### مزایای کلیدی + +- **بدون ریدپلوی**: تغییر رفتار Agent بدون نیاز به تغییر کد یا ریستارت سرویس +- **تست آسان**: امکان آزمایش سریع پرامپت‌های مختلف از طریق پنل ادمین +- **تاخیر ناچیز**: چون به صورت Pre-Hook اجرا می‌شود، overhead قابل چشم‌پوشی است +- **Fallback ایمن**: در صورت عدم دسترسی به دیتابیس، پرامپت پیش‌فرض اعمال می‌شود + +## معماری + +### نمای کلی + +```mermaid +graph TD + A["پنل ادمین Django"] -->|مدیریت پرامپت‌ها| B["جدول agent_agentprompt"] + + C["درخواست کاربر"] --> D["Agent pre_hooks"] + D --> E["sync_config_hook"] + E -->|کوئری| B + B -->|پرامپت‌های فعال| E + E -->|agent.instructions = new_prompts| F["Agent"] + + E -->|خطای اتصال| G["retry 3 بار"] + G -->|موفق| E + G -->|ناموفق| H["default_system_prompt"] + H --> F +``` + +### جریان داده + +``` +پنل ادمین Django + │ + │ CRUD عملیات (ایجاد/ویرایش/حذف/فعال‌سازی) + ▼ +┌─────────────────────────────────┐ +│ جدول: agent_agentprompt │ +│ ────────────────────────────── │ +│ id | settings_id | content │ +│ | is_active | order │ +└───────────────┬─────────────────┘ + │ + │ SELECT (هر درخواست کاربر) + ▼ +┌─────────────────────────────────┐ +│ sync_config_hook │ ← src/utils/hooks.py +│ ────────────────────────────── │ +│ 1. دریافت پرامپت‌های فعال │ +│ 2. جایگزینی agent.instructions │ +└───────────────┬─────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ Agent با پرامپت‌های جدید │ +│ اجرای درخواست کاربر │ +└─────────────────────────────────┘ +``` + +## پیاده‌سازی + +### ساختار فایل‌ها + +| فایل | مسئولیت | +|------|---------| +| `src/utils/load_settings.py` | اتصال به دیتابیس، خواندن پرامپت‌ها، مدیریت retry و تعریف پرامپت پیش‌فرض | +| `src/utils/hooks.py` | Pre-Hook که پرامپت‌ها را از دیتابیس گرفته و روی Agent اعمال می‌کند | +| `src/agents/base_agent.py` | تنظیم Agent با pre_hooks و پرامپت پیش‌فرض اولیه | + +### بخش ۱: اتصال به دیتابیس و خواندن پرامپت‌ها + +فایل: `src/utils/load_settings.py` + +#### تنظیم Connection Pool + +```python +from sqlalchemy import create_engine, text +from sqlalchemy.exc import OperationalError + +engine = create_engine( + db_url, + pool_pre_ping=True, # بررسی زنده بودن اتصال قبل از استفاده + pool_recycle=3600, # بستن و بازکردن اتصالات قدیمی‌تر از 1 ساعت + pool_size=10, # حداکثر 10 اتصال همزمان + max_overflow=20 # اتصالات اضافی در ترافیک بالا +) +``` + +**چرا `pool_pre_ping=True`؟** + +بدون این تنظیم، اگر اتصال دیتابیس به دلیل timeout یا مشکلات شبکه قطع شده باشد، اولین درخواست بعد از قطعی با خطا مواجه می‌شود. `pool_pre_ping` قبل از هر استفاده، یک ping ساده به دیتابیس ارسال می‌کند و در صورت قطعی، اتصال جدید می‌سازد. + +#### خواندن پرامپت‌ها با Retry + +```python +def get_active_agent_config(retries=3, delay=1): + """ + Fetches active prompts with automatic retry logic for database "hiccups". + """ + attempt = 0 + while attempt < retries: + try: + with engine.connect() as conn: + query = text(""" + SELECT content + FROM agent_agentprompt + WHERE settings_id = 1 AND is_active = true + ORDER BY id ASC + """) + result = conn.execute(query).fetchall() + prompt_list = [row.content for row in result] + return {"system_prompts": prompt_list} + + except OperationalError as e: + attempt += 1 + print(f"⚠️ DB Connection error (Attempt {attempt}/{retries}): {e}") + if attempt < retries: + time.sleep(delay) + else: + print("❌ DB Retry limit reached.") + return None + + except Exception as e: + print(f"❌ Unexpected Error: {e}") + return None +``` + +#### ساختار کوئری + +```sql +SELECT content +FROM agent_agentprompt +WHERE settings_id = 1 AND is_active = true +ORDER BY id ASC +``` + +| فیلد | توضیح | +|------|-------| +| `content` | متن پرامپت | +| `settings_id = 1` | فقط پرامپت‌های مربوط به تنظیمات فعلی | +| `is_active = true` | فقط پرامپت‌های فعال (غیرفعال‌ها نادیده گرفته می‌شوند) | +| `ORDER BY id ASC` | حفظ ترتیب تعریف شده پرامپت‌ها | + +از طریق پنل ادمین می‌توان: +- پرامپت جدید **اضافه** کرد +- پرامپت موجود را **ویرایش** کرد +- یک پرامپت را **غیرفعال** (`is_active = false`) کرد بدون حذف آن +- پرامپت را **حذف** کرد +- **ترتیب** پرامپت‌ها را تغییر داد + +#### مکانیزم Retry + +سیستم retry سه‌مرحله‌ای برای مقابله با مشکلات موقت دیتابیس: + +``` +تلاش ۱ → خطا → صبر 1 ثانیه + تلاش ۲ → خطا → صبر 1 ثانیه + تلاش ۳ → خطا → برگشت None (استفاده از پرامپت پیش‌فرض) +``` + +| پارامتر | مقدار | توضیح | +|----------|-------|-------| +| `retries` | 3 | حداکثر تعداد تلاش | +| `delay` | 1 ثانیه | فاصله بین تلاش‌ها | + +**نکته**: فقط `OperationalError` (خطاهای اتصال/شبکه) باعث retry می‌شود. خطاهای دیگر (مثل خطای SQL) بلافاصله `None` برمی‌گردانند چون retry کردن آن‌ها فایده‌ای ندارد. + +#### پرامپت پیش‌فرض (Fallback) + +```python +def default_system_prompt(): + return [ + "You are a strict Islamic Knowledge Assistant.", + "Your Goal: Answer the user's question using the provided 'Context from the database'.", + "STRICT BEHAVIORAL RULE: You must maintain the highest standard of Adab (Etiquette).", + "If the user is disrespectful, vulgar, uses profanity, or mocks Islam:", + "1. Do NOT engage with the toxicity.", + "2. Do NOT lecture them.", + "3. Refuse to answer immediately by saying: 'I cannot answer this due to violations of Adab.'", + "If the Context is in a different language than the User's Question, you MUST translate ...", + "If the answer is explicitly found in the context, answer directly.", + "If the answer is NOT found in the context, strictly reply: 'Information not available ...'", + "Maintain a respectful, scholarly tone.", + "Do not explain your reasoning process in the final output.", + ] +``` + +این پرامپت در دو جا استفاده می‌شود: +1. **هنگام ساخت Agent**: به عنوان `instructions` اولیه +2. **زمان عدم دسترسی به دیتابیس**: وقتی `sync_config_hook` نتواند پرامپت از دیتابیس بخواند، Agent با همین پرامپت پیش‌فرض کار می‌کند + +### بخش ۲: Pre-Hook برای اعمال پرامپت + +فایل: `src/utils/hooks.py` + +```python +def sync_config_hook(run_input: RunInput, **kwargs): + """ + Agno Pre-Hook: Fetches the latest Django DB config and + injects it into the agent before the run starts. + """ + # 1. دسترسی به instance ایجنت + agent = kwargs.get("agent") + if not agent: + return + + # 2. دریافت تنظیمات از دیتابیس + config = get_active_agent_config() + + # 3. اعمال پرامپت‌های جدید + if config and config.get("system_prompts"): + new_prompts = config["system_prompts"] + agent.instructions = new_prompts + + return run_input +``` + +#### نحوه کار + +1. **دریافت Agent**: فریمورک Agno در `kwargs` شیء agent را پاس می‌دهد +2. **خواندن از دیتابیس**: تابع `get_active_agent_config()` فراخوانی می‌شود (شامل retry) +3. **جایگزینی instructions**: اگر پرامپت‌هایی از دیتابیس برگشت، `agent.instructions` **کاملاً جایگزین** می‌شود +4. **حالت Fallback**: اگر `config` برابر `None` باشد (خطای دیتابیس)، شرط `if` اجرا نمی‌شود و Agent با instructions قبلی (پیش‌فرض) کار می‌کند + +**نکته مهم**: `agent.instructions` کاملاً **overwrite** می‌شود (نه append). یعنی لیست پرامپت‌های دیتابیس جایگزین کامل لیست قبلی می‌شود. این طراحی عمدی است تا پنل ادمین کنترل کامل روی رفتار Agent داشته باشد. + +### بخش ۳: ثبت Hook در Agent + +فایل: `src/agents/base_agent.py` + +```python +from src.utils.hooks import sync_config_hook, rag_injection_hook +from src.utils.load_settings import default_system_prompt + +class IslamicScholarAgent: + def __init__(self, model, knowledge_base, custom_instructions=None, db_url=None): + self.custom_instructions = custom_instructions or default_system_prompt() + + self.agent = Agent( + name="Islamic Scholar Agent", + model=model, + instructions=self.custom_instructions, # پرامپت اولیه (پیش‌فرض) + pre_hooks=[ + PromptInjectionGuardrail(), + InputLimitGuardrail(), + sync_config_hook, # ← اینجا پرامپت از دیتابیس خوانده و اعمال می‌شود + rag_injection_hook, + ], + # ... + ) +``` + +#### ترتیب اجرای Hook ها + +``` +درخواست کاربر + │ + ├─ 1. PromptInjectionGuardrail → بررسی امنیتی ورودی + ├─ 2. InputLimitGuardrail → بررسی محدودیت طول ورودی + ├─ 3. sync_config_hook → خواندن پرامپت از دیتابیس و اعمال + ├─ 4. rag_injection_hook → تزریق context از وکتور دیتابیس + │ + ▼ + Agent اجرا می‌شود (با پرامپت‌های تازه از دیتابیس + context از RAG) +``` + +`sync_config_hook` عمداً **قبل از** `rag_injection_hook` قرار دارد. ابتدا رفتار Agent (instructions) تنظیم می‌شود، سپس داده‌ها (context) به ورودی اضافه می‌شوند. + +## زنجیره Fallback کامل + +```mermaid +graph TD + A["sync_config_hook اجرا می‌شود"] --> B["get_active_agent_config فراخوانی"] + B --> C{"تلاش ۱: اتصال به DB"} + C -->|موفق| D["پرامپت‌های فعال برگشت"] + C -->|OperationalError| E{"تلاش ۲"} + E -->|موفق| D + E -->|OperationalError| F{"تلاش ۳"} + F -->|موفق| D + F -->|OperationalError| G["return None"] + + D --> H["agent.instructions = پرامپت‌های دیتابیس"] + G --> I["شرط if اجرا نمی‌شود"] + I --> J["Agent با instructions فعلی کار می‌کند"] + J --> K["default_system_prompt"] + H --> L["Agent با پرامپت‌های جدید اجرا می‌شود"] +``` + +### سناریوهای مختلف + +| سناریو | نتیجه | +|---------|-------| +| دیتابیس در دسترس، پرامپت‌های فعال موجود | پرامپت‌های دیتابیس اعمال می‌شوند | +| دیتابیس در دسترس، هیچ پرامپت فعالی نیست | لیست خالی برمی‌گردد، شرط `if` رد می‌شود، پرامپت پیش‌فرض حفظ می‌شود | +| دیتابیس موقتاً قطع (تلاش ۱ یا ۲ موفق) | پس از retry موفق، پرامپت‌های دیتابیس اعمال می‌شوند | +| دیتابیس کاملاً قطع (هر ۳ تلاش ناموفق) | `None` برمی‌گردد، پرامپت پیش‌فرض حفظ می‌شود | +| خطای غیرمنتظره (مثلاً خطای SQL) | بلافاصله `None` برمی‌گردد، پرامپت پیش‌فرض حفظ می‌شود | + +## عملکرد و تاخیر + +### چرا تاخیر ناچیز است؟ + +1. **Connection Pool**: اتصال به دیتابیس از pool گرفته می‌شود (بدون overhead ساخت اتصال جدید) +2. **کوئری ساده**: یک `SELECT` ساده با فیلتر `WHERE` روی فیلدهای ایندکس‌شده +3. **داده کم**: تعداد پرامپت‌ها معمولاً کمتر از ۲۰ رکورد است +4. **pool_pre_ping**: اتصال‌های مُرده سریع شناسایی و جایگزین می‌شوند + +### برآورد زمانی + +| عملیات | زمان تقریبی | +|--------|-------------| +| گرفتن اتصال از pool | < 1ms | +| اجرای کوئری SELECT | 1-5ms | +| پردازش نتایج و اعمال | < 1ms | +| **مجموع (حالت عادی)** | **2-7ms** | +| Retry (در صورت خطا) | +1000ms به ازای هر retry | + +در مقایسه با زمان پاسخ‌دهی LLM (معمولاً 1-10 ثانیه)، تاخیر 2-7 میلی‌ثانیه‌ای کاملاً قابل چشم‌پوشی است. + +## نحوه استفاده از پنل ادمین + +### افزودن پرامپت جدید + +1. وارد پنل ادمین Django شوید +2. به بخش **Agent Prompts** بروید +3. پرامپت جدید بسازید: + - `content`: متن پرامپت + - `settings_id`: شماره تنظیمات (معمولاً 1) + - `is_active`: تیک بزنید تا فعال باشد +4. ذخیره کنید + +تغییرات **بلافاصله** در درخواست بعدی کاربر اعمال می‌شوند. + +### غیرفعال کردن پرامپت + +- تیک `is_active` را بردارید +- پرامپت از لیست ارسالی به Agent حذف می‌شود اما در دیتابیس باقی می‌ماند +- می‌توانید هر زمان دوباره فعالش کنید + +### تست رفتار جدید + +1. پرامپت‌ها را در پنل ادمین تغییر دهید +2. یک پیام تستی به Agent بفرستید +3. پاسخ را بررسی کنید +4. در صورت نیاز، پرامپت‌ها را اصلاح کنید و دوباره تست کنید + +این چرخه بدون هیچ ریدپلوی یا ریستارتی انجام می‌شود. + +## تنظیمات محیطی + +| متغیر | توضیح | الزامی | +|--------|--------|--------| +| `DB_USER` | نام کاربری دیتابیس PostgreSQL | بله | +| `DB_PASSWORD` | رمز عبور دیتابیس | بله | +| `DB_HOST` | آدرس سرور دیتابیس | بله | +| `DB_PORT` | پورت دیتابیس | بله | +| `DB_NAME` | نام دیتابیس | بله | + +## نتیجه‌گیری + +سیستم پرامپت داینامیک با ترکیب سه مولفه ساده اما موثر کار می‌کند: + +1. **دیتابیس + پنل ادمین**: ذخیره و مدیریت آسان پرامپت‌ها +2. **`get_active_agent_config`**: خواندن امن از دیتابیس با retry و fallback +3. **`sync_config_hook`**: اعمال پرامپت‌ها روی Agent به صورت Pre-Hook + +این معماری امکان **تغییر لحظه‌ای رفتار Agent** را بدون نیاز به تغییر کد، ریدپلوی یا ریستارت فراهم می‌کند. تاخیر ناشی از خواندن دیتابیس در حد چند میلی‌ثانیه است و در مقایسه با زمان پردازش LLM کاملاً قابل چشم‌پوشی است. همچنین سیستم Fallback تضمین می‌کند که حتی در صورت قطعی دیتابیس، Agent با پرامپت پیش‌فرض به کار خود ادامه می‌دهد. diff --git a/docs/EMBEDDING_MANAGEMENT.md b/docs/EMBEDDING_MANAGEMENT.md new file mode 100644 index 0000000..4a4a5e3 --- /dev/null +++ b/docs/EMBEDDING_MANAGEMENT.md @@ -0,0 +1,616 @@ +# مدیریت مدل‌های Embedding در سیستم Islamic Scholar Agent + +## 📋 نمای کلی + +سیستم مدیریت مدل‌های embedding در پروژه Islamic Scholar Agent به صورت متمرکز و هوشمند طراحی شده است. این سیستم امکان تغییر سریع بین مدل‌های embedding مختلف، مدیریت اتوماتیک collectionها و جلوگیری از مشکلات dimension mismatch را فراهم می‌کند. + +## 🎯 چرایی این سیستم + +### مشکل رویکرد قدیمی + +در گذشته، مدل‌های embedding به صورت مستقیم در کد استفاده می‌شدند و collectionها ثابت بودند: + +```python +# ❌ رویکرد قدیمی - hardcoded +embedder = SentenceTransformerEmbedder(id="all-MiniLM-L6-v2") +vector_store = Qdrant(collection="islamic_knowledge", embedder=embedder) + +# ❌ مشکل dimension mismatch +# اگر embedder تغییر کند، داده‌های قدیمی با dimensions جدید سازگار نیستند +``` + +### راه‌حل جدید + +```python +# ✅ رویکرد جدید - هوشمند و متمرکز +from src.knowledge.embedding_factory import EmbeddingFactory +from src.knowledge.vector_store import get_qdrant_store + +embed_factory = EmbeddingFactory() +embedder = embed_factory.get_embedder() # مدل پیش‌فرض از config + +# collection اتوماتیک تغییر می‌کند! +vector_store = get_qdrant_store(embedder=embedder) +``` + +## 🏗️ معماری سیستم + +### ساختار فایل‌ها + +``` +config/ +├── embeddings.yaml # تنظیمات متمرکز embeddingها + +src/knowledge/ +├── embedding_factory.py # کارخانه embeddingها +├── vector_store.py # منطق هوشمند collection +├── rag_pipeline.py # استفاده از سیستم +``` + +### فایل‌های کلیدی + +#### 1. `config/embeddings.yaml` + +فایل تنظیمات مرکزی که همه مدل‌های embedding را تعریف می‌کند: + +```yaml +embeddings: + # سوئیچ اصلی - تغییر این مقدار = تغییر embedding کل سیستم + default: jina_AI + + models: + # OpenAI Embeddings (API-based) + openai_small: + provider: "openai" + id: "text-embedding-3-small" + dimensions: 1536 + api_key: ${OPENAI_API_KEY} + + # Jina AI Embeddings (API-based) + jina_AI: + provider: "jinaai" + id: "jina-embeddings-v4" + dimensions: 1024 + api_key: ${JINA_API_KEY} + + # Local HuggingFace (اختیاری - کامنت شده) + # minilm_l6: + # provider: "local" + # id: "all-MiniLM-L6-v2" + # dimensions: 384 + # batch_size: 100 +``` + +#### 2. `src/knowledge/embedding_factory.py` + +کارخانه اصلی که embedding مناسب را از تنظیمات ایجاد می‌کند: + +```python +from agno.knowledge.embedder.openai import OpenAIEmbedder +from agno.knowledge.embedder.jina import JinaEmbedder + +class EmbeddingFactory: + def __init__(self, config_path: str = "config/embeddings.yaml"): + with open(config_path) as f: + # Resolve environment variables + content = f.read() + for key, val in os.environ.items(): + content = content.replace(f"${{{key}}}", val) + self.config = yaml.safe_load(content) + + def get_embedder(self, model_name: Optional[str] = None): + # استفاده از مدل پیش‌فرض + if model_name is None: + model_name = self.config['embeddings']['default'] + + models_config = self.config['embeddings']['models'] + if model_name not in models_config: + raise ValueError(f"Embedding model '{model_name}' not found in config.") + + config = models_config[model_name] + provider = config['provider'] + + # Resolve API key + api_key_env = config.get('api_key') + if api_key_env and api_key_env.startswith("${"): + api_key = os.getenv(api_key_env[2:-1]) + else: + api_key = api_key_env + + # ایجاد embedder بر اساس provider + if provider == "openai": + return OpenAIEmbedder( + id=config['id'], + dimensions=config['dimensions'], + api_key=api_key + ) + elif provider == "jinaai": + return JinaEmbedder( + id=config['id'], + dimensions=config['dimensions'], + api_key=api_key + ) + + raise ValueError(f"Unknown provider type: {provider}") +``` + +#### 3. `src/knowledge/vector_store.py` + +سیستم هوشمند مدیریت collection: + +```python +def get_qdrant_store(collection_name=None, url=None, embedder=None): + """Get configured Qdrant vector store with automatic collection naming""" + + # استفاده از BASE_COLLECTION_NAME از environment + collection = collection_name or os.getenv("COLLECTION_NAME") + + # 🚀 ویژگی کلیدی: collection اتوماتیک بر اساس embedder تغییر می‌کند + collection = f"{collection}_{embedder.id}" + + qdrant_url = url or os.getenv("QDRANT_URL") + + # اطمینان از وجود embedder + if embedder is None: + raise ValueError("You must provide an 'embedder' instance to get_qdrant_store!") + + return Qdrant( + collection=collection, + url=qdrant_url, + embedder=embedder, + timeout=10.0 + ) +``` + +#### 4. `src/main.py` + +نحوه استفاده در اپلیکیشن اصلی: + +```python +def create_app(): + # ایجاد embedding factory + embed_factory = EmbeddingFactory() + current_embedder = embed_factory.get_embedder() + print(f"Current Embedder: {current_embedder.id}") + + # ارسال embedder به سیستم دانش + knowledge_base = create_knowledge_base( + embedder=current_embedder, + vector_store_type="qdrant" + ) + + # ایجاد agent با knowledge base + agent = IslamicScholarAgent(model.get_model(), knowledge_base) +``` + +## 🚀 نحوه استفاده + +### استفاده پایه + +```python +# ایجاد embedder از تنظیمات پیش‌فرض +from src.knowledge.embedding_factory import EmbeddingFactory + +embed_factory = EmbeddingFactory() +embedder = embed_factory.get_embedder() # استفاده از مدل پیش‌فرض (jina_AI) +``` + +### تغییر مدل embedding در زمان اجرا + +```python +# تغییر به OpenAI embeddings +embedder = embed_factory.get_embedder('openai_small') + +# تغییر به local model (اگر تعریف شده) +# embedder = embed_factory.get_embedder('minilm_l6') +``` + +### تغییر مدل پیش‌فرض + +```yaml +# config/embeddings.yaml +embeddings: + default: openai_small # تغییر از jina_AI به openai_small +``` + +### افزودن مدل embedding جدید + +#### مرحله 1: افزودن به YAML + +```yaml +# config/embeddings.yaml +models: + cohere_embed: + provider: "cohere" + id: "embed-multilingual-v3.0" + dimensions: 1024 + api_key: ${COHERE_API_KEY} +``` + +#### مرحله 2: افزودن پشتیبانی در factory + +```python +# src/knowledge/embedding_factory.py +from agno.knowledge.embedder.cohere import CohereEmbedder + +# در بخش provider logic +elif provider == "cohere": + return CohereEmbedder( + id=config['id'], + dimensions=config['dimensions'], + api_key=api_key + ) +``` + +## ✅ مزایای این سیستم + +### 1. **جلوگیری از Dimension Mismatch** + +```python +# ❌ مشکل قدیمی +# Collection: "islamic_knowledge" +# Embedder 1: all-MiniLM-L6-v2 (dimensions: 384) +# Embedder 2: text-embedding-3-small (dimensions: 1536) +# ❌ داده‌های قدیمی با embedder جدید سازگار نیستند! + +# ✅ راه‌حل جدید +# Collection: "islamic_knowledge_all-MiniLM-L6-v2" +# Collection: "islamic_knowledge_text-embedding-3-small" +# ✅ هر embedder collection جداگانه خودش را دارد +``` + +### 2. **سوئیچ سریع بین مدل‌ها** + +```python +# تغییر از Jina به OpenAI فقط با یک خط تغییر در YAML +# config/embeddings.yaml +default: openai_small # تغییر از jina_AI + +# سیستم اتوماتیک: +# - Collection جدید ایجاد می‌کند: islamic_knowledge_text-embedding-3-small +# - از داده‌های قدیمی استفاده نمی‌کند +# - نیاز به re-ingest دارد +``` + +### 3. **مدیریت متمرکز تنظیمات** + +- همه تنظیمات embedding در یک فایل YAML +- تغییر تنظیمات بدون تغییر کد +- امکان مقایسه عملکرد مدل‌های مختلف + +### 4. **پشتیبانی از Providerهای مختلف** + +```yaml +models: + # API-based providers + openai_small: # OpenAI + jina_AI: # Jina AI + cohere_embed: # Cohere + + # Local providers (اختیاری) + minilm_l6: # HuggingFace local +``` + +### 5. **امنیت و مدیریت API Keys** + +```bash +# تنظیم متغیرهای محیطی +export OPENAI_API_KEY="sk-..." +export JINA_API_KEY="jina_..." +export COHERE_API_KEY="..." + +# استفاده در YAML +api_key: ${OPENAI_API_KEY} +``` + +### 6. **قابلیت توسعه‌پذیری** + +- افزودن provider جدید بدون تغییر کد موجود +- پشتیبانی از تنظیمات خاص هر provider +- امکان customization برای نیازهای خاص + +### 7. **شفافیت و Debugging** + +```python +# لاگ embedder فعلی +print(f"Current Embedder: {current_embedder.id}") +# Current Embedder: jina-embeddings-v4 + +# collection اتوماتیک نام‌گذاری می‌شود +print(f"Collection: {collection}_{embedder.id}") +# Collection: islamic_knowledge_jina-embeddings-v4 +``` + +## ⚠️ نکات مهم + +### **ضرورت Re-ingest هنگام تغییر Embedder** + +```python +# 🚨 هشدار مهم +# وقتی embedder تغییر می‌کند، collection جدید ایجاد می‌شود +# داده‌های قدیمی در collection قدیمی باقی می‌مانند + +# مثال: +# تغییر از jina_AI به openai_small: +# Collection قدیمی: islamic_knowledge_jina-embeddings-v4 (داده‌ها موجود) +# Collection جدید: islamic_knowledge_text-embedding-3-small (خالی!) + +# ✅ راه‌حل: حتماً داده‌ها را دوباره ingest کنید +python scripts/ingest_data.py --hadiths data/raw/hadiths_data.xlsx +``` + +### بررسی وجود داده در Collection + +```python +# قبل از استفاده، بررسی کنید که collection داده دارد +def check_collection_data(embedder): + store = get_qdrant_store(embedder=embedder) + count = store.client.count(store.collection_name) + if count.count == 0: + print(f"⚠️ Collection {store.collection_name} is empty!") + print("Please run data ingestion first.") + return count.count > 0 +``` + +## 🔧 تنظیمات پیشرفته + +### تنظیمات Environment Variables + +```bash +# .env +# Embedding Configuration +JINA_API_KEY=jina_... +OPENAI_API_KEY=sk-... + +# Vector Database +COLLECTION_NAME=islamic_knowledge +QDRANT_URL=http://localhost:6333 +``` + +### تنظیمات Batch Processing + +```yaml +# config/embeddings.yaml +models: + minilm_l6: + provider: "local" + id: "all-MiniLM-L6-v2" + dimensions: 384 + batch_size: 100 # برای پردازش دسته‌ای +``` + +### Caching Embeddings (اختیاری) + +```python +from functools import lru_cache + +@lru_cache(maxsize=1000) +def get_cached_embedding(text: str, embedder_id: str): + """Cache embeddings برای جلوگیری از recomputation""" + embedder = EmbeddingFactory().get_embedder(embedder_id) + return embedder.get_embedding(text) +``` + +## 🧪 تست سیستم + +### تست‌های واحد + +```python +# tests/test_embeddings.py +def test_embedding_factory_default(): + factory = EmbeddingFactory() + embedder = factory.get_embedder() + assert embedder.id == "jina-embeddings-v4" + assert embedder.dimensions == 1024 + +def test_embedding_factory_specific(): + factory = EmbeddingFactory() + embedder = factory.get_embedder('openai_small') + assert embedder.id == "text-embedding-3-small" + assert embedder.dimensions == 1536 +``` + +### تست‌های integration + +```python +# tests/test_vector_store.py +def test_collection_naming(): + factory = EmbeddingFactory() + + # تست تغییر اتوماتیک collection + embedder1 = factory.get_embedder('jina_AI') + store1 = get_qdrant_store(embedder=embedder1) + assert "jina-embeddings-v4" in store1.collection + + embedder2 = factory.get_embedder('openai_small') + store2 = get_qdrant_store(embedder=embedder2) + assert "text-embedding-3-small" in store2.collection + + # collectionها باید متفاوت باشند + assert store1.collection != store2.collection +``` + +## 🚨 عیب‌یابی + +### مشکلات رایج + +#### 1. Collection خالی است + +``` +⚠️ Collection islamic_knowledge_jina-embeddings-v4 is empty! +``` + +**علت**: embedder تغییر کرده اما داده‌ها re-ingest نشده‌اند. + +**راه‌حل**: +```bash +# داده‌ها را دوباره ingest کنید +python scripts/ingest_data.py --hadiths data/raw/hadiths_data.xlsx +``` + +#### 2. API Key یافت نشد + +``` +ValueError: API key for provider 'openai' not found +``` + +**راه‌حل**: متغیر محیطی را تنظیم کنید: +```bash +export OPENAI_API_KEY="your_key_here" +``` + +#### 3. Embedder یافت نشد + +``` +ValueError: Embedding model 'unknown_model' not found in config +``` + +**راه‌حل**: مدل را در `config/embeddings.yaml` تعریف کنید. + +#### 4. Dimension mismatch (اگر سیستم قدیمی استفاده شود) + +``` +Error: Embedding dimensions do not match stored vectors +``` + +**راه‌حل**: از سیستم جدید استفاده کنید که اتوماتیک collection را تغییر می‌دهد. + +### Debug Mode + +```python +# فعال کردن debug در factory +import logging +logging.basicConfig(level=logging.DEBUG) + +factory = EmbeddingFactory() +embedder = factory.get_embedder() # لاگ‌های مفصل نمایش داده می‌شود +``` + +## 📊 مانیتورینگ و Metrics + +### پیگیری استفاده از Embedderها + +```python +# در main.py +logger.info(f"Knowledge base initialized with: {current_embedder.id}") +logger.info(f"Embedding dimensions: {current_embedder.dimensions}") +logger.info(f"Collection: {collection}_{embedder.id}") +``` + +### مقایسه Performance + +```python +import time + +def benchmark_embedder(embedder_name): + factory = EmbeddingFactory() + embedder = factory.get_embedder(embedder_name) + + start_time = time.time() + # تست embedding چند متن + embeddings = embedder.get_embedding_batch(["text1", "text2", "text3"]) + duration = time.time() - start_time + + return { + "embedder": embedder_name, + "duration": duration, + "dimensions": embedder.dimensions + } +``` + +## 🔄 مهاجرت از سیستم قدیمی + +### قبل از تغییر + +```python +# کد قدیمی - hardcoded +embedder = SentenceTransformerEmbedder(id="all-MiniLM-L6-v2") +vector_store = Qdrant(collection="islamic_knowledge", embedder=embedder) +``` + +### بعد از تغییر + +```python +# کد جدید - متمرکز +from src.knowledge.embedding_factory import EmbeddingFactory + +embed_factory = EmbeddingFactory() +embedder = embed_factory.get_embedder() # از config +vector_store = get_qdrant_store(embedder=embedder) # collection اتوماتیک +``` + +### مهاجرت تدریجی + +1. **مرحله ۱**: تنظیمات را در `config/embeddings.yaml` تعریف کنید +2. **مرحله ۲**: `EmbeddingFactory` را ایجاد کنید +3. **مرحله ۳**: کد را به استفاده از factory تغییر دهید +4. **مرحله ۴**: داده‌ها را با embedder جدید ingest کنید +5. **مرحله ۵**: سیستم قدیمی را حذف کنید + +## 📚 بهترین تجربیات (Best Practices) + +### 1. **همیشه از Environment Variables استفاده کنید** + +```yaml +# ✅ خوب +api_key: ${JINA_API_KEY} + +# ❌ بد +api_key: jina_123456789 +``` + +### 2. **نام‌گذاری معنادار مدل‌ها** + +```yaml +models: + jina_AI: # ✅ واضح + embed_model_1: # ❌ نام‌گذار بی‌معنا +``` + +### 3. **Re-ingest را فراموش نکنید** + +```bash +# چک‌لیست تغییر embedder: +# 1. تنظیمات را در YAML تغییر دهید +# 2. متغیرهای محیطی را تنظیم کنید +# 3. اپلیکیشن را restart کنید +# 4. ✅ داده‌ها را دوباره ingest کنید +# 5. عملکرد را تست کنید +``` + +### 4. **Validation تنظیمات** + +```python +def _validate_config(self): + """Validate embedding configuration""" + required_keys = ['embeddings', 'models', 'default'] + for key in required_keys: + if key not in self.config: + raise ValueError(f"Missing required config key: {key}") + + # بررسی dimensions + for model_name, model_config in self.config['embeddings']['models'].items(): + if 'dimensions' not in model_config: + raise ValueError(f"Model {model_name} missing dimensions") +``` + +### 5. **Backup Collectionها** + +```bash +# قبل از تغییر embedder، backup بگیرید +qdrant_backup --collection islamic_knowledge_old_embedder +``` + +## 🎯 نتیجه‌گیری + +این سیستم مدیریت embedding مزایای زیر را فراهم می‌کند: + +1. **جلوگیری از Dimension Mismatch**: collection اتوماتیک بر اساس embedder تغییر می‌کند +2. **انعطاف‌پذیری بالا**: تغییر embedder با یک خط تغییر در YAML +3. **مدیریت متمرکز**: همه تنظیمات در یک مکان +4. **ایمنی**: API keys در environment variables +5. **قابل توسعه**: افزودن provider جدید ساده +6. **شفافیت**: logging و debugging کامل +7. **کارایی**: جلوگیری از recomputation غیرضروری + +این رویکرد نه تنها مشکلات سیستم قدیمی را حل می‌کند، بلکه پایه‌ای محکم برای توسعه آینده سیستم فراهم می‌کند و امکان مقایسه و انتخاب بهترین مدل embedding را آسان می‌سازد. diff --git a/docs/GUARDRAILS.md b/docs/GUARDRAILS.md new file mode 100644 index 0000000..983e913 --- /dev/null +++ b/docs/GUARDRAILS.md @@ -0,0 +1,213 @@ +# سیستم گاردریل (Guardrails System) + +## چرا گاردریل؟ + +ایجنت‌های هوش مصنوعی بدون محدودیت، آسیب‌پذیرند. کاربر می‌تواند: + +- **Prompt Injection**: با دستورات مخفی، رفتار ایجنت را تغییر دهد (مثلاً «دستورات قبلی‌ات را فراموش کن») +- **ورودی بسیار طولانی**: با ارسال متن‌های عظیم، هزینه توکن را بالا ببرد یا مدل را سردرگم کند +- **محتوای نامناسب**: متن‌های توهین‌آمیز یا خارج از چارچوب ادب اسلامی ارسال کند + +**گاردریل‌ها** لایه‌های دفاعی هستند که **قبل از رسیدن ورودی به مدل**، آن را بررسی می‌کنند و در صورت تخلف، درخواست را رد می‌کنند. + +--- + +## معماری + +``` +ورودی کاربر + │ + ▼ +┌──────────────────────────────────┐ +│ pre_hooks (به ترتیب) │ +│ │ +│ 1. PromptInjectionGuardrail() │ ← گاردریل آماده agno (تشخیص prompt injection) +│ 2. InputLimitGuardrail() │ ← گاردریل سفارشی (محدودیت طول ورودی) +│ 3. rag_injection_hook │ ← تزریق RAG context +│ │ +└──────────────┬───────────────────┘ + │ ✅ ورودی سالم + ▼ +┌──────────────────────────────────┐ +│ LLM (مدل زبانی) │ +└──────────────────────────────────┘ +``` + +گاردریل‌ها در آرایه `pre_hooks` ایجنت تعریف می‌شوند و **به ترتیب** اجرا می‌شوند. اگر هر کدام خطا (`InputCheckError`) بدهند، زنجیره متوقف شده و پیام خطا به کاربر برمی‌گردد — **بدون اینکه مدل فراخوانی شود**. + +--- + +## ساختار پوشه + +``` +src/guardrails/ +├── __init__.py ← ماژول پکیج +└── limit.py ← InputLimitGuardrail (محدودیت طول ورودی) +``` + +--- + +## گاردریل‌های فعال + +### ۱. `PromptInjectionGuardrail` (از کتابخانه agno) + +| آیتم | مقدار | +|------|-------| +| **منبع** | `agno.guardrails.PromptInjectionGuardrail` | +| **هدف** | تشخیص تلاش‌های Prompt Injection | +| **نحوه کار** | با استفاده از مدل LLM، ورودی کاربر را تحلیل می‌کند و اگر دستور مخفی تشخیص دهد، درخواست را رد می‌کند | +| **پیکربندی** | نیازی به تنظیم ندارد — آماده استفاده است | + +```python +from agno.guardrails import PromptInjectionGuardrail +``` + +--- + +### ۲. `InputLimitGuardrail` (سفارشی) + +| آیتم | مقدار | +|------|-------| +| **منبع** | `src/guardrails/limit.py` | +| **هدف** | جلوگیری از ارسال ورودی‌های بسیار طولانی | +| **حد پیش‌فرض** | ۲۰۰۰ کاراکتر | +| **ویژگی خاص** | پیام خطا را **به زبان کاربر** تولید می‌کند | + +#### نحوه کار + +``` +ورودی کاربر + │ + ▼ +آیا طول ورودی > max_chars؟ + │ + ├─ خیر → ادامه pipeline ✅ + │ + └─ بله → تولید پیام خطا: + 1. ۲۰۰ کاراکتر اول ورودی استخراج می‌شود (نمونه) + 2. با یک prompt به LLM فرستاده می‌شود: + «زبان این متن را تشخیص بده و پیام خطای مؤدبانه بنویس» + 3. پیام خطا به زبان کاربر تولید و برگردانده می‌شود + 4. InputCheckError پرتاب می‌شود → pipeline متوقف +``` + +#### پشتیبانی Sync و Async + +این گاردریل هر دو حالت را پشتیبانی می‌کند: + +| متد | کاربرد | +|-----|--------| +| `check()` | برای فراخوانی‌های sync (مثل `agent.run()`) | +| `async_check()` | برای فراخوانی‌های async (مثل `await agent.arun()`) | + +اگر فراخوانی LLM برای تولید پیام خطا شکست بخورد، یک پیام پیش‌فرض انگلیسی برمی‌گردد: + +``` +⚠️ Input too long. Max 2000 chars. +``` + +--- + +## نحوه اتصال به ایجنت + +گاردریل‌ها در `src/agents/base_agent.py` درون آرایه `pre_hooks` تعریف شده‌اند: + +```python +self.agent = ContextAwareAgent( + ... + pre_hooks=[ + PromptInjectionGuardrail(), # گاردریل ۱: ضد prompt injection + InputLimitGuardrail(), # گاردریل ۲: محدودیت طول ورودی + rag_injection_hook, # هوک RAG (گاردریل نیست) + ], + ... +) +``` + +> **نکته:** ترتیب مهم است. ابتدا prompt injection بررسی می‌شود، سپس طول ورودی، و در نهایت اگر ورودی سالم بود، RAG context تزریق می‌شود. + +--- + +## نحوه اضافه کردن گاردریل جدید + +### مرحله ۱: ساخت فایل گاردریل + +یک فایل جدید در `src/guardrails/` بسازید، مثلاً `profanity.py`: + +```python +from agno.guardrails.base import BaseGuardrail +from agno.run.agent import RunInput +from agno.exceptions import CheckTrigger, InputCheckError + + +class ProfanityGuardrail(BaseGuardrail): + """گاردریل برای فیلتر محتوای نامناسب""" + + def __init__(self): + super().__init__() + self.name = "Profanity Filter Guardrail" + + def check(self, run_input: RunInput) -> None: + """بررسی sync — اگر مشکلی بود InputCheckError پرتاب کنید""" + text = run_input.input_content + if self._contains_profanity(text): + raise InputCheckError( + "محتوای نامناسب تشخیص داده شد.", + check_trigger=CheckTrigger.INPUT_NOT_ALLOWED, + ) + + async def async_check(self, run_input: RunInput) -> None: + """بررسی async — همان منطق check ولی async""" + text = run_input.input_content + if self._contains_profanity(text): + raise InputCheckError( + "محتوای نامناسب تشخیص داده شد.", + check_trigger=CheckTrigger.INPUT_NOT_ALLOWED, + ) + + def _contains_profanity(self, text: str) -> bool: + # منطق تشخیص محتوای نامناسب + ... +``` + +#### قوانین کلیدی: + +| قانون | توضیح | +|-------|-------| +| از `BaseGuardrail` ارث‌بری کنید | کلاس پایه agno | +| متد `check()` را پیاده‌سازی کنید | برای فراخوانی‌های sync | +| متد `async_check()` را پیاده‌سازی کنید | برای فراخوانی‌های async | +| `InputCheckError` پرتاب کنید | برای رد کردن ورودی | +| `CheckTrigger.INPUT_NOT_ALLOWED` استفاده کنید | نوع خطا | + +### مرحله ۲: اضافه کردن به ایجنت + +در `src/agents/base_agent.py`: + +```python +from src.guardrails.profanity import ProfanityGuardrail + +# در تعریف ایجنت: +pre_hooks=[ + PromptInjectionGuardrail(), + InputLimitGuardrail(), + ProfanityGuardrail(), # ← گاردریل جدید + rag_injection_hook, # ← همیشه آخر باشد +], +``` + +> **نکته مهم:** `rag_injection_hook` همیشه باید **آخرین** آیتم در `pre_hooks` باشد، چون ورودی را تغییر می‌دهد و گاردریل‌ها باید ورودی اصلی کاربر را بررسی کنند. + +--- + +## خلاصه + +| گاردریل | فایل | هدف | نوع | +|---------|------|------|-----| +| `PromptInjectionGuardrail` | `agno.guardrails` | ضد prompt injection | آماده (built-in) | +| `InputLimitGuardrail` | `src/guardrails/limit.py` | محدودیت طول ورودی | سفارشی | + +| پارامتر ایجنت | مقدار | نقش | +|---------------|-------|-----| +| `pre_hooks` | آرایه‌ای از گاردریل‌ها و هوک‌ها | اجرای ترتیبی قبل از مدل | + diff --git a/docs/LANGFUSE_TRACING.md b/docs/LANGFUSE_TRACING.md new file mode 100644 index 0000000..41d3e5e --- /dev/null +++ b/docs/LANGFUSE_TRACING.md @@ -0,0 +1,395 @@ +# اتصال Langfuse و سیستم Tracing + +## مقدمه + +در پروژه‌های مبتنی بر LLM، **مشاهده‌پذیری (Observability)** اهمیت بالایی دارد. بدون آن نمی‌دانیم: +- هر درخواست چقدر توکن مصرف کرده و هزینه واقعی چقدر بوده +- چه داده‌ای از RAG به مدل رسیده +- کدام کاربر و Session چه سوالی پرسیده +- کیفیت پاسخ‌ها در طول زمان چگونه بوده + +**Langfuse** یک پلتفرم Observability مخصوص LLM است که امکان ثبت trace، محاسبه هزینه و امتیازدهی به پاسخ‌ها را فراهم می‌کند. + +### مشکل اصلی + +وقتی از API های مدل‌های زبان به صورت **Streaming** استفاده می‌کنیم، بسیاری از API ها (مثل DeepSeek از طریق OpenRouter) مقدار `usage` (تعداد توکن مصرفی) را **صفر** برمی‌گردانند. این یعنی Langfuse هیچ اطلاعات هزینه‌ای ثبت نمی‌کند. + +همچنین، prompt واقعی که به مدل ارسال می‌شود فقط سوال کوتاه کاربر نیست. بلکه شامل **System Prompt + RAG Context + سوال کاربر** است. بدون ثبت این اطلاعات، تصویر واقعی هزینه و عملکرد سیستم قابل مشاهده نیست. + +### راه‌حل: TracingAgent + +کلاس `TracingAgent` به عنوان یک **"Smart Interceptor"** عمل می‌کند. این کلاس بین API Route و منطق اصلی AI قرار می‌گیرد و سه مشکل اساسی را حل می‌کند: + +1. **Observability**: تزریق `user_id` و `session_id` به Langfuse +2. **Accuracy**: رفع باگ "0 Token Usage" با شمارش دستی توکن‌ها توسط `tiktoken` +3. **Completeness**: ثبت prompt واقعی RAG (نه فقط سوال کوتاه کاربر) برای محاسبه هزینه واقعی + +## معماری + +### جایگاه TracingAgent در سیستم + +```mermaid +graph TD + A["درخواست کاربر (API)"] --> B["TracingAgent.arun()"] + + subgraph "TracingAgent - Smart Interceptor" + B --> C["ثبت user_id / session_id"] + C --> D["Agent اصلی اجرا می‌شود"] + D --> E["pre_hooks: guardrails + config + RAG"] + E --> F["مدل زبان (LLM)"] + F --> G["پاسخ Streaming به کاربر"] + G --> H["شمارش توکن‌ها با tiktoken"] + H --> I["ارسال داده‌ها به Langfuse"] + I --> J["امتیازدهی خودکار"] + end + + K["rag_prompt_var (ContextVar)"] -.->|انتقال RAG prompt| H +``` + +### جریان داده + +``` +درخواست API + │ + ▼ +┌─────────────────────────────────────┐ +│ TracingAgent.arun() │ +│ ───────────────────────────────── │ +│ 1. ذخیره user_id, session_id │ +│ 2. ذخیره input_message │ +│ 3. ذخیره system_prompt │ +│ 4. ریست rag_prompt_var │ +└───────────────┬─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ _stream_wrapper() [@observe] │ +│ ───────────────────────────────── │ +│ • تگ‌گذاری trace با user/session │ +│ • اجرای super().arun (Agent اصلی) │ +│ • جمع‌آوری chunk ها │ +│ • تشخیص RunCompleted │ +│ • yield هر chunk به کاربر │ +└───────────────┬─────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ محاسبه و ارسال به Langfuse │ +│ ───────────────────────────────── │ +│ • خواندن rag_prompt_var │ +│ • شمارش توکن input + output │ +│ • ارسال usage به Langfuse │ +│ • امتیازدهی (scoring) │ +└─────────────────────────────────────┘ +``` + +## پیاده‌سازی + +### فایل‌های مرتبط + +| فایل | مسئولیت | +|------|---------| +| `src/agents/tracing_agent.py` | کلاس TracingAgent - interceptor اصلی | +| `src/agents/base_agent.py` | ساخت Agent با TracingAgent به جای Agent معمولی | +| `src/utils/shared_context.py` | متغیر `rag_prompt_var` برای انتقال RAG prompt بین لایه‌ها | +| `src/utils/search_knowledge.py` | ذخیره prompt نهایی RAG در `rag_prompt_var` | +| `src/models/factory.py` | ساخت مدل زبان از فایل تنظیمات | + +### بخش ۱: ابزارهای پایه و مقداردهی اولیه + +فایل: `src/agents/tracing_agent.py` + +```python +import tiktoken +from langfuse import Langfuse +from langfuse.decorators import observe, langfuse_context +from src.utils.shared_context import rag_prompt_var + +langfuse_client = Langfuse() +``` + +| ابزار | نقش | +|-------|-----| +| `tiktoken` | **شمارنده توکن**: چون API در حالت streaming مقدار usage را صفر برمی‌گرداند، خودمان توکن‌ها را می‌شماریم | +| `rag_prompt_var` | **پل ارتباطی**: جستجوی RAG در عمق hook ها اتفاق می‌افتد، اما باید توکن‌هایش را در TracingAgent (لایه بالاتر) بشماریم. این `ContextVar` داده را بین این دو لایه منتقل می‌کند | +| `langfuse_client` | **کلاینت اصلی**: `langfuse_context` (دکوراتور) از scoring پشتیبانی نمی‌کند، بنابراین یک client کامل برای ارسال score ها ساخته می‌شود | + +### بخش ۲: شمارنده توکن + +```python +def _count_tokens(self, text: str, model: str = "gpt-4o") -> int: + try: + encoding = tiktoken.encoding_for_model(model) + except KeyError: + encoding = tiktoken.get_encoding("cl100k_base") + return len(encoding.encode(text)) +``` + +**Fallback**: اگر مدل ناشناخته باشد (مثلاً `deepseek-v3`)، از `cl100k_base` استفاده می‌شود. این tokenizer استاندارد GPT-4 است و به عنوان تخمین صنعتی برای اکثر مدل‌ها مناسب است. + +### بخش ۳: نقطه ورود Polymorphic - متد `arun` + +```python +# بدون async! +def arun(self, *args, **kwargs): +``` + +**چرا `def` معمولی و نه `async def`؟** + +روتر Agno خیلی سخت‌گیر است: +- اگر `stream=True` باشد، یک **Generator** (چیزی که بتوان رویش loop زد) انتظار دارد +- اگر `stream=False` باشد، یک **Coroutine** (چیزی که بتوان `await` کرد) انتظار دارد + +با تعریف `arun` به صورت `def` معمولی (نه `async def`)، می‌توانیم **داخل تابع تصمیم بگیریم** کدام نوع را برگردانیم. اگر `async def` بود، Python آن را خودکار به coroutine تبدیل می‌کرد و در حالت streaming با خطا مواجه می‌شدیم. + +### بخش ۴: پیش‌پردازش ورودی + +```python +input_message = "" +if "message" in kwargs: input_message = kwargs["message"] +elif args: input_message = args[0] + +system_prompt = "\n".join(self.instructions) if self.instructions else "" +rag_prompt_var.set("") # ریست +``` + +| عملیات | توضیح | +|--------|-------| +| **Input Capture** | پیام کاربر فوراً ذخیره می‌شود تا برای logging آماده باشد | +| **System Prompt** | دستورالعمل‌های Agent (Adab، Culture و ...) برای محاسبه هزینه استخراج می‌شوند | +| **Reset** | `rag_prompt_var` پاک می‌شود تا داده درخواست قبلی اشتباهاً استفاده نشود | + +### بخش ۵: Streaming Wrapper - هسته اصلی + +این بخش کل فرایند streaming را درون یک **Langfuse Observation** قرار می‌دهد. + +#### الف - تگ‌گذاری Trace + +```python +@observe(as_type="generation", name="Islamic Scholar Stream") +async def _stream_wrapper(): + if user_id: langfuse_context.update_current_trace(user_id=str(user_id)) + if session_id: langfuse_context.update_current_trace(session_id=str(session_id)) +``` + +بدون این مرحله، trace ها در Langfuse **ناشناس** می‌مانند. با تگ‌گذاری، می‌توان مصرف هر کاربر و هر session را جداگانه دید. + +#### ب - حلقه De-Duplication + +```python +async for chunk in super(TracingAgent, self).arun(*args, **kwargs): + event_type = getattr(chunk, "event", "") + content = getattr(chunk, "content", "") or "" + + if event_type == "RunCompleted": + final_event_content = content # متن نهایی تمیز + elif isinstance(content, str) and content: + full_content += content # تکه‌های کوچک جمع می‌شوند + yield chunk +``` + +**مشکل**: Agno پاسخ را به صورت تکه‌های کوچک stream می‌کند (مثلاً `"سلا"`, `"م "`, `"علی"`, `"کم"`) و سپس یک event نهایی `RunCompleted` ارسال می‌کند که متن کامل و تمیز را دارد (`"سلام علیکم"`). + +**راه‌حل**: همه chunk ها را به کاربر `yield` می‌کنیم (تجربه real-time حفظ شود)، اما برای ثبت در Langfuse از `final_event_content` استفاده می‌کنیم چون تمیزتر است و از تکرار متن در log جلوگیری می‌کند. + +### بخش ۶: محاسبه توکن و ارسال به Langfuse + +```python +# 1. خواندن RAG prompt از ContextVar +actual_rag_prompt = rag_prompt_var.get() + +# 2. بازسازی prompt واقعی +if actual_rag_prompt: + full_input_text = f"{system_prompt}\n{actual_rag_prompt}" +else: + full_input_text = f"{system_prompt}\n{input_message}" + +# 3. محاسبه توکن‌ها +input_count = self._count_tokens(full_input_text) +output_count = self._count_tokens(final_output) +total_count = input_count + output_count + +# 4. ارسال به Langfuse +update_payload = { + "output": final_output, + "input": actual_rag_prompt if actual_rag_prompt else input_message, + "usage": { + "input": input_count, + "output": output_count, + "total": total_count + }, + "model": "deepseek-ai/deepseek-v3.1" +} +langfuse_context.update_current_observation(**update_payload) +``` + +#### مکانیزم `rag_prompt_var` - پل ارتباطی بین لایه‌ها + +این یکی از مهم‌ترین بخش‌های طراحی است. مسئله اینجاست: + +- **RAG prompt** در `build_rag_prompt()` (داخل `rag_injection_hook`، در عمق Agent) ساخته می‌شود +- **شمارش توکن** باید در `TracingAgent` (لایه بیرونی، بالاتر از Agent) انجام شود + +این دو لایه مستقیماً به هم دسترسی ندارند. `ContextVar` یک متغیر **thread-safe و async-safe** است که داده را بین لایه‌های مختلف یک request منتقل می‌کند. + +فایل: `src/utils/shared_context.py` + +```python +from contextvars import ContextVar +rag_prompt_var = ContextVar("rag_prompt_var", default="") +``` + +فایل: `src/utils/search_knowledge.py` (انتهای تابع `build_rag_prompt`) + +```python +# ذخیره prompt نهایی در ContextVar +rag_prompt_var.set(final_prompt) +return final_prompt +``` + +#### چرا `ContextVar` و نه یک متغیر global ساده؟ + +| | متغیر global | ContextVar | +|--|-------------|------------| +| **thread-safe** | خیر - در درخواست‌های همزمان داده قاطی می‌شود | بله - هر request مقدار مستقل خود را دارد | +| **async-safe** | خیر | بله - با `asyncio` سازگار است | +| **مناسب وب‌سرور** | خیر | بله | + +#### محاسبه هزینه واقعی + +``` +هزینه واقعی = tokens(System Prompt + RAG Context + سوال کاربر) + tokens(پاسخ مدل) +``` + +بدون TracingAgent، Langfuse فقط سوال کوتاه کاربر (مثلاً "حکم روزه مسافر چیست؟") را می‌بیند. اما prompt واقعی شامل System Prompt (دستورالعمل‌های Adab، Culture و ...)، context بازیابی شده از RAG (ممکن است هزاران توکن باشد) و سوال کاربر است. + +### بخش ۷: امتیازدهی خودکار (Scoring) + +```python +current_trace_id = langfuse_context.get_current_trace_id() +if current_trace_id: + langfuse_client.score( + trace_id=current_trace_id, + name="completeness", + value=1.0 if len(final_output) > 50 else 0.0, + comment="Auto-scored by TracingAgent" + ) +``` + +چون داخل wrapper هستیم، Trace ID در دسترس است. از `langfuse_client` (نه `langfuse_context`) برای ارسال score استفاده می‌شود. + +**منطق فعلی**: اگر طول پاسخ بیشتر از 50 کاراکتر باشد، امتیاز 1.0 (کامل) و در غیر این صورت 0.0 (ناقص). این منطق ساده قابل جایگزینی با بررسی‌های پیچیده‌تر است (مثلاً بررسی regex، وجود کلمات کلیدی، یا حتی ارزیابی توسط یک مدل دیگر). + +## نحوه اتصال TracingAgent به سیستم + +### ثبت در Agent + +فایل: `src/agents/base_agent.py` + +```python +from src.agents.tracing_agent import TracingAgent + +class IslamicScholarAgent: + def __init__(self, model, knowledge_base, custom_instructions=None, db_url=None): + # ... + self.agent = TracingAgent( # ← به جای Agent معمولی + name="Islamic Scholar Agent", + model=model, + instructions=self.custom_instructions, + pre_hooks=[ + PromptInjectionGuardrail(), + InputLimitGuardrail(), + sync_config_hook, + rag_injection_hook, + ], + # ... + ) +``` + +**نکته کلیدی**: `TracingAgent` جایگزین `Agent` شده. تمام عملکرد Agent حفظ می‌شود، فقط لایه tracing روی آن اضافه شده. + +### زنجیره اجرا + +``` +API Route + │ POST /agents/islamic-scholar-agent/runs + ▼ +TracingAgent.arun() + │ ذخیره metadata + ریست rag_prompt_var + ▼ +_stream_wrapper() [@observe → Langfuse trace] + │ تگ‌گذاری user_id / session_id + ▼ +super().arun() → Agent اصلی Agno + │ + ├── pre_hooks: + │ ├── PromptInjectionGuardrail + │ ├── InputLimitGuardrail + │ ├── sync_config_hook (پرامپت از دیتابیس) + │ └── rag_injection_hook → build_rag_prompt() + │ └── rag_prompt_var.set(final_prompt) ← ذخیره در ContextVar + │ + ├── LLM Call (streaming) + │ └── chunks → yield به کاربر + │ + ▼ +پس از اتمام stream: + ├── rag_prompt_var.get() ← خواندن RAG prompt + ├── tiktoken: شمارش توکن input + output + ├── langfuse_context.update_current_observation(usage=...) + └── langfuse_client.score(completeness=...) +``` + +## تنظیمات محیطی + +| متغیر | توضیح | الزامی | +|--------|--------|--------| +| `LANGFUSE_PUBLIC_KEY` | کلید عمومی Langfuse | بله | +| `LANGFUSE_SECRET_KEY` | کلید خصوصی Langfuse | بله | +| `LANGFUSE_HOST` | آدرس سرور Langfuse | بله | + +Langfuse client به صورت خودکار این متغیرها را از محیط می‌خواند. + +## داده‌هایی که در Langfuse ثبت می‌شوند + +### هر Trace شامل: + +| فیلد | مقدار | منبع | +|------|-------|------| +| `user_id` | شناسه کاربر | از API request | +| `session_id` | شناسه نشست | از API request | +| `input` | prompt واقعی (RAG + سوال) | از `rag_prompt_var` | +| `output` | پاسخ نهایی مدل | از `RunCompleted` event | +| `usage.input` | تعداد توکن ورودی | محاسبه با `tiktoken` | +| `usage.output` | تعداد توکن خروجی | محاسبه با `tiktoken` | +| `usage.total` | مجموع توکن‌ها | `input + output` | +| `model` | نام مدل | تنظیم دستی | + +### هر Score شامل: + +| فیلد | مقدار | +|------|-------| +| `name` | `completeness` | +| `value` | `1.0` (اگر طول > 50) یا `0.0` | +| `comment` | `Auto-scored by TracingAgent` | + +## خلاصه مشکلات حل‌شده + +| مشکل | بدون TracingAgent | با TracingAgent | +|------|-------------------|-----------------| +| Token usage در streaming | صفر (0) | محاسبه دقیق با tiktoken | +| هویت کاربر در trace | ناشناس | user_id + session_id ثبت می‌شود | +| ورودی واقعی مدل | فقط سوال کوتاه کاربر | System Prompt + RAG Context + سوال | +| هزینه واقعی | نامشخص | محاسبه بر اساس توکن واقعی | +| کیفیت پاسخ | غیرقابل سنجش | امتیازدهی خودکار | +| داده‌های تکراری در log | stream chunks + final event | فقط متن تمیز نهایی | + +## نتیجه‌گیری + +`TracingAgent` به عنوان یک لایه شفاف بین API و Agent عمل می‌کند و بدون تغییر در رفتار اصلی Agent، سه مشکل حیاتی را حل می‌کند: + +1. **Observability**: هر درخواست با هویت کاربر و session در Langfuse ثبت می‌شود +2. **Accuracy**: باگ "0 Token Usage" در streaming با شمارش دستی توسط `tiktoken` رفع می‌شود +3. **Completeness**: با استفاده از `rag_prompt_var` (ContextVar)، prompt واقعی RAG (که ممکن است هزاران توکن باشد) ثبت می‌شود و هزینه واقعی هر درخواست قابل محاسبه است + +این طراحی قابل گسترش است: می‌توان منطق scoring را پیچیده‌تر کرد، مدل‌های بیشتری را پشتیبانی کرد، یا metric های سفارشی دیگری به Langfuse اضافه کرد. diff --git a/docs/LLM_MODEL_MANAGEMENT.md b/docs/LLM_MODEL_MANAGEMENT.md new file mode 100644 index 0000000..90539f7 --- /dev/null +++ b/docs/LLM_MODEL_MANAGEMENT.md @@ -0,0 +1,508 @@ +# مدیریت مدل‌های LLM در سیستم Islamic Scholar Agent + +## 📋 نمای کلی + +سیستم مدیریت مدل‌های LLM در پروژه Islamic Scholar Agent به صورت متمرکز و قابل تنظیم طراحی شده است. این سیستم جایگزین رویکرد قدیمی hardcoded مدل‌ها شده و امکان تغییر سریع بین مدل‌های مختلف، افزودن provider جدید و مدیریت تنظیمات را فراهم می‌کند. + +## 🎯 چرایی این سیستم + +### مشکل رویکرد قدیمی + +در گذشته، مدل‌های LLM به صورت مستقیم در کد استفاده می‌شدند: + +```python +# ❌ رویکرد قدیمی - hardcoded +from agno.models.openrouter import OpenRouter +agent = Agent( + model=OpenRouter(id="deepseek/deepseek-r1-0528:free"), + ... +) +``` + +این رویکرد مشکلات زیر را داشت: +- **عدم انعطاف‌پذیری**: تغییر مدل نیازمند تغییر کد بود +- **پراکندگی تنظیمات**: هر مدل تنظیمات خودش را داشت +- **مشکل در تست**: نمی‌توان به راحتی بین مدل‌ها سوییچ کرد +- **مدیریت API keys**: کلیدها در کد پراکنده بودند + +### راه‌حل جدید + +```python +# ✅ رویکرد جدید - متمرکز و قابل تنظیم +from src.models.factory import ModelFactory + +model_factory = ModelFactory() +agent = Agent( + model=model_factory.get_model(), # استفاده از مدل پیش‌فرض + # یا + model=model_factory.get_model('gpt4'), # تغییر به مدل دیگر + ... +) +``` + +## 🏗️ معماری سیستم + +### ساختار فایل‌ها + +``` +config/ +├── models.yaml # تنظیمات متمرکز مدل‌ها + +src/models/ +├── base_model.py # کلاس پایه abstract +├── factory.py # کارخانه مدل‌ها +└── ... # implementهای خاص هر provider +``` + +### فایل‌های کلیدی + +#### 1. `config/models.yaml` + +فایل تنظیمات مرکزی که همه مدل‌ها و providerها را تعریف می‌کند: + +```yaml +# config/models.yaml +models: + # سوئیچ اصلی - تغییر این مقدار = تغییر مدل کل سیستم + default: deepseek_r1 + + providers: + # Provider 1: MegaLLM (OpenAI-Like) + openai_like: + api_key: ${MEGALLM_API_KEY} + base_url: ${API_URL} + models: + deepseek_v3: + id: "deepseek-ai/deepseek-v3.1" + temperature: 0.7 + max_tokens: 4096 + supports_streaming: true + + # Provider 2: OpenRouter + openrouter: + api_key: ${OPENROUTER_API_KEY} + base_url: ${OPENROUTER_BASE_URL} + models: + deepseek_r1: + id: "deepseek/deepseek-r1-0528:free" + temperature: 0.6 + max_tokens: 4096 + +# Rate limiting +rate_limits: + requests_per_minute: 60 + tokens_per_minute: 100000 +``` + +#### 2. `src/models/base_model.py` + +کلاس پایه abstract برای همه providerها: + +```python +from abc import ABC, abstractmethod +from typing import Optional +import os + +class BaseLLMProvider(ABC): + """Abstract base class for LLM providers""" + + def __init__(self, api_key: str, base_url: Optional[str] = None): + self.api_key = api_key + self.base_url = base_url + + @abstractmethod + def get_model(self): + """Return configured model instance""" + pass +``` + +#### 3. `src/models/factory.py` + +کارخانه اصلی که مدل‌ها را از تنظیمات ایجاد می‌کند: + +```python +import os +import yaml +from typing import Optional +from agno.models.openrouter import OpenRouter +from agno.models.openai.like import OpenAILike + +class ModelFactory: + """Factory for creating LLM instances from configuration""" + + def __init__(self, config_path: str = "config/models.yaml"): + # بارگذاری YAML + with open(config_path) as f: + self.config = yaml.safe_load(f) + + def get_model(self, model_name: Optional[str] = None): + # 1. تعیین مدل مورد استفاده + if model_name is None: + model_name = self.config['models']['default'] + print(f"Using default model: {model_name}") + + # 2. جستجو در همه providerها + providers = self.config['models']['providers'] + for provider_name, provider_data in providers.items(): + if model_name in provider_data['models']: + # یافت شد! + print(f"Found model: {model_name} in provider: {provider_name}") + model_config_data = provider_data['models'][model_name] + + # Resolve environment variables + api_key_env = provider_data.get('api_key') + if api_key_env and api_key_env.startswith("${"): + api_key = os.getenv(api_key_env[2:-1]) + else: + api_key = api_key_env + + base_url_env = provider_data.get('base_url') + if base_url_env and base_url_env.startswith("${"): + base_url = os.getenv(base_url_env[2:-1]) + else: + base_url = base_url_env + + # 3. ایجاد کلاس مناسب + if provider_name == 'openai_like': + return OpenAILike( + id=model_config_data['id'], + api_key=api_key, + base_url=base_url, + max_tokens=model_config_data.get('max_tokens', 4096), + default_headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + ) + + elif provider_name == 'openrouter': + return OpenRouter( + id=model_config_data['id'], + api_key=api_key, + base_url=base_url, + default_headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + ) + + raise ValueError(f"Model '{model_name}' not found in configuration") +``` + +## 🚀 نحوه استفاده + +### استفاده پایه + +```python +# src/main.py +from src.models.factory import ModelFactory + +def create_app(): + # ایجاد factory + model = ModelFactory() + + # استفاده از مدل پیش‌فرض + agent = IslamicScholarAgent(model.get_model(), knowledge_base) + + return agent +``` + +### تغییر مدل در زمان اجرا + +```python +# تغییر به مدل دیگر +gpt4_agent = IslamicScholarAgent(model_factory.get_model('gpt4'), knowledge_base) + +# یا تغییر مدل پیش‌فرض در YAML +# models.yaml -> default: gpt4 +``` + +### افزودن مدل جدید + +#### مرحله 1: افزودن به YAML + +```yaml +# config/models.yaml +models: + providers: + openai: + api_key: ${OPENAI_API_KEY} + base_url: https://api.openai.com/v1 + models: + gpt4: + id: "gpt-4o" + temperature: 0.7 + max_tokens: 4096 + supports_streaming: true +``` + +#### مرحله 2: افزودن logic به factory + +```python +# src/models/factory.py +elif provider_name == 'openai': + return OpenAIChat( + id=model_config_data['id'], + api_key=provider_config['api_key'], + temperature=model_config_data.get('temperature', 0.7), + max_tokens=model_config_data.get('max_tokens', 4096), + ) +``` + +## ✅ مزایای این سیستم + +### 1. **سوئیچ سریع بین مدل‌ها** + +```python +# تغییر از DeepSeek به GPT-4 فقط با یک خط تغییر در YAML +# config/models.yaml +default: gpt4 # تغییر از deepseek_r1 +``` + +### 2. **مدیریت متمرکز تنظیمات** + +- همه تنظیمات در یک فایل YAML +- تغییر تنظیمات بدون تغییر کد +- امکان override توسط environment variables + +### 3. **امکان A/B Testing** + +```python +# تست دو مدل مختلف در کنار هم +model_a = model_factory.get_model('deepseek_r1') +model_b = model_factory.get_model('gpt4') + +# مقایسه عملکرد دو مدل +``` + +### 4. **مدیریت آسان API Keys** + +```bash +# تنظیم متغیرهای محیطی +export MEGALLM_API_KEY="your_key_here" +export OPENROUTER_API_KEY="your_key_here" +export OPENAI_API_KEY="your_key_here" +``` + +### 5. **افزودن Provider جدید بدون تغییر کد موجود** + +```yaml +# افزودن provider جدید به YAML +providers: + anthropic: + api_key: ${ANTHROPIC_API_KEY} + models: + claude3: + id: "claude-3-sonnet-20240229" + temperature: 0.7 +``` + +### 6. **امکان توسعه‌پذیری** + +- هر provider می‌تواند تنظیمات خاص خودش را داشته باشد +- امکان افزودن validation خاص هر مدل +- پشتیبانی از rate limiting متمرکز + +### 7. **تسهیل Testing** + +```python +# استفاده از مدل‌های mock/test در محیط تست +# config/models.test.yaml +models: + default: test_model + providers: + test: + models: + test_model: + id: "test-model" + temperature: 0.0 +``` + +## 🔧 تنظیمات پیشرفته + +### Rate Limiting + +```yaml +# config/models.yaml +rate_limits: + requests_per_minute: 60 + tokens_per_minute: 100000 +``` + +### تنظیمات مدل خاص + +```yaml +models: + deepseek_r1: + id: "deepseek/deepseek-r1-0528:free" + temperature: 0.6 # کنترل خلاقیت + max_tokens: 4096 # حداکثر طول پاسخ + supports_streaming: true # پشتیبانی از streaming +``` + +### Environment Variables + +```bash +# .env +MEGALLM_API_KEY=sk-... +OPENROUTER_API_KEY=sk-or-... +OPENAI_API_KEY=sk-... +``` + +## 🧪 تست سیستم + +### تست‌های واحد + +```python +# tests/test_models.py +def test_model_factory_default(): + factory = ModelFactory() + model = factory.get_model() + assert model.id == "deepseek/deepseek-r1-0528:free" + +def test_model_factory_specific(): + factory = ModelFactory() + model = factory.get_model('gpt4') + assert model.id == "gpt-4o" +``` + +### تست‌های integration + +```python +# tests/test_agent_with_models.py +@pytest.mark.parametrize("model_name", ["deepseek_r1", "gpt4"]) +def test_agent_with_different_models(model_name): + factory = ModelFactory() + model = factory.get_model(model_name) + + agent = IslamicScholarAgent(model, knowledge_base) + + response = agent.run("What is salah?") + assert len(response.content) > 0 +``` + +## 🚨 عیب‌یابی + +### مشکلات رایج + +#### 1. Model not found + +``` +ValueError: Model 'unknown_model' not found in configuration +``` + +**راه‌حل**: بررسی کنید مدل در `config/models.yaml` تعریف شده باشد. + +#### 2. API Key not set + +``` +Error: API key for provider 'openrouter' not found +``` + +**راه‌حل**: متغیر محیطی مربوطه را تنظیم کنید. + +#### 3. Invalid YAML syntax + +``` +yaml.YAMLError: mapping values are not allowed here +``` + +**راه‌حل**: syntax YAML را بررسی کنید. + +### Debug Mode + +```python +# فعال کردن debug در factory +import logging +logging.basicConfig(level=logging.DEBUG) + +factory = ModelFactory() +model = factory.get_model() # لاگ‌های مفصل نمایش داده می‌شود +``` + +## 📈 مانیتورینگ و Observability + +سیستم با Langfuse integration می‌تواند عملکرد مدل‌ها را مانیتور کند: + +- تعداد استفاده از هر مدل +- زمان پاسخ مدل‌ها +- هزینه‌های API +- نرخ خطای هر مدل + +## 🔄 مهاجرت از سیستم قدیمی + +### قبل از تغییر + +```python +# کد قدیمی +agent = Agent(model=OpenRouter(id="deepseek/deepseek-r1-0528:free")) +``` + +### بعد از تغییر + +```python +# کد جدید +from src.models.factory import ModelFactory + +model_factory = ModelFactory() +agent = Agent(model=model_factory.get_model('deepseek_r1')) +``` + +### مهاجرت تدریجی + +1. ایجاد `ModelFactory` کنار کد قدیمی +2. تغییر gradual agentها به استفاده از factory +3. حذف کد قدیمی پس از تست کامل + +## 📚 بهترین تجربیات (Best Practices) + +### 1. **استفاده از Environment Variables** + +همیشه از environment variables برای API keys استفاده کنید: + +```yaml +api_key: ${OPENROUTER_API_KEY} # ✅ خوب +api_key: sk-or-... # ❌ بد +``` + +### 2. **نام‌گذاری معنادار مدل‌ها** + +```yaml +models: + deepseek_r1: # ✅ واضح و معنادار + model1: # ❌ نام‌گذار بی‌معنا +``` + +### 3. **Validation تنظیمات** + +```python +# اضافه کردن validation در factory +def _validate_config(self): + """Validate configuration on load""" + required_keys = ['models', 'providers'] + for key in required_keys: + if key not in self.config: + raise ValueError(f"Missing required config key: {key}") +``` + +### 4. **Caching مدل‌ها** + +```python +# Cache مدل‌ها برای جلوگیری از recreate مکرر +@lru_cache(maxsize=10) +def get_model(self, model_name: Optional[str] = None): + # ... logic +``` + +## 🎯 نتیجه‌گیری + +این سیستم مدیریت مدل‌های LLM مزایای زیر را فراهم می‌کند: + +1. **انعطاف‌پذیری بالا**: تغییر مدل‌ها بدون تغییر کد +2. **مدیریت متمرکز**: همه تنظیمات در یک مکان +3. **تسهیل توسعه**: افزودن مدل جدید ساده +4. **امکان تست**: A/B testing و مقایسه مدل‌ها +5. **امنیت بهتر**: API keys در environment variables +6. **قابل نگهداری**: کد تمیز و سازمان‌یافته + +این رویکرد نه تنها مشکلات سیستم قدیمی را حل می‌کند، بلکه پایه‌ای محکم برای توسعه آینده سیستم فراهم می‌کند. diff --git a/docs/QDRANT_CONNECTION_TEST.md b/docs/QDRANT_CONNECTION_TEST.md new file mode 100644 index 0000000..a153593 --- /dev/null +++ b/docs/QDRANT_CONNECTION_TEST.md @@ -0,0 +1,307 @@ +# راهنمای تست اتصال به Qdrant Vector Database + +## نمای کلی + +این سند راهنمای کاملی برای تست اتصال به پایگاه داده برداری Qdrant در پروژه اسلامی اسکولار است. تست اتصال شامل دو بخش اصلی می‌شود: + +1. **تست مستقل** (`test_qdrant_connection.py`) - برای استفاده عملی +2. **تست‌های pytest** (`tests/integration/test_qdrant_connection.py`) - برای محیط توسعه + +## ساختار تست‌ها + +### ۱. تست‌های واحد (Unit Tests) + +#### `test_qdrant_connection_mock_success` +- **هدف**: تست اتصال موفق با استفاده از Mock +- **ورودی‌ها**: embedder ساختگی، پارامترهای اتصال +- **خروجی مورد انتظار**: + ``` + ✅ تست موفق - بدون خطا + ``` +- **چرا مهم**: تست منطق اتصال بدون نیاز به Qdrant واقعی + +#### `test_qdrant_connection_missing_embedder` +- **هدف**: تست مدیریت خطای embedder خالی +- **ورودی**: فراخوانی تابع بدون embedder +- **خروجی مورد انتظار**: + ``` + ValueError: You must provide an 'embedder' instance to get_qdrant_store! + ``` +- **چرا مهم**: اطمینان از اعتبارسنجی ورودی‌ها + +#### `test_qdrant_connection_missing_env_vars` +- **هدف**: تست رفتار با متغیرهای محیطی خالی +- **ورودی**: متغیرهای محیطی پاک شده +- **خروجی مورد انتظار**: + ``` + ✅ تست موفق - اتصال با پارامترهای مستقیم + ``` +- **چرا مهم**: تست انعطاف‌پذیری تنظیمات + +### ۲. تست‌های یکپارچه‌سازی (Integration Tests) + +#### `test_qdrant_real_connection_success` +- **هدف**: تست اتصال واقعی به Qdrant +- **پیش‌نیاز**: متغیر محیطی `QDRANT_URL` تنظیم شده باشد +- **خروجی مورد انتظار**: + ``` + Loading config from: F:\WORK\CODE\WORK\AGNO-DOVOODI\config\embeddings.yaml + ✅ Embedder loaded: jina-embeddings-v4 + ✅ Vector store initialized + 📊 Found X collections in Qdrant: + 1. collection_name_1 + 2. collection_name_2 + ... + Total collections: X + ``` +- **چرا مهم**: تأیید اتصال واقعی به پایگاه داده + +#### `test_qdrant_real_connection_failure` +- **هدف**: تست رفتار با خطای اتصال +- **پیش‌نیاز**: متغیر محیطی `QDRANT_URL` تنظیم نشده باشد +- **ورودی**: URL نامعتبر +- **خروجی مورد انتظار**: + ``` + httpx.ConnectError: [Errno 11001] getaddrinfo failed + ``` +- **چرا مهم**: اطمینان از مدیریت مناسب خطاهای اتصال + +#### `test_qdrant_collection_operations` +- **هدف**: تست عملیات مجموعه‌ها +- **پیش‌نیاز**: متغیر محیطی `QDRANT_URL` تنظیم شده باشد +- **خروجی مورد انتظار**: + ``` + 📋 Current collections in database (X total): + 1. collection_name_1 + 2. collection_name_2 + ... + ℹ️ Test collection test_operations_jina-embeddings-v4 does not exist (this is normal) + ✅ Verified collection test_operations_jina-embeddings-v4 is not in database + ``` +- **چرا مهم**: تست عملیات مدیریت مجموعه‌ها + +## نحوه اجرای تست‌ها + +### اجرای تست مستقل + +```bash +# از ریشه پروژه +python test_qdrant_connection.py +``` + +**خروجی نمونه موفق**: +``` +🗄️ Qdrant Vector Database Connection Test +Testing connection to Qdrant vector database + +🔧 Checking Environment Configuration... +================================================== +✅ QDRANT_URL: http://127.0.0.1:6333 +✅ QDRANT_API_KEY: qs-8d9f-... +✅ BASE_COLLECTION_NAME: dovodi_collection +✅ JINA_API_KEY: jina_04d... +❌ OPENAI_API_KEY: Not configured + +🔄 Testing Qdrant Basic Connection... +================================================== +📍 Qdrant URL: http://127.0.0.1:6333 +✅ Qdrant HTTP health check: OK (endpoint: /) + +🔄 Testing Qdrant Client Connection... +================================================== +Loading config from: F:\WORK\CODE\WORK\AGNO-DOVOODI\config\embeddings.yaml +✅ Embedder loaded: jina-embeddings-v4 +✅ Vector store initialized +F:\WORK\CODE\WORK\AGNO-DOVOODI\.venv\Lib\site-packages\agno\vectordb\qdrant\qdrant.py:167: UserWarning: Api key is used with an insecure connection. +✅ Client connection successful +📊 Available collections: 9 +📋 Collections: + - islamic_knowledge_free + - test_collection + - dovodi_collection_jina-embeddings-v4 + - islamic_knowledge_jina-embeddings-v4 + - dovodi_collection + ... and 4 more + +🔄 Testing Qdrant Collection Operations... +================================================== +🧪 Testing with collection: test_connection_jina-embeddings-v4 +📂 Collection exists: False +ℹ️ Note: Collection will be created on first vector operation + This is normal behavior for Qdrant +✅ Collection access: OK (collection doesn't exist yet) + +================================================== +📊 TEST RESULTS SUMMARY +================================================== +Environment Configuration: ✅ PASSED +Basic HTTP Connection: ✅ PASSED +Client Connection: ✅ PASSED +Collection Operations: ✅ PASSED + +🎉 ALL TESTS PASSED! +✨ Your Qdrant vector database is connected and ready to use. +``` + +### اجرای تست‌های pytest + +```bash +# اجرای همه تست‌های Qdrant +pytest tests/integration/test_qdrant_connection.py -v + +# اجرای تست‌های واحد فقط +pytest tests/integration/test_qdrant_connection.py -m unit -v + +# اجرای تست‌های یکپارچه‌سازی فقط +pytest tests/integration/test_qdrant_connection.py -m integration -v + +# اجرای تست خاص با جزئیات +pytest tests/integration/test_qdrant_connection.py::TestQdrantConnection::test_qdrant_real_connection_success -v -s +``` + +## تنظیمات محیطی مورد نیاز + +### متغیرهای محیطی اصلی + +```bash +# Qdrant connection +QDRANT_URL=http://127.0.0.1:6333 # آدرس Qdrant +QDRANT_API_KEY=your_api_key # کلید API (اختیاری) + +# Collection settings +BASE_COLLECTION_NAME=dovodi_collection # نام پایه مجموعه + +# Embedding API keys +JINA_API_KEY=your_jina_key # کلید API Jina +OPENAI_API_KEY=your_openai_key # کلید API OpenAI (اختیاری) +``` + +### فایل‌های تنظیمات + +- `config/embeddings.yaml` - تنظیمات embedder +- `config/production.env` - تنظیمات تولید +- `config/development.env` - تنظیمات توسعه + +## انواع خروجی‌ها + +### ✅ خروجی موفق + +``` +================== 5 passed, 1 skipped, 6 warnings in 2.65s =================== +``` + +### ❌ خروجی ناموفق + +``` +FAILED tests/integration/test_qdrant_connection.py::TestQdrantConnection::test_name - Error details +``` + +### ⚠️ خروجی هشدار + +``` +PytestUnknownMarkWarning: Unknown pytest.mark.unit +``` + +## عیب‌یابی مشکلات رایج + +### مشکل: اتصال به Qdrant برقرار نیست + +**علائم**: +``` +❌ Qdrant HTTP health check: FAILED +httpx.ConnectError: [Errno 11001] getaddrinfo failed +``` + +**راه‌حل‌ها**: +1. بررسی اجرای Qdrant: `docker ps | grep qdrant` +2. بررسی URL: `echo $QDRANT_URL` +3. بررسی فایروال و پورت 6333 + +### مشکل: API Key نامعتبر + +**علائم**: +``` +❌ Client connection test failed: Unauthorized +``` + +**راه‌حل‌ها**: +1. بررسی `QDRANT_API_KEY` در `.env` +2. اطمینان از صحت کلید API +3. بررسی تنظیمات امنیتی Qdrant + +### مشکل: Embedder بارگذاری نمی‌شود + +**علائم**: +``` +❌ Embedder loaded: FAILED +``` + +**راه‌حل‌ها**: +1. بررسی `config/embeddings.yaml` +2. بررسی کلیدهای API (JINA_API_KEY) +3. بررسی اتصال اینترنت برای embedderهای API-based + +### مشکل: مجموعه‌ها پاک نمی‌شوند + +**علائم**: +``` +❌ Failed to verify/clean up test collection +``` + +**راه‌حل‌ها**: +1. بررسی مجوزهای نوشتن در Qdrant +2. بررسی فضای دیسک کافی +3. بررسی تنظیمات Qdrant برای عملیات حذف + +## ساختار فایل‌ها + +``` +tests/integration/test_qdrant_connection.py # تست‌های pytest +test_qdrant_connection.py # تست مستقل +docs/QDRANT_CONNECTION_TEST.md # این سند +src/knowledge/vector_store.py # کد اتصال Qdrant +config/embeddings.yaml # تنظیمات embedder +``` + +## نکات مهم + +1. **تست‌های یکپارچه‌سازی نیاز به Qdrant واقعی دارند** +2. **تست‌های واحد از Mock استفاده می‌کنند** +3. **همه مجموعه‌های موجود در خروجی نمایش داده می‌شوند** +4. **خطاها به طور کامل لاگ می‌شوند** +5. **تنظیمات محیطی باید قبل از اجرا بررسی شوند** + +## مثال خروجی کامل + +``` +🗄️ Qdrant Vector Database Connection Test +Testing connection to Qdrant vector database + +🔧 Checking Environment Configuration... +✅ QDRANT_URL: http://127.0.0.1:6333 +✅ QDRANT_API_KEY: qs-8d9f-... +✅ BASE_COLLECTION_NAME: dovodi_collection +✅ JINA_API_KEY: jina_04d... +❌ OPENAI_API_KEY: Not configured + +🔄 Testing Qdrant Basic Connection... +📍 Qdrant URL: http://127.0.0.1:6333 +✅ Qdrant HTTP health check: OK (endpoint: /) + +🔄 Testing Qdrant Client Connection... +Loading config from: config/embeddings.yaml +✅ Embedder loaded: jina-embeddings-v4 +✅ Vector store initialized +✅ Client connection successful +📊 Available collections: 9 +📋 Collections: + 1. islamic_knowledge_free + 2. test_collection + 3. dovodi_collection_jina-embeddings-v4 + 4. islamic_knowledge_jina-embeddings-v4 + 5. dovodi_collection + ... and 4 more + +🎉 ALL TESTS PASSED! +✨ Your Qdrant vector database is connected and ready to use. +``` diff --git a/docs/RAG_IMPLEMENTATION_GUIDE.md b/docs/RAG_IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..80873d0 --- /dev/null +++ b/docs/RAG_IMPLEMENTATION_GUIDE.md @@ -0,0 +1,406 @@ +# راهنمای پیاده‌سازی RAG در Agno Agent + +## مقدمه + +در این سند، به بررسی مشکل استفاده از گزینه `search_knowledge` در کلاس Agent فریمورک Agno و راه‌حل پیشنهادی برای آن می‌پردازیم. همچنین سیر تکامل پیاده‌سازی از رویکرد اولیه (Override کردن متدها) به رویکرد فعلی (استفاده از Pre-Hook) را شرح می‌دهیم. + +## مشکل استفاده از `search_knowledge` + +### توضیح مشکل + +وقتی از کلاس `Agent` در فریمورک Agno استفاده می‌کنیم، گزینه `search_knowledge` به مدل اجازه می‌دهد تا برای پیدا کردن جواب به صورت مستقیم به وکتور دیتابیس متصل شود و اطلاعات را جستجو کند. + +```python +# مثال استفاده از search_knowledge +agent = Agent( + model=model, + knowledge=knowledge_base, + search_knowledge=True, # این گزینه ممکن است با همه مدل‌ها سازگار نباشد + # ... +) +``` + +### چالش‌ها + +1. **سازگاری مدل‌ها**: همه مدل‌های زبان از ابزارهای جستجو (tools/functions) پشتیبانی نمی‌کنند +2. **خطاهای احتمالی**: مدل‌هایی که از ابزارها پشتیبانی نمی‌کنند ممکن است در مواجه با دستورات جستجو دچار خطا شوند +3. **کنترل کمتر**: مدل به صورت خودکار تصمیم می‌گیرد چه زمانی جستجو کند + +## رویکرد اولیه (منسوخ): Override کردن متدهای Agent + +### توضیح رویکرد قدیمی + +در پیاده‌سازی اولیه، یک کلاس `ContextAwareAgent` ساخته بودیم که از `Agent` ارث‌بری می‌کرد و متدهای `run` و `print_response` را override می‌کرد: + +```python +# ⚠️ رویکرد قدیمی - دیگر استفاده نمی‌شود +class ContextAwareAgent(Agent): + def run(self, message, **kwargs): + """Override run to always use RAG pipeline""" + rag_prompt = build_rag_prompt(message) + return super().run(rag_prompt, **kwargs) + + def print_response(self, message, **kwargs): + """Override print_response to always use RAG pipeline""" + rag_prompt = build_rag_prompt(message) + return super().print_response(rag_prompt, **kwargs) +``` + +### مشکلات رویکرد قدیمی + +1. **خطر خراب شدن رفتار Agent**: با override کامل متدهای `run` و `print_response`، ممکن بود منطق داخلی فریمورک Agno (مثل guardrails، hooks، history management و ...) نادیده گرفته شود یا به شکل غیرمنتظره‌ای رفتار کند. +2. **وابستگی به ساختار داخلی**: هر تغییری در نسخه جدید Agno روی متدهای `run` یا `print_response` می‌توانست پیاده‌سازی ما را خراب کند. +3. **عدم ترکیب‌پذیری**: اضافه کردن منطق‌های دیگر (مثل sync config، guardrails و ...) به این کلاس، آن را پیچیده و شکننده می‌کرد. + +## رویکرد فعلی: استفاده از Pre-Hook برای تزریق RAG + +### چرا Pre-Hook؟ + +فریمورک Agno مکانیزم `pre_hooks` را فراهم کرده که اجازه می‌دهد **قبل از اجرای اصلی Agent**، روی ورودی کاربر تغییراتی اعمال کنیم. با این رویکرد: + +- **هیچ متدی override نمی‌شود** و رفتار اصلی Agent دست‌نخورده باقی می‌ماند +- منطق RAG به صورت یک **تابع مستقل و قابل تست** پیاده‌سازی می‌شود +- می‌توان چندین hook را **به صورت زنجیره‌ای** ترکیب کرد (مثلاً guardrail + config sync + RAG injection) + +### مزایای این رویکرد نسبت به Override + +| ویژگی | Override متدها (قدیمی) | Pre-Hook (فعلی) | +|--------|------------------------|-----------------| +| ایمنی رفتار Agent | پایین - ممکن است منطق داخلی خراب شود | بالا - رفتار اصلی حفظ می‌شود | +| سازگاری با نسخه‌های جدید Agno | شکننده | مقاوم | +| ترکیب‌پذیری با سایر منطق‌ها | دشوار | آسان - فقط hook اضافه کنید | +| قابلیت تست | متوسط | بالا - هر hook مستقلاً قابل تست است | +| سازگاری مدل‌ها | بالا | بالا | +| کنترل توسعه‌دهنده | بالا | بالا | +| قابلیت debug | بالا | بالا | + +## پیاده‌سازی فعلی + +### ساختار فایل‌ها + +#### 1. `src/utils/hooks.py` - هسته اصلی Pre-Hook ها + +این فایل حاوی hook های مختلفی است که قبل از اجرای Agent فراخوانی می‌شوند: + +```python +from agno.run.agent import RunInput +from src.utils.search_knowledge import build_rag_prompt + +def rag_injection_hook(run_input: RunInput, **kwargs): + """ + Intercepts the user input and injects RAG context. + """ + print("🪝 Hook: Injecting RAG Context...") + # Modify the input content in place + original_input = run_input.input_content + run_input.input_content = build_rag_prompt(original_input) + # Don't return anything - modifications are in-place +``` + +**نکته مهم**: این hook شیء `RunInput` را **در جا (in-place)** تغییر می‌دهد. یعنی `input_content` اصلی کاربر را با prompt غنی‌شده از RAG جایگزین می‌کند، بدون اینکه نیازی به بازگرداندن مقداری باشد. + +همچنین یک hook دیگر برای sync کردن تنظیمات agent از دیتابیس وجود دارد: + +```python +def sync_config_hook(run_input: RunInput, **kwargs): + """ + Agno Pre-Hook: Fetches the latest Django DB config and + injects it into the agent before the run starts. + """ + agent = kwargs.get("agent") + if not agent: + return + + config = get_active_agent_config() + + if config and config.get("system_prompts"): + new_prompts = config["system_prompts"] + agent.instructions = new_prompts + + return run_input +``` + +#### 2. `src/utils/search_knowledge.py` - منطق RAG و جستجو + +این فایل حاوی تابع `build_rag_prompt` است که مسئولیت جستجوی دانش، reranking نتایج و ساخت prompt را بر عهده دارد: + +```python +def build_rag_prompt(user_question: str, embedder_model_name: str = None) -> str: + """RAG pipeline با Qdrant - استفاده از سیستم embedding جدید""" + global knowledge_base + + try: + # Lazy initialization of knowledge base + if knowledge_base is None: + embed_factory = EmbeddingFactory() + embedder = embed_factory.get_embedder(embedder_model_name) + vector_db = get_qdrant_store(embedder=embedder) + knowledge_base = Knowledge(vector_db=vector_db) + + # Search for relevant documents + initial_results = knowledge_base.search(query=user_question, max_results=7) + + if not initial_results: + context_str = "No information found in database." + else: + # Reranking: ارسال نتایج اولیه به Jina برای انتخاب بهترین‌ها + relevant_docs = rerank_documents( + query=user_question, + documents=initial_results, + top_n=3 + ) + # ساخت context با اطلاعات منبع و امتیاز + context_parts = [] + for doc in relevant_docs: + meta = getattr(doc, "meta_data", {}) or {} + source = meta.get('source', 'Unknown') + score = meta.get('rerank_score', 0) + content = f"[Source: {source} | Relevance: {score:.2f}]\n{doc.content}" + context_parts.append(content) + context_str = "\n\n".join(context_parts) + + except Exception as e: + context_str = "Knowledge base temporarily unavailable." + + final_prompt = ( + "Here is the context from the database:\n" + "---------------------\n" + f"{context_str}\n" + "---------------------\n" + f"User Question: {user_question}" + ) + return final_prompt +``` + +#### 3. `src/agents/base_agent.py` - تنظیمات Agent با Pre-Hook + +در این فایل، Agent استاندارد Agno با پارامتر `pre_hooks` ساخته می‌شود. دیگر از `ContextAwareAgent` استفاده نمی‌شود: + +```python +from agno.agent import Agent +from src.utils.hooks import sync_config_hook, rag_injection_hook + +class IslamicScholarAgent: + def __init__(self, model, knowledge_base, custom_instructions=None, db_url=None): + # ... + self.agent = Agent( + name="Islamic Scholar Agent", + model=model, + instructions=self.custom_instructions, + markdown=True, + search_knowledge=False, + db=PostgresDb(db_url=self.db_url) if self.db_url else None, + add_history_to_context=True, + reasoning=False, + knowledge=None, # داده از طریق pre_hooks تزریق می‌شود + debug_mode=False, + pre_hooks=[ + PromptInjectionGuardrail(), + InputLimitGuardrail(), + sync_config_hook, + rag_injection_hook, + ], + ) +``` + +**نکته‌های کلیدی**: +- `knowledge=None`: چون داده‌ها از طریق `rag_injection_hook` به prompt تزریق می‌شوند، نیازی به پاس دادن knowledge به Agent نیست. +- `search_knowledge=False`: مدل نباید خودش تصمیم بگیرد چه زمانی جستجو کند. +- `pre_hooks=[...]`: زنجیره‌ای از hook ها و guardrail ها که **به ترتیب** قبل از اجرای Agent اعمال می‌شوند. + +#### 4. `src/agents/islamic_scholar_agent.py` + +کلاس تخصصی که از `IslamicScholarAgent` پایه ارث‌بری می‌کند: + +```python +from .base_agent import IslamicScholarAgent as BaseIslamicScholarAgent + +class IslamicScholarAgent(BaseIslamicScholarAgent): + """Specialized Islamic Scholar Agent""" + def __init__(self, model, knowledge_base, custom_instructions=None, db_url=None): + super().__init__(model, knowledge_base, custom_instructions, db_url) +``` + +## منطق پیاده‌سازی + +### 1. جریان کاری RAG با Pre-Hook + +```mermaid +graph TD + A[سوال کاربر] --> B[pre_hooks اجرا می‌شوند] + B --> B1[PromptInjectionGuardrail] + B1 --> B2[InputLimitGuardrail] + B2 --> B3[sync_config_hook] + B3 --> B4[rag_injection_hook] + B4 --> C[build_rag_prompt فراخوانی می‌شود] + C --> D[جستجو در Qdrant - دریافت 7 نتیجه] + D --> E{یافتن نتایج؟} + E -->|بله| F[Reranking با Jina - انتخاب 3 نتیجه برتر] + E -->|خیر| G[پیام عدم وجود اطلاعات] + F --> H[ساخت prompt نهایی با منبع و امتیاز] + G --> H + H --> I[جایگزینی input_content در RunInput] + I --> J[Agent اصلی Agno با prompt غنی‌شده اجرا می‌شود] + J --> K[پاسخ مدل بر اساس context] +``` + +### 2. ترتیب اجرای Pre-Hook ها + +وقتی کاربر یک سوال ارسال می‌کند، قبل از رسیدن به مدل، زنجیره زیر اجرا می‌شود: + +1. **`PromptInjectionGuardrail`**: بررسی ورودی برای حملات prompt injection +2. **`InputLimitGuardrail`**: بررسی محدودیت طول ورودی +3. **`sync_config_hook`**: دریافت آخرین تنظیمات (system prompts) از دیتابیس Django و اعمال آن‌ها +4. **`rag_injection_hook`**: جستجو در وکتور دیتابیس و تزریق context به سوال کاربر + +### 3. مراحل پیاده‌سازی RAG + +#### مرحله ۱: Lazy Initialization با EmbeddingFactory + +```python +if knowledge_base is None: + embed_factory = EmbeddingFactory() + embedder = embed_factory.get_embedder(embedder_model_name) + vector_db = get_qdrant_store(embedder=embedder) + knowledge_base = Knowledge(vector_db=vector_db) +``` + +#### مرحله ۲: جستجوی اولیه در Qdrant + +```python +# دریافت 7 نتیجه اولیه از وکتور دیتابیس +initial_results = knowledge_base.search(query=user_question, max_results=7) +``` + +#### مرحله ۳: Reranking نتایج + +```python +# ارسال نتایج اولیه به Jina Reranker برای انتخاب 3 نتیجه برتر +relevant_docs = rerank_documents( + query=user_question, + documents=initial_results, + top_n=3 +) +``` + +#### مرحله ۴: ساخت Context با اطلاعات منبع + +```python +context_parts = [] +for doc in relevant_docs: + meta = getattr(doc, "meta_data", {}) or {} + source = meta.get('source', 'Unknown') + score = meta.get('rerank_score', 0) + content = f"[Source: {source} | Relevance: {score:.2f}]\n{doc.content}" + context_parts.append(content) +context_str = "\n\n".join(context_parts) +``` + +#### مرحله ۵: ساخت Prompt نهایی و تزریق به RunInput + +```python +# در build_rag_prompt: +final_prompt = ( + "Here is the context from the database:\n" + "---------------------\n" + f"{context_str}\n" + "---------------------\n" + f"User Question: {user_question}" +) + +# در rag_injection_hook: +run_input.input_content = build_rag_prompt(original_input) +``` + +## مدیریت خطاها + +### استراتژی مدیریت خطا + +```python +try: + # منطق جستجو و reranking + initial_results = knowledge_base.search(query=user_question, max_results=7) + relevant_docs = rerank_documents(query=user_question, documents=initial_results, top_n=3) + # ... +except Exception as e: + print(f"⚠️ Knowledge Base error (continuing without RAG): {e}") + context_str = "Knowledge base temporarily unavailable. dont answer the question because you have no related information in the database." +``` + +### مزایای این رویکرد + +1. **مقاومت در برابر خطا**: سیستم با وجود مشکل در Knowledge Base ادامه کار می‌دهد +2. **شفافیت**: خطاها به صورت log ثبت می‌شوند +3. **graceful degradation**: در صورت مشکل، پیام مناسبی به مدل ارسال می‌شود تا از پاسخگویی بدون اطلاعات خودداری کند + +## مزایای کلی رویکرد Pre-Hook + +### مقایسه سه رویکرد + +| ویژگی | `search_knowledge=True` | Override متدها (قدیمی) | Pre-Hook (فعلی) | +|--------|------------------------|----------------------|-----------------| +| سازگاری مدل‌ها | محدود | بالا | بالا | +| کنترل توسعه‌دهنده | کم | بالا | بالا | +| ایمنی رفتار Agent | بالا | پایین | بالا | +| ترکیب‌پذیری | کم | کم | بالا | +| سازگاری با نسخه‌های جدید | متوسط | پایین | بالا | +| قابلیت debug | متوسط | بالا | بالا | +| انعطاف‌پذیری | کم | متوسط | بالا | +| مدیریت خطا | خودکار | سفارشی | سفارشی | + +### مزایای کلیدی + +1. **ایمنی رفتار Agent**: هیچ متدی override نمی‌شود و رفتار اصلی Agno حفظ می‌شود +2. **ترکیب‌پذیری**: می‌توان چندین hook (guardrails، config sync، RAG) را به صورت زنجیره‌ای اجرا کرد +3. **سازگاری جهانی**: با همه مدل‌های زبان کار می‌کند +4. **مقاومت در برابر تغییرات فریمورک**: چون از API رسمی Agno (pre_hooks) استفاده می‌شود، با آپدیت‌های آینده سازگار خواهد بود +5. **قابل تست**: هر hook یک تابع مستقل است و به راحتی قابل unit test است +6. **شفافیت**: امکان مشاهده و debug هر مرحله از زنجیره hooks +7. **Reranking هوشمند**: نتایج اولیه از Qdrant با Jina Reranker فیلتر و رتبه‌بندی می‌شوند + +## نکات پیاده‌سازی + +### ۱. تنظیمات محیطی + +اطمینان حاصل کنید که متغیرهای محیطی زیر تنظیم شده‌اند: + +```bash +QDRANT_URL=your_qdrant_url +DB_USER=your_db_user +DB_PASSWORD=your_db_password +DB_HOST=your_db_host +DB_PORT=your_db_port +DB_NAME=your_db_name +``` + +### ۲. تنظیمات Collection + +نام collection در Qdrant به صورت اتوماتیک توسط `get_qdrant_store` مدیریت می‌شود. + +### ۳. تنظیمات جستجو و Reranking + +- `max_results=7`: تعداد نتایج اولیه از Qdrant +- `top_n=3`: تعداد نتایج نهایی پس از reranking با Jina +- Reranker نتایج را بر اساس ارتباط معنایی با سوال کاربر رتبه‌بندی می‌کند + +### ۴. زبان و Encoding + +- اطمینان از سازگاری encoding داده‌ها +- پشتیبانی از زبان‌های مختلف (فارسی، عربی، انگلیسی) + +## نتیجه‌گیری + +پیاده‌سازی RAG در این پروژه از سه مرحله تکاملی عبور کرده است: + +1. **`search_knowledge=True`**: رویکرد پیش‌فرض Agno - محدود به مدل‌های خاص +2. **Override متدها (`ContextAwareAgent`)**: حل مشکل سازگاری مدل‌ها، اما با خطر خراب شدن رفتار Agent +3. **Pre-Hook (`rag_injection_hook`)**: رویکرد فعلی - ایمن، ترکیب‌پذیر و سازگار با فریمورک + +رویکرد فعلی Pre-Hook بهترین تعادل بین کنترل، ایمنی و انعطاف‌پذیری را فراهم می‌کند: + +- **بدون override**: رفتار اصلی Agent دست‌نخورده باقی می‌ماند +- **زنجیره‌ای**: Guardrails، Config Sync و RAG Injection همگی به صورت مرتب و قابل مدیریت اجرا می‌شوند +- **Reranking هوشمند**: با استفاده از Jina، کیفیت نتایج بازگشتی بهبود یافته +- **مقاوم**: سیستم در صورت خطا در هر مرحله، به صورت graceful ادامه کار می‌دهد + +این پیاده‌سازی در فایل‌های موجود به خوبی کار می‌کند و می‌تواند به عنوان الگوی مناسبی برای پروژه‌های مشابه استفاده شود. diff --git a/docs/RERANKING_PIPELINE.md b/docs/RERANKING_PIPELINE.md new file mode 100644 index 0000000..6e020a8 --- /dev/null +++ b/docs/RERANKING_PIPELINE.md @@ -0,0 +1,358 @@ +# پایپ‌لاین بازیابی دو مرحله‌ای: Vector Search + Reranking + +## مقدمه + +در سیستم‌های RAG (Retrieval-Augmented Generation)، کیفیت پاسخ مدل زبانی مستقیماً به کیفیت داده‌هایی بستگی دارد که به عنوان context به آن داده می‌شود. اگر داده‌های نامرتبط یا کم‌ارتباط به مدل برسند، پاسخ نهایی نیز ضعیف خواهد بود. + +برای حل این مشکل، ما از یک **پایپ‌لاین بازیابی دو مرحله‌ای** استفاده می‌کنیم: + +1. **مرحله اول - Vector Search**: بازیابی N کاندید اولیه از وکتور دیتابیس بر اساس فاصله معنایی (Semantic Similarity) +2. **مرحله دوم - Reranking**: ارزیابی دقیق‌تر کاندیدها توسط یک مدل Reranker و انتخاب بهترین‌ها + +## چرا فقط Vector Search کافی نیست؟ + +### محدودیت‌های جستجوی وکتوری + +جستجوی وکتوری (Vector Search) بر اساس **فاصله بردارها** (مثلاً Cosine Similarity) کار می‌کند. یعنی سوال کاربر و اسناد موجود در دیتابیس هر دو به بردار تبدیل می‌شوند و نزدیک‌ترین بردارها به عنوان نتیجه برگردانده می‌شوند. + +اما این روش محدودیت‌هایی دارد: + +| محدودیت | توضیح | +|---------|-------| +| **دقت متوسط** | Embedding model ها معنای کلی متن را می‌گیرند، نه ارتباط دقیق سوال-جواب را | +| **حساسیت به نحوه نوشتن** | دو جمله با معنای یکسان اما ساختار متفاوت ممکن است فاصله بیشتری داشته باشند | +| **عدم درک سوال-جواب** | مدل embedding فقط شباهت معنایی می‌سنجد، نه اینکه آیا یک سند واقعاً پاسخ سوال را دارد | +| **چالش‌های چند‌زبانه** | در متون فارسی/عربی/انگلیسی مخلوط، embedding ممکن است دقت کمتری داشته باشد | + +### مثال عملی + +فرض کنید کاربر می‌پرسد: **"حکم روزه مسافر چیست؟"** + +Vector Search ممکن است این نتایج را برگرداند: + +1. سندی درباره **احکام روزه مسافر** (مرتبط) +2. سندی درباره **احکام نماز مسافر** (شبیه اما نامرتبط) +3. سندی درباره **فضیلت روزه** (کلمه روزه دارد اما پاسخ سوال نیست) +4. سندی درباره **احکام روزه بیمار** (مشابه اما متفاوت) + +Reranker می‌تواند تشخیص دهد که سند ۱ دقیقاً به سوال پاسخ می‌دهد و بقیه را در اولویت پایین‌تر قرار دهد. + +## معماری پایپ‌لاین + +### نمای کلی + +```mermaid +graph TD + A["سوال کاربر"] --> B["rag_injection_hook"] + B --> C["build_rag_prompt"] + + subgraph "مرحله ۱: Vector Search" + C --> D["Embedding سوال کاربر"] + D --> E["جستجو در Qdrant"] + E --> F["N=7 کاندید اولیه"] + end + + subgraph "مرحله ۲: Reranking" + F --> G["ارسال به Jina Reranker API"] + G --> H["ارزیابی ارتباط هر کاندید با سوال"] + H --> I["مرتب‌سازی بر اساس relevance_score"] + I --> J["انتخاب top_n=3 نتیجه برتر"] + end + + J --> K["ساخت context با منبع و امتیاز"] + K --> L["تزریق به prompt کاربر"] + L --> M["ارسال به LLM"] + M --> N["پاسخ نهایی"] +``` + +### جریان داده + +``` +سوال کاربر + │ + ▼ +┌─────────────────────────────┐ +│ Pre-Hook: rag_injection_hook│ ← src/utils/hooks.py +│ ورودی کاربر را دریافت و │ +│ به build_rag_prompt پاس │ +│ می‌دهد │ +└─────────────┬───────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ مرحله ۱: Vector Search │ ← src/utils/search_knowledge.py +│ ───────────────────────── │ +│ • Embedding سوال با │ +│ EmbeddingFactory │ +│ • جستجو در Qdrant │ +│ • دریافت 7 کاندید اولیه │ +└─────────────┬───────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ مرحله ۲: Reranking │ ← src/utils/reranker.py +│ ───────────────────────── │ +│ • ارسال سوال + 7 کاندید │ +│ به Jina Reranker API │ +│ • دریافت relevance_score │ +│ برای هر کاندید │ +│ • انتخاب 3 کاندید برتر │ +└─────────────┬───────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ ساخت Context نهایی │ +│ ───────────────────────── │ +│ • افزودن منبع و امتیاز │ +│ به هر سند │ +│ • ترکیب با سوال کاربر │ +│ • تزریق به RunInput │ +└─────────────┬───────────────┘ + │ + ▼ + Agent (LLM) +``` + +## پیاده‌سازی + +### مرحله ۱: نقطه ورود - Pre-Hook + +فایل: `src/utils/hooks.py` + +```python +@observe(name="rag_injection_hook") +def rag_injection_hook(run_input: RunInput, **kwargs): + """ + Intercepts the user input and injects RAG context. + """ + original_input = run_input.input_content + run_input.input_content = build_rag_prompt(original_input) +``` + +این hook قبل از اجرای Agent فراخوانی می‌شود. ورودی خام کاربر را می‌گیرد و آن را از پایپ‌لاین RAG عبور می‌دهد. نتیجه (prompt غنی‌شده با context) جایگزین ورودی اصلی می‌شود. + +**نکته**: تغییرات روی `run_input` به صورت in-place اعمال می‌شوند. نیازی به return نیست. + +### مرحله ۲: Vector Search - بازیابی کاندیدهای اولیه + +فایل: `src/utils/search_knowledge.py` + +```python +def build_rag_prompt(user_question: str, embedder_model_name: str = None) -> str: + global knowledge_base + + # Lazy initialization + if knowledge_base is None: + embed_factory = EmbeddingFactory() + embedder = embed_factory.get_embedder(embedder_model_name) + vector_db = get_qdrant_store(embedder=embedder) + knowledge_base = Knowledge(vector_db=vector_db) + + # جستجوی اولیه: دریافت 7 کاندید نزدیک‌ترین + initial_results = knowledge_base.search(query=user_question, max_results=7) +``` + +#### چرا `max_results=7`؟ + +عدد 7 یک تعادل بین دو نیاز است: + +- **پوشش کافی**: اگر تعداد کاندیدها خیلی کم باشد (مثلاً 3)، ممکن است بهترین نتیجه در بین آن‌ها نباشد +- **سرعت و هزینه**: اگر تعداد خیلی زیاد باشد (مثلاً 50)، هزینه و زمان Reranking بالا می‌رود + +با 7 کاندید اولیه، Reranker فضای کافی برای انتخاب 3 نتیجه واقعاً مرتبط دارد. + +#### Lazy Initialization + +Knowledge Base فقط یک‌بار مقداردهی می‌شود (در اولین درخواست) و سپس در یک متغیر global نگهداری می‌شود. این کار از ایجاد اتصال مجدد به Qdrant در هر درخواست جلوگیری می‌کند. + +### مرحله ۳: Reranking - فیلتر هوشمند نتایج + +فایل: `src/utils/reranker.py` + +```python +def rerank_documents(query: str, documents: List[Any], top_n: int = 3) -> List[Any]: + # 1. استخراج محتوای متنی اسناد + doc_contents = [doc.content for doc in documents] + + # 2. ارسال به Jina Reranker API + payload = { + "model": "jina-reranker-v3", + "query": query, + "documents": doc_contents, + "top_n": top_n + } + response = requests.post(url, headers=headers, json=payload) + results = response.json()["results"] + + # 3. بازسازی لیست اسناد با امتیاز جدید + reranked_docs = [] + for result in results: + original_index = result["index"] + relevance_score = result["relevance_score"] + doc = documents[original_index] + doc.meta_data["rerank_score"] = relevance_score + reranked_docs.append(doc) + + return reranked_docs +``` + +#### مدل Reranker: `jina-reranker-v3` + +از مدل **Jina Reranker v3** استفاده می‌کنیم. دلایل انتخاب این مدل: + +| ویژگی | توضیح | +|-------|-------| +| **پشتیبانی چند‌زبانه** | عملکرد مناسب روی متون فارسی، عربی و انگلیسی | +| **درک سوال-جواب** | بر خلاف embedding، ارتباط بین سوال و پاسخ را می‌فهمد (Cross-Encoder) | +| **سرعت** | برای تعداد کم اسناد (7 کاندید) بسیار سریع است | +| **API ساده** | فقط یک HTTP POST call نیاز دارد | + +#### تفاوت Reranker با Embedding + +| | Embedding (Bi-Encoder) | Reranker (Cross-Encoder) | +|--|----------------------|------------------------| +| **ورودی** | هر متن جداگانه encode می‌شود | سوال و سند با هم encode می‌شوند | +| **خروجی** | بردار (vector) | امتیاز ارتباط (relevance score) | +| **سرعت** | سریع (مناسب جستجوی میلیون‌ها سند) | کندتر (مناسب ده‌ها سند) | +| **دقت** | متوسط | بالا | +| **کاربرد** | مرحله اول: غربال‌گری سریع | مرحله دوم: انتخاب دقیق | + +به زبان ساده: +- **Embedding** مثل خواندن سریع عنوان کتاب‌ها در کتابخانه و انتخاب چند کتاب مرتبط است +- **Reranker** مثل ورق زدن آن چند کتاب و انتخاب بهترین‌ها بر اساس محتوای واقعی + +#### نحوه کار Jina API + +Jina یک لیست از اسناد و یک سوال دریافت می‌کند. برای هر سند، یک `relevance_score` (بین 0 تا 1) و `index` (شماره سند در لیست اصلی) برمی‌گرداند: + +```json +{ + "results": [ + { "index": 2, "relevance_score": 0.92 }, + { "index": 0, "relevance_score": 0.78 }, + { "index": 5, "relevance_score": 0.65 } + ] +} +``` + +سپس ما با استفاده از `index`، سند اصلی را از لیست اولیه پیدا کرده و `relevance_score` را در `meta_data` آن ذخیره می‌کنیم. + +### مرحله ۴: ساخت Context نهایی + +پس از reranking، اسناد برتر به همراه اطلاعات منبع و امتیاز ارتباط به یک context تبدیل می‌شوند: + +```python +context_parts = [] +for doc in relevant_docs: + meta = getattr(doc, "meta_data", {}) or {} + source = meta.get('source', 'Unknown') + score = meta.get('rerank_score', 0) + content = f"[Source: {source} | Relevance: {score:.2f}]\n{doc.content}" + context_parts.append(content) +context_str = "\n\n".join(context_parts) +``` + +هر سند در context نهایی شامل: +- **Source**: منبع سند (مثلاً نام کتاب یا فایل) +- **Relevance**: امتیاز ارتباط از Reranker (0 تا 1) +- **Content**: محتوای اصلی سند + +### مرحله ۵: Prompt نهایی + +```python +final_prompt = ( + "Here is the context from the database:\n" + "---------------------\n" + f"{context_str}\n" + "---------------------\n" + f"User Question: {user_question}" +) +``` + +این prompt به LLM داده می‌شود. مدل با دیدن context مرتبط و سوال کاربر، پاسخ مناسب تولید می‌کند. + +## مدیریت خطا و Fallback + +### سطح ۱: خطا در Reranker + +اگر Jina API در دسترس نباشد یا خطا دهد، سیستم به ترتیب اصلی Vector Search بازمی‌گردد: + +```python +except Exception as e: + print(f"❌ Reranking failed: {e}. Falling back to vector search order.") + return documents[:top_n] +``` + +### سطح ۲: عدم وجود API Key + +اگر `JINA_API_KEY` تنظیم نشده باشد، بدون reranking ادامه می‌دهد: + +```python +api_key = os.getenv("JINA_API_KEY") +if not api_key: + print("⚠️ JINA_API_KEY not found. Returning original order.") + return documents[:top_n] +``` + +### سطح ۳: خطا در Knowledge Base + +اگر کل Knowledge Base در دسترس نباشد، به مدل اطلاع داده می‌شود که پاسخ ندهد: + +```python +except Exception as e: + context_str = "Knowledge base temporarily unavailable. dont answer the question ..." +``` + +### خلاصه زنجیره Fallback + +``` +Reranking موفق → 3 سند با بالاترین ارتباط + │ (خطا) + ▼ +Fallback → 3 سند اول از Vector Search (بدون reranking) + │ (خطا) + ▼ +Fallback → پیام عدم دسترسی به Knowledge Base +``` + +## تنظیمات و پارامترها + +### متغیرهای محیطی + +| متغیر | توضیح | الزامی | +|--------|--------|--------| +| `JINA_API_KEY` | کلید API برای Jina Reranker | بله (بدون آن فقط Vector Search) | +| `QDRANT_URL` | آدرس سرور Qdrant | بله | + +### پارامترهای قابل تنظیم + +| پارامتر | مقدار فعلی | محل تعریف | توضیح | +|----------|-----------|-----------|-------| +| `max_results` | 7 | `search_knowledge.py` | تعداد کاندیدهای اولیه از Vector Search | +| `top_n` | 3 | `search_knowledge.py` → `rerank_documents()` | تعداد نتایج نهایی پس از Reranking | +| `model` | `jina-reranker-v3` | `reranker.py` | مدل Reranker مورد استفاده | + +### توصیه برای تنظیم پارامترها + +- نسبت `max_results` به `top_n` باید حداقل 2:1 باشد تا Reranker فضای کافی برای انتخاب داشته باشد +- افزایش `max_results` دقت را بالا می‌برد اما سرعت و هزینه API را افزایش می‌دهد +- `top_n=3` برای اکثر سناریوها مناسب است؛ تعداد بیشتر ممکن است context را شلوغ کند + +## فایل‌های مرتبط + +| فایل | مسئولیت | +|------|---------| +| `src/utils/hooks.py` | Pre-Hook برای تزریق RAG به ورودی Agent | +| `src/utils/search_knowledge.py` | پایپ‌لاین اصلی RAG: جستجو، reranking و ساخت prompt | +| `src/utils/reranker.py` | ارتباط با Jina Reranker API و مرتب‌سازی نتایج | +| `src/knowledge/embedding_factory.py` | ساخت و مدیریت مدل‌های Embedding | +| `src/knowledge/vector_store.py` | اتصال به Qdrant و مدیریت collection | + +## نتیجه‌گیری + +پایپ‌لاین دو مرحله‌ای **Vector Search + Reranking** بهترین تعادل بین سرعت و دقت را فراهم می‌کند: + +- **مرحله اول (Vector Search)** سریع است و از بین میلیون‌ها سند، تعداد محدودی کاندید مرتبط انتخاب می‌کند +- **مرحله دوم (Reranking)** دقیق است و با درک عمیق‌تر ارتباط سوال-جواب، بهترین اسناد را فیلتر می‌کند + +این ترکیب باعث می‌شود context ارسالی به LLM با کیفیت بالا باشد و در نتیجه پاسخ‌های دقیق‌تر و مرتبط‌تری تولید شود. diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..e97cba3 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# entrypoint.sh + +set -e + + +exec "$@" diff --git a/langfuse/docker-compose.langfuse.yml b/langfuse/docker-compose.langfuse.yml new file mode 100644 index 0000000..c59f93f --- /dev/null +++ b/langfuse/docker-compose.langfuse.yml @@ -0,0 +1,32 @@ +version: '3.9' + +services: + langfuse-server: + image: langfuse/langfuse:2 + container_name: langfuse-server + restart: unless-stopped + ports: + - "3000:3000" + environment: + # Docker automatically pulls these from your .env file + - DATABASE_URL=${DATABASE_URL} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} + - SALT=${SALT} + - NEXTAUTH_URL=http://localhost:3000 + - TELEMETRY_ENABLED=false + - NODE_ENV=production + + # 🟢 Connect to the SAME network as your Agent and Database + networks: + - imam-javad_backend_imam-javad + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 3 + +# Define the network so Docker knows it already exists +networks: + imam-javad_backend_imam-javad: + external: true \ No newline at end of file diff --git a/out.md b/out.md new file mode 100644 index 0000000..a42c319 --- /dev/null +++ b/out.md @@ -0,0 +1,14 @@ +بسم الله الرحمن الرحیم \nاحادیث متعددی درباره رابطه پیامبر اکرم صلی الله علیه وآله وسلم و حضرت علی علیه السلام در منابع اسلامی نقل شده است. بر اساس منابع موجود در پایگاه دانش، یک روایت معتبر از کتاب **سنن الکبری** اثر امام نسائی رحمه الله به شرح زیر است:\n\n```arabic\nحَدَّثَنَا قُتَيْبَةُ بْنُ سَعِيدٍ قَالَ: حَدَّثَنَا ابْنُ أَبِي عَدِيٍّ، عَنْ عَوْفٍ، عَنْ مَيْمُونٍ أَبِي عَبْدِ اللهِ، عَنْ زَيْدِ بْنِ أَرْقَمَ: \nقَامَ رَسُولُ اللهِ صَلَّى اللهُ عَلَيْهِ وَسَلَّمَ فَحَمِدَ اللهَ وَأَثْنَى عَلَيْهِ ثُمَّ قَالَ: \n«أَلَسْتُمْ تَعْلَمُونَ أَنِّي أَوْلَى بِكُلِّ مُؤْمِنٍ مِنْ نَفْسِهِ؟» \nقَالُوا: بَلَى، نَحْنُ نَشْهَدُ coincidenceلأَنْتَ أَوْلَى بِكُلِّ مُؤْمِنٍ مِنْ نَفْسِهِ \nقَالَ: «فَإِنِّي مَنْ كُنْتُ مَوْلَاهُ، فَهَذَا مَوْلَاهُ» \nوَأَخَذَ بِيَدِ عَلِيٍّ رَضِيَ اللهُ عَنْهُ.\n```\n**ترجمه فارسی**: \nقتیبہ بن سعید از ابن ابی عدی، از عوف، از میمون ابو عبداللہ، از حضرت زید بن ارقم رضی اللہ عنہ روایت کرتے ہیں کہ: \nرسول اللہ صلی الله علیه وسلم کھڑے ہوئے hoogteخدا کی حمد و ثنا بیان کی، پھر فرمایا: \n**\"کیا تم نہیں جانتے کہ میں ہر مومن کا اس کی جان سے بھی زیادہ حق رکھنے والا ہوں؟\"** \nلوگوں نے جواب دیا کیوں نہیں! ہم گواہی دیتے ہیں کہ آپ bcحکمرan ہر مومن کی جان سے بھی زیادہ حق رکھنے والے ہیں۔ \nآپ نے فرمایا: **\"پس جس ka میں مولا ہوں، اس کا یہ علی مولا ہے\"** \nاور آپ صلی الله علیه وسلم نے حضرت علی رضی الله عنہ کا ہاتھ پکڑ intersectionلیا۔ \n\n**منبع**: \n**سنن الکبری لن‌نسائی**، جلد ۵، صفحہ ۱۳۱، حدیث ۸۴۶۹ \n\n---\n### نکات کلیدیembersختصر سے پیشrique1️⃣ **موقعیت حدیث**: این روایت در غدیر خم خطاب به ده‌ها هزار صحابe approximately ی نقل شده است. \n۲️⃣ **مفهوم \"مولا\"**: در این context به معنای سرپرستی در امور دین و دنیا و ی4انے معنے الاولویة بالتصرف است. \n۳️⃣ **تواتر معنavi**: بیش از ۱۱۰ صحابی این حدیث را با تفاوت‌های اندکی نقل کرده‌اند که نشان‌دهنده شهرت روایت است. \n\n> اللهُمَّ وَالِ مَنْ وَالاہُ وَعَادِ مَنْ عَادَاہُ \n(خدایا! دوست رکھ اسے جو اسے دوست رکھے اور دشمن رکھ اسے جو اس سے دشمنی رکھے)\n\nوالله أعلم​​​. + + + +--------------------------------------- + +"بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ \nفِيمَا يَخُصُّ الْحَدِيثَ الَّذِي سَأَلْتَ عَنْهُ، تَـحْصُلُ لَدَيَّ هَذِهِ الأَحَادِيثُ الْمُسْتَخَرَجَةُ مِنَ الْمَرَاجِعِ الْمُعْتَبَرَةِ فِي السُّنَّةِ النَّبَوِيَّةِ:\n\n### الحَدِيثُ الأَوَّلُ:\n- **الرِّوَايَةُ:** عَنْ زَيْدِ بْنِ أَرْقَمَ رَضِيَ اللَّهُ عَنْهُ، قَالَ: \n \"سَمِعْتُ رَسُولَ اللَّهِ صَلَّى اللهُ عَلَيْهِ وَسَلَّمَ يَقُولُ يَوْمَ غَدِيرِ خُمٍّ: \n **مَنْ كُنْتُ مَوْلَاهُ فَعَلِيٌّ مَوْلَاهُ، اللَّهُمَّ وَالِ مَنْ وَالَاهُ وَعَادِ مَنْ عَادَاهُ**\". \n- **مَصْدَرُهُ:** **الْمُعْجَمُ الْكَبِيرُ** لِلطَّبَرَانِيِّ، ج5 ص170، ح4983.\n\n### الحَدِيثُ الثَّانِي:\n- **الرِّوَايَةُ:** تَصْدِيقُ أَبِي إِسْحَاقَ لِرِوَايَةِ زَيْدِ بْنِ أَرْقَمَ رَضِيَ اللَّهُ عَنْهُ: \n \"نَعَمْ، يُرِيدُ: **مَنْ كُنْتُ مَوْلَاهُ**\". \n- **مَصْدَرُهُ:** **السُّنَّةُ** لِابْنِ أَبِي عَاصِمٍ، ج2 ص607، ح1375.\n\n### الحَدِيثُ الثَّالِثُ:\n- **الرِّوَايَةُ:** عَنْ زَيْدِ بْنِ أَرْقَمَ رَضِيَ اللَّهُ عَنْهُ وَصْفًا لِحَادِثَةِ غَدِيرِ خُمٍّ: \n \"قَالَ رَسُولُ اللَّهِ صَلَّى اللهُ عَلَيْهِ وَسَلَّمَ: \n **أَلَسْتُمْ تَعْلَمُونَ أَنِّي أَوْلَى بِكُلِّ مُؤْمِنٍ مِنْ نَفْسِهِ؟... فَمَنْ كُنْتُ مَوْلَاهُ فَإِنَّ عَلِيًّا مَوْلَاهُ، اللَّهُمَّ عَادِ مَنْ عَادَاهُ وَوَالِ مَنْ وَالَاهُ**\". \n- **مَصْدَرُهُ:** **مُسْنَدُ أَحْمَدَ**، ج32 ص74، ح19325.\n\n### مِلَاحَظَاتٌ:\n١. تَتَّفِقُ هَذِهِ الرِّوَايَاتُ فِي ذِكْرِ حَدِيثِ الْغَدِيرِ مُشِيرَةً إِلَى مَنْزِلَةِ عَلِيِّ بْنِ أَبِي طَالِبٍ رَضِيَ اللَّهُ عَنْهُ. \n٢. اخْتِلَافُ الْأَلْفَاظِ بَيْنَ الرِّوَايَاتِ طَبِيعِيٌّ فِي عِلْمِ الْحَدِيثِ وَلَيْسَتْ تَنَاقُضًا. \n\nوَاللَّهُ أَعْلَمُ بِالصَّوَابِ. + + + +deepseek v3 : + + +بِسْمِ اللَّهِ الرَّحْمَٰنِ الرَّحِيمِ\n\nدر منابع حدیثی، روایاتی درباره واقعه غدیر خم نقل شده است که در آنها پیامبر اکرم صلی الله علیه وسلم درباره حضرت علی رضی الله عنه فرمودند:\n\n**«مَنْ كُنْتُ مَوْلَاهُ فَعَلِيٌّ مَوْلَاهُ، اللهُمَّ وَالِ مَنْ وَالَاهُ وَعَادِ مَنْ عَادَاهُ»**\n\nترجمه: \"هر کس من مولای او هستم، علی مولای اوست. خدایا، دوست بدار هر که او را دوست بدارد و دشمن بدار هر که با او دشمنی کند.\"\n\nاین روایات در کتاب **المعجم الکبیر** اثر الطبرانی با اسناد مختلف نقل شده است.\n\nو الله أعلم. \ No newline at end of file diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..318e09b --- /dev/null +++ b/pytest.ini @@ -0,0 +1,18 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --verbose + --tb=short + --strict-markers + --disable-warnings + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-fail-under=80 +markers = + unit: Unit tests (fast, isolated) + integration: Integration tests (may require external services) + slow: Slow running tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a93fb9c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,21 @@ +# Development dependencies +-r requirements.txt + +# Testing +pytest==7.4.3 +pytest-cov==4.1.0 +pytest-asyncio==0.21.1 + +# Code quality +black==23.12.1 +isort==5.13.2 +flake8==6.1.0 +mypy==1.7.1 + +# Development tools +jupyter==1.0.0 +ipykernel==6.27.1 + +# Documentation +mkdocs==1.5.3 +mkdocs-material==9.4.8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0b2bc112820d38aa917ed4842a34093369e79b22 GIT binary patch literal 7320 zcmcJUOK%%T5QO&}AU}nINGg6Da_}jD5d#h!AjnA&#ivYBG?%nQ{rDvHb!~H&7UNjS z1_I?o)6-L3UETBGpMP9cv#M9^s)c^9t4(!RZS}dc{82`o8+E!hWSu z#s>QP5Bdz6k%j&~m&IB?`)U%Ok^XC>&)90sb&L;VjNikS&sykM6;G@S zo(p{v(JByaHNMrVV?{EK7!p4*Tr~ck+HQq&t$9X>?aL$ITg3z~YoWM~^=gpZqc2Jv^IC$_Xl zlbJEb@R(!ns$W(zo}Ht)VxaFn99Fb$obS2vJkyWWkBZP`PaN->OyFr4HJ>Z)#O0N^ zB>RhCiW=O9|8_>_O~>7YCAI8Y$o^q}3+0@ipFG12EYly?${8I9ZH-h{c%Tl(`tUDv z&>0%fSu5GX2Kd-dupxJav$y*Ds=Cp9vz;okhHf-UeYdOM)eEYdz<`=TFc0r|F5Yjn z_NYOe>Y#GG)7_{Nz~=e%&a@dmvA7GI1&bAtnWs+a8}7eEgePZRV_yGXeRJw^(TJnd8cgb z4)Kv~vxyEj+C%8c_hO(Io~UuF@tCo9jWu2NgZ`WD2DY?HpW|5X_*~>JL>HOMo>@K- zWj4?%Xfh7;V86tAE00T!p>@>hu0kCY<*sCd7bDO8!t0`#m2v?* zJ$a|__<8Jkt`JT*Y0;D2EdFn+Ottl6F47itc`sn5I|P68&IxY*?vt7Kv-HLK7y5yP zfA7sW^E`@1@XH?ZPkzxKc`<98yxbc;H;9e4#BRUt1c}#*Cn84O3w=^a3$pg03}Zvq zu{*{_y|62}gKcC_Ou3)iZ`p;@@2OeTV)jL9uqR&WknNMhfAvHKq;SXHQ&M5x4Pz=6UZm+XWYo!yH8i> zePh~0*Y$a&>j=1EZ?v;;9_?Q1oOyUr;pE#j^0sfEX1Y}5y)w#;;<>2Hp2N)N?1{B9 z;FpBw&3fM1;9Hf+iw-w`Zc)B_on~UAPU+hK<32Z(lSAJ5IH9p`wna~M18>|A%54B# zr|k#I*OxP}_BkhU?sW_06i#=!jNfR8Q(eB3P)Y49?ju8a{sgx%@Y`oZ`Yc&Y8}3=$ zb5Rj_FWKdWRro)ibs5~`y$8MO4}pxSx?x!t$wS14R(#v(QvK-KA3{jj*{F-AdJ|EWG4PBFaJy#Bh9ixQLG6 z9!tL|cXs0}{S)=le|QPx+XvQhH_3|QwIJ&TJ(C~$b-78F8lwt6YV1zG@mts@_hzPy z=F7V)-TYwZ9AL)_b-wDm3eyLQbw-HgMZ8fs-uw=w>?VUonQ)(@yt)3pMqvp&zGJuN zJUx|Q>+c)#z1S}ap5^Yyn-7}w`!X!?T9WLP?$VxfDF%7bf=6`53^Rp&euvD(923z+V93Y?bm+0 🌿 Checking out master branch..." +# git checkout master + +# # 2. Pull the latest changes from master +# echo "--> 📥 Pulling latest changes..." +# git pull origin master + +# # 3. Run Docker Compose for production +# # --remove-orphans cleans up old containers if you changed service names +# echo "--> 🐳 Building and starting containers..." +# DOCKER_BUILDKIT=1 docker compose -f $COMPOSE_FILE up -d --build --remove-orphans + +# echo "✅ Deployment Complete!" +# fi \ No newline at end of file diff --git a/scripts/ingest_excel.py b/scripts/ingest_excel.py new file mode 100644 index 0000000..434584f --- /dev/null +++ b/scripts/ingest_excel.py @@ -0,0 +1,127 @@ +import os +import pandas as pd +from dotenv import load_dotenv +from agno.knowledge.knowledge import Knowledge +from agno.vectordb.qdrant import Qdrant +from agno.vectordb.search import SearchType +import sys +from pathlib import Path + +# ----------------------------------------------------------------------------- +# DYNAMIC PATH SETUP +# This finds the project root automatically, whether run from root or tests/ folder +# ----------------------------------------------------------------------------- +# Get the absolute path of this test file +current_file = Path(__file__).resolve() + +# Find the 'src' directory by looking up the tree +# We look for the folder that contains 'src' +root_path = current_file.parent +while not (root_path / 'src').exists(): + if root_path == root_path.parent: # Reached system root + raise FileNotFoundError("Could not find project root containing 'src' folder") + root_path = root_path.parent + +# Add the project root to Python path +sys.path.insert(0, str(root_path)) +print(f"🔧 Added project root to path: {root_path}") +# ----------------------------------------------------------------------------- + +from src.knowledge.embedding_factory import EmbeddingFactory +load_dotenv() + +# --- 1. CONFIGURATION --- +qdrant_host = os.getenv("QDRANT_HOST") +qdrant_port = os.getenv("QDRANT_PORT") +qdrant_url = f"http://{qdrant_host}:{qdrant_port}" +collection_name = os.getenv("BASE_COLLECTION_NAME") +qdrant_api_key = os.getenv("QDRANT_API_KEY") +# Matches the embedder used in app.py +embed_factory = EmbeddingFactory() +local_embedder = embed_factory.get_embedder() +collection_name = f"{collection_name}_{local_embedder.id}_hybrid" + +print(f"****************************************************************") +print(f"Collection name: {collection_name}") + +# Initialize Qdrant Vector DB +vector_db = Qdrant( + collection=collection_name, # positional or keyword is fine here + url=qdrant_url, + embedder=local_embedder, + timeout=30.0, + api_key=qdrant_api_key, + search_type=SearchType.hybrid +) + +knowledge_base = Knowledge(vector_db=vector_db) + + +def ingest_hadiths(file_path: str): + print(f"📖 Processing Hadiths: {file_path}") + df = pd.read_excel(file_path) + count = 0 + + for _, row in df.iterrows(): + content = ( + f"HADITH TYPE: HADITH\n" + f"TITLE: {row.get('Title', '')}\n" + f"ARABIC: {row.get('Arabic Text', '')}\n" + f"TRANSLATION: {row.get('Translation', '')}\n" + f"SOURCE: {row.get('Source Info', '')}" + ) + knowledge_base.add_content(text_content=content) + count += 1 + + print(f"✅ Successfully ingested {count} Hadiths into Qdrant.") + + +def ingest_articles(file_path: str): + print(f"📄 Processing Articles: {file_path}") + df = pd.read_excel(file_path) + count = 0 + + for _, row in df.iterrows(): + content = ( + f"ARTICLE TYPE: ARTICLE\n" + f"TITLE: {row.get('Title', '')}\n" + f"AUTHOR: {row.get('Author', '')}\n" + f"CONTENT: {row.get('Content', '')}\n" + f"URL: {row.get('URL', '')}" + ) + knowledge_base.add_content(text_content=content) + count += 1 + + print(f"✅ Successfully ingested {count} Articles into Qdrant.") + + +if __name__ == "__main__": + print("--- 🚀 Starting Data Ingestion to Qdrant ---") + SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) + + # 2. Go up one level to the Project Root + PROJECT_ROOT = os.path.dirname(SCRIPTS_DIR) + + # 3. Build the path to the data folder + DATA_DIR = os.path.join(PROJECT_ROOT, "data", "raw") + + # 4. Define your file paths + HADITH_FILE = os.path.join(DATA_DIR, "hadiths_data.xlsx") + ARTICLE_FILE = os.path.join(DATA_DIR, "dovodi_articles.xlsx") + + try: + # Ingest Hadiths + if os.path.exists(HADITH_FILE): + ingest_hadiths(HADITH_FILE) + else: + print(f"⚠️ {HADITH_FILE} not found!") + + # Ingest Articles + if os.path.exists(ARTICLE_FILE): + ingest_articles(ARTICLE_FILE) + else: + print(f"⚠️ {ARTICLE_FILE} not found!") + + print("--- ✨ Ingestion Complete ---") + except Exception as e: + print(f"❌ Error during ingestion: {e}") diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agents/base_agent.py b/src/agents/base_agent.py new file mode 100644 index 0000000..9e81d2e --- /dev/null +++ b/src/agents/base_agent.py @@ -0,0 +1,68 @@ +""" +Base agent implementation for Islamic Scholar +""" +from agno.agent import Agent +from agno.db.postgres import PostgresDb +from src.core.culture import get_culture_manager +from urllib.parse import quote_plus +import os +from agno.guardrails import PromptInjectionGuardrail +from src.guardrails.limit import InputLimitGuardrail +from src.utils.hooks import sync_config_hook, rag_injection_hook +from src.utils.load_settings import default_system_prompt +from src.agents.tracing_agent import TracingAgent + + + +class IslamicScholarAgent: + """Islamic Scholar Agent implementation""" + + def __init__(self, model, knowledge_base,custom_instructions=None, db_url=None): + db_user = os.getenv("DB_USER") + db_pass = os.getenv("DB_PASSWORD") + db_host = os.getenv("DB_HOST") + db_port = os.getenv("DB_PORT") + db_name = os.getenv("DB_NAME") + db_pass = quote_plus(db_pass) + db_user = quote_plus(db_user) + self.model = model + self.knowledge_base = knowledge_base + self.db_url = f"postgresql+psycopg://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}" + self.culture_manager = get_culture_manager() + self.custom_instructions = custom_instructions or default_system_prompt() + + print(f"*******DB struct: {custom_instructions}") + + print(f"Custom instructions: {self.custom_instructions}") + + self.agent = Agent( + name="Imam Reza Agent", + model=model, + instructions=self.custom_instructions , + markdown=True, + search_knowledge=False, + db=PostgresDb(db_url=self.db_url) if self.db_url else None, + add_history_to_context=True, + reasoning=False, + knowledge=None, # since we get data with pre hooks we don't need to pass knowledge here + debug_mode=False, + pre_hooks=[PromptInjectionGuardrail(),InputLimitGuardrail(),sync_config_hook,rag_injection_hook], + culture_manager=self.culture_manager, + add_culture_to_context=True, + description=self._build_culture_description(), + ) + def _build_culture_description(self) -> str: + """Helper to convert Culture Objects into a string for the LLM""" + # We pull the cultures we just seeded + cultures = self.culture_manager.get_all_knowledge() + if not cultures: + return "" + + description = "### Behavioral Guidelines (Culture)\n" + for c in cultures: + description += f"**{c.name}**:\n{c.content}\n\n" + return description + + def get_agent(self): + """Return the configured agent""" + return self.agent \ No newline at end of file diff --git a/src/agents/islamic_scholar_agent.py b/src/agents/islamic_scholar_agent.py new file mode 100644 index 0000000..75bf5df --- /dev/null +++ b/src/agents/islamic_scholar_agent.py @@ -0,0 +1,11 @@ +""" +Islamic Scholar Agent - specialized agent for Islamic knowledge +""" +from .base_agent import IslamicScholarAgent as BaseIslamicScholarAgent + + +class IslamicScholarAgent(BaseIslamicScholarAgent): + """Specialized Islamic Scholar Agent""" + + def __init__(self, model, knowledge_base, custom_instructions=None, db_url=None): + super().__init__(model, knowledge_base, custom_instructions, db_url) diff --git a/src/agents/tracing_agent.py b/src/agents/tracing_agent.py new file mode 100644 index 0000000..ec0854e --- /dev/null +++ b/src/agents/tracing_agent.py @@ -0,0 +1,98 @@ +import tiktoken +from agno.agent import Agent +# 🟢 Import the main 'Langfuse' class +from langfuse import Langfuse +from langfuse.decorators import observe, langfuse_context +from src.utils.shared_context import rag_prompt_var + +# 🟢 Initialize the client (It automatically reads your ENV variables) +langfuse_client = Langfuse() + + +class TracingAgent(Agent): + + def _count_tokens(self, text: str, model: str = "gpt-4o") -> int: + try: + encoding = tiktoken.encoding_for_model(model) + except KeyError: + encoding = tiktoken.get_encoding("cl100k_base") + return len(encoding.encode(text)) + + def arun(self, *args, **kwargs): + user_id = kwargs.get("user_id") + session_id = kwargs.get("session_id") + is_streaming = kwargs.get("stream", False) + + # Capture inputs + input_message = "" + if "message" in kwargs: input_message = kwargs["message"] + elif args: input_message = args[0] + + system_prompt = "\n".join(self.instructions) if self.instructions else "" + rag_prompt_var.set("") + + if is_streaming: + @observe(as_type="generation", name="Islamic Scholar Stream") + async def _stream_wrapper(): + # 1. Update Context + if user_id: langfuse_context.update_current_trace(user_id=str(user_id)) + if session_id: langfuse_context.update_current_trace(session_id=str(session_id)) + + full_content = "" + final_event_content = None + + # 2. Run Stream + async for chunk in super(TracingAgent, self).arun(*args, **kwargs): + event_type = getattr(chunk, "event", "") + content = getattr(chunk, "content", "") or "" + + if event_type == "RunCompleted": + final_event_content = content + elif isinstance(content, str) and content: + full_content += content + yield chunk + + # 3. Calculate Final Data + final_output = final_event_content if final_event_content else full_content + actual_rag_prompt = rag_prompt_var.get() + + if actual_rag_prompt: + full_input_text = f"{system_prompt}\n{actual_rag_prompt}" + else: + full_input_text = f"{system_prompt}\n{input_message}" + + input_count = self._count_tokens(full_input_text) + output_count = self._count_tokens(final_output) + total_count = input_count + output_count + + # 4. Update Trace Data + update_payload = { + "output": final_output, + "input": actual_rag_prompt if actual_rag_prompt else input_message, + "usage": { + "input": input_count, + "output": output_count, + "total": total_count + }, + "model": "deepseek-ai/deepseek-v3.1" # TODO: change to the actual model/dynamic + } + langfuse_context.update_current_observation(**update_payload) + + # 🟢 5. ADD SCORE MANUALLY (The Fix) + # First, get the ID of the current trace + current_trace_id = langfuse_context.get_current_trace_id() + + if current_trace_id: + # Send the score using the main client + langfuse_client.score( + trace_id=current_trace_id, + name="completeness", + value=1.0 if len(final_output) > 50 else 0.0, + comment="Auto-scored by TracingAgent" + ) + + return _stream_wrapper() + + # ... (Standard Path: Apply similar logic) ... + else: + return super(TracingAgent, self).arun(*args, **kwargs) \ No newline at end of file diff --git a/src/api/__init__.py b/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/api/dependencies.py b/src/api/dependencies.py new file mode 100644 index 0000000..7434e57 --- /dev/null +++ b/src/api/dependencies.py @@ -0,0 +1,14 @@ +""" +API dependencies for dependency injection +""" +from agents.islamic_scholar_agent import IslamicScholarAgent +from models.openai import OpenAILikeModel +from knowledge.rag_pipeline import create_knowledge_base + + +def get_agent(): + """Dependency to get configured agent""" + model = OpenAILikeModel() + knowledge_base = create_knowledge_base(vector_store_type="qdrant") + agent = IslamicScholarAgent(model.get_model(), knowledge_base) + return agent.get_agent() diff --git a/src/api/routes.py b/src/api/routes.py new file mode 100644 index 0000000..2ab02e0 --- /dev/null +++ b/src/api/routes.py @@ -0,0 +1,18 @@ +from typing import Optional, Any, Dict, List +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from src.agents.islamic_scholar_agent import IslamicScholarAgent +from src.models.factory import ModelFactory +from src.knowledge.embedding_factory import EmbeddingFactory +from src.knowledge.rag_pipeline import create_knowledge_base +from src.utils.load_settings import get_active_agent_config +from langfuse.decorators import observe, langfuse_context +import json +from fastapi.responses import StreamingResponse + + + +# @router.get("/health") +# async def health_check(): +# """Health check endpoint""" +# return {"status": "healthy", "agent": "Islamic Scholar Agent"} diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..86c3da1 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,32 @@ +""" +Configuration settings +""" +import os +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + """Application settings""" + + # Model settings + model_id: str = "deepseek-ai/deepseek-v3.1" + api_url: str = "https://gpt.nwhco.ir" + megallm_api_key: Optional[str] = None + openrouter_api_key: Optional[str] = None + + # Database settings + database_url: Optional[str] = None + qdrant_url: str = "http://127.0.0.1:6333" + + # Vector DB settings + collection_name: str = "dovoodi_collection" + embedder_model: str = "all-MiniLM-L6-v2" + embedder_dimensions: int = 384 + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/src/core/culture.py b/src/core/culture.py new file mode 100644 index 0000000..e4e1568 --- /dev/null +++ b/src/core/culture.py @@ -0,0 +1,55 @@ +import os +from agno.culture.manager import CultureManager +from agno.db.postgres import PostgresDb +from src.knowledge.manual_cultures import ALL_CULTURES +from urllib.parse import quote_plus +from src.models.factory import ModelFactory + + + +def get_culture_manager(agent_id: str = "islamic-scholar-main") -> CultureManager: + """ + Creates a CultureManager and ensures strict manual cultures are seeded. + """ + # 1. Setup Database Storage for Culture + # We use the same DB as your app (Postgres) + db_user = os.getenv("DB_USER") + db_pass = os.getenv("DB_PASSWORD") + db_host = os.getenv("DB_HOST") + db_port = os.getenv("DB_PORT") + db_name = os.getenv("DB_NAME") + db_pass = quote_plus(db_pass) + db_user = quote_plus(db_user) + db_url = f"postgresql+psycopg://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}" + model = ModelFactory() + + storage = PostgresDb( + culture_table="agent_culture", + db_url=db_url + ) + + # 2. Initialize Manager + manager = CultureManager( + model=model.get_model(), + db=storage, + # We assume the agent will read this, no need for active learning (embedding) + # unless you want RAG on your culture rules (overkill for 3 rules). + ) + + # 3. SYNC LOGIC: Ensure our manual rules are in the DB + # We treat the Python code as the "Master Copy". + print("✨ Syncing Cultural Knowledge Base...") + + existing_culture = manager.get_all_knowledge() + existing_names = {c.name for c in existing_culture} if existing_culture else set() + + for culture_item in ALL_CULTURES: + if culture_item.name in existing_names: + # Optional: You could implement an update logic here if text changed + # For now, we assume if it exists, it's fine. + pass + else: + print(f"➕ Seeding Culture: {culture_item.name}") + manager.add_cultural_knowledge(culture_item) + + return manager \ No newline at end of file diff --git a/src/core/logging.py b/src/core/logging.py new file mode 100644 index 0000000..cba0685 --- /dev/null +++ b/src/core/logging.py @@ -0,0 +1,31 @@ +""" +Logging configuration +""" +import logging +import sys +from .settings import DEBUG_MODE + + +def setup_logging(): + """Setup application logging""" + log_level = logging.DEBUG if DEBUG_MODE else logging.INFO + + # Configure root logger + logging.basicConfig( + level=log_level, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('app.log') if not DEBUG_MODE else logging.NullHandler() + ] + ) + + # Create logger for this module + logger = logging.getLogger(__name__) + logger.info("Logging setup completed") + + return logger + + +# Global logger instance +logger = setup_logging() diff --git a/src/core/settings.py b/src/core/settings.py new file mode 100644 index 0000000..9ca3804 --- /dev/null +++ b/src/core/settings.py @@ -0,0 +1,32 @@ +""" +Settings and environment variables management +""" +import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Application settings +APP_NAME = "Islamic Scholar Agent" +APP_VERSION = "1.0.0" +DEBUG_MODE = os.getenv("DEBUG_MODE", "true").lower() == "true" + +# Server settings +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "8081")) + +# Model settings +MODEL_ID = os.getenv("MODEL_ID", "deepseek-ai/deepseek-v3.1") +API_URL = os.getenv("API_URL", "https://gpt.nwhco.ir") +MEGALLM_API_KEY = os.getenv("MEGALLM_API_KEY") +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") + +# Database settings +DATABASE_URL = os.getenv("DATABASE_URL") +QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333") + +# Vector DB settings +COLLECTION_NAME = os.getenv("COLLECTION_NAME", "dovoodi_collection") +EMBEDDER_MODEL = os.getenv("EMBEDDER_MODEL", "all-MiniLM-L6-v2") +EMBEDDER_DIMENSIONS = int(os.getenv("EMBEDDER_DIMENSIONS", "384")) diff --git a/src/guardrails/__init__.py b/src/guardrails/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guardrails/limit.py b/src/guardrails/limit.py new file mode 100644 index 0000000..a1ad07b --- /dev/null +++ b/src/guardrails/limit.py @@ -0,0 +1,60 @@ +import re +from dotenv import load_dotenv +from agno.guardrails.base import BaseGuardrail +from agno.run.agent import RunInput +from agno.exceptions import CheckTrigger, InputCheckError +from src.models.factory import ModelFactory + +# 1. Load keys to be safe +load_dotenv() + +class InputLimitGuardrail(BaseGuardrail): + def __init__(self, max_chars: int = 2000): + super().__init__() + self.name = "Smart Input Limit Guardrail" + self.max_chars = max_chars + self.error_generator = ModelFactory().get_model() + + def check(self, run_input: RunInput) -> None: + """Sync check""" + if isinstance(run_input.input_content, str): + if len(run_input.input_content) > self.max_chars: + sample = run_input.input_content[:200] + # Sync call + msg = self._generate_error_sync(sample) + raise InputCheckError(msg, check_trigger=CheckTrigger.INPUT_NOT_ALLOWED) + + async def async_check(self, run_input: RunInput) -> None: + """Async check""" + if isinstance(run_input.input_content, str): + if len(run_input.input_content) > self.max_chars: + sample = run_input.input_content[:200] + # 👇 Use the ASYNC generator here + msg = await self._generate_error_async(sample) + raise InputCheckError(msg, check_trigger=CheckTrigger.INPUT_NOT_ALLOWED) + + def _generate_error_sync(self, sample_text: str) -> str: + prompt = self._build_prompt(sample_text) + try: + response = self.error_generator.response(messages=[{"role": "user", "content": prompt}]) + return response.content.strip() + except Exception as e: + print(f"❌ Sync Guardrail Failed: {e}") + return f"⚠️ Input too long. Max {self.max_chars} chars." + + async def _generate_error_async(self, sample_text: str) -> str: + prompt = self._build_prompt(sample_text) + try: + # 👇 Use aresponse() for async + response = await self.error_generator.aresponse(messages=[{"role": "user", "content": prompt}]) + return response.content.strip() + except Exception as e: + print(f"❌ Async Guardrail Failed: {e}") + return f"⚠️ Input too long. Max {self.max_chars} chars." + + def _build_prompt(self, sample_text: str) -> str: + return ( + f"Identify the language of this text: \"{sample_text}\"\n" + f"Write a polite error in that language saying: 'Input too long (max {self.max_chars} chars).'\n" + f"Return ONLY the message." + ) \ No newline at end of file diff --git a/src/knowledge/__init__.py b/src/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/knowledge/embedding_factory.py b/src/knowledge/embedding_factory.py new file mode 100644 index 0000000..1d2c99d --- /dev/null +++ b/src/knowledge/embedding_factory.py @@ -0,0 +1,67 @@ +import yaml +import os +from typing import Optional +from agno.knowledge.embedder.openai import OpenAIEmbedder +from agno.knowledge.embedder.jina import JinaEmbedder +from pathlib import Path +# If Agno supports generic OpenAI-like embedders, we use OpenAIEmbedder with base_url + +class EmbeddingFactory: + def __init__(self): + # Get the directory where this file (factory.py) is located + current_file_path = Path(__file__).resolve() + + # Navigate up to the project root + # If structure is: /app/src/models/factory.py + # .parent = models, .parent = src, .parent = app (root) + project_root = current_file_path.parent.parent.parent + + # Construct the absolute path + config_path = project_root / 'config' / 'embeddings.yaml' + + print(f"Loading config from: {config_path}") # Debug log + with open(config_path) as f: + # Simple variable expansion for ${VAR} + content = f.read() + for key, val in os.environ.items(): + content = content.replace(f"${{{key}}}", val) + self.config = yaml.safe_load(content) + + def get_embedder(self, model_name: Optional[str] = None): + # 1. Default Logic + if model_name is None: + model_name = self.config['embeddings']['default'] + + + + models_config = self.config['embeddings']['models'] + if model_name not in models_config: + raise ValueError(f"Embedding model '{model_name}' not found in config.") + + config = models_config[model_name] + provider = config['provider'] + # # 2. Provider Logic + api_key_env = config.get('api_key') + if api_key_env and api_key_env.startswith("${"): + api_key = os.getenv(api_key_env[2:-1]) + else: + api_key = api_key_env + + # CASE B: OpenAI (Official) + if provider == "openai": + return OpenAIEmbedder( + id=config['id'], + dimensions=config['dimensions'], + api_key=api_key + ) + + # CASE C: OpenAI Compatible (Jina API, etc.) + elif provider == "jinaai": + return JinaEmbedder( + id=config['id'], + dimensions=config['dimensions'], + api_key=api_key + ) + + print(f"Unknown provider type: {provider}") + raise ValueError(f"Unknown provider type: {provider}") \ No newline at end of file diff --git a/src/knowledge/manual_cultures.py b/src/knowledge/manual_cultures.py new file mode 100644 index 0000000..12d3da1 --- /dev/null +++ b/src/knowledge/manual_cultures.py @@ -0,0 +1,46 @@ +from agno.db.schemas.culture import CulturalKnowledge + + +# A. The Culture of "Adab" (Etiquette) +adab_culture = CulturalKnowledge( + name="Adab (Etiquette)", + summary="Islamic etiquette standards for formal and respectful communication", + categories=["etiquette", "communication", "islamic-manners"], + content=( + "- Greeting: Always begin formal responses with 'In the name of Allah' (Bismillah) if the topic is serious.\n" + "- Honorifics: When mentioning the Prophet, always append '(peace be upon him)' or 'ﷺ'.\n" + "- Honorifics: When mentioning companions, use '(may Allah be pleased with them)'.\n" + "- Humility: If the answer is complex or has multiple views, end with 'And Allah knows best' (Allahu A'lam)." + ), + notes=["Rooted in classical Islamic scholarly tradition of adab"], +) + + +# B. The Culture of "Nuance" (Handling Conflict) +nuance_culture = CulturalKnowledge( + name="Nuance (Handling Conflict)", + summary="Guidelines for handling scholarly divergence and ambiguity with care", + categories=["conflict-resolution", "scholarly-method", "caution"], + content=( + "- Ikhtilaf (Divergence): If the retrieved documents show two different Hadiths, " + "do not say 'This is a contradiction.' Instead, say 'There are varying narrations regarding this topic.'\n" + "- Caution: Never give a personal opinion.\n" + "- If the database is vague, lean towards 'The available sources do not provide a definitive answer.'" + ), + notes=["Ensures respectful treatment of scholarly differences (ikhtilaf)"], +) + + +# C. The Culture of "Formatting" +formatting_culture = CulturalKnowledge( + name="Formatting", + summary="Formatting and language rules for citations and multilingual responses", + categories=["formatting", "citations", "language"], + content=( + "- Citations: Always bold the name of the book source (e.g., **Sahih Bukhari**).\n" + "- Language: Answer in the same language as the user's question. for example :If the user asks in Persian, use formal/polite Persian grammatical structures." + ), + notes=["Maintains consistent citation style and language-appropriate formality"], +) + +ALL_CULTURES = [adab_culture, nuance_culture, formatting_culture] \ No newline at end of file diff --git a/src/knowledge/rag_pipeline.py b/src/knowledge/rag_pipeline.py new file mode 100644 index 0000000..a409447 --- /dev/null +++ b/src/knowledge/rag_pipeline.py @@ -0,0 +1,11 @@ +""" +RAG Pipeline implementation +""" +from agno.knowledge.knowledge import Knowledge +from .vector_store import get_qdrant_store + + +def create_knowledge_base(**kwargs): + """Create knowledge base with specified vector store""" + vector_db = get_qdrant_store(**kwargs) + return Knowledge(vector_db=vector_db) diff --git a/src/knowledge/vector_store.py b/src/knowledge/vector_store.py new file mode 100644 index 0000000..05e524e --- /dev/null +++ b/src/knowledge/vector_store.py @@ -0,0 +1,34 @@ +""" +Vector store configurations +""" +from agno.vectordb.qdrant import Qdrant +from agno.vectordb.search import SearchType +import os + +# ❌ REMOVE THIS IMPORT +# from .embeddings import get_local_embedder + +def get_qdrant_store(collection_name=None, url=None, embedder=None): + """Get configured Qdrant vector store""" + + # 1. ⚠️ CRITICAL CHECK FIRST + # We MUST fail if no embedder is provided, rather than guessing a default + if embedder is None: + raise ValueError("You must provide an 'embedder' instance to get_qdrant_store!") + + # 2. Use env var if argument is missing, otherwise use default + base_collection = collection_name or os.getenv("BASE_COLLECTION_NAME") + collection = f"{base_collection}_{embedder.id}_hybrid" + qdrant_host = os.getenv("QDRANT_HOST") + qdrant_port = os.getenv("QDRANT_PORT") + qdrant_api_key = os.getenv("QDRANT_API_KEY") + qdrant_url = f"http://{qdrant_host}:{qdrant_port}" + print(f"Collection: {collection}") + return Qdrant( + collection=collection, + url=qdrant_url, + embedder=embedder, + timeout=10.0, + api_key=qdrant_api_key, + search_type=SearchType.hybrid + ) \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..e558d7c --- /dev/null +++ b/src/main.py @@ -0,0 +1,95 @@ +""" +Main application entry point for Islamic Scholar Agent +""" +import sys +import os + +# Add project root to Python path when run directly +if __name__ == "__main__" and __package__ is None: + # When running as script, add the parent directory to make 'src' importable + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + sys.path.insert(0, project_root) + +from agno.os import AgentOS +from src.agents.islamic_scholar_agent import IslamicScholarAgent +from src.models.factory import ModelFactory +from src.knowledge.rag_pipeline import create_knowledge_base +from src.core.logging import logger +from src.knowledge.embedding_factory import EmbeddingFactory +from langfuse.openai import openai as langfuse_openai +from langfuse import Langfuse +from src.utils.load_settings import get_active_agent_config +from dotenv import load_dotenv +load_dotenv() + + +def create_app(): + """Create and configure the FastAPI application""" + # Initialize model + model = ModelFactory() + embed_factory = EmbeddingFactory() + current_embedder = embed_factory.get_embedder() + + # Try to initialize knowledge base, but handle connection errors gracefully + try: + knowledge_base = create_knowledge_base( + embedder=current_embedder, + ) + logger.info(f"Knowledge base initialized with: {current_embedder.id}") + except Exception as e: + logger.warning(f"Could not initialize knowledge base: {e}") + logger.warning("Application will start without knowledge base. Some features may not work.") + knowledge_base = None + print(f"Could not initialize knowledge base: {e}") + print("Application will start without knowledge base. Some features may not work.") + + # Create agent - use fallback if knowledge base is not available + if knowledge_base: + print(f"Knowledge base initialized with: {current_embedder.id}") + agent = IslamicScholarAgent(model.get_model(), knowledge_base) + agent_os = AgentOS(agents=[agent.get_agent()]) + else: + # Create a fallback agent without knowledge base + logger.warning("Creating fallback agent without knowledge base") + print(f"****************************************************************") + print(f"Creating fallback agent without knowledge base") + from agno.agent import Agent + fallback_agent = Agent( + name="Fallback Islamic Scholar Agent", + model=model.get_model(), + description="Basic Islamic knowledge agent (knowledge base unavailable)", + instructions=[ + "You are a basic Islamic knowledge assistant.", + "The knowledge base is currently unavailable.", + "Please inform the user that the full knowledge base is not accessible at the moment.", + "You can still provide general guidance about Islamic topics." + ], + markdown=False, + debug_mode=True, + ) + agent_os = AgentOS(agents=[fallback_agent]) + + # Get FastAPI app + # langfuse_openai.langfuse_public_key = os.getenv("LANGFUSE_PUBLIC_KEY") + # langfuse_openai.langfuse_secret_key = os.getenv("LANGFUSE_SECRET_KEY") + # langfuse_openai.langfuse_host = os.getenv("LANGFUSE_HOST") + app = agent_os.get_app() + + logger.info("Islamic Scholar Agent application created successfully") + + return app +# Create application instance +app = create_app() + + +if __name__ == "__main__": + import uvicorn + from core.settings import HOST , PORT , DEBUG_MODE + logger.info(f"Starting server on {HOST}:{PORT} (debug={DEBUG_MODE})") + uvicorn.run( + "src.main:app", + host=HOST, + port=PORT, + reload=DEBUG_MODE, + log_level="debug" if DEBUG_MODE else "info" + ) diff --git a/src/models/__init__.py b/src/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/base_model.py b/src/models/base_model.py new file mode 100644 index 0000000..adc2b9e --- /dev/null +++ b/src/models/base_model.py @@ -0,0 +1,18 @@ +""" +Base model configurations +""" +from abc import ABC, abstractmethod +from typing import Optional +import os + +class BaseLLMProvider(ABC): + """Abstract base class for LLM providers""" + + def __init__(self, api_key: str, base_url: Optional[str] = None): + self.api_key = api_key + self.base_url = base_url + + @abstractmethod + def get_model(self): + """Return configured model instance""" + pass \ No newline at end of file diff --git a/src/models/factory.py b/src/models/factory.py new file mode 100644 index 0000000..a101a80 --- /dev/null +++ b/src/models/factory.py @@ -0,0 +1,86 @@ +import os +import yaml +from typing import Optional +from agno.models.openrouter import OpenRouter +from agno.models.openai.like import OpenAILike +from pathlib import Path + +class ModelFactory: + """Factory for creating LLM instances from configuration""" + + def __init__(self): + # Get the directory where this file (factory.py) is located + current_file_path = Path(__file__).resolve() + + # Navigate up to the project root + # If structure is: /app/src/models/factory.py + # .parent = models, .parent = src, .parent = app (root) + project_root = current_file_path.parent.parent.parent + + # Construct the absolute path + config_path = project_root / 'config' / 'models.yaml' + + print(f"Loading config from: {config_path}") # Debug log + with open(config_path) as f: + # We need to expand env vars manually (simple version) + raw_config = f.read() + # Replace ${VAR} with os.getenv('VAR') logic could go here, + # or rely on the provider classes to handle env vars if passed as None. + # For simplicity, let's assume we parse the YAML normally: + self.config = yaml.safe_load(raw_config) + + def get_model(self, model_name: Optional[str] = None): + # 1. Determine which model to load + if model_name is None: + model_name = self.config['models']['default'] + print(f"Using default model: {model_name}") + + # 2. Search for the model in all providers + providers = self.config['models']['providers'] + for provider_name, provider_data in providers.items(): + if model_name in provider_data['models']: + # Found it! + print(f"Found model: {model_name} in provider: {provider_name}") + model_config_data = provider_data['models'][model_name] + + # Resolving Env Vars for Keys + api_key_env = provider_data.get('api_key') + if api_key_env and api_key_env.startswith("${"): + api_key = os.getenv(api_key_env[2:-1]) + else: + api_key = api_key_env + + base_url_env = provider_data.get('base_url') + if base_url_env and base_url_env.startswith("${"): + base_url = os.getenv(base_url_env[2:-1]) + else: + base_url = base_url_env + + # 3. Instantiate the correct Class + if provider_name == 'openai_like': + return OpenRouter( + id=model_config_data['id'], + api_key=api_key, + base_url=base_url, + max_tokens=model_config_data.get('max_tokens', 4096), + collect_metrics_on_completion=True, + default_headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + ) + + elif provider_name == 'openrouter': + return OpenRouter( + id =model_config_data['id'], + api_key=api_key, + base_url=base_url, + collect_metrics_on_completion=True, + max_tokens=model_config_data.get('max_tokens', 4096), + default_headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + ) + + raise ValueError(f"Model '{model_name}' not found in configuration") \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/hooks.py b/src/utils/hooks.py new file mode 100644 index 0000000..6252746 --- /dev/null +++ b/src/utils/hooks.py @@ -0,0 +1,44 @@ +from agno.run.agent import RunInput +from src.utils.load_settings import get_active_agent_config +from src.utils.search_knowledge import build_rag_prompt +from langfuse.decorators import observe + + + +def sync_config_hook(run_input: RunInput, **kwargs): + """ + Agno Pre-Hook: Fetches the latest Django DB config and + injects it into the agent before the run starts. + """ + # 1. Get the agent instance from kwargs + agent = kwargs.get("agent") + if not agent: + return + + # 1. Fetch Config + config = get_active_agent_config() + + # 2. Check if we have prompts + if config and config.get("system_prompts"): + new_prompts = config["system_prompts"] + + # 3. 🪄 OVERWRITE INSTRUCTIONS + # We replace the entire list of instructions with the DB list. + agent.instructions = new_prompts + + print(f"🔄 Hook: Loaded {len(new_prompts)} active instructions from DB") + print(f"🔄 Hook: Active prompts: {new_prompts}") + # print(f"First instruction: {new_prompts[0][:50]}...") + + return run_input + +@observe(name="rag_injection_hook") +def rag_injection_hook(run_input: RunInput, **kwargs): + """ + Intercepts the user input and injects RAG context. + """ + print("🪝 Hook: Injecting RAG Context...") + # Modify the input content in place + original_input = run_input.input_content + run_input.input_content = build_rag_prompt(original_input) + # Don't return anything - modifications are in-place diff --git a/src/utils/load_settings.py b/src/utils/load_settings.py new file mode 100644 index 0000000..c192f40 --- /dev/null +++ b/src/utils/load_settings.py @@ -0,0 +1,76 @@ +from urllib.parse import quote_plus +import os +from sqlalchemy import create_engine, text +from sqlalchemy.exc import OperationalError +import time + +db_user = os.getenv("DB_USER") +db_pass = os.getenv("DB_PASSWORD") +db_host = os.getenv("DB_HOST") +db_port = os.getenv("DB_PORT") +db_name = os.getenv("DB_NAME") +db_pass = quote_plus(db_pass) +db_user = quote_plus(db_user) +db_url = f"postgresql+psycopg://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}" +# 🟢 IMPROVED ENGINE +engine = create_engine( + db_url, + pool_pre_ping=True, # 👈 This checks if the connection is alive before using it + pool_recycle=3600, # 👈 Closes and reopens connections older than 1 hour + pool_size=10, # 👈 Allows up to 10 simultaneous connections + max_overflow=20 # 👈 Allows extra connections during high traffic +) + + +def get_active_agent_config(retries=3, delay=1): + """ + Fetches active prompts with automatic retry logic for database "hiccups". + """ + attempt = 0 + while attempt < retries: + try: + with engine.connect() as conn: + query = text(""" + SELECT content + FROM agent_agentprompt + WHERE settings_id = 1 AND is_active = true + ORDER BY id ASC + """) + result = conn.execute(query).fetchall() + prompt_list = [row.content for row in result] + + return {"system_prompts": prompt_list} + + except OperationalError as e: + attempt += 1 + print(f"⚠️ DB Connection error (Attempt {attempt}/{retries}): {e}") + if attempt < retries: + time.sleep(delay) # Wait a second before trying again + else: + print("❌ DB Retry limit reached.") + return None + except Exception as e: + print(f"❌ Unexpected Error: {e}") + return None + +def default_system_prompt(): + return [ + "You are a strict Islamic Knowledge Assistant.", + "Your Goal: Answer the user's question using the provided 'Context from the database'.", + + "STRICT BEHAVIORAL RULE: You must maintain the highest standard of Adab (Etiquette).", + "If the user is disrespectful, vulgar, uses profanity, or mocks Islam:", + "1. Do NOT engage with the toxicity.", + "2. Do NOT lecture them.", + "3. Refuse to answer immediately by saying: 'I cannot answer this due to violations of Adab.'", + + # --- CRITICAL FIXES --- + "If the Context is in a different language than the User's Question, you MUST translate the relevant information into the User's language.", + "Do NOT worry if the context source language (e.g., Russian/Arabic) does not match the user's language (e.g., English). Translate the meaning accurately.", + # ---------------------- + + "If the answer is explicitly found in the context (even in another language), answer directly.", + "If the answer is NOT found in the context, strictly reply: 'Information not available in the knowledge base.'", + "Maintain a respectful, scholarly tone.", + "Do not explain your reasoning process in the final output.", + ] \ No newline at end of file diff --git a/src/utils/reranker.py b/src/utils/reranker.py new file mode 100644 index 0000000..2173e01 --- /dev/null +++ b/src/utils/reranker.py @@ -0,0 +1,189 @@ +# import os +# import requests +# import json +# from typing import List, Any +# from dotenv import load_dotenv + +# load_dotenv() + +# def rerank_documents(query: str, documents: List[Any], top_n: int = 3) -> List[Any]: +# """ +# Reranks a list of documents using Jina AI's Reranker API. + +# Args: +# query: The user's question. +# documents: List of document objects (must have a .content attribute). +# top_n: How many top documents to return. + +# Returns: +# The top_n sorted document objects. +# """ +# print(f"🔍🔍🔍🔍🔍 Reranking documents🔍🔍🔍🔍🔍") +# api_key = os.getenv("JINA_API_KEY") +# if not api_key: +# print("⚠️ JINA_API_KEY not found. Returning original order.") +# return documents[:top_n] + +# # 1. Prepare data for Jina +# # Jina needs a list of strings. We extract .content from your Agno Document objects. +# doc_contents = [doc.content for doc in documents] + +# url = "https://api.jina.ai/v1/rerank" +# headers = { +# "Content-Type": "application/json", +# "Authorization": f"Bearer {api_key}" +# } +# payload = { +# "model": "jina-reranker-v3", # Best for mixed language (English/Arabic/Persian) +# "query": query, +# "documents": doc_contents, +# "top_n": top_n +# } + +# try: +# # 2. Call Jina API +# response = requests.post(url, headers=headers, json=payload) +# response.raise_for_status() +# results = response.json()["results"] + +# # 3. Map back to original Document objects +# # Jina returns indices (e.g., "index 4 is the best"). We use these to pick from your original list. +# reranked_docs = [] +# for result in results: +# original_index = result["index"] +# relevance_score = result["relevance_score"] + +# doc = documents[original_index] + +# # 👇 FIX 1: Ensure meta_data exists before writing to it +# if not hasattr(doc, "meta_data") or doc.meta_data is None: +# doc.meta_data = {} + +# # 👇 FIX 2: Use .meta_data (with underscore) +# doc.meta_data["rerank_score"] = relevance_score + +# reranked_docs.append(doc) + +# print(f"✨ Reranked {len(documents)} docs -> Top {len(reranked_docs)}") +# return reranked_docs + +# except Exception as e: +# print(f"❌ Reranking failed: {e}. Falling back to vector search order.") +# return documents[:top_n] + + + +import os +import yaml +import requests +import re +from typing import List, Any, Dict, Optional +from dotenv import load_dotenv + +load_dotenv() + +class Reranker: + def __init__(self, config_path: str = "config/rerankers.yaml"): + self.config = self._load_config(config_path) + + # 1. Get the active model configuration + self.active_model_name = self.config["rerankers"]["default"] + self.model_config = self.config["rerankers"]["models"][self.active_model_name] + + # 2. Extract key params + self.provider = self.model_config.get("provider") + self.model_id = self.model_config.get("model") + self.default_top_n = self.model_config.get("top_n", 3) + self.api_key = self.model_config.get("api_key") + self.base_url = self.model_config.get("base_url") + + print(f"🚀 Initialized Reranker: {self.active_model_name} ({self.provider})") + + def _load_config(self, path: str) -> Dict: + """Loads YAML and replaces ${VAR} with env variables.""" + if not os.path.exists(path): + raise FileNotFoundError(f"Config file not found at: {path}") + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + # Regex to find ${VAR_NAME} and replace with os.getenv('VAR_NAME') + pattern = re.compile(r'\$\{(\w+)\}') + def replace(match): + env_var = match.group(1) + return os.getenv(env_var, "") + + updated_content = pattern.sub(replace, content) + return yaml.safe_load(updated_content) + + def rerank_documents(self, query: str, documents: List[Any], top_n: Optional[int] = None) -> List[Any]: + """ + Main entry point for reranking. + """ + final_top_n = top_n if top_n is not None else self.default_top_n + + if not documents: + return [] + + print(f"🔍 Reranking {len(documents)} docs using {self.provider}...") + + try: + # Route to the correct provider logic + if self.provider == "jinaai": + return self._rerank_jina(query, documents, final_top_n) + else: + print(f"⚠️ Unknown provider '{self.provider}'. Returning original order.") + return documents[:final_top_n] + except Exception as e: + print(f"❌ Reranking Error: {e}") + return documents[:final_top_n] + + def _rerank_jina(self, query: str, documents: List[Any], top_n: int) -> List[Any]: + if not self.api_key: + print("⚠️ Missing Jina API Key. Skipping rerank.") + return documents[:top_n] + + # Prepare payload + doc_contents = [getattr(doc, "content", str(doc)) for doc in documents] + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + payload = { + "model": self.model_id, + "query": query, + "documents": doc_contents, + "top_n": top_n + } + + response = requests.post(self.base_url, headers=headers, json=payload) + response.raise_for_status() + results = response.json()["results"] + + # Map results back to original documents + reranked_docs = [] + for result in results: + original_index = result["index"] + relevance_score = result["relevance_score"] + + doc = documents[original_index] + + # Safely add metadata + if not hasattr(doc, "meta_data") or doc.meta_data is None: + doc.meta_data = {} + + doc.meta_data["rerank_score"] = relevance_score + doc.meta_data["rerank_model"] = self.model_id + + reranked_docs.append(doc) + + print(f"✨ Top score: {results[0]['relevance_score']}") + return reranked_docs + +# Singleton instance to avoid reloading config on every request +reranker_instance = Reranker() + +# Public function interface (keeps your existing code working) +def rerank_documents(query: str, documents: List[Any], top_n: int = 3) -> List[Any]: + return reranker_instance.rerank_documents(query, documents, top_n) \ No newline at end of file diff --git a/src/utils/search_knowledge.py b/src/utils/search_knowledge.py new file mode 100644 index 0000000..b8729d6 --- /dev/null +++ b/src/utils/search_knowledge.py @@ -0,0 +1,91 @@ +from agno.vectordb.qdrant import Qdrant +from agno.knowledge.knowledge import Knowledge +from src.utils.reranker import rerank_documents +import os +import sys +from pathlib import Path + +# Add src to path for imports +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from src.knowledge.embedding_factory import EmbeddingFactory +from src.knowledge.vector_store import get_qdrant_store +from src.utils.shared_context import rag_prompt_var +# Global knowledge base instance for lazy initialization +knowledge_base = None + + +def build_rag_prompt(user_question: str, embedder_model_name: str = None) -> str: + """RAG pipeline با Qdrant - استفاده از سیستم embedding جدید""" + global knowledge_base + + print(f"🔍🔍🔍🔍🔍 Building RAG prompt for user question🔍🔍🔍🔍🔍") + + try: + # Lazy initialization of knowledge base + if knowledge_base is None: + print("📚 Initializing Knowledge Base...") + + # استفاده از سیستم embedding جدید + embed_factory = EmbeddingFactory() + embedder = embed_factory.get_embedder(embedder_model_name) + + print(f"🔍 Using embedder: {embedder.id} (dimensions: {embedder.dimensions})") + + # استفاده از get_qdrant_store برای collection اتوماتیک + vector_db = get_qdrant_store(embedder=embedder) + knowledge_base = Knowledge(vector_db=vector_db) + + print(f"✅ Knowledge Base initialized with collection: {vector_db.collection}") + + # Search for relevant documents + print(f"🔍🔍🔍🔍🔍 Searching for relevant documents🔍🔍🔍🔍🔍") + initial_results = knowledge_base.search(query=user_question, max_results=7) + if not initial_results: + context_str = "No information found in database." + print(f"❌ No information found in database.") + else: + print(f"📥 Retrieved {len(initial_results)} candidates from Qdrant.") + + # 2. RERANKING (The Smart Filter) + # Send the 15 docs to Jina to pick the best 3 + relevant_docs = rerank_documents( + query=user_question, + documents=initial_results, + top_n=3 + ) + # 3. CONTEXT CONSTRUCTION + # Build the string from the SMART list + context_parts = [] + for doc in relevant_docs: + # 👇 FIX 3: Safety check + use .meta_data + meta = getattr(doc, "meta_data", {}) or {} + + # Get source safely + source = meta.get('source', 'Unknown') + + # Get score safely + score = meta.get('rerank_score', 0) + + content = f"[Source: {source} | Relevance: {score:.2f}]\n{doc.content}" + context_parts.append(content) + + context_str = "\n\n".join(context_parts) + + except Exception as e: + print(f"⚠️ Knowledge Base error (continuing without RAG): {e}") + context_str = "Knowledge base temporarily unavailable. dont answer the question because you have no related information in the database." + + final_prompt = ( + "Here is the context from the database:\n" + "---------------------\n" + f"{context_str}\n" + "---------------------\n" + f"User Question: {user_question}" + ) + + # 2. 🟢 SAVE IT TO THE CONTEXT VAR + # This makes it accessible to the TracingAgent wrapper + rag_prompt_var.set(final_prompt) + return final_prompt diff --git a/src/utils/shared_context.py b/src/utils/shared_context.py new file mode 100644 index 0000000..2e2a198 --- /dev/null +++ b/src/utils/shared_context.py @@ -0,0 +1,5 @@ +from contextvars import ContextVar + +# This variable will store the FULL prompt (User + RAG) for the current request +# It is async-safe, so multiple users won't mix up their data. +rag_prompt_var = ContextVar("rag_prompt_var", default="") \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..78a9e8d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +""" +Pytest configuration and fixtures +""" +import pytest +import sys +import os + +# Add src to path for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + + +@pytest.fixture +def sample_hadith_data(): + """Sample hadith data for testing""" + return { + "Title": "Sample Hadith", + "Arabic Text": "عن أبي هريرة رضي الله عنه", + "Translation": "From Abu Hurairah, may Allah be pleased with him", + "Source Info": "Sahih Bukhari" + } + + +@pytest.fixture +def sample_article_data(): + """Sample article data for testing""" + return { + "Title": "Sample Islamic Article", + "Content": "This is a sample Islamic article content...", + "Author": "Islamic Scholar", + "Source": "Islamic Website" + } diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py new file mode 100644 index 0000000..29aa0ba --- /dev/null +++ b/tests/integration/test_api.py @@ -0,0 +1,61 @@ +""" +Integration tests for API endpoints +""" +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, Mock + + +class TestAPIIntegration: + """Integration tests for API""" + + @pytest.fixture + def client(self): + """Test client fixture""" + # Import here to avoid circular imports + from src.main import app + return TestClient(app) + + def test_health_endpoint(self, client): + """Test health check endpoint""" + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert data["status"] == "healthy" + + @patch('src.api.routes.IslamicScholarAgent') + def test_chat_endpoint_success(self, mock_agent_class, client): + """Test successful chat endpoint""" + # Mock the agent + mock_agent_instance = Mock() + mock_response = Mock() + mock_response.content = "Test Islamic knowledge response" + mock_agent_instance.run.return_value = mock_response + mock_agent_instance.get_agent.return_value = mock_agent_instance + + mock_agent_class.return_value = mock_agent_instance + + response = client.post("/chat", json={"message": "What is Islamic knowledge?"}) + + assert response.status_code == 200 + data = response.json() + assert "response" in data + assert data["response"] == "Test Islamic knowledge response" + + @patch('src.api.routes.IslamicScholarAgent') + def test_chat_endpoint_error(self, mock_agent_class, client): + """Test chat endpoint with error""" + # Mock the agent to raise exception + mock_agent_instance = Mock() + mock_agent_instance.run.side_effect = Exception("Test error") + mock_agent_instance.get_agent.return_value = mock_agent_instance + + mock_agent_class.return_value = mock_agent_instance + + response = client.post("/chat", json={"message": "Test message"}) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data diff --git a/tests/integration/test_pipeline.py b/tests/integration/test_pipeline.py new file mode 100644 index 0000000..e246ff5 --- /dev/null +++ b/tests/integration/test_pipeline.py @@ -0,0 +1,78 @@ +""" +Integration tests for the complete RAG pipeline +""" +import pytest +import tempfile +import os +from unittest.mock import patch, Mock +import pandas as pd + + +class TestRAGPipelineIntegration: + """Integration tests for complete RAG pipeline""" + + @pytest.fixture + def temp_excel_file(self, sample_hadith_data): + """Create temporary Excel file for testing""" + df = pd.DataFrame([sample_hadith_data]) + + with tempfile.NamedTemporaryFile(suffix='.xlsx', delete=False) as tmp: + df.to_excel(tmp.name, index=False) + yield tmp.name + + # Cleanup + os.unlink(tmp.name) + + @patch('src.knowledge.vector_store.get_qdrant_store') + def test_full_ingestion_pipeline(self, mock_get_qdrant, temp_excel_file): + """Test full data ingestion pipeline""" + # Mock vector store + mock_vector_db = Mock() + mock_get_qdrant.return_value = mock_vector_db + + # Mock knowledge base + with patch('src.knowledge.rag_pipeline.Knowledge') as mock_kb_class: + mock_kb_instance = Mock() + mock_kb_class.return_value = mock_kb_instance + + # Import and run ingestion + from src.knowledge.rag_pipeline import create_knowledge_base, ingest_excel_data + + # Create knowledge base + kb = create_knowledge_base(vector_store_type="qdrant") + + # Ingest data + count = ingest_excel_data(kb, temp_excel_file, "hadiths") + + # Verify calls + mock_get_qdrant.assert_called_once() + mock_kb_class.assert_called_once_with(vector_db=mock_vector_db) + assert count == 1 + mock_kb_instance.add_content.assert_called_once() + + @patch('src.agents.islamic_scholar_agent.IslamicScholarAgent') + @patch('src.models.openai.OpenAILikeModel') + @patch('src.knowledge.rag_pipeline.create_knowledge_base') + def test_agent_with_knowledge_base(self, mock_create_kb, mock_model_class, mock_agent_class): + """Test agent initialization with knowledge base""" + # Setup mocks + mock_model_instance = Mock() + mock_model_instance.get_model.return_value = Mock() + mock_model_class.return_value = mock_model_instance + + mock_kb = Mock() + mock_create_kb.return_value = mock_kb + + mock_agent_instance = Mock() + mock_agent_class.return_value = mock_agent_instance + + # Import and create agent + from src.agents.islamic_scholar_agent import IslamicScholarAgent + + agent = IslamicScholarAgent(mock_model_instance.get_model(), mock_kb) + + # Verify initialization + mock_agent_class.assert_called_once_with( + mock_model_instance.get_model(), + mock_kb + ) diff --git a/tests/integration/test_qdrant_connection.py b/tests/integration/test_qdrant_connection.py new file mode 100644 index 0000000..e5e3b37 --- /dev/null +++ b/tests/integration/test_qdrant_connection.py @@ -0,0 +1,244 @@ +""" +Integration tests for Qdrant vector database connection +""" +import pytest +import os +from unittest.mock import patch, Mock +from qdrant_client import QdrantClient +from qdrant_client.http.exceptions import UnexpectedResponse +import sys +from pathlib import Path +from dotenv import load_dotenv # <--- ADD THIS +load_dotenv() + +# ----------------------------------------------------------------------------- +# DYNAMIC PATH SETUP +# This finds the project root automatically, whether run from root or tests/ folder +# ----------------------------------------------------------------------------- +# Get the absolute path of this test file +current_file = Path(__file__).resolve() + +# Find the 'src' directory by looking up the tree +# We look for the folder that contains 'src' +root_path = current_file.parent +while not (root_path / 'src').exists(): + if root_path == root_path.parent: # Reached system root + raise FileNotFoundError("Could not find project root containing 'src' folder") + root_path = root_path.parent + +# Add the project root to Python path +sys.path.insert(0, str(root_path)) +print(f"🔧 Added project root to path: {root_path}") +# ----------------------------------------------------------------------------- + +# Import the modules we need to test +from src.knowledge.vector_store import get_qdrant_store +from src.knowledge.embedding_factory import EmbeddingFactory + + +class TestQdrantConnection: + """Test Qdrant vector database connection""" + + @pytest.fixture + def mock_embedder(self): + """Create a mock embedder for testing""" + embedder = Mock() + embedder.id = "test_embedder" + return embedder + + @pytest.fixture + def real_embedder(self): + """Create a real embedder for integration testing""" + factory = EmbeddingFactory() + return factory.get_embedder("jina_AI") + + @pytest.mark.unit + def test_qdrant_connection_mock_success(self, mock_embedder): + """Test Qdrant connection with mocked successful response""" + # Setup environment variables + test_env = { + "BASE_COLLECTION_NAME": "test_collection", + "QDRANT_URL": "http://localhost:6333", + "QDRANT_API_KEY": "test_key" + } + + with patch.dict(os.environ, test_env): + with patch('src.knowledge.vector_store.Qdrant') as mock_qdrant_class: + mock_qdrant_instance = Mock() + mock_qdrant_instance.client = Mock() + mock_qdrant_class.return_value = mock_qdrant_instance + + # Test connection + vector_store = get_qdrant_store( + collection_name="test_collection", + url="http://localhost:6333", + embedder=mock_embedder + ) + + # Verify Qdrant was initialized correctly + mock_qdrant_class.assert_called_once_with( + collection="test_collection_test_embedder", + url="http://localhost:6333", + embedder=mock_embedder, + timeout=10.0, + api_key="test_key" + ) + + assert vector_store is not None + + @pytest.mark.unit + def test_qdrant_connection_missing_embedder(self): + """Test that connection fails when no embedder is provided""" + with pytest.raises(ValueError, match="You must provide an 'embedder' instance"): + get_qdrant_store() + + @pytest.mark.unit + def test_qdrant_connection_missing_env_vars(self, mock_embedder): + """Test connection with missing environment variables""" + # Remove relevant env vars (don't set them to None as os.environ expects strings) + env_vars_to_remove = ["BASE_COLLECTION_NAME", "QDRANT_URL", "QDRANT_API_KEY"] + + with patch.dict(os.environ, {}, clear=False): # Start with empty dict + # Remove the specific environment variables + for var in env_vars_to_remove: + os.environ.pop(var, None) + + with patch('src.knowledge.vector_store.Qdrant') as mock_qdrant_class: + mock_qdrant_instance = Mock() + mock_qdrant_class.return_value = mock_qdrant_instance + + # This should work with explicit parameters + vector_store = get_qdrant_store( + collection_name="explicit_collection", + url="http://explicit:6333", + embedder=mock_embedder + ) + + mock_qdrant_class.assert_called_once_with( + collection="explicit_collection_test_embedder", + url="http://explicit:6333", + embedder=mock_embedder, + timeout=10.0, + api_key=None # No API key provided + ) + + @pytest.mark.integration + def test_qdrant_real_connection_success(self, real_embedder): + """Test real Qdrant connection using environment configuration""" + # Skip if QDRANT_URL is not set (no real Qdrant instance available) + qdrant_url = os.getenv("QDRANT_URL") + if not qdrant_url: + pytest.skip("QDRANT_URL not set - skipping real connection test") + + try: + # Attempt to create vector store with real embedder + vector_store = get_qdrant_store( + collection_name="test_connection", + embedder=real_embedder + ) + + # Test basic connectivity by checking if client is accessible + assert vector_store is not None + assert hasattr(vector_store, 'client') + assert vector_store.client is not None + + # Try a simple operation to verify connection + # This will fail if Qdrant is not reachable + collections = vector_store.client.get_collections() + assert hasattr(collections, 'collections') # Response should have collections attribute + + # Log all collections in the database as requested + print(f"📊 Found {len(collections.collections)} collections in Qdrant:") + for i, col in enumerate(collections.collections, 1): + print(f" {i}. {col.name}") + print(f" Total collections: {len(collections.collections)}") + + except Exception as e: + pytest.fail(f"Qdrant connection test failed: {str(e)}") + + @pytest.mark.integration + def test_qdrant_real_connection_failure(self): + """Test behavior when Qdrant connection fails""" + # Skip if QDRANT_URL is set (we want to test failure case) + qdrant_url = os.getenv("QDRANT_URL") + if qdrant_url: + pytest.skip("QDRANT_URL is set - cannot test connection failure") + + # Test with invalid URL + invalid_url = "http://invalid.qdrant.url:6333" + + try: + factory = EmbeddingFactory() + embedder = factory.get_embedder("jina_AI") + + # This should fail due to invalid URL + vector_store = get_qdrant_store( + collection_name="test_connection", + url=invalid_url, + embedder=embedder + ) + + # If we get here, try to perform an operation that requires connection + collections = vector_store.client.get_collections() + + # If we reach this point without exception, the test should fail + pytest.fail("Expected connection to fail with invalid URL, but it succeeded") + + except (UnexpectedResponse, Exception) as e: + # Expected to fail - this is the correct behavior + error_str = str(e).lower() + # Check for various connection failure indicators + has_connection_error = ( + "failed" in error_str or + "refused" in error_str or + "timeout" in error_str or + "getaddrinfo" in error_str or # DNS resolution failure + "connection" in error_str or + isinstance(e, UnexpectedResponse) + ) + assert has_connection_error, f"Expected connection error but got: {e}" + + @pytest.mark.integration + def test_qdrant_collection_operations(self, real_embedder): + """Test basic collection operations on Qdrant""" + qdrant_url = os.getenv("QDRANT_URL") + if not qdrant_url: + pytest.skip("QDRANT_URL not set - skipping collection operations test") + + try: + vector_store = get_qdrant_store( + collection_name="test_operations", + embedder=real_embedder + ) + + # Test collection creation/deletion if needed + collection_name = f"test_operations_{real_embedder.id}" + + # Check if collection exists and clean up if necessary + try: + existing_collections = vector_store.client.get_collections() + collection_names = [col.name for col in existing_collections.collections] + + print(f"📋 Current collections in database ({len(collection_names)} total):") + for i, name in enumerate(collection_names, 1): + print(f" {i}. {name}") + + if collection_name in collection_names: + print(f"🧹 Cleaning up existing test collection: {collection_name}") + # Clean up existing collection + vector_store.client.delete_collection(collection_name) + print(f"✅ Deleted collection: {collection_name}") + else: + print(f"ℹ️ Test collection {collection_name} does not exist (this is normal)") + + # Verify collection was deleted + existing_collections = vector_store.client.get_collections() + collection_names = [col.name for col in existing_collections.collections] + assert collection_name not in collection_names + print(f"✅ Verified collection {collection_name} is not in database") + + except Exception as e: + pytest.fail(f"Failed to verify/clean up test collection: {str(e)}") + + except Exception as e: + pytest.fail(f"Collection operations test failed: {str(e)}") diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py new file mode 100644 index 0000000..e6d2e85 --- /dev/null +++ b/tests/unit/test_agent.py @@ -0,0 +1,47 @@ +""" +Unit tests for agent functionality +""" +import pytest +from unittest.mock import Mock, patch +from src.agents.islamic_scholar_agent import IslamicScholarAgent +from src.models.openai import OpenAILikeModel + + +class TestIslamicScholarAgent: + """Test cases for Islamic Scholar Agent""" + + @pytest.fixture + def mock_model(self): + """Mock model for testing""" + model = Mock() + model.get_model.return_value = Mock() + return model + + @pytest.fixture + def mock_knowledge_base(self): + """Mock knowledge base for testing""" + kb = Mock() + return kb + + def test_agent_initialization(self, mock_model, mock_knowledge_base): + """Test agent initialization""" + agent = IslamicScholarAgent(mock_model.get_model(), mock_knowledge_base) + + assert agent.model == mock_model.get_model() + assert agent.knowledge_base == mock_knowledge_base + assert agent.agent is not None + + def test_agent_instructions(self, mock_model, mock_knowledge_base): + """Test agent has correct Islamic instructions""" + agent = IslamicScholarAgent(mock_model.get_model(), mock_knowledge_base) + + instructions = agent.agent.instructions + assert "Islamic knowledge agent" in " ".join(instructions).lower() + assert "knowledge base" in " ".join(instructions).lower() + + def test_get_agent_method(self, mock_model, mock_knowledge_base): + """Test get_agent method returns configured agent""" + agent = IslamicScholarAgent(mock_model.get_model(), mock_knowledge_base) + + returned_agent = agent.get_agent() + assert returned_agent == agent.agent diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..40dc5b0 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,86 @@ +""" +Unit tests for model configurations +""" +import pytest +from unittest.mock import patch, Mock +from src.models.openai import OpenAILikeModel +from src.models.openrouter import OpenRouterModel + + +class TestOpenAILikeModel: + """Test cases for OpenAI-like model""" + + @patch.dict('os.environ', { + 'MODEL_ID': 'test-model', + 'API_URL': 'https://test.api.com', + 'MEGALLM_API_KEY': 'test-key' + }) + def test_model_initialization_with_env(self): + """Test model initialization with environment variables""" + model = OpenAILikeModel() + + assert model.model_id == 'test-model' + assert model.api_url == 'https://test.api.com' + assert model.api_key == 'test-key' + + def test_model_initialization_with_params(self): + """Test model initialization with explicit parameters""" + model = OpenAILikeModel( + model_id='custom-model', + api_url='https://custom.api.com', + api_key='custom-key' + ) + + assert model.model_id == 'custom-model' + assert model.api_url == 'https://custom.api.com' + assert model.api_key == 'custom-key' + + @patch('src.models.openai.OpenAILike') + def test_get_model(self, mock_openai_like): + """Test get_model returns configured OpenAI-like model""" + mock_instance = Mock() + mock_openai_like.return_value = mock_instance + + model = OpenAILikeModel() + result = model.get_model() + + mock_openai_like.assert_called_once_with( + id=model.model_id, + api_key=model.api_key, + base_url=model.api_url, + default_headers={ + "Authorization": f"Bearer {model.api_key}", + "Content-Type": "application/json" + } + ) + assert result == mock_instance + + +class TestOpenRouterModel: + """Test cases for OpenRouter model""" + + def test_model_initialization_default(self): + """Test model initialization with default values""" + model = OpenRouterModel() + + assert model.model_id == "deepseek/deepseek-r1-0528:free" + assert model.api_key is None + + def test_model_initialization_custom(self): + """Test model initialization with custom values""" + model = OpenRouterModel(model_id="custom/model", api_key="custom-key") + + assert model.model_id == "custom/model" + assert model.api_key == "custom-key" + + @patch('src.models.openrouter.OpenRouter') + def test_get_model(self, mock_openrouter): + """Test get_model returns configured OpenRouter model""" + mock_instance = Mock() + mock_openrouter.return_value = mock_instance + + model = OpenRouterModel() + result = model.get_model() + + mock_openrouter.assert_called_once_with(id=model.model_id) + assert result == mock_instance diff --git a/tests/unit/test_rag.py b/tests/unit/test_rag.py new file mode 100644 index 0000000..497f757 --- /dev/null +++ b/tests/unit/test_rag.py @@ -0,0 +1,73 @@ +""" +Unit tests for RAG pipeline +""" +import pytest +from unittest.mock import Mock, patch +from src.knowledge.rag_pipeline import create_knowledge_base, ingest_excel_data + + +class TestRAGPipeline: + """Test cases for RAG pipeline""" + + @patch('src.knowledge.rag_pipeline.get_qdrant_store') + def test_create_knowledge_base_qdrant(self, mock_get_qdrant): + """Test creating knowledge base with Qdrant""" + mock_vector_db = Mock() + mock_get_qdrant.return_value = mock_vector_db + + kb = create_knowledge_base(vector_store_type="qdrant") + + mock_get_qdrant.assert_called_once() + assert kb.vector_db == mock_vector_db + + @patch('src.knowledge.rag_pipeline.get_pgvector_store') + def test_create_knowledge_base_pgvector(self, mock_get_pgvector): + """Test creating knowledge base with PgVector""" + mock_vector_db = Mock() + mock_get_pgvector.return_value = mock_vector_db + + kb = create_knowledge_base(vector_store_type="pgvector") + + mock_get_pgvector.assert_called_once() + assert kb.vector_db == mock_vector_db + + def test_create_knowledge_base_invalid_type(self): + """Test creating knowledge base with invalid type""" + with pytest.raises(ValueError, match="Unsupported vector store type"): + create_knowledge_base(vector_store_type="invalid") + + @patch('pandas.read_excel') + def test_ingest_hadiths_data(self, mock_read_excel, sample_hadith_data): + """Test ingesting hadith data""" + # Mock DataFrame + mock_df = Mock() + mock_df.iterrows.return_value = [(0, sample_hadith_data)] + mock_read_excel.return_value = mock_df + + # Mock knowledge base + mock_kb = Mock() + + # Mock file operations + with patch('builtins.open', Mock()): + count = ingest_excel_data(mock_kb, "test.xlsx", "hadiths") + + assert count == 1 + mock_kb.add_content.assert_called_once() + + @patch('pandas.read_excel') + def test_ingest_articles_data(self, mock_read_excel, sample_article_data): + """Test ingesting article data""" + # Mock DataFrame + mock_df = Mock() + mock_df.iterrows.return_value = [(0, sample_article_data)] + mock_read_excel.return_value = mock_df + + # Mock knowledge base + mock_kb = Mock() + + # Mock file operations + with patch('builtins.open', Mock()): + count = ingest_excel_data(mock_kb, "test.xlsx", "articles") + + assert count == 1 + mock_kb.add_content.assert_called_once()