Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ============================================================================

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
24 changes: 24 additions & 0 deletions backend/app/api/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 = {
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 6 additions & 0 deletions backend/app/api/outfits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions backend/app/api/pairings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion backend/app/services/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
35 changes: 34 additions & 1 deletion backend/app/services/ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import math
import re
from pathlib import Path
from typing import Literal

import httpx
from PIL import Image, ImageOps
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion backend/app/services/pairing_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion backend/app/services/recommendation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 []

Expand Down
5 changes: 5 additions & 0 deletions backend/app/workers/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}
Expand Down
23 changes: 23 additions & 0 deletions backend/app/workers/tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions backend/app/workers/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading