Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
package support
import (
"sync"
"time"
"github.com/google/uuid"
)
const (
defaultAICompanyPerHour = 10
defaultAIPlatformPerMinute = 30
)
// AIRateLimiter bounds AI auto-reply jobs (not FAQ matches).
// In-process only — effective limit ≈ N × replicas (same pattern as processing.StartLimiter).
// RATE_LIMIT_REPLICAS does not divide this limiter; multi-replica hard caps need edge/WAF.
type AIRateLimiter struct {
mu sync.Mutex
companyLimit int
companyWindow time.Duration
companyHits map[uuid.UUID][]time.Time
platformLimit int
platformWindow time.Duration
platformHits []time.Time
lastGC time.Time
}
// NewAIRateLimiter builds a limiter with contract defaults (10/company/hour, 30/platform/min).
func NewAIRateLimiter(companyPerHour, platformPerMinute int) *AIRateLimiter {
if companyPerHour <= 0 {
companyPerHour = defaultAICompanyPerHour
}
if platformPerMinute <= 0 {
platformPerMinute = defaultAIPlatformPerMinute
}
return &AIRateLimiter{
companyLimit: companyPerHour,
companyWindow: time.Hour,
companyHits: make(map[uuid.UUID][]time.Time),
platformLimit: platformPerMinute,
platformWindow: time.Minute,
lastGC: time.Now(),
}
}
// Allow reports whether an AI auto-reply job may proceed for companyID.
// On deny, no counters are incremented (caller may retry later).
func (l *AIRateLimiter) Allow(companyID uuid.UUID) bool {
if l == nil {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
l.gcLocked(now)
companyCut := now.Add(-l.companyWindow)
ch := l.companyHits[companyID]
keptC := ch[:0]
for _, t := range ch {
if t.After(companyCut) {
keptC = append(keptC, t)
}
}
if len(keptC) >= l.companyLimit {
l.companyHits[companyID] = keptC
return false
}
platformCut := now.Add(-l.platformWindow)
keptP := l.platformHits[:0]
for _, t := range l.platformHits {
if t.After(platformCut) {
keptP = append(keptP, t)
}
}
if len(keptP) >= l.platformLimit {
l.platformHits = keptP
l.companyHits[companyID] = keptC
return false
}
l.companyHits[companyID] = append(keptC, now)
l.platformHits = append(keptP, now)
return true
}
func (l *AIRateLimiter) gcLocked(now time.Time) {
if now.Sub(l.lastGC) < l.companyWindow {
return
}
companyCut := now.Add(-l.companyWindow)
for id, ts := range l.companyHits {
kept := ts[:0]
for _, t := range ts {
if t.After(companyCut) {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(l.companyHits, id)
} else {
l.companyHits[id] = kept
}
}
platformCut := now.Add(-l.platformWindow)
keptP := l.platformHits[:0]
for _, t := range l.platformHits {
if t.After(platformCut) {
keptP = append(keptP, t)
}
}
l.platformHits = keptP
l.lastGC = now
}
// package-level limiter used by TryAutoReplyLLM until Service gains an injected field.
var defaultAIRateLimiter = NewAIRateLimiter(0, 0)
// SetAIRateLimiter replaces the package default (tests / wiring).
func SetAIRateLimiter(l *AIRateLimiter) {
if l == nil {
l = NewAIRateLimiter(0, 0)
}
defaultAIRateLimiter = l
}
// AIRateLimiterDefault returns the package limiter.
func AIRateLimiterDefault() *AIRateLimiter {
return defaultAIRateLimiter
}