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

296 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.SaInboxItem, userID uint) ([]Suggestion, error)
}
type StaticAnalyzer struct {
Suggestions []Suggestion
}
func (a StaticAnalyzer) Analyze(item models.SaInboxItem, 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: "document", Title: subject + "整理笔记.md", Body: body},
{Kind: "document", Title: subject + "背景资料.md", 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.SaInboxItem, 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.SaInboxItem
err := s.database().Transaction(func(tx *gorm.DB) error {
var project models.SaProject
if err := tx.Where("owner_id = ? AND identity = ?", input.UserID, input.ProjectIdentity).First(&project).Error; err != nil {
return err
}
item = models.SaInboxItem{
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.SaInboxSuggestion{}).Error; err != nil {
return err
}
for _, suggestion := range normalized {
draft := models.SaInboxSuggestion{
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) {
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" {
result.CreatedCount = item.ConfirmedCount
return nil
}
selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities)
if err != nil {
return err
}
var saved []models.SaInboxSuggestion
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.SaInboxSuggestion, 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).Updates(map[string]any{
"status": "processed",
"confirmed_count": result.CreatedCount,
}).Error
})
return result, err
}
func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SaInboxItem, error) {
var item models.SaInboxItem
query := tx.Model(&models.SaInboxItem{}).
Joins("JOIN sa_projects ON sa_projects.id = sa_inbox_items.project_id").
Where("sa_inbox_items.identity = ? AND sa_inbox_items.created_by = ? AND sa_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 != "document") {
return nil, ErrInvalidSuggestion
}
result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)})
}
return result, nil
}
func suggestionSubject(item models.SaInboxItem) 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.SaInboxItem, suggestion models.SaInboxSuggestion) error {
sourceInboxItemID := item.ID
switch suggestion.Kind {
case "task":
return tx.Create(&models.SaTask{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open",
}).Error
case "document":
name := strings.TrimSpace(suggestion.Title)
if !strings.HasSuffix(strings.ToLower(name), ".md") {
name += ".md"
}
document := models.SaDocumentTree{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, OwnerID: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Kind: models.DocumentKindFile, Name: name,
NormalizedName: strings.ToLower(name), Extension: ".md", MimeType: "text/markdown; charset=utf-8",
Size: int64(len([]byte(suggestion.Body))), Revision: 1, ScanStatus: "not_scanned",
}
if err := tx.Create(&document).Error; err != nil {
return err
}
return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: suggestion.Body}).Error
default:
return ErrInvalidSuggestion
}
}