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:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user