Bug fixes

This commit is contained in:
2026-07-05 17:52:43 +02:00
parent fb6108bd0c
commit c40531b6ae
12 changed files with 1196 additions and 946 deletions

View File

@@ -0,0 +1,49 @@
const { applyCatalogGrounding } = require('../../server/lib/scanGrounding');
describe('scan confidence honesty', () => {
const catalogEntries = [
{
name: 'Rose',
botanicalName: 'Rosa chinensis',
description: 'Catalog rose entry.',
careInfo: { waterIntervalDays: 4, light: 'Full sun', temp: '15-25C' },
},
];
const lowConfidenceAiResult = {
name: 'Rose',
botanicalName: 'Rosa chinensis',
confidence: 0.55,
description: 'Possibly a rose, image is ambiguous.',
careInfo: { waterIntervalDays: 5, light: 'Full sun', temp: '15-25C' },
};
it('does not inflate a low AI confidence when a catalog match is found', () => {
const { grounded, result } = applyCatalogGrounding(lowConfidenceAiResult, catalogEntries, 'en');
expect(grounded).toBe(true);
// Regression: this used to be forced up to at least 0.78, presenting an
// uncertain model guess as a confident identification.
expect(result.confidence).toBe(0.55);
});
it('keeps a high AI confidence unchanged', () => {
const { result } = applyCatalogGrounding(
{ ...lowConfidenceAiResult, confidence: 0.9 },
catalogEntries,
'en',
);
expect(result.confidence).toBe(0.9);
});
it('clamps missing confidence to the neutral default without boosting it', () => {
const { result } = applyCatalogGrounding(
{ ...lowConfidenceAiResult, confidence: undefined },
catalogEntries,
'en',
);
expect(result.confidence).toBe(0.6);
});
});

View File

@@ -35,7 +35,7 @@ describe('scan language guards', () => {
expect(grounded.result.botanicalName).toBe('Euphorbia pulcherrima'); expect(grounded.result.botanicalName).toBe('Euphorbia pulcherrima');
expect(grounded.result.description).toContain('identified with AI'); expect(grounded.result.description).toContain('identified with AI');
expect(grounded.result.careInfo.light).toBe('Bright indirect light'); expect(grounded.result.careInfo.light).toBe('Bright indirect light');
expect(grounded.result.confidence).toBeGreaterThanOrEqual(0.78); expect(grounded.result.confidence).toBe(0.66);
}); });
it('keeps a botanical fallback name for English scans when the catalog name is German', () => { it('keeps a botanical fallback name for English scans when the catalog name is German', () => {

View File

@@ -0,0 +1,104 @@
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('../../server/lib/scanReview');
const ai = (name, botanicalName, confidence) => ({ name, botanicalName, confidence });
describe('reviewAgreesWithPrimary', () => {
it('agrees on matching botanical names regardless of casing/accents', () => {
expect(reviewAgreesWithPrimary(
ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.7),
ai('Fensterblatt', 'MONSTERA DELICIOSA', 0.6),
)).toBe(true);
});
it('agrees on matching common names when botanicals differ', () => {
expect(reviewAgreesWithPrimary(
ai('Rose', 'Rosa chinensis', 0.5),
ai('Rose', 'Rosa hybrida', 0.5),
)).toBe(true);
});
it('does not agree for different species in the same genus', () => {
// Regression: post-grounding comparison used to collapse these onto the
// same catalog entry and fake an agreement.
expect(reviewAgreesWithPrimary(
ai('Fiddle Leaf Fig', 'Ficus lyrata', 0.7),
ai('Rubber Plant', 'Ficus elastica', 0.7),
)).toBe(false);
});
it('never agrees when either side is missing', () => {
expect(reviewAgreesWithPrimary(null, ai('Rose', 'Rosa chinensis', 0.5))).toBe(false);
expect(reviewAgreesWithPrimary(ai('Rose', 'Rosa chinensis', 0.5), null)).toBe(false);
});
});
describe('decideReviewOutcome', () => {
it('rejects a disagreeing review at lower confidence', () => {
const decision = decideReviewOutcome({
primaryResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.85),
reviewResult: ai('Rose', 'Rosa chinensis', 0.55),
agrees: false,
});
expect(decision).toEqual({ accept: false, replace: false, reason: 'review-rejected-low-confidence' });
});
it('accepts a disagreeing review at higher confidence', () => {
const decision = decideReviewOutcome({
primaryResult: ai('Rose', 'Rosa chinensis', 0.55),
reviewResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.8),
agrees: false,
});
expect(decision).toEqual({ accept: true, replace: true, reason: 'review-overrode-primary' });
});
it('accepts a disagreeing stronger-model review within the 0.05 calibration margin', () => {
// Models are not calibrated against each other: an honest gpt-5 answer at
// 0.75 must not lose to an overconfident gpt-5-mini answer at 0.79.
const decision = decideReviewOutcome({
primaryResult: ai('Rose', 'Rosa chinensis', 0.79),
reviewResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.75),
agrees: false,
});
expect(decision).toEqual({ accept: true, replace: true, reason: 'review-overrode-primary' });
});
it('still rejects a disagreeing review clearly below the margin', () => {
const decision = decideReviewOutcome({
primaryResult: ai('Rose', 'Rosa chinensis', 0.79),
reviewResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.73),
agrees: false,
});
expect(decision.accept).toBe(false);
});
it('keeps the higher-confidence primary when the review agrees at lower confidence', () => {
const decision = decideReviewOutcome({
primaryResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.85),
reviewResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.6),
agrees: true,
});
expect(decision.accept).toBe(true);
expect(decision.replace).toBe(false);
expect(decision.reason).toBe('review-confirmed-primary');
});
it('replaces with the agreeing review on a confidence tie (stronger model wins ties)', () => {
const decision = decideReviewOutcome({
primaryResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.7),
reviewResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.7),
agrees: true,
});
expect(decision.accept).toBe(true);
expect(decision.replace).toBe(true);
});
it('treats missing confidences as 0 without crashing', () => {
const decision = decideReviewOutcome({
primaryResult: ai('Rose', 'Rosa chinensis', undefined),
reviewResult: ai('Swiss Cheese Plant', 'Monstera deliciosa', 0.5),
agrees: false,
});
expect(decision.accept).toBe(true);
expect(decision.replace).toBe(true);
});
});

