No model. A subfolder/frontmatter wing must be a plain name of an existing wing and then places; conflicting signals and client-marker text park; everything else goes to the default wing. Wings are read from disk per decision. Mutation-checked. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
// Package wing decides which brain wing an incoming raw note belongs to
|
|
// without the human typing it (hyperguild#205).
|
|
//
|
|
// The contract is conservative: a note is placed automatically only when the
|
|
// decision is explicit, or when two independent opinions (an LLM and a
|
|
// deterministic scorer) agree with enough confidence on a wing that is both
|
|
// known and eligible. Everything else is an Exception for a human to approve.
|
|
// A wing is never created here, and a wing the caller does not mark eligible
|
|
// (client wings, anything not deliberately tagged) is never auto-assigned.
|
|
package wing
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Source says how a Decision was reached.
|
|
type Source string
|
|
|
|
const (
|
|
Explicit Source = "explicit" // subfolder or frontmatter named the wing
|
|
Default Source = "default" // no signal; the configured default wing
|
|
Classified Source = "classified" // classifier + scorer agreed, confident
|
|
Exception Source = "exception" // needs a human
|
|
)
|
|
|
|
// Note is what the resolver sees of an incoming raw note.
|
|
type Note struct {
|
|
Dir string // first subfolder under raw/, "" when dropped at the top
|
|
FrontmatterWing string // `wing:` from the note's frontmatter, "" if absent
|
|
Text string
|
|
}
|
|
|
|
// Suggestion is a Classifier's answer. An empty Wing means "none fits".
|
|
type Suggestion struct {
|
|
Wing string
|
|
Confidence float64 // 0..1
|
|
Reason string
|
|
}
|
|
|
|
// Classifier proposes a wing from the candidates. Implementations must run on
|
|
// a local model; the resolver cannot enforce that.
|
|
type Classifier interface {
|
|
Classify(ctx context.Context, text string, candidates []string) (Suggestion, error)
|
|
}
|
|
|
|
// Scorer is an independent, deterministic opinion (e.g. BM25 against each
|
|
// wing's index). Higher is better; the key is the wing name.
|
|
type Scorer interface {
|
|
Score(text string) map[string]float64
|
|
}
|
|
|
|
// Decision is the outcome. For an Exception, Wing holds the proposal when
|
|
// there was one (so a reviewer can approve it) and is empty otherwise.
|
|
type Decision struct {
|
|
Wing string
|
|
Source Source
|
|
Confidence float64
|
|
Reason string
|
|
}
|
|
|
|
// Resolver holds the policy. Known is every existing wing; Eligible reports
|
|
// whether a known wing may be assigned automatically.
|
|
type Resolver struct {
|
|
Known []string
|
|
Eligible func(wing string) bool
|
|
Classifier Classifier
|
|
Scorer Scorer
|
|
Threshold float64
|
|
}
|
|
|
|
// Resolve never returns an error: anything it cannot decide safely is an
|
|
// Exception with the reason spelled out.
|
|
func (r Resolver) Resolve(ctx context.Context, n Note) Decision {
|
|
dir := norm(n.Dir)
|
|
fm := norm(n.FrontmatterWing)
|
|
switch {
|
|
case dir != "" && fm != "" && dir != fm:
|
|
return exception("", 0, fmt.Sprintf("subfolder %q and frontmatter wing %q disagree", dir, fm))
|
|
case dir != "":
|
|
return r.explicit(dir, "subfolder")
|
|
case fm != "":
|
|
return r.explicit(fm, "frontmatter")
|
|
}
|
|
return r.classify(ctx, n)
|
|
}
|
|
|
|
func (r Resolver) explicit(w, via string) Decision {
|
|
if why := r.refuse(w); why != "" {
|
|
return exception(w, 0, fmt.Sprintf("%s names %q: %s", via, w, why))
|
|
}
|
|
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
|
|
}
|
|
|
|
func (r Resolver) classify(ctx context.Context, n Note) Decision {
|
|
if r.Classifier == nil {
|
|
return exception("", 0, "no classifier configured")
|
|
}
|
|
s, err := r.Classifier.Classify(ctx, n.Text, r.Known)
|
|
if err != nil {
|
|
return exception("", 0, fmt.Sprintf("classifier failed: %v", err))
|
|
}
|
|
w := norm(s.Wing)
|
|
if w == "" {
|
|
return exception("", s.Confidence, "classifier found no fitting wing")
|
|
}
|
|
if why := r.refuse(w); why != "" {
|
|
return exception(w, s.Confidence, fmt.Sprintf("classifier proposed %q: %s", w, why))
|
|
}
|
|
if s.Confidence < r.Threshold {
|
|
return exception(w, s.Confidence, fmt.Sprintf("confidence %.2f below threshold %.2f", s.Confidence, r.Threshold))
|
|
}
|
|
top, ok := r.scorerTop(n.Text)
|
|
if !ok {
|
|
return exception(w, s.Confidence, "scorer has no clear opinion to cross-check against")
|
|
}
|
|
if top != w {
|
|
return exception(w, s.Confidence, fmt.Sprintf("classifier says %q, scorer says %q", w, top))
|
|
}
|
|
return Decision{Wing: w, Source: Classified, Confidence: s.Confidence,
|
|
Reason: fmt.Sprintf("classifier and scorer agree (%.2f): %s", s.Confidence, s.Reason)}
|
|
}
|
|
|
|
// refuse returns why a wing may not be auto-assigned, or "" if it may.
|
|
func (r Resolver) refuse(w string) string {
|
|
if !r.known(w) {
|
|
return "not an existing wing (new wings are never created automatically)"
|
|
}
|
|
if r.Eligible == nil || !r.Eligible(w) {
|
|
return "not eligible for automatic assignment"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r Resolver) known(w string) bool {
|
|
for _, k := range r.Known {
|
|
if norm(k) == w {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// scorerTop returns the single best-scoring wing; a missing scorer, no scores,
|
|
// or a tie at the top is "no clear opinion".
|
|
func (r Resolver) scorerTop(text string) (string, bool) {
|
|
if r.Scorer == nil {
|
|
return "", false
|
|
}
|
|
return TopWing(r.Scorer.Score(text))
|
|
}
|
|
|
|
// TopWing returns the unique highest-scoring wing in scores, or false when
|
|
// there is none or the top is tied. Shared by the Resolver and the backtest so
|
|
// both apply the same notion of "the scorer has an opinion".
|
|
func TopWing(scores map[string]float64) (string, bool) {
|
|
best, bestScore, tied := "", 0.0, false
|
|
for k, v := range scores {
|
|
switch {
|
|
case v > bestScore:
|
|
best, bestScore, tied = norm(k), v, false
|
|
case v == bestScore && v > 0:
|
|
tied = true
|
|
}
|
|
}
|
|
if best == "" || tied {
|
|
return "", false
|
|
}
|
|
return best, true
|
|
}
|
|
|
|
func exception(w string, conf float64, reason string) Decision {
|
|
return Decision{Wing: w, Source: Exception, Confidence: conf, Reason: reason}
|
|
}
|
|
|
|
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|