Bug fixes
This commit is contained in:
49
__tests__/server/scanConfidenceHonesty.test.js
Normal file
49
__tests__/server/scanConfidenceHonesty.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
104
__tests__/server/scanReview.test.js
Normal file
104
__tests__/server/scanReview.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
46
server/lib/scanReview.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user