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

1""" 

2AI settings and prompts API endpoints. 

3""" 

4 

5from functools import wraps 

6from typing import Callable, List, Optional 

7 

8from ninja import Router, Schema 

9 

10from apps.core.models import AppSettings 

11from apps.profiles.models import Profile 

12from apps.recipes.models import Recipe 

13 

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 

23 

24router = Router(tags=["ai"]) 

25 

26 

27# Decorators 

28 

29 

30def handle_ai_errors(func: Callable) -> Callable: 

31 """Decorator to handle common AI service errors. 

32 

33 Catches AIUnavailableError, AIResponseError, and ValidationError, 

34 returning appropriate error responses. 

35 

36 Returns: 

37 - 503 with 'ai_unavailable' error for AIUnavailableError 

38 - 400 with 'ai_error' error for AIResponseError or ValidationError 

39 """ 

40 

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 } 

56 

57 return wrapper 

58 

59 

60# Schemas 

61 

62 

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 

70 

71 

72class TestApiKeyIn(Schema): 

73 api_key: str 

74 

75 

76class TestApiKeyOut(Schema): 

77 success: bool 

78 message: str 

79 

80 

81class SaveApiKeyIn(Schema): 

82 api_key: str 

83 

84 

85class SaveApiKeyOut(Schema): 

86 success: bool 

87 message: str 

88 

89 

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 

98 

99 

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 

105 

106 

107class ModelOut(Schema): 

108 id: str 

109 name: str 

110 

111 

112class ErrorOut(Schema): 

113 error: str 

114 message: str 

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

116 

117 

118# Endpoints 

119 

120 

121@router.get("/status", response=AIStatusOut) 

122def get_ai_status(request): 

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

124 

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) 

135 

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 } 

144 

145 if not has_key: 

146 status["error"] = "No API key configured" 

147 status["error_code"] = "no_api_key" 

148 return status 

149 

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 

154 

155 if not is_valid: 

156 status["error"] = error_message or "API key is invalid or expired" 

157 status["error_code"] = "invalid_api_key" 

158 

159 return status 

160 

161 

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 } 

170 

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

172 return { 

173 "success": success, 

174 "message": message, 

175 } 

176 

177 

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

184 

185 # Invalidate the validation cache since key was updated 

186 OpenRouterService.invalidate_key_cache() 

187 

188 return { 

189 "success": True, 

190 "message": "API key saved successfully", 

191 } 

192 

193 

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) 

199 

200 

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 } 

212 

213 

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 } 

224 

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} 

231 

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 

243 

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 

253 

254 prompt.save() 

255 return prompt 

256 

257 

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

270 

271 

272# Remix Schemas 

273 

274 

275class RemixSuggestionsIn(Schema): 

276 recipe_id: int 

277 

278 

279class RemixSuggestionsOut(Schema): 

280 suggestions: List[str] 

281 

282 

283class CreateRemixIn(Schema): 

284 recipe_id: int 

285 modification: str 

286 profile_id: int 

287 

288 

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 

303 

304 

305# Remix Endpoints 

306 

307 

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. 

312 

313 Only works for recipes owned by the requesting profile. 

314 """ 

315 from apps.profiles.utils import get_current_profile_or_none 

316 

317 profile = get_current_profile_or_none(request) 

318 

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 } 

326 

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 } 

332 

333 suggestions = get_remix_suggestions(data.recipe_id) 

334 return {"suggestions": suggestions} 

335 

336 

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. 

341 

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 

346 

347 profile = get_current_profile_or_none(request) 

348 

349 if not profile: 

350 return 404, { 

351 "error": "not_found", 

352 "message": "Profile not found", 

353 } 

354 

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 } 

361 

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 } 

369 

370 if recipe.profile_id != profile.id: 

371 return 404, { 

372 "error": "not_found", 

373 "message": f"Recipe {data.recipe_id} not found", 

374 } 

375 

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 } 

396 

397 

398# Scaling Schemas 

399 

400 

401class ScaleIn(Schema): 

402 recipe_id: int 

403 target_servings: int 

404 unit_system: str = "metric" 

405 profile_id: int 

406 

407 

408class NutritionOut(Schema): 

409 per_serving: dict 

410 total: dict 

411 

412 

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 

424 

425 

426# Scaling Endpoints 

427 

428 

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. 

433 

434 Only works for recipes owned by the requesting profile. 

435 """ 

436 from apps.profiles.utils import get_current_profile_or_none 

437 

438 profile = get_current_profile_or_none(request) 

439 

440 if not profile: 

441 return 404, { 

442 "error": "not_found", 

443 "message": "Profile not found", 

444 } 

445 

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 } 

452 

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 } 

460 

461 if recipe.profile_id != profile.id: 

462 return 404, { 

463 "error": "not_found", 

464 "message": f"Recipe {data.recipe_id} not found", 

465 } 

466 

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 } 

479 

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 ) 

488 

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 } 

501 

502 

503# Tips Schemas 

504 

505 

506class TipsIn(Schema): 

507 recipe_id: int 

508 regenerate: bool = False 

509 

510 

511class TipsOut(Schema): 

512 tips: List[str] 

513 cached: bool 

514 

515 

516# Tips Endpoints 

517 

518 

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. 

523 

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 

528 

529 profile = get_current_profile_or_none(request) 

530 

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 } 

538 

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 } 

544 

545 # Clear existing tips if regenerate requested 

546 if data.regenerate: 

547 clear_tips(data.recipe_id) 

548 

549 result = generate_tips(data.recipe_id) 

550 return result 

551 

552 

553# Timer Naming Schemas 

554 

555 

556class TimerNameIn(Schema): 

557 step_text: str 

558 duration_minutes: int 

559 

560 

561class TimerNameOut(Schema): 

562 label: str 

563 

564 

565# Timer Naming Endpoints 

566 

567 

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. 

572 

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 } 

580 

581 if data.duration_minutes <= 0: 

582 return 400, { 

583 "error": "validation_error", 

584 "message": "Duration must be positive", 

585 } 

586 

587 result = generate_timer_name( 

588 step_text=data.step_text, 

589 duration_minutes=data.duration_minutes, 

590 ) 

591 return result 

592 

593 

594# Discover Schemas 

595 

596 

597class DiscoverSuggestionOut(Schema): 

598 type: str 

599 title: str 

600 description: str 

601 search_query: str 

602 

603 

604class DiscoverOut(Schema): 

605 suggestions: List[DiscoverSuggestionOut] 

606 refreshed_at: str 

607 

608 

609# Discover Endpoints 

610 

611 

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. 

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 

631 

632# Selector Repair Schemas 

633 

634 

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 

641 

642 

643class SelectorRepairOut(Schema): 

644 suggestions: List[str] 

645 confidence: float 

646 original_selector: str 

647 updated: bool 

648 new_selector: Optional[str] = None 

649 

650 

651class SourceNeedingAttentionOut(Schema): 

652 id: int 

653 host: str 

654 name: str 

655 result_selector: str 

656 consecutive_failures: int 

657 

658 

659# Selector Repair Endpoints 

660 

661 

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. 

666 

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. 

669 

670 This endpoint is intended for admin/maintenance use. 

671 """ 

672 from apps.recipes.models import SearchSource 

673 

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 } 

681 

682 if not data.html_sample: 

683 return 400, { 

684 "error": "validation_error", 

685 "message": "HTML sample is required", 

686 } 

687 

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 } 

702 

703 

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

705def sources_needing_attention_endpoint(request): 

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

707 

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 ] 

← Back to Dashboard