diff --git a/.env-example b/.env-example new file mode 100755 index 0000000000000000000000000000000000000000..a9c7b19757b0fab5f347d6ece79517cdeabbf73f --- /dev/null +++ b/.env-example @@ -0,0 +1,34 @@ +MY_SECRET_TOKEN="SECRET_CODE_TOKEN" + +# CHROMA_HOST = "localhost" (Host gareko address rakhney) + + +# EXAMPLE CONFIGURATIONS FOR DIFFERENT PROVIDERS(Use only one at once) +# =========================================== + +# FOR OPENAI:(PAID) +# LLM_PROVIDER=openai +# LLM_API_KEY=sk-your-openai-api-key +# LLM_MODEL=gpt-3.5-turbo +# # Other options: gpt-4, gpt-4-turbo-preview, etc. + +# FOR GROQ:(FREE: BABAL XA-> prefer this) +# LLM_PROVIDER=groq +# LLM_API_KEY=gsk_your-groq-api-key +# LLM_MODEL=llama-3.3-70b-versatile +# # Other options: llama-3.1-70b-versatile, mixtral-8x7b-32768, etc. + +# FOR OPENROUTER:(FREE: LASTAI RATE LIMIT LAGAUXA) +# LLM_PROVIDER=openrouter +# LLM_API_KEY=sk-or-your-openrouter-api-key +# LLM_MODEL=meta-llama/llama-3.1-8b-instruct:free +# # Other options: anthropic/claude-3-haiku, google/gemma-7b-it, etc. + +# =========================================== +# ADVANCED CONFIGURATION +# =========================================== +# Temperature (0.0 to 1.0) - controls randomness +# LLM_TEMPERATURE=0.1 + +# Maximum tokens for response +# LLM_MAX_TOKENS=4096 diff --git a/.gitignore b/.gitignore index 0a197900e25d259ab4af2e31e78501787d7a6daa..85a7214d088cede51362aa0a029a17a908ebb1a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,174 +1,71 @@ -# Byte-compiled / optimized / DLL files +# ---- Python Environment ---- +venv/ +.venv/ +env/ +ENV/ +*.pyc +*.pyo +*.pyd __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so +**/__pycache__/ + +# ---- VS Code / IDEs ---- +.vscode/ +.idea/ +*.swp + +# ---- Jupyter / IPython ---- +.ipynb_checkpoints/ +*.ipynb + +# ---- Model & Data Artifacts ---- +*.pth +*.pt +*.h5 +*.ckpt +*.onnx +*.joblib +*.pkl + +# ---- Hugging Face Cache ---- +~/.cache/huggingface/ +huggingface_cache/ + +# ---- Logs and Dumps ---- +*.log +*.out +*.err -# Distribution / packaging -.Python +# ---- Build Artifacts ---- build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-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 -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock +# ---- System Files ---- +.DS_Store +Thumbs.db -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +# ---- Environment Configs ---- .env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc +.env.* + +# ---- Project-specific ---- +Ai-Text-Detector/ +HuggingFace/model/ + +# ---- Node Projects (if applicable) ---- +node_modules/ +model/ +models/.gitattributes #<-- This line can stay if you only want to ignore that file, not the whole folder + +todo.md +np_text_model +IMG_Models +notebooks +# Ignore model and tokenizer files +np_text_model/classifier/sentencepiece.bpe.model +np_text_model/classifier/tokenizer.json + +# vector database +chroma_data +chroma_database \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000000000000000000000000000000000000..97fdacd23f849777907e5762064f820c55ac879c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker +# you will also find guides on how best to write your Dockerfile + +FROM python:3.10 + +# Create user first +RUN useradd -m -u 1000 user + +# Install system dependencies (requires root) +RUN apt-get update && apt-get install -y libgl1 + +# Switch to non-root user +USER user +ENV PATH="/home/user/.local/bin:$PATH" + +# Add TensorFlow environment variables to reduce logging noise +WORKDIR /app + +COPY --chown=user ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +RUN python -m spacy download en_core_web_sm || echo "Failed to download model" + +COPY --chown=user . /app + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] + diff --git a/Procfile b/Procfile new file mode 100755 index 0000000000000000000000000000000000000000..3cb2d1c4026eaac4e838afbba9b64d3d5c9d1c4c --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} diff --git a/README.md b/README.md index 9850b6ccce6f3d0fb8f0e5f082d4735e1b4d5f43..1fca9b4b3f4877f43d77eb4de53f57fc275fa0a6 100644 --- a/README.md +++ b/README.md @@ -1 +1,22 @@ -# aiapi \ No newline at end of file +--- +title: Testing AI Contain +emoji: πŸ€– +colorFrom: blue +colorTo: green +sdk: docker +sdk_version: "latest" +app_file: app.py +pinned: false +--- + +# Testing AI Contain + +This Hugging Face Space uses **Docker** to run a custom environment for AI content detection. + +## How to run locally + +```bash +docker build -t testing-ai-contain . +docker run -p 7860:7860 testing-ai-contain + +``` diff --git a/READMEs.md b/READMEs.md new file mode 100644 index 0000000000000000000000000000000000000000..b036cf52798c9188a302e93567fedb728c9e1d66 --- /dev/null +++ b/READMEs.md @@ -0,0 +1,152 @@ +# AI-Contain-Checker + +A modular AI content detection system with support for **image classification**, **image edit detection**, **Nepali text classification**, and **general text classification**. Built for performance and extensibility, it is ideal for detecting AI-generated content in both visual and textual forms. + + +## 🌟 Features + +### πŸ–ΌοΈ Image Classifier + +* **Purpose**: Classifies whether an image is AI-generated or a real-life photo. +* **Model**: Fine-tuned **InceptionV3** CNN. +* **Dataset**: Custom curated dataset with **\~79,950 images** for binary classification. +* **Location**: [`features/image_classifier`](features/image_classifier) +* **Docs**: [`docs/features/image_classifier.md`](docs/features/image_classifier.md) + +### πŸ–ŒοΈ Image Edit Detector + +* **Purpose**: Detects image tampering or post-processing. +* **Techniques Used**: + + * **Error Level Analysis (ELA)**: Visualizes compression artifacts. + * **Fast Fourier Transform (FFT)**: Detects unnatural frequency patterns. +* **Location**: [`features/image_edit_detector`](features/image_edit_detector) +* **Docs**: + + * [ELA](docs/detector/ELA.md) + * [FFT](docs/detector/fft.md ) + * [Metadata Analysis](docs/detector/meta.md) + * [Backend Notes](docs/detector/note-for-backend.md) + +### πŸ“ Nepali Text Classifier + +* **Purpose**: Determines if Nepali text content is AI-generated or written by a human. +* **Model**: Based on `XLMRClassifier` fine-tuned on Nepali language data. +* **Dataset**: Scraped dataset of **\~18,000** Nepali texts. +* **Location**: [`features/nepali_text_classifier`](features/nepali_text_classifier) +* **Docs**: [`docs/features/nepali_text_classifier.md`](docs/features/nepali_text_classifier.md) + +### 🌐 English Text Classifier + +* **Purpose**: Detects if English text is AI-generated or human-written. +* **Pipeline**: + + * Uses **GPT2 tokenizer** for input preprocessing. + * Custom binary classifier to differentiate between AI and human-written content. +* **Location**: [`features/text_classifier`](features/text_classifier) +* **Docs**: [`docs/features/text_classifier.md`](docs/features/text_classifier.md) + +--- + +## πŸ—‚οΈ Project Structure + +```bash +AI-Checker/ +β”‚ +β”œβ”€β”€ app.py # Main FastAPI entry point +β”œβ”€β”€ config.py # Configuration settings +β”œβ”€β”€ Dockerfile # Docker build script +β”œβ”€β”€ Procfile # Deployment file for Heroku or similar +β”œβ”€β”€ requirements.txt # Python dependencies +β”œβ”€β”€ README.md # You are here πŸ“˜ +β”‚ +β”œβ”€β”€ features/ # Core detection modules +β”‚ β”œβ”€β”€ image_classifier/ +β”‚ β”œβ”€β”€ image_edit_detector/ +β”‚ β”œβ”€β”€ nepali_text_classifier/ +β”‚ └── text_classifier/ +β”‚ +β”œβ”€β”€ docs/ # Internal and API documentation +β”‚ β”œβ”€β”€ api_endpoints.md +β”‚ β”œβ”€β”€ deployment.md +β”‚ β”œβ”€β”€ detector/ +β”‚ β”‚ β”œβ”€β”€ ELA.md +β”‚ β”‚ β”œβ”€β”€ fft.md +β”‚ β”‚ β”œβ”€β”€ meta.md +β”‚ β”‚ └── note-for-backend.md +β”‚ β”œβ”€β”€ functions.md +β”‚ β”œβ”€β”€ nestjs_integration.md +β”‚ β”œβ”€β”€ security.md +β”‚ β”œβ”€β”€ setup.md +β”‚ └── structure.md +β”‚ +β”œβ”€β”€ IMG_Models/ # Saved image classifier model(s) +β”‚ └── latest-my_cnn_model.h5 +β”‚ +β”œβ”€β”€ notebooks/ # Experimental and debug notebooks +β”œβ”€β”€ static/ # Static assets if needed +└── test.md # Test notes +```` + +--- + +## πŸ“š Documentation Links + +* [API Endpoints](docs/api_endpoints.md) +* [Deployment Guide](docs/deployment.md) +* [Detector Documentation](docs/detector/) + + * [Error Level Analysis (ELA)](docs/detector/ELA.md) + * [Fast Fourier Transform (FFT)](docs/detector/fft.md) + * [Metadata Analysis](docs/detector/meta.md) + * [Backend Notes](docs/detector/note-for-backend.md) +* [Functions Overview](docs/functions.md) +* [NestJS Integration Guide](docs/nestjs_integration.md) +* [Security Details](docs/security.md) +* [Setup Instructions](docs/setup.md) +* [Project Structure](docs/structure.md) + +--- + +## πŸš€ Usage + +1. **Install dependencies** + + ```bash + pip install -r requirements.txt + ``` + +2. **Run the API** + + ```bash + uvicorn app:app --reload + ``` + +3. **Build Docker (optional)** + + ```bash + docker build -t ai-contain-checker . + docker run -p 8000:8000 ai-contain-checker + ``` + +--- + +## πŸ” Security & Integration + +* **Token Authentication** and **IP Whitelisting** supported. +* NestJS integration guide: [`docs/nestjs_integration.md`](docs/nestjs_integration.md) +* Rate limiting handled using `slowapi`. + +--- + +## πŸ›‘οΈ Future Plans + +* Add **video classifier** module. +* Expand dataset for **multilingual** AI content detection. +* Add **fine-tuning UI** for models. + +--- + +## πŸ“„ License + +See full license terms here: [`LICENSE.md`](license.md) diff --git a/__init__.py b/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app.py b/app.py new file mode 100755 index 0000000000000000000000000000000000000000..2215bd7ffb2318dadcb44fe16f19493f5afce664 --- /dev/null +++ b/app.py @@ -0,0 +1,62 @@ +from fastapi import FastAPI, Request +from slowapi import Limiter, _rate_limit_exceeded_handler +from fastapi.responses import FileResponse +from slowapi.middleware import SlowAPIMiddleware +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address +from fastapi.responses import JSONResponse +from features.text_classifier.routes import router as text_classifier_router +from features.nepali_text_classifier.routes import ( + router as nepali_text_classifier_router, +) +from features.image_classifier.routes import router as image_classifier_router +from features.image_edit_detector.routes import router as image_edit_detector_router +from fastapi.staticfiles import StaticFiles + +from config import ACCESS_RATE + +import requests + +limiter = Limiter(key_func=get_remote_address, default_limits=[ACCESS_RATE]) + +app = FastAPI() +# added the robots.txt +# Set up SlowAPI +app.state.limiter = limiter +app.add_exception_handler( + RateLimitExceeded, + lambda request, exc: JSONResponse( + status_code=429, + content={ + "status_code": 429, + "error": "Rate limit exceeded", + "message": "Too many requests. Chill for a bit and try again", + }, + ), +) +app.add_middleware(SlowAPIMiddleware) + +# Include your routes +app.include_router(text_classifier_router, prefix="/text") +app.include_router(nepali_text_classifier_router, prefix="/NP") +app.include_router(image_classifier_router, prefix="/AI-image") +app.include_router(image_edit_detector_router, prefix="/detect") + + +@app.get("/") +@limiter.limit(ACCESS_RATE) +async def root(request: Request): + return { + "message": "API is working", + "endpoints": [ + "/text/analyse", + "/text/upload", + "/text/analyse-sentences", + "/text/analyse-sentance-file", + "/NP/analyse", + "/NP/upload", + "/NP/analyse-sentences", + "/NP/file-sentences-analyse", + "/AI-image/analyse", + ], + } diff --git a/config.py b/config.py new file mode 100755 index 0000000000000000000000000000000000000000..31f65b4c7ee751e9835893ab7a877223bc1067a7 --- /dev/null +++ b/config.py @@ -0,0 +1,2 @@ +ACCESS_RATE = "20/minute" + diff --git a/docs/api_endpoints.md b/docs/api_endpoints.md new file mode 100755 index 0000000000000000000000000000000000000000..82190d6be1266ad707b474ae6a8bbf6440ff0246 --- /dev/null +++ b/docs/api_endpoints.md @@ -0,0 +1,92 @@ +# 🧩 API Endpoints + +### English (GPT-2) - `/text/` + +| Endpoint | Method | Description | +| ----------------------------- | ------ | -------------------------------------- | +| `/text/analyse` | POST | Classify raw English text | +| `/text/analyse-sentences` | POST | Sentence-by-sentence breakdown | +| `/text/analyse-sentance-file` | POST | Upload file, per-sentence breakdown | +| `/text/upload` | POST | Upload file for overall classification | +| `/text/health` | GET | Health check | + +#### Example: Classify English text + +```bash +curl -X POST http://localhost:8000/text/analyse \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"text": "This is a sample text for analysis."}' +``` + +**Response:** + +```json +{ + "result": "AI-generated", + "perplexity": 55.67, + "ai_likelihood": 66.6 +} +``` + +#### Example: File upload + +```bash +curl -X POST http://localhost:8000/text/upload \ + -H "Authorization: Bearer " \ + -F 'file=@yourfile.txt;type=text/plain' +``` + +--- + +### Nepali (SentencePiece) - `/NP/` + +| Endpoint | Method | Description | +| ---------------------------- | ------ | ------------------------------------ | +| `/NP/analyse` | POST | Classify Nepali text | +| `/NP/analyse-sentences` | POST | Sentence-by-sentence breakdown | +| `/NP/upload` | POST | Upload Nepali PDF for classification | +| `/NP/file-sentences-analyse` | POST | PDF upload, per-sentence breakdown | +| `/NP/health` | GET | Health check | + +#### Example: Nepali text classification + +```bash +curl -X POST http://localhost:8000/NP/analyse \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"text": "ΰ€―ΰ₯‹ ΰ€‰ΰ€¦ΰ€Ύΰ€Ήΰ€°ΰ€£ ΰ€΅ΰ€Ύΰ€•ΰ₯ΰ€― ΰ€Ήΰ₯‹ΰ₯€"}' +``` + +**Response:** + +```json +{ + "label": "Human", + "confidence": 98.6 +} +``` + +#### Example: Nepali PDF upload + +```bash +curl -X POST http://localhost:8000/NP/upload \ + -H "Authorization: Bearer " \ + -F 'file=@NepaliText.pdf;type=application/pdf' +``` + +### Image-Classification -`/verify-image/` + +| Endpoint | Method | Description | +| ----------------------- | ------ | ----------------------- | +| `/verify-image/analyse` | POST | Classify Image using ML | + +#### Example: Image-Classification + +```bash +curl -X POST http://localhost:8000/verify-image/analyse \ + -H "Authorization: Bearer " \ + -F 'file=@test1.png' +``` + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100755 index 0000000000000000000000000000000000000000..1dce95d2afedbfad11db29cbf231fd9692e02835 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,108 @@ + +# Deployment + +This project is containerized and deployed on **Hugging Face Spaces** using a custom `Dockerfile`. This guide explains the structure of the Dockerfile and key considerations for deploying FastAPI apps on Spaces with Docker SDK. + +--- + +## πŸ“¦ Base Image + +```dockerfile +FROM python:3.9 +```` + +We use the official Python 3.9 image for compatibility and stability across most Python libraries and tools. + +--- + +## πŸ‘€ Create a Non-Root User + +```dockerfile +RUN useradd -m -u 1000 user +USER user +ENV PATH="/home/user/.local/bin:$PATH" +``` + +* Hugging Face Spaces **requires** that containers run as a non-root user with UID `1000`. +* We also prepend the user's local binary path to `PATH` for Python package accessibility. + +--- + +## πŸ—‚οΈ Set Working Directory + +```dockerfile +WORKDIR /app +``` + +All application files will reside under `/app` for consistency and clarity. + +--- + +## πŸ“‹ Install Dependencies + +```dockerfile +COPY --chown=user ./requirements.txt requirements.txt +RUN pip install --no-cache-dir --upgrade -r requirements.txt +``` + +* Copies the dependency list with correct file ownership. +* Uses `--no-cache-dir` to reduce image size. +* Ensures the latest compatible versions are installed. + +--- + +## πŸ”‘ Download Language Model (Optional) + +```dockerfile +RUN python -m spacy download en_core_web_sm || echo "Failed to download model" +``` + +* Downloads the small English NLP model required by SpaCy. +* Uses `|| echo ...` to prevent build failure if the download fails (optional safeguard). + +--- + +## πŸ“ Copy Project Files + +```dockerfile +COPY --chown=user . /app +``` + +Copies the entire project source into the container, setting correct ownership for Hugging Face's user-based execution. + +--- + +## 🌐 Start the FastAPI Server + +```dockerfile +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] +``` + +* Launches the FastAPI app using `uvicorn`. +* **Port 7860 is mandatory** for Docker-based Hugging Face Spaces deployments. +* `app:app` refers to the `FastAPI()` instance in `app.py`. + +--- + +## βœ… Deployment Checklist + +* [x] Ensure your main file is named `app.py` or adjust `CMD` accordingly. +* [x] All dependencies should be listed in `requirements.txt`. +* [x] If using models like SpaCy, verify they are downloaded or bundled. +* [x] Test your Dockerfile locally with `docker build` before pushing to Hugging Face. + +--- + +## πŸ“š References + +* Hugging Face Docs: [Spaces Docker SDK](https://huggingface.co/docs/hub/spaces-sdks-docker) +* Uvicorn Docs: [https://www.uvicorn.org/](https://www.uvicorn.org/) +* SpaCy Models: [https://spacy.io/models](https://spacy.io/models) + +--- + +Happy deploying! +**P.S.** Try not to break stuff. πŸ˜… + + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/detector/ELA.md b/docs/detector/ELA.md new file mode 100644 index 0000000000000000000000000000000000000000..0ff39750147cedd592afe51175af3ec2fa3686b0 --- /dev/null +++ b/docs/detector/ELA.md @@ -0,0 +1,65 @@ +# Error Level Analysis (ELA) Detector + +This module provides a function to perform Error Level Analysis (ELA) on images to detect potential manipulations or edits. + +## Function: `run_ela` + +```python +def run_ela(image: Image.Image, quality: int = 90, threshold: int = 15) -> bool: +``` + +### Description + +Error Level Analysis (ELA) works by recompressing an image at a specified JPEG quality level and comparing it to the original image. Differences between the two images reveal areas with inconsistent compression artifacts β€” often indicating image manipulation. + +The function computes the maximum pixel difference across all color channels and uses a threshold to determine if the image is likely edited. + +### Parameters + +| Parameter | Type | Default | Description | +| ----------- | ----------- | ------- | ------------------------------------------------------------------------------------------- | +| `image` | `PIL.Image` | N/A | Input image in RGB mode to analyze. | +| `quality` | `int` | 90 | JPEG compression quality used for recompression during analysis (lower = more compression). | +| `threshold` | `int` | 15 | Pixel difference threshold to flag the image as edited. | + +### Returns + +`bool` + +- `True` if the image is likely edited (max pixel difference > threshold). +- `False` if the image appears unedited. + +### Usage Example + +```python +from PIL import Image +from detectors.ela import run_ela + +# Open and convert image to RGB +img = Image.open("example.jpg").convert("RGB") + +# Run ELA detection +is_edited = run_ela(img, quality=90, threshold=15) + +print("Image edited:", is_edited) +``` + +### Notes + +- The input image **must** be in RGB mode for accurate analysis. +- ELA is a heuristic technique; combining it with other detection methods increases reliability. +- Visualizing the enhanced difference image can help identify edited regions (not returned by this function but possible to add). + +### Installation + +Make sure you have Pillow installed: + +```bash +pip install pillow +``` + +### Running Locally + +Just put the function in a notebook or script file and run it with your image. It works well for basic images. + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/detector/ai_human_image_checker.md b/docs/detector/ai_human_image_checker.md new file mode 100644 index 0000000000000000000000000000000000000000..3be2d73cacae5dbb99323a8d8e436e507624cae9 --- /dev/null +++ b/docs/detector/ai_human_image_checker.md @@ -0,0 +1,132 @@ +Real vs. Fake Image Classification for Production Pipeline +========================================================== + +1\. Business Problem +-------------------- + +This project addresses the critical business need to automatically identify and flag manipulated or synthetically generated images. By accurately classifying images asΒ **"real"**Β orΒ **"fake,"**Β we can enhance the integrity of our platform, prevent the spread of misinformation, and protect our users from fraudulent content. This solution is designed for integration into our production pipeline to process images in real-time. + +2\. Solution Overview +--------------------- + +This solution leverages OpenAI's CLIP (Contrastive Language-Image Pre-Training) model to differentiate between real and fake images. The system operates as follows: + +1. **Feature Extraction:**Β A pre-trained CLIP model ('ViT-L/14') converts input images into 768-dimensional feature vectors. + +2. **Classification:**Β A Support Vector Machine (SVM) model, trained on our internal dataset of real and fake images, classifies the feature vectors. + +3. **Deployment:**Β The trained model is deployed as a service that can be integrated into our production image processing pipeline. + + +The model has achieved an accuracy ofΒ **98.29%**Β on our internal test set, demonstrating its effectiveness in distinguishing between real and fake images. + +3\. Getting Started +------------------- + +### 3.1. Dependencies + +To ensure a reproducible environment, all dependencies are listed in theΒ requirements.txtΒ file. Install them using pip: + +```bash +pip install -r requirements.txt +``` + +**requirements.txt**: + - numpy + - Pillow + - torch + - clip-by-openai + - scikit-learn + - tqdm + - seaborn + - matplotlib + +### 3.2. Data Preparation + +The model was trained on a dataset of real and fake images obtained form kaggle the dataset link is https://www.kaggle.com/datasets/tristanzhang32/ai-generated-images-vs-real-images/data$0. + +### 3.3. Usage + +#### 3.3.1. Feature Extraction + +To extract features from a new dataset, run the following command: + +``` + python extract_features.py --data_dir /path/to/your/data --output_file features.npz + ``` + +#### 3.3.2. Model Training + +To retrain the SVM model on a new set of extracted features, run: + +``` +python train_model.py --features_file features.npz --model_output_path model.joblib +``` + +#### 3.3.3. Inference + +To classify a single image using the trained model, use the provided inference script: +``` + python classify.py --image_path /path/to/your/image.jpg --model_path model.joblib + ``` + +4\. Production Deployment +------------------------- + +The image classification model is deployed as a microservice. The service exposes an API endpoint that accepts an image and returns a classification result ("real" or "fake"). + +### 4.1. API Specification + +* **Endpoint:**Β /classify + +* **Method:**Β POST + +* **Request Body:**Β multipart/form-dataΒ with a single fieldΒ image. + +* **Response:** + + * JSON{ "classification": "real", "confidence": 0.95} + + * JSON{ "error": "Error message"} + + +### 4.2. Scalability and Monitoring + +The service is deployed in a containerized environment (e.g., Docker) and managed by an orchestrator (e.g., Kubernetes) to ensure scalability and high availability. Monitoring and logging are in place to track model performance, API latency, and error rates. + +5\. Model Versioning +-------------------- + +We use a combination of Git for code versioning and a model registry for tracking trained model artifacts. Each model is versioned and associated with the commit hash of the code that produced it. The current production model isΒ **v1.2.0**. + +6\. Testing +----------- + +The project includes a suite of tests to ensure correctness and reliability: + +* **Unit tests:**Β To verify individual functions and components. + +* **Integration tests:**Β To test the interaction between different parts of the system. + +* **Model evaluation tests:**Β To continuously monitor model performance on a golden dataset. + + +To run the tests, execute: +``` +pytest +``` + +7\. Future Work +--------------- + +* **Explore more advanced classifiers:**Β Investigate the use of neural network-based classifiers on top of CLIP features. + +* **Fine-tune the CLIP model:**Β For even better performance, we can fine-tune the CLIP model on our specific domain of images. + +* **Expand the training dataset:**Β Continuously augment the training data with new examples of real and fake images to improve the model's robustness. + + +8\. Contact/Support +------------------- + +For any questions or issues regarding this project, please contact the Machine Learning team atΒ [your-team-email@yourcompany.com](mailto:your-team-email@yourcompany.com)Β . \ No newline at end of file diff --git a/docs/detector/fft.md b/docs/detector/fft.md new file mode 100644 index 0000000000000000000000000000000000000000..e2184551d9d2e2bddc972e787ff4291ec3fa7c57 --- /dev/null +++ b/docs/detector/fft.md @@ -0,0 +1,136 @@ + +# Fast Fourier Transform (FFT) Detector + +```python +def run_fft(image: Image.Image, threshold: float = 0.92) -> bool: +``` + +## **Overview** + +The `run_fft` function performs a frequency domain analysis on an image using the **Fast Fourier Transform (FFT)** to detect possible **AI generation or digital manipulation**. It leverages the fact that artificially generated or heavily edited images often exhibit a distinct high-frequency pattern. + +--- + +## **Parameters** + +| Parameter | Type | Description | +| ----------- | ----------------- | --------------------------------------------------------------------------------------- | +| `image` | `PIL.Image.Image` | Input image to analyze. It will be converted to grayscale and resized. | +| `threshold` | `float` | Proportion threshold of high-frequency components to flag the image. Default is `0.92`. | + +--- + +## **Returns** + +| Type | Description | +| ------ | ---------------------------------------------------------------------- | +| `bool` | `True` if image is likely AI-generated/manipulated; otherwise `False`. | + +--- + +## **Step-by-Step Explanation** + +### 1. **Grayscale Conversion** + +All images are converted to grayscale: + +```python +gray_image = image.convert("L") +``` + +### 2. **Resize** + +The image is resized to a fixed $512 \times 512$ for uniformity: + +```python +resized_image = gray_image.resize((512, 512)) +``` + +### 3. **FFT Calculation** + +Compute the 2D Discrete Fourier Transform: + +$$ +F(u, v) = \sum_{x=0}^{M-1} \sum_{y=0}^{N-1} f(x, y) \cdot e^{-2\pi i \left( \frac{ux}{M} + \frac{vy}{N} \right)} +$$ + +```python +fft_result = fft2(image_array) +``` + +### 4. **Shift Zero Frequency to Center** + +Use `fftshift` to center the zero-frequency component: + +```python +fft_shifted = fftshift(fft_result) +``` + +### 5. **Magnitude Spectrum** + +$$ +|F(u, v)| = \sqrt{\Re^2 + \Im^2} +$$ + +```python +magnitude_spectrum = np.abs(fft_shifted) +``` + +### 6. **Normalization** + +Normalize the spectrum to avoid scale issues: + +$$ +\text{Normalized}(u,v) = \frac{|F(u,v)|}{\max(|F(u,v)|)} +$$ + +```python +normalized_spectrum = magnitude_spectrum / max_magnitude +``` + +### 7. **High-Frequency Detection** + +High-frequency components are defined as: + +$$ +\text{Mask}(u,v) = +\begin{cases} +1 & \text{if } \text{Normalized}(u,v) > 0.5 \\ +0 & \text{otherwise} +\end{cases} +$$ + +```python +high_freq_mask = normalized_spectrum > 0.5 +``` + +### 8. **Proportion Calculation** + +$$ +\text{Ratio} = \frac{\sum \text{Mask}}{\text{Total pixels}} +$$ + +```python +high_freq_ratio = np.sum(high_freq_mask) / normalized_spectrum.size +``` + +### 9. **Threshold Decision** + +If the ratio exceeds the threshold: + +$$ +\text{is\_fake} = (\text{Ratio} > \text{Threshold}) +$$ + +```python +is_fake = high_freq_ratio > threshold +``` + +it is implemented in the api + +### Running Locally + +Just put the function in a notebook or script file and run it with your image. It works well for basic images. + + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/detector/meta.md b/docs/detector/meta.md new file mode 100644 index 0000000000000000000000000000000000000000..89f72d65c2722e6f2c6c52a8c83ad4d822dbfdcf --- /dev/null +++ b/docs/detector/meta.md @@ -0,0 +1,20 @@ +# Metadata Analysis for Image Edit Detection + +This module inspects image metadata to detect possible signs of AI-generation or post-processing edits. + +## Overview + +- Many AI-generated images and edited images leave identifiable traces in their metadata. +- This detector scans image EXIF metadata and raw bytes for known AI generation indicators and common photo editing software signatures. +- It classifies images as `"ai_generated"`, `"edited"`, or `"undetermined"` based on detected markers. +- Handles invalid image formats gracefully by reporting errors. + +## How It Works + +- Opens the image from raw bytes using the Python Pillow library (`PIL`). +- Reads EXIF metadata and specifically looks for the "Software" tag that often contains the editing app name. +- Checks for common image editors such as Photoshop, GIMP, Snapseed, etc. +- Scans the entire raw byte content of the image for embedded AI generation identifiers like "midjourney", "stable-diffusion", "openai", etc. +- Returns a status string indicating the metadata classification. + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/detector/note-for-backend.md b/docs/detector/note-for-backend.md new file mode 100644 index 0000000000000000000000000000000000000000..460f2150b39893903dba44139e97704cd46ac307 --- /dev/null +++ b/docs/detector/note-for-backend.md @@ -0,0 +1,94 @@ + +# πŸ“¦API integration note + +## Overview + +This system integrates **three image forensics methods**β€”**ELA**, **FFT**, and **Metadata analysis**β€”into a single detection pipeline to determine whether an image is AI-generated, manipulated, or authentic. + +--- + +## πŸ” Detection Modules + +### 1. **ELA (Error Level Analysis)** + +* **Purpose:** Detects tampering or editing by analyzing compression error levels. +* **Accuracy:** βœ… *Most accurate method* +* **Performance:** ❗ *Slowest method* +* **Output:** `True` (edited) or `False` (authentic) + +### 2. **FFT (Fast Fourier Transform)** + +* **Purpose:** Identifies high-frequency patterns typical of AI-generated images. +* **Accuracy:** ⚠️ *Moderately accurate* +* **Performance:** ❗ *Moderate to slow* +* **Output:** `True` (likely AI-generated) or `False` (authentic) + +### 3. **Metadata Analysis** + +* **Purpose:** Detects traces of AI tools or editors in image metadata or binary content. +* **Accuracy:** ⚠️ *Fast but weaker signal* +* **Performance:** πŸš€ *Fastest method* +* **Output:** One of: + + * `"ai_generated"` – AI tool or generator identified + * `"edited"` – Edited using known software + * `"undetermined"` – No signature found + +--- + +## 🧩 Integration Plan + +### βž• Combine all three APIs into one unified endpoint: + +```bash +POST /api/detect-image +``` + +### Input: + +* `image`: Image file (binary, any format supported by Pillow) + +### Output: + +```json +{ + "ela_result": true, + "fft_result": false, + "metadata_result": "ai_generated", + "final_decision": "ai_generated" +} +``` +> NOTE:Optionally recommending a default logic (e.g., trust ELA > FFT > Metadata). + +## Result implementation +| `ela_result` | `fft_result` | `metadata_result` | Suggested Final Decision | Notes | +| ------------ | ------------ | ----------------- | ------------------------ | ----------------------------------------------------------------------- | +| `true` | `true` | `"ai_generated"` | `ai_generated` | Strong evidence from all three modules | +| `true` | `false` | `"edited"` | `edited` | ELA confirms editing, no AI signals | +| `true` | `false` | `"undetermined"` | `edited` | ELA indicates manipulation | +| `false` | `true` | `"ai_generated"` | `ai_generated` | No edits, but strong AI frequency & metadata signature | +| `false` | `true` | `"undetermined"` | `possibly_ai_generated` | Weak metadata, but FFT indicates possible AI generation | +| `false` | `false` | `"ai_generated"` | `ai_generated` | Metadata alone shows AI use | +| `false` | `false` | `"edited"` | `possibly_edited` | Weak signalβ€”metadata shows editing but no structural or frequency signs | +| `false` | `false` | `"undetermined"` | `authentic` | No detectable manipulation or AI indicators | + + +### Decision Logic: + +* Use **ELA** as the **primary indicator** for manipulation. +* Supplement with **FFT** and **Metadata** to improve reliability. +* Combine using a simple rule-based or voting system. + +--- + +## βš™οΈ Performance Consideration + +| Method | Speed | Strength | +| -------- | ----------- | -------------------- | +| ELA | ❗ Slow | βœ… Highly accurate | +| FFT | ⚠️ Moderate | ⚠️ Somewhat reliable | +| Metadata | πŸš€ Fast | ⚠️ Low confidence | + +> For high-throughput systems, consider running Metadata first and conditionally applying ELA/FFT if suspicious. + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/features/image_classifier.md b/docs/features/image_classifier.md new file mode 100644 index 0000000000000000000000000000000000000000..0de01a9be15416977748910a151bb0dccdbab3ff --- /dev/null +++ b/docs/features/image_classifier.md @@ -0,0 +1,31 @@ +# Image Classifier + +## Overview + +This module classifies whether an input image is AI-generated or a real-life photograph. + +## Model + +- Architecture: InceptionV3 +- Type: Binary Classifier (AI vs Real) +- Format: H5 model (`latest-my_cnn_model.h5`) + +## Dataset + +- Total images: ~79,950 +- Balanced between real and generated images +- Preprocessing: Resizing, normalization + +## Code Location + +- Controller: `features/image_classifier/controller.py` +- Model Loader: `features/image_classifier/model_loader.py` +- Preprocessor: `features/image_classifier/preprocess.py` + +## API + +- Endpoint: [ENDPOINTS](../api_endpoints.md) +- Input: Image file (PNG/JPG) +- Output: JSON response with classification result and confidence + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/features/nepali_text_classifier.md b/docs/features/nepali_text_classifier.md new file mode 100644 index 0000000000000000000000000000000000000000..0940b2291be3633bcefc85001f668e3d77707c4c --- /dev/null +++ b/docs/features/nepali_text_classifier.md @@ -0,0 +1,30 @@ +# Nepali Text Classifier + +## Overview + +This classifier identifies whether Nepali-language text content is written by a human or AI. + +## Model + +- Base Model: XLM-Roberta (XLMRClassifier) +- Language: Nepali (Multilingual model) +- Fine-tuned with scraped web content (~18,000 samples) + +## Dataset + +- Custom scraped dataset with manual labeling +- Includes news, blogs, and synthetic content from various LLMs + +## Code Location + +- Controller: `features/nepali_text_classifier/controller.py` +- Inference: `features/nepali_text_classifier/inferencer.py` +- Model Loader: `features/nepali_text_classifier/model_loader.py` + +## API + +- Endpoint: [ENDPOINTS](../api_endpoints.md) +- Input: Raw text +- Output: JSON classification with label and confidence score + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/features/text_classifier.md b/docs/features/text_classifier.md new file mode 100644 index 0000000000000000000000000000000000000000..a678d5fb7010c4408049859491cc5346515adbda --- /dev/null +++ b/docs/features/text_classifier.md @@ -0,0 +1,30 @@ +# English Text Classifier + +## Overview + +Detects whether English-language text is AI-generated or human-written. + +## Model Pipeline + +- Tokenizer: GPT-2 Tokenizer +- Model: Custom trained binary classifier + +## Dataset + +- Balanced dataset: Human vs AI-generated (ChatGPT, Claude, etc.) +- Tokenized and fed into the model using PyTorch/TensorFlow + +## Code Location + +- Controller: `features/text_classifier/controller.py` +- Inference: `features/text_classifier/inferencer.py` +- Model Loader: `features/text_classifier/model_loader.py` +- Preprocessor: `features/text_classifier/preprocess.py` + +## API + +- Endpoint: [ENDPOINTS](../api_endpoints.md) +- Input: Raw English text +- Output: Prediction result with probability/confidence + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/functions.md b/docs/functions.md new file mode 100755 index 0000000000000000000000000000000000000000..43934658fcd9135fb3788dff4cc346d17706ec07 --- /dev/null +++ b/docs/functions.md @@ -0,0 +1,62 @@ +# Major Functions used + +## in Text Classifier (`features/text_classifier/` and `features/text_classifier/`) + +- **`load_model()`** + Loads the GPT-2 model and tokenizer from the specified directory paths. + +- **`lifespan()`** + Manages the application lifecycle. Initializes the model at startup and handles cleanup on shutdown. + +- **`classify_text_sync()`** + Synchronously tokenizes input text and predicts using the GPT-2 model. Returns classification and perplexity. + +- **`classify_text()`** + Asynchronously runs `classify_text_sync()` in a thread pool for non-blocking text classification. + +- **`analyze_text()`** + **POST** endpoint: Accepts text input, classifies it using `classify_text()`, and returns the result with perplexity. + +- **`health()`** + **GET** endpoint: Simple health check for API liveness. + +- **`parse_docx()`, `parse_pdf()`, `parse_txt()`** + Utilities to extract and convert `.docx`, `.pdf`, and `.txt` file contents to plain text. + +- **`warmup()`** + Downloads the model repository and initializes the model/tokenizer using `load_model()`. + +- **`download_model_repo()`** + Downloads the model files from the designated `MODEL` folder. + +- **`get_model_tokenizer()`** + Checks if the model already exists; if not, downloads itβ€”otherwise, loads the cached model. + +- **`handle_file_upload()`** + Handles file uploads from the `/upload` route. Extracts text, classifies, and returns results. + +- **`extract_file_contents()`** + Extracts and returns plain text from uploaded files (PDF, DOCX, TXT). + +- **`handle_file_sentence()`** + Processes file uploads by analyzing each sentence (under 10,000 chars) before classification. + +- **`handle_sentence_level_analysis()`** + Checks/strips each sentence, then computes AI/human likelihood for each. + +- **`analyze_sentences()`** + Splits paragraphs into sentences, classifies each, and returns all results. + +- **`analyze_sentence_file()`** + Like `handle_file_sentence()`β€”analyzes sentences in uploaded files. +--- +## for image_classifier + +- **`Classify_Image_router()`** – Handles image classification requests by routing and coordinating preprocessing and inference. +- **`classify_image()`** – Performs AI vs human image classification using the loaded model. +- **`load_model()`** – Loads the pretrained model from Hugging Face at server startup. +- **`preprocess_image()`** – Applies all required preprocessing steps to the input image. + +> Note: While many functions mirror those in the text classifier, the image classifier primarily uses TensorFlow rather than PyTorch. + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/nestjs_integration.md b/docs/nestjs_integration.md new file mode 100755 index 0000000000000000000000000000000000000000..36337367c685703dc0af57263b1dd79cb6052a17 --- /dev/null +++ b/docs/nestjs_integration.md @@ -0,0 +1,83 @@ +# Nestjs + fastapi + +You can easily call this API from a NestJS microservice. + +**.env** +```env +FASTAPI_BASE_URL=http://localhost:8000 +SECRET_TOKEN=your_secret_token_here +``` + +**fastapi.service.ts** + +```typescript +import { Injectable } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { ConfigService } from "@nestjs/config"; +import { firstValueFrom } from "rxjs"; + +@Injectable() +export class FastAPIService { + constructor( + private http: HttpService, + private config: ConfigService, + ) {} + + async analyzeText(text: string) { + const url = `${this.config.get("FASTAPI_BASE_URL")}/text/analyse`; + const token = this.config.get("SECRET_TOKEN"); + + const response = await firstValueFrom( + this.http.post( + url, + { text }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ), + ); + + return response.data; + } +} +``` + +**app.module.ts** +```typescript +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { HttpModule } from "@nestjs/axios"; +import { AppController } from "./app.controller"; +import { FastAPIService } from "./fastapi.service"; + +@Module({ + imports: [ConfigModule.forRoot(), HttpModule], + controllers: [AppController], + providers: [FastAPIService], +}) +export class AppModule {} +``` + +**app.controller.ts** +```typescript +import { Body, Controller, Post, Get } from '@nestjs/common'; +import { FastAPIService } from './fastapi.service'; + +@Controller() +export class AppController { + constructor(private readonly fastapiService: FastAPIService) {} + + @Post('analyze-text') + async callFastAPI(@Body('text') text: string) { + return this.fastapiService.analyzeText(text); + } + + @Get() + getHello(): string { + return 'NestJS is connected to FastAPI'; + } +} +``` +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/security.md b/docs/security.md new file mode 100755 index 0000000000000000000000000000000000000000..2310fe2998bf2837264d8737a24178c59f71b2e1 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,10 @@ +# Security: Bearer Token Auth + +All endpoints require authentication via Bearer token: + +- Set `SECRET_TOKEN` in `.env` +- Add header: `Authorization: Bearer ` + +Unauthorized requests receive `403 Forbidden`. + +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/setup.md b/docs/setup.md new file mode 100755 index 0000000000000000000000000000000000000000..13468666a89b93c246db89a09943ff2b379cd99c --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,24 @@ +# Setup & Installation + +## 1. Clone the Repository +```bash +git clone https://github.com/cyberalertnepal/aiapi +cd aiapi +``` + +## 2. Install Dependencies +```bash +pip install -r requirements.txt +``` + +## 3. Configure Environment +Create a `.env` file: +```env +SECRET_TOKEN=your_secret_token_here +``` + +## 4. Run the API +```bash +uvicorn app:app --host 0.0.0.0 --port 8000 +``` +[πŸ”™ Back to Main README](../README.md) diff --git a/docs/status_code.md b/docs/status_code.md new file mode 100644 index 0000000000000000000000000000000000000000..d9a5b0ba05048aeb6932087a8fa85731a59c1582 --- /dev/null +++ b/docs/status_code.md @@ -0,0 +1,68 @@ +# Error Codes Reference + +## πŸ”Ή Summary Table + +| Code | Message | Description | +| ---- | ----------------------------------------------------- | ------------------------------------------ | +| 400 | Text must contain at least two words | Input text too short | +| 400 | Text should be less than 10,000 characters | Input text too long | +| 404 | The file is empty or only contains whitespace | File has no usable content | +| 404 | Invalid file type. Only .docx, .pdf, and .txt allowed | Unsupported file format | +| 403 | Invalid or expired token | Authentication token is invalid or expired | +| 413 | Text must contain at least two words | Text too short (alternative condition) | +| 413 | Text must be less than 10,000 characters | Text too long (alternative condition) | +| 413 | The image error (preprocessing) | Image size/content issue | +| 500 | Error processing the file | Internal server error while processing | + +--- + +## πŸ” Error Details + +### `400` - Bad Request + +- **Text must contain at least two words** + The input text field is too short. Submit at least two words to proceed. + +- **Text should be less than 10,000 characters** + Input text exceeds the maximum allowed character limit. Consider truncating or summarizing the content. + +--- + +### `404` - Not Found + +- **The file is empty or only contains whitespace** + The uploaded file is invalid due to lack of meaningful content. Ensure the file has readable, non-empty text. + +- **Invalid file type. Only .docx, .pdf, and .txt are allowed** + The file format is not supported. Convert the file to one of the allowed formats before uploading. + +--- + +### `403` - Forbidden + +- **Invalid or expired token** + Your access token is either expired or incorrect. Try logging in again or refreshing the token. + +--- + +### `413` - Payload Too Large + +- **Text must contain at least two words** + The text payload is too small or malformed under a large upload context. Add more content. + +- **Text must be less than 10,000 characters** + The payload exceeds the allowed character limit for a single request. Break it into smaller chunks if needed. + +- **The image error** + The uploaded image is too large or corrupted. Try resizing or compressing it before retrying. + +--- + +### `500` - Internal Server Error + +- **Error processing the file** + An unexpected server-side failure occurred during file analysis. Retry later or contact support if persistent. + +--- + +> πŸ“Œ **Note:** Always validate inputs, check token status, and follow file guidelines before making requests. diff --git a/docs/structure.md b/docs/structure.md new file mode 100755 index 0000000000000000000000000000000000000000..2e3f59b5ea9965ee307cc32b604eeb99a722212e --- /dev/null +++ b/docs/structure.md @@ -0,0 +1,74 @@ +## πŸ—οΈ Project Structure + +```bash +AI-Checker/ +β”‚ +β”œβ”€β”€ app.py # Main FastAPI entry point +β”œβ”€β”€ config.py # Configuration settings +β”œβ”€β”€ Dockerfile # Docker build script +β”œβ”€β”€ Procfile # Deployment entry for platforms like Heroku/Railway +β”œβ”€β”€ requirements.txt # Python dependency list +β”œβ”€β”€ README.md # Main project overview πŸ“˜ +β”‚ +β”œβ”€β”€ features/ # Core AI content detection modules +β”‚ β”œβ”€β”€ image_classifier/ # Classifies AI vs Real images +β”‚ β”‚ β”œβ”€β”€ controller.py +β”‚ β”‚ β”œβ”€β”€ model_loader.py +β”‚ β”‚ └── preprocess.py +β”‚ β”œβ”€β”€ image_edit_detector/ # Detects tampered or edited images +β”‚ β”œβ”€β”€ nepali_text_classifier/ # Classifies Nepali text as AI or Human +β”‚ β”‚ β”œβ”€β”€ controller.py +β”‚ β”‚ β”œβ”€β”€ inferencer.py +β”‚ β”‚ β”œβ”€β”€ model_loader.py +β”‚ β”‚ └── preprocess.py +β”‚ └── text_classifier/ # Classifies English text as AI or Human +β”‚ β”œβ”€β”€ controller.py +β”‚ β”œβ”€β”€ inferencer.py +β”‚ β”œβ”€β”€ model_loader.py +β”‚ └── preprocess.py +β”‚ +β”œβ”€β”€ docs/ # Internal documentation and API references +β”‚ β”œβ”€β”€ api_endpoints.md +β”‚ β”œβ”€β”€ deployment.md +β”‚ β”œβ”€β”€ detector/ +β”‚ β”‚ β”œβ”€β”€ ELA.md +β”‚ β”‚ β”œβ”€β”€ fft.md +β”‚ β”‚ β”œβ”€β”€ meta.md +β”‚ β”‚ └── note-for-backend.md +β”‚ β”œβ”€β”€ features/ +β”‚ β”‚ β”œβ”€β”€ image_classifier.md +β”‚ β”‚ β”œβ”€β”€ nepali_text_classifier.md +β”‚ β”‚ └── text_classifier.md +β”‚ β”œβ”€β”€ functions.md +β”‚ β”œβ”€β”€ nestjs_integration.md +β”‚ β”œβ”€β”€ security.md +β”‚ β”œβ”€β”€ setup.md +β”‚ └── structure.md +β”‚ +β”œβ”€β”€ IMG_Models/ # Stored model weights +β”‚ └── latest-my_cnn_model.h5 +β”‚ +β”œβ”€β”€ notebooks/ # Experimental/debug Jupyter notebooks +β”œβ”€β”€ static/ # Static files (e.g., UI assets, test inputs) +└── test.md # Test usage notes +``` + +### 🌟 Key Files and Their Roles + +- **`app.py`**: Entry point initializing FastAPI app and routes. +- **`Procfile`**: Tells Railway (or similar platforms) how to run the program. +- **`requirements.txt`**: Tracks all Python dependencies for the project. +- **`__init__.py`**: Package initializer for the root module and submodules. +- **`features/text_classifier/`** + - **`controller.py`**: Handles logic between routes and the model. + - **`inferencer.py`**: Runs inference and returns predictions as well as file system + utilities. +- **`features/NP/`** + - **`controller.py`**: Handles logic between routes and the model. + - **`inferencer.py`**: Runs inference and returns predictions as well as file system + utilities. + - **`model_loader.py`**: Loads the ML model and tokenizer. + - **`preprocess.py`**: Prepares input text for the model. + - **`routes.py`**: Defines API routes for text classification. + +[πŸ”™ Back to Main README](../README.md) diff --git a/features/ai_human_image_classifier/controller.py b/features/ai_human_image_classifier/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..b5845fd7e2224d3bf93275dcd6f10811f7072a16 --- /dev/null +++ b/features/ai_human_image_classifier/controller.py @@ -0,0 +1,35 @@ +from typing import IO +from preprocessor import preprocessor +from inferencer import inferencer + +class ClassificationController: + """ + Controller to handle the image classification logic. + """ + def classify_image(self, image_file: IO) -> dict: + """ + Orchestrates the classification of a single image file. + + Args: + image_file (IO): The image file to classify. + + Returns: + dict: The classification result. + """ + try: + # Step 1: Preprocess the image + image_tensor = preprocessor.process(image_file) + + # Step 2: Perform inference + result = inferencer.predict(image_tensor) + + return result + except ValueError as e: + # Handle specific errors like invalid images + return {"error": str(e)} + except Exception as e: + # Handle unexpected errors + print(f"An unexpected error occurred: {e}") + return {"error": "An internal error occurred during classification."} + +controller = ClassificationController() diff --git a/features/ai_human_image_classifier/inferencer.py b/features/ai_human_image_classifier/inferencer.py new file mode 100644 index 0000000000000000000000000000000000000000..07b2fed5202b3173074e9b44e3f1820e6b21a2a3 --- /dev/null +++ b/features/ai_human_image_classifier/inferencer.py @@ -0,0 +1,48 @@ +import torch +import numpy as np +from model_loader import models + +class Inferencer: + + def __init__(self): + self.clip_model = models.clip_model + self.svm_model = models.svm_model + + @torch.no_grad() + def predict(self, image_tensor:torch.Tensor) -> dict: + """ + Takes a preprocessed image tensor and returns the classification result. + + Args: + image_tensor (torch.Tensor): The preprocessed image tensor. + + Returns: + dict: A dictionary containing the classification label and confidence score. + """ + + image_features = self.clip_model.encode_image(image_tensor) + image_features_np = image_features.cpu().numpy() + + prediction = self.svm_model.predict(image_features_np)[0] + + if hasattr(self.svm_model, "predict_proba"): + # If yes, use predict_proba for a true confidence score + confidence_scores = self.svm_model.predict_proba(image_features_np)[0] + confidence = float(np.max(confidence_scores)) + else: + # If no, use decision_function as a fallback confidence measure. + # The absolute value of the decision function score indicates confidence. + # We can apply a sigmoid function to scale it to a [0, 1] range for consistency. + decision_score = self.svm_model.decision_function(image_features_np)[0] + confidence = 1 / (1 + np.exp(-np.abs(decision_score))) + confidence = float(confidence) + + label_map = {0: 'real', 1: 'fake'} + classification_label = label_map.get(prediction, "unknown") + + return { + "classification": classification_label, + "confidence": confidence + } + +inferencer = Inferencer() \ No newline at end of file diff --git a/features/ai_human_image_classifier/main.py b/features/ai_human_image_classifier/main.py new file mode 100644 index 0000000000000000000000000000000000000000..5e11036e5083c2efc300896056615623a48818a8 --- /dev/null +++ b/features/ai_human_image_classifier/main.py @@ -0,0 +1,27 @@ +from fastapi import FastAPI +from routes import router as api_router + +# Initialize the FastAPI app +app = FastAPI( + title="Real vs. Fake Image Classification API", + description="An API to classify images as real or fake using OpenAI's CLIP and an SVM model.", + version="1.0.0" +) + +# Include the API router +# All routes defined in routes.py will be available under the /api prefix +app.include_router(api_router, prefix="/api", tags=["Classification"]) + +@app.get("/", tags=["Root"]) +async def read_root(): + """ + A simple root endpoint to confirm the API is running. + """ + return {"message": "Welcome to the Image Classification API. Go to /docs for the API documentation."} + + +# To run this application: +# 1. Make sure you have all dependencies from requirements.txt installed. +# 2. Make sure the 'svm_model.joblib' file is in the same directory. +# 3. Run the following command in your terminal: +# uvicorn main:app --reload diff --git a/features/ai_human_image_classifier/model_loader.py b/features/ai_human_image_classifier/model_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..e26ec9680da3ef950e237b6c620275673130d0ee --- /dev/null +++ b/features/ai_human_image_classifier/model_loader.py @@ -0,0 +1,80 @@ +import clip +import torch +import joblib +from pathlib import Path +from huggingface_hub import hf_hub_download + +class ModelLoader: + """ + A class to load and hold the machine learning models. + This ensures that models are loaded only once. + """ + def __init__(self, clip_model_name: str, svm_repo_id: str, svm_filename: str): + """ + Initializes the ModelLoader and loads the models. + + Args: + clip_model_name (str): The name of the CLIP model to load (e.g., 'ViT-L/14'). + svm_repo_id (str): The repository ID on Hugging Face (e.g., 'rhnsa/ai_human_image_detector'). + svm_filename (str): The name of the model file in the repository (e.g., 'model.joblib'). + """ + self.device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"Using device: {self.device}") + + self.clip_model, self.clip_preprocess = self._load_clip_model(clip_model_name) + self.svm_model = self._load_svm_model(repo_id=svm_repo_id, filename=svm_filename) + print("Models loaded successfully.") + + def _load_clip_model(self, model_name: str): + """ + Loads the specified CLIP model and its preprocessor. + + Args: + model_name (str): The name of the CLIP model. + + Returns: + A tuple containing the loaded CLIP model and its preprocess function. + """ + try: + model, preprocess = clip.load(model_name, device=self.device) + return model, preprocess + except Exception as e: + print(f"Error loading CLIP model: {e}") + raise + + def _load_svm_model(self, repo_id: str, filename: str): + """ + Downloads and loads the SVM model from a Hugging Face Hub repository. + + Args: + repo_id (str): The repository ID on Hugging Face. + filename (str): The name of the model file in the repository. + + Returns: + The loaded SVM model object. + """ + print(f"Downloading SVM model from Hugging Face repo: {repo_id}") + try: + # Download the model file from the Hub. It returns the cached path. + model_path = hf_hub_download(repo_id=repo_id, filename=filename) + print(f"SVM model downloaded to: {model_path}") + + # Load the model from the downloaded path + svm_model = joblib.load(model_path) + return svm_model + except Exception as e: + print(f"Error downloading or loading SVM model from Hugging Face: {e}") + raise + +# --- Global Model Instance --- +# This creates a single instance of the models that can be imported by other modules. +CLIP_MODEL_NAME = 'ViT-L/14' +SVM_REPO_ID = 'rhnsa/ai_human_image_detector' +SVM_FILENAME = 'svm_model_real.joblib' # The name of your model file in the Hugging Face repo + +# This instance will be created when the application starts. +models = ModelLoader( + clip_model_name=CLIP_MODEL_NAME, + svm_repo_id=SVM_REPO_ID, + svm_filename=SVM_FILENAME +) diff --git a/features/ai_human_image_classifier/preprocessor.py b/features/ai_human_image_classifier/preprocessor.py new file mode 100644 index 0000000000000000000000000000000000000000..546f9d9c0d05e1433f51d9d463fb85bf6b449ecb --- /dev/null +++ b/features/ai_human_image_classifier/preprocessor.py @@ -0,0 +1,34 @@ +from PIL import Image +import torch +from typing import IO +from model_loader import models + +class ImagePreprocessor: + + def __init__(self): + self.preprocess = models.clip_preprocess + self.device = models.device + + def process(self, image_file: IO) -> torch.Tensor: + """ + Opens an image file, preprocesses it, and returns it as a tensor. + + Args: + image_file (IO): The image file object (e.g., from a file upload). + + Returns: + torch.Tensor: The preprocessed image as a tensor, ready for the model. + """ + try: + # Open the image from the file-like object + image = Image.open(image_file).convert("RGB") + except Exception as e: + print(f"Error opening image: {e}") + # You might want to raise a custom exception here + raise ValueError("Invalid or corrupted image file.") + + # Apply the CLIP preprocessing transformations and move to the correct device + image_tensor = self.preprocess(image).unsqueeze(0).to(self.device) + return image_tensor + +preprocessor = ImagePreprocessor() diff --git a/features/ai_human_image_classifier/routes.py b/features/ai_human_image_classifier/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..9d716c4404c818cc5fdcc241e81ab18048e5e572 --- /dev/null +++ b/features/ai_human_image_classifier/routes.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, File, UploadFile, HTTPException, status +from fastapi.responses import JSONResponse +from controller import controller + +from fastapi import Request, Depends +from fastapi.security import HTTPBearer +from slowapi import Limiter +from slowapi.util import get_remote_address + + +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) +security = HTTPBearer() +# Create an API router +router = APIRouter() + +@router.post("/classify", summary="Classify an image as Real or Fake") +async def classify_image_endpoint(image: UploadFile = File(...)): + """ + Accepts an image file and classifies it as 'real' or 'fake'. + + - **image**: The image file to be classified (e.g., JPEG, PNG). + + Returns a JSON object with the classification and a confidence score. + """ + # Check for a valid image content type + if not image.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Unsupported file type. Please upload an image (e.g., JPEG, PNG)." + ) + + # The controller expects a file-like object, which `image.file` provides + result = controller.classify_image(image.file) + + if "error" in result: + # If the controller returned an error, forward it as an HTTP exception + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=result["error"] + ) + + return JSONResponse(content=result, status_code=status.HTTP_200_OK) + diff --git a/features/image_classifier/__init__.py b/features/image_classifier/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/features/image_classifier/controller.py b/features/image_classifier/controller.py new file mode 100755 index 0000000000000000000000000000000000000000..3f59e7458adae343be1ca26e8970b47dadbd5950 --- /dev/null +++ b/features/image_classifier/controller.py @@ -0,0 +1,16 @@ +from fastapi import HTTPException, File, UploadFile +from .preprocess import preprocess_image +from .inferencer import classify_image + + +async def Classify_Image_router(file: UploadFile = File(...)): + try: + image_array = preprocess_image(file) + try: + result = classify_image(image_array) + return result + except: + raise HTTPException(status_code=423, detail="something went wrong") + + except Exception as e: + raise HTTPException(status_code=413, detail=str(e)) diff --git a/features/image_classifier/inferencer.py b/features/image_classifier/inferencer.py new file mode 100755 index 0000000000000000000000000000000000000000..844a62e6700822a11fb477237cafcbb7cc38022d --- /dev/null +++ b/features/image_classifier/inferencer.py @@ -0,0 +1,42 @@ +import numpy as np +from .model_loader import get_model + +# Thresholds +AI_THRESHOLD = 0.55 +HUMAN_THRESHOLD = 0.45 + + +def classify_image(image_array: np.ndarray) -> dict: + try: + model = get_model() + predictions = model.predict(image_array) + + if predictions.ndim != 2 or predictions.shape[1] != 1: + raise ValueError( + "Model output shape is invalid. Expected shape: (batch, 1)" + ) + + ai_conf = float(np.clip(predictions[0][0], 0.0, 1.0)) + human_conf = 1.0 - ai_conf + + # Classification logic + if ai_conf > AI_THRESHOLD: + label = "AI Generated" + elif ai_conf < HUMAN_THRESHOLD: + label = "Human Generated" + else: + label = "Uncertain (Maybe AI)" + + return { + "label": label, + "ai_confidence": round(ai_conf * 100, 2), + "human_confidence": round(human_conf * 100, 2), + } + + except Exception as e: + return { + "error": str(e), + "label": "Classification Failed", + "ai_confidence": None, + "human_confidence": None, + } diff --git a/features/image_classifier/model_loader.py b/features/image_classifier/model_loader.py new file mode 100755 index 0000000000000000000000000000000000000000..e419e7dc32eb0bb75b7597b5421a25b186cfa2a4 --- /dev/null +++ b/features/image_classifier/model_loader.py @@ -0,0 +1,58 @@ +import os +import shutil +import logging +import tensorflow as tf +from tensorflow.keras.layers import Layer +from huggingface_hub import snapshot_download + +# Model config +REPO_ID = "can-org/AI-VS-HUMAN-IMAGE-classifier" +MODEL_DIR = "./IMG_Models" +WEIGHTS_PATH = os.path.join(MODEL_DIR, "latest-my_cnn_model.h5") + +# Device info (for logging) +gpus = tf.config.list_physical_devices("GPU") +device = "cuda" if gpus else "cpu" + +# Global model reference +_model_img = None + +# Custom layer used in the model +class Cast(Layer): + def call(self, inputs): + return tf.cast(inputs, tf.float32) + +def warmup(): + global _model_img + download_model_repo() + _model_img = load_model() + logging.info("Image model is ready.") + +def download_model_repo(): + if os.path.exists(MODEL_DIR) and os.path.isdir(MODEL_DIR): + logging.info("Image model already exists, skipping download.") + return + snapshot_path = snapshot_download(repo_id=REPO_ID) + os.makedirs(MODEL_DIR, exist_ok=True) + shutil.copytree(snapshot_path, MODEL_DIR, dirs_exist_ok=True) + +def load_model(): + global _model_img + if _model_img is not None: + return _model_img + + print(f"{'GPU detected' if device == 'cuda' else 'No GPU detected'}, loading model on {device.upper()}.") + + _model_img = tf.keras.models.load_model( + WEIGHTS_PATH, custom_objects={"Cast": Cast} + ) + print("Model input shape:", _model_img.input_shape) + return _model_img + +def get_model(): + global _model_img + if _model_img is None: + download_model_repo() + _model_img = load_model() + return _model_img + diff --git a/features/image_classifier/preprocess.py b/features/image_classifier/preprocess.py new file mode 100755 index 0000000000000000000000000000000000000000..9ecf3f8ada44910df1cf4d0ce064b4f78df0314c --- /dev/null +++ b/features/image_classifier/preprocess.py @@ -0,0 +1,26 @@ +import numpy as np +import cv2 +from fastapi import HTTPException + + +def preprocess_image(file): + try: + file.file.seek(0) + image_bytes = file.file.read() + nparr = np.frombuffer(image_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + raise HTTPException(status_code=500, detail="Could not decode image.") + + img = cv2.resize(img, (299, 299)) + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + img = img / 255.0 + img = np.expand_dims(img, axis=0).astype(np.float32) + return img + + except HTTPException: + raise # Re-raise already defined HTTP errors + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Image preprocessing failed: {str(e)}" + ) diff --git a/features/image_classifier/routes.py b/features/image_classifier/routes.py new file mode 100755 index 0000000000000000000000000000000000000000..e64983c8a4298bc9abf8ceedf7ca3b517d3595de --- /dev/null +++ b/features/image_classifier/routes.py @@ -0,0 +1,26 @@ +from slowapi import Limiter +from config import ACCESS_RATE +from fastapi import APIRouter, File, Request, Depends, HTTPException, UploadFile +from fastapi.security import HTTPBearer +from slowapi import Limiter +from slowapi.util import get_remote_address +from .controller import Classify_Image_router +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) +security = HTTPBearer() + +@router.post("/analyse") +@limiter.limit(ACCESS_RATE) +async def analyse( + request: Request, + file: UploadFile = File(...), + token: str = Depends(security) +): + result = await Classify_Image_router(file) # await the async function + return result + +@router.get("/health") +@limiter.limit(ACCESS_RATE) +def health(request: Request): + return {"status": "ok"} + diff --git a/features/image_edit_detector/controller.py b/features/image_edit_detector/controller.py new file mode 100755 index 0000000000000000000000000000000000000000..cd65df1b01288a251304eadd83c7db255f8e803d --- /dev/null +++ b/features/image_edit_detector/controller.py @@ -0,0 +1,49 @@ +from PIL import Image +import io +from io import BytesIO +from .detectors.fft import run_fft +from .detectors.metadata import run_metadata +from .detectors.ela import run_ela +from .preprocess import preprocess_image +from fastapi import HTTPException,status,Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +security=HTTPBearer() +import os +async def process_image_ela(image_bytes: bytes, quality: int=90): + image = Image.open(io.BytesIO(image_bytes)) + + if image.mode != "RGB": + image = image.convert("RGB") + + compressed_image = preprocess_image(image, quality) + ela_result = run_ela(compressed_image, quality) + + return { + "is_edited": ela_result, + "ela_score": ela_result + } + +async def process_fft_image(image_bytes: bytes,threshold:float=0.95) -> dict: + image = Image.open(BytesIO(image_bytes)).convert("RGB") + result = run_fft(image,threshold) + return {"edited": bool(result)} + + +async def process_meta_image(image_bytes: bytes) -> dict: + try: + result = run_metadata(image_bytes) + return {"source": result} # e.g. "edited", "phone_capture", "unknown" + except Exception as e: + # Handle errors gracefully, return useful message or raise HTTPException if preferred + return {"error": str(e)} + + +async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + token = credentials.credentials + expected_token = os.getenv("MY_SECRET_TOKEN") + if token != expected_token: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid or expired token" + ) + return token diff --git a/features/image_edit_detector/detectors/ela.py b/features/image_edit_detector/detectors/ela.py new file mode 100644 index 0000000000000000000000000000000000000000..3f9f1697037e1d70d30c48f2ed91234b9793e6ef --- /dev/null +++ b/features/image_edit_detector/detectors/ela.py @@ -0,0 +1,32 @@ +from PIL import Image, ImageChops, ImageEnhance +import io + + +def run_ela(image: Image.Image, quality: int = 90, threshold: int = 15) -> bool: + """ + Perform Error Level Analysis to detect image manipulation. + + Parameters: + image (PIL.Image): Input image (should be RGB). + quality (int): JPEG compression quality for ELA. + threshold (int): Maximum pixel difference threshold to classify as edited. + + Returns: + bool: True if image appears edited, False otherwise. + """ + + # Recompress the image into JPEG format in memory + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=quality) + buffer.seek(0) + recompressed = Image.open(buffer) + + # Compute the pixel-wise difference + diff = ImageChops.difference(image, recompressed) + extrema = diff.getextrema() + max_diff = max([ex[1] for ex in extrema]) + + # Enhance difference image for debug (not returned) + _ = ImageEnhance.Brightness(diff).enhance(10) + + return max_diff > threshold diff --git a/features/image_edit_detector/detectors/fft.py b/features/image_edit_detector/detectors/fft.py new file mode 100644 index 0000000000000000000000000000000000000000..7cee524053b83154b9de8ae554584694cd7c8b7a --- /dev/null +++ b/features/image_edit_detector/detectors/fft.py @@ -0,0 +1,40 @@ +import numpy as np +from PIL import Image +from scipy.fft import fft2, fftshift + + +def run_fft(image: Image.Image, threshold: float = 0.92) -> bool: + """ + Detects potential image manipulation or generation using FFT-based high-frequency analysis. + + Parameters: + image (PIL.Image.Image): The input image. + threshold (float): Proportion of high-frequency components above which the image is flagged. + + Returns: + bool: True if the image is likely AI-generated or manipulated, False otherwise. + """ + gray_image = image.convert("L") + + resized_image = gray_image.resize((512, 512)) + + + image_array = np.array(resized_image) + + fft_result = fft2(image_array) + + fft_shifted = fftshift(fft_result) + + magnitude_spectrum = np.abs(fft_shifted) + max_magnitude = np.max(magnitude_spectrum) + if max_magnitude == 0: + return False # Avoid division by zero if image is blank + normalized_spectrum = magnitude_spectrum / max_magnitude + + high_freq_mask = normalized_spectrum > 0.5 + + high_freq_ratio = np.sum(high_freq_mask) / normalized_spectrum.size + + is_fake = high_freq_ratio > threshold + return is_fake + diff --git a/features/image_edit_detector/detectors/metadata.py b/features/image_edit_detector/detectors/metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..aa0ac19cd2e4025a0b14c2d8b6784060af2a40e9 --- /dev/null +++ b/features/image_edit_detector/detectors/metadata.py @@ -0,0 +1,82 @@ +from PIL import Image, UnidentifiedImageError +import io + +# Common AI metadata identifiers in image files. +AI_INDICATORS = [ + b'c2pa', b'claim_generator', b'claim_generator_info', + b'created_software_agent', b'actions.v2', b'assertions', + b'urn:c2pa', b'jumd', b'jumb', b'jumdcbor', b'jumdc2ma', + b'jumdc2as', b'jumdc2cl', b'cbor', b'convertedsfwareagent',b'c2pa.version', + b'c2pa.assertions', b'c2pa.actions', + b'c2pa.thumbnail', b'c2pa.signature', b'c2pa.manifest', + b'c2pa.manifest_store', b'c2pa.ingredient', b'c2pa.parent', + b'c2pa.provenance', b'c2pa.claim', b'c2pa.hash', b'c2pa.authority', + b'jumdc2pn', b'jumdrefs', b'jumdver', b'jumdmeta', + + + 'midjourney'.encode('utf-8'), + 'stable-diffusion'.encode('utf-8'), + 'stable diffusion'.encode('utf-8'), + 'stable_diffusion'.encode('utf-8'), + 'artbreeder'.encode('utf-8'), + 'runwayml'.encode('utf-8'), + 'remix.ai'.encode('utf-8'), + 'firefly'.encode('utf-8'), + 'adobe_firefly'.encode('utf-8'), + + # OpenAI / DALLΒ·E indicators (all encoded to bytes) + 'openai'.encode('utf-8'), + 'dalle'.encode('utf-8'), + 'dalle2'.encode('utf-8'), + 'DALL-E'.encode('utf-8'), + 'DALLΒ·E'.encode('utf-8'), + 'created_by: openai'.encode('utf-8'), + 'tool: dalle'.encode('utf-8'), + 'tool: dalle2'.encode('utf-8'), + 'creator: openai'.encode('utf-8'), + 'creator: dalle'.encode('utf-8'), + 'openai.com'.encode('utf-8'), + 'api.openai.com'.encode('utf-8'), + 'openai_model'.encode('utf-8'), + 'openai_gpt'.encode('utf-8'), + + #Further possible AI-Generation Indicators + 'generated_by'.encode('utf-8'), + 'model_id'.encode('utf-8'), + 'model_version'.encode('utf-8'), + 'model_info'.encode('utf-8'), + 'tool_name'.encode('utf-8'), + 'tool_creator'.encode('utf-8'), + 'tool_version'.encode('utf-8'), + 'model_signature'.encode('utf-8'), + 'ai_model'.encode('utf-8'), + 'ai_tool'.encode('utf-8'), + 'generator'.encode('utf-8'), + 'generated_by_ai'.encode('utf-8'), + 'ai_generated'.encode('utf-8'), + 'ai_art'.encode('utf-8') + ] + + +def run_metadata(image_bytes: bytes) -> str: + try: + img = Image.open(io.BytesIO(image_bytes)) + img.load() + + exif = img.getexif() + software = str(exif.get(305, "")).strip() + + suspicious_editors = ["Photoshop", "GIMP", "Snapseed", "Pixlr", "VSCO", "Editor", "Adobe", "Luminar"] + + if any(editor.lower() in software.lower() for editor in suspicious_editors): + return "edited" + + if any(indicator in image_bytes for indicator in AI_INDICATORS): + return "ai_generated" + + return "undetermined" + + except UnidentifiedImageError: + return "error: invalid image format" + except Exception as e: + return f"error: {str(e)}" diff --git a/features/image_edit_detector/preprocess.py b/features/image_edit_detector/preprocess.py new file mode 100755 index 0000000000000000000000000000000000000000..55c70308cb0feb3e058b1e7d1b74bbc23d08e31b --- /dev/null +++ b/features/image_edit_detector/preprocess.py @@ -0,0 +1,9 @@ +from PIL import Image +import io + +def preprocess_image(img: Image.Image, quality: int) -> Image.Image: + buffer = io.BytesIO() + img.save(buffer, format="JPEG", quality=quality) + buffer.seek(0) + return Image.open(buffer) + diff --git a/features/image_edit_detector/routes.py b/features/image_edit_detector/routes.py new file mode 100755 index 0000000000000000000000000000000000000000..26b328d743a723e96f4cb2efceaa150888eed45d --- /dev/null +++ b/features/image_edit_detector/routes.py @@ -0,0 +1,53 @@ +from slowapi import Limiter +from config import ACCESS_RATE +from fastapi import APIRouter, File, Request, Depends, HTTPException, UploadFile +from fastapi.security import HTTPBearer +from slowapi import Limiter +from slowapi.util import get_remote_address +from io import BytesIO +from .controller import process_image_ela , verify_token,process_fft_image, process_meta_image +import requests +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) +security = HTTPBearer() + + + +@router.post("/ela") +@limiter.limit(ACCESS_RATE) +async def detect_ela(request:Request,file: UploadFile = File(...), quality: int = 90 ,token: str = Depends(verify_token)): + # Check file extension + allowed_types = ["image/jpeg", "image/png"] + + if file.content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail="Unsupported file type. Only JPEG and PNG images are allowed." + ) + + content = await file.read() + result = await process_image_ela(content, quality) + return result + +@router.post("/fft") +@limiter.limit(ACCESS_RATE) +async def detect_fft(request:Request,file:UploadFile =File(...),threshold:float=0.95,token:str=Depends(verify_token)): + if file.content_type not in ["image/jpeg", "image/png"]: + raise HTTPException(status_code=400, detail="Unsupported image type.") + + content = await file.read() + result = await process_fft_image(content,threshold) + return result + +@router.post("/meta") +@limiter.limit(ACCESS_RATE) +async def detect_meta(request:Request,file:UploadFile=File(...),token:str=Depends(verify_token)): + if file.content_type not in ["image/jpeg", "image/png"]: + raise HTTPException(status_code=400, detail="Unsupported image type.") + content = await file.read() + result = await process_meta_image(content) + return result +@router.post("/health") +@limiter.limit(ACCESS_RATE) +def heath(request:Request): + return {"status":"ok"} diff --git a/features/nepali_text_classifier/__init__.py b/features/nepali_text_classifier/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/features/nepali_text_classifier/controller.py b/features/nepali_text_classifier/controller.py new file mode 100755 index 0000000000000000000000000000000000000000..09a816fa49adb4ced016715629fed5b2b45d14e1 --- /dev/null +++ b/features/nepali_text_classifier/controller.py @@ -0,0 +1,130 @@ +import asyncio +from io import BytesIO +from fastapi import HTTPException, UploadFile, status, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import os +from features.nepali_text_classifier.inferencer import classify_text +from features.nepali_text_classifier.preprocess import * +import re + +security = HTTPBearer() + +def contains_english(text: str) -> bool: + # Remove escape characters + cleaned = text.replace("\n", "").replace("\t", "") + return bool(re.search(r'[a-zA-Z]', cleaned)) + + +async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + token = credentials.credentials + expected_token = os.getenv("MY_SECRET_TOKEN") + if token != expected_token: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid or expired token" + ) + return token + +async def nepali_text_analysis(text: str): + end_symbol_for_NP_text(text) + words = text.split() + if len(words) < 10: + raise HTTPException(status_code=400, detail="Text must contain at least 10 words") + if len(text) > 10000: + raise HTTPException(status_code=413, detail="Text must be less than 10,000 characters") + + result = await asyncio.to_thread(classify_text, text) + + return result + + +#Extract text form uploaded files(.docx,.pdf,.txt) +async def extract_file_contents(file:UploadFile)-> str: + content = await file.read() + file_stream = BytesIO(content) + if file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return parse_docx(file_stream) + elif file.content_type =="application/pdf": + return parse_pdf(file_stream) + elif file.content_type =="text/plain": + return parse_txt(file_stream) + else: + raise HTTPException(status_code=415,detail="Invalid file type. Only .docx,.pdf and .txt are allowed") + +async def handle_file_upload(file: UploadFile): + try: + file_contents = await extract_file_contents(file) + end_symbol_for_NP_text(file_contents) + if len(file_contents) > 10000: + raise HTTPException(status_code=413, detail="Text must be less than 10,000 characters") + + cleaned_text = file_contents.replace("\n", " ").replace("\t", " ").strip() + if not cleaned_text: + raise HTTPException(status_code=404, detail="The file is empty or only contains whitespace.") + + result = await asyncio.to_thread(classify_text, cleaned_text) + return result + except Exception as e: + logging.error(f"Error processing file: {e}") + raise HTTPException(status_code=500, detail="Error processing the file") + + + +async def handle_sentence_level_analysis(text: str): + text = text.strip() + if len(text) > 10000: + raise HTTPException(status_code=413, detail="Text must be less than 10,000 characters") + + end_symbol_for_NP_text(text) + + # Split text into sentences + sentences = [s.strip() + "ΰ₯€" for s in text.split("ΰ₯€") if s.strip()] + + results = [] + for sentence in sentences: + end_symbol_for_NP_text(sentence) + result = await asyncio.to_thread(classify_text, sentence) + results.append({ + "text": sentence, + "result": result["label"], + "likelihood": result["confidence"] + }) + + return {"analysis": results} + + +async def handle_file_sentence(file:UploadFile): + try: + file_contents = await extract_file_contents(file) + if len(file_contents) > 10000: + raise HTTPException(status_code=413, detail="Text must be less than 10,000 characters") + + cleaned_text = file_contents.replace("\n", " ").replace("\t", " ").strip() + if not cleaned_text: + raise HTTPException(status_code=404, detail="The file is empty or only contains whitespace.") + # Ensure text ends with danda so last sentence is included + + # Split text into sentences + sentences = [s.strip() + "ΰ₯€" for s in cleaned_text.split("ΰ₯€") if s.strip()] + + results = [] + for sentence in sentences: + end_symbol_for_NP_text(sentence) + + result = await asyncio.to_thread(classify_text, sentence) + results.append({ + "text": sentence, + "result": result["label"], + "likelihood": result["confidence"] + }) + + return {"analysis": results} + + except Exception as e: + logging.error(f"Error processing file: {e}") + raise HTTPException(status_code=500, detail="Error processing the file") + + +def classify(text: str): + return classify_text(text) + diff --git a/features/nepali_text_classifier/inferencer.py b/features/nepali_text_classifier/inferencer.py new file mode 100755 index 0000000000000000000000000000000000000000..5e56e6ba8a83cf66415fb059fad5f0e985bb3c61 --- /dev/null +++ b/features/nepali_text_classifier/inferencer.py @@ -0,0 +1,23 @@ +import torch +from .model_loader import get_model_tokenizer +import torch.nn.functional as F + +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +def classify_text(text: str): + model, tokenizer = get_model_tokenizer() + inputs = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512) + inputs = {k: v.to(device) for k, v in inputs.items()} + + with torch.no_grad(): + outputs = model(**inputs) + logits = outputs if isinstance(outputs, torch.Tensor) else outputs.logits + probs = F.softmax(logits, dim=1) + pred = torch.argmax(probs, dim=1).item() + prob_percent = probs[0][pred].item() * 100 + + return {"label": "Human" if pred == 0 else "AI", "confidence": round(prob_percent, 2)} + + + diff --git a/features/nepali_text_classifier/model_loader.py b/features/nepali_text_classifier/model_loader.py new file mode 100755 index 0000000000000000000000000000000000000000..2e2137a7c9d339e39619bb9760f72fb1caf652bc --- /dev/null +++ b/features/nepali_text_classifier/model_loader.py @@ -0,0 +1,54 @@ +import os +import shutil +import torch +import torch.nn as nn +import torch.nn.functional as F +import logging +from huggingface_hub import snapshot_download +from transformers import AutoTokenizer, AutoModel + +# Configs +REPO_ID = "can-org/Nepali-AI-VS-HUMAN" +BASE_DIR = "./np_text_model" +TOKENIZER_DIR = os.path.join(BASE_DIR, "classifier") # <- update this to match your uploaded folder +WEIGHTS_PATH = os.path.join(BASE_DIR, "model_95_acc.pth") # <- change to match actual uploaded weight +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +# Define model class +class XLMRClassifier(nn.Module): + def __init__(self): + super(XLMRClassifier, self).__init__() + self.bert = AutoModel.from_pretrained("xlm-roberta-base") + self.classifier = nn.Linear(self.bert.config.hidden_size, 2) + + def forward(self, input_ids, attention_mask): + outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask) + cls_output = outputs.last_hidden_state[:, 0, :] + return self.classifier(cls_output) + +# Globals for caching +_model = None +_tokenizer = None + +def download_model_repo(): + if os.path.exists(BASE_DIR) and os.path.isdir(BASE_DIR): + logging.info("Model already downloaded.") + return + snapshot_path = snapshot_download(repo_id=REPO_ID) + os.makedirs(BASE_DIR, exist_ok=True) + shutil.copytree(snapshot_path, BASE_DIR, dirs_exist_ok=True) + +def load_model(): + download_model_repo() + tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_DIR) + model = XLMRClassifier().to(device) + model.load_state_dict(torch.load(WEIGHTS_PATH, map_location=device)) + model.eval() + return model, tokenizer + +def get_model_tokenizer(): + global _model, _tokenizer + if _model is None or _tokenizer is None: + _model, _tokenizer = load_model() + return _model, _tokenizer + diff --git a/features/nepali_text_classifier/preprocess.py b/features/nepali_text_classifier/preprocess.py new file mode 100755 index 0000000000000000000000000000000000000000..038c05082e1e814d8133a70981656c2f46d02f37 --- /dev/null +++ b/features/nepali_text_classifier/preprocess.py @@ -0,0 +1,35 @@ +# import fitz # PyMuPDF +import docx +from io import BytesIO +import logging +from fastapi import HTTPException +from pypdf import PdfReader + +def parse_docx(file: BytesIO): + doc = docx.Document(file) + text = "" + for para in doc.paragraphs: + text += para.text + "\n" + return text + + +def parse_pdf(file: BytesIO): + try: + doc = PdfReader(file) + text = "" + for page in doc.pages: + text += page.extract_text() + return text + except Exception as e: + logging.error(f"Error while processing PDF: {str(e)}") + raise HTTPException( + status_code=500, detail="Error processing PDF file") + +def parse_txt(file: BytesIO): + return file.read().decode("utf-8") + +def end_symbol_for_NP_text(text: str) -> str: + text = text.strip() + if not text.endswith("ΰ₯€"): + text += "ΰ₯€" + return text diff --git a/features/nepali_text_classifier/routes.py b/features/nepali_text_classifier/routes.py new file mode 100755 index 0000000000000000000000000000000000000000..0931d50d3bfa9f19bfb6e37e0564521b682a6996 --- /dev/null +++ b/features/nepali_text_classifier/routes.py @@ -0,0 +1,45 @@ +from slowapi import Limiter +from config import ACCESS_RATE +from .controller import handle_file_sentence, handle_sentence_level_analysis, nepali_text_analysis +from .inferencer import classify_text +from fastapi import APIRouter, File, Request, Depends, HTTPException, UploadFile +from fastapi.security import HTTPBearer +from slowapi import Limiter +from slowapi.util import get_remote_address +from pydantic import BaseModel +from .controller import handle_file_upload +router = APIRouter() +limiter = Limiter(key_func=get_remote_address) +security = HTTPBearer() + +# Input schema +class TextInput(BaseModel): + text: str + +@router.post("/analyse") +@limiter.limit(ACCESS_RATE) +async def analyse(request: Request, data: TextInput, token: str = Depends(security)): + result = classify_text(data.text) + return result + +@router.post("/upload") +@limiter.limit(ACCESS_RATE) +async def upload_file(request:Request,file:UploadFile=File(...),token:str=Depends(security)): + return await handle_file_upload(file) + +@router.post("/analyse-sentences") +@limiter.limit(ACCESS_RATE) +async def upload_file(request:Request,data:TextInput,token:str=Depends(security)): + return await handle_sentence_level_analysis(data.text) + +@router.post("/file-sentences-analyse") +@limiter.limit(ACCESS_RATE) +async def analyze_sentance_file(request: Request, file: UploadFile = File(...), token: str = Depends(security)): + return await handle_file_sentence(file) + + +@router.get("/health") +@limiter.limit(ACCESS_RATE) +def health(request: Request): + return {"status": "ok"} + diff --git a/features/rag_chatbot/__init__.py b/features/rag_chatbot/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/features/rag_chatbot/controller.py b/features/rag_chatbot/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..f25ca2f4e90bf2b7e33516754db9f43927817de2 --- /dev/null +++ b/features/rag_chatbot/controller.py @@ -0,0 +1,182 @@ +import os +import asyncio +import logging +from io import BytesIO +from typing import Dict, Any + +from fastapi import HTTPException, UploadFile, status, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from .rag_pipeline import route_and_process_query, add_document_to_rag, check_system_health +from .document_handler import extract_text_from_file + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +security = HTTPBearer() + +# Supported file types +SUPPORTED_CONTENT_TYPES = { + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain" +} + +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB + +async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Verify Bearer token from Authorization header.""" + token = credentials.credentials + expected_token = os.getenv("MY_SECRET_TOKEN") + + if not expected_token: + logger.error("MY_SECRET_TOKEN not configured") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Server configuration error" + ) + + if token != expected_token: + logger.warning(f"Invalid token attempt: {token[:10]}...") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid or expired token" + ) + return token + +async def handle_rag_query(query: str) -> Dict[str, Any]: + """Handle an incoming query by routing it and getting the appropriate answer.""" + + # Input validation + if not query or not query.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Query cannot be empty" + ) + + if len(query) > 1000: # Reasonable limit + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Query too long. Please limit to 1000 characters." + ) + + try: + logger.info(f"Processing query: {query[:50]}...") + + # Process query in thread pool + response = await asyncio.to_thread(route_and_process_query, query) + + logger.info(f"Query processed successfully. Route: {response.get('route', 'Unknown')}") + return response + + except Exception as e: + logger.error(f"Error processing query: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error processing your query. Please try again." + ) + +async def handle_document_upload(file: UploadFile) -> Dict[str, str]: + """Handle uploading a document to the RAG's vector store.""" + + # File validation + if not file.filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No file provided" + ) + + if file.content_type not in SUPPORTED_CONTENT_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=f"Unsupported file type: {file.content_type}. " + f"Supported types: {', '.join(SUPPORTED_CONTENT_TYPES)}" + ) + + # Check file size + contents = await file.read() + if len(contents) > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"File too large. Maximum size: {MAX_FILE_SIZE / (1024*1024):.1f}MB" + ) + + # Reset file pointer + await file.seek(0) + + try: + logger.info(f"Processing file upload: {file.filename}") + + # Extract text from file + text = await extract_text_from_file(file) + + if not text or not text.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The file appears to be empty or could not be read." + ) + + if len(text) < 50: # Too short to be meaningful + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The extracted text is too short to be meaningful." + ) + + # Add to RAG system + success = await asyncio.to_thread( + add_document_to_rag, + text, + { + "source": file.filename, + "content_type": file.content_type, + "size": len(contents) + } + ) + + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to add document to the knowledge base" + ) + + logger.info(f"Successfully processed file: {file.filename}") + + return { + "message": f"Successfully uploaded and processed '{file.filename}'. " + f"It is now available for querying.", + "filename": file.filename, + "text_length": len(text), + "content_type": file.content_type + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing file {file.filename}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error processing the file. Please try again." + ) + +async def handle_health_check() -> Dict[str, Any]: + """Handle health check requests.""" + try: + health_status = await asyncio.to_thread(check_system_health) + + if health_status["status"] == "unhealthy": + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Service is currently unhealthy" + ) + + return health_status + + except HTTPException: + raise + except Exception as e: + logger.error(f"Health check failed: {e}") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Health check failed" + ) \ No newline at end of file diff --git a/features/rag_chatbot/document_handler.py b/features/rag_chatbot/document_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..ada33c4629c3ff99361298b928dc146a6a7687b1 --- /dev/null +++ b/features/rag_chatbot/document_handler.py @@ -0,0 +1,37 @@ +from io import BytesIO +from fastapi import UploadFile, HTTPException +import PyPDF2 +import docx + +async def extract_text_from_file(file: UploadFile) -> str: + """Extracts text from various file types.""" + content = await file.read() + file_stream = BytesIO(content) + + if file.content_type == "application/pdf": + return extract_text_from_pdf(file_stream) + elif file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return extract_text_from_docx(file_stream) + elif file.content_type == "text/plain": + return file_stream.read().decode("utf-8") + else: + raise HTTPException( + status_code=415, + detail="Unsupported file type. Please upload a .pdf, .docx, or .txt file." + ) + +def extract_text_from_pdf(file_stream: BytesIO) -> str: + """Extracts text from a PDF file.""" + reader = PyPDF2.PdfReader(file_stream) + text = "" + for page in reader.pages: + text += page.extract_text() or "" + return text + +def extract_text_from_docx(file_stream: BytesIO) -> str: + """Extracts text from a DOCX file.""" + doc = docx.Document(file_stream) + text = "" + for para in doc.paragraphs: + text += para.text + "\n" + return text diff --git a/features/rag_chatbot/rag_pipeline.py b/features/rag_chatbot/rag_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..fefc4a56649db26cab62bf8fa2b5ef397543029e --- /dev/null +++ b/features/rag_chatbot/rag_pipeline.py @@ -0,0 +1,327 @@ +import os +import chromadb +from dotenv import load_dotenv +from langchain_core.documents import Document +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_community.embeddings import HuggingFaceEmbeddings +from langchain_community.llms import OpenAI +from langchain.chains.question_answering import load_qa_chain +from langchain_community.vectorstores import Chroma +from langchain.chains import LLMChain +from langchain.prompts import PromptTemplate +from langchain.chat_models import ChatOpenAI + + +load_dotenv() + +# ChromaDB configuration +CHROMA_HOST = os.getenv("CHROMA_HOST", "localhost") # change in env in production when hosted +COLLECTION_NAME = "company_docs_collection" + +# LLM Provider Configuration +LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").lower() +LLM_API_KEY = os.getenv("LLM_API_KEY") +LLM_MODEL = os.getenv("LLM_MODEL", "gpt-3.5-turbo") +LLM_TEMPERATURE = float(os.getenv("LLM_TEMPERATURE", "0")) +LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "2048")) + +# Provider-specific configurations +PROVIDER_CONFIGS = { + "openai": { + "api_base": "https://api.openai.com/v1", + "default_model": "gpt-3.5-turbo" + }, + "groq": { + "api_base": "https://api.groq.com/openai/v1", + "default_model": "llama-3.3-70b-versatile" + }, + "openrouter": { + "api_base": "https://openrouter.ai/api/v1", + "default_model": "mistralai/mistral-small-3.2-24b-instruct:free" + } +} + +vector_store = None +company_qa_chain = None +query_router_chain = None +cybersecurity_chain = None +llm = None + +def get_llm_config(): + """Get the appropriate LLM configuration based on the provider.""" + if LLM_PROVIDER not in PROVIDER_CONFIGS: + raise ValueError(f"Unsupported LLM provider: {LLM_PROVIDER}. Supported: {list(PROVIDER_CONFIGS.keys())}") + + config = PROVIDER_CONFIGS[LLM_PROVIDER].copy() + + # Use provided model or fall back to default + model = LLM_MODEL if LLM_MODEL != "gpt-3.5-turbo" else config["default_model"] + + return { + "model": model, + "openai_api_key": LLM_API_KEY, + "openai_api_base": config["api_base"], + "temperature": LLM_TEMPERATURE, + "max_tokens": LLM_MAX_TOKENS, + } + +def initialize_llm(): + """Initialize the LLM based on the configured provider.""" + if not LLM_API_KEY: + raise ValueError(f"LLM_API_KEY environment variable is required for {LLM_PROVIDER}") + + config = get_llm_config() + + print(f"Initializing {LLM_PROVIDER.upper()} with model: {config['model']}") + + return ChatOpenAI(**config) + +def initialize_pipelines(): + """Initializes all required models, chains, and the vector store.""" + global vector_store, company_qa_chain, query_router_chain, cybersecurity_chain, llm + + try: + # Initialize LLM + llm = initialize_llm() + + # Initialize embeddings + embeddings = HuggingFaceEmbeddings( + model_name="all-MiniLM-L6-v2", + model_kwargs={'device': 'cpu'}, + encode_kwargs={'normalize_embeddings': True} + ) + + # Initialize ChromaDB client + try: + chroma_client = chromadb.HttpClient(host=CHROMA_HOST, port=8000) + chroma_client.heartbeat() + except Exception as e: + raise ConnectionError("Failed to connect to ChromaDB.") from e + + # Initialize vector store + vector_store = Chroma( + client=chroma_client, + collection_name=COLLECTION_NAME, + embedding_function=embeddings, + ) + + # Query Router Chain + router_template = """You are a query classifier. Classify the following query into one of these categories: +- COMPANY: Questions about our company, its products, services, or general information +- CYBERSECURITY: Questions about cybersecurity, security threats, best practices, or vulnerabilities +- OFF_TOPIC: Questions that don't fit the above categories + +Query: {query} + +Respond with only the category name (COMPANY, CYBERSECURITY, or OFF_TOPIC):""" + + router_prompt = PromptTemplate( + input_variables=["query"], + template=router_template + ) + + query_router_chain = LLMChain( + llm=llm, + prompt=router_prompt + ) + + # Custom Company QA Chain + company_qa_template = """You are a helpful assistant for CyberAlertNepal. Answer the following question about our company using the information provided and links if only available. Give a natural, direct and polite response. + +Question: {question} + +Information: +{context} + +Answer:""" + + company_qa_prompt = PromptTemplate( + input_variables=["question", "context"], + template=company_qa_template + ) + + company_qa_chain = LLMChain( + llm=llm, + prompt=company_qa_prompt + ) + + # Cybersecurity Chain + cybersecurity_template = """You are a cybersecurity professional. Answer the following question truthfully and concisely. +If you are not 100% sure about the answer, simply respond with: "I am not sure about the answer." +Do not add extra explanations or assumptions. Do not provide false or speculative information. + +Question: {question} + +Provide a comprehensive and accurate answer about cybersecurity:""" + + cybersecurity_prompt = PromptTemplate( + input_variables=["question"], + template=cybersecurity_template + ) + + cybersecurity_chain = LLMChain( + llm=llm, + prompt=cybersecurity_prompt + ) + + print(f"Successfully initialized pipelines with {LLM_PROVIDER.upper()}") + + except Exception as e: + print(f"Error initializing pipelines: {e}") + raise + +def add_document_to_rag(text: str, metadata: dict): + """Splits a document and adds it to the ChromaDB index.""" + global vector_store + + if not vector_store: + initialize_pipelines() + + try: + text_splitter = RecursiveCharacterTextSplitter( + chunk_size=1000, + chunk_overlap=200 + ) + docs = text_splitter.create_documents([text], metadatas=[metadata]) + + if not docs: + print("Document was empty after splitting, not adding to ChromaDB.") + return False + + vector_store.add_documents(docs) + print("Successfully added documents.") + return True + + except Exception as e: + print(f"Error adding document to RAG: {e}") + return False + +def route_and_process_query(query: str): + """Routes the query and processes it using the appropriate pipeline.""" + global query_router_chain, vector_store, company_qa_chain, cybersecurity_chain + + if not all([query_router_chain, vector_store, company_qa_chain, cybersecurity_chain]): + initialize_pipelines() + + try: + # 1. Classify the query + route_result = query_router_chain.run(query) + route = route_result.strip().upper() + + + # 2. Route to appropriate logic + if "CYBERSECURITY" in route: + answer = cybersecurity_chain.run(question=query) + return { + "answer": answer, + "source": "Cybersecurity Knowledge Base", + "route": "CYBERSECURITY", + "provider": LLM_PROVIDER.upper(), + "model": get_llm_config()["model"] + } + + elif "COMPANY" in route: + # Perform similarity search on ChromaDB + docs = vector_store.similarity_search(query, k=3) + + if not docs: + return { + "answer": "I could not find any relevant information to answer your question.", + "source": "Company Documents", + "route": "COMPANY", + "provider": LLM_PROVIDER.upper(), + "model": get_llm_config()["model"] + } + + # Combine document content for context + context = "\n\n".join([doc.page_content for doc in docs]) + + # Run the custom QA chain + answer = company_qa_chain.run(question=query, context=context) + sources = list(set([doc.metadata.get("source", "Unknown") for doc in docs])) + + return { + "answer": answer, + "source": "Company Documents", + "documents": sources, + "route": "COMPANY", + "provider": LLM_PROVIDER.upper(), + "model": get_llm_config()["model"] + } + + else: # OFF_TOPIC + return { + "answer": "I am a specialized assistant of CyberAlertNepal. I cannot answer questions outside of cybersecurity topics.", + "source": "N/A", + "route": "OFF_TOPIC", + "provider": LLM_PROVIDER.upper(), + "model": get_llm_config()["model"] + } + + except Exception as e: + print(f"Error processing query: {e}") + return { + "answer": "I encountered an error while processing your query. Please try again.", + "source": "Error", + "route": None, + "documents": None, + "provider": LLM_PROVIDER.upper(), + "error": str(e) + } + +def check_system_health(): + """Check if all components are properly initialized.""" + try: + # Test ChromaDB connection + if vector_store: + vector_store._client.heartbeat() + + # Test if all chains are initialized + components = { + "vector_store": vector_store is not None, + "company_qa_chain": company_qa_chain is not None, + "query_router_chain": query_router_chain is not None, + "cybersecurity_chain": cybersecurity_chain is not None, + "llm": llm is not None + } + + return { + "status": "healthy" if all(components.values()) else "unhealthy", + "components": components, + "provider": LLM_PROVIDER.upper(), + "model": get_llm_config()["model"] if llm else "Not initialized" + } + + except Exception as e: + return { + "status": "unhealthy", + "error": str(e), + "provider": LLM_PROVIDER.upper() + } + +def test_llm_connection(): + """Test the LLM API connection.""" + try: + if not llm: + initialize_pipelines() + + # Simple test query + test_response = llm("Say 'Hello, LLM is working!'") + return { + "success": True, + "provider": LLM_PROVIDER.upper(), + "model": get_llm_config()["model"], + "response": str(test_response) + } + except Exception as e: + return { + "success": False, + "provider": LLM_PROVIDER.upper(), + "error": str(e) + } + +# Initialize pipelines on module import +try: + initialize_pipelines() +except Exception as e: + print(f"Failed to initialize pipelines on startup: {e}") \ No newline at end of file diff --git a/features/rag_chatbot/routes.py b/features/rag_chatbot/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..11878a30c567302cd3d51ec363a26042bcdc6c33 --- /dev/null +++ b/features/rag_chatbot/routes.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request +from fastapi.security import HTTPBearer +from pydantic import BaseModel, Field +from slowapi.util import get_remote_address +from slowapi import Limiter +from typing import Optional +from config import ACCESS_RATE +from .controller import ( + handle_rag_query, + handle_document_upload, + handle_health_check, + verify_token, +) + +limiter = Limiter(key_func=get_remote_address) +router = APIRouter(prefix="/rag", tags=["RAG Chatbot"]) +security = HTTPBearer() + +class QueryInput(BaseModel): + query: str = Field(..., min_length=1, max_length=1000, description="The question to ask") + +class QueryResponse(BaseModel): + answer: str + source: str + route: Optional[str] = None + documents: Optional[list] = None + error: Optional[str] = None + +class UploadResponse(BaseModel): + message: str + filename: str + text_length: int + content_type: str + +class HealthResponse(BaseModel): + status: str + components: Optional[dict] = None + error: Optional[str] = None + +@router.post("/question", response_model=QueryResponse) +@limiter.limit(ACCESS_RATE) +async def ask_question( + request: Request, + data: QueryInput, + token: str = Depends(verify_token) +) -> QueryResponse: + """ + Ask a question to the RAG chatbot. + + The chatbot can answer: + - Company-related questions (based on uploaded documents) + - Cybersecurity questions (from knowledge base) + """ + response = await handle_rag_query(data.query) + return QueryResponse(**response) + +@router.post("/upload", response_model=UploadResponse) +@limiter.limit(ACCESS_RATE) +async def upload_document( + request: Request, + file: UploadFile = File(..., description="Document file (PDF, DOCX, or TXT)"), + token: str = Depends(verify_token) +) -> UploadResponse: + """ + Upload a document to the company knowledge base. + + Supported formats: + - PDF (.pdf) + - Word documents (.docx) + - Plain text (.txt) + + Maximum file size: 10MB + """ + response = await handle_document_upload(file) + return UploadResponse(**response) + +@router.get("/health", response_model=HealthResponse) +@limiter.limit(ACCESS_RATE) +async def health_check(request: Request) -> HealthResponse: + """ + Check the health status of the RAG system. + + Returns the status of all components: + - ChromaDB connection + - Vector store + - AI chains + """ + response = await handle_health_check() + return HealthResponse(**response) + +@router.get("/info") +@limiter.limit(ACCESS_RATE) +async def get_system_info(request: Request): + """Get information about the RAG system capabilities.""" + return { + "name": "RAG Chatbot", + "version": "1.0.0", + "description": "A specialized chatbot for cybersecurity and company-related questions", + "capabilities": [ + "Company document Q&A (based on uploaded documents)", + "Cybersecurity knowledge and best practices", + "Document upload and processing (PDF, DOCX, TXT)" + ], + "supported_file_types": [ + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/plain" + ], + "max_file_size_mb": 10, + "max_query_length": 1000 + } \ No newline at end of file diff --git a/features/real_forged_classifier/controller.py b/features/real_forged_classifier/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..630d4ff856aaee46760f15fb47c7b8357dbfc08b --- /dev/null +++ b/features/real_forged_classifier/controller.py @@ -0,0 +1,36 @@ +from typing import IO +from preprocessor import preprocessor +from inferencer import interferencer + +class ClassificationController: + """ + Controller to handle the image classification logic. + """ + def classify_image(self, image_file: IO) -> dict: + """ + Orchestrates the classification of a single image file. + + Args: + image_file (IO): The image file to classify. + + Returns: + dict: The classification result. + """ + try: + # Step 1: Preprocess the image + image_tensor = preprocessor.process(image_file) + + # Step 2: Perform inference + result = interferencer.predict(image_tensor) + + return result + except ValueError as e: + # Handle specific errors like invalid images + return {"error": str(e)} + except Exception as e: + # Handle unexpected errors + print(f"An unexpected error occurred: {e}") + return {"error": "An internal error occurred during classification."} + +# Create a single instance of the controller +controller = ClassificationController() diff --git a/features/real_forged_classifier/inferencer.py b/features/real_forged_classifier/inferencer.py new file mode 100644 index 0000000000000000000000000000000000000000..e31208b1211a7e9d6add6613ca06b75b7dcbfc8e --- /dev/null +++ b/features/real_forged_classifier/inferencer.py @@ -0,0 +1,52 @@ +import torch +import torch.nn.functional as F +import numpy as np + +# Import the globally loaded models instance +from model_loader import models + +class Interferencer: + """ + Performs inference using the FFT CNN model. + """ + def __init__(self): + """ + Initializes the interferencer with the loaded model. + """ + self.fft_model = models.fft_model + + @torch.no_grad() + def predict(self, image_tensor: torch.Tensor) -> dict: + """ + Takes a preprocessed image tensor and returns the classification result. + + Args: + image_tensor (torch.Tensor): The preprocessed image tensor. + + Returns: + dict: A dictionary containing the classification label and confidence score. + """ + # 1. Get model outputs (logits) + outputs = self.fft_model(image_tensor) + + # 2. Apply softmax to get probabilities + probabilities = F.softmax(outputs, dim=1) + + # 3. Get the confidence and the predicted class index + confidence, predicted_idx = torch.max(probabilities, 1) + + prediction = predicted_idx.item() + + # 4. Map the prediction to a human-readable label + # Ensure this mapping matches the labels used during training + # Typically: 0 -> fake, 1 -> real + label_map = {0: 'fake', 1: 'real'} + classification_label = label_map.get(prediction, "unknown") + + return { + "classification": classification_label, + "confidence": confidence.item() + } + +# Create a single instance of the interferencer +interferencer = Interferencer() diff --git a/features/real_forged_classifier/main.py b/features/real_forged_classifier/main.py new file mode 100644 index 0000000000000000000000000000000000000000..41d8e7673b2d3193a68db17457a495426ec1970d --- /dev/null +++ b/features/real_forged_classifier/main.py @@ -0,0 +1,26 @@ +from fastapi import FastAPI +from routes import router as api_router + +# Initialize the FastAPI app +app = FastAPI( + title="Real vs. Fake Image Classification API", + description="An API to classify images as real or forged using FFT and cnn.", + version="1.0.0" +) + +# Include the API router +# All routes defined in routes.py will be available under the /api prefix +app.include_router(api_router, prefix="/api", tags=["Classification"]) + +@app.get("/", tags=["Root"]) +async def read_root(): + """ + A simple root endpoint to confirm the API is running. + """ + return {"message": "Welcome to the Image Classification API. Go to /docs for the API documentation."} + +# To run this application: +# 1. Make sure you have all dependencies from requirements.txt installed. +# 2. Make sure the 'svm_model.joblib' file is in the same directory. +# 3. Run the following command in your terminal: +# uvicorn main:app --reload diff --git a/features/real_forged_classifier/model.py b/features/real_forged_classifier/model.py new file mode 100644 index 0000000000000000000000000000000000000000..6925b9d2d9cf1ac2f22448c0b5a109ce1208fb8d --- /dev/null +++ b/features/real_forged_classifier/model.py @@ -0,0 +1,34 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +class FFTCNN(nn.Module): + """ + Defines the Convolutional Neural Network architecture. + This structure must match the model that was trained and saved. + """ + def __init__(self): + super(FFTCNN, self).__init__() + # Ensure 'self.' is used here to define the layers as instance attributes + self.conv_layers = nn.Sequential( + nn.Conv2d(1, 16, kernel_size=3, padding=1), + nn.ReLU(), + nn.MaxPool2d(kernel_size=2, stride=2), + nn.Conv2d(16, 32, kernel_size=3, padding=1), + nn.ReLU(), + nn.MaxPool2d(kernel_size=2, stride=2) + ) + + # Ensure 'self.' is used here as well + self.fc_layers = nn.Sequential( + nn.Linear(32 * 56 * 56, 128), # This size depends on your 224x224 input + nn.ReLU(), + nn.Linear(128, 2) # 2 output classes + ) + + def forward(self, x): + # Now, 'self.conv_layers' can be found because it was defined correctly + x = self.conv_layers(x) + x = x.view(x.size(0), -1) # Flatten the feature maps + x = self.fc_layers(x) + return x \ No newline at end of file diff --git a/features/real_forged_classifier/model_loader.py b/features/real_forged_classifier/model_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..6cf3fb65dd082ed694f8002212ec9213b4dc974e --- /dev/null +++ b/features/real_forged_classifier/model_loader.py @@ -0,0 +1,60 @@ +import torch +from pathlib import Path +from huggingface_hub import hf_hub_download +from model import FFTCNN # Import the model architecture + +class ModelLoader: + """ + A class to load and hold the PyTorch CNN model. + """ + def __init__(self, model_repo_id: str, model_filename: str): + """ + Initializes the ModelLoader and loads the model. + + Args: + model_repo_id (str): The repository ID on Hugging Face. + model_filename (str): The name of the model file (.pth) in the repository. + """ + self.device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"Using device: {self.device}") + + self.fft_model = self._load_fft_model(repo_id=model_repo_id, filename=model_filename) + print("FFT CNN model loaded successfully.") + + def _load_fft_model(self, repo_id: str, filename: str): + """ + Downloads and loads the FFT CNN model from a Hugging Face Hub repository. + + Args: + repo_id (str): The repository ID on Hugging Face. + filename (str): The name of the model file (.pth) in the repository. + + Returns: + The loaded PyTorch model object. + """ + print(f"Downloading FFT CNN model from Hugging Face repo: {repo_id}") + try: + # Download the model file from the Hub. It returns the cached path. + model_path = hf_hub_download(repo_id=repo_id, filename=filename) + print(f"Model downloaded to: {model_path}") + + # Initialize the model architecture + model = FFTCNN() + + # Load the saved weights (state_dict) into the model + model.load_state_dict(torch.load(model_path, map_location=torch.device(self.device))) + + # Set the model to evaluation mode + model.to(self.device) + model.eval() + + return model + except Exception as e: + print(f"Error downloading or loading model from Hugging Face: {e}") + raise + +# --- Global Model Instance --- +MODEL_REPO_ID = 'rhnsa/real_forged_classifier' +MODEL_FILENAME = 'fft_cnn_model_78.pth' +models = ModelLoader(model_repo_id=MODEL_REPO_ID, model_filename=MODEL_FILENAME) + diff --git a/features/real_forged_classifier/preprocessor.py b/features/real_forged_classifier/preprocessor.py new file mode 100644 index 0000000000000000000000000000000000000000..5ef09f3811d17abb8d23c7d06f56ce7a6d747fcf --- /dev/null +++ b/features/real_forged_classifier/preprocessor.py @@ -0,0 +1,67 @@ +from PIL import Image +import torch +import numpy as np +from typing import IO +import cv2 +from torchvision import transforms + +# Import the globally loaded models instance +from model_loader import models + +class ImagePreprocessor: + """ + Handles preprocessing of images for the FFT CNN model. + """ + def __init__(self): + """ + Initializes the preprocessor. + """ + self.device = models.device + # Define the image transformations, matching the training process + self.transform = transforms.Compose([ + transforms.ToPILImage(), + transforms.Resize((224, 224)), + transforms.ToTensor(), + ]) + + def process(self, image_file: IO) -> torch.Tensor: + """ + Opens an image file, applies FFT, preprocesses it, and returns a tensor. + + Args: + image_file (IO): The image file object (e.g., from a file upload). + + Returns: + torch.Tensor: The preprocessed image as a tensor, ready for the model. + """ + try: + # Read the image file into a numpy array + image_np = np.frombuffer(image_file.read(), np.uint8) + # Decode the image as grayscale + img = cv2.imdecode(image_np, cv2.IMREAD_GRAYSCALE) + except Exception as e: + print(f"Error reading or decoding image: {e}") + raise ValueError("Invalid or corrupted image file.") + + if img is None: + raise ValueError("Could not decode image. File may be empty or corrupted.") + + # 1. Apply Fast Fourier Transform (FFT) + f = np.fft.fft2(img) + fshift = np.fft.fftshift(f) + magnitude_spectrum = 20 * np.log(np.abs(fshift) + 1) # Add 1 to avoid log(0) + + # Normalize the magnitude spectrum to be in the range [0, 255] + magnitude_spectrum = cv2.normalize(magnitude_spectrum, None, 0, 255, cv2.NORM_MINMAX) + magnitude_spectrum = np.uint8(magnitude_spectrum) + + # 2. Apply torchvision transforms + image_tensor = self.transform(magnitude_spectrum) + + # Add a batch dimension and move to the correct device + image_tensor = image_tensor.unsqueeze(0).to(self.device) + + return image_tensor + +# Create a single instance of the preprocessor +preprocessor = ImagePreprocessor() diff --git a/features/real_forged_classifier/routes.py b/features/real_forged_classifier/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..ebe42dff889a1586e3921bc2bd811aadf44772b4 --- /dev/null +++ b/features/real_forged_classifier/routes.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, File, UploadFile, HTTPException, status +from fastapi.responses import JSONResponse + +# Import the controller instance +from controller import controller + +# Create an API router +router = APIRouter() + +@router.post("/classify_forgery", summary="Classify an image as Real or Fake") +async def classify_image_endpoint(image: UploadFile = File(...)): + """ + Accepts an image file and classifies it as 'real' or 'fake'. + + - **image**: The image file to be classified (e.g., JPEG, PNG). + + Returns a JSON object with the classification and a confidence score. + """ + # Check for a valid image content type + if not image.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Unsupported file type. Please upload an image (e.g., JPEG, PNG)." + ) + + # The controller expects a file-like object, which `image.file` provides + result = controller.classify_image(image.file) + + if "error" in result: + # If the controller returned an error, forward it as an HTTP exception + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=result["error"] + ) + + return JSONResponse(content=result, status_code=status.HTTP_200_OK) + diff --git a/features/text_classifier/__init__.py b/features/text_classifier/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/features/text_classifier/__init__.py @@ -0,0 +1 @@ + diff --git a/features/text_classifier/controller.py b/features/text_classifier/controller.py new file mode 100755 index 0000000000000000000000000000000000000000..0ec7c4d418ec221e50cb6c422998b9443deeb661 --- /dev/null +++ b/features/text_classifier/controller.py @@ -0,0 +1,130 @@ +import os +import asyncio +import logging +from io import BytesIO + +from fastapi import HTTPException, UploadFile, status, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +from .inferencer import classify_text +from .preprocess import parse_docx, parse_pdf, parse_txt +import spacy +security = HTTPBearer() +nlp = spacy.load("en_core_web_sm") + +# Verify Bearer token from Authorization header +async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): + token = credentials.credentials + expected_token = os.getenv("MY_SECRET_TOKEN") + if token != expected_token: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid or expired token" + ) + return token + +# Classify plain text input +async def handle_text_analysis(text: str): + text = text.strip() + if not text or len(text.split()) < 10: + raise HTTPException(status_code=400, detail="Text must contain at least 10 words") + if len(text) > 10000: + raise HTTPException(status_code=413, detail="Text must be less than 10,000 characters") + + label, perplexity, ai_likelihood = await asyncio.to_thread(classify_text, text) + return { + "result": label, + "perplexity": round(perplexity, 2), + "ai_likelihood": ai_likelihood + } + +# Extract text from uploaded files (.docx, .pdf, .txt) +async def extract_file_contents(file: UploadFile) -> str: + content = await file.read() + file_stream = BytesIO(content) + + if file.content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return parse_docx(file_stream) + elif file.content_type == "application/pdf": + return parse_pdf(file_stream) + elif file.content_type == "text/plain": + return parse_txt(file_stream) + else: + raise HTTPException( + status_code=415, + detail="Invalid file type. Only .docx, .pdf and .txt are allowed." + ) + +# Classify text from uploaded file +async def handle_file_upload(file: UploadFile): + try: + file_contents = await extract_file_contents(file) + if len(file_contents) > 10000: + return {"status_code": 413, "detail": "Text must be less than 10,000 characters"} + + cleaned_text = file_contents.replace("\n", " ").replace("\t", " ").strip() + if not cleaned_text: + raise HTTPException(status_code=404, detail="The file is empty or only contains whitespace.") + # print(f"Cleaned text: '{cleaned_text}'") # Debugging statement + label, perplexity, ai_likelihood = await asyncio.to_thread(classify_text, cleaned_text) + return { + "content": file_contents, + "result": label, + "perplexity": round(perplexity, 2), + "ai_likelihood": ai_likelihood + } + except Exception as e: + logging.error(f"Error processing file: {e}") + raise HTTPException(status_code=500, detail="Error processing the file") + + + +async def handle_sentence_level_analysis(text: str): + text = text.strip() + if not text.endswith("."): + text += "." + + if len(text) > 10000: + raise HTTPException(status_code=413, detail="Text must be less than 10,000 characters") + + doc = nlp(text) + sentences = [sent.text.strip() for sent in doc.sents] + + results = [] + for sentence in sentences: + if not sentence: + continue + label, perplexity, ai_likelihood = await asyncio.to_thread(classify_text, sentence) + results.append({ + "sentence": sentence, + "label": label, + "perplexity": round(perplexity, 2), + "ai_likelihood": ai_likelihood + }) + + return {"analysis": results} + +# Analyze each sentence from uploaded file +async def handle_file_sentence(file: UploadFile): + try: + file_contents = await extract_file_contents(file) + if len(file_contents) > 10000: + # raise HTTPException(status_code=413, detail="Text must be less than 10,000 characters") + return {"status_code": 413, "detail": "Text must be less than 10,000 characters"} + + cleaned_text = file_contents.replace("\n", " ").replace("\t", " ").strip() + if not cleaned_text: + raise HTTPException(status_code=404, detail="The file is empty or only contains whitespace.") + + result = await handle_sentence_level_analysis(cleaned_text) + return { + "content": file_contents, + **result + } + except Exception as e: + logging.error(f"Error processing file: {e}") + raise HTTPException(status_code=500, detail="Error processing the file") + +def classify(text: str): + return classify_text(text) + diff --git a/features/text_classifier/inferencer.py b/features/text_classifier/inferencer.py new file mode 100755 index 0000000000000000000000000000000000000000..5716ee65f306ce5a3259908a47e855a4b188d04c --- /dev/null +++ b/features/text_classifier/inferencer.py @@ -0,0 +1,40 @@ +import torch +from .model_loader import get_model_tokenizer + +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +def perplexity_to_ai_likelihood(ppl: float) -> float: + # You can tune these parameters + min_ppl = 10 # very confident it's AI + max_ppl = 100 # very confident it's human + + # Clamp to bounds + ppl = max(min_ppl, min(ppl, max_ppl)) + + # Invert and scale: lower perplexity -> higher AI-likelihood + likelihood = 1 - ((ppl - min_ppl) / (max_ppl - min_ppl)) + + return round(likelihood * 100, 2) + + +def classify_text(text: str): + model, tokenizer = get_model_tokenizer() + inputs = tokenizer(text, return_tensors="pt", + truncation=True, padding=True) + input_ids = inputs["input_ids"].to(device) + attention_mask = inputs["attention_mask"].to(device) + + with torch.no_grad(): + outputs = model( + input_ids, attention_mask=attention_mask, labels=input_ids) + loss = outputs.loss + perplexity = torch.exp(loss).item() + + if perplexity < 55: + result = "AI-generated" + elif perplexity < 80: + result = "Probably AI-generated" + else: + result = "Human-written" + likelihood_result=perplexity_to_ai_likelihood(perplexity) + return result, perplexity,likelihood_result diff --git a/features/text_classifier/model_loader.py b/features/text_classifier/model_loader.py new file mode 100755 index 0000000000000000000000000000000000000000..890d1ea972816c8e69f44a619ef08e225fe4e09c --- /dev/null +++ b/features/text_classifier/model_loader.py @@ -0,0 +1,50 @@ +import os +import shutil +import logging +from transformers import GPT2LMHeadModel, GPT2TokenizerFast, GPT2Config +from huggingface_hub import snapshot_download +import torch +from dotenv import load_dotenv +load_dotenv() +REPO_ID = "can-org/AI-Content-Checker" +MODEL_DIR = "./models" +TOKENIZER_DIR = os.path.join(MODEL_DIR, "model") +WEIGHTS_PATH = os.path.join(MODEL_DIR, "model_weights.pth") + +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") +_model, _tokenizer = None, None + + +def warmup(): + global _model, _tokenizer + # Ensure punkt is available + download_model_repo() + _model, _tokenizer = load_model() + logging.info("Its ready") + + +def download_model_repo(): + if os.path.exists(MODEL_DIR) and os.path.isdir(MODEL_DIR): + logging.info("Model already exists, skipping download.") + return + snapshot_path = snapshot_download(repo_id=REPO_ID) + os.makedirs(MODEL_DIR, exist_ok=True) + shutil.copytree(snapshot_path, MODEL_DIR, dirs_exist_ok=True) + + +def load_model(): + tokenizer = GPT2TokenizerFast.from_pretrained(TOKENIZER_DIR) + config = GPT2Config.from_pretrained(TOKENIZER_DIR) + model = GPT2LMHeadModel(config) + model.load_state_dict(torch.load(WEIGHTS_PATH, map_location=device)) + model.to(device) + model.eval() + return model, tokenizer + + +def get_model_tokenizer(): + global _model, _tokenizer + if _model is None or _tokenizer is None: + download_model_repo() + _model, _tokenizer = load_model() + return _model, _tokenizer diff --git a/features/text_classifier/preprocess.py b/features/text_classifier/preprocess.py new file mode 100755 index 0000000000000000000000000000000000000000..8338287b01c013b72190de415cc45d580fe12654 --- /dev/null +++ b/features/text_classifier/preprocess.py @@ -0,0 +1,30 @@ +from pypdf import PdfReader +import docx +from io import BytesIO +import logging +from fastapi import HTTPException + + +def parse_docx(file: BytesIO): + doc = docx.Document(file) + text = "" + for para in doc.paragraphs: + text += para.text + "\n" + return text + + +def parse_pdf(file: BytesIO): + try: + doc = PdfReader(file) + text = "" + for page in doc.pages: + text += page.extract_text() + return text + except Exception as e: + logging.error(f"Error while processing PDF: {str(e)}") + raise HTTPException( + status_code=500, detail="Error processing PDF file") + +def parse_txt(file: BytesIO): + return file.read().decode("utf-8") + diff --git a/features/text_classifier/routes.py b/features/text_classifier/routes.py new file mode 100755 index 0000000000000000000000000000000000000000..67b9d05d7924fb199e2b2673d3fb4fcc2459851d --- /dev/null +++ b/features/text_classifier/routes.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request +from fastapi.security import HTTPBearer +from pydantic import BaseModel +from slowapi.util import get_remote_address +from slowapi import Limiter +from slowapi.errors import RateLimitExceeded +from config import ACCESS_RATE +from .controller import ( + handle_text_analysis, + handle_file_upload, + handle_sentence_level_analysis, + handle_file_sentence, + verify_token +) + +limiter = Limiter(key_func=get_remote_address) +router = APIRouter() +security = HTTPBearer() + +class TextInput(BaseModel): + text: str + +@router.post("/analyse") +@limiter.limit(ACCESS_RATE) +async def analyze(request: Request, data: TextInput, token: str = Depends(verify_token)): + return await handle_text_analysis(data.text) + +@router.post("/upload") +@limiter.limit(ACCESS_RATE) +async def upload_file(request: Request, file: UploadFile = File(...), token: str = Depends(verify_token)): + return await handle_file_upload(file) + +@router.post("/analyse-sentences") +@limiter.limit(ACCESS_RATE) +async def analyze_sentences(request: Request, data: TextInput, token: str = Depends(verify_token)): + if not data.text: + raise HTTPException(status_code=400, detail="Missing 'text' in request body") + return await handle_sentence_level_analysis(data.text) + +@router.post("/analyse-sentance-file") +@limiter.limit(ACCESS_RATE) +async def analyze_sentance_file(request: Request, file: UploadFile = File(...), token: str = Depends(verify_token)): + return await handle_file_sentence(file) + +@router.get("/health") +@limiter.limit(ACCESS_RATE) +def health(request: Request): + return {"status": "ok"} + diff --git a/license.md b/license.md new file mode 100644 index 0000000000000000000000000000000000000000..c6eac5dbb11da919ae0622dfa5e37e0909f40180 --- /dev/null +++ b/license.md @@ -0,0 +1,20 @@ +# License - All Rights Reserved + +Copyright (c) 2025 CyberAlertNepal + +This software and all associated materials are **not open source** and are protected under a custom license. + +## Strict Usage Terms + +Unless explicit written permission is granted by **CyberAlertNepal**, **no individual or entity** is allowed to: + +- Use this codebase or its models in any capacity β€” personal, educational, or commercial. +- Modify, copy, distribute, or sublicense any part of this project. +- Deploy, mirror, or host this project, either publicly or privately. +- Incorporate any component of this project into derivative works or other applications. + +This project is intended for **private, internal use by the author(s) only**. + +Any unauthorized usage, reproduction, or distribution is strictly prohibited and may result in legal action. + +**All rights reserved.** diff --git a/requirements.txt b/requirements.txt new file mode 100755 index 0000000000000000000000000000000000000000..632284e4938a96f7f20837b04f5da6ee46230dab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +fastapi +uvicorn +torch +transformers +huggingface_hub +python-dotenv +python-docx +pydantic +PyMuPDF +python-multipart +slowapi +spacy +nltk +tensorflow +opencv-python +pillow +scipy +pypdf +frontend +tools diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/test.md b/test.md new file mode 100755 index 0000000000000000000000000000000000000000..7ec6a060472cf4da99c28d4de5d351f070a35002 --- /dev/null +++ b/test.md @@ -0,0 +1,31 @@ + +**Update: Edited & AI-Generated Content Detection – Project Plan** + +### πŸ” Phase 1: Rule-Based Image Detection (In Progress) + +We're implementing three core techniques to individually flag edited or AI-generated images: + +* **ELA (Error Level Analysis):** Highlights inconsistencies via JPEG recompression. +* **FFT (Frequency Analysis):** Uses 2D Fourier Transform to detect unnatural image frequency patterns. +* **Metadata Analysis:** Parses EXIF data to catch clues like editing software tags. + + These give us visual + interpretable results for each image, and currently offer \~60–70% accuracy on typical AI-edited content. + +--- + +### Phase 2: AI vs Human Detection System (Coming Soon) + +**Goal:** Build an AI model that classifies whether content is AI- or human-made β€” initially focusing on **images**, and later expanding to **text**. + +**Data Strategy:** + +* Scraping large volumes of recent AI-gen images (e.g. SDXL, Gibbli, MidJourney). +* Balancing with high-quality human images. + +**Model Plan:** + +* Use ELA, FFT, and metadata as feature extractors. +* Feed these into a CNN or ensemble model. +* Later, unify into a full web-based platform (upload β†’ get AI/human probability). + +