This commit is contained in:
2025-12-02 15:51:37 +08:00
parent 70e8627a96
commit bdf74652b3
17 changed files with 619 additions and 32 deletions

View File

@@ -261,6 +261,7 @@ func (a *API) setupRoutes() {
feedGroup.GET("/recipes", a.recipeController.ListRecipes)
feedGroup.POST("/recipes/generate-from-all-materials/:pig_type_id", a.recipeController.GenerateFromAllMaterials)
feedGroup.POST("/recipes/generate-prioritized-stock/:pig_type_id", a.recipeController.GenerateRecipeWithPrioritizedStockRawMaterials)
feedGroup.GET("/recipes/:id/ai-diagnose", a.recipeController.AIDiagnoseRecipe)
}
logger.Debug("饲料管理相关接口注册成功 (需要认证和审计)")

View File

@@ -3,6 +3,7 @@ package feed
import (
"context"
"errors"
"fmt"
"strconv"
"git.huangwc.com/pig/pig-farm-controller/internal/app/controller"
@@ -256,3 +257,48 @@ func (c *RecipeController) GenerateRecipeWithPrioritizedStockRawMaterials(ctx ec
logger.Infof("%s: 配方生成成功, 新配方ID: %d", actionType, resp.ID)
return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "配方生成成功", resp, actionType, "配方生成成功", resp)
}
// AIDiagnoseRecipe godoc
// @Summary AI点评配方
// @Description 使用AI对指定配方进行点评并针对目标猪类型给出建议。
// @Tags 饲料管理-配方
// @Security BearerAuth
// @Produce json
// @Param id path int true "配方ID"
// @Param pig_type_id query int true "猪类型ID"
// @Success 200 {object} controller.Response{data=dto.ReviewRecipeResponse} "业务码为200代表AI点评成功"
// @Router /api/v1/feed/recipes/{id}/ai-diagnose [get]
func (c *RecipeController) AIDiagnoseRecipe(ctx echo.Context) error {
reqCtx, logger := logs.Trace(ctx.Request().Context(), c.ctx, "AIDiagnoseRecipe")
const actionType = "AI点评配方"
fmt.Println("xxx")
// 从路径参数中获取配方ID
recipeIDStr := ctx.Param("id")
recipeID, err := strconv.ParseUint(recipeIDStr, 10, 32)
if err != nil {
logger.Errorf("%s: 配方ID格式错误: %v, ID: %s", actionType, err, recipeIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的配方ID格式", actionType, "配方ID格式错误", recipeIDStr)
}
// 从查询参数中获取猪类型ID
pigTypeIDStr := ctx.QueryParam("pig_type_id")
pigTypeID, err := strconv.ParseUint(pigTypeIDStr, 10, 32)
if err != nil {
logger.Errorf("%s: 猪类型ID格式错误: %v, ID: %s", actionType, err, pigTypeIDStr)
return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的猪类型ID格式", actionType, "猪类型ID格式错误", pigTypeIDStr)
}
// 调用应用服务进行AI点评
reviewResponse, err := c.recipeService.AIDiagnoseRecipe(reqCtx, uint32(recipeID), uint32(pigTypeID))
if err != nil {
logger.Errorf("%s: 服务层AI点评失败: %v, RecipeID: %d, PigTypeID: %d", actionType, err, recipeID, pigTypeID)
if errors.Is(err, service.ErrRecipeNotFound) {
return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "配方或猪类型不存在", map[string]uint32{"recipe_id": uint32(recipeID), "pig_type_id": uint32(pigTypeID)})
}
// 对于其他错误,统一返回内部服务器错误
return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "AI点评失败: "+err.Error(), actionType, "服务层AI点评失败", map[string]uint32{"recipe_id": uint32(recipeID), "pig_type_id": uint32(pigTypeID)})
}
logger.Infof("%s: AI点评成功, RecipeID: %d, PigTypeID: %d", actionType, recipeID, pigTypeID)
return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "AI点评成功", reviewResponse, actionType, "AI点评成功", reviewResponse)
}

View File

@@ -1,5 +1,7 @@
package dto
import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
// =============================================================================================================
// 营养种类 (Nutrient) 相关 DTO
// =============================================================================================================
@@ -335,3 +337,14 @@ type GenerateRecipeResponse struct {
Name string `json:"name"` // 新生成的配方名称
Description string `json:"description"` // 新生成的配方描述
}
// ReviewRecipeRequest 定义了点评配方的请求体
type ReviewRecipeRequest struct {
PigTypeID uint32 `json:"pig_type_id" binding:"required"` // 猪类型ID
}
// ReviewRecipeResponse 定义了点评配方的响应体
type ReviewRecipeResponse struct {
ReviewMessage string `json:"review_message"` // 点评内容
AIModel models.AIModel `json:"ai_model"` // 使用的 AI 模型
}

