suggest

package
v0.78.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 9, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package suggest implements the community suggestion queue: an ephemeral store aggregating anonymous patron suggestions and flags per (work, term) pair for staff review, generalized from qllpoc's single-vocabulary DynamoDB implementation onto the portable document store and arbitrary vocabularies (controlled TermRefs or folksonomy tags). The BIBFRAME graph remains the source of truth for approved assignments; nothing here is durable except the audit trail.

Index

Constants

View Source
const (
	ChallengeMinAge = 3 * time.Second
	ChallengeMaxAge = 2 * time.Hour
)

Challenge age bounds: the floor filters scripted submissions that POST faster than a human could pick a vocabulary term; the ceiling expires stale dialogs.

View Source
const ConcernScheme = "concern"

ConcernScheme is the pseudo-scheme concern ids ride in Term -- reusing the suggestion aggregate's storage/queue/review machinery without a parallel record type (tasks/210). A concern never resolves against a vocabulary and never publishes.

Variables

View Source
var (
	ErrTombstoned  = errors.New("suggest: term was rejected for this work and may not be re-suggested")
	ErrRateLimited = errors.New("suggest: rate limit exceeded")
	ErrBadTerm     = errors.New("suggest: term not in a loaded vocabulary")
	ErrFolkBlocked = errors.New("suggest: folksonomy term is blocked")
)

Sentinel errors; the API layer maps these to HTTP statuses.

View Source
var DefaultCaps = Caps{PerDay: 20, PerHour: 8, SupporterTTL: 90 * 24 * time.Hour}

DefaultCaps are deliberately tight -- suggesting subjects is a considered act, not a feed interaction.

View Source
var ErrPromotionExists = errors.New("suggest: promotion already proposed for this tag")

ErrPromotionExists reports an already-open proposal for the tag.

Reasons lists every valid flag reason for input validation.

Functions

This section is empty.

Types

type Abuse

type Abuse struct {
	// contains filtered or unexported fields
}

Abuse pseudonymizes source IPs and issues the anonymous challenge tokens that gate patron submissions -- qllpoc's IPHasher, ported intact. The API stores HMAC(secret, ip), never the address itself.

func NewAbuse

func NewAbuse(secret []byte) (*Abuse, error)

NewAbuse requires a non-trivial secret (32 random bytes in deployment secret storage).

func (*Abuse) Challenge

func (a *Abuse) Challenge() string

Challenge issues an anonymous, short-lived proof-of-page-open token: "<unix-ts>.<hex hmac>". The patron client fetches one when the suggest dialog opens and echoes it on submit. No identity is involved -- the token binds only a timestamp.

func (*Abuse) HashIP

func (a *Abuse) HashIP(ip string) string

HashIP returns a hex HMAC-SHA256 of the source IP.

func (*Abuse) SetClock

func (a *Abuse) SetClock(now func() time.Time)

SetClock overrides the clock (tests).

func (*Abuse) VerifyChallenge

func (a *Abuse) VerifyChallenge(token string) bool

VerifyChallenge checks the token's MAC and that its age is within bounds.

type ActorStats

type ActorStats struct {
	Actor      string         `json:"actor"`
	Total      int            `json:"total"`
	ByAction   map[string]int `json:"byAction"`
	Works      int            `json:"works"`
	ActiveDays int            `json:"activeDays"`
	First      time.Time      `json:"first"`
	Last       time.Time      `json:"last"`
	Sessions   []Session      `json:"sessions"`
}

ActorStats rolls up one cataloger's audit activity for the month.

type AuditEntry

type AuditEntry struct {
	WorkID string    `json:"workId,omitempty"`
	At     time.Time `json:"at"`
	Action string    `json:"action"` // REVIEW_APPROVE, REVIEW_REJECT, MANUAL_TERM, FOLK_ACCEPT, FOLK_BLOCK, PUBLISH_*
	Actor  string    `json:"actor"`
	Terms  []string  `json:"terms,omitempty"` // "<scheme>:<id>"
	Note   string    `json:"note,omitempty"`
	ETag   string    `json:"etag,omitempty"` // grain etag for publish events
}

AuditEntry records a staff decision or publish event. Editorial history for the published state itself lives in the grain store; this trail covers queue-side actions.

type Caps

type Caps struct {
	PerDay  int
	PerHour int
	// SupporterTTL ages dedup markers off; rate counters use fixed short TTLs.
	SupporterTTL time.Duration
}

Caps bounds anonymous submission volume per supporter hash.

type Decision

