Files
Cyrene_v2/internal/runtime/orchestration/audit/context.go
T

123 lines
3.8 KiB
Go

package audit
import (
"context"
"errors"
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// ErrQueryEmpty is returned by ContextAuditor.Keyword when the query is empty
// or blank. Use errors.Is to test for it.
var ErrQueryEmpty = errors.New("audit: query is empty")
// contextRadius is how many messages on each side of a keyword hit are included
// in the surrounding window (one above and one below).
const contextRadius = 1
// Located describes a single keyword match within a subagent context.
type Located struct {
// Line is the 1-based index of the matched message in the full context.
Line int
// Surrounding is a window centered on the matched message: the message
// immediately before it, the matched message, and the message immediately
// after it, clamped at the context boundaries. It always contains the
// matched message.
Surrounding []model.Message
}
// ContextAuditor provides read-only, traceable access to a subagent's context.
// It snapshots the message list at construction so later mutation by the caller
// does not change what the auditor reports. It is a value type that holds no
// storage or policy; the orchestral layer decides which context to inspect.
type ContextAuditor struct {
messages []model.Message
}
// NewContextAuditor snapshots messages so subsequent caller mutations do not
// affect the auditor. A nil input is allowed and yields an empty context.
func NewContextAuditor(messages []model.Message) ContextAuditor {
cp := make([]model.Message, len(messages))
copy(cp, messages)
return ContextAuditor{messages: cp}
}
// Full returns a defensive copy of the entire context.
func (a ContextAuditor) Full() []model.Message {
cp := make([]model.Message, len(a.messages))
copy(cp, a.messages)
return cp
}
// Tail returns the last n messages of the context in their original order.
//
// n <= 0 yields an empty, non-nil slice; n >= len returns a copy of the full
// context. The result is a defensive copy.
func (a ContextAuditor) Tail(n int) []model.Message {
if n <= 0 {
return []model.Message{}
}
if n >= len(a.messages) {
return a.Full()
}
start := len(a.messages) - n
out := make([]model.Message, n)
copy(out, a.messages[start:])
return out
}
// Keyword locates the first message whose text contains query, using a
// case-insensitive substring match against the message's text parts (image
// payloads are ignored). It returns the message's 1-based index and a
// surrounding window, or nil, nil when there is no match.
//
// It returns ErrQueryEmpty for a blank query and wraps ctx.Err() when ctx is
// canceled. Only the earliest match is reported.
func (a ContextAuditor) Keyword(ctx context.Context, query string) (*Located, error) {
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("%w: query is blank", ErrQueryEmpty)
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("audit: keyword: %w", err)
}
q := strings.ToLower(query)
for i, m := range a.messages {
if strings.Contains(strings.ToLower(messageText(m)), q) {
return &Located{Line: i + 1, Surrounding: a.window(i)}, nil
}
}
return nil, nil
}
// window returns up to 2*contextRadius+1 messages centered on index i, clamped
// to the context boundaries.
func (a ContextAuditor) window(i int) []model.Message {
lo := i - contextRadius
hi := i + contextRadius
if lo < 0 {
lo = 0
}
if hi >= len(a.messages) {
hi = len(a.messages) - 1
}
if lo > hi {
return nil
}
out := make([]model.Message, 0, hi-lo+1)
out = append(out, a.messages[lo:hi+1]...)
return out
}
// messageText returns the concatenated text of a message's text parts.
func messageText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText && c.Text != "" {
b.WriteString(c.Text)
}
}
return b.String()
}