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:
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user