diff --git a/controller/healthPlanController.js b/controller/healthPlanController.js index f270c5b..76dc338 100644 --- a/controller/healthPlanController.js +++ b/controller/healthPlanController.js @@ -1,13 +1,48 @@ // controllers/healthPlanController.js - -// Node 18+ has global fetch; if you're on Node 16, uncomment: -// const fetch = require("node-fetch"); +const Groq = require('groq-sdk'); // [TEMP-DB-OFF] keep import for easy revert; safe to leave unused const supabase = require("../dbConnection.js"); -const AI_BASE = - process.env.AI_BASE_URL || "http://localhost:8000/ai-model/medical-report"; +const HEALTH_PLAN_PROMPT = `You are a certified fitness coach and clinical dietitian. +Generate a personalised 4-week health and wellness plan based on the user's medical data and goals. +Respond with ONLY valid JSON — no markdown, no backticks, no explanation. + +Return exactly this JSON shape: +{ + "weekly_plan": [ + { + "week": 1, + "target_calories_per_day": 1800, + "focus": "Building baseline activity", + "workouts": ["20 min walk", "10 min stretching", "Light bodyweight squats x10"], + "meal_notes": "Prioritise lean protein and vegetables; avoid processed foods.", + "reminders": ["Drink 8 glasses of water", "Sleep 7–8 hours"] + } + ], + "suggestion": "One paragraph of personalised advice based on the medical profile.", + "progress_analysis": "Brief note on expected progress over 4 weeks." +}`; + +function buildHealthPlanPrompt(medicalReport, healthGoal, healthSurvey) { + const report = Array.isArray(medicalReport) ? medicalReport[0] : medicalReport; + const obesity = report?.obesity_prediction?.obesity_level || "Unknown"; + const diabetes = report?.diabetes_prediction?.diabetes ? "Yes" : "No"; + const info = report?.health_info || healthSurvey || {}; + + return `User profile: +- Gender: ${info.gender || "Not specified"} +- Age: ${info.age || "Not specified"} +- Height: ${info.height ? `${info.height}m` : "Not specified"} +- Weight: ${info.weight ? `${info.weight}kg` : "Not specified"} +- Obesity level: ${obesity} +- Diabetes: ${diabetes} +- Workout days per week: ${healthGoal.days_per_week} +- Preferred workout place: ${healthGoal.workout_place || "any"} +- Target weight: ${healthGoal.target_weight ? `${healthGoal.target_weight}kg` : "Not specified"} + +Generate a 4-week health plan for this person.`; +} // ---------- helpers ---------- const toNum = (x) => { @@ -197,54 +232,43 @@ const generateWeeklyPlan = async (req, res) => { // survey (optional for AI payload) const health_survey = buildHealthSurvey(body.survey_data); - const payload = { - medical_report: Array.isArray(body.medical_report) - ? body.medical_report.map(normalizeMedicalReportForPlan) - : [normalizeMedicalReportForPlan(body.medical_report)], - survey_data: health_survey || undefined, - health_goal, - followup_qa: null, - }; - Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]); - - // call AI - const aiResponse = await fetch(`${AI_BASE}/plan/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); + const medicalReportNormalized = Array.isArray(body.medical_report) + ? body.medical_report.map(normalizeMedicalReportForPlan) + : [normalizeMedicalReportForPlan(body.medical_report)]; - const text = await aiResponse.text(); let result; - try { - result = JSON.parse(text); - } catch { - result = text; - } - if (!aiResponse.ok) { - const weeklyPlan = buildFallbackWeeklyPlan({ - medicalReport: payload.medical_report[0], - healthGoal: health_goal - }); - return res.status(200).json({ - plan_id: null, - suggestion: "Generated a local roadmap because the AI plan service could not process the request.", - weekly_plan: weeklyPlan, - progress_analysis: null, - goal: derivePlanGoal(weeklyPlan) ?? null, - length: weeklyPlan.length, - }); + if (process.env.GROQ_API_KEY) { + const userMessage = buildHealthPlanPrompt(medicalReportNormalized, health_goal, health_survey); + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY, timeout: 30000 }); + + try { + const completion = await groq.chat.completions.create({ + model: "llama-3.3-70b-versatile", + messages: [ + { role: "system", content: HEALTH_PLAN_PROMPT }, + { role: "user", content: userMessage }, + ], + max_tokens: 2000, + }); + + const rawText = completion.choices[0]?.message?.content || ""; + const cleaned = rawText.replace(/```json\s*/gi, "").replace(/```/g, "").trim(); + const jsonMatch = cleaned.match(/\{[\s\S]*\}/); + result = JSON.parse(jsonMatch ? jsonMatch[0] : cleaned); + } catch (groqErr) { + console.warn("[healthPlanController] Groq failed, using fallback:", groqErr.message); + } } - if (!result.weekly_plan) { + if (!result?.weekly_plan) { const weeklyPlan = buildFallbackWeeklyPlan({ - medicalReport: payload.medical_report[0], - healthGoal: health_goal + medicalReport: medicalReportNormalized[0], + healthGoal: health_goal, }); return res.status(200).json({ plan_id: null, - suggestion: "Generated a local roadmap because the AI plan service did not return a weekly plan.", + suggestion: "Generated a local roadmap based on your health profile.", weekly_plan: weeklyPlan, progress_analysis: null, goal: derivePlanGoal(weeklyPlan) ?? null, diff --git a/controller/mealPlanAIController.js b/controller/mealPlanAIController.js index 7f6e95d..6436d43 100644 --- a/controller/mealPlanAIController.js +++ b/controller/mealPlanAIController.js @@ -3,10 +3,10 @@ const { saveMealPlan } = require('../model/aiMealPlanModel'); const generateAIMealPlan = async (req, res) => { try { - const dietType = typeof req.body.dietType === 'string' ? req.body.dietType : 'balanced'; - const goal = typeof req.body.goal === 'string' ? req.body.goal : 'maintain weight'; + const dietType = req.body.dietType || req.body.dietary_preference || 'balanced'; + const goal = req.body.goal || 'maintain weight'; const allergies = Array.isArray(req.body.allergies) ? req.body.allergies : []; - const calorieTarget = typeof req.body.calorieTarget === 'number' ? req.body.calorieTarget : 1800; + const calorieTarget = Number(req.body.calorieTarget || req.body.calorie_target) || 1800; const cuisine = typeof req.body.cuisine === 'string' ? req.body.cuisine : 'any'; const healthConditions = Array.isArray(req.body.healthConditions) ? req.body.healthConditions : []; const mealTexture = ['regular', 'soft', 'pureed'].includes(req.body.mealTexture) ? req.body.mealTexture : 'regular'; diff --git a/package-lock.json b/package-lock.json index ecd582f..8801101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "base64-arraybuffer": "^1.0.2", "bcrypt": "5.1.1", "bcryptjs": "2.4.3", + "chromadb": "^3.4.3", "cors": "2.8.5", "crypto": "1.0.1", "dotenv": "16.6.1", @@ -39,6 +40,7 @@ }, "devDependencies": { "chai": "6.0.1", + "concurrently": "^9.2.1", "eslint": "8.57.0", "jest": "^29.7.0", "mocha": "11.7.2", @@ -3021,6 +3023,114 @@ "node": ">=10" } }, + "node_modules/chromadb": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/chromadb/-/chromadb-3.4.3.tgz", + "integrity": "sha512-AouHyTh70Ux1w5TKDmk54wGAszY7NtU/DXyC7o3qGuE8b+b1nT/W3+s8UVd9XwqGZIR4A76+g4u7yJuV/WU3OA==", + "license": "Apache-2.0", + "dependencies": { + "semver": "^7.7.1" + }, + "bin": { + "chroma": "dist/cli.mjs" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "chromadb-js-bindings-darwin-arm64": "^1.3.3", + "chromadb-js-bindings-darwin-x64": "^1.3.3", + "chromadb-js-bindings-linux-arm64-gnu": "^1.3.3", + "chromadb-js-bindings-linux-x64-gnu": "^1.3.3", + "chromadb-js-bindings-win32-x64-msvc": "^1.3.3" + } + }, + "node_modules/chromadb-js-bindings-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/chromadb-js-bindings-darwin-arm64/-/chromadb-js-bindings-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-pDdWCcR0hCz3pSDiIijBito7YG6FumewfzYE2mIjSwjrO1CKSfQKLwDdTVK4ZNpVGQa16ePoMX+pg7ShSUARJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/chromadb-js-bindings-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/chromadb-js-bindings-darwin-x64/-/chromadb-js-bindings-darwin-x64-1.3.4.tgz", + "integrity": "sha512-5vKVFXSFo+S9qC9by/7oofjcXqQK4IGHiUnPrvTfc7B52gNlPSKeyDO1wha556COc3KtXSZjICEH0nYBJ8UXng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/chromadb-js-bindings-linux-arm64-gnu": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/chromadb-js-bindings-linux-arm64-gnu/-/chromadb-js-bindings-linux-arm64-gnu-1.3.4.tgz", + "integrity": "sha512-ELiqDZzU5mZd1BvZugGrhoMkVeNyQaa+PGizd06WIpIJVoHY+S+9ZBjdeM6ZkRnL3vTxD79XBrosxNWhzqy/kg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/chromadb-js-bindings-linux-x64-gnu": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/chromadb-js-bindings-linux-x64-gnu/-/chromadb-js-bindings-linux-x64-gnu-1.3.4.tgz", + "integrity": "sha512-HmTUe7PEIaBngCQ70Yyle81z2wYyg9OMgJYyRUNBYCBp4ED6zTmdaOro41Vs/c/h2UyQeBXFIKNmHDeD2Nr2fw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/chromadb-js-bindings-win32-x64-msvc": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/chromadb-js-bindings-win32-x64-msvc/-/chromadb-js-bindings-win32-x64-msvc-1.3.4.tgz", + "integrity": "sha512-punrofQRzKspNMxHmv24qoPeRycV2T3KfedekGV4lOuPPG14i6qggS4x/OWLSAK7ozPfBbrzCejvCa5wfvujhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -3228,6 +3338,31 @@ "typedarray": "^0.0.6" } }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -8022,6 +8157,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -8169,6 +8314,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -8833,6 +8991,16 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", diff --git a/package.json b/package.json index e8831a6..5995263 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", + "dev:all": "concurrently --names \"API,AI\" --prefix-colors \"blue,green\" \"npm run dev\" \"cd ../NutriHelp-AI && python3 run.py\"", + "start:all": "concurrently --names \"API,AI\" --prefix-colors \"blue,green\" \"npm run start\" \"cd ../NutriHelp-AI && python3 run.py\"", "test": "mocha ./test/**/*.test.js --timeout 5000 --exit --ignore ./test/contractTests/**", "test:unit": "mocha ./test/unit/**/*.test.js --timeout 5000 --exit", "test:contract": "jest ./test/contractTests/ --testTimeout=10000 --forceExit", @@ -21,6 +23,7 @@ }, "devDependencies": { "chai": "6.0.1", + "concurrently": "^9.2.1", "eslint": "8.57.0", "jest": "^29.7.0", "mocha": "11.7.2", @@ -37,6 +40,7 @@ "base64-arraybuffer": "^1.0.2", "bcrypt": "5.1.1", "bcryptjs": "2.4.3", + "chromadb": "^3.4.3", "cors": "2.8.5", "crypto": "1.0.1", "dotenv": "16.6.1", diff --git a/routes/ai/chatbot.js b/routes/ai/chatbot.js new file mode 100644 index 0000000..d0ed17f --- /dev/null +++ b/routes/ai/chatbot.js @@ -0,0 +1,368 @@ +const express = require('express'); +const Groq = require('groq-sdk'); +const { ChromaClient } = require('chromadb'); +const { v4: uuidv4 } = require('uuid'); +const multer = require('multer'); + +const router = express.Router(); +const upload = multer({ storage: multer.memoryStorage() }); + +// --- Settings (match Python ActiveAISettings defaults exactly) --- +const GROQ_MODEL = process.env.GROQ_MODEL || 'llama-3.1-8b-instant'; +const GROQ_TEMPERATURE = parseFloat(process.env.GROQ_TEMPERATURE || '0.0'); +const GROQ_TOP_P = parseFloat(process.env.GROQ_TOP_P || '1.0'); +const RAG_N_RESULTS = parseInt(process.env.RAG_N_RESULTS || '5'); +const RAG_DISTANCE_THRESHOLD = parseFloat(process.env.RAG_DISTANCE_THRESHOLD || '0.8'); +const RAG_RELAXED_DISTANCE_THRESHOLD = parseFloat(process.env.RAG_RELAXED_DISTANCE_THRESHOLD || '1.6'); +const RAG_COLLECTION = process.env.RAG_COLLECTION || 'aus_food_nutrition'; + +// --- Exact system prompts from Python --- +const GROUNDING_SYSTEM_PROMPT = + 'You are NutriBot, a nutrition assistant.\n' + + 'Strict grounding rules (follow exactly):\n' + + '1) Use ONLY the information provided in the context below.\n' + + '2) Answer ONLY using the provided context.\n' + + '3) Do not add information not present in the context.\n' + + '4) Do not rephrase, embellish, or expand unnecessarily.\n' + + '5) Be concise and direct.\n' + + "6) If the context is insufficient, reply exactly: 'I don\\'t have enough information on that topic in my knowledge base.'"; + +const DOMAIN_CHAT_SYSTEM_PROMPT = + 'You are NutriBot, the NutriHelp assistant.\n' + + 'You only help with nutrition, meals, food choices, healthy eating, calorie guidance, nutrients, ' + + 'dietary habits, food scanning follow-up, and user health goals related to diet and lifestyle.\n' + + 'If the user asks something outside that scope, do not answer it as a general-purpose assistant. ' + + 'Instead, briefly explain that you focus on nutrition, meals, and healthy eating, then invite the user ' + + 'to ask a food or nutrition question.\n' + + 'Keep replies concise, practical, and friendly.'; + +const SAFE_REPLY = 'Nutribot is currently unavailable.'; + +const DOMAIN_REDIRECT_REPLY = + "I'm here to help with nutrition, meals, healthy eating, and your food-related health goals. " + + 'Try asking about foods, calories, nutrients, meal ideas, or diet guidance.'; + +// --- Exact keyword lists from Python _is_nutrition_domain_prompt --- +const NUTRITION_KEYWORDS = [ + 'nutrition', 'nutrient', 'nutrients', 'calorie', 'calories', 'protein', 'carb', 'carbs', + 'carbohydrate', 'carbohydrates', 'fat', 'fats', 'fibre', 'fiber', 'vitamin', 'vitamins', + 'mineral', 'minerals', 'iron', 'calcium', 'sodium', 'cholesterol', 'salt', 'sugar', 'food', + 'foods', 'meal', 'meals', 'meal plan', 'meal planning', 'diet', 'dietary', 'healthy eating', + 'weight loss', 'weight gain', 'breakfast', 'lunch', 'dinner', 'snack', 'snacks', 'recipe', + 'recipes', 'ingredient', 'ingredients', 'serving', 'portion', 'scan', 'dish', 'older adults', + 'seniors', 'hydration', 'water intake', 'diabetes', 'blood pressure', 'vegan', 'vegetarian', + 'keto', 'low-carb', 'high-protein', 'gluten-free', 'dairy-free', 'lactose-free', 'nut-free', + 'allergen', 'allergens', 'coeliac', 'celiac', +]; + +const FOOD_TERMS = [ + 'fruit', 'fruits', 'vegetable', 'vegetables', 'berry', 'berries', 'strawberry', 'strawberries', + 'apple', 'apples', 'banana', 'bananas', 'orange', 'oranges', 'grape', 'grapes', 'avocado', + 'avocados', 'broccoli', 'carrot', 'carrots', 'spinach', 'salad', 'salads', 'rice', 'pasta', + 'bread', 'cereal', 'cereals', 'gluten', 'wheat', 'barley', 'rye', 'malt', 'flour', 'noodle', + 'noodles', 'oat', 'oats', 'oatmeal', 'yogurt', 'yoghurt', 'milk', 'cheese', 'egg', 'eggs', + 'chicken', 'beef', 'pork', 'fish', 'salmon', 'tuna', 'sushi', 'sashimi', 'tofu', 'beans', + 'lentils', 'nuts', 'almonds', 'smoothie', 'smoothies', 'juice', 'water', 'coffee', 'tea', + 'ice cream', 'pizza', 'burger', 'hamburger', +]; + +const LIFESTYLE_TERMS = [ + 'exercise', 'gym', 'workout', 'workouts', 'walking', 'hydration', 'sleep and diet', +]; + +const SENSITIVITY_KEYWORDS = [ + 'allergy', 'allergies', 'allergic', 'intolerance', 'intolerant', +]; + +const COMMON_FOOD_ALLERGENS = [ + 'peanut', 'nut', 'milk', 'dairy', 'egg', 'soy', 'sesame', 'fish', 'shellfish', 'lactose', +]; + +// --- Exact meta context markers from Python _looks_like_meta_context --- +const META_MARKERS = [ + 'structured prompting approach', + 'content-type: application/json', + 'json body', + 'example successful response', + 'the recipe engine does not work directly', + 'langchain', + 'redis memory', + 'serp', + 'openai models', +]; + +// --- Exact weak response markers from Python _is_weak_rag_response --- +const WEAK_MARKERS = [ + "i don't know", + 'i do not know', + 'unable to verify', + 'unable to answer', + 'unable to confirm', + 'cannot verify', + 'not enough information', + 'insufficient information', + 'no relevant nutrition information', + 'knowledge base', +]; + +// --- Helper: word-boundary match (replicates Python re.search with \b) --- +function hasWordBoundaryTerm(terms, text) { + return terms.some((term) => { + const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`\\b${escaped}\\b`).test(text); + }); +} + +// --- Exact _is_social_prompt logic --- +function isSocialPrompt(prompt) { + const clean = (prompt || '').trim().toLowerCase(); + if (!clean) return false; + const patterns = [ + /^(hi|hello|hey|good morning|good afternoon|good evening)\b/, + /^(thanks|thank you|thx)\b/, + /^(how are you|how are you doing)\b/, + ]; + return patterns.some((p) => p.test(clean)); +} + +// --- Exact _is_nutrition_domain_prompt logic --- +function isNutritionDomainPrompt(prompt) { + const clean = (prompt || '').trim().toLowerCase(); + if (!clean) return false; + + if ( + hasWordBoundaryTerm(NUTRITION_KEYWORDS, clean) || + hasWordBoundaryTerm(FOOD_TERMS, clean) || + hasWordBoundaryTerm(LIFESTYLE_TERMS, clean) + ) return true; + + if (hasWordBoundaryTerm(SENSITIVITY_KEYWORDS, clean) && hasWordBoundaryTerm(COMMON_FOOD_ALLERGENS, clean)) + return true; + + const consumptionPatterns = [ + /\b(should|can)\s+i\s+(eat|drink|have)\s+[\w\s-]+\b/, + /\b(is|are)\s+[\w\s-]+\s+(ok|okay|safe)\s+to\s+(eat|drink|have)\b/, + ]; + if (consumptionPatterns.some((p) => p.test(clean))) return true; + + const foodHealthPatterns = [ + /\b(is|are)\s+[\w\s-]+\s+(healthy|good for (my|your|our)?\s*health)\b/, + /\b(benefits?|nutrition facts?|nutritional value)\s+of\s+[\w\s-]+\b/, + /\bwhat\s+(are|is)\s+the\s+(benefits?|nutrition|nutrients?)\s+of\s+[\w\s-]+\b/, + /\bhow\s+(healthy|nutritious)\s+is\s+[\w\s-]+\b/, + ]; + return hasWordBoundaryTerm(FOOD_TERMS, clean) && foodHealthPatterns.some((p) => p.test(clean)); +} + +// --- Exact _looks_like_meta_context logic --- +function looksLikeMetaContext(doc) { + const lower = doc.toLowerCase(); + return META_MARKERS.some((m) => lower.includes(m)); +} + +// --- Exact _is_weak_rag_response logic --- +function isWeakRagResponse(response) { + if (!response) return true; + const clean = response.trim(); + if (!clean) return true; + const lower = clean.toLowerCase(); + if (WEAK_MARKERS.some((m) => lower.includes(m))) return true; + const veryShort = clean.length < 20; + const hasNutritionalSignal = /\d|%|serving|vegetable|fruit|diet|nutrition|guideline/i.test(clean); + return veryShort && !hasNutritionalSignal; +} + +// --- ChromaDB client factory --- +function buildChromaClient() { + return new ChromaClient({ + path: 'https://api.trychroma.com', + auth: { + provider: 'token', + credentials: process.env.CHROMA_API_KEY, + providerOptions: { headerType: 'X_CHROMA_TOKEN' }, + }, + tenant: process.env.CHROMA_TENANT, + database: process.env.CHROMA_DATABASE, + }); +} + +// --- Exact retrieve_ranked logic --- +async function retrieveRanked(query, limit) { + limit = limit || RAG_N_RESULTS; + const fetchLimit = Math.max(limit, limit * 3); + try { + const client = buildChromaClient(); + const collection = await client.getOrCreateCollection({ name: RAG_COLLECTION }); + const result = await collection.query({ queryTexts: [query], nResults: fetchLimit, include: ['documents', 'distances'] }); + const documents = result.documents?.[0] || []; + const distances = result.distances?.[0] || []; + + const ranked = []; + const seenDocs = new Set(); + for (let i = 0; i < documents.length; i++) { + const doc = documents[i]; + const dist = distances[i]; + if (!doc || dist == null) continue; + const normalized = doc.replace(/\s+/g, ' ').trim().toLowerCase(); + if (seenDocs.has(normalized)) continue; + seenDocs.add(normalized); + ranked.push({ doc, dist: parseFloat(dist) }); + if (ranked.length >= limit) break; + } + return ranked; + } catch { + return []; + } +} + +// --- Exact retrieve_for_rag logic --- +async function retrieveForRag(query, nResults, distanceThreshold, relaxedDistanceThreshold) { + const limit = nResults || RAG_N_RESULTS; + const strict = distanceThreshold != null ? distanceThreshold : RAG_DISTANCE_THRESHOLD; + let relaxed = relaxedDistanceThreshold != null ? relaxedDistanceThreshold : RAG_RELAXED_DISTANCE_THRESHOLD; + if (relaxed < strict) relaxed = strict; + + let ranked = await retrieveRanked(query, limit); + if (!ranked.length) return []; + + // Filter meta contexts + const filtered = ranked.filter(({ doc }) => !looksLikeMetaContext(doc)); + if (!filtered.length) return []; + ranked = filtered; + + // Strict threshold first + const strictContexts = ranked.filter(({ dist }) => dist <= strict).map(({ doc }) => doc); + if (strictContexts.length) return strictContexts; + + // Relaxed threshold fallback + const relaxedContexts = ranked.filter(({ dist }) => dist <= relaxed).map(({ doc }) => doc); + if (relaxedContexts.length) return relaxedContexts; + + return []; +} + +// --- Simple retrieve (no distance filtering, used by health plan) --- +async function retrieve(query, nResults) { + nResults = nResults || 4; + try { + const client = buildChromaClient(); + const collection = await client.getOrCreateCollection({ name: RAG_COLLECTION }); + const result = await collection.query({ queryTexts: [query], nResults }); + return result.documents?.[0] || []; + } catch { + return []; + } +} + +// --- Groq chat --- +async function chat(prompt, systemPrompt, temperature) { + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + const temp = temperature != null ? temperature : GROQ_TEMPERATURE; + const messages = []; + if (systemPrompt) messages.push({ role: 'system', content: systemPrompt }); + messages.push({ role: 'user', content: prompt }); + const res = await groq.chat.completions.create({ + messages, + model: GROQ_MODEL, + temperature: temp, + top_p: GROQ_TOP_P, + }); + return res.choices[0]?.message?.content || SAFE_REPLY; +} + +// --- Exact _build_grounded_user_prompt --- +function buildGroundedUserPrompt(contexts, question) { + const joinedContext = contexts.join('\n\n'); + return `CONTEXT:\n${joinedContext}\n\nQUESTION: ${question}\n\nAnswer using only the provided context.`; +} + +// --- Exact _chat_with_domain_guard --- +async function chatWithDomainGuard(prompt) { + if (isSocialPrompt(prompt)) { + return chat(prompt, DOMAIN_CHAT_SYSTEM_PROMPT); + } + if (!isNutritionDomainPrompt(prompt)) { + return DOMAIN_REDIRECT_REPLY; + } + return chat(prompt, DOMAIN_CHAT_SYSTEM_PROMPT); +} + +// --- Exact chat_with_rag_fallback (used by POST /chat) --- +async function chatWithRagFallback(prompt) { + const contexts = await retrieveForRag(prompt, RAG_N_RESULTS, RAG_DISTANCE_THRESHOLD, RAG_RELAXED_DISTANCE_THRESHOLD); + + if (!contexts.length) { + return chatWithDomainGuard(prompt); + } + + const grounded = buildGroundedUserPrompt(contexts, prompt); + const ragResponse = await chat(grounded, GROUNDING_SYSTEM_PROMPT, 0.0); + + if (isWeakRagResponse(ragResponse)) { + return chatWithDomainGuard(prompt); + } + + return ragResponse; +} + +// --- Exact generate_with_rag (used by POST /chat_with_rag) --- +async function generateWithRag(prompt) { + const contexts = await retrieveForRag(prompt); + if (!contexts.length) { + return ( + "I'm sorry, I could not find relevant nutrition information for your question. " + + 'Please try asking about specific foods, nutrients, or dietary guidelines for Australian seniors.' + ); + } + const grounded = buildGroundedUserPrompt(contexts, prompt); + return chat(grounded, GROUNDING_SYSTEM_PROMPT, 0.0); +} + +// --- Routes --- + +// POST /ai-model/chatbot/chat +router.post('/chat', async (req, res) => { + try { + const { query } = req.body; + if (!query || String(query).trim().length === 0) { + return res.status(400).json({ status: 'error', error: 'query is required' }); + } + const msg = await chatWithRagFallback(String(query).trim()); + res.json({ status: 'success', msg, id: uuidv4(), timestamp: new Date().toISOString() }); + } catch (e) { + res.status(500).json({ status: 'error', error: 'Internal Server Error', detail: String(e.message), timestamp: new Date().toISOString() }); + } +}); + +// POST /ai-model/chatbot/chat_with_rag +router.post('/chat_with_rag', async (req, res) => { + try { + const { query } = req.body; + if (!query || String(query).trim().length === 0) { + return res.status(400).json({ status: 'error', error: 'query is required' }); + } + const msg = await generateWithRag(String(query).trim()); + res.json({ status: 'success', msg, id: uuidv4(), timestamp: new Date().toISOString() }); + } catch (e) { + res.status(500).json({ status: 'error', error: 'Internal Server Error', detail: String(e.message), timestamp: new Date().toISOString() }); + } +}); + +// POST /ai-model/chatbot/transcribe (multipart/form-data, field name: "audio") +router.post('/transcribe', upload.single('audio'), async (req, res) => { + try { + if (!req.file) return res.status(400).json({ error: 'Could not transcribe audio.' }); + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + const transcription = await groq.audio.transcriptions.create({ + model: 'whisper-large-v3', + file: new File([req.file.buffer], 'recording.webm', { type: 'audio/webm' }), + }); + if (!transcription.text?.trim()) return res.status(400).json({ error: 'Could not transcribe audio.' }); + res.json({ transcript: transcription.text }); + } catch (e) { + res.status(500).json({ error: `Transcription error: ${e.message}` }); + } +}); + +module.exports = router; +module.exports.retrieve = retrieve; diff --git a/routes/ai/healthPlan.js b/routes/ai/healthPlan.js new file mode 100644 index 0000000..9ec6fbb --- /dev/null +++ b/routes/ai/healthPlan.js @@ -0,0 +1,205 @@ +const express = require('express'); +const Groq = require('groq-sdk'); +const { retrieve } = require('./chatbot'); + +const router = express.Router(); + +const GROQ_MODEL = process.env.GROQ_MODEL || 'llama-3.1-8b-instant'; + +// --- Exact STRICT_SCHEMA_EXAMPLE from Python --- +const STRICT_SCHEMA_EXAMPLE = { + suggestion: 'string', + weekly_plan: [ + { + week: 1, + target_calories_per_day: 2000, + focus: 'string', + workouts: ['string'], + meal_notes: 'string', + reminders: ['string'], + }, + ], + progress_analysis: 'string', +}; + +const ALLOWED_FOCUS = new Set(['Weight Loss', 'Muscle Gain', 'Endurance']); +const CAL_MIN = 1200; +const CAL_MAX = 3500; + +// --- Exact _clean_str --- +function cleanStr(value) { + return String(value == null ? '' : value).trim(); +} + +// --- Exact _clean_str_list --- +function cleanStrList(values) { + if (Array.isArray(values)) return values.map((v) => cleanStr(v)).filter((v) => v.length > 0); + if (typeof values === 'string' && values.trim()) return [values.trim()]; + return []; +} + +// --- Exact _coerce_int --- +function coerceInt(value, defaultVal) { + try { + const parsed = parseInt(parseFloat(String(value))); + if (isNaN(parsed)) return defaultVal; + return Math.max(CAL_MIN, Math.min(CAL_MAX, parsed)); + } catch { + return defaultVal; + } +} + +// --- Exact _normalize_week_item --- +function normalizeWeekItem(index, item) { + item = item || {}; + const focus = ALLOWED_FOCUS.has(item.focus) ? item.focus : 'Weight Loss'; + let workouts = cleanStrList(item.workouts || []); + const mealNotes = cleanStr(item.meal_notes || ''); + const reminders = cleanStrList(item.reminders || []); + + // If workouts exist but none contain ":", prepend day names + if (workouts.length > 0 && !workouts[0].includes(':')) { + const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + workouts = workouts.map((w, offset) => `${days[offset % 7]}: ${w}`); + } + + return { + week: index + 1, + target_calories_per_day: coerceInt(item.target_calories_per_day, 1900), + focus, + workouts: workouts.slice(0, 7).length + ? workouts.slice(0, 7) + : ['Monday: 30 minutes brisk walking', 'Wednesday: Rest day', 'Friday: 20 minutes strength training'], + meal_notes: mealNotes || 'Eat 3 main meals and 2 snacks, include lean protein, whole grains, and healthy fats.', + reminders: reminders.slice(0, 6).length + ? reminders.slice(0, 6) + : ['Drink 8 glasses of water daily', 'Limit sugary drinks and fast food'], + }; +} + +// --- Exact _force_json --- +function forceJson(text) { + const match = text.match(/\{[\s\S]*\}\s*$/); + let raw = match ? match[0] : text; + // Remove trailing commas before ] or } + raw = raw.replace(/,(\s*[\]}])/g, '$1'); + + let data; + try { + data = JSON.parse(raw); + } catch { + return { suggestion: String(text).trim(), weekly_plan: [], progress_analysis: '' }; + } + + // Unwrap nested JSON in suggestion field + if (typeof data.suggestion === 'string' && data.suggestion.includes('{') && data.suggestion.includes('}')) { + try { + const inner = JSON.parse(data.suggestion); + if (inner && typeof inner === 'object') { + for (const key of ['suggestion', 'weekly_plan', 'progress_analysis']) { + if (key in inner && !(key in data)) data[key] = inner[key]; + } + } + } catch { + // ignore + } + } + + return data; +} + +// --- Exact _enforce_schema --- +function enforceSchema(data, numWeeks) { + let suggestion = cleanStr(data.suggestion || ''); + if (suggestion.includes('{') && suggestion.includes('}')) { + suggestion = 'Increase daily water intake to 2 liters and consume 5 servings of fruits and vegetables.'; + } + + let weeklyPlan = Array.isArray(data.weekly_plan) ? data.weekly_plan : []; + const normalizedItems = []; + for (let i = 0; i < numWeeks; i++) { + const base = (i < weeklyPlan.length && typeof weeklyPlan[i] === 'object') ? weeklyPlan[i] : {}; + normalizedItems.push(normalizeWeekItem(i, base)); + } + + let progressAnalysis = cleanStr(data.progress_analysis || ''); + if (!progressAnalysis) { + progressAnalysis = 'Progress trend: steady adherence recommended. Track weight, BMI, and blood pressure weekly.'; + } + + return { suggestion, weekly_plan: normalizedItems, progress_analysis: progressAnalysis }; +} + +// --- Exact _build_prompt (note: {n} in rules is intentionally literal, not substituted) --- +function buildPrompt(analyzedCondition, numWeeks, ragContexts) { + const conditionJson = JSON.stringify(analyzedCondition, null, 2); + const schemaJson = JSON.stringify(STRICT_SCHEMA_EXAMPLE, null, 2); + const joinedContext = ragContexts.length ? '\n- ' + ragContexts.join('\n- ') : ''; + + return ( + 'You are a nutrition and fitness assistant.\n' + + 'OUTPUT RULES (READ CAREFULLY):\n' + + '1) Output MUST be a single valid JSON object. No prose, no code fences.\n' + + '2) Allowed top-level keys ONLY: "suggestion", "weekly_plan", "progress_analysis".\n' + + '3) Types:\n' + + ' - suggestion: string (one sentence). It MUST NOT contain JSON or braces.\n' + + ' - weekly_plan: array of exactly {n} objects, with keys:\n' + + ' week (int 1..{n}), target_calories_per_day (int), focus (string: Weight Loss|Muscle Gain|Endurance),\n' + + ' workouts (array of strings), meal_notes (string), reminders (array of strings)\n' + + ' - progress_analysis: string (short paragraph)\n' + + '4) Keep workouts/reminders as short bullet-like strings.\n' + + '5) Use realistic AU norms (hydration, calories, macros) when relevant.\n' + + '6) Do not include any extra keys anywhere.\n' + + '7) Produce exactly {n} items in weekly_plan with weeks numbered 1..{n}.\n' + + '\nSTRICT SHAPE EXAMPLE (TYPES ONLY, NOT CONTENT):\n' + + schemaJson + '\n' + + '\nUSER CONDITION HISTORY:\n' + + conditionJson + '\n' + + '\nRAG CONTEXT (Australian norms):\n' + + joinedContext + '\n' + ); +} + +// POST /ai-model/medical-report/plan/generate +router.post('/generate', async (req, res) => { + try { + const { medical_report, health_goal } = req.body; + if (!medical_report || !health_goal) { + return res.status(400).json({ error: 'medical_report and health_goal are required' }); + } + + const numWeeks = 8; + const analyzed = { medical_report, health_goal, health_survey: null, followup_qa: null }; + + // Build the same query string as Python _build_prompt + const userTask = + `Generate a ${numWeeks}-week diet & workout plan and analyze progress across reports. ` + + 'If multiple reports are given, compare them and include improvement/no-improvement notes ' + + "in the 'progress_analysis' field. Return STRICT JSON only."; + const conditionJson = JSON.stringify(analyzed, null, 2); + const ragQuery = `${userTask}\nUser condition history: ${conditionJson}`; + + // Uses simple retrieve (no distance threshold), exactly like Python HealthPlanService + const ragContexts = await retrieve(ragQuery, 4); + const prompt = buildPrompt(analyzed, numWeeks, ragContexts); + + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY }); + const response = await groq.chat.completions.create({ + messages: [ + { role: 'system', content: 'You output strictly valid JSON.' }, + { role: 'user', content: prompt }, + ], + model: GROQ_MODEL, + max_tokens: 1200, + temperature: 0.2, + }); + + const output = (response.choices[0]?.message?.content || '').trim(); + const parsed = forceJson(output); + res.json(enforceSchema(parsed, numWeeks)); + } catch (e) { + res.status(500).json({ error: 'Plan generation failed due to server error.', detail: e.message }); + } +}); + +module.exports = router; diff --git a/routes/chatbot.js b/routes/chatbot.js index 2e7d487..b583844 100644 --- a/routes/chatbot.js +++ b/routes/chatbot.js @@ -5,6 +5,7 @@ const { authenticateToken } = require('../middleware/authenticateToken'); const { chatbot: chatbotController } = aiAndMedical; +router.route('/').post(authenticateToken, chatbotController.getChatResponse); router.route('/query').post(authenticateToken, chatbotController.getChatResponse); // router.route('/chat').post(chatbotController.getChatResponse); diff --git a/routes/index.js b/routes/index.js index 91f3816..78fa52d 100644 --- a/routes/index.js +++ b/routes/index.js @@ -47,4 +47,8 @@ module.exports = app => { app.use('/api/security', require('./securityEvents')); app.use('/api/recommendations', require('./recommendations')); app.use('/api/meal-plan', require('./mealPlanAIRoutes')); + + // AI model routes (ported from NutriHelp-ai) + app.use('/ai-model/chatbot', require('./ai/chatbot')); + app.use('/ai-model/medical-report/plan', require('./ai/healthPlan')); }; diff --git a/services/chatbotService.js b/services/chatbotService.js index 9751169..dd36804 100644 --- a/services/chatbotService.js +++ b/services/chatbotService.js @@ -1,12 +1,48 @@ +const Groq = require('groq-sdk'); const { ServiceError } = require('./serviceError'); + +const SYSTEM_PROMPT = `You are NutriHelp AI, a friendly and knowledgeable nutrition and health assistant for elderly users. +Your role is to answer questions about nutrition, diet, meal planning, hydration, and general wellness. +Keep responses concise (2–4 sentences), warm, and easy to understand. +Avoid medical diagnoses. If a user describes a serious symptom, recommend they consult a healthcare professional. +Always respond in the same language the user writes in.`; + +const GROQ_TIMEOUT_MS = 25000; + class ChatbotService { async getChatResponse({ userId, userInput }) { - if (!userId || !userInput) throw new ServiceError(400, 'Missing fields'); - return { response: "Hello" }; + if (!userId || !userInput) { + throw new ServiceError(400, 'Missing fields'); + } + + if (!process.env.GROQ_API_KEY) { + return { + statusCode: 200, + body: { response: "AI service is not configured. Please contact your administrator." }, + }; + } + + const groq = new Groq({ apiKey: process.env.GROQ_API_KEY, timeout: GROQ_TIMEOUT_MS }); + + const completion = await groq.chat.completions.create({ + model: 'llama-3.3-70b-versatile', + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: String(userInput).slice(0, 2000) }, + ], + max_tokens: 400, + temperature: 0.7, + }); + + const response = completion.choices[0]?.message?.content || "I'm sorry, I couldn't generate a response. Please try again."; + + return { statusCode: 200, body: { response } }; } + async addUrl(userId, url) { if (url === 'http://fail.com') throw new ServiceError(503, 'AI server unavailable'); return { status: 'success' }; } } + module.exports = { ChatbotService };