Ctrl K
ComunidadGuías…

CONTRIBUTING.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Contributing to OpenNVR AI Adapter

Thanks for being here. This repo is two things in one: the `opennvr-adapter-sdk` PyPI package (the boilerplate-free way to ship a contract-compliant adapter) and a reference monolith server that bundles several adapters in one process. There's a contribution path for each, and the recommended path depends on what you're trying to do.

## Which path are you on?

If you're shipping a new adapter, use **Path A — the SDK plus a standalone container.** Your adapter becomes its own service with its own `pyproject.toml`, declared permissions, and lifecycle. Nothing in this repo needs to change, and the adapter ships under whatever licence you choose. The seven shipped adapters in `adapters/<name>/` are the reference implementations — find the one with the closest body shape and copy the structure. Bug fixes to those services also live on this path.

If you're extending the bundled reference monolith — `app/adapters/<name>/` — use **Path B**, the `BaseAdapter` plugin pattern. This path stays supported for contributors keeping the monolith useful, but new adapters should target Path A: a standalone container is contract-compliant out of the box, ships independently, and survives any future change to the monolith's internals.

If you're improving the SDK itself, work in [`opennvr_adapter_sdk/`](opennvr_adapter_sdk/) and read its [README](opennvr_adapter_sdk/README.md) for the surface and conventions.

## Development setup

```bash
git clone https://github.com/open-nvr/ai-adapter.git
cd ai-adapter

uv venv && source .venv/bin/activate     # Windows: .venv\Scripts\activate
uv sync --extra all --extra cpu --extra dev

# Download model weights for the adapters you'll touch
uv run python download_models.py

# Run the legacy monolith server (still the simplest way to dogfood everything)
uv run uvicorn app.main:app --reload --port 9100

# Run tests
uv run pytest tests/ -v
```

Per-adapter services live in `adapters/<name>/` and are their own
deployable units. Each one has its own `pyproject.toml`, `service.py`,
`main.py`, and tests. They're built and published as separate GHCR
images by `.github/workflows/publish-images.yml`.

## Anti-bloat rules — read this first

These apply to **both paths**. They exist because the project becomes
unusable for everyone if every adapter drags in PyTorch, transformers,
and onnxruntime by default.

### Rule 1: Heavy ML libraries go in optional dependency groups

Never add an ML library to `[project.dependencies]`. That section is
shared core — keep it small.

```toml
# pyproject.toml

# WRONG — forces ~500 MB on every deployment
[project.dependencies]
"my-ml-library>=1.0.0"

# RIGHT — only installed when the user opts in
[project.optional-dependencies]
my_adapter = ["my-ml-library>=1.0.0"]

# Also add to the "all" group so `uv sync --extra all` still works
all = ["opennvr-ai-adapter[yolo,yolo11,face,blip,huggingface,my_adapter]"]
```

For Path A (standalone container), the equivalent is your per-adapter
`pyproject.toml` — keep it tight, list only what the adapter actually
needs. The Dockerfile is built from this so every megabyte counts.

### Rule 2: Import heavy libraries inside `load()` / `load_model()`, not at the top

In Path A, `AdapterService.load()` is the lifecycle hook the SDK
guarantees is called once before the service goes ready. In Path B,
`BaseAdapter.load_model()` is the equivalent. Either way, defer heavy
imports until you actually need them.

```python
# WRONG — torch loads even if this adapter is never used
import torch
from transformers import AutoModel

class MyService(AdapterService):
    def load(self):
        self._model = AutoModel.from_pretrained("...")

# RIGHT — torch loads only when the adapter is actually instantiated
class MyService(AdapterService):
    def load(self):
        import torch
        from transformers import AutoModel
        self._model = AutoModel.from_pretrained("...")
```

### Rule 3: What happens when a dep isn't installed

Path A (standalone): your container's `pip install` lists everything;
the adapter either runs or fails to start. No surprises.

Path B (monolith): `PluginManager` catches the `ImportError` at
discovery time and logs `Skipping 'app.adapters.vision.my_adapter':
optional dependency 'my_lib' not installed. Install with: uv sync
--extra my_adapter`. All other adapters load fine; yours just doesn't
appear in `/capabilities`. No crash, no stack trace.

## Path A — SDK + standalone container

The end state is a Docker image that runs a single `AdapterService`
behind the FastAPI app `AdapterApp` builds for you. The SDK README
walks the minimum viable example in detail; the short version:

