Files
weichen 4e0e6a1418 feat(notification): digest aggregates, supplement-impact batch, and free-collab hooks
Add notification digests with ON CONFLICT DO UPDATE, migrations 00068-00070,
supplementimpact module (analyze → confirm → claim-aware split), and wire
coordinator/mutate hooks plus proposal_required digests.
2026-09-24 11:44:20 +00:00

163 lines
4.6 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"chat-agent-system/internal/app/intentflow"
)
const mutateActionKey = "project.fact.supplement"
// ProjectBlackboardPort reads/writes the same project blackboard as SupplementInfo.
type ProjectBlackboardPort struct {
CurrentVersion func(ctx context.Context, projectID int64) (string, error)
MergeFacts func(ctx context.Context, projectID int64, facts map[string]string) (newVersion string, err error)
}
// MutateOwner is the U10 live adapter for project.fact.supplement only.
type MutateOwner struct {
Access ProjectAccess
Blackboard ProjectBlackboardPort
Denied bool
// OnSupplement is optional; composition typically wires supplementimpact.TriggerAsync.
OnSupplement func(ctx context.Context, projectID, userID int64, sessionID, summary, markdown string)
mu sync.Mutex
receipts map[string]intentflow.Receipt
}
func NewMutateOwner(access ProjectAccess, bb ProjectBlackboardPort) *MutateOwner {
return &MutateOwner{
Access: access,
Blackboard: bb,
receipts: map[string]intentflow.Receipt{},
}
}
func (m *MutateOwner) Authorize(ctx context.Context, exec intentflow.StepExec) error {
if m == nil || m.Denied {
return intentflow.ErrPlanActionForbidden
}
if exec.ActionKey != mutateActionKey {
return intentflow.ErrPlanActionForbidden
}
if exec.Principal.UserID <= 0 || exec.Principal.ProjectID <= 0 || exec.Principal.SessionID == "" {
return intentflow.ErrPlanActionForbidden
}
if m.Access != nil {
if err := m.Access(ctx, exec.Principal.ProjectID, exec.Principal.UserID); err != nil {
return intentflow.ErrPlanActionForbidden
}
}
if exec.ExpectedResourceVersion == "" {
return intentflow.ErrPlanActionVersionConflict
}
if m.Blackboard.CurrentVersion != nil {
got, err := m.Blackboard.CurrentVersion(ctx, exec.Principal.ProjectID)
if err != nil {
return err
}
if got != exec.ExpectedResourceVersion {
return intentflow.ErrPlanActionVersionConflict
}
}
return nil
}
func (m *MutateOwner) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
if err := m.Authorize(ctx, exec); err != nil {
return intentflow.Receipt{}, err
}
m.mu.Lock()
if rec, ok := m.receipts[exec.IdempotencyKey]; ok {
m.mu.Unlock()
if rec.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return rec, nil
}
m.mu.Unlock()
facts := supplementFactsFromExec(exec)
if len(facts) == 0 {
return intentflow.Receipt{}, intentflow.ErrPlanActionForbidden
}
newVer := exec.ExpectedResourceVersion
if m.Blackboard.MergeFacts != nil {
got, err := m.Blackboard.MergeFacts(ctx, exec.Principal.ProjectID, facts)
if err != nil {
return intentflow.Receipt{}, err
}
newVer = got
if m.OnSupplement != nil {
summary := strings.TrimSpace(exec.SafeSummary)
if summary == "" {
summary = "project facts supplemented"
}
markdown := summary
if len(facts) > 0 {
parts := make([]string, 0, len(facts))
for k, v := range facts {
parts = append(parts, k+": "+v)
}
markdown = strings.Join(parts, "\n")
}
m.OnSupplement(ctx, exec.Principal.ProjectID, exec.Principal.UserID, exec.Principal.SessionID, summary, markdown)
}
}
rec := intentflow.Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: fmt.Sprintf("blackboard:%d", exec.Principal.ProjectID),
ResultDigest: fmt.Sprintf("supplement:ver=%s", newVer),
SafeSummary: "project facts supplemented",
}
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
}
return existing, nil
}
m.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (m *MutateOwner) LookupReceipt(_ context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
if m == nil {
return intentflow.Receipt{}, false, nil
}
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.receipts[idempotencyKey]
return rec, ok, nil
}
func supplementFactsFromExec(exec intentflow.StepExec) map[string]string {
if len(exec.Slots) > 0 {
var slots map[string]string
if json.Unmarshal(exec.Slots, &slots) == nil && len(slots) > 0 {
out := map[string]string{}
for k, v := range slots {
if strings.TrimSpace(v) != "" {
out[k] = strings.TrimSpace(v)
}
}
if len(out) > 0 {
return out
}
}
}
text := strings.TrimSpace(exec.SafeSummary)
if text == "" {
return nil
}
return map[string]string{"supplement_doc": text}
}