mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
5aa9e49ab4
* feat(needs): importance and simplicity scorers on /me/needs (TASK-3959, part 1b)
Registers the two needs scorers in server.New. Each fills needs.Score
{Value, Hint, Why} on every row, reading through the part 1a batch reads
and a request-scoped memo, so one request does each read once.
- Importance: round(100 x (.25P + .20B + .20D + .15S + .10H + .10A)).
B is a breadth-first fan-out over blocks links (visited set, depth 5,
cap 99). It counts and expands only targets visible under the scope and
still open, and returns a count, never refs. S is 0 until the
high_stakes eval (TASK-4011, ruling 440f5c63), so importance is capped
at 85 and not renormalised.
- Simplicity: (3 - effort)/3 x 100. The effort level is the argmax and the
hint comes from a fixed table (cap 40). With no decision runner, effort
is the text heuristic, with no hint.
- needs.Viewer gains Now, set from asOf, as the scorers' clock.
- Why emits only a fixed vocabulary, listed in docs/api.md; a test holds it
to that list.
The PLAN-3535 guard classes the new IsTerminalItem call as "keep".
Rulings 99c2bcfe and 6901f2f5 on the TASK-3959 trail.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): pass the workspace to the scoped decision and job reads (TASK-3959, part 1b)
Follows part 1a's round-1 fix, which scopes LatestDecisionsForItems and OwedDecisionJobs to a workspace. The needs memo passes its own workspace id.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): fail closed on scope and unknown collections, local-day deadline, request ctx (TASK-3959, part 1b r1)
Leak test through the router for a restricted viewer, fail-closed fan-out
pins, isOpen fails closed for an unknown collection, the question-hash read
takes the request ctx, D measures the server-local day like the dashboard,
H is clamped to [0,1], and docs/api.md's degraded prose names the real causes.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* fix(needs): pin every importance input, overdue parity with the dashboard, snapped rounding (TASK-3959, part 1b r2)
Per-input contribution table; overdue decided by itemOverdue; weighted sum
snapped before rounding; injectable location instead of mutating time.Local;
restricted-viewer score compared exactly to the hidden-item-deleted score;
docs: overdue rule and the WorkspaceFlagsForItems degraded path.
Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw
* test(needs): pin the deadline key pick, priority case and unreadable effort; scores at as_of (TASK-3959, part 1b r3)
- The importance parts table pins the earlier of due_date and end_date
(either key earlier, end_date alone) and the case-insensitive priority.
- A current effort answer whose level cannot be read falls back to the
text heuristic with no hint; a test now pins it, and docs/api.md says so.
- docs/api.md: scores are computed at the request's as_of, which the
cursor carries to later pages.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): as_of reaches the scorers through the handler; threshold, key and date pins (TASK-3959, part 1b r4)
- A request with a cursor scores at the cursor's as_of, end to end through
ServeHTTP (page 1 at the wall clock is 33; a cursor a week after
waiting_since is 28).
- "judged to need a human" from the attention threshold up, not below.
- Unreadable effort keys -1 and x fall back to the heuristic, like 7.
- A date too short to parse scores D=0.
- blocks() states the lock it runs under.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): the as_of check is its own test, so the binding test stays one claim (TASK-3959, part 1b r4)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): a failed read degrades both real scorers; short-date overdue; effort without a medium (TASK-3959, part 1b r5)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* docs(needs): every scorer sharing a memo passes the same rows (TASK-3959, part 1b r6)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* fix(needs): needs-human weighs only an answer the inbox's flag rule holds current (TASK-3959, part 1b r7)
H and "judged to need a human" read needs_human through the flag path
(WorkspaceFlagsForItems at threshold 0 over the rows), so an answer made
stale by an edit, with no refresh owed, no longer weighs or says so while
the router does not flag the item. Effort keeps question-hash currency;
docs/api.md states both rules. EffortMediumMinProb is pinned at exactly .5.
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
* test(needs): a needs-human answer held while its refresh is owed; the asked-row exception in the docs (TASK-3959, part 1b r8)
Claude-Session: https://claude.ai/code/session_014QJdFAhYDf79NBf2udvfdY
308 lines
10 KiB
Go
308 lines
10 KiB
Go
// Package needs is the cross-workspace "needs you" inbox's vocabulary and its
|
|
// routing rule (TASK-3920, IDEA-3906 half A unit A4). It holds no store and no
|
|
// HTTP: the server loads candidates, asks Resolve who each one is for, and
|
|
// renders the rows. Keeping the rule pure is what lets one table test pin its
|
|
// order.
|
|
package needs
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// The four routing rules, in the order Resolve tries them.
|
|
const (
|
|
RuleAsked = "asked"
|
|
RuleMentioned = "mentioned"
|
|
RuleAssigned = "assigned"
|
|
RuleAdminFallback = "admin_fallback"
|
|
)
|
|
|
|
// The kinds a Because entry can have.
|
|
const (
|
|
BecauseHumanTask = "human_task"
|
|
BecauseNeedsHuman = "needs_human"
|
|
BecauseMention = "mention"
|
|
)
|
|
|
|
// Mention is one open @mention of a user on an item.
|
|
type Mention struct {
|
|
UserID string
|
|
CommentID string
|
|
By string
|
|
At time.Time
|
|
}
|
|
|
|
// Candidate is one open item the server found waiting on someone, with the
|
|
// facts the rules read. The server has already dropped terminal items.
|
|
type Candidate struct {
|
|
ItemID string
|
|
CollectionID string
|
|
// AwaitsHuman: the item is in an awaits_human collection (an ask).
|
|
AwaitsHuman bool
|
|
// AssigneeUserID is the assigned PERSON; a role-only assignment is "" and
|
|
// falls through every rule that needs one (TASK-3615's ruling).
|
|
AssigneeUserID string
|
|
// AssignedAt is when the item was last assigned to that person, or the
|
|
// item's creation when no assignment was recorded.
|
|
AssignedAt time.Time
|
|
CreatedAt time.Time
|
|
Mentions []Mention
|
|
// NeedsHuman, when set, is when the attention set's needs_human answer was
|
|
// written.
|
|
NeedsHuman *time.Time
|
|
}
|
|
|
|
// Eligibility answers whether a user may be routed an item: a live human who
|
|
// may see it. Implementations cache; Resolve calls it freely.
|
|
type Eligibility interface {
|
|
Eligible(userID, collectionID, itemID string) bool
|
|
}
|
|
|
|
// Because is one open claim on an item, shown to the person it is routed to.
|
|
type Because struct {
|
|
Kind string `json:"kind"`
|
|
At time.Time `json:"at"`
|
|
By string `json:"by,omitempty"`
|
|
CommentID string `json:"comment_id,omitempty"`
|
|
}
|
|
|
|
// Why says which rule routed the item to the viewer.
|
|
type Why struct {
|
|
Rule string `json:"rule"`
|
|
Text string `json:"text"`
|
|
By string `json:"by,omitempty"`
|
|
At *time.Time `json:"at,omitempty"`
|
|
CommentID string `json:"comment_id,omitempty"`
|
|
}
|
|
|
|
// Routing is Resolve's answer for one candidate.
|
|
type Routing struct {
|
|
Rule string
|
|
// People are the users the winning rule routes to, in rule order. Only the
|
|
// winning rule's people are returned (lead ruling A1: exclusive).
|
|
People []string
|
|
Why Why
|
|
Because []Because
|
|
WaitingSince time.Time
|
|
|
|
// byUser holds, for the mentioned rule, each routed person's own view: the
|
|
// row for one person is computed from that person's mentions only.
|
|
byUser map[string]Routing
|
|
}
|
|
|
|
// For returns the routing as the given user sees it. Under the mentioned rule
|
|
// that is built from the user's own mentions alone, so nothing of another
|
|
// person's mention (its time, author or comment id) reaches them. Under the
|
|
// other rules the routing is the same for every routed person.
|
|
func (r Routing) For(userID string) Routing {
|
|
if v, ok := r.byUser[userID]; ok {
|
|
return v
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Includes reports whether the user is among the routed people.
|
|
func (r Routing) Includes(userID string) bool {
|
|
for _, p := range r.People {
|
|
if p == userID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Resolve applies the rules in strict, exclusive order and returns the first
|
|
// that yields at least one eligible person, or ok=false when none does (the
|
|
// item is dropped: never "everyone"):
|
|
//
|
|
// 1. asked: the item is in an awaits_human collection and its assigned
|
|
// person is eligible;
|
|
// 2. mentioned: the eligible people with an open @mention, by mention time;
|
|
// 3. assigned: the assigned person, if eligible;
|
|
// 4. admin_fallback: the eligible workspace owners.
|
|
//
|
|
// WaitingSince is the earliest open claim: the winning rule's own claim (the
|
|
// item's creation for asked and the fallback, the mention for mentioned, the
|
|
// latest assignment for assigned) and the needs_human answer when the item
|
|
// carries one. Why.Text comes from the winning rule alone and never names a
|
|
// person the rules skipped.
|
|
func Resolve(c Candidate, owners []string, elig Eligibility) (Routing, bool) {
|
|
ok := func(id string) bool { return id != "" && elig.Eligible(id, c.CollectionID, c.ItemID) }
|
|
r := Routing{}
|
|
var claim time.Time
|
|
switch {
|
|
case c.AwaitsHuman && ok(c.AssigneeUserID):
|
|
r.Rule, r.People, claim = RuleAsked, []string{c.AssigneeUserID}, c.CreatedAt
|
|
r.Because = append(r.Because, Because{Kind: BecauseHumanTask, At: c.CreatedAt})
|
|
r.Why = Why{Text: "An ask of you: it is assigned to you in a collection of asks."}
|
|
case len(eligibleMentions(c, ok)) > 0:
|
|
ms := eligibleMentions(c, ok)
|
|
seen := map[string]bool{}
|
|
for _, m := range ms {
|
|
if !seen[m.UserID] {
|
|
seen[m.UserID] = true
|
|
r.People = append(r.People, m.UserID)
|
|
}
|
|
}
|
|
r.Rule = RuleMentioned
|
|
r.byUser = map[string]Routing{}
|
|
for _, uid := range r.People {
|
|
own := Routing{Rule: RuleMentioned, People: r.People}
|
|
var first *Mention
|
|
for i := range ms {
|
|
m := ms[i]
|
|
if m.UserID != uid {
|
|
continue
|
|
}
|
|
own.Because = append(own.Because, Because{Kind: BecauseMention, At: m.At, By: m.By, CommentID: m.CommentID})
|
|
if first == nil || m.At.Before(first.At) {
|
|
first = &ms[i]
|
|
}
|
|
}
|
|
at := first.At
|
|
own.Why = Why{Text: "You were @-mentioned and have not replied.", By: first.By, At: &at, CommentID: first.CommentID}
|
|
own.WaitingSince = first.At
|
|
r.byUser[uid] = own
|
|
}
|
|
// The shared claim is the earliest mention of anyone; a row never
|
|
// shows it, only each person's own (see For).
|
|
claim = ms[0].At
|
|
for _, m := range ms {
|
|
if m.At.Before(claim) {
|
|
claim = m.At
|
|
}
|
|
}
|
|
case ok(c.AssigneeUserID):
|
|
r.Rule, r.People, claim = RuleAssigned, []string{c.AssigneeUserID}, c.AssignedAt
|
|
r.Why = Why{Text: "It is assigned to you."}
|
|
default:
|
|
for _, o := range owners {
|
|
if ok(o) {
|
|
r.People = append(r.People, o)
|
|
}
|
|
}
|
|
if len(r.People) == 0 {
|
|
return Routing{}, false
|
|
}
|
|
r.Rule, claim = RuleAdminFallback, c.CreatedAt
|
|
r.Why = Why{Text: "Nobody else can take it, so it falls to the workspace owners."}
|
|
}
|
|
finish := func(x *Routing, claim time.Time) {
|
|
x.Why.Rule = x.Rule
|
|
if c.NeedsHuman != nil {
|
|
x.Because = append(x.Because, Because{Kind: BecauseNeedsHuman, At: *c.NeedsHuman})
|
|
if c.NeedsHuman.Before(claim) {
|
|
claim = *c.NeedsHuman
|
|
}
|
|
}
|
|
x.WaitingSince = claim
|
|
if x.Why.At == nil {
|
|
at := claim
|
|
x.Why.At = &at
|
|
}
|
|
}
|
|
for uid, own := range r.byUser {
|
|
finish(&own, own.WaitingSince)
|
|
r.byUser[uid] = own
|
|
}
|
|
finish(&r, claim)
|
|
return r, true
|
|
}
|
|
|
|
// eligibleMentions are the open mentions of eligible users, by time then user.
|
|
func eligibleMentions(c Candidate, ok func(string) bool) []Mention {
|
|
var out []Mention
|
|
for _, m := range c.Mentions {
|
|
if ok(m.UserID) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
// The server hands mentions ordered; keep that order stable.
|
|
return out
|
|
}
|
|
|
|
// Score is one scorer's verdict on a row. Value is 0-100.
|
|
type Score struct {
|
|
Value int `json:"value"`
|
|
Hint string `json:"hint,omitempty"`
|
|
Why []string `json:"why,omitempty"`
|
|
}
|
|
|
|
// Viewer is who the rows are scored for.
|
|
type Viewer struct {
|
|
UserID string
|
|
AuthIsBearer bool
|
|
// Now is the clock a scorer measures age and deadlines against: the
|
|
// request's as_of, so every page of one listing scores at one instant.
|
|
// The zero value means the wall clock.
|
|
Now time.Time
|
|
}
|
|
|
|
// Scope is what the viewer may see in one workspace, in the shape the stores
|
|
// take: Unrestricted, or the collections and items listed (a nil list arrives
|
|
// as an empty one). A scorer counts only under it.
|
|
type Scope struct {
|
|
WorkspaceID string
|
|
Unrestricted bool
|
|
CollectionIDs []string
|
|
ItemIDs []string
|
|
}
|
|
|
|
// Scorer fills a score on rows (the importance and simplicity scorers of
|
|
// TASK-3959 are the registered ones). It is called once per workspace, after
|
|
// visibility, routing and snooze and before sort and pagination, and must not
|
|
// call a provider synchronously. degraded
|
|
// reports that its source failed and the scores came back partial or empty:
|
|
// the server flags that workspace only.
|
|
type Scorer interface {
|
|
Name() string // "importance" or "simplicity"
|
|
Score(ctx context.Context, viewer Viewer, scope Scope, rows []*Row) (degraded bool, err error)
|
|
}
|
|
|
|
// WorkspaceLabel names the workspace a row lives in. It is built only from
|
|
// workspaces the caller was admitted to.
|
|
type WorkspaceLabel struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
OwnerUsername string `json:"owner_username"`
|
|
}
|
|
|
|
// ItemInfo is the item a row is about.
|
|
type ItemInfo struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Collection string `json:"collection"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
Status string `json:"status,omitempty"`
|
|
Priority string `json:"priority,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// Row is one item that needs the viewer.
|
|
type Row struct {
|
|
Workspace WorkspaceLabel `json:"workspace"`
|
|
Item ItemInfo `json:"item"`
|
|
Why Why `json:"why"`
|
|
Because []Because `json:"because"`
|
|
NeedsHuman *time.Time `json:"needs_human,omitempty"`
|
|
// WaitingSince is the earliest open claim; sort=age orders by it.
|
|
WaitingSince time.Time `json:"waiting_since"`
|
|
// Marker is the item's state now, for the client to send back when it
|
|
// snoozes the row, so a comment that lands meanwhile is not swallowed.
|
|
Marker *models.NeedsSnoozeMarker `json:"marker,omitempty"`
|
|
Snooze *models.NeedsSnooze `json:"snooze,omitempty"`
|
|
Importance *Score `json:"importance,omitempty"`
|
|
Simplicity *Score `json:"simplicity,omitempty"`
|
|
|
|
// Internal, never serialized.
|
|
ItemID string `json:"-"`
|
|
WorkspaceID string `json:"-"`
|
|
// Held: the viewer's snooze on the item holds now.
|
|
Held bool `json:"-"`
|
|
}
|