feat: Fase 2/3 features — hybrid search, feed, domeinen, referenties

- Hybrid search met Reciprocal Rank Fusion (Meilisearch + Qdrant)
- Atom feed endpoint (/api/v1/feed.xml) op basis van git log
- Compliance-domeinen classificatie (NIS2, DORA, AVG, etc.)
  en endpoints /api/v1/domeinen + /api/v1/regelingen/{id}/domeinen
- Cross-referentie extractie tussen regelingen, endpoint
  /api/v1/regelingen/{id}/referenties
- Change-alerts uitgebreid met webhook-notificaties en
  optionele domein-filter
- Rate limiting via slowapi (60/min default, 30/min zoeken)
- datum_verval veld in RegelingMeta + indexer
- Celery app module (wetgit.tasks) voor sync/reindex/alerts
- REPO_PATH respecteert WETGIT_GIT_REPOS_DIR (voor Ansible deploy)
This commit is contained in:
Coornhert 2026-04-21 20:58:27 +02:00
parent 3065243f73
commit 34fd5a2bf3
8 changed files with 680 additions and 49 deletions

View file

@ -13,7 +13,6 @@ classifiers = [
"Development Status :: 2 - Pre-Alpha", "Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"Intended Audience :: Legal Industry", "Intended Audience :: Legal Industry",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Topic :: Text Processing :: Markup", "Topic :: Text Processing :: Markup",
] ]
@ -34,6 +33,9 @@ api = [
"uvicorn>=0.30", "uvicorn>=0.30",
"celery>=5.4", "celery>=5.4",
"redis>=5.0", "redis>=5.0",
"markdown>=3.5",
"jinja2>=3.1",
"slowapi>=0.1.9",
] ]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
@ -61,6 +63,9 @@ build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
"wetgit.web" = ["static/**/*", "templates/**/*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
markers = [ markers = [

View file

@ -1,21 +1,30 @@
"""Change-alerts — stuur notificaties bij wetswijzigingen. """Change-alerts — stuur notificaties bij wetswijzigingen.
Vergelijkt de huidige staat met de vorige en stuurt een e-mail Vergelijkt de huidige staat met de vorige en stuurt notificaties
met een AI-gegenereerde change-summary via AgentMail. via e-mail (AgentMail) en/of webhooks.
Ondersteunt domein-filtering (NIS2, DORA, AVG, etc.) zodat
abonnees alleen relevante wijzigingen ontvangen.
Usage: Usage:
python -m wetgit.ai.alerts --bwb-id BWBR0001840 --diff "..." python -m wetgit.ai.alerts --bwb-id BWBR0001840 --diff "..."
python -m wetgit.ai.alerts --test # Stuur test-alert python -m wetgit.ai.alerts --test # Stuur test-alert
python -m wetgit.ai.alerts --domains # Toon beschikbare domeinen
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
from datetime import date from datetime import date
import sys
import httpx import httpx
from wetgit.ai.domains import classify_regeling
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions" MISTRAL_API_URL = "https://api.mistral.ai/v1/chat/completions"
@ -36,22 +45,34 @@ def send_change_alert(
titel: str, titel: str,
diff_text: str, diff_text: str,
recipients: list[str] | None = None, recipients: list[str] | None = None,
webhooks: list[str] | None = None,
domain_filter: list[str] | None = None,
mistral_api_key: str | None = None, mistral_api_key: str | None = None,
agentmail_api_key: str | None = None, agentmail_api_key: str | None = None,
) -> bool: ) -> bool:
"""Genereer een change-summary en stuur een e-mail alert. """Genereer een change-summary en stuur notificaties.
Args: Args:
bwb_id: BWB identificatienummer van de gewijzigde regeling. bwb_id: BWB identificatienummer van de gewijzigde regeling.
titel: Titel van de regeling. titel: Titel van de regeling.
diff_text: Git diff van de wijziging. diff_text: Git diff van de wijziging.
recipients: E-mailadressen (default: coornhert@wetgit.nl). recipients: E-mailadressen (default: coornhert@wetgit.nl).
webhooks: Lijst van webhook URLs om een POST naar te sturen.
domain_filter: Alleen alert sturen als regeling bij deze domeinen hoort.
Als None, altijd sturen.
mistral_api_key: Mistral API key. mistral_api_key: Mistral API key.
agentmail_api_key: AgentMail API key. agentmail_api_key: AgentMail API key.
Returns: Returns:
True als de alert succesvol verstuurd is. True als minstens één notificatie verstuurd is.
""" """
# Domein-filtering
if domain_filter:
matched_domains = classify_regeling(titel, diff_text)
if not any(d in domain_filter for d in matched_domains):
logger.info("Regeling %s matcht niet met domeinen %s, alert overgeslagen", bwb_id, domain_filter)
return False
mistral_key = mistral_api_key or os.environ.get("MISTRAL_API_KEY", "") mistral_key = mistral_api_key or os.environ.get("MISTRAL_API_KEY", "")
agentmail_key = agentmail_api_key or os.environ.get("AGENTMAIL_API_KEY", "") agentmail_key = agentmail_api_key or os.environ.get("AGENTMAIL_API_KEY", "")
recipients = recipients or ["coornhert@wetgit.nl"] recipients = recipients or ["coornhert@wetgit.nl"]
@ -100,7 +121,7 @@ Dit is geen juridisch advies.
""" """
# Stap 4: Verstuur via AgentMail # Stap 4: Verstuur via AgentMail
return _send_email( email_ok = _send_email(
from_address="coornhert@wetgit.nl", from_address="coornhert@wetgit.nl",
to_addresses=recipients, to_addresses=recipients,
subject=subject, subject=subject,
@ -108,6 +129,23 @@ Dit is geen juridisch advies.
agentmail_key=agentmail_key, agentmail_key=agentmail_key,
) )
# Stap 5: Verstuur webhooks
webhook_ok = True
matched_domains = classify_regeling(titel, diff_text)
for url in (webhooks or []):
webhook_ok = _send_webhook(url, {
"event": "regeling.gewijzigd",
"bwb_id": bwb_id,
"titel": titel,
"datum": date.today().isoformat(),
"regels_toegevoegd": added,
"regels_verwijderd": removed,
"domeinen": matched_domains,
"samenvatting": summary,
}) and webhook_ok
return email_ok or webhook_ok
def _generate_change_summary(titel: str, diff_text: str, api_key: str) -> str | None: def _generate_change_summary(titel: str, diff_text: str, api_key: str) -> str | None:
"""Genereer een AI-samenvatting van de wetswijziging.""" """Genereer een AI-samenvatting van de wetswijziging."""
@ -170,6 +208,23 @@ def _send_email(
return False return False
def _send_webhook(url: str, payload: dict) -> bool:
"""Verstuur een webhook POST."""
try:
resp = httpx.post(
url,
json=payload,
headers={"Content-Type": "application/json", "User-Agent": "WetGIT/1.0"},
timeout=10,
)
resp.raise_for_status()
logger.info("Webhook verstuurd naar %s", url)
return True
except httpx.HTTPError as e:
logger.error("Webhook fout voor %s: %s", url, e)
return False
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
@ -177,10 +232,19 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="WetGit change alerts") parser = argparse.ArgumentParser(description="WetGit change alerts")
parser.add_argument("--test", action="store_true", help="Stuur een test-alert") parser.add_argument("--test", action="store_true", help="Stuur een test-alert")
parser.add_argument("--domains", action="store_true", help="Toon beschikbare domeinen")
parser.add_argument("--bwb-id", default="BWBR0001840") parser.add_argument("--bwb-id", default="BWBR0001840")
parser.add_argument("--to", default="coornhert@wetgit.nl") parser.add_argument("--to", default="coornhert@wetgit.nl")
parser.add_argument("--domain-filter", nargs="*", help="Filter op domeinen")
parser.add_argument("--webhook", nargs="*", help="Webhook URLs")
args = parser.parse_args() args = parser.parse_args()
if args.domains:
from wetgit.ai.domains import list_domains
for d in list_domains():
print(f" {d['naam']}: {', '.join(d['keywords'][:3])}...")
sys.exit(0)
if args.test: if args.test:
# Simuleer een wijziging in de Grondwet # Simuleer een wijziging in de Grondwet
test_diff = """--- a/wet/grondwet/BWBR0001840/README.md test_diff = """--- a/wet/grondwet/BWBR0001840/README.md

209
src/wetgit/ai/crossref.py Normal file
View file

@ -0,0 +1,209 @@
"""Cross-referentie analyse — extract verwijzingen tussen regelingen.
Parseert wetteksten op verwijzingen naar andere regelingen en bouwt
een doorzoekbare graaf op als JSON adjacency list.
Usage:
python -m wetgit.ai.crossref --repo /path/to/rijk
python -m wetgit.ai.crossref --repo /path/to/rijk --query BWBR0001840
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
logger = logging.getLogger(__name__)
# Patronen voor verwijzingen naar andere regelingen
# "de Telecommunicatiewet", "de Wet open overheid", "het Burgerlijk Wetboek"
WET_REF_PATTERN = re.compile(
r"(?:de|het|van de|in de|bij de|krachtens de|bedoeld in de)\s+"
r"((?:Wet|Algemene wet|Wetboek|Boek|Grondwet|Besluit|Regeling|Verordening)"
r"(?:\s+(?:op|van|tot|inzake|betreffende|ter))?"
r"(?:\s+\w+){0,6}?)"
r"(?=[,.\s;)])",
re.IGNORECASE,
)
# "artikel 6 van de AVG", "artikel 1.1, tweede lid"
ARTIKEL_REF_PATTERN = re.compile(
r"artikel(?:en)?\s+([\d.]+(?:\s*(?:,\s*[\d.]+|tot en met\s+[\d.]+))*)"
r"(?:\s*,?\s*(?:eerste|tweede|derde|vierde|vijfde|zesde|zevende|achtste|negende|tiende)\s+lid)?"
r"(?:\s+van\s+(?:de|het)\s+(.+?))?(?=[,.\s;)])",
re.IGNORECASE,
)
# BWB-ID verwijzingen (zeldzaam in tekst, maar soms in metadata)
BWB_REF_PATTERN = re.compile(r"BWBR\d{7}")
def extract_references(bwb_id: str, tekst: str) -> list[dict]:
"""Extraheer verwijzingen uit een wettekst.
Args:
bwb_id: BWB-ID van de bronregeling.
tekst: Volledige Markdown tekst.
Returns:
Lijst van dicts met bron, doel, type, en context.
"""
refs: list[dict] = []
seen: set[str] = set()
# Zoek directe BWB-ID verwijzingen
for match in BWB_REF_PATTERN.finditer(tekst):
target_bwb = match.group(0)
if target_bwb != bwb_id and target_bwb not in seen:
seen.add(target_bwb)
start = max(0, match.start() - 50)
end = min(len(tekst), match.end() + 50)
refs.append({
"bron": bwb_id,
"doel": target_bwb,
"type": "bwb_id",
"context": tekst[start:end].strip(),
})
# Zoek wet-naam verwijzingen
for match in WET_REF_PATTERN.finditer(tekst):
wet_naam = match.group(1).strip().rstrip(".,;")
if len(wet_naam) < 5 or wet_naam.lower() in ("wet", "wetboek", "besluit"):
continue
ref_key = wet_naam.lower()
if ref_key not in seen:
seen.add(ref_key)
start = max(0, match.start() - 30)
end = min(len(tekst), match.end() + 30)
refs.append({
"bron": bwb_id,
"doel_naam": wet_naam,
"type": "wet_naam",
"context": tekst[start:end].strip(),
})
return refs
def build_reference_graph(repo_path: Path) -> dict:
"""Bouw de volledige cross-referentie graaf.
Args:
repo_path: Pad naar de wetgit/rijk repo.
Returns:
Dict met nodes (regelingen) en edges (verwijzingen).
"""
index_path = repo_path / "index.json"
if index_path.exists():
data = json.loads(index_path.read_text(encoding="utf-8"))
regelingen = data.get("regelingen", [])
else:
from wetgit.pipeline.indexer import generate_index
regelingen = generate_index(repo_path)
# Bouw titel → bwb_id lookup
titel_to_bwb: dict[str, str] = {}
for r in regelingen:
titel_to_bwb[r.get("titel", "").lower()] = r["bwb_id"]
if r.get("citeertitel"):
titel_to_bwb[r["citeertitel"].lower()] = r["bwb_id"]
all_refs: list[dict] = []
edges: dict[str, list[str]] = {} # bwb_id → [verwezen bwb_ids]
for regeling in regelingen:
bwb_id = regeling["bwb_id"]
md_path = repo_path / regeling["pad"] / "README.md"
if not md_path.exists():
continue
tekst = md_path.read_text(encoding="utf-8")
refs = extract_references(bwb_id, tekst)
targets: list[str] = []
for ref in refs:
# Probeer wet_naam te resolven naar bwb_id
if ref["type"] == "wet_naam":
doel_naam = ref["doel_naam"].lower()
resolved = titel_to_bwb.get(doel_naam)
if resolved:
ref["doel"] = resolved
targets.append(ref.get("doel", ref.get("doel_naam", "")))
else:
targets.append(ref["doel"])
all_refs.append(ref)
if targets:
edges[bwb_id] = list(set(targets))
# Bereken inbound references
inbound: dict[str, list[str]] = {}
for src, dests in edges.items():
for dest in dests:
inbound.setdefault(dest, []).append(src)
logger.info(
"Graaf: %d regelingen met verwijzingen, %d unieke edges",
len(edges),
sum(len(v) for v in edges.values()),
)
return {
"nodes": len(edges),
"total_edges": sum(len(v) for v in edges.values()),
"outbound": edges,
"inbound": inbound,
"references": all_refs,
}
def query_references(
graph: dict, bwb_id: str, direction: str = "both",
) -> dict:
"""Query verwijzingen voor een specifieke regeling.
Args:
graph: De cross-referentie graaf.
bwb_id: BWB-ID om te querien.
direction: "outbound" (verwijst naar), "inbound" (verwezen door), "both".
Returns:
Dict met verwijzingen in de gevraagde richting.
"""
result: dict = {"bwb_id": bwb_id}
if direction in ("outbound", "both"):
result["verwijst_naar"] = graph.get("outbound", {}).get(bwb_id, [])
if direction in ("inbound", "both"):
result["verwezen_door"] = graph.get("inbound", {}).get(bwb_id, [])
return result
if __name__ == "__main__":
import argparse
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="WetGit cross-referentie analyse")
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--query", help="BWB-ID om te querien")
parser.add_argument("--output", type=Path, help="Schrijf graaf naar JSON bestand")
args = parser.parse_args()
graph = build_reference_graph(args.repo)
print(f"Graaf: {graph['nodes']} regelingen, {graph['total_edges']} verwijzingen")
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
json.dump(graph, f, ensure_ascii=False, indent=2)
print(f"Geschreven: {args.output}")
if args.query:
result = query_references(graph, args.query)
print(json.dumps(result, ensure_ascii=False, indent=2))

112
src/wetgit/ai/domains.py Normal file
View file

@ -0,0 +1,112 @@
"""Domein-classificatie voor change-alerts.
Classificeert regelingen naar compliance-domeinen (NIS2, DORA, AVG, etc.)
op basis van keyword-matching in titel en tekst.
Domeinen zijn gedefinieerd als sets van zoektermen. Een regeling hoort bij
een domein als minstens één term voorkomt in de titel of tekst.
"""
from __future__ import annotations
import re
# Domein-definities: naam → keywords die matchen in titel/tekst
DOMAINS: dict[str, list[str]] = {
"nis2-cybersecurity": [
"netwerk- en informatiebeveiliging",
"NIS2",
"cybersecurity",
"cyberbeveiligingswet",
"beveiligingsincident",
"digitale weerbaarheid",
"CSIRT",
"Telecommunicatiewet",
"Wet beveiliging netwerk- en informatiesystemen",
],
"dora-financieel": [
"DORA",
"digitale operationele veerkracht",
"financiële sector",
"Wet op het financieel toezicht",
"DNB",
"AFM",
"ICT-risicobeheer",
"Pensioenwet",
"Bankwet",
],
"avg-privacy": [
"persoonsgegevens",
"AVG",
"GDPR",
"Uitvoeringswet Algemene verordening gegevensbescherming",
"verwerking",
"Autoriteit Persoonsgegevens",
"gegevensbescherming",
"privacy",
"betrokkene",
],
"omgevingswet": [
"Omgevingswet",
"omgevingsplan",
"omgevingsvergunning",
"omgevingsvisie",
"Besluit activiteiten leefomgeving",
"Besluit kwaliteit leefomgeving",
"milieueffectrapportage",
],
"arbeidsrecht": [
"arbeidsovereenkomst",
"Burgerlijk Wetboek Boek 7",
"Arbeidstijdenwet",
"Arbeidsomstandighedenwet",
"Wet minimumloon",
"Wet werk en zekerheid",
"Ontslagrecht",
"CAO",
"Wet arbeid vreemdelingen",
"WIA",
"Werkloosheidswet",
],
"belastingrecht": [
"Wet inkomstenbelasting",
"Wet op de vennootschapsbelasting",
"Wet op de omzetbelasting",
"Algemene wet inzake rijksbelastingen",
"Invorderingswet",
"belastingplichtige",
],
}
def classify_regeling(titel: str, tekst: str | None = None) -> list[str]:
"""Classificeer een regeling naar domeinen.
Args:
titel: Titel van de regeling.
tekst: Optioneel: volledige tekst (voor diepere matching).
Returns:
Lijst van domein-namen waar de regeling bij hoort.
"""
search_text = titel.lower()
if tekst:
# Eerste 5000 chars is genoeg voor domein-detectie
search_text += " " + tekst[:5000].lower()
matched: list[str] = []
for domain, keywords in DOMAINS.items():
for kw in keywords:
if kw.lower() in search_text:
matched.append(domain)
break
return matched
def list_domains() -> list[dict[str, str | list[str]]]:
"""Lijst van alle beschikbare domeinen met hun keywords."""
return [
{"naam": name, "keywords": keywords}
for name, keywords in DOMAINS.items()
]

View file

@ -9,11 +9,16 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from wetgit import __version__ from wetgit import __version__
from wetgit.api.data import RegelingStore from wetgit.api.data import RegelingStore
from fastapi.responses import Response
from wetgit.api.models import ( from wetgit.api.models import (
ArtikelDetail, ArtikelDetail,
ArtikelItem, ArtikelItem,
@ -25,10 +30,13 @@ from wetgit.api.models import (
ZoekResultaat, ZoekResultaat,
) )
REPO_PATH = Path(os.environ.get("WETGIT_REPO", "/tmp/wetgit-index-test")) _git_repos = os.environ.get("WETGIT_GIT_REPOS_DIR", os.environ.get("WETGIT_REPO", "/data/wetgit/git-repos"))
REPO_PATH = Path(_git_repos) / "rijk" if "WETGIT_GIT_REPOS_DIR" in os.environ else Path(_git_repos)
MEILI_URL = os.environ.get("MEILI_URL", "http://127.0.0.1:7700") MEILI_URL = os.environ.get("MEILI_URL", "http://127.0.0.1:7700")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333") QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"])
app = FastAPI( app = FastAPI(
title="WetGit API", title="WetGit API",
description="Nederlandse wetgeving als code — REST API", description="Nederlandse wetgeving als code — REST API",
@ -38,6 +46,9 @@ app = FastAPI(
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json",
) )
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@ -156,63 +167,182 @@ def get_diff(
# --- Zoeken --- # --- Zoeken ---
@app.get("/api/v1/zoeken", response_model=list[ZoekResultaat]) @app.get("/api/v1/zoeken", response_model=list[ZoekResultaat])
@limiter.limit("30/minute")
def zoeken( def zoeken(
request: Request,
q: str = Query(..., min_length=2, description="Zoekterm"), q: str = Query(..., min_length=2, description="Zoekterm"),
type: str | None = Query(None, description="Filter op type"), type: str | None = Query(None, description="Filter op type"),
mode: str = Query("keyword", description="Zoekmodus: keyword, semantic, of hybrid"), mode: str = Query("keyword", description="Zoekmodus: keyword, semantic, of hybrid"),
limit: int = Query(20, ge=1, le=100, description="Max resultaten"), limit: int = Query(20, ge=1, le=100, description="Max resultaten"),
) -> list[dict]: ) -> list[dict]:
"""Doorzoek alle wetgeving. Modes: keyword (Meilisearch), semantic (Qdrant), hybrid (beide).""" """Doorzoek alle wetgeving. Modes: keyword (Meilisearch), semantic (Qdrant), hybrid (beide)."""
from wetgit.api.search import MeiliSearch
from wetgit.ai.semantic import SemanticSearch
semantic_results: list[dict] = []
keyword_results: list[dict] = []
# Semantic search # Semantic search
if mode in ("semantic", "hybrid"): if mode in ("semantic", "hybrid"):
from wetgit.ai.semantic import SemanticSearch
sem = SemanticSearch(qdrant_url=QDRANT_URL) sem = SemanticSearch(qdrant_url=QDRANT_URL)
if sem.health(): if sem.health():
results = sem.search(q, limit=limit) semantic_results = sem.search(q, limit=limit)
if mode == "semantic": if mode == "semantic":
return results return semantic_results
# Hybrid: combineer met keyword # Keyword search (Meilisearch → grep fallback)
semantic_results = {r["artikel"]: r for r in results} if mode in ("keyword", "hybrid"):
meili = MeiliSearch(url=MEILI_URL)
if meili.health():
filter_str = f'type = "{type}"' if type else None
result = meili.search(q, filter_=filter_str, limit=limit)
keyword_results = [
{
"bwb_id": hit["bwb_id"],
"titel": hit.get("regeling_titel", ""),
"artikel": f"Artikel {hit.get('artikel_nummer', '?')}",
"context": hit.get("tekst", "")[:200],
}
for hit in result.get("hits", [])
]
else:
# Fallback: grep-style zoeken
for regeling in store.list_regelingen():
if type and regeling.get("type") != type:
continue
tekst = store.get_tekst(regeling["bwb_id"])
if tekst is None or q.lower() not in tekst.lower():
continue
current_artikel = ""
for line in tekst.split("\n"):
if line.startswith("### Artikel"):
current_artikel = line.replace("### ", "")
if q.lower() in line.lower() and current_artikel:
keyword_results.append({
"bwb_id": regeling["bwb_id"],
"titel": regeling.get("titel", ""),
"artikel": current_artikel,
"context": line.strip()[:200],
})
if len(keyword_results) >= limit:
break
from wetgit.api.search import MeiliSearch if mode == "keyword":
meili = MeiliSearch(url=MEILI_URL) return keyword_results
# Probeer Meilisearch, fallback naar grep # Hybrid: Reciprocal Rank Fusion (RRF)
if meili.health(): k = 60 # RRF constant
filter_str = f'type = "{type}"' if type else None scores: dict[str, float] = {}
result = meili.search(q, filter_=filter_str, limit=limit) items: dict[str, dict] = {}
return [ for rank, r in enumerate(semantic_results):
{ key = f"{r['bwb_id']}_{r['artikel']}"
"bwb_id": hit["bwb_id"], scores[key] = scores.get(key, 0) + 1 / (k + rank + 1)
"titel": hit.get("regeling_titel", ""), items[key] = r
"artikel": f"Artikel {hit.get('artikel_nummer', '?')}",
"context": hit.get("tekst", "")[:200],
}
for hit in result.get("hits", [])
]
# Fallback: grep-style zoeken for rank, r in enumerate(keyword_results):
resultaten: list[dict] = [] key = f"{r['bwb_id']}_{r['artikel']}"
for regeling in store.list_regelingen(): scores[key] = scores.get(key, 0) + 1 / (k + rank + 1)
if type and regeling.get("type") != type: if key not in items:
items[key] = r
fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [
{**items[key], "score": round(score, 4)} for key, score in fused[:limit]
]
# --- Feed ---
@app.get("/api/v1/feed.xml", response_class=Response)
def feed(limit: int = Query(50, ge=1, le=200, description="Max entries")) -> Response:
"""Atom feed van recente wijzigingen in de wetgeving."""
import subprocess
from datetime import datetime
try:
result = subprocess.run(
["git", "log", f"--max-count={limit}", "--format=%H%n%ai%n%s%n---"],
cwd=store.repo_path, capture_output=True, text=True, check=True,
)
except subprocess.CalledProcessError:
return Response(content="<feed/>", media_type="application/atom+xml")
entries = []
lines = result.stdout.strip().split("\n---\n")
for block in lines:
parts = block.strip().split("\n")
if len(parts) < 3:
continue continue
tekst = store.get_tekst(regeling["bwb_id"]) commit_hash, date_str, subject = parts[0], parts[1], parts[2]
if tekst is None or q.lower() not in tekst.lower(): # Parse "2026-03-30 12:00:00 +0200"
continue dt = date_str.split(" +")[0].split(" -")[0]
current_artikel = "" entries.append(
for line in tekst.split("\n"): f' <entry>\n'
if line.startswith("### Artikel"): f' <title>{_xml_escape(subject)}</title>\n'
current_artikel = line.replace("### ", "") f' <id>urn:wetgit:commit:{commit_hash}</id>\n'
if q.lower() in line.lower() and current_artikel: f' <updated>{dt.replace(" ", "T")}Z</updated>\n'
resultaten.append({ f' <link href="https://git.wetgit.nl/wetgit/rijk/commit/{commit_hash}"/>\n'
"bwb_id": regeling["bwb_id"], f' <summary>{_xml_escape(subject)}</summary>\n'
"titel": regeling.get("titel", ""), f' </entry>'
"artikel": current_artikel, )
"context": line.strip()[:200],
}) now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
if len(resultaten) >= limit: atom = (
return resultaten '<?xml version="1.0" encoding="utf-8"?>\n'
return resultaten '<feed xmlns="http://www.w3.org/2005/Atom">\n'
f' <title>WetGIT — Wijzigingen in Nederlandse wetgeving</title>\n'
f' <link href="https://api.wetgit.nl/api/v1/feed.xml" rel="self"/>\n'
f' <link href="https://wetgit.nl"/>\n'
f' <id>urn:wetgit:feed:wijzigingen</id>\n'
f' <updated>{now}</updated>\n'
f' <subtitle>Elke wet een Markdown-bestand, elke wijziging een Git-commit.</subtitle>\n'
+ "\n".join(entries)
+ "\n</feed>"
)
return Response(content=atom, media_type="application/atom+xml")
def _xml_escape(s: str) -> str:
"""Escape XML special characters."""
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
# --- Domeinen ---
@app.get("/api/v1/domeinen")
def list_domeinen() -> list[dict]:
"""Lijst van beschikbare compliance-domeinen voor alerts."""
from wetgit.ai.domains import list_domains
return list_domains()
@app.get("/api/v1/regelingen/{bwb_id}/domeinen")
def get_regeling_domeinen(bwb_id: str) -> dict:
"""Classificeer een regeling naar compliance-domeinen."""
from wetgit.ai.domains import classify_regeling as classify
regeling = store.get_regeling(bwb_id)
if not regeling:
raise HTTPException(status_code=404, detail=f"Regeling {bwb_id} niet gevonden")
tekst = store.get_tekst(bwb_id)
domeinen = classify(regeling.get("titel", ""), tekst)
return {"bwb_id": bwb_id, "domeinen": domeinen}
# --- Cross-referenties ---
@app.get("/api/v1/regelingen/{bwb_id}/referenties")
def get_referenties(
bwb_id: str,
richting: str = Query("both", description="outbound, inbound, of both"),
) -> dict:
"""Toon cross-referenties van/naar een regeling."""
from wetgit.ai.crossref import extract_references
regeling = store.get_regeling(bwb_id)
if not regeling:
raise HTTPException(status_code=404, detail=f"Regeling {bwb_id} niet gevonden")
tekst = store.get_tekst(bwb_id)
if tekst is None:
return {"bwb_id": bwb_id, "referenties": []}
refs = extract_references(bwb_id, tekst)
return {"bwb_id": bwb_id, "referenties": refs, "aantal": len(refs)}

View file

@ -14,6 +14,7 @@ class RegelingMeta(BaseModel):
type: str type: str
status: str status: str
datum_inwerkingtreding: str | None = None datum_inwerkingtreding: str | None = None
datum_verval: str | None = None
bron: str bron: str
pad: str pad: str
artikelen: int artikelen: int

View file

@ -73,6 +73,7 @@ def _parse_regeling(md_path: Path, repo_path: Path) -> dict | None:
"type": meta.get("type", ""), "type": meta.get("type", ""),
"status": meta.get("status", ""), "status": meta.get("status", ""),
"datum_inwerkingtreding": meta.get("datum_inwerkingtreding"), "datum_inwerkingtreding": meta.get("datum_inwerkingtreding"),
"datum_verval": meta.get("datum_verval"),
"bron": meta.get("bron", ""), "bron": meta.get("bron", ""),
"pad": rel_path, "pad": rel_path,
"artikelen": artikel_count, "artikelen": artikel_count,

109
src/wetgit/tasks.py Normal file
View file

@ -0,0 +1,109 @@
"""Celery achtergrondtaken voor WetGIT.
Taken voor dagelijkse sync, alert-verwerking en indexering.
Usage:
celery -A wetgit.tasks worker --loglevel=info
celery -A wetgit.tasks beat --loglevel=info # voor scheduling
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from celery import Celery
from celery.schedules import crontab
logger = logging.getLogger(__name__)
REDIS_URL = os.environ.get("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0")
RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/1")
REPO_PATH = Path(os.environ.get("WETGIT_GIT_REPOS_DIR", "/data/wetgit/git-repos"))
app = Celery("wetgit", broker=REDIS_URL, backend=RESULT_BACKEND)
app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="Europe/Amsterdam",
task_track_started=True,
beat_schedule={
"daily-sync": {
"task": "wetgit.tasks.daily_sync",
"schedule": crontab(hour=3, minute=0),
},
"daily-index": {
"task": "wetgit.tasks.reindex",
"schedule": crontab(hour=3, minute=30),
},
},
)
@app.task(name="wetgit.tasks.daily_sync")
def daily_sync() -> dict:
"""Voer de dagelijkse sync uit (SRU delta-updates)."""
from wetgit.pipeline.sync import run_sync
rijk_repo = REPO_PATH / "rijk"
xml_cache = Path(os.environ.get("WETGIT_DATA_DIR", "/data/wetgit")) / "xml-cache"
result = run_sync(
rijk_repo=rijk_repo,
xml_cache=xml_cache,
)
logger.info("Sync voltooid: %s", result)
return result
@app.task(name="wetgit.tasks.reindex")
def reindex() -> dict:
"""Herindexeer Meilisearch en Qdrant."""
meili_url = os.environ.get("MEILI_URL", "http://127.0.0.1:7700")
qdrant_url = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
rijk_repo = REPO_PATH / "rijk"
results: dict = {}
# Meilisearch
try:
from wetgit.api.search import index_repo as meili_index
results["meilisearch"] = meili_index(rijk_repo, meili_url)
except Exception as e:
logger.error("Meilisearch indexering mislukt: %s", e)
results["meilisearch_error"] = str(e)
# Qdrant
try:
from wetgit.ai.semantic import index_repo as qdrant_index
results["qdrant"] = qdrant_index(rijk_repo, qdrant_url)
except Exception as e:
logger.error("Qdrant indexering mislukt: %s", e)
results["qdrant_error"] = str(e)
return results
@app.task(name="wetgit.tasks.send_alert")
def send_alert(
bwb_id: str,
titel: str,
diff_text: str,
recipients: list[str] | None = None,
webhooks: list[str] | None = None,
domain_filter: list[str] | None = None,
) -> bool:
"""Verstuur een change-alert als achtergrondtaak."""
from wetgit.ai.alerts import send_change_alert
return send_change_alert(
bwb_id=bwb_id,
titel=titel,
diff_text=diff_text,
recipients=recipients,
webhooks=webhooks,
domain_filter=domain_filter,
)