402 lines
13 KiB
Python
402 lines
13 KiB
Python
"""食谱系统信号源 API
|
||
|
||
功能:
|
||
1. 接收食谱系统的 webhook 请求
|
||
2. 将食谱识别需求转换为 PMFlow 信号
|
||
3. 自动关联到指定项目(食谱系统项目)
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional, Dict, Any
|
||
import json
|
||
|
||
from ..database import get_db
|
||
from ..dependencies.tenant import get_tenant_id
|
||
from ..core.logging import get_logger
|
||
from ..models import Signal, ProjectSignalLink, Project
|
||
|
||
router = APIRouter(prefix="/signal-sources", tags=["Signal Sources"])
|
||
logger = get_logger(__name__)
|
||
|
||
RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc")
|
||
|
||
|
||
async def _resolve_target_project_id(
|
||
*,
|
||
db: AsyncSession,
|
||
tenant_id: int,
|
||
project_id: Optional[int],
|
||
) -> int:
|
||
"""解析目标项目:优先显式 project_id,其次按关键字自动匹配。"""
|
||
from sqlalchemy import select, or_
|
||
|
||
if project_id is not None:
|
||
result = await db.execute(
|
||
select(Project.id).where(
|
||
Project.tenant_id == tenant_id,
|
||
Project.id == int(project_id),
|
||
).limit(1)
|
||
)
|
||
found = result.scalar_one_or_none()
|
||
if found is None:
|
||
raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限")
|
||
return int(found)
|
||
|
||
keyword_conditions = []
|
||
for keyword in RECIPE_PROJECT_KEYWORDS:
|
||
keyword_conditions.extend([
|
||
Project.title.ilike(f"%{keyword}%"),
|
||
Project.name.ilike(f"%{keyword}%"),
|
||
])
|
||
|
||
match_result = await db.execute(
|
||
select(Project.id).where(
|
||
Project.tenant_id == tenant_id,
|
||
or_(*keyword_conditions),
|
||
).limit(1)
|
||
)
|
||
matched = match_result.scalar_one_or_none()
|
||
if matched is not None:
|
||
return int(matched)
|
||
|
||
# 兜底:若租户仅存在一个活跃项目,自动绑定,避免“已调用但未入库”。
|
||
active_result = await db.execute(
|
||
select(Project.id).where(
|
||
Project.tenant_id == tenant_id,
|
||
Project.status == "active",
|
||
).limit(2)
|
||
)
|
||
active_ids = list(active_result.scalars().all())
|
||
if len(active_ids) == 1:
|
||
return int(active_ids[0])
|
||
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)",
|
||
)
|
||
|
||
|
||
# ============================================================================
|
||
# 食谱系统信号源 Webhook
|
||
# ============================================================================
|
||
|
||
class RecipeRecognitionRequest(BaseModel):
|
||
"""食谱识别请求"""
|
||
# 食谱信息
|
||
food_name: str = Field(..., description="食物名称")
|
||
food_description: Optional[str] = Field(None, description="食物描述")
|
||
image_url: Optional[str] = Field(None, description="图片URL")
|
||
category: Optional[str] = Field(None, description="分类")
|
||
cuisine: Optional[str] = Field(None, description="菜系")
|
||
|
||
# 营养信息(如果来源系统已分析)
|
||
calories: Optional[float] = Field(None, description="热量(千卡)")
|
||
protein: Optional[float] = Field(None, description="蛋白质(克)")
|
||
fat: Optional[float] = Field(None, description="脂肪(克)")
|
||
carbs: Optional[float] = Field(None, description="碳水化合物(克)")
|
||
fiber: Optional[float] = Field(None, description="膳食纤维(克)")
|
||
|
||
# 意图
|
||
intent: str = Field(
|
||
default="analyze",
|
||
description="意图:analyze-热量分析, save-保存食谱, recommend-推荐菜谱"
|
||
)
|
||
|
||
# 用户信息(可选)
|
||
user_name: Optional[str] = Field(None, description="用户名")
|
||
|
||
|
||
class RecipeRecognitionResponse(BaseModel):
|
||
"""响应"""
|
||
success: bool
|
||
signal_id: Optional[int] = None
|
||
project_id: Optional[int] = None
|
||
message: str
|
||
details: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
@router.post("/recipe/webhook", response_model=RecipeRecognitionResponse, summary="食谱系统 Webhook")
|
||
async def recipe_webhook(
|
||
request: RecipeRecognitionRequest,
|
||
project_id: Optional[int] = Query(None, description="项目ID,不传则自动查找食谱系统项目"),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
):
|
||
"""
|
||
接收食谱系统的识别请求
|
||
|
||
用法:
|
||
- 食谱系统完成拍照识别后,调用此 API 将需求推送到 PMFlow
|
||
- PMFlow 会自动创建信号并关联到食谱系统项目
|
||
|
||
请求示例:
|
||
```json
|
||
{
|
||
"food_name": "红烧肉",
|
||
"food_description": "一块红烧肉,约200克,肥瘦相间",
|
||
"image_url": "https://example.com/image.jpg",
|
||
"category": "肉类",
|
||
"cuisine": "中式",
|
||
"calories": 350,
|
||
"protein": 20,
|
||
"fat": 25,
|
||
"carbs": 10,
|
||
"fiber": 0,
|
||
"intent": "analyze",
|
||
"user_name": "张三"
|
||
}
|
||
```
|
||
"""
|
||
try:
|
||
# 1. 查找目标项目
|
||
target_project_id = await _resolve_target_project_id(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
)
|
||
|
||
# 2. 构建信号内容
|
||
content_parts = []
|
||
content_parts.append(f"食物:{request.food_name}")
|
||
|
||
if request.food_description:
|
||
content_parts.append(f"描述:{request.food_description}")
|
||
|
||
# 营养信息
|
||
if request.calories or request.protein or request.fat or request.carbs or request.fiber:
|
||
nutrition = []
|
||
if request.calories:
|
||
nutrition.append(f"热量 {request.calories} kcal")
|
||
if request.protein:
|
||
nutrition.append(f"蛋白 {request.protein}g")
|
||
if request.fat:
|
||
nutrition.append(f"脂肪 {request.fat}g")
|
||
if request.carbs:
|
||
nutrition.append(f"碳水 {request.carbs}g")
|
||
if request.fiber:
|
||
nutrition.append(f"纤维 {request.fiber}g")
|
||
content_parts.append("营养:" + ", ".join(nutrition))
|
||
|
||
if request.category:
|
||
content_parts.append(f"分类:{request.category}")
|
||
if request.cuisine:
|
||
content_parts.append(f"菜系:{request.cuisine}")
|
||
content_parts.append(f"意图:{request.intent}")
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
# 3. 构建标题
|
||
intent_text = {
|
||
"analyze": "热量分析",
|
||
"save": "保存食谱",
|
||
"recommend": "推荐菜谱"
|
||
}.get(request.intent, request.intent)
|
||
|
||
title = f"🍽️ 食谱{intent_text}:{request.food_name}"
|
||
|
||
# 4. 构建摘要
|
||
summary = f"食物:{request.food_name}"
|
||
if request.calories:
|
||
summary += f"\n热量:{request.calories} kcal"
|
||
if request.category:
|
||
summary += f"\n分类:{request.category}"
|
||
summary += f"\n意图:{intent_text}"
|
||
|
||
# 5. 构建信号元数据
|
||
signal_metadata = {
|
||
"source": "recipe_system",
|
||
"intent": request.intent,
|
||
"category": request.category,
|
||
"cuisine": request.cuisine,
|
||
"nutrition": {
|
||
"calories": request.calories,
|
||
"protein": request.protein,
|
||
"fat": request.fat,
|
||
"carbs": request.carbs,
|
||
"fiber": request.fiber,
|
||
},
|
||
"image_url": request.image_url,
|
||
"user_name": request.user_name,
|
||
}
|
||
|
||
# 6. 创建信号
|
||
signal = Signal(
|
||
tenant_id=tenant_id,
|
||
source="recipe_system",
|
||
title=title,
|
||
content=content,
|
||
summary=summary,
|
||
signal_type="recipe_recognition",
|
||
priority="medium",
|
||
impact_score=0.5,
|
||
status="待处理",
|
||
processed=False,
|
||
strategic_analysis=json.dumps(signal_metadata, ensure_ascii=False),
|
||
)
|
||
|
||
db.add(signal)
|
||
await db.flush() # 获取 signal.id
|
||
|
||
# 7. 关联到项目
|
||
link = ProjectSignalLink(
|
||
tenant_id=tenant_id,
|
||
project_id=target_project_id,
|
||
signal_id=signal.id,
|
||
)
|
||
db.add(link)
|
||
|
||
await db.commit()
|
||
await db.refresh(signal)
|
||
|
||
return RecipeRecognitionResponse(
|
||
success=True,
|
||
signal_id=signal.id,
|
||
project_id=target_project_id,
|
||
message="信号创建成功并已关联到食谱系统项目",
|
||
details={
|
||
"signal_id": signal.id,
|
||
"project_id": target_project_id,
|
||
"title": title,
|
||
"status": signal.status,
|
||
}
|
||
)
|
||
|
||
except HTTPException:
|
||
await db.rollback()
|
||
raise
|
||
except Exception as e:
|
||
await db.rollback()
|
||
logger.exception(
|
||
"recipe_webhook_create_signal_failed",
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
food_name=request.food_name,
|
||
error=str(e),
|
||
)
|
||
raise HTTPException(status_code=500, detail=f"创建信号失败: {str(e)}")
|
||
|
||
|
||
@router.get("/recipe/status/{signal_id}", summary="查询食谱信号状态")
|
||
async def get_recipe_signal_status(
|
||
signal_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
):
|
||
"""查询食谱识别信号的处理状态"""
|
||
from sqlalchemy import select
|
||
|
||
result = await db.execute(
|
||
select(Signal).where(
|
||
Signal.id == signal_id,
|
||
Signal.tenant_id == tenant_id,
|
||
Signal.source == "recipe_system",
|
||
)
|
||
)
|
||
signal = result.scalar_one_or_none()
|
||
|
||
if not signal:
|
||
raise HTTPException(status_code=404, detail="信号不存在")
|
||
|
||
return {
|
||
"signal_id": signal.id,
|
||
"title": signal.title,
|
||
"status": signal.status,
|
||
"processed": signal.processed,
|
||
"summary": signal.summary,
|
||
}
|
||
|
||
|
||
# ============================================================================
|
||
# 批量导入食谱记录
|
||
# ============================================================================
|
||
|
||
class RecipeBatchRequest(BaseModel):
|
||
"""批量导入请求"""
|
||
recipes: list[RecipeRecognitionRequest] = Field(..., description="食谱列表")
|
||
|
||
|
||
@router.post("/recipe/batch", response_model=RecipeRecognitionResponse, summary="批量导入食谱")
|
||
async def recipe_batch_import(
|
||
request: RecipeBatchRequest,
|
||
project_id: Optional[int] = Query(None, description="项目ID"),
|
||
db: AsyncSession = Depends(get_db),
|
||
tenant_id: int = Depends(get_tenant_id),
|
||
):
|
||
"""批量导入食谱记录,每个都会创建信号"""
|
||
try:
|
||
target_project_id = await _resolve_target_project_id(
|
||
db=db,
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
)
|
||
|
||
signal_ids = []
|
||
for recipe in request.recipes:
|
||
# 构建内容
|
||
content = f"食物:{recipe.food_name}"
|
||
if recipe.calories:
|
||
content += f"\n热量:{recipe.calories} kcal"
|
||
|
||
title = f"🍽️ 食谱记录:{recipe.food_name}"
|
||
|
||
metadata = {
|
||
"source": "recipe_system",
|
||
"intent": recipe.intent,
|
||
"nutrition": {
|
||
"calories": recipe.calories,
|
||
"protein": recipe.protein,
|
||
"fat": recipe.fat,
|
||
"carbs": recipe.carbs,
|
||
}
|
||
}
|
||
|
||
signal = Signal(
|
||
tenant_id=tenant_id,
|
||
source="recipe_system",
|
||
title=title,
|
||
content=content,
|
||
summary=f"食物:{recipe.food_name}",
|
||
signal_type="recipe_recognition",
|
||
priority="low",
|
||
impact_score=0.3,
|
||
status="待处理",
|
||
processed=True, # 批量导入的视为已处理
|
||
strategic_analysis=json.dumps(metadata),
|
||
)
|
||
db.add(signal)
|
||
await db.flush()
|
||
|
||
# 关联项目
|
||
link = ProjectSignalLink(
|
||
tenant_id=tenant_id,
|
||
project_id=target_project_id,
|
||
signal_id=signal.id,
|
||
)
|
||
db.add(link)
|
||
signal_ids.append(signal.id)
|
||
|
||
await db.commit()
|
||
|
||
return RecipeRecognitionResponse(
|
||
success=True,
|
||
signal_id=len(signal_ids), # 返回数量
|
||
project_id=target_project_id,
|
||
message=f"成功导入 {len(signal_ids)} 条食谱记录",
|
||
details={"signal_count": len(signal_ids)}
|
||
)
|
||
|
||
except HTTPException:
|
||
await db.rollback()
|
||
raise
|
||
except Exception as e:
|
||
await db.rollback()
|
||
logger.exception(
|
||
"recipe_batch_import_failed",
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
recipe_count=len(request.recipes),
|
||
error=str(e),
|
||
)
|
||
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}")
|