feat(ai): AmbientDrift — frame-grounded drift judge
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
// internal/ai/ambient.go
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AmbientDriftJudge judges whether current activity serves the user's standing
|
||||
// frame when NO task is declared. Like Nudge it takes primitives, not domain
|
||||
// types, so ai stays a leaf. The returned string is a one-sentence advisory, or
|
||||
// "" when the activity is plausibly on-track.
|
||||
type AmbientDriftJudge interface {
|
||||
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
|
||||
}
|
||||
|
||||
// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response.
|
||||
var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift")
|
||||
|
||||
// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend.
|
||||
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) {
|
||||
out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseAmbientDrift(out)
|
||||
}
|
||||
|
||||
func buildAmbientPrompt(frame string, recentTitles []string) string {
|
||||
return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame — goals, values, and life-domains — and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift.
|
||||
|
||||
Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters.
|
||||
|
||||
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||
{"drifting": <true or false>, "message": "<short explanation, one sentence>"}
|
||||
|
||||
Rules:
|
||||
- drifting: true only if the recent activity serves none of the frame; false otherwise.
|
||||
- message: one short sentence naming the drift. REQUIRED when drifting is true.
|
||||
|
||||
## Frame
|
||||
` + frame + `
|
||||
|
||||
## Recent window titles (oldest to newest)
|
||||
` + strings.Join(recentTitles, "\n")
|
||||
}
|
||||
|
||||
type rawAmbientDrift struct {
|
||||
Drifting bool `json:"drifting"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields
|
||||
// "". A drift with no message degrades to "" (silence) rather than an error:
|
||||
// the ambient signal is advisory, so the safe degenerate is to say nothing —
|
||||
// exactly like parseNudge.
|
||||
func parseAmbientDrift(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", ErrInvalidAmbientDrift, err)
|
||||
}
|
||||
var raw rawAmbientDrift
|
||||
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
|
||||
}
|
||||
if !raw.Drifting {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(raw.Message), nil
|
||||
}
|
||||
Reference in New Issue
Block a user