Add safe YOLO model env configurator
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-06 16:43:53 +02:00
parent c1f81adeea
commit 72ee6233a6
8 changed files with 414 additions and 0 deletions
+7
View File
@@ -7,6 +7,13 @@
# Changelog # Changelog
## Sprint 117 Safe local YOLO model activation (2026-07-06)
- Added `scripts/configure_yolo_model.py` to configure an existing local YOLO model into the Unraid/Tower `.env` file without downloading weights, loading a model or running inference.
- The helper refuses no-model and ambiguous multi-model states, and only applies env changes when `--apply` is provided.
- Documented the Tower flow for placing model files under `/mnt/user/appdata/geointel/models`, applying the env update and restarting/redeploying the all-in-one container.
- Added regression coverage for no-model, multi-model, dry-run and env-file apply behavior.
## Sprint 116 Operational GIS map workflow (2026-07-04) ## Sprint 116 Operational GIS map workflow (2026-07-04)
- Switched the default MapLibre basemap from demo tiles to an OpenStreetMap road raster basemap with visible attribution while keeping `VITE_MAP_STYLE_URL` as the production override. - Switched the default MapLibre basemap from demo tiles to an OpenStreetMap road raster basemap with visible attribution while keeping `VITE_MAP_STYLE_URL` as the production override.
+9
View File
@@ -263,6 +263,15 @@ YOLO_ENABLED=true
YOLO_MODEL_PATH=/app/models/local-model.pt YOLO_MODEL_PATH=/app/models/local-model.pt
``` ```
The root helper can write those values safely after a local model is placed:
```bash
python scripts/configure_yolo_model.py \
--models-dir /mnt/user/appdata/geointel/models \
--env-file /mnt/user/appdata/geointel/.env \
--apply
```
The smoke loads only the supplied local model file, does not run inference and The smoke loads only the supplied local model file, does not run inference and
does not download weights. does not download weights.
@@ -0,0 +1,97 @@
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPT = ROOT / "scripts" / "configure_yolo_model.py"
def _run_configure(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), *args, "--json"],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
def test_configure_yolo_model_reports_no_local_model(tmp_path: Path) -> None:
result = _run_configure("--models-dir", str(tmp_path), "--env-file", str(tmp_path / ".env"))
payload = json.loads(result.stdout)
assert result.returncode == 2
assert payload["status"] == "no_model_found"
assert payload["will_download_models"] is False
assert payload["env_updates"] == {}
assert not (tmp_path / ".env").exists()
def test_configure_yolo_model_refuses_ambiguous_model_selection(tmp_path: Path) -> None:
(tmp_path / "a.pt").write_bytes(b"model-a")
(tmp_path / "b.onnx").write_bytes(b"model-b")
result = _run_configure("--models-dir", str(tmp_path), "--env-file", str(tmp_path / ".env"))
payload = json.loads(result.stdout)
assert result.returncode == 3
assert payload["status"] == "multiple_models_found"
assert len(payload["candidates"]) == 2
assert payload["env_updates"] == {}
assert not (tmp_path / ".env").exists()
def test_configure_yolo_model_dry_run_selects_single_model(tmp_path: Path) -> None:
model_path = tmp_path / "nested" / "detector.pt"
model_path.parent.mkdir()
model_path.write_bytes(b"model")
result = _run_configure(
"--models-dir",
str(tmp_path),
"--container-model-dir",
"/app/models",
"--env-file",
str(tmp_path / ".env"),
)
payload = json.loads(result.stdout)
assert result.returncode == 0
assert payload["status"] == "ready_to_apply"
assert payload["selected_host_model_path"] == str(model_path)
assert payload["selected_container_model_path"] == "/app/models/nested/detector.pt"
assert payload["env_updates"]["GEOINTEL_INSTALL_AI"] == "true"
assert payload["env_updates"]["YOLO_ENABLED"] == "true"
assert payload["env_updates"]["YOLO_MODEL_PATH"] == "/app/models/nested/detector.pt"
assert payload["will_download_models"] is False
assert not (tmp_path / ".env").exists()
def test_configure_yolo_model_apply_updates_existing_env_file(tmp_path: Path) -> None:
model_path = tmp_path / "detector.engine"
model_path.write_bytes(b"model")
env_file = tmp_path / ".env"
env_file.write_text("GEOINTEL_FRONTEND_PORT=1202\nYOLO_ENABLED=false\n", encoding="utf-8")
result = _run_configure(
"--models-dir",
str(tmp_path),
"--container-model-dir",
"/app/models",
"--env-file",
str(env_file),
"--apply",
)
payload = json.loads(result.stdout)
assert result.returncode == 0
assert payload["status"] == "applied"
contents = env_file.read_text(encoding="utf-8")
assert "GEOINTEL_FRONTEND_PORT=1202" in contents
assert "GEOINTEL_INSTALL_AI=true" in contents
assert "YOLO_ENABLED=true" in contents
assert "YOLO_MODEL_PATH=/app/models/detector.engine" in contents
+30
View File
@@ -91,6 +91,36 @@ libraries needed for Ultralytics imports; it still never downloads model weights
`YOLO_CONFIG_DIR` defaults to `/app/storage/ultralytics`, a writable persistent `YOLO_CONFIG_DIR` defaults to `/app/storage/ultralytics`, a writable persistent
path, so Ultralytics settings do not fall back to root user config directories. path, so Ultralytics settings do not fall back to root user config directories.
To safely configure an existing local YOLO model on Tower, place one supported
model file (`.pt`, `.onnx` or `.engine`) under:
```text
/mnt/user/appdata/geointel/models
```
Then run a dry-run first:
```bash
cd /mnt/user/appdata/geointel
python scripts/configure_yolo_model.py \
--models-dir /mnt/user/appdata/geointel/models \
--env-file .env
```
Apply only after the selected host and container paths are correct:
```bash
python scripts/configure_yolo_model.py \
--models-dir /mnt/user/appdata/geointel/models \
--env-file .env \
--apply
bash deploy/unraid/run-dockerman-container.sh
```
If multiple model files are present, add `--model-file /mnt/user/appdata/geointel/models/<name>.pt`.
The helper does not download weights, does not load a model and does not run
inference; it only updates the env file for the mounted local model.
Validate: Validate:
```bash ```bash
+32
View File
@@ -4279,6 +4279,38 @@ Limitations:
Next recommended pass: Next recommended pass:
- Continue with V1 usability work that reduces operator confusion without expanding frozen product scope. - Continue with V1 usability work that reduces operator confusion without expanding frozen product scope.
## Sprint 117 Safe local YOLO model activation (2026-07-06)
Changed:
- Added `scripts/configure_yolo_model.py` to configure an existing local YOLO model into the deployment `.env` file without downloading model weights, loading a model or running inference.
- The helper scans a mounted model directory for `.pt`, `.onnx` and `.engine` files, refuses no-model and ambiguous multi-model states, and writes env updates only when `--apply` is provided.
- Added regression coverage in `backend/tests/test_sprint119_yolo_model_configuration.py` for no local model, ambiguous model selection, dry-run single model selection and env-file apply behavior.
- Updated `scripts/README.md`, `deploy/unraid/README.md`, `backend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
Tested:
- Red step: `python -m pytest backend\tests\test_sprint119_yolo_model_configuration.py -q` failed while `scripts/configure_yolo_model.py` was absent.
- `python -m pytest backend\tests\test_sprint119_yolo_model_configuration.py -q` (`4 passed`)
- `python -m py_compile scripts\configure_yolo_model.py`
- `python -m compileall backend/app`
- `cd backend && python -m pytest -q` (`375 passed`, existing Pydantic protected-namespace warnings remain)
- `cd frontend && npm run typecheck`
- `cd frontend && npm run build`
- `bash scripts/run_readiness_check.sh` (`Run readiness check passed`)
- `cd backend && python -m alembic heads` (`202606120900 (head)`)
- `cd backend && python -m alembic upgrade head --sql`
- `bash -n scripts/live_migration_smoke.sh`
- `bash -n scripts/deploy_tower.sh`
Open:
- No local YOLO model file is currently present on Tower under `/mnt/user/appdata/geointel/models`, so runtime YOLO activation remains intentionally not configured until the operator places a real model file.
Limitations:
- Operational configuration helper only; no AI inference behavior, model download behavior, backend API contract, database migration, provider fetching or frontend product workflow changed.
- If multiple local model files are present, the operator must choose one with `--model-file` so GeoIntel does not silently activate the wrong model.
Next recommended pass:
- Place a real local model under `/mnt/user/appdata/geointel/models`, run the configurator with `--apply`, redeploy/restart the all-in-one container, then run the YOLO preflight with `--check-model-load` before any detection test run.
## Sprint 104 AI Lab action guardrails (2026-06-24) ## Sprint 104 AI Lab action guardrails (2026-06-24)
Changed: Changed:
+1
View File
@@ -388,3 +388,4 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Add Map workspace QA/QC evidence drilldown handoff for saved selection comparisons. - [x] Add Map workspace QA/QC evidence drilldown handoff for saved selection comparisons.
- [x] Persist QA/QC feature-level evidence for matches, false positives and false negatives. - [x] Persist QA/QC feature-level evidence for matches, false positives and false negatives.
- [x] Render persisted QA/QC feature-level evidence as Map workspace overlays. - [x] Render persisted QA/QC feature-level evidence as Map workspace overlays.
- [x] Add safe local YOLO model env configuration helper for Unraid/Tower runtime activation.
+24
View File
@@ -147,6 +147,30 @@ For Unraid/all-in-one deployments, place model files under
`GEOINTEL_MODELS_PATH` so they appear in the container under `/app/models`, then `GEOINTEL_MODELS_PATH` so they appear in the container under `/app/models`, then
set `YOLO_ENABLED=true` and `YOLO_MODEL_PATH=/app/models/<model>.pt`. set `YOLO_ENABLED=true` and `YOLO_MODEL_PATH=/app/models/<model>.pt`.
Configure the Unraid/Tower env file from an existing local model without
downloading weights or running inference:
```bash
python scripts/configure_yolo_model.py \
--models-dir /mnt/user/appdata/geointel/models \
--env-file /mnt/user/appdata/geointel/.env
```
If exactly one supported model file (`.pt`, `.onnx` or `.engine`) is present,
apply the env update explicitly:
```bash
python scripts/configure_yolo_model.py \
--models-dir /mnt/user/appdata/geointel/models \
--env-file /mnt/user/appdata/geointel/.env \
--apply
```
The configurator refuses to proceed when no model exists or when multiple model
files are present without `--model-file`. It writes only
`GEOINTEL_INSTALL_AI=true`, `YOLO_ENABLED=true` and the mounted
`YOLO_MODEL_PATH`.
Clean old offline demo export artifacts without touching uploaded source data: Clean old offline demo export artifacts without touching uploaded source data:
```bash ```bash
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Iterable
SUPPORTED_MODEL_SUFFIXES = {".pt", ".onnx", ".engine"}
ENV_KEYS = ("GEOINTEL_INSTALL_AI", "YOLO_ENABLED", "YOLO_MODEL_PATH")
def _candidate_paths(models_dir: Path) -> list[Path]:
if not models_dir.exists():
return []
return sorted(
path.resolve()
for path in models_dir.rglob("*")
if path.is_file() and path.suffix.lower() in SUPPORTED_MODEL_SUFFIXES
)
def _container_path(host_model_path: Path, models_dir: Path, container_model_dir: str) -> str:
relative = host_model_path.resolve().relative_to(models_dir.resolve())
base = container_model_dir.rstrip("/")
return f"{base}/{relative.as_posix()}" if relative.as_posix() else base
def _read_env_lines(env_file: Path) -> list[str]:
if not env_file.exists():
return []
return env_file.read_text(encoding="utf-8").splitlines()
def _update_env_file(env_file: Path, updates: dict[str, str]) -> None:
existing_lines = _read_env_lines(env_file)
seen: set[str] = set()
next_lines: list[str] = []
for line in existing_lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in line:
next_lines.append(line)
continue
key = line.split("=", 1)[0].strip()
if key in updates:
next_lines.append(f"{key}={updates[key]}")
seen.add(key)
else:
next_lines.append(line)
for key, value in updates.items():
if key not in seen:
next_lines.append(f"{key}={value}")
env_file.parent.mkdir(parents=True, exist_ok=True)
env_file.write_text("\n".join(next_lines).rstrip() + "\n", encoding="utf-8")
def _base_payload(args: argparse.Namespace, candidates: Iterable[Path]) -> dict[str, object]:
return {
"models_dir": str(Path(args.models_dir).resolve()),
"env_file": str(Path(args.env_file).resolve()),
"candidates": [str(path) for path in candidates],
"selected_host_model_path": None,
"selected_container_model_path": None,
"env_updates": {},
"apply": args.apply,
"applied": False,
"docker_restart_required": False,
"will_download_models": False,
"will_run_inference": False,
}
def _emit(payload: dict[str, object], *, as_json: bool) -> None:
if as_json:
print(json.dumps(payload, indent=2, sort_keys=True))
return
print(f"status: {payload['status']}")
print(f"message: {payload['message']}")
if payload.get("selected_host_model_path"):
print(f"host model: {payload['selected_host_model_path']}")
print(f"container model: {payload['selected_container_model_path']}")
if payload.get("env_updates"):
print("env updates:")
for key, value in payload["env_updates"].items(): # type: ignore[union-attr]
print(f" {key}={value}")
def configure(args: argparse.Namespace) -> tuple[int, dict[str, object]]:
models_dir = Path(args.models_dir).resolve()
env_file = Path(args.env_file).resolve()
candidates = _candidate_paths(models_dir)
payload = _base_payload(args, candidates)
if args.model_file:
selected = Path(args.model_file).resolve()
if not selected.exists() or not selected.is_file():
payload.update(
{
"status": "model_not_found",
"message": "The requested YOLO model file does not exist.",
}
)
return 2, payload
if selected.suffix.lower() not in SUPPORTED_MODEL_SUFFIXES:
payload.update(
{
"status": "unsupported_model_file",
"message": "The requested YOLO model file type is not supported.",
}
)
return 4, payload
try:
selected.relative_to(models_dir)
except ValueError:
payload.update(
{
"status": "model_outside_models_dir",
"message": "The requested model must be inside the mounted models directory.",
}
)
return 4, payload
elif not candidates:
payload.update(
{
"status": "no_model_found",
"message": "No local YOLO model file was found. Place a .pt, .onnx or .engine file in the models directory first.",
}
)
return 2, payload
elif len(candidates) > 1:
payload.update(
{
"status": "multiple_models_found",
"message": "Multiple model files were found. Re-run with --model-file to select one explicitly.",
}
)
return 3, payload
else:
selected = candidates[0]
selected_container_path = _container_path(selected, models_dir, args.container_model_dir)
updates = {
"GEOINTEL_INSTALL_AI": "true",
"YOLO_ENABLED": "true",
"YOLO_MODEL_PATH": selected_container_path,
}
payload.update(
{
"status": "ready_to_apply",
"message": "A local YOLO model was selected. Re-run with --apply to update the environment file.",
"selected_host_model_path": str(selected),
"selected_container_model_path": selected_container_path,
"env_updates": updates,
}
)
if args.apply:
_update_env_file(env_file, updates)
payload.update(
{
"status": "applied",
"message": "Environment file updated. Rebuild or restart the GeoIntel container to activate YOLO.",
"applied": True,
"docker_restart_required": True,
}
)
return 0, payload
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Configure GeoIntel to use an existing local YOLO model without downloading or running inference."
)
parser.add_argument(
"--models-dir",
default=os.environ.get("GEOINTEL_MODELS_PATH", "models"),
help="Host directory that is mounted into the container as the model directory.",
)
parser.add_argument(
"--container-model-dir",
default="/app/models",
help="Container path where --models-dir is mounted.",
)
parser.add_argument(
"--env-file",
default=".env",
help="Environment file to update when --apply is provided.",
)
parser.add_argument(
"--model-file",
help="Explicit model file to select when multiple local models exist.",
)
parser.add_argument("--apply", action="store_true", help="Write the required YOLO env values to --env-file.")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
exit_code, payload = configure(args)
_emit(payload, as_json=args.json)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())