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() }