Files
Agent-System/internal/bizdev/materials.go
WeiCHENandCursor 908d4bdb2e feat(agent): project asset upload and squad materials assembly
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>
2026-09-13 02:50:22 +00:00

160 lines
4.8 KiB
Go
Raw Permalink 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 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,
}
}