Add ai.Nudger role for semantic drift within allowed apps

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 17:54:29 -04:00
parent 4e5e1f3881
commit b96319d847
2 changed files with 139 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
package ai
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Nudger judges whether recent activity within an allowed app still serves the
// commitment. Like Coach and DriftJudge it takes primitives, not domain/evidence
// types, so ai stays a leaf package. The returned string is a one-sentence
// advisory, or "" when the trajectory is still on-task.
type Nudger interface {
Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error)
}
// ErrInvalidNudge marks a parseable-but-unusable nudge response.
var ErrInvalidNudge = errors.New("ai: invalid nudge")
// Nudge makes Service satisfy Nudger over the same backend as Coach and JudgeDrift.
func (s *Service) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
out, err := s.backend.Run(ctx, buildNudgePrompt(commitment, recentTitles))
if err != nil {
return "", err
}
return parseNudge(out)
}
func buildNudgePrompt(commitment string, recentTitles []string) string {
return `You are a focus monitor. The user committed to a task and is working in an allowed application, so the application itself is fine. Judge whether the SEQUENCE of recent window titles shows them still working toward the commitment, or whether they have drifted onto unrelated work within that app.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"on_track": <true or false>, "message": "<short explanation, one sentence>"}
Rules:
- on_track: true if the recent titles plausibly serve the commitment, false if they show unrelated work.
- message: one short sentence naming the drift. REQUIRED when on_track is false.
Commitment: ` + commitment + `
Recent window titles (oldest to newest):
` + strings.Join(recentTitles, "\n")
}
type rawNudge struct {
OnTrack bool `json:"on_track"`
Message string `json:"message"`
}
// parseNudge extracts the advisory from raw CLI output. It reuses extractJSON
// and the shared empty/no-JSON sentinels. An on-track result yields "". A
// concern with no message degrades to "" (silence) rather than an error: the
// nudge is ambient, so the safe degenerate is to say nothing, unlike the
// interruptive drift verdict which rejects a reasonless drift.
func parseNudge(s string) (string, error) {
if strings.TrimSpace(s) == "" {
return "", ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return "", ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
}
var raw rawNudge
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
}
if raw.OnTrack {
return "", nil
}
return strings.TrimSpace(raw.Message), nil
}
+64
View File
@@ -0,0 +1,64 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestServiceNudgeSuccess(t *testing.T) {
fb := &fakeBackend{out: `{"on_track": false, "message": "reading news, not the auth flow"}`}
advisory, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"})
if err != nil {
t.Fatalf("nudge: %v", err)
}
if advisory == "" {
t.Fatal("expected non-empty advisory")
}
if !strings.Contains(fb.gotPrompt, "fix auth") {
t.Fatalf("prompt should embed the commitment, got: %s", fb.gotPrompt)
}
}
func TestServiceNudgeBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"}); err == nil {
t.Fatal("want backend error")
}
}
func TestParseNudge(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr error
}{
{"on track yields empty", `{"on_track": true, "message": ""}`, "", nil},
{"concern returns message", `{"on_track": false, "message": "editing unrelated CSS, not the auth flow"}`, "editing unrelated CSS, not the auth flow", nil},
{"concern is trimmed", `{"on_track": false, "message": " drifted "}`, "drifted", nil},
{"concern without message degrades to silence", `{"on_track": false, "message": ""}`, "", nil},
{"json embedded in prose", `sure thing: {"on_track": false, "message": "wrong project"} done`, "wrong project", nil},
{"empty response", " ", "", ErrEmptyResponse},
{"no json", "no braces here", "", ErrNoJSON},
{"malformed json", `{"on_track": false, "message":`, "", ErrInvalidNudge},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseNudge(tt.in)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}