|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace Kami\Cocktail\Http\Controllers; |
| 6 | + |
| 7 | +use Kami\Cocktail\Models\Tag; |
| 8 | +use OpenApi\Attributes as OAT; |
| 9 | +use Prism\Prism\Facades\Prism; |
| 10 | +use Prism\Prism\Enums\Provider; |
| 11 | +use Kami\Cocktail\OpenAPI as BAO; |
| 12 | +use Kami\Cocktail\Models\Cocktail; |
| 13 | +use Illuminate\Support\Facades\Log; |
| 14 | +use Prism\Prism\Schema\ArraySchema; |
| 15 | +use Prism\Prism\Schema\NumberSchema; |
| 16 | +use Prism\Prism\Schema\ObjectSchema; |
| 17 | +use Prism\Prism\Schema\StringSchema; |
| 18 | +use Kami\Cocktail\External\Model\Schema; |
| 19 | +use Kami\Cocktail\Http\Requests\CompleteIngredientRequest; |
| 20 | +use Kami\Cocktail\Http\Requests\CompleteCocktailTagsRequest; |
| 21 | +use Kami\Cocktail\Http\Requests\CocktailRecipeFromTextRequest; |
| 22 | +use Kami\Cocktail\Http\Resources\Generated\GeneratedIngredientResource; |
| 23 | +use Kami\Cocktail\Http\Resources\Generated\GeneratedCocktailTagsResource; |
| 24 | +use Kami\Cocktail\Http\Resources\Generated\GeneratedCocktailFromTextResource; |
| 25 | + |
| 26 | +class GenerateController extends Controller |
| 27 | +{ |
| 28 | + #[OAT\Post(path: '/generate/ingredient', tags: ['Generation'], operationId: 'completeIngredient', description: 'Suggested ingredient information based on it\'s name, generated by an LLM.', summary: 'Complete ingredient', parameters: [ |
| 29 | + new BAO\Parameters\BarIdHeaderParameter(), |
| 30 | + ], requestBody: new OAT\RequestBody( |
| 31 | + required: true, |
| 32 | + content: [ |
| 33 | + new OAT\JsonContent( |
| 34 | + type: 'object', |
| 35 | + properties: [ |
| 36 | + new OAT\Property(property: 'name', type: 'string', example: 'Vodka', description: 'Name of the ingredient to complete information for'), |
| 37 | + ], |
| 38 | + required: ['name'], |
| 39 | + ), |
| 40 | + ] |
| 41 | + ))] |
| 42 | + #[BAO\SuccessfulResponse(content: [ |
| 43 | + new BAO\WrapObjectWithData(GeneratedIngredientResource::class), |
| 44 | + ])] |
| 45 | + public function completeIngredient(CompleteIngredientRequest $request): GeneratedIngredientResource |
| 46 | + { |
| 47 | + $provider = Provider::tryFrom(config('bar-assistant.ai.provider')); |
| 48 | + $model = config('bar-assistant.ai.model'); |
| 49 | + $timeout = config('bar-assistant.ai.timeout'); |
| 50 | + $ingredientName = $request->input('name'); |
| 51 | + |
| 52 | + $schema = new ObjectSchema( |
| 53 | + name: 'cocktail_ingredient', |
| 54 | + description: 'Basic information about cocktail ingredient', |
| 55 | + properties: [ |
| 56 | + new StringSchema('name', 'The name of the ingredient'), |
| 57 | + new StringSchema('description', 'Helpful description of ingredient (1-2 short paragraphs)'), |
| 58 | + new NumberSchema('strength', 'Alcohol by volume percentage', true), |
| 59 | + new StringSchema('color', 'The predominant color of the ingredient as hex value', true), |
| 60 | + new StringSchema('distillery', 'Name of the distillery producing the ingredient', true), |
| 61 | + new StringSchema('origin', 'The geographical origin of the ingredient', true), |
| 62 | + ], |
| 63 | + requiredFields: ['name', 'description', 'color', 'distillery', 'origin', 'strength'] |
| 64 | + ); |
| 65 | + |
| 66 | + $prompt = <<<PROMPT |
| 67 | + You are a cocktail and spirits expert. Provide detailed information about the following ingredient used in cocktails. |
| 68 | +
|
| 69 | + Ingredient: {$ingredientName} |
| 70 | +
|
| 71 | + Instructions: |
| 72 | + - Keep the description informative and concise (1-2 short paragraphs) |
| 73 | + - For strength (ABV), provide the typical alcohol by volume percentage as a number (e.g., 40 for 40% ABV). Use null if non-alcoholic. |
| 74 | + - For color, provide a hex color code (e.g., #FF5733) representing the ingredient's predominant color |
| 75 | + - For distillery, provide the name of a well-known producer if applicable, otherwise use null |
| 76 | + - For origin, specify the country or region where this ingredient typically originates |
| 77 | + PROMPT; |
| 78 | + |
| 79 | + Log::info("[LLM] Generating ingredient information for: {$ingredientName}"); |
| 80 | + |
| 81 | + $response = Prism::structured() |
| 82 | + ->using($provider, $model) |
| 83 | + ->withSchema($schema) |
| 84 | + ->withPrompt(trim($prompt)) |
| 85 | + ->withClientOptions(['timeout' => $timeout]) |
| 86 | + ->asStructured(); |
| 87 | + |
| 88 | + return new GeneratedIngredientResource($response->structured); |
| 89 | + } |
| 90 | + |
| 91 | + #[OAT\Post(path: '/generate/cocktail-tags', tags: ['Generation'], operationId: 'completeCocktailTags', description: 'Suggested cocktail tags, based on existing cocktail information, generated by an LLM.', summary: 'Cocktail tags', parameters: [ |
| 92 | + new BAO\Parameters\BarIdHeaderParameter(), |
| 93 | + ], requestBody: new OAT\RequestBody( |
| 94 | + required: true, |
| 95 | + content: [ |
| 96 | + new OAT\JsonContent( |
| 97 | + type: 'object', |
| 98 | + properties: [ |
| 99 | + new OAT\Property(property: 'cocktail_id', type: 'string', example: 'Vodka', description: 'Id or slug of the ingredient'), |
| 100 | + ], |
| 101 | + required: ['cocktail_id'], |
| 102 | + ), |
| 103 | + ] |
| 104 | + ))] |
| 105 | + #[BAO\SuccessfulResponse(content: [ |
| 106 | + new BAO\WrapObjectWithData(GeneratedCocktailTagsResource::class), |
| 107 | + ])] |
| 108 | + public function completeCocktailTags(CompleteCocktailTagsRequest $request): GeneratedCocktailTagsResource |
| 109 | + { |
| 110 | + $cocktailId = $request->input('cocktail_id'); |
| 111 | + $cocktail = Cocktail::where('slug', $cocktailId) |
| 112 | + ->orWhere('id', $cocktailId) |
| 113 | + ->firstOrFail(); |
| 114 | + |
| 115 | + $provider = Provider::tryFrom(config('bar-assistant.ai.provider')); |
| 116 | + $model = config('bar-assistant.ai.model'); |
| 117 | + $timeout = config('bar-assistant.ai.timeout'); |
| 118 | + |
| 119 | + // Limit to top 50 most used tags to reduce token usage |
| 120 | + $existingTags = Tag::where('bar_id', $cocktail->bar_id) |
| 121 | + ->withCount('cocktails') |
| 122 | + ->orderByDesc('cocktails_count') |
| 123 | + ->limit(50) |
| 124 | + ->pluck('name') |
| 125 | + ->implode(', '); |
| 126 | + |
| 127 | + $schema = new ObjectSchema( |
| 128 | + name: 'cocktail_tags', |
| 129 | + description: 'Cocktail tags', |
| 130 | + properties: [ |
| 131 | + new ArraySchema('tags', 'List of recommended cocktail tags', new StringSchema('tag', 'A single cocktail tag')), |
| 132 | + ], |
| 133 | + requiredFields: ['tags'] |
| 134 | + ); |
| 135 | + |
| 136 | + $cocktailMarkdown = Schema::fromCocktailModel($cocktail)->toMarkdown(); |
| 137 | + |
| 138 | + $existingTagsContext = !empty($existingTags) |
| 139 | + ? "Here are the most commonly used tags in this bar: {$existingTags}" |
| 140 | + : "This bar has no existing tags yet."; |
| 141 | + |
| 142 | + $prompt = <<<PROMPT |
| 143 | + You are a cocktail expert assistant. Analyze the following cocktail recipe and suggest relevant tags. |
| 144 | +
|
| 145 | + {$existingTagsContext} |
| 146 | +
|
| 147 | + Instructions: |
| 148 | + - Generate 5-6 relevant tags (no more than 6) |
| 149 | + - Strongly prefer existing tags when they match the cocktail's characteristics |
| 150 | + - Only create new tags if existing ones don't adequately describe the cocktail |
| 151 | + - Tags should describe: flavor profile, occasion, difficulty, alcohol content, season, or ingredients |
| 152 | + - Keep tags concise (1-3 words each) |
| 153 | + - Use lowercase for consistency |
| 154 | +
|
| 155 | + Cocktail recipe: |
| 156 | + {$cocktailMarkdown} |
| 157 | + PROMPT; |
| 158 | + |
| 159 | + Log::info("[LLM] Generating cocktail tags for cocktail ID: {$cocktailId}"); |
| 160 | + |
| 161 | + $response = Prism::structured() |
| 162 | + ->using($provider, $model) |
| 163 | + ->withSchema($schema) |
| 164 | + ->withPrompt(trim($prompt)) |
| 165 | + ->withClientOptions(['timeout' => $timeout]) |
| 166 | + ->asStructured(); |
| 167 | + |
| 168 | + return new GeneratedCocktailTagsResource($response->structured); |
| 169 | + } |
| 170 | + |
| 171 | + #[OAT\Post(path: '/generate/cocktail-recipe-from-text', tags: ['Generation'], operationId: 'cocktailRecipeFromText', description: 'Generate a parseable json recipe structure from raw text.', summary: 'Recipe from text', parameters: [ |
| 172 | + new BAO\Parameters\BarIdHeaderParameter(), |
| 173 | + ], requestBody: new OAT\RequestBody( |
| 174 | + required: true, |
| 175 | + content: [ |
| 176 | + new OAT\JsonContent( |
| 177 | + type: 'object', |
| 178 | + properties: [ |
| 179 | + new OAT\Property(property: 'recipe', type: 'string', example: 'My recipe details are here...', description: 'The raw text of the cocktail recipe to parse') |
| 180 | + ], |
| 181 | + required: ['recipe'], |
| 182 | + ), |
| 183 | + ] |
| 184 | + ))] |
| 185 | + #[BAO\SuccessfulResponse(content: [ |
| 186 | + new BAO\WrapObjectWithData(GeneratedCocktailFromTextResource::class), |
| 187 | + ])] |
| 188 | + public function cocktailRecipeFromText(CocktailRecipeFromTextRequest $request): GeneratedCocktailFromTextResource |
| 189 | + { |
| 190 | + $provider = Provider::tryFrom(config('bar-assistant.ai.provider')); |
| 191 | + $model = config('bar-assistant.ai.model'); |
| 192 | + $timeout = config('bar-assistant.ai.timeout'); |
| 193 | + |
| 194 | + $textRecipe = trim((string) $request->input('recipe')); |
| 195 | + |
| 196 | + $schema = new ObjectSchema( |
| 197 | + name: 'cocktail_recipe', |
| 198 | + description: 'Cocktail recipe parsed by LLM', |
| 199 | + properties: [ |
| 200 | + new StringSchema('name', 'Title of the recipe'), |
| 201 | + new StringSchema('instructions', 'Step by step instructions to prepare the cocktail'), |
| 202 | + new StringSchema('garnish', 'Recommended garnish for the cocktail', true), |
| 203 | + new StringSchema('method', 'Cocktail preperation method.', true), |
| 204 | + new StringSchema('description', 'Helpful description of the cocktail (1-2 short paragraphs)', true), |
| 205 | + new ArraySchema('ingredients', 'List of ingredients', new ObjectSchema( |
| 206 | + name: 'cocktail_recipe_ingredient', |
| 207 | + description: 'Cocktail ingredient', |
| 208 | + properties: [ |
| 209 | + new StringSchema('name', 'Name of the ingredient'), |
| 210 | + new NumberSchema('amount', 'The min amount of the ingredient'), |
| 211 | + new NumberSchema('amount_max', 'The max amount of the ingredient', true), |
| 212 | + new StringSchema('units', 'Units of the ingredient amount'), |
| 213 | + new StringSchema('note', 'Additional note about the ingredient', true), |
| 214 | + ], |
| 215 | + requiredFields: ['name', 'amount', 'units'] |
| 216 | + )), |
| 217 | + ], |
| 218 | + requiredFields: ['name', 'instructions', 'ingredients', 'description', 'garnish'] |
| 219 | + ); |
| 220 | + |
| 221 | + $prompt = <<<PROMPT |
| 222 | + You are a cocktail recipe parser. Extract structured information from the following cocktail recipe text. |
| 223 | +
|
| 224 | + Instructions: |
| 225 | + - Extract the recipe name, ingredients, and preparation instructions |
| 226 | + - Format instructions as a numbered markdown list (e.g., "1. Step one\n2. Step two"). Do NOT include markdown headings. Do NOT include ingredient amounts in instructions. |
| 227 | + - For method, choose ONLY one of: "Shake", "Stir", "Build", "Blend", "Muddle", "Layer" |
| 228 | + - Standardize ingredient units to: "ml", "cl", "oz", "dash", "tsp", "tbsp", "cup", "part", "slice", "wedge", "piece", "whole" |
| 229 | + - For ingredient amounts: |
| 230 | + * If a range is given (e.g., "2-3 oz"), use amount for minimum and amount_max for maximum |
| 231 | + * If a fraction is given (e.g., "1/2 oz"), convert to decimal (0.5) |
| 232 | + * If no amount is specified, use a sensible default or estimation |
| 233 | + - If the recipe text is incomplete or ambiguous, extract what you can and make reasonable assumptions |
| 234 | + - Provide a brief, engaging description (1-2 paragraphs) if one isn't included in the text |
| 235 | +
|
| 236 | + COCKTAIL RECIPE: |
| 237 | + {$textRecipe} |
| 238 | + PROMPT; |
| 239 | + |
| 240 | + Log::info("[LLM] Generating cocktail recipe from text."); |
| 241 | + |
| 242 | + $response = Prism::structured() |
| 243 | + ->using($provider, $model) |
| 244 | + ->withSchema($schema) |
| 245 | + ->withPrompt(trim($prompt)) |
| 246 | + ->withClientOptions(['timeout' => $timeout]) |
| 247 | + ->asStructured(); |
| 248 | + |
| 249 | + return new GeneratedCocktailFromTextResource($response->structured); |
| 250 | + } |
| 251 | +} |
0 commit comments