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>
This commit is contained in:
@@ -1,23 +1,9 @@
|
||||
"""
|
||||
Runtime feature flags for expensive pipeline stages.
|
||||
Runtime feature flags for the vision pipeline.
|
||||
|
||||
The YAML config (``mulita.yml``) ships reasonable defaults. Admins can
|
||||
toggle these at runtime from the Settings → AI Features tab without
|
||||
rebuilding the image or editing the bind-mounted YAML; the overrides
|
||||
live in Redis so both the FastAPI backend and the Celery workers see
|
||||
the same value within ~1s of the write.
|
||||
|
||||
The key namespace is:
|
||||
|
||||
mulita:flags:<name> → "true" | "false"
|
||||
|
||||
An unset key means "fall back to the YAML default" — so an admin who
|
||||
has never touched the tab sees exactly the config-file behaviour.
|
||||
|
||||
Only bool flags live here. Thresholds, batch sizes, model names etc.
|
||||
stay in the YAML file because flipping them safely requires restarting
|
||||
the vision workers (model reload, ONNX session re-init); that's not
|
||||
something a single admin click should do.
|
||||
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
|
||||
|
||||
@@ -31,30 +17,16 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Feature identifiers. The public name is what the admin UI sends; the
|
||||
# ``yaml_default`` getter returns the value the YAML would have set.
|
||||
# Keep these in sync with the VisionSettings fields in ``config.py``.
|
||||
FLAG_VISION_ENABLED = 'vision.enabled'
|
||||
FLAG_OCR_ENABLED = 'vision.ocr.enabled'
|
||||
FLAG_DETECTOR_ENABLED = 'vision.detector.enabled'
|
||||
FLAG_FACES_ENABLED = 'vision.faces.enabled'
|
||||
FLAG_CLASSIFIER_ENABLED = 'vision.classifier.enabled'
|
||||
|
||||
ALL_FLAGS = (
|
||||
FLAG_VISION_ENABLED,
|
||||
FLAG_OCR_ENABLED,
|
||||
FLAG_DETECTOR_ENABLED,
|
||||
FLAG_FACES_ENABLED,
|
||||
FLAG_CLASSIFIER_ENABLED,
|
||||
)
|
||||
ALL_FLAGS = (FLAG_VISION_ENABLED,)
|
||||
|
||||
_VISION_QUEUE = 'vision'
|
||||
|
||||
_REDIS: Optional[redis.Redis] = None
|
||||
|
||||
|
||||
def _redis() -> Optional[redis.Redis]:
|
||||
"""Lazy Redis client. Returns None if the broker is unreachable so
|
||||
callers can fall back to YAML defaults instead of crashing."""
|
||||
global _REDIS
|
||||
if _REDIS is None:
|
||||
try:
|
||||
@@ -69,19 +41,8 @@ def _redis() -> Optional[redis.Redis]:
|
||||
|
||||
|
||||
def _yaml_default(name: str) -> bool:
|
||||
"""Return the YAML-configured default for a flag. Used when Redis
|
||||
has no value set (fresh install or admin never touched the tab)."""
|
||||
v = settings.vision
|
||||
if name == FLAG_VISION_ENABLED:
|
||||
return bool(v.enabled)
|
||||
if name == FLAG_OCR_ENABLED:
|
||||
return bool(v.ocr.enabled)
|
||||
if name == FLAG_DETECTOR_ENABLED:
|
||||
return bool(v.detector.enabled)
|
||||
if name == FLAG_FACES_ENABLED:
|
||||
return bool(v.faces.enabled)
|
||||
if name == FLAG_CLASSIFIER_ENABLED:
|
||||
return bool(v.classifier.enabled)
|
||||
return bool(settings.vision.enabled)
|
||||
raise ValueError(f"Unknown feature flag: {name!r}")
|
||||
|
||||
|
||||
@@ -90,16 +51,6 @@ def _redis_key(name: str) -> str:
|
||||
|
||||
|
||||
def is_enabled(name: str) -> bool:
|
||||
"""Return True if feature ``name`` is currently enabled.
|
||||
|
||||
Order of precedence:
|
||||
1. Redis override (set by PATCH /admin/feature-flags)
|
||||
2. YAML default
|
||||
|
||||
Reads are cheap (~ms) and we intentionally do NOT add a local
|
||||
process cache — the whole point of runtime flags is that a toggle
|
||||
takes effect on the next task without a worker restart.
|
||||
"""
|
||||
r = _redis()
|
||||
if r is not None:
|
||||
try:
|
||||
@@ -112,9 +63,6 @@ def is_enabled(name: str) -> bool:
|
||||
|
||||
|
||||
def set_flag(name: str, value: bool) -> None:
|
||||
"""Persist a flag override to Redis. No-op if Redis is unreachable
|
||||
(we don't silently pretend to have written; raise so the admin
|
||||
request returns a 500 instead of misleading success)."""
|
||||
if name not in ALL_FLAGS:
|
||||
raise ValueError(f"Unknown feature flag: {name!r}")
|
||||
r = _redis()
|
||||
@@ -125,9 +73,6 @@ def set_flag(name: str, value: bool) -> None:
|
||||
|
||||
|
||||
def reset_flag(name: str) -> None:
|
||||
"""Delete the Redis override so the flag falls back to its YAML
|
||||
default. Useful if an admin wants a clean slate without guessing
|
||||
what the config defaults are."""
|
||||
if name not in ALL_FLAGS:
|
||||
raise ValueError(f"Unknown feature flag: {name!r}")
|
||||
r = _redis()
|
||||
@@ -137,150 +82,41 @@ def reset_flag(name: str) -> None:
|
||||
_apply_worker_side_effects(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worker-level side effects: when the admin flips a flag we don't just want
|
||||
# gating at task-start (which still executes the message, it just returns
|
||||
# 'skipped'). We also want queued work gone and the vision worker genuinely
|
||||
# idle when the master switch is off.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VISION_QUEUE = 'vision'
|
||||
|
||||
# Flag → celery task name(s) whose queued messages should be dropped when
|
||||
# the flag goes off. Keeps the queue from replaying yesterday's work the
|
||||
# moment someone re-enables the stage.
|
||||
_TASKS_BY_FLAG: dict[str, tuple[str, ...]] = {
|
||||
FLAG_VISION_ENABLED: (
|
||||
'embed_photo', 'ocr_photo', 'detect_objects', 'extract_faces',
|
||||
'classify_content', 'vision_fanout', 'recluster_faces',
|
||||
),
|
||||
FLAG_OCR_ENABLED: ('ocr_photo',),
|
||||
FLAG_DETECTOR_ENABLED: ('detect_objects',),
|
||||
FLAG_FACES_ENABLED: ('extract_faces', 'recluster_faces'),
|
||||
FLAG_CLASSIFIER_ENABLED: ('classify_content',),
|
||||
}
|
||||
|
||||
|
||||
def _apply_worker_side_effects(name: str) -> None:
|
||||
"""Bring the live workers in line with the new flag value.
|
||||
|
||||
For the master ``vision.enabled`` flag we go beyond task gating and
|
||||
actually stop consumption from the ``vision`` queue — flipping it
|
||||
off puts the vision worker to sleep (no CPU, no model memory
|
||||
churn) until it's flipped back on. For per-feature flags, the
|
||||
running tasks already skip via ``is_enabled``; we just purge any
|
||||
messages already sitting in the queue so the admin doesn't pay for
|
||||
a backlog on re-enable.
|
||||
|
||||
All operations are best-effort — if control messaging or a Redis
|
||||
op fails, we log and return; the flag state itself is already
|
||||
persisted so the gating path continues to work.
|
||||
"""
|
||||
"""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:
|
||||
# Lazy import: avoids a circular dependency between the services
|
||||
# module (imported from tasks.vision) and the celery app config.
|
||||
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 name == FLAG_VISION_ENABLED:
|
||||
if is_enabled(FLAG_VISION_ENABLED):
|
||||
# Re-attach the vision consumer so workers pick up tasks
|
||||
# again. broadcast=True ensures every running worker
|
||||
# receives the command.
|
||||
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"
|
||||
)
|
||||
return
|
||||
|
||||
# Per-feature flag going off → drop pending tasks of its types.
|
||||
if not is_enabled(name):
|
||||
targets = _TASKS_BY_FLAG.get(name, ())
|
||||
if targets:
|
||||
removed = _purge_queue_by_task_names(_VISION_QUEUE, targets)
|
||||
logger.info(
|
||||
f"feature_flags: {name} disabled; removed {removed} "
|
||||
f"pending messages from {_VISION_QUEUE}"
|
||||
)
|
||||
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 for {name}: {e}")
|
||||
logger.warning(f"feature_flags: worker side effects failed: {e}")
|
||||
|
||||
|
||||
def _purge_queue(queue: str) -> int:
|
||||
"""Drop every pending message from ``queue``. Returns the count
|
||||
deleted. Celery's control.purge() purges the default queue only,
|
||||
so we delete the Redis key directly (the broker's queue list)."""
|
||||
r = _redis()
|
||||
if r is None:
|
||||
return 0
|
||||
try:
|
||||
removed = r.delete(queue)
|
||||
return int(removed or 0)
|
||||
return int(r.delete(queue) or 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"feature_flags: purge {queue} failed: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def _purge_queue_by_task_names(queue: str, task_names: tuple[str, ...]) -> int:
|
||||
"""Walk ``queue`` and drop any message whose Celery task name is in
|
||||
``task_names``. Other messages are preserved (pushed back in order)
|
||||
so we don't flush embed tasks when the admin disabled only OCR.
|
||||
|
||||
Celery stores each message as a JSON blob in a Redis list; the
|
||||
task name lives at ``headers.task``.
|
||||
"""
|
||||
import json
|
||||
r = _redis()
|
||||
if r is None:
|
||||
return 0
|
||||
try:
|
||||
# Snapshot the queue, then rebuild it without the filtered names.
|
||||
# Done inside a Redis transaction so a concurrent enqueue doesn't
|
||||
# race with us (worst case it gets re-delivered after we release,
|
||||
# which is the normal enqueue path anyway).
|
||||
pipe = r.pipeline()
|
||||
pipe.lrange(queue, 0, -1)
|
||||
pipe.delete(queue)
|
||||
raw_items, _ = pipe.execute()
|
||||
kept: list[bytes | str] = []
|
||||
removed = 0
|
||||
for raw in raw_items or []:
|
||||
try:
|
||||
# Messages can be bytes or str depending on decode_responses.
|
||||
payload = raw.decode() if isinstance(raw, bytes) else raw
|
||||
msg = json.loads(payload)
|
||||
task = (
|
||||
msg.get('headers', {}).get('task')
|
||||
or msg.get('task')
|
||||
)
|
||||
if task in task_names:
|
||||
removed += 1
|
||||
continue
|
||||
except Exception:
|
||||
# Unparseable message — keep it, better to leak than
|
||||
# to silently drop a message we can't identify.
|
||||
pass
|
||||
kept.append(raw)
|
||||
if kept:
|
||||
r.rpush(queue, *kept)
|
||||
return removed
|
||||
except Exception as e:
|
||||
logger.warning(f"feature_flags: selective purge failed on {queue}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def snapshot() -> dict[str, dict[str, object]]:
|
||||
"""Return every flag's current effective value, YAML default, and
|
||||
whether it's overridden. Powers the admin UI tab.
|
||||
"""
|
||||
r = _redis()
|
||||
out: dict[str, dict[str, object]] = {}
|
||||
for name in ALL_FLAGS:
|
||||
|
||||
Reference in New Issue
Block a user