Compare commits
12 Commits
cf6005a47c
...
4087bb5f18
| Author | SHA1 | Date | |
|---|---|---|---|
| 4087bb5f18 | |||
| 85e758b250 | |||
| 916dec87ee | |||
| 5fc16dd61d | |||
| 46278cda5b | |||
| 75174273f4 | |||
| 6baf44ac84 | |||
| 299375e486 | |||
| 2b1fe3713a | |||
| 872145d883 | |||
| 9bdaa28038 | |||
| 0a84c747f2 |
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface HistoryEntry {
|
||||
cid: string
|
||||
version: string | null
|
||||
document_type: string | null
|
||||
document_id: string | null
|
||||
parent_cid: string | null
|
||||
created_at: string | null
|
||||
checksum: string | null
|
||||
}
|
||||
|
||||
interface DiffResponse {
|
||||
kind: 'text' | 'binary'
|
||||
cid_a: string
|
||||
cid_b: string
|
||||
metadata_diff: Record<string, { old: unknown; new: unknown }>
|
||||
diff?: string
|
||||
added_lines?: number
|
||||
removed_lines?: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cid: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function shorten(cid: string): string {
|
||||
if (cid.length <= 14) return cid
|
||||
return cid.slice(0, 8) + '…' + cid.slice(-6)
|
||||
}
|
||||
|
||||
export default function CIDHistoryModal({ cid, onClose }: Props) {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [diffPair, setDiffPair] = useState<{ a: string; b: string } | null>(null)
|
||||
const [diff, setDiff] = useState<DiffResponse | null>(null)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetch(`/api/sdk/v1/dsms/documents/${encodeURIComponent(cid)}/history`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const json = await r.json()
|
||||
if (!cancel) setHistory(json.history || [])
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancel) setError(e?.message || 'Fehler beim Laden')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancel) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [cid])
|
||||
|
||||
async function loadDiff(a: string, b: string) {
|
||||
setDiffPair({ a, b })
|
||||
setDiff(null)
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sdk/v1/dsms/documents/${encodeURIComponent(a)}/diff/${encodeURIComponent(b)}`
|
||||
)
|
||||
if (res.ok) {
|
||||
const json = (await res.json()) as DiffResponse
|
||||
setDiff(json)
|
||||
} else {
|
||||
setDiff({ kind: 'binary', cid_a: a, cid_b: b, metadata_diff: {}, note: `HTTP ${res.status}` })
|
||||
}
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col bg-white dark:bg-gray-800 rounded-xl shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">DSMS-Versionsverlauf</h2>
|
||||
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400">{shorten(cid)}</code>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 text-sm">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
||||
{loading && <div className="text-sm text-gray-500">Verlauf wird geladen…</div>}
|
||||
{error && <div className="text-sm text-red-600 dark:text-red-400">{error}</div>}
|
||||
|
||||
{!loading && !error && history.length === 0 && (
|
||||
<div className="text-sm text-gray-500 italic">
|
||||
Kein Versionsverlauf gefunden. Diese CID hat keine parent_cid-Kette.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && history.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{history.length} Version{history.length > 1 ? 'en' : ''} in der Kette (neueste oben).
|
||||
</div>
|
||||
<ol className="relative border-l-2 border-emerald-500/40 pl-4 space-y-3">
|
||||
{history.map((entry, idx) => {
|
||||
const next = history[idx + 1]
|
||||
return (
|
||||
<li key={entry.cid} className="relative">
|
||||
<div className="absolute -left-[1.4rem] top-1.5 w-3 h-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-gray-800" />
|
||||
<div className="bg-gray-50 dark:bg-gray-900/40 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Version {entry.version || '?'} {idx === 0 && <span className="ml-2 text-[10px] text-emerald-600 font-semibold">AKTUELL</span>}
|
||||
</div>
|
||||
<code className="text-[10px] font-mono text-gray-500 dark:text-gray-400 break-all">{entry.cid}</code>
|
||||
</div>
|
||||
{next && (
|
||||
<button
|
||||
onClick={() => loadDiff(next.cid, entry.cid)}
|
||||
className="shrink-0 text-[11px] text-purple-600 hover:text-purple-800 dark:text-purple-400 hover:underline"
|
||||
title="Aenderungen zur Vorversion anzeigen"
|
||||
>
|
||||
Diff zu V{next.version || '?'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400 flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
{entry.document_type && <span>Typ: {entry.document_type}</span>}
|
||||
{entry.document_id && <span>Dok-ID: {entry.document_id}</span>}
|
||||
{entry.created_at && <span>{new Date(entry.created_at).toLocaleString('de-DE')}</span>}
|
||||
</div>
|
||||
{entry.checksum && (
|
||||
<div className="mt-1 text-[10px] text-gray-400 font-mono">SHA-256: {entry.checksum.slice(0, 16)}…</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
|
||||
{diffPair && (
|
||||
<div className="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-gray-900 dark:text-white">
|
||||
Diff: {shorten(diffPair.a)} → {shorten(diffPair.b)}
|
||||
</h3>
|
||||
<button onClick={() => { setDiff(null); setDiffPair(null) }} className="text-[11px] text-gray-500 hover:text-gray-700">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
{diffLoading && <div className="text-xs text-gray-500">Diff wird geladen…</div>}
|
||||
{!diffLoading && diff && (
|
||||
<>
|
||||
{Object.keys(diff.metadata_diff || {}).length > 0 && (
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300 mb-1">Metadaten-Aenderungen</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{Object.entries(diff.metadata_diff).map(([field, { old, new: nv }]) => (
|
||||
<tr key={field} className="border-b border-gray-100 dark:border-gray-800">
|
||||
<td className="py-0.5 pr-2 font-mono text-[10px] text-gray-500">{field}</td>
|
||||
<td className="py-0.5 pr-2 text-red-600 dark:text-red-400 line-through">{JSON.stringify(old)}</td>
|
||||
<td className="py-0.5 text-green-700 dark:text-green-400">{JSON.stringify(nv)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{diff.kind === 'text' && diff.diff && (
|
||||
<>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
{diff.added_lines ?? 0} Zeilen hinzu, {diff.removed_lines ?? 0} entfernt
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono whitespace-pre-wrap bg-gray-900 text-gray-100 p-3 rounded max-h-64 overflow-y-auto">
|
||||
{diff.diff}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{diff.kind === 'binary' && (
|
||||
<div className="text-xs text-amber-700 dark:text-amber-400 italic">
|
||||
{diff.note || 'Binaere Datei — kein Text-Diff verfuegbar.'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||
import CIDHistoryModal from './_components/CIDHistoryModal'
|
||||
|
||||
const ENTITY_LABELS: Record<string, string> = {
|
||||
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||
@@ -16,8 +18,24 @@ const ACTION_COLORS: Record<string, string> = {
|
||||
|
||||
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||
|
||||
// new_value may be a plain CID (from Python evidence flow) or a JSON envelope
|
||||
// {"cid":"X","filename":"...","size":"..."} (from the Go IACE tech-file flow).
|
||||
function extractCID(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (typeof parsed.cid === 'string') return parsed.cid
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export default function AuditTimelinePage() {
|
||||
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||
const [historyCID, setHistoryCID] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
@@ -58,16 +76,18 @@ export default function AuditTimelinePage() {
|
||||
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry) => (
|
||||
<TimelineEntry key={entry.id} entry={entry} />
|
||||
<TimelineEntry key={entry.id} entry={entry} onShowHistory={setHistoryCID} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyCID && <CIDHistoryModal cid={historyCID} onClose={() => setHistoryCID(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
function TimelineEntry({ entry, onShowHistory }: { entry: AuditEntry; onShowHistory: (cid: string) => void }) {
|
||||
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||
const date = new Date(entry.performed_at)
|
||||
@@ -94,7 +114,7 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||
)}
|
||||
{isCID && entry.new_value && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
@@ -102,6 +122,16 @@ function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||
</code>
|
||||
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (entry.new_value) onShowHistory(extractCID(entry.new_value))
|
||||
}}
|
||||
className="text-[10px] text-purple-600 hover:text-purple-800 dark:text-purple-400 underline-offset-2 hover:underline"
|
||||
title="DSMS-Versionsverlauf und Diff zur Vorversion anzeigen"
|
||||
>
|
||||
Verlauf anzeigen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateAP } from './useFMEA'
|
||||
|
||||
describe('calculateAP — AIAG-VDA 2019 Handbook Action Priority', () => {
|
||||
it('returns H for severity 10 with mid occurrence', () => {
|
||||
expect(calculateAP(10, 5, 5)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns H for severity 9 with low detection', () => {
|
||||
expect(calculateAP(9, 4, 7)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 9 with low occurrence and good detection', () => {
|
||||
expect(calculateAP(9, 2, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for severity 9 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(9, 1, 4)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns H for severity 7 with high occurrence', () => {
|
||||
expect(calculateAP(7, 5, 1)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 7 with mid occurrence', () => {
|
||||
expect(calculateAP(7, 3, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for low-severity well-controlled mode', () => {
|
||||
expect(calculateAP(3, 1, 1)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns L for severity 5 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(5, 1, 1)).toBe('L')
|
||||
})
|
||||
})
|
||||
@@ -156,5 +156,52 @@ export function useFMEA(projectId: string) {
|
||||
// Get unique components for the suggest button
|
||||
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||
|
||||
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
||||
/**
|
||||
* Accept a suggested FM: build an FMEA row from the FM defaults, prepend it
|
||||
* to the table state, and remove the FM from the suggestion list.
|
||||
* Returns false if the (component, fm.id) combo already exists in rows.
|
||||
*/
|
||||
function acceptSuggestion(fm: FailureMode, componentId: string): boolean {
|
||||
const comp = components.find((c) => c.id === componentId)
|
||||
if (!comp) return false
|
||||
const dup = rows.find((r) => r.component.id === componentId && r.failureMode.id === fm.id)
|
||||
if (dup) {
|
||||
// Still drop the suggestion so the UI does not keep offering it.
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== fm.id))
|
||||
return false
|
||||
}
|
||||
const s = fm.default_severity || 5
|
||||
const o = fm.default_occurrence || 5
|
||||
const d = fm.default_detection || 5
|
||||
const newRow: FMEARow = {
|
||||
component: comp,
|
||||
failureMode: fm,
|
||||
severity: s,
|
||||
occurrence: o,
|
||||
detection: d,
|
||||
rpz: s * o * d,
|
||||
ap: calculateAP(s, o, d),
|
||||
}
|
||||
setRows((prev) => [newRow, ...prev].sort((a, b) => b.rpz - a.rpz))
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fm.id))
|
||||
return true
|
||||
}
|
||||
|
||||
function rejectSuggestion(fmId: string) {
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fmId))
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
loading,
|
||||
stats,
|
||||
components,
|
||||
suggestFMs,
|
||||
suggesting,
|
||||
suggestions,
|
||||
suggestSource,
|
||||
setSuggestions,
|
||||
acceptSuggestion,
|
||||
rejectSuggestion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||
|
||||
@@ -27,8 +27,17 @@ function rpzLabel(rpz: number): string {
|
||||
|
||||
export default function FMEAPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
||||
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions, acceptSuggestion, rejectSuggestion } = useFMEA(projectId)
|
||||
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||
const [acceptedCount, setAcceptedCount] = useState(0)
|
||||
|
||||
// Reset accepted-count when a fresh suggestion run is loaded or the panel closes.
|
||||
useEffect(() => {
|
||||
if (suggesting) setAcceptedCount(0)
|
||||
}, [suggesting])
|
||||
useEffect(() => {
|
||||
if (suggestions.length === 0) setAcceptedCount(0)
|
||||
}, [suggestions.length])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -97,26 +106,60 @@ export default function FMEAPage() {
|
||||
{suggestions.length > 0 && (
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
||||
</h3>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek-Fallback'}
|
||||
</h3>
|
||||
{acceptedCount > 0 && (
|
||||
<div className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||
{acceptedCount} Vorschlag{acceptedCount > 1 ? 'e' : ''} uebernommen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{suggestions.map((fm, i) => (
|
||||
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>S={fm.default_severity}</span>
|
||||
<span>O={fm.default_occurrence}</span>
|
||||
<span>D={fm.default_detection}</span>
|
||||
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
||||
{suggestions.map((fm) => {
|
||||
const rpz = fm.default_severity * fm.default_occurrence * fm.default_detection
|
||||
return (
|
||||
<div key={fm.id} className="flex items-start justify-between gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>S={fm.default_severity}</span>
|
||||
<span>O={fm.default_occurrence}</span>
|
||||
<span>D={fm.default_detection}</span>
|
||||
<span className={`font-bold ${rpz > 200 ? 'text-red-600' : rpz > 100 ? 'text-orange-600' : 'text-gray-500'}`}>RPZ={rpz}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!suggestComp) return
|
||||
const ok = acceptSuggestion(fm, suggestComp)
|
||||
if (ok) setAcceptedCount((c) => c + 1)
|
||||
}}
|
||||
disabled={!suggestComp}
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded transition-colors"
|
||||
title="Diesen Fehlermodus der FMEA-Tabelle hinzufuegen"
|
||||
>
|
||||
Uebernehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectSuggestion(fm.id)}
|
||||
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-xs font-medium rounded transition-colors"
|
||||
title="Diesen Vorschlag verwerfen"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[10px] text-purple-700 dark:text-purple-400 mt-3">
|
||||
Hinweis: Uebernommene Fehlermodi erscheinen sofort in der Tabelle unten. Bewertung (S/O/D) ist anpassbar — Standardwerte aus der Bibliothek.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface NormMapping {
|
||||
region: string
|
||||
identifier: string
|
||||
relation: string
|
||||
confidence: string
|
||||
notes?: string
|
||||
source_url?: string
|
||||
}
|
||||
|
||||
interface CrossRefResponse {
|
||||
norm_id: string
|
||||
mappings: NormMapping[]
|
||||
notes?: string
|
||||
batch_id?: string
|
||||
}
|
||||
|
||||
const RELATION_COLORS: Record<string, string> = {
|
||||
identical: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300',
|
||||
equivalent: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
|
||||
partial: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300',
|
||||
supersedes: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
|
||||
superseded_by: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400',
|
||||
}
|
||||
|
||||
const CONFIDENCE_COLORS: Record<string, string> = {
|
||||
verified: 'text-emerald-700 dark:text-emerald-300 font-semibold',
|
||||
high: 'text-blue-700 dark:text-blue-300',
|
||||
medium: 'text-amber-700 dark:text-amber-300',
|
||||
low: 'text-red-700 dark:text-red-300',
|
||||
}
|
||||
|
||||
const REGION_LABELS: Record<string, string> = {
|
||||
'EU-DIN': 'EU (DIN)',
|
||||
'INTL-ISO': 'International (ISO/IEC)',
|
||||
'US-ANSI': 'US — ANSI',
|
||||
'US-NFPA': 'US — NFPA',
|
||||
'US-UL': 'US — UL',
|
||||
'US-OSHA': 'US — OSHA',
|
||||
'US-ASME': 'US — ASME',
|
||||
'US-ASTM': 'US — ASTM',
|
||||
'US-SAE': 'US — SAE',
|
||||
'US-NIOSH': 'US — NIOSH',
|
||||
'US-FDA': 'US — FDA',
|
||||
'US-EPA': 'US — EPA',
|
||||
'US-NEMA': 'US — NEMA',
|
||||
'US-NSF': 'US — NSF',
|
||||
'US-API': 'US — API',
|
||||
'US-CPSC': 'US — CPSC',
|
||||
'US-AHRI': 'US — AHRI',
|
||||
'US-ASHRAE': 'US — ASHRAE',
|
||||
'US-FCC': 'US — FCC',
|
||||
'US-DOT': 'US — DOT',
|
||||
'CN-GB': 'China (GB)',
|
||||
'JP-JIS': 'Japan (JIS)',
|
||||
}
|
||||
|
||||
function formatRegion(region: string): string {
|
||||
return REGION_LABELS[region] || region
|
||||
}
|
||||
|
||||
interface Props {
|
||||
normId: string
|
||||
}
|
||||
|
||||
export default function NormCrossRefPanel({ normId }: Props) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [data, setData] = useState<CrossRefResponse | null>(null)
|
||||
|
||||
const handleLoad = async () => {
|
||||
if (loaded || loading) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/norms-library/${encodeURIComponent(normId)}/crossref`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = (await res.json()) as CrossRefResponse
|
||||
setData(json)
|
||||
setLoaded(true)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded && !loading && !error) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoad}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-200 font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
Internationale Aequivalenzen anzeigen (DIN/ANSI/GB/JIS)
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-xs text-gray-500 dark:text-gray-400">Cross-Reference wird geladen…</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
Cross-Reference konnte nicht geladen werden: {error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.mappings.length === 0) {
|
||||
return (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
Fuer diese Norm liegt (noch) kein internationales Mapping in der Bibliothek vor.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Internationale Aequivalenzen
|
||||
</div>
|
||||
{data.notes && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 italic">{data.notes}</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="text-left py-1 pr-3 font-medium">Region</th>
|
||||
<th className="text-left py-1 pr-3 font-medium">Identifier</th>
|
||||
<th className="text-left py-1 pr-3 font-medium">Relation</th>
|
||||
<th className="text-left py-1 pr-3 font-medium">Confidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.mappings.map((m, i) => (
|
||||
<tr key={i} className="border-b border-gray-100 dark:border-gray-800 last:border-0 align-top">
|
||||
<td className="py-1 pr-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">{formatRegion(m.region)}</td>
|
||||
<td className="py-1 pr-3 font-mono text-gray-800 dark:text-gray-200">
|
||||
{m.source_url ? (
|
||||
<a href={m.source_url} target="_blank" rel="noopener noreferrer" className="text-purple-600 hover:text-purple-800 dark:text-purple-400">
|
||||
{m.identifier}
|
||||
</a>
|
||||
) : (
|
||||
m.identifier
|
||||
)}
|
||||
{m.notes && (
|
||||
<div className="text-[10px] text-gray-500 dark:text-gray-400 mt-0.5 font-sans">{m.notes}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 pr-3">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded ${RELATION_COLORS[m.relation] || 'bg-gray-100 dark:bg-gray-800 text-gray-600'}`}>
|
||||
{m.relation}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`py-1 pr-3 ${CONFIDENCE_COLORS[m.confidence] || 'text-gray-600'}`}>
|
||||
{m.confidence}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
Vor Nutzung in einem Drittmarkt durch eine sachkundige Person verifizieren.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react'
|
||||
import { SearchInput, FilterDropdown, Pagination, ExpandableRow, ExternalLinkIcon } from './LibraryTable'
|
||||
import NormCrossRefPanel from './NormCrossRefPanel'
|
||||
|
||||
export interface Norm {
|
||||
id: string
|
||||
@@ -128,6 +129,7 @@ export default function NormenTab({ norms }: Props) {
|
||||
{n.tags.map((t) => <span key={t} className="px-1.5 py-0.5 rounded text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
<NormCrossRefPanel normId={n.id} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -73,6 +73,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/benchmark" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>} label="Branchen-Benchmark" isActive={pathname?.startsWith('/sdk/benchmark') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* CRA Compliance */}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Contract tests for the new /norms-library/crossref endpoints.
|
||||
// These are the practical equivalent of an OpenAPI snapshot: they pin
|
||||
// the response shape so a downstream consumer (admin-compliance,
|
||||
// developer-portal, SDK) cannot be silently broken.
|
||||
|
||||
func TestGetNormCrossRef_KnownID_ReturnsExpectedShape(t *testing.T) {
|
||||
handler := &IACEHandler{}
|
||||
w, c := newTestContext("GET", "/norms-library/ISO-12100/crossref", nil, nil, gin.Params{
|
||||
{Key: "id", Value: "ISO-12100"},
|
||||
})
|
||||
|
||||
handler.GetNormCrossRef(c)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
NormID string `json:"norm_id"`
|
||||
Mappings []struct {
|
||||
Region string `json:"region"`
|
||||
Identifier string `json:"identifier"`
|
||||
Relation string `json:"relation"`
|
||||
Confidence string `json:"confidence"`
|
||||
} `json:"mappings"`
|
||||
BatchID string `json:"batch_id"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response not parsable: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if resp.NormID != "ISO-12100" {
|
||||
t.Errorf("expected norm_id ISO-12100, got %q", resp.NormID)
|
||||
}
|
||||
if len(resp.Mappings) < 3 {
|
||||
t.Errorf("expected ISO-12100 to have at least 3 mappings, got %d", len(resp.Mappings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNormCrossRef_MissingID_Returns400(t *testing.T) {
|
||||
handler := &IACEHandler{}
|
||||
w, c := newTestContext("GET", "/norms-library//crossref", nil, nil, gin.Params{
|
||||
{Key: "id", Value: ""},
|
||||
})
|
||||
|
||||
handler.GetNormCrossRef(c)
|
||||
if w.Code != 400 {
|
||||
t.Errorf("expected 400 for missing id, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNormCrossRef_UnknownID_ReturnsEmptyMappings(t *testing.T) {
|
||||
handler := &IACEHandler{}
|
||||
w, c := newTestContext("GET", "/norms-library/ISO-DOESNOTEXIST/crossref", nil, nil, gin.Params{
|
||||
{Key: "id", Value: "ISO-DOESNOTEXIST"},
|
||||
})
|
||||
|
||||
handler.GetNormCrossRef(c)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200 for unknown id (returns empty), got %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
NormID string `json:"norm_id"`
|
||||
Mappings []interface{} `json:"mappings"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response not parsable: %v", err)
|
||||
}
|
||||
if resp.NormID != "ISO-DOESNOTEXIST" {
|
||||
t.Errorf("expected norm_id to echo back, got %q", resp.NormID)
|
||||
}
|
||||
if len(resp.Mappings) != 0 {
|
||||
t.Errorf("expected empty mappings, got %d", len(resp.Mappings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNormCrossRefs_ReturnsAll(t *testing.T) {
|
||||
handler := &IACEHandler{}
|
||||
w, c := newTestContext("GET", "/norms-library/crossref", nil, nil, nil)
|
||||
|
||||
handler.ListNormCrossRefs(c)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Entries []struct {
|
||||
NormID string `json:"norm_id"`
|
||||
} `json:"entries"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response not parsable: %v", err)
|
||||
}
|
||||
if resp.Total != 671 {
|
||||
t.Errorf("expected 671 cross-ref entries, got %d", resp.Total)
|
||||
}
|
||||
if len(resp.Entries) != resp.Total {
|
||||
t.Errorf("entries count %d does not match total %d", len(resp.Entries), resp.Total)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -412,7 +413,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("PDF export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.pdf", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.pdf", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.pdf"`, safeName))
|
||||
c.Data(http.StatusOK, "application/pdf", data)
|
||||
|
||||
@@ -422,7 +423,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Excel export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.xlsx", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.xlsx", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.xlsx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||
|
||||
@@ -432,7 +433,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("DOCX export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.docx", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.docx", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.docx"`, safeName))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
|
||||
|
||||
@@ -442,7 +443,7 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Markdown export failed: %v", err)})
|
||||
return
|
||||
}
|
||||
archiveTechFile(data, fmt.Sprintf("CE-Akte-%s.md", safeName), projectID.String())
|
||||
h.archiveTechFile(c, data, fmt.Sprintf("CE-Akte-%s.md", safeName), projectID)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="CE-Akte-%s.md"`, safeName))
|
||||
c.Data(http.StatusOK, "text/markdown", data)
|
||||
|
||||
@@ -468,7 +469,30 @@ func (h *IACEHandler) ExportTechFile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// archiveTechFile stores a tech-file export to DSMS (best-effort, non-blocking).
|
||||
func archiveTechFile(data []byte, filename, projectID string) {
|
||||
dsms.Archive(data, filename, "ce_techfile", projectID, "1")
|
||||
// archiveTechFile stores a tech-file export to DSMS (best-effort, non-blocking)
|
||||
// AND records the resulting CID in the IACE audit trail so the export is
|
||||
// traceable. The "new_values" JSON carries the CID + filename so the audit
|
||||
// timeline can later resolve the CID against the DSMS gateway for verify.
|
||||
func (h *IACEHandler) archiveTechFile(c *gin.Context, data []byte, filename string, projectID uuid.UUID) {
|
||||
result := dsms.Archive(data, filename, "ce_techfile", projectID.String(), "1")
|
||||
if result == nil || result.CID == "" {
|
||||
return
|
||||
}
|
||||
payload := map[string]string{
|
||||
"cid": result.CID,
|
||||
"filename": filename,
|
||||
"size": fmt.Sprintf("%d", result.Size),
|
||||
}
|
||||
newValues, _ := json.Marshal(payload)
|
||||
userID := rbac.GetUserID(c)
|
||||
_ = h.store.AddAuditEntry(
|
||||
c.Request.Context(),
|
||||
projectID,
|
||||
"tech_file_export",
|
||||
projectID,
|
||||
iace.AuditActionCreate,
|
||||
userID.String(),
|
||||
nil,
|
||||
newValues,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package dsms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArchive_Success_ReturnsCID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/api/v1/documents" {
|
||||
http.Error(w, "wrong route", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
http.Error(w, "wrong content-type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") == "" {
|
||||
http.Error(w, "missing auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(ArchiveResult{
|
||||
CID: "bafytest123",
|
||||
Size: 42,
|
||||
GatewayURL: "/ipfs/bafytest123",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
old := gatewayURL
|
||||
defer func() { gatewayURL = old }()
|
||||
gatewayURL = server.URL
|
||||
|
||||
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil result on 200 OK")
|
||||
}
|
||||
if got.CID != "bafytest123" {
|
||||
t.Errorf("expected CID bafytest123, got %q", got.CID)
|
||||
}
|
||||
if got.Size != 42 {
|
||||
t.Errorf("expected Size 42, got %d", got.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive_GatewayDown_ReturnsNil(t *testing.T) {
|
||||
old := gatewayURL
|
||||
defer func() { gatewayURL = old }()
|
||||
gatewayURL = "http://127.0.0.1:1" // unreachable
|
||||
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil when gateway unreachable, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchive_GatewayReturnsError_ReturnsNil(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
old := gatewayURL
|
||||
defer func() { gatewayURL = old }()
|
||||
gatewayURL = server.URL
|
||||
|
||||
got := Archive([]byte("hello"), "test.pdf", "ce_techfile", "proj-1", "1")
|
||||
if got != nil {
|
||||
t.Errorf("expected nil on 500 response, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderCrossRefAppendix builds a Markdown appendix for a tech-file section
|
||||
// that lists the international equivalents of the given norm IDs. It is
|
||||
// intended to be appended to the "Applied Harmonised Standards" section so
|
||||
// the same tech file is usable for CE + US/CN/JP market submissions.
|
||||
//
|
||||
// Output format:
|
||||
//
|
||||
// ## Anhang: Internationale Aequivalenzen / International Cross-Reference
|
||||
//
|
||||
// Diese Tabelle ordnet die in dieser technischen Dokumentation angewandten
|
||||
// EU-Normen den Pendants in anderen Maerkten zu. Die Spalte "Relation" gibt
|
||||
// an, ob es sich um eine identische Uebernahme, eine teilweise Ueberdeckung
|
||||
// oder ein abgeloestes (superseded_by) Dokument handelt. Vor Nutzung im
|
||||
// jeweiligen Marktraum durch eine sachkundige Person verifizieren.
|
||||
//
|
||||
// | EU Norm | Region | International Identifier | Relation | Confidence |
|
||||
// |---------|--------|--------------------------|----------|------------|
|
||||
// ...
|
||||
//
|
||||
// If no norms have crossref entries, returns an empty string so the caller
|
||||
// can skip the appendix entirely.
|
||||
func RenderCrossRefAppendix(normIDs []string) string {
|
||||
rows := collectCrossRefRows(normIDs)
|
||||
if len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n## Anhang: Internationale Aequivalenzen / International Cross-Reference\n\n")
|
||||
b.WriteString("Diese Tabelle ordnet die in dieser technischen Dokumentation angewandten EU-Normen den Pendants in anderen Maerkten zu (DIN, ANSI/NFPA/UL/OSHA, GB, JIS u.a.). Die Spalte ")
|
||||
b.WriteString("**Relation** kennzeichnet `identical` (wortgleiche Uebernahme), `equivalent` (Kompatibilitaet auf Verfahrensebene), ")
|
||||
b.WriteString("`partial` (Teilueberdeckung — vor Nutzung pruefen), `supersedes`/`superseded_by` (Ablaufverhaeltnis). ")
|
||||
b.WriteString("Die Spalte **Confidence** drueckt die intern hinterlegte Verlaesslichkeit der Zuordnung aus. ")
|
||||
b.WriteString("Vor Verwendung in einem Drittmarkt durch eine sachkundige Person verifizieren.\n\n")
|
||||
b.WriteString("| EU Norm (verwendet) | Region | International Identifier | Relation | Confidence | Hinweis |\n")
|
||||
b.WriteString("|---------------------|--------|--------------------------|----------|------------|---------|\n")
|
||||
|
||||
for _, row := range rows {
|
||||
note := row.Notes
|
||||
if note == "" {
|
||||
note = "—"
|
||||
}
|
||||
// Escape pipes in identifier and note for markdown table safety.
|
||||
fmt.Fprintf(&b,
|
||||
"| %s | %s | %s | %s | %s | %s |\n",
|
||||
escapeCell(row.SourceNorm),
|
||||
escapeCell(row.Region),
|
||||
escapeCell(row.Identifier),
|
||||
escapeCell(row.Relation),
|
||||
escapeCell(row.Confidence),
|
||||
escapeCell(note),
|
||||
)
|
||||
}
|
||||
|
||||
b.WriteString("\n*Quelle: BreakPilot Cross-Reference Matrix. Keine Originalnormtexte reproduziert — nur Identifikatoren. Stand: Bezugsperiode der jeweiligen Norm-Bibliothek.*\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// crossRefRow is a flattened row of the matrix used by the renderer.
|
||||
type crossRefRow struct {
|
||||
SourceNorm string
|
||||
Region string
|
||||
Identifier string
|
||||
Relation string
|
||||
Confidence string
|
||||
Notes string
|
||||
}
|
||||
|
||||
// collectCrossRefRows expands the per-norm mapping list into a sorted slice
|
||||
// of rows. Sort order: source norm ID first, then region in a canonical
|
||||
// regional order so EU markets appear before non-EU.
|
||||
func collectCrossRefRows(normIDs []string) []crossRefRow {
|
||||
regionRank := map[string]int{
|
||||
"EU-DIN": 0,
|
||||
"INTL-ISO": 1,
|
||||
"US-ANSI": 2,
|
||||
"US-NFPA": 3,
|
||||
"US-UL": 4,
|
||||
"US-OSHA": 5,
|
||||
"US-ASME": 6,
|
||||
"US-ASTM": 7,
|
||||
"US-SAE": 8,
|
||||
"US-NIOSH": 9,
|
||||
"US-FDA": 10,
|
||||
"US-EPA": 11,
|
||||
"US-NEMA": 12,
|
||||
"US-NSF": 13,
|
||||
"US-API": 14,
|
||||
"US-CPSC": 15,
|
||||
"US-AHRI": 16,
|
||||
"US-ASHRAE": 17,
|
||||
"US-FCC": 18,
|
||||
"US-DOT": 19,
|
||||
"US-MSHA": 20,
|
||||
"US-FM": 21,
|
||||
"US-AAR": 22,
|
||||
"US-ACI": 23,
|
||||
"US-ADA": 24,
|
||||
"US-AAMA": 25,
|
||||
"US-APA": 26,
|
||||
"US-APSP": 27,
|
||||
"US-EJMA": 28,
|
||||
"US-ICC": 29,
|
||||
"US-SMACNA": 30,
|
||||
"CN-GB": 40,
|
||||
"JP-JIS": 50,
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var rows []crossRefRow
|
||||
for _, id := range normIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
cr := GetNormCrossRef(id)
|
||||
for _, m := range cr.Mappings {
|
||||
rows = append(rows, crossRefRow{
|
||||
SourceNorm: id,
|
||||
Region: m.Region,
|
||||
Identifier: m.Identifier,
|
||||
Relation: m.Relation,
|
||||
Confidence: m.Confidence,
|
||||
Notes: m.Notes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].SourceNorm != rows[j].SourceNorm {
|
||||
return rows[i].SourceNorm < rows[j].SourceNorm
|
||||
}
|
||||
ri, ok := regionRank[rows[i].Region]
|
||||
if !ok {
|
||||
ri = 99
|
||||
}
|
||||
rj, ok := regionRank[rows[j].Region]
|
||||
if !ok {
|
||||
rj = 99
|
||||
}
|
||||
return ri < rj
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
// escapeCell escapes pipes and newlines so a Markdown table cell does not break.
|
||||
func escapeCell(s string) string {
|
||||
s = strings.ReplaceAll(s, "|", "\\|")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package iace
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderCrossRefAppendix_EmptyInput(t *testing.T) {
|
||||
got := RenderCrossRefAppendix(nil)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty string for nil input, got %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrossRefAppendix_UnknownIDs(t *testing.T) {
|
||||
got := RenderCrossRefAppendix([]string{"ISO-DOES-NOT-EXIST", "EN-ALSO-MISSING"})
|
||||
if got != "" {
|
||||
t.Errorf("expected empty string when no IDs match, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrossRefAppendix_ISO12100_RendersAllRegions(t *testing.T) {
|
||||
got := RenderCrossRefAppendix([]string{"ISO-12100"})
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty appendix for ISO-12100")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"## Anhang: Internationale Aequivalenzen",
|
||||
"ISO-12100",
|
||||
"EU-DIN",
|
||||
"US-ANSI",
|
||||
"CN-GB",
|
||||
"JP-JIS",
|
||||
"DIN EN ISO 12100",
|
||||
"GB/T 15706",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("expected appendix to contain %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrossRefAppendix_RegionOrdering(t *testing.T) {
|
||||
got := RenderCrossRefAppendix([]string{"EN-60204-1"})
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty appendix for EN-60204-1")
|
||||
}
|
||||
// EU-DIN must appear before US-NFPA which must appear before CN-GB.
|
||||
euIdx := strings.Index(got, "EU-DIN")
|
||||
usIdx := strings.Index(got, "US-NFPA")
|
||||
cnIdx := strings.Index(got, "CN-GB")
|
||||
if euIdx < 0 || usIdx < 0 || cnIdx < 0 {
|
||||
t.Fatalf("missing one of EU-DIN/US-NFPA/CN-GB markers, got:\n%s", got)
|
||||
}
|
||||
if !(euIdx < usIdx && usIdx < cnIdx) {
|
||||
t.Errorf("expected region order EU-DIN < US-NFPA < CN-GB, got positions %d, %d, %d", euIdx, usIdx, cnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrossRefAppendix_MultipleNorms_SortedByID(t *testing.T) {
|
||||
got := RenderCrossRefAppendix([]string{"ISO-13850", "ISO-12100", "EN-60204-1"})
|
||||
if got == "" {
|
||||
t.Fatal("expected non-empty appendix")
|
||||
}
|
||||
// Expect EN-60204-1 first (alphabetical), then ISO-12100, then ISO-13850.
|
||||
en := strings.Index(got, "EN-60204-1")
|
||||
iso12100 := strings.Index(got, "ISO-12100")
|
||||
iso13850 := strings.Index(got, "ISO-13850")
|
||||
if en < 0 || iso12100 < 0 || iso13850 < 0 {
|
||||
t.Fatalf("missing one of the IDs in output:\n%s", got)
|
||||
}
|
||||
if !(en < iso12100 && iso12100 < iso13850) {
|
||||
t.Errorf("expected source-norm ordering by alphabetical ID, got positions %d, %d, %d", en, iso12100, iso13850)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCrossRefAppendix_PipeEscape(t *testing.T) {
|
||||
got := RenderCrossRefAppendix([]string{"ISO-12100"})
|
||||
// Find a line that came from a mapping with the pipe character — none of
|
||||
// our identifiers contain literal '|' so this just checks that the table
|
||||
// header is intact (no accidental pipe injection).
|
||||
if !strings.Contains(got, "| EU Norm (verwendet) |") {
|
||||
t.Errorf("table header malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -153,8 +153,61 @@ func (g *TechFileGenerator) GenerateSection(ctx context.Context, projectID uuid.
|
||||
})
|
||||
if err != nil {
|
||||
// LLM unavailable — return structured fallback with real project data
|
||||
return buildFallbackContent(sctx, sectionType), nil
|
||||
return appendCrossRefIfApplicable(buildFallbackContent(sctx, sectionType), sctx, sectionType), nil
|
||||
}
|
||||
|
||||
return resp.Message.Content, nil
|
||||
return appendCrossRefIfApplicable(resp.Message.Content, sctx, sectionType), nil
|
||||
}
|
||||
|
||||
// appendCrossRefIfApplicable adds the international cross-reference appendix
|
||||
// (DIN/ANSI/GB/JIS) to the "standards_applied" section. For other section
|
||||
// types it returns content unchanged. The appendix is built deterministically
|
||||
// from the in-process registry, so it is never hallucinated by the LLM.
|
||||
func appendCrossRefIfApplicable(content string, sctx *SectionGenerationContext, sectionType string) string {
|
||||
if sectionType != SectionStandardsApplied {
|
||||
return content
|
||||
}
|
||||
normIDs := suggestNormIDsForProject(sctx)
|
||||
appendix := RenderCrossRefAppendix(normIDs)
|
||||
if appendix == "" {
|
||||
return content
|
||||
}
|
||||
return content + appendix
|
||||
}
|
||||
|
||||
// suggestNormIDsForProject reuses the existing SuggestNorms heuristic to pick
|
||||
// the norms most likely applicable to this project. We only need the IDs;
|
||||
// the rest of the SuggestNorms output (scores, reasons) is discarded.
|
||||
func suggestNormIDsForProject(sctx *SectionGenerationContext) []string {
|
||||
if sctx == nil || sctx.Project == nil {
|
||||
return nil
|
||||
}
|
||||
hazardCats := make([]string, 0, len(sctx.Hazards))
|
||||
seenCat := map[string]bool{}
|
||||
for _, h := range sctx.Hazards {
|
||||
if h.Category != "" && !seenCat[h.Category] {
|
||||
seenCat[h.Category] = true
|
||||
hazardCats = append(hazardCats, h.Category)
|
||||
}
|
||||
}
|
||||
result := SuggestNorms(sctx.Project.MachineType, hazardCats, nil)
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, result.Total)
|
||||
seenID := map[string]bool{}
|
||||
push := func(suggs []NormSuggestion) {
|
||||
for _, s := range suggs {
|
||||
if s.Norm.ID == "" || seenID[s.Norm.ID] {
|
||||
continue
|
||||
}
|
||||
seenID[s.Norm.ID] = true
|
||||
ids = append(ids, s.Norm.ID)
|
||||
}
|
||||
}
|
||||
push(result.ANorms)
|
||||
push(result.B1Norms)
|
||||
push(result.B2Norms)
|
||||
push(result.CNorms)
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -38,13 +38,15 @@ _PROCESS_INTERNAL_PATTERNS = [
|
||||
r"\bsensibilisier",
|
||||
# Vertraege intern
|
||||
r"\bauftragsverarbeitungsvertrag\b",
|
||||
r"\bAVV\b\s+abgeschlossen",
|
||||
r"\bavv\b\s+abgeschlossen",
|
||||
r"\bvertrag.*abgeschlossen",
|
||||
r"\bdpa\s+(geschlossen|abgeschlossen|vorhanden)",
|
||||
r"\bSCC\s+(geschlossen|abgeschlossen|implementiert)",
|
||||
# Technisch-organisatorische Massnahmen (intern)
|
||||
r"\bscc\s+(geschlossen|abgeschlossen|implementiert)",
|
||||
# Technisch-organisatorische Massnahmen (intern). Lowercase: blob
|
||||
# ist bereits .lower(); Case-sensitive Patterns (TOM/AVV/SCC) matchen
|
||||
# nie. Daher hier explizit klein.
|
||||
r"\btechnisch[-\s]*organisatorische\s+ma(ß|ss)nahmen?\b",
|
||||
r"\bTOM\s+(umgesetzt|dokumentiert|implementiert)",
|
||||
r"\btom\s+(umgesetzt|dokumentiert|implementiert)",
|
||||
r"\bverschluesselung\s+(implementiert|aktiv)",
|
||||
r"\bpseudonymisierung\s+(implementiert|aktiv)",
|
||||
r"\bbackup[s]?\s+(eingerichtet|vorhanden)",
|
||||
@@ -68,7 +70,11 @@ _PROCESS_INTERNAL_PATTERNS = [
|
||||
r"\bbitte\s+(intern\s+)?dokumentieren",
|
||||
r"\bin\s+der\s+verfahrens",
|
||||
r"\bnach\s+innen\s+geh",
|
||||
r"\bausnahmen\s+(dokumentieren|protokollieren)",
|
||||
# "Ausnahmen ... dokumentieren" — Wortabstand bis 60 Zeichen erlauben,
|
||||
# damit "ausnahmen bei bereits vorhandenen informationen dokumentieren"
|
||||
# matcht. Sonst Pattern zu eng → process-internal-MC bleibt FAIL.
|
||||
r"\bausnahmen\b[^\n]{0,60}\b(dokumentieren|protokollieren)\b",
|
||||
r"\bdokumentations[\s-]?pflicht",
|
||||
r"\bkostenfrei\s+(zur\s+verfuegung|gewaehren|ermoegli)",
|
||||
r"\bunentgeltlich\s+(zur\s+verfuegung)",
|
||||
# Vertragsleistung / Service-Level (intern)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Heuristic-coverage tests for mc_audit_type.classify_mc_audit_type.
|
||||
|
||||
Hand-kuratierte Beispiele aus DSGVO-/AI-Act-/eCall-Kontext, die nahe an
|
||||
den real-world FAILs aus VW-/BMW-Audits liegen. Wenn ein Pattern hier
|
||||
fehlschlaegt, fehlt _PROCESS_INTERNAL_PATTERNS bzw. _VERIFIABLE_PATTERNS
|
||||
eine entsprechende Regel.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from compliance.services.mc_audit_type import classify_mc_audit_type
|
||||
|
||||
|
||||
VERIFIABLE_CASES = [
|
||||
("Datenschutzerklaerung nennt Kontaktdaten des Verantwortlichen",
|
||||
"Steht im Impressum / DSE der Name + Adresse + Email?"),
|
||||
("Cookie-Banner enthaelt Ablehnen-Button",
|
||||
"Ist im Cookie-Banner ein 'Ablehnen' Button sichtbar?"),
|
||||
("Datenschutzerklaerung verweist auf Recht auf Auskunft",
|
||||
"Wird in der Datenschutzerklaerung Art. 15 DSGVO erwaehnt?"),
|
||||
("Cookie-Richtlinie listet alle Drittanbieter",
|
||||
"Sind alle Vendors in der Cookie-Richtlinie aufgefuehrt?"),
|
||||
("Impressum nennt Telefonnummer",
|
||||
"Steht im Impressum eine Kontakttelefonnummer?"),
|
||||
("Datenschutzerklaerung nennt Speicherdauer",
|
||||
"Sind in der DSE die Aufbewahrungsfristen angegeben?"),
|
||||
("Cookie-Banner Save-Label entspricht UWG-Vorgabe",
|
||||
"Ist der Speichern-Button im Banner korrekt beschriftet?"),
|
||||
]
|
||||
|
||||
PROCESS_INTERNAL_CASES = [
|
||||
("Mitarbeiter-Schulung zu Datenschutz durchgefuehrt",
|
||||
"Wurden alle Mitarbeiter zum Thema Datenschutz geschult?"),
|
||||
("AVV mit allen Auftragsverarbeitern abgeschlossen",
|
||||
"Liegt fuer jeden Dienst ein Auftragsverarbeitungsvertrag vor?"),
|
||||
("TOM dokumentiert und implementiert",
|
||||
"Sind die technisch-organisatorischen Massnahmen umgesetzt?"),
|
||||
("DSFA fuer Hochrisiko-Verarbeitung durchgefuehrt",
|
||||
"Wurde eine DSFA durchgefuehrt und dokumentiert?"),
|
||||
("Rollenkonzept eingerichtet",
|
||||
"Existiert ein Berechtigungskonzept mit klaren Rollen?"),
|
||||
("Pseudonymisierung von Daten implementiert",
|
||||
"Wird Pseudonymisierung aktiv eingesetzt?"),
|
||||
("Backup-Strategie eingerichtet",
|
||||
"Sind Backups vorhanden und werden regelmaessig getestet?"),
|
||||
("Abschaltung der Standortdatenverarbeitung kostenfrei ermoeglichen",
|
||||
"Kann der Nutzer die Standortdatenverarbeitung kostenfrei abschalten?"),
|
||||
("Ausnahmen bei bereits vorhandenen Informationen dokumentieren",
|
||||
"Werden Ausnahmen intern dokumentiert?"),
|
||||
("Sensibilisierung neue Mitarbeiter",
|
||||
"Erhalten neue Mitarbeiter eine Awareness-Schulung?"),
|
||||
("72-Stunden-Meldung an Aufsichtsbehoerde eingehalten",
|
||||
"Wird die Meldepflicht innerhalb von 72 Stunden umgesetzt?"),
|
||||
]
|
||||
|
||||
DOC_INTERNAL_CASES = [
|
||||
("Verzeichnis von Verarbeitungstaetigkeiten gefuehrt",
|
||||
"Existiert ein VVT mit allen Pflichtangaben?"),
|
||||
("Subprozessor-Liste aktualisiert",
|
||||
"Wird die Sub-Prozessor-Liste gepflegt?"),
|
||||
("Auftragsverarbeitungsverzeichnis vollstaendig",
|
||||
"Sind alle AVVs im Auftragsverarbeitungsverzeichnis erfasst?"),
|
||||
("Aufbewahrungskonzept dokumentiert",
|
||||
"Existiert ein dokumentiertes Aufbewahrungskonzept?"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title,question", VERIFIABLE_CASES)
|
||||
def test_verifiable_cases(title: str, question: str) -> None:
|
||||
"""These MCs MUST be classified as verifiable — they describe things
|
||||
visible from the outside (website / banner / DSE)."""
|
||||
result = classify_mc_audit_type(title, question)
|
||||
assert result == "verifiable", (
|
||||
f"Expected 'verifiable' for {title!r}, got {result!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title,question", PROCESS_INTERNAL_CASES)
|
||||
def test_process_internal_cases(title: str, question: str) -> None:
|
||||
"""These MCs MUST be classified as process_internal — they describe
|
||||
internal customer processes that we cannot verify from the outside."""
|
||||
result = classify_mc_audit_type(title, question)
|
||||
assert result == "process_internal", (
|
||||
f"Expected 'process_internal' for {title!r}, got {result!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title,question", DOC_INTERNAL_CASES)
|
||||
def test_doc_internal_cases(title: str, question: str) -> None:
|
||||
"""These MCs MUST be classified as doc_internal — they describe
|
||||
internal documentation (VVT, AVV-Verzeichnis) we cannot inspect."""
|
||||
result = classify_mc_audit_type(title, question)
|
||||
assert result == "doc_internal", (
|
||||
f"Expected 'doc_internal' for {title!r}, got {result!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_empty_input_is_ambiguous() -> None:
|
||||
assert classify_mc_audit_type("", "") == "ambiguous"
|
||||
assert classify_mc_audit_type(None, None) == "ambiguous"
|
||||
@@ -80,21 +80,33 @@ class CMPCapture:
|
||||
data = await _parse_json_response(response)
|
||||
if data is None:
|
||||
return
|
||||
# Skip tiny payloads — real CMP cookie policies are ≥5KB.
|
||||
# A 4KB JSON of cookie-shaped data is almost never the policy.
|
||||
try:
|
||||
size_kb = len(json.dumps(data)) // 1024
|
||||
except Exception:
|
||||
size_kb = 0
|
||||
if size_kb < 5:
|
||||
return
|
||||
from services.cmp_heuristic import looks_like_cookie_policy
|
||||
if looks_like_cookie_policy(data):
|
||||
matched = looks_like_cookie_policy(data)
|
||||
if matched and size_kb >= 5:
|
||||
self.payloads.append(("_heuristic", data))
|
||||
logger.info(
|
||||
"CMP captured: _heuristic (%s, ~%dKB)",
|
||||
url[:120], size_kb,
|
||||
)
|
||||
elif size_kb >= 3:
|
||||
# Phase-0-Diagnose-Log: JSON-Response die als CMP-Kandidat
|
||||
# ueberlebt hat, aber heuristic OR size-threshold abgelehnt
|
||||
# wurde. Zeigt beim naechsten VW/BMW/... Run welche Endpoints
|
||||
# uebersehen werden — schneller Pattern-Add ohne raten.
|
||||
top_keys = []
|
||||
if isinstance(data, dict):
|
||||
top_keys = list(data.keys())[:8]
|
||||
elif isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
top_keys = list(data[0].keys())[:8]
|
||||
logger.info(
|
||||
"CMP candidate skipped: url=%s size=%dKB heuristic=%s "
|
||||
"top_keys=%s",
|
||||
url[:120], size_kb, matched, top_keys,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("CMP listener error: %s", e)
|
||||
|
||||
|
||||
@@ -256,7 +256,8 @@ async def archive_legal_document(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/documents/{cid}/history")
|
||||
@router.get("/api/v1/documents/{cid}/history")
|
||||
@router.get("/documents/{cid}/history") # legacy path, kept for backwards compatibility
|
||||
async def get_document_history(cid: str):
|
||||
"""Follow the parent_cid chain to reconstruct version history."""
|
||||
history = []
|
||||
@@ -285,3 +286,99 @@ async def get_document_history(cid: str):
|
||||
break
|
||||
|
||||
return {"cid": cid, "history": history, "depth": len(history)}
|
||||
|
||||
|
||||
@router.get("/api/v1/documents/{cid_a}/diff/{cid_b}")
|
||||
async def diff_documents(cid_a: str, cid_b: str):
|
||||
"""
|
||||
Compare two DSMS document versions by their CIDs.
|
||||
|
||||
Returns a unified diff of the textual content when both documents are
|
||||
text-decodable (UTF-8). For binary documents the response indicates
|
||||
"binary" and returns just the metadata differences. Used by the Audit
|
||||
Timeline UI to render "what changed between V2 and V3 of CE-Akte X".
|
||||
"""
|
||||
try:
|
||||
raw_a = await ipfs_cat(cid_a)
|
||||
raw_b = await ipfs_cat(cid_b)
|
||||
except Exception as exc:
|
||||
return {"error": f"could not fetch one of the CIDs: {exc}", "cid_a": cid_a, "cid_b": cid_b}
|
||||
|
||||
try:
|
||||
pkg_a = json.loads(raw_a)
|
||||
pkg_b = json.loads(raw_b)
|
||||
except Exception:
|
||||
# Documents are not the wrapped-package JSON shape — treat as raw.
|
||||
pkg_a = {"metadata": {}, "content_base64": ""}
|
||||
pkg_b = {"metadata": {}, "content_base64": ""}
|
||||
|
||||
meta_a = pkg_a.get("metadata", {}) or {}
|
||||
meta_b = pkg_b.get("metadata", {}) or {}
|
||||
meta_diff = _diff_metadata(meta_a, meta_b)
|
||||
|
||||
# Try to decode the content. The Archive flow stores files as base64 in
|
||||
# `content_base64`; older payloads may use `content` (utf-8 text).
|
||||
text_a, text_b, is_binary = _extract_texts(pkg_a, pkg_b)
|
||||
|
||||
if is_binary:
|
||||
return {
|
||||
"cid_a": cid_a,
|
||||
"cid_b": cid_b,
|
||||
"kind": "binary",
|
||||
"metadata_diff": meta_diff,
|
||||
"note": "Binary payload — text diff omitted. Compare via the rendered tech-file export instead.",
|
||||
}
|
||||
|
||||
diff_lines = list(
|
||||
_unified_diff(text_a.splitlines(), text_b.splitlines(), fromfile=cid_a, tofile=cid_b, lineterm="")
|
||||
)
|
||||
return {
|
||||
"cid_a": cid_a,
|
||||
"cid_b": cid_b,
|
||||
"kind": "text",
|
||||
"metadata_diff": meta_diff,
|
||||
"diff": "\n".join(diff_lines),
|
||||
"added_lines": sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++")),
|
||||
"removed_lines": sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---")),
|
||||
}
|
||||
|
||||
|
||||
def _diff_metadata(a: dict, b: dict) -> dict:
|
||||
"""Return per-field change list: {field: {"old": ..., "new": ...}}."""
|
||||
keys = set(a.keys()) | set(b.keys())
|
||||
changes = {}
|
||||
for k in sorted(keys):
|
||||
if a.get(k) != b.get(k):
|
||||
changes[k] = {"old": a.get(k), "new": b.get(k)}
|
||||
return changes
|
||||
|
||||
|
||||
def _extract_texts(pkg_a: dict, pkg_b: dict) -> tuple[str, str, bool]:
|
||||
"""Return (text_a, text_b, is_binary). Falls back to base64-decode."""
|
||||
import base64
|
||||
|
||||
def to_text(pkg: dict) -> tuple[str, bool]:
|
||||
if isinstance(pkg.get("content"), str):
|
||||
return pkg["content"], False
|
||||
b64 = pkg.get("content_base64")
|
||||
if not b64:
|
||||
return "", False
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return "", True
|
||||
try:
|
||||
return raw.decode("utf-8"), False
|
||||
except UnicodeDecodeError:
|
||||
return "", True
|
||||
|
||||
text_a, bin_a = to_text(pkg_a)
|
||||
text_b, bin_b = to_text(pkg_b)
|
||||
return text_a, text_b, (bin_a or bin_b)
|
||||
|
||||
|
||||
def _unified_diff(a, b, fromfile, tofile, lineterm):
|
||||
"""Tiny shim around difflib.unified_diff so the function reads cleanly."""
|
||||
import difflib
|
||||
|
||||
return difflib.unified_diff(a, b, fromfile=fromfile, tofile=tofile, lineterm=lineterm, n=2)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Tests for the version-chain diff endpoint added in DSMS Stufe 3.
|
||||
|
||||
Mocks ipfs_cat so the test does not require a running IPFS node.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _wrap(metadata: dict, content_text: str) -> str:
|
||||
"""Mimic the JSON envelope that routers.documents.ipfs_cat returns."""
|
||||
return json.dumps(
|
||||
{
|
||||
"metadata": metadata,
|
||||
"content_base64": base64.b64encode(content_text.encode("utf-8")).decode("ascii"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_text_documents_returns_unified_diff():
|
||||
pkg_a = _wrap({"version": "1", "document_type": "ce_techfile"}, "alpha\nbeta\ngamma\n")
|
||||
pkg_b = _wrap({"version": "2", "document_type": "ce_techfile"}, "alpha\nDELTA\ngamma\n")
|
||||
|
||||
async def fake_cat(cid: str):
|
||||
return pkg_a if cid == "cidA" else pkg_b
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["kind"] == "text"
|
||||
assert body["cid_a"] == "cidA"
|
||||
assert body["cid_b"] == "cidB"
|
||||
assert body["added_lines"] >= 1
|
||||
assert body["removed_lines"] >= 1
|
||||
assert "DELTA" in body["diff"]
|
||||
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_binary_documents_returns_metadata_only():
|
||||
# Use raw bytes that are not utf-8 decodable
|
||||
invalid_utf8 = b"\xff\xfe\xfd\xfc"
|
||||
pkg_a = json.dumps(
|
||||
{"metadata": {"version": "1"}, "content_base64": base64.b64encode(invalid_utf8).decode()}
|
||||
)
|
||||
pkg_b = json.dumps(
|
||||
{"metadata": {"version": "2"}, "content_base64": base64.b64encode(invalid_utf8 + b"\x00").decode()}
|
||||
)
|
||||
|
||||
async def fake_cat(cid: str):
|
||||
return pkg_a if cid == "cidA" else pkg_b
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["kind"] == "binary"
|
||||
assert body["metadata_diff"] == {"version": {"old": "1", "new": "2"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diff_handles_fetch_error():
|
||||
async def fake_cat(cid: str):
|
||||
raise RuntimeError("not pinned")
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/cidA/diff/cidB")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "error" in body
|
||||
assert body["cid_a"] == "cidA"
|
||||
assert body["cid_b"] == "cidB"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_endpoint_follows_parent_chain():
|
||||
"""Sanity check that the existing history endpoint still works after route alias."""
|
||||
|
||||
chain = {
|
||||
"v3": _wrap({"version": "3", "parent_cid": "v2"}, "x"),
|
||||
"v2": _wrap({"version": "2", "parent_cid": "v1"}, "x"),
|
||||
"v1": _wrap({"version": "1", "parent_cid": None}, "x"),
|
||||
}
|
||||
|
||||
async def fake_cat(cid: str):
|
||||
return chain[cid]
|
||||
|
||||
with patch("routers.documents.ipfs_cat", new=AsyncMock(side_effect=fake_cat)):
|
||||
resp = client.get("/api/v1/documents/v3/history")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["depth"] == 3
|
||||
assert [h["version"] for h in body["history"]] == ["3", "2", "1"]
|
||||
Reference in New Issue
Block a user