# EUR-Lex AI Chat — Full Implementation Plan **Project:** EUR-Lex AI Chat — Ask questions about EU law in plain language, get answers with citations to real EUR-Lex documents. **Build target:** ~15 hours total (one-time, laptop-based) **Ongoing cost:** $0.00/month — runs 100% autonomously after deployment --- ## Overview ### Architecture ``` DATA FLOW (fully automated): EUR-Lex Cellar SPARQL (public) │ GitHub Actions queries daily ▼ Cellar REST RDF → XHTML (public, no auth) │ GitHub Actions downloads via RDF graph traversal ▼ GitHub Actions Runner (free, 7GB RAM, 2-core CPU) │ pip install → chunk → embed (sentence-transformers ONNX) → merge ▼ HuggingFace Hub Dataset (public, free storage) │ vectors.npy + chunks.json │ ▲ │ │ │ ▼ │ │ Render (512MB, free) │ │ ├── startup: download from HF Hub │ │ ├── hourly: check HF Hub for updates via /refresh │ │ └── live: FastAPI + numpy KNN (brute-force cosine similarity) │ │ │ │ │ ▼ │ │ ┌─────────────────┐ │ └──────────────┤ Groq API │ │ │ (Llama 3.3 70B) │ │ └────────┬─────────┘ │ │ USER FLOW: │ [Google Search] │ │ user finds FAQ/blog post ▼ [Browser] ─► [Vercel: Astro site] ─► [Render: FastAPI API] ─► [Groq] Static HTML pages /chat endpoint answer + React chat island numpy KNN + RAG KEEP ALIVE: [cron-job.org] ──► [Render /health] every 5 min [cron-job.org] ──► [Render /refresh] every 60 min ``` ### Files Layout ``` eur-lex-ai-chat/ ├── backend/ │ ├── main.py # FastAPI: /chat, /health, /refresh │ ├── search.py # numpy KNN search over pre-loaded vectors │ ├── rag.py # Build prompt, call Groq, parse citations │ ├── data_loader.py # Download index from HF Hub at startup │ ├── rate_limit.py # Per-IP + global rate limiting │ ├── requirements.txt # fastapi, uvicorn, numpy, huggingface_hub, httpx │ └── startup.sh # Render entry point ├── frontend/ │ ├── src/ │ │ ├── pages/ │ │ │ ├── index.astro # Landing page (SEO + chat island) │ │ │ ├── faq.astro # FAQ page (JSON-LD) │ │ │ └── blog/ │ │ │ ├── index.astro # Blog listing │ │ │ └── posts/ # Markdown blog posts │ │ ├── components/ │ │ │ ├── ChatWidget.jsx # React chat island │ │ │ └── SeoHead.astro # JSON-LD + OG tags │ │ └── layouts/ │ │ └── Base.astro # Main layout │ ├── astro.config.mjs │ ├── tailwind.config.mjs │ └── package.json ├── scripts/ │ ├── build_index.py # Laptop: FULL build from scratch (one-time) │ └── update_index.py # GitHub Actions: INCREMENTAL update (daily) ├── .github/ │ └── workflows/ │ └── update-index.yml # GitHub Actions workflow ├── proposal.md ├── implementation-plan.md └── README.md ``` --- ## Phase 0: Pre-flight Checklist **Goal:** Verify every tool, API key, and service is working before we start building. ### Step 0.1 — Verify Node.js (needs >=22.12.0 for Astro 5) ```bash source ~/.nvm/nvm.sh nvm use v22.22.3 node --version # must show v22.22.3 npm --version # must show 10.x or higher ``` If nvm is not found, install it: ```bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash source ~/.bashrc nvm install v22.22.3 nvm use v22.22.3 ``` ### Step 0.2 — Verify Python venv + packages ```bash source ~/Desktop/EUProjects/.venv/bin/activate python3 --version # must show 3.12.x pip list | grep -E "fastapi|uvicorn|numpy|httpx|huggingface|polars|eurlxp|beautifulsoup4|pymupdf|tqdm|pydantic" ``` Expected: fastapi, uvicorn, numpy, httpx, huggingface_hub, polars, eurlxp, beautifulsoup4, pymupdf, tqdm, pydantic. If any are missing: ```bash pip install fastapi uvicorn numpy httpx huggingface_hub polars eurlxp[sparql] beautifulsoup4 pymupdf tqdm ``` ### Step 0.3 — Install sentence-transformers (needed for Phase 1) ```bash source ~/Desktop/EUProjects/.venv/bin/activate pip install sentence-transformers ``` This installs torch (~2GB). It's a one-time install for the laptop build. The build script runs once. ### Step 0.4 — Verify GROQ_API_KEY ```bash source ~/Desktop/EUProjects/.venv/bin/activate python3 -c " import os, httpx key = os.environ.get('GROQ_API_KEY') if not key: print('MISSING: GROQ_API_KEY not found in env') else: r = httpx.get('https://api.groq.com/openai/v1/models', headers={'Authorization': f'Bearer {key}'}, timeout=10) assert r.status_code == 200, f'GROQ API returned {r.status_code}' models = [m['id'] for m in r.json()['data']] print(f'GROQ OK — {len(models)} models available') print(f' Default: llama-3.3-70b-versatile') " ``` If `GROQ_API_KEY` is missing, it should be in `/home/nedaktov/Desktop/NedCode3/.env`: ```bash source /home/nedaktov/Desktop/NedCode3/.env echo "GROQ_API_KEY is set: ${GROQ_API_KEY:0:10}..." ``` ### Step 0.5 — Verify HuggingFace Hub access ```bash source ~/Desktop/EUProjects/.venv/bin/activate python3 -c " from huggingface_hub import HfApi api = HfApi() # Test anonymous read access try: api.dataset_info('hf-internal-testing/dummy_dataset') print('HF Hub accessible (anonymous)') except Exception as e: print(f'HF Hub error: {e}') " ``` ### Step 0.6 — Create HuggingFace token and log in We need a write token to upload the vector index. Create one at: https://huggingface.co/settings/tokens Create a token with "write" permissions. Then: ```bash source ~/Desktop/EUProjects/.venv/bin/activate huggingface-cli login --token YOUR_HF_TOKEN # Or use the Python API: python3 -c " from huggingface_hub import login login(token='YOUR_HF_TOKEN', add_to_git_credential=True) print('Logged in to HuggingFace') " ``` ### Step 0.7 — Create project directories ```bash mkdir -p ~/Desktop/EUProjects/eur-lex-ai-chat/{backend,frontend/src/{pages,components,layouts,pages/blog/posts},scripts,.github/workflows,data} ``` ### Step 0.8 — Verify EUR-Lex SPARQL endpoint + eurlxp ```bash source ~/Desktop/EUProjects/.venv/bin/activate python3 -c " from eurlxp import get_documents docs = get_documents(types=['REG'], limit=3) print(f'SPARQL OK — got {len(docs)} documents') for d in docs: print(f' {d[\"celex\"]} ({d[\"type\"]}) — {d[\"date\"]}') " ``` Expected output: ``` SPARQL OK — got 3 documents 32025R1355R(02) (REG) — 2026-03-27 ... ``` ### Step 0.9 — Verify EUR-Lex HTML fetch + parse ⚠️ **Known issue:** `eurlxp.parse_html()` has a Polars schema bug — the `modifier` field is conditionally included, causing schema mismatch. Use the internal parser directly with a fixed schema instead. ```bash source ~/Desktop/EUProjects/.venv/bin/activate python3 -c " from eurlxp import get_html_by_celex_id from eurlxp.parser import _parse_html_with_beautifulsoup as internal_parse import polars as pl html = get_html_by_celex_id('32019R0947', language='en') print(f'HTML fetched: {len(html)} bytes') results = internal_parse(html) print(f'Parsed: {len(results)} elements') # Build records with fixed schema (workaround for eurlxp Polars bug) records = [] for r in results: records.append({ 'text': r.text, 'type': r.item_type, 'ref': str(r.ref), 'modifier': r.modifier, 'document': r.context.document, 'article': r.context.article, 'article_subtitle': r.context.article_subtitle, 'paragraph': r.context.paragraph, 'group': r.context.group, 'section': r.context.section, }) df = pl.DataFrame(records, schema={ 'text': pl.Utf8, 'type': pl.Utf8, 'ref': pl.Utf8, 'modifier': pl.Utf8, 'document': pl.Utf8, 'article': pl.Utf8, 'article_subtitle': pl.Utf8, 'paragraph': pl.Utf8, 'group': pl.Utf8, 'section': pl.Utf8, }) print(f'DataFrame: {len(df)} rows') texts = df.filter(pl.col('type') == 'text') print(f'Text elements: {len(texts)}') print(texts.head(5)) " ``` ### Step 0.10 — Pre-flight checklist summary | # | Check | Status | Fix if failing | |---|-------|--------|----------------| | 1 | Node.js >=22.12.0 | | `nvm install v22.22.3` | | 2 | Python venv active | | `source ~/Desktop/EUProjects/.venv/bin/activate` | | 3 | All Python packages installed | | `pip install -r requirements.txt` | | 4 | sentence-transformers installed | | `pip install sentence-transformers` | | 5 | GROQ_API_KEY in env | | Add to .env and source it | | 6 | HF Hub accessible | | Check internet, try again | | 7 | HF write token + login | | Create token at huggingface.co/settings/tokens | | 8 | Project directories exist | | Run mkdir command above | | 9 | EUR-Lex SPARQL works | | Check internet, try later | | 10 | EUR-Lex HTML fetch works | | Check eurlxp version, try again | --- ## Phase 1: Build the Data Index **Goal:** Create the vector index of all EU legislative acts. This runs ONCE on your laptop (~3-5 hours). Output: vectors.npy + chunks.json uploaded to HuggingFace Hub. **Scope:** All regulations (REG), directives (DIR), implementing regulations (REG_IMPL), and implementing directives (DIR_IMPL) with English titles, published from 2000-01-01 onwards. This covers ~25 years of EU legislation — the laws people actually search for. ### Step 1.1 — Write `scripts/build_index.py` This is the main build script. Save it as `~/Desktop/EUProjects/eur-lex-ai-chat/scripts/build_index.py`: ```python #!/usr/bin/env python3 """ build_index.py — One-time full build of the EUR-Lex vector index. Pipeline: 1. SPARQL query → list of CELEX IDs with metadata 2. For each CELEX ID: RDF graph traversal → XHTML content 3. Parse HTML → structured text via eurlxp parser 4. Chunk into passages (by article/paragraph) 5. Embed with sentence-transformers (all-MiniLM-L6-v2, 384-dim) 6. Upload vectors.npy + chunks.json to HuggingFace Hub Usage: source ~/Desktop/EUProjects/.venv/bin/activate HF_TOKEN=hf_yourtoken python3 scripts/build_index.py Output: data/vectors.npy — numpy array of shape (N, 384), float32 data/chunks.json — list of dicts with text + metadata data/last_updated.txt — ISO timestamp of build """ import json import logging import os import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import numpy as np import requests from tqdm import tqdm logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger(__name__) DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data") os.makedirs(DATA_DIR, exist_ok=True) # ── Configuration ────────────────────────────────────────────────────────── SPARQL_ENDPOINT = "https://publications.europa.eu/webapi/rdf/sparql" # Document types to include DOC_TYPES = [ "REG", # Regulations "DIR", # Directives "REG_IMPL", # Implementing regulations "DIR_IMPL", # Implementing directives ] # Date range: 2000-01-01 onwards (covers 25+ years of EU law) FROM_DATE = "2000-01-01" # How many documents to fetch per SPARQL page SPARQL_PAGE_SIZE = 1000 # How many parallel download workers DOWNLOAD_WORKERS = 20 # Embedding model EMBEDDING_MODEL = "all-MiniLM-L6-v2" # 384-dim, runs on CPU # HuggingFace dataset name HF_DATASET_NAME = "eurlex-chat-data" # ── Step 1: SPARQL Query ─────────────────────────────────────────────────── def make_type_filter(types): """Build SPARQL FILTER for document types.""" type_uris = [ f"" for t in types ] return " ||\n ".join(f"?type = {uri}" for uri in type_uris) def query_all_documents(): """Query all documents matching our criteria via SPARQL with pagination.""" logger.info("Querying SPARQL for documents...") all_docs = [] offset = 0 while True: type_filter = make_type_filter(DOC_TYPES) query = f""" PREFIX cdm: PREFIX dc: SELECT DISTINCT ?doc ?type ?celex ?title ?date WHERE {{ ?doc cdm:work_has_resource-type ?type . ?doc cdm:resource_legal_id_celex ?celex . ?doc dc:title ?title . ?doc cdm:work_date_document ?date . FILTER({type_filter}) FILTER(LANG(?title) = "en") FILTER(?date >= "{FROM_DATE}"^^xsd:date) }} ORDER BY DESC(?date) OFFSET {offset} LIMIT {SPARQL_PAGE_SIZE} """ r = requests.get( SPARQL_ENDPOINT, params={"query": query, "format": "json"}, timeout=60, ) r.raise_for_status() data = r.json() bindings = data["results"]["bindings"] if not bindings: break for b in bindings: all_docs.append({ "celex": b["celex"]["value"], "title": b["title"]["value"], "date": b["date"]["value"], "type": b["type"]["value"].split("/")[-1], "cellar_url": b["doc"]["value"], }) offset += SPARQL_PAGE_SIZE logger.info(f" Fetched {len(all_docs)} documents so far...") if len(bindings) < SPARQL_PAGE_SIZE: break logger.info(f"Total documents found: {len(all_docs)}") return all_docs # ── Step 2: Download XHTML Content ───────────────────────────────────────── def fetch_document_xhtml(doc): """Fetch a document's XHTML content via Cellar RDF graph traversal. Uses the same approach as eurlxp's _fetch_html_via_sparql: 1. Get work RDF → find English expression 2. Get expression RDF → find XHTML manifestation 3. Download XHTML """ celex = doc["celex"] try: # Step 1: Get work RDF graph work_url = f"http://publications.europa.eu/resource/celex/{celex}?language=eng" r = requests.get(work_url, timeout=30) r.raise_for_status() # Parse RDF/XML to find English expression from xml.etree import ElementTree as ET root = ET.fromstring(r.content) # Namespaces ns = { "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "cdm": "http://publications.europa.eu/ontology/cdm#", } # Find the English expression (ENG) expressions = root.findall(".//cdm:work_has_expression", ns) expression_url = None for expr in expressions: resource = expr.get("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}resource") if resource and resource.endswith(".ENG"): expression_url = resource break if not expression_url and expressions: # Fallback to first expression resource = expressions[0].get( "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}resource" ) if resource: expression_url = resource if not expression_url: logger.warning(f" No expression found for {celex}") return None # Step 2: Get expression RDF to find XHTML manifestation r2 = requests.get(expression_url, timeout=30) r2.raise_for_status() expr_root = ET.fromstring(r2.content) manifestations = expr_root.findall( ".//cdm:expression_manifested_by_manifestation", ns ) xhtml_url = None for manif in manifestations: resource = manif.get( "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}resource" ) if resource and resource.endswith(".xhtml"): xhtml_url = resource break if not xhtml_url and manifestations: # Fallback to .fmx4 (Formex 4 format) for manif in manifestations: resource = manif.get( "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}resource" ) if resource and resource.endswith(".fmx4"): xhtml_url = resource break if not xhtml_url: logger.warning(f" No XHTML manifestation for {celex}") return None # Step 3: Download the XHTML content r3 = requests.get( xhtml_url, headers={"Accept": "application/xhtml+xml, text/html"}, timeout=30, ) r3.raise_for_status() html = r3.text if len(html) < 100: logger.warning(f" Empty content for {celex}") return None return html except requests.RequestException as e: logger.warning(f" HTTP error for {celex}: {e}") return None except ET.ParseError as e: logger.warning(f" XML parse error for {celex}: {e}") return None except Exception as e: logger.warning(f" Unexpected error for {celex}: {e}") return None # ── Step 3: Parse HTML → Structured Text ─────────────────────────────────── def parse_html_to_chunks(html, celex_id, title): """Parse EUR-Lex HTML into text chunks using eurlxp internal parser. ⚠️ Uses _parse_html_with_beautifulsoup directly instead of parse_html() because eurlxp's parse_html() has a Polars schema bug (conditional modifier field causes schema mismatch). This workaround uses a fixed schema. """ from eurlxp.parser import _parse_html_with_beautifulsoup as internal_parse import polars as pl try: results = internal_parse(html) except Exception as e: logger.warning(f" Parse error for {celex_id}: {e}") return [] if not results: return [] # Convert to DataFrame with fixed schema (bypasses eurlxp bug) records = [] for r in results: records.append({ 'text': r.text, 'type': r.item_type, 'ref': str(r.ref), 'modifier': r.modifier, 'document': r.context.document, 'article': r.context.article, 'article_subtitle': r.context.article_subtitle, 'paragraph': r.context.paragraph, 'group': r.context.group, 'section': r.context.section, }) df = pl.DataFrame(records, schema={ 'text': pl.Utf8, 'type': pl.Utf8, 'ref': pl.Utf8, 'modifier': pl.Utf8, 'document': pl.Utf8, 'article': pl.Utf8, 'article_subtitle': pl.Utf8, 'paragraph': pl.Utf8, 'group': pl.Utf8, 'section': pl.Utf8, }) if len(df) == 0: return [] chunks = [] current_article = None current_text = [] # Process rows in order for row in df.to_dicts(): text = row.get("text", "").strip() row_type = row.get("type", "") article = row.get("article", None) if not text: continue # Skip very short fragments (titles, headers) if len(text) < 40 and row_type in ("doc-title", "art-title", "art-subtitle", "group-title"): if row_type == "doc-title": pass # We already have the title from SPARQL elif row_type == "art-title" and article: # Finalize previous article chunk if current_text: chunk_text = " ".join(current_text) if len(chunk_text) > 50: chunks.append({ "text": chunk_text, "celex": celex_id, "title": title, "article": current_article, "type": "article", }) current_text = [] current_article = article continue # Skip pure metadata rows if row_type in ("doc-title", "art-subtitle", "group-title", "section-title"): continue # Skip notice/note rows if row.get("modifier") in ("note", "signatory"): continue current_text.append(text) # If text is a full paragraph and we have enough context, finalize if len(text) > 100 and current_text: # Check if this is a natural break (new article, new section) pass # Flush remaining text if current_text: chunk_text = " ".join(current_text) if len(chunk_text) > 50: chunks.append({ "text": chunk_text, "celex": celex_id, "title": title, "article": current_article, "type": "article", }) # If no article-based chunks found, fall back to paragraph splitting if not chunks: # Try to split by paragraphs (double newlines) paragraphs = [ p.strip() for p in html.replace("

