mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
b1785f8952
* fix(decision): retire text-derived blocked, waiting_on_external and conventions_comments; keep a pending answer on the dashboard (TASK-3915, TASK-3924) IDEA-3906 half B, units 1 and 3. - The attention set asks one question, needs_human_decision. The text-derived blocked Noul is gone: blocked is the dependency graph's alone, and the dashboard no longer appends a "its text says it is blocked" entry. waiting_on_external flagged nothing on 10-17 and goes too. - conventions_comments is no longer registered in production. It took 42% of provider calls on 10-17 for 7 chip-level answers. The set definition and the PerComment machinery stay, since tests and the conventions eval gates use them. The web drops the comment-card chips and their store. - Stored answers to the retired keys and set need no migration: no registered question carries their keys, so they read current=false and the brief counts them as stale questions. - WorkspaceFlags pending tolerance: an above-threshold answer whose item has moved on is kept while a job is owed for it with no recorded failure and the set still applies. Without that, the dashboard entry vanishes for the whole wait for the re-ask, which TASK-3916's debounce will stretch to an hour. - The day-73 titlebody eval control keeps the retired blocked question text locally, so it still re-runs that shape. - docs/api.md and README updated. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * test(decision): a claimed re-ask still counts as pending; rewording one question keeps the others (TASK-3915 r1) r1 hunter Lows, all FIXED: the per-key rewording rule had lost its coverage with the one-question set, so a two-question test set keeps it; a claimed-but-in-flight job leg is added to the pending test; the ProductionRegistry comment's run-on join is split. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF
159 lines
5.7 KiB
Go
159 lines
5.7 KiB
Go
package decision
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/collections"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/store"
|
|
)
|
|
|
|
// TASK-3119 U1: executable conventions. One Noul per active `always`
|
|
// convention, asked about every item write in a user collection: "does this
|
|
// content break it?". The answer becomes a "Possibly breaks CONVE-N" chip; it
|
|
// NEVER blocks or refuses a write.
|
|
//
|
|
// Measured before it was built (U0, TASK-3119's trail, 116 hand-labelled
|
|
// cases): precise when it fires on a convention whose subject is in the item,
|
|
// but shy (it catches blatant breaks, not subtle ones), and silent on rules
|
|
// that don't apply to an item (a behaviour rule fired 0 of 20). Hence the
|
|
// chip wording, and no "complies" state anywhere: no chip means nothing.
|
|
const (
|
|
ConventionsSetName = "conventions"
|
|
|
|
// ConventionKeyPrefix starts every question key: conv:<CONVE-ref>.
|
|
ConventionKeyPrefix = "conv:"
|
|
|
|
// ConventionThreshold is the Noul probability at or above which a chip
|
|
// shows. U0 measured it: 100% precision on the conventions whose subject
|
|
// is in the item.
|
|
ConventionThreshold = 0.9
|
|
|
|
// conventionMaxBodyBytes skips a convention whose body is longer: it would
|
|
// dominate every call's prompt (one convention here is 31 KB). Skips are
|
|
// logged by ref.
|
|
conventionMaxBodyBytes = 4096
|
|
|
|
// conventionsPerCall bounds the questions in one provider call. 12, from
|
|
// the U2 gates' measure C (TASK-3119 trail): up to 13 questions in ONE
|
|
// call gave identical precision and recall to one question per call on
|
|
// every convention, and moved 0 of 116 answers by more than 0.2. The
|
|
// state is sent once per call, so fewer calls is most of the saving.
|
|
conventionsPerCall = 12
|
|
|
|
// decisionCheckField is the conventions schema field that switches a
|
|
// convention's check off (value "off"); absent or anything else is on.
|
|
decisionCheckField = "decision_check"
|
|
)
|
|
|
|
// conventionQuestion is the Noul, worded exactly as U0 measured it.
|
|
func conventionQuestion(body string) Question {
|
|
return Noul(
|
|
"This content violates the following convention: "+body,
|
|
"the content does what the convention forbids, or lacks what it requires",
|
|
"the content complies with the convention, or the convention does not apply to it",
|
|
)
|
|
}
|
|
|
|
// ConventionsSet returns the `conventions` question set.
|
|
func ConventionsSet() QuestionSet {
|
|
return QuestionSet{
|
|
Name: ConventionsSetName,
|
|
Resolve: resolveConventions,
|
|
WithLinks: true,
|
|
NoTrail: true,
|
|
MaxPerCall: conventionsPerCall,
|
|
// User collections only: a convention or playbook is not work an
|
|
// item-content rule is about.
|
|
Eligible: func(_ *models.Item, coll *models.Collection) bool { return !coll.IsSystem },
|
|
Threshold: ConventionThreshold,
|
|
}
|
|
}
|
|
|
|
// ConventionsCommentsSetName is the comment subject of the conventions check
|
|
// (TASK-3119 U2b): the same questions, asked about each comment alone.
|
|
const ConventionsCommentsSetName = "conventions_comments"
|
|
|
|
// ConventionsCommentsSet returns the `conventions_comments` question set:
|
|
// ConventionsSet's questions, asked about each of the item's recent comments
|
|
// with the item's title and collection for context (BuildCommentState).
|
|
//
|
|
// NOT registered in production since TASK-3924 (see ProductionRegistry). It
|
|
// stays defined, with the PerComment machinery, because tests exercise that
|
|
// machinery through it and the conventions eval gates use its state builder.
|
|
func ConventionsCommentsSet() QuestionSet {
|
|
return QuestionSet{
|
|
Name: ConventionsCommentsSetName,
|
|
Resolve: resolveConventions,
|
|
PerComment: true,
|
|
// stateFor is not used for this set's asking; NoTrail keeps the item
|
|
// state Decisions builds for it from reading comments it ignores.
|
|
NoTrail: true,
|
|
MaxPerCall: conventionsPerCall,
|
|
Eligible: func(_ *models.Item, coll *models.Collection) bool { return !coll.IsSystem },
|
|
Threshold: ConventionThreshold,
|
|
}
|
|
}
|
|
|
|
// resolveConventions is the workspace's active `always` conventions whose
|
|
// check is not switched off, one question each, keyed conv:<ref>.
|
|
func resolveConventions(_ context.Context, s *store.Store, workspaceID string) (map[string]Question, error) {
|
|
colls, err := s.ListTraitedCollections(workspaceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
coll := collections.FindByArtifactKind(colls, collections.BuiltinConvention)
|
|
if coll == nil {
|
|
return map[string]Question{}, nil
|
|
}
|
|
items, err := s.ListItems(workspaceID, models.ItemListParams{
|
|
ScopeCollectionID: coll.ID,
|
|
Fields: map[string]string{"status": "active", "trigger": "always"},
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list conventions: %w", err)
|
|
}
|
|
out := make(map[string]Question, len(items))
|
|
for i := range items {
|
|
it := &items[i]
|
|
if conventionCheckOff(it) || it.Ref == "" {
|
|
continue
|
|
}
|
|
if len(it.Content) > conventionMaxBodyBytes {
|
|
logSkippedConvention(workspaceID, it.Ref, len(it.Content))
|
|
continue
|
|
}
|
|
out[ConventionKeyPrefix+it.Ref] = conventionQuestion(it.Content)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func conventionCheckOff(it *models.Item) bool {
|
|
if it.Fields == "" {
|
|
return false
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal([]byte(it.Fields), &fields); err != nil {
|
|
return false
|
|
}
|
|
v, _ := fields[decisionCheckField].(string)
|
|
return v == "off"
|
|
}
|
|
|
|
// skipLogged makes each skipped convention named once per process, not on
|
|
// every write that resolves the set.
|
|
var skipLogged sync.Map
|
|
|
|
func logSkippedConvention(workspaceID, ref string, size int) {
|
|
key := workspaceID + "/" + ref
|
|
if _, seen := skipLogged.LoadOrStore(key, true); seen {
|
|
return
|
|
}
|
|
slog.Info("conventions check: skipping a convention over the body cap",
|
|
"workspace_id", workspaceID, "ref", ref, "bytes", size, "cap_bytes", conventionMaxBodyBytes)
|
|
}
|