Files
agent/backend/internal/logic/inbox/service.go

287 lines
9.3 KiB
Go
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.
package inbox
import (
"errors"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"senlinai-agent/backend/internal/models"
)
var (
ErrProjectAndUserRequired = errors.New("project and user are required")
ErrSourceTypeRequired = errors.New("source type is required")
ErrInvalidSuggestion = errors.New("invalid inbox suggestion")
ErrSuggestionSelectionRequired = errors.New("suggestion selection is required")
ErrSuggestionNotFound = errors.New("selected suggestion was not saved for inbox item")
ErrInboxAlreadyConfirmed = errors.New("inbox item already confirmed")
)
type CaptureInput struct {
ProjectIdentity string
UserID uint
SourceType string
Title string
Body string
}
// Suggestion 是分析接口返回的候选草稿ID 由服务端保存后生成,确认接口不再接受 kind/title/body。
type Suggestion struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
}
type ConfirmResult struct {
CreatedCount int `json:"createdCount"`
}
type Analyzer interface {
Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error)
}
type StaticAnalyzer struct {
Suggestions []Suggestion
}
func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) {
if len(a.Suggestions) > 0 {
return a.Suggestions, nil
}
subject := suggestionSubject(item)
body := strings.TrimSpace(item.Body)
if body == "" {
body = strings.TrimSpace(item.Title)
}
// 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider也不创建正式对象。
return []Suggestion{
{Kind: "task", Title: "跟进:" + subject, Body: body},
{Kind: "note", Title: subject + "整理笔记", Body: body},
{Kind: "source", Title: subject + "背景资料", Body: body},
}, nil
}
type Service struct {
analyzer Analyzer
db *gorm.DB
}
func NewService(analyzer Analyzer, databases ...*gorm.DB) *Service {
var database *gorm.DB
if len(databases) > 0 {
database = databases[0]
}
return &Service{analyzer: analyzer, db: database}
}
func (s *Service) database() *gorm.DB {
if s.db != nil {
return s.db
}
return models.DBService
}
// Capture 先以 owner + project identity 校验项目边界,再创建归属于当前用户的原始 Inbox 条目。
func (s *Service) Capture(input CaptureInput) (*models.SenlinAgentInboxItem, error) {
if strings.TrimSpace(input.ProjectIdentity) == "" || input.UserID == 0 {
return nil, ErrProjectAndUserRequired
}
sourceType := strings.TrimSpace(input.SourceType)
if sourceType == "" {
return nil, ErrSourceTypeRequired
}
var item models.SenlinAgentInboxItem
err := s.database().Transaction(func(tx *gorm.DB) error {
var project models.SenlinAgentProject
if err := tx.Where("owner_id = ? AND identity = ?", input.UserID, input.ProjectIdentity).First(&project).Error; err != nil {
return err
}
item = models.SenlinAgentInboxItem{
ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: input.UserID,
SourceType: sourceType, Title: strings.TrimSpace(input.Title), Body: strings.TrimSpace(input.Body), Status: "open",
}
return tx.Create(&item).Error
})
return &item, err
}
// Analyze 只保存候选草稿,不创建任何任务、笔记或资料;重新分析会原子替换该条目的旧草稿。
func (s *Service) Analyze(itemIdentity string, userID uint) ([]Suggestion, error) {
item, err := findOwnedInboxItem(s.database(), itemIdentity, userID, false)
if err != nil {
return nil, err
}
if item.Status != "open" {
return nil, ErrInboxAlreadyConfirmed
}
generated := []Suggestion{}
if s.analyzer != nil {
generated, err = s.analyzer.Analyze(*item, userID)
if err != nil {
return nil, err
}
}
normalized, err := normalizeSuggestions(generated)
if err != nil {
return nil, err
}
saved := make([]Suggestion, 0, len(normalized))
err = s.database().Transaction(func(tx *gorm.DB) error {
locked, err := findOwnedInboxItem(tx, itemIdentity, userID, true)
if err != nil {
return err
}
if locked.Status != "open" {
return ErrInboxAlreadyConfirmed
}
if err := tx.Where("inbox_item_id = ?", locked.ID).Delete(&models.SenlinAgentInboxSuggestion{}).Error; err != nil {
return err
}
for _, suggestion := range normalized {
draft := models.SenlinAgentInboxSuggestion{
InboxItemID: locked.ID, InboxItemIdentity: locked.Identity, CreatedBy: userID,
Kind: suggestion.Kind, Title: suggestion.Title, Body: suggestion.Body,
}
if err := tx.Create(&draft).Error; err != nil {
return err
}
saved = append(saved, Suggestion{ID: draft.Identity, Kind: draft.Kind, Title: draft.Title, Body: draft.Body})
}
return nil
})
return saved, err
}
// Confirm 只按服务端保存的建议 identity 创建对象;全部写入和 Inbox 状态变更位于同一事务中。
func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) {
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
if err != nil {
return ConfirmResult{}, err
}
result := ConfirmResult{}
err = s.database().Transaction(func(tx *gorm.DB) error {
item, err := findOwnedInboxItem(tx, itemIdentity, userID, true)
if err != nil {
return err
}
if item.Status != "open" {
return ErrInboxAlreadyConfirmed
}
var saved []models.SenlinAgentInboxSuggestion
if err := tx.Where("inbox_item_id = ? AND created_by = ? AND identity IN ?", item.ID, userID, selected).Find(&saved).Error; err != nil {
return err
}
if len(saved) != len(selected) {
return ErrSuggestionNotFound
}
byIdentity := make(map[string]models.SenlinAgentInboxSuggestion, len(saved))
for _, suggestion := range saved {
byIdentity[suggestion.Identity] = suggestion
}
for _, identity := range selected {
suggestion, ok := byIdentity[identity]
if !ok {
return ErrSuggestionNotFound
}
if err := createConfirmedObject(tx, *item, suggestion); err != nil {
return err
}
result.CreatedCount++
}
return tx.Model(item).Update("status", "processed").Error
})
return result, err
}
func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) {
var item models.SenlinAgentInboxItem
query := tx.Model(&models.SenlinAgentInboxItem{}).
Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id").
Where("senlin_agent_inbox_items.identity = ? AND senlin_agent_inbox_items.created_by = ? AND senlin_agent_projects.owner_id = ?", identity, userID, userID)
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
if err := query.First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) {
result := make([]Suggestion, 0, len(suggestions))
for _, suggestion := range suggestions {
kind := strings.ToLower(strings.TrimSpace(suggestion.Kind))
title := strings.TrimSpace(suggestion.Title)
if title == "" || (kind != "task" && kind != "note" && kind != "source") {
return nil, ErrInvalidSuggestion
}
result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)})
}
return result, nil
}
func suggestionSubject(item models.SenlinAgentInboxItem) string {
value := strings.TrimSpace(item.Title)
if value == "" {
value = strings.TrimSpace(item.Body)
}
if value == "" {
return "Inbox 内容"
}
runes := []rune(value)
if len(runes) > 40 {
return string(runes[:40]) + "…"
}
return value
}
func normalizeSelectedIdentities(values []string) ([]string, error) {
if len(values) == 0 {
return nil, ErrSuggestionSelectionRequired
}
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
identity, err := uuid.Parse(strings.TrimSpace(value))
if err != nil || identity.Version() != 7 || identity.Variant() != uuid.RFC4122 {
return nil, ErrSuggestionNotFound
}
normalized := identity.String()
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
result = append(result, normalized)
}
return result, nil
}
func createConfirmedObject(tx *gorm.DB, item models.SenlinAgentInboxItem, suggestion models.SenlinAgentInboxSuggestion) error {
sourceInboxItemID := item.ID
switch suggestion.Kind {
case "task":
return tx.Create(&models.SenlinAgentTask{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open",
}).Error
case "note":
return tx.Create(&models.SenlinAgentNote{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body,
}).Error
case "source":
// 文本型 Inbox 资料不是上传文件,不构造或伪造任何本地路径。
return tx.Create(&models.SenlinAgentSource{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body,
}).Error
default:
return ErrInvalidSuggestion
}
}