From 017c9b3c12a768f40714f08888df592972d97775 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 20 Jun 2026 14:34:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(advisor):=20Legal-RAG=20Zitier-Metadaten?= =?UTF-8?q?=20=E2=80=94=20ai-sdk=20+=20Advisor/Drafting=20lesen=20article?= =?UTF-8?q?=5Flabel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ai-sdk (legal_rag_client/scroll/types) liest die gepinnten Spec-Felder article_label/regulation_code/article/paragraph/sub/citation_style/is_recital mit Fallback auf alt-ingestierte Chunks (regulation_id, section); neuer getBool-Helfer. Advisor + Drafting-Engine bilden die Quellenzeile primaer aus article_label ("BDSG § 38 Abs. 1"), sonst aus den strukturierten Feldern. 17 Tests gruen, tsc sauber. Vertrag: docs-src/development/rag_reingest_spec.md (§2/§7). Deploy an den Re-Ingest gekoppelt — neue Felder sind bis dahin leer (graceful Fallback). Co-Authored-By: Claude Opus 4.7 --- .../sdk/agents/__tests__/advisor-rag.test.ts | 14 ++ .../lib/sdk/agents/advisor-rag.ts | 16 ++- .../lib/sdk/drafting-engine/rag-query.ts | 15 +- .../internal/ucca/legal_rag_client.go | 19 ++- .../internal/ucca/legal_rag_scroll.go | 7 + .../internal/ucca/legal_rag_types.go | 4 + docs-src/development/rag_reingest_spec.md | 130 ++++++++++++++++++ 7 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 docs-src/development/rag_reingest_spec.md diff --git a/admin-compliance/lib/sdk/agents/__tests__/advisor-rag.test.ts b/admin-compliance/lib/sdk/agents/__tests__/advisor-rag.test.ts index c9c88943..54fb8fdb 100644 --- a/admin-compliance/lib/sdk/agents/__tests__/advisor-rag.test.ts +++ b/admin-compliance/lib/sdk/agents/__tests__/advisor-rag.test.ts @@ -24,6 +24,20 @@ describe('advisor-rag', () => { expect(out).toEqual([{ content: 'Art. 35 DSGVO ...', source: 'DSGVO', score: 0.91 }]) }) + it('haengt article/paragraph an die Quelle an (Fallback ohne article_label)', () => { + const out = mod.mapSdkResults([ + { text: 'Pflicht zur Benennung ...', regulation_short: 'BDSG', article: '§ 38', paragraph: '(1)', score: 0.8 }, + ]) + expect(out).toEqual([{ content: 'Pflicht zur Benennung ...', source: 'BDSG § 38 (1)', score: 0.8 }]) + }) + + it('nutzt article_label direkt, wenn vorhanden (druckbare Fundstelle)', () => { + const out = mod.mapSdkResults([ + { text: 'x', regulation_short: 'BDSG', article: '38', paragraph: '1', sub: 'Satz 2', article_label: 'BDSG § 38 Abs. 1', score: 0.9 }, + ]) + expect(out[0].source).toBe('BDSG § 38 Abs. 1') + }) + it('faellt auf regulation_name/code zurueck und filtert leere Inhalte', () => { const out = mod.mapSdkResults([ { text: '', regulation_short: 'X' }, diff --git a/admin-compliance/lib/sdk/agents/advisor-rag.ts b/admin-compliance/lib/sdk/agents/advisor-rag.ts index fdf446b9..058dc768 100644 --- a/admin-compliance/lib/sdk/agents/advisor-rag.ts +++ b/admin-compliance/lib/sdk/agents/advisor-rag.ts @@ -31,6 +31,12 @@ interface SdkRagResult { regulation_code?: string regulation_name?: string regulation_short?: string + article_label?: string + article?: string + paragraph?: string + sub?: string + citation_style?: string + is_recital?: boolean category?: string source_url?: string score?: number @@ -47,7 +53,15 @@ export function mapSdkResults(results: SdkRagResult[] | undefined): ScoredPassag return (results || []) .map((r) => ({ content: r.text || '', - source: r.regulation_short || r.regulation_name || r.regulation_code || 'Unbekannt', + // Fundstelle: article_label ist die fertig formatierte, druckbare Quelle aus der + // Ingestion ("BDSG § 38 Abs. 1"); Fallback baut sie aus den strukturierten Feldern + // (bzw. alt-ingestierte Chunks ohne Legal-Metadaten). Siehe rag_reingest_spec.md §2/§7. + source: + (r.article_label && r.article_label.trim()) || + [r.regulation_short || r.regulation_name || r.regulation_code, r.article, r.paragraph, r.sub] + .filter(Boolean) + .join(' ') || + 'Unbekannt', score: typeof r.score === 'number' ? r.score : 0, })) .filter((p) => p.content) diff --git a/admin-compliance/lib/sdk/drafting-engine/rag-query.ts b/admin-compliance/lib/sdk/drafting-engine/rag-query.ts index 56184393..43537ee8 100644 --- a/admin-compliance/lib/sdk/drafting-engine/rag-query.ts +++ b/admin-compliance/lib/sdk/drafting-engine/rag-query.ts @@ -18,6 +18,10 @@ interface SdkRagResult { regulation_code?: string regulation_name?: string regulation_short?: string + article_label?: string + article?: string + paragraph?: string + sub?: string // Rueckwaerts-kompatibel, falls eine Quelle noch das alte rag-service-Format liefert: content?: string source_name?: string @@ -56,12 +60,13 @@ export async function queryRAG(query: string, topK = 3, collection?: string): Pr return results .map((r, i) => { + const base = + r.regulation_short || r.regulation_name || r.regulation_code || r.source_name || r.source_code + // article_label = fertig formatierte Fundstelle aus der Ingestion ("BDSG § 38 Abs. 1"); + // Fallback baut sie aus den strukturierten Feldern. Siehe rag_reingest_spec.md §2/§7. const source = - r.regulation_short || - r.regulation_name || - r.regulation_code || - r.source_name || - r.source_code || + (r.article_label && r.article_label.trim()) || + [base, r.article, r.paragraph, r.sub].filter(Boolean).join(' ') || 'Unbekannt' const content = r.text || r.content || '' return `[Quelle ${i + 1}: ${source}]\n${content}` diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_client.go b/ai-compliance-sdk/internal/ucca/legal_rag_client.go index e7ccd746..9f27ee13 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_client.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_client.go @@ -95,12 +95,29 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, results := make([]LegalSearchResult, len(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: getString(hit.Payload, "regulation_id"), + 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, diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_scroll.go b/ai-compliance-sdk/internal/ucca/legal_rag_scroll.go index 8db10a36..8058149b 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_scroll.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_scroll.go @@ -191,6 +191,13 @@ func (c *LegalRAGClient) ScrollDocumentIndex(ctx context.Context, collection str // Helper functions +func getBool(m map[string]interface{}, key string) bool { + if v, ok := m[key].(bool); ok { + return v + } + return false +} + func getString(m map[string]interface{}, key string) string { if v, ok := m[key]; ok { if s, ok := v.(string); ok { diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_types.go b/ai-compliance-sdk/internal/ucca/legal_rag_types.go index ac8ffff4..abca2fc7 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_types.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_types.go @@ -10,7 +10,11 @@ type LegalSearchResult struct { RegulationShort string `json:"regulation_short"` Category string `json:"category"` Article string `json:"article,omitempty"` + ArticleLabel string `json:"article_label,omitempty"` Paragraph string `json:"paragraph,omitempty"` + Sub string `json:"sub,omitempty"` + IsRecital bool `json:"is_recital,omitempty"` + CitationStyle string `json:"citation_style,omitempty"` Pages []int `json:"pages,omitempty"` SourceURL string `json:"source_url"` Score float64 `json:"score"` diff --git a/docs-src/development/rag_reingest_spec.md b/docs-src/development/rag_reingest_spec.md new file mode 100644 index 00000000..1d76db82 --- /dev/null +++ b/docs-src/development/rag_reingest_spec.md @@ -0,0 +1,130 @@ +# RAG Re-Ingest-Spezifikation — Zitierfähige Chunks (Cross-Session-Vertrag) + +> **Status:** v1 — Vertrag zwischen **core-Session** (Ingestion/Pipeline) und **compliance-Session** (Consumer: ai-sdk/Advisor). +> **Datum:** 2026-06-19 +> **Ziel:** Chunks neu ingestieren mit sauberem Text + vollständigen **Legal-Metadaten**, damit Controls/Findings **zitierfähig** werden ("BDSG § 38 Abs. 1", "Art. 13 Abs. 1 lit. c DSGVO") — **ohne** die teuren Pass 0a/0b/Dedup. + +## 0. Kernentscheidung (Frage 1): Re-Link statt Regenerieren + +Controls hängen **an `control_uuid`, nicht an Chunks** (`canonical_controls` hat keine `chunk_id`/`chunk_hash`-Spalte; ebenso atom_classification, control_classification, doc_check_controls). **Ein Chunk-Re-Ingest bricht keine Control.** Das einzige stale-werdende Artefakt ist das Idempotenz-Ledger `compliance.canonical_processed_chunks` (Key `sha256(text)+collection+document_version`) — relevant nur für *zukünftige* Generierung. + +→ **Pass 0a/0b/Dedup entfallen.** Stattdessen werden Controls per **Textabgleich** an die neuen Chunks **re-gelinkt** (Zitat-Anreicherung). Reichweite: +- **7 %** der 315.914 Controls haben `source_original_text` (Re-Link-Anker) → direkter Abgleich. +- **~93 % Atome** erben das Zitat über `parent_control_uuid`. +- **self-written** brauchen kein Chunk-Zitat (eigene Bibliothek). +- **unmatched Reste** → billiges per-Control-LLM-Zitat (Tier-3), keine Regenerierung. + +## 1. Ingestion-Anforderungen (Frage 2) + +1. **`chunk_strategy="legal"` EXPLIZIT setzen.** (Korrektur zur Historie: `recursive` aliased inzwischen auf `chunk_text_legal`, `embedding-service/main.py:1079/1093`, live-verifiziert — Upload mit `recursive` lieferte `section:"§ 38"`, `paragraph:"(1)"`, `paragraph_num:1`. Trotzdem `legal` explizit, nicht aufs Alias verlassen.) +2. **Deterministische Chunk-ID** (heute random) = `sha1(regulation_code|article|paragraph|chunk_index|document_version)` → stabiler Re-Link + Alt/Neu-Koexistenz. +3. **`chunk_hash` IN die Payload** schreiben (heute nur im PG-Ledger) = `sha256(normalisierter chunk_text)`. +4. **Echte `document_version`** (heute hardcoded `"1.0"`) → Re-Chunk kollidiert sonst im Ledger. +5. **Upload-before-delete** je Collection (alte Chunks erst nach Verify löschen). + +## 2. PAYLOAD-FELD-VERTRAG (verbindlich — Consumer liest GENAU diese Namen) + +Die ai-sdk/Advisor liest die **consumer-facing** Felder. Ingestion füllt alle. + +| Feld | Typ | Consumer | Beschreibung / Beispiel | +|---|---|---|---| +| `article_label` | string | **JA (Anzeige)** | **Fertig formatiert, direkt druckbar.** "BDSG § 38 Abs. 1" · "Art. 13 Abs. 1 lit. c DSGVO". Ingestion formatiert (kennt §- vs Art.-Stil). | +| `regulation_code` | string | **JA** | Kurzcode, UPPERCASE: `BDSG`,`DSGVO`,`TTDSG`,`DDG`,`CRA`,`NIS2` | +| `citation_style` | enum | **JA** | `paragraph` (§-Gesetze) \| `article` (EU-Verordnungen) — steuert §/Art.-Rendering, falls Consumer selbst formatiert | +| `article` | string | **JA** | bare Nummer: `"38"` bzw. `"13"` (egal ob § oder Art.) | +| `paragraph` | string | **JA** | bare Absatz: `"1"` (nicht `"(1)"`) | +| `sub` | string\|null | **JA** | feinste Granularität: `"lit. c"` · `"Satz 2"` · `"Nr. 3"` | +| `is_recital` | bool | **JA** | Erwägungsgrund vs operativer Artikel | +| `regulation_name` | string | optional | Volltext: "Bundesdatenschutzgesetz" | +| `page` | int\|null | optional | Seite (PDF-Quellen) | +| `chunk_text` | string | **JA** | sauberer Text (keine Soft-Hyphens/OCR-Reste) | +| `section_header` | string | optional | Kontext-Überschrift, **separat** (nicht inline im chunk_text) | +| `chunk_id` | string | — | deterministisch (s. §1.2) | +| `chunk_hash` | string | — | sha256(normalisierter Text) | +| `document_id`, `document_version`, `chunk_index` | — | — | Identität/Versionierung | +| `doc_type`, `use_case[]`, `source_type`, `license`, `bundesland`, `year` | — | Scope | Routing/Scope (source_type: gesetz/leitlinie/urteil) | + +**Verbindlich:** `article_label` ist der bevorzugte Anzeige-Pfad (Ingestion ownt die Zitat-Formatierung, weil sie die Regulierung kennt). Die strukturierten Teile (`regulation_code`/`article`/`paragraph`/`sub`) sind zusätzlich für Filtern/Gruppieren da. + +## 3. Normalisierung alt → neu (core-seitiger Transform) + +Der legal-Chunker emittiert heute uneinheitlich; Ingestion normalisiert: + +| heute (raw payload) | → neu | +|---|---| +| `section` = `"§ 38"` / `"Artikel 13"` | `citation_style` (§→`paragraph`, Art/Artikel→`article`) + `article`=`"38"`/`"13"` | +| `section_title` | `section_header` | +| `paragraph`=`"(1)"` / `paragraph_num`=1 | `paragraph`=`"1"` | +| (lit./Satz/Nr. aus `_PARAGRAPH_RE`) | `sub` (best-effort) | +| `regulation_id`/`regulation_short` (extra_metadata) | `regulation_code` (UPPERCASE) | +| — | `article_label` (formatiert aus regulation_code+style+article+paragraph+sub) | + +`article_label`-Formatierung: +- `paragraph`-Stil: `"{regulation_code} § {article} Abs. {paragraph}"` (+ `" {sub}"`) +- `article`-Stil: `"Art. {article} Abs. {paragraph} {sub} {regulation_code}"` + +## 4. Control-Re-Link (core) + +Erweiterung von `control-pipeline/services/citation_backfill.py` (heute 3-Tier, Tier-1 = `sha256(source_original_text)` → Chunk-Hash-Index): Tier-1 bricht beim Re-Chunk (neuer Text → neuer Hash) → **Fuzzy/Embedding-Alignment** ergänzen (`source_original_text` ↔ neue Chunk-Texte, Substring + Cosine). Präzedenz: PDF-QA-Matcher (~52 % Trefferquote). Füllt `canonical_controls.source_citation = {regulation_code, article, paragraph, sub, page, source_type, license, url}`. Atome erben über `parent_control_uuid`. `doc_check_controls` re-derivieren danach automatisch zitierfähig (`derive_doc_check_controls.py` liest `source_citation->>'article'/'source'`). + +**Beim künftigen Generieren IMMER `source_original_text` setzen** (warum heute nur 7 % re-linkbar sind). + +## 5. Pipeline-Reihenfolge + +1. Re-Ingest je Collection: `strategy="legal"`, deterministische IDs, neue `document_version`, normalisierte Payload + `chunk_hash`. +2. Verify: Zähler alt/neu + Stichprobe `article`/`paragraph`/`article_label` befüllt. +3. Re-Link (`citation_backfill` erweitert) → `source_citation`; Atome erben. +4. Reste → Tier-3-LLM-Zitat. +5. Alte Chunks löschen. +6. *(optional, nur für künftige Generierung)* Ledger `canonical_processed_chunks` neu aufbauen. + +## 6. Arbeitsteilung + Akzeptanz + +**core-Session:** Ingest-Spec umsetzen, `citation_backfill` Hash→Fuzzy→Embedding, Payload-Normalisierung (§3), deterministische IDs/`chunk_hash`/`document_version`, AGG-Lücke. + +**compliance-Session (Consumer):** ai-sdk `legal_rag_client.go` + Advisor/Drafting auf die **§2-Feldnamen** ummappen (kein Deploy vor Pin — jetzt gepinnt), Prod-Qdrant-Verify, **6-Fragen-Re-Test** (sind §38 BDSG / AGG / CRA Art. 14 grounded zitiert?). + +**Akzeptanzkriterium:** Advisor rendert für eine Beispielfrage eine echte Fundstelle aus `article_label` (z. B. "BDSG § 38 Abs. 1"), nicht nur "Quelle: BDSG". + +## 7. Consumer-Detailabschnitt (compliance-Session — implementiert 2026-06-19) + +Status: Code steht + getestet (17 Tests grün, tsc sauber). **Deploy gekoppelt an den Re-Ingest** (liest bis dahin leere Felder → graceful, Advisor zeigt wie heute "Quelle: BDSG"). + +### 7.1 ai-sdk: Payload → Response (`internal/ucca/legal_rag_client.go` `searchInternal`, Struct `legal_rag_types.go`) + +| Response-Feld (JSON) | gelesen aus Payload | Fallback (alt-Korpus) | +|---|---|---| +| `article_label` | `article_label` | — (leer → Consumer baut selbst) | +| `regulation_code` | `regulation_code` | → `regulation_id` | +| `article` | `article` | → `section` ("§ 38") | +| `paragraph` | `paragraph` | — | +| `sub` | `sub` | — | +| `citation_style` | `citation_style` | — | +| `is_recital` | `is_recital` (bool) | — | +| `text` | `chunk_text` | — | +| `regulation_name` | `regulation_name_de` | — | +| `regulation_short` | `regulation_short` | — | +| `category`,`pages`,`source_url`,`score` | `category`,`pages`,`source`,(score) | — | + +→ `/sdk/v1/rag/search` liefert diese Felder snake_case. Neuer Bool-Helfer `getBool` ergänzt. + +### 7.2 Advisor + Drafting: Fundstellen-Format + +Beide Konsumenten (`admin-compliance/lib/sdk/agents/advisor-rag.ts`, `.../drafting-engine/rag-query.ts`) bilden die Quellenzeile so: + +``` +source = article_label?.trim() // bevorzugt: druckbar aus Ingestion + || [regulation_short|regulation_name|regulation_code, article, paragraph, sub] + .filter(Boolean).join(' ') // Fallback: strukturiert zusammensetzen + || 'Unbekannt' +Ausgabe je Treffer: [Quelle N: {source}]\n{text} +``` + +→ Der Advisor **druckt `article_label` direkt** (kein §-vs-Art-Ableiten); `citation_style` nur nötig, falls wir später selbst formatieren. Erfüllt das Akzeptanzkriterium (§6): "BDSG § 38 Abs. 1" statt nur "Quelle: BDSG". + +### 7.3 Deploy-Kopplung +Code ist additiv/safe (neue Felder leer bis Re-Ingest). **Kein Solo-Deploy** — geht mit dem Re-Ingest-Go-Live live, danach **6-Fragen-Re-Test** auf prod (§38/AGG/CRA Art. 14 grounded zitiert?). + +## 8. Offen (core) +- **AGG-Lücke**: Quelle (§ 15 Abs. 4 — Bewerberdaten-Frist) zu spezifizieren + ingestieren. +- Soft-Hyphen/OCR-Normalisierung des `chunk_text` — Regelsatz definieren.