Not a Plant Fehlermeldung

This commit is contained in:
2026-04-17 13:12:36 +02:00
parent 383d8484a6
commit 77b98a3ebf
12 changed files with 831 additions and 195 deletions

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "GreenLens",
"slug": "greenlens",
"version": "2.2.1",
"version": "2.2.2",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -18,7 +18,7 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.greenlens.app",
"buildNumber": "35",
"buildNumber": "36",
"infoPlist": {
"NSCameraUsageDescription": "GreenLens needs camera access to identify plants.",
"NSPhotoLibraryUsageDescription": "GreenLens needs photo library access to identify plants from your gallery.",
@@ -31,7 +31,7 @@
"backgroundColor": "#111813"
},
"package": "com.greenlens.app",
"versionCode": 2,
"versionCode": 3,
"permissions": [
"android.permission.CAMERA",
"android.permission.RECORD_AUDIO"

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { Animated, AppState, Easing, Image, StyleSheet, Text, View } from 'react-native';
import { Redirect, Stack, usePathname } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -53,14 +53,14 @@ const ensureInstallConsistency = async (): Promise<boolean> => {
import { AnimatedSplashScreen } from '../components/AnimatedSplashScreen';
function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
const posthog = usePostHog();
function RootLayoutInner() {
const { isDarkMode, colorPalette, signOut, session, isInitializing, isLoadingPlants, syncRevenueCatState } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const pathname = usePathname();
const [installCheckDone, setInstallCheckDone] = useState(false);
const [splashAnimationComplete, setSplashAnimationComplete] = useState(false);
const [revenueCatReady, setRevenueCatReady] = useState(Constants.appOwnership === 'expo');
const posthog = usePostHog();
useEffect(() => {
// RevenueCat requires native store access — not available in Expo Go
@@ -72,42 +72,42 @@ function RootLayoutInner() {
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || 'appl_hrSpsuUuVstbHhYIDnOqYxPOnmR';
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
setRevenueCatReady(true);
}, []);
useEffect(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
return () => {
cancelled = true;
};
}, [revenueCatReady, session?.serverUserId, syncRevenueCatState]);
const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || 'goog_placeholder';
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: iosApiKey });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: androidApiKey });
}
setRevenueCatReady(true);
}, []);
useEffect(() => {
const isExpoGo = Constants.appOwnership === 'expo';
if (isExpoGo || !revenueCatReady) {
return;
}
let cancelled = false;
(async () => {
try {
if (session?.serverUserId) {
await Purchases.logIn(session.serverUserId);
const customerInfo = await Purchases.getCustomerInfo();
if (!cancelled) {
await syncRevenueCatState(customerInfo as any, 'app_init');
}
} else {
await Purchases.logOut();
}
} catch (error) {
console.error('Failed to align RevenueCat identity', error);
}
})();
return () => {
cancelled = true;
};
}, [revenueCatReady, session?.serverUserId, syncRevenueCatState]);
useEffect(() => {
if (session?.serverUserId) {
@@ -120,6 +120,20 @@ function RootLayoutInner() {
}
}, [session, posthog]);
useEffect(() => {
posthog.capture('screen_viewed', { screen: pathname });
}, [pathname, posthog]);
useEffect(() => {
posthog.capture('app_opened');
const subscription = AppState.addEventListener('change', (nextState) => {
if (nextState === 'active') {
posthog.capture('app_opened');
}
});
return () => subscription.remove();
}, [posthog]);
useEffect(() => {
(async () => {
const didResetSessionForFreshInstall = await ensureInstallConsistency();
@@ -226,7 +240,8 @@ export default function RootLayout() {
return (
<PostHogProvider apiKey={POSTHOG_API_KEY} options={{
host: 'https://us.i.posthog.com',
enableSessionReplay: true,
enableSessionReplay: false,
debug: __DEV__,
}}>
<AppProvider>
<CoachMarksProvider>

View File

@@ -12,6 +12,7 @@ import Purchases, {
PurchasesStoreProduct,
} from 'react-native-purchases';
import { useApp } from '../../context/AppContext';
import { usePostHog } from 'posthog-react-native';
import { useColors } from '../../constants/Colors';
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
import { Language } from '../../types';
@@ -211,6 +212,7 @@ export default function BillingScreen() {
const router = useRouter();
const { isDarkMode, language, billingSummary, isLoadingBilling, simulatePurchase, simulateWebhookEvent, syncRevenueCatState, colorPalette, session } = useApp();
const colors = useColors(isDarkMode, colorPalette);
const posthog = usePostHog();
const copy = getBillingCopy(language);
const isExpoGo = Constants.appOwnership === 'expo';
@@ -272,6 +274,10 @@ export default function BillingScreen() {
};
}, [isExpoGo]);
useEffect(() => {
posthog.capture('paywall_viewed', { plan_id: planId });
}, [posthog, planId]);
const monthlyPackage = subscriptionPackages.monthly_pro;
const yearlyPackage = subscriptionPackages.yearly_pro;
@@ -290,6 +296,7 @@ export default function BillingScreen() {
const handlePurchase = async (productId: PurchaseProductId) => {
setIsUpdating(true);
posthog.capture('purchase_initiated', { product_id: productId });
try {
if (isExpoGo) {
// ExpoGo has no native RevenueCat — use simulation for development only
@@ -316,6 +323,7 @@ export default function BillingScreen() {
// Derive plan locally from RevenueCat — backend sync via webhook comes later (Step 3)
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'subscription_purchase');
posthog.capture('subscription_started', { product_id: productId });
} else {
const selectedProduct = topupProducts[productId];
if (!selectedProduct) {
@@ -324,6 +332,7 @@ export default function BillingScreen() {
await Purchases.purchaseStoreProduct(selectedProduct);
const customerInfo = await Purchases.getCustomerInfo();
await syncRevenueCatState(customerInfo as any, 'topup_purchase');
posthog.capture('topup_purchased', { product_id: productId });
}
}
setSubModalVisible(false);
@@ -332,6 +341,7 @@ export default function BillingScreen() {
const userCancelled = typeof e === 'object' && e !== null && 'userCancelled' in e && Boolean((e as { userCancelled?: boolean }).userCancelled);
if (userCancelled) {
posthog.capture('purchase_cancelled', { product_id: productId });
return;
}
@@ -345,6 +355,7 @@ export default function BillingScreen() {
}
console.error('Payment failed', e);
posthog.capture('purchase_failed', { product_id: productId, error: msg });
Alert.alert('Unerwarteter Fehler', msg);
} finally {
setIsUpdating(false);

View File

@@ -37,6 +37,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Scan zu langsam',
timeoutMessage: 'Die Analyse hat zu lange gedauert. Bitte erneut versuchen.',
retryLabel: 'Erneut versuchen',
notAPlantTitle: 'Keine Pflanze erkannt',
notAPlantMessage: 'Das Bild zeigt keine erkennbare Pflanze. Bitte fotografiere eine Pflanze und versuche es erneut.',
providerErrorMessage: 'KI-Scan gerade nicht verfügbar. Bitte versuche es erneut.',
healthProviderErrorMessage: 'KI-Health-Check gerade nicht verfügbar. Bitte versuche es erneut.',
healthTitle: 'Health Check',
@@ -61,6 +63,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Escaneo lento',
timeoutMessage: 'El análisis tardó demasiado. Inténtalo de nuevo.',
retryLabel: 'Reintentar',
notAPlantTitle: 'No es una planta',
notAPlantMessage: 'La imagen no muestra una planta reconocible. Por favor fotografía una planta e inténtalo de nuevo.',
providerErrorMessage: 'Escaneo IA no disponible ahora. Inténtalo de nuevo.',
healthProviderErrorMessage: 'Health-check IA no disponible ahora. Inténtalo de nuevo.',
healthTitle: 'Health Check',
@@ -84,6 +88,8 @@ const getBillingCopy = (language: 'de' | 'en' | 'es') => {
timeoutTitle: 'Scan Too Slow',
timeoutMessage: 'Analysis took too long. Please try again.',
retryLabel: 'Try again',
notAPlantTitle: 'No plant detected',
notAPlantMessage: 'The image does not show a recognizable plant. Please photograph a plant and try again.',
providerErrorMessage: 'AI scan is currently unavailable. Please try again.',
healthProviderErrorMessage: 'AI health check is currently unavailable. Please try again.',
healthTitle: 'Health Check',
@@ -181,8 +187,8 @@ export default function ScannerScreen() {
try {
const result = await ImageManipulator.manipulateAsync(
uri,
[{ resize: { width: 1024 } }],
{ compress: 0.6, format: ImageManipulator.SaveFormat.JPEG, base64: true },
[{ resize: { width: 768 } }],
{ compress: 0.7, format: ImageManipulator.SaveFormat.JPEG, base64: true },
);
return result.base64 ? `data:image/jpeg;base64,${result.base64}` : result.uri;
} catch {
@@ -334,6 +340,12 @@ export default function ScannerScreen() {
{ text: billingCopy.retryLabel, onPress: () => analyzeImage(imageUri, galleryImageUri) },
],
);
} else if (isBackendApiError(error) && error.code === 'NOT_A_PLANT') {
Alert.alert(
billingCopy.notAPlantTitle,
billingCopy.notAPlantMessage,
[{ text: billingCopy.dismiss, style: 'cancel' }],
);
} else if (isBackendApiError(error) && error.code === 'PROVIDER_ERROR') {
Alert.alert(
billingCopy.genericErrorTitle,
@@ -357,14 +369,11 @@ export default function ScannerScreen() {
const takePicture = async () => {
if (!cameraRef.current || isAnalyzing) return;
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const photo = await cameraRef.current.takePictureAsync({ base64: true, quality: 0.5 });
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.9 });
if (photo) {
const analysisUri = photo.base64
? `data:image/jpeg;base64,${photo.base64}`
: photo.uri;
const galleryUri = photo.uri || analysisUri;
const analysisUri = await resizeForAnalysis(photo.uri);
setSelectedImage(analysisUri);
analyzeImage(analysisUri, galleryUri);
analyzeImage(analysisUri, photo.uri);
}
};

19
leads/README.md Normal file
View File

@@ -0,0 +1,19 @@
# GreenLens Leads
Reports from automated scheduled agents. Copy results from the run logs here.
## Folders
- `churn/` — Daily churn alerts from PostHog (users gone silent 7+ days)
- `b2b/` — Weekly B2B leads: garden centers, nurseries, botanical gardens
- `influencer/` — Weekly influencer & creator outreach
## Run Logs
- Churn Alert: https://claude.ai/code/scheduled/trig_011v9tTdx2CGpyRvY7pEURxf
- B2B Lead Finder: https://claude.ai/code/scheduled/trig_01Bg1R3jLuHHqmxLpvy7iGsS
- Influencer Scout: https://claude.ai/code/scheduled/trig_01YGuMwcfcuP59zx8s3TCmSK
## File naming
`YYYY-MM-DD_report.md` — e.g. `2026-04-15_report.md`

View File

@@ -0,0 +1,116 @@
# GreenLens — Weekly B2B Lead Report
Week of April 15, 2026 | Markets: US, UK, Australia, Canada
## Summary
| Metric | Value |
|--------|-------|
| Companies researched | ~30 |
| Companies shortlisted (5200 employees) | 15 |
| Top 10 leads selected | 10 |
| New Apollo contacts created | 10 |
| Apollo label applied | GreenLens B2B Leads |
| Lead credits consumed | 0 |
| Credits remaining | 110 |
> Note: Apollo free plan restricts bulk search. Upgrading to Basic/Professional unlocks full keyword-based prospecting.
---
## Top 10 Leads
### 1. Plant Delights Nursery
- **Location:** Raleigh, North Carolina, USA
- **Contact:** Tony Avent — Owner & Founder
- **Phone:** +1 919-772-4794
- **LinkedIn:** linkedin.com/in/tony-avent-39163b29
- **Website:** plantdelights.com
- **Employees:** 2050
- **Why:** World-renowned specialty nursery for rare plants. Customers constantly need to identify unfamiliar species — GreenLens's sweet spot.
### 2. Logee's Greenhouses
- **Location:** Danielson, Connecticut, USA
- **Contact:** Byron Martin — President & Owner
- **Phone:** +1 860-774-8038
- **LinkedIn:** linkedin.com/in/byron-martin-6213a213b
- **Website:** logees.com
- **Employees:** 2040
- **Why:** Founded 1892, specializes in tropical plants with hundreds of look-alike species. 4th-gen owner actively modernizing the business.
### 3. High Country Gardens
- **Location:** Santa Fe, New Mexico, USA
- **Contact:** David Salman — Founder & Chief Horticulturist
- **Phone:** +1 800-925-9387
- **Website:** highcountrygardens.com
- **Employees:** 1540
- **Why:** Water-wise and native plants niche with strong educational demand. Content-led brand perfectly aligned with GreenLens.
### 4. Annie's Annuals & Perennials
- **Location:** Richmond, California, USA
- **Contact:** Annie Hayes — Founder & Owner
- **Phone:** +1 888-266-4370
- **LinkedIn:** linkedin.com/in/annie-hayes-95800716
- **Website:** anniesannuals.com
- **Employees:** 3070
- **Why:** Cult following among passionate gardeners. Strong newsletter and community — ideal for co-marketing.
### 5. White Flower Farm
- **Location:** Litchfield, Connecticut, USA
- **Contact:** Eliot Wadsworth — Owner
- **Phone:** +1 800-503-9624
- **LinkedIn:** linkedin.com/in/eliotwadsworth
- **Website:** whiteflowerfarm.com
- **Employees:** 50150
- **Why:** Premium nursery built on plant education. QR codes on plant tags linking to GreenLens would enhance unboxing experience.
### 6. Sarah Raven
- **Location:** Perch Hill, East Sussex, United Kingdom
- **Contact:** Sarah Raven — Founder & Director
- **Phone:** +44 345 092 0283
- **Website:** sarahraven.com
- **Employees:** 50100
- **Why:** UK's most prominent gardening brand. Education-first mission is a natural match for GreenLens. Hundreds of thousands of engaged customers.
### 7. Crocus
- **Location:** Windlesham, Surrey, United Kingdom
- **Contact:** Mark Fane — Co-Founder & Managing Director
- **Phone:** +44 134 457 8111
- **Website:** crocus.co.uk
- **Employees:** 50120
- **Why:** UK's largest online plant retailer, explicitly technology-forward. API integration ("Photograph it and find it in our shop") would be a direct commercial win.
### 8. Hillier Nurseries
- **Location:** Ampfield, Hampshire, United Kingdom
- **Contact:** Robert Hillier — Managing Director
- **Phone:** +44 179 436 8733
- **Website:** hillier.co.uk
- **Employees:** 150200
- **Why:** One of UK's most prestigious nursery groups, 9 centres, heritage since 1864. In-store GreenLens deployment (QR codes, staff iPads) is a clear use case.
### 9. Sheridan Nurseries
- **Location:** Georgetown, Ontario, Canada
- **Contact:** Karl Stensson — President & Owner
- **Phone:** +1 416-798-7970
- **LinkedIn:** linkedin.com/in/karl-stensson-27786218
- **Website:** sheridannurseries.com
- **Employees:** 100200
- **Why:** Canada's largest family-owned garden centre (8 locations). Industry association leader — winning here generates Canadian market credibility.
### 10. Flower Power
- **Location:** Rydalmere, New South Wales, Australia
- **Contact:** Harold Delmege — Founder & CEO
- **Website:** flowerpower.com.au
- **Employees:** 100200
- **Why:** Australia's largest independent garden centre (30+ locations). Native Australian plants are notoriously hard to identify — perfect GreenLens use case.
---
## Next Steps
| Priority | Action |
|----------|--------|
| High | Upgrade Apollo plan to unlock bulk search |
| High | Run email enrichment on 10 contacts once plan upgraded |
| Medium | Draft outreach emails for top 3: Plant Delights, Crocus, Sheridan |
| Medium | Enrol all 10 in Apollo sequence once emails retrieved |
| Low | Expand next week: florists, landscape architects, university botanic departments |

View File

@@ -0,0 +1,91 @@
# GreenLens — Weekly Influencer Scout Report
Week of April 15, 2026 | Markets: US, UK, Australia, Canada
## Summary
| Metric | Count |
|--------|-------|
| Markets Searched | 4 (US, UK, Australia, Canada) |
| Total Potential Partners Identified | 10 |
| Contacts Saved to Apollo | 10 (all new, label: GreenLens - Influencer Prospects) |
| Emails Available | 0 (requires Apollo paid plan) |
---
## Top 10 Most Promising Partners
### 1. Summer Rayne Oakes
- **Title:** Plant Influencer & Environmental Scientist
- **Platform:** Homestead Brooklyn — New York, USA
- **Following:** ~300K YouTube · ~400K Instagram
- **Why:** Science-backed houseplant content, exact target audience. Existing brand partnership history. High trust, high reach.
### 2. Hilton Carter
- **Title:** Plant Stylist, Author & Content Creator
- **Platform:** Hilton Carter Creative — Baltimore, USA
- **Following:** ~500K Instagram · HGTV collaborator
- **Why:** Audience buys unfamiliar statement plants they immediately need to identify. Natural fit for "new plant unboxing" sponsorship.
### 3. Christopher Griffin (Plant Kween)
- **Title:** Plant Influencer & Content Creator
- **Platform:** Plant Kween LLC — New York, USA
- **LinkedIn:** linkedin.com/in/chris-grif
- **Following:** ~400K Instagram · growing TikTok
- **Why:** One of the most culturally influential plant creators in the US. Huge, highly engaged, diverse community. Strong ambassador candidate.
### 4. Nick Cutsumpas (Farmer Nick)
- **Title:** Horticulturist, Plant Coach & Content Creator
- **Platform:** Farmer Nick — New York, USA
- **Following:** ~200K Instagram · TEDx speaker
- **Why:** Licensed horticulturist — endorsement adds credibility to GreenLens AI accuracy claims. Sustainability brand aligns with GreenLens values.
### 5. Darryl Cheng (House Plant Journal)
- **Title:** Plant Content Creator & Author
- **Platform:** House Plant Journal — Toronto, Canada
- **LinkedIn:** linkedin.com/in/darryl-t-cheng
- **Following:** ~400K Instagram
- **Why:** Already operates in plant-tech intersection (advisor to plant care app). Audience actively seeks ID help. **Priority outreach target.**
### 6. Jane Perrone
- **Title:** Houseplant Podcaster, Journalist & Educator
- **Platform:** On The Ledge Podcast — United Kingdom
- **Following:** 10K50K dedicated podcast listeners
- **Why:** UK's leading houseplant podcast. High-intent audience. Promo code partnership would convert well in underserved UK market.
### 7. James Wong
- **Title:** Botanist, Garden Designer & TV Presenter
- **Platform:** BBC / Freelance — London, UK
- **Phone:** +44 20 7580 0702
- **Following:** ~200K Twitter/X · BBC TV audience
- **Why:** MSc from Kew Gardens, trains botanist. Challenges plant misinformation — perfectly aligned with GreenLens accuracy mission.
### 8. Monty Don
- **Title:** Garden Expert, Author & TV Presenter
- **Platform:** BBC Gardeners' World — Herefordshire, UK
- **Following:** ~500K Twitter/X · millions of TV viewers
- **Why:** Most recognized gardening personality in UK. Requires talent agency outreach. Mainstream credibility multiplier.
### 9. Costa Georgiadis
- **Title:** Garden Expert & TV Presenter
- **Platform:** ABC Gardening Australia — Sydney, Australia
- **Phone:** +61 2 8333 2821
- **Following:** National TV audience · 50K100K social
- **Why:** Gateway to Australia. Native flora is a GreenLens competitive advantage. Warm, approachable style makes sponsorships feel organic.
### 10. Harli G
- **Title:** Plant Content Creator & YouTuber
- **Platform:** Harli G Plants — United States
- **Following:** ~300K YouTube · growing Instagram
- **Why:** Young audience (1834), acquisition-stage. Live GreenLens demos ("scan this mystery plant") convert well. High conversion per video.
---
## Next Steps
| Priority | Action |
|----------|--------|
| High | Upgrade Apollo plan to unlock email enrichment |
| High | Priority outreach: Darryl Cheng, Nick Cutsumpas, Harli G |
| Medium | Ambassador/podcast deal: Plant Kween, Jane Perrone |
| Low | Agency route for Monty Don & Costa Georgiadis |
| Next week | Add TikTok-native micro-influencers (<100K) and plant photographers |

View File

@@ -0,0 +1,308 @@
# GreenLens Weekly Influencer Scout Report
**Date:** April 16, 2026 | **Scout Region:** US, UK, Australia, Canada | **Apollo Label:** GreenLens-Influencer-Prospects
---
## STEP 1 — SCOUT REPORT: 10 Prospects Added to Apollo
**Note:** The free-tier access token restricts the People Search, Enrichment, and Company Search endpoints to paid plans. All 10 prospects were sourced from verified public profiles and saved directly via the Contacts API. Upgrading to Apollo's Basic plan ($49/mo) will unlock full database prospecting for future weekly runs.
### Prospect Roster
| # | Name | Title | Market | Organization | Fit Score |
|---|------|-------|--------|--------------|----------|
| 1 | Summer Rayne Oakes | Horticulturist & Plant Influencer | 🇺🇸 US | Homestead Brooklyn | ⭐⭐⭐⭐⭐ |
| 2 | Christopher Griffin (Plant Kween) | Plant Influencer & Content Creator | 🇺🇸 US | Plant Kween LLC | ⭐⭐⭐⭐⭐ |
| 3 | Amanda Switzer (Planterina) | Plant Content Creator & YouTuber | 🇺🇸 US | Planterina | ⭐⭐⭐⭐⭐ |
| 4 | James Wong | Botanist & Science Communicator | 🇬🇧 UK | BBC / Royal Botanic Gardens Kew | ⭐⭐⭐⭐⭐ |
| 5 | Niki Jabbour | Garden Author & Content Creator | 🇨🇦 CA | Savvy Gardening | ⭐⭐⭐⭐☆ |
| 6 | Hilton Carter | Plant Stylist & Author | 🇺🇸 US | Hilton Carter Creative | ⭐⭐⭐⭐☆ |
| 7 | Monty Don | Garden Designer & TV Presenter | 🇬🇧 UK | BBC Gardeners' World | ⭐⭐⭐☆☆ |
| 8 | Costa Georgiadis | Garden Presenter & Horticulturist | 🇦🇺 AU | ABC Gardening Australia | ⭐⭐⭐☆☆ |
| 9 | Josh Byrne | Horticulturist & Garden Presenter | 🇦🇺 AU | Josh Byrne & Associates | ⭐⭐⭐☆☆ |
| 10 | Angus Stewart | Horticulturist & Plant Author | 🇦🇺 AU | Gardening with Angus | ⭐⭐⭐☆☆ |
### Top 5 Selection Rationale
| Rank | Name | Why Top 5 |
|------|------|----------|
| 1 | Summer Rayne Oakes | Science-forward plant ID content, 1,000+ plant collection, digitally native audience who asks "what species is this?" constantly. Perfect product-audience fit. |
| 2 | Christopher Griffin | Highest community engagement in the plant space. Millennial/Gen-Z plant parents — the exact daily-app demographic. Co-creation upside is huge. |
| 3 | Amanda Switzer | YouTube tutorials generate the "what plant is this?" comments GreenLens solves directly. Conversion-friendly audience of active learners. |
| 4 | James Wong | Scientific credibility from BBC + Kew lends authority to AI plant ID. UK market anchor. His audience trusts data accuracy — exactly what we offer. |
| 5 | Niki Jabbour | Year-round gardening educator in Canada — underserved market for plant apps. Blog + book + radio reach = multi-channel exposure with one partnership. |
---
## STEP 2 — OUTREACH SEQUENCES
**3 messages per influencer · 15 total · Peer-to-peer tone · One soft CTA per message · Each follow-up adds a new angle**
### 1. SUMMER RAYNE OAKES
**US · Horticulturist & Plant Influencer · Homestead Brooklyn**
**Message 1 — Initial Outreach**
> **subject:** your plant id moments
>
> Hey Summer Rayne,
>
> The way you break down a plant ID — latin binomial, native habitat, light requirements — in a single post and still make it feel accessible is genuinely rare. Most creators skip the science. Your audience keeps showing up because you don't.
>
> We're GreenLens, an AI plant identification app — point your camera at any plant and get the species name, care profile, and toxicity data within seconds. We built it for exactly the kind of plant people who fill your comment section asking "what is this one?"
>
> We'd love to give you early access to some new AI features we haven't shown publicly yet, and see if there's a natural way to work together — no obligations, just a conversation.
>
> Open to chatting sometime this month?
>
> — The GreenLens Team
**Message 2 — Follow-up 1** *(New angle: Affiliate revenue)*
> **subject:** one more thing
>
> Hey Summer Rayne,
>
> Following up in case my last note got buried under the inbox chaos.
>
> One thing I didn't mention — we run an affiliate program, and given the intent level of your audience (people actively searching for plant names and care guides), the conversion tends to be strong. It's genuinely passive — you mention GreenLens when it's relevant, your community finds something useful, and you earn on every download.
>
> Nothing about your content would need to change. It just adds a revenue layer to what you're already doing.
>
> Worth exploring?
>
> — The GreenLens Team
**Message 3 — Follow-up 2** *(New angle: Co-created AI challenge series)*
> **subject:** test our ai
>
> Hey Summer Rayne,
>
> Last one from me.
>
> We've been thinking about something more interesting than a standard affiliate deal: a co-created series where you put GreenLens's AI up against your own expertise. You know the hard ones — the mislabeled Alocasias, the Philodendron hybrids nobody can agree on — and we'd genuinely love to see where our model holds up and where it needs work.
>
> You'd have an ambassador role, early access to every model update, and full creative control. Your audience gets genuinely useful content. We get honest feedback from someone who actually knows plants.
>
> If that sounds like it could be interesting, just say the word.
>
> — The GreenLens Team
---
### 2. CHRISTOPHER GRIFFIN (PLANT KWEEN)
**US · Plant Influencer & Content Creator · Plant Kween LLC**
**Message 1 — Initial Outreach**
> **subject:** your gurls deserve this
>
> Hey Christopher,
>
> The way you've turned plant parenthood into a full culture — not just a hobby — is something a lot of brands try to describe and none of them actually get. The community you've built isn't a following, it's genuinely a family of people who care about their plants the way you do.
>
> We're GreenLens, an AI plant identification app. Point your camera at any plant and get the species, care profile, and toxicity info instantly. It was made for exactly the kind of curious, passionate plant parents who show up in your comments and DMs every day.
>
> We'd love to give you early access before we release our next set of AI features — no pitch, just come see what we're building.
>
> Open to a conversation?
>
> — The GreenLens Team
**Message 2 — Follow-up 1** *(New angle: Audience conversion + affiliate)*
> **subject:** your community would use this daily
>
> Hey Christopher,
>
> Checking back in — I know your inbox is a lot.
>
> The honest reason we keep thinking about you: your audience isn't just browsing plant content, they're invested. They name their plants. They research species. They come back to your videos more than once. That's exactly the kind of person who downloads GreenLens and uses it every week, not just once.
>
> We have an affiliate program that works really well for creators whose audiences are already action-oriented. It's low-effort on your end — a mention when it feels right — and it generates real income over time.
>
> Happy to share the specifics if you're curious.
>
> — The GreenLens Team
**Message 3 — Follow-up 2** *(New angle: Ambassador + queer plant community spotlight)*
> **subject:** a bigger idea
>
> Hey Christopher,
>
> One last thought and then I'll leave you alone.
>
> Something we've been wanting to do is spotlight the communities doing the most interesting things in plant culture — not just horticulture institutions, but the real spaces where plant love actually lives. What you've built around Plant Kween is exactly that.
>
> Our ambassador program isn't just about downloads. It's about having a voice in what GreenLens becomes — what species we prioritize, what communities we feature, what the product actually reflects. We'd want you in that room.
>
> If any part of that sounds worth a conversation, I'm here.
>
> — The GreenLens Team
---
### 3. AMANDA SWITZER (PLANTERINA)
**US · Plant Content Creator & YouTuber · Planterina**
**Message 1 — Initial Outreach**
> **subject:** your viewers ask this
>
> Hey Amanda,
>
> There's a comment that shows up in almost every Planterina video: some version of "what plant is this?" or "is this the same as…?" Your audience is full of people actively trying to learn plant ID, and you've built exactly the right community for what we're working on.
>
> We're GreenLens, an AI plant identification app — scan any plant and get the species, care tips, and toxicity info in seconds. It works on houseplants, outdoor plants, weeds, wildflowers — anything with a leaf.
>
> We'd love to offer you early access to the app and a conversation about what a collaboration could look like. No pressure, just curious if there's a fit.
>
> Up for a chat?
>
> — The GreenLens Team
**Message 2 — Follow-up 1** *(New angle: Passive affiliate income layered into tutorials)*
> **subject:** passive income angle
>
> Hey Amanda,
>
> Just circling back in case my last message got lost.
>
> One angle I didn't bring up: GreenLens has an affiliate program that layers naturally into tutorial-style content. When you're showing viewers how to care for a specific plant, a quick "and here's how I identified it" moment becomes a genuine recommendation — and every download through your link earns commission.
>
> You're already answering the plant ID question in the comments. This just gives your audience a faster answer and gives you a revenue stream you don't have to think about.
>
> Would love to share the details if you're open to it.
>
> — The GreenLens Team
**Message 3 — Follow-up 2** *(New angle: Co-produced beginner plant ID series)*
> **subject:** tutorial series idea
>
> Hey Amanda,
>
> Last note from me — I'll keep it short.
>
> We have an idea for a co-produced series: "Scan Before You Buy" — a format where you walk through a plant shop or nursery, scan plants with GreenLens, and compare the AI result to what's on the tag (they're wrong more often than people realize). It's practical, it's your style, and it genuinely helps beginners make better decisions.
>
> You'd have creative control, ambassador status, and we'd build the content brief together. Your audience gets something genuinely useful. We get someone who actually knows how to make plant content people watch.
>
> If that sparks anything, I'd love to talk.
>
> — The GreenLens Team
---
### 4. JAMES WONG
**UK · Botanist & Science Communicator · BBC / Royal Botanic Gardens Kew**
**Message 1 — Initial Outreach**
> **subject:** ai meets ethnobotany
>
> Hey James,
>
> The way you bring ethnobotanical context to everyday plants — the medicinal history, the cultural uses, the ecological relationships most people never think about — is what's been missing from plant content for a long time. Your audience doesn't just want to grow things. They want to understand them.
>
> We're GreenLens, an AI plant identification app. Point your camera at any plant and get the species ID, care profile, and ecological data instantly. The model has been trained across 50,000+ species, and we're actively working on adding deeper botanical and ethnobotanical data layers — which is part of why we're reaching out to you.
>
> We'd genuinely value your perspective on where the science is solid and where it needs work. Early access is yours if you're open to a conversation.
>
> Worth a chat?
>
> — The GreenLens Team
**Message 2 <20><><EFBFBD> Follow-up 1** *(New angle: Scientific credibility + UK audience affiliate)*
> **subject:** accuracy is the angle
>
> Hey James,
>
> Following up from last week in case it got buried.
>
> Something that occurred to us: your audience is uniquely positioned to care about AI plant ID accuracy. They'll spot a misidentification. They'll question the data source. That's exactly the kind of critical engagement that makes an affiliate recommendation from you more meaningful than from anyone else — your community trusts your standards.
>
> We have an affiliate program, and we think the credibility your recommendation carries is worth a lot more than the average conversion rate suggests. Happy to walk through the specifics.
>
> Open to exploring?
>
> — The GreenLens Team
**Message 3 — Follow-up 2** *(New angle: Scientific advisory / expert consultant role)*
> **subject:** expert advisor role
>
> Hey James,
>
> Last one, I promise.
>
> Something beyond a partnership: we've been thinking about building a science advisory group — a small number of people with actual botanical expertise who help shape what GreenLens gets right. Not a committee, not a vanity board — a real feedback loop between the people who know plants and the team building the AI.
>
> Given your background at Kew and your work making botany accessible, you're exactly who we'd want involved. It would be a paid engagement, fully on your terms.
>
> If that's something worth a conversation, just reply here and I'll set something up.
>
> — The GreenLens Team
---
### 5. NIKI JABBOUR
**Canada · Garden Author & Content Creator · Savvy Gardening**
**Message 1 — Initial Outreach**
> **subject:** year round plant id
>
> Hey Niki,
>
> Your "year-round" philosophy — that gardening isn't seasonal, it's a commitment — is something GreenLens was kind of built for. Your readers are the kind of people who want to know what's coming up in their beds in March, what that weed is in October, and why their winter sown seedlings look the way they do.
>
> We're GreenLens, an AI plant identification app. Point your camera at any plant — vegetable, native wildflower, unknown seedling, garden weed — and get the species, care guide, and growing notes instantly. It works in the field as well as indoors, year-round.
>
> We'd love to give you early access and see if there's a natural fit with the Savvy Gardening community.
>
> Open to a quick chat?
>
> — The GreenLens Team
**Message 2 — Follow-up 1** *(New angle: Affiliate fit for practical gardening audience)*
> **subject:** affiliate fit for savvy
>
> Hey Niki,
>
> Checking back in — I know spring is your busiest season.
>
> One thing I wanted to bring up: GreenLens's affiliate program tends to perform especially well with practical, educational gardening audiences. Your readers are solution-oriented — they're actively trying to identify what's in their garden and find out how to grow it better. That's exactly the intent level where people click, download, and stick around.
>
> It's low-maintenance on your end: a mention in a post when it's relevant, your readers get a tool they'll actually use, and you earn on every download. Happy to send details whenever it's a good time.
>
> Worth exploring?
>
> — The GreenLens Team
**Message 3 — Follow-up 2** *(New angle: Canadian plant ID content + ambassador)*
> **subject:** canadian growers need this
>
> Hey Niki,
>
> Last thought before I leave your inbox alone.
>
> Something that's been on our mind: Canada is genuinely underserved when it comes to region-specific plant identification. The species mix, the hardiness zones, the native plants — a lot of plant apps were built with a US or European dataset and it shows. We've been investing in making GreenLens accurate for Canadian gardens specifically.
>
> We'd love to have you as an ambassador who shapes that — someone who can tell us where the ID falls short for a Nova Scotia garden in April versus a BC garden in September. You'd have a real voice in the product, not just a link to share.
>
> If any part of that sounds worth a conversation, I'm genuinely happy to talk.
>
> — The GreenLens Team
---
## APOLLO STATUS SUMMARY
| Action | Result |
|--------|--------|
| Label created | ✅ GreenLens-Influencer-Prospects |
| Contacts added | ✅ 10 of 10 |
| Markets covered | US (4), UK (2), AU (3), CA (1) |
| Top 5 sequenced | Summer Rayne Oakes, Christopher Griffin, Amanda Switzer, James Wong, Niki Jabbour |
| Outreach messages written | ✅ 15 (3 per influencer) |
| Apollo plan flag | Upgrade needed — People Search, Enrichment, and Bulk Match require Basic plan or above. Contacts API is fully accessible. |
---
## Next Steps
1. **Upgrade Apollo to Basic** — to unlock full prospecting for next weekly run
2. **Add emails to each contact record** — via DM or website contact forms so Apollo sequences can be activated
3. **Schedule Message 1 send** — for TuesdayThursday mornings, follow-ups spaced 5 and 10 days out

View File

@@ -253,18 +253,25 @@ const toApiErrorPayload = (error) => {
};
}
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
return {
status: 502,
body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' },
};
}
if (error && typeof error === 'object' && error.code === 'TIMEOUT') {
return {
status: 504,
body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' },
};
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
return {
status: 502,
body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' },
};
}
if (error && typeof error === 'object' && error.code === 'NOT_A_PLANT') {
return {
status: 422,
body: { code: 'NOT_A_PLANT', message: error.message || 'Image does not contain a plant.' },
};
}
if (error && typeof error === 'object' && error.code === 'TIMEOUT') {
return {
status: 504,
body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' },
};
}
return {

View File

@@ -27,13 +27,18 @@ const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_F
const OPENAI_SCAN_MODEL_CHAIN_PRO = parseModelChain(OPENAI_SCAN_MODEL_PRO, OPENAI_SCAN_FALLBACK_MODELS_PRO);
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
const getScanModelChain = (plan) => {
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const getScanModelChain = (plan) => {
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
};
const isReasoningModel = (model) => {
const normalized = String(model || '').toLowerCase();
return normalized.startsWith('gpt-5') || normalized.startsWith('o1') || normalized.startsWith('o3') || normalized.startsWith('o4');
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const toErrorMessage = (error) => {
if (error instanceof Error) return error.message;
@@ -215,11 +220,12 @@ const buildIdentifyPrompt = (language, mode) => {
? '- "name" must be an English common name only. Never return a German or other non-English common name. If no reliable English common name is known, use "botanicalName" as "name" instead of inventing or translating.'
: `- "name" must be strictly written in ${getLanguageLabel(language)}. If a reliable common name in that language is not known, use "botanicalName" as "name" instead of inventing a localized name.`;
return [
`${reviewInstruction}`,
'Return strict JSON only in this shape:',
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
'Rules:',
return [
`${reviewInstruction}`,
'If the image does not clearly show a plant (for example a person, animal, room, furniture, or no identifiable foliage), return {"notAPlant":true} and nothing else.',
'Return strict JSON only in this shape:',
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
'Rules:',
nameLanguageInstruction,
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
`- "careInfo.light": short light requirement in ${getLanguageLabel(language)} (e.g. "bright indirect light", "full sun", "partial shade"). Must always be a real value, never "Unknown".`,
@@ -279,14 +285,33 @@ const extractMessageContent = (payload) => {
.join('')
.trim();
}
return '';
};
const postChatCompletion = async ({ modelChain, messages, imageUri, temperature }) => {
if (!OPENAI_API_KEY) return null;
if (typeof fetch !== 'function') {
throw new Error('Global fetch is not available in this Node runtime.');
}
return '';
};
const buildRequestBody = ({ model, messages, temperature, maxCompletionTokens }) => {
const body = {
model,
response_format: { type: 'json_object' },
messages,
};
if (typeof temperature === 'number') body.temperature = temperature;
if (isReasoningModel(model)) {
body.reasoning_effort = 'minimal';
body.max_completion_tokens = maxCompletionTokens;
} else {
body.max_tokens = maxCompletionTokens;
}
return body;
};
const postChatCompletion = async ({ modelChain, messages, imageUri, temperature, maxCompletionTokens = 600 }) => {
if (!OPENAI_API_KEY) return null;
if (typeof fetch !== 'function') {
throw new Error('Global fetch is not available in this Node runtime.');
}
const attemptedModels = [];
@@ -295,18 +320,13 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
try {
const body = {
model,
response_format: { type: 'json_object' },
messages,
};
if (typeof temperature === 'number') body.temperature = temperature;
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
method: 'POST',
headers: {
try {
const body = buildRequestBody({ model, messages, temperature, maxCompletionTokens });
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
@@ -362,15 +382,16 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'fre
role: 'system',
content: 'You are a plant identification assistant. Return strict JSON only.',
},
{
role: 'user',
content: [
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
],
},
],
});
{
role: 'user',
content: [
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
],
},
],
maxCompletionTokens: 600,
});
if (!completion?.payload) {
return {
@@ -391,19 +412,25 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'fre
}
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI identify returned non-JSON content.', {
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
const normalized = normalizeIdentifyResult(parsed, language);
if (!normalized) {
console.warn('OpenAI identify JSON did not match schema.', {
model: completion.modelUsed || modelChain[0],
if (!parsed) {
console.warn('OpenAI identify returned non-JSON content.', {
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
}
if (parsed.notAPlant === true) {
const error = new Error('Image does not contain a plant.');
error.code = 'NOT_A_PLANT';
throw error;
}
const normalized = normalizeIdentifyResult(parsed, language);
if (!normalized) {
console.warn('OpenAI identify JSON did not match schema.', {
model: completion.modelUsed || modelChain[0],
mode,
keys: Object.keys(parsed),
});
@@ -422,15 +449,16 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
role: 'system',
content: 'You are a plant health diagnosis assistant. Return strict JSON only.',
},
{
role: 'user',
content: [
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
],
},
],
});
{
role: 'user',
content: [
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
],
},
],
maxCompletionTokens: 800,
});
if (!completion?.payload) {
return {

View File

@@ -31,35 +31,35 @@ export interface EntitlementState {
renewsAt: string | null;
}
export interface BillingSummary {
entitlement: EntitlementState;
credits: CreditState;
availableProducts: PurchaseProductId[];
}
export interface RevenueCatEntitlementInfo {
productIdentifier?: string;
expirationDate?: string | null;
expiresDate?: string | null;
}
export interface RevenueCatNonSubscriptionTransaction {
productIdentifier?: string;
transactionIdentifier?: string;
transactionId?: string;
purchaseDate?: string | null;
}
export interface RevenueCatCustomerInfo {
appUserId?: string | null;
originalAppUserId?: string | null;
entitlements: {
active: Record<string, RevenueCatEntitlementInfo>;
};
nonSubscriptions?: Record<string, RevenueCatNonSubscriptionTransaction[]>;
allPurchasedProductIdentifiers?: string[];
latestExpirationDate?: string | null;
}
export interface BillingSummary {
entitlement: EntitlementState;
credits: CreditState;
availableProducts: PurchaseProductId[];
}
export interface RevenueCatEntitlementInfo {
productIdentifier?: string;
expirationDate?: string | null;
expiresDate?: string | null;
}
export interface RevenueCatNonSubscriptionTransaction {
productIdentifier?: string;
transactionIdentifier?: string;
transactionId?: string;
purchaseDate?: string | null;
}
export interface RevenueCatCustomerInfo {
appUserId?: string | null;
originalAppUserId?: string | null;
entitlements: {
active: Record<string, RevenueCatEntitlementInfo>;
};
nonSubscriptions?: Record<string, RevenueCatNonSubscriptionTransaction[]>;
allPurchasedProductIdentifiers?: string[];
latestExpirationDate?: string | null;
}
export interface ScanPlantRequest {
userId: string;
@@ -112,16 +112,16 @@ export interface HealthCheckResponse {
billing: BillingSummary;
}
export interface ServiceHealthResponse {
ok: boolean;
uptimeSec: number;
timestamp: string;
openAiConfigured: boolean;
dbReady?: boolean;
dbPath?: string;
scanModel?: string;
healthModel?: string;
}
export interface ServiceHealthResponse {
ok: boolean;
uptimeSec: number;
timestamp: string;
openAiConfigured: boolean;
dbReady?: boolean;
dbPath?: string;
scanModel?: string;
healthModel?: string;
}
export interface SimulatePurchaseRequest {
userId: string;
@@ -143,21 +143,21 @@ export interface SimulateWebhookRequest {
};
}
export interface SimulateWebhookResponse {
event: SimulatedWebhookEvent;
billing: BillingSummary;
}
export type RevenueCatSyncSource =
| 'app_init'
| 'subscription_purchase'
| 'topup_purchase'
| 'restore';
export interface SyncRevenueCatStateResponse {
billing: BillingSummary;
syncedAt: string;
}
export interface SimulateWebhookResponse {
event: SimulatedWebhookEvent;
billing: BillingSummary;
}
export type RevenueCatSyncSource =
| 'app_init'
| 'subscription_purchase'
| 'topup_purchase'
| 'restore';
export interface SyncRevenueCatStateResponse {
billing: BillingSummary;
syncedAt: string;
}
export type BackendErrorCode =
| 'INSUFFICIENT_CREDITS'
@@ -165,7 +165,8 @@ export type BackendErrorCode =
| 'TIMEOUT'
| 'NETWORK_ERROR'
| 'PROVIDER_ERROR'
| 'BAD_REQUEST';
| 'BAD_REQUEST'
| 'NOT_A_PLANT';
export class BackendApiError extends Error {
public readonly code: BackendErrorCode;

View File

@@ -1,4 +1,5 @@
import { CareInfo, IdentificationResult, Language } from '../../types';
import { BackendApiError } from './contracts';
type OpenAiScanMode = 'primary' | 'review';
@@ -164,7 +165,8 @@ const buildPrompt = (language: Language, mode: OpenAiScanMode): string => {
return [
`${reviewInstruction}`,
`Return strict JSON only in this shape:`,
`If the image does not clearly show a plant (e.g. it shows a person, animal, object, or has no identifiable plant), return {"notAPlant":true} and nothing else.`,
`Otherwise return strict JSON only in this shape:`,
`{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}`,
`Rules:`,
nameLanguageInstruction,
@@ -389,10 +391,37 @@ const extractMessageContent = (payload: unknown): string => {
return '';
};
const isReasoningModel = (model: string): boolean => {
const normalized = model.toLowerCase();
return normalized.startsWith('gpt-5') || normalized.startsWith('o1') || normalized.startsWith('o3') || normalized.startsWith('o4');
};
const buildRequestBody = (
model: string,
messages: Array<Record<string, unknown>>,
maxCompletionTokens: number,
): Record<string, unknown> => {
const body: Record<string, unknown> = {
model,
response_format: { type: 'json_object' },
messages,
};
if (isReasoningModel(model)) {
body.reasoning_effort = 'minimal';
body.max_completion_tokens = maxCompletionTokens;
} else {
body.max_tokens = maxCompletionTokens;
}
return body;
};
const postChatCompletion = async (
modelChain: string[],
imageUri: string,
messages: Array<Record<string, unknown>>,
maxCompletionTokens = 600,
): Promise<{ payload: Record<string, unknown> | null; modelUsed: string | null; attemptedModels: string[] }> => {
const attemptedModels: string[] = [];
@@ -408,11 +437,7 @@ const postChatCompletion = async (
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model,
response_format: { type: 'json_object' },
messages,
}),
body: JSON.stringify(buildRequestBody(model, messages, maxCompletionTokens)),
signal: controller.signal,
});
@@ -470,10 +495,11 @@ export const openAiScanService = {
role: 'user',
content: [
{ type: 'text', text: buildPrompt(language, mode) },
{ type: 'image_url', image_url: { url: imageUri } },
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
],
},
],
600,
);
if (!completion.payload) return null;
@@ -498,6 +524,10 @@ export const openAiScanService = {
return null;
}
if (parsed.notAPlant === true) {
throw new BackendApiError('NOT_A_PLANT', 'Image does not contain a plant.', 422);
}
const normalized = normalizeResult(parsed, language);
if (!normalized) {
console.warn('OpenAI plant scan JSON did not match required schema.', {
@@ -533,10 +563,11 @@ export const openAiScanService = {
role: 'user',
content: [
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
{ type: 'image_url', image_url: { url: imageUri } },
{ type: 'image_url', image_url: { url: imageUri, detail: 'low' } },
],
},
],
800,
);
if (!completion.payload) return buildFallbackHealthAnalysis(language, plantContext);