View File

@@ -29,6 +29,8 @@ type RecipeService interface {
GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料
GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// AIDiagnoseRecipe 智能诊断配方, 返回智能诊断结果和使用的AI模型
AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (*dto.ReviewRecipeResponse, error)
}
// recipeServiceImpl 是 RecipeService 接口的实现
@@ -175,3 +177,18 @@ func (s *recipeServiceImpl) ListRecipes(ctx context.Context, req *dto.ListRecipe
return dto.ConvertRecipeListToDTO(recipes, total, req.Page, req.PageSize), nil
}
// AIDiagnoseRecipe 实现智能诊断配方的方法
func (s *recipeServiceImpl) AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (*dto.ReviewRecipeResponse, error) {
serviceCtx := logs.AddFuncName(ctx, s.ctx, "AIDiagnoseRecipe")
reviewMessage, aiModel, err := s.recipeSvc.AIDiagnoseRecipe(serviceCtx, recipeID, pigTypeID)
if err != nil {
return nil, fmt.Errorf("AI 诊断配方失败: %w", err)
}
return &dto.ReviewRecipeResponse{
ReviewMessage: reviewMessage,
AIModel: aiModel,
}, nil
}

View File

@@ -15,6 +15,7 @@ import (
"git.huangwc.com/pig/pig-farm-controller/internal/domain/plan"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/recipe"
"git.huangwc.com/pig/pig-farm-controller/internal/domain/task"
infra_ai "git.huangwc.com/pig/pig-farm-controller/internal/infra/ai"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/database"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
@@ -34,6 +35,7 @@ type Infrastructure struct {
storage database.Storage
repos *Repositories
lora *LoraComponents
aiManager infra_ai.AI
tokenGenerator token.Generator
}
@@ -53,10 +55,17 @@ func initInfrastructure(ctx context.Context, cfg *config.Config) (*Infrastructur
tokenGenerator := token.NewTokenGenerator([]byte(cfg.App.JWTSecret))
// 初始化 AI
aiManager, err := infra_ai.NewGeminiAI(logs.AddCompName(ctx, "GeminiAI"), &cfg.AI)
if err != nil {
return nil, fmt.Errorf("初始化 AI 管理器失败: %w", err)
}
return &Infrastructure{
storage: storage,
repos: repos,
lora: lora,
aiManager: aiManager,
tokenGenerator: tokenGenerator,
}, nil
}
@@ -238,6 +247,8 @@ func initDomainServices(ctx context.Context, cfg *config.Config, infra *Infrastr
pigTypeService,
recipeCoreService,
recipeGenerateManager,
infra.repos.recipeRepo,
infra.aiManager,
)
return &DomainServices{

View File

@@ -2,8 +2,11 @@ package recipe
import (
"context"
"encoding/json"
"fmt"
"strings"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/ai"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository"
@@ -22,6 +25,8 @@ type Service interface {
GenerateRecipeWithAllRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// GenerateRecipeWithPrioritizedStockRawMaterials 生成新配方,优先使用有库存的原料
GenerateRecipeWithPrioritizedStockRawMaterials(ctx context.Context, pigTypeID uint32) (*models.Recipe, error)
// AIDiagnoseRecipe 智能诊断配方, 返回智能诊断结果
AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (string, models.AIModel, error)
}
// recipeServiceImpl 是 Service 的实现,通过组合各个子服务来实现
@@ -34,6 +39,9 @@ type recipeServiceImpl struct {
PigTypeService
RecipeCoreService
RecipeGenerateManager
recipeRepo repository.RecipeRepository
ai ai.AI
}
// NewRecipeService 创建一个新的 Service 实例
@@ -46,6 +54,8 @@ func NewRecipeService(
pigTypeService PigTypeService,
recipeCoreService RecipeCoreService,
recipeGenerateManager RecipeGenerateManager,
recipeRepo repository.RecipeRepository,
ai ai.AI,
) Service {
return &recipeServiceImpl{
ctx: ctx,
@@ -56,6 +66,8 @@ func NewRecipeService(
PigTypeService: pigTypeService,
RecipeCoreService: recipeCoreService,
RecipeGenerateManager: recipeGenerateManager,
recipeRepo: recipeRepo,
ai: ai,
}
}
@@ -225,3 +237,113 @@ func (r *recipeServiceImpl) GenerateRecipeWithPrioritizedStockRawMaterials(ctx c
// 7. 返回创建的配方
return recipe, nil
}
// AIDiagnoseRecipe 使用 AI 为指定食谱生成诊断。
func (s *recipeServiceImpl) AIDiagnoseRecipe(ctx context.Context, recipeID uint32, pigTypeID uint32) (string, models.AIModel, error) {
serviceCtx, logger := logs.Trace(ctx, context.Background(), "AIDiagnoseRecipe")
// 1. 根据 recipeID 获取配方详情
recipe, err := s.recipeRepo.GetRecipeByID(serviceCtx, recipeID)
if err != nil {
logger.Errorf("获取配方详情失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("获取配方详情失败: %w", err)
}
if recipe == nil {
logger.Warnf("未找到配方ID: %d", recipeID)
return "", s.ai.AIModel(), fmt.Errorf("未找到配方ID: %d", recipeID)
}
// 2. 获取目标猪只类型信息
pigType, err := s.GetPigTypeByID(serviceCtx, pigTypeID)
if err != nil {
logger.Errorf("获取猪只类型信息失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("获取猪只类型信息失败: %w", err)
}
if pigType == nil {
logger.Warnf("未找到猪只类型ID: %d", pigTypeID)
return "", s.ai.AIModel(), fmt.Errorf("未找到猪只类型ID: %d", pigTypeID)
}
// 3. 定义 AI 输入结构体
type ingredientNutrient struct {
NutrientName string `json:"nutrient_name"`
Value float32 `json:"value"`
}
type recipeIngredient struct {
RawMaterialName string `json:"raw_material_name"`
Percentage float32 `json:"percentage"`
Nutrients []ingredientNutrient `json:"nutrients"`
}
type aiDiagnosisInput struct {
RecipeName string `json:"recipe_name"`
TargetPigType struct {
Name string `json:"name"`
} `json:"target_pig_type"`
Ingredients []recipeIngredient `json:"ingredients"`
}
// 4. 填充 AI 输入结构体
input := aiDiagnosisInput{
RecipeName: recipe.Name,
}
input.TargetPigType.Name = fmt.Sprintf("%s-%s", pigType.Breed.Name, pigType.AgeStage.Name)
for _, ingredient := range recipe.RecipeIngredients {
if ingredient.RawMaterial.ID == 0 {
logger.Warnf("配方成分中存在未加载的原料信息RecipeIngredientID: %d", ingredient.ID)
continue
}
ing := recipeIngredient{
RawMaterialName: ingredient.RawMaterial.Name,
Percentage: ingredient.Percentage,
}
for _, rmn := range ingredient.RawMaterial.RawMaterialNutrients {
if rmn.Nutrient.ID == 0 {
logger.Warnf("原料营养成分中存在未加载的营养素信息RawMaterialNutrientID: %d", rmn.ID)
continue
}
ing.Nutrients = append(ing.Nutrients, ingredientNutrient{
NutrientName: rmn.Nutrient.Name,
Value: rmn.Value,
})
}
input.Ingredients = append(input.Ingredients, ing)
}
// 5. 序列化为 JSON 字符串
jsonBytes, err := json.Marshal(input)
if err != nil {
logger.Errorf("序列化配方和猪只类型信息为 JSON 失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("序列化数据失败: %w", err)
}
jsonString := string(jsonBytes)
// 6. 构建 AI Prompt
var promptBuilder strings.Builder
promptBuilder.WriteString(`
你是一个专业的动物营养师。请根据以下猪饲料配方数据,生成一份详细的、对养殖户友好的说明报告。
说明报告应包括以下部分:
1. 诊断猪只配方是否合理,如合理需要说明为什么合理, 如不合理需给出详细的改进建议。
2. 关键成分分析:分析主要原料和营养成分的作用
3. 使用建议:提供使用此配方的最佳实践和注意事项。
\n`)
promptBuilder.WriteString("```")
promptBuilder.WriteString(jsonString)
promptBuilder.WriteString("```")
prompt := promptBuilder.String()
logger.Debugf("生成的 AI 诊断 Prompt: \n%s", prompt)
// 7. 调用 AI Manager 进行诊断
diagnosisResult, err := s.ai.GenerateReview(serviceCtx, prompt)
if err != nil {
logger.Errorf("调用 AI Manager 诊断配方失败: %v", err)
return "", s.ai.AIModel(), fmt.Errorf("AI 诊断失败: %w", err)
}
logger.Infof("成功对配方 ID: %d (目标猪只类型 ID: %d) 进行 AI 诊断。", recipeID, pigTypeID)
return diagnosisResult, s.ai.AIModel(), nil
}

19
internal/infra/ai/ai.go Normal file
View File

@@ -0,0 +1,19 @@
package ai
import (
"context"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
)
// AI 定义了通用的 AI 管理接口。
// 它可以用于处理各种 AI 相关的任务,例如文本生成、内容审核等。
type AI interface {
// GenerateReview 根据提供的文本内容生成评论。
// prompt: 用于生成评论的输入文本。
// 返回生成的评论字符串和可能发生的错误。
GenerateReview(ctx context.Context, prompt string) (string, error)
// AIModel 返回当前使用的 AI 模型。
AIModel() models.AIModel
}

View File

@@ -0,0 +1,73 @@
package ai
import (
"context"
"fmt"
"time"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/config"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs"
"git.huangwc.com/pig/pig-farm-controller/internal/infra/models"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
// geminiImpl 是 Gemini AI 服务的实现。
type geminiImpl struct {
client *genai.GenerativeModel
cfg *config.Gemini
}
// NewGeminiAI 创建一个新的 geminiImpl 实例。
func NewGeminiAI(ctx context.Context, cfg *config.AIConfig) (AI, error) {
// 检查 API Key 是否存在
if cfg.Gemini.APIKey == "" {
return nil, fmt.Errorf("Gemini API Key 未配置")
}
// 创建 Gemini 客户端
genaiClient, err := genai.NewClient(ctx, option.WithAPIKey(cfg.Gemini.APIKey))
if err != nil {
return nil, fmt.Errorf("创建 Gemini 客户端失败: %w", err)
}
return &geminiImpl{
client: genaiClient.GenerativeModel(cfg.Gemini.ModelName),
cfg: &cfg.Gemini,
}, nil
}
// GenerateReview 根据提供的文本内容生成评论。
func (g *geminiImpl) GenerateReview(ctx context.Context, prompt string) (string, error) {
serviceCtx, logger := logs.Trace(ctx, context.Background(), "GenerateReview")
logger.Debugf("开始调用 Gemini 生成评论prompt: %s", prompt)
timeoutCtx, cancel := context.WithTimeout(serviceCtx, time.Duration(g.cfg.Timeout)*time.Second)
defer cancel()
resp, err := g.client.GenerateContent(timeoutCtx, genai.Text(prompt))
if err != nil {
logger.Errorf("调用 Gemini API 失败: %v", err)
return "", fmt.Errorf("调用 Gemini API 失败: %w", err)
}
if resp == nil || len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
logger.Warn("Gemini API 返回空内容或无候选评论")
return "", fmt.Errorf("Gemini API 返回空内容或无候选评论")
}
var review string
for _, part := range resp.Candidates[0].Content.Parts {
if txt, ok := part.(genai.Text); ok {
review += string(txt)
}
}
logger.Debugf("成功从 Gemini 生成评论: %s", review)
return review, nil
}
func (g *geminiImpl) AIModel() models.AIModel {
return models.AI_MODEL_GEMINI
}

View File

@@ -236,11 +236,14 @@ type AlarmNotificationConfig struct {
// AIConfig AI 服务配置
type AIConfig struct {
Gemini struct {
APIKey string `yaml:"api_key"` // Gemini API Key
ModelName string `yaml:"model_name"` // Gemini 模型名称,例如 "gemini-pro"
Timeout int `yaml:"timeout"` // AI 请求超时时间 (秒)
} `yaml:"gemini"`
Gemini Gemini `yaml:"gemini"`
}
// Gemini 代表 Gemini AI 服务的配置
type Gemini struct {
APIKey string `yaml:"api_key"` // Gemini API Key
ModelName string `yaml:"model_name"` // Gemini 模型名称,例如 "gemini-pro"
Timeout int `yaml:"timeout"` // AI 请求超时时间 (秒)
}
// NewConfig 创建并返回一个新的配置实例

View File

@@ -66,8 +66,8 @@ func NewLogger(cfg config.LogConfig) *Logger {
// 5. 构建 Logger
// zap.AddCaller() 会记录调用日志的代码行
// zap.AddCallerSkip(1) 可以向上跳层调用栈,如果我们将 logger.Info 等方法再封装一层,这个选项会很有用
zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
// zap.AddCallerSkip(2) 可以向上跳层调用栈,因为我们的日志方法被封装了两层 (Logger.Info -> Logger.logWithTrace)
zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(2))
return &Logger{sl: zapLogger.Sugar()}
}

View File

@@ -12,6 +12,12 @@ import (
"gorm.io/gorm"
)
type AIModel string
const (
AI_MODEL_GEMINI AIModel = "Gemini"
)
// Model 用于代替gorm.Model, 使用uint32以节约空间
type Model struct {
ID uint32 `gorm:"primarykey"`