Files
mule-image/backend/app/services/feature_flags.py
dtoro 574d71371f refactor: strip AI pipeline to binary photo/other classifier
Drops face recognition, OCR, object detection, and semantic embeddings.
The sole remaining vision task is a CLIP-based binary classifier
(photography vs other); photos in "other" get needs_review=true so
screenshots, documents, memes and scans can be triaged from a new
filter pill in the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:27:17 +02:00

138 lines
3.9 KiB
Python

"""
Runtime feature flags for the vision pipeline.
Only one flag now — the master vision switch. Runtime overrides live in
Redis under ``mulita:flags:<name>``; an unset key falls back to the
YAML default.
"""
from __future__ import annotations
import logging
from typing import Optional
import redis
from app.config import settings
logger = logging.getLogger(__name__)
FLAG_VISION_ENABLED = 'vision.enabled'
ALL_FLAGS = (FLAG_VISION_ENABLED,)
_VISION_QUEUE = 'vision'
_REDIS: Optional[redis.Redis] = None
def _redis() -> Optional[redis.Redis]:
global _REDIS
if _REDIS is None:
try:
_REDIS = redis.Redis.from_url(
settings.celery_broker_url, decode_responses=True
)
_REDIS.ping()
except Exception as e:
logger.warning(f"feature_flags: Redis unavailable, using YAML defaults ({e})")
_REDIS = None
return _REDIS
def _yaml_default(name: str) -> bool:
if name == FLAG_VISION_ENABLED:
return bool(settings.vision.enabled)
raise ValueError(f"Unknown feature flag: {name!r}")
def _redis_key(name: str) -> str:
return f"mulita:flags:{name}"
def is_enabled(name: str) -> bool:
r = _redis()
if r is not None:
try:
raw = r.get(_redis_key(name))
if raw is not None:
return raw.lower() == 'true'
except Exception as e:
logger.warning(f"feature_flags: Redis read failed for {name} ({e})")
return _yaml_default(name)
def set_flag(name: str, value: bool) -> None:
if name not in ALL_FLAGS:
raise ValueError(f"Unknown feature flag: {name!r}")
r = _redis()
if r is None:
raise RuntimeError("Redis unavailable; cannot update feature flags")
r.set(_redis_key(name), 'true' if value else 'false')
_apply_worker_side_effects(name)
def reset_flag(name: str) -> None:
if name not in ALL_FLAGS:
raise ValueError(f"Unknown feature flag: {name!r}")
r = _redis()
if r is None:
raise RuntimeError("Redis unavailable; cannot reset feature flags")
r.delete(_redis_key(name))
_apply_worker_side_effects(name)
def _apply_worker_side_effects(name: str) -> None:
"""Attach or detach the vision consumer and purge queued work when
the master flag flips. Best-effort — state is already persisted."""
if name != FLAG_VISION_ENABLED:
return
try:
from app.tasks.celery import celery_app
except Exception as e:
logger.warning(f"feature_flags: celery app unavailable for side effects ({e})")
return
try:
if is_enabled(FLAG_VISION_ENABLED):
celery_app.control.add_consumer(_VISION_QUEUE, reply=False)
logger.info("feature_flags: vision re-enabled; consumer added")
else:
celery_app.control.cancel_consumer(_VISION_QUEUE, reply=False)
_purge_queue(_VISION_QUEUE)
logger.info("feature_flags: vision disabled; consumer cancelled and queue purged")
except Exception as e:
logger.warning(f"feature_flags: worker side effects failed: {e}")
def _purge_queue(queue: str) -> int:
r = _redis()
if r is None:
return 0
try:
return int(r.delete(queue) or 0)
except Exception as e:
logger.warning(f"feature_flags: purge {queue} failed: {e}")
return 0
def snapshot() -> dict[str, dict[str, object]]:
r = _redis()
out: dict[str, dict[str, object]] = {}
for name in ALL_FLAGS:
default = _yaml_default(name)
override = None
if r is not None:
try:
raw = r.get(_redis_key(name))
if raw is not None:
override = raw.lower() == 'true'
except Exception:
pass
out[name] = {
'effective': override if override is not None else default,
'default': default,
'overridden': override is not None,
}
return out