diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_test.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_test.go new file mode 100644 index 0000000..153971b --- /dev/null +++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_test.go @@ -0,0 +1,479 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// ============================================================================ +// Helper: create a gin test context with optional JSON body, headers, params +// ============================================================================ + +func newTestContext(method, path string, body interface{}, headers map[string]string, params gin.Params) (*httptest.ResponseRecorder, *gin.Context) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + var reqBody *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + reqBody = bytes.NewReader(b) + } else { + reqBody = bytes.NewReader(nil) + } + + c.Request, _ = http.NewRequest(method, path, reqBody) + c.Request.Header.Set("Content-Type", "application/json") + for k, v := range headers { + c.Request.Header.Set(k, v) + } + if params != nil { + c.Params = params + } + + return w, c +} + +func parseResponse(w *httptest.ResponseRecorder) map[string]interface{} { + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + return resp +} + +// ============================================================================ +// InitFromProfile Tests +// ============================================================================ + +func TestInitFromProfile_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("POST", "/projects/not-a-uuid/init-from-profile", nil, nil, gin.Params{ + {Key: "id", Value: "not-a-uuid"}, + }) + + handler.InitFromProfile(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } + + resp := parseResponse(w) + if resp["error"] == nil { + t.Error("Expected error message in response") + } +} + +func TestInitFromProfile_EmptyProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("POST", "/projects//init-from-profile", nil, nil, gin.Params{ + {Key: "id", Value: ""}, + }) + + handler.InitFromProfile(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestInitFromProfile_RequestBinding(t *testing.T) { + // Verify the InitFromProfileRequest properly binds company_profile and compliance_scope + body := `{ + "company_profile": {"company_name": "TestCorp GmbH", "contact_email": "info@testcorp.de"}, + "compliance_scope": {"machine_name": "Robot XY", "has_ai": true, "applicable_regulations": ["machinery_regulation"]} + }` + + var parsed struct { + CompanyProfile json.RawMessage `json:"company_profile"` + ComplianceScope json.RawMessage `json:"compliance_scope"` + } + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("Failed to parse request body: %v", err) + } + + if parsed.CompanyProfile == nil { + t.Error("company_profile should not be nil") + } + if parsed.ComplianceScope == nil { + t.Error("compliance_scope should not be nil") + } + + // Verify scope parsing + var scope struct { + MachineName string `json:"machine_name"` + HasAI bool `json:"has_ai"` + ApplicableRegulations []string `json:"applicable_regulations"` + } + if err := json.Unmarshal(parsed.ComplianceScope, &scope); err != nil { + t.Fatalf("Failed to parse compliance_scope: %v", err) + } + if scope.MachineName != "Robot XY" { + t.Errorf("Expected machine_name 'Robot XY', got %q", scope.MachineName) + } + if !scope.HasAI { + t.Error("Expected has_ai to be true") + } + if len(scope.ApplicableRegulations) != 1 || scope.ApplicableRegulations[0] != "machinery_regulation" { + t.Errorf("Unexpected applicable_regulations: %v", scope.ApplicableRegulations) + } + + // Verify profile parsing + var profile struct { + CompanyName string `json:"company_name"` + ContactEmail string `json:"contact_email"` + } + if err := json.Unmarshal(parsed.CompanyProfile, &profile); err != nil { + t.Fatalf("Failed to parse company_profile: %v", err) + } + if profile.CompanyName != "TestCorp GmbH" { + t.Errorf("Expected company_name 'TestCorp GmbH', got %q", profile.CompanyName) + } + if profile.ContactEmail != "info@testcorp.de" { + t.Errorf("Expected contact_email 'info@testcorp.de', got %q", profile.ContactEmail) + } +} + +// ============================================================================ +// GenerateSingleSection Tests +// ============================================================================ + +func TestGenerateSingleSection_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("POST", "/projects/invalid/tech-file/risk_assessment_report/generate", nil, nil, gin.Params{ + {Key: "id", Value: "invalid"}, + {Key: "section", Value: "risk_assessment_report"}, + }) + + handler.GenerateSingleSection(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } + + resp := parseResponse(w) + errMsg, _ := resp["error"].(string) + if errMsg != "invalid project ID" { + t.Errorf("Expected 'invalid project ID' error, got %q", errMsg) + } +} + +func TestGenerateSingleSection_EmptySectionType_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("POST", "/projects/00000000-0000-0000-0000-000000000001/tech-file//generate", nil, nil, gin.Params{ + {Key: "id", Value: "00000000-0000-0000-0000-000000000001"}, + {Key: "section", Value: ""}, + }) + + handler.GenerateSingleSection(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } + + resp := parseResponse(w) + errMsg, _ := resp["error"].(string) + if errMsg != "section type required" { + t.Errorf("Expected 'section type required' error, got %q", errMsg) + } +} + +func TestGenerateSingleSection_SectionTitleMapping(t *testing.T) { + // Verify the section title map covers all 19 section types + sectionTitles := map[string]string{ + "general_description": "General Description of the Machinery", + "risk_assessment_report": "Risk Assessment Report", + "hazard_log_combined": "Combined Hazard Log", + "essential_requirements": "Essential Health and Safety Requirements", + "design_specifications": "Design Specifications and Drawings", + "test_reports": "Test Reports and Verification Results", + "standards_applied": "Applied Harmonised Standards", + "declaration_of_conformity": "EU Declaration of Conformity", + "component_list": "Component List", + "classification_report": "Regulatory Classification Report", + "mitigation_report": "Mitigation Measures Report", + "verification_report": "Verification Report", + "evidence_index": "Evidence Index", + "instructions_for_use": "Instructions for Use", + "monitoring_plan": "Post-Market Monitoring Plan", + "ai_intended_purpose": "AI System Intended Purpose", + "ai_model_description": "AI Model Description and Training Data", + "ai_risk_management": "AI Risk Management System", + "ai_human_oversight": "AI Human Oversight Measures", + } + + if len(sectionTitles) != 19 { + t.Errorf("Expected 19 section types, got %d", len(sectionTitles)) + } + + for key, title := range sectionTitles { + if key == "" { + t.Error("Section key must not be empty") + } + if title == "" { + t.Errorf("Section title for %q must not be empty", key) + } + } +} + +// ============================================================================ +// ExportTechFile Tests +// ============================================================================ + +func TestExportTechFile_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("GET", "/projects/invalid/tech-file/export", nil, nil, gin.Params{ + {Key: "id", Value: "invalid"}, + }) + + handler.ExportTechFile(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestExportTechFile_FormatQueryParam(t *testing.T) { + // Verify that DefaultQuery correctly parses format parameter + tests := []struct { + name string + queryString string + expectedQuery string + }{ + {"default json", "", "json"}, + {"explicit pdf", "format=pdf", "pdf"}, + {"explicit xlsx", "format=xlsx", "xlsx"}, + {"explicit docx", "format=docx", "docx"}, + {"explicit md", "format=md", "md"}, + {"explicit json", "format=json", "json"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + url := "/projects/test/tech-file/export" + if tt.queryString != "" { + url += "?" + tt.queryString + } + c.Request, _ = http.NewRequest("GET", url, nil) + + result := c.DefaultQuery("format", "json") + if result != tt.expectedQuery { + t.Errorf("Expected format %q, got %q", tt.expectedQuery, result) + } + }) + } +} + +func TestExportTechFile_ContentDispositionHeaders(t *testing.T) { + // Verify the header format for different export types + tests := []struct { + format string + contentType string + }{ + {"pdf", "application/pdf"}, + {"xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + {"docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + {"md", "text/markdown"}, + } + + for _, tt := range tests { + t.Run(tt.format, func(t *testing.T) { + if tt.contentType == "" { + t.Errorf("Content-Type for format %q must not be empty", tt.format) + } + }) + } +} + +// ============================================================================ +// CheckCompleteness Tests +// ============================================================================ + +func TestCheckCompleteness_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("POST", "/projects/invalid/completeness-check", nil, nil, gin.Params{ + {Key: "id", Value: "not-valid-uuid"}, + }) + + handler.CheckCompleteness(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +// ============================================================================ +// getTenantID Tests +// ============================================================================ + +func TestGetTenantID_MissingHeader_ReturnsError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("GET", "/test", nil) + + _, err := getTenantID(c) + if err == nil { + t.Error("Expected error for missing X-Tenant-Id header") + } +} + +func TestGetTenantID_InvalidUUID_ReturnsError(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("GET", "/test", nil) + c.Request.Header.Set("X-Tenant-Id", "not-a-uuid") + + _, err := getTenantID(c) + if err == nil { + t.Error("Expected error for invalid UUID in X-Tenant-Id header") + } +} + +func TestGetTenantID_ValidUUID_ReturnsUUID(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("GET", "/test", nil) + c.Request.Header.Set("X-Tenant-Id", "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e") + + tid, err := getTenantID(c) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if tid.String() != "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" { + t.Errorf("Expected tenant ID '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e', got %q", tid.String()) + } +} + +// ============================================================================ +// CreateProject / ListProjects — Input Validation +// ============================================================================ + +func TestCreateProject_MissingTenantID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("POST", "/projects", map[string]string{"machine_name": "Test"}, nil, nil) + + handler.CreateProject(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestListProjects_MissingTenantID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("GET", "/projects", nil, nil, nil) + + handler.ListProjects(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +// ============================================================================ +// Component Handlers — Input Validation +// ============================================================================ + +func TestCreateComponent_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("POST", "/projects/invalid/components", map[string]string{"name": "Test"}, nil, gin.Params{ + {Key: "id", Value: "invalid"}, + }) + + handler.CreateComponent(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestListComponents_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("GET", "/projects/invalid/components", nil, nil, gin.Params{ + {Key: "id", Value: "invalid"}, + }) + + handler.ListComponents(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestUpdateComponent_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("PUT", "/projects/invalid/components/abc", map[string]string{"name": "New"}, nil, gin.Params{ + {Key: "id", Value: "invalid"}, + {Key: "cid", Value: "00000000-0000-0000-0000-000000000001"}, + }) + + handler.UpdateComponent(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestUpdateComponent_InvalidComponentID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("PUT", "/projects/00000000-0000-0000-0000-000000000001/components/invalid", nil, nil, gin.Params{ + {Key: "id", Value: "00000000-0000-0000-0000-000000000001"}, + {Key: "cid", Value: "invalid"}, + }) + + handler.UpdateComponent(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestDeleteComponent_InvalidProjectID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("DELETE", "/projects/invalid/components/abc", nil, nil, gin.Params{ + {Key: "id", Value: "invalid"}, + {Key: "cid", Value: "00000000-0000-0000-0000-000000000001"}, + }) + + handler.DeleteComponent(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} + +func TestDeleteComponent_InvalidComponentID_Returns400(t *testing.T) { + handler := &IACEHandler{} + + w, c := newTestContext("DELETE", "/projects/00000000-0000-0000-0000-000000000001/components/invalid", nil, nil, gin.Params{ + {Key: "id", Value: "00000000-0000-0000-0000-000000000001"}, + {Key: "cid", Value: "invalid"}, + }) + + handler.DeleteComponent(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", w.Code) + } +} diff --git a/ai-compliance-sdk/internal/iace/document_export_test.go b/ai-compliance-sdk/internal/iace/document_export_test.go index 612991e..5b52734 100644 --- a/ai-compliance-sdk/internal/iace/document_export_test.go +++ b/ai-compliance-sdk/internal/iace/document_export_test.go @@ -2,6 +2,7 @@ package iace import ( "bytes" + "encoding/json" "strings" "testing" @@ -254,6 +255,154 @@ func TestExportDOCX_EmptyProject(t *testing.T) { // Helper Function Tests // ============================================================================ +// ============================================================================ +// JSON Export Tests +// ============================================================================ + +func TestExportJSON_ValidOutput(t *testing.T) { + exporter := NewDocumentExporter() + project, sections, hazards, assessments, mitigations, classifications := createTestExportData() + + data, err := exporter.ExportJSON(project, sections, hazards, assessments, mitigations, classifications) + if err != nil { + t.Fatalf("ExportJSON returned error: %v", err) + } + + if len(data) == 0 { + t.Fatal("ExportJSON returned empty bytes") + } + + // Must be valid JSON + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("ExportJSON output is not valid JSON: %v", err) + } + + // Check required top-level keys + requiredKeys := []string{"project", "sections", "hazards", "assessments", "mitigations", "classifications", "exported_at", "format_version"} + for _, key := range requiredKeys { + if _, ok := parsed[key]; !ok { + t.Errorf("ExportJSON output missing key %q", key) + } + } + + // format_version should be "1.0" + if fv, _ := parsed["format_version"].(string); fv != "1.0" { + t.Errorf("Expected format_version '1.0', got %q", fv) + } +} + +func TestExportJSON_EmptyProject(t *testing.T) { + exporter := NewDocumentExporter() + project, sections, hazards, assessments, mitigations, classifications := createEmptyExportData() + + data, err := exporter.ExportJSON(project, sections, hazards, assessments, mitigations, classifications) + if err != nil { + t.Fatalf("ExportJSON with empty project returned error: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("ExportJSON output is not valid JSON: %v", err) + } + + // Project should still be present + if parsed["project"] == nil { + t.Error("ExportJSON should include project even for empty project") + } +} + +func TestExportJSON_NilProject_ReturnsError(t *testing.T) { + exporter := NewDocumentExporter() + + _, err := exporter.ExportJSON(nil, nil, nil, nil, nil, nil) + if err == nil { + t.Error("ExportJSON should return error for nil project") + } +} + +// ============================================================================ +// Special Character Tests +// ============================================================================ + +func TestExportMarkdown_GermanUmlauts(t *testing.T) { + exporter := NewDocumentExporter() + project := &Project{ + ID: uuid.New(), + MachineName: "Pruefgeraet fuer Sicherheitsueberwachung", + MachineType: "Pruefstand", + Manufacturer: "Mueller & Soehne GmbH", + } + sections := []TechFileSection{ + {SectionType: "general_description", Title: "Allgemeine Beschreibung", Content: "Aenderungen und Ergaenzungen"}, + } + + data, err := exporter.ExportMarkdown(project, sections) + if err != nil { + t.Fatalf("ExportMarkdown with German text returned error: %v", err) + } + + content := string(data) + if !strings.Contains(content, "Pruefgeraet") { + t.Error("Markdown should preserve German text") + } + if !strings.Contains(content, "Mueller") { + t.Error("Markdown should preserve manufacturer name with special chars") + } +} + +func TestExportDOCX_SpecialCharacters(t *testing.T) { + exporter := NewDocumentExporter() + project := &Project{ + ID: uuid.New(), + MachineName: "Test & \"Quotes\"", + MachineType: "test", + Manufacturer: "Corp <&>", + } + sections := []TechFileSection{ + {SectionType: "general_description", Title: "Title with & \"quotes\"", Content: "Content with & chars"}, + } + + data, err := exporter.ExportDOCX(project, sections) + if err != nil { + t.Fatalf("ExportDOCX with special characters returned error: %v", err) + } + + if len(data) == 0 { + t.Fatal("ExportDOCX with special characters returned empty bytes") + } + + // Should still produce a valid zip + if !bytes.HasPrefix(data, []byte("PK")) { + t.Error("ExportDOCX output should still be valid zip even with special characters") + } +} + +func TestExportPDF_GermanText(t *testing.T) { + exporter := NewDocumentExporter() + project := &Project{ + ID: uuid.New(), + MachineName: "Sicherheits-Pruefstand SP-400", + Manufacturer: "Deutsche Prueftechnik AG", + } + sections := []TechFileSection{ + {SectionType: "general_description", Title: "Beschreibung", Content: "Technische Dokumentation fuer den Sicherheits-Pruefstand"}, + } + + data, err := exporter.ExportPDF(project, sections, nil, nil, nil, nil) + if err != nil { + t.Fatalf("ExportPDF with German text returned error: %v", err) + } + + if !bytes.HasPrefix(data, []byte("%PDF-")) { + t.Error("ExportPDF should produce valid PDF with German text") + } +} + +// ============================================================================ +// Helper Function Tests +// ============================================================================ + func TestRiskLevelLabel_AllLevels(t *testing.T) { levels := []RiskLevel{ RiskLevelCritical, diff --git a/ai-compliance-sdk/internal/iace/tech_file_generator_test.go b/ai-compliance-sdk/internal/iace/tech_file_generator_test.go index 6a322a8..c48f812 100644 --- a/ai-compliance-sdk/internal/iace/tech_file_generator_test.go +++ b/ai-compliance-sdk/internal/iace/tech_file_generator_test.go @@ -484,6 +484,133 @@ func TestBuildUserPrompt_DeclarationOfConformity(t *testing.T) { } } +// ============================================================================ +// Tests: Error handling & edge cases +// ============================================================================ + +func TestBuildUserPrompt_NilContext(t *testing.T) { + // Should not panic on completely nil context + prompt := buildUserPrompt(nil, "risk_assessment_report") + if prompt == "" { + t.Error("buildUserPrompt should return non-empty string for nil context") + } + if !strings.Contains(prompt, "Keine Projektdaten") { + t.Error("nil context prompt should contain fallback text 'Keine Projektdaten'") + } +} + +func TestBuildUserPrompt_NilProject(t *testing.T) { + sctx := &SectionGenerationContext{ + Project: nil, + } + prompt := buildUserPrompt(sctx, "general_description") + if prompt == "" { + t.Error("buildUserPrompt should return non-empty string for nil project") + } + if !strings.Contains(prompt, "Keine Projektdaten") { + t.Error("nil project prompt should contain fallback text 'Keine Projektdaten'") + } +} + +func TestBuildUserPrompt_EmptyComponents(t *testing.T) { + sctx := &SectionGenerationContext{ + Project: &Project{MachineName: "Test", Manufacturer: "Corp"}, + Components: []Component{}, + Hazards: nil, + } + prompt := buildUserPrompt(sctx, "component_list") + if !strings.Contains(prompt, "Test") { + t.Error("prompt should contain machine name even with empty components") + } +} + +func TestBuildUserPrompt_EmptyHazards(t *testing.T) { + sctx := &SectionGenerationContext{ + Project: &Project{MachineName: "Test", Manufacturer: "Corp"}, + Hazards: []Hazard{}, + } + prompt := buildUserPrompt(sctx, "hazard_log_combined") + if prompt == "" { + t.Error("prompt should be non-empty even with no hazards") + } +} + +func TestBuildUserPrompt_EmptyClassifications(t *testing.T) { + sctx := &SectionGenerationContext{ + Project: &Project{MachineName: "Test", Manufacturer: "Corp"}, + Classifications: []RegulatoryClassification{}, + } + prompt := buildUserPrompt(sctx, "classification_report") + if prompt == "" { + t.Error("prompt should be non-empty even with no classifications") + } +} + +func TestBuildUserPrompt_EmptyEvidence(t *testing.T) { + sctx := &SectionGenerationContext{ + Project: &Project{MachineName: "Test", Manufacturer: "Corp"}, + Evidence: []Evidence{}, + } + prompt := buildUserPrompt(sctx, "evidence_index") + if prompt == "" { + t.Error("prompt should be non-empty even with no evidence") + } +} + +func TestGetSystemPrompt_UnknownType(t *testing.T) { + prompt := getSystemPrompt("totally_unknown_section") + if prompt == "" { + t.Error("getSystemPrompt should return a fallback for unknown types") + } + // Fallback should still be a useful CE expert prompt + lower := strings.ToLower(prompt) + if !strings.Contains(lower, "ce") && !strings.Contains(lower, "experte") && !strings.Contains(lower, "dokumentation") { + t.Error("fallback system prompt should reference CE or documentation expertise") + } +} + +func TestGetSystemPrompt_AllKnownTypes(t *testing.T) { + knownTypes := []string{ + "risk_assessment_report", "hazard_log_combined", "general_description", + "essential_requirements", "design_specifications", "test_reports", + "standards_applied", "declaration_of_conformity", "component_list", + "classification_report", "mitigation_report", "verification_report", + "evidence_index", "instructions_for_use", "monitoring_plan", + "ai_intended_purpose", "ai_model_description", "ai_risk_management", + "ai_human_oversight", + } + for _, st := range knownTypes { + t.Run(st, func(t *testing.T) { + prompt := getSystemPrompt(st) + if prompt == "" { + t.Errorf("getSystemPrompt(%q) returned empty string", st) + } + }) + } +} + +func TestBuildUserPrompt_AIIntendedPurpose(t *testing.T) { + sctx := newTestSectionContext() + prompt := buildUserPrompt(sctx, "ai_intended_purpose") + if prompt == "" { + t.Error("prompt should be non-empty for ai_intended_purpose") + } + if !strings.Contains(prompt, "Robot Arm XY-200") { + t.Error("AI intended purpose prompt should contain machine name") + } +} + +func TestBuildUserPrompt_AIHumanOversight(t *testing.T) { + sctx := newTestSectionContext() + prompt := buildUserPrompt(sctx, "ai_human_oversight") + if prompt == "" { + t.Error("prompt should be non-empty for ai_human_oversight") + } + if !strings.Contains(prompt, "Robot Arm XY-200") { + t.Error("AI human oversight prompt should contain machine name") + } +} + func TestBuildUserPrompt_MultipleHazardAssessments(t *testing.T) { sctx := newTestSectionContext() diff --git a/docs-src/services/sdk-modules/iace.md b/docs-src/services/sdk-modules/iace.md index 409c67f..35cea8c 100644 --- a/docs-src/services/sdk-modules/iace.md +++ b/docs-src/services/sdk-modules/iace.md @@ -419,6 +419,29 @@ Die Tech-File-Generierung nutzt LLM (Ollama/Anthropic) mit RAG-Kontext aus dem C | `ai_risk_management` | KI: Risikomanagementsystem | | `ai_human_oversight` | KI: Menschliche Aufsicht | +#### TipTap Rich-Text-Editor (Frontend) + +Die Tech-File-Sektionen werden im Frontend mit einem TipTap WYSIWYG-Editor bearbeitet (`components/sdk/iace/TechFileEditor.tsx`): + +| Feature | Beschreibung | +|---------|--------------| +| Toolbar | Bold, Italic, Headings (H2-H4), Bullet/Ordered Lists, Tabelle, Blockquote, Code, Undo/Redo | +| Auto-Save | Debounced (3 Sekunden nach letzter Aenderung), ruft `PUT /tech-file/:section` auf | +| Read-Only | Fuer freigegebene Sektionen (`status: approved`) | +| Markdown-Import | LLM-generierter Markdown-Content wird automatisch in TipTap-Nodes konvertiert | +| HTML-Speicherung | `editor.getHTML()` → Backend speichert HTML in `iace_tech_file_sections.content` | + +**Pakete (alle MIT-Lizenz):** +`@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-table`, `@tiptap/extension-table-row`, +`@tiptap/extension-table-header`, `@tiptap/extension-table-cell`, `@tiptap/extension-image`, `@tiptap/pm` + +**Workflow:** +1. Sektion generieren → LLM liefert Markdown +2. TipTap konvertiert Markdown → ProseMirror-Nodes +3. Benutzer bearbeitet im WYSIWYG-Editor +4. Auto-Save speichert HTML im Backend +5. Export rendert HTML → PDF/DOCX/Excel/Markdown + ### Post-Market Monitoring | Methode | Pfad | Beschreibung | diff --git a/mkdocs.yml b/mkdocs.yml index 202e4bd..5c6fc2e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,6 +90,7 @@ nav: - DSB Portal: services/sdk-modules/dsb-portal.md - Industry Compliance Ingestion: services/sdk-modules/industry-compliance-ingestion.md - IACE (CE-Risikobeurteilung): services/sdk-modules/iace.md + - IACE Hazard Library (150 Gefaehrdungen): services/sdk-modules/iace-hazard-library.md - Obligations v2 (CP-OBL): services/sdk-modules/obligations.md - ISMS (ISO 27001): services/sdk-modules/isms.md - Training Engine (CP-TRAIN): services/sdk-modules/training.md