Initial commit for Greenlens

This commit is contained in:
Timo Knuth
2026-03-16 21:31:46 +01:00
parent 307135671f
commit 05d4f6e78b
573 changed files with 54233 additions and 1891 deletions

View File

@@ -1,17 +1,17 @@
const { get, run } = require('./sqlite');
const FREE_MONTHLY_CREDITS = 15;
const PRO_MONTHLY_CREDITS = 50;
const PRO_MONTHLY_CREDITS = 250;
const TOPUP_DEFAULT_CREDITS = 60;
const TOPUP_CREDITS_BY_PRODUCT = {
pro_monthly: 0,
topup_small: 50,
topup_small: 25,
topup_medium: 120,
topup_large: 300,
};
const AVAILABLE_PRODUCTS = ['pro_monthly', 'topup_small', 'topup_medium', 'topup_large'];
const AVAILABLE_PRODUCTS = ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'];
const nowIso = () => new Date().toISOString();
@@ -68,7 +68,7 @@ const normalizeAccountRow = (row) => {
return {
userId: String(row.userId),
plan: row.plan === 'pro' ? 'pro' : 'free',
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'stripe',
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'revenuecat',
cycleStartedAt: String(row.cycleStartedAt),
cycleEndsAt: String(row.cycleEndsAt),
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
@@ -84,7 +84,7 @@ const buildDefaultAccount = (userId, now) => {
return {
userId,
plan: 'free',
provider: 'stripe',
provider: 'revenuecat',
cycleStartedAt: cycleStartedAt.toISOString(),
cycleEndsAt: cycleEndsAt.toISOString(),
monthlyAllowance: FREE_MONTHLY_CREDITS,
@@ -305,6 +305,20 @@ const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
};
const getBillingSummary = async (db, userId) => {
if (userId === 'guest') {
return {
entitlement: { plan: 'free', provider: 'mock', status: 'active', renewsAt: null },
credits: {
monthlyAllowance: 5,
usedThisCycle: 0,
topupBalance: 0,
available: 5,
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso()
},
availableProducts: AVAILABLE_PRODUCTS,
};
}
return runInTransaction(db, async () => {
const account = await getOrCreateAccount(db, userId);
account.updatedAt = nowIso();
@@ -314,6 +328,20 @@ const getBillingSummary = async (db, userId) => {
};
const getAccountSnapshot = async (db, userId) => {
if (userId === 'guest') {
return {
userId: 'guest',
plan: 'free',
provider: 'mock',
cycleStartedAt: nowIso(),
cycleEndsAt: nowIso(),
monthlyAllowance: 5,
usedThisCycle: 0,
topupBalance: 0,
renewsAt: null,
updatedAt: nowIso(),
};
}
return runInTransaction(db, async () => {
const account = await getOrCreateAccount(db, userId);
account.updatedAt = nowIso();
@@ -342,11 +370,11 @@ const simulatePurchase = async (db, userId, idempotencyKey, productId) => {
const account = await getOrCreateAccount(db, userId);
if (productId === 'pro_monthly') {
if (productId === 'monthly_pro' || productId === 'yearly_pro') {
const now = new Date();
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
account.plan = 'pro';
account.provider = 'stripe';
account.provider = 'revenuecat';
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
account.usedThisCycle = 0;
account.cycleStartedAt = cycleStartedAt.toISOString();
@@ -441,7 +469,7 @@ const ensureBillingSchema = async (db) => {
`CREATE TABLE IF NOT EXISTS billing_accounts (
userId TEXT PRIMARY KEY,
plan TEXT NOT NULL DEFAULT 'free',
provider TEXT NOT NULL DEFAULT 'stripe',
provider TEXT NOT NULL DEFAULT 'revenuecat',
cycleStartedAt TEXT NOT NULL,
cycleEndsAt TEXT NOT NULL,
monthlyAllowance INTEGER NOT NULL DEFAULT 15,

193
server/lib/hybridSearch.js Normal file
View File

@@ -0,0 +1,193 @@
const { SEARCH_INTENT_CONFIG } = require('./searchIntentConfig');
const normalizeSearchText = (value) => {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/[^a-z0-9\s_-]+/g, ' ')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
};
const tokenize = (normalizedValue) => normalizedValue.split(' ').filter(Boolean);
const normalizeArray = (values) => {
return [...new Set((values || []).map((value) => normalizeSearchText(value)).filter(Boolean))];
};
const tokenSetFromQuery = (normalizedQuery) => {
const noise = new Set(SEARCH_INTENT_CONFIG.noiseTokens.map((token) => normalizeSearchText(token)));
return new Set(tokenize(normalizedQuery).filter((token) => !noise.has(token)));
};
const includesPhrase = (normalizedQuery, normalizedAlias, queryTokens) => {
if (!normalizedAlias) return false;
if (normalizedQuery.includes(normalizedAlias)) return true;
const aliasTokens = tokenize(normalizedAlias);
if (aliasTokens.length <= 1) return queryTokens.has(normalizedAlias);
return aliasTokens.every((token) => queryTokens.has(token));
};
const detectQueryIntents = (normalizedQuery) => {
const queryTokens = tokenSetFromQuery(normalizedQuery);
return Object.entries(SEARCH_INTENT_CONFIG.intents)
.filter(([, value]) =>
(value.aliases || []).some((alias) => includesPhrase(normalizedQuery, normalizeSearchText(alias), queryTokens)))
.map(([intentId]) => intentId);
};
const getLevenshteinDistance = (left, right) => {
const rows = left.length + 1;
const cols = right.length + 1;
const matrix = Array.from({ length: rows }, (_, rowIndex) => [rowIndex]);
for (let col = 0; col < cols; col += 1) {
matrix[0][col] = col;
}
for (let row = 1; row < rows; row += 1) {
for (let col = 1; col < cols; col += 1) {
const cost = left[row - 1] === right[col - 1] ? 0 : 1;
matrix[row][col] = Math.min(
matrix[row - 1][col] + 1,
matrix[row][col - 1] + 1,
matrix[row - 1][col - 1] + cost,
);
}
}
return matrix[left.length][right.length];
};
const fuzzyBonus = (normalizedQuery, candidates) => {
if (normalizedQuery.length < 3 || normalizedQuery.length > 32) return 0;
let best = Number.POSITIVE_INFINITY;
(candidates || []).forEach((candidate) => {
if (!candidate) return;
tokenize(candidate).forEach((token) => {
best = Math.min(best, getLevenshteinDistance(normalizedQuery, token));
});
best = Math.min(best, getLevenshteinDistance(normalizedQuery, candidate));
});
if (best === 1) return 14;
if (best === 2) return 8;
return 0;
};
const scoreTextMatch = (normalizedQuery, normalizedTarget, exact, prefix, contains) => {
if (!normalizedQuery || !normalizedTarget) return 0;
if (normalizedTarget === normalizedQuery) return exact;
if (normalizedTarget.startsWith(normalizedQuery)) return prefix;
if (normalizedTarget.includes(normalizedQuery)) return contains;
return 0;
};
const buildDerivedIntentSignals = (entry) => {
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedLight = normalizeSearchText(entry.careInfo && entry.careInfo.light ? entry.careInfo.light : '');
const derivedSignals = new Set((entry.categories || []).map((category) => normalizeSearchText(category)));
Object.entries(SEARCH_INTENT_CONFIG.intents).forEach(([intentId, intentConfig]) => {
const entryHints = normalizeArray(intentConfig.entryHints || []);
if (entryHints.some((hint) => normalizedDescription.includes(hint))) {
derivedSignals.add(intentId);
}
const lightHints = normalizeArray(intentConfig.lightHints || []);
if (lightHints.some((hint) => normalizedLight.includes(hint))) {
derivedSignals.add(intentId);
}
});
return [...derivedSignals];
};
const scoreHybridEntry = (entry, query) => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return 0;
const normalizedName = normalizeSearchText(entry.name || '');
const normalizedBotanical = normalizeSearchText(entry.botanicalName || '');
const normalizedDescription = normalizeSearchText(entry.description || '');
const normalizedCategories = (entry.categories || []).map((category) => normalizeSearchText(category));
const derivedSignals = buildDerivedIntentSignals(entry);
const requestedIntents = detectQueryIntents(normalizedQuery);
let score = 0;
score += Math.max(
scoreTextMatch(normalizedQuery, normalizedName, 140, 100, 64),
scoreTextMatch(normalizedQuery, normalizedBotanical, 130, 96, 58),
);
if (normalizedDescription.includes(normalizedQuery)) {
score += 24;
}
score += fuzzyBonus(normalizedQuery, [normalizedName, normalizedBotanical, ...normalizedCategories]);
let matchedIntentCount = 0;
requestedIntents.forEach((intentId) => {
const categoryHit = normalizedCategories.includes(intentId);
const derivedHit = derivedSignals.includes(intentId);
if (categoryHit) {
score += 92;
matchedIntentCount += 1;
return;
}
if (derivedHit) {
score += 56;
matchedIntentCount += 1;
}
});
if (matchedIntentCount >= 2) {
score += 38 * matchedIntentCount;
} else if (matchedIntentCount === 1) {
score += 10;
}
const queryTokens = [...tokenSetFromQuery(normalizedQuery)];
if (queryTokens.length > 1) {
const searchableText = [
normalizedName,
normalizedBotanical,
normalizedDescription,
...normalizedCategories,
...derivedSignals,
].join(' ');
const tokenHits = queryTokens.filter((token) => searchableText.includes(token)).length;
score += tokenHits * 8;
if (tokenHits === queryTokens.length) {
score += 16;
}
}
return score;
};
const rankHybridEntries = (entries, query, limit = 30) => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) {
return entries.slice(0, limit).map((entry) => ({ entry, score: 0 }));
}
return entries
.map((entry) => ({ entry, score: scoreHybridEntry(entry, normalizedQuery) }))
.filter((candidate) => candidate.score > 0)
.sort((left, right) =>
right.score - left.score ||
left.entry.name.length - right.entry.name.length ||
left.entry.name.localeCompare(right.entry.name))
.slice(0, limit);
};
module.exports = {
normalizeSearchText,
rankHybridEntries,
scoreHybridEntry,
};

View File

@@ -1,7 +1,9 @@
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim();
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5-mini').trim();
const OPENAI_SCAN_MODEL_PRO = (process.env.OPENAI_SCAN_MODEL_PRO || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL_PRO || OPENAI_SCAN_MODEL).trim();
const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4o-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_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
const OPENAI_TIMEOUT_MS = (() => {
@@ -22,8 +24,13 @@ 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 getScanModelChain = (plan) => {
return plan === 'pro' ? OPENAI_SCAN_MODEL_CHAIN_PRO : OPENAI_SCAN_MODEL_CHAIN;
};
const clamp = (value, min, max) => {
return Math.min(max, Math.max(min, value));
};
@@ -197,12 +204,17 @@ const buildIdentifyPrompt = (language, mode) => {
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
: 'Identify the most likely houseplant species from this image with conservative confidence.';
const nameLanguageInstruction = language === 'en'
? '- "name" must be an English common name only. Never return a German or other non-English common name. If no reliable English common name is known, use "botanicalName" as "name" instead of inventing or translating.'
: `- "name" must be strictly written in ${getLanguageLabel(language)}. If a reliable common name in that language is not known, use "botanicalName" as "name" instead of inventing a localized name.`;
return [
`${reviewInstruction}`,
'Return strict JSON only in this shape:',
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
'Rules:',
`- "name", "description", and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
nameLanguageInstruction,
`- "description" and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
'- "confidence" must be between 0 and 1.',
@@ -324,10 +336,11 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
return { payload: null, modelUsed: null, attemptedModels };
};
const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const identifyPlant = async ({ imageUri, language, mode = 'primary', plan = 'free' }) => {
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
const modelChain = getScanModelChain(plan);
const completion = await postChatCompletion({
modelChain: OPENAI_SCAN_MODEL_CHAIN,
modelChain,
imageUri,
messages: [
{
@@ -355,7 +368,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const content = extractMessageContent(completion.payload);
if (!content) {
console.warn('OpenAI identify returned empty content.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
image: summarizeImageUri(imageUri),
});
@@ -365,7 +378,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const parsed = parseContentToJson(content);
if (!parsed) {
console.warn('OpenAI identify returned non-JSON content.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
preview: content.slice(0, 220),
});
@@ -375,7 +388,7 @@ const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
const normalized = normalizeIdentifyResult(parsed, language);
if (!normalized) {
console.warn('OpenAI identify JSON did not match schema.', {
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
model: completion.modelUsed || modelChain[0],
mode,
keys: Object.keys(parsed),
});
@@ -439,8 +452,10 @@ const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
module.exports = {
analyzePlantHealth,
buildIdentifyPrompt,
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
getScanModel: () => OPENAI_SCAN_MODEL_CHAIN[0],
getScanModel: (plan = 'free') => getScanModelChain(plan)[0],
identifyPlant,
isConfigured: () => Boolean(OPENAI_API_KEY),
normalizeIdentifyResult,
};

View File

@@ -1,11 +1,15 @@
const crypto = require('crypto');
const { all, get, run } = require('./sqlite');
const { normalizeSearchText, rankHybridEntries } = require('./hybridSearch');
const DEFAULT_LIMIT = 60;
const MAX_LIMIT = 500;
const MAX_AUDIT_DETAILS = 80;
const WIKIMEDIA_FILEPATH_SEGMENT = 'Special:FilePath/';
const WIKIMEDIA_REDIRECT_BASE = 'https://commons.wikimedia.org/wiki/Special:FilePath/';
const WIKIMEDIA_SEARCH_PREFIX = 'wikimedia-search:';
const LOCAL_PLANT_IMAGE_PREFIX = '/plants/';
const LOCAL_PLANT_IMAGE_PATH_PATTERN = /^\/plants\/[A-Za-z0-9/_-]+\.[A-Za-z0-9]+$/;
class PlantImportValidationError extends Error {
constructor(message, details) {
@@ -19,12 +23,7 @@ const normalizeWhitespace = (value) => {
return value.trim().replace(/\s+/g, ' ');
};
const normalizeKey = (value) => {
return normalizeWhitespace(value)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
};
const normalizeKey = (value) => normalizeSearchText(normalizeWhitespace(value));
const unwrapMarkdownLink = (value) => {
const markdownMatch = value.match(/^\[[^\]]+]\((https?:\/\/[^)]+)\)(.*)$/i);
@@ -41,6 +40,16 @@ const tryDecode = (value) => {
}
};
const decodeRepeatedly = (value, rounds = 3) => {
let current = value;
for (let index = 0; index < rounds; index += 1) {
const decoded = tryDecode(current);
if (decoded === current) break;
current = decoded;
}
return current;
};
const convertWikimediaFilePathUrl = (value) => {
const segmentIndex = value.indexOf(WIKIMEDIA_FILEPATH_SEGMENT);
if (segmentIndex < 0) return null;
@@ -55,12 +64,75 @@ const convertWikimediaFilePathUrl = (value) => {
return `${WIKIMEDIA_REDIRECT_BASE}${encodedFileName}`;
};
const toWikimediaFilePathUrl = (value) => {
if (typeof value !== 'string' || !value.includes('upload.wikimedia.org/wikipedia/commons/')) {
return null;
}
const cleanUrl = value.split(/[?#]/)[0];
const parts = cleanUrl.split('/').filter(Boolean);
if (parts.length < 2) return null;
let fileName = null;
const thumbIndex = parts.indexOf('thumb');
if (thumbIndex >= 0 && parts.length >= thumbIndex + 5) {
fileName = parts[parts.length - 2];
} else {
fileName = parts[parts.length - 1];
}
if (!fileName) return null;
const decoded = tryDecode(fileName).trim();
if (!decoded) return null;
return `${WIKIMEDIA_REDIRECT_BASE}${encodeURIComponent(decoded)}`;
};
const normalizeLocalImagePath = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const withoutQuery = trimmed.split(/[?#]/)[0].replace(/\\/g, '/');
const withLeadingSlash = withoutQuery.startsWith('/') ? withoutQuery : `/${withoutQuery}`;
if (!withLeadingSlash.startsWith(LOCAL_PLANT_IMAGE_PREFIX)) return null;
if (withLeadingSlash.includes('..')) return null;
if (!LOCAL_PLANT_IMAGE_PATH_PATTERN.test(withLeadingSlash)) return null;
return withLeadingSlash;
};
const normalizeWikimediaSearchUri = (value) => {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed.toLowerCase().startsWith(WIKIMEDIA_SEARCH_PREFIX)) return null;
const rawQuery = trimmed.slice(WIKIMEDIA_SEARCH_PREFIX.length).trim();
if (!rawQuery) return null;
const normalizedQuery = normalizeWhitespace(decodeRepeatedly(rawQuery));
if (!normalizedQuery) return null;
return `${WIKIMEDIA_SEARCH_PREFIX}${encodeURIComponent(normalizedQuery)}`;
};
const normalizeImageUri = (rawUri) => {
if (typeof rawUri !== 'string') return null;
const trimmed = rawUri.trim();
if (!trimmed) return null;
const localPath = normalizeLocalImagePath(trimmed);
if (localPath) return localPath;
const wikimediaSearchUri = normalizeWikimediaSearchUri(trimmed);
if (wikimediaSearchUri) return wikimediaSearchUri;
const normalized = unwrapMarkdownLink(trimmed);
const converted = convertWikimediaFilePathUrl(normalized);
const candidate = (converted || normalized).replace(/^http:\/\//i, 'https://');
@@ -142,10 +214,11 @@ const prepareEntry = (rawEntry, index, existingIdMap, preserveExistingIds) => {
errors.push({
index,
field: 'imageUri',
message: 'imageUri is missing or invalid. A valid http(s) URL is required.',
message: 'imageUri is missing or invalid. Use a valid http(s) URL, a local /plants/... path, or wikimedia-search:<query>.',
value: rawEntry?.imageUri ?? null,
});
}
const imageStatus = imageUri && imageUri.startsWith(WIKIMEDIA_SEARCH_PREFIX) ? 'pending' : 'ok';
const categories = toArrayOfStrings(rawEntry?.categories);
const confidence = parseNumber(rawEntry?.confidence, 1);
@@ -168,7 +241,7 @@ const prepareEntry = (rawEntry, index, existingIdMap, preserveExistingIds) => {
name,
botanicalName,
imageUri,
imageStatus: 'ok',
imageStatus,
description,
categories,
careInfo,
@@ -335,11 +408,12 @@ const parseJsonObject = (value) => {
const toApiPlant = (row) => {
const categories = parseJsonArray(row.categories);
const careInfo = parseJsonObject(row.careInfo);
const imageUri = toWikimediaFilePathUrl(row.imageUri) || row.imageUri;
return {
id: row.id,
name: row.name,
botanicalName: row.botanicalName,
imageUri: row.imageUri,
imageUri,
imageStatus: row.imageStatus || 'ok',
description: row.description || '',
categories,
@@ -349,7 +423,7 @@ const toApiPlant = (row) => {
};
const getPlants = async (db, options = {}) => {
const query = typeof options.query === 'string' ? options.query.trim().toLowerCase() : '';
const query = typeof options.query === 'string' ? options.query.trim() : '';
const category = typeof options.category === 'string' ? options.category.trim() : '';
const limitRaw = Number(options.limit);
const limit = Number.isFinite(limitRaw)
@@ -368,15 +442,6 @@ const getPlants = async (db, options = {}) => {
confidence
FROM plants`;
const params = [];
if (query) {
sql += ` WHERE (
LOWER(name) LIKE ?
OR LOWER(botanicalName) LIKE ?
OR LOWER(COALESCE(description, '')) LIKE ?
)`;
const likePattern = `%${query}%`;
params.push(likePattern, likePattern, likePattern);
}
sql += ' ORDER BY name COLLATE NOCASE ASC';
const rows = await all(db, sql, params);
@@ -386,7 +451,12 @@ const getPlants = async (db, options = {}) => {
results = results.filter((plant) => plant.categories.includes(category));
}
return results.slice(0, limit);
if (!query) {
return results.slice(0, limit);
}
return rankHybridEntries(results, query, limit)
.map((candidate) => candidate.entry);
};
const getPlantDiagnostics = async (db) => {
@@ -565,7 +635,7 @@ const rebuildPlantsCatalog = async (db, rawEntries, options = {}) => {
entry.name,
entry.botanicalName,
entry.imageUri,
'ok',
entry.imageStatus,
entry.description,
JSON.stringify(entry.categories),
JSON.stringify(entry.careInfo),
@@ -647,6 +717,8 @@ module.exports = {
ensurePlantSchema,
getPlantDiagnostics,
getPlants,
normalizeKey,
normalizeImageUri,
toWikimediaFilePathUrl,
rebuildPlantsCatalog,
};

131
server/lib/scanGrounding.js Normal file
View File

@@ -0,0 +1,131 @@
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',
];
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)\b/.test(normalized)) return true;
return GERMAN_COMMON_NAME_HINTS.some((hint) => normalized.includes(hint));
};
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(Math.max(aiResult.confidence || 0.6, 0.78), 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 || aiResult.careInfo?.light || 'Unknown',
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
},
},
};
};
module.exports = {
applyCatalogGrounding,
findCatalogMatch,
isLikelyGermanCommonName,
normalizeText,
shouldUseCatalogNameOverride,
};

View File

@@ -0,0 +1,402 @@
const SEARCH_INTENT_CONFIG = {
intents: {
easy: {
aliases: [
'easy',
'easy care',
'easy plant',
'easy plants',
'easy to care',
'beginner',
'beginner plant',
'beginner plants',
'low maintenance',
'hard to kill',
'starter plant',
'starter plants',
'pflegearm',
'pflegeleicht',
'anfanger',
'anfangerpflanze',
'anfangerpflanzen',
'einfach',
'unkompliziert',
'facil cuidado',
'facil',
'principiante',
'planta facil',
'planta resistente',
],
entryHints: [
'easy',
'pflegeleicht',
'robust',
'resilient',
'hardy',
'low maintenance',
'beginner',
'facil',
'resistente',
'uncomplicated',
],
},
low_light: {
aliases: [
'low light',
'dark corner',
'dark room',
'office plant',
'office',
'windowless room',
'shade',
'shady',
'indirect light',
'little light',
'wenig licht',
'dunkle ecke',
'buero',
'buro',
'dunkel',
'schatten',
'halbschatten',
'poca luz',
'oficina',
'rincon oscuro',
'sombra',
],
lightHints: [
'low light',
'low to full light',
'shade',
'partial shade',
'indirect',
'indirect bright',
'bright indirect',
'wenig licht',
'schatten',
'halbschatten',
'indirekt',
'poca luz',
'sombra',
'luz indirecta',
],
},
pet_friendly: {
aliases: [
'pet friendly',
'pet-safe',
'pet safe',
'safe for cats',
'safe for dogs',
'cat safe',
'dog safe',
'non toxic',
'non-toxic',
'haustierfreundlich',
'tierfreundlich',
'katzensicher',
'hundefreundlich',
'mascota',
'pet friendly plant',
'segura para gatos',
'segura para perros',
'no toxica',
'no tóxica',
],
entryHints: [
'pet friendly',
'safe for pets',
'safe for cats',
'safe for dogs',
'tierfreundlich',
'haustierfreundlich',
'mascota',
],
},
air_purifier: {
aliases: [
'air purifier',
'air purifying',
'clean air',
'cleaner air',
'air cleaning',
'air freshening',
'luftreiniger',
'luftreinigend',
'reinigt luft',
'purificador',
'aire limpio',
'purifica aire',
],
entryHints: [
'air purifier',
'air purifying',
'clean air',
'luftreiniger',
'purificador',
],
},
flowering: {
aliases: [
'flowering',
'flowers',
'blooms',
'in bloom',
'bluhend',
'bluht',
'blumen',
'con flores',
'floracion',
],
entryHints: [
'flowering',
'blooms',
'flower',
'bluh',
'flor',
],
},
succulent: {
aliases: [
'succulent',
'succulents',
'cactus',
'cactus-like',
'drought tolerant',
'sukkulente',
'sukkulenten',
'trockenheitsvertraglich',
'trockenheitsvertraeglich',
'suculenta',
'suculentas',
],
entryHints: [
'succulent',
'cactus',
'drought tolerant',
'sukkulent',
'suculenta',
],
},
bright_light: {
aliases: [
'bright light',
'bright room',
'bright spot',
'east window',
'west window',
'sunny room',
'helles licht',
'hell',
'lichtreich',
'fensterplatz',
'mucha luz',
'luz brillante',
],
lightHints: [
'bright light',
'bright indirect',
'bright',
'helles licht',
'helles indirektes licht',
'luz brillante',
],
},
sun: {
aliases: [
'full sun',
'sun',
'sunny window',
'direct sun',
'south window',
'south facing window',
'volle sonne',
'sonnig',
'direkte sonne',
'fenster sud',
'fenster sued',
'fenster süd',
'ventana soleada',
'sol directo',
],
lightHints: [
'full sun',
'sunny',
'direct sun',
'volles sonnenlicht',
'sonnig',
'sol directo',
],
},
high_humidity: {
aliases: [
'high humidity',
'humid',
'bathroom plant',
'bathroom',
'shower room',
'humid room',
'tropical humidity',
'hohe luftfeuchtigkeit',
'feucht',
'badezimmer',
'dusche',
'luftfeucht',
'humedad alta',
'bano',
'baño',
],
entryHints: [
'high humidity',
'humidity',
'humid',
'hohe luftfeuchtigkeit',
'luftfeuchtigkeit',
'humedad alta',
],
},
hanging: {
aliases: [
'hanging',
'trailing',
'hanging basket',
'shelf plant',
'vine plant',
'cascading',
'hangend',
'ampel',
'rankend',
'colgante',
'planta colgante',
],
entryHints: [
'hanging',
'trailing',
'vine',
'hang',
'colgante',
],
},
patterned: {
aliases: [
'patterned',
'patterned leaves',
'striped',
'variegated',
'spotted',
'decorative leaves',
'fancy leaves',
'gemustert',
'muster',
'gestreift',
'bunt',
'variegada',
'rayada',
],
entryHints: [
'patterned',
'striped',
'variegated',
'spotted',
'gemustert',
'gestreift',
],
},
tree: {
aliases: [
'tree',
'indoor tree',
'small tree',
'floor tree',
'zimmerbaum',
'baum',
'arbol',
'árbol',
],
entryHints: [
'tree',
'baum',
'arbol',
],
},
large: {
aliases: [
'large',
'big plant',
'tall plant',
'statement plant',
'floor plant',
'oversized plant',
'gross',
'groß',
'grosse pflanze',
'hohe pflanze',
'planta grande',
'planta alta',
],
entryHints: [
'large',
'big',
'tall',
'gross',
'groß',
'grande',
],
},
medicinal: {
aliases: [
'medicinal',
'healing plant',
'herb',
'kitchen herb',
'tea herb',
'apothecary plant',
'heilpflanze',
'heilkraut',
'kraut',
'medicinal plant',
'medicinal herb',
'medicinales',
'hierba',
'hierba medicinal',
],
entryHints: [
'medicinal',
'herb',
'heil',
'kraut',
'hierba',
],
},
},
noiseTokens: [
'plant',
'plants',
'pflanze',
'pflanzen',
'planta',
'plantas',
'for',
'fur',
'fuer',
'para',
'mit',
'with',
'and',
'und',
'y',
'the',
'der',
'die',
'das',
'el',
'la',
'de',
'a',
'an',
],
};
module.exports = {
SEARCH_INTENT_CONFIG,
};

72
server/lib/storage.js Normal file
View File

@@ -0,0 +1,72 @@
const Minio = require('minio');
const crypto = require('crypto');
const getTrimmedEnv = (name, fallback = '') => String(process.env[name] ?? fallback).trim();
const MINIO_ENDPOINT = getTrimmedEnv('MINIO_ENDPOINT');
const MINIO_PORT = Number(process.env.MINIO_PORT || 9000);
const MINIO_USE_SSL = process.env.MINIO_USE_SSL === 'true';
const MINIO_ACCESS_KEY = getTrimmedEnv('MINIO_ACCESS_KEY');
const MINIO_SECRET_KEY = getTrimmedEnv('MINIO_SECRET_KEY');
const MINIO_BUCKET = getTrimmedEnv('MINIO_BUCKET', 'plant-images') || 'plant-images';
const isStorageConfigured = () => Boolean(MINIO_ENDPOINT && MINIO_ACCESS_KEY && MINIO_SECRET_KEY);
const getMinioPublicUrl = () =>
getTrimmedEnv('MINIO_PUBLIC_URL', `http://${MINIO_ENDPOINT}:${MINIO_PORT}`).replace(/\/$/, '');
const getClient = () => {
if (!isStorageConfigured()) {
throw new Error('Image storage is not configured.');
}
return new Minio.Client({
endPoint: MINIO_ENDPOINT,
port: MINIO_PORT,
useSSL: MINIO_USE_SSL,
accessKey: MINIO_ACCESS_KEY,
secretKey: MINIO_SECRET_KEY,
});
};
const ensureStorageBucket = async () => {
const client = getClient();
const exists = await client.bucketExists(MINIO_BUCKET);
if (!exists) {
await client.makeBucket(MINIO_BUCKET);
const policy = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${MINIO_BUCKET}/*`],
},
],
});
await client.setBucketPolicy(MINIO_BUCKET, policy);
console.log(`MinIO bucket '${MINIO_BUCKET}' created with public read policy.`);
}
};
const uploadImage = async (base64Data, contentType = 'image/jpeg') => {
const client = getClient();
const rawExtension = contentType.split('/')[1] || 'jpg';
const extension = rawExtension === 'jpeg' ? 'jpg' : rawExtension;
const filename = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.${extension}`;
const buffer = Buffer.from(base64Data, 'base64');
await client.putObject(MINIO_BUCKET, filename, buffer, buffer.length, {
'Content-Type': contentType,
});
const url = `${getMinioPublicUrl()}/${MINIO_BUCKET}/${filename}`;
return { url, filename };
};
module.exports = {
ensureStorageBucket,
uploadImage,
isStorageConfigured,
};