Construirea unui asistent AI juridic: RAG, Guardrail și interfață profesională
De la începutul anului 2025, au fost documentate 518 cazuri de conținut generat de IA cu halucinații au fost prezentate în procedurile judiciare din SUA. În aceeași perioadă, evaluările independente arată că Westlaw AI și LexisNexis Lexis+ — cele mai populare două sisteme din lumea juridică — oferă răspunsuri precise doar în 65-83% din cazuri pe întrebări specifice de drept. Problema nu este AI: este cum este folosit și cum este construit.
În acest articol construim un Asistent juridic AI (copilot juridic) profesionist care abordeaza direct problema halucinatiei: RAG on corpus balustradă legală de proprietate, pe mai multe niveluri, pentru a prinde răspunsuri neacceptate din surse, citări verificabile și o interfață Angular optimizată pentru flux a muncii avocaţilor.
Ce vei învăța
- Arhitectură Retrieval-Augmented Generation (RAG) pentru domeniul legal
- Construirea unui corpus juridic: reglementări, sentințe, doctrină
- Balustradă cu mai multe niveluri: fundamentarea citației, scorul de încredere, logica refuzului
- Inginerie promptă pentru răspunsuri juridice precise și neînșelătoare
- Interfață unghiulară prietenoasă cu avocații cu streaming de răspuns
- Cadrul de evaluare pentru măsurarea calității sistemului
Arhitectura RAG pentru Domeniul Juridic
Diferența dintre un chatbot generic și un Copilot Legal profesionist constă în arhitectura RAG: fiecare răspuns trebuie să fie pe baza unor documente specifice recuperat din corpus juridic, negenerat de memoria parametrică a modelului. Acesta este mecanismul fundamental de reducere a halucinațiilor dintr-o problemă sistemic cu un risc gestionabil.
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)
Construirea Corpusului Juridic
Calitatea corpusului este cel mai critic factor pentru un copilot legal. Un corpus juridic italianul bine structurat trebuie să includă:
- Legislație primară: Cod civil, Cod penal, Cod de judecată Legi civile, speciale - în versiunea curentă (consolidată).
- Jurisprudenţă: hotărârile Curții Supreme, Curții Constituționale, TAR și Consiliul de Stat, Curtea de Justiție a UE (CEDO)
- Regulamente și circulare: Banca Italiei, Consob, Garant de confidențialitate, AGCM, INPS
- Doctrina actualizată: articole din reviste juridice autorizate
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
Sistem RAG cu balustradă pe mai multe niveluri
Inima Copilotului Legal și a sistemului RAG cu balustrade: nu toate întrebările trebuie primi un răspuns. Dacă sursele preluate nu acoperă suficient întrebarea, sistemul trebuie să precizeze acest lucru în mod explicit în loc să genereze un răspuns speculativ.
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
)
Interfață unghiulară cu Streaming
Un copilot juridic profesionist trebuie să ofere avocaților o experiență optimă de utilizare și parajuriști. Interfața trebuie să arate sursele în timp real, să vă permită extinderea fiecare extras normativ și clarificați nivelul de încredere al răspunsului.
// 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();
});
}
}
Cadrul de evaluare
Măsurarea calității unui copilot legal necesită metrici specifice domeniului juridic. Nu este suficient să măsurați satisfacția utilizatorilor: trebuie evaluată acuratețea legală.
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)
}
Considerații privind implementarea și conformitatea
Declinări obligatorii și limitări ale sistemului
- Acesta nu este un sfat juridic: fiecare răspuns trebuie să fie însoțit printr-o declinare explicită conform căreia sistemul furnizează informații legale, nu consultanta juridica personalizata. Exercitarea profesiei de avocat este confidențială la avocații calificați.
- Actualizare corpus: reglementările se schimbă. Corpusul trebuie să fie actualizat cel puțin săptămânal, cu indicarea ultimei date actualizare vizibilă pentru utilizatori.
- Înregistrare pentru audituri: toate întrebările și răspunsurile trebuie să fie autentificat pentru audit juridic și pentru îmbunătățirea continuă a sistemului.
- Responsabilitati utilizator: definesc contractual că responsabilitatea pentru deciziile luate pe baza răspunsurilor sistemului rămâne la avocatul utilizatorului.
Concluzii
Un asistent legal AI nu este doar „ChatGPT conectat la documente legale”. Este un sistem complex care necesită arhitectură RAG specializată, balustrade multi-nivel pentru halucinații, corpus legal actualizat și o interfață conceput special pentru fluxul de lucru legal.
Cifrele din industrie sunt clare: sisteme construite fără balustrade adecvate produc halucinații în 17-33% din cazuri pe întrebări juridice specifice. Cu arhitectura prezentată în acest articol — RAG + motivare citare + refuz logica — este posibil să reduceți semnificativ această rată și să construiți un sistem pe care avocații îl pot folosi cu încredere ca instrument de cercetare și analiză.
Seria LegalTech și AI
- NLP pentru analiza contractelor: de la OCR la înțelegere
- Arhitectura platformei e-Discovery
- Automatizarea conformității cu Dynamic Rules Engine
- Contract inteligent pentru acorduri juridice: Solidity și Vyper
- Rezumat documente legale cu IA generativă
- Legea motoarelor de căutare: înglobări vectoriale
- Semnătura digitală și autentificarea documentelor la Scala
- Confidențialitatea datelor și sisteme de conformitate GDPR
- Crearea unui asistent legal AI - Copilot legal (acest articol)
- Model de integrare a datelor LegalTech







