Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View 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

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

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

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

View 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

View 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

View 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"

View 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"

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

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

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

View file

@ -0,0 +1 @@
"""Feature extractors (CPU-only, deterministic)."""

View file

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

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

View file

@ -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

View file

@ -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,
)

View 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,
)

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1 @@
"""HTTP routers."""

View file

@ -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)),
}

View file

@ -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

View 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

View file

@ -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

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

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

View 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

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

View 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

View 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

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

View 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

View 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ă"

View 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

View 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