From af339652dfcf266544c22e09c2d7c15156246569 Mon Sep 17 00:00:00 2001 From: Coornhert Date: Mon, 30 Mar 2026 10:38:55 +0200 Subject: [PATCH] feat: MVP AI-samenvattingen via Mistral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/wetgit/ai/__init__.py | 1 + src/wetgit/ai/summarize.py | 199 +++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/wetgit/ai/__init__.py create mode 100644 src/wetgit/ai/summarize.py diff --git a/src/wetgit/ai/__init__.py b/src/wetgit/ai/__init__.py new file mode 100644 index 0000000..830a998 --- /dev/null +++ b/src/wetgit/ai/__init__.py @@ -0,0 +1 @@ +"""WetGit AI features — samenvattingen, alerts, semantisch zoeken.""" diff --git a/src/wetgit/ai/summarize.py b/src/wetgit/ai/summarize.py new file mode 100644 index 0000000..2ab4e45 --- /dev/null +++ b/src/wetgit/ai/summarize.py @@ -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")