Spaces:
Sleeping
Sleeping
Commit
·
fdc2693
1
Parent(s):
7c98348
add backend source
Browse files- .env +13 -0
- __init__.py +1 -0
- __pycache__/__init__.cpython-313.pyc +0 -0
- __pycache__/database.cpython-313.pyc +0 -0
- __pycache__/init_db.cpython-313.pyc +0 -0
- __pycache__/main.cpython-313.pyc +0 -0
- app.cpython-313.pyc +0 -0
- app.py +65 -0
- app/main.py +26 -0
- backend/requirements.txt +8 -0
- backend/setup.py +47 -0
- conftest.py +7 -0
- database.py +31 -0
- food_analyzer.py +339 -0
- health_assistant.db +0 -0
- init_db.py +70 -0
- main.cpython-313.pyc +0 -0
- main.py +209 -0
- models/__pycache__/meal_log.cpython-313.pyc +0 -0
- models/__pycache__/nutrition.cpython-313.pyc +0 -0
- models/meal_log.py +19 -0
- models/nutrition.py +22 -0
- routers/__pycache__/ai_router.cpython-313.pyc +0 -0
- routers/__pycache__/meal_router.cpython-313.pyc +0 -0
- routers/ai_router.py +36 -0
- routers/meal_router.py +103 -0
- services/__init__.py +5 -0
- services/__pycache__/__init__.cpython-313.pyc +0 -0
- services/__pycache__/ai_service.cpython-313.pyc +0 -0
- services/__pycache__/food_analyzer_service.cpython-313.pyc +0 -0
- services/__pycache__/meal_service.cpython-313.pyc +0 -0
- services/__pycache__/nutrition_api_service.cpython-313.pyc +0 -0
- services/ai_service.py +95 -0
- services/food_analyzer_service.py +256 -0
- services/meal_service.py +89 -0
- services/nutrition_api_service.py +101 -0
- setup.py +30 -0
- test_pytest.py +2 -0
.env
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 資料庫設定
|
| 2 |
+
DATABASE_URL=sqlite:///./data/health_assistant.db
|
| 3 |
+
|
| 4 |
+
# Redis 設定
|
| 5 |
+
REDIS_HOST=localhost
|
| 6 |
+
REDIS_PORT=6379
|
| 7 |
+
REDIS_DB=0
|
| 8 |
+
|
| 9 |
+
# API 設定
|
| 10 |
+
API_HOST=localhost
|
| 11 |
+
API_PORT=8000
|
| 12 |
+
|
| 13 |
+
USDA_API_KEY="0bZCszzPSbc5r6RXfm0aHKtWGX2V0SX1hLiMmXwi"
|
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (167 Bytes). View file
|
|
|
__pycache__/database.cpython-313.pyc
ADDED
|
Binary file (1.3 kB). View file
|
|
|
__pycache__/init_db.cpython-313.pyc
ADDED
|
Binary file (2.72 kB). View file
|
|
|
__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (1.17 kB). View file
|
|
|
app.cpython-313.pyc
ADDED
|
Binary file (2.26 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, File, UploadFile
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
import uvicorn
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from PIL import Image
|
| 6 |
+
import io
|
| 7 |
+
import random
|
| 8 |
+
|
| 9 |
+
app = FastAPI()
|
| 10 |
+
|
| 11 |
+
# 允許跨域請求
|
| 12 |
+
app.add_middleware(
|
| 13 |
+
CORSMiddleware,
|
| 14 |
+
allow_origins=["*"],
|
| 15 |
+
allow_credentials=True,
|
| 16 |
+
allow_methods=["*"],
|
| 17 |
+
allow_headers=["*"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# 模擬食物列表
|
| 21 |
+
FOOD_ITEMS = [
|
| 22 |
+
"牛肉麵",
|
| 23 |
+
"滷肉飯",
|
| 24 |
+
"炒飯",
|
| 25 |
+
"水餃",
|
| 26 |
+
"炸雞",
|
| 27 |
+
"三明治",
|
| 28 |
+
"沙拉",
|
| 29 |
+
"義大利麵",
|
| 30 |
+
"披薩",
|
| 31 |
+
"漢堡"
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
@app.post("/api/ai/analyze-food")
|
| 35 |
+
async def analyze_food(file: UploadFile = File(...)):
|
| 36 |
+
# 讀取圖片(僅作為示範,不進行實際分析)
|
| 37 |
+
contents = await file.read()
|
| 38 |
+
image = Image.open(io.BytesIO(contents))
|
| 39 |
+
|
| 40 |
+
# 隨機選擇一個食物和信心度
|
| 41 |
+
food = random.choice(FOOD_ITEMS)
|
| 42 |
+
confidence = random.uniform(85.0, 99.9)
|
| 43 |
+
|
| 44 |
+
# 模擬營養資訊
|
| 45 |
+
nutrition = {
|
| 46 |
+
"calories": random.randint(200, 800),
|
| 47 |
+
"protein": random.randint(10, 30),
|
| 48 |
+
"carbs": random.randint(20, 60),
|
| 49 |
+
"fat": random.randint(5, 25)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# 返回結果
|
| 53 |
+
return {
|
| 54 |
+
"success": True,
|
| 55 |
+
"analysis_time": datetime.now().isoformat(),
|
| 56 |
+
"top_prediction": {
|
| 57 |
+
"label": food,
|
| 58 |
+
"confidence": confidence,
|
| 59 |
+
"nutrition": nutrition,
|
| 60 |
+
"description": f"這是一道美味的{food},營養豐富且美味可口。"
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
uvicorn.run(app, host="127.0.0.1", port=8000)
|
app/main.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app.routers import ai_router, meal_router
|
| 4 |
+
from app.database import engine, Base
|
| 5 |
+
|
| 6 |
+
# 創建資料庫表
|
| 7 |
+
Base.metadata.create_all(bind=engine)
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="Health Assistant API")
|
| 10 |
+
|
| 11 |
+
# 配置 CORS
|
| 12 |
+
app.add_middleware(
|
| 13 |
+
CORSMiddleware,
|
| 14 |
+
allow_origins=["http://localhost:5173"], # React 開發伺服器的位址
|
| 15 |
+
allow_credentials=True,
|
| 16 |
+
allow_methods=["*"],
|
| 17 |
+
allow_headers=["*"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# 註冊路由
|
| 21 |
+
app.include_router(ai_router.router)
|
| 22 |
+
app.include_router(meal_router.router)
|
| 23 |
+
|
| 24 |
+
@app.get("/")
|
| 25 |
+
async def root():
|
| 26 |
+
return {"message": "Health Assistant API is running"}
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
transformers
|
| 4 |
+
torch
|
| 5 |
+
pillow
|
| 6 |
+
opencv-python
|
| 7 |
+
python-multipart
|
| 8 |
+
requests
|
backend/setup.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
def setup_project():
|
| 6 |
+
"""設置專案目錄結構和必要檔案"""
|
| 7 |
+
# 獲取專案根目錄
|
| 8 |
+
project_root = Path(__file__).parent
|
| 9 |
+
|
| 10 |
+
# 創建必要的目錄
|
| 11 |
+
directories = [
|
| 12 |
+
'data', # 資料庫目錄
|
| 13 |
+
'logs', # 日誌目錄
|
| 14 |
+
'uploads' # 上傳檔案目錄
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
for directory in directories:
|
| 18 |
+
dir_path = project_root / directory
|
| 19 |
+
dir_path.mkdir(exist_ok=True)
|
| 20 |
+
print(f"Created directory: {dir_path}")
|
| 21 |
+
|
| 22 |
+
# 創建 .env 檔案(如果不存在)
|
| 23 |
+
env_file = project_root / '.env'
|
| 24 |
+
if not env_file.exists():
|
| 25 |
+
with open(env_file, 'w', encoding='utf-8') as f:
|
| 26 |
+
f.write("""# 資料庫設定
|
| 27 |
+
DATABASE_URL=sqlite:///./data/health_assistant.db
|
| 28 |
+
|
| 29 |
+
# Redis 設定
|
| 30 |
+
REDIS_HOST=localhost
|
| 31 |
+
REDIS_PORT=6379
|
| 32 |
+
REDIS_DB=0
|
| 33 |
+
|
| 34 |
+
# API 設定
|
| 35 |
+
API_HOST=localhost
|
| 36 |
+
API_PORT=8000
|
| 37 |
+
""")
|
| 38 |
+
print(f"Created .env file: {env_file}")
|
| 39 |
+
|
| 40 |
+
print("\nProject setup completed successfully!")
|
| 41 |
+
print("\nNext steps:")
|
| 42 |
+
print("1. Install required packages: pip install -r requirements.txt")
|
| 43 |
+
print("2. Start the Redis server")
|
| 44 |
+
print("3. Run the application: uvicorn app.main:app --reload")
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
setup_project()
|
conftest.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Add the backend directory to the Python path
|
| 6 |
+
sys.path.insert(0, str(Path(__file__).parent / "backend"))
|
| 7 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
database.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
# 確保資料庫目錄存在
|
| 7 |
+
DB_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
|
| 8 |
+
os.makedirs(DB_DIR, exist_ok=True)
|
| 9 |
+
|
| 10 |
+
# 資料庫 URL
|
| 11 |
+
SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DB_DIR, 'health_assistant.db')}"
|
| 12 |
+
|
| 13 |
+
# 創建資料庫引擎
|
| 14 |
+
engine = create_engine(
|
| 15 |
+
SQLALCHEMY_DATABASE_URL,
|
| 16 |
+
connect_args={"check_same_thread": False} # SQLite 特定配置
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# 創建 SessionLocal 類
|
| 20 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 21 |
+
|
| 22 |
+
# 創建 Base 類
|
| 23 |
+
Base = declarative_base()
|
| 24 |
+
|
| 25 |
+
# 獲取資料庫會話的依賴項
|
| 26 |
+
def get_db():
|
| 27 |
+
db = SessionLocal()
|
| 28 |
+
try:
|
| 29 |
+
yield db
|
| 30 |
+
finally:
|
| 31 |
+
db.close()
|
food_analyzer.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from PIL import Image
|
| 4 |
+
import io
|
| 5 |
+
import base64
|
| 6 |
+
from transformers import pipeline
|
| 7 |
+
import requests
|
| 8 |
+
import json
|
| 9 |
+
from typing import Dict, Any
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
import uvicorn
|
| 12 |
+
|
| 13 |
+
app = FastAPI(title="Health Assistant AI - Food Recognition API")
|
| 14 |
+
|
| 15 |
+
# CORS設定
|
| 16 |
+
app.add_middleware(
|
| 17 |
+
CORSMiddleware,
|
| 18 |
+
allow_origins=["*"], # 生產環境請設定具體的域名
|
| 19 |
+
allow_credentials=True,
|
| 20 |
+
allow_methods=["*"],
|
| 21 |
+
allow_headers=["*"],
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# 初始化Hugging Face模型
|
| 25 |
+
try:
|
| 26 |
+
# 使用nateraw/food專門的食物分類模型
|
| 27 |
+
food_classifier = pipeline(
|
| 28 |
+
"image-classification",
|
| 29 |
+
model="nateraw/food",
|
| 30 |
+
device=-1 # 使用CPU,如果有GPU可以設為0
|
| 31 |
+
)
|
| 32 |
+
print("nateraw/food 食物辨識模型載入成功")
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f"模型載入失敗: {e}")
|
| 35 |
+
food_classifier = None
|
| 36 |
+
|
| 37 |
+
# 食物營養資料庫(擴展版,涵蓋nateraw/food模型常見的食物類型)
|
| 38 |
+
NUTRITION_DATABASE = {
|
| 39 |
+
# 水果類
|
| 40 |
+
"apple": {"name": "蘋果", "calories_per_100g": 52, "protein": 0.3, "carbs": 14, "fat": 0.2, "fiber": 2.4, "sugar": 10.4, "vitamin_c": 4.6},
|
| 41 |
+
"banana": {"name": "香蕉", "calories_per_100g": 89, "protein": 1.1, "carbs": 23, "fat": 0.3, "fiber": 2.6, "sugar": 12.2, "potassium": 358},
|
| 42 |
+
"orange": {"name": "橘子", "calories_per_100g": 47, "protein": 0.9, "carbs": 12, "fat": 0.1, "fiber": 2.4, "sugar": 9.4, "vitamin_c": 53.2},
|
| 43 |
+
"strawberry": {"name": "草莓", "calories_per_100g": 32, "protein": 0.7, "carbs": 7.7, "fat": 0.3, "fiber": 2, "sugar": 4.9, "vitamin_c": 58.8},
|
| 44 |
+
"grape": {"name": "葡萄", "calories_per_100g": 62, "protein": 0.6, "carbs": 16.8, "fat": 0.2, "fiber": 0.9, "sugar": 16.1},
|
| 45 |
+
|
| 46 |
+
# 主食類
|
| 47 |
+
"bread": {"name": "麵包", "calories_per_100g": 265, "protein": 9, "carbs": 49, "fat": 3.2, "fiber": 2.7, "sodium": 491},
|
| 48 |
+
"rice": {"name": "米飯", "calories_per_100g": 130, "protein": 2.7, "carbs": 28, "fat": 0.3, "fiber": 0.4},
|
| 49 |
+
"pasta": {"name": "義大利麵", "calories_per_100g": 131, "protein": 5, "carbs": 25, "fat": 1.1, "fiber": 1.8},
|
| 50 |
+
"noodles": {"name": "麵條", "calories_per_100g": 138, "protein": 4.5, "carbs": 25, "fat": 2.2, "fiber": 1.2},
|
| 51 |
+
"pizza": {"name": "披薩", "calories_per_100g": 266, "protein": 11, "carbs": 33, "fat": 10, "sodium": 598},
|
| 52 |
+
|
| 53 |
+
# 肉類
|
| 54 |
+
"chicken": {"name": "雞肉", "calories_per_100g": 165, "protein": 31, "carbs": 0, "fat": 3.6, "iron": 0.9},
|
| 55 |
+
"beef": {"name": "牛肉", "calories_per_100g": 250, "protein": 26, "carbs": 0, "fat": 15, "iron": 2.6, "zinc": 4.8},
|
| 56 |
+
"pork": {"name": "豬肉", "calories_per_100g": 242, "protein": 27, "carbs": 0, "fat": 14, "thiamine": 0.7},
|
| 57 |
+
"fish": {"name": "魚肉", "calories_per_100g": 206, "protein": 22, "carbs": 0, "fat": 12, "omega_3": "豐富"},
|
| 58 |
+
|
| 59 |
+
# 蔬菜類
|
| 60 |
+
"broccoli": {"name": "花椰菜", "calories_per_100g": 34, "protein": 2.8, "carbs": 7, "fat": 0.4, "fiber": 2.6, "vitamin_c": 89.2},
|
| 61 |
+
"carrot": {"name": "胡蘿蔔", "calories_per_100g": 41, "protein": 0.9, "carbs": 10, "fat": 0.2, "fiber": 2.8, "vitamin_a": 835},
|
| 62 |
+
"tomato": {"name": "番茄", "calories_per_100g": 18, "protein": 0.9, "carbs": 3.9, "fat": 0.2, "fiber": 1.2, "vitamin_c": 13.7},
|
| 63 |
+
"lettuce": {"name": "萵苣", "calories_per_100g": 15, "protein": 1.4, "carbs": 2.9, "fat": 0.2, "fiber": 1.3, "folate": 38},
|
| 64 |
+
|
| 65 |
+
# 飲品類
|
| 66 |
+
"coffee": {"name": "咖啡", "calories_per_100g": 2, "protein": 0.3, "carbs": 0, "fat": 0, "caffeine": 95},
|
| 67 |
+
"tea": {"name": "茶", "calories_per_100g": 1, "protein": 0, "carbs": 0.3, "fat": 0, "antioxidants": "豐富"},
|
| 68 |
+
"milk": {"name": "牛奶", "calories_per_100g": 42, "protein": 3.4, "carbs": 5, "fat": 1, "calcium": 113},
|
| 69 |
+
"juice": {"name": "果汁", "calories_per_100g": 45, "protein": 0.7, "carbs": 11, "fat": 0.2, "vitamin_c": "因果汁種類而異"},
|
| 70 |
+
|
| 71 |
+
# 甜點類
|
| 72 |
+
"cake": {"name": "蛋糕", "calories_per_100g": 257, "protein": 4, "carbs": 46, "fat": 6, "sugar": 35},
|
| 73 |
+
"cookie": {"name": "餅乾", "calories_per_100g": 502, "protein": 5.9, "carbs": 64, "fat": 25, "sugar": 39},
|
| 74 |
+
"ice_cream": {"name": "冰淇淋", "calories_per_100g": 207, "protein": 3.5, "carbs": 24, "fat": 11, "sugar": 21},
|
| 75 |
+
"chocolate": {"name": "巧克力", "calories_per_100g": 546, "protein": 4.9, "carbs": 61, "fat": 31, "sugar": 48},
|
| 76 |
+
|
| 77 |
+
# 其他常見食物
|
| 78 |
+
"egg": {"name": "雞蛋", "calories_per_100g": 155, "protein": 13, "carbs": 1.1, "fat": 11, "choline": 294},
|
| 79 |
+
"cheese": {"name": "起司", "calories_per_100g": 113, "protein": 7, "carbs": 1, "fat": 9, "calcium": 200},
|
| 80 |
+
"yogurt": {"name": "優格", "calories_per_100g": 59, "protein": 10, "carbs": 3.6, "fat": 0.4, "probiotics": "豐富"},
|
| 81 |
+
"nuts": {"name": "堅果", "calories_per_100g": 607, "protein": 15, "carbs": 7, "fat": 54, "vitamin_e": 26},
|
| 82 |
+
"salad": {"name": "沙拉", "calories_per_100g": 20, "protein": 1.5, "carbs": 4, "fat": 0.2, "fiber": 2, "vitamins": "多種維生素"}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
# 回應模型
|
| 86 |
+
class FoodAnalysisResponse(BaseModel):
|
| 87 |
+
success: bool
|
| 88 |
+
food_name: str
|
| 89 |
+
confidence: float
|
| 90 |
+
nutrition_info: Dict[str, Any]
|
| 91 |
+
ai_suggestions: list
|
| 92 |
+
message: str
|
| 93 |
+
|
| 94 |
+
class HealthResponse(BaseModel):
|
| 95 |
+
status: str
|
| 96 |
+
message: str
|
| 97 |
+
|
| 98 |
+
def get_nutrition_info(food_name: str) -> Dict[str, Any]:
|
| 99 |
+
"""根據食物名稱獲取營養資訊"""
|
| 100 |
+
# 將食物名稱轉為小寫並清理
|
| 101 |
+
food_key = food_name.lower().strip()
|
| 102 |
+
|
| 103 |
+
# 移除常見的修飾詞和格式化字符
|
| 104 |
+
food_key = food_key.replace("_", " ").replace("-", " ")
|
| 105 |
+
|
| 106 |
+
# 直接匹配
|
| 107 |
+
if food_key in NUTRITION_DATABASE:
|
| 108 |
+
return NUTRITION_DATABASE[food_key]
|
| 109 |
+
|
| 110 |
+
# 模糊匹配 - 檢查是否包含關鍵字
|
| 111 |
+
for key, value in NUTRITION_DATABASE.items():
|
| 112 |
+
if key in food_key or food_key in key:
|
| 113 |
+
return value
|
| 114 |
+
# 也檢查中文名稱
|
| 115 |
+
if value["name"] in food_name:
|
| 116 |
+
return value
|
| 117 |
+
|
| 118 |
+
# 更智能的匹配 - 處理複合詞
|
| 119 |
+
food_words = food_key.split()
|
| 120 |
+
for word in food_words:
|
| 121 |
+
for key, value in NUTRITION_DATABASE.items():
|
| 122 |
+
if word == key or word in key:
|
| 123 |
+
return value
|
| 124 |
+
|
| 125 |
+
# 特殊情況處理
|
| 126 |
+
special_mappings = {
|
| 127 |
+
"french fries": "potato",
|
| 128 |
+
"hamburger": "beef",
|
| 129 |
+
"sandwich": "bread",
|
| 130 |
+
"soda": "juice",
|
| 131 |
+
"water": {"name": "水", "calories_per_100g": 0, "protein": 0, "carbs": 0, "fat": 0},
|
| 132 |
+
"soup": {"name": "湯", "calories_per_100g": 50, "protein": 2, "carbs": 8, "fat": 1, "sodium": 400}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
for special_key, mapping in special_mappings.items():
|
| 136 |
+
if special_key in food_key:
|
| 137 |
+
if isinstance(mapping, str):
|
| 138 |
+
return NUTRITION_DATABASE.get(mapping, {"name": food_name, "message": "營養資料不完整"})
|
| 139 |
+
else:
|
| 140 |
+
return mapping
|
| 141 |
+
|
| 142 |
+
# 如果沒有找到,返回預設值
|
| 143 |
+
return {
|
| 144 |
+
"name": food_name,
|
| 145 |
+
"calories_per_100g": "未知",
|
| 146 |
+
"protein": "未知",
|
| 147 |
+
"carbs": "未知",
|
| 148 |
+
"fat": "未知",
|
| 149 |
+
"message": f"抱歉,暫時沒有「{food_name}」的詳細營養資料,建議查詢專業營養資料庫"
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
def generate_ai_suggestions(food_name: str, nutrition_info: Dict) -> list:
|
| 153 |
+
"""根據食物和營養資訊生成AI建議"""
|
| 154 |
+
suggestions = []
|
| 155 |
+
food_name_lower = food_name.lower()
|
| 156 |
+
|
| 157 |
+
# 檢查是否有完整的營養資訊
|
| 158 |
+
if isinstance(nutrition_info.get("calories_per_100g"), (int, float)):
|
| 159 |
+
calories = nutrition_info["calories_per_100g"]
|
| 160 |
+
|
| 161 |
+
# 熱量相關建議
|
| 162 |
+
if calories > 400:
|
| 163 |
+
suggestions.append("⚠️ 這是高熱量食物,建議控制份量,搭配運動")
|
| 164 |
+
elif calories > 200:
|
| 165 |
+
suggestions.append("🍽️ 中等熱量食物,適量食用,建議搭配蔬菜")
|
| 166 |
+
elif calories < 50:
|
| 167 |
+
suggestions.append("✅ 低熱量食物,適合減重期間食用")
|
| 168 |
+
|
| 169 |
+
# 營養素相關建議
|
| 170 |
+
protein = nutrition_info.get("protein", 0)
|
| 171 |
+
if isinstance(protein, (int, float)) and protein > 20:
|
| 172 |
+
suggestions.append("💪 高蛋白食物,有助於肌肉發展和修復")
|
| 173 |
+
|
| 174 |
+
fiber = nutrition_info.get("fiber", 0)
|
| 175 |
+
if isinstance(fiber, (int, float)) and fiber > 3:
|
| 176 |
+
suggestions.append("🌿 富含纖維,有助於消化健康和增加飽足感")
|
| 177 |
+
|
| 178 |
+
sugar = nutrition_info.get("sugar", 0)
|
| 179 |
+
if isinstance(sugar, (int, float)) and sugar > 20:
|
| 180 |
+
suggestions.append("🍯 含糖量較高,建議適量食用,避免血糖快速上升")
|
| 181 |
+
|
| 182 |
+
# 特殊營養素
|
| 183 |
+
if nutrition_info.get("vitamin_c", 0) > 30:
|
| 184 |
+
suggestions.append("🍊 富含維生素C,有助於增強免疫力和抗氧化")
|
| 185 |
+
|
| 186 |
+
if nutrition_info.get("calcium", 0) > 100:
|
| 187 |
+
suggestions.append("🦴 富含鈣質,有助於骨骼和牙齒健康")
|
| 188 |
+
|
| 189 |
+
if nutrition_info.get("omega_3"):
|
| 190 |
+
suggestions.append("🐟 含有Omega-3脂肪酸,對心血管健康有益")
|
| 191 |
+
|
| 192 |
+
# 根據食物類型給出特定建議
|
| 193 |
+
if any(fruit in food_name_lower for fruit in ["apple", "banana", "orange", "strawberry", "grape"]):
|
| 194 |
+
suggestions.append("🍎 建議在餐前或運動前食用,提供天然糖分和維生素")
|
| 195 |
+
|
| 196 |
+
elif any(meat in food_name_lower for meat in ["chicken", "beef", "pork", "fish"]):
|
| 197 |
+
suggestions.append("🥩 建議搭配蔬菜食用,選擇健康的烹調方式(烤、蒸、煮)")
|
| 198 |
+
|
| 199 |
+
elif any(sweet in food_name_lower for sweet in ["cake", "cookie", "ice_cream", "chocolate"]):
|
| 200 |
+
suggestions.append("🍰 甜點建議偶爾享用,可在運動後適量食用")
|
| 201 |
+
suggestions.append("💡 可以考慮與朋友分享,減少單次攝取量")
|
| 202 |
+
|
| 203 |
+
elif any(drink in food_name_lower for drink in ["coffee", "tea"]):
|
| 204 |
+
suggestions.append("☕ 建議控制咖啡因攝取量,避免影響睡眠")
|
| 205 |
+
|
| 206 |
+
elif "salad" in food_name_lower:
|
| 207 |
+
suggestions.append("🥗 很棒的選擇!可以添加堅果或橄欖油增加健康脂肪")
|
| 208 |
+
|
| 209 |
+
# 通用健康建議
|
| 210 |
+
if not suggestions:
|
| 211 |
+
suggestions.extend([
|
| 212 |
+
"🍽️ 建議均衡飲食,搭配多樣化的食物",
|
| 213 |
+
"💧 記得多喝水,保持身體水分充足",
|
| 214 |
+
"🏃♂️ 搭配適量運動,維持健康生活型態"
|
| 215 |
+
])
|
| 216 |
+
else:
|
| 217 |
+
# 添加一些通用的健康提醒
|
| 218 |
+
suggestions.append("💧 記得多喝水,幫助營養吸收")
|
| 219 |
+
if len(suggestions) < 4:
|
| 220 |
+
suggestions.append("⚖️ 注意食物份量,適量攝取是健康飲食的關鍵")
|
| 221 |
+
|
| 222 |
+
return suggestions[:5] # 限制建議數量,避免過多
|
| 223 |
+
|
| 224 |
+
@app.get("/", response_model=HealthResponse)
|
| 225 |
+
async def root():
|
| 226 |
+
"""API根路径"""
|
| 227 |
+
return HealthResponse(
|
| 228 |
+
status="success",
|
| 229 |
+
message="Health Assistant AI - Food Recognition API is running!"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
@app.get("/health", response_model=HealthResponse)
|
| 233 |
+
async def health_check():
|
| 234 |
+
"""健康檢查端點"""
|
| 235 |
+
model_status = "正常" if food_classifier else "模型載入失敗"
|
| 236 |
+
return HealthResponse(
|
| 237 |
+
status="success",
|
| 238 |
+
message=f"API運行正常,模型狀態: {model_status}"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
@app.post("/analyze-food", response_model=FoodAnalysisResponse)
|
| 242 |
+
async def analyze_food(file: UploadFile = File(...)):
|
| 243 |
+
"""分析上傳的食物圖片"""
|
| 244 |
+
try:
|
| 245 |
+
# 檢查模型是否載入成功
|
| 246 |
+
if not food_classifier:
|
| 247 |
+
raise HTTPException(status_code=500, detail="AI模型尚未載入,請稍後再試")
|
| 248 |
+
|
| 249 |
+
# 檢查文件類型
|
| 250 |
+
if not file.content_type.startswith("image/"):
|
| 251 |
+
raise HTTPException(status_code=400, detail="請上傳圖片文件")
|
| 252 |
+
|
| 253 |
+
# 讀取圖片
|
| 254 |
+
image_data = await file.read()
|
| 255 |
+
image = Image.open(io.BytesIO(image_data))
|
| 256 |
+
|
| 257 |
+
# 確保圖片是RGB格式
|
| 258 |
+
if image.mode != "RGB":
|
| 259 |
+
image = image.convert("RGB")
|
| 260 |
+
|
| 261 |
+
# 使用AI模型進行食物辨識
|
| 262 |
+
results = food_classifier(image)
|
| 263 |
+
|
| 264 |
+
# 獲取最高信心度的結果
|
| 265 |
+
top_result = results[0]
|
| 266 |
+
food_name = top_result["label"]
|
| 267 |
+
confidence = top_result["score"]
|
| 268 |
+
|
| 269 |
+
# 獲取營養資訊
|
| 270 |
+
nutrition_info = get_nutrition_info(food_name)
|
| 271 |
+
|
| 272 |
+
# 生成AI建議
|
| 273 |
+
ai_suggestions = generate_ai_suggestions(food_name, nutrition_info)
|
| 274 |
+
|
| 275 |
+
return FoodAnalysisResponse(
|
| 276 |
+
success=True,
|
| 277 |
+
food_name=food_name,
|
| 278 |
+
confidence=round(confidence * 100, 2),
|
| 279 |
+
nutrition_info=nutrition_info,
|
| 280 |
+
ai_suggestions=ai_suggestions,
|
| 281 |
+
message="食物分析完成"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
except Exception as e:
|
| 285 |
+
raise HTTPException(status_code=500, detail=f"分析失敗: {str(e)}")
|
| 286 |
+
|
| 287 |
+
@app.post("/analyze-food-base64", response_model=FoodAnalysisResponse)
|
| 288 |
+
async def analyze_food_base64(image_data: dict):
|
| 289 |
+
"""分析base64編碼的食物圖片"""
|
| 290 |
+
try:
|
| 291 |
+
# 檢查模型是否載入成功
|
| 292 |
+
if not food_classifier:
|
| 293 |
+
raise HTTPException(status_code=500, detail="AI模型尚未載入,請稍後再試")
|
| 294 |
+
|
| 295 |
+
# 解碼base64圖片
|
| 296 |
+
base64_string = image_data.get("image", "")
|
| 297 |
+
if not base64_string:
|
| 298 |
+
raise HTTPException(status_code=400, detail="缺少圖片資料")
|
| 299 |
+
|
| 300 |
+
# 移除base64前綴(如果有的話)
|
| 301 |
+
if "," in base64_string:
|
| 302 |
+
base64_string = base64_string.split(",")[1]
|
| 303 |
+
|
| 304 |
+
# 解碼圖片
|
| 305 |
+
image_bytes = base64.b64decode(base64_string)
|
| 306 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 307 |
+
|
| 308 |
+
# 確保圖片是RGB格式
|
| 309 |
+
if image.mode != "RGB":
|
| 310 |
+
image = image.convert("RGB")
|
| 311 |
+
|
| 312 |
+
# 使用AI模型進行食物辨識
|
| 313 |
+
results = food_classifier(image)
|
| 314 |
+
|
| 315 |
+
# 獲取最高信心度的結果
|
| 316 |
+
top_result = results[0]
|
| 317 |
+
food_name = top_result["label"]
|
| 318 |
+
confidence = top_result["score"]
|
| 319 |
+
|
| 320 |
+
# 獲取營養資訊
|
| 321 |
+
nutrition_info = get_nutrition_info(food_name)
|
| 322 |
+
|
| 323 |
+
# 生成AI建議
|
| 324 |
+
ai_suggestions = generate_ai_suggestions(food_name, nutrition_info)
|
| 325 |
+
|
| 326 |
+
return FoodAnalysisResponse(
|
| 327 |
+
success=True,
|
| 328 |
+
food_name=food_name,
|
| 329 |
+
confidence=round(confidence * 100, 2),
|
| 330 |
+
nutrition_info=nutrition_info,
|
| 331 |
+
ai_suggestions=ai_suggestions,
|
| 332 |
+
message="食物分析完成"
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
except Exception as e:
|
| 336 |
+
raise HTTPException(status_code=500, detail=f"分析失敗: {str(e)}")
|
| 337 |
+
|
| 338 |
+
if __name__ == "__main__":
|
| 339 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
health_assistant.db
ADDED
|
Binary file (32.8 kB). View file
|
|
|
init_db.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .database import Base, engine, SessionLocal
|
| 2 |
+
from .models.meal_log import MealLog
|
| 3 |
+
from .models.nutrition import Nutrition
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
def init_db():
|
| 7 |
+
"""初始化資料庫並填入初始營養數據"""
|
| 8 |
+
print("Creating database tables...")
|
| 9 |
+
# 根據模型建立所有表格
|
| 10 |
+
Base.metadata.create_all(bind=engine)
|
| 11 |
+
print("Database tables created successfully!")
|
| 12 |
+
|
| 13 |
+
# 檢查是否已有資料,避免重複新增
|
| 14 |
+
db = SessionLocal()
|
| 15 |
+
if db.query(Nutrition).count() == 0:
|
| 16 |
+
print("Populating nutrition table with initial data...")
|
| 17 |
+
|
| 18 |
+
# 從 main.py 移植過來的模擬資料
|
| 19 |
+
mock_nutrition_data = [
|
| 20 |
+
{
|
| 21 |
+
"food_name": "hamburger", "chinese_name": "漢堡", "calories": 540, "protein": 25, "fat": 31, "carbs": 40,
|
| 22 |
+
"fiber": 3, "sugar": 6, "sodium": 1040, "health_score": 45,
|
| 23 |
+
"recommendations": ["脂肪和鈉含量過高,建議減少食用頻率。"],
|
| 24 |
+
"warnings": ["高熱量", "高脂肪", "高鈉"]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"food_name": "pizza", "chinese_name": "披薩", "calories": 266, "protein": 11, "fat": 10, "carbs": 33,
|
| 28 |
+
"fiber": 2, "sugar": 4, "sodium": 598, "health_score": 65,
|
| 29 |
+
"recommendations": ["可搭配沙拉以增加纖維攝取。"],
|
| 30 |
+
"warnings": ["高鈉"]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"food_name": "sushi", "chinese_name": "壽司", "calories": 200, "protein": 12, "fat": 8, "carbs": 20,
|
| 34 |
+
"fiber": 1, "sugar": 2, "sodium": 380, "health_score": 85,
|
| 35 |
+
"recommendations": ["優質的蛋白質和碳水化合物來源。"],
|
| 36 |
+
"warnings": []
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"food_name": "fried rice", "chinese_name": "炒飯", "calories": 238, "protein": 8, "fat": 12, "carbs": 26,
|
| 40 |
+
"fiber": 2, "sugar": 3, "sodium": 680, "health_score": 60,
|
| 41 |
+
"recommendations": ["注意油脂和鈉含量。"],
|
| 42 |
+
"warnings": ["高鈉"]
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"food_name": "chicken wings", "chinese_name": "雞翅", "calories": 203, "protein": 18, "fat": 14, "carbs": 0,
|
| 46 |
+
"fiber": 0, "sugar": 0, "sodium": 380, "health_score": 70,
|
| 47 |
+
"recommendations": ["蛋白質的良好來源。"],
|
| 48 |
+
"warnings": []
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"food_name": "salad", "chinese_name": "沙拉", "calories": 33, "protein": 3, "fat": 0.2, "carbs": 6,
|
| 52 |
+
"fiber": 3, "sugar": 3, "sodium": 65, "health_score": 95,
|
| 53 |
+
"recommendations": ["低熱量高纖維,是健康的選擇。"],
|
| 54 |
+
"warnings": []
|
| 55 |
+
}
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
for food_data in mock_nutrition_data:
|
| 59 |
+
db_item = Nutrition(**food_data)
|
| 60 |
+
db.add(db_item)
|
| 61 |
+
|
| 62 |
+
db.commit()
|
| 63 |
+
print(f"{len(mock_nutrition_data)} items populated.")
|
| 64 |
+
else:
|
| 65 |
+
print("Nutrition table already contains data. Skipping population.")
|
| 66 |
+
|
| 67 |
+
db.close()
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
init_db()
|
main.cpython-313.pyc
ADDED
|
Binary file (9.03 kB). View file
|
|
|
main.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import Dict, Any, List, Optional
|
| 5 |
+
import uvicorn
|
| 6 |
+
import base64
|
| 7 |
+
from io import BytesIO
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
|
| 13 |
+
# Database and services
|
| 14 |
+
from .app.database import get_db
|
| 15 |
+
from sqlalchemy.orm import Session
|
| 16 |
+
from .app.models.nutrition import Nutrition
|
| 17 |
+
from .app.services import nutrition_api_service
|
| 18 |
+
|
| 19 |
+
# Routers
|
| 20 |
+
from .app.routers import ai_router, meal_router
|
| 21 |
+
|
| 22 |
+
app = FastAPI(title="Health Assistant API")
|
| 23 |
+
app.include_router(ai_router.router)
|
| 24 |
+
app.include_router(meal_router.router)
|
| 25 |
+
|
| 26 |
+
# Load environment variables
|
| 27 |
+
load_dotenv()
|
| 28 |
+
|
| 29 |
+
# CORS middleware to allow frontend access
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=["*"],
|
| 33 |
+
allow_credentials=True,
|
| 34 |
+
allow_methods=["*"],
|
| 35 |
+
allow_headers=["*"],
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
class FoodRecognitionResponse(BaseModel):
|
| 39 |
+
food_name: str
|
| 40 |
+
chinese_name: str
|
| 41 |
+
confidence: float
|
| 42 |
+
success: bool
|
| 43 |
+
|
| 44 |
+
class NutritionAnalysisResponse(BaseModel):
|
| 45 |
+
success: bool
|
| 46 |
+
food_name: str
|
| 47 |
+
chinese_name: Optional[str] = None
|
| 48 |
+
nutrition: Dict[str, Any]
|
| 49 |
+
analysis: Dict[str, Any]
|
| 50 |
+
|
| 51 |
+
class ErrorResponse(BaseModel):
|
| 52 |
+
detail: str
|
| 53 |
+
|
| 54 |
+
@app.get("/")
|
| 55 |
+
async def root():
|
| 56 |
+
return {"message": "Health Assistant API is running"}
|
| 57 |
+
|
| 58 |
+
@app.get("/api/analyze-nutrition/{food_name}", response_model=NutritionAnalysisResponse, responses={404: {"model": ErrorResponse}})
|
| 59 |
+
async def analyze_nutrition(food_name: str, db: Session = Depends(get_db)):
|
| 60 |
+
"""
|
| 61 |
+
Analyze nutrition for a recognized food item by querying the database.
|
| 62 |
+
If not found locally, it queries an external API and saves the new data.
|
| 63 |
+
"""
|
| 64 |
+
try:
|
| 65 |
+
# Query the database for the food item (case-insensitive search)
|
| 66 |
+
food_item = db.query(Nutrition).filter(Nutrition.food_name.ilike(f"%{food_name.strip()}%")).first()
|
| 67 |
+
|
| 68 |
+
if not food_item:
|
| 69 |
+
# If not found, query the external API
|
| 70 |
+
print(f"Food '{food_name}' not in local DB. Querying external API...")
|
| 71 |
+
api_data = nutrition_api_service.fetch_nutrition_data(food_name)
|
| 72 |
+
|
| 73 |
+
if not api_data:
|
| 74 |
+
# If external API also doesn't find it, raise 404
|
| 75 |
+
raise HTTPException(status_code=404, detail=f"Food '{food_name}' not found in local database or external API.")
|
| 76 |
+
|
| 77 |
+
# Create a new Nutrition object from the API data
|
| 78 |
+
recommendations = generate_recommendations(api_data)
|
| 79 |
+
warnings = generate_warnings(api_data)
|
| 80 |
+
health_score = calculate_health_score(api_data)
|
| 81 |
+
|
| 82 |
+
new_food_item = Nutrition(
|
| 83 |
+
food_name=api_data.get('food_name', food_name),
|
| 84 |
+
chinese_name=api_data.get('chinese_name'),
|
| 85 |
+
calories=api_data.get('calories', 0),
|
| 86 |
+
protein=api_data.get('protein', 0),
|
| 87 |
+
fat=api_data.get('fat', 0),
|
| 88 |
+
carbs=api_data.get('carbs', 0),
|
| 89 |
+
fiber=api_data.get('fiber', 0),
|
| 90 |
+
sugar=api_data.get('sugar', 0),
|
| 91 |
+
sodium=api_data.get('sodium', 0),
|
| 92 |
+
health_score=health_score,
|
| 93 |
+
recommendations=recommendations,
|
| 94 |
+
warnings=warnings,
|
| 95 |
+
details={} # API doesn't provide extra details in our format
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Add to DB and commit
|
| 99 |
+
db.add(new_food_item)
|
| 100 |
+
db.commit()
|
| 101 |
+
db.refresh(new_food_item)
|
| 102 |
+
print(f"Saved new food '{new_food_item.food_name}' to the database.")
|
| 103 |
+
food_item = new_food_item # Use the new item for the response
|
| 104 |
+
|
| 105 |
+
if not food_item:
|
| 106 |
+
raise HTTPException(status_code=404, detail=f"Food '{food_name}' not found in the database.")
|
| 107 |
+
|
| 108 |
+
# Structure the response
|
| 109 |
+
nutrition_details = {
|
| 110 |
+
"calories": food_item.calories,
|
| 111 |
+
"protein": food_item.protein,
|
| 112 |
+
"fat": food_item.fat,
|
| 113 |
+
"carbs": food_item.carbs,
|
| 114 |
+
"fiber": food_item.fiber,
|
| 115 |
+
"sugar": food_item.sugar,
|
| 116 |
+
"sodium": food_item.sodium,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
# Safely add details if they exist and are a dictionary
|
| 120 |
+
if isinstance(food_item.details, dict):
|
| 121 |
+
nutrition_details.update(food_item.details)
|
| 122 |
+
|
| 123 |
+
analysis_details = {
|
| 124 |
+
"healthScore": food_item.health_score,
|
| 125 |
+
"recommendations": food_item.recommendations,
|
| 126 |
+
"warnings": food_item.warnings
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
return {
|
| 130 |
+
"success": True,
|
| 131 |
+
"food_name": food_item.food_name,
|
| 132 |
+
"chinese_name": food_item.chinese_name,
|
| 133 |
+
"nutrition": nutrition_details,
|
| 134 |
+
"analysis": analysis_details
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
except HTTPException:
|
| 138 |
+
raise
|
| 139 |
+
except Exception as e:
|
| 140 |
+
# Log the error for debugging
|
| 141 |
+
print(f"Error in analyze_nutrition: {str(e)}")
|
| 142 |
+
raise HTTPException(status_code=500, detail=f"Internal server error while processing '{food_name}'.")
|
| 143 |
+
|
| 144 |
+
def calculate_health_score(nutrition: Dict[str, Any]) -> int:
|
| 145 |
+
"""Calculate a health score based on nutritional values"""
|
| 146 |
+
score = 100
|
| 147 |
+
|
| 148 |
+
# 熱量評分
|
| 149 |
+
if nutrition.get("calories", 0) > 400:
|
| 150 |
+
score -= 20
|
| 151 |
+
elif nutrition.get("calories", 0) > 300:
|
| 152 |
+
score -= 10
|
| 153 |
+
|
| 154 |
+
# 脂肪評分
|
| 155 |
+
if nutrition.get("fat", 0) > 20:
|
| 156 |
+
score -= 15
|
| 157 |
+
elif nutrition.get("fat", 0) > 15:
|
| 158 |
+
score -= 8
|
| 159 |
+
|
| 160 |
+
# 蛋白質評分
|
| 161 |
+
if nutrition.get("protein", 0) > 15:
|
| 162 |
+
score += 10
|
| 163 |
+
elif nutrition.get("protein", 0) < 5:
|
| 164 |
+
score -= 10
|
| 165 |
+
|
| 166 |
+
# 鈉含量評分
|
| 167 |
+
if nutrition.get("sodium", 0) > 800:
|
| 168 |
+
score -= 15
|
| 169 |
+
elif nutrition.get("sodium", 0) > 600:
|
| 170 |
+
score -= 8
|
| 171 |
+
|
| 172 |
+
return max(0, min(100, score))
|
| 173 |
+
|
| 174 |
+
def generate_recommendations(nutrition: Dict[str, Any]) -> List[str]:
|
| 175 |
+
"""Generate dietary recommendations based on nutrition data"""
|
| 176 |
+
recommendations = []
|
| 177 |
+
|
| 178 |
+
if nutrition.get("protein", 0) < 10:
|
| 179 |
+
recommendations.append("建議增加蛋白質攝取,可搭配雞蛋或豆腐")
|
| 180 |
+
|
| 181 |
+
if nutrition.get("fat", 0) > 20:
|
| 182 |
+
recommendations.append("脂肪含量較高,建議適量食用")
|
| 183 |
+
|
| 184 |
+
if nutrition.get("fiber", 0) < 3:
|
| 185 |
+
recommendations.append("纖維含量不足,建議搭配蔬菜沙拉")
|
| 186 |
+
|
| 187 |
+
if nutrition.get("sodium", 0) > 600:
|
| 188 |
+
recommendations.append("鈉含量偏高,建議多喝水並減少其他鹽分攝取")
|
| 189 |
+
|
| 190 |
+
return recommendations
|
| 191 |
+
|
| 192 |
+
def generate_warnings(nutrition: Dict[str, Any]) -> List[str]:
|
| 193 |
+
"""Generate dietary warnings based on nutrition data"""
|
| 194 |
+
warnings = []
|
| 195 |
+
|
| 196 |
+
if nutrition.get("calories", 0) > 500:
|
| 197 |
+
warnings.append("高熱量食物")
|
| 198 |
+
|
| 199 |
+
if nutrition.get("fat", 0) > 25:
|
| 200 |
+
warnings.append("高脂肪食物")
|
| 201 |
+
|
| 202 |
+
if nutrition.get("sodium", 0) > 1000:
|
| 203 |
+
warnings.append("高鈉食物")
|
| 204 |
+
|
| 205 |
+
return warnings
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
# It's better to run with `uvicorn backend.main:app --reload` from the project root.
|
| 209 |
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
models/__pycache__/meal_log.cpython-313.pyc
ADDED
|
Binary file (1.13 kB). View file
|
|
|
models/__pycache__/nutrition.cpython-313.pyc
ADDED
|
Binary file (1.17 kB). View file
|
|
|
models/meal_log.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, JSON
|
| 2 |
+
from ..database import Base
|
| 3 |
+
|
| 4 |
+
class MealLog(Base):
|
| 5 |
+
__tablename__ = "meal_logs"
|
| 6 |
+
|
| 7 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 8 |
+
food_name = Column(String, index=True)
|
| 9 |
+
meal_type = Column(String) # breakfast, lunch, dinner, snack
|
| 10 |
+
portion_size = Column(String) # small, medium, large
|
| 11 |
+
calories = Column(Float)
|
| 12 |
+
protein = Column(Float)
|
| 13 |
+
carbs = Column(Float)
|
| 14 |
+
fat = Column(Float)
|
| 15 |
+
fiber = Column(Float)
|
| 16 |
+
meal_date = Column(DateTime, index=True)
|
| 17 |
+
image_url = Column(String)
|
| 18 |
+
ai_analysis = Column(JSON) # 儲存完整的 AI 分析結果
|
| 19 |
+
created_at = Column(DateTime)
|
models/nutrition.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/app/models/nutrition.py
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Float, JSON
|
| 3 |
+
from ..database import Base
|
| 4 |
+
|
| 5 |
+
class Nutrition(Base):
|
| 6 |
+
__tablename__ = "nutrition"
|
| 7 |
+
|
| 8 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 9 |
+
food_name = Column(String, unique=True, index=True, nullable=False)
|
| 10 |
+
chinese_name = Column(String)
|
| 11 |
+
calories = Column(Float)
|
| 12 |
+
protein = Column(Float)
|
| 13 |
+
fat = Column(Float)
|
| 14 |
+
carbs = Column(Float)
|
| 15 |
+
fiber = Column(Float)
|
| 16 |
+
sugar = Column(Float)
|
| 17 |
+
sodium = Column(Float)
|
| 18 |
+
# For more complex data like vitamins, minerals, etc.
|
| 19 |
+
details = Column(JSON)
|
| 20 |
+
health_score = Column(Integer)
|
| 21 |
+
recommendations = Column(JSON)
|
| 22 |
+
warnings = Column(JSON)
|
routers/__pycache__/ai_router.cpython-313.pyc
ADDED
|
Binary file (1.52 kB). View file
|
|
|
routers/__pycache__/meal_router.cpython-313.pyc
ADDED
|
Binary file (4.72 kB). View file
|
|
|
routers/ai_router.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 檔案路徑: backend/app/routers/ai_router.py
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, File, UploadFile, HTTPException
|
| 4 |
+
from ..services.ai_service import classify_food_image # 直接引入分類函式
|
| 5 |
+
from ..services.nutrition_api_service import fetch_nutrition_data # 匯入營養查詢函式
|
| 6 |
+
|
| 7 |
+
router = APIRouter(
|
| 8 |
+
prefix="/ai",
|
| 9 |
+
tags=["AI"],
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
@router.post("/analyze-food-image/")
|
| 13 |
+
async def analyze_food_image_endpoint(file: UploadFile = File(...)):
|
| 14 |
+
"""
|
| 15 |
+
這個端點接收使用者上傳的食物圖片,使用 AI 模型進行辨識,
|
| 16 |
+
並返回辨識出的食物名稱。
|
| 17 |
+
"""
|
| 18 |
+
# 檢查上傳的檔案是否為圖片格式
|
| 19 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 20 |
+
raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
|
| 21 |
+
|
| 22 |
+
# 讀取圖片的二進位制內容
|
| 23 |
+
image_bytes = await file.read()
|
| 24 |
+
|
| 25 |
+
# 呼叫 AI 服務中的分類函式
|
| 26 |
+
food_name = classify_food_image(image_bytes)
|
| 27 |
+
|
| 28 |
+
# 查詢營養資訊
|
| 29 |
+
nutrition_info = fetch_nutrition_data(food_name)
|
| 30 |
+
if nutrition_info is None:
|
| 31 |
+
raise HTTPException(status_code=404, detail=f"找不到 {food_name} 的營養資訊。")
|
| 32 |
+
|
| 33 |
+
# TODO: 在下一階段,我們會在這裡加入從資料庫查詢營養資訊的邏輯
|
| 34 |
+
# 目前,我們先直接回傳辨識出的食物名稱
|
| 35 |
+
|
| 36 |
+
return {"food_name": food_name, "nutrition_info": nutrition_info}
|
routers/meal_router.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List, Dict, Any, Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from ..services.meal_service import MealService
|
| 6 |
+
from ..database import get_db
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/meals", tags=["Meals"])
|
| 10 |
+
|
| 11 |
+
class MealCreate(BaseModel):
|
| 12 |
+
food_name: str
|
| 13 |
+
meal_type: str
|
| 14 |
+
portion_size: str
|
| 15 |
+
meal_date: datetime
|
| 16 |
+
nutrition: Dict[str, float]
|
| 17 |
+
image_url: Optional[str] = None
|
| 18 |
+
ai_analysis: Optional[Dict[str, Any]] = None
|
| 19 |
+
|
| 20 |
+
class DateRange(BaseModel):
|
| 21 |
+
start_date: datetime
|
| 22 |
+
end_date: datetime
|
| 23 |
+
meal_type: Optional[str] = None
|
| 24 |
+
|
| 25 |
+
@router.post("/log")
|
| 26 |
+
async def create_meal_log(
|
| 27 |
+
meal: MealCreate,
|
| 28 |
+
db: Session = Depends(get_db)
|
| 29 |
+
) -> Dict[str, Any]:
|
| 30 |
+
"""創建新的用餐記錄"""
|
| 31 |
+
meal_service = MealService(db)
|
| 32 |
+
try:
|
| 33 |
+
meal_log = meal_service.create_meal_log(
|
| 34 |
+
food_name=meal.food_name,
|
| 35 |
+
meal_type=meal.meal_type,
|
| 36 |
+
portion_size=meal.portion_size,
|
| 37 |
+
nutrition=meal.nutrition,
|
| 38 |
+
meal_date=meal.meal_date,
|
| 39 |
+
image_url=meal.image_url,
|
| 40 |
+
ai_analysis=meal.ai_analysis
|
| 41 |
+
)
|
| 42 |
+
return {
|
| 43 |
+
"success": True,
|
| 44 |
+
"message": "用餐記錄已創建",
|
| 45 |
+
"data": {
|
| 46 |
+
"id": meal_log.id,
|
| 47 |
+
"food_name": meal_log.food_name,
|
| 48 |
+
"meal_type": meal_log.meal_type,
|
| 49 |
+
"meal_date": meal_log.meal_date
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
except Exception as e:
|
| 53 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 54 |
+
|
| 55 |
+
@router.post("/list")
|
| 56 |
+
async def get_meal_logs(
|
| 57 |
+
date_range: DateRange,
|
| 58 |
+
db: Session = Depends(get_db)
|
| 59 |
+
) -> Dict[str, Any]:
|
| 60 |
+
"""獲取用餐記錄列表"""
|
| 61 |
+
meal_service = MealService(db)
|
| 62 |
+
try:
|
| 63 |
+
logs = meal_service.get_meal_logs(
|
| 64 |
+
start_date=date_range.start_date,
|
| 65 |
+
end_date=date_range.end_date,
|
| 66 |
+
meal_type=date_range.meal_type
|
| 67 |
+
)
|
| 68 |
+
return {
|
| 69 |
+
"success": True,
|
| 70 |
+
"data": [{
|
| 71 |
+
"id": log.id,
|
| 72 |
+
"food_name": log.food_name,
|
| 73 |
+
"meal_type": log.meal_type,
|
| 74 |
+
"portion_size": log.portion_size,
|
| 75 |
+
"calories": log.calories,
|
| 76 |
+
"protein": log.protein,
|
| 77 |
+
"carbs": log.carbs,
|
| 78 |
+
"fat": log.fat,
|
| 79 |
+
"meal_date": log.meal_date,
|
| 80 |
+
"image_url": log.image_url
|
| 81 |
+
} for log in logs]
|
| 82 |
+
}
|
| 83 |
+
except Exception as e:
|
| 84 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 85 |
+
|
| 86 |
+
@router.post("/nutrition-summary")
|
| 87 |
+
async def get_nutrition_summary(
|
| 88 |
+
date_range: DateRange,
|
| 89 |
+
db: Session = Depends(get_db)
|
| 90 |
+
) -> Dict[str, Any]:
|
| 91 |
+
"""獲取營養攝入總結"""
|
| 92 |
+
meal_service = MealService(db)
|
| 93 |
+
try:
|
| 94 |
+
summary = meal_service.get_nutrition_summary(
|
| 95 |
+
start_date=date_range.start_date,
|
| 96 |
+
end_date=date_range.end_date
|
| 97 |
+
)
|
| 98 |
+
return {
|
| 99 |
+
"success": True,
|
| 100 |
+
"data": summary
|
| 101 |
+
}
|
| 102 |
+
except Exception as e:
|
| 103 |
+
raise HTTPException(status_code=500, detail=str(e))
|
services/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file makes the services directory a Python package
|
| 2 |
+
# Import the HybridFoodAnalyzer class to make it available when importing from app.services
|
| 3 |
+
from .food_analyzer_service import HybridFoodAnalyzer
|
| 4 |
+
|
| 5 |
+
__all__ = ['HybridFoodAnalyzer']
|
services/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (273 Bytes). View file
|
|
|
services/__pycache__/ai_service.cpython-313.pyc
ADDED
|
Binary file (3.44 kB). View file
|
|
|
services/__pycache__/food_analyzer_service.cpython-313.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
services/__pycache__/meal_service.cpython-313.pyc
ADDED
|
Binary file (4.13 kB). View file
|
|
|
services/__pycache__/nutrition_api_service.cpython-313.pyc
ADDED
|
Binary file (4.14 kB). View file
|
|
|
services/ai_service.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 檔案路徑: backend/app/services/ai_service.py
|
| 2 |
+
|
| 3 |
+
from transformers.pipelines import pipeline
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import io
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# 設置日誌
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# 全局變量
|
| 13 |
+
image_classifier = None
|
| 14 |
+
|
| 15 |
+
def load_model():
|
| 16 |
+
"""載入模型的函數"""
|
| 17 |
+
global image_classifier
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
logger.info("正在載入食物辨識模型...")
|
| 21 |
+
# 載入模型 - 移除不支持的參數
|
| 22 |
+
image_classifier = pipeline(
|
| 23 |
+
"image-classification",
|
| 24 |
+
model="juliensimon/autotrain-food101-1471154053",
|
| 25 |
+
device=-1 # 使用CPU
|
| 26 |
+
)
|
| 27 |
+
logger.info("模型載入成功!")
|
| 28 |
+
return True
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"模型載入失敗: {str(e)}")
|
| 31 |
+
image_classifier = None
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
def classify_food_image(image_bytes: bytes) -> str:
|
| 35 |
+
"""
|
| 36 |
+
接收圖片的二進位制數據,進行分類並返回可能性最高的食物名稱。
|
| 37 |
+
"""
|
| 38 |
+
global image_classifier
|
| 39 |
+
|
| 40 |
+
# 如果模型未載入,嘗試重新載入
|
| 41 |
+
if image_classifier is None:
|
| 42 |
+
logger.warning("模型未載入,嘗試重新載入...")
|
| 43 |
+
if not load_model():
|
| 44 |
+
return "Error: Model not loaded"
|
| 45 |
+
|
| 46 |
+
if image_classifier is None:
|
| 47 |
+
return "Error: Model could not be loaded"
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
# 驗證圖片數據
|
| 51 |
+
if not image_bytes:
|
| 52 |
+
return "Error: Empty image data"
|
| 53 |
+
|
| 54 |
+
# 從記憶體中的 bytes 打開圖片
|
| 55 |
+
image = Image.open(io.BytesIO(image_bytes))
|
| 56 |
+
|
| 57 |
+
# 確保圖片是RGB格式
|
| 58 |
+
if image.mode != 'RGB':
|
| 59 |
+
image = image.convert('RGB')
|
| 60 |
+
|
| 61 |
+
logger.info(f"處理圖片,尺寸: {image.size}")
|
| 62 |
+
|
| 63 |
+
# 使用模型管線進行分類
|
| 64 |
+
pipeline_output = image_classifier(image)
|
| 65 |
+
|
| 66 |
+
logger.info(f"模型輸出: {pipeline_output}")
|
| 67 |
+
|
| 68 |
+
# 處理輸出結果
|
| 69 |
+
if not pipeline_output:
|
| 70 |
+
return "Unknown"
|
| 71 |
+
|
| 72 |
+
# pipeline_output 通常是一個列表
|
| 73 |
+
if isinstance(pipeline_output, list) and len(pipeline_output) > 0:
|
| 74 |
+
result = pipeline_output[0]
|
| 75 |
+
if isinstance(result, dict) and 'label' in result:
|
| 76 |
+
label = result['label']
|
| 77 |
+
confidence = result.get('score', 0)
|
| 78 |
+
|
| 79 |
+
logger.info(f"辨識結果: {label}, 信心度: {confidence:.2f}")
|
| 80 |
+
|
| 81 |
+
# 標籤可能包含底線,我們將其替換為空格,並讓首字母大寫
|
| 82 |
+
formatted_label = str(label).replace('_', ' ').title()
|
| 83 |
+
return formatted_label
|
| 84 |
+
|
| 85 |
+
return "Unknown"
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
logger.error(f"圖片分類過程中發生錯誤: {str(e)}")
|
| 89 |
+
return f"Error: {str(e)}"
|
| 90 |
+
|
| 91 |
+
# 在模塊載入時嘗試載入模型
|
| 92 |
+
logger.info("初始化 AI 服務...")
|
| 93 |
+
load_model()
|
| 94 |
+
|
| 95 |
+
__all__ = ["classify_food_image"]
|
services/food_analyzer_service.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import json
|
| 3 |
+
from typing import Dict, Any, Optional
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
import base64
|
| 7 |
+
import os
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
# Load environment variables
|
| 11 |
+
load_dotenv()
|
| 12 |
+
|
| 13 |
+
class HybridFoodAnalyzer:
|
| 14 |
+
def __init__(self, claude_api_key: Optional[str] = None):
|
| 15 |
+
"""
|
| 16 |
+
Initialize the HybridFoodAnalyzer with HuggingFace model and Claude API.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
claude_api_key: Optional API key for Claude. If not provided, will try to get from environment variable CLAUDE_API_KEY.
|
| 20 |
+
"""
|
| 21 |
+
# Initialize HuggingFace model
|
| 22 |
+
from transformers import AutoImageProcessor, AutoModelForImageClassification
|
| 23 |
+
|
| 24 |
+
print("Loading HuggingFace food recognition model...")
|
| 25 |
+
self.processor = AutoImageProcessor.from_pretrained("nateraw/food")
|
| 26 |
+
self.model = AutoModelForImageClassification.from_pretrained("nateraw/food")
|
| 27 |
+
self.model.eval() # Set model to evaluation mode
|
| 28 |
+
|
| 29 |
+
# Initialize Claude API
|
| 30 |
+
print("Initializing Claude API...")
|
| 31 |
+
import anthropic
|
| 32 |
+
self.claude_api_key = claude_api_key or os.getenv('CLAUDE_API_KEY')
|
| 33 |
+
if not self.claude_api_key:
|
| 34 |
+
raise ValueError("Claude API key is required. Please set CLAUDE_API_KEY environment variable or pass it as an argument.")
|
| 35 |
+
|
| 36 |
+
self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key)
|
| 37 |
+
|
| 38 |
+
def recognize_food(self, image: Image.Image) -> Dict[str, Any]:
|
| 39 |
+
"""
|
| 40 |
+
Recognize food from an image using HuggingFace model.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
image: PIL Image object containing the food image
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Dictionary containing food name and confidence score
|
| 47 |
+
"""
|
| 48 |
+
try:
|
| 49 |
+
print("Processing image for food recognition...")
|
| 50 |
+
inputs = self.processor(images=image, return_tensors="pt")
|
| 51 |
+
|
| 52 |
+
with torch.no_grad():
|
| 53 |
+
outputs = self.model(**inputs)
|
| 54 |
+
predictions = torch.nn.functional.softmax(outputs.logits, dim=-1)
|
| 55 |
+
|
| 56 |
+
predicted_class_id = int(predictions.argmax().item())
|
| 57 |
+
confidence = predictions[0][predicted_class_id].item()
|
| 58 |
+
food_name = self.model.config.id2label[predicted_class_id]
|
| 59 |
+
|
| 60 |
+
# Map common food names to Chinese
|
| 61 |
+
food_name_mapping = {
|
| 62 |
+
"hamburger": "漢堡",
|
| 63 |
+
"pizza": "披薩",
|
| 64 |
+
"sushi": "壽司",
|
| 65 |
+
"fried rice": "炒飯",
|
| 66 |
+
"chicken wings": "雞翅",
|
| 67 |
+
"salad": "沙拉",
|
| 68 |
+
"apple": "蘋果",
|
| 69 |
+
"banana": "香蕉",
|
| 70 |
+
"orange": "橙子",
|
| 71 |
+
"noodles": "麵條"
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
chinese_name = food_name_mapping.get(food_name.lower(), food_name)
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"food_name": food_name,
|
| 78 |
+
"chinese_name": chinese_name,
|
| 79 |
+
"confidence": confidence,
|
| 80 |
+
"success": True
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"Error in food recognition: {str(e)}")
|
| 85 |
+
return {
|
| 86 |
+
"success": False,
|
| 87 |
+
"error": str(e)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
def analyze_nutrition(self, food_name: str) -> Dict[str, Any]:
|
| 91 |
+
"""
|
| 92 |
+
Analyze nutrition information for a given food using Claude API.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
food_name: Name of the food to analyze
|
| 96 |
+
|
| 97 |
+
Returns:
|
| 98 |
+
Dictionary containing nutrition information
|
| 99 |
+
"""
|
| 100 |
+
try:
|
| 101 |
+
print(f"Analyzing nutrition for {food_name}...")
|
| 102 |
+
prompt = f"""
|
| 103 |
+
請分析 {food_name} 的營養成分(每100g),並以JSON格式回覆:
|
| 104 |
+
{{
|
| 105 |
+
"calories": 數值,
|
| 106 |
+
"protein": 數值,
|
| 107 |
+
"fat": 數值,
|
| 108 |
+
"carbs": 數值,
|
| 109 |
+
"fiber": 數值,
|
| 110 |
+
"sugar": 數值,
|
| 111 |
+
"sodium": 數值
|
| 112 |
+
}}
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
message = self.claude_client.messages.create(
|
| 116 |
+
model="claude-3-sonnet-20240229",
|
| 117 |
+
max_tokens=500,
|
| 118 |
+
messages=[{"role": "user", "content": prompt}]
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Extract and parse the JSON response
|
| 122 |
+
response_text = message.content[0].get("text", "") if isinstance(message.content[0], dict) else str(message.content[0])
|
| 123 |
+
try:
|
| 124 |
+
nutrition_data = json.loads(response_text.strip())
|
| 125 |
+
return {
|
| 126 |
+
"success": True,
|
| 127 |
+
"nutrition": nutrition_data
|
| 128 |
+
}
|
| 129 |
+
except json.JSONDecodeError as e:
|
| 130 |
+
print(f"Error parsing Claude response: {e}")
|
| 131 |
+
return {
|
| 132 |
+
"success": False,
|
| 133 |
+
"error": f"Failed to parse nutrition data: {e}",
|
| 134 |
+
"raw_response": response_text
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
print(f"Error in nutrition analysis: {str(e)}")
|
| 139 |
+
return {
|
| 140 |
+
"success": False,
|
| 141 |
+
"error": str(e)
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
def process_image(self, image_data: bytes) -> Dict[str, Any]:
|
| 145 |
+
"""
|
| 146 |
+
Process an image to recognize food and analyze its nutrition.
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
image_data: Binary image data
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Dictionary containing recognition and analysis results
|
| 153 |
+
"""
|
| 154 |
+
try:
|
| 155 |
+
# Convert bytes to PIL Image
|
| 156 |
+
image = Image.open(BytesIO(image_data))
|
| 157 |
+
|
| 158 |
+
# Step 1: Recognize food
|
| 159 |
+
recognition_result = self.recognize_food(image)
|
| 160 |
+
if not recognition_result.get("success"):
|
| 161 |
+
return recognition_result
|
| 162 |
+
|
| 163 |
+
# Step 2: Analyze nutrition
|
| 164 |
+
nutrition_result = self.analyze_nutrition(recognition_result["food_name"])
|
| 165 |
+
if not nutrition_result.get("success"):
|
| 166 |
+
return nutrition_result
|
| 167 |
+
|
| 168 |
+
# Calculate health score
|
| 169 |
+
nutrition = nutrition_result["nutrition"]
|
| 170 |
+
health_score = self.calculate_health_score(nutrition)
|
| 171 |
+
|
| 172 |
+
# Generate recommendations and warnings
|
| 173 |
+
recommendations = self.generate_recommendations(nutrition)
|
| 174 |
+
warnings = self.generate_warnings(nutrition)
|
| 175 |
+
|
| 176 |
+
return {
|
| 177 |
+
"success": True,
|
| 178 |
+
"food_name": recognition_result["food_name"],
|
| 179 |
+
"chinese_name": recognition_result["chinese_name"],
|
| 180 |
+
"confidence": recognition_result["confidence"],
|
| 181 |
+
"nutrition": nutrition,
|
| 182 |
+
"analysis": {
|
| 183 |
+
"healthScore": health_score,
|
| 184 |
+
"recommendations": recommendations,
|
| 185 |
+
"warnings": warnings
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
return {
|
| 191 |
+
"success": False,
|
| 192 |
+
"error": f"Failed to process image: {str(e)}"
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
def calculate_health_score(self, nutrition: Dict[str, float]) -> int:
|
| 196 |
+
"""Calculate a health score based on nutritional values"""
|
| 197 |
+
score = 100
|
| 198 |
+
|
| 199 |
+
# 熱量評分
|
| 200 |
+
if nutrition["calories"] > 400:
|
| 201 |
+
score -= 20
|
| 202 |
+
elif nutrition["calories"] > 300:
|
| 203 |
+
score -= 10
|
| 204 |
+
|
| 205 |
+
# 脂肪評分
|
| 206 |
+
if nutrition["fat"] > 20:
|
| 207 |
+
score -= 15
|
| 208 |
+
elif nutrition["fat"] > 15:
|
| 209 |
+
score -= 8
|
| 210 |
+
|
| 211 |
+
# 蛋白質評分
|
| 212 |
+
if nutrition["protein"] > 15:
|
| 213 |
+
score += 10
|
| 214 |
+
elif nutrition["protein"] < 5:
|
| 215 |
+
score -= 10
|
| 216 |
+
|
| 217 |
+
# 鈉含量評分
|
| 218 |
+
if "sodium" in nutrition and nutrition["sodium"] > 800:
|
| 219 |
+
score -= 15
|
| 220 |
+
elif "sodium" in nutrition and nutrition["sodium"] > 600:
|
| 221 |
+
score -= 8
|
| 222 |
+
|
| 223 |
+
return max(0, min(100, score))
|
| 224 |
+
|
| 225 |
+
def generate_recommendations(self, nutrition: Dict[str, float]) -> list:
|
| 226 |
+
"""Generate dietary recommendations based on nutrition data"""
|
| 227 |
+
recommendations = []
|
| 228 |
+
|
| 229 |
+
if nutrition["protein"] < 10:
|
| 230 |
+
recommendations.append("建議增加蛋白質攝取,可搭配雞蛋或豆腐")
|
| 231 |
+
|
| 232 |
+
if nutrition["fat"] > 20:
|
| 233 |
+
recommendations.append("脂肪含量較高,建議適量食用")
|
| 234 |
+
|
| 235 |
+
if "fiber" in nutrition and nutrition["fiber"] < 3:
|
| 236 |
+
recommendations.append("纖維含量不足,建議搭配蔬菜沙拉")
|
| 237 |
+
|
| 238 |
+
if "sodium" in nutrition and nutrition["sodium"] > 600:
|
| 239 |
+
recommendations.append("鈉含量偏高,建議多喝水並減少其他鹽分攝取")
|
| 240 |
+
|
| 241 |
+
return recommendations
|
| 242 |
+
|
| 243 |
+
def generate_warnings(self, nutrition: Dict[str, float]) -> list:
|
| 244 |
+
"""Generate dietary warnings based on nutrition data"""
|
| 245 |
+
warnings = []
|
| 246 |
+
|
| 247 |
+
if nutrition["calories"] > 500:
|
| 248 |
+
warnings.append("高熱量食物")
|
| 249 |
+
|
| 250 |
+
if nutrition["fat"] > 25:
|
| 251 |
+
warnings.append("高脂肪食物")
|
| 252 |
+
|
| 253 |
+
if "sodium" in nutrition and nutrition["sodium"] > 1000:
|
| 254 |
+
warnings.append("高鈉食物")
|
| 255 |
+
|
| 256 |
+
return warnings
|
services/meal_service.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import List, Dict, Any, Optional
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from ..models.meal_log import MealLog
|
| 5 |
+
|
| 6 |
+
class MealService:
|
| 7 |
+
def __init__(self, db: Session):
|
| 8 |
+
self.db = db
|
| 9 |
+
|
| 10 |
+
def create_meal_log(
|
| 11 |
+
self,
|
| 12 |
+
food_name: str,
|
| 13 |
+
meal_type: str,
|
| 14 |
+
portion_size: str,
|
| 15 |
+
nutrition: Dict[str, float],
|
| 16 |
+
meal_date: datetime,
|
| 17 |
+
image_url: Optional[str] = None,
|
| 18 |
+
ai_analysis: Optional[Dict[str, Any]] = None
|
| 19 |
+
) -> MealLog:
|
| 20 |
+
"""創建新的用餐記錄"""
|
| 21 |
+
meal_log = MealLog(
|
| 22 |
+
food_name=food_name,
|
| 23 |
+
meal_type=meal_type,
|
| 24 |
+
portion_size=portion_size,
|
| 25 |
+
calories=nutrition.get('calories', 0),
|
| 26 |
+
protein=nutrition.get('protein', 0),
|
| 27 |
+
carbs=nutrition.get('carbs', 0),
|
| 28 |
+
fat=nutrition.get('fat', 0),
|
| 29 |
+
fiber=nutrition.get('fiber', 0),
|
| 30 |
+
meal_date=meal_date,
|
| 31 |
+
image_url=image_url,
|
| 32 |
+
ai_analysis=ai_analysis,
|
| 33 |
+
created_at=datetime.utcnow()
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
self.db.add(meal_log)
|
| 37 |
+
self.db.commit()
|
| 38 |
+
self.db.refresh(meal_log)
|
| 39 |
+
return meal_log
|
| 40 |
+
|
| 41 |
+
def get_meal_logs(
|
| 42 |
+
self,
|
| 43 |
+
start_date: Optional[datetime] = None,
|
| 44 |
+
end_date: Optional[datetime] = None,
|
| 45 |
+
meal_type: Optional[str] = None
|
| 46 |
+
) -> List[MealLog]:
|
| 47 |
+
"""獲取用餐記錄"""
|
| 48 |
+
query = self.db.query(MealLog)
|
| 49 |
+
|
| 50 |
+
if start_date:
|
| 51 |
+
query = query.filter(MealLog.meal_date >= start_date)
|
| 52 |
+
if end_date:
|
| 53 |
+
query = query.filter(MealLog.meal_date <= end_date)
|
| 54 |
+
if meal_type:
|
| 55 |
+
query = query.filter(MealLog.meal_type == meal_type)
|
| 56 |
+
|
| 57 |
+
return query.order_by(MealLog.meal_date.desc()).all()
|
| 58 |
+
|
| 59 |
+
def get_nutrition_summary(
|
| 60 |
+
self,
|
| 61 |
+
start_date: datetime,
|
| 62 |
+
end_date: datetime
|
| 63 |
+
) -> Dict[str, float]:
|
| 64 |
+
"""獲取指定時間範圍內的營養攝入總結"""
|
| 65 |
+
meals = self.get_meal_logs(start_date, end_date)
|
| 66 |
+
|
| 67 |
+
summary = {
|
| 68 |
+
'total_calories': 0,
|
| 69 |
+
'total_protein': 0,
|
| 70 |
+
'total_carbs': 0,
|
| 71 |
+
'total_fat': 0,
|
| 72 |
+
'total_fiber': 0
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
for meal in meals:
|
| 76 |
+
# 根據份量大小調整營養值
|
| 77 |
+
multiplier = {
|
| 78 |
+
'small': 0.7,
|
| 79 |
+
'medium': 1.0,
|
| 80 |
+
'large': 1.3
|
| 81 |
+
}.get(meal.portion_size, 1.0)
|
| 82 |
+
|
| 83 |
+
summary['total_calories'] += meal.calories * multiplier
|
| 84 |
+
summary['total_protein'] += meal.protein * multiplier
|
| 85 |
+
summary['total_carbs'] += meal.carbs * multiplier
|
| 86 |
+
summary['total_fat'] += meal.fat * multiplier
|
| 87 |
+
summary['total_fiber'] += meal.fiber * multiplier
|
| 88 |
+
|
| 89 |
+
return summary
|
services/nutrition_api_service.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/app/services/nutrition_api_service.py
|
| 2 |
+
import os
|
| 3 |
+
import requests
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
# 設置日誌
|
| 8 |
+
logging.basicConfig(level=logging.INFO)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
# 載入環境變數
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
# 從環境變數中獲取 API 金鑰
|
| 15 |
+
USDA_API_KEY = os.getenv("USDA_API_KEY", "DEMO_KEY")
|
| 16 |
+
USDA_API_URL = "https://api.nal.usda.gov/fdc/v1/foods/search"
|
| 17 |
+
|
| 18 |
+
# 我們關心的主要營養素及其在 USDA API 中的名稱或編號
|
| 19 |
+
# 我們可以透過 nutrient.nutrientNumber 或 nutrient.name 來匹配
|
| 20 |
+
NUTRIENT_MAP = {
|
| 21 |
+
'calories': 'Energy',
|
| 22 |
+
'protein': 'Protein',
|
| 23 |
+
'fat': 'Total lipid (fat)',
|
| 24 |
+
'carbs': 'Carbohydrate, by difference',
|
| 25 |
+
'fiber': 'Fiber, total dietary',
|
| 26 |
+
'sugar': 'Total sugars',
|
| 27 |
+
'sodium': 'Sodium, Na'
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
def fetch_nutrition_data(food_name: str):
|
| 31 |
+
"""
|
| 32 |
+
從 USDA FoodData Central API 獲取食物的營養資訊。
|
| 33 |
+
|
| 34 |
+
:param food_name: 要查詢的食物名稱 (例如 "Donuts")。
|
| 35 |
+
:return: 包含營養資訊的字典,如果找不到則返回 None。
|
| 36 |
+
"""
|
| 37 |
+
if not USDA_API_KEY:
|
| 38 |
+
logger.error("USDA_API_KEY 未設定,無法查詢營養資訊。")
|
| 39 |
+
return None
|
| 40 |
+
|
| 41 |
+
params = {
|
| 42 |
+
'query': food_name,
|
| 43 |
+
'api_key': USDA_API_KEY,
|
| 44 |
+
'dataType': 'Branded', # 優先搜尋品牌食品,結果通常更符合預期
|
| 45 |
+
'pageSize': 1 # 我們只需要最相關的一筆結果
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
logger.info(f"正在向 USDA API 查詢食物:{food_name}")
|
| 50 |
+
response = requests.get(USDA_API_URL, params=params)
|
| 51 |
+
response.raise_for_status() # 如果請求失敗 (例如 4xx 或 5xx),則會拋出異常
|
| 52 |
+
|
| 53 |
+
data = response.json()
|
| 54 |
+
|
| 55 |
+
# 檢查是否有找到食物
|
| 56 |
+
if data.get('foods') and len(data['foods']) > 0:
|
| 57 |
+
food_data = data['foods'][0] # 取第一個最相關的結果
|
| 58 |
+
logger.info(f"從 API 成功獲取到食物 '{food_data.get('description')}' 的資料")
|
| 59 |
+
|
| 60 |
+
nutrition_info = {
|
| 61 |
+
"food_name": food_data.get('description', food_name).capitalize(),
|
| 62 |
+
"chinese_name": None, # USDA API 不提供中文名
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# 遍歷我們需要的營養素
|
| 66 |
+
extracted_nutrients = {key: 0.0 for key in NUTRIENT_MAP.keys()} # 初始化
|
| 67 |
+
|
| 68 |
+
for nutrient in food_data.get('foodNutrients', []):
|
| 69 |
+
for key, name in NUTRIENT_MAP.items():
|
| 70 |
+
if nutrient.get('nutrientName').strip().lower() == name.strip().lower():
|
| 71 |
+
# 將值存入我們的格式
|
| 72 |
+
extracted_nutrients[key] = float(nutrient.get('value', 0.0))
|
| 73 |
+
break # 找到後就跳出內層迴圈
|
| 74 |
+
|
| 75 |
+
nutrition_info.update(extracted_nutrients)
|
| 76 |
+
|
| 77 |
+
# 由於 USDA 不直接提供健康建議,我們先回傳原始數據
|
| 78 |
+
# 後續可以在 main.py 中根據這些數據生成我們自己的建議
|
| 79 |
+
return nutrition_info
|
| 80 |
+
|
| 81 |
+
else:
|
| 82 |
+
logger.warning(f"在 USDA API 中找不到食物:{food_name}")
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
except requests.exceptions.RequestException as e:
|
| 86 |
+
logger.error(f"請求 USDA API 時發生錯誤: {e}")
|
| 87 |
+
return None
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"處理 API 回應時發生未知錯誤: {e}")
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
if __name__ == '__main__':
|
| 93 |
+
# 測試此模組的功能
|
| 94 |
+
test_food = "donuts"
|
| 95 |
+
nutrition = fetch_nutrition_data(test_food)
|
| 96 |
+
if nutrition:
|
| 97 |
+
import json
|
| 98 |
+
print(f"成功獲取 '{test_food}' 的營養資訊:")
|
| 99 |
+
print(json.dumps(nutrition, indent=2))
|
| 100 |
+
else:
|
| 101 |
+
print(f"無法獲取 '{test_food}' 的營養資訊。")
|
setup.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from setuptools import setup, find_packages
|
| 2 |
+
|
| 3 |
+
setup(
|
| 4 |
+
name="health_assistant",
|
| 5 |
+
version="0.1.0",
|
| 6 |
+
packages=find_packages(where="backend"),
|
| 7 |
+
package_dir={"": "backend"},
|
| 8 |
+
install_requires=[
|
| 9 |
+
"fastapi>=0.68.0",
|
| 10 |
+
"uvicorn>=0.15.0",
|
| 11 |
+
"python-multipart>=0.0.5",
|
| 12 |
+
"Pillow>=8.3.1",
|
| 13 |
+
"transformers>=4.11.3",
|
| 14 |
+
"torch>=1.9.0",
|
| 15 |
+
"python-dotenv>=0.19.0",
|
| 16 |
+
"httpx>=0.19.0",
|
| 17 |
+
"pydantic>=1.8.0",
|
| 18 |
+
],
|
| 19 |
+
extras_require={
|
| 20 |
+
"dev": [
|
| 21 |
+
"pytest>=6.2.5",
|
| 22 |
+
"pytest-cov>=2.12.1",
|
| 23 |
+
"pytest-asyncio>=0.15.1",
|
| 24 |
+
"black>=21.7b0",
|
| 25 |
+
"isort>=5.9.3",
|
| 26 |
+
"mypy>=0.910",
|
| 27 |
+
],
|
| 28 |
+
},
|
| 29 |
+
python_requires=">=3.8",
|
| 30 |
+
)
|
test_pytest.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
def test_example():
|
| 2 |
+
assert 1 + 1 == 2
|