", "\n\n") .replace("", "\n\n") .replace("
", "\n") .split("\n\n") if len(p.strip()) > 80 ] for i, para in enumerate(paragraphs): chunks.append({ "text": para, "celex": celex_id, "title": title, "article": None, "type": "paragraph", }) return chunks # ── Step 4: Embed ────────────────────────────────────────────────────────── def embed_chunks(all_chunks, batch_size=128): """Embed all chunks using sentence-transformers.""" from sentence_transformers import SentenceTransformer logger.info(f"Loading embedding model: {EMBEDDING_MODEL}") model = SentenceTransformer(EMBEDDING_MODEL) logger.info("Model loaded") texts = [c["text"] for c in all_chunks] all_embeddings = [] logger.info(f"Embedding {len(texts)} chunks in batches of {batch_size}...") for i in range(0, len(texts), batch_size): batch = texts[i : i + batch_size] embeddings = model.encode(batch, show_progress_bar=False, normalize_embeddings=True) all_embeddings.append(embeddings) if (i // batch_size) % 10 == 0: logger.info(f" Embedded {min(i + batch_size, len(texts))}/{len(texts)}") return np.vstack(all_embeddings).astype(np.float32) # ── Step 5: Upload to HuggingFace Hub ────────────────────────────────────── def upload_to_hub(vectors, chunks, dataset_name, token): """Upload vectors.npy + chunks.json + last_updated.txt to HF Hub.""" from huggingface_hub import HfApi, create_repo api = HfApi() repo_id = f"{api.whoami()['name']}/{dataset_name}" # Create repo if it doesn't exist try: create_repo(repo_id, repo_type="dataset", exist_ok=True, token=token) logger.info(f"HF dataset repo: {repo_id}") except Exception as e: logger.warning(f"Repo creation warning (may already exist): {e}") # Save vectors vectors_path = os.path.join(DATA_DIR, "vectors.npy") np.save(vectors_path, vectors) logger.info(f"Saved vectors: {vectors.shape} ({os.path.getsize(vectors_path) / 1e6:.1f} MB)") # Save chunks chunks_path = os.path.join(DATA_DIR, "chunks.json") with open(chunks_path, "w") as f: json.dump(chunks, f, indent=2) logger.info(f"Saved chunks: {len(chunks)} items ({os.path.getsize(chunks_path) / 1e6:.1f} MB)") # Save timestamp ts_path = os.path.join(DATA_DIR, "last_updated.txt") ts = datetime.now(timezone.utc).isoformat() with open(ts_path, "w") as f: f.write(ts) # Upload files api.upload_file( repo_id=repo_id, path_in_repo="vectors.npy", path_or_fileobj=vectors_path, repo_type="dataset", token=token, ) api.upload_file( repo_id=repo_id, path_in_repo="chunks.json", path_or_fileobj=chunks_path, repo_type="dataset", token=token, ) api.upload_file( repo_id=repo_id, path_in_repo="last_updated.txt", path_or_fileobj=ts_path, repo_type="dataset", token=token, ) logger.info(f"Uploaded to HF Hub: {repo_id}") return repo_id # ── Main Pipeline ────────────────────────────────────────────────────────── def main(): hf_token = os.environ.get("HF_TOKEN") if not hf_token: logger.error("HF_TOKEN environment variable required") logger.error("Usage: HF_TOKEN=hf_yourtoken python3 scripts/build_index.py") return total_start = time.time() # Step 1: Query SPARQL docs = query_all_documents() if not docs: logger.error("No documents found — SPARQL query returned empty") return logger.info(f"Documents to process: {len(docs)}") # Step 2: Download all documents in parallel logger.info(f"Downloading documents ({DOWNLOAD_WORKERS} workers)...") html_results = {} with ThreadPoolExecutor(max_workers=DOWNLOAD_WORKERS) as executor: future_map = { executor.submit(fetch_document_xhtml, doc): doc for doc in docs } for future in tqdm(as_completed(future_map), total=len(docs), desc="Downloading"): doc = future_map[future] try: html = future.result() if html: html_results[doc["celex"]] = html except Exception as e: logger.debug(f"Failed {doc['celex']}: {e}") logger.info(f"Downloaded {len(html_results)}/{len(docs)} documents successfully") # Step 3: Parse all documents into chunks logger.info("Parsing HTML into chunks...") all_chunks = [] for doc in tqdm(docs, desc="Parsing"): celex = doc["celex"] html = html_results.get(celex) if not html: continue chunks = parse_html_to_chunks(html, celex, doc["title"]) all_chunks.extend(chunks) logger.info(f"Total chunks: {len(all_chunks)}") if not all_chunks: logger.error("No chunks produced — check parsing") return # Step 4: Embed all chunks vectors = embed_chunks(all_chunks) logger.info(f"Embedding complete: {vectors.shape}") # Step 5: Upload to HF Hub repo_id = upload_to_hub(vectors, all_chunks, HF_DATASET_NAME, hf_token) total_time = time.time() - total_start logger.info(f"Build complete in {total_time / 60:.1f} minutes") logger.info(f"Dataset: {repo_id}") logger.info(f"Documents: {len(html_results)} | Chunks: {len(all_chunks)} | Dims: {vectors.shape[1]}") if __name__ == "__main__": main() ``` ### Step 1.2 — Run the build > **⏱ Estimated runtime: 3-5 hours.** This downloads ~30K-80K documents (depending on date filter) and embeds ~100K-200K chunks. You can leave it running overnight. ```bash source ~/Desktop/EUProjects/.venv/bin/activate cd ~/Desktop/EUProjects/eur-lex-ai-chat HF_TOKEN=hf_your_write_token python3 scripts/build_index.py 2>&1 | tee build_log.txt ``` Expected output milestones: ``` Querying SPARQL for documents... Fetched 1000 documents so far... Fetched 2000 documents so far... ... Total documents found: XXXX Downloading documents (20 workers)... Downloading: 100%|████████████| XXXX/XXXX [XX:XX<00:00] Downloaded XXXX/XXXX documents successfully Parsing HTML into chunks... Total chunks: XXXX Loading embedding model: all-MiniLM-L6-v2 Embedding XXXX chunks in batches of 128... Embedding complete: (XXXX, 384) Saved vectors: (XXXX, 384) (XX.X MB) Saved chunks: XXXX items (XX.X MB) Uploaded to HF Hub: yourusername/eurlex-chat-data Build complete in XXX.X minutes ``` ### Step 1.3 — Verify the upload ```bash source ~/Desktop/EUProjects/.venv/bin/activate python3 -c " from huggingface_hub import hf_hub_download import numpy as np, json # Download vectors vectors_path = hf_hub_download('yourusername/eurlex-chat-data', 'vectors.npy') vectors = np.load(vectors_path) print(f'Vectors: {vectors.shape}, dtype={vectors.dtype}') # Download chunks chunks_path = hf_hub_download('yourusername/eurlex-chat-data', 'chunks.json') with open(chunks_path) as f: chunks = json.load(f) print(f'Chunks: {len(chunks)}') print(f'Sample: {chunks[0][\"text\"][:100]}...') print(f'Source: CELEX {chunks[0][\"celex\"]} — {chunks[0][\"title\"][:60]}...') " ``` If verification passes, **Phase 1 is complete.** The vector index is live on HuggingFace Hub. --- ## Phase 2: FastAPI Backend **Goal:** A FastAPI server that loads the vector index from HF Hub at startup, accepts chat queries, finds relevant chunks via numpy KNN, calls Groq to generate answers with citations, and provides /health and /refresh endpoints. ### Step 2.1 — Write `backend/requirements.txt` ``` fastapi==0.136.1 uvicorn==0.47.0 numpy==2.4.6 httpx==0.28.1 huggingface_hub==1.15.0 ``` ### Step 2.2 — Write `backend/data_loader.py` ```python """Download and manage the vector index from HuggingFace Hub.""" import json import logging import os import time from datetime import datetime, timezone import numpy as np from huggingface_hub import hf_hub_download, HfApi logger = logging.getLogger(__name__) # HF dataset configuration HF_USERNAME = os.environ.get("HF_USERNAME", "yourusername") HF_DATASET = os.environ.get("HF_DATASET", "eurlex-chat-data") HF_TOKEN = os.environ.get("HF_TOKEN", None) REPO_ID = f"{HF_USERNAME}/{HF_DATASET}" # Current index in memory _index_data = { "vectors": None, "chunks": None, "last_updated": None, "loaded_at": None, } def download_index(): """Download vectors.npy + chunks.json from HF Hub into memory.""" logger.info(f"Downloading index from {REPO_ID}...") try: vectors_path = hf_hub_download( repo_id=REPO_ID, filename="vectors.npy", repo_type="dataset", token=HF_TOKEN, ) chunks_path = hf_hub_download( repo_id=REPO_ID, filename="chunks.json", repo_type="dataset", token=HF_TOKEN, ) except Exception as e: logger.error(f"Failed to download from HF Hub: {e}") raise vectors = np.load(vectors_path) with open(chunks_path, "r") as f: chunks = json.load(f) _index_data["vectors"] = vectors _index_data["chunks"] = chunks _index_data["last_updated"] = _get_last_updated() _index_data["loaded_at"] = datetime.now(timezone.utc).isoformat() logger.info(f"Index loaded: {vectors.shape[0]} vectors, {len(chunks)} chunks") return _index_data def _get_last_updated(): """Get the last_updated timestamp from HF Hub.""" try: ts_path = hf_hub_download( repo_id=REPO_ID, filename="last_updated.txt", repo_type="dataset", token=HF_TOKEN, ) with open(ts_path, "r") as f: return f.read().strip() except Exception: return None def check_for_updates(): """Check if the index has been updated on HF Hub since we loaded it.""" current_remote = _get_last_updated() if current_remote and current_remote != _index_data["last_updated"]: logger.info(f"Remote index updated: {current_remote}") return True return False def reload_index(): """Re-download and reload the index.""" return download_index() def get_index(): """Get the current in-memory index.""" return _index_data def get_stats(): """Get index statistics.""" data = get_index() return { "vectors": data["vectors"].shape if data["vectors"] is not None else None, "chunks": len(data["chunks"]) if data["chunks"] is not None else 0, "last_updated": data["last_updated"], "loaded_at": data["loaded_at"], } ``` ### Step 2.3 — Write `backend/search.py` ```python """numpy KNN search over pre-loaded vectors.""" import logging import numpy as np logger = logging.getLogger(__name__) def search(query_vector, top_k=10): """Find top-k most similar chunks using brute-force cosine similarity. Uses numpy dot product since vectors are L2-normalized. Returns list of dicts with chunk data and similarity score. """ from data_loader import get_index index = get_index() vectors = index["vectors"] chunks = index["chunks"] if vectors is None or chunks is None: logger.error("Index not loaded") return [] # Cosine similarity = dot product (vectors are normalized) if query_vector.ndim == 1: query_vector = query_vector.reshape(1, -1) similarities = np.dot(vectors, query_vector.T).flatten() # Get top-k indices top_indices = np.argpartition(similarities, -top_k)[-top_k:] top_indices = top_indices[np.argsort(-similarities[top_indices])] results = [] for idx in top_indices: results.append({ "score": float(similarities[idx]), "text": chunks[idx]["text"], "celex": chunks[idx]["celex"], "title": chunks[idx]["title"], "article": chunks[idx].get("article"), }) return results ``` ### Step 2.4 — Write `backend/rag.py` ```python """Build prompts and call Groq API for RAG.""" import json import logging import os import re import httpx logger = logging.getLogger(__name__) GROQ_API_KEY = os.environ.get("GROQ_API_KEY") GROQ_MODEL = os.environ.get("GROQ_MODEL", "llama-3.3-70b-versatile") GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions" SYSTEM_PROMPT = """You are a legal AI assistant specialized in EU law. You help users understand EU legislation by answering their questions based on provided context from EUR-Lex documents. Guidelines: 1. Answer based ONLY on the provided context. If the context doesn't contain enough information, say so. 2. Always cite the specific EUR-Lex document(s) you used with their CELEX numbers. 3. When citing articles, include the article number and CELEX number. 4. Keep answers clear and accessible — explain legal concepts in plain language. 5. If the user asks in a non-English language, respond in that language. 6. Do not make up legal citations or references. Only cite what's in the context. 7. Be honest about limitations — if you're unsure, say so.""" def build_prompt(query, context_chunks): """Build the prompt with context chunks and user query.""" context_parts = [] for i, chunk in enumerate(context_chunks): source = f"[{i+1}] CELEX {chunk['celex']}" if chunk.get("article"): source += f", Article {chunk['article']}" context_parts.append(f"Context {i+1} ({source}):\n{chunk['text']}") context_str = "\n\n---\n\n".join(context_parts) prompt = f"""Here are relevant excerpts from EU law documents: {context_str} Based on the above legal texts, please answer the following question: {query}""" return prompt def call_groq(prompt, max_retries=3): """Call Groq API with the prompt and return the response.""" if not GROQ_API_KEY: logger.error("GROQ_API_KEY not set") return None headers = { "Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json", } payload = { "model": GROQ_MODEL, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], "temperature": 0.2, "max_tokens": 2048, } for attempt in range(max_retries): try: r = httpx.post( GROQ_API_URL, headers=headers, json=payload, timeout=30, ) if r.status_code == 429: logger.warning(f"Groq rate limited (attempt {attempt + 1})") if attempt < max_retries - 1: import time time.sleep(2 ** attempt) continue r.raise_for_status() data = r.json() return data["choices"][0]["message"]["content"] except httpx.HTTPStatusError as e: if e.response.status_code == 429 and attempt < max_retries - 1: import time time.sleep(2 ** attempt) continue logger.error(f"Groq API error: {e}") return None except Exception as e: logger.error(f"Groq API error: {e}") return None return None def extract_citations(text): """Extract CELEX citations from the response text.""" celex_pattern = r"CELEX\s+(\d{2,4}[A-Z0-9]+(?:\([A-Z0-9]+\))?(?:\([0-9]+\))?)" return re.findall(celex_pattern, text) def answer_question(query, context_chunks): """Full RAG pipeline: build prompt → call Groq → return answer with citations.""" prompt = build_prompt(query, context_chunks) answer = call_groq(prompt) if not answer: return { "answer": "Sorry, I couldn't generate an answer right now. Please try again.", "citations": [], } citations = extract_citations(answer) # Also add sources from our context source_citations = [] seen = set() for chunk in context_chunks: if chunk["celex"] not in seen: seen.add(chunk["celex"]) source_citations.append({ "celex": chunk["celex"], "title": chunk["title"], "article": chunk.get("article"), "score": chunk["score"], }) return { "answer": answer, "citations": citations, "sources": source_citations, } ``` ### Step 2.5 — Write `backend/rate_limit.py` ```python """Per-IP rate limiting for the /chat endpoint.""" import time from collections import defaultdict # Rate limit configuration MAX_REQUESTS_PER_IP = 20 # Max requests per window WINDOW_SECONDS = 60 # Window size in seconds MAX_GLOBAL_PER_MINUTE = 100 # Global max _ip_counters = defaultdict(list) _global_counters = [] def is_rate_limited(client_ip): """Check if a client IP has exceeded the rate limit. Returns True if rate limited, False if request is allowed. """ now = time.time() window_start = now - WINDOW_SECONDS # Clean old entries _ip_counters[client_ip] = [ t for t in _ip_counters[client_ip] if t > window_start ] # Check IP limit if len(_ip_counters[client_ip]) >= MAX_REQUESTS_PER_IP: return True # Check global limit global _global_counters _global_counters = [t for t in _global_counters if t > window_start] if len(_global_counters) >= MAX_GLOBAL_PER_MINUTE: return True # Record this request _ip_counters[client_ip].append(now) _global_counters.append(now) return False ``` ### Step 2.6 — Write `backend/main.py` ```python """FastAPI application for EUR-Lex AI Chat.""" import logging import os from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger(__name__) # Global model instance — loaded once at startup, reused for all requests _embedding_model = None def get_embedding_model(): """Get the singleton embedding model instance.""" global _embedding_model if _embedding_model is None: from sentence_transformers import SentenceTransformer logger.info("Loading embedding model: all-MiniLM-L6-v2") _embedding_model = SentenceTransformer("all-MiniLM-L6-v2") return _embedding_model @asynccontextmanager async def lifespan(app: FastAPI): """Load the vector index and embedding model on startup.""" from data_loader import download_index logger.info("Starting up — loading index...") try: download_index() logger.info("Index loaded successfully") except Exception as e: logger.error(f"Failed to load index: {e}") # Pre-load embedding model logger.info("Pre-loading embedding model...") get_embedding_model() logger.info("Embedding model loaded") yield logger.info("Shutting down") app = FastAPI(title="EUR-Lex AI Chat API", version="1.0.0", lifespan=lifespan) # CORS — allow frontend on Vercel app.add_middleware( CORSMiddleware, allow_origins=[ "https://eurlex-chat.vercel.app", "http://localhost:4321", "http://localhost:3000", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get("/health") async def health(): """Health check endpoint — used by cron-job.org keepalive.""" from data_loader import get_stats stats = get_stats() return { "status": "ok", "index_loaded": stats["vectors"] is not None, "vector_count": stats["vectors"][0] if stats["vectors"] else 0, "chunk_count": stats["chunks"], "last_updated": stats["last_updated"], "loaded_at": stats["loaded_at"], } @app.get("/refresh") async def refresh(): """Check for index updates on HF Hub and reload if newer.""" from data_loader import check_for_updates, reload_index, get_stats try: has_updates = check_for_updates() if has_updates: logger.info("New index available, reloading...") reload_index() return {"status": "reloaded", "message": "Index updated successfully"} else: stats = get_stats() return {"status": "current", "message": "Index is up to date"} except Exception as e: logger.error(f"Refresh failed: {e}") return JSONResponse( status_code=500, content={"status": "error", "message": str(e)}, ) @app.post("/chat") async def chat(request: Request): """Main chat endpoint. Accepts {query: string}, returns {answer, sources}.""" from rate_limit import is_rate_limited from search import search from rag import answer_question # Get client IP client_ip = request.client.host if request.client else "unknown" # Rate limit check if is_rate_limited(client_ip): raise HTTPException( status_code=429, detail="Rate limit exceeded. Max 20 requests per minute per IP.", ) # Parse request body = await request.json() query = body.get("query", "").strip() if not query: raise HTTPException(status_code=400, detail="Query is required") if len(query) > 2000: raise HTTPException(status_code=400, detail="Query too long (max 2000 chars)") # Embed query using the singleton model model = get_embedding_model() query_vector = model.encode(query, normalize_embeddings=True) # Search chunks = search(query_vector, top_k=10) if not chunks: return { "answer": "I don't have enough information to answer that question. Try asking about a specific EU regulation or directive.", "citations": [], "sources": [], } # Generate answer via RAG result = answer_question(query, chunks) return result if __name__ == "__main__": import uvicorn port = int(os.environ.get("PORT", 8000)) uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False) ``` ### Step 2.7 — Write `backend/startup.sh` ```bash #!/bin/bash # Render entry point — downloads index and starts uvicorn set -e cd "$(dirname "$0")" echo "=== EUR-Lex AI Chat Backend Startup ===" echo "Python: $(python3 --version)" # The FastAPI app loads the index at startup via lifespan echo "Starting uvicorn..." exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1 ``` Make it executable: ```bash chmod +x ~/Desktop/EUProjects/eur-lex-ai-chat/backend/startup.sh ``` ### Step 2.8 — Test the backend locally ```bash source ~/Desktop/EUProjects/.venv/bin/activate cd ~/Desktop/EUProjects/eur-lex-ai-chat/backend # Start server in background uvicorn main:app --host 0.0.0.0 --port 8000 & sleep 5 # Test health endpoint curl -s http://localhost:8000/health | python3 -m json.tool # Test chat endpoint curl -s -X POST http://localhost:8000/chat \ -H "Content-Type: application/json" \ -d '{"query": "What are the requirements of the GDPR?"}' \ | python3 -m json.tool # Kill the server kill %1 2>/dev/null ``` Expected `/health` response: ```json { "status": "ok", "index_loaded": true, "vector_count": 123456, "chunk_count": 123456, "last_updated": "2026-05-20T12:00:00+00:00", "loaded_at": "2026-05-20T12:00:00+00:00" } ``` ### Step 2.9 — Create `backend/render.yaml` for Render deployment ```yaml # render.yaml — Render Blueprint for EUR-Lex AI Chat backend services: - type: web name: eurlex-chat-api runtime: python repo: https://github.com/yourusername/eur-lex-ai-chat branch: main buildCommand: pip install -r backend/requirements.txt startCommand: cd backend && ./startup.sh envVars: - key: GROQ_API_KEY sync: false - key: HF_TOKEN sync: false - key: HF_USERNAME value: yourusername - key: HF_DATASET value: eurlex-chat-data - key: PYTHON_VERSION value: 3.12.3 healthCheckPath: /health ``` We'll set the environment variables in the Render dashboard (not in the yaml for security). --- ## Phase 3: Astro Frontend **Goal:** A fully SEO-optimized Astro website with a React chat island. Ships zero-JS HTML by default. Chat widget is only interactive JS on the page. ### Step 3.1 — Scaffold Astro project ```bash source ~/.nvm/nvm.sh nvm use v22.22.3 cd ~/Desktop/EUProjects/eur-lex-ai-chat # Create Astro project in frontend/ directory npm create astro@latest frontend -- --template basics --typescript --no-install --no-git cd frontend # Install dependencies npm install astro @astrojs/react @astrojs/tailwind @astrojs/sitemap tailwindcss react react-dom # Add integrations npx astro add react --yes npx astro add tailwind --yes npx astro add sitemap --yes ``` ### Step 3.2 — Write `frontend/astro.config.mjs` ```javascript import { defineConfig } from "astro/config"; import react from "@astrojs/react"; import tailwind from "@astrojs/tailwind"; import sitemap from "@astrojs/sitemap"; export default defineConfig({ site: "https://eurlex-chat.vercel.app", integrations: [ react(), tailwind(), sitemap({ changefreq: "weekly", priority: 0.7, lastmod: new Date(), }), ], }); ``` ### Step 3.3 — Write `frontend/tailwind.config.mjs` ```javascript /** @type {import('tailwindcss').Config} */ export default { content: ["./src/**/*.{astro,html,js,jsx,mdx,tsx}"], theme: { extend: { colors: { eu: { blue: "#003399", gold: "#FFCC00", navy: "#002266", light: "#F0F4FF", }, }, }, }, plugins: [], }; ``` ### Step 3.4 — Write `frontend/src/layouts/Base.astro` ```astro --- // Base layout — used by all pages // Includes: SEO meta tags, JSON-LD, nav, footer export interface Props { title: string; description: string; canonical?: string; ogType?: string; jsonLd?: Record; } const { title, description, canonical = "https://eurlex-chat.vercel.app", ogType = "website", jsonLd, } = Astro.props; --- {title} — EUR-Lex AI Chat {/* JSON-LD */} {jsonLd &&