diff --git a/CHANGELOG.md b/CHANGELOG.md index d93a5490..04fbc225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 163 Guarded YOLO candidate activation (2026-07-11) + +- Added `scripts/activate_promoted_yolo_candidate.py` to validate a promotion report and exact candidate key before emitting YOLO `.env` activation updates. +- The helper supports dry-run by default and writes `.env` only with `--apply`; it does not download weights, load models or run inference. +- Updated Detection Lab operator profiles: balanced `0.15` remains candidate-only, while conservative `0.35` is marked as the promoted profile backed by the split-background pure-empty gate. +- Added tests for dry-run activation, `.env` apply behavior, rejected report handling and promoted UI profile status. +- No API contract, database migration, provider fetching, fake detections, model file mutation or automatic runtime activation was introduced. + ## Sprint 162 Split-background promotion runtime pass (2026-07-11) - Hardened split-background preflight compatibility for legacy operator manifests by deriving missing background categories from `reference_feature_count`. diff --git a/backend/README.md b/backend/README.md index b7a51e77..bbba759f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -275,8 +275,23 @@ python scripts/configure_yolo_model.py \ --apply ``` -The smoke loads only the supplied local model file, does not run inference and -does not download weights. +When a promotion report recommends an exact model/tile/threshold candidate, +prefer the guarded activation helper. It validates the report, checks the local +model asset and writes `.env` only when `--apply` is supplied: + +```bash +python scripts/activate_promoted_yolo_candidate.py \ + --promotion-report /mnt/user/appdata/geointel/artifacts/detection-model-promotion/split-aware/aoi1024bg512r3e50-high-threshold-split-20260710T222934Z/detection_model_promotion_report.json \ + --candidate-key 'geointel-building-yolov8s-aoi1024bg512r3e50-pt|512|64|0.35' \ + --models-dir /mnt/user/appdata/geointel/models \ + --env-file /mnt/user/appdata/geointel/.env \ + --json +``` + +Add `--apply` only after reviewing the emitted env updates. The smoke and +activation helpers load no model by default, run no inference and do not +download weights. Restart or rebuild the runtime after applying because the +active model is read from `YOLO_MODEL_PATH`. Operator-only local training preparation is available when real public model candidates are too weak for the target imagery. It is not a browser feature and diff --git a/backend/tests/test_sprint155_detection_operator_profiles.py b/backend/tests/test_sprint155_detection_operator_profiles.py index 36c5c7b6..ff3b4f7b 100644 --- a/backend/tests/test_sprint155_detection_operator_profiles.py +++ b/backend/tests/test_sprint155_detection_operator_profiles.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -def test_detection_operator_profiles_define_explicit_non_default_yolo_candidates() -> None: +def test_detection_operator_profiles_define_explicit_yolo_candidates_and_promoted_profile() -> None: profiles = ROOT / "frontend" / "src" / "components" / "detection" / "detectionProfiles.ts" source = profiles.read_text(encoding="utf-8") @@ -15,7 +15,10 @@ def test_detection_operator_profiles_define_explicit_non_default_yolo_candidates assert "confidenceThreshold: 0.15" in source assert "confidenceThreshold: 0.35" in source assert "defaultApproved: false" in source + assert "defaultApproved: true" in source assert "promotionRecommendation: 'none'" in source + assert "promotionRecommendation: 'promote_candidate'" in source + assert "pure-empty gate passed" in source assert "false-positive pressure" in source @@ -29,6 +32,7 @@ def test_detection_lab_surfaces_profiles_as_deliberate_operator_actions() -> Non assert "profile.displayName" in lab assert "profile.confidenceThreshold" in lab assert "Candidate only - not default-approved" in lab + assert "default-approved" in lab assert "Apply profile" in lab assert "onApplyOperatorProfile(profile)" in lab assert "Recommended starting threshold: 0.25" not in lab diff --git a/backend/tests/test_sprint162_promoted_model_activation.py b/backend/tests/test_sprint162_promoted_model_activation.py new file mode 100644 index 00000000..9151f5f3 --- /dev/null +++ b/backend/tests/test_sprint162_promoted_model_activation.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "activate_promoted_yolo_candidate.py" +CANDIDATE_KEY = "geointel-building-yolov8s-aoi1024bg512r3e50-pt|512|64|0.35" + + +def _write_model(models_dir: Path) -> Path: + model_file = models_dir / "geointel-building-yolov8s-aoi1024bg512r3e50.pt" + model_file.parent.mkdir(parents=True) + model_file.write_bytes(b"local promoted model") + return model_file + + +def _write_report(path: Path, *, promotion_status: str = "promote_candidate") -> None: + rejection_reasons = [] if promotion_status == "promote_candidate" else ["background_false_positive_pressure"] + path.write_text( + json.dumps( + { + "gates": { + "max_background_detections_per_sample": 0, + "min_background_samples": 2, + "min_mean_f1": 0.25, + "min_positive_samples": 7, + }, + "recommended_candidate": { + "background_sample_count": 3, + "background_samples": [["arendonk_heide", 0], ["lommel_heide", 0], ["postel_bos", 0]], + "candidate_key": CANDIDATE_KEY, + "max_background_detections": 0, + "mean_f1": 0.32086574003576274, + "mean_precision": 0.8400057773951873, + "mean_recall": 0.20213514285308795, + "model_asset_id": "geointel-building-yolov8s-aoi1024bg512r3e50-pt", + "positive_sample_count": 7, + "promotion_status": promotion_status, + "rejection_reasons": rejection_reasons, + "threshold": 0.35, + "tile_overlap": 64, + "tile_size": 512, + "total_background_detections": 0, + }, + } + ), + encoding="utf-8", + ) + + +def _run_activation(tmp_path: Path, *extra_args: str) -> subprocess.CompletedProcess[str]: + models_dir = tmp_path / "models" + _write_model(models_dir) + report_path = tmp_path / "promotion_report.json" + _write_report(report_path) + env_file = tmp_path / ".env" + env_file.write_text("GEOINTEL_ENV=production\nYOLO_ENABLED=false\n", encoding="utf-8") + + return subprocess.run( + [ + "python", + str(SCRIPT), + "--promotion-report", + str(report_path), + "--candidate-key", + CANDIDATE_KEY, + "--models-dir", + str(models_dir), + "--container-model-dir", + "/app/models", + "--env-file", + str(env_file), + "--json", + *extra_args, + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + +def test_promoted_yolo_activation_dry_run_validates_report_and_model(tmp_path: Path) -> None: + result = _run_activation(tmp_path) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "ready_to_apply" + assert payload["applied"] is False + assert payload["will_download_models"] is False + assert payload["candidate"]["candidate_key"] == CANDIDATE_KEY + assert payload["candidate"]["threshold"] == 0.35 + assert payload["env_updates"]["YOLO_ENABLED"] == "true" + assert payload["env_updates"]["YOLO_MODEL_PATH"] == "/app/models/geointel-building-yolov8s-aoi1024bg512r3e50.pt" + + +def test_promoted_yolo_activation_apply_updates_env_file(tmp_path: Path) -> None: + result = _run_activation(tmp_path, "--apply") + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "applied" + env_text = (tmp_path / ".env").read_text(encoding="utf-8") + assert "GEOINTEL_ENV=production" in env_text + assert "GEOINTEL_INSTALL_AI=true" in env_text + assert "YOLO_ENABLED=true" in env_text + assert "YOLO_MODELS_DIR=/app/models" in env_text + assert "YOLO_MODEL_PATH=/app/models/geointel-building-yolov8s-aoi1024bg512r3e50.pt" in env_text + + +def test_promoted_yolo_activation_rejects_non_promoted_report(tmp_path: Path) -> None: + models_dir = tmp_path / "models" + _write_model(models_dir) + report_path = tmp_path / "promotion_report.json" + _write_report(report_path, promotion_status="reject") + + result = subprocess.run( + [ + "python", + str(SCRIPT), + "--promotion-report", + str(report_path), + "--candidate-key", + CANDIDATE_KEY, + "--models-dir", + str(models_dir), + "--env-file", + str(tmp_path / ".env"), + "--json", + ], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 3 + payload = json.loads(result.stdout) + assert payload["status"] == "candidate_not_promoted" + assert "rejection_reasons" in payload + + +def test_readiness_gate_compiles_promoted_activation_script() -> None: + readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") + + assert "-m py_compile scripts/activate_promoted_yolo_candidate.py" in readiness diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index 17521bde..cfca347d 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -335,13 +335,35 @@ hard-negative gates, then run `sparse_building_context` as a separate review matrix. The first expanded local model improved dense AOI F1, but Kasterlee-bos false positives block default promotion. -The current inactive AOI1024 background-aware local model asset, +The AOI1024 background-aware local model asset, `geointel-building-yolov8s-aoi1024bg512r3e50-pt`, is exposed in Detection Lab only through deliberate operator profiles. `balanced-review` applies threshold -`0.15` for the strongest positive-AOI F1 observed so far; `conservative-review` -applies threshold `0.35` for higher precision review. Both profiles remain -candidate-only, not default-approved, because the promotion recommendation is -still `none` and background false-positive pressure has not passed the gate. +`0.15` for the strongest positive-AOI F1 observed so far, but remains +candidate-only because pure-empty false-positive pressure failed at that +threshold. `conservative-review` applies threshold `0.35` and is marked as the +promoted candidate after the split-background report passed the strict +pure-empty gate. Sparse-context detections remain review-only evidence, not a +default-promotion blocker. + +To update a Tower/Unraid `.env` from a promoted report, use the guarded +activation helper. It validates the exact report candidate key, verifies that +the candidate has `promotion_status=promote_candidate`, resolves the local model +asset under the mounted models directory, and writes environment updates only +when `--apply` is supplied: + +```bash +python scripts/activate_promoted_yolo_candidate.py \ + --promotion-report artifacts/detection-model-promotion/split-aware/aoi1024bg512r3e50-high-threshold-split-20260710T222934Z/detection_model_promotion_report.json \ + --candidate-key 'geointel-building-yolov8s-aoi1024bg512r3e50-pt|512|64|0.35' \ + --models-dir /mnt/user/appdata/geointel/models \ + --env-file /mnt/user/appdata/geointel/.env \ + --json +``` + +Re-run with `--apply` only after reviewing the emitted env updates. The helper +does not download weights, load a model or run inference. Restart or rebuild the +runtime after applying because `YOLO_MODEL_PATH` is read from environment +configuration. To compare the same model/tile/threshold grid across all prepared operator samples, use: diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index e46d7b1e..963d0a1e 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -6532,3 +6532,44 @@ Open: ## Next recommended pass - Add a guarded model activation/operator-selection workflow that can mark a promoted candidate as active only after the report artifact and candidate key are explicitly supplied. + +# Sprint 163 - Guarded promoted YOLO activation workflow + +## What changed + +- Added `scripts/activate_promoted_yolo_candidate.py`. +- The helper validates: + - the promotion report file exists and is valid JSON; + - the exact supplied `candidate_key` matches the report recommended candidate; + - the candidate has `promotion_status=promote_candidate`; + - positive sample count, background sample count, mean F1 and max background detections still satisfy report gates; + - the candidate `model_asset_id` resolves to an existing local model file under the mounted models directory. +- The helper emits `.env` updates in dry-run mode by default and writes them only when `--apply` is supplied. +- Updated Detection Lab operator profiles: + - `balanced-review` at threshold `0.15` remains candidate-only because pure-empty false-positive pressure failed. + - `conservative-review` at threshold `0.35` is marked as promoted/default-approved based on the split-background pure-empty gate. +- Added docs for the guarded activation command in `docs/AI_PIPELINES.md`, `scripts/README.md`, `backend/README.md` and `frontend/README.md`. +- Added readiness coverage for compiling the new helper. +- No API contract, database migration, provider fetching, fake detection path, model file mutation, model download or automatic runtime activation was introduced in code. + +## What was tested locally + +- RED: `python -m pytest tests/test_sprint162_promoted_model_activation.py -q` failed while `scripts/activate_promoted_yolo_candidate.py` was absent. +- RED: `python -m pytest tests/test_sprint155_detection_operator_profiles.py -q` failed before `conservative-review` was marked promoted. +- RED: `python -m pytest tests/test_sprint162_promoted_model_activation.py::test_readiness_gate_compiles_promoted_activation_script -q` failed before readiness compiled the helper. +- Ran `python -m pytest tests/test_sprint162_promoted_model_activation.py tests/test_sprint155_detection_operator_profiles.py -q`: 7 passed. +- Ran `python -m py_compile scripts/activate_promoted_yolo_candidate.py`. +- Ran `python -m compileall backend/app`. +- Ran `python -m pytest` in `backend`: 454 passed, 17 existing Pydantic protected-namespace warnings. +- Ran `npm run typecheck` in `frontend`. +- Ran `npm run build` in `frontend`. +- Ran `bash scripts/run_readiness_check.sh`: passed. + +## Known limitations + +- The helper updates runtime environment only; a container restart or rebuild is still required for `YOLO_MODEL_PATH` changes to take effect. +- The promoted threshold is represented in the operator profile and promotion report. The backend detection endpoint still requires clients to submit the intended confidence threshold explicitly. + +## Next recommended pass + +- Push this helper to Tower, run it first as dry-run against the high-threshold promotion report, then apply and redeploy/restart only if the emitted `YOLO_MODEL_PATH` matches the promoted local asset. diff --git a/docs/TODO.md b/docs/TODO.md index dad6234c..26d57d99 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -121,14 +121,15 @@ This file now starts with the current implementation status. Older preparation/b - [x] Export and audit AOI1024 clean-label variants; select `yolo-building-aoi1024-visible050-minpx8` as the first audit-passing 512px training candidate. - [x] Train and gate `geointel-building-yolov8s-aoi1024clean512e50-pt` through seven positive AOIs and nine hard-negative/background AOIs. - [x] Train and gate background-aware `geointel-building-yolov8s-aoi1024bg512r3e50-pt`; it is the strongest positive-AOI candidate so far but remains inactive because full background-candidate false-positive pressure still blocks default promotion. -- [x] Add explicit operator detection profiles for local model assets: balanced review around threshold `0.15` and conservative high-precision review around threshold `0.35`, both clearly marked as non-default-approved until promotion gates pass. +- [x] Add explicit operator detection profiles for local model assets: balanced review around threshold `0.15` remains candidate-only, while conservative high-precision review around threshold `0.35` is marked promoted after the pure-empty split-background gate passed. - [x] Add pure-empty versus sparse-building contextual background corpus classification to operator manifests, hard-negative matrix filters and YOLO tile provenance. - [x] Add a split background-corpus matrix runner and report builder that runs pure-empty and sparse-context matrices separately. - [x] Teach the model promotion report to consume split background summaries so only `pure_empty_negative` blocks default promotion and `sparse_building_context` stays review-only. - [x] Add one-command operator workflow to run split background matrices and immediately build the split-aware promotion report. - [x] Add preflight-only validation for the split-background promotion workflow before long runtime matrices. -- [ ] Rerun split background matrices on Tower after rebuild, then retrain or recalibrate against the cleaner pure-empty gate plus separate sparse-context inspection matrix. -- [ ] Promote a V1 default building detector only after it passes seven positive AOIs, clean hard-negative/background gates and persisted QA/QC evidence without fake detections or model downloads. +- [x] Rerun split background matrices on Tower after rebuild, then recalibrate against the cleaner pure-empty gate plus separate sparse-context inspection matrix. +- [x] Add guarded promoted-candidate activation helper requiring a promotion report path and exact candidate key before `.env` can be changed. +- [ ] Apply promoted V1 default building detector only after explicit operator review of the emitted `.env` updates, followed by rebuild/restart and browser/runtime smoke. ## Sprint 8 status diff --git a/frontend/README.md b/frontend/README.md index 6317b631..42b62a09 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -124,7 +124,8 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst - Detection Lab now exposes the `yolo-configured` capability reported by the backend. - When `yolo-configured` is selected, users can provide an existing raster tile manifest path. - Detection Lab lists local model assets from `GET /api/v1/detection/model-assets` so operators can choose an existing mounted model file instead of editing only one hidden `YOLO_MODEL_PATH` slot. -- Detection Lab exposes explicit operator profiles for the current inactive local AOI1024 building detector: balanced review at threshold `0.15` and conservative review at threshold `0.35`. Applying a profile deliberately selects the local model asset and threshold; it does not approve or promote a default model. +- Detection Lab exposes explicit operator profiles for the local AOI1024 building detector: balanced review at threshold `0.15` remains candidate-only, while conservative review at threshold `0.35` is marked as the promoted profile after the split-background pure-empty gate passed. +- Applying a profile deliberately selects the local model asset and threshold for the browser-run request; runtime default activation remains a separate guarded `.env` operation through `scripts/activate_promoted_yolo_candidate.py`. - Detection Lab includes a read-only YOLO runtime preflight panel with backend status, dependency visibility, local model configuration, `torch`/`ultralytics` versions, CUDA state and `YOLO_CONFIG_DIR`. - The UI still does not download models or create fake detections; backend status and error codes remain the source of truth. diff --git a/frontend/src/components/detection/DetectionLab.tsx b/frontend/src/components/detection/DetectionLab.tsx index 15f71c3b..d9ed152f 100644 --- a/frontend/src/components/detection/DetectionLab.tsx +++ b/frontend/src/components/detection/DetectionLab.tsx @@ -255,7 +255,7 @@ export function DetectionLab({ Operator profiles

