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:
540
school-service/internal/services/ai_service_test.go
Normal file
540
school-service/internal/services/ai_service_test.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAIService_ValidateVariationType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
variationType string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid - rewrite",
|
||||
variationType: "rewrite",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid - alternative",
|
||||
variationType: "alternative",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid - similar",
|
||||
variationType: "similar",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
variationType: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty type",
|
||||
variationType: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateVariationType(tt.variationType)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIService_BuildExamVariantPrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
originalContent string
|
||||
variationType string
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "rewrite prompt",
|
||||
originalContent: "Berechne 5 + 3",
|
||||
variationType: "rewrite",
|
||||
expectedContains: []string{
|
||||
"Nachschreiber",
|
||||
"gleichen Schwierigkeitsgrad",
|
||||
"Berechne 5 + 3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alternative prompt",
|
||||
originalContent: "Erkläre die Photosynthese",
|
||||
variationType: "alternative",
|
||||
expectedContains: []string{
|
||||
"alternative",
|
||||
"gleichen Lernziele",
|
||||
"Erkläre die Photosynthese",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "similar prompt",
|
||||
originalContent: "Löse die Gleichung x + 5 = 10",
|
||||
variationType: "similar",
|
||||
expectedContains: []string{
|
||||
"ähnliche",
|
||||
"Übung",
|
||||
"Löse die Gleichung x + 5 = 10",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prompt := buildExamVariantPrompt(tt.originalContent, tt.variationType)
|
||||
for _, expected := range tt.expectedContains {
|
||||
assert.Contains(t, prompt, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIService_BuildFeedbackPrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
studentName string
|
||||
subject string
|
||||
grade float64
|
||||
expectedContains []string
|
||||
}{
|
||||
{
|
||||
name: "good grade feedback",
|
||||
studentName: "Max Mustermann",
|
||||
subject: "Mathematik",
|
||||
grade: 1.5,
|
||||
expectedContains: []string{
|
||||
"Max Mustermann",
|
||||
"Mathematik",
|
||||
"1.5",
|
||||
"Zeugnis",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "improvement needed feedback",
|
||||
studentName: "Anna Schmidt",
|
||||
subject: "Deutsch",
|
||||
grade: 4.0,
|
||||
expectedContains: []string{
|
||||
"Anna Schmidt",
|
||||
"Deutsch",
|
||||
"4.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prompt := buildFeedbackPrompt(tt.studentName, tt.subject, tt.grade)
|
||||
for _, expected := range tt.expectedContains {
|
||||
assert.Contains(t, prompt, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIService_ValidateContentLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
maxLength int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid content length",
|
||||
content: "Short content",
|
||||
maxLength: 1000,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty content",
|
||||
content: "",
|
||||
maxLength: 1000,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "content too long",
|
||||
content: generateLongString(10001),
|
||||
maxLength: 10000,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "exactly at max length",
|
||||
content: generateLongString(1000),
|
||||
maxLength: 1000,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateContentLength(tt.content, tt.maxLength)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIService_ParseLLMResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
expectedResult string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid response",
|
||||
response: `{"content": "Generated exam content here"}`,
|
||||
expectedResult: "Generated exam content here",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty response",
|
||||
response: "",
|
||||
expectedResult: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "plain text response",
|
||||
response: "This is a plain text response",
|
||||
expectedResult: "This is a plain text response",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseLLMResponse(tt.response)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedResult, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIService_EstimateTokenCount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
expectedTokens int
|
||||
}{
|
||||
{
|
||||
name: "short text",
|
||||
text: "Hello world",
|
||||
expectedTokens: 3, // Rough estimate: words + overhead
|
||||
},
|
||||
{
|
||||
name: "empty text",
|
||||
text: "",
|
||||
expectedTokens: 0,
|
||||
},
|
||||
{
|
||||
name: "longer text",
|
||||
text: "This is a longer text with multiple words that should result in more tokens",
|
||||
expectedTokens: 15, // Rough estimate
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokens := estimateTokenCount(tt.text)
|
||||
// Allow some variance in token estimation
|
||||
assert.InDelta(t, tt.expectedTokens, tokens, float64(tt.expectedTokens)*0.5+2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIService_SanitizePrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "clean input",
|
||||
input: "Calculate the sum of 5 and 3",
|
||||
expected: "Calculate the sum of 5 and 3",
|
||||
},
|
||||
{
|
||||
name: "input with newlines",
|
||||
input: "Line 1\nLine 2\nLine 3",
|
||||
expected: "Line 1\nLine 2\nLine 3",
|
||||
},
|
||||
{
|
||||
name: "input with excessive whitespace",
|
||||
input: "Word with spaces",
|
||||
expected: "Word with spaces",
|
||||
},
|
||||
{
|
||||
name: "input with leading/trailing whitespace",
|
||||
input: " trimmed content ",
|
||||
expected: "trimmed content",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := sanitizePrompt(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIService_DetermineModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskType string
|
||||
expectedModel string
|
||||
}{
|
||||
{
|
||||
name: "exam generation - complex task",
|
||||
taskType: "exam_generation",
|
||||
expectedModel: "gpt-4",
|
||||
},
|
||||
{
|
||||
name: "feedback generation - simpler task",
|
||||
taskType: "feedback",
|
||||
expectedModel: "gpt-3.5-turbo",
|
||||
},
|
||||
{
|
||||
name: "improvement - complex task",
|
||||
taskType: "improvement",
|
||||
expectedModel: "gpt-4",
|
||||
},
|
||||
{
|
||||
name: "unknown task - default",
|
||||
taskType: "unknown",
|
||||
expectedModel: "gpt-3.5-turbo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
model := determineModel(tt.taskType)
|
||||
assert.Equal(t, tt.expectedModel, model)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func validateVariationType(varType string) error {
|
||||
validTypes := map[string]bool{
|
||||
"rewrite": true,
|
||||
"alternative": true,
|
||||
"similar": true,
|
||||
}
|
||||
if !validTypes[varType] {
|
||||
return assert.AnError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildExamVariantPrompt(originalContent, variationType string) string {
|
||||
var instruction string
|
||||
switch variationType {
|
||||
case "rewrite":
|
||||
instruction = "Erstelle eine Nachschreiber-Version mit dem gleichen Schwierigkeitsgrad."
|
||||
case "alternative":
|
||||
instruction = "Erstelle eine alternative Version mit den gleichen Lernzielen."
|
||||
case "similar":
|
||||
instruction = "Erstelle ähnliche Aufgaben für Übung."
|
||||
}
|
||||
|
||||
return "Du bist ein erfahrener Lehrer.\n\n" +
|
||||
instruction + "\n\n" +
|
||||
"Original:\n" + originalContent
|
||||
}
|
||||
|
||||
func buildFeedbackPrompt(studentName, subject string, grade float64) string {
|
||||
gradeStr := ""
|
||||
if grade < 10 {
|
||||
gradeStr = "0" + string(rune('0'+int(grade)))
|
||||
} else {
|
||||
gradeStr = string(rune('0'+int(grade/10))) + string(rune('0'+int(grade)%10))
|
||||
}
|
||||
// Simplified grade formatting
|
||||
gradeStr = formatGrade(grade)
|
||||
|
||||
return "Erstelle einen Zeugnis-Kommentar für " + studentName + " im Fach " + subject + " mit Note " + gradeStr + "."
|
||||
}
|
||||
|
||||
func formatGrade(grade float64) string {
|
||||
whole := int(grade)
|
||||
frac := int((grade - float64(whole)) * 10)
|
||||
return string(rune('0'+whole)) + "." + string(rune('0'+frac))
|
||||
}
|
||||
|
||||
func validateContentLength(content string, maxLength int) error {
|
||||
if content == "" {
|
||||
return assert.AnError
|
||||
}
|
||||
if len(content) > maxLength {
|
||||
return assert.AnError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateLongString(length int) string {
|
||||
result := ""
|
||||
for i := 0; i < length; i++ {
|
||||
result += "a"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseLLMResponse(response string) (string, error) {
|
||||
if response == "" {
|
||||
return "", assert.AnError
|
||||
}
|
||||
|
||||
// Check if it's JSON
|
||||
if len(response) > 0 && response[0] == '{' {
|
||||
// Simple JSON extraction - look for "content": "..."
|
||||
start := findString(response, `"content": "`)
|
||||
if start >= 0 {
|
||||
start += len(`"content": "`)
|
||||
end := findString(response[start:], `"`)
|
||||
if end >= 0 {
|
||||
return response[start : start+end], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return as-is for plain text
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func findString(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func estimateTokenCount(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
// Rough estimation: ~4 characters per token on average
|
||||
// Plus some overhead for special tokens
|
||||
return len(text)/4 + 1
|
||||
}
|
||||
|
||||
func sanitizePrompt(input string) string {
|
||||
// Trim leading/trailing whitespace
|
||||
result := trimSpace(input)
|
||||
|
||||
// Collapse multiple spaces into one
|
||||
result = collapseSpaces(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start := 0
|
||||
end := len(s)
|
||||
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
||||
end--
|
||||
}
|
||||
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func collapseSpaces(s string) string {
|
||||
result := ""
|
||||
lastWasSpace := false
|
||||
|
||||
for _, c := range s {
|
||||
if c == ' ' || c == '\t' {
|
||||
if !lastWasSpace {
|
||||
result += " "
|
||||
lastWasSpace = true
|
||||
}
|
||||
} else {
|
||||
result += string(c)
|
||||
lastWasSpace = false
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func determineModel(taskType string) string {
|
||||
complexTasks := map[string]bool{
|
||||
"exam_generation": true,
|
||||
"improvement": true,
|
||||
}
|
||||
|
||||
if complexTasks[taskType] {
|
||||
return "gpt-4"
|
||||
}
|
||||
return "gpt-3.5-turbo"
|
||||
}
|
||||
|
||||
func TestAIService_RetryLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
maxRetries int
|
||||
failuresCount int
|
||||
shouldSucceed bool
|
||||
}{
|
||||
{
|
||||
name: "succeeds first try",
|
||||
maxRetries: 3,
|
||||
failuresCount: 0,
|
||||
shouldSucceed: true,
|
||||
},
|
||||
{
|
||||
name: "succeeds after retries",
|
||||
maxRetries: 3,
|
||||
failuresCount: 2,
|
||||
shouldSucceed: true,
|
||||
},
|
||||
{
|
||||
name: "fails after max retries",
|
||||
maxRetries: 3,
|
||||
failuresCount: 4,
|
||||
shouldSucceed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
success := simulateRetryLogic(tt.maxRetries, tt.failuresCount)
|
||||
assert.Equal(t, tt.shouldSucceed, success)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func simulateRetryLogic(maxRetries, failuresCount int) bool {
|
||||
attempts := 0
|
||||
for attempts <= maxRetries {
|
||||
if attempts >= failuresCount {
|
||||
return true // Success
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
return false // All retries failed
|
||||
}
|
||||
Reference in New Issue
Block a user