Register uploads via CAS, resolve project asset pools into task materials with server-side extraction, and propagate asset_id through the bizdev/squad chain. Co-authored-by: Cursor <cursoragent@cursor.com>
160 lines
4.8 KiB
Go
160 lines
4.8 KiB
Go
package bizdev
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"chat-agent-system/internal/squad"
|
||
"chat-agent-system/internal/storage"
|
||
)
|
||
|
||
// ResolveMaterials 将请求材料 + 项目 CAS 资产解析为可注入工作区的 squad 材料。
|
||
// 项目内全部有效资产自动纳入(含私密文档);显式附件优先并去重。
|
||
func (s *Service) ResolveMaterials(ctx context.Context, req TaskRunRequest) ([]squad.MaterialItem, map[string]any, error) {
|
||
manifest := map[string]any{
|
||
"explicit_materials": len(req.Materials),
|
||
}
|
||
byKey := make(map[string]squad.MaterialItem)
|
||
|
||
addItem := func(item squad.MaterialItem) error {
|
||
if strings.TrimSpace(item.Filename) == "" {
|
||
return errors.New("material filename required")
|
||
}
|
||
if len(item.Content) == 0 {
|
||
return fmt.Errorf("material %q has no extractable content", item.Filename)
|
||
}
|
||
key := item.Filename
|
||
if item.AssetID > 0 {
|
||
key = fmt.Sprintf("asset:%d", item.AssetID)
|
||
}
|
||
byKey[key] = item
|
||
return nil
|
||
}
|
||
|
||
// 1. 项目资产池(不要求用户本轮再次附加)
|
||
if s.cas != nil && req.ProjectID > 0 {
|
||
refs, err := s.cas.ListProjectRefs(ctx, req.ProjectID)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
var assetEntries []map[string]any
|
||
for _, ref := range refs {
|
||
data, loadedRef, err := s.cas.ReadRefBytes(ctx, ref.ID)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
text, err := squad.ExtractTextWithAnchors(loadedRef.FileName, data)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("extract project asset %q: %w", loadedRef.FileName, err)
|
||
}
|
||
if strings.TrimSpace(text) == "" {
|
||
return nil, nil, fmt.Errorf("project asset %q extracted empty text", loadedRef.FileName)
|
||
}
|
||
item := squad.MaterialItem{
|
||
Filename: loadedRef.FileName,
|
||
Content: []byte(text),
|
||
AssetID: loadedRef.ID,
|
||
SHA256: loadedRef.BlobSHA256,
|
||
OwnerID: loadedRef.UserID,
|
||
}
|
||
_ = addItem(item)
|
||
assetEntries = append(assetEntries, map[string]any{
|
||
"asset_id": loadedRef.ID,
|
||
"filename": loadedRef.FileName,
|
||
"sha256": loadedRef.BlobSHA256,
|
||
"owner_user_id": loadedRef.UserID,
|
||
"file_category": loadedRef.FileCategory,
|
||
"source": "project_asset_pool",
|
||
})
|
||
}
|
||
manifest["project_assets"] = assetEntries
|
||
}
|
||
|
||
// 2. 显式附件(asset_id 优先,其次 inline content)
|
||
for _, m := range req.Materials {
|
||
if m.AssetID > 0 && s.cas != nil {
|
||
data, ref, err := s.cas.ReadRefBytes(ctx, m.AssetID)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("load attachment asset_id=%d: %w", m.AssetID, err)
|
||
}
|
||
if ref.ProjectID > 0 && req.ProjectID > 0 && ref.ProjectID != req.ProjectID {
|
||
return nil, nil, fmt.Errorf("asset_id=%d belongs to another project", m.AssetID)
|
||
}
|
||
filename := m.Filename
|
||
if filename == "" {
|
||
filename = ref.FileName
|
||
}
|
||
text, err := squad.ExtractTextWithAnchors(filename, data)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("extract attachment %q: %w", filename, err)
|
||
}
|
||
if strings.TrimSpace(text) == "" {
|
||
return nil, nil, fmt.Errorf("attachment %q extracted empty text", filename)
|
||
}
|
||
if err := addItem(squad.MaterialItem{
|
||
Filename: filename,
|
||
Content: []byte(text),
|
||
AssetID: ref.ID,
|
||
SHA256: ref.BlobSHA256,
|
||
OwnerID: ref.UserID,
|
||
}); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
continue
|
||
}
|
||
|
||
filename := strings.TrimSpace(m.Filename)
|
||
if filename == "" {
|
||
return nil, nil, errors.New("material filename required when asset_id absent")
|
||
}
|
||
if len(m.Content) == 0 {
|
||
return nil, nil, fmt.Errorf("material %q has empty content and no asset_id", filename)
|
||
}
|
||
text, err := squad.ExtractTextWithAnchors(filename, m.Content)
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("extract inline material %q: %w", filename, err)
|
||
}
|
||
if strings.TrimSpace(text) == "" {
|
||
return nil, nil, fmt.Errorf("inline material %q extracted empty text", filename)
|
||
}
|
||
if err := addItem(squad.MaterialItem{
|
||
Filename: filepath.Base(filename),
|
||
Content: []byte(text),
|
||
}); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
}
|
||
|
||
out := make([]squad.MaterialItem, 0, len(byKey))
|
||
for _, item := range byKey {
|
||
out = append(out, item)
|
||
}
|
||
manifest["resolved_count"] = len(out)
|
||
return out, manifest, nil
|
||
}
|
||
|
||
// ValidateTaskRunIdentity 拒绝缺失项目/会话标识的任务启动。
|
||
func ValidateTaskRunIdentity(sessionID string, projectID int64) error {
|
||
if strings.TrimSpace(sessionID) == "" {
|
||
return errors.New("session_id required for task run")
|
||
}
|
||
if projectID <= 0 {
|
||
return errors.New("project_id required for task run")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MaterialInputFromRef 由 CAS 引用构造任务材料输入(供上传 API 测试)。
|
||
func MaterialInputFromRef(ref *storage.FileReference) MaterialInput {
|
||
if ref == nil {
|
||
return MaterialInput{}
|
||
}
|
||
return MaterialInput{
|
||
Filename: ref.FileName,
|
||
AssetID: ref.ID,
|
||
}
|
||
}
|