From e7af8dafff1215788ecff6814f4f2a0d115f51cb Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Thu, 18 Jun 2026 01:00:22 +0200 Subject: [PATCH 01/10] feat(ai): make internal AI optional with capability switches and /capabilities Add AI_INTERNAL_ENABLED (master, default true) plus AI_VISION_ENABLED and AI_TEXT_ENABLED, which inherit the master when unset; effective flags resolve to vision = internal and vision, text = internal and text, with a false master forcing both off. Expose the effective state at GET /api/v1/capabilities so an external agent can decide whether to drive tagging/suggestions/pairings itself, and degrade GET /health/ai to "disabled" instead of probing endpoints when AI is off. Defaults preserve current behavior, so existing deployments are unaffected. --- .env.example | 14 +++++++++++++- README.md | 11 +++++++++++ backend/app/api/health.py | 24 ++++++++++++++++++++++++ backend/app/config.py | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index ad962404..4bf1f91c 100644 --- a/.env.example +++ b/.env.example @@ -58,7 +58,19 @@ NEXTAUTH_SECRET=change-me-in-production-use-openssl-rand-hex-32 # AI Service Configuration # ============================================================================ -# REQUIRED: Choose ONE of the following AI providers +# Capability switches (optional). Defaults keep internal AI ON, so existing +# setups are unaffected. Set AI_INTERNAL_ENABLED=false to run with no internal +# AI at all — the app boots and serves without AI_BASE_URL/AI_API_KEY/models set, +# and tagging/suggestions/pairings are deferred to an external agent. The +# per-capability switches inherit the master when unset; an explicit value wins. +# Check effective state at GET /api/v1/capabilities. +# AI_INTERNAL_ENABLED=true # master switch +# AI_VISION_ENABLED=true # internal auto-tagging (inherits master if unset) +# AI_TEXT_ENABLED=true # internal suggestions/pairings (inherits master if unset) +# +# The provider settings below are only REQUIRED when internal AI is enabled. +# ============================================================================ +# Choose ONE of the following AI providers # The app needs a vision model (for analyzing clothing images) and a text model (for recommendations) # ============================================================================ diff --git a/README.md b/README.md index 9b421d1e..cde499cc 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,17 @@ Wardrowbe works with any OpenAI-compatible API. You need two types of models: - **Vision model**: Analyzes clothing images to extract colors, patterns, styles - **Text model**: Generates outfit recommendations and descriptions +### Running without internal AI + +Internal AI is optional. Set `AI_INTERNAL_ENABLED=false` to run the backend with +no internal AI provider at all — it boots and serves without `AI_BASE_URL`, +`AI_API_KEY`, or model names configured, and defers tagging/suggestions/pairings +to an external agent. You can also disable a single capability with +`AI_VISION_ENABLED=false` (auto-tagging) or `AI_TEXT_ENABLED=false` +(suggestions/pairings); unset switches inherit the master. The effective state is +reported at `GET /api/v1/capabilities`. Defaults keep internal AI **on**, so +existing deployments are unaffected. + ### Using Ollama (Recommended for Self-Hosting) **Free, runs locally, no API key needed, works offline** diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 3f453aac..f3c685fd 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -4,6 +4,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from app.config import get_settings from app.database import get_db from app.services.ai_service import get_ai_service @@ -15,6 +16,26 @@ async def health_check() -> dict[str, str]: return {"status": "healthy"} +@router.get("/capabilities") +async def capabilities() -> dict[str, Any]: + """Report effective AI capabilities so external agents (e.g. the MCP server) + can decide whether to route tagging/suggestions/pairings to themselves or + trust the backend. Public/no-auth: leaks no user data.""" + settings = get_settings() + return { + "ai": { + "vision": settings.effective_ai_vision_enabled, + "text": settings.effective_ai_text_enabled, + }, + "features": { + "external_tagging": True, + "external_suggestions": True, + "external_pairings": True, + }, + "version": "1.0.0", + } + + @router.get("/health/ready") async def readiness_check(db: AsyncSession = Depends(get_db)) -> dict[str, Any]: checks = { @@ -50,6 +71,9 @@ async def feature_check() -> dict[str, Any]: @router.get("/health/ai") async def ai_health_check() -> dict[str, Any]: + if not get_settings().ai_enabled: + return {"status": "disabled", "endpoints": []} + ai_service = get_ai_service() raw = await ai_service.check_health() diff --git a/backend/app/config.py b/backend/app/config.py index e8846819..252ebd6d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,15 @@ class Settings(BaseSettings): oidc_client_secret: str | None = None oidc_mobile_client_id: str | None = None + # AI capability switches. + # ai_internal_enabled is the master switch; ai_vision_enabled / ai_text_enabled + # inherit it when left unset (None). Defaults preserve current behavior + # (internal AI on). When a capability is disabled, no AI client is constructed + # for it and the corresponding work is deferred to an external agent. + ai_internal_enabled: bool = Field(default=True) + ai_vision_enabled: bool | None = Field(default=None) + ai_text_enabled: bool | None = Field(default=None) + # AI Service (OpenAI-compatible API - supports Ollama, OpenAI, etc.) ai_base_url: str = Field(default="") ai_api_key: str | None = Field(default=None) @@ -79,6 +88,33 @@ class Settings(BaseSettings): original_max_size: int = 2400 image_quality: int = 90 + @property + def effective_ai_vision_enabled(self) -> bool: + """Whether internal vision (auto-tagging) is active. + + vision = ai_internal_enabled AND ai_vision_enabled, where ai_vision_enabled + inherits the master switch when unset (None). + """ + if not self.ai_internal_enabled: + return False + return True if self.ai_vision_enabled is None else self.ai_vision_enabled + + @property + def effective_ai_text_enabled(self) -> bool: + """Whether internal text (suggestions/pairings) is active. + + text = ai_internal_enabled AND ai_text_enabled, where ai_text_enabled + inherits the master switch when unset (None). + """ + if not self.ai_internal_enabled: + return False + return True if self.ai_text_enabled is None else self.ai_text_enabled + + @property + def ai_enabled(self) -> bool: + """True if any internal AI capability is active.""" + return self.effective_ai_vision_enabled or self.effective_ai_text_enabled + def validate_security(self) -> str | None: if self.secret_key == DEFAULT_SECRET_KEY and not self.debug: raise RuntimeError( From e8c8fe9ce523dc5ea08b6216ce13e5fa399307d8 Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Thu, 18 Jun 2026 01:00:22 +0200 Subject: [PATCH 02/10] feat(ai): guard AI-client construction when a capability is disabled Add AIDisabledError and require_internal_ai(capability), and guard both get_ai_service() and AIService.__init__ so a client is never constructed against absent configuration. Per-capability call sites guard with require_internal_ai; the constructor check is the backstop. Booting with internal AI off and no AI_BASE_URL/AI_API_KEY/models set now succeeds cleanly. --- backend/app/services/__init__.py | 3 ++- backend/app/services/ai_service.py | 35 +++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 4b4a0bf7..cee5d4f2 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,12 +1,13 @@ """Service layer for business logic.""" -from app.services.ai_service import AIService, get_ai_service +from app.services.ai_service import AIDisabledError, AIService, get_ai_service from app.services.image_service import ImageService from app.services.item_service import ItemService from app.services.learning_service import LearningService from app.services.user_service import UserService __all__ = [ + "AIDisabledError", "AIService", "get_ai_service", "ImageService", diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 4c689b6e..c3f9a632 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -5,6 +5,7 @@ import math import re from pathlib import Path +from typing import Literal import httpx from PIL import Image, ImageOps @@ -257,8 +258,14 @@ def __init__(self, endpoints: list[dict] | None = None): Args: endpoints: List of endpoint configs from user preferences. If None or empty, uses default from settings. + + Raises: + AIDisabledError: backstop when internal AI is disabled; call sites + should guard with require_internal_ai() first. """ self.settings = get_settings() + if not self.settings.ai_enabled: + raise AIDisabledError("Internal AI is disabled; defer to an external agent.") self.timeout = self.settings.ai_timeout self.api_key = self.settings.ai_api_key @@ -683,12 +690,38 @@ async def generate_text( raise RuntimeError("Failed to generate text - no endpoints available") +class AIDisabledError(RuntimeError): + """Raised when an internal AI client is requested while that capability is off.""" + + +def require_internal_ai(capability: Literal["vision", "text"]) -> None: + """Raise AIDisabledError if the given internal-AI capability is disabled. + + Call before constructing AIService directly so deferred work never builds a + client or reaches a provider. + """ + settings = get_settings() + enabled = ( + settings.effective_ai_vision_enabled + if capability == "vision" + else settings.effective_ai_text_enabled + ) + if not enabled: + raise AIDisabledError( + f"Internal AI {capability} is disabled " + f"(AI_INTERNAL_ENABLED / AI_{capability.upper()}_ENABLED=false). " + "Defer this work to an external agent." + ) + + # Singleton instance _ai_service: AIService | None = None def get_ai_service() -> AIService: - """Get or create AI service instance.""" + """Return the shared AIService, or raise AIDisabledError if internal AI is off.""" + if not get_settings().ai_enabled: + raise AIDisabledError("Internal AI is disabled; defer to an external agent.") global _ai_service if _ai_service is None: _ai_service = AIService() From c57057415a43ed06cd8e1600fef2f101c68f4c40 Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Thu, 18 Jun 2026 01:00:22 +0200 Subject: [PATCH 03/10] feat(ai): defer to an external agent at every internal-AI consumer Enforce the switches everywhere the internal model is reached, so no client is built and no provider is called when a capability is off: - recommendation and pairing services guard text first, so deferral is unconditional rather than shadowed by location/weather/item validation; - the tagging worker skips when vision is off and leaves the item ready (untagged, usable) instead of error; - the worker skips AI init and the health probe at startup when AI is off; - the scheduled-notification worker treats AIDisabledError as a clean skip rather than a retried failure; - POST /outfits/suggest and POST /pairings/generate/{id} map AIDisabledError to a typed 503 ("deferred to an external agent") instead of a 500 or a hang. --- backend/app/api/outfits.py | 6 +++++ backend/app/api/pairings.py | 6 +++++ backend/app/services/pairing_service.py | 5 +++- .../app/services/recommendation_service.py | 5 +++- backend/app/workers/notifications.py | 5 ++++ backend/app/workers/tagging.py | 23 +++++++++++++++++++ backend/app/workers/worker.py | 10 +++++--- 7 files changed, 55 insertions(+), 5 deletions(-) diff --git a/backend/app/api/outfits.py b/backend/app/api/outfits.py index bb0c6324..039abf30 100644 --- a/backend/app/api/outfits.py +++ b/backend/app/api/outfits.py @@ -22,6 +22,7 @@ ) from app.models.user import User from app.schemas.item import DEFAULT_WASH_INTERVALS +from app.services.ai_service import AIDisabledError from app.services.item_service import ItemService from app.services.learning_service import LearningService from app.services.outfit_service import OutfitListFilters, OutfitService @@ -426,6 +427,11 @@ async def suggest_outfit( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from None + except AIDisabledError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Internal AI is disabled; outfit suggestions are deferred to an external agent.", + ) from None except AIRecommendationError as e: logger.error(f"AI recommendation error: {e}") raise HTTPException( diff --git a/backend/app/api/pairings.py b/backend/app/api/pairings.py index a99f90b2..8a3aac2c 100644 --- a/backend/app/api/pairings.py +++ b/backend/app/api/pairings.py @@ -11,6 +11,7 @@ from app.database import get_db from app.models.outfit import Outfit, OutfitSource from app.models.user import User +from app.services.ai_service import AIDisabledError from app.services.pairing_service import ( AIGenerationError, InsufficientItemsError, @@ -232,6 +233,11 @@ async def generate_pairings( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from None + except AIDisabledError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Internal AI is disabled; pairings are deferred to an external agent.", + ) from None except AIGenerationError as e: logger.error(f"AI pairing generation error: {e}") raise HTTPException( diff --git a/backend/app/services/pairing_service.py b/backend/app/services/pairing_service.py index f7b36e7b..970fecf8 100644 --- a/backend/app/services/pairing_service.py +++ b/backend/app/services/pairing_service.py @@ -10,7 +10,7 @@ from app.models.item import ClothingItem, ItemStatus from app.models.outfit import FamilyOutfitRating, Outfit, OutfitItem, OutfitSource, OutfitStatus from app.models.user import User -from app.services.ai_service import AIService +from app.services.ai_service import AIService, require_internal_ai from app.utils.clothing import deduplicate_by_body_slot from app.utils.prompts import load_prompt from app.utils.timezone import get_user_today @@ -166,6 +166,9 @@ async def generate_pairings( source_item_id: UUID, num_pairings: int = 3, ) -> list[Outfit]: + # Guard first so deferral is unconditional, before any item lookup. + require_internal_ai("text") + num_pairings = max(1, min(5, num_pairings)) # Get source item diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py index 46067c56..3a20bb0d 100644 --- a/backend/app/services/recommendation_service.py +++ b/backend/app/services/recommendation_service.py @@ -22,7 +22,7 @@ ) from app.models.preference import UserPreference from app.models.user import User -from app.services.ai_service import AIService +from app.services.ai_service import AIService, require_internal_ai from app.services.item_scorer import get_season, score_items from app.services.suggestion_cache import pop_suggestion, push_suggestions from app.services.weather_service import WeatherData, WeatherService, WeatherServiceError @@ -639,6 +639,9 @@ async def generate_recommendation( single_outfit: bool = False, scheduled_date: date | None = None, ) -> Outfit: + # Guard first so deferral is unconditional, before any location/weather work. + require_internal_ai("text") + exclude_items = exclude_items or [] include_items = include_items or [] diff --git a/backend/app/workers/notifications.py b/backend/app/workers/notifications.py index d897d395..f19d0e82 100644 --- a/backend/app/workers/notifications.py +++ b/backend/app/workers/notifications.py @@ -13,6 +13,7 @@ from app.models.schedule import Schedule from app.models.user import User from app.schemas.notification import EmailConfig, ExpoPushConfig, NtfyConfig +from app.services.ai_service import AIDisabledError from app.services.learning_service import LearningService from app.services.notification_providers import ( EmailProvider, @@ -234,6 +235,10 @@ async def process_scheduled_notification(ctx: dict, schedule_id: str): ) return {"status": "sent", "outfit_id": str(outfit.id)} + except AIDisabledError: + # Internal text is off — defer to the external agent; skip, don't retry. + logger.info(f"Skipping schedule {schedule_id}: internal AI text disabled") + return {"status": "skipped", "reason": "internal_ai_disabled"} except ValueError as e: logger.warning(f"Cannot generate outfit for schedule {schedule_id}: {e}") return {"status": "skipped", "reason": str(e)} diff --git a/backend/app/workers/tagging.py b/backend/app/workers/tagging.py index 3840b53a..615b1329 100644 --- a/backend/app/workers/tagging.py +++ b/backend/app/workers/tagging.py @@ -5,6 +5,7 @@ from sqlalchemy import select +from app.config import get_settings from app.models.item import ClothingItem, ItemStatus from app.services.ai_service import AIService, ClothingTags from app.workers.db import get_db_session @@ -52,6 +53,22 @@ def tags_to_item_fields(tags: ClothingTags, raw_response: str | None = None) -> return fields +async def mark_item_tagging_skipped(ctx: dict, item_id: str) -> None: + """Promote an item still in 'processing' to 'ready' (untagged) when vision is off.""" + try: + db = get_db_session(ctx) + try: + result = await db.execute(select(ClothingItem).where(ClothingItem.id == UUID(item_id))) + item = result.scalar_one_or_none() + if item and item.status == ItemStatus.processing: + item.status = ItemStatus.ready + await db.commit() + finally: + await db.close() + except Exception as e: + logger.error(f"Failed to mark item {item_id} ready (untagged): {e}") + + async def update_item_status_to_error(ctx: dict, item_id: str, error_msg: str) -> None: """Update item status to error in database.""" try: @@ -83,6 +100,12 @@ async def tag_item_image(ctx: dict, item_id: str, image_path: str) -> dict[str, """ logger.info(f"Starting AI tagging for item {item_id}") + # Vision off — leave the item ready (untagged) for an external agent to tag. + if not get_settings().effective_ai_vision_enabled: + logger.info(f"Internal vision disabled; skipping AI tagging for item {item_id}") + await mark_item_tagging_skipped(ctx, item_id) + return {"status": "skipped", "reason": "vision disabled", "item_id": item_id} + try: # Verify image exists path = Path(image_path) diff --git a/backend/app/workers/worker.py b/backend/app/workers/worker.py index 0290e0a6..06972b0c 100644 --- a/backend/app/workers/worker.py +++ b/backend/app/workers/worker.py @@ -44,9 +44,13 @@ async def recover_stale_processing_items(ctx: dict) -> None: async def startup(ctx: dict) -> None: logger.info("Worker starting up...") await init_db(ctx) - ctx["ai_service"] = AIService() - health = await ctx["ai_service"].check_health() - logger.info(f"AI service health: {health}") + if get_settings().ai_enabled: + ctx["ai_service"] = AIService() + health = await ctx["ai_service"].check_health() + logger.info(f"AI service health: {health}") + else: + ctx["ai_service"] = None + logger.info("Internal AI disabled; skipping AI client init and health check") await recover_stale_processing_items(ctx) From c0330387e26c621dfc641b8b84af0685812a1466 Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Thu, 18 Jun 2026 01:00:22 +0200 Subject: [PATCH 04/10] test(ai): cover capability flags, guards, and deferral paths Cover the flag-resolution truth table; the disabled guard at get_ai_service, AIService.__init__, and require_internal_ai (including vision-off/text-on isolation); the tagging worker skip and startup skip; the /capabilities and /health/ai endpoints on and off; and the typed 503 for suggest/pairings. Also assert generate_recommendation and generate_pairings defer up front (before location/item validation), locking the guard ordering. --- backend/tests/test_capabilities.py | 347 +++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 backend/tests/test_capabilities.py diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py new file mode 100644 index 00000000..1a0ede4b --- /dev/null +++ b/backend/tests/test_capabilities.py @@ -0,0 +1,347 @@ +"""Tests for AI capability flags, the /capabilities endpoint, and the AI-disabled +guard. Covers the AI-off and AI-on paths and asserts defaults preserve current +behavior (internal AI on).""" + +import pytest +from httpx import AsyncClient + +from app.config import Settings +from app.services.ai_service import ( + AIDisabledError, + AIService, + get_ai_service, + require_internal_ai, +) + +# --- Flag resolution truth table (no DB) ----------------------------------- + + +@pytest.mark.parametrize( + "internal, vision, text, exp_vision, exp_text", + [ + # Defaults: everything inherits the master, which defaults on. + (True, None, None, True, True), + # Master on, a single capability explicitly disabled. + (True, False, None, False, True), + (True, None, False, True, False), + (True, False, False, False, False), + # Master off forces both off, even with an explicit sub-switch on. + (False, None, None, False, False), + (False, True, None, False, False), + (False, True, True, False, False), + # Master on, both explicitly enabled. + (True, True, True, True, True), + ], +) +def test_effective_flag_resolution(internal, vision, text, exp_vision, exp_text): + settings = Settings( + ai_internal_enabled=internal, + ai_vision_enabled=vision, + ai_text_enabled=text, + ) + assert settings.effective_ai_vision_enabled is exp_vision + assert settings.effective_ai_text_enabled is exp_text + assert settings.ai_enabled is (exp_vision or exp_text) + + +def test_defaults_keep_internal_ai_on(): + """Regression: an unconfigured Settings keeps internal AI fully on.""" + settings = Settings() + assert settings.ai_internal_enabled is True + assert settings.effective_ai_vision_enabled is True + assert settings.effective_ai_text_enabled is True + assert settings.ai_enabled is True + + +# --- AI-disabled guard ------------------------------------------------------ + + +def test_get_ai_service_raises_when_disabled(monkeypatch): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + with pytest.raises(AIDisabledError): + get_ai_service() + + +def test_get_ai_service_constructs_when_enabled(monkeypatch): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_internal_enabled=True), + ) + service = get_ai_service() + assert service is not None + + +# --- /capabilities endpoint ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_capabilities_default_on(client: AsyncClient): + response = await client.get("/api/v1/capabilities") + assert response.status_code == 200 + data = response.json() + assert data["ai"] == {"vision": True, "text": True} + assert data["features"] == { + "external_tagging": True, + "external_suggestions": True, + "external_pairings": True, + } + assert data["version"] == "1.0.0" + + +@pytest.mark.asyncio +async def test_capabilities_reports_disabled(client: AsyncClient, monkeypatch): + monkeypatch.setattr( + "app.api.health.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + response = await client.get("/api/v1/capabilities") + assert response.status_code == 200 + assert response.json()["ai"] == {"vision": False, "text": False} + + +@pytest.mark.asyncio +async def test_capabilities_reports_vision_only(client: AsyncClient, monkeypatch): + monkeypatch.setattr( + "app.api.health.get_settings", + lambda: Settings(ai_text_enabled=False), + ) + response = await client.get("/api/v1/capabilities") + assert response.status_code == 200 + assert response.json()["ai"] == {"vision": True, "text": False} + + +@pytest.mark.asyncio +async def test_ai_health_reports_disabled(client: AsyncClient, monkeypatch): + """With AI off, /health/ai degrades cleanly instead of probing endpoints.""" + monkeypatch.setattr( + "app.api.health.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + response = await client.get("/api/v1/health/ai") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "disabled" + assert data["endpoints"] == [] + + +# --- require_internal_ai() guard (per-capability) --------------------------- + + +def test_require_internal_ai_text_raises_when_off(monkeypatch): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_text_enabled=False), + ) + with pytest.raises(AIDisabledError): + require_internal_ai("text") + + +def test_require_internal_ai_vision_raises_when_off(monkeypatch): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_vision_enabled=False), + ) + with pytest.raises(AIDisabledError): + require_internal_ai("vision") + + +def test_require_internal_ai_passes_when_on(monkeypatch): + monkeypatch.setattr("app.services.ai_service.get_settings", lambda: Settings()) + # Neither call raises when internal AI is on. + require_internal_ai("vision") + require_internal_ai("text") + + +def test_require_internal_ai_capability_isolation(monkeypatch): + """vision off + text on: the text guard passes, the vision guard raises.""" + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_vision_enabled=False), + ) + require_internal_ai("text") + with pytest.raises(AIDisabledError): + require_internal_ai("vision") + + +# --- AIService.__init__ backstop -------------------------------------------- + + +def test_aiservice_init_raises_when_fully_disabled(monkeypatch): + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_internal_enabled=False), + ) + with pytest.raises(AIDisabledError): + AIService() + + +def test_aiservice_init_constructs_when_enabled(monkeypatch): + monkeypatch.setattr("app.services.ai_service.get_settings", lambda: Settings()) + assert AIService() is not None + + +# --- Worker: tagging no-op when vision disabled ----------------------------- + + +@pytest.mark.asyncio +async def test_tag_item_image_skips_when_vision_disabled(monkeypatch): + """Vision off: the task builds no client, calls no provider, and leaves the + item ready (untagged) instead of marking it error.""" + from app.workers import tagging + + monkeypatch.setattr(tagging, "get_settings", lambda: Settings(ai_internal_enabled=False)) + + def _boom(*args, **kwargs): + raise AssertionError("AIService constructed while vision disabled") + + monkeypatch.setattr(tagging, "AIService", _boom) + + marked: dict[str, str] = {} + + async def _skip(ctx, item_id): + marked["id"] = item_id + + monkeypatch.setattr(tagging, "mark_item_tagging_skipped", _skip) + + item_id = "11111111-1111-1111-1111-111111111111" + result = await tagging.tag_item_image({}, item_id, "/unused/path.jpg") + assert result["status"] == "skipped" + assert marked["id"] == item_id + + +@pytest.mark.asyncio +async def test_tag_item_image_runs_ai_when_enabled(monkeypatch): + """Regression: vision on still reaches the AI path (constructs a client).""" + from app.workers import tagging + + monkeypatch.setattr(tagging, "get_settings", lambda: Settings()) + + constructed = {"called": False} + + class _StubAI: + def __init__(self, *args, **kwargs): + constructed["called"] = True + + async def analyze_image(self, path): # pragma: no cover - not reached + raise RuntimeError("stop after construction") + + monkeypatch.setattr(tagging, "AIService", _StubAI) + + async def _err(ctx, item_id, msg): + return None + + monkeypatch.setattr(tagging, "update_item_status_to_error", _err) + + # Missing image short-circuits before construction, so point at this file. + result = await tagging.tag_item_image({}, "1", __file__) + # It did NOT take the "skipped" branch — it proceeded into the AI path. + assert result["status"] != "skipped" + + +# --- Worker startup: no AI client when fully disabled ----------------------- + + +@pytest.mark.asyncio +async def test_worker_startup_skips_ai_when_disabled(monkeypatch): + from app.workers import worker + + monkeypatch.setattr(worker, "get_settings", lambda: Settings(ai_internal_enabled=False)) + + def _boom(*args, **kwargs): + raise AssertionError("AIService constructed at startup while disabled") + + monkeypatch.setattr(worker, "AIService", _boom) + + async def _noop(ctx): + return None + + monkeypatch.setattr(worker, "init_db", _noop) + monkeypatch.setattr(worker, "recover_stale_processing_items", _noop) + + ctx: dict = {} + await worker.startup(ctx) + assert ctx["ai_service"] is None + + +# --- Endpoints degrade to a clean 503 (not 500) when text is off ------------ + + +@pytest.mark.asyncio +async def test_suggest_returns_503_when_text_disabled(client, auth_headers, monkeypatch): + async def _raise(self, *args, **kwargs): + raise AIDisabledError("text disabled") + + monkeypatch.setattr( + "app.services.recommendation_service.RecommendationService.generate_recommendation", + _raise, + ) + resp = await client.post( + "/api/v1/outfits/suggest", json={"occasion": "casual"}, headers=auth_headers + ) + assert resp.status_code == 503 + assert "external agent" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_pairings_returns_503_when_text_disabled(client, auth_headers, monkeypatch): + async def _raise(self, *args, **kwargs): + raise AIDisabledError("text disabled") + + monkeypatch.setattr( + "app.services.pairing_service.PairingService.generate_pairings", + _raise, + ) + item_id = "22222222-2222-2222-2222-222222222222" + resp = await client.post( + f"/api/v1/pairings/generate/{item_id}", + json={"num_pairings": 3}, + headers=auth_headers, + ) + assert resp.status_code == 503 + assert "external agent" in resp.json()["detail"] + + +# --- Service guards run first: deferred contract is unconditional ------------ +# These exercise the REAL guard placement (the 503 tests above stub the service +# methods). They lock in fail-fast ordering: with text disabled the guard must +# fire before any location/weather/item validation, so the deferred contract +# never depends on unrelated preconditions. + + +@pytest.mark.asyncio +async def test_generate_recommendation_defers_before_location_check( + db_session, test_user, monkeypatch +): + from app.services.recommendation_service import RecommendationService + + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_text_enabled=False), + ) + service = RecommendationService(db_session) + # test_user has no location set; a misordered guard would raise + # ValueError("User location not set") before reaching the AI guard. + with pytest.raises(AIDisabledError): + await service.generate_recommendation(user=test_user, occasion="casual") + + +@pytest.mark.asyncio +async def test_generate_pairings_defers_before_item_lookup( + db_session, test_user, monkeypatch +): + from uuid import uuid4 + + from app.services.pairing_service import PairingService + + monkeypatch.setattr( + "app.services.ai_service.get_settings", + lambda: Settings(ai_text_enabled=False), + ) + service = PairingService(db_session) + # Nonexistent source item; a misordered guard would raise + # ValueError("Source item not found") before reaching the AI guard. + with pytest.raises(AIDisabledError): + await service.generate_pairings(user=test_user, source_item_id=uuid4()) From 329204e1bd4fa505bcfcde0247c18bf16a744cc1 Mon Sep 17 00:00:00 2001 From: Jan Sitarski Date: Thu, 18 Jun 2026 01:24:59 +0200 Subject: [PATCH 05/10] feat(ai): wire capability switches into compose and k8s deployments The AI_INTERNAL_ENABLED / AI_VISION_ENABLED / AI_TEXT_ENABLED switches were documented in .env.example and the README but never passed to the containers, so the documented "disable internal AI" path was inert under both docker compose and Kubernetes. Forward them to the backend and worker in docker-compose.yml and docker-compose.prod.yml (defaulting to on), and add them to the k8s ConfigMap and the backend/worker Deployments as optional refs so existing ConfigMaps without the keys still start. Defaults preserve current behavior. --- docker-compose.prod.yml | 6 ++++++ docker-compose.yml | 6 ++++++ k8s/backend.yaml | 18 ++++++++++++++++++ k8s/configmap.yaml | 7 +++++++ k8s/worker.yaml | 18 ++++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 06626335..00639a78 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -53,6 +53,9 @@ services: AI_TEXT_MODEL: ${AI_TEXT_MODEL:-gpt-4o} AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} + AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} LOG_LEVEL: ${LOG_LEVEL:-INFO} SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env} # OIDC (set these if using OIDC instead of forward auth) @@ -104,6 +107,9 @@ services: AI_TEXT_MODEL: ${AI_TEXT_MODEL:-gpt-4o} AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} + AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} LOG_LEVEL: ${LOG_LEVEL:-INFO} # Notifications NTFY_SERVER: ${NTFY_SERVER:-} diff --git a/docker-compose.yml b/docker-compose.yml index b9628b64..52519be3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,9 @@ services: AI_TEXT_MODEL: ${AI_TEXT_MODEL:-gpt-4o} AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} + AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} NTFY_SERVER: ${NTFY_SERVER:-} NTFY_TOKEN: ${NTFY_TOKEN:-} MATTERMOST_WEBHOOK_URL: ${MATTERMOST_WEBHOOK_URL:-} @@ -104,6 +107,9 @@ services: AI_TEXT_MODEL: ${AI_TEXT_MODEL:-gpt-4o} AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} + AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} STORAGE_PATH: /data/wardrobe volumes: - wardrobe_data:/data/wardrobe diff --git a/k8s/backend.yaml b/k8s/backend.yaml index 44576f0f..dd9bb345 100644 --- a/k8s/backend.yaml +++ b/k8s/backend.yaml @@ -89,6 +89,24 @@ spec: configMapKeyRef: name: wardrobe-config key: AI_MAX_RETRIES + - name: AI_INTERNAL_ENABLED + valueFrom: + configMapKeyRef: + name: wardrobe-config + key: AI_INTERNAL_ENABLED + optional: true + - name: AI_VISION_ENABLED + valueFrom: + configMapKeyRef: + name: wardrobe-config + key: AI_VISION_ENABLED + optional: true + - name: AI_TEXT_ENABLED + valueFrom: + configMapKeyRef: + name: wardrobe-config + key: AI_TEXT_ENABLED + optional: true - name: LOG_LEVEL valueFrom: configMapKeyRef: diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml index a75c47b6..68cc95a1 100644 --- a/k8s/configmap.yaml +++ b/k8s/configmap.yaml @@ -27,6 +27,13 @@ data: AI_TIMEOUT: "120" AI_MAX_RETRIES: "3" + # Capability switches. Defaults keep internal AI on. Set AI_INTERNAL_ENABLED + # to "false" to run with no internal AI and defer to an external agent; the + # per-capability switches inherit the master when unset. + AI_INTERNAL_ENABLED: "true" + AI_VISION_ENABLED: "true" + AI_TEXT_ENABLED: "true" + # Logging LOG_LEVEL: "INFO" diff --git a/k8s/worker.yaml b/k8s/worker.yaml index b518ed4a..62467bcc 100644 --- a/k8s/worker.yaml +++ b/k8s/worker.yaml @@ -76,6 +76,24 @@ spec: configMapKeyRef: name: wardrobe-config key: AI_MAX_RETRIES + - name: AI_INTERNAL_ENABLED + valueFrom: + configMapKeyRef: + name: wardrobe-config + key: AI_INTERNAL_ENABLED + optional: true + - name: AI_VISION_ENABLED + valueFrom: + configMapKeyRef: + name: wardrobe-config + key: AI_VISION_ENABLED + optional: true + - name: AI_TEXT_ENABLED + valueFrom: + configMapKeyRef: + name: wardrobe-config + key: AI_TEXT_ENABLED + optional: true - name: LOG_LEVEL valueFrom: configMapKeyRef: From e3364af697ca79abd56d2957500dbbeca50dea3a Mon Sep 17 00:00:00 2001 From: Anish Shrestha Date: Tue, 30 Jun 2026 23:19:47 +1000 Subject: [PATCH 06/10] fix: propagate DB errors in mark_item_tagging_skipped so arq retries --- backend/app/workers/tagging.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/backend/app/workers/tagging.py b/backend/app/workers/tagging.py index 615b1329..48bdad89 100644 --- a/backend/app/workers/tagging.py +++ b/backend/app/workers/tagging.py @@ -54,19 +54,15 @@ def tags_to_item_fields(tags: ClothingTags, raw_response: str | None = None) -> async def mark_item_tagging_skipped(ctx: dict, item_id: str) -> None: - """Promote an item still in 'processing' to 'ready' (untagged) when vision is off.""" + db = get_db_session(ctx) try: - db = get_db_session(ctx) - try: - result = await db.execute(select(ClothingItem).where(ClothingItem.id == UUID(item_id))) - item = result.scalar_one_or_none() - if item and item.status == ItemStatus.processing: - item.status = ItemStatus.ready - await db.commit() - finally: - await db.close() - except Exception as e: - logger.error(f"Failed to mark item {item_id} ready (untagged): {e}") + result = await db.execute(select(ClothingItem).where(ClothingItem.id == UUID(item_id))) + item = result.scalar_one_or_none() + if item and item.status == ItemStatus.processing: + item.status = ItemStatus.ready + await db.commit() + finally: + await db.close() async def update_item_status_to_error(ctx: dict, item_id: str, error_msg: str) -> None: @@ -100,7 +96,6 @@ async def tag_item_image(ctx: dict, item_id: str, image_path: str) -> dict[str, """ logger.info(f"Starting AI tagging for item {item_id}") - # Vision off — leave the item ready (untagged) for an external agent to tag. if not get_settings().effective_ai_vision_enabled: logger.info(f"Internal vision disabled; skipping AI tagging for item {item_id}") await mark_item_tagging_skipped(ctx, item_id) From 1ebd1a8427a5e5be3d0f2889d2a4d66332dfad06 Mon Sep 17 00:00:00 2001 From: Anish Shrestha Date: Tue, 30 Jun 2026 23:20:00 +1000 Subject: [PATCH 07/10] fix: reset _ai_service singleton in test to prevent stale instance --- backend/tests/test_capabilities.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index 1a0ede4b..b8cffc9d 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -70,8 +70,9 @@ def test_get_ai_service_constructs_when_enabled(monkeypatch): "app.services.ai_service.get_settings", lambda: Settings(ai_internal_enabled=True), ) + monkeypatch.setattr("app.services.ai_service._ai_service", None) service = get_ai_service() - assert service is not None + assert isinstance(service, AIService) # --- /capabilities endpoint ------------------------------------------------- @@ -329,9 +330,7 @@ async def test_generate_recommendation_defers_before_location_check( @pytest.mark.asyncio -async def test_generate_pairings_defers_before_item_lookup( - db_session, test_user, monkeypatch -): +async def test_generate_pairings_defers_before_item_lookup(db_session, test_user, monkeypatch): from uuid import uuid4 from app.services.pairing_service import PairingService From 23da9e04367c54601eed0b49bf8cfdf83561f5ba Mon Sep 17 00:00:00 2001 From: Anish Shrestha Date: Tue, 30 Jun 2026 23:22:06 +1000 Subject: [PATCH 08/10] fix: sub-capability env vars inherit master when unset in compose --- backend/app/config.py | 1 + docker-compose.prod.yml | 8 ++++---- docker-compose.yml | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 252ebd6d..57abbc0e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -15,6 +15,7 @@ class Settings(BaseSettings): env_file_encoding="utf-8", case_sensitive=False, extra="ignore", + env_ignore_empty=True, ) # Application diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 00639a78..a5337fdd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -54,8 +54,8 @@ services: AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} - AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} - AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-} LOG_LEVEL: ${LOG_LEVEL:-INFO} SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env} # OIDC (set these if using OIDC instead of forward auth) @@ -108,8 +108,8 @@ services: AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} - AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} - AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-} LOG_LEVEL: ${LOG_LEVEL:-INFO} # Notifications NTFY_SERVER: ${NTFY_SERVER:-} diff --git a/docker-compose.yml b/docker-compose.yml index 52519be3..8f910d64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,8 +56,8 @@ services: AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} - AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} - AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-} NTFY_SERVER: ${NTFY_SERVER:-} NTFY_TOKEN: ${NTFY_TOKEN:-} MATTERMOST_WEBHOOK_URL: ${MATTERMOST_WEBHOOK_URL:-} @@ -108,8 +108,8 @@ services: AI_TIMEOUT: ${AI_TIMEOUT:-120} AI_MAX_RETRIES: ${AI_MAX_RETRIES:-3} AI_INTERNAL_ENABLED: ${AI_INTERNAL_ENABLED:-true} - AI_VISION_ENABLED: ${AI_VISION_ENABLED:-true} - AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-true} + AI_VISION_ENABLED: ${AI_VISION_ENABLED:-} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-} STORAGE_PATH: /data/wardrobe volumes: - wardrobe_data:/data/wardrobe From 87925b6925d8ef04b656e51e0edc699c7670f8e5 Mon Sep 17 00:00:00 2001 From: Anish Shrestha Date: Tue, 30 Jun 2026 23:22:25 +1000 Subject: [PATCH 09/10] fix: capabilities features flags default to false until write-back endpoints land --- backend/app/api/health.py | 6 +++--- backend/tests/test_capabilities.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/app/api/health.py b/backend/app/api/health.py index f3c685fd..f354233c 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -28,9 +28,9 @@ async def capabilities() -> dict[str, Any]: "text": settings.effective_ai_text_enabled, }, "features": { - "external_tagging": True, - "external_suggestions": True, - "external_pairings": True, + "external_tagging": False, + "external_suggestions": False, + "external_pairings": False, }, "version": "1.0.0", } diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index b8cffc9d..aac3d825 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -85,9 +85,9 @@ async def test_capabilities_default_on(client: AsyncClient): data = response.json() assert data["ai"] == {"vision": True, "text": True} assert data["features"] == { - "external_tagging": True, - "external_suggestions": True, - "external_pairings": True, + "external_tagging": False, + "external_suggestions": False, + "external_pairings": False, } assert data["version"] == "1.0.0" From 23d4ec11e8dbadaf785c006b7d88066354d5123a Mon Sep 17 00:00:00 2001 From: Anish Shrestha Date: Tue, 30 Jun 2026 23:26:50 +1000 Subject: [PATCH 10/10] fix: document features flags intent and add env_ignore_empty regression test --- backend/app/api/health.py | 12 +++++++++--- backend/tests/test_capabilities.py | 13 ++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/app/api/health.py b/backend/app/api/health.py index f354233c..2cd12c7e 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -18,15 +18,21 @@ async def health_check() -> dict[str, str]: @router.get("/capabilities") async def capabilities() -> dict[str, Any]: - """Report effective AI capabilities so external agents (e.g. the MCP server) - can decide whether to route tagging/suggestions/pairings to themselves or - trust the backend. Public/no-auth: leaks no user data.""" + """Report effective AI capabilities for external agents. + + `ai.*` — whether the internal AI capability is active. `false` means the backend + is deferring that work to an external agent. + `features.*` — whether the write-back endpoint for that capability exists and + accepts agent-authored results. `false` until the endpoint lands. + Public / no-auth: leaks no user data. + """ settings = get_settings() return { "ai": { "vision": settings.effective_ai_vision_enabled, "text": settings.effective_ai_text_enabled, }, + # Set to True in PR 2-3 when agent write-back endpoints exist. "features": { "external_tagging": False, "external_suggestions": False, diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py index aac3d825..28af51b3 100644 --- a/backend/tests/test_capabilities.py +++ b/backend/tests/test_capabilities.py @@ -45,7 +45,6 @@ def test_effective_flag_resolution(internal, vision, text, exp_vision, exp_text) def test_defaults_keep_internal_ai_on(): - """Regression: an unconfigured Settings keeps internal AI fully on.""" settings = Settings() assert settings.ai_internal_enabled is True assert settings.effective_ai_vision_enabled is True @@ -53,6 +52,18 @@ def test_defaults_keep_internal_ai_on(): assert settings.ai_enabled is True +def test_empty_string_env_var_inherits_master(monkeypatch): + # env_ignore_empty=True: compose :- default sends "" which must map to None, + # so the sub-switch inherits the master rather than clamping to True. + monkeypatch.setenv("AI_VISION_ENABLED", "") + monkeypatch.setenv("AI_TEXT_ENABLED", "") + settings = Settings(ai_internal_enabled=True) + assert settings.ai_vision_enabled is None + assert settings.ai_text_enabled is None + assert settings.effective_ai_vision_enabled is True + assert settings.effective_ai_text_enabled is True + + # --- AI-disabled guard ------------------------------------------------------