Files
weichen 77b8d07cd7 fix(governance): 统一文档访问判定与上下文快照排除计数
补齐 DocumentAccessDecider HTTP 409、projectcontext 私有对话排除统计,并更新整改 gap matrix 与测试报告。
2026-09-13 02:50:23 +00:00

293 lines
8.0 KiB
Go

package projectcontext
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/squad"
)
const (
AuthorityConfirmedFact = "confirmed_fact"
AuthorityApprovedDoc = "approved_document"
AuthorityUpload = "upload_material"
AuthorityMilestone = "milestone"
AuthorityDialogue = "dialogue"
AuthorityKnowledge = "knowledge"
)
// Source 来源清单条目(全量枚举,按任务检索后分级注入)。
type Source struct {
Type string `json:"type"`
ID string `json:"id"`
Version string `json:"version,omitempty"`
ContentHash string `json:"content_hash"`
AuthorityLevel string `json:"authority_level"`
PermissionScope string `json:"permission_scope"`
Referenced bool `json:"referenced"`
Snippet string `json:"snippet,omitempty"`
}
// Snapshot 不可变项目上下文快照。
type Snapshot struct {
ID int64 `json:"id"`
ProjectID int64 `json:"project_id"`
TaskKey string `json:"task_key"`
CutoffAt time.Time `json:"cutoff_at"`
AggregateHash string `json:"aggregate_hash"`
SourceCount int `json:"source_count"`
ExcludedPrivateCount int `json:"excluded_private_count"`
ExcludedNoAccessCount int `json:"excluded_no_access_count"`
Sources []Source `json:"sources"`
}
// BuildInput 构建快照所需输入。
type BuildInput struct {
ProjectID int64
TaskKey string
UserID int64
ProjectName string
Facts map[string]string
Materials []squad.MaterialItem
PriorDocs []squad.PriorDocItem
Knowledge []squad.KnowledgeHit
Dialogue []string
Decisions []string
}
// Service 项目上下文快照服务。
type Service struct {
pool *pgxpool.Pool
}
func NewService(pool *pgxpool.Pool) *Service {
return &Service{pool: pool}
}
func (s *Service) Available() bool {
return s != nil && s.pool != nil
}
// BuildSnapshot 建立来源清单并按任务检索相关片段(不 dump 全量原文)。
func (s *Service) BuildSnapshot(ctx context.Context, in BuildInput) (*Snapshot, error) {
cutoff := time.Now()
sources, excludedPrivate, excludedNoAccess := enumerateSources(in)
sources = selectForTask(in.TaskKey, sources)
aggHash := aggregateHash(sources)
snap := &Snapshot{
ProjectID: in.ProjectID,
TaskKey: in.TaskKey,
CutoffAt: cutoff,
AggregateHash: aggHash,
SourceCount: len(sources),
ExcludedPrivateCount: excludedPrivate,
ExcludedNoAccessCount: excludedNoAccess,
Sources: sources,
}
if !s.Available() || in.ProjectID <= 0 {
return snap, nil
}
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO project_context_snapshots (project_id, task_key, cutoff_at, aggregate_hash, source_count, created_by, frozen)
VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id
`, in.ProjectID, in.TaskKey, cutoff, aggHash, len(sources), in.UserID).Scan(&id)
if err != nil {
return snap, err
}
snap.ID = id
for _, src := range sources {
_, _ = s.pool.Exec(ctx, `
INSERT INTO project_context_snapshot_sources
(snapshot_id, source_type, source_id, source_version, content_hash, authority_level, permission_scope, referenced)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (snapshot_id, source_type, source_id, source_version) DO NOTHING
`, id, src.Type, src.ID, src.Version, src.ContentHash, src.AuthorityLevel, src.PermissionScope, src.Referenced)
}
return snap, nil
}
// MaterialsForWorkspace 按权威等级与任务目标注入工作区材料(核心固定 + 相关分批)。
func MaterialsForWorkspace(snap *Snapshot, maxItems int) []squad.MaterialItem {
if snap == nil || len(snap.Sources) == 0 {
return nil
}
if maxItems <= 0 {
maxItems = 12
}
out := make([]squad.MaterialItem, 0, maxItems)
for _, src := range snap.Sources {
if !src.Referenced || strings.TrimSpace(src.Snippet) == "" {
continue
}
out = append(out, squad.MaterialItem{
Filename: fmt.Sprintf("%s_%s.md", src.Type, src.ID),
Content: []byte(src.Snippet),
})
if len(out) >= maxItems {
break
}
}
return out
}
func enumerateSources(in BuildInput) ([]Source, int, int) {
out := make([]Source, 0)
excludedPrivate := 0
excludedNoAccess := 0
for k, v := range in.Facts {
if strings.TrimSpace(v) == "" {
continue
}
out = append(out, Source{
Type: AuthorityConfirmedFact, ID: k, ContentHash: hashText(v),
AuthorityLevel: AuthorityConfirmedFact, PermissionScope: "project",
})
}
for i, m := range in.Materials {
body := string(m.Content)
if len(body) == 0 {
continue
}
id := m.Filename
if id == "" {
id = fmt.Sprintf("material_%d", i)
}
out = append(out, Source{
Type: "upload_material", ID: id, ContentHash: hashText(body),
AuthorityLevel: AuthorityUpload, PermissionScope: "project", Snippet: truncate(body, 4000),
})
}
for _, d := range in.PriorDocs {
body := d.Content
if body == "" {
continue
}
id := d.DocKey
if id == "" {
id = d.Title
}
out = append(out, Source{
Type: "document", ID: id, Version: d.Stage, ContentHash: hashText(body),
AuthorityLevel: AuthorityApprovedDoc, PermissionScope: "project", Snippet: truncate(body, 4000),
})
}
for i, line := range in.Dialogue {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
excludedPrivate++
continue
}
if strings.HasPrefix(trimmed, "[private]") || strings.HasPrefix(trimmed, "[draft]") {
excludedPrivate++
continue
}
out = append(out, Source{
Type: "dialogue", ID: fmt.Sprintf("turn_%d", i), ContentHash: hashText(trimmed),
AuthorityLevel: AuthorityDialogue, PermissionScope: "project_shared", Snippet: truncate(trimmed, 2000),
})
}
for i, d := range in.Decisions {
out = append(out, Source{
Type: "decision", ID: fmt.Sprintf("decision_%d", i), ContentHash: hashText(d),
AuthorityLevel: AuthorityConfirmedFact, PermissionScope: "project", Snippet: truncate(d, 2000),
})
}
for _, h := range in.Knowledge {
body := h.Content
if body == "" {
body = h.Title
}
out = append(out, Source{
Type: "knowledge", ID: h.CiteKey, ContentHash: hashText(body),
AuthorityLevel: AuthorityKnowledge, PermissionScope: "global_kb", Snippet: truncate(body, 2000),
})
}
sort.Slice(out, func(i, j int) bool {
return authorityRank(out[i].AuthorityLevel) < authorityRank(out[j].AuthorityLevel)
})
return out, excludedPrivate, excludedNoAccess
}
func selectForTask(taskKey string, sources []Source) []Source {
if len(sources) == 0 {
return sources
}
keywords := taskKeywords(taskKey)
for i := range sources {
src := &sources[i]
if src.AuthorityLevel == AuthorityConfirmedFact || src.AuthorityLevel == AuthorityApprovedDoc {
src.Referenced = true
continue
}
if len(keywords) == 0 {
src.Referenced = src.AuthorityLevel != AuthorityDialogue
continue
}
hay := strings.ToLower(src.Snippet + " " + src.ID + " " + src.Type)
for _, kw := range keywords {
if strings.Contains(hay, kw) {
src.Referenced = true
break
}
}
}
return sources
}
func taskKeywords(taskKey string) []string {
switch taskKey {
case "system_reqs":
return []string{"需求", "requirement", "功能", "system_reqs"}
case "test_manager":
return []string{"测试", "验证", "风险", "test", "verification", "requirement"}
default:
return nil
}
}
func authorityRank(level string) int {
switch level {
case AuthorityConfirmedFact:
return 0
case AuthorityApprovedDoc:
return 1
case AuthorityUpload:
return 2
case AuthorityMilestone:
return 3
case AuthorityDialogue:
return 4
default:
return 5
}
}
func aggregateHash(sources []Source) string {
raw, _ := json.Marshal(sources)
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
func hashText(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "\n…(truncated)"
}