fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit 21a844cb8a
1986 changed files with 744143 additions and 1731 deletions

View File

@@ -0,0 +1,340 @@
package heuristics
import (
"math"
"sync"
"time"
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
)
// SessionMetrics holds behavioral metrics for a session
type SessionMetrics struct {
SessionID string `json:"session_id"`
StartTime time.Time `json:"start_time"`
VisibleTime float64 `json:"visible_time"` // Seconds visible
LastVisibleTS time.Time `json:"last_visible_ts"` // Last visibility timestamp
MaxScrollPercent float64 `json:"max_scroll_percent"` // 0-1 scroll depth
ClickCount int `json:"click_count"`
MouseMoves int `json:"mouse_moves"`
KeyStrokes int `json:"key_strokes"`
TouchEvents int `json:"touch_events"`
// Advanced metrics
MouseVelocities []float64 `json:"mouse_velocities,omitempty"` // For variance calculation
ScrollVelocities []float64 `json:"scroll_velocities,omitempty"` // Scroll speed patterns
ClickIntervals []float64 `json:"click_intervals,omitempty"` // Time between clicks
// Computed score
LastScore float64 `json:"last_score"`
LastScoreTime time.Time `json:"last_score_time"`
}
// Scorer calculates human-likelihood scores based on behavioral heuristics
type Scorer struct {
config *config.Config
mu sync.RWMutex
sessions map[string]*SessionMetrics
}
// NewScorer creates a new heuristic scorer
func NewScorer(cfg *config.Config) *Scorer {
return &Scorer{
config: cfg,
sessions: make(map[string]*SessionMetrics),
}
}
// GetOrCreateSession retrieves or creates a session
func (s *Scorer) GetOrCreateSession(sessionID string) *SessionMetrics {
s.mu.Lock()
defer s.mu.Unlock()
if session, exists := s.sessions[sessionID]; exists {
return session
}
session := &SessionMetrics{
SessionID: sessionID,
StartTime: time.Now(),
LastVisibleTS: time.Now(),
}
s.sessions[sessionID] = session
return session
}
// UpdateMetrics updates session metrics from a tick
func (s *Scorer) UpdateMetrics(sessionID string, metrics *SessionMetrics) {
s.mu.Lock()
defer s.mu.Unlock()
if existing, exists := s.sessions[sessionID]; exists {
// Merge metrics
existing.VisibleTime = metrics.VisibleTime
existing.MaxScrollPercent = metrics.MaxScrollPercent
existing.ClickCount = metrics.ClickCount
existing.MouseMoves = metrics.MouseMoves
existing.KeyStrokes = metrics.KeyStrokes
existing.TouchEvents = metrics.TouchEvents
if len(metrics.MouseVelocities) > 0 {
existing.MouseVelocities = append(existing.MouseVelocities, metrics.MouseVelocities...)
}
if len(metrics.ScrollVelocities) > 0 {
existing.ScrollVelocities = append(existing.ScrollVelocities, metrics.ScrollVelocities...)
}
if len(metrics.ClickIntervals) > 0 {
existing.ClickIntervals = append(existing.ClickIntervals, metrics.ClickIntervals...)
}
} else {
s.sessions[sessionID] = metrics
}
}
// CalculateScore computes the human-likelihood score for a session
func (s *Scorer) CalculateScore(sessionID string) float64 {
s.mu.RLock()
session, exists := s.sessions[sessionID]
if !exists {
s.mu.RUnlock()
return 0.0
}
s.mu.RUnlock()
weights := s.config.Weights
// Calculate individual heuristic scores (0-1)
dwellScore := s.calculateDwellScore(session)
scrollScore := s.calculateScrollScore(session)
pointerScore := s.calculatePointerScore(session)
clickScore := s.calculateClickScore(session)
// Weighted sum
totalScore := dwellScore*weights.DwellRatio +
scrollScore*weights.ScrollScore +
pointerScore*weights.PointerVariance +
clickScore*weights.ClickRate
// Clamp to [0, 1]
if totalScore > 1.0 {
totalScore = 1.0
}
if totalScore < 0.0 {
totalScore = 0.0
}
// Update session with score
s.mu.Lock()
session.LastScore = totalScore
session.LastScoreTime = time.Now()
s.mu.Unlock()
return totalScore
}
// calculateDwellScore: visible time / total time ratio
func (s *Scorer) calculateDwellScore(session *SessionMetrics) float64 {
totalTime := time.Since(session.StartTime).Seconds()
if totalTime <= 0 {
return 0.0
}
// Calculate visible time including current period if visible
visibleTime := session.VisibleTime
ratio := visibleTime / totalTime
if ratio > 1.0 {
ratio = 1.0
}
// Apply sigmoid to reward longer dwell times
// A 30+ second dwell with high visibility is very human-like
return sigmoid(ratio, 0.5, 10)
}
// calculateScrollScore: scroll depth and natural patterns
func (s *Scorer) calculateScrollScore(session *SessionMetrics) float64 {
// Base score from scroll depth
baseScore := session.MaxScrollPercent
if baseScore > 1.0 {
baseScore = 1.0
}
// Bonus for natural scroll velocity patterns (humans have variable scroll speeds)
if len(session.ScrollVelocities) > 2 {
variance := calculateVariance(session.ScrollVelocities)
// Too uniform = bot, some variance = human
if variance > 0.01 && variance < 10.0 {
baseScore *= 1.2 // Boost for natural variance
}
}
if baseScore > 1.0 {
baseScore = 1.0
}
return baseScore
}
// calculatePointerScore: mouse movement patterns
func (s *Scorer) calculatePointerScore(session *SessionMetrics) float64 {
// Binary: has mouse activity at all
if session.MouseMoves == 0 && session.TouchEvents == 0 {
return 0.0
}
baseScore := 0.5 // Some activity
// Humans have variable mouse velocities
if len(session.MouseVelocities) > 5 {
variance := calculateVariance(session.MouseVelocities)
// Bots often have either very uniform or very erratic movement
if variance > 0.1 && variance < 100.0 {
baseScore = 0.9 // Natural variance pattern
} else if variance <= 0.1 {
baseScore = 0.3 // Too uniform - suspicious
} else {
baseScore = 0.4 // Too erratic - also suspicious
}
}
// Boost for touch events (mobile users)
if session.TouchEvents > 0 {
baseScore += 0.2
}
if baseScore > 1.0 {
baseScore = 1.0
}
return baseScore
}
// calculateClickScore: click patterns
func (s *Scorer) calculateClickScore(session *SessionMetrics) float64 {
if session.ClickCount == 0 {
return 0.0
}
totalTime := time.Since(session.StartTime).Seconds()
if totalTime <= 0 {
return 0.0
}
// Clicks per second
clickRate := float64(session.ClickCount) / totalTime
// Natural click rate is 0.1-2 clicks per second
// Too fast = bot, none = no interaction
var baseScore float64
if clickRate > 0.05 && clickRate < 3.0 {
baseScore = 0.8
} else if clickRate >= 3.0 {
baseScore = 0.2 // Suspiciously fast clicking
} else {
baseScore = 0.4
}
// Check for natural intervals between clicks
if len(session.ClickIntervals) > 2 {
variance := calculateVariance(session.ClickIntervals)
// Natural human timing has variance
if variance > 0.01 {
baseScore += 0.2
}
}
if baseScore > 1.0 {
baseScore = 1.0
}
return baseScore
}
// EvaluateRequest determines action based on score
func (s *Scorer) EvaluateRequest(sessionID string, path string, pathConfigs map[string]config.PathConfig) *EvaluationResult {
score := s.CalculateScore(sessionID)
// Get path-specific config or use defaults
minScore := s.config.Thresholds.ScoreChallenge
var stepUpMethod *string
if cfg, exists := pathConfigs[path]; exists {
minScore = cfg.MinScore
stepUpMethod = cfg.StepUpMethod
}
result := &EvaluationResult{
SessionID: sessionID,
Score: score,
MinScore: minScore,
Action: "allow",
}
if score >= s.config.Thresholds.ScorePass {
result.Action = "allow"
} else if score >= minScore {
result.Action = "allow" // In gray zone but above minimum
} else {
result.Action = "challenge"
if stepUpMethod != nil {
result.StepUpMethod = *stepUpMethod
} else {
result.StepUpMethod = s.config.StepUp.Primary
}
}
return result
}
// EvaluationResult contains the score evaluation outcome
type EvaluationResult struct {
SessionID string `json:"session_id"`
Score float64 `json:"score"`
MinScore float64 `json:"min_score"`
Action string `json:"action"` // "allow", "challenge", "block"
StepUpMethod string `json:"step_up_method,omitempty"`
}
// CleanupOldSessions removes sessions older than maxAge
func (s *Scorer) CleanupOldSessions(maxAge time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for id, session := range s.sessions {
if now.Sub(session.StartTime) > maxAge {
delete(s.sessions, id)
}
}
}
// Helper functions
func calculateVariance(values []float64) float64 {
if len(values) < 2 {
return 0.0
}
// Calculate mean
var sum float64
for _, v := range values {
sum += v
}
mean := sum / float64(len(values))
// Calculate variance
var variance float64
for _, v := range values {
diff := v - mean
variance += diff * diff
}
variance /= float64(len(values) - 1)
return variance
}
// sigmoid applies a sigmoid transformation for smoother score curves
func sigmoid(x, midpoint, steepness float64) float64 {
return 1.0 / (1.0 + math.Exp(-steepness*(x-midpoint)))
}

View File

@@ -0,0 +1,250 @@
package heuristics
import (
"testing"
"time"
"github.com/breakpilot/pca-platform/heuristic-service/internal/config"
)
func TestNewScorer(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
if scorer == nil {
t.Fatal("Expected non-nil scorer")
}
if scorer.config == nil {
t.Error("Expected config to be set")
}
if scorer.sessions == nil {
t.Error("Expected sessions map to be initialized")
}
}
func TestGetOrCreateSession(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
// First call should create session
session1 := scorer.GetOrCreateSession("test-session-1")
if session1 == nil {
t.Fatal("Expected non-nil session")
}
if session1.SessionID != "test-session-1" {
t.Errorf("Expected session ID 'test-session-1', got '%s'", session1.SessionID)
}
// Second call should return same session
session2 := scorer.GetOrCreateSession("test-session-1")
if session1 != session2 {
t.Error("Expected same session instance on second call")
}
// Different ID should create new session
session3 := scorer.GetOrCreateSession("test-session-2")
if session1 == session3 {
t.Error("Expected different session for different ID")
}
}
func TestCalculateScore_NewSession(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
// New session with no activity should have low score
scorer.GetOrCreateSession("test-new")
score := scorer.CalculateScore("test-new")
if score < 0 || score > 1 {
t.Errorf("Expected score between 0 and 1, got %f", score)
}
}
func TestCalculateScore_HighActivity(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
session := scorer.GetOrCreateSession("test-active")
session.StartTime = time.Now().Add(-30 * time.Second)
session.VisibleTime = 28.0 // High visibility
session.MaxScrollPercent = 0.8
session.ClickCount = 10
session.MouseMoves = 100
session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90}
session.ClickIntervals = []float64{1.5, 2.0, 1.2, 0.8}
score := scorer.CalculateScore("test-active")
// Active session should have higher score
if score < 0.5 {
t.Errorf("Expected score > 0.5 for active session, got %f", score)
}
}
func TestCalculateScore_BotLikeActivity(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
session := scorer.GetOrCreateSession("test-bot")
session.StartTime = time.Now().Add(-5 * time.Second)
session.VisibleTime = 1.0 // Very short
session.MaxScrollPercent = 0.0
session.ClickCount = 0
session.MouseMoves = 0
score := scorer.CalculateScore("test-bot")
// Bot-like session should have very low score
if score > 0.3 {
t.Errorf("Expected score < 0.3 for bot-like session, got %f", score)
}
}
func TestCalculateScore_UniformMouseMovement(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
session := scorer.GetOrCreateSession("test-uniform")
session.StartTime = time.Now().Add(-20 * time.Second)
session.VisibleTime = 18.0
session.MouseMoves = 50
// Very uniform velocities (suspicious)
session.MouseVelocities = []float64{100, 100, 100, 100, 100, 100, 100, 100}
score := scorer.CalculateScore("test-uniform")
// Uniform movement should result in lower pointer score
if score > 0.7 {
t.Errorf("Expected score < 0.7 for uniform mouse movement, got %f", score)
}
}
func TestEvaluateRequest(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
// High score session
session := scorer.GetOrCreateSession("test-evaluate")
session.StartTime = time.Now().Add(-60 * time.Second)
session.VisibleTime = 55.0
session.MaxScrollPercent = 0.9
session.ClickCount = 15
session.MouseMoves = 200
session.MouseVelocities = []float64{100, 150, 80, 200, 120, 90, 110}
result := scorer.EvaluateRequest("test-evaluate", "/default", nil)
if result.SessionID != "test-evaluate" {
t.Errorf("Expected session ID 'test-evaluate', got '%s'", result.SessionID)
}
if result.Action != "allow" && result.Score >= cfg.Thresholds.ScorePass {
t.Errorf("Expected 'allow' action for high score, got '%s'", result.Action)
}
}
func TestEvaluateRequest_Challenge(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
// Low score session
scorer.GetOrCreateSession("test-challenge")
result := scorer.EvaluateRequest("test-challenge", "/api", nil)
if result.Action != "challenge" {
t.Errorf("Expected 'challenge' action for new session, got '%s'", result.Action)
}
if result.StepUpMethod == "" {
t.Error("Expected step-up method to be set for challenge")
}
}
func TestCleanupOldSessions(t *testing.T) {
cfg := config.DefaultConfig()
scorer := NewScorer(cfg)
// Create some sessions
scorer.GetOrCreateSession("session-new")
oldSession := scorer.GetOrCreateSession("session-old")
oldSession.StartTime = time.Now().Add(-2 * time.Hour)
// Verify both exist
if len(scorer.sessions) != 2 {
t.Errorf("Expected 2 sessions, got %d", len(scorer.sessions))
}
// Cleanup with 1 hour max age
scorer.CleanupOldSessions(1 * time.Hour)
// Old session should be removed
if len(scorer.sessions) != 1 {
t.Errorf("Expected 1 session after cleanup, got %d", len(scorer.sessions))
}
if _, exists := scorer.sessions["session-old"]; exists {
t.Error("Expected old session to be cleaned up")
}
}
func TestCalculateVariance(t *testing.T) {
tests := []struct {
name string
values []float64
expected float64
}{
{
name: "empty",
values: []float64{},
expected: 0.0,
},
{
name: "single value",
values: []float64{5.0},
expected: 0.0,
},
{
name: "uniform values",
values: []float64{5.0, 5.0, 5.0, 5.0},
expected: 0.0,
},
{
name: "varied values",
values: []float64{1.0, 2.0, 3.0, 4.0, 5.0},
expected: 2.5, // Variance of [1,2,3,4,5]
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calculateVariance(tt.values)
if tt.expected == 0.0 && result != 0.0 {
t.Errorf("Expected 0 variance, got %f", result)
}
if tt.expected != 0.0 && (result < tt.expected-0.1 || result > tt.expected+0.1) {
t.Errorf("Expected variance ~%f, got %f", tt.expected, result)
}
})
}
}
func TestSigmoid(t *testing.T) {
// Test sigmoid at midpoint
result := sigmoid(0.5, 0.5, 10)
if result < 0.49 || result > 0.51 {
t.Errorf("Expected sigmoid(0.5, 0.5, 10) ~ 0.5, got %f", result)
}
// Test sigmoid well above midpoint
result = sigmoid(1.0, 0.5, 10)
if result < 0.9 {
t.Errorf("Expected sigmoid(1.0, 0.5, 10) > 0.9, got %f", result)
}
// Test sigmoid well below midpoint
result = sigmoid(0.0, 0.5, 10)
if result > 0.1 {
t.Errorf("Expected sigmoid(0.0, 0.5, 10) < 0.1, got %f", result)
}
}