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

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:
Benjamin Admin
2026-03-16 12:50:53 +01:00
parent 5adb1c5f16
commit 6d2de9b897
16 changed files with 5828 additions and 161 deletions

View File

@@ -1,7 +1,8 @@
'use client'
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { useParams } from 'next/navigation'
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
interface TechFileSection {
id: string
@@ -67,6 +68,14 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: str
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
}
const EXPORT_FORMATS: { value: string; label: string; extension: string }[] = [
{ value: 'pdf', label: 'PDF', extension: '.pdf' },
{ value: 'xlsx', label: 'Excel', extension: '.xlsx' },
{ value: 'docx', label: 'Word', extension: '.docx' },
{ value: 'md', label: 'Markdown', extension: '.md' },
{ value: 'json', label: 'JSON', extension: '.json' },
]
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
return (
@@ -87,7 +96,6 @@ function SectionViewer({
onApprove: (id: string) => void
onSave: (id: string, content: string) => void
}) {
const [editedContent, setEditedContent] = useState(section.content || '')
const [editing, setEditing] = useState(false)
return (
@@ -111,13 +119,10 @@ function SectionViewer({
)}
{editing && (
<button
onClick={() => {
onSave(section.id, editedContent)
setEditing(false)
}}
className="text-sm px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
onClick={() => setEditing(false)}
className="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Speichern
Fertig
</button>
)}
{section.status !== 'approved' && section.content && !editing && (
@@ -136,19 +141,19 @@ function SectionViewer({
</div>
</div>
<div className="p-6">
{editing ? (
<textarea
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
rows={20}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
) : section.content ? (
<div className="prose prose-sm max-w-none dark:prose-invert">
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-750 p-4 rounded-lg">
{section.content}
</pre>
</div>
{section.content ? (
editing ? (
<TechFileEditor
content={section.content}
onSave={(html) => onSave(section.id, html)}
/>
) : (
<TechFileEditor
content={section.content}
onSave={() => {}}
readOnly
/>
)
) : (
<div className="text-center py-8 text-gray-500">
Kein Inhalt vorhanden. Klicken Sie &quot;Generieren&quot; um den Abschnitt zu erstellen.
@@ -167,6 +172,21 @@ export default function TechFilePage() {
const [generatingSection, setGeneratingSection] = useState<string | null>(null)
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
const [exporting, setExporting] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
const exportMenuRef = useRef<HTMLDivElement>(null)
// Close export menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (exportMenuRef.current && !exportMenuRef.current.contains(event.target as Node)) {
setShowExportMenu(false)
}
}
if (showExportMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [showExportMenu])
useEffect(() => {
fetchSections()
@@ -236,18 +256,22 @@ export default function TechFilePage() {
}
}
async function handleExportZip() {
async function handleExport(format: string) {
setExporting(true)
setShowExportMenu(false)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
method: 'POST',
})
const res = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/tech-file/export?format=${format}`,
{ method: 'GET' }
)
if (res.ok) {
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const formatConfig = EXPORT_FORMATS.find((f) => f.value === format)
const extension = formatConfig?.extension || `.${format}`
const a = document.createElement('a')
a.href = url
a.download = `CE-Akte-${projectId}.zip`
a.download = `CE-Akte-${projectId}${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -284,25 +308,45 @@ export default function TechFilePage() {
Sie alle erforderlichen Abschnitte.
</p>
</div>
<button
onClick={handleExportZip}
disabled={!allRequiredApproved || exporting}
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte als ZIP exportieren'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
allRequiredApproved && !exporting
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{exporting ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
{/* Export Dropdown */}
<div className="relative" ref={exportMenuRef}>
<button
onClick={() => setShowExportMenu((prev) => !prev)}
disabled={!allRequiredApproved || exporting}
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte exportieren'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
allRequiredApproved && !exporting
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{exporting ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
Exportieren
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showExportMenu && allRequiredApproved && !exporting && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.value}
onClick={() => handleExport(fmt.value)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
>
<span className="text-xs font-mono uppercase w-10 text-gray-400">{fmt.extension}</span>
<span>{fmt.label}</span>
</button>
))}
</div>
)}
ZIP exportieren
</button>
</div>
</div>
{/* Progress */}

View File

@@ -0,0 +1,272 @@
'use client'
import React, { useCallback, useEffect, useRef } from 'react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import Image from '@tiptap/extension-image'
interface TechFileEditorProps {
content: string
onSave: (content: string) => void
readOnly?: boolean
}
function normalizeContent(content: string): string {
if (!content) return '<p></p>'
const trimmed = content.trim()
// If it looks like JSON array or has no HTML tags, wrap in <p>
if (trimmed.startsWith('[') || !/<[a-z][\s\S]*>/i.test(trimmed)) {
return `<p>${trimmed.replace(/\n/g, '</p><p>')}</p>`
}
return trimmed
}
interface ToolbarButtonProps {
onClick: () => void
isActive?: boolean
disabled?: boolean
title: string
children: React.ReactNode
}
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`p-1.5 rounded text-sm font-medium transition-colors ${
isActive
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
{children}
</button>
)
}
export function TechFileEditor({ content, onSave, readOnly = false }: TechFileEditorProps) {
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const onSaveRef = useRef(onSave)
onSaveRef.current = onSave
const debouncedSave = useCallback((html: string) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
onSaveRef.current(html)
}, 3000)
}, [])
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4] },
}),
Table.configure({
resizable: true,
HTMLAttributes: { class: 'border-collapse border border-gray-300' },
}),
TableRow,
TableHeader,
TableCell,
Image.configure({
HTMLAttributes: { class: 'max-w-full rounded' },
}),
],
content: normalizeContent(content),
editable: !readOnly,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
if (!readOnly) {
debouncedSave(ed.getHTML())
}
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none dark:prose-invert focus:outline-none min-h-[300px] px-4 py-3',
},
},
})
// Update content when parent prop changes
useEffect(() => {
if (editor && content) {
const normalized = normalizeContent(content)
const currentHTML = editor.getHTML()
if (normalized !== currentHTML) {
editor.commands.setContent(normalized)
}
}
}, [content, editor])
// Update editable state when readOnly changes
useEffect(() => {
if (editor) {
editor.setEditable(!readOnly)
}
}, [readOnly, editor])
// Cleanup debounce timer
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [])
if (!editor) {
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[300px] flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
</div>
)
}
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Toolbar */}
{!readOnly && (
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
{/* Text formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Fett (Ctrl+B)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Kursiv (Ctrl+I)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Headings */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Ueberschrift 2"
>
<span className="text-xs font-bold">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Ueberschrift 3"
>
<span className="text-xs font-bold">H3</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
isActive={editor.isActive('heading', { level: 4 })}
title="Ueberschrift 4"
>
<span className="text-xs font-bold">H4</span>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Lists */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Aufzaehlung"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Nummerierte Liste"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Table */}
<ToolbarButton
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
title="Tabelle einfuegen (3x3)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
</ToolbarButton>
{/* Blockquote */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Zitat"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</svg>
</ToolbarButton>
{/* Code Block */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')}
title="Code-Block"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16,18 22,12 16,6" />
<polyline points="8,6 2,12 8,18" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Rueckgaengig (Ctrl+Z)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Wiederholen (Ctrl+Shift+Z)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z" />
</svg>
</ToolbarButton>
</div>
)}
{/* Editor Content */}
<EditorContent editor={editor} />
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,14 @@
"test:all": "vitest run && playwright test --project=chromium"
},
"dependencies": {
"@tiptap/extension-image": "^3.20.2",
"@tiptap/extension-table": "^3.20.2",
"@tiptap/extension-table-cell": "^3.20.2",
"@tiptap/extension-table-header": "^3.20.2",
"@tiptap/extension-table-row": "^3.20.2",
"@tiptap/pm": "^3.20.2",
"@tiptap/react": "^3.20.2",
"@tiptap/starter-kit": "^3.20.2",
"bpmn-js": "^18.0.1",
"jspdf": "^4.1.0",
"jszip": "^3.10.1",

View File

@@ -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

View File

@@ -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",
})
}
}
// ============================================================================

View File

@@ -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
},
},

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View 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)
}
})
}
}

View File

@@ -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
// ============================================================================

View 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] + "..."
}

View 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")
}
}

View 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))])
}
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -297,9 +297,14 @@ Fuer die Verifikation stehen **50 Nachweistypen** zur Auswahl:
| GET | `/sdk/v1/iace/hazard-library` | Alle Gefaehrdungen (150+) |
| GET | `/sdk/v1/iace/controls-library` | Alle Controls (200) |
| GET | `/sdk/v1/iace/protective-measures-library` | Schutzmassnahmen-Bibliothek (160) |
| GET | `/sdk/v1/iace/component-library` | Komponenten-Bibliothek (C001-C120) |
| GET | `/sdk/v1/iace/energy-sources` | Energiequellen (EN01-EN20) |
| GET | `/sdk/v1/iace/hazard-patterns` | Gefaehrdungs-Patterns (102) |
| GET | `/sdk/v1/iace/tags` | Tag-Taxonomie |
| GET | `/sdk/v1/iace/lifecycle-phases` | 25 Lebensphasen (DE/EN) |
| GET | `/sdk/v1/iace/roles` | 20 betroffene Personengruppen (DE/EN) |
| GET | `/sdk/v1/iace/evidence-types` | 50 Nachweistypen in 7 Kategorien |
| POST | `/sdk/v1/iace/library-search` | RAG-Bibliothekssuche |
### Projektmanagement
@@ -311,12 +316,19 @@ Fuer die Verifikation stehen **50 Nachweistypen** zur Auswahl:
| PUT | `/sdk/v1/iace/projects/:id` | Projekt aktualisieren |
| DELETE | `/sdk/v1/iace/projects/:id` | Projekt archivieren |
### Onboarding
### Onboarding & Profil-Import
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| POST | `/sdk/v1/iace/projects/:id/init-from-profile` | Projekt aus Company-Profile initialisieren |
| POST | `/sdk/v1/iace/projects/:id/completeness-check` | 25-Gates-Pruefung |
| POST | `/sdk/v1/iace/projects/:id/completeness-check` | 22-Gates-Pruefung |
Der `init-from-profile` Endpoint uebernimmt Daten aus dem Company-Profile und Compliance-Scope:
- **company_profile** → Hersteller-Name, Kontaktdaten
- **compliance_scope** → Maschinenname, Typ, Zweckbeschreibung, Software/Firmware/KI-Flags
- Erstellt automatisch initiale Komponenten (Software, Firmware, KI-Modell, Netzwerk)
- Triggert initiale regulatorische Klassifizierungen fuer anwendbare Verordnungen
### Komponenten
@@ -364,9 +376,48 @@ Fuer die Verifikation stehen **50 Nachweistypen** zur Auswahl:
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/sdk/v1/iace/projects/:id/tech-file` | Technische Akte abrufen |
| POST | `/sdk/v1/iace/projects/:id/tech-file/generate` | Akte generieren |
| GET | `/sdk/v1/iace/projects/:id/tech-file/export` | Akte exportieren (PDF/Markdown) |
| PUT | `/sdk/v1/iace/projects/:id/tech-file/sections/:sid` | Abschnitt aktualisieren |
| POST | `/sdk/v1/iace/projects/:id/tech-file/generate` | Alle Sektionen generieren (LLM-basiert) |
| POST | `/sdk/v1/iace/projects/:id/tech-file/:section/generate` | Einzelne Sektion (re-)generieren (LLM) |
| PUT | `/sdk/v1/iace/projects/:id/tech-file/:section` | Abschnitt manuell aktualisieren |
| POST | `/sdk/v1/iace/projects/:id/tech-file/:section/approve` | Abschnitt freigeben |
| POST | `/sdk/v1/iace/projects/:id/tech-file/:section/enrich` | Abschnitt mit RAG-Kontext anreichern |
| GET | `/sdk/v1/iace/projects/:id/tech-file/export?format=` | Akte exportieren (pdf/xlsx/docx/md/json) |
#### Export-Formate
| Format | MIME-Type | Inhalt |
|--------|-----------|--------|
| `pdf` | application/pdf | Vollstaendige CE-Akte mit Deckblatt, Inhaltsverzeichnis, Risikomatrix, Gefaehrdungsprotokoll |
| `xlsx` | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | 5 Worksheets: Uebersicht, Gefaehrdungsprotokoll, Massnahmen, Risikomatrix, Sektionen |
| `docx` | application/vnd.openxmlformats-officedocument.wordprocessingml.document | Word-Dokument mit allen Sektionen als formatierte Absaetze |
| `md` | text/markdown | Markdown-Dokument mit Projekt-Metadaten und allen Sektionen |
| `json` | application/json | JSON-Export mit Projekt, Sektionen, Klassifizierungen, Risikouebersicht |
#### LLM-basierte Sektionsgenerierung (19 Sektionstypen)
Die Tech-File-Generierung nutzt LLM (Ollama/Anthropic) mit RAG-Kontext aus dem CE-Corpus:
| Sektion | Beschreibung |
|---------|--------------|
| `general_description` | Allgemeine Maschinenbeschreibung |
| `risk_assessment_report` | Zusammenfassung der Risikobeurteilung |
| `hazard_log_combined` | Tabellarisches Gefaehrdungsprotokoll |
| `essential_requirements` | Grundlegende Anforderungen (MVO Anhang III) |
| `design_specifications` | Konstruktionsdaten und Zeichnungen |
| `test_reports` | Pruefberichte und Verifikationsergebnisse |
| `standards_applied` | Angewandte harmonisierte Normen |
| `declaration_of_conformity` | EU-Konformitaetserklaerung (MVO Anhang IV) |
| `component_list` | Komponentenverzeichnis |
| `classification_report` | Regulatorische Klassifikation |
| `mitigation_report` | Massnahmen nach 3-Stufen-Hierarchie |
| `verification_report` | Verifikationsplan und Ergebnisse |
| `evidence_index` | Nachweisdokumenten-Index |
| `instructions_for_use` | Sicherheitshinweise / Betriebsanleitung |
| `monitoring_plan` | Post-Market Surveillance Plan |
| `ai_intended_purpose` | KI: Bestimmungsgemaesser Zweck |
| `ai_model_description` | KI: Modellbeschreibung und Trainingsdaten |
| `ai_risk_management` | KI: Risikomanagementsystem |
| `ai_human_oversight` | KI: Menschliche Aufsicht |
### Post-Market Monitoring
@@ -383,40 +434,59 @@ Fuer die Verifikation stehen **50 Nachweistypen** zur Auswahl:
---
## Completeness Gates (25)
## Completeness Gates (22)
Das Modul prueft 25 Vollstaendigkeitstore vor dem CE-Export:
Das Modul prueft 22 Vollstaendigkeitstore (20 Required, 2 Recommended) vor dem CE-Export:
| Gate | Kategorie | Pflicht |
|------|-----------|---------|
| G01 | Projekt-Grunddaten vollstaendig | ✅ Required |
| G02 | CE-Markierungsziel definiert | ✅ Required |
| G03 | Mind. 1 Komponente erfasst | ✅ Required |
| G04 | Regulatorische Klassifizierung abgeschlossen | ✅ Required |
| G05 | HARA-Dokument vorhanden (Evidence) | ✅ Required |
| G06 | Mind. 1 Gefaehrdung identifiziert | ✅ Required |
| G07 | Alle Gefaehrdungen bewertet | ✅ Required |
| G08 | Kein Restrisiko > critical ohne Akzeptanz | ✅ Required |
| G09 | Mind. 1 Minderungsmassnahme je Gefaehrdung | ✅ Required |
| G10 | Minderungsmassnahmen verifiziert | ✅ Required |
| G11 | Verifikationsplan vorhanden | ✅ Required |
| G12 | SIL/PL-Dokumentation (Evidence) | ✅ Required |
| G13 | Technische Akte generiert | ✅ Required |
| G14 | Konformitaetserklaerung bereit | ✅ Required |
| G15 | Betriebsanleitung vorhanden | ✅ Required |
| G16 | Wartungsanleitung vorhanden | Recommended |
| G17 | Post-Market Monitoring aktiv | Recommended |
| G18 | Cybersecurity-Massnahmen dokumentiert | Recommended |
| G19 | AI-spezifische Anforderungen erfuellt | Recommended (bei AI) |
| G20 | Kalibrierprotokolle vorhanden | Recommended |
| G21 | SBOM generiert | Optional |
| G22 | Penetrationstest durchgefuehrt | Optional |
| G23 | EMV-Pruefung dokumentiert | Optional |
| G24 | Lebenszyklusplan vorhanden | Optional |
| G25 | Monitoring-Ereignisse protokolliert | Optional |
### Onboarding (G01-G09)
| Gate | Label | Pflicht |
|------|-------|---------|
| G01 | Machine identity set | ✅ Required |
| G02 | Intended use described | ✅ Required |
| G03 | Operating limits defined | ✅ Required |
| G04 | Foreseeable misuse documented | ✅ Required |
| G05 | Component tree exists | ✅ Required |
| G06 | AI classification done (if applicable) | ✅ Required |
| G07 | Safety relevance marked | ✅ Required |
| G08 | Manufacturer info present | ✅ Required |
| G09 | Pattern matching performed | Recommended |
### Klassifizierung (G10-G13)
| Gate | Label | Pflicht |
|------|-------|---------|
| G10 | AI Act classification complete | ✅ Required |
| G11 | Machinery Regulation check done | ✅ Required |
| G12 | NIS2 check done | ✅ Required |
| G13 | CRA check done | ✅ Required |
### Gefaehrdungen & Risiko (G20-G24)
| Gate | Label | Pflicht |
|------|-------|---------|
| G20 | Hazards identified | ✅ Required |
| G21 | All hazards assessed | ✅ Required |
| G22 | Critical/High risks mitigated | ✅ Required |
| G23 | **Mitigations verified** | ✅ Required |
| G24 | Residual risk accepted | ✅ Required |
!!! warning "G23 — Strenge Verifikationspflicht"
Alle Mitigations muessen den Status `verified` oder `rejected` haben. Mitigations im Status `planned` oder `implemented` blockieren den Export. Dies stellt sicher, dass keine Massnahme unueberprueft bleibt.
### Evidence & Tech File (G30, G40-G42)
| Gate | Label | Pflicht |
|------|-------|---------|
| G30 | Test evidence linked | Recommended |
| G40 | Risk assessment report generated | ✅ Required |
| G41 | Hazard log generated | ✅ Required |
| G42 | AI documents present (if applicable) | ✅ Required |
**Completeness Score:** `(passed_required/total_required)*80 + (passed_recommended/total_recommended)*15 + (passed_optional/total_optional)*5`
**CanExport** ist nur `true`, wenn alle Required-Gates bestanden sind.
---
## CE RAG-Corpus