type Decision struct {
	WorkID  string        `json:"workId"`
	Term    vocab.TermRef `json:"term"`
	Type    SuggType      `json:"type"`
	Approve bool          `json:"approve"`
	// SubstituteTerm, when set on an approval, records that the reviewer
	// slid to a neighbouring vocabulary term instead of the suggested one.
	SubstituteTerm *vocab.TermRef `json:"substituteTerm,omitempty"`
	Note           string         `json:"note,omitempty"`
	// Tombstone on a rejection also blocks future re-suggestions of the pair.
	Tombstone bool `json:"tombstone,omitempty"`
}

Decision is one staff review action inside a batch.

type FolkStatus

type FolkStatus string

FolkStatus is the lifecycle of a novel folksonomy term itself (distinct from any per-work suggestion of it): PROPOSED terms are invisible to autocomplete until a moderator ACCEPTS them; BLOCKED terms cannot be suggested at all.

const (
	FolkProposed FolkStatus = "PROPOSED"
	FolkAccepted FolkStatus = "ACCEPTED"
	FolkBlocked  FolkStatus = "BLOCKED"
)

type FolkTerm

type FolkTerm struct {
	Term      string     `json:"term"` // normalized text = identity
	Status    FolkStatus `json:"status"`
	CreatedAt time.Time  `json:"createdAt"`
	// UseCount tracks how many suggestions cite the term (moderation signal).
	UseCount int `json:"useCount"`
}

FolkTerm is the stored lifecycle record of one normalized community tag.

type MonthStats

type MonthStats struct {
	Month    string         `json:"month"`
	Total    int            `json:"total"`
	Actors   int            `json:"actors"`
	Works    int            `json:"works"`
	ByAction map[string]int `json:"byAction"`
	PerActor []ActorStats   `json:"perActor"`
}

MonthStats is the editing-activity rollup for a single YYYY-MM audit partition: overall totals plus a per-cataloger breakdown.

type Promotion

type Promotion struct {
	Tag        string        `json:"tag"` // normalized (vocab.NormalizeFolk)
	Term       vocab.TermRef `json:"term"`
	Status     Status        `json:"status"` // PENDING -> APPROVED / REJECTED
	ProposedBy string        `json:"proposedBy"`
	CreatedAt  time.Time     `json:"createdAt"`
	DecidedBy  string        `json:"decidedBy,omitempty"`
	DecidedAt  time.Time     `json:"decidedAt,omitzero"`
	// Works counts the rewrites at execution (stamped by the publisher).
	Works int `json:"works,omitempty"`
}

Promotion proposes elevating an uncontrolled tag to a controlled term (tasks/044): on approval the publisher rewrites every carrying Work (subject added; editorial tag retracted) and records lcat:tagAlias so the projector suppresses the tag where the term is present and pickers auto-suggest the term. One open promotion per normalized tag.

type Provenance

type Provenance string

Provenance records where a suggestion originated.

const (
	ProvenancePatron    Provenance = "PATRON"
	ProvenancePipeline  Provenance = "PIPELINE" // enrichment providers (tasks/039)
	ProvenanceLibrarian Provenance = "LIBRARIAN"
)

type QueuePage

type QueuePage struct {
	Items  []Suggestion `json:"items"`
	Cursor string       `json:"cursor,omitempty"`
}

QueuePage is one page of the review queue, supporter-count-descending.

type QueueQuery

type QueueQuery struct {
	Status     Status // default PENDING
	Scheme     string
	Provenance Provenance
	Type       SuggType
	Limit      int    // default 50
	Cursor     string // opaque; from a prior QueuePage
}

QueueQuery filters the review queue.

type Reason

type Reason string

Reason is the fixed flag-reason enum -- no free text anywhere.

const (
	ReasonDoesNotApply Reason = "does_not_apply"
	ReasonTooBroad     Reason = "too_broad_narrower_fits"
	ReasonOutdated     Reason = "outdated_or_harmful_in_context"
	ReasonSpoiler      Reason = "spoiler"
)

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the suggestion queue over the document store. Controlled terms are validated against the vocabulary index; folksonomy terms pass through the normalizer and their own moderation lifecycle.

func New

func New(db store.Store, ix *vocab.Index, caps Caps) *Service

New wires the service (zero-value caps fall back to DefaultCaps; a nil index rejects every controlled term but folk terms still work).

func (*Service) AcceptedFolkTerms

func (s *Service) AcceptedFolkTerms(ctx context.Context, prefix string, limit int) ([]string, error)

AcceptedFolkTerms lists ACCEPTED folk terms matching prefix -- merged into autocomplete beside controlled vocabularies.

func (*Service) ApprovedUnpublished

func (s *Service) ApprovedUnpublished(ctx context.Context) ([]Suggestion, error)

ApprovedUnpublished lists APPROVED aggregates not yet carried into the graph -- the publisher's worklist (tasks/036).

