feat: initialize project with docker-compose infrastructure and server application logic

This commit is contained in:
2026-04-08 00:11:24 +02:00
parent 1b40f1eb1b
commit 8d90d97182
3 changed files with 343 additions and 324 deletions

View File

@@ -41,7 +41,8 @@ services:
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-https://greenlenspro.com/storage}
OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini} OPENAI_SCAN_MODEL: ${OPENAI_SCAN_MODEL:-gpt-5-mini}
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-5-mini} OPENAI_SCAN_MODEL_PRO: ${OPENAI_SCAN_MODEL_PRO:-gpt-5.4}
OPENAI_HEALTH_MODEL: ${OPENAI_HEALTH_MODEL:-gpt-4o-mini}
REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-} REVENUECAT_WEBHOOK_SECRET: ${REVENUECAT_WEBHOOK_SECRET:-}
REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro} REVENUECAT_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}

View File

@@ -180,7 +180,7 @@ const toPlantResult = (entry, confidence) => {
}; };
}; };
const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => { const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false, { silent = false } = {}) => {
if (!Array.isArray(entries) || entries.length === 0) return null; if (!Array.isArray(entries) || entries.length === 0) return null;
const baseHash = hashString(`${imageUri || ''}|${entries.length}`); const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
const index = baseHash % entries.length; const index = baseHash % entries.length;
@@ -188,11 +188,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
const confidence = preferHighConfidence const confidence = preferHighConfidence
? 0.22 + ((baseHash % 3) / 100) ? 0.22 + ((baseHash % 3) / 100)
: 0.18 + ((baseHash % 7) / 100); : 0.18 + ((baseHash % 7) / 100);
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', { if (!silent) {
plant: entries[index]?.name, console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
confidence, plant: entries[index]?.name,
imageHint: (imageUri || '').slice(0, 80), confidence,
}); imageHint: (imageUri || '').slice(0, 80),
});
}
return toPlantResult(entries[index], confidence); return toPlantResult(entries[index], confidence);
}; };
@@ -637,7 +639,7 @@ app.post('/v1/scan', async (request, response) => {
const accountSnapshot = await getAccountSnapshot(db, userId); const accountSnapshot = await getAccountSnapshot(db, userId);
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free'; const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
const catalogEntries = await getPlants(db, { limit: 500 }); const catalogEntries = await getPlants(db, { limit: 500 });
let result = pickCatalogFallback(catalogEntries, imageUri, false); let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
let usedOpenAi = false; let usedOpenAi = false;
if (isOpenAiConfigured()) { if (isOpenAiConfigured()) {
@@ -662,7 +664,10 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-primary'); modelPath.push('openai-primary');
if (grounded.grounded) modelPath.push('catalog-grounded-primary'); if (grounded.grounded) modelPath.push('catalog-grounded-primary');
} else { } else {
console.warn(`OpenAI primary identification returned null for user ${userId}`); console.warn(`OpenAI primary identification returned null for user ${userId} — using catalog fallback.`, {
attemptedModels: openAiPrimary?.attemptedModels,
plant: result?.name,
});
modelPath.push('openai-primary-failed'); modelPath.push('openai-primary-failed');
modelPath.push('catalog-primary-fallback'); modelPath.push('catalog-primary-fallback');
} }
@@ -711,11 +716,13 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-review'); modelPath.push('openai-review');
if (grounded.grounded) modelPath.push('catalog-grounded-review'); if (grounded.grounded) modelPath.push('catalog-grounded-review');
} 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,
});
modelPath.push('openai-review-failed'); modelPath.push('openai-review-failed');
} }
} else { } else {
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true); const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true });
if (reviewFallback) { if (reviewFallback) {
result = reviewFallback; result = reviewFallback;
} }
@@ -827,7 +834,13 @@ app.post('/v1/health-check', async (request, response) => {
}); });
const analysis = analysisResponse?.analysis; const analysis = analysisResponse?.analysis;
if (!analysis) { if (!analysis) {
const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.'); console.warn('Health check analysis was null — all models returned unusable output.', {
attemptedModels: analysisResponse?.attemptedModels,
modelUsed: analysisResponse?.modelUsed,
});
const error = new Error(
`Health check AI failed. Tried: ${(analysisResponse?.attemptedModels || []).join(', ')}. Verify API key, model access, and network.`
);
error.code = 'PROVIDER_ERROR'; error.code = 'PROVIDER_ERROR';
throw error; throw error;
} }

View File

@@ -137,15 +137,16 @@ const normalizeHealthAnalysis = (raw, language) => {
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8); const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10); const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) { // Use safe defaults instead of returning null — bad/partial JSON falls through
return null; // to the graceful "Uncertain analysis" fallback at line 164 rather than
} // propagating null → PROVIDER_ERROR to the caller.
const score = scoreRaw ?? 50;
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical' const status = (statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical')
? statusRaw ? statusRaw
: 'watch'; : 'watch';
const issuesInput = Array.isArray(issuesRaw) ? issuesRaw : [];
const likelyIssues = issuesRaw const likelyIssues = issuesInput
.map((entry) => { .map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const title = getString(entry.title); const title = getString(entry.title);
@@ -168,7 +169,7 @@ const normalizeHealthAnalysis = (raw, language) => {
? 'La IA no pudo extraer senales de salud estables.' ? 'La IA no pudo extraer senales de salud estables.'
: 'AI could not extract stable health signals.'; : 'AI could not extract stable health signals.';
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
likelyIssues: [ likelyIssues: [
{ {
@@ -191,7 +192,7 @@ const normalizeHealthAnalysis = (raw, language) => {
} }
return { return {
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)), overallHealthScore: Math.round(clamp(score, 0, 100)),
status, status,
likelyIssues, likelyIssues,
actionsNow: actionsNowRaw, actionsNow: actionsNowRaw,
@@ -305,10 +306,14 @@ const postChatCompletion = async ({ modelChain, messages, imageUri, temperature
if (!response.ok) { if (!response.ok) {
const body = await response.text(); const body = await response.text();
let parsedError = {};
try { parsedError = JSON.parse(body); } catch {}
console.warn('OpenAI request HTTP error.', { console.warn('OpenAI request HTTP error.', {
status: response.status, status: response.status,
model, model,
endpoint: OPENAI_CHAT_COMPLETIONS_URL, endpoint: OPENAI_CHAT_COMPLETIONS_URL,
openAiCode: parsedError?.error?.code,
openAiMessage: parsedError?.error?.message,
image: summarizeImageUri(imageUri), image: summarizeImageUri(imageUri),
bodyPreview: body.slice(0, 300), bodyPreview: body.slice(0, 300),
}); });