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}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
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_PRO_ENTITLEMENT_ID: ${REVENUECAT_PRO_ENTITLEMENT_ID:-pro}
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;
const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
const index = baseHash % entries.length;
@@ -188,11 +188,13 @@ const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) =>
const confidence = preferHighConfidence
? 0.22 + ((baseHash % 3) / 100)
: 0.18 + ((baseHash % 7) / 100);
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
plant: entries[index]?.name,
confidence,
imageHint: (imageUri || '').slice(0, 80),
});
if (!silent) {
console.warn('Using hash-based catalog fallback — OpenAI is unavailable or returned null.', {
plant: entries[index]?.name,
confidence,
imageHint: (imageUri || '').slice(0, 80),
});
}
return toPlantResult(entries[index], confidence);
};
@@ -637,7 +639,7 @@ app.post('/v1/scan', async (request, response) => {
const accountSnapshot = await getAccountSnapshot(db, userId);
const scanPlan = accountSnapshot.plan === 'pro' ? 'pro' : 'free';
const catalogEntries = await getPlants(db, { limit: 500 });
let result = pickCatalogFallback(catalogEntries, imageUri, false);
let result = pickCatalogFallback(catalogEntries, imageUri, false, { silent: true });
let usedOpenAi = false;
if (isOpenAiConfigured()) {
@@ -662,7 +664,10 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-primary');
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
} 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('catalog-primary-fallback');
}
@@ -711,11 +716,13 @@ app.post('/v1/scan', async (request, response) => {
modelPath.push('openai-review');
if (grounded.grounded) modelPath.push('catalog-grounded-review');
} 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');
}
} else {
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true);
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true, { silent: true });
if (reviewFallback) {
result = reviewFallback;
}
@@ -827,7 +834,13 @@ app.post('/v1/health-check', async (request, response) => {
});
const analysis = analysisResponse?.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';
throw error;
}

View File

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