func (*Service) Audit

func (s *Service) Audit(ctx context.Context, month string) ([]AuditEntry, error)

Audit returns a month's audit trail, newest first.

func (*Service) DecidePromotion

func (s *Service) DecidePromotion(ctx context.Context, tag string, approve bool, actor string) (Promotion, error)

DecidePromotion resolves a pending proposal. Rejection just stamps it; approval stamps it and returns the promotion for the publisher to execute (the caller runs Publisher.PromoteTag and then MarkPromotionExecuted).

func (*Service) FolkTermStatus

func (s *Service) FolkTermStatus(ctx context.Context, norm string) (FolkTerm, error)

FolkTermStatus returns a folk term's lifecycle record.

func (*Service) ForWork

func (s *Service) ForWork(ctx context.Context, workID string) ([]Suggestion, error)

ForWork returns the public per-work view: aggregates only, no supporter hashes, for the "N patrons suggested this" display.

func (*Service) GetPromotion

func (s *Service) GetPromotion(ctx context.Context, tag string) (Promotion, error)

GetPromotion returns one promotion by normalized tag.

func (*Service) ManualTerm

func (s *Service) ManualTerm(ctx context.Context, workID string, ref vocab.TermRef, workTitle, actor string) error

ManualTerm lets a librarian add a term patrons and pipelines missed. The aggregate is born APPROVED (the librarian is the review) and flows to the graph on the next publish.

func (*Service) MarkPromotionExecuted

func (s *Service) MarkPromotionExecuted(ctx context.Context, tag string, works int) error

MarkPromotionExecuted stamps the rewrite count after the publisher runs.

func (*Service) MarkPublished

func (s *Service) MarkPublished(ctx context.Context, items []Suggestion, etag string) error

MarkPublished stamps aggregates with the grain etag that carried them.

func (*Service) PipelineSuggest

func (s *Service) PipelineSuggest(ctx context.Context, workID string, term vocab.TermRef, confidence float64) error

PipelineSuggest lands one machine-produced candidate in the moderation queue: a PENDING aggregate with PIPELINE provenance and confidence, create-only (an existing aggregate for the pair -- from patrons or a prior run -- is left untouched, so re-running an enrichment source never spams the queue). The term is deliberately not gated by the vocabulary index: enrichment sources assert terms from vocabularies too large to load, and moderation is the gate. Tombstoned pairs are skipped silently.

func (*Service) Promotions

func (s *Service) Promotions(ctx context.Context, status Status) ([]Promotion, error)

Promotions lists proposals, optionally by status.

func (*Service) ProposePromotion

func (s *Service) ProposePromotion(ctx context.Context, rawTag string, term vocab.TermRef, actor string) (Promotion, error)

ProposePromotion records a pending promotion. The target term must resolve in the vocabulary index -- promotions are into loaded controlled vocabularies.

func (*Service) Queue

func (s *Service) Queue(ctx context.Context, q QueueQuery) (QueuePage, error)

Queue lists aggregates in the requested status. It reads the status index partition and hydrates each aggregate; index items whose aggregate has moved on are deleted in passing (the index is repairable, the aggregate is truth).

func (*Service) Review

func (s *Service) Review(ctx context.Context, decisions []Decision, actor string) error

Review applies a batch of staff decisions. Each decision flips a PENDING/DISPUTED aggregate to APPROVED or REJECTED, stamps the reviewer, and writes an audit entry. Rejections may tombstone the pair. Decisions against already-resolved items are skipped, not errors -- two reviewers may race on a hot queue.

func (*Service) SetClock

func (s *Service) SetClock(now func() time.Time)

SetClock overrides the clock (tests).

func (*Service) SetFolkStatus

func (s *Service) SetFolkStatus(ctx context.Context, norm string, status FolkStatus, actor string) error

SetFolkStatus accepts or blocks a folksonomy term. Accepted terms enter the autocomplete index; blocking removes them from it and refuses future suggestions.

func (*Service) Stats

func (s *Service) Stats(ctx context.Context, month string) (MonthStats, error)

Stats aggregates a month's audit trail into editing statistics and session summaries. It is a read-only rollup over the same entries Audit returns; entries without an actor are ignored (they carry no editorial attribution).

func (*Service) Submit

func (s *Service) Submit(ctx context.Context, in SubmitInput) (SubmitResult, error)

Submit records one anonymous suggestion/flag: term validation, tombstone and folk-lifecycle gates, per-supporter rate caps, supporter dedup, then the aggregate bump and dispute reconciliation. Unlike qllpoc's single TransactWriteItems this is a sequence of conditional writes -- a crash mid-sequence can lose one vote's count bump, which is acceptable for approximate supporter tallies (review is the arbiter); it can never double-count (the dedup marker is first) or corrupt review state.

