2 Commits

  1. 86
      .dockerignore
  2. 22
      .env.example
  3. 162
      .gitignore
  4. 114
      DOCKER_TESTING_README.md
  5. 42
      Dockerfile
  6. 34
      Jenkinsfile
  7. 1534
      PRODUCTION_READINESS_REPORT.md
  8. 117
      README.md
  9. 27
      config/development.env
  10. 24
      config/embeddings.yaml
  11. 32
      config/models.yaml
  12. 43
      config/production.env
  13. 11
      config/rerankers.yaml
  14. 46
      docker-compose.yml
  15. 42
      docker/Dockerfile
  16. 28
      docker/Dockerfile.dev
  17. 45
      docker/docker-compose.dev.yml
  18. 46
      docker/docker-compose.yml
  19. 208
      docs/CULTURE_SYSTEM.md
  20. 373
      docs/DYNAMIC_SYSTEM_PROMPT.md
  21. 616
      docs/EMBEDDING_MANAGEMENT.md
  22. 213
      docs/GUARDRAILS.md
  23. 395
      docs/LANGFUSE_TRACING.md
  24. 508
      docs/LLM_MODEL_MANAGEMENT.md
  25. 307
      docs/QDRANT_CONNECTION_TEST.md
  26. 406
      docs/RAG_IMPLEMENTATION_GUIDE.md
  27. 358
      docs/RERANKING_PIPELINE.md
  28. 7
      entrypoint.sh
  29. 32
      langfuse/docker-compose.langfuse.yml
  30. 14
      out.md
  31. 18
      pytest.ini
  32. 21
      requirements-dev.txt
  33. BIN
      requirements.txt
  34. 33
      runner.sh
  35. 127
      scripts/ingest_excel.py
  36. 0
      src/__init__.py
  37. 0
      src/agents/__init__.py
  38. 68
      src/agents/base_agent.py
  39. 11
      src/agents/islamic_scholar_agent.py
  40. 98
      src/agents/tracing_agent.py
  41. 0
      src/api/__init__.py
  42. 14
      src/api/dependencies.py
  43. 18
      src/api/routes.py
  44. 0
      src/core/__init__.py
  45. 32
      src/core/config.py
  46. 55
      src/core/culture.py
  47. 31
      src/core/logging.py
  48. 32
      src/core/settings.py
  49. 0
      src/guardrails/__init__.py
  50. 60
      src/guardrails/limit.py
  51. 0
      src/knowledge/__init__.py
  52. 67
      src/knowledge/embedding_factory.py
  53. 46
      src/knowledge/manual_cultures.py
  54. 11
      src/knowledge/rag_pipeline.py
  55. 34
      src/knowledge/vector_store.py
  56. 95
      src/main.py
  57. 0
      src/models/__init__.py
  58. 18
      src/models/base_model.py
  59. 86
      src/models/factory.py
  60. 0
      src/utils/__init__.py
  61. 44
      src/utils/hooks.py
  62. 76
      src/utils/load_settings.py
  63. 189
      src/utils/reranker.py
  64. 91
      src/utils/search_knowledge.py
  65. 5
      src/utils/shared_context.py
  66. 31
      tests/conftest.py
  67. 61
      tests/integration/test_api.py
  68. 78
      tests/integration/test_pipeline.py
  69. 244
      tests/integration/test_qdrant_connection.py
  70. 47
      tests/unit/test_agent.py
  71. 86
      tests/unit/test_models.py
  72. 73
      tests/unit/test_rag.py

86
.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/

22
.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

162
.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

114
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!

42
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"]

34
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
// """
// }
// }
// }
// }
// }
// }
// }

1534
PRODUCTION_READINESS_REPORT.md
File diff suppressed because it is too large
View File

117
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.

27
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

24
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}

32
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

43
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

11
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

46
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:

42
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"]

28
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"]

45
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:

46
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:

208
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` |

373
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 با پرامپت پیش‌فرض به کار خود ادامه می‌دهد.

616
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 را آسان می‌سازد.

213
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` | آرایه‌ای از گاردریل‌ها و هوک‌ها | اجرای ترتیبی قبل از مدل |

395
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 اضافه کرد.

508
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. **قابل نگهداری**: کد تمیز و سازمان‌یافته
این رویکرد نه تنها مشکلات سیستم قدیمی را حل می‌کند، بلکه پایه‌ای محکم برای توسعه آینده سیستم فراهم می‌کند.

307
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.
```

406
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 ادامه کار می‌دهد
این پیاده‌سازی در فایل‌های موجود به خوبی کار می‌کند و می‌تواند به عنوان الگوی مناسبی برای پروژه‌های مشابه استفاده شود.

358
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 با کیفیت بالا باشد و در نتیجه پاسخ‌های دقیق‌تر و مرتبط‌تری تولید شود.

7
entrypoint.sh

@ -0,0 +1,7 @@
#!/bin/bash
# entrypoint.sh
set -e
exec "$@"

32
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

14
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و الله أعلم.

18
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

21
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

BIN
requirements.txt

33
runner.sh

@ -0,0 +1,33 @@
# #!/bin/bash
# # Define the compose file to use (since you currently only have one)
# COMPOSE_FILE="docker-compose.yml"
# if [ "$1" == "--dev" ]; then
# echo "========================================"
# echo "🧪 STARTING DEVELOPMENT MODE"
# echo "========================================"
# # Run Docker Compose for local development
# DOCKER_BUILDKIT=1 docker compose -f $COMPOSE_FILE up -d --build
# else
# echo "========================================"
# echo "🚀 DEPLOYING TO PRODUCTION"
# echo "========================================"
# # 1. Ensure we are on the master branch
# echo "--> 🌿 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

127
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}")

0
src/__init__.py

0
src/agents/__init__.py

68
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

11
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)

98
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)

0
src/api/__init__.py

14
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()

18
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"}

0
src/core/__init__.py

32
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()

55
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

31
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()

32
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"))

0
src/guardrails/__init__.py

60
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."
)

0
src/knowledge/__init__.py

67
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}")

46
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]

11
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)

34
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
)

95
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"
)

0
src/models/__init__.py

18
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

86
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")

0
src/utils/__init__.py

44
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

76
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.",
]

189
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)

91
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

5
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="")

31
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"
}

61
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

78
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
)

244
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)}")

47
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

86
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

73
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()
Loading…
Cancel
Save