From 85c0e6066eb1c0d24e4c978e51fd64674a55ca68 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Wed, 28 Jan 2026 21:21:17 -1000 Subject: [PATCH] Enhance AI fertilizer recommendations code --- apps/api/src/models/Species.ts | 42 ++- apps/api/src/models/SpeciesUser.ts | 3 +- apps/api/src/services/species/index.ts | 279 +++++++++++------- .../components/FertilizerRecommendations.tsx | 2 +- package-lock.json | 4 +- 5 files changed, 212 insertions(+), 118 deletions(-) diff --git a/apps/api/src/models/Species.ts b/apps/api/src/models/Species.ts index 5b357ef..a473ced 100644 --- a/apps/api/src/models/Species.ts +++ b/apps/api/src/models/Species.ts @@ -2,7 +2,19 @@ import mongoose from 'mongoose' export interface ISpeciesFertilizerRecommendations { text?: string, - recommendedProducts?: string[], + recommendedProducts?: { + brand?: string, + name: string, + isOrganic?: boolean, + isLiquid?: boolean, + isGranules?: boolean, + isPellets?: boolean, + isPowder?: boolean, + isSlowRelease?: boolean, + nitrogen?: number, + phosphorus?: number, + potassium?: number, + }[], } export interface ISpecies { @@ -61,10 +73,30 @@ export const speciesSchema = new mongoose.Schema({ default: null, }, fertilizerRecommendations: { - type: { - text: String, - recommendedProducts: [String], - }, + type: new mongoose.Schema({ + text: { + type: String, + default: null, + }, + recommendedProducts: { + type: [ + { + brand: String, + name: String, + isOrganic: Boolean, + isLiquid: Boolean, + isGranules: Boolean, + isPellets: Boolean, + isPowder: Boolean, + isSlowRelease: Boolean, + nitrogen: Number, + phosphorus: Number, + potassium: Number, + }, + ], + default: null, + }, + }), default: null, }, }, { diff --git a/apps/api/src/models/SpeciesUser.ts b/apps/api/src/models/SpeciesUser.ts index 603efe5..8be6d13 100644 --- a/apps/api/src/models/SpeciesUser.ts +++ b/apps/api/src/models/SpeciesUser.ts @@ -4,7 +4,7 @@ export interface ISpeciesUser { _id: string, species: mongoose.Types.ObjectId, user: mongoose.Types.ObjectId, - recommendedFertilizers: mongoose.Types.ObjectId[], + recommendedFertilizers: mongoose.Types.ObjectId[] | null, createdAt: Date, updatedAt: Date, } @@ -23,6 +23,7 @@ export const speciesUserSchema = new mongoose.Schema({ recommendedFertilizers: { type: [mongoose.Schema.Types.ObjectId], ref: 'Fertilizer', + default: null, }, }, { collation: { locale: 'en', strength: 2 }, diff --git a/apps/api/src/services/species/index.ts b/apps/api/src/services/species/index.ts index f630903..904b48f 100644 --- a/apps/api/src/services/species/index.ts +++ b/apps/api/src/services/species/index.ts @@ -9,17 +9,12 @@ import { type IFertilizer, type ISpecies, type ISpeciesFertilizerRecommendations, + type ISpeciesUser, } from '../../models' import * as openAiService from '../openAi' -const OpenAiFertilizerRecommendationsSchema = z.object({ - generalRecommendations: z.string().optional(), - recommendedUserFertilizerIds: z.array(z.coerce.string()).optional(), - recommendedProducts: z.array(z.string()).optional(), -}) - -export type IFertilizerRecommendations = ISpeciesFertilizerRecommendations & { recommendedUserFertilizers: IFertilizer[] } +export type IFertilizerRecommendations = ISpeciesFertilizerRecommendations & { recommendedUserFertilizers?: IFertilizer[] } export const getFertilizerRecommendations = async ({ speciesId, @@ -38,8 +33,6 @@ export const getFertilizerRecommendations = async ({ }) } - const speciesFertilizerRecommendations = species.toObject().fertilizerRecommendations - // Check if SpeciesUser record already exists const speciesUser = await SpeciesUser.findOne({ species: speciesId, @@ -48,78 +41,22 @@ export const getFertilizerRecommendations = async ({ .populate<{ recommendedFertilizers: IFertilizer[] }>('recommendedFertilizers') // If we already have both species-level suggestions and user-level fertilizer recommendations (if userId was passed), return the existing data - if (speciesFertilizerRecommendations?.text && speciesFertilizerRecommendations?.recommendedProducts && (!userId || speciesUser?.recommendedFertilizers)) { + if (species.fertilizerRecommendations?.text && species.fertilizerRecommendations?.recommendedProducts && (!userId || speciesUser?.recommendedFertilizers)) { const existingData = { - ...speciesFertilizerRecommendations, + ...species.toObject().fertilizerRecommendations, recommendedUserFertilizers: speciesUser?.toObject().recommendedFertilizers || [], } return existingData } - // Fetch user's fertilizers - const userFertilizers = !userId ? [] : await Fertilizer.find({ - user: userId, - deletedAt: null, - }).sort({ name: 'asc' }) - - const openai = openAiService.createClient() - - const prompt = buildGetFertilizerRecommendationsPrompt({ + // Get recommendations from AI + const fertilizerRecommendations = await getFertilizerRecommendationsFromAi({ species, - existingSpeciesFertilizerRecommendations: speciesFertilizerRecommendations, - userFertilizers, + speciesUser, + userId, }) - // Call OpenAI API - let aiResponse: OpenAI.Chat.Completions.ChatCompletion - try { - aiResponse = await openai.chat.completions.create({ - model: 'gpt-4o-mini', - messages: [ - { - role: 'system', - content: 'You are a helpful plant care expert specializing in fertilizer recommendations for different plant species. Always respond with valid JSON in the exact format requested.', - }, - { - role: 'user', - content: prompt, - }, - ], - temperature: 0.7, - response_format: { type: 'json_object' }, - }) - } catch (error) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to generate fertilizer recommendations: ${error instanceof Error ? error.message : 'Unknown error'}`, - }) - } - - // Parse AI response - const fertilizerRecommendations: IFertilizerRecommendations = { - text: '', - recommendedUserFertilizers: [], - recommendedProducts: [], - } - try { - const responseContent = aiResponse.choices[0]?.message?.content || '{}' - - const parsedJson: unknown = JSON.parse(responseContent) - const parsedResponse = OpenAiFertilizerRecommendationsSchema.parse(parsedJson) - - fertilizerRecommendations.text = parsedResponse.generalRecommendations || '' - fertilizerRecommendations.recommendedProducts = parsedResponse.recommendedProducts || [] - fertilizerRecommendations.recommendedUserFertilizers = (parsedResponse.recommendedUserFertilizerIds || []) - .map(id => userFertilizers.find(f => f._id.toString() === id) as IFertilizer) - .filter(Boolean) - } catch (parseError) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to parse fertilizer recommendations: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`, - }) - } - // Store species-level suggestions (text and recommendedProducts) on Species if they do not already exist const newSpeciesFertilizerRecommendations: ISpeciesFertilizerRecommendations = { ...species.fertilizerRecommendations } if (fertilizerRecommendations.text && !species.fertilizerRecommendations?.text) { @@ -135,47 +72,45 @@ export const getFertilizerRecommendations = async ({ } // Store user-specific suggestions (recommendedUserFertilizers) on SpeciesUser if they do not already exist - if (fertilizerRecommendations.recommendedUserFertilizers.length > 0) { - await SpeciesUser.create({ + if (!(speciesUser?.recommendedFertilizers && speciesUser.recommendedFertilizers.length > 0) && fertilizerRecommendations.recommendedUserFertilizers && fertilizerRecommendations.recommendedUserFertilizers.length > 0) { + await SpeciesUser.updateOne({ species: speciesId, user: userId, + }, { recommendedFertilizers: fertilizerRecommendations.recommendedUserFertilizers.map(f => f._id), + }, { + upsert: true, }) } const response: IFertilizerRecommendations = { - text: fertilizerRecommendations.text || speciesFertilizerRecommendations?.text || '', - recommendedProducts: fertilizerRecommendations.recommendedProducts || speciesFertilizerRecommendations?.recommendedProducts || [], + text: fertilizerRecommendations.text || species.fertilizerRecommendations?.text || '', + recommendedProducts: fertilizerRecommendations.recommendedProducts || species.fertilizerRecommendations?.recommendedProducts || [], recommendedUserFertilizers: fertilizerRecommendations.recommendedUserFertilizers || speciesUser?.toObject().recommendedFertilizers || [], } return response } -const buildGetFertilizerRecommendationsPrompt = ({ +const getFertilizerRecommendationsFromAi = async ({ species, - existingSpeciesFertilizerRecommendations, - userFertilizers, + speciesUser, + userId, }: { species: ISpecies, - existingSpeciesFertilizerRecommendations: ISpeciesFertilizerRecommendations | null, - userFertilizers: IFertilizer[], + speciesUser: (Omit & { recommendedFertilizers: IFertilizer[] }) | null, + userId?: string, }) => { - // Gather prompt instructions - let promptPrefix = '' + const openai = openAiService.createClient() + + const systemPrompt = 'You are a helpful plant care expert specializing in fertilizer recommendations for different plant species. Always respond with valid JSON in the exact format requested.' + + // Gather user prompt instructions + let promptPrefix = 'What are the best fertilizers for this plant species?' let promptInstructionsSpec = [] - let promptSuffix = `If you cannot recommend specific product names, you can still provide general recommendations in the "recommendedProducts" array with general descriptions like "Balanced NPK fertilizer (10-10-10)" or "High-phosphorus organic fertilizer".` - const responseFormatSpec: { - generalRecommendations: string, - recommendedUserFertilizerIds: string[], - recommendedProducts: string[], - } = { - generalRecommendations: 'Your detailed text response here', - recommendedUserFertilizerIds: [], - recommendedProducts: ['Brand Name - Product Name (N-P-K)', 'Brand Name - Product Name (N-P-K)'], - } + let promptSuffix = '' - // Build species information for prompt + // Build species information for user prompt const speciesInfo = [ species.commonName && `Common name: ${species.commonName}`, species.scientificName && `Scientific name: ${species.scientificName}`, @@ -184,13 +119,27 @@ const buildGetFertilizerRecommendationsPrompt = ({ species.familyCommonName && `Family common name: ${species.familyCommonName}`, ].filter(Boolean).join('\n') - // Generate prompt - if (!existingSpeciesFertilizerRecommendations?.text) { + // Generate user prompt + if (!species.fertilizerRecommendations?.text) { promptInstructionsSpec.push('A detailed, practical response about fertilizer recommendations that would be helpful for someone caring for this plant.') + responseFormatSpec.generalRecommendations = responseExamples.generalRecommendations + + promptPrefix = `${promptPrefix} Please provide specific fertilizer recommendations, including: +- Recommended fertilizer types (e.g., NPK ratios, organic vs synthetic) +- Application frequency and timing +- Specific nutrient requirements +- Any special considerations for this species` + promptSuffix = `${promptSuffix}\nIf you cannot recommend specific product names, you can still provide general recommendations in the "generalRecommendations" text with general descriptions like "Balanced NPK fertilizer (10-10-10)" or "High-phosphorus organic fertilizer".` } - // Tailor prompt based on whether or not the user has fertilizers - if (userFertilizers && userFertilizers.length > 0) { + // Fetch user's fertilizers + const userFertilizers = !userId ? [] : await Fertilizer.find({ + user: userId, + deletedAt: null, + }).sort({ name: 'asc' }) + + // Tailor user prompt based on whether or not the user has fertilizers + if (userFertilizers && userFertilizers.length > 0 && !(speciesUser?.recommendedFertilizers && speciesUser.recommendedFertilizers.length > 0)) { const userFertilizersList = userFertilizers .map(fertilizer => { const npk = [ @@ -207,30 +156,26 @@ const buildGetFertilizerRecommendationsPrompt = ({ promptInstructionsSpec.push("A list of specific fertilizer products from the user's current inventory (listed above) that would be suitable for this plant species. If none are suitable, state that clearly.") - if (!existingSpeciesFertilizerRecommendations?.recommendedProducts?.length) { + if (!species.fertilizerRecommendations?.recommendedProducts?.length) { promptInstructionsSpec.push("A list of other specific fertilizer products (brand names and product names) that are available in the world but NOT in the user's current inventory that would be excellent for this plant species.") + responseFormatSpec.recommendedProducts = responseExamples.recommendedProducts } - promptSuffix = `If no fertilizers from the inventory are suitable, set "recommendedUserFertilizerIds" to an empty array.\n\n${promptSuffix}` + promptSuffix = `${promptSuffix}\nIf no fertilizers from the inventory are suitable, set "recommendedUserFertilizerIds" to an empty array.` - responseFormatSpec.recommendedUserFertilizerIds = ['Fertilizer id 1', 'Fertilizer id 2'] + responseFormatSpec.recommendedUserFertilizers = responseExamples.recommendedUserFertilizers } else { - if (!existingSpeciesFertilizerRecommendations?.recommendedProducts?.length) { + if (!species.fertilizerRecommendations?.recommendedProducts?.length) { promptInstructionsSpec.push("A list of specific fertilizer products (brand names and product names) that would be excellent for this plant species.") + responseFormatSpec.recommendedProducts = responseExamples.recommendedProducts } } - const prompt = `What are the best fertilizers for this plant species? Please provide specific fertilizer recommendations, including: -- Recommended fertilizer types (e.g., NPK ratios, organic vs synthetic) -- Application frequency and timing -- Specific nutrient requirements -- Any special considerations for this species + const userPrompt = `${promptPrefix} Plant information: ${speciesInfo} -${promptPrefix} - Please provide: ${promptInstructionsSpec.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n')} @@ -240,5 +185,121 @@ ${JSON.stringify(responseFormatSpec)} ${promptSuffix} ` - return prompt + // Call OpenAI API + let aiResponse: OpenAI.Chat.Completions.ChatCompletion + try { + aiResponse = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: systemPrompt, + }, + { + role: 'user', + content: userPrompt, + }, + ], + temperature: 0.7, + response_format: { type: 'json_object' }, + }) + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to generate fertilizer recommendations: ${error instanceof Error ? error.message : 'Unknown error'}`, + }) + } + + // Parse AI response + const fertilizerRecommendations: IFertilizerRecommendations = { + text: undefined, + recommendedProducts: undefined, + recommendedUserFertilizers: undefined, + } + try { + const responseContent = aiResponse.choices[0]?.message?.content || '{}' + + const parsedJson: unknown = JSON.parse(responseContent) + const parsedResponse = OpenAiFertilizerRecommendationsSchema.parse(parsedJson) + + fertilizerRecommendations.text = parsedResponse.generalRecommendations + fertilizerRecommendations.recommendedProducts = parsedResponse.recommendedProducts + fertilizerRecommendations.recommendedUserFertilizers = parsedResponse.recommendedUserFertilizers + ?.map(recommendedUserFertilizer => userFertilizers.find(f => f._id.toString() === recommendedUserFertilizer.id) as IFertilizer) + .filter(Boolean) + } catch (parseError) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to parse fertilizer recommendations: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`, + }) + } + + return fertilizerRecommendations +} + +const OpenAiFertilizerRecommendationsSchema = z.object({ + generalRecommendations: z.string().optional(), + recommendedUserFertilizers: z.array(z.object({ id: z.string() })).optional(), + recommendedProducts: z.array(z.object({ + brand: z.string(), + name: z.string(), + isOrganic: z.boolean(), + isLiquid: z.boolean(), + isGranules: z.boolean(), + isPellets: z.boolean(), + isPowder: z.boolean(), + isSlowRelease: z.boolean(), + nitrogen: z.number(), + phosphorus: z.number(), + potassium: z.number(), + })).optional(), +}) +const responseFormatSpec: { + generalRecommendations?: string, + recommendedUserFertilizers?: { id: string }[], + recommendedProducts?: { + brand: string, + name: string, + isOrganic: boolean, + isLiquid: boolean, + isGranules: boolean, + isPellets: boolean, + isPowder: boolean, + isSlowRelease: boolean, + nitrogen: number, + phosphorus: number, + potassium: number, + }[], +} = {} +const responseExamples = { + generalRecommendations: 'Your detailed text response here', + recommendedUserFertilizers: [ { id: 'Fertilizer id 1' }, { id: 'Fertilizer id 2' } ], + recommendedProducts: [ + { + brand: 'Brand Name', + name: 'Product Name', + isOrganic: true, + isLiquid: true, + isGranules: true, + isPellets: true, + isPowder: true, + isSlowRelease: true, + nitrogen: 10, + phosphorus: 10, + potassium: 10, + }, + { + brand: 'Brand Name', + name: 'Product Name', + isOrganic: true, + isLiquid: true, + isGranules: true, + isPellets: true, + isPowder: true, + isSlowRelease: true, + nitrogen: 10, + phosphorus: 10, + potassium: 10, + }, + ], } diff --git a/apps/mobile/src/components/FertilizerRecommendations.tsx b/apps/mobile/src/components/FertilizerRecommendations.tsx index 847ca2c..058da10 100644 --- a/apps/mobile/src/components/FertilizerRecommendations.tsx +++ b/apps/mobile/src/components/FertilizerRecommendations.tsx @@ -95,7 +95,7 @@ export function FertilizerRecommendations({ {data.recommendedProducts.map((product, index) => ( - {product} + {product.brand ? `${product.brand} - ` : ''}{product.name} ({product.nitrogen !== null ? product.nitrogen : null}-{product.phosphorus !== null ? product.phosphorus : null}-{product.potassium !== null ? product.potassium : null}) ))} diff --git a/package-lock.json b/package-lock.json index 35e64ec..508afd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ }, "apps/api": { "name": "@plannting/api", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@trpc/server": "^11.5.1", "@types/debug": "^4.1.12", @@ -131,7 +131,7 @@ }, "apps/mobile": { "name": "@plannting/mobile", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "8.4.4",