feat(orchestration/audit): 子代理上下文可审计查询(全量/尾部/关键词定位)+ 主代理代审授权(审计日志+小字)
This commit is contained in:
1 parent
71697ca4f8
commit
8b723009f9
5 files changed
+658
No files matched your search
@@ -0,0 +1,124 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by Approver. Use errors.Is to test for them.
|
||||
var (
|
||||
// ErrActionMissing is returned when the action is blank.
|
||||
ErrActionMissing = errors.New("audit: action is missing")
|
||||
// ErrUnknownActor is returned when the acting role name is blank. This
|
||||
// package has no role registry, so a blank name is the only "unknown" it
|
||||
// can detect; whether a role is truly authorized is decided upstream.
|
||||
ErrUnknownActor = errors.New("audit: actor is unknown")
|
||||
// ErrExplainMissing is returned when the explanation is blank.
|
||||
ErrExplainMissing = errors.New("audit: explanation is missing")
|
||||
)
|
||||
|
||||
// reviewerMain is the name recorded for the main agent that performs the review.
|
||||
const reviewerMain = "main"
|
||||
|
||||
// Approval is the immutable record of a single main-agent review-and-approve of
|
||||
// a subagent's sensitive action.
|
||||
type Approval struct {
|
||||
// Actor is the acting role name (角色名).
|
||||
Actor string
|
||||
// Action is the sensitive action being approved.
|
||||
Action string
|
||||
// Description is the explanation of what the actor wants to do (what the
|
||||
// action means). It is named Description rather than Explain because the
|
||||
// Explain method (below) returns the frontend notice.
|
||||
Description string
|
||||
// Allowed reports whether the action was granted.
|
||||
Allowed bool
|
||||
// Reviewer is the main agent that performed the review.
|
||||
Reviewer string
|
||||
// At is when the review-and-approve happened.
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Explain returns the short frontend notice describing the review outcome. For
|
||||
// an allowed action it is "「<actor> 想要干什么,我审查后批准了」"; for a denied
|
||||
// action it uses "我审查后拒绝了". Actor is interpolated in place of <actor>.
|
||||
func (a Approval) Explain() string {
|
||||
if a.Allowed {
|
||||
return "「" + a.Actor + " 想要干什么,我审查后批准了」"
|
||||
}
|
||||
return "「" + a.Actor + " 想要干什么,我审查后拒绝了」"
|
||||
}
|
||||
|
||||
// Approver records main-agent review-and-approve events and their audit logs.
|
||||
type Approver struct {
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewApprover builds an Approver that writes audit logs through log. The logger
|
||||
// is tagged with source "orchestration/audit". A nil logger is allowed, making
|
||||
// the approver record silently (no log output).
|
||||
func NewApprover(log *slog.Logger) *Approver {
|
||||
return &Approver{log: logger.WithSource(log, "orchestration/audit")}
|
||||
}
|
||||
|
||||
// ReviewAndApprove validates the request, checks ctx, writes a structured audit
|
||||
// log (who / what / when / result), and returns the recorded approval with
|
||||
// Allowed set to true.
|
||||
//
|
||||
// Whether an action is actually sensitive and whether it should be granted is
|
||||
// decided by the upper policy layer; this method only records, narrates and
|
||||
// returns the result model. It returns ErrUnknownActor / ErrActionMissing /
|
||||
// ErrExplainMissing for blank inputs and wraps ctx.Err() when ctx is canceled.
|
||||
func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe string) (*Approval, error) {
|
||||
if err := validateApproval(actor, action, describe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("audit: review-and-approve: %w", err)
|
||||
}
|
||||
ap := &Approval{
|
||||
Actor: actor,
|
||||
Action: action,
|
||||
Description: describe,
|
||||
Allowed: true,
|
||||
Reviewer: reviewerMain,
|
||||
At: time.Now(),
|
||||
}
|
||||
a.logAudit(ap)
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
// logAudit writes the structured who/what/when/result audit record.
|
||||
func (a *Approver) logAudit(ap *Approval) {
|
||||
if a.log == nil {
|
||||
return
|
||||
}
|
||||
a.log.Info("audit: review-and-approve",
|
||||
slog.String("actor", ap.Actor),
|
||||
slog.String("action", ap.Action),
|
||||
slog.String("explain", ap.Description),
|
||||
slog.Bool("allowed", ap.Allowed),
|
||||
slog.String("reviewer", ap.Reviewer),
|
||||
slog.Time("at", ap.At),
|
||||
)
|
||||
}
|
||||
|
||||
// validateApproval rejects blank inputs with the matching sentinel.
|
||||
func validateApproval(actor, action, describe string) error {
|
||||
if strings.TrimSpace(actor) == "" {
|
||||
return fmt.Errorf("%w: actor is blank", ErrUnknownActor)
|
||||
}
|
||||
if strings.TrimSpace(action) == "" {
|
||||
return fmt.Errorf("%w: action is blank", ErrActionMissing)
|
||||
}
|
||||
if strings.TrimSpace(describe) == "" {
|
||||
return fmt.Errorf("%w: explanation is blank", ErrExplainMissing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// newSilentApprover builds an Approver whose audit log is discarded.
|
||||
func newSilentApprover() *Approver {
|
||||
return NewApprover(logger.New(logger.Options{Out: io.Discard}))
|
||||
}
|
||||
|
||||
func TestReviewAndApprove_Records(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ap, err := newSilentApprover().ReviewAndApprove(context.Background(), "worker", "run ls", "list the project dir")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove unexpected error: %v", err)
|
||||
}
|
||||
if ap.Actor != "worker" {
|
||||
t.Fatalf("Actor = %q, want worker", ap.Actor)
|
||||
}
|
||||
if ap.Action != "run ls" {
|
||||
t.Fatalf("Action = %q, want run ls", ap.Action)
|
||||
}
|
||||
if ap.Description != "list the project dir" {
|
||||
t.Fatalf("Description = %q, want list the project dir", ap.Description)
|
||||
}
|
||||
if !ap.Allowed {
|
||||
t.Fatal("Allowed = false, want true")
|
||||
}
|
||||
if ap.Reviewer != "main" {
|
||||
t.Fatalf("Reviewer = %q, want main", ap.Reviewer)
|
||||
}
|
||||
if ap.At.IsZero() {
|
||||
t.Fatal("At is zero, want a timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_Explain_Allowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ap := Approval{Actor: "worker", Allowed: true}
|
||||
if got, want := ap.Explain(), "「worker 想要干什么,我审查后批准了」"; got != want {
|
||||
t.Fatalf("Explain() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproval_Explain_Denied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ap := Approval{Actor: "worker", Allowed: false}
|
||||
if got, want := ap.Explain(), "「worker 想要干什么,我审查后拒绝了」"; got != want {
|
||||
t.Fatalf("Explain() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewAndApprove_Validation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actor string
|
||||
action string
|
||||
desc string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "blank actor", actor: "", action: "run ls", desc: "list dir", wantErr: ErrUnknownActor},
|
||||
{name: "space actor", actor: " ", action: "run ls", desc: "list dir", wantErr: ErrUnknownActor},
|
||||
{name: "blank action", actor: "worker", action: "", desc: "list dir", wantErr: ErrActionMissing},
|
||||
{name: "blank explain", actor: "worker", action: "run ls", desc: "", wantErr: ErrExplainMissing},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ap, err := newSilentApprover().ReviewAndApprove(context.Background(), tt.actor, tt.action, tt.desc)
|
||||
if err == nil {
|
||||
t.Fatalf("ReviewAndApprove returned nil error, want %v", tt.wantErr)
|
||||
}
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("error = %v, want Is(%v)", err, tt.wantErr)
|
||||
}
|
||||
if ap != nil {
|
||||
t.Fatalf("Approval = %+v, want nil on validation failure", ap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewAndApprove_WritesAuditLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
ap := NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf}))
|
||||
|
||||
_, err := ap.ReviewAndApprove(context.Background(), "worker", "run command", "delete prod db")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := buf.String()
|
||||
for _, want := range []string{
|
||||
`"source":"orchestration/audit"`,
|
||||
`"actor":"worker"`,
|
||||
`"action":"run command"`,
|
||||
`"explain":"delete prod db"`,
|
||||
`"allowed":true`,
|
||||
`"reviewer":"main"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("audit log missing %s, got: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewAndApprove_CanceledContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := newSilentApprover().ReviewAndApprove(ctx, "worker", "run ls", "list dir")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewApprover_NilLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ap, err := NewApprover(nil).ReviewAndApprove(context.Background(), "worker", "run ls", "list dir")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove with nil logger returned error: %v", err)
|
||||
}
|
||||
if ap == nil || ap.Actor != "worker" || !ap.Allowed {
|
||||
t.Fatalf("Approval with nil logger not recorded: %+v", ap)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package audit provides the subagent-auditing primitives specified by the
|
||||
// Cyrene orchestration design (DevDocs/design/05-子代理.md §3 and §6) and the
|
||||
// security/audit requirements in DevDocs/design/11-安全与权限.md §4 and §8.
|
||||
//
|
||||
// It has two halves:
|
||||
//
|
||||
// - ContextAuditor gives a main agent read-only, traceable access to a
|
||||
// subagent's context (a []model.Message) so it can audit and pinpoint what
|
||||
// the subagent did: the full context, a tail window, or a keyword hit with
|
||||
// its surrounding lines. It is a pure value type over the message list the
|
||||
// orchestral layer hands it; it holds no storage and no policy.
|
||||
//
|
||||
// - Approver records a main-agent review-and-approve (代审授权) of a
|
||||
// subagent's sensitive action. It writes a structured audit log (who / what
|
||||
// / when / result) and produces the short frontend notice "「xxx 想要干什么,
|
||||
// 我审查后批准了」". Whether an action is actually sensitive and whether it is
|
||||
// allowed is decided by the upper policy layer; this package only records,
|
||||
// narrates, and returns the result model — it never judges permission.
|
||||
//
|
||||
// The package is a leaf: it depends only on the standard library,
|
||||
// internal/runtime/model and internal/logger, and never imports the agent loop
|
||||
// or other runtime packages, so it can sit inside runtime/orchestration without
|
||||
// an import cycle.
|
||||
package audit
|
||||
@@ -0,0 +1,122 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// texts builds a context of text-only messages with the given body texts.
|
||||
func texts(role model.Role, bodies ...string) []model.Message {
|
||||
out := make([]model.Message, 0, len(bodies))
|
||||
for _, b := range bodies {
|
||||
out = append(out, model.TextMessage(role, b))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameMessage compares a message by role and concatenated text; the test
|
||||
// messages are text-only, so this is sufficient for equality.
|
||||
func sameMessage(a, b model.Message) bool {
|
||||
return a.Role == b.Role && messageText(a) == messageText(b)
|
||||
}
|
||||
|
||||
func TestNewContextAuditor_Snapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msgs := texts(model.RoleUser, "alpha", "bravo", "charlie")
|
||||
aud := NewContextAuditor(msgs)
|
||||
|
||||
// Mutating the caller's slice after construction must not affect the audit.
|
||||
msgs[0].Role = model.RoleSystem
|
||||
got := aud.Full()
|
||||
if got[0].Role != model.RoleUser {
|
||||
t.Fatalf("snapshot leaked caller mutation: role = %q, want %q", got[0].Role, model.RoleUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFull_ReturnsDefensiveCopy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
|
||||
|
||||
got := aud.Full()
|
||||
got[0].Role = model.RoleSystem
|
||||
got[1].Role = model.RoleSystem
|
||||
|
||||
fresh := aud.Full()
|
||||
if fresh[0].Role != model.RoleUser || fresh[1].Role != model.RoleUser {
|
||||
t.Fatalf("Full leaked mutation of its returned copy: %+v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
full := texts(model.RoleUser, "alpha", "bravo", "charlie", "delta")
|
||||
aud := NewContextAuditor(full)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
n int
|
||||
want []string
|
||||
}{
|
||||
{name: "zero returns empty", n: 0, want: nil},
|
||||
{name: "negative returns empty", n: -1, want: nil},
|
||||
{name: "single", n: 1, want: []string{"delta"}},
|
||||
{name: "pair", n: 2, want: []string{"charlie", "delta"}},
|
||||
{name: "exact length", n: 4, want: []string{"alpha", "bravo", "charlie", "delta"}},
|
||||
{name: "beyond length clamps to full", n: 9, want: []string{"alpha", "bravo", "charlie", "delta"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := aud.Tail(tt.n)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("Tail(%d) length = %d, want %d", tt.n, len(got), len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i].Role != model.RoleUser || messageText(got[i]) != tt.want[i] {
|
||||
t.Fatalf("Tail(%d)[%d] = %q/%s, want text %q", tt.n, i, messageText(got[i]), got[i].Role, tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyword_LocateWithSurroundings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo", "charlie"))
|
||||
|
||||
loc, err := aud.Keyword(context.Background(), "rav")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc == nil {
|
||||
t.Fatal("Keyword returned nil for a present match")
|
||||
}
|
||||
if loc.Line != 2 {
|
||||
t.Fatalf("Line = %d, want 2", loc.Line)
|
||||
}
|
||||
if len(loc.Surrounding) != 3 {
|
||||
t.Fatalf("Surrounding length = %d, want 3", len(loc.Surrounding))
|
||||
}
|
||||
if !sameMessage(loc.Surrounding[1], model.TextMessage(model.RoleUser, "bravo")) {
|
||||
t.Fatalf("matched message missing from window: %+v", loc.Surrounding)
|
||||
}
|
||||
if !sameMessage(loc.Surrounding[0], model.TextMessage(model.RoleUser, "alpha")) {
|
||||
t.Fatalf("above-message missing from window: %+v", loc.Surrounding)
|
||||
}
|
||||
if !sameMessage(loc.Surrounding[2], model.TextMessage(model.RoleUser, "charlie")) {
|
||||
t.Fatalf("below-message missing from window: %+v", loc.Surrounding)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyword_Edges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("first message has no above-neighbor", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
|
||||
loc, err := aud.Keyword(context.Background(), "alp")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc.Line != 1 {
|
||||
t.Fatalf("Line = %d, want 1", loc.Line)
|
||||
}
|
||||
if len(loc.Surrounding) != 2 {
|
||||
t.Fatalf("Surrounding length = %d, want 2", len(loc.Surrounding))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("last message has no below-neighbor", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
|
||||
loc, err := aud.Keyword(context.Background(), "bra")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc.Line != 2 {
|
||||
t.Fatalf("Line = %d, want 2", loc.Line)
|
||||
}
|
||||
if len(loc.Surrounding) != 2 {
|
||||
t.Fatalf("Surrounding length = %d, want 2", len(loc.Surrounding))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyword_CaseInsensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
|
||||
loc, err := aud.Keyword(context.Background(), "BRAVO")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc == nil || loc.Line != 2 {
|
||||
t.Fatalf("case-insensitive match failed: line = %d", loc.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyword_NoMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha", "bravo"))
|
||||
loc, err := aud.Keyword(context.Background(), "zzz")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc != nil {
|
||||
t.Fatalf("Keyword = %+v, want nil for a missing match", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyword_EmptyAndBlankQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha"))
|
||||
for _, q := range []string{"", " "} {
|
||||
_, err := aud.Keyword(context.Background(), q)
|
||||
if !errors.Is(err, ErrQueryEmpty) {
|
||||
t.Fatalf("Keyword(%q) error = %v, want ErrQueryEmpty", q, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyword_CanceledContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "alpha"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := aud.Keyword(ctx, "alpha")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Keyword error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyword_FirstMatchOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
aud := NewContextAuditor(texts(model.RoleUser, "foo", "bar", "foo"))
|
||||
loc, err := aud.Keyword(context.Background(), "foo")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc.Line != 1 {
|
||||
t.Fatalf("Line = %d, want 1 (earliest match)", loc.Line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyword_IgnoresImagePayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msgs := []model.Message{
|
||||
model.TextMessage(model.RoleUser, "alpha"),
|
||||
model.ImageMessage(model.RoleUser, "", "http://example.com/bravo.png"),
|
||||
}
|
||||
aud := NewContextAuditor(msgs)
|
||||
|
||||
loc, err := aud.Keyword(context.Background(), "bravo")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc != nil {
|
||||
t.Fatalf("Keyword matched an image URL (ignored): %+v", loc)
|
||||
}
|
||||
|
||||
loc, err = aud.Keyword(context.Background(), "alpha")
|
||||
if err != nil {
|
||||
t.Fatalf("Keyword unexpected error: %v", err)
|
||||
}
|
||||
if loc == nil || loc.Line != 1 {
|
||||
t.Fatalf("text-part match failed: line = %d", loc.Line)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user