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.description).toContain('identified with AI');
|
||||
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', () => {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
} else if (!hasActiveEntitlement && !isLoadingBilling && !isAllowedWithoutEntitlement) {
|
||||
content = <Redirect href="/onboarding" />;
|
||||
} else if (!hasActiveEntitlement && !isLoadingBilling && billingSummary && !isAllowedWithoutEntitlement) {
|
||||
// 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 {
|
||||
content = (
|
||||
<>
|
||||
|
||||
@@ -69,8 +69,11 @@ export default function LoginScreen() {
|
||||
setError(null);
|
||||
try {
|
||||
const session = await AuthService.login(email, password);
|
||||
await hydrateSession(session);
|
||||
router.replace('/(tabs)');
|
||||
const billing = await hydrateSession(session);
|
||||
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) {
|
||||
if (e.message === 'USER_NOT_FOUND') {
|
||||
setError(t.errUserNotFound);
|
||||
@@ -114,12 +117,17 @@ export default function LoginScreen() {
|
||||
email: credential.email,
|
||||
name: fullName || undefined,
|
||||
});
|
||||
await hydrateSession(session);
|
||||
const billing = await hydrateSession(session);
|
||||
if (session.isNewUser) {
|
||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||
}
|
||||
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) {
|
||||
if (e?.code === 'ERR_REQUEST_CANCELED') {
|
||||
return;
|
||||
|
||||
@@ -131,10 +131,17 @@ export default function SignupScreen() {
|
||||
email: credential.email,
|
||||
name: fullName || undefined,
|
||||
});
|
||||
await hydrateSession(session);
|
||||
const billing = await hydrateSession(session);
|
||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||
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) {
|
||||
if (e?.code === 'ERR_REQUEST_CANCELED') {
|
||||
return;
|
||||
|
||||
@@ -50,14 +50,14 @@ interface AppState {
|
||||
deletePlant: (id: string) => Promise<void>;
|
||||
updatePlant: (plant: Plant) => void;
|
||||
refreshPlants: () => void;
|
||||
refreshBillingSummary: () => Promise<void>;
|
||||
refreshBillingSummary: () => Promise<BillingSummary | null>;
|
||||
syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo, source?: RevenueCatSyncSource) => Promise<BillingSummary | null>;
|
||||
simulatePurchase: (productId: PurchaseProductId) => Promise<void>;
|
||||
simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>;
|
||||
getLexiconSearchHistory: () => string[];
|
||||
saveLexiconSearchQuery: (query: string) => void;
|
||||
clearLexiconSearchHistory: () => void;
|
||||
hydrateSession: (session: AuthSession) => Promise<void>;
|
||||
hydrateSession: (session: AuthSession) => Promise<BillingSummary | null>;
|
||||
signOut: () => Promise<void>;
|
||||
setPendingPlant: (result: IdentificationResult, imageUri: string) => void;
|
||||
getPendingPlant: () => { result: IdentificationResult; imageUri: string } | null;
|
||||
@@ -166,13 +166,19 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const isDarkMode = resolvedScheme === 'dark';
|
||||
const t = getTranslation(language);
|
||||
|
||||
const refreshBillingSummary = useCallback(async () => {
|
||||
const refreshBillingSummary = useCallback(async (): Promise<BillingSummary | null> => {
|
||||
setIsLoadingBilling(true);
|
||||
try {
|
||||
const summary = await backendApiClient.getBillingSummary();
|
||||
setBillingSummary(summary);
|
||||
return summary;
|
||||
} catch (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 {
|
||||
setIsLoadingBilling(false);
|
||||
}
|
||||
@@ -180,6 +186,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
const resetStateForSignedOutUser = useCallback(() => {
|
||||
setSession(null);
|
||||
// Old account's billing must not survive into the guest/next session.
|
||||
setBillingSummary(null);
|
||||
setPlants([]);
|
||||
setLanguage(getDeviceLanguage());
|
||||
setAppearanceModeState('system');
|
||||
@@ -292,6 +300,10 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setProfileNameState(nextSession.name);
|
||||
setIsLoadingPlants(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
|
||||
try {
|
||||
@@ -318,18 +330,11 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
|
||||
// Billing laden
|
||||
try {
|
||||
await refreshBillingSummary();
|
||||
} catch (e) {
|
||||
console.error('Initial billing summary check failed', e);
|
||||
setIsLoadingBilling(false);
|
||||
let summary = await refreshBillingSummary();
|
||||
if (!summary) {
|
||||
// Einmaliger Retry nach 2s
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await refreshBillingSummary();
|
||||
} catch {
|
||||
// silent — user can retry manually
|
||||
}
|
||||
setTimeout(() => {
|
||||
refreshBillingSummary();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -348,6 +353,8 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}, [refreshBillingSummary, pendingPlant, savePlant]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
|
||||
@@ -67,7 +67,9 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-greenlns}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
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:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
|
||||
@@ -67,6 +67,7 @@ const {
|
||||
isConfigured: isOpenAiConfigured,
|
||||
} = require('./lib/openai');
|
||||
const { applyCatalogGrounding, normalizeText } = require('./lib/scanGrounding');
|
||||
const { decideReviewOutcome, reviewAgreesWithPrimary } = require('./lib/scanReview');
|
||||
const { ensureStorageBucketWithRetry, uploadImage, isStorageConfigured } = require('./lib/storage');
|
||||
const { isPurchaseEventType, notifyPurchase, notifyNewUser } = require('./lib/discord');
|
||||
const {
|
||||
@@ -85,6 +86,9 @@ const SCAN_REVIEW_COST = 0;
|
||||
const SEMANTIC_SEARCH_COST = 2;
|
||||
const HEALTH_CHECK_COST = 2;
|
||||
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;
|
||||
|
||||
@@ -738,6 +742,7 @@ app.post('/v1/scan', async (request, response) => {
|
||||
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
|
||||
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
|
||||
let usedOpenAi = false;
|
||||
let rawPrimaryResult = null;
|
||||
|
||||
if (isOpenAiConfigured()) {
|
||||
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) {
|
||||
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);
|
||||
result = grounded.result;
|
||||
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
|
||||
usedOpenAi = true;
|
||||
modelUsed = openAiPrimary.modelUsed || modelUsed;
|
||||
modelPath.push('openai-primary');
|
||||
@@ -806,12 +811,23 @@ app.post('/v1/scan', async (request, response) => {
|
||||
);
|
||||
if (openAiReview?.result) {
|
||||
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);
|
||||
result = grounded.result;
|
||||
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
|
||||
modelUsed = openAiReview.modelUsed || modelUsed;
|
||||
modelPath.push('openai-review');
|
||||
if (grounded.grounded) modelPath.push('catalog-grounded-review');
|
||||
const decision = decideReviewOutcome({ primaryResult: result, reviewResult: grounded.result, agrees });
|
||||
if (decision.accept) {
|
||||
// modelUsed and the grounding marker describe the RESULT the user
|
||||
// gets, so they only change when the review actually replaces it.
|
||||
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 {
|
||||
console.warn(`OpenAI review identification returned null for user ${userId}.`, {
|
||||
attemptedModels: openAiReview?.attemptedModels,
|
||||
@@ -840,6 +856,7 @@ app.post('/v1/scan', async (request, response) => {
|
||||
|
||||
const payload = {
|
||||
result,
|
||||
lowConfidence: (result.confidence || 0) < LOW_CONFIDENCE_RESULT_THRESHOLD,
|
||||
creditsCharged,
|
||||
modelPath,
|
||||
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_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_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_TIMEOUT_MS = (() => {
|
||||
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_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_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;
|
||||
};
|
||||
|
||||
@@ -377,7 +383,7 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature,
|
||||
|
||||
const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => {
|
||||
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
|
||||
const modelChain = getScanModelChain(plan);
|
||||
const modelChain = getScanModelChain(plan, mode);
|
||||
const completion = await postChatCompletion({
|
||||
modelChain,
|
||||
imageUri,
|
||||
|
||||
@@ -111,7 +111,7 @@ const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
|
||||
result: {
|
||||
name: useCatalogName ? matchedEntry.name || aiResult.name : aiResult.name,
|
||||
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 || '',
|
||||
careInfo: {
|
||||
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