suggest

package
v0.115.2 Latest Latest
Warning

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

Go to latest
Published: Jul 10, 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

func NewRunID added in v0.93.0

func NewRunID() string

NewRunID mints the identifier shared by one bulk run's audit entries.

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
	// RunID ties a bulk run's per-record entries to its aggregate entry
	// (tasks/239). Empty for single-record actions, which are their own run.
	RunID string `json:"runId,omitempty"`
}

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 ReviewResult added in v0.101.0

type ReviewResult struct {
	Applied int        `json:"applied"`
	Skipped []Decision `json:"skipped,omitempty"`
}

ReviewResult reports what a Review batch actually did. Skipped carries the decisions that lost the race -- another moderator had already resolved the suggestion, or it no longer exists -- so a caller can tell the human whose decision was discarded rather than counting the request back to them (tasks/257).

type RunNote added in v0.93.0

type RunNote struct {
	Selection string   `json:"selection,omitempty"`
	Matched   int      `json:"matched"`
	Applied   int      `json:"applied"`
	Rewritten int      `json:"rewritten"`
	Failed    int      `json:"failed"`
	Added     int      `json:"added"`
	Removed   int      `json:"removed"`
	Works     []string `json:"works"`
	More      int      `json:"more,omitempty"`
}

RunNote is the note a bulk run's aggregate audit entry carries: the run's counts and the records it rewrote, as JSON (tasks/239).

The split is deliberate. A per-record entry's note is prose, because it is read by a cataloger in that record's History tab. The aggregate entry appears only on the Audit screen and exists to answer "what did that run touch?", so it is machine-readable and names its records.

It replaces a note built by marshalling the results and cutting the bytes at 512, which past a handful of works stopped being parseable and could split a UTF-8 rune at the boundary. This truncates the *list*, and says how many it dropped.

func (RunNote) String added in v0.93.0

func (n RunNote) String() string

String renders the note as JSON, capping the work list and recording how many ids it left out rather than dropping them silently.

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.

The sort is not redundant with the descending key scan. Entries written before auditSKLayout keyed on time.RFC3339Nano, which trims trailing zeros from the fractional second: ".16779Z" (167790ns) sorts above ".167792Z" in lexicographic order, because 'Z' outranks '2'. Those keys are still in the store, and sorting on the timestamp itself puts them back in order.

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) (ReviewResult, 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 -- and the ReviewResult says which.

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