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

1""" 

2AI settings and prompts API endpoints. 

3""" 

4 

5from typing import List, Optional 

6 

7from ninja import Router, Schema 

8 

9from apps.core.models import AppSettings 

10from apps.profiles.models import Profile 

11from apps.recipes.models import Recipe 

12 

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 

22 

23router = Router(tags=['ai']) 

24 

25 

26# Schemas 

27 

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 

35 

36 

37class TestApiKeyIn(Schema): 

38 api_key: str 

39 

40 

41class TestApiKeyOut(Schema): 

42 success: bool 

43 message: str 

44 

45 

46class SaveApiKeyIn(Schema): 

47 api_key: str 

48 

49 

50class SaveApiKeyOut(Schema): 

51 success: bool 

52 message: str 

53 

54 

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 

63 

64 

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 

70 

71 

72class ModelOut(Schema): 

73 id: str 

74 name: str 

75 

76 

77class ErrorOut(Schema): 

78 error: str 

79 message: str 

80 action: Optional[str] = None # User-facing action to resolve the error 

81 

82 

83# Endpoints 

84 

85@router.get('/status', response=AIStatusOut) 

86def get_ai_status(request): 

87 """Check if AI service is available with optional key validation. 

88 

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) 

99 

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 } 

108 

109 if not has_key: 

110 status['error'] = 'No API key configured' 

111 status['error_code'] = 'no_api_key' 

112 return status 

113 

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 

118 

119 if not is_valid: 

120 status['error'] = error_message or 'API key is invalid or expired' 

121 status['error_code'] = 'invalid_api_key' 

122 

123 return status 

124 

125 

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 } 

134 

135 success, message = OpenRouterService.test_connection(data.api_key) 

136 return { 

137 'success': success, 

138 'message': message, 

139 } 

140 

141 

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() 

148 

149 # Invalidate the validation cache since key was updated 

150 OpenRouterService.invalidate_key_cache() 

151 

152 return { 

153 'success': True, 

154 'message': 'API key saved successfully', 

155 } 

156 

157 

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) 

163 

164 

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 } 

176 

177 

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 } 

188 

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} 

195 

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 

207 

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 

217 

218 prompt.save() 

219 return prompt 

220 

221 

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 [] 

234 

235 

236# Remix Schemas 

237 

238class RemixSuggestionsIn(Schema): 

239 recipe_id: int 

240 

241 

242class RemixSuggestionsOut(Schema): 

243 suggestions: List[str] 

244 

245 

246class CreateRemixIn(Schema): 

247 recipe_id: int 

248 modification: str 

249 profile_id: int 

250 

251 

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 

266 

267 

268# Remix Endpoints 

269 

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. 

273 

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) 

278 

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 } 

287 

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 } 

306 

307 

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. 

311 

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) 

317 

318 if not profile: 

319 return 404, { 

320 'error': 'not_found', 

321 'message': 'Profile not found', 

322 } 

323 

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 } 

330 

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 } 

339 

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 } 

376 

377 

378# Scaling Schemas 

379 

380class ScaleIn(Schema): 

381 recipe_id: int 

382 target_servings: int 

383 unit_system: str = 'metric' 

384 profile_id: int 

385 

386 

387class NutritionOut(Schema): 

388 per_serving: dict 

389 total: dict 

390 

391 

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 

403 

404 

405# Scaling Endpoints 

406 

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. 

410 

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) 

415 

416 if not profile: 

417 return 404, { 

418 'error': 'not_found', 

419 'message': 'Profile not found', 

420 } 

421 

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 } 

428 

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 } 

442 

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 ) 

450 

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 ) 

459 

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 } 

488 

489 

490# Tips Schemas 

491 

492class TipsIn(Schema): 

493 recipe_id: int 

494 regenerate: bool = False 

495 

496 

497class TipsOut(Schema): 

498 tips: List[str] 

499 cached: bool 

500 

501 

502# Tips Endpoints 

503 

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. 

507 

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) 

513 

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 } 

522 

523 # Clear existing tips if regenerate requested 

524 if data.regenerate: 

525 clear_tips(data.recipe_id) 

526 

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 } 

545 

546 

547# Timer Naming Schemas 

548 

549class TimerNameIn(Schema): 

550 step_text: str 

551 duration_minutes: int 

552 

553 

554class TimerNameOut(Schema): 

555 label: str 

556 

557 

558# Timer Naming Endpoints 

559 

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. 

563 

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 } 

571 

572 if data.duration_minutes <= 0: 

573 return 400, { 

574 'error': 'validation_error', 

575 'message': 'Duration must be positive', 

576 } 

577 

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 } 

595 

596 

597# Discover Schemas 

598 

599class DiscoverSuggestionOut(Schema): 

600 type: str 

601 title: str 

602 description: str 

603 search_query: str 

604 

605 

606class DiscoverOut(Schema): 

607 suggestions: List[DiscoverSuggestionOut] 

608 refreshed_at: str 

609 

610 

611# Discover Endpoints 

612 

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. 

616 

617 Returns cached suggestions if still valid (within 24 hours), 

618 otherwise generates new suggestions via AI. 

619 

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 } 

636 

637 

638# Selector Repair Schemas 

639 

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 

646 

647 

648class SelectorRepairOut(Schema): 

649 suggestions: List[str] 

650 confidence: float 

651 original_selector: str 

652 updated: bool 

653 new_selector: Optional[str] = None 

654 

655 

656class SourceNeedingAttentionOut(Schema): 

657 id: int 

658 host: str 

659 name: str 

660 result_selector: str 

661 consecutive_failures: int 

662 

663 

664# Selector Repair Endpoints 

665 

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. 

669 

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. 

672 

673 This endpoint is intended for admin/maintenance use. 

674 """ 

675 from apps.recipes.models import SearchSource 

676 

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 } 

684 

685 if not data.html_sample: 

686 return 400, { 

687 'error': 'validation_error', 

688 'message': 'HTML sample is required', 

689 } 

690 

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 } 

717 

718 

719@router.get('/sources-needing-attention', response=List[SourceNeedingAttentionOut]) 

720def sources_needing_attention_endpoint(request): 

721 """List all SearchSources that need attention (broken selectors). 

722 

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 ] 

← Back to Dashboard