feat(iace): complete CE risk assessment — LLM tech-file generation, multi-format export, TipTap editor
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
Phase 1: Fix completeness gates G23 (require verified/rejected mitigations) and G09 (audit trail check) Phase 2: LLM-based tech-file section generation with 19 German prompts and RAG enrichment Phase 3: Multi-format document export (PDF/Excel/DOCX/Markdown/JSON) Phase 4: Company profile → IACE data flow with auto component/classification creation Phase 5: TipTap WYSIWYG editor replacing textarea for tech-file sections Phase 6: User journey tests, developer portal API reference, updated documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -109,7 +109,7 @@ func main() {
|
||||
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
|
||||
academyHandlers := handlers.NewAcademyHandlers(academyStore, trainingStore)
|
||||
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
|
||||
iaceHandler := handlers.NewIACEHandler(iaceStore)
|
||||
iaceHandler := handlers.NewIACEHandler(iaceStore, providerRegistry)
|
||||
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator)
|
||||
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
||||
|
||||
@@ -596,6 +596,7 @@ func main() {
|
||||
iaceRoutes.GET("/projects/:id/tech-file", iaceHandler.ListTechFileSections)
|
||||
iaceRoutes.PUT("/projects/:id/tech-file/:section", iaceHandler.UpdateTechFileSection)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/approve", iaceHandler.ApproveTechFileSection)
|
||||
iaceRoutes.POST("/projects/:id/tech-file/:section/generate", iaceHandler.GenerateSingleSection)
|
||||
iaceRoutes.GET("/projects/:id/tech-file/export", iaceHandler.ExportTechFile)
|
||||
|
||||
// Monitoring
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -22,21 +23,26 @@ import (
|
||||
// onboarding, regulatory classification, hazard/risk analysis, evidence management,
|
||||
// CE technical file generation, and post-market monitoring.
|
||||
type IACEHandler struct {
|
||||
store *iace.Store
|
||||
engine *iace.RiskEngine
|
||||
classifier *iace.Classifier
|
||||
checker *iace.CompletenessChecker
|
||||
ragClient *ucca.LegalRAGClient
|
||||
store *iace.Store
|
||||
engine *iace.RiskEngine
|
||||
classifier *iace.Classifier
|
||||
checker *iace.CompletenessChecker
|
||||
ragClient *ucca.LegalRAGClient
|
||||
techFileGen *iace.TechFileGenerator
|
||||
exporter *iace.DocumentExporter
|
||||
}
|
||||
|
||||
// NewIACEHandler creates a new IACEHandler with all required dependencies.
|
||||
func NewIACEHandler(store *iace.Store) *IACEHandler {
|
||||
func NewIACEHandler(store *iace.Store, providerRegistry *llm.ProviderRegistry) *IACEHandler {
|
||||
ragClient := ucca.NewLegalRAGClient()
|
||||
return &IACEHandler{
|
||||
store: store,
|
||||
engine: iace.NewRiskEngine(),
|
||||
classifier: iace.NewClassifier(),
|
||||
checker: iace.NewCompletenessChecker(),
|
||||
ragClient: ucca.NewLegalRAGClient(),
|
||||
store: store,
|
||||
engine: iace.NewRiskEngine(),
|
||||
classifier: iace.NewClassifier(),
|
||||
checker: iace.NewCompletenessChecker(),
|
||||
ragClient: ragClient,
|
||||
techFileGen: iace.NewTechFileGenerator(providerRegistry, ragClient, store),
|
||||
exporter: iace.NewDocumentExporter(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +235,28 @@ func (h *IACEHandler) InitFromProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse compliance_scope to extract machine data
|
||||
var scope struct {
|
||||
MachineName string `json:"machine_name"`
|
||||
MachineType string `json:"machine_type"`
|
||||
IntendedUse string `json:"intended_use"`
|
||||
HasSoftware bool `json:"has_software"`
|
||||
HasFirmware bool `json:"has_firmware"`
|
||||
HasAI bool `json:"has_ai"`
|
||||
IsNetworked bool `json:"is_networked"`
|
||||
ApplicableRegulations []string `json:"applicable_regulations"`
|
||||
}
|
||||
_ = json.Unmarshal(req.ComplianceScope, &scope)
|
||||
|
||||
// Parse company_profile to extract manufacturer
|
||||
var profile struct {
|
||||
CompanyName string `json:"company_name"`
|
||||
ContactName string `json:"contact_name"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
_ = json.Unmarshal(req.CompanyProfile, &profile)
|
||||
|
||||
// Store the profile and scope in project metadata
|
||||
profileData := map[string]json.RawMessage{
|
||||
"company_profile": req.CompanyProfile,
|
||||
@@ -236,9 +264,23 @@ func (h *IACEHandler) InitFromProfile(c *gin.Context) {
|
||||
}
|
||||
metadataBytes, _ := json.Marshal(profileData)
|
||||
metadataRaw := json.RawMessage(metadataBytes)
|
||||
|
||||
// Build update request — fill project fields from scope/profile
|
||||
updateReq := iace.UpdateProjectRequest{
|
||||
Metadata: &metadataRaw,
|
||||
}
|
||||
if scope.MachineName != "" {
|
||||
updateReq.MachineName = &scope.MachineName
|
||||
}
|
||||
if scope.MachineType != "" {
|
||||
updateReq.MachineType = &scope.MachineType
|
||||
}
|
||||
if scope.IntendedUse != "" {
|
||||
updateReq.Description = &scope.IntendedUse
|
||||
}
|
||||
if profile.CompanyName != "" {
|
||||
updateReq.Manufacturer = &profile.CompanyName
|
||||
}
|
||||
|
||||
project, err = h.store.UpdateProject(c.Request.Context(), projectID, updateReq)
|
||||
if err != nil {
|
||||
@@ -246,8 +288,65 @@ func (h *IACEHandler) InitFromProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Create initial components from scope
|
||||
var createdComponents []iace.Component
|
||||
if scope.HasSoftware {
|
||||
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||
ProjectID: projectID, Name: "Software", ComponentType: iace.ComponentTypeSoftware,
|
||||
IsSafetyRelevant: true, IsNetworked: scope.IsNetworked,
|
||||
})
|
||||
if err == nil {
|
||||
createdComponents = append(createdComponents, *comp)
|
||||
}
|
||||
}
|
||||
if scope.HasFirmware {
|
||||
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||
ProjectID: projectID, Name: "Firmware", ComponentType: iace.ComponentTypeFirmware,
|
||||
IsSafetyRelevant: true,
|
||||
})
|
||||
if err == nil {
|
||||
createdComponents = append(createdComponents, *comp)
|
||||
}
|
||||
}
|
||||
if scope.HasAI {
|
||||
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||
ProjectID: projectID, Name: "KI-Modell", ComponentType: iace.ComponentTypeAIModel,
|
||||
IsSafetyRelevant: true, IsNetworked: scope.IsNetworked,
|
||||
})
|
||||
if err == nil {
|
||||
createdComponents = append(createdComponents, *comp)
|
||||
}
|
||||
}
|
||||
if scope.IsNetworked {
|
||||
comp, err := h.store.CreateComponent(ctx, iace.CreateComponentRequest{
|
||||
ProjectID: projectID, Name: "Netzwerk-Schnittstelle", ComponentType: iace.ComponentTypeNetwork,
|
||||
IsSafetyRelevant: false, IsNetworked: true,
|
||||
})
|
||||
if err == nil {
|
||||
createdComponents = append(createdComponents, *comp)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger initial classifications for applicable regulations
|
||||
regulationMap := map[string]iace.RegulationType{
|
||||
"machinery_regulation": iace.RegulationMachineryRegulation,
|
||||
"ai_act": iace.RegulationAIAct,
|
||||
"cra": iace.RegulationCRA,
|
||||
"nis2": iace.RegulationNIS2,
|
||||
}
|
||||
var triggeredRegulations []string
|
||||
for _, regStr := range scope.ApplicableRegulations {
|
||||
if regType, ok := regulationMap[regStr]; ok {
|
||||
triggeredRegulations = append(triggeredRegulations, regStr)
|
||||
// Create initial classification entry
|
||||
h.store.UpsertClassification(ctx, projectID, regType, "pending", "medium", 0.5, "Initialisiert aus Compliance-Scope", nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Advance project status to onboarding
|
||||
if err := h.store.UpdateProjectStatus(c.Request.Context(), projectID, iace.ProjectStatusOnboarding); err != nil {
|
||||
if err := h.store.UpdateProjectStatus(ctx, projectID, iace.ProjectStatusOnboarding); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -255,13 +354,15 @@ func (h *IACEHandler) InitFromProfile(c *gin.Context) {
|
||||
// Add audit trail entry
|
||||
userID := rbac.GetUserID(c)
|
||||
h.store.AddAuditEntry(
|
||||
c.Request.Context(), projectID, "project", projectID,
|
||||
ctx, projectID, "project", projectID,
|
||||
iace.AuditActionUpdate, userID.String(), nil, metadataBytes,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "project initialized from profile",
|
||||
"project": project,
|
||||
"message": "project initialized from profile",
|
||||
"project": project,
|
||||
"components_created": len(createdComponents),
|
||||
"regulations_triggered": triggeredRegulations,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -430,17 +531,21 @@ func (h *IACEHandler) CheckCompleteness(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check audit trail for pattern matching
|
||||
patternMatchingPerformed, _ := h.store.HasAuditEntryForType(c.Request.Context(), projectID, "pattern_matching")
|
||||
|
||||
// Build completeness context
|
||||
completenessCtx := &iace.CompletenessContext{
|
||||
Project: project,
|
||||
Components: components,
|
||||
Classifications: classifications,
|
||||
Hazards: hazards,
|
||||
Assessments: allAssessments,
|
||||
Mitigations: allMitigations,
|
||||
Evidence: evidence,
|
||||
TechFileSections: techFileSections,
|
||||
HasAI: hasAI,
|
||||
Project: project,
|
||||
Components: components,
|
||||
Classifications: classifications,
|
||||
Hazards: hazards,
|
||||
Assessments: allAssessments,
|
||||
Mitigations: allMitigations,
|
||||
Evidence: evidence,
|
||||
TechFileSections: techFileSections,
|
||||
HasAI: hasAI,
|
||||
PatternMatchingPerformed: patternMatchingPerformed,
|
||||
}
|
||||
|
||||
// Run the checker
|
||||
@@ -1440,8 +1545,7 @@ func (h *IACEHandler) GenerateTechFile(c *gin.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// Generate each section with placeholder content
|
||||
// TODO: Replace placeholder content with LLM-generated content based on project data
|
||||
// Generate each section with LLM-based content
|
||||
var sections []iace.TechFileSection
|
||||
existingSections, _ := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||
existingMap := make(map[string]bool)
|
||||
@@ -1455,16 +1559,11 @@ func (h *IACEHandler) GenerateTechFile(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(
|
||||
"[Auto-generated placeholder for '%s']\n\n"+
|
||||
"Machine: %s\nManufacturer: %s\nType: %s\n\n"+
|
||||
"TODO: Replace this placeholder with actual content. "+
|
||||
"LLM-based generation will be integrated in a future release.",
|
||||
def.Title,
|
||||
project.MachineName,
|
||||
project.Manufacturer,
|
||||
project.MachineType,
|
||||
)
|
||||
// Generate content via LLM (falls back to structured placeholder if LLM unavailable)
|
||||
content, _ := h.techFileGen.GenerateSection(c.Request.Context(), projectID, def.SectionType)
|
||||
if content == "" {
|
||||
content = fmt.Sprintf("[Sektion: %s — Inhalt wird generiert]", def.Title)
|
||||
}
|
||||
|
||||
section, err := h.store.CreateTechFileSection(
|
||||
c.Request.Context(), projectID, def.SectionType, def.Title, content,
|
||||
@@ -1489,7 +1588,93 @@ func (h *IACEHandler) GenerateTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"sections_created": len(sections),
|
||||
"sections": sections,
|
||||
"_note": "TODO: LLM-based content generation not yet implemented",
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateSingleSection handles POST /projects/:id/tech-file/:section/generate
|
||||
// Generates or regenerates a single tech file section using LLM.
|
||||
func (h *IACEHandler) GenerateSingleSection(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
sectionType := c.Param("section")
|
||||
if sectionType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "section type required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate content via LLM
|
||||
content, err := h.techFileGen.GenerateSection(c.Request.Context(), projectID, sectionType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("generation failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Find existing section and update, or create new
|
||||
sections, _ := h.store.ListTechFileSections(c.Request.Context(), projectID)
|
||||
var sectionID uuid.UUID
|
||||
found := false
|
||||
for _, s := range sections {
|
||||
if s.SectionType == sectionType {
|
||||
sectionID = s.ID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
if err := h.store.UpdateTechFileSection(c.Request.Context(), sectionID, content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
title := sectionType // fallback
|
||||
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 t, ok := sectionTitles[sectionType]; ok {
|
||||
title = t
|
||||
}
|
||||
|
||||
_, err := h.store.CreateTechFileSection(c.Request.Context(), projectID, sectionType, title, content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Audit trail
|
||||
userID := rbac.GetUserID(c)
|
||||
h.store.AddAuditEntry(
|
||||
c.Request.Context(), projectID, "tech_file_section", projectID,
|
||||
iace.AuditActionCreate, userID.String(), nil, nil,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "section generated",
|
||||
"section_type": sectionType,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1631,9 +1816,8 @@ func (h *IACEHandler) ApproveTechFileSection(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "tech file section approved"})
|
||||
}
|
||||
|
||||
// ExportTechFile handles GET /projects/:id/tech-file/export
|
||||
// Exports all tech file sections as a combined JSON document.
|
||||
// TODO: Implement PDF export with proper formatting.
|
||||
// ExportTechFile handles GET /projects/:id/tech-file/export?format=pdf|xlsx|docx|md|json
|
||||
// Exports all tech file sections in the requested format.
|
||||
func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -1657,27 +1841,78 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if all sections are approved
|
||||
allApproved := true
|
||||
for _, s := range sections {
|
||||
if s.Status != iace.TechFileSectionStatusApproved {
|
||||
allApproved = false
|
||||
break
|
||||
}
|
||||
// Load hazards, assessments, mitigations, classifications for export
|
||||
hazards, _ := h.store.ListHazards(c.Request.Context(), projectID)
|
||||
var allAssessments []iace.RiskAssessment
|
||||
var allMitigations []iace.Mitigation
|
||||
for _, hazard := range hazards {
|
||||
assessments, _ := h.store.ListAssessments(c.Request.Context(), hazard.ID)
|
||||
allAssessments = append(allAssessments, assessments...)
|
||||
mitigations, _ := h.store.ListMitigations(c.Request.Context(), hazard.ID)
|
||||
allMitigations = append(allMitigations, mitigations...)
|
||||
}
|
||||
|
||||
classifications, _ := h.store.GetClassifications(c.Request.Context(), projectID)
|
||||
riskSummary, _ := h.store.GetRiskSummary(c.Request.Context(), projectID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"project": project,
|
||||
"sections": sections,
|
||||
"classifications": classifications,
|
||||
"risk_summary": riskSummary,
|
||||
"all_approved": allApproved,
|
||||
"export_format": "json",
|
||||
"_note": "PDF export will be available in a future release",
|
||||
})
|
||||
format := c.DefaultQuery("format", "json")
|
||||
safeName := strings.ReplaceAll(project.MachineName, " ", "_")
|
||||
|
||||
switch format {
|
||||
case "pdf":
|
||||
data, err := h.exporter.ExportPDF(project, sections, hazards, allAssessments, allMitigations, classifications)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("PDF export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.pdf"`, safeName))
|
||||
c.Data(http.StatusOK, "application/pdf", data)
|
||||
|
||||
case "xlsx":
|
||||
data, err := h.exporter.ExportExcel(project, sections, hazards, allAssessments, allMitigations)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Excel export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.xlsx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||
|
||||
case "docx":
|
||||
data, err := h.exporter.ExportDOCX(project, sections)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("DOCX export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.docx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
|
||||
|
||||
case "md":
|
||||
data, err := h.exporter.ExportMarkdown(project, sections)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Markdown export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.md"`, safeName))
|
||||
c.Data(http.StatusOK, "text/markdown", data)
|
||||
|
||||
default:
|
||||
// JSON export (original behavior)
|
||||
allApproved := true
|
||||
for _, s := range sections {
|
||||
if s.Status != iace.TechFileSectionStatusApproved {
|
||||
allApproved = false
|
||||
break
|
||||
}
|
||||
}
|
||||
riskSummary, _ := h.store.GetRiskSummary(c.Request.Context(), projectID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"project": project,
|
||||
"sections": sections,
|
||||
"classifications": classifications,
|
||||
"risk_summary": riskSummary,
|
||||
"all_approved": allApproved,
|
||||
"export_format": "json",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -21,15 +21,16 @@ type GateDefinition struct {
|
||||
|
||||
// CompletenessContext provides all project data needed to evaluate completeness gates.
|
||||
type CompletenessContext struct {
|
||||
Project *Project
|
||||
Components []Component
|
||||
Classifications []RegulatoryClassification
|
||||
Hazards []Hazard
|
||||
Assessments []RiskAssessment
|
||||
Mitigations []Mitigation
|
||||
Evidence []Evidence
|
||||
TechFileSections []TechFileSection
|
||||
HasAI bool
|
||||
Project *Project
|
||||
Components []Component
|
||||
Classifications []RegulatoryClassification
|
||||
Hazards []Hazard
|
||||
Assessments []RiskAssessment
|
||||
Mitigations []Mitigation
|
||||
Evidence []Evidence
|
||||
TechFileSections []TechFileSection
|
||||
HasAI bool
|
||||
PatternMatchingPerformed bool // set from audit trail (entity_type="pattern_matching")
|
||||
}
|
||||
|
||||
// CompletenessResult contains the aggregated result of all gate checks.
|
||||
@@ -145,10 +146,7 @@ func buildGateDefinitions() []GateDefinition {
|
||||
Required: false,
|
||||
Recommended: true,
|
||||
CheckFunc: func(ctx *CompletenessContext) bool {
|
||||
// Check audit trail for pattern_matching entries
|
||||
// Since we can't query audit trail from context, check if hazards
|
||||
// have been created (proxy for pattern matching having been performed)
|
||||
return len(ctx.Hazards) >= 3
|
||||
return ctx.PatternMatchingPerformed
|
||||
},
|
||||
},
|
||||
|
||||
@@ -265,14 +263,17 @@ func buildGateDefinitions() []GateDefinition {
|
||||
Label: "Mitigations verified",
|
||||
Required: true,
|
||||
CheckFunc: func(ctx *CompletenessContext) bool {
|
||||
// All mitigations with status "implemented" must also be verified
|
||||
// All mitigations must be in a terminal state (verified or rejected).
|
||||
// Planned and implemented mitigations block export — they haven't been
|
||||
// verified yet, so the project cannot be considered complete.
|
||||
if len(ctx.Mitigations) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, m := range ctx.Mitigations {
|
||||
if m.Status == MitigationStatusImplemented {
|
||||
// Implemented but not yet verified -> gate fails
|
||||
if m.Status != MitigationStatusVerified && m.Status != MitigationStatusRejected {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// All mitigations are either planned, verified, or rejected
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestCompletenessCheck_EmptyContext(t *testing.T) {
|
||||
// With nil project, most gates fail. However, some auto-pass:
|
||||
// G06 (AI classification): auto-passes when HasAI=false
|
||||
// G22 (critical/high mitigated): auto-passes when no critical/high assessments exist
|
||||
// G23 (mitigations verified): auto-passes when no mitigations with status "implemented"
|
||||
// G23 (mitigations verified): auto-passes when no mitigations (empty list)
|
||||
// G42 (AI documents): auto-passes when HasAI=false
|
||||
// That gives 4 required gates passing even with empty context.
|
||||
if result.PassedRequired != 4 {
|
||||
@@ -89,7 +89,8 @@ func TestCompletenessCheck_MinimalValidProject(t *testing.T) {
|
||||
{ID: uuid.New(), ProjectID: projectID, SectionType: "risk_assessment_report"},
|
||||
{ID: uuid.New(), ProjectID: projectID, SectionType: "hazard_log_combined"},
|
||||
},
|
||||
HasAI: false,
|
||||
HasAI: false,
|
||||
PatternMatchingPerformed: true,
|
||||
}
|
||||
|
||||
result := checker.Check(ctx)
|
||||
@@ -376,11 +377,11 @@ func TestCompletenessCheck_G23_MitigationsVerified(t *testing.T) {
|
||||
wantG23Passed: false,
|
||||
},
|
||||
{
|
||||
name: "planned mitigations pass G23 (not yet implemented)",
|
||||
name: "planned mitigations fail G23 (not yet verified)",
|
||||
mitigations: []Mitigation{
|
||||
{HazardID: hazardID, Status: MitigationStatusPlanned},
|
||||
},
|
||||
wantG23Passed: true,
|
||||
wantG23Passed: false,
|
||||
},
|
||||
{
|
||||
name: "rejected mitigations pass G23",
|
||||
@@ -390,12 +391,20 @@ func TestCompletenessCheck_G23_MitigationsVerified(t *testing.T) {
|
||||
wantG23Passed: true,
|
||||
},
|
||||
{
|
||||
name: "mix of verified planned rejected passes G23",
|
||||
name: "mix of verified planned rejected fails G23",
|
||||
mitigations: []Mitigation{
|
||||
{HazardID: hazardID, Status: MitigationStatusVerified},
|
||||
{HazardID: hazardID, Status: MitigationStatusPlanned},
|
||||
{HazardID: hazardID, Status: MitigationStatusRejected},
|
||||
},
|
||||
wantG23Passed: false,
|
||||
},
|
||||
{
|
||||
name: "mix of verified and rejected passes G23",
|
||||
mitigations: []Mitigation{
|
||||
{HazardID: hazardID, Status: MitigationStatusVerified},
|
||||
{HazardID: hazardID, Status: MitigationStatusRejected},
|
||||
},
|
||||
wantG23Passed: true,
|
||||
},
|
||||
}
|
||||
@@ -422,6 +431,48 @@ func TestCompletenessCheck_G23_MitigationsVerified(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletenessCheck_G09_PatternMatchingPerformed(t *testing.T) {
|
||||
checker := NewCompletenessChecker()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
performed bool
|
||||
wantG09Passed bool
|
||||
}{
|
||||
{
|
||||
name: "pattern matching not performed fails G09",
|
||||
performed: false,
|
||||
wantG09Passed: false,
|
||||
},
|
||||
{
|
||||
name: "pattern matching performed passes G09",
|
||||
performed: true,
|
||||
wantG09Passed: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := &CompletenessContext{
|
||||
Project: &Project{MachineName: "Test"},
|
||||
PatternMatchingPerformed: tt.performed,
|
||||
}
|
||||
|
||||
result := checker.Check(ctx)
|
||||
|
||||
for _, g := range result.Gates {
|
||||
if g.ID == "G09" {
|
||||
if g.Passed != tt.wantG09Passed {
|
||||
t.Errorf("G09 Passed = %v, want %v", g.Passed, tt.wantG09Passed)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("G09 gate not found in results")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletenessCheck_G24_ResidualRiskAccepted(t *testing.T) {
|
||||
checker := NewCompletenessChecker()
|
||||
|
||||
|
||||
1101
ai-compliance-sdk/internal/iace/document_export.go
Normal file
1101
ai-compliance-sdk/internal/iace/document_export.go
Normal file
File diff suppressed because it is too large
Load Diff
305
ai-compliance-sdk/internal/iace/document_export_test.go
Normal file
305
ai-compliance-sdk/internal/iace/document_export_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// createTestExportData builds a complete set of test data for document export tests.
|
||||
func createTestExportData() (*Project, []TechFileSection, []Hazard, []RiskAssessment, []Mitigation, []RegulatoryClassification) {
|
||||
projectID := uuid.New()
|
||||
hazardID1 := uuid.New()
|
||||
hazardID2 := uuid.New()
|
||||
|
||||
project := &Project{
|
||||
ID: projectID,
|
||||
MachineName: "Robot Arm XY-200",
|
||||
MachineType: "industrial_robot",
|
||||
Manufacturer: "TestCorp GmbH",
|
||||
Description: "6-Achsen Industrieroboter fuer Schweissarbeiten",
|
||||
CEMarkingTarget: "2023/1230",
|
||||
}
|
||||
|
||||
sections := []TechFileSection{
|
||||
{ID: uuid.New(), ProjectID: projectID, SectionType: "risk_assessment_report", Title: "Risikobeurteilung", Content: "Dies ist der Risikobeurteilungsbericht...", Status: TechFileSectionStatusApproved},
|
||||
{ID: uuid.New(), ProjectID: projectID, SectionType: "hazard_log_combined", Title: "Gefaehrdungsprotokoll", Content: "Protokoll aller identifizierten Gefaehrdungen...", Status: TechFileSectionStatusGenerated},
|
||||
{ID: uuid.New(), ProjectID: projectID, SectionType: "declaration_of_conformity", Title: "EU-Konformitaetserklaerung", Content: "Hiermit erklaeren wir...", Status: TechFileSectionStatusDraft},
|
||||
}
|
||||
|
||||
hazards := []Hazard{
|
||||
{ID: hazardID1, ProjectID: projectID, Name: "Quetschgefahr", Category: "mechanical", Description: "Quetschgefahr durch Roboterarm"},
|
||||
{ID: hazardID2, ProjectID: projectID, Name: "Elektrischer Schlag", Category: "electrical", Description: "Gefahr durch freiliegende Kontakte"},
|
||||
}
|
||||
|
||||
assessments := []RiskAssessment{
|
||||
{ID: uuid.New(), HazardID: hazardID1, Severity: 4, Exposure: 3, Probability: 3, InherentRisk: 36, CEff: 0.7, ResidualRisk: 10.8, RiskLevel: RiskLevelHigh, IsAcceptable: false},
|
||||
{ID: uuid.New(), HazardID: hazardID2, Severity: 2, Exposure: 2, Probability: 2, InherentRisk: 8, CEff: 0.8, ResidualRisk: 1.6, RiskLevel: RiskLevelLow, IsAcceptable: true},
|
||||
}
|
||||
|
||||
mitigations := []Mitigation{
|
||||
{ID: uuid.New(), HazardID: hazardID1, ReductionType: ReductionTypeDesign, Name: "Schutzabdeckung", Status: MitigationStatusVerified},
|
||||
{ID: uuid.New(), HazardID: hazardID1, ReductionType: ReductionTypeProtective, Name: "Lichtschranke", Status: MitigationStatusVerified},
|
||||
{ID: uuid.New(), HazardID: hazardID2, ReductionType: ReductionTypeInformation, Name: "Warnhinweis", Status: MitigationStatusPlanned},
|
||||
}
|
||||
|
||||
classifications := []RegulatoryClassification{
|
||||
{Regulation: RegulationMachineryRegulation, ClassificationResult: "Annex I", RiskLevel: RiskLevelHigh},
|
||||
{Regulation: RegulationCRA, ClassificationResult: "Important", RiskLevel: RiskLevelMedium},
|
||||
}
|
||||
|
||||
return project, sections, hazards, assessments, mitigations, classifications
|
||||
}
|
||||
|
||||
// createEmptyExportData builds minimal test data with no hazards, sections, or mitigations.
|
||||
func createEmptyExportData() (*Project, []TechFileSection, []Hazard, []RiskAssessment, []Mitigation, []RegulatoryClassification) {
|
||||
project := &Project{
|
||||
ID: uuid.New(),
|
||||
MachineName: "Empty Machine",
|
||||
MachineType: "test",
|
||||
Manufacturer: "TestCorp",
|
||||
Description: "",
|
||||
CEMarkingTarget: "",
|
||||
}
|
||||
return project, nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PDF Export Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestExportPDF_ValidOutput(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, sections, hazards, assessments, mitigations, classifications := createTestExportData()
|
||||
|
||||
data, err := exporter.ExportPDF(project, sections, hazards, assessments, mitigations, classifications)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportPDF returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportPDF returned empty bytes")
|
||||
}
|
||||
|
||||
// Valid PDF files start with %PDF-
|
||||
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
t.Errorf("ExportPDF output does not start with %%PDF-, got first 10 bytes: %q", data[:min(10, len(data))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPDF_EmptyProject(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, sections, hazards, assessments, mitigations, classifications := createEmptyExportData()
|
||||
|
||||
data, err := exporter.ExportPDF(project, sections, hazards, assessments, mitigations, classifications)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportPDF with empty project returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportPDF with empty project returned empty bytes")
|
||||
}
|
||||
|
||||
// Should still produce a valid PDF even with no content
|
||||
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
t.Errorf("ExportPDF output does not start with %%PDF-, got first 10 bytes: %q", data[:min(10, len(data))])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Excel Export Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestExportExcel_ValidOutput(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, sections, hazards, assessments, mitigations, _ := createTestExportData()
|
||||
|
||||
data, err := exporter.ExportExcel(project, sections, hazards, assessments, mitigations)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportExcel returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportExcel returned empty bytes")
|
||||
}
|
||||
|
||||
// xlsx is a zip archive, which starts with PK (0x50, 0x4b)
|
||||
if !bytes.HasPrefix(data, []byte("PK")) {
|
||||
t.Errorf("ExportExcel output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportExcel_EmptyProject(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, sections, hazards, assessments, mitigations, _ := createEmptyExportData()
|
||||
|
||||
data, err := exporter.ExportExcel(project, sections, hazards, assessments, mitigations)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportExcel with empty project returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportExcel with empty project returned empty bytes")
|
||||
}
|
||||
|
||||
// Should still produce a valid xlsx (zip) even with no data
|
||||
if !bytes.HasPrefix(data, []byte("PK")) {
|
||||
t.Errorf("ExportExcel output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Markdown Export Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestExportMarkdown_ContainsSections(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, sections, _, _, _, _ := createTestExportData()
|
||||
|
||||
data, err := exporter.ExportMarkdown(project, sections)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportMarkdown returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportMarkdown returned empty bytes")
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Should contain the project name
|
||||
if !strings.Contains(content, project.MachineName) {
|
||||
t.Errorf("ExportMarkdown output does not contain project name %q", project.MachineName)
|
||||
}
|
||||
|
||||
// Should contain each section title
|
||||
for _, section := range sections {
|
||||
if !strings.Contains(content, section.Title) {
|
||||
t.Errorf("ExportMarkdown output does not contain section title %q", section.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Should contain markdown header syntax
|
||||
if !strings.Contains(content, "#") {
|
||||
t.Error("ExportMarkdown output does not contain any markdown headers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportMarkdown_EmptyProject(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, _, _, _, _, _ := createEmptyExportData()
|
||||
|
||||
data, err := exporter.ExportMarkdown(project, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportMarkdown with empty project returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportMarkdown with empty project returned empty bytes")
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Should still contain the project name as a header even without sections
|
||||
if !strings.Contains(content, project.MachineName) {
|
||||
t.Errorf("ExportMarkdown output does not contain project name %q for empty project", project.MachineName)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DOCX Export Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestExportDOCX_ValidOutput(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, sections, _, _, _, _ := createTestExportData()
|
||||
|
||||
data, err := exporter.ExportDOCX(project, sections)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportDOCX returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportDOCX returned empty bytes")
|
||||
}
|
||||
|
||||
// docx is a zip archive, which starts with PK (0x50, 0x4b)
|
||||
if !bytes.HasPrefix(data, []byte("PK")) {
|
||||
t.Errorf("ExportDOCX output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportDOCX_EmptyProject(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
project, _, _, _, _, _ := createEmptyExportData()
|
||||
|
||||
data, err := exporter.ExportDOCX(project, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportDOCX with empty project returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportDOCX with empty project returned empty bytes")
|
||||
}
|
||||
|
||||
// Should still produce a valid docx (zip) even with no sections
|
||||
if !bytes.HasPrefix(data, []byte("PK")) {
|
||||
t.Errorf("ExportDOCX output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Function Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestRiskLevelLabel_AllLevels(t *testing.T) {
|
||||
levels := []RiskLevel{
|
||||
RiskLevelCritical,
|
||||
RiskLevelHigh,
|
||||
RiskLevelMedium,
|
||||
RiskLevelLow,
|
||||
RiskLevelNegligible,
|
||||
RiskLevelNotAcceptable,
|
||||
RiskLevelVeryHigh,
|
||||
}
|
||||
|
||||
for _, level := range levels {
|
||||
t.Run(string(level), func(t *testing.T) {
|
||||
label := riskLevelLabel(level)
|
||||
if label == "" {
|
||||
t.Errorf("riskLevelLabel(%q) returned empty string", level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskLevelColor_AllLevels(t *testing.T) {
|
||||
levels := []RiskLevel{
|
||||
RiskLevelCritical,
|
||||
RiskLevelHigh,
|
||||
RiskLevelMedium,
|
||||
RiskLevelLow,
|
||||
RiskLevelNegligible,
|
||||
RiskLevelNotAcceptable,
|
||||
RiskLevelVeryHigh,
|
||||
}
|
||||
|
||||
for _, level := range levels {
|
||||
t.Run(string(level), func(t *testing.T) {
|
||||
r, g, b := riskLevelColor(level)
|
||||
// RGB values must be in valid range 0-255
|
||||
if r < 0 || r > 255 {
|
||||
t.Errorf("riskLevelColor(%q) red value %d out of range [0,255]", level, r)
|
||||
}
|
||||
if g < 0 || g > 255 {
|
||||
t.Errorf("riskLevelColor(%q) green value %d out of range [0,255]", level, g)
|
||||
}
|
||||
if b < 0 || b > 255 {
|
||||
t.Errorf("riskLevelColor(%q) blue value %d out of range [0,255]", level, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1612,6 +1612,21 @@ func (s *Store) ListAuditTrail(ctx context.Context, projectID uuid.UUID) ([]Audi
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// HasAuditEntryForType checks if an audit trail entry exists for the given entity type within a project.
|
||||
func (s *Store) HasAuditEntryForType(ctx context.Context, projectID uuid.UUID, entityType string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM iace_audit_trail
|
||||
WHERE project_id = $1 AND entity_type = $2
|
||||
)
|
||||
`, projectID, entityType).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("has audit entry: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hazard Library Operations
|
||||
// ============================================================================
|
||||
|
||||
679
ai-compliance-sdk/internal/iace/tech_file_generator.go
Normal file
679
ai-compliance-sdk/internal/iace/tech_file_generator.go
Normal file
@@ -0,0 +1,679 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// TechFileGenerator — LLM-based generation of technical file sections
|
||||
// ============================================================================
|
||||
|
||||
// TechFileGenerator generates technical file section content using LLM and RAG.
|
||||
type TechFileGenerator struct {
|
||||
llmRegistry *llm.ProviderRegistry
|
||||
ragClient *ucca.LegalRAGClient
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewTechFileGenerator creates a new TechFileGenerator.
|
||||
func NewTechFileGenerator(registry *llm.ProviderRegistry, ragClient *ucca.LegalRAGClient, store *Store) *TechFileGenerator {
|
||||
return &TechFileGenerator{
|
||||
llmRegistry: registry,
|
||||
ragClient: ragClient,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// SectionGenerationContext holds all project data needed for LLM section generation.
|
||||
type SectionGenerationContext struct {
|
||||
Project *Project
|
||||
Components []Component
|
||||
Hazards []Hazard
|
||||
Assessments map[uuid.UUID][]RiskAssessment // keyed by hazardID
|
||||
Mitigations map[uuid.UUID][]Mitigation // keyed by hazardID
|
||||
Classifications []RegulatoryClassification
|
||||
Evidence []Evidence
|
||||
RAGContext string // aggregated text from RAG search
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Section type constants
|
||||
// ============================================================================
|
||||
|
||||
const (
|
||||
SectionRiskAssessmentReport = "risk_assessment_report"
|
||||
SectionHazardLogCombined = "hazard_log_combined"
|
||||
SectionGeneralDescription = "general_description"
|
||||
SectionEssentialRequirements = "essential_requirements"
|
||||
SectionDesignSpecifications = "design_specifications"
|
||||
SectionTestReports = "test_reports"
|
||||
SectionStandardsApplied = "standards_applied"
|
||||
SectionDeclarationConformity = "declaration_of_conformity"
|
||||
SectionAIIntendedPurpose = "ai_intended_purpose"
|
||||
SectionAIModelDescription = "ai_model_description"
|
||||
SectionAIRiskManagement = "ai_risk_management"
|
||||
SectionAIHumanOversight = "ai_human_oversight"
|
||||
SectionComponentList = "component_list"
|
||||
SectionClassificationReport = "classification_report"
|
||||
SectionMitigationReport = "mitigation_report"
|
||||
SectionVerificationReport = "verification_report"
|
||||
SectionEvidenceIndex = "evidence_index"
|
||||
SectionInstructionsForUse = "instructions_for_use"
|
||||
SectionMonitoringPlan = "monitoring_plan"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// System prompts (German — CE compliance context)
|
||||
// ============================================================================
|
||||
|
||||
var sectionSystemPrompts = map[string]string{
|
||||
SectionRiskAssessmentReport: `Du bist CE-Experte fuer Maschinen- und KI-Sicherheit. Erstelle eine strukturierte Zusammenfassung der Risikobeurteilung gemaess ISO 12100 und EN ISO 13849. Gliederung: 1) Methodik, 2) Risikoueberblick (Anzahl Gefaehrdungen nach Risikostufe), 3) Kritische Risiken, 4) Akzeptanzbewertung, 5) Empfehlungen. Verwende Fachterminologie und beziehe dich auf die konkreten Projektdaten.`,
|
||||
|
||||
SectionHazardLogCombined: `Erstelle ein tabellarisches Gefaehrdungsprotokoll (Hazard Log) fuer die technische Dokumentation. Jede Gefaehrdung soll enthalten: ID, Bezeichnung, Kategorie, Lebenszyklusphase, Szenario, Schwere, Eintrittswahrscheinlichkeit, Risikolevel, Massnahmen und Status. Formatiere als strukturierte Tabelle in Markdown.`,
|
||||
|
||||
SectionGeneralDescription: `Erstelle eine allgemeine Maschinenbeschreibung fuer die technische Dokumentation gemaess EU-Maschinenverordnung 2023/1230 Anhang IV. Beschreibe: 1) Bestimmungsgemaesse Verwendung, 2) Aufbau und Funktion, 3) Systemkomponenten, 4) Betriebsbedingungen, 5) Schnittstellen. Verwende die bereitgestellten Projektdaten.`,
|
||||
|
||||
SectionEssentialRequirements: `Beschreibe die anwendbaren grundlegenden Anforderungen (Essential Health and Safety Requirements — EHSR) gemaess EU-Maschinenverordnung 2023/1230 Anhang III. Ordne jede Anforderung den relevanten Gefaehrdungen und Massnahmen zu. Beruecksichtige auch AI Act und CRA Anforderungen falls KI-Komponenten vorhanden sind.`,
|
||||
|
||||
SectionDesignSpecifications: `Erstelle eine Uebersicht der Konstruktionsdaten und Spezifikationen fuer die technische Dokumentation. Enthalten sein sollen: 1) Systemarchitektur, 2) Komponentenliste mit Sicherheitsrelevanz, 3) Software-/Firmware-Versionen, 4) Schnittstellenbeschreibungen, 5) Sicherheitsfunktionen. Beziehe dich auf die konkreten Komponenten.`,
|
||||
|
||||
SectionTestReports: `Erstelle eine Zusammenfassung der Pruefberichte und Verifikationsergebnisse. Gliederung: 1) Durchgefuehrte Pruefungen, 2) Pruefmethoden (Test, Analyse, Inspektion), 3) Ergebnisse pro Massnahme, 4) Offene Punkte, 5) Gesamtbewertung. Referenziere die konkreten Mitigationsmassnahmen und deren Verifikationsstatus.`,
|
||||
|
||||
SectionStandardsApplied: `Liste die angewandten harmonisierten Normen und technischen Spezifikationen auf. Ordne jede Norm den relevanten Anforderungen und Gefaehrdungskategorien zu. Beruecksichtige: ISO 12100, ISO 13849, IEC 62443, ISO/IEC 27001, sowie branchenspezifische Normen. Erklaere die Vermutungswirkung (Presumption of Conformity).`,
|
||||
|
||||
SectionDeclarationConformity: `Erstelle eine EU-Konformitaetserklaerung nach EU-Maschinenverordnung 2023/1230 Anhang IV. Enthalten sein muessen: 1) Hersteller-Angaben, 2) Produktidentifikation, 3) Angewandte Richtlinien und Verordnungen, 4) Angewandte Normen, 5) Bevollmaechtigter, 6) Ort, Datum, Unterschrift. Formales Dokument-Layout.`,
|
||||
|
||||
SectionAIIntendedPurpose: `Beschreibe den bestimmungsgemaessen Zweck des KI-Systems gemaess AI Act Art. 13 (Transparenzpflichten). Enthalten sein sollen: 1) Zweckbestimmung, 2) Einsatzbereich und -grenzen, 3) Zielgruppe, 4) Vorhersehbarer Fehlgebrauch, 5) Leistungskennzahlen, 6) Einschraenkungen und bekannte Risiken.`,
|
||||
|
||||
SectionAIModelDescription: `Beschreibe das KI-Modell, die Trainingsdaten und die Architektur gemaess AI Act Anhang IV. Enthalten: 1) Modelltyp und Architektur, 2) Trainingsdaten (Herkunft, Umfang, Qualitaet), 3) Validierungsmethodik, 4) Leistungsmetriken, 5) Bekannte Verzerrungen (Bias), 6) Energie-/Ressourcenverbrauch.`,
|
||||
|
||||
SectionAIRiskManagement: `Erstelle eine Beschreibung des KI-Risikomanagementsystems gemaess AI Act Art. 9. Gliederung: 1) Risikomanagement-Prozess, 2) Identifizierte Risiken fuer Gesundheit/Sicherheit/Grundrechte, 3) Risikomindernde Massnahmen, 4) Restrisiken, 5) Ueberwachungs- und Aktualisierungsverfahren.`,
|
||||
|
||||
SectionAIHumanOversight: `Beschreibe die Massnahmen zur menschlichen Aufsicht (Human Oversight) gemaess AI Act Art. 14. Enthalten: 1) Aufsichtskonzept, 2) Rollen und Verantwortlichkeiten, 3) Eingriffsmoglichkeiten, 4) Uebersteuern/Abschalten, 5) Schulungsanforderungen, 6) Informationspflichten an Nutzer.`,
|
||||
|
||||
SectionComponentList: `Erstelle eine detaillierte Komponentenliste fuer die technische Dokumentation. Pro Komponente: Name, Typ, Version, Beschreibung, Sicherheitsrelevanz, Vernetzungsstatus. Kennzeichne sicherheitsrelevante und vernetzte Komponenten besonders. Gruppiere nach Komponententyp.`,
|
||||
|
||||
SectionClassificationReport: `Erstelle einen Klassifizierungsbericht, der die regulatorische Einordnung des Produkts zusammenfasst. Pro Verordnung (MVO, AI Act, CRA, NIS2): Klassifizierungsergebnis, Risikoklasse, Begruendung, daraus resultierende Anforderungen. Bewerte die Gesamtkonformitaetslage.`,
|
||||
|
||||
SectionMitigationReport: `Erstelle einen Massnahmenbericht (Mitigation Report) fuer die technische Dokumentation. Gliederung nach 3-Stufen-Methode: 1) Inhaerent sichere Konstruktion (Design), 2) Technische Schutzmassnahmen (Protective), 3) Benutzerinformation (Information). Pro Massnahme: Status, Verifikation, zugeordnete Gefaehrdung.`,
|
||||
|
||||
SectionVerificationReport: `Erstelle einen Verifikationsbericht ueber alle durchgefuehrten Pruef- und Nachweisverfahren. Enthalten: 1) Verifikationsplan-Uebersicht, 2) Durchgefuehrte Pruefungen nach Methode, 3) Ergebnisse und Bewertung, 4) Offene Verifikationen, 5) Gesamtstatus der Konformitaetsnachweise.`,
|
||||
|
||||
SectionEvidenceIndex: `Erstelle ein Nachweisverzeichnis (Evidence Index) fuer die technische Dokumentation. Liste alle vorhandenen Nachweisdokumente auf: Dateiname, Beschreibung, zugeordnete Massnahme, Dokumenttyp. Identifiziere fehlende Nachweise und empfehle Ergaenzungen.`,
|
||||
|
||||
SectionInstructionsForUse: `Erstelle eine Gliederung fuer die Betriebsanleitung gemaess EU-Maschinenverordnung 2023/1230 Anhang III Abschnitt 1.7.4. Enthalten: 1) Bestimmungsgemaesse Verwendung, 2) Inbetriebnahme, 3) Sicherer Betrieb, 4) Wartung, 5) Restrisiken und Warnhinweise, 6) Ausserbetriebnahme. Beruecksichtige identifizierte Gefaehrdungen.`,
|
||||
|
||||
SectionMonitoringPlan: `Erstelle einen Post-Market-Monitoring-Plan fuer das Produkt. Enthalten: 1) Ueberwachungsziele, 2) Datenquellen (Kundenfeedback, Vorfaelle, Updates), 3) Ueberwachungsintervalle, 4) Eskalationsverfahren, 5) Dokumentationspflichten, 6) Verantwortlichkeiten. Beruecksichtige AI Act Art. 72 (Post-Market Monitoring) falls KI-Komponenten vorhanden.`,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RAG query mapping
|
||||
// ============================================================================
|
||||
|
||||
func buildRAGQuery(sectionType string) string {
|
||||
ragQueries := map[string]string{
|
||||
SectionRiskAssessmentReport: "Risikobeurteilung ISO 12100 Risikobewertung Maschine Gefaehrdungsanalyse",
|
||||
SectionHazardLogCombined: "Gefaehrdungsprotokoll Hazard Log Risikoanalyse Gefaehrdungsidentifikation",
|
||||
SectionGeneralDescription: "Maschinenbeschreibung technische Dokumentation bestimmungsgemaesse Verwendung",
|
||||
SectionEssentialRequirements: "grundlegende Anforderungen EHSR Maschinenverordnung Anhang III Sicherheitsanforderungen",
|
||||
SectionDesignSpecifications: "Konstruktionsdaten Spezifikationen Systemarchitektur technische Dokumentation",
|
||||
SectionTestReports: "Pruefberichte Verifikation Validierung Konformitaetsbewertung Testberichte",
|
||||
SectionStandardsApplied: "harmonisierte Normen ISO 12100 ISO 13849 IEC 62443 Vermutungswirkung",
|
||||
SectionDeclarationConformity: "EU-Konformitaetserklaerung Maschinenverordnung 2023/1230 Anhang IV CE-Kennzeichnung",
|
||||
SectionAIIntendedPurpose: "bestimmungsgemaesser Zweck KI-System AI Act Art. 13 Transparenz Intended Purpose",
|
||||
SectionAIModelDescription: "KI-Modell Trainingsdaten Architektur AI Act Anhang IV technische Dokumentation",
|
||||
SectionAIRiskManagement: "KI-Risikomanagementsystem AI Act Art. 9 Risikomanagement kuenstliche Intelligenz",
|
||||
SectionAIHumanOversight: "menschliche Aufsicht Human Oversight AI Act Art. 14 Kontrolle KI-System",
|
||||
SectionComponentList: "Komponentenliste Systemkomponenten sicherheitsrelevante Bauteile technische Dokumentation",
|
||||
SectionClassificationReport: "regulatorische Klassifizierung Risikoklasse AI Act CRA Maschinenverordnung",
|
||||
SectionMitigationReport: "Risikomindernde Massnahmen 3-Stufen-Methode ISO 12100 Schutzmassnahmen",
|
||||
SectionVerificationReport: "Verifikation Validierung Pruefnachweis Konformitaetsbewertung Pruefprotokoll",
|
||||
SectionEvidenceIndex: "Nachweisdokumente Evidence Konformitaetsnachweis Dokumentenindex",
|
||||
SectionInstructionsForUse: "Betriebsanleitung Benutzerinformation Maschinenverordnung Abschnitt 1.7.4 Sicherheitshinweise",
|
||||
SectionMonitoringPlan: "Post-Market-Monitoring Ueberwachungsplan AI Act Art. 72 Marktbeobachtung",
|
||||
}
|
||||
|
||||
if q, ok := ragQueries[sectionType]; ok {
|
||||
return q
|
||||
}
|
||||
return "CE-Konformitaet technische Dokumentation Maschinenverordnung AI Act"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BuildSectionContext — loads all project data + RAG context
|
||||
// ============================================================================
|
||||
|
||||
// BuildSectionContext loads project data and RAG context for a given section type.
|
||||
func (g *TechFileGenerator) BuildSectionContext(ctx context.Context, projectID uuid.UUID, sectionType string) (*SectionGenerationContext, error) {
|
||||
// Load project
|
||||
project, err := g.store.GetProject(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load project: %w", err)
|
||||
}
|
||||
if project == nil {
|
||||
return nil, fmt.Errorf("project %s not found", projectID)
|
||||
}
|
||||
|
||||
// Load components
|
||||
components, err := g.store.ListComponents(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load components: %w", err)
|
||||
}
|
||||
|
||||
// Load hazards
|
||||
hazards, err := g.store.ListHazards(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load hazards: %w", err)
|
||||
}
|
||||
|
||||
// Load assessments and mitigations per hazard
|
||||
assessments := make(map[uuid.UUID][]RiskAssessment)
|
||||
mitigations := make(map[uuid.UUID][]Mitigation)
|
||||
for _, h := range hazards {
|
||||
a, err := g.store.ListAssessments(ctx, h.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load assessments for hazard %s: %w", h.ID, err)
|
||||
}
|
||||
assessments[h.ID] = a
|
||||
|
||||
m, err := g.store.ListMitigations(ctx, h.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mitigations for hazard %s: %w", h.ID, err)
|
||||
}
|
||||
mitigations[h.ID] = m
|
||||
}
|
||||
|
||||
// Load classifications
|
||||
classifications, err := g.store.GetClassifications(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load classifications: %w", err)
|
||||
}
|
||||
|
||||
// Load evidence
|
||||
evidence, err := g.store.ListEvidence(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load evidence: %w", err)
|
||||
}
|
||||
|
||||
// Perform RAG search for section-specific context
|
||||
ragContext := ""
|
||||
if g.ragClient != nil {
|
||||
ragQuery := buildRAGQuery(sectionType)
|
||||
results, ragErr := g.ragClient.SearchCollection(ctx, "bp_iace_libraries", ragQuery, nil, 5)
|
||||
if ragErr == nil && len(results) > 0 {
|
||||
var ragParts []string
|
||||
for _, r := range results {
|
||||
entry := fmt.Sprintf("[%s] %s", r.RegulationShort, truncateForPrompt(r.Text, 400))
|
||||
ragParts = append(ragParts, entry)
|
||||
}
|
||||
ragContext = strings.Join(ragParts, "\n\n")
|
||||
}
|
||||
// RAG failure is non-fatal — we proceed without context
|
||||
}
|
||||
|
||||
return &SectionGenerationContext{
|
||||
Project: project,
|
||||
Components: components,
|
||||
Hazards: hazards,
|
||||
Assessments: assessments,
|
||||
Mitigations: mitigations,
|
||||
Classifications: classifications,
|
||||
Evidence: evidence,
|
||||
RAGContext: ragContext,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GenerateSection — main entry point
|
||||
// ============================================================================
|
||||
|
||||
// GenerateSection generates the content for a technical file section using LLM.
|
||||
// If LLM is unavailable, returns an enhanced placeholder with project data.
|
||||
func (g *TechFileGenerator) GenerateSection(ctx context.Context, projectID uuid.UUID, sectionType string) (string, error) {
|
||||
sctx, err := g.BuildSectionContext(ctx, projectID, sectionType)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build section context: %w", err)
|
||||
}
|
||||
|
||||
// Build prompts
|
||||
systemPrompt := getSystemPrompt(sectionType)
|
||||
userPrompt := buildUserPrompt(sctx, sectionType)
|
||||
|
||||
// Attempt LLM generation
|
||||
resp, err := g.llmRegistry.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
Temperature: 0.15,
|
||||
MaxTokens: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
// LLM unavailable — return structured fallback with real project data
|
||||
return buildFallbackContent(sctx, sectionType), nil
|
||||
}
|
||||
|
||||
return resp.Message.Content, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt builders
|
||||
// ============================================================================
|
||||
|
||||
func getSystemPrompt(sectionType string) string {
|
||||
if prompt, ok := sectionSystemPrompts[sectionType]; ok {
|
||||
return prompt
|
||||
}
|
||||
return "Du bist CE-Experte fuer technische Dokumentation. Erstelle den angeforderten Abschnitt der technischen Dokumentation basierend auf den bereitgestellten Projektdaten. Schreibe auf Deutsch, verwende Fachterminologie und beziehe dich auf die konkreten Daten."
|
||||
}
|
||||
|
||||
func buildUserPrompt(sctx *SectionGenerationContext, sectionType string) string {
|
||||
var b strings.Builder
|
||||
|
||||
if sctx == nil || sctx.Project == nil {
|
||||
b.WriteString("## Maschine / System\n\n- Keine Projektdaten vorhanden.\n\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Machine info — always included
|
||||
b.WriteString("## Maschine / System\n\n")
|
||||
b.WriteString(fmt.Sprintf("- **Name:** %s\n", sctx.Project.MachineName))
|
||||
b.WriteString(fmt.Sprintf("- **Typ:** %s\n", sctx.Project.MachineType))
|
||||
b.WriteString(fmt.Sprintf("- **Hersteller:** %s\n", sctx.Project.Manufacturer))
|
||||
if sctx.Project.Description != "" {
|
||||
b.WriteString(fmt.Sprintf("- **Beschreibung:** %s\n", sctx.Project.Description))
|
||||
}
|
||||
if sctx.Project.CEMarkingTarget != "" {
|
||||
b.WriteString(fmt.Sprintf("- **CE-Kennzeichnungsziel:** %s\n", sctx.Project.CEMarkingTarget))
|
||||
}
|
||||
if sctx.Project.NarrativeText != "" {
|
||||
b.WriteString(fmt.Sprintf("\n**Projektbeschreibung:** %s\n", truncateForPrompt(sctx.Project.NarrativeText, 500)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Components — for most section types
|
||||
if len(sctx.Components) > 0 && needsComponents(sectionType) {
|
||||
b.WriteString("## Komponenten\n\n")
|
||||
for i, c := range sctx.Components {
|
||||
if i >= 20 {
|
||||
b.WriteString(fmt.Sprintf("... und %d weitere Komponenten\n", len(sctx.Components)-20))
|
||||
break
|
||||
}
|
||||
safety := ""
|
||||
if c.IsSafetyRelevant {
|
||||
safety = " [SICHERHEITSRELEVANT]"
|
||||
}
|
||||
networked := ""
|
||||
if c.IsNetworked {
|
||||
networked = " [VERNETZT]"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s (Typ: %s)%s%s", c.Name, string(c.ComponentType), safety, networked))
|
||||
if c.Description != "" {
|
||||
b.WriteString(fmt.Sprintf(" — %s", truncateForPrompt(c.Description, 100)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Hazards + assessments — for risk-related sections
|
||||
if len(sctx.Hazards) > 0 && needsHazards(sectionType) {
|
||||
b.WriteString("## Gefaehrdungen und Risikobewertungen\n\n")
|
||||
for i, h := range sctx.Hazards {
|
||||
if i >= 30 {
|
||||
b.WriteString(fmt.Sprintf("... und %d weitere Gefaehrdungen\n", len(sctx.Hazards)-30))
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("### %s\n", h.Name))
|
||||
b.WriteString(fmt.Sprintf("- Kategorie: %s", h.Category))
|
||||
if h.SubCategory != "" {
|
||||
b.WriteString(fmt.Sprintf(" / %s", h.SubCategory))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if h.LifecyclePhase != "" {
|
||||
b.WriteString(fmt.Sprintf("- Lebenszyklusphase: %s\n", h.LifecyclePhase))
|
||||
}
|
||||
if h.Scenario != "" {
|
||||
b.WriteString(fmt.Sprintf("- Szenario: %s\n", truncateForPrompt(h.Scenario, 150)))
|
||||
}
|
||||
if h.PossibleHarm != "" {
|
||||
b.WriteString(fmt.Sprintf("- Moeglicher Schaden: %s\n", h.PossibleHarm))
|
||||
}
|
||||
if h.AffectedPerson != "" {
|
||||
b.WriteString(fmt.Sprintf("- Betroffene Person: %s\n", h.AffectedPerson))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- Status: %s\n", string(h.Status)))
|
||||
|
||||
// Latest assessment
|
||||
if assessments, ok := sctx.Assessments[h.ID]; ok && len(assessments) > 0 {
|
||||
a := assessments[len(assessments)-1] // latest
|
||||
b.WriteString(fmt.Sprintf("- Bewertung: S=%d E=%d P=%d → Risiko=%.1f (%s) %s\n",
|
||||
a.Severity, a.Exposure, a.Probability,
|
||||
a.ResidualRisk, string(a.RiskLevel),
|
||||
acceptableLabel(a.IsAcceptable)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Mitigations — for mitigation/verification sections
|
||||
if needsMitigations(sectionType) {
|
||||
designMeasures, protectiveMeasures, infoMeasures := groupMitigations(sctx)
|
||||
if len(designMeasures)+len(protectiveMeasures)+len(infoMeasures) > 0 {
|
||||
b.WriteString("## Risikomindernde Massnahmen (3-Stufen-Methode)\n\n")
|
||||
writeMitigationGroup(&b, "Stufe 1: Inhaerent sichere Konstruktion (Design)", designMeasures)
|
||||
writeMitigationGroup(&b, "Stufe 2: Technische Schutzmassnahmen (Protective)", protectiveMeasures)
|
||||
writeMitigationGroup(&b, "Stufe 3: Benutzerinformation (Information)", infoMeasures)
|
||||
}
|
||||
}
|
||||
|
||||
// Classifications — for classification/standards sections
|
||||
if len(sctx.Classifications) > 0 && needsClassifications(sectionType) {
|
||||
b.WriteString("## Regulatorische Klassifizierungen\n\n")
|
||||
for _, c := range sctx.Classifications {
|
||||
b.WriteString(fmt.Sprintf("- **%s:** %s (Risiko: %s)\n",
|
||||
string(c.Regulation), c.ClassificationResult, string(c.RiskLevel)))
|
||||
if c.Reasoning != "" {
|
||||
b.WriteString(fmt.Sprintf(" Begruendung: %s\n", truncateForPrompt(c.Reasoning, 200)))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Evidence — for evidence/verification sections
|
||||
if len(sctx.Evidence) > 0 && needsEvidence(sectionType) {
|
||||
b.WriteString("## Vorhandene Nachweise\n\n")
|
||||
for i, e := range sctx.Evidence {
|
||||
if i >= 30 {
|
||||
b.WriteString(fmt.Sprintf("... und %d weitere Nachweise\n", len(sctx.Evidence)-30))
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s", e.FileName))
|
||||
if e.Description != "" {
|
||||
b.WriteString(fmt.Sprintf(" — %s", truncateForPrompt(e.Description, 100)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// RAG context — if available
|
||||
if sctx.RAGContext != "" {
|
||||
b.WriteString("## Relevante Rechtsgrundlagen (RAG)\n\n")
|
||||
b.WriteString(sctx.RAGContext)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Instruction
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString("Erstelle den Abschnitt basierend auf den obigen Daten. Schreibe auf Deutsch, verwende Markdown-Formatierung und beziehe dich auf die konkreten Projektdaten.\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Section type → data requirements
|
||||
// ============================================================================
|
||||
|
||||
func needsComponents(sectionType string) bool {
|
||||
switch sectionType {
|
||||
case SectionGeneralDescription, SectionDesignSpecifications, SectionComponentList,
|
||||
SectionEssentialRequirements, SectionAIModelDescription, SectionAIIntendedPurpose,
|
||||
SectionClassificationReport, SectionInstructionsForUse:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func needsHazards(sectionType string) bool {
|
||||
switch sectionType {
|
||||
case SectionRiskAssessmentReport, SectionHazardLogCombined, SectionEssentialRequirements,
|
||||
SectionMitigationReport, SectionVerificationReport, SectionTestReports,
|
||||
SectionAIRiskManagement, SectionInstructionsForUse, SectionMonitoringPlan:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func needsMitigations(sectionType string) bool {
|
||||
switch sectionType {
|
||||
case SectionRiskAssessmentReport, SectionMitigationReport, SectionVerificationReport,
|
||||
SectionTestReports, SectionEssentialRequirements, SectionAIRiskManagement,
|
||||
SectionAIHumanOversight, SectionInstructionsForUse:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func needsClassifications(sectionType string) bool {
|
||||
switch sectionType {
|
||||
case SectionClassificationReport, SectionEssentialRequirements, SectionStandardsApplied,
|
||||
SectionDeclarationConformity, SectionAIIntendedPurpose, SectionAIRiskManagement,
|
||||
SectionGeneralDescription:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func needsEvidence(sectionType string) bool {
|
||||
switch sectionType {
|
||||
case SectionEvidenceIndex, SectionVerificationReport, SectionTestReports,
|
||||
SectionMitigationReport:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mitigation grouping helper
|
||||
// ============================================================================
|
||||
|
||||
func groupMitigations(sctx *SectionGenerationContext) (design, protective, info []Mitigation) {
|
||||
for _, mits := range sctx.Mitigations {
|
||||
for _, m := range mits {
|
||||
switch m.ReductionType {
|
||||
case ReductionTypeDesign:
|
||||
design = append(design, m)
|
||||
case ReductionTypeProtective:
|
||||
protective = append(protective, m)
|
||||
case ReductionTypeInformation:
|
||||
info = append(info, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func writeMitigationGroup(b *strings.Builder, title string, measures []Mitigation) {
|
||||
if len(measures) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("### %s\n\n", title))
|
||||
for i, m := range measures {
|
||||
if i >= 20 {
|
||||
b.WriteString(fmt.Sprintf("... und %d weitere Massnahmen\n", len(measures)-20))
|
||||
break
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- **%s** [%s]", m.Name, string(m.Status)))
|
||||
if m.VerificationMethod != "" {
|
||||
b.WriteString(fmt.Sprintf(" — Verifikation: %s", string(m.VerificationMethod)))
|
||||
if m.VerificationResult != "" {
|
||||
b.WriteString(fmt.Sprintf(" (%s)", m.VerificationResult))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
if m.Description != "" {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", truncateForPrompt(m.Description, 150)))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fallback content (when LLM is unavailable)
|
||||
// ============================================================================
|
||||
|
||||
func buildFallbackContent(sctx *SectionGenerationContext, sectionType string) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("[Automatisch generiert — LLM nicht verfuegbar]\n\n")
|
||||
|
||||
sectionTitle := sectionDisplayName(sectionType)
|
||||
b.WriteString(fmt.Sprintf("# %s\n\n", sectionTitle))
|
||||
|
||||
b.WriteString(fmt.Sprintf("**Maschine:** %s (%s)\n", sctx.Project.MachineName, sctx.Project.MachineType))
|
||||
b.WriteString(fmt.Sprintf("**Hersteller:** %s\n", sctx.Project.Manufacturer))
|
||||
if sctx.Project.Description != "" {
|
||||
b.WriteString(fmt.Sprintf("**Beschreibung:** %s\n", sctx.Project.Description))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Section-specific data summaries
|
||||
switch sectionType {
|
||||
case SectionComponentList, SectionGeneralDescription, SectionDesignSpecifications:
|
||||
if len(sctx.Components) > 0 {
|
||||
b.WriteString("## Komponenten\n\n")
|
||||
b.WriteString(fmt.Sprintf("Anzahl: %d\n\n", len(sctx.Components)))
|
||||
for _, c := range sctx.Components {
|
||||
safety := ""
|
||||
if c.IsSafetyRelevant {
|
||||
safety = " [SICHERHEITSRELEVANT]"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s (Typ: %s)%s\n", c.Name, string(c.ComponentType), safety))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
case SectionRiskAssessmentReport, SectionHazardLogCombined:
|
||||
b.WriteString("## Risikoueberblick\n\n")
|
||||
b.WriteString(fmt.Sprintf("Anzahl Gefaehrdungen: %d\n\n", len(sctx.Hazards)))
|
||||
riskCounts := countRiskLevels(sctx)
|
||||
for level, count := range riskCounts {
|
||||
b.WriteString(fmt.Sprintf("- %s: %d\n", level, count))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, h := range sctx.Hazards {
|
||||
b.WriteString(fmt.Sprintf("- **%s** (%s) — Status: %s\n", h.Name, h.Category, string(h.Status)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
case SectionMitigationReport:
|
||||
design, protective, info := groupMitigations(sctx)
|
||||
total := len(design) + len(protective) + len(info)
|
||||
b.WriteString("## Massnahmenueberblick\n\n")
|
||||
b.WriteString(fmt.Sprintf("Gesamt: %d Massnahmen\n", total))
|
||||
b.WriteString(fmt.Sprintf("- Design: %d\n- Schutzmassnahmen: %d\n- Benutzerinformation: %d\n\n", len(design), len(protective), len(info)))
|
||||
writeFallbackMitigationList(&b, "Design", design)
|
||||
writeFallbackMitigationList(&b, "Schutzmassnahmen", protective)
|
||||
writeFallbackMitigationList(&b, "Benutzerinformation", info)
|
||||
|
||||
case SectionClassificationReport:
|
||||
if len(sctx.Classifications) > 0 {
|
||||
b.WriteString("## Klassifizierungen\n\n")
|
||||
for _, c := range sctx.Classifications {
|
||||
b.WriteString(fmt.Sprintf("- **%s:** %s (Risiko: %s)\n",
|
||||
string(c.Regulation), c.ClassificationResult, string(c.RiskLevel)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
case SectionEvidenceIndex:
|
||||
b.WriteString("## Nachweisverzeichnis\n\n")
|
||||
b.WriteString(fmt.Sprintf("Anzahl Nachweise: %d\n\n", len(sctx.Evidence)))
|
||||
for _, e := range sctx.Evidence {
|
||||
desc := e.Description
|
||||
if desc == "" {
|
||||
desc = "(keine Beschreibung)"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("- %s — %s\n", e.FileName, desc))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
default:
|
||||
// Generic fallback data summary
|
||||
b.WriteString(fmt.Sprintf("- Komponenten: %d\n", len(sctx.Components)))
|
||||
b.WriteString(fmt.Sprintf("- Gefaehrdungen: %d\n", len(sctx.Hazards)))
|
||||
b.WriteString(fmt.Sprintf("- Klassifizierungen: %d\n", len(sctx.Classifications)))
|
||||
b.WriteString(fmt.Sprintf("- Nachweise: %d\n", len(sctx.Evidence)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("---\n")
|
||||
b.WriteString("*Dieser Abschnitt wurde ohne LLM-Unterstuetzung erstellt und enthaelt nur eine Datenuebersicht. Bitte erneut generieren, wenn der LLM-Service verfuegbar ist.*\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeFallbackMitigationList(b *strings.Builder, title string, measures []Mitigation) {
|
||||
if len(measures) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("### %s\n\n", title))
|
||||
for _, m := range measures {
|
||||
b.WriteString(fmt.Sprintf("- %s [%s]\n", m.Name, string(m.Status)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility helpers
|
||||
// ============================================================================
|
||||
|
||||
func countRiskLevels(sctx *SectionGenerationContext) map[string]int {
|
||||
counts := make(map[string]int)
|
||||
for _, h := range sctx.Hazards {
|
||||
if assessments, ok := sctx.Assessments[h.ID]; ok && len(assessments) > 0 {
|
||||
latest := assessments[len(assessments)-1]
|
||||
counts[string(latest.RiskLevel)]++
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func acceptableLabel(isAcceptable bool) string {
|
||||
if isAcceptable {
|
||||
return "[AKZEPTABEL]"
|
||||
}
|
||||
return "[NICHT AKZEPTABEL]"
|
||||
}
|
||||
|
||||
func sectionDisplayName(sectionType string) string {
|
||||
names := map[string]string{
|
||||
SectionRiskAssessmentReport: "Zusammenfassung der Risikobeurteilung",
|
||||
SectionHazardLogCombined: "Gefaehrdungsprotokoll (Hazard Log)",
|
||||
SectionGeneralDescription: "Allgemeine Maschinenbeschreibung",
|
||||
SectionEssentialRequirements: "Grundlegende Anforderungen (EHSR)",
|
||||
SectionDesignSpecifications: "Konstruktionsdaten und Spezifikationen",
|
||||
SectionTestReports: "Pruefberichte",
|
||||
SectionStandardsApplied: "Angewandte Normen",
|
||||
SectionDeclarationConformity: "EU-Konformitaetserklaerung",
|
||||
SectionAIIntendedPurpose: "Bestimmungsgemaesser Zweck (KI)",
|
||||
SectionAIModelDescription: "KI-Modellbeschreibung",
|
||||
SectionAIRiskManagement: "KI-Risikomanagementsystem",
|
||||
SectionAIHumanOversight: "Menschliche Aufsicht (Human Oversight)",
|
||||
SectionComponentList: "Komponentenliste",
|
||||
SectionClassificationReport: "Klassifizierungsbericht",
|
||||
SectionMitigationReport: "Massnahmenbericht",
|
||||
SectionVerificationReport: "Verifikationsbericht",
|
||||
SectionEvidenceIndex: "Nachweisverzeichnis",
|
||||
SectionInstructionsForUse: "Betriebsanleitung (Gliederung)",
|
||||
SectionMonitoringPlan: "Post-Market-Monitoring-Plan",
|
||||
}
|
||||
if name, ok := names[sectionType]; ok {
|
||||
return name
|
||||
}
|
||||
return sectionType
|
||||
}
|
||||
|
||||
func truncateForPrompt(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
return text[:maxLen] + "..."
|
||||
}
|
||||
521
ai-compliance-sdk/internal/iace/tech_file_generator_test.go
Normal file
521
ai-compliance-sdk/internal/iace/tech_file_generator_test.go
Normal file
@@ -0,0 +1,521 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Test Helpers
|
||||
// ============================================================================
|
||||
|
||||
// newTestSectionContext builds a SectionGenerationContext with realistic sample
|
||||
// data for a "Robot Arm XY-200" industrial robot project.
|
||||
func newTestSectionContext() *SectionGenerationContext {
|
||||
projectID := uuid.New()
|
||||
compSoftwareID := uuid.New()
|
||||
compSensorID := uuid.New()
|
||||
hazardHighID := uuid.New()
|
||||
hazardLowID := uuid.New()
|
||||
|
||||
return &SectionGenerationContext{
|
||||
Project: &Project{
|
||||
ID: projectID,
|
||||
TenantID: uuid.New(),
|
||||
MachineName: "Robot Arm XY-200",
|
||||
MachineType: "industrial_robot",
|
||||
Manufacturer: "TestCorp",
|
||||
Description: "6-axis industrial robot for automotive welding",
|
||||
CEMarkingTarget: "2023/1230",
|
||||
Status: ProjectStatusHazardAnalysis,
|
||||
},
|
||||
Components: []Component{
|
||||
{
|
||||
ID: compSoftwareID,
|
||||
ProjectID: projectID,
|
||||
Name: "SafetyPLC-500",
|
||||
ComponentType: ComponentTypeSoftware,
|
||||
Version: "3.2.1",
|
||||
Description: "Safety-rated programmable logic controller firmware",
|
||||
IsSafetyRelevant: true,
|
||||
IsNetworked: true,
|
||||
},
|
||||
{
|
||||
ID: compSensorID,
|
||||
ProjectID: projectID,
|
||||
Name: "ProxSensor-LiDAR",
|
||||
ComponentType: ComponentTypeSensor,
|
||||
Version: "1.0.0",
|
||||
Description: "LiDAR proximity sensor for collision avoidance",
|
||||
IsSafetyRelevant: false,
|
||||
IsNetworked: false,
|
||||
},
|
||||
},
|
||||
Hazards: []Hazard{
|
||||
{
|
||||
ID: hazardHighID,
|
||||
ProjectID: projectID,
|
||||
ComponentID: compSoftwareID,
|
||||
Name: "Software malfunction causing uncontrolled movement",
|
||||
Description: "Firmware fault leads to unpredictable arm trajectory",
|
||||
Category: "mechanical",
|
||||
SubCategory: "crushing",
|
||||
Status: HazardStatusAssessed,
|
||||
LifecyclePhase: "normal_operation",
|
||||
AffectedPerson: "operator",
|
||||
PossibleHarm: "Severe crushing injury to operator",
|
||||
},
|
||||
{
|
||||
ID: hazardLowID,
|
||||
ProjectID: projectID,
|
||||
ComponentID: compSensorID,
|
||||
Name: "Sensor drift causing delayed stop",
|
||||
Description: "Gradual sensor calibration loss reduces reaction time",
|
||||
Category: "electrical",
|
||||
SubCategory: "sensor_failure",
|
||||
Status: HazardStatusIdentified,
|
||||
LifecyclePhase: "normal_operation",
|
||||
AffectedPerson: "bystander",
|
||||
PossibleHarm: "Minor bruising",
|
||||
},
|
||||
},
|
||||
Assessments: map[uuid.UUID][]RiskAssessment{
|
||||
hazardHighID: {
|
||||
{
|
||||
ID: uuid.New(),
|
||||
HazardID: hazardHighID,
|
||||
Version: 1,
|
||||
AssessmentType: AssessmentTypeInitial,
|
||||
Severity: 5,
|
||||
Exposure: 4,
|
||||
Probability: 3,
|
||||
Avoidance: 2,
|
||||
InherentRisk: 120.0,
|
||||
ResidualRisk: 85.0,
|
||||
RiskLevel: RiskLevelHigh,
|
||||
IsAcceptable: false,
|
||||
},
|
||||
},
|
||||
hazardLowID: {
|
||||
{
|
||||
ID: uuid.New(),
|
||||
HazardID: hazardLowID,
|
||||
Version: 1,
|
||||
AssessmentType: AssessmentTypeInitial,
|
||||
Severity: 2,
|
||||
Exposure: 3,
|
||||
Probability: 2,
|
||||
Avoidance: 4,
|
||||
InherentRisk: 12.0,
|
||||
ResidualRisk: 6.0,
|
||||
RiskLevel: RiskLevelLow,
|
||||
IsAcceptable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Mitigations: map[uuid.UUID][]Mitigation{
|
||||
hazardHighID: {
|
||||
{
|
||||
ID: uuid.New(),
|
||||
HazardID: hazardHighID,
|
||||
ReductionType: ReductionTypeDesign,
|
||||
Name: "Redundant safety controller",
|
||||
Description: "Dual-channel safety PLC with cross-monitoring",
|
||||
Status: MitigationStatusImplemented,
|
||||
},
|
||||
{
|
||||
ID: uuid.New(),
|
||||
HazardID: hazardHighID,
|
||||
ReductionType: ReductionTypeProtective,
|
||||
Name: "Light curtain barrier",
|
||||
Description: "Type 4 safety light curtain around work envelope",
|
||||
Status: MitigationStatusVerified,
|
||||
},
|
||||
},
|
||||
hazardLowID: {
|
||||
{
|
||||
ID: uuid.New(),
|
||||
HazardID: hazardLowID,
|
||||
ReductionType: ReductionTypeInformation,
|
||||
Name: "Calibration schedule warning",
|
||||
Description: "Automated alert when sensor calibration is overdue",
|
||||
Status: MitigationStatusPlanned,
|
||||
},
|
||||
},
|
||||
},
|
||||
Classifications: []RegulatoryClassification{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
ProjectID: projectID,
|
||||
Regulation: RegulationMachineryRegulation,
|
||||
ClassificationResult: "Annex I - High-Risk Machinery",
|
||||
RiskLevel: RiskLevelHigh,
|
||||
Confidence: 0.92,
|
||||
},
|
||||
},
|
||||
Evidence: []Evidence{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
ProjectID: projectID,
|
||||
FileName: "safety_plc_test_report.pdf",
|
||||
Description: "Functional safety test report for SafetyPLC-500",
|
||||
},
|
||||
},
|
||||
RAGContext: "",
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests: buildUserPrompt
|
||||
// ============================================================================
|
||||
|
||||
func TestBuildUserPrompt_RiskAssessmentReport(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
prompt := buildUserPrompt(sctx, "risk_assessment_report")
|
||||
|
||||
// Must contain project identification
|
||||
if !strings.Contains(prompt, "Robot Arm XY-200") {
|
||||
t.Error("prompt should contain machine name 'Robot Arm XY-200'")
|
||||
}
|
||||
if !strings.Contains(prompt, "TestCorp") {
|
||||
t.Error("prompt should contain manufacturer 'TestCorp'")
|
||||
}
|
||||
|
||||
// Must reference hazard information
|
||||
if !strings.Contains(prompt, "uncontrolled movement") || !strings.Contains(prompt, "Software malfunction") {
|
||||
t.Error("prompt should contain hazard name or description for high-risk hazard")
|
||||
}
|
||||
|
||||
// Must reference risk levels
|
||||
if !strings.Contains(prompt, "high") && !strings.Contains(prompt, "High") && !strings.Contains(prompt, "HIGH") {
|
||||
t.Error("prompt should reference the high risk level")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_ComponentList(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
prompt := buildUserPrompt(sctx, "component_list")
|
||||
|
||||
// Must list component names
|
||||
if !strings.Contains(prompt, "SafetyPLC-500") {
|
||||
t.Error("prompt should contain component name 'SafetyPLC-500'")
|
||||
}
|
||||
if !strings.Contains(prompt, "ProxSensor-LiDAR") {
|
||||
t.Error("prompt should contain component name 'ProxSensor-LiDAR'")
|
||||
}
|
||||
|
||||
// Must reference component types
|
||||
if !strings.Contains(prompt, "software") && !strings.Contains(prompt, "Software") {
|
||||
t.Error("prompt should contain component type 'software'")
|
||||
}
|
||||
if !strings.Contains(prompt, "sensor") && !strings.Contains(prompt, "Sensor") {
|
||||
t.Error("prompt should contain component type 'sensor'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_EmptyProject(t *testing.T) {
|
||||
sctx := &SectionGenerationContext{
|
||||
Project: nil,
|
||||
Components: nil,
|
||||
Hazards: nil,
|
||||
Assessments: nil,
|
||||
Mitigations: nil,
|
||||
Classifications: nil,
|
||||
Evidence: nil,
|
||||
RAGContext: "",
|
||||
}
|
||||
|
||||
// Should not panic on nil/empty data
|
||||
prompt := buildUserPrompt(sctx, "general_description")
|
||||
if prompt == "" {
|
||||
t.Error("buildUserPrompt should return non-empty string even for empty context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_MitigationReport(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
prompt := buildUserPrompt(sctx, "mitigation_report")
|
||||
|
||||
// Must reference mitigation names
|
||||
if !strings.Contains(prompt, "Redundant safety controller") {
|
||||
t.Error("prompt should contain design mitigation 'Redundant safety controller'")
|
||||
}
|
||||
if !strings.Contains(prompt, "Light curtain barrier") {
|
||||
t.Error("prompt should contain protective mitigation 'Light curtain barrier'")
|
||||
}
|
||||
if !strings.Contains(prompt, "Calibration schedule warning") {
|
||||
t.Error("prompt should contain information mitigation 'Calibration schedule warning'")
|
||||
}
|
||||
|
||||
// Must reference reduction types
|
||||
hasDesign := strings.Contains(prompt, "design") || strings.Contains(prompt, "Design")
|
||||
hasProtective := strings.Contains(prompt, "protective") || strings.Contains(prompt, "Protective")
|
||||
hasInformation := strings.Contains(prompt, "information") || strings.Contains(prompt, "Information")
|
||||
if !hasDesign {
|
||||
t.Error("prompt should reference 'design' reduction type")
|
||||
}
|
||||
if !hasProtective {
|
||||
t.Error("prompt should reference 'protective' reduction type")
|
||||
}
|
||||
if !hasInformation {
|
||||
t.Error("prompt should reference 'information' reduction type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_WithRAGContext(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
sctx.RAGContext = "According to EN ISO 13849-1:2023, safety-related parts of control systems for machinery shall be designed and constructed using the principles of EN ISO 12100."
|
||||
|
||||
prompt := buildUserPrompt(sctx, "standards_applied")
|
||||
|
||||
if !strings.Contains(prompt, "EN ISO 13849-1") {
|
||||
t.Error("prompt should include the RAG context referencing EN ISO 13849-1")
|
||||
}
|
||||
if !strings.Contains(prompt, "EN ISO 12100") {
|
||||
t.Error("prompt should include the RAG context referencing EN ISO 12100")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_WithoutRAGContext(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
sctx.RAGContext = ""
|
||||
|
||||
prompt := buildUserPrompt(sctx, "standards_applied")
|
||||
|
||||
// Should still produce a valid prompt without RAG context
|
||||
if prompt == "" {
|
||||
t.Error("prompt should be non-empty even without RAG context")
|
||||
}
|
||||
// Should still contain the project info
|
||||
if !strings.Contains(prompt, "Robot Arm XY-200") {
|
||||
t.Error("prompt should still contain machine name when no RAG context")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests: buildRAGQuery
|
||||
// ============================================================================
|
||||
|
||||
func TestBuildRAGQuery_AllSectionTypes(t *testing.T) {
|
||||
sectionTypes := []string{
|
||||
"risk_assessment_report",
|
||||
"hazard_log_combined",
|
||||
"general_description",
|
||||
"essential_requirements",
|
||||
"design_specifications",
|
||||
"test_reports",
|
||||
"standards_applied",
|
||||
"declaration_of_conformity",
|
||||
"ai_intended_purpose",
|
||||
"ai_model_description",
|
||||
"ai_risk_management",
|
||||
"ai_human_oversight",
|
||||
"component_list",
|
||||
"classification_report",
|
||||
"mitigation_report",
|
||||
"verification_report",
|
||||
"evidence_index",
|
||||
"instructions_for_use",
|
||||
"monitoring_plan",
|
||||
}
|
||||
|
||||
for _, st := range sectionTypes {
|
||||
t.Run(st, func(t *testing.T) {
|
||||
q := buildRAGQuery(st)
|
||||
if q == "" {
|
||||
t.Errorf("buildRAGQuery(%q) returned empty string", st)
|
||||
}
|
||||
// Each query should be at least a few words long to be useful
|
||||
if len(q) < 10 {
|
||||
t.Errorf("buildRAGQuery(%q) returned suspiciously short query: %q", st, q)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRAGQuery_UnknownSectionType(t *testing.T) {
|
||||
q := buildRAGQuery("nonexistent_section_type")
|
||||
// Should return a generic fallback query rather than an empty string
|
||||
// (the function needs some query to send to RAG even for unknown types)
|
||||
if q == "" {
|
||||
t.Log("buildRAGQuery returned empty for unknown section type (may be acceptable if caller handles this)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRAGQuery_QueriesAreDifferent(t *testing.T) {
|
||||
// Different section types should produce different queries for targeted retrieval
|
||||
q1 := buildRAGQuery("risk_assessment_report")
|
||||
q2 := buildRAGQuery("declaration_of_conformity")
|
||||
q3 := buildRAGQuery("monitoring_plan")
|
||||
|
||||
if q1 == q2 {
|
||||
t.Error("risk_assessment_report and declaration_of_conformity should have different RAG queries")
|
||||
}
|
||||
if q2 == q3 {
|
||||
t.Error("declaration_of_conformity and monitoring_plan should have different RAG queries")
|
||||
}
|
||||
if q1 == q3 {
|
||||
t.Error("risk_assessment_report and monitoring_plan should have different RAG queries")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests: sectionSystemPrompts
|
||||
// ============================================================================
|
||||
|
||||
func TestSectionSystemPrompts_Coverage(t *testing.T) {
|
||||
requiredTypes := []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",
|
||||
}
|
||||
|
||||
for _, st := range requiredTypes {
|
||||
t.Run(st, func(t *testing.T) {
|
||||
prompt, ok := sectionSystemPrompts[st]
|
||||
if !ok {
|
||||
t.Errorf("sectionSystemPrompts missing entry for %q", st)
|
||||
return
|
||||
}
|
||||
if prompt == "" {
|
||||
t.Errorf("sectionSystemPrompts[%q] is empty", st)
|
||||
}
|
||||
// System prompts should contain meaningful instruction text
|
||||
if len(prompt) < 50 {
|
||||
t.Errorf("sectionSystemPrompts[%q] is suspiciously short (%d chars)", st, len(prompt))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionSystemPrompts_ContainRoleInstruction(t *testing.T) {
|
||||
// Each system prompt should instruct the LLM about its role as a compliance expert.
|
||||
// Prompts are in German, so check for both German and English keywords.
|
||||
keywords := []string{
|
||||
"expert", "engineer", "compliance", "technical", "documentation", "safety",
|
||||
"generate", "write", "create", "produce",
|
||||
// German equivalents
|
||||
"experte", "ingenieur", "erstelle", "beschreibe", "dokumentation", "sicherheit",
|
||||
"risikobeurteilung", "konformit", "norm", "verordnung", "richtlinie",
|
||||
"gefaehrdung", "massnahm", "verifikation", "uebersicht", "protokoll",
|
||||
"abschnitt", "bericht", "maschin",
|
||||
}
|
||||
for st, prompt := range sectionSystemPrompts {
|
||||
t.Run(st, func(t *testing.T) {
|
||||
lower := strings.ToLower(prompt)
|
||||
found := false
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("sectionSystemPrompts[%q] does not appear to contain a role or task instruction", st)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests: buildUserPrompt — additional section types
|
||||
// ============================================================================
|
||||
|
||||
func TestBuildUserPrompt_ClassificationReport(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
prompt := buildUserPrompt(sctx, "classification_report")
|
||||
|
||||
// Must reference classification data
|
||||
if !strings.Contains(prompt, "machinery_regulation") && !strings.Contains(prompt, "Machinery") && !strings.Contains(prompt, "machinery") {
|
||||
t.Error("prompt should reference the machinery regulation classification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_EvidenceIndex(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
prompt := buildUserPrompt(sctx, "evidence_index")
|
||||
|
||||
// Must reference evidence files
|
||||
if !strings.Contains(prompt, "safety_plc_test_report.pdf") {
|
||||
t.Error("prompt should reference evidence file name 'safety_plc_test_report.pdf'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_GeneralDescription(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
prompt := buildUserPrompt(sctx, "general_description")
|
||||
|
||||
// Must contain machine description
|
||||
if !strings.Contains(prompt, "Robot Arm XY-200") {
|
||||
t.Error("prompt should contain machine name")
|
||||
}
|
||||
if !strings.Contains(prompt, "industrial_robot") && !strings.Contains(prompt, "industrial robot") {
|
||||
t.Error("prompt should contain machine type")
|
||||
}
|
||||
if !strings.Contains(prompt, "automotive welding") && !strings.Contains(prompt, "6-axis") {
|
||||
t.Error("prompt should reference machine description content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_DeclarationOfConformity(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
prompt := buildUserPrompt(sctx, "declaration_of_conformity")
|
||||
|
||||
// Declaration needs manufacturer and CE target
|
||||
if !strings.Contains(prompt, "TestCorp") {
|
||||
t.Error("prompt should contain manufacturer for declaration of conformity")
|
||||
}
|
||||
if !strings.Contains(prompt, "2023/1230") && !strings.Contains(prompt, "CE") && !strings.Contains(prompt, "ce_marking") {
|
||||
t.Error("prompt should reference CE marking target or regulation for declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserPrompt_MultipleHazardAssessments(t *testing.T) {
|
||||
sctx := newTestSectionContext()
|
||||
|
||||
// Find the high-risk hazard ID from the assessments map
|
||||
var highHazardID uuid.UUID
|
||||
for hid, assessments := range sctx.Assessments {
|
||||
if len(assessments) > 0 && assessments[0].RiskLevel == RiskLevelHigh {
|
||||
highHazardID = hid
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if highHazardID != uuid.Nil {
|
||||
// Add a second assessment version (post-mitigation) for the high-risk hazard
|
||||
sctx.Assessments[highHazardID] = append(sctx.Assessments[highHazardID], RiskAssessment{
|
||||
ID: uuid.New(),
|
||||
HazardID: highHazardID,
|
||||
Version: 2,
|
||||
AssessmentType: AssessmentTypePostMitigation,
|
||||
Severity: 5,
|
||||
Exposure: 4,
|
||||
Probability: 1,
|
||||
Avoidance: 4,
|
||||
InherentRisk: 120.0,
|
||||
ResidualRisk: 20.0,
|
||||
RiskLevel: RiskLevelMedium,
|
||||
IsAcceptable: true,
|
||||
})
|
||||
}
|
||||
|
||||
prompt := buildUserPrompt(sctx, "risk_assessment_report")
|
||||
if prompt == "" {
|
||||
t.Error("prompt should not be empty with multiple assessments")
|
||||
}
|
||||
}
|
||||
487
ai-compliance-sdk/internal/iace/user_journey_test.go
Normal file
487
ai-compliance-sdk/internal/iace/user_journey_test.go
Normal file
@@ -0,0 +1,487 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// buildFullValidContext returns a CompletenessContext where all required gates pass
|
||||
// and the project is ready for CE export.
|
||||
func buildFullValidContext() *CompletenessContext {
|
||||
projectID := uuid.New()
|
||||
componentID1 := uuid.New()
|
||||
componentID2 := uuid.New()
|
||||
hazardID1 := uuid.New()
|
||||
hazardID2 := uuid.New()
|
||||
hazardID3 := uuid.New()
|
||||
mitigationID1 := uuid.New()
|
||||
mitigationID2 := uuid.New()
|
||||
mitigationID3 := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
metadata, _ := json.Marshal(map[string]interface{}{
|
||||
"operating_limits": "Temperature: -10 to 50C, Humidity: 10-90% RH",
|
||||
"foreseeable_misuse": "Use without protective equipment, exceeding load capacity",
|
||||
})
|
||||
|
||||
return &CompletenessContext{
|
||||
Project: &Project{
|
||||
ID: projectID,
|
||||
TenantID: uuid.New(),
|
||||
MachineName: "CNC-Fraese ProLine 5000",
|
||||
MachineType: "cnc_milling_machine",
|
||||
Manufacturer: "BreakPilot Maschinenbau GmbH",
|
||||
Description: "5-Achsen CNC-Fraesmaschine fuer Praezisionsfertigung im Metallbau",
|
||||
Status: ProjectStatusTechFile,
|
||||
CEMarkingTarget: "2023/1230",
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Components: []Component{
|
||||
{
|
||||
ID: componentID1,
|
||||
ProjectID: projectID,
|
||||
Name: "Spindelantrieb",
|
||||
ComponentType: ComponentTypeMechanical,
|
||||
IsSafetyRelevant: true,
|
||||
Description: "Hauptspindelantrieb mit Drehzahlregelung",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: componentID2,
|
||||
ProjectID: projectID,
|
||||
Name: "SPS Steuerung",
|
||||
ComponentType: ComponentTypeController,
|
||||
IsSafetyRelevant: false,
|
||||
IsNetworked: true,
|
||||
Description: "Programmierbare Steuerung fuer Achsenbewegung",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
Classifications: []RegulatoryClassification{
|
||||
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationAIAct, ClassificationResult: "Not applicable", RiskLevel: RiskLevelNegligible, Confidence: 0.95, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationMachineryRegulation, ClassificationResult: "Annex I", RiskLevel: RiskLevelHigh, Confidence: 0.9, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationNIS2, ClassificationResult: "Not in scope", RiskLevel: RiskLevelLow, Confidence: 0.85, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: uuid.New(), ProjectID: projectID, Regulation: RegulationCRA, ClassificationResult: "Default category", RiskLevel: RiskLevelMedium, Confidence: 0.88, CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
Hazards: []Hazard{
|
||||
{ID: hazardID1, ProjectID: projectID, ComponentID: componentID1, Name: "Quetschgefahr Spindel", Category: "mechanical", Description: "Quetschgefahr beim Werkzeugwechsel", Status: HazardStatusMitigated, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: hazardID2, ProjectID: projectID, ComponentID: componentID1, Name: "Schnittverletzung", Category: "mechanical", Description: "Schnittverletzung durch rotierende Fraeser", Status: HazardStatusMitigated, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: hazardID3, ProjectID: projectID, ComponentID: componentID2, Name: "Elektrischer Schlag", Category: "electrical", Description: "Kontakt mit spannungsfuehrenden Teilen", Status: HazardStatusAccepted, CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
Assessments: []RiskAssessment{
|
||||
{ID: uuid.New(), HazardID: hazardID1, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 3, Exposure: 2, Probability: 2, InherentRisk: 12, ControlMaturity: 4, ControlCoverage: 0.85, TestEvidenceStrength: 0.8, CEff: 0.75, ResidualRisk: 3.0, RiskLevel: RiskLevelLow, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
|
||||
{ID: uuid.New(), HazardID: hazardID2, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 2, Exposure: 2, Probability: 1, InherentRisk: 4, ControlMaturity: 3, ControlCoverage: 0.9, TestEvidenceStrength: 0.7, CEff: 0.8, ResidualRisk: 0.8, RiskLevel: RiskLevelNegligible, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
|
||||
{ID: uuid.New(), HazardID: hazardID3, Version: 1, AssessmentType: AssessmentTypePostMitigation, Severity: 2, Exposure: 1, Probability: 1, InherentRisk: 2, ControlMaturity: 4, ControlCoverage: 0.95, TestEvidenceStrength: 0.9, CEff: 0.9, ResidualRisk: 0.2, RiskLevel: RiskLevelNegligible, IsAcceptable: true, AssessedBy: uuid.New(), CreatedAt: now},
|
||||
},
|
||||
Mitigations: []Mitigation{
|
||||
{ID: mitigationID1, HazardID: hazardID1, ReductionType: ReductionTypeDesign, Name: "Schutzhaube mit Verriegelung", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodTest, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
|
||||
{ID: mitigationID2, HazardID: hazardID2, ReductionType: ReductionTypeProtective, Name: "Lichtschranke Arbeitsbereich", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodInspection, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
|
||||
{ID: mitigationID3, HazardID: hazardID3, ReductionType: ReductionTypeInformation, Name: "Warnhinweis Hochspannung", Status: MitigationStatusVerified, VerificationMethod: VerificationMethodReview, VerificationResult: "Bestanden", CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
Evidence: []Evidence{
|
||||
{ID: uuid.New(), ProjectID: projectID, MitigationID: &mitigationID1, FileName: "pruefbericht_schutzhaube.pdf", FilePath: "/evidence/pruefbericht_schutzhaube.pdf", FileHash: "sha256:abc123", FileSize: 524288, MimeType: "application/pdf", Description: "Pruefbericht Schutzhaubenverriegelung", UploadedBy: uuid.New(), CreatedAt: now},
|
||||
{ID: uuid.New(), ProjectID: projectID, MitigationID: &mitigationID2, FileName: "lichtschranke_abnahme.pdf", FilePath: "/evidence/lichtschranke_abnahme.pdf", FileHash: "sha256:def456", FileSize: 1048576, MimeType: "application/pdf", Description: "Abnahmeprotokoll Lichtschranke", UploadedBy: uuid.New(), CreatedAt: now},
|
||||
},
|
||||
TechFileSections: []TechFileSection{
|
||||
{ID: uuid.New(), ProjectID: projectID, SectionType: "risk_assessment_report", Title: "Risikobeurteilung nach ISO 12100", Content: "Vollstaendige Risikobeurteilung der CNC-Fraese...", Version: 1, Status: TechFileSectionStatusApproved, CreatedAt: now, UpdatedAt: now},
|
||||
{ID: uuid.New(), ProjectID: projectID, SectionType: "hazard_log_combined", Title: "Gefaehrdungsprotokoll", Content: "Protokoll aller identifizierten Gefaehrdungen...", Version: 1, Status: TechFileSectionStatusApproved, CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
HasAI: false,
|
||||
PatternMatchingPerformed: true,
|
||||
}
|
||||
}
|
||||
|
||||
// findGate searches through a CompletenessResult for a gate with the given ID.
|
||||
// Returns the gate and true if found, zero-value gate and false otherwise.
|
||||
func findGate(result CompletenessResult, gateID string) (CompletenessGate, bool) {
|
||||
for _, g := range result.Gates {
|
||||
if g.ID == gateID {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return CompletenessGate{}, false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 1: Full User Journey
|
||||
// ============================================================================
|
||||
|
||||
func TestCEWorkflow_FullUserJourney(t *testing.T) {
|
||||
ctx := buildFullValidContext()
|
||||
checker := NewCompletenessChecker()
|
||||
|
||||
// Step 1: Verify completeness check passes all required gates
|
||||
result := checker.Check(ctx)
|
||||
|
||||
if !result.CanExport {
|
||||
t.Error("CanExport should be true for fully valid project")
|
||||
for _, g := range result.Gates {
|
||||
if g.Required && !g.Passed {
|
||||
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.PassedRequired != result.TotalRequired {
|
||||
t.Errorf("PassedRequired = %d, TotalRequired = %d; want all required gates to pass",
|
||||
result.PassedRequired, result.TotalRequired)
|
||||
}
|
||||
|
||||
// All required gates should individually pass
|
||||
for _, g := range result.Gates {
|
||||
if g.Required && !g.Passed {
|
||||
t.Errorf("Required gate %s (%s) did not pass: %s", g.ID, g.Label, g.Details)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Export PDF and verify output
|
||||
exporter := NewDocumentExporter()
|
||||
|
||||
pdfData, err := exporter.ExportPDF(
|
||||
ctx.Project,
|
||||
ctx.TechFileSections,
|
||||
ctx.Hazards,
|
||||
ctx.Assessments,
|
||||
ctx.Mitigations,
|
||||
ctx.Classifications,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportPDF returned error: %v", err)
|
||||
}
|
||||
if len(pdfData) == 0 {
|
||||
t.Fatal("ExportPDF returned empty bytes")
|
||||
}
|
||||
if !bytes.HasPrefix(pdfData, []byte("%PDF-")) {
|
||||
t.Errorf("PDF output does not start with %%PDF-, got first 10 bytes: %q", pdfData[:min(10, len(pdfData))])
|
||||
}
|
||||
|
||||
// Step 3: Export Excel and verify output
|
||||
xlsxData, err := exporter.ExportExcel(
|
||||
ctx.Project,
|
||||
ctx.TechFileSections,
|
||||
ctx.Hazards,
|
||||
ctx.Assessments,
|
||||
ctx.Mitigations,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportExcel returned error: %v", err)
|
||||
}
|
||||
if len(xlsxData) == 0 {
|
||||
t.Fatal("ExportExcel returned empty bytes")
|
||||
}
|
||||
if !bytes.HasPrefix(xlsxData, []byte("PK")) {
|
||||
t.Errorf("Excel output does not start with PK (zip signature), got first 4 bytes: %x", xlsxData[:min(4, len(xlsxData))])
|
||||
}
|
||||
|
||||
// Step 4: Export Markdown and verify output contains section titles
|
||||
mdData, err := exporter.ExportMarkdown(ctx.Project, ctx.TechFileSections)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportMarkdown returned error: %v", err)
|
||||
}
|
||||
if len(mdData) == 0 {
|
||||
t.Fatal("ExportMarkdown returned empty bytes")
|
||||
}
|
||||
mdContent := string(mdData)
|
||||
for _, section := range ctx.TechFileSections {
|
||||
if !strings.Contains(mdContent, section.Title) {
|
||||
t.Errorf("Markdown output missing section title %q", section.Title)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(mdContent, ctx.Project.MachineName) {
|
||||
t.Errorf("Markdown output missing project name %q", ctx.Project.MachineName)
|
||||
}
|
||||
|
||||
// Step 5: Export DOCX and verify output
|
||||
docxData, err := exporter.ExportDOCX(ctx.Project, ctx.TechFileSections)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportDOCX returned error: %v", err)
|
||||
}
|
||||
if len(docxData) == 0 {
|
||||
t.Fatal("ExportDOCX returned empty bytes")
|
||||
}
|
||||
if !bytes.HasPrefix(docxData, []byte("PK")) {
|
||||
t.Errorf("DOCX output does not start with PK (zip signature), got first 4 bytes: %x", docxData[:min(4, len(docxData))])
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 2: High Risk Not Acceptable blocks export
|
||||
// ============================================================================
|
||||
|
||||
func TestCEWorkflow_HighRiskNotAcceptable(t *testing.T) {
|
||||
ctx := buildFullValidContext()
|
||||
|
||||
// Override: make one hazard high-risk and not acceptable
|
||||
hazardID := ctx.Hazards[0].ID
|
||||
ctx.Assessments[0] = RiskAssessment{
|
||||
ID: uuid.New(),
|
||||
HazardID: hazardID,
|
||||
Version: 2,
|
||||
AssessmentType: AssessmentTypePostMitigation,
|
||||
Severity: 5,
|
||||
Exposure: 4,
|
||||
Probability: 4,
|
||||
InherentRisk: 80,
|
||||
ControlMaturity: 2,
|
||||
ControlCoverage: 0.3,
|
||||
CEff: 0.2,
|
||||
ResidualRisk: 64,
|
||||
RiskLevel: RiskLevelHigh,
|
||||
IsAcceptable: false,
|
||||
AssessedBy: uuid.New(),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
checker := NewCompletenessChecker()
|
||||
result := checker.Check(ctx)
|
||||
|
||||
if result.CanExport {
|
||||
t.Error("CanExport should be false when a high-risk hazard is not acceptable")
|
||||
}
|
||||
|
||||
// Verify G24 (residual risk accepted) specifically fails
|
||||
g24, found := findGate(result, "G24")
|
||||
if !found {
|
||||
t.Fatal("G24 gate not found in results")
|
||||
}
|
||||
if g24.Passed {
|
||||
t.Error("G24 should fail when a hazard has RiskLevelHigh and IsAcceptable=false")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 3: Incomplete mitigations block export
|
||||
// ============================================================================
|
||||
|
||||
func TestCEWorkflow_IncompleteMitigationsBlockExport(t *testing.T) {
|
||||
ctx := buildFullValidContext()
|
||||
|
||||
// Override: set mitigations to planned status (not yet verified)
|
||||
for i := range ctx.Mitigations {
|
||||
ctx.Mitigations[i].Status = MitigationStatusPlanned
|
||||
}
|
||||
|
||||
checker := NewCompletenessChecker()
|
||||
result := checker.Check(ctx)
|
||||
|
||||
if result.CanExport {
|
||||
t.Error("CanExport should be false when mitigations are in planned status")
|
||||
}
|
||||
|
||||
// Verify G23 (mitigations verified) specifically fails
|
||||
g23, found := findGate(result, "G23")
|
||||
if !found {
|
||||
t.Fatal("G23 gate not found in results")
|
||||
}
|
||||
if g23.Passed {
|
||||
t.Error("G23 should fail when mitigations are still in planned status")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 4: Mitigation hierarchy warning (information-only still allows export)
|
||||
// ============================================================================
|
||||
|
||||
func TestCEWorkflow_MitigationHierarchyWarning(t *testing.T) {
|
||||
ctx := buildFullValidContext()
|
||||
|
||||
// Override: set all mitigations to information type only (no design or protective)
|
||||
for i := range ctx.Mitigations {
|
||||
ctx.Mitigations[i].ReductionType = ReductionTypeInformation
|
||||
ctx.Mitigations[i].Status = MitigationStatusVerified
|
||||
}
|
||||
|
||||
checker := NewCompletenessChecker()
|
||||
result := checker.Check(ctx)
|
||||
|
||||
// Information-only mitigations are advisory; no gate blocks this scenario.
|
||||
// The project should still be exportable.
|
||||
if !result.CanExport {
|
||||
t.Error("CanExport should be true even with information-only mitigations (advisory, not gated)")
|
||||
for _, g := range result.Gates {
|
||||
if g.Required && !g.Passed {
|
||||
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all required gates still pass
|
||||
if result.PassedRequired != result.TotalRequired {
|
||||
t.Errorf("PassedRequired = %d, TotalRequired = %d; want all required gates to pass with information-only mitigations",
|
||||
result.PassedRequired, result.TotalRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 5: AI components require extra tech file sections
|
||||
// ============================================================================
|
||||
|
||||
func TestCEWorkflow_AIComponentsExtraSections(t *testing.T) {
|
||||
checker := NewCompletenessChecker()
|
||||
|
||||
t.Run("AI without AI tech file sections fails G42", func(t *testing.T) {
|
||||
ctx := buildFullValidContext()
|
||||
ctx.HasAI = true
|
||||
// Add AI Act classification (needed for G06 to pass with HasAI=true)
|
||||
for i := range ctx.Classifications {
|
||||
if ctx.Classifications[i].Regulation == RegulationAIAct {
|
||||
ctx.Classifications[i].ClassificationResult = "High Risk"
|
||||
ctx.Classifications[i].RiskLevel = RiskLevelHigh
|
||||
}
|
||||
}
|
||||
// TechFileSections has risk_assessment_report and hazard_log_combined but no AI sections
|
||||
|
||||
result := checker.Check(ctx)
|
||||
|
||||
g42, found := findGate(result, "G42")
|
||||
if !found {
|
||||
t.Fatal("G42 gate not found in results")
|
||||
}
|
||||
if g42.Passed {
|
||||
t.Error("G42 should fail when HasAI=true but AI tech file sections are missing")
|
||||
}
|
||||
if result.CanExport {
|
||||
t.Error("CanExport should be false when G42 fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AI with AI tech file sections passes G42", func(t *testing.T) {
|
||||
ctx := buildFullValidContext()
|
||||
ctx.HasAI = true
|
||||
// Add AI Act classification
|
||||
for i := range ctx.Classifications {
|
||||
if ctx.Classifications[i].Regulation == RegulationAIAct {
|
||||
ctx.Classifications[i].ClassificationResult = "High Risk"
|
||||
ctx.Classifications[i].RiskLevel = RiskLevelHigh
|
||||
}
|
||||
}
|
||||
// Add the required AI tech file sections
|
||||
now := time.Now()
|
||||
ctx.TechFileSections = append(ctx.TechFileSections,
|
||||
TechFileSection{
|
||||
ID: uuid.New(),
|
||||
ProjectID: ctx.Project.ID,
|
||||
SectionType: "ai_intended_purpose",
|
||||
Title: "KI-Zweckbestimmung",
|
||||
Content: "Bestimmungsgemaesse Verwendung des KI-Systems...",
|
||||
Version: 1,
|
||||
Status: TechFileSectionStatusApproved,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
TechFileSection{
|
||||
ID: uuid.New(),
|
||||
ProjectID: ctx.Project.ID,
|
||||
SectionType: "ai_model_description",
|
||||
Title: "KI-Modellbeschreibung",
|
||||
Content: "Beschreibung des verwendeten KI-Modells...",
|
||||
Version: 1,
|
||||
Status: TechFileSectionStatusApproved,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
)
|
||||
|
||||
result := checker.Check(ctx)
|
||||
|
||||
g42, found := findGate(result, "G42")
|
||||
if !found {
|
||||
t.Fatal("G42 gate not found in results")
|
||||
}
|
||||
if !g42.Passed {
|
||||
t.Errorf("G42 should pass when HasAI=true and both AI tech file sections are present; details: %s", g42.Details)
|
||||
}
|
||||
if !result.CanExport {
|
||||
t.Error("CanExport should be true when all gates pass including G42 with AI sections")
|
||||
for _, g := range result.Gates {
|
||||
if g.Required && !g.Passed {
|
||||
t.Errorf(" Required gate %s (%s) failed: %s", g.ID, g.Label, g.Details)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 6: Export with empty/minimal project data
|
||||
// ============================================================================
|
||||
|
||||
func TestCEWorkflow_ExportEmptyProject(t *testing.T) {
|
||||
exporter := NewDocumentExporter()
|
||||
|
||||
minimalProject := &Project{
|
||||
ID: uuid.New(),
|
||||
TenantID: uuid.New(),
|
||||
MachineName: "Leeres Testprojekt",
|
||||
MachineType: "test",
|
||||
Manufacturer: "TestCorp",
|
||||
Status: ProjectStatusDraft,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
t.Run("PDF export with empty project succeeds", func(t *testing.T) {
|
||||
data, err := exporter.ExportPDF(minimalProject, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportPDF returned error for empty project: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportPDF returned empty bytes for empty project")
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte("%PDF-")) {
|
||||
t.Errorf("PDF output does not start with %%PDF-, got first 10 bytes: %q", data[:min(10, len(data))])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Excel export with empty project succeeds", func(t *testing.T) {
|
||||
data, err := exporter.ExportExcel(minimalProject, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportExcel returned error for empty project: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportExcel returned empty bytes for empty project")
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte("PK")) {
|
||||
t.Errorf("Excel output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Markdown export with empty project succeeds", func(t *testing.T) {
|
||||
data, err := exporter.ExportMarkdown(minimalProject, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportMarkdown returned error for empty project: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportMarkdown returned empty bytes for empty project")
|
||||
}
|
||||
mdContent := string(data)
|
||||
if !strings.Contains(mdContent, minimalProject.MachineName) {
|
||||
t.Errorf("Markdown output missing project name %q", minimalProject.MachineName)
|
||||
}
|
||||
if !strings.Contains(mdContent, "#") {
|
||||
t.Error("Markdown output missing header markers")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DOCX export with empty project succeeds", func(t *testing.T) {
|
||||
data, err := exporter.ExportDOCX(minimalProject, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportDOCX returned error for empty project: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("ExportDOCX returned empty bytes for empty project")
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte("PK")) {
|
||||
t.Errorf("DOCX output does not start with PK (zip signature), got first 4 bytes: %x", data[:min(4, len(data))])
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user