Coverage for apps / ai / api.py: 49%
328 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
1"""
2AI settings and prompts API endpoints.
3"""
5from typing import List, Optional
7from ninja import Router, Schema
9from apps.core.models import AppSettings
10from apps.profiles.models import Profile
11from apps.recipes.models import Recipe
13from .models import AIPrompt
14from .services.openrouter import OpenRouterService, AIUnavailableError, AIResponseError
15from .services.remix import get_remix_suggestions, create_remix
16from .services.scaling import scale_recipe, calculate_nutrition
17from .services.tips import generate_tips, clear_tips
18from .services.discover import get_discover_suggestions
19from .services.timer import generate_timer_name
20from .services.selector import repair_selector, get_sources_needing_attention
21from .services.validator import ValidationError
23router = Router(tags=['ai'])
26# Schemas
28class AIStatusOut(Schema):
29 available: bool
30 configured: bool
31 valid: bool
32 default_model: str
33 error: Optional[str] = None
34 error_code: Optional[str] = None
37class TestApiKeyIn(Schema):
38 api_key: str
41class TestApiKeyOut(Schema):
42 success: bool
43 message: str
46class SaveApiKeyIn(Schema):
47 api_key: str
50class SaveApiKeyOut(Schema):
51 success: bool
52 message: str
55class PromptOut(Schema):
56 prompt_type: str
57 name: str
58 description: str
59 system_prompt: str
60 user_prompt_template: str
61 model: str
62 is_active: bool
65class PromptUpdateIn(Schema):
66 system_prompt: Optional[str] = None
67 user_prompt_template: Optional[str] = None
68 model: Optional[str] = None
69 is_active: Optional[bool] = None
72class ModelOut(Schema):
73 id: str
74 name: str
77class ErrorOut(Schema):
78 error: str
79 message: str
80 action: Optional[str] = None # User-facing action to resolve the error
83# Endpoints
85@router.get('/status', response=AIStatusOut)
86def get_ai_status(request):
87 """Check if AI service is available with optional key validation.
89 Returns a status object with:
90 - available: Whether AI features can be used (configured AND valid)
91 - configured: Whether an API key is configured
92 - valid: Whether the API key has been validated successfully
93 - default_model: The default AI model
94 - error: Error message if something is wrong
95 - error_code: Machine-readable error code
96 """
97 settings = AppSettings.get()
98 has_key = bool(settings.openrouter_api_key)
100 status = {
101 'available': False,
102 'configured': has_key,
103 'valid': False,
104 'default_model': settings.default_ai_model,
105 'error': None,
106 'error_code': None,
107 }
109 if not has_key:
110 status['error'] = 'No API key configured'
111 status['error_code'] = 'no_api_key'
112 return status
114 # Validate key using cached validation
115 is_valid, error_message = OpenRouterService.validate_key_cached()
116 status['valid'] = is_valid
117 status['available'] = is_valid
119 if not is_valid:
120 status['error'] = error_message or 'API key is invalid or expired'
121 status['error_code'] = 'invalid_api_key'
123 return status
126@router.post('/test-api-key', response={200: TestApiKeyOut, 400: ErrorOut})
127def test_api_key(request, data: TestApiKeyIn):
128 """Test if an API key is valid."""
129 if not data.api_key:
130 return 400, {
131 'error': 'validation_error',
132 'message': 'API key is required',
133 }
135 success, message = OpenRouterService.test_connection(data.api_key)
136 return {
137 'success': success,
138 'message': message,
139 }
142@router.post('/save-api-key', response={200: SaveApiKeyOut, 400: ErrorOut})
143def save_api_key(request, data: SaveApiKeyIn):
144 """Save the OpenRouter API key."""
145 settings = AppSettings.get()
146 settings.openrouter_api_key = data.api_key
147 settings.save()
149 # Invalidate the validation cache since key was updated
150 OpenRouterService.invalidate_key_cache()
152 return {
153 'success': True,
154 'message': 'API key saved successfully',
155 }
158@router.get('/prompts', response=List[PromptOut])
159def list_prompts(request):
160 """List all AI prompts."""
161 prompts = AIPrompt.objects.all()
162 return list(prompts)
165@router.get('/prompts/{prompt_type}', response={200: PromptOut, 404: ErrorOut})
166def get_prompt(request, prompt_type: str):
167 """Get a specific AI prompt by type."""
168 try:
169 prompt = AIPrompt.objects.get(prompt_type=prompt_type)
170 return prompt
171 except AIPrompt.DoesNotExist:
172 return 404, {
173 'error': 'not_found',
174 'message': f'Prompt type "{prompt_type}" not found',
175 }
178@router.put('/prompts/{prompt_type}', response={200: PromptOut, 404: ErrorOut, 422: ErrorOut})
179def update_prompt(request, prompt_type: str, data: PromptUpdateIn):
180 """Update a specific AI prompt."""
181 try:
182 prompt = AIPrompt.objects.get(prompt_type=prompt_type)
183 except AIPrompt.DoesNotExist:
184 return 404, {
185 'error': 'not_found',
186 'message': f'Prompt type "{prompt_type}" not found',
187 }
189 # Validate model if provided
190 if data.model is not None:
191 try:
192 service = OpenRouterService()
193 available_models = service.get_available_models()
194 valid_model_ids = {m['id'] for m in available_models}
196 if data.model not in valid_model_ids:
197 return 422, {
198 'error': 'invalid_model',
199 'message': f'Model "{data.model}" is not available. Please select a valid model.',
200 }
201 except AIUnavailableError:
202 # If we can't validate (no API key), allow the change but it may fail later
203 pass
204 except AIResponseError:
205 # If model list fetch fails, allow the change but it may fail later
206 pass
208 # Update only provided fields
209 if data.system_prompt is not None:
210 prompt.system_prompt = data.system_prompt
211 if data.user_prompt_template is not None:
212 prompt.user_prompt_template = data.user_prompt_template
213 if data.model is not None:
214 prompt.model = data.model
215 if data.is_active is not None:
216 prompt.is_active = data.is_active
218 prompt.save()
219 return prompt
222@router.get('/models', response=List[ModelOut])
223def list_models(request):
224 """List available AI models from OpenRouter."""
225 try:
226 service = OpenRouterService()
227 return service.get_available_models()
228 except AIUnavailableError:
229 # No API key configured - return empty list
230 return []
231 except AIResponseError:
232 # API error - return empty list
233 return []
236# Remix Schemas
238class RemixSuggestionsIn(Schema):
239 recipe_id: int
242class RemixSuggestionsOut(Schema):
243 suggestions: List[str]
246class CreateRemixIn(Schema):
247 recipe_id: int
248 modification: str
249 profile_id: int
252class RemixOut(Schema):
253 id: int
254 title: str
255 description: str
256 ingredients: List[str]
257 instructions: List[str]
258 host: str
259 site_name: str
260 is_remix: bool
261 prep_time: Optional[int] = None
262 cook_time: Optional[int] = None
263 total_time: Optional[int] = None
264 yields: str = ''
265 servings: Optional[int] = None
268# Remix Endpoints
270@router.post('/remix-suggestions', response={200: RemixSuggestionsOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
271def remix_suggestions(request, data: RemixSuggestionsIn):
272 """Get 6 AI-generated remix suggestions for a recipe.
274 Only works for recipes owned by the requesting profile.
275 """
276 from apps.profiles.utils import get_current_profile_or_none
277 profile = get_current_profile_or_none(request)
279 try:
280 # Verify recipe ownership
281 recipe = Recipe.objects.get(id=data.recipe_id)
282 if not profile or recipe.profile_id != profile.id:
283 return 404, {
284 'error': 'not_found',
285 'message': f'Recipe {data.recipe_id} not found',
286 }
288 suggestions = get_remix_suggestions(data.recipe_id)
289 return {'suggestions': suggestions}
290 except Recipe.DoesNotExist:
291 return 404, {
292 'error': 'not_found',
293 'message': f'Recipe {data.recipe_id} not found',
294 }
295 except AIUnavailableError as e:
296 return 503, {
297 'error': 'ai_unavailable',
298 'message': str(e) or 'AI features are not available. Please configure your API key in Settings.',
299 'action': 'configure_key',
300 }
301 except (AIResponseError, ValidationError) as e:
302 return 400, {
303 'error': 'ai_error',
304 'message': str(e),
305 }
308@router.post('/remix', response={200: RemixOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
309def create_remix_endpoint(request, data: CreateRemixIn):
310 """Create a remixed recipe using AI.
312 Only works for recipes owned by the requesting profile.
313 The remix will be owned by the same profile.
314 """
315 from apps.profiles.utils import get_current_profile_or_none
316 profile = get_current_profile_or_none(request)
318 if not profile:
319 return 404, {
320 'error': 'not_found',
321 'message': 'Profile not found',
322 }
324 # Verify the profile_id in the request matches the session profile
325 if data.profile_id != profile.id:
326 return 404, {
327 'error': 'not_found',
328 'message': f'Profile {data.profile_id} not found',
329 }
331 try:
332 # Verify recipe ownership
333 recipe = Recipe.objects.get(id=data.recipe_id)
334 if recipe.profile_id != profile.id:
335 return 404, {
336 'error': 'not_found',
337 'message': f'Recipe {data.recipe_id} not found',
338 }
340 remix = create_remix(
341 recipe_id=data.recipe_id,
342 modification=data.modification,
343 profile=profile,
344 )
345 return {
346 'id': remix.id,
347 'title': remix.title,
348 'description': remix.description,
349 'ingredients': remix.ingredients,
350 'instructions': remix.instructions,
351 'host': remix.host,
352 'site_name': remix.site_name,
353 'is_remix': remix.is_remix,
354 'prep_time': remix.prep_time,
355 'cook_time': remix.cook_time,
356 'total_time': remix.total_time,
357 'yields': remix.yields,
358 'servings': remix.servings,
359 }
360 except Recipe.DoesNotExist:
361 return 404, {
362 'error': 'not_found',
363 'message': f'Recipe {data.recipe_id} not found',
364 }
365 except AIUnavailableError as e:
366 return 503, {
367 'error': 'ai_unavailable',
368 'message': str(e) or 'AI features are not available. Please configure your API key in Settings.',
369 'action': 'configure_key',
370 }
371 except (AIResponseError, ValidationError) as e:
372 return 400, {
373 'error': 'ai_error',
374 'message': str(e),
375 }
378# Scaling Schemas
380class ScaleIn(Schema):
381 recipe_id: int
382 target_servings: int
383 unit_system: str = 'metric'
384 profile_id: int
387class NutritionOut(Schema):
388 per_serving: dict
389 total: dict
392class ScaleOut(Schema):
393 target_servings: int
394 original_servings: int
395 ingredients: List[str]
396 instructions: List[str] = [] # QA-031
397 notes: List[str]
398 prep_time_adjusted: Optional[int] = None # QA-032
399 cook_time_adjusted: Optional[int] = None # QA-032
400 total_time_adjusted: Optional[int] = None # QA-032
401 nutrition: Optional[NutritionOut] = None
402 cached: bool
405# Scaling Endpoints
407@router.post('/scale', response={200: ScaleOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
408def scale_recipe_endpoint(request, data: ScaleIn):
409 """Scale a recipe to a different number of servings.
411 Only works for recipes owned by the requesting profile.
412 """
413 from apps.profiles.utils import get_current_profile_or_none
414 profile = get_current_profile_or_none(request)
416 if not profile:
417 return 404, {
418 'error': 'not_found',
419 'message': 'Profile not found',
420 }
422 # Verify the profile_id in the request matches the session profile
423 if data.profile_id != profile.id:
424 return 404, {
425 'error': 'not_found',
426 'message': f'Profile {data.profile_id} not found',
427 }
429 try:
430 recipe = Recipe.objects.get(id=data.recipe_id)
431 # Verify recipe ownership
432 if recipe.profile_id != profile.id:
433 return 404, {
434 'error': 'not_found',
435 'message': f'Recipe {data.recipe_id} not found',
436 }
437 except Recipe.DoesNotExist:
438 return 404, {
439 'error': 'not_found',
440 'message': f'Recipe {data.recipe_id} not found',
441 }
443 try:
444 result = scale_recipe(
445 recipe_id=data.recipe_id,
446 target_servings=data.target_servings,
447 profile=profile,
448 unit_system=data.unit_system,
449 )
451 # Calculate nutrition if available
452 nutrition = None
453 if recipe.nutrition:
454 nutrition = calculate_nutrition(
455 recipe=recipe,
456 original_servings=recipe.servings,
457 target_servings=data.target_servings,
458 )
460 return {
461 'target_servings': result['target_servings'],
462 'original_servings': result['original_servings'],
463 'ingredients': result['ingredients'],
464 'instructions': result.get('instructions', []), # QA-031
465 'notes': result['notes'],
466 'prep_time_adjusted': result.get('prep_time_adjusted'), # QA-032
467 'cook_time_adjusted': result.get('cook_time_adjusted'), # QA-032
468 'total_time_adjusted': result.get('total_time_adjusted'), # QA-032
469 'nutrition': nutrition,
470 'cached': result['cached'],
471 }
472 except ValueError as e:
473 return 400, {
474 'error': 'validation_error',
475 'message': str(e),
476 }
477 except AIUnavailableError as e:
478 return 503, {
479 'error': 'ai_unavailable',
480 'message': str(e) or 'AI features are not available. Please configure your API key in Settings.',
481 'action': 'configure_key',
482 }
483 except (AIResponseError, ValidationError) as e:
484 return 400, {
485 'error': 'ai_error',
486 'message': str(e),
487 }
490# Tips Schemas
492class TipsIn(Schema):
493 recipe_id: int
494 regenerate: bool = False
497class TipsOut(Schema):
498 tips: List[str]
499 cached: bool
502# Tips Endpoints
504@router.post('/tips', response={200: TipsOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
505def tips_endpoint(request, data: TipsIn):
506 """Generate cooking tips for a recipe.
508 Pass regenerate=True to clear existing tips and generate fresh ones.
509 Only works for recipes owned by the requesting profile.
510 """
511 from apps.profiles.utils import get_current_profile_or_none
512 profile = get_current_profile_or_none(request)
514 try:
515 # Verify recipe ownership
516 recipe = Recipe.objects.get(id=data.recipe_id)
517 if not profile or recipe.profile_id != profile.id:
518 return 404, {
519 'error': 'not_found',
520 'message': f'Recipe {data.recipe_id} not found',
521 }
523 # Clear existing tips if regenerate requested
524 if data.regenerate:
525 clear_tips(data.recipe_id)
527 result = generate_tips(data.recipe_id)
528 return result
529 except Recipe.DoesNotExist:
530 return 404, {
531 'error': 'not_found',
532 'message': f'Recipe {data.recipe_id} not found',
533 }
534 except AIUnavailableError as e:
535 return 503, {
536 'error': 'ai_unavailable',
537 'message': str(e) or 'AI features are not available. Please configure your API key in Settings.',
538 'action': 'configure_key',
539 }
540 except (AIResponseError, ValidationError) as e:
541 return 400, {
542 'error': 'ai_error',
543 'message': str(e),
544 }
547# Timer Naming Schemas
549class TimerNameIn(Schema):
550 step_text: str
551 duration_minutes: int
554class TimerNameOut(Schema):
555 label: str
558# Timer Naming Endpoints
560@router.post('/timer-name', response={200: TimerNameOut, 400: ErrorOut, 503: ErrorOut})
561def timer_name_endpoint(request, data: TimerNameIn):
562 """Generate a descriptive name for a cooking timer.
564 Takes a cooking instruction and duration, returns a short label.
565 """
566 if not data.step_text:
567 return 400, {
568 'error': 'validation_error',
569 'message': 'Step text is required',
570 }
572 if data.duration_minutes <= 0:
573 return 400, {
574 'error': 'validation_error',
575 'message': 'Duration must be positive',
576 }
578 try:
579 result = generate_timer_name(
580 step_text=data.step_text,
581 duration_minutes=data.duration_minutes,
582 )
583 return result
584 except AIUnavailableError as e:
585 return 503, {
586 'error': 'ai_unavailable',
587 'message': str(e) or 'AI features are not available. Please configure your API key in Settings.',
588 'action': 'configure_key',
589 }
590 except (AIResponseError, ValidationError) as e:
591 return 400, {
592 'error': 'ai_error',
593 'message': str(e),
594 }
597# Discover Schemas
599class DiscoverSuggestionOut(Schema):
600 type: str
601 title: str
602 description: str
603 search_query: str
606class DiscoverOut(Schema):
607 suggestions: List[DiscoverSuggestionOut]
608 refreshed_at: str
611# Discover Endpoints
613@router.get('/discover/{profile_id}/', response={200: DiscoverOut, 404: ErrorOut, 503: ErrorOut})
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 }
630 except AIUnavailableError as e:
631 return 503, {
632 'error': 'ai_unavailable',
633 'message': str(e) or 'AI features are not available. Please configure your API key in Settings.',
634 'action': 'configure_key',
635 }
638# Selector Repair Schemas
640class SelectorRepairIn(Schema):
641 source_id: int
642 html_sample: str
643 target: str = 'recipe search result'
644 confidence_threshold: float = 0.8
645 auto_update: bool = True
648class SelectorRepairOut(Schema):
649 suggestions: List[str]
650 confidence: float
651 original_selector: str
652 updated: bool
653 new_selector: Optional[str] = None
656class SourceNeedingAttentionOut(Schema):
657 id: int
658 host: str
659 name: str
660 result_selector: str
661 consecutive_failures: int
664# Selector Repair Endpoints
666@router.post('/repair-selector', response={200: SelectorRepairOut, 400: ErrorOut, 404: ErrorOut, 503: ErrorOut})
667def repair_selector_endpoint(request, data: SelectorRepairIn):
668 """Attempt to repair a broken CSS selector using AI.
670 Analyzes HTML from the search page and suggests new selectors.
671 If confidence is high enough and auto_update=True, the source is updated.
673 This endpoint is intended for admin/maintenance use.
674 """
675 from apps.recipes.models import SearchSource
677 try:
678 source = SearchSource.objects.get(id=data.source_id)
679 except SearchSource.DoesNotExist:
680 return 404, {
681 'error': 'not_found',
682 'message': f'SearchSource {data.source_id} not found',
683 }
685 if not data.html_sample:
686 return 400, {
687 'error': 'validation_error',
688 'message': 'HTML sample is required',
689 }
691 try:
692 result = repair_selector(
693 source=source,
694 html_sample=data.html_sample,
695 target=data.target,
696 confidence_threshold=data.confidence_threshold,
697 auto_update=data.auto_update,
698 )
699 return {
700 'suggestions': result['suggestions'],
701 'confidence': result['confidence'],
702 'original_selector': result['original_selector'] or '',
703 'updated': result['updated'],
704 'new_selector': result.get('new_selector'),
705 }
706 except AIUnavailableError as e:
707 return 503, {
708 'error': 'ai_unavailable',
709 'message': str(e) or 'AI features are not available. Please configure your API key in Settings.',
710 'action': 'configure_key',
711 }
712 except (AIResponseError, ValidationError) as e:
713 return 400, {
714 'error': 'ai_error',
715 'message': str(e),
716 }
719@router.get('/sources-needing-attention', response=List[SourceNeedingAttentionOut])
720def sources_needing_attention_endpoint(request):
721 """List all SearchSources that need attention (broken selectors).
723 Returns sources with consecutive_failures >= 3 or needs_attention flag set.
724 """
725 sources = get_sources_needing_attention()
726 return [
727 {
728 'id': s.id,
729 'host': s.host,
730 'name': s.name,
731 'result_selector': s.result_selector or '',
732 'consecutive_failures': s.consecutive_failures,
733 }
734 for s in sources
735 ]