Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8be768772c |
+2
-3
@@ -130,11 +130,10 @@ rsync -avz --exclude node_modules --exclude .next --exclude .git \
|
|||||||
|
|
||||||
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services:
|
**breakpilot-core MUSS laufen!** Dieses Projekt nutzt Core-Services:
|
||||||
- Valkey (Session-Cache)
|
- Valkey (Session-Cache)
|
||||||
|
- Vault (Secrets)
|
||||||
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
||||||
- Nginx (Reverse Proxy)
|
- Nginx (Reverse Proxy)
|
||||||
|
|
||||||
Secrets liegen in Infisical (`secrets.meghsakha.com`); die Projektverknuepfung steht in `.infisical.json`. Lokal mit `infisical run --env=dev -- docker compose up` (oder `make dev`) starten — `.env`/`.env.local` werden nicht mehr verwendet.
|
|
||||||
|
|
||||||
**Externe Services (Production):**
|
**Externe Services (Production):**
|
||||||
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
|
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
|
||||||
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
|
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
|
||||||
@@ -317,7 +316,7 @@ ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/brea
|
|||||||
|
|
||||||
### 5. Sensitive Dateien
|
### 5. Sensitive Dateien
|
||||||
**NIEMALS aendern oder committen:**
|
**NIEMALS aendern oder committen:**
|
||||||
- `.env`, `.env.local`, Infisical-Tokens, SSL-Zertifikate
|
- `.env`, `.env.local`, Vault-Tokens, SSL-Zertifikate
|
||||||
- `*.pdf`, `*.docx`, kompilierte Binaries, grosse Medien
|
- `*.pdf`, `*.docx`, kompilierte Binaries, grosse Medien
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Wenn Hochrisiko:
|
|||||||
|
|
||||||
- [ ] **Transit:** TLS 1.3 für alle Verbindungen
|
- [ ] **Transit:** TLS 1.3 für alle Verbindungen
|
||||||
- [ ] **Rest:** Datenbank-Verschlüsselung
|
- [ ] **Rest:** Datenbank-Verschlüsselung
|
||||||
- [ ] **Secrets:** Infisical (`secrets.meghsakha.com`) für Credentials
|
- [ ] **Secrets:** Vault für Credentials
|
||||||
|
|
||||||
### Zugriffskontrollen
|
### Zugriffskontrollen
|
||||||
|
|
||||||
|
|||||||
@@ -136,14 +136,12 @@ jobs:
|
|||||||
runs-on: docker
|
runs-on: docker
|
||||||
needs: detect-changes
|
needs: detect-changes
|
||||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
||||||
container: golangci/golangci-lint:v1.64.8-alpine
|
container: golangci/golangci-lint:v1.62-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
apk add --no-cache git
|
apk add --no-cache git
|
||||||
# Full clone so `main` is a local ref — new-from-merge-base needs the merge base.
|
git clone --depth 1 --branch ${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
git clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
|
||||||
git checkout ${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}
|
|
||||||
- name: Lint ai-compliance-sdk
|
- name: Lint ai-compliance-sdk
|
||||||
run: |
|
run: |
|
||||||
[ -d "ai-compliance-sdk" ] || exit 0
|
[ -d "ai-compliance-sdk" ] || exit 0
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"workspaceId": "996bda36-9e01-4071-ae8d-69a9f9ff5a23",
|
|
||||||
"defaultEnvironment": "",
|
|
||||||
"gitBranchToEnvironmentMapping": null
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Infisical Setup for Local Development
|
|
||||||
|
|
||||||
This is the per-developer onboarding for accessing the `breakpilot-compliance` secrets while developing locally. Once this is done, **everything you launch through `make dev` (or `infisical run …`) gets the dev secrets injected as environment variables** — including any Claude Code session that spawns those commands.
|
|
||||||
|
|
||||||
Secrets live in the self-hosted Infisical instance at **`secrets.meghsakha.com`**. The project link is committed in `.infisical.json`, so you don't need to know the project ID.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Install the Infisical CLI
|
|
||||||
|
|
||||||
**macOS (recommended):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install infisical/get-cli/infisical
|
|
||||||
```
|
|
||||||
|
|
||||||
**Other platforms / manual install:**
|
|
||||||
|
|
||||||
See <https://infisical.com/docs/cli/overview>. Verify with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
infisical --version
|
|
||||||
# infisical version 0.43.x (or newer)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Log in to the self-hosted instance
|
|
||||||
|
|
||||||
```bash
|
|
||||||
infisical login --domain https://secrets.meghsakha.com
|
|
||||||
```
|
|
||||||
|
|
||||||
This opens a browser for SSO. The login is persisted to your OS keychain — you only do this once per machine.
|
|
||||||
|
|
||||||
Sanity check:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd ~/projects/breakpilot-compliance # wherever you cloned the repo
|
|
||||||
infisical --domain https://secrets.meghsakha.com secrets --env=dev
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see a table of secret names + values. If you get an auth error, re-run `infisical login`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Verify the project link
|
|
||||||
|
|
||||||
The repo already contains `.infisical.json` pointing at the `breakpilot-compliance` project:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat .infisical.json
|
|
||||||
# { "workspaceId": "996bda36-9e01-4071-ae8d-69a9f9ff5a23", ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
If the file is missing (rare — only if you reset the repo), recreate it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
infisical init --domain https://secrets.meghsakha.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Pick the `breakpilot-compliance` project from the picker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Launch the stack
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev
|
|
||||||
```
|
|
||||||
|
|
||||||
This runs `infisical run --env=dev -- docker compose up`. Every service in the compose stack sees its secrets as normal env vars — no `.env` file ever touches disk.
|
|
||||||
|
|
||||||
Other targets:
|
|
||||||
|
|
||||||
| Target | What it does |
|
|
||||||
|--------|--------------|
|
|
||||||
| `make dev-build` | Same as `make dev` but rebuilds images first |
|
|
||||||
| `make dev-down` | Stop the stack (no secrets needed) |
|
|
||||||
| `make dev-logs` | Tail logs |
|
|
||||||
| `make dev-ps` | List running containers |
|
|
||||||
| `make secrets` | Print all secrets in `dev` (read-only) |
|
|
||||||
| `make secrets-set KEY=FOO VALUE=bar` | Add or update a secret in `dev` |
|
|
||||||
|
|
||||||
To target a different environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make dev ENV=staging
|
|
||||||
make secrets ENV=prod
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Using secrets from Claude Code
|
|
||||||
|
|
||||||
When Claude Code runs commands in this repo via its Bash tool, the commands inherit your shell's environment. Two patterns:
|
|
||||||
|
|
||||||
**Pattern A — let Claude launch the stack normally**
|
|
||||||
|
|
||||||
Claude just runs `make dev`. The Infisical CLI inside that command resolves secrets at run time and passes them to docker compose. Claude doesn't see plaintext secrets in its context, but the running services do.
|
|
||||||
|
|
||||||
**Pattern B — let Claude run a one-off script with secrets**
|
|
||||||
|
|
||||||
If Claude needs to execute a Python/Go script that requires secrets, wrap the command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
infisical run --env=dev -- python scripts/some_one_off.py
|
|
||||||
```
|
|
||||||
|
|
||||||
This works for any subprocess: pytest, alembic, go run, npm scripts. If Claude proposes a command that reads env vars and runs raw, ask it to wrap it in `infisical run --env=dev --` first.
|
|
||||||
|
|
||||||
**What Claude should not do:**
|
|
||||||
|
|
||||||
- `infisical export --env=dev > .env` — defeats the whole point and the `.gitignore` will still try to keep the file out.
|
|
||||||
- `infisical secrets get KEY --env=dev --raw` and pasting the value into a code edit — secrets must stay out of the repo.
|
|
||||||
|
|
||||||
If you want Claude to never accidentally dump secrets, add this to your `.claude/settings.json` permissions (project-level or user-level):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"deny": [
|
|
||||||
"Bash(infisical export*)",
|
|
||||||
"Bash(infisical secrets get*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Fix |
|
|
||||||
|---------|-----|
|
|
||||||
| `please either run infisical init or pass --projectId` | `.infisical.json` is missing or unreadable — re-run `infisical init` |
|
|
||||||
| `unauthorized` / `please log in` | Re-run `infisical login --domain https://secrets.meghsakha.com` |
|
|
||||||
| `make dev` says secret is empty | Check the name in `make secrets` matches what docker-compose expects, then update the service config or rename the secret in Infisical |
|
|
||||||
| Browser SSO doesn't open | Use `infisical login --domain https://secrets.meghsakha.com --method=user` and paste the URL manually |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What the dev env contains
|
|
||||||
|
|
||||||
Run `make secrets` to see the live list. As of this writing the dev env includes (at minimum):
|
|
||||||
|
|
||||||
- `BREAKPILOT_DB_PASSWORD`
|
|
||||||
- `BREAKPILOT_QDRANT_API_KEY`
|
|
||||||
- `LITELLM_API_KEY`
|
|
||||||
|
|
||||||
Every other variable in `.env.example` either has a sane default in `docker-compose.yml` or needs to be added to Infisical. To add one:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make secrets-set KEY=ANTHROPIC_API_KEY VALUE=sk-ant-xxxx
|
|
||||||
```
|
|
||||||
|
|
||||||
Or via the web UI: <https://secrets.meghsakha.com>.
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# breakpilot-compliance — developer workflow
|
|
||||||
#
|
|
||||||
# Secrets are managed in Infisical (secrets.meghsakha.com). The project
|
|
||||||
# link lives in .infisical.json. To get started:
|
|
||||||
# 1) infisical login --domain https://secrets.meghsakha.com (once per machine)
|
|
||||||
# 2) make dev
|
|
||||||
#
|
|
||||||
# .env / .env.local are NOT used in this repo anymore. Anything that needs
|
|
||||||
# secrets MUST be launched through `infisical run` so the values come from
|
|
||||||
# the secrets store instead of disk.
|
|
||||||
|
|
||||||
INFISICAL ?= infisical
|
|
||||||
INFISICAL_DOMAIN ?= https://secrets.meghsakha.com
|
|
||||||
ENV ?= dev
|
|
||||||
|
|
||||||
INFISICAL_RUN := $(INFISICAL) --domain $(INFISICAL_DOMAIN) run --env=$(ENV) --
|
|
||||||
INFISICAL_SECRETS := $(INFISICAL) --domain $(INFISICAL_DOMAIN) secrets --env=$(ENV)
|
|
||||||
|
|
||||||
.PHONY: help dev dev-build dev-down dev-logs dev-ps secrets secrets-set check-loc
|
|
||||||
|
|
||||||
help:
|
|
||||||
@echo "Targets:"
|
|
||||||
@echo " dev Start the full compose stack with secrets injected from Infisical"
|
|
||||||
@echo " dev-build Same as dev, but force a rebuild first"
|
|
||||||
@echo " dev-down Stop the compose stack (no secrets needed)"
|
|
||||||
@echo " dev-logs Tail logs from all services"
|
|
||||||
@echo " dev-ps Show running containers"
|
|
||||||
@echo " secrets List all secrets in the current env ($(ENV))"
|
|
||||||
@echo " secrets-set Set a secret (KEY=... VALUE=...)"
|
|
||||||
@echo " check-loc Run the 500-line LOC guard"
|
|
||||||
|
|
||||||
dev:
|
|
||||||
$(INFISICAL_RUN) docker compose up
|
|
||||||
|
|
||||||
dev-build:
|
|
||||||
$(INFISICAL_RUN) docker compose up --build
|
|
||||||
|
|
||||||
dev-down:
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
dev-logs:
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
dev-ps:
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
$(INFISICAL_SECRETS)
|
|
||||||
|
|
||||||
secrets-set:
|
|
||||||
@if [ -z "$(KEY)" ] || [ -z "$(VALUE)" ]; then \
|
|
||||||
echo "Usage: make secrets-set KEY=MY_KEY VALUE=my_value"; exit 1; \
|
|
||||||
fi
|
|
||||||
$(INFISICAL) --domain $(INFISICAL_DOMAIN) secrets set $(KEY)=$(VALUE) --env=$(ENV)
|
|
||||||
|
|
||||||
check-loc:
|
|
||||||
bash scripts/check-loc.sh
|
|
||||||
@@ -42,26 +42,23 @@ All containers share the external `breakpilot-network` Docker network and depend
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
**Prerequisites:** Docker, Go 1.24+, Python 3.12+, Node.js 20+, [Infisical CLI](https://infisical.com/docs/cli/overview)
|
**Prerequisites:** Docker, Go 1.24+, Python 3.12+, Node.js 20+
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||||
cd breakpilot-compliance
|
cd breakpilot-compliance
|
||||||
|
|
||||||
# One-time per machine: log in to the self-hosted Infisical instance
|
# Copy and populate secrets (never commit .env)
|
||||||
infisical login --domain https://secrets.meghsakha.com
|
cp .env.example .env
|
||||||
|
|
||||||
# Start the full stack with secrets injected from Infisical (env=dev)
|
# Start all services
|
||||||
make dev
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Secrets are pulled from Infisical (`secrets.meghsakha.com`) at runtime; `.env` files are not used. See [INFISICAL_SETUP.md](./INFISICAL_SETUP.md) for full onboarding, and `make help` for the rest of the targets (`dev-build`, `dev-down`, `secrets`, `secrets-set`).
|
|
||||||
|
|
||||||
For the Orca/Hetzner production target (x86_64), use the override:
|
For the Orca/Hetzner production target (x86_64), use the override:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev ENV=prod # or:
|
docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d
|
||||||
infisical run --env=prod -- docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -46,28 +46,6 @@ export interface CorpusOverview {
|
|||||||
totals: { documents: number; catalog_sources: number }
|
totals: { documents: number; catalog_sources: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ingested legal-corpus structure (from the vector store, via the Go SDK).
|
|
||||||
// Shows WHAT each eur-lex act consists of (articles/annexes/recitals), so the
|
|
||||||
// ingested corpus is not a black box for developers. ---
|
|
||||||
export interface LegalActStructure {
|
|
||||||
regulation_short: string
|
|
||||||
regulation_name: string
|
|
||||||
articles: number
|
|
||||||
annexes: number
|
|
||||||
recitals: number
|
|
||||||
chunks: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegalCorpus {
|
|
||||||
regulations: LegalActStructure[]
|
|
||||||
totals: {
|
|
||||||
regulations: number
|
|
||||||
articles: number
|
|
||||||
annexes: number
|
|
||||||
recitals: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Korpus-Dokumente: gruppieren nach Art (Gesetz/Leitfaden/Standard/Urteil)
|
// --- Korpus-Dokumente: gruppieren nach Art (Gesetz/Leitfaden/Standard/Urteil)
|
||||||
// + Herausgeber-Familie (DSK, EDPB, OWASP, NIST …). Deterministisch, pure. ---
|
// + Herausgeber-Familie (DSK, EDPB, OWASP, NIST …). Deterministisch, pure. ---
|
||||||
interface DocCat {
|
interface DocCat {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Link from 'next/link'
|
|||||||
import {
|
import {
|
||||||
type UseCaseRow,
|
type UseCaseRow,
|
||||||
type CorpusOverview,
|
type CorpusOverview,
|
||||||
type LegalCorpus,
|
|
||||||
licenseTierBadgeClass,
|
licenseTierBadgeClass,
|
||||||
commercialBadgeClass,
|
commercialBadgeClass,
|
||||||
groupUseCases,
|
groupUseCases,
|
||||||
@@ -12,46 +11,28 @@ import {
|
|||||||
|
|
||||||
const BACKEND_URL =
|
const BACKEND_URL =
|
||||||
process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
// The legal-corpus structure comes from the Go SDK (it owns the vector store).
|
|
||||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
// Fetched from the SDK and isolated in its own try/catch so a vector-store
|
|
||||||
// hiccup degrades to "no structure shown" instead of blanking the whole page.
|
|
||||||
async function fetchLegalCorpus(): Promise<LegalCorpus | null> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${SDK_URL}/sdk/v1/rag/legal-corpus`, {
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
|
||||||
return res.ok ? await res.json() : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getData(): Promise<{
|
async function getData(): Promise<{
|
||||||
useCases: UseCaseRow[]
|
useCases: UseCaseRow[]
|
||||||
corpus: CorpusOverview | null
|
corpus: CorpusOverview | null
|
||||||
legalCorpus: LegalCorpus | null
|
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const [ucRes, corpusRes, legalCorpus] = await Promise.all([
|
const [ucRes, corpusRes] = await Promise.all([
|
||||||
fetch(`${BACKEND_URL}/api/compliance/v1/controls/use-cases`, {
|
fetch(`${BACKEND_URL}/api/compliance/v1/controls/use-cases`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
fetch(`${BACKEND_URL}/api/compliance/v1/controls/corpus`, {
|
fetch(`${BACKEND_URL}/api/compliance/v1/controls/corpus`, {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
}),
|
}),
|
||||||
fetchLegalCorpus(),
|
|
||||||
])
|
])
|
||||||
return {
|
return {
|
||||||
useCases: ucRes.ok ? await ucRes.json() : [],
|
useCases: ucRes.ok ? await ucRes.json() : [],
|
||||||
corpus: corpusRes.ok ? await corpusRes.json() : null,
|
corpus: corpusRes.ok ? await corpusRes.json() : null,
|
||||||
legalCorpus,
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { useCases: [], corpus: null, legalCorpus: null }
|
return { useCases: [], corpus: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +46,7 @@ function Stat({ label, value }: { label: string; value: string | number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function CoveragePage() {
|
export default async function CoveragePage() {
|
||||||
const { useCases, corpus, legalCorpus } = await getData()
|
const { useCases, corpus } = await getData()
|
||||||
const groups = groupUseCases(useCases)
|
const groups = groupUseCases(useCases)
|
||||||
const totalRelevant = useCases.reduce((s, u) => s + u.atom_relevant, 0)
|
const totalRelevant = useCases.reduce((s, u) => s + u.atom_relevant, 0)
|
||||||
const totalAtoms = useCases.reduce((s, u) => s + u.atom_total, 0)
|
const totalAtoms = useCases.reduce((s, u) => s + u.atom_total, 0)
|
||||||
@@ -240,67 +221,6 @@ export default async function CoveragePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{legalCorpus?.regulations?.length ? (
|
|
||||||
<section className="space-y-2">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
|
||||||
Ingestierter Rechtskorpus – Struktur ({legalCorpus.totals.regulations}{' '}
|
|
||||||
Rechtsakte)
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Woraus jeder ingestierte eur-lex-Rechtsakt tatsächlich besteht:
|
|
||||||
Artikel (§), Anhänge, Erwägungsgründe und retrievbare Chunks — direkt
|
|
||||||
aus dem Vektorspeicher, damit kein Black-Box-Korpus entsteht.
|
|
||||||
</p>
|
|
||||||
<div className="overflow-auto rounded-lg border border-gray-200">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
|
||||||
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2">Rechtsakt</th>
|
|
||||||
<th className="px-4 py-2 text-right">Artikel (§)</th>
|
|
||||||
<th className="px-4 py-2 text-right">Anhänge</th>
|
|
||||||
<th className="px-4 py-2 text-right">Erwägungsgründe</th>
|
|
||||||
<th className="px-4 py-2 text-right">Chunks</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
|
||||||
{legalCorpus.regulations.map((r) => (
|
|
||||||
<tr key={r.regulation_short}>
|
|
||||||
<td className="px-4 py-2 text-gray-900">
|
|
||||||
<span className="font-medium">{r.regulation_short}</span>
|
|
||||||
{r.regulation_name !== r.regulation_short ? (
|
|
||||||
<span className="ml-2 text-xs text-gray-500">
|
|
||||||
{r.regulation_name}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right font-semibold">
|
|
||||||
{r.articles.toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
{r.annexes > 0 ? (
|
|
||||||
r.annexes.toLocaleString('de-DE')
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-300">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right text-gray-500">
|
|
||||||
{r.recitals > 0 ? (
|
|
||||||
r.recitals.toLocaleString('de-DE')
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-300">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right text-gray-500">
|
|
||||||
{r.chunks.toLocaleString('de-DE')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{corpus?.license_catalog?.length ? (
|
{corpus?.license_catalog?.length ? (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export const COMPLIANCE_COLLECTIONS = [
|
|||||||
'bp_compliance_ce',
|
'bp_compliance_ce',
|
||||||
'bp_compliance_datenschutz',
|
'bp_compliance_datenschutz',
|
||||||
'bp_dsfa_corpus',
|
'bp_dsfa_corpus',
|
||||||
'bp_compliance_recht',
|
// 'bp_compliance_recht' entfernt 2026-06-22: existiert NICHT in Qdrant -> 500 bei jeder Suche
|
||||||
|
// (wird still verworfen, aber unnoetige Last). Wieder aufnehmen, sobald die Collection befuellt ist.
|
||||||
'bp_legal_templates',
|
'bp_legal_templates',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ linters-settings:
|
|||||||
rules:
|
rules:
|
||||||
- name: exported
|
- name: exported
|
||||||
arguments:
|
arguments:
|
||||||
- disableStutteringCheck
|
- checkPrivateReceivers: false
|
||||||
|
- disableStutteringCheck: true
|
||||||
- name: error-return
|
- name: error-return
|
||||||
- name: increment-decrement
|
- name: increment-decrement
|
||||||
- name: var-declaration
|
- name: var-declaration
|
||||||
@@ -82,6 +83,6 @@ issues:
|
|||||||
max-issues-per-linter: 50
|
max-issues-per-linter: 50
|
||||||
max-same-issues: 5
|
max-same-issues: 5
|
||||||
|
|
||||||
# New code only: lint lines changed vs main, so pre-existing debt doesn't fail CI.
|
# New code only: don't fail on pre-existing issues in files we haven't touched.
|
||||||
# Needs the go-lint job to clone with a local `main` ref (see .gitea/workflows/ci.yaml).
|
# Remove this once a clean baseline is established.
|
||||||
new-from-merge-base: main
|
new: false
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
# ci-retrigger 2026-06-27: transient registry.meghsakha.com 502 on push (Runde 1) + last-build
|
|
||||||
# tag-bug skipped the rerun (Runde 2). No logic change — forces detect-changes to rebuild ai-sdk.
|
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -35,14 +33,6 @@ COPY migrations/ ./migrations/
|
|||||||
# Copy policy files (YAML rules)
|
# Copy policy files (YAML rules)
|
||||||
COPY policies/ ./policies/
|
COPY policies/ ./policies/
|
||||||
|
|
||||||
# Copy Compliance Execution Graph data (file-backed: Registry join-key copy + accepted control
|
|
||||||
# mappings + evidence requirements) consumed by GET /sdk/v1/compliance/obligation-status.
|
|
||||||
# data/obligations/obligation_join_keys.json is a synced copy of the repo-root Registry contract
|
|
||||||
# (the Obligation Registry owns the canonical file) — re-sync it when the Registry grows.
|
|
||||||
COPY data/control_mappings/ ./data/control_mappings/
|
|
||||||
COPY data/evidence_requirements/ ./data/evidence_requirements/
|
|
||||||
COPY data/obligations/ ./data/obligations/
|
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN adduser -D -u 1000 appuser
|
RUN adduser -D -u 1000 appuser
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ func main() {
|
|||||||
cmdEcho(os.Args[2:])
|
cmdEcho(os.Args[2:])
|
||||||
case "hierarchy":
|
case "hierarchy":
|
||||||
cmdHierarchy(os.Args[2:])
|
cmdHierarchy(os.Args[2:])
|
||||||
case "propose":
|
|
||||||
cmdPropose(os.Args[2:])
|
|
||||||
default:
|
default:
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@@ -43,7 +41,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func usage() {
|
func usage() {
|
||||||
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy|propose> [args]")
|
fmt.Fprintln(os.Stderr, "Usage: iace-audit <reachability|consistency|vocabulary|echo|hierarchy> [args]")
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmdReachability(_ []string) {
|
func cmdReachability(_ []string) {
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace/audit"
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type narrativeInput struct {
|
|
||||||
MachineType string `json:"machine_type"`
|
|
||||||
Narrative string `json:"narrative"`
|
|
||||||
MachineTypes []string `json:"machine_types,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// cmdPropose — Method P: offline dedup-candidate proposer.
|
|
||||||
//
|
|
||||||
// iace-audit propose <narrative.json> [<ground-truth.json>]
|
|
||||||
//
|
|
||||||
// Detect near-duplicate patterns, screen survivors against a ground truth (if
|
|
||||||
// given), judge them (heuristic by default, LLM when enabled), and write the
|
|
||||||
// human-review queue to audit-reports/proposals.{md,json}. Propose-only — it
|
|
||||||
// writes a report and never mutates the pattern library.
|
|
||||||
//
|
|
||||||
// Env:
|
|
||||||
//
|
|
||||||
// IACE_PROPOSE_THRESHOLD candidate score threshold (default 0.30)
|
|
||||||
// IACE_PROPOSE_LLM=1 use the offline LLM judge instead of the heuristic
|
|
||||||
// OLLAMA_URL ollama base URL (default http://localhost:11434)
|
|
||||||
// SELF_HOSTED_LLM_MODEL model name (default qwen2.5:32b-instruct)
|
|
||||||
func cmdPropose(args []string) {
|
|
||||||
if len(args) < 1 {
|
|
||||||
fmt.Fprintln(os.Stderr, "propose: usage: iace-audit propose <narrative.json> [<ground-truth.json>]")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
var in narrativeInput
|
|
||||||
must(readJSONFile(args[0], &in))
|
|
||||||
if in.Narrative == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "propose: narrative is empty")
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
var gt *iace.GroundTruth
|
|
||||||
if len(args) >= 2 {
|
|
||||||
var g iace.GroundTruth
|
|
||||||
must(readJSONFile(args[1], &g))
|
|
||||||
gt = &g
|
|
||||||
}
|
|
||||||
|
|
||||||
threshold := envFloat("IACE_PROPOSE_THRESHOLD", 0.30)
|
|
||||||
hazards, mits, fired := iace.BuildProposerInput(in.Narrative, in.MachineType, in.MachineTypes)
|
|
||||||
candidates := iace.FindDedupCandidates(fired, threshold)
|
|
||||||
|
|
||||||
byID := make(map[string]iace.PatternMatch, len(fired))
|
|
||||||
for _, pm := range fired {
|
|
||||||
byID[pm.PatternID] = pm
|
|
||||||
}
|
|
||||||
|
|
||||||
judge := selectJudge(in.MachineType)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var proposals []iace.JudgedProposal
|
|
||||||
blocked := 0
|
|
||||||
for _, c := range candidates {
|
|
||||||
var sr iace.ScreenResult
|
|
||||||
if gt != nil {
|
|
||||||
sr = iace.ScreenSupersession(gt, hazards, mits, c.KeepHazardName, c.DropName)
|
|
||||||
if sr.RecallAfter < sr.RecallBefore || sr.DistinctGT {
|
|
||||||
blocked++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v, conf, rat := judge.Judge(ctx, c, byID[c.KeepPattern], byID[c.DropPattern])
|
|
||||||
proposals = append(proposals, iace.JudgedProposal{
|
|
||||||
Candidate: c, Screen: sr, Verdict: v, Confidence: conf, Rationale: rat, Judge: judge.Name(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
writeText("audit-reports/proposals.md", iace.RenderProposalQueue(in.MachineType, proposals))
|
|
||||||
writeJSON("audit-reports/proposals.json", proposals)
|
|
||||||
|
|
||||||
// Type 2: foreign-framing candidates (zone terms with no narrative echo).
|
|
||||||
framing := iace.FindFramingCandidates(fired, in.Narrative, envFloat("IACE_FRAMING_MIN_ORPHAN", 0.6))
|
|
||||||
writeText("audit-reports/framing.md", iace.RenderFramingQueue(in.MachineType, framing))
|
|
||||||
writeJSON("audit-reports/framing.json", framing)
|
|
||||||
|
|
||||||
// Type 3: vocab->tag proposals (unknown narrative tokens that pattern text
|
|
||||||
// names as a whole word, with a dominant shared required tag).
|
|
||||||
vocab := audit.RunVocabulary(map[string]any{"narrative": in.Narrative})
|
|
||||||
var vgaps []audit.DictionarySuggestion
|
|
||||||
for _, s := range vocab.SuggestedDictionaryEntries {
|
|
||||||
if len(s.SuggestedTags) > 0 {
|
|
||||||
vgaps = append(vgaps, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeText("audit-reports/vocab.md", renderVocabQueue(in.MachineType, vgaps))
|
|
||||||
writeJSON("audit-reports/vocab.json", vgaps)
|
|
||||||
|
|
||||||
// Type 4: coverage blind-spots (empty ISO 12100 groups A-G) + LLM expansion.
|
|
||||||
gaps := iace.FindCoverageGaps(hazards)
|
|
||||||
var missing []iace.MissingHazard
|
|
||||||
if lj, ok := judge.(iace.LLMJudge); ok {
|
|
||||||
missing = iace.ProposeMissingHazards(ctx, lj.Completer, in.MachineType, in.Narrative, hazards, gaps)
|
|
||||||
}
|
|
||||||
writeText("audit-reports/coverage.md", iace.RenderCoverageQueue(in.MachineType, gaps, missing))
|
|
||||||
writeJSON("audit-reports/coverage.json", gaps)
|
|
||||||
|
|
||||||
printSummary("Method P — Dedup Proposer ("+judge.Name()+")", map[string]int{
|
|
||||||
"fired_patterns": len(fired),
|
|
||||||
"candidates": len(candidates),
|
|
||||||
"in_queue": len(proposals),
|
|
||||||
"gt_blocked": blocked,
|
|
||||||
"framing_flags": len(framing),
|
|
||||||
"vocab_gaps": len(vgaps),
|
|
||||||
"coverage_gaps": len(gaps),
|
|
||||||
})
|
|
||||||
if gt == nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "note: no ground truth provided — GT wall NOT applied (candidates not recall-screened)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectJudge(machineClass string) iace.CandidateJudge {
|
|
||||||
if os.Getenv("IACE_PROPOSE_LLM") != "1" {
|
|
||||||
return iace.HeuristicJudge{}
|
|
||||||
}
|
|
||||||
base := envStr("OLLAMA_URL", "http://localhost:11434")
|
|
||||||
model := envStr("SELF_HOSTED_LLM_MODEL", "qwen2.5:32b-instruct")
|
|
||||||
reg := llm.NewProviderRegistry("ollama", "")
|
|
||||||
reg.Register(llm.NewOllamaAdapter(base, model))
|
|
||||||
fmt.Printf("using LLM judge (ollama %s, model %s)\n", base, model)
|
|
||||||
return iace.LLMJudge{Completer: iace.NewRegistryCompleter(reg, model), MachineClass: machineClass}
|
|
||||||
}
|
|
||||||
|
|
||||||
func readJSONFile(path string, v any) error {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return json.Unmarshal(raw, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeText(path, content string) {
|
|
||||||
_ = os.MkdirAll("audit-reports", 0o755)
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "warn: could not write", path, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println("→ wrote", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func envStr(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func envFloat(key string, def float64) float64 {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderVocabQueue(machine string, entries []audit.DictionarySuggestion) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintf(&b, "# Vocab→tag review queue — %s\n\n", machine)
|
|
||||||
fmt.Fprintf(&b, "%d unknown token(s) appear in pattern text but map to no dictionary tag. Propose-only — a human (or the LLM) confirms the tag, then adds a keyword_dictionary entry and pins a GT case.\n\n", len(entries))
|
|
||||||
for i, s := range entries {
|
|
||||||
tag := "<tag>"
|
|
||||||
if len(s.SuggestedTags) > 0 {
|
|
||||||
tag = s.SuggestedTags[0]
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "## %d. \"%s\" → suggested tag(s): %s\n", i+1, s.Token, strings.Join(s.SuggestedTags, ", "))
|
|
||||||
fmt.Fprintf(&b, "- named by %d pattern(s): %s\n", len(s.PatternIDs), strings.Join(s.PatternIDs, ", "))
|
|
||||||
fmt.Fprintf(&b, "- suggested action: add keyword_dictionary entry {%q → %s} so narratives mentioning it trigger those patterns; human confirms\n\n", s.Token, tag)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Control-Mapping: CRA Annex I -> NIST SP 800-53 Rev. 5. Eine Zeile = ein Mapping (Schema: ControlMapping).
|
|
||||||
// Reviewt 2026-06-25 (benjamin): 3 accepted, mapping_type=primary_implementation (kanonische Primaer-Control je Anforderung).
|
|
||||||
// Heimat der OWASP-Rejects (2)(e)/(2)(l)/(2)(i): dort war OWASP nicht der Zielstandard ("Mapping ueber NIST/BSI erforderlich").
|
|
||||||
// related-Controls (SC-3(3), RA-5, AC-6, SI-16, ...) folgen separat als mapping_type=supports — hier nur der kanonische Einstieg.
|
|
||||||
// obligation_id (Registry-Handoff #4 adoptiert, #6 auf CORE re-pointet 2026-06-26): SI-7->software_integrity_protection (CORE (2)(f)), SI-2->provide_security_updates, CM-7->attack_surface_minimization (CORE (2)(j)). Join exakt. Die domaenen-scoped IDs (signed_update_integrity, remote_access_attack_surface_min) bleiben gueltige Obligations und zeigen per specializes->CORE auf diese Ziele.
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "SI-7", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST SI-7 = Software, Firmware, and Information Integrity — kanonische Integritaetskontrolle (Signaturpruefung, Manipulationserkennung).", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Primaere Implementierung der CRA-Integritaetsanforderung; OWASP war hier kein passender Treffer. Related (spaeter, supports): SA-10, CM-14.", "version": "2026-06-25", "obligation_id": "software_integrity_protection"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "SI-2", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST SI-2 = Flaw Remediation — kanonische Update-/Patch-Kontrolle.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Primaere Implementierung der CRA-Update-Anforderung. Related (spaeter, supports): RA-5, CM-3, SA-11.", "version": "2026-06-25", "obligation_id": "provide_security_updates"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "NIST SP 800-53", "target_control": "CM-7", "mapping_type": "primary_implementation", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "NIST CM-7 = Least Functionality — Deaktivierung nicht benoetigter Ports/Dienste/Funktionen.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "CM-7 als Primaer-Control fuer Angriffsflaeche (nicht SC-3(3)). Related (spaeter, supports): SC-3(3), AC-6, SI-16.", "version": "2026-06-25", "obligation_id": "attack_surface_minimization"}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
// Control-Mapping: CRA Annex I -> OWASP ASVS 5.0. Eine Zeile = ein Mapping (Schema: ControlMapping).
|
|
||||||
// Reviewt 2026-06-25 (benjamin): 7 accepted, 13 rejected. accepted = Audit-Wahrheit (Advisor nutzt acceptedOnly).
|
|
||||||
// rejected bleiben als Audit-Spur ("warum verworfen"). KEIN confidence — kuratiert = fachliche Feststellung.
|
|
||||||
// Architekturbeweis: CRA -> OWASP fuer AppSec/Auth/Crypto/Logging; Ops/Update/Attack-Surface/Integritaet -> NIST/BSI.
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.3.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V6 = Authentication.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V6 = Authentication, sauberer Treffer fuer Zugriffsschutz/Authentisierung.", "version": "2026-06-25", "obligation_id": "user_authentication_required"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.2.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11 = Cryptography.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11 = Cryptography, richtiger Bereich fuer Verschluesselung.", "version": "2026-06-25", "obligation_id": "credential_confidentiality_protection"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V11.7.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V11.7 = Key Management.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "Korrektur von V14: V11.7 = Key Management fuer Verschluesselung/Schluesselverwaltung.", "version": "2026-06-25", "obligation_id": "auth_key_management"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.3", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.3.4", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(k) — Sicherheitsrelevante Ereignisse / Logging", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V16.1.1", "mapping_type": "supports", "mapping_status": "accepted", "provenance": "human_curated", "rationale": "V16 = Security Logging.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V16 = Logging, sauberer Treffer fuer sicherheitsrelevante Ereignisse.", "version": "2026-06-25", "obligation_id": "event_logging_security_events"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, kein Auth — verworfen.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.3.2", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(d) — Vertraulichkeit / Verschluesselung", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.3", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "V14 = Config, Crypto gehoert zu V11 — verworfen.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V1.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(e) — Integritaet", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V14.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V2.4.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(l) — Sichere Updates", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V6.1.1", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V15.3.3", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
{"source_norm": "CRA Annex I Part I (2)(i) — Angriffsflaeche minimieren", "source_role": "operational_requirement", "target_framework": "OWASP ASVS", "target_control": "V8.2.4", "mapping_type": "related", "mapping_status": "rejected", "provenance": "human_curated", "rationale": "Retriever-Kandidat.", "reviewed_by": "benjamin", "review_date": "2026-06-25", "review_reason": "OWASP ASVS ist hier nicht der passende Zielstandard. Mapping ueber NIST/BSI erforderlich.", "version": "2026-06-25"}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// Evidence-Requirements je NIST-SP-800-53-Control (Schema: EvidenceRequirement). Eine Zeile = eine geforderte Evidenz.
|
|
||||||
// WICHTIG: evidence_type ist FRAMEWORK-AGNOSTISCH (geteilter Katalog config_export/test_report/repo_scan/sbom/...) —
|
|
||||||
// dieselben Typen tragen CRA, NIST, ISO 27001, IEC 62443, BSI. (framework, control) ist nur der Verweis, nicht der Typ.
|
|
||||||
// Stand 2026-06-25, Basis: die 3 accepted CRA->NIST primary_implementation-Mappings (SI-7 Integritaet, SI-2 Updates, CM-7 Angriffsflaeche).
|
|
||||||
{"framework": "NIST SP 800-53", "control": "SI-7", "evidence_type": "sbom", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "SBOM weist die Integritaet/Herkunft der Software-Bestandteile nach (bekannte, unmanipulierte Komponenten).", "version": "2026-06-25"}
|
|
||||||
{"framework": "NIST SP 800-53", "control": "SI-7", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Secure-Boot-/Code-Signing-Konfiguration als Nachweis der Integritaetspruefung.", "version": "2026-06-25"}
|
|
||||||
{"framework": "NIST SP 800-53", "control": "SI-2", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration des sicheren Update-/Patch-Mechanismus (signierte/automatische Updates) als technischer Nachweis.", "version": "2026-06-25"}
|
|
||||||
{"framework": "NIST SP 800-53", "control": "SI-2", "evidence_type": "test_report", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "Update-/Patch-Verifikationstest (CI) belegt, dass Sicherheitsupdates greifen.", "version": "2026-06-25"}
|
|
||||||
{"framework": "NIST SP 800-53", "control": "CM-7", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration deaktivierter Ports/Dienste/Funktionen als Nachweis minimierter Angriffsflaeche.", "version": "2026-06-25"}
|
|
||||||
{"framework": "NIST SP 800-53", "control": "CM-7", "evidence_type": "repo_scan", "evidence_source": "scanner", "freshness_requirement": "per_release", "required": true, "rationale": "Angriffsflaechen-Scan (offene Ports/Dienste) als Nachweis tatsaechlich minimierter Angriffsflaeche.", "version": "2026-06-25"}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// Evidence-Requirements je OWASP-ASVS-Control (Schema: EvidenceRequirement). Eine Zeile = eine geforderte Evidenz.
|
|
||||||
// Autoriert/kuratiert (nicht Retriever). Der Advisor kann eine CRA-Anforderung erst dann als erfuellt melden,
|
|
||||||
// wenn die required Evidenzen der gemappten, accepted Controls vorliegen + frisch genug sind.
|
|
||||||
// Stand 2026-06-25, Basis: die 7 accepted CRA->OWASP-Mappings (Auth V6, Crypto V11, Logging V16).
|
|
||||||
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "IAM-/Zugriffskonfiguration als Nachweis der Authentisierungs-Anforderung.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "test_report", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "Automatisierter Zugriffstest (CI) belegt funktionierende Zugriffskontrolle.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V6.3.1", "evidence_type": "pentest", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": false, "rationale": "Jaehrlicher PenTest der Authentisierung — vertieft, aber nicht Pflicht je Release.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V6.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Rollenmodell/Auth-Architektur als Nachweis.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Krypto-Konfiguration (zugelassene Algorithmen) als Nachweis der Verschluesselung.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V11.2.1", "evidence_type": "sbom", "evidence_source": "ci", "freshness_requirement": "per_release", "required": true, "rationale": "SBOM weist die eingesetzten Krypto-Bibliotheken/-Versionen nach.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "policy", "evidence_source": "manual_upload", "freshness_requirement": "annually", "required": true, "rationale": "Key-Management-Policy (Rotation, Aufbewahrung) als organisatorischer Nachweis.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V11.7.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Konfiguration der Schluesselverwaltung als technischer Nachweis.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs belegen, dass sicherheitsrelevante Ereignisse protokolliert werden.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V16.3.3", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Konfiguration als Nachweis der erfassten Ereignisarten.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V16.3.4", "evidence_type": "audit_log", "evidence_source": "ci", "freshness_requirement": "continuous", "required": true, "rationale": "Security-Audit-Logs.", "version": "2026-06-25"}
|
|
||||||
{"framework": "OWASP ASVS", "control": "V16.1.1", "evidence_type": "config_export", "evidence_source": "github", "freshness_requirement": "per_release", "required": true, "rationale": "Logging-Architektur-Konfiguration als Nachweis.", "version": "2026-06-25"}
|
|
||||||
@@ -1,846 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "obligation_join_keys_v1",
|
|
||||||
"contract": "obligation_id ist der stabile Join-Key. Legal Knowledge Graph haengt citation_spans an obligation_id; Compliance Execution Graph mappt control_mapping.source_norm -> obligation_id. Interim-Bruecke = citation_units. obligation_id NIE neu vergeben (re-link).",
|
|
||||||
"count": 95,
|
|
||||||
"obligation_ids": [
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_creation",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_dependency_coverage",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Art. 3(36) i.V.m. Annex I Part II (1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_format_standard",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_maintenance_update",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_completeness_verification",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_tooling_automation",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "IMPLEMENTATION"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_access_provision",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_authority_provision",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Art. 31 / Annex I Part II (1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_confidentiality",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Art. 31(4)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_supply_chain_contracts",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "sbom_technical_documentation",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "sbom",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Art. 31 i.V.m. Annex VII"
|
|
||||||
],
|
|
||||||
"source_role": "EVIDENCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "vuln_identification_inventory",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "vuln",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "vuln_assessment_prioritization",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "vuln",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "vuln_remediation_patching",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "vuln",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (2) & (8)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "vuln_handling_process",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "vuln",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Article 13(8) & Annex VII"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "coordinated_vulnerability_disclosure",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "vuln",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (5)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "exploited_vuln_reporting_authorities",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "vuln",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Article 14 & Article 16"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "vuln_info_dissemination_users",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "vuln",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part II (4) & (6)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "attack_surface_minimization",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "core",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(j)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "software_integrity_protection",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "core",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(f)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "user_authentication_required",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(d)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "authentication_policy_documented",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "auth_exceptions_documented",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "mfa_required",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "step_up_authentication",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "privileged_op_reauth",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "strong_crypto_authentication",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(e)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "credential_lifecycle_management",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "credential_confidentiality_protection",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(e)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "password_policy",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "no_default_credentials",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(a)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "account_lockout_failed_attempts",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "server_side_validation",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "session_binding_management",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "reauth_after_inactivity",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "token_validation_lifecycle",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "mutual_authentication",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "revocation_check",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "encrypted_auth_channel",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(e)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "tls_certificate_auth",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "service_to_service_auth",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "auth_key_management",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "biometric_authentication",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "federated_auth_assertions",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "separate_authn_authz",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_authentication",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "supplier_access_auth",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "personal_admin_accounts",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "firmware_software_authentication",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "authentication",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(c)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "event_logging_security_events",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(k)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "access_control_event_logging",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(k)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "audit_trail_admin_actions",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(k)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_integrity_immutability",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(k)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_access_control_protection",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(k)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_retention_archival",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "centralized_log_management",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_monitoring_alerting",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I Part I (2)(k)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_data_minimization_privacy",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_format_standardization",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_timestamp_synchronization",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "logging_availability_resilience",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "logging_thread_safety_correctness",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "IMPLEMENTATION"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "logging_library_supply_chain",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "logging_config_management",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "logging_governance_roles",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "incident_response_logging",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "log_transmission_security",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "network_traffic_logging",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "logging",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_control_least_privilege",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (1)(2)(d)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_confidentiality_integrity",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (1)(2)(b)(c)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_session_management",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_mfa",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_encryption",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "reject_insecure_remote_protocols",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_logging_audit",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (1)(2)(g)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_user_validation_ot",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_training",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_architecture_design",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_attack_surface_min",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (1)(2)(a)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_vuln_patch_mgmt",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(1)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_threat_detection",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_maintenance_governance",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "temporary_remote_access_mgmt",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_data_export_protection",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "component_remote_interface_security",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "remote_access_fallback_concept",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "remote_access",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "provide_security_updates",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(c)",
|
|
||||||
"Art. 13"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "support_period_maintenance",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Art. 13(8)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "signed_update_integrity",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (1)(3)(f)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "trusted_update_source",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (1)(3)(d)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "update_testing_validation",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "update_rollback",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "GUIDANCE"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "automatic_updates_optout",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (2)(c)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "update_risk_assessment",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "LEGAL_MINIMUM",
|
|
||||||
"citation_units": [
|
|
||||||
"Annex I (1)(2)"
|
|
||||||
],
|
|
||||||
"source_role": "LEGAL_BASIS"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"obligation_id": "secure_modification_control",
|
|
||||||
"regulation": "CRA",
|
|
||||||
"family": "updates",
|
|
||||||
"tier": "BEST_PRACTICE",
|
|
||||||
"citation_units": [],
|
|
||||||
"source_role": "IMPLEMENTATION"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/ucca"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ComplianceGraphHandlers serves the read-only Compliance Execution Graph
|
|
||||||
// (Regulation -> Obligation -> Control -> Evidence) over the file-backed bridge artifacts.
|
|
||||||
// It is intentionally SEPARATE from the DB-backed ObligationsHandlers: this is the curated
|
|
||||||
// cross-session graph (Registry join keys + accepted control mappings + evidence requirements),
|
|
||||||
// loaded once at startup. Fail-closed: if the graph could not load, every request answers 503.
|
|
||||||
type ComplianceGraphHandlers struct {
|
|
||||||
joins *ucca.ObligationJoinKeys
|
|
||||||
mappings *ucca.ControlMappingSet
|
|
||||||
evidence *ucca.EvidenceRequirementSet
|
|
||||||
loadErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewComplianceGraphHandlers loads the graph once. Construction never fails; a load error is
|
|
||||||
// retained and surfaced as 503 per request (matches the codebase's load-warn-continue startup).
|
|
||||||
func NewComplianceGraphHandlers() *ComplianceGraphHandlers {
|
|
||||||
joins, mappings, evidence, err := ucca.LoadComplianceGraph()
|
|
||||||
return &ComplianceGraphHandlers{joins: joins, mappings: mappings, evidence: evidence, loadErr: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadError exposes a startup load failure so the wiring can log a warning.
|
|
||||||
func (h *ComplianceGraphHandlers) LoadError() error { return h.loadErr }
|
|
||||||
|
|
||||||
// RegisterRoutes mounts the compliance-graph routes under /compliance.
|
|
||||||
func (h *ComplianceGraphHandlers) RegisterRoutes(r *gin.RouterGroup) {
|
|
||||||
g := r.Group("/compliance")
|
|
||||||
g.GET("/obligation-status", h.ObligationStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
type cgControlDTO struct {
|
|
||||||
Framework string `json:"framework"`
|
|
||||||
Control string `json:"control"`
|
|
||||||
MappingType string `json:"mapping_type"`
|
|
||||||
EvidenceRequired []string `json:"evidence_required"`
|
|
||||||
EvidenceStatus string `json:"evidence_status"` // missing | partial | present | none_required
|
|
||||||
}
|
|
||||||
|
|
||||||
type cgStatusResponse struct {
|
|
||||||
ObligationID string `json:"obligation_id"`
|
|
||||||
OverallStatus string `json:"overall_status"` // unknown_obligation | unmapped | not_assessed | open | met
|
|
||||||
LegalBasis []string `json:"legal_basis,omitempty"`
|
|
||||||
CitationSpans string `json:"citation_spans"` // "pending" until the Legal-KG attaches spans
|
|
||||||
Controls []cgControlDTO `json:"controls"`
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObligationStatus answers GET /sdk/v1/compliance/obligation-status?obligation_id=...
|
|
||||||
//
|
|
||||||
// It NEVER asserts fulfillment automatically. With no evidence collection wired (MVP), a mapped
|
|
||||||
// obligation is "not_assessed" and every required evidence is "missing" — the honest picture is
|
|
||||||
// "required vs present evidence", not "a document exists". Fail-closed otherwise:
|
|
||||||
// - no obligation_id -> 400
|
|
||||||
// - graph not loaded -> 503
|
|
||||||
// - id not in the Registry -> 200 overall_status=unknown_obligation
|
|
||||||
// - mapped but no control yet -> 200 overall_status=unmapped
|
|
||||||
func (h *ComplianceGraphHandlers) ObligationStatus(c *gin.Context) {
|
|
||||||
if h.loadErr != nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "compliance graph unavailable", "detail": h.loadErr.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
obID := strings.TrimSpace(c.Query("obligation_id"))
|
|
||||||
if obID == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "obligation_id query parameter required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp := cgStatusResponse{ObligationID: obID, CitationSpans: "pending", Controls: []cgControlDTO{}}
|
|
||||||
|
|
||||||
if h.joins.FindObligation(obID) == nil {
|
|
||||||
resp.OverallStatus = "unknown_obligation"
|
|
||||||
resp.Note = "obligation_id not in the Registry join-key contract"
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// MVP: hasEvidence=nil -> no collection wired -> all required evidence counts as missing.
|
|
||||||
st := ucca.AssessObligationStatus(h.joins, h.mappings, h.evidence, obID, nil)
|
|
||||||
resp.LegalBasis = st.LegalBasis
|
|
||||||
|
|
||||||
if len(st.Controls) == 0 {
|
|
||||||
resp.OverallStatus = "unmapped"
|
|
||||||
resp.Note = "no accepted control maps to this obligation yet"
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, cs := range st.Controls {
|
|
||||||
types := make([]string, 0, len(cs.RequiredEvidence))
|
|
||||||
for _, e := range cs.RequiredEvidence {
|
|
||||||
types = append(types, e.EvidenceType)
|
|
||||||
}
|
|
||||||
resp.Controls = append(resp.Controls, cgControlDTO{
|
|
||||||
Framework: cs.Framework,
|
|
||||||
Control: cs.Control,
|
|
||||||
MappingType: cs.MappingType,
|
|
||||||
EvidenceRequired: types,
|
|
||||||
EvidenceStatus: cgEvidenceStatus(len(cs.RequiredEvidence), len(cs.MissingEvidence)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// No fulfillment claim without real evidence collection.
|
|
||||||
resp.OverallStatus = "not_assessed"
|
|
||||||
resp.Note = "evidence collection not wired (MVP) — fulfillment not asserted"
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cgEvidenceStatus(required, missing int) string {
|
|
||||||
switch {
|
|
||||||
case required == 0:
|
|
||||||
return "none_required"
|
|
||||||
case missing == 0:
|
|
||||||
return "present"
|
|
||||||
case missing == required:
|
|
||||||
return "missing"
|
|
||||||
default:
|
|
||||||
return "partial"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newComplianceGraphTestRouter(t *testing.T) *gin.Engine {
|
|
||||||
t.Helper()
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
h := NewComplianceGraphHandlers()
|
|
||||||
if err := h.LoadError(); err != nil {
|
|
||||||
t.Fatalf("compliance graph failed to load (candidate paths): %v", err)
|
|
||||||
}
|
|
||||||
r := gin.New()
|
|
||||||
h.RegisterRoutes(r.Group("/sdk/v1"))
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func getObligationStatus(t *testing.T, r *gin.Engine, query string) (int, cgStatusResponse) {
|
|
||||||
t.Helper()
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, "/sdk/v1/compliance/obligation-status"+query, nil)
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
var resp cgStatusResponse
|
|
||||||
if w.Code == http.StatusOK {
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return w.Code, resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObligationStatus(t *testing.T) {
|
|
||||||
r := newComplianceGraphTestRouter(t)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
query string
|
|
||||||
wantHTTP int
|
|
||||||
wantOverall string
|
|
||||||
wantControls bool // expect >=1 control
|
|
||||||
}{
|
|
||||||
{"missing param -> 400", "", http.StatusBadRequest, "", false},
|
|
||||||
{"unknown id -> unknown_obligation", "?obligation_id=does_not_exist", http.StatusOK, "unknown_obligation", false},
|
|
||||||
{"mapped (OWASP V6) -> not_assessed", "?obligation_id=user_authentication_required", http.StatusOK, "not_assessed", true},
|
|
||||||
{"NIST adopted (SI-2) -> not_assessed", "?obligation_id=provide_security_updates", http.StatusOK, "not_assessed", true},
|
|
||||||
{"CORE attack_surface_minimization -> CM-7", "?obligation_id=attack_surface_minimization", http.StatusOK, "not_assessed", true},
|
|
||||||
{"CORE software_integrity_protection -> SI-7", "?obligation_id=software_integrity_protection", http.StatusOK, "not_assessed", true},
|
|
||||||
{"in registry, no control -> unmapped", "?obligation_id=sbom_creation", http.StatusOK, "unmapped", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
code, resp := getObligationStatus(t, r, tt.query)
|
|
||||||
if code != tt.wantHTTP {
|
|
||||||
t.Fatalf("http %d, want %d", code, tt.wantHTTP)
|
|
||||||
}
|
|
||||||
if tt.wantHTTP != http.StatusOK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp.OverallStatus != tt.wantOverall {
|
|
||||||
t.Errorf("overall_status=%q, want %q", resp.OverallStatus, tt.wantOverall)
|
|
||||||
}
|
|
||||||
if tt.wantControls && len(resp.Controls) == 0 {
|
|
||||||
t.Error("expected >=1 control")
|
|
||||||
}
|
|
||||||
if !tt.wantControls && len(resp.Controls) != 0 {
|
|
||||||
t.Errorf("expected 0 controls, got %d", len(resp.Controls))
|
|
||||||
}
|
|
||||||
if resp.CitationSpans != "pending" {
|
|
||||||
t.Errorf("citation_spans=%q, want pending", resp.CitationSpans)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The MVP must NEVER auto-assert fulfillment: with no evidence collection wired, every required
|
|
||||||
// evidence is "missing" and the overall status stays "not_assessed".
|
|
||||||
func TestObligationStatus_NoFulfillmentClaim(t *testing.T) {
|
|
||||||
r := newComplianceGraphTestRouter(t)
|
|
||||||
code, resp := getObligationStatus(t, r, "?obligation_id=user_authentication_required")
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Fatalf("http %d", code)
|
|
||||||
}
|
|
||||||
if resp.OverallStatus == "met" || resp.OverallStatus == "erfuellt" {
|
|
||||||
t.Fatalf("MVP must not assert fulfillment, got overall_status=%q", resp.OverallStatus)
|
|
||||||
}
|
|
||||||
for _, ctl := range resp.Controls {
|
|
||||||
if len(ctl.EvidenceRequired) > 0 && ctl.EvidenceStatus != "missing" {
|
|
||||||
t.Errorf("control %s/%s evidence_status=%q, want missing (no collection wired)", ctl.Framework, ctl.Control, ctl.EvidenceStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin the curated evidence_required set per NIST obligation. A required:false row silently
|
|
||||||
// drops from evidence_required, which the table test above (control-count only) would miss.
|
|
||||||
func TestObligationStatus_NISTEvidenceTypes(t *testing.T) {
|
|
||||||
r := newComplianceGraphTestRouter(t)
|
|
||||||
want := map[string][]string{
|
|
||||||
"attack_surface_minimization": {"config_export", "repo_scan"},
|
|
||||||
"software_integrity_protection": {"sbom", "config_export"},
|
|
||||||
"provide_security_updates": {"config_export", "test_report"},
|
|
||||||
}
|
|
||||||
for ob, exp := range want {
|
|
||||||
_, resp := getObligationStatus(t, r, "?obligation_id="+ob)
|
|
||||||
if len(resp.Controls) != 1 {
|
|
||||||
t.Fatalf("%s: want 1 control, got %d", ob, len(resp.Controls))
|
|
||||||
}
|
|
||||||
if got := resp.Controls[0].EvidenceRequired; !sameStringSet(got, exp) {
|
|
||||||
t.Errorf("%s evidence_required = %v, want %v", ob, got, exp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameStringSet(a, b []string) bool {
|
|
||||||
if len(a) != len(b) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
m := make(map[string]bool, len(a))
|
|
||||||
for _, x := range a {
|
|
||||||
m[x] = true
|
|
||||||
}
|
|
||||||
for _, x := range b {
|
|
||||||
if !m[x] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -211,13 +211,6 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, cat := range mp.HazardCats {
|
for _, cat := range mp.HazardCats {
|
||||||
// Native cyber/AI categories (frontend groups I+J) belong to the
|
|
||||||
// CRA module, not the traditional CE (ISO 12100) hazard log.
|
|
||||||
// Enforced centrally here so it holds for EVERY project.
|
|
||||||
if isCyberSecurityCategory(cat) {
|
|
||||||
fmt.Printf("CYBER-SKIP: cat=%s pattern=%s — routed to CRA module\n", cat, mp.PatternID)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
maxForCat := categoryHazardCap(cat, len(comps))
|
maxForCat := categoryHazardCap(cat, len(comps))
|
||||||
if catCount[cat] >= maxForCat {
|
if catCount[cat] >= maxForCat {
|
||||||
continue
|
continue
|
||||||
@@ -298,10 +291,6 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
|
|||||||
if len(mp.SuggestedMeasureIDs) > 0 {
|
if len(mp.SuggestedMeasureIDs) > 0 {
|
||||||
hazardPatternMeasures[hz.ID] = mp.SuggestedMeasureIDs
|
hazardPatternMeasures[hz.ID] = mp.SuggestedMeasureIDs
|
||||||
}
|
}
|
||||||
// E1: one hazard per pattern — keep only the primary (first
|
|
||||||
// eligible) category; a secondary category would be the same
|
|
||||||
// scenario+zone under a different label (cross-category duplicate).
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
// Safety/Security separation for the IACE hazard log.
|
|
||||||
//
|
|
||||||
// The traditional CE risk assessment (Maschinenrichtlinie / EN ISO 12100) and
|
|
||||||
// the cybersecurity assessment (Cyber Resilience Act) are two distinct steps.
|
|
||||||
// IACE owns the traditional, physical + functional-safety hazards; the CRA
|
|
||||||
// module (/sdk/iace/{id}/cra) owns the native cyber/AI topics and re-examines
|
|
||||||
// which safety functions a cyber attack can re-open (see iace-safety-bridge).
|
|
||||||
//
|
|
||||||
// The split is by the NATURE of the hazard, not by the component: a control
|
|
||||||
// fault, bus failure or botched update is FUNCTIONAL safety (random/systematic
|
|
||||||
// fault) and stays in CE — independent of whether the controller is a bought-in
|
|
||||||
// CE-marked PLC or the manufacturer's own embedded control. Only the security
|
|
||||||
// PROPERTIES against malicious actors (access control, firmware/update
|
|
||||||
// integrity, SBOM, vulnerability handling, default passwords) are CRA.
|
|
||||||
//
|
|
||||||
// Functional-safety control categories (software_control, software_fault,
|
|
||||||
// safety_function_failure, configuration_error, communication_failure,
|
|
||||||
// update_failure, sensor_fault, …) therefore intentionally STAY in IACE — they
|
|
||||||
// are the safety functions whose loss the CRA bridge re-examines.
|
|
||||||
//
|
|
||||||
// Enforced centrally in InitializeProject so it holds for EVERY project.
|
|
||||||
var nativeCyberSecurityCategories = map[string]bool{
|
|
||||||
// I. Cyber / Netzwerk — security against malicious actors
|
|
||||||
"unauthorized_access": true,
|
|
||||||
"firmware_corruption": true,
|
|
||||||
"cyber_resilience": true,
|
|
||||||
"logging_audit_failure": true,
|
|
||||||
"cyber_network": true,
|
|
||||||
"sensor_spoofing": true,
|
|
||||||
// J. KI-spezifisch
|
|
||||||
"ai_specific": true,
|
|
||||||
"ai_misclassification": true,
|
|
||||||
"false_classification": true,
|
|
||||||
"model_drift": true,
|
|
||||||
"data_poisoning": true,
|
|
||||||
"unintended_bias": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// isCyberSecurityCategory reports whether a hazard category is a native cyber/AI
|
|
||||||
// topic that belongs to the CRA module rather than the traditional CE hazard log.
|
|
||||||
func isCyberSecurityCategory(category string) bool {
|
|
||||||
return nativeCyberSecurityCategories[category]
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestIsCyberSecurityCategory_RoutedToCRA(t *testing.T) {
|
|
||||||
cyber := []string{
|
|
||||||
"unauthorized_access", "firmware_corruption", "cyber_resilience",
|
|
||||||
"logging_audit_failure", "cyber_network", "sensor_spoofing",
|
|
||||||
"ai_specific", "ai_misclassification", "false_classification",
|
|
||||||
"model_drift", "data_poisoning", "unintended_bias",
|
|
||||||
}
|
|
||||||
for _, c := range cyber {
|
|
||||||
if !isCyberSecurityCategory(c) {
|
|
||||||
t.Errorf("category %q must be routed to the CRA module, not the traditional IACE log", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCyberSecurityCategory_StaysInIACE(t *testing.T) {
|
|
||||||
// Physical + functional-safety categories must remain in the traditional CE
|
|
||||||
// hazard log. communication_failure (bus failure -> loss of control) and
|
|
||||||
// update_failure (botched update -> lost safety function) are FUNCTIONAL
|
|
||||||
// faults, not attacks, so they stay too.
|
|
||||||
keep := []string{
|
|
||||||
"mechanical_hazard", "electrical_hazard", "thermal_hazard",
|
|
||||||
"pneumatic_hydraulic", "noise_vibration", "ergonomic_hazard",
|
|
||||||
"material_environmental", "chemical_risk", "fire_explosion",
|
|
||||||
"software_control", "software_fault", "safety_function_failure",
|
|
||||||
"configuration_error", "sensor_fault", "hmi_error",
|
|
||||||
"communication_failure", "update_failure",
|
|
||||||
}
|
|
||||||
for _, c := range keep {
|
|
||||||
if isCyberSecurityCategory(c) {
|
|
||||||
t.Errorf("category %q must stay in the traditional IACE log, not be routed to CRA", c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,10 +75,9 @@ func (h *RAGHandlers) Search(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"query": req.Query,
|
"query": req.Query,
|
||||||
"results": results,
|
"results": results,
|
||||||
"count": len(results),
|
"count": len(results),
|
||||||
"assessment": ucca.Assess(results),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,32 +206,3 @@ func (h *RAGHandlers) HandleScrollChunks(c *gin.Context) {
|
|||||||
"total": len(chunks),
|
"total": len(chunks),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// LegalCorpusStructure returns the composition (distinct articles, annexes,
|
|
||||||
// recitals + chunk count) of every ingested eur-lex legal act, so the coverage
|
|
||||||
// page can show WHAT was ingested instead of just the act name.
|
|
||||||
// GET /sdk/v1/rag/legal-corpus
|
|
||||||
func (h *RAGHandlers) LegalCorpusStructure(c *gin.Context) {
|
|
||||||
acts, err := h.ragClient.CorpusStructure(c.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate legal corpus: " + err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
arts, anns, recs := 0, 0, 0
|
|
||||||
for _, a := range acts {
|
|
||||||
arts += a.Articles
|
|
||||||
anns += a.Annexes
|
|
||||||
recs += a.Recitals
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"regulations": acts,
|
|
||||||
"totals": gin.H{
|
|
||||||
"regulations": len(acts),
|
|
||||||
"articles": arts,
|
|
||||||
"annexes": anns,
|
|
||||||
"recitals": recs,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -153,12 +153,6 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
|||||||
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
|
||||||
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
|
obligationsHandlers := handlers.NewObligationsHandlersWithStore(obligationsStore)
|
||||||
|
|
||||||
// Compliance Execution Graph (file-backed: Registry join keys + accepted control mappings + evidence)
|
|
||||||
complianceGraphHandlers := handlers.NewComplianceGraphHandlers()
|
|
||||||
if err := complianceGraphHandlers.LoadError(); err != nil {
|
|
||||||
log.Printf("WARNING: compliance graph not loaded (obligation-status -> 503): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regulatory News
|
// Regulatory News
|
||||||
allV2Regs, err := ucca.LoadAllV2Regulations()
|
allV2Regs, err := ucca.LoadAllV2Regulations()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,8 +201,7 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool) *gin.Engine {
|
|||||||
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
uccaHandlers, escalationHandlers, obligationsHandlers, ragHandlers,
|
||||||
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
roadmapHandlers, workshopHandlers, portfolioHandlers,
|
||||||
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
academyHandlers, trainingHandlers, whistleblowerHandlers, iaceHandler,
|
||||||
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler,
|
gapHandler, maximizerHandlers, regulatoryNewsHandlers, useCaseHandler)
|
||||||
complianceGraphHandlers)
|
|
||||||
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ func registerRoutes(
|
|||||||
maximizerHandlers *handlers.MaximizerHandlers,
|
maximizerHandlers *handlers.MaximizerHandlers,
|
||||||
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
regulatoryNewsHandlers *handlers.RegulatoryNewsHandlers,
|
||||||
useCaseHandler *handlers.UseCaseHandler,
|
useCaseHandler *handlers.UseCaseHandler,
|
||||||
complianceGraphHandlers *handlers.ComplianceGraphHandlers,
|
|
||||||
) {
|
) {
|
||||||
v1 := router.Group("/sdk/v1")
|
v1 := router.Group("/sdk/v1")
|
||||||
{
|
{
|
||||||
@@ -55,7 +54,6 @@ func registerRoutes(
|
|||||||
registerMaximizerRoutes(v1, maximizerHandlers)
|
registerMaximizerRoutes(v1, maximizerHandlers)
|
||||||
registerUseCaseRoutes(v1, useCaseHandler)
|
registerUseCaseRoutes(v1, useCaseHandler)
|
||||||
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
v1.GET("/regulatory-news", regulatoryNewsHandlers.GetNews)
|
||||||
complianceGraphHandlers.RegisterRoutes(v1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +161,6 @@ func registerRAGRoutes(v1 *gin.RouterGroup, h *handlers.RAGHandlers) {
|
|||||||
ragRoutes.GET("/corpus-status", h.CorpusStatus)
|
ragRoutes.GET("/corpus-status", h.CorpusStatus)
|
||||||
ragRoutes.GET("/corpus-versions/:collection", h.CorpusVersionHistory)
|
ragRoutes.GET("/corpus-versions/:collection", h.CorpusVersionHistory)
|
||||||
ragRoutes.GET("/scroll", h.HandleScrollChunks)
|
ragRoutes.GET("/scroll", h.HandleScrollChunks)
|
||||||
ragRoutes.GET("/legal-corpus", h.LegalCorpusStructure)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ type DictionarySuggestion struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Field string `json:"field"`
|
Field string `json:"field"`
|
||||||
PatternIDs []string `json:"pattern_ids"`
|
PatternIDs []string `json:"pattern_ids"`
|
||||||
// SuggestedTags are the RequiredComponentTags shared by the naming patterns,
|
|
||||||
// ranked by frequency — the candidate tags a keyword_dictionary entry for this
|
|
||||||
// token would emit so narratives mentioning it can trigger those patterns.
|
|
||||||
SuggestedTags []string `json:"suggested_tags,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type VocabularyReport struct {
|
type VocabularyReport struct {
|
||||||
|
|||||||
@@ -66,19 +66,14 @@ func runVocabulary(form map[string]any) VocabularyReport {
|
|||||||
|
|
||||||
// For each unknown token check if any pattern names it
|
// For each unknown token check if any pattern names it
|
||||||
patterns := iace.AllPatterns()
|
patterns := iace.AllPatterns()
|
||||||
byID := make(map[string]iace.HazardPattern, len(patterns))
|
|
||||||
for _, p := range patterns {
|
|
||||||
byID[p.ID] = p
|
|
||||||
}
|
|
||||||
for _, tok := range report.UnknownTokens {
|
for _, tok := range report.UnknownTokens {
|
||||||
hits := patternsMentioning(tok, patterns)
|
hits := patternsMentioning(tok, patterns)
|
||||||
if len(hits) == 0 {
|
if len(hits) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
report.SuggestedDictionaryEntries = append(report.SuggestedDictionaryEntries, DictionarySuggestion{
|
report.SuggestedDictionaryEntries = append(report.SuggestedDictionaryEntries, DictionarySuggestion{
|
||||||
Token: tok,
|
Token: tok,
|
||||||
PatternIDs: hits,
|
PatternIDs: hits,
|
||||||
SuggestedTags: suggestTagsFor(hits, byID),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(report.SuggestedDictionaryEntries, func(i, j int) bool {
|
sort.Slice(report.SuggestedDictionaryEntries, func(i, j int) bool {
|
||||||
@@ -134,24 +129,18 @@ func dictTokenHit(tok string, dict map[string]bool) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// patternsMentioning returns up to 8 pattern IDs whose scenario/trigger/harm/
|
// patternsMentioning returns up to 8 pattern IDs whose scenario/trigger/
|
||||||
// zone text names the token as a WHOLE WORD. Whole-word (not substring) matching
|
// harm/zone text contains the token (case-insensitive substring).
|
||||||
// is essential: a substring match flags common fragments like "stehen" inside
|
|
||||||
// "entstehen", producing spurious hits and nonsensical tag suggestions.
|
|
||||||
func patternsMentioning(tok string, patterns []iace.HazardPattern) []string {
|
func patternsMentioning(tok string, patterns []iace.HazardPattern) []string {
|
||||||
tokLower := strings.ToLower(tok)
|
tokLower := strings.ToLower(tok)
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var out []string
|
var out []string
|
||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
hay := strings.ToLower(p.ScenarioDE + " " + p.TriggerDE + " " + p.HarmDE + " " + p.ZoneDE + " " + p.NameDE)
|
hay := strings.ToLower(p.ScenarioDE + " " + p.TriggerDE + " " + p.HarmDE + " " + p.ZoneDE + " " + p.NameDE)
|
||||||
matched := false
|
if !strings.Contains(hay, tokLower) {
|
||||||
for _, w := range tokenRE.FindAllString(hay, -1) {
|
continue
|
||||||
if w == tokLower {
|
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if !matched || seen[p.ID] {
|
if seen[p.ID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[p.ID] = true
|
seen[p.ID] = true
|
||||||
@@ -162,57 +151,3 @@ func patternsMentioning(tok string, patterns []iace.HazardPattern) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// suggestTagsFor returns the RequiredComponentTags shared across the naming
|
|
||||||
// patterns, ranked by how many of them require each tag (ties broken by name),
|
|
||||||
// top 3. These are the candidate tags a dictionary entry for the token should
|
|
||||||
// emit so a narrative mentioning the token can trigger those patterns.
|
|
||||||
func suggestTagsFor(ids []string, byID map[string]iace.HazardPattern) []string {
|
|
||||||
freq := map[string]int{}
|
|
||||||
total := 0
|
|
||||||
for _, id := range ids {
|
|
||||||
p, ok := byID[id]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
total++
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, tag := range p.RequiredComponentTags {
|
|
||||||
if seen[tag] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[tag] = true
|
|
||||||
freq[tag]++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
type tf struct {
|
|
||||||
tag string
|
|
||||||
n int
|
|
||||||
}
|
|
||||||
ranked := make([]tf, 0, len(freq))
|
|
||||||
for t, n := range freq {
|
|
||||||
ranked = append(ranked, tf{t, n})
|
|
||||||
}
|
|
||||||
sort.Slice(ranked, func(i, j int) bool {
|
|
||||||
if ranked[i].n != ranked[j].n {
|
|
||||||
return ranked[i].n > ranked[j].n
|
|
||||||
}
|
|
||||||
return ranked[i].tag < ranked[j].tag
|
|
||||||
})
|
|
||||||
// Only suggest a tag shared by >= 40% of the naming patterns. Diffuse tokens
|
|
||||||
// (common verbs spread across categories) get no dominant tag and are dropped.
|
|
||||||
var out []string
|
|
||||||
for _, x := range ranked {
|
|
||||||
if float64(x.n)/float64(total) < 0.4 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out = append(out, x.tag)
|
|
||||||
if len(out) >= 3 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package audit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSuggestTagsFor_RanksSharedRequiredTags(t *testing.T) {
|
|
||||||
byID := map[string]iace.HazardPattern{
|
|
||||||
"P1": {ID: "P1", RequiredComponentTags: []string{"backflow_risk", "dom_warewashing"}},
|
|
||||||
"P2": {ID: "P2", RequiredComponentTags: []string{"backflow_risk"}},
|
|
||||||
"P3": {ID: "P3", RequiredComponentTags: []string{"sharp_edge"}},
|
|
||||||
}
|
|
||||||
got := suggestTagsFor([]string{"P1", "P2", "P3"}, byID)
|
|
||||||
if len(got) == 0 || got[0] != "backflow_risk" {
|
|
||||||
t.Fatalf("want backflow_risk ranked first (2 patterns), got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuggestTagsFor_TopThreeStableAlpha(t *testing.T) {
|
|
||||||
byID := map[string]iace.HazardPattern{
|
|
||||||
"P1": {ID: "P1", RequiredComponentTags: []string{"d", "b", "a", "c"}},
|
|
||||||
}
|
|
||||||
got := suggestTagsFor([]string{"P1"}, byID)
|
|
||||||
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
|
||||||
t.Fatalf("want stable alpha top-3 [a b c], got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuggestTagsFor_UnknownPatternIgnored(t *testing.T) {
|
|
||||||
byID := map[string]iace.HazardPattern{}
|
|
||||||
if got := suggestTagsFor([]string{"missing"}, byID); len(got) != 0 {
|
|
||||||
t.Fatalf("want empty for unknown patterns, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestKistenhub_GTCoverage runs the Kistenhubgeraet ground truth (37 entries)
|
// TestKistenhub_GTCoverage runs the Kistenhubgeraet ground truth (37 entries)
|
||||||
@@ -108,6 +110,65 @@ func TestKistenhub_GTCoverage(t *testing.T) {
|
|||||||
// patternsToHazardsAndMitigations converts a pattern match output into the
|
// patternsToHazardsAndMitigations converts a pattern match output into the
|
||||||
// Hazard/Mitigation shapes that CompareBenchmark expects. Mirrors what
|
// Hazard/Mitigation shapes that CompareBenchmark expects. Mirrors what
|
||||||
// iace_handler_init.go does in production but without DB writes.
|
// iace_handler_init.go does in production but without DB writes.
|
||||||
|
func patternsToHazardsAndMitigations(out *MatchOutput) ([]Hazard, []Mitigation) {
|
||||||
|
hazards := make([]Hazard, 0, len(out.MatchedPatterns))
|
||||||
|
patternToHazard := make(map[string]uuid.UUID, len(out.MatchedPatterns))
|
||||||
|
|
||||||
|
for _, pm := range out.MatchedPatterns {
|
||||||
|
cat := ""
|
||||||
|
if len(pm.HazardCats) > 0 {
|
||||||
|
cat = pm.HazardCats[0]
|
||||||
|
}
|
||||||
|
zone := pm.ZoneDE
|
||||||
|
lifecycle := ""
|
||||||
|
if len(pm.ApplicableLifecycles) > 0 {
|
||||||
|
lifecycle = pm.ApplicableLifecycles[0]
|
||||||
|
}
|
||||||
|
h := Hazard{
|
||||||
|
ID: uuid.New(),
|
||||||
|
Name: pm.ScenarioDE,
|
||||||
|
Category: cat,
|
||||||
|
Description: pm.ScenarioDE,
|
||||||
|
Scenario: pm.ScenarioDE,
|
||||||
|
TriggerEvent: pm.TriggerDE,
|
||||||
|
PossibleHarm: pm.HarmDE,
|
||||||
|
AffectedPerson: pm.AffectedDE,
|
||||||
|
HazardousZone: zone,
|
||||||
|
LifecyclePhase: lifecycle,
|
||||||
|
}
|
||||||
|
if h.Name == "" {
|
||||||
|
h.Name = pm.PatternName
|
||||||
|
}
|
||||||
|
hazards = append(hazards, h)
|
||||||
|
patternToHazard[pm.PatternID] = h.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
measureNames := make(map[string]string)
|
||||||
|
for _, m := range GetProtectiveMeasureLibrary() {
|
||||||
|
measureNames[m.ID] = m.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
var mitigations []Mitigation
|
||||||
|
for _, sm := range out.SuggestedMeasures {
|
||||||
|
name := measureNames[sm.MeasureID]
|
||||||
|
if name == "" {
|
||||||
|
name = sm.MeasureID
|
||||||
|
}
|
||||||
|
for _, srcPattern := range sm.SourcePatterns {
|
||||||
|
hid, ok := patternToHazard[srcPattern]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mitigations = append(mitigations, Mitigation{
|
||||||
|
ID: uuid.New(),
|
||||||
|
HazardID: hid,
|
||||||
|
Name: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hazards, mitigations
|
||||||
|
}
|
||||||
|
|
||||||
func abbrev(s string, max int) string {
|
func abbrev(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GT #3 — commercial UNDERCOUNTER dishwasher (Winterhalter UC-M). Self-assessed
|
|
||||||
// ground truth: we can judge what a dishwasher is. The test runs the narrative
|
|
||||||
// through the SAME chain as production (ParseNarrative -> engine -> relevance
|
|
||||||
// filter + cyber-skip), so keyword/gating fixes are measured on the hazard set
|
|
||||||
// the user actually sees — not the raw pattern flood.
|
|
||||||
|
|
||||||
// Condensed UC-M limits_form narrative. Deliberately includes "Cool-Ausfuehrung"
|
|
||||||
// and "Filter" so the known false components (Kuehlaggregat, Absauganlage) are
|
|
||||||
// reproduced and visible in the baseline.
|
|
||||||
const warewashingNarrative = `Gewerbliche Untertisch-Geschirrspuelmaschine fuer Gastronomie-Kueche, ` +
|
|
||||||
`vernetzt ueber LAN und WLAN (Connected Wash Internetportal). Heisswasser-Boiler mit ` +
|
|
||||||
`Nachspueltemperatur ca. 85 Grad C, Tank mit Hygiene-Tankheizkoerper. Spuelpumpe 150-200 l/min ` +
|
|
||||||
`mit rotierenden Spuelfeldern und Spuelarmen, Ablaufpumpe. Eingebautes Dosiergeraet fuer Reiniger ` +
|
|
||||||
`und Klarspueler (aetzende Konzentrate). 4-fach-Laugenfiltration mit Filter. Doppelwandige Tuer ` +
|
|
||||||
`mit Sicherheitsschalter und Rastposition (Thermostopp). Elektromotor (Drehstrom) 400 V. ` +
|
|
||||||
`Touch-Steuerung (SPS) mit Bedienfeld und HMI, USB-Schnittstelle fuer Softwareupdates, ` +
|
|
||||||
`PIN-geschuetzter Servicetechniker-Fernzugriff. Cool-Ausfuehrung mit kalter Nachspuelung. ` +
|
|
||||||
`Untertischmontage. Eingreifen in die Spuelkammer moeglich. Aerosole und Daempfe der ` +
|
|
||||||
`Reinigungschemie gelangen in die Atemzone. Manuelles Be- und Entladen der Spuelkoerbe von Hand. ` +
|
|
||||||
`Reinigung und Wartung durch Servicetechniker. Branche Lebensmittel und Getraenke. ` +
|
|
||||||
`Siebe und scharfe Blechkanten in der Spuelkammer. Boiler kann bei Wassermangel trockenlaufen. ` +
|
|
||||||
`Frequenzumrichter und Elektronik mit Restspannung nach dem Abschalten. Wartung nur im ` +
|
|
||||||
`freigeschalteten Zustand; Gefahr des unerwarteten Wiederanlaufs. Frischwasseranschluss mit ` +
|
|
||||||
`Rueckflussverhinderer gegen Ruecksaugen in das Trinkwassernetz. Stehwasser im Boiler ` +
|
|
||||||
`(Hygiene/Legionellen). Standsicherheit bei Untertischmontage.`
|
|
||||||
|
|
||||||
// warewashingCyberCategories mirrors handlers.nativeCyberSecurityCategories —
|
|
||||||
// native cyber/AI hazards are routed to the CRA module, not the CE hazard log.
|
|
||||||
var warewashingCyberCategories = map[string]bool{
|
|
||||||
"unauthorized_access": true, "firmware_corruption": true, "cyber_resilience": true,
|
|
||||||
"logging_audit_failure": true, "cyber_network": true, "sensor_spoofing": true,
|
|
||||||
"ai_specific": true, "ai_misclassification": true, "false_classification": true,
|
|
||||||
"model_drift": true, "data_poisoning": true, "unintended_bias": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// warewashingEngineOutput runs the production chain and returns the filtered
|
|
||||||
// hazards/mitigations the user would see for the UC-M.
|
|
||||||
func warewashingEngineOutput() ([]Hazard, []Mitigation, []PatternMatch) {
|
|
||||||
res := ParseNarrative(warewashingNarrative, "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)")
|
|
||||||
|
|
||||||
var compIDs, compNames []string
|
|
||||||
for _, c := range res.Components {
|
|
||||||
if c.Negated {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
compIDs = append(compIDs, c.LibraryID)
|
|
||||||
compNames = append(compNames, c.NameDE)
|
|
||||||
}
|
|
||||||
var energyIDs []string
|
|
||||||
for _, e := range res.EnergySources {
|
|
||||||
energyIDs = append(energyIDs, e.SourceID)
|
|
||||||
}
|
|
||||||
lifecycles := append([]string{}, res.LifecyclePhases...)
|
|
||||||
lifecycles = append(lifecycles, "normal_operation", "maintenance", "cleaning", "setup", "fault_clearing")
|
|
||||||
|
|
||||||
input := MatchInput{
|
|
||||||
ComponentLibraryIDs: compIDs,
|
|
||||||
EnergySourceIDs: energyIDs,
|
|
||||||
LifecyclePhases: lifecycles,
|
|
||||||
CustomTags: res.CustomTags,
|
|
||||||
OperationalStates: append(res.OperationalStates, "normal_operation", "cleaning", "maintenance"),
|
|
||||||
HumanRoles: res.Roles,
|
|
||||||
MachineTypes: []string{"food_processing", "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)"},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := NewPatternEngine().Match(input)
|
|
||||||
|
|
||||||
var kept []PatternMatch
|
|
||||||
for _, pm := range out.MatchedPatterns {
|
|
||||||
if !IsPatternRelevant(pm, warewashingNarrative, compNames) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
allCyber := len(pm.HazardCats) > 0
|
|
||||||
for _, c := range pm.HazardCats {
|
|
||||||
if !warewashingCyberCategories[c] {
|
|
||||||
allCyber = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if allCyber {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, pm)
|
|
||||||
}
|
|
||||||
filtered := *out
|
|
||||||
filtered.MatchedPatterns = kept
|
|
||||||
hazards, mitigations := patternsToHazardsAndMitigations(&filtered)
|
|
||||||
return hazards, mitigations, kept
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarewashing_GTCoverage(t *testing.T) {
|
|
||||||
gtPath := filepath.Join("testdata", "ground_truth_warewashing.json")
|
|
||||||
raw, err := os.ReadFile(gtPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read GT: %v", err)
|
|
||||||
}
|
|
||||||
var gt GroundTruth
|
|
||||||
if err := json.Unmarshal(raw, >); err != nil {
|
|
||||||
t.Fatalf("parse GT: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
res := ParseNarrative(warewashingNarrative, "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)")
|
|
||||||
var cn []string
|
|
||||||
for _, c := range res.Components {
|
|
||||||
if !c.Negated {
|
|
||||||
cn = append(cn, c.NameDE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("Parsed components: %v", cn)
|
|
||||||
}
|
|
||||||
|
|
||||||
hazards, mitigations, keptPatterns := warewashingEngineOutput()
|
|
||||||
t.Logf("Engine: %d patterns kept (relevance+cyber filter) -> %d hazards", len(keptPatterns), len(hazards))
|
|
||||||
|
|
||||||
result := CompareBenchmark(>, hazards, mitigations)
|
|
||||||
precision := 0.0
|
|
||||||
if result.TotalEngine > 0 {
|
|
||||||
precision = float64(len(result.MatchedPairs)) / float64(result.TotalEngine)
|
|
||||||
}
|
|
||||||
t.Logf("=== Warewashing-GT (GT #3) Baseline ===")
|
|
||||||
t.Logf("Recall (Coverage): %.1f%% (%d/%d matched, %d missing)",
|
|
||||||
result.CoverageScore*100, len(result.MatchedPairs), result.TotalGT, len(result.MissingFromEngine))
|
|
||||||
t.Logf("Precision: %.1f%% (%d engine hazards, %d extra)",
|
|
||||||
precision*100, result.TotalEngine, len(result.ExtraInEngine))
|
|
||||||
|
|
||||||
if len(result.MissingFromEngine) > 0 {
|
|
||||||
t.Logf("--- MISSING (recall gaps) ---")
|
|
||||||
for _, m := range result.MissingFromEngine {
|
|
||||||
t.Logf(" MISS %s: %s", m.Nr, abbrev(m.HazardType, 60))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure completeness: which generated hazards have NO protective measure?
|
|
||||||
t.Logf("--- Measure completeness ---")
|
|
||||||
t.Logf("Measure coverage (GT-matched): %.0f%%", result.MeasureCoverage*100)
|
|
||||||
withMeas := make(map[string]bool)
|
|
||||||
for _, m := range mitigations {
|
|
||||||
withMeas[m.HazardID.String()] = true
|
|
||||||
}
|
|
||||||
noMeasure := 0
|
|
||||||
for _, h := range hazards {
|
|
||||||
if !withMeas[h.ID.String()] {
|
|
||||||
noMeasure++
|
|
||||||
n := h.Name
|
|
||||||
if n == "" {
|
|
||||||
n = h.Scenario
|
|
||||||
}
|
|
||||||
t.Logf(" NO-MEASURE: [%s] %s", h.Category, abbrev(n, 60))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("Hazards without any measure: %d/%d", noMeasure, len(hazards))
|
|
||||||
if len(result.ExtraInEngine) > 0 {
|
|
||||||
t.Logf("--- EXTRA (false positives / precision loss) ---")
|
|
||||||
names := make([]string, 0, len(result.ExtraInEngine))
|
|
||||||
for _, e := range result.ExtraInEngine {
|
|
||||||
n := e.Name
|
|
||||||
if n == "" {
|
|
||||||
n = e.Scenario
|
|
||||||
}
|
|
||||||
names = append(names, "["+e.Category+"] "+n)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
for _, n := range names {
|
|
||||||
t.Logf(" EXTRA %s", abbrev(n, 85))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loose smoke floor for the baseline — fixes should push recall up, not down.
|
|
||||||
if result.CoverageScore < 0.4 {
|
|
||||||
t.Errorf("warewashing recall below 40%% floor: %.1f%%", result.CoverageScore*100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWarewashing_DedupProposer exercises the offline dedup-candidate proposer
|
|
||||||
// end-to-end on the real warewashing engine output: detect candidates, screen
|
|
||||||
// each against the GT, and log the human-review queue. It asserts the WALL is
|
|
||||||
// self-consistent — a PASS verdict may never coincide with a recall drop.
|
|
||||||
func TestWarewashing_DedupProposer(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile(filepath.Join("testdata", "ground_truth_warewashing.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read GT: %v", err)
|
|
||||||
}
|
|
||||||
var gt GroundTruth
|
|
||||||
if err := json.Unmarshal(raw, >); err != nil {
|
|
||||||
t.Fatalf("parse GT: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hazards, mits, kept := warewashingEngineOutput()
|
|
||||||
byID := map[string]PatternMatch{}
|
|
||||||
for _, pm := range kept {
|
|
||||||
byID[pm.PatternID] = pm
|
|
||||||
}
|
|
||||||
// 0.25 is a deliberately permissive candidate threshold: the proposer is meant
|
|
||||||
// to over-surface, because the deterministic GT wall below (and a human, and the
|
|
||||||
// LLM judge) is the precision filter — not the detector.
|
|
||||||
candidates := FindDedupCandidates(kept, 0.25)
|
|
||||||
t.Logf("Proposer: %d dedup candidate(s) from %d fired patterns", len(candidates), len(kept))
|
|
||||||
|
|
||||||
// Deterministic judge in the test; the dev-time CLI swaps in LLMJudge.
|
|
||||||
judge := HeuristicJudge{}
|
|
||||||
var judged []JudgedProposal
|
|
||||||
blocked := 0
|
|
||||||
for _, c := range candidates {
|
|
||||||
sr := ScreenSupersession(>, hazards, mits, c.KeepHazardName, c.DropName)
|
|
||||||
switch {
|
|
||||||
case sr.RecallAfter < sr.RecallBefore:
|
|
||||||
t.Logf("[BLOCK recall-load-bearing] keep %s / drop %s", c.KeepPattern, c.DropPattern)
|
|
||||||
blocked++
|
|
||||||
case sr.DistinctGT:
|
|
||||||
t.Logf("[BLOCK distinct GT %s vs %s] keep %s / drop %s", sr.KeepGT, sr.DropGT, c.KeepPattern, c.DropPattern)
|
|
||||||
blocked++
|
|
||||||
default:
|
|
||||||
if !sr.Safe {
|
|
||||||
t.Errorf("RECALL-SAFE branch but ScreenResult.Safe=false for drop %s", c.DropPattern)
|
|
||||||
}
|
|
||||||
v, conf, rat := judge.Judge(context.Background(), c, byID[c.KeepPattern], byID[c.DropPattern])
|
|
||||||
judged = append(judged, JudgedProposal{
|
|
||||||
Candidate: c, Screen: sr, Verdict: v, Confidence: conf, Rationale: rat, Judge: judge.Name(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("\n%s", RenderProposalQueue("Gewerbliche Geschirrspuelmaschine (vernetzt)", judged))
|
|
||||||
t.Logf("Proposer summary: %d candidate(s) in queue (judge=%s), %d BLOCKED by the GT wall — propose-only, nothing auto-applied",
|
|
||||||
len(judged), judge.Name(), blocked)
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import "sort"
|
|
||||||
|
|
||||||
// EN ISO 12100 hazard-group ordering for the hazard log. Without it the log is
|
|
||||||
// returned in pattern-firing order, which reads as a jumble. This groups the
|
|
||||||
// hazards top-down by type (A. Mechanisch, B. Elektrisch, C. Thermisch, …),
|
|
||||||
// matching the frontend CATEGORY_LABELS.
|
|
||||||
var isoCategoryRank = map[string]int{
|
|
||||||
// A. Mechanisch
|
|
||||||
"mechanical_hazard": 10, "mechanical": 10, "maintenance_hazard": 11,
|
|
||||||
// B. Elektrisch
|
|
||||||
"electrical_hazard": 20, "electrical": 20, "emc_hazard": 21,
|
|
||||||
// C. Thermisch
|
|
||||||
"thermal_hazard": 30, "thermal": 30, "high_temperature": 31, "fire_explosion": 32,
|
|
||||||
// D. Pneumatik / Hydraulik
|
|
||||||
"pneumatic_hydraulic": 40,
|
|
||||||
// E. Laerm / Vibration
|
|
||||||
"noise_hazard": 50, "noise_vibration": 50, "vibration_hazard": 51,
|
|
||||||
// F. Ergonomie
|
|
||||||
"ergonomic_hazard": 60, "ergonomic": 60,
|
|
||||||
// G. Stoffe / Umwelt
|
|
||||||
"material_environmental": 70, "chemical_risk": 71, "radiation_hazard": 72,
|
|
||||||
// H. Software / Steuerung (funktionale Sicherheit)
|
|
||||||
"software_control": 80, "software_fault": 80, "safety_function_failure": 81,
|
|
||||||
"configuration_error": 82, "sensor_fault": 83, "hmi_error": 84, "mode_confusion": 85,
|
|
||||||
"communication_failure": 86, "update_failure": 87,
|
|
||||||
// I. Cyber / Netzwerk (zur Ordnungs-Vollstaendigkeit; im CE-Log ausgeschlossen)
|
|
||||||
"unauthorized_access": 90, "firmware_corruption": 91, "cyber_resilience": 92,
|
|
||||||
"cyber_network": 93, "logging_audit_failure": 94, "sensor_spoofing": 95,
|
|
||||||
// J. KI-spezifisch
|
|
||||||
"ai_specific": 100, "ai_misclassification": 100, "false_classification": 100,
|
|
||||||
"model_drift": 100, "data_poisoning": 100, "unintended_bias": 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
func categoryRank(cat string) int {
|
|
||||||
if r, ok := isoCategoryRank[cat]; ok {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
return 999 // unknown categories last
|
|
||||||
}
|
|
||||||
|
|
||||||
// SortHazardsByISO12100 groups hazards by ISO 12100 hazard group. Stable: the
|
|
||||||
// relative order within a group (creation/priority order from the engine) is
|
|
||||||
// preserved.
|
|
||||||
func SortHazardsByISO12100(hazards []Hazard) {
|
|
||||||
sort.SliceStable(hazards, func(i, j int) bool {
|
|
||||||
return categoryRank(hazards[i].Category) < categoryRank(hazards[j].Category)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -62,13 +62,6 @@ type HazardPattern struct {
|
|||||||
// "hazard" = source only, "hazardous_situation" = person exposed, "harm" = injury.
|
// "hazard" = source only, "hazardous_situation" = person exposed, "harm" = injury.
|
||||||
// Empty = default (hazardous_situation).
|
// Empty = default (hazardous_situation).
|
||||||
GeneratedHazardType string `json:"generated_hazard_type,omitempty"`
|
GeneratedHazardType string `json:"generated_hazard_type,omitempty"`
|
||||||
// GuardableByEnclosure marks a contact/entanglement hazard that an interlocked
|
|
||||||
// enclosure removes during normal operation. When the project emits the
|
|
||||||
// "interlocked_enclosure" tag, such a pattern is re-scoped to maintenance/
|
|
||||||
// cleaning (guard open) and does NOT fire as a normal-operation hazard.
|
|
||||||
// Generic EN ISO 14120 logic — surfaced by the warewashing GT (the spray
|
|
||||||
// arm rotates behind the interlocked door).
|
|
||||||
GuardableByEnclosure bool `json:"guardable_by_enclosure,omitempty"`
|
|
||||||
// RequiredFailureModes restricts this pattern to fire only when at least one
|
// RequiredFailureModes restricts this pattern to fire only when at least one
|
||||||
// of the listed failure modes is relevant (by ComponentType match against project components).
|
// of the listed failure modes is relevant (by ComponentType match against project components).
|
||||||
// Empty/nil = fires regardless of failure modes (backwards compatible).
|
// Empty/nil = fires regardless of failure modes (backwards compatible).
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "HP096", NameDE: "Reibung/Abrieb durch rotierende Oberflaechen", NameEN: "Friction/abrasion by rotating surfaces",
|
ID: "HP096", NameDE: "Reibung/Abrieb durch rotierende Oberflaechen", NameEN: "Friction/abrasion by rotating surfaces",
|
||||||
GuardableByEnclosure: true,
|
|
||||||
RequiredComponentTags: []string{"rotating_part"},
|
RequiredComponentTags: []string{"rotating_part"},
|
||||||
RequiredEnergyTags: []string{},
|
RequiredEnergyTags: []string{},
|
||||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||||
@@ -89,7 +88,6 @@ func GetDGUVExtendedPatterns() []HazardPattern {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "HP101", NameDE: "Aufwickeln von Kleidung/Haaren", NameEN: "Winding up of clothing/hair",
|
ID: "HP101", NameDE: "Aufwickeln von Kleidung/Haaren", NameEN: "Winding up of clothing/hair",
|
||||||
GuardableByEnclosure: true,
|
|
||||||
RequiredComponentTags: []string{"rotating_part"},
|
RequiredComponentTags: []string{"rotating_part"},
|
||||||
RequiredEnergyTags: []string{"rotational"},
|
RequiredEnergyTags: []string{"rotational"},
|
||||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func GetGTBremseHazardPatterns() []HazardPattern {
|
|||||||
// ════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════
|
||||||
{
|
{
|
||||||
ID: "HP1717", NameDE: "Verletzung durch unvermittelt austretende pneumatische Restenergie", NameEN: "Injury from unexpectedly released pneumatic stored energy",
|
ID: "HP1717", NameDE: "Verletzung durch unvermittelt austretende pneumatische Restenergie", NameEN: "Injury from unexpectedly released pneumatic stored energy",
|
||||||
RequiredComponentTags: []string{"pneumatic_part"},
|
RequiredComponentTags: []string{"stored_energy"},
|
||||||
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
RequiredEnergyTags: []string{"pneumatic_pressure"},
|
||||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
GeneratedHazardCats: []string{"mechanical_hazard"},
|
||||||
SuggestedMeasureIDs: []string{"M485", "M534", "M527"},
|
SuggestedMeasureIDs: []string{"M485", "M534", "M527"},
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
|||||||
// ================================================================
|
// ================================================================
|
||||||
{
|
{
|
||||||
ID: "HP753", NameDE: "Thermal Runaway bei Lithium-Batterie", NameEN: "Thermal runaway of lithium battery",
|
ID: "HP753", NameDE: "Thermal Runaway bei Lithium-Batterie", NameEN: "Thermal runaway of lithium battery",
|
||||||
RequiredComponentTags: []string{"battery", "high_temperature"},
|
RequiredComponentTags: []string{"stored_energy", "high_temperature"},
|
||||||
RequiredEnergyTags: []string{"electrical_energy", "thermal"},
|
RequiredEnergyTags: []string{"electrical_energy", "thermal"},
|
||||||
GeneratedHazardCats: []string{"thermal_hazard", "electrical_hazard"},
|
GeneratedHazardCats: []string{"thermal_hazard", "electrical_hazard"},
|
||||||
SuggestedMeasureIDs: []string{"M005", "M141"},
|
SuggestedMeasureIDs: []string{"M005", "M141"},
|
||||||
@@ -390,7 +390,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "HP754", NameDE: "Ausgasung giftiger Daempfe aus Batterie", NameEN: "Toxic gas emission from battery",
|
ID: "HP754", NameDE: "Ausgasung giftiger Daempfe aus Batterie", NameEN: "Toxic gas emission from battery",
|
||||||
RequiredComponentTags: []string{"battery", "chemical_risk"},
|
RequiredComponentTags: []string{"stored_energy", "chemical_risk"},
|
||||||
RequiredEnergyTags: []string{},
|
RequiredEnergyTags: []string{},
|
||||||
GeneratedHazardCats: []string{"material_environmental"},
|
GeneratedHazardCats: []string{"material_environmental"},
|
||||||
SuggestedMeasureIDs: []string{"M005", "M141"},
|
SuggestedMeasureIDs: []string{"M005", "M141"},
|
||||||
@@ -405,7 +405,7 @@ func GetSpecificMachinePatterns() []HazardPattern {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "HP755", NameDE: "Elektrischer Schlag an Hochvolt-Batteriespeicher", NameEN: "Electric shock from high-voltage battery storage",
|
ID: "HP755", NameDE: "Elektrischer Schlag an Hochvolt-Batteriespeicher", NameEN: "Electric shock from high-voltage battery storage",
|
||||||
RequiredComponentTags: []string{"battery", "electrical_part"},
|
RequiredComponentTags: []string{"stored_energy", "electrical_part"},
|
||||||
RequiredEnergyTags: []string{"electrical_energy"},
|
RequiredEnergyTags: []string{"electrical_energy"},
|
||||||
GeneratedHazardCats: []string{"electrical_hazard"},
|
GeneratedHazardCats: []string{"electrical_hazard"},
|
||||||
SuggestedMeasureIDs: []string{"M082", "M141"},
|
SuggestedMeasureIDs: []string{"M082", "M141"},
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
// GetWarewashingPatterns returns hazard patterns for commercial warewashing
|
|
||||||
// machines (gewerbliche Geschirrspuelmaschinen / Untertisch-, Hauben-, Korb-
|
|
||||||
// und Bandspuelmaschinen). These capture the machine-specific hazards a
|
|
||||||
// Fachmann immediately expects but that the generic library did not cover:
|
|
||||||
// hot-water/steam scalding on door opening, hot surfaces, hot ware, corrosive
|
|
||||||
// detergent/rinse-aid contact, door pinch and wet-floor slipping.
|
|
||||||
//
|
|
||||||
// Every pattern is gated by the capability tag "dom_warewashing" (emitted only
|
|
||||||
// by warewashing narrative keywords in keyword_dictionary.go), so none of these
|
|
||||||
// leak into unrelated machine classes.
|
|
||||||
//
|
|
||||||
// HP range: HP2200-HP2206. ISO 12100 Annex B section identifiers only (facts);
|
|
||||||
// product standard EN 60335-2-58 (commercial dishwashing machines).
|
|
||||||
func GetWarewashingPatterns() []HazardPattern {
|
|
||||||
return []HazardPattern{
|
|
||||||
{
|
|
||||||
ID: "HP2200", NameDE: "Verbruehung durch Heisswasser/Dampf beim Oeffnen der Tuer", NameEN: "Scalding by hot water/steam when opening the door",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "steam_emission"},
|
|
||||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2200", "M2201", "M2202", "M2208"},
|
|
||||||
Priority: 94,
|
|
||||||
ApplicableLifecycles: []string{"normal_operation", "cleaning"},
|
|
||||||
ScenarioDE: "Beim Oeffnen der Tuer waehrend oder unmittelbar nach dem Spuelgang tritt ein Schwall aus heissem Wasser und Wrasen (Dampf) aus der Spuelkammer aus und trifft Gesicht, Haende und Arme des Bedieners.",
|
|
||||||
TriggerDE: "Tuer wird vor Programmende oder bei noch vorhandenem Restdampf geoeffnet; Tuerverriegelung fehlt oder ist ueberbrueckt; Nachspueltemperatur ca. 85 Grad C.",
|
|
||||||
HarmDE: "Verbruehung 1.-2. Grades an Gesicht, Haenden und Unterarmen; Augenreizung durch heissen Dampf.",
|
|
||||||
AffectedDE: "Bedienpersonal (Spuelkraft)",
|
|
||||||
ZoneDE: "Tuer- und Beschickungsoeffnung der Spuelkammer",
|
|
||||||
ISO12100Section: "6.2.4",
|
|
||||||
DefaultSeverity: 3, DefaultExposure: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2201", NameDE: "Verbrennung an heissen Oberflaechen (Boiler/Tank/Spuelkammer)", NameEN: "Burn on hot surfaces (boiler/tank/wash chamber)",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "high_temperature"},
|
|
||||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2202", "M055", "M2208"},
|
|
||||||
Priority: 90,
|
|
||||||
ApplicableLifecycles: []string{"cleaning", "maintenance"},
|
|
||||||
ScenarioDE: "Beruehrung heisser Oberflaechen von Boiler, Tankheizkoerper oder Spuelkammerwaenden bei Reinigung, Entkalkung oder Wartung fuehrt zu Kontaktverbrennungen.",
|
|
||||||
TriggerDE: "Reinigung/Entkalkung ohne Abkuehlzeit; Eingriff in die Spuelkammer bei betriebswarmem Geraet.",
|
|
||||||
HarmDE: "Kontaktverbrennung an Haenden und Unterarmen.",
|
|
||||||
AffectedDE: "Reinigungspersonal, Wartungspersonal",
|
|
||||||
ZoneDE: "Boiler, Tankheizkoerper, Spuelkammerwaende",
|
|
||||||
ISO12100Section: "6.2.4",
|
|
||||||
DefaultSeverity: 2, DefaultExposure: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2202", NameDE: "Verbrennung an heissem Spuelgut beim Entladen", NameEN: "Burn on hot ware when unloading",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "hot_water"},
|
|
||||||
GeneratedHazardCats: []string{"thermal_hazard"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2202", "M055", "M2208"},
|
|
||||||
Priority: 86,
|
|
||||||
ApplicableLifecycles: []string{"normal_operation"},
|
|
||||||
ScenarioDE: "Geschirr, Glaeser und Bestecke sind nach dem Spuelgang durch die Heisswasser-Nachspuelung sehr heiss; beim Entladen kommt es zu Verbrennungen.",
|
|
||||||
TriggerDE: "Sofortiges Entnehmen des Spuelguts nach Programmende ohne Abkuehl-/Trocknungszeit.",
|
|
||||||
HarmDE: "Verbrennung an Haenden/Fingern beim Greifen heisser Teile.",
|
|
||||||
AffectedDE: "Bedienpersonal (Spuelkraft)",
|
|
||||||
ZoneDE: "Spuelkammer, Entnahmebereich/Korb",
|
|
||||||
ISO12100Section: "6.2.4",
|
|
||||||
DefaultSeverity: 2, DefaultExposure: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2203", NameDE: "Chemische Veraetzung (Haut/Augen) durch Reiniger-/Klarspueler-Konzentrat", NameEN: "Chemical burn (skin/eyes) from detergent/rinse-aid concentrate",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "corrosive_chemical"},
|
|
||||||
GeneratedHazardCats: []string{"chemical_risk"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2203", "M2204", "M2208"},
|
|
||||||
Priority: 92,
|
|
||||||
ApplicableLifecycles: []string{"normal_operation", "maintenance"},
|
|
||||||
ScenarioDE: "Direkter Kontakt mit dem aetzenden (alkalischen) Reiniger- bzw. Klarspueler-Konzentrat beim Nachfuellen, Sauglanzenwechsel oder bei Leckage fuehrt zu Veraetzungen von Haut und Augen.",
|
|
||||||
TriggerDE: "Gebinde-/Sauglanzenwechsel ohne Schutzausruestung; Umfuellen von Konzentrat; undichte Dosierleitung.",
|
|
||||||
HarmDE: "Veraetzung von Haut und Augen (alkalische Verletzung), bleibende Augenschaeden moeglich.",
|
|
||||||
AffectedDE: "Bedienpersonal, Reinigungspersonal beim Chemikalien-Handling",
|
|
||||||
ZoneDE: "Dosiergeraet, Reiniger-/Klarspueler-Gebinde, Sauglanzen",
|
|
||||||
ISO12100Section: "6.2.4",
|
|
||||||
DefaultSeverity: 3, DefaultExposure: 3,
|
|
||||||
ClarificationQuestionsDE: []string{
|
|
||||||
"Liegt fuer alle eingesetzten Reiniger/Klarspueler/Entkalker ein aktuelles Sicherheitsdatenblatt (SDB) am Geraet vor?",
|
|
||||||
"Ist ein geschlossenes Dosiersystem mit Sauglanzen vorhanden, sodass kein Umfuellen noetig ist?",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2204", NameDE: "Reizung/Veraetzung der Atemwege durch Reinigungs-Aerosole/Daempfe", NameEN: "Respiratory irritation from cleaning aerosols/vapours",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "corrosive_chemical"},
|
|
||||||
GeneratedHazardCats: []string{"chemical_risk"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2205", "M2203", "M2204"},
|
|
||||||
Priority: 82,
|
|
||||||
ApplicableLifecycles: []string{"normal_operation", "maintenance"},
|
|
||||||
ScenarioDE: "Aerosole und Daempfe der Reinigungschemie (insbesondere beim Oeffnen kurz nach dem Spuelgang oder bei der Entkalkung mit Saeure) gelangen in die Atemzone und reizen Atemwege und Schleimhaeute.",
|
|
||||||
TriggerDE: "Oeffnen bei laufender/heisser Chemie; Entkalkung mit Saeure; unzureichende Lueftung des Aufstellbereichs.",
|
|
||||||
HarmDE: "Reizung von Atemwegen, Augen und Schleimhaeuten; bei Saeure-/Laugen-Vermischung gefaehrliche Gase.",
|
|
||||||
AffectedDE: "Bedienpersonal, Reinigungspersonal",
|
|
||||||
ZoneDE: "Atemzone vor der Spuelkammer, Aufstellbereich",
|
|
||||||
ISO12100Section: "6.2.4",
|
|
||||||
DefaultSeverity: 2, DefaultExposure: 2,
|
|
||||||
ClarificationQuestionsDE: []string{
|
|
||||||
"Ist der Aufstellbereich ausreichend be-/entlueftet (Kuechenlueftung)?",
|
|
||||||
"Wird in der BA vor dem Vermischen von Reiniger und Entkalker/Saeure gewarnt?",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2205", NameDE: "Quetschen der Finger an der Tuer/Haube", NameEN: "Finger crushing at the door/hood",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "access_door"},
|
|
||||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2206", "M003", "M2208"},
|
|
||||||
Priority: 78,
|
|
||||||
ApplicableLifecycles: []string{"normal_operation"},
|
|
||||||
ScenarioDE: "Beim Schliessen der Tuer bzw. Absenken der Haube werden Finger zwischen Tuer/Haube und Gehaeuse gequetscht.",
|
|
||||||
TriggerDE: "Greifen in den Schliessbereich beim Schliessen; hohe Schliesskraft der Haube; scharfe Kanten.",
|
|
||||||
HarmDE: "Quetschung und Prellung der Finger.",
|
|
||||||
AffectedDE: "Bedienpersonal (Spuelkraft)",
|
|
||||||
ZoneDE: "Tuer-/Haubenkante, Schliessbereich",
|
|
||||||
ISO12100Section: "6.2.3",
|
|
||||||
DefaultSeverity: 1, DefaultExposure: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2206", NameDE: "Ausrutschen auf nassem Boden (Wasseraustritt/Leckage)", NameEN: "Slipping on wet floor (water leakage)",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing"},
|
|
||||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2207", "M538", "M2208"},
|
|
||||||
Priority: 76,
|
|
||||||
ApplicableLifecycles: []string{"normal_operation", "cleaning", "maintenance"},
|
|
||||||
ScenarioDE: "Aus der Spuelmaschine austretendes Wasser (Beschickung, Tuer oeffnen, Leckage, Tankwasserwechsel) macht den Boden im Aufstellbereich rutschig; der Bediener rutscht aus.",
|
|
||||||
TriggerDE: "Wasseraustritt beim Oeffnen/Beschicken; undichter Ablauf; fehlender Bodenablauf.",
|
|
||||||
HarmDE: "Sturz mit Prellungen, Knochenbruechen oder Kopfaufprall.",
|
|
||||||
AffectedDE: "Bedienpersonal, Reinigungspersonal",
|
|
||||||
ZoneDE: "Aufstell- und Bedienbereich der Spuelmaschine",
|
|
||||||
ISO12100Section: "6.3.5.6",
|
|
||||||
DefaultSeverity: 2, DefaultExposure: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2207", NameDE: "Rueckfluss / Kontamination des Trinkwassers", NameEN: "Backflow / potable-water contamination",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "backflow_risk"},
|
|
||||||
GeneratedHazardCats: []string{"material_environmental"},
|
|
||||||
SuggestedMeasureIDs: []string{"M2209"},
|
|
||||||
Priority: 84,
|
|
||||||
ApplicableLifecycles: []string{"normal_operation"},
|
|
||||||
ScenarioDE: "Verschmutztes Spuel- oder Chemiewasser wird ueber den Frischwasseranschluss in das Trinkwassernetz zurueckgesaugt und kontaminiert es (Ruecksaugen bei Unterdruck im Netz).",
|
|
||||||
TriggerDE: "Fehlender oder defekter Rueckflussverhinderer/Systemtrenner; Unterdruck im Trinkwassernetz; kein freier Auslauf.",
|
|
||||||
HarmDE: "Gesundheitsgefaehrdung Dritter durch kontaminiertes Trinkwasser (Chemie, Keime).",
|
|
||||||
AffectedDE: "Verbraucher am selben Trinkwassernetz, Betreiber",
|
|
||||||
ZoneDE: "Frischwasseranschluss, Wasserzulauf",
|
|
||||||
ISO12100Section: "6.2.4",
|
|
||||||
DefaultSeverity: 3, DefaultExposure: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2208", NameDE: "Schnittverletzung an scharfen Kanten/Sieben", NameEN: "Cut injury on sharp edges/screens",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "sharp_edge"},
|
|
||||||
GeneratedHazardCats: []string{"mechanical_hazard"},
|
|
||||||
SuggestedMeasureIDs: []string{"M003"},
|
|
||||||
Priority: 74,
|
|
||||||
ApplicableLifecycles: []string{"cleaning", "maintenance"},
|
|
||||||
ScenarioDE: "Schneiden an scharfen Blechkanten, Sieben oder dem Ablaufpumpen-Laufrad beim Reinigen oder Eingreifen in die Spuelkammer.",
|
|
||||||
TriggerDE: "Entnehmen/Reinigen der Siebe; Eingreifen an scharfen Kanten ohne Schutzhandschuhe.",
|
|
||||||
HarmDE: "Schnittwunden an Haenden und Fingern.",
|
|
||||||
AffectedDE: "Reinigungspersonal, Bedienpersonal",
|
|
||||||
ZoneDE: "Zugaengliche Kanten, Siebe, Spuelkammer, Ablaufpumpe",
|
|
||||||
ISO12100Section: "6.2.2.1",
|
|
||||||
DefaultSeverity: 1, DefaultExposure: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "HP2209", NameDE: "Unerwarteter Wiederanlauf bei Wartung/Reinigung", NameEN: "Unexpected restart during maintenance/cleaning",
|
|
||||||
RequiredComponentTags: []string{"dom_warewashing", "programmable"},
|
|
||||||
RequiredLifecycles: []string{"maintenance", "cleaning", "fault_clearing"},
|
|
||||||
GeneratedHazardCats: []string{"safety_function_failure"},
|
|
||||||
SuggestedMeasureIDs: []string{"M042"},
|
|
||||||
Priority: 80,
|
|
||||||
ApplicableLifecycles: []string{"maintenance", "cleaning"},
|
|
||||||
ScenarioDE: "Waehrend Wartung oder Reinigung laeuft die Maschine durch fehlende Freischaltung (LOTO) oder automatischen Wiederanlauf unerwartet an (Pumpe, Spuelgang).",
|
|
||||||
TriggerDE: "Kein Freischalten/Sichern gegen Wiedereinschalten; automatischer Wiederanlauf nach Netzunterbrechung.",
|
|
||||||
HarmDE: "Verbruehung, Quetschen oder elektrischer Schlag durch unerwartet anlaufende Maschine.",
|
|
||||||
AffectedDE: "Wartungspersonal, Reinigungspersonal",
|
|
||||||
ZoneDE: "Gesamte Maschine, Pumpe, Antriebe",
|
|
||||||
ISO12100Section: "6.2.11.4",
|
|
||||||
DefaultSeverity: 3, DefaultExposure: 2,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// firedSet runs the engine for the given custom tags and returns the set of
|
|
||||||
// fired pattern IDs.
|
|
||||||
func firedSet(customTags []string) map[string]bool {
|
|
||||||
engine := NewPatternEngine()
|
|
||||||
out := engine.Match(MatchInput{CustomTags: customTags})
|
|
||||||
fired := make(map[string]bool, len(out.MatchedPatterns))
|
|
||||||
for _, m := range out.MatchedPatterns {
|
|
||||||
fired[m.PatternID] = true
|
|
||||||
}
|
|
||||||
return fired
|
|
||||||
}
|
|
||||||
|
|
||||||
// A warewashing narrative emits these capability + functional tags.
|
|
||||||
var warewashingTags = []string{
|
|
||||||
"dom_warewashing", "steam_emission", "hot_water", "high_temperature",
|
|
||||||
"corrosive_chemical", "access_door", "rotating_part",
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarewashing_PatternsFireForDishwasher(t *testing.T) {
|
|
||||||
fired := firedSet(warewashingTags)
|
|
||||||
want := []string{"HP2200", "HP2201", "HP2202", "HP2203", "HP2204", "HP2205", "HP2206"}
|
|
||||||
for _, id := range want {
|
|
||||||
if !fired[id] {
|
|
||||||
t.Errorf("expected warewashing pattern %s to fire for a dishwasher, but it did not", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarewashing_PatternsDoNotLeakIntoOtherMachines(t *testing.T) {
|
|
||||||
// A machine with thermal + electrical + chemical capability but NOT a
|
|
||||||
// dishwasher must never produce warewashing hazards (dom_warewashing gate).
|
|
||||||
fired := firedSet([]string{"high_temperature", "electrical_part", "chemical_risk", "rotating_part", "moving_part"})
|
|
||||||
for _, id := range []string{"HP2200", "HP2201", "HP2202", "HP2203", "HP2204", "HP2205", "HP2206"} {
|
|
||||||
if fired[id] {
|
|
||||||
t.Errorf("warewashing pattern %s leaked into a non-dishwasher machine", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarewashing_WeldingAndGlueDoNotLeakIntoDishwasher(t *testing.T) {
|
|
||||||
// The gate-term additions must stop the welding/flame/glue burn patterns
|
|
||||||
// from firing for a dishwasher (they previously leaked via high_temperature
|
|
||||||
// / electrical_part). dom_welding/dom_flame/dom_glue are absent here.
|
|
||||||
fired := firedSet(warewashingTags)
|
|
||||||
leak := map[string]string{
|
|
||||||
"HP530": "Lichtbogen-Verbrennung (Schweissen)",
|
|
||||||
"HP532": "Schweissrauch",
|
|
||||||
"HP533": "Brand durch Schweissfunken (Schweissen)",
|
|
||||||
}
|
|
||||||
for id, name := range leak {
|
|
||||||
if fired[id] {
|
|
||||||
t.Errorf("cross-domain pattern %s (%s) leaked into a dishwasher", id, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarewashing_MeasureIDsExist(t *testing.T) {
|
|
||||||
lib := GetProtectiveMeasureLibrary()
|
|
||||||
have := make(map[string]bool, len(lib))
|
|
||||||
for _, m := range lib {
|
|
||||||
have[m.ID] = true
|
|
||||||
}
|
|
||||||
for _, p := range GetWarewashingPatterns() {
|
|
||||||
for _, mid := range p.SuggestedMeasureIDs {
|
|
||||||
if !have[mid] {
|
|
||||||
t.Errorf("pattern %s references measure %s which is not in the library", p.ID, mid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarewashing_NarrativeEmitsTags(t *testing.T) {
|
|
||||||
// Closes the loop: a realistic dishwasher description must emit the tags
|
|
||||||
// the warewashing patterns gate on (otherwise the patterns are dead).
|
|
||||||
narrative := "Gewerbliche Untertisch-Geschirrspuelmaschine mit Heisswasser-Boiler " +
|
|
||||||
"und Nachspuelung ca. 85 Grad C, Spuelpumpe mit rotierenden Spuelfeldern, " +
|
|
||||||
"Dampf-/Wrasenabgabe beim Oeffnen, Reiniger und Klarspueler ueber Dosiergeraet, " +
|
|
||||||
"Tuer mit Sicherheitsschalter, Eingreifen in die Spuelkammer."
|
|
||||||
res := ParseNarrative(narrative, "Gewerbliche Geschirrspuelmaschine")
|
|
||||||
got := make(map[string]bool, len(res.CustomTags))
|
|
||||||
for _, tag := range res.CustomTags {
|
|
||||||
got[tag] = true
|
|
||||||
}
|
|
||||||
for _, want := range []string{"dom_warewashing", "steam_emission", "hot_water", "corrosive_chemical", "access_door", "rotating_part"} {
|
|
||||||
if !got[want] {
|
|
||||||
t.Errorf("narrative did not emit expected tag %q (got %v)", want, res.CustomTags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And it must NOT emit any welding/flame/glue domain that would re-open leaks.
|
|
||||||
for _, bad := range []string{"dom_welding", "dom_flame", "dom_glue"} {
|
|
||||||
if got[bad] {
|
|
||||||
t.Errorf("dishwasher narrative unexpectedly emitted cross-domain tag %q", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarewashing_NewMeasuresPresent(t *testing.T) {
|
|
||||||
lib := GetProtectiveMeasureLibrary()
|
|
||||||
have := make(map[string]bool, len(lib))
|
|
||||||
for _, m := range lib {
|
|
||||||
have[m.ID] = true
|
|
||||||
}
|
|
||||||
for _, mid := range []string{"M2200", "M2201", "M2202", "M2203", "M2204", "M2205", "M2206", "M2207", "M2208"} {
|
|
||||||
if !have[mid] {
|
|
||||||
t.Errorf("expected warewashing measure %s to be registered in the library", mid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -88,28 +88,6 @@ func GetKeywordDictionary() []KeywordEntry {
|
|||||||
{Keywords: []string{"folienwickler", "wickelmaschine", "konfektioniermaschine", "folienverpackung", "wellpappe"}, ExtraTags: []string{"dom_converting"}},
|
{Keywords: []string{"folienwickler", "wickelmaschine", "konfektioniermaschine", "folienverpackung", "wellpappe"}, ExtraTags: []string{"dom_converting"}},
|
||||||
{Keywords: []string{"bergbau", "untertage", "tunnelbau", "off-grid"}, ExtraTags: []string{"dom_remote"}},
|
{Keywords: []string{"bergbau", "untertage", "tunnelbau", "off-grid"}, ExtraTags: []string{"dom_remote"}},
|
||||||
{Keywords: []string{"asbest", "asbestsanierung", "asbestexposition"}, ExtraTags: []string{"dom_asbestos"}},
|
{Keywords: []string{"asbest", "asbestsanierung", "asbestexposition"}, ExtraTags: []string{"dom_asbestos"}},
|
||||||
{Keywords: []string{"gasbrenner", "brennerbetrieb", "offene flamme", "flammhaert", "abflammen", "flammrichten"}, ExtraTags: []string{"dom_flame"}},
|
|
||||||
{Keywords: []string{"heissleim", "heissleimanlage", "schmelzkleber", "schmelzklebstoff", "klebstoffschmelzer", "leimwerk"}, ExtraTags: []string{"dom_glue"}},
|
|
||||||
|
|
||||||
// ── Gewerbliche Spuelmaschine / Warewashing ──────────────────────
|
|
||||||
// dom_warewashing gates the warewashing-specific patterns
|
|
||||||
// (hazard_patterns_warewashing.go) so they never leak into other
|
|
||||||
// machine classes. The functional tags (hot_water, steam_emission,
|
|
||||||
// corrosive_chemical, access_door) are the within-domain triggers.
|
|
||||||
{Keywords: []string{"spuelmaschine", "geschirrspuelmaschine", "geschirrspueler", "haubenspuelmaschine", "untertischspuelmaschine", "korbspuelmaschine", "bandspuelmaschine", "glaeserspuelmaschine", "bistrospuelmaschine", "warewashing", "dishwasher"}, ExtraTags: []string{"dom_warewashing"}},
|
|
||||||
{Keywords: []string{"heisswasser", "nachspuelung", "nachspueltemperatur", "spuelgang", "spuelzyklus", "thermostopp", "thermostop"}, ExtraTags: []string{"hot_water", "high_temperature"}},
|
|
||||||
{Keywords: []string{"dampf", "wrasen", "schwaden", "brueden"}, ExtraTags: []string{"steam_emission", "high_temperature"}},
|
|
||||||
{Keywords: []string{"boiler", "spuelboiler", "nachspuelboiler", "tankheiz", "boilerheiz"}, ComponentIDs: []string{"C094"}, ExtraTags: []string{"heating_element", "high_temperature"}},
|
|
||||||
{Keywords: []string{"reiniger", "klarspueler", "spuelmittel", "reinigungsmittel", "reinigerkonzentrat", "spuelchemie", "dosiergeraet", "dosierpumpe", "sauglanze", "entkalker"}, ExtraTags: []string{"corrosive_chemical"}},
|
|
||||||
// Spuelarm/Spuelfeld emit only the rotating_part capability tag. They are
|
|
||||||
// NOT mapped to a library component — C004 is a "Drehtisch" (rotary table)
|
|
||||||
// and that mislabels the spray arm. Keyword->component must be semantically
|
|
||||||
// honest (generic hygiene; surfaced by the warewashing GT).
|
|
||||||
{Keywords: []string{"spuelarm", "spuelfeld", "wascharm", "spruehfeld"}, ExtraTags: []string{"rotating_part"}},
|
|
||||||
{Keywords: []string{"spuelkammer", "spueltuer", "geraetetuer", "haubentuer", "klapptuer"}, ExtraTags: []string{"access_door"}},
|
|
||||||
// Frischwasseranschluss an das Trinkwassernetz -> Rueckfluss/Ruecksaug-Risiko (EN 1717).
|
|
||||||
{Keywords: []string{"rueckfluss", "rueckflussverhinderer", "ruecksaug", "trinkwasser", "frischwasseranschluss", "systemtrenner"}, ExtraTags: []string{"backflow_risk"}},
|
|
||||||
{Keywords: []string{"scharfe kante", "scharfkant", "blechkante", "scharfe blechkante", "sieb", "siebe"}, ExtraTags: []string{"sharp_edge"}},
|
|
||||||
// Ghost-Closure (Emit-Seite): macht die 34 toten Required-Tags
|
// Ghost-Closure (Emit-Seite): macht die 34 toten Required-Tags
|
||||||
// emittierbar, jeweils NUR via domaenenspezifische Keywords -> die 120
|
// emittierbar, jeweils NUR via domaenenspezifische Keywords -> die 120
|
||||||
// Ghost-Patterns feuern wieder, aber nur fuer ihre echte Maschine (kein
|
// Ghost-Patterns feuern wieder, aber nur fuer ihre echte Maschine (kein
|
||||||
@@ -137,7 +115,7 @@ func GetKeywordDictionary() []KeywordEntry {
|
|||||||
{Keywords: []string{"kreiselmaeher", "scheibenmaeher", "maehwerk"}, ExtraTags: []string{"agri_mower"}},
|
{Keywords: []string{"kreiselmaeher", "scheibenmaeher", "maehwerk"}, ExtraTags: []string{"agri_mower"}},
|
||||||
{Keywords: []string{"spruehduese", "spritzduese", "spruehkopf"}, ExtraTags: []string{"spray_nozzle"}},
|
{Keywords: []string{"spruehduese", "spritzduese", "spruehkopf"}, ExtraTags: []string{"spray_nozzle"}},
|
||||||
{Keywords: []string{"galvanikbad", "tauchbad", "beizbad", "chemiebad"}, ExtraTags: []string{"chemical_bath"}},
|
{Keywords: []string{"galvanikbad", "tauchbad", "beizbad", "chemiebad"}, ExtraTags: []string{"chemical_bath"}},
|
||||||
{Keywords: []string{"batterie", "akku", "akkumulator", "traktionsbatterie", "lithium", "batteriespeicher", "hochvoltbatterie", "lithium-batterie"}, ExtraTags: []string{"battery"}},
|
{Keywords: []string{"batterie", "akku", "akkumulator", "traktionsbatterie"}, ExtraTags: []string{"battery"}},
|
||||||
{Keywords: []string{"heizelement", "heizpatrone", "heizband"}, ExtraTags: []string{"heating_element"}},
|
{Keywords: []string{"heizelement", "heizpatrone", "heizband"}, ExtraTags: []string{"heating_element"}},
|
||||||
{Keywords: []string{"uv-lampe", "uv-strahler", "uv-c-strahler"}, ExtraTags: []string{"uv_source"}},
|
{Keywords: []string{"uv-lampe", "uv-strahler", "uv-c-strahler"}, ExtraTags: []string{"uv_source"}},
|
||||||
{Keywords: []string{"roentgen", "radioaktiv", "strahlenquelle", "gammastrahl", "isotop"}, ExtraTags: []string{"radiation_source"}},
|
{Keywords: []string{"roentgen", "radioaktiv", "strahlenquelle", "gammastrahl", "isotop"}, ExtraTags: []string{"radiation_source"}},
|
||||||
@@ -204,12 +182,6 @@ func GetKeywordDictionary() []KeywordEntry {
|
|||||||
{Keywords: []string{"lichtgitter", "lichtvorhang", "light curtain", "light grid"}, ComponentIDs: []string{"C102"}, ExtraTags: []string{"safety_device"}},
|
{Keywords: []string{"lichtgitter", "lichtvorhang", "light curtain", "light grid"}, ComponentIDs: []string{"C102"}, ExtraTags: []string{"safety_device"}},
|
||||||
{Keywords: []string{"sicherheitsschalter", "safety switch"}, ComponentIDs: []string{"C104"}, ExtraTags: []string{"safety_device", "interlocked"}},
|
{Keywords: []string{"sicherheitsschalter", "safety switch"}, ComponentIDs: []string{"C104"}, ExtraTags: []string{"safety_device", "interlocked"}},
|
||||||
{Keywords: []string{"zuhaltung", "guard locking", "interlock"}, ComponentIDs: []string{"C105"}, ExtraTags: []string{"safety_device", "interlocked"}},
|
{Keywords: []string{"zuhaltung", "guard locking", "interlock"}, ComponentIDs: []string{"C105"}, ExtraTags: []string{"safety_device", "interlocked"}},
|
||||||
// interlocked_enclosure signals that moving parts are inaccessible behind a
|
|
||||||
// guard that is monitored/locked — feeds the GuardableByEnclosure re-scoping
|
|
||||||
// (contact/entanglement becomes a maintenance/guard-open hazard, not a
|
|
||||||
// normal-operation one). Emitted only by explicit "interlocked door/guard"
|
|
||||||
// vocabulary so it does not trigger for machines with exposed motion.
|
|
||||||
{Keywords: []string{"tuer mit sicherheitsschalter", "verriegelte tuer", "verriegelte haube", "verriegelte einhausung", "sicherheitstuer", "tuerverriegelung", "haube mit sicherheitsschalter"}, ExtraTags: []string{"interlocked_enclosure"}},
|
|
||||||
{Keywords: []string{"zweihand", "two-hand", "zweihandschaltung"}, ComponentIDs: []string{"C106"}, ExtraTags: []string{"safety_device", "two_hand_control_required"}},
|
{Keywords: []string{"zweihand", "two-hand", "zweihandschaltung"}, ComponentIDs: []string{"C106"}, ExtraTags: []string{"safety_device", "two_hand_control_required"}},
|
||||||
{Keywords: []string{"schaltmatte", "safety mat"}, ComponentIDs: []string{"C108"}, ExtraTags: []string{"safety_device"}},
|
{Keywords: []string{"schaltmatte", "safety mat"}, ComponentIDs: []string{"C108"}, ExtraTags: []string{"safety_device"}},
|
||||||
{Keywords: []string{"seilzug", "pull wire"}, ComponentIDs: []string{"C109"}, ExtraTags: []string{"safety_device"}},
|
{Keywords: []string{"seilzug", "pull wire"}, ComponentIDs: []string{"C109"}, ExtraTags: []string{"safety_device"}},
|
||||||
@@ -222,9 +194,7 @@ func GetKeywordDictionary() []KeywordEntry {
|
|||||||
|
|
||||||
// ── Absaugung / Umwelt ──────────────────────────────────────────
|
// ── Absaugung / Umwelt ──────────────────────────────────────────
|
||||||
{Keywords: []string{"absaug", "extraction", "abscheider"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{"noise_source"}},
|
{Keywords: []string{"absaug", "extraction", "abscheider"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{"noise_source"}},
|
||||||
// "filteranlage" only — bare "filter" falsely mapped any filter (Laugen-,
|
{Keywords: []string{"filter", "filteranlage"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{}},
|
||||||
// Wasser-, Oel-, Netzfilter) to the oil-mist extractor C124.
|
|
||||||
{Keywords: []string{"filteranlage"}, ComponentIDs: []string{"C124"}, ExtraTags: []string{}},
|
|
||||||
|
|
||||||
// ── IT / Netzwerk ───────────────────────────────────────────────
|
// ── IT / Netzwerk ───────────────────────────────────────────────
|
||||||
{Keywords: []string{"switch", "netzwerk"}, ComponentIDs: []string{"C111"}, ExtraTags: []string{"networked"}},
|
{Keywords: []string{"switch", "netzwerk"}, ComponentIDs: []string{"C111"}, ExtraTags: []string{"networked"}},
|
||||||
@@ -253,10 +223,7 @@ func GetKeywordDictionary() []KeywordEntry {
|
|||||||
{Keywords: []string{"biege", "bend"}, ComponentIDs: []string{"C019"}, ExtraTags: []string{"high_force"}},
|
{Keywords: []string{"biege", "bend"}, ComponentIDs: []string{"C019"}, ExtraTags: []string{"high_force"}},
|
||||||
{Keywords: []string{"stanz", "stamp", "punch"}, ComponentIDs: []string{"C018"}, ExtraTags: []string{"high_force", "crush_point"}},
|
{Keywords: []string{"stanz", "stamp", "punch"}, ComponentIDs: []string{"C018"}, ExtraTags: []string{"high_force", "crush_point"}},
|
||||||
{Keywords: []string{"heiz", "heater", "heating"}, ComponentIDs: []string{"C094"}, EnergyIDs: []string{"EN06"}, ExtraTags: []string{"high_temperature"}},
|
{Keywords: []string{"heiz", "heater", "heating"}, ComponentIDs: []string{"C094"}, EnergyIDs: []string{"EN06"}, ExtraTags: []string{"high_temperature"}},
|
||||||
// Cooling UNIT only — not the bare adjectives "kuehl"/"cool", which falsely
|
{Keywords: []string{"kuehl", "cool"}, ComponentIDs: []string{"C095"}, ExtraTags: []string{}},
|
||||||
// matched product-variant names ("Cool-Ausfuehrung") and outputs ("kuehle
|
|
||||||
// Glaeser"). Keyword->component must name an actual component.
|
|
||||||
{Keywords: []string{"kuehlaggregat", "kuehlanlage", "kuehler", "kaeltemaschine", "chiller", "rueckkuehl"}, ComponentIDs: []string{"C095"}, ExtraTags: []string{}},
|
|
||||||
{Keywords: []string{"luefter", "fan", "geblaese"}, ComponentIDs: []string{"C096"}, ExtraTags: []string{"rotating_part", "noise_source"}},
|
{Keywords: []string{"luefter", "fan", "geblaese"}, ComponentIDs: []string{"C096"}, ExtraTags: []string{"rotating_part", "noise_source"}},
|
||||||
{Keywords: []string{"spannvorrichtung", "fixture", "clamp"}, ComponentIDs: []string{"C100"}, ExtraTags: []string{"clamping_part"}},
|
{Keywords: []string{"spannvorrichtung", "fixture", "clamp"}, ComponentIDs: []string{"C100"}, ExtraTags: []string{"clamping_part"}},
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ func GetProtectiveMeasureLibrary() []ProtectiveMeasureEntry {
|
|||||||
all = append(all, getGTBremseMeasures()...) // GT-Bremse-Coverage-Gaps (M483-M522)
|
all = append(all, getGTBremseMeasures()...) // GT-Bremse-Coverage-Gaps (M483-M522)
|
||||||
all = append(all, GetCRAMeasures()...) // CRA / DIN EN 40000-1-2 cyber-resilience (M540-M548)
|
all = append(all, GetCRAMeasures()...) // CRA / DIN EN 40000-1-2 cyber-resilience (M540-M548)
|
||||||
all = append(all, getLiftEndstopMeasures()...) // Lift/hoist endstop (M600-M604) — bridges OSHA MD library
|
all = append(all, getLiftEndstopMeasures()...) // Lift/hoist endstop (M600-M604) — bridges OSHA MD library
|
||||||
all = append(all, getWarewashingMeasures()...) // Commercial dishwasher (M2200-M2208) — scald/chemical/door/slip
|
|
||||||
return all
|
return all
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
// getWarewashingMeasures returns protective measures for commercial warewashing
|
|
||||||
// machines (gewerbliche Geschirrspuelmaschinen): hot-water/steam scalding,
|
|
||||||
// hot surfaces, corrosive cleaning chemicals, door pinch and wet-floor slip.
|
|
||||||
// They complement the generic thermal/mechanical/material measures with the
|
|
||||||
// machine-specific controls a Fachmann expects for this product class.
|
|
||||||
//
|
|
||||||
// M-ID range: M2200-M2208. Norm identifiers only (facts) — no norm text is
|
|
||||||
// reproduced (DIN/Beuth license). Lead standard: EN 60335-2-58 (safety of
|
|
||||||
// commercial electric dishwashing machines).
|
|
||||||
func getWarewashingMeasures() []ProtectiveMeasureEntry {
|
|
||||||
return []ProtectiveMeasureEntry{
|
|
||||||
{ID: "M2200", ReductionType: "design", SubType: "interlock",
|
|
||||||
Name: "Tuer-/Haubenverriegelung beendet Spuelgang vor dem Oeffnen",
|
|
||||||
Description: "Die Tuer bzw. Haube ist so mit der Steuerung verriegelt, dass beim Oeffnen Spuelpumpe und Nachspuelung sofort abschalten und ein Oeffnen erst nach Programmende (bzw. nach Abbau des Restdampfs) freigegeben wird. Verhindert den Schwall aus Heisswasser/Wrasen und den Kontakt mit noch rotierenden Spuelfeldern.",
|
|
||||||
HazardCategory: "thermal",
|
|
||||||
Examples: []string{"Tuerkontaktschalter schaltet Pumpe + Heizung beim Oeffnen ab", "Rastposition mit Restdampf-Verzoegerung vor Freigabe"},
|
|
||||||
NormReferences: []string{"EN 60335-2-58", "EN ISO 12100 — Inhaerent sichere Konstruktion"}},
|
|
||||||
{ID: "M2201", ReductionType: "design", SubType: "thermal",
|
|
||||||
Name: "Wrasen-/Dampfreduzierung (Kondensations- / Waermerueckgewinnungssystem)",
|
|
||||||
Description: "Der beim Oeffnen austretende Wrasen wird durch ein Kondensations- bzw. Waermerueckgewinnungssystem reduziert, sodass beim Entnehmen kein gefaehrlicher Dampfschwall entsteht. Senkt zugleich die Restwaerme- und Feuchtebelastung am Arbeitsplatz.",
|
|
||||||
HazardCategory: "thermal",
|
|
||||||
Examples: []string{"Umluft-Waermerueckgewinnung reduziert austretenden Wrasen", "Kondensationshaube ueber der Spuelkammer"},
|
|
||||||
NormReferences: []string{"EN 60335-2-58"}},
|
|
||||||
{ID: "M2202", ReductionType: "protection", SubType: "monitoring",
|
|
||||||
Name: "Thermostop / Temperaturueberwachung von Boiler und Tank",
|
|
||||||
Description: "Boiler- und Tanktemperatur werden ueberwacht; ein Thermostop gibt den naechsten Schritt erst frei, wenn die Solltemperatur erreicht ist, und begrenzt die maximale Nachspueltemperatur. Schuetzt vor Verbruehung durch unkontrolliert heisses Nachspuelwasser.",
|
|
||||||
HazardCategory: "thermal",
|
|
||||||
Examples: []string{"Temperatursensor in Boiler und Tank mit Abschaltgrenze", "Thermostop-Funktion im Spuelprogramm"},
|
|
||||||
NormReferences: []string{"EN 60335-2-58", "EN ISO 13732-1"}},
|
|
||||||
{ID: "M2203", ReductionType: "design", SubType: "containment",
|
|
||||||
Name: "Geschlossenes Dosiersystem mit Sauglanzen und Niveauueberwachung",
|
|
||||||
Description: "Reiniger und Klarspueler werden ausschliesslich ueber ein geschlossenes Dosiersystem mit Sauglanzen aus dem Originalgebinde gefoerdert (Niveau-Ueberwachung statt Umfuellen). Direkter Haut-/Augenkontakt mit dem aetzenden Konzentrat beim Nachfuellen wird konstruktiv vermieden.",
|
|
||||||
HazardCategory: "material_environmental",
|
|
||||||
Examples: []string{"Sauglanze mit Leermeldung im Reiniger-Kanister", "Kein Umfuellen — Gebindewechsel ohne offenen Chemiekontakt"},
|
|
||||||
NormReferences: []string{"EN 60335-2-58", "Verordnung (EG) Nr. 1272/2008 (CLP/GHS)"}},
|
|
||||||
{ID: "M2204", ReductionType: "information", SubType: "ppe",
|
|
||||||
Name: "PSA (Augen-/Hautschutz) + GHS-Kennzeichnung und Sicherheitsdatenblatt",
|
|
||||||
Description: "Fuer Handhabung, Gebindewechsel und Entkalkung werden Augen- und Handschutz vorgeschrieben; Reiniger/Klarspueler/Entkalker sind GHS-gekennzeichnet und das Sicherheitsdatenblatt liegt am Geraet vor. Stellt die sichere Handhabung der aetzenden Konzentrate sicher.",
|
|
||||||
HazardCategory: "material_environmental",
|
|
||||||
Examples: []string{"Schutzbrille + chemikalienbestaendige Handschuhe bei Gebindewechsel", "GHS-Etikett und SDB im Chemikalienschrank am Geraet"},
|
|
||||||
NormReferences: []string{"Verordnung (EG) Nr. 1272/2008 (CLP/GHS)", "TRGS 500"}},
|
|
||||||
{ID: "M2205", ReductionType: "protection", SubType: "ventilation",
|
|
||||||
Name: "Be-/Entlueftung bzw. geschlossene Haube gegen Chemie-Aerosole und Wrasen",
|
|
||||||
Description: "Der Aufstellbereich ist ausreichend be- und entlueftet bzw. die Spuelkammer bleibt waehrend des Programms geschlossen, sodass Reinigungs-Aerosole und heisser Wrasen nicht in die Atemzone des Bedieners gelangen.",
|
|
||||||
HazardCategory: "material_environmental",
|
|
||||||
Examples: []string{"Kuechenlueftung ueber dem Spuelbereich", "Programmstart nur bei geschlossener Haube"},
|
|
||||||
NormReferences: []string{"EN 60335-2-58", "TRGS 500"}},
|
|
||||||
{ID: "M2206", ReductionType: "design", SubType: "geometry",
|
|
||||||
Name: "Tuerkanten mit geringer Schliesskraft / Einklemmschutz",
|
|
||||||
Description: "Die Tuer-/Haubenmechanik ist so gestaltet (gefuehrte Bewegung, begrenzte Schliesskraft, abgerundete Kanten), dass beim Schliessen keine Finger gequetscht werden.",
|
|
||||||
HazardCategory: "mechanical",
|
|
||||||
Examples: []string{"Gefuehrte Haube mit gedaempfter Schliessbewegung", "Abgerundete Tuerkanten ohne Quetschspalt"},
|
|
||||||
NormReferences: []string{"EN 60335-2-58", "EN ISO 12100 — Geometrie und Anordnung"}},
|
|
||||||
{ID: "M2207", ReductionType: "design", SubType: "environment",
|
|
||||||
Name: "Rutschhemmender Bodenbelag + Ablauf/Leckagewanne im Aufstellbereich",
|
|
||||||
Description: "Im Aufstell- und Bedienbereich der Spuelmaschine sorgen rutschhemmender Bodenbelag und ein definierter Ablauf bzw. eine Leckagewanne dafuer, dass austretendes Wasser nicht zur Sturzgefahr wird.",
|
|
||||||
HazardCategory: "mechanical",
|
|
||||||
Examples: []string{"Rutschhemmender Industrieboden (Bewertungsgruppe R11/R12)", "Bodenablauf bzw. Leckagewanne unter dem Geraet"},
|
|
||||||
NormReferences: []string{"ASR A1.5/1,2", "DGUV Regel 108-003"}},
|
|
||||||
{ID: "M2208", ReductionType: "information", SubType: "signage",
|
|
||||||
Name: "Warnhinweis heisser Dampf/Heisswasser — Tuer erst nach Programmende oeffnen",
|
|
||||||
Description: "Am Geraet und in der Betriebsanleitung wird vor heissem Dampf und Heisswasser gewarnt und das Oeffnen der Tuer erst nach Programmende mit vorsichtigem Anheben vorgeschrieben. Sprachneutrale Piktogramme ergaenzen den Hinweis.",
|
|
||||||
HazardCategory: "general",
|
|
||||||
Examples: []string{"Warnpiktogramm 'Heisser Dampf' an der Tuer", "BA-Hinweis 'Tuer nach Programmende langsam oeffnen'"},
|
|
||||||
NormReferences: []string{"ISO 7010", "EN 60335-2-58"}},
|
|
||||||
{ID: "M2209", ReductionType: "design", SubType: "containment",
|
|
||||||
Name: "Rueckflussverhinderer / Systemtrenner nach EN 1717",
|
|
||||||
Description: "Der Frischwasseranschluss ist durch einen Rueckflussverhinderer bzw. Systemtrenner der passenden Schutzklasse oder durch einen freien Auslauf gegen Ruecksaugen verschmutzten Wassers in das Trinkwassernetz gesichert.",
|
|
||||||
HazardCategory: "material_environmental",
|
|
||||||
Examples: []string{"Systemtrenner Typ BA nach EN 1717", "Freier Auslauf Typ AB ueber dem hoechsten Wasserstand"},
|
|
||||||
NormReferences: []string{"EN 1717", "EN 60335-2-58"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,20 +46,6 @@ var domainGateTerms = map[string]string{
|
|||||||
"widerstandsschweiss": "dom_welding", "lichtbogenschweiss": "dom_welding",
|
"widerstandsschweiss": "dom_welding", "lichtbogenschweiss": "dom_welding",
|
||||||
"schutzgasschweiss": "dom_welding", "punktschweiss": "dom_welding",
|
"schutzgasschweiss": "dom_welding", "punktschweiss": "dom_welding",
|
||||||
"schweisselektrod": "dom_welding", "elektrodenspalt": "dom_welding",
|
"schweisselektrod": "dom_welding", "elektrodenspalt": "dom_welding",
|
||||||
// Schweissen — Oberflaechenformen die bisher ungegatet leakten (z.B. in
|
|
||||||
// thermische Hazards einer Spuelmaschine ueber high_temperature/electrical_part)
|
|
||||||
"schweissarbeitsplatz": "dom_welding", "schweissfunke": "dom_welding",
|
|
||||||
"schweisshelm": "dom_welding", "schweisserschutz": "dom_welding",
|
|
||||||
"lichtbogenzone": "dom_welding", "lichtbogen-verbrennung": "dom_welding",
|
|
||||||
"schweissrauch": "dom_welding", "schweissgeraet": "dom_welding",
|
|
||||||
"schweisszone": "dom_welding", "schweissbrenner": "dom_welding",
|
|
||||||
"schweissspritzer": "dom_welding", "schweissstrom": "dom_welding",
|
|
||||||
// Offene Flamme / Brenner (Gasbrenner, Flammhaerten, Abflammen)
|
|
||||||
"offene flamme": "dom_flame", "brennerbereich": "dom_flame",
|
|
||||||
"flammenzone": "dom_flame", "gasbrenner": "dom_flame",
|
|
||||||
// Heissleim / Schmelzkleber
|
|
||||||
"heissleimanlage": "dom_glue", "klebstoffschmelzer": "dom_glue",
|
|
||||||
"heisskleber": "dom_glue", "schmelzkleber": "dom_glue",
|
|
||||||
// Solar / PV
|
// Solar / PV
|
||||||
"pv-modul": "dom_solar", "photovoltaik": "dom_solar", "pv-anlage": "dom_solar",
|
"pv-modul": "dom_solar", "photovoltaik": "dom_solar", "pv-anlage": "dom_solar",
|
||||||
"dc-steckverbindung": "dom_solar", "solarmodul": "dom_solar",
|
"dc-steckverbindung": "dom_solar", "solarmodul": "dom_solar",
|
||||||
@@ -67,7 +53,6 @@ var domainGateTerms = map[string]string{
|
|||||||
"gondel": "dom_wind", "rotorblatt": "dom_wind", "windenergieanlage": "dom_wind",
|
"gondel": "dom_wind", "rotorblatt": "dom_wind", "windenergieanlage": "dom_wind",
|
||||||
// CNC / Zerspanung
|
// CNC / Zerspanung
|
||||||
"drehmaschine": "dom_cnc", "fraesmaschine": "dom_cnc",
|
"drehmaschine": "dom_cnc", "fraesmaschine": "dom_cnc",
|
||||||
"spanende": "dom_cnc", "spanenden bearbeitung": "dom_cnc",
|
|
||||||
// Landwirtschaft
|
// Landwirtschaft
|
||||||
"maehdrescher": "dom_agri", "ballenpresse": "dom_agri", "feldhaecksler": "dom_agri",
|
"maehdrescher": "dom_agri", "ballenpresse": "dom_agri", "feldhaecksler": "dom_agri",
|
||||||
// Roll-/Fahrtreppe
|
// Roll-/Fahrtreppe
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
// Interlocked-enclosure model (EN ISO 14120 / EN ISO 12100).
|
|
||||||
//
|
|
||||||
// A contact or entanglement hazard from a moving part is removed during NORMAL
|
|
||||||
// operation when that part is inaccessible behind an interlocked guard. The
|
|
||||||
// hazard then remains only when the guard is open — maintenance, cleaning or
|
|
||||||
// fault clearing. Patterns flagged GuardableByEnclosure express this; a project
|
|
||||||
// emits the "interlocked_enclosure" tag (interlocked door/hood, see
|
|
||||||
// keyword_dictionary.go) to declare the guard.
|
|
||||||
//
|
|
||||||
// This is GENERIC: it applies to every enclosed machine (dishwasher spray arm,
|
|
||||||
// enclosed mixer, centrifuge ...) and is regression-safe — machines that do not
|
|
||||||
// emit interlocked_enclosure are unaffected.
|
|
||||||
|
|
||||||
const (
|
|
||||||
phaseMaintenance = "maintenance"
|
|
||||||
phaseCleaning = "cleaning"
|
|
||||||
phaseFaultClearing = "fault_clearing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// suppressedByEnclosure reports whether a guardable hazard must be dropped: the
|
|
||||||
// part is enclosed AND none of the project's lifecycle phases opens the guard.
|
|
||||||
func suppressedByEnclosure(p HazardPattern, tagSet map[string]bool, lifecycles []string) bool {
|
|
||||||
if !p.GuardableByEnclosure || !tagSet["interlocked_enclosure"] || len(lifecycles) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, lc := range lifecycles {
|
|
||||||
if lc == phaseMaintenance || lc == phaseCleaning || lc == phaseFaultClearing {
|
|
||||||
return false // guard is open in some phase → hazard remains there
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// guardedLifecycles re-scopes a guardable hazard to the guard-open phases when
|
|
||||||
// the project declares an interlocked enclosure, so it is documented as a
|
|
||||||
// maintenance/cleaning hazard rather than a normal-operation one.
|
|
||||||
func guardedLifecycles(p HazardPattern, tagSet map[string]bool) []string {
|
|
||||||
if p.GuardableByEnclosure && tagSet["interlocked_enclosure"] {
|
|
||||||
return []string{phaseMaintenance, phaseCleaning}
|
|
||||||
}
|
|
||||||
return p.ApplicableLifecycles
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domain-specific supersession.
|
|
||||||
//
|
|
||||||
// A generic pattern that fires via a broad tag (e.g. high_temperature) can
|
|
||||||
// duplicate a domain-specific pattern that describes the same hazard more
|
|
||||||
// precisely. When the domain is present, the specific pattern wins and the
|
|
||||||
// generic duplicate is dropped. Scoped to the domain tag, so machines outside
|
|
||||||
// the domain keep the generic pattern — regression-safe by construction.
|
|
||||||
//
|
|
||||||
// HP016 (generic hot surfaces) -> HP2201 (Boiler/Tank/Spuelkammer)
|
|
||||||
// HP018 (actuator burn) -> HP2201 (same contact-burn hazard)
|
|
||||||
// HP013 (stored electrical NRG) -> HP144 (residual voltage; HP013's zone is
|
|
||||||
// framed for Batteriefaecher/USV-Anlagen a
|
|
||||||
// dishwasher does not have, HP144 is the
|
|
||||||
// Frequenzumrichter/Zwischenkreis variant)
|
|
||||||
var genericSupersededByWarewashing = map[string]bool{
|
|
||||||
"HP016": true,
|
|
||||||
"HP018": true,
|
|
||||||
"HP013": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// supersededByDomainSpecific reports whether a generic pattern is replaced by a
|
|
||||||
// more precise equivalent that the project's domain already provides.
|
|
||||||
func supersededByDomainSpecific(p HazardPattern, tagSet map[string]bool) bool {
|
|
||||||
return tagSet["dom_warewashing"] && genericSupersededByWarewashing[p.ID]
|
|
||||||
}
|
|
||||||
@@ -223,7 +223,7 @@ func (e *PatternEngine) Match(input MatchInput) *MatchOutput {
|
|||||||
HumanRoles: p.HumanRoles,
|
HumanRoles: p.HumanRoles,
|
||||||
GeneratedHazardType: p.GeneratedHazardType,
|
GeneratedHazardType: p.GeneratedHazardType,
|
||||||
MatchedFailureModes: matchedFMs,
|
MatchedFailureModes: matchedFMs,
|
||||||
ApplicableLifecycles: guardedLifecycles(p, tagSet),
|
ApplicableLifecycles: p.ApplicableLifecycles,
|
||||||
SuggestedMeasureIDs: p.SuggestedMeasureIDs,
|
SuggestedMeasureIDs: p.SuggestedMeasureIDs,
|
||||||
ClarificationQuestionsDE: p.ClarificationQuestionsDE,
|
ClarificationQuestionsDE: p.ClarificationQuestionsDE,
|
||||||
ISO12100Section: p.ISO12100Section,
|
ISO12100Section: p.ISO12100Section,
|
||||||
@@ -411,16 +411,6 @@ func patternMatches(p HazardPattern, tagSet map[string]bool, input MatchInput) b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interlocked-enclosure gate (guardable contact/entanglement). See pattern_enclosure.go.
|
|
||||||
if suppressedByEnclosure(p, tagSet, input.LifecyclePhases) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Domain-specific supersession (generic duplicate replaced by a precise one).
|
|
||||||
if supersededByDomainSpecific(p, tagSet) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ func collectAllPatterns() []HazardPattern {
|
|||||||
patterns = append(patterns, GetCRAPatterns()...) // HP1910-HP1918 CRA / DIN EN 40000-1-2 cyber-resilience spur
|
patterns = append(patterns, GetCRAPatterns()...) // HP1910-HP1918 CRA / DIN EN 40000-1-2 cyber-resilience spur
|
||||||
patterns = append(patterns, GetSecondaryHarmDemoPatterns()...) // HP2000-HP2001 secondary harm chain demos (Cola splitter, Pharma)
|
patterns = append(patterns, GetSecondaryHarmDemoPatterns()...) // HP2000-HP2001 secondary harm chain demos (Cola splitter, Pharma)
|
||||||
patterns = append(patterns, GetLiftEndstopPatterns()...) // HP2100-HP2102 lift body-part crush at endstops
|
patterns = append(patterns, GetLiftEndstopPatterns()...) // HP2100-HP2102 lift body-part crush at endstops
|
||||||
patterns = append(patterns, GetWarewashingPatterns()...) // HP2200-HP2206 commercial dishwasher (scald/chemical/door/slip)
|
|
||||||
patterns = applyMachineTypeOverrides(patterns) // Fill MachineTypes on legacy patterns to prevent drift
|
patterns = applyMachineTypeOverrides(patterns) // Fill MachineTypes on legacy patterns to prevent drift
|
||||||
patterns = applyDomainGates(patterns) // Capability-domain gate: stop domain-specific patterns leaking cross-machine
|
patterns = applyDomainGates(patterns) // Capability-domain gate: stop domain-specific patterns leaking cross-machine
|
||||||
return patterns
|
return patterns
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Coverage blind-spot proposer (P2 slice 6, type 4). DEV-TIME, propose-only.
|
|
||||||
//
|
|
||||||
// Deterministic skeleton: which EN ISO 12100 hazard groups (A-G, the classic CE
|
|
||||||
// groups; H-J are control/CRA and routinely routed elsewhere) did the engine
|
|
||||||
// leave with ZERO hazards for this machine? An empty group is a structural
|
|
||||||
// blind-spot signal — the machine may genuinely lack that hazard, or a pattern
|
|
||||||
// may be missing. The LLM then expands each gap into specific expected-but-missing
|
|
||||||
// hazards a safety assessor would name, for a human to confirm into a new pattern
|
|
||||||
// or GT case. The gaps alone are useful without any model.
|
|
||||||
|
|
||||||
type isoGroup struct {
|
|
||||||
Key string
|
|
||||||
Label string
|
|
||||||
Cats []string
|
|
||||||
}
|
|
||||||
|
|
||||||
var iso12100Groups = []isoGroup{
|
|
||||||
{"mechanical", "A. Mechanisch", []string{"mechanical_hazard", "mechanical", "maintenance_hazard"}},
|
|
||||||
{"electrical", "B. Elektrisch", []string{"electrical_hazard", "electrical", "emc_hazard"}},
|
|
||||||
{"thermal", "C. Thermisch", []string{"thermal_hazard", "thermal", "high_temperature", "fire_explosion"}},
|
|
||||||
{"pneumatic_hydraulic", "D. Pneumatik/Hydraulik", []string{"pneumatic_hydraulic"}},
|
|
||||||
{"noise_vibration", "E. Laerm/Vibration", []string{"noise_hazard", "noise_vibration", "vibration_hazard"}},
|
|
||||||
{"ergonomic", "F. Ergonomie", []string{"ergonomic_hazard", "ergonomic"}},
|
|
||||||
{"material", "G. Stoffe/Umwelt", []string{"material_environmental", "chemical_risk", "radiation_hazard"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoverageGap is an ISO 12100 hazard group with no engine hazard.
|
|
||||||
type CoverageGap struct {
|
|
||||||
Group string `json:"group"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Note string `json:"note"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindCoverageGaps returns the A-G hazard groups that produced zero hazards.
|
|
||||||
func FindCoverageGaps(hazards []Hazard) []CoverageGap {
|
|
||||||
present := make(map[string]bool, len(hazards))
|
|
||||||
for _, h := range hazards {
|
|
||||||
present[h.Category] = true
|
|
||||||
}
|
|
||||||
var gaps []CoverageGap
|
|
||||||
for _, g := range iso12100Groups {
|
|
||||||
covered := false
|
|
||||||
for _, c := range g.Cats {
|
|
||||||
if present[c] {
|
|
||||||
covered = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !covered {
|
|
||||||
gaps = append(gaps, CoverageGap{
|
|
||||||
Group: g.Label, Key: g.Key,
|
|
||||||
Note: "no engine hazard in this ISO 12100 group — verify the machine truly lacks it, or a pattern is missing",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gaps
|
|
||||||
}
|
|
||||||
|
|
||||||
// MissingHazard is an LLM-proposed hazard a safety assessor would expect.
|
|
||||||
type MissingHazard struct {
|
|
||||||
Group string `json:"group"`
|
|
||||||
Hazard string `json:"hazard"`
|
|
||||||
Why string `json:"why"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProposeMissingHazards asks the LLM to expand the empty groups into specific
|
|
||||||
// expected hazards. Returns nil without a completer or on any error — propose-only,
|
|
||||||
// never breaks the run.
|
|
||||||
func ProposeMissingHazards(ctx context.Context, completer LLMCompleter, machineClass, narrative string, produced []Hazard, gaps []CoverageGap) []MissingHazard {
|
|
||||||
if completer == nil || len(gaps) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
system, user := BuildCoveragePrompt(machineClass, narrative, produced, gaps)
|
|
||||||
raw, err := completer.Complete(ctx, system, user)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return parseMissingHazards(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildCoveragePrompt frames the "what is missing?" question for the LLM.
|
|
||||||
func BuildCoveragePrompt(machineClass, narrative string, produced []Hazard, gaps []CoverageGap) (system, user string) {
|
|
||||||
system = "Du bist Sachverstaendiger fuer Maschinensicherheit nach EN ISO 12100. " +
|
|
||||||
"Dir werden eine Maschine, die bereits erkannten Gefaehrdungen und Gefaehrdungsgruppen OHNE Eintrag genannt. " +
|
|
||||||
"Nenne nur Gefaehrdungen, die ein Sachverstaendiger fuer DIESE Maschine ERWARTET, die aber FEHLEN. " +
|
|
||||||
"Erfinde nichts Maschinenfremdes. Antworte AUSSCHLIESSLICH als JSON-Array: " +
|
|
||||||
`[{"group":"...","hazard":"...","why":"..."}].`
|
|
||||||
|
|
||||||
var have []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, h := range produced {
|
|
||||||
if h.Category != "" && !seen[h.Category] {
|
|
||||||
seen[h.Category] = true
|
|
||||||
have = append(have, h.Category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var empty []string
|
|
||||||
for _, g := range gaps {
|
|
||||||
empty = append(empty, g.Group)
|
|
||||||
}
|
|
||||||
user = fmt.Sprintf("Maschinenklasse: %s\n\nBeschreibung:\n%s\n\nBereits erkannte Kategorien: %s\n\nGruppen OHNE Eintrag (Fokus): %s\n\nWelche erwarteten Gefaehrdungen fehlen?",
|
|
||||||
machineClass, narrative, strings.Join(have, ", "), strings.Join(empty, ", "))
|
|
||||||
return system, user
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseMissingHazards(raw string) []MissingHazard {
|
|
||||||
start, end := strings.Index(raw, "["), strings.LastIndex(raw, "]")
|
|
||||||
if start < 0 || end <= start {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []MissingHazard
|
|
||||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderCoverageQueue renders the deterministic gaps plus any LLM-proposed missing
|
|
||||||
// hazards as a markdown review queue.
|
|
||||||
func RenderCoverageQueue(machine string, gaps []CoverageGap, missing []MissingHazard) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintf(&b, "# Coverage blind-spot queue — %s\n\n", machine)
|
|
||||||
fmt.Fprintf(&b, "%d ISO 12100 group(s) (A-G) have no engine hazard. Propose-only — a human confirms whether the machine truly lacks it or a pattern/GT case is missing.\n\n", len(gaps))
|
|
||||||
for _, g := range gaps {
|
|
||||||
fmt.Fprintf(&b, "- **%s** — %s\n", g.Group, g.Note)
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
fmt.Fprintf(&b, "\n## LLM-proposed expected-but-missing hazards (%d)\n\n", len(missing))
|
|
||||||
for i, m := range missing {
|
|
||||||
fmt.Fprintf(&b, "%d. [%s] %s\n - why: %s\n", i+1, m.Group, m.Hazard, m.Why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFindCoverageGaps(t *testing.T) {
|
|
||||||
hazards := []Hazard{
|
|
||||||
{Category: "mechanical_hazard"},
|
|
||||||
{Category: "thermal_hazard"},
|
|
||||||
{Category: "electrical_hazard"},
|
|
||||||
{Category: "material_environmental"},
|
|
||||||
}
|
|
||||||
gapKeys := map[string]bool{}
|
|
||||||
for _, g := range FindCoverageGaps(hazards) {
|
|
||||||
gapKeys[g.Key] = true
|
|
||||||
}
|
|
||||||
for _, want := range []string{"pneumatic_hydraulic", "noise_vibration", "ergonomic"} {
|
|
||||||
if !gapKeys[want] {
|
|
||||||
t.Errorf("expected gap %s", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, notWant := range []string{"mechanical", "thermal", "electrical", "material"} {
|
|
||||||
if gapKeys[notWant] {
|
|
||||||
t.Errorf("did not expect gap %s (covered)", notWant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildCoveragePrompt_ContainsContext(t *testing.T) {
|
|
||||||
produced := []Hazard{{Category: "thermal_hazard"}}
|
|
||||||
gaps := []CoverageGap{{Group: "F. Ergonomie", Key: "ergonomic"}}
|
|
||||||
system, user := BuildCoveragePrompt("Geschirrspuelmaschine", "Eine Spuelmaschine mit Tank.", produced, gaps)
|
|
||||||
if !strings.Contains(system, "EN ISO 12100") || !strings.Contains(system, "JSON") {
|
|
||||||
t.Errorf("system prompt missing framing")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"Geschirrspuelmaschine", "thermal_hazard", "F. Ergonomie", "Spuelmaschine mit Tank"} {
|
|
||||||
if !strings.Contains(user, want) {
|
|
||||||
t.Errorf("user prompt missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProposeMissingHazards_ParsesAndDegrades(t *testing.T) {
|
|
||||||
gaps := []CoverageGap{{Group: "F. Ergonomie", Key: "ergonomic"}}
|
|
||||||
c := fakeCompleter{out: `Hier: [{"group":"F. Ergonomie","hazard":"Heben schwerer Koerbe","why":"manuelles Beladen"}] fertig`}
|
|
||||||
got := ProposeMissingHazards(context.Background(), c, "x", "n", nil, gaps)
|
|
||||||
if len(got) != 1 || got[0].Hazard != "Heben schwerer Koerbe" {
|
|
||||||
t.Fatalf("parse: got %+v", got)
|
|
||||||
}
|
|
||||||
if ProposeMissingHazards(context.Background(), nil, "x", "n", nil, gaps) != nil {
|
|
||||||
t.Errorf("nil completer must return nil")
|
|
||||||
}
|
|
||||||
if ProposeMissingHazards(context.Background(), fakeCompleter{err: context.DeadlineExceeded}, "x", "n", nil, gaps) != nil {
|
|
||||||
t.Errorf("error must return nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Offline dedup-candidate proposer (P2, type 1). DEV-TIME ONLY.
|
|
||||||
//
|
|
||||||
// It inspects the patterns that fired for one machine and proposes which look
|
|
||||||
// like duplicates, so a human (later an LLM) can decide a supersession/merge. It
|
|
||||||
// NEVER mutates the pattern library or the runtime — it only surfaces candidates.
|
|
||||||
// The deterministic GT screen (ScreenSupersession, proposer_screen.go) is the
|
|
||||||
// wall that proves a proposal is safe before a human ever sees it.
|
|
||||||
//
|
|
||||||
// Detection here is purely structural (category + zone + measure + scenario
|
|
||||||
// overlap) and therefore reproducible. Two safety rules bake in what P1 taught
|
|
||||||
// us about the dishwasher review:
|
|
||||||
// - only patterns with the SAME primary category are ever compared;
|
|
||||||
// - a pair with DIFFERENT operational states is NEVER proposed, because
|
|
||||||
// normal-operation and maintenance are legitimately distinct contexts with
|
|
||||||
// different protective measures (e.g. HP011 vs HP077). Merging them would
|
|
||||||
// erase the maintenance view.
|
|
||||||
|
|
||||||
// DedupCandidate is a proposed near-duplicate pattern pair for one machine class.
|
|
||||||
type DedupCandidate struct {
|
|
||||||
KeepPattern string `json:"keep_pattern"` // higher-priority survivor
|
|
||||||
DropPattern string `json:"drop_pattern"` // supersession target
|
|
||||||
KeepName string `json:"keep_name"`
|
|
||||||
KeepHazardName string `json:"keep_hazard_name"` // keep pattern ScenarioDE (for the GT-distinctness screen)
|
|
||||||
DropName string `json:"drop_name"` // == generated hazard Name (ScenarioDE) of the drop pattern
|
|
||||||
Category string `json:"category"`
|
|
||||||
ZoneJaccard float64 `json:"zone_jaccard"`
|
|
||||||
MeasureJaccard float64 `json:"measure_jaccard"`
|
|
||||||
ScenarioJaccard float64 `json:"scenario_jaccard"`
|
|
||||||
Score float64 `json:"score"`
|
|
||||||
Rationale string `json:"rationale"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindDedupCandidates compares the fired patterns pairwise and returns near-dup
|
|
||||||
// candidates whose combined overlap score meets threshold, deterministically
|
|
||||||
// ordered (score desc, then drop-pattern id). The combined score weights measure
|
|
||||||
// overlap highest (shared measures are the strongest duplicate signal), then zone
|
|
||||||
// and scenario equally.
|
|
||||||
func FindDedupCandidates(fired []PatternMatch, threshold float64) []DedupCandidate {
|
|
||||||
var out []DedupCandidate
|
|
||||||
for i := 0; i < len(fired); i++ {
|
|
||||||
for j := i + 1; j < len(fired); j++ {
|
|
||||||
a, b := fired[i], fired[j]
|
|
||||||
ca := primaryCat(a)
|
|
||||||
if ca == "" || ca != primaryCat(b) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !sameOpStateSet(a.OperationalStates, b.OperationalStates) {
|
|
||||||
continue // legitimate lifecycle variants — never propose a merge
|
|
||||||
}
|
|
||||||
zj := tokenJaccard(zoneTokenSet(a.ZoneDE), zoneTokenSet(b.ZoneDE))
|
|
||||||
mj := tokenJaccard(toSet(a.SuggestedMeasureIDs), toSet(b.SuggestedMeasureIDs))
|
|
||||||
sj := tokenJaccard(wordTokenSet(a.ScenarioDE), wordTokenSet(b.ScenarioDE))
|
|
||||||
score := 0.4*mj + 0.3*zj + 0.3*sj
|
|
||||||
if score < threshold {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keep, drop := a, b
|
|
||||||
if b.Priority > a.Priority {
|
|
||||||
keep, drop = b, a
|
|
||||||
}
|
|
||||||
out = append(out, DedupCandidate{
|
|
||||||
KeepPattern: keep.PatternID, DropPattern: drop.PatternID,
|
|
||||||
KeepName: keep.PatternName, KeepHazardName: keep.ScenarioDE, DropName: drop.ScenarioDE,
|
|
||||||
Category: ca, ZoneJaccard: round2(zj), MeasureJaccard: round2(mj),
|
|
||||||
ScenarioJaccard: round2(sj), Score: round2(score),
|
|
||||||
Rationale: fmt.Sprintf(
|
|
||||||
"same category %q · measure overlap %.0f%% · zone overlap %.0f%% · scenario overlap %.0f%% → keep %s (P%d), supersede %s (P%d)",
|
|
||||||
ca, mj*100, zj*100, sj*100, keep.PatternID, keep.Priority, drop.PatternID, drop.Priority),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
|
||||||
if out[i].Score != out[j].Score {
|
|
||||||
return out[i].Score > out[j].Score
|
|
||||||
}
|
|
||||||
return out[i].DropPattern < out[j].DropPattern
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func primaryCat(pm PatternMatch) string {
|
|
||||||
if len(pm.HazardCats) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return pm.HazardCats[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameOpStateSet(a, b []string) bool {
|
|
||||||
sa, sb := toSet(a), toSet(b)
|
|
||||||
if len(sa) != len(sb) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for k := range sa {
|
|
||||||
if !sb[k] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
var proposerWordSplit = regexp.MustCompile(`[^\p{L}]+`)
|
|
||||||
|
|
||||||
// zoneTokenSet splits a comma-separated zone string into its component terms.
|
|
||||||
func zoneTokenSet(zone string) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, part := range strings.Split(strings.ToLower(zone), ",") {
|
|
||||||
if t := strings.TrimSpace(part); len([]rune(t)) >= 3 {
|
|
||||||
out[t] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// wordTokenSet tokenises free text into words of length >= 4 (drops connectives).
|
|
||||||
func wordTokenSet(s string) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, w := range proposerWordSplit.Split(strings.ToLower(s), -1) {
|
|
||||||
if len([]rune(w)) >= 4 {
|
|
||||||
out[w] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func tokenJaccard(a, b map[string]bool) float64 {
|
|
||||||
if len(a) == 0 && len(b) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
inter := 0
|
|
||||||
for k := range a {
|
|
||||||
if b[k] {
|
|
||||||
inter++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
union := len(a) + len(b) - inter
|
|
||||||
if union == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return float64(inter) / float64(union)
|
|
||||||
}
|
|
||||||
|
|
||||||
func round2(x float64) float64 { return math.Round(x*100) / 100 }
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func mkPM(id, cat, zone, scenario string, prio int, measures, opstates []string) PatternMatch {
|
|
||||||
return PatternMatch{
|
|
||||||
PatternID: id, PatternName: id, Priority: prio,
|
|
||||||
HazardCats: []string{cat}, ZoneDE: zone, ScenarioDE: scenario,
|
|
||||||
SuggestedMeasureIDs: measures, OperationalStates: opstates,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindDedupCandidates_FindsOverlappingPair(t *testing.T) {
|
|
||||||
fired := []PatternMatch{
|
|
||||||
mkPM("HPa", "update_failure", "Steuerung, SPS", "Software-Update der Steuerung scheitert nach Abbruch", 80,
|
|
||||||
[]string{"M138", "M146"}, nil),
|
|
||||||
mkPM("HPb", "update_failure", "Steuerung, Antriebsregler", "Software-Update der Steuerung schlaegt fehl", 75,
|
|
||||||
[]string{"M138", "M146", "M141"}, nil),
|
|
||||||
mkPM("HPc", "mechanical_hazard", "Tuer", "Quetschen der Finger an der Tuer", 70,
|
|
||||||
[]string{"M003"}, nil),
|
|
||||||
}
|
|
||||||
got := FindDedupCandidates(fired, 0.4)
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("want 1 candidate, got %d: %+v", len(got), got)
|
|
||||||
}
|
|
||||||
// Higher-priority pattern survives, lower one is the drop target.
|
|
||||||
if got[0].KeepPattern != "HPa" || got[0].DropPattern != "HPb" {
|
|
||||||
t.Errorf("want keep HPa / drop HPb, got keep %s / drop %s", got[0].KeepPattern, got[0].DropPattern)
|
|
||||||
}
|
|
||||||
if got[0].DropName != "Software-Update der Steuerung schlaegt fehl" {
|
|
||||||
t.Errorf("DropName must equal drop pattern ScenarioDE, got %q", got[0].DropName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindDedupCandidates_LifecycleGuard(t *testing.T) {
|
|
||||||
// Same category, zone and measures — but normal-operation vs maintenance.
|
|
||||||
// These are legitimate variants (HP011 vs HP077) and must NOT be proposed.
|
|
||||||
fired := []PatternMatch{
|
|
||||||
mkPM("HP011", "electrical_hazard", "Schaltschrank, Klemmenkasten", "Person beruehrt spannungsfuehrende Teile", 95,
|
|
||||||
[]string{"M481", "M482"}, nil),
|
|
||||||
mkPM("HP077", "electrical_hazard", "Schaltschrank, Klemmenkasten", "Person beruehrt spannungsfuehrende Teile", 80,
|
|
||||||
[]string{"M481", "M482"}, []string{"maintenance"}),
|
|
||||||
}
|
|
||||||
if got := FindDedupCandidates(fired, 0.4); len(got) != 0 {
|
|
||||||
t.Fatalf("lifecycle guard failed: want 0 candidates, got %d: %+v", len(got), got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindDedupCandidates_DifferentCategoryIgnored(t *testing.T) {
|
|
||||||
fired := []PatternMatch{
|
|
||||||
mkPM("HPa", "thermal_hazard", "Boiler", "Heisse Oberflaeche am Boiler", 80, []string{"M071"}, nil),
|
|
||||||
mkPM("HPb", "mechanical_hazard", "Boiler", "Heisse Oberflaeche am Boiler", 80, []string{"M071"}, nil),
|
|
||||||
}
|
|
||||||
if got := FindDedupCandidates(fired, 0.3); len(got) != 0 {
|
|
||||||
t.Fatalf("cross-category pair must not be proposed, got %d", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindDedupCandidates_BelowThresholdDropped(t *testing.T) {
|
|
||||||
fired := []PatternMatch{
|
|
||||||
mkPM("HPa", "mechanical_hazard", "Tuer", "Quetschen an der Tuer", 80, []string{"M003"}, nil),
|
|
||||||
mkPM("HPb", "mechanical_hazard", "Foerderband", "Einzug am Foerderband", 80, []string{"M540"}, nil),
|
|
||||||
}
|
|
||||||
if got := FindDedupCandidates(fired, 0.4); len(got) != 0 {
|
|
||||||
t.Fatalf("disjoint pair must be below threshold, got %d: %+v", len(got), got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Foreign-framing proposer (P2 slice 4, type 2). DEV-TIME, propose-only.
|
|
||||||
//
|
|
||||||
// A pattern can fire for a machine yet describe its hazard with a zone text
|
|
||||||
// framed for a DIFFERENT machine (e.g. a dishwasher hazard whose zone names
|
|
||||||
// "Walzen, Transportbaender" or "Bearbeitungszone"). Such foreign framing leaks
|
|
||||||
// through terms that are NOT yet in domainGateTerms — once a term is a gate term,
|
|
||||||
// the ghost-pattern invariant already fences the pattern out. So we surface the
|
|
||||||
// candidates structurally: zone terms a fired pattern names that the machine's
|
|
||||||
// narrative never mentions (minus generic hazard-location vocabulary). A human
|
|
||||||
// (or the LLM) then decides: add a dom_* gate term, or re-frame the zone text.
|
|
||||||
//
|
|
||||||
// This OVER-surfaces by design — the human/LLM is the precision filter, not the
|
|
||||||
// detector (same contract as the dedup proposer).
|
|
||||||
|
|
||||||
// genericHazardStop are hazard-LOCATION words that legitimately appear in zones
|
|
||||||
// without being echoed in a narrative — they are not evidence of foreign framing.
|
|
||||||
var genericHazardStop = map[string]bool{
|
|
||||||
"quetschstelle": true, "einzugstelle": true, "einzugsstelle": true, "scherstelle": true,
|
|
||||||
"schneidstelle": true, "stossstelle": true, "fangstelle": true, "klemmstelle": true,
|
|
||||||
"gefahrbereich": true, "gefahrenbereich": true, "gefahrstelle": true, "gefahrenstelle": true,
|
|
||||||
"arbeitsbereich": true, "wirkbereich": true, "schutzbereich": true, "umgebung": true,
|
|
||||||
"bereich": true, "zugang": true, "oberflaeche": true, "oberflaechen": true,
|
|
||||||
"gehaeuse": true, "bauteil": true, "bauteile": true, "komponente": true, "maschine": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// FramingCandidate is a fired pattern whose zone text looks foreign for the machine.
|
|
||||||
type FramingCandidate struct {
|
|
||||||
Pattern string `json:"pattern"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
Zone string `json:"zone"`
|
|
||||||
OrphanTerms []string `json:"orphan_terms"`
|
|
||||||
OrphanFraction float64 `json:"orphan_fraction"`
|
|
||||||
Verdict string `json:"verdict"` // heuristic lean: foreign | plausible
|
|
||||||
Evidence string `json:"evidence"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindFramingCandidates returns fired patterns whose zone is mostly not echoed in
|
|
||||||
// the narrative, sorted by orphan fraction descending (deterministic).
|
|
||||||
func FindFramingCandidates(fired []PatternMatch, narrative string, minFraction float64) []FramingCandidate {
|
|
||||||
nar := strings.ToLower(narrative)
|
|
||||||
var narStems []string
|
|
||||||
for _, w := range proposerWordSplit.Split(nar, -1) {
|
|
||||||
if len([]rune(w)) >= 5 {
|
|
||||||
narStems = append(narStems, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var out []FramingCandidate
|
|
||||||
for _, pm := range fired {
|
|
||||||
parts := zoneParts(pm.ZoneDE)
|
|
||||||
if len(parts) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var orphans []string
|
|
||||||
for _, p := range parts {
|
|
||||||
if !partEchoed(p, nar, narStems) {
|
|
||||||
orphans = append(orphans, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frac := float64(len(orphans)) / float64(len(parts))
|
|
||||||
if len(orphans) == 0 || frac < minFraction {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, FramingCandidate{
|
|
||||||
Pattern: pm.PatternID, Name: pm.PatternName, Category: primaryCat(pm),
|
|
||||||
Zone: pm.ZoneDE, OrphanTerms: orphans, OrphanFraction: round2(frac),
|
|
||||||
Verdict: framingHeuristicVerdict(frac),
|
|
||||||
Evidence: fmt.Sprintf("%d/%d zone terms have no narrative echo: %s", len(orphans), len(parts), strings.Join(orphans, ", ")),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
|
||||||
if out[i].OrphanFraction != out[j].OrphanFraction {
|
|
||||||
return out[i].OrphanFraction > out[j].OrphanFraction
|
|
||||||
}
|
|
||||||
return out[i].Pattern < out[j].Pattern
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func framingHeuristicVerdict(frac float64) string {
|
|
||||||
if frac >= 0.99 {
|
|
||||||
return "foreign" // nothing in the zone is echoed by the narrative
|
|
||||||
}
|
|
||||||
return "plausible" // partial echo — likely generic vocabulary, human to confirm
|
|
||||||
}
|
|
||||||
|
|
||||||
// zoneParts splits a zone string into significant terms on commas, slashes,
|
|
||||||
// parentheses and semicolons, lowercased, length >= 4.
|
|
||||||
func zoneParts(zone string) []string {
|
|
||||||
fields := strings.FieldsFunc(strings.ToLower(zone), func(r rune) bool {
|
|
||||||
return r == ',' || r == '/' || r == ';' || r == '(' || r == ')'
|
|
||||||
})
|
|
||||||
var out []string
|
|
||||||
for _, f := range fields {
|
|
||||||
if t := strings.TrimSpace(f); len([]rune(t)) >= 4 {
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// partEchoed reports whether a zone part is reflected in the narrative. Matching
|
|
||||||
// is bidirectional to survive German compounding: a zone word echoes if it is a
|
|
||||||
// generic hazard term, if it is a substring of the narrative, OR if any narrative
|
|
||||||
// stem (>= 5 chars) is a substring of the zone word (so narrative "Steuerung"
|
|
||||||
// echoes zone "Steuerungssystem").
|
|
||||||
func partEchoed(part, narrative string, narStems []string) bool {
|
|
||||||
for _, w := range strings.Fields(part) {
|
|
||||||
if genericHazardStop[w] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len([]rune(w)) < 4 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(narrative, w) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, ns := range narStems {
|
|
||||||
if strings.Contains(w, ns) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderFramingQueue renders foreign-framing candidates as a markdown review queue.
|
|
||||||
func RenderFramingQueue(machine string, candidates []FramingCandidate) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintf(&b, "# Foreign-framing review queue — %s\n\n", machine)
|
|
||||||
fmt.Fprintf(&b, "%d fired pattern(s) name zone terms the narrative never mentions. Propose-only — a human (or the LLM) decides: add a dom_* gate term, or re-frame the zone.\n\n", len(candidates))
|
|
||||||
for i, c := range candidates {
|
|
||||||
fmt.Fprintf(&b, "## %d. %s — %s [%s, orphan %.0f%%]\n", i+1, c.Pattern, c.Name, c.Verdict, c.OrphanFraction*100)
|
|
||||||
fmt.Fprintf(&b, "- category: %s\n- zone: %s\n", c.Category, c.Zone)
|
|
||||||
fmt.Fprintf(&b, "- orphan terms (no narrative echo): %s\n", strings.Join(c.OrphanTerms, ", "))
|
|
||||||
fmt.Fprintf(&b, "- suggested action: %s\n\n", framingAction(c.Verdict))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func framingAction(verdict string) string {
|
|
||||||
if verdict == "foreign" {
|
|
||||||
return "likely foreign-framed — propose a dom_* gate term for the orphan term(s), or re-frame the zone; human confirms + commits + pins a GT case"
|
|
||||||
}
|
|
||||||
return "partial echo — likely generic vocabulary; human to confirm whether any orphan term is a foreign-machine component"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestFindFramingCandidates_FlagsForeignZone(t *testing.T) {
|
|
||||||
narrative := "Gewerbliche Geschirrspuelmaschine mit Boiler und Tank. Die Tuer ist verriegelt."
|
|
||||||
fired := []PatternMatch{
|
|
||||||
mkPM("HPforeign", "mechanical_hazard", "Walzen, Transportbaender, Bearbeitungszone", "Einzug", 80, nil, nil),
|
|
||||||
mkPM("HPlocal", "thermal_hazard", "Boiler, Tank, Tuer", "Verbrennung", 80, nil, nil),
|
|
||||||
mkPM("HPgeneric", "mechanical_hazard", "Quetschstelle, Gefahrbereich", "Quetschen", 80, nil, nil),
|
|
||||||
}
|
|
||||||
got := FindFramingCandidates(fired, narrative, 0.6)
|
|
||||||
if len(got) != 1 || got[0].Pattern != "HPforeign" {
|
|
||||||
t.Fatalf("want only HPforeign flagged, got %+v", got)
|
|
||||||
}
|
|
||||||
if got[0].Verdict != "foreign" {
|
|
||||||
t.Errorf("fully-orphan zone should be 'foreign', got %s", got[0].Verdict)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindFramingCandidates_PartialEchoIsPlausible(t *testing.T) {
|
|
||||||
narrative := "Maschine mit Boiler und Tank."
|
|
||||||
fired := []PatternMatch{
|
|
||||||
mkPM("HPx", "thermal_hazard", "Boiler, Tank, Auspuffleitung", "x", 80, nil, nil),
|
|
||||||
}
|
|
||||||
got := FindFramingCandidates(fired, narrative, 0.3)
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("want 1 candidate (1/3 orphan >= 0.3), got %d", len(got))
|
|
||||||
}
|
|
||||||
if got[0].Verdict != "plausible" || len(got[0].OrphanTerms) != 1 || got[0].OrphanTerms[0] != "auspuffleitung" {
|
|
||||||
t.Errorf("want plausible + orphan [auspuffleitung], got %s %v", got[0].Verdict, got[0].OrphanTerms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import "github.com/google/uuid"
|
|
||||||
|
|
||||||
// Non-test plumbing for the offline proposer (P2 slice 3): run the engine for a
|
|
||||||
// narrative and produce the fired patterns + the engine-built hazards/mitigations
|
|
||||||
// the dedup proposer and GT screen consume. This is the same pipeline the GT
|
|
||||||
// benchmark tests use, lifted out of test scope so the dev-time CLI can call it.
|
|
||||||
|
|
||||||
// universalLifecyclePhases are appended so patterns gated to a specific lifecycle
|
|
||||||
// (maintenance/cleaning/setup/fault clearing) still fire — the proposer wants the
|
|
||||||
// full hazard picture, not only normal-operation hazards.
|
|
||||||
var universalLifecyclePhases = []string{"normal_operation", "maintenance", "cleaning", "setup", "fault_clearing"}
|
|
||||||
|
|
||||||
// BuildProposerInput parses a narrative, runs the pattern engine, keeps the
|
|
||||||
// narrative-relevant patterns, and returns the hazards, mitigations and fired
|
|
||||||
// patterns. NOTE: it does not apply the CE cyber-category skip, so the proposer
|
|
||||||
// view may include cyber/AI hazards that the CE log excludes — harmless for the
|
|
||||||
// GT recall screen (they match no CE ground-truth entry).
|
|
||||||
func BuildProposerInput(narrative, machineType string, extraMachineTypes []string) ([]Hazard, []Mitigation, []PatternMatch) {
|
|
||||||
res := ParseNarrative(narrative, machineType)
|
|
||||||
|
|
||||||
var compIDs, compNames, energyIDs []string
|
|
||||||
for _, c := range res.Components {
|
|
||||||
if c.Negated {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
compIDs = append(compIDs, c.LibraryID)
|
|
||||||
compNames = append(compNames, c.NameDE)
|
|
||||||
}
|
|
||||||
for _, e := range res.EnergySources {
|
|
||||||
energyIDs = append(energyIDs, e.SourceID)
|
|
||||||
}
|
|
||||||
|
|
||||||
machineTypes := append([]string{}, extraMachineTypes...)
|
|
||||||
if machineType != "" {
|
|
||||||
machineTypes = append(machineTypes, machineType)
|
|
||||||
}
|
|
||||||
lifecycles := append(append([]string{}, res.LifecyclePhases...), universalLifecyclePhases...)
|
|
||||||
|
|
||||||
out := NewPatternEngine().Match(MatchInput{
|
|
||||||
ComponentLibraryIDs: compIDs,
|
|
||||||
EnergySourceIDs: energyIDs,
|
|
||||||
LifecyclePhases: lifecycles,
|
|
||||||
CustomTags: res.CustomTags,
|
|
||||||
OperationalStates: res.OperationalStates,
|
|
||||||
StateTransitions: res.StateTransitions,
|
|
||||||
HumanRoles: res.Roles,
|
|
||||||
MachineTypes: machineTypes,
|
|
||||||
})
|
|
||||||
|
|
||||||
kept := make([]PatternMatch, 0, len(out.MatchedPatterns))
|
|
||||||
for _, pm := range out.MatchedPatterns {
|
|
||||||
if IsPatternRelevant(pm, narrative, compNames) {
|
|
||||||
kept = append(kept, pm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filtered := *out
|
|
||||||
filtered.MatchedPatterns = kept
|
|
||||||
hazards, mits := patternsToHazardsAndMitigations(&filtered)
|
|
||||||
return hazards, mits, kept
|
|
||||||
}
|
|
||||||
|
|
||||||
// patternsToHazardsAndMitigations converts engine output into the hazard/mitigation
|
|
||||||
// entities the benchmark + proposer compare on. Simplified vs InitializeProject
|
|
||||||
// (no risk estimation, no norm refs) — it only needs category/zone/scenario/measures.
|
|
||||||
func patternsToHazardsAndMitigations(out *MatchOutput) ([]Hazard, []Mitigation) {
|
|
||||||
hazards := make([]Hazard, 0, len(out.MatchedPatterns))
|
|
||||||
patternToHazard := make(map[string]uuid.UUID, len(out.MatchedPatterns))
|
|
||||||
|
|
||||||
for _, pm := range out.MatchedPatterns {
|
|
||||||
cat := ""
|
|
||||||
if len(pm.HazardCats) > 0 {
|
|
||||||
cat = pm.HazardCats[0]
|
|
||||||
}
|
|
||||||
lifecycle := ""
|
|
||||||
if len(pm.ApplicableLifecycles) > 0 {
|
|
||||||
lifecycle = pm.ApplicableLifecycles[0]
|
|
||||||
}
|
|
||||||
h := Hazard{
|
|
||||||
ID: uuid.New(),
|
|
||||||
Name: pm.ScenarioDE,
|
|
||||||
Category: cat,
|
|
||||||
Description: pm.ScenarioDE,
|
|
||||||
Scenario: pm.ScenarioDE,
|
|
||||||
TriggerEvent: pm.TriggerDE,
|
|
||||||
PossibleHarm: pm.HarmDE,
|
|
||||||
AffectedPerson: pm.AffectedDE,
|
|
||||||
HazardousZone: pm.ZoneDE,
|
|
||||||
LifecyclePhase: lifecycle,
|
|
||||||
}
|
|
||||||
if h.Name == "" {
|
|
||||||
h.Name = pm.PatternName
|
|
||||||
}
|
|
||||||
hazards = append(hazards, h)
|
|
||||||
patternToHazard[pm.PatternID] = h.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
measureNames := make(map[string]string)
|
|
||||||
for _, m := range GetProtectiveMeasureLibrary() {
|
|
||||||
measureNames[m.ID] = m.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
var mitigations []Mitigation
|
|
||||||
for _, sm := range out.SuggestedMeasures {
|
|
||||||
name := measureNames[sm.MeasureID]
|
|
||||||
if name == "" {
|
|
||||||
name = sm.MeasureID
|
|
||||||
}
|
|
||||||
for _, srcPattern := range sm.SourcePatterns {
|
|
||||||
hid, ok := patternToHazard[srcPattern]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mitigations = append(mitigations, Mitigation{
|
|
||||||
ID: uuid.New(),
|
|
||||||
HazardID: hid,
|
|
||||||
Name: name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hazards, mitigations
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestBuildProposerInput_WarewashingFires(t *testing.T) {
|
|
||||||
hazards, _, fired := BuildProposerInput(
|
|
||||||
warewashingNarrative,
|
|
||||||
"Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)",
|
|
||||||
[]string{"food_processing"},
|
|
||||||
)
|
|
||||||
if len(fired) == 0 || len(hazards) == 0 {
|
|
||||||
t.Fatalf("want fired patterns + hazards, got %d patterns / %d hazards", len(fired), len(hazards))
|
|
||||||
}
|
|
||||||
has := func(id string) bool {
|
|
||||||
for _, pm := range fired {
|
|
||||||
if pm.PatternID == id {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !has("HP2201") {
|
|
||||||
t.Errorf("warewashing-specific HP2201 must fire via BuildProposerInput")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/breakpilot/ai-compliance-sdk/internal/llm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Semantic judgement over RECALL-SAFE dedup candidates (P2 slice 2). DEV-TIME,
|
|
||||||
// propose-only. The deterministic GT wall (proposer_screen.go) has already
|
|
||||||
// removed candidates that would drop recall or that credit different GT entries;
|
|
||||||
// the judge only adds an opinion on whether the survivors are truly the same
|
|
||||||
// hazard, plus a rationale, for the human review queue. It NEVER mutates anything.
|
|
||||||
//
|
|
||||||
// The judge is pluggable behind CandidateJudge so the runtime/tests stay
|
|
||||||
// deterministic (HeuristicJudge) while the dev-time CLI can plug in the
|
|
||||||
// non-deterministic LLM (LLMJudge over the shared llm.ProviderRegistry).
|
|
||||||
|
|
||||||
const (
|
|
||||||
VerdictDuplicate = "duplicate"
|
|
||||||
VerdictDistinct = "distinct"
|
|
||||||
VerdictUncertain = "uncertain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JudgedProposal is one candidate with its GT-wall result and the judge's opinion.
|
|
||||||
type JudgedProposal struct {
|
|
||||||
Candidate DedupCandidate `json:"candidate"`
|
|
||||||
Screen ScreenResult `json:"screen"`
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Confidence string `json:"confidence"`
|
|
||||||
Rationale string `json:"rationale"`
|
|
||||||
Judge string `json:"judge"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CandidateJudge decides whether two near-duplicate patterns are the same hazard.
|
|
||||||
type CandidateJudge interface {
|
|
||||||
Name() string
|
|
||||||
Judge(ctx context.Context, c DedupCandidate, a, b PatternMatch) (verdict, confidence, rationale string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HeuristicJudge is the deterministic default/fallback. It only ever returns "low"
|
|
||||||
// confidence — it is a placeholder for the LLM, and it deliberately punts to
|
|
||||||
// "uncertain" on the hard cases (low text overlap, shared measures) so the queue
|
|
||||||
// makes clear exactly where the LLM earns its keep.
|
|
||||||
type HeuristicJudge struct{}
|
|
||||||
|
|
||||||
func (HeuristicJudge) Name() string { return "heuristic" }
|
|
||||||
|
|
||||||
func (HeuristicJudge) Judge(_ context.Context, c DedupCandidate, _, _ PatternMatch) (string, string, string) {
|
|
||||||
switch {
|
|
||||||
case c.ScenarioJaccard >= 0.5 || (c.ZoneJaccard >= 0.5 && c.MeasureJaccard >= 0.5):
|
|
||||||
return VerdictDuplicate, "low", "structural: high scenario, or combined zone+measure, overlap"
|
|
||||||
case c.MeasureJaccard >= 0.99 && c.ZoneJaccard == 0 && c.ScenarioJaccard < 0.3:
|
|
||||||
return VerdictDistinct, "low", "structural: identical measures but no zone/scenario overlap — likely distinct hazards sharing generic measures"
|
|
||||||
default:
|
|
||||||
return VerdictUncertain, "low", "structural signal inconclusive — needs the LLM judge"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLMJudge asks an offline model to make the semantic call. Non-deterministic, so
|
|
||||||
// it lives only in the dev-time tool, never in tests or the runtime. It degrades
|
|
||||||
// to "uncertain" on any transport or parse error — it must never break the run.
|
|
||||||
type LLMJudge struct {
|
|
||||||
Completer LLMCompleter
|
|
||||||
MachineClass string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (LLMJudge) Name() string { return "llm" }
|
|
||||||
|
|
||||||
func (j LLMJudge) Judge(ctx context.Context, c DedupCandidate, a, b PatternMatch) (string, string, string) {
|
|
||||||
system, user := BuildJudgePrompt(j.MachineClass, a, b)
|
|
||||||
raw, err := j.Completer.Complete(ctx, system, user)
|
|
||||||
if err != nil {
|
|
||||||
return VerdictUncertain, "low", "LLM error: " + err.Error()
|
|
||||||
}
|
|
||||||
return parseJudgeJSON(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildJudgePrompt is the real LLM artifact — built and unit-tested deterministically
|
|
||||||
// even though the call itself is not. It frames the ISO 12100 same-vs-distinct
|
|
||||||
// question and forces a JSON answer.
|
|
||||||
func BuildJudgePrompt(machineClass string, a, b PatternMatch) (system, user string) {
|
|
||||||
system = "Du bist Sachverstaendiger fuer Maschinensicherheit nach EN ISO 12100. " +
|
|
||||||
"Entscheide, ob zwei generierte Gefaehrdungen fuer DIESE Maschine DIESELBE Gefaehrdung " +
|
|
||||||
"beschreiben (Dublette) oder fachlich VERSCHIEDENE Gefaehrdungen sind, die nur zufaellig " +
|
|
||||||
"dieselben Schutzmassnahmen teilen. Verschieden, wenn Wirkort, Ausloeser oder " +
|
|
||||||
"Schadensmechanismus abweichen — auch bei gleicher Kategorie und gleichen Massnahmen. " +
|
|
||||||
"Antworte AUSSCHLIESSLICH als JSON: " +
|
|
||||||
`{"verdict":"duplicate|distinct|uncertain","confidence":"high|medium|low","rationale":"..."}.`
|
|
||||||
user = fmt.Sprintf(`Maschinenklasse: %s
|
|
||||||
|
|
||||||
Gefaehrdung A (%s):
|
|
||||||
Name: %s
|
|
||||||
Kategorie: %s
|
|
||||||
Zone: %s
|
|
||||||
Szenario: %s
|
|
||||||
Ausloeser: %s
|
|
||||||
Schaden: %s
|
|
||||||
Massnahmen: %s
|
|
||||||
|
|
||||||
Gefaehrdung B (%s):
|
|
||||||
Name: %s
|
|
||||||
Kategorie: %s
|
|
||||||
Zone: %s
|
|
||||||
Szenario: %s
|
|
||||||
Ausloeser: %s
|
|
||||||
Schaden: %s
|
|
||||||
Massnahmen: %s
|
|
||||||
|
|
||||||
Sind A und B dieselbe Gefaehrdung fuer diese Maschine?`,
|
|
||||||
machineClass,
|
|
||||||
a.PatternID, a.PatternName, primaryCat(a), a.ZoneDE, a.ScenarioDE, a.TriggerDE, a.HarmDE, strings.Join(a.SuggestedMeasureIDs, ", "),
|
|
||||||
b.PatternID, b.PatternName, primaryCat(b), b.ZoneDE, b.ScenarioDE, b.TriggerDE, b.HarmDE, strings.Join(b.SuggestedMeasureIDs, ", "))
|
|
||||||
return system, user
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJudgeJSON(raw string) (verdict, confidence, rationale string) {
|
|
||||||
start, end := strings.Index(raw, "{"), strings.LastIndex(raw, "}")
|
|
||||||
if start < 0 || end <= start {
|
|
||||||
return VerdictUncertain, "low", "unparseable LLM output"
|
|
||||||
}
|
|
||||||
var v struct {
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Confidence string `json:"confidence"`
|
|
||||||
Rationale string `json:"rationale"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &v); err != nil {
|
|
||||||
return VerdictUncertain, "low", "unparseable LLM JSON: " + err.Error()
|
|
||||||
}
|
|
||||||
switch v.Verdict {
|
|
||||||
case VerdictDuplicate, VerdictDistinct, VerdictUncertain:
|
|
||||||
default:
|
|
||||||
v.Verdict = VerdictUncertain
|
|
||||||
}
|
|
||||||
if v.Confidence == "" {
|
|
||||||
v.Confidence = "low"
|
|
||||||
}
|
|
||||||
return v.Verdict, v.Confidence, v.Rationale
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLMCompleter is the minimal text-in/text-out the LLM judge needs. Tests pass a
|
|
||||||
// stub; the dev-time tool passes a registry-backed adapter (NewRegistryCompleter).
|
|
||||||
type LLMCompleter interface {
|
|
||||||
Complete(ctx context.Context, system, user string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type registryCompleter struct {
|
|
||||||
reg *llm.ProviderRegistry
|
|
||||||
model string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRegistryCompleter adapts the shared llm.ProviderRegistry to LLMCompleter so
|
|
||||||
// the proposer can reuse the platform's offline model wiring (e.g. self-hosted qwen).
|
|
||||||
func NewRegistryCompleter(reg *llm.ProviderRegistry, model string) LLMCompleter {
|
|
||||||
return ®istryCompleter{reg: reg, model: model}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rc *registryCompleter) Complete(ctx context.Context, system, user string) (string, error) {
|
|
||||||
resp, err := rc.reg.Chat(ctx, &llm.ChatRequest{
|
|
||||||
Model: rc.model,
|
|
||||||
Messages: []llm.Message{
|
|
||||||
{Role: "system", Content: system},
|
|
||||||
{Role: "user", Content: user},
|
|
||||||
},
|
|
||||||
Temperature: 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return resp.Message.Content, nil
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHeuristicJudge_Verdicts(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
zone, meas float64
|
|
||||||
scenario float64
|
|
||||||
wantVerdict string
|
|
||||||
}{
|
|
||||||
{"high scenario overlap -> duplicate", 0, 0.3, 0.6, VerdictDuplicate},
|
|
||||||
{"high zone+measure -> duplicate", 0.6, 0.6, 0.1, VerdictDuplicate},
|
|
||||||
{"identical measures, no text -> distinct", 0, 1.0, 0.0, VerdictDistinct},
|
|
||||||
{"shared measures, low text -> uncertain", 0, 0.67, 0.19, VerdictUncertain},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
c := DedupCandidate{ZoneJaccard: tt.zone, MeasureJaccard: tt.meas, ScenarioJaccard: tt.scenario}
|
|
||||||
v, conf, _ := HeuristicJudge{}.Judge(context.Background(), c, PatternMatch{}, PatternMatch{})
|
|
||||||
if v != tt.wantVerdict {
|
|
||||||
t.Errorf("verdict: want %s, got %s", tt.wantVerdict, v)
|
|
||||||
}
|
|
||||||
if conf != "low" {
|
|
||||||
t.Errorf("heuristic confidence must be low, got %s", conf)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildJudgePrompt_ContainsKeyFacts(t *testing.T) {
|
|
||||||
a := PatternMatch{PatternID: "HPa", PatternName: "Heisse Flaeche", HazardCats: []string{"thermal_hazard"},
|
|
||||||
ZoneDE: "Boiler", ScenarioDE: "Beruehrung heisser Boiler", SuggestedMeasureIDs: []string{"M071"}}
|
|
||||||
b := PatternMatch{PatternID: "HPb", PatternName: "Heisses Spuelgut", HazardCats: []string{"thermal_hazard"},
|
|
||||||
ZoneDE: "Spuelgut", ScenarioDE: "Beruehrung heisses Geschirr", SuggestedMeasureIDs: []string{"M071"}}
|
|
||||||
system, user := BuildJudgePrompt("Geschirrspuelmaschine", a, b)
|
|
||||||
|
|
||||||
for _, want := range []string{"EN ISO 12100", "JSON", "verdict"} {
|
|
||||||
if !strings.Contains(system, want) {
|
|
||||||
t.Errorf("system prompt missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, want := range []string{"Geschirrspuelmaschine", "HPa", "HPb", "Boiler", "Spuelgut", "thermal_hazard"} {
|
|
||||||
if !strings.Contains(user, want) {
|
|
||||||
t.Errorf("user prompt missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeCompleter struct {
|
|
||||||
out string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f fakeCompleter) Complete(_ context.Context, _, _ string) (string, error) { return f.out, f.err }
|
|
||||||
|
|
||||||
func TestLLMJudge_ParsesAndDegrades(t *testing.T) {
|
|
||||||
cand := DedupCandidate{KeepPattern: "HPa", DropPattern: "HPb"}
|
|
||||||
|
|
||||||
// Well-formed JSON, even wrapped in chatter, parses.
|
|
||||||
j := LLMJudge{Completer: fakeCompleter{out: "Sicher. {\"verdict\":\"distinct\",\"confidence\":\"high\",\"rationale\":\"andere Wirkorte\"}"}, MachineClass: "x"}
|
|
||||||
if v, conf, r := j.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictDistinct || conf != "high" || r != "andere Wirkorte" {
|
|
||||||
t.Errorf("parse: got %s/%s/%q", v, conf, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown verdict value normalises to uncertain.
|
|
||||||
j2 := LLMJudge{Completer: fakeCompleter{out: `{"verdict":"maybe","confidence":"medium","rationale":"x"}`}}
|
|
||||||
if v, _, _ := j2.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictUncertain {
|
|
||||||
t.Errorf("unknown verdict must normalise to uncertain, got %s", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transport error degrades gracefully, never panics.
|
|
||||||
j3 := LLMJudge{Completer: fakeCompleter{err: errors.New("connection refused")}}
|
|
||||||
if v, _, r := j3.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictUncertain || !strings.Contains(r, "LLM error") {
|
|
||||||
t.Errorf("error path: got %s / %q", v, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Garbage (no JSON) degrades to uncertain.
|
|
||||||
j4 := LLMJudge{Completer: fakeCompleter{out: "no json here"}}
|
|
||||||
if v, _, _ := j4.Judge(context.Background(), cand, PatternMatch{}, PatternMatch{}); v != VerdictUncertain {
|
|
||||||
t.Errorf("garbage must degrade to uncertain, got %s", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRenderProposalQueue_ShowsActions(t *testing.T) {
|
|
||||||
proposals := []JudgedProposal{
|
|
||||||
{
|
|
||||||
Candidate: DedupCandidate{KeepPattern: "HP807", DropPattern: "HP033", Category: "update_failure", Score: 0.32},
|
|
||||||
Screen: ScreenResult{RecallBefore: 1, RecallAfter: 1},
|
|
||||||
Verdict: VerdictDuplicate, Confidence: "medium", Rationale: "same update failure", Judge: "llm",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
out := RenderProposalQueue("Geschirrspuelmaschine", proposals)
|
|
||||||
for _, want := range []string{"HP807", "HP033", "update_failure", "supersession", "Propose-only"} {
|
|
||||||
if !strings.Contains(out, want) {
|
|
||||||
t.Errorf("queue missing %q\n%s", want, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RenderProposalQueue turns judged dedup proposals into the human-review queue
|
|
||||||
// (markdown). Deterministic. Nothing here applies a change — every entry is a
|
|
||||||
// suggestion for a human to confirm, edit, commit, and pin with a GT case.
|
|
||||||
func RenderProposalQueue(machine string, proposals []JudgedProposal) string {
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintf(&b, "# Dedup proposal queue — %s\n\n", machine)
|
|
||||||
fmt.Fprintf(&b, "%d candidate(s) survived the deterministic GT wall. Propose-only — nothing is applied automatically.\n\n", len(proposals))
|
|
||||||
|
|
||||||
for i, p := range proposals {
|
|
||||||
c := p.Candidate
|
|
||||||
fmt.Fprintf(&b, "## %d. keep %s ⊃ drop %s [%s → %s (%s)]\n",
|
|
||||||
i+1, c.KeepPattern, c.DropPattern, p.Judge, p.Verdict, p.Confidence)
|
|
||||||
fmt.Fprintf(&b, "- category %s · score %.2f (measures %.0f%%, zone %.0f%%, scenario %.0f%%)\n",
|
|
||||||
c.Category, c.Score, c.MeasureJaccard*100, c.ZoneJaccard*100, c.ScenarioJaccard*100)
|
|
||||||
fmt.Fprintf(&b, "- GT recall %.1f%% → %.1f%% when %s is dropped (wall: %s)\n",
|
|
||||||
p.Screen.RecallBefore*100, p.Screen.RecallAfter*100, c.DropPattern, wallNote(p.Screen))
|
|
||||||
fmt.Fprintf(&b, "- keep: %s\n- drop: %s\n", c.KeepHazardName, c.DropName)
|
|
||||||
fmt.Fprintf(&b, "- judge rationale: %s\n", p.Rationale)
|
|
||||||
fmt.Fprintf(&b, "- suggested action: %s\n\n", suggestedAction(p))
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func wallNote(s ScreenResult) string {
|
|
||||||
if s.DistinctGT {
|
|
||||||
return fmt.Sprintf("distinct GT %s vs %s", s.KeepGT, s.DropGT)
|
|
||||||
}
|
|
||||||
return "recall-safe"
|
|
||||||
}
|
|
||||||
|
|
||||||
func suggestedAction(p JudgedProposal) string {
|
|
||||||
switch p.Verdict {
|
|
||||||
case VerdictDuplicate:
|
|
||||||
return fmt.Sprintf("add %s to a supersession set, then a human confirms + commits + pins a GT case", p.Candidate.DropPattern)
|
|
||||||
case VerdictDistinct:
|
|
||||||
return "keep both — judge considers them distinct hazards"
|
|
||||||
default:
|
|
||||||
return "needs human (or higher-confidence LLM) review — no automatic action"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package iace
|
|
||||||
|
|
||||||
import "github.com/google/uuid"
|
|
||||||
|
|
||||||
// ScreenResult is the deterministic GT verdict for one proposed supersession.
|
|
||||||
type ScreenResult struct {
|
|
||||||
RecallBefore float64 `json:"recall_before"`
|
|
||||||
RecallAfter float64 `json:"recall_after"`
|
|
||||||
KeepGT string `json:"keep_gt,omitempty"` // GT entry the keeper credits (if any)
|
|
||||||
DropGT string `json:"drop_gt,omitempty"` // GT entry the drop credits (if any)
|
|
||||||
DistinctGT bool `json:"distinct_gt"` // keep & drop credit DIFFERENT GT entries -> distinct hazards
|
|
||||||
Safe bool `json:"safe"` // recall preserved AND not distinct
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScreenSupersession is the WALL between "propose" and "decide". A proposal is
|
|
||||||
// safe only if BOTH deterministic checks pass:
|
|
||||||
//
|
|
||||||
// 1. RECALL is not reduced when the drop-hazard (and its mitigations) is removed
|
|
||||||
// — otherwise the drop is load-bearing for GT coverage.
|
|
||||||
// 2. The two hazards do NOT credit DIFFERENT ground-truth entries. Recall alone
|
|
||||||
// is necessary but not sufficient: two genuinely distinct hazards that share
|
|
||||||
// the same measures (e.g. hot boiler surface vs hot ware on unloading) keep
|
|
||||||
// recall at 100% when one is dropped, yet must NOT be merged. If keep and
|
|
||||||
// drop each match a different GT entry, they are distinct.
|
|
||||||
//
|
|
||||||
// Whatever survives both is still only RECALL-SAFE — a candidate for a human (and
|
|
||||||
// in slice 2, an LLM) to confirm semantically. Deterministic; reuses
|
|
||||||
// CompareBenchmark; touches neither the library nor the runtime.
|
|
||||||
func ScreenSupersession(gt *GroundTruth, hazards []Hazard, mits []Mitigation, keepHazardName, dropHazardName string) ScreenResult {
|
|
||||||
before := CompareBenchmark(gt, hazards, mits)
|
|
||||||
|
|
||||||
gtOf := map[string]string{}
|
|
||||||
for _, p := range before.MatchedPairs {
|
|
||||||
gtOf[p.EngineHazard.Name] = p.GTEntry.Nr
|
|
||||||
}
|
|
||||||
keepGT, dropGT := gtOf[keepHazardName], gtOf[dropHazardName]
|
|
||||||
distinct := keepGT != "" && dropGT != "" && keepGT != dropGT
|
|
||||||
|
|
||||||
kept := make([]Hazard, 0, len(hazards))
|
|
||||||
dropped := map[uuid.UUID]bool{}
|
|
||||||
for _, h := range hazards {
|
|
||||||
if h.Name == dropHazardName {
|
|
||||||
dropped[h.ID] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, h)
|
|
||||||
}
|
|
||||||
keptMits := make([]Mitigation, 0, len(mits))
|
|
||||||
for _, m := range mits {
|
|
||||||
if !dropped[m.HazardID] {
|
|
||||||
keptMits = append(keptMits, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
after := CompareBenchmark(gt, kept, keptMits)
|
|
||||||
|
|
||||||
return ScreenResult{
|
|
||||||
RecallBefore: before.CoverageScore, RecallAfter: after.CoverageScore,
|
|
||||||
KeepGT: keepGT, DropGT: dropGT, DistinctGT: distinct,
|
|
||||||
Safe: after.CoverageScore >= before.CoverageScore && !distinct,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -160,7 +160,6 @@ func (s *Store) ListHazards(ctx context.Context, projectID uuid.UUID) ([]Hazard,
|
|||||||
hazards = append(hazards, h)
|
hazards = append(hazards, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
SortHazardsByISO12100(hazards)
|
|
||||||
return hazards, nil
|
return hazards, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
{
|
|
||||||
"machine_name": "Gewerbliche Untertisch-Geschirrspuelmaschine (Winterhalter UC-M)",
|
|
||||||
"machine_description": "Untertisch-Gewerbespuelmaschine, vernetzt (Connected Wash), Heisswasser-Boiler, Spuelpumpe mit rotierenden Spuelfeldern, Tuer mit Sicherheitsschalter, Reiniger-/Klarspueler-Dosierung.",
|
|
||||||
"source": "Selbstbewertung GT #3 (Fachmann-Erwartung, EN 60335-2-58 + EN ISO 12100)",
|
|
||||||
"version": "1.0",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"nr": "1.1",
|
|
||||||
"hazard_group": "Thermische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Verbrühung durch Heißwasser und Dampf",
|
|
||||||
"hazard_cause": "Beim Öffnen der Tür während oder kurz nach dem Spülgang tritt heißes Wasser und Wrasen (Dampf) aus der Spülkammer aus und trifft Gesicht, Hände und Arme",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Reinigung"],
|
|
||||||
"component_zone": "Tür und Beschickungsöffnung der Spülkammer",
|
|
||||||
"risk_in": {"f": 4, "w": 3, "p": 2, "s": 3, "r": 27},
|
|
||||||
"measures": ["Türverriegelung beendet Spülgang vor dem Öffnen", "Wrasen-/Dampfreduzierung", "Warnhinweis heißer Dampf"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 2, "w": 1, "p": 1, "s": 2, "r": 8},
|
|
||||||
"norm_references": ["EN 60335-2-58"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "1.2",
|
|
||||||
"hazard_group": "Thermische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Verbrennung an heißen Oberflächen",
|
|
||||||
"hazard_cause": "Berührung heißer Oberflächen von Boiler, Tankheizkörper oder Spülkammerwänden bei Reinigung, Entkalkung oder Wartung",
|
|
||||||
"lifecycle_phases": ["Reinigung", "Instandhaltung"],
|
|
||||||
"component_zone": "Boiler, Tankheizkörper, Spülkammerwände",
|
|
||||||
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 2, "r": 14},
|
|
||||||
"measures": ["Temperaturbegrenzung zugänglicher Oberflächen", "Warnhinweis heiße Oberfläche"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
|
|
||||||
"norm_references": ["EN ISO 13732-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "1.3",
|
|
||||||
"hazard_group": "Thermische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Verbrennung an heißem Spülgut",
|
|
||||||
"hazard_cause": "Geschirr und Gläser sind nach der Heißwasser-Nachspülung sehr heiß, beim Entladen kommt es zu Verbrennungen an den Händen",
|
|
||||||
"lifecycle_phases": ["Betrieb"],
|
|
||||||
"component_zone": "Spülkammer, Entnahmebereich, Korb",
|
|
||||||
"risk_in": {"f": 3, "w": 3, "p": 2, "s": 2, "r": 16},
|
|
||||||
"measures": ["Abkühl-/Trocknungszeit", "Warnhinweis heißes Spülgut"],
|
|
||||||
"measure_type": "BI",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
|
|
||||||
"norm_references": ["EN 60335-2-58"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "2.1",
|
|
||||||
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Verätzung von Haut und Augen durch Reiniger-/Klarspüler-Konzentrat",
|
|
||||||
"hazard_cause": "Direkter Kontakt mit dem ätzenden Reiniger- bzw. Klarspüler-Konzentrat beim Nachfüllen, Sauglanzenwechsel oder bei Leckage des Dosiergeräts",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
|
|
||||||
"component_zone": "Dosiergerät, Reiniger- und Klarspüler-Gebinde, Sauglanzen",
|
|
||||||
"risk_in": {"f": 3, "w": 3, "p": 2, "s": 3, "r": 24},
|
|
||||||
"measures": ["Geschlossenes Dosiersystem mit Sauglanzen", "PSA Augen-/Hautschutz", "GHS-Kennzeichnung und Sicherheitsdatenblatt"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["Verordnung (EG) Nr. 1272/2008", "TRGS 500"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "2.2",
|
|
||||||
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Reizung der Atemwege durch Reinigungs-Aerosole und Dämpfe",
|
|
||||||
"hazard_cause": "Einatmen von Aerosolen und Dämpfen der Reinigungschemie beim Öffnen kurz nach dem Spülgang oder bei der Entkalkung mit Säure",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
|
|
||||||
"component_zone": "Atemzone vor der Spülkammer, Aufstellbereich",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 2, "r": 12},
|
|
||||||
"measures": ["Be-/Entlüftung", "geschlossene Haube", "Warnung vor Vermischen von Reiniger und Säure"],
|
|
||||||
"measure_type": "BI",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
|
|
||||||
"norm_references": ["TRGS 500"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "3.1",
|
|
||||||
"hazard_group": "Elektrische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Elektrischer Schlag in Nassumgebung",
|
|
||||||
"hazard_cause": "Berührung spannungsführender Teile bei unzureichendem IP-Schutz, defekten Kabeldurchführungen oder Feuchtigkeit im Steuerungsgehäuse",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Reinigung", "Instandhaltung"],
|
|
||||||
"component_zone": "Steuerungsgehäuse, Kabelübergänge, Antriebsgehäuse",
|
|
||||||
"risk_in": {"f": 3, "w": 2, "p": 3, "s": 4, "r": 32},
|
|
||||||
"measures": ["IP-Schutz gegen eindringendes Wasser", "Fehlerstrom-Schutzeinrichtung (RCD)"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 4, "r": 12},
|
|
||||||
"norm_references": ["IEC 60335-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "3.2",
|
|
||||||
"hazard_group": "Elektrische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Kurzschluss und Brand bei Reinigung am Schaltschrank",
|
|
||||||
"hazard_cause": "Reinigung ohne vorherige Freischaltung oder mit Hochdruckreiniger am elektrisch aktiven Schaltschrank führt zu Kurzschluss und Brand",
|
|
||||||
"lifecycle_phases": ["Reinigung", "Instandhaltung"],
|
|
||||||
"component_zone": "Schaltschrank, elektrisch aktive Komponenten",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
|
|
||||||
"measures": ["Netztrenneinrichtung", "Warnhinweis Reinigung nur spannungsfrei, kein Hochdruckreiniger"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["IEC 60204-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "3.3",
|
|
||||||
"hazard_group": "Elektrische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Motorüberlast mit Überhitzung",
|
|
||||||
"hazard_cause": "Blockierter oder überlasteter Pumpenmotor überhitzt, Wicklungsbrand und Rauchentwicklung",
|
|
||||||
"lifecycle_phases": ["Betrieb"],
|
|
||||||
"component_zone": "Motorgehäuse, Umgebung",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 2, "r": 12},
|
|
||||||
"measures": ["Überstromschutz", "Motorschutzschalter"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
|
|
||||||
"norm_references": ["IEC 60204-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "4.1",
|
|
||||||
"hazard_group": "Mechanische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Ausrutschen auf nassem Boden",
|
|
||||||
"hazard_cause": "Aus der Spülmaschine austretendes Wasser durch Leckage oder beim Öffnen macht den Boden im Aufstellbereich rutschig, Person rutscht aus und stürzt",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Reinigung", "Instandhaltung"],
|
|
||||||
"component_zone": "Aufstell- und Bedienbereich der Spülmaschine",
|
|
||||||
"risk_in": {"f": 3, "w": 3, "p": 2, "s": 2, "r": 16},
|
|
||||||
"measures": ["Rutschhemmender Bodenbelag", "Bodenablauf bzw. Leckagewanne"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 6},
|
|
||||||
"norm_references": ["ASR A1.5/1,2"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "4.2",
|
|
||||||
"hazard_group": "Mechanische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Quetschen der Finger an der Tür/Haube",
|
|
||||||
"hazard_cause": "Beim Schließen der Tür bzw. Absenken der Haube werden Finger zwischen Tür/Haube und Gehäuse gequetscht",
|
|
||||||
"lifecycle_phases": ["Betrieb"],
|
|
||||||
"component_zone": "Tür- und Haubenkante, Schließbereich",
|
|
||||||
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 1, "r": 7},
|
|
||||||
"measures": ["Geringe Schließkraft, Einklemmschutz", "Abgerundete Türkanten"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
|
|
||||||
"norm_references": ["EN ISO 12100"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "4.3",
|
|
||||||
"hazard_group": "Mechanische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Kontakt mit rotierendem Spülarm bei geöffneter Tür",
|
|
||||||
"hazard_cause": "Eingreifen in die Spülkammer bei noch nachlaufendem rotierendem Spülarm/Spülfeld nach dem Öffnen der Tür",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Reinigung"],
|
|
||||||
"component_zone": "Spülkammer, Spülarm und Spülfeld",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 1, "r": 6},
|
|
||||||
"measures": ["Türverriegelung stoppt Spülarm beim Öffnen"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
|
|
||||||
"norm_references": ["EN ISO 12100"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "5.1",
|
|
||||||
"hazard_group": "Ergonomische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Belastung des Bewegungsapparats durch wiederholte Be- und Entladung",
|
|
||||||
"hazard_cause": "Wiederholtes Heben und Bücken beim manuellen Be- und Entladen der Spülkörbe am Untertischgerät",
|
|
||||||
"lifecycle_phases": ["Betrieb"],
|
|
||||||
"component_zone": "Be- und Entladestelle, Spülkorb",
|
|
||||||
"risk_in": {"f": 4, "w": 3, "p": 2, "s": 1, "r": 9},
|
|
||||||
"measures": ["Ergonomische Arbeitshöhe", "Be-/Entladung auf günstiger Greifhöhe"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 2, "w": 1, "p": 1, "s": 1, "r": 4},
|
|
||||||
"norm_references": ["EN 1005-2"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "5.2",
|
|
||||||
"hazard_group": "Ergonomische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Zwangshaltung durch ungünstige Bedienelement-Position",
|
|
||||||
"hazard_cause": "Bedienelemente am HMI außerhalb der ergonomisch günstigen Reichweite führen bei dauerhafter Bedienung zu Zwangshaltung",
|
|
||||||
"lifecycle_phases": ["Betrieb"],
|
|
||||||
"component_zone": "Bedienstand HMI, Steuerpult",
|
|
||||||
"risk_in": {"f": 3, "w": 2, "p": 1, "s": 1, "r": 6},
|
|
||||||
"measures": ["Bedienelemente in ergonomisch günstiger Höhe"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
|
|
||||||
"norm_references": ["EN 894-3"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "6.1",
|
|
||||||
"hazard_group": "zusätzliche Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Verlust einer Sicherheitsfunktion durch Steuerungs- oder Softwarefehler",
|
|
||||||
"hazard_cause": "Steuerungs- oder Softwarefehler der eigenen Maschinensteuerung führt zu unkontrolliertem Verhalten oder Verlust einer Sicherheitsfunktion",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
|
|
||||||
"component_zone": "Gesamte Maschine, Steuerung",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
|
|
||||||
"measures": ["Sichere Fehlerbehandlung", "Sichere Software-Fallbacks", "Watchdog"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["EN ISO 13849-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "6.2",
|
|
||||||
"hazard_group": "zusätzliche Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Verlust der Sicherheitsfunktion nach fehlerhaftem Software-Update",
|
|
||||||
"hazard_cause": "Korrupte oder inkompatible Firmware nach fehlerhaftem Update über die USB-Schnittstelle lässt die Steuerung undefiniert verhalten oder Sicherheitsfunktion verlieren",
|
|
||||||
"lifecycle_phases": ["Instandhaltung"],
|
|
||||||
"component_zone": "Gesamte Maschine, Steuerung, Update-Schnittstelle",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
|
|
||||||
"measures": ["Atomares Update mit Rückfall auf lauffähige Version", "Kompatibilitätsprüfung vor Update"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["EN ISO 13849-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "4.4",
|
|
||||||
"hazard_group": "Mechanische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Erfassen/Aufwickeln an rotierenden Teilen bei geöffneter Schutztür",
|
|
||||||
"hazard_cause": "Bei geöffneter Tür im Wartungs- oder Reinigungsfall können lose Kleidung oder Haare an noch zugänglichen rotierenden Wellen erfasst und aufgewickelt werden",
|
|
||||||
"lifecycle_phases": ["Instandhaltung", "Reinigung"],
|
|
||||||
"component_zone": "Rotierende Wellen, Spülarm bei geöffneter Schutztür",
|
|
||||||
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 3, "r": 12},
|
|
||||||
"measures": ["Rotation stoppt bei geöffneter Tür durch Verriegelung", "Warnhinweis"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 6},
|
|
||||||
"norm_references": ["EN ISO 14120"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "4.5",
|
|
||||||
"hazard_group": "Mechanische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Reibung/Hautabschürfung an rotierenden Teilen bei geöffneter Schutztür",
|
|
||||||
"hazard_cause": "Berührung rotierender Wellen oder Oberflächen bei geöffneter Tür im Wartungsfall führt zu Hautabschürfungen durch Reibung",
|
|
||||||
"lifecycle_phases": ["Instandhaltung"],
|
|
||||||
"component_zone": "Rotierende Welle bei geöffneter Schutztür",
|
|
||||||
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 2, "r": 8},
|
|
||||||
"measures": ["Rotation stoppt bei geöffneter Tür durch Verriegelung"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 4},
|
|
||||||
"norm_references": ["EN ISO 14120"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "1.4",
|
|
||||||
"hazard_group": "Thermische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Trockenlauf-Überhitzung von Boiler/Heizung",
|
|
||||||
"hazard_cause": "Das Heizelement bzw. der Boiler läuft bei Wassermangel trocken, überhitzt und kann einen Brand oder eine Verbrühung durch überhitztes Wasser auslösen",
|
|
||||||
"lifecycle_phases": ["Betrieb"],
|
|
||||||
"component_zone": "Boiler, Tankheizkörper, Heizelement",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
|
|
||||||
"measures": ["Trockengehschutz / Niveauüberwachung der Heizung", "Temperaturbegrenzer (STB)"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["EN 60335-2-58", "EN 60335-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "3.4",
|
|
||||||
"hazard_group": "Elektrische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Restspannung / gespeicherte elektrische Energie nach Abschalten",
|
|
||||||
"hazard_cause": "Nach dem Abschalten der Spannungsversorgung stehen durch Kondensatoren im Frequenzumrichter oder Netzfilter noch gefährliche Berührungsspannungen an",
|
|
||||||
"lifecycle_phases": ["Instandhaltung", "Fehlersuche und -beseitigung"],
|
|
||||||
"component_zone": "Frequenzumrichter, Netzfilter, Schaltschrank",
|
|
||||||
"risk_in": {"f": 1, "w": 2, "p": 3, "s": 4, "r": 24},
|
|
||||||
"measures": ["Sichere Energieentladung nach Abschalten", "Warnhinweis Restspannung, Entladezeit abwarten"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 4, "r": 12},
|
|
||||||
"norm_references": ["IEC 60204-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "4.6",
|
|
||||||
"hazard_group": "Mechanische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Schnittverletzung an scharfen Kanten",
|
|
||||||
"hazard_cause": "Schneiden an scharfen Blechkanten, Sieben oder dem Ablaufpumpen-Laufrad beim Reinigen oder Eingreifen in die Spülkammer",
|
|
||||||
"lifecycle_phases": ["Reinigung", "Instandhaltung"],
|
|
||||||
"component_zone": "Zugängliche Kanten, Siebe, Spülkammer, Ablaufpumpe",
|
|
||||||
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 1, "r": 7},
|
|
||||||
"measures": ["Brechen oder Runden aller zugänglichen Kanten"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 1, "r": 3},
|
|
||||||
"norm_references": ["EN ISO 12100"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "4.7",
|
|
||||||
"hazard_group": "Mechanische Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Kippen / mangelnde Standsicherheit",
|
|
||||||
"hazard_cause": "Unzureichende Standsicherheit bei Untertischmontage, Transport oder Installation führt zum Kippen oder Umstürzen der Maschine",
|
|
||||||
"lifecycle_phases": ["Transport", "Montage und Installation"],
|
|
||||||
"component_zone": "Gesamte Maschine, Aufstellbereich",
|
|
||||||
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 2, "r": 8},
|
|
||||||
"measures": ["Standsichere Aufstellung / Befestigung", "Kippsichere Konstruktion"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 2, "r": 4},
|
|
||||||
"norm_references": ["EN ISO 12100"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "2.3",
|
|
||||||
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Rückfluss / Kontamination des Trinkwassers",
|
|
||||||
"hazard_cause": "Verschmutztes Spül- oder Chemiewasser wird ohne Rückflussverhinderer in das Trinkwassernetz zurückgesaugt und kontaminiert es",
|
|
||||||
"lifecycle_phases": ["Betrieb"],
|
|
||||||
"component_zone": "Frischwasseranschluss, Wasserzulauf",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
|
|
||||||
"measures": ["Rückflussverhinderer / Systemtrenner nach EN 1717", "Freier Auslauf"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["EN 1717", "EN 60335-2-58"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "2.4",
|
|
||||||
"hazard_group": "Gefährdungen durch Materialien und Substanzen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Mikrobielle Belastung / Legionellen im Stehwasser",
|
|
||||||
"hazard_cause": "Stehwasser im Boiler oder Tank bei niedrigen Temperaturen begünstigt mikrobielles Wachstum und Legionellen, die über Aerosole eingeatmet werden",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
|
|
||||||
"component_zone": "Boiler, Tank, Stehwasser",
|
|
||||||
"risk_in": {"f": 1, "w": 1, "p": 2, "s": 3, "r": 12},
|
|
||||||
"measures": ["Thermische Desinfektion / ausreichende Wassertemperatur", "Regelmäßiger Wasserwechsel"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["EN 60335-2-58"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "6.3",
|
|
||||||
"hazard_group": "zusätzliche Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Versagen der Tür-/Schutzeinrichtungs-Verriegelung",
|
|
||||||
"hazard_cause": "Die Verriegelung des Tür-Sicherheitsschalters versagt oder wird überbrückt, sodass der Zugriff in die Spülkammer bei laufendem Spülgang (Heißwasser, rotierender Spülarm) möglich wird",
|
|
||||||
"lifecycle_phases": ["Betrieb", "Instandhaltung"],
|
|
||||||
"component_zone": "Tür-Sicherheitsschalter, Verriegelung, Spülkammer",
|
|
||||||
"risk_in": {"f": 3, "w": 2, "p": 2, "s": 3, "r": 21},
|
|
||||||
"measures": ["Sichere Verriegelung mit Fehlerüberwachung (PL nach ISO 13849)", "Zwangsöffnende Kontakte"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["EN ISO 14119", "EN ISO 13849-1"],
|
|
||||||
"sufficient": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"nr": "6.4",
|
|
||||||
"hazard_group": "zusätzliche Gefährdungen",
|
|
||||||
"hazard_group_applicable": true,
|
|
||||||
"hazard_type": "Unerwarteter Wiederanlauf bei Wartung",
|
|
||||||
"hazard_cause": "Während Wartung oder Reinigung läuft die Maschine durch fehlende Freischaltung (LOTO) oder automatischen Wiederanlauf unerwartet an",
|
|
||||||
"lifecycle_phases": ["Instandhaltung", "Reinigung"],
|
|
||||||
"component_zone": "Gesamte Maschine, Antriebe, Pumpe",
|
|
||||||
"risk_in": {"f": 2, "w": 2, "p": 2, "s": 3, "r": 18},
|
|
||||||
"measures": ["Freischalten und gegen Wiedereinschalten sichern (LOTO)", "Kein automatischer Wiederanlauf"],
|
|
||||||
"measure_type": "KM",
|
|
||||||
"risk_out": {"f": 1, "w": 1, "p": 1, "s": 3, "r": 9},
|
|
||||||
"norm_references": ["IEC 60204-1", "EN ISO 12100"],
|
|
||||||
"sufficient": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// authorityInfo is the normative classification of a search result, used internally
|
|
||||||
// for re-ranking only (Phase 1 changes ordering, not the response contract).
|
|
||||||
type authorityInfo struct {
|
|
||||||
weight int // 100 binding, 80 technical_standard, 70 guidance, 0 foreign, 50 unknown
|
|
||||||
sourceClass string // binding_law | technical_standard | supervisory_guidance | foreign_law | unknown
|
|
||||||
jurisdiction string // DE | EU | CH
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
guidanceMarkers = []string{
|
|
||||||
"DSK", "EDPB", "BfDI", "BFDI", "BayLfD", "Baylfb", "ENISA", "BSI", "EUCC",
|
|
||||||
"Standards Mapping", "Kpnr", "Orientierungshilfe", "Handreichung", "Beschluss",
|
|
||||||
"Leitlinie", "Guidance", "Empfehlung", "OECD", "CISA", "Blue Guide",
|
|
||||||
}
|
|
||||||
// Technical standards / control frameworks (best-practice controls). Checked BEFORE
|
|
||||||
// guidanceMarkers so a "BSI Grundschutz" chunk classifies as a standard, not BSI guidance.
|
|
||||||
standardMarkers = []string{
|
|
||||||
"NIST", "OWASP", "Grundschutz", "ISO 27001", "ISO/IEC 27001",
|
|
||||||
"CSA CCM", "Cloud Controls Matrix", "CIS Benchmark", "CIS Control",
|
|
||||||
}
|
|
||||||
foreignMarkers = []string{"RevDSG", "fedlex", "(CH)"}
|
|
||||||
deMarkers = []string{"BDSG", "DSK", "BfDI", "BFDI", "BayLfD", "Baylfb", "BSI"}
|
|
||||||
normPattern = regexp.MustCompile(`(§|Art\.?)\s*\d`)
|
|
||||||
bdsgParagraph = regexp.MustCompile(`§\s*(\d+)`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// classifyAuthority derives weight/source-class/jurisdiction. Explicitly tagged payload
|
|
||||||
// values win; otherwise it falls back to the curated category + name markers, so the
|
|
||||||
// not-yet-re-ingested (untagged) corpus is still classified deterministically.
|
|
||||||
func classifyAuthority(r LegalSearchResult) authorityInfo {
|
|
||||||
jur := r.Jurisdiction
|
|
||||||
if jur == "" {
|
|
||||||
jur = inferJurisdiction(r)
|
|
||||||
}
|
|
||||||
hay := r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.RegulationCode
|
|
||||||
// A recognised standard NAME (NIST/OWASP/ISO 27001/CIS/CSA CCM/Grundschutz) is authoritative
|
|
||||||
// even when the corpus mis-tagged the chunk as supervisory_guidance (weight 70) — many
|
|
||||||
// standards were ingested with a generic guidance source_class. The name wins, so they
|
|
||||||
// classify (and rank) as technical_standard / control_standard. binding_law is preserved.
|
|
||||||
if r.SourceClass != "binding_law" && containsAny(hay, standardMarkers) {
|
|
||||||
return authorityInfo{weight: 80, sourceClass: "technical_standard", jurisdiction: jur}
|
|
||||||
}
|
|
||||||
if r.SourceClass != "" {
|
|
||||||
w := r.AuthorityWeight
|
|
||||||
if w == 0 && r.SourceClass == "binding_law" {
|
|
||||||
w = 100
|
|
||||||
}
|
|
||||||
return authorityInfo{weight: w, sourceClass: r.SourceClass, jurisdiction: jur}
|
|
||||||
}
|
|
||||||
if r.AuthorityWeight > 0 {
|
|
||||||
return authorityInfo{weight: r.AuthorityWeight, sourceClass: sourceClassFromWeight(r.AuthorityWeight), jurisdiction: jur}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case containsAny(hay, foreignMarkers):
|
|
||||||
return authorityInfo{weight: 0, sourceClass: "foreign_law", jurisdiction: "CH"}
|
|
||||||
case r.Category == "standard" || containsAny(hay, standardMarkers):
|
|
||||||
return authorityInfo{weight: 80, sourceClass: "technical_standard", jurisdiction: jur}
|
|
||||||
case r.Category == "guidance" || containsAny(hay, guidanceMarkers):
|
|
||||||
return authorityInfo{weight: 70, sourceClass: "supervisory_guidance", jurisdiction: jur}
|
|
||||||
case r.Category == "regulation" || r.Category == "eu_recht" || normPattern.MatchString(r.ArticleLabel):
|
|
||||||
return authorityInfo{weight: 100, sourceClass: "binding_law", jurisdiction: jur}
|
|
||||||
default:
|
|
||||||
return authorityInfo{weight: 50, sourceClass: "unknown", jurisdiction: jur}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceClassFromWeight(w int) string {
|
|
||||||
switch {
|
|
||||||
case w >= 100:
|
|
||||||
return "binding_law"
|
|
||||||
case w >= 80:
|
|
||||||
return "technical_standard"
|
|
||||||
case w >= 70:
|
|
||||||
return "supervisory_guidance"
|
|
||||||
case w <= 0:
|
|
||||||
return "foreign_law"
|
|
||||||
default:
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func inferJurisdiction(r LegalSearchResult) string {
|
|
||||||
hay := r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName
|
|
||||||
switch {
|
|
||||||
case containsAny(hay, foreignMarkers):
|
|
||||||
return "CH"
|
|
||||||
case strings.Contains(hay, "§") || containsAny(hay, deMarkers):
|
|
||||||
return "DE"
|
|
||||||
default:
|
|
||||||
return "EU"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Domain routing: separates same-authority but topically foreign norms ---
|
|
||||||
|
|
||||||
type domainDef struct {
|
|
||||||
name string
|
|
||||||
regs []string // regulation markers found in a chunk
|
|
||||||
keywords []string // query keywords that signal this domain
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deterministic order (slice, not map) — important for stable classification + tests.
|
|
||||||
var domains = []domainDef{
|
|
||||||
{"data_protection",
|
|
||||||
[]string{"DSGVO", "GDPR", "BDSG", "TDDDG", "TTDSG", "EDPB", "DSK", "BfDI", "BayLfD", "DPF"},
|
|
||||||
[]string{"personenbezogen", "betroffene", "datenschutz", "datenschutzbeauftrag", "dsb",
|
|
||||||
"datenpanne", "auskunft", "loesch", "lösch", "einwilligung", "besondere kategorien", "auftragsverarbeit",
|
|
||||||
"cookie", "endeinrichtung", "endgerät", "endgeraet", "tracking"}},
|
|
||||||
{"cyber",
|
|
||||||
[]string{"CRA", "NIS2", "NIS-2", "ENISA", "DORA", "EUCC"},
|
|
||||||
[]string{"security update", "sicherheitsupdate", "sicherheitsaktualisierung", "schwachstelle", "sbom",
|
|
||||||
"cybersicherheit", "konformit", "hersteller", "importeur", "haendler", "händler", "ikt-",
|
|
||||||
"resilienz", "sicherheitsvorfall", "digitalen elementen"}},
|
|
||||||
{"ai",
|
|
||||||
[]string{"AI Act", "KI-VO", "KI-Verordnung"},
|
|
||||||
[]string{"ki-system", "ki-modell", "hochrisiko", "kuenstliche intelligenz", "künstliche intelligenz"}},
|
|
||||||
{"product_safety",
|
|
||||||
[]string{"Maschinenverordnung", "MaschinenVO", "GPSR", "RED", "MDR"},
|
|
||||||
nil},
|
|
||||||
}
|
|
||||||
|
|
||||||
// euPrimaryDomains are domains whose PRIMARY binding act is an EU regulation/directive
|
|
||||||
// (DSGVO, CRA/NIS2, AI Act, MaschinenVO). In these domains a NATIONAL implementing law
|
|
||||||
// (e.g. BDSG) is subsidiary for general questions — see nationalSubsidiarityPenalty.
|
|
||||||
var euPrimaryDomains = map[string]bool{
|
|
||||||
"data_protection": true,
|
|
||||||
"cyber": true,
|
|
||||||
"ai": true,
|
|
||||||
"product_safety": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryDomain(query string) string {
|
|
||||||
ql := strings.ToLower(query)
|
|
||||||
for _, d := range domains {
|
|
||||||
for _, kw := range d.keywords {
|
|
||||||
if strings.Contains(ql, kw) {
|
|
||||||
return d.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: an explicit regulation mention (e.g. "DSGVO", "BDSG", "CRA") also signals the
|
|
||||||
// domain — so a question phrased around the act ("... gilt die DSGVO ...") is scoped even
|
|
||||||
// without a topical keyword. Keyword match wins first (more specific).
|
|
||||||
for _, d := range domains {
|
|
||||||
for _, reg := range d.regs {
|
|
||||||
if strings.Contains(ql, strings.ToLower(reg)) {
|
|
||||||
return d.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func chunkDomain(r LegalSearchResult) string {
|
|
||||||
hay := r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationCode + " " + r.RegulationName
|
|
||||||
for _, d := range domains {
|
|
||||||
if containsAny(hay, d.regs) {
|
|
||||||
return d.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// scopeClass flags special sub-regimes that must not win general questions —
|
|
||||||
// BDSG Teil 3 (§§ 45-84) implements the JI directive (law enforcement), not the general regime.
|
|
||||||
func scopeClass(r LegalSearchResult) string {
|
|
||||||
hay := r.ArticleLabel + " " + r.RegulationShort
|
|
||||||
if strings.Contains(hay, "BDSG") {
|
|
||||||
if m := bdsgParagraph.FindStringSubmatch(hay); m != nil {
|
|
||||||
if n, err := strconv.Atoi(m[1]); err == nil && n >= 45 && n <= 84 {
|
|
||||||
return "law_enforcement"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "general"
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Topic ontology: amplifier only (boost), never an override ---
|
|
||||||
|
|
||||||
type topicDef struct {
|
|
||||||
keywords []string
|
|
||||||
norms []string // preferred canonical citation fragments
|
|
||||||
}
|
|
||||||
|
|
||||||
var topics = []topicDef{
|
|
||||||
{[]string{"datenschutzbeauftrag", "dsb", "benennung"}, []string{"Art. 37", "§ 38 BDSG"}},
|
|
||||||
{[]string{"stellung des"}, []string{"Art. 38"}},
|
|
||||||
{[]string{"aufgaben des"}, []string{"Art. 39"}},
|
|
||||||
{[]string{"folgenabsch", "dsfa"}, []string{"Art. 35"}},
|
|
||||||
{[]string{"besondere kategorien"}, []string{"Art. 9", "§ 22 BDSG"}},
|
|
||||||
{[]string{"auskunft"}, []string{"Art. 15", "§ 34 BDSG"}},
|
|
||||||
{[]string{"loesch", "lösch"}, []string{"Art. 17", "§ 35 BDSG"}},
|
|
||||||
{[]string{"bussgeld", "geldbusse"}, []string{"Art. 83"}},
|
|
||||||
{[]string{"security update", "sicherheitsupdate", "schwachstelle", "sbom", "cybersicherheitsanforderung"}, []string{"CRA Anhang I"}},
|
|
||||||
{[]string{"meldepflicht", "sicherheitsvorfall"}, []string{"Art. 14 CRA"}},
|
|
||||||
// ePrivacy / cookies: § 25 TDDDG (ex-TTDSG) is lex specialis for terminal-equipment access /
|
|
||||||
// cookie consent. Co-primary on a cookie/tracking query, so the subsidiarity rule does NOT
|
|
||||||
// demote it like general-DP DE law subsidiary to the DSGVO. Keywords are cookie-specific
|
|
||||||
// (NOT bare "Einwilligung") so a general consent question still resolves to Art. 7 DSGVO.
|
|
||||||
{[]string{"cookie", "endeinrichtung", "endgerät", "endgeraet", "tracking", "speicherung von informationen", "zugriff auf informationen"}, []string{"§ 25 TDDDG"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
// resultMatchesTopic reports whether the result is a preferred norm of a topic the query hits.
|
|
||||||
func resultMatchesTopic(query string, r LegalSearchResult) bool {
|
|
||||||
ql := strings.ToLower(query)
|
|
||||||
hay := r.ArticleLabel + " " + r.RegulationShort
|
|
||||||
for _, t := range topics {
|
|
||||||
if !containsAnyLower(ql, t.keywords) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, n := range t.norms {
|
|
||||||
if normMatches(hay, n) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// normMatches checks that norm appears in hay with a non-digit boundary, so "Art. 9"
|
|
||||||
// matches "Art. 9 DSGVO" but not "Art. 90".
|
|
||||||
func normMatches(hay, norm string) bool {
|
|
||||||
idx := strings.Index(hay, norm)
|
|
||||||
if idx < 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
end := idx + len(norm)
|
|
||||||
if end < len(hay) && hay[end] >= '0' && hay[end] <= '9' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryIsForeign(query string) bool {
|
|
||||||
return containsAnyLower(strings.ToLower(query),
|
|
||||||
[]string{"schweiz", "revdsg", "fedlex", " ch ", "oesterreich", "österreich"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsAny(hay string, markers []string) bool {
|
|
||||||
for _, m := range markers {
|
|
||||||
if strings.Contains(hay, m) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsAnyLower(haylower string, markers []string) bool {
|
|
||||||
for _, m := range markers {
|
|
||||||
if strings.Contains(haylower, strings.ToLower(m)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Re-ranking coefficients (validated in the offline golden harness; Phase A — conservative).
|
|
||||||
const (
|
|
||||||
authorityCoef = 0.40 // * weight/100
|
|
||||||
jurisdictionGain = 0.05 // binding/guidance from DE or EU
|
|
||||||
foreignPenalty = 0.60 // foreign law on a DE/EU question (demoted, not removed)
|
|
||||||
unknownPenalty = 0.08
|
|
||||||
domainMatchGain = 0.15
|
|
||||||
offDomainPenalty = 0.10 // off-domain binding (demoted, not removed)
|
|
||||||
scopePenalty = 0.25 // BDSG Teil 3 (law enforcement) on a general DP question
|
|
||||||
subsidiarityPen = 0.18 // national implementing law (BDSG) on a general EU-primary question: SOFT demote, not exclusion
|
|
||||||
topicGain = 0.18 // amplifier only
|
|
||||||
supersededPenalty = 0.50 // superseded Alt-Quelle (pre-eu-v1): demoted, nicht versteckt
|
|
||||||
intentLiftGain = 0.10 // epsilon a qualifying interpretative source is lifted ABOVE the best binding
|
|
||||||
intentLiftMargin = 0.05 // ...only if that source is semantically competitive with binding
|
|
||||||
)
|
|
||||||
|
|
||||||
// guidanceIntentSignals mark a query that EXPLICITLY asks for an interpretation /
|
|
||||||
// recommendation by a guidance body, rather than for the binding obligation. Only
|
|
||||||
// then may a (semantically competitive) guideline outrank the binding norm.
|
|
||||||
var guidanceIntentSignals = []string{
|
|
||||||
"edpb", "europäischer datenschutzausschuss", "europaeischer datenschutzausschuss",
|
|
||||||
"dsk", "enisa", "bsi", "leitlinie", "guideline", "orientierungshilfe",
|
|
||||||
"auslegung", "empfiehlt", "empfehlung", "sagt", "laut",
|
|
||||||
}
|
|
||||||
|
|
||||||
// controlIntentSignals mark a query that asks HOW to implement / which controls or
|
|
||||||
// measures fit — rather than WHAT the binding obligation is. Only then may a
|
|
||||||
// (semantically competitive) technical_standard outrank the binding norm.
|
|
||||||
var controlIntentSignals = []string{
|
|
||||||
"control", "controls", "maßnahme", "massnahme", "schutzmaßnahme",
|
|
||||||
"best practice", "best-practice", "umsetzen", "implementier", "absicher",
|
|
||||||
"härt", "haert", "hardening", "nist", "owasp", "grundschutz",
|
|
||||||
"ccm", "iso 27001", "isms",
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryMatchesAny(query string, signals []string) bool {
|
|
||||||
q := strings.ToLower(query)
|
|
||||||
for _, sig := range signals {
|
|
||||||
if strings.Contains(q, sig) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// queryWantsGuidance reports whether the query explicitly asks for guidance/interpretation.
|
|
||||||
func queryWantsGuidance(query string) bool { return queryMatchesAny(query, guidanceIntentSignals) }
|
|
||||||
|
|
||||||
// queryWantsControls reports whether the query asks for implementation controls/measures.
|
|
||||||
func queryWantsControls(query string) bool { return queryMatchesAny(query, controlIntentSignals) }
|
|
||||||
|
|
||||||
// bestBindingSemantic returns the highest RAW semantic score among binding-law
|
|
||||||
// results (0 if none / no intent). Used as the guard threshold so an off-topic
|
|
||||||
// interpretative source cannot ride the intent boost.
|
|
||||||
func bestBindingSemantic(results []LegalSearchResult, wantsIntent bool) float64 {
|
|
||||||
if !wantsIntent {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
best := 0.0
|
|
||||||
for _, r := range results {
|
|
||||||
if classifyAuthority(r).sourceClass == "binding_law" && r.Score > best {
|
|
||||||
best = r.Score
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// authorityScore computes the normative relevance of a result for a query. It augments the
|
|
||||||
// semantic score with authority/jurisdiction/domain/scope/topic signals. Exposed for tests.
|
|
||||||
func authorityScore(query string, r LegalSearchResult, qDomain string, qForeign bool) float64 {
|
|
||||||
info := classifyAuthority(r)
|
|
||||||
score := r.Score + authorityCoef*float64(info.weight)/100.0
|
|
||||||
|
|
||||||
if r.Superseded {
|
|
||||||
// Alt-Quelle (pre-eu-v1): Default-Fragen sollen die eu-v1-Norm sehen. Demoted,
|
|
||||||
// nicht entfernt — fuer Historie/Uebergangsfragen bleibt sie auffindbar.
|
|
||||||
score -= supersededPenalty
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.jurisdiction == "CH" && !qForeign {
|
|
||||||
score -= foreignPenalty // Fremdrecht bei DE/EU-Frage: demoted, nicht geloescht
|
|
||||||
} else {
|
|
||||||
score += jurisdictionGain
|
|
||||||
}
|
|
||||||
if info.sourceClass == "unknown" {
|
|
||||||
score -= unknownPenalty
|
|
||||||
}
|
|
||||||
if qDomain != "" {
|
|
||||||
switch cd := chunkDomain(r); {
|
|
||||||
case cd == qDomain:
|
|
||||||
score += domainMatchGain
|
|
||||||
case cd != "":
|
|
||||||
score -= offDomainPenalty // off-domain binding: demoted, nicht geloescht
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if qDomain == "data_protection" && scopeClass(r) == "law_enforcement" {
|
|
||||||
score -= scopePenalty
|
|
||||||
}
|
|
||||||
// Subsidiarity: a national implementing law (DE binding, e.g. BDSG) is subsidiary to the
|
|
||||||
// primary EU act for GENERAL questions in an EU-primary domain — UNLESS the query hits a
|
|
||||||
// topic where the national norm is co-primary (DSB §38, special categories §22, ...). The
|
|
||||||
// topic boost below lifts those; here we only SOFT-demote the non-topic national norm, so
|
|
||||||
// it stays visible and can still win on a strongly matching topic. No hard exclusion.
|
|
||||||
if euPrimaryDomains[qDomain] && info.sourceClass == "binding_law" &&
|
|
||||||
info.jurisdiction == "DE" && !resultMatchesTopic(query, r) {
|
|
||||||
score -= subsidiarityPen
|
|
||||||
}
|
|
||||||
if resultMatchesTopic(query, r) {
|
|
||||||
score += topicGain // Verstaerker, kein Override
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
// rerankByAuthority re-orders results so binding law from the matching jurisdiction/domain
|
|
||||||
// ranks above guidance, foreign and off-domain law — WITHOUT dropping anything (guidance is
|
|
||||||
// kept as interpretation context). The computed score is written back to Score so downstream
|
|
||||||
// merges (e.g. the multi-collection advisor) preserve this order. Pure + deterministic.
|
|
||||||
func rerankByAuthority(query string, results []LegalSearchResult) []LegalSearchResult {
|
|
||||||
if len(results) < 2 {
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
qDomain := queryDomain(query)
|
|
||||||
qForeign := queryIsForeign(query)
|
|
||||||
wantsGuidance := queryWantsGuidance(query)
|
|
||||||
wantsControls := queryWantsControls(query)
|
|
||||||
bestBindingSem := bestBindingSemantic(results, wantsGuidance)
|
|
||||||
|
|
||||||
out := make([]LegalSearchResult, len(results))
|
|
||||||
copy(out, results)
|
|
||||||
for i := range out {
|
|
||||||
out[i].Score = authorityScore(query, out[i], qDomain, qForeign)
|
|
||||||
}
|
|
||||||
// Explicit interpretation intent → a competitive guideline may outrank binding (lift
|
|
||||||
// above the best binding FINAL). Explicit implementation intent → boost the CONTROL-POOL
|
|
||||||
// (operational/procedural requirement, control standard, implementation guidance) over
|
|
||||||
// the abstract obligation, soft-ordered by role. Norm questions (neither) stay untouched.
|
|
||||||
if wantsGuidance {
|
|
||||||
liftAboveBinding(out, results, bestBindingSem, "supervisory_guidance")
|
|
||||||
}
|
|
||||||
if wantsControls {
|
|
||||||
applyControlRoles(out)
|
|
||||||
}
|
|
||||||
sort.SliceStable(out, func(a, b int) bool {
|
|
||||||
return out[a].Score > out[b].Score
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// liftAboveBinding lifts a semantically-competitive interpretative source (the given
|
|
||||||
// sourceClass — supervisory_guidance or technical_standard) just ABOVE the best binding
|
|
||||||
// hit, ordered by semantic, so an EXPLICIT guidance/implementation question can return
|
|
||||||
// that source Top-1. A pure norm question (no intent → not called) keeps binding on top.
|
|
||||||
// Sources below the semantic margin are left untouched, so an off-topic source can never
|
|
||||||
// ride the override — and the lift is from the binding FINAL score, so authority/topic/
|
|
||||||
// domain bonuses cannot edge it out.
|
|
||||||
func liftAboveBinding(out, raw []LegalSearchResult, bestBindingSem float64, sourceClass string) {
|
|
||||||
bestBindingFinal := 0.0
|
|
||||||
for i := range out {
|
|
||||||
if classifyAuthority(out[i]).sourceClass == "binding_law" && out[i].Score > bestBindingFinal {
|
|
||||||
bestBindingFinal = out[i].Score
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range out {
|
|
||||||
// Classify (not raw payload) so the untagged legacy corpus — e.g. NIST ingested
|
|
||||||
// before source_class tagging — is still recognized as its interpretative class.
|
|
||||||
if classifyAuthority(out[i]).sourceClass != sourceClass || raw[i].Score < bestBindingSem-intentLiftMargin {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lifted := bestBindingFinal + intentLiftGain + (raw[i].Score - bestBindingSem)
|
|
||||||
if lifted > out[i].Score {
|
|
||||||
out[i].Score = lifted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func bindingRes(label, reg, jur string, score float64) LegalSearchResult {
|
|
||||||
return LegalSearchResult{ArticleLabel: label, RegulationShort: reg, SourceClass: "binding_law", AuthorityWeight: 100, Jurisdiction: jur, Score: score}
|
|
||||||
}
|
|
||||||
|
|
||||||
func guidanceRes(label, reg string, score float64) LegalSearchResult {
|
|
||||||
return LegalSearchResult{ArticleLabel: label, RegulationShort: reg, SourceClass: "supervisory_guidance", AuthorityWeight: 70, Jurisdiction: "EU", Score: score}
|
|
||||||
}
|
|
||||||
|
|
||||||
func foreignRes(label string, score float64) LegalSearchResult {
|
|
||||||
return LegalSearchResult{ArticleLabel: label, RegulationShort: "RevDSG", SourceClass: "foreign_law", AuthorityWeight: 0, Jurisdiction: "CH", Score: score}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acceptance criteria (Phase 1) expressed as ordering tests.
|
|
||||||
func TestRerankByAuthority_Acceptance(t *testing.T) {
|
|
||||||
t.Run("guidance does not overtake semantically competitive binding", func(t *testing.T) {
|
|
||||||
out := rerankByAuthority("Was gilt hier?", []LegalSearchResult{
|
|
||||||
guidanceRes("ENISA Mapping", "ENISA", 0.72),
|
|
||||||
bindingRes("CRA Anhang I", "CRA", "EU", 0.66),
|
|
||||||
})
|
|
||||||
if out[0].RegulationShort != "CRA" {
|
|
||||||
t.Fatalf("binding must rank first over competitive guidance, got %q", out[0].RegulationShort)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("foreign law demoted on DE/EU question but kept", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{foreignRes("RevDSG Art 1", 0.85), bindingRes("Art. 9 DSGVO", "DSGVO", "EU", 0.62)}
|
|
||||||
out := rerankByAuthority("Welche Daten sind besonders geschuetzt?", in)
|
|
||||||
if out[0].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("binding EU must beat foreign on a DE/EU query, got %q", out[0].RegulationShort)
|
|
||||||
}
|
|
||||||
if len(out) != 2 {
|
|
||||||
t.Fatalf("foreign law must be kept, got len=%d", len(out))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("off-domain binding demoted but not removed", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("Art. 13 EU MDR", "MDR", "EU", 0.70),
|
|
||||||
bindingRes("Art. 13 CRA", "CRA", "EU", 0.60),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Welche Pflichten hat der Hersteller von Produkten mit digitalen Elementen?", in)
|
|
||||||
if out[0].RegulationShort != "CRA" {
|
|
||||||
t.Fatalf("on-domain CRA must beat off-domain MDR, got %q", out[0].RegulationShort)
|
|
||||||
}
|
|
||||||
if len(out) != 2 {
|
|
||||||
t.Fatalf("off-domain MDR must be kept, got len=%d", len(out))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("same-regime binding wins over guidance", func(t *testing.T) {
|
|
||||||
out := rerankByAuthority("Was gilt hier?", []LegalSearchResult{
|
|
||||||
bindingRes("Art. 13 CRA", "CRA", "EU", 0.70),
|
|
||||||
guidanceRes("ENISA Mapping", "ENISA", 0.60),
|
|
||||||
})
|
|
||||||
if out[0].RegulationShort != "CRA" {
|
|
||||||
t.Fatalf("binding must win, got %q", out[0].RegulationShort)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BDSG Teil 3 demoted below DSGVO on general DP question", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("§ 48 BDSG", "BDSG", "DE", 0.70), // Teil 3 (law enforcement)
|
|
||||||
bindingRes("Art. 9 DSGVO", "DSGVO", "EU", 0.62),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Was sind besondere Kategorien personenbezogener Daten?", in)
|
|
||||||
if out[0].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("DSGVO must beat BDSG Teil 3 on a general DP question, got %q", out[0].RegulationShort)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Subsidiarity (KB-2026.1 BDSG-pilot regression): a national implementing § that is NOT a
|
|
||||||
// co-primary topic norm must not outrank the primary DSGVO article on a general question.
|
|
||||||
t.Run("subsidiarity dp_05: BDSG §23 below DSGVO Art.6 (Rechtsgrundlage)", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("§ 23 BDSG", "BDSG", "DE", 0.70),
|
|
||||||
bindingRes("Art. 6 DSGVO", "DSGVO", "EU", 0.66),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Welche Rechtsgrundlagen erlauben eine Verarbeitung personenbezogener Daten?", in)
|
|
||||||
if out[0].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("DSGVO Art.6 must beat general BDSG §, got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
if len(out) != 2 {
|
|
||||||
t.Fatalf("BDSG must stay visible (soft demote), got len=%d", len(out))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("subsidiarity dp_08: BDSG §70 below DSGVO Art.28 (Auftragsverarbeitung)", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("§ 70 BDSG", "BDSG", "DE", 0.70), // Teil 3 → scope + subsidiarity
|
|
||||||
bindingRes("Art. 28 DSGVO", "DSGVO", "EU", 0.66),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Was muss ein Auftragsverarbeitungsvertrag enthalten?", in)
|
|
||||||
if out[0].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("DSGVO Art.28 must beat BDSG §70, got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("subsidiarity dp_11: BDSG §22 below DSGVO Art.32 on a TOM question", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("§ 22 BDSG", "BDSG", "DE", 0.70),
|
|
||||||
bindingRes("Art. 32 DSGVO", "DSGVO", "EU", 0.66),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Welche technischen und organisatorischen Massnahmen verlangt das Datenschutzrecht?", in)
|
|
||||||
if out[0].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("DSGVO Art.32 must beat BDSG §22 on a non-topic TOM question, got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("cr_07: a 'DSGVO' mention scopes the domain so BDSG Teil-3 §64 is demoted", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("§ 64 BDSG", "BDSG", "DE", 0.70), // Teil 3 (law enforcement)
|
|
||||||
bindingRes("Art. 32 DSGVO", "DSGVO", "EU", 0.66),
|
|
||||||
}
|
|
||||||
// Query has no DP keyword but names the DSGVO → domain fallback scopes it data_protection,
|
|
||||||
// so scope+subsidiarity demote the law-enforcement § below the primary norm.
|
|
||||||
out := rerankByAuthority("Welche rechtliche Grundlage gilt fuer technische und organisatorische Massnahmen - DSGVO oder ein Standard?", in)
|
|
||||||
if out[0].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("DSGVO must win on a DSGVO-mention question, got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ePrivacy: a cookie query lifts §25 TDDDG above DSGVO consent (lex specialis topic)", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("Art. 7 DSGVO", "DSGVO", "EU", 0.70), // higher semantic
|
|
||||||
bindingRes("§ 25 TDDDG", "TDDDG", "DE", 0.66),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Wann ist eine Einwilligung fuer das Speichern von Cookies auf Endgeraeten erforderlich?", in)
|
|
||||||
if out[0].RegulationShort != "TDDDG" {
|
|
||||||
t.Fatalf("§25 TDDDG must win a cookie question (lex specialis topic), got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("a general consent question still resolves to DSGVO, not §25 TDDDG", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("§ 25 TDDDG", "TDDDG", "DE", 0.70), // higher semantic but no cookie topic
|
|
||||||
bindingRes("Art. 7 DSGVO", "DSGVO", "EU", 0.66),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Welche Anforderungen gelten an eine wirksame Einwilligung?", in)
|
|
||||||
if out[0].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("a general consent question must resolve to DSGVO (TDDDG demoted), got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("co-primary dp_01: BDSG §38 stays top on a DSB question (national special rule)", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
bindingRes("§ 38 BDSG", "BDSG", "DE", 0.66),
|
|
||||||
bindingRes("Art. 37 DSGVO", "DSGVO", "EU", 0.64),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Ab wann muss ein Datenschutzbeauftragter benannt werden?", in)
|
|
||||||
// DSB topic → §38 is co-primary (topic-matched, NOT subsidiarity-demoted) and keeps its
|
|
||||||
// semantic lead; Art. 37 stays a close second. Both remain top-2.
|
|
||||||
if out[0].RegulationShort != "BDSG" {
|
|
||||||
t.Fatalf("BDSG §38 (DSB co-primary) must stay top, got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
if out[1].RegulationShort != "DSGVO" {
|
|
||||||
t.Fatalf("Art. 37 DSGVO must stay co-primary second, got %q", out[1].ArticleLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nothing is dropped and topic amplifies", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{
|
|
||||||
guidanceRes("ENISA", "ENISA", 0.72),
|
|
||||||
bindingRes("CRA Anhang I", "CRA", "EU", 0.66),
|
|
||||||
foreignRes("RevDSG", 0.5),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Anforderungen an Security Updates?", in)
|
|
||||||
if len(out) != len(in) {
|
|
||||||
t.Fatalf("rerank must preserve all results, got %d want %d", len(out), len(in))
|
|
||||||
}
|
|
||||||
if out[0].ArticleLabel != "CRA Anhang I" {
|
|
||||||
t.Fatalf("topic+authority must lift CRA Anhang I to top, got %q", out[0].ArticleLabel)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("single result returned unchanged", func(t *testing.T) {
|
|
||||||
in := []LegalSearchResult{bindingRes("Art. 1 CRA", "CRA", "EU", 0.5)}
|
|
||||||
if out := rerankByAuthority("x", in); len(out) != 1 {
|
|
||||||
t.Fatalf("len=%d", len(out))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestClassifyAuthority(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
result LegalSearchResult
|
|
||||||
wantW int
|
|
||||||
wantSC string
|
|
||||||
wantJur string
|
|
||||||
}{
|
|
||||||
{"tagged binding EU", LegalSearchResult{AuthorityWeight: 100, SourceClass: "binding_law", Jurisdiction: "EU"}, 100, "binding_law", "EU"},
|
|
||||||
{"tagged guidance DE", LegalSearchResult{AuthorityWeight: 70, SourceClass: "supervisory_guidance", Jurisdiction: "DE"}, 70, "supervisory_guidance", "DE"},
|
|
||||||
{"tagged foreign CH", LegalSearchResult{AuthorityWeight: 0, SourceClass: "foreign_law", Jurisdiction: "CH"}, 0, "foreign_law", "CH"},
|
|
||||||
{"untagged ENISA guidance", LegalSearchResult{RegulationShort: "ENISA", ArticleLabel: "ENISA CRA Standards Mapping"}, 70, "supervisory_guidance", "EU"},
|
|
||||||
{"untagged NIST standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, 80, "technical_standard", "EU"},
|
|
||||||
{"mis-tagged NIST guidance -> standard by name", LegalSearchResult{SourceClass: "supervisory_guidance", AuthorityWeight: 70, RegulationShort: "NIST SP 800-82r3", ArticleLabel: "NIST SP 800-82r3"}, 80, "technical_standard", "EU"},
|
|
||||||
{"BSI Grundschutz standard beats BSI guidance", LegalSearchResult{RegulationShort: "BSI Grundschutz", ArticleLabel: "BSI Grundschutz Baustein"}, 80, "technical_standard", "DE"},
|
|
||||||
{"weight-only 85 TRGS standard", LegalSearchResult{AuthorityWeight: 85, RegulationShort: "TRGS 529"}, 85, "technical_standard", "EU"},
|
|
||||||
{"tagged technical_standard", LegalSearchResult{AuthorityWeight: 80, SourceClass: "technical_standard", Jurisdiction: "EU"}, 80, "technical_standard", "EU"},
|
|
||||||
{"untagged CRA binding", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 13 CRA", Category: "regulation"}, 100, "binding_law", "EU"},
|
|
||||||
{"untagged BDSG binding DE", LegalSearchResult{RegulationShort: "BDSG", ArticleLabel: "§ 38 BDSG"}, 100, "binding_law", "DE"},
|
|
||||||
{"untagged RevDSG foreign", LegalSearchResult{RegulationShort: "RevDSG", ArticleLabel: "RevDSG (CH)"}, 0, "foreign_law", "CH"},
|
|
||||||
{"untagged unknown", LegalSearchResult{RegulationShort: "", ArticleLabel: ""}, 50, "unknown", "EU"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := classifyAuthority(tt.result)
|
|
||||||
if got.weight != tt.wantW || got.sourceClass != tt.wantSC || got.jurisdiction != tt.wantJur {
|
|
||||||
t.Errorf("classifyAuthority() = {%d %s %s}, want {%d %s %s}",
|
|
||||||
got.weight, got.sourceClass, got.jurisdiction, tt.wantW, tt.wantSC, tt.wantJur)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryDomain(t *testing.T) {
|
|
||||||
tests := []struct{ q, want string }{
|
|
||||||
{"Welche Anforderungen an Security Updates?", "cyber"},
|
|
||||||
{"Wer braucht einen Datenschutzbeauftragten?", "data_protection"},
|
|
||||||
{"Was sind besondere Kategorien personenbezogener Daten?", "data_protection"},
|
|
||||||
{"Welche Pflichten beim Hochrisiko-KI-System?", "ai"},
|
|
||||||
{"Wie spaet ist es?", ""},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := queryDomain(tt.q); got != tt.want {
|
|
||||||
t.Errorf("queryDomain(%q) = %q, want %q", tt.q, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChunkDomain(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
r LegalSearchResult
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"CRA cyber", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 13 CRA"}, "cyber"},
|
|
||||||
{"DSGVO dp", LegalSearchResult{RegulationShort: "DSGVO", ArticleLabel: "Art. 9 DSGVO"}, "data_protection"},
|
|
||||||
{"AI Act ai", LegalSearchResult{RegulationShort: "AI Act", ArticleLabel: "Art. 10 AI Act"}, "ai"},
|
|
||||||
{"MDR product", LegalSearchResult{RegulationShort: "MDR", ArticleLabel: "Art. 13 EU MDR"}, "product_safety"},
|
|
||||||
{"unknown", LegalSearchResult{RegulationShort: "XYZ"}, ""},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := chunkDomain(tt.r); got != tt.want {
|
|
||||||
t.Errorf("chunkDomain() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScopeClass(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
r LegalSearchResult
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"BDSG Teil 3 law enforcement", LegalSearchResult{RegulationShort: "BDSG", ArticleLabel: "§ 48 BDSG"}, "law_enforcement"},
|
|
||||||
{"BDSG general part", LegalSearchResult{RegulationShort: "BDSG", ArticleLabel: "§ 38 BDSG"}, "general"},
|
|
||||||
{"DSGVO general", LegalSearchResult{RegulationShort: "DSGVO", ArticleLabel: "Art. 9 DSGVO"}, "general"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := scopeClass(tt.r); got != tt.want {
|
|
||||||
t.Errorf("scopeClass() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResultMatchesTopic(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
query string
|
|
||||||
r LegalSearchResult
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"besondere Kategorien -> Art 9 match", "Was sind besondere Kategorien?", LegalSearchResult{ArticleLabel: "Art. 9 DSGVO"}, true},
|
|
||||||
{"besondere Kategorien -> Art 90 no match", "Was sind besondere Kategorien?", LegalSearchResult{ArticleLabel: "Art. 90 DSGVO"}, false},
|
|
||||||
{"security updates -> CRA Anhang I", "Anforderungen an Security Updates?", LegalSearchResult{ArticleLabel: "CRA Anhang I"}, true},
|
|
||||||
{"no topic keyword", "Wie spaet ist es?", LegalSearchResult{ArticleLabel: "Art. 9 DSGVO"}, false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := resultMatchesTopic(tt.query, tt.r); got != tt.want {
|
|
||||||
t.Errorf("resultMatchesTopic() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormMatches(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
hay, norm string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"Art. 9 DSGVO", "Art. 9", true},
|
|
||||||
{"Art. 90 DSGVO", "Art. 9", false},
|
|
||||||
{"§ 38 BDSG", "§ 38 BDSG", true},
|
|
||||||
{"§ 380 BDSG", "§ 38", false},
|
|
||||||
{"Art. 14 CRA", "Art. 14 CRA", true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := normMatches(tt.hay, tt.norm); got != tt.want {
|
|
||||||
t.Errorf("normMatches(%q,%q) = %v, want %v", tt.hay, tt.norm, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// graphCallerRel resolves a path relative to THIS source file (build-time location), so the
|
|
||||||
// graph data is findable under `go test` (cwd = package dir) regardless of working directory.
|
|
||||||
// In a built container the source is gone, so cwd-relative candidates carry the load instead.
|
|
||||||
func graphCallerRel(rel string) string {
|
|
||||||
_, file, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(filepath.Dir(file), rel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// firstExisting returns the first candidate path that exists with the requested kind (dir vs
|
|
||||||
// file). Empty candidates (e.g. unset env overrides) are skipped.
|
|
||||||
func firstExisting(candidates []string, wantDir bool) string {
|
|
||||||
for _, p := range candidates {
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, err := os.Stat(p)
|
|
||||||
if err != nil || info.IsDir() != wantDir {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadComplianceGraph loads the file-backed Compliance Execution Graph: the Registry join-key
|
|
||||||
// contract (obligations/obligation_join_keys.json — owned by the Obligation session) + our
|
|
||||||
// curated, accepted control mappings + evidence requirements. Locations are resolved across
|
|
||||||
// three layouts: dev (cwd = ai-compliance-sdk/, canonical contract at ../obligations), container
|
|
||||||
// (WORKDIR /app, data/ copied in incl. a synced data/obligations/ copy) and `go test`
|
|
||||||
// (cwd = package dir, via graphCallerRel). Fail-closed: a missing/invalid source returns an
|
|
||||||
// error so the handler serves 503 — never a half-built graph.
|
|
||||||
//
|
|
||||||
// NOTE: data/obligations/obligation_join_keys.json is a SYNCED COPY of the repo-root contract
|
|
||||||
// (the canonical owner is the Obligation session). Re-sync it when the Registry grows; dev/test
|
|
||||||
// prefer the canonical repo-root path, only the container falls back to the copy.
|
|
||||||
func LoadComplianceGraph() (*ObligationJoinKeys, *ControlMappingSet, *EvidenceRequirementSet, error) {
|
|
||||||
joinPath := firstExisting([]string{
|
|
||||||
os.Getenv("BP_OBLIGATION_JOIN_KEYS"),
|
|
||||||
"../obligations/obligation_join_keys.json",
|
|
||||||
graphCallerRel("../../../obligations/obligation_join_keys.json"),
|
|
||||||
"data/obligations/obligation_join_keys.json",
|
|
||||||
graphCallerRel("../../data/obligations/obligation_join_keys.json"),
|
|
||||||
}, false)
|
|
||||||
if joinPath == "" {
|
|
||||||
return nil, nil, nil, fmt.Errorf("obligation_join_keys.json not found in any candidate path")
|
|
||||||
}
|
|
||||||
mapDir := firstExisting([]string{
|
|
||||||
os.Getenv("BP_CONTROL_MAPPINGS_DIR"),
|
|
||||||
"data/control_mappings",
|
|
||||||
graphCallerRel("../../data/control_mappings"),
|
|
||||||
}, true)
|
|
||||||
if mapDir == "" {
|
|
||||||
return nil, nil, nil, fmt.Errorf("control_mappings dir not found in any candidate path")
|
|
||||||
}
|
|
||||||
evDir := firstExisting([]string{
|
|
||||||
os.Getenv("BP_EVIDENCE_DIR"),
|
|
||||||
"data/evidence_requirements",
|
|
||||||
graphCallerRel("../../data/evidence_requirements"),
|
|
||||||
}, true)
|
|
||||||
if evDir == "" {
|
|
||||||
return nil, nil, nil, fmt.Errorf("evidence_requirements dir not found in any candidate path")
|
|
||||||
}
|
|
||||||
|
|
||||||
joins, err := LoadObligationJoinKeys(joinPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, nil, fmt.Errorf("load join keys (%s): %w", joinPath, err)
|
|
||||||
}
|
|
||||||
mappings, err := LoadControlMappings(mapDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, nil, fmt.Errorf("load control mappings (%s): %w", mapDir, err)
|
|
||||||
}
|
|
||||||
evidence, err := LoadEvidenceRequirements(evDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, nil, fmt.Errorf("load evidence (%s): %w", evDir, err)
|
|
||||||
}
|
|
||||||
return joins, mappings, evidence, nil
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
// ObligationStatus is the Advisor's vertical slice over the compliance graph for ONE legal
|
|
||||||
// obligation: which accepted controls satisfy it, what evidence they require, what's missing,
|
|
||||||
// and the resulting status. The point is "the required evidence is (not) present", not "a
|
|
||||||
// document exists". citation_spans is pending until the Legal-Knowledge-Graph session attaches
|
|
||||||
// them to the obligation (the upper half of the bridge).
|
|
||||||
type ObligationStatus struct {
|
|
||||||
ObligationID string `json:"obligation_id"`
|
|
||||||
LegalBasis []string `json:"legal_basis"` // the obligation's citation_units
|
|
||||||
Status string `json:"status"` // erfuellt | offen | unklar
|
|
||||||
Controls []ObligationControlStatus `json:"controls"`
|
|
||||||
CitationSpans string `json:"citation_spans"` // "pending" until the registry fills them
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObligationControlStatus is one control under an obligation with its evidence picture.
|
|
||||||
type ObligationControlStatus struct {
|
|
||||||
Framework string `json:"framework"`
|
|
||||||
Control string `json:"control"`
|
|
||||||
MappingType string `json:"mapping_type"`
|
|
||||||
RequiredEvidence []EvidenceRequirement `json:"required_evidence"`
|
|
||||||
MissingEvidence []EvidenceRequirement `json:"missing_evidence"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssessObligationStatus traverses obligation_id -> (citation_unit) -> accepted Controls ->
|
|
||||||
// required Evidence -> Status. hasEvidence reports whether a given (framework, control,
|
|
||||||
// evidence_type) is already collected; pass nil in the MVP (no collection yet) -> everything
|
|
||||||
// required is missing and the status is "offen". Unknown or unmapped obligation -> "unklar".
|
|
||||||
func AssessObligationStatus(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet, obligationID string, hasEvidence func(framework, control, evidenceType string) bool) ObligationStatus {
|
|
||||||
ob := joins.FindObligation(obligationID)
|
|
||||||
if ob == nil {
|
|
||||||
return ObligationStatus{ObligationID: obligationID, Status: "unklar", CitationSpans: "pending"}
|
|
||||||
}
|
|
||||||
st := ObligationStatus{
|
|
||||||
ObligationID: obligationID,
|
|
||||||
LegalBasis: ob.CitationUnits,
|
|
||||||
CitationSpans: "pending",
|
|
||||||
Controls: []ObligationControlStatus{},
|
|
||||||
}
|
|
||||||
ctrls := AcceptedControlsForObligation(*ob, mappings)
|
|
||||||
if len(ctrls) == 0 {
|
|
||||||
st.Status = "unklar" // no accepted control reaches it — we cannot assess
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
anyMissing := false
|
|
||||||
for _, m := range ctrls {
|
|
||||||
req := evidence.RequiredFor(m.TargetFramework, m.TargetControl)
|
|
||||||
missing := make([]EvidenceRequirement, 0, len(req))
|
|
||||||
for _, e := range req {
|
|
||||||
if hasEvidence == nil || !hasEvidence(e.Framework, e.Control, e.EvidenceType) {
|
|
||||||
missing = append(missing, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(missing) > 0 {
|
|
||||||
anyMissing = true
|
|
||||||
}
|
|
||||||
st.Controls = append(st.Controls, ObligationControlStatus{
|
|
||||||
Framework: m.TargetFramework,
|
|
||||||
Control: m.TargetControl,
|
|
||||||
MappingType: m.MappingType,
|
|
||||||
RequiredEvidence: req,
|
|
||||||
MissingEvidence: missing,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if anyMissing {
|
|
||||||
st.Status = "offen"
|
|
||||||
} else {
|
|
||||||
st.Status = "erfuellt"
|
|
||||||
}
|
|
||||||
return st
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func loadGraph(t *testing.T) (*ObligationJoinKeys, *ControlMappingSet, *EvidenceRequirementSet) {
|
|
||||||
t.Helper()
|
|
||||||
joins, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("join keys: %v", err)
|
|
||||||
}
|
|
||||||
maps, err := LoadControlMappings("../../data/control_mappings")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mappings: %v", err)
|
|
||||||
}
|
|
||||||
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("evidence: %v", err)
|
|
||||||
}
|
|
||||||
return joins, maps, ev
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssessObligationStatus(t *testing.T) {
|
|
||||||
joins, maps, ev := loadGraph(t)
|
|
||||||
|
|
||||||
// covered obligation, no evidence collected yet (MVP) -> offen
|
|
||||||
st := AssessObligationStatus(joins, maps, ev, "user_authentication_required", nil)
|
|
||||||
if st.Status != "offen" {
|
|
||||||
t.Errorf("want offen, got %q", st.Status)
|
|
||||||
}
|
|
||||||
if len(st.Controls) == 0 {
|
|
||||||
t.Fatal("expected controls for a covered obligation")
|
|
||||||
}
|
|
||||||
for _, c := range st.Controls {
|
|
||||||
if len(c.MissingEvidence) != len(c.RequiredEvidence) {
|
|
||||||
t.Error("MVP: all required evidence should be missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Logf("DURCHSTICH user_authentication_required: status=%s legal_basis=%v citation_spans=%s",
|
|
||||||
st.Status, st.LegalBasis, st.CitationSpans)
|
|
||||||
for _, c := range st.Controls {
|
|
||||||
t.Logf(" %s %s (%s): %d required evidence, %d missing", c.Framework, c.Control, c.MappingType, len(c.RequiredEvidence), len(c.MissingEvidence))
|
|
||||||
}
|
|
||||||
|
|
||||||
// all evidence present -> erfuellt
|
|
||||||
st2 := AssessObligationStatus(joins, maps, ev, "user_authentication_required", func(f, c, et string) bool { return true })
|
|
||||||
if st2.Status != "erfuellt" {
|
|
||||||
t.Errorf("want erfuellt with all evidence present, got %q", st2.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// uncovered obligation (no accepted control reaches it) -> unklar
|
|
||||||
if st3 := AssessObligationStatus(joins, maps, ev, "sbom_creation", nil); st3.Status != "unklar" {
|
|
||||||
t.Errorf("uncovered sbom_creation: want unklar, got %q", st3.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// unknown obligation_id -> unklar
|
|
||||||
if st4 := AssessObligationStatus(joins, maps, ev, "does_not_exist", nil); st4.Status != "unklar" {
|
|
||||||
t.Errorf("unknown obligation: want unklar, got %q", st4.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ControlMapping is one persisted, versioned, REVIEWABLE link from a legal
|
|
||||||
// obligation/requirement to a concrete framework control — a node in the curated
|
|
||||||
// compliance graph (Regulation -> Obligation -> Control -> Evidence). The retriever only
|
|
||||||
// PROPOSES candidates (mapping_status=candidate); a human/rule decision turns the good ones
|
|
||||||
// into mapping_status=accepted, which is the audited truth the Advisor uses at runtime.
|
|
||||||
//
|
|
||||||
// There is intentionally NO probabilistic "confidence" field: once curated, a mapping is a
|
|
||||||
// professional statement, not an AI guess. The retriever's score lives only in the rationale
|
|
||||||
// of a candidate, never as structured truth.
|
|
||||||
type ControlMapping struct {
|
|
||||||
SourceNorm string `json:"source_norm"` // e.g. "CRA Annex I Part I (2)(c)"
|
|
||||||
SourceRole string `json:"source_role"` // source_role of the norm (operational_requirement, ...)
|
|
||||||
TargetFramework string `json:"target_framework"` // e.g. "OWASP ASVS"
|
|
||||||
TargetControl string `json:"target_control"` // e.g. "V6.3.1"
|
|
||||||
MappingType string `json:"mapping_type"` // primary_implementation | implements | supports | partially_supports | related | contradicts
|
|
||||||
MappingStatus string `json:"mapping_status"` // candidate | accepted | rejected | superseded
|
|
||||||
Provenance string `json:"provenance"` // retriever_candidate | human_curated | rule_based
|
|
||||||
ObligationID string `json:"obligation_id,omitempty"` // stable cross-session join key (Obligation Registry); empty until adopted, citation_unit is the interim bridge
|
|
||||||
Rationale string `json:"rationale"`
|
|
||||||
ReviewedBy string `json:"reviewed_by,omitempty"` // who decided (human or rule id)
|
|
||||||
ReviewDate string `json:"review_date,omitempty"` // YYYY-MM-DD
|
|
||||||
ReviewReason string `json:"review_reason,omitempty"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowed enum values — the deterministic "rule" layer that keeps the curated store clean.
|
|
||||||
var (
|
|
||||||
mappingTypeValues = map[string]bool{"primary_implementation": true, "implements": true, "supports": true, "partially_supports": true, "related": true, "contradicts": true}
|
|
||||||
mappingStatusValues = map[string]bool{"candidate": true, "accepted": true, "rejected": true, "superseded": true}
|
|
||||||
provenanceValues = map[string]bool{"retriever_candidate": true, "human_curated": true, "rule_based": true}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validate checks required fields + enum membership, and enforces the audit trail: any
|
|
||||||
// human/rule DECISION (accepted/rejected) must carry who/when/why. Fail-closed at load.
|
|
||||||
func (m ControlMapping) Validate() error {
|
|
||||||
switch {
|
|
||||||
case m.SourceNorm == "":
|
|
||||||
return fmt.Errorf("control mapping: source_norm required")
|
|
||||||
case m.TargetFramework == "":
|
|
||||||
return fmt.Errorf("control mapping: target_framework required")
|
|
||||||
case m.TargetControl == "":
|
|
||||||
return fmt.Errorf("control mapping: target_control required")
|
|
||||||
case !mappingTypeValues[m.MappingType]:
|
|
||||||
return fmt.Errorf("control mapping: invalid mapping_type %q", m.MappingType)
|
|
||||||
case !mappingStatusValues[m.MappingStatus]:
|
|
||||||
return fmt.Errorf("control mapping: invalid mapping_status %q", m.MappingStatus)
|
|
||||||
case !provenanceValues[m.Provenance]:
|
|
||||||
return fmt.Errorf("control mapping: invalid provenance %q", m.Provenance)
|
|
||||||
}
|
|
||||||
if m.MappingStatus == "accepted" || m.MappingStatus == "rejected" {
|
|
||||||
if m.ReviewedBy == "" || m.ReviewDate == "" || m.ReviewReason == "" {
|
|
||||||
return fmt.Errorf("control mapping %s->%s: status %q requires reviewed_by + review_date + review_reason (audit trail)",
|
|
||||||
m.SourceNorm, m.TargetControl, m.MappingStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAccepted reports whether this mapping is the active audited truth.
|
|
||||||
func (m ControlMapping) IsAccepted() bool { return m.MappingStatus == "accepted" }
|
|
||||||
|
|
||||||
// ControlMappingSet is the loaded, indexed mapping store (forward + reverse lookup).
|
|
||||||
type ControlMappingSet struct {
|
|
||||||
All []ControlMapping
|
|
||||||
bySourceNorm map[string][]ControlMapping
|
|
||||||
byControl map[string][]ControlMapping
|
|
||||||
}
|
|
||||||
|
|
||||||
func controlKey(framework, control string) string { return framework + ":" + control }
|
|
||||||
|
|
||||||
// ControlsFor returns the controls mapped to a source norm. acceptedOnly restricts to the
|
|
||||||
// audited truth (what the Advisor may treat as fact).
|
|
||||||
func (s *ControlMappingSet) ControlsFor(sourceNorm string, acceptedOnly bool) []ControlMapping {
|
|
||||||
return filterAccepted(s.bySourceNorm[sourceNorm], acceptedOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObligationsFor returns the norms mapped to a framework control (reverse lookup).
|
|
||||||
func (s *ControlMappingSet) ObligationsFor(framework, control string, acceptedOnly bool) []ControlMapping {
|
|
||||||
return filterAccepted(s.byControl[controlKey(framework, control)], acceptedOnly)
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterAccepted(in []ControlMapping, acceptedOnly bool) []ControlMapping {
|
|
||||||
if !acceptedOnly {
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
out := make([]ControlMapping, 0, len(in))
|
|
||||||
for _, m := range in {
|
|
||||||
if m.IsAccepted() {
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadControlMappings reads every *.jsonl file under dir (one mapping per line; blank and
|
|
||||||
// //-prefixed lines ignored), validates each row, and builds the index. An invalid row
|
|
||||||
// aborts the whole load — fail-closed, because this is the audit truth, not best-effort.
|
|
||||||
func LoadControlMappings(dir string) (*ControlMappingSet, error) {
|
|
||||||
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
set := &ControlMappingSet{
|
|
||||||
bySourceNorm: map[string][]ControlMapping{},
|
|
||||||
byControl: map[string][]ControlMapping{},
|
|
||||||
}
|
|
||||||
for _, f := range files {
|
|
||||||
fh, err := os.Open(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sc := bufio.NewScanner(fh)
|
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
||||||
line := 0
|
|
||||||
for sc.Scan() {
|
|
||||||
line++
|
|
||||||
raw := strings.TrimSpace(sc.Text())
|
|
||||||
if raw == "" || strings.HasPrefix(raw, "//") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var m ControlMapping
|
|
||||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
||||||
fh.Close()
|
|
||||||
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
|
||||||
}
|
|
||||||
if err := m.Validate(); err != nil {
|
|
||||||
fh.Close()
|
|
||||||
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
|
||||||
}
|
|
||||||
set.All = append(set.All, m)
|
|
||||||
set.bySourceNorm[m.SourceNorm] = append(set.bySourceNorm[m.SourceNorm], m)
|
|
||||||
k := controlKey(m.TargetFramework, m.TargetControl)
|
|
||||||
set.byControl[k] = append(set.byControl[k], m)
|
|
||||||
}
|
|
||||||
fh.Close()
|
|
||||||
if err := sc.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return set, nil
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestControlMapping_Validate(t *testing.T) {
|
|
||||||
candidate := ControlMapping{SourceNorm: "CRA Annex I", TargetFramework: "OWASP ASVS", TargetControl: "V6.3.1", MappingType: "supports", MappingStatus: "candidate", Provenance: "retriever_candidate"}
|
|
||||||
if err := candidate.Validate(); err != nil {
|
|
||||||
t.Fatalf("valid candidate rejected: %v", err)
|
|
||||||
}
|
|
||||||
accepted := ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "implements", MappingStatus: "accepted", Provenance: "human_curated", ReviewedBy: "benjamin", ReviewDate: "2026-06-25", ReviewReason: "passt"}
|
|
||||||
if err := accepted.Validate(); err != nil {
|
|
||||||
t.Fatalf("valid accepted rejected: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bad := []struct {
|
|
||||||
name string
|
|
||||||
m ControlMapping
|
|
||||||
}{
|
|
||||||
{"no source_norm", ControlMapping{TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "candidate", Provenance: "retriever_candidate"}},
|
|
||||||
{"bad mapping_type", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "nope", MappingStatus: "candidate", Provenance: "retriever_candidate"}},
|
|
||||||
{"bad mapping_status", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "maybe", Provenance: "retriever_candidate"}},
|
|
||||||
{"bad provenance", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "candidate", Provenance: "guessed"}},
|
|
||||||
{"accepted without audit trail", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "accepted", Provenance: "human_curated"}},
|
|
||||||
{"rejected without reason", ControlMapping{SourceNorm: "A", TargetFramework: "X", TargetControl: "Y", MappingType: "supports", MappingStatus: "rejected", Provenance: "human_curated", ReviewedBy: "b", ReviewDate: "2026-06-25"}},
|
|
||||||
}
|
|
||||||
for _, tt := range bad {
|
|
||||||
if err := tt.m.Validate(); err == nil {
|
|
||||||
t.Errorf("%s: expected rejection", tt.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadControlMappings(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
content := `// header comment, ignored
|
|
||||||
{"source_norm":"CRA Annex I","source_role":"operational_requirement","target_framework":"OWASP ASVS","target_control":"V6.3.1","mapping_type":"supports","mapping_status":"accepted","provenance":"human_curated","reviewed_by":"benjamin","review_date":"2026-06-25","review_reason":"V6=Auth passt","rationale":"r","version":"2026-06-25"}
|
|
||||||
{"source_norm":"CRA Annex I","source_role":"operational_requirement","target_framework":"OWASP ASVS","target_control":"V14.2.4","mapping_type":"related","mapping_status":"candidate","provenance":"retriever_candidate","rationale":"r","version":"2026-06-25"}
|
|
||||||
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "m.jsonl"), []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
set, err := LoadControlMappings(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load: %v", err)
|
|
||||||
}
|
|
||||||
if len(set.All) != 2 {
|
|
||||||
t.Fatalf("want 2 mappings, got %d", len(set.All))
|
|
||||||
}
|
|
||||||
if got := set.ControlsFor("CRA Annex I", false); len(got) != 2 {
|
|
||||||
t.Errorf("ControlsFor(all): want 2, got %d", len(got))
|
|
||||||
}
|
|
||||||
if got := set.ControlsFor("CRA Annex I", true); len(got) != 1 {
|
|
||||||
t.Errorf("ControlsFor(acceptedOnly): want 1 (only accepted), got %d", len(got))
|
|
||||||
}
|
|
||||||
if got := set.ObligationsFor("OWASP ASVS", "V6.3.1", true); len(got) != 1 {
|
|
||||||
t.Errorf("ObligationsFor accepted reverse lookup: want 1, got %d", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadControlMappings_RejectsInvalid(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
// accepted without the who/when/why audit trail must fail-closed.
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "bad.jsonl"), []byte(`{"source_norm":"A","target_framework":"X","target_control":"Y","mapping_type":"supports","mapping_status":"accepted","provenance":"human_curated","rationale":"r","version":"v"}`), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := LoadControlMappings(dir); err == nil {
|
|
||||||
t.Error("accepted mapping without audit trail must fail the load (fail-closed)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlMappings_SeedFileValid(t *testing.T) {
|
|
||||||
// The committed seed store must always load + validate.
|
|
||||||
set, err := LoadControlMappings("../../data/control_mappings")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seed control_mappings failed to load: %v", err)
|
|
||||||
}
|
|
||||||
if len(set.All) == 0 {
|
|
||||||
t.Fatal("seed control_mappings is empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// source_role is the FUNCTIONAL role of a chunk — WHAT must be done (obligation),
|
|
||||||
// HOW to implement it (operational/procedural requirement, control standard,
|
|
||||||
// implementation guidance), or how to READ the norm (interpretation/definition).
|
|
||||||
// It is ORTHOGONAL to source_class (legal authority): source_class decides RANK,
|
|
||||||
// source_role decides CONTROL-POOL membership for implementation questions.
|
|
||||||
// Derived deterministically from markers, so the untagged corpus needs no re-tag.
|
|
||||||
const (
|
|
||||||
roleObligation = "obligation" // the abstract duty (the WHAT)
|
|
||||||
roleOperationalReq = "operational_requirement" // concrete binding requirement (CRA Annex I)
|
|
||||||
roleProceduralReq = "procedural_requirement" // a process: notification/registration/DPIA/incident report
|
|
||||||
roleControlStandard = "control_standard" // best-practice control catalog (NIST/OWASP/ISO/CIS)
|
|
||||||
roleImplGuidance = "implementation_guidance" // advisory how-to (ENISA good practices, BSI)
|
|
||||||
roleInterpretation = "interpretation" // interprets the norm's MEANING (EDPB guideline)
|
|
||||||
roleDefinition = "definition" // definitions / scope / recitals
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
proceduralMarkers = []string{
|
|
||||||
"Meldung", "Meldepflicht", "Notification", "Notifizierung", "Registrierung",
|
|
||||||
"Registration", "Konformitätserklärung", "Declaration of Conformity", "Incident",
|
|
||||||
"Berichterstattung", "Reporting", "Folgenabschätzung", "DSFA", "DPIA", "Anzeigepflicht",
|
|
||||||
}
|
|
||||||
annexMarkers = []string{"Anhang", "Annex", "Appendix", "Anlage"}
|
|
||||||
operationalMarkers = []string{"Anforderung", "Requirement", "essential", "wesentliche"}
|
|
||||||
implMarkers = []string{
|
|
||||||
"Good Practice", "Best Practice", "Standards Mapping", "Umsetzung", "Implementation",
|
|
||||||
"Handreichung", "Maßnahmenkatalog", "ICS", "SCADA", "Technical Guideline", "TIG",
|
|
||||||
}
|
|
||||||
definitionMarkers = []string{"Begriffsbestimmung", "Definition"}
|
|
||||||
)
|
|
||||||
|
|
||||||
// classifyRole derives the functional source_role from chunk metadata + the authority
|
|
||||||
// class. technical_standard is always a control_standard; guidance splits into
|
|
||||||
// implementation_guidance (how-to) vs interpretation (meaning); binding splits into
|
|
||||||
// procedural / operational requirement / definition / plain obligation.
|
|
||||||
func classifyRole(r LegalSearchResult) string {
|
|
||||||
cls := classifyAuthority(r).sourceClass
|
|
||||||
hay := strings.ToLower(r.ArticleLabel + " " + r.RegulationShort + " " + r.RegulationName + " " + r.Article)
|
|
||||||
switch {
|
|
||||||
case r.IsRecital:
|
|
||||||
return roleDefinition
|
|
||||||
case cls == "technical_standard":
|
|
||||||
return roleControlStandard
|
|
||||||
case cls == "supervisory_guidance":
|
|
||||||
if containsAnyLower(hay, implMarkers) {
|
|
||||||
return roleImplGuidance
|
|
||||||
}
|
|
||||||
return roleInterpretation
|
|
||||||
case cls == "binding_law":
|
|
||||||
switch {
|
|
||||||
case containsAnyLower(hay, definitionMarkers):
|
|
||||||
return roleDefinition
|
|
||||||
case containsAnyLower(hay, proceduralMarkers):
|
|
||||||
return roleProceduralReq
|
|
||||||
case containsAnyLower(hay, annexMarkers) || containsAnyLower(hay, operationalMarkers):
|
|
||||||
return roleOperationalReq
|
|
||||||
default:
|
|
||||||
return roleObligation
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return roleObligation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// controlRoleBonus is the soft intra-pool preference (User 2026-06-24):
|
|
||||||
// operational_requirement > procedural_requirement > control_standard > implementation_guidance.
|
|
||||||
var controlRoleBonus = map[string]float64{
|
|
||||||
roleOperationalReq: 0.100,
|
|
||||||
roleProceduralReq: 0.075,
|
|
||||||
roleControlStandard: 0.050,
|
|
||||||
roleImplGuidance: 0.000,
|
|
||||||
}
|
|
||||||
|
|
||||||
// controlPoolGain lifts EVERY control-pool role over the non-control roles (obligation/
|
|
||||||
// interpretation/definition) on an implementation question, so the binding abstract
|
|
||||||
// obligation does not dominate by authority alone. The obligation is not removed — it
|
|
||||||
// stays visible as "Rechtsgrundlage" context below the recommended measures.
|
|
||||||
const controlPoolGain = 0.15
|
|
||||||
|
|
||||||
// applyControlRoles boosts the control-pool (the four implementation roles) for an
|
|
||||||
// EXPLICIT implementation question, soft-ordered op_req > procedural > standard > guidance.
|
|
||||||
// Replaces the earlier "lift technical_standard above binding" — controls are not only
|
|
||||||
// technical_standard, and the binding operational_requirement (e.g. CRA Annex I) should win.
|
|
||||||
func applyControlRoles(out []LegalSearchResult) {
|
|
||||||
for i := range out {
|
|
||||||
if bonus, ok := controlRoleBonus[classifyRole(out[i])]; ok {
|
|
||||||
out[i].Score += controlPoolGain + bonus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isControlPoolRole reports whether a role belongs to the control-pool surfaced on
|
|
||||||
// implementation questions (the four "how to implement" roles).
|
|
||||||
func isControlPoolRole(role string) bool {
|
|
||||||
switch role {
|
|
||||||
case roleOperationalReq, roleProceduralReq, roleControlStandard, roleImplGuidance:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// controlRoleOf classifies a raw Qdrant payload into a source_role, so searchControls can
|
|
||||||
// filter its deep dense pull to the control-pool BEFORE hits are mapped to LegalSearchResult.
|
|
||||||
func controlRoleOf(payload map[string]interface{}) string {
|
|
||||||
article := getString(payload, "article")
|
|
||||||
if article == "" {
|
|
||||||
article = getString(payload, "section")
|
|
||||||
}
|
|
||||||
return classifyRole(LegalSearchResult{
|
|
||||||
RegulationShort: getString(payload, "regulation_short"),
|
|
||||||
RegulationName: getString(payload, "regulation_name_de"),
|
|
||||||
ArticleLabel: getString(payload, "article_label"),
|
|
||||||
Article: article,
|
|
||||||
Category: getString(payload, "category"),
|
|
||||||
SourceClass: getString(payload, "source_class"),
|
|
||||||
AuthorityWeight: getInt(payload, "authority_weight"),
|
|
||||||
IsRecital: getBool(payload, "is_recital"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureControlDiversity guarantees that the returned top-K of a control question surfaces at
|
|
||||||
// least one operational_requirement and one control_standard WHEN the pool contains them —
|
|
||||||
// without forcing them to Top-1. implementation_guidance (e.g. ENISA good practices) keeps its
|
|
||||||
// earned semantic lead; the rule only promotes the best hit of a missing control role into the
|
|
||||||
// top-K by overwriting the lowest-ranked redundant guidance slot. So an implementation question
|
|
||||||
// shows the relevant source ROLES (binding requirement + standard + guidance) side by side
|
|
||||||
// instead of one role flooding the list. The promoted hit's original (now duplicate) position
|
|
||||||
// stays in the tail and is dropped by the caller's truncation to topK.
|
|
||||||
func ensureControlDiversity(results []LegalSearchResult, topK int) []LegalSearchResult {
|
|
||||||
if topK <= 0 || topK >= len(results) {
|
|
||||||
return results // everything is already returned — nothing to promote
|
|
||||||
}
|
|
||||||
roleAt := make([]string, len(results))
|
|
||||||
for i := range results {
|
|
||||||
roleAt[i] = classifyRole(results[i])
|
|
||||||
}
|
|
||||||
present := make(map[string]bool, topK)
|
|
||||||
for i := 0; i < topK; i++ {
|
|
||||||
present[roleAt[i]] = true
|
|
||||||
}
|
|
||||||
for _, want := range []string{roleOperationalReq, roleControlStandard} {
|
|
||||||
if present[want] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
src := -1
|
|
||||||
for i := topK; i < len(results); i++ {
|
|
||||||
if roleAt[i] == want {
|
|
||||||
src = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if src < 0 {
|
|
||||||
continue // role absent from the whole pool — nothing to promote
|
|
||||||
}
|
|
||||||
dst := -1
|
|
||||||
for j := topK - 1; j >= 0; j-- {
|
|
||||||
if roleAt[j] == roleImplGuidance {
|
|
||||||
dst = j
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if dst < 0 {
|
|
||||||
continue // no redundant guidance to sacrifice — leave the head untouched
|
|
||||||
}
|
|
||||||
results[dst] = results[src]
|
|
||||||
roleAt[dst] = want
|
|
||||||
present[want] = true
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestClassifyRole(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
r LegalSearchResult
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"NIST -> control_standard", LegalSearchResult{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8"}, roleControlStandard},
|
|
||||||
{"OWASP -> control_standard", LegalSearchResult{RegulationShort: "OWASP ASVS"}, roleControlStandard},
|
|
||||||
{"CRA Anhang -> operational_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation"}, roleOperationalReq},
|
|
||||||
{"CRA Meldepflicht -> procedural_requirement", LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, roleProceduralReq},
|
|
||||||
{"ENISA Good Practices -> implementation_guidance", LegalSearchResult{RegulationShort: "ENISA Supply Chain Good Practices"}, roleImplGuidance},
|
|
||||||
{"EDPB Leitlinie -> interpretation", LegalSearchResult{RegulationShort: "EDPB DPO", ArticleLabel: "WP243 Leitlinien Datenschutzbeauftragte"}, roleInterpretation},
|
|
||||||
{"DORA article -> obligation", LegalSearchResult{RegulationShort: "DORA", ArticleLabel: "Art. 5 DORA", Category: "regulation"}, roleObligation},
|
|
||||||
{"DSGVO Begriffsbestimmungen -> definition", LegalSearchResult{RegulationShort: "DSGVO", ArticleLabel: "Art. 4 DSGVO Begriffsbestimmungen", Category: "regulation"}, roleDefinition},
|
|
||||||
{"recital -> definition", LegalSearchResult{RegulationShort: "CRA", IsRecital: true}, roleDefinition},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := classifyRole(tt.r); got != tt.want {
|
|
||||||
t.Errorf("classifyRole() = %q, want %q", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyControlRoles_PoolPreference(t *testing.T) {
|
|
||||||
// op_req > procedural > control_standard > impl_guidance; non-control roles get no boost.
|
|
||||||
roles := []struct {
|
|
||||||
r LegalSearchResult
|
|
||||||
wantGain float64
|
|
||||||
}{
|
|
||||||
{LegalSearchResult{ArticleLabel: "CRA Anhang I", Category: "regulation"}, controlPoolGain + 0.100},
|
|
||||||
{LegalSearchResult{ArticleLabel: "Art. 14 CRA Meldepflicht", Category: "regulation"}, controlPoolGain + 0.075},
|
|
||||||
{LegalSearchResult{RegulationShort: "NIST SP 800-53"}, controlPoolGain + 0.050},
|
|
||||||
{LegalSearchResult{RegulationShort: "ENISA Good Practices"}, controlPoolGain + 0.000},
|
|
||||||
{LegalSearchResult{ArticleLabel: "Art. 5 DORA", Category: "regulation"}, 0.0}, // obligation: no boost
|
|
||||||
}
|
|
||||||
for _, rc := range roles {
|
|
||||||
out := []LegalSearchResult{rc.r}
|
|
||||||
out[0].Score = 1.0
|
|
||||||
applyControlRoles(out)
|
|
||||||
if got := out[0].Score - 1.0; got < rc.wantGain-1e-9 || got > rc.wantGain+1e-9 {
|
|
||||||
t.Errorf("role %q: gain %.3f, want %.3f", classifyRole(rc.r), got, rc.wantGain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsControlPoolRole(t *testing.T) {
|
|
||||||
for _, r := range []string{roleOperationalReq, roleProceduralReq, roleControlStandard, roleImplGuidance} {
|
|
||||||
if !isControlPoolRole(r) {
|
|
||||||
t.Errorf("%q should be in the control-pool", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range []string{roleObligation, roleInterpretation, roleDefinition} {
|
|
||||||
if isControlPoolRole(r) {
|
|
||||||
t.Errorf("%q should NOT be in the control-pool", r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestControlRoleOf_Payload(t *testing.T) {
|
|
||||||
// searchControls filters its deep dense pull by classifying the raw Qdrant payload.
|
|
||||||
nist := map[string]interface{}{"regulation_short": "NIST SP 800-82r3", "article": "AU-8"}
|
|
||||||
if got := controlRoleOf(nist); got != roleControlStandard {
|
|
||||||
t.Errorf("untagged NIST payload role = %q, want control_standard", got)
|
|
||||||
}
|
|
||||||
craAnnex := map[string]interface{}{"regulation_short": "CRA", "article": "Anhang-I", "category": "regulation"}
|
|
||||||
if got := controlRoleOf(craAnnex); got != roleOperationalReq {
|
|
||||||
t.Errorf("CRA Anhang payload role = %q, want operational_requirement", got)
|
|
||||||
}
|
|
||||||
dora := map[string]interface{}{"regulation_short": "DORA", "article_label": "Art. 5 DORA", "category": "regulation"}
|
|
||||||
if got := controlRoleOf(dora); isControlPoolRole(got) {
|
|
||||||
t.Errorf("DORA abstract article role = %q must be excluded from the control-pool", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func headHasRole(head []LegalSearchResult, role string) bool {
|
|
||||||
for _, r := range head {
|
|
||||||
if classifyRole(r) == role {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureControlDiversity(t *testing.T) {
|
|
||||||
ig := func(n string) LegalSearchResult {
|
|
||||||
return LegalSearchResult{RegulationShort: "ENISA " + n + " Good Practices"}
|
|
||||||
}
|
|
||||||
opReq := LegalSearchResult{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation"}
|
|
||||||
std := LegalSearchResult{RegulationShort: "NIST SP 800-53"}
|
|
||||||
|
|
||||||
t.Run("injects missing op_req + control_standard, guidance keeps Top-1", func(t *testing.T) {
|
|
||||||
out := ensureControlDiversity([]LegalSearchResult{ig("A"), ig("B"), ig("C"), std, opReq}, 3)
|
|
||||||
head := out[:3]
|
|
||||||
if classifyRole(head[0]) != roleImplGuidance {
|
|
||||||
t.Errorf("Top-1 should stay implementation_guidance, got %q", classifyRole(head[0]))
|
|
||||||
}
|
|
||||||
if !headHasRole(head, roleOperationalReq) {
|
|
||||||
t.Error("top-K must contain an operational_requirement after diversity")
|
|
||||||
}
|
|
||||||
if !headHasRole(head, roleControlStandard) {
|
|
||||||
t.Error("top-K must contain a control_standard after diversity")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no-op when both roles already present", func(t *testing.T) {
|
|
||||||
out := ensureControlDiversity([]LegalSearchResult{opReq, std, ig("A"), ig("B")}, 3)
|
|
||||||
if classifyRole(out[0]) != roleOperationalReq || classifyRole(out[1]) != roleControlStandard {
|
|
||||||
t.Error("already-diverse top-K must be left untouched")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("absent role is not forced (no panic)", func(t *testing.T) {
|
|
||||||
out := ensureControlDiversity([]LegalSearchResult{ig("A"), ig("B"), ig("C"), std}, 3)
|
|
||||||
if !headHasRole(out[:3], roleControlStandard) {
|
|
||||||
t.Error("present control_standard should be injected")
|
|
||||||
}
|
|
||||||
if headHasRole(out[:3], roleOperationalReq) {
|
|
||||||
t.Error("operational_requirement absent from the pool must NOT appear")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("topK covering the whole pool is unchanged", func(t *testing.T) {
|
|
||||||
out := ensureControlDiversity([]LegalSearchResult{ig("A"), opReq}, 5)
|
|
||||||
if len(out) != 2 || classifyRole(out[0]) != roleImplGuidance {
|
|
||||||
t.Error("topK >= len must return results unchanged")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EvidenceRequirement is the last edge of the compliance graph: it says WHAT concrete
|
|
||||||
// evidence proves a framework control is met, and how fresh that evidence must be. This is
|
|
||||||
// what lets the Advisor eventually state "the CRA requirement is fulfilled" — not because a
|
|
||||||
// document exists, but because the required, current evidence is present. Authored/curated,
|
|
||||||
// not retriever-generated.
|
|
||||||
type EvidenceRequirement struct {
|
|
||||||
Framework string `json:"framework"` // e.g. "OWASP ASVS"
|
|
||||||
Control string `json:"control"` // e.g. "V6.3.1"
|
|
||||||
EvidenceType string `json:"evidence_type"` // sbom|test_report|config_export|repo_scan|policy|ticket|audit_log|pentest
|
|
||||||
EvidenceSource string `json:"evidence_source"` // github|ci|scanner|manual_upload
|
|
||||||
FreshnessRequirement string `json:"freshness_requirement"` // per_release|quarterly|annually|continuous
|
|
||||||
Required bool `json:"required"`
|
|
||||||
Rationale string `json:"rationale"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allowed enum values — the rule layer that keeps the evidence catalog clean.
|
|
||||||
var (
|
|
||||||
evidenceTypeValues = map[string]bool{"sbom": true, "test_report": true, "config_export": true, "repo_scan": true, "policy": true, "ticket": true, "audit_log": true, "pentest": true}
|
|
||||||
evidenceSourceValues = map[string]bool{"github": true, "ci": true, "scanner": true, "manual_upload": true}
|
|
||||||
freshnessValues = map[string]bool{"per_release": true, "quarterly": true, "annually": true, "continuous": true}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Validate checks required fields + enum membership. Fail-closed at load.
|
|
||||||
func (e EvidenceRequirement) Validate() error {
|
|
||||||
switch {
|
|
||||||
case e.Framework == "":
|
|
||||||
return fmt.Errorf("evidence requirement: framework required")
|
|
||||||
case e.Control == "":
|
|
||||||
return fmt.Errorf("evidence requirement: control required")
|
|
||||||
case !evidenceTypeValues[e.EvidenceType]:
|
|
||||||
return fmt.Errorf("evidence requirement: invalid evidence_type %q", e.EvidenceType)
|
|
||||||
case !evidenceSourceValues[e.EvidenceSource]:
|
|
||||||
return fmt.Errorf("evidence requirement: invalid evidence_source %q", e.EvidenceSource)
|
|
||||||
case !freshnessValues[e.FreshnessRequirement]:
|
|
||||||
return fmt.Errorf("evidence requirement: invalid freshness_requirement %q", e.FreshnessRequirement)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EvidenceRequirementSet is the loaded, indexed evidence catalog.
|
|
||||||
type EvidenceRequirementSet struct {
|
|
||||||
All []EvidenceRequirement
|
|
||||||
byControl map[string][]EvidenceRequirement
|
|
||||||
}
|
|
||||||
|
|
||||||
// For returns all evidence requirements declared for a framework control.
|
|
||||||
func (s *EvidenceRequirementSet) For(framework, control string) []EvidenceRequirement {
|
|
||||||
return s.byControl[controlKey(framework, control)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequiredFor returns only the required evidence for a control — the minimum that must be
|
|
||||||
// present before the control may be treated as met.
|
|
||||||
func (s *EvidenceRequirementSet) RequiredFor(framework, control string) []EvidenceRequirement {
|
|
||||||
out := make([]EvidenceRequirement, 0)
|
|
||||||
for _, e := range s.byControl[controlKey(framework, control)] {
|
|
||||||
if e.Required {
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadEvidenceRequirements reads every *.jsonl file under dir (one requirement per line;
|
|
||||||
// blank and //-prefixed lines ignored), validates each, and builds the per-control index.
|
|
||||||
// An invalid row aborts the load — fail-closed.
|
|
||||||
func LoadEvidenceRequirements(dir string) (*EvidenceRequirementSet, error) {
|
|
||||||
files, err := filepath.Glob(filepath.Join(dir, "*.jsonl"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
set := &EvidenceRequirementSet{byControl: map[string][]EvidenceRequirement{}}
|
|
||||||
for _, f := range files {
|
|
||||||
fh, err := os.Open(f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sc := bufio.NewScanner(fh)
|
|
||||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
||||||
line := 0
|
|
||||||
for sc.Scan() {
|
|
||||||
line++
|
|
||||||
raw := strings.TrimSpace(sc.Text())
|
|
||||||
if raw == "" || strings.HasPrefix(raw, "//") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var e EvidenceRequirement
|
|
||||||
if err := json.Unmarshal([]byte(raw), &e); err != nil {
|
|
||||||
fh.Close()
|
|
||||||
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
|
||||||
}
|
|
||||||
if err := e.Validate(); err != nil {
|
|
||||||
fh.Close()
|
|
||||||
return nil, fmt.Errorf("%s:%d: %w", f, line, err)
|
|
||||||
}
|
|
||||||
set.All = append(set.All, e)
|
|
||||||
k := controlKey(e.Framework, e.Control)
|
|
||||||
set.byControl[k] = append(set.byControl[k], e)
|
|
||||||
}
|
|
||||||
fh.Close()
|
|
||||||
if err := sc.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return set, nil
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestEvidenceRequirement_Validate(t *testing.T) {
|
|
||||||
valid := EvidenceRequirement{Framework: "OWASP ASVS", Control: "V6.3.1", EvidenceType: "config_export", EvidenceSource: "github", FreshnessRequirement: "per_release", Required: true}
|
|
||||||
if err := valid.Validate(); err != nil {
|
|
||||||
t.Fatalf("valid rejected: %v", err)
|
|
||||||
}
|
|
||||||
bad := []struct {
|
|
||||||
name string
|
|
||||||
e EvidenceRequirement
|
|
||||||
}{
|
|
||||||
{"no control", EvidenceRequirement{Framework: "X", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "per_release"}},
|
|
||||||
{"bad evidence_type", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "screenshot", EvidenceSource: "ci", FreshnessRequirement: "per_release"}},
|
|
||||||
{"bad evidence_source", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "email", FreshnessRequirement: "per_release"}},
|
|
||||||
{"bad freshness", EvidenceRequirement{Framework: "X", Control: "Y", EvidenceType: "sbom", EvidenceSource: "ci", FreshnessRequirement: "weekly"}},
|
|
||||||
}
|
|
||||||
for _, tt := range bad {
|
|
||||||
if err := tt.e.Validate(); err == nil {
|
|
||||||
t.Errorf("%s: expected rejection", tt.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadEvidenceRequirements(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
content := `// header
|
|
||||||
{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"config_export","evidence_source":"github","freshness_requirement":"per_release","required":true,"version":"2026-06-25"}
|
|
||||||
{"framework":"OWASP ASVS","control":"V6.3.1","evidence_type":"pentest","evidence_source":"manual_upload","freshness_requirement":"annually","required":false,"version":"2026-06-25"}
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "e.jsonl"), []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
set, err := LoadEvidenceRequirements(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load: %v", err)
|
|
||||||
}
|
|
||||||
if len(set.All) != 2 {
|
|
||||||
t.Fatalf("want 2, got %d", len(set.All))
|
|
||||||
}
|
|
||||||
if got := set.For("OWASP ASVS", "V6.3.1"); len(got) != 2 {
|
|
||||||
t.Errorf("For: want 2, got %d", len(got))
|
|
||||||
}
|
|
||||||
if got := set.RequiredFor("OWASP ASVS", "V6.3.1"); len(got) != 1 {
|
|
||||||
t.Errorf("RequiredFor: want 1 (pentest is optional), got %d", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvidenceRequirements_SeedFileValid(t *testing.T) {
|
|
||||||
set, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seed evidence_requirements failed to load: %v", err)
|
|
||||||
}
|
|
||||||
if len(set.All) == 0 {
|
|
||||||
t.Fatal("seed evidence_requirements is empty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGraph_AcceptedControlsHaveEvidence wires the two layers: every control an accepted
|
|
||||||
// CRA->OWASP mapping points to must have >=1 required evidence — the Obligation -> Control ->
|
|
||||||
// Evidence chain must be connected, no dangling control nodes.
|
|
||||||
func TestGraph_AcceptedControlsHaveEvidence(t *testing.T) {
|
|
||||||
maps, err := LoadControlMappings("../../data/control_mappings")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, m := range maps.All {
|
|
||||||
if !m.IsAccepted() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(ev.RequiredFor(m.TargetFramework, m.TargetControl)) == 0 {
|
|
||||||
t.Errorf("accepted control %s %s has no required evidence (dangling graph node)", m.TargetFramework, m.TargetControl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LegalActStructure is the composition of one ingested eur-lex legal act — how
|
|
||||||
// many distinct articles, annexes and recitals it consists of (plus the raw
|
|
||||||
// chunk count). Backs the coverage page so the ingested corpus is not a black
|
|
||||||
// box: a developer SEES what each act actually contains, not only its name.
|
|
||||||
type LegalActStructure struct {
|
|
||||||
RegulationShort string `json:"regulation_short"`
|
|
||||||
RegulationName string `json:"regulation_name"`
|
|
||||||
Articles int `json:"articles"`
|
|
||||||
Annexes int `json:"annexes"`
|
|
||||||
Recitals int `json:"recitals"`
|
|
||||||
Chunks int `json:"chunks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const eurlexSource = "eur-lex.europa.eu"
|
|
||||||
|
|
||||||
// legalStructureCollections hold the clean eur-lex legal corpus (chunks tagged
|
|
||||||
// with chunk_scope = section | annex | recital).
|
|
||||||
var legalStructureCollections = []string{"bp_compliance_ce", "bp_compliance_datenschutz"}
|
|
||||||
|
|
||||||
// chunkScopeBucket maps a Qdrant chunk_scope to the structure field it feeds.
|
|
||||||
var chunkScopeBucket = map[string]string{"section": "articles", "annex": "annexes", "recital": "recitals"}
|
|
||||||
|
|
||||||
// CorpusStructure scrolls the eur-lex legal corpus across the legal collections
|
|
||||||
// and aggregates the per-act composition. The source filter keeps it to a few
|
|
||||||
// hundred points regardless of total corpus size. Read-only; a collection that
|
|
||||||
// fails to scroll is skipped rather than failing the whole call.
|
|
||||||
func (c *LegalRAGClient) CorpusStructure(ctx context.Context) ([]LegalActStructure, error) {
|
|
||||||
var all []qdrantScrollPoint
|
|
||||||
for _, coll := range legalStructureCollections {
|
|
||||||
pts, err := c.scrollLegalCorpus(ctx, coll)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
all = append(all, pts...)
|
|
||||||
}
|
|
||||||
return aggregateStructure(all), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// aggregateStructure counts distinct article labels per (regulation, scope).
|
|
||||||
// Pure → unit-testable without a vector store.
|
|
||||||
func aggregateStructure(points []qdrantScrollPoint) []LegalActStructure {
|
|
||||||
distinct := map[string]map[string]map[string]struct{}{}
|
|
||||||
names := map[string]string{}
|
|
||||||
chunks := map[string]int{}
|
|
||||||
order := []string{}
|
|
||||||
|
|
||||||
for _, pt := range points {
|
|
||||||
reg := getString(pt.Payload, "regulation_short")
|
|
||||||
if reg == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, seen := names[reg]; !seen {
|
|
||||||
name := getString(pt.Payload, "regulation_name_de")
|
|
||||||
if name == "" {
|
|
||||||
name = reg
|
|
||||||
}
|
|
||||||
names[reg] = name
|
|
||||||
distinct[reg] = map[string]map[string]struct{}{}
|
|
||||||
order = append(order, reg)
|
|
||||||
}
|
|
||||||
chunks[reg]++
|
|
||||||
bucket, ok := chunkScopeBucket[getString(pt.Payload, "chunk_scope")]
|
|
||||||
article := getString(pt.Payload, "article")
|
|
||||||
if !ok || article == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if distinct[reg][bucket] == nil {
|
|
||||||
distinct[reg][bucket] = map[string]struct{}{}
|
|
||||||
}
|
|
||||||
distinct[reg][bucket][article] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]LegalActStructure, 0, len(order))
|
|
||||||
for _, reg := range order {
|
|
||||||
out = append(out, LegalActStructure{
|
|
||||||
RegulationShort: reg,
|
|
||||||
RegulationName: names[reg],
|
|
||||||
Articles: len(distinct[reg]["articles"]),
|
|
||||||
Annexes: len(distinct[reg]["annexes"]),
|
|
||||||
Recitals: len(distinct[reg]["recitals"]),
|
|
||||||
Chunks: chunks[reg],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
sort.SliceStable(out, func(i, j int) bool {
|
|
||||||
if out[i].Articles != out[j].Articles {
|
|
||||||
return out[i].Articles > out[j].Articles
|
|
||||||
}
|
|
||||||
return out[i].RegulationShort < out[j].RegulationShort
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// scrollLegalCorpus pages through one collection, filtered to the eur-lex legal
|
|
||||||
// corpus, returning minimal-payload points (no text/vectors).
|
|
||||||
func (c *LegalRAGClient) scrollLegalCorpus(ctx context.Context, collection string) ([]qdrantScrollPoint, error) {
|
|
||||||
var all []qdrantScrollPoint
|
|
||||||
var offset interface{}
|
|
||||||
for {
|
|
||||||
points, next, err := c.scrollLegalPage(ctx, collection, offset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
all = append(all, points...)
|
|
||||||
if next == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset = next
|
|
||||||
}
|
|
||||||
return all, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// scrollLegalPage fetches one page of the filtered scroll and returns the
|
|
||||||
// points plus the next-page offset (nil when exhausted).
|
|
||||||
func (c *LegalRAGClient) scrollLegalPage(ctx context.Context, collection string, offset interface{}) ([]qdrantScrollPoint, interface{}, error) {
|
|
||||||
reqBody := map[string]interface{}{
|
|
||||||
"limit": 500,
|
|
||||||
"with_payload": map[string]interface{}{"include": []string{"regulation_short", "regulation_name_de", "chunk_scope", "article"}},
|
|
||||||
"with_vectors": false,
|
|
||||||
"filter": map[string]interface{}{
|
|
||||||
"must": []map[string]interface{}{
|
|
||||||
{"key": "source", "match": map[string]interface{}{"value": eurlexSource}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if offset != nil {
|
|
||||||
reqBody["offset"] = offset
|
|
||||||
}
|
|
||||||
jsonBody, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
url := fmt.Sprintf("%s/collections/%s/points/scroll", c.qdrantURL, collection)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if c.qdrantAPIKey != "" {
|
|
||||||
req.Header.Set("api-key", c.qdrantAPIKey)
|
|
||||||
}
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, nil, fmt.Errorf("qdrant returned %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
var scrollResp qdrantScrollResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&scrollResp); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return scrollResp.Result.Points, scrollResp.Result.NextPageOffset, nil
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func structPoint(reg, name, scope, article string) qdrantScrollPoint {
|
|
||||||
return qdrantScrollPoint{Payload: map[string]interface{}{
|
|
||||||
"regulation_short": reg,
|
|
||||||
"regulation_name_de": name,
|
|
||||||
"chunk_scope": scope,
|
|
||||||
"article": article,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregateStructure_CountsDistinctPerScope(t *testing.T) {
|
|
||||||
points := []qdrantScrollPoint{
|
|
||||||
structPoint("CRA", "Cyber Resilience Act", "section", "13"),
|
|
||||||
structPoint("CRA", "Cyber Resilience Act", "section", "13"), // duplicate article → still 1
|
|
||||||
structPoint("CRA", "Cyber Resilience Act", "section", "14"),
|
|
||||||
structPoint("CRA", "Cyber Resilience Act", "annex", "Anhang-I"),
|
|
||||||
structPoint("CRA", "Cyber Resilience Act", "annex", "Anhang-VII"),
|
|
||||||
structPoint("DORA", "", "section", "6"), // first sighting has no name →
|
|
||||||
structPoint("DORA", "", "section", "19"), // regulation_name falls back to short
|
|
||||||
structPoint("DORA", "", "recital", ""), // empty article → ignored for distinct
|
|
||||||
structPoint("", "x", "section", "1"), // missing regulation → skipped entirely
|
|
||||||
}
|
|
||||||
|
|
||||||
got := aggregateStructure(points)
|
|
||||||
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("want 2 acts, got %d (%+v)", len(got), got)
|
|
||||||
}
|
|
||||||
// CRA has more articles → sorts first.
|
|
||||||
cra := got[0]
|
|
||||||
if cra.RegulationShort != "CRA" || cra.Articles != 2 || cra.Annexes != 2 || cra.Recitals != 0 || cra.Chunks != 5 {
|
|
||||||
t.Errorf("CRA wrong: %+v", cra)
|
|
||||||
}
|
|
||||||
dora := got[1]
|
|
||||||
if dora.RegulationShort != "DORA" || dora.Articles != 2 || dora.Chunks != 3 {
|
|
||||||
t.Errorf("DORA wrong: %+v", dora)
|
|
||||||
}
|
|
||||||
if dora.RegulationName != "DORA" {
|
|
||||||
t.Errorf("DORA name fallback failed: %q", dora.RegulationName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregateStructure_Empty(t *testing.T) {
|
|
||||||
if got := aggregateStructure(nil); len(got) != 0 {
|
|
||||||
t.Errorf("want empty, got %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
assessConnectedCap = 12 // cap connected norms surfaced in the assessment
|
|
||||||
assessCrossRegimeTopN = 5 // window over which "cross regime" is judged
|
|
||||||
assessReviewMargin = 0.05 // a tighter winner gap → recommend human review
|
|
||||||
)
|
|
||||||
|
|
||||||
// Assess builds the auditable explanation layer over a ranked result set:
|
|
||||||
// primary norm, the norms it connects to (citation graph), cross-regime, a
|
|
||||||
// human-review flag, the winner margin and a short reasoning string. Pure →
|
|
||||||
// unit-testable. It EXPLAINS the ranking, it does not change it. Returns nil for
|
|
||||||
// an empty result set.
|
|
||||||
func Assess(results []LegalSearchResult) *LegalAssessment {
|
|
||||||
if len(results) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Norm-level view: collapse multiple chunks of the same article/annex so the
|
|
||||||
// margin and cross-regime are judged between DISTINCT norms, not near-identical
|
|
||||||
// chunks of one norm (which would make every winner margin ~0).
|
|
||||||
norms := distinctNorms(results)
|
|
||||||
p := norms[0]
|
|
||||||
|
|
||||||
primary := primaryLabel(p)
|
|
||||||
connected := dedupStrings(p.ReferencesOut, p.ReferencesIn, p.CitationUnit)
|
|
||||||
if len(connected) > assessConnectedCap {
|
|
||||||
connected = connected[:assessConnectedCap]
|
|
||||||
}
|
|
||||||
|
|
||||||
window := norms
|
|
||||||
if len(window) > assessCrossRegimeTopN {
|
|
||||||
window = window[:assessCrossRegimeTopN]
|
|
||||||
}
|
|
||||||
regimes := make(map[string]bool)
|
|
||||||
for _, r := range window {
|
|
||||||
if r.RegulationShort != "" {
|
|
||||||
regimes[r.RegulationShort] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crossRegime := len(regimes) > 1
|
|
||||||
|
|
||||||
margin := 0.0
|
|
||||||
if len(norms) > 1 {
|
|
||||||
margin = norms[0].Score - norms[1].Score
|
|
||||||
}
|
|
||||||
|
|
||||||
primaryBinding := p.SourceClass == "binding_law"
|
|
||||||
humanReview := margin < assessReviewMargin || crossRegime || !primaryBinding
|
|
||||||
|
|
||||||
return &LegalAssessment{
|
|
||||||
PrimaryNorm: primary,
|
|
||||||
PrimaryRegulation: p.RegulationShort,
|
|
||||||
ConnectedNorms: connected,
|
|
||||||
CrossRegime: crossRegime,
|
|
||||||
HumanReviewFlag: humanReview,
|
|
||||||
WinnerMargin: margin,
|
|
||||||
ScoreReasoning: assessReasoning(p, margin, crossRegime, primaryBinding),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func primaryLabel(p LegalSearchResult) string {
|
|
||||||
if p.CitationUnit != "" {
|
|
||||||
return p.CitationUnit
|
|
||||||
}
|
|
||||||
if p.ArticleLabel != "" {
|
|
||||||
return p.ArticleLabel
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(p.RegulationShort + " " + p.Article)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assessReasoning renders a short, human-readable justification (German).
|
|
||||||
func assessReasoning(p LegalSearchResult, margin float64, crossRegime, primaryBinding bool) string {
|
|
||||||
label := primaryLabel(p)
|
|
||||||
parts := make([]string, 0, 4)
|
|
||||||
if primaryBinding {
|
|
||||||
parts = append(parts, fmt.Sprintf("Primärtreffer %s: bindendes Recht (Autorität %d).", label, p.AuthorityWeight))
|
|
||||||
} else {
|
|
||||||
parts = append(parts, fmt.Sprintf("Primärtreffer %s ist keine bindende Norm (Leitlinie/Standard) — Quelle prüfen.", label))
|
|
||||||
}
|
|
||||||
if margin > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("Vorsprung %.2f vor #2.", margin))
|
|
||||||
}
|
|
||||||
if margin < assessReviewMargin {
|
|
||||||
parts = append(parts, "Knapper Vorsprung — Alternativtreffer prüfen.")
|
|
||||||
}
|
|
||||||
if crossRegime {
|
|
||||||
parts = append(parts, "Mehrere Regime betroffen — Querbezug prüfen.")
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// distinctNorms collapses results that share a citation (multiple chunks of the
|
|
||||||
// same article/annex) to the first — i.e. highest-ranked — occurrence. Results
|
|
||||||
// without any citation identity are each kept, since they cannot be matched.
|
|
||||||
func distinctNorms(results []LegalSearchResult) []LegalSearchResult {
|
|
||||||
seen := make(map[string]bool, len(results))
|
|
||||||
out := make([]LegalSearchResult, 0, len(results))
|
|
||||||
for _, r := range results {
|
|
||||||
key := r.CitationUnit
|
|
||||||
if key == "" {
|
|
||||||
key = r.ArticleLabel
|
|
||||||
}
|
|
||||||
if key != "" {
|
|
||||||
if seen[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = true
|
|
||||||
}
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// dedupStrings concatenates out+in, drops empties and the excluded value, and
|
|
||||||
// returns a stable de-duplicated slice (insertion order preserved).
|
|
||||||
func dedupStrings(out, in []string, exclude string) []string {
|
|
||||||
seen := map[string]bool{exclude: true}
|
|
||||||
res := make([]string, 0, len(out)+len(in))
|
|
||||||
for _, list := range [][]string{out, in} {
|
|
||||||
for _, s := range list {
|
|
||||||
if s == "" || seen[s] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[s] = true
|
|
||||||
res = append(res, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func ares(reg, cu, sc string, score float64, weight int, out, in []string) LegalSearchResult {
|
|
||||||
return LegalSearchResult{
|
|
||||||
RegulationShort: reg, CitationUnit: cu, SourceClass: sc, Score: score,
|
|
||||||
AuthorityWeight: weight, ReferencesOut: out, ReferencesIn: in,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssess_Empty(t *testing.T) {
|
|
||||||
if Assess(nil) != nil {
|
|
||||||
t.Error("empty results → nil assessment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssess_BindingPrimary_NoReview(t *testing.T) {
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
ares("CRA", "Art. 13 CRA", "binding_law", 1.05, 100,
|
|
||||||
[]string{"CRA Anhang I", "Art. 14 CRA"}, []string{"Art. 12 CRA"}),
|
|
||||||
ares("CRA", "Art. 14 CRA", "binding_law", 0.80, 100, nil, nil),
|
|
||||||
}
|
|
||||||
a := Assess(results)
|
|
||||||
if a == nil {
|
|
||||||
t.Fatal("nil assessment")
|
|
||||||
}
|
|
||||||
if a.PrimaryNorm != "Art. 13 CRA" || a.PrimaryRegulation != "CRA" {
|
|
||||||
t.Errorf("primary wrong: %+v", a)
|
|
||||||
}
|
|
||||||
if len(a.ConnectedNorms) != 3 { // out(2) + in(1), self excluded, deduped
|
|
||||||
t.Errorf("connected norms: %v", a.ConnectedNorms)
|
|
||||||
}
|
|
||||||
if a.CrossRegime {
|
|
||||||
t.Error("single regime must not be cross-regime")
|
|
||||||
}
|
|
||||||
if a.WinnerMargin < 0.24 || a.WinnerMargin > 0.26 {
|
|
||||||
t.Errorf("margin = %v, want ~0.25", a.WinnerMargin)
|
|
||||||
}
|
|
||||||
if a.HumanReviewFlag {
|
|
||||||
t.Error("clean binding + healthy margin + single regime → no review")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssess_CrossRegimeFlagsReview(t *testing.T) {
|
|
||||||
a := Assess([]LegalSearchResult{
|
|
||||||
ares("CRA", "Art. 13 CRA", "binding_law", 1.05, 100, nil, nil),
|
|
||||||
ares("DORA", "Art. 6 DORA", "binding_law", 0.70, 100, nil, nil),
|
|
||||||
})
|
|
||||||
if !a.CrossRegime || !a.HumanReviewFlag {
|
|
||||||
t.Errorf("cross-regime must flag review: %+v", a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssess_NonBindingFlagsReview(t *testing.T) {
|
|
||||||
a := Assess([]LegalSearchResult{
|
|
||||||
ares("ENISA", "ENISA SBOM", "supervisory_guidance", 0.90, 70, nil, nil),
|
|
||||||
ares("ENISA", "ENISA X", "supervisory_guidance", 0.40, 70, nil, nil),
|
|
||||||
})
|
|
||||||
if !a.HumanReviewFlag {
|
|
||||||
t.Error("non-binding primary → review")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssess_TightMarginFlagsReview(t *testing.T) {
|
|
||||||
a := Assess([]LegalSearchResult{
|
|
||||||
ares("CRA", "Art. 13 CRA", "binding_law", 1.00, 100, nil, nil),
|
|
||||||
ares("CRA", "Art. 14 CRA", "binding_law", 0.98, 100, nil, nil),
|
|
||||||
})
|
|
||||||
if a.WinnerMargin >= 0.05 || !a.HumanReviewFlag {
|
|
||||||
t.Errorf("tight margin → review: %+v", a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAssess_MarginIsNormLevelNotChunkLevel(t *testing.T) {
|
|
||||||
// Two near-identical chunks of the SAME norm at the top, then a distinct norm.
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
ares("CRA", "Art. 13 CRA", "binding_law", 1.050, 100, []string{"CRA Anhang I"}, nil),
|
|
||||||
ares("CRA", "Art. 13 CRA", "binding_law", 1.049, 100, nil, nil), // same norm
|
|
||||||
ares("CRA", "Art. 14 CRA", "binding_law", 0.800, 100, nil, nil),
|
|
||||||
}
|
|
||||||
a := Assess(results)
|
|
||||||
if a.WinnerMargin < 0.24 || a.WinnerMargin > 0.26 { // Art.13 vs Art.14, not chunk vs chunk
|
|
||||||
t.Errorf("margin must be norm-level (~0.25), got %v", a.WinnerMargin)
|
|
||||||
}
|
|
||||||
if a.HumanReviewFlag {
|
|
||||||
t.Error("healthy norm-level margin → no review")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDistinctNorms(t *testing.T) {
|
|
||||||
got := distinctNorms([]LegalSearchResult{
|
|
||||||
{CitationUnit: "Art. 13 CRA"},
|
|
||||||
{CitationUnit: "Art. 13 CRA"}, // duplicate norm → collapsed
|
|
||||||
{CitationUnit: "Art. 14 CRA"},
|
|
||||||
{CitationUnit: ""}, // no identity → kept
|
|
||||||
{CitationUnit: ""}, // no identity → kept
|
|
||||||
})
|
|
||||||
if len(got) != 4 {
|
|
||||||
t.Errorf("want 4 (2 distinct + 2 unidentified), got %d", len(got))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDedupStrings(t *testing.T) {
|
|
||||||
got := dedupStrings([]string{"a", "b", "", "a"}, []string{"b", "c"}, "self")
|
|
||||||
if len(got) != 3 || got[0] != "a" || got[1] != "b" || got[2] != "c" {
|
|
||||||
t.Errorf("dedup: %v", got)
|
|
||||||
}
|
|
||||||
if len(dedupStrings([]string{"self"}, nil, "self")) != 0 {
|
|
||||||
t.Error("excluded value must be dropped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ type LegalRAGClient struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
textIndexEnsured map[string]bool
|
textIndexEnsured map[string]bool
|
||||||
hybridEnabled bool
|
hybridEnabled bool
|
||||||
graphEnabled bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLegalRAGClient creates a new Legal RAG client using Ollama bge-m3 embeddings.
|
// NewLegalRAGClient creates a new Legal RAG client using Ollama bge-m3 embeddings.
|
||||||
@@ -39,11 +38,6 @@ func NewLegalRAGClient() *LegalRAGClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hybridEnabled := os.Getenv("RAG_HYBRID_SEARCH") != "false"
|
hybridEnabled := os.Getenv("RAG_HYBRID_SEARCH") != "false"
|
||||||
// Graph-Expansion ist OPT-IN: kein gemessener Rang-Nutzen ggue. der Binding-Augmentation,
|
|
||||||
// +1 Qdrant-Call/Suche, Flutungsrisiko ueber Reverse-Kanten. Bleibt als Recall-Sicherheitsnetz
|
|
||||||
// fuer spaetere Luecken (RAG_GRAPH_EXPANSION=true). Die Graph-Kanten werden in der Response
|
|
||||||
// zur Begruendung/Vollstaendigkeit genutzt, nicht zur Pool-Expansion (Default).
|
|
||||||
graphEnabled := os.Getenv("RAG_GRAPH_EXPANSION") == "true"
|
|
||||||
|
|
||||||
return &LegalRAGClient{
|
return &LegalRAGClient{
|
||||||
qdrantURL: qdrantURL,
|
qdrantURL: qdrantURL,
|
||||||
@@ -53,7 +47,6 @@ func NewLegalRAGClient() *LegalRAGClient {
|
|||||||
collection: "bp_compliance_ce",
|
collection: "bp_compliance_ce",
|
||||||
textIndexEnsured: make(map[string]bool),
|
textIndexEnsured: make(map[string]bool),
|
||||||
hybridEnabled: hybridEnabled,
|
hybridEnabled: hybridEnabled,
|
||||||
graphEnabled: graphEnabled,
|
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -100,29 +93,6 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
|
|||||||
hits = denseHits
|
hits = denseHits
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stratified: den binding_law-Pool ERGAENZEN (nicht ersetzen), damit die Pflichtquelle
|
|
||||||
// immer Kandidat ist — Guidance bleibt als Auslegungskontext erhalten. Best-effort:
|
|
||||||
// Fehler beim Binding-Query degradieren still auf den semantischen Pool.
|
|
||||||
if bindingHits, bErr := c.searchBinding(ctx, collection, embedding, topK); bErr == nil {
|
|
||||||
hits = mergeDedupHits(hits, bindingHits)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control-Augmentation: bei expliziter Umsetzungsfrage einen tiefen dense-Pool ziehen und
|
|
||||||
// nur die Control-Pool-Rollen behalten — so werden NIST/CRA-Anhang (dense rank ~8-9, unter
|
|
||||||
// dem kleinen top-K) Kandidaten. Re-Rank/applyControlRoles ordnen sie danach.
|
|
||||||
if queryWantsControls(query) {
|
|
||||||
if controlHits, cErr := c.searchControls(ctx, collection, embedding); cErr == nil {
|
|
||||||
hits = mergeDedupHits(hits, controlHits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph-Augmentation: verbundene Normen (references_out/in) der Top-Hits ueber die
|
|
||||||
// praezise Zitations-Kante in den Pool ziehen — z.B. Art. 13 CRA zieht Anhang I (die
|
|
||||||
// eigentliche Pflichtquelle). Pool-Augmentation only; Re-Rank + topK bleiben.
|
|
||||||
if c.graphEnabled {
|
|
||||||
hits = c.expandViaGraph(ctx, collection, hits)
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]LegalSearchResult, len(hits))
|
results := make([]LegalSearchResult, len(hits))
|
||||||
for i, hit := range hits {
|
for i, hit := range hits {
|
||||||
// Legal-Metadaten nach rag_reingest_spec.md §2: bevorzugt die normalisierten Felder
|
// Legal-Metadaten nach rag_reingest_spec.md §2: bevorzugt die normalisierten Felder
|
||||||
@@ -151,54 +121,12 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
|
|||||||
Pages: getIntSlice(hit.Payload, "pages"),
|
Pages: getIntSlice(hit.Payload, "pages"),
|
||||||
SourceURL: getString(hit.Payload, "source"),
|
SourceURL: getString(hit.Payload, "source"),
|
||||||
Score: hit.Score,
|
Score: hit.Score,
|
||||||
AuthorityWeight: getInt(hit.Payload, "authority_weight"),
|
|
||||||
SourceClass: getString(hit.Payload, "source_class"),
|
|
||||||
Jurisdiction: getString(hit.Payload, "jurisdiction"),
|
|
||||||
CitationUnit: getString(hit.Payload, "citation_unit"),
|
|
||||||
ReferencesOut: getStringSlice(hit.Payload, "references_out"),
|
|
||||||
ReferencesIn: getStringSlice(hit.Payload, "references_in"),
|
|
||||||
Superseded: getString(hit.Payload, "status") == "superseded",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authority-aware Re-Ranking: bindendes Recht der passenden Jurisdiktion/Domaene nach
|
|
||||||
// oben, Guidance/Fremdrecht/Off-Domain runter (nichts wird geloescht). Reihenfolge only,
|
|
||||||
// Response-Schema unveraendert. Score traegt den Authority-Score, damit nachgelagerte
|
|
||||||
// Multi-Collection-Merges (Advisor) die Ordnung bewahren.
|
|
||||||
results = rerankByAuthority(query, results)
|
|
||||||
|
|
||||||
// Control-Diversity: auf einer Umsetzungsfrage darf impl_guidance (ENISA) Top-1 bleiben,
|
|
||||||
// aber die Top-K soll mindestens eine binding operational_requirement (CRA Anhang I) und
|
|
||||||
// einen control_standard (NIST/ISO) zeigen, falls im Pool — Quellenarten sichtbar machen
|
|
||||||
// statt sie kuenstlich auf Top-1 zu heben. Nur Reihenfolge, vor der Truncation.
|
|
||||||
if queryWantsControls(query) {
|
|
||||||
results = ensureControlDiversity(results, topK)
|
|
||||||
}
|
|
||||||
|
|
||||||
if topK > 0 && len(results) > topK {
|
|
||||||
results = results[:topK]
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeDedupHits concatenates two hit lists, keeping the first occurrence of each point ID.
|
|
||||||
func mergeDedupHits(primary, extra []qdrantSearchHit) []qdrantSearchHit {
|
|
||||||
seen := make(map[string]bool, len(primary)+len(extra))
|
|
||||||
out := make([]qdrantSearchHit, 0, len(primary)+len(extra))
|
|
||||||
for _, list := range [][]qdrantSearchHit{primary, extra} {
|
|
||||||
for _, h := range list {
|
|
||||||
id := fmt.Sprint(h.ID)
|
|
||||||
if seen[id] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[id] = true
|
|
||||||
out = append(out, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatLegalContextForPrompt formats the legal context for inclusion in an LLM prompt.
|
// FormatLegalContextForPrompt formats the legal context for inclusion in an LLM prompt.
|
||||||
func (c *LegalRAGClient) FormatLegalContextForPrompt(lc *LegalContext) string {
|
func (c *LegalRAGClient) FormatLegalContextForPrompt(lc *LegalContext) string {
|
||||||
if lc == nil || len(lc.Results) == 0 {
|
if lc == nil || len(lc.Results) == 0 {
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Graph-augmented retrieval: when a top hit cites an annex/article (references_out)
|
|
||||||
// or is cited by one (references_in), pull that connected norm into the candidate
|
|
||||||
// pool via the PRECISE citation graph instead of hoping semantic search surfaces
|
|
||||||
// it. E.g. a hit on CRA Art. 13 pulls in CRA Anhang I (the actual requirement).
|
|
||||||
// Pool-augmentation only — authority re-rank + topK slice still apply, so the
|
|
||||||
// response schema is unchanged.
|
|
||||||
const (
|
|
||||||
graphSeedCount = 5 // only the top hits seed the expansion
|
|
||||||
graphMaxExpand = 15 // cap connected norms pulled in (avoid pool explosion)
|
|
||||||
graphHopPenalty = 0.05 // a one-hop neighbour ranks just below its seed
|
|
||||||
)
|
|
||||||
|
|
||||||
// expandViaGraph augments hits with the norms they cite and the norms that cite
|
|
||||||
// them. Best-effort: on any error (or nothing to expand) the original hits are
|
|
||||||
// returned unchanged.
|
|
||||||
func (c *LegalRAGClient) expandViaGraph(ctx context.Context, collection string, hits []qdrantSearchHit) []qdrantSearchHit {
|
|
||||||
if len(hits) == 0 {
|
|
||||||
return hits
|
|
||||||
}
|
|
||||||
present := make(map[string]bool, len(hits))
|
|
||||||
for _, h := range hits {
|
|
||||||
if cu := getString(h.Payload, "citation_unit"); cu != "" {
|
|
||||||
present[cu] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seeds := hits
|
|
||||||
if len(seeds) > graphSeedCount {
|
|
||||||
seeds = seeds[:graphSeedCount]
|
|
||||||
}
|
|
||||||
// Forward edges only (references_out = the detail a hit explicitly points to,
|
|
||||||
// e.g. Art. 13 → Anhang I). Reverse (references_in) has high fan-out for popular
|
|
||||||
// annexes (Anhang I is cited by 23 articles) → pool flooding; it is surfaced as
|
|
||||||
// connected-norm metadata in the Phase 2 response instead of expanding the pool.
|
|
||||||
want := make(map[string]float64) // connected citation_unit -> best seeding score
|
|
||||||
for _, h := range seeds {
|
|
||||||
for _, cu := range getStringSlice(h.Payload, "references_out") {
|
|
||||||
if cu == "" || present[cu] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if s, ok := want[cu]; !ok || h.Score > s {
|
|
||||||
want[cu] = h.Score
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(want) == 0 {
|
|
||||||
return hits
|
|
||||||
}
|
|
||||||
|
|
||||||
units := topByScore(want, graphMaxExpand)
|
|
||||||
fetched, err := c.fetchByCitationUnits(ctx, collection, units)
|
|
||||||
if err != nil || len(fetched) == 0 {
|
|
||||||
return hits
|
|
||||||
}
|
|
||||||
neighbours := make([]qdrantSearchHit, 0, len(fetched))
|
|
||||||
for cu, pt := range fetched {
|
|
||||||
neighbours = append(neighbours, qdrantSearchHit{ID: pt.ID, Score: want[cu] - graphHopPenalty, Payload: pt.Payload})
|
|
||||||
}
|
|
||||||
return mergeDedupHits(hits, neighbours)
|
|
||||||
}
|
|
||||||
|
|
||||||
// topByScore returns up to n keys with the highest values. Deterministic: ties
|
|
||||||
// broken by the key string so the cap is stable across runs.
|
|
||||||
func topByScore(m map[string]float64, n int) []string {
|
|
||||||
keys := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Slice(keys, func(i, j int) bool {
|
|
||||||
if m[keys[i]] != m[keys[j]] {
|
|
||||||
return m[keys[i]] > m[keys[j]]
|
|
||||||
}
|
|
||||||
return keys[i] < keys[j]
|
|
||||||
})
|
|
||||||
if len(keys) > n {
|
|
||||||
keys = keys[:n]
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchByCitationUnits loads one representative point (the first chunk) per
|
|
||||||
// citation_unit from the given collection.
|
|
||||||
func (c *LegalRAGClient) fetchByCitationUnits(ctx context.Context, collection string, units []string) (map[string]qdrantScrollPoint, error) {
|
|
||||||
should := make([]map[string]interface{}, 0, len(units))
|
|
||||||
for _, cu := range units {
|
|
||||||
should = append(should, map[string]interface{}{"key": "citation_unit", "match": map[string]interface{}{"value": cu}})
|
|
||||||
}
|
|
||||||
reqBody := map[string]interface{}{
|
|
||||||
"limit": len(units) * 4,
|
|
||||||
"with_payload": true,
|
|
||||||
"with_vectors": false,
|
|
||||||
"filter": map[string]interface{}{"should": should},
|
|
||||||
}
|
|
||||||
jsonBody, err := json.Marshal(reqBody)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
url := fmt.Sprintf("%s/collections/%s/points/scroll", c.qdrantURL, collection)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if c.qdrantAPIKey != "" {
|
|
||||||
req.Header.Set("api-key", c.qdrantAPIKey)
|
|
||||||
}
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("qdrant scroll returned %d: %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
var scrollResp qdrantScrollResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&scrollResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make(map[string]qdrantScrollPoint, len(units))
|
|
||||||
for _, pt := range scrollResp.Result.Points {
|
|
||||||
cu := getString(pt.Payload, "citation_unit")
|
|
||||||
if cu != "" {
|
|
||||||
if _, seen := out[cu]; !seen {
|
|
||||||
out[cu] = pt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStringSlice extracts a []string from a Qdrant payload list field
|
|
||||||
// (references_out / references_in are stored as JSON arrays of strings).
|
|
||||||
func getStringSlice(m map[string]interface{}, key string) []string {
|
|
||||||
v, ok := m[key]
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
arr, ok := v.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(arr))
|
|
||||||
for _, item := range arr {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetStringSlice(t *testing.T) {
|
|
||||||
m := map[string]interface{}{
|
|
||||||
"refs": []interface{}{"a", "b", 3, "c"}, // non-strings are skipped
|
|
||||||
"str": "not-a-list",
|
|
||||||
}
|
|
||||||
got := getStringSlice(m, "refs")
|
|
||||||
if len(got) != 3 || got[0] != "a" || got[2] != "c" {
|
|
||||||
t.Errorf("refs: %v", got)
|
|
||||||
}
|
|
||||||
if getStringSlice(m, "missing") != nil {
|
|
||||||
t.Error("missing key should be nil")
|
|
||||||
}
|
|
||||||
if getStringSlice(m, "str") != nil {
|
|
||||||
t.Error("non-list should be nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTopByScore_DeterministicCap(t *testing.T) {
|
|
||||||
m := map[string]float64{"x": 0.5, "y": 0.9, "z": 0.5, "w": 0.7}
|
|
||||||
got := topByScore(m, 2)
|
|
||||||
if len(got) != 2 || got[0] != "y" || got[1] != "w" {
|
|
||||||
t.Errorf("want [y w], got %v", got)
|
|
||||||
}
|
|
||||||
all := topByScore(m, 10)
|
|
||||||
if all[2] != "x" || all[3] != "z" { // tie 0.5 broken by key string
|
|
||||||
t.Errorf("tie-break not deterministic: %v", all)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpandViaGraph_NoSeedsOrRefs(t *testing.T) {
|
|
||||||
c := &LegalRAGClient{} // nil httpClient → must not be called on these paths
|
|
||||||
if out := c.expandViaGraph(context.Background(), "x", nil); out != nil {
|
|
||||||
t.Error("empty hits should return nil")
|
|
||||||
}
|
|
||||||
hits := []qdrantSearchHit{{ID: 1, Score: 0.8, Payload: map[string]interface{}{"citation_unit": "Art. 1 CRA"}}}
|
|
||||||
if out := c.expandViaGraph(context.Background(), "x", hits); len(out) != 1 {
|
|
||||||
t.Errorf("no references → unchanged, got %d", len(out))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpandViaGraph_PullsConnectedNorm(t *testing.T) {
|
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"result": map[string]interface{}{
|
|
||||||
"points": []map[string]interface{}{
|
|
||||||
{"id": 99, "payload": map[string]interface{}{
|
|
||||||
"citation_unit": "CRA Anhang I", "chunk_text": "Sicherheitsanforderungen",
|
|
||||||
"source_class": "binding_law", "authority_weight": 100, "regulation_short": "CRA",
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
"next_page_offset": nil,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
defer srv.Close()
|
|
||||||
|
|
||||||
c := &LegalRAGClient{qdrantURL: srv.URL, httpClient: srv.Client()}
|
|
||||||
hits := []qdrantSearchHit{
|
|
||||||
{ID: 1, Score: 0.70, Payload: map[string]interface{}{
|
|
||||||
"citation_unit": "Art. 13 CRA", "references_out": []interface{}{"CRA Anhang I"},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
out := c.expandViaGraph(context.Background(), "bp_compliance_ce", hits)
|
|
||||||
if len(out) != 2 {
|
|
||||||
t.Fatalf("want 2 hits (seed + connected annex), got %d", len(out))
|
|
||||||
}
|
|
||||||
var found *qdrantSearchHit
|
|
||||||
for i := range out {
|
|
||||||
if getString(out[i].Payload, "citation_unit") == "CRA Anhang I" {
|
|
||||||
found = &out[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found == nil {
|
|
||||||
t.Fatal("connected norm CRA Anhang I was not pulled into the pool")
|
|
||||||
}
|
|
||||||
if found.Score < 0.64 || found.Score > 0.66 { // 0.70 seed − 0.05 hop penalty
|
|
||||||
t.Errorf("connected score = %v, want ~0.65", found.Score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -185,55 +185,6 @@ func (c *LegalRAGClient) searchDense(ctx context.Context, collection string, emb
|
|||||||
searchReq.Filter = &qdrantFilter{Should: conditions}
|
searchReq.Filter = &qdrantFilter{Should: conditions}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.doPointsSearch(ctx, collection, searchReq)
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchBinding fetches the top binding_law hits (authority-stratified pool) so the
|
|
||||||
// obligation source is always a candidate even when guidance dominates semantically.
|
|
||||||
// It AUGMENTS the semantic pool — guidance is preserved as interpretation context.
|
|
||||||
func (c *LegalRAGClient) searchBinding(ctx context.Context, collection string, embedding []float64, topK int) ([]qdrantSearchHit, error) {
|
|
||||||
searchReq := qdrantSearchRequest{
|
|
||||||
Vector: embedding,
|
|
||||||
Limit: topK,
|
|
||||||
WithPayload: true,
|
|
||||||
Filter: &qdrantFilter{Must: []qdrantCondition{
|
|
||||||
{Key: "source_class", Match: qdrantMatch{Value: "binding_law"}},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.doPointsSearch(ctx, collection, searchReq)
|
|
||||||
}
|
|
||||||
|
|
||||||
// controlPoolDepth is how deep the dense control pull reaches. Measured: for an EU-cyber
|
|
||||||
// control query the relevant control sources sit at dense rank ~8-9 (NIST, CRA Annex), far
|
|
||||||
// below the client's small top-K — so a fixed dense depth of 60 reliably surfaces them.
|
|
||||||
const controlPoolDepth = 60
|
|
||||||
|
|
||||||
// searchControls fetches a DEEP dense pool and keeps only the control-pool roles, so control
|
|
||||||
// sources that the small top-K (hybrid) search misses become candidates on an implementation
|
|
||||||
// question. Role is derived in code (no source_role tag needed). AUGMENTS the pool — the
|
|
||||||
// caller gates it on control-intent.
|
|
||||||
func (c *LegalRAGClient) searchControls(ctx context.Context, collection string, embedding []float64) ([]qdrantSearchHit, error) {
|
|
||||||
searchReq := qdrantSearchRequest{
|
|
||||||
Vector: embedding,
|
|
||||||
Limit: controlPoolDepth,
|
|
||||||
WithPayload: true,
|
|
||||||
}
|
|
||||||
hits, err := c.doPointsSearch(ctx, collection, searchReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
kept := make([]qdrantSearchHit, 0, len(hits))
|
|
||||||
for _, h := range hits {
|
|
||||||
if isControlPoolRole(controlRoleOf(h.Payload)) {
|
|
||||||
kept = append(kept, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return kept, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// doPointsSearch issues a POST /points/search and decodes the hits.
|
|
||||||
func (c *LegalRAGClient) doPointsSearch(ctx context.Context, collection string, searchReq qdrantSearchRequest) ([]qdrantSearchHit, error) {
|
|
||||||
jsonBody, err := json.Marshal(searchReq)
|
jsonBody, err := json.Marshal(searchReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal search request: %w", err)
|
return nil, fmt.Errorf("failed to marshal search request: %w", err)
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func intentRes(reg, sourceClass string, sem float64, weight int) LegalSearchResult {
|
|
||||||
return LegalSearchResult{
|
|
||||||
RegulationShort: reg, SourceClass: sourceClass, Score: sem,
|
|
||||||
AuthorityWeight: weight, Jurisdiction: "EU",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryWantsGuidance(t *testing.T) {
|
|
||||||
wants := []string{
|
|
||||||
"Was empfiehlt der EDPB zum DSB?",
|
|
||||||
"Was sagt die ENISA zu Security Updates?",
|
|
||||||
"laut DSK ...",
|
|
||||||
"Orientierungshilfe zur DSFA",
|
|
||||||
"Welche BSI-Empfehlung gilt?",
|
|
||||||
"Auslegung der Aufsichtsbehörde",
|
|
||||||
}
|
|
||||||
plain := []string{
|
|
||||||
"Ab wann braucht man einen Datenschutzbeauftragten?",
|
|
||||||
"Welche Anforderungen bestehen an Security Updates?",
|
|
||||||
}
|
|
||||||
for _, q := range wants {
|
|
||||||
if !queryWantsGuidance(q) {
|
|
||||||
t.Errorf("should detect interpretation intent: %q", q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, q := range plain {
|
|
||||||
if queryWantsGuidance(q) {
|
|
||||||
t.Errorf("should NOT detect intent (norm question): %q", q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRerank_NormQuestion_BindingStaysTop(t *testing.T) {
|
|
||||||
// No intent signal → binding wins even though guidance is semantically higher.
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
intentRes("EDPB DPO", "supervisory_guidance", 0.64, 70),
|
|
||||||
intentRes("DSGVO", "binding_law", 0.58, 100),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Ab wann braucht man einen Datenschutzbeauftragten?", results)
|
|
||||||
if out[0].SourceClass != "binding_law" {
|
|
||||||
t.Errorf("norm question: binding must stay Top-1, got %s", out[0].SourceClass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRerank_InterpretationQuestion_GuidanceMayWin(t *testing.T) {
|
|
||||||
// Explicit intent + guidance semantically competitive → guidance wins.
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
intentRes("EDPB DPO", "supervisory_guidance", 0.64, 70),
|
|
||||||
intentRes("DSGVO", "binding_law", 0.58, 100),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Was empfiehlt der EDPB zum Datenschutzbeauftragten?", results)
|
|
||||||
if out[0].SourceClass != "supervisory_guidance" {
|
|
||||||
t.Errorf("interpretation question: guidance should win Top-1, got %s", out[0].SourceClass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRerank_OffTopicGuidance_BlockedByGuard(t *testing.T) {
|
|
||||||
// Intent present, but guidance semantic is far below the best binding hit →
|
|
||||||
// the margin guard keeps binding on top (no off-topic guideline override).
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
intentRes("EDPB DPO", "supervisory_guidance", 0.40, 70),
|
|
||||||
intentRes("DSGVO", "binding_law", 0.58, 100),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Was empfiehlt der EDPB zum Datenschutzbeauftragten?", results)
|
|
||||||
if out[0].SourceClass != "binding_law" {
|
|
||||||
t.Errorf("off-topic guidance must not win even with intent, got %s", out[0].SourceClass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestQueryWantsControls(t *testing.T) {
|
|
||||||
wants := []string{
|
|
||||||
"Welche Controls passen zu Security Updates?",
|
|
||||||
"Welche Maßnahmen sollten wir umsetzen?",
|
|
||||||
"Wie härten wir den Server ab?",
|
|
||||||
"Gibt es NIST-Controls dafür?",
|
|
||||||
"OWASP Best Practice für Logging?",
|
|
||||||
"BSI Grundschutz Bausteine",
|
|
||||||
}
|
|
||||||
plain := []string{
|
|
||||||
"Welche Anforderungen bestehen an Security Updates?",
|
|
||||||
"Ab wann braucht man einen Datenschutzbeauftragten?",
|
|
||||||
}
|
|
||||||
for _, q := range wants {
|
|
||||||
if !queryWantsControls(q) {
|
|
||||||
t.Errorf("should detect control/implementation intent: %q", q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, q := range plain {
|
|
||||||
if queryWantsControls(q) {
|
|
||||||
t.Errorf("should NOT detect control intent (norm question): %q", q)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRerank_ControlQuestion_OperationalReqTop(t *testing.T) {
|
|
||||||
// User priority for implementation questions: operational_requirement (binding concrete,
|
|
||||||
// CRA Anhang I) > control_standard (NIST). Both are in the control-pool; op_req wins.
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.60},
|
|
||||||
{RegulationShort: "CRA", ArticleLabel: "CRA Anhang I", Category: "regulation", Score: 0.58},
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results)
|
|
||||||
if out[0].RegulationShort != "CRA" {
|
|
||||||
t.Errorf("operational_requirement (CRA Anhang I) should be Top-1 over control_standard, got %q", out[0].RegulationShort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRerank_NormQuestion_BindingOverStandard(t *testing.T) {
|
|
||||||
// "Anforderungen" → no control intent → binding obligation stays Top-1 over the standard.
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
intentRes("NIST SP 800-82", "technical_standard", 0.62, 80),
|
|
||||||
intentRes("CRA", "binding_law", 0.58, 100),
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Welche Anforderungen bestehen an Security Updates?", results)
|
|
||||||
if out[0].SourceClass != "binding_law" {
|
|
||||||
t.Errorf("norm question: binding must stay Top-1 over standard, got %s", out[0].SourceClass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRerank_ControlQuestion_PoolBeatsBareObligation(t *testing.T) {
|
|
||||||
// A control-pool source (NIST control_standard) outranks an abstract obligation with no
|
|
||||||
// domain/topic advantage, because the implementation intent boosts the control-pool.
|
|
||||||
results := []LegalSearchResult{
|
|
||||||
{RegulationShort: "NIST SP 800-82r3", ArticleLabel: "AU-8", SourceClass: "technical_standard", AuthorityWeight: 80, Jurisdiction: "EU", Score: 0.55},
|
|
||||||
{RegulationShort: "XYZ", ArticleLabel: "Art. 5 XYZ", Category: "regulation", Score: 0.58},
|
|
||||||
}
|
|
||||||
out := rerankByAuthority("Welche Controls und Massnahmen passen zu Security Updates?", results)
|
|
||||||
if out[0].RegulationShort != "NIST SP 800-82r3" {
|
|
||||||
t.Errorf("control_standard should beat a bare abstract obligation on a control question, got %q", out[0].RegulationShort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -225,18 +225,6 @@ func getIntSlice(m map[string]interface{}, key string) []int {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func getInt(m map[string]interface{}, key string) int {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
switch n := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return int(n)
|
|
||||||
case int:
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(slice []string, item string) bool {
|
func contains(slice []string, item string) bool {
|
||||||
for _, s := range slice {
|
for _, s := range slice {
|
||||||
if s == item {
|
if s == item {
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// A superseded alt-source must rank below the same result when it is NOT
|
|
||||||
// superseded (the eu-v1 norm), but only demoted — the penalty is finite, so it
|
|
||||||
// stays in the pool and remains findable for history/transition questions.
|
|
||||||
func TestAuthorityScore_SupersededIsDemotedNotRemoved(t *testing.T) {
|
|
||||||
fresh := LegalSearchResult{
|
|
||||||
Score: 0.65, SourceClass: "binding_law", AuthorityWeight: 100,
|
|
||||||
Jurisdiction: "EU", RegulationShort: "CRA", Article: "13",
|
|
||||||
}
|
|
||||||
old := fresh
|
|
||||||
old.Superseded = true
|
|
||||||
|
|
||||||
sFresh := authorityScore("CRA Sicherheitsupdates Hersteller", fresh, "", false)
|
|
||||||
sOld := authorityScore("CRA Sicherheitsupdates Hersteller", old, "", false)
|
|
||||||
|
|
||||||
if sOld >= sFresh {
|
|
||||||
t.Errorf("superseded must score lower: fresh=%.3f superseded=%.3f", sFresh, sOld)
|
|
||||||
}
|
|
||||||
gap := sFresh - sOld
|
|
||||||
if gap < supersededPenalty-0.001 || gap > supersededPenalty+0.001 {
|
|
||||||
t.Errorf("demotion should equal supersededPenalty (%.2f), got %.3f", supersededPenalty, gap)
|
|
||||||
}
|
|
||||||
// Still a positive, finite score → present in the pool, not hidden.
|
|
||||||
if sOld <= -1 {
|
|
||||||
t.Errorf("superseded score collapsed (%.3f) — must remain findable", sOld)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -399,9 +399,8 @@ func TestHybridSearch_UsesQueryAPI(t *testing.T) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// /points/search is now the stratified binding-law augmentation query (it AUGMENTS
|
// Fallback: should not reach dense search
|
||||||
// the hybrid pool, it is not a dense fallback). Return empty so the hybrid hit
|
t.Error("Unexpected dense search call when hybrid succeeded")
|
||||||
// remains the sole result for this test.
|
|
||||||
json.NewEncoder(w).Encode(qdrantSearchResponse{Result: []qdrantSearchHit{}})
|
json.NewEncoder(w).Encode(qdrantSearchResponse{Result: []qdrantSearchHit{}})
|
||||||
}))
|
}))
|
||||||
defer qdrantMock.Close()
|
defer qdrantMock.Close()
|
||||||
@@ -447,59 +446,6 @@ func TestHybridSearch_UsesQueryAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSearch_StratifiedBindingRerank verifies that the binding-law pool augments the
|
|
||||||
// semantic pool and that authority re-ranking lifts binding law above higher-semantic guidance.
|
|
||||||
func TestSearch_StratifiedBindingRerank(t *testing.T) {
|
|
||||||
ollamaMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
json.NewEncoder(w).Encode(ollamaEmbeddingResponse{Embedding: make([]float64, 1024)})
|
|
||||||
}))
|
|
||||||
defer ollamaMock.Close()
|
|
||||||
|
|
||||||
qdrantMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if strings.Contains(r.URL.Path, "/index") {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(`{"result":{"status":"completed"}}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.Contains(r.URL.Path, "/points/query") {
|
|
||||||
json.NewEncoder(w).Encode(qdrantQueryResponse{Result: []qdrantSearchHit{
|
|
||||||
{ID: "g1", Score: 0.72, Payload: map[string]interface{}{
|
|
||||||
"chunk_text": "ENISA guidance", "regulation_short": "ENISA",
|
|
||||||
"article_label": "ENISA CRA Mapping", "source_class": "supervisory_guidance",
|
|
||||||
"authority_weight": float64(70), "jurisdiction": "EU",
|
|
||||||
}},
|
|
||||||
}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// /points/search = stratified binding-law pool (source_class=binding_law)
|
|
||||||
json.NewEncoder(w).Encode(qdrantSearchResponse{Result: []qdrantSearchHit{
|
|
||||||
{ID: "b1", Score: 0.66, Payload: map[string]interface{}{
|
|
||||||
"chunk_text": "CRA Anhang I requirement", "regulation_short": "CRA",
|
|
||||||
"article_label": "CRA Anhang I", "source_class": "binding_law",
|
|
||||||
"authority_weight": float64(100), "jurisdiction": "EU",
|
|
||||||
}},
|
|
||||||
}})
|
|
||||||
}))
|
|
||||||
defer qdrantMock.Close()
|
|
||||||
|
|
||||||
client := &LegalRAGClient{
|
|
||||||
qdrantURL: qdrantMock.URL, ollamaURL: ollamaMock.URL, embeddingModel: "bge-m3",
|
|
||||||
collection: "bp_compliance_ce", textIndexEnsured: make(map[string]bool),
|
|
||||||
hybridEnabled: true, httpClient: http.DefaultClient,
|
|
||||||
}
|
|
||||||
|
|
||||||
results, err := client.Search(context.Background(), "Was gilt hier?", nil, 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("search failed: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 2 {
|
|
||||||
t.Fatalf("expected 2 merged results (guidance + binding), got %d", len(results))
|
|
||||||
}
|
|
||||||
if results[0].RegulationShort != "CRA" {
|
|
||||||
t.Errorf("binding CRA must rank first over higher-semantic guidance, got %q", results[0].RegulationShort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHybridSearch_FallbackToDense(t *testing.T) {
|
func TestHybridSearch_FallbackToDense(t *testing.T) {
|
||||||
var requestedPaths []string
|
var requestedPaths []string
|
||||||
|
|
||||||
|
|||||||
@@ -20,38 +20,6 @@ type LegalSearchResult struct {
|
|||||||
Pages []int `json:"pages,omitempty"`
|
Pages []int `json:"pages,omitempty"`
|
||||||
SourceURL string `json:"source_url"`
|
SourceURL string `json:"source_url"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
|
|
||||||
// Interne Felder fuer das Authority-Re-Ranking (Phase 1) — NICHT serialisiert
|
|
||||||
// (json:"-"), daher kein Contract-Change. Aus dem Qdrant-Payload befuellt und nur
|
|
||||||
// fuer die Sortierung in rerankByAuthority verwendet.
|
|
||||||
AuthorityWeight int `json:"-"`
|
|
||||||
SourceClass string `json:"-"`
|
|
||||||
Jurisdiction string `json:"-"`
|
|
||||||
|
|
||||||
// Zitations-Graph (Phase 2) — intern, speist nur die Assessment-Berechnung
|
|
||||||
// (verbundene Normen, Begruendung). Pro-Result-Schema bleibt eingefroren.
|
|
||||||
CitationUnit string `json:"-"`
|
|
||||||
ReferencesOut []string `json:"-"`
|
|
||||||
ReferencesIn []string `json:"-"`
|
|
||||||
|
|
||||||
// Supersede-Status (status="superseded", use_for_primary=false) — Alt-Quelle,
|
|
||||||
// die fuer Default-Fragen demoted wird (nicht versteckt; fuer Historie auffindbar).
|
|
||||||
Superseded bool `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LegalAssessment is the auditable explanation layer over a ranked result set:
|
|
||||||
// which norm is primary, which norms connect to it via the citation graph,
|
|
||||||
// whether the answer crosses regulatory regimes, and whether a human should
|
|
||||||
// review. Computed from the already-ranked results — it EXPLAINS retrieval, it
|
|
||||||
// does not change it (graph edges for reasoning/completeness, not pool-expansion).
|
|
||||||
type LegalAssessment struct {
|
|
||||||
PrimaryNorm string `json:"primary_norm"`
|
|
||||||
PrimaryRegulation string `json:"primary_regulation"`
|
|
||||||
ConnectedNorms []string `json:"connected_norms"`
|
|
||||||
CrossRegime bool `json:"cross_regime"`
|
|
||||||
HumanReviewFlag bool `json:"human_review_flag"`
|
|
||||||
WinnerMargin float64 `json:"winner_margin"`
|
|
||||||
ScoreReasoning string `json:"score_reasoning"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LegalContext represents aggregated legal context for an assessment.
|
// LegalContext represents aggregated legal context for an assessment.
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ObligationKey is one entry of the Obligation Registry's cross-session contract
|
|
||||||
// (obligations/obligation_join_keys.json). obligation_id is the STABLE join key — assigned
|
|
||||||
// only by the Registry, never minted here. citation_units are the interim bridge until our
|
|
||||||
// ControlMapping adopts obligation_id directly.
|
|
||||||
type ObligationKey struct {
|
|
||||||
ObligationID string `json:"obligation_id"`
|
|
||||||
Regulation string `json:"regulation"`
|
|
||||||
Family string `json:"family"`
|
|
||||||
Tier string `json:"tier"`
|
|
||||||
CitationUnits []string `json:"citation_units"`
|
|
||||||
SourceRole string `json:"source_role"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObligationJoinKeys is the loaded contract + a citation-unit index for the interim join.
|
|
||||||
type ObligationJoinKeys struct {
|
|
||||||
SchemaVersion string `json:"schema_version"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
ObligationIDs []ObligationKey `json:"obligation_ids"`
|
|
||||||
byCitationKey map[string][]string
|
|
||||||
}
|
|
||||||
|
|
||||||
var citationRefRe = regexp.MustCompile(`\(([0-9a-zA-Z]+)\)`)
|
|
||||||
|
|
||||||
// citationUnitKey normalizes a CRA Annex I reference for the INTERIM citation_unit join, so
|
|
||||||
// our "CRA Annex I Part I (2)(c)" and the Registry's "Annex I (2)(c)" collapse to the same
|
|
||||||
// key ("i:2.c"). Interim only — superseded by the stable obligation_id once adopted.
|
|
||||||
func citationUnitKey(cu string) string {
|
|
||||||
low := strings.ToLower(cu)
|
|
||||||
part := ""
|
|
||||||
switch {
|
|
||||||
case strings.Contains(low, "part ii"):
|
|
||||||
part = "ii"
|
|
||||||
case strings.Contains(low, "part i"), strings.Contains(low, "(2)"):
|
|
||||||
part = "i" // CRA Annex I Part I = the (2)(x) essential requirements
|
|
||||||
}
|
|
||||||
var refs []string
|
|
||||||
for _, m := range citationRefRe.FindAllStringSubmatch(cu, -1) {
|
|
||||||
refs = append(refs, strings.ToLower(m[1]))
|
|
||||||
}
|
|
||||||
return part + ":" + strings.Join(refs, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadObligationJoinKeys reads the Registry contract and indexes it by citation-unit key.
|
|
||||||
func LoadObligationJoinKeys(path string) (*ObligationJoinKeys, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var o ObligationJoinKeys
|
|
||||||
if err := json.Unmarshal(raw, &o); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
o.byCitationKey = map[string][]string{}
|
|
||||||
for _, ob := range o.ObligationIDs {
|
|
||||||
for _, cu := range ob.CitationUnits {
|
|
||||||
k := citationUnitKey(cu)
|
|
||||||
o.byCitationKey[k] = append(o.byCitationKey[k], ob.ObligationID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &o, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObligationsForCitation returns the obligation_ids that join (interim) to a citation
|
|
||||||
// reference such as a control_mapping.source_norm.
|
|
||||||
func (o *ObligationJoinKeys) ObligationsForCitation(citationRef string) []string {
|
|
||||||
return o.byCitationKey[citationUnitKey(citationRef)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindObligation returns the registry entry for an obligation_id (nil if unknown).
|
|
||||||
func (o *ObligationJoinKeys) FindObligation(obligationID string) *ObligationKey {
|
|
||||||
for i := range o.ObligationIDs {
|
|
||||||
if o.ObligationIDs[i].ObligationID == obligationID {
|
|
||||||
return &o.ObligationIDs[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mappingReaches reports whether a control mapping reaches an obligation — EXACT via the
|
|
||||||
// adopted obligation_id (semantic, preferred), else via the interim citation_unit join (for
|
|
||||||
// not-yet-adopted rows). Once obligation_id is set, the coarse citation_unit match is ignored:
|
|
||||||
// that is how the semantic join replaces the structural one (e.g. V11.2.1 crypto no longer
|
|
||||||
// rides (2)(d) into user_authentication_required — it goes to credential_confidentiality_protection).
|
|
||||||
func mappingReaches(m ControlMapping, ob ObligationKey, citationKeys map[string]bool) bool {
|
|
||||||
if m.ObligationID != "" {
|
|
||||||
return m.ObligationID == ob.ObligationID
|
|
||||||
}
|
|
||||||
return citationKeys[citationUnitKey(m.SourceNorm)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// AcceptedControlsForObligation returns our accepted control mappings that reach an obligation
|
|
||||||
// (deduped by target control), obligation_id-exact where adopted, citation_unit otherwise.
|
|
||||||
func AcceptedControlsForObligation(ob ObligationKey, mappings *ControlMappingSet) []ControlMapping {
|
|
||||||
keys := make(map[string]bool, len(ob.CitationUnits))
|
|
||||||
for _, cu := range ob.CitationUnits {
|
|
||||||
keys[citationUnitKey(cu)] = true
|
|
||||||
}
|
|
||||||
out := []ControlMapping{}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, m := range mappings.All {
|
|
||||||
if !m.IsAccepted() || !mappingReaches(m, ob, keys) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ck := m.TargetFramework + ":" + m.TargetControl
|
|
||||||
if seen[ck] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[ck] = true
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ObligationCoverage is one row of the cross-session coverage report.
|
|
||||||
type ObligationCoverage struct {
|
|
||||||
ObligationID string `json:"obligation_id"`
|
|
||||||
Family string `json:"family"`
|
|
||||||
Status string `json:"status"` // covered | mapped_rejected | uncovered
|
|
||||||
AcceptedControls []string `json:"accepted_controls"`
|
|
||||||
EvidenceCount int `json:"evidence_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComputeObligationCoverage joins the Registry obligations to our control mappings — exact via
|
|
||||||
// obligation_id where adopted, else via the interim citation_unit join — and reports per
|
|
||||||
// obligation: covered (>=1 accepted control reaches it), mapped_rejected (only rejected
|
|
||||||
// mappings reach it), or uncovered. The signal back to the Obligation session.
|
|
||||||
func ComputeObligationCoverage(joins *ObligationJoinKeys, mappings *ControlMappingSet, evidence *EvidenceRequirementSet) []ObligationCoverage {
|
|
||||||
out := make([]ObligationCoverage, 0, len(joins.ObligationIDs))
|
|
||||||
for _, ob := range joins.ObligationIDs {
|
|
||||||
keys := make(map[string]bool, len(ob.CitationUnits))
|
|
||||||
for _, cu := range ob.CitationUnits {
|
|
||||||
keys[citationUnitKey(cu)] = true
|
|
||||||
}
|
|
||||||
cov := ObligationCoverage{ObligationID: ob.ObligationID, Family: ob.Family}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
rejected := false
|
|
||||||
for _, m := range mappings.All {
|
|
||||||
if !mappingReaches(m, ob, keys) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if m.IsAccepted() {
|
|
||||||
ck := m.TargetFramework + ":" + m.TargetControl
|
|
||||||
if !seen[ck] {
|
|
||||||
seen[ck] = true
|
|
||||||
cov.AcceptedControls = append(cov.AcceptedControls, ck)
|
|
||||||
cov.EvidenceCount += len(evidence.RequiredFor(m.TargetFramework, m.TargetControl))
|
|
||||||
}
|
|
||||||
} else if m.MappingStatus == "rejected" {
|
|
||||||
rejected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case len(cov.AcceptedControls) > 0:
|
|
||||||
cov.Status = "covered"
|
|
||||||
case rejected:
|
|
||||||
cov.Status = "mapped_rejected"
|
|
||||||
default:
|
|
||||||
cov.Status = "uncovered"
|
|
||||||
}
|
|
||||||
out = append(out, cov)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package ucca
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCitationUnitKey_Join(t *testing.T) {
|
|
||||||
// our source_norm and the registry citation_unit must collapse to the SAME key.
|
|
||||||
if citationUnitKey("CRA Annex I Part I (2)(c) — Schutz vor unbefugtem Zugriff") != citationUnitKey("Annex I (2)(c)") {
|
|
||||||
t.Errorf("interim join broken: %q vs %q",
|
|
||||||
citationUnitKey("CRA Annex I Part I (2)(c)"), citationUnitKey("Annex I (2)(c)"))
|
|
||||||
}
|
|
||||||
// Part II must NOT collide with Part I.
|
|
||||||
if citationUnitKey("Annex I Part II (1)") == citationUnitKey("CRA Annex I Part I (2)(c)") {
|
|
||||||
t.Error("Part II must not join to Part I")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadObligationJoinKeys(t *testing.T) {
|
|
||||||
o, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load: %v", err)
|
|
||||||
}
|
|
||||||
if o.Count != len(o.ObligationIDs) {
|
|
||||||
t.Errorf("count %d != len %d", o.Count, len(o.ObligationIDs))
|
|
||||||
}
|
|
||||||
if len(o.ObligationIDs) == 0 {
|
|
||||||
t.Fatal("empty contract")
|
|
||||||
}
|
|
||||||
if got := o.ObligationsForCitation("CRA Annex I Part I (2)(c)"); len(got) == 0 {
|
|
||||||
t.Error("expected an obligation joined to (2)(c)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestObligationCoverage_Report(t *testing.T) {
|
|
||||||
joins, err := LoadObligationJoinKeys("../../../obligations/obligation_join_keys.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("join keys: %v", err)
|
|
||||||
}
|
|
||||||
maps, err := LoadControlMappings("../../data/control_mappings")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mappings: %v", err)
|
|
||||||
}
|
|
||||||
ev, err := LoadEvidenceRequirements("../../data/evidence_requirements")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("evidence: %v", err)
|
|
||||||
}
|
|
||||||
cov := ComputeObligationCoverage(joins, maps, ev)
|
|
||||||
if len(cov) == 0 {
|
|
||||||
t.Fatal("no coverage computed")
|
|
||||||
}
|
|
||||||
byStatus := map[string]int{}
|
|
||||||
for _, c := range cov {
|
|
||||||
byStatus[c.Status]++
|
|
||||||
}
|
|
||||||
t.Logf("COVERAGE: %d Obligations | covered=%d mapped_rejected=%d uncovered=%d",
|
|
||||||
len(cov), byStatus["covered"], byStatus["mapped_rejected"], byStatus["uncovered"])
|
|
||||||
for _, c := range cov {
|
|
||||||
if c.Status != "uncovered" {
|
|
||||||
t.Logf(" %-15s %-36s controls=%v evidence=%d", c.Status, c.ObligationID, c.AcceptedControls, c.EvidenceCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -77,7 +77,6 @@ _ROUTER_MODULES = [
|
|||||||
"licenses_routes",
|
"licenses_routes",
|
||||||
"template_rule_routes",
|
"template_rule_routes",
|
||||||
"specialist_agent_routes",
|
"specialist_agent_routes",
|
||||||
"reasoning_routes",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
_loaded_count = 0
|
_loaded_count = 0
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
"""HTTP endpoints for the Regulatory Reasoning Engine (spec §7).
|
|
||||||
|
|
||||||
Thin handlers — all reasoning lives in `compliance.reasoning.*`. No DB, no RAG;
|
|
||||||
pure deterministic rule evaluation.
|
|
||||||
|
|
||||||
POST /reasoning/scope -> which regulations apply + missing facts
|
|
||||||
POST /reasoning/obligations -> obligations, overlaps, multi-evidence
|
|
||||||
POST /reasoning/implementation-reasoning -> claim->obligation mapping (Welt 1, no verdict)
|
|
||||||
POST /reasoning/interpretation-assessment -> verdict on a customer interpretation
|
|
||||||
POST /reasoning/product-scope -> gate on facts, else run discover_scope once
|
|
||||||
POST /reasoning/regulatory-map -> customer-readable read-model over the scope
|
|
||||||
POST /reasoning/interpretation-in-map -> judge a customer interpretation within the map
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from compliance.interpretation_map import (
|
|
||||||
InterpretationInMapRequest,
|
|
||||||
InterpretationInMapResult,
|
|
||||||
interpret_in_map,
|
|
||||||
)
|
|
||||||
from compliance.product_scope import (
|
|
||||||
ProductScopeRequest,
|
|
||||||
ProductScopeResponse,
|
|
||||||
resolve_product_scope,
|
|
||||||
)
|
|
||||||
from compliance.regulatory_map import RegulatoryMap, RegulatoryMapRequest, render_regulatory_map
|
|
||||||
from compliance.reasoning import (
|
|
||||||
assess_interpretation,
|
|
||||||
derive_obligations,
|
|
||||||
discover_scope,
|
|
||||||
reason_implementation_claim,
|
|
||||||
)
|
|
||||||
from compliance.reasoning.schemas import (
|
|
||||||
ImplementationReasoningRequest,
|
|
||||||
ImplementationReasoningResponse,
|
|
||||||
InterpretationRequest,
|
|
||||||
InterpretationResponse,
|
|
||||||
ObligationsRequest,
|
|
||||||
ObligationsResponse,
|
|
||||||
ScopeRequest,
|
|
||||||
ScopeResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/reasoning", tags=["reasoning"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/scope", response_model=ScopeResponse)
|
|
||||||
def scope_discovery(req: ScopeRequest) -> ScopeResponse:
|
|
||||||
scope = discover_scope(req.product_profile)
|
|
||||||
return ScopeResponse(
|
|
||||||
regulatory_scope=scope,
|
|
||||||
missing_facts=scope.missing_facts,
|
|
||||||
confidence=scope.confidence,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/obligations", response_model=ObligationsResponse)
|
|
||||||
def applicable_obligations(req: ObligationsRequest) -> ObligationsResponse:
|
|
||||||
return derive_obligations(req.product_profile, req.regulatory_scope)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/implementation-reasoning", response_model=ImplementationReasoningResponse)
|
|
||||||
def implementation_reasoning(req: ImplementationReasoningRequest) -> ImplementationReasoningResponse:
|
|
||||||
return reason_implementation_claim(req.product_profile, req.customer_claim)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/product-scope", response_model=ProductScopeResponse)
|
|
||||||
def product_scope(req: ProductScopeRequest) -> ProductScopeResponse:
|
|
||||||
return resolve_product_scope(req.product_profile)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/regulatory-map", response_model=RegulatoryMap)
|
|
||||||
def regulatory_map(req: RegulatoryMapRequest) -> RegulatoryMap:
|
|
||||||
return render_regulatory_map(req.product_profile)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/interpretation-in-map", response_model=InterpretationInMapResult)
|
|
||||||
def interpretation_in_map(req: InterpretationInMapRequest) -> InterpretationInMapResult:
|
|
||||||
reg_map = render_regulatory_map(req.product_profile)
|
|
||||||
return interpret_in_map(reg_map, req.customer_interpretation)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/interpretation-assessment", response_model=InterpretationResponse)
|
|
||||||
def interpretation_assessment(req: InterpretationRequest) -> InterpretationResponse:
|
|
||||||
result = assess_interpretation(req.customer_interpretation, req.product_profile)
|
|
||||||
return InterpretationResponse(
|
|
||||||
assessment=result.assessment,
|
|
||||||
affected_regulations=result.affected_regulations,
|
|
||||||
affected_obligations=result.affected_obligations,
|
|
||||||
corrected_interpretation=result.corrected_interpretation,
|
|
||||||
risks=result.risks,
|
|
||||||
legal_basis_refs=result.legal_basis_refs,
|
|
||||||
explanation=result.explanation,
|
|
||||||
confidence=result.confidence,
|
|
||||||
)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Master Capability Registry v0 (Phase 2C) — Compliance Execution domain.
|
|
||||||
|
|
||||||
Registry + minting layer for Master Capabilities — the third instance of the
|
|
||||||
identity-machine pattern (Master Controls, Master Obligations, Master Capabilities).
|
|
||||||
|
|
||||||
STORED: identities, sources, relationship types, policy versions, lifecycle events,
|
|
||||||
provenance. DERIVED (never stored): confidence, coverage, gap.
|
|
||||||
|
|
||||||
v0 scope: types + minting + typed relations + versioned policy + identity lifecycle.
|
|
||||||
NOT here: Company-Gap, real ISO/cert mappings, certification derivations, UI, RAG,
|
|
||||||
new meta-model class, generic canonicalization engine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .engine import (
|
|
||||||
CapabilityRegistry,
|
|
||||||
deprecate_capability,
|
|
||||||
evaluate_relation,
|
|
||||||
merge_capabilities,
|
|
||||||
mint_capability,
|
|
||||||
resolve,
|
|
||||||
split_capability,
|
|
||||||
)
|
|
||||||
from .policy import DEFAULT_POLICY, assert_no_certification_confirms
|
|
||||||
from .schemas import (
|
|
||||||
AssertionStatus,
|
|
||||||
CapabilityCandidate,
|
|
||||||
CapabilityRelation,
|
|
||||||
Confidence,
|
|
||||||
DerivedAssessment,
|
|
||||||
EvidenceKind,
|
|
||||||
IdentityLifecycleEvent,
|
|
||||||
LifecycleEventType,
|
|
||||||
LifecycleState,
|
|
||||||
MasterCapability,
|
|
||||||
PolicyRule,
|
|
||||||
PolicyVersion,
|
|
||||||
Provenance,
|
|
||||||
RelationType,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# engine
|
|
||||||
"CapabilityRegistry",
|
|
||||||
"mint_capability",
|
|
||||||
"evaluate_relation",
|
|
||||||
"resolve",
|
|
||||||
"deprecate_capability",
|
|
||||||
"merge_capabilities",
|
|
||||||
"split_capability",
|
|
||||||
# policy
|
|
||||||
"DEFAULT_POLICY",
|
|
||||||
"assert_no_certification_confirms",
|
|
||||||
# schemas
|
|
||||||
"MasterCapability",
|
|
||||||
"CapabilityCandidate",
|
|
||||||
"CapabilityRelation",
|
|
||||||
"RelationType",
|
|
||||||
"EvidenceKind",
|
|
||||||
"AssertionStatus",
|
|
||||||
"Confidence",
|
|
||||||
"PolicyRule",
|
|
||||||
"PolicyVersion",
|
|
||||||
"IdentityLifecycleEvent",
|
|
||||||
"LifecycleEventType",
|
|
||||||
"LifecycleState",
|
|
||||||
"Provenance",
|
|
||||||
"DerivedAssessment",
|
|
||||||
]
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
"""Master Capability Registry v0 — minting, derivation, identity lifecycle.
|
|
||||||
|
|
||||||
STORED on the registry: identities, sources, relation types, policy versions,
|
|
||||||
lifecycle events, provenance. DERIVED (never stored): confidence/status, via
|
|
||||||
`evaluate_relation` under a versioned policy.
|
|
||||||
|
|
||||||
Python 3.9 compatible (no `|` unions).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Set
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from .policy import DEFAULT_POLICY
|
|
||||||
from .schemas import (
|
|
||||||
AssertionStatus,
|
|
||||||
CapabilityCandidate,
|
|
||||||
CapabilityRelation,
|
|
||||||
Confidence,
|
|
||||||
DerivedAssessment,
|
|
||||||
IdentityLifecycleEvent,
|
|
||||||
LifecycleEventType,
|
|
||||||
LifecycleState,
|
|
||||||
MasterCapability,
|
|
||||||
PolicyVersion,
|
|
||||||
Provenance,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRegistry(BaseModel):
|
|
||||||
# NOTE: no confidence/coverage field anywhere — those are DERIVED, never stored.
|
|
||||||
capabilities: Dict[str, MasterCapability] = Field(default_factory=dict)
|
|
||||||
relations: List[CapabilityRelation] = Field(default_factory=list)
|
|
||||||
lifecycle_events: List[IdentityLifecycleEvent] = Field(default_factory=list)
|
|
||||||
policy: PolicyVersion = Field(default_factory=lambda: DEFAULT_POLICY)
|
|
||||||
next_serial: int = 1
|
|
||||||
|
|
||||||
|
|
||||||
def _mcap_id(serial: int) -> str:
|
|
||||||
return "MCAP-%05d" % serial
|
|
||||||
|
|
||||||
|
|
||||||
def _next_event_id(registry: "CapabilityRegistry") -> str:
|
|
||||||
return "evt-%d" % (len(registry.lifecycle_events) + 1)
|
|
||||||
|
|
||||||
|
|
||||||
def mint_capability(
|
|
||||||
registry: CapabilityRegistry,
|
|
||||||
candidate: CapabilityCandidate,
|
|
||||||
provenance: Optional[Provenance] = None,
|
|
||||||
name: str = "",
|
|
||||||
definition: str = "",
|
|
||||||
category: str = "",
|
|
||||||
domains: Optional[List[str]] = None,
|
|
||||||
) -> MasterCapability:
|
|
||||||
"""Assign the next stable MCAP id to a candidate and register it (with provenance)."""
|
|
||||||
cap_id = _mcap_id(registry.next_serial)
|
|
||||||
cap = MasterCapability(
|
|
||||||
capability_id=cap_id,
|
|
||||||
name=name or candidate.normalized or candidate.raw_term,
|
|
||||||
definition=definition,
|
|
||||||
category=category,
|
|
||||||
domains=domains or [],
|
|
||||||
provenance=provenance
|
|
||||||
or Provenance(author="system", basis="minted from candidate '%s'" % candidate.raw_term),
|
|
||||||
)
|
|
||||||
registry.capabilities[cap_id] = cap
|
|
||||||
registry.next_serial += 1
|
|
||||||
return cap
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_relation(
|
|
||||||
relation: CapabilityRelation, policy: Optional[PolicyVersion] = None
|
|
||||||
) -> DerivedAssessment:
|
|
||||||
"""Derive (status, confidence) from (relationship_type, evidence_kind) under a
|
|
||||||
versioned policy. Deterministic; result is returned, never stored."""
|
|
||||||
pol = policy if policy is not None else DEFAULT_POLICY
|
|
||||||
status = AssertionStatus.UNKNOWN
|
|
||||||
confidence = Confidence.LOW
|
|
||||||
found = False
|
|
||||||
for rule in pol.rules:
|
|
||||||
if (
|
|
||||||
rule.relationship_type == relation.relationship_type
|
|
||||||
and rule.evidence_kind == relation.evidence_kind
|
|
||||||
):
|
|
||||||
status, confidence, found = rule.status, rule.confidence, True
|
|
||||||
break
|
|
||||||
expl = "%s + %s under %s -> %s/%s%s" % (
|
|
||||||
relation.relationship_type.value,
|
|
||||||
relation.evidence_kind.value,
|
|
||||||
pol.policy_version,
|
|
||||||
status.value,
|
|
||||||
confidence.value,
|
|
||||||
"" if found else " (no rule)",
|
|
||||||
)
|
|
||||||
return DerivedAssessment(
|
|
||||||
target_capability_id=relation.target_capability_id,
|
|
||||||
status=status,
|
|
||||||
confidence=confidence,
|
|
||||||
policy_version=pol.policy_version,
|
|
||||||
explanation=expl,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve(
|
|
||||||
registry: CapabilityRegistry, capability_id: str, _seen: Optional[Set[str]] = None
|
|
||||||
) -> Optional[MasterCapability]:
|
|
||||||
"""Follow redirects (from merge/deprecate) to the current canonical capability."""
|
|
||||||
seen = _seen if _seen is not None else set()
|
|
||||||
if capability_id in seen:
|
|
||||||
return None # redirect cycle guard
|
|
||||||
seen.add(capability_id)
|
|
||||||
cap = registry.capabilities.get(capability_id)
|
|
||||||
if cap is None:
|
|
||||||
return None
|
|
||||||
if cap.redirect_to:
|
|
||||||
return resolve(registry, cap.redirect_to, seen)
|
|
||||||
# terminal: only an ACTIVE capability resolves; a deprecated dead-end -> None
|
|
||||||
return cap if cap.state == LifecycleState.ACTIVE else None
|
|
||||||
|
|
||||||
|
|
||||||
def deprecate_capability(
|
|
||||||
registry: CapabilityRegistry,
|
|
||||||
capability_id: str,
|
|
||||||
redirect_to: Optional[str] = None,
|
|
||||||
provenance: Optional[Provenance] = None,
|
|
||||||
) -> IdentityLifecycleEvent:
|
|
||||||
cap = registry.capabilities.get(capability_id)
|
|
||||||
if cap is None:
|
|
||||||
raise KeyError(capability_id)
|
|
||||||
cap.state = LifecycleState.DEPRECATED
|
|
||||||
cap.redirect_to = redirect_to
|
|
||||||
event = IdentityLifecycleEvent(
|
|
||||||
event_id=_next_event_id(registry),
|
|
||||||
event_type=LifecycleEventType.REDIRECT if redirect_to else LifecycleEventType.DEPRECATE,
|
|
||||||
from_ids=[capability_id],
|
|
||||||
to_ids=[redirect_to] if redirect_to else [],
|
|
||||||
provenance=provenance or Provenance(author="system", basis="deprecate %s" % capability_id),
|
|
||||||
)
|
|
||||||
registry.lifecycle_events.append(event)
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def merge_capabilities(
|
|
||||||
registry: CapabilityRegistry,
|
|
||||||
from_id: str,
|
|
||||||
into_id: str,
|
|
||||||
provenance: Optional[Provenance] = None,
|
|
||||||
) -> IdentityLifecycleEvent:
|
|
||||||
"""Merge `from_id` into `into_id`: deprecate `from_id` with a redirect to `into_id`."""
|
|
||||||
if from_id not in registry.capabilities or into_id not in registry.capabilities:
|
|
||||||
raise KeyError("%s or %s" % (from_id, into_id))
|
|
||||||
frm = registry.capabilities[from_id]
|
|
||||||
frm.state = LifecycleState.DEPRECATED
|
|
||||||
frm.redirect_to = into_id
|
|
||||||
event = IdentityLifecycleEvent(
|
|
||||||
event_id=_next_event_id(registry),
|
|
||||||
event_type=LifecycleEventType.MERGE,
|
|
||||||
from_ids=[from_id],
|
|
||||||
to_ids=[into_id],
|
|
||||||
provenance=provenance or Provenance(author="system", basis="merge %s -> %s" % (from_id, into_id)),
|
|
||||||
)
|
|
||||||
registry.lifecycle_events.append(event)
|
|
||||||
return event
|
|
||||||
|
|
||||||
|
|
||||||
def split_capability(
|
|
||||||
registry: CapabilityRegistry,
|
|
||||||
from_id: str,
|
|
||||||
into_ids: List[str],
|
|
||||||
primary: Optional[str] = None,
|
|
||||||
provenance: Optional[Provenance] = None,
|
|
||||||
) -> IdentityLifecycleEvent:
|
|
||||||
"""Split `from_id` into several capabilities. The old id deprecates; it redirects
|
|
||||||
to `primary` only if one is given (else it resolves to None — split is ambiguous)."""
|
|
||||||
if from_id not in registry.capabilities:
|
|
||||||
raise KeyError(from_id)
|
|
||||||
frm = registry.capabilities[from_id]
|
|
||||||
frm.state = LifecycleState.DEPRECATED
|
|
||||||
frm.redirect_to = primary
|
|
||||||
event = IdentityLifecycleEvent(
|
|
||||||
event_id=_next_event_id(registry),
|
|
||||||
event_type=LifecycleEventType.SPLIT,
|
|
||||||
from_ids=[from_id],
|
|
||||||
to_ids=list(into_ids),
|
|
||||||
provenance=provenance or Provenance(author="system", basis="split %s" % from_id),
|
|
||||||
)
|
|
||||||
registry.lifecycle_events.append(event)
|
|
||||||
return event
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Derivation policy v0 for the Master Capability Registry.
|
|
||||||
|
|
||||||
Confidence + status are DERIVED from (relationship_type, evidence_kind) under a
|
|
||||||
versioned policy — never stored. HARD RULE baked in and structurally guarded: a
|
|
||||||
CERTIFICATION is a claim, never proof — no certification-backed rule may yield
|
|
||||||
CONFIRMED. CONFIRMED requires a CONFIRMS relation backed by a concrete ARTIFACT
|
|
||||||
(or an EXPERT assertion).
|
|
||||||
|
|
||||||
Python 3.9 compatible (no `|` unions).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .schemas import (
|
|
||||||
AssertionStatus,
|
|
||||||
Confidence,
|
|
||||||
EvidenceKind,
|
|
||||||
PolicyRule,
|
|
||||||
PolicyVersion,
|
|
||||||
RelationType,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _rule(rt: RelationType, ek: EvidenceKind, st: AssertionStatus, cf: Confidence) -> PolicyRule:
|
|
||||||
return PolicyRule(relationship_type=rt, evidence_kind=ek, status=st, confidence=cf)
|
|
||||||
|
|
||||||
|
|
||||||
# (relationship_type, evidence_kind) -> (status, confidence)
|
|
||||||
_V0_RULES = [
|
|
||||||
# concrete artifact / expert confirming the capability -> CONFIRMED
|
|
||||||
_rule(RelationType.CONFIRMS, EvidenceKind.ARTIFACT, AssertionStatus.CONFIRMED, Confidence.HIGH),
|
|
||||||
_rule(RelationType.CONFIRMS, EvidenceKind.EXPERT, AssertionStatus.CONFIRMED, Confidence.MEDIUM),
|
|
||||||
# equivalent capability — certificate or artifact behind it -> INFERRED (never confirmed)
|
|
||||||
_rule(RelationType.EQUIVALENT, EvidenceKind.CERTIFICATION, AssertionStatus.INFERRED, Confidence.HIGH),
|
|
||||||
_rule(RelationType.EQUIVALENT, EvidenceKind.ARTIFACT, AssertionStatus.INFERRED, Confidence.HIGH),
|
|
||||||
# supports — weaker
|
|
||||||
_rule(RelationType.SUPPORTS, EvidenceKind.CERTIFICATION, AssertionStatus.INFERRED, Confidence.LOW),
|
|
||||||
_rule(RelationType.SUPPORTS, EvidenceKind.ARTIFACT, AssertionStatus.INFERRED, Confidence.MEDIUM),
|
|
||||||
# requires = an obligation NEEDS the capability (relevance, not possession)
|
|
||||||
_rule(RelationType.REQUIRES, EvidenceKind.NONE, AssertionStatus.UNKNOWN, Confidence.LOW),
|
|
||||||
# broader/narrower certificate -> weak inference
|
|
||||||
_rule(RelationType.BROADER_THAN, EvidenceKind.CERTIFICATION, AssertionStatus.INFERRED, Confidence.LOW),
|
|
||||||
_rule(RelationType.NARROWER_THAN, EvidenceKind.CERTIFICATION, AssertionStatus.INFERRED, Confidence.LOW),
|
|
||||||
_rule(RelationType.RELATED_TO, EvidenceKind.CERTIFICATION, AssertionStatus.UNKNOWN, Confidence.LOW),
|
|
||||||
]
|
|
||||||
|
|
||||||
DEFAULT_POLICY = PolicyVersion(
|
|
||||||
policy_version="capability-policy-v0",
|
|
||||||
description="v0: certification never yields CONFIRMED; only CONFIRMS + ARTIFACT/EXPERT does.",
|
|
||||||
rules=_V0_RULES,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_no_certification_confirms(policy: PolicyVersion) -> None:
|
|
||||||
"""Structural guard for the hard rule: no CERTIFICATION-backed rule is CONFIRMED."""
|
|
||||||
for r in policy.rules:
|
|
||||||
if r.evidence_kind == EvidenceKind.CERTIFICATION and r.status == AssertionStatus.CONFIRMED:
|
|
||||||
raise ValueError(
|
|
||||||
"policy %s violates hard rule: certification -> confirmed (%s)"
|
|
||||||
% (policy.policy_version, r.relationship_type.value)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# fail fast at import: the shipped default must satisfy the hard rule
|
|
||||||
assert_no_certification_confirms(DEFAULT_POLICY)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Master Capability Registry v0 — Compliance Execution domain (Phase 2C).
|
|
||||||
|
|
||||||
Built from the Reasoning session per user directive, but this IS the Compliance
|
|
||||||
Execution model (Execution owns Capability). Third real instance of the
|
|
||||||
identity-machine pattern (after Master Controls and Master Obligations):
|
|
||||||
|
|
||||||
Candidate -> Normalization -> Dedup -> Stable Identity (MCAP) -> Typed Relations
|
|
||||||
|
|
||||||
KEY SENTENCE (stored vs derived):
|
|
||||||
STORED : identities, sources, relationship types, policy versions, lifecycle
|
|
||||||
events, provenance.
|
|
||||||
DERIVED : confidence, coverage and gap statements — computed on demand, NEVER
|
|
||||||
stored (see policy.py / engine.evaluate_relation).
|
|
||||||
|
|
||||||
These are APPLICATION/registry types, NOT compliance-meta-model classes. In
|
|
||||||
particular `CapabilityRelation` is relation METADATA inside the registry — it does
|
|
||||||
NOT introduce a new meta-model class. Whether a reified relation must enter the
|
|
||||||
frozen meta-model is a Meta-Model-Owner decision (architecture freeze v1.0),
|
|
||||||
deferred until a demonstrable failure case exists.
|
|
||||||
|
|
||||||
Self-contained (no Reasoning import — Reasoning consumes Capability, not the other
|
|
||||||
way round). Python 3.9 compatible (no `|` unions).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class Confidence(str, Enum):
|
|
||||||
HIGH = "high"
|
|
||||||
MEDIUM = "medium"
|
|
||||||
LOW = "low"
|
|
||||||
|
|
||||||
|
|
||||||
class AssertionStatus(str, Enum):
|
|
||||||
"""How well-established a capability claim is. A numeric score is presentation;
|
|
||||||
THIS type is the truth (derived from relationship type + evidence + policy)."""
|
|
||||||
|
|
||||||
DECLARED = "declared"
|
|
||||||
INFERRED = "inferred"
|
|
||||||
CONFIRMED = "confirmed"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class RelationType(str, Enum):
|
|
||||||
EQUIVALENT = "equivalent"
|
|
||||||
SUPPORTS = "supports"
|
|
||||||
REQUIRES = "requires"
|
|
||||||
CONFIRMS = "confirms"
|
|
||||||
BROADER_THAN = "broader_than"
|
|
||||||
NARROWER_THAN = "narrower_than"
|
|
||||||
RELATED_TO = "related_to"
|
|
||||||
|
|
||||||
|
|
||||||
class EvidenceKind(str, Enum):
|
|
||||||
CERTIFICATION = "certification" # a held certificate — a CLAIM, never proof
|
|
||||||
ARTIFACT = "artifact" # concrete doc/config/test/log
|
|
||||||
EXPERT = "expert" # human expert assertion
|
|
||||||
NONE = "none"
|
|
||||||
|
|
||||||
|
|
||||||
class LifecycleState(str, Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
DEPRECATED = "deprecated"
|
|
||||||
|
|
||||||
|
|
||||||
class LifecycleEventType(str, Enum):
|
|
||||||
MERGE = "merge"
|
|
||||||
SPLIT = "split"
|
|
||||||
DEPRECATE = "deprecate"
|
|
||||||
REDIRECT = "redirect"
|
|
||||||
|
|
||||||
|
|
||||||
class Provenance(BaseModel):
|
|
||||||
"""Every CURATED atom carries its own provenance (who / when / on what basis)."""
|
|
||||||
|
|
||||||
author: str = ""
|
|
||||||
asserted_at: Optional[str] = None # ISO timestamp passed in; never generated here
|
|
||||||
basis: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# ── stored: identity ──────────────────────────────────────────────────────
|
|
||||||
class MasterCapability(BaseModel):
|
|
||||||
capability_id: str # stable MCAP-xxxxx
|
|
||||||
name: str = ""
|
|
||||||
definition: str = ""
|
|
||||||
category: str = ""
|
|
||||||
domains: List[str] = Field(default_factory=list)
|
|
||||||
typical_evidence: List[str] = Field(default_factory=list)
|
|
||||||
version: int = 1
|
|
||||||
state: LifecycleState = LifecycleState.ACTIVE
|
|
||||||
redirect_to: Optional[str] = None # set on merge/deprecate
|
|
||||||
provenance: Provenance = Field(default_factory=Provenance)
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityCandidate(BaseModel):
|
|
||||||
raw_term: str # e.g. "Patch Management"
|
|
||||||
source: str = "" # e.g. "CRA:Annex I (2)(d)"
|
|
||||||
normalized: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# ── stored: typed relation metadata (NOT a meta-model class) ──────────────
|
|
||||||
class CapabilityRelation(BaseModel):
|
|
||||||
relation_id: str
|
|
||||||
source: str # external term/obligation/certification id, e.g. "certification:ISO27001"
|
|
||||||
target_capability_id: str # MCAP-...
|
|
||||||
relationship_type: RelationType
|
|
||||||
evidence_kind: EvidenceKind = EvidenceKind.NONE
|
|
||||||
provenance: Provenance = Field(default_factory=Provenance)
|
|
||||||
|
|
||||||
|
|
||||||
# ── stored: versioned derivation policy ───────────────────────────────────
|
|
||||||
class PolicyRule(BaseModel):
|
|
||||||
relationship_type: RelationType
|
|
||||||
evidence_kind: EvidenceKind
|
|
||||||
status: AssertionStatus
|
|
||||||
confidence: Confidence
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyVersion(BaseModel):
|
|
||||||
"""A versioned derivation policy. `policy_version` is recorded with every
|
|
||||||
assessment so "why did you say X last year" is answerable with the policy
|
|
||||||
as-of-then. Without this, `derived` and `auditable/reproducible` contradict."""
|
|
||||||
|
|
||||||
policy_version: str
|
|
||||||
description: str = ""
|
|
||||||
rules: List[PolicyRule] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── stored: identity lifecycle ────────────────────────────────────────────
|
|
||||||
class IdentityLifecycleEvent(BaseModel):
|
|
||||||
event_id: str
|
|
||||||
event_type: LifecycleEventType
|
|
||||||
from_ids: List[str] = Field(default_factory=list)
|
|
||||||
to_ids: List[str] = Field(default_factory=list)
|
|
||||||
at: Optional[str] = None
|
|
||||||
provenance: Provenance = Field(default_factory=Provenance)
|
|
||||||
|
|
||||||
|
|
||||||
# ── DERIVED — never stored ────────────────────────────────────────────────
|
|
||||||
class DerivedAssessment(BaseModel):
|
|
||||||
target_capability_id: str
|
|
||||||
status: AssertionStatus
|
|
||||||
confidence: Confidence
|
|
||||||
policy_version: str
|
|
||||||
explanation: str = ""
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""Company Intelligence (Phase 2A) — Company Capability Profile foundation.
|
|
||||||
|
|
||||||
The HEAD of the spine Company -> Capability -> Product -> Regulation -> Obligation
|
|
||||||
-> Procedure -> Evidence. Builds a CompanyContext into a CompanyCapabilityProfile
|
|
||||||
with a four-state trust model (declared/inferred/confirmed/unknown). A certification
|
|
||||||
yields at most an INFERRED candidate — never "erfuellt".
|
|
||||||
|
|
||||||
Reasoning OWNS the container + trust-state; it CONSUMES the Certification->Capability
|
|
||||||
mapping (Execution-owned) via an injected contract — no mapping data in product code.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .contract import CapabilityMappingEntry, CertificationCapabilityMap, EMPTY_MAPPING
|
|
||||||
from .engine import build_company_profile
|
|
||||||
from .schemas import (
|
|
||||||
CapabilityEvidence,
|
|
||||||
Certification,
|
|
||||||
CompanyCapabilityProfile,
|
|
||||||
CompanyContext,
|
|
||||||
Declaration,
|
|
||||||
ExistingEvidence,
|
|
||||||
ExistingProcess,
|
|
||||||
ExistingSystem,
|
|
||||||
OperationalCapability,
|
|
||||||
OperationalCapabilityCandidate,
|
|
||||||
VerificationStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"build_company_profile",
|
|
||||||
"CompanyContext",
|
|
||||||
"CompanyCapabilityProfile",
|
|
||||||
"Certification",
|
|
||||||
"Declaration",
|
|
||||||
"ExistingProcess",
|
|
||||||
"ExistingSystem",
|
|
||||||
"ExistingEvidence",
|
|
||||||
"CapabilityEvidence",
|
|
||||||
"OperationalCapabilityCandidate",
|
|
||||||
"OperationalCapability",
|
|
||||||
"VerificationStatus",
|
|
||||||
"CapabilityMappingEntry",
|
|
||||||
"CertificationCapabilityMap",
|
|
||||||
"EMPTY_MAPPING",
|
|
||||||
]
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"""Consumption contract for the Certification -> Capability mapping.
|
|
||||||
|
|
||||||
OWNERSHIP BOUNDARY (hard): the Capability Registry, CapabilityDefinition and the
|
|
||||||
Certification->Capability / Feature->Capability mapping RULES live in the Compliance
|
|
||||||
Execution domain. This Reasoning layer defines ONLY the shape it consumes and never
|
|
||||||
ships mapping DATA in product code — tests inject mocks, so the real table can only
|
|
||||||
ever live in Execution.
|
|
||||||
|
|
||||||
Execution will eventually provide CapabilityRegistry / CapabilityMapping /
|
|
||||||
CapabilityDefinition; Reasoning consumes exactly `OperationalCapabilityCandidate`
|
|
||||||
{capability_id, source, confidence, verification_status} (see schemas.py) and the
|
|
||||||
minimal mapping SHAPE below — nothing more.
|
|
||||||
|
|
||||||
Python 3.9 compatible (no `|` unions).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from compliance.reasoning.enums import Confidence
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityMappingEntry(BaseModel):
|
|
||||||
"""One mapping rule SHAPE: a certification implies candidate capabilities.
|
|
||||||
|
|
||||||
Contract type only. The actual table (which capabilities ISO27001 implies) is
|
|
||||||
Execution's DATA and MUST NOT be hard-coded here or anywhere in product code.
|
|
||||||
"""
|
|
||||||
|
|
||||||
capability_ids: List[str] = Field(default_factory=list)
|
|
||||||
confidence: Confidence = Confidence.MEDIUM
|
|
||||||
|
|
||||||
|
|
||||||
# certification_id -> entry. Injected at call time; product code holds NO entries.
|
|
||||||
CertificationCapabilityMap = Dict[str, CapabilityMappingEntry]
|
|
||||||
|
|
||||||
# Intentionally empty: without an injected mapping there are zero inferred
|
|
||||||
# candidates. This is the architectural guarantee that the registry lives only in
|
|
||||||
# the Compliance Execution domain.
|
|
||||||
EMPTY_MAPPING: CertificationCapabilityMap = {}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
"""Company Intelligence engine (Phase 2A) — build the Company Capability Profile.
|
|
||||||
|
|
||||||
Deterministic, no LLM/RAG. Turns a raw CompanyContext into capability evidence,
|
|
||||||
candidates and (only via explicit verification) confirmed capabilities.
|
|
||||||
|
|
||||||
HARD RULE enforced here: a certification yields at most an INFERRED candidate; it
|
|
||||||
can NEVER produce a CONFIRMED capability on its own. Only real ExistingEvidence
|
|
||||||
(`proves_capability_id`) promotes a capability to CONFIRMED. Certifications without
|
|
||||||
a known mapping yield evidence-of-claim but NO inferred capability (the mapping is
|
|
||||||
Execution's data, injected — never hard-coded here).
|
|
||||||
|
|
||||||
Python 3.9 compatible (no `|` unions).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from compliance.reasoning.enums import Confidence
|
|
||||||
|
|
||||||
from .contract import EMPTY_MAPPING, CertificationCapabilityMap
|
|
||||||
from .schemas import (
|
|
||||||
CapabilityEvidence,
|
|
||||||
CompanyCapabilityProfile,
|
|
||||||
CompanyContext,
|
|
||||||
OperationalCapability,
|
|
||||||
OperationalCapabilityCandidate,
|
|
||||||
VerificationStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _declared(context: CompanyContext) -> List[OperationalCapabilityCandidate]:
|
|
||||||
out: List[OperationalCapabilityCandidate] = []
|
|
||||||
for d in context.declarations:
|
|
||||||
out.append(
|
|
||||||
OperationalCapabilityCandidate(
|
|
||||||
capability_id=d.capability_id,
|
|
||||||
source="declaration:%s" % context.company_id,
|
|
||||||
confidence=Confidence.MEDIUM,
|
|
||||||
verification_status=VerificationStatus.DECLARED,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _from_certifications(
|
|
||||||
context: CompanyContext, mapping: CertificationCapabilityMap
|
|
||||||
) -> Tuple[List[CapabilityEvidence], List[OperationalCapabilityCandidate]]:
|
|
||||||
# refinement 1: certification -> evidence-of-capability (claim) -> inferred candidate
|
|
||||||
evidence: List[CapabilityEvidence] = []
|
|
||||||
inferred: List[OperationalCapabilityCandidate] = []
|
|
||||||
for cert in context.certifications:
|
|
||||||
source = "certification:%s" % cert.certification_id
|
|
||||||
evidence.append(
|
|
||||||
CapabilityEvidence(
|
|
||||||
source=source,
|
|
||||||
claim="Company holds %s" % (cert.name or cert.certification_id),
|
|
||||||
certification_id=cert.certification_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
entry = mapping.get(cert.certification_id)
|
|
||||||
if entry is None:
|
|
||||||
continue # no mapping known -> NO inferred capability (data is Execution's)
|
|
||||||
for cap_id in entry.capability_ids:
|
|
||||||
inferred.append(
|
|
||||||
OperationalCapabilityCandidate(
|
|
||||||
capability_id=cap_id,
|
|
||||||
source=source,
|
|
||||||
confidence=entry.confidence,
|
|
||||||
verification_status=VerificationStatus.INFERRED,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return evidence, inferred
|
|
||||||
|
|
||||||
|
|
||||||
def _confirmed_from_evidence(context: CompanyContext) -> List[OperationalCapability]:
|
|
||||||
proven: Dict[str, List[str]] = {}
|
|
||||||
for ev in context.evidence:
|
|
||||||
cap = ev.proves_capability_id
|
|
||||||
if not cap:
|
|
||||||
continue
|
|
||||||
proven.setdefault(cap, []).append(ev.evidence_id)
|
|
||||||
return [
|
|
||||||
OperationalCapability(
|
|
||||||
capability_id=cap,
|
|
||||||
verification_status=VerificationStatus.CONFIRMED,
|
|
||||||
confidence=Confidence.HIGH,
|
|
||||||
sources=sources,
|
|
||||||
)
|
|
||||||
for cap, sources in proven.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_company_profile(
|
|
||||||
context: CompanyContext, mapping: Optional[CertificationCapabilityMap] = None
|
|
||||||
) -> CompanyCapabilityProfile:
|
|
||||||
"""Build the Company Capability Profile from raw context + an injected mapping.
|
|
||||||
|
|
||||||
`mapping` defaults to EMPTY (no inferred candidates) so that the cert->capability
|
|
||||||
table can only ever come from the Compliance Execution domain.
|
|
||||||
"""
|
|
||||||
mapping = EMPTY_MAPPING if mapping is None else mapping
|
|
||||||
evidence, inferred = _from_certifications(context, mapping)
|
|
||||||
declared = _declared(context)
|
|
||||||
confirmed = _confirmed_from_evidence(context)
|
|
||||||
confirmed_ids = {oc.capability_id for oc in confirmed}
|
|
||||||
# a confirmed capability is no longer a mere candidate
|
|
||||||
candidates = [c for c in (declared + inferred) if c.capability_id not in confirmed_ids]
|
|
||||||
return CompanyCapabilityProfile(
|
|
||||||
company_id=context.company_id,
|
|
||||||
capability_evidence=evidence,
|
|
||||||
candidate_capabilities=candidates,
|
|
||||||
confirmed_capabilities=confirmed,
|
|
||||||
)
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
"""Company Intelligence (Phase 2A) — Company Capability Profile (domain objects).
|
|
||||||
|
|
||||||
This is the HEAD of the spine
|
|
||||||
|
|
||||||
Company -> (Operational) Capability -> Product -> Applicable Regulation ->
|
|
||||||
Obligation -> Procedure -> Evidence
|
|
||||||
|
|
||||||
and answers a DIFFERENT question than Regulatory Intelligence: not "which laws
|
|
||||||
apply to my product" but "which capabilities does my company already have, and
|
|
||||||
which regulatory obligations might they already cover".
|
|
||||||
|
|
||||||
HARD RULE (structural, not convention): a capability derived from a certification
|
|
||||||
is at most INFERRED — never CONFIRMED, never "erfuellt". A certification produces
|
|
||||||
EVIDENCE for a capability, an inference produces a CANDIDATE, and only checked
|
|
||||||
evidence produces a CONFIRMED capability. This keeps the company side inside
|
|
||||||
Welt 1 (potential), mirroring `ClaimCoverage` on the obligation side; it is NOT a
|
|
||||||
conformity verdict (`ComplianceStatus`, Welt 2, owned by Compliance Execution).
|
|
||||||
|
|
||||||
OWNERSHIP: Reasoning OWNS this CompanyContext container + the trust-state machine.
|
|
||||||
It does NOT own the Certification->Capability mapping RULES — those are the same
|
|
||||||
kind of rule as Feature->Capability and belong to the Compliance Execution
|
|
||||||
Capability Registry. This layer only CONSUMES `OperationalCapabilityCandidate`
|
|
||||||
{capability_id, source, confidence, verification_status} via an injected mapping
|
|
||||||
(see contract.py). No mapping DATA lives in product code (tests inject mocks).
|
|
||||||
|
|
||||||
Application/reasoning types, NOT compliance-meta-model classes (architecture
|
|
||||||
freeze v1.0 untouched). Python 3.9 compatible (no `|` unions).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from compliance.reasoning.enums import Confidence
|
|
||||||
|
|
||||||
|
|
||||||
class VerificationStatus(str, Enum):
|
|
||||||
"""Trust state of an operational capability — a FOURTH vocabulary.
|
|
||||||
|
|
||||||
Disjoint from ClaimCoverage (Welt 1, customer claim vs obligation),
|
|
||||||
ComplianceStatus (Welt 2, verified conformity) and DeltaType (RCI). It says
|
|
||||||
only how well-established a company CAPABILITY is, never whether an obligation
|
|
||||||
is met. Progression: DECLARED (customer says) -> INFERRED (a certification
|
|
||||||
implies it) -> CONFIRMED (checked against real evidence); UNKNOWN = no signal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
DECLARED = "declared"
|
|
||||||
INFERRED = "inferred"
|
|
||||||
CONFIRMED = "confirmed"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
# ── raw company inputs (the CompanyContext children) ──────────────────────
|
|
||||||
class Certification(BaseModel):
|
|
||||||
certification_id: str # e.g. "ISO27001"
|
|
||||||
name: str = ""
|
|
||||||
scope: str = "" # what the cert covers, customer-stated
|
|
||||||
|
|
||||||
|
|
||||||
class Declaration(BaseModel):
|
|
||||||
"""A customer statement that they have a capability ("we do patch management")."""
|
|
||||||
|
|
||||||
capability_id: str
|
|
||||||
statement: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ExistingProcess(BaseModel):
|
|
||||||
process_id: str
|
|
||||||
name: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ExistingSystem(BaseModel):
|
|
||||||
system_id: str
|
|
||||||
name: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ExistingEvidence(BaseModel):
|
|
||||||
"""A concrete artefact the company already holds (policy, audit log, SBOM ...).
|
|
||||||
|
|
||||||
`proves_capability_id` is the ONLY thing that may lift a capability to
|
|
||||||
CONFIRMED — and only when a human/engine has attached real evidence.
|
|
||||||
"""
|
|
||||||
|
|
||||||
evidence_id: str
|
|
||||||
evidence_type: str = "" # config_export/test_report/policy/audit_log/...
|
|
||||||
proves_capability_id: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
# ── intermediate: certification -> evidence-of-capability (refinement 1) ──
|
|
||||||
class CapabilityEvidence(BaseModel):
|
|
||||||
"""A certification does not yield a capability directly — only EVIDENCE for one.
|
|
||||||
|
|
||||||
"Company holds a certified ISMS" is the evidence/claim; capabilities are then
|
|
||||||
INFERRED from it via the injected (Execution-owned) mapping, never directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
source: str # provenance, e.g. "certification:ISO27001"
|
|
||||||
claim: str = ""
|
|
||||||
certification_id: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# ── consumed contract type (refinement 2) ─────────────────────────────────
|
|
||||||
class OperationalCapabilityCandidate(BaseModel):
|
|
||||||
"""The ONLY thing Reasoning consumes from Execution's capability mapping.
|
|
||||||
|
|
||||||
Named "operational" (organisational ability) to stay distinct from later
|
|
||||||
Product/AI/Safety capabilities. A candidate is always Welt 1 — DECLARED or
|
|
||||||
INFERRED — and never CONFIRMED on its own.
|
|
||||||
"""
|
|
||||||
|
|
||||||
capability_id: str
|
|
||||||
source: str
|
|
||||||
confidence: Confidence = Confidence.MEDIUM
|
|
||||||
verification_status: VerificationStatus = VerificationStatus.INFERRED
|
|
||||||
|
|
||||||
|
|
||||||
class OperationalCapability(BaseModel):
|
|
||||||
"""A capability the company actually has, CONFIRMED against real evidence."""
|
|
||||||
|
|
||||||
capability_id: str
|
|
||||||
verification_status: VerificationStatus
|
|
||||||
confidence: Confidence = Confidence.MEDIUM
|
|
||||||
sources: List[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── the container Reasoning OWNS (raw inputs) ─────────────────────────────
|
|
||||||
class CompanyContext(BaseModel):
|
|
||||||
company_id: str
|
|
||||||
certifications: List[Certification] = Field(default_factory=list)
|
|
||||||
declarations: List[Declaration] = Field(default_factory=list)
|
|
||||||
processes: List[ExistingProcess] = Field(default_factory=list)
|
|
||||||
systems: List[ExistingSystem] = Field(default_factory=list)
|
|
||||||
evidence: List[ExistingEvidence] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
# ── derived view (the Company Capability Profile) ─────────────────────────
|
|
||||||
class CompanyCapabilityProfile(BaseModel):
|
|
||||||
"""Derived: capability evidence + candidates (declared/inferred) + confirmed.
|
|
||||||
|
|
||||||
`candidate_capabilities` NEVER auto-promote to `confirmed_capabilities`; only
|
|
||||||
explicit ExistingEvidence does that. The hard rule is enforced in engine.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
company_id: str
|
|
||||||
capability_evidence: List[CapabilityEvidence] = Field(default_factory=list)
|
|
||||||
candidate_capabilities: List[OperationalCapabilityCandidate] = Field(default_factory=list)
|
|
||||||
confirmed_capabilities: List[OperationalCapability] = Field(default_factory=list)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""Interpretation-in-Map — evaluate a customer interpretation within the map.
|
|
||||||
|
|
||||||
Thin adapter over the existing `assess_interpretation`: it judges the customer's
|
|
||||||
reading against the regulations/obligations actually present in the product's
|
|
||||||
RegulatoryMap, and flags touched unsupported domains as future_corpus_needed
|
|
||||||
instead of pseudo-evaluating them. No new legal reasoning, no RCI, no UI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .adapter import interpret_in_map
|
|
||||||
from .schemas import InterpretationInMapRequest, InterpretationInMapResult
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"interpret_in_map",
|
|
||||||
"InterpretationInMapRequest",
|
|
||||||
"InterpretationInMapResult",
|
|
||||||
]
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""Interpretation-in-Map adapter (step 5).
|
|
||||||
|
|
||||||
Evaluates a customer interpretation WITHIN the already-built RegulatoryMap. It
|
|
||||||
reuses the existing `assess_interpretation` (no new legal engine), restricts the
|
|
||||||
affected regulations/obligations to those present in the map, and reports any
|
|
||||||
touched unsupported domain (wastewater/chemicals/...) as future_corpus_needed
|
|
||||||
rather than pseudo-evaluating it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
from compliance.reasoning.enums import InterpretationVerdict
|
|
||||||
from compliance.reasoning.interpretation_engine import assess_interpretation
|
|
||||||
from compliance.regulatory_map.schemas import RegulatoryMap
|
|
||||||
|
|
||||||
from .schemas import InterpretationInMapResult
|
|
||||||
|
|
||||||
_LABEL: Dict[InterpretationVerdict, str] = {
|
|
||||||
InterpretationVerdict.PLAUSIBLE: "plausibel",
|
|
||||||
InterpretationVerdict.TOO_NARROW: "zu eng",
|
|
||||||
InterpretationVerdict.TOO_BROAD: "zu weit",
|
|
||||||
InterpretationVerdict.PARTIALLY_CORRECT: "teilweise korrekt",
|
|
||||||
InterpretationVerdict.UNSUPPORTED: "nicht belegt",
|
|
||||||
InterpretationVerdict.UNCERTAIN: "unsicher",
|
|
||||||
}
|
|
||||||
|
|
||||||
# domain -> keywords that signal the interpretation is ABOUT that (uncovered) domain.
|
|
||||||
_ENV_KEYWORDS: Dict[str, List[str]] = {
|
|
||||||
"environment_water": ["abwasser", "wastewater", "gewässer", "gewaesser", "einleitung", "abfluss"],
|
|
||||||
"chemicals": ["chemikalie", "reach", "clp", "reinigungsmittel", "biozid", "gefahrstoff", "detergenz", "lösemittel", "loesemittel"],
|
|
||||||
"environment_air": ["luft", "emission", "voc", "immission", "abluft", "verbrennung"],
|
|
||||||
"waste": ["abfall", "entsorgung", "weee", "recycling"],
|
|
||||||
"energy_resources": ["energie", "ökodesign", "oekodesign", "verbrauch"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _touches(text: str, domain: str) -> bool:
|
|
||||||
low = text.lower()
|
|
||||||
return any(kw in low for kw in _ENV_KEYWORDS.get(domain, []))
|
|
||||||
|
|
||||||
|
|
||||||
def _explain(label: str, detail: str, affected_regs: List[str], future_domains: List[str], in_scope: bool) -> str:
|
|
||||||
base = "Ihre Interpretation ist wahrscheinlich %s." % label
|
|
||||||
if detail:
|
|
||||||
base += " " + detail
|
|
||||||
if affected_regs:
|
|
||||||
base += " Betroffen in Ihrer Map: %s." % ", ".join(affected_regs)
|
|
||||||
if future_domains:
|
|
||||||
base += (
|
|
||||||
" Für %s liegt noch kein Regelkorpus vor — diese Aspekte werden nicht bewertet (future_corpus_needed)."
|
|
||||||
% ", ".join(future_domains)
|
|
||||||
)
|
|
||||||
if not in_scope and not future_domains:
|
|
||||||
base += " Diese Auslegung betrifft kein Regelwerk Ihrer aktuellen Produkt-Map."
|
|
||||||
return base
|
|
||||||
|
|
||||||
|
|
||||||
def interpret_in_map(reg_map: RegulatoryMap, interpretation: str) -> InterpretationInMapResult:
|
|
||||||
a = assess_interpretation(interpretation) # existing engine — no new reasoning
|
|
||||||
|
|
||||||
map_reg_ids = (
|
|
||||||
{v.regulation_id for v in reg_map.applicable_regulations}
|
|
||||||
| {v.regulation_id for v in reg_map.uncertain_regulations}
|
|
||||||
| {v.regulation_id for v in reg_map.excluded_regulations}
|
|
||||||
)
|
|
||||||
map_ob_ids = {o.obligation_id for v in reg_map.applicable_regulations for o in v.obligations}
|
|
||||||
uncertain_ids = {v.regulation_id for v in reg_map.uncertain_regulations}
|
|
||||||
|
|
||||||
affected_regs = [r for r in a.affected_regulations if r in map_reg_ids]
|
|
||||||
affected_obs = [o for o in a.affected_obligations if o in map_ob_ids]
|
|
||||||
related_unc = [r for r in a.affected_regulations if r in uncertain_ids]
|
|
||||||
future = [d for d in reg_map.unsupported_domains if _touches(interpretation, d.domain)]
|
|
||||||
in_scope = bool(affected_regs or affected_obs)
|
|
||||||
|
|
||||||
return InterpretationInMapResult(
|
|
||||||
raw_interpretation=interpretation,
|
|
||||||
assessment=a.assessment,
|
|
||||||
in_scope_of_map=in_scope,
|
|
||||||
affected_regulations=affected_regs,
|
|
||||||
affected_obligations=affected_obs,
|
|
||||||
related_uncertainties=related_unc,
|
|
||||||
future_corpus_domains=future,
|
|
||||||
corrected_interpretation=a.corrected_interpretation,
|
|
||||||
risks=a.risks,
|
|
||||||
legal_basis_refs=a.legal_basis_refs,
|
|
||||||
explanation=_explain(_LABEL[a.assessment], a.explanation, affected_regs, [d.domain for d in future], in_scope),
|
|
||||||
confidence=a.confidence,
|
|
||||||
)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user