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