Initial commit for Greenlens

This commit is contained in:
Timo Knuth
2026-03-16 21:31:46 +01:00
parent 307135671f
commit 05d4f6e78b
573 changed files with 54233 additions and 1891 deletions

View File

@@ -0,0 +1,277 @@
import {
BackendApiError,
BackendErrorCode,
BillingSummary,
HealthCheckResponse,
PurchaseProductId,
ScanPlantResponse,
SemanticSearchResponse,
ServiceHealthResponse,
SimulatedWebhookEvent,
SimulatePurchaseResponse,
SimulateWebhookResponse,
} from './contracts';
import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim();
const REQUEST_TIMEOUT_MS = 15000;
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
if (status === 400) return 'BAD_REQUEST';
if (status === 401 || status === 403) return 'UNAUTHORIZED';
if (status === 402) return 'INSUFFICIENT_CREDITS';
if (status === 408 || status === 504) return 'TIMEOUT';
return 'PROVIDER_ERROR';
};
const buildBackendUrl = (path: string): string => {
const base = BACKEND_BASE_URL.replace(/\/$/, '');
return `${base}${path}`;
};
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
if (!value) return null;
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
return null;
}
};
const makeRequest = async <T,>(
path: string,
options: {
method: 'GET' | 'POST';
token: string;
idempotencyKey?: string;
body?: Record<string, unknown>;
},
): Promise<T> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${options.token}`,
};
if (options.idempotencyKey) {
headers['Idempotency-Key'] = options.idempotencyKey;
}
const response = await fetch(buildBackendUrl(path), {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
});
const rawText = await response.text();
const jsonPayload = parseMaybeJson(rawText);
if (!response.ok) {
const payloadCode = jsonPayload?.code;
const code = typeof payloadCode === 'string'
? payloadCode as BackendErrorCode
: mapHttpStatusToErrorCode(response.status);
const payloadMessage = jsonPayload?.message;
const message = typeof payloadMessage === 'string'
? payloadMessage
: `Backend request failed with status ${response.status}.`;
throw new BackendApiError(code, message, response.status, jsonPayload || undefined);
}
if (jsonPayload == null) {
throw new BackendApiError('PROVIDER_ERROR', 'Backend returned invalid JSON.', 502);
}
return jsonPayload as T;
} catch (error) {
if (error instanceof BackendApiError) throw error;
if (error instanceof Error && error.name === 'AbortError') {
throw new BackendApiError('TIMEOUT', 'Backend request timed out.', 408);
}
throw new BackendApiError('PROVIDER_ERROR', 'Backend request failed unexpectedly.', 500, {
detail: error instanceof Error ? error.message : String(error),
});
} finally {
clearTimeout(timeout);
}
};
export const backendApiClient = {
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!BACKEND_BASE_URL) {
return {
ok: true,
uptimeSec: 0,
timestamp: new Date().toISOString(),
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
dbReady: true,
dbPath: 'in-app-mock-backend',
stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY),
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
};
}
const token = await getAuthToken();
return makeRequest<ServiceHealthResponse>('/health', {
method: 'GET',
token,
});
},
getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.getBillingSummary(token);
}
return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET',
token,
});
},
scanPlant: async (params: {
idempotencyKey: string;
imageUri: string;
language: Language;
}): Promise<ScanPlantResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.scanPlant({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
language: params.language,
});
}
return makeRequest<ScanPlantResponse>('/v1/scan', {
method: 'POST',
token,
idempotencyKey: params.idempotencyKey,
body: {
imageUri: params.imageUri,
language: params.language,
},
});
},
semanticSearch: async (params: {
idempotencyKey: string;
query: string;
language: Language;
}): Promise<SemanticSearchResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.semanticSearch({
userId: token,
idempotencyKey: params.idempotencyKey,
query: params.query,
language: params.language,
});
}
return makeRequest<SemanticSearchResponse>('/v1/search/semantic', {
method: 'POST',
token,
idempotencyKey: params.idempotencyKey,
body: {
query: params.query,
language: params.language,
},
});
},
runHealthCheck: async (params: {
idempotencyKey: string;
imageUri: string;
language: Language;
plantContext?: {
name: string;
botanicalName: string;
careInfo: CareInfo;
description?: string;
};
}): Promise<HealthCheckResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.healthCheck({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
language: params.language,
plantContext: params.plantContext,
});
}
return makeRequest<HealthCheckResponse>('/v1/health-check', {
method: 'POST',
token,
idempotencyKey: params.idempotencyKey,
body: {
imageUri: params.imageUri,
language: params.language,
plantContext: params.plantContext,
},
});
},
simulatePurchase: async (params: {
idempotencyKey: string;
productId: PurchaseProductId;
}): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.simulatePurchase({
userId: token,
idempotencyKey: params.idempotencyKey,
productId: params.productId,
});
}
return makeRequest<SimulatePurchaseResponse>('/v1/billing/simulate-purchase', {
method: 'POST',
token,
idempotencyKey: params.idempotencyKey,
body: {
productId: params.productId,
},
});
},
simulateWebhook: async (params: {
idempotencyKey: string;
event: SimulatedWebhookEvent;
payload?: { credits?: number };
}): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken();
if (!BACKEND_BASE_URL) {
return mockBackendService.simulateWebhook({
userId: token,
idempotencyKey: params.idempotencyKey,
event: params.event,
payload: params.payload,
});
}
return makeRequest<SimulateWebhookResponse>('/v1/billing/simulate-webhook', {
method: 'POST',
token,
idempotencyKey: params.idempotencyKey,
body: {
event: params.event,
payload: params.payload || {},
},
});
},
};
export const isInsufficientCreditsError = (error: unknown): boolean => {
return error instanceof BackendApiError && error.code === 'INSUFFICIENT_CREDITS';
};

View File

@@ -0,0 +1,156 @@
import { CareInfo, IdentificationResult, Language, PlantHealthCheck } from '../../types';
export type PlanId = 'free' | 'pro';
export type BillingProvider = 'mock' | 'revenuecat' | 'stripe';
export type PurchaseProductId = 'monthly_pro' | 'yearly_pro' | 'topup_small' | 'topup_medium' | 'topup_large';
export type SimulatedWebhookEvent =
| 'entitlement_granted'
| 'entitlement_revoked'
| 'topup_granted'
| 'credits_depleted';
export interface BackendDatabaseEntry extends IdentificationResult {
imageUri: string;
imageStatus?: 'ok' | 'missing' | 'invalid';
categories: string[];
}
export interface CreditState {
monthlyAllowance: number;
usedThisCycle: number;
topupBalance: number;
available: number;
cycleStartedAt: string;
cycleEndsAt: string;
}
export interface EntitlementState {
plan: PlanId;
provider: BillingProvider;
status: 'active' | 'inactive';
renewsAt: string | null;
}
export interface BillingSummary {
entitlement: EntitlementState;
credits: CreditState;
availableProducts: PurchaseProductId[];
}
export interface ScanPlantRequest {
userId: string;
idempotencyKey: string;
imageUri: string;
language: Language;
}
export interface ScanPlantResponse {
result: IdentificationResult;
creditsCharged: number;
modelPath: string[];
modelUsed?: string | null;
modelFallbackCount?: number;
billing: BillingSummary;
}
export interface SemanticSearchRequest {
userId: string;
idempotencyKey: string;
query: string;
language: Language;
}
export interface SemanticSearchResponse {
status: 'success' | 'no_results';
results: BackendDatabaseEntry[];
creditsCharged: number;
billing: BillingSummary;
}
export interface HealthCheckRequest {
userId: string;
idempotencyKey: string;
imageUri: string;
language: Language;
plantContext?: {
name: string;
botanicalName: string;
careInfo: CareInfo;
description?: string;
};
}
export interface HealthCheckResponse {
healthCheck: PlantHealthCheck;
creditsCharged: number;
modelUsed?: string | null;
modelFallbackCount?: number;
billing: BillingSummary;
}
export interface ServiceHealthResponse {
ok: boolean;
uptimeSec: number;
timestamp: string;
openAiConfigured: boolean;
dbReady?: boolean;
dbPath?: string;
stripeConfigured?: boolean;
scanModel?: string;
healthModel?: string;
}
export interface SimulatePurchaseRequest {
userId: string;
idempotencyKey: string;
productId: PurchaseProductId;
}
export interface SimulatePurchaseResponse {
appliedProduct: PurchaseProductId;
billing: BillingSummary;
}
export interface SimulateWebhookRequest {
userId: string;
idempotencyKey: string;
event: SimulatedWebhookEvent;
payload?: {
credits?: number;
};
}
export interface SimulateWebhookResponse {
event: SimulatedWebhookEvent;
billing: BillingSummary;
}
export type BackendErrorCode =
| 'INSUFFICIENT_CREDITS'
| 'UNAUTHORIZED'
| 'TIMEOUT'
| 'PROVIDER_ERROR'
| 'BAD_REQUEST';
export class BackendApiError extends Error {
public readonly code: BackendErrorCode;
public readonly status: number;
public readonly metadata?: Record<string, unknown>;
constructor(
code: BackendErrorCode,
message: string,
status = 500,
metadata?: Record<string, unknown>,
) {
super(message);
this.name = 'BackendApiError';
this.code = code;
this.status = status;
this.metadata = metadata;
}
}
export const isBackendApiError = (error: unknown): error is BackendApiError => {
return error instanceof BackendApiError;
};

View File

@@ -0,0 +1,975 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
BackendApiError,
BillingProvider,
BillingSummary,
HealthCheckRequest,
HealthCheckResponse,
PlanId,
PurchaseProductId,
ScanPlantRequest,
ScanPlantResponse,
SemanticSearchRequest,
SemanticSearchResponse,
SimulatePurchaseRequest,
SimulatePurchaseResponse,
SimulateWebhookRequest,
SimulateWebhookResponse,
isBackendApiError,
} from './contracts';
import { getMockPlantByImage, searchMockCatalog } from './mockCatalog';
import { openAiScanService } from './openAiScanService';
import { IdentificationResult, PlantHealthCheck } from '../../types';
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
const FREE_MONTHLY_CREDITS = 15;
const GUEST_TRIAL_CREDITS = 5;
const PRO_MONTHLY_CREDITS = 250;
const SCAN_PRIMARY_COST = 1;
const SCAN_REVIEW_COST = 1;
const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
const FREE_SIMULATED_DELAY_MS = 1100;
const PRO_SIMULATED_DELAY_MS = 280;
const TOPUP_DEFAULT_CREDITS = 60;
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
monthly_pro: 0,
yearly_pro: 0,
topup_small: 25,
topup_medium: 75,
topup_large: 200,
};
interface MockAccountRecord {
userId: string;
plan: PlanId;
provider: BillingProvider;
cycleStartedAt: string;
cycleEndsAt: string;
monthlyAllowance: number;
usedThisCycle: number;
topupBalance: number;
renewsAt: string | null;
updatedAt: string;
}
interface IdempotencyRecord {
response: unknown;
createdAt: string;
}
type AccountStore = Record<string, MockAccountRecord>;
type IdempotencyStore = Record<string, IdempotencyRecord>;
const userLocks = new Map<string, Promise<void>>();
const nowIso = (): string => new Date().toISOString();
const startOfUtcMonth = (date: Date): Date => {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
};
const addUtcMonths = (date: Date, months: number): Date => {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
};
const addDays = (date: Date, days: number): Date => {
const result = new Date(date.getTime());
result.setUTCDate(result.getUTCDate() + days);
return result;
};
const getCycleBounds = (now: Date) => {
const cycleStartedAt = startOfUtcMonth(now);
const cycleEndsAt = addUtcMonths(cycleStartedAt, 1);
return { cycleStartedAt, cycleEndsAt };
};
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
if (userId === 'guest') return GUEST_TRIAL_CREDITS;
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
};
const getSimulatedDelay = (plan: PlanId): number => {
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS;
};
const sleep = async (ms: number): Promise<void> => {
if (ms <= 0) return;
await new Promise(resolve => setTimeout(resolve, ms));
};
const withUserLock = async <T>(userId: string, worker: () => Promise<T>): Promise<T> => {
const previousLock = userLocks.get(userId) || Promise.resolve();
let releaseLock: () => void = () => {};
const activeLock = new Promise<void>((resolve) => {
releaseLock = resolve;
});
userLocks.set(userId, activeLock);
await previousLock;
try {
return await worker();
} finally {
releaseLock();
if (userLocks.get(userId) === activeLock) {
userLocks.delete(userId);
}
}
};
const readJson = async <T,>(key: string, fallbackValue: T): Promise<T> => {
try {
const raw = await AsyncStorage.getItem(key);
if (!raw) return fallbackValue;
return JSON.parse(raw) as T;
} catch (error) {
console.error(`Failed to read mock backend key ${key}`, error);
return fallbackValue;
}
};
const writeJson = async <T,>(key: string, value: T): Promise<void> => {
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Failed to write mock backend key ${key}`, error);
}
};
const loadStores = async (): Promise<{ accounts: AccountStore; idempotency: IdempotencyStore }> => {
const [accounts, idempotency] = await Promise.all([
readJson<AccountStore>(MOCK_ACCOUNT_STORE_KEY, {}),
readJson<IdempotencyStore>(MOCK_IDEMPOTENCY_STORE_KEY, {}),
]);
return { accounts, idempotency };
};
const persistStores = async (stores: { accounts: AccountStore; idempotency: IdempotencyStore }): Promise<void> => {
await Promise.all([
writeJson(MOCK_ACCOUNT_STORE_KEY, stores.accounts),
writeJson(MOCK_IDEMPOTENCY_STORE_KEY, stores.idempotency),
]);
};
const buildDefaultAccount = (userId: string, now: Date): MockAccountRecord => {
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
const allowance = getMonthlyAllowanceForPlan('free', userId);
return {
userId,
plan: 'free',
provider: 'mock',
cycleStartedAt: cycleStartedAt.toISOString(),
cycleEndsAt: cycleEndsAt.toISOString(),
monthlyAllowance: allowance,
usedThisCycle: 0,
topupBalance: 0,
renewsAt: null,
updatedAt: nowIso(),
};
};
const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
const next = { ...account };
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
next.monthlyAllowance = expectedMonthlyAllowance;
}
if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') {
next.renewsAt = addDays(now, 30).toISOString();
}
const cycleEndsAtMs = new Date(next.cycleEndsAt).getTime();
if (Number.isNaN(cycleEndsAtMs) || now.getTime() >= cycleEndsAtMs) {
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
next.cycleStartedAt = cycleStartedAt.toISOString();
next.cycleEndsAt = cycleEndsAt.toISOString();
next.usedThisCycle = 0;
next.monthlyAllowance = expectedMonthlyAllowance;
}
return next;
};
const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string): MockAccountRecord => {
const now = new Date();
const existing = stores.accounts[userId] || buildDefaultAccount(userId, now);
const aligned = alignAccountToCurrentCycle(existing, now);
stores.accounts[userId] = aligned;
return aligned;
};
const getAvailableCredits = (account: MockAccountRecord): number => {
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
return monthlyRemaining + Math.max(0, account.topupBalance);
};
const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
return {
entitlement: {
plan: account.plan,
provider: account.provider,
status: 'active',
renewsAt: account.renewsAt,
},
credits: {
monthlyAllowance: account.monthlyAllowance,
usedThisCycle: account.usedThisCycle,
topupBalance: account.topupBalance,
available: getAvailableCredits(account),
cycleStartedAt: account.cycleStartedAt,
cycleEndsAt: account.cycleEndsAt,
},
availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'],
};
};
const readIdempotentResponse = <T,>(store: IdempotencyStore, key: string): T | null => {
const record = store[key];
if (!record) return null;
return record.response as T;
};
const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value: T): void => {
store[key] = {
response: value,
createdAt: nowIso(),
};
};
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
if (cost <= 0) return 0;
const available = getAvailableCredits(account);
if (available < cost) {
throw new BackendApiError(
'INSUFFICIENT_CREDITS',
`Insufficient credits. Required ${cost}, available ${available}.`,
402,
{ required: cost, available },
);
}
let remaining = cost;
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
if (monthlyRemaining > 0) {
const monthlyUsage = Math.min(monthlyRemaining, remaining);
account.usedThisCycle += monthlyUsage;
remaining -= monthlyUsage;
}
if (remaining > 0 && account.topupBalance > 0) {
const topupUsage = Math.min(account.topupBalance, remaining);
account.topupBalance -= topupUsage;
remaining -= topupUsage;
}
return cost;
};
const consumeCreditsWithIdempotency = (
account: MockAccountRecord,
idempotencyStore: IdempotencyStore,
key: string,
cost: number,
): number => {
const existing = readIdempotentResponse<{ charged: number }>(idempotencyStore, key);
if (existing) return existing.charged;
const charged = consumeCredits(account, cost);
writeIdempotentResponse(idempotencyStore, key, { charged });
return charged;
};
const endpointKey = (scope: string, userId: string, idempotencyKey: string): string => {
return `endpoint:${scope}:${userId}:${idempotencyKey}`;
};
const chargeKey = (scope: string, userId: string, idempotencyKey: string): string => {
return `charge:${scope}:${userId}:${idempotencyKey}`;
};
const hashString = (value: string): number => {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
}
return Math.abs(hash);
};
const clampConfidence = (value: number): number => {
return Math.max(0.05, Math.min(0.99, Number(value.toFixed(2))));
};
const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: number): PlantHealthCheck => {
const source = [
request.imageUri,
request.plantContext?.name || '',
request.plantContext?.botanicalName || '',
request.plantContext?.careInfo?.light || '',
request.plantContext?.careInfo?.temp || '',
].join('|');
const hash = hashString(source);
const score = 40 + (hash % 56);
const status: PlantHealthCheck['status'] = score >= 75
? 'healthy'
: score >= 55
? 'watch'
: 'critical';
const confidenceBase = 0.45 + ((hash % 20) / 100);
const confidenceMid = 0.35 + (((hash >> 2) % 20) / 100);
const confidenceLow = 0.25 + (((hash >> 4) % 20) / 100);
if (request.language === 'de') {
const likelyIssues = status === 'critical'
? [
{
title: 'Moegliche Ueberwaesserung',
confidence: clampConfidence(confidenceBase + 0.22),
details: 'Gelbe, weiche Blaetter koennen auf zu nasse Erde hindeuten.',
},
{
title: 'Wurzelstress',
confidence: clampConfidence(confidenceMid + 0.15),
details: 'Pruefe, ob das Substrat verdichtet ist oder unangenehm riecht.',
},
{
title: 'Lichtmangel',
confidence: clampConfidence(confidenceLow + 0.1),
details: 'Zu wenig Licht kann zu Vergilbung und schwaecherem Wuchs fuehren.',
},
]
: status === 'watch'
? [
{
title: 'Leichter Naehrstoffmangel',
confidence: clampConfidence(confidenceBase),
details: 'Ein Teil der Vergilbung kann durch fehlende Naehrstoffe entstehen.',
},
{
title: 'Unregelmaessiges Giessen',
confidence: clampConfidence(confidenceMid),
details: 'Zu grosse Schwankungen zwischen trocken und sehr nass belasten die Pflanze.',
},
{
title: 'Standortstress',
confidence: clampConfidence(confidenceLow),
details: 'Zugluft oder haeufige Standortwechsel koennen Blattreaktionen ausloesen.',
},
]
: [
{
title: 'Leichte Lichtanpassung noetig',
confidence: clampConfidence(confidenceBase - 0.1),
details: 'Einige Blaetter zeigen milde Stressanzeichen, insgesamt wirkt die Pflanze stabil.',
},
{
title: 'Naechsten Duengertermin beobachten',
confidence: clampConfidence(confidenceMid - 0.1),
details: 'Bei weiterem Vergilben Duengung in kleiner Dosis einplanen.',
},
{
title: 'Normale Blattalterung',
confidence: clampConfidence(confidenceLow - 0.05),
details: 'Aeltere untere Blaetter duerfen gelegentlich gelb werden.',
},
];
const actionsNow = status === 'critical'
? [
'Giessen fuer 3-5 Tage pausieren und Feuchtigkeit tief im Topf pruefen.',
'Gelbe oder matschige Blaetter sauber entfernen.',
'Topf auf gute Drainage pruefen; stehendes Wasser sofort entfernen.',
]
: status === 'watch'
? [
'Giessrhythmus fuer die naechsten 7 Tage konsistent halten.',
'Pflanze heller, aber ohne harte Mittagssonne stellen.',
'Auf Schaedlinge an Blattunterseiten kontrollieren.',
]
: [
'Aktuellen Pflegeplan beibehalten.',
'Nur bei deutlich trockener Erde giessen.',
'Gelbe Altblaetter nach Bedarf entfernen.',
];
const plan7Days = status === 'critical'
? [
'Tag 1: Feuchtigkeit messen und Uebertopf entleeren.',
'Tag 3: Blattfarbe und Festigkeit erneut pruefen.',
'Tag 5: Bei nasser Erde Umtopfen mit luftiger Mischung erwaegen.',
'Tag 7: Neuen Foto-Health-Check ausfuehren.',
]
: [
'Tag 1: Standort und Lichtdauer notieren.',
'Tag 3: Leichte Drehung fuer gleichmaessigen Wuchs.',
'Tag 5: Bodenfeuchte vor Giessen kontrollieren.',
'Tag 7: Vergleichsfoto erstellen.',
];
return {
generatedAt: nowIso(),
overallHealthScore: score,
status,
likelyIssues,
actionsNow,
plan7Days,
creditsCharged,
imageUri: request.imageUri,
};
}
if (request.language === 'es') {
const likelyIssues = status === 'critical'
? [
{
title: 'Posible exceso de riego',
confidence: clampConfidence(confidenceBase + 0.22),
details: 'Hojas amarillas y blandas pueden indicar demasiada humedad.',
},
{
title: 'Estres de raiz',
confidence: clampConfidence(confidenceMid + 0.15),
details: 'Revisa si el sustrato esta compacto o con mal olor.',
},
{
title: 'Falta de luz',
confidence: clampConfidence(confidenceLow + 0.1),
details: 'La luz insuficiente puede causar amarilleo y crecimiento lento.',
},
]
: status === 'watch'
? [
{
title: 'Deficit leve de nutrientes',
confidence: clampConfidence(confidenceBase),
details: 'Parte del amarilleo puede venir de nutricion insuficiente.',
},
{
title: 'Riego irregular',
confidence: clampConfidence(confidenceMid),
details: 'Cambios bruscos entre seco y muy humedo estresan la planta.',
},
{
title: 'Estres de ubicacion',
confidence: clampConfidence(confidenceLow),
details: 'Corrientes de aire o cambios frecuentes pueden afectar las hojas.',
},
]
: [
{
title: 'Ajuste suave de luz',
confidence: clampConfidence(confidenceBase - 0.1),
details: 'Se observan signos leves, pero el estado general es bueno.',
},
{
title: 'Monitorear fertilizacion',
confidence: clampConfidence(confidenceMid - 0.1),
details: 'Si continua el amarilleo, aplicar dosis pequena de fertilizante.',
},
{
title: 'Envejecimiento normal',
confidence: clampConfidence(confidenceLow - 0.05),
details: 'Hojas inferiores viejas pueden amarillear de forma natural.',
},
];
const actionsNow = status === 'critical'
? [
'Pausar riego 3-5 dias y comprobar humedad profunda.',
'Retirar hojas amarillas o blandas con herramienta limpia.',
'Verificar drenaje y eliminar agua acumulada.',
]
: status === 'watch'
? [
'Mantener riego estable durante 7 dias.',
'Mover a una zona mas luminosa sin sol fuerte directo.',
'Revisar plagas en el reverso de las hojas.',
]
: [
'Mantener rutina actual de cuidado.',
'Regar solo cuando el sustrato este claramente seco.',
'Retirar hojas amarillas antiguas si hace falta.',
];
const plan7Days = status === 'critical'
? [
'Dia 1: Medir humedad y vaciar agua retenida.',
'Dia 3: Revisar color y firmeza de hojas.',
'Dia 5: Si sigue muy humedo, considerar trasplante con mezcla aireada.',
'Dia 7: Repetir health-check con foto nueva.',
]
: [
'Dia 1: Registrar ubicacion y horas de luz.',
'Dia 3: Girar planta ligeramente para crecimiento uniforme.',
'Dia 5: Comprobar humedad antes de regar.',
'Dia 7: Tomar foto de comparacion.',
];
return {
generatedAt: nowIso(),
overallHealthScore: score,
status,
likelyIssues,
actionsNow,
plan7Days,
creditsCharged,
imageUri: request.imageUri,
};
}
const likelyIssues = status === 'critical'
? [
{
title: 'Possible overwatering',
confidence: clampConfidence(confidenceBase + 0.22),
details: 'Yellow and soft leaves can indicate excess moisture.',
},
{
title: 'Root stress',
confidence: clampConfidence(confidenceMid + 0.15),
details: 'Check if the substrate is compacted or has a sour smell.',
},
{
title: 'Low light stress',
confidence: clampConfidence(confidenceLow + 0.1),
details: 'Insufficient light can trigger yellowing and slower growth.',
},
]
: status === 'watch'
? [
{
title: 'Mild nutrient deficiency',
confidence: clampConfidence(confidenceBase),
details: 'Part of the yellowing may come from missing nutrients.',
},
{
title: 'Inconsistent watering',
confidence: clampConfidence(confidenceMid),
details: 'Large swings between dry and wet can stress foliage.',
},
{
title: 'Placement stress',
confidence: clampConfidence(confidenceLow),
details: 'Drafts or frequent location changes can affect leaves.',
},
]
: [
{
title: 'Minor light adjustment',
confidence: clampConfidence(confidenceBase - 0.1),
details: 'Mild stress signs are present, but overall condition looks stable.',
},
{
title: 'Monitor next feeding',
confidence: clampConfidence(confidenceMid - 0.1),
details: 'If yellowing continues, apply a light fertilizer dose.',
},
{
title: 'Normal leaf aging',
confidence: clampConfidence(confidenceLow - 0.05),
details: 'Older lower leaves can yellow naturally over time.',
},
];
const actionsNow = status === 'critical'
? [
'Pause watering for 3-5 days and check deep soil moisture.',
'Remove yellow or mushy leaves with clean tools.',
'Ensure good drainage and empty standing water.',
]
: status === 'watch'
? [
'Keep watering schedule stable for 7 days.',
'Move to brighter indirect light.',
'Inspect leaf undersides for pests.',
]
: [
'Keep the current care routine.',
'Water only when soil is clearly dry.',
'Trim older yellow leaves if needed.',
];
const plan7Days = status === 'critical'
? [
'Day 1: Check moisture and remove excess water.',
'Day 3: Re-check leaf color and firmness.',
'Day 5: If still soggy, repot into an airy mix.',
'Day 7: Run another health-check photo.',
]
: [
'Day 1: Note light duration and placement.',
'Day 3: Rotate plant slightly for even growth.',
'Day 5: Check soil moisture before watering.',
'Day 7: Take a comparison photo.',
];
return {
generatedAt: nowIso(),
overallHealthScore: score,
status,
likelyIssues,
actionsNow,
plan7Days,
creditsCharged,
imageUri: request.imageUri,
};
};
export const mockBackendService = {
getBillingSummary: async (userId: string): Promise<BillingSummary> => {
return withUserLock(userId, async () => {
const stores = await loadStores();
const account = getOrCreateAccount(stores, userId);
account.updatedAt = nowIso();
await persistStores(stores);
return buildBillingSummary(account);
});
},
scanPlant: async (request: ScanPlantRequest): Promise<ScanPlantResponse> => {
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId);
const idemEndpointKey = endpointKey('scan', request.userId, request.idempotencyKey);
const cachedResponse = readIdempotentResponse<ScanPlantResponse>(stores.idempotency, idemEndpointKey);
if (cachedResponse) {
return {
response: cachedResponse,
simulatedDelayMs: getSimulatedDelay(account.plan),
};
}
let creditsCharged = 0;
const modelPath: string[] = [];
creditsCharged += consumeCreditsWithIdempotency(
account,
stores.idempotency,
chargeKey('scan-primary', request.userId, request.idempotencyKey),
SCAN_PRIMARY_COST,
);
let usedOpenAi = false;
let result: IdentificationResult = getMockPlantByImage(request.imageUri, request.language, false);
if (openAiScanService.isConfigured()) {
const openAiPrimary = await openAiScanService.identifyPlant(
request.imageUri,
request.language,
'primary',
account.plan === 'pro' ? 'pro' : 'free',
);
if (openAiPrimary) {
result = openAiPrimary;
usedOpenAi = true;
modelPath.push('openai-primary');
} else {
result = getMockPlantByImage(request.imageUri, request.language, false);
modelPath.push('openai-primary-failed');
modelPath.push('mock-primary-fallback');
}
} else {
modelPath.push('mock-primary');
}
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
if (shouldReview && account.plan === 'pro') {
try {
creditsCharged += consumeCreditsWithIdempotency(
account,
stores.idempotency,
chargeKey('scan-review', request.userId, request.idempotencyKey),
SCAN_REVIEW_COST,
);
if (usedOpenAi) {
const openAiReview = await openAiScanService.identifyPlant(
request.imageUri,
request.language,
'review',
account.plan === 'pro' ? 'pro' : 'free',
);
if (openAiReview) {
result = openAiReview;
modelPath.push('openai-review');
} else {
modelPath.push('openai-review-failed');
}
} else {
result = getMockPlantByImage(request.imageUri, request.language, true);
modelPath.push('mock-review');
}
} catch (error) {
if (isBackendApiError(error) && error.code === 'INSUFFICIENT_CREDITS') {
modelPath.push('review-skipped-insufficient-credits');
} else {
throw error;
}
}
} else if (shouldReview) {
modelPath.push('review-skipped-free-plan');
}
account.updatedAt = nowIso();
const response: ScanPlantResponse = {
result,
creditsCharged,
modelPath,
billing: buildBillingSummary(account),
};
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
await persistStores(stores);
return {
response,
simulatedDelayMs: getSimulatedDelay(account.plan),
};
});
await sleep(simulatedDelayMs);
return response;
},
semanticSearch: async (request: SemanticSearchRequest): Promise<SemanticSearchResponse> => {
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId);
const idemEndpointKey = endpointKey('semantic-search', request.userId, request.idempotencyKey);
const cachedResponse = readIdempotentResponse<SemanticSearchResponse>(stores.idempotency, idemEndpointKey);
if (cachedResponse) {
return {
response: cachedResponse,
simulatedDelayMs: getSimulatedDelay(account.plan),
};
}
const normalizedQuery = request.query.trim();
if (!normalizedQuery) {
const noResultResponse: SemanticSearchResponse = {
status: 'no_results',
results: [],
creditsCharged: 0,
billing: buildBillingSummary(account),
};
writeIdempotentResponse(stores.idempotency, idemEndpointKey, noResultResponse);
await persistStores(stores);
return {
response: noResultResponse,
simulatedDelayMs: getSimulatedDelay(account.plan),
};
}
const creditsCharged = consumeCreditsWithIdempotency(
account,
stores.idempotency,
chargeKey('semantic-search', request.userId, request.idempotencyKey),
SEMANTIC_SEARCH_COST,
);
const results = searchMockCatalog(request.query, request.language, 18);
account.updatedAt = nowIso();
const response: SemanticSearchResponse = {
status: results.length > 0 ? 'success' : 'no_results',
results,
creditsCharged,
billing: buildBillingSummary(account),
};
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
await persistStores(stores);
return {
response,
simulatedDelayMs: getSimulatedDelay(account.plan),
};
});
await sleep(simulatedDelayMs);
return response;
},
healthCheck: async (request: HealthCheckRequest): Promise<HealthCheckResponse> => {
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId);
const idemEndpointKey = endpointKey('health-check', request.userId, request.idempotencyKey);
const cachedResponse = readIdempotentResponse<HealthCheckResponse>(stores.idempotency, idemEndpointKey);
if (cachedResponse) {
return {
response: cachedResponse,
simulatedDelayMs: getSimulatedDelay(account.plan),
};
}
const normalizedImageUri = request.imageUri.trim();
if (!normalizedImageUri) {
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
}
if (!openAiScanService.isConfigured()) {
throw new BackendApiError(
'PROVIDER_ERROR',
'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.',
502,
);
}
const aiAnalysis = await openAiScanService.analyzePlantHealth(
normalizedImageUri,
request.language,
request.plantContext,
);
if (!aiAnalysis) {
throw new BackendApiError(
'PROVIDER_ERROR',
'OpenAI health check failed. Please verify API key, network access, and image quality.',
502,
);
}
const creditsCharged = consumeCreditsWithIdempotency(
account,
stores.idempotency,
chargeKey('health-check', request.userId, request.idempotencyKey),
HEALTH_CHECK_COST,
);
const healthCheck: PlantHealthCheck = {
generatedAt: nowIso(),
overallHealthScore: aiAnalysis.overallHealthScore,
status: aiAnalysis.status,
likelyIssues: aiAnalysis.likelyIssues,
actionsNow: aiAnalysis.actionsNow,
plan7Days: aiAnalysis.plan7Days,
creditsCharged,
imageUri: normalizedImageUri,
};
account.updatedAt = nowIso();
const response: HealthCheckResponse = {
healthCheck,
creditsCharged,
billing: buildBillingSummary(account),
};
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
await persistStores(stores);
return {
response,
simulatedDelayMs: getSimulatedDelay(account.plan),
};
});
await sleep(simulatedDelayMs);
return response;
},
simulatePurchase: async (request: SimulatePurchaseRequest): Promise<SimulatePurchaseResponse> => {
return withUserLock(request.userId, async () => {
const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId);
const idemEndpointKey = endpointKey('simulate-purchase', request.userId, request.idempotencyKey);
const cachedResponse = readIdempotentResponse<SimulatePurchaseResponse>(stores.idempotency, idemEndpointKey);
if (cachedResponse) return cachedResponse;
if (request.productId === 'monthly_pro' || request.productId === 'yearly_pro') {
const now = new Date();
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.plan = 'pro';
account.provider = 'mock';
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
account.usedThisCycle = 0;
account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString();
account.renewsAt = addDays(now, 30).toISOString();
} else {
const credits = TOPUP_CREDITS_BY_PRODUCT[request.productId];
account.topupBalance += credits;
}
account.updatedAt = nowIso();
const response: SimulatePurchaseResponse = {
appliedProduct: request.productId,
billing: buildBillingSummary(account),
};
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
await persistStores(stores);
return response;
});
},
simulateWebhook: async (request: SimulateWebhookRequest): Promise<SimulateWebhookResponse> => {
return withUserLock(request.userId, async () => {
const stores = await loadStores();
const account = getOrCreateAccount(stores, request.userId);
const idemEndpointKey = endpointKey('simulate-webhook', request.userId, request.idempotencyKey);
const cachedResponse = readIdempotentResponse<SimulateWebhookResponse>(stores.idempotency, idemEndpointKey);
if (cachedResponse) return cachedResponse;
if (request.event === 'entitlement_granted') {
const now = new Date();
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.plan = 'pro';
account.provider = 'revenuecat';
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
account.usedThisCycle = 0;
account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString();
account.renewsAt = addDays(now, 30).toISOString();
}
if (request.event === 'entitlement_revoked') {
const now = new Date();
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.plan = 'free';
account.provider = 'revenuecat';
account.monthlyAllowance = FREE_MONTHLY_CREDITS;
account.usedThisCycle = 0;
account.cycleStartedAt = cycleStartedAt.toISOString();
account.cycleEndsAt = cycleEndsAt.toISOString();
account.renewsAt = null;
}
if (request.event === 'topup_granted') {
const credits = Math.max(1, request.payload?.credits || TOPUP_DEFAULT_CREDITS);
account.topupBalance += credits;
}
if (request.event === 'credits_depleted') {
account.usedThisCycle = account.monthlyAllowance;
account.topupBalance = 0;
}
account.updatedAt = nowIso();
const response: SimulateWebhookResponse = {
event: request.event,
billing: buildBillingSummary(account),
};
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
await persistStores(stores);
return response;
});
},
};

