Budowanie prawnego asystenta AI: RAG, poręcz i profesjonalny interfejs
Od początku 2025 r. udokumentowano 518 przypadków treści generowanych przez sztuczną inteligencję z halucynacjami zostały przedstawione w postępowaniu sądowym w USA. Z tego samego okresu niezależne oceny pokazują, że Westlaw AI i LexisNexis Lexis+ — dwa najpopularniejsze systemy w świecie prawa — dostarczają trafnych odpowiedzi jedynie w 65-83% przypadków w związku z konkretnymi zapytaniami prawnymi. Problemem nie jest sztuczna inteligencja: problem polega na tym, jak jest używany i jak jest zbudowany.
W tym artykule budujemy Asystent prawny AI (drugi pilot prawny) profesjonalista, który bezpośrednio zajmuje się problemem halucynacji: RAG na ciele zastrzeżona, legalna, wielopoziomowa poręcz do wyłapywania nieobsługiwanych odpowiedzi ze źródeł, weryfikowalnych cytatów i zoptymalizowanego pod kątem przepływu interfejsu Angular pracy prawników.
Czego się nauczysz
- Architektura generowania rozszerzonego wyszukiwania (RAG) dla domeny prawnej
- Budowa korpusu prawnego: przepisy, wyroki, doktryna
- Wielopoziomowa poręcz: uziemienie cytatów, punktacja pewności, logika odmowy
- Szybka inżynieria zapewniająca dokładne i niewprowadzające w błąd odpowiedzi prawne
- Angularowy, przyjazny dla prawników interfejs ze strumieniowym przesyłaniem odpowiedzi
- Ramy oceny do pomiaru jakości systemu
Architektura RAG dla domeny prawnej
Różnica między zwykłym chatbotem a profesjonalnym prawniczym drugim pilotem polega na tym, że jest to możliwe w architekturze RAG: każda odpowiedź musi być w oparciu o konkretne dokumenty odzyskane z korpusu prawnego, a nie wygenerowane przez pamięć parametryczną modelu. Jest to podstawowy mechanizm zmniejszający halucynacje wynikające z problemu systemowe, przy możliwym do kontrolowania ryzyku.
from dataclasses import dataclass, field
from typing import List, Optional
from datetime import datetime
@dataclass
class LegalSource:
"""Documento sorgente recuperato per fondare la risposta."""
doc_id: str
doc_type: str # "sentenza", "legge", "regolamento", "dottrina"
title: str
citation: str # citazione formale (es. "Cass. civ. n. 12345/2024")
content_chunk: str # estratto rilevante
relevance_score: float # score di rilevanza [0, 1]
source_url: Optional[str] = None
@dataclass
class LegalQueryResult:
"""
Risultato strutturato di una query al Legal Copilot.
Ogni affermazione deve essere tracciabile a una fonte specifica.
"""
query: str
answer: str
sources: List[LegalSource]
confidence: float # confidence score complessivo [0, 1]
grounding_ratio: float # % di affermazioni supportate da fonti
uncertainty_disclaimer: str # disclaimer quando la confidenza e bassa
generated_at: datetime
model_version: str
warnings: List[str] = field(default_factory=list)
Budowa Korpusu Prawnego
Jakość korpusu jest najważniejszym czynnikiem dla prawnego drugiego pilota. Korpus prawny dobrze zorganizowany język włoski musi obejmować:
- Prawodawstwo pierwotne: Kodeks cywilny, kodeks karny, kodeks procesowy Prawa cywilne, szczególne - w aktualnej (jednolitej) wersji
- Jurysprudencja: orzeczenia Sądu Najwyższego, Trybunału Konstytucyjnego, TRA i Rada Stanu, Trybunał Sprawiedliwości UE (EKPC)
- Regulamin i okólniki: Bank Włoch, Consob, gwarant prywatności, AGCM, INPS
- Zaktualizowana doktryna: artykuły z autorytatywnych czasopism prawniczych
import asyncio
import aiohttp
from bs4 import BeautifulSoup
from dataclasses import dataclass
from typing import List, AsyncIterator
import re
@dataclass
class RawLegalDocument:
source_id: str
doc_type: str
raw_text: str
metadata: dict
class LegalCorpusBuilder:
"""
Builder per il corpus giuridico.
Scarica e normalizza documenti da fonti ufficiali.
"""
# Normativa italiana via Normattiva (fonte ufficiale)
NORMATTIVA_API = "https://www.normattiva.it/uri-res/N2Ls"
async def fetch_normativa(self, uri: str) -> Optional[RawLegalDocument]:
"""
Scarica una norma da Normattiva (il portale ufficiale delle leggi italiane).
URI format: urn:nir:stato:legge:2023-12-31;234
"""
async with aiohttp.ClientSession() as session:
try:
async with session.get(
f"{self.NORMATTIVA_API}?urn={uri}&mimetype=text/plain",
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status == 200:
text = await resp.text()
return RawLegalDocument(
source_id=uri,
doc_type="legge",
raw_text=self._clean_normativa_text(text),
metadata={'source': 'normattiva', 'uri': uri}
)
except Exception as e:
print(f"Errore fetch {uri}: {e}")
return None
def _clean_normativa_text(self, text: str) -> str:
"""Rimuove markup e normalizza il testo normativo."""
# Rimuovi intestazioni burocratiche
text = re.sub(r'^.*?CAPO I', 'CAPO I', text, flags=re.DOTALL)
# Normalizza a capo
text = re.sub(r'\n{3,}', '\n\n', text)
# Rimuovi numeri di pagina
text = re.sub(r'\n\d+\n', '\n', text)
return text.strip()
def chunk_legal_text(
self,
doc: RawLegalDocument,
max_chars: int = 1500,
overlap_chars: int = 200
) -> List[dict]:
"""
Chunking strutturato per testi normativi.
Divide per articoli mantenendo la struttura legislativa.
"""
chunks = []
# Pattern per articoli: "Art. 1" o "Articolo 1" con varianti
article_pattern = re.compile(
r'(?:Art(?:icolo)?\.?\s+(\d+(?:\s*-\s*(?:bis|ter|quater|quinquies))?)'
r'|(\d+\.\s+))',
re.IGNORECASE
)
articles = list(article_pattern.finditer(doc.raw_text))
if not articles:
# Nessuna struttura: chunk fisso con overlap
for i in range(0, len(doc.raw_text), max_chars - overlap_chars):
chunk_text = doc.raw_text[i:i + max_chars]
chunks.append({
'content': chunk_text,
'doc_id': doc.source_id,
'doc_type': doc.doc_type,
'metadata': doc.metadata
})
else:
for idx, match in enumerate(articles):
start = match.start()
end = articles[idx + 1].start() if idx + 1 < len(articles) else len(doc.raw_text)
chunk_text = doc.raw_text[start:end].strip()
if len(chunk_text) > max_chars:
# Articolo molto lungo: suddividi ulteriormente
for j in range(0, len(chunk_text), max_chars - overlap_chars):
chunks.append({
'content': chunk_text[j:j + max_chars],
'doc_id': doc.source_id,
'article_ref': match.group(0),
'doc_type': doc.doc_type,
'metadata': doc.metadata
})
else:
chunks.append({
'content': chunk_text,
'doc_id': doc.source_id,
'article_ref': match.group(0),
'doc_type': doc.doc_type,
'metadata': doc.metadata
})
return chunks
System RAG z wielopoziomową poręczą
Serce Legal Copilot i system RAG z poręczami: nie wszystkie pytania muszą być otrzymać odpowiedź. Jeżeli odnalezione źródła nie wyczerpują tematu, system musi to wyraźnie stwierdzić, zamiast generować spekulatywną odpowiedź.
from langchain_openai import ChatOpenAI
from langchain.schema import SystemMessage, HumanMessage
from sentence_transformers import SentenceTransformer, util
import json
import re
from typing import List, Tuple
class LegalGuardrailSystem:
"""
Sistema di guardrail multi-livello per il Legal Copilot.
Impedisce la generazione di risposte non supportate dalle fonti.
"""
# Prompt di sistema con istruzioni esplicite anti-hallucination
SYSTEM_PROMPT = """Sei un assistente legale AI altamente specializzato.
REGOLE ASSOLUTE:
1. Rispondi SOLO basandoti sui documenti forniti nel contesto.
2. Se le fonti non coprono adeguatamente la domanda, di' esplicitamente
"Le fonti disponibili non sono sufficienti per rispondere a questa domanda."
3. Cita sempre la fonte specifica per ogni affermazione (art. X, sentenza Y).
4. Non interpretare o speculare oltre quanto dichiarato nelle fonti.
5. Usa linguaggio giuridico preciso, non parafrasare formule legali standard.
6. Segnala quando una norma potrebbe essere stata modificata di recente.
FORMATO RISPOSTA:
- Risposta strutturata con paragrafi
- Ogni affermazione seguita da [Fonte: ...]
- Conclusione con disclaimer se appropriato"""
def __init__(self, llm_model: str = "gpt-4o", embedding_model: str = "nlpaueb/legal-bert-base-uncased"):
self.llm = ChatOpenAI(model=llm_model, temperature=0.1, max_tokens=2000)
self.embedding_model = SentenceTransformer(embedding_model)
def _compute_grounding_score(
self,
answer: str,
sources: List[LegalSource]
) -> Tuple[float, List[str]]:
"""
Calcola quanto la risposta e 'fondata' nelle sorgenti.
Usa similarity semantica tra frasi della risposta e chunk delle fonti.
"""
if not sources:
return 0.0, ["Nessuna fonte disponibile"]
# Estrai frasi dalla risposta
sentences = [s.strip() for s in re.split(r'[.!?]', answer) if len(s.strip()) > 20]
if not sentences:
return 0.0, []
source_texts = [s.content_chunk for s in sources]
ungrounded = []
sentence_embeds = self.embedding_model.encode(sentences, convert_to_tensor=True)
source_embeds = self.embedding_model.encode(source_texts, convert_to_tensor=True)
grounded_count = 0
for i, sent_embed in enumerate(sentence_embeds):
max_sim = float(util.cos_sim(sent_embed, source_embeds).max())
if max_sim >= 0.65:
grounded_count += 1
else:
ungrounded.append(sentences[i])
grounding_ratio = grounded_count / len(sentences) if sentences else 0.0
return grounding_ratio, ungrounded
def _check_refusal_conditions(self, query: str, sources: List[LegalSource]) -> Optional[str]:
"""
Verifica se il sistema deve rifiutare di rispondere.
Restituisce il motivo del rifiuto o None se si può procedere.
"""
# Nessuna fonte trovata
if not sources:
return "Non ho trovato documenti rilevanti nel corpus per rispondere a questa domanda."
# Tutte le fonti hanno rilevanza molto bassa
max_relevance = max(s.relevance_score for s in sources)
if max_relevance < 0.4:
return (
f"Le fonti disponibili hanno una rilevanza troppo bassa (max: {max_relevance:.2f}) "
"per rispondere con sufficiente affidabilità."
)
# Query su consulenza legale personale specifica
advice_patterns = [
r'cosa devo fare (io|noi)', r'ho torto o ragione',
r'posso vincere la causa', r'devo (firmare|accettare|rifiutare)'
]
for pattern in advice_patterns:
if re.search(pattern, query, re.IGNORECASE):
return (
"Non posso fornire consulenza legale personalizzata. "
"Rivolgiti a un avvocato abilitato per il tuo caso specifico."
)
return None # Nessun rifiuto: procedi
async def generate_legal_answer(
self,
query: str,
retrieved_sources: List[LegalSource]
) -> LegalQueryResult:
"""
Genera una risposta legale fondata sulle sorgenti.
"""
from datetime import datetime
# Step 1: Verifica condizioni di rifiuto
refusal_reason = self._check_refusal_conditions(query, retrieved_sources)
if refusal_reason:
return LegalQueryResult(
query=query,
answer=refusal_reason,
sources=[],
confidence=0.0,
grounding_ratio=0.0,
uncertainty_disclaimer=refusal_reason,
generated_at=datetime.utcnow(),
model_version="gpt-4o-guardrailed-v1",
warnings=["RIFIUTO: " + refusal_reason]
)
# Step 2: Costruisci contesto dalle fonti
context = "\n\n---\n\n".join([
f"[{s.doc_type.upper()}] {s.citation}\n{s.content_chunk}"
for s in retrieved_sources
])
messages = [
SystemMessage(content=self.SYSTEM_PROMPT),
HumanMessage(content=f"CONTESTO NORMATIVO:\n{context}\n\nDOMANDA: {query}")
]
# Step 3: Genera risposta con LLM
response = await self.llm.ainvoke(messages)
answer = response.content
# Step 4: Calcola grounding score
grounding_ratio, ungrounded = self._compute_grounding_score(answer, retrieved_sources)
# Step 5: Genera disclaimer se grounding insufficiente
disclaimer = ""
warnings = []
if grounding_ratio < 0.7:
disclaimer = (
f"ATTENZIONE: Il {(1-grounding_ratio)*100:.0f}% delle affermazioni potrebbe "
"non essere direttamente supportato dalle fonti citate. Verificare sempre "
"con il testo normativo originale."
)
warnings.append(f"Grounding score basso: {grounding_ratio:.2%}")
confidence = (
grounding_ratio * 0.6 +
(max(s.relevance_score for s in retrieved_sources) if retrieved_sources else 0) * 0.4
)
return LegalQueryResult(
query=query,
answer=answer,
sources=retrieved_sources,
confidence=confidence,
grounding_ratio=grounding_ratio,
uncertainty_disclaimer=disclaimer,
generated_at=datetime.utcnow(),
model_version="gpt-4o-guardrailed-v1",
warnings=warnings
)
Interfejs kątowy ze strumieniowaniem
Profesjonalny Legal Copilot musi zapewniać prawnikom optymalną obsługę i asystentów prawnych. Interfejs musi pokazywać źródła w czasie rzeczywistym, umożliwiać rozbudowę każdego ekstraktu normatywnego i jasno określa poziom ufności odpowiedzi.
// legal-copilot.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, Subject } from 'rxjs';
export interface LegalSource {
docId: string;
docType: string;
citation: string;
contentChunk: string;
relevanceScore: number;
sourceUrl?: string;
}
export interface CopilotResponse {
query: string;
answer: string;
sources: LegalSource[];
confidence: number;
groundingRatio: number;
uncertaintyDisclaimer: string;
warnings: string[];
generatedAt: string;
}
@Injectable({ providedIn: 'root' })
export class LegalCopilotService {
private http = inject(HttpClient);
private readonly API_BASE = '/api/v1/legal-copilot';
isLoading = signal(false);
currentResponse = signal<CopilotResponse | null>(null);
streamingText = signal('');
ask(query: string): Observable<CopilotResponse> {
this.isLoading.set(true);
this.streamingText.set('');
return new Observable(observer => {
// SSE per streaming della risposta
const eventSource = new EventSource(
`{this.API_BASE}/stream?query={encodeURIComponent(query)}`
);
eventSource.addEventListener('token', (e: MessageEvent) => {
this.streamingText.update(prev => prev + e.data);
});
eventSource.addEventListener('complete', (e: MessageEvent) => {
const result = JSON.parse(e.data) as CopilotResponse;
this.currentResponse.set(result);
this.isLoading.set(false);
observer.next(result);
observer.complete();
eventSource.close();
});
eventSource.addEventListener('error', () => {
this.isLoading.set(false);
observer.error(new Error('Errore nella comunicazione con il Legal Copilot'));
eventSource.close();
});
return () => eventSource.close();
});
}
}
Ramy oceny
Pomiar jakości prawnego drugiego pilota wymaga wskaźników specyficznych dla dziedziny prawa. Nie wystarczy zmierzyć satysfakcję użytkownika: należy ocenić prawidłowość prawną.
from dataclasses import dataclass
from typing import List
import statistics
@dataclass
class EvaluationCase:
"""Un caso di valutazione con risposta attesa verificata da un esperto legale."""
query: str
reference_answer: str # risposta attesa da avvocato senior
required_citations: List[str] # citazioni che devono comparire nella risposta
forbidden_claims: List[str] # affermazioni false che non devono apparire
class LegalCopilotEvaluator:
"""
Framework di evaluation per il Legal Copilot.
Combina metriche automatiche e expert review.
"""
def evaluate_response(
self,
response: LegalQueryResult,
eval_case: EvaluationCase
) -> dict:
"""Valuta una singola risposta su più dimensioni."""
# 1. Citation recall: % delle citazioni attese presenti nella risposta
answer_lower = response.answer.lower()
found_citations = sum(
1 for cit in eval_case.required_citations
if cit.lower() in answer_lower
)
citation_recall = found_citations / len(eval_case.required_citations) if eval_case.required_citations else 1.0
# 2. Hallucination rate: % delle affermazioni false rilevate
hallucinations_found = sum(
1 for claim in eval_case.forbidden_claims
if claim.lower() in answer_lower
)
hallucination_rate = hallucinations_found / len(eval_case.forbidden_claims) if eval_case.forbidden_claims else 0.0
# 3. Refusal appropriateness (solo per query senza copertura nel corpus)
# Valutazione manuale: 1 se il rifiuto era corretto, 0 se errato
return {
'citation_recall': citation_recall,
'hallucination_rate': hallucination_rate,
'grounding_ratio': response.grounding_ratio,
'confidence': response.confidence,
'answer_length': len(response.answer),
'sources_count': len(response.sources)
}
def aggregate_evaluation(self, results: List[dict]) -> dict:
"""Aggrega i risultati di più casi di valutazione."""
return {
'avg_citation_recall': statistics.mean(r['citation_recall'] for r in results),
'avg_hallucination_rate': statistics.mean(r['hallucination_rate'] for r in results),
'avg_grounding_ratio': statistics.mean(r['grounding_ratio'] for r in results),
'avg_confidence': statistics.mean(r['confidence'] for r in results),
'total_cases': len(results)
}
Zagadnienia dotyczące wdrożenia i zgodności
Obowiązkowe zastrzeżenia i ograniczenia systemu
- To nie jest porada prawna: do każdej odpowiedzi należy dołączyć poprzez wyraźne zastrzeżenie, że system dostarcza informacji prawnych, a nie spersonalizowane porady prawne. Wykonywanie zawodu prawnika jest objęte tajemnicą do wykwalifikowanych prawników.
- Aktualizacja korpusu: przepisy się zmieniają. Korpus musi być aktualizowane co najmniej raz w tygodniu, ze wskazaniem ostatniej daty aktualizacja widoczna dla użytkowników.
- Rejestrowanie do audytów: wszystkie zapytania i odpowiedzi muszą być zalogowany w celu audytu prawnego i ciągłego doskonalenia systemu.
- Obowiązki użytkownika: umownie określić, że odpowiedzialność za decyzje podejmowane na podstawie reakcji systemu pozostaje u prawnika użytkownika.
Wnioski
Prawny asystent AI to nie tylko „ChatGPT połączony z dokumentami prawnymi”. Jest to złożony system, który wymaga specjalistycznej architektury RAG, poręczy wielopoziomowy dla halucynacji, zaktualizowany korpus prawny i interfejs zaprojektowany specjalnie do legalnego przepływu pracy.
Liczby branżowe są jasne: systemy zbudowane bez odpowiednich poręczy wywołują halucynacje w 17–33% przypadków w przypadku określonych zapytań prawnych. Z architektura przedstawiona w tym artykule — RAG + uziemienie cytowania + odmowa logika — można znacznie ten wskaźnik zmniejszyć i zbudować system które prawnicy mogą śmiało wykorzystać jako narzędzie badawcze i analityczne.
Seria LegalTech i AI
- NLP w analizie kontraktów: od OCR do zrozumienia
- Architektura platformy e-Discovery
- Automatyzacja zgodności z silnikiem dynamicznych reguł
- Inteligentna umowa dotycząca umów prawnych: Solidity i Vyper
- Podsumowanie dokumentów prawnych z generatywną sztuczną inteligencją
- Prawo dotyczące wyszukiwarek: osadzanie wektorów
- Podpis cyfrowy i uwierzytelnianie dokumentów w Scala
- Systemy ochrony danych i zgodności z RODO
- Budowanie prawnego asystenta AI – prawniczy drugi pilot (ten artykuł)
- Wzór integracji danych LegalTech







