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:
parent
c3d1efc3df
commit
af339652df
2 changed files with 200 additions and 0 deletions
1
src/wetgit/ai/__init__.py
Normal file
1
src/wetgit/ai/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""WetGit AI features — samenvattingen, alerts, semantisch zoeken."""
|
||||||
199
src/wetgit/ai/summarize.py
Normal file
199
src/wetgit/ai/summarize.py
Normal 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")
|
||||||
Loading…
Add table
Reference in a new issue