View File

@@ -166,8 +166,12 @@ function RootLayoutInner() {
</Stack> </Stack>
); );
} }
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) { } else if (!hasActiveEntitlement && !isLoadingBilling && billingSummary && !isAllowedWithoutEntitlement) {
content = <Redirect href="/onboarding" />; // Signed-in but confirmed non-pro: send to the paywall, not back to
// onboarding — bouncing a logged-in user to onboarding looks like an
// app restart. billingSummary === null means "unknown" (fetch failed),
// never redirect on unknown.
content = <Redirect href="/profile/billing" />;
} else { } else {
content = ( content = (
<> <>

View File

@@ -69,8 +69,11 @@ export default function LoginScreen() {
setError(null); setError(null);
try { try {
const session = await AuthService.login(email, password); const session = await AuthService.login(email, password);
await hydrateSession(session); const billing = await hydrateSession(session);
router.replace('/(tabs)'); const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active';
// Non-pro accounts land on the paywall with context instead of being
// bounced through the root redirect (which looks like an app restart).
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
} catch (e: any) { } catch (e: any) {
if (e.message === 'USER_NOT_FOUND') { if (e.message === 'USER_NOT_FOUND') {
setError(t.errUserNotFound); setError(t.errUserNotFound);
@@ -114,12 +117,17 @@ export default function LoginScreen() {
email: credential.email, email: credential.email,
name: fullName || undefined, name: fullName || undefined,
}); });
await hydrateSession(session); const billing = await hydrateSession(session);
if (session.isNewUser) { if (session.isNewUser) {
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
} }
posthog.capture('apple_login_succeeded', { surface: 'login' }); posthog.capture('apple_login_succeeded', { surface: 'login' });
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)'); const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active';
if (session.isNewUser) {
router.replace('/onboarding/source');
} else {
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
}
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') { if (e?.code === 'ERR_REQUEST_CANCELED') {
return; return;

View File

@@ -131,10 +131,17 @@ export default function SignupScreen() {
email: credential.email, email: credential.email,
name: fullName || undefined, name: fullName || undefined,
}); });
await hydrateSession(session); const billing = await hydrateSession(session);
await AsyncStorage.setItem('greenlens_show_tour', 'true'); await AsyncStorage.setItem('greenlens_show_tour', 'true');
posthog.capture('apple_login_succeeded', { surface: 'signup' }); posthog.capture('apple_login_succeeded', { surface: 'signup' });
router.replace(session.isNewUser ? '/onboarding/source' : '/(tabs)'); const isPro = billing?.entitlement?.plan === 'pro' && billing?.entitlement?.status === 'active';
if (session.isNewUser) {
router.replace('/onboarding/source');
} else {
// Same routing as login: existing non-pro accounts go to the paywall
// directly instead of bouncing through the root entitlement redirect.
router.replace(isPro || !billing ? '/(tabs)' : '/profile/billing');
}
} catch (e: any) { } catch (e: any) {
if (e?.code === 'ERR_REQUEST_CANCELED') { if (e?.code === 'ERR_REQUEST_CANCELED') {
return; return;

View File

@@ -50,14 +50,14 @@ interface AppState {
deletePlant: (id: string) => Promise<void>; deletePlant: (id: string) => Promise<void>;
updatePlant: (plant: Plant) => void; updatePlant: (plant: Plant) => void;
refreshPlants: () => void; refreshPlants: () => void;
refreshBillingSummary: () => Promise<void>; refreshBillingSummary: () => Promise<BillingSummary | null>;
syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo, source?: RevenueCatSyncSource) => Promise<BillingSummary | null>; syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo, source?: RevenueCatSyncSource) => Promise<BillingSummary | null>;
simulatePurchase: (productId: PurchaseProductId) => Promise<void>; simulatePurchase: (productId: PurchaseProductId) => Promise<void>;
simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>; simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>;
getLexiconSearchHistory: () => string[]; getLexiconSearchHistory: () => string[];
saveLexiconSearchQuery: (query: string) => void; saveLexiconSearchQuery: (query: string) => void;
clearLexiconSearchHistory: () => void; clearLexiconSearchHistory: () => void;
hydrateSession: (session: AuthSession) => Promise<void>; hydrateSession: (session: AuthSession) => Promise<BillingSummary | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
setPendingPlant: (result: IdentificationResult, imageUri: string) => void; setPendingPlant: (result: IdentificationResult, imageUri: string) => void;
getPendingPlant: () => { result: IdentificationResult; imageUri: string } | null; getPendingPlant: () => { result: IdentificationResult; imageUri: string } | null;
@@ -166,13 +166,19 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const isDarkMode = resolvedScheme === 'dark'; const isDarkMode = resolvedScheme === 'dark';
const t = getTranslation(language); const t = getTranslation(language);
const refreshBillingSummary = useCallback(async () => { const refreshBillingSummary = useCallback(async (): Promise<BillingSummary | null> => {
setIsLoadingBilling(true); setIsLoadingBilling(true);
try { try {
const summary = await backendApiClient.getBillingSummary(); const summary = await backendApiClient.getBillingSummary();
setBillingSummary(summary); setBillingSummary(summary);
return summary;
} catch (e) { } catch (e) {
console.error('Failed to refresh billing summary', e); console.error('Failed to refresh billing summary', e);
// Transient failure: keep the last-known summary so a network blip
// doesn't wipe a paying user's entitlement mid-session. Session
// transitions (hydrateSession/sign-out) clear the summary themselves,
// so a stale summary can never leak across accounts.
return null;
} finally { } finally {
setIsLoadingBilling(false); setIsLoadingBilling(false);
} }
@@ -180,6 +186,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
const resetStateForSignedOutUser = useCallback(() => { const resetStateForSignedOutUser = useCallback(() => {
setSession(null); setSession(null);
// Old account's billing must not survive into the guest/next session.
setBillingSummary(null);
setPlants([]); setPlants([]);
setLanguage(getDeviceLanguage()); setLanguage(getDeviceLanguage());
setAppearanceModeState('system'); setAppearanceModeState('system');
@@ -292,6 +300,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
setProfileNameState(nextSession.name); setProfileNameState(nextSession.name);
setIsLoadingPlants(true); setIsLoadingPlants(true);
setIsLoadingBilling(true); setIsLoadingBilling(true);
// The previous (guest or prior account) summary is meaningless for this
// session — clear it so the entitlement gate treats it as "unknown"
// instead of bouncing on stale data if the fetch below fails.
setBillingSummary(null);
// Settings aus SQLite // Settings aus SQLite
try { try {
@@ -318,18 +330,11 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
// Billing laden // Billing laden
try { let summary = await refreshBillingSummary();
await refreshBillingSummary(); if (!summary) {
} catch (e) {
console.error('Initial billing summary check failed', e);
setIsLoadingBilling(false);
// Einmaliger Retry nach 2s // Einmaliger Retry nach 2s
setTimeout(async () => { setTimeout(() => {
try { refreshBillingSummary();
await refreshBillingSummary();
} catch {
// silent — user can retry manually
}
}, 2000); }, 2000);
} }
@@ -348,6 +353,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
}, 800); }, 800);
} }
return summary;
}, [refreshBillingSummary, pendingPlant, savePlant]); }, [refreshBillingSummary, pendingPlant, savePlant]);
const signOut = useCallback(async () => { const signOut = useCallback(async () => {

View File

@@ -67,7 +67,9 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-greenlns} POSTGRES_USER: ${POSTGRES_USER:-greenlns}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
ports: ports:
- "5434:5432" # Loopback only: the api container reaches postgres over greenlens_net;
# publishing this publicly invited brute-force attacks (see server logs).
- "127.0.0.1:5434:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:

View File

@@ -67,6 +67,7 @@ const {
isConfigured: isOpenAiConfigured, isConfigured: isOpenAiConfigured,
} = require('./lib/openai'); } = require('./lib/openai');
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding'); const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage'); const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
const { isPurchaseEventType, notifyPurchase, notifyNewUser } = require('./lib/discord'); const { isPurchaseEventType, notifyPurchase, notifyNewUser } = require('./lib/discord');
const { const {
@@ -85,6 +86,9 @@ const SCAN_REVIEW_COST = 0;
const SEMANTIC_SEARCH_COST = 2; const SEMANTIC_SEARCH_COST = 2;
const HEALTH_CHECK_COST = 2; const HEALTH_CHECK_COST = 2;
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8; const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
// Below this the app should treat the identification as uncertain and nudge
// the user toward a clearer photo instead of presenting the name as settled.
const LOW_CONFIDENCE_RESULT_THRESHOLD = 0.6;
let catalogCache = null; let catalogCache = null;
@@ -738,6 +742,7 @@ app.post('/v1/scan', async (request, response) => {
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true }); let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
let usedOpenAi = false; let usedOpenAi = false;
let rawPrimaryResult = null;
if (isOpenAiConfigured()) { if (isOpenAiConfigured()) {
console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel(scanPlan)} (plan: ${scanPlan})`); console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel(scanPlan)} (plan: ${scanPlan})`);
@@ -753,9 +758,9 @@ app.post('/v1/scan', async (request, response) => {
); );
if (openAiPrimary?.result) { if (openAiPrimary?.result) {
console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`); console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`);
rawPrimaryResult = openAiPrimary.result;
const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries, language); const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries, language);
result = grounded.result; result = grounded.result;
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
usedOpenAi = true; usedOpenAi = true;
modelUsed = openAiPrimary.modelUsed || modelUsed; modelUsed = openAiPrimary.modelUsed || modelUsed;
modelPath.push('openai-primary'); modelPath.push('openai-primary');
@@ -806,12 +811,23 @@ app.post('/v1/scan', async (request, response) => {
); );
if (openAiReview?.result) { if (openAiReview?.result) {
console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`); console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`);
const agrees = reviewAgreesWithPrimary(rawPrimaryResult, openAiReview.result);
const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries, language); const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries, language);
result = grounded.result; const decision = decideReviewOutcome({ primaryResult: result, reviewResult: grounded.result, agrees });
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) }; if (decision.accept) {
modelUsed = openAiReview.modelUsed || modelUsed; // modelUsed and the grounding marker describe the RESULT the user
modelPath.push('openai-review'); // gets, so they only change when the review actually replaces it.
if (grounded.grounded) modelPath.push('catalog-grounded-review'); if (decision.replace) {
result = grounded.result;
modelUsed = openAiReview.modelUsed || modelUsed;
if (grounded.grounded) modelPath.push('catalog-grounded-review');
}
modelPath.push('openai-review');
modelPath.push(decision.reason);
} else {
console.log(`OpenAI review disagreed at lower confidence for user ${userId} (${grounded.result.name} ${grounded.result.confidence} vs ${result.name} ${result.confidence}) — keeping primary result.`);
modelPath.push(decision.reason);
}
} else { } else {
console.warn(`OpenAI review identification returned null for user ${userId}.`, { console.warn(`OpenAI review identification returned null for user ${userId}.`, {
attemptedModels: openAiReview?.attemptedModels, attemptedModels: openAiReview?.attemptedModels,
@@ -840,6 +856,7 @@ app.post('/v1/scan', async (request, response) => {
const payload = { const payload = {
result, result,
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,
creditsCharged, creditsCharged,
modelPath, modelPath,
modelUsed, modelUsed,

View File

@@ -5,6 +5,8 @@ const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.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 = (process.env.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.OPENAI_SCAN_FALLBACK_MODELS_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS_PRO || OPENAI_SCAN_FALLBACK_MODELS).trim(); const OPENAI_SCAN_FALLBACK_MODELS_PRO = (process.env.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.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim(); const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_SCAN_REVIEW_MODEL = (process.env.OPENAI_SCAN_REVIEW_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_REVIEW_MODEL || 'gpt-5').trim();
const OPENAI_SCAN_REVIEW_FALLBACK_MODELS = (process.env.OPENAI_SCAN_REVIEW_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_REVIEW_FALLBACK_MODELS || 'gpt-5-mini,gpt-4.1-mini').trim();
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim(); const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
const OPENAI_TIMEOUT_MS = (() => { const OPENAI_TIMEOUT_MS = (() => {
const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim(); const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
@@ -26,8 +28,12 @@ const parseModelChain = (primaryModel, fallbackModels) => {
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_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_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 OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
const OPENAI_SCAN_REVIEW_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_REVIEW_MODEL, OPENAI_SCAN_REVIEW_FALLBACK_MODELS);
const getScanModelChain = (plan) => { const getScanModelChain = (plan, mode = 'primary') => {
// The review pass exists to catch low-confidence primary IDs, so re-running
// the primary model on the same image adds nothing — use a stronger model.
if (mode === 'review') return OPENAI_SCAN_REVIEW_MODEL_CHAIN;
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN; return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
}; };
@@ -377,7 +383,7 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature,
const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => { const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => {
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] }; if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
const modelChain = getScanModelChain(plan); const modelChain = getScanModelChain(plan, mode);
const completion = await postChatCompletion({ const completion = await postChatCompletion({
modelChain, modelChain,
imageUri, imageUri,

View File

@@ -111,7 +111,7 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
result: { result: {
name: useCatalogName ? matchedEntry.name || aiResult.name : aiResult.name, name: useCatalogName ? matchedEntry.name || aiResult.name : aiResult.name,
botanicalName: matchedEntry.botanicalName || aiResult.botanicalName, botanicalName: matchedEntry.botanicalName || aiResult.botanicalName,
confidence: clamp(Math.max(aiResult.confidence || 0.6, 0.78), 0.05, 0.99), confidence: clamp(aiResult.confidence || 0.6, 0.05, 0.99),
description: aiResult.description || matchedEntry.description || '', description: aiResult.description || matchedEntry.description || '',
careInfo: { careInfo: {
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7), waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),

46
server/lib/scanReview.js Normal file
View File

@@ -0,0 +1,46 @@
const { normalizeText } = require('./scanGrounding');
// Agreement must be judged on the RAW model answers, not the grounded ones:
// catalog grounding has a genus-level fallback that can collapse two different
// species onto the same catalog entry and fake an agreement.
const reviewAgreesWithPrimary = (rawPrimary, rawReview) => {
if (!rawPrimary || !rawReview) return false;
const primaryBotanical = normalizeText(rawPrimary.botanicalName);
const primaryName = normalizeText(rawPrimary.name);
const botanicalMatch = Boolean(primaryBotanical) && primaryBotanical === normalizeText(rawReview.botanicalName);
const nameMatch = Boolean(primaryName) && primaryName === normalizeText(rawReview.name);
return botanicalMatch || nameMatch;
};
// The review runs on a stronger model chain than the primary, and models are
// not calibrated against each other — so on disagreement the review wins even
// when it trails the primary by up to this margin.
const REVIEW_DISAGREEMENT_MARGIN = 0.05;
// A second low-confidence guess is not a verification: the review may only
// replace the primary when it agrees with it or is (near-)competitively
// confident. On agreement the higher-confidence variant wins (tie goes to
// the review, which runs on the stronger model chain).
const decideReviewOutcome = ({ primaryResult, reviewResult, agrees }) => {
const primaryConfidence = primaryResult?.confidence || 0;
const reviewConfidence = reviewResult?.confidence || 0;
if (agrees) {
return {
accept: true,
replace: reviewConfidence >= primaryConfidence,
reason: 'review-confirmed-primary',
};
}
if (reviewConfidence >= primaryConfidence - REVIEW_DISAGREEMENT_MARGIN) {
return { accept: true, replace: true, reason: 'review-overrode-primary' };
}
return { accept: false, replace: false, reason: 'review-rejected-low-confidence' };
};
module.exports = {
decideReviewOutcome,
reviewAgreesWithPrimary,
};