- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态) - 裁决编号与 design 节号(02.4.3 / 11.4.2 / design/05 §9 等)逐字保留 - 复核:go build 绿;orchestration 及其子包(audit/comm)测试绿;gofmt 干净
115 lines
3.6 KiB
Go
115 lines
3.6 KiB
Go
package audit
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// ErrQueryEmpty 在 ContextAuditor.Keyword 的 query 为空或全为空白时返回。用 errors.Is
|
||
// 判断它。
|
||
var ErrQueryEmpty = errors.New("audit: query is empty")
|
||
|
||
// contextRadius 是关键词命中处两侧各纳入多少条消息作为上下行窗口(上下各一条)。
|
||
const contextRadius = 1
|
||
|
||
// Located 描述子代理上下文中的单次关键词命中。
|
||
type Located struct {
|
||
// Line 是命中消息在完整上下文中的 1 基下标。
|
||
Line int
|
||
// Surrounding 是以命中消息为中心的窗口:紧邻其前的那条消息、命中消息本身,
|
||
// 以及紧随其后的一条消息,在上下文边界处截断。它始终包含命中消息。
|
||
Surrounding []model.Message
|
||
}
|
||
|
||
// ContextAuditor 提供对某个子代理上下文的只读、可追溯访问。它在构造时快照消息列表,
|
||
// 因此调用方此后的修改不会改变审计器所报告的内容。它是值类型,不持有存储或策略;由
|
||
// 编排层决定要检视哪段上下文。
|
||
type ContextAuditor struct {
|
||
messages []model.Message
|
||
}
|
||
|
||
// NewContextAuditor 快照 messages,使调用方后续的修改不影响审计器。允许传入 nil,
|
||
// 此时得到一个空上下文。
|
||
func NewContextAuditor(messages []model.Message) ContextAuditor {
|
||
cp := make([]model.Message, len(messages))
|
||
copy(cp, messages)
|
||
return ContextAuditor{messages: cp}
|
||
}
|
||
|
||
// Full 返回整段上下文的防御性拷贝。
|
||
func (a ContextAuditor) Full() []model.Message {
|
||
cp := make([]model.Message, len(a.messages))
|
||
copy(cp, a.messages)
|
||
return cp
|
||
}
|
||
|
||
// Tail 按原顺序返回上下文最后 n 条消息。
|
||
//
|
||
// n <= 0 得到空但非 nil 的切片;n >= len 返回整段上下文的拷贝。结果是防御性拷贝。
|
||
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 定位首个文本包含 query 的消息,对消息的文本分段做大小写不敏感的子串匹配
|
||
// (图片载荷被忽略)。它返回该消息的 1 基下标与一个上下行窗口;无命中时返回
|
||
// nil, nil。
|
||
//
|
||
// query 为空时返回 ErrQueryEmpty,ctx 被取消时包装 ctx.Err()。只报告最早的命中。
|
||
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 返回以 index i 为中心、至多 2*contextRadius+1 条消息,并在上下文边界处截断。
|
||
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 返回一条消息各文本分段拼接后的文本。
|
||
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()
|
||
}
|