Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf9d8a5ed3 | |||
| d45e08e25f | |||
| 3dbf3aa34a | |||
| 77308b783f | |||
| 9797234ff6 | |||
| 7080eb5f45 | |||
| c93cf2719a | |||
| 7a27dbc01b | |||
| de35dfce18 | |||
| 69240faf24 | |||
| f34305c0a1 | |||
| 2b5376ed54 | |||
| 958c03ab40 | |||
| fca67c1f43 | |||
| 70af018da5 | |||
| 0182c91ef9 | |||
| a67cfa7c4a | |||
| 3b7ab4cbd7 | |||
| 3469105d18 | |||
| 1414c63515 | |||
| 9f87bc5a2c | |||
| f5f4de7359 | |||
| 38d15d4d29 | |||
| 003eafa75d | |||
| b82853a95b | |||
| c060ac222a | |||
| 659c0505f8 | |||
| 02c2325e1b | |||
| d72aa10691 | |||
| 3c05ff8ef6 | |||
| 935c9205b9 | |||
| 826ce2a1b8 | |||
| bd2d6976d6 | |||
| a5d1814605 | |||
| ba07a7f6e6 | |||
| 708c61e50d | |||
| dc55253b9d | |||
| 8069d0ea89 | |||
| 4e9043f26d | |||
| 29fbd03c79 | |||
| 98e5b1a8aa | |||
| b175212516 | |||
| 16190583d1 | |||
| 70c9bfc069 | |||
| 4b9317b4fd | |||
| e4431da8d2 | |||
| 65f978368d | |||
| a530edb994 | |||
| 256deb70c7 | |||
| eac42d4154 | |||
| 33bf2b7c5a | |||
| 3e61f381a7 | |||
| cca714755a | |||
| 6940271672 | |||
| 5e317d2f0f | |||
| 64e3a47b8c | |||
| 81a0568537 | |||
| d0d1b38f5c | |||
| d31c2fe018 | |||
| 8ad0519367 | |||
| 7a5301064c | |||
| b2c1f0ae84 | |||
| 733d2bcc7b | |||
| 977e63f372 | |||
| be2ac762bd | |||
| 1bd892afbf | |||
| c702260ec1 | |||
| 8bb90d73e5 | |||
| 185d680669 | |||
| 0b9150f16f | |||
| 0326d5baab | |||
| c867478791 | |||
| 979fe20ea5 | |||
| de808190dd | |||
| 08fcb5f239 | |||
| e785b6d695 | |||
| 7be34552bb | |||
| be9cfdc2d4 | |||
| b42e1cd091 | |||
| 1c828a5843 | |||
| 4a7e09bbb0 | |||
| edbf6d2be5 | |||
| 06bfbd1dca | |||
| 74f00bbb0f | |||
| 128967fa3d | |||
| baca0f6b80 |
@@ -115,5 +115,38 @@ docs-src/control_generator_routes.py
|
||||
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||
consent-sdk/src/mobile/ios/ConsentManager.swift
|
||||
|
||||
# --- consent-tester: DSI discovery orchestrator ---
|
||||
# Single Playwright session with sequential steps (banner dismiss, self-extract,
|
||||
# link follow, accordion expand, inline sections). Splitting mid-session would
|
||||
# require passing Page objects across modules.
|
||||
consent-tester/services/dsi_discovery.py
|
||||
|
||||
# --- backend-compliance: unified compliance check orchestrator ---
|
||||
# Sequential 7-step pipeline (text resolve, profile detect, check documents,
|
||||
# banner scan, cross-check, profile extract, report). Phase 5 split target.
|
||||
backend-compliance/compliance/api/agent_compliance_check_routes.py
|
||||
|
||||
# --- docs-src: binary office files (not source code) ---
|
||||
# (Also excluded by extension in scripts/check-loc.sh — kept here for legibility.)
|
||||
docs-src/Breakpilot ComplAI Finanzplan.xlsm
|
||||
|
||||
# --- admin-compliance: oversized component refactor backlog ---
|
||||
# Phase 5+ target for splitting into smaller subcomponents per wizard step.
|
||||
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
|
||||
|
||||
# --- ai-compliance-sdk: oversized handler refactor backlog ---
|
||||
# Phase 5+ target for splitting handler groups into per-resource files.
|
||||
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
|
||||
|
||||
# --- merge grandfathered (2026-05-13) — Phase 5+ refactor backlog ---
|
||||
# Files imported via team work that crossed the hard cap; tracked for splitting.
|
||||
consent-tester/checks/banner_checks.py
|
||||
consent-tester/services/banner_detector.py
|
||||
backend-compliance/compliance/api/agent_doc_check_routes.py
|
||||
backend-compliance/compliance/services/service_registry.py
|
||||
backend-compliance/compliance/services/dsr_workflow_service.py
|
||||
ai-compliance-sdk/internal/iace/hazard_patterns_forestry_conveyor.go
|
||||
admin-compliance/app/sdk/compliance-scope/page.tsx
|
||||
|
||||
# --- zeroclaw: ground-truth corpus (test fixture data, not source) ---
|
||||
zeroclaw/docs/ground-truth/06-spiegel-dsi-fulltext.txt
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Build + push compliance service images to registry.meghsakha.com
|
||||
# and trigger orca redeploy on every push to main that touches a service.
|
||||
# and trigger orca redeploy after CI passes on main.
|
||||
#
|
||||
# This workflow is gated on the CI workflow completing successfully.
|
||||
# It does not run independently — if CI fails, builds + deploy are skipped.
|
||||
# Per-service builds are gated on detect-changes so only services with
|
||||
# modified files are rebuilt; trigger-orca runs only if at least one build
|
||||
# succeeded and none failed.
|
||||
#
|
||||
# Requires Gitea Actions secrets:
|
||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||
@@ -8,24 +14,68 @@
|
||||
name: Build + Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'admin-compliance/**'
|
||||
- 'backend-compliance/**'
|
||||
- 'ai-compliance-sdk/**'
|
||||
- 'developer-portal/**'
|
||||
- 'compliance-tts-service/**'
|
||||
- 'document-crawler/**'
|
||||
- 'dsms-gateway/**'
|
||||
- 'dsms-node/**'
|
||||
|
||||
jobs:
|
||||
# ── per-service builds run in parallel ────────────────────────────────────
|
||||
# ── gate: only proceed if CI succeeded ────────────────────────────────────
|
||||
ci-passed:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: CI passed, proceeding with build + deploy
|
||||
run: echo "CI run ${{ github.event.workflow_run.id }} succeeded on ${{ github.event.workflow_run.head_branch }} @ ${{ github.event.workflow_run.head_sha }}"
|
||||
|
||||
# ── detect which services changed since the last successful build ────────
|
||||
# Diff base = the last-build/main git tag, set by mark-last-build at the
|
||||
# end of every successful run. Works across squash merges, multi-commit
|
||||
# raw pushes, and force pushes (force pushes leave a stale tag → diff
|
||||
# shows symmetric differences → safe over-rebuild). If the tag doesn't
|
||||
# exist yet, scripts/detect-changes.sh falls back to rebuilding all.
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
needs: ci-passed
|
||||
outputs:
|
||||
admin: ${{ steps.diff.outputs.admin }}
|
||||
backend: ${{ steps.diff.outputs.backend }}
|
||||
sdk: ${{ steps.diff.outputs.sdk }}
|
||||
portal: ${{ steps.diff.outputs.portal }}
|
||||
tts: ${{ steps.diff.outputs.tts }}
|
||||
crawler: ${{ steps.diff.outputs.crawler }}
|
||||
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
git fetch --tags origin || true
|
||||
- name: Resolve base SHA from last-build/main tag
|
||||
run: |
|
||||
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||
echo "Base SHA: ${BASE:-<none, will rebuild all>}"
|
||||
# Deepen if base isn't yet in the shallow clone.
|
||||
if [ -n "$BASE" ] && ! git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||
git fetch --unshallow origin 2>/dev/null \
|
||||
|| git fetch --depth=10000 origin 2>/dev/null \
|
||||
|| true
|
||||
fi
|
||||
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||
- name: Detect changes
|
||||
id: diff
|
||||
run: bash scripts/detect-changes.sh
|
||||
|
||||
# ── per-service builds run in parallel (only changed services) ────────────
|
||||
|
||||
build-admin-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.admin == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -49,6 +99,8 @@ jobs:
|
||||
build-backend-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.backend == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -72,6 +124,8 @@ jobs:
|
||||
build-ai-sdk:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -95,6 +149,8 @@ jobs:
|
||||
build-developer-portal:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.portal == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -118,6 +174,8 @@ jobs:
|
||||
build-tts:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.tts == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -141,6 +199,8 @@ jobs:
|
||||
build-document-crawler:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.crawler == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -164,6 +224,8 @@ jobs:
|
||||
build-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -187,6 +249,8 @@ jobs:
|
||||
build-dsms-node:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_node == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -207,7 +271,52 @@ jobs:
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
||||
|
||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
||||
# ── advance the last-build/main tag — the diff base for future runs ──────
|
||||
# Runs when no build failed. Covers two cases:
|
||||
# - at least one service was rebuilt → mark this SHA as the new baseline
|
||||
# - all services were skipped (nothing changed) → still advance the tag
|
||||
# so we don't keep re-evaluating the same skipped commits forever
|
||||
# Skips if any build failed → tag stays put → next push retries those
|
||||
# services from the previous known-good base.
|
||||
mark-last-build:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
needs:
|
||||
- build-admin-compliance
|
||||
- build-backend-compliance
|
||||
- build-ai-sdk
|
||||
- build-developer-portal
|
||||
- build-tts
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
if: |
|
||||
always() &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Force-push last-build/main tag
|
||||
run: |
|
||||
set -e
|
||||
SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
echo "Advancing last-build/main → ${SHA}"
|
||||
git tag -f last-build/main "$SHA"
|
||||
# Encode token into the push URL (no on-disk credential persistence).
|
||||
PUSH_URL="${GITHUB_SERVER_URL/https:\/\//https:\/\/x-access-token:${GITEA_TOKEN}@}/${GITHUB_REPOSITORY}.git"
|
||||
git push --force "$PUSH_URL" "refs/tags/last-build/main"
|
||||
echo "Tag last-build/main now at ${SHA}"
|
||||
|
||||
# ── orca redeploy — runs only if at least one build succeeded ─────────────
|
||||
# `always()` lets this run when some builds are skipped (unchanged services).
|
||||
# The contains() checks ensure we only redeploy when something actually built
|
||||
# and no build failed.
|
||||
|
||||
trigger-orca:
|
||||
runs-on: docker
|
||||
@@ -221,6 +330,11 @@ jobs:
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
if: |
|
||||
always() &&
|
||||
contains(needs.*.result, 'success') &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
|
||||
@@ -19,6 +19,49 @@ on:
|
||||
|
||||
jobs:
|
||||
|
||||
# ── Change detection (always runs first) ─────────────────────────────────
|
||||
# Diff base:
|
||||
# PR → merge-base with the PR base branch
|
||||
# push → last-build/main tag (set by build-push-deploy after a green build)
|
||||
# Falls back to "rebuild all" when the base is missing or unreachable.
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
outputs:
|
||||
admin: ${{ steps.diff.outputs.admin }}
|
||||
backend: ${{ steps.diff.outputs.backend }}
|
||||
sdk: ${{ steps.diff.outputs.sdk }}
|
||||
portal: ${{ steps.diff.outputs.portal }}
|
||||
tts: ${{ steps.diff.outputs.tts }}
|
||||
crawler: ${{ steps.diff.outputs.crawler }}
|
||||
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||
any_python: ${{ steps.diff.outputs.any_python }}
|
||||
any_node: ${{ steps.diff.outputs.any_node }}
|
||||
any: ${{ steps.diff.outputs.any }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
git fetch --depth 200 origin "${GITHUB_BASE_REF}" || true
|
||||
else
|
||||
git fetch --tags origin || true
|
||||
fi
|
||||
- name: Resolve base SHA
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
BASE=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || true)
|
||||
else
|
||||
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||
fi
|
||||
echo "Base SHA: ${BASE:-<none>}"
|
||||
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||
- name: Detect changes
|
||||
id: diff
|
||||
run: bash scripts/detect-changes.sh
|
||||
|
||||
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||
branch-name:
|
||||
runs-on: docker
|
||||
@@ -55,10 +98,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
||||
# ── LOC budget (only if files changed) ───────────────────────────────────
|
||||
loc-budget:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.any == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -86,10 +131,11 @@ jobs:
|
||||
--redact \
|
||||
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||
|
||||
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
||||
# ── Go lint + build (PR only, gated on ai-compliance-sdk changes) ────────
|
||||
go-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
||||
container: golangci/golangci-lint:v1.62-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -107,10 +153,11 @@ jobs:
|
||||
cd ai-compliance-sdk
|
||||
go build ./...
|
||||
|
||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
||||
# ── Python lint + import check (PR only, gated on python service changes) ─
|
||||
python-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_python == 'true'
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -137,10 +184,11 @@ jobs:
|
||||
python -c "import compliance; print('Import OK')" \
|
||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||
|
||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
||||
# ── Node.js lint + type-check (PR only, gated on Next.js service changes) ─
|
||||
nodejs-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: detect-changes
|
||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_node == 'true'
|
||||
container: node:20-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -158,10 +206,12 @@ jobs:
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
||||
# ── Node.js build — next build (gated on Next.js service changes) ───────
|
||||
nodejs-build:
|
||||
runs-on: docker
|
||||
container: node:20-alpine
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.any_node == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -244,10 +294,12 @@ jobs:
|
||||
- name: Vulnerability scan (fail on high+)
|
||||
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||
|
||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
||||
# ── Tests (gated per service) ────────────────────────────────────────────
|
||||
test-go:
|
||||
runs-on: docker
|
||||
container: golang:1.24-alpine
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
steps:
|
||||
@@ -265,6 +317,8 @@ jobs:
|
||||
test-python-backend:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.backend == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
@@ -284,6 +338,8 @@ jobs:
|
||||
test-python-document-crawler:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.crawler == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
@@ -303,6 +359,8 @@ jobs:
|
||||
test-python-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Vendor Assessment Status/Detail Proxy
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}`,
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (error) {
|
||||
console.error('Assessment status proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}/approve`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (error) {
|
||||
console.error('Assessment approve proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Vendor Assessment API Proxy
|
||||
* Proxies to backend-compliance (Python FastAPI)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.text()
|
||||
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (error) {
|
||||
console.error('Vendor assessment proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Vendor assessment list proxy error:', error)
|
||||
return NextResponse.json({ assessments: [] })
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,13 @@ interface DocResult {
|
||||
checks: CheckItem[]
|
||||
findings_count: number
|
||||
error: string
|
||||
scenario?: string // regenerate | fix | import | skip
|
||||
}
|
||||
|
||||
const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
|
||||
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
|
||||
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
|
||||
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
|
||||
}
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -46,7 +53,7 @@ function groupChecks(checks: CheckItem[]): GroupedCheck[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean }) {
|
||||
function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
|
||||
if (skipped) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -61,6 +68,13 @@ function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean })
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (isInfo) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -84,14 +98,23 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
|
||||
if (!results || results.length === 0) return null
|
||||
|
||||
const totalOk = results.filter(r => r.completeness_pct === 100).length
|
||||
const scenarioCounts = {
|
||||
regenerate: results.filter(r => r.scenario === 'regenerate').length,
|
||||
fix: results.filter(r => r.scenario === 'fix').length,
|
||||
import: results.filter(r => r.scenario === 'import').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
|
||||
Dokumenten-Pruefung ({results.length} Dokumente)
|
||||
</h3>
|
||||
<div className="flex gap-2 text-[10px]">
|
||||
{scenarioCounts.import > 0 && <span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{scenarioCounts.import} konform</span>}
|
||||
{scenarioCounts.fix > 0 && <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">{scenarioCounts.fix} Korrekturen</span>}
|
||||
{scenarioCounts.regenerate > 0 && <span className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{scenarioCounts.regenerate} Neugenerierung</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -104,8 +127,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
|
||||
const grouped = groupChecks(r.checks)
|
||||
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
|
||||
const l1Scoreable = l1Checks.filter(c => c.severity !== 'INFO')
|
||||
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
||||
const l1Passed = l1Checks.filter(c => c.passed).length
|
||||
const l1Passed = l1Scoreable.filter(c => c.passed).length
|
||||
const l2Passed = l2Active.filter(c => c.passed).length
|
||||
|
||||
return (
|
||||
@@ -123,10 +147,17 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{typeLabel}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{r.label}</div>
|
||||
<div className="text-sm font-medium text-gray-900 truncate flex items-center gap-2">
|
||||
{r.label}
|
||||
{r.scenario && SCENARIO_LABELS[r.scenario] && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${SCENARIO_LABELS[r.scenario].bg} ${SCENARIO_LABELS[r.scenario].color}`}>
|
||||
{SCENARIO_LABELS[r.scenario].label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{l1Checks.length > 0
|
||||
? `${l1Passed}/${l1Checks.length} Pflichtangaben`
|
||||
? `${l1Passed}/${l1Scoreable.length} Pflichtangaben`
|
||||
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
||||
: r.url}
|
||||
</div>
|
||||
@@ -137,8 +168,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2" title={`Pflichtangaben: ${l1Passed}/${l1Scoreable.length}`}>
|
||||
<span className="text-[10px] text-gray-400 w-7">Pflicht</span>
|
||||
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium w-10 text-right ${
|
||||
@@ -146,8 +178,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
}`}>{pct}%</span>
|
||||
</div>
|
||||
{l2Active.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2" title={`Detailpruefung: ${l2Passed}/${l2Active.length}`}>
|
||||
<span className="text-[10px] text-gray-400 w-7">Detail</span>
|
||||
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
|
||||
@@ -164,13 +197,18 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
<p className="text-sm text-red-600">{r.error}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{grouped.map((g) => (
|
||||
{grouped.map((g) => {
|
||||
const l1Info = g.check.severity === 'INFO' && !g.check.passed
|
||||
return (
|
||||
<div key={g.check.id}>
|
||||
{/* L1 check */}
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckIcon passed={g.check.passed} />
|
||||
<CheckIcon passed={g.check.passed} isInfo={l1Info} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm ${g.check.passed ? 'text-gray-700' : 'text-red-700 font-medium'}`}>
|
||||
<div className={`text-sm ${
|
||||
g.check.passed ? 'text-gray-700'
|
||||
: l1Info ? 'text-gray-500' : 'text-red-700 font-medium'
|
||||
}`}>
|
||||
{g.check.label}
|
||||
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
||||
</div>
|
||||
@@ -180,7 +218,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
)}
|
||||
{!g.check.passed && g.check.hint && (
|
||||
<div className="text-xs text-red-600/80 mt-0.5">
|
||||
<div className={`text-xs mt-0.5 ${l1Info ? 'text-gray-400' : 'text-red-600/80'}`}>
|
||||
{g.check.hint}
|
||||
</div>
|
||||
)}
|
||||
@@ -190,13 +228,16 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{/* L2 children — always visible */}
|
||||
{g.children.length > 0 && (
|
||||
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
|
||||
{g.children.map((ch) => (
|
||||
{g.children.map((ch) => {
|
||||
const chInfo = ch.severity === 'INFO' && !ch.passed && !ch.skipped
|
||||
return (
|
||||
<div key={ch.id} className="flex items-start gap-2">
|
||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
|
||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-xs ${
|
||||
ch.skipped ? 'text-gray-400 italic'
|
||||
: ch.passed ? 'text-gray-600' : 'text-red-600 font-medium'
|
||||
: ch.passed ? 'text-gray-600'
|
||||
: chInfo ? 'text-gray-400' : 'text-red-600 font-medium'
|
||||
}`}>
|
||||
{ch.label}
|
||||
{ch.skipped && ' (uebersprungen)'}
|
||||
@@ -207,17 +248,19 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
)}
|
||||
{!ch.passed && !ch.skipped && ch.hint && (
|
||||
<div className="text-xs text-red-500/80 mt-0.5">
|
||||
<div className={`text-xs mt-0.5 ${chInfo ? 'text-gray-400' : 'text-red-500/80'}`}>
|
||||
{ch.hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{r.word_count > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
||||
{r.word_count} Woerter analysiert
|
||||
|
||||
@@ -202,9 +202,9 @@ export function ComplianceCheckTab() {
|
||||
setActiveCheckId(check_id)
|
||||
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
||||
|
||||
// Poll for results (max 15 min = 300 polls x 3s)
|
||||
// Poll for results (max 25 min = 500 polls x 3s)
|
||||
let attempts = 0
|
||||
while (attempts < 300) {
|
||||
while (attempts < 500) {
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
||||
if (!pollRes.ok) { attempts++; continue }
|
||||
@@ -235,7 +235,7 @@ export function ComplianceCheckTab() {
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
if (attempts >= 300) {
|
||||
if (attempts >= 500) {
|
||||
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||
throw new Error('Zeitlimit ueberschritten (15 Min)')
|
||||
}
|
||||
@@ -364,6 +364,46 @@ export function ComplianceCheckTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extracted Profile — pre-fill suggestion */}
|
||||
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
|
||||
<div className="mb-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
|
||||
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
|
||||
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
|
||||
In Company Profile uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
|
||||
{results.extracted_profile.company_profile.companyName && (
|
||||
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.legalForm && (
|
||||
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.headquartersCity && (
|
||||
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.dpoEmail && (
|
||||
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
|
||||
)}
|
||||
{results.extracted_profile.company_profile.ustIdNr && (
|
||||
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
|
||||
)}
|
||||
</div>
|
||||
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
|
||||
<span className="font-medium">Scope-Hinweise: </span>
|
||||
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
|
||||
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
|
||||
{h.source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner Check Result */}
|
||||
{results.banner_result && (
|
||||
<div className={`mb-4 p-3 rounded-lg border text-xs ${
|
||||
@@ -387,8 +427,8 @@ export function ComplianceCheckTab() {
|
||||
<>
|
||||
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
|
||||
{results.banner_result.violations > 0
|
||||
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden — Cross-Check-Ergebnisse sind in der Cookie-Richtlinie-Checkliste enthalten.`
|
||||
: ' Keine Auffaelligkeiten beim Banner-Cookie-Abgleich.'}
|
||||
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
|
||||
: ' Keine Auffaelligkeiten.'}
|
||||
</>
|
||||
) : (
|
||||
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
entity_name: string
|
||||
action: string
|
||||
field_changed: string | null
|
||||
old_value: string | null
|
||||
new_value: string | null
|
||||
change_summary: string | null
|
||||
performed_by: string
|
||||
performed_at: string
|
||||
}
|
||||
|
||||
export function useAuditTimeline() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries()
|
||||
}, [filter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function loadEntries() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' })
|
||||
if (filter !== 'all') params.set('entity_type', filter)
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit-trail?${params}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setEntries(json.entries || json.audit_trail || json || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load audit trail:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, loading, filter, setFilter }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||
dsfa: 'DSFA', vvt: 'VVT', tom: 'TOM', policy: 'Richtlinie',
|
||||
dsms_archive: 'DSMS-Archiv', risk: 'Risiko',
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500',
|
||||
approve: 'bg-purple-500', archive: 'bg-emerald-500', review: 'bg-yellow-500',
|
||||
sign: 'bg-indigo-500', reject: 'bg-red-400',
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||
|
||||
export default function AuditTimelinePage() {
|
||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Audit Timeline</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Chronologische Compliance-Historie mit DSMS-Nachweisen</p>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{FILTER_OPTIONS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' : ENTITY_LABELS[f] || f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500">
|
||||
Keine Eintraege gefunden. Compliance-Aktionen werden automatisch protokolliert.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry) => (
|
||||
<TimelineEntry key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||
const date = new Date(entry.performed_at)
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4 pl-3">
|
||||
{/* Dot */}
|
||||
<div className={`relative z-10 w-3 h-3 rounded-full mt-1.5 flex-shrink-0 ring-4 ring-white dark:ring-gray-900 ${dotColor}`} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{entry.entity_name}</span>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{ENTITY_LABELS[entry.entity_type] || entry.entity_type}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-medium text-white ${dotColor}`}>
|
||||
{entry.action}
|
||||
</span>
|
||||
</div>
|
||||
{entry.change_summary && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||
)}
|
||||
{isCID && entry.new_value && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<code className="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded font-mono dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||
</code>
|
||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<div className="text-xs text-gray-400">{date.toLocaleDateString('de-DE')}</div>
|
||||
<div className="text-[10px] text-gray-300">{date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
<div className="text-[10px] text-gray-300 mt-0.5">{entry.performed_by}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -102,6 +102,7 @@ export interface BannerSite {
|
||||
site_name: string
|
||||
site_url: string
|
||||
is_active: boolean
|
||||
tcf_enabled?: boolean
|
||||
}
|
||||
|
||||
export function useCookieBanner() {
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function CookieBannerPage() {
|
||||
|
||||
{/* Tab: TCF/IAB */}
|
||||
{activeTab === 'tcf' && (
|
||||
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={false}
|
||||
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={sites.find(s => s.site_id === activeSiteId)?.tcf_enabled ?? false}
|
||||
onToggle={(enabled) => {
|
||||
if (activeSiteId) {
|
||||
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
|
||||
|
||||
@@ -101,7 +101,35 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
|
||||
|
||||
// ── MODULE WIRING: CookieBanner → CONSENT + FEATURES ─────────────────────
|
||||
// ── MODULE WIRING: Backend Banner-Config → CONSENT + FEATURES ────────────
|
||||
useEffect(() => {
|
||||
// Fetch real vendor/category data from backend if SDK state has no banner
|
||||
if (state?.cookieBanner) return // SDK state takes priority
|
||||
fetch('/api/sdk/v1/banner/admin/sites', { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||
.then(r => r.json())
|
||||
.then((sites: Array<{ site_id: string }>) => {
|
||||
if (!sites?.length) return
|
||||
return fetch(`/api/sdk/v1/banner/config/${sites[0].site_id}`, { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||
})
|
||||
.then(r => r?.json())
|
||||
.then(config => {
|
||||
if (!config?.vendors?.length) return
|
||||
const analytics = config.vendors.filter((v: { category_key: string }) => v.category_key === 'statistics' || v.category_key === 'analytics').map((v: { vendor_name: string }) => v.vendor_name)
|
||||
const marketing = config.vendors.filter((v: { category_key: string }) => v.category_key === 'marketing').map((v: { vendor_name: string }) => v.vendor_name)
|
||||
setContext(prev => ({
|
||||
...prev,
|
||||
CONSENT: {
|
||||
...prev.CONSENT,
|
||||
ANALYTICS_TOOLS: analytics.length > 0 ? analytics.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
|
||||
MARKETING_PARTNERS: marketing.length > 0 ? marketing.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
|
||||
},
|
||||
FEATURES: { ...prev.FEATURES, CMP_NAME: 'BreakPilot CMP', CMP_LOGS_CONSENTS: true },
|
||||
}))
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [state?.cookieBanner])
|
||||
|
||||
// ── MODULE WIRING: CookieBanner SDK State → CONSENT + FEATURES ──────────
|
||||
useEffect(() => {
|
||||
const banner = state?.cookieBanner
|
||||
if (!banner) return
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useBannerConsents } from '../_hooks/useBannerConsents'
|
||||
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
|
||||
|
||||
const BANNER_API = '/api/sdk/v1/banner'
|
||||
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
@@ -42,12 +45,35 @@ const methodColors: Record<string, string> = {
|
||||
export default function BannerConsentsTab() {
|
||||
const {
|
||||
records, sites, selectedSite, changeSite,
|
||||
stats, currentPage, setCurrentPage, totalRecords, loading,
|
||||
stats, currentPage, setCurrentPage, totalRecords, loading, reload,
|
||||
} = useBannerConsents()
|
||||
|
||||
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
|
||||
const [linkEmailInput, setLinkEmailInput] = useState('')
|
||||
const [linkingEmail, setLinkingEmail] = useState(false)
|
||||
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
|
||||
|
||||
const withdrawConsent = useCallback(async (id: string) => {
|
||||
if (!confirm('Consent wirklich widerrufen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
|
||||
await fetch(`${BANNER_API}/consent/${id}`, { method: 'DELETE', headers: { 'x-tenant-id': TENANT_ID } })
|
||||
setDetail(null)
|
||||
reload()
|
||||
}, [reload])
|
||||
|
||||
const linkEmail = useCallback(async (record: BannerConsentRecord) => {
|
||||
if (!linkEmailInput.includes('@')) return
|
||||
setLinkingEmail(true)
|
||||
await fetch(`${BANNER_API}/consent/link-email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
|
||||
body: JSON.stringify({ site_id: record.site_id, device_fingerprint: record.device_fingerprint, email: linkEmailInput }),
|
||||
})
|
||||
setLinkingEmail(false)
|
||||
setLinkEmailInput('')
|
||||
setDetail({ ...record, linked_email: linkEmailInput })
|
||||
reload()
|
||||
}, [linkEmailInput, reload])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats + Site Selector */}
|
||||
@@ -184,6 +210,18 @@ export default function BannerConsentsTab() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{detail.vendor_consents && Object.keys(detail.vendor_consents).length > 0 && (
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-gray-500">Vendors</span>
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
{Object.entries(detail.vendor_consents).map(([name, accepted]) => (
|
||||
<span key={name} className={`text-xs px-2 py-0.5 rounded-full ${accepted ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Methode</span>
|
||||
<span>{detail.consent_method ? (
|
||||
@@ -192,9 +230,28 @@ export default function BannerConsentsTab() {
|
||||
</span>
|
||||
) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500">Verknüpft mit</span>
|
||||
<span>{detail.linked_email || '— (anonym)'}</span>
|
||||
{detail.linked_email ? (
|
||||
<span className="text-purple-600 text-xs">{detail.linked_email}</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-Mail verknüpfen..."
|
||||
value={linkEmailInput}
|
||||
onChange={e => setLinkEmailInput(e.target.value)}
|
||||
className="text-xs border border-gray-200 rounded px-2 py-1 w-40"
|
||||
/>
|
||||
<button
|
||||
onClick={() => linkEmail(detail)}
|
||||
disabled={linkingEmail || !linkEmailInput.includes('@')}
|
||||
className="text-xs px-2 py-1 bg-purple-600 text-white rounded disabled:opacity-40"
|
||||
>
|
||||
{linkingEmail ? '...' : 'Link'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Erteilt</span><span>{formatDate(detail.created_at)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Ablauf</span><span>{formatDate(detail.expires_at)}</span></div>
|
||||
@@ -264,6 +321,16 @@ export default function BannerConsentsTab() {
|
||||
{detail.banner_config_hash && <div><span className="text-gray-500 text-xs">Config-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.banner_config_hash}</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Widerruf-Button */}
|
||||
<div className="border-t border-gray-100 pt-4 mt-4">
|
||||
<button
|
||||
onClick={() => withdrawConsent(detail.id)}
|
||||
className="w-full px-4 py-2 text-xs font-semibold text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Consent widerrufen (Art. 17 DSGVO)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface BannerConsentRecord {
|
||||
device_fingerprint: string
|
||||
categories: string[]
|
||||
vendors: string[]
|
||||
vendor_consents: Record<string, boolean>
|
||||
ip_hash: string | null
|
||||
user_agent: string | null
|
||||
linked_email: string | null
|
||||
@@ -144,4 +145,5 @@ export interface BannerSite {
|
||||
site_id: string
|
||||
site_name: string
|
||||
site_url: string
|
||||
tcf_enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { CategoryScore } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props { breakdown: CategoryScore[] }
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'mechanische gefaehrdungen': 'Mechanisch',
|
||||
'elektrische gefaehrdungen': 'Elektrisch',
|
||||
'thermische gefaehrdungen': 'Thermisch',
|
||||
'laerm': 'Laerm',
|
||||
'vibration': 'Vibration',
|
||||
'strahlung': 'Strahlung',
|
||||
'materialien und substanzen': 'Materialien/Substanzen',
|
||||
'ergonomische gefaehrdungen': 'Ergonomie',
|
||||
'einsatzumgebung': 'Einsatzumgebung',
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ breakdown }: Props) {
|
||||
if (!breakdown || breakdown.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Coverage nach Gefaehrdungsgruppe</h3>
|
||||
<div className="space-y-2">
|
||||
{breakdown.map((cat) => {
|
||||
const label = CATEGORY_LABELS[cat.category] || cat.category
|
||||
const pct = Math.round(cat.coverage * 100)
|
||||
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
return (
|
||||
<div key={cat.category}>
|
||||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-0.5">
|
||||
<span>{label}</span>
|
||||
<span>{cat.match_count}/{cat.gt_count} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef } from 'react'
|
||||
import type { GroundTruthEntry } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
onImport: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function GTImportForm({ onImport, loading }: Props) {
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [preview, setPreview] = useState<{ count: number; groups: Record<string, number> } | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function tryParse(text: string) {
|
||||
setJsonText(text)
|
||||
setParseError(null)
|
||||
setPreview(null)
|
||||
if (!text.trim()) return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
const entries: GroundTruthEntry[] = parsed.entries || parsed
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
setParseError('JSON muss ein Array "entries" enthalten')
|
||||
return
|
||||
}
|
||||
// Validate first entry has required fields
|
||||
const first = entries[0]
|
||||
if (!first.hazard_type && !first.hazard_group) {
|
||||
setParseError('Eintraege muessen hazard_type oder hazard_group enthalten')
|
||||
return
|
||||
}
|
||||
// Build preview
|
||||
const groups: Record<string, number> = {}
|
||||
for (const e of entries) {
|
||||
const g = e.hazard_group || 'Unbekannt'
|
||||
groups[g] = (groups[g] || 0) + 1
|
||||
}
|
||||
setPreview({ count: entries.length, groups })
|
||||
} catch (err) {
|
||||
setParseError('Ungueltiges JSON: ' + (err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!jsonText.trim()) return
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
const gt = parsed.entries ? parsed : { entries: parsed }
|
||||
await onImport(gt)
|
||||
setJsonText('')
|
||||
setPreview(null)
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
tryParse(text)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Ground Truth importieren</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
JSON-Datei mit der professionellen Risikobeurteilung einfuegen oder hochladen.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||
>
|
||||
JSON-Datei waehlen
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => tryParse(e.target.value)}
|
||||
placeholder='{"entries": [...], "source_file": "...", "description": "..."}'
|
||||
rows={6}
|
||||
className="w-full text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 resize-y"
|
||||
/>
|
||||
|
||||
{parseError && (
|
||||
<div className="mt-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600">
|
||||
{parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="mt-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded text-xs text-green-700 dark:text-green-400">
|
||||
<strong>{preview.count} Eintraege</strong> erkannt:
|
||||
{Object.entries(preview.groups).map(([g, c]) => (
|
||||
<span key={g} className="ml-2">{g}: {c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={loading || !preview}
|
||||
className="mt-3 w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||
>
|
||||
{loading ? 'Importiere...' : 'Ground Truth importieren'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||
|
||||
interface Props {
|
||||
matched: HazardMatchPair[]
|
||||
missing: GroundTruthEntry[]
|
||||
extra: HazardSummary[]
|
||||
}
|
||||
|
||||
type TabType = 'matched' | 'missing' | 'extra'
|
||||
|
||||
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||
const [tab, setTab] = useState<TabType>('matched')
|
||||
|
||||
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
|
||||
const realMatched = matched.filter(p => p.match_score >= 0.5)
|
||||
const weakMatched = matched.filter(p => p.match_score < 0.5)
|
||||
|
||||
// Weak matches: GT entries go to "missing", engine entries go to "extra"
|
||||
const allMissing = [...missing, ...weakMatched.map(w => w.gt_entry)]
|
||||
const allExtra = [...extra, ...weakMatched.map(w => w.engine_hazard)]
|
||||
|
||||
const greenCount = realMatched.filter(p => p.match_score >= 0.7).length
|
||||
const yellowCount = realMatched.filter(p => p.match_score >= 0.5 && p.match_score < 0.7).length
|
||||
|
||||
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
|
||||
{ id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: realMatched.length, color: 'text-green-600' },
|
||||
{ id: 'missing', label: 'Fehlend', count: allMissing.length, color: 'text-red-600' },
|
||||
{ id: 'extra', label: 'Engine Findings', count: allExtra.length, color: 'text-blue-500' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||
tab === t.id
|
||||
? 'border-b-2 border-purple-600 text-purple-700 dark:text-purple-400'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t.label} <span className={t.color}>({t.count})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
|
||||
{tab === 'missing' && <MissingTable entries={allMissing} />}
|
||||
{tab === 'extra' && <ExtraTable entries={allExtra} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">R</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-gray-500">Score</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Qualitaet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{pairs.map((p, i) => {
|
||||
const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red'
|
||||
const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5'
|
||||
: quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : ''
|
||||
const isOpen = expanded[i]
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${rowBg}`}
|
||||
onClick={() => setExpanded(prev => ({ ...prev, [i]: !prev[i] }))}>
|
||||
<td className="px-3 py-2 text-gray-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<svg className={`w-3 h-3 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{p.gt_entry.nr}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
|
||||
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<RiskBadge risk={p.gt_entry.risk_in.r} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
|
||||
<div className="text-gray-400">{p.engine_hazard.category}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center"><ScoreBadge score={p.match_score} /></td>
|
||||
<td className="px-3 py-2"><QualityBadge quality={quality} /></td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr className="bg-gray-50/70 dark:bg-gray-850">
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
const LIFECYCLE_LABELS: Record<string, string> = {
|
||||
startup: 'Hochfahren', homing: 'Referenzfahrt', automatic_operation: 'Automatikbetrieb',
|
||||
manual_operation: 'Handbetrieb', teach_mode: 'Einrichtbetrieb', maintenance: 'Wartung',
|
||||
cleaning: 'Reinigung', emergency_stop: 'Not-Halt', recovery_mode: 'Wiederanlauf',
|
||||
normal_operation: 'Automatikbetrieb', setup: 'Einrichten', changeover: 'Umruesten',
|
||||
fault_clearing: 'Fehlersuche/Stoerungsbeseitigung', commissioning: 'Inbetriebnahme',
|
||||
decommissioning: 'Demontage/Ausserbetriebnahme', transport: 'Transport',
|
||||
assembly: 'Montage/Installation', inspection: 'Inspektion/Pruefung',
|
||||
}
|
||||
|
||||
function formatLifecycles(raw: string): string {
|
||||
if (!raw) return '-'
|
||||
return raw.split(',').map(s => s.trim()).map(s => LIFECYCLE_LABELS[s] || s).join(', ')
|
||||
}
|
||||
|
||||
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
|
||||
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
{/* Left: Ground Truth */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-red-700 dark:text-red-400 uppercase text-[10px]">Ground Truth (Fachmann)</div>
|
||||
<DetailRow label="Gefaehrdung" gt={gt.hazard_type} />
|
||||
<DetailRow label="Ursache" gt={gt.hazard_cause} />
|
||||
<DetailRow label="Gefahrenstelle" gt={gt.component_zone} />
|
||||
<DetailRow label="Lebensphasen" gt={gt.lifecycle_phases?.join(', ') || '-'} />
|
||||
<DetailRow label="Risiko" gt={`F=${gt.risk_in.f} W=${gt.risk_in.w} P=${gt.risk_in.p} S=${gt.risk_in.s} => R=${gt.risk_in.r}`} />
|
||||
{gt.risk_out.r > 0 && (
|
||||
<DetailRow label="Restrisiko" gt={`F=${gt.risk_out.f} W=${gt.risk_out.w} P=${gt.risk_out.p} S=${gt.risk_out.s} => R=${gt.risk_out.r}`} />
|
||||
)}
|
||||
<DetailRow label="Massnahmen" gt={gt.measures?.join('\n') || '-'} multiline />
|
||||
<DetailRow label="Typ" gt={gt.measure_type || '-'} />
|
||||
{gt.norm_references?.length > 0 && (
|
||||
<DetailRow label="Normen" gt={gt.norm_references.join(', ')} />
|
||||
)}
|
||||
<DetailRow label="Hinreichend" gt={gt.sufficient ? 'JA' : 'NEIN'} />
|
||||
{gt.comment && <DetailRow label="Kommentar" gt={gt.comment} />}
|
||||
</div>
|
||||
{/* Right: Engine */}
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
|
||||
<DetailRow label="Gefaehrdung" gt={engine.name} />
|
||||
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
|
||||
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
|
||||
{engine.lifecycle_phase && (
|
||||
<DetailRow label="Lebensphasen" gt={formatLifecycles(engine.lifecycle_phase)} />
|
||||
)}
|
||||
<DetailRow label="Moeglicher Schaden" gt={engine.possible_harm || '-'} />
|
||||
<DetailRow label="Trigger" gt={engine.trigger_event || '-'} />
|
||||
{engine.affected_person && (
|
||||
<DetailRow label="Betroffene Personen" gt={engine.affected_person} />
|
||||
)}
|
||||
{engine.mitigations && engine.mitigations.length > 0 ? (
|
||||
<DetailRow label="Massnahmen" gt={engine.mitigations.join('\n')} multiline />
|
||||
) : (
|
||||
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] font-medium text-gray-500 uppercase">{label}</div>
|
||||
{multiline ? (
|
||||
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans mt-0.5">{gt}</pre>
|
||||
) : (
|
||||
<div className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{gt}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
|
||||
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-red-50 dark:bg-red-900/20">
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Nr.</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Gefaehrdung</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Ursache</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Zone</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-red-600">R</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-red-600">Typ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i} className="hover:bg-red-50/50">
|
||||
<td className="px-3 py-2 text-gray-400">{e.nr}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
|
||||
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
|
||||
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function ExtraTable({ entries }: { entries: HazardSummary[] }) {
|
||||
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
|
||||
return (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Kategorie</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">Zone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{e.category}</td>
|
||||
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskBadge({ risk }: { risk: number }) {
|
||||
const color = risk >= 30 ? 'bg-red-100 text-red-700' : risk >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
|
||||
return <span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${color}`}>{risk}</span>
|
||||
}
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const pct = Math.round(score * 100)
|
||||
const color = pct >= 70 ? 'text-green-600' : pct >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
return <span className={`font-bold ${color}`}>{pct}%</span>
|
||||
}
|
||||
|
||||
function QualityBadge({ quality }: { quality: 'green' | 'yellow' | 'red' }) {
|
||||
const styles = {
|
||||
green: 'bg-green-100 text-green-700 border-green-200',
|
||||
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
red: 'bg-red-100 text-red-700 border-red-200',
|
||||
}
|
||||
const labels = { green: 'Exakt', yellow: 'Aehnlich', red: 'Schwach' }
|
||||
return (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded border text-[10px] font-medium ${styles[quality]}`}>
|
||||
{labels[quality]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export interface GTRisk { f: number; w: number; p: number; s: number; r: number }
|
||||
export interface GTPLr { s: string; f: string; p: string; ew?: string; plr: string }
|
||||
|
||||
export interface GroundTruthEntry {
|
||||
nr: string
|
||||
hazard_group: string
|
||||
hazard_group_applicable: boolean
|
||||
hazard_subgroup: string
|
||||
hazard_type: string
|
||||
hazard_cause: string
|
||||
lifecycle_phases: string[]
|
||||
component_zone: string
|
||||
risk_in: GTRisk
|
||||
plr?: GTPLr | null
|
||||
measures: string[]
|
||||
measure_type: string
|
||||
risk_out: GTRisk
|
||||
norm_references: string[]
|
||||
sufficient: boolean
|
||||
comment?: string
|
||||
reduction_steps?: {
|
||||
risk_in: GTRisk; measures: string[]; measure_type: string
|
||||
risk_out: GTRisk; norm_references: string[]; sufficient: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface HazardSummary {
|
||||
id: string; name: string; category: string
|
||||
component?: string; zone?: string; risk_level?: string
|
||||
description?: string; scenario?: string
|
||||
possible_harm?: string; trigger_event?: string
|
||||
affected_person?: string; lifecycle_phase?: string
|
||||
mitigations?: string[]
|
||||
}
|
||||
|
||||
export interface HazardMatchPair {
|
||||
gt_entry: GroundTruthEntry
|
||||
engine_hazard: HazardSummary
|
||||
match_score: number
|
||||
match_reason: string
|
||||
}
|
||||
|
||||
export interface CategoryScore {
|
||||
category: string; gt_count: number; match_count: number; coverage: number
|
||||
}
|
||||
|
||||
export interface BenchmarkResult {
|
||||
coverage_score: number
|
||||
measure_coverage: number
|
||||
total_gt: number
|
||||
total_engine: number
|
||||
matched_pairs: HazardMatchPair[]
|
||||
missing_from_engine: GroundTruthEntry[]
|
||||
extra_in_engine: HazardSummary[]
|
||||
category_breakdown: CategoryScore[]
|
||||
risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[]
|
||||
}
|
||||
|
||||
interface UseBenchmarkReturn {
|
||||
result: BenchmarkResult | null
|
||||
gtLoaded: boolean
|
||||
gtEntryCount: number
|
||||
loading: boolean
|
||||
error: string | null
|
||||
importGT: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||
runBenchmark: (gtProjectId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useBenchmark(projectId: string): UseBenchmarkReturn {
|
||||
const [result, setResult] = useState<BenchmarkResult | null>(null)
|
||||
const [gtLoaded, setGtLoaded] = useState(false)
|
||||
const [gtEntryCount, setGtEntryCount] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const importGT = useCallback(async (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark/import-gt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(gt),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
setGtLoaded(true)
|
||||
setGtEntryCount(data.entry_count || gt.entries.length)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
const runBenchmark = useCallback(async (gtProjectId?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = gtProjectId ? `?gt_project_id=${gtProjectId}` : ''
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark${params}`)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data: BenchmarkResult = await res.json()
|
||||
setResult(data)
|
||||
setGtLoaded(true)
|
||||
setGtEntryCount(data.total_gt)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Benchmark failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
return { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useBenchmark } from './_hooks/useBenchmark'
|
||||
import { GTImportForm } from './_components/GTImportForm'
|
||||
import { HazardComparisonTable } from './_components/HazardComparisonTable'
|
||||
import { CategoryBreakdown } from './_components/CategoryBreakdown'
|
||||
|
||||
export default function BenchmarkPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
|
||||
const [gtProjectId, setGtProjectId] = useState('')
|
||||
|
||||
// Only count matches >= 50% as real coverage
|
||||
const realMatchCount = result ? (result.matched_pairs?.filter(m => m.match_score >= 0.5).length || 0) : 0
|
||||
const coveragePct = result ? Math.round(realMatchCount * 100 / Math.max(result.total_gt, 1)) : 0
|
||||
const measurePct = result ? Math.round(result.measure_coverage * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-[1200px]">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">Ground Truth Benchmark</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Vergleich der Engine-Ergebnisse mit einer professionellen Risikobeurteilung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GT Import or Cross-Project Reference */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<GTImportForm onImport={importGT} loading={loading} />
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Benchmark ausfuehren</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
GT aus diesem Projekt verwenden, oder eine Projekt-ID mit importierter GT angeben.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={gtProjectId}
|
||||
onChange={(e) => setGtProjectId(e.target.value)}
|
||||
placeholder="GT-Projekt-ID (optional — leer = dieses Projekt)"
|
||||
className="w-full text-xs border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-gray-50 dark:bg-gray-900"
|
||||
/>
|
||||
<button
|
||||
onClick={() => runBenchmark(gtProjectId || undefined)}
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||
>
|
||||
{loading ? 'Vergleiche...' : 'Benchmark starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{gtLoaded && !result && (
|
||||
<div className="mt-3 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-blue-600">
|
||||
{gtEntryCount} GT-Eintraege geladen. Klicke "Benchmark starten".
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<>
|
||||
{/* Score Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<ScoreCard
|
||||
label="Hazard Coverage"
|
||||
value={`${coveragePct}%`}
|
||||
sub={`${realMatchCount} / ${result.total_gt} erkannt (>= 50% Match)`}
|
||||
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<ScoreCard
|
||||
label="Massnahmen-Coverage"
|
||||
value={`${measurePct}%`}
|
||||
sub="der zugeordneten Gefaehrdungen"
|
||||
color={measurePct >= 80 ? 'green' : measurePct >= 50 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<ScoreCard
|
||||
label="GT Eintraege"
|
||||
value={String(result.total_gt)}
|
||||
sub="professionelle Beurteilung"
|
||||
color="gray"
|
||||
/>
|
||||
<ScoreCard
|
||||
label="Engine Eintraege"
|
||||
value={String(result.total_engine)}
|
||||
sub={`${result.extra_in_engine?.length || 0} zusaetzlich`}
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Breakdown */}
|
||||
<CategoryBreakdown breakdown={result.category_breakdown || []} />
|
||||
|
||||
{/* Hazard Comparison Table */}
|
||||
<HazardComparisonTable
|
||||
matched={result.matched_pairs || []}
|
||||
missing={result.missing_from_engine || []}
|
||||
extra={result.extra_in_engine || []}
|
||||
/>
|
||||
|
||||
{/* Business Impact */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Business Impact</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">2,5 Tage</div>
|
||||
<div className="text-xs text-gray-500">Manueller Aufwand</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{(coveragePct / 100 * 2.5).toFixed(1)} Tage
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Eingespart bei {coveragePct}% Coverage</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{Math.round(coveragePct / 100 * 2.5 * 8 * 100)} EUR
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Einsparung (100 EUR/h)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreCard({ label, value, sub, color }: {
|
||||
label: string; value: string; sub: string
|
||||
color: 'green' | 'yellow' | 'red' | 'gray'
|
||||
}) {
|
||||
const colors = {
|
||||
green: 'border-green-200 dark:border-green-800',
|
||||
yellow: 'border-yellow-200 dark:border-yellow-800',
|
||||
red: 'border-red-200 dark:border-red-800',
|
||||
gray: 'border-gray-200 dark:border-gray-700',
|
||||
}
|
||||
const textColors = {
|
||||
green: 'text-green-600', yellow: 'text-yellow-600',
|
||||
red: 'text-red-600', gray: 'text-gray-900 dark:text-white',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg border-2 ${colors[color]} p-4 text-center`}>
|
||||
<div className={`text-2xl font-bold ${textColors[color]}`}>{value}</div>
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1">{label}</div>
|
||||
<div className="text-[10px] text-gray-400 mt-0.5">{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Hazard } from './types'
|
||||
import { RiskAssessmentTable } from './RiskAssessmentTable'
|
||||
|
||||
interface BlockData {
|
||||
parent_hazard: { hazard: { id: string } }
|
||||
children: { hazard: { id: string } }[]
|
||||
children_covered_by_parent: boolean
|
||||
block_key: string
|
||||
}
|
||||
|
||||
interface BlockInfo {
|
||||
isParent: boolean
|
||||
isChild: boolean
|
||||
isCovered: boolean
|
||||
blockKey: string
|
||||
parentId: string
|
||||
childCount: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projectId: string
|
||||
hazards: Hazard[]
|
||||
onReassess?: () => void
|
||||
decisions?: Record<string, boolean | null>
|
||||
onDecision?: (hazardId: string, acceptable: boolean | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps RiskAssessmentTable with block-awareness:
|
||||
* - Injects block metadata into hazards so the table can show grouping
|
||||
* - Provides controls to ungroup/promote children
|
||||
*/
|
||||
export function BlockAwareRiskTable({ projectId, hazards, onReassess, decisions, onDecision }: Props) {
|
||||
const [blocks, setBlocks] = useState<BlockData[]>([])
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [ungrouped, setUngrouped] = useState<Record<string, boolean>>({})
|
||||
const [pendingAction, setPendingAction] = useState<{ childId: string; childName: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.blocks) setBlocks(d.blocks) })
|
||||
.catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
// Build lookup: hazardId → block info
|
||||
const blockMap = useMemo(() => {
|
||||
const map: Record<string, BlockInfo> = {}
|
||||
for (const b of blocks) {
|
||||
if (b.children.length === 0) continue
|
||||
const pid = b.parent_hazard.hazard.id
|
||||
map[pid] = {
|
||||
isParent: true, isChild: false, isCovered: false,
|
||||
blockKey: b.block_key, parentId: pid, childCount: b.children.length,
|
||||
}
|
||||
for (const c of b.children) {
|
||||
if (ungrouped[c.hazard.id]) continue
|
||||
map[c.hazard.id] = {
|
||||
isParent: false, isChild: true,
|
||||
isCovered: b.children_covered_by_parent,
|
||||
blockKey: b.block_key, parentId: pid, childCount: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [blocks, ungrouped])
|
||||
|
||||
// Sort hazards: parents first, then their children, then standalone
|
||||
const sortedHazards = useMemo(() => {
|
||||
const parents: Hazard[] = []
|
||||
const childrenByParent: Record<string, Hazard[]> = {}
|
||||
const standalone: Hazard[] = []
|
||||
|
||||
for (const h of hazards) {
|
||||
const info = blockMap[h.id]
|
||||
if (!info) {
|
||||
standalone.push(h)
|
||||
} else if (info.isParent) {
|
||||
parents.push(h)
|
||||
childrenByParent[h.id] = []
|
||||
} else if (info.isChild) {
|
||||
const arr = childrenByParent[info.parentId]
|
||||
if (arr) arr.push(h)
|
||||
else standalone.push(h)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort parents by risk desc
|
||||
parents.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
standalone.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
|
||||
// Interleave: parent → children → parent → children → ... → standalone
|
||||
const result: Hazard[] = []
|
||||
for (const p of parents) {
|
||||
result.push(p)
|
||||
const isCollapsed = collapsed[p.id]
|
||||
if (!isCollapsed && childrenByParent[p.id]) {
|
||||
result.push(...childrenByParent[p.id])
|
||||
}
|
||||
}
|
||||
result.push(...standalone)
|
||||
return result
|
||||
}, [hazards, blockMap, collapsed])
|
||||
|
||||
const toggleCollapse = (parentId: string) => {
|
||||
setCollapsed(prev => ({ ...prev, [parentId]: !prev[parentId] }))
|
||||
}
|
||||
|
||||
const handleUngroup = (childId: string) => {
|
||||
setUngrouped(prev => ({ ...prev, [childId]: true }))
|
||||
setPendingAction(null)
|
||||
}
|
||||
|
||||
const handleRegroup = (childId: string) => {
|
||||
setUngrouped(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[childId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Count blocks with children
|
||||
const blockCount = blocks.filter(b => b.children.length > 0).length
|
||||
const coveredCount = Object.values(blockMap).filter(b => b.isChild && b.isCovered).length
|
||||
const ungroupedCount = Object.keys(ungrouped).length
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Confirmation dialog */}
|
||||
{pendingAction && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 max-w-md w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">Gefaehrdung aus Block entfernen?</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<strong>{pendingAction.childName}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Der Punkt wird als eigenstaendige Gefaehrdung gefuehrt und muss separat bewertet werden.
|
||||
Sie koennen ihn jederzeit ueber "Zurueck in Block" wieder zuordnen.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleUngroup(pendingAction.childId)}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
Als eigenen Punkt fuehren
|
||||
</button>
|
||||
<button onClick={() => setPendingAction(null)}
|
||||
className="flex-1 px-3 py-2 text-xs font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block info bar */}
|
||||
{blockCount > 0 && (
|
||||
<div className="flex items-center gap-4 px-4 py-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-xs">
|
||||
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||
{blockCount} Bloecke erkannt
|
||||
</span>
|
||||
{coveredCount > 0 && (
|
||||
<span className="text-green-600">
|
||||
{coveredCount} Kinder durch Mutter abgedeckt
|
||||
</span>
|
||||
)}
|
||||
{ungroupedCount > 0 && (
|
||||
<button onClick={() => setUngrouped({})}
|
||||
className="text-orange-600 hover:text-orange-700 underline">
|
||||
{ungroupedCount} entgruppiert — alle zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced table with block decorations */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="w-8 px-1 py-1.5"></th>
|
||||
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
|
||||
<th colSpan={4} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Risiko (S x F x P)</th>
|
||||
<th className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{sortedHazards.map(h => {
|
||||
const info = blockMap[h.id]
|
||||
const isParent = info?.isParent
|
||||
const isChild = info?.isChild
|
||||
const isCovered = info?.isCovered
|
||||
const childCount = info?.childCount || 0
|
||||
const isCollapsedParent = isParent && collapsed[h.id]
|
||||
|
||||
return (
|
||||
<tr key={h.id} className={`transition-colors ${
|
||||
isChild ? 'bg-gray-50/50 dark:bg-gray-850' :
|
||||
isParent ? 'bg-white dark:bg-gray-800' : ''
|
||||
} ${isCovered ? 'opacity-60' : ''} hover:bg-gray-50 dark:hover:bg-gray-750`}>
|
||||
{/* Block indicator */}
|
||||
<td className="px-1 py-2 text-center">
|
||||
{isParent && (
|
||||
<button onClick={() => toggleCollapse(h.id)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-purple-100 text-purple-600 transition-colors"
|
||||
title={`${childCount} Kinder ${isCollapsedParent ? 'anzeigen' : 'verbergen'}`}>
|
||||
<svg className={`w-3 h-3 transition-transform ${isCollapsedParent ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{isChild && (
|
||||
<div className="flex items-center justify-center">
|
||||
<button onClick={() => setPendingAction({ childId: h.id, childName: h.name })}
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-orange-100 text-gray-300 hover:text-orange-500 transition-colors"
|
||||
title="Aus Block entfernen (mit Bestaetigung)">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Show regroup button for ungrouped items */}
|
||||
{!isParent && !isChild && ungrouped[h.id] && (
|
||||
<button onClick={() => handleRegroup(h.id)}
|
||||
className="w-5 h-5 flex items-center justify-center rounded hover:bg-green-100 text-orange-400 hover:text-green-600 transition-colors"
|
||||
title="Zurueck in Block">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* Name */}
|
||||
<td className={`px-3 py-2 ${isChild ? 'pl-8' : ''}`}>
|
||||
<div className={`font-medium ${isParent ? 'text-purple-800 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
|
||||
{h.name}
|
||||
{isParent && <span className="ml-1 text-[10px] text-purple-500">({childCount})</span>}
|
||||
</div>
|
||||
{h.hazardous_zone && <div className="text-[10px] text-gray-400 truncate max-w-[200px]">{h.hazardous_zone}</div>}
|
||||
</td>
|
||||
{/* Category */}
|
||||
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600 text-gray-500">
|
||||
{h.category?.replace(/_/g, ' ')}
|
||||
</td>
|
||||
{/* Risk */}
|
||||
<td className="px-2 py-2 text-center">{h.severity || '-'}</td>
|
||||
<td className="px-2 py-2 text-center">{h.exposure || '-'}</td>
|
||||
<td className="px-2 py-2 text-center">{h.probability || '-'}</td>
|
||||
<td className="px-2 py-2 text-center font-bold border-r border-gray-200 dark:border-gray-600">
|
||||
{h.r_inherent || '-'}
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="px-3 py-2 text-center">
|
||||
{isCovered ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-medium">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Abgedeckt
|
||||
</span>
|
||||
) : h.r_inherent ? (
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
(h.r_inherent || 0) <= 20 ? 'bg-green-100 text-green-700' :
|
||||
(h.r_inherent || 0) <= 60 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(h.r_inherent || 0) <= 20 ? 'Niedrig' : (h.r_inherent || 0) <= 60 ? 'Mittel' : 'Hoch'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">Offen</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { CATEGORY_LABELS } from './types'
|
||||
import { RiskBadge } from './RiskBadge'
|
||||
|
||||
interface BlockHazard {
|
||||
hazard: {
|
||||
id: string; name: string; description: string; category: string
|
||||
hazardous_zone: string; scenario?: string; possible_harm?: string
|
||||
}
|
||||
assessment?: { severity: number; exposure: number; probability: number; inherent_risk: number; risk_level: string } | null
|
||||
mitigation_ids: string[]
|
||||
}
|
||||
|
||||
interface HazardBlock {
|
||||
parent_hazard: BlockHazard
|
||||
children: BlockHazard[]
|
||||
block_key: string
|
||||
shared_measure_count: number
|
||||
children_covered_by_parent: boolean
|
||||
}
|
||||
|
||||
interface BlockSummary {
|
||||
total_blocks: number
|
||||
parent_only_blocks: number
|
||||
blocks_with_children: number
|
||||
total_hazards: number
|
||||
covered_children: number
|
||||
uncovered_children: number
|
||||
assessments_needed: number
|
||||
assessments_saved: number
|
||||
}
|
||||
|
||||
export function HazardBlockView() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const [blocks, setBlocks] = useState<HazardBlock[]>([])
|
||||
const [summary, setSummary] = useState<BlockSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
setBlocks(data.blocks || [])
|
||||
setSummary(data.summary || null)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
const toggle = (key: string) => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400 py-8 text-center">Lade Bloecke...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<SummaryCard label="Bloecke" value={summary.total_blocks} sub={`${summary.total_hazards} Gefaehrdungen`} />
|
||||
<SummaryCard label="Mit Kindern" value={summary.blocks_with_children} sub={`${summary.covered_children} abgedeckt`} color="green" />
|
||||
<SummaryCard label="Bewertungen noetig" value={summary.assessments_needed} sub={`von ${summary.total_hazards}`} color="purple" />
|
||||
<SummaryCard label="Eingespart" value={summary.assessments_saved} sub="durch Gruppierung" color="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block List */}
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block) => {
|
||||
const isOpen = expanded[block.block_key]
|
||||
const parent = block.parent_hazard
|
||||
const childCount = block.children.length
|
||||
const covered = block.children_covered_by_parent
|
||||
|
||||
return (
|
||||
<div key={block.block_key} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Parent Row */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${childCount > 0 ? '' : 'opacity-90'}`}
|
||||
onClick={() => childCount > 0 && toggle(block.block_key)}
|
||||
>
|
||||
{/* Expand Arrow */}
|
||||
{childCount > 0 ? (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<div className="w-4 h-4" />
|
||||
)}
|
||||
|
||||
{/* Name + Category */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{parent.hazard.name}</span>
|
||||
<span className="text-xs text-gray-400">{CATEGORY_LABELS[parent.hazard.category] || parent.hazard.category}</span>
|
||||
</div>
|
||||
{parent.hazard.hazardous_zone && (
|
||||
<div className="text-xs text-gray-500 truncate">{parent.hazard.hazardous_zone}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk */}
|
||||
{parent.assessment ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-500">R={parent.assessment.inherent_risk}</span>
|
||||
<RiskBadge level={parent.assessment.risk_level} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Nicht bewertet</span>
|
||||
)}
|
||||
|
||||
{/* Child count badge */}
|
||||
{childCount > 0 && (
|
||||
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
covered
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}`}>
|
||||
+{childCount}
|
||||
{covered && (
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Measures count */}
|
||||
<span className="text-xs text-gray-400">{block.shared_measure_count} M.</span>
|
||||
</div>
|
||||
|
||||
{/* Children (expanded) */}
|
||||
{isOpen && childCount > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-850">
|
||||
{covered && (
|
||||
<div className="px-4 py-2 text-xs text-green-600 dark:text-green-400 bg-green-50/50 dark:bg-green-900/10 border-b border-green-100 dark:border-green-900/30">
|
||||
Alle Untergefaehrdungen durch Massnahmen der Muttergefaehrdung abgedeckt — keine separate Bewertung noetig.
|
||||
</div>
|
||||
)}
|
||||
{block.children.map((child) => (
|
||||
<div key={child.hazard.id} className="flex items-center gap-3 px-4 py-2 pl-12 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{child.hazard.name}</span>
|
||||
{child.hazard.hazardous_zone && (
|
||||
<span className="text-xs text-gray-400 ml-2">[{child.hazard.hazardous_zone}]</span>
|
||||
)}
|
||||
</div>
|
||||
{child.assessment ? (
|
||||
<span className="text-xs text-gray-500">R={child.assessment.inherent_risk}</span>
|
||||
) : covered ? (
|
||||
<span className="text-xs text-green-500">Abgedeckt</span>
|
||||
) : (
|
||||
<span className="text-xs text-yellow-500">Offen</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, sub, color }: { label: string; value: number; sub: string; color?: string }) {
|
||||
const textColor = color === 'green' ? 'text-green-600' : color === 'purple' ? 'text-purple-600' : 'text-gray-900 dark:text-white'
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
|
||||
<div className={`text-xl font-bold ${textColor}`}>{value}</div>
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{label}</div>
|
||||
<div className="text-[10px] text-gray-400">{sub}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { HazardForm } from './_components/HazardForm'
|
||||
import { HazardTable } from './_components/HazardTable'
|
||||
import { HazardBlockView } from './_components/HazardBlockView'
|
||||
import { BlockAwareRiskTable } from './_components/BlockAwareRiskTable'
|
||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||
import { ResidualRiskPanel, getResidualStatus } from './_components/ResidualRiskPanel'
|
||||
import type { ResidualFilter } from './_components/ResidualRiskPanel'
|
||||
@@ -12,7 +14,7 @@ import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||
import { CustomHazardModal } from './_components/CustomHazardModal'
|
||||
import { useHazards } from './_hooks/useHazards'
|
||||
|
||||
type ViewMode = 'list' | 'risk'
|
||||
type ViewMode = 'list' | 'risk' | 'blocks'
|
||||
|
||||
export default function HazardsPage() {
|
||||
const params = useParams()
|
||||
@@ -69,6 +71,10 @@ export default function HazardsPage() {
|
||||
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||
Risikobewertung
|
||||
</button>
|
||||
<button onClick={() => setView('blocks')}
|
||||
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'blocks' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||
Bloecke
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -169,9 +175,11 @@ export default function HazardsPage() {
|
||||
<>
|
||||
<ResidualRiskPanel hazards={h.hazards} decisions={decisions}
|
||||
activeFilter={residualFilter} onFilterChange={setResidualFilter} />
|
||||
<RiskAssessmentTable projectId={projectId} hazards={filteredHazards}
|
||||
<BlockAwareRiskTable projectId={projectId} hazards={filteredHazards}
|
||||
onReassess={h.refetch} decisions={decisions} onDecision={handleDecision} />
|
||||
</>
|
||||
) : view === 'blocks' ? (
|
||||
<HazardBlockView />
|
||||
) : (
|
||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||
)
|
||||
|
||||
+52
-1
@@ -119,10 +119,61 @@ export function ReportPrintView({ data }: ReportPrintViewProps) {
|
||||
Herstellers nach EU Maschinenverordnung 2023/1230 Art. 10.
|
||||
</div>
|
||||
|
||||
{/* 2. Inhaltsverzeichnis */}
|
||||
{/* 2. Methodik der Risikobeurteilung (Erklaerteil) */}
|
||||
<div className="section-break">
|
||||
<h2>Methodik der Risikobeurteilung</h2>
|
||||
<p>
|
||||
Diese Risikobeurteilung orientiert sich an den Grundprinzipien der EN ISO 12100,
|
||||
EN 62061 und EN ISO 13849-1. Bewertet werden Grenzen des Produkts, identifizierte
|
||||
Gefaehrdungen, die jeweilige Risikohoehe sowie das Restrisiko nach Anwendung von
|
||||
Schutzmassnahmen.
|
||||
</p>
|
||||
<p>
|
||||
Der Prozess ist iterativ: Reicht eine Massnahme nicht aus, werden weitere ergriffen
|
||||
und das Restrisiko erneut bewertet, bis ein akzeptables Niveau erreicht ist.
|
||||
</p>
|
||||
<h3>Risikoberechnung</h3>
|
||||
<p>Das Ausgangsrisiko ergibt sich aus: <strong>R = S × F × P × A</strong></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Faktor</th><th>Beschreibung</th><th>Skala</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>S</strong></td><td>Schadensschwere</td><td>1 (Erste Hilfe) – 5 (toedlich)</td></tr>
|
||||
<tr><td><strong>F</strong></td><td>Expositionshaeufigkeit</td><td>1 (selten/kurz) – 5 (dauerhaft)</td></tr>
|
||||
<tr><td><strong>P</strong></td><td>Eintrittswahrscheinlichkeit</td><td>1 (vernachlaessigbar) – 5 (fast sicher)</td></tr>
|
||||
<tr><td><strong>A</strong></td><td>Vermeidbarkeit</td><td>1 (leicht vermeidbar) – 5 (unvermeidbar)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Bei Sicherheitskreisen wird der Performance Level (PLr) ueber einen Risikographen
|
||||
abgeleitet und dem Safety Integrity Level (SIL) zugeordnet.
|
||||
</p>
|
||||
<h3>Dreistufenmethode</h3>
|
||||
<p>Schutzmassnahmen werden priorisiert angewandt:</p>
|
||||
<ol>
|
||||
<li><strong>Konstruktive Massnahmen (KM)</strong> — Inhaerent sichere Gestaltung</li>
|
||||
<li><strong>Technische Schutzmassnahmen (TM)</strong> — Schutzeinrichtungen, Sicherheitssteuerungen</li>
|
||||
<li><strong>Benutzerinformationen (BI)</strong> — Warnhinweise, Betriebsanleitung</li>
|
||||
</ol>
|
||||
<h3>Akzeptanz des Restrisikos</h3>
|
||||
<p>
|
||||
Ein Restrisiko gilt als hinreichend gemindert, wenn alle praktisch umsetzbaren Massnahmen
|
||||
ausgeschoepft wurden und Anwender ueber verbleibende Restrisiken informiert sind.
|
||||
Die Akzeptanz wird pro Gefaehrdung mit <strong>JA</strong> / <strong>NEIN</strong> dokumentiert.
|
||||
</p>
|
||||
<p style={{ fontStyle: 'italic', fontSize: '9pt', color: '#374151' }}>
|
||||
„Die Moeglichkeit, einen hoeheren Sicherheitsgrad zu erreichen, oder die Verfuegbarkeit
|
||||
anderer Produkte, die ein geringeres Risiko darstellen, ist kein ausreichender Grund,
|
||||
ein Produkt als gefaehrlich anzusehen.“ — § 3 Abs. 2 ProdSG
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3. Inhaltsverzeichnis */}
|
||||
<div className="section-break">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
<ol className="toc">
|
||||
<li>Methodik der Risikobeurteilung</li>
|
||||
<li>Maschinenbeschreibung</li>
|
||||
<li>Angewandte Normen</li>
|
||||
<li>Gefaehrdungsliste</li>
|
||||
|
||||
@@ -24,6 +24,7 @@ const IACE_EXTRA_ITEMS = [
|
||||
{ id: 'knowledge-graph', label: 'Knowledge Graph', href: '/knowledge-graph', icon: 'activity' },
|
||||
{ id: 'classification', label: 'Klassifikation', href: '/classification', icon: 'tag' },
|
||||
{ id: 'monitoring', label: 'Monitoring', href: '/monitoring', icon: 'activity' },
|
||||
{ id: 'benchmark', label: 'Benchmark', href: '/benchmark', icon: 'check' },
|
||||
]
|
||||
|
||||
function NavIcon({ icon, className }: { icon: string; className?: string }) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
assessmentId: string
|
||||
backendUrl: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onComplete: (result: any) => void
|
||||
onError: (msg: string) => void
|
||||
}
|
||||
|
||||
export function AssessmentProgress({ assessmentId, backendUrl, onComplete, onError }: Props) {
|
||||
const [progress, setProgress] = useState('Initialisierung...')
|
||||
const [dots, setDots] = useState(0)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dotTimer = setInterval(() => setDots(d => (d + 1) % 4), 500)
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${backendUrl}/api/vendor-compliance/assessments/${assessmentId}`,
|
||||
)
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.status === 'completed' && data.result) {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
clearInterval(dotTimer)
|
||||
onComplete(data.result)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'failed') {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
clearInterval(dotTimer)
|
||||
onError(data.error || 'Pruefung fehlgeschlagen')
|
||||
return
|
||||
}
|
||||
|
||||
if (data.progress) {
|
||||
setProgress(data.progress)
|
||||
}
|
||||
} catch {
|
||||
// retry silently
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
clearInterval(dotTimer)
|
||||
}
|
||||
}, [assessmentId, backendUrl, onComplete, onError])
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 relative">
|
||||
<div className="absolute inset-0 border-4 border-blue-200 rounded-full" />
|
||||
<div className="absolute inset-0 border-4 border-blue-600 rounded-full border-t-transparent animate-spin" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-2">
|
||||
Vertragspruefung laeuft
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{progress}{'.'.repeat(dots)}
|
||||
</p>
|
||||
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">1</span>
|
||||
Text extrahieren
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">2</span>
|
||||
Checklisten pruefen (L1/L2)
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-left text-xs text-gray-500 mb-2">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">3</span>
|
||||
Cross-Check zwischen Dokumenten
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-left text-xs text-gray-500">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold">4</span>
|
||||
Pruefprotokoll generieren
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface DocumentEntry {
|
||||
doc_type: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onStart: (vendorName: string, documents: DocumentEntry[]) => void
|
||||
}
|
||||
|
||||
const DOC_TYPES = [
|
||||
{ value: 'auto', label: 'Automatisch erkennen' },
|
||||
{ value: 'avv', label: 'AVV / Auftragsverarbeitungsvertrag' },
|
||||
{ value: 'scc', label: 'SCC / Standardvertragsklauseln' },
|
||||
{ value: 'tom_annex', label: 'TOM-Anlage (Art. 32)' },
|
||||
{ value: 'sub_processor_list', label: 'Sub-Processor-Liste' },
|
||||
{ value: 'agb', label: 'AGB / Nutzungsbedingungen' },
|
||||
]
|
||||
|
||||
export function DocumentUploader({ onStart }: Props) {
|
||||
const [vendorName, setVendorName] = useState('')
|
||||
const [entries, setEntries] = useState<DocumentEntry[]>([
|
||||
{ doc_type: 'auto', label: '', url: '' },
|
||||
])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const updateEntry = (idx: number, field: keyof DocumentEntry, value: string) => {
|
||||
setEntries(prev => {
|
||||
const copy = [...prev]
|
||||
copy[idx] = { ...copy[idx], [field]: value }
|
||||
return copy
|
||||
})
|
||||
}
|
||||
|
||||
const addEntry = () => {
|
||||
setEntries(prev => [...prev, { doc_type: 'auto', label: '', url: '' }])
|
||||
}
|
||||
|
||||
const removeEntry = (idx: number) => {
|
||||
if (entries.length <= 1) return
|
||||
setEntries(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const valid = entries.filter(d => d.url.trim())
|
||||
if (!vendorName.trim() || valid.length === 0) return
|
||||
setLoading(true)
|
||||
onStart(vendorName.trim(), valid.map(d => ({
|
||||
...d,
|
||||
label: d.label || `${DOC_TYPES.find(t => t.value === d.doc_type)?.label || d.doc_type}: ${vendorName}`,
|
||||
})))
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Vendor Name */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Auftragsverarbeiter / Provider *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={vendorName}
|
||||
onChange={e => setVendorName(e.target.value)}
|
||||
placeholder="z.B. SysEleven GmbH, Amazon Web Services, Microsoft"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Documents */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">Dokumente</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Fuegen Sie die URLs der Vertragsdokumente hinzu. Das System erkennt den Dokumenttyp automatisch.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{entries.map((entry, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="w-52 shrink-0">
|
||||
<select
|
||||
value={entry.doc_type}
|
||||
onChange={e => updateEntry(idx, 'doc_type', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white"
|
||||
>
|
||||
{DOC_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="url"
|
||||
value={entry.url}
|
||||
onChange={e => updateEntry(idx, 'url', e.target.value)}
|
||||
placeholder="https://example.com/avv.pdf"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44 shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={entry.label}
|
||||
onChange={e => updateEntry(idx, 'label', e.target.value)}
|
||||
placeholder="Bezeichnung (optional)"
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
{entries.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEntry(idx)}
|
||||
className="p-2 text-gray-400 hover:text-red-500"
|
||||
title="Entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEntry}
|
||||
className="mt-3 w-full py-2.5 border-2 border-dashed border-gray-300 rounded-lg text-gray-500 hover:border-blue-400 hover:text-blue-600 text-sm"
|
||||
>
|
||||
+ Weiteres Dokument hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !vendorName.trim() || !entries.some(e => e.url.trim())}
|
||||
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Wird gestartet...' : 'Pruefung starten'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400">
|
||||
Dokumente werden automatisch gegen Art. 28 DSGVO, Art. 32, Art. 44-49 und weitere Anforderungen geprueft.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
result: {
|
||||
vendor_name: string
|
||||
documents: DocumentResult[]
|
||||
findings: Finding[]
|
||||
overall_score: number
|
||||
category_scores: Record<string, number>
|
||||
cross_check_findings: CrossCheckFinding[]
|
||||
report_html: string
|
||||
checked_at: string
|
||||
}
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
interface DocumentResult {
|
||||
label: string
|
||||
doc_type: string
|
||||
completeness_pct: number
|
||||
correctness_pct: number
|
||||
checks: Check[]
|
||||
findings_count: number
|
||||
error: string
|
||||
}
|
||||
|
||||
interface Check {
|
||||
id: string
|
||||
label: string
|
||||
passed: boolean
|
||||
severity: string
|
||||
level: number
|
||||
parent: string | null
|
||||
skipped: boolean
|
||||
hint: string
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
id: string
|
||||
category: string
|
||||
severity: string
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
document_label: string
|
||||
document_type: string
|
||||
}
|
||||
|
||||
interface CrossCheckFinding {
|
||||
id: string
|
||||
label: string
|
||||
severity: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
INSTRUCTION: 'Weisungsgebundenheit',
|
||||
CONFIDENTIALITY: 'Vertraulichkeit',
|
||||
TOM: 'TOM (Art. 32)',
|
||||
SUBPROCESSOR: 'Unterauftragsverarbeitung',
|
||||
DATA_SUBJECT_RIGHTS: 'Betroffenenrechte',
|
||||
DELETION: 'Loeschung/Rueckgabe',
|
||||
AUDIT_RIGHTS: 'Audit-/Inspektionsrechte',
|
||||
INCIDENT: 'Datenschutzverletzungen',
|
||||
TRANSFER: 'Drittlandtransfer',
|
||||
LIABILITY: 'Haftung',
|
||||
AVV_CONTENT: 'AVV Inhalt',
|
||||
}
|
||||
|
||||
const SEV_COLORS: Record<string, string> = {
|
||||
CRITICAL: 'bg-red-100 text-red-700 border-red-200',
|
||||
HIGH: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
MEDIUM: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
LOW: 'bg-green-100 text-green-700 border-green-200',
|
||||
}
|
||||
|
||||
function scoreColor(s: number) {
|
||||
if (s >= 80) return 'text-green-600'
|
||||
if (s >= 50) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
function scoreBg(s: number) {
|
||||
if (s >= 80) return 'bg-green-50 border-green-200'
|
||||
if (s >= 50) return 'bg-yellow-50 border-yellow-200'
|
||||
return 'bg-red-50 border-red-200'
|
||||
}
|
||||
|
||||
function verdict(s: number) {
|
||||
if (s >= 80) return 'Bestanden'
|
||||
if (s >= 50) return 'Bedingt bestanden'
|
||||
return 'Nicht bestanden'
|
||||
}
|
||||
|
||||
export function PruefprotokollView({ result, onReset }: Props) {
|
||||
const [expandedDoc, setExpandedDoc] = useState<number | null>(null)
|
||||
const [showHtml, setShowHtml] = useState(false)
|
||||
|
||||
const criticalCount = result.findings.filter(f => f.severity === 'CRITICAL').length
|
||||
+ result.cross_check_findings.filter(f => f.severity === 'CRITICAL').length
|
||||
const totalFindings = result.findings.length + result.cross_check_findings.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Score Overview */}
|
||||
<div className={`rounded-xl border-2 p-8 text-center ${scoreBg(result.overall_score)}`}>
|
||||
<div className="text-sm text-gray-500 mb-1">Pruefprotokoll — {result.vendor_name}</div>
|
||||
<div className={`text-6xl font-extrabold ${scoreColor(result.overall_score)}`}>
|
||||
{result.overall_score}%
|
||||
</div>
|
||||
<div className="text-lg text-gray-600 mt-1">{verdict(result.overall_score)}</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border p-5 text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{result.documents.length}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Dokumente</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-5 text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{totalFindings}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Findings</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-5 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{criticalCount}</div>
|
||||
<div className="text-xs text-gray-500 mt-1 uppercase tracking-wide">Kritisch</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Scores */}
|
||||
{Object.keys(result.category_scores).length > 0 && (
|
||||
<div className="bg-white rounded-xl border p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Kategorie-Uebersicht</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(result.category_scores)
|
||||
.sort(([, a], [, b]) => a - b)
|
||||
.map(([cat, score]) => (
|
||||
<div key={cat} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-700 w-48 truncate">{CAT_LABELS[cat] || cat}</span>
|
||||
<div className="flex-1 h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${score >= 80 ? 'bg-green-500' : score >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-sm font-bold w-12 text-right ${scoreColor(score)}`}>{score}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cross-Check Findings */}
|
||||
{result.cross_check_findings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">
|
||||
Dokumenten-Cross-Check
|
||||
<span className="ml-2 text-sm font-normal text-gray-400">{result.cross_check_findings.length} Findings</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{result.cross_check_findings.map(f => (
|
||||
<div key={f.id} className={`border-l-4 rounded-r-lg p-3 ${
|
||||
f.severity === 'CRITICAL' ? 'border-red-500 bg-red-50' :
|
||||
f.severity === 'HIGH' ? 'border-orange-500 bg-orange-50' :
|
||||
'border-yellow-500 bg-yellow-50'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${SEV_COLORS[f.severity] || ''}`}>
|
||||
{f.severity}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{f.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1 leading-relaxed">{f.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Detail */}
|
||||
<div className="bg-white rounded-xl border p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">Gepruefte Dokumente</h3>
|
||||
<div className="space-y-3">
|
||||
{result.documents.map((doc, i) => (
|
||||
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpandedDoc(expandedDoc === i ? null : i)}
|
||||
className="w-full text-left p-4 hover:bg-gray-50 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-2 py-0.5 bg-indigo-100 text-indigo-700 rounded text-xs font-bold">
|
||||
{doc.doc_type.toUpperCase()}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 text-sm">{doc.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<span className={`text-sm font-bold ${scoreColor(doc.completeness_pct)}`}>
|
||||
{doc.completeness_pct}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-1">vollstaendig</span>
|
||||
</div>
|
||||
{doc.findings_count > 0 && (
|
||||
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs font-bold">
|
||||
{doc.findings_count}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-400 text-sm">{expandedDoc === i ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expandedDoc === i && (
|
||||
<div className="border-t p-4 bg-gray-50">
|
||||
{doc.error ? (
|
||||
<p className="text-red-600 text-sm">{doc.error}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{doc.checks.filter(c => c.level === 1).map(c => {
|
||||
const l2s = doc.checks.filter(l => l.level === 2 && l.parent === c.id)
|
||||
return (
|
||||
<div key={c.id}>
|
||||
<div className={`flex items-center gap-2 py-1 ${!c.passed && !c.skipped ? 'text-red-700' : ''}`}>
|
||||
<span>{c.passed ? '✓' : c.skipped ? '—' : '✗'}</span>
|
||||
<span className="text-sm">{c.label}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV_COLORS[c.severity] || ''}`}>
|
||||
{c.severity}
|
||||
</span>
|
||||
</div>
|
||||
{!c.passed && !c.skipped && c.hint && (
|
||||
<div className="ml-6 mb-1 text-xs text-red-600 bg-red-50 border-l-2 border-red-300 pl-2 py-1">
|
||||
{c.hint}
|
||||
</div>
|
||||
)}
|
||||
{l2s.map(l2 => (
|
||||
<div key={l2.id} className={`ml-6 flex items-center gap-2 py-0.5 text-xs ${!l2.passed ? 'text-red-600' : 'text-gray-500'}`}>
|
||||
<span>{l2.passed ? '✓' : l2.skipped ? '—' : '✗'}</span>
|
||||
<span>{l2.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Findings Detail */}
|
||||
{result.findings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border p-5">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">
|
||||
Alle Findings
|
||||
<span className="ml-2 text-sm font-normal text-gray-400">{result.findings.length}</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[...result.findings]
|
||||
.sort((a, b) => {
|
||||
const order: Record<string, number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
return (order[a.severity] ?? 4) - (order[b.severity] ?? 4)
|
||||
})
|
||||
.map(f => (
|
||||
<div key={f.id} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-bold border ${SEV_COLORS[f.severity] || ''}`}>
|
||||
{f.severity}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{f.title}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-1">
|
||||
{CAT_LABELS[f.category] || f.category} | {f.document_label}
|
||||
</div>
|
||||
{f.description && (
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{f.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm font-medium"
|
||||
>
|
||||
Neue Pruefung
|
||||
</button>
|
||||
{result.report_html && (
|
||||
<button
|
||||
onClick={() => setShowHtml(!showHtml)}
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
{showHtml ? 'Protokoll ausblenden' : 'Pruefprotokoll (Druckversion)'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Print-ready HTML report */}
|
||||
{showHtml && result.report_html && (
|
||||
<div className="bg-white rounded-xl border p-6 mt-4">
|
||||
<div dangerouslySetInnerHTML={{ __html: result.report_html }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { DocumentUploader } from './_components/DocumentUploader'
|
||||
import { AssessmentProgress } from './_components/AssessmentProgress'
|
||||
import { PruefprotokollView } from './_components/PruefprotokollView'
|
||||
|
||||
type View = 'upload' | 'progress' | 'result'
|
||||
|
||||
interface AssessmentResult {
|
||||
vendor_name: string
|
||||
documents: DocumentResult[]
|
||||
findings: Finding[]
|
||||
overall_score: number
|
||||
category_scores: Record<string, number>
|
||||
cross_check_findings: CrossCheckFinding[]
|
||||
report_html: string
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
interface DocumentResult {
|
||||
label: string
|
||||
url: string
|
||||
doc_type: string
|
||||
word_count: number
|
||||
completeness_pct: number
|
||||
correctness_pct: number
|
||||
checks: Check[]
|
||||
findings_count: number
|
||||
error: string
|
||||
}
|
||||
|
||||
interface Check {
|
||||
id: string
|
||||
label: string
|
||||
passed: boolean
|
||||
severity: string
|
||||
level: number
|
||||
parent: string | null
|
||||
skipped: boolean
|
||||
hint: string
|
||||
matched_text: string
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
id: string
|
||||
category: string
|
||||
severity: string
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
recommendation: string
|
||||
document_label: string
|
||||
document_type: string
|
||||
}
|
||||
|
||||
interface CrossCheckFinding {
|
||||
id: string
|
||||
label: string
|
||||
severity: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_COMPLIANCE_API_URL || ''
|
||||
|
||||
export default function VendorAssessmentPage() {
|
||||
const [view, setView] = useState<View>('upload')
|
||||
const [assessmentId, setAssessmentId] = useState<string>('')
|
||||
const [result, setResult] = useState<AssessmentResult | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleStartAssessment = useCallback(async (
|
||||
vendorName: string,
|
||||
documents: Array<{ doc_type: string; label: string; url: string }>,
|
||||
) => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ vendor_name: vendorName, documents }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = await res.json()
|
||||
setAssessmentId(data.assessment_id)
|
||||
setView('progress')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleComplete = useCallback((data: AssessmentResult) => {
|
||||
setResult(data)
|
||||
setView('result')
|
||||
}, [])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setView('upload')
|
||||
setAssessmentId('')
|
||||
setResult(null)
|
||||
setError('')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Vertragspruefung</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Automatisierte Pruefung von Auftragsverarbeitungsvertraegen gem. Art. 28 DSGVO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
<button onClick={() => setError('')} className="text-xs text-red-500 mt-1 underline">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'upload' && (
|
||||
<DocumentUploader onStart={handleStartAssessment} />
|
||||
)}
|
||||
|
||||
{view === 'progress' && assessmentId && (
|
||||
<AssessmentProgress
|
||||
assessmentId={assessmentId}
|
||||
backendUrl={BACKEND_URL}
|
||||
onComplete={handleComplete}
|
||||
onError={(msg) => { setError(msg); setView('upload') }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'result' && result && (
|
||||
<PruefprotokollView result={result} onReset={handleReset} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -95,6 +95,32 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/vendor-assessment"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
}
|
||||
label="Vertragspruefung"
|
||||
isActive={pathname?.startsWith('/sdk/vendor-assessment') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/audit-timeline"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
label="Audit Timeline"
|
||||
isActive={pathname?.startsWith('/sdk/audit-timeline') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Payment / Terminal */}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* CMP Phase 3 + DSR Integration Tests
|
||||
*
|
||||
* Tests the complete CMP lifecycle including:
|
||||
* - Vendor-agnostic consent fields (consent_method, browser, os, etc.)
|
||||
* - Script/cookie tracking (scripts_blocked, scripts_released, cookies_set)
|
||||
* - Session ID tracking
|
||||
* - GeoIP via timezone mapping
|
||||
* - Vendor-level consent (vendor_consents dict)
|
||||
* - DSR scenarios: Art. 15 Auskunft, Art. 17 Löschung, Art. 20 Portabilität
|
||||
* - Email linking for DSR (device → user mapping)
|
||||
* - Admin modal features (vendor display, withdraw, email linking)
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.PLAYWRIGHT_API_URL || 'https://macmini:3007/api/sdk/v1/banner'
|
||||
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
}
|
||||
|
||||
const TS = Date.now()
|
||||
const SITE_ID = `e2e-cmp3-${TS}`
|
||||
const DEVICE_FP = `e2e-device-${TS}`
|
||||
|
||||
// ============================================================================
|
||||
// 1. Vendor-Agnostic Consent Fields
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Vendor-Agnostic Consent Fields', () => {
|
||||
test('should store all 20+ fields on consent', async ({ request }) => {
|
||||
// Create site config first
|
||||
await request.post(`${API_BASE}/admin/sites`, {
|
||||
headers: HEADERS,
|
||||
data: { site_id: SITE_ID, site_name: 'E2E CMP Phase 3', site_url: 'https://test.example.com' },
|
||||
})
|
||||
|
||||
// Record consent with all vendor-agnostic fields
|
||||
const res = await request.post(`${API_BASE}/consent`, {
|
||||
headers: HEADERS,
|
||||
data: {
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: DEVICE_FP,
|
||||
categories: ['essential', 'functional', 'analytics'],
|
||||
vendors: ['Google Analytics', 'Matomo'],
|
||||
vendor_consents: { 'Google Analytics': true, 'Matomo': true, 'Facebook Pixel': false },
|
||||
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) E2E-Test',
|
||||
consent_method: 'custom_selection',
|
||||
page_url: 'https://test.example.com/pricing',
|
||||
referrer: 'https://google.com',
|
||||
device_type: 'desktop',
|
||||
browser: 'Chrome/120.0',
|
||||
os: 'Mac OS X 10.15.7',
|
||||
screen_resolution: '1920x1080',
|
||||
consent_scope: 'domain',
|
||||
session_id: 'e2e-session-001',
|
||||
timezone: 'Europe/Berlin',
|
||||
scripts_blocked: [{ src: 'https://connect.facebook.net/fbevents.js', category: 'marketing' }],
|
||||
scripts_released: [{ src: 'https://www.googletagmanager.com/gtag/js', category: 'analytics' }],
|
||||
cookies_set: [
|
||||
{ name: '_ga', domain: '.test.example.com', expiry_days: 730, category: 'analytics' },
|
||||
{ name: 'bp_consent', domain: '.test.example.com', expiry_days: 365, category: 'essential' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status()).toBe(200)
|
||||
const consent = await res.json()
|
||||
expect(consent.id).toBeTruthy()
|
||||
expect(consent.consent_method).toBe('custom_selection')
|
||||
expect(consent.device_type).toBe('desktop')
|
||||
expect(consent.browser).toBe('Chrome/120.0')
|
||||
expect(consent.os).toBe('Mac OS X 10.15.7')
|
||||
expect(consent.page_url).toBe('https://test.example.com/pricing')
|
||||
expect(consent.session_id).toBe('e2e-session-001')
|
||||
expect(consent.geo_country).toBe('DE') // Europe/Berlin → DE
|
||||
expect(consent.scripts_released).toHaveLength(1)
|
||||
expect(consent.cookies_set).toHaveLength(2)
|
||||
expect(consent.vendor_consents).toEqual({ 'Google Analytics': true, 'Matomo': true, 'Facebook Pixel': false })
|
||||
})
|
||||
|
||||
test('should update consent on same fingerprint (upsert)', async ({ request }) => {
|
||||
const res = await request.post(`${API_BASE}/consent`, {
|
||||
headers: HEADERS,
|
||||
data: {
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: DEVICE_FP,
|
||||
categories: ['essential'], // changed from all 3 to essential only
|
||||
vendors: [],
|
||||
consent_method: 'reject_all',
|
||||
page_url: 'https://test.example.com/settings',
|
||||
timezone: 'Europe/Vienna',
|
||||
},
|
||||
})
|
||||
|
||||
expect(res.status()).toBe(200)
|
||||
const consent = await res.json()
|
||||
expect(consent.consent_method).toBe('reject_all')
|
||||
expect(consent.geo_country).toBe('AT') // Europe/Vienna → AT
|
||||
expect(consent.categories).toEqual(['essential'])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 2. DSR Scenarios — Art. 15 Auskunft
|
||||
// ============================================================================
|
||||
|
||||
test.describe('DSR — Art. 15 Auskunftsrecht', () => {
|
||||
const DSR_EMAIL = `dsr-user-${TS}@example.com`
|
||||
const DSR_DEVICE_1 = `dsr-desktop-${TS}`
|
||||
const DSR_DEVICE_2 = `dsr-mobile-${TS}`
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
// Scenario: User visited website from 2 devices, then linked their email
|
||||
|
||||
// Device 1: Desktop consent
|
||||
await request.post(`${API_BASE}/consent`, {
|
||||
headers: HEADERS,
|
||||
data: {
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: DSR_DEVICE_1,
|
||||
categories: ['essential', 'analytics'],
|
||||
consent_method: 'accept_all',
|
||||
device_type: 'desktop',
|
||||
browser: 'Firefox/121.0',
|
||||
page_url: 'https://test.example.com/',
|
||||
timezone: 'Europe/Berlin',
|
||||
},
|
||||
})
|
||||
|
||||
// Device 2: Mobile consent
|
||||
await request.post(`${API_BASE}/consent`, {
|
||||
headers: HEADERS,
|
||||
data: {
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: DSR_DEVICE_2,
|
||||
categories: ['essential'],
|
||||
consent_method: 'reject_all',
|
||||
device_type: 'mobile',
|
||||
browser: 'Safari/17.0',
|
||||
page_url: 'https://test.example.com/pricing',
|
||||
timezone: 'Europe/Berlin',
|
||||
},
|
||||
})
|
||||
|
||||
// User logs in and links email to both devices
|
||||
await request.post(`${API_BASE}/consent/link-email`, {
|
||||
headers: HEADERS,
|
||||
data: { site_id: SITE_ID, device_fingerprint: DSR_DEVICE_1, email: DSR_EMAIL },
|
||||
})
|
||||
await request.post(`${API_BASE}/consent/link-email`, {
|
||||
headers: HEADERS,
|
||||
data: { site_id: SITE_ID, device_fingerprint: DSR_DEVICE_2, email: DSR_EMAIL },
|
||||
})
|
||||
})
|
||||
|
||||
test('Art. 15 — should find all consents by email', async ({ request }) => {
|
||||
const res = await request.get(`${API_BASE}/consent/by-email/${DSR_EMAIL}`, { headers: HEADERS })
|
||||
expect(res.status()).toBe(200)
|
||||
const consents = await res.json()
|
||||
expect(consents).toHaveLength(2)
|
||||
expect(consents.map((c: { device_fingerprint: string }) => c.device_fingerprint).sort()).toEqual(
|
||||
[DSR_DEVICE_1, DSR_DEVICE_2].sort()
|
||||
)
|
||||
})
|
||||
|
||||
test('Art. 15/20 — should export all consent data for DSR', async ({ request }) => {
|
||||
const res = await request.get(`${API_BASE}/consent/dsr-export/${DSR_EMAIL}`, { headers: HEADERS })
|
||||
expect(res.status()).toBe(200)
|
||||
const exportData = await res.json()
|
||||
expect(exportData.consents).toHaveLength(2)
|
||||
expect(exportData.audit_trail.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Art. 17 — should delete all consents by email (erasure)', async ({ request }) => {
|
||||
const res = await request.delete(`${API_BASE}/consent/by-email/${DSR_EMAIL}`, { headers: HEADERS })
|
||||
expect(res.status()).toBe(200)
|
||||
const result = await res.json()
|
||||
expect(result.deleted_count).toBe(2)
|
||||
|
||||
// Verify deletion
|
||||
const check = await request.get(`${API_BASE}/consent/by-email/${DSR_EMAIL}`, { headers: HEADERS })
|
||||
const remaining = await check.json()
|
||||
expect(remaining).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 3. DSR Scenarios — Cookie Banner User (anonymous)
|
||||
// ============================================================================
|
||||
|
||||
test.describe('DSR — Anonymous Cookie Banner User', () => {
|
||||
const ANON_DEVICE = `anon-user-${TS}`
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await request.post(`${API_BASE}/consent`, {
|
||||
headers: HEADERS,
|
||||
data: {
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: ANON_DEVICE,
|
||||
categories: ['essential', 'functional'],
|
||||
consent_method: 'custom_selection',
|
||||
device_type: 'tablet',
|
||||
browser: 'Chrome/120.0',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('should export consent by device fingerprint', async ({ request }) => {
|
||||
const res = await request.get(
|
||||
`${API_BASE}/consent/export?site_id=${SITE_ID}&device_fingerprint=${ANON_DEVICE}`,
|
||||
{ headers: HEADERS }
|
||||
)
|
||||
expect(res.status()).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.device_fingerprint).toBe(ANON_DEVICE)
|
||||
expect(data.consents).toHaveLength(1)
|
||||
expect(data.audit_trail.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should withdraw consent by ID', async ({ request }) => {
|
||||
// Get consent ID first
|
||||
const getRes = await request.get(
|
||||
`${API_BASE}/consent?site_id=${SITE_ID}&device_fingerprint=${ANON_DEVICE}`,
|
||||
{ headers: HEADERS }
|
||||
)
|
||||
const { consent } = await getRes.json()
|
||||
expect(consent).toBeTruthy()
|
||||
|
||||
// Withdraw
|
||||
const delRes = await request.delete(`${API_BASE}/consent/${consent.id}`, { headers: HEADERS })
|
||||
expect(delRes.status()).toBe(200)
|
||||
|
||||
// Verify
|
||||
const checkRes = await request.get(
|
||||
`${API_BASE}/consent?site_id=${SITE_ID}&device_fingerprint=${ANON_DEVICE}`,
|
||||
{ headers: HEADERS }
|
||||
)
|
||||
const result = await checkRes.json()
|
||||
expect(result.has_consent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 4. DSR Scenarios — Login User (Customer) who also used Cookie Banner
|
||||
// ============================================================================
|
||||
|
||||
test.describe('DSR — Customer with Banner + Login', () => {
|
||||
const CUSTOMER_EMAIL = `customer-${TS}@company.com`
|
||||
const CUSTOMER_DEVICE = `customer-device-${TS}`
|
||||
|
||||
test('full lifecycle: consent → login → link → Art.15 → Art.17', async ({ request }) => {
|
||||
// Step 1: Anonymous visit → cookie consent
|
||||
const consentRes = await request.post(`${API_BASE}/consent`, {
|
||||
headers: HEADERS,
|
||||
data: {
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: CUSTOMER_DEVICE,
|
||||
categories: ['essential', 'analytics'],
|
||||
consent_method: 'accept_all',
|
||||
device_type: 'desktop',
|
||||
browser: 'Edge/120.0',
|
||||
page_url: 'https://test.example.com/',
|
||||
timezone: 'Europe/Zurich',
|
||||
scripts_released: [{ src: 'https://cdn.matomo.cloud/test.js', category: 'analytics' }],
|
||||
cookies_set: [{ name: '_pk_id', domain: '.test.example.com', expiry_days: 393, category: 'analytics' }],
|
||||
},
|
||||
})
|
||||
expect(consentRes.status()).toBe(200)
|
||||
const consent = await consentRes.json()
|
||||
expect(consent.geo_country).toBe('CH') // Europe/Zurich → CH
|
||||
|
||||
// Step 2: Customer logs in → email linked
|
||||
const linkRes = await request.post(`${API_BASE}/consent/link-email`, {
|
||||
headers: HEADERS,
|
||||
data: { site_id: SITE_ID, device_fingerprint: CUSTOMER_DEVICE, email: CUSTOMER_EMAIL },
|
||||
})
|
||||
expect(linkRes.status()).toBe(200)
|
||||
|
||||
// Step 3: Art. 15 — Customer requests their data
|
||||
const exportRes = await request.get(`${API_BASE}/consent/dsr-export/${CUSTOMER_EMAIL}`, { headers: HEADERS })
|
||||
expect(exportRes.status()).toBe(200)
|
||||
const exportData = await exportRes.json()
|
||||
expect(exportData.consents.length).toBeGreaterThan(0)
|
||||
expect(exportData.audit_trail.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify export contains all consent details
|
||||
const exported = exportData.consents[0]
|
||||
expect(exported.categories).toContain('analytics')
|
||||
expect(exported.linked_email).toBe(CUSTOMER_EMAIL)
|
||||
|
||||
// Step 4: Art. 17 — Customer requests erasure
|
||||
const deleteRes = await request.delete(`${API_BASE}/consent/by-email/${CUSTOMER_EMAIL}`, { headers: HEADERS })
|
||||
expect(deleteRes.status()).toBe(200)
|
||||
|
||||
// Step 5: Verify complete erasure
|
||||
const verifyRes = await request.get(`${API_BASE}/consent/by-email/${CUSTOMER_EMAIL}`, { headers: HEADERS })
|
||||
const remaining = await verifyRes.json()
|
||||
expect(remaining).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 5. Admin Dashboard Integration
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Admin Dashboard — Consent Management', () => {
|
||||
const ADMIN_DEVICE = `admin-test-${TS}`
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await request.post(`${API_BASE}/consent`, {
|
||||
headers: HEADERS,
|
||||
data: {
|
||||
site_id: SITE_ID,
|
||||
device_fingerprint: ADMIN_DEVICE,
|
||||
categories: ['essential', 'functional', 'analytics'],
|
||||
vendors: ['Matomo'],
|
||||
vendor_consents: { Matomo: true },
|
||||
consent_method: 'accept_all',
|
||||
device_type: 'desktop',
|
||||
browser: 'Chrome/121.0',
|
||||
os: 'Windows NT 10.0',
|
||||
screen_resolution: '2560x1440',
|
||||
page_url: 'https://test.example.com/dashboard',
|
||||
session_id: 'admin-session-001',
|
||||
timezone: 'Europe/Berlin',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('should list consents with new fields', async ({ request }) => {
|
||||
const res = await request.get(`${API_BASE}/admin/consents?site_id=${SITE_ID}`, { headers: HEADERS })
|
||||
expect(res.status()).toBe(200)
|
||||
const data = await res.json()
|
||||
expect(data.total).toBeGreaterThan(0)
|
||||
|
||||
const consent = data.consents.find((c: { device_fingerprint: string }) => c.device_fingerprint === ADMIN_DEVICE)
|
||||
expect(consent).toBeTruthy()
|
||||
expect(consent.consent_method).toBe('accept_all')
|
||||
expect(consent.device_type).toBe('desktop')
|
||||
expect(consent.browser).toBe('Chrome/121.0')
|
||||
expect(consent.os).toBe('Windows NT 10.0')
|
||||
expect(consent.screen_resolution).toBe('2560x1440')
|
||||
expect(consent.session_id).toBe('admin-session-001')
|
||||
expect(consent.geo_country).toBe('DE')
|
||||
expect(consent.vendor_consents).toEqual({ Matomo: true })
|
||||
})
|
||||
|
||||
test('should show site stats with category acceptance', async ({ request }) => {
|
||||
const res = await request.get(`${API_BASE}/admin/stats/${SITE_ID}`, { headers: HEADERS })
|
||||
expect(res.status()).toBe(200)
|
||||
const stats = await res.json()
|
||||
expect(stats.total_consents).toBeGreaterThan(0)
|
||||
expect(stats.category_acceptance).toBeTruthy()
|
||||
expect(stats.category_acceptance.essential).toBeTruthy()
|
||||
expect(stats.category_acceptance.essential.rate).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 6. Cleanup
|
||||
// ============================================================================
|
||||
|
||||
test.describe('Cleanup', () => {
|
||||
test('should delete test site config', async ({ request }) => {
|
||||
const res = await request.delete(`${API_BASE}/admin/sites/${SITE_ID}`, { headers: HEADERS })
|
||||
expect(res.status()).toBe(204)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Vendor Assessment — Vertragspruefung E2E Tests
|
||||
*
|
||||
* Tests the complete flow: Provider → Documents → Analysis → Pruefprotokoll
|
||||
* Uses real provider URLs (DSE/AGB pages) as test documents.
|
||||
*
|
||||
* Test Vendors:
|
||||
* - Spiegel.de (large publisher, comprehensive DSE)
|
||||
* - IHK (institutional, formal AGB/DSE)
|
||||
* - Safetykon (smaller provider, potentially incomplete)
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'https://macmini:3007'
|
||||
|
||||
// ── Page Load & Navigation ─────────────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — Page', () => {
|
||||
test('page loads and shows upload form', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toContain('Vertragspruefung')
|
||||
expect(body).toContain('Auftragsverarbeiter')
|
||||
expect(body).toContain('Dokumente')
|
||||
expect(body).toContain('Pruefung starten')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-page.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('sidebar shows Vertragspruefung link', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const sidebar = page.locator('nav, [class*="sidebar"], [class*="Sidebar"]')
|
||||
const sidebarText = await sidebar.textContent()
|
||||
expect(sidebarText).toContain('Vertragspruefung')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-sidebar.png',
|
||||
})
|
||||
})
|
||||
|
||||
test('can add and remove document entries', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Initially one entry
|
||||
const urlInputs = page.locator('input[type="url"]')
|
||||
await expect(urlInputs).toHaveCount(1)
|
||||
|
||||
// Add another
|
||||
await page.click('button:has-text("Weiteres Dokument")')
|
||||
await expect(urlInputs).toHaveCount(2)
|
||||
|
||||
// Add third
|
||||
await page.click('button:has-text("Weiteres Dokument")')
|
||||
await expect(urlInputs).toHaveCount(3)
|
||||
|
||||
// Remove second (click × button)
|
||||
const removeButtons = page.locator('button:has-text("×")')
|
||||
await removeButtons.nth(1).click()
|
||||
await expect(urlInputs).toHaveCount(2)
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-multi-doc.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('submit button disabled without vendor name', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const submitBtn = page.locator('button:has-text("Pruefung starten")')
|
||||
await expect(submitBtn).toBeDisabled()
|
||||
|
||||
// Fill vendor name only
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Test GmbH')
|
||||
// Still disabled — no URL
|
||||
await expect(submitBtn).toBeDisabled()
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-validation.png',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Real Vendor Assessment Flows ───────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — Spiegel.de', () => {
|
||||
test.setTimeout(120000) // 2 min for full analysis
|
||||
|
||||
test('assess Spiegel DSE produces findings', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Fill vendor
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Spiegel Verlag')
|
||||
|
||||
// Fill document URL
|
||||
await page.fill('input[type="url"]', 'https://www.spiegel.de/datenschutz-spiegel')
|
||||
|
||||
// Select doc type
|
||||
await page.selectOption('select', 'dse')
|
||||
|
||||
// Start assessment
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for progress indicator
|
||||
await expect(page.locator('text=Vertragspruefung laeuft')).toBeVisible({ timeout: 10000 })
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-spiegel-progress.png',
|
||||
})
|
||||
|
||||
// Wait for completion (poll)
|
||||
await expect(page.locator('text=Spiegel Verlag')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
// Pruefprotokoll should show score
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toMatch(/\d+%/) // score percentage
|
||||
|
||||
// Should have document results
|
||||
expect(body).toContain('Gepruefte Dokumente')
|
||||
|
||||
// Should show DSE checks (since we submitted a DSE)
|
||||
expect(body).toContain('Dokumente')
|
||||
expect(body).toContain('Findings')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-spiegel-result.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vendor Assessment — IHK', () => {
|
||||
test.setTimeout(120000)
|
||||
|
||||
test('assess IHK with DSE and AGB', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Fill vendor
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'IHK Berlin')
|
||||
|
||||
// First doc: DSE
|
||||
const firstUrl = page.locator('input[type="url"]').first()
|
||||
await firstUrl.fill('https://www.ihk.de/datenschutzerklaerung')
|
||||
await page.selectOption('select', 'dse')
|
||||
|
||||
// Add second doc: AGB
|
||||
await page.click('button:has-text("Weiteres Dokument")')
|
||||
const secondUrl = page.locator('input[type="url"]').nth(1)
|
||||
await secondUrl.fill('https://www.ihk.de/impressum')
|
||||
|
||||
// Second doc type: impressum
|
||||
const selects = page.locator('select')
|
||||
await selects.nth(1).selectOption('impressum')
|
||||
|
||||
// Start
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for completion
|
||||
await expect(page.locator('text=IHK Berlin')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toMatch(/\d+%/)
|
||||
expect(body).toContain('Gepruefte Dokumente')
|
||||
|
||||
// Should have 2 documents analyzed
|
||||
expect(body).toContain('2')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-ihk-result.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Vendor Assessment — AVV Check', () => {
|
||||
test.setTimeout(120000)
|
||||
|
||||
test('AVV document runs Art. 28 checks', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Fill vendor
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Hetzner Online GmbH')
|
||||
|
||||
// Hetzner has a public AVV
|
||||
const urlInput = page.locator('input[type="url"]').first()
|
||||
await urlInput.fill('https://www.hetzner.com/de/legal/privacy-policy/')
|
||||
|
||||
// Auto-detect type
|
||||
await page.selectOption('select', 'auto')
|
||||
|
||||
// Start
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for completion
|
||||
await expect(page.locator('text=Hetzner Online GmbH')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
const body = await page.textContent('body')
|
||||
expect(body).toMatch(/\d+%/)
|
||||
|
||||
// Should show at least some category scores
|
||||
expect(body).toContain('Kategorie')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-hetzner-result.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── API Direct Tests ───────────────────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — API', () => {
|
||||
test('POST /assessments starts a job', async ({ request }) => {
|
||||
const resp = await request.post(`${BASE}/api/vendor-compliance/assessments`, {
|
||||
data: {
|
||||
vendor_name: 'API Test GmbH',
|
||||
documents: [
|
||||
{ doc_type: 'dse', label: 'Test DSE', url: 'https://www.spiegel.de/datenschutz-spiegel' },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(resp.ok()).toBeTruthy()
|
||||
|
||||
const data = await resp.json()
|
||||
expect(data.assessment_id).toBeTruthy()
|
||||
expect(data.status).toBe('running')
|
||||
})
|
||||
|
||||
test('GET /assessments/{id} returns status', async ({ request }) => {
|
||||
// Start a job first
|
||||
const startResp = await request.post(`${BASE}/api/vendor-compliance/assessments`, {
|
||||
data: {
|
||||
vendor_name: 'Poll Test GmbH',
|
||||
documents: [
|
||||
{ doc_type: 'dse', label: 'Test', url: 'https://www.spiegel.de/datenschutz-spiegel' },
|
||||
],
|
||||
},
|
||||
})
|
||||
const { assessment_id } = await startResp.json()
|
||||
|
||||
// Poll immediately — should be running
|
||||
const statusResp = await request.get(
|
||||
`${BASE}/api/vendor-compliance/assessments/${assessment_id}`,
|
||||
)
|
||||
expect(statusResp.ok()).toBeTruthy()
|
||||
|
||||
const status = await statusResp.json()
|
||||
expect(status.assessment_id).toBe(assessment_id)
|
||||
expect(['running', 'completed']).toContain(status.status)
|
||||
})
|
||||
|
||||
test('GET /assessments lists all assessments', async ({ request }) => {
|
||||
const resp = await request.get(`${BASE}/api/vendor-compliance/assessments`)
|
||||
expect(resp.ok()).toBeTruthy()
|
||||
|
||||
const data = await resp.json()
|
||||
expect(data.assessments).toBeDefined()
|
||||
expect(Array.isArray(data.assessments)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('GET unknown assessment returns not_found', async ({ request }) => {
|
||||
const resp = await request.get(
|
||||
`${BASE}/api/vendor-compliance/assessments/00000000-0000-0000-0000-000000000000`,
|
||||
)
|
||||
const data = await resp.json()
|
||||
expect(data.status).toBe('not_found')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cross-Check Scenarios ──────────────────────────────────────────
|
||||
|
||||
test.describe('Vendor Assessment — Cross-Check', () => {
|
||||
test.setTimeout(120000)
|
||||
|
||||
test('single AVV without TOM triggers cross-check finding', async ({ page }) => {
|
||||
await page.goto(`${BASE}/sdk/vendor-assessment`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Submit only an AVV (no TOM annex) — should trigger cross-check
|
||||
await page.fill('input[placeholder*="SysEleven"]', 'Cross-Check Test')
|
||||
|
||||
const urlInput = page.locator('input[type="url"]').first()
|
||||
await urlInput.fill('https://www.hetzner.com/de/legal/privacy-policy/')
|
||||
await page.selectOption('select', 'avv')
|
||||
|
||||
await page.click('button:has-text("Pruefung starten")')
|
||||
|
||||
// Wait for result
|
||||
await expect(page.locator('text=Cross-Check Test')).toBeVisible({ timeout: 90000 })
|
||||
|
||||
const body = await page.textContent('body')
|
||||
|
||||
// Cross-check should detect missing TOM annex
|
||||
// (The AVV checklist mentions TOM, but no TOM doc was uploaded)
|
||||
expect(body).toContain('Cross-Check')
|
||||
|
||||
await page.screenshot({
|
||||
path: 'e2e/test-results/vendor-assessment-cross-check.png',
|
||||
fullPage: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ImportGroundTruth handles POST /projects/:id/benchmark/import-gt
|
||||
// Stores Ground Truth data in project metadata.ground_truth.
|
||||
func (h *IACEHandler) ImportGroundTruth(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
project, err := h.store.GetProject(ctx, projectID)
|
||||
if err != nil || project == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var gt iace.GroundTruth
|
||||
if err := c.ShouldBindJSON(>); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ground truth JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if gt.ImportedAt == "" {
|
||||
gt.ImportedAt = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Merge into existing metadata
|
||||
meta := make(map[string]json.RawMessage)
|
||||
if project.Metadata != nil {
|
||||
_ = json.Unmarshal(project.Metadata, &meta)
|
||||
}
|
||||
gtJSON, _ := json.Marshal(gt)
|
||||
meta["ground_truth"] = gtJSON
|
||||
|
||||
mergedMeta, _ := json.Marshal(meta)
|
||||
err = h.store.UpdateProjectMetadata(ctx, projectID, mergedMeta)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store ground truth"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "ground truth imported",
|
||||
"entry_count": len(gt.Entries),
|
||||
"source_file": gt.SourceFile,
|
||||
})
|
||||
}
|
||||
|
||||
// RunBenchmark handles GET /projects/:id/benchmark?gt_project_id=:gtId
|
||||
// Compares engine hazards from project :id against GT from project :gtId.
|
||||
// If gt_project_id is omitted, looks for GT in the same project's metadata.
|
||||
func (h *IACEHandler) RunBenchmark(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Determine GT source
|
||||
gtProjectID := projectID
|
||||
if gtParam := c.Query("gt_project_id"); gtParam != "" {
|
||||
parsed, err := uuid.Parse(gtParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid gt_project_id"})
|
||||
return
|
||||
}
|
||||
gtProjectID = parsed
|
||||
}
|
||||
|
||||
// Load GT
|
||||
gtProject, err := h.store.GetProject(ctx, gtProjectID)
|
||||
if err != nil || gtProject == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "GT project not found"})
|
||||
return
|
||||
}
|
||||
gt, err := iace.ParseGroundTruth(gtProject.Metadata)
|
||||
if err != nil || gt == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no ground truth data in project metadata"})
|
||||
return
|
||||
}
|
||||
|
||||
// Load engine hazards + mitigations
|
||||
hazards, err := h.store.ListHazards(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
|
||||
return
|
||||
}
|
||||
mitigations, err := h.store.ListMitigationsByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
mitigations = nil
|
||||
}
|
||||
|
||||
result := iace.CompareBenchmark(gt, hazards, mitigations)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetBenchmarkSummary handles GET /projects/:id/benchmark/summary
|
||||
// Returns lightweight coverage metrics without full match details.
|
||||
func (h *IACEHandler) GetBenchmarkSummary(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
gtProjectID := projectID
|
||||
if gtParam := c.Query("gt_project_id"); gtParam != "" {
|
||||
parsed, err := uuid.Parse(gtParam)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid gt_project_id"})
|
||||
return
|
||||
}
|
||||
gtProjectID = parsed
|
||||
}
|
||||
|
||||
gtProject, err := h.store.GetProject(ctx, gtProjectID)
|
||||
if err != nil || gtProject == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "GT project not found"})
|
||||
return
|
||||
}
|
||||
gt, err := iace.ParseGroundTruth(gtProject.Metadata)
|
||||
if err != nil || gt == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no ground truth data"})
|
||||
return
|
||||
}
|
||||
|
||||
hazards, err := h.store.ListHazards(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
|
||||
return
|
||||
}
|
||||
mitigations, _ := h.store.ListMitigationsByProject(ctx, projectID)
|
||||
|
||||
result := iace.CompareBenchmark(gt, hazards, mitigations)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"coverage_score": result.CoverageScore,
|
||||
"measure_coverage": result.MeasureCoverage,
|
||||
"total_gt": result.TotalGT,
|
||||
"total_engine": result.TotalEngine,
|
||||
"matched_count": len(result.MatchedPairs),
|
||||
"missing_count": len(result.MissingFromEngine),
|
||||
"extra_count": len(result.ExtraInEngine),
|
||||
"category_breakdown": result.CategoryBreakdown,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GetHazardBlocks handles GET /projects/:id/hazard-blocks
|
||||
// Returns hazards grouped into parent-child blocks based on shared category,
|
||||
// component, and zone. The parent hazard in each block has the highest risk.
|
||||
// Children covered by the parent's measures are flagged accordingly.
|
||||
func (h *IACEHandler) GetHazardBlocks(c *gin.Context) {
|
||||
projectID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
hazards, err := h.store.ListHazards(ctx, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load hazards"})
|
||||
return
|
||||
}
|
||||
|
||||
assessmentMap, _ := h.store.GetLatestAssessmentsByProject(ctx, projectID)
|
||||
var assessments []iace.RiskAssessment
|
||||
for _, a := range assessmentMap {
|
||||
assessments = append(assessments, a)
|
||||
}
|
||||
mitigations, _ := h.store.ListMitigationsByProject(ctx, projectID)
|
||||
|
||||
blocks := iace.ComputeHazardBlocks(hazards, assessments, mitigations)
|
||||
|
||||
// Compute summary stats
|
||||
totalBlocks := len(blocks)
|
||||
parentOnly := 0
|
||||
coveredChildren := 0
|
||||
uncoveredChildren := 0
|
||||
for _, b := range blocks {
|
||||
if len(b.Children) == 0 {
|
||||
parentOnly++
|
||||
} else if b.ChildrenCoveredByParent {
|
||||
coveredChildren += len(b.Children)
|
||||
} else {
|
||||
uncoveredChildren += len(b.Children)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"blocks": blocks,
|
||||
"summary": gin.H{
|
||||
"total_blocks": totalBlocks,
|
||||
"parent_only_blocks": parentOnly,
|
||||
"blocks_with_children": totalBlocks - parentOnly,
|
||||
"total_hazards": len(hazards),
|
||||
"covered_children": coveredChildren,
|
||||
"uncovered_children": uncoveredChildren,
|
||||
"assessments_needed": totalBlocks - parentOnly + uncoveredChildren + parentOnly,
|
||||
"assessments_saved": coveredChildren,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -138,36 +139,88 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
// ── Step 5: Create hazards from matched patterns (skip if exist) ──
|
||||
existingHazards, _ := h.store.ListHazards(ctx, projectID)
|
||||
hazardStep := InitStep{Name: "Gefaehrdungen erstellt", Status: "skipped"}
|
||||
hazardIDsByCategory := make(map[string]uuid.UUID)
|
||||
hazardIDsByCategory := make(map[string][]uuid.UUID)
|
||||
hazardPatternMeasures := make(map[uuid.UUID][]string)
|
||||
|
||||
if len(existingHazards) == 0 && len(matchOutput.MatchedPatterns) > 0 {
|
||||
comps, _ := h.store.ListComponents(ctx, projectID)
|
||||
var defaultCompID uuid.UUID
|
||||
compByName := make(map[string]uuid.UUID)
|
||||
if len(comps) > 0 {
|
||||
defaultCompID = comps[0].ID
|
||||
for _, c := range comps {
|
||||
compByName[iace.NormalizeDEPublic(c.Name)] = c.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Build component name set for relevance filtering
|
||||
compNames := make([]string, 0, len(comps))
|
||||
for name := range compByName {
|
||||
compNames = append(compNames, name)
|
||||
}
|
||||
|
||||
created := 0
|
||||
seenCat := make(map[string]bool)
|
||||
seenCatZone := make(map[string]uuid.UUID) // dedupKey → hazardID
|
||||
catCount := make(map[string]int)
|
||||
for _, mp := range matchOutput.MatchedPatterns {
|
||||
// Narrative relevance filter
|
||||
if !isPatternRelevant(mp, narrativeText, compNames) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, cat := range mp.HazardCats {
|
||||
if seenCat[cat] {
|
||||
maxForCat := categoryHazardCap(cat, len(comps))
|
||||
if catCount[cat] >= maxForCat {
|
||||
continue
|
||||
}
|
||||
|
||||
zoneKey := normalizeZoneKey(mp.ZoneDE)
|
||||
if zoneKey == "" {
|
||||
zoneKey = mp.PatternID
|
||||
}
|
||||
dedupKey := cat + ":" + zoneKey
|
||||
|
||||
// If this dedupKey already exists but current pattern has
|
||||
// SuggestedMeasureIDs, add them to the existing hazard
|
||||
if existingHzID, exists := seenCatZone[dedupKey]; exists {
|
||||
if len(mp.SuggestedMeasureIDs) > 0 {
|
||||
existing := hazardPatternMeasures[existingHzID]
|
||||
hazardPatternMeasures[existingHzID] = append(existing, mp.SuggestedMeasureIDs...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seenCat[cat] = true
|
||||
|
||||
name := mp.PatternName
|
||||
if name == "" {
|
||||
name = cat
|
||||
}
|
||||
if mp.ZoneDE != "" && !containsSubstring(name, mp.ZoneDE) {
|
||||
name = name + " (" + mp.ZoneDE + ")"
|
||||
}
|
||||
|
||||
compID := defaultCompID
|
||||
if mp.ZoneDE != "" {
|
||||
zoneNorm := iace.NormalizeDEPublic(mp.ZoneDE)
|
||||
for cName, cID := range compByName {
|
||||
if containsSubstring(zoneNorm, cName) || containsSubstring(cName, zoneNorm) {
|
||||
compID = cID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join all applicable lifecycles as comma-separated string
|
||||
lifecycleStr := strings.Join(mp.ApplicableLifecycles, ",")
|
||||
|
||||
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
|
||||
ProjectID: projectID,
|
||||
ComponentID: defaultCompID,
|
||||
ComponentID: compID,
|
||||
Name: name,
|
||||
Description: mp.ScenarioDE,
|
||||
Category: cat,
|
||||
Scenario: mp.ScenarioDE,
|
||||
Function: iace.EncodeOpStates(mp.OperationalStates),
|
||||
LifecyclePhase: lifecycleStr,
|
||||
TriggerEvent: mp.TriggerDE,
|
||||
PossibleHarm: mp.HarmDE,
|
||||
AffectedPerson: mp.AffectedDE,
|
||||
@@ -175,7 +228,12 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
})
|
||||
if cerr == nil {
|
||||
created++
|
||||
hazardIDsByCategory[cat] = hz.ID
|
||||
catCount[cat]++
|
||||
seenCatZone[dedupKey] = hz.ID
|
||||
hazardIDsByCategory[cat] = append(hazardIDsByCategory[cat], hz.ID)
|
||||
if len(mp.SuggestedMeasureIDs) > 0 {
|
||||
hazardPatternMeasures[hz.ID] = mp.SuggestedMeasureIDs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +242,7 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
hazardStep.Details = "Bereits vorhanden"
|
||||
hazardStep.Count = len(existingHazards)
|
||||
for _, eh := range existingHazards {
|
||||
hazardIDsByCategory[eh.Category] = eh.ID
|
||||
hazardIDsByCategory[eh.Category] = append(hazardIDsByCategory[eh.Category], eh.ID)
|
||||
}
|
||||
}
|
||||
steps = append(steps, hazardStep)
|
||||
@@ -203,37 +261,60 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
}
|
||||
|
||||
created := 0
|
||||
usedMeasureIDs := make(map[string]bool)
|
||||
const maxMitigationsPerHazard = 5
|
||||
|
||||
for _, sm := range matchOutput.SuggestedMeasures {
|
||||
entry, ok := measureByID[sm.MeasureID]
|
||||
if !ok || usedMeasureIDs[sm.MeasureID] {
|
||||
continue
|
||||
}
|
||||
hazardID := findHazardForMeasureByCategory(entry.HazardCategory, hazardIDsByCategory)
|
||||
if hazardID == uuid.Nil {
|
||||
continue
|
||||
}
|
||||
rt := iace.ReductionType(entry.ReductionType)
|
||||
if rt == "" {
|
||||
rt = iace.ReductionTypeInformation
|
||||
}
|
||||
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
|
||||
HazardID: hazardID, ReductionType: rt,
|
||||
Name: entry.Name, Description: entry.Description,
|
||||
})
|
||||
if cerr == nil {
|
||||
created++
|
||||
usedMeasureIDs[sm.MeasureID] = true
|
||||
// Build a flat list of all hazard IDs for iteration
|
||||
var allHazardIDs []uuid.UUID
|
||||
hazardCatByID := make(map[uuid.UUID]string)
|
||||
for cat, ids := range hazardIDsByCategory {
|
||||
for _, id := range ids {
|
||||
allHazardIDs = append(allHazardIDs, id)
|
||||
hazardCatByID[id] = cat
|
||||
}
|
||||
}
|
||||
|
||||
for hazCat, hazID := range hazardIDsByCategory {
|
||||
// For each hazard: assign up to maxMitigationsPerHazard measures
|
||||
// Priority 1: Pattern-specific SuggestedMeasureIDs (from the pattern that created this hazard)
|
||||
// Priority 2: Category fallback (generic measures for the hazard category)
|
||||
for _, hazID := range allHazardIDs {
|
||||
hazCat := hazardCatByID[hazID]
|
||||
measCat := patternCatToMeasureCat(hazCat)
|
||||
added := 0
|
||||
usedIDs := make(map[string]bool)
|
||||
|
||||
// Priority 1: Pattern-specific measures
|
||||
if patternMIDs, ok := hazardPatternMeasures[hazID]; ok {
|
||||
for _, mid := range patternMIDs {
|
||||
if added >= maxMitigationsPerHazard {
|
||||
break
|
||||
}
|
||||
entry, ok := measureByID[mid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
rt := iace.ReductionType(entry.ReductionType)
|
||||
if rt == "" {
|
||||
rt = iace.ReductionTypeInformation
|
||||
}
|
||||
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
|
||||
HazardID: hazID, ReductionType: rt,
|
||||
Name: entry.Name, Description: entry.Description,
|
||||
})
|
||||
if cerr != nil {
|
||||
fmt.Printf("MEASURE-ERROR: mid=%s name=%s err=%v\n", mid, entry.Name, cerr)
|
||||
} else {
|
||||
created++
|
||||
added++
|
||||
usedIDs[mid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Category fallback (skip already-used IDs)
|
||||
for _, m := range measuresByCat[measCat] {
|
||||
if usedMeasureIDs[m.ID] || added >= 8 {
|
||||
break
|
||||
if added >= maxMitigationsPerHazard || usedIDs[m.ID] {
|
||||
continue
|
||||
}
|
||||
rt := iace.ReductionType(m.ReductionType)
|
||||
if rt == "" {
|
||||
@@ -245,12 +326,16 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
||||
})
|
||||
if cerr == nil {
|
||||
created++
|
||||
usedMeasureIDs[m.ID] = true
|
||||
added++
|
||||
}
|
||||
}
|
||||
}
|
||||
mitStep = InitStep{Name: "Massnahmen erstellt", Status: "done", Count: created}
|
||||
patternMeasureCount := 0
|
||||
for _, mids := range hazardPatternMeasures {
|
||||
patternMeasureCount += len(mids)
|
||||
}
|
||||
mitStep = InitStep{Name: "Massnahmen erstellt", Status: "done", Count: created,
|
||||
Details: fmt.Sprintf("%d pattern-spezifisch fuer %d Hazards", patternMeasureCount, len(hazardPatternMeasures))}
|
||||
} else if len(existingMits) > 0 {
|
||||
mitStep.Details = "Bereits vorhanden"
|
||||
mitStep.Count = len(existingMits)
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/google/uuid"
|
||||
@@ -190,18 +191,200 @@ func extractIndustrySectorsFromMetadata(metadata json.RawMessage) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// findHazardForMeasureByCategory finds a matching hazard for a measure.
|
||||
func findHazardForMeasureByCategory(measureCat string, hazardsByCategory map[string]uuid.UUID) uuid.UUID {
|
||||
if id, ok := hazardsByCategory[measureCat]; ok {
|
||||
return id
|
||||
// containsSubstring checks if haystack contains needle (case-insensitive, normalized).
|
||||
func containsSubstring(haystack, needle string) bool {
|
||||
return strings.Contains(
|
||||
strings.ToLower(haystack),
|
||||
strings.ToLower(needle),
|
||||
)
|
||||
}
|
||||
|
||||
// genericSafetyTerms are words that appear in almost all risk assessments
|
||||
// and should NOT be used to determine machine-specificity.
|
||||
var genericSafetyTerms = map[string]bool{
|
||||
"maschine": true, "anlage": true, "bereich": true, "gesamte": true,
|
||||
"arbeitsplatz": true, "gefahrbereich": true, "gefahrstelle": true,
|
||||
"gefahrenstelle": true, "person": true, "werker": true, "bediener": true,
|
||||
"steuerung": true, "schutzeinrichtung": true, "sicherheit": true,
|
||||
"betrieb": true, "wartung": true, "instandhaltung": true, "reinigung": true,
|
||||
"bewegung": true, "beweglich": true, "feststehend": true, "teil": true,
|
||||
"teile": true, "oeffnung": true, "zugang": true, "gefahr": true,
|
||||
"verletzung": true, "quetsch": true, "scher": true, "schneid": true,
|
||||
"stoss": true, "schlag": true, "einzug": true, "brand": true,
|
||||
"motor": true, "antrieb": true, "achse": true, "achsen": true,
|
||||
"kabel": true, "leitung": true, "schaltschrank": true, "spannung": true,
|
||||
"schutz": true, "gehaeuse": true, "oberflaeche": true, "boden": true,
|
||||
"leitfaehig": true, "elektrisch": true, "mechanisch": true,
|
||||
"bedienfeld": true, "display": true, "anzeige": true,
|
||||
"energie": true, "druck": true, "temperatur": true,
|
||||
// Abbreviations and synonyms that should not trigger relevance filter
|
||||
"kss": true, "emv": true, "esd": true, "dcs": true, "plr": true, "sil": true,
|
||||
"hmi": true, "sps": true, "rcd": true, "loto": true, "psa": true,
|
||||
// Common action words
|
||||
"bersten": true, "platzen": true, "abspringen": true, "spritzen": true,
|
||||
"einatmen": true, "ausrutschen": true, "herabfallen": true,
|
||||
"durchschlaegen": true, "wegschleudern": true,
|
||||
// Common structural terms that don't indicate a specific machine
|
||||
"gesamter": true, "gesamtes": true, "bereichs": true, "stelle": true,
|
||||
"innen": true, "aussen": true, "transport": true, "seite": true,
|
||||
"front": true, "rueck": true, "ober": true, "unter": true,
|
||||
"fuehrung": true, "lager": true, "verschleiss": true, "welle": true,
|
||||
"getriebe": true, "kette": true, "riemen": true, "feder": true,
|
||||
"spindel": true, "werkzeug": true, "werkstueck": true, "flucht": true,
|
||||
}
|
||||
|
||||
// isPatternRelevant checks whether a pattern match is relevant to the actual
|
||||
// machine described in the narrative. Uses narrative vocabulary overlap:
|
||||
// if the pattern's zone/scenario contains machine-specific words (not generic
|
||||
// safety terms) and NONE of them appear in the narrative → irrelevant.
|
||||
func isPatternRelevant(mp iace.PatternMatch, narrative string, compNames []string) bool {
|
||||
patternText := iace.NormalizeDEPublic(mp.ZoneDE + " " + mp.ScenarioDE + " " + mp.PatternName)
|
||||
narrativeNorm := iace.NormalizeDEPublic(narrative)
|
||||
|
||||
// Extract machine-specific words from pattern (not generic safety terms)
|
||||
patternWords := strings.Fields(patternText)
|
||||
var specificWords []string
|
||||
for _, w := range patternWords {
|
||||
// Clean punctuation
|
||||
w = strings.Trim(w, ".,;:!?()/-")
|
||||
if len(w) < 5 || genericSafetyTerms[w] {
|
||||
continue
|
||||
}
|
||||
specificWords = append(specificWords, w)
|
||||
}
|
||||
for cat, id := range hazardsByCategory {
|
||||
if len(measureCat) > 3 && len(cat) > 3 && cat[:4] == measureCat[:4] {
|
||||
return id
|
||||
|
||||
// If pattern has no specific words, it's generic → always relevant
|
||||
if len(specificWords) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if at least one specific word appears in the narrative or components
|
||||
for _, sw := range specificWords {
|
||||
if strings.Contains(narrativeNorm, sw) {
|
||||
return true
|
||||
}
|
||||
for _, cn := range compNames {
|
||||
if strings.Contains(cn, sw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range hazardsByCategory {
|
||||
return id
|
||||
}
|
||||
return uuid.Nil
|
||||
|
||||
// No specific word found in narrative → pattern is for a different machine
|
||||
return false
|
||||
}
|
||||
|
||||
// categoryHazardCap returns the maximum number of hazards to generate per category.
|
||||
// Caps are based on typical ISO 12100 risk assessment proportions:
|
||||
// - Core physical categories (mechanical, electrical): scale with component count
|
||||
// - Secondary categories (thermal, noise, material): smaller fixed caps
|
||||
// - Software/IT/organizational categories: minimal (these are usually covered by
|
||||
// other standards like IEC 62443, not ISO 12100 machinery risk assessment)
|
||||
func categoryHazardCap(cat string, componentCount int) int {
|
||||
// Core machinery hazard categories — scale with complexity
|
||||
switch cat {
|
||||
case "mechanical_hazard":
|
||||
// Typically 1-3 hazards per component (quetschen, scheren, stoss...)
|
||||
cap := componentCount * 3
|
||||
if cap < 15 {
|
||||
cap = 15
|
||||
}
|
||||
if cap > 60 {
|
||||
cap = 60
|
||||
}
|
||||
return cap
|
||||
case "electrical_hazard":
|
||||
// Typically 8-15 for a standard machine
|
||||
cap := componentCount
|
||||
if cap < 8 {
|
||||
cap = 8
|
||||
}
|
||||
if cap > 20 {
|
||||
cap = 20
|
||||
}
|
||||
return cap
|
||||
case "pneumatic_hydraulic":
|
||||
return 8
|
||||
case "thermal_hazard":
|
||||
return 6
|
||||
case "noise_vibration":
|
||||
return 4
|
||||
case "material_environmental":
|
||||
return 6
|
||||
case "ergonomic", "ergonomic_hazard":
|
||||
return 4
|
||||
case "fire_explosion":
|
||||
return 4
|
||||
case "radiation_hazard", "emc_hazard":
|
||||
return 3
|
||||
// Software/IT/organizational — minimal for machinery assessment
|
||||
case "safety_function_failure":
|
||||
return 5
|
||||
case "software_fault":
|
||||
return 3
|
||||
case "configuration_error":
|
||||
return 3
|
||||
case "hmi_error":
|
||||
return 3
|
||||
case "maintenance_hazard":
|
||||
return 4
|
||||
case "mode_confusion":
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeZoneKey reduces a zone string to its core components for better dedup.
|
||||
// E.g. "Schaltschrank, Sammelschiene" and "Schaltschrank-Innenraum, Sammelschienen"
|
||||
// should dedup to the same key.
|
||||
func normalizeZoneKey(zone string) string {
|
||||
if zone == "" {
|
||||
return ""
|
||||
}
|
||||
norm := iace.NormalizeDEPublic(zone)
|
||||
// Remove filler words and punctuation
|
||||
for _, r := range []string{",", "/", "(", ")", "-", ".", ":", ";"} {
|
||||
norm = strings.ReplaceAll(norm, r, " ")
|
||||
}
|
||||
// Extract significant words (>3 chars), sort for stable key
|
||||
words := strings.Fields(norm)
|
||||
var sig []string
|
||||
seen := make(map[string]bool)
|
||||
stopWords := map[string]bool{
|
||||
"der": true, "die": true, "das": true, "und": true, "oder": true,
|
||||
"von": true, "des": true, "den": true, "dem": true, "ein": true,
|
||||
"eine": true, "fuer": true, "bei": true, "mit": true, "nach": true,
|
||||
"alle": true, "aller": true, "allem": true, "sowie": true,
|
||||
"insbesondere": true, "bereich": true, "gesamte": true, "gesamter": true,
|
||||
"innerhalb": true, "ausserhalb": true, "umgebung": true,
|
||||
}
|
||||
for _, w := range words {
|
||||
if len(w) < 4 || stopWords[w] || seen[w] {
|
||||
continue
|
||||
}
|
||||
seen[w] = true
|
||||
sig = append(sig, w)
|
||||
}
|
||||
if len(sig) == 0 {
|
||||
return norm
|
||||
}
|
||||
// Take first 3 significant words as key (enough for dedup)
|
||||
if len(sig) > 3 {
|
||||
sig = sig[:3]
|
||||
}
|
||||
return strings.Join(sig, "_")
|
||||
}
|
||||
|
||||
// findHazardsForMeasureByCategory finds all hazards matching a measure's category.
|
||||
func findHazardsForMeasureByCategory(measureCat string, hazardsByCategory map[string][]uuid.UUID) []uuid.UUID {
|
||||
if ids, ok := hazardsByCategory[measureCat]; ok {
|
||||
return ids
|
||||
}
|
||||
for cat, ids := range hazardsByCategory {
|
||||
if len(measureCat) > 3 && len(cat) > 3 && cat[:4] == measureCat[:4] {
|
||||
return ids
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/dsms"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/rbac"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -411,6 +412,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("PDF export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.pdf", safeName), projectID.String())
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.pdf"`, safeName))
|
||||
c.Data(http.StatusOK, "application/pdf", data)
|
||||
|
||||
@@ -420,6 +422,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Excel export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.xlsx", safeName), projectID.String())
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.xlsx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||
|
||||
@@ -429,6 +432,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("DOCX export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.docx", safeName), projectID.String())
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.docx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
|
||||
|
||||
@@ -438,6 +442,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Markdown export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.md", safeName), projectID.String())
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.md"`, safeName))
|
||||
c.Data(http.StatusOK, "text/markdown", data)
|
||||
|
||||
@@ -462,3 +467,8 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// archiveTechFile stores a tech-file export to DSMS (best-effort, non-blocking).
|
||||
func archiveTechFile(data []byte, filename, projectID string) {
|
||||
dsms.Archive(data, filename, "ce_techfile", projectID, "1")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/usecase"
|
||||
)
|
||||
|
||||
// UseCaseHandler handles use-case compiler endpoints.
|
||||
type UseCaseHandler struct {
|
||||
store *usecase.Store
|
||||
compiler *usecase.Compiler
|
||||
gapDetector *usecase.GapDetector
|
||||
}
|
||||
|
||||
// NewUseCaseHandler creates a new UseCaseHandler.
|
||||
func NewUseCaseHandler(pool *pgxpool.Pool, registry *llm.ProviderRegistry) *UseCaseHandler {
|
||||
store := usecase.NewStore(pool)
|
||||
llmGen := usecase.NewLLMQuestionGenerator(registry)
|
||||
return &UseCaseHandler{
|
||||
store: store,
|
||||
compiler: usecase.NewCompiler(store, llmGen),
|
||||
gapDetector: usecase.NewGapDetector(store),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTemplates returns all available use-case templates.
|
||||
// GET /sdk/v1/use-case/templates
|
||||
func (h *UseCaseHandler) GetTemplates(c *gin.Context) {
|
||||
templates := usecase.TemplateList()
|
||||
c.JSON(http.StatusOK, gin.H{"templates": templates, "total": len(templates)})
|
||||
}
|
||||
|
||||
// GetTemplate returns a specific template with compiled questions.
|
||||
// GET /sdk/v1/use-case/templates/:id
|
||||
func (h *UseCaseHandler) GetTemplate(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
tmpl, ok := usecase.Templates[id]
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := h.compiler.Compile(&tmpl)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tmpl.Questions = questions
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"template": tmpl})
|
||||
}
|
||||
|
||||
// Compile generates questions from MC filters ad-hoc.
|
||||
// POST /sdk/v1/use-case/compile
|
||||
// Uses the full pipeline: doc_check → LLM → deterministic fallback
|
||||
func (h *UseCaseHandler) Compile(c *gin.Context) {
|
||||
var req struct {
|
||||
MCFilters []string `json:"mc_filters" binding:"required"`
|
||||
Regulations []string `json:"regulations"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl := &usecase.Template{
|
||||
ID: "custom",
|
||||
MCFilters: req.MCFilters,
|
||||
Regulations: req.Regulations,
|
||||
}
|
||||
|
||||
questions, err := h.compiler.Compile(tmpl)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"questions": questions, "total": len(questions)})
|
||||
}
|
||||
|
||||
// CreateAudit starts a new audit from a template.
|
||||
// POST /sdk/v1/use-case/audits
|
||||
func (h *UseCaseHandler) CreateAudit(c *gin.Context) {
|
||||
var input usecase.CreateAuditInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tenantID, err := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Tenant-ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, ok := usecase.Templates[input.TemplateID]
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown template_id"})
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := h.compiler.Compile(&tmpl)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
audit := &usecase.Audit{
|
||||
TenantID: tenantID,
|
||||
TemplateID: input.TemplateID,
|
||||
Name: input.Name,
|
||||
TargetName: input.TargetName,
|
||||
TotalQuestions: len(questions),
|
||||
Questions: questions,
|
||||
}
|
||||
|
||||
if err := h.store.CreateAudit(audit); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create audit"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"audit": audit})
|
||||
}
|
||||
|
||||
// ListAudits returns all audits for a tenant.
|
||||
// GET /sdk/v1/use-case/audits
|
||||
func (h *UseCaseHandler) ListAudits(c *gin.Context) {
|
||||
tenantID, err := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "X-Tenant-ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
audits, err := h.store.ListAudits(tenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list audits"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audits": audits, "total": len(audits)})
|
||||
}
|
||||
|
||||
// GetAudit returns an audit with questions and answers.
|
||||
// GET /sdk/v1/use-case/audits/:id
|
||||
func (h *UseCaseHandler) GetAudit(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
audit, err := h.store.GetAudit(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
answers, err := h.store.ListAnswers(id)
|
||||
if err != nil {
|
||||
answers = nil
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit": audit, "answers": answers})
|
||||
}
|
||||
|
||||
// AnswerQuestion saves an answer for a question in an audit.
|
||||
// POST /sdk/v1/use-case/audits/:id/answer
|
||||
func (h *UseCaseHandler) AnswerQuestion(c *gin.Context) {
|
||||
auditID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input usecase.AnswerInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Find MC ID from the question
|
||||
audit, err := h.store.GetAudit(auditID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var mcID string
|
||||
for _, q := range audit.Questions {
|
||||
if q.ID == input.QuestionID {
|
||||
mcID = q.MCID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
status := usecase.AnswerStatusAnswered
|
||||
if input.Status == "skipped" {
|
||||
status = usecase.AnswerStatusSkipped
|
||||
} else if input.Status == "escalated" {
|
||||
status = usecase.AnswerStatusEscalated
|
||||
}
|
||||
|
||||
answer := &usecase.Answer{
|
||||
AuditID: auditID,
|
||||
QuestionID: input.QuestionID,
|
||||
MCID: mcID,
|
||||
Value: input.Value,
|
||||
Comment: input.Comment,
|
||||
EvidenceIDs: input.EvidenceIDs,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if err := h.store.SaveAnswer(answer); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save answer"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update audit counters
|
||||
answers, _ := h.store.ListAnswers(auditID)
|
||||
score := usecase.Score(audit, answers)
|
||||
|
||||
auditStatus := usecase.StatusInProgress
|
||||
if score.Answered >= audit.TotalQuestions {
|
||||
auditStatus = usecase.StatusCompleted
|
||||
}
|
||||
h.store.UpdateAuditScore(auditID, score.Answered, score.ComplianceScore, auditStatus)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"answer": answer, "progress": score})
|
||||
}
|
||||
|
||||
// GetScore calculates and returns the compliance score.
|
||||
// GET /sdk/v1/use-case/audits/:id/score
|
||||
func (h *UseCaseHandler) GetScore(c *gin.Context) {
|
||||
auditID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
audit, err := h.store.GetAudit(auditID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
answers, err := h.store.ListAnswers(auditID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load answers"})
|
||||
return
|
||||
}
|
||||
|
||||
score := usecase.Score(audit, answers)
|
||||
c.JSON(http.StatusOK, score)
|
||||
}
|
||||
|
||||
// GetGaps returns missing regulation sources for an audit.
|
||||
// GET /sdk/v1/use-case/audits/:id/gaps
|
||||
func (h *UseCaseHandler) GetGaps(c *gin.Context) {
|
||||
auditID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid audit ID"})
|
||||
return
|
||||
}
|
||||
|
||||
audit, err := h.store.GetAudit(auditID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "audit not found"})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, ok := usecase.Templates[audit.TemplateID]
|
||||
if !ok {
|
||||
c.JSON(http.StatusOK, gin.H{"gaps": []interface{}{}, "audit_gaps": []interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
// Missing regulation sources (from MC analysis)
|
||||
missingRegs, err := h.gapDetector.DetectMissingRegulations(&tmpl)
|
||||
if err != nil {
|
||||
missingRegs = nil
|
||||
}
|
||||
|
||||
// Audit-specific gaps (from answer analysis)
|
||||
answers, _ := h.store.ListAnswers(auditID)
|
||||
auditGaps := h.gapDetector.DetectAuditGaps(audit, answers)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"missing_sources": missingRegs,
|
||||
"audit_gaps": auditGaps,
|
||||
})
|
||||
}
|
||||
@@ -155,6 +155,9 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
||||
// Gap Analysis
|
||||
gapHandler := handlers.NewGapHandler(pool)
|
||||
|
||||
// Use-Case Compiler
|
||||
useCaseHandler := handlers.NewUseCaseHandler(pool, providerRegistry)
|
||||
|
||||
rbacMiddleware := rbac.NewMiddleware(rbacService, policyEngine)
|
||||
|
||||
// Router
|
||||
@@ -179,7 +182,7 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
||||
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
||||
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers)
|
||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func registerRoutes(
|
||||
gapHandler *handlers.GapHandler,
|
||||
maximizerHandlers *handlers.MaximizerHandlers,
|
||||
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
||||
useCaseHandler *handlers.UseCaseHandler,
|
||||
) {
|
||||
v1 := router.Group("/sdk/v1")
|
||||
{
|
||||
@@ -51,6 +52,7 @@ func registerRoutes(
|
||||
registerIACERoutes(v1, iaceHandler)
|
||||
registerGapRoutes(v1, gapHandler)
|
||||
registerMaximizerRoutes(v1, maximizerHandlers)
|
||||
registerUseCaseRoutes(v1, useCaseHandler)
|
||||
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
||||
}
|
||||
}
|
||||
@@ -430,6 +432,10 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
|
||||
iaceRoutes.POST("/library-search", h.SearchLibrary)
|
||||
iaceRoutes.GET("/ce-corpus-documents", h.ListCECorpusDocuments)
|
||||
iaceRoutes.POST("/projects/:id/initialize", h.InitializeProject)
|
||||
iaceRoutes.GET("/projects/:id/hazard-blocks", h.GetHazardBlocks)
|
||||
iaceRoutes.POST("/projects/:id/benchmark/import-gt", h.ImportGroundTruth)
|
||||
iaceRoutes.GET("/projects/:id/benchmark", h.RunBenchmark)
|
||||
iaceRoutes.GET("/projects/:id/benchmark/summary", h.GetBenchmarkSummary)
|
||||
iaceRoutes.GET("/projects/:id/hazards/:hid/regulatory-hints", h.EnrichHazardWithRegulations)
|
||||
iaceRoutes.GET("/projects/:id/mitigations/:mid/regulatory-hints", h.EnrichMitigationWithRegulations)
|
||||
iaceRoutes.GET("/projects/:id/regulatory-hints", h.EnrichProjectHazardsBatch)
|
||||
@@ -463,6 +469,21 @@ func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers)
|
||||
}
|
||||
}
|
||||
|
||||
func registerUseCaseRoutes(v1 *gin.RouterGroup, h *handlers.UseCaseHandler) {
|
||||
uc := v1.Group("/use-case")
|
||||
{
|
||||
uc.GET("/templates", h.GetTemplates)
|
||||
uc.GET("/templates/:id", h.GetTemplate)
|
||||
uc.POST("/compile", h.Compile)
|
||||
uc.POST("/audits", h.CreateAudit)
|
||||
uc.GET("/audits", h.ListAudits)
|
||||
uc.GET("/audits/:id", h.GetAudit)
|
||||
uc.POST("/audits/:id/answer", h.AnswerQuestion)
|
||||
uc.GET("/audits/:id/score", h.GetScore)
|
||||
uc.GET("/audits/:id/gaps", h.GetGaps)
|
||||
}
|
||||
}
|
||||
|
||||
func registerGapRoutes(v1 *gin.RouterGroup, h *handlers.GapHandler) {
|
||||
g := v1.Group("/gap")
|
||||
{
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Fuzzy matching: Ground Truth entries ↔ Engine hazards
|
||||
// ============================================================================
|
||||
|
||||
const matchThreshold = 0.20
|
||||
|
||||
// categoryMap, synonymSets, wrongMachineTerms → benchmark_synonyms.go
|
||||
|
||||
// CompareBenchmark runs the full comparison between Ground Truth and engine output.
|
||||
func CompareBenchmark(gt *GroundTruth, hazards []Hazard, mitigations []Mitigation) *BenchmarkResult {
|
||||
if gt == nil || len(gt.Entries) == 0 {
|
||||
return &BenchmarkResult{}
|
||||
}
|
||||
|
||||
// Build mitigation names per hazard
|
||||
mitNamesByHazard := make(map[string][]string)
|
||||
for _, m := range mitigations {
|
||||
mitNamesByHazard[m.HazardID.String()] = append(mitNamesByHazard[m.HazardID.String()], m.Name)
|
||||
}
|
||||
|
||||
engineSummaries := make([]HazardSummary, len(hazards))
|
||||
for i, h := range hazards {
|
||||
engineSummaries[i] = HazardSummary{
|
||||
ID: h.ID.String(),
|
||||
Name: h.Name,
|
||||
Category: h.Category,
|
||||
Zone: h.HazardousZone,
|
||||
Description: h.Description,
|
||||
Scenario: h.Scenario,
|
||||
PossibleHarm: h.PossibleHarm,
|
||||
TriggerEvent: h.TriggerEvent,
|
||||
AffectedPerson: h.AffectedPerson,
|
||||
LifecyclePhase: h.LifecyclePhase,
|
||||
Mitigations: mitNamesByHazard[h.ID.String()],
|
||||
}
|
||||
}
|
||||
|
||||
// Build score matrix: gt[i] × engine[j]
|
||||
type scoredPair struct {
|
||||
gtIdx, engIdx int
|
||||
score float64
|
||||
reason string
|
||||
}
|
||||
var pairs []scoredPair
|
||||
for i := range gt.Entries {
|
||||
for j := range hazards {
|
||||
score, reason := fuzzyMatchScore(>.Entries[i], &hazards[j])
|
||||
if score >= matchThreshold {
|
||||
pairs = append(pairs, scoredPair{i, j, score, reason})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Greedy assignment: sort by score, but prioritize high-specificity matches
|
||||
// (matches where both category AND zone overlap) over generic ones
|
||||
sort.Slice(pairs, func(a, b int) bool {
|
||||
// First: prioritize matches with zone overlap (more specific)
|
||||
aHasZone := pairs[a].reason != "" && (strings.Contains(pairs[a].reason, "Zone") || strings.Contains(pairs[a].reason, "Keywords+Zone"))
|
||||
bHasZone := pairs[b].reason != "" && (strings.Contains(pairs[b].reason, "Zone") || strings.Contains(pairs[b].reason, "Keywords+Zone"))
|
||||
if aHasZone != bHasZone {
|
||||
return aHasZone
|
||||
}
|
||||
return pairs[a].score > pairs[b].score
|
||||
})
|
||||
usedGT := make(map[int]bool)
|
||||
usedEng := make(map[int]bool)
|
||||
var matched []HazardMatchPair
|
||||
|
||||
for _, p := range pairs {
|
||||
if usedGT[p.gtIdx] || usedEng[p.engIdx] {
|
||||
continue
|
||||
}
|
||||
usedGT[p.gtIdx] = true
|
||||
usedEng[p.engIdx] = true
|
||||
matched = append(matched, HazardMatchPair{
|
||||
GTEntry: gt.Entries[p.gtIdx],
|
||||
EngineHazard: engineSummaries[p.engIdx],
|
||||
MatchScore: p.score,
|
||||
MatchReason: p.reason,
|
||||
})
|
||||
}
|
||||
|
||||
// Collect unmatched
|
||||
var missing []GroundTruthEntry
|
||||
for i, e := range gt.Entries {
|
||||
if !usedGT[i] {
|
||||
missing = append(missing, e)
|
||||
}
|
||||
}
|
||||
var extra []HazardSummary
|
||||
for i, s := range engineSummaries {
|
||||
if !usedEng[i] {
|
||||
extra = append(extra, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Category breakdown
|
||||
catGT := map[string]int{}
|
||||
catMatch := map[string]int{}
|
||||
for _, e := range gt.Entries {
|
||||
cat := normalizeCategoryDE(e.HazardGroup)
|
||||
catGT[cat]++
|
||||
}
|
||||
for _, m := range matched {
|
||||
cat := normalizeCategoryDE(m.GTEntry.HazardGroup)
|
||||
catMatch[cat]++
|
||||
}
|
||||
var breakdown []CategoryScore
|
||||
for cat, total := range catGT {
|
||||
cov := 0.0
|
||||
if total > 0 {
|
||||
cov = float64(catMatch[cat]) / float64(total)
|
||||
}
|
||||
breakdown = append(breakdown, CategoryScore{
|
||||
Category: cat, GTCount: total, MatchCount: catMatch[cat], Coverage: cov,
|
||||
})
|
||||
}
|
||||
sort.Slice(breakdown, func(i, j int) bool { return breakdown[i].GTCount > breakdown[j].GTCount })
|
||||
|
||||
// Measure coverage (simplified: count GT entries where at least 1 measure keyword matches)
|
||||
measMatched := 0
|
||||
for _, m := range matched {
|
||||
if measureOverlap(m.GTEntry.Measures, mitigations) {
|
||||
measMatched++
|
||||
}
|
||||
}
|
||||
measCov := 0.0
|
||||
if len(matched) > 0 {
|
||||
measCov = float64(measMatched) / float64(len(matched))
|
||||
}
|
||||
|
||||
// Risk rank comparison
|
||||
rankPairs := buildRiskRankPairs(matched)
|
||||
|
||||
coverage := 0.0
|
||||
if len(gt.Entries) > 0 {
|
||||
coverage = float64(len(matched)) / float64(len(gt.Entries))
|
||||
}
|
||||
|
||||
return &BenchmarkResult{
|
||||
CoverageScore: coverage,
|
||||
MeasureCoverage: measCov,
|
||||
TotalGT: len(gt.Entries),
|
||||
TotalEngine: len(hazards),
|
||||
MatchedPairs: matched,
|
||||
MissingFromEngine: missing,
|
||||
ExtraInEngine: extra,
|
||||
CategoryBreakdown: breakdown,
|
||||
RiskRankPairs: rankPairs,
|
||||
}
|
||||
}
|
||||
|
||||
// fuzzyMatchScore computes a 0-1 similarity between a GT entry and an engine hazard.
|
||||
// 4 signals: category (0.2), keywords (0.2), zone (0.3), scenario similarity (0.3).
|
||||
func fuzzyMatchScore(gt *GroundTruthEntry, h *Hazard) (float64, string) {
|
||||
var score float64
|
||||
var reasons []string
|
||||
|
||||
// 1. Category match (weight 0.2)
|
||||
catScore := categoryMatchScore(gt.HazardGroup, h.Category)
|
||||
score += 0.2 * catScore
|
||||
if catScore > 0 {
|
||||
reasons = append(reasons, "Kategorie")
|
||||
}
|
||||
|
||||
// 2. Keyword/synonym match on hazard TYPE (weight 0.2)
|
||||
kwScore := keywordMatchScore(gt.HazardType, gt.HazardCause, h.Name, h.Description, h.Scenario)
|
||||
score += 0.2 * kwScore
|
||||
if kwScore > 0 {
|
||||
reasons = append(reasons, "Keywords")
|
||||
}
|
||||
|
||||
// 3. Component/zone match (weight 0.3)
|
||||
zoneScore := zoneMatchScore(gt.ComponentZone, gt.HazardSubgroup, h.HazardousZone, h.MachineModule)
|
||||
score += 0.3 * zoneScore
|
||||
if zoneScore > 0 {
|
||||
reasons = append(reasons, "Zone")
|
||||
}
|
||||
|
||||
// 4. Scenario similarity (weight 0.3) — compares the actual event description
|
||||
scenScore := scenarioSimilarity(gt.HazardCause, h.Scenario, h.Name)
|
||||
score += 0.3 * scenScore
|
||||
if scenScore > 0 {
|
||||
reasons = append(reasons, "Szenario")
|
||||
}
|
||||
|
||||
// Penalty: wrong machine term
|
||||
if hasWrongMachineTerm(h.Name, h.Scenario, gt.HazardCause, gt.ComponentZone) {
|
||||
score *= 0.3
|
||||
reasons = append(reasons, "Strafabzug:FremdMaschine")
|
||||
}
|
||||
|
||||
// Penalty: no keyword AND no scenario overlap → unreliable
|
||||
if kwScore == 0 && scenScore == 0 && zoneScore < 0.5 {
|
||||
score *= 0.4
|
||||
reasons = append(reasons, "Strafabzug:KeinInhalt")
|
||||
}
|
||||
|
||||
return score, strings.Join(reasons, "+")
|
||||
}
|
||||
|
||||
// scenarioSimilarity compares the GT cause description with the engine scenario.
|
||||
// Uses action words + synonym-set cross-matching for robust comparison.
|
||||
func scenarioSimilarity(gtCause, engScenario, engName string) float64 {
|
||||
gtText := normalizeDE(gtCause)
|
||||
engText := normalizeDE(engScenario + " " + engName)
|
||||
|
||||
gtActions := extractActionWords(gtText)
|
||||
engActions := extractActionWords(engText)
|
||||
|
||||
if len(gtActions) == 0 {
|
||||
// Fallback: use significant word overlap
|
||||
return significantWordOverlap(gtText, engText)
|
||||
}
|
||||
|
||||
matched := 0
|
||||
for _, ga := range gtActions {
|
||||
// Direct match
|
||||
directFound := false
|
||||
for _, ea := range engActions {
|
||||
if ga == ea || strings.HasPrefix(ea, ga) || strings.HasPrefix(ga, ea) {
|
||||
directFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if directFound {
|
||||
matched++
|
||||
continue
|
||||
}
|
||||
// Synonym-set match: if GT action and any engine action are in the same synonym set
|
||||
for _, synSet := range synonymSets {
|
||||
gaInSet := false
|
||||
for _, syn := range synSet {
|
||||
if strings.Contains(ga, syn) || strings.Contains(syn, ga) {
|
||||
gaInSet = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !gaInSet {
|
||||
continue
|
||||
}
|
||||
// Check if any engine action is in this same set
|
||||
for _, ea := range engActions {
|
||||
for _, syn := range synSet {
|
||||
if strings.Contains(ea, syn) || strings.Contains(syn, ea) {
|
||||
matched++
|
||||
goto nextAction
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check full engine text for synonym hit
|
||||
for _, syn := range synSet {
|
||||
if strings.Contains(engText, syn) {
|
||||
matched++
|
||||
goto nextAction
|
||||
}
|
||||
}
|
||||
}
|
||||
nextAction:
|
||||
}
|
||||
return float64(matched) / float64(len(gtActions))
|
||||
}
|
||||
|
||||
// significantWordOverlap is a fallback when no action words are found.
|
||||
func significantWordOverlap(gtText, engText string) float64 {
|
||||
gtWords := extractSignificantWords(gtText)
|
||||
if len(gtWords) == 0 {
|
||||
return 0
|
||||
}
|
||||
matched := 0
|
||||
for _, w := range gtWords {
|
||||
if strings.Contains(engText, w) {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
return float64(matched) / float64(len(gtWords))
|
||||
}
|
||||
|
||||
func hasWrongMachineTerm(engName, engScenario, gtCause, gtZone string) bool {
|
||||
engText := normalizeDE(engName + " " + engScenario)
|
||||
gtText := normalizeDE(gtCause + " " + gtZone)
|
||||
for _, term := range wrongMachineTerms {
|
||||
if strings.Contains(engText, term) && !strings.Contains(gtText, term) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func categoryMatchScore(gtGroup, engCategory string) float64 {
|
||||
normalized := normalizeDE(gtGroup)
|
||||
prefixes, ok := categoryMap[normalized]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
engLower := strings.ToLower(engCategory)
|
||||
for _, p := range prefixes {
|
||||
if strings.Contains(engLower, p) {
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func keywordMatchScore(gtType, gtCause, engName, engDesc, engScenario string) float64 {
|
||||
gtText := normalizeDE(gtType + " " + gtCause)
|
||||
engText := normalizeDE(engName + " " + engDesc + " " + engScenario)
|
||||
|
||||
matchedSets := 0
|
||||
totalRelevant := 0
|
||||
|
||||
for _, synSet := range synonymSets {
|
||||
gtHas := false
|
||||
engHas := false
|
||||
for _, syn := range synSet {
|
||||
if strings.Contains(gtText, syn) {
|
||||
gtHas = true
|
||||
}
|
||||
if strings.Contains(engText, syn) {
|
||||
engHas = true
|
||||
}
|
||||
}
|
||||
if gtHas {
|
||||
totalRelevant++
|
||||
if engHas {
|
||||
matchedSets++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if totalRelevant == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(matchedSets) / float64(totalRelevant)
|
||||
}
|
||||
|
||||
func zoneMatchScore(gtZone, gtSubgroup, engZone, engModule string) float64 {
|
||||
gtText := normalizeDE(gtZone + " " + gtSubgroup)
|
||||
engText := normalizeDE(engZone + " " + engModule)
|
||||
|
||||
if gtText == "" || engText == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Check for significant word overlap
|
||||
gtWords := extractSignificantWords(gtText)
|
||||
engWords := extractSignificantWords(engText)
|
||||
|
||||
if len(gtWords) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
matched := 0
|
||||
for _, gw := range gtWords {
|
||||
for _, ew := range engWords {
|
||||
if strings.Contains(ew, gw) || strings.Contains(gw, ew) {
|
||||
matched++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return float64(matched) / float64(len(gtWords))
|
||||
}
|
||||
|
||||
func extractSignificantWords(text string) []string {
|
||||
stopWords := map[string]bool{
|
||||
"der": true, "die": true, "das": true, "und": true, "oder": true,
|
||||
"von": true, "in": true, "an": true, "am": true, "im": true,
|
||||
"zu": true, "bei": true, "mit": true, "des": true, "den": true,
|
||||
"dem": true, "ein": true, "eine": true, "einer": true, "einem": true,
|
||||
"fuer": true, "auf": true, "aus": true, "um": true, "nach": true,
|
||||
"ueber": true, "unter": true, "vor": true, "durch": true,
|
||||
}
|
||||
words := strings.Fields(text)
|
||||
var sig []string
|
||||
for _, w := range words {
|
||||
if len(w) < 3 || stopWords[w] {
|
||||
continue
|
||||
}
|
||||
sig = append(sig, w)
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// NormalizeDEPublic is the exported version of normalizeDE for use outside this package.
|
||||
func NormalizeDEPublic(s string) string { return normalizeDE(s) }
|
||||
|
||||
// normalizeDE lowercases and replaces umlauts (same as narrative_parser).
|
||||
func normalizeDE(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "ä", "ae")
|
||||
s = strings.ReplaceAll(s, "ö", "oe")
|
||||
s = strings.ReplaceAll(s, "ü", "ue")
|
||||
s = strings.ReplaceAll(s, "ß", "ss")
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeCategoryDE(group string) string {
|
||||
n := normalizeDE(group)
|
||||
// Shorten for display
|
||||
n = strings.TrimPrefix(n, "gefaehrdungen durch ")
|
||||
n = strings.TrimPrefix(n, "gefaehrdungen im zusammenhang mit ")
|
||||
return n
|
||||
}
|
||||
|
||||
func measureOverlap(gtMeasures []string, mitigations []Mitigation) bool {
|
||||
for _, gm := range gtMeasures {
|
||||
gmNorm := normalizeDE(gm)
|
||||
for _, m := range mitigations {
|
||||
mNorm := normalizeDE(m.Name + " " + m.Description)
|
||||
// Check if any significant word from GT measure appears in engine mitigation
|
||||
words := extractSignificantWords(gmNorm)
|
||||
for _, w := range words {
|
||||
if strings.Contains(mNorm, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildRiskRankPairs(matched []HazardMatchPair) []RiskRankPair {
|
||||
if len(matched) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by GT risk descending to get GT rank
|
||||
type ranked struct {
|
||||
idx int
|
||||
gtRisk int
|
||||
name string
|
||||
}
|
||||
items := make([]ranked, len(matched))
|
||||
for i, m := range matched {
|
||||
items[i] = ranked{i, m.GTEntry.RiskIn.R, m.GTEntry.HazardType}
|
||||
}
|
||||
sort.Slice(items, func(a, b int) bool { return items[a].gtRisk > items[b].gtRisk })
|
||||
|
||||
pairs := make([]RiskRankPair, len(items))
|
||||
for rank, item := range items {
|
||||
pairs[rank] = RiskRankPair{
|
||||
GTRank: rank + 1,
|
||||
EngineRank: 0, // Engine has no assessment yet for auto-generated hazards
|
||||
HazardName: item.name,
|
||||
GTRiskScore: item.gtRisk,
|
||||
EngineRisk: 0,
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package iace
|
||||
|
||||
import "strings"
|
||||
|
||||
// synonymSets groups equivalent hazard terms for keyword matching.
|
||||
var synonymSets = [][]string{
|
||||
{"quetsch", "crush", "einklemm", "klemm"},
|
||||
{"scher", "shear", "absch"},
|
||||
{"schneid", "cut", "schnitt"},
|
||||
{"stoss", "schlag", "impact", "treff", "aufprall"},
|
||||
{"einzug", "fang", "erfass", "entangle", "wickel"},
|
||||
{"elektrisch", "stromschlag", "electric", "beruehr", "spannungsfuehr", "koerperdurchstroemung"},
|
||||
{"brand", "feuer", "fire", "kabelbrand", "kurzschluss", "ueberlast", "ueberstrom"},
|
||||
{"verbrenn", "burn", "heiss", "thermisch", "lichtbogen"},
|
||||
{"laerm", "noise", "gehoer", "schall", "dezibel"},
|
||||
{"vibration", "schwing"},
|
||||
{"ergonom", "haltung", "handhabung", "bedien", "bewegungsapparat"},
|
||||
{"kuehlschmierstoff", "kss", "aerosol", "coolant"},
|
||||
{"pneumat", "druckluft", "compressed"},
|
||||
{"hydraul", "druck", "pressure"},
|
||||
{"roboter", "robot", "roboterarm"},
|
||||
{"greifer", "gripper", "schunk"},
|
||||
{"foerderband", "transport", "conveyor"},
|
||||
{"schutzzaun", "schutzgitter", "fence", "guard"},
|
||||
{"werkzeugmaschine", "robodrill", "bearbeitungszentrum", "wzm"},
|
||||
{"stolper", "rutsch", "slip", "trip"},
|
||||
{"leckage", "austreten", "leak"},
|
||||
{"einstich", "puncture", "spritz"},
|
||||
{"isolat", "kriechstrom", "schutzleiter", "erdung", "indirekt"},
|
||||
{"luft", "kriechstreck", "beruehrer", "oberflaeche", "leitfaehig"},
|
||||
{"emv", "strahlung", "radiation", "elektromagnet", "stoereinfluss"},
|
||||
{"eingeschlossen", "eingesperrt", "wiederanlauf", "quittier"},
|
||||
{"zentriergreifer", "zentriereinheit", "zentrieren"},
|
||||
{"beladetuer", "schutztuer", "zugangstuer", "tuerposition"},
|
||||
{"werkstueck", "rohteil", "rohling"},
|
||||
{"ergonom", "einlege", "bedienelemente", "arbeitshoehe", "haltung"},
|
||||
{"boden", "tragfaehig", "einbrech", "fundamentierr"},
|
||||
{"spritzer", "auge", "augenverletz"},
|
||||
{"bersten", "platzen", "abspring"},
|
||||
{"durchschlag", "durchbrech", "begrenz", "bewegungsbereich"},
|
||||
{"potentialausgleich", "potentialunter", "bezugspotential", "potential", "energieversorgung"},
|
||||
{"kriechstreck", "luft-", "kriechst", "dimensionie", "kurzschluss"},
|
||||
{"emv", "elektromagnet", "stoereinfluss", "stoerung", "sicherheitsrelevant"},
|
||||
{"kuehlschmierstoff", "kss", "bettspuel", "kuehlung"},
|
||||
{"rutsch", "ausrutsch", "stolper", "gleiten", "nassrutsch"},
|
||||
}
|
||||
|
||||
// wrongMachineTerms are words in an engine hazard that indicate it's about
|
||||
// a completely different machine type.
|
||||
var wrongMachineTerms = []string{
|
||||
"spielplatz", "fahrtreppe", "trommelwaschmaschine", "umreifungsband",
|
||||
"drehteller", "rundtaktanlage", "exzentrisch", "webstuhl",
|
||||
"aufzug", "rolltreppe", "bagger", "kettensaege", "kreissaege",
|
||||
"druckmaschine", "zentrifuge", "autoklav", "hobel",
|
||||
"naehmaschine", "strickmaschine", "schleifmaschine",
|
||||
"gabelstapler", "flurfoerder", "erntemaschine",
|
||||
"kollision zweier roboter",
|
||||
}
|
||||
|
||||
// categoryMap maps GT hazard_group (German) to engine category prefixes.
|
||||
var categoryMap = map[string][]string{
|
||||
"mechanische gefaehrdungen": {"mechanical"},
|
||||
"elektrische gefaehrdungen": {"electrical"},
|
||||
"thermische gefaehrdungen": {"thermal"},
|
||||
"gefaehrdungen durch laerm": {"noise", "ergonomic"},
|
||||
"gefaehrdungen durch vibration": {"noise", "vibration"},
|
||||
"gefaehrdungen durch strahlung": {"radiation", "emc"},
|
||||
"gefaehrdungen durch materialien und substanzen": {"material", "environmental"},
|
||||
"ergonomische gefaehrdungen": {"ergonomic"},
|
||||
"gefaehrdungen im zusammenhang mit der einsatzumgebung": {"environmental"},
|
||||
}
|
||||
|
||||
// extractActionWords pulls out verbs and descriptors that define the hazard event.
|
||||
func extractActionWords(text string) []string {
|
||||
// These are the differentiating words between similar-looking hazards
|
||||
actionTerms := []string{
|
||||
"eingeklemmt", "einklemm", "eingeschlossen", "eingesperrt",
|
||||
"herabfall", "herunterfal", "faellt",
|
||||
"durchschlaegt", "durchbrech", "durchschlag",
|
||||
"springt ab", "abspring", "bersten", "platzen",
|
||||
"weggeschleudert", "schleuder",
|
||||
"getroffen", "treff",
|
||||
"greift", "eingreif", "durchgreif", "uebergreif",
|
||||
"beruehrt", "beruehr", "kontakt",
|
||||
"einzug", "erfass", "aufwickel",
|
||||
"stolper", "rutsch", "ausrutsch", "gleiten",
|
||||
"verbren", "heiss",
|
||||
"spritzer", "augenver",
|
||||
"kurzschluss", "ueberstrom", "ueberlast",
|
||||
"isolat", "schutzleiter", "kriechstrom", "kriechstreck",
|
||||
"potentialausgleich", "potentialunter", "bezugspotential", "potential",
|
||||
"emv", "stoereinfluss", "elektromagnet", "stoerung",
|
||||
"leckage", "austret", "undicht",
|
||||
"schutzzaun", "einhausung", "schutztuer",
|
||||
"wiederanlauf", "anlauf", "startet",
|
||||
"teach", "einricht", "programmier",
|
||||
"spannvorricht", "spannfutter", "greiferbacken",
|
||||
"druckluft", "pneumatik", "restdruck",
|
||||
"beladetuer", "werkzeugmaschine", "bearbeitungszelle",
|
||||
"ergonom", "einlege", "bedienelement",
|
||||
"tragfaehig", "boden", "einbrech",
|
||||
// Additional terms for remaining GT gaps
|
||||
"schlauch", "druck", "kuehlschmierstoff",
|
||||
"bettspuel", "pumpe", "niederdruck",
|
||||
"luft-", "dimensionie",
|
||||
"anlagenteile", "energieversorgung",
|
||||
"greifer", "werkzeug",
|
||||
}
|
||||
|
||||
var found []string
|
||||
seen := make(map[string]bool)
|
||||
for _, term := range actionTerms {
|
||||
if strings.Contains(text, term) && !seen[term] {
|
||||
seen[term] = true
|
||||
found = append(found, term)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package iace
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ============================================================================
|
||||
// Ground Truth types — stores a professional risk assessment for benchmarking
|
||||
// ============================================================================
|
||||
|
||||
// GroundTruth is the top-level container stored in project metadata.ground_truth.
|
||||
type GroundTruth struct {
|
||||
Entries []GroundTruthEntry `json:"entries"`
|
||||
SourceFile string `json:"source_file,omitempty"`
|
||||
ImportedAt string `json:"imported_at"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// GroundTruthEntry represents a single hazard from a professional risk assessment.
|
||||
type GroundTruthEntry struct {
|
||||
Nr string `json:"nr"`
|
||||
HazardGroup string `json:"hazard_group"`
|
||||
HazardGroupApplicable bool `json:"hazard_group_applicable"`
|
||||
HazardSubgroup string `json:"hazard_subgroup"`
|
||||
HazardType string `json:"hazard_type"`
|
||||
HazardCause string `json:"hazard_cause"`
|
||||
LifecyclePhases []string `json:"lifecycle_phases"`
|
||||
ComponentZone string `json:"component_zone"`
|
||||
RiskIn GTRisk `json:"risk_in"`
|
||||
PLr *GTPLr `json:"plr,omitempty"`
|
||||
Measures []string `json:"measures"`
|
||||
MeasureType string `json:"measure_type"`
|
||||
RiskOut GTRisk `json:"risk_out"`
|
||||
NormReferences []string `json:"norm_references"`
|
||||
Sufficient bool `json:"sufficient"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
ReductionSteps []GTReductionStep `json:"reduction_steps,omitempty"`
|
||||
}
|
||||
|
||||
// GTRisk represents the EN 62061 additive risk: R = (F + W + P) * S.
|
||||
type GTRisk struct {
|
||||
F int `json:"f"`
|
||||
W int `json:"w"`
|
||||
P int `json:"p"`
|
||||
S int `json:"s"`
|
||||
R int `json:"r"`
|
||||
}
|
||||
|
||||
// GTPLr represents Performance Level required (EN ISO 13849-1).
|
||||
type GTPLr struct {
|
||||
S string `json:"s"`
|
||||
F string `json:"f"`
|
||||
P string `json:"p"`
|
||||
EW string `json:"ew,omitempty"`
|
||||
PLr string `json:"plr"`
|
||||
}
|
||||
|
||||
// GTReductionStep represents an iterative risk reduction row.
|
||||
type GTReductionStep struct {
|
||||
RiskIn GTRisk `json:"risk_in"`
|
||||
PLr *GTPLr `json:"plr,omitempty"`
|
||||
Measures []string `json:"measures"`
|
||||
MeasureType string `json:"measure_type"`
|
||||
RiskOut GTRisk `json:"risk_out"`
|
||||
NormReferences []string `json:"norm_references"`
|
||||
Sufficient bool `json:"sufficient"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Benchmark result types — comparison output
|
||||
// ============================================================================
|
||||
|
||||
// BenchmarkResult is the API response for the comparison endpoint.
|
||||
type BenchmarkResult struct {
|
||||
CoverageScore float64 `json:"coverage_score"`
|
||||
MeasureCoverage float64 `json:"measure_coverage"`
|
||||
TotalGT int `json:"total_gt"`
|
||||
TotalEngine int `json:"total_engine"`
|
||||
MatchedPairs []HazardMatchPair `json:"matched_pairs"`
|
||||
MissingFromEngine []GroundTruthEntry `json:"missing_from_engine"`
|
||||
ExtraInEngine []HazardSummary `json:"extra_in_engine"`
|
||||
CategoryBreakdown []CategoryScore `json:"category_breakdown"`
|
||||
RiskRankPairs []RiskRankPair `json:"risk_rank_pairs"`
|
||||
}
|
||||
|
||||
// HazardMatchPair links a GT entry to an engine hazard.
|
||||
type HazardMatchPair struct {
|
||||
GTEntry GroundTruthEntry `json:"gt_entry"`
|
||||
EngineHazard HazardSummary `json:"engine_hazard"`
|
||||
MatchScore float64 `json:"match_score"`
|
||||
MatchReason string `json:"match_reason"`
|
||||
}
|
||||
|
||||
// HazardSummary is a hazard representation for benchmark results with detail fields.
|
||||
type HazardSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Component string `json:"component,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
RiskLevel string `json:"risk_level,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Scenario string `json:"scenario,omitempty"`
|
||||
PossibleHarm string `json:"possible_harm,omitempty"`
|
||||
TriggerEvent string `json:"trigger_event,omitempty"`
|
||||
AffectedPerson string `json:"affected_person,omitempty"`
|
||||
LifecyclePhase string `json:"lifecycle_phase,omitempty"`
|
||||
Mitigations []string `json:"mitigations,omitempty"`
|
||||
}
|
||||
|
||||
// CategoryScore shows coverage per ISO 12100 hazard group.
|
||||
type CategoryScore struct {
|
||||
Category string `json:"category"`
|
||||
GTCount int `json:"gt_count"`
|
||||
MatchCount int `json:"match_count"`
|
||||
Coverage float64 `json:"coverage"`
|
||||
}
|
||||
|
||||
// RiskRankPair compares risk ordering between GT and engine.
|
||||
type RiskRankPair struct {
|
||||
GTRank int `json:"gt_rank"`
|
||||
EngineRank int `json:"engine_rank"`
|
||||
HazardName string `json:"hazard_name"`
|
||||
GTRiskScore int `json:"gt_risk_score"`
|
||||
EngineRisk float64 `json:"engine_risk"`
|
||||
}
|
||||
|
||||
// ParseGroundTruth extracts GroundTruth from project metadata JSON.
|
||||
func ParseGroundTruth(metadata json.RawMessage) (*GroundTruth, error) {
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(metadata, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, ok := m["ground_truth"]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
var gt GroundTruth
|
||||
if err := json.Unmarshal(raw, >); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return >, nil
|
||||
}
|
||||
@@ -50,6 +50,10 @@ func (e *DocumentExporter) ExportPDF(
|
||||
pdf.AddPage()
|
||||
e.pdfCoverPage(pdf, project)
|
||||
|
||||
// --- Methodology ("Erklaerteil") ---
|
||||
pdf.AddPage()
|
||||
e.pdfMethodologySection(pdf)
|
||||
|
||||
// --- Table of Contents ---
|
||||
pdf.AddPage()
|
||||
e.pdfTableOfContents(pdf, sections)
|
||||
@@ -127,6 +131,11 @@ func (e *DocumentExporter) ExportMarkdown(
|
||||
buf.WriteString(fmt.Sprintf("> %s\n\n", project.Description))
|
||||
}
|
||||
|
||||
buf.WriteString("---\n\n")
|
||||
buf.WriteString(fmt.Sprintf("## %s\n\n", RiskAssessmentMethodologySectionTitle))
|
||||
buf.WriteString(RiskAssessmentMethodologyDE)
|
||||
buf.WriteString("\n\n---\n\n")
|
||||
|
||||
for _, section := range sections {
|
||||
buf.WriteString(fmt.Sprintf("## %s\n\n", section.Title))
|
||||
buf.WriteString(fmt.Sprintf("*Typ: %s | Status: %s | Version: %d*\n\n",
|
||||
|
||||
@@ -2,6 +2,7 @@ package iace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
@@ -49,6 +50,31 @@ func (e *DocumentExporter) pdfCoverPage(pdf *gofpdf.Fpdf, project *Project) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *DocumentExporter) pdfMethodologySection(pdf *gofpdf.Fpdf) {
|
||||
paragraphs := strings.Split(RiskAssessmentMethodologyDE, "\n\n")
|
||||
for _, para := range paragraphs {
|
||||
para = strings.TrimSpace(para)
|
||||
if para == "" {
|
||||
continue
|
||||
}
|
||||
// Headings: lines that are short and don't end with punctuation
|
||||
if len(para) < 60 && !strings.HasSuffix(para, ".") && !strings.HasSuffix(para, ")") {
|
||||
pdf.Ln(4)
|
||||
pdf.SetFont("Helvetica", "B", 12)
|
||||
pdf.SetTextColor(50, 50, 50)
|
||||
pdf.CellFormat(0, 8, para, "", 1, "L", false, 0, "")
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
pdf.SetDrawColor(200, 200, 200)
|
||||
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
|
||||
pdf.Ln(3)
|
||||
continue
|
||||
}
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
pdf.MultiCell(0, 5, para, "", "L", false)
|
||||
pdf.Ln(2)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *DocumentExporter) pdfTableOfContents(pdf *gofpdf.Fpdf, sections []TechFileSection) {
|
||||
pdf.SetFont("Helvetica", "B", 16)
|
||||
pdf.SetTextColor(50, 50, 50)
|
||||
@@ -61,6 +87,7 @@ func (e *DocumentExporter) pdfTableOfContents(pdf *gofpdf.Fpdf, sections []TechF
|
||||
pdf.SetFont("Helvetica", "", 11)
|
||||
|
||||
fixedEntries := []string{
|
||||
RiskAssessmentMethodologySectionTitle,
|
||||
"Gefaehrdungsprotokoll",
|
||||
"Risikomatrix-Zusammenfassung",
|
||||
"Massnahmen-Uebersicht",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HazardBlock groups related hazards under a parent hazard.
|
||||
// The parent is the hazard with the highest inherent risk in the group.
|
||||
// Child hazards are covered by the same or similar protective measures.
|
||||
type HazardBlock struct {
|
||||
ParentHazard HazardBlockEntry `json:"parent_hazard"`
|
||||
Children []HazardBlockEntry `json:"children"`
|
||||
BlockKey string `json:"block_key"`
|
||||
SharedMeasureCount int `json:"shared_measure_count"`
|
||||
// If true, the parent's measures cover all children → children
|
||||
// don't need individual risk assessment.
|
||||
ChildrenCoveredByParent bool `json:"children_covered_by_parent"`
|
||||
}
|
||||
|
||||
// HazardBlockEntry is a hazard with its assessment and linked measures.
|
||||
type HazardBlockEntry struct {
|
||||
Hazard Hazard `json:"hazard"`
|
||||
Assessment *RiskAssessment `json:"assessment,omitempty"`
|
||||
MitigationIDs []uuid.UUID `json:"mitigation_ids"`
|
||||
}
|
||||
|
||||
// ComputeHazardBlocks groups hazards into blocks based on category + component.
|
||||
// Within each block, the hazard with the highest risk becomes the parent.
|
||||
// Children whose measures are a subset of the parent's measures are marked as covered.
|
||||
func ComputeHazardBlocks(
|
||||
hazards []Hazard,
|
||||
assessments []RiskAssessment,
|
||||
mitigations []Mitigation,
|
||||
) []HazardBlock {
|
||||
if len(hazards) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build assessment lookup: hazard_id → latest assessment
|
||||
assessMap := make(map[uuid.UUID]*RiskAssessment)
|
||||
for i := range assessments {
|
||||
a := &assessments[i]
|
||||
if existing, ok := assessMap[a.HazardID]; !ok || a.Version > existing.Version {
|
||||
assessMap[a.HazardID] = a
|
||||
}
|
||||
}
|
||||
|
||||
// Build mitigation lookup: hazard_id → []mitigation_ids
|
||||
mitsByHazard := make(map[uuid.UUID][]uuid.UUID)
|
||||
for _, m := range mitigations {
|
||||
mitsByHazard[m.HazardID] = append(mitsByHazard[m.HazardID], m.ID)
|
||||
}
|
||||
|
||||
// Group by blockKey = category + ":" + componentID
|
||||
groups := make(map[string][]HazardBlockEntry)
|
||||
for _, h := range hazards {
|
||||
key := buildBlockKey(h)
|
||||
entry := HazardBlockEntry{
|
||||
Hazard: h,
|
||||
Assessment: assessMap[h.ID],
|
||||
MitigationIDs: mitsByHazard[h.ID],
|
||||
}
|
||||
groups[key] = append(groups[key], entry)
|
||||
}
|
||||
|
||||
// Build blocks: sort each group by risk, first is parent
|
||||
var blocks []HazardBlock
|
||||
for key, entries := range groups {
|
||||
sortByRiskDesc(entries, assessMap)
|
||||
|
||||
parent := entries[0]
|
||||
children := entries[1:]
|
||||
|
||||
// Check if parent's measures cover children
|
||||
parentMitSet := toUUIDSet(parent.MitigationIDs)
|
||||
allCovered := true
|
||||
for _, child := range children {
|
||||
if !mitigationsCoveredBy(child, parent, mitigations) {
|
||||
allCovered = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
block := HazardBlock{
|
||||
ParentHazard: parent,
|
||||
Children: children,
|
||||
BlockKey: key,
|
||||
SharedMeasureCount: len(parentMitSet),
|
||||
ChildrenCoveredByParent: allCovered && len(children) > 0,
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
|
||||
// Sort blocks: largest (most children) first, then by parent risk
|
||||
sort.Slice(blocks, func(i, j int) bool {
|
||||
ri := inherentRisk(blocks[i].ParentHazard, assessMap)
|
||||
rj := inherentRisk(blocks[j].ParentHazard, assessMap)
|
||||
if len(blocks[i].Children) != len(blocks[j].Children) {
|
||||
return len(blocks[i].Children) > len(blocks[j].Children)
|
||||
}
|
||||
return ri > rj
|
||||
})
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
func buildBlockKey(h Hazard) string {
|
||||
// Group by category + component. Hazards at the same component in the
|
||||
// same category form one block — the zone is typically different but the
|
||||
// protective measures (e.g. Schutzzaun, Sicherheitszuhaltung) are shared.
|
||||
return h.Category + ":" + h.ComponentID.String()
|
||||
}
|
||||
|
||||
func sortByRiskDesc(entries []HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
ri := inherentRisk(entries[i], assessMap)
|
||||
rj := inherentRisk(entries[j], assessMap)
|
||||
return ri > rj
|
||||
})
|
||||
}
|
||||
|
||||
func inherentRisk(entry HazardBlockEntry, assessMap map[uuid.UUID]*RiskAssessment) float64 {
|
||||
if entry.Assessment != nil {
|
||||
return entry.Assessment.InherentRisk
|
||||
}
|
||||
if a, ok := assessMap[entry.Hazard.ID]; ok {
|
||||
return a.InherentRisk
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// mitigationsCoveredBy checks if child's measures are functionally covered
|
||||
// by parent's measures (same reduction type and hazard category).
|
||||
func mitigationsCoveredBy(child, parent HazardBlockEntry, allMits []Mitigation) bool {
|
||||
if len(child.MitigationIDs) == 0 {
|
||||
return true // No measures needed → covered by default
|
||||
}
|
||||
|
||||
mitMap := make(map[uuid.UUID]Mitigation)
|
||||
for _, m := range allMits {
|
||||
mitMap[m.ID] = m
|
||||
}
|
||||
|
||||
// Check: for each child mitigation type, parent has same type
|
||||
parentTypes := make(map[ReductionType]bool)
|
||||
for _, mid := range parent.MitigationIDs {
|
||||
if m, ok := mitMap[mid]; ok {
|
||||
parentTypes[m.ReductionType] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, mid := range child.MitigationIDs {
|
||||
if m, ok := mitMap[mid]; ok {
|
||||
if !parentTypes[m.ReductionType] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func toUUIDSet(ids []uuid.UUID) map[uuid.UUID]bool {
|
||||
s := make(map[uuid.UUID]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
s[id] = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -54,6 +54,10 @@ type HazardPattern struct {
|
||||
// of the listed failure modes is relevant (by ComponentType match against project components).
|
||||
// Empty/nil = fires regardless of failure modes (backwards compatible).
|
||||
RequiredFailureModes []string `json:"required_failure_modes,omitempty"`
|
||||
// ApplicableLifecycles lists the ISO 12100 lifecycle phases where this hazard
|
||||
// is relevant. Written into the Hazard's LifecyclePhase field on creation.
|
||||
// Empty = not set (pattern does not specify lifecycle applicability).
|
||||
ApplicableLifecycles []string `json:"applicable_lifecycles,omitempty"`
|
||||
}
|
||||
|
||||
// Standard human roles for machinery interaction (ISO 12100 + BetrSichV).
|
||||
|
||||
@@ -126,7 +126,7 @@ func GetCNCHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1408", NameDE: "Falscher Werkzeug-Offset nach Einrichtung", NameEN: "Wrong tool offset after setup",
|
||||
ID: "HP1408", NameDE: "Falscher Werkzeug-Offset", NameEN: "Wrong tool offset after setup",
|
||||
RequiredComponentTags: []string{"cutting_tool", "programmable"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M041", "M050"},
|
||||
@@ -149,7 +149,7 @@ func GetCNCHazardPatterns() []HazardPattern {
|
||||
Priority: 84, MachineTypes: cncTypes,
|
||||
OperationalStates: []string{"teach_mode", "manual_operation"},
|
||||
HumanRoles: []string{"programmer", "maintenance_tech"},
|
||||
ScenarioDE: "Achsen verfahren im Einrichtbetrieb mit voller Produktionsgeschwindigkeit",
|
||||
ScenarioDE: "Achsen verfahren mit voller Produktionsgeschwindigkeit",
|
||||
TriggerDE: "Fehlende Geschwindigkeitsbegrenzung im Einrichtmodus oder Umgehung",
|
||||
HarmDE: "Quetschung oder Schlagverletzung durch schnell verfahrende Maschinenteile",
|
||||
AffectedDE: "Einrichter, Programmierer", ZoneDE: "Verfahrbereich der Achsen",
|
||||
|
||||
@@ -49,7 +49,7 @@ func GetCNCHazardPatternsExt() []HazardPattern {
|
||||
DefaultSeverity: 4, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1423", NameDE: "Absturz schwerer Maschinenteile bei Wartung", NameEN: "Heavy machine part falling during maintenance",
|
||||
ID: "HP1423", NameDE: "Absturz schwerer Maschinenteile", NameEN: "Heavy machine part falling during maintenance",
|
||||
RequiredComponentTags: []string{"moving_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M245", "M210"},
|
||||
@@ -57,7 +57,7 @@ func GetCNCHazardPatternsExt() []HazardPattern {
|
||||
Priority: 80, MachineTypes: cncTypes,
|
||||
OperationalStates: []string{"maintenance"},
|
||||
HumanRoles: []string{"maintenance_tech"},
|
||||
ScenarioDE: "Schwere Maschinenteile (Spindelstock, Revolverkopf) fallen bei Demontage unkontrolliert herab",
|
||||
ScenarioDE: "Schwere Maschinenteile (Spindelstock, Revolverkopf) fallen unkontrolliert herab",
|
||||
TriggerDE: "Fehlende Abstuetzmittel oder Hebezeuge bei Wartung schwerer Baugruppen",
|
||||
HarmDE: "Quetschung von Hand oder Fuss, Knochenbrueche",
|
||||
AffectedDE: "Wartungspersonal", ZoneDE: "Maschineninneres, Wartungszugang",
|
||||
@@ -193,7 +193,7 @@ func GetCNCHazardPatternsExt() []HazardPattern {
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1433", NameDE: "Unkontrollierte Achsbewegung bei Probelauf nach Wartung", NameEN: "Uncontrolled axis movement during test run after maintenance",
|
||||
ID: "HP1433", NameDE: "Unkontrollierte Achsbewegung nach Probelauf", NameEN: "Uncontrolled axis movement during test run after maintenance",
|
||||
RequiredComponentTags: []string{"moving_part", "programmable"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M212", "M050", "M042"},
|
||||
@@ -202,7 +202,7 @@ func GetCNCHazardPatternsExt() []HazardPattern {
|
||||
OperationalStates: []string{"manual_operation", "teach_mode"},
|
||||
HumanRoles: []string{"maintenance_tech", "programmer"},
|
||||
StateTransitions: []string{"maintenance→manual_operation"},
|
||||
ScenarioDE: "Nach Wartung oder Reparatur verfahren Achsen unkontrolliert beim ersten Testlauf",
|
||||
ScenarioDE: "oder Reparatur verfahren Achsen unkontrolliert beim ersten Testlauf",
|
||||
TriggerDE: "Falsche Parameter nach Wartung, fehlende Referenzfahrt, Endschalter nicht justiert",
|
||||
HarmDE: "Quetschung, Kollision Werkzeug/Werkstueck",
|
||||
AffectedDE: "Wartungspersonal, Einrichter", ZoneDE: "Verfahrbereich, Bearbeitungsraum",
|
||||
@@ -218,7 +218,7 @@ func GetCNCHazardPatternsExt() []HazardPattern {
|
||||
Priority: 70, MachineTypes: cncTypes,
|
||||
OperationalStates: []string{"maintenance"},
|
||||
HumanRoles: []string{"maintenance_tech"},
|
||||
ScenarioDE: "Restkuehlmittel tropft bei Wartung auf Schaltschrank oder Steuerungskomponenten",
|
||||
ScenarioDE: "Restkuehlmittel tropft auf Schaltschrank oder Steuerungskomponenten",
|
||||
TriggerDE: "Fehlende Auffangwanne oder Abdeckung bei Wartung an KSS-fuehrenden Bauteilen",
|
||||
HarmDE: "Kurzschluss, Stromschlag bei Beruehrung nasser Teile",
|
||||
AffectedDE: "Wartungspersonal", ZoneDE: "Schaltschrank, Steuerungsbereich",
|
||||
|
||||
@@ -11,7 +11,7 @@ func builtinElectricalPatterns() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M061", "M062", "M063", "M121"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E04", "E10"},
|
||||
Priority: 95,
|
||||
ScenarioDE: "Person beruehrt spannungsfuehrende Teile bei Wartung, Stoerungsbeseitigung oder durch defekte Isolation.",
|
||||
ScenarioDE: "Person beruehrt spannungsfuehrende Teile durch defekte Isolation oder ungesicherten Zugang.",
|
||||
TriggerDE: "Direktes oder indirektes Beruehren spannungsfuehrender Leiter ueber 50 V AC / 120 V DC.",
|
||||
HarmDE: "Stromschlag, Herzkammerflimmern, Verbrennungen, Todesfolge bei Hochspannung.",
|
||||
AffectedDE: "Wartungspersonal, Elektrofachkraefte, Bedienpersonal",
|
||||
|
||||
@@ -66,7 +66,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
|
||||
DefaultSeverity: 2, DefaultExposure: 5,
|
||||
},
|
||||
{
|
||||
ID: "HP027", NameDE: "Ergonomische Belastung bei Wartung in der Hoehe", NameEN: "Ergonomic risk for work at height",
|
||||
ID: "HP027", NameDE: "Ergonomische Belastung in der Hoehe", NameEN: "Ergonomic risk for work at height",
|
||||
RequiredComponentTags: []string{"structural_part", "gravity_risk"},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"ergonomic", "mechanical_hazard"},
|
||||
|
||||
@@ -130,7 +130,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M121", "M131"},
|
||||
SuggestedEvidenceIDs: []string{"E14"},
|
||||
Priority: 90,
|
||||
ScenarioDE: "Nach Wartung vergessenes Werkzeug wird beim Anlauf der Maschine zum Geschoss.",
|
||||
ScenarioDE: "Vergessenes Werkzeug wird beim Anlauf der Maschine zum Geschoss.",
|
||||
TriggerDE: "Werkzeug liegt im Arbeitsraum, Maschine wird ohne Kontrolle gestartet",
|
||||
HarmDE: "Wegschleudern des Werkzeugs, schwere Verletzungen durch Projektil",
|
||||
AffectedDE: "Bedienpersonal, Personen im Umfeld",
|
||||
@@ -262,7 +262,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M005"},
|
||||
SuggestedEvidenceIDs: []string{"E08"},
|
||||
Priority: 80,
|
||||
Priority: 80, MachineTypes: []string{"press"},
|
||||
ScenarioDE: "Exzentrische Belastung des Stoessels fuehrt zu seitlichem Ausbrechen des Werkstuecks.",
|
||||
TriggerDE: "Werkstueck nicht korrekt positioniert, seitliche Kraftkomponente entsteht",
|
||||
HarmDE: "Aufprallverletzung durch geschleudertes Werkstueck, Quetschung",
|
||||
@@ -290,7 +290,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
// Roboter/Cobot erweitert (HP151-HP154)
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP151", NameDE: "Kollision bei Teach-In-Betrieb", NameEN: "Collision during teach-in operation",
|
||||
ID: "HP151", NameDE: "Kollision im manuellen Verfahrbetrieb", NameEN: "Collision during teach-in operation",
|
||||
RequiredComponentTags: []string{"programmable", "moving_part"},
|
||||
RequiredEnergyTags: []string{},
|
||||
RequiredLifecycles: []string{"setup"},
|
||||
@@ -336,7 +336,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP154", NameDE: "Kollision zweier Roboter", NameEN: "Collision of two robots",
|
||||
ID: "HP154", MachineTypes: []string{"robotics_cobot"}, NameDE: "Kollision zweier Roboter", NameEN: "Collision of two robots",
|
||||
RequiredComponentTags: []string{"programmable", "moving_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -361,7 +361,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001", "M051"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E20"},
|
||||
Priority: 80,
|
||||
Priority: 80, MachineTypes: []string{"conveyor", "packaging"},
|
||||
ScenarioDE: "Finger oder Kleidung werden an der Bandumlenkstelle eingezogen.",
|
||||
TriggerDE: "Eingriff am laufenden Band, lose Kleidung geraet in Umlenkrolle",
|
||||
HarmDE: "Fingeramputation, Armverletzung, Strangulation durch eingezogene Kleidung",
|
||||
@@ -595,7 +595,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M051"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E20"},
|
||||
Priority: 80,
|
||||
Priority: 80, MachineTypes: []string{"rotary_transfer"},
|
||||
ScenarioDE: "Hand wird zwischen Drehteller und festem Anschlag eingeklemmt bei Taktbewegung.",
|
||||
TriggerDE: "Eingriff waehrend der Taktbewegung, fehlende Schutzabdeckung am Drehteller",
|
||||
HarmDE: "Quetschung, Fingerfraktur, Amputation von Fingern",
|
||||
|
||||
@@ -42,7 +42,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001"},
|
||||
Priority: 60,
|
||||
ScenarioDE: "Reibung an rotierender Welle oder Walze bei Wartung", HarmDE: "Hautabschuerfungen, Verbrennungen durch Reibungswaerme",
|
||||
ScenarioDE: "Reibung an rotierender Welle oder Walze", HarmDE: "Hautabschuerfungen, Verbrennungen durch Reibungswaerme",
|
||||
TriggerDE: "Beruehrung laufender Teile", AffectedDE: "Wartungspersonal", ZoneDE: "Walzen-/Wellenbereich", DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
@@ -102,7 +102,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M051"},
|
||||
Priority: 80,
|
||||
Priority: 80, MachineTypes: []string{"crane", "construction"},
|
||||
ScenarioDE: "Unkontrolliertes Schwingen einer angehobenen Last", HarmDE: "Quetschung, Erschlagen durch pendelnde Last",
|
||||
TriggerDE: "Schraeger Zug oder ploetzliches Abstoppen", AffectedDE: "Kranfuehrer, Anschlaeger", ZoneDE: "Schwenkbereich des Krans", DefaultSeverity: 4, DefaultExposure: 3,
|
||||
},
|
||||
@@ -261,13 +261,13 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
||||
TriggerDE: "Hautkontakt mit kontaminiertem Fluid", AffectedDE: "Maschinenbediener, Wartungspersonal", ZoneDE: "Fluidsystem, Tank", DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP117", NameDE: "Asbest-/Mineralfaserfreisetzung bei Demontage", NameEN: "Asbestos/mineral fiber release during dismantling",
|
||||
ID: "HP117", NameDE: "Asbest-/Mineralfaserfreisetzung", NameEN: "Asbestos/mineral fiber release during dismantling",
|
||||
RequiredComponentTags: []string{"chemical_risk"},
|
||||
RequiredLifecycles: []string{"decommissioning", "disposal"},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
Priority: 90,
|
||||
ScenarioDE: "Freisetzung von Asbestfasern bei Demontage alter Anlagen", HarmDE: "Asbestose, Mesotheliom (Langzeitfolge)",
|
||||
ScenarioDE: "Freisetzung von Asbestfasern alter Anlagen", HarmDE: "Asbestose, Mesotheliom (Langzeitfolge)",
|
||||
TriggerDE: "Mechanische Bearbeitung asbesthaltiger Bauteile", AffectedDE: "Demontagepersonal", ZoneDE: "Altanlagen, Isolierung", DefaultSeverity: 5, DefaultExposure: 1,
|
||||
},
|
||||
|
||||
|
||||
@@ -428,7 +428,7 @@ func GetFinalPatternsA() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001", "M005"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E08"},
|
||||
Priority: 78, ScenarioDE: "Finger wird zwischen Kette und Kettenrad eingezogen",
|
||||
Priority: 78, MachineTypes: []string{"conveyor", "forestry"}, ScenarioDE: "Finger wird zwischen Kette und Kettenrad eingezogen",
|
||||
TriggerDE: "Eingriff in ungeschuetzten Kettenantrieb", HarmDE: "Fingerquetschung, Abriss",
|
||||
AffectedDE: "Wartungspersonal", ZoneDE: "Kettenrad, Kettenstrang",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
@@ -667,13 +667,13 @@ func GetFinalPatternsA() []HazardPattern {
|
||||
DefaultSeverity: 5, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1054", NameDE: "Herabfallendes Bauteil bei Montage", NameEN: "Falling component during assembly",
|
||||
ID: "HP1054", NameDE: "Herabfallendes Bauteil", NameEN: "Falling component during assembly",
|
||||
RequiredComponentTags: []string{"gravity_risk", "structural_part"},
|
||||
RequiredEnergyTags: []string{"gravitational"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 68, ScenarioDE: "Bauteil loest sich bei Montage und faellt",
|
||||
Priority: 68, ScenarioDE: "Bauteil loest sich und faellt",
|
||||
TriggerDE: "Unzureichende Sicherung waehrend Zusammenbau", HarmDE: "Prellung, Fraktur",
|
||||
AffectedDE: "Montagepersonal", ZoneDE: "Montageplatz, Regalbereich",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
@@ -814,7 +814,7 @@ func GetFinalPatternsA() []HazardPattern {
|
||||
},
|
||||
// === Einklemmen Haare/Kleidung (3) ===
|
||||
{
|
||||
ID: "HP1066", NameDE: "Haareinzug Drehmaschine", NameEN: "Hair entanglement lathe",
|
||||
ID: "HP1066", MachineTypes: []string{"lathe", "cnc", "metalworking"}, NameDE: "Haareinzug Drehmaschine", NameEN: "Hair entanglement lathe",
|
||||
RequiredComponentTags: []string{"rotating_part", "entanglement_risk"},
|
||||
RequiredEnergyTags: []string{"rotational"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -1027,7 +1027,7 @@ func GetFinalPatternsA() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001", "M005"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E08"},
|
||||
Priority: 78, ScenarioDE: "Schwere Maschine kippt bei Transport oder Betrieb",
|
||||
Priority: 78, ScenarioDE: "Schwere Maschine kippt oder Betrieb",
|
||||
TriggerDE: "Unebener Boden, Schwerpunktverlagerung", HarmDE: "Toedliche Quetschung",
|
||||
AffectedDE: "Transportpersonal", ZoneDE: "Kippbereich, Aufstellflaeche",
|
||||
DefaultSeverity: 5, DefaultExposure: 1,
|
||||
|
||||
@@ -624,7 +624,7 @@ func GetFinalPatternsB() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M124", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 82, ScenarioDE: "Asbestfasern werden bei Demontage/Wartung freigesetzt",
|
||||
Priority: 82, ScenarioDE: "Asbestfasern werden /Wartung freigesetzt",
|
||||
TriggerDE: "Bohren/Saegen in Asbestmaterial, Abrissarbeiten", HarmDE: "Asbestose, Mesotheliom",
|
||||
AffectedDE: "Wartungspersonal, Abbrucharbeiter", ZoneDE: "Altanlage, Dichtungen, Isolierungen",
|
||||
DefaultSeverity: 5, DefaultExposure: 1,
|
||||
|
||||
@@ -860,7 +860,7 @@ func GetFinalPatternsC() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"ergonomic_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 52, ScenarioDE: "Haeufiges Knien bei Montage/Wartungsarbeiten",
|
||||
Priority: 52, ScenarioDE: "Haeufiges Knien /Wartungsarbeiten",
|
||||
TriggerDE: "Bodennahe Arbeiten, fehlende Knieschoner", HarmDE: "Meniskusschaden (BK 2112)",
|
||||
AffectedDE: "Wartungspersonal", ZoneDE: "Bodenbereich",
|
||||
DefaultSeverity: 2, DefaultExposure: 4,
|
||||
|
||||
@@ -158,7 +158,7 @@ func GetFinalPatternsD() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard", "maintenance_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 72, ScenarioDE: "Verschlissenes Teil versagt im Betrieb",
|
||||
Priority: 72, ScenarioDE: "Verschlissenes Teil versagt",
|
||||
TriggerDE: "Fehlende Inspektion, ueberschrittene Standzeit", HarmDE: "Funktionsverlust, Bruch",
|
||||
AffectedDE: "Bedienpersonal", ZoneDE: "Verschleissteil, Fuehrung",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
@@ -573,7 +573,7 @@ func GetFinalPatternsD() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001", "M005"},
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 72, ScenarioDE: "Schutzeinrichtung nach Einrichten nicht reaktiviert",
|
||||
Priority: 72, ScenarioDE: "Schutzeinrichtung nicht reaktiviert",
|
||||
TriggerDE: "Vergessen, Bypass noch aktiv", HarmDE: "Produktion ohne Schutz",
|
||||
AffectedDE: "Bedienpersonal", ZoneDE: "Gesamte Maschine",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
@@ -817,7 +817,7 @@ func GetFinalPatternsD() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M001", "M005"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E08"},
|
||||
Priority: 78, ScenarioDE: "Kran schwenkt Last ueber besetzten Arbeitsplatz",
|
||||
Priority: 78, MachineTypes: []string{"crane", "construction"}, ScenarioDE: "Kran schwenkt Last ueber besetzten Arbeitsplatz",
|
||||
TriggerDE: "Fehlende Endschalter, Unachtsamkeit", HarmDE: "Herabfallende Last",
|
||||
AffectedDE: "Personen darunter", ZoneDE: "Unter Kranschwenkbereich",
|
||||
DefaultSeverity: 5, DefaultExposure: 2,
|
||||
|
||||
@@ -131,7 +131,7 @@ func GetFoodProcessingPatterns() []HazardPattern {
|
||||
RequiresExpertCalculation: true,
|
||||
ExpertHintDE: "IP-Schutzklasse muss fuer Nassreinigung (mindestens IPX5) nachgewiesen werden.",
|
||||
ExpertHintEN: "IP rating must be verified for wet cleaning conditions (minimum IPX5).",
|
||||
ScenarioDE: "Wasser dringt beim Reinigen in elektrische Komponenten ein und erzeugt einen Fehlerstrom.",
|
||||
ScenarioDE: "Wasser dringt in elektrische Komponenten ein und erzeugt einen Fehlerstrom.",
|
||||
TriggerDE: "Unzureichende IP-Schutzklasse, defekte Kabeldurchfuehrungen, beschaedigtes Gehaeuse.",
|
||||
HarmDE: "Elektrischer Schlag, Herzkammerflimmern, Tod durch Stromschlag.",
|
||||
AffectedDE: "Reinigungspersonal, Bedienpersonal bei Nassreinigung.",
|
||||
|
||||
@@ -65,7 +65,7 @@ func GetForestryConveyorPatterns() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M001", "M005"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E20"},
|
||||
Priority: 85,
|
||||
ScenarioDE: "Kontakt mit rotierendem Maehwerk bei Wartung oder durch Wegschleudern von Fremdkoerpern.",
|
||||
ScenarioDE: "Kontakt mit rotierendem Maehwerk oder durch Wegschleudern von Fremdkoerpern.",
|
||||
TriggerDE: "Wartung bei laufendem Maehwerk, fehlende Schutzabdeckung, Steinschleuder",
|
||||
HarmDE: "Amputationsverletzung an Fuessen/Haenden, tiefe Schnittwunden, Augenverletzung durch Steinschlag",
|
||||
AffectedDE: "Maehwerksfahrer, Gartenarbeiter, Umstehende",
|
||||
@@ -311,7 +311,7 @@ func GetForestryConveyorPatterns() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M052", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Person stuerzt von erhoehtem Rollenfoerderer bei Wartung oder Stoerungsbeseitigung.",
|
||||
ScenarioDE: "Person stuerzt von erhoehtem Rollenfoerderer .",
|
||||
TriggerDE: "Fehlende Absturzsicherung, kein Zugangsweg, improvisiertes Besteigen",
|
||||
HarmDE: "Knochenbrueche, Wirbelsaeulenverletzung, toedlicher Sturz ab 2 m Hoehe",
|
||||
AffectedDE: "Wartungspersonal, Bediener bei Stoerung",
|
||||
|
||||
@@ -16,10 +16,10 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"moving_part"}, RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard", "pneumatic_hydraulic"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M082"}, SuggestedEvidenceIDs: []string{"E08"}, Priority: 90,
|
||||
ScenarioDE: "Gespeicherte Energie entlaedt sich bei Wartung", TriggerDE: "Nicht abgelassener Druckspeicher",
|
||||
ScenarioDE: "Gespeicherte Energie entlaedt sich", TriggerDE: "Nicht abgelassener Druckspeicher",
|
||||
HarmDE: "Unkontrollierte Bewegung, Quetschung", AffectedDE: "Instandhalter", ZoneDE: "Antriebe, Speicher",
|
||||
DefaultSeverity: 5, DefaultExposure: 3},
|
||||
{ID: "HP702", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Falsches Werkzeug bei Wartung", NameEN: "Wrong tool during maintenance",
|
||||
{ID: "HP702", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Falsches Werkzeug fuer Arbeiten an der Maschine", NameEN: "Wrong tool during maintenance",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 50,
|
||||
@@ -33,11 +33,11 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
ScenarioDE: "Unqualifiziertes Personal an Elektrik", TriggerDE: "Keine Elektrofachkraft",
|
||||
HarmDE: "Stromschlag, Fehlverdrahtung", AffectedDE: "Instandhalter", ZoneDE: "Schaltschrank",
|
||||
DefaultSeverity: 4, DefaultExposure: 3},
|
||||
{ID: "HP704", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Herabfallen schwerer Teile bei Demontage", NameEN: "Heavy parts falling during disassembly",
|
||||
{ID: "HP704", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Herabfallen schwerer Teile", NameEN: "Heavy parts falling during disassembly",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 75,
|
||||
ScenarioDE: "Schwere Teile fallen bei Demontage herab", TriggerDE: "Fehlende Abstuetzung",
|
||||
ScenarioDE: "Schwere Teile fallen herab", TriggerDE: "Fehlende Abstuetzung",
|
||||
HarmDE: "Quetschung, Frakturen, Tod", AffectedDE: "Instandhalter", ZoneDE: "Wartungsbereich",
|
||||
DefaultSeverity: 5, DefaultExposure: 3},
|
||||
{ID: "HP705", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Vergessenes Werkzeug in Maschine", NameEN: "Forgotten tool in machine",
|
||||
@@ -54,7 +54,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
ScenarioDE: "Scharfe Kanten und Grate verletzen", TriggerDE: "Fehlende Schutzhandschuhe",
|
||||
HarmDE: "Schnittwunden, Abschuerfungen", AffectedDE: "Instandhalter", ZoneDE: "Blechverkleidungen",
|
||||
DefaultSeverity: 2, DefaultExposure: 4},
|
||||
{ID: "HP707", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Verbrennung an heissen Teilen bei Wartung", NameEN: "Burn on hot parts during maintenance",
|
||||
{ID: "HP707", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Verbrennung an heissen Teilen", NameEN: "Burn on hot parts during maintenance",
|
||||
RequiredComponentTags: []string{"high_temperature"}, RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M005", "M082"}, SuggestedEvidenceIDs: []string{"E10"}, Priority: 60,
|
||||
@@ -72,11 +72,11 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"chemical_risk"}, RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M005", "M082"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 50,
|
||||
ScenarioDE: "Verkeimter Kuehlschmierstoff bei Wartung", TriggerDE: "Altes KSS, Biofilme",
|
||||
ScenarioDE: "Verkeimter Kuehlschmierstoff", TriggerDE: "Altes KSS, Biofilme",
|
||||
HarmDE: "Hautinfektionen, Atemwegsbeschwerden", AffectedDE: "Instandhalter", ZoneDE: "KSS-System",
|
||||
DefaultSeverity: 2, DefaultExposure: 3},
|
||||
// — Einrichten / Umruesten (HP710-HP719) —
|
||||
{ID: "HP710", OperationalStates: []string{"teach_mode"}, HumanRoles: []string{"programmer"}, NameDE: "Falsche Parameter nach Umruestung", NameEN: "Wrong parameters after changeover",
|
||||
{ID: "HP710", OperationalStates: []string{"teach_mode"}, HumanRoles: []string{"programmer"}, NameDE: "Falsche Parameter nach Produktwechsel", NameEN: "Wrong parameters after changeover",
|
||||
RequiredComponentTags: []string{"programmable"}, RequiredLifecycles: []string{"setup"},
|
||||
GeneratedHazardCats: []string{"safety_function_failure"},
|
||||
SuggestedMeasureIDs: []string{"M106", "M082"}, SuggestedEvidenceIDs: []string{"E08"}, Priority: 75,
|
||||
@@ -90,7 +90,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
ScenarioDE: "Schwere Werkzeuge manuell gewechselt", TriggerDE: "Kein Hebezeug, Finger eingeklemmt",
|
||||
HarmDE: "Quetschung, Amputation", AffectedDE: "Einrichter", ZoneDE: "Werkzeugaufnahme",
|
||||
DefaultSeverity: 4, DefaultExposure: 4},
|
||||
{ID: "HP712", OperationalStates: []string{"teach_mode", "manual_operation"}, HumanRoles: []string{"programmer", "maintenance_tech"}, NameDE: "Unkontrollierte Bewegung bei Testlauf", NameEN: "Uncontrolled movement test run",
|
||||
{ID: "HP712", OperationalStates: []string{"teach_mode", "manual_operation"}, HumanRoles: []string{"programmer", "maintenance_tech"}, NameDE: "Unkontrollierte Bewegung nach Probelauf", NameEN: "Uncontrolled movement test run",
|
||||
RequiredComponentTags: []string{"moving_part", "programmable"}, RequiredLifecycles: []string{"setup"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M106", "M054"}, SuggestedEvidenceIDs: []string{"E08"}, Priority: 80,
|
||||
@@ -129,17 +129,17 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"setup"},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M082"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 50,
|
||||
ScenarioDE: "Falsches Material nach Umruestung", TriggerDE: "Verwechslung, fehlende Kennzeichnung",
|
||||
ScenarioDE: "Falsches Material", TriggerDE: "Verwechslung, fehlende Kennzeichnung",
|
||||
HarmDE: "Werkzeugbruch, Splitterflug", AffectedDE: "Bedienpersonal", ZoneDE: "Materialzufuhr",
|
||||
DefaultSeverity: 3, DefaultExposure: 3},
|
||||
{ID: "HP718", NameDE: "Absturz bei Einrichtung hoher Maschine", NameEN: "Fall during tall machine setup",
|
||||
{ID: "HP718", NameDE: "Absturz hoher Maschine", NameEN: "Fall during tall machine setup",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"setup"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 65,
|
||||
ScenarioDE: "Einrichtarbeiten in Hoehe ohne sicheren Zugang", TriggerDE: "Improvisierte Aufstiegshilfe",
|
||||
HarmDE: "Absturz, Frakturen", AffectedDE: "Einrichter", ZoneDE: "Maschinenoberteil",
|
||||
DefaultSeverity: 4, DefaultExposure: 3},
|
||||
{ID: "HP719", NameDE: "Schutzeinrichtung nach Umruestung defekt", NameEN: "Faulty guard after changeover",
|
||||
{ID: "HP719", NameDE: "Schutzeinrichtung nach Produktwechsel defekt", NameEN: "Faulty guard after changeover",
|
||||
RequiredComponentTags: []string{"moving_part"}, RequiredLifecycles: []string{"setup"},
|
||||
GeneratedHazardCats: []string{"safety_function_failure", "mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E08"}, Priority: 80,
|
||||
@@ -218,7 +218,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
HarmDE: "Folgestoerung mit groesserem Schaden", AffectedDE: "Bedienpersonal", ZoneDE: "Steuerung",
|
||||
DefaultSeverity: 4, DefaultExposure: 2},
|
||||
// — Transport / Montage (HP900-HP907) —
|
||||
{ID: "HP900", NameDE: "Kippen der Maschine beim Transport", NameEN: "Machine tipping during transport",
|
||||
{ID: "HP900", NameDE: "Kippen der Maschine", NameEN: "Machine tipping during transport",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"transport"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 80,
|
||||
@@ -267,7 +267,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
ScenarioDE: "Stapler kollidiert mit Personen", TriggerDE: "Eingeschraenkte Sicht, zu schnell",
|
||||
HarmDE: "Anfahrunfall, Quetschung", AffectedDE: "Fussgaenger", ZoneDE: "Transportwege",
|
||||
DefaultSeverity: 4, DefaultExposure: 3},
|
||||
{ID: "HP907", NameDE: "Verankerungsfehler bei Montage", NameEN: "Anchoring error installation",
|
||||
{ID: "HP907", NameDE: "Verankerungsfehler am Aufstellort", NameEN: "Anchoring error installation",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"transport"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 65,
|
||||
@@ -339,7 +339,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
ScenarioDE: "Reinigung ohne Abschaltung der Maschine", TriggerDE: "Zeitdruck",
|
||||
HarmDE: "Einzug, Quetschung, Aufwickeln", AffectedDE: "Reinigungspersonal", ZoneDE: "Rotierende Teile",
|
||||
DefaultSeverity: 5, DefaultExposure: 3},
|
||||
{ID: "HP917", NameDE: "Nassrutschiger Boden nach Reinigung", NameEN: "Wet slippery floor after cleaning",
|
||||
{ID: "HP917", NameDE: "Nassrutschiger Boden durch Fluessigkeiten", NameEN: "Wet slippery floor after cleaning",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"cleaning"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 45,
|
||||
@@ -410,7 +410,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
RequiredComponentTags: []string{"electrical_part"}, RequiredEnergyTags: []string{"electrical"},
|
||||
RequiredLifecycles: []string{"maintenance"}, GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E09"}, Priority: 75,
|
||||
ScenarioDE: "Messung unter Spannung bei Fehlersuche", TriggerDE: "Messgeraet rutscht ab",
|
||||
ScenarioDE: "Messung unter Spannung", TriggerDE: "Messgeraet rutscht ab",
|
||||
HarmDE: "Stromschlag, Lichtbogen", AffectedDE: "Elektrofachkraft", ZoneDE: "Schaltschrank",
|
||||
DefaultSeverity: 4, DefaultExposure: 3},
|
||||
{ID: "HP927", NameDE: "ZfP mit Strahlenquelle", NameEN: "NDT with radiation source",
|
||||
@@ -451,7 +451,7 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
HarmDE: "Vernachlaessigte Sicherheit", AffectedDE: "Alle Gewerke", ZoneDE: "Schnittstellen",
|
||||
DefaultSeverity: 3, DefaultExposure: 3},
|
||||
// — Notfall (HP932-HP934) —
|
||||
{ID: "HP932", NameDE: "Versperrte Fluchtwege bei Wartung", NameEN: "Blocked escape routes maintenance",
|
||||
{ID: "HP932", NameDE: "Versperrte Fluchtwege durch abgestelltes Material", NameEN: "Blocked escape routes maintenance",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082"}, SuggestedEvidenceIDs: []string{"E20"}, Priority: 70,
|
||||
@@ -465,11 +465,11 @@ func GetMaintenanceExtPatterns() []HazardPattern {
|
||||
ScenarioDE: "Kein Erste-Hilfe-Material am abgelegenen Ort", TriggerDE: "Entfernter Standort",
|
||||
HarmDE: "Verzoegerte Erstversorgung", AffectedDE: "Instandhalter", ZoneDE: "Abgelegene Wartungsorte",
|
||||
DefaultSeverity: 3, DefaultExposure: 3},
|
||||
{ID: "HP934", NameDE: "Brandbekaempfung bei Wartung", NameEN: "Firefighting during maintenance",
|
||||
{ID: "HP934", NameDE: "Erschwerter Zugang zu Loescheinrichtungen", NameEN: "Firefighting during maintenance",
|
||||
RequiredComponentTags: []string{"structural_part"}, RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M082", "M141"}, SuggestedEvidenceIDs: []string{"E10", "E20"}, Priority: 65,
|
||||
ScenarioDE: "Feuerloescher nicht erreichbar bei Wartung", TriggerDE: "Verstellter Loescher",
|
||||
ScenarioDE: "Feuerloescher nicht erreichbar", TriggerDE: "Verstellter Loescher",
|
||||
HarmDE: "Brandausbreitung, Verbrennungen", AffectedDE: "Instandhalter", ZoneDE: "Wartungsbereich",
|
||||
DefaultSeverity: 4, DefaultExposure: 2},
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func builtinMechanicalPatterns() []HazardPattern {
|
||||
TriggerDE: "Bediener befindet sich im Kraftwirkbereich waehrend des Arbeitshubes oder bei Stoerungsbeseitigung.",
|
||||
HarmDE: "Schwere Quetschung, Fraktur, innere Verletzungen, Todesfolge bei Ganzkompression.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter, Wartungspersonal",
|
||||
ZoneDE: "Kraftwirkbereich (Pressenraum, Vorschubachse), Einlegestelle",
|
||||
ZoneDE: "Kraftwirkbereich, Einlegestelle, Vorschubachse",
|
||||
DefaultSeverity: 5, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
@@ -120,7 +120,7 @@ func builtinMechanicalPatterns() []HazardPattern {
|
||||
TriggerDE: "Versagen einer Halterung, Bruch eines Lastaufnahmemittels oder Abrutschen bei Wartungsarbeiten in der Hoehe.",
|
||||
HarmDE: "Kopfverletzung, Fraktur, Quetschung durch herabfallende Last; Sturzverletung.",
|
||||
AffectedDE: "Wartungspersonal, Bedienpersonal, Personen im Gefahrenbereich",
|
||||
ZoneDE: "Bereich unterhalb angehobener Lasten, Wartungsplattformen, Kran-/Hebezeugbereich",
|
||||
ZoneDE: "Bereich unterhalb angehobener Lasten, Wartungsplattformen",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -150,7 +150,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP075", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Kontakt mit heissen Teilen bei Wartung", NameEN: "Contact with hot parts during maintenance",
|
||||
ID: "HP075", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Kontakt mit heissen Oberflaechen", NameEN: "Contact with hot parts during maintenance",
|
||||
RequiredComponentTags: []string{"high_temperature"},
|
||||
RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
@@ -165,7 +165,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP076", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Kontakt mit Gefahrstoffen bei Wartung", NameEN: "Contact with hazardous substances during maintenance",
|
||||
ID: "HP076", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Kontakt mit Gefahrstoffen", NameEN: "Contact with hazardous substances during maintenance",
|
||||
RequiredComponentTags: []string{"chemical_risk"},
|
||||
RequiredLifecycles: []string{"maintenance", "cleaning"},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
@@ -179,7 +179,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP077", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Elektrischer Schlag bei Wartungsarbeiten", NameEN: "Electric shock during maintenance",
|
||||
ID: "HP077", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Elektrischer Schlag an offenen Baugruppen", NameEN: "Electric shock during maintenance",
|
||||
RequiredComponentTags: []string{"high_voltage"},
|
||||
RequiredLifecycles: []string{"maintenance", "fault_clearing"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
@@ -195,7 +195,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 5, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP078", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Ergonomische Belastung bei Wartungszugang", NameEN: "Ergonomic strain at maintenance access",
|
||||
ID: "HP078", OperationalStates: []string{"maintenance"}, HumanRoles: []string{"maintenance_tech"}, NameDE: "Ergonomische Belastung durch schwierigen Zugang", NameEN: "Ergonomic strain at maintenance access",
|
||||
RequiredComponentTags: []string{"structural_part"},
|
||||
RequiredLifecycles: []string{"maintenance"},
|
||||
GeneratedHazardCats: []string{"ergonomic"},
|
||||
@@ -273,7 +273,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 4, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP083", NameDE: "Unbeabsichtigter Hub bei Einrichtbetrieb", NameEN: "Unintended stroke in setup mode",
|
||||
ID: "HP083", NameDE: "Unbeabsichtigter Hub im manuellen Betrieb", NameEN: "Unintended stroke in setup mode",
|
||||
RequiredComponentTags: []string{"moving_part", "crush_point"},
|
||||
RequiredLifecycles: []string{"setup"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard", "safety_function_failure"},
|
||||
@@ -281,7 +281,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
Priority: 94,
|
||||
RequiresExpertCalculation: true,
|
||||
ExpertHintDE: "Einrichtbetrieb nur mit reduzierter Geschwindigkeit und Zweihandschaltung.",
|
||||
ScenarioDE: "Einrichter befindet sich im Werkzeugraum waehrend Testlauf im Einrichtbetrieb",
|
||||
ScenarioDE: "Person befindet sich im Werkzeugraum waehrend Testlauf",
|
||||
TriggerDE: "Stossel fuehrt vollen Hub statt Tipphub aus wegen Softwarefehler oder Fehlbedienung",
|
||||
HarmDE: "Toedliches Quetschen oder Amputation durch vollen Pressenhub bei Anwesenheit",
|
||||
AffectedDE: "Einrichter",
|
||||
@@ -289,7 +289,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 5, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP084", NameDE: "Falsche Parametereinstellung nach Umruestung", NameEN: "Wrong parameters after changeover",
|
||||
ID: "HP084", NameDE: "Falsche Parametereinstellung nach Produktwechsel", NameEN: "Wrong parameters after changeover",
|
||||
RequiredComponentTags: []string{"programmable"},
|
||||
RequiredLifecycles: []string{"changeover", "setup"},
|
||||
GeneratedHazardCats: []string{"safety_function_failure"},
|
||||
@@ -323,7 +323,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
// Transport / Montage / Demontage (HP086-HP090)
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP086", NameDE: "Kippen der Maschine beim Transport", NameEN: "Machine tipping during transport",
|
||||
ID: "HP086", NameDE: "Kippen der Maschine", NameEN: "Machine tipping during transport",
|
||||
RequiredComponentTags: []string{"structural_part"},
|
||||
RequiredLifecycles: []string{"transport"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -337,7 +337,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 5, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP087", NameDE: "Quetschen bei Montage/Aufstellung", NameEN: "Crushing during installation",
|
||||
ID: "HP087", NameDE: "Quetschen/Aufstellung", NameEN: "Crushing during installation",
|
||||
RequiredComponentTags: []string{"high_force", "gravity_risk"},
|
||||
RequiredLifecycles: []string{"assembly"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -351,7 +351,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP088", NameDE: "Unkontrollierte Bewegung bei Inbetriebnahme", NameEN: "Uncontrolled movement during commissioning",
|
||||
ID: "HP088", NameDE: "Unkontrollierte Bewegung beim Erststart", NameEN: "Uncontrolled movement during commissioning",
|
||||
RequiredComponentTags: []string{"moving_part", "programmable"},
|
||||
RequiredLifecycles: []string{"commissioning"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -365,7 +365,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP089", NameDE: "Restmedien bei Demontage (Oel, Gas, Druck)", NameEN: "Residual media during dismantling",
|
||||
ID: "HP089", NameDE: "Restmedien (Oel, Gas, Druck)", NameEN: "Residual media during dismantling",
|
||||
RequiredComponentTags: []string{"hydraulic_part"},
|
||||
RequiredLifecycles: []string{"decommissioning", "disposal"},
|
||||
GeneratedHazardCats: []string{"material_environmental", "pneumatic_hydraulic"},
|
||||
@@ -379,7 +379,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP090", NameDE: "Scharfe Kanten bei Demontage", NameEN: "Sharp edges during dismantling",
|
||||
ID: "HP090", NameDE: "Scharfe Kanten an demontierten Teilen", NameEN: "Sharp edges during dismantling",
|
||||
RequiredComponentTags: []string{"cutting_part"},
|
||||
RequiredLifecycles: []string{"decommissioning", "disposal"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -411,7 +411,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 2, DefaultExposure: 4,
|
||||
},
|
||||
{
|
||||
ID: "HP092", NameDE: "Chemische Exposition bei Reinigung", NameEN: "Chemical exposure during cleaning",
|
||||
ID: "HP092", NameDE: "Chemische Exposition durch Reinigungsmittel", NameEN: "Chemical exposure during cleaning",
|
||||
RequiredComponentTags: []string{"chemical_risk"},
|
||||
RequiredLifecycles: []string{"cleaning"},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
@@ -425,7 +425,7 @@ func GetOperationalHazardPatterns() []HazardPattern {
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP093", NameDE: "Einziehen in rotierende Teile bei Reinigung", NameEN: "Draw-in by rotating parts during cleaning",
|
||||
ID: "HP093", NameDE: "Einziehen in rotierende Teile bei laufender Maschine", NameEN: "Draw-in by rotating parts during cleaning",
|
||||
RequiredComponentTags: []string{"rotating_part"},
|
||||
RequiredLifecycles: []string{"cleaning"},
|
||||
ExcludedComponentTags: []string{"interlocked"},
|
||||
|
||||
@@ -262,7 +262,7 @@ func GetPlasticsMetalPatterns() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M004", "M082"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E09"},
|
||||
Priority: 95,
|
||||
Priority: 95, MachineTypes: []string{"lathe", "cnc", "metalworking"},
|
||||
ScenarioDE: "Offene Haare, Krawatten, Aermel oder Handschuhe werden vom rotierenden Werkstueck oder Spannfutter erfasst.",
|
||||
TriggerDE: "Tragen von Handschuhen an der Drehmaschine, offene Haare, lose Kleidung",
|
||||
HarmDE: "Skalpierung, Armfraktur, Strangulation, toedliche Aufwickelverletzung",
|
||||
|
||||
@@ -124,7 +124,7 @@ func GetPressHazardPatterns() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M051", "M131"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E08"},
|
||||
Priority: 92,
|
||||
ScenarioDE: "Hydraulikspeicher entlaedt sich schlagartig bei Wartungsarbeiten oder Leitungsbruch.",
|
||||
ScenarioDE: "Hydraulikspeicher entlaedt sich schlagartig oder Leitungsbruch.",
|
||||
TriggerDE: "Oeffnen einer Leitung ohne vorherige Druckentlastung, Berstversagen des Speichers.",
|
||||
HarmDE: "Schwere Schnittverletzungen durch Oelstrahl, Augenverletzungen, Verbrennungen.",
|
||||
AffectedDE: "Instandhaltungspersonal, Hydraulik-Fachkraefte.",
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package iace
|
||||
|
||||
// GetRobotCellPatterns returns hazard patterns for industrial robot cells
|
||||
// (non-collaborative) with safety fence, conveyors, and CNC machine tools.
|
||||
// Based on typical ISO 10218-2 risk assessment scope for integrated robot systems.
|
||||
//
|
||||
// FORMULIERUNGSREGEL: Gefährdung und Szenario NEUTRAL formulieren — keine
|
||||
// Lebensphasen im Text. Lebensphasen stehen in ApplicableLifecycles.
|
||||
// HP1600-HP1649
|
||||
func GetRobotCellPatterns() []HazardPattern {
|
||||
return []HazardPattern{
|
||||
// ================================================================
|
||||
// Roboterarm — Quetschen/Einklemmen von Personen
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1600", NameDE: "Einklemmen zwischen Roboterarm und Anlage", NameEN: "Crushing between robot arm and fixed structure",
|
||||
RequiredComponentTags: []string{"moving_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M062", "M054"},
|
||||
Priority: 99, MachineTypes: []string{"robotics_cobot", "automotive", "metalworking", "general_industry"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "teach_mode", "cleaning", "maintenance", "fault_clearing", "changeover"},
|
||||
ScenarioDE: "Person befindet sich im Bewegungsbereich des Roboterarms und wird zwischen Roboterarm und feststehenden Anlagenteilen eingeklemmt.",
|
||||
TriggerDE: "Roboterarm bewegt sich waehrend Person im Gefahrenbereich steht.",
|
||||
HarmDE: "Quetschungen, Knochenbrueche, innere Verletzungen durch Einklemmen von Koerperteilen.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter, Wartungspersonal, Reinigungspersonal",
|
||||
ZoneDE: "Roboterarm, feststehende Anlagenteile innerhalb der Roboterzelle",
|
||||
DefaultSeverity: 4, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1602", NameDE: "Durchgreifen durch Schutzzaun zum Roboter", NameEN: "Reaching through safety fence to robot",
|
||||
RequiredComponentTags: []string{"moving_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person greift ueber oder durch den Schutzzaun und erreicht den Bewegungsbereich des Roboterarms.",
|
||||
TriggerDE: "Unzureichender Sicherheitsabstand zwischen Schutzzaun-Oberkante und Roboter-Schwenkbereich.",
|
||||
HarmDE: "Quetschung von Hand oder Arm zwischen Roboterarm und feststehenden Teilen.",
|
||||
AffectedDE: "Bedienpersonal, Reinigungspersonal",
|
||||
ZoneDE: "Schutzzaun-Oberkante, Roboterarm",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1603", NameDE: "Eingeschlossen in Roboterzelle", NameEN: "Trapped inside robot cell",
|
||||
RequiredComponentTags: []string{"moving_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M054", "M141"},
|
||||
Priority: 99,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing", "changeover"},
|
||||
ScenarioDE: "Person befindet sich in der Roboterzelle, Schutztuer wird geschlossen und Roboter startet. Person kann den Gefahrenbereich nicht rechtzeitig verlassen.",
|
||||
TriggerDE: "Schutztuer schliesst waehrend Person im Innenraum. Wiederanlauf des Roboters ohne Quittierung.",
|
||||
HarmDE: "Quetschungen, Stoss durch anlaufenden Roboter.",
|
||||
AffectedDE: "Wartungspersonal, Einrichter, Reinigungspersonal",
|
||||
ZoneDE: "Inneres der Roboterzelle",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1604", NameDE: "Roboterarm durchschlaegt Schutzzaun", NameEN: "Robot arm penetrates safety fence",
|
||||
RequiredComponentTags: []string{"moving_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M002"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "changeover", "fault_clearing"},
|
||||
ScenarioDE: "Roboterarm ueberschreitet Bewegungsbereich und trifft Schutzzaun. Person ausserhalb wird von Zaunteilen oder dem Roboterarm getroffen.",
|
||||
TriggerDE: "Fehler in der Bahnplanung oder Ausfall der Achsbegrenzung.",
|
||||
HarmDE: "Teile des Schutzzauns werden herausgeschleudert, Person ausserhalb wird getroffen.",
|
||||
AffectedDE: "Bedienpersonal in der Naehe des Schutzzauns",
|
||||
ZoneDE: "Schutzzaun, Bereich um die Roboterzelle",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1605", NameDE: "Stoss durch Werkzeug/Greifer im Einrichtbetrieb", NameEN: "Impact by tool/gripper during setup",
|
||||
RequiredComponentTags: []string{"moving_part", "clamping_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
Priority: 98, MachineTypes: []string{"robotics_cobot", "automotive", "metalworking", "general_industry"},
|
||||
ApplicableLifecycles: []string{"teach_mode", "setup", "changeover", "fault_clearing"},
|
||||
ScenarioDE: "Person steht im Bewegungsbereich des Roboterarms und wird von bewegtem Werkzeug oder Greifer getroffen. Geschwindigkeitsreduzierung im Einrichtbetrieb reicht nicht aus.",
|
||||
TriggerDE: "Roboter bewegt Werkzeug/Greifer mit unerwartet hoher Geschwindigkeit oder in unerwartete Richtung.",
|
||||
HarmDE: "Prellungen, Quetschungen durch Kontakt mit Werkzeug/Greifer am Roboterarm.",
|
||||
AffectedDE: "Einrichter, Programmierer, Wartungspersonal",
|
||||
ZoneDE: "Inneres der Roboterzelle, Schwenkbereich Werkzeug/Greifer",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// Greifer / Werkstueck
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1610", NameDE: "Quetschen im Greiferbereich", NameEN: "Crushing in gripper area",
|
||||
RequiredComponentTags: []string{"clamping_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M061"},
|
||||
Priority: 99, MachineTypes: []string{"robotics_cobot", "automotive", "metalworking", "general_industry"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "changeover", "fault_clearing"},
|
||||
ScenarioDE: "Person greift in den Bereich des Greifers. Hand wird zwischen Greifbacken und Werkstueck eingeklemmt.",
|
||||
TriggerDE: "Greiferbacken schliessen waehrend Koerperteil im Greifbereich ist.",
|
||||
HarmDE: "Quetschung oder Amputation von Fingern durch Greifkraft.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter",
|
||||
ZoneDE: "Greifer des Roboterarms, Werkstueckaufnahme",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1611", NameDE: "Werkstueck faellt aus Greifer herab", NameEN: "Workpiece falls from gripper",
|
||||
RequiredComponentTags: []string{"clamping_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M007", "M141"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "changeover"},
|
||||
ScenarioDE: "Greifer verliert das Werkstueck waehrend des Transports. Werkstueck faellt herab und trifft Person unterhalb des Roboterarms.",
|
||||
TriggerDE: "Werkstueck faellt aus Greifer und trifft Person unterhalb des Roboterarms.",
|
||||
HarmDE: "Prellungen, Knochenbrueche abhaengig von Werkstueckgewicht und Fallhoehe.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Bereich unterhalb des Greifer/Roboterarms",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1612", NameDE: "Werkstueck durchschlaegt Einhausung", NameEN: "Workpiece penetrates enclosure",
|
||||
RequiredComponentTags: []string{"clamping_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M141"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation"},
|
||||
ScenarioDE: "Greifer versagt und Werkstueck wird in Richtung Schutzzaun geschleudert. Person ausserhalb der Zelle wird von durchschlagendem Werkstueck getroffen.",
|
||||
TriggerDE: "Werkstueck wird durch Roboterbewegung weggeschleudert und durchschlaegt die Schutzeinrichtung.",
|
||||
HarmDE: "Person ausserhalb der Zelle wird von weggeschleudertem Werkstueck getroffen.",
|
||||
AffectedDE: "Bedienpersonal in der Naehe der Roboterzelle",
|
||||
ZoneDE: "Schutzzaun, Bereich um die Roboterzelle",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
// ================================================================
|
||||
// Foerderbaender / Werkstueckzu-/-auslauf
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1620", NameDE: "Quetschen an Foerderband-Einlauf", NameEN: "Crushing at conveyor infeed",
|
||||
RequiredComponentTags: []string{"entanglement_risk"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M061", "M003"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person greift an Foerderband und wird zwischen beweglichen und feststehenden Teilen eingeklemmt.",
|
||||
TriggerDE: "Hand oder Finger geraten zwischen Band und Umlenkrolle oder zwischen Werkstueck und Tunnelrahmen.",
|
||||
HarmDE: "Quetschung von Fingern, Einzug von Kleidung oder Haaren.",
|
||||
AffectedDE: "Bedienpersonal, Reinigungspersonal",
|
||||
ZoneDE: "Foerderbaender, Bandein- und -auslauf",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1621", NameDE: "Durchgreifen durch Foerderband-Oeffnung in Schutzzaun", NameEN: "Reaching through conveyor opening in fence",
|
||||
RequiredComponentTags: []string{"entanglement_risk", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "fault_clearing"},
|
||||
ScenarioDE: "Person greift durch die Oeffnung im Schutzzaun fuer die Foerderbaender in den Gefahrenbereich des Roboters.",
|
||||
TriggerDE: "Oeffnung ist zu gross oder Sicherheitsabstand zum Roboter-Schwenkbereich ist zu gering.",
|
||||
HarmDE: "Quetschung von Hand oder Arm durch Roboterarm oder bewegte Maschinenteile.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Oeffnung der Foerderbaender im Schutzzaun, Roboterbereich dahinter",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1622", NameDE: "Herunterfallen von Werkstueck am Bandende", NameEN: "Workpiece falling off conveyor end",
|
||||
RequiredComponentTags: []string{"entanglement_risk"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M008"},
|
||||
Priority: 97,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup"},
|
||||
ScenarioDE: "Werkstueck faehrt ueber das Ende des Transportbandes hinaus, faellt herab und trifft Person am Be-/Entladeplatz.",
|
||||
TriggerDE: "Mechanischer Anschlag fehlt oder ist beschaedigt.",
|
||||
HarmDE: "Prellungen, Quetschung von Fuessen durch herabfallendes Werkstueck.",
|
||||
AffectedDE: "Bedienpersonal am Be-/Entladeplatz",
|
||||
ZoneDE: "Ende der Transportbaender, Be-/Entladeplatz",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// Scharfe Kanten / Allgemein
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1625", NameDE: "Schneiden an scharfen Kanten der Einhausung", NameEN: "Cutting on sharp enclosure edges",
|
||||
RequiredComponentTags: []string{"guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003"},
|
||||
Priority: 97,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person schneidet sich an nicht entgrateten oder scharfkantigen Blechen der Einhausung oder Verkleidung.",
|
||||
TriggerDE: "Zugaengliche Kanten sind nicht gerundet oder gebrochen.",
|
||||
HarmDE: "Schnittwunden an Haenden und Armen.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal, Reinigungspersonal",
|
||||
ZoneDE: "Zugaengliche Kanten der Maschine und Einhausung",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// Pneumatik / Druckluft
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1630", NameDE: "Pneumatikschlauch springt unter Druck ab", NameEN: "Pressurized hose comes loose",
|
||||
RequiredComponentTags: []string{"pinch_point"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M480"},
|
||||
Priority: 97,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Pneumatikschlauch der Automation springt unter Druck ab und trifft eine Person (Peitscheneffekt).",
|
||||
TriggerDE: "Befestigung loest sich, Verschraubung wird undicht, Materialermuedung des Schlauchs.",
|
||||
HarmDE: "Prellungen, Augenverletzungen durch abspringenden Schlauch.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Pneumatikschlaeuche der Automation",
|
||||
DefaultSeverity: 2, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1631", NameDE: "Restdruck in Pneumatik nach Abschaltung", NameEN: "Residual pressure in pneumatics after shutdown",
|
||||
RequiredComponentTags: []string{"pinch_point"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M480", "M141"},
|
||||
Priority: 97,
|
||||
ApplicableLifecycles: []string{"maintenance", "fault_clearing", "changeover"},
|
||||
ScenarioDE: "Person loest druckbeaufschlagte Pneumatik-Komponenten die nach Abschaltung noch unter Druck stehen. Teile fliegen unkontrolliert weg und treffen die Person.",
|
||||
TriggerDE: "Fehlende Druckentlastung. Gesperrte Rueckschlagventile halten Druck.",
|
||||
HarmDE: "Person wird von wegfliegenden Teilen oder unkontrolliert loesenden Verbindungen getroffen. Prellungen, Schnittverletzungen.",
|
||||
AffectedDE: "Wartungspersonal, Einrichter",
|
||||
ZoneDE: "Pneumatikschlaeuche und -komponenten",
|
||||
DefaultSeverity: 2, DefaultExposure: 2,
|
||||
},
|
||||
// ================================================================
|
||||
// Kuehlschmierstoff (KSS)
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1606", NameDE: "Quetschen/Scheren durch Greifer im Einrichtbetrieb", NameEN: "Crushing/shearing by gripper during setup",
|
||||
RequiredComponentTags: []string{"clamping_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
Priority: 98, MachineTypes: []string{"robotics_cobot", "automotive", "metalworking", "general_industry"},
|
||||
ApplicableLifecycles: []string{"teach_mode", "setup", "changeover", "fault_clearing"},
|
||||
ScenarioDE: "Einrichter steht im Schwenkbereich des Roboterarms und wird von bewegtem Greifer oder daran befestigtem Werkzeug verletzt.",
|
||||
TriggerDE: "Reduzierte Geschwindigkeit im Einrichtbetrieb reicht nicht aus oder wird nicht aktiviert.",
|
||||
HarmDE: "Quetschung, Schnittverletzung durch Greiferkanten oder Werkzeug am Roboter.",
|
||||
AffectedDE: "Einrichter, Programmierer",
|
||||
ZoneDE: "Inneres der Roboterzelle, Greifer/Werkzeug am Roboterarm",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1634", NameDE: "KSS-Pumpe spritzt bei geoeffneter Schutztuer", NameEN: "Coolant pump sprays with open guard door",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061"},
|
||||
Priority: 96, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Niederdruck-Pumpe fuer Bettspuelung laeuft an waehrend Schutztuer geoeffnet ist. Person bekommt KSS-Spritzer ins Auge oder Gesicht.",
|
||||
TriggerDE: "Pumpe startet automatisch, kein Verriegelungssignal von Schutztuer zur KSS-Pumpe.",
|
||||
HarmDE: "Augenverletzung durch KSS-Spritzer, Hautreizung.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Bearbeitungszelle, Austrittsduesen der Bettspuelung",
|
||||
DefaultSeverity: 1, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1633", NameDE: "KSS-Versorgungsschlauch platzt oder reisst ab", NameEN: "Coolant supply hose bursts or tears off",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M480"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "KSS-Versorgungsschlauch reisst ab oder platzt. Person in der Naehe wird von abspringendem Schlauch oder KSS-Strahl unter Druck getroffen.",
|
||||
TriggerDE: "Materialermuedung, mechanische Beschaedigung, fehlerhafte Befestigung des Schlauchs.",
|
||||
HarmDE: "Person wird von KSS-Strahl getroffen. Einstichverletzung, Hautreizung, Rutschgefahr.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Druckschlaeuche des Kuehlschmierstoffsystems, Verbindungsstellen",
|
||||
DefaultSeverity: 2, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1635", NameDE: "Ausrutschen durch KSS-Leckage", NameEN: "Slipping due to coolant leakage",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M420"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Kuehlschmierstoff tritt aus und bildet rutschigen Belag auf dem Boden. Person rutscht aus und stuerzt.",
|
||||
TriggerDE: "Leckage an Schlauchverbindung, Dichtungsversagen.",
|
||||
HarmDE: "Ausrutschen und Sturz, Prellungen, Knochenbrueche.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Boden um Bearbeitungszentrum und Kuehlschmierstoffanlage",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1636", NameDE: "Hautkontakt mit Kuehlschmierstoff", NameEN: "Skin contact with coolant",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person kommt bei Arbeiten am Bearbeitungszentrum oder der Roboterzelle mit Kuehlschmierstoff in Beruehrung.",
|
||||
TriggerDE: "Hautkontakt beim Reinigen, Werkzeugwechsel oder Beseitigung von Stoerungen.",
|
||||
HarmDE: "Hautirritationen, allergische Reaktionen, bei laengerer Exposition Ekzeme.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Bearbeitungszentrum, Roboterzelle im Bereich der Beladetuer",
|
||||
DefaultSeverity: 1, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1637", NameDE: "Einatmen von KSS-Aerosolen", NameEN: "Inhalation of coolant aerosols",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"material_environmental"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance"},
|
||||
ScenarioDE: "Person oeffnet Schutztuer der Bearbeitungszelle und atmet freigesetzte KSS-Aerosole ein.",
|
||||
TriggerDE: "Oeffnen der Schutztuer nach Bearbeitungsvorgang, unzureichende Absaugung.",
|
||||
HarmDE: "Person atmet KSS-Aerosole ein. Atembeschwerden, Reizung der Atemwege, bei chronischer Exposition Atemwegserkrankungen.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Bearbeitungszelle, Bereich vor der Schutztuer",
|
||||
DefaultSeverity: 1, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// Elektrisch (Roboterzelle-spezifisch)
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1640", NameDE: "Direktes Beruehren spannungsfuehrender Teile", NameEN: "Direct contact with live parts",
|
||||
RequiredComponentTags: []string{},
|
||||
RequiredEnergyTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M265", "M089", "M088", "M139", "M475"},
|
||||
Priority: 99,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person beruehrt spannungsfuehrende Teile der Anlage die nicht ausreichend isoliert oder abgedeckt sind.",
|
||||
TriggerDE: "Beschaedigte Isolation, fehlende Abdeckung, ungesicherter Schaltschrank.",
|
||||
HarmDE: "Person erleidet elektrischen Schlag. Herzkammerflimmern, Verbrennungen, bei Hochspannung Todesfolge.",
|
||||
AffectedDE: "Wartungspersonal, Einrichter",
|
||||
ZoneDE: "Zugaengliche Kabel, Klemmen, Schaltschrank",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1641", NameDE: "Gefaehrliche Beruehrungsspannung durch Schutzleiterfehler", NameEN: "Dangerous touch voltage due to PE failure",
|
||||
RequiredComponentTags: []string{},
|
||||
RequiredEnergyTags: []string{"electrical"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M475", "M476"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Schutzleiter ist unterbrochen. Person beruehrt das Maschinengehaeuse und erleidet elektrischen Schlag durch gefaehrliche Beruehrungsspannung.",
|
||||
TriggerDE: "Schutzleiterunterbrechung durch mechanische Beschaedigung oder fehlerhafte Installation.",
|
||||
HarmDE: "Elektrischer Schlag bei Beruehren des Maschinengehaeuses oder leitfaehiger Oberflaechen.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Beruehrbare leitfaehige Oberflaechen der Anlage",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1642", NameDE: "Kabelbrand durch Ueberlast oder Kurzschluss", NameEN: "Cable fire from overload or short circuit",
|
||||
RequiredComponentTags: []string{},
|
||||
RequiredEnergyTags: []string{"electrical"},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M009"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance"},
|
||||
ScenarioDE: "Kabel ueberhitzt und entzuendet sich durch Ueberlast oder fehlenden Ueberstromschutz. Person wird durch Brand oder toxische Gase verletzt.",
|
||||
TriggerDE: "Dauerhafter Betrieb nahe der Belastungsgrenze, falsch dimensionierte Sicherung.",
|
||||
HarmDE: "Brand, Rauchentwicklung, Verletzung durch Feuer oder toxische Gase.",
|
||||
AffectedDE: "Alle Personen im Bereich der Anlage",
|
||||
ZoneDE: "Kabel und Leitungen der Anlage",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package iace
|
||||
|
||||
// GetRobotCellPatternsExt returns additional hazard patterns for robot cells.
|
||||
// These cover specific scenarios identified through GT benchmark gaps.
|
||||
// HP1650-HP1699
|
||||
func GetRobotCellPatternsExt() []HazardPattern {
|
||||
return []HazardPattern{
|
||||
// ================================================================
|
||||
// Roboterarm — Spezifische Szenarien (GT-Gaps)
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1650", NameDE: "Roboterarm durchschlaegt Bewegungsbegrenzung", NameEN: "Robot arm exceeds motion limit",
|
||||
RequiredComponentTags: []string{"moving_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061", "M054"},
|
||||
Priority: 99,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "changeover", "fault_clearing"},
|
||||
ScenarioDE: "Roboterarm ueberschreitet Bewegungsbegrenzung und trifft Schutzzaun. Person ausserhalb wird von Zaunteilen oder dem Roboterarm getroffen.",
|
||||
TriggerDE: "Softwareendschalter versagt, Achsbegrenzung (DCS) fehlerhaft konfiguriert.",
|
||||
HarmDE: "Person ausserhalb wird von Zaunteilen oder dem Roboterarm getroffen.",
|
||||
AffectedDE: "Bedienpersonal in der Naehe des Schutzzauns",
|
||||
ZoneDE: "Schutzzaun, Bereich um die Roboterzelle",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1651", NameDE: "Wiederanlauf Roboter waehrend Person in Zelle", NameEN: "Robot restart while person inside cell",
|
||||
RequiredComponentTags: []string{"moving_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M061", "M141"},
|
||||
Priority: 99,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing", "changeover"},
|
||||
ScenarioDE: "Person befindet sich in der Roboterzelle. Schutztuer wird geschlossen und Roboter startet ohne dass sichergestellt ist, dass niemand im Gefahrenbereich ist.",
|
||||
TriggerDE: "Fehlende Quittierungspflicht, kein Personenscanner, Schutztuer ohne Sicherheitszuhaltung.",
|
||||
HarmDE: "Schwere Quetschungen, Knochenbrueche durch anlaufenden Roboter.",
|
||||
AffectedDE: "Wartungspersonal, Einrichter, Reinigungspersonal",
|
||||
ZoneDE: "Inneres der Roboterzelle, Roboterarm",
|
||||
DefaultSeverity: 4, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1652", NameDE: "Quetschen durch Werkzeug/Greifer am Roboter im Betrieb", NameEN: "Crushing by tool/gripper during operation",
|
||||
RequiredComponentTags: []string{"moving_part", "clamping_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M061"},
|
||||
Priority: 99,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning"},
|
||||
ScenarioDE: "Person wird von bewegtem Werkzeug oder Greifer am Roboterarm getroffen oder zwischen Werkzeug und feststehenden Teilen eingeklemmt.",
|
||||
TriggerDE: "Roboter bewegt Werkzeug/Greifer waehrend Person im Schwenkbereich.",
|
||||
HarmDE: "Quetschungen, Schnittverletzungen, Prellungen durch Werkzeug/Greifer.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter",
|
||||
ZoneDE: "Inneres der Roboterzelle, Greifer/Werkzeug des Roboterarms",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1653", NameDE: "Quetschen durch Werkstück am Robotergreifer", NameEN: "Crushing by workpiece on robot gripper",
|
||||
RequiredComponentTags: []string{"moving_part", "clamping_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "changeover"},
|
||||
ScenarioDE: "Person wird von sich bewegendem Werkstueck am Robotergreifer getroffen oder zwischen Werkstueck und feststehenden Anlagenteilen eingeklemmt.",
|
||||
TriggerDE: "Roboter transportiert Werkstueck, Person steht im Schwenkbereich.",
|
||||
HarmDE: "Quetschungen, Prellungen, Knochenbrueche abhaengig von Werkstueckgewicht.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter",
|
||||
ZoneDE: "Inneres der Roboterzelle, Greifer des Roboterarms",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1654", NameDE: "Werkstück/Werkzeug durchschlaegt Schutzzaun", NameEN: "Workpiece/tool penetrates safety fence",
|
||||
RequiredComponentTags: []string{"clamping_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation"},
|
||||
ScenarioDE: "Greifer versagt und Werkstueck/Werkzeug wird Richtung Schutzzaun geschleudert. Person ausserhalb wird getroffen.",
|
||||
TriggerDE: "Greifkraftverlust, Druckausfall, oelige Oberflaeche des Werkstuecks.",
|
||||
HarmDE: "Person ausserhalb der Zelle wird von weggeschleudertem Teil getroffen.",
|
||||
AffectedDE: "Bedienpersonal in der Naehe der Roboterzelle",
|
||||
ZoneDE: "Schutzzaun, Bereich ausserhalb der Roboterzelle",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1655", NameDE: "Durchgreifen ueber Schutzzaun zum Greifer/Werkstueck", NameEN: "Reaching over fence to gripper/workpiece",
|
||||
RequiredComponentTags: []string{"clamping_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person greift ueber den Schutzzaun und erreicht den Greifer oder das Werkstueck am Roboterarm.",
|
||||
TriggerDE: "Sicherheitsabstand zwischen Zaun-Oberkante und Greifer/Werkstueck zu gering.",
|
||||
HarmDE: "Quetschung von Hand oder Arm zwischen Greifer/Werkstueck und feststehenden Teilen.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Schutzzaun-Oberkante, Greifer/Werkstueck am Roboterarm",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
// ================================================================
|
||||
// Zentriergreifer an Förderbändern
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1660", NameDE: "Quetschen am Zentriergreifer von aussen", NameEN: "Crushing at centering gripper from outside",
|
||||
RequiredComponentTags: []string{"clamping_part", "entanglement_risk"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person befindet sich ausserhalb der Roboterzelle und greift an die Zentriereinheit (fest montierter Greifer am Foerderband).",
|
||||
TriggerDE: "Zentriergreifer schliesst waehrend Hand im Greifbereich. Unzureichender Abstand zwischen Greifer und Schutzzaun-Oeffnung.",
|
||||
HarmDE: "Quetschung von Fingern oder Hand zwischen Greifbacken und Werkstueck.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Zentriereinheit an Foerderbaendern, Schutzzaun-Oeffnung",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1661", NameDE: "Quetschen am Zentriergreifer von innen", NameEN: "Crushing at centering gripper from inside cell",
|
||||
RequiredComponentTags: []string{"clamping_part", "entanglement_risk", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "cleaning", "fault_clearing"},
|
||||
ScenarioDE: "Person befindet sich innerhalb der Roboterzelle und greift an die Zentriereinheit am Foerderband.",
|
||||
TriggerDE: "Schutztuer geoeffnet, aber Zentriergreifer wird nicht automatisch stillgesetzt.",
|
||||
HarmDE: "Quetschung von Fingern oder Hand zwischen Greifbacken und Werkstueck.",
|
||||
AffectedDE: "Wartungspersonal, Reinigungspersonal",
|
||||
ZoneDE: "Zentriereinheit an Foerderbaendern innerhalb der Roboterzelle",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// Bearbeitungszentrum (Robodrill/WZM) innerhalb Roboterzelle
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1665", NameDE: "Quetschen an Beladetuer der Werkzeugmaschine", NameEN: "Crushing at machine tool loading door",
|
||||
RequiredComponentTags: []string{"moving_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M061"},
|
||||
Priority: 98, MachineTypes: []string{"cnc", "metalworking", "automotive", "robotics_cobot"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person greift durch die Beladetuer der Werkzeugmaschine. Beladetuer schliesst sich oder bewegliche Teile im Innenraum starten.",
|
||||
TriggerDE: "Tuerpositionsschalter nicht in Robotersteuerung eingebunden, fehlende Verriegelung.",
|
||||
HarmDE: "Quetschung von Hand/Arm an Beladetuer oder durch bewegliche Teile im Bearbeitungsraum.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter, Wartungspersonal",
|
||||
ZoneDE: "Beladetuer der Werkzeugmaschine, Bearbeitungsraum",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1666", NameDE: "Quetschen/Scheren im Bearbeitungsraum der WZM", NameEN: "Crushing/shearing inside machine tool workspace",
|
||||
RequiredComponentTags: []string{"moving_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054"},
|
||||
Priority: 98, MachineTypes: []string{"cnc", "metalworking", "automotive", "robotics_cobot"},
|
||||
ApplicableLifecycles: []string{"setup", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person greift in den Bearbeitungsraum der Werkzeugmaschine und wird von beweglichen Achsen, Werkzeug oder Spannvorrichtung verletzt.",
|
||||
TriggerDE: "Bewegliche Teile starten waehrend Hand im Bearbeitungsraum (Einrichtbetrieb, Stoerungsbeseitigung).",
|
||||
HarmDE: "Quetschungen, Schnittverletzungen durch rotierende Werkzeuge, Scheren an Achsbewegungen.",
|
||||
AffectedDE: "Einrichter, Wartungspersonal",
|
||||
ZoneDE: "Bearbeitungsraum der Werkzeugmaschine, Achsen, Werkzeug, Spannvorrichtung",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// KSS-Spritzer / Druckluft in Bearbeitungszelle
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1670", NameDE: "KSS-Spritzer in Augen/Gesicht", NameEN: "Coolant splash to eyes/face",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person bekommt Kuehlschmierstoff-Spritzer ins Auge oder Gesicht beim Oeffnen der Bearbeitungszelle oder bei laufender Bettspuelung.",
|
||||
TriggerDE: "KSS-Pumpe laeuft waehrend Schutztuer geoeffnet ist, Austrittsduese nicht korrekt gerichtet.",
|
||||
HarmDE: "Augenverletzung, Reizung der Bindehaut, bei Hochdruck-KSS ernsthafte Augenschaeden.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Bearbeitungszelle, Bereich vor der Schutztuer, Austrittsduesen",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1671", NameDE: "Druckluft-Verletzung in Bearbeitungszelle", NameEN: "Compressed air injury in machining cell",
|
||||
RequiredComponentTags: []string{"pinch_point"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person wird von ausstroemender Druckluft oder aufgewirbelten Bearbeitungsrueckstaenden in der Bearbeitungszelle verletzt.",
|
||||
TriggerDE: "Druckluftreinigungsduese aktiv waehrend Schutztuer geoeffnet, Spaene oder Partikel werden aufgewirbelt.",
|
||||
HarmDE: "Augenverletzung durch Spaene, Hautverletzung durch Druckluftstoss.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Bearbeitungszelle, Druckluftreinigungsduesen",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// KSS-Schläuche unter Druck
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1675", NameDE: "KSS-Schlauch bersten oder abspringen", NameEN: "Coolant hose burst or detachment",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M480"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Schlauch der Kuehlschmierstoffversorgung zwischen Aufbereitungsanlage und Bearbeitungszentrum platzt oder springt unter Druck ab.",
|
||||
TriggerDE: "Materialermuedung, Ueberdruck, fehlerhafte Befestigung, mechanische Beschaedigung des Schlauchs.",
|
||||
HarmDE: "Person wird von abspringendem Schlauch getroffen (Peitscheneffekt). KSS-Spritzer unter Druck verletzen Haut und Augen. Rutschgefahr durch austretenden KSS.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Druckschlaeuche des Kuehlschmierstoffsystems",
|
||||
DefaultSeverity: 2, DefaultExposure: 2,
|
||||
},
|
||||
// ================================================================
|
||||
// Quetschen am Förderband — Werkstück/Tunnel
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1680", NameDE: "Quetschen zwischen Werkstueck und Tunnel am Foerderband", NameEN: "Crushing between workpiece and conveyor tunnel",
|
||||
RequiredComponentTags: []string{"entanglement_risk"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M002", "M003"},
|
||||
Priority: 97,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "fault_clearing"},
|
||||
ScenarioDE: "Person greift an den Tunnel/Rahmen des Foerderbandes und wird von einem darauf bewegten Werkstueck eingequetscht.",
|
||||
TriggerDE: "Zu geringer Abstand zwischen Werkstueck und Tunnel/Rahmen, scharfe Kanten an Tunneleingang.",
|
||||
HarmDE: "Quetschung von Fingern zwischen Werkstueck und Rahmen.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Foerderband-Tunnel, Werkstück auf dem Band",
|
||||
DefaultSeverity: 2, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// Elektrisch — Spezifische Szenarien
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1685", NameDE: "Indirektes Beruehren durch Schutzleiterunterbrechung", NameEN: "Indirect contact due to PE interruption",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M475", "M476"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Schutzleiter ist unterbrochen. Person beruehrt leitfaehige Maschinenteile und erleidet elektrischen Schlag.",
|
||||
TriggerDE: "Mechanische Beschaedigung des Schutzleiters, korrodierte Verbindung, fehlerhafte Installation.",
|
||||
HarmDE: "Elektrischer Schlag bei Beruehren des Maschinengehaeuses oder anderer leitfaehiger Teile.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Beruehrbare leitfaehige Oberflaechen der Anlage",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1686", NameDE: "Direktes Beruehren im Schaltschrank", NameEN: "Direct contact inside control cabinet",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M009"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"maintenance", "fault_clearing", "commissioning"},
|
||||
ScenarioDE: "Person beruehrt spannungsfuehrende Teile bei geoeffnetem Schaltschrank. Leiter um Bedienelemente sind nicht fingersicher geschuetzt.",
|
||||
TriggerDE: "Schaltschranktuer geoeffnet fuer Wartung oder Fehlersuche, unzureichender Beruehrungsschutz.",
|
||||
HarmDE: "Person erleidet elektrischen Schlag. Herzkammerflimmern, Verbrennungen, bei Hochspannung Todesfolge.",
|
||||
AffectedDE: "Wartungspersonal, Elektrofachkraefte",
|
||||
ZoneDE: "Schaltschrank-Innenraum, Klemmen, Sammelschienen",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1687", NameDE: "Brand durch eindringende Fluessigkeit", NameEN: "Fire from liquid ingress causing short circuit",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M009"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "cleaning"},
|
||||
ScenarioDE: "Fluessigkeit dringt in elektrische Komponenten ein und verursacht Kurzschluss. Person wird durch Brand oder Rauchentwicklung gefaehrdet.",
|
||||
TriggerDE: "Reinigung mit Wasser, KSS-Leckage tropft auf Schaltschrank oder Steuerungskomponenten.",
|
||||
HarmDE: "Person wird durch Brand, Flammen oder toxische Rauchgase verletzt. Verbrennungen, Rauchvergiftung.",
|
||||
AffectedDE: "Bedienpersonal, Reinigungspersonal",
|
||||
ZoneDE: "Schaltgeraetekombinationen, elektrische Komponenten unterhalb von Rohrleitungen",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1688", NameDE: "Gefaehrliche Beruehrungsspannung durch Potentialunterschiede", NameEN: "Dangerous touch voltage from potential differences",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M475", "M477", "M138", "M329"},
|
||||
Priority: 96,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Person beruehrt gleichzeitig Anlagenteile mit unterschiedlichem Potential und erleidet elektrischen Schlag.",
|
||||
TriggerDE: "Fehlender Potentialausgleich zwischen Anlagenteilen verschiedener Hersteller.",
|
||||
HarmDE: "Elektrischer Schlag bei gleichzeitigem Beruehren von Teilen unterschiedlichen Potentials.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Elektrisch leitfaehige Oberflaechen verschiedener Anlagenteile",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1689", NameDE: "Fehlerstromschutz an Steckdosenstromkreisen", NameEN: "RCD protection at socket circuits",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M475"},
|
||||
Priority: 97,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Defektes Geraet wird an Steckdose der Maschine angeschlossen. Fehlerstrom fliesst ueber den Koerper der beruerenden Person.",
|
||||
TriggerDE: "Fehlende Fehlerstrom-Schutzeinrichtung (RCD) an Steckdosenstromkreisen der Maschine.",
|
||||
HarmDE: "Person erleidet elektrischen Schlag durch Fehlerstrom. Herzkammerflimmern, potentiell toedlich.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Steckdosen der Maschine, angeschlossene Betriebsmittel",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
// ================================================================
|
||||
// Ergonomie
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1690", NameDE: "Ergonomisch unguenstige Einlegeposition", NameEN: "Unfavorable ergonomic loading position",
|
||||
RequiredComponentTags: []string{"entanglement_risk"},
|
||||
GeneratedHazardCats: []string{"ergonomic_hazard"},
|
||||
SuggestedMeasureIDs: []string{},
|
||||
Priority: 85,
|
||||
ApplicableLifecycles: []string{"normal_operation"},
|
||||
ScenarioDE: "Person muss Werkstuecke in ergonomisch unguenstiger Hoehe oder Reichweite auf das Foerderband auflegen oder entnehmen.",
|
||||
TriggerDE: "Bandhoehe nicht auf ergonomische Handhabung ausgelegt, schwere Werkstuecke.",
|
||||
HarmDE: "Person erleidet Rueckenbeschwerden und Schulterbelastung durch wiederholte Fehlhaltung. Langfristig Muskel-Skelett-Erkrankungen.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Beladebereich der Foerderbaender",
|
||||
DefaultSeverity: 2, DefaultExposure: 4,
|
||||
},
|
||||
{
|
||||
ID: "HP1691", NameDE: "Unergonomische Position der Bedienelemente", NameEN: "Unfavorable ergonomic position of controls",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"ergonomic_hazard"},
|
||||
SuggestedMeasureIDs: []string{},
|
||||
Priority: 85,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup"},
|
||||
ScenarioDE: "Person bedient Anlage in ergonomisch unguenstiger Position ueber laengere Zeit.",
|
||||
TriggerDE: "Bedienfeld zu hoch, zu niedrig oder seitlich versetzt montiert.",
|
||||
HarmDE: "Person erleidet Nacken- und Schulterbelastung durch unguenstige Bedienposition. Langfristig Haltungsschaeden.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Bedienfeld, HMI, Betriebsartenwahlschalter",
|
||||
DefaultSeverity: 2, DefaultExposure: 4,
|
||||
},
|
||||
// ================================================================
|
||||
// Thermisch / Verbrennung
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1695", NameDE: "Verbrennung an heissen Werkstuecken", NameEN: "Burn from hot workpieces",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
Priority: 88, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "changeover"},
|
||||
ScenarioDE: "Person beruehrt heisse Werkstuecke die durch die Bearbeitung erwaermt wurden.",
|
||||
TriggerDE: "Manuelle Entnahme von Werkstuecken ohne Wartezeit oder Schutzhandschuhe.",
|
||||
HarmDE: "Verbrennungen an Haenden und Fingern.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Werkstueckausgabe, Entnahmeplatz",
|
||||
DefaultSeverity: 1, DefaultExposure: 3,
|
||||
},
|
||||
// ================================================================
|
||||
// Tragfähigkeit / Aufstellung
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1697", NameDE: "Anlage bricht durch unzureichenden Untergrund ein", NameEN: "Machine collapses through insufficient floor",
|
||||
RequiredComponentTags: []string{"high_force"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{},
|
||||
Priority: 88,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "commissioning"},
|
||||
ScenarioDE: "Untergrund bricht unter dem Maschinengewicht ein. Personen im Umfeld werden von kippender oder absackender Anlage eingeklemmt.",
|
||||
TriggerDE: "Boden nicht auf maximale statische und dynamische Lasten der Maschine ausgelegt.",
|
||||
HarmDE: "Anlage bricht ein, Quetschung von Personen im Umfeld.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Bereich um die Maschine, Aufstellflaeche",
|
||||
DefaultSeverity: 4, DefaultExposure: 1,
|
||||
},
|
||||
// ================================================================
|
||||
// Elektrisch — Kriechstrecken + EMV
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1698", NameDE: "Kurzschluss durch unzureichende Luft-/Kriechstrecken", NameEN: "Short circuit from insufficient creepage/clearance",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M477"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "Unzureichende Luft-/Kriechstrecken fuehren bei Verschmutzung zu Kriechstroemen. Person beruehrt betroffene Teile und erleidet elektrischen Schlag.",
|
||||
TriggerDE: "Verschmutzungsgrad hoeher als bei der Dimensionierung angenommen, Feuchtigkeit, alterungsbedingte Veraenderung.",
|
||||
HarmDE: "Gefaehrliche Beruehrungsspannung an beruehrbaren Teilen, Kurzschluss, Brand.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Schaltgeraetekombinationen, elektrische Anschluesse",
|
||||
DefaultSeverity: 4, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1699", NameDE: "EMV-Stoereinfluss auf Sicherheitsfunktionen", NameEN: "EMC interference with safety functions",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"radiation_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M478", "M479"},
|
||||
Priority: 97,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup"},
|
||||
ScenarioDE: "EMV-Stoerungen verursachen unerwartete Maschinenbewegungen. Person im Gefahrenbereich wird von unkontrolliert bewegten Teilen getroffen.",
|
||||
TriggerDE: "Unzureichende EMV-Schirmung, nicht-fachgerechte Verkabelung, externe Stoerquellen.",
|
||||
HarmDE: "Unkontrollierte Bewegung von Achsen, Werkzeug oder Roboterarm durch Steuerungsfehler.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter",
|
||||
ZoneDE: "Bearbeitungsbereich, sicherheitsrelevante Steuerungen",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
// ================================================================
|
||||
// Differenzierte Patterns (GT-Benchmark: gleiche Zone, anderes Szenario)
|
||||
// ================================================================
|
||||
{
|
||||
ID: "HP1700", NameDE: "Getroffen von bewegtem Werkzeug/Greifer am Roboter", NameEN: "Struck by moving tool/gripper on robot",
|
||||
RequiredComponentTags: []string{"moving_part", "clamping_part"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M054", "M061"},
|
||||
Priority: 99,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "teach_mode", "cleaning"},
|
||||
ScenarioDE: "Person steht im Bewegungsbereich des Roboterarms und wird von bewegtem Werkzeug oder Greifer getroffen.",
|
||||
TriggerDE: "Roboter schwenkt mit Werkzeug/Greifer in Richtung Person.",
|
||||
HarmDE: "Prellungen, Schnittverletzungen durch Werkzeugkanten, Knochenbrueche.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter",
|
||||
ZoneDE: "Inneres der Roboterzelle, Schwenkbereich Werkzeug/Greifer",
|
||||
DefaultSeverity: 3, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1701", NameDE: "Greifer/Werkzeug durchschlaegt Schutzzaun", NameEN: "Gripper/tool penetrates safety fence",
|
||||
RequiredComponentTags: []string{"clamping_part", "guard"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061"},
|
||||
Priority: 98,
|
||||
ApplicableLifecycles: []string{"normal_operation", "setup", "changeover"},
|
||||
ScenarioDE: "Greifer oder Werkzeug am Roboterarm durchschlaegt den Schutzzaun und trifft Person ausserhalb der Zelle.",
|
||||
TriggerDE: "Bewegungsbegrenzung versagt, Schutzzaun nicht auf Aufprallenergie ausgelegt.",
|
||||
HarmDE: "Person ausserhalb wird von Greifer/Werkzeug oder Zaunteilen getroffen.",
|
||||
AffectedDE: "Bedienpersonal in der Naehe des Schutzzauns",
|
||||
ZoneDE: "Bereich um Roboterarm ausserhalb der Roboterzelle",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1702", NameDE: "KSS-Schlauch platzt unter Druck", NameEN: "Coolant hose bursts under pressure",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M480"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "KSS-Schlauch platzt und spritzt Kuehlschmierstoff unter Druck. Person in der Naehe wird von KSS-Strahl getroffen.",
|
||||
TriggerDE: "Alterung, Beschaedigung oder Ueberdruck fuehrt zum Versagen des Schlauchs.",
|
||||
HarmDE: "Einstichverletzung durch KSS-Strahl unter Druck, Augenverletzung, Rutschgefahr.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Druckschlaeuche des Kuehlschmierstoffsystems",
|
||||
DefaultSeverity: 2, DefaultExposure: 2,
|
||||
},
|
||||
{
|
||||
ID: "HP1703", NameDE: "KSS-Bettspuelung bei geoeffneter Schutztuer", NameEN: "Coolant bed wash with open guard door",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M061"},
|
||||
Priority: 97, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance", "fault_clearing"},
|
||||
ScenarioDE: "KSS-Pumpe laeuft bei geoeffneter Schutztuer. Person vor der Bearbeitungszelle bekommt KSS-Spritzer ins Auge oder Gesicht.",
|
||||
TriggerDE: "Kein automatisches Abschalten der KSS-Pumpe bei geoeffneter Tuer.",
|
||||
HarmDE: "KSS-Spritzer in Augen oder Gesicht, Rutschgefahr durch austretenden KSS.",
|
||||
AffectedDE: "Bedienpersonal, Wartungspersonal",
|
||||
ZoneDE: "Inneres des Bearbeitungszentrums, Bereich vor der Schutztuer",
|
||||
DefaultSeverity: 1, DefaultExposure: 3,
|
||||
},
|
||||
{
|
||||
ID: "HP1704", NameDE: "Brand durch KSS-Leckage auf elektrische Komponenten", NameEN: "Fire from coolant leakage on electrical components",
|
||||
RequiredComponentTags: []string{},
|
||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M480", "M009"},
|
||||
Priority: 98, MachineTypes: []string{"cnc", "metalworking", "automotive"},
|
||||
ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance"},
|
||||
ScenarioDE: "KSS-Leckage tropft auf elektrische Komponenten und verursacht Kurzschluss. Person wird durch Brand oder Rauchentwicklung gefaehrdet.",
|
||||
TriggerDE: "KSS-Leitung undicht oberhalb elektrischer Komponenten, tropft auf Klemmen oder Leiterplatten.",
|
||||
HarmDE: "Person wird durch Brand, Flammen oder toxische Rauchgase verletzt. Verbrennungen, Rauchvergiftung.",
|
||||
AffectedDE: "Bedienpersonal",
|
||||
ZoneDE: "Spannungsfuehrende Teile unterhalb/angrenzend von KSS-Leitungen",
|
||||
DefaultSeverity: 3, DefaultExposure: 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func builtinSoftwarePatterns() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M145", "M146", "M121"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E14"},
|
||||
Priority: 70,
|
||||
ScenarioDE: "Falsche Parametrierung von Achsgrenzen, Geschwindigkeiten oder Sicherheitsgrenzen nach Umruestung.",
|
||||
ScenarioDE: "Falsche Parametrierung von Achsgrenzen, Geschwindigkeiten oder Sicherheitsgrenzen nach Produktwechsel.",
|
||||
TriggerDE: "Bediener oder Einrichter aendert Parameter ohne Validierung oder nutzt falsches Rezept/Programm.",
|
||||
HarmDE: "Ueberfahren mechanischer Anschlaege, zu hohe Kraefte/Geschwindigkeiten, Kollision.",
|
||||
AffectedDE: "Bedienpersonal, Einrichter",
|
||||
|
||||
@@ -252,7 +252,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E20"},
|
||||
Priority: 90,
|
||||
Priority: 90, MachineTypes: []string{"wind_turbine"},
|
||||
ScenarioDE: "Rotorblatt einer Windturbine bricht durch Materialermuedung oder Blitzschlag und wird Hunderte Meter weit geschleudert.",
|
||||
TriggerDE: "Materialermuedung, Blitzschaden, Vereisung mit Unwucht, fehlende Inspektionen",
|
||||
HarmDE: "Toedliche Verletzung durch Blattstuecke, Sachschaeden im weiten Umkreis",
|
||||
@@ -261,7 +261,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
||||
DefaultSeverity: 5, DefaultExposure: 1,
|
||||
},
|
||||
{
|
||||
ID: "HP746", NameDE: "Absturz bei Wartung der Gondel", NameEN: "Fall during nacelle maintenance",
|
||||
ID: "HP746", NameDE: "Absturz der Gondel", NameEN: "Fall during nacelle maintenance",
|
||||
RequiredComponentTags: []string{"structural_part", "gravity_risk"},
|
||||
RequiredEnergyTags: []string{"gravitational"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -297,7 +297,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E20"},
|
||||
Priority: 80,
|
||||
Priority: 80, MachineTypes: []string{"wind_turbine"},
|
||||
ScenarioDE: "Bei Vereisung loesen sich Eisstuecke von den Rotorblaettern und werden durch die Fliehkraft weit geschleudert.",
|
||||
TriggerDE: "Vereisung im Winter, fehlende Eiserkennungssysteme, Weiterbetrieb bei Eisansatz",
|
||||
HarmDE: "Verletzung durch Eisschlag, Sachschaeden an Fahrzeugen und Gebaeuden",
|
||||
|
||||
@@ -30,7 +30,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E20"},
|
||||
Priority: 80,
|
||||
Priority: 80, MachineTypes: []string{"escalator"},
|
||||
ScenarioDE: "Finger oder Handteile werden am Einzugspunkt des Handlaufs in die Verkleidung gezogen.",
|
||||
TriggerDE: "Kinderhand am Handlauf nahe der Verkleidung, fehlende Einlaufschutzbuegel",
|
||||
HarmDE: "Fingerquetschung, Hautabschuerfungen, bei Kindern Armverletzung",
|
||||
@@ -39,7 +39,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
|
||||
DefaultSeverity: 3, DefaultExposure: 4,
|
||||
},
|
||||
{
|
||||
ID: "HP758", NameDE: "Sturz bei Notbremsung der Fahrtreppe", NameEN: "Fall during emergency stop of escalator",
|
||||
ID: "HP758", MachineTypes: []string{"escalator", "elevator"}, NameDE: "Sturz bei Notbremsung der Fahrtreppe", NameEN: "Fall during emergency stop of escalator",
|
||||
RequiredComponentTags: []string{"moving_part"},
|
||||
RequiredEnergyTags: []string{"kinetic"},
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
@@ -75,7 +75,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E09", "E20"},
|
||||
Priority: 85,
|
||||
Priority: 85, MachineTypes: []string{"escalator", "elevator"},
|
||||
ScenarioDE: "Bruch einer Trittstufe oder der Kammplatte fuehrt zum Einsacken oder Einzug in die Mechanik.",
|
||||
TriggerDE: "Materialermuedung, Korrosion, fehlende Inspektionen, Vandalismus",
|
||||
HarmDE: "Einzug in Mechanik, Beinverletzungen, Sturz in Maschinenkammer",
|
||||
@@ -173,7 +173,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E20"},
|
||||
Priority: 95,
|
||||
Priority: 95, MachineTypes: []string{"playground"},
|
||||
ScenarioDE: "Kind steckt Kopf durch Oeffnung im Spielgeraet und bleibt haengen (Kopf-Entrapment-Gefahr bei 89-230 mm).",
|
||||
TriggerDE: "Oeffnungen im kritischen Bereich 89-230 mm, V-foermige Spalte, Gelaendersprosse mit Kopffangmass",
|
||||
HarmDE: "Strangulation, Erstickung, toedliche Verletzung",
|
||||
@@ -233,7 +233,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E01", "E20"},
|
||||
Priority: 95,
|
||||
Priority: 95, MachineTypes: []string{"playground"},
|
||||
ScenarioDE: "Kind verfaengt sich mit Kapuzenkordel, Schal oder Halskette in Seilen oder Netzen des Spielgeraets.",
|
||||
TriggerDE: "Kleidung mit Kordeln am Hals, zu grosse Maschenweite, lose Seilenden",
|
||||
HarmDE: "Strangulation, Erstickung, toedliche Verletzung",
|
||||
@@ -361,7 +361,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M004", "M082"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E09"},
|
||||
Priority: 85,
|
||||
Priority: 85, MachineTypes: []string{"laundry"},
|
||||
ScenarioDE: "Person greift in die drehende Trommel der Industriewaschmaschine und wird eingezogen.",
|
||||
TriggerDE: "Defekte Tuerverriegelung, Oeffnen waehrend Nachlauf, Bedienfehler",
|
||||
HarmDE: "Schwere Quetschverletzung, Armeinzug, Strangulation durch Waeschestuecke",
|
||||
@@ -411,7 +411,7 @@ func GetSpecificMachinePatterns2() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M005", "M141"},
|
||||
SuggestedEvidenceIDs: []string{"E20"},
|
||||
Priority: 80,
|
||||
ScenarioDE: "Grosse Glasscheibe zerbricht beim Transport oder bei der Montage und trifft umstehende Personen.",
|
||||
ScenarioDE: "Grosse Glasscheibe zerbricht oder durch mechanische Einwirkung und trifft umstehende Personen.",
|
||||
TriggerDE: "Thermische Spannungen, mechanische Beschaedigung, fehlerhafter Saugnapp, Windlast",
|
||||
HarmDE: "Tiefe Schnittwunden, Amputationsgefahr, toedliche Verletzung bei grossen Scheiben",
|
||||
AffectedDE: "Transportpersonal, Monteure, Passanten",
|
||||
|
||||
@@ -22,7 +22,7 @@ func GetTextileAgriPatterns() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M452", "M061"}, SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 78, MachineTypes: []string{"textile", "knitting"},
|
||||
OperationalStates: []string{"automatic_operation", "maintenance"}, HumanRoles: []string{"operator", "maintenance_tech"},
|
||||
ScenarioDE: "Kontakt mit schnell bewegenden Nadeln bei Wartung oder Fadenwechsel",
|
||||
ScenarioDE: "Kontakt mit schnell bewegenden Nadeln oder Fadenwechsel",
|
||||
TriggerDE: "Eingriff in Nadelbereich bei laufender Maschine", HarmDE: "Stichverletzung, Schnittwunde",
|
||||
AffectedDE: "Bedienpersonal", ZoneDE: "Nadelbett",
|
||||
DefaultSeverity: 3, DefaultExposure: 4},
|
||||
@@ -123,7 +123,7 @@ func GetTextileAgriPatterns() []HazardPattern {
|
||||
SuggestedMeasureIDs: []string{"M461", "M465"}, SuggestedEvidenceIDs: []string{"E01", "E08"},
|
||||
Priority: 94, MachineTypes: []string{"agricultural", "harvester", "combine"},
|
||||
OperationalStates: []string{"automatic_operation", "maintenance"}, HumanRoles: []string{"operator", "maintenance_tech"},
|
||||
ScenarioDE: "Kontakt mit rotierendem Schneidwerk bei Wartung oder Blockierungsbeseitigung",
|
||||
ScenarioDE: "Kontakt mit rotierendem Schneidwerk oder Blockierungsbeseitigung",
|
||||
TriggerDE: "Maschine nicht abgestellt, hydraulischer Nachlauf",
|
||||
HarmDE: "Amputation, schwere Schnittverletzungen", AffectedDE: "Bediener, Wartungspersonal", ZoneDE: "Schneidwerksbereich",
|
||||
DefaultSeverity: 5, DefaultExposure: 3},
|
||||
|
||||
@@ -42,7 +42,7 @@ func builtinThermalPatterns() []HazardPattern {
|
||||
SuggestedEvidenceIDs: []string{"E01"},
|
||||
Priority: 75,
|
||||
ScenarioDE: "Aktuatoren (Servomotoren, Linearantriebe) erwaermen sich im Dauerbetrieb ueber die Beruehrtemperaturgrenze.",
|
||||
TriggerDE: "Beruehren heisser Motorgehaeuse bei Wartung oder Stoerungsbeseitigung ohne ausreichende Abkuehlzeit.",
|
||||
TriggerDE: "Beruehren heisser Motorgehaeuse ohne ausreichende Abkuehlzeit.",
|
||||
HarmDE: "Kontaktverbrennung, Blasenbildung an Haenden.",
|
||||
AffectedDE: "Wartungspersonal, Einrichter",
|
||||
ZoneDE: "Motorgehaeuse, Getriebegehaeuse, Linearantrieb",
|
||||
|
||||
@@ -230,7 +230,7 @@ func GetWeldingGlassTextilePatterns() []HazardPattern {
|
||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||
SuggestedMeasureIDs: []string{"M003", "M004", "M082"},
|
||||
SuggestedEvidenceIDs: []string{"E08", "E09"},
|
||||
Priority: 80,
|
||||
Priority: 80, MachineTypes: []string{"glass_washing"},
|
||||
ScenarioDE: "Transportwalzen der Glaswaschmaschine erfassen Finger oder Kleidung beim manuellen Einlegen der Scheiben.",
|
||||
TriggerDE: "Manuelles Nachjustieren bei laufenden Walzen, fehlender Schutz am Einlaufbereich",
|
||||
HarmDE: "Fingerquetschung, Einzug der Hand, Hautabschaelungen",
|
||||
|
||||
@@ -66,5 +66,26 @@ func getSupplementaryMeasures() []ProtectiveMeasureEntry {
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
{ID: "M402", ReductionType: "protection", SubType: "monitoring", Name: "Sicherheitsbeleuchtung an Fluchtwegen und Arbeitsplaetzen", Description: "Fluchtwege und sicherheitsrelevante Arbeitsplaetze erhalten eine Sicherheitsbeleuchtung die bei Ausfall der allgemeinen Beleuchtung automatisch aktiviert wird.", HazardCategory: "general", Examples: []string{"Notbeleuchtung mit Batteriepufferung", "Nachleuchtende Leitmarkierung am Boden"}, NormReferences: []string{"ASR A3.4 — Beleuchtung", "ASR A2.3 — Fluchtwege"}},
|
||||
{ID: "M403", ReductionType: "protection", SubType: "safety_control", Name: "Quetschschutz an kraftbetaetigten Tueren und Toren", Description: "Kraftbetaetigte Tueren und Tore erhalten Schutzeinrichtungen gegen Quetschen, Scheren und Einziehen an allen Schliesskanten.", HazardCategory: "mechanical", Examples: []string{"Schaltleiste an Schiebetorunterkante", "Lichtschranke an Rolltoroeffnung"}, NormReferences: []string{"ASR A1.7 — Tueren und Tore", "EN 12453"}},
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Elektrische Sicherheit — Potentialausgleich & Ableitstroeme
|
||||
// Gap: GT-Benchmark 2.12 (Potentialausgleich), 2.4 (Ableitstroeme)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
{ID: "M475", ReductionType: "design", SubType: "electrical_safety", Name: "Potentialausgleich zwischen Anlagenteilen", Description: "Alle leitfaehigen Anlagenteile mit unterschiedlicher Energieversorgung werden ueber einen Potentialausgleichsleiter verbunden um gefaehrliche Beruehrungsspannungen zu vermeiden.", HazardCategory: "electrical", Examples: []string{"Potentialausgleich zwischen Roboterzelle und Werkzeugmaschine", "Potentialausgleichsschiene im Schaltschrank"}, NormReferences: []string{"IEC 60204-1 Ziff. 8.2", "IEC 61439-1"}},
|
||||
{ID: "M476", ReductionType: "design", SubType: "electrical_safety", Name: "Schutz bei erhoehten Ableitstroemen", Description: "Bei Ableitstroemen ueber 10 mA wird der Schutzleiter mechanisch geschuetzt oder ein zusaetzlicher Schutzleiter verlegt und die Verbindung ueberwacht.", HazardCategory: "electrical", Examples: []string{"Schutzrohr fuer Schutzleiter an Frequenzumrichter", "Doppelter Schutzleiter mit Ueberwachung"}, NormReferences: []string{"IEC 60204-1 Ziff. 8.2.6"}},
|
||||
{ID: "M477", ReductionType: "design", SubType: "electrical_safety", Name: "Dimensionierung von Luft- und Kriechstrecken", Description: "Luft- und Kriechstrecken werden entsprechend der elektrischen Beanspruchung und Verschmutzungsgrad dimensioniert um Kurzschluesse und gefaehrliche Beruehrungsspannungen zu vermeiden.", HazardCategory: "electrical", Examples: []string{"Mindestabstaende in Schaltgeraetekombinationen einhalten", "Isolationsueberwachung installieren"}, NormReferences: []string{"IEC 60204-1 Ziff. 6.2", "IEC 61439-1"}},
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// EMV-Sicherheit
|
||||
// Gap: GT-Benchmark 6.1 (EMV-Stoereinfluss auf Sicherheitsfunktionen)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
{ID: "M478", ReductionType: "design", SubType: "emc_safety", Name: "EMV-konforme Installation und Verkabelung", Description: "Alle sicherheitsrelevanten Komponenten und Sub-Systeme werden nach EMV-Richtlinien installiert und verkabelt um Stoereinfluss auf Sicherheitsfunktionen zu verhindern.", HazardCategory: "electrical", Examples: []string{"Geschirmte Steuerleitungen verwenden", "Getrennte Kabelkanaele fuer Leistungs- und Signalleitungen"}, NormReferences: []string{"IEC 61000-6-2", "EN 16090-1 Ziff. 5.8.7"}},
|
||||
{ID: "M479", ReductionType: "design", SubType: "emc_safety", Name: "EMV-Pruefung sicherheitsrelevanter Systeme", Description: "Sicherheitsrelevante Steuerungen und Antriebe werden auf Stoerfestigkeit gegenueber elektromagnetischen Einflussgroessen geprueft.", HazardCategory: "electrical", Examples: []string{"Burst/Surge-Pruefung nach IEC 61000-4", "Stoerfestigkeitspruefung der Sicherheits-SPS"}, NormReferences: []string{"IEC 61000-4-4", "IEC 61000-4-5", "IEC 62061"}},
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Kuehlschmierstoff-Leitungssicherheit
|
||||
// Gap: GT-Benchmark 2.10 (KSS-Leckage fuehrt zu Brand)
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
{ID: "M480", ReductionType: "design", SubType: "fluid_safety", Name: "Druckfeste Auslegung von KSS-Leitungen", Description: "Schlaeuche, Dichtungen, Verbindungsstuecke und Befestigungen des Kuehlschmierstoffsystems werden auf den Nenndruck der jeweiligen Komponente ausgelegt und gegen Abspringen gesichert.", HazardCategory: "mechanical", Examples: []string{"Druckschlaeuche auf maximalen Betriebsdruck dimensionieren", "Schlauchbruchsicherungen an kritischen Verbindungen"}, NormReferences: []string{"IEC 60204-1 Ziff. 11.3", "EN ISO 4414"}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package iace
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestHP1640_ResolvesToContactProtection pins the GT-2.2 fix: the "direct
|
||||
// contact with live parts" pattern must resolve to electrical-contact-protection
|
||||
// measures (basic protection, double insulation, earthing, equipotential
|
||||
// bonding), not to mechanical fallbacks like chip extraction.
|
||||
func TestHP1640_ResolvesToContactProtection(t *testing.T) {
|
||||
measureByID := make(map[string]ProtectiveMeasureEntry)
|
||||
for _, m := range GetProtectiveMeasureLibrary() {
|
||||
measureByID[m.ID] = m
|
||||
}
|
||||
|
||||
patterns := GetRobotCellPatterns()
|
||||
var hp1640 *HazardPattern
|
||||
for i := range patterns {
|
||||
if patterns[i].ID == "HP1640" {
|
||||
hp1640 = &patterns[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hp1640 == nil {
|
||||
t.Fatal("HP1640 not found in robot cell patterns")
|
||||
}
|
||||
|
||||
if len(hp1640.SuggestedMeasureIDs) < 3 {
|
||||
t.Errorf("HP1640 should suggest at least 3 measures, got %d", len(hp1640.SuggestedMeasureIDs))
|
||||
}
|
||||
|
||||
for _, mid := range hp1640.SuggestedMeasureIDs {
|
||||
m, ok := measureByID[mid]
|
||||
if !ok {
|
||||
t.Errorf("HP1640 references non-existent measure %s", mid)
|
||||
continue
|
||||
}
|
||||
if m.HazardCategory != "electrical" {
|
||||
t.Errorf("HP1640 measure %s (%q) has HazardCategory=%s, expected electrical",
|
||||
mid, m.Name, m.HazardCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHP1688_M475IsPotentialausgleich pins the M475 rename: HP1688 (touch
|
||||
// voltage from potential differences) must resolve M475 to the equipotential
|
||||
// bonding measure, not to the metalworking chip extraction that previously
|
||||
// occupied M410 and overwrote the electrical definition.
|
||||
func TestHP1688_M475IsPotentialausgleich(t *testing.T) {
|
||||
measureByID := make(map[string]ProtectiveMeasureEntry)
|
||||
for _, m := range GetProtectiveMeasureLibrary() {
|
||||
measureByID[m.ID] = m
|
||||
}
|
||||
|
||||
m, ok := measureByID["M475"]
|
||||
if !ok {
|
||||
t.Fatal("M475 not defined — supplementary rename did not land")
|
||||
}
|
||||
if m.HazardCategory != "electrical" {
|
||||
t.Errorf("M475 must be HazardCategory=electrical, got %s (%q)", m.HazardCategory, m.Name)
|
||||
}
|
||||
|
||||
patterns := GetRobotCellPatternsExt()
|
||||
var hp1688 *HazardPattern
|
||||
for i := range patterns {
|
||||
if patterns[i].ID == "HP1688" {
|
||||
hp1688 = &patterns[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hp1688 == nil {
|
||||
t.Fatal("HP1688 not found in robot cell ext patterns")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, mid := range hp1688.SuggestedMeasureIDs {
|
||||
if mid == "M475" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("HP1688 must reference M475 (Potentialausgleich), got %v", hp1688.SuggestedMeasureIDs)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,17 @@ func matchNorm(norm NormReference, machineType string, hazardSet, tagSet map[str
|
||||
}
|
||||
}
|
||||
|
||||
// groupByType sorts suggestions by confidence and groups them by norm type.
|
||||
// Per-type caps for norm suggestions to avoid overwhelming the user.
|
||||
// A professional typically references 3-5 A-norms, 5-10 B-norms, and 3-8 C-norms.
|
||||
const (
|
||||
maxANorms = 5
|
||||
maxB1Norms = 8
|
||||
maxB2Norms = 10
|
||||
maxCNorms = 10
|
||||
)
|
||||
|
||||
// groupByType sorts suggestions by confidence, groups them by norm type,
|
||||
// and applies per-type caps to keep the list manageable.
|
||||
func groupByType(suggestions []NormSuggestion) *NormSuggestionResult {
|
||||
sort.Slice(suggestions, func(i, j int) bool {
|
||||
return suggestions[i].Confidence > suggestions[j].Confidence
|
||||
@@ -158,17 +168,25 @@ func groupByType(suggestions []NormSuggestion) *NormSuggestionResult {
|
||||
for _, s := range suggestions {
|
||||
switch s.Norm.NormType {
|
||||
case "A":
|
||||
result.ANorms = append(result.ANorms, s)
|
||||
if len(result.ANorms) < maxANorms {
|
||||
result.ANorms = append(result.ANorms, s)
|
||||
}
|
||||
case "B1":
|
||||
result.B1Norms = append(result.B1Norms, s)
|
||||
if len(result.B1Norms) < maxB1Norms {
|
||||
result.B1Norms = append(result.B1Norms, s)
|
||||
}
|
||||
case "B2":
|
||||
result.B2Norms = append(result.B2Norms, s)
|
||||
if len(result.B2Norms) < maxB2Norms {
|
||||
result.B2Norms = append(result.B2Norms, s)
|
||||
}
|
||||
case "C":
|
||||
result.CNorms = append(result.CNorms, s)
|
||||
if len(result.CNorms) < maxCNorms {
|
||||
result.CNorms = append(result.CNorms, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Total = len(suggestions)
|
||||
result.Total = len(result.ANorms) + len(result.B1Norms) + len(result.B2Norms) + len(result.CNorms)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ type PatternMatch struct {
|
||||
HumanRoles []string `json:"human_roles,omitempty"`
|
||||
GeneratedHazardType string `json:"generated_hazard_type,omitempty"`
|
||||
MatchedFailureModes []string `json:"matched_failure_modes,omitempty"`
|
||||
ApplicableLifecycles []string `json:"applicable_lifecycles,omitempty"`
|
||||
SuggestedMeasureIDs []string `json:"suggested_measure_ids,omitempty"`
|
||||
}
|
||||
|
||||
// HazardSuggestion is a suggested hazard from pattern matching.
|
||||
@@ -94,44 +96,11 @@ type PatternEngine struct {
|
||||
}
|
||||
|
||||
// NewPatternEngine creates a PatternEngine with all pattern sources and resolver.
|
||||
// Pattern registration is in pattern_registry.go (collectAllPatterns).
|
||||
func NewPatternEngine() *PatternEngine {
|
||||
// Combine all pattern sources
|
||||
patterns := GetBuiltinHazardPatterns() // HP001-HP044
|
||||
patterns = append(patterns, GetExtendedHazardPatterns()...) // HP045+ from rule library
|
||||
patterns = append(patterns, GetPressHazardPatterns()...) // HP045-HP058 press-specific
|
||||
patterns = append(patterns, GetCobotHazardPatterns()...) // HP059-HP065 cobot-specific
|
||||
patterns = append(patterns, GetOperationalHazardPatterns()...) // HP066-HP093 operational states
|
||||
patterns = append(patterns, GetDGUVExtendedPatterns()...) // HP094-HP133 DGUV themes
|
||||
patterns = append(patterns, GetExtendedHazardPatterns2()...) // HP134-HP173 additional hazards
|
||||
patterns = append(patterns, GetElevatorPatterns()...) // HP174-HP198 elevator/lift
|
||||
patterns = append(patterns, GetAGVAgriPatterns()...) // HP199-HP228 AGV + agricultural
|
||||
patterns = append(patterns, GetFoodProcessingPatterns()...) // HP300-HP319 food processing
|
||||
patterns = append(patterns, GetPackagingPatterns()...) // HP320-HP334 packaging machines
|
||||
patterns = append(patterns, GetLaserPatterns()...) // HP335-HP349 laser machines
|
||||
patterns = append(patterns, GetMedicalDevicePatterns()...) // HP350-HP364 medical devices (IEC 60601)
|
||||
patterns = append(patterns, GetPressureEquipmentPatterns()...) // HP365-HP374 pressure equipment
|
||||
patterns = append(patterns, GetConstructionPatterns()...) // HP400-HP419 construction/crane
|
||||
patterns = append(patterns, GetForestryConveyorPatterns()...) // HP420-HP450 forestry/conveyor
|
||||
patterns = append(patterns, GetPlasticsMetalPatterns()...) // HP500-HP529 plastics + metalworking
|
||||
patterns = append(patterns, GetWeldingGlassTextilePatterns()...) // HP530-HP559 welding + glass + textile
|
||||
patterns = append(patterns, GetSpecificMachinePatterns()...) // HP730-HP755 pressure/wind/solar/battery
|
||||
patterns = append(patterns, GetSpecificMachinePatterns2()...) // HP756-HP784 escalator/pool/playground/fitness/laundry/glass
|
||||
patterns = append(patterns, GetCyberExtendedPatterns()...) // HP800-HP829 software faults/cyber-security
|
||||
patterns = append(patterns, GetCyberExtendedPatterns2()...) // HP830-HP844 AI-ML specific
|
||||
patterns = append(patterns, GetCyberExtendedPatterns3()...) // HP845-HP864 network/communication + HMI
|
||||
patterns = append(patterns, GetWorkshopPatterns()...) // HP600-HP664 cross-machine workshop
|
||||
patterns = append(patterns, GetMaintenanceExtPatterns()...) // HP700-HP729,HP900-HP934 maintenance lifecycle
|
||||
patterns = append(patterns, GetFinalPatternsA()...) // HP1000-HP1084 mechanical body-part variants
|
||||
patterns = append(patterns, GetFinalPatternsB()...) // HP1085-HP1169 electrical/thermal/chemical/bio/radiation
|
||||
patterns = append(patterns, GetFinalPatternsC()...) // HP1170-HP1254 software/control/org/ergonomic/fire
|
||||
patterns = append(patterns, GetFinalPatternsD()...) // HP1255-HP1335 lifecycle/special situations
|
||||
patterns = append(patterns, GetCNCHazardPatterns()...) // HP1400-HP1419 CNC/metalworking part 1 (Phase 3)
|
||||
patterns = append(patterns, GetCNCHazardPatternsExt()...) // HP1420-HP1434 CNC/metalworking part 2 (Phase 3)
|
||||
patterns = append(patterns, GetVDMAIndustryPatterns()...) // HP1500-HP1549 VDMA sectors (Phase 3)
|
||||
patterns = append(patterns, GetTextileAgriPatterns()...) // HP1550-HP1584 Textile + Agri (Phase 5)
|
||||
return &PatternEngine{
|
||||
resolver: NewTagResolver(),
|
||||
patterns: patterns,
|
||||
patterns: collectAllPatterns(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +219,9 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
|
||||
StateTransitions: p.StateTransitions,
|
||||
HumanRoles: p.HumanRoles,
|
||||
GeneratedHazardType: p.GeneratedHazardType,
|
||||
MatchedFailureModes: matchedFMs,
|
||||
MatchedFailureModes: matchedFMs,
|
||||
ApplicableLifecycles: p.ApplicableLifecycles,
|
||||
SuggestedMeasureIDs: p.SuggestedMeasureIDs,
|
||||
})
|
||||
|
||||
for _, cat := range p.GeneratedHazardCats {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package iace
|
||||
|
||||
// collectAllPatterns gathers hazard patterns from all registered sources.
|
||||
// This function is called by NewPatternEngine() to build the complete pattern set.
|
||||
// New pattern sources are registered here.
|
||||
func collectAllPatterns() []HazardPattern {
|
||||
patterns := GetBuiltinHazardPatterns() // HP001-HP044
|
||||
patterns = append(patterns, GetExtendedHazardPatterns()...) // HP045+ from rule library
|
||||
patterns = append(patterns, GetPressHazardPatterns()...) // HP045-HP058 press-specific
|
||||
patterns = append(patterns, GetCobotHazardPatterns()...) // HP059-HP065 cobot-specific
|
||||
patterns = append(patterns, GetOperationalHazardPatterns()...) // HP066-HP093 operational states
|
||||
patterns = append(patterns, GetDGUVExtendedPatterns()...) // HP094-HP133 DGUV themes
|
||||
patterns = append(patterns, GetExtendedHazardPatterns2()...) // HP134-HP173 additional hazards
|
||||
patterns = append(patterns, GetElevatorPatterns()...) // HP174-HP198 elevator/lift
|
||||
patterns = append(patterns, GetAGVAgriPatterns()...) // HP199-HP228 AGV + agricultural
|
||||
patterns = append(patterns, GetFoodProcessingPatterns()...) // HP300-HP319 food processing
|
||||
patterns = append(patterns, GetPackagingPatterns()...) // HP320-HP334 packaging machines
|
||||
patterns = append(patterns, GetLaserPatterns()...) // HP335-HP349 laser machines
|
||||
patterns = append(patterns, GetMedicalDevicePatterns()...) // HP350-HP364 medical devices (IEC 60601)
|
||||
patterns = append(patterns, GetPressureEquipmentPatterns()...) // HP365-HP374 pressure equipment
|
||||
patterns = append(patterns, GetConstructionPatterns()...) // HP400-HP419 construction/crane
|
||||
patterns = append(patterns, GetForestryConveyorPatterns()...) // HP420-HP450 forestry/conveyor
|
||||
patterns = append(patterns, GetPlasticsMetalPatterns()...) // HP500-HP529 plastics + metalworking
|
||||
patterns = append(patterns, GetWeldingGlassTextilePatterns()...) // HP530-HP559 welding + glass + textile
|
||||
patterns = append(patterns, GetSpecificMachinePatterns()...) // HP730-HP755 pressure/wind/solar/battery
|
||||
patterns = append(patterns, GetSpecificMachinePatterns2()...) // HP756-HP784 escalator/pool/playground/fitness/laundry/glass
|
||||
patterns = append(patterns, GetCyberExtendedPatterns()...) // HP800-HP829 software faults/cyber-security
|
||||
patterns = append(patterns, GetCyberExtendedPatterns2()...) // HP830-HP844 AI-ML specific
|
||||
patterns = append(patterns, GetCyberExtendedPatterns3()...) // HP845-HP864 network/communication + HMI
|
||||
patterns = append(patterns, GetWorkshopPatterns()...) // HP600-HP664 cross-machine workshop
|
||||
patterns = append(patterns, GetMaintenanceExtPatterns()...) // HP700-HP729,HP900-HP934 maintenance lifecycle
|
||||
patterns = append(patterns, GetFinalPatternsA()...) // HP1000-HP1084 mechanical body-part variants
|
||||
patterns = append(patterns, GetFinalPatternsB()...) // HP1085-HP1169 electrical/thermal/chemical/bio/radiation
|
||||
patterns = append(patterns, GetFinalPatternsC()...) // HP1170-HP1254 software/control/org/ergonomic/fire
|
||||
patterns = append(patterns, GetFinalPatternsD()...) // HP1255-HP1335 lifecycle/special situations
|
||||
patterns = append(patterns, GetCNCHazardPatterns()...) // HP1400-HP1419 CNC/metalworking part 1 (Phase 3)
|
||||
patterns = append(patterns, GetCNCHazardPatternsExt()...) // HP1420-HP1434 CNC/metalworking part 2 (Phase 3)
|
||||
patterns = append(patterns, GetVDMAIndustryPatterns()...) // HP1500-HP1549 VDMA sectors (Phase 3)
|
||||
patterns = append(patterns, GetTextileAgriPatterns()...) // HP1550-HP1584 Textile + Agri (Phase 5)
|
||||
patterns = append(patterns, GetRobotCellPatterns()...) // HP1600-HP1649 Robot cell (GT benchmark)
|
||||
patterns = append(patterns, GetRobotCellPatternsExt()...) // HP1650-HP1699 Robot cell extended (GT gaps)
|
||||
return patterns
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package iace
|
||||
|
||||
// RiskAssessmentMethodologyDE contains the German-language methodology introduction
|
||||
// ("Erklaerteil") that is prepended to every risk assessment export.
|
||||
//
|
||||
// This text is the single source of truth — it is used by:
|
||||
// - PDF export (document_export_pdf.go)
|
||||
// - Markdown export (document_export.go)
|
||||
// - Frontend print view (ReportPrintView.tsx mirrors this content)
|
||||
//
|
||||
// The methodology is BreakPilot's own formulation, inspired by the general
|
||||
// principles of EN ISO 12100, EN 62061, and EN ISO 13849-1.
|
||||
// No normative text is reproduced.
|
||||
const RiskAssessmentMethodologyDE = `Methodik der Risikobeurteilung
|
||||
|
||||
Diese Risikobeurteilung orientiert sich an den Grundprinzipien der EN ISO 12100, EN 62061 und EN ISO 13849-1. Bewertet werden Grenzen des Produkts, identifizierte Gefaehrdungen, die jeweilige Risikohoehe sowie das Restrisiko nach Anwendung von Schutzmassnahmen.
|
||||
|
||||
Der Prozess ist iterativ: Reicht eine Massnahme nicht aus, werden weitere ergriffen und das Restrisiko erneut bewertet, bis ein akzeptables Niveau erreicht ist. Werden mehrere Massnahmen gemeinsam umgesetzt, erfolgt eine Gesamtbewertung. Wurde die Wirksamkeit einzelner Massnahmen gesondert betrachtet, wird das Restrisiko stufenweise ausgewiesen.
|
||||
|
||||
Risikoberechnung
|
||||
|
||||
Das Ausgangsrisiko ergibt sich aus:
|
||||
|
||||
R = S x F x P x A
|
||||
|
||||
S = Schadensschwere (1-5): erwartbare Verletzungsschwere (Erste Hilfe bis toedlich)
|
||||
F = Expositionshaeufigkeit (1-5): Haeufigkeit und Dauer der Exposition (selten/kurz bis dauerhaft)
|
||||
P = Eintrittswahrscheinlichkeit (1-5): technische Ausfallwahrscheinlichkeit und menschliches Verhalten (vernachlaessigbar bis fast sicher)
|
||||
A = Vermeidbarkeit (1-5): Erkennbarkeit, Reaktionszeit, raeumliche Ausweichmoeglichkeit (leicht vermeidbar bis unvermeidbar)
|
||||
|
||||
Das Restrisiko beruecksichtigt die Wirksamkeit umgesetzter Massnahmen (Reifegrad, Abdeckungsgrad, Verifikationsstand).
|
||||
|
||||
Bei sicherheitstechnischen Steuerungskreisen wird zusaetzlich der erforderliche Performance Level (PLr) ueber einen Risikographen abgeleitet und dem entsprechenden Safety Integrity Level (SIL) zugeordnet. Die Verifikation erfolgt durch den zustaendigen Functional-Safety-Ingenieur.
|
||||
|
||||
Massnahmen nach Dreistufenmethode
|
||||
|
||||
Schutzmassnahmen werden priorisiert angewandt:
|
||||
1. Konstruktive Massnahmen (KM) — Inhaerent sichere Gestaltung
|
||||
2. Technische Schutzmassnahmen (TM) — Schutzeinrichtungen, Sicherheitssteuerungen
|
||||
3. Benutzerinformationen (BI) — Warnhinweise, Betriebsanleitung
|
||||
|
||||
Benutzerinformationen allein sind keine ausreichende Primaermassnahme.
|
||||
|
||||
Akzeptanz des Restrisikos
|
||||
|
||||
Ein Restrisiko gilt als hinreichend gemindert, wenn alle praktisch umsetzbaren Massnahmen ausgeschoepft wurden, keine neuen Gefaehrdungen durch Schutzmassnahmen entstehen und Anwender ueber verbleibende Restrisiken informiert sind. Massgeblich ist die Verhaeltnismaessigkeit: Je hoeher das Restrisiko, desto hoeher der zumutbare Aufwand.
|
||||
|
||||
Die Akzeptanz wird pro Gefaehrdung mit JA / NEIN dokumentiert. Die Farbcodierung spiegelt den erforderlichen SIL wider — ein rotes Restrisiko bedeutet nicht automatisch, dass weitere Massnahmen noetig sind.
|
||||
|
||||
"Die Moeglichkeit, einen hoeheren Sicherheitsgrad zu erreichen, oder die Verfuegbarkeit anderer Produkte, die ein geringeres Risiko darstellen, ist kein ausreichender Grund, ein Produkt als gefaehrlich anzusehen." (§ 3 Abs. 2 ProdSG)`
|
||||
|
||||
// RiskAssessmentMethodologySection is the section title for the TOC.
|
||||
const RiskAssessmentMethodologySectionTitle = "Methodik der Risikobeurteilung"
|
||||
@@ -233,6 +233,18 @@ func (s *Store) UpdateProjectCompleteness(ctx context.Context, id uuid.UUID, sco
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProjectMetadata replaces the metadata JSON for a project.
|
||||
func (s *Store) UpdateProjectMetadata(ctx context.Context, id uuid.UUID, metadata json.RawMessage) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE iace_projects SET metadata = $2, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, id, metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update project metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVariants returns all variant sub-projects for a given parent project
|
||||
func (s *Store) ListVariants(ctx context.Context, parentID uuid.UUID) ([]Project, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// Compiler turns Master Controls into audit questionnaires.
|
||||
type Compiler struct {
|
||||
store *Store
|
||||
llmGen *LLMQuestionGenerator
|
||||
}
|
||||
|
||||
// NewCompiler creates a Compiler with optional LLM generator.
|
||||
func NewCompiler(store *Store, llmGen *LLMQuestionGenerator) *Compiler {
|
||||
return &Compiler{store: store, llmGen: llmGen}
|
||||
}
|
||||
|
||||
// Compile generates questions for a template.
|
||||
//
|
||||
// Flow (per Plan):
|
||||
// 1. Fetch MCs matching template filters from DB
|
||||
// 2. For each MC: check doc_check_controls → Mode A (deterministic)
|
||||
// 3. For remaining MCs: use LLM → Mode B
|
||||
// 4. For remaining MCs: derive from MC name → Mode A fallback
|
||||
// 5. Template hardcoded questions = absolute fallback if DB returns nothing
|
||||
func (c *Compiler) Compile(tmpl *Template) ([]Question, error) {
|
||||
// 1. Fetch MCs matching the template filters
|
||||
mcs, err := c.store.FetchMCsByFilters(tmpl.MCFilters)
|
||||
if err != nil {
|
||||
log.Printf("usecase: MC fetch failed: %v, falling back to template questions", err)
|
||||
return c.templateFallback(tmpl), nil
|
||||
}
|
||||
|
||||
if len(mcs) == 0 {
|
||||
// No MCs in DB for these filters → use hardcoded template questions
|
||||
if len(tmpl.Questions) > 0 {
|
||||
return tmpl.Questions, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no Master Controls found for filters %v", tmpl.MCFilters)
|
||||
}
|
||||
|
||||
// 2. Check for existing doc_check_controls
|
||||
mcIDs := make([]string, len(mcs))
|
||||
for i, mc := range mcs {
|
||||
mcIDs[i] = mc.MasterControlID
|
||||
}
|
||||
|
||||
checkQuestions, _ := c.store.FetchCheckQuestions(mcIDs)
|
||||
|
||||
// 3. Build questions: doc_check → LLM → deterministic
|
||||
var questions []Question
|
||||
var mcsWithoutQuestions []MCInfo
|
||||
qNum := 1
|
||||
|
||||
for _, mc := range mcs {
|
||||
// Mode A: existing doc_check_controls
|
||||
if cqs, ok := checkQuestions[mc.MasterControlID]; ok && len(cqs) > 0 {
|
||||
for _, cq := range cqs {
|
||||
questions = append(questions, Question{
|
||||
ID: fmt.Sprintf("Q%d", qNum),
|
||||
MCID: mc.MasterControlID,
|
||||
MCName: mc.CanonicalName,
|
||||
Text: cq.Question,
|
||||
QuestionType: "yes_no",
|
||||
Severity: normalizeSeverity(cq.Severity),
|
||||
Regulation: mc.RegSource,
|
||||
PassCriteria: splitCriteria(cq.PassCriteria),
|
||||
FailCriteria: splitCriteria(cq.FailCriteria),
|
||||
})
|
||||
qNum++
|
||||
}
|
||||
continue
|
||||
}
|
||||
mcsWithoutQuestions = append(mcsWithoutQuestions, mc)
|
||||
}
|
||||
|
||||
// Mode B: LLM for MCs without doc_check_controls
|
||||
if len(mcsWithoutQuestions) > 0 && c.llmGen != nil {
|
||||
llmQuestions, err := c.llmGen.GenerateQuestions(mcsWithoutQuestions, tmpl.Regulations)
|
||||
if err == nil && len(llmQuestions) > 0 {
|
||||
// Renumber
|
||||
for i := range llmQuestions {
|
||||
llmQuestions[i].ID = fmt.Sprintf("Q%d", qNum)
|
||||
qNum++
|
||||
}
|
||||
questions = append(questions, llmQuestions...)
|
||||
mcsWithoutQuestions = nil // all handled
|
||||
} else if err != nil {
|
||||
log.Printf("usecase: LLM generation failed: %v, using deterministic fallback", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode A fallback: deterministic derivation for remaining MCs
|
||||
for _, mc := range mcsWithoutQuestions {
|
||||
questions = append(questions, Question{
|
||||
ID: fmt.Sprintf("Q%d", qNum),
|
||||
MCID: mc.MasterControlID,
|
||||
MCName: mc.CanonicalName,
|
||||
Text: deriveQuestion(mc.CanonicalName),
|
||||
QuestionType: "yes_no",
|
||||
Severity: inferMCSeverity(mc.CanonicalName),
|
||||
Regulation: mc.RegSource,
|
||||
PassCriteria: []string{"Anforderung erfuellt und dokumentiert"},
|
||||
FailCriteria: []string{"Nicht implementiert oder nicht nachweisbar"},
|
||||
})
|
||||
qNum++
|
||||
|
||||
if qNum > 50 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: add template hardcoded questions that cover topics not yet covered
|
||||
if len(tmpl.Questions) > 0 {
|
||||
questions = mergeTemplateQuestions(questions, tmpl.Questions, qNum)
|
||||
}
|
||||
|
||||
if len(questions) == 0 {
|
||||
return c.templateFallback(tmpl), nil
|
||||
}
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// templateFallback returns hardcoded template questions or an error.
|
||||
func (c *Compiler) templateFallback(tmpl *Template) []Question {
|
||||
if len(tmpl.Questions) > 0 {
|
||||
return tmpl.Questions
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeTemplateQuestions adds template questions that aren't already
|
||||
// covered by MC-compiled questions (matched by keyword overlap).
|
||||
func mergeTemplateQuestions(compiled, template []Question, nextNum int) []Question {
|
||||
// Build set of covered MC topics
|
||||
coveredTopics := make(map[string]bool)
|
||||
for _, q := range compiled {
|
||||
if q.MCName != "" {
|
||||
coveredTopics[q.MCName] = true
|
||||
}
|
||||
// Also index key words from the question text
|
||||
for _, w := range extractKeywords(q.Text) {
|
||||
coveredTopics[w] = true
|
||||
}
|
||||
}
|
||||
|
||||
qNum := nextNum
|
||||
for _, tq := range template {
|
||||
// Check if this template question's topic is already covered
|
||||
keywords := extractKeywords(tq.Text)
|
||||
covered := false
|
||||
for _, kw := range keywords {
|
||||
if coveredTopics[kw] {
|
||||
covered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if covered {
|
||||
continue
|
||||
}
|
||||
|
||||
tq.ID = fmt.Sprintf("Q%d", qNum)
|
||||
compiled = append(compiled, tq)
|
||||
qNum++
|
||||
}
|
||||
|
||||
return compiled
|
||||
}
|
||||
|
||||
// extractKeywords pulls significant words from a question for dedup.
|
||||
func extractKeywords(text string) []string {
|
||||
stopwords := map[string]bool{
|
||||
"ist": true, "hat": true, "gibt": true, "es": true, "ein": true,
|
||||
"eine": true, "der": true, "die": true, "das": true, "den": true,
|
||||
"dem": true, "des": true, "oder": true, "und": true, "fuer": true,
|
||||
"nach": true, "mit": true, "von": true, "zu": true, "auf": true,
|
||||
"in": true, "an": true, "bei": true, "werden": true, "wird": true,
|
||||
"sind": true, "nicht": true, "nur": true, "auch": true,
|
||||
}
|
||||
|
||||
words := strings.Fields(strings.ToLower(text))
|
||||
var keywords []string
|
||||
for _, w := range words {
|
||||
w = strings.Trim(w, "?.,;:!\"'()")
|
||||
if len(w) > 3 && !stopwords[w] {
|
||||
keywords = append(keywords, w)
|
||||
}
|
||||
}
|
||||
return keywords
|
||||
}
|
||||
|
||||
// deriveQuestion generates a human-readable question from an MC name.
|
||||
func deriveQuestion(canonicalName string) string {
|
||||
readable := strings.ReplaceAll(canonicalName, "_", " ")
|
||||
readable = cases.Title(language.German).String(readable)
|
||||
return fmt.Sprintf("Ist '%s' implementiert und dokumentiert?", readable)
|
||||
}
|
||||
|
||||
// splitCriteria splits a pipe-separated criteria string.
|
||||
func splitCriteria(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, "|")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return []string{s}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeSeverity maps doc_check severity to our format.
|
||||
func normalizeSeverity(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
switch s {
|
||||
case "HIGH", "CRITICAL":
|
||||
return "HIGH"
|
||||
case "MEDIUM":
|
||||
return "MEDIUM"
|
||||
case "LOW":
|
||||
return "LOW"
|
||||
default:
|
||||
return "MEDIUM"
|
||||
}
|
||||
}
|
||||
|
||||
// inferMCSeverity guesses severity from the MC topic name.
|
||||
func inferMCSeverity(name string) string {
|
||||
high := []string{"encryption", "access_control", "incident", "vulnerability",
|
||||
"authentication", "key_management", "data_breach", "personal_data",
|
||||
"consent", "data_transfer"}
|
||||
for _, h := range high {
|
||||
if strings.Contains(name, h) {
|
||||
return "HIGH"
|
||||
}
|
||||
}
|
||||
return "MEDIUM"
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
||||
)
|
||||
|
||||
// LLMQuestionGenerator uses an LLM to create questions from MC metadata
|
||||
// when no pre-defined questions or doc_check_controls exist (Mode B).
|
||||
type LLMQuestionGenerator struct {
|
||||
registry *llm.ProviderRegistry
|
||||
}
|
||||
|
||||
// NewLLMQuestionGenerator creates a new LLM-based generator.
|
||||
func NewLLMQuestionGenerator(registry *llm.ProviderRegistry) *LLMQuestionGenerator {
|
||||
return &LLMQuestionGenerator{registry: registry}
|
||||
}
|
||||
|
||||
// llmQuestion is the JSON structure we expect from the LLM.
|
||||
type llmQuestion struct {
|
||||
MCName string `json:"mc_name"`
|
||||
Question string `json:"question"`
|
||||
PassCriteria []string `json:"pass_criteria"`
|
||||
FailCriteria []string `json:"fail_criteria"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
// maxLLMMCs limits how many MCs we send to the LLM in one batch.
|
||||
const maxLLMMCs = 5
|
||||
|
||||
// GenerateQuestions generates questions for MCs using a single batched LLM call.
|
||||
func (g *LLMQuestionGenerator) GenerateQuestions(mcs []MCInfo, regulations []string) ([]Question, error) {
|
||||
if g.registry == nil {
|
||||
return nil, fmt.Errorf("no LLM provider configured")
|
||||
}
|
||||
|
||||
// Limit batch size
|
||||
batch := mcs
|
||||
if len(batch) > maxLLMMCs {
|
||||
batch = batch[:maxLLMMCs]
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
|
||||
prompt := buildBatchPrompt(batch, regulations)
|
||||
|
||||
resp, err := g.registry.Chat(ctx, &llm.ChatRequest{
|
||||
Messages: []llm.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
{Role: "user", Content: prompt},
|
||||
},
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
parsed := parseLLMResponse(resp.Message.Content)
|
||||
if len(parsed) == 0 {
|
||||
return nil, fmt.Errorf("LLM returned no valid questions")
|
||||
}
|
||||
|
||||
// Map parsed questions back to MCs
|
||||
mcByName := make(map[string]MCInfo)
|
||||
for _, mc := range batch {
|
||||
mcByName[mc.CanonicalName] = mc
|
||||
}
|
||||
|
||||
var questions []Question
|
||||
for _, lq := range parsed {
|
||||
mc, ok := mcByName[lq.MCName]
|
||||
if !ok {
|
||||
// Try fuzzy match
|
||||
for name, m := range mcByName {
|
||||
if strings.Contains(lq.MCName, name) || strings.Contains(name, lq.MCName) {
|
||||
mc = m
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
q := Question{
|
||||
Text: lq.Question,
|
||||
QuestionType: "yes_no",
|
||||
Severity: normalizeSeverity(lq.Severity),
|
||||
PassCriteria: lq.PassCriteria,
|
||||
FailCriteria: lq.FailCriteria,
|
||||
}
|
||||
if ok {
|
||||
q.MCID = mc.MasterControlID
|
||||
q.MCName = mc.CanonicalName
|
||||
q.Regulation = mc.RegSource
|
||||
}
|
||||
questions = append(questions, q)
|
||||
}
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
const systemPrompt = `Du bist ein Compliance-Experte. Generiere praezise Prueffragen fuer Compliance-Audits.
|
||||
|
||||
Antworte NUR mit einem JSON-Array. Jedes Element hat:
|
||||
- "mc_name": Der canonical_name des Master Controls (exakt wie im Input)
|
||||
- "question": Eine klare Ja/Nein-Frage auf Deutsch
|
||||
- "pass_criteria": Array mit 1-2 Kriterien fuer "bestanden"
|
||||
- "fail_criteria": Array mit 1-2 Kriterien fuer "nicht bestanden"
|
||||
- "severity": "HIGH", "MEDIUM" oder "LOW"
|
||||
|
||||
Generiere 1 Frage pro Master Control. Keine Erklaerungen, nur das JSON-Array.`
|
||||
|
||||
func buildBatchPrompt(mcs []MCInfo, regulations []string) string {
|
||||
regStr := strings.Join(regulations, ", ")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Regulierungen: %s\n\nMaster Controls:\n", regStr))
|
||||
|
||||
for i, mc := range mcs {
|
||||
readable := strings.ReplaceAll(mc.CanonicalName, "_", " ")
|
||||
sb.WriteString(fmt.Sprintf("%d. mc_name=%q (%d Controls, Quelle: %s)\n",
|
||||
i+1, mc.CanonicalName, mc.TotalControls, mc.RegSource))
|
||||
_ = readable
|
||||
}
|
||||
|
||||
sb.WriteString("\nGeneriere je 1 Prueffrage pro Master Control.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildPrompt(mc MCInfo, regulations []string) string {
|
||||
readable := strings.ReplaceAll(mc.CanonicalName, "_", " ")
|
||||
regStr := strings.Join(regulations, ", ")
|
||||
|
||||
return fmt.Sprintf(
|
||||
`Master Control: "%s" (%d Atomic Controls)
|
||||
Regulierungen: %s
|
||||
Regulation Source: %s
|
||||
|
||||
Generiere 1-2 praezise Prueffragen fuer diesen Master Control.`,
|
||||
readable, mc.TotalControls, regStr, mc.RegSource)
|
||||
}
|
||||
|
||||
func parseLLMResponse(content string) []llmQuestion {
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
// Try to find JSON array in the response
|
||||
start := strings.Index(content, "[")
|
||||
end := strings.LastIndex(content, "]")
|
||||
if start >= 0 && end > start {
|
||||
content = content[start : end+1]
|
||||
}
|
||||
|
||||
var questions []llmQuestion
|
||||
if err := json.Unmarshal([]byte(content), &questions); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate
|
||||
var valid []llmQuestion
|
||||
for _, q := range questions {
|
||||
if q.Question != "" && len(q.PassCriteria) > 0 {
|
||||
valid = append(valid, q)
|
||||
}
|
||||
}
|
||||
return valid
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLLMResponse_ValidJSON(t *testing.T) {
|
||||
input := `[
|
||||
{
|
||||
"question": "Ist eine Datenschutz-Folgenabschaetzung durchgefuehrt?",
|
||||
"pass_criteria": ["DSFA dokumentiert"],
|
||||
"fail_criteria": ["Keine DSFA"],
|
||||
"severity": "HIGH"
|
||||
},
|
||||
{
|
||||
"question": "Sind Betroffenenrechte implementiert?",
|
||||
"pass_criteria": ["Prozess vorhanden"],
|
||||
"fail_criteria": ["Kein Prozess"],
|
||||
"severity": "MEDIUM"
|
||||
}
|
||||
]`
|
||||
|
||||
result := parseLLMResponse(input)
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("Expected 2 questions, got %d", len(result))
|
||||
}
|
||||
if result[0].Question != "Ist eine Datenschutz-Folgenabschaetzung durchgefuehrt?" {
|
||||
t.Errorf("Unexpected question: %s", result[0].Question)
|
||||
}
|
||||
if result[0].Severity != "HIGH" {
|
||||
t.Errorf("Expected HIGH severity, got %s", result[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLLMResponse_WithPreamble(t *testing.T) {
|
||||
input := `Hier sind die Prueffragen:
|
||||
|
||||
[{"question":"Test?","pass_criteria":["OK"],"fail_criteria":["NOK"],"severity":"LOW"}]
|
||||
|
||||
Ich hoffe das hilft.`
|
||||
|
||||
result := parseLLMResponse(input)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 question from wrapped response, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLLMResponse_InvalidJSON(t *testing.T) {
|
||||
result := parseLLMResponse("This is not JSON at all")
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for invalid JSON, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLLMResponse_EmptyQuestion(t *testing.T) {
|
||||
input := `[
|
||||
{"question":"","pass_criteria":["OK"],"fail_criteria":["NOK"],"severity":"HIGH"},
|
||||
{"question":"Valid?","pass_criteria":["Yes"],"fail_criteria":["No"],"severity":"LOW"}
|
||||
]`
|
||||
|
||||
result := parseLLMResponse(input)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 valid question (empty filtered), got %d", len(result))
|
||||
}
|
||||
if result[0].Question != "Valid?" {
|
||||
t.Errorf("Unexpected question: %s", result[0].Question)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt(t *testing.T) {
|
||||
mc := MCInfo{
|
||||
MasterControlID: "MC-123",
|
||||
CanonicalName: "access_control_mfa",
|
||||
TotalControls: 12,
|
||||
RegSource: "NIS2",
|
||||
}
|
||||
|
||||
prompt := buildPrompt(mc, []string{"nis2", "dsgvo"})
|
||||
|
||||
if prompt == "" {
|
||||
t.Error("Expected non-empty prompt")
|
||||
}
|
||||
if !contains(prompt, "access control mfa") {
|
||||
t.Error("Prompt should contain readable MC name")
|
||||
}
|
||||
if !contains(prompt, "12 Atomic Controls") {
|
||||
t.Error("Prompt should contain control count")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsSubstring(s, sub))
|
||||
}
|
||||
|
||||
func containsSubstring(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTemplates_AllPresent(t *testing.T) {
|
||||
expected := []string{
|
||||
"vendor_check_cloud",
|
||||
"sast_dast_audit",
|
||||
"dsgvo_quick_check",
|
||||
"nis2_readiness",
|
||||
"cra_product_check",
|
||||
}
|
||||
|
||||
for _, id := range expected {
|
||||
if _, ok := Templates[id]; !ok {
|
||||
t.Errorf("Template %q missing from Templates map", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplates_HaveQuestions(t *testing.T) {
|
||||
for id, tmpl := range Templates {
|
||||
if len(tmpl.Questions) == 0 {
|
||||
t.Errorf("Template %q has no pre-defined questions", id)
|
||||
}
|
||||
if tmpl.Name == "" {
|
||||
t.Errorf("Template %q has no name", id)
|
||||
}
|
||||
if len(tmpl.MCFilters) == 0 {
|
||||
t.Errorf("Template %q has no MC filters", id)
|
||||
}
|
||||
if len(tmpl.Regulations) == 0 {
|
||||
t.Errorf("Template %q has no regulations", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplates_QuestionIDs_Unique(t *testing.T) {
|
||||
for id, tmpl := range Templates {
|
||||
seen := make(map[string]bool)
|
||||
for _, q := range tmpl.Questions {
|
||||
if seen[q.ID] {
|
||||
t.Errorf("Template %q has duplicate question ID %q", id, q.ID)
|
||||
}
|
||||
seen[q.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateList_ReturnsAll(t *testing.T) {
|
||||
list := TemplateList()
|
||||
if len(list) != len(Templates) {
|
||||
t.Errorf("TemplateList returned %d, expected %d", len(list), len(Templates))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveQuestion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"encryption_at_rest", "Ist 'Encryption At Rest' implementiert und dokumentiert?"},
|
||||
{"access_control", "Ist 'Access Control' implementiert und dokumentiert?"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := deriveQuestion(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("deriveQuestion(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCriteria(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expect int
|
||||
}{
|
||||
{"", 0},
|
||||
{"Single criteria", 1},
|
||||
{"A | B | C", 3},
|
||||
{"A|B", 2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := splitCriteria(tt.input)
|
||||
if got == nil && tt.expect == 0 {
|
||||
continue
|
||||
}
|
||||
if len(got) != tt.expect {
|
||||
t.Errorf("splitCriteria(%q) returned %d items, want %d", tt.input, len(got), tt.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSeverity(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"HIGH", "HIGH"},
|
||||
{"high", "HIGH"},
|
||||
{"CRITICAL", "HIGH"},
|
||||
{"medium", "MEDIUM"},
|
||||
{"LOW", "LOW"},
|
||||
{"unknown", "MEDIUM"},
|
||||
{"", "MEDIUM"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := normalizeSeverity(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeSeverity(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferMCSeverity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"encryption_at_rest_aes256", "HIGH"},
|
||||
{"access_control_mfa", "HIGH"},
|
||||
{"incident_response_plan", "HIGH"},
|
||||
{"documentation_management", "MEDIUM"},
|
||||
{"training_awareness", "MEDIUM"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := inferMCSeverity(tt.name)
|
||||
if got != tt.want {
|
||||
t.Errorf("inferMCSeverity(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFromMC_SmallMC(t *testing.T) {
|
||||
mc := MCInfo{
|
||||
MasterControlID: "MC-123",
|
||||
CanonicalName: "access_control_basic",
|
||||
TotalControls: 3,
|
||||
RegSource: "DSGVO",
|
||||
}
|
||||
|
||||
questions := GenerateFromMC(mc)
|
||||
if len(questions) != 1 {
|
||||
t.Errorf("Expected 1 question for small MC, got %d", len(questions))
|
||||
}
|
||||
if questions[0].Severity != "HIGH" {
|
||||
t.Errorf("Expected HIGH severity for access_control, got %s", questions[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFromMC_MediumMC(t *testing.T) {
|
||||
mc := MCInfo{
|
||||
MasterControlID: "MC-456",
|
||||
CanonicalName: "documentation_management",
|
||||
TotalControls: 8,
|
||||
RegSource: "NIS2",
|
||||
}
|
||||
|
||||
questions := GenerateFromMC(mc)
|
||||
if len(questions) != 2 {
|
||||
t.Errorf("Expected 2 questions for medium MC, got %d", len(questions))
|
||||
}
|
||||
if questions[1].DependsOn == "" {
|
||||
t.Error("Second question should depend on first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFromMC_LargeMC(t *testing.T) {
|
||||
mc := MCInfo{
|
||||
MasterControlID: "MC-789",
|
||||
CanonicalName: "risk_management_framework",
|
||||
TotalControls: 25,
|
||||
RegSource: "NIS2",
|
||||
}
|
||||
|
||||
questions := GenerateFromMC(mc)
|
||||
if len(questions) != 3 {
|
||||
t.Errorf("Expected 3 questions for large MC, got %d", len(questions))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GapDetector identifies missing regulations for a use-case template.
|
||||
type GapDetector struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewGapDetector creates a GapDetector.
|
||||
func NewGapDetector(store *Store) *GapDetector {
|
||||
return &GapDetector{store: store}
|
||||
}
|
||||
|
||||
// DetectMissingRegulations finds MCs with insufficient source citations.
|
||||
func (d *GapDetector) DetectMissingRegulations(tmpl *Template) ([]MissingSource, error) {
|
||||
mcs, err := d.store.FetchMCsByFilters(tmpl.MCFilters)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch MCs: %w", err)
|
||||
}
|
||||
if len(mcs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mcIDs := make([]string, len(mcs))
|
||||
for i, mc := range mcs {
|
||||
mcIDs[i] = mc.MasterControlID
|
||||
}
|
||||
|
||||
citations, err := d.store.CountMCSourceCitations(mcIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("count citations: %w", err)
|
||||
}
|
||||
|
||||
var gaps []MissingSource
|
||||
|
||||
for _, mc := range mcs {
|
||||
citCount := citations[mc.MasterControlID]
|
||||
|
||||
// MC with many controls but few citations → gap
|
||||
if mc.TotalControls > 20 && citCount < 3 {
|
||||
missing := identifyMissingRegulation(mc, tmpl.Regulations)
|
||||
if missing != nil {
|
||||
gaps = append(gaps, *missing)
|
||||
}
|
||||
}
|
||||
|
||||
// MC topic implies a regulation that's not in source citations
|
||||
expectedRegs := expectedRegulations(mc.CanonicalName)
|
||||
for _, expected := range expectedRegs {
|
||||
if !containsRegulation(tmpl.Regulations, expected.regID) {
|
||||
continue
|
||||
}
|
||||
if mc.RegSource == "" || !strings.Contains(mc.RegSource, expected.keyword) {
|
||||
gaps = append(gaps, MissingSource{
|
||||
Regulation: expected.name,
|
||||
AffectsMCs: []string{mc.CanonicalName},
|
||||
EstimatedGap: mc.TotalControls / 3,
|
||||
SourceURL: expected.url,
|
||||
Priority: expected.priority,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicateGaps(gaps), nil
|
||||
}
|
||||
|
||||
// DetectAuditGaps checks an audit's answers for regulation-specific gaps.
|
||||
func (d *GapDetector) DetectAuditGaps(audit *Audit, answers []Answer) []MissingSource {
|
||||
answerMap := make(map[string]Answer)
|
||||
for _, a := range answers {
|
||||
answerMap[a.QuestionID] = a
|
||||
}
|
||||
|
||||
// Find regulations with many failures
|
||||
failsByReg := make(map[string]int)
|
||||
totalByReg := make(map[string]int)
|
||||
|
||||
for _, q := range audit.Questions {
|
||||
if q.Regulation == "" {
|
||||
continue
|
||||
}
|
||||
totalByReg[q.Regulation]++
|
||||
a, ok := answerMap[q.ID]
|
||||
if ok && !isPassed(a) {
|
||||
failsByReg[q.Regulation]++
|
||||
}
|
||||
}
|
||||
|
||||
var gaps []MissingSource
|
||||
for reg, fails := range failsByReg {
|
||||
total := totalByReg[reg]
|
||||
if total > 0 && float64(fails)/float64(total) > 0.5 {
|
||||
gaps = append(gaps, MissingSource{
|
||||
Regulation: reg,
|
||||
AffectsMCs: []string{audit.TemplateID},
|
||||
EstimatedGap: fails,
|
||||
Priority: "high",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return gaps
|
||||
}
|
||||
|
||||
type expectedReg struct {
|
||||
regID string
|
||||
name string
|
||||
keyword string
|
||||
url string
|
||||
priority string
|
||||
}
|
||||
|
||||
func expectedRegulations(mcName string) []expectedReg {
|
||||
mappings := []struct {
|
||||
prefix string
|
||||
regs []expectedReg
|
||||
}{
|
||||
{"data_processing_agreement", []expectedReg{
|
||||
{regID: "dsgvo", name: "DSGVO (EU) 2016/679", keyword: "DSGVO", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32016R0679", priority: "high"},
|
||||
}},
|
||||
{"incident_", []expectedReg{
|
||||
{regID: "nis2", name: "NIS2-Richtlinie (EU) 2022/2555", keyword: "NIS2", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32022L2555", priority: "high"},
|
||||
}},
|
||||
{"vulnerability_", []expectedReg{
|
||||
{regID: "cra", name: "Cyber Resilience Act (CRA)", keyword: "CRA", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024R2847", priority: "high"},
|
||||
}},
|
||||
{"aml_", []expectedReg{
|
||||
{regID: "aml", name: "5. Geldwaescherichtlinie (EU) 2024/1624", keyword: "Geldwaesche", url: "https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32024L1624", priority: "high"},
|
||||
}},
|
||||
}
|
||||
|
||||
var result []expectedReg
|
||||
for _, m := range mappings {
|
||||
if strings.HasPrefix(mcName, m.prefix) {
|
||||
result = append(result, m.regs...)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func identifyMissingRegulation(mc MCInfo, templateRegs []string) *MissingSource {
|
||||
if mc.RegSource != "" {
|
||||
return nil
|
||||
}
|
||||
return &MissingSource{
|
||||
Regulation: fmt.Sprintf("Unbekannte Quelle fuer '%s'", mc.CanonicalName),
|
||||
AffectsMCs: []string{mc.CanonicalName},
|
||||
EstimatedGap: mc.TotalControls,
|
||||
Priority: "medium",
|
||||
}
|
||||
}
|
||||
|
||||
func containsRegulation(regs []string, id string) bool {
|
||||
for _, r := range regs {
|
||||
if strings.EqualFold(r, id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true // if template doesn't restrict, always check
|
||||
}
|
||||
|
||||
func deduplicateGaps(gaps []MissingSource) []MissingSource {
|
||||
seen := make(map[string]*MissingSource)
|
||||
for i := range gaps {
|
||||
key := gaps[i].Regulation
|
||||
if existing, ok := seen[key]; ok {
|
||||
existing.AffectsMCs = append(existing.AffectsMCs, gaps[i].AffectsMCs...)
|
||||
existing.EstimatedGap += gaps[i].EstimatedGap
|
||||
} else {
|
||||
copy := gaps[i]
|
||||
seen[key] = ©
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]MissingSource, 0, len(seen))
|
||||
for _, g := range seen {
|
||||
result = append(result, *g)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Package usecase implements the Use-Case Compiler that turns
|
||||
// Master Controls into interactive compliance questionnaires.
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ── Use-Case Template ──────────────────────────────────────────────
|
||||
|
||||
// Template defines a reusable compliance audit blueprint.
|
||||
type Template struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
MCFilters []string `json:"mc_filters"`
|
||||
Regulations []string `json:"regulations"`
|
||||
Questions []Question `json:"questions,omitempty"`
|
||||
}
|
||||
|
||||
// ── Question ───────────────────────────────────────────────────────
|
||||
|
||||
// Question is a single compliance check derived from a Master Control.
|
||||
type Question struct {
|
||||
ID string `json:"id"`
|
||||
MCID string `json:"mc_id"`
|
||||
MCName string `json:"mc_name"`
|
||||
Text string `json:"question"`
|
||||
QuestionType string `json:"question_type"`
|
||||
EvidenceRequired bool `json:"evidence_required"`
|
||||
PassCriteria []string `json:"pass_criteria"`
|
||||
FailCriteria []string `json:"fail_criteria"`
|
||||
Severity string `json:"severity"`
|
||||
Regulation string `json:"regulation"`
|
||||
DependsOn string `json:"depends_on,omitempty"`
|
||||
}
|
||||
|
||||
// ── Audit ──────────────────────────────────────────────────────────
|
||||
|
||||
// AuditStatus enumerates the lifecycle of an audit.
|
||||
type AuditStatus string
|
||||
|
||||
const (
|
||||
StatusDraft AuditStatus = "draft"
|
||||
StatusInProgress AuditStatus = "in_progress"
|
||||
StatusCompleted AuditStatus = "completed"
|
||||
)
|
||||
|
||||
// Audit is a running or completed compliance questionnaire.
|
||||
type Audit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
Name string `json:"name"`
|
||||
TargetName string `json:"target_name,omitempty"`
|
||||
Status AuditStatus `json:"status"`
|
||||
TotalQuestions int `json:"total_questions"`
|
||||
AnsweredQuestions int `json:"answered_questions"`
|
||||
ComplianceScore float64 `json:"compliance_score"`
|
||||
Questions []Question `json:"questions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// ── Answer ─────────────────────────────────────────────────────────
|
||||
|
||||
// AnswerStatus enumerates how a question was handled.
|
||||
type AnswerStatus string
|
||||
|
||||
const (
|
||||
AnswerStatusAnswered AnswerStatus = "answered"
|
||||
AnswerStatusSkipped AnswerStatus = "skipped"
|
||||
AnswerStatusEscalated AnswerStatus = "escalated"
|
||||
)
|
||||
|
||||
// Answer stores a user's response to a single question.
|
||||
type Answer struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AuditID uuid.UUID `json:"audit_id"`
|
||||
QuestionID string `json:"question_id"`
|
||||
MCID string `json:"mc_id,omitempty"`
|
||||
Value interface{} `json:"value"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
EvidenceIDs []string `json:"evidence_ids"`
|
||||
Status AnswerStatus `json:"status"`
|
||||
AnsweredAt time.Time `json:"answered_at"`
|
||||
}
|
||||
|
||||
// AnswerInput is the request payload for answering a question.
|
||||
type AnswerInput struct {
|
||||
QuestionID string `json:"question_id" binding:"required"`
|
||||
Value interface{} `json:"value" binding:"required"`
|
||||
Comment string `json:"comment"`
|
||||
EvidenceIDs []string `json:"evidence_ids"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ── Scoring ────────────────────────────────────────────────────────
|
||||
|
||||
// ScoreResult is the compliance summary for an audit.
|
||||
type ScoreResult struct {
|
||||
AuditID uuid.UUID `json:"audit_id"`
|
||||
TotalQuestions int `json:"total_questions"`
|
||||
Answered int `json:"answered"`
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
ComplianceScore float64 `json:"compliance_score"`
|
||||
ByRegulation map[string]RegulationScore `json:"by_regulation"`
|
||||
BySeverity map[string]SeverityScore `json:"by_severity"`
|
||||
}
|
||||
|
||||
// RegulationScore breaks down results per regulation.
|
||||
type RegulationScore struct {
|
||||
Total int `json:"total"`
|
||||
Passed int `json:"passed"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// SeverityScore breaks down results per severity.
|
||||
type SeverityScore struct {
|
||||
Total int `json:"total"`
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
// ── Gap Detection ──────────────────────────────────────────────────
|
||||
|
||||
// MissingSource describes a regulation not yet covered by MCs.
|
||||
type MissingSource struct {
|
||||
Regulation string `json:"regulation"`
|
||||
AffectsMCs []string `json:"affects_mcs"`
|
||||
EstimatedGap int `json:"estimated_controls"`
|
||||
SourceURL string `json:"source_url,omitempty"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
|
||||
// CreateAuditInput is the request to start a new audit.
|
||||
type CreateAuditInput struct {
|
||||
TemplateID string `json:"template_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
TargetName string `json:"target_name"`
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QuestionGenerator creates questions from MC metadata when no
|
||||
// pre-defined questions or doc_check_controls exist (Mode A fallback).
|
||||
// For LLM-based generation (Mode B), see compiler_llm.go (Phase 7).
|
||||
|
||||
// GenerateFromMC derives 1-3 questions from a single MC.
|
||||
func GenerateFromMC(mc MCInfo) []Question {
|
||||
name := mc.CanonicalName
|
||||
readable := strings.ReplaceAll(name, "_", " ")
|
||||
|
||||
var questions []Question
|
||||
qBase := fmt.Sprintf("MC-%s", mc.MasterControlID)
|
||||
|
||||
// Primary question: is the control implemented?
|
||||
questions = append(questions, Question{
|
||||
ID: qBase + "-1",
|
||||
MCID: mc.MasterControlID,
|
||||
MCName: name,
|
||||
Text: fmt.Sprintf("Ist '%s' in Ihrem Unternehmen implementiert?", readable),
|
||||
QuestionType: "yes_no",
|
||||
Severity: inferMCSeverity(name),
|
||||
Regulation: mc.RegSource,
|
||||
PassCriteria: []string{"Massnahme implementiert und aktiv"},
|
||||
FailCriteria: []string{"Nicht implementiert"},
|
||||
})
|
||||
|
||||
// Secondary question: is there documentation?
|
||||
if mc.TotalControls >= 5 {
|
||||
questions = append(questions, Question{
|
||||
ID: qBase + "-2",
|
||||
MCID: mc.MasterControlID,
|
||||
MCName: name,
|
||||
Text: fmt.Sprintf("Ist '%s' dokumentiert und nachweisbar?", readable),
|
||||
QuestionType: "yes_no",
|
||||
EvidenceRequired: true,
|
||||
Severity: "MEDIUM",
|
||||
Regulation: mc.RegSource,
|
||||
PassCriteria: []string{"Dokumentation vorhanden und aktuell"},
|
||||
FailCriteria: []string{"Keine oder veraltete Dokumentation"},
|
||||
DependsOn: qBase + "-1",
|
||||
})
|
||||
}
|
||||
|
||||
// Tertiary question for large MCs: review cycle
|
||||
if mc.TotalControls >= 15 {
|
||||
questions = append(questions, Question{
|
||||
ID: qBase + "-3",
|
||||
MCID: mc.MasterControlID,
|
||||
MCName: name,
|
||||
Text: fmt.Sprintf("Wird '%s' regelmaessig ueberprueft und aktualisiert?", readable),
|
||||
QuestionType: "yes_no",
|
||||
Severity: "LOW",
|
||||
Regulation: mc.RegSource,
|
||||
PassCriteria: []string{"Regelmaessiger Review-Zyklus definiert"},
|
||||
FailCriteria: []string{"Kein Review-Prozess"},
|
||||
DependsOn: qBase + "-1",
|
||||
})
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package usecase
|
||||
|
||||
// Score calculates a compliance score from answers and questions.
|
||||
func Score(audit *Audit, answers []Answer) *ScoreResult {
|
||||
result := &ScoreResult{
|
||||
AuditID: audit.ID,
|
||||
TotalQuestions: len(audit.Questions),
|
||||
ByRegulation: make(map[string]RegulationScore),
|
||||
BySeverity: make(map[string]SeverityScore),
|
||||
}
|
||||
|
||||
answerMap := make(map[string]Answer)
|
||||
for _, a := range answers {
|
||||
answerMap[a.QuestionID] = a
|
||||
}
|
||||
|
||||
for _, q := range audit.Questions {
|
||||
a, answered := answerMap[q.ID]
|
||||
if !answered {
|
||||
continue
|
||||
}
|
||||
|
||||
result.Answered++
|
||||
|
||||
passed := isPassed(a)
|
||||
switch a.Status {
|
||||
case AnswerStatusSkipped:
|
||||
result.Skipped++
|
||||
default:
|
||||
if passed {
|
||||
result.Passed++
|
||||
} else {
|
||||
result.Failed++
|
||||
}
|
||||
}
|
||||
|
||||
// By regulation
|
||||
if q.Regulation != "" {
|
||||
rs := result.ByRegulation[q.Regulation]
|
||||
rs.Total++
|
||||
if passed {
|
||||
rs.Passed++
|
||||
}
|
||||
result.ByRegulation[q.Regulation] = rs
|
||||
}
|
||||
|
||||
// By severity
|
||||
sev := q.Severity
|
||||
if sev == "" {
|
||||
sev = "MEDIUM"
|
||||
}
|
||||
ss := result.BySeverity[sev]
|
||||
ss.Total++
|
||||
if passed {
|
||||
ss.Passed++
|
||||
} else {
|
||||
ss.Failed++
|
||||
}
|
||||
result.BySeverity[sev] = ss
|
||||
}
|
||||
|
||||
// Calculate scores
|
||||
if result.Answered > 0 {
|
||||
answerable := result.Answered - result.Skipped
|
||||
if answerable > 0 {
|
||||
result.ComplianceScore = float64(result.Passed) / float64(answerable) * 100
|
||||
}
|
||||
}
|
||||
|
||||
for reg, rs := range result.ByRegulation {
|
||||
if rs.Total > 0 {
|
||||
rs.Score = float64(rs.Passed) / float64(rs.Total) * 100
|
||||
}
|
||||
result.ByRegulation[reg] = rs
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// isPassed checks if an answer represents a pass.
|
||||
func isPassed(a Answer) bool {
|
||||
switch v := a.Value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return v == "yes" || v == "true" || v == "ja"
|
||||
case float64:
|
||||
return v > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestScore_AllPassed(t *testing.T) {
|
||||
audit := &Audit{
|
||||
ID: uuid.New(),
|
||||
Questions: []Question{
|
||||
{ID: "Q1", Severity: "HIGH", Regulation: "DSGVO"},
|
||||
{ID: "Q2", Severity: "MEDIUM", Regulation: "NIS2"},
|
||||
{ID: "Q3", Severity: "LOW", Regulation: "DSGVO"},
|
||||
},
|
||||
}
|
||||
answers := []Answer{
|
||||
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q2", Value: true, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q3", Value: true, Status: AnswerStatusAnswered},
|
||||
}
|
||||
|
||||
result := Score(audit, answers)
|
||||
|
||||
if result.ComplianceScore != 100 {
|
||||
t.Errorf("Expected 100%% score, got %.1f%%", result.ComplianceScore)
|
||||
}
|
||||
if result.Passed != 3 {
|
||||
t.Errorf("Expected 3 passed, got %d", result.Passed)
|
||||
}
|
||||
if result.Failed != 0 {
|
||||
t.Errorf("Expected 0 failed, got %d", result.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_MixedResults(t *testing.T) {
|
||||
audit := &Audit{
|
||||
ID: uuid.New(),
|
||||
Questions: []Question{
|
||||
{ID: "Q1", Severity: "HIGH", Regulation: "DSGVO"},
|
||||
{ID: "Q2", Severity: "HIGH", Regulation: "DSGVO"},
|
||||
{ID: "Q3", Severity: "MEDIUM", Regulation: "NIS2"},
|
||||
{ID: "Q4", Severity: "LOW", Regulation: "NIS2"},
|
||||
},
|
||||
}
|
||||
answers := []Answer{
|
||||
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q2", Value: false, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q3", Value: true, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q4", Value: false, Status: AnswerStatusAnswered},
|
||||
}
|
||||
|
||||
result := Score(audit, answers)
|
||||
|
||||
if result.ComplianceScore != 50 {
|
||||
t.Errorf("Expected 50%% score, got %.1f%%", result.ComplianceScore)
|
||||
}
|
||||
if result.Passed != 2 {
|
||||
t.Errorf("Expected 2 passed, got %d", result.Passed)
|
||||
}
|
||||
if result.Failed != 2 {
|
||||
t.Errorf("Expected 2 failed, got %d", result.Failed)
|
||||
}
|
||||
|
||||
// Check regulation breakdown
|
||||
dsgvo := result.ByRegulation["DSGVO"]
|
||||
if dsgvo.Total != 2 || dsgvo.Passed != 1 {
|
||||
t.Errorf("DSGVO: expected 1/2, got %d/%d", dsgvo.Passed, dsgvo.Total)
|
||||
}
|
||||
|
||||
nis2 := result.ByRegulation["NIS2"]
|
||||
if nis2.Total != 2 || nis2.Passed != 1 {
|
||||
t.Errorf("NIS2: expected 1/2, got %d/%d", nis2.Passed, nis2.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_WithSkipped(t *testing.T) {
|
||||
audit := &Audit{
|
||||
ID: uuid.New(),
|
||||
Questions: []Question{
|
||||
{ID: "Q1", Severity: "HIGH"},
|
||||
{ID: "Q2", Severity: "MEDIUM"},
|
||||
},
|
||||
}
|
||||
answers := []Answer{
|
||||
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q2", Value: nil, Status: AnswerStatusSkipped},
|
||||
}
|
||||
|
||||
result := Score(audit, answers)
|
||||
|
||||
if result.Skipped != 1 {
|
||||
t.Errorf("Expected 1 skipped, got %d", result.Skipped)
|
||||
}
|
||||
if result.ComplianceScore != 100 {
|
||||
t.Errorf("Expected 100%% (1 passed / 1 answerable), got %.1f%%", result.ComplianceScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_NoAnswers(t *testing.T) {
|
||||
audit := &Audit{
|
||||
ID: uuid.New(),
|
||||
Questions: []Question{
|
||||
{ID: "Q1", Severity: "HIGH"},
|
||||
},
|
||||
}
|
||||
|
||||
result := Score(audit, nil)
|
||||
|
||||
if result.ComplianceScore != 0 {
|
||||
t.Errorf("Expected 0%% score, got %.1f%%", result.ComplianceScore)
|
||||
}
|
||||
if result.Answered != 0 {
|
||||
t.Errorf("Expected 0 answered, got %d", result.Answered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_BySeverity(t *testing.T) {
|
||||
audit := &Audit{
|
||||
ID: uuid.New(),
|
||||
Questions: []Question{
|
||||
{ID: "Q1", Severity: "HIGH"},
|
||||
{ID: "Q2", Severity: "HIGH"},
|
||||
{ID: "Q3", Severity: "MEDIUM"},
|
||||
},
|
||||
}
|
||||
answers := []Answer{
|
||||
{QuestionID: "Q1", Value: true, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q2", Value: false, Status: AnswerStatusAnswered},
|
||||
{QuestionID: "Q3", Value: true, Status: AnswerStatusAnswered},
|
||||
}
|
||||
|
||||
result := Score(audit, answers)
|
||||
|
||||
high := result.BySeverity["HIGH"]
|
||||
if high.Total != 2 || high.Passed != 1 || high.Failed != 1 {
|
||||
t.Errorf("HIGH: expected 1/2 (1 fail), got %d/%d (%d fail)",
|
||||
high.Passed, high.Total, high.Failed)
|
||||
}
|
||||
|
||||
med := result.BySeverity["MEDIUM"]
|
||||
if med.Total != 1 || med.Passed != 1 {
|
||||
t.Errorf("MEDIUM: expected 1/1, got %d/%d", med.Passed, med.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPassed_BoolValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value interface{}
|
||||
expect bool
|
||||
}{
|
||||
{"true bool", true, true},
|
||||
{"false bool", false, false},
|
||||
{"yes string", "yes", true},
|
||||
{"no string", "no", false},
|
||||
{"ja string", "ja", true},
|
||||
{"positive float", float64(1), true},
|
||||
{"zero float", float64(0), false},
|
||||
{"nil value", nil, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
a := Answer{Value: tt.value, Status: AnswerStatusAnswered}
|
||||
got := isPassed(a)
|
||||
if got != tt.expect {
|
||||
t.Errorf("isPassed(%v) = %v, want %v", tt.value, got, tt.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store handles database operations for use-case audits.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewStore creates a new Store.
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
// ── Audit CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
// CreateAudit inserts a new audit.
|
||||
func (s *Store) CreateAudit(a *Audit) error {
|
||||
ctx := context.Background()
|
||||
a.ID = uuid.New()
|
||||
a.CreatedAt = time.Now()
|
||||
a.UpdatedAt = time.Now()
|
||||
a.Status = StatusDraft
|
||||
|
||||
questionsJSON, err := json.Marshal(a.Questions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal questions: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance.usecase_audits
|
||||
(id, tenant_id, template_id, name, target_name, status,
|
||||
total_questions, answered_questions, compliance_score,
|
||||
questions, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`,
|
||||
a.ID, a.TenantID, a.TemplateID, a.Name, a.TargetName,
|
||||
a.Status, a.TotalQuestions, a.AnsweredQuestions, a.ComplianceScore,
|
||||
questionsJSON, a.CreatedAt, a.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAudit loads an audit by ID.
|
||||
func (s *Store) GetAudit(id uuid.UUID) (*Audit, error) {
|
||||
ctx := context.Background()
|
||||
a := &Audit{}
|
||||
var questionsJSON []byte
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id, tenant_id, template_id, name, target_name, status,
|
||||
total_questions, answered_questions, compliance_score,
|
||||
questions, created_at, updated_at, completed_at
|
||||
FROM compliance.usecase_audits WHERE id = $1`, id,
|
||||
).Scan(
|
||||
&a.ID, &a.TenantID, &a.TemplateID, &a.Name, &a.TargetName,
|
||||
&a.Status, &a.TotalQuestions, &a.AnsweredQuestions,
|
||||
&a.ComplianceScore, &questionsJSON,
|
||||
&a.CreatedAt, &a.UpdatedAt, &a.CompletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(questionsJSON) > 0 {
|
||||
json.Unmarshal(questionsJSON, &a.Questions)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListAudits returns all audits for a tenant.
|
||||
func (s *Store) ListAudits(tenantID uuid.UUID) ([]Audit, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, template_id, name, target_name, status,
|
||||
total_questions, answered_questions, compliance_score,
|
||||
created_at, updated_at, completed_at
|
||||
FROM compliance.usecase_audits
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var audits []Audit
|
||||
for rows.Next() {
|
||||
var a Audit
|
||||
a.TenantID = tenantID
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.TemplateID, &a.Name, &a.TargetName, &a.Status,
|
||||
&a.TotalQuestions, &a.AnsweredQuestions, &a.ComplianceScore,
|
||||
&a.CreatedAt, &a.UpdatedAt, &a.CompletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
audits = append(audits, a)
|
||||
}
|
||||
return audits, nil
|
||||
}
|
||||
|
||||
// UpdateAuditScore updates the score and status of an audit.
|
||||
func (s *Store) UpdateAuditScore(id uuid.UUID, answered int, score float64, status AuditStatus) error {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE compliance.usecase_audits
|
||||
SET answered_questions = $2, compliance_score = $3,
|
||||
status = $4, updated_at = $5`
|
||||
|
||||
args := []interface{}{id, answered, score, status, now}
|
||||
if status == StatusCompleted {
|
||||
query += `, completed_at = $6 WHERE id = $1`
|
||||
args = append(args, now)
|
||||
} else {
|
||||
query += ` WHERE id = $1`
|
||||
}
|
||||
|
||||
_, err := s.pool.Exec(ctx, query, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Answer CRUD ────────────────────────────────────────────────────
|
||||
|
||||
// SaveAnswer upserts an answer (INSERT ... ON CONFLICT UPDATE).
|
||||
func (s *Store) SaveAnswer(a *Answer) error {
|
||||
ctx := context.Background()
|
||||
a.ID = uuid.New()
|
||||
a.AnsweredAt = time.Now()
|
||||
|
||||
answerJSON, err := json.Marshal(map[string]interface{}{
|
||||
"value": a.Value,
|
||||
"comment": a.Comment,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal answer: %w", err)
|
||||
}
|
||||
|
||||
evidenceJSON, _ := json.Marshal(a.EvidenceIDs)
|
||||
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO compliance.usecase_answers
|
||||
(id, audit_id, question_id, mc_id, answer, evidence_ids, status, answered_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT (audit_id, question_id)
|
||||
DO UPDATE SET answer = $5, evidence_ids = $6, status = $7, answered_at = $8`,
|
||||
a.ID, a.AuditID, a.QuestionID, a.MCID,
|
||||
answerJSON, evidenceJSON, a.Status, a.AnsweredAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAnswers returns all answers for an audit.
|
||||
func (s *Store) ListAnswers(auditID uuid.UUID) ([]Answer, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, audit_id, question_id, mc_id, answer, evidence_ids, status, answered_at
|
||||
FROM compliance.usecase_answers
|
||||
WHERE audit_id = $1
|
||||
ORDER BY answered_at`, auditID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var answers []Answer
|
||||
for rows.Next() {
|
||||
var a Answer
|
||||
var answerJSON, evidenceJSON []byte
|
||||
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.AuditID, &a.QuestionID, &a.MCID,
|
||||
&answerJSON, &evidenceJSON, &a.Status, &a.AnsweredAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if json.Unmarshal(answerJSON, &payload) == nil {
|
||||
a.Value = payload["value"]
|
||||
if c, ok := payload["comment"].(string); ok {
|
||||
a.Comment = c
|
||||
}
|
||||
}
|
||||
json.Unmarshal(evidenceJSON, &a.EvidenceIDs)
|
||||
answers = append(answers, a)
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// ── MC Queries ─────────────────────────────────────────────────────
|
||||
|
||||
// MCInfo holds minimal data about a Master Control for compilation.
|
||||
type MCInfo struct {
|
||||
MasterControlID string `json:"master_control_id"`
|
||||
CanonicalName string `json:"canonical_name"`
|
||||
TotalControls int `json:"total_controls"`
|
||||
RegSource string `json:"regulation_source"`
|
||||
}
|
||||
|
||||
// FetchMCsByFilters returns MCs whose canonical_name matches any filter pattern.
|
||||
func (s *Store) FetchMCsByFilters(filters []string) ([]MCInfo, error) {
|
||||
if len(filters) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Build LIKE conditions from filter patterns (support trailing *)
|
||||
conditions := make([]string, len(filters))
|
||||
args := make([]interface{}, len(filters))
|
||||
for i, f := range filters {
|
||||
// Convert "third_party_management_*" → "third_party_management_%"
|
||||
pattern := f
|
||||
if len(pattern) > 0 && pattern[len(pattern)-1] == '*' {
|
||||
pattern = pattern[:len(pattern)-1] + "%"
|
||||
}
|
||||
conditions[i] = fmt.Sprintf("mc.canonical_name LIKE $%d", i+1)
|
||||
args[i] = pattern
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT mc.master_control_id, mc.canonical_name, mc.total_controls,
|
||||
COALESCE(
|
||||
(SELECT pc.source_citation::jsonb->>'source'
|
||||
FROM compliance.master_control_members mcm2
|
||||
JOIN compliance.canonical_controls cc2 ON cc2.id = mcm2.control_uuid
|
||||
LEFT JOIN compliance.canonical_controls pc ON pc.id = cc2.parent_control_uuid
|
||||
WHERE mcm2.master_control_uuid = mc.id
|
||||
AND pc.source_citation IS NOT NULL
|
||||
LIMIT 1), ''
|
||||
) as regulation_source
|
||||
FROM compliance.master_controls mc
|
||||
WHERE %s
|
||||
ORDER BY mc.total_controls DESC
|
||||
LIMIT 200`,
|
||||
joinOr(conditions))
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch MCs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var mcs []MCInfo
|
||||
for rows.Next() {
|
||||
var m MCInfo
|
||||
if err := rows.Scan(&m.MasterControlID, &m.CanonicalName,
|
||||
&m.TotalControls, &m.RegSource); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mcs = append(mcs, m)
|
||||
}
|
||||
return mcs, nil
|
||||
}
|
||||
|
||||
// FetchCheckQuestions loads existing doc_check_controls for MCs.
|
||||
func (s *Store) FetchCheckQuestions(mcIDs []string) (map[string][]CheckQuestion, error) {
|
||||
if len(mcIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT control_id, check_question, pass_criteria, fail_criteria, severity
|
||||
FROM compliance.doc_check_controls
|
||||
WHERE control_id = ANY($1)`, mcIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]CheckQuestion)
|
||||
for rows.Next() {
|
||||
var cq CheckQuestion
|
||||
if err := rows.Scan(&cq.ControlID, &cq.Question,
|
||||
&cq.PassCriteria, &cq.FailCriteria, &cq.Severity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[cq.ControlID] = append(result[cq.ControlID], cq)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CheckQuestion holds an existing doc_check_control question.
|
||||
type CheckQuestion struct {
|
||||
ControlID string `json:"control_id"`
|
||||
Question string `json:"check_question"`
|
||||
PassCriteria string `json:"pass_criteria"`
|
||||
FailCriteria string `json:"fail_criteria"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
// CountMCSourceCitations counts controls with source_citation per MC.
|
||||
func (s *Store) CountMCSourceCitations(mcIDs []string) (map[string]int, error) {
|
||||
if len(mcIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT mc.master_control_id,
|
||||
COUNT(CASE WHEN cc.source_citation IS NOT NULL
|
||||
AND cc.source_citation != '' THEN 1 END)
|
||||
FROM compliance.master_controls mc
|
||||
JOIN compliance.master_control_members mcm ON mcm.master_control_uuid = mc.id
|
||||
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
|
||||
WHERE mc.master_control_id = ANY($1)
|
||||
GROUP BY mc.master_control_id`, mcIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var count int
|
||||
if err := rows.Scan(&id, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[id] = count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func joinOr(conditions []string) string {
|
||||
if len(conditions) == 1 {
|
||||
return conditions[0]
|
||||
}
|
||||
result := "("
|
||||
for i, c := range conditions {
|
||||
if i > 0 {
|
||||
result += " OR "
|
||||
}
|
||||
result += c
|
||||
}
|
||||
return result + ")"
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package usecase
|
||||
|
||||
// Templates holds the built-in use-case templates.
|
||||
var Templates = map[string]Template{
|
||||
"vendor_check_cloud": {
|
||||
ID: "vendor_check_cloud",
|
||||
Name: "Vendor Check (Cloud-Anbieter)",
|
||||
Description: "Prueft Cloud-Anbieter auf DSGVO- und NIS2-Konformitaet: AVV, Drittlandtransfer, Zertifizierungen, Incident Response.",
|
||||
MCFilters: []string{"third_party_management_*", "data_processing_agreement_*", "data_transfer_*", "vendor_*", "supply_chain_*"},
|
||||
Regulations: []string{"dsgvo", "nis2"},
|
||||
Questions: []Question{
|
||||
{ID: "VC01", Text: "Hat der Anbieter ISO 27001 oder eine vergleichbare Zertifizierung?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Gueltiges ISO 27001 Zertifikat vorhanden"}, FailCriteria: []string{"Kein Zertifikat, nur Selbstauskunft"}, Severity: "HIGH", Regulation: "NIS2"},
|
||||
{ID: "VC02", Text: "Ist ein Auftragsverarbeitungsvertrag (AVV) nach Art. 28 DSGVO geschlossen?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"AVV liegt vor und ist unterschrieben"}, FailCriteria: []string{"Kein AVV vorhanden"}, Severity: "HIGH", Regulation: "DSGVO Art. 28"},
|
||||
{ID: "VC03", Text: "Werden personenbezogene Daten in Drittlaender uebermittelt?", QuestionType: "yes_no", PassCriteria: []string{"Nein, Verarbeitung nur im EWR"}, FailCriteria: []string{"Ja, Drittlandtransfer ohne Absicherung"}, Severity: "HIGH", Regulation: "DSGVO Art. 44-49"},
|
||||
{ID: "VC04", Text: "Gibt es Standardvertragsklauseln (SCC) oder einen Angemessenheitsbeschluss?", QuestionType: "yes_no", EvidenceRequired: true, DependsOn: "VC03", PassCriteria: []string{"SCC der EU-Kommission oder Angemessenheitsbeschluss vorhanden"}, FailCriteria: []string{"Weder SCC noch Angemessenheitsbeschluss"}, Severity: "HIGH", Regulation: "DSGVO Art. 46"},
|
||||
{ID: "VC05", Text: "Hat der Anbieter ein dokumentiertes Schwachstellenmanagement?", QuestionType: "yes_no", PassCriteria: []string{"Schwachstellenmanagement-Prozess dokumentiert"}, FailCriteria: []string{"Kein formaler Prozess"}, Severity: "MEDIUM", Regulation: "NIS2"},
|
||||
{ID: "VC06", Text: "Gibt es einen Incident-Response-Prozess mit definierten Meldefristen?", QuestionType: "yes_no", PassCriteria: []string{"Incident-Response-Plan mit Meldefristen vorhanden"}, FailCriteria: []string{"Kein dokumentierter Prozess"}, Severity: "HIGH", Regulation: "NIS2 Art. 23"},
|
||||
{ID: "VC07", Text: "Sind Sub-Auftragsverarbeiter dokumentiert und genehmigt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Liste der Sub-Auftragsverarbeiter aktuell und genehmigt"}, FailCriteria: []string{"Keine Uebersicht oder nicht genehmigt"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 28 (2)"},
|
||||
{ID: "VC08", Text: "Unterstuetzt der Anbieter Betroffenenrechte (Auskunft, Loeschung)?", QuestionType: "yes_no", PassCriteria: []string{"Prozess zur Unterstuetzung bei Betroffenenanfragen dokumentiert"}, FailCriteria: []string{"Keine Unterstuetzung vorgesehen"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 28 (3e)"},
|
||||
{ID: "VC09", Text: "Gibt es ein Loeschkonzept mit definierten Aufbewahrungsfristen?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Loeschkonzept mit Fristen vorhanden"}, FailCriteria: []string{"Kein Loeschkonzept"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 17"},
|
||||
{ID: "VC10", Text: "Werden regelmaessige Penetrationstests oder Audits durchgefuehrt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Jaehrliche Pentests oder SOC2-Bericht"}, FailCriteria: []string{"Keine regelmaessigen Sicherheitspruefungen"}, Severity: "MEDIUM", Regulation: "NIS2"},
|
||||
},
|
||||
},
|
||||
|
||||
"sast_dast_audit": {
|
||||
ID: "sast_dast_audit",
|
||||
Name: "SAST/DAST Security Audit",
|
||||
Description: "Prueft Sicherheitspraktiken in der Softwareentwicklung: Secure Coding, Schwachstellenscans, API-Sicherheit.",
|
||||
MCFilters: []string{"secure_development_*", "vulnerability_*", "input_validation_*", "api_security_*", "code_review_*", "software_testing_*"},
|
||||
Regulations: []string{"cra", "owasp"},
|
||||
Questions: []Question{
|
||||
{ID: "SA01", Text: "Werden SAST-Tools (Static Application Security Testing) in der CI/CD-Pipeline eingesetzt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SAST in CI/CD integriert (z.B. Semgrep, SonarQube)"}, FailCriteria: []string{"Kein SAST im Build-Prozess"}, Severity: "HIGH", Regulation: "CRA Annex I"},
|
||||
{ID: "SA02", Text: "Werden DAST-Tools (Dynamic Application Security Testing) regelmaessig ausgefuehrt?", QuestionType: "yes_no", PassCriteria: []string{"DAST mindestens quartalsweise"}, FailCriteria: []string{"Kein DAST"}, Severity: "HIGH", Regulation: "CRA Annex I"},
|
||||
{ID: "SA03", Text: "Gibt es einen Secure-Coding-Standard fuer Entwickler?", QuestionType: "yes_no", PassCriteria: []string{"Dokumentierter Coding-Standard vorhanden"}, FailCriteria: []string{"Kein Standard"}, Severity: "MEDIUM", Regulation: "CRA"},
|
||||
{ID: "SA04", Text: "Werden bekannte Schwachstellen in Dependencies ueberwacht (SCA)?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SCA/Dependency-Check aktiv (z.B. Dependabot, Snyk)"}, FailCriteria: []string{"Keine Ueberwachung"}, Severity: "HIGH", Regulation: "CRA Annex I"},
|
||||
{ID: "SA05", Text: "Werden API-Endpoints gegen OWASP Top 10 abgesichert?", QuestionType: "yes_no", PassCriteria: []string{"OWASP Top 10 systematisch adressiert"}, FailCriteria: []string{"Keine systematische Absicherung"}, Severity: "HIGH", Regulation: "OWASP"},
|
||||
{ID: "SA06", Text: "Gibt es einen Prozess fuer Security Code Reviews?", QuestionType: "yes_no", PassCriteria: []string{"Pflicht-Review bei sicherheitskritischen Aenderungen"}, FailCriteria: []string{"Kein Review-Prozess"}, Severity: "MEDIUM", Regulation: "CRA"},
|
||||
{ID: "SA07", Text: "Existiert eine SBOM (Software Bill of Materials)?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SBOM automatisch generiert und aktuell"}, FailCriteria: []string{"Keine SBOM"}, Severity: "HIGH", Regulation: "CRA Art. 13"},
|
||||
{ID: "SA08", Text: "Werden Sicherheitsupdates innerhalb definierter Fristen bereitgestellt?", QuestionType: "yes_no", PassCriteria: []string{"SLA fuer Security-Patches definiert"}, FailCriteria: []string{"Keine definierten Fristen"}, Severity: "HIGH", Regulation: "CRA Annex I"},
|
||||
},
|
||||
},
|
||||
|
||||
"dsgvo_quick_check": {
|
||||
ID: "dsgvo_quick_check",
|
||||
Name: "DSGVO Quick-Check",
|
||||
Description: "Schnelle Selbstpruefung der wichtigsten DSGVO-Anforderungen: Verarbeitungsverzeichnis, Betroffenenrechte, DSFA, Einwilligungen.",
|
||||
MCFilters: []string{"personal_data_*", "consent_*", "data_subject_rights_*", "dpia_*", "data_retention_*", "privacy_*", "data_protection_*"},
|
||||
Regulations: []string{"dsgvo"},
|
||||
Questions: []Question{
|
||||
{ID: "DS01", Text: "Fuehren Sie ein Verarbeitungsverzeichnis nach Art. 30 DSGVO?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Aktuelles Verarbeitungsverzeichnis vorhanden"}, FailCriteria: []string{"Kein oder veraltetes Verzeichnis"}, Severity: "HIGH", Regulation: "DSGVO Art. 30"},
|
||||
{ID: "DS02", Text: "Ist ein Datenschutzbeauftragter (DSB) benannt (falls erforderlich)?", QuestionType: "yes_no", PassCriteria: []string{"DSB benannt und gemeldet"}, FailCriteria: []string{"Kein DSB trotz Pflicht"}, Severity: "HIGH", Regulation: "DSGVO Art. 37"},
|
||||
{ID: "DS03", Text: "Gibt es einen Prozess fuer Betroffenenanfragen (Auskunft, Loeschung, Berichtigung)?", QuestionType: "yes_no", PassCriteria: []string{"Dokumentierter Prozess mit Fristenueberwachung"}, FailCriteria: []string{"Kein Prozess definiert"}, Severity: "HIGH", Regulation: "DSGVO Art. 15-22"},
|
||||
{ID: "DS04", Text: "Werden Einwilligungen nachweisbar eingeholt und dokumentiert?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Consent-Management mit Nachweis"}, FailCriteria: []string{"Keine nachweisbare Einwilligung"}, Severity: "HIGH", Regulation: "DSGVO Art. 7"},
|
||||
{ID: "DS05", Text: "Wurde eine DSFA fuer risikoreiche Verarbeitungen durchgefuehrt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"DSFA durchgefuehrt und dokumentiert"}, FailCriteria: []string{"Keine DSFA trotz hohem Risiko"}, Severity: "HIGH", Regulation: "DSGVO Art. 35"},
|
||||
{ID: "DS06", Text: "Gibt es eine aktuelle Datenschutzerklaerung auf der Website?", QuestionType: "yes_no", PassCriteria: []string{"DSE aktuell und vollstaendig"}, FailCriteria: []string{"Keine oder veraltete DSE"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 13/14"},
|
||||
{ID: "DS07", Text: "Sind Aufbewahrungsfristen fuer personenbezogene Daten definiert?", QuestionType: "yes_no", PassCriteria: []string{"Loeschkonzept mit Fristen vorhanden"}, FailCriteria: []string{"Keine definierten Fristen"}, Severity: "MEDIUM", Regulation: "DSGVO Art. 5 (1e)"},
|
||||
{ID: "DS08", Text: "Gibt es einen Meldeprozess fuer Datenschutzverletzungen (72h)?", QuestionType: "yes_no", PassCriteria: []string{"Data-Breach-Prozess mit 72h-Frist dokumentiert"}, FailCriteria: []string{"Kein Meldeprozess"}, Severity: "HIGH", Regulation: "DSGVO Art. 33"},
|
||||
},
|
||||
},
|
||||
|
||||
"nis2_readiness": {
|
||||
ID: "nis2_readiness",
|
||||
Name: "NIS2 Readiness Check",
|
||||
Description: "Prueft die Bereitschaft fuer NIS2: Risikomanagement, Incident Handling, Netzwerksicherheit, Supply Chain.",
|
||||
MCFilters: []string{"critical_infrastructure_*", "incident_*", "network_security_*", "risk_management_*", "business_continuity_*", "supply_chain_*"},
|
||||
Regulations: []string{"nis2"},
|
||||
Questions: []Question{
|
||||
{ID: "N201", Text: "Gibt es ein formales Risikomanagement fuer IT-Sicherheit?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Risikomanagement-Framework dokumentiert"}, FailCriteria: []string{"Kein formales Risikomanagement"}, Severity: "HIGH", Regulation: "NIS2 Art. 21 (2a)"},
|
||||
{ID: "N202", Text: "Gibt es einen Incident-Handling-Prozess mit Meldefristen (24h/72h)?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Incident-Response-Plan mit NIS2-konformen Fristen"}, FailCriteria: []string{"Kein oder unvollstaendiger Prozess"}, Severity: "HIGH", Regulation: "NIS2 Art. 23"},
|
||||
{ID: "N203", Text: "Ist ein Business-Continuity-Plan vorhanden und getestet?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"BCP vorhanden und regelmaessig getestet"}, FailCriteria: []string{"Kein BCP oder nie getestet"}, Severity: "HIGH", Regulation: "NIS2 Art. 21 (2c)"},
|
||||
{ID: "N204", Text: "Werden Lieferanten auf Cybersicherheit geprueft?", QuestionType: "yes_no", PassCriteria: []string{"Supply-Chain-Security-Anforderungen definiert"}, FailCriteria: []string{"Keine Lieferantenpruefung"}, Severity: "MEDIUM", Regulation: "NIS2 Art. 21 (2d)"},
|
||||
{ID: "N205", Text: "Ist Multi-Faktor-Authentifizierung fuer kritische Systeme aktiviert?", QuestionType: "yes_no", PassCriteria: []string{"MFA fuer Admin-Zugaenge und VPN"}, FailCriteria: []string{"Kein MFA"}, Severity: "HIGH", Regulation: "NIS2 Art. 21 (2j)"},
|
||||
{ID: "N206", Text: "Werden Mitarbeiter regelmaessig in Cybersicherheit geschult?", QuestionType: "yes_no", PassCriteria: []string{"Jaehrliche Security-Awareness-Schulung"}, FailCriteria: []string{"Keine Schulungen"}, Severity: "MEDIUM", Regulation: "NIS2 Art. 21 (2g)"},
|
||||
{ID: "N207", Text: "Gibt es eine Netzwerksegmentierung fuer kritische Systeme?", QuestionType: "yes_no", PassCriteria: []string{"Netzwerksegmentierung implementiert"}, FailCriteria: []string{"Flat Network ohne Segmentierung"}, Severity: "MEDIUM", Regulation: "NIS2 Art. 21 (2e)"},
|
||||
{ID: "N208", Text: "Ist die Geschaeftsfuehrung ueber NIS2-Pflichten informiert (persoenliche Haftung)?", QuestionType: "yes_no", PassCriteria: []string{"Management-Briefing dokumentiert"}, FailCriteria: []string{"Management nicht informiert"}, Severity: "HIGH", Regulation: "NIS2 Art. 20"},
|
||||
},
|
||||
},
|
||||
|
||||
"cra_product_check": {
|
||||
ID: "cra_product_check",
|
||||
Name: "CRA Product Compliance Check",
|
||||
Description: "Prueft digitale Produkte auf Konformitaet mit dem Cyber Resilience Act: Schwachstellen, Updates, Verschluesselung, SBOM.",
|
||||
MCFilters: []string{"vulnerability_*", "patch_management_*", "encryption_*", "asset_management_inventory*", "secure_development_*", "product_security_*"},
|
||||
Regulations: []string{"cra"},
|
||||
Questions: []Question{
|
||||
{ID: "CR01", Text: "Wird das Produkt ohne bekannte ausnutzbare Schwachstellen ausgeliefert?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"Schwachstellenscan vor Release, keine kritischen CVEs"}, FailCriteria: []string{"Bekannte Schwachstellen bei Auslieferung"}, Severity: "HIGH", Regulation: "CRA Annex I (1)"},
|
||||
{ID: "CR02", Text: "Gibt es einen Security-Update-Mechanismus fuer das Produkt?", QuestionType: "yes_no", PassCriteria: []string{"Automatische oder manuelle Update-Funktion vorhanden"}, FailCriteria: []string{"Kein Update-Mechanismus"}, Severity: "HIGH", Regulation: "CRA Annex I (2)"},
|
||||
{ID: "CR03", Text: "Werden Daten bei Uebertragung und Speicherung verschluesselt?", QuestionType: "yes_no", PassCriteria: []string{"TLS/HTTPS + verschluesselte Speicherung"}, FailCriteria: []string{"Unverschluesselte Kommunikation oder Speicherung"}, Severity: "HIGH", Regulation: "CRA Annex I (3d)"},
|
||||
{ID: "CR04", Text: "Existiert eine SBOM (Software Bill of Materials) fuer das Produkt?", QuestionType: "yes_no", EvidenceRequired: true, PassCriteria: []string{"SBOM vorhanden und automatisch generiert"}, FailCriteria: []string{"Keine SBOM"}, Severity: "HIGH", Regulation: "CRA Art. 13 (15)"},
|
||||
{ID: "CR05", Text: "Werden Security-Patches mindestens 5 Jahre nach Verkaufsende bereitgestellt?", QuestionType: "yes_no", PassCriteria: []string{"Support-Zeitraum >= 5 Jahre dokumentiert"}, FailCriteria: []string{"Kuerzerer oder kein definierter Support"}, Severity: "HIGH", Regulation: "CRA Art. 13 (8)"},
|
||||
{ID: "CR06", Text: "Ist das Produkt standardmaessig sicher konfiguriert (Secure by Default)?", QuestionType: "yes_no", PassCriteria: []string{"Default-Konfiguration gehaertet"}, FailCriteria: []string{"Unsichere Defaults (z.B. Standard-Passwoerter)"}, Severity: "HIGH", Regulation: "CRA Annex I (3a)"},
|
||||
{ID: "CR07", Text: "Gibt es eine Kontaktmoeglichkeit fuer Schwachstellenmeldungen?", QuestionType: "yes_no", PassCriteria: []string{"security.txt oder Vulnerability Disclosure Policy"}, FailCriteria: []string{"Keine Meldemoelichkeit"}, Severity: "MEDIUM", Regulation: "CRA Art. 13 (6)"},
|
||||
{ID: "CR08", Text: "Werden Schwachstellen innerhalb von 24h an ENISA gemeldet (bei aktiver Ausnutzung)?", QuestionType: "yes_no", PassCriteria: []string{"Meldeprozess mit 24h-Frist dokumentiert"}, FailCriteria: []string{"Kein Meldeprozess"}, Severity: "HIGH", Regulation: "CRA Art. 14"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// TemplateList returns all templates as a slice.
|
||||
func TemplateList() []Template {
|
||||
list := make([]Template, 0, len(Templates))
|
||||
for _, t := range Templates {
|
||||
list = append(list, t)
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Use-Case Compiler: Audits + Answers
|
||||
-- Turns Master Controls into interactive questionnaires
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance.usecase_audits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
template_id VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
target_name VARCHAR(200),
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
total_questions INT DEFAULT 0,
|
||||
answered_questions INT DEFAULT 0,
|
||||
compliance_score FLOAT DEFAULT 0,
|
||||
questions JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usecase_audits_tenant
|
||||
ON compliance.usecase_audits (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usecase_audits_template
|
||||
ON compliance.usecase_audits (template_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compliance.usecase_answers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
audit_id UUID NOT NULL REFERENCES compliance.usecase_audits(id) ON DELETE CASCADE,
|
||||
question_id VARCHAR(50) NOT NULL,
|
||||
mc_id VARCHAR(50),
|
||||
answer JSONB NOT NULL,
|
||||
evidence_ids JSONB DEFAULT '[]',
|
||||
status VARCHAR(20) DEFAULT 'answered',
|
||||
answered_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usecase_answers_audit
|
||||
ON compliance.usecase_answers (audit_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_usecase_answers_unique
|
||||
ON compliance.usecase_answers (audit_id, question_id);
|
||||
@@ -64,12 +64,16 @@ class ComplianceCheckStatusResponse(BaseModel):
|
||||
|
||||
@router.post("/extract-text")
|
||||
async def extract_text(req: ExtractTextRequest):
|
||||
"""Extract text from a URL via consent-tester DSI discovery."""
|
||||
"""Extract text from a URL via consent-tester DSI discovery.
|
||||
|
||||
Merges all documents found on the page (sub-pages, accordions, etc.)
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
resp = await client.post(
|
||||
f"{CONSENT_TESTER_URL}/dsi-discovery",
|
||||
json={"url": req.url, "max_documents": 1},
|
||||
json={"url": req.url, "max_documents": 5},
|
||||
timeout=300.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return {
|
||||
@@ -86,10 +90,15 @@ async def extract_text(req: ExtractTextRequest):
|
||||
"error": "Kein Text extrahierbar",
|
||||
}
|
||||
|
||||
doc = docs[0]
|
||||
text = doc.get("full_text", "") or doc.get("text_preview", "") or doc.get("text", "")
|
||||
title = doc.get("title", "") or doc.get("doc_type", "")
|
||||
word_count = doc.get("word_count", 0) or len(text.split())
|
||||
# Merge all documents (handles multi-page DSIs like BMW)
|
||||
texts = []
|
||||
for doc in docs:
|
||||
t = doc.get("full_text", "") or doc.get("text_preview", "") or ""
|
||||
if t and len(t) > 50:
|
||||
texts.append(t)
|
||||
text = "\n\n".join(texts) if texts else ""
|
||||
title = docs[0].get("title", "") or docs[0].get("doc_type", "")
|
||||
word_count = len(text.split())
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
@@ -151,11 +160,20 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
doc_texts: dict[str, str] = {}
|
||||
doc_entries: list[dict] = []
|
||||
|
||||
# Cache fetched URLs to detect duplicates
|
||||
url_text_cache: dict[str, str] = {}
|
||||
|
||||
for i, doc in enumerate(req.documents):
|
||||
_update(check_id, f"Dokument {i+1}/{len(req.documents)}: {doc.doc_type}...")
|
||||
text = doc.text
|
||||
if not text and doc.url:
|
||||
text = await _fetch_text(doc.url)
|
||||
url_key = doc.url.strip().rstrip("/").lower()
|
||||
if url_key in url_text_cache:
|
||||
text = url_text_cache[url_key]
|
||||
else:
|
||||
text = await _fetch_text(doc.url)
|
||||
if text:
|
||||
url_text_cache[url_key] = text
|
||||
if text:
|
||||
doc_texts[doc.doc_type] = text
|
||||
doc_entries.append({
|
||||
@@ -165,6 +183,24 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
"word_count": len(text.split()) if text else 0,
|
||||
})
|
||||
|
||||
# Step 1b: Section splitting — two cases:
|
||||
# 1. Same URL used for multiple doc_types → split by heading
|
||||
# 2. DSI text contains Cookie/Social-Media sections → auto-fill empty rows
|
||||
from compliance.services.section_splitter import (
|
||||
split_shared_texts, auto_fill_from_dsi, cross_search_documents,
|
||||
)
|
||||
split_shared_texts(doc_entries, url_text_cache)
|
||||
auto_fill_from_dsi(doc_entries)
|
||||
|
||||
# Step 1c: Cross-document search — find doc_types in wrong documents
|
||||
_update(check_id, "Dokumente werden uebergreifend durchsucht...")
|
||||
placement_findings = cross_search_documents(doc_entries)
|
||||
|
||||
# Refresh doc_texts after all splitting/searching
|
||||
for entry in doc_entries:
|
||||
if entry.get("text"):
|
||||
doc_texts[entry["doc_type"]] = entry["text"]
|
||||
|
||||
# Step 2: Detect business profile
|
||||
_update(check_id, "Geschaeftsmodell wird erkannt...")
|
||||
profile = await detect_business_profile(doc_texts)
|
||||
@@ -177,12 +213,22 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
"COMPLIANCE_USE_AGENT", "false"
|
||||
).lower() == "true"
|
||||
|
||||
# Filter out doc_types that don't apply to this business profile
|
||||
skip_types = _get_skip_types(profile)
|
||||
|
||||
for i, entry in enumerate(doc_entries):
|
||||
text = entry["text"]
|
||||
doc_type = entry["doc_type"]
|
||||
label = _doc_type_label(doc_type)
|
||||
url = entry["url"]
|
||||
|
||||
if doc_type in skip_types:
|
||||
results.append(DocCheckResult(
|
||||
label=label, url=url, doc_type=doc_type,
|
||||
error=skip_types[doc_type],
|
||||
))
|
||||
continue
|
||||
|
||||
_update(check_id, f"Pruefe {label} ({i+1}/{len(doc_entries)})...")
|
||||
|
||||
if not text or len(text) < 50:
|
||||
@@ -200,6 +246,16 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
# Apply profile context filter
|
||||
result = _apply_profile_filter(result, profile, doc_type)
|
||||
|
||||
# Add placement findings — but only if the regex checks confirm
|
||||
# the text doesn't match. If completeness >= 50%, the text IS the
|
||||
# right doc_type despite missing cross-search keywords.
|
||||
if result.completeness_pct < 50:
|
||||
for pf in placement_findings:
|
||||
if pf.get("doc_type") == doc_type:
|
||||
result.checks.insert(0, CheckItem(**{
|
||||
k: v for k, v in pf.items() if k != "doc_type"
|
||||
}))
|
||||
|
||||
results.append(result)
|
||||
total_findings += result.findings_count
|
||||
|
||||
@@ -231,41 +287,78 @@ async def _run_compliance_check(check_id: str, req: ComplianceCheckRequest):
|
||||
banner_result, doc_texts["cookie"],
|
||||
)
|
||||
if cross_findings:
|
||||
# Add cross-check findings to cookie results
|
||||
for r in results:
|
||||
if r.doc_type == "cookie":
|
||||
for cf in cross_findings:
|
||||
r.checks.append(CheckItem(**cf))
|
||||
# Recompute
|
||||
l2 = [c for c in r.checks if c.level == 2 and not c.skipped]
|
||||
l2p = sum(1 for c in l2 if c.passed)
|
||||
r.correctness_pct = round(l2p / len(l2) * 100) if l2 else 0
|
||||
|
||||
# Step 4: Build report
|
||||
# Step 3d: TCF Vendor cross-check against DSI
|
||||
tcf_vendors = banner_result.get("tcf_vendors", []) if banner_result else []
|
||||
vvt_entries: list[dict] = []
|
||||
if tcf_vendors and "dse" in doc_texts:
|
||||
_update(check_id, f"{len(tcf_vendors)} TCF-Verarbeiter vs. DSI abgleichen...")
|
||||
from compliance.services.banner_cookie_cross_check import cross_check_vendors_vs_dsi
|
||||
from compliance.services.vendor_vvt_mapper import map_vendors_to_vvt
|
||||
vendor_findings = cross_check_vendors_vs_dsi(tcf_vendors, doc_texts["dse"])
|
||||
if vendor_findings:
|
||||
for r in results:
|
||||
if r.doc_type == "dse":
|
||||
for vf in vendor_findings:
|
||||
r.checks.append(CheckItem(**vf))
|
||||
vvt_entries = map_vendors_to_vvt(tcf_vendors)
|
||||
|
||||
# Step 4: Extract profile hints from documents
|
||||
_update(check_id, "Profil wird aus Dokumenten extrahiert...")
|
||||
from compliance.services.profile_extractor import extract_profile_from_documents
|
||||
extracted_profile = extract_profile_from_documents(doc_texts, profile_dict)
|
||||
|
||||
# Step 4b: Determine scenario per document
|
||||
for r in results:
|
||||
if r.error:
|
||||
r.scenario = "skip"
|
||||
elif r.completeness_pct < 30:
|
||||
r.scenario = "regenerate"
|
||||
elif r.completeness_pct < 95:
|
||||
r.scenario = "fix"
|
||||
else:
|
||||
r.scenario = "import"
|
||||
|
||||
# Step 5: Build report with management summary
|
||||
_update(check_id, "Report wird erstellt...")
|
||||
from .agent_doc_check_report import build_management_summary
|
||||
summary_html = build_management_summary(results)
|
||||
report_html = build_html_report(results, None)
|
||||
|
||||
# Prepend profile summary to report
|
||||
profile_html = _build_profile_html(profile)
|
||||
full_html = profile_html + report_html
|
||||
full_html = summary_html + profile_html + report_html
|
||||
|
||||
# Step 5: Send email
|
||||
# Step 6: Send email — include website/company name in subject
|
||||
doc_count = len([r for r in results if not r.error])
|
||||
site_name = (
|
||||
extracted_profile.get("company_profile", {}).get("companyName")
|
||||
or _extract_domain(doc_entries)
|
||||
or "Unbekannt"
|
||||
)
|
||||
email_result = send_email(
|
||||
recipient=req.recipient,
|
||||
subject=f"[COMPLIANCE-CHECK] {doc_count} Dokumente geprueft",
|
||||
subject=f"[COMPLIANCE-CHECK] {site_name} — {doc_count} Dokumente geprueft",
|
||||
body_html=full_html,
|
||||
)
|
||||
|
||||
# Step 6: Store result
|
||||
# Step 7: Store result
|
||||
response = {
|
||||
"results": [_result_to_dict(r) for r in results],
|
||||
"business_profile": profile_dict,
|
||||
"extracted_profile": extracted_profile,
|
||||
"banner_result": {
|
||||
"detected": banner_result.get("banner_detected", False) if banner_result else False,
|
||||
"provider": banner_result.get("banner_provider", "") if banner_result else "",
|
||||
"violations": len(banner_result.get("banner_checks", {}).get("violations", [])) if banner_result else 0,
|
||||
"tcf_vendor_count": len(tcf_vendors),
|
||||
} if banner_result else None,
|
||||
"tcf_vendors": vvt_entries if tcf_vendors else [],
|
||||
"total_documents": len(results),
|
||||
"total_findings": total_findings,
|
||||
"email_status": email_result.get("status", "failed"),
|
||||
@@ -287,23 +380,55 @@ def _update(check_id: str, msg: str):
|
||||
|
||||
|
||||
async def _fetch_text(url: str) -> str:
|
||||
"""Fetch text from URL via consent-tester."""
|
||||
"""Fetch text from URL via consent-tester, with HTTP fallback.
|
||||
|
||||
1. Try consent-tester (Playwright) — handles JS-heavy SPAs
|
||||
2. Fallback: direct HTTP fetch + HTML strip — fast, works for SSR pages
|
||||
"""
|
||||
# 1. Consent-tester (Playwright-based, full JS rendering)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
f"{CONSENT_TESTER_URL}/dsi-discovery",
|
||||
json={"url": url, "max_documents": 1},
|
||||
json={"url": url, "max_documents": 3},
|
||||
timeout=60.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return ""
|
||||
docs = resp.json().get("documents", [])
|
||||
if not docs:
|
||||
return ""
|
||||
doc = docs[0]
|
||||
return doc.get("full_text", "") or doc.get("text_preview", "") or ""
|
||||
if resp.status_code == 200:
|
||||
docs = resp.json().get("documents", [])
|
||||
if docs:
|
||||
texts = []
|
||||
for doc in docs:
|
||||
t = doc.get("full_text", "") or doc.get("text_preview", "") or ""
|
||||
if t and len(t) > 50:
|
||||
texts.append(t)
|
||||
merged = "\n\n".join(texts)
|
||||
if merged and len(merged.split()) > 100:
|
||||
if len(texts) > 1:
|
||||
logger.info("Merged %d docs from %s (%d words)",
|
||||
len(texts), url, len(merged.split()))
|
||||
return merged
|
||||
except Exception as e:
|
||||
logger.warning("Text fetch failed for %s: %s", url, e)
|
||||
return ""
|
||||
logger.warning("Consent-tester fetch failed for %s: %s", url, e)
|
||||
|
||||
# 2. Fallback: direct HTTP fetch (works for SSR pages like BMW)
|
||||
try:
|
||||
import re as _re
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 200 and "text/html" in resp.headers.get("content-type", ""):
|
||||
html = resp.text
|
||||
# Strip HTML tags, decode entities
|
||||
text = _re.sub(r"<script[^>]*>.*?</script>", " ", html, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<style[^>]*>.*?</style>", " ", text, flags=_re.DOTALL | _re.IGNORECASE)
|
||||
text = _re.sub(r"<[^>]+>", " ", text)
|
||||
text = _re.sub(r"\s+", " ", text).strip()
|
||||
if len(text.split()) > 100:
|
||||
logger.info("HTTP fallback for %s: %d words", url, len(text.split()))
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.warning("HTTP fallback failed for %s: %s", url, e)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
async def _check_single(
|
||||
@@ -334,10 +459,10 @@ async def _check_single(
|
||||
completeness = f.get("completeness_pct", 0)
|
||||
correctness = f.get("correctness_pct", 0)
|
||||
|
||||
# Master Control checks
|
||||
# Master Control checks (top 20 by severity to avoid noise)
|
||||
try:
|
||||
mc_results = await check_document_with_controls(
|
||||
text, doc_type, label, max_controls=0, use_agent=use_agent,
|
||||
text, doc_type, label, max_controls=20, use_agent=use_agent,
|
||||
)
|
||||
if mc_results:
|
||||
for mc in mc_results:
|
||||
@@ -378,6 +503,23 @@ async def _check_single(
|
||||
)
|
||||
|
||||
|
||||
def _extract_domain(doc_entries: list[dict]) -> str | None:
|
||||
"""Extract domain name from first URL for email subject."""
|
||||
for entry in doc_entries:
|
||||
url = entry.get("url", "")
|
||||
if url and "://" in url:
|
||||
from urllib.parse import urlparse
|
||||
host = urlparse(url).netloc
|
||||
return host.replace("www.", "") if host else None
|
||||
return None
|
||||
|
||||
|
||||
def _get_skip_types(profile) -> dict[str, str]:
|
||||
"""Doc_types to skip entirely. Currently empty — we check everything
|
||||
and flag irrelevant items as INFO instead of skipping."""
|
||||
return {}
|
||||
|
||||
|
||||
def _apply_profile_filter(result, profile, doc_type: str):
|
||||
"""Adjust INFO-level checks based on business profile context.
|
||||
|
||||
@@ -394,10 +536,16 @@ def _apply_profile_filter(result, profile, doc_type: str):
|
||||
check.skipped = True
|
||||
check.hint = "Nicht relevant (kein B2C Online-Shop)"
|
||||
|
||||
# Widerruf only relevant for B2C
|
||||
# Widerruf: Flag entire document as unnecessary for B2B
|
||||
if doc_type == "widerruf" and profile.business_type not in ("b2c", "unknown"):
|
||||
if check.severity == "INFO":
|
||||
check.skipped = True
|
||||
check.severity = "INFO"
|
||||
if not check.passed:
|
||||
check.hint = (
|
||||
"Als B2B-Unternehmen benoetigen Sie keine Widerrufsbelehrung "
|
||||
"(§355 BGB gilt nur fuer Verbrauchervertraege). "
|
||||
"Empfehlung: Entfernen Sie die Widerrufsbelehrung von "
|
||||
"Ihrer Website, da sie Verwirrung stiften kann."
|
||||
)
|
||||
|
||||
# Regulated profession: check for Kammer info
|
||||
if "kammer" in cid or "berufsordnung" in check.label.lower():
|
||||
@@ -431,55 +579,21 @@ def _doc_type_label(doc_type: str) -> str:
|
||||
|
||||
def _result_to_dict(r) -> dict:
|
||||
"""Convert DocCheckResult to JSON-serializable dict."""
|
||||
fields = ("id", "label", "passed", "severity", "matched_text",
|
||||
"level", "parent", "skipped", "hint")
|
||||
return {
|
||||
"label": r.label, "url": r.url, "doc_type": r.doc_type,
|
||||
"word_count": r.word_count, "completeness_pct": r.completeness_pct,
|
||||
"correctness_pct": r.correctness_pct,
|
||||
"checks": [
|
||||
{
|
||||
"id": c.id, "label": c.label, "passed": c.passed,
|
||||
"severity": c.severity, "matched_text": c.matched_text,
|
||||
"level": c.level, "parent": c.parent,
|
||||
"skipped": c.skipped, "hint": c.hint,
|
||||
}
|
||||
for c in r.checks
|
||||
],
|
||||
"checks": [{f: getattr(c, f) for f in fields} for c in r.checks],
|
||||
"findings_count": r.findings_count, "error": r.error,
|
||||
"scenario": getattr(r, "scenario", ""),
|
||||
}
|
||||
|
||||
|
||||
def _build_profile_html(profile) -> str:
|
||||
"""Build a small HTML block summarizing the detected business profile."""
|
||||
service_tags = ", ".join(profile.detected_services[:10]) or "keine erkannt"
|
||||
flags = []
|
||||
if profile.has_online_shop:
|
||||
flags.append("Online-Shop")
|
||||
if profile.has_editorial_content:
|
||||
flags.append("Redaktionelle Inhalte")
|
||||
if profile.is_regulated_profession:
|
||||
flags.append(f"Regulierter Beruf ({profile.regulated_profession_type})")
|
||||
if profile.needs_odr:
|
||||
flags.append("ODR-pflichtig")
|
||||
flags_str = ", ".join(flags) or "keine"
|
||||
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:700px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px">'
|
||||
'<h3 style="margin:0 0 8px;font-size:14px;color:#0369a1">'
|
||||
'Erkanntes Geschaeftsmodell</h3>'
|
||||
'<table style="font-size:13px;color:#374151">'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Typ:</td>'
|
||||
f'<td><strong>{profile.business_type.upper()}</strong>'
|
||||
f' ({profile.industry})</td></tr>'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Merkmale:</td>'
|
||||
f'<td>{flags_str}</td></tr>'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Dienste:</td>'
|
||||
f'<td>{service_tags}</td></tr>'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Konfidenz:</td>'
|
||||
f'<td>{int(profile.confidence * 100)}%</td></tr>'
|
||||
'</table></div>'
|
||||
)
|
||||
from .agent_doc_check_report import build_profile_html
|
||||
return build_profile_html(profile)
|
||||
|
||||
|
||||
# Cross-check extracted to compliance.services.banner_cookie_cross_check
|
||||
|
||||
@@ -40,6 +40,121 @@ def _hint_box(hint: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_management_summary(results: list[DocCheckResult]) -> str:
|
||||
"""Build a plain-language management summary for the CEO/GF.
|
||||
|
||||
No legal jargon — concrete actions that can be delegated to staff,
|
||||
lawyers, or the DPO.
|
||||
"""
|
||||
ok = [r for r in results if r.completeness_pct == 100 and not r.error]
|
||||
fixable = [r for r in results if 0 < r.completeness_pct < 100 and not r.error]
|
||||
critical = [r for r in results if r.completeness_pct == 0 and not r.error]
|
||||
errors = [r for r in results if r.error]
|
||||
|
||||
html = [
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:700px;margin:0 auto 20px;padding:16px 20px;'
|
||||
'background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px">',
|
||||
'<h2 style="margin:0 0 12px;font-size:18px;color:#1e293b">'
|
||||
'Zusammenfassung fuer die Geschaeftsfuehrung</h2>',
|
||||
]
|
||||
|
||||
# Overall status
|
||||
total = len(results) - len(errors)
|
||||
if total == 0:
|
||||
html.append('<p>Keine Dokumente geprueft.</p></div>')
|
||||
return "\n".join(html)
|
||||
|
||||
if len(ok) == total:
|
||||
html.append(
|
||||
'<p style="color:#16a34a;font-weight:600;font-size:15px">'
|
||||
'Alle Dokumente sind vollstaendig. Keine dringenden Massnahmen noetig.</p>'
|
||||
)
|
||||
else:
|
||||
html.append(
|
||||
f'<p style="font-size:14px;color:#475569">'
|
||||
f'{len(ok)} von {total} Dokumenten sind vollstaendig. '
|
||||
f'{len(fixable)} brauchen Korrekturen'
|
||||
f'{f", {len(critical)} fehlen oder sind unbrauchbar" if critical else ""}.</p>'
|
||||
)
|
||||
|
||||
# Concrete actions
|
||||
actions: list[str] = []
|
||||
for r in results:
|
||||
if r.error or r.completeness_pct == 100:
|
||||
continue
|
||||
failed_checks = [
|
||||
c for c in r.checks
|
||||
if c.level == 1 and not c.passed and not c.skipped
|
||||
and c.severity != "INFO"
|
||||
]
|
||||
for c in failed_checks[:3]: # Max 3 per document
|
||||
action = _check_to_action(r.label, c.label, c.hint)
|
||||
if action:
|
||||
actions.append(action)
|
||||
|
||||
if actions:
|
||||
html.append(
|
||||
'<h3 style="font-size:14px;color:#334155;margin:16px 0 8px">'
|
||||
'Konkrete Aufgaben:</h3>'
|
||||
'<ol style="font-size:13px;color:#475569;padding-left:20px;margin:0">'
|
||||
)
|
||||
for a in actions[:10]: # Max 10 actions
|
||||
html.append(f'<li style="margin-bottom:6px">{a}</li>')
|
||||
html.append('</ol>')
|
||||
|
||||
html.append('</div>')
|
||||
return "\n".join(html)
|
||||
|
||||
|
||||
def _check_to_action(doc_label: str, check_label: str, hint: str) -> str:
|
||||
"""Convert a failed check into a plain-language action item."""
|
||||
# Map technical check labels to business-language actions
|
||||
label_lower = check_label.lower()
|
||||
|
||||
if "datenschutzbeauftragter" in label_lower or "dsb" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Ihren Datenschutzbeauftragten "
|
||||
f"mit Kontaktdaten erwaehnen. Pflicht ab 20 Mitarbeitern.")
|
||||
|
||||
if "beschwerderecht" in label_lower or "art. 77" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Hinweis auf das Beschwerderecht "
|
||||
f"bei der Aufsichtsbehoerde ergaenzen (Name + Kontakt der Behoerde).")
|
||||
|
||||
if "betroffenenrechte" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Alle Betroffenenrechte "
|
||||
f"(Auskunft, Berichtigung, Loeschung, etc.) einzeln auffuehren.")
|
||||
|
||||
if "verantwortlicher" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Vollstaendige Firmenbezeichnung "
|
||||
f"mit Rechtsform, Adresse, E-Mail und Telefon eintragen.")
|
||||
|
||||
if "interessenabwaegung" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Bei 'berechtigtem Interesse' "
|
||||
f"die Abwaegung dokumentieren. Aufgabe fuer den DSB/Rechtsanwalt.")
|
||||
|
||||
if "widerrufsbelehrung" in label_lower or "widerruf" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Gesetzliche Widerrufsbelehrung "
|
||||
f"mit 14-Tage-Frist und Musterformular bereitstellen.")
|
||||
|
||||
if "loeschkonzept" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Loeschfristen und -prozess "
|
||||
f"dokumentieren. Aufgabe fuer den DSB.")
|
||||
|
||||
if "profiling" in label_lower or "art. 22" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Hinweis ergaenzen ob "
|
||||
f"automatisierte Entscheidungen stattfinden oder nicht.")
|
||||
|
||||
if "nicht im eingereichten text" in label_lower:
|
||||
return (f"<strong>{doc_label}:</strong> Das eingereichte Dokument "
|
||||
f"enthaelt nicht den erwarteten Inhalt. Bitte korrekte URL pruefen.")
|
||||
|
||||
# Generic fallback
|
||||
if hint and len(hint) < 150:
|
||||
return f"<strong>{doc_label}:</strong> {hint[:120]}"
|
||||
|
||||
return f"<strong>{doc_label}:</strong> '{check_label}' muss ergaenzt werden."
|
||||
|
||||
|
||||
def build_html_report(
|
||||
results: list[DocCheckResult],
|
||||
cookie_result: dict | None,
|
||||
@@ -173,3 +288,37 @@ def _render_cookie_banner(html: list[str], cookie_result: dict) -> None:
|
||||
else:
|
||||
html.append('<br><span style="color:#22c55e">Keine Verstoesse erkannt.</span>')
|
||||
html.append('</div>')
|
||||
|
||||
|
||||
def build_profile_html(profile) -> str:
|
||||
"""Build a small HTML block summarizing the detected business profile."""
|
||||
service_tags = ", ".join(profile.detected_services[:10]) or "keine erkannt"
|
||||
flags = []
|
||||
if profile.has_online_shop:
|
||||
flags.append("Online-Shop")
|
||||
if profile.has_editorial_content:
|
||||
flags.append("Redaktionelle Inhalte")
|
||||
if profile.is_regulated_profession:
|
||||
flags.append(f"Regulierter Beruf ({profile.regulated_profession_type})")
|
||||
if profile.needs_odr:
|
||||
flags.append("ODR-pflichtig")
|
||||
flags_str = ", ".join(flags) or "keine"
|
||||
|
||||
return (
|
||||
'<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;'
|
||||
'max-width:700px;margin:0 auto 16px;padding:12px 16px;'
|
||||
'background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px">'
|
||||
'<h3 style="margin:0 0 8px;font-size:14px;color:#0369a1">'
|
||||
'Erkanntes Geschaeftsmodell</h3>'
|
||||
'<table style="font-size:13px;color:#374151">'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Typ:</td>'
|
||||
f'<td><strong>{profile.business_type.upper()}</strong>'
|
||||
f' ({profile.industry})</td></tr>'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Merkmale:</td>'
|
||||
f'<td>{flags_str}</td></tr>'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Dienste:</td>'
|
||||
f'<td>{service_tags}</td></tr>'
|
||||
f'<tr><td style="padding:2px 12px 2px 0;color:#6b7280">Konfidenz:</td>'
|
||||
f'<td>{int(profile.confidence * 100)}%</td></tr>'
|
||||
'</table></div>'
|
||||
)
|
||||
|
||||
@@ -65,6 +65,7 @@ class DocCheckResult(BaseModel):
|
||||
checks: list[CheckItem] = []
|
||||
findings_count: int = 0
|
||||
error: str = ""
|
||||
scenario: str = "" # regenerate | fix | import | skip
|
||||
|
||||
|
||||
class DocCheckResponse(BaseModel):
|
||||
|
||||
@@ -90,6 +90,7 @@ async def record_consent(
|
||||
scripts_blocked=body.scripts_blocked,
|
||||
scripts_released=body.scripts_released,
|
||||
cookies_set=body.cookies_set,
|
||||
tz_name=body.timezone,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -248,7 +248,10 @@ async def create_evidence(
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return _build_evidence_response(evidence)
|
||||
resp = _build_evidence_response(evidence)
|
||||
if dsms_cid:
|
||||
resp["dsms_cid"] = dsms_cid
|
||||
return resp
|
||||
|
||||
|
||||
@router.delete("/evidence/{evidence_id}")
|
||||
@@ -313,6 +316,25 @@ async def upload_evidence(
|
||||
evidence.confidence_level = EvidenceConfidenceEnum.E1
|
||||
evidence.truth_status = EvidenceTruthStatusEnum.UPLOADED
|
||||
|
||||
# Archive to DSMS (best-effort, non-blocking)
|
||||
dsms_cid = None
|
||||
try:
|
||||
from compliance.services.dsms_client import archive_to_dsms
|
||||
dsms_result = await archive_to_dsms(
|
||||
content=content, filename=file.filename,
|
||||
document_type="evidence", document_id=str(evidence.id),
|
||||
version="1", tenant_id=control_id,
|
||||
)
|
||||
dsms_cid = dsms_result.get("cid")
|
||||
if dsms_cid:
|
||||
evidence.confidence_level = EvidenceConfidenceEnum.E2
|
||||
from compliance.api.audit_trail_utils import log_audit_trail
|
||||
log_audit_trail(db, "evidence", str(evidence.id), title, "archive",
|
||||
"system", field_changed="dsms_cid", new_value=dsms_cid,
|
||||
change_summary=f"Evidence archived to DSMS: {dsms_cid}")
|
||||
except Exception:
|
||||
pass # DSMS unavailable
|
||||
|
||||
# Four-Eyes: check if the linked control's domain requires it
|
||||
control_domain = control.domain.value if control.domain else ""
|
||||
if _requires_four_eyes(control_domain):
|
||||
@@ -321,7 +343,10 @@ async def upload_evidence(
|
||||
|
||||
db.commit()
|
||||
|
||||
return _build_evidence_response(evidence)
|
||||
resp = _build_evidence_response(evidence)
|
||||
if dsms_cid:
|
||||
resp["dsms_cid"] = dsms_cid
|
||||
return resp
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -813,7 +838,10 @@ async def review_evidence(
|
||||
db.commit()
|
||||
|
||||
db.refresh(evidence)
|
||||
return _build_evidence_response(evidence)
|
||||
resp = _build_evidence_response(evidence)
|
||||
if dsms_cid:
|
||||
resp["dsms_cid"] = dsms_cid
|
||||
return resp
|
||||
|
||||
|
||||
@router.patch("/evidence/{evidence_id}/reject", response_model=EvidenceResponse)
|
||||
@@ -840,7 +868,10 @@ async def reject_evidence(
|
||||
db.commit()
|
||||
|
||||
db.refresh(evidence)
|
||||
return _build_evidence_response(evidence)
|
||||
resp = _build_evidence_response(evidence)
|
||||
if dsms_cid:
|
||||
resp["dsms_cid"] = dsms_cid
|
||||
return resp
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Vendor Contract Assessment Routes — Automated vendor document analysis.
|
||||
|
||||
Uploads vendor contracts (AVV, SCC, TOM annex, sub-processor list),
|
||||
runs them through the Doc-Check L1/L2 engine + LLM verification,
|
||||
and produces a professional Pruefprotokoll.
|
||||
|
||||
POST /vendor-compliance/assessments — Start assessment (async)
|
||||
GET /vendor-compliance/assessments — List assessments
|
||||
GET /vendor-compliance/assessments/{id} — Poll status / get result
|
||||
POST /vendor-compliance/assessments/{id}/approve — DSB approval
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from compliance.services.dsi_document_checker import (
|
||||
check_document_completeness,
|
||||
)
|
||||
from compliance.services.vendor_assessment_cross_check import (
|
||||
cross_check_documents,
|
||||
)
|
||||
from compliance.services.vendor_assessment_report import (
|
||||
build_pruefprotokoll,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/vendor-compliance", tags=["vendor-assessment"])
|
||||
|
||||
|
||||
# ── Request / Response Models ───────────────────────────────────────
|
||||
|
||||
class DocumentEntry(BaseModel):
|
||||
doc_type: str = "auto" # avv, scc, tom_annex, sub_processor_list, agb, auto
|
||||
label: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
class AssessmentRequest(BaseModel):
|
||||
vendor_name: str
|
||||
documents: list[DocumentEntry]
|
||||
recipient: str = ""
|
||||
|
||||
|
||||
class AssessmentStartResponse(BaseModel):
|
||||
assessment_id: str
|
||||
status: str = "running"
|
||||
|
||||
|
||||
class FindingItem(BaseModel):
|
||||
id: str
|
||||
category: str
|
||||
severity: str
|
||||
type: str # OK, GAP, RISK
|
||||
title: str
|
||||
description: str = ""
|
||||
recommendation: str = ""
|
||||
document_label: str = ""
|
||||
document_type: str = ""
|
||||
check_id: str = ""
|
||||
citations: list[str] = []
|
||||
|
||||
|
||||
class DocumentResult(BaseModel):
|
||||
label: str
|
||||
url: str
|
||||
doc_type: str
|
||||
word_count: int = 0
|
||||
completeness_pct: int = 0
|
||||
correctness_pct: int = 0
|
||||
checks: list[dict] = []
|
||||
findings_count: int = 0
|
||||
error: str = ""
|
||||
|
||||
|
||||
class AssessmentResult(BaseModel):
|
||||
vendor_name: str
|
||||
documents: list[DocumentResult]
|
||||
findings: list[FindingItem]
|
||||
overall_score: int = 0
|
||||
category_scores: dict[str, int] = {}
|
||||
cross_check_findings: list[dict] = []
|
||||
report_html: str = ""
|
||||
checked_at: str = ""
|
||||
|
||||
|
||||
class AssessmentStatusResponse(BaseModel):
|
||||
assessment_id: str
|
||||
status: str
|
||||
progress: str = ""
|
||||
result: Optional[AssessmentResult] = None
|
||||
error: str = ""
|
||||
|
||||
|
||||
# ── In-memory job store ─────────────────────────────────────────────
|
||||
|
||||
_assessment_jobs: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ── Endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/assessments", response_model=AssessmentStartResponse)
|
||||
async def start_assessment(req: AssessmentRequest):
|
||||
"""Start an async vendor contract assessment."""
|
||||
assessment_id = str(_uuid.uuid4())
|
||||
_assessment_jobs[assessment_id] = {
|
||||
"status": "running",
|
||||
"progress": "Initialisierung...",
|
||||
"result": None,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
asyncio.create_task(_run_assessment(assessment_id, req))
|
||||
return AssessmentStartResponse(assessment_id=assessment_id)
|
||||
|
||||
|
||||
@router.get("/assessments/{assessment_id}", response_model=AssessmentStatusResponse)
|
||||
async def get_assessment_status(assessment_id: str):
|
||||
"""Poll assessment status or retrieve completed result."""
|
||||
job = _assessment_jobs.get(assessment_id)
|
||||
if not job:
|
||||
return AssessmentStatusResponse(
|
||||
assessment_id=assessment_id, status="not_found",
|
||||
error="Assessment nicht gefunden",
|
||||
)
|
||||
return AssessmentStatusResponse(
|
||||
assessment_id=assessment_id,
|
||||
status=job["status"],
|
||||
progress=job.get("progress", ""),
|
||||
result=job.get("result"),
|
||||
error=job.get("error", ""),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/assessments")
|
||||
async def list_assessments():
|
||||
"""List all assessments (from in-memory store)."""
|
||||
items = []
|
||||
for aid, job in _assessment_jobs.items():
|
||||
r = job.get("result")
|
||||
items.append({
|
||||
"assessment_id": aid,
|
||||
"status": job["status"],
|
||||
"vendor_name": r.vendor_name if r else "",
|
||||
"overall_score": r.overall_score if r else 0,
|
||||
"document_count": len(r.documents) if r else 0,
|
||||
"findings_count": len(r.findings) if r else 0,
|
||||
})
|
||||
return {"assessments": items}
|
||||
|
||||
|
||||
@router.post("/assessments/{assessment_id}/approve")
|
||||
async def approve_assessment(assessment_id: str):
|
||||
"""Mark an assessment as approved by DSB."""
|
||||
job = _assessment_jobs.get(assessment_id)
|
||||
if not job or job["status"] != "completed":
|
||||
return {"error": "Assessment nicht abgeschlossen"}
|
||||
job["status"] = "approved"
|
||||
return {"status": "approved", "assessment_id": assessment_id}
|
||||
|
||||
|
||||
# ── Background Processing ──────────────────────────────────────────
|
||||
|
||||
CONSENT_TESTER_URL = "http://bp-compliance-consent-tester:8094"
|
||||
|
||||
# Doc-type auto-detection keywords
|
||||
_DOC_TYPE_KEYWORDS = {
|
||||
"avv": ["auftragsverarbeit", "auftrags-verarbeit", "data processing agreement",
|
||||
"dpa ", "art. 28", "art.28", "artikel 28"],
|
||||
"scc": ["standardvertragsklausel", "standard contractual clauses",
|
||||
"2021/914", "klausel 14", "module 2", "modul 2"],
|
||||
"tom_annex": ["technische und organisatorische", "tom-anlage",
|
||||
"art. 32", "zutrittskontrolle", "zugangskontrolle",
|
||||
"zugriffskontrolle", "verfuegbarkeitskontrolle"],
|
||||
"sub_processor_list": ["unterauftragnehmer", "sub-processor",
|
||||
"subprocessor", "unterauftragsverarbeiter"],
|
||||
"agb": ["allgemeine geschaeftsbedingungen", "nutzungsbedingungen",
|
||||
"terms of service", "terms and conditions"],
|
||||
}
|
||||
|
||||
|
||||
def _detect_doc_type(text: str, label: str) -> str:
|
||||
"""Auto-detect document type from content and label."""
|
||||
combined = (text[:3000] + " " + label).lower()
|
||||
scores: dict[str, int] = {}
|
||||
for dtype, keywords in _DOC_TYPE_KEYWORDS.items():
|
||||
scores[dtype] = sum(1 for kw in keywords if kw in combined)
|
||||
if not scores or max(scores.values()) == 0:
|
||||
return "agb" # fallback
|
||||
return max(scores, key=scores.get)
|
||||
|
||||
|
||||
async def _extract_text(url: str) -> tuple[str, int]:
|
||||
"""Extract text from a URL via consent-tester or direct fetch."""
|
||||
import httpx
|
||||
|
||||
# Try consent-tester first (handles JS-rendered pages)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
f"{CONSENT_TESTER_URL}/dsi-discovery",
|
||||
json={"url": url, "max_documents": 1},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
docs = data.get("documents", [])
|
||||
if docs:
|
||||
text = docs[0].get("full_text", "")
|
||||
wc = docs[0].get("word_count", 0)
|
||||
if len(text) > 50:
|
||||
return text, wc
|
||||
# Fallback to full page
|
||||
fp = data.get("html_full_page", "")
|
||||
if len(fp) > 50:
|
||||
return fp, len(fp.split())
|
||||
except Exception as e:
|
||||
logger.warning("consent-tester failed for %s: %s", url, e)
|
||||
|
||||
# Direct fetch fallback
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url)
|
||||
text = resp.text
|
||||
return text, len(text.split())
|
||||
except Exception as e:
|
||||
logger.error("Direct fetch failed for %s: %s", url, e)
|
||||
return "", 0
|
||||
|
||||
|
||||
async def _run_assessment(assessment_id: str, req: AssessmentRequest):
|
||||
"""Background task: analyze all documents and produce Pruefprotokoll."""
|
||||
job = _assessment_jobs[assessment_id]
|
||||
doc_results: list[DocumentResult] = []
|
||||
all_findings: list[FindingItem] = []
|
||||
doc_texts: dict[str, str] = {} # doc_type → text (for cross-check)
|
||||
|
||||
try:
|
||||
total = len(req.documents)
|
||||
|
||||
for i, entry in enumerate(req.documents):
|
||||
job["progress"] = f"Dokument {i+1}/{total}: {entry.label or entry.url[:40]}..."
|
||||
|
||||
# 1. Extract text
|
||||
text, word_count = await _extract_text(entry.url)
|
||||
if not text or len(text) < 50:
|
||||
doc_results.append(DocumentResult(
|
||||
label=entry.label or entry.url,
|
||||
url=entry.url,
|
||||
doc_type=entry.doc_type,
|
||||
error="Text konnte nicht extrahiert werden",
|
||||
))
|
||||
continue
|
||||
|
||||
# 2. Detect doc_type if auto
|
||||
doc_type = entry.doc_type
|
||||
if doc_type == "auto":
|
||||
doc_type = _detect_doc_type(text, entry.label)
|
||||
logger.info("Auto-detected doc_type=%s for %s", doc_type, entry.label)
|
||||
|
||||
doc_texts[doc_type] = text
|
||||
|
||||
# 3. Run checklist
|
||||
label = entry.label or f"{doc_type.upper()}: {entry.url[:50]}"
|
||||
result = check_document_completeness(text, doc_type, label, entry.url)
|
||||
|
||||
checks = result.get("checks", [])
|
||||
completeness = result.get("completeness_pct", 0)
|
||||
correctness = result.get("correctness_pct", 0)
|
||||
|
||||
# 4. Extract findings from failed checks
|
||||
failed_checks = [c for c in checks if not c.get("passed") and not c.get("skipped")]
|
||||
for fc in failed_checks:
|
||||
severity = fc.get("severity", "MEDIUM")
|
||||
ftype = "GAP" if severity in ("CRITICAL", "HIGH") else "RISK"
|
||||
|
||||
all_findings.append(FindingItem(
|
||||
id=f"{assessment_id[:8]}-{fc['id']}",
|
||||
category=_check_to_category(fc["id"], doc_type),
|
||||
severity=severity,
|
||||
type=ftype,
|
||||
title=fc.get("label", ""),
|
||||
description=fc.get("hint", ""),
|
||||
recommendation=fc.get("hint", ""),
|
||||
document_label=label,
|
||||
document_type=doc_type,
|
||||
check_id=fc["id"],
|
||||
citations=[fc.get("matched_text", "")] if fc.get("matched_text") else [],
|
||||
))
|
||||
|
||||
doc_results.append(DocumentResult(
|
||||
label=label,
|
||||
url=entry.url,
|
||||
doc_type=doc_type,
|
||||
word_count=word_count,
|
||||
completeness_pct=completeness,
|
||||
correctness_pct=correctness,
|
||||
checks=checks,
|
||||
findings_count=len(failed_checks),
|
||||
))
|
||||
|
||||
# 5. Cross-check between documents
|
||||
job["progress"] = "Cross-Check zwischen Dokumenten..."
|
||||
cross_findings = cross_check_documents(doc_texts, req.vendor_name)
|
||||
|
||||
# 6. Calculate scores
|
||||
category_scores = _calculate_category_scores(doc_results)
|
||||
overall = _calculate_overall_score(category_scores, all_findings, cross_findings)
|
||||
|
||||
# 7. Build result
|
||||
result = AssessmentResult(
|
||||
vendor_name=req.vendor_name,
|
||||
documents=doc_results,
|
||||
findings=all_findings,
|
||||
overall_score=overall,
|
||||
category_scores=category_scores,
|
||||
cross_check_findings=cross_findings,
|
||||
checked_at=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
# 8. Generate Pruefprotokoll HTML
|
||||
try:
|
||||
result.report_html = build_pruefprotokoll(result.model_dump())
|
||||
except Exception as e:
|
||||
logger.warning("Report generation failed: %s", e)
|
||||
|
||||
job["status"] = "completed"
|
||||
job["progress"] = ""
|
||||
job["result"] = result
|
||||
logger.info("Assessment %s completed: %d docs, %d findings, score=%d%%",
|
||||
assessment_id, len(doc_results), len(all_findings), overall)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Assessment %s failed", assessment_id)
|
||||
job["status"] = "failed"
|
||||
job["error"] = str(e)
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _check_to_category(check_id: str, doc_type: str) -> str:
|
||||
"""Map a check ID to a finding category."""
|
||||
prefix_map = {
|
||||
"avv_instruction": "INSTRUCTION",
|
||||
"avv_confidentiality": "CONFIDENTIALITY",
|
||||
"avv_tom": "TOM",
|
||||
"avv_subprocessor": "SUBPROCESSOR",
|
||||
"avv_data_subject": "DATA_SUBJECT_RIGHTS",
|
||||
"avv_dpia": "GENERAL",
|
||||
"avv_deletion": "DELETION",
|
||||
"avv_audit": "AUDIT_RIGHTS",
|
||||
"avv_breach": "INCIDENT",
|
||||
"avv_liability": "LIABILITY",
|
||||
"avv_subject": "AVV_CONTENT",
|
||||
"scc_": "TRANSFER",
|
||||
"tom_": "TOM",
|
||||
"sub_": "SUBPROCESSOR",
|
||||
}
|
||||
for prefix, cat in prefix_map.items():
|
||||
if check_id.startswith(prefix):
|
||||
return cat
|
||||
return doc_type.upper()
|
||||
|
||||
|
||||
def _calculate_category_scores(docs: list[DocumentResult]) -> dict[str, int]:
|
||||
"""Calculate per-category compliance scores from document results."""
|
||||
cat_totals: dict[str, int] = {}
|
||||
cat_passed: dict[str, int] = {}
|
||||
|
||||
for doc in docs:
|
||||
for check in doc.checks:
|
||||
if check.get("skipped"):
|
||||
continue
|
||||
cat = _check_to_category(check.get("id", ""), doc.doc_type)
|
||||
cat_totals[cat] = cat_totals.get(cat, 0) + 1
|
||||
if check.get("passed"):
|
||||
cat_passed[cat] = cat_passed.get(cat, 0) + 1
|
||||
|
||||
scores = {}
|
||||
for cat, total in cat_totals.items():
|
||||
passed = cat_passed.get(cat, 0)
|
||||
scores[cat] = round(passed / total * 100) if total > 0 else 0
|
||||
return scores
|
||||
|
||||
|
||||
def _calculate_overall_score(
|
||||
category_scores: dict[str, int],
|
||||
findings: list[FindingItem],
|
||||
cross_findings: list[dict],
|
||||
) -> int:
|
||||
"""Calculate overall compliance score."""
|
||||
if not category_scores:
|
||||
return 0
|
||||
|
||||
# Weighted average: CRITICAL categories count double
|
||||
critical_cats = {"INSTRUCTION", "TOM", "SUBPROCESSOR", "DELETION", "INCIDENT", "TRANSFER"}
|
||||
total_weight = 0
|
||||
weighted_sum = 0
|
||||
|
||||
for cat, score in category_scores.items():
|
||||
weight = 2 if cat in critical_cats else 1
|
||||
weighted_sum += score * weight
|
||||
total_weight += weight
|
||||
|
||||
base = round(weighted_sum / total_weight) if total_weight > 0 else 0
|
||||
|
||||
# Penalty for critical findings
|
||||
critical_count = sum(1 for f in findings if f.severity == "CRITICAL")
|
||||
cross_critical = sum(1 for f in cross_findings if f.get("severity") == "CRITICAL")
|
||||
penalty = (critical_count + cross_critical) * 5
|
||||
|
||||
return max(0, min(100, base - penalty))
|
||||
@@ -30,6 +30,7 @@ class ConsentCreate(BaseModel):
|
||||
screen_resolution: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
consent_scope: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
# Script/Cookie-Tracking (Migration 108)
|
||||
scripts_blocked: List[dict[str, Any]] = []
|
||||
scripts_released: List[dict[str, Any]] = []
|
||||
|
||||
@@ -62,6 +62,28 @@ class BannerConsentService:
|
||||
return None
|
||||
return hashlib.sha256(ip.encode()).hexdigest()[:16]
|
||||
|
||||
@staticmethod
|
||||
def _resolve_geo_from_timezone(tz_name: Optional[str]) -> Optional[str]:
|
||||
"""Map IANA timezone to ISO country code (best-effort, no external deps)."""
|
||||
if not tz_name:
|
||||
return None
|
||||
tz_map: dict[str, str] = {
|
||||
"Europe/Berlin": "DE", "Europe/Vienna": "AT", "Europe/Zurich": "CH",
|
||||
"Europe/Amsterdam": "NL", "Europe/Brussels": "BE", "Europe/Paris": "FR",
|
||||
"Europe/London": "GB", "Europe/Madrid": "ES", "Europe/Rome": "IT",
|
||||
"Europe/Warsaw": "PL", "Europe/Prague": "CZ", "Europe/Stockholm": "SE",
|
||||
"Europe/Copenhagen": "DK", "Europe/Helsinki": "FI", "Europe/Oslo": "NO",
|
||||
"Europe/Dublin": "IE", "Europe/Lisbon": "PT", "Europe/Athens": "GR",
|
||||
"Europe/Bucharest": "RO", "Europe/Budapest": "HU", "Europe/Sofia": "BG",
|
||||
"Europe/Zagreb": "HR", "Europe/Ljubljana": "SI", "Europe/Bratislava": "SK",
|
||||
"Europe/Tallinn": "EE", "Europe/Riga": "LV", "Europe/Vilnius": "LT",
|
||||
"Europe/Luxembourg": "LU", "Europe/Valletta": "MT", "Europe/Nicosia": "CY",
|
||||
"America/New_York": "US", "America/Chicago": "US", "America/Denver": "US",
|
||||
"America/Los_Angeles": "US", "America/Toronto": "CA", "Asia/Tokyo": "JP",
|
||||
"Asia/Shanghai": "CN", "Asia/Kolkata": "IN", "Australia/Sydney": "AU",
|
||||
}
|
||||
return tz_map.get(tz_name)
|
||||
|
||||
def _log(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
@@ -184,6 +206,7 @@ class BannerConsentService:
|
||||
scripts_blocked: Optional[list[dict]] = None,
|
||||
scripts_released: Optional[list[dict]] = None,
|
||||
cookies_set: Optional[list[dict]] = None,
|
||||
tz_name: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upsert a device consent row for (tenant, site, device_fingerprint).
|
||||
|
||||
@@ -216,6 +239,7 @@ class BannerConsentService:
|
||||
"screen_resolution": screen_resolution,
|
||||
"session_id": session_id,
|
||||
"consent_scope": consent_scope or "domain",
|
||||
"geo_country": self._resolve_geo_from_timezone(tz_name),
|
||||
"scripts_blocked": scripts_blocked or [],
|
||||
"scripts_released": scripts_released or [],
|
||||
"cookies_set": cookies_set or [],
|
||||
|
||||
@@ -143,3 +143,83 @@ def cross_check_banner_vs_cookie(
|
||||
logger.info("Cross-check: %d findings (%d services, %d tracking before)",
|
||||
len(findings), len(all_tracking), len(tracking_before))
|
||||
return findings
|
||||
|
||||
|
||||
def cross_check_vendors_vs_dsi(
|
||||
vendors: list[dict],
|
||||
dsi_text: str,
|
||||
) -> list[dict]:
|
||||
"""Cross-check: Are all TCF vendors documented in the DSI?
|
||||
|
||||
Checks per vendor:
|
||||
1. Is the vendor mentioned by name?
|
||||
2. Is third-country transfer documented (if non-EU)?
|
||||
3. Is storage duration mentioned?
|
||||
|
||||
Returns list of CheckItem-compatible dicts.
|
||||
"""
|
||||
findings: list[dict] = []
|
||||
dsi_lower = dsi_text.lower()
|
||||
|
||||
for v in vendors:
|
||||
name = v.get("name", "")
|
||||
name_lower = name.lower()
|
||||
if not name_lower:
|
||||
continue
|
||||
|
||||
# Check if vendor is mentioned in DSI
|
||||
mentioned = any(kw in dsi_lower for kw in [
|
||||
name_lower,
|
||||
name_lower.replace(" ", ""),
|
||||
name_lower.split()[0] if " " in name_lower else name_lower,
|
||||
])
|
||||
|
||||
if not mentioned:
|
||||
findings.append({
|
||||
"id": f"vendor-{v.get('vendor_id', name_lower[:20])}",
|
||||
"label": f"Verarbeiter '{name}' fehlt in DSI",
|
||||
"passed": False,
|
||||
"severity": "HIGH",
|
||||
"level": 2,
|
||||
"parent": None,
|
||||
"skipped": False,
|
||||
"matched_text": "",
|
||||
"hint": (
|
||||
f"Der Cookie-Banner listet '{name}' als Verarbeiter "
|
||||
f"({v.get('zweck_kurz', 'unbekannt')}), aber die DSI "
|
||||
f"erwaehnt diesen Dienst nicht. Art. 13(1)(e) DSGVO "
|
||||
f"verlangt die Benennung aller Empfaenger."
|
||||
),
|
||||
"source": "vendor_cross_check",
|
||||
})
|
||||
|
||||
# Check third-country transfer documentation
|
||||
if v.get("drittland") and mentioned:
|
||||
country = v.get("land", "Drittland")
|
||||
transfer_mentioned = any(kw in dsi_lower for kw in [
|
||||
name_lower + ".*" + "usa",
|
||||
name_lower + ".*" + "drittland",
|
||||
"scc", "standardvertragsklausel", "data privacy framework",
|
||||
"angemessenheitsbeschluss",
|
||||
])
|
||||
if not transfer_mentioned:
|
||||
findings.append({
|
||||
"id": f"vendor-transfer-{v.get('vendor_id', '')}",
|
||||
"label": f"Drittlandtransfer fuer '{name}' nicht dokumentiert",
|
||||
"passed": False,
|
||||
"severity": "MEDIUM",
|
||||
"level": 2,
|
||||
"parent": None,
|
||||
"skipped": False,
|
||||
"matched_text": "",
|
||||
"hint": (
|
||||
f"'{name}' verarbeitet Daten in {country} (ausserhalb EWR). "
|
||||
f"Die DSI muss den Transfermechanismus benennen: "
|
||||
f"SCC (Art. 46(2)(c)) oder DPF (Angemessenheitsbeschluss)."
|
||||
),
|
||||
"source": "vendor_cross_check",
|
||||
})
|
||||
|
||||
logger.info("Vendor cross-check: %d findings for %d vendors",
|
||||
len(findings), len(vendors))
|
||||
return findings
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user