267 lines
7.9 KiB
Python
267 lines
7.9 KiB
Python
"""食谱服务 - 包含热量识别与营养分析"""
|
|
|
|
import base64
|
|
import io
|
|
from typing import Optional
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ...models import Recipe
|
|
from ...schemas import RecipeCreate, RecipeUpdate, RecipeResponse
|
|
from ..ai_service_core import AIService
|
|
from ...config.ai_prompts import PROMPT_ANALYZE_FOOD_CALORIES
|
|
|
|
|
|
class RecipeService:
|
|
"""食谱服务"""
|
|
|
|
def __init__(self, ai_service: AIService):
|
|
self.ai_service = ai_service
|
|
|
|
async def create_recipe(
|
|
self,
|
|
db: AsyncSession,
|
|
recipe_data: RecipeCreate,
|
|
tenant_id: int,
|
|
) -> Recipe:
|
|
"""创建食谱"""
|
|
recipe = Recipe(
|
|
**recipe_data.model_dump(),
|
|
tenant_id=tenant_id,
|
|
status="draft",
|
|
)
|
|
db.add(recipe)
|
|
await db.commit()
|
|
await db.refresh(recipe)
|
|
return recipe
|
|
|
|
async def get_recipe(
|
|
self,
|
|
db: AsyncSession,
|
|
recipe_id: int,
|
|
tenant_id: int,
|
|
) -> Optional[Recipe]:
|
|
"""获取单个食谱"""
|
|
result = await db.execute(
|
|
select(Recipe).where(
|
|
Recipe.id == recipe_id,
|
|
Recipe.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def list_recipes(
|
|
self,
|
|
db: AsyncSession,
|
|
tenant_id: int,
|
|
skip: int = 0,
|
|
limit: int = 20,
|
|
category: Optional[str] = None,
|
|
cuisine: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
is_favorite: Optional[bool] = None,
|
|
) -> list[Recipe]:
|
|
"""获取食谱列表"""
|
|
query = select(Recipe).where(Recipe.tenant_id == tenant_id)
|
|
|
|
if category:
|
|
query = query.where(Recipe.category == category)
|
|
if cuisine:
|
|
query = query.where(Recipe.cuisine == cuisine)
|
|
if status:
|
|
query = query.where(Recipe.status == status)
|
|
if is_favorite is not None:
|
|
query = query.where(Recipe.is_favorite == is_favorite)
|
|
|
|
query = query.order_by(Recipe.created_at.desc()).offset(skip).limit(limit)
|
|
result = await db.execute(query)
|
|
return list(result.scalars().all())
|
|
|
|
async def update_recipe(
|
|
self,
|
|
db: AsyncSession,
|
|
recipe_id: int,
|
|
recipe_data: RecipeUpdate,
|
|
tenant_id: int,
|
|
) -> Optional[Recipe]:
|
|
"""更新食谱"""
|
|
recipe = await self.get_recipe(db, recipe_id, tenant_id)
|
|
if not recipe:
|
|
return None
|
|
|
|
update_data = recipe_data.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(recipe, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(recipe)
|
|
return recipe
|
|
|
|
async def delete_recipe(
|
|
self,
|
|
db: AsyncSession,
|
|
recipe_id: int,
|
|
tenant_id: int,
|
|
) -> bool:
|
|
"""删除食谱"""
|
|
recipe = await self.get_recipe(db, recipe_id, tenant_id)
|
|
if not recipe:
|
|
return False
|
|
|
|
await db.delete(recipe)
|
|
await db.commit()
|
|
return True
|
|
|
|
async def analyze_food_from_description(
|
|
self,
|
|
db: AsyncSession,
|
|
description: str,
|
|
tenant_id: int,
|
|
) -> dict:
|
|
"""
|
|
基于食物描述分析热量和营养成分
|
|
|
|
注意:此方法使用纯文本分析。对于图片识别,
|
|
建议先使用图像识别服务将图片转为描述文本,再调用此方法。
|
|
"""
|
|
tid = str(tenant_id)
|
|
prompt = PROMPT_ANALYZE_FOOD_CALORIES.format(food_description=description)
|
|
|
|
try:
|
|
response_text = await self.ai_service._invoke_with_retry(
|
|
prompt,
|
|
db=db,
|
|
tenant_id=tid,
|
|
function_name="analyze_food_calories",
|
|
use_cache=True,
|
|
)
|
|
result = self.ai_service._parse_json(response_text)
|
|
return {
|
|
"success": True,
|
|
"data": result,
|
|
"message": "分析成功",
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"success": False,
|
|
"data": None,
|
|
"message": f"分析失败: {str(e)}",
|
|
}
|
|
|
|
async def create_recipe_with_ai_analysis(
|
|
self,
|
|
db: AsyncSession,
|
|
recipe_data: RecipeCreate,
|
|
food_description: str,
|
|
tenant_id: int,
|
|
) -> tuple[Recipe, dict]:
|
|
"""
|
|
创建食谱并进行 AI 热量分析
|
|
|
|
Args:
|
|
db: 数据库会话
|
|
recipe_data: 食谱基础数据
|
|
food_description: 食物描述(可以是图片识别后的描述)
|
|
tenant_id: 租户ID
|
|
|
|
Returns:
|
|
(Recipe, analysis_result): 创建的食谱对象和分析结果
|
|
"""
|
|
# 先创建食谱
|
|
recipe = await self.create_recipe(db, recipe_data, tenant_id)
|
|
|
|
# 进行 AI 分析
|
|
analysis = await self.analyze_food_from_description(
|
|
db, food_description, tenant_id
|
|
)
|
|
|
|
if analysis["success"] and analysis["data"]:
|
|
# 更新食谱的营养信息
|
|
data = analysis["data"]
|
|
recipe.calories = data.get("calories")
|
|
recipe.protein = data.get("protein")
|
|
recipe.fat = data.get("fat")
|
|
recipe.carbs = data.get("carbs")
|
|
recipe.fiber = data.get("fiber")
|
|
recipe.ai_recognition_confidence = data.get("confidence")
|
|
recipe.ai_analysis_result = data
|
|
recipe.category = data.get("category") or recipe_data.category
|
|
recipe.cuisine = data.get("cuisine") or recipe_data.cuisine
|
|
|
|
await db.commit()
|
|
await db.refresh(recipe)
|
|
|
|
return recipe, analysis
|
|
|
|
def decode_base64_image(self, image_base64: str) -> tuple[bytes, str]:
|
|
"""
|
|
解码 Base64 图片数据
|
|
|
|
Args:
|
|
image_base64: Base64 编码的图片数据(可能包含 data:image/xxx;base64, 前缀)
|
|
|
|
Returns:
|
|
(image_bytes, mime_type): 图片字节数据和 MIME 类型
|
|
"""
|
|
# 移除可能的数据 URI 前缀
|
|
if "," in image_base64:
|
|
header, image_base64 = image_base64.split(",", 1)
|
|
# 提取 MIME 类型
|
|
mime_type = header.split(";")[0].split(":")[-1] if ":" in header else "image/jpeg"
|
|
else:
|
|
mime_type = "image/jpeg"
|
|
|
|
# 解码 Base64
|
|
image_bytes = base64.b64decode(image_base64)
|
|
return image_bytes, mime_type
|
|
|
|
async def upload_image(
|
|
self,
|
|
image_base64: str,
|
|
filename: Optional[str] = None,
|
|
) -> str:
|
|
"""
|
|
上传图片并返回 URL
|
|
|
|
注意:这是一个示例实现,实际项目中应该集成:
|
|
- AWS S3
|
|
- 阿里云 OSS
|
|
- 腾讯云 COS
|
|
- MinIO
|
|
等对象存储服务
|
|
|
|
Args:
|
|
image_base64: Base64 编码的图片数据
|
|
filename: 原始文件名
|
|
|
|
Returns:
|
|
图片 URL
|
|
"""
|
|
# TODO: 集成实际的文件存储服务
|
|
# 这里只是示例,实际应该上传到对象存储
|
|
image_bytes, mime_type = self.decode_base64_image(image_base64)
|
|
|
|
# 示例:生成一个模拟 URL
|
|
# 实际应该上传后返回真实的 URL
|
|
import hashlib
|
|
import time
|
|
|
|
file_hash = hashlib.md5(image_bytes).hexdigest()
|
|
timestamp = int(time.time())
|
|
mock_url = f"https://example.com/recipe-images/{file_hash}_{timestamp}.jpg"
|
|
|
|
return mock_url
|
|
|
|
|
|
# 单例
|
|
_recipe_service: Optional[RecipeService] = None
|
|
|
|
|
|
def get_recipe_service() -> RecipeService:
|
|
"""获取食谱服务单例"""
|
|
global _recipe_service
|
|
if _recipe_service is None:
|
|
from ..ai_service_core import get_ai_service
|
|
_recipe_service = RecipeService(ai_service=get_ai_service())
|
|
return _recipe_service
|