feat(ai): add local expert library
This commit is contained in:
62
backend/internal/logic/ai/experts.go
Normal file
62
backend/internal/logic/ai/experts.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
// ExpertFilter 是本地专家库的查询条件。
|
||||
type ExpertFilter struct {
|
||||
Category string
|
||||
Query string
|
||||
}
|
||||
|
||||
func (s *SessionService) ListExperts(filter ExpertFilter) ([]models.SenlinAgentAIExpert, error) {
|
||||
query := models.DBService.Model(&models.SenlinAgentAIExpert{}).Where("enabled = ?", true)
|
||||
if category := strings.TrimSpace(filter.Category); category != "" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
if keyword := strings.ToLower(strings.TrimSpace(filter.Query)); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where(
|
||||
"LOWER(name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(category_name) LIKE ?",
|
||||
like, like, like,
|
||||
)
|
||||
}
|
||||
var experts []models.SenlinAgentAIExpert
|
||||
if err := query.Order("category asc, id asc").Find(&experts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if experts == nil {
|
||||
experts = []models.SenlinAgentAIExpert{}
|
||||
}
|
||||
return experts, nil
|
||||
}
|
||||
|
||||
func (s *SessionService) GetExpert(identity string) (*models.SenlinAgentAIExpert, error) {
|
||||
var expert models.SenlinAgentAIExpert
|
||||
if err := models.DBService.Where("identity = ? AND enabled = ?", strings.TrimSpace(identity), true).First(&expert).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrExpertNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &expert, nil
|
||||
}
|
||||
|
||||
func findExpertByIdentity(tx *gorm.DB, identity string) (*models.SenlinAgentAIExpert, error) {
|
||||
if strings.TrimSpace(identity) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var expert models.SenlinAgentAIExpert
|
||||
if err := tx.Where("identity = ? AND enabled = ?", identity, true).First(&expert).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrExpertNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &expert, nil
|
||||
}
|
||||
@@ -15,18 +15,38 @@ import (
|
||||
|
||||
// SessionDTO 是普通项目 AI 会话的公开契约,不携带数据库主键或自动创建对象的 ID。
|
||||
type SessionDTO struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Context string `json:"context"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Context string `json:"context"`
|
||||
Status string `json:"status"`
|
||||
Expert *ExpertSummaryDTO `json:"expert"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type createSessionRequest struct {
|
||||
Title string `json:"title"`
|
||||
Context string `json:"context"`
|
||||
Title string `json:"title"`
|
||||
Context string `json:"context"`
|
||||
ExpertID string `json:"expertId"`
|
||||
}
|
||||
|
||||
type ExpertSummaryDTO struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Category string `json:"category"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Emoji string `json:"emoji"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type ExpertDetailDTO struct {
|
||||
ExpertSummaryDTO
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
Source string `json:"source"`
|
||||
SourceLicense string `json:"sourceLicense"`
|
||||
}
|
||||
|
||||
// Handler 注册受认证、受项目所有权保护的 AI 会话接口。
|
||||
@@ -39,6 +59,8 @@ func NewHandler(service *SessionService) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
router.GET("/ai-experts", h.listExperts)
|
||||
router.GET("/ai-experts/:id", h.getExpert)
|
||||
router.GET("/projects/:id/ai-sessions", h.list)
|
||||
router.POST("/projects/:id/ai-sessions", h.create)
|
||||
}
|
||||
@@ -70,7 +92,7 @@ func (h *Handler) create(c *gin.Context) {
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效")
|
||||
return
|
||||
}
|
||||
session, err := h.service.Create(userID, projectIdentity, input.Title, input.Context)
|
||||
session, err := h.service.CreateWithExpert(userID, projectIdentity, input.Title, input.Context, input.ExpertID)
|
||||
if err != nil {
|
||||
writeAIError(c, err)
|
||||
return
|
||||
@@ -78,6 +100,35 @@ func (h *Handler) create(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, sessionDTO(*session))
|
||||
}
|
||||
|
||||
func (h *Handler) listExperts(c *gin.Context) {
|
||||
experts, err := h.service.ListExperts(ExpertFilter{Category: c.Query("category"), Query: c.Query("q")})
|
||||
if err != nil {
|
||||
writeAIError(c, err)
|
||||
return
|
||||
}
|
||||
items := make([]ExpertSummaryDTO, 0, len(experts))
|
||||
for _, expert := range experts {
|
||||
items = append(items, expertSummaryDTO(expert))
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
func (h *Handler) getExpert(c *gin.Context) {
|
||||
identity, ok := httpx.IdentityParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
expert, err := h.service.GetExpert(identity)
|
||||
if err != nil {
|
||||
writeAIError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ExpertDetailDTO{
|
||||
ExpertSummaryDTO: expertSummaryDTO(*expert),
|
||||
SystemPrompt: expert.SystemPrompt, Source: expert.Source, SourceLicense: expert.SourceLicense,
|
||||
})
|
||||
}
|
||||
|
||||
func aiRequestContext(c *gin.Context) (uint, string, bool) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
@@ -92,7 +143,7 @@ func aiRequestContext(c *gin.Context) (uint, string, bool) {
|
||||
}
|
||||
|
||||
func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
|
||||
return SessionDTO{
|
||||
dto := SessionDTO{
|
||||
ID: session.Identity,
|
||||
ProjectID: session.ProjectIdentity,
|
||||
Title: session.Title,
|
||||
@@ -101,10 +152,29 @@ func sessionDTO(session models.SenlinAgentAISession) SessionDTO {
|
||||
CreatedAt: session.CreatedAt.UTC(),
|
||||
UpdatedAt: session.UpdatedAt.UTC(),
|
||||
}
|
||||
if session.Expert != nil {
|
||||
dto.Expert = expertSummaryPointer(*session.Expert)
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func expertSummaryPointer(expert models.SenlinAgentAIExpert) *ExpertSummaryDTO {
|
||||
dto := expertSummaryDTO(expert)
|
||||
return &dto
|
||||
}
|
||||
|
||||
func expertSummaryDTO(expert models.SenlinAgentAIExpert) ExpertSummaryDTO {
|
||||
return ExpertSummaryDTO{
|
||||
ID: expert.Identity, Slug: expert.Slug, Category: expert.Category,
|
||||
CategoryName: expert.CategoryName, Name: expert.Name, Description: expert.Description,
|
||||
Emoji: expert.Emoji, Color: expert.Color,
|
||||
}
|
||||
}
|
||||
|
||||
func writeAIError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrExpertNotFound):
|
||||
httpx.Error(c, http.StatusNotFound, "expert_not_found", "专家不存在或已停用")
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在或无权访问")
|
||||
case errors.Is(err, ErrInvalidSession):
|
||||
|
||||
@@ -50,6 +50,48 @@ func TestAISessionHandlersRequireOwnedProject(t *testing.T) {
|
||||
require.Empty(t, gateway.steps)
|
||||
}
|
||||
|
||||
func TestAIExpertLibraryListsDetailsAndCreatesAssociatedSession(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "expert-owner@example.com")
|
||||
project := createAIHandlerProject(t, database, owner.ID, "EXPERTS")
|
||||
var expert models.SenlinAgentAIExpert
|
||||
require.NoError(t, database.Order("id asc").First(&expert).Error)
|
||||
router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("system-key", "test-encryption-secret"))
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRecorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/ai-experts", nil))
|
||||
require.Equal(t, http.StatusOK, listRecorder.Code)
|
||||
var listPayload []map[string]any
|
||||
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
|
||||
require.Len(t, listPayload, 267)
|
||||
require.Equal(t, expert.Identity, listPayload[0]["id"])
|
||||
require.NotContains(t, listPayload[0], "systemPrompt")
|
||||
|
||||
detailRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(detailRecorder, authenticatedAIRequest(t, http.MethodGet, "/api/v1/ai-experts/"+expert.Identity, nil))
|
||||
require.Equal(t, http.StatusOK, detailRecorder.Code)
|
||||
var detailPayload map[string]any
|
||||
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
|
||||
require.Equal(t, expert.Name, detailPayload["name"])
|
||||
require.NotEmpty(t, detailPayload["systemPrompt"])
|
||||
require.Equal(t, "MIT", detailPayload["sourceLicense"])
|
||||
|
||||
createRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRecorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||
"title": "专家会话", "context": "请给出下一步计划", "expertId": expert.Identity,
|
||||
}))
|
||||
require.Equal(t, http.StatusCreated, createRecorder.Code)
|
||||
var createPayload map[string]any
|
||||
require.NoError(t, json.Unmarshal(createRecorder.Body.Bytes(), &createPayload))
|
||||
responseExpert := createPayload["expert"].(map[string]any)
|
||||
require.Equal(t, expert.Identity, responseExpert["id"])
|
||||
require.Equal(t, expert.Name, responseExpert["name"])
|
||||
var session models.SenlinAgentAISession
|
||||
require.NoError(t, database.First(&session).Error)
|
||||
require.NotNil(t, session.ExpertID)
|
||||
require.Equal(t, expert.ID, *session.ExpertID)
|
||||
}
|
||||
|
||||
func TestCreateAISessionChecksRateLimitBeforeSelectingProvider(t *testing.T) {
|
||||
database := newAIHandlerTestDB(t)
|
||||
owner := createAIHandlerUser(t, database, "owner@example.com")
|
||||
@@ -210,7 +252,8 @@ func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObject
|
||||
require.Equal(t, http.StatusCreated, recorder.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload))
|
||||
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "expert", "createdAt", "updatedAt"}, aiMapKeys(payload))
|
||||
require.Nil(t, payload["expert"])
|
||||
identity, err := uuid.Parse(payload["id"].(string))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uuid.Version(7), identity.Version())
|
||||
@@ -256,7 +299,8 @@ func TestListAISessionsReturnsOnlyOwnedProjectIdentityDTOs(t *testing.T) {
|
||||
var payload []map[string]any
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.Len(t, payload, 1)
|
||||
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "createdAt", "updatedAt"}, aiMapKeys(payload[0]))
|
||||
require.ElementsMatch(t, []string{"id", "projectId", "title", "context", "status", "expert", "createdAt", "updatedAt"}, aiMapKeys(payload[0]))
|
||||
require.Nil(t, payload[0]["expert"])
|
||||
require.Equal(t, project.Identity, payload[0]["projectId"])
|
||||
require.Equal(t, "目标会话", payload[0]["title"])
|
||||
require.Equal(t, "ready", payload[0]["status"])
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
|
||||
var (
|
||||
ErrInvalidSession = errors.New("invalid ai session")
|
||||
ErrExpertNotFound = errors.New("ai expert not found")
|
||||
)
|
||||
|
||||
type sessionGateway interface {
|
||||
@@ -43,7 +44,7 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
|
||||
return nil, err
|
||||
}
|
||||
var sessions []models.SenlinAgentAISession
|
||||
if err := models.DBService.Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil {
|
||||
if err := models.DBService.Preload("Expert").Where("project_id = ?", project.ID).Order("updated_at desc, id desc").Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sessions == nil {
|
||||
@@ -54,6 +55,11 @@ func (s *SessionService) List(userID uint, projectIdentity string) ([]models.Sen
|
||||
|
||||
// Create 先校验项目,再限流,最后才选择 provider/key;会话创建不会生成任何正式业务对象。
|
||||
func (s *SessionService) Create(userID uint, projectIdentity, title, context string) (*models.SenlinAgentAISession, error) {
|
||||
return s.CreateWithExpert(userID, projectIdentity, title, context, "")
|
||||
}
|
||||
|
||||
// CreateWithExpert 创建带本地专家角色的项目会话。
|
||||
func (s *SessionService) CreateWithExpert(userID uint, projectIdentity, title, context, expertIdentity string) (*models.SenlinAgentAISession, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
context = strings.TrimSpace(context)
|
||||
if title == "" {
|
||||
@@ -66,6 +72,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
|
||||
if s.gateway == nil {
|
||||
return nil, errors.New("ai gateway is required")
|
||||
}
|
||||
expert, err := findExpertByIdentity(models.DBService, expertIdentity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.gateway.ReserveRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil {
|
||||
if errors.Is(err, ErrAIRateLimited) {
|
||||
@@ -100,6 +110,10 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
|
||||
Context: context,
|
||||
Status: defaultSessionStatus,
|
||||
}
|
||||
if expert != nil {
|
||||
session.ExpertID = &expert.ID
|
||||
session.ExpertIdentity = &expert.Identity
|
||||
}
|
||||
failureCode := "session_create_failed"
|
||||
err = models.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&session).Error; err != nil {
|
||||
@@ -119,6 +133,7 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
session.Expert = expert
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user