feat: connect inbox confirmation workflow
This commit is contained in:
@@ -2,25 +2,43 @@ 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 {
|
||||
ProjectID uint
|
||||
UserID uint
|
||||
SourceType string
|
||||
Title string
|
||||
Body string
|
||||
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)
|
||||
}
|
||||
@@ -30,72 +48,239 @@ type StaticAnalyzer struct {
|
||||
}
|
||||
|
||||
func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) {
|
||||
return a.Suggestions, nil
|
||||
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) *Service {
|
||||
return &Service{analyzer: analyzer}
|
||||
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 input.ProjectID == 0 || input.UserID == 0 {
|
||||
return nil, errors.New("project and user are required")
|
||||
if strings.TrimSpace(input.ProjectIdentity) == "" || input.UserID == 0 {
|
||||
return nil, ErrProjectAndUserRequired
|
||||
}
|
||||
if input.SourceType == "" {
|
||||
return nil, errors.New("source type is required")
|
||||
sourceType := strings.TrimSpace(input.SourceType)
|
||||
if sourceType == "" {
|
||||
return nil, ErrSourceTypeRequired
|
||||
}
|
||||
item := &models.SenlinAgentInboxItem{
|
||||
ProjectID: input.ProjectID,
|
||||
CreatedBy: input.UserID,
|
||||
SourceType: input.SourceType,
|
||||
Title: input.Title,
|
||||
Body: input.Body,
|
||||
Status: "open",
|
||||
}
|
||||
return item, models.DBService.Create(item).Error
|
||||
}
|
||||
|
||||
func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) {
|
||||
var item models.SenlinAgentInboxItem
|
||||
if err := models.DBService.First(&item, itemID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.analyzer == nil {
|
||||
return []Suggestion{}, nil
|
||||
}
|
||||
return s.analyzer.Analyze(item, userID)
|
||||
}
|
||||
|
||||
func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
|
||||
return models.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var item models.SenlinAgentInboxItem
|
||||
if err := tx.First(&item, itemID).Error; err != nil {
|
||||
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
|
||||
}
|
||||
sourceInboxItemID := item.ID
|
||||
// Inbox 建议只有在用户确认后才创建正式对象,并把来源 ID 写入每个对象以保留可追溯性。
|
||||
for _, suggestion := range selected {
|
||||
switch suggestion.Kind {
|
||||
case "task":
|
||||
if err := tx.Create(&models.SenlinAgentTask{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case "note":
|
||||
if err := tx.Create(&models.SenlinAgentNote{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case "source":
|
||||
if err := tx.Create(&models.SenlinAgentSource{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errors.New("unsupported suggestion kind")
|
||||
}
|
||||
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.Model(&item).Update("status", "processed").Error
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user