f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
425 lines
13 KiB
Go
425 lines
13 KiB
Go
package db
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// automatedPrefixes are first_message prefixes that identify
|
|
// automated (roborev) sessions. Matched case-sensitively.
|
|
// Combined with the single-turn gate (user_message_count <= 1)
|
|
// to avoid misclassifying interactive sessions.
|
|
var automatedPrefixes = []string{
|
|
"You are a code reviewer.",
|
|
"You are a security code reviewer.",
|
|
"You are a design reviewer.",
|
|
"You are a code assistant. Your task is to address",
|
|
"You are a code review insights analyst.",
|
|
"You are reviewing whether an implementation matches",
|
|
"You are a plan document reviewer.",
|
|
"You are a spec document reviewer.",
|
|
"You are summarizing a day of AI agent activity.",
|
|
"You are analyzing AI agent sessions.",
|
|
"## Analysis Request",
|
|
"# Fix Request",
|
|
"You are a helpful assistant working on a software project.",
|
|
"You are combining multiple code review outputs into a single GitHub PR comment.",
|
|
"You are generating a changelog",
|
|
"<user_action>",
|
|
"Review the code changes introduced by commit ",
|
|
"Review the code changes in commit ",
|
|
"Implement the following plan:",
|
|
}
|
|
|
|
// automatedSubstrings are patterns matched anywhere in the
|
|
// first message. Used for catch-all markers embedded in
|
|
// longer prompts.
|
|
var automatedSubstrings = []string{
|
|
"invoked by roborev to perform this review",
|
|
"You are a security code reviewer with an exploitability burden of proof. Review the code changes for concrete vulnerabilities",
|
|
"You are a conversation title generator",
|
|
}
|
|
|
|
// automatedExactMatches are first messages that, after trimming
|
|
// surrounding whitespace, exactly equal one of these strings.
|
|
// Used for prompts too generic for prefix or substring matching
|
|
// (e.g., a single-word warmup ping).
|
|
var automatedExactMatches = []string{
|
|
"Warmup",
|
|
"Respond with exactly: OK",
|
|
"Reply with exactly OK.",
|
|
}
|
|
|
|
var (
|
|
automatedPrefixBytes = automationPatternsAsBytes(automatedPrefixes)
|
|
automatedSubstringBytes = automationPatternsAsBytes(automatedSubstrings)
|
|
)
|
|
|
|
const userPatternMaxLen = 1024
|
|
|
|
// AutomationEvidencePrefixBytes is the bounded prefix size used by backend
|
|
// integrity audits. It is large enough to hold every accepted user pattern.
|
|
const AutomationEvidencePrefixBytes = userPatternMaxLen
|
|
|
|
var (
|
|
userPatternsMu sync.RWMutex
|
|
userPrefixes []string
|
|
userSubstrings []string
|
|
userExactMatches []string
|
|
)
|
|
|
|
// SetUserAutomationPrefixes replaces the user-prefix slice
|
|
// with a normalized copy of the input. Normalization (trim,
|
|
// drop empty, length cap, dedupe within input, drop entries
|
|
// that equal a built-in prefix) happens here so callers can
|
|
// pass the raw list straight from config. Pass nil to clear.
|
|
// Idempotent and silent, safe to call from quiet CLI paths
|
|
// (statusline, JSON output). Callers that want a startup
|
|
// summary should read the relevant getter(s).
|
|
func SetUserAutomationPrefixes(prefixes []string) {
|
|
setUserAutomationPatterns(&userPrefixes, prefixes, automatedPrefixes)
|
|
}
|
|
|
|
// SetUserAutomationSubstrings replaces the user-substring
|
|
// slice with a normalized copy of the input. The same trim,
|
|
// dedupe, and length rules apply; overlap filtering only
|
|
// removes built-in substrings.
|
|
func SetUserAutomationSubstrings(substrings []string) {
|
|
setUserAutomationPatterns(&userSubstrings, substrings, automatedSubstrings)
|
|
}
|
|
|
|
// SetUserAutomationExactMatches replaces the user exact-match
|
|
// slice with a normalized copy of the input. Matching trims
|
|
// the message before comparing against these literals.
|
|
func SetUserAutomationExactMatches(matches []string) {
|
|
setUserAutomationPatterns(&userExactMatches, matches, automatedExactMatches)
|
|
}
|
|
|
|
// UserAutomationPrefixes returns a copy of the current
|
|
// user-prefix slice. Used by ClassifierHash and tests; the
|
|
// copy prevents callers from mutating singleton state.
|
|
func UserAutomationPrefixes() []string {
|
|
return copyUserAutomationPatterns(&userPrefixes)
|
|
}
|
|
|
|
// UserAutomationSubstrings returns a copy of the current
|
|
// user-substring slice.
|
|
func UserAutomationSubstrings() []string {
|
|
return copyUserAutomationPatterns(&userSubstrings)
|
|
}
|
|
|
|
// UserAutomationExactMatches returns a copy of the current
|
|
// user exact-match slice.
|
|
func UserAutomationExactMatches() []string {
|
|
return copyUserAutomationPatterns(&userExactMatches)
|
|
}
|
|
|
|
func setUserAutomationPatterns(dst *[]string, in []string, builtIns []string) {
|
|
cleaned := normalizeUserAutomationPatterns(in, builtIns)
|
|
userPatternsMu.Lock()
|
|
defer userPatternsMu.Unlock()
|
|
*dst = cleaned
|
|
}
|
|
|
|
func copyUserAutomationPatterns(src *[]string) []string {
|
|
userPatternsMu.RLock()
|
|
defer userPatternsMu.RUnlock()
|
|
return append([]string(nil), (*src)...)
|
|
}
|
|
|
|
func snapshotUserAutomationPatterns() (
|
|
prefixes, substrings, exactMatches []string,
|
|
) {
|
|
userPatternsMu.RLock()
|
|
defer userPatternsMu.RUnlock()
|
|
return append([]string(nil), userPrefixes...),
|
|
append([]string(nil), userSubstrings...),
|
|
append([]string(nil), userExactMatches...)
|
|
}
|
|
|
|
type automationPatternSnapshot struct {
|
|
userPrefixes []string
|
|
userSubstrings []string
|
|
userExactMatches []string
|
|
userPrefixBytes [][]byte
|
|
userSubstringBytes [][]byte
|
|
}
|
|
|
|
// AutomationClassifier is an immutable pattern snapshot for a complete
|
|
// storage audit. Reusing it keeps every row on the same classifier version and
|
|
// avoids rebuilding user-pattern byte slices per row.
|
|
type AutomationClassifier struct {
|
|
patterns automationPatternSnapshot
|
|
}
|
|
|
|
// SnapshotAutomationClassifier captures the current built-in and user
|
|
// automation patterns for reuse across one backend audit.
|
|
func SnapshotAutomationClassifier() AutomationClassifier {
|
|
return AutomationClassifier{patterns: snapshotAutomationPatterns()}
|
|
}
|
|
|
|
func snapshotAutomationPatterns() automationPatternSnapshot {
|
|
prefixes, substrings, exactMatches := snapshotUserAutomationPatterns()
|
|
return automationPatternSnapshot{
|
|
userPrefixes: prefixes,
|
|
userSubstrings: substrings,
|
|
userExactMatches: exactMatches,
|
|
userPrefixBytes: automationPatternsAsBytes(prefixes),
|
|
userSubstringBytes: automationPatternsAsBytes(substrings),
|
|
}
|
|
}
|
|
|
|
func automationPatternsAsBytes(patterns []string) [][]byte {
|
|
if len(patterns) == 0 {
|
|
return nil
|
|
}
|
|
out := make([][]byte, len(patterns))
|
|
for i, pattern := range patterns {
|
|
out[i] = []byte(pattern)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeUserAutomationPatterns applies the validation rules from the
|
|
// design spec ("Validation behavior" section). Built-in overlap is checked
|
|
// against the category-specific pattern slice directly.
|
|
func normalizeUserAutomationPatterns(in []string, builtIns []string) []string {
|
|
seen := make(map[string]struct{}, len(in))
|
|
out := make([]string, 0, len(in))
|
|
for _, raw := range in {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" || len(s) > userPatternMaxLen {
|
|
continue
|
|
}
|
|
if _, dup := seen[s]; dup {
|
|
continue
|
|
}
|
|
if slices.Contains(builtIns, s) {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// IsAutomatedSession returns true if the first message
|
|
// matches a known automated review/fix prompt pattern.
|
|
func IsAutomatedSession(firstMessage string) bool {
|
|
return snapshotAutomationPatterns().matches(firstMessage)
|
|
}
|
|
|
|
func (patterns automationPatternSnapshot) matches(firstMessage string) bool {
|
|
for _, prefix := range automatedPrefixes {
|
|
if strings.HasPrefix(firstMessage, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
for _, prefix := range patterns.userPrefixes {
|
|
if strings.HasPrefix(firstMessage, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
for _, sub := range automatedSubstrings {
|
|
if strings.Contains(firstMessage, sub) {
|
|
return true
|
|
}
|
|
}
|
|
for _, sub := range patterns.userSubstrings {
|
|
if strings.Contains(firstMessage, sub) {
|
|
return true
|
|
}
|
|
}
|
|
trimmed := strings.TrimSpace(firstMessage)
|
|
if slices.Contains(automatedExactMatches, trimmed) {
|
|
return true
|
|
}
|
|
if slices.Contains(patterns.userExactMatches, trimmed) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AutomationVerdictFromPrefix classifies bounded byte evidence. A visible
|
|
// prefix or substring match is definitive even when text was truncated. A
|
|
// non-match is definitive only when the prefix contains the complete value;
|
|
// unseen bytes may contain a substring or change an exact-match verdict.
|
|
func AutomationVerdictFromPrefix(
|
|
prefix []byte, fullByteLength int64,
|
|
) (matched, conclusive bool) {
|
|
return snapshotAutomationPatterns().verdictFromPrefix(
|
|
prefix, fullByteLength,
|
|
)
|
|
}
|
|
|
|
func (patterns automationPatternSnapshot) verdictFromPrefix(
|
|
prefix []byte, fullByteLength int64,
|
|
) (matched, conclusive bool) {
|
|
for _, pattern := range automatedPrefixBytes {
|
|
if bytes.HasPrefix(prefix, pattern) {
|
|
return true, true
|
|
}
|
|
}
|
|
for _, pattern := range patterns.userPrefixBytes {
|
|
if bytes.HasPrefix(prefix, pattern) {
|
|
return true, true
|
|
}
|
|
}
|
|
for _, pattern := range automatedSubstringBytes {
|
|
if bytes.Contains(prefix, pattern) {
|
|
return true, true
|
|
}
|
|
}
|
|
for _, pattern := range patterns.userSubstringBytes {
|
|
if bytes.Contains(prefix, pattern) {
|
|
return true, true
|
|
}
|
|
}
|
|
if fullByteLength != int64(len(prefix)) {
|
|
return false, false
|
|
}
|
|
trimmed := strings.TrimSpace(string(prefix))
|
|
return slices.Contains(automatedExactMatches, trimmed) ||
|
|
slices.Contains(patterns.userExactMatches, trimmed), true
|
|
}
|
|
|
|
// AutomationTextEvidence is bounded text and its complete byte length. Valid
|
|
// distinguishes a missing SQL value from an empty complete string.
|
|
type AutomationTextEvidence struct {
|
|
Prefix []byte
|
|
FullByteLength int64
|
|
Valid bool
|
|
}
|
|
|
|
// AutomationVerdictFromEvidence combines the first stored user message and
|
|
// fallback first_message using the same ordering and single-turn rule as the
|
|
// full-text classifier. An inconclusive result requires full-text fallback.
|
|
func AutomationVerdictFromEvidence(
|
|
userMessageCount int,
|
|
firstUser, firstMessage AutomationTextEvidence,
|
|
) (matched, conclusive bool) {
|
|
return SnapshotAutomationClassifier().VerdictFromEvidence(
|
|
userMessageCount, firstUser, firstMessage,
|
|
)
|
|
}
|
|
|
|
// VerdictFromEvidence classifies bounded evidence using this snapshot.
|
|
func (classifier AutomationClassifier) VerdictFromEvidence(
|
|
userMessageCount int,
|
|
firstUser, firstMessage AutomationTextEvidence,
|
|
) (matched, conclusive bool) {
|
|
return classifier.patterns.verdictFromEvidence(
|
|
userMessageCount, firstUser, firstMessage,
|
|
)
|
|
}
|
|
|
|
func (patterns automationPatternSnapshot) verdictFromEvidence(
|
|
userMessageCount int,
|
|
firstUser, firstMessage AutomationTextEvidence,
|
|
) (matched, conclusive bool) {
|
|
if userMessageCount > 1 {
|
|
return false, true
|
|
}
|
|
|
|
unresolved := false
|
|
for _, candidate := range [...]AutomationTextEvidence{firstUser, firstMessage} {
|
|
if !candidate.Valid {
|
|
continue
|
|
}
|
|
matched, conclusive := patterns.verdictFromPrefix(
|
|
candidate.Prefix, candidate.FullByteLength,
|
|
)
|
|
if matched {
|
|
return true, true
|
|
}
|
|
if !conclusive {
|
|
unresolved = true
|
|
}
|
|
}
|
|
return false, !unresolved
|
|
}
|
|
|
|
// IsAutomatedTranscript classifies automation from the actual
|
|
// transcript and the stored first_message. The first_message
|
|
// candidate preserves legacy/imported rows whose messages are
|
|
// unavailable or less specific than the parser-owned preview.
|
|
func IsAutomatedTranscript(
|
|
userMessageCount int,
|
|
msgs []Message,
|
|
firstMessage *string,
|
|
) bool {
|
|
if userMessageCount > 1 {
|
|
return false
|
|
}
|
|
if firstUser, ok := firstUserMessageContent(msgs); ok &&
|
|
IsAutomatedSession(firstUser) {
|
|
return true
|
|
}
|
|
return firstMessage != nil && IsAutomatedSession(*firstMessage)
|
|
}
|
|
|
|
func firstUserMessageContent(msgs []Message) (string, bool) {
|
|
for _, m := range msgs {
|
|
if m.Role != "user" || m.IsSystem {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(m.Content) == "" {
|
|
continue
|
|
}
|
|
return m.Content, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func isAutomatedFromTextCandidates(
|
|
userMessageCount int,
|
|
firstUserMessage, firstMessage sql.NullString,
|
|
) bool {
|
|
return IsAutomatedFromTextCandidates(
|
|
userMessageCount, firstUserMessage, firstMessage,
|
|
)
|
|
}
|
|
|
|
// IsAutomatedFromTextCandidates applies the authoritative transcript fallback
|
|
// order to storage values shared by the SQLite and PostgreSQL audits.
|
|
func IsAutomatedFromTextCandidates(
|
|
userMessageCount int,
|
|
firstUserMessage, firstMessage sql.NullString,
|
|
) bool {
|
|
return SnapshotAutomationClassifier().IsAutomatedFromTextCandidates(
|
|
userMessageCount, firstUserMessage, firstMessage,
|
|
)
|
|
}
|
|
|
|
// IsAutomatedFromTextCandidates applies the full-text fallback order using
|
|
// this classifier snapshot.
|
|
func (classifier AutomationClassifier) IsAutomatedFromTextCandidates(
|
|
userMessageCount int,
|
|
firstUserMessage, firstMessage sql.NullString,
|
|
) bool {
|
|
return classifier.patterns.matchesTextCandidates(
|
|
userMessageCount, firstUserMessage, firstMessage,
|
|
)
|
|
}
|
|
|
|
func (patterns automationPatternSnapshot) matchesTextCandidates(
|
|
userMessageCount int,
|
|
firstUserMessage, firstMessage sql.NullString,
|
|
) bool {
|
|
if userMessageCount > 1 {
|
|
return false
|
|
}
|
|
if firstUserMessage.Valid &&
|
|
strings.TrimSpace(firstUserMessage.String) != "" &&
|
|
patterns.matches(firstUserMessage.String) {
|
|
return true
|
|
}
|
|
return firstMessage.Valid && patterns.matches(firstMessage.String)
|
|
}
|