View File

@@ -0,0 +1,299 @@
import { Language } from '../../types';
import { BackendDatabaseEntry } from './contracts';
import { LEXICON_BATCH_1_ENTRIES } from '../../constants/lexiconBatch1';
import { LEXICON_BATCH_2_ENTRIES } from '../../constants/lexiconBatch2';
import { normalizeSearchText, rankHybridEntries } from '../../utils/hybridSearch';
interface MockPlantSeed {
botanicalName: string;
names: Record<Language, string>;
descriptions: Record<Language, string>;
imageUri: string;
categories: string[];
baseConfidence: number;
care: {
waterIntervalDays: number;
light: Record<Language, string>;
temp: string;
};
}
const PLANTS: MockPlantSeed[] = [
{
botanicalName: 'Monstera deliciosa',
names: { de: 'Monstera', en: 'Monstera', es: 'Monstera' },
descriptions: {
de: 'Beliebte Zimmerpflanze mit grossen gelochten Blaettern. Mag helles indirektes Licht.',
en: 'Popular indoor plant with large split leaves. Prefers bright indirect light.',
es: 'Planta de interior popular con hojas grandes. Prefiere luz brillante indirecta.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/500px-Monstera_deliciosa2.jpg',
categories: ['easy', 'low_light', 'air_purifier'],
baseConfidence: 0.76,
care: {
waterIntervalDays: 7,
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
temp: '18-24C',
},
},
{
botanicalName: 'Sansevieria trifasciata',
names: { de: 'Bogenhanf', en: 'Snake Plant', es: 'Lengua de suegra' },
descriptions: {
de: 'Sehr robust und trockenheitsvertraeglich. Kommt auch mit wenig Licht klar.',
en: 'Very resilient and drought tolerant. Handles low light well.',
es: 'Muy resistente y tolera sequia. Funciona bien con poca luz.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/500px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg',
categories: ['easy', 'succulent', 'low_light'],
baseConfidence: 0.73,
care: {
waterIntervalDays: 14,
light: { de: 'Schatten bis Sonne', en: 'Shade to sun', es: 'Sombra a sol' },
temp: '16-30C',
},
},
{
botanicalName: 'Aloe vera',
names: { de: 'Aloe Vera', en: 'Aloe Vera', es: 'Aloe Vera' },
descriptions: {
de: 'Sukkulente mit heilender Gelstruktur in den Blaettern. Braucht viel Licht.',
en: 'Succulent with gel-filled leaves. Needs plenty of light.',
es: 'Suculenta con hojas de gel. Necesita bastante luz.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Aloe_vera_flower_inset.png/500px-Aloe_vera_flower_inset.png',
categories: ['succulent', 'easy', 'sun'],
baseConfidence: 0.78,
care: {
waterIntervalDays: 12,
light: { de: 'Sonnig', en: 'Sunny', es: 'Soleado' },
temp: '18-30C',
},
},
{
botanicalName: 'Epipremnum aureum',
names: { de: 'Efeutute', en: 'Pothos', es: 'Poto' },
descriptions: {
de: 'Schnell wachsende Haengepflanze fuer Einsteiger. Vertraegt variierende Bedingungen.',
en: 'Fast-growing trailing plant ideal for beginners. Handles varied conditions.',
es: 'Planta colgante de crecimiento rapido para principiantes.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/62/Money_Plant_%28Epipremnum_aureum%29_4.jpg/500px-Money_Plant_%28Epipremnum_aureum%29_4.jpg',
categories: ['easy', 'hanging', 'air_purifier'],
baseConfidence: 0.74,
care: {
waterIntervalDays: 7,
light: { de: 'Halbschatten bis hell', en: 'Partial shade to bright', es: 'Sombra parcial a brillante' },
temp: '18-27C',
},
},
{
botanicalName: 'Spathiphyllum',
names: { de: 'Einblatt', en: 'Peace Lily', es: 'Cuna de moises' },
descriptions: {
de: 'Bluetenpflanze fuer Innenraeume. Zeigt Wasserbedarf schnell durch haengende Blaetter.',
en: 'Indoor flowering plant. Droops clearly when it needs water.',
es: 'Planta de flor de interior. Muestra rapido cuando necesita riego.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/Spathiphyllum_cochlearispathum_RTBG.jpg/500px-Spathiphyllum_cochlearispathum_RTBG.jpg',
categories: ['flowering', 'low_light'],
baseConfidence: 0.72,
care: {
waterIntervalDays: 5,
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
temp: '18-26C',
},
},
{
botanicalName: 'Zamioculcas zamiifolia',
names: { de: 'Gluecksfeder', en: 'ZZ Plant', es: 'Planta ZZ' },
descriptions: {
de: 'Extrem pflegeleichte Pflanze mit dickem Rhizom. Perfekt fuer wenig Zeit.',
en: 'Extremely low-maintenance plant with thick rhizomes. Great for busy schedules.',
es: 'Planta muy facil de cuidar con rizomas gruesos.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/cf/Zamioculcas_zamiifolia_1.jpg/500px-Zamioculcas_zamiifolia_1.jpg',
categories: ['easy', 'low_light'],
baseConfidence: 0.77,
care: {
waterIntervalDays: 14,
light: { de: 'Wenig Licht bis hell', en: 'Low light to bright', es: 'Poca luz a brillante' },
temp: '18-28C',
},
},
{
botanicalName: 'Calathea orbifolia',
names: { de: 'Korbmarante', en: 'Calathea', es: 'Calathea' },
descriptions: {
de: 'Dekorative Blaetter mit Muster. Liebt gleichmaessige Feuchte und hohe Luftfeuchtigkeit.',
en: 'Decorative patterned leaves. Prefers steady moisture and humidity.',
es: 'Hojas decorativas con patron. Prefiere humedad constante.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Calathea_orbifolia_2.jpg/500px-Calathea_orbifolia_2.jpg',
categories: ['patterned', 'pet_friendly'],
baseConfidence: 0.68,
care: {
waterIntervalDays: 4,
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
temp: '18-25C',
},
},
{
botanicalName: 'Chlorophytum comosum',
names: { de: 'Gruenlilie', en: 'Spider Plant', es: 'Cinta' },
descriptions: {
de: 'Unkomplizierte Zimmerpflanze mit langen gebogenen Blaettern. Vermehrt sich schnell.',
en: 'Easy houseplant with long arched leaves. Propagates quickly.',
es: 'Planta sencilla con hojas largas arqueadas. Se multiplica rapido.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/b/b1/Hierbabuena_0611_Revised.jpg/500px-Hierbabuena_0611_Revised.jpg',
categories: ['easy', 'pet_friendly', 'air_purifier'],
baseConfidence: 0.79,
care: {
waterIntervalDays: 6,
light: { de: 'Hell bis halbschattig', en: 'Bright to partial shade', es: 'Brillante a sombra parcial' },
temp: '16-24C',
},
},
{
botanicalName: 'Anthurium andraeanum',
names: { de: 'Flamingoblume', en: 'Anthurium', es: 'Anturio' },
descriptions: {
de: 'Auffaellige Hochblaetter und lange Bluetezeit. Braucht Waerme und gleichmaessige Feuchte.',
en: 'Known for vivid spathes and long bloom cycles. Likes warmth and even moisture.',
es: 'Conocida por sus flores vistosas. Requiere calor y riego regular.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8b/AnthuriumAndraenum.jpg/500px-AnthuriumAndraenum.jpg',
categories: ['flowering', 'high_humidity'],
baseConfidence: 0.71,
care: {
waterIntervalDays: 6,
light: { de: 'Hell ohne direkte Sonne', en: 'Bright indirect light', es: 'Luz brillante indirecta' },
temp: '18-27C',
},
},
{
botanicalName: 'Nephrolepis exaltata',
names: { de: 'Schwertfarn', en: 'Boston Fern', es: 'Helecho de Boston' },
descriptions: {
de: 'Fein gefiederter Farn. Benoetigt regelmaessige Feuchte und keine direkte Sonne.',
en: 'Fine-textured fern. Needs regular moisture and no harsh direct sun.',
es: 'Helecho de textura fina. Necesita humedad constante.',
},
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Boston_Fern_%282873392811%29.png/500px-Boston_Fern_%282873392811%29.png',
categories: ['pet_friendly', 'air_purifier', 'high_humidity'],
baseConfidence: 0.7,
care: {
waterIntervalDays: 3,
light: { de: 'Halbschatten', en: 'Partial shade', es: 'Sombra parcial' },
temp: '16-24C',
},
},
];
const hashString = (value: string): number => {
let hash = 2166136261;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const clampConfidence = (value: number): number => {
return Number(Math.max(0.4, Math.min(0.99, value)).toFixed(2));
};
const toEntry = (
seed: MockPlantSeed,
lang: Language,
confidence: number,
): BackendDatabaseEntry => {
return {
name: seed.names[lang],
botanicalName: seed.botanicalName,
description: seed.descriptions[lang],
confidence: clampConfidence(confidence),
careInfo: {
waterIntervalDays: seed.care.waterIntervalDays,
light: seed.care.light[lang],
temp: seed.care.temp,
},
imageUri: seed.imageUri,
categories: seed.categories,
};
};
const getFullCatalog = (lang: Language): BackendDatabaseEntry[] => {
const seeds = PLANTS.map(seed => toEntry(seed, lang, seed.baseConfidence));
const batches = getBatchCatalog(lang);
const catalog = [...seeds, ...batches];
const seenBotanical = new Set<string>();
const seenImage = new Set<string>();
return catalog.filter(e => {
const botanicalKey = normalizeSearchText(e.botanicalName);
const imageKey = e.imageUri.trim();
if (seenBotanical.has(botanicalKey) || seenImage.has(imageKey)) {
return false;
}
seenBotanical.add(botanicalKey);
seenImage.add(imageKey);
return true;
});
};
export const getMockCatalog = (lang: Language): BackendDatabaseEntry[] => {
return getFullCatalog(lang);
};
export const getMockPlantByImage = (
imageUri: string,
lang: Language,
preferHighConfidence: boolean,
): BackendDatabaseEntry => {
const seeds = PLANTS;
const indexHash = hashString(`${lang}:${imageUri}`);
const selectedSeed = seeds[indexHash % seeds.length];
const varianceHash = hashString(`confidence:${imageUri}:${selectedSeed.botanicalName}`);
const variance = (varianceHash % 28) / 100; // 0.00 - 0.27
const confidenceBase = selectedSeed.baseConfidence - 0.08 + variance;
const confidence = preferHighConfidence ? confidenceBase + 0.09 : confidenceBase;
return toEntry(selectedSeed, lang, confidence);
};
const getBatchCatalog = (lang: Language): BackendDatabaseEntry[] => {
const all = [...LEXICON_BATCH_1_ENTRIES, ...LEXICON_BATCH_2_ENTRIES];
return all.map(entry => ({
name: entry.name,
botanicalName: entry.botanicalName,
description: entry.description || '',
confidence: entry.confidence,
careInfo: {
waterIntervalDays: entry.careInfo.waterIntervalDays,
light: entry.careInfo.light,
temp: entry.careInfo.temp,
},
imageUri: entry.imageUri,
categories: entry.categories,
}));
};
export const searchMockCatalog = (
query: string,
lang: Language,
limit = 12,
): BackendDatabaseEntry[] => {
const normalizedQuery = normalizeSearchText(query);
const deduped = getFullCatalog(lang);
if (!normalizedQuery) return deduped.slice(0, limit);
return rankHybridEntries(deduped, normalizedQuery, limit)
.map((candidate) => candidate.entry);
};

View File

@@ -0,0 +1,564 @@
import { CareInfo, IdentificationResult, Language } from '../../types';
type OpenAiScanMode = 'primary' | 'review';
export interface OpenAiHealthIssue {
title: string;
confidence: number;
details: string;
}
export interface OpenAiHealthAnalysis {
overallHealthScore: number;
status: 'healthy' | 'watch' | 'critical';
likelyIssues: OpenAiHealthIssue[];
actionsNow: string[];
plan7Days: string[];
}
const OPENAI_API_KEY = (process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
const OPENAI_SCAN_MODEL = (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim();
const OPENAI_SCAN_MODEL_PRO = (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim();
const OPENAI_HEALTH_MODEL = (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4.1-mini').trim();
const OPENAI_SCAN_FALLBACK_MODELS_PRO = (process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_CHAT_COMPLETIONS_URL = 'https://api.openai.com/v1/chat/completions';
const OPENAI_TIMEOUT_MS = (() => {
const raw = (process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed >= 10000) return parsed;
return 45000;
})();
const parseModelChain = (primaryModel: string, fallbackModels: string): string[] => {
const models = [primaryModel];
fallbackModels.split(',').forEach((model) => {
const normalized = model.trim();
if (normalized) models.push(normalized);
});
return [...new Set(models)];
};
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
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: 'free' | 'pro'): string[] => {
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
};
const clamp = (value: number, min: number, max: number): number => {
return Math.min(max, Math.max(min, value));
};
const toErrorMessage = (error: unknown): string => {
if (error instanceof Error) return error.message;
return String(error);
};
const summarizeImageUri = (imageUri: string): string => {
const trimmed = imageUri.trim();
if (!trimmed) return 'empty';
if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`;
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed;
};
const toJsonString = (content: string): string => {
const trimmed = content.trim();
if (!trimmed) return trimmed;
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fenced?.[1]) return fenced[1].trim();
return trimmed;
};
const parseContentToJson = (content: string): Record<string, unknown> | null => {
try {
const parsed = JSON.parse(toJsonString(content));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return null;
} catch {
return null;
}
};
const getString = (value: unknown): string => {
return typeof value === 'string' ? value.trim() : '';
};
const getNumber = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
};
const getStringArray = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
};
const normalizeResult = (
raw: Record<string, unknown>,
language: Language,
): IdentificationResult | null => {
const name = getString(raw.name);
const botanicalName = getString(raw.botanicalName);
const description = getString(raw.description);
const confidenceRaw = getNumber(raw.confidence);
const careInfoRaw = raw.careInfo;
if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) {
return null;
}
const careInfoObj = careInfoRaw as Record<string, unknown>;
const waterIntervalRaw = getNumber(careInfoObj.waterIntervalDays);
const light = getString(careInfoObj.light);
const temp = getString(careInfoObj.temp);
if (waterIntervalRaw == null || !light || !temp) {
return null;
}
const fallbackDescription = language === 'de'
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
: language === 'es'
? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.`
: `${name} was identified with AI. Care guidance is shown below.`;
return {
name,
botanicalName,
confidence: clamp(confidenceRaw ?? 0.72, 0.05, 0.99),
description: description || fallbackDescription,
careInfo: {
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
light,
temp,
},
};
};
const getLanguageLabel = (language: Language): string => {
if (language === 'de') return 'German';
if (language === 'es') return 'Spanish';
return 'English';
};
const buildPrompt = (language: Language, mode: OpenAiScanMode): string => {
const reviewInstruction = mode === 'review'
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
: 'Identify the most likely houseplant species from this image with conservative confidence.';
const nameLanguageInstruction = language === 'en'
? '- "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:`,
nameLanguageInstruction,
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
`- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.`,
`- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").`,
`- "confidence" must be between 0 and 1.`,
`- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.`,
`- "waterIntervalDays" must be an integer between 1 and 45.`,
`- Do not include markdown, explanations, or extra keys.`,
].join('\n');
};
const buildHealthPrompt = (
language: Language,
plantContext?: {
name: string;
botanicalName: string;
careInfo: CareInfo;
description?: string;
},
): string => {
const contextLines = plantContext
? [
`Plant context:`,
`- name: ${plantContext.name}`,
`- botanicalName: ${plantContext.botanicalName}`,
`- care.light: ${plantContext.careInfo.light}`,
`- care.temp: ${plantContext.careInfo.temp}`,
`- care.waterIntervalDays: ${plantContext.careInfo.waterIntervalDays}`,
`- description: ${plantContext.description || 'n/a'}`,
]
: ['Plant context: not provided'];
return [
`Analyze this plant photo for real health condition signs with focus on yellowing leaves, watering stress, pests, and light stress.`,
`Return strict JSON only in this shape:`,
`{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}`,
`Rules:`,
`- "overallHealthScore" must be an integer between 0 and 100.`,
`- "status" must be one of: "healthy", "watch", "critical".`,
`- "likelyIssues" must contain 1 to 4 items sorted by confidence descending.`,
`- "confidence" must be between 0 and 1.`,
`- "title", "details", "actionsNow", and "plan7Days" must be written in ${getLanguageLabel(language)}.`,
`- "actionsNow" should be immediate steps for the next 24 hours.`,
`- "plan7Days" should be short actionable steps for the next week.`,
`- Do not include markdown, explanations, or extra keys.`,
...contextLines,
].join('\n');
};
const buildFallbackHealthAnalysis = (
language: Language,
plantContext?: {
name: string;
botanicalName: string;
careInfo: CareInfo;
description?: string;
},
): OpenAiHealthAnalysis => {
if (language === 'de') {
return {
overallHealthScore: 58,
status: 'watch',
likelyIssues: [
{
title: 'Eingeschraenkte KI-Analyse',
confidence: 0.42,
details: `${plantContext?.name || 'Die Pflanze'} konnte wegen instabiler Antwort nicht vollstaendig bewertet werden.`,
},
],
actionsNow: [
'Neues Foto bei hellem, indirektem Licht aufnehmen.',
'Blaetter auf Flecken, Schaedlinge und trockene Raender pruefen.',
'Erst giessen, wenn die oberen 2-3 cm Erde trocken sind.',
],
plan7Days: [
'In 2 Tagen mit neuem Foto erneut pruefen.',
'Farbe und Blattspannung taeglich beobachten.',
'Bei Verschlechterung Standort und Giessrhythmus anpassen.',
],
};
}
if (language === 'es') {
return {
overallHealthScore: 58,
status: 'watch',
likelyIssues: [
{
title: 'Analisis de IA limitado',
confidence: 0.42,
details: `${plantContext?.name || 'La planta'} no pudo evaluarse por completo por una respuesta inestable.`,
},
],
actionsNow: [
'Tomar una foto nueva con luz brillante e indirecta.',
'Revisar hojas por manchas, plagas y bordes secos.',
'Regar solo si los 2-3 cm superiores del sustrato estan secos.',
],
plan7Days: [
'Revisar otra vez en 2 dias con una foto nueva.',
'Observar color y firmeza de hojas cada dia.',
'Si empeora, ajustar ubicacion y frecuencia de riego.',
],
};
}
return {
overallHealthScore: 58,
status: 'watch',
likelyIssues: [
{
title: 'Limited AI analysis',
confidence: 0.42,
details: `${plantContext?.name || 'This plant'} could not be fully assessed due to an unstable provider response.`,
},
],
actionsNow: [
'Capture a new photo in bright indirect light.',
'Inspect leaves for spots, pests, and dry edges.',
'Water only if the top 2-3 cm of soil is dry.',
],
plan7Days: [
'Re-check in 2 days with a new photo.',
'Track leaf color and firmness daily.',
'If symptoms worsen, adjust placement and watering cadence.',
],
};
};
const normalizeHealthAnalysis = (
raw: Record<string, unknown>,
language: Language,
): OpenAiHealthAnalysis | null => {
const scoreRaw = getNumber(raw.overallHealthScore);
const statusRaw = getString(raw.status);
const issuesRaw = raw.likelyIssues;
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 6);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 7);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
return null;
}
const status: OpenAiHealthAnalysis['status'] = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
? statusRaw
: 'watch';
const likelyIssues = issuesRaw
.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const issueObj = entry as Record<string, unknown>;
const title = getString(issueObj.title);
const details = getString(issueObj.details);
const confidenceRaw = getNumber(issueObj.confidence);
if (!title || !details || confidenceRaw == null) return null;
return {
title,
details,
confidence: clamp(confidenceRaw, 0.05, 0.99),
} as OpenAiHealthIssue;
})
.filter((entry): entry is OpenAiHealthIssue => Boolean(entry))
.slice(0, 4);
if (likelyIssues.length === 0 || actionsNowRaw.length === 0 || plan7DaysRaw.length === 0) {
const fallbackIssue = language === 'de'
? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.'
: language === 'es'
? 'La IA no pudo extraer senales de salud estables.'
: 'AI could not extract stable health signals.';
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status,
likelyIssues: [
{
title: language === 'de'
? 'Analyse unsicher'
: language === 'es'
? 'Analisis incierto'
: 'Uncertain analysis',
confidence: 0.35,
details: fallbackIssue,
},
],
actionsNow: actionsNowRaw.length > 0
? actionsNowRaw
: [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'],
plan7Days: plan7DaysRaw.length > 0
? plan7DaysRaw
: [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'],
};
}
return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
status,
likelyIssues,
actionsNow: actionsNowRaw,
plan7Days: plan7DaysRaw,
};
};
const extractMessageContent = (payload: unknown): string => {
const response = payload as {
choices?: Array<{
message?: {
content?: string | Array<{ type?: string; text?: string }>;
};
}>;
};
const content = response.choices?.[0]?.message?.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.map((chunk) => (chunk?.type === 'text' ? chunk.text || '' : ''))
.join('')
.trim();
}
return '';
};
const postChatCompletion = async (
modelChain: string[],
imageUri: string,
messages: Array<Record<string, unknown>>,
): Promise<{ payload: Record<string, unknown> | null; modelUsed: string | null; attemptedModels: string[] }> => {
const attemptedModels: string[] = [];
for (const model of modelChain) {
attemptedModels.push(model);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
try {
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model,
response_format: { type: 'json_object' },
messages,
}),
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text();
console.warn('OpenAI request HTTP error.', {
status: response.status,
model,
image: summarizeImageUri(imageUri),
bodyPreview: body.slice(0, 300),
});
continue;
}
const payload = (await response.json()) as Record<string, unknown>;
return { payload, modelUsed: model, attemptedModels };
} catch (error) {
const isTimeoutAbort = error instanceof Error && error.name === 'AbortError';
console.warn('OpenAI request failed.', {
model,
timeoutMs: OPENAI_TIMEOUT_MS,
aborted: isTimeoutAbort,
error: toErrorMessage(error),
image: summarizeImageUri(imageUri),
});
continue;
} finally {
clearTimeout(timeout);
}
}
return { payload: null, modelUsed: null, attemptedModels };
};
export const openAiScanService = {
isConfigured: (): boolean => Boolean(OPENAI_API_KEY),
identifyPlant: async (
imageUri: string,
language: Language,
mode: OpenAiScanMode = 'primary',
plan: 'free' | 'pro' = 'free',
): Promise<IdentificationResult | null> => {
if (!OPENAI_API_KEY) return null;
const modelChain = getScanModelChain(plan);
const completion = await postChatCompletion(
modelChain,
imageUri,
[
{
role: 'system',
content: 'You are a plant identification assistant. Return strict JSON only.',
},
{
role: 'user',
content: [
{ type: 'text', text: buildPrompt(language, mode) },
{ type: 'image_url', image_url: { url: imageUri } },
],
},
],
);
if (!completion.payload) return null;
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI plant scan returned empty message content.', {
model: completion.modelUsed || modelChain[0],
mode,
image: summarizeImageUri(imageUri),
});
return null;
}
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI plant scan returned non-JSON content.', {
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
return null;
}
const normalized = normalizeResult(parsed, language);
if (!normalized) {
console.warn('OpenAI plant scan JSON did not match required schema.', {
model: completion.modelUsed || modelChain[0],
mode,
keys: Object.keys(parsed),
});
}
return normalized;
},
analyzePlantHealth: async (
imageUri: string,
language: Language,
plantContext?: {
name: string;
botanicalName: string;
careInfo: CareInfo;
description?: string;
},
): Promise<OpenAiHealthAnalysis | null> => {
if (!OPENAI_API_KEY) return null;
const completion = await postChatCompletion(
OPENAI_HEALTH_MODEL_CHAIN,
imageUri,
[
{
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 } },
],
},
],
);
if (!completion.payload) return buildFallbackHealthAnalysis(language, plantContext);
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI health check returned empty content.', {
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
image: summarizeImageUri(imageUri),
});
return buildFallbackHealthAnalysis(language, plantContext);
}
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI health check returned non-JSON content.', {
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
preview: content.slice(0, 220),
});
return buildFallbackHealthAnalysis(language, plantContext);
}
return normalizeHealthAnalysis(parsed, language) || buildFallbackHealthAnalysis(language, plantContext);
},
};

View File

@@ -0,0 +1,17 @@
import * as SecureStore from 'expo-secure-store';
const SESSION_KEY = 'greenlens_session_v3';
export const getAuthToken = async (): Promise<string> => {
try {
const raw = await SecureStore.getItemAsync(SESSION_KEY);
if (raw) {
const session = JSON.parse(raw);
if (typeof session?.token === 'string' && session.token) {
return session.token;
}
}
} catch {}
// Return 'guest' instead of throwing to allow guest mode
return 'guest';
};