Candidate profiles apply a local model asset and confidence threshold only after an explicit click. - Candidate only - not default-approved while the promotion recommendation remains none. + Promoted profiles still require explicit operator action and do not mutate the runtime environment.

@@ -305,8 +305,8 @@ export function DetectionLab({
Selected model asset status

- {selectedModelAsset.display_name} is operator-selected. Keep local candidates inactive until persisted - promotion evidence explicitly recommends default activation. + {selectedModelAsset.display_name} is selected for this browser-run request. Runtime default activation + remains a separate guarded operator action backed by a promotion report.

) : null} diff --git a/frontend/src/components/detection/detectionProfiles.ts b/frontend/src/components/detection/detectionProfiles.ts index dea1cb69..29026995 100644 --- a/frontend/src/components/detection/detectionProfiles.ts +++ b/frontend/src/components/detection/detectionProfiles.ts @@ -24,24 +24,24 @@ export const DETECTION_OPERATOR_PROFILES: DetectionOperatorProfile[] = [ precision: 0.636639, recall: 0.424258, f1: 0.5074022485589402, - maxBackgroundDetections: 103, + maxBackgroundDetections: 46, description: 'Best positive-AOI F1 profile for deliberate operator review of the inactive AOI1024 model asset.', limitationMessage: - 'Candidate only because false-positive pressure still blocks default promotion on the background/hard-negative gate.', + 'Candidate only because pure-empty false-positive pressure still blocks default promotion on the background gate.', }, { id: 'conservative-review', - displayName: 'Conservative review', + displayName: 'Promoted conservative review', modelAssetId: 'geointel-building-yolov8s-aoi1024bg512r3e50-pt', confidenceThreshold: 0.35, - defaultApproved: false, - promotionRecommendation: 'none', + defaultApproved: true, + promotionRecommendation: 'promote_candidate', precision: 0.840006, recall: 0.202135, f1: 0.32086574003576274, - maxBackgroundDetections: 55, - description: 'Higher-precision profile for demos or review sessions where fewer false positives matter more than recall.', + maxBackgroundDetections: 0, + description: 'Promoted high-precision profile for demos or review sessions where fewer false positives matter more than recall.', limitationMessage: - 'Candidate only because false-positive pressure remains visible; use it deliberately and inspect persisted QA evidence.', + 'Default-approved after the split-background pure-empty gate passed; sparse-context detections remain review-only evidence.', }, ] diff --git a/scripts/README.md b/scripts/README.md index e53a5e31..ad4cb97e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -648,6 +648,24 @@ files are present without `--model-file`. It writes only `GEOINTEL_INSTALL_AI=true`, `YOLO_ENABLED=true`, `YOLO_MODELS_DIR=/app/models` and the mounted `YOLO_MODEL_PATH`. +When a split-background promotion report recommends a specific candidate, use +the guarded activation helper instead of choosing a model path manually. The +helper validates the exact `candidate_key`, promotion status and local model +asset before writing anything, and it mutates `.env` only with `--apply`: + +```bash +python scripts/activate_promoted_yolo_candidate.py \ + --promotion-report /mnt/user/appdata/geointel/artifacts/detection-model-promotion/split-aware/aoi1024bg512r3e50-high-threshold-split-20260710T222934Z/detection_model_promotion_report.json \ + --candidate-key 'geointel-building-yolov8s-aoi1024bg512r3e50-pt|512|64|0.35' \ + --models-dir /mnt/user/appdata/geointel/models \ + --env-file /mnt/user/appdata/geointel/.env \ + --json +``` + +Add `--apply` only after reviewing the emitted updates. The helper never +downloads weights, loads the model or runs inference; restart or rebuild the +container after applying because `YOLO_MODEL_PATH` is read from the environment. + Tower-local model evaluation status: - `geointel-building-yolov8s-hardneg160r4e50.pt` is available as an evaluated diff --git a/scripts/activate_promoted_yolo_candidate.py b/scripts/activate_promoted_yolo_candidate.py new file mode 100644 index 00000000..0014c21a --- /dev/null +++ b/scripts/activate_promoted_yolo_candidate.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + + +SUPPORTED_MODEL_SUFFIXES = {".pt", ".onnx", ".engine"} +ENV_KEYS = ("GEOINTEL_INSTALL_AI", "YOLO_ENABLED", "YOLO_MODELS_DIR", "YOLO_MODEL_PATH") + + +def _asset_id(path: Path) -> str: + raw = f"{path.stem}-{path.suffix.lower().lstrip('.')}" + normalized = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-") + return normalized or "model-asset" + + +def _candidate_paths(models_dir: Path) -> list[Path]: + if not models_dir.exists() or not models_dir.is_dir(): + 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 _load_report(path: Path) -> dict[str, Any]: + if not path.exists() or not path.is_file(): + raise ValueError("Promotion report file does not exist") + payload = json.loads(path.read_text(encoding="utf-8-sig")) + if not isinstance(payload, dict): + raise ValueError("Promotion report must be a JSON object") + return payload + + +def _candidate_from_report(report: dict[str, Any], candidate_key: str) -> dict[str, Any] | None: + recommended = report.get("recommended_candidate") + if isinstance(recommended, dict): + if recommended.get("candidate_key") == candidate_key: + return recommended + return None + if isinstance(recommended, str) and recommended == candidate_key: + for item in report.get("candidate_decisions") or []: + if isinstance(item, dict) and item.get("candidate_key") == candidate_key: + return item + for item in report.get("candidate_decisions") or []: + if isinstance(item, dict) and item.get("candidate_key") == candidate_key: + return item + return None + + +def _gate_failures(report: dict[str, Any], candidate: dict[str, Any]) -> list[str]: + gates = report.get("gates") if isinstance(report.get("gates"), dict) else {} + failures: list[str] = [] + if candidate.get("promotion_status") != "promote_candidate": + failures.append("candidate_not_promoted") + if candidate.get("rejection_reasons"): + failures.append("candidate_has_rejection_reasons") + + min_positive_samples = int(gates.get("min_positive_samples") or 0) + min_background_samples = int(gates.get("min_background_samples") or 0) + min_mean_f1 = float(gates.get("min_mean_f1") or 0) + max_background_detections = int(gates.get("max_background_detections_per_sample") or 0) + + if int(candidate.get("positive_sample_count") or 0) < min_positive_samples: + failures.append("insufficient_positive_samples") + if int(candidate.get("background_sample_count") or 0) < min_background_samples: + failures.append("insufficient_background_samples") + if float(candidate.get("mean_f1") or 0) < min_mean_f1: + failures.append("positive_mean_f1_below_gate") + if int(candidate.get("max_background_detections") or 0) > max_background_detections: + failures.append("background_false_positive_pressure") + return failures + + +def _resolve_model_asset(models_dir: Path, model_asset_id: str) -> Path | None: + for path in _candidate_paths(models_dir): + if _asset_id(path) == model_asset_id: + return path + return None + + +def _base_payload(args: argparse.Namespace) -> dict[str, Any]: + return { + "promotion_report": str(Path(args.promotion_report).resolve()), + "candidate_key": args.candidate_key, + "models_dir": str(Path(args.models_dir).resolve()), + "env_file": str(Path(args.env_file).resolve()), + "candidate": None, + "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 activate(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + payload = _base_payload(args) + try: + report = _load_report(Path(args.promotion_report)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + payload.update({"status": "invalid_promotion_report", "message": str(exc)}) + return 2, payload + + candidate = _candidate_from_report(report, args.candidate_key) + if not candidate: + payload.update( + { + "status": "candidate_not_recommended", + "message": "Candidate key does not match the report recommended candidate.", + } + ) + return 3, payload + + payload["candidate"] = candidate + failures = _gate_failures(report, candidate) + if failures: + payload.update( + { + "status": "candidate_not_promoted", + "message": "Candidate did not pass the promotion gates.", + "rejection_reasons": failures, + } + ) + return 3, payload + + model_asset_id = str(candidate.get("model_asset_id") or "").strip() + if not model_asset_id: + payload.update({"status": "model_asset_missing", "message": "Candidate does not record model_asset_id."}) + return 2, payload + + models_dir = Path(args.models_dir).resolve() + selected_model = _resolve_model_asset(models_dir, model_asset_id) + if selected_model is None: + payload.update( + { + "status": "model_asset_not_found", + "message": "Promoted candidate model asset was not found in the local models directory.", + "model_asset_id": model_asset_id, + } + ) + return 2, payload + + selected_container_path = _container_path(selected_model, models_dir, args.container_model_dir) + updates = { + "GEOINTEL_INSTALL_AI": "true", + "YOLO_ENABLED": "true", + "YOLO_MODELS_DIR": args.container_model_dir.rstrip("/"), + "YOLO_MODEL_PATH": selected_container_path, + } + payload.update( + { + "status": "ready_to_apply", + "message": "Promoted YOLO candidate validated. Re-run with --apply to update the environment file.", + "selected_host_model_path": str(selected_model), + "selected_container_model_path": selected_container_path, + "env_updates": updates, + "docker_restart_required": True, + } + ) + + if args.apply: + _update_env_file(Path(args.env_file).resolve(), updates) + payload.update( + { + "status": "applied", + "message": "Environment file updated. Rebuild or restart the GeoIntel container to use the promoted model path.", + "applied": True, + } + ) + + return 0, payload + + +def _emit(payload: dict[str, Any], *, 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 in ENV_KEYS: + if key in payload["env_updates"]: + print(f" {key}={payload['env_updates'][key]}") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Activate an existing local YOLO model only after a promotion report recommends the exact candidate key." + ) + parser.add_argument("--promotion-report", required=True, help="Path to detection_model_promotion_report.json.") + parser.add_argument("--candidate-key", required=True, help="Exact promoted candidate key from the report.") + parser.add_argument( + "--models-dir", + default=os.environ.get("GEOINTEL_MODELS_PATH", "models"), + help="Host directory containing mounted local model files.", + ) + 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 supplied.") + parser.add_argument("--apply", action="store_true", help="Write YOLO env updates after all promotion gates pass.") + 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 = activate(args) + _emit(payload, as_json=args.json) + return exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 6a3af4ad..7c9ff9b5 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -47,6 +47,7 @@ ${PYTHON_BIN} -m py_compile scripts/export_operator_yolo_tile_dataset.py ${PYTHON_BIN} -m py_compile scripts/audit_operator_yolo_dataset_quality.py ${PYTHON_BIN} -m py_compile scripts/build_detection_model_promotion_report.py ${PYTHON_BIN} -m py_compile scripts/build_background_corpus_split_report.py +${PYTHON_BIN} -m py_compile scripts/activate_promoted_yolo_candidate.py ${PYTHON_BIN} -m py_compile scripts/cleanup_demo_artifacts.py ${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py ${PYTHON_BIN} -m compileall backend/app