feat: MVP AI-samenvattingen via Mistral

- summarize.py: genereert B1-niveau samenvattingen per hoofdstuk
- Mistral Large API met lage temperature (0.3)
- Disclaimer en metadata in elk summary.md bestand
- Getest: Grondwet — 8 hoofdstukken samengevat in begrijpelijk Nederlands

Sluit #36
This commit is contained in:
Coornhert 2026-03-30 10:38:55 +02:00
parent c3d1efc3df
commit af339652df
2 changed files with 200 additions and 0 deletions

View file

@ -0,0 +1 @@
"""WetGit AI features — samenvattingen, alerts, semantisch zoeken."""

199
src/wetgit/ai/summarize.py Normal file
View file

@ -0,0 +1,199 @@
"""AI-samenvattingen van wetgeving in begrijpelijk Nederlands.
Genereert B1-niveau samenvattingen per hoofdstuk/deel via Mistral API.
Usage:
python -m wetgit.ai.summarize --bwb-id BWBR0001840 --repo /path/to/rijk
"""
from __future__ import annotations
import logging
import os
import re
from datetime import date
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"
DEFAULT_MODEL = "mistral-large-latest"
SYSTEM_PROMPT = """Je bent een juridisch communicatie-expert die Nederlandse wetgeving samenvat in begrijpelijk Nederlands (B1-niveau).
Regels:
- Gebruik korte zinnen (max 15 woorden)
- Vermijd juridisch jargon leg het uit in dagelijkse taal
- Gebruik concrete voorbeelden waar mogelijk
- Schrijf in de tegenwoordige tijd
- Richt je tot de lezer als "je" of "iedereen"
- Begin NIET met "Dit hoofdstuk gaat over..." maar spring direct in de inhoud
- Maximaal 200 woorden per samenvatting
- Schrijf in het Nederlands"""
DISCLAIMER = (
"> **Let op:** Dit is een AI-gegenereerde samenvatting en geen juridisch advies. "
"Raadpleeg altijd [wetten.overheid.nl](https://wetten.overheid.nl) voor de officiële tekst."
)
def summarize_regeling(
bwb_id: str,
repo_path: Path,
model: str = DEFAULT_MODEL,
api_key: str | None = None,
) -> Path | None:
"""Genereer een samenvatting voor een regeling.
Args:
bwb_id: BWB identificatienummer.
repo_path: Pad naar wetgit/rijk repo.
model: Mistral model naam.
api_key: Mistral API key (of uit MISTRAL_API_KEY env var).
Returns:
Pad naar het gegenereerde summary.md bestand, of None bij fout.
"""
api_key = api_key or os.environ.get("MISTRAL_API_KEY")
if not api_key:
raise ValueError("MISTRAL_API_KEY niet gevonden")
# Zoek de regeling
md_path = _find_md(repo_path, bwb_id)
if md_path is None:
logger.error("Regeling %s niet gevonden", bwb_id)
return None
tekst = md_path.read_text(encoding="utf-8")
# Splits in hoofdstukken/delen
chunks = _split_into_chunks(tekst)
if not chunks:
logger.warning("Geen hoofdstukken gevonden in %s, samenvatting van hele tekst", bwb_id)
chunks = [("Volledige tekst", tekst)]
# Haal titel op
titel = ""
for line in tekst.split("\n"):
if line.startswith("titel:"):
titel = line.split(":", 1)[1].strip().strip('"')
break
titel = titel or bwb_id
# Genereer samenvattingen
summaries: list[str] = []
summaries.append(f"# Samenvatting: {titel}\n")
summaries.append(DISCLAIMER + "\n")
summaries.append(f"*Gegenereerd op {date.today().isoformat()} met {model}*\n")
for chunk_title, chunk_text in chunks:
logger.info(" Samenvatting voor: %s", chunk_title)
summary = _call_mistral(chunk_title, chunk_text, titel, model, api_key)
if summary:
summaries.append(f"## {chunk_title}\n\n{summary}\n")
else:
summaries.append(f"## {chunk_title}\n\n*Samenvatting niet beschikbaar.*\n")
# Schrijf summary.md
summary_path = md_path.parent / "summary.md"
summary_content = "\n".join(summaries)
summary_path.write_text(summary_content, encoding="utf-8")
logger.info("Samenvatting geschreven: %s", summary_path)
return summary_path
def _split_into_chunks(tekst: str) -> list[tuple[str, str]]:
"""Splits tekst in hoofdstukken/delen."""
chunks: list[tuple[str, str]] = []
pattern = r"(## .+?)(?=\n## |\Z)"
for match in re.finditer(pattern, tekst, re.DOTALL):
section = match.group(1).strip()
first_line = section.split("\n")[0]
title = first_line.lstrip("#").strip()
# Skip zeer korte secties
if len(section) > 100:
chunks.append((title, section))
return chunks
def _call_mistral(
chunk_title: str,
chunk_text: str,
regeling_titel: str,
model: str,
api_key: str,
) -> str | None:
"""Roep Mistral API aan voor een samenvatting."""
# Beperk input tot ~4000 tokens (~16000 chars)
if len(chunk_text) > 16000:
chunk_text = chunk_text[:16000] + "\n\n[...tekst ingekort...]"
user_prompt = (
f"Vat het volgende deel van de {regeling_titel} samen in begrijpelijk Nederlands "
f"(B1-niveau, max 200 woorden).\n\n"
f"Sectie: {chunk_title}\n\n"
f"---\n\n{chunk_text}"
)
try:
resp = httpx.post(
MISTRAL_API_URL,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"temperature": 0.3,
"max_tokens": 500,
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"].strip()
except httpx.HTTPError as e:
logger.error("Mistral API fout: %s", e)
return None
except (KeyError, IndexError) as e:
logger.error("Onverwacht Mistral response: %s", e)
return None
def _find_md(repo_path: Path, bwb_id: str) -> Path | None:
"""Zoek het Markdown bestand voor een BWB-ID."""
for md in repo_path.rglob("README.md"):
if bwb_id in str(md):
return md
return None
if __name__ == "__main__":
import argparse
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
parser = argparse.ArgumentParser(description="WetGit AI-samenvatting")
parser.add_argument("--bwb-id", required=True, help="BWB identificatienummer")
parser.add_argument("--repo", type=Path, required=True, help="Pad naar wetgit/rijk repo")
parser.add_argument("--model", default=DEFAULT_MODEL, help="Mistral model")
args = parser.parse_args()
result = summarize_regeling(args.bwb_id, args.repo, model=args.model)
if result:
print(f"Samenvatting: {result}")
else:
print("Fout bij genereren samenvatting")