func (*Service) SubmitConcern added in v0.61.0

func (s *Service) SubmitConcern(ctx context.Context, workID, note, workTitle, supporterHash string) error

SubmitConcern records an anonymous report-a-problem against a work: the freetext lands in Note, moderation happens in the same queue as term suggestions (resolve/dismiss), and nothing ever reaches the graph. The concern id is a content hash, so an identical resubmission is an idempotent no-op; rate caps are shared with term suggestions.

func (*Service) WriteAudit

func (s *Service) WriteAudit(ctx context.Context, entry AuditEntry)

WriteAudit records a publish-lifecycle event not tied to one decision.

func (*Service) WriteTombstone

func (s *Service) WriteTombstone(ctx context.Context, workID string, term vocab.TermRef, actor string) error

WriteTombstone blocks future suggestions of a (work, term) pair.

type Session

type Session struct {
	Start   time.Time `json:"start"`
	End     time.Time `json:"end"`
	Actions int       `json:"actions"`
	Works   int       `json:"works"`
}

Session is one contiguous run of a cataloger's activity -- actions no more than sessionGap apart -- with the works it touched.

type Status

type Status string

Status is the review lifecycle of an aggregated (work, term, type) item.

const (
	StatusPending  Status = "PENDING"
	StatusApproved Status = "APPROVED"
	StatusRejected Status = "REJECTED"
	StatusDisputed Status = "DISPUTED"
)

type SubmitInput

type SubmitInput struct {
	WorkID        string
	Term          vocab.TermRef
	Type          SuggType
	Reason        Reason // REMOVE only
	SupporterHash string
	WorkTitle     string
	SourceRef     string
}

SubmitInput is one anonymous patron suggestion or flag. SupporterHash is HMAC(serverSecret, sourceIP) computed by the caller -- raw IPs never reach this package. Term.Label is display-only; identity is (Scheme, ID) and controlled terms must resolve in the vocabulary index.

type SubmitResult

type SubmitResult struct {
	// Duplicate is true when this supporter already voiced this exact
	// (work, term, type) -- the call is an idempotent no-op.
	Duplicate bool
	// Disputed is true when the pair now has both ADD and REMOVE pressure.
	Disputed bool
	// FolkProposed is true when the submission's novel folksonomy term
	// entered the PROPOSED lifecycle (held for moderation).
	FolkProposed bool
}

SubmitResult reports the outcome of a Submit.

type SuggType

type SuggType string

SuggType distinguishes a proposal to add a term from a flag to remove one.

const (
	TypeAdd    SuggType = "ADD"
	TypeRemove SuggType = "REMOVE"
	// TypeConcern is an anonymous report-a-problem (tasks/210): freetext in
	// Note, moderated in the same queue (approve = resolve, reject =
	// dismiss), never published to the graph.
	TypeConcern SuggType = "CONCERN"
)

type Suggestion

type Suggestion struct {
	WorkID         string         `json:"workId"`
	Term           vocab.TermRef  `json:"term"`
	Type           SuggType       `json:"type"`
	Status         Status         `json:"status"`
	SupporterCount int            `json:"supporterCount"`
	ReasonCounts   map[Reason]int `json:"reasonCounts,omitempty"`
	Provenance     Provenance     `json:"provenance"`
	Confidence     float64        `json:"confidence,omitempty"`
	WorkTitle      string         `json:"workTitle,omitempty"`
	SourceRef      string         `json:"sourceRef,omitempty"`
	// Note carries a concern's freetext (TypeConcern only).
	Note           string    `json:"note,omitempty"`
	CreatedAt      time.Time `json:"createdAt"`
	LastActivityAt time.Time `json:"lastActivityAt"`
	ReviewedAt     time.Time `json:"reviewedAt,omitzero"`
	ReviewedBy     string    `json:"reviewedBy,omitempty"`
	ReviewNote     string    `json:"reviewNote,omitempty"`
	// SubstituteTerm is set when the reviewer approved a neighbouring term
	// instead of the suggested one; the publisher writes the substitute.
	SubstituteTerm *vocab.TermRef `json:"substituteTerm,omitempty"`
	PublishedAt    time.Time      `json:"publishedAt,omitzero"`
	// PublishedETag is the grain ETag that carried the change into the
	// graph (the git-commit-SHA analog for the S3 grain store).
	PublishedETag string `json:"publishedEtag,omitempty"`
}

Suggestion is one aggregated (work, term, type) queue item. SupporterCount is the number of distinct supporter hashes; the hashes themselves live in separate TTL'd marker items and are never exposed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL