Coverage for apps / ai / api.py: 54%
318 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-14 19:13 +0000
1"""
2AI settings and prompts API endpoints.
3"""
5from functools import wraps
6from typing import Callable, List, Optional
8from ninja import Router, Schema
10from apps.core.models import AppSettings
11from apps.profiles.models import Profile
12from apps.recipes.models import Recipe
14from .models import AIPrompt
15from .services.openrouter import OpenRouterService, AIUnavailableError, AIResponseError
16from .services.remix import get_remix_suggestions, create_remix
17from .services.scaling import scale_recipe, calculate_nutrition
18from .services.tips import generate_tips, clear_tips
19from .services.discover import get_discover_suggestions
20from .services.timer import generate_timer_name
21from .services.selector import repair_selector, get_sources_needing_attention
22from .services.validator import ValidationError
24router = Router(tags=["ai"])
27# Decorators
30def handle_ai_errors(func: Callable) -> Callable:
31 """Decorator to handle common AI service errors.
33 Catches AIUnavailableError, AIResponseError, and ValidationError,
34 returning appropriate error responses.
36 Returns:
37 - 503 with 'ai_unavailable' error for AIUnavailableError
38 - 400 with 'ai_error' error for AIResponseError or ValidationError
39 """
41 @wraps(func)
42 def wrapper(*args, **kwargs):
43 try:
44 return func(*args, **kwargs)
45 except AIUnavailableError as e:
46 return 503, {
47 "error": "ai_unavailable",
48 "message": str(e) or "AI features are not available. Please configure your API key in Settings.",
49 "action": "configure_key",
50 }
51 except (AIResponseError, ValidationError) as e:
52 return 400, {
53 "error": "ai_error",
54 "message": str(e),
55 }
57 return wrapper
60# Schemas
63class AIStatusOut(Schema):
64 available: bool
65 configured: bool
66 valid: bool
67 default_model: str
68 error: Optional[str] = None
69 error_code: Optional[str] = None
72class TestApiKeyIn(Schema):
73 api_key: str
76class TestApiKeyOut(Schema):
77 success: bool
78 message: str
81class SaveApiKeyIn(Schema):
82 api_key: str
85class SaveApiKeyOut(Schema):
86 success: bool
87 message: str
90class PromptOut(Schema):
91 prompt_type: str
92 name: str
93 description: str
94 system_prompt: str
95 user_prompt_template: str
96 model: str
97 is_active: bool
100class PromptUpdateIn(Schema):
101 system_prompt: Optional[str] = None
102 user_prompt_template: Optional[str] = None
103 model: Optional[str] = None
104 is_active: Optional[bool] = None
107class ModelOut(Schema):
108 id: str
109 name: str
112class ErrorOut(Schema):
113 error: str
114 message: str
115 action: Optional[str] = None # User-facing action to resolve the error
118# Endpoints
121@router.get("/status", response=AIStatusOut)
122def get_ai_status(request):
123 """Check if AI service is available with optional key validation.
125 Returns a status object with:
126 - available: Whether AI features can be used (configured AND valid)
127 - configured: Whether an API key is configured
128 - valid: Whether the API key has been validated successfully
129 - default_model: The default AI model
130 - error: Error message if something is wrong
131 - error_code: Machine-readable error code
132 """
133 settings = AppSettings.get()
134 has_key = bool(settings.openrouter_api_key)
136 status = {
137 "available": False,
138 "configured": has_key,
139 "valid": False,
140 "default_model": settings.default_ai_model,
141 "error": None,
142 "error_code": None,
143 }
145 if not has_key:
146 status["error"] = "No API key configured"
147 status["error_code"] = "no_api_key"
148 return status
150 # Validate key using cached validation
151 is_valid, error_message = OpenRouterService.validate_key_cached()
152 status["valid"] = is_valid
153 status["available"] = is_valid
155 if not is_valid:
156 status["error"] = error_message or "API key is invalid or expired"
157 status["error_code"] = "invalid_api_key"
159 return status
162@router.post("/test-api-key", response={200: TestApiKeyOut, 400: ErrorOut})
163def test_api_key(request, data: TestApiKeyIn):
164 """Test if an API key is valid."""
165 if not data.api_key:
166 return 400, {
167 "error": "validation_error",
168 "message": "API key is required",
169 }
171 success, message = OpenRouterService.test_connection(data.api_key)
172 return {
173 "success": success,
174 "message": message,
175 }
178@router.post("/save-api-key", response={200: SaveApiKeyOut, 400: ErrorOut})
179def save_api_key(request, data: SaveApiKeyIn):
180 """Save the OpenRouter API key."""
181 settings = AppSettings.get()
182 settings.openrouter_api_key = data.api_key
183 settings.save()
185 # Invalidate the validation cache since key was updated
186 OpenRouterService.invalidate_key_cache()
188 return {
189 "success": True,
190 "message": "API key saved successfully",
191 }
194@router.get("/prompts", response=List[PromptOut])
195def list_prompts(request):
196 """List all AI prompts."""
197 prompts = AIPrompt.objects.all()
198 return list(prompts)
201@router.get("/prompts/{prompt_type}", response={200: PromptOut, 404: ErrorOut})
202def get_prompt(request, prompt_type: str):
203 """Get a specific AI prompt by type."""
204 try:
205 prompt = AIPrompt.objects.get(prompt_type=prompt_type)
206 return prompt
207 except AIPrompt.DoesNotExist:
208 return 404, {
209 "error": "not_found",
210 "message": f'Prompt type "{prompt_type}" not found',
211 }
214@router.put("/prompts/{prompt_type}", response={200: PromptOut, 404: ErrorOut, 422: ErrorOut})
215def update_prompt(request, prompt_type: str, data: PromptUpdateIn):
216 """Update a specific AI prompt."""
217 try:
218 prompt = AIPrompt.objects.get(prompt_type=prompt_type)
219 except AIPrompt.DoesNotExist:
220 return 404, {
221 "error": "not_found",
222 "message": f'Prompt type "{prompt_type}" not found',
223 }
225 # Validate model if provided
226 if data.model is not None:
227 try:
228 service = OpenRouterService()
229 available_models = service.get_available_models()
230 valid_model_ids = {m["id"] for m in available_models}
232 if data.model not in valid_model_ids:
233 return 422, {
234 "error": "invalid_model",
235 "message": f'Model "{data.model}" is not available. Please select a valid model.',
236 }
237 except AIUnavailableError:
238 # If we can't validate (no API key), allow the change but it may fail later
239 pass
240 except AIResponseError:
241 # If model list fetch fails, allow the change but it may fail later
242 pass
244 # Update only provided fields
245 if data.system_prompt is not None:
246 prompt.system_prompt = data.system_prompt
247 if data.user_prompt_template is not None:
248 prompt.user_prompt_template = data.user_prompt_template
249 if data.model is not None:
250 prompt.model = data.model
251 if data.is_active is not None:
252 prompt.is_active = data.is_active
254 prompt.save()
255 return prompt
258@router.get("/models", response=List[ModelOut])
259def list_models(request):
260 """List available AI models from OpenRouter."""
261 try:
262 service = OpenRouterService()
263 return service.get_available_models()
264 except AIUnavailableError:
265 # No API key configured - return empty list
266 return []
267 except AIResponseError:
268 # API error - return empty list
269 return []
272# Remix Schemas
275class RemixSuggestionsIn(Schema):
276 recipe_id: int
279class RemixSuggestionsOut(Schema):
280 suggestions: List[str]
283class CreateRemixIn(Schema):
284 recipe_id: int
285 modification: str
286 profile_id: int
289class RemixOut(Schema):
290 id: int
291 title: str
292 description: str
293 ingredients: List[str]
294 instructions: List[str]
295 host: str
296 site_name: str
297 is_remix: bool
298 prep_time: Optional[int] = None
299 cook_time: Optional[int] = None
300 total_time: Optional[int] = None
301 yields: str = ""
302 servings: Optional[int] = None
305# Remix Endpoints
308@router.post("/remix-suggestions", response={200: RemixSuggestionsOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
309@handle_ai_errors
310def remix_suggestions(request, data: RemixSuggestionsIn):
311 """Get 6 AI-generated remix suggestions for a recipe.
313 Only works for recipes owned by the requesting profile.
314 """
315 from apps.profiles.utils import get_current_profile_or_none
317 profile = get_current_profile_or_none(request)
319 try:
320 recipe = Recipe.objects.get(id=data.recipe_id)
321 except Recipe.DoesNotExist:
322 return 404, {
323 "error": "not_found",
324 "message": f"Recipe {data.recipe_id} not found",
325 }
327 if not profile or recipe.profile_id != profile.id:
328 return 404, {
329 "error": "not_found",
330 "message": f"Recipe {data.recipe_id} not found",
331 }
333 suggestions = get_remix_suggestions(data.recipe_id)
334 return {"suggestions": suggestions}
337@router.post("/remix", response={200: RemixOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
338@handle_ai_errors
339def create_remix_endpoint(request, data: CreateRemixIn):
340 """Create a remixed recipe using AI.
342 Only works for recipes owned by the requesting profile.
343 The remix will be owned by the same profile.
344 """
345 from apps.profiles.utils import get_current_profile_or_none
347 profile = get_current_profile_or_none(request)
349 if not profile:
350 return 404, {
351 "error": "not_found",
352 "message": "Profile not found",
353 }
355 # Verify the profile_id in the request matches the session profile
356 if data.profile_id != profile.id:
357 return 404, {
358 "error": "not_found",
359 "message": f"Profile {data.profile_id} not found",
360 }
362 try:
363 recipe = Recipe.objects.get(id=data.recipe_id)
364 except Recipe.DoesNotExist:
365 return 404, {
366 "error": "not_found",
367 "message": f"Recipe {data.recipe_id} not found",
368 }
370 if recipe.profile_id != profile.id:
371 return 404, {
372 "error": "not_found",
373 "message": f"Recipe {data.recipe_id} not found",
374 }
376 remix = create_remix(
377 recipe_id=data.recipe_id,
378 modification=data.modification,
379 profile=profile,
380 )
381 return {
382 "id": remix.id,
383 "title": remix.title,
384 "description": remix.description,
385 "ingredients": remix.ingredients,
386 "instructions": remix.instructions,
387 "host": remix.host,
388 "site_name": remix.site_name,
389 "is_remix": remix.is_remix,
390 "prep_time": remix.prep_time,
391 "cook_time": remix.cook_time,
392 "total_time": remix.total_time,
393 "yields": remix.yields,
394 "servings": remix.servings,
395 }
398# Scaling Schemas
401class ScaleIn(Schema):
402 recipe_id: int
403 target_servings: int
404 unit_system: str = "metric"
405 profile_id: int
408class NutritionOut(Schema):
409 per_serving: dict
410 total: dict
413class ScaleOut(Schema):
414 target_servings: int
415 original_servings: int
416 ingredients: List[str]
417 instructions: List[str] = [] # QA-031
418 notes: List[str]
419 prep_time_adjusted: Optional[int] = None # QA-032
420 cook_time_adjusted: Optional[int] = None # QA-032
421 total_time_adjusted: Optional[int] = None # QA-032
422 nutrition: Optional[NutritionOut] = None
423 cached: bool
426# Scaling Endpoints
429@router.post("/scale", response={200: ScaleOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
430@handle_ai_errors
431def scale_recipe_endpoint(request, data: ScaleIn):
432 """Scale a recipe to a different number of servings.
434 Only works for recipes owned by the requesting profile.
435 """
436 from apps.profiles.utils import get_current_profile_or_none
438 profile = get_current_profile_or_none(request)
440 if not profile:
441 return 404, {
442 "error": "not_found",
443 "message": "Profile not found",
444 }
446 # Verify the profile_id in the request matches the session profile
447 if data.profile_id != profile.id:
448 return 404, {
449 "error": "not_found",
450 "message": f"Profile {data.profile_id} not found",
451 }
453 try:
454 recipe = Recipe.objects.get(id=data.recipe_id)
455 except Recipe.DoesNotExist:
456 return 404, {
457 "error": "not_found",
458 "message": f"Recipe {data.recipe_id} not found",
459 }
461 if recipe.profile_id != profile.id:
462 return 404, {
463 "error": "not_found",
464 "message": f"Recipe {data.recipe_id} not found",
465 }
467 try:
468 result = scale_recipe(
469 recipe_id=data.recipe_id,
470 target_servings=data.target_servings,
471 profile=profile,
472 unit_system=data.unit_system,
473 )
474 except ValueError as e:
475 return 400, {
476 "error": "validation_error",
477 "message": str(e),
478 }
480 # Calculate nutrition if available
481 nutrition = None
482 if recipe.nutrition:
483 nutrition = calculate_nutrition(
484 recipe=recipe,
485 original_servings=recipe.servings,
486 target_servings=data.target_servings,
487 )
489 return {
490 "target_servings": result["target_servings"],
491 "original_servings": result["original_servings"],
492 "ingredients": result["ingredients"],
493 "instructions": result.get("instructions", []), # QA-031
494 "notes": result["notes"],
495 "prep_time_adjusted": result.get("prep_time_adjusted"), # QA-032
496 "cook_time_adjusted": result.get("cook_time_adjusted"), # QA-032
497 "total_time_adjusted": result.get("total_time_adjusted"), # QA-032
498 "nutrition": nutrition,
499 "cached": result["cached"],
500 }
503# Tips Schemas
506class TipsIn(Schema):
507 recipe_id: int
508 regenerate: bool = False
511class TipsOut(Schema):
512 tips: List[str]
513 cached: bool
516# Tips Endpoints
519@router.post("/tips", response={200: TipsOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
520@handle_ai_errors
521def tips_endpoint(request, data: TipsIn):
522 """Generate cooking tips for a recipe.
524 Pass regenerate=True to clear existing tips and generate fresh ones.
525 Only works for recipes owned by the requesting profile.
526 """
527 from apps.profiles.utils import get_current_profile_or_none
529 profile = get_current_profile_or_none(request)
531 try:
532 recipe = Recipe.objects.get(id=data.recipe_id)
533 except Recipe.DoesNotExist:
534 return 404, {
535 "error": "not_found",
536 "message": f"Recipe {data.recipe_id} not found",
537 }
539 if not profile or recipe.profile_id != profile.id:
540 return 404, {
541 "error": "not_found",
542 "message": f"Recipe {data.recipe_id} not found",
543 }
545 # Clear existing tips if regenerate requested
546 if data.regenerate:
547 clear_tips(data.recipe_id)
549 result = generate_tips(data.recipe_id)
550 return result
553# Timer Naming Schemas
556class TimerNameIn(Schema):
557 step_text: str
558 duration_minutes: int
561class TimerNameOut(Schema):
562 label: str
565# Timer Naming Endpoints
568@router.post("/timer-name", response={200: TimerNameOut, 400: ErrorOut, 503: ErrorOut})
569@handle_ai_errors
570def timer_name_endpoint(request, data: TimerNameIn):
571 """Generate a descriptive name for a cooking timer.
573 Takes a cooking instruction and duration, returns a short label.
574 """
575 if not data.step_text:
576 return 400, {
577 "error": "validation_error",
578 "message": "Step text is required",
579 }
581 if data.duration_minutes <= 0:
582 return 400, {
583 "error": "validation_error",
584 "message": "Duration must be positive",
585 }
587 result = generate_timer_name(
588 step_text=data.step_text,
589 duration_minutes=data.duration_minutes,
590 )
591 return result
594# Discover Schemas
597class DiscoverSuggestionOut(Schema):
598 type: str
599 title: str
600 description: str
601 search_query: str
604class DiscoverOut(Schema):
605 suggestions: List[DiscoverSuggestionOut]
606 refreshed_at: str
609# Discover Endpoints
612@router.get("/discover/{profile_id}/", response={200: DiscoverOut, 404: ErrorOut, 503: ErrorOut})
613@handle_ai_errors
614def discover_endpoint(request, profile_id: int):
615 """Get AI discovery suggestions for a profile.
617 Returns cached suggestions if still valid (within 24 hours),
618 otherwise generates new suggestions via AI.
620 For new users (no favorites), only seasonal suggestions are returned.
621 """
622 try:
623 result = get_discover_suggestions(profile_id)
624 return result
625 except Profile.DoesNotExist:
626 return 404, {
627 "error": "not_found",
628 "message": f"Profile {profile_id} not found",
629 }
632# Selector Repair Schemas
635class SelectorRepairIn(Schema):
636 source_id: int
637 html_sample: str
638 target: str = "recipe search result"
639 confidence_threshold: float = 0.8
640 auto_update: bool = True
643class SelectorRepairOut(Schema):
644 suggestions: List[str]
645 confidence: float
646 original_selector: str
647 updated: bool
648 new_selector: Optional[str] = None
651class SourceNeedingAttentionOut(Schema):
652 id: int
653 host: str
654 name: str
655 result_selector: str
656 consecutive_failures: int
659# Selector Repair Endpoints
662@router.post("/repair-selector", response={200: SelectorRepairOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
663@handle_ai_errors
664def repair_selector_endpoint(request, data: SelectorRepairIn):
665 """Attempt to repair a broken CSS selector using AI.
667 Analyzes HTML from the search page and suggests new selectors.
668 If confidence is high enough and auto_update=True, the source is updated.
670 This endpoint is intended for admin/maintenance use.
671 """
672 from apps.recipes.models import SearchSource
674 try:
675 source = SearchSource.objects.get(id=data.source_id)
676 except SearchSource.DoesNotExist:
677 return 404, {
678 "error": "not_found",
679 "message": f"SearchSource {data.source_id} not found",
680 }
682 if not data.html_sample:
683 return 400, {
684 "error": "validation_error",
685 "message": "HTML sample is required",
686 }
688 result = repair_selector(
689 source=source,
690 html_sample=data.html_sample,
691 target=data.target,
692 confidence_threshold=data.confidence_threshold,
693 auto_update=data.auto_update,
694 )
695 return {
696 "suggestions": result["suggestions"],
697 "confidence": result["confidence"],
698 "original_selector": result["original_selector"] or "",
699 "updated": result["updated"],
700 "new_selector": result.get("new_selector"),
701 }
704@router.get("/sources-needing-attention", response=List[SourceNeedingAttentionOut])
705def sources_needing_attention_endpoint(request):
706 """List all SearchSources that need attention (broken selectors).
708 Returns sources with consecutive_failures >= 3 or needs_attention flag set.
709 """
710 sources = get_sources_needing_attention()
711 return [
712 {
713 "id": s.id,
714 "host": s.host,
715 "name": s.name,
716 "result_selector": s.result_selector or "",
717 "consecutive_failures": s.consecutive_failures,
718 }
719 for s in sources
720 ]