PMFlow/backend/app/api/signal_sources.py

402 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""食谱系统信号源 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)}")