Compare commits

..

23 Commits

Author SHA1 Message Date
Benjamin_Boenisch f0120b237e fix(ucca): Cross-Reg 0070 — beide Domaenen im Router-Top-K (#47)
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 9s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 59s
CI / iace-gt-coverage (push) Successful in 18s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-30 13:42:28 +00:00
Benjamin Admin 1d65d99d5f style(ucca): gocritic equalFold in balanceByRegulation (go-lint gruen)
CI / detect-changes (pull_request) Successful in 14s
CI / branch-name (pull_request) Successful in 2s
CI / guardrail-integrity (pull_request) Successful in 6s
CI / secret-scan (pull_request) Successful in 6s
CI / dep-audit (pull_request) Failing after 54s
CI / sbom-scan (pull_request) Failing after 58s
CI / build-sha-integrity (pull_request) Successful in 5s
CI / validate-canonical-controls (pull_request) Successful in 4s
CI / loc-budget (pull_request) Successful in 20s
CI / go-lint (pull_request) Successful in 43s
CI / python-lint (pull_request) Failing after 18s
CI / nodejs-lint (pull_request) Failing after 1m10s
CI / nodejs-build (pull_request) Successful in 3m1s
CI / test-go (pull_request) Successful in 1m4s
CI / iace-gt-coverage (pull_request) Successful in 16s
CI / test-python-backend (pull_request) Successful in 27s
CI / test-python-document-crawler (pull_request) Successful in 12s
CI / test-python-dsms-gateway (pull_request) Successful in 13s
strings.EqualFold(code, cv) statt code==strings.ToUpper(cv) — behebt den einzigen
gocritic-Befund auf der neuen Zeile (CI go-lint, new-from-merge-base). Verhalten
unveraendert (case-insensitive exakter regulation_code-Match); Unit + 0070-e2e bleiben gruen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 15:30:58 +02:00
Benjamin Admin f2d445b891 fix(ucca): Cross-Reg 0070 — beide Regelwerk-Domaenen im Router-Top-K (Known Defects 0)
CI / detect-changes (pull_request) Successful in 13s
CI / branch-name (pull_request) Successful in 1s
CI / guardrail-integrity (pull_request) Successful in 9s
CI / secret-scan (pull_request) Successful in 10s
CI / dep-audit (pull_request) Failing after 56s
CI / sbom-scan (pull_request) Failing after 59s
CI / build-sha-integrity (pull_request) Successful in 5s
CI / validate-canonical-controls (pull_request) Successful in 3s
CI / test-python-document-crawler (pull_request) Successful in 15s
CI / test-python-dsms-gateway (pull_request) Successful in 13s
CI / loc-budget (pull_request) Successful in 23s
CI / go-lint (pull_request) Failing after 51s
CI / python-lint (pull_request) Failing after 18s
CI / nodejs-lint (pull_request) Failing after 1m8s
CI / nodejs-build (pull_request) Successful in 3m6s
CI / test-go (pull_request) Successful in 1m3s
CI / iace-gt-coverage (pull_request) Successful in 18s
CI / test-python-backend (pull_request) Successful in 28s
Der einzige offene Retrieval-Haertefall: eine Query mit >=2 genannten Regelwerken
("CRA und Maschinenverordnung") lieferte nur die keyword-dominante Domaene (CRA),
MaschVO fiel raus. Drei zusammenwirkende Ursachen, alle behoben:

1. CodeValues-Mismatch: MaschVO heisst je Collection anders (Slice MASCHVO ·
   gesetze MVO · ce MACHINERY/MASCHINENVO), der Catalog hatte nur ["MASCHVO","MaschVO"]
   → Filter fand MaschVO nur in der Slice. Jetzt alle Varianten als CodeValues.
2. Per-Collection-Truncation: der Router gab perColl=3 → searchMultiRegulation holte
   3+3=6, schnitt auf 3 → konnte eine Domaene je Collection verlieren. Multi-Reg-Queries
   bekommen jetzt perColl = 3*len(regs).
3. Router-Score-Merge starvte die nicht-dominante Domaene. Neue balanceByRegulation()
   gruppiert den gemergten Pool per Regelwerk (exakter regulation_code-Match) und nimmt
   round-robin ueber die genannten Domaenen → jede Domaene mit Treffern ist im Top-K.
   Generisch ueber jede genannte Menge; Single-Domain-Pfad unveraendert.

Validierung: Go-Unit (balanceByRegulation: dominante CRA verdraengt MaschVO NICHT mehr);
0070-e2e gegen dev (Retrieve() → [CRA MVO CRA MVO CRA MVO CRA MASCHINENVO] = beide
Domaenen, vorher nur CRA); CB-100-Stichprobe REGR 0 (Gain-Profil unveraendert).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 15:08:18 +02:00
Benjamin_Boenisch 08086ee75f feat: Authority Router — Advisor collection-agnostisch, KB-2026.1 live (#46)
CI / detect-changes (push) Successful in 6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 5s
CI / validate-canonical-controls (push) Successful in 3s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m58s
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-30 12:26:53 +00:00
Benjamin Admin 1e5aaf7103 feat(advisor): Authority Router — Advisor collection-agnostisch, KB-2026.1-Gewinn im Produktpfad
CI / detect-changes (pull_request) Successful in 13s
CI / branch-name (pull_request) Successful in 2s
CI / guardrail-integrity (pull_request) Successful in 5s
CI / secret-scan (pull_request) Successful in 11s
CI / dep-audit (pull_request) Failing after 54s
CI / sbom-scan (pull_request) Failing after 1m1s
CI / build-sha-integrity (pull_request) Successful in 11s
CI / validate-canonical-controls (pull_request) Successful in 7s
CI / loc-budget (pull_request) Successful in 23s
CI / go-lint (pull_request) Successful in 53s
CI / python-lint (pull_request) Failing after 17s
CI / nodejs-lint (pull_request) Failing after 1m6s
CI / nodejs-build (pull_request) Successful in 2m59s
CI / test-go (pull_request) Successful in 1m0s
CI / iace-gt-coverage (pull_request) Successful in 17s
CI / test-python-backend (pull_request) Successful in 26s
CI / test-python-document-crawler (pull_request) Successful in 12s
CI / test-python-dsms-gateway (pull_request) Successful in 8s
Der Advisor fan-outete bisher selbst ueber eine feste Liste expliziter Collections
(advisor-rag.ts) und umging damit das #61-Scope-Routing (das nur den Default-Pfad
routet) → der gemessene +28-Retrieval-Gewinn (CB-100: 53→81, 0 Regr) kam nie beim
Antwort-LLM an. Dieser Router zieht den Fan-out in die Retriever-Schicht:

- SDK: LegalRAGClient.Retrieve() + POST /sdk/v1/rag/retrieve {query, top_k} —
  fan-outet server-seitig ueber die Broad-Authority-Base + die KB-2026.1-Slice bei
  inKBScope, merge+dedup, sortiert nach Authority-Score (rerankByAuthority je
  Collection), top-K. Index-Warmup vor dem nebenlaeufigen Fan-out (Map-Race-frei).
  Per-Env via RAG_ROUTER_COLLECTIONS.
- admin: advisor-rag.ts ruft EINMAL /retrieve statt 6-fach expliziter Collections.
  Advisor ist collection-agnostisch (Vertrag Compiler→Collections→Retriever→Advisor);
  COMPLIANCE_COLLECTIONS/searchCollection entfernt.

Validierung: Go-Unit (Router-Selektion, dedup); e2e gegen dev-Qdrant (echter
Retrieve(), CB-100-Stichprobe stride 5): OLD-hit 11/20 → NEW-hit 15/20, GAIN 4
(alle DS-Guidance), REGR 0 — reproduziert den +28/0-Regr durch den Produktionscode.
TS-Tests auf den Single-/retrieve-Call angepasst.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 14:13:09 +02:00
Benjamin_Boenisch af11d21f6e feat(ucca): Blue-Green KB-2026.1 Scope-Routing (authoritative slice) (#45)
CI / detect-changes (push) Successful in 5s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 59s
CI / build-sha-integrity (push) Successful in 4s
CI / validate-canonical-controls (push) Successful in 4s
CI / loc-budget (push) Successful in 18s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / iace-gt-coverage (push) Successful in 15s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-30 10:01:59 +00:00
Benjamin Admin e2c74fd243 feat(ucca): Blue-Green „authoritative slice promotion" — KB-2026.1 Scope-Routing
CI / detect-changes (pull_request) Successful in 12s
CI / branch-name (pull_request) Successful in 1s
CI / guardrail-integrity (pull_request) Successful in 9s
CI / secret-scan (pull_request) Successful in 10s
CI / dep-audit (pull_request) Failing after 56s
CI / sbom-scan (pull_request) Failing after 1m1s
CI / build-sha-integrity (pull_request) Successful in 6s
CI / validate-canonical-controls (pull_request) Successful in 3s
CI / loc-budget (pull_request) Successful in 18s
CI / go-lint (pull_request) Successful in 52s
CI / python-lint (pull_request) Failing after 15s
CI / nodejs-lint (pull_request) Failing after 1m12s
CI / nodejs-build (pull_request) Successful in 3m4s
CI / test-go (pull_request) Successful in 1m2s
CI / iace-gt-coverage (pull_request) Successful in 19s
CI / test-python-backend (pull_request) Successful in 27s
CI / test-python-document-crawler (pull_request) Successful in 19s
CI / test-python-dsms-gateway (pull_request) Successful in 15s
Additiv (KEIN CE-Ersatz): faellt eine Query in den KB-2026.1-Scope (DP/CRA/MaschVO/
NIS2/DataAct/DORA/AIAct + EDPB/DSK-Guidance), wird die hochwertige Slice-Collection
`kb_2026_1_build` abgefragt; sonst bleibt der breite Default `bp_compliance_ce`.
Damit werden die Guidance-Intent- + Multi-Reg-Fixes (PR #42/#43) fuer den Slice LIVE,
Broad-Corpus (OWASP/NIST/ENISA/IFRS/ISO) unangetastet -> 0 Regressionen by construction.

- resolveCollection(query, requested): explizit angefragte Collection unveraendert;
  Default-Request -> Slice bei inKBScope, sonst CE. Env RAG_KB_SCOPE_ROUTING=false = Rollback
  ohne Redeploy; RAG_KB_SLICE_COLLECTION ueberschreibt den Slice-Namen.
- inKBScope: detectRegulations (in-Slice-Regelwerke) + DP-Guidance-Marker (edpb/dsk/wp/gl) +
  DP/Compliance-Topics. Bewusst NICHT die generischen Verben aus guidanceIntentSignals
  (sagt/laut) und NICHT enisa/bsi/nist/owasp (die liegen in CE) -> konservativ, in-scope->Slice.

Validierung: Unit (Scoping + resolveCollection); dev-e2e (RUN_E2E, geroutetes Search() gegen
dev): WP248/MaschVO/CRA+MaschVO -> Slice (Treffer da, fehlen in dev-ce); NIST -> CE (NIST-Treffer).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 11:49:34 +02:00
Benjamin_Boenisch 8ed99c255d Merge pull request 'fix(api): F821-Regression (Extract-Service-Halb-Refactor) — 7 Route-Dateien' (#44) from fix/api-f821-extract-service-regression into main
CI / detect-changes (push) Successful in 8s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 9s
CI / validate-canonical-controls (push) Successful in 7s
CI / loc-budget (push) Successful in 22s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 27s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-30 09:06:08 +00:00
Benjamin Admin 3389fa3e7a fix(api): F821-Regression in 6 weiteren Route-Dateien beheben
CI / detect-changes (pull_request) Successful in 5s
CI / branch-name (pull_request) Successful in 1s
CI / guardrail-integrity (pull_request) Successful in 5s
CI / secret-scan (pull_request) Successful in 8s
CI / dep-audit (pull_request) Failing after 57s
CI / sbom-scan (pull_request) Failing after 56s
CI / build-sha-integrity (pull_request) Successful in 6s
CI / validate-canonical-controls (pull_request) Successful in 5s
CI / loc-budget (pull_request) Successful in 22s
CI / go-lint (pull_request) Successful in 46s
CI / python-lint (pull_request) Failing after 17s
CI / nodejs-lint (pull_request) Failing after 1m8s
CI / nodejs-build (pull_request) Successful in 3m1s
CI / test-go (pull_request) Successful in 1m2s
CI / iace-gt-coverage (pull_request) Successful in 18s
CI / test-python-backend (pull_request) Successful in 25s
CI / test-python-document-crawler (pull_request) Successful in 14s
CI / test-python-dsms-gateway (pull_request) Successful in 10s
Gleiche Wurzel wie evidence_routes (Extract-Service-Refactor a638d0e5 ff.):
Signaturen/Imports halb umgestellt → undefined names → NameError beim Aufruf.

- routes.py: db-Param in get_control/update_control/review_control + EvidenceDB-Import
- dsfa_routes.py: db-Param in create_dsfa + HTTPException/text-Import
- dashboard_routes.py: timezone-Import
- canonical_control_routes.py: logger-Definition
- ai_routes.py: timezone in den lokalen datetime-Imports
- vvt_routes.py: HTTPException-Import

Verifiziert: ruff F821 0 über das gesamte compliance/api/, alle 6 py_compile,
294 Tests grün auf den betroffenen Modulen (die 2 dsfa-invalid-status/risk-Failures
sind vorbestehend = 400-vs-422, unabhängig von diesem Fix).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 10:51:00 +02:00
Benjamin Admin 79abf23ea8 fix(api): evidence_routes F821-Regression beheben (Extract-Service-Halb-Refactor)
a638d0e5 ("extract EvidenceService") stellte Signaturen auf service=Depends um,
ließ aber Bodies + Imports auf dem alten Stand → 43 F821 (NameError zur Laufzeit).

- gelöschte stdlib-Imports restauriert (os/json/hashlib/uuid/datetime/timedelta)
- db: Session = Depends(get_db) an den betroffenen Endpoints restauriert
- translate_domain_errors + _update_risks_impl (=evidence_service._update_risks) importiert
- unerreichbaren toten Block (alte get_ci_evidence_status-Impl nach dem return) entfernt
- dsms_cid=None no-op in create/review/reject (DSMS-Commit-Copy-Paste)

Verifiziert: ruff F821 0, py_compile, test_evidence_routes.py 35 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 10:19:28 +02:00
Benjamin Admin d5925e57af feat(ai-sdk): pin accepted proposer decisions into the GT gate (P3)
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 9s
CI / validate-canonical-controls (push) Successful in 8s
CI / loc-budget (push) Successful in 21s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 59s
CI / iace-gt-coverage (push) Successful in 19s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
When a human accepts a proposer proposal, an AcceptedPin records a machine-scoped
invariant — a pattern MUST fire (coverage/vocab→tag) or must NOT fire
(dedup/framing) — that a test re-checks on every run. This makes the library's
growth COMPOUND into the gate instead of eroding it: a change that re-introduces
a dropped duplicate, un-gates a foreign pattern, or removes a coverage hazard
breaks a pin and fails CI. One boolean covers all four proposal types.

Seeded testdata/accepted_pins_warewashing.json with the accepted P1 supersessions
(HP016/HP018/HP013 must NOT fire; their clean equivalents HP2201/HP144 must fire).
TestWarewashing_AcceptedPins re-checks 5/5 against the live engine output;
GenerateDedupPin turns an accepted dedup verdict into its pin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 09:42:31 +02:00
Benjamin Admin 1877829b1d Merge remote-tracking branch 'gitea/main' into reconcile-dev
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 8s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 22s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m3s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 26s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-30 09:04:58 +02:00
Benjamin_Boenisch 866889b453 Merge pull request 'feat(ucca): Multi-Regulation-Retrieval (Cross-Regulation-Fragen)' (#43) from fix/multi-regulation-retrieval into main
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 7s
CI / validate-canonical-controls (push) Successful in 6s
CI / loc-budget (push) Successful in 21s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 20s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-30 06:46:21 +00:00
Benjamin Admin 9760dca443 feat(ucca): Multi-Regulation-Retrieval für Cross-Regulation-Fragen
CI / detect-changes (pull_request) Successful in 10s
CI / branch-name (pull_request) Successful in 1s
CI / guardrail-integrity (pull_request) Successful in 8s
CI / secret-scan (pull_request) Successful in 9s
CI / dep-audit (pull_request) Failing after 56s
CI / sbom-scan (pull_request) Failing after 58s
CI / build-sha-integrity (pull_request) Successful in 9s
CI / validate-canonical-controls (pull_request) Successful in 7s
CI / loc-budget (pull_request) Successful in 24s
CI / go-lint (pull_request) Successful in 54s
CI / python-lint (pull_request) Failing after 16s
CI / nodejs-lint (pull_request) Failing after 1m9s
CI / nodejs-build (pull_request) Successful in 3m6s
CI / test-go (pull_request) Successful in 1m3s
CI / iace-gt-coverage (pull_request) Successful in 19s
CI / test-python-backend (pull_request) Successful in 26s
CI / test-python-document-crawler (pull_request) Successful in 15s
CI / test-python-dsms-gateway (pull_request) Successful in 12s
Nennt eine Query EXPLIZIT >=2 Regelwerke ("Wie greifen CRA und Maschinen-
verordnung ineinander?"), retrievt searchInternal pro Regelwerk separat
(regulation_code/regulation_id-Filter) und merged — damit BEIDE Domänen im
Prompt landen statt nur der keyword-dominanten. Generisch (Query->Regelwerke,
KEINE doc-spezifische Logik), gegated auf >=2 erkannte Regelwerke; sonst
unveränderter Single-Domain-Pfad.

Behebt GQ-0070: vorher CRA x8 / null MaschVO -> Modell halluzinierte
MaschVO=2019/2144 + falsche "CRA ausgenommen"-Konklusion. Nachher CRA + MaschVO
im Prompt -> korrekt "beide gleichzeitig anwendbar" + Art. 20(9)
Konformitätsvermutung, gegroundet.

Validierung (Build-Collection, echtes SearchCollection):
- Unit: detectRegulations-Scoping (>=2 -> multi, 1/0 -> single)
- 5 Cross-Reg-Fälle (0070 + DSGVO+TDDDG/CRA+NIS2/DORA+NIS2/AI Act+DSGVO):
  beide Regelwerke in Top-8
- CB-100 Freeze-Regression: NUR GQ-0070 + GQ-0095 geändert (beide echte
  Cross-Reg, beide verbessert), 98/100 byte-identisch
- 10 Hard Cases: 9 Single-Domain unverändert, 0070 behält CRA Rang 1

Filter erweitert auf regulation_id UND regulation_code (rückwärtskompatibel,
aktiviert die re-ingestierte Build-Collection).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 08:18:06 +02:00
Benjamin_Boenisch e5e7b825af Merge pull request 'fix(ucca): Guidance-Intent für direkt benannte WP/GL-Dokumente' (#42) from fix/legal-rag-guidance-intent into main
CI / branch-name (push) Has been skipped
CI / detect-changes (push) Successful in 7s
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / build-sha-integrity (push) Successful in 6s
CI / validate-canonical-controls (push) Successful in 5s
CI / loc-budget (push) Successful in 20s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 1m0s
CI / iace-gt-coverage (push) Successful in 17s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-06-29 18:42:27 +00:00
pilotadmin f0da86ca19 Merge pull request 'feat(onboarding): advisor responsiveness — moving headline + auto-recompute' (#54) from feat/advisor-ux-responsiveness into main 2026-06-28 19:31:20 +02:00
Benjamin Admin 867f8c3854 feat(onboarding): make the advisor visibly responsive — headline leads with the moving number + auto-recompute
Testing surfaced that toggling certifications appeared to "do nothing": the headline led with the TOTAL
requirement count (constant per target, e.g. 17 for CRA), and the page only recomputed on an explicit
button click. Both fixed:
  - engine.py headline now leads with the number that actually moves: "11 von 17 Anforderungen offen ·
    6 wahrscheinlich (Zertifikate) · 5 zu klären" (was "17 Anforderungen erkannt · …"). Keeps the
    "automatisch erkannt (Intake)" substring.
  - frontend auto-recomputes on certifications / target / scanner-signal change (no button needed).

Now ISO27001 alone -> "13 von 17 offen · 4 wahrscheinlich"; + ISO9001+TISAX+IEC62443 -> "11 von 17 offen ·
6 wahrscheinlich". (Domain truth stays visible: CRA's product-cyber gaps barely move with management-system
certs.) 28 onboarding+transition tests pass, check-loc 0.
2026-06-28 19:31:15 +02:00
pilotadmin 26a8518107 Merge pull request 'feat(onboarding): surface curated expert text + human labels' (#53) from feat/advisor-human-text into main 2026-06-28 18:47:07 +02:00
Benjamin Admin 807a7002b2 feat(onboarding): surface curated expert text + human capability labels (advisor was showing snake_case)
The advisor was structurally correct but unusable: every question showed a snake_case capability id plus a
single generic fallback reason ("Keine Anhaltspunkte im Unternehmensprofil — klären"). The expert text
already EXISTED in the transition patterns (why_asked / reviewable_claim) — the pipeline just dropped it.

  - transition_reasoning: TargetRequirement gains `rationale`; assess_transition uses it as the request
    reason when present, else the generic fallback (additive, backward-compatible for all consumers).
  - onboarding_service._target carries the pattern's why_asked (delta) and reviewable_claim (likely_covered)
    into the requirement rationale -> the question's `why`.
  - knowledge/onboarding/capability_labels.yaml: curated DE labels (id -> human), reusable across targets;
    labels_for() + response.capability_labels expose them; the frontend renders label || prettified id.

Now ISO27001->TISAX reads "Auftragsverarbeitung (Art. 28 DSGVO) — If a TISAX data label is in scope, you
must show Art. 28 GDPR processing-on-behalf controls; ISO 27001 does not establish these." instead of
"data_protection_processing_on_behalf — klären". why_asked text is still EN (existing knowledge; translation
is curation). 34 onboarding+transition tests pass, mypy --strict clean (13 modules), check-loc 0.
2026-06-28 18:46:56 +02:00
pilotadmin 5beb5a319a Merge pull request 'feat(admin): ETO / Onboarding-Advisor test page' (#52) from feat/onboarding-advisor-frontend into main 2026-06-28 17:12:44 +02:00
Benjamin Admin 239702fdca feat(admin): ETO / Onboarding-Advisor test page (thin operator surface over the advisor endpoint)
A focused client page at /sdk/onboarding-advisor that exercises POST /api/compliance/onboarding/
advisor-start through the existing compliance proxy: pick certifications + target + scanner findings
(observation / partial / requirement) and render the result — headline, silent-intake summary,
auto-detected (green), indications (amber), next-best questions with WHY, inferred (Welt-1) vs rejected
assumptions, capability delta, evidence requests, completeness. NOT the regulation gap engine
(/sdk/gap-analysis is a different flow). No new backend; calls only the existing endpoint. 195 lines.
2026-06-28 17:12:40 +02:00
pilotadmin d1a5fc7205 Merge pull request 'feat(onboarding): Observation Log — append-only JSONL calibration store (59b/c)' (#51) from feat/observation-log into main 2026-06-28 16:29:58 +02:00
Benjamin Admin 7df15010ff feat(onboarding): Observation Log — append-only JSONL calibration store (Task 59b/c v1)
Per the user's decision (2026-06-28): observations are CALIBRATION data for the knowledge base, NOT
business data and NOT product-DB data. So they live with the other versioned knowledge artifacts as an
append-only JSONL log under knowledge/observations/ — NO migration, NO DB. (A real persistence layer is
only warranted once thousands of onboardings exist; not before.)

  - ObservationRecord = Observation + log metadata (observation_id, timestamp [caller-stamped, no hidden
    clock], customer_archetype [anonymised — NEVER a real name], evidence, provenance, knowledge_version).
  - append_observation() writes one JSON line; append-only, lines are never rewritten. A later review is a
    NEW line with the same observation_id; load_observations(reconcile=True) keeps the latest per id.
  - load_observations() reads a single .jsonl or a directory of monthly .jsonl files.
  - aggregate_by_hypothesis() (59c) -> per-hypothesis distribution + confidence, COMPUTED from the log
    (computed-not-stored); the review gate (reviewed-only) is enforced in empirical_distribution/confidence.
  - review_queue() -> the unreviewed worklist. Observation -> Review -> Accepted -> recompute, never
    Observation -> confidence++. Nothing is ever written back to a hypothesis.

You can `rm` the log and recompute, `git diff` it over months, or rebuild confidence under a new policy —
fully consistent with computed-not-stored and the product/knowledge data separation.

Non-runtime (module + tests only, no endpoint) -> origin/main, NO dev deploy. 5 new tests (append-only,
review supersession, review-gate statistics, queue, monthly-file load); 27 onboarding tests pass, mypy
--strict clean (9 modules), check-loc 0. 59d (surface computed confidence at runtime) stays a later step.
2026-06-28 16:29:54 +02:00
35 changed files with 1624 additions and 194 deletions
@@ -0,0 +1,200 @@
'use client'
// ETO / Onboarding-Advisor — thin operator surface over POST /api/compliance/onboarding/advisor-start.
// Certifications + target + scanner findings -> Silent Pass -> Advisor. NOT the regulation gap engine
// (/sdk/gap-analysis is a different flow: product -> applicable regulations). This tests the cert->delta
// case: "TISAX/ISO27001 -> CRA, what is auto-detected, what stays an open question?". No new backend.
import React, { useEffect, useState } from 'react'
const CERTS = ['ISO27001', 'TISAX', 'ISO9001', 'IEC62443', 'ISO13485', 'ISO14001', 'ASPICE', 'IATF16949']
// label -> {signal_id, source_type} — demonstrates all three signal KINDS (observation / partial / requirement)
const FINDINGS: Array<{ label: string; signal_id: string; source_type: string; kind: string }> = [
{ label: 'SBOM im Repo (CycloneDX/SPDX)', signal_id: 'cyclonedx_found', source_type: 'repository', kind: 'observation' },
{ label: 'security.txt / CVD-Policy veröffentlicht', signal_id: 'security_txt', source_type: 'website', kind: 'observation' },
{ label: 'Signierte Releases', signal_id: 'signed_releases', source_type: 'repository', kind: 'observation' },
{ label: 'Produkt-Risikobewertung (Dokument)', signal_id: 'risk_assessment_pdf', source_type: 'document', kind: 'observation' },
{ label: 'CI-Pipeline vorhanden (nur Indikation)', signal_id: 'github_actions_ci', source_type: 'repository', kind: 'partial' },
{ label: 'Cloud-/vernetztes Produkt', signal_id: 'cloud_hosted', source_type: 'product', kind: 'observation' },
{ label: 'Ausschreibung FORDERT SBOM (Requirement)', signal_id: 'requires_sbom', source_type: 'tender', kind: 'requirement' },
{ label: 'OEM FORDERT PSIRT (Requirement)', signal_id: 'supplier_requires_psirt', source_type: 'oem', kind: 'requirement' },
]
interface Question { capability_id: string; question_intent: string; why: string; information_value: number; priority: string }
interface Inferred { certification: string; capabilities: string[]; statement: string }
interface Rejected { certification?: string; statement: string; reason: string }
interface Measure { capability_id: string; leverage: number; closes: string[] }
interface AdvisorResponse {
silent_intake_summary: string; headline: string; auto_detected: string[]; indications: string[]
inferred_assumptions: Inferred[]; rejected_assumptions: Rejected[]; top_5_questions: Question[]
capability_delta: string[]; top_measures: Measure[]; evidence_requests: string[]
unsupported_domains: string[]; completeness_summary: string; capability_labels: Record<string, string>
}
const PROXY = '/api/sdk/v1/compliance/onboarding'
function Chips({ items, tone }: { items: string[]; tone: string }) {
if (!items.length) return <span className="text-gray-400 text-sm"></span>
return (
<div className="flex flex-wrap gap-2">
{items.map(c => <span key={c} className={`px-2.5 py-1 rounded-full text-xs font-medium ${tone}`}>{c}</span>)}
</div>
)
}
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900">{title}</h3>
{hint && <p className="text-xs text-gray-500 mt-0.5 mb-2">{hint}</p>}
<div className="mt-2">{children}</div>
</div>
)
}
export default function OnboardingAdvisorPage() {
const [targets, setTargets] = useState<string[]>([])
const [company, setCompany] = useState('Beispiel Maschinenbau')
const [industry, setIndustry] = useState('machine_builder')
const [certs, setCerts] = useState<string[]>(['ISO27001', 'ISO9001'])
const [target, setTarget] = useState('CRA')
const [findings, setFindings] = useState<string[]>(['cyclonedx_found', 'github_actions_ci', 'requires_sbom'])
const [knownEvidence, setKnownEvidence] = useState('CE-Prozess')
const [result, setResult] = useState<AdvisorResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
fetch(`${PROXY}/targets`).then(r => r.json()).then(d => {
if (Array.isArray(d.targets)) { setTargets(d.targets); if (!d.targets.includes('CRA') && d.targets[0]) setTarget(d.targets[0]) }
}).catch(() => {})
}, [])
const toggle = (list: string[], set: (v: string[]) => void, v: string) =>
set(list.includes(v) ? list.filter(x => x !== v) : [...list, v])
const lbl = (id: string) => result?.capability_labels?.[id] || id.replace(/_/g, ' ')
const run = async () => {
setLoading(true); setError(''); setResult(null)
try {
const scanner_findings = FINDINGS.filter(f => findings.includes(f.signal_id))
.map(f => ({ signal_id: f.signal_id, source_type: f.source_type }))
const res = await fetch(`${PROXY}/advisor-start`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
company, industry, products: [], markets: ['EU'], certifications: certs,
known_evidence: knownEvidence ? knownEvidence.split(',').map(s => s.trim()).filter(Boolean) : [],
target, scanner_findings,
}),
})
if (!res.ok) throw new Error(await res.text())
setResult(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Advisor fehlgeschlagen')
} finally { setLoading(false) }
}
// auto-recompute when certifications / target / scanner signals change (no button click needed)
useEffect(() => { if (certs.length) run() }, [certs, target, findings]) // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-5xl mx-auto px-4">
<h1 className="text-3xl font-bold text-gray-900">ETO / Onboarding-Advisor</h1>
<p className="text-gray-600 mt-2 mb-6">
Zertifikate + Ziel + Scanner-Signale Silent Pass Capability-Delta + nächste beste Fragen.
Welt-1: ein Zertifikat <em>legt nahe</em>, beweist nichts (Verifikation erforderlich).
</p>
<div className="grid md:grid-cols-2 gap-4 mb-6">
<Section title="Unternehmen & Ziel">
<label className="block text-sm text-gray-600">Unternehmen
<input value={company} onChange={e => setCompany(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2" /></label>
<label className="block text-sm text-gray-600 mt-3">Branche
<input value={industry} onChange={e => setIndustry(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2" /></label>
<label className="block text-sm text-gray-600 mt-3">Ziel
<select value={target} onChange={e => setTarget(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2">
{(targets.length ? targets : ['CRA']).map(t => <option key={t} value={t}>{t}</option>)}
</select></label>
<label className="block text-sm text-gray-600 mt-3">Vorhandene Nachweise (kommagetrennt)
<input value={knownEvidence} onChange={e => setKnownEvidence(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2" /></label>
</Section>
<Section title="Zertifizierungen">
<div className="flex flex-wrap gap-2">
{CERTS.map(c => (
<button key={c} onClick={() => toggle(certs, setCerts, c)}
className={`px-3 py-1.5 rounded-lg text-sm border ${certs.includes(c) ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-700 border-gray-300'}`}>{c}</button>
))}
</div>
</Section>
</div>
<Section title="Scanner-Signale (Silent Pass)" hint="observation = gesehen · partial = Indikation · requirement = gefordert (≠ vorhanden)">
<div className="grid sm:grid-cols-2 gap-2">
{FINDINGS.map(f => (
<label key={f.signal_id} className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={findings.includes(f.signal_id)} onChange={() => toggle(findings, setFindings, f.signal_id)} />
<span>{f.label}</span>
<span className={`ml-auto text-[10px] px-1.5 py-0.5 rounded ${f.kind === 'requirement' ? 'bg-purple-100 text-purple-700' : f.kind === 'partial' ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'}`}>{f.kind}</span>
</label>
))}
</div>
</Section>
<button onClick={run} disabled={loading || !certs.length}
className="mt-6 w-full py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 disabled:opacity-50">
{loading ? 'Analysiere…' : 'Advisor starten'}
</button>
{error && <div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm whitespace-pre-wrap">{error}</div>}
{result && (
<div className="mt-8 space-y-4">
<div className="bg-blue-600 text-white rounded-xl p-5">
<div className="text-lg font-semibold">{result.headline}</div>
<div className="text-blue-100 text-sm mt-1">{result.silent_intake_summary}</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<Section title="Automatisch erkannt" hint="konkrete Artefakte nicht mehr gefragt"><Chips items={result.auto_detected.map(lbl)} tone="bg-emerald-100 text-emerald-800" /></Section>
<Section title="Indikationen" hint="erhöht Annahmestärke trotzdem gefragt"><Chips items={result.indications.map(lbl)} tone="bg-amber-100 text-amber-800" /></Section>
</div>
<Section title="Nächste beste Fragen" hint="max 5, jede erklärt sich selbst">
{result.top_5_questions.length ? (
<ol className="space-y-3">
{result.top_5_questions.map((q, i) => (
<li key={q.capability_id} className="border-l-2 border-blue-300 pl-3">
<div className="font-medium text-gray-900">{i + 1}. {lbl(q.capability_id)}</div>
<div className="text-sm text-gray-600">{q.why}</div>
</li>
))}
</ol>
) : <span className="text-gray-400 text-sm"></span>}
</Section>
<div className="grid md:grid-cols-2 gap-4">
<Section title="Wahrscheinlich abgedeckt (Welt-1)" hint="Zertifikat legt nahe Verifikation erforderlich">
{result.inferred_assumptions.length ? result.inferred_assumptions.map(a => (
<div key={a.certification} className="mb-2"><span className="font-medium">{a.certification}</span>: {a.capabilities.map(lbl).join(', ')}</div>
)) : <span className="text-gray-400 text-sm"></span>}
</Section>
<Section title="Nicht relevant" hint="relevance(evidence, target) = 0">
{result.rejected_assumptions.length ? result.rejected_assumptions.map((a, i) => (
<div key={i} className="mb-1 text-sm text-gray-700">{a.statement}</div>
)) : <span className="text-gray-400 text-sm"></span>}
</Section>
</div>
<div className="grid md:grid-cols-2 gap-4">
<Section title="Offene Lücken (Delta)"><Chips items={result.capability_delta.map(lbl)} tone="bg-gray-100 text-gray-700" /></Section>
<Section title="Geforderte Nachweise"><Chips items={result.evidence_requests} tone="bg-gray-100 text-gray-700" /></Section>
</div>
<Section title="Vollständigkeit" hint={result.unsupported_domains.length ? `nicht abgedeckt: ${result.unsupported_domains.join(', ')}` : undefined}>
<span className="text-sm text-gray-700">{result.completeness_summary || '—'}</span>
</Section>
</div>
)}
</div>
</div>
)
}
@@ -51,8 +51,8 @@ describe('advisor-rag', () => {
}) })
}) })
describe('queryAdvisorRAG', () => { describe('queryAdvisorRAG (Authority Router)', () => {
it('fragt alle 6 Collections ab und formatiert die Treffer', async () => { it('ruft den Router EINMAL auf und formatiert die Treffer', async () => {
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
json: async () => ({ results: [{ text: 'Inhalt A', regulation_short: 'DSGVO', score: 0.9 }] }), json: async () => ({ results: [{ text: 'Inhalt A', regulation_short: 'DSGVO', score: 0.9 }] }),
@@ -60,19 +60,19 @@ describe('advisor-rag', () => {
const result = await mod.queryAdvisorRAG('Was ist eine DSFA?') const result = await mod.queryAdvisorRAG('Was ist eine DSFA?')
expect(result).toContain('[Quelle 1: DSGVO]') expect(result).toContain('[Quelle 1: DSGVO]')
expect(result).toContain('Inhalt A') expect(result).toContain('Inhalt A')
expect(mockFetch).toHaveBeenCalledTimes(mod.COMPLIANCE_COLLECTIONS.length) expect(mockFetch).toHaveBeenCalledTimes(1)
}) })
it('ruft die ai-sdk /sdk/v1/rag/search mit collection + top_k auf', async () => { it('ruft /sdk/v1/rag/retrieve mit query + top_k (ohne collection) auf', async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ results: [] }) }) mockFetch.mockResolvedValue({ ok: true, json: async () => ({ results: [] }) })
await mod.queryAdvisorRAG('test') await mod.queryAdvisorRAG('test')
expect(mockFetch).toHaveBeenCalledWith( expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/sdk/v1/rag/search'), expect.stringContaining('/sdk/v1/rag/retrieve'),
expect.objectContaining({ method: 'POST' }), expect.objectContaining({ method: 'POST' }),
) )
const body = JSON.parse(mockFetch.mock.calls[0][1].body) const body = JSON.parse(mockFetch.mock.calls[0][1].body)
expect(body).toMatchObject({ query: 'test', top_k: 3 }) expect(body).toMatchObject({ query: 'test', top_k: 8 })
expect(mod.COMPLIANCE_COLLECTIONS).toContain(body.collection) expect(body.collection).toBeUndefined()
}) })
it('liefert leeren String wenn das RAG-Backend nicht erreichbar ist (graceful)', async () => { it('liefert leeren String wenn das RAG-Backend nicht erreichbar ist (graceful)', async () => {
@@ -80,10 +80,5 @@ describe('advisor-rag', () => {
const result = await mod.queryAdvisorRAG('test') const result = await mod.queryAdvisorRAG('test')
expect(result).toBe('') expect(result).toBe('')
}) })
it('umfasst genau die 6 Compliance-Collections', () => {
expect(mod.COMPLIANCE_COLLECTIONS).toHaveLength(6)
expect(mod.COMPLIANCE_COLLECTIONS).toContain('bp_compliance_recht')
})
}) })
}) })
+25 -37
View File
@@ -1,12 +1,13 @@
/** /**
* Compliance-Advisor RAG-Suche. * Compliance-Advisor RAG-Suche.
* *
* Fragt die ai-compliance-sdk (`/sdk/v1/rag/search`) ab statt des frueheren * Fragt den Authority Router der ai-compliance-sdk (`/sdk/v1/rag/retrieve`) mit NUR der
* `rag-service:8097` (auf prod nicht erreichbar). Die ai-sdk embeddet die Query * Query ab — der Router waehlt selbst die Collections (Broad-Authority-Base + KB-2026.1-Slice
* mit bge-m3 (prod: ollama-embed) und sucht in den Qdrant-Compliance-Collections * bei in-scope), embeddet mit bge-m3 (prod: ollama-embed), merged + authority-ranked. Der
* — damit profitiert der Advisor vom reicheren Embedding. * Advisor bleibt damit collection-agnostisch (Vertrag: Compiler -> Collections -> Retriever
* -> Advisor); die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
* *
* Fehler je Collection werden geschluckt (graceful: Antwort ohne diesen Treffer). * Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
* Fundstellen via article_label sind live ab dem Prod-Re-Ingest 2026-06. * Fundstellen via article_label sind live ab dem Prod-Re-Ingest 2026-06.
*/ */
@@ -17,16 +18,6 @@ const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
const DEFAULT_TENANT = const DEFAULT_TENANT =
process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
// Compliance-relevante Collections (ai-sdk-Whitelist `AllowedCollections`).
export const COMPLIANCE_COLLECTIONS = [
'bp_compliance_gesetze',
'bp_compliance_ce',
'bp_compliance_datenschutz',
'bp_dsfa_corpus',
'bp_compliance_recht',
'bp_legal_templates',
] as const
interface SdkRagResult { interface SdkRagResult {
text?: string text?: string
regulation_code?: string regulation_code?: string
@@ -68,39 +59,36 @@ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassag
.filter((p) => p.content) .filter((p) => p.content)
} }
async function searchCollection(collection: string, query: string): Promise<ScoredPassage[]> { /**
* Authority Router: EIN collection-agnostischer Aufruf an die ai-sdk (`/sdk/v1/rag/retrieve`).
* Der Router waehlt die Collections (Broad-Authority-Base + KB-2026.1-Slice bei in-scope),
* merged + authority-ranked sie und liefert die Top-Passagen. Der Advisor weiss damit nichts
* mehr ueber einzelne Collections — die fruehere Multi-Collection-Logik liegt jetzt im Retriever.
* Fehler werden geschluckt (graceful: Antwort ohne RAG-Kontext).
*/
export async function queryAdvisorRAG(query: string): Promise<string> {
let passages: ScoredPassage[] = []
try { try {
const res = await fetch(`${SDK_URL}/sdk/v1/rag/search`, { const res = await fetch(`${SDK_URL}/sdk/v1/rag/retrieve`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-User-ID': DEFAULT_USER, 'X-User-ID': DEFAULT_USER,
'X-Tenant-ID': DEFAULT_TENANT, 'X-Tenant-ID': DEFAULT_TENANT,
}, },
body: JSON.stringify({ query, collection, top_k: 3 }), body: JSON.stringify({ query, top_k: 8 }),
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(15000),
}) })
if (!res.ok) return [] if (res.ok) {
const data = await res.json() const data = await res.json()
return mapSdkResults(data.results) passages = mapSdkResults(data.results)
}
} catch { } catch {
return [] // graceful: keine Verbindung -> Antwort ohne RAG-Kontext
} }
} // Der Router liefert bereits authority-geordnete Top-K; Reihenfolge bewahren.
if (passages.length === 0) return ''
/** return passages
* Fragt alle Compliance-Collections parallel ab und liefert die Top-8-Passagen
* als formatierten Kontextblock (oder '' wenn nichts erreichbar/gefunden).
*/
export async function queryAdvisorRAG(query: string): Promise<string> {
const settled = await Promise.all(
COMPLIANCE_COLLECTIONS.map((c) => searchCollection(c, query)),
)
const all = settled.flat()
if (all.length === 0) return ''
all.sort((a, b) => b.score - a.score)
return all
.slice(0, 8)
.map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`) .map((r, i) => `[Quelle ${i + 1}: ${r.source}]\n${r.content}`)
.join('\n\n---\n\n') .join('\n\n---\n\n')
} }
@@ -82,6 +82,43 @@ func (h *RAGHandlers) Search(c *gin.Context) {
}) })
} }
// RetrieveRequest is the Authority Router request: a query only, no collection — the router decides
// which collections to query (broad authority base + the in-scope KB-2026.1 slice).
type RetrieveRequest struct {
Query string `json:"query" binding:"required"`
TopK int `json:"top_k,omitempty"`
}
// Retrieve is the Authority Router endpoint. The Advisor calls this with ONLY a query and stays
// collection-agnostic; the router fans out over the authority base + the in-scope slice, merges by
// authority score, and returns the unified top-K. Response shape matches Search (query/results/
// count/assessment) so existing consumers parse it unchanged.
// POST /sdk/v1/rag/retrieve
func (h *RAGHandlers) Retrieve(c *gin.Context) {
var req RetrieveRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.TopK <= 0 || req.TopK > 20 {
req.TopK = 8
}
results, err := h.ragClient.Retrieve(c.Request.Context(), req.Query, req.TopK)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "RAG retrieve failed: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"query": req.Query,
"results": results,
"count": len(results),
"assessment": ucca.Assess(results),
})
}
// ListRegulations returns the list of available regulations in the corpus. // ListRegulations returns the list of available regulations in the corpus.
// GET /sdk/v1/rag/regulations // GET /sdk/v1/rag/regulations
func (h *RAGHandlers) ListRegulations(c *gin.Context) { func (h *RAGHandlers) ListRegulations(c *gin.Context) {
+1 -1
View File
@@ -159,6 +159,7 @@ func registerRAGRoutes(v1 *gin.RouterGroup, h *handlers.RAGHandlers) {
ragRoutes := v1.Group("/rag") ragRoutes := v1.Group("/rag")
{ {
ragRoutes.POST("/search", h.Search) ragRoutes.POST("/search", h.Search)
ragRoutes.POST("/retrieve", h.Retrieve)
ragRoutes.GET("/regulations", h.ListRegulations) ragRoutes.GET("/regulations", h.ListRegulations)
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)
@@ -358,7 +359,6 @@ func registerWhistleblowerRoutes(v1 *gin.RouterGroup, h *handlers.WhistleblowerH
} }
} }
func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers) { func registerMaximizerRoutes(v1 *gin.RouterGroup, h *handlers.MaximizerHandlers) {
m := v1.Group("/maximizer") m := v1.Group("/maximizer")
{ {
@@ -0,0 +1,73 @@
package iace
// P3: pin accepted proposer decisions into the GT gate.
//
// When a human accepts a proposal from the offline proposer (a dedup
// supersession, a foreign-framing gate, a vocab→tag mapping, a coverage hazard),
// they record an AcceptedPin. A pin is a tiny, machine-scoped invariant — "this
// pattern MUST (or must NOT) fire for this machine" — that a test re-checks on
// every run. This is what makes the library's growth COMPOUND into the gate
// instead of silently eroding it: a future change that re-introduces a dropped
// duplicate, un-gates a foreign pattern, or removes a coverage hazard breaks the
// pin and fails CI.
//
// A single boolean covers all four proposal types:
// - dedup supersession accepted → DropPattern MustFire=false
// - foreign-framing gate accepted → foreign pattern MustFire=false
// - vocab→tag / coverage hazard accepted → the enabled pattern MustFire=true
// AcceptedPin is one regression invariant for an accepted proposal.
type AcceptedPin struct {
Pattern string `json:"pattern"`
MustFire bool `json:"must_fire"`
Reason string `json:"reason"`
FromProposal string `json:"from_proposal,omitempty"`
}
// PinSet is the accepted-pin registry for one machine (testdata/accepted_pins_*.json).
type PinSet struct {
Machine string `json:"machine"`
Pins []AcceptedPin `json:"pins"`
}
// PinResult is the verdict for one pin against an engine run.
type PinResult struct {
Pin AcceptedPin
OK bool
Detail string
}
// VerifyPins checks every pin against the set of pattern IDs the engine actually
// fired for the machine. A pin holds iff the pattern's presence equals MustFire.
func VerifyPins(pins []AcceptedPin, firedPatternIDs []string) []PinResult {
fired := make(map[string]bool, len(firedPatternIDs))
for _, id := range firedPatternIDs {
fired[id] = true
}
out := make([]PinResult, 0, len(pins))
for _, p := range pins {
got := fired[p.Pattern]
ok := got == p.MustFire
detail := "ok"
if !ok {
if p.MustFire {
detail = "expected to fire but did NOT — coverage/mapping regressed"
} else {
detail = "expected to be suppressed but FIRED — gate/supersession regressed"
}
}
out = append(out, PinResult{Pin: p, OK: ok, Detail: detail})
}
return out
}
// GenerateDedupPin turns an accepted (verdict=duplicate) dedup candidate into the
// pin that protects the supersession: the dropped pattern must no longer fire.
func GenerateDedupPin(c DedupCandidate) AcceptedPin {
return AcceptedPin{
Pattern: c.DropPattern,
MustFire: false,
Reason: "accepted duplicate of " + c.KeepPattern + " (" + c.Category + ")",
FromProposal: "dedup " + c.DropPattern + " -> " + c.KeepPattern,
}
}
@@ -0,0 +1,63 @@
package iace
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestVerifyPins(t *testing.T) {
pins := []AcceptedPin{
{Pattern: "HPa", MustFire: true},
{Pattern: "HPb", MustFire: false},
}
res := VerifyPins(pins, []string{"HPa", "HPb"})
if !res[0].OK {
t.Errorf("HPa must_fire=true and it fired -> should be OK")
}
if res[1].OK {
t.Errorf("HPb must_fire=false but it fired -> should be VIOLATED")
}
res2 := VerifyPins(pins, []string{})
if res2[0].OK || !res2[1].OK {
t.Errorf("expected HPa violated + HPb ok, got %+v", res2)
}
}
func TestGenerateDedupPin(t *testing.T) {
pin := GenerateDedupPin(DedupCandidate{KeepPattern: "HP144", DropPattern: "HP013", Category: "electrical_hazard"})
if pin.Pattern != "HP013" || pin.MustFire {
t.Fatalf("want pin {HP013, must_fire=false}, got %+v", pin)
}
}
// TestWarewashing_AcceptedPins re-checks every accepted P1 supersession against the
// live warewashing engine output. A future change that un-suppresses HP013/016/018
// or drops HP2201/HP144 breaks a pin here — the gate compounds, not erodes.
func TestWarewashing_AcceptedPins(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "accepted_pins_warewashing.json"))
if err != nil {
t.Fatalf("read pins: %v", err)
}
var ps PinSet
if err := json.Unmarshal(raw, &ps); err != nil {
t.Fatalf("parse pins: %v", err)
}
_, _, kept := warewashingEngineOutput()
firedIDs := make([]string, 0, len(kept))
for _, pm := range kept {
firedIDs = append(firedIDs, pm.PatternID)
}
ok := 0
for _, r := range VerifyPins(ps.Pins, firedIDs) {
if r.OK {
ok++
continue
}
t.Errorf("PIN VIOLATED: %s (must_fire=%v) — %s [%s]", r.Pin.Pattern, r.Pin.MustFire, r.Detail, r.Pin.Reason)
}
t.Logf("accepted pins for %q: %d/%d hold", ps.Machine, ok, len(ps.Pins))
}
@@ -0,0 +1,10 @@
{
"machine": "Gewerbliche Untertisch-Geschirrspuelmaschine (vernetzt)",
"pins": [
{"pattern": "HP016", "must_fire": false, "reason": "generic hot-surface (Formwerkzeuge/Auspuffleitung framing) superseded by HP2201", "from_proposal": "P1 thermal supersession"},
{"pattern": "HP018", "must_fire": false, "reason": "actuator-burn superseded by HP2201", "from_proposal": "P1 thermal supersession"},
{"pattern": "HP013", "must_fire": false, "reason": "stored-energy Batterie/USV framing superseded by HP144", "from_proposal": "P1 stored-energy supersession"},
{"pattern": "HP2201", "must_fire": true, "reason": "warewashing hot-surface (Boiler/Tank/Spuelkammer) must remain — it is the clean equivalent that replaces HP016/HP018", "from_proposal": "P1 thermal supersession"},
{"pattern": "HP144", "must_fire": true, "reason": "residual-voltage (Frequenzumrichter/Zwischenkreis) must remain — clean equivalent that replaces HP013", "from_proposal": "P1 stored-energy supersession"}
]
}
@@ -0,0 +1,129 @@
package ucca
import (
"context"
"os"
"sort"
"strings"
"sync"
)
// routerBaseCollections is the broad authority base the Authority Router fans out over. It mirrors
// the Advisor's historical multi-collection set; the KB-2026.1 slice is added separately when the
// query is in scope. Override via RAG_ROUTER_COLLECTIONS (comma-separated) per environment.
func (c *LegalRAGClient) routerBaseCollections() []string {
if v := strings.TrimSpace(os.Getenv("RAG_ROUTER_COLLECTIONS")); v != "" {
var out []string
for _, p := range strings.Split(v, ",") {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
return out
}
}
return []string{
"bp_compliance_gesetze",
"bp_compliance_ce",
"bp_compliance_datenschutz",
"bp_dsfa_corpus",
"bp_compliance_recht",
"bp_legal_templates",
}
}
const routerPerCollectionTopK = 3
// Retrieve is the Authority Router entry point: callers (the Advisor) pass ONLY a query and stay
// collection-agnostic. The router fans out over the broad authority base and ADDS the KB-2026.1
// slice when the query is in scope (inKBScope), then merges all hits, deduplicates, and returns the
// top-K by authority score. This moves the former Advisor-side collection fan-out into the retrieval
// layer (the "Retriever" tier of the quality pyramid), so the proven KB-2026.1 slice gain reaches
// the product path without the Advisor knowing about individual collections.
//
// The merged set is ordered by the per-collection authority score that rerankByAuthority already
// produced inside searchInternal — i.e. binding-vs-guidance ordering is preserved across the merge.
// Per-collection failures (e.g. a collection absent on an environment) degrade gracefully.
func (c *LegalRAGClient) Retrieve(ctx context.Context, query string, topK int) ([]LegalSearchResult, error) {
if topK <= 0 {
topK = 8
}
collections := c.routerBaseCollections()
if c.kbScopeRoutingEnabled && c.kbSliceCollection != "" && inKBScope(query) {
collections = append(collections, c.kbSliceCollection)
}
// Cross-regulation queries (>=2 explicitly named regulations) get a larger per-collection budget
// so each collection's multi-regulation search isn't truncated down to the keyword-dominant
// domain; the final per-regulation balancing then guarantees every named domain in the top-K.
regs := detectRegulations(query)
perColl := routerPerCollectionTopK
if len(regs) >= 2 {
perColl = routerPerCollectionTopK * len(regs)
}
// Warm the full-text indexes sequentially first so the concurrent fan-out below only READS the
// shared textIndexEnsured map (the writes happen here, serialized) — closes the cold-start map
// race deterministically. Best-effort: a missing collection just stays un-indexed (hybrid then
// falls back to dense, or the per-collection search degrades to nothing).
if c.hybridEnabled {
for _, coll := range collections {
_ = c.ensureTextIndex(ctx, coll)
}
}
out := make([][]LegalSearchResult, len(collections))
var wg sync.WaitGroup
for i, coll := range collections {
wg.Add(1)
go func(i int, coll string) {
defer wg.Done()
if res, err := c.searchInternal(ctx, coll, query, nil, perColl); err == nil {
out[i] = res
}
}(i, coll)
}
wg.Wait()
merged := make([]LegalSearchResult, 0, len(collections)*perColl)
for _, r := range out {
merged = append(merged, r...)
}
merged = dedupResults(merged)
sort.SliceStable(merged, func(a, b int) bool { return merged[a].Score > merged[b].Score })
// Cross-regulation: guarantee every named domain is represented (0070-class fix) instead of
// letting a global score-sort starve the non-dominant domain.
if len(regs) >= 2 {
return balanceByRegulation(merged, regs, topK), nil
}
if len(merged) > topK {
merged = merged[:topK]
}
return merged, nil
}
// dedupResults removes duplicate passages that can appear when collections overlap, keeping the
// highest-scoring occurrence. Identity = regulation_code + article_label + a text prefix.
func dedupResults(in []LegalSearchResult) []LegalSearchResult {
pos := make(map[string]int, len(in))
out := make([]LegalSearchResult, 0, len(in))
for _, r := range in {
text := r.Text
if len(text) > 80 {
text = text[:80]
}
key := r.RegulationCode + "|" + r.ArticleLabel + "|" + text
if idx, ok := pos[key]; ok {
if r.Score > out[idx].Score {
out[idx] = r
}
continue
}
pos[key] = len(out)
out = append(out, r)
}
return out
}
@@ -0,0 +1,164 @@
package ucca
import (
"context"
"encoding/json"
"os"
"strconv"
"strings"
"testing"
)
type benchQ struct {
ID string `json:"id"`
Document string `json:"document"`
Question string `json:"question"`
}
// docTokens maps a bench question's expected document to acceptable regulation_code/label substrings.
func docTokens(document string) []string {
d := strings.ToUpper(document)
var t []string
for _, wp := range []string{"WP243", "WP248", "WP260"} {
if strings.Contains(d, wp) {
t = append(t, wp)
}
}
dns := strings.ReplaceAll(d, " ", "")
for _, gl := range []struct{ key, tok string }{{"07/2020", "GL07"}, {"05/2020", "GL05"}, {"09/2022", "GL09"}} {
if strings.Contains(dns, gl.key) {
t = append(t, gl.tok)
}
}
if strings.Contains(d, "TDDDG") {
t = append(t, "TDDDG")
}
if strings.Contains(d, "DSGVO") || strings.Contains(d, "ART. 13") || strings.Contains(d, "ART. 14") {
t = append(t, "DSGVO")
}
if strings.Contains(d, "BDSG") {
t = append(t, "BDSG")
}
if strings.Contains(d, "CRA") {
t = append(t, "CRA")
}
if strings.Contains(d, "MASCH") {
t = append(t, "MASCH", "MACHINERY", "MVO")
}
return t
}
func hitDoc(results []LegalSearchResult, toks []string) bool {
for _, r := range results {
s := strings.ReplaceAll(strings.ToUpper(r.RegulationCode+" "+r.ArticleLabel), " ", "")
for _, tk := range toks {
if strings.Contains(s, strings.ReplaceAll(tk, " ", "")) {
return true
}
}
}
return false
}
// TestMultiReg0070E2E (RUN_E2E=1) is the 0070 regression: a cross-regulation query (CRA + MaschVO)
// must return BOTH domains through the real Retrieve(), not just the keyword-dominant CRA.
func TestMultiReg0070E2E(t *testing.T) {
if os.Getenv("RUN_E2E") != "1" {
t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL/QDRANT_API_KEY")
}
c := NewLegalRAGClient()
q := "Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?"
res, err := c.Retrieve(context.Background(), q, 8)
if err != nil {
t.Fatalf("retrieve: %v", err)
}
var hasCRA, hasMasch bool
var codes []string
for _, r := range res {
u := strings.ToUpper(r.RegulationCode)
codes = append(codes, u)
if strings.Contains(u, "CRA") {
hasCRA = true
}
if strings.Contains(u, "MASCH") || strings.Contains(u, "MACHIN") || u == "MVO" {
hasMasch = true
}
}
t.Logf("0070 top-8 codes: %v", codes)
if !hasCRA || !hasMasch {
t.Errorf("0070 must return BOTH domains via Retrieve(): CRA=%v MaschVO=%v", hasCRA, hasMasch)
}
}
// TestAuthorityRouterCB100 (RUN_E2E=1) drives the REAL Retrieve() over the ComplianceBench-100 against
// the live collections: NEW (scope routing on → slice added for in-scope queries) vs OLD (routing off
// → broad base only). It is the regression gate that the router actually delivers the proven slice
// gain (+28/0-regr in the offline simulation) through the production Go code path.
func TestAuthorityRouterCB100(t *testing.T) {
if os.Getenv("RUN_E2E") != "1" {
t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL/QDRANT_API_KEY + BENCH_PATH")
}
path := os.Getenv("BENCH_PATH")
if path == "" {
path = "/tmp/compliance_bench.json"
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("bench read: %v", err)
}
var doc struct {
Questions []benchQ `json:"questions"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("bench parse: %v", err)
}
// BENCH_STRIDE samples every Kth question (stratified across DS/CRA/MaschVO) so the gate stays
// tractable against the remote dev Qdrant; default 1 = full CB-100.
stride := 1
if s := os.Getenv("BENCH_STRIDE"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 {
stride = n
}
}
c := NewLegalRAGClient()
ctx := context.Background()
var n, oldHit, newHit, gain, regr int
for i, q := range doc.Questions {
if i%stride != 0 {
continue
}
n++
toks := docTokens(q.Document)
c.kbScopeRoutingEnabled = false
oldRes, _ := c.Retrieve(ctx, q.Question, 8)
c.kbScopeRoutingEnabled = true
newRes, _ := c.Retrieve(ctx, q.Question, 8)
oh, nh := hitDoc(oldRes, toks), hitDoc(newRes, toks)
if oh {
oldHit++
}
if nh {
newHit++
}
flip := "="
switch {
case !oh && nh:
gain++
flip = "GAIN"
case oh && !nh:
regr++
flip = "REGR"
}
t.Logf("%-9s [%-14s] OLD=%-5v NEW=%-5v %s", q.ID, q.Document, oh, nh, flip)
}
t.Logf("CB-100 sample (stride=%d) via Retrieve(): N=%d | OLD-hit %d | NEW-hit %d | GAIN %d | REGR %d",
stride, n, oldHit, newHit, gain, regr)
if newHit <= oldHit || gain < 3 {
t.Errorf("router must add slice gains: NEW(%d) must exceed OLD(%d), gain=%d", newHit, oldHit, gain)
}
if regr > 2 {
t.Errorf("too many regressions through the router: %d", regr)
}
}
@@ -0,0 +1,99 @@
package ucca
import (
"os"
"testing"
)
func TestRouterBaseCollections(t *testing.T) {
c := &LegalRAGClient{}
os.Unsetenv("RAG_ROUTER_COLLECTIONS")
def := c.routerBaseCollections()
if len(def) != 6 || def[1] != "bp_compliance_ce" {
t.Fatalf("default base collections unexpected: %v", def)
}
os.Setenv("RAG_ROUTER_COLLECTIONS", " bp_compliance_ce , kb_2026_1_build ,, ")
defer os.Unsetenv("RAG_ROUTER_COLLECTIONS")
got := c.routerBaseCollections()
if len(got) != 2 || got[0] != "bp_compliance_ce" || got[1] != "kb_2026_1_build" {
t.Fatalf("env override parse failed (trim/empty): %v", got)
}
}
func TestRouterSliceSelection(t *testing.T) {
// The router appends the slice exactly when the query is in scope (inKBScope) and routing is on.
// Mirror the selection logic so a regression in either is caught without a live Qdrant.
c := &LegalRAGClient{kbSliceCollection: "kb_2026_1_build", kbScopeRoutingEnabled: true}
sel := func(q string) bool {
colls := c.routerBaseCollections()
if c.kbScopeRoutingEnabled && c.kbSliceCollection != "" && inKBScope(q) {
colls = append(colls, c.kbSliceCollection)
}
for _, x := range colls {
if x == c.kbSliceCollection {
return true
}
}
return false
}
if !sel("Welche neun Kriterien nennt WP248 fuer ein hohes Risiko?") {
t.Error("in-scope guidance query must include the slice")
}
if sel("Was sagt NIST SP 800-53 zu Access Control?") {
t.Error("out-of-scope query must NOT include the slice")
}
c.kbScopeRoutingEnabled = false
if sel("Welche Kriterien nennt WP248?") {
t.Error("routing disabled => slice never included")
}
}
func TestBalanceByRegulation(t *testing.T) {
regs := []detectedRegulation{
{Canonical: "CRA", CodeValues: []string{"CRA"}},
{Canonical: "MaschVO", CodeValues: []string{"MASCHVO", "MVO", "MACHINERY"}},
}
// CRA dominates by score; without balancing the top-4 would be all CRA + NIST.
pool := []LegalSearchResult{
{RegulationCode: "CRA", Score: 0.99},
{RegulationCode: "CRA", Score: 0.98},
{RegulationCode: "CRA", Score: 0.97},
{RegulationCode: "NIST", Score: 0.96},
{RegulationCode: "MACHINERY", Score: 0.70},
{RegulationCode: "MVO", Score: 0.65},
}
out := balanceByRegulation(pool, regs, 4)
var hasCRA, hasMasch bool
for _, r := range out {
switch r.RegulationCode {
case "CRA":
hasCRA = true
case "MACHINERY", "MVO":
hasMasch = true
}
}
if !hasCRA || !hasMasch {
t.Errorf("both named domains must be represented: CRA=%v MaschVO=%v out=%v", hasCRA, hasMasch, out)
}
if out[0].RegulationCode != "CRA" || !(out[1].RegulationCode == "MACHINERY" || out[1].RegulationCode == "MVO") {
t.Errorf("round-robin should alternate domains, got %s then %s", out[0].RegulationCode, out[1].RegulationCode)
}
}
func TestDedupResults(t *testing.T) {
in := []LegalSearchResult{
{RegulationCode: "EDPB WP248", ArticleLabel: "III.B", Text: "lorem", Score: 0.7},
{RegulationCode: "EDPB WP248", ArticleLabel: "III.B", Text: "lorem", Score: 0.9}, // dup, higher score
{RegulationCode: "DSGVO", ArticleLabel: "Art. 35", Text: "ipsum", Score: 0.8},
}
out := dedupResults(in)
if len(out) != 2 {
t.Fatalf("expected 2 deduped, got %d", len(out))
}
for _, r := range out {
if r.RegulationCode == "EDPB WP248" && r.Score != 0.9 {
t.Errorf("dedup must keep highest score, got %v", r.Score)
}
}
}
@@ -0,0 +1,52 @@
package ucca
import "strings"
// kbScopeTopics are high-precision data-protection / compliance topic markers that place a query in
// the KB-2026.1 authoritative slice even when it does NOT name a regulation. Conservative by design:
// an unmatched query falls back to the broad CE default (no regression) — the slice is only used when
// the query is confidently in-scope.
var kbScopeTopics = []string{
// DP-Guidance-Marker, die IN der Slice liegen (EDPB/DSK/WP/GL) — bewusst NICHT die generischen
// Verben aus guidanceIntentSignals (sagt/laut/empfiehlt/auslegung) und NICHT enisa/bsi/nist/owasp
// (die liegen im breiten CE-Pool, nicht in der Slice).
"edpb", "dsk", "datenschutzausschuss", "orientierungshilfe",
"wp2", "wp 2", "wp29", "working paper", "gl 0",
"datenschutz", "dsgvo", "gdpr", "dsfa", "folgenabschätzung", "folgenabschaetzung",
"einwilligung", "auftragsverarbeit", "betroffenenrecht", "auskunftsrecht",
"verarbeitungsverzeichnis", "datenschutzbeauftragt", "verzeichnis von verarbeitung",
"cookie", "tracking", "transparenzpflicht", "datenpanne", "meldepflicht",
"technische und organisatorische maßnahmen",
"cyber resilience", "schwachstelle", "vulnerability", "sicherheitsupdate",
"maschinensicherheit", "wesentliche veränderung", "wesentliche veraenderung",
"konformitätsbewertung", "konformitaetsbewertung", "ce-kennzeichnung",
}
// inKBScope reports whether the query belongs to the KB-2026.1 authoritative slice. True when it
// names an in-slice regulation (detectRegulations), asks for guidance (EDPB/DSK/WP/GL), or hits a
// data-protection / compliance topic marker.
func inKBScope(query string) bool {
if len(detectRegulations(query)) > 0 {
return true
}
q := strings.ToLower(query)
for _, t := range kbScopeTopics {
if strings.Contains(q, t) {
return true
}
}
return false
}
// resolveCollection applies the Blue-Green „authoritative slice promotion" routing. An explicitly
// requested collection is honoured unchanged; the DEFAULT (empty) request is routed to the KB-2026.1
// slice when the query is in-scope, else to the broad CE default. Disable via RAG_KB_SCOPE_ROUTING=false.
func (c *LegalRAGClient) resolveCollection(query, requested string) string {
if requested != "" {
return requested
}
if c.kbScopeRoutingEnabled && c.kbSliceCollection != "" && inKBScope(query) {
return c.kbSliceCollection
}
return c.collection
}
@@ -0,0 +1,101 @@
package ucca
import (
"context"
"fmt"
"os"
"strings"
"testing"
)
func TestInKBScope(t *testing.T) {
inScope := []string{
"Welche neun Kriterien nennt WP248 fuer ein hohes Risiko?",
"Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?",
"Wann ist eine Datenschutz-Folgenabschaetzung erforderlich?",
"Welche Anforderungen stellt die DSGVO an die Einwilligung?",
"Brauche ich einen Datenschutzbeauftragten?",
"Wann muss eine aktiv ausgenutzte Schwachstelle gemeldet werden?",
}
outScope := []string{
"Welche OWASP-Kontrollen gibt es fuer Authentifizierung?",
"Was sagt NIST SP 800-53 zu Access Control?",
"Wie funktioniert ISO 27001 Zertifizierung?",
"Welche IFRS-Standards gelten fuer Leasing?",
}
for _, q := range inScope {
if !inKBScope(q) {
t.Errorf("inKBScope(%q) = false, want true", q)
}
}
for _, q := range outScope {
if inKBScope(q) {
t.Errorf("inKBScope(%q) = true, want false", q)
}
}
}
func TestResolveCollection(t *testing.T) {
c := &LegalRAGClient{collection: "bp_compliance_ce", kbSliceCollection: "kb_2026_1_build", kbScopeRoutingEnabled: true}
if got := c.resolveCollection("Welche Kriterien nennt WP248?", ""); got != "kb_2026_1_build" {
t.Errorf("in-scope default -> %s, want kb_2026_1_build", got)
}
if got := c.resolveCollection("Was sagt NIST SP 800-53?", ""); got != "bp_compliance_ce" {
t.Errorf("out-of-scope default -> %s, want bp_compliance_ce", got)
}
if got := c.resolveCollection("Welche Kriterien nennt WP248?", "explicit_coll"); got != "explicit_coll" {
t.Errorf("explicit request must be honoured -> %s", got)
}
c.kbScopeRoutingEnabled = false
if got := c.resolveCollection("Welche Kriterien nennt WP248?", ""); got != "bp_compliance_ce" {
t.Errorf("disabled routing -> %s, want bp_compliance_ce", got)
}
}
// TestKBScopeRoutingE2E (RUN_E2E=1) verifies the routing against the REAL collections: a default
// Search() of an in-scope query must hit the KB-2026.1 slice (WP248/MaschVO live there but NOT in
// the broad CE pool = clean discriminator); an out-of-scope query stays on CE.
func TestKBScopeRoutingE2E(t *testing.T) {
if os.Getenv("RUN_E2E") != "1" {
t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL/QDRANT_API_KEY")
}
c := NewLegalRAGClient()
cases := []struct {
q string
wantToken string // expected in top-8 when routed to the slice
wantInKB bool
}{
{"Welche neun Kriterien nennt WP248 fuer ein voraussichtlich hohes Risiko?", "WP248", true},
{"Welche grundlegenden Sicherheits- und Gesundheitsschutzanforderungen enthaelt Anhang III der Maschinenverordnung?", "MASCH", true},
{"Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?", "MASCH", true},
{"Was sagt NIST SP 800-53 zu Access Control?", "", false},
}
for _, tc := range cases {
routed := c.resolveCollection(tc.q, "")
res, err := c.Search(context.Background(), tc.q, nil, 8)
if err != nil {
t.Fatalf("%q: %v", tc.q, err)
}
codes := map[string]bool{}
for _, r := range res {
codes[strings.ToUpper(r.RegulationCode)] = true
}
hit := false
if tc.wantToken != "" {
for cd := range codes {
if strings.Contains(cd, tc.wantToken) {
hit = true
break
}
}
}
col := make([]string, 0, len(codes))
for cd := range codes {
col = append(col, cd)
}
fmt.Printf("inKB=%-5v routed=%-16s wantTok=%-6s found=%-5v | %v\n", tc.wantInKB, routed, tc.wantToken, hit, col)
if tc.wantInKB && tc.wantToken != "" && !hit {
t.Errorf("%q routed to %s but %s not in top-8 (slice not active?)", tc.q, routed, tc.wantToken)
}
}
}
@@ -21,6 +21,12 @@ type LegalRAGClient struct {
textIndexEnsured map[string]bool textIndexEnsured map[string]bool
hybridEnabled bool hybridEnabled bool
graphEnabled bool graphEnabled bool
// Blue-Green „authoritative slice promotion" (additiv, KEIN CE-Ersatz): faellt eine Query
// in den KB-2026.1-Scope (DP/CRA/MaschVO/NIS2/DataAct/DORA/AIAct + EDPB/DSK-Guidance), wird
// die hochwertige Slice-Collection abgefragt; sonst bleibt der breite Default (bp_compliance_ce).
kbSliceCollection string
kbScopeRoutingEnabled 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.
@@ -45,6 +51,14 @@ func NewLegalRAGClient() *LegalRAGClient {
// zur Begruendung/Vollstaendigkeit genutzt, nicht zur Pool-Expansion (Default). // zur Begruendung/Vollstaendigkeit genutzt, nicht zur Pool-Expansion (Default).
graphEnabled := os.Getenv("RAG_GRAPH_EXPANSION") == "true" graphEnabled := os.Getenv("RAG_GRAPH_EXPANSION") == "true"
// KB-2026.1 authoritative slice (Blue-Green, additiv). Routing default AN; Rollback ohne
// Redeploy ueber RAG_KB_SCOPE_ROUTING=false (dann faellt alles auf den CE-Default zurueck).
kbSlice := os.Getenv("RAG_KB_SLICE_COLLECTION")
if kbSlice == "" {
kbSlice = "kb_2026_1_build"
}
kbScopeRouting := os.Getenv("RAG_KB_SCOPE_ROUTING") != "false"
return &LegalRAGClient{ return &LegalRAGClient{
qdrantURL: qdrantURL, qdrantURL: qdrantURL,
qdrantAPIKey: qdrantAPIKey, qdrantAPIKey: qdrantAPIKey,
@@ -54,6 +68,8 @@ func NewLegalRAGClient() *LegalRAGClient {
textIndexEnsured: make(map[string]bool), textIndexEnsured: make(map[string]bool),
hybridEnabled: hybridEnabled, hybridEnabled: hybridEnabled,
graphEnabled: graphEnabled, graphEnabled: graphEnabled,
kbSliceCollection: kbSlice,
kbScopeRoutingEnabled: kbScopeRouting,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
}, },
@@ -63,21 +79,32 @@ func NewLegalRAGClient() *LegalRAGClient {
// SearchCollection queries a specific Qdrant collection for relevant passages. // SearchCollection queries a specific Qdrant collection for relevant passages.
// If collection is empty, it falls back to the default collection (bp_compliance_ce). // If collection is empty, it falls back to the default collection (bp_compliance_ce).
func (c *LegalRAGClient) SearchCollection(ctx context.Context, collection string, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) { func (c *LegalRAGClient) SearchCollection(ctx context.Context, collection string, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) {
if collection == "" { return c.searchInternal(ctx, c.resolveCollection(query, collection), query, regulationIDs, topK)
collection = c.collection
}
return c.searchInternal(ctx, collection, query, regulationIDs, topK)
} }
// Search queries the compliance CE corpus for relevant passages. // Search queries the compliance corpus for relevant passages. The target collection is resolved by
// the Blue-Green slice routing: the KB-2026.1 slice for in-scope queries, else the broad CE default.
func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) { func (c *LegalRAGClient) Search(ctx context.Context, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) {
return c.searchInternal(ctx, c.collection, query, regulationIDs, topK) return c.searchInternal(ctx, c.resolveCollection(query, ""), query, regulationIDs, topK)
} }
// searchInternal performs the actual search against a given collection. // searchInternal performs the actual search against a given collection.
// If hybrid search is enabled, it uses the Qdrant Query API with RRF fusion // If hybrid search is enabled, it uses the Qdrant Query API with RRF fusion
// (dense + full-text). Falls back to dense-only /points/search on failure. // (dense + full-text). Falls back to dense-only /points/search on failure.
func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) { func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, query string, regulationIDs []string, topK int) ([]LegalSearchResult, error) {
// Multi-Regulation-Retrieval: nennt die Query EXPLIZIT >=2 Regelwerke (z.B. "CRA und
// Maschinenverordnung"), wird pro Regelwerk separat retrieved + gemergt, damit BEIDE
// Domaenen im Prompt landen statt nur der keyword-dominanten. Generisch (Query->Regelwerke,
// keine doc-spezifische Logik); nur wenn der Caller nicht ohnehin schon auf Regulierungen
// filtert. Best-effort: leeres/fehlerhaftes Multi-Ergebnis faellt auf die Standardsuche zurueck.
if len(regulationIDs) == 0 {
if regs := detectRegulations(query); len(regs) >= 2 {
if mr, mErr := c.searchMultiRegulation(ctx, collection, query, regs, topK); mErr == nil && len(mr) > 0 {
return mr, nil
}
}
}
embedding, err := c.generateEmbedding(ctx, query) embedding, err := c.generateEmbedding(ctx, query)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate embedding: %w", err) return nil, fmt.Errorf("failed to generate embedding: %w", err)
@@ -123,43 +150,7 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string,
hits = c.expandViaGraph(ctx, collection, hits) hits = c.expandViaGraph(ctx, collection, hits)
} }
results := make([]LegalSearchResult, len(hits)) results := hitsToResults(hits)
for i, hit := range hits {
// Legal-Metadaten nach rag_reingest_spec.md §2: bevorzugt die normalisierten Felder
// (article_label/regulation_code/article/...); Fallback auf alte Feldnamen, solange der
// Korpus noch nicht re-ingestiert ist (regulation_id, section="§ 38").
regCode := getString(hit.Payload, "regulation_code")
if regCode == "" {
regCode = getString(hit.Payload, "regulation_id")
}
article := getString(hit.Payload, "article")
if article == "" {
article = getString(hit.Payload, "section")
}
results[i] = LegalSearchResult{
Text: getString(hit.Payload, "chunk_text"),
RegulationCode: regCode,
RegulationName: getString(hit.Payload, "regulation_name_de"),
RegulationShort: getString(hit.Payload, "regulation_short"),
Category: getString(hit.Payload, "category"),
ArticleLabel: getString(hit.Payload, "article_label"),
Article: article,
Paragraph: getString(hit.Payload, "paragraph"),
Sub: getString(hit.Payload, "sub"),
IsRecital: getBool(hit.Payload, "is_recital"),
CitationStyle: getString(hit.Payload, "citation_style"),
Pages: getIntSlice(hit.Payload, "pages"),
SourceURL: getString(hit.Payload, "source"),
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 // Authority-aware Re-Ranking: bindendes Recht der passenden Jurisdiktion/Domaene nach
// oben, Guidance/Fremdrecht/Off-Domain runter (nichts wird geloescht). Reihenfolge only, // oben, Guidance/Fremdrecht/Off-Domain runter (nichts wird geloescht). Reihenfolge only,
@@ -122,12 +122,14 @@ func (c *LegalRAGClient) searchHybrid(ctx context.Context, collection string, em
} }
if len(regulationIDs) > 0 { if len(regulationIDs) > 0 {
conditions := make([]qdrantCondition, len(regulationIDs)) // Match BOTH the legacy field (regulation_id) and the normalized field
for i, regID := range regulationIDs { // (regulation_code) so per-regulation filtering works on the re-ingested corpus too.
conditions[i] = qdrantCondition{ conditions := make([]qdrantCondition, 0, len(regulationIDs)*2)
Key: "regulation_id", for _, regID := range regulationIDs {
Match: qdrantMatch{Value: regID}, conditions = append(conditions,
} qdrantCondition{Key: "regulation_id", Match: qdrantMatch{Value: regID}},
qdrantCondition{Key: "regulation_code", Match: qdrantMatch{Value: regID}},
)
} }
queryReq.Filter = &qdrantFilter{Should: conditions} queryReq.Filter = &qdrantFilter{Should: conditions}
} }
@@ -175,12 +177,14 @@ func (c *LegalRAGClient) searchDense(ctx context.Context, collection string, emb
} }
if len(regulationIDs) > 0 { if len(regulationIDs) > 0 {
conditions := make([]qdrantCondition, len(regulationIDs)) // Match BOTH the legacy field (regulation_id) and the normalized field
for i, regID := range regulationIDs { // (regulation_code) so per-regulation filtering works on the re-ingested corpus too.
conditions[i] = qdrantCondition{ conditions := make([]qdrantCondition, 0, len(regulationIDs)*2)
Key: "regulation_id", for _, regID := range regulationIDs {
Match: qdrantMatch{Value: regID}, conditions = append(conditions,
} qdrantCondition{Key: "regulation_id", Match: qdrantMatch{Value: regID}},
qdrantCondition{Key: "regulation_code", Match: qdrantMatch{Value: regID}},
)
} }
searchReq.Filter = &qdrantFilter{Should: conditions} searchReq.Filter = &qdrantFilter{Should: conditions}
} }
@@ -0,0 +1,201 @@
package ucca
import (
"context"
"fmt"
"strings"
)
// multiRegMinPerRegulation is the minimum number of hits fetched per named regulation, so
// each domain is fairly represented even when topK/len(regs) would be tiny.
const multiRegMinPerRegulation = 3
// regulationCatalog maps a regulation to (a) the aliases that signal it is EXPLICITLY named
// in a query and (b) the regulation_code/regulation_id values used to filter the corpus.
// Deterministic + generic: a query naming >=2 regulations triggers per-regulation retrieval
// so a cross-regulation question returns every named domain — NOT a doc-specific rule.
var regulationCatalog = []struct {
Canonical string
Aliases []string
CodeValues []string
}{
{"CRA", []string{"cra", "cyber resilience"}, []string{"CRA"}},
// MaschVO heisst je Collection anders: Slice MASCHVO · gesetze MVO · ce MACHINERY/MASCHINENVO.
// Alle Varianten als CodeValues, sonst findet der per-Reg-Filter MaschVO nur in der Slice (0070).
{"MaschVO", []string{"maschinenverordnung", "maschvo", "machinery regulation"}, []string{"MASCHVO", "MaschVO", "MVO", "MASCHINENVO", "MACHINERY"}},
{"NIS2", []string{"nis2", "nis-2", "nis 2"}, []string{"NIS2"}},
{"DORA", []string{"dora"}, []string{"DORA"}},
{"Data Act", []string{"data act", "datengesetz"}, []string{"DATA ACT", "DataAct"}},
{"AI Act", []string{"ai act", "ki-vo", "ki-verordnung", "ai-verordnung"}, []string{"AI ACT", "AIAct"}},
{"DSGVO", []string{"dsgvo", "gdpr"}, []string{"DSGVO"}},
{"TDDDG", []string{"tdddg"}, []string{"TDDDG"}},
{"BDSG", []string{"bdsg"}, []string{"BDSG"}},
}
type detectedRegulation struct {
Canonical string
CodeValues []string
}
// detectRegulations returns the DISTINCT regulations explicitly named in the query. >=2 of
// them is the trigger for multi-regulation retrieval. Pure + deterministic, no LLM.
func detectRegulations(query string) []detectedRegulation {
q := strings.ToLower(query)
var out []detectedRegulation
for _, r := range regulationCatalog {
for _, a := range r.Aliases {
if strings.Contains(q, a) {
out = append(out, detectedRegulation{Canonical: r.Canonical, CodeValues: r.CodeValues})
break
}
}
}
return out
}
func hitID(h qdrantSearchHit) string { return fmt.Sprintf("%v", h.ID) }
// balanceByRegulation builds the final top-K so EVERY explicitly-named regulation with hits is
// represented, instead of letting the keyword-dominant domain (e.g. CRA) crowd out the other
// (e.g. MaschVO) in a cross-regulation query. The input pool must already be score-ordered;
// results are grouped by exact regulation_code match against each regulation's CodeValues, then
// taken round-robin across the named domains (highest-scored first within each), with any
// remaining slots filled by the leftover pool in score order. Generic; no doc-specific logic.
func balanceByRegulation(pool []LegalSearchResult, regs []detectedRegulation, topK int) []LegalSearchResult {
if topK <= 0 {
topK = 8
}
byReg := make([][]LegalSearchResult, len(regs))
matched := make([]bool, len(pool))
for ri, r := range regs {
for pi := range pool {
if matched[pi] {
continue
}
code := strings.TrimSpace(pool[pi].RegulationCode)
for _, cv := range r.CodeValues {
if strings.EqualFold(code, cv) {
byReg[ri] = append(byReg[ri], pool[pi])
matched[pi] = true
break
}
}
}
}
out := make([]LegalSearchResult, 0, topK)
idx := make([]int, len(regs))
for len(out) < topK {
progressed := false
for ri := range regs {
if idx[ri] < len(byReg[ri]) {
out = append(out, byReg[ri][idx[ri]])
idx[ri]++
progressed = true
if len(out) >= topK {
break
}
}
}
if !progressed {
break
}
}
for pi := range pool {
if len(out) >= topK {
break
}
if !matched[pi] {
out = append(out, pool[pi])
}
}
return out
}
// searchMultiRegulation retrieves each explicitly-named regulation SEPARATELY (per-regulation
// filter) and merges, so a cross-regulation query ("Wie greifen CRA und MaschVO ineinander?")
// returns BOTH domains in the prompt instead of only the keyword-dominant one. Generic over any
// named pair (DSGVO+TDDDG, CRA+NIS2, DORA+NIS2, AI Act+DSGVO, ...). The merged pool is
// authority-reranked once. Pure pool-construction; topK contract preserved.
func (c *LegalRAGClient) searchMultiRegulation(ctx context.Context, collection, query string, regs []detectedRegulation, topK int) ([]LegalSearchResult, error) {
embedding, err := c.generateEmbedding(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to generate embedding: %w", err)
}
perReg := topK / len(regs)
if perReg < multiRegMinPerRegulation {
perReg = multiRegMinPerRegulation
}
var merged []qdrantSearchHit
seen := make(map[string]bool)
for _, r := range regs {
var hits []qdrantSearchHit
if c.hybridEnabled {
if h, hErr := c.searchHybrid(ctx, collection, embedding, r.CodeValues, perReg); hErr == nil {
hits = h
}
}
if hits == nil {
if h, dErr := c.searchDense(ctx, collection, embedding, r.CodeValues, perReg); dErr == nil {
hits = h
}
}
for _, h := range hits {
id := hitID(h)
if seen[id] {
continue
}
seen[id] = true
merged = append(merged, h)
}
}
if len(merged) == 0 {
return nil, fmt.Errorf("multi-regulation search returned no hits")
}
results := hitsToResults(merged)
results = rerankByAuthority(query, results)
if topK > 0 && len(results) > topK {
results = results[:topK]
}
return results, nil
}
// hitsToResults maps raw Qdrant hits to LegalSearchResult, preferring the normalized payload
// fields (regulation_code/article_label/...) with fallback to the legacy names (regulation_id,
// section) while the corpus is mid-re-ingestion. Shared by searchInternal + searchMultiRegulation.
func hitsToResults(hits []qdrantSearchHit) []LegalSearchResult {
results := make([]LegalSearchResult, len(hits))
for i, hit := range hits {
regCode := getString(hit.Payload, "regulation_code")
if regCode == "" {
regCode = getString(hit.Payload, "regulation_id")
}
article := getString(hit.Payload, "article")
if article == "" {
article = getString(hit.Payload, "section")
}
results[i] = LegalSearchResult{
Text: getString(hit.Payload, "chunk_text"),
RegulationCode: regCode,
RegulationName: getString(hit.Payload, "regulation_name_de"),
RegulationShort: getString(hit.Payload, "regulation_short"),
Category: getString(hit.Payload, "category"),
ArticleLabel: getString(hit.Payload, "article_label"),
Article: article,
Paragraph: getString(hit.Payload, "paragraph"),
Sub: getString(hit.Payload, "sub"),
IsRecital: getBool(hit.Payload, "is_recital"),
CitationStyle: getString(hit.Payload, "citation_style"),
Pages: getIntSlice(hit.Payload, "pages"),
SourceURL: getString(hit.Payload, "source"),
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",
}
}
return results
}
@@ -0,0 +1,92 @@
package ucca
import (
"context"
"fmt"
"os"
"strings"
"testing"
)
// TestDetectRegulations is a pure unit test of the multi-regulation TRIGGER (no Qdrant):
// only an explicit naming of >=2 regulations enables multi-regulation retrieval. A single
// named regulation, or a topical question that doesn't name one, stays single-domain.
func TestDetectRegulations(t *testing.T) {
cases := []struct {
q string
want int
}{
{"Welche neun Kriterien nennt WP248 fuer ein voraussichtlich hohes Risiko?", 0},
{"Welche Anforderungen gelten fuer wesentliche Veraenderungen einer Maschine?", 0}, // "Maschine" != MaschVO
{"Benoetigt eine SPS ohne Netzwerkanschluss eine CRA-Bewertung?", 1}, // 1 -> single
{"Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?", 2},
{"Wie greifen DSGVO und TDDDG bei der Nutzung von Cookies ineinander?", 2},
{"Wie verhalten sich DORA und NIS2 fuer ein Finanzunternehmen?", 2},
{"Wie greifen AI Act und DSGVO bei einem KI-System ineinander?", 2},
}
for _, c := range cases {
if got := len(detectRegulations(c.q)); got != c.want {
t.Errorf("detectRegulations(%q) = %d, want %d", c.q, got, c.want)
}
}
}
// TestMultiRegE2E (RUN_E2E=1) verifies against the build collection that an explicit
// cross-regulation query returns BOTH named domains in the top-K — the core acceptance
// gate for multi-regulation retrieval.
func TestMultiRegE2E(t *testing.T) {
if os.Getenv("RUN_E2E") != "1" {
t.Skip("set RUN_E2E=1 + QDRANT_URL/OLLAMA_URL")
}
c := NewLegalRAGClient()
coll := os.Getenv("E2E_COLLECTION")
if coll == "" {
coll = "bp_compliance_kb_2026_1_build"
}
cases := []struct {
id string
q string
want []string
}{
{"GQ-0070 CRA+MaschVO", "Wie greifen CRA und Maschinenverordnung bei einer vernetzten Maschine ineinander?", []string{"CRA", "MASCH"}},
{"DSGVO+TDDDG", "Wie greifen DSGVO und TDDDG bei der Nutzung von Cookies und Tracking-Technologien ineinander?", []string{"DSGVO", "TDDDG"}},
{"CRA+NIS2", "Wie verhalten sich CRA und NIS2 bei einem vernetzten Produkt eines wichtigen Unternehmens zueinander?", []string{"CRA", "NIS2"}},
{"DORA+NIS2", "Wie greifen DORA und NIS2 bei einem Finanzunternehmen ineinander?", []string{"DORA", "NIS2"}},
{"AI Act+DSGVO", "Wie greifen AI Act und DSGVO bei einem KI-System ineinander, das personenbezogene Daten verarbeitet?", []string{"AI ACT", "DSGVO"}},
}
for _, tc := range cases {
res, err := c.SearchCollection(context.Background(), coll, tc.q, nil, 8)
if err != nil {
t.Fatalf("%s: %v", tc.id, err)
}
present := map[string]bool{}
for _, r := range res {
present[strings.ToUpper(r.RegulationCode)] = true
}
ok := true
for _, w := range tc.want {
found := false
for cd := range present {
if strings.Contains(cd, w) {
found = true
break
}
}
if !found {
ok = false
}
}
codes := make([]string, 0, len(present))
for cd := range present {
codes = append(codes, cd)
}
status := "OK"
if !ok {
status = "FAIL"
}
fmt.Printf("%-22s want=%v present=%v %s\n", tc.id, tc.want, codes, status)
if !ok {
t.Errorf("%s: not all named regulations in top-8 (want %v, got %v)", tc.id, tc.want, codes)
}
}
}
@@ -162,7 +162,7 @@ async def update_ai_system(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Update an AI system.""" """Update an AI system."""
from datetime import datetime from datetime import datetime, timezone
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first() system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
if not system: if not system:
@@ -226,7 +226,7 @@ async def assess_ai_system(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Run AI Act risk assessment for an AI system.""" """Run AI Act risk assessment for an AI system."""
from datetime import datetime from datetime import datetime, timezone
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first() system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
if not system: if not system:
@@ -47,6 +47,8 @@ from compliance.services.canonical_control_service import (
_control_row, # re-exported for legacy test imports _control_row, # re-exported for legacy test imports
) )
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/canonical", tags=["canonical-controls"]) router = APIRouter(prefix="/v1/canonical", tags=["canonical-controls"])
@@ -14,7 +14,7 @@ Endpoints:
""" """
import logging import logging
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta, timezone
from calendar import month_abbr from calendar import month_abbr
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from decimal import Decimal from decimal import Decimal
@@ -26,10 +26,11 @@ versions). Module-level helpers re-exported for legacy tests.
import logging import logging
from typing import Any, List, Optional from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text
from classroom_engine.database import get_db from classroom_engine.database import get_db
from compliance.api._http_errors import translate_domain_errors from compliance.api._http_errors import translate_domain_errors
@@ -484,6 +485,7 @@ async def list_dsfas(
async def create_dsfa( async def create_dsfa(
request: DSFACreate, request: DSFACreate,
tenant_id: Optional[str] = Query(None), tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
service: DSFAService = Depends(get_dsfa_service), service: DSFAService = Depends(get_dsfa_service),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Neue DSFA erstellen.""" """Neue DSFA erstellen."""
@@ -16,6 +16,11 @@ from the legacy path.
""" """
import logging import logging
import os
import json
import hashlib
import uuid as uuid_module
from datetime import datetime, timedelta
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
@@ -30,14 +35,15 @@ from ..db import (
EvidenceConfidenceEnum, EvidenceConfidenceEnum,
EvidenceTruthStatusEnum, EvidenceTruthStatusEnum,
) )
from ..db.models import EvidenceDB, ControlDB, AuditTrailDB from ..db.models import EvidenceDB, AuditTrailDB
from ..services.auto_risk_updater import AutoRiskUpdater from ..services.auto_risk_updater import AutoRiskUpdater
from ..services.evidence_service import EvidenceService from ..services.evidence_service import EvidenceService, _update_risks as _update_risks_impl
from .schemas import ( from .schemas import (
EvidenceCreate, EvidenceResponse, EvidenceListResponse, EvidenceCreate, EvidenceResponse, EvidenceListResponse,
EvidenceRejectRequest, EvidenceRejectRequest,
) )
from .audit_trail_utils import log_audit_trail from .audit_trail_utils import log_audit_trail
from ._http_errors import translate_domain_errors
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["compliance-evidence"]) router = APIRouter(tags=["compliance-evidence"])
@@ -146,6 +152,7 @@ async def list_evidence(
status: Optional[str] = None, status: Optional[str] = None,
page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"), page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"),
limit: Optional[int] = Query(None, ge=1, le=500, description="Items per page"), limit: Optional[int] = Query(None, ge=1, le=500, description="Items per page"),
db: Session = Depends(get_db),
service: EvidenceService = Depends(get_evidence_service), service: EvidenceService = Depends(get_evidence_service),
) -> EvidenceListResponse: ) -> EvidenceListResponse:
"""List evidence with optional filters and pagination.""" """List evidence with optional filters and pagination."""
@@ -186,9 +193,11 @@ async def list_evidence(
@router.post("/evidence", response_model=EvidenceResponse) @router.post("/evidence", response_model=EvidenceResponse)
async def create_evidence( async def create_evidence(
evidence_data: EvidenceCreate, evidence_data: EvidenceCreate,
db: Session = Depends(get_db),
service: EvidenceService = Depends(get_evidence_service), service: EvidenceService = Depends(get_evidence_service),
) -> EvidenceResponse: ) -> EvidenceResponse:
"""Create new evidence record.""" """Create new evidence record."""
dsms_cid = None
repo = EvidenceRepository(db) repo = EvidenceRepository(db)
# Get control UUID # Get control UUID
@@ -257,6 +266,7 @@ async def create_evidence(
@router.delete("/evidence/{evidence_id}") @router.delete("/evidence/{evidence_id}")
async def delete_evidence( async def delete_evidence(
evidence_id: str, evidence_id: str,
db: Session = Depends(get_db),
service: EvidenceService = Depends(get_evidence_service), service: EvidenceService = Depends(get_evidence_service),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Delete an evidence record.""" """Delete an evidence record."""
@@ -275,6 +285,7 @@ async def upload_evidence(
title: str = Query(...), title: str = Query(...),
file: UploadFile = File(...), file: UploadFile = File(...),
description: Optional[str] = Query(None), description: Optional[str] = Query(None),
db: Session = Depends(get_db),
service: EvidenceService = Depends(get_evidence_service), service: EvidenceService = Depends(get_evidence_service),
) -> EvidenceResponse: ) -> EvidenceResponse:
"""Upload evidence file.""" """Upload evidence file."""
@@ -674,6 +685,7 @@ async def collect_ci_evidence(
async def get_ci_evidence_status( async def get_ci_evidence_status(
control_id: Optional[str] = Query(None, description="Filter by control ID"), control_id: Optional[str] = Query(None, description="Filter by control ID"),
days: int = Query(30, description="Look back N days"), days: int = Query(30, description="Look back N days"),
db: Session = Depends(get_db),
service: EvidenceService = Depends(get_evidence_service), service: EvidenceService = Depends(get_evidence_service),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Get CI/CD evidence collection status overview.""" """Get CI/CD evidence collection status overview."""
@@ -681,70 +693,8 @@ async def get_ci_evidence_status(
return service.ci_status(control_id, days) return service.ci_status(control_id, days)
# ---------------------------------------------------------------------------- # (Alte CI-Status-Implementierung entfernt — unerreichbarer Code nach `return
# Legacy re-exports for tests that import helpers directly. # service.ci_status(...)`; durch den Service ersetzt, `query` war nie initialisiert.)
# ----------------------------------------------------------------------------
if control_id:
ctrl_repo = ControlRepository(db)
control = ctrl_repo.get_by_control_id(control_id)
if control:
query = query.filter(EvidenceDB.control_id == control.id)
evidence_list = query.order_by(EvidenceDB.collected_at.desc()).limit(100).all()
# Group by control and calculate stats
control_stats = defaultdict(lambda: {
"total": 0,
"valid": 0,
"failed": 0,
"last_collected": None,
"evidence": [],
})
for e in evidence_list:
# Get control_id string
control = db.query(ControlDB).filter(ControlDB.id == e.control_id).first()
ctrl_id = control.control_id if control else "unknown"
stats = control_stats[ctrl_id]
stats["total"] += 1
if e.status:
if e.status.value == "valid":
stats["valid"] += 1
elif e.status.value == "failed":
stats["failed"] += 1
if not stats["last_collected"] or e.collected_at > stats["last_collected"]:
stats["last_collected"] = e.collected_at
# Add evidence summary
stats["evidence"].append({
"id": e.id,
"type": e.evidence_type,
"status": e.status.value if e.status else None,
"collected_at": e.collected_at.isoformat() if e.collected_at else None,
"ci_job_id": e.ci_job_id,
})
# Convert to list and sort
result = []
for ctrl_id, stats in control_stats.items():
result.append({
"control_id": ctrl_id,
"total_evidence": stats["total"],
"valid_count": stats["valid"],
"failed_count": stats["failed"],
"last_collected": stats["last_collected"].isoformat() if stats["last_collected"] else None,
"recent_evidence": stats["evidence"][:5],
})
result.sort(key=lambda x: x["last_collected"] or "", reverse=True)
return {
"period_days": days,
"total_evidence": len(evidence_list),
"controls": result,
}
# ============================================================================ # ============================================================================
@@ -772,6 +722,7 @@ async def review_evidence(
approval_status='first_approved'. A second (different) reviewer then approval_status='first_approved'. A second (different) reviewer then
sets second_reviewer and approval_status='approved'. sets second_reviewer and approval_status='approved'.
""" """
dsms_cid = None
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first() evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
if not evidence: if not evidence:
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found") raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
@@ -851,6 +802,7 @@ async def reject_evidence(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""Reject evidence (sets approval_status='rejected').""" """Reject evidence (sets approval_status='rejected')."""
dsms_cid = None
evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first() evidence = db.query(EvidenceDB).filter(EvidenceDB.id == evidence_id).first()
if not evidence: if not evidence:
raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found") raise HTTPException(status_code=404, detail=f"Evidence {evidence_id} not found")
@@ -8,7 +8,7 @@ This adds NO new reasoning logic. It exposes the already-built, tested orchestra
""" """
import logging import logging
from typing import List, Optional from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -20,7 +20,7 @@ from compliance.onboarding import (
ProducedSignal, ProducedSignal,
RejectedAssumption, RejectedAssumption,
) )
from compliance.services.onboarding_service import run_advisor, supported_targets from compliance.services.onboarding_service import labels_for, run_advisor, supported_targets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/onboarding", tags=["onboarding"]) router = APIRouter(prefix="/onboarding", tags=["onboarding"])
@@ -50,6 +50,7 @@ class AdvisorResponse(BaseModel):
evidence_requests: List[str] = Field(default_factory=list) evidence_requests: List[str] = Field(default_factory=list)
unsupported_domains: List[str] = Field(default_factory=list) unsupported_domains: List[str] = Field(default_factory=list)
completeness_summary: str = "" completeness_summary: str = ""
capability_labels: Dict[str, str] = Field(default_factory=dict) # capability_id -> human label (DE)
@router.get("/targets") @router.get("/targets")
@@ -65,10 +66,17 @@ def advisor_start_endpoint(req: OnboardingAdvisorRequest) -> AdvisorResponse:
company=req.company, certifications=req.certifications, target=req.target, company=req.company, certifications=req.certifications, target=req.target,
signals=req.scanner_findings, known_evidence=req.known_evidence, signals=req.scanner_findings, known_evidence=req.known_evidence,
products=req.products, markets=req.markets, industry=req.industry or "") products=req.products, markets=req.markets, industry=req.industry or "")
surfaced = [
*result.auto_detected, *result.indications, *result.capability_delta,
*(q.capability_id for q in result.next_best_questions),
*(c for a in result.inferred_assumptions for c in a.capabilities),
*(m.capability_id for m in result.top_measures),
]
return AdvisorResponse( return AdvisorResponse(
silent_intake_summary=si_summary, headline=result.headline, auto_detected=result.auto_detected, silent_intake_summary=si_summary, headline=result.headline, auto_detected=result.auto_detected,
indications=result.indications, indications=result.indications,
inferred_assumptions=result.inferred_assumptions, rejected_assumptions=result.rejected_assumptions, inferred_assumptions=result.inferred_assumptions, rejected_assumptions=result.rejected_assumptions,
top_5_questions=result.next_best_questions, capability_delta=result.capability_delta, top_5_questions=result.next_best_questions, capability_delta=result.capability_delta,
top_measures=result.top_measures, evidence_requests=result.evidence_requests, top_measures=result.top_measures, evidence_requests=result.evidence_requests,
unsupported_domains=result.unsupported_domains, completeness_summary=result.completeness_summary) unsupported_domains=result.unsupported_domains, completeness_summary=result.completeness_summary,
capability_labels=labels_for(surfaced))
@@ -24,6 +24,7 @@ from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from classroom_engine.database import get_db from classroom_engine.database import get_db
from ..db.models import EvidenceDB
from .audit_trail_utils import log_audit_trail from .audit_trail_utils import log_audit_trail
from ..db import ( from ..db import (
@@ -310,6 +311,7 @@ async def list_controls_paginated(
) )
async def get_control( async def get_control(
control_id: str, control_id: str,
db: Session = Depends(get_db),
svc: ControlExportService = Depends(get_ctrl_export_service), svc: ControlExportService = Depends(get_ctrl_export_service),
) -> ControlResponse: ) -> ControlResponse:
"""Get a specific control by control_id.""" """Get a specific control by control_id."""
@@ -354,6 +356,7 @@ async def get_control(
async def update_control( async def update_control(
control_id: str, control_id: str,
update: ControlUpdate, update: ControlUpdate,
db: Session = Depends(get_db),
svc: ControlExportService = Depends(get_ctrl_export_service), svc: ControlExportService = Depends(get_ctrl_export_service),
) -> ControlResponse: ) -> ControlResponse:
"""Update a control.""" """Update a control."""
@@ -443,6 +446,7 @@ async def update_control(
async def review_control( async def review_control(
control_id: str, control_id: str,
review: ControlReviewRequest, review: ControlReviewRequest,
db: Session = Depends(get_db),
svc: ControlExportService = Depends(get_ctrl_export_service), svc: ControlExportService = Depends(get_ctrl_export_service),
) -> ControlResponse: ) -> ControlResponse:
"""Mark a control as reviewed with new status.""" """Mark a control as reviewed with new status."""
@@ -21,7 +21,7 @@ Phase 1 Step 4 refactor: handlers delegate to VVTService.
import logging import logging
from typing import Any, List, Optional from typing import Any, List, Optional
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -21,6 +21,14 @@ from .observations import (
empirical_distribution, empirical_distribution,
reviewed, reviewed,
) )
from .observation_log import (
HypothesisStats,
ObservationRecord,
aggregate_by_hypothesis,
append_observation,
load_observations,
review_queue,
)
from .signals import ( from .signals import (
ProducedSignal, ProducedSignal,
SignalVocabularyEntry, SignalVocabularyEntry,
@@ -69,4 +77,10 @@ __all__ = [
"ProducedSignal", "ProducedSignal",
"SignalVocabularyEntry", "SignalVocabularyEntry",
"normalize_signals", "normalize_signals",
"ObservationRecord",
"HypothesisStats",
"append_observation",
"load_observations",
"aggregate_by_hypothesis",
"review_queue",
] ]
@@ -143,8 +143,8 @@ def advisor_start(
next_best_questions=next_q, capability_delta=delta, top_measures=measures, next_best_questions=next_q, capability_delta=delta, top_measures=measures,
evidence_requests=evidence, unsupported_domains=unsupported, evidence_requests=evidence, unsupported_domains=unsupported,
completeness_summary=rep.completeness_summary, completeness_summary=rep.completeness_summary,
headline="%d Anforderungen erkannt · %d automatisch erkannt (Intake) · %d wahrscheinlich (Zertifikate) · %d zu klären" headline="%d von %d Anforderungen offen · %d automatisch erkannt (Intake) · %d wahrscheinlich (Zertifikate) · %d zu klären"
% (len(assess.coverage), len(auto_detected), len(probably), len(next_q))) % (len(delta), len(assess.coverage), len(auto_detected), len(probably), len(next_q)))
def apply_answer(known_capabilities: Sequence[str], capability_id: str, answer: str) -> List[str]: def apply_answer(known_capabilities: Sequence[str], capability_id: str, answer: str) -> List[str]:
@@ -0,0 +1,108 @@
"""Observation Log — append-only JSONL store for empirical calibration events (Task 59b v1).
Observations are NOT business data and NOT product-DB data they are CALIBRATION events for the
knowledge base ("ISO27001 -> SDL confirmed", "TISAX -> supplier security refuted"). So they live with the
other versioned knowledge artifacts (hypotheses, transition patterns, vocabulary), NOT in the product
database: an append-only JSONL log under `knowledge/observations/`. NO migration, NO DB. The empirical
DISTRIBUTION and CONFIDENCE are COMPUTED from this log on demand (computed-not-stored) a hypothesis is
NEVER auto-updated; only REVIEWED observations calibrate (the review gate, enforced in observations.py).
Append-only: each line is one ObservationRecord and lines are NEVER modified in place. A later review is
a NEW line with the same observation_id and reviewed=true; load_observations() reconciles to the latest
per id. You can `rm` the log and recompute, `git diff` it over months, or rebuild confidence under a new
policy. Anonymisation is MANDATORY: customer_archetype is a sector/cert archetype, NEVER a real company
name (this file is committed to git). Time is stamped by the CALLER (no hidden clock) for determinism.
I/O only at the append/load boundary; statistics are pure. Python 3.9 compatible.
"""
from __future__ import annotations
import json
import os
from typing import Dict, List, Optional, Sequence
from pydantic import BaseModel, Field
from .observations import Observation, empirical_confidence, empirical_distribution
_DEFAULT_LOG = os.path.join(
os.path.dirname(__file__), "..", "..", "knowledge", "observations", "observations.jsonl")
class ObservationRecord(Observation):
"""A persisted observation line: an Observation (with its review gate + observation_type) plus log
metadata. `observation_id` is stable a review re-appends the SAME id with reviewed=true."""
observation_id: str # stable id; a review re-appends the same id
timestamp: str = "" # ISO 8601, stamped by the CALLER (no hidden clock)
customer_archetype: str = "" # sector/cert archetype — NEVER a real company name
evidence: str = "" # what backs the answer (reference, not the artifact)
provenance: str = "" # where the answer came from (audit trail)
knowledge_version: str = "" # hypotheses/vocabulary version observed under
class HypothesisStats(BaseModel):
"""Per-hypothesis empirical rollup — all COMPUTED from the log, nothing stored on the hypothesis."""
hypothesis_id: str
distribution: Dict[str, int] = Field(default_factory=dict) # reviewed counts per observation_type
confidence: Optional[float] = None # None until a for/against obs is reviewed
reviewed_count: int = 0
total_count: int = 0
def append_observation(record: ObservationRecord, path: str = _DEFAULT_LOG) -> None:
"""Append ONE record as a JSON line. Append-only — existing lines are never rewritten."""
os.makedirs(os.path.dirname(path), exist_ok=True)
line = json.dumps(record.model_dump(mode="json"), ensure_ascii=False, sort_keys=True)
with open(path, "a", encoding="utf-8") as fh:
fh.write(line + "\n")
def load_observations(path: str = _DEFAULT_LOG, reconcile: bool = True) -> List[ObservationRecord]:
"""Read all records — a single `.jsonl` file or a directory of monthly `.jsonl` files. With
reconcile, the LATEST record per observation_id wins (a later reviewed=true supersedes the original).
Returns deterministic order (by observation_id when reconciled, else append order)."""
files: List[str] = []
if os.path.isdir(path):
files = sorted(os.path.join(path, f) for f in os.listdir(path) if f.endswith(".jsonl"))
elif os.path.exists(path):
files = [path]
records: List[ObservationRecord] = []
for fpath in files:
with open(fpath, encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if raw:
records.append(ObservationRecord(**json.loads(raw)))
if not reconcile:
return records
latest: Dict[str, ObservationRecord] = {}
for r in records: # file/append order -> later lines win
latest[r.observation_id] = r
return [latest[k] for k in sorted(latest)]
def aggregate_by_hypothesis(records: Sequence[ObservationRecord]) -> List[HypothesisStats]:
"""Per-hypothesis distribution + confidence. The review gate applies inside empirical_distribution/
empirical_confidence (reviewed-only), so unreviewed observations are counted in total but never
calibrate. Deterministic order (by hypothesis id)."""
by_hyp: Dict[str, List[ObservationRecord]] = {}
for r in records:
by_hyp.setdefault(r.hypothesis_id, []).append(r)
out: List[HypothesisStats] = []
for hyp in sorted(by_hyp):
obs = by_hyp[hyp]
out.append(HypothesisStats(
hypothesis_id=hyp,
distribution=empirical_distribution(obs), # reviewed-only (the gate)
confidence=empirical_confidence(obs), # None until reviewed for/against
reviewed_count=sum(1 for o in obs if o.reviewed),
total_count=len(obs)))
return out
def review_queue(records: Sequence[ObservationRecord]) -> List[ObservationRecord]:
"""The reviewer's worklist: observations not yet reviewed. Calibration ignores these until a reviewer
accepts them (Observation -> Review -> Accepted -> Knowledge recomputed), never Observation -> conf++."""
return [r for r in records if not r.reviewed]
@@ -9,7 +9,7 @@ It adds NO new reasoning logic — it only exposes what exists. No DB, no persis
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any, Dict, List, Sequence, Tuple from typing import Any, Dict, Iterable, List, Sequence, Tuple
import yaml import yaml
@@ -37,6 +37,13 @@ def _load(*parts: str) -> Any:
_HYP_LIB = [CapabilityHypothesis(**h) for h in _load("certification_hypotheses", "hypotheses.yaml")["hypotheses"]] _HYP_LIB = [CapabilityHypothesis(**h) for h in _load("certification_hypotheses", "hypotheses.yaml")["hypotheses"]]
_VOCAB = [SignalVocabularyEntry(**v) for v in _load("onboarding", "signal_vocabulary.yaml")["signals"]] _VOCAB = [SignalVocabularyEntry(**v) for v in _load("onboarding", "signal_vocabulary.yaml")["signals"]]
_SIGNAL_MAP = [SignalMapping(**m) for m in _load("onboarding", "intake_signal_map.yaml")["mappings"]] _SIGNAL_MAP = [SignalMapping(**m) for m in _load("onboarding", "intake_signal_map.yaml")["mappings"]]
_LABELS: Dict[str, str] = _load("onboarding", "capability_labels.yaml")["labels"]
def labels_for(capability_ids: Iterable[str]) -> Dict[str, str]:
"""Human labels (DE) for the given capability ids — presentation only. Ids without a curated label
are omitted (the frontend falls back to a prettified id). Deduped, deterministic."""
return {c: _LABELS[c] for c in dict.fromkeys(capability_ids) if c in _LABELS}
# target id -> transition pattern that defines its required capabilities (curated registry) # target id -> transition pattern that defines its required capabilities (curated registry)
_TARGET_PATTERNS = { _TARGET_PATTERNS = {
@@ -53,9 +60,10 @@ def supported_targets() -> List[str]:
def _target(target_id: str) -> Tuple[List[TargetRequirement], Dict[str, List[str]]]: def _target(target_id: str) -> Tuple[List[TargetRequirement], Dict[str, List[str]]]:
pat = _load("transition_patterns", _TARGET_PATTERNS[target_id]) pat = _load("transition_patterns", _TARGET_PATTERNS[target_id])
reqs = [TargetRequirement(capability_id=a["capability"]) for a in pat["likely_covered"]] reqs = [TargetRequirement(capability_id=a["capability"], rationale=a.get("reviewable_claim", "")) for a in pat["likely_covered"]]
reqs += [TargetRequirement(capability_id=d["capability"], question_intent=d.get("needed_information", "verify_existence"), reqs += [TargetRequirement(capability_id=d["capability"], question_intent=d.get("needed_information", "verify_existence"),
expected_evidence=d.get("expected_evidence", [])) for d in pat["delta_requirements"]] rationale=d.get("why_asked", ""), expected_evidence=d.get("expected_evidence", []))
for d in pat["delta_requirements"]]
covers = {d["capability"]: d.get("covers_targets", []) for d in pat["delta_requirements"]} covers = {d["capability"]: d.get("covers_targets", []) for d in pat["delta_requirements"]}
return reqs, covers return reqs, covers
@@ -104,7 +104,8 @@ def assess_transition(
) )
buckets[status].append(req.capability_id) buckets[status].append(req.capability_id)
if status in _REQUESTABLE: if status in _REQUESTABLE:
reason, prio = _REQUESTABLE[status] default_reason, prio = _REQUESTABLE[status]
reason = req.rationale or default_reason # curated human text wins over the generic fallback
requests.append( requests.append(
TransitionQuestionRequest( TransitionQuestionRequest(
capability_id=req.capability_id, capability_id=req.capability_id,
@@ -70,6 +70,7 @@ class TargetRequirement(BaseModel):
capability_id: str # MCAP-... capability_id: str # MCAP-...
question_intent: str = "verify_existence" # passed through to the request, not rendered question_intent: str = "verify_existence" # passed through to the request, not rendered
rationale: str = "" # curated human text (e.g. why_asked / reviewable_claim) — surfaced as the request reason
expected_evidence: List[str] = Field(default_factory=list) expected_evidence: List[str] = Field(default_factory=list)
source_control_id: Optional[str] = None source_control_id: Optional[str] = None
supports_obligations: List[str] = Field(default_factory=list) supports_obligations: List[str] = Field(default_factory=list)
@@ -0,0 +1,2 @@
# Append-only observation log (Task 59b). Real lines (observations.jsonl / YYYY-MM.jsonl) are written at
# runtime via compliance/onboarding/observation_log.py. Anonymised archetypes only — NEVER real company names.
@@ -0,0 +1,45 @@
# Human-readable capability labels (DE) — presentation only, reusable across all targets.
# A capability id is the stable machine identity; this maps it to an expert-facing label for the UI.
# Curated knowledge (draft — to be corrected by the domain expert). Missing ids fall back to a
# prettified id in the frontend. NO real company names. Keep labels short + concrete.
labels:
# ── ISMS / ISO 27001 core ───────────────────────────────────────────────
information_security_management: "Informationssicherheits-Managementsystem (ISMS)"
access_control_and_authentication: "Zugriffskontrolle & Authentifizierung"
asset_and_configuration_management: "Asset- & Konfigurationsverwaltung"
cryptography: "Kryptographie / Verschlüsselung"
incident_management: "Security-Incident-Management"
security_awareness_training: "Security-Awareness-Schulungen"
supplier_security: "Lieferanten-Sicherheit"
security_logging_and_monitoring: "Security-Logging & Monitoring"
technical_vulnerability_management: "Technisches Schwachstellen-Management"
# ── TISAX / VDA-spezifisch ──────────────────────────────────────────────
prototype_protection: "Prototypenschutz (physisch & logisch)"
tisax_label_scope_selection: "TISAX-Label-/Scope-Festlegung"
tisax_assessment_via_enx: "TISAX-Assessment über die ENX-Plattform"
vda_isa_self_assessment: "VDA-ISA-Selbstauskunft"
data_protection_processing_on_behalf: "Auftragsverarbeitung (Art. 28 DSGVO)"
physical_security: "Physische Sicherheit / Zutrittskontrolle"
# ── QM / ISO 9001 ───────────────────────────────────────────────────────
document_and_change_control: "Dokumenten- & Änderungslenkung"
supplier_evaluation: "Lieferantenbewertung"
release_and_approval_process: "Freigabe- & Genehmigungsprozess"
ce_conformity_assessment_and_technical_documentation: "CE-Konformitätsbewertung & technische Dokumentation"
# ── CRA / Produkt-Cybersecurity ─────────────────────────────────────────
sbom_creation: "SBOM-Erstellung (Software-Stückliste)"
coordinated_vulnerability_disclosure: "Coordinated Vulnerability Disclosure (CVD)"
secure_development_lifecycle: "Sicherer Entwicklungslebenszyklus (SDLC)"
secure_signed_update_distribution: "Sichere, signierte Update-Verteilung"
security_update_support_period: "Sicherheits-Update-Supportzeitraum"
product_cyber_risk_assessment: "Produkt-Cyber-Risikobewertung"
exploited_vuln_and_incident_reporting: "Meldung ausgenutzter Schwachstellen & Vorfälle"
public_security_advisories: "Öffentliche Security Advisories"
cybersecurity_management_system: "Cybersecurity-Managementsystem (CSMS)"
# ── MaschinenVO / Safety ────────────────────────────────────────────────
machine_safety_risk_assessment: "Maschinen-Risikobeurteilung"
mechanical_safety_and_guards: "Mechanische Sicherheit & Schutzeinrichtungen"
operating_instructions_and_safety_information: "Betriebsanleitung & Sicherheitshinweise"
protection_against_corruption_of_safety_functions: "Schutz der Sicherheitsfunktionen vor Manipulation"
# ── Umwelt ──────────────────────────────────────────────────────────────
environmental_management_documentation: "Umweltmanagement-Dokumentation"
@@ -0,0 +1,73 @@
"""Observation Log — append-only JSONL store + computed statistics (Task 59b/c v1).
Pins the user's decision (2026-06-28): observations are CALIBRATION data, not product data -> an
append-only JSONL log under knowledge/observations/, NO DB, NO migration. Distribution and confidence are
COMPUTED from the log; only REVIEWED observations calibrate (review gate); a later review is a new line
that supersedes by observation_id. Nothing is ever written back to a hypothesis.
"""
from __future__ import annotations
from compliance.onboarding import (
ObservationRecord,
ObservationType,
aggregate_by_hypothesis,
append_observation,
load_observations,
review_queue,
)
def _rec(oid, hyp, otype, reviewed=False, **kw):
return ObservationRecord(
observation_id=oid, hypothesis_id=hyp, observation_type=otype, reviewed=reviewed,
timestamp="2026-07-01T00:00:00Z", customer_archetype="machine_builder+ISO27001", **kw)
def test_append_only_round_trip(tmp_path):
p = str(tmp_path / "obs.jsonl")
append_observation(_rec("o1", "HYP-secure_dev", ObservationType.CONFIRMED, reviewed=True), p)
append_observation(_rec("o2", "HYP-secure_dev", ObservationType.REFUTED, reviewed=True), p)
recs = load_observations(p)
assert {r.observation_id for r in recs} == {"o1", "o2"}
assert all(r.customer_archetype == "machine_builder+ISO27001" for r in recs) # anonymised archetype, not a name
def test_review_supersedes_by_id_append_only(tmp_path):
p = str(tmp_path / "obs.jsonl")
append_observation(_rec("o1", "HYP-x", ObservationType.CONFIRMED, reviewed=False), p) # raw answer
append_observation(_rec("o1", "HYP-x", ObservationType.CONFIRMED, reviewed=True,
reviewed_by="anna"), p) # later review event
assert len(load_observations(p, reconcile=False)) == 2 # both lines kept (append-only)
recs = load_observations(p) # reconciled
assert len(recs) == 1 and recs[0].reviewed and recs[0].reviewed_by == "anna"
def test_statistics_apply_the_review_gate(tmp_path):
p = str(tmp_path / "obs.jsonl")
append_observation(_rec("a", "HYP-sdl", ObservationType.CONFIRMED, reviewed=True), p)
append_observation(_rec("b", "HYP-sdl", ObservationType.CONFIRMED, reviewed=True), p)
append_observation(_rec("c", "HYP-sdl", ObservationType.REFUTED, reviewed=True), p)
append_observation(_rec("d", "HYP-sdl", ObservationType.CONFIRMED, reviewed=False), p) # unreviewed -> ignored
stats = {s.hypothesis_id: s for s in aggregate_by_hypothesis(load_observations(p))}
s = stats["HYP-sdl"]
assert s.total_count == 4 and s.reviewed_count == 3
assert s.distribution["confirmed"] == 2 and s.distribution["refuted"] == 1 # unreviewed one excluded
assert s.confidence == round(2 / 3, 2) # (2 + 0.5*0) / 3
def test_review_queue_lists_unreviewed(tmp_path):
p = str(tmp_path / "obs.jsonl")
append_observation(_rec("a", "HYP-y", ObservationType.CONFIRMED, reviewed=True), p)
append_observation(_rec("b", "HYP-y", ObservationType.PARTIAL, reviewed=False), p)
q = review_queue(load_observations(p))
assert [r.observation_id for r in q] == ["b"]
def test_load_directory_of_monthly_files(tmp_path):
d = tmp_path / "observations"
d.mkdir()
append_observation(_rec("a", "HYP-z", ObservationType.CONFIRMED, reviewed=True), str(d / "2026-06.jsonl"))
append_observation(_rec("b", "HYP-z", ObservationType.REFUTED, reviewed=True), str(d / "2026-07.jsonl"))
recs = load_observations(str(d))
assert {r.observation_id for r in recs} == {"a", "b"}
@@ -73,6 +73,17 @@ def test_partial_signal_surfaces_as_indication_and_is_still_asked():
assert "secure_development_lifecycle" in asked or "secure_development_lifecycle" in d["capability_delta"] assert "secure_development_lifecycle" in asked or "secure_development_lifecycle" in d["capability_delta"]
def test_questions_carry_curated_text_and_human_labels():
# the curated why_asked from the transition pattern must reach the question (not the generic
# fallback "Keine Anhaltspunkte ... klären"), and surfaced capabilities get human labels.
body = dict(_BODY, certifications=["ISO27001"], target="TISAX", scanner_findings=[])
r = _client.post("/onboarding/advisor-start", json=body)
assert r.status_code == 200, r.text
d = r.json()
assert any("Keine Anhaltspunkte" not in q["why"] for q in d["top_5_questions"]) # real expert text surfaced
assert d["capability_labels"].get("vda_isa_self_assessment") == "VDA-ISA-Selbstauskunft"
def test_unknown_target_is_404(): def test_unknown_target_is_404():
body = dict(_BODY, target="NOPE") body = dict(_BODY, target="NOPE")
r = _client.post("/onboarding/advisor-start", json=body) r = _client.post("/onboarding/advisor-start", json=body)