Files
Greenlens/server/lib/scanGrounding.js

160 lines
5.7 KiB
JavaScript

const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
const normalizeText = (value) => {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
const GERMAN_COMMON_NAME_HINTS = [
'weihnachtsstern',
'weinachtsstern',
'einblatt',
'fensterblatt',
'korbmarante',
'glucksfeder',
'gluecksfeder',
'efeutute',
'drachenbaum',
'gummibaum',
'geigenfeige',
'bogenhanf',
'yucca palme',
'gluckskastanie',
'glueckskastanie',
];
// Most of the catalog's German common names are compound nouns ending in one
// of these — a hardcoded name list alone (GERMAN_COMMON_NAME_HINTS) only
// catches names we've already seen slip through, so this suffix check acts
// as a general-purpose backstop for names we haven't hardcoded.
const GERMAN_NAME_SUFFIXES = [
'blume', 'blueten', 'bluten', 'baum', 'strauch', 'kraut', 'wurz', 'wurzel',
'farn', 'palme', 'pflanze', 'hanf', 'tute', 'veilchen', 'dorn', 'kaktus',
'lilie', 'birke', 'weide', 'ranke', 'stern', 'kette', 'schwanz', 'ohren',
'feige', 'moos', 'zwiebel', 'knolle', 'rohr', 'kirsche',
];
const isLikelyGermanCommonName = (value) => {
const raw = String(value || '').trim();
if (!raw) return false;
if (/[äöüß]/i.test(raw)) return true;
const normalized = normalizeText(raw).replace(/[^a-z0-9 ]+/g, ' ');
if (!normalized) return false;
if (/\b(der|die|das|ein|eine|und|mit|fuer)\b/.test(normalized)) return true;
const compact = normalized.replace(/\s+/g, '');
if (GERMAN_NAME_SUFFIXES.some((suffix) => compact.endsWith(suffix))) return true;
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
};
// Last-resort safety net applied to the FINAL result regardless of whether
// it went through catalog grounding — the AI is instructed to never return a
// German name when English was requested, but doesn't always comply, so this
// catches the ones that slip through and falls back to the botanical name
// (which is language-neutral) rather than showing a mismatched German name
// alongside English description/care text.
const enforceEnglishName = (result, language) => {
if (!result || language !== 'en') return result;
if (!isLikelyGermanCommonName(result.name)) return result;
if (!result.botanicalName) return result;
return { ...result, name: result.botanicalName };
};
const isLikelyBotanicalName = (value, botanicalName) => {
const raw = String(value || '').trim();
const botanicalRaw = String(botanicalName || '').trim();
if (!raw) return false;
if (normalizeText(raw) === normalizeText(botanicalRaw)) return true;
return /^[A-Z][a-z-]+(?:\s[a-z.-]+){1,2}$/.test(raw);
};
const findCatalogMatch = (aiResult, entries) => {
if (!aiResult || !Array.isArray(entries) || entries.length === 0) return null;
const aiBotanical = normalizeText(aiResult.botanicalName);
const aiName = normalizeText(aiResult.name);
if (!aiBotanical && !aiName) return null;
const byExactBotanical = entries.find((entry) => normalizeText(entry.botanicalName) === aiBotanical);
if (byExactBotanical) return byExactBotanical;
const byExactName = entries.find((entry) => normalizeText(entry.name) === aiName);
if (byExactName) return byExactName;
if (aiBotanical) {
const aiGenus = aiBotanical.split(' ')[0];
if (aiGenus) {
const byGenus = entries.find((entry) => normalizeText(entry.botanicalName).startsWith(`${aiGenus} `));
if (byGenus) return byGenus;
}
}
const byContains = entries.find((entry) => {
const plantName = normalizeText(entry.name);
const botanical = normalizeText(entry.botanicalName);
return (aiName && (plantName.includes(aiName) || aiName.includes(plantName)))
|| (aiBotanical && (botanical.includes(aiBotanical) || aiBotanical.includes(botanical)));
});
if (byContains) return byContains;
return null;
};
const shouldUseCatalogNameOverride = ({ language, aiResult, matchedEntry }) => {
const catalogName = String(matchedEntry?.name || '').trim();
if (!catalogName) return false;
if (language !== 'en') return true;
if (isLikelyBotanicalName(catalogName, matchedEntry?.botanicalName || aiResult?.botanicalName)) {
return true;
}
if (isLikelyGermanCommonName(catalogName)) {
return false;
}
return true;
};
const applyCatalogGrounding = (aiResult, catalogEntries, language = 'en') => {
const matchedEntry = findCatalogMatch(aiResult, catalogEntries);
if (!matchedEntry) {
return { grounded: false, result: aiResult };
}
const useCatalogName = shouldUseCatalogNameOverride({ language, aiResult, matchedEntry });
return {
grounded: true,
result: {
name: useCatalogName ? matchedEntry.name || aiResult.name : aiResult.name,
botanicalName: matchedEntry.botanicalName || aiResult.botanicalName,
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),
light: (matchedEntry.careInfo?.light && matchedEntry.careInfo.light !== 'Unknown') ? matchedEntry.careInfo.light : (aiResult.careInfo?.light || 'Unknown'),
temp: (matchedEntry.careInfo?.temp && matchedEntry.careInfo.temp !== 'Unknown') ? matchedEntry.careInfo.temp : (aiResult.careInfo?.temp || 'Unknown'),
},
},
};
};
module.exports = {
applyCatalogGrounding,
enforceEnglishName,
findCatalogMatch,
isLikelyGermanCommonName,
normalizeText,
shouldUseCatalogNameOverride,
};