feat: initialize project with docker-compose infrastructure and server application logic
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user