mission17-ai / app.py
GitHub Action
sync: feat: integrate protected BrgyLink chatbot AI service
9ebcdd4
Raw History Blame Contribute Delete
6.82 kB
"""Mission17 TensorFlow proof-verification HTTP service."""
import hmac
import logging
import os
from io import BytesIO
from dotenv import load_dotenv
from flask import Flask, jsonify, request
from PIL import Image
from werkzeug.exceptions import RequestEntityTooLarge
from utils.anticheat import AntiCheatEngine, AntiCheatIndeterminate, AntiCheatUnavailable
from utils.predictor import Predictor, PredictorUnavailable
from utils.verdict import get_verdict
from chatbot_runtime.service import ChatService
load_dotenv()
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
app = Flask(__name__)
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'webp'}
ALLOWED_IMAGE_FORMATS = {'png', 'jpeg', 'webp'}
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024
AI_SERVICE_TOKEN = os.getenv('AI_SERVICE_TOKEN', '')
logger.info('Loading the Mission17 TensorFlow CNN verification service.')
anticheat = AntiCheatEngine()
predictor = Predictor()
chatbot = ChatService()
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def require_backend_service_token():
if not AI_SERVICE_TOKEN:
logger.error('AI_SERVICE_TOKEN is not configured.')
return jsonify({'error': 'AI service is not configured.'}), 503
authorization = request.headers.get('Authorization', '')
if not authorization.startswith('Bearer '):
return jsonify({'error': 'Unauthorized'}), 401
provided_token = authorization.removeprefix('Bearer ')
if not hmac.compare_digest(provided_token, AI_SERVICE_TOKEN):
return jsonify({'error': 'Unauthorized'}), 401
return None
def unavailable_response(component, message):
return jsonify({
'status': 'UNCERTAIN',
'verdict': 'UNCERTAIN',
'is_verified': False,
'prediction': component,
'message': message,
'sdg': 'N/A',
'source_check': 'Unavailable',
}), 503
@app.errorhandler(413)
def request_too_large(_error):
return jsonify({'error': 'Image is too large. Maximum upload size is 5 MB.'}), 413
@app.route('/health', methods=['GET'])
def health():
storage_status = anticheat.health_status()
model_status = predictor.health_status()
ready = storage_status == 'ready' and model_status == 'ready'
return jsonify({
'status': 'ok' if ready else 'degraded',
'service': 'mission17-ai',
'antiCheatStorage': storage_status,
'modelStatus': model_status,
}), 200 if ready else 503
@app.route('/predict', methods=['POST'])
def predict():
auth_error = require_backend_service_token()
if auth_error:
return auth_error
try:
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['file']
file.seek(0, os.SEEK_END)
if file.tell() == 0:
return jsonify({'error': 'Processing failed: Empty file'}), 400
file.seek(0)
if file.filename == '':
return jsonify({'error': 'No selected file'}), 400
if not allowed_file(file.filename):
return jsonify({
'error': 'Invalid file type. Only PNG, JPG, JPEG, and WebP are allowed.'
}), 400
file_bytes = file.read()
try:
with Image.open(BytesIO(file_bytes)) as uploaded_image:
detected_format = uploaded_image.format
uploaded_image.verify()
except Exception:
return jsonify({'error': 'Invalid image content.'}), 400
if not detected_format or detected_format.lower() not in ALLOWED_IMAGE_FORMATS:
return jsonify({'error': 'Image content type does not match a supported format.'}), 400
# Only the authenticated backend can supply this re-analysis header.
skip_anticheat = request.headers.get('X-Mission17-Admin-Reanalysis') == '1'
if not skip_anticheat and anticheat.is_duplicate(file_bytes):
logger.warning('Anti-cheat duplicate image detected.')
return jsonify({
'status': 'REJECTED',
'verdict': 'REJECTED',
'is_verified': False,
'error': 'Duplicate image detected.',
'prediction': 'Anti-Cheat: Duplicate',
}), 400
logger.info('Sending image to the Mission17 TensorFlow CNN.')
ai_result = predictor.predict(file_bytes)
category = ai_result.get('category', 'Non_SDG_Invalid')
confidence = ai_result.get('confidence', 0)
verdict_response = get_verdict(category, confidence, threshold=55)
verdict_response['reason'] = ai_result.get('reason', '')
verdict_response['model'] = predictor.get_model_name()
if verdict_response['is_verified'] and not skip_anticheat:
if not anticheat.register(file_bytes):
return jsonify({
'status': 'REJECTED',
'verdict': 'REJECTED',
'is_verified': False,
'error': 'Duplicate image detected during verification.',
'prediction': 'Anti-Cheat: Duplicate',
}), 400
logger.info('Unique verified image registered in durable anti-cheat storage.')
return jsonify(verdict_response)
except PredictorUnavailable as error:
logger.warning('Model verification unavailable: %s', error)
return unavailable_response(
'Model Unavailable',
'Photo verification requires manual review because the model is unavailable.',
)
except (AntiCheatUnavailable, AntiCheatIndeterminate) as error:
logger.warning('Anti-cheat verification unavailable: %s', error)
return unavailable_response(
'Anti-Cheat Unavailable',
'Photo verification requires manual review. Please try again later.',
)
except RequestEntityTooLarge:
return request_too_large(None)
except Exception:
logger.exception('Processing error')
return jsonify({'error': 'Processing failed'}), 500
@app.route('/chat', methods=['POST'])
def chat():
"""Server-to-server BrgyLink AI chatbot endpoint.
It deliberately accepts only a short message and an optional opaque session
identifier. The mobile client never calls this Space directly and resident
history or account data is not sent to Hugging Face.
"""
auth_error = require_backend_service_token()
if auth_error:
return auth_error
status_code, payload = chatbot.respond(request.get_json(silent=True))
return jsonify(payload), status_code
if __name__ == '__main__':
app.run(host='0.0.0.0', port=7860, debug=False)