// This feature is only for debug mode (Castle Mode). // This file was not reviewed by a human reviewer and must not become a part of production experience. import { useAlert } from '@/aesthetics/Alert'; import { Text } from '@/aesthetics/Text'; import { useColors } from '@/aesthetics/styles'; import { useStrings } from '@/common/hooks/useStrings'; import { resizeAndEncodeImage } from '@/common/utils/image'; import { NUTRIENT_META } from '@/common/constants/nutrition'; import { ClaudeProvider, GeminiProvider, OpenAIProvider } from '@/add-log/food-detection/providers'; import type { FoodLogPred } from '@/types/foodlog'; import type { NutrientKey } from '@/types/nutrition'; import type { Strings } from '@/common/strings'; import { useMemo, useState } from 'react'; import { StyleSheet, View } from 'react-native'; type AIProviderName = 'claude' | 'gemini' | 'openai'; type FoodPrediction = { provider: AIProviderName; result?: { foodName: string; ingredients: string[]; nutrition?: Record }; }; const claudeProvider = new ClaudeProvider(); const geminiProvider = new GeminiProvider(); const openaiProvider = new OpenAIProvider(); const PROVIDER_INSTANCES = { claude: claudeProvider, gemini: geminiProvider, openai: openaiProvider, } as const; async function askJudge( provider: AIProviderName, input: { base64?: string; prompt: string } ): Promise { const result = await PROVIDER_INSTANCES[provider].query(input.prompt, input.base64); return result.trim(); } export type JudgeResult = { judge: AIProviderName; selectedProvider: AIProviderName | 'unknown'; reasoning: string; report: string; }; const JUDGE_REPORT_WORDS = 50; const trimToWordCount = (text: string, wordCount: number) => { const words = text.split(/\s+/).filter(Boolean); if (words.length <= wordCount) return words.join(' '); return words.slice(0, wordCount).join(' '); }; const buildFallbackJudgeReport = ( selectedProvider: AIProviderName | 'unknown', candidates: FoodPrediction[], strings: Strings ) => { const selected = candidates.find(c => c.provider === selectedProvider); const selectedName = selected?.result?.foodName ?? strings.seeLog.castle.theSelectedOption; const ingredients = selected?.result?.ingredients && selected.result.ingredients.length > 0 ? ` and its listed ingredients (${selected.result.ingredients.slice(0, 3).join(', ')})` : ''; const otherNames = candidates .filter(c => c.provider !== selectedProvider) .map(c => c.result?.foodName) .filter(Boolean); const otherList = otherNames.length > 0 ? otherNames.join(', ') : strings.seeLog.castle.theOtherOptions; return strings.seeLog.castle.fallbackReport(selectedName, ingredients, otherList); }; const normalizeJudgeReport = (report: string, fallback: string) => { let text = report.trim(); if (!text) { text = fallback; } else if (text.split(/\s+/).filter(Boolean).length < JUDGE_REPORT_WORDS) { text = `${text} ${fallback}`; } return trimToWordCount(text, JUDGE_REPORT_WORDS); }; const parseJudgeResponse = (text: string) => { const winnerMatch = text.match(/Winner:\s*(\d+)/i) || text.match(/Option\s*(\d+)/i) || text.match(/\b(\d)\b/); const reportMatch = text.match(/Report:\s*([\s\S]+)/i); return { winnerIndex: winnerMatch ? parseInt(winnerMatch[1], 10) - 1 : null, report: reportMatch ? reportMatch[1].trim() : '', }; }; async function judgePredictions( base64: string, predictions: FoodPrediction[], strings: Strings ): Promise { const candidates = predictions.filter(p => p.result); if (candidates.length < 2) return []; const candidateDescriptions = candidates .map((c, i) => strings.seeLog.castle.optionTemplate( i + 1, c.result?.foodName ?? '', c.result?.ingredients.join(', ') ?? '' ) ) .join('\n\n'); const prompt = strings.ai.judgePredictions(candidates.length, candidateDescriptions); const input = { base64, prompt }; const results = await Promise.allSettled([ askJudge('claude', input), askJudge('gemini', input), askJudge('openai', input), ]); return results.map((res, i) => { const judge = PROVIDERS[i]; if (res.status === 'rejected') { const fallback = buildFallbackJudgeReport('unknown', candidates, strings); return { judge, selectedProvider: 'unknown' as const, reasoning: strings.seeLog.castle.failedToJudge, report: trimToWordCount(fallback, JUDGE_REPORT_WORDS), }; } const text = res.value; const { winnerIndex, report } = parseJudgeResponse(text); let selectedProvider: AIProviderName | 'unknown' = 'unknown'; if (winnerIndex !== null && winnerIndex >= 0 && winnerIndex < candidates.length) { selectedProvider = candidates[winnerIndex].provider; } const fallbackReport = buildFallbackJudgeReport(selectedProvider, candidates, strings); return { judge, selectedProvider, reasoning: text, report: normalizeJudgeReport(report, fallbackReport), }; }); } export const PROVIDERS = ['claude', 'gemini', 'openai'] as const; export const getProviderLabels = (strings: Strings): Record => ({ claude: strings.seeLog.castle.claude, gemini: strings.seeLog.castle.gemini, openai: strings.seeLog.castle.gpt, }); export const PROVIDER_COLORS: Record = { claude: '#D97706', gemini: '#4285F4', openai: '#10A37F', }; function NutrientComparisonBar({ nutrientKey, predictionsByProvider, maxValue, visibleProviders, }: { nutrientKey: NutrientKey; predictionsByProvider: Map; maxValue: number; visibleProviders: readonly string[]; }) { const c = useColors(); const meta = NUTRIENT_META[nutrientKey]; if (!meta) return null; const values = visibleProviders.map(provider => { const pred = predictionsByProvider.get(provider); return pred?.nutrition?.[nutrientKey] ?? 0; }); const sortedIndices = values .map((v, i) => ({ v, i })) .sort((a, b) => b.v - a.v) .map(x => x.i); return ( {meta.label} {sortedIndices.map(idx => { const provider = visibleProviders[idx]; const val = values[idx]; if (val === 0) return null; const pct = Math.min((val / maxValue) * 100, 100); return ( ); })} {visibleProviders.map(provider => { const pred = predictionsByProvider.get(provider); const val = pred?.nutrition?.[nutrientKey]; if (val == null || val === 0) return null; const display = val >= 10 ? Math.round(val) : Math.round(val * 10) / 10; return ( {display} ); })} ); } export function NutritionComparisonChart({ predictions, visibleProviders, providerLabels, }: { predictions: FoodLogPred[]; visibleProviders: readonly string[]; providerLabels: Record; }) { const c = useColors(); const strings = useStrings(); const predictionsByProvider = useMemo( () => new Map(predictions.map(p => [p.aiModel, p])), [predictions] ); const { nutrientKeys, maxValues } = useMemo(() => { const allKeys = new Set(); const maxVals: Record = {}; for (const pred of predictions) { if (!pred.nutrition) continue; for (const [key, val] of Object.entries(pred.nutrition)) { if (val != null && val > 0) { allKeys.add(key as NutrientKey); maxVals[key] = Math.max(maxVals[key] ?? 0, val); } } } const sorted = Array.from(allKeys).sort((a, b) => { const metaA = NUTRIENT_META[a]; const metaB = NUTRIENT_META[b]; return (metaA?.importance ?? 999) - (metaB?.importance ?? 999); }); return { nutrientKeys: sorted, maxValues: maxVals }; }, [predictions]); if (nutrientKeys.length === 0) { return ( {strings.seeLog.castle.noNutritionData} ); } return ( {visibleProviders.map(provider => ( {providerLabels[provider]} ))} {nutrientKeys.map(key => ( ))} ); } export function MultiProviderIngredients({ predictions, visibleProviders, }: { predictions: FoodLogPred[]; visibleProviders: readonly string[]; }) { const c = useColors(); return ( {visibleProviders.map(provider => { const pred = predictions.find(p => p.aiModel === provider); return ( {pred?.predictedFoodName ?? '—'} {pred?.ingredients && pred.ingredients.length > 0 && ( {pred.ingredients.join(', ')} )} ); })} ); } export function JudgeResultsView({ results, providerLabels, }: { results: JudgeResult[]; providerLabels: Record; }) { const c = useColors(); const strings = useStrings(); return ( {results.map((res, i) => ( {providerLabels[res.judge]} {strings.seeLog.castle.votedFor} {res.selectedProvider !== 'unknown' ? providerLabels[res.selectedProvider] : strings.common.unknown} {strings.seeLog.castle.report} {res.report} ))} ); } function useJudge( photoUri: string | undefined, predictions: FoodLogPred[] | null, strings: Strings, showAlert: (title: string, message?: string) => void ) { const [judgeResults, setJudgeResults] = useState(null); const [judging, setJudging] = useState(false); const handleJudge = async () => { if (!photoUri || !predictions) return; setJudging(true); try { const base64 = await resizeAndEncodeImage(photoUri); const candidates = predictions.map(p => { const nutrition: Record = {}; if (p.nutrition) { for (const [k, v] of Object.entries(p.nutrition)) { if (v != null) nutrition[k] = v; } } return { provider: p.aiModel as 'claude' | 'gemini' | 'openai', result: { foodName: p.predictedFoodName ?? strings.common.unknown, ingredients: p.ingredients ?? [], nutrition, }, }; }); const results = await judgePredictions(base64, candidates, strings); setJudgeResults(results); } catch { showAlert(strings.common.error, strings.seeLog.castle.failedToJudgePredictions); } finally { setJudging(false); } }; return { judgeResults, judging, handleJudge }; } export function CastleDebugSections({ predictions, photoUri, }: { predictions: FoodLogPred[]; photoUri: string | undefined; }) { const c = useColors(); const strings = useStrings(); const { showAlert } = useAlert(); const providerLabels = getProviderLabels(strings); const { judgeResults, judging, handleJudge } = useJudge( photoUri, predictions, strings, showAlert ); return ( <> {strings.seeLog.castle.whatEachAiDetected} {strings.seeLog.castle.castle} {strings.seeLog.castle.nutritionComparison} {strings.seeLog.castle.castle} {!judgeResults && ( {judging ? strings.seeLog.castle.judging : strings.seeLog.castle.judgeResults} )} {judgeResults && ( {strings.seeLog.castle.theVerdict} )} ); } const styles = StyleSheet.create({ section: { marginTop: 20, borderRadius: 12, borderWidth: 1, padding: 16, }, sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, }, sectionTitle: { fontSize: 16, fontWeight: '600' }, castlePill: { backgroundColor: '#000', paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10, }, castlePillText: { color: '#fff', fontSize: 11, fontWeight: '600' }, judgeBtn: { marginTop: 20, paddingVertical: 14, borderRadius: 12, alignItems: 'center', }, judgeBtnText: { fontSize: 16, fontWeight: '600', }, compChart: { gap: 10 }, compLegend: { flexDirection: 'row', gap: 16, marginBottom: 8 }, legendItem: { flexDirection: 'row', alignItems: 'center', gap: 4 }, legendDot: { width: 8, height: 8, borderRadius: 4 }, legendText: { fontSize: 11 }, compBarRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, compBarLabel: { width: 70, fontSize: 11, fontWeight: '500' }, compBarContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6 }, compBarBg: { flex: 1, height: 16, borderRadius: 4, overflow: 'hidden', position: 'relative' }, compBarFill: { position: 'absolute', left: 0, top: 0, height: '100%', borderRadius: 4 }, compBarValues: { flexDirection: 'row', gap: 4, minWidth: 80 }, compBarValue: { fontSize: 10, fontWeight: '600' }, noDataText: { fontSize: 13, textAlign: 'center', paddingVertical: 12 }, ingredientComp: { gap: 12 }, ingredientCompRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10 }, providerDot: { width: 10, height: 10, borderRadius: 5, marginTop: 5 }, ingredientCompContent: { flex: 1 }, ingredientCompName: { fontSize: 15, fontWeight: '500' }, ingredientCompList: { fontSize: 12, marginTop: 2 }, judgeResults: { gap: 16 }, judgeRow: { gap: 4 }, judgeHeader: { flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }, judgeName: { fontWeight: '700', fontSize: 15 }, judgeAction: { fontSize: 14 }, judgeWinner: { fontWeight: '700', fontSize: 15 }, judgeReportLabel: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase' }, judgeReason: { fontSize: 13, lineHeight: 18 }, });