This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/ai-compliance-sdk/internal/audit/exporter.go
Benjamin Admin 21a844cb8a 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>
2026-02-09 09:51:32 +01:00

445 lines
12 KiB
Go

package audit
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/google/uuid"
)
// ExportFormat represents the export format
type ExportFormat string
const (
FormatCSV ExportFormat = "csv"
FormatJSON ExportFormat = "json"
)
// Exporter exports audit data in various formats
type Exporter struct {
store *Store
}
// NewExporter creates a new exporter
func NewExporter(store *Store) *Exporter {
return &Exporter{store: store}
}
// ExportOptions defines export options
type ExportOptions struct {
TenantID uuid.UUID
NamespaceID *uuid.UUID
UserID *uuid.UUID
StartDate time.Time
EndDate time.Time
Format ExportFormat
IncludePII bool // If false, redact PII fields
}
// ExportLLMAudit exports LLM audit entries
func (e *Exporter) ExportLLMAudit(ctx context.Context, w io.Writer, opts *ExportOptions) error {
filter := &LLMAuditFilter{
TenantID: opts.TenantID,
NamespaceID: opts.NamespaceID,
UserID: opts.UserID,
StartDate: &opts.StartDate,
EndDate: &opts.EndDate,
Limit: 0, // No limit for export
}
entries, _, err := e.store.QueryLLMAuditEntries(ctx, filter)
if err != nil {
return fmt.Errorf("failed to query audit entries: %w", err)
}
switch opts.Format {
case FormatCSV:
return e.exportLLMAuditCSV(w, entries, opts.IncludePII)
case FormatJSON:
return e.exportLLMAuditJSON(w, entries, opts.IncludePII)
default:
return fmt.Errorf("unsupported format: %s", opts.Format)
}
}
// ExportGeneralAudit exports general audit entries
func (e *Exporter) ExportGeneralAudit(ctx context.Context, w io.Writer, opts *ExportOptions) error {
filter := &GeneralAuditFilter{
TenantID: opts.TenantID,
NamespaceID: opts.NamespaceID,
UserID: opts.UserID,
StartDate: &opts.StartDate,
EndDate: &opts.EndDate,
Limit: 0,
}
entries, _, err := e.store.QueryGeneralAuditEntries(ctx, filter)
if err != nil {
return fmt.Errorf("failed to query audit entries: %w", err)
}
switch opts.Format {
case FormatCSV:
return e.exportGeneralAuditCSV(w, entries, opts.IncludePII)
case FormatJSON:
return e.exportGeneralAuditJSON(w, entries, opts.IncludePII)
default:
return fmt.Errorf("unsupported format: %s", opts.Format)
}
}
func (e *Exporter) exportLLMAuditCSV(w io.Writer, entries []*LLMAuditEntry, includePII bool) error {
writer := csv.NewWriter(w)
defer writer.Flush()
// Write header
header := []string{
"ID", "Tenant ID", "Namespace ID", "User ID", "Session ID",
"Operation", "Model", "Provider", "Prompt Hash", "Prompt Length", "Response Length",
"Tokens Used", "Duration (ms)", "PII Detected", "PII Types", "PII Redacted",
"Policy ID", "Policy Violations", "Data Categories", "Error", "Created At",
}
if err := writer.Write(header); err != nil {
return err
}
// Write entries
for _, entry := range entries {
namespaceID := ""
if entry.NamespaceID != nil {
namespaceID = entry.NamespaceID.String()
}
policyID := ""
if entry.PolicyID != nil {
policyID = entry.PolicyID.String()
}
userID := entry.UserID.String()
sessionID := entry.SessionID
if !includePII {
userID = "[REDACTED]"
sessionID = "[REDACTED]"
}
row := []string{
entry.ID.String(),
entry.TenantID.String(),
namespaceID,
userID,
sessionID,
entry.Operation,
entry.ModelUsed,
entry.Provider,
entry.PromptHash,
fmt.Sprintf("%d", entry.PromptLength),
fmt.Sprintf("%d", entry.ResponseLength),
fmt.Sprintf("%d", entry.TokensUsed),
fmt.Sprintf("%d", entry.DurationMS),
fmt.Sprintf("%t", entry.PIIDetected),
strings.Join(entry.PIITypesDetected, ";"),
fmt.Sprintf("%t", entry.PIIRedacted),
policyID,
strings.Join(entry.PolicyViolations, ";"),
strings.Join(entry.DataCategoriesAccessed, ";"),
entry.ErrorMessage,
entry.CreatedAt.Format(time.RFC3339),
}
if err := writer.Write(row); err != nil {
return err
}
}
return nil
}
func (e *Exporter) exportLLMAuditJSON(w io.Writer, entries []*LLMAuditEntry, includePII bool) error {
// Redact PII if needed
if !includePII {
for _, entry := range entries {
entry.UserID = uuid.Nil
entry.SessionID = "[REDACTED]"
entry.RequestMetadata = nil
}
}
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(map[string]any{
"type": "llm_audit_export",
"exported_at": time.Now().UTC().Format(time.RFC3339),
"count": len(entries),
"entries": entries,
})
}
func (e *Exporter) exportGeneralAuditCSV(w io.Writer, entries []*GeneralAuditEntry, includePII bool) error {
writer := csv.NewWriter(w)
defer writer.Flush()
// Write header
header := []string{
"ID", "Tenant ID", "Namespace ID", "User ID",
"Action", "Resource Type", "Resource ID",
"IP Address", "User Agent", "Reason", "Created At",
}
if err := writer.Write(header); err != nil {
return err
}
// Write entries
for _, entry := range entries {
namespaceID := ""
if entry.NamespaceID != nil {
namespaceID = entry.NamespaceID.String()
}
resourceID := ""
if entry.ResourceID != nil {
resourceID = entry.ResourceID.String()
}
userID := entry.UserID.String()
ipAddress := entry.IPAddress
userAgent := entry.UserAgent
if !includePII {
userID = "[REDACTED]"
ipAddress = "[REDACTED]"
userAgent = "[REDACTED]"
}
row := []string{
entry.ID.String(),
entry.TenantID.String(),
namespaceID,
userID,
entry.Action,
entry.ResourceType,
resourceID,
ipAddress,
userAgent,
entry.Reason,
entry.CreatedAt.Format(time.RFC3339),
}
if err := writer.Write(row); err != nil {
return err
}
}
return nil
}
func (e *Exporter) exportGeneralAuditJSON(w io.Writer, entries []*GeneralAuditEntry, includePII bool) error {
// Redact PII if needed
if !includePII {
for _, entry := range entries {
entry.UserID = uuid.Nil
entry.IPAddress = "[REDACTED]"
entry.UserAgent = "[REDACTED]"
}
}
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(map[string]any{
"type": "general_audit_export",
"exported_at": time.Now().UTC().Format(time.RFC3339),
"count": len(entries),
"entries": entries,
})
}
// ExportUsageStats exports LLM usage statistics
func (e *Exporter) ExportUsageStats(ctx context.Context, w io.Writer, tenantID uuid.UUID, startDate, endDate time.Time, format ExportFormat) error {
stats, err := e.store.GetLLMUsageStats(ctx, tenantID, startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get usage stats: %w", err)
}
report := map[string]any{
"type": "llm_usage_report",
"tenant_id": tenantID.String(),
"period_start": startDate.Format(time.RFC3339),
"period_end": endDate.Format(time.RFC3339),
"generated_at": time.Now().UTC().Format(time.RFC3339),
"total_requests": stats.TotalRequests,
"total_tokens": stats.TotalTokens,
"total_duration_ms": stats.TotalDurationMS,
"requests_with_pii": stats.RequestsWithPII,
"policy_violations": stats.PolicyViolations,
"models_used": stats.ModelsUsed,
}
switch format {
case FormatJSON:
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(report)
case FormatCSV:
writer := csv.NewWriter(w)
defer writer.Flush()
// Write summary as CSV
if err := writer.Write([]string{"Metric", "Value"}); err != nil {
return err
}
rows := [][]string{
{"Total Requests", fmt.Sprintf("%d", stats.TotalRequests)},
{"Total Tokens", fmt.Sprintf("%d", stats.TotalTokens)},
{"Total Duration (ms)", fmt.Sprintf("%d", stats.TotalDurationMS)},
{"Requests with PII", fmt.Sprintf("%d", stats.RequestsWithPII)},
{"Policy Violations", fmt.Sprintf("%d", stats.PolicyViolations)},
}
for model, count := range stats.ModelsUsed {
rows = append(rows, []string{fmt.Sprintf("Model: %s", model), fmt.Sprintf("%d", count)})
}
for _, row := range rows {
if err := writer.Write(row); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("unsupported format: %s", format)
}
}
// ComplianceReport generates a compliance report
type ComplianceReport struct {
TenantID uuid.UUID `json:"tenant_id"`
PeriodStart time.Time `json:"period_start"`
PeriodEnd time.Time `json:"period_end"`
GeneratedAt time.Time `json:"generated_at"`
TotalLLMRequests int `json:"total_llm_requests"`
TotalTokensUsed int `json:"total_tokens_used"`
PIIIncidents int `json:"pii_incidents"`
PIIRedactionRate float64 `json:"pii_redaction_rate"`
PolicyViolations int `json:"policy_violations"`
ModelsUsed map[string]int `json:"models_used"`
TopUsers []UserActivity `json:"top_users,omitempty"`
NamespaceBreakdown []NamespaceStats `json:"namespace_breakdown,omitempty"`
}
// UserActivity represents user activity in the report
type UserActivity struct {
UserID uuid.UUID `json:"user_id"`
RequestCount int `json:"request_count"`
TokensUsed int `json:"tokens_used"`
PIIIncidents int `json:"pii_incidents"`
}
// NamespaceStats represents namespace statistics
type NamespaceStats struct {
NamespaceID uuid.UUID `json:"namespace_id"`
NamespaceName string `json:"namespace_name,omitempty"`
RequestCount int `json:"request_count"`
TokensUsed int `json:"tokens_used"`
}
// GenerateComplianceReport generates a detailed compliance report
func (e *Exporter) GenerateComplianceReport(ctx context.Context, tenantID uuid.UUID, startDate, endDate time.Time) (*ComplianceReport, error) {
// Get basic stats
stats, err := e.store.GetLLMUsageStats(ctx, tenantID, startDate, endDate)
if err != nil {
return nil, err
}
// Calculate PII redaction rate
var redactionRate float64
if stats.RequestsWithPII > 0 {
// Query entries with PII to check redaction
filter := &LLMAuditFilter{
TenantID: tenantID,
PIIDetected: boolPtr(true),
StartDate: &startDate,
EndDate: &endDate,
}
entries, _, err := e.store.QueryLLMAuditEntries(ctx, filter)
if err == nil {
redactedCount := 0
for _, e := range entries {
if e.PIIRedacted {
redactedCount++
}
}
redactionRate = float64(redactedCount) / float64(len(entries)) * 100
}
}
report := &ComplianceReport{
TenantID: tenantID,
PeriodStart: startDate,
PeriodEnd: endDate,
GeneratedAt: time.Now().UTC(),
TotalLLMRequests: stats.TotalRequests,
TotalTokensUsed: stats.TotalTokens,
PIIIncidents: stats.RequestsWithPII,
PIIRedactionRate: redactionRate,
PolicyViolations: stats.PolicyViolations,
ModelsUsed: stats.ModelsUsed,
}
return report, nil
}
// ExportComplianceReport exports a compliance report
func (e *Exporter) ExportComplianceReport(ctx context.Context, w io.Writer, tenantID uuid.UUID, startDate, endDate time.Time, format ExportFormat) error {
report, err := e.GenerateComplianceReport(ctx, tenantID, startDate, endDate)
if err != nil {
return err
}
switch format {
case FormatJSON:
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
return encoder.Encode(report)
case FormatCSV:
writer := csv.NewWriter(w)
defer writer.Flush()
// Write header
if err := writer.Write([]string{"Metric", "Value"}); err != nil {
return err
}
rows := [][]string{
{"Report Type", "AI Compliance Report"},
{"Tenant ID", report.TenantID.String()},
{"Period Start", report.PeriodStart.Format(time.RFC3339)},
{"Period End", report.PeriodEnd.Format(time.RFC3339)},
{"Generated At", report.GeneratedAt.Format(time.RFC3339)},
{"Total LLM Requests", fmt.Sprintf("%d", report.TotalLLMRequests)},
{"Total Tokens Used", fmt.Sprintf("%d", report.TotalTokensUsed)},
{"PII Incidents", fmt.Sprintf("%d", report.PIIIncidents)},
{"PII Redaction Rate", fmt.Sprintf("%.2f%%", report.PIIRedactionRate)},
{"Policy Violations", fmt.Sprintf("%d", report.PolicyViolations)},
}
for _, row := range rows {
if err := writer.Write(row); err != nil {
return err
}
}
return nil
default:
return fmt.Errorf("unsupported format: %s", format)
}
}
func boolPtr(b bool) *bool {
return &b
}