123 lines
3.8 KiB
Go
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()
|
|
}
|