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>
138 lines
3.9 KiB
Python
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
|