Chat-GDPR / gdpr_logic.py
alfakat's picture
Upload 3 files
603f59c verified
Raw
History Blame Contribute Delete
13.1 kB
import json
import re
import time
from pathlib import Path
from typing import Any
from urllib.parse import urljoin
import requests
import torch
from bs4 import BeautifulSoup, Tag
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from transformers import AutoModelForCausalLM, AutoTokenizer
GDPR_INDEX_URL = "https://gdpr.eu/tag/gdpr/"
CACHE_FILE = Path("gdpr_articles_cache.json")
MODEL_NAME = "Qwen/Qwen2.5-1.5B-Instruct"
MAX_CONTEXT_ARTICLES = 4
REQUEST_DELAY_SECONDS = 0.15
QUICK_QUESTIONS = [
"What is GDPR?",
"What are the main GDPR principles?",
"What rights do data subjects have?",
"When is a Data Protection Officer required?",
"What makes consent valid under the GDPR?",
"How quickly must a personal data breach be reported?",
]
def clean_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def create_session() -> requests.Session:
session = requests.Session()
session.headers.update(
{
"User-Agent": (
"Mozilla/5.0 (compatible; GDPR-Assistant/1.0)"
),
"Accept-Language": "en-US,en;q=0.9",
}
)
return session
def download_page(session: requests.Session, url: str) -> BeautifulSoup:
response = session.get(url, timeout=40)
response.raise_for_status()
return BeautifulSoup(response.text, "lxml")
def get_article_links(
session: requests.Session,
) -> dict[int, dict[str, Any]]:
soup = download_page(session, GDPR_INDEX_URL)
articles: dict[int, dict[str, Any]] = {}
article_pattern = re.compile(
r"\bArt\.\s*(\d+)\s+GDPR\b",
flags=re.IGNORECASE,
)
for link in soup.find_all("a", href=True):
link_text = clean_text(link.get_text(" ", strip=True))
match = article_pattern.search(link_text)
if not match:
continue
article_number = int(match.group(1))
if not 1 <= article_number <= 99:
continue
articles[article_number] = {
"number": article_number,
"title": link_text,
"url": urljoin(GDPR_INDEX_URL, link["href"]),
}
return dict(sorted(articles.items()))
def find_article_heading(
soup: BeautifulSoup,
article_number: int,
) -> Tag | None:
heading_pattern = re.compile(
rf"\bArt\.\s*{article_number}\s+GDPR\b",
flags=re.IGNORECASE,
)
for heading_name in ("h1", "h2", "h3"):
for heading in soup.find_all(heading_name):
heading_text = clean_text(
heading.get_text(" ", strip=True)
)
if heading_pattern.search(heading_text):
return heading
return None
def is_stop_heading(element: Tag) -> bool:
if element.name not in {
"h1", "h2", "h3", "h4", "h5", "h6"
}:
return False
text = clean_text(
element.get_text(" ", strip=True)
).lower()
stop_phrases = (
"suitable recitals",
"related posts",
"about gdpr.eu",
"getting started",
"templates",
"technical review",
)
return any(phrase in text for phrase in stop_phrases)
def extract_article_content(
heading: Tag,
) -> str:
content_parts: list[str] = []
seen: set[str] = set()
for element in heading.find_all_next():
if not isinstance(element, Tag):
continue
if is_stop_heading(element):
break
if element.name not in {"p", "li"}:
continue
text = clean_text(
element.get_text(" ", strip=True)
)
if not text:
continue
if re.match(
r"^(Chapter|Art\.\s*\d+\s+GDPR)",
text,
flags=re.IGNORECASE,
):
continue
normalized = text.casefold()
if normalized in seen:
continue
seen.add(normalized)
content_parts.append(text)
return "\n".join(content_parts)
def extract_single_article(
session: requests.Session,
article_number: int,
article_info: dict[str, Any],
) -> dict[str, Any] | None:
soup = download_page(session, article_info["url"])
heading = find_article_heading(soup, article_number)
if heading is None:
return None
title = clean_text(heading.get_text(" ", strip=True))
content = extract_article_content(heading)
if not content:
return None
return {
"number": article_number,
"title": title,
"url": article_info["url"],
"content": content,
"search_text": (
f"Article {article_number}\n"
f"{title}\n"
f"{content}"
),
}
def save_articles_to_cache(
articles: list[dict[str, Any]],
) -> None:
with CACHE_FILE.open("w", encoding="utf-8") as file:
json.dump(
articles,
file,
ensure_ascii=False,
indent=2,
)
def load_cache() -> list[dict[str, Any]]:
if not CACHE_FILE.exists():
return []
try:
with CACHE_FILE.open("r", encoding="utf-8") as file:
data = json.load(file)
return data if isinstance(data, list) else []
except (OSError, json.JSONDecodeError):
return []
def extract_all_articles(
force_refresh: bool = False,
) -> list[dict[str, Any]]:
if not force_refresh:
cached = load_cache()
if cached:
return cached
session = create_session()
article_links = get_article_links(session)
if not article_links:
raise RuntimeError(
"No GDPR article links were found."
)
extracted: list[dict[str, Any]] = []
for number, info in article_links.items():
try:
article = extract_single_article(
session,
number,
info,
)
if article:
extracted.append(article)
except requests.RequestException as error:
print(
f"Article {number} could not be downloaded: {error}"
)
time.sleep(REQUEST_DELAY_SECONDS)
if not extracted:
raise RuntimeError(
"No GDPR article content could be extracted."
)
save_articles_to_cache(extracted)
return extracted
class GDPRSearchIndex:
def __init__(self, articles: list[dict[str, Any]]):
if not articles:
raise ValueError(
"At least one article is required."
)
self.articles = articles
self.vectorizer = TfidfVectorizer(
stop_words="english",
ngram_range=(1, 2),
max_features=30000,
)
documents = [
article["search_text"]
for article in articles
]
self.article_matrix = (
self.vectorizer.fit_transform(documents)
)
def search(
self,
query: str,
limit: int = MAX_CONTEXT_ARTICLES,
) -> list[dict[str, Any]]:
query_vector = self.vectorizer.transform([query])
scores = cosine_similarity(
query_vector,
self.article_matrix,
).flatten()
ranked_indexes = scores.argsort()[::-1]
results: list[dict[str, Any]] = []
for index in ranked_indexes:
score = float(scores[index])
if score <= 0:
continue
article = dict(self.articles[index])
article["score"] = score
results.append(article)
if len(results) >= limit:
break
return results
print(f"Loading model: {MODEL_NAME}")
tokenizer = AutoTokenizer.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
torch_dtype="auto",
device_map="auto",
trust_remote_code=True,
)
print("Model loaded.")
def build_context(
articles: list[dict[str, Any]],
) -> str:
return "\n\n---\n\n".join(
(
f"ARTICLE {article['number']}\n"
f"TITLE: {article['title']}\n"
f"SOURCE: {article['url']}\n\n"
f"{article['content']}"
)
for article in articles
)
def generate_answer(
question: str,
articles: list[dict[str, Any]],
) -> str:
context = build_context(articles)
messages = [
{
"role": "system",
"content": (
"You are a GDPR document assistant. "
"Answer only from the supplied GDPR article text. "
"If the answer is not in the supplied articles, say so. "
"Explain the answer clearly, mention relevant article "
"numbers, and do not present the response as formal "
"legal advice."
),
},
{
"role": "user",
"content": (
f"GDPR CONTEXT:\n\n{context}\n\n"
f"QUESTION:\n{question}"
),
},
]
prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
model_inputs = tokenizer(
prompt,
return_tensors="pt",
truncation=True,
max_length=7000,
).to(model.device)
with torch.inference_mode():
output_ids = model.generate(
**model_inputs,
max_new_tokens=350,
do_sample=True,
temperature=0.2,
top_p=0.9,
repetition_penalty=1.08,
pad_token_id=tokenizer.eos_token_id,
)
generated_ids = output_ids[
:,
model_inputs["input_ids"].shape[1]:
]
return tokenizer.batch_decode(
generated_ids,
skip_special_tokens=True,
)[0].strip()
def format_sources(
articles: list[dict[str, Any]],
) -> str:
return "\n".join(
(
f"- [Article {article['number']}: "
f"{article['title']}]({article['url']})"
)
for article in articles
)
gdpr_articles: list[dict[str, Any]] = []
search_index: GDPRSearchIndex | None = None
def initialize_data() -> str:
global gdpr_articles, search_index
try:
gdpr_articles = extract_all_articles(
force_refresh=False
)
search_index = GDPRSearchIndex(gdpr_articles)
return (
f"✅ GDPR knowledge base ready: "
f"**{len(gdpr_articles)} articles loaded**."
)
except Exception as error:
gdpr_articles = []
search_index = None
return (
"❌ Could not initialize the GDPR knowledge base. "
f"`{error}`"
)
def refresh_data() -> str:
global gdpr_articles, search_index
try:
gdpr_articles = extract_all_articles(
force_refresh=True
)
search_index = GDPRSearchIndex(gdpr_articles)
return (
f"✅ GDPR knowledge base refreshed: "
f"**{len(gdpr_articles)} articles loaded**."
)
except Exception as error:
return (
"❌ Could not refresh the GDPR website. "
f"`{error}`"
)
def ask_question(
question: str,
history: list[dict[str, str]] | None,
):
history = history or []
if not question or not question.strip():
return history, ""
question = question.strip()
history.append(
{
"role": "user",
"content": question,
}
)
if search_index is None:
history.append(
{
"role": "assistant",
"content": (
"The GDPR knowledge base is unavailable. "
"Please refresh the website and try again."
),
}
)
return history, ""
relevant_articles = search_index.search(question)
if not relevant_articles:
history.append(
{
"role": "assistant",
"content": (
"I could not find relevant information in "
"the loaded GDPR articles."
),
}
)
return history, ""
try:
answer = generate_answer(
question,
relevant_articles,
)
sources = format_sources(relevant_articles)
complete_answer = (
f"{answer}\n\n"
f"### Sources used\n{sources}\n\n"
"*This response is based on the loaded GDPR articles "
"and is not formal legal advice.*"
)
except Exception as error:
complete_answer = (
"Relevant GDPR articles were found, but the model "
f"could not generate an answer. `{error}`"
)
history.append(
{
"role": "assistant",
"content": complete_answer,
}
)
return history, ""
def clear_chat():
return [], ""
INITIAL_STATUS = initialize_data()