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