package relationships import ( "context" "errors" "fmt" "strings" "time" "github.com/andydixon/relata/internal/audit" "github.com/andydixon/relata/internal/domain" "github.com/andydixon/relata/internal/ids" ) // Reviewer records human decisions about relationships. // // Every decision is additive. Rejecting a relationship does not delete it, and // editing its type does not erase what was originally proposed: the original // type and provenance are preserved on the record and every action appends to // an immutable history. A user must be able to ask "what did the system think, // and who overruled it?" months later. type Reviewer struct { rels domain.RelationshipRepository items domain.ItemRepository audit *audit.Recorder } // NewReviewer builds the review service. func NewReviewer(rels domain.RelationshipRepository, items domain.ItemRepository, rec *audit.Recorder) *Reviewer { return &Reviewer{rels: rels, items: items, audit: rec} } const maxNoteLength = 2000 // Confirm records that a person agrees with a relationship. // // The confirmation is added as a signal, so the score rises through the same // arithmetic as everything else rather than being overwritten with a magic // number, and the provenance becomes user_confirmed. func (r *Reviewer) Confirm(ctx context.Context, p *domain.Principal, id, note string) (*domain.Relationship, error) { rel, err := r.load(ctx, p, id) if err != nil { return nil, err } signals := stripUserSignals(rel.Evidence.Signals) signals = append(signals, UserConfirmedSignal(actorName(p))) rel.Evidence.Signals = Normalise(signals) rel.Score = Score(rel.Evidence.Signals) rel.ScoreVersion = ScoreVersion rel.Provenance = domain.ProvUserConfirmed rel.UpdatedAt = time.Now().UTC() if err := r.rels.Update(ctx, rel); err != nil { return nil, err } ev := domain.ReviewEvent{ At: time.Now().UTC(), Action: "confirm", ActorID: p.UserID, ActorName: p.Username, Note: cleanNote(note), FromValue: string(rel.OriginalProvenance), ToValue: string(domain.ProvUserConfirmed), } out, err := r.rels.AppendReview(ctx, p.WorkspaceID, id, ev, domain.ReviewConfirmed, rel.Type, domain.ProvUserConfirmed) if err != nil { return nil, err } r.audit.Record(ctx, p, domain.AuditRelConfirmed, "relationship", id, "success", map[string]string{ "type": string(out.Type), "score": fmt.Sprintf("%.3f", out.Score), }) return out, nil } // Reject records that a person disagrees with a relationship. // // The relationship and its evidence are kept. Its score falls to zero through // the rejection signal, and it stops influencing search expansion — but the // record of what was proposed, and who rejected it, survives. func (r *Reviewer) Reject(ctx context.Context, p *domain.Principal, id, note string) (*domain.Relationship, error) { rel, err := r.load(ctx, p, id) if err != nil { return nil, err } signals := stripUserSignals(rel.Evidence.Signals) signals = append(signals, UserRejectedSignal(actorName(p))) rel.Evidence.Signals = Normalise(signals) rel.Score = Score(rel.Evidence.Signals) rel.ScoreVersion = ScoreVersion rel.Provenance = domain.ProvUserRejected rel.UpdatedAt = time.Now().UTC() if err := r.rels.Update(ctx, rel); err != nil { return nil, err } ev := domain.ReviewEvent{ At: time.Now().UTC(), Action: "reject", ActorID: p.UserID, ActorName: p.Username, Note: cleanNote(note), FromValue: string(rel.OriginalProvenance), ToValue: string(domain.ProvUserRejected), } out, err := r.rels.AppendReview(ctx, p.WorkspaceID, id, ev, domain.ReviewRejected, rel.Type, domain.ProvUserRejected) if err != nil { return nil, err } r.audit.Record(ctx, p, domain.AuditRelRejected, "relationship", id, "success", map[string]string{ "type": string(out.Type), "original_type": string(out.OriginalType), }) return out, nil } // ChangeType records that a person corrected the relationship's type. func (r *Reviewer) ChangeType(ctx context.Context, p *domain.Principal, id string, newType domain.RelationshipType, note string) (*domain.Relationship, error) { if !newType.Valid() { return nil, fmt.Errorf("%w: %q is not a recognised relationship type", domain.ErrInvalidInput, newType) } rel, err := r.load(ctx, p, id) if err != nil { return nil, err } if rel.Type == newType { return rel, nil } previous := rel.Type // A person correcting the type is asserting it, so the link becomes // user-confirmed with a confirmation signal behind its score. signals := stripUserSignals(rel.Evidence.Signals) signals = append(signals, UserConfirmedSignal(actorName(p))) rel.Evidence.Signals = Normalise(signals) rel.Score = Score(rel.Evidence.Signals) rel.ScoreVersion = ScoreVersion rel.Type = newType rel.Provenance = domain.ProvUserConfirmed rel.UpdatedAt = time.Now().UTC() if err := r.rels.Update(ctx, rel); err != nil { return nil, err } ev := domain.ReviewEvent{ At: time.Now().UTC(), Action: "change_type", ActorID: p.UserID, ActorName: p.Username, Note: cleanNote(note), FromValue: string(previous), ToValue: string(newType), } out, err := r.rels.AppendReview(ctx, p.WorkspaceID, id, ev, domain.ReviewEdited, newType, domain.ProvUserConfirmed) if err != nil { return nil, err } r.audit.Record(ctx, p, domain.AuditRelEdited, "relationship", id, "success", map[string]string{ "from": string(previous), "to": string(newType), }) return out, nil } // CreateManual records a relationship a person asserted directly. // // It is stored with explicit provenance and confirmed review status: a human // statement is not a proposal awaiting review. func (r *Reviewer) CreateManual(ctx context.Context, p *domain.Principal, sourceID, targetID string, relType domain.RelationshipType, note string) (*domain.Relationship, error) { if p == nil || !p.Role.CanWrite() { return nil, domain.ErrForbidden } if !relType.Valid() { return nil, fmt.Errorf("%w: %q is not a recognised relationship type", domain.ErrInvalidInput, relType) } if sourceID == targetID { return nil, fmt.Errorf("%w: an item cannot be related to itself", domain.ErrInvalidInput) } if !ids.Valid(sourceID) || !ids.Valid(targetID) { return nil, fmt.Errorf("%w: item identifiers must be UUIDs", domain.ErrInvalidInput) } // Both endpoints must exist IN THIS WORKSPACE. This is the check that stops // a relationship being created across the workspace boundary. src, err := r.items.Get(ctx, p.WorkspaceID, sourceID) if err != nil { return nil, fmt.Errorf("source item: %w", err) } tgt, err := r.items.Get(ctx, p.WorkspaceID, targetID) if err != nil { return nil, fmt.Errorf("target item: %w", err) } if src.Deleted || tgt.Deleted { return nil, fmt.Errorf("%w: one of the items has been deleted", domain.ErrNotFound) } s, t := sourceID, targetID if !relType.Directed() && s > t { s, t = t, s } now := time.Now().UTC() signals := Normalise([]domain.Signal{UserConfirmedSignal(actorName(p))}) rel := &domain.Relationship{ ID: ids.New(), WorkspaceID: p.WorkspaceID, SourceKind: domain.NodeItem, SourceID: s, TargetKind: domain.NodeItem, TargetID: t, Type: relType, Provenance: domain.ProvExplicit, Score: Score(signals), ScoreVersion: ScoreVersion, Evidence: domain.Evidence{ Signals: signals, SourceExcerpt: excerpt(src.ExtractedText, 300), TargetExcerpt: excerpt(tgt.ExtractedText, 300), }, OriginalType: relType, OriginalProvenance: domain.ProvExplicit, ReviewStatus: domain.ReviewConfirmed, ReviewHistory: []domain.ReviewEvent{{ At: now, Action: "create", ActorID: p.UserID, ActorName: p.Username, Note: cleanNote(note), ToValue: string(relType), }}, DedupeKey: PairKey(p.WorkspaceID, s, t), CreatedAt: now, UpdatedAt: now, } // Upsert rather than insert: if analysis already proposed a link between // this pair, the person's assertion replaces the proposal instead of // creating a second edge. out, err := r.upsertManual(ctx, rel) if err != nil { return nil, err } r.audit.Record(ctx, p, domain.AuditRelCreated, "relationship", out.ID, "success", map[string]string{ "type": string(relType), "source": s, "target": t, }) return out, nil } // upsertManual writes a user-asserted relationship over any existing proposal. // // The existing link is looked up by dedupe key rather than by scanning the // node's relationships: a well-connected item has more than one page of them, so // a paged scan would miss the match and then collide with the unique index. // // A person asserting a relationship is the authority on it, so this deliberately // overwrites type, provenance and review status — unlike UpsertByDedupeKey, // which protects a human decision from a later analysis run. func (r *Reviewer) upsertManual(ctx context.Context, rel *domain.Relationship) (*domain.Relationship, error) { existing, err := r.rels.FindByDedupeKey(ctx, rel.WorkspaceID, rel.DedupeKey) if err != nil && !errors.Is(err, domain.ErrNotFound) { return nil, err } if existing != nil { return r.applyManual(ctx, existing, rel) } if err := r.rels.Create(ctx, rel); err != nil { // Another request created the same link between the lookup and this // insert. The unique index caught it; fold this assertion into the // record that won rather than failing the user's action. if errors.Is(err, domain.ErrDuplicate) { winner, findErr := r.rels.FindByDedupeKey(ctx, rel.WorkspaceID, rel.DedupeKey) if findErr == nil && winner != nil { return r.applyManual(ctx, winner, rel) } } return nil, err } return rel, nil } // applyManual overlays a user assertion onto an existing relationship record, // keeping the original identity and appending to the review history rather than // replacing it. func (r *Reviewer) applyManual(ctx context.Context, existing, rel *domain.Relationship) (*domain.Relationship, error) { existing.Type = rel.Type existing.Provenance = domain.ProvExplicit existing.Score = rel.Score existing.ScoreVersion = rel.ScoreVersion existing.Evidence.Signals = rel.Evidence.Signals existing.ReviewStatus = domain.ReviewConfirmed existing.ReviewHistory = append(existing.ReviewHistory, rel.ReviewHistory...) existing.UpdatedAt = rel.UpdatedAt if err := r.rels.Update(ctx, existing); err != nil { return nil, err } return existing, nil } // load fetches a relationship, enforcing the workspace boundary. func (r *Reviewer) load(ctx context.Context, p *domain.Principal, id string) (*domain.Relationship, error) { if p == nil || p.WorkspaceID == "" { return nil, domain.ErrWorkspaceMiss } if !p.Role.CanWrite() { return nil, domain.ErrForbidden } if !ids.Valid(id) { return nil, fmt.Errorf("%w: relationship id must be a UUID", domain.ErrInvalidInput) } return r.rels.Get(ctx, p.WorkspaceID, id) } // stripUserSignals removes previous human-decision signals so that changing a // decision replaces it rather than stacking a confirmation on a rejection. func stripUserSignals(signals []domain.Signal) []domain.Signal { out := signals[:0:0] for _, s := range signals { if s.Type == domain.SigUserConfirmed || s.Type == domain.SigUserRejected { continue } out = append(out, s) } return out } func actorName(p *domain.Principal) string { if p == nil { return "unknown" } if p.DisplayName != "" { return p.DisplayName } return p.Username } // cleanNote bounds and sanitises a user-supplied note. It is stored as plain // text and escaped on output; this only keeps it a sane size and free of // control characters. func cleanNote(s string) string { s = strings.Map(func(r rune) rune { if r < 0x20 && r != '\n' || r == 0x7f { return -1 } return r }, strings.TrimSpace(s)) if len(s) > maxNoteLength { s = s[:maxNoteLength] } return s }