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..2cd12c7e 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,32 @@ async def health_check() -> dict[str, str]: return {"status": "healthy"} +@router.get("/capabilities") +async def capabilities() -> dict[str, Any]: + """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, + "external_pairings": False, + }, + "version": "1.0.0", + } + + @router.get("/health/ready") async def readiness_check(db: AsyncSession = Depends(get_db)) -> dict[str, Any]: checks = { @@ -50,6 +77,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/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/config.py b/backend/app/config.py index e8846819..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 @@ -41,6 +42,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 +89,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( 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() 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..48bdad89 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,18 @@ 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: + 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() + + 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 +96,11 @@ 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}") + 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) diff --git a/backend/tests/test_capabilities.py b/backend/tests/test_capabilities.py new file mode 100644 index 00000000..28af51b3 --- /dev/null +++ b/backend/tests/test_capabilities.py @@ -0,0 +1,357 @@ +"""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(): + 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 + + +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 ------------------------------------------------------ + + +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), + ) + monkeypatch.setattr("app.services.ai_service._ai_service", None) + service = get_ai_service() + assert isinstance(service, AIService) + + +# --- /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": False, + "external_suggestions": False, + "external_pairings": False, + } + 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()) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 06626335..a5337fdd 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:-} + 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) @@ -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:-} + 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 b9628b64..8f910d64 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:-} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-} 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:-} + AI_TEXT_ENABLED: ${AI_TEXT_ENABLED:-} 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: