| 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() |
|
|