```python
# my_adapter/main.py
from datetime import datetime, timezone
from opennvr_adapter_sdk import (
    AdapterApp, AdapterService, BodyShape, BODY_BYTES_KEY,
    HardwareEvaluationResponse, HardwareVerdict,
    InferResponse, ModelInfo,
)

class MyService(AdapterService):
    def load(self):
        import onnxruntime as ort
        self._model = ort.InferenceSession("/weights/my-model.onnx")

    def is_ready(self) -> bool:
        return self._model is not None

    def fingerprint(self) -> str | None:
        return "sha256:..."     # sha256 of the weights file

    def model_info(self) -> ModelInfo:
        return ModelInfo(
            name="my-model", version="1.0.0",
            framework="onnx", fingerprint=self.fingerprint(),
        )

    def hardware_evaluation(self) -> HardwareEvaluationResponse:
        return HardwareEvaluationResponse(
            verdict=HardwareVerdict.OK, reasoning="ok",
            checked_at=datetime.now(timezone.utc), details={},
        )

    def infer(self, payload) -> InferResponse:
        frame_bytes = payload[BODY_BYTES_KEY]
        # ... run your model ...
        return InferResponse(
            model_name="my-model", model_version="1.0.0",
            inference_ms=42,
            result={"detections": [{"label": "fire", "confidence": 0.95}]},
        )

app = AdapterApp(
    service=MyService(),
    name="my-adapter", version="1.0.0", vendor="me", license="MIT",
    tasks_advertised=["fire_detection"],
    body_shape=BodyShape.IMAGE,
).fastapi_app
```

```bash
OPENNVR_ADAPTER_TOKEN=dev-token \
  uvicorn my_adapter.main:app --host 0.0.0.0 --port 9001
```

Then verify your adapter is contract-compliant:

```bash
python -m conformance http://localhost:9001 --token dev-token
```

The conformance suite checks every required endpoint, the `/health`
state machine, the failure envelope shape, and the multipart/JSON body
parser. If your service passes conformance, KAI-C will register it.

**Reference implementations** to copy-from in this repo:

- `adapters/yolov8/` — `BodyShape.IMAGE`, WebSocket streaming
- `adapters/whisper/` — `BodyShape.AUDIO`, audio decode pipeline
- `adapters/piper/` — `BodyShape.TEXT`, custom `/voices` route
- `adapters/insightface/` — `BodyShape.IMAGE`, face DB enrollment endpoints
- `adapters/blip/` — `BodyShape.IMAGE`, transformer model
- `adapters/fast_plate_ocr/` — `BodyShape.IMAGE`, OCR over a pre-cropped plate
- `adapters/bytetrack/` — `BodyShape.TEXT`, stateful post-processor over an upstream detector

Full SDK reference: [`opennvr_adapter_sdk/README.md`](opennvr_adapter_sdk/README.md).

## Path B — Add to the reference monolith

The monolith uses a plugin pattern that predates the contract. It's
still supported because it's simpler for contributors who just want to
extend the bundled server without standing up a separate container.

### Option B1 — Adapter only (raw output)

The simplest shape: your adapter's raw output goes straight to the
client. No Pydantic validation.

```python
# app/adapters/vision/my_adapter.py
from app.adapters.base import BaseAdapter

class MyAdapter(BaseAdapter):
    name = "my_adapter"     # Unique; used in TASK_ADAPTER_MAP routing
    type = "vision"         # "vision" or "llm"

    def __init__(self, config=None):
        self.config = config or {}
        self.model = None   # Must start as None for lazy loading

    def load_model(self):
        # Import heavy libraries HERE, not at the top of the file.
        import my_ml_library
        self.model = my_ml_library.load(self.config.get("weights_path"))

    def infer_local(self, input_data):
        from app.utils.image_utils import load_image_from_uri
        img = load_image_from_uri(input_data["frame"]["uri"])
        return self.model.predict(img)
```

```toml
# pyproject.toml — add the optional dep group
[project.optional-dependencies]
my_adapter = ["my-ml-library>=1.0.0"]
all = ["opennvr-ai-adapter[yolo,yolo11,face,blip,huggingface,my_adapter]"]
```

```python
# app/config/config.py — register routing
TASK_ADAPTER_MAP["my_task"] = "my_adapter"
CONFIG["adapters"]["my_adapter"] = {"enabled": True, "weights_path": "my_model.bin"}
```

```python
# download_models.py — only if your model needs a local weights file
MODEL_REGISTRY["my_adapter"] = [
    {
        "filename": "my_model.bin",
        "url": "https://github.com/me/my-models/releases/download/v1/my_model.bin",
        "size_hint": "~50 MB",
    }
]
```

Restart the server. `PluginManager` auto-discovers your class. Done.

### Option B2 — Adapter + Pydantic-validated task

Same as B1 plus a task pipeline that validates the adapter output
against a Pydantic schema before returning it.

```python
# app/schemas/responses.py
class MyResponse(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)
    label: str
    confidence: float = Field(ge=0.0, le=1.0)
    bbox: list[int]
```

```python
# app/pipelines/my_task/task.py
from app.interfaces.task import BaseTask
from app.schemas.responses import MyResponse

class MyTask(BaseTask):
    name = "my_task"        # Must match the key in TASK_ADAPTER_MAP

    def process(self, image, adapter):
        raw = adapter.predict(image)
        return MyResponse(**raw)
```

Full Path B tutorial with all the details:
[`docs/PLUGIN_DEVELOPMENT.md`](docs/PLUGIN_DEVELOPMENT.md).

## How auto-discovery works (Path B only)

At startup `PluginManager.discover_plugins()`:

1. Walks every non-private `.py` file under `app/adapters/` and `app/pipelines/`.
2. Attempts `importlib.import_module(...)` on each.
   - `ImportError` → logs a helpful skip message (missing optional dep — expected).
   - Other error → WARNING (real bug in your code).
   - Success → registers any `BaseAdapter` / `BaseTask` subclasses by their `name`.
3. Logs a summary: `Discovered 3 adapters: [insightface_adapter, yolov8_adapter, yolov11_adapter]`.

No imports in `main.py`. No registration calls. Just create the file
and restart. (Path A doesn't need this — your service is the only
class in the container.)

## Installation profiles (monolith)

Per-profile dependencies and sizes are documented in the main [README](README.md#install-profiles). The relevant addition for contributors is the `--extra dev` group, which adds the test suite and tooling (around 4 GB total with `--extra all --extra cpu --extra dev`).

## Testing

```bash
# Monolith suite
uv run pytest tests/ -v

# Per-adapter suites
uv run pytest adapters/yolov8/tests/ -v
uv run pytest adapters/whisper/tests/ -v
# ...etc

# With coverage
uv run pytest tests/ --cov=app --cov-report=term-missing

# Conformance check against a running adapter
python -m conformance http://localhost:9001 --token dev-token
```

When adding a new adapter, please add tests in either `tests/` (Path B)
or `adapters/<name>/tests/` (Path A).

## Pre-PR checklist

### For both paths

- [ ] Heavy ML libraries imported inside `load()` / `load_model()`, not module top.
- [ ] Optional dep group declared (or per-adapter `pyproject.toml` updated).
- [ ] Tests added for the new behaviour.
- [ ] Tested with `curl POST /infer` locally.
- [ ] All tests pass: `uv run pytest -v`.

### Path A additions

- [ ] Adapter passes the conformance suite (`python -m conformance ...`).
- [ ] Per-adapter `pyproject.toml` lists only what the adapter actually needs.
- [ ] Per-adapter `Dockerfile` builds and produces a runnable image.

### Path B additions

- [ ] `BaseAdapter` subclass has `name` and `type` class attributes.
- [ ] Model loads in `load_model()`, not `__init__()`.
- [ ] Config updated: `TASK_ADAPTER_MAP` + `CONFIG["adapters"]`.
- [ ] If local weights needed: `MODEL_REGISTRY` entry added in `download_models.py`.
- [ ] If returning structured output: Pydantic response model added to `app/schemas/responses.py`.
- [ ] Response JSON example added to `docs/API_REFERENCE.md`.

## Commit messages

[Conventional Commits](https://www.conventionalcommits.org/). Common
scopes: `sdk`, `adapters/<name>`, `app`, `conformance`, `docs`.

```text
feat(adapters/yolov8): support WebSocket streaming protocol
fix(sdk): correct BodyShape.AUDIO multipart field name
docs(contributing): clarify Path A vs Path B
```

## Code of conduct

We want this to stay a project people enjoy contributing to. The expectation is welcoming language and respectful disagreement; harassment, doxxing, and inflammatory off-topic posting are not part of that. Report violations to **contact@opennvr.org** — reports are confidential.

## License

By contributing, you agree your contributions are licensed under:

- **AGPL v3** for code in `app/`, `adapters/<name>/`, and the rest of
  the reference server.
- **Apache-2.0** for code in `opennvr_adapter_sdk/` — the SDK is
  intentionally permissive so adapter authors can publish under any
  compatible license, including proprietary.