Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
12
ai_platform/modules/extractors/.env.example
Normal file
12
ai_platform/modules/extractors/.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Extractors service configuration (no silent fallback defaults).
|
||||
|
||||
# --- Service ---------------------------------------------------------------
|
||||
EXTRACTORS_HOST=0.0.0.0
|
||||
EXTRACTORS_PORT=54400
|
||||
EXTRACTORS_TAG=0.1.0
|
||||
EXTRACTORS_MAX_UPLOAD_MB=100
|
||||
|
||||
# --- LLM gateway (only required by sentiment/NER/OCR, added later) ----------
|
||||
# EXTRACTORS_LLM_GATEWAY_URL=http://didiAI-llm-gateway:14011
|
||||
# EXTRACTORS_LLM_MODEL=qwen3.5
|
||||
# EXTRACTORS_LLM_TIMEOUT_S=60
|
||||
133
ai_platform/modules/extractors/API.md
Normal file
133
ai_platform/modules/extractors/API.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Extractors API
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
{BASE_URL}
|
||||
```
|
||||
|
||||
- **Local development:** `http://localhost:54400`
|
||||
- **Docker (internal):** `http://extractors:54400`
|
||||
- **Production:** use your configured hostname (port `14400`)
|
||||
|
||||
## Authentication
|
||||
|
||||
None at the service level — access is mediated by the platform API gateway
|
||||
(Bearer token at the perimeter). Do not expose this service directly.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /health
|
||||
|
||||
Liveness probe.
|
||||
|
||||
```json
|
||||
{ "status": "ok", "version": "0.1.0" }
|
||||
```
|
||||
|
||||
### GET /ready
|
||||
|
||||
Readiness + optional tooling availability (informational; the service degrades
|
||||
gracefully if ffprobe/ffmpeg are absent).
|
||||
|
||||
```json
|
||||
{ "status": "ready", "ffprobe": true, "ffmpeg": true }
|
||||
```
|
||||
|
||||
### POST /v1/metadata
|
||||
|
||||
Extract deterministic media metadata & integrity features. The applicable
|
||||
extractors are chosen automatically from the detected media type.
|
||||
|
||||
**Request** — `multipart/form-data`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `file` | file | Image, video or audio file |
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:54400/v1/metadata \
|
||||
-F "file=@photo.jpg"
|
||||
```
|
||||
|
||||
**Response** `200 OK`
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "photo.jpg",
|
||||
"media_type": "image",
|
||||
"sha256": "9f86d081884c7d65...",
|
||||
"size_bytes": 84211,
|
||||
"analyses": {
|
||||
"integrity": {
|
||||
"tool_id": "integrity",
|
||||
"name": "Integrity & signature",
|
||||
"ok": true,
|
||||
"results": { "container": "JPEG", "sha256": "9f86...", "size_bytes": 84211 },
|
||||
"evidence": ["Container: JPEG.", "SHA-256: 9f86d081884c7d65…"],
|
||||
"anomalies": [],
|
||||
"confidence": 1.0
|
||||
},
|
||||
"exif": {
|
||||
"tool_id": "exif",
|
||||
"name": "EXIF metadata",
|
||||
"ok": true,
|
||||
"results": {
|
||||
"format": "JPEG", "dimensions": [4032, 3024],
|
||||
"make": "Apple", "model": "iPhone 13",
|
||||
"datetimeoriginal": "2024:06:15 14:30:00",
|
||||
"gps": { "latitude": 44.426, "longitude": 26.102 },
|
||||
"software": "Adobe Photoshop 25.0"
|
||||
},
|
||||
"evidence": ["Captured with Apple iPhone 13.", "GPS location present: 44.426, 26.102."],
|
||||
"anomalies": ["Editing/generation software detected in EXIF: Adobe Photoshop 25.0."],
|
||||
"confidence": 0.95
|
||||
},
|
||||
"ela": {
|
||||
"tool_id": "ela", "name": "Error Level Analysis", "ok": true,
|
||||
"results": { "quality": 90, "mean_error": 6.1, "max_error": 211.0, "p99_error": 38.0, "hot_pixel_fraction": 0.031 },
|
||||
"evidence": ["ELA mean error 6.1, p99 38.0 (quality=90)."],
|
||||
"anomalies": ["Localized high-error region(s) detected — possible splice/edit (3.1% of pixels)."],
|
||||
"confidence": 0.6
|
||||
}
|
||||
},
|
||||
"evidence": ["..."],
|
||||
"anomalies": ["..."],
|
||||
"execution_time_ms": 42.7
|
||||
}
|
||||
```
|
||||
|
||||
For **video** inputs the `analyses` block contains `integrity`, `video_metadata`
|
||||
(codec/width/height/fps/bitrate/encoder) and `spectrogram`. For **audio**:
|
||||
`integrity` + `spectrogram`.
|
||||
|
||||
Each extractor returns a uniform `FeatureResult`:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `tool_id` | string | Stable extractor id |
|
||||
| `name` | string | Human-readable name |
|
||||
| `ok` | bool | Ran without error |
|
||||
| `results` | object | Structured extracted data |
|
||||
| `evidence` | string[] | Neutral findings (LLM-readable) |
|
||||
| `anomalies` | string[] | Tampering/edit hints |
|
||||
| `confidence` | number\|null | 0..1 (extraction confidence, not a verdict) |
|
||||
| `error` | string\|null | Set when `ok` is false |
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| 400 | Empty file |
|
||||
| 413 | File exceeds `EXTRACTORS_MAX_UPLOAD_MB` |
|
||||
| 422 | Missing `file` field |
|
||||
|
||||
```json
|
||||
{ "detail": "empty file" }
|
||||
```
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Required | Notes |
|
||||
|--------|----------|-------|
|
||||
| `Content-Type` | yes | `multipart/form-data` (set by the client) |
|
||||
57
ai_platform/modules/extractors/README.md
Normal file
57
ai_platform/modules/extractors/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# extractors
|
||||
|
||||
Consolidated **lightweight feature-extraction** service for the DiDi AI platform
|
||||
(LOT 1, Modul 2 — Extractoare ML). Hosts the CPU-only / LLM-delegating feature
|
||||
extractors that do **not** warrant a dedicated GPU model server.
|
||||
|
||||
> Heavy model services stay separate: **deepfake/BusterX** → `video-analysis`,
|
||||
> **speech-to-text/Whisper** → `audio`. They require GPU at runtime and are not
|
||||
> folded in here.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Endpoint | Status | Type | Notes |
|
||||
|----------|--------|------|-------|
|
||||
| `POST /v1/metadata` | ✅ implemented | CPU, deterministic | EXIF, video metadata (ffprobe), spectrogram, ELA, integrity/signature |
|
||||
| `POST /v1/sentiment` | ✅ implemented | LLM-delegated | LLM gateway + Romanian-aware prompt (irony/sarcasm) |
|
||||
| `POST /v1/ocr` | ✅ implemented | LLM vision | verbatim text from images via the vision model |
|
||||
| `POST /v1/ner` | ✅ implemented | model (`.[ml]`) | GLiNER multilingual, configurable entity types, GPU-capable |
|
||||
| `POST /v1/detect` | ✅ implemented | model (`.[ml]`) | YOLO object detection (COCO), boxes+conf, GPU-capable |
|
||||
|
||||
The metadata extractors are selected automatically from the detected media type
|
||||
(image / video / audio).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- **ffmpeg/ffprobe** on PATH (provided by the container image) — required for
|
||||
video metadata and non-WAV audio decoding. Image extractors (EXIF/ELA/
|
||||
integrity) work without it.
|
||||
|
||||
## Run (local)
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run uvicorn extractors.app:app --port 54400
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
uv run pytest # local (needs deps)
|
||||
./deploy/deploy.sh test # or build the wheel in Docker
|
||||
```
|
||||
|
||||
In the container image, ffmpeg-dependent paths run for real; the unit tests are
|
||||
fixture-driven so they pass without the binaries.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set EXTRACTORS_TAG / PORT / MAX_UPLOAD_MB
|
||||
./deploy/deploy.sh up
|
||||
```
|
||||
|
||||
Ports follow the datacenter convention: `54400` (dev), `14400` (prod).
|
||||
|
||||
See `API.md` for the full request/response contract.
|
||||
43
ai_platform/modules/extractors/deploy/Dockerfile
Normal file
43
ai_platform/modules/extractors/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Multi-stage build for the extractors service. Non-root runtime, ffmpeg/ffprobe
|
||||
# available for video metadata and audio decoding.
|
||||
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY pyproject.toml ./
|
||||
COPY src ./src
|
||||
RUN pip install --no-cache-dir --upgrade pip build \
|
||||
&& pip wheel --no-cache-dir --wheel-dir /wheels ".[ml]"
|
||||
|
||||
FROM python:3.11-slim AS runtime
|
||||
|
||||
# ffmpeg provides ffmpeg/ffprobe; libgl1/libglib/libxcb are the OpenCV runtime
|
||||
# libs needed by ultralytics (YOLO).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libxcb1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --uid 10001 appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /wheels /wheels
|
||||
RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV EXTRACTORS_HOST=0.0.0.0 \
|
||||
EXTRACTORS_PORT=54400
|
||||
|
||||
EXPOSE 54400
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request,os,sys; \
|
||||
url=f'http://127.0.0.1:{os.getenv(\"EXTRACTORS_PORT\",\"54400\")}/health'; \
|
||||
sys.exit(0 if urllib.request.urlopen(url, timeout=3).status==200 else 1)"
|
||||
|
||||
CMD ["sh", "-c", "uvicorn extractors.app:app --host $EXTRACTORS_HOST --port $EXTRACTORS_PORT"]
|
||||
41
ai_platform/modules/extractors/deploy/deploy.sh
Normal file
41
ai_platform/modules/extractors/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env bash
|
||||
# Deploy the extractors service. Fail-fast on missing config (no silent defaults).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
[[ -f .env ]] && source .env
|
||||
|
||||
check_required_var() {
|
||||
if [[ -z "${!1:-}" ]]; then
|
||||
echo "ERROR: $1 not set (see .env.example)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 {up|down|logs|build|test}
|
||||
|
||||
Required env vars (set in .env):
|
||||
EXTRACTORS_TAG Image tag (e.g. 0.1.0)
|
||||
EXTRACTORS_PORT Host/container port (e.g. 54400 dev / 14400 prod)
|
||||
EXTRACTORS_MAX_UPLOAD_MB Max upload size in MB (e.g. 100)
|
||||
|
||||
Optional:
|
||||
EXTRACTORS_LLM_GATEWAY_URL LLM gateway base URL (sentiment/NER/OCR only)
|
||||
EXTRACTORS_LLM_MODEL Model alias (default: qwen3.5)
|
||||
EOF
|
||||
}
|
||||
|
||||
check_required_var "EXTRACTORS_TAG"
|
||||
check_required_var "EXTRACTORS_PORT"
|
||||
check_required_var "EXTRACTORS_MAX_UPLOAD_MB"
|
||||
|
||||
case "${1:-}" in
|
||||
up) docker compose up -d --build ;;
|
||||
down) docker compose down ;;
|
||||
logs) docker compose logs -f ;;
|
||||
build) docker compose build ;;
|
||||
test) docker build -f deploy/Dockerfile --target builder -t extractors-test .. ;;
|
||||
*) usage; exit 1 ;;
|
||||
esac
|
||||
29
ai_platform/modules/extractors/deploy/docker-compose.yml
Normal file
29
ai_platform/modules/extractors/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
services:
|
||||
extractors:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
image: extractors:${EXTRACTORS_TAG}
|
||||
container_name: extractors
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
EXTRACTORS_HOST: "0.0.0.0"
|
||||
EXTRACTORS_PORT: "${EXTRACTORS_PORT}"
|
||||
EXTRACTORS_MAX_UPLOAD_MB: "${EXTRACTORS_MAX_UPLOAD_MB}"
|
||||
# Optional, only needed by sentiment/NER/OCR (added later):
|
||||
EXTRACTORS_LLM_GATEWAY_URL: "${EXTRACTORS_LLM_GATEWAY_URL:-}"
|
||||
EXTRACTORS_LLM_MODEL: "${EXTRACTORS_LLM_MODEL:-qwen3.5}"
|
||||
ports:
|
||||
- "${EXTRACTORS_PORT}:${EXTRACTORS_PORT}"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,os,sys; sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{os.getenv(\"EXTRACTORS_PORT\")}/health',timeout=3).status==200 else 1)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- didi-network
|
||||
|
||||
networks:
|
||||
didi-network:
|
||||
external: true
|
||||
45
ai_platform/modules/extractors/pyproject.toml
Normal file
45
ai_platform/modules/extractors/pyproject.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[project]
|
||||
name = "extractors"
|
||||
version = "0.1.0"
|
||||
description = "Consolidated lightweight feature-extraction service (metadata, EXIF, ELA, spectrogram, integrity; LLM-delegated sentiment/NER/OCR; object detection)"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0,<0.116",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
"httpx>=0.27.0",
|
||||
"pillow>=11.0",
|
||||
"numpy>=2.0",
|
||||
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Heavy model backends (torch). Kept out of the base install so dev/CI stay
|
||||
# fast; the deployed image installs `.[ml]`. NER degrades to 503 without it.
|
||||
ml = [
|
||||
"gliner>=0.2.13",
|
||||
"ultralytics>=8.3.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=4.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/extractors"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../ruff.toml"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
11
ai_platform/modules/extractors/src/extractors/__init__.py
Normal file
11
ai_platform/modules/extractors/src/extractors/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Consolidated lightweight feature-extraction service.
|
||||
|
||||
Hosts CPU-only deterministic extractors (media metadata: EXIF, video metadata,
|
||||
spectrogram, ELA, integrity) plus — added incrementally — LLM-delegated text
|
||||
features (sentiment, NER, OCR) and object detection.
|
||||
|
||||
Heavy model services (deepfake/BusterX, Whisper) remain separate modules
|
||||
(`video-analysis`, `audio`); they require GPU at runtime and are not folded in.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
35
ai_platform/modules/extractors/src/extractors/app.py
Normal file
35
ai_platform/modules/extractors/src/extractors/app.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""FastAPI application factory for the extractors service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from prometheus_fastapi_instrumentator import Instrumentator
|
||||
|
||||
from extractors import __version__
|
||||
from extractors.routes import health, metadata, text, vision
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Build and configure the FastAPI app."""
|
||||
|
||||
app = FastAPI(
|
||||
title="Extractors",
|
||||
version=__version__,
|
||||
description=(
|
||||
"Consolidated lightweight feature-extraction service: media "
|
||||
"metadata (EXIF, video metadata, spectrogram, ELA, integrity); "
|
||||
"LLM-delegated sentiment/NER/OCR and object detection are added "
|
||||
"incrementally."
|
||||
),
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(metadata.router)
|
||||
app.include_router(text.router)
|
||||
app.include_router(vision.router)
|
||||
|
||||
Instrumentator().instrument(app).expose(app, include_in_schema=False)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
77
ai_platform/modules/extractors/src/extractors/contract.py
Normal file
77
ai_platform/modules/extractors/src/extractors/contract.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Uniform output contract for extractor features.
|
||||
|
||||
Mirrors the philosophy of `modules/forensic_features/docs/CONTRACT.md`: every
|
||||
extractor returns a stable, self-describing block with structured ``results``
|
||||
plus human/LLM-readable ``evidence`` and ``anomalies`` strings, so the backend
|
||||
consumes one consistent shape across all feature types.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FeatureResult(BaseModel):
|
||||
"""Result of a single extractor run over one input."""
|
||||
|
||||
tool_id: str = Field(..., description="Stable extractor id, e.g. 'exif'.")
|
||||
name: str = Field(..., description="Human-readable extractor name.")
|
||||
ok: bool = Field(..., description="True if the extractor ran without error.")
|
||||
results: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Structured extracted data."
|
||||
)
|
||||
evidence: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Short human/LLM-readable findings (neutral facts).",
|
||||
)
|
||||
anomalies: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Findings that suggest tampering/editing/inconsistency.",
|
||||
)
|
||||
confidence: float | None = Field(
|
||||
default=None, description="0..1 confidence in the extraction (not a verdict)."
|
||||
)
|
||||
error: str | None = Field(default=None, description="Error message if ok is False.")
|
||||
|
||||
|
||||
class MetadataResponse(BaseModel):
|
||||
"""Top-level response for POST /v1/metadata."""
|
||||
|
||||
filename: str
|
||||
media_type: str = Field(..., description="image | video | audio | unknown")
|
||||
sha256: str
|
||||
size_bytes: int
|
||||
analyses: dict[str, FeatureResult] = Field(default_factory=dict)
|
||||
evidence: list[str] = Field(default_factory=list)
|
||||
anomalies: list[str] = Field(default_factory=list)
|
||||
execution_time_ms: float = 0.0
|
||||
|
||||
|
||||
def ok_result(
|
||||
tool_id: str,
|
||||
name: str,
|
||||
results: dict[str, Any],
|
||||
*,
|
||||
evidence: list[str] | None = None,
|
||||
anomalies: list[str] | None = None,
|
||||
confidence: float | None = None,
|
||||
) -> FeatureResult:
|
||||
"""Build a successful :class:`FeatureResult`."""
|
||||
|
||||
return FeatureResult(
|
||||
tool_id=tool_id,
|
||||
name=name,
|
||||
ok=True,
|
||||
results=results,
|
||||
evidence=evidence or [],
|
||||
anomalies=anomalies or [],
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
|
||||
def err_result(tool_id: str, name: str, error: str) -> FeatureResult:
|
||||
"""Build a failed :class:`FeatureResult` (extractor unavailable/raised)."""
|
||||
|
||||
return FeatureResult(tool_id=tool_id, name=name, ok=False, error=error)
|
||||
11
ai_platform/modules/extractors/src/extractors/deps.py
Normal file
11
ai_platform/modules/extractors/src/extractors/deps.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""FastAPI dependencies (injectable, overridable in tests)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.llm_client import LLMClient
|
||||
|
||||
|
||||
def get_llm_client() -> LLMClient:
|
||||
"""Provide an LLM gateway client. Overridden in tests with a fake."""
|
||||
|
||||
return LLMClient()
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Feature extractors (CPU-only, deterministic)."""
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""Robust extraction of a JSON object from free-form LLM output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FENCE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
|
||||
|
||||
|
||||
def extract_json_object(text: str) -> dict[str, Any]:
|
||||
"""Best-effort parse of the first JSON object in ``text``.
|
||||
|
||||
Handles fenced ```json blocks and stray prose around the object.
|
||||
|
||||
Raises:
|
||||
ValueError: If no parseable JSON object is found.
|
||||
"""
|
||||
|
||||
text = (text or "").strip()
|
||||
candidates: list[str] = []
|
||||
|
||||
fenced = _FENCE.search(text)
|
||||
if fenced:
|
||||
candidates.append(fenced.group(1))
|
||||
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start != -1 and end > start:
|
||||
candidates.append(text[start : end + 1])
|
||||
|
||||
for cand in candidates:
|
||||
try:
|
||||
obj = json.loads(cand)
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
raise ValueError("no JSON object found in model output")
|
||||
114
ai_platform/modules/extractors/src/extractors/features/detect.py
Normal file
114
ai_platform/modules/extractors/src/extractors/features/detect.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Object detection via YOLO (ultralytics, torch backend, GPU-capable).
|
||||
|
||||
Generic object detection over the COCO label set. Runs on CPU (YOLOv8n is light)
|
||||
and uses CUDA automatically when a GPU is allocated. The model is loaded lazily;
|
||||
without the `ml` extra (ultralytics/torch) the endpoint reports unavailable.
|
||||
|
||||
Note: YOLO returns generic object classes (person, car, …). For disinformation
|
||||
analysis its main value is scene inventory + person/object presence; semantic
|
||||
verdicts come from the LLM/vision and forensic modules, not from here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from extractors.contract import FeatureResult, err_result, ok_result
|
||||
from extractors.settings import settings
|
||||
|
||||
TOOL_ID = "detect"
|
||||
NAME = "Object detection (YOLO)"
|
||||
|
||||
|
||||
class DetectModelUnavailable(RuntimeError):
|
||||
"""Raised when ultralytics / torch are not installed in this deployment."""
|
||||
|
||||
|
||||
_model: Any | None = None
|
||||
|
||||
|
||||
def _resolve_device() -> str | None:
|
||||
if settings.detect_device == "auto":
|
||||
return None # let ultralytics pick GPU if present, else CPU
|
||||
return settings.detect_device
|
||||
|
||||
|
||||
def get_model() -> Any:
|
||||
"""Lazily load and cache the YOLO model."""
|
||||
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
except ImportError as exc: # pragma: no cover - only without `ml`
|
||||
raise DetectModelUnavailable(
|
||||
"ultralytics is not installed; install the `ml` extra to enable "
|
||||
"object detection."
|
||||
) from exc
|
||||
_model = YOLO(settings.detect_model)
|
||||
return _model
|
||||
|
||||
|
||||
def _summarize(detections: list[dict[str, Any]]) -> FeatureResult:
|
||||
"""Build a FeatureResult from parsed detections (pure, testable)."""
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for d in detections:
|
||||
counts[d["label"]] = counts.get(d["label"], 0) + 1
|
||||
|
||||
results = {
|
||||
"detections": detections,
|
||||
"count": len(detections),
|
||||
"by_label": counts,
|
||||
}
|
||||
evidence = (
|
||||
["Objects: " + ", ".join(f"{k}×{v}" for k, v in sorted(counts.items()))]
|
||||
if detections
|
||||
else ["No objects detected."]
|
||||
)
|
||||
return ok_result(TOOL_ID, NAME, results, evidence=evidence, confidence=0.8)
|
||||
|
||||
|
||||
def analyze(
|
||||
image: bytes,
|
||||
threshold: float | None = None,
|
||||
*,
|
||||
model: Any | None = None,
|
||||
) -> FeatureResult:
|
||||
"""Detect objects in ``image`` and return labels, boxes and confidences."""
|
||||
|
||||
if not image:
|
||||
return err_result(TOOL_ID, NAME, "empty image")
|
||||
|
||||
predictor = model or get_model()
|
||||
conf = threshold if threshold is not None else settings.detect_threshold
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(io.BytesIO(image)).convert("RGB")
|
||||
except Exception as exc:
|
||||
return err_result(TOOL_ID, NAME, f"cannot read image: {exc}")
|
||||
|
||||
kwargs: dict[str, Any] = {"conf": conf, "verbose": False}
|
||||
device = _resolve_device()
|
||||
if device is not None:
|
||||
kwargs["device"] = device
|
||||
outputs = predictor.predict(img, **kwargs)
|
||||
|
||||
result0 = outputs[0]
|
||||
names = getattr(result0, "names", {})
|
||||
detections: list[dict[str, Any]] = []
|
||||
for box in result0.boxes:
|
||||
cls_id = int(box.cls[0])
|
||||
detections.append(
|
||||
{
|
||||
"label": names.get(cls_id, str(cls_id)),
|
||||
"confidence": round(float(box.conf[0]), 3),
|
||||
"box": [round(float(v), 1) for v in box.xyxy[0]],
|
||||
}
|
||||
)
|
||||
detections.sort(key=lambda d: d["confidence"], reverse=True)
|
||||
return _summarize(detections)
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
"""Media-type detection and metadata extractor orchestration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from extractors.contract import MetadataResponse
|
||||
from extractors.features import ela, exif, integrity, spectrogram, video_meta
|
||||
|
||||
_IMAGE_MAGIC = (b"\xff\xd8\xff", b"\x89PNG\r\n\x1a\n", b"GIF87a", b"GIF89a")
|
||||
_AUDIO_MAGIC = (b"OggS", b"fLaC", b"ID3", b"RIFF")
|
||||
|
||||
|
||||
def detect_media_type(data: bytes, filename: str) -> str:
|
||||
"""Classify the input as image | video | audio | unknown."""
|
||||
|
||||
if data.startswith(_IMAGE_MAGIC):
|
||||
# WEBP is RIFF....WEBP — treat as image.
|
||||
return "image"
|
||||
if data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
||||
return "image"
|
||||
if len(data) >= 12 and data[4:8] == b"ftyp":
|
||||
return "video"
|
||||
if data.startswith(b"\x1aE\xdf\xa3"): # Matroska/WebM
|
||||
return "video"
|
||||
if data.startswith(b"RIFF") and data[8:12] == b"AVI ":
|
||||
return "video"
|
||||
if data.startswith(_AUDIO_MAGIC):
|
||||
return "audio"
|
||||
# Fall back to extension when magic is inconclusive.
|
||||
ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
|
||||
if ext in ("jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff"):
|
||||
return "image"
|
||||
if ext in ("mp4", "mov", "mkv", "webm", "avi", "m4v"):
|
||||
return "video"
|
||||
if ext in ("mp3", "wav", "ogg", "flac", "m4a", "aac"):
|
||||
return "audio"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def analyze(path: str, data: bytes, filename: str) -> MetadataResponse:
|
||||
"""Run the applicable metadata extractors and aggregate the response.
|
||||
|
||||
Args:
|
||||
path: Filesystem path to the saved upload (extractors that shell out to
|
||||
ffprobe/ffmpeg need a real path).
|
||||
data: Raw file bytes (used for media detection and integrity).
|
||||
filename: Original client filename (extension fallback + reporting).
|
||||
"""
|
||||
|
||||
started = time.perf_counter()
|
||||
media_type = detect_media_type(data, filename)
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
|
||||
response = MetadataResponse(
|
||||
filename=filename,
|
||||
media_type=media_type,
|
||||
sha256=sha256,
|
||||
size_bytes=len(data),
|
||||
)
|
||||
|
||||
# Integrity runs for every input.
|
||||
runners = [("integrity", lambda: integrity.analyze_bytes(data))]
|
||||
if media_type == "image":
|
||||
runners += [
|
||||
("exif", lambda: exif.extract(path)),
|
||||
("ela", lambda: ela.extract(path)),
|
||||
]
|
||||
elif media_type == "video":
|
||||
runners += [
|
||||
("video_metadata", lambda: video_meta.extract(path)),
|
||||
("spectrogram", lambda: spectrogram.extract(path)),
|
||||
]
|
||||
elif media_type == "audio":
|
||||
runners.append(("spectrogram", lambda: spectrogram.extract(path)))
|
||||
|
||||
for key, run in runners:
|
||||
result = run()
|
||||
response.analyses[key] = result
|
||||
response.evidence.extend(result.evidence)
|
||||
response.anomalies.extend(result.anomalies)
|
||||
|
||||
response.execution_time_ms = round((time.perf_counter() - started) * 1000, 1)
|
||||
return response
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"""Error Level Analysis (ELA) for images (Pillow + numpy).
|
||||
|
||||
ELA re-compresses an image at a known quality and measures the per-pixel
|
||||
difference against the original. Uniformly edited regions tend to show a
|
||||
different error level than the rest of the frame, hinting at splices/edits.
|
||||
This returns quantitative stats (not a verdict) for downstream consumption.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from extractors.contract import FeatureResult, err_result, ok_result
|
||||
|
||||
TOOL_ID = "ela"
|
||||
NAME = "Error Level Analysis"
|
||||
|
||||
|
||||
def _ela_array(img: Image.Image, quality: int) -> np.ndarray:
|
||||
"""Return the amplified per-pixel ELA difference as a uint8 RGB array."""
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=quality)
|
||||
buf.seek(0)
|
||||
recompressed = Image.open(buf).convert("RGB")
|
||||
diff = ImageChops.difference(img, recompressed)
|
||||
arr = np.asarray(diff, dtype=np.float32)
|
||||
peak = float(arr.max()) or 1.0
|
||||
scaled = np.clip(arr * (255.0 / peak), 0, 255).astype(np.uint8)
|
||||
return scaled
|
||||
|
||||
|
||||
def extract(path: str, quality: int = 90) -> FeatureResult:
|
||||
"""Compute ELA statistics for the image at ``path``."""
|
||||
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
img = im.convert("RGB")
|
||||
except Exception as exc:
|
||||
return err_result(TOOL_ID, NAME, f"cannot read image: {exc}")
|
||||
|
||||
scaled = _ela_array(img, quality)
|
||||
gray = scaled.mean(axis=2)
|
||||
|
||||
mean_err = float(gray.mean())
|
||||
max_err = float(gray.max())
|
||||
p99 = float(np.percentile(gray, 99))
|
||||
|
||||
# Fraction of pixels well above the mean error → localized residue.
|
||||
hot_threshold = max(mean_err * 3.0, 40.0)
|
||||
hot_fraction = float((gray > hot_threshold).mean())
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"quality": quality,
|
||||
"mean_error": round(mean_err, 2),
|
||||
"max_error": round(max_err, 2),
|
||||
"p99_error": round(p99, 2),
|
||||
"hot_pixel_fraction": round(hot_fraction, 4),
|
||||
}
|
||||
evidence = [
|
||||
f"ELA mean error {mean_err:.1f}, p99 {p99:.1f} (quality={quality})."
|
||||
]
|
||||
anomalies: list[str] = []
|
||||
if hot_fraction > 0.02 and p99 > mean_err * 4:
|
||||
anomalies.append(
|
||||
"Localized high-error region(s) detected — possible splice/edit "
|
||||
f"({hot_fraction * 100:.1f}% of pixels)."
|
||||
)
|
||||
|
||||
return ok_result(
|
||||
TOOL_ID,
|
||||
NAME,
|
||||
results,
|
||||
evidence=evidence,
|
||||
anomalies=anomalies,
|
||||
confidence=0.6,
|
||||
)
|
||||
139
ai_platform/modules/extractors/src/extractors/features/exif.py
Normal file
139
ai_platform/modules/extractors/src/extractors/features/exif.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""EXIF metadata extraction from images (Pillow, no system deps).
|
||||
|
||||
Extracts camera/make/model, capture timestamps, editing software and GPS, and
|
||||
flags editing-software fingerprints as anomalies (a classic manipulation hint).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from PIL import ExifTags, Image
|
||||
from PIL.ExifTags import GPSTAGS
|
||||
|
||||
from extractors.contract import FeatureResult, err_result, ok_result
|
||||
|
||||
TOOL_ID = "exif"
|
||||
NAME = "EXIF metadata"
|
||||
|
||||
# Software names that indicate the image passed through an editor.
|
||||
_EDITORS = (
|
||||
"photoshop",
|
||||
"lightroom",
|
||||
"gimp",
|
||||
"affinity",
|
||||
"pixelmator",
|
||||
"snapseed",
|
||||
"facetune",
|
||||
"luminar",
|
||||
"topaz",
|
||||
"midjourney",
|
||||
"dall-e",
|
||||
"stable diffusion",
|
||||
)
|
||||
|
||||
|
||||
def _to_jsonable(value: Any) -> Any:
|
||||
"""Coerce Pillow EXIF values into JSON-serialisable primitives."""
|
||||
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", "replace").strip("\x00").strip()
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [_to_jsonable(v) for v in value]
|
||||
try:
|
||||
from PIL.TiffImagePlugin import IFDRational
|
||||
|
||||
if isinstance(value, IFDRational):
|
||||
return float(value) if value.denominator else None
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def _dms_to_deg(dms: Any, ref: Any) -> float | None:
|
||||
"""Convert EXIF GPS (degrees, minutes, seconds) + hemisphere ref to float."""
|
||||
|
||||
try:
|
||||
d, m, s = (float(x) for x in dms)
|
||||
deg = d + m / 60.0 + s / 3600.0
|
||||
if str(ref).upper() in ("S", "W"):
|
||||
deg = -deg
|
||||
return round(deg, 6)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_gps(gps_ifd: dict[int, Any]) -> dict[str, Any]:
|
||||
named = {GPSTAGS.get(k, k): v for k, v in gps_ifd.items()}
|
||||
lat = _dms_to_deg(named.get("GPSLatitude"), named.get("GPSLatitudeRef"))
|
||||
lon = _dms_to_deg(named.get("GPSLongitude"), named.get("GPSLongitudeRef"))
|
||||
out: dict[str, Any] = {}
|
||||
if lat is not None and lon is not None:
|
||||
out["latitude"] = lat
|
||||
out["longitude"] = lon
|
||||
return out
|
||||
|
||||
|
||||
def extract(path: str) -> FeatureResult:
|
||||
"""Extract EXIF metadata from an image file at ``path``."""
|
||||
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
img.load()
|
||||
exif = img.getexif()
|
||||
fmt = img.format
|
||||
size = list(img.size)
|
||||
except Exception as exc: # not an image / unreadable
|
||||
return err_result(TOOL_ID, NAME, f"cannot read image: {exc}")
|
||||
|
||||
results: dict[str, Any] = {"format": fmt, "dimensions": size}
|
||||
evidence: list[str] = []
|
||||
anomalies: list[str] = []
|
||||
|
||||
if not exif:
|
||||
evidence.append("No EXIF metadata present.")
|
||||
return ok_result(
|
||||
TOOL_ID, NAME, results, evidence=evidence, confidence=0.9
|
||||
)
|
||||
|
||||
named = {ExifTags.TAGS.get(k, k): _to_jsonable(v) for k, v in exif.items()}
|
||||
|
||||
for key in ("Make", "Model", "Software", "DateTimeOriginal", "DateTime"):
|
||||
if named.get(key):
|
||||
results[key.lower()] = named[key]
|
||||
|
||||
# GPS lives in a sub-IFD.
|
||||
try:
|
||||
gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
|
||||
except Exception:
|
||||
gps_ifd = {}
|
||||
if gps_ifd:
|
||||
gps = _parse_gps(gps_ifd)
|
||||
if gps:
|
||||
results["gps"] = gps
|
||||
evidence.append(
|
||||
f"GPS location present: {gps['latitude']}, {gps['longitude']}."
|
||||
)
|
||||
|
||||
if results.get("make") or results.get("model"):
|
||||
evidence.append(
|
||||
f"Captured with {results.get('make', '?')} {results.get('model', '')}".strip()
|
||||
+ "."
|
||||
)
|
||||
|
||||
software = str(results.get("software", "")).lower()
|
||||
if software:
|
||||
evidence.append(f"Software tag: {results['software']}.")
|
||||
if any(ed in software for ed in _EDITORS):
|
||||
anomalies.append(
|
||||
f"Editing/generation software detected in EXIF: {results['software']}."
|
||||
)
|
||||
|
||||
return ok_result(
|
||||
TOOL_ID,
|
||||
NAME,
|
||||
results,
|
||||
evidence=evidence,
|
||||
anomalies=anomalies,
|
||||
confidence=0.95,
|
||||
)
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"""File integrity & signature checks (stdlib only).
|
||||
|
||||
Computes a content hash, identifies the container by magic bytes, and looks for
|
||||
classic tamper indicators: trailing data after a JPEG end-of-image marker and
|
||||
embedded C2PA/JUMBF provenance manifests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from extractors.contract import FeatureResult, ok_result
|
||||
|
||||
TOOL_ID = "integrity"
|
||||
NAME = "Integrity & signature"
|
||||
|
||||
# (magic prefix, label). Order matters: check longer/more specific first.
|
||||
_MAGIC: tuple[tuple[bytes, str], ...] = (
|
||||
(b"\xff\xd8\xff", "JPEG"),
|
||||
(b"\x89PNG\r\n\x1a\n", "PNG"),
|
||||
(b"GIF87a", "GIF"),
|
||||
(b"GIF89a", "GIF"),
|
||||
(b"RIFF", "RIFF (WAV/AVI/WEBP)"),
|
||||
(b"\x1aE\xdf\xa3", "Matroska/WebM"),
|
||||
(b"OggS", "Ogg"),
|
||||
(b"fLaC", "FLAC"),
|
||||
(b"ID3", "MP3 (ID3)"),
|
||||
)
|
||||
|
||||
|
||||
def _identify(data: bytes) -> str:
|
||||
for magic, label in _MAGIC:
|
||||
if data.startswith(magic):
|
||||
return label
|
||||
# ISO-BMFF (mp4/mov): 'ftyp' box at offset 4.
|
||||
if len(data) >= 12 and data[4:8] == b"ftyp":
|
||||
brand = data[8:12].decode("ascii", "replace")
|
||||
return f"ISO-BMFF/MP4 (brand={brand})"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def analyze_bytes(data: bytes) -> FeatureResult:
|
||||
"""Run integrity checks over the raw file ``data`` (testable, no IO)."""
|
||||
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
container = _identify(data)
|
||||
results: dict[str, Any] = {
|
||||
"sha256": sha256,
|
||||
"size_bytes": len(data),
|
||||
"container": container,
|
||||
}
|
||||
evidence = [f"Container: {container}.", f"SHA-256: {sha256[:16]}…"]
|
||||
anomalies: list[str] = []
|
||||
|
||||
# JPEG: data after the EOI marker (FFD9) is suspicious (appended payload).
|
||||
if container == "JPEG":
|
||||
eoi = data.rfind(b"\xff\xd9")
|
||||
if eoi != -1:
|
||||
trailing = len(data) - (eoi + 2)
|
||||
if trailing > 16:
|
||||
results["trailing_bytes_after_eoi"] = trailing
|
||||
anomalies.append(
|
||||
f"{trailing} bytes appended after JPEG end-of-image marker."
|
||||
)
|
||||
|
||||
# C2PA / JUMBF provenance manifest (Content Credentials).
|
||||
if b"c2pa" in data[:1_000_000] or b"jumb" in data[:1_000_000]:
|
||||
results["c2pa_manifest"] = True
|
||||
evidence.append("C2PA/JUMBF provenance manifest embedded.")
|
||||
|
||||
return ok_result(
|
||||
TOOL_ID,
|
||||
NAME,
|
||||
results,
|
||||
evidence=evidence,
|
||||
anomalies=anomalies,
|
||||
confidence=1.0,
|
||||
)
|
||||
|
||||
|
||||
def extract(path: str) -> FeatureResult:
|
||||
"""Run integrity checks over the file at ``path``."""
|
||||
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
return analyze_bytes(data)
|
||||
115
ai_platform/modules/extractors/src/extractors/features/ner.py
Normal file
115
ai_platform/modules/extractors/src/extractors/features/ner.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Named-entity recognition via GLiNER (torch backend, GPU-capable).
|
||||
|
||||
GLiNER is a small multilingual zero-shot NER model: you pass the entity types
|
||||
you want and it labels spans. It runs fine on CPU (~tens of ms/sentence) and
|
||||
uses CUDA automatically when a GPU is allocated. Romanian is supported by the
|
||||
multilingual checkpoint.
|
||||
|
||||
The model is loaded lazily on first use; if the `ml` extra (gliner/torch) is not
|
||||
installed, the endpoint reports unavailable rather than crashing at import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from extractors.contract import FeatureResult, ok_result
|
||||
from extractors.settings import settings
|
||||
|
||||
TOOL_ID = "ner"
|
||||
NAME = "Named-entity recognition (GLiNER)"
|
||||
|
||||
|
||||
class NerModelUnavailable(RuntimeError):
|
||||
"""Raised when GLiNER / torch are not installed in this deployment."""
|
||||
|
||||
|
||||
class _Predictor(Protocol):
|
||||
def predict_entities(
|
||||
self, text: str, labels: list[str], threshold: float = ...
|
||||
) -> list[dict[str, Any]]: ...
|
||||
|
||||
|
||||
_model: _Predictor | None = None
|
||||
|
||||
|
||||
def _resolve_device() -> str:
|
||||
if settings.ner_device != "auto":
|
||||
return settings.ner_device
|
||||
try:
|
||||
import torch
|
||||
|
||||
return "cuda" if torch.cuda.is_available() else "cpu"
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
|
||||
def get_model() -> _Predictor:
|
||||
"""Lazily load and cache the GLiNER model (CPU/GPU per config)."""
|
||||
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
try:
|
||||
from gliner import GLiNER
|
||||
except ImportError as exc: # pragma: no cover - exercised only without `ml`
|
||||
raise NerModelUnavailable(
|
||||
"GLiNER is not installed; install the `ml` extra to enable NER."
|
||||
) from exc
|
||||
|
||||
model = GLiNER.from_pretrained(settings.ner_model)
|
||||
device = _resolve_device()
|
||||
try:
|
||||
model = model.to(device)
|
||||
except Exception:
|
||||
device = "cpu"
|
||||
_model = model
|
||||
return _model
|
||||
|
||||
|
||||
def analyze(
|
||||
text: str,
|
||||
labels: list[str] | None = None,
|
||||
*,
|
||||
model: _Predictor | None = None,
|
||||
) -> FeatureResult:
|
||||
"""Extract named entities of the requested ``labels`` from ``text``.
|
||||
|
||||
Args:
|
||||
text: Input text.
|
||||
labels: Entity types to extract (defaults to the RO-tuned set).
|
||||
model: Injected predictor (tests); falls back to the cached GLiNER.
|
||||
"""
|
||||
|
||||
text = (text or "").strip()
|
||||
labels = labels or list(settings.ner_default_labels)
|
||||
predictor = model or get_model()
|
||||
|
||||
raw = predictor.predict_entities(text, labels, threshold=settings.ner_threshold)
|
||||
|
||||
entities = [
|
||||
{
|
||||
"text": e["text"],
|
||||
"label": e["label"],
|
||||
"start": e.get("start"),
|
||||
"end": e.get("end"),
|
||||
"score": round(float(e.get("score", 0.0)), 3),
|
||||
}
|
||||
for e in raw
|
||||
]
|
||||
entities.sort(key=lambda e: e["start"] if e["start"] is not None else 0)
|
||||
|
||||
by_label: dict[str, int] = {}
|
||||
for e in entities:
|
||||
by_label[e["label"]] = by_label.get(e["label"], 0) + 1
|
||||
|
||||
results = {
|
||||
"entities": entities,
|
||||
"count": len(entities),
|
||||
"by_label": by_label,
|
||||
}
|
||||
evidence = [
|
||||
f"{len(entities)} entities: "
|
||||
+ ", ".join(f"{k}×{v}" for k, v in sorted(by_label.items()))
|
||||
] if entities else ["No entities detected."]
|
||||
return ok_result(TOOL_ID, NAME, results, evidence=evidence, confidence=0.85)
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"""OCR delegated to the platform LLM vision model.
|
||||
|
||||
Per the offer, OCR is provided by the vision-capable model (Qwen) exposed by the
|
||||
inference module — not a separate engine. The image is forwarded as an
|
||||
``image_url`` data URI and the model transcribes visible text verbatim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.contract import FeatureResult, err_result, ok_result
|
||||
from extractors.features._jsonparse import extract_json_object
|
||||
from extractors.llm_client import LLMClient, image_data_url
|
||||
|
||||
TOOL_ID = "ocr"
|
||||
NAME = "OCR (LLM vision)"
|
||||
|
||||
_PROMPT = (
|
||||
"Extrage TOT textul vizibil din imagine, exact cum apare (verbatim), "
|
||||
"păstrând diacriticele și ordinea citirii. Nu traduce, nu rezuma. "
|
||||
"Întoarce STRICT un obiect JSON cu cheile:\n"
|
||||
' "text": textul extras (string, gol dacă nu există text),\n'
|
||||
' "language": codul limbii detectate (ex. "ro", "en") sau null,\n'
|
||||
' "has_text": true/false.'
|
||||
)
|
||||
|
||||
|
||||
async def analyze(
|
||||
image: bytes, client: LLMClient, mime: str = "image/jpeg"
|
||||
) -> FeatureResult:
|
||||
"""Extract visible text from ``image`` via the LLM vision model."""
|
||||
|
||||
if not image:
|
||||
return err_result(TOOL_ID, NAME, "empty image")
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": _PROMPT},
|
||||
{"type": "image_url", "image_url": {"url": image_data_url(image, mime)}},
|
||||
],
|
||||
}
|
||||
]
|
||||
raw = await client.chat(messages, max_tokens=2048, temperature=0.0)
|
||||
|
||||
try:
|
||||
obj = extract_json_object(raw)
|
||||
text = str(obj.get("text", "")).strip()
|
||||
language = obj.get("language")
|
||||
has_text = bool(obj.get("has_text", bool(text)))
|
||||
except ValueError:
|
||||
# Model returned plain text instead of JSON — accept it as the OCR result.
|
||||
text = raw.strip()
|
||||
language = None
|
||||
has_text = bool(text)
|
||||
|
||||
results = {
|
||||
"text": text,
|
||||
"language": language,
|
||||
"has_text": has_text,
|
||||
"char_count": len(text),
|
||||
}
|
||||
evidence = (
|
||||
[f"Extracted {len(text)} chars of text (lang={language})."]
|
||||
if has_text
|
||||
else ["No visible text detected."]
|
||||
)
|
||||
return ok_result(TOOL_ID, NAME, results, evidence=evidence, confidence=0.8)
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""Sentiment analysis delegated to the platform LLM (Romanian-aware).
|
||||
|
||||
Uses the inference gateway with a dedicated, structured prompt. The LLM is the
|
||||
right tool here: disinformation text relies on irony/sarcasm and Romanian, which
|
||||
small classifiers handle poorly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.contract import FeatureResult, err_result, ok_result
|
||||
from extractors.features._jsonparse import extract_json_object
|
||||
from extractors.llm_client import LLMClient
|
||||
|
||||
TOOL_ID = "sentiment"
|
||||
NAME = "Sentiment analysis (LLM)"
|
||||
|
||||
_LABELS = {"pozitiv", "negativ", "neutru", "mixt"}
|
||||
|
||||
_SYSTEM = (
|
||||
"Ești un analist lingvistic. Clasifici sentimentul unui text în limba "
|
||||
"română (sau orice limbă). Ții cont de ironie, sarcasm și ton manipulativ. "
|
||||
"Răspunzi STRICT cu un obiect JSON, fără alt text."
|
||||
)
|
||||
|
||||
_PROMPT = (
|
||||
"Analizează sentimentul textului de mai jos. Întoarce JSON cu cheile:\n"
|
||||
' "label": una dintre "pozitiv" | "negativ" | "neutru" | "mixt",\n'
|
||||
' "score": număr între -1.0 (foarte negativ) și 1.0 (foarte pozitiv),\n'
|
||||
' "confidence": număr între 0.0 și 1.0,\n'
|
||||
' "rationale": explicație scurtă (o propoziție, în română).\n\n'
|
||||
"TEXT:\n{text}"
|
||||
)
|
||||
|
||||
|
||||
async def analyze(text: str, client: LLMClient) -> FeatureResult:
|
||||
"""Classify the sentiment of ``text`` via the LLM gateway."""
|
||||
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return err_result(TOOL_ID, NAME, "empty text")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM},
|
||||
{"role": "user", "content": _PROMPT.format(text=text[:8000])},
|
||||
]
|
||||
raw = await client.chat(messages, max_tokens=300, temperature=0.0)
|
||||
|
||||
try:
|
||||
obj = extract_json_object(raw)
|
||||
except ValueError as exc:
|
||||
return err_result(TOOL_ID, NAME, f"unparseable model output: {exc}")
|
||||
|
||||
label = str(obj.get("label", "")).lower().strip()
|
||||
if label not in _LABELS:
|
||||
label = "neutru"
|
||||
try:
|
||||
score = max(-1.0, min(1.0, float(obj.get("score", 0.0))))
|
||||
except (TypeError, ValueError):
|
||||
score = 0.0
|
||||
try:
|
||||
confidence = max(0.0, min(1.0, float(obj.get("confidence", 0.5))))
|
||||
except (TypeError, ValueError):
|
||||
confidence = 0.5
|
||||
rationale = str(obj.get("rationale", "")).strip()
|
||||
|
||||
results = {"label": label, "score": round(score, 3), "rationale": rationale}
|
||||
evidence = [f"Sentiment: {label} (score {score:+.2f})."]
|
||||
if rationale:
|
||||
evidence.append(rationale)
|
||||
return ok_result(TOOL_ID, NAME, results, evidence=evidence, confidence=confidence)
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"""Audio spectrogram & discontinuity analysis (numpy STFT).
|
||||
|
||||
Pure-numpy STFT (no librosa dependency, matching the lightweight approach used
|
||||
in ``forensic_features/tools/m26_audio``). Audio is decoded to mono PCM via
|
||||
ffmpeg for non-WAV inputs; WAV is read with the stdlib. The DSP core is split
|
||||
from IO so it can be unit-tested on synthetic signals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import wave
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from extractors.contract import FeatureResult, err_result, ok_result
|
||||
from extractors.settings import settings
|
||||
|
||||
TOOL_ID = "spectrogram"
|
||||
NAME = "Audio spectrogram"
|
||||
|
||||
_TARGET_SR = 16_000
|
||||
|
||||
|
||||
def compute_features(pcm: np.ndarray, sr: int) -> dict[str, Any]:
|
||||
"""Compute spectral features + splice candidates from mono float PCM."""
|
||||
|
||||
if pcm.size == 0:
|
||||
return {"duration_s": 0.0, "sample_rate": sr, "frames": 0}
|
||||
|
||||
win = 1024
|
||||
hop = 512
|
||||
n_frames = max(1, 1 + (len(pcm) - win) // hop) if len(pcm) >= win else 1
|
||||
freqs = np.fft.rfftfreq(win, d=1.0 / sr)
|
||||
|
||||
centroids = np.zeros(n_frames, dtype=np.float64)
|
||||
energies = np.zeros(n_frames, dtype=np.float64)
|
||||
window = np.hanning(win)
|
||||
|
||||
for i in range(n_frames):
|
||||
seg = pcm[i * hop : i * hop + win]
|
||||
if len(seg) < win:
|
||||
seg = np.pad(seg, (0, win - len(seg)))
|
||||
spec = np.abs(np.fft.rfft(seg * window))
|
||||
total = spec.sum()
|
||||
centroids[i] = float((freqs * spec).sum() / total) if total > 0 else 0.0
|
||||
energies[i] = float(np.sqrt(np.mean(seg**2)))
|
||||
|
||||
# Splice candidates: abrupt frame-to-frame energy jumps.
|
||||
discontinuities: list[float] = []
|
||||
if n_frames > 2:
|
||||
denom = energies + 1e-6
|
||||
rel_jump = np.abs(np.diff(energies)) / denom[:-1]
|
||||
for idx in np.where(rel_jump > 4.0)[0]:
|
||||
discontinuities.append(round((idx * hop) / sr, 3))
|
||||
|
||||
return {
|
||||
"duration_s": round(len(pcm) / sr, 3),
|
||||
"sample_rate": sr,
|
||||
"frames": int(n_frames),
|
||||
"spectral_centroid_hz_mean": round(float(centroids.mean()), 1),
|
||||
"rms_mean": round(float(energies.mean()), 5),
|
||||
"discontinuities_s": discontinuities[:20],
|
||||
}
|
||||
|
||||
|
||||
def _result_from_features(feats: dict[str, Any]) -> FeatureResult:
|
||||
evidence = [
|
||||
f"Audio {feats['duration_s']}s @ {feats['sample_rate']} Hz, "
|
||||
f"centroid {feats.get('spectral_centroid_hz_mean', 0)} Hz."
|
||||
]
|
||||
anomalies: list[str] = []
|
||||
disc = feats.get("discontinuities_s", [])
|
||||
if disc:
|
||||
anomalies.append(
|
||||
f"{len(disc)} abrupt energy discontinuity(ies) — possible splice "
|
||||
f"at {', '.join(f'{t}s' for t in disc[:5])}."
|
||||
)
|
||||
return ok_result(
|
||||
TOOL_ID, NAME, feats, evidence=evidence, anomalies=anomalies, confidence=0.55
|
||||
)
|
||||
|
||||
|
||||
def _read_wav(path: str) -> tuple[np.ndarray, int]:
|
||||
with wave.open(path, "rb") as wf:
|
||||
sr = wf.getframerate()
|
||||
n = wf.getnframes()
|
||||
raw = wf.readframes(n)
|
||||
width = wf.getsampwidth()
|
||||
ch = wf.getnchannels()
|
||||
dtype = {1: np.uint8, 2: np.int16, 4: np.int32}.get(width, np.int16)
|
||||
arr = np.frombuffer(raw, dtype=dtype).astype(np.float32)
|
||||
if dtype == np.uint8:
|
||||
arr = (arr - 128.0) / 128.0
|
||||
else:
|
||||
arr = arr / float(np.iinfo(dtype).max)
|
||||
if ch > 1:
|
||||
arr = arr.reshape(-1, ch).mean(axis=1)
|
||||
return arr, sr
|
||||
|
||||
|
||||
def _decode_via_ffmpeg(path: str) -> tuple[np.ndarray, int]:
|
||||
cmd = [
|
||||
settings.ffmpeg_bin,
|
||||
"-v",
|
||||
"quiet",
|
||||
"-i",
|
||||
path,
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
str(_TARGET_SR),
|
||||
"-f",
|
||||
"s16le",
|
||||
"-",
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
raise RuntimeError("ffmpeg failed to decode audio")
|
||||
arr = np.frombuffer(proc.stdout, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
return arr, _TARGET_SR
|
||||
|
||||
|
||||
def extract(path: str) -> FeatureResult:
|
||||
"""Compute spectrogram features for the audio/video file at ``path``."""
|
||||
|
||||
try:
|
||||
if path.lower().endswith(".wav"):
|
||||
pcm, sr = _read_wav(path)
|
||||
else:
|
||||
pcm, sr = _decode_via_ffmpeg(path)
|
||||
except FileNotFoundError:
|
||||
return err_result(TOOL_ID, NAME, "ffmpeg binary not found")
|
||||
except Exception as exc:
|
||||
return err_result(TOOL_ID, NAME, f"cannot decode audio: {exc}")
|
||||
|
||||
if pcm.size == 0:
|
||||
return err_result(TOOL_ID, NAME, "no audio stream / empty signal")
|
||||
return _result_from_features(compute_features(pcm, sr))
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"""Video/container metadata via ffprobe.
|
||||
|
||||
The subprocess call and the JSON parser are split so the parser can be
|
||||
unit-tested with captured fixtures without needing the ffprobe binary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from extractors.contract import FeatureResult, err_result, ok_result
|
||||
from extractors.settings import settings
|
||||
|
||||
TOOL_ID = "video_metadata"
|
||||
NAME = "Video/container metadata"
|
||||
|
||||
_EDITORS = ("lavf", "handbrake", "premiere", "vegas", "ffmpeg", "shotcut", "imovie")
|
||||
|
||||
|
||||
def _parse_fps(rate: str | None) -> float | None:
|
||||
"""Parse an ffprobe frame-rate string like '30000/1001'."""
|
||||
|
||||
if not rate or rate == "0/0":
|
||||
return None
|
||||
try:
|
||||
if "/" in rate:
|
||||
num, den = rate.split("/")
|
||||
den_f = float(den)
|
||||
return round(float(num) / den_f, 3) if den_f else None
|
||||
return round(float(rate), 3)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_ffprobe(data: dict[str, Any]) -> FeatureResult:
|
||||
"""Build a FeatureResult from a parsed ffprobe ``-show_format -show_streams``."""
|
||||
|
||||
fmt = data.get("format", {}) or {}
|
||||
streams = data.get("streams", []) or []
|
||||
video = next((s for s in streams if s.get("codec_type") == "video"), {})
|
||||
audio = next((s for s in streams if s.get("codec_type") == "audio"), {})
|
||||
|
||||
results: dict[str, Any] = {
|
||||
"container": fmt.get("format_name"),
|
||||
"duration_s": float(fmt["duration"]) if fmt.get("duration") else None,
|
||||
"bitrate_bps": int(fmt["bit_rate"]) if fmt.get("bit_rate") else None,
|
||||
"has_audio": bool(audio),
|
||||
}
|
||||
if video:
|
||||
results["video"] = {
|
||||
"codec": video.get("codec_name"),
|
||||
"width": video.get("width"),
|
||||
"height": video.get("height"),
|
||||
"fps": _parse_fps(video.get("avg_frame_rate")),
|
||||
"pix_fmt": video.get("pix_fmt"),
|
||||
}
|
||||
if audio:
|
||||
results["audio"] = {
|
||||
"codec": audio.get("codec_name"),
|
||||
"sample_rate": audio.get("sample_rate"),
|
||||
"channels": audio.get("channels"),
|
||||
}
|
||||
|
||||
evidence: list[str] = []
|
||||
anomalies: list[str] = []
|
||||
if video:
|
||||
v = results["video"]
|
||||
evidence.append(
|
||||
f"Video: {v['codec']} {v['width']}x{v['height']} @ {v['fps']} fps."
|
||||
)
|
||||
if fmt.get("bit_rate"):
|
||||
evidence.append(f"Bitrate: {int(fmt['bit_rate']) // 1000} kbps.")
|
||||
|
||||
# Encoder/handler tags can reveal re-muxing/editing.
|
||||
tags = {k.lower(): str(v) for k, v in (fmt.get("tags", {}) or {}).items()}
|
||||
encoder = tags.get("encoder", "")
|
||||
if encoder:
|
||||
results["encoder"] = encoder
|
||||
evidence.append(f"Encoder tag: {encoder}.")
|
||||
if any(ed in encoder.lower() for ed in _EDITORS):
|
||||
anomalies.append(f"Re-encoding/editing tool in metadata: {encoder}.")
|
||||
|
||||
return ok_result(
|
||||
TOOL_ID, NAME, results, evidence=evidence, anomalies=anomalies, confidence=0.95
|
||||
)
|
||||
|
||||
|
||||
def extract(path: str) -> FeatureResult:
|
||||
"""Probe the media file at ``path`` with ffprobe."""
|
||||
|
||||
cmd = [
|
||||
settings.ffprobe_bin,
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||||
except FileNotFoundError:
|
||||
return err_result(TOOL_ID, NAME, "ffprobe binary not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
return err_result(TOOL_ID, NAME, "ffprobe timed out")
|
||||
|
||||
if proc.returncode != 0 or not proc.stdout.strip():
|
||||
return err_result(TOOL_ID, NAME, "ffprobe failed to parse the file")
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
return err_result(TOOL_ID, NAME, f"invalid ffprobe output: {exc}")
|
||||
return parse_ffprobe(data)
|
||||
95
ai_platform/modules/extractors/src/extractors/llm_client.py
Normal file
95
ai_platform/modules/extractors/src/extractors/llm_client.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Async client for the platform LLM inference gateway (OpenAI-compatible).
|
||||
|
||||
All LLM-backed extractors (sentiment, OCR, …) call the model exposed by the
|
||||
`llm-inference` module through this single client. Nothing in this service hosts
|
||||
an LLM itself — it only forwards OpenAI-style chat completions to the gateway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from extractors.settings import settings
|
||||
|
||||
|
||||
class LLMNotConfigured(RuntimeError):
|
||||
"""Raised when an LLM-backed endpoint is hit but no gateway is configured."""
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
"""Raised when the gateway returns an error or an unusable response."""
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Thin async wrapper over the gateway's /v1/chat/completions endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> None:
|
||||
self._base = (base_url or settings.llm_gateway_url or "").rstrip("/")
|
||||
self._model = model or settings.llm_model
|
||||
self._timeout = timeout or settings.llm_timeout_s
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self._base)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
max_tokens: int = 1024,
|
||||
temperature: float = 0.0,
|
||||
) -> str:
|
||||
"""Send a chat completion and return the assistant message content.
|
||||
|
||||
Args:
|
||||
messages: OpenAI-style message list (supports multimodal content).
|
||||
max_tokens: Upper bound on generated tokens.
|
||||
temperature: Sampling temperature (0 for deterministic extraction).
|
||||
|
||||
Raises:
|
||||
LLMNotConfigured: No gateway URL set.
|
||||
LLMError: Transport failure or malformed response.
|
||||
"""
|
||||
|
||||
if not self.configured:
|
||||
raise LLMNotConfigured(
|
||||
"EXTRACTORS_LLM_GATEWAY_URL is not set; LLM-backed endpoint "
|
||||
"unavailable in this deployment."
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": self._model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base}/v1/chat/completions", json=payload
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMError(f"gateway request failed: {exc}") from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise LLMError(f"gateway HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
try:
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, ValueError) as exc:
|
||||
raise LLMError(f"malformed gateway response: {exc}") from exc
|
||||
|
||||
|
||||
def image_data_url(data: bytes, mime: str = "image/jpeg") -> str:
|
||||
"""Encode raw image bytes as an OpenAI ``image_url`` data URI."""
|
||||
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""HTTP routers."""
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"""Health and readiness probes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from extractors import __version__
|
||||
from extractors.settings import settings
|
||||
|
||||
router = APIRouter(tags=["meta"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Liveness probe — the process is up."""
|
||||
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
def ready() -> dict[str, object]:
|
||||
"""Readiness probe — reports availability of optional external tooling.
|
||||
|
||||
The metadata endpoint degrades gracefully when ffprobe/ffmpeg are absent
|
||||
(image extractors still work), so readiness is informational, not a gate.
|
||||
"""
|
||||
|
||||
return {
|
||||
"status": "ready",
|
||||
"ffprobe": bool(shutil.which(settings.ffprobe_bin)),
|
||||
"ffmpeg": bool(shutil.which(settings.ffmpeg_bin)),
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
"""POST /v1/metadata — deterministic media metadata & integrity extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from extractors.contract import MetadataResponse
|
||||
from extractors.features import dispatch
|
||||
from extractors.settings import settings
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["metadata"])
|
||||
|
||||
|
||||
@router.post("/metadata", response_model=MetadataResponse)
|
||||
async def metadata(file: UploadFile = File(...)) -> MetadataResponse:
|
||||
"""Extract EXIF, ELA, video metadata, spectrogram and integrity features.
|
||||
|
||||
The applicable extractors are selected automatically from the detected media
|
||||
type (image / video / audio). Returns a uniform per-extractor result block
|
||||
plus aggregated evidence/anomaly findings.
|
||||
"""
|
||||
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="empty file")
|
||||
if len(data) > settings.max_upload_mb * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"file exceeds {settings.max_upload_mb} MB limit",
|
||||
)
|
||||
|
||||
suffix = os.path.splitext(file.filename or "")[1]
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
||||
try:
|
||||
tmp.write(data)
|
||||
tmp.flush()
|
||||
tmp.close()
|
||||
return dispatch.analyze(tmp.name, data, file.filename or "upload")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
50
ai_platform/modules/extractors/src/extractors/routes/text.py
Normal file
50
ai_platform/modules/extractors/src/extractors/routes/text.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Text feature endpoints delegated to the LLM gateway (sentiment; NER later)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from extractors.contract import FeatureResult
|
||||
from extractors.deps import get_llm_client
|
||||
from extractors.features import ner, sentiment
|
||||
from extractors.llm_client import LLMClient, LLMError, LLMNotConfigured
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["text"])
|
||||
|
||||
|
||||
class TextRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, description="Text to analyze.")
|
||||
|
||||
|
||||
class NerRequest(BaseModel):
|
||||
text: str = Field(..., min_length=1, description="Text to analyze.")
|
||||
labels: list[str] | None = Field(
|
||||
default=None, description="Entity types to extract (defaults to RO set)."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sentiment", response_model=FeatureResult)
|
||||
async def sentiment_endpoint(
|
||||
req: TextRequest, client: LLMClient = Depends(get_llm_client)
|
||||
) -> FeatureResult:
|
||||
"""Classify sentiment of the input text via the LLM model."""
|
||||
|
||||
try:
|
||||
return await sentiment.analyze(req.text, client)
|
||||
except LLMNotConfigured as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except LLMError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/ner", response_model=FeatureResult)
|
||||
async def ner_endpoint(req: NerRequest) -> FeatureResult:
|
||||
"""Extract named entities from the input text via GLiNER."""
|
||||
|
||||
try:
|
||||
# GLiNER inference is CPU/GPU-bound and synchronous → offload.
|
||||
return await run_in_threadpool(ner.analyze, req.text, req.labels)
|
||||
except ner.NerModelUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"""Vision feature endpoints delegated to the LLM vision model (OCR)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from extractors.contract import FeatureResult
|
||||
from extractors.deps import get_llm_client
|
||||
from extractors.features import detect, ocr
|
||||
from extractors.llm_client import LLMClient, LLMError, LLMNotConfigured
|
||||
from extractors.settings import settings
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["vision"])
|
||||
|
||||
|
||||
def _check_upload(data: bytes) -> None:
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="empty file")
|
||||
if len(data) > settings.max_upload_mb * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=413, detail=f"file exceeds {settings.max_upload_mb} MB limit"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ocr", response_model=FeatureResult)
|
||||
async def ocr_endpoint(
|
||||
file: UploadFile = File(...), client: LLMClient = Depends(get_llm_client)
|
||||
) -> FeatureResult:
|
||||
"""Extract visible text from an image via the LLM vision model."""
|
||||
|
||||
data = await file.read()
|
||||
_check_upload(data)
|
||||
|
||||
mime = file.content_type or "image/jpeg"
|
||||
try:
|
||||
return await ocr.analyze(data, client, mime=mime)
|
||||
except LLMNotConfigured as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except LLMError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/detect", response_model=FeatureResult)
|
||||
async def detect_endpoint(
|
||||
file: UploadFile = File(...),
|
||||
threshold: float | None = Query(default=None, ge=0.0, le=1.0),
|
||||
) -> FeatureResult:
|
||||
"""Detect objects in an image via YOLO."""
|
||||
|
||||
data = await file.read()
|
||||
_check_upload(data)
|
||||
try:
|
||||
# YOLO inference is CPU/GPU-bound and synchronous → offload.
|
||||
return await run_in_threadpool(detect.analyze, data, threshold)
|
||||
except detect.DetectModelUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
62
ai_platform/modules/extractors/src/extractors/settings.py
Normal file
62
ai_platform/modules/extractors/src/extractors/settings.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Runtime configuration (pydantic-settings).
|
||||
|
||||
Per repo convention, required config has no silent fallback defaults. The LLM
|
||||
gateway settings are only consumed by the text/vision endpoints (added later);
|
||||
the metadata endpoint is fully self-contained and needs none of them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="EXTRACTORS_",
|
||||
env_file=".env",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# --- Service -----------------------------------------------------------
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 54400
|
||||
|
||||
# --- Upload limits -----------------------------------------------------
|
||||
max_upload_mb: int = 100
|
||||
|
||||
# --- External tooling --------------------------------------------------
|
||||
# ffprobe/ffmpeg are resolved from PATH inside the container image.
|
||||
ffprobe_bin: str = "ffprobe"
|
||||
ffmpeg_bin: str = "ffmpeg"
|
||||
|
||||
# --- LLM gateway (sentiment, OCR) --------------------------------------
|
||||
# Left optional so the metadata-only deployment needs no LLM config.
|
||||
llm_gateway_url: str | None = None
|
||||
llm_model: str = "qwen3.5"
|
||||
llm_timeout_s: float = 60.0
|
||||
|
||||
# --- NER (GLiNER, torch backend; needs the `ml` extra) -----------------
|
||||
ner_model: str = "urchade/gliner_multi-v2.1"
|
||||
ner_threshold: float = 0.5
|
||||
# "auto" -> CUDA if available else CPU; or force "cpu" / "cuda".
|
||||
ner_device: str = "auto"
|
||||
# Default entity types tuned for Romanian disinformation analysis.
|
||||
ner_default_labels: list[str] = [
|
||||
"persoană",
|
||||
"organizație",
|
||||
"instituție publică",
|
||||
"funcție publică",
|
||||
"locație",
|
||||
"țară",
|
||||
"dată",
|
||||
"eveniment",
|
||||
"lege sau act normativ",
|
||||
]
|
||||
|
||||
# --- Object detection (YOLO/ultralytics; needs the `ml` extra) ----------
|
||||
detect_model: str = "yolov8n.pt"
|
||||
detect_threshold: float = 0.35
|
||||
detect_device: str = "auto"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
0
ai_platform/modules/extractors/tests/__init__.py
Normal file
0
ai_platform/modules/extractors/tests/__init__.py
Normal file
45
ai_platform/modules/extractors/tests/conftest.py
Normal file
45
ai_platform/modules/extractors/tests/conftest.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Shared test fixtures: synthetic media generated in-memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jpeg_bytes() -> bytes:
|
||||
"""A small valid JPEG with no EXIF."""
|
||||
|
||||
img = Image.fromarray(
|
||||
(np.random.default_rng(0).random((64, 64, 3)) * 255).astype("uint8")
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def png_bytes() -> bytes:
|
||||
"""A small valid PNG."""
|
||||
|
||||
img = Image.new("RGB", (48, 48), (120, 30, 200))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jpeg_with_software(tmp_path):
|
||||
"""Write a JPEG carrying a Photoshop Software EXIF tag; return its path."""
|
||||
|
||||
from PIL import Image as PImage
|
||||
|
||||
img = PImage.new("RGB", (32, 32), (10, 20, 30))
|
||||
exif = img.getexif()
|
||||
exif[0x0131] = "Adobe Photoshop 25.0" # Software tag
|
||||
path = tmp_path / "edited.jpg"
|
||||
img.save(path, format="JPEG", exif=exif)
|
||||
return str(path)
|
||||
51
ai_platform/modules/extractors/tests/test_api.py
Normal file
51
ai_platform/modules/extractors/tests/test_api.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""End-to-end API tests via FastAPI TestClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from extractors.app import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (40, 40), (200, 10, 10)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_health():
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_ready_reports_tooling():
|
||||
r = client.get("/ready")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "ffprobe" in body and "ffmpeg" in body
|
||||
|
||||
|
||||
def test_metadata_image_runs_image_extractors():
|
||||
r = client.post(
|
||||
"/v1/metadata",
|
||||
files={"file": ("photo.png", _png(), "image/png")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["media_type"] == "image"
|
||||
assert set(body["analyses"]) >= {"integrity", "exif", "ela"}
|
||||
assert len(body["sha256"]) == 64
|
||||
assert body["execution_time_ms"] >= 0
|
||||
|
||||
|
||||
def test_metadata_rejects_empty():
|
||||
r = client.post(
|
||||
"/v1/metadata", files={"file": ("empty.png", b"", "image/png")}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
80
ai_platform/modules/extractors/tests/test_detect.py
Normal file
80
ai_platform/modules/extractors/tests/test_detect.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Object-detection tests with a fake YOLO predictor (no torch/weights)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from extractors.app import app
|
||||
from extractors.features import detect
|
||||
|
||||
|
||||
class _Box:
|
||||
def __init__(self, cls_id, conf, xyxy):
|
||||
self.cls = [cls_id]
|
||||
self.conf = [conf]
|
||||
self.xyxy = [xyxy]
|
||||
|
||||
|
||||
class _Result:
|
||||
names = {0: "person", 2: "car"}
|
||||
|
||||
def __init__(self, boxes):
|
||||
self.boxes = boxes
|
||||
|
||||
|
||||
class FakeYOLO:
|
||||
def __init__(self, boxes):
|
||||
self._boxes = boxes
|
||||
|
||||
def predict(self, img, **kwargs):
|
||||
return [_Result(self._boxes)]
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (64, 64), (0, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_summarize_counts():
|
||||
dets = [
|
||||
{"label": "person", "confidence": 0.9, "box": [0, 0, 10, 10]},
|
||||
{"label": "person", "confidence": 0.8, "box": [5, 5, 20, 20]},
|
||||
{"label": "car", "confidence": 0.7, "box": [0, 0, 30, 30]},
|
||||
]
|
||||
res = detect._summarize(dets)
|
||||
assert res.results["count"] == 3
|
||||
assert res.results["by_label"] == {"person": 2, "car": 1}
|
||||
assert "person×2" in res.evidence[0]
|
||||
|
||||
|
||||
def test_analyze_parses_and_sorts():
|
||||
fake = FakeYOLO([_Box(2, 0.6, [1, 1, 9, 9]), _Box(0, 0.95, [0, 0, 8, 8])])
|
||||
res = detect.analyze(_png(), model=fake)
|
||||
assert res.ok is True
|
||||
assert res.results["count"] == 2
|
||||
# sorted by confidence desc → person (0.95) first
|
||||
assert res.results["detections"][0]["label"] == "person"
|
||||
assert res.results["detections"][0]["confidence"] == 0.95
|
||||
|
||||
|
||||
def test_analyze_empty_image():
|
||||
res = detect.analyze(b"", model=FakeYOLO([]))
|
||||
assert res.ok is False
|
||||
|
||||
|
||||
def test_detect_endpoint_503_without_ml_extra():
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/detect", files={"file": ("x.png", _png(), "image/png")})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_detect_endpoint_with_monkeypatched_model(monkeypatch):
|
||||
monkeypatch.setattr(detect, "get_model", lambda: FakeYOLO([_Box(0, 0.9, [0, 0, 5, 5])]))
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/detect", files={"file": ("x.png", _png(), "image/png")})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["results"]["by_label"] == {"person": 1}
|
||||
22
ai_platform/modules/extractors/tests/test_ela.py
Normal file
22
ai_platform/modules/extractors/tests/test_ela.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""ELA extractor tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.features import ela
|
||||
|
||||
|
||||
def test_ela_returns_stats(tmp_path, jpeg_bytes):
|
||||
path = tmp_path / "img.jpg"
|
||||
path.write_bytes(jpeg_bytes)
|
||||
res = ela.extract(str(path))
|
||||
assert res.ok is True
|
||||
for key in ("mean_error", "max_error", "p99_error", "hot_pixel_fraction"):
|
||||
assert key in res.results
|
||||
assert 0.0 <= res.results["hot_pixel_fraction"] <= 1.0
|
||||
|
||||
|
||||
def test_ela_on_bad_input(tmp_path):
|
||||
path = tmp_path / "x.bin"
|
||||
path.write_bytes(b"\x00\x01\x02")
|
||||
res = ela.extract(str(path))
|
||||
assert res.ok is False
|
||||
33
ai_platform/modules/extractors/tests/test_exif.py
Normal file
33
ai_platform/modules/extractors/tests/test_exif.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""EXIF extractor tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from extractors.features import exif
|
||||
|
||||
|
||||
def test_no_exif_is_reported(tmp_path, png_bytes):
|
||||
path = tmp_path / "plain.png"
|
||||
path.write_bytes(png_bytes)
|
||||
res = exif.extract(str(path))
|
||||
assert res.ok is True
|
||||
assert any("No EXIF" in e for e in res.evidence)
|
||||
assert not res.anomalies
|
||||
|
||||
|
||||
def test_editing_software_flagged_as_anomaly(jpeg_with_software):
|
||||
res = exif.extract(jpeg_with_software)
|
||||
assert res.ok is True
|
||||
assert res.results.get("software", "").lower().startswith("adobe photoshop")
|
||||
assert any("Editing/generation software" in a for a in res.anomalies)
|
||||
|
||||
|
||||
def test_non_image_returns_error(tmp_path):
|
||||
path = tmp_path / "notimage.bin"
|
||||
path.write_bytes(b"not an image at all")
|
||||
res = exif.extract(str(path))
|
||||
assert res.ok is False
|
||||
assert res.error
|
||||
32
ai_platform/modules/extractors/tests/test_integrity.py
Normal file
32
ai_platform/modules/extractors/tests/test_integrity.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Integrity & signature extractor tests (no external deps)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.features import integrity
|
||||
|
||||
|
||||
def test_identifies_jpeg_and_hashes(jpeg_bytes):
|
||||
res = integrity.analyze_bytes(jpeg_bytes)
|
||||
assert res.ok is True
|
||||
assert res.results["container"] == "JPEG"
|
||||
assert len(res.results["sha256"]) == 64
|
||||
assert res.results["size_bytes"] == len(jpeg_bytes)
|
||||
assert not res.anomalies
|
||||
|
||||
|
||||
def test_identifies_png(png_bytes):
|
||||
res = integrity.analyze_bytes(png_bytes)
|
||||
assert res.results["container"] == "PNG"
|
||||
|
||||
|
||||
def test_detects_trailing_data_after_jpeg_eoi(jpeg_bytes):
|
||||
tampered = jpeg_bytes + b"\x00" * 64 # appended payload after FFD9
|
||||
res = integrity.analyze_bytes(tampered)
|
||||
assert res.results.get("trailing_bytes_after_eoi") == 64
|
||||
assert any("appended after JPEG" in a for a in res.anomalies)
|
||||
|
||||
|
||||
def test_detects_mp4_brand():
|
||||
data = b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 32
|
||||
res = integrity.analyze_bytes(data)
|
||||
assert "ISO-BMFF/MP4" in res.results["container"]
|
||||
133
ai_platform/modules/extractors/tests/test_llm_features.py
Normal file
133
ai_platform/modules/extractors/tests/test_llm_features.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Tests for LLM-delegated features (sentiment, OCR) with a fake gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from extractors.app import app
|
||||
from extractors.deps import get_llm_client
|
||||
from extractors.features import ocr, sentiment
|
||||
from extractors.features._jsonparse import extract_json_object
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
"""Stand-in for LLMClient that returns a canned completion."""
|
||||
|
||||
configured = True
|
||||
|
||||
def __init__(self, reply: str):
|
||||
self._reply = reply
|
||||
self.calls: list[list[dict]] = []
|
||||
|
||||
async def chat(self, messages, *, max_tokens=1024, temperature=0.0):
|
||||
self.calls.append(messages)
|
||||
return self._reply
|
||||
|
||||
|
||||
# --- JSON parsing --------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_json_plain():
|
||||
assert extract_json_object('{"a": 1}') == {"a": 1}
|
||||
|
||||
|
||||
def test_extract_json_fenced_with_prose():
|
||||
txt = 'Sigur!\n```json\n{"label": "negativ", "score": -0.7}\n```\nGata.'
|
||||
assert extract_json_object(txt)["label"] == "negativ"
|
||||
|
||||
|
||||
def test_extract_json_missing_raises():
|
||||
with pytest.raises(ValueError):
|
||||
extract_json_object("no json here")
|
||||
|
||||
|
||||
# --- Sentiment (unit) ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentiment_parses_model_json():
|
||||
fake = FakeLLM('{"label":"negativ","score":-0.8,"confidence":0.9,'
|
||||
'"rationale":"Ton agresiv."}')
|
||||
res = await sentiment.analyze("Ești un mincompetent total.", fake)
|
||||
assert res.ok is True
|
||||
assert res.results["label"] == "negativ"
|
||||
assert res.results["score"] == -0.8
|
||||
assert res.confidence == 0.9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentiment_clamps_and_defaults():
|
||||
fake = FakeLLM('{"label":"ceva-invalid","score":5,"confidence":2}')
|
||||
res = await sentiment.analyze("text", fake)
|
||||
assert res.results["label"] == "neutru" # invalid label → neutru
|
||||
assert res.results["score"] == 1.0 # clamped to 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentiment_empty_text():
|
||||
res = await sentiment.analyze(" ", FakeLLM("{}"))
|
||||
assert res.ok is False
|
||||
|
||||
|
||||
# --- OCR (unit) ----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_parses_text():
|
||||
fake = FakeLLM('{"text":"BREAKING: fake news","language":"en","has_text":true}')
|
||||
res = await ocr.analyze(b"\xff\xd8\xff_fake_jpeg", fake)
|
||||
assert res.ok is True
|
||||
assert res.results["text"] == "BREAKING: fake news"
|
||||
assert res.results["char_count"] == 19
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_accepts_plain_text_fallback():
|
||||
fake = FakeLLM("Doar text simplu, fără JSON")
|
||||
res = await ocr.analyze(b"img", fake)
|
||||
assert res.ok is True
|
||||
assert "text simplu" in res.results["text"]
|
||||
|
||||
|
||||
# --- API level -----------------------------------------------------------
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (32, 32), (0, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_sentiment_endpoint():
|
||||
fake = FakeLLM('{"label":"pozitiv","score":0.6,"confidence":0.8}')
|
||||
app.dependency_overrides[get_llm_client] = lambda: fake
|
||||
try:
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/sentiment", json={"text": "Ce zi frumoasă!"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["results"]["label"] == "pozitiv"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_ocr_endpoint():
|
||||
fake = FakeLLM('{"text":"STOP","language":"en","has_text":true}')
|
||||
app.dependency_overrides[get_llm_client] = lambda: fake
|
||||
try:
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/ocr", files={"file": ("x.png", _png(), "image/png")})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["results"]["text"] == "STOP"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_sentiment_endpoint_503_without_gateway():
|
||||
# Default client has no gateway URL configured → 503.
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/sentiment", json={"text": "test"})
|
||||
assert r.status_code == 503
|
||||
58
ai_platform/modules/extractors/tests/test_ner.py
Normal file
58
ai_platform/modules/extractors/tests/test_ner.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""NER tests with a fake GLiNER predictor (no torch/model download)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from extractors.app import app
|
||||
from extractors.features import ner
|
||||
|
||||
|
||||
class FakePredictor:
|
||||
"""Mimics GLiNER.predict_entities with canned spans."""
|
||||
|
||||
def __init__(self, ents):
|
||||
self._ents = ents
|
||||
|
||||
def predict_entities(self, text, labels, threshold=0.5):
|
||||
return self._ents
|
||||
|
||||
|
||||
_SAMPLE = [
|
||||
{"text": "Klaus Iohannis", "label": "persoană", "start": 0, "end": 14, "score": 0.97},
|
||||
{"text": "Guvernul României", "label": "instituție publică", "start": 20,
|
||||
"end": 37, "score": 0.91},
|
||||
]
|
||||
|
||||
|
||||
def test_ner_parses_and_sorts():
|
||||
res = ner.analyze("Klaus Iohannis și Guvernul României.", model=FakePredictor(_SAMPLE))
|
||||
assert res.ok is True
|
||||
assert res.results["count"] == 2
|
||||
assert res.results["entities"][0]["text"] == "Klaus Iohannis"
|
||||
assert res.results["by_label"]["persoană"] == 1
|
||||
assert "persoană×1" in res.evidence[0]
|
||||
|
||||
|
||||
def test_ner_empty_entities():
|
||||
res = ner.analyze("text neutru", model=FakePredictor([]))
|
||||
assert res.ok is True
|
||||
assert res.results["count"] == 0
|
||||
assert "No entities" in res.evidence[0]
|
||||
|
||||
|
||||
def test_ner_endpoint_503_without_ml_extra():
|
||||
# gliner/torch are not installed in the fast (dev) environment.
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/ner", json={"text": "Klaus Iohannis"})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_ner_endpoint_with_monkeypatched_model(monkeypatch):
|
||||
monkeypatch.setattr(ner, "get_model", lambda: FakePredictor(_SAMPLE))
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/ner", json={"text": "Klaus Iohannis și Guvernul României."})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["results"]["count"] == 2
|
||||
assert body["results"]["entities"][1]["label"] == "instituție publică"
|
||||
34
ai_platform/modules/extractors/tests/test_spectrogram.py
Normal file
34
ai_platform/modules/extractors/tests/test_spectrogram.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Spectrogram DSP tests (synthetic signals, no ffmpeg)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from extractors.features import spectrogram
|
||||
|
||||
|
||||
def test_compute_features_on_tone():
|
||||
sr = 16_000
|
||||
t = np.linspace(0, 1.0, sr, endpoint=False)
|
||||
pcm = 0.5 * np.sin(2 * np.pi * 440 * t).astype(np.float32)
|
||||
feats = spectrogram.compute_features(pcm, sr)
|
||||
assert feats["sample_rate"] == sr
|
||||
assert abs(feats["duration_s"] - 1.0) < 0.01
|
||||
assert feats["spectral_centroid_hz_mean"] > 0
|
||||
assert feats["discontinuities_s"] == []
|
||||
|
||||
|
||||
def test_detects_energy_discontinuity():
|
||||
sr = 16_000
|
||||
quiet = np.full(sr // 2, 0.001, dtype=np.float32)
|
||||
loud = (0.6 * np.sin(2 * np.pi * 300 * np.linspace(0, 0.5, sr // 2))).astype(
|
||||
np.float32
|
||||
)
|
||||
pcm = np.concatenate([quiet, loud])
|
||||
feats = spectrogram.compute_features(pcm, sr)
|
||||
assert len(feats["discontinuities_s"]) >= 1
|
||||
|
||||
|
||||
def test_empty_signal():
|
||||
feats = spectrogram.compute_features(np.array([], dtype=np.float32), 16_000)
|
||||
assert feats["frames"] == 0
|
||||
59
ai_platform/modules/extractors/tests/test_video_meta.py
Normal file
59
ai_platform/modules/extractors/tests/test_video_meta.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""ffprobe parser tests (no binary needed — fixture-driven)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.features import video_meta
|
||||
|
||||
_FIXTURE = {
|
||||
"format": {
|
||||
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
|
||||
"duration": "12.500",
|
||||
"bit_rate": "2500000",
|
||||
"tags": {"encoder": "Lavf58.76.100"},
|
||||
},
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"avg_frame_rate": "30000/1001",
|
||||
"pix_fmt": "yuv420p",
|
||||
},
|
||||
{
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"sample_rate": "48000",
|
||||
"channels": 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_fps_fractional():
|
||||
assert video_meta._parse_fps("30000/1001") == 29.97
|
||||
assert video_meta._parse_fps("25/1") == 25.0
|
||||
assert video_meta._parse_fps("0/0") is None
|
||||
assert video_meta._parse_fps(None) is None
|
||||
|
||||
|
||||
def test_parse_ffprobe_full():
|
||||
res = video_meta.parse_ffprobe(_FIXTURE)
|
||||
assert res.ok is True
|
||||
assert res.results["video"]["codec"] == "h264"
|
||||
assert res.results["video"]["width"] == 1920
|
||||
assert res.results["video"]["fps"] == 29.97
|
||||
assert res.results["bitrate_bps"] == 2500000
|
||||
assert res.results["has_audio"] is True
|
||||
assert res.results["audio"]["codec"] == "aac"
|
||||
# Lavf encoder tag should be flagged as a re-encoding tool.
|
||||
assert any("Lavf" in a for a in res.anomalies)
|
||||
|
||||
|
||||
def test_parse_ffprobe_audio_only():
|
||||
res = video_meta.parse_ffprobe(
|
||||
{"format": {"format_name": "wav"}, "streams": [{"codec_type": "audio"}]}
|
||||
)
|
||||
assert res.ok is True
|
||||
assert res.results["has_audio"] is True
|
||||
assert "video" not in res.results
|
||||
Loading…
Add table
Add a link
Reference in a new issue