Building Custom Extensions
Overview
OpenCHS supports custom extensions to add new features, integrate with external services, or customize behavior without modifying the core codebase. This guide shows developers how to build extensions for both the Helpline service and AI service.
Extension Types
1. PHP Plugins (Helpline Service)
Custom modules for case management, reporting, and workflows
2. AI Model Extensions (AI Service)
Additional AI models for specialized analysis
3. Frontend Widgets (User Interface)
Custom dashboard components and visualizations
4. API Middleware (Both Services)
Custom request/response processing
PHP Plugin Extensions (Helpline Service)
Plugin Structure
plugins/
└── custom-case-validator/
├── plugin.php # Main plugin file
├── config.json # Plugin configuration
├── hooks.php # Event hooks
├── templates/ # HTML templates
└── assets/ # CSS, JS filesBasic Plugin Template
php
<?php
/**
* Plugin Name: Custom Case Validator
* Description: Adds custom validation rules for cases
* Version: 1.0.0
* Author: Your Name
*/
class CustomCaseValidatorPlugin {
private $config;
public function __construct() {
$this->config = $this->loadConfig();
$this->registerHooks();
}
private function loadConfig() {
$configPath = __DIR__ . '/config.json';
return json_decode(file_get_contents($configPath), true);
}
private function registerHooks() {
// Register hooks for plugin functionality
add_hook('before_case_create', [$this, 'validateCase']);
add_hook('after_case_create', [$this, 'notifyExternal']);
}
public function validateCase($caseData) {
// Custom validation logic
if (empty($caseData['reporter_phone']) && empty($caseData['reporter_email'])) {
throw new ValidationException(
'Either phone or email must be provided'
);
}
// Custom business rules
if ($caseData['priority'] === 'critical') {
if (empty($caseData['description']) || strlen($caseData['description']) < 50) {
throw new ValidationException(
'Critical cases require detailed description (min 50 characters)'
);
}
}
return $caseData;
}
public function notifyExternal($case) {
// Send notification to external system
if ($case['priority'] === 'critical') {
$this->sendToExternalAPI($case);
}
}
private function sendToExternalAPI($case) {
$url = $this->config['external_api_url'];
$apiKey = $this->config['external_api_key'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($case),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer {$apiKey}"
],
CURLOPT_RETURNTRANSFER => true
]);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
}
// Initialize plugin
global $customCaseValidator;
$customCaseValidator = new CustomCaseValidatorPlugin();
?>Plugin Configuration (config.json)
json
{
"name": "Custom Case Validator",
"version": "1.0.0",
"author": "Your Name",
"description": "Adds custom validation rules for cases",
"requires": {
"php": ">=8.2",
"openchs": ">=1.0"
},
"settings": {
"external_api_url": "https://external-system.com/api/cases",
"external_api_key": "your-api-key",
"enable_notifications": true
},
"hooks": [
"before_case_create",
"after_case_create",
"before_case_update",
"after_case_update"
]
}Available Hooks
php
<?php
// Case hooks
add_hook('before_case_create', $callback);
add_hook('after_case_create', $callback);
add_hook('before_case_update', $callback);
add_hook('after_case_update', $callback);
add_hook('before_case_delete', $callback);
add_hook('case_status_changed', $callback);
add_hook('case_assigned', $callback);
// Communication hooks
add_hook('before_communication_create', $callback);
add_hook('after_communication_create', $callback);
add_hook('call_received', $callback);
add_hook('call_ended', $callback);
// AI processing hooks
add_hook('before_audio_process', $callback);
add_hook('after_audio_process', $callback);
add_hook('ai_prediction_complete', $callback);
// User hooks
add_hook('user_login', $callback);
add_hook('user_logout', $callback);
// System hooks
add_hook('daily_cleanup', $callback);
add_hook('report_generated', $callback);
?>Installing Plugins
bash
# Copy plugin to plugins directory
cp -r custom-case-validator /var/www/html/helpline/plugins/
# Set permissions
chown -R nginx:nginx /var/www/html/helpline/plugins/custom-case-validator
chmod -R 755 /var/www/html/helpline/plugins/custom-case-validator
# Enable plugin in database
mysql -e "INSERT INTO helpline.plugin_registry (name, path, is_active)
VALUES ('custom-case-validator', 'plugins/custom-case-validator', TRUE);"
# Restart PHP-FPM
sudo systemctl restart php8.2-fpmAI Model Extensions (AI Service)
Adding Custom AI Models
Structure for custom AI models:
ai_service/
└── app/
└── models/
└── custom/
├── __init__.py
├── sentiment_analyzer.py
└── emotion_detector.pyCustom Model Example
python
# app/models/custom/sentiment_analyzer.py
from typing import Dict, List
import torch
from transformers import pipeline
import logging
logger = logging.getLogger(__name__)
class SentimentAnalyzer:
"""Custom sentiment analysis for child protection calls"""
def __init__(self):
self.model = None
self.loaded = False
def load(self) -> bool:
"""Load sentiment analysis model"""
try:
logger.info("Loading sentiment analysis model...")
self.model = pipeline(
"sentiment-analysis",
model="nlptown/bert-base-multilingual-uncased-sentiment",
device=0 if torch.cuda.is_available() else -1
)
self.loaded = True
logger.info("Sentiment analysis model loaded successfully")
return True
except Exception as e:
logger.error(f"Failed to load sentiment model: {e}")
self.loaded = False
return False
def analyze(self, text: str) -> Dict:
"""
Analyze sentiment of text
Args:
text: Input text to analyze
Returns:
Dictionary with sentiment analysis results
"""
if not self.loaded:
raise RuntimeError("Model not loaded")
try:
# Run sentiment analysis
result = self.model(text)[0]
# Convert to our format
sentiment_score = self._convert_score(result['label'])
return {
"sentiment": result['label'],
"score": sentiment_score,
"confidence": result['score'],
"urgency_level": self._assess_urgency(sentiment_score)
}
except Exception as e:
logger.error(f"Sentiment analysis failed: {e}")
raise
def _convert_score(self, label: str) -> float:
"""Convert label to numeric score (-1 to 1)"""
mapping = {
"1 star": -1.0,
"2 stars": -0.5,
"3 stars": 0.0,
"4 stars": 0.5,
"5 stars": 1.0
}
return mapping.get(label, 0.0)
def _assess_urgency(self, score: float) -> str:
"""Assess urgency based on sentiment"""
if score <= -0.7:
return "critical"
elif score <= -0.3:
return "high"
elif score <= 0.3:
return "medium"
else:
return "low"
def get_model_info(self) -> Dict:
"""Return model metadata"""
return {
"name": "Sentiment Analyzer",
"version": "1.0.0",
"description": "Multilingual sentiment analysis for call transcripts",
"loaded": self.loaded
}
# Initialize model instance
sentiment_analyzer = SentimentAnalyzer()Register Custom Model
python
# app/models/model_loader.py
from app.models.custom.sentiment_analyzer import sentiment_analyzer
class ModelLoader:
def __init__(self):
# Existing models
self.whisper_model = WhisperModel()
self.translation_model = TranslationModel()
# Register custom models
self.sentiment_analyzer = sentiment_analyzer
self.model_dependencies = {
# ... existing dependencies
"sentiment_analyzer": {
"required": ["torch", "transformers"],
"description": "Sentiment analysis for call transcripts"
}
}
def load_all_models(self):
"""Load all models including custom ones"""
# Load standard models
self.whisper_model.load()
self.translation_model.load()
# Load custom models
self.sentiment_analyzer.load()Add API Endpoint for Custom Model
python
# app/api/custom_routes.py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.models.model_loader import model_loader
router = APIRouter()
class SentimentRequest(BaseModel):
text: str
class SentimentResponse(BaseModel):
sentiment: str
score: float
confidence: float
urgency_level: str
@router.post("/sentiment/analyze", response_model=SentimentResponse)
async def analyze_sentiment(request: SentimentRequest):
"""
Analyze sentiment of text
- **text**: Text to analyze (call transcript)
"""
try:
result = model_loader.sentiment_analyzer.analyze(request.text)
return SentimentResponse(**result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Include in main app
# app/main.py
from app.api.custom_routes import router as custom_router
app.include_router(custom_router, prefix="/custom", tags=["Custom Models"])Testing Custom Model
python
# tests/test_sentiment_analyzer.py
import pytest
from app.models.custom.sentiment_analyzer import sentiment_analyzer
@pytest.fixture(scope="module")
def loaded_model():
sentiment_analyzer.load()
yield sentiment_analyzer
def test_sentiment_analysis_negative(loaded_model):
"""Test negative sentiment detection"""
text = "I am very worried about this child, situation is terrible"
result = loaded_model.analyze(text)
assert result['score'] < 0
assert result['urgency_level'] in ['high', 'critical']
def test_sentiment_analysis_positive(loaded_model):
"""Test positive sentiment detection"""
text = "The child is safe now, family is receiving good support"
result = loaded_model.analyze(text)
assert result['score'] > 0
assert result['urgency_level'] == 'low'
def test_sentiment_analysis_multilingual(loaded_model):
"""Test with Swahili text"""
text = "Mtoto ana hali mbaya sana, anahitaji msaada haraka"
result = loaded_model.analyze(text)
assert 'sentiment' in result
assert 'confidence' in resultFrontend Widget Extensions
Custom Dashboard Widget
javascript
// frontend/src/widgets/CaseStatisticsWidget.jsx
import React, { useState, useEffect } from 'react';
import { Card, CardContent, Typography } from '@mui/material';
import { BarChart, Bar, XAxis, YAxis, Tooltip } from 'recharts';
const CaseStatisticsWidget = () => {
const [stats, setStats] = useState(null);
useEffect(() => {
fetchStatistics();
}, []);
const fetchStatistics = async () => {
try {
const response = await fetch('/helpline/api/cases/statistics');
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Failed to fetch statistics:', error);
}
};
if (!stats) return <div>Loading...</div>;
const chartData = [
{ category: 'Abuse', count: stats.abuse_cases },
{ category: 'Neglect', count: stats.neglect_cases },
{ category: 'Education', count: stats.education_cases },
{ category: 'Health', count: stats.health_cases }
];
return (
<Card>
<CardContent>
<Typography variant="h6">Case Statistics (Last 30 Days)</Typography>
<BarChart width={400} height={300} data={chartData}>
<XAxis dataKey="category" />
<YAxis />
<Tooltip />
<Bar dataKey="count" fill="#8884d8" />
</BarChart>
</CardContent>
</Card>
);
};
export default CaseStatisticsWidget;Register Widget
javascript
// frontend/src/dashboard/WidgetRegistry.js
import CaseStatisticsWidget from '../widgets/CaseStatisticsWidget';
import CustomAlertWidget from '../widgets/CustomAlertWidget';
export const widgetRegistry = {
'case-statistics': {
component: CaseStatisticsWidget,
title: 'Case Statistics',
defaultSize: { width: 6, height: 4 }
},
'custom-alerts': {
component: CustomAlertWidget,
title: 'Custom Alerts',
defaultSize: { width: 4, height: 3 }
}
};
// Usage in dashboard
// frontend/src/pages/Dashboard.jsx
import { widgetRegistry } from './WidgetRegistry';
const Dashboard = () => {
const userWidgets = ['case-statistics', 'custom-alerts'];
return (
<Grid container spacing={2}>
{userWidgets.map(widgetId => {
const Widget = widgetRegistry[widgetId].component;
return (
<Grid item xs={12} md={6} key={widgetId}>
<Widget />
</Grid>
);
})}
</Grid>
);
};API Middleware Extensions
Custom Request Middleware
python
# ai_service/app/middleware/custom_auth.py
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
import logging
logger = logging.getLogger(__name__)
class CustomAuthMiddleware(BaseHTTPMiddleware):
"""Custom authentication middleware for special integrations"""
async def dispatch(self, request: Request, call_next):
# Skip auth for health endpoints
if request.url.path.startswith("/health"):
return await call_next(request)
# Check for custom API key header
api_key = request.headers.get("X-Custom-API-Key")
if api_key:
# Validate custom API key
if self.validate_custom_key(api_key):
# Add custom user info to request state
request.state.auth_source = "custom"
request.state.api_key_id = self.get_key_id(api_key)
# Log usage
logger.info(f"Custom API key used: {request.state.api_key_id}")
return await call_next(request)
# Fall through to default authentication
return await call_next(request)
def validate_custom_key(self, api_key: str) -> bool:
"""Validate custom API key against database"""
# Implementation here
return True
def get_key_id(self, api_key: str) -> str:
"""Get key identifier for logging"""
return api_key[:8] + "..."
# Register middleware
# app/main.py
from app.middleware.custom_auth import CustomAuthMiddleware
app.add_middleware(CustomAuthMiddleware)Response Transformation Middleware
php
<?php
// helpline/middleware/ResponseTransformer.php
class ResponseTransformerMiddleware {
public function handle($request, $next) {
$response = $next($request);
// Add custom headers
$response->headers->set('X-OpenCHS-Version', '1.0.0');
$response->headers->set('X-Request-ID', $request->id());
// Transform response for legacy clients
if ($request->header('X-Legacy-Client') === 'true') {
$content = json_decode($response->getContent(), true);
$transformed = $this->transformForLegacy($content);
$response->setContent(json_encode($transformed));
}
return $response;
}
private function transformForLegacy($data) {
// Transform new format to legacy format
return [
'status' => $data['success'] ? 'ok' : 'error',
'result' => $data['data'],
'timestamp' => time()
];
}
}
?>Extension Best Practices
1. Version Compatibility
json
// config.json
{
"requires": {
"openchs": ">=1.0.0 <2.0.0",
"php": ">=8.2",
"python": ">=3.11"
},
"compatibility": {
"tested_with": ["1.0.0", "1.1.0", "1.2.0"]
}
}2. Error Handling
python
class CustomExtension:
def process(self, data):
try:
# Extension logic
result = self.custom_processing(data)
return {"success": True, "data": result}
except ValueError as e:
# Validation errors
logger.warning(f"Validation error: {e}")
return {"success": False, "error": str(e)}
except Exception as e:
# Unexpected errors - don't break main flow
logger.error(f"Extension error: {e}", exc_info=True)
return {"success": False, "error": "Extension processing failed"}3. Performance Monitoring
python
import time
from functools import wraps
def monitor_performance(func):
"""Decorator to monitor extension performance"""
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start_time
# Log slow operations
if duration > 1.0:
logger.warning(
f"{func.__name__} took {duration:.2f}s (threshold: 1.0s)"
)
return result
except Exception as e:
duration = time.time() - start_time
logger.error(
f"{func.__name__} failed after {duration:.2f}s: {e}"
)
raise
return wrapper
class MyExtension:
@monitor_performance
def expensive_operation(self, data):
# Extension logic
pass4. Configuration Management
python
import os
from typing import Dict
class ExtensionConfig:
"""Centralized configuration for extensions"""
def __init__(self, config_file: str = "config.json"):
self.config = self.load_config(config_file)
self.validate_config()
def load_config(self, config_file: str) -> Dict:
"""Load configuration from file and environment"""
with open(config_file) as f:
config = json.load(f)
# Override with environment variables
for key in config.get('env_vars', []):
env_value = os.getenv(key)
if env_value:
config[key] = env_value
return config
def validate_config(self):
"""Validate required configuration"""
required = self.config.get('required_settings', [])
for setting in required:
if setting not in self.config:
raise ValueError(f"Required setting missing: {setting}")
def get(self, key: str, default=None):
"""Get configuration value"""
return self.config.get(key, default)Testing Extensions
Unit Tests
python
# tests/test_custom_extension.py
import pytest
from app.extensions.custom import CustomExtension
@pytest.fixture
def extension():
return CustomExtension()
def test_extension_initialization(extension):
"""Test extension initializes correctly"""
assert extension is not None
assert extension.loaded == True
def test_extension_processing(extension):
"""Test extension processes data correctly"""
input_data = {"text": "test input"}
result = extension.process(input_data)
assert result['success'] == True
assert 'data' in result
def test_extension_error_handling(extension):
"""Test extension handles errors gracefully"""
invalid_data = None
result = extension.process(invalid_data)
assert result['success'] == False
assert 'error' in resultIntegration Tests
python
# tests/test_extension_integration.py
def test_extension_with_api(test_client):
"""Test extension works with API endpoints"""
response = test_client.post(
"/custom/analyze",
json={"text": "test input"}
)
assert response.status_code == 200
assert 'sentiment' in response.json()Publishing Extensions
Extension Metadata
json
{
"name": "custom-extension",
"version": "1.0.0",
"description": "Custom extension for OpenCHS",
"author": "Your Name",
"license": "MIT",
"homepage": "https://github.com/yourname/custom-extension",
"repository": {
"type": "git",
"url": "https://github.com/yourname/custom-extension.git"
},
"keywords": ["openchs", "extension", "custom"],
"requires": {
"openchs": ">=1.0.0"
}
}Documentation Requirements
- README with installation instructions
- Configuration examples
- API documentation
- Usage examples
- Troubleshooting guide
Submission Process
- Test extension thoroughly
- Document all features
- Submit to extension registry
- Respond to review feedback
- Maintain and update
For more information on extension development, see the API Reference and Integration Guide.