Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions agent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Censor entity names and workspace path segments in output (UI-only).",
)
parser.add_argument(
"--locale",
choices=["us", "de"],
default=None,
help="Locale for data sources and entity resolution (default: us).",
)
return parser


Expand Down Expand Up @@ -316,6 +322,8 @@ def _apply_runtime_overrides(cfg: AgentConfig, args: argparse.Namespace, creds:
cfg.acceptance_criteria = True
if args.demo:
cfg.demo = True
if args.locale:
cfg.locale = args.locale


def run_plain_repl(ctx: ChatContext) -> None:
Expand Down
2 changes: 2 additions & 0 deletions agent/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ def build_engine(cfg: AgentConfig) -> RLMEngine:
max_search_hits=cfg.max_search_hits,
exa_api_key=cfg.exa_api_key,
exa_base_url=cfg.exa_base_url,
locale=cfg.locale,
lobbyregister_api_key=cfg.lobbyregister_api_key,
)

try:
Expand Down
4 changes: 4 additions & 0 deletions agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class AgentConfig:
acceptance_criteria: bool = True
max_plan_chars: int = 40_000
demo: bool = False
locale: str = "us"
lobbyregister_api_key: str = "5bHB2zrUuHR6YdPoZygQhWfg2CBrjUOi"

@classmethod
def from_env(cls, workspace: str | Path) -> "AgentConfig":
Expand Down Expand Up @@ -100,4 +102,6 @@ def from_env(cls, workspace: str | Path) -> "AgentConfig":
acceptance_criteria=os.getenv("OPENPLANTER_ACCEPTANCE_CRITERIA", "true").strip().lower() in ("1", "true", "yes"),
max_plan_chars=int(os.getenv("OPENPLANTER_MAX_PLAN_CHARS", "40000")),
demo=os.getenv("OPENPLANTER_DEMO", "").strip().lower() in ("1", "true", "yes"),
locale=os.getenv("OPENPLANTER_LOCALE", "us").strip().lower(),
lobbyregister_api_key=os.getenv("OPENPLANTER_LOBBYREGISTER_API_KEY", "5bHB2zrUuHR6YdPoZygQhWfg2CBrjUOi"),
)
94 changes: 94 additions & 0 deletions agent/connectors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""German/EU data source connectors for OpenPlanter.

Shared HTTP helper following the urllib.request pattern from tools.py.
"""
from __future__ import annotations

import json
import urllib.error
import urllib.request
import urllib.parse
from typing import Any


class ConnectorError(RuntimeError):
"""Raised when a connector request fails."""


def _api_request(
url: str,
payload: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
method: str = "GET",
timeout: int = 30,
) -> dict[str, Any]:
"""Stdlib HTTP helper (urllib.request). Returns parsed JSON."""
hdrs = {
"User-Agent": "OpenPlanter/1.0",
"Accept": "application/json",
}
if headers:
hdrs.update(headers)

data: bytes | None = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
hdrs["Content-Type"] = "application/json"

req = urllib.request.Request(url=url, data=data, headers=hdrs, method=method)

try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise ConnectorError(f"HTTP {exc.code}: {body[:500]}") from exc
except urllib.error.URLError as exc:
raise ConnectorError(f"Connection error: {exc}") from exc
except OSError as exc:
raise ConnectorError(f"Network error: {exc}") from exc

try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ConnectorError(f"Non-JSON response: {raw[:500]}") from exc
if not isinstance(parsed, dict):
raise ConnectorError(f"Expected JSON object, got {type(parsed).__name__}")
return parsed


def _api_request_list(
url: str,
headers: dict[str, str] | None = None,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""Like _api_request but expects a JSON array at top level."""
hdrs = {
"User-Agent": "OpenPlanter/1.0",
"Accept": "application/json",
}
if headers:
hdrs.update(headers)

req = urllib.request.Request(url=url, headers=hdrs, method="GET")

try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise ConnectorError(f"HTTP {exc.code}: {body[:500]}") from exc
except urllib.error.URLError as exc:
raise ConnectorError(f"Connection error: {exc}") from exc
except OSError as exc:
raise ConnectorError(f"Network error: {exc}") from exc

try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ConnectorError(f"Non-JSON response: {raw[:500]}") from exc
if isinstance(parsed, dict):
return [parsed]
if not isinstance(parsed, list):
raise ConnectorError(f"Expected JSON array, got {type(parsed).__name__}")
return parsed
231 changes: 231 additions & 0 deletions agent/connectors/abgeordnetenwatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"""abgeordnetenwatch.de API v2 connector.

Accesses German MP data, votes, questions, and side income via the
public CC0-licensed REST API.
Base URL: https://www.abgeordnetenwatch.de/api/v2/
No authentication required.
"""
from __future__ import annotations

import json
import urllib.parse
from typing import Any

from . import ConnectorError, _api_request

_BASE_URL = "https://www.abgeordnetenwatch.de/api/v2"


def _build_url(endpoint: str, params: dict[str, Any] | None = None) -> str:
"""Build API URL with optional query parameters."""
url = f"{_BASE_URL}/{endpoint.lstrip('/')}"
if params:
# Filter out None values
clean = {k: str(v) for k, v in params.items() if v is not None}
if clean:
url += f"?{urllib.parse.urlencode(clean)}"
return url


def _normalize_politician(raw: dict[str, Any]) -> dict[str, Any]:
"""Normalize a politician record."""
return {
"id": raw.get("id"),
"label": raw.get("label", ""),
"first_name": raw.get("first_name", ""),
"last_name": raw.get("last_name", ""),
"birth_name": raw.get("birth_name", ""),
"year_of_birth": raw.get("year_of_birth"),
"party": _extract_party(raw),
"occupation": raw.get("occupation", ""),
"education": raw.get("education", ""),
"url": raw.get("abgeordnetenwatch_url", ""),
"mandates": _extract_mandates(raw),
}


def _extract_party(raw: dict[str, Any]) -> str:
"""Extract party name from nested party object."""
party = raw.get("party")
if isinstance(party, dict):
return party.get("label", "") or party.get("full_name", "")
return str(party) if party else ""


def _extract_mandates(raw: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract mandates from related data."""
mandates_raw = raw.get("mandates") or raw.get("related_data", {}).get("mandates", {})
if isinstance(mandates_raw, dict):
mandates_raw = mandates_raw.get("data", [])
if not isinstance(mandates_raw, list):
return []
result: list[dict[str, Any]] = []
for m in mandates_raw:
if not isinstance(m, dict):
continue
result.append({
"id": m.get("id"),
"label": m.get("label", ""),
"parliament_period": _nested_label(m.get("parliament_period")),
"fraction": _nested_label(m.get("fraction")),
"start_date": m.get("start_date", ""),
"end_date": m.get("end_date", ""),
})
return result


def _nested_label(obj: Any) -> str:
if isinstance(obj, dict):
return obj.get("label", "") or obj.get("full_name", "")
return str(obj) if obj else ""


def _normalize_sidejob(raw: dict[str, Any]) -> dict[str, Any]:
"""Normalize a sidejob record."""
return {
"id": raw.get("id"),
"label": raw.get("label", ""),
"organization": raw.get("sidejob_organization", {}).get("label", "") if isinstance(raw.get("sidejob_organization"), dict) else "",
"category": raw.get("category", ""),
"income_level": raw.get("income_level", ""),
"interval": raw.get("interval", ""),
"created": raw.get("created", ""),
"politician_id": _extract_nested_id(raw.get("mandate", {})),
}


def _extract_nested_id(obj: Any) -> int | None:
if isinstance(obj, dict):
pol = obj.get("politician")
if isinstance(pol, dict):
return pol.get("id")
return obj.get("id")
return None


def _normalize_vote(raw: dict[str, Any]) -> dict[str, Any]:
"""Normalize a vote record."""
return {
"id": raw.get("id"),
"vote": raw.get("vote", ""),
"reason_no_show": raw.get("reason_no_show", ""),
"mandate": _nested_label(raw.get("mandate")),
"fraction": _nested_label(raw.get("fraction")),
"poll_id": raw.get("poll", {}).get("id") if isinstance(raw.get("poll"), dict) else None,
}


def search_politicians(
query: str | None = None,
parliament_period: int | None = None,
party_id: int | None = None,
max_results: int = 20,
) -> str:
"""Search politicians on abgeordnetenwatch."""
params: dict[str, Any] = {
"range_end": min(max_results, 100),
}
if parliament_period is not None:
params["parliament_period"] = parliament_period
if party_id is not None:
params["party"] = party_id
if query:
params["label[cn]"] = query

url = _build_url("politicians", params)

try:
data = _api_request(url, timeout=30)
except ConnectorError as exc:
return json.dumps({"error": str(exc), "query": query})

results: list[dict[str, Any]] = []
entries = data.get("data", [])
if isinstance(entries, list):
for entry in entries[:max_results]:
if isinstance(entry, dict):
results.append(_normalize_politician(entry))

meta = data.get("meta", {})
return json.dumps({
"source": "abgeordnetenwatch",
"query": query,
"total_results": meta.get("result", {}).get("total", len(results)) if isinstance(meta, dict) else len(results),
"results": results,
}, ensure_ascii=False, indent=2)


def get_politician(politician_id: int) -> str:
"""Fetch a single politician with mandates."""
url = _build_url(f"politicians/{politician_id}", {"related_data": "mandates"})

try:
data = _api_request(url, timeout=30)
except ConnectorError as exc:
return json.dumps({"error": str(exc), "politician_id": politician_id})

entry = data.get("data", data)
if isinstance(entry, dict):
entry = _normalize_politician(entry)

return json.dumps({
"source": "abgeordnetenwatch",
"politician": entry,
}, ensure_ascii=False, indent=2)


def get_poll_votes(poll_id: int, max_results: int = 100) -> str:
"""Fetch votes for a specific poll."""
url = _build_url(f"polls/{poll_id}/votes", {"range_end": min(max_results, 500)})

try:
data = _api_request(url, timeout=30)
except ConnectorError as exc:
return json.dumps({"error": str(exc), "poll_id": poll_id})

results: list[dict[str, Any]] = []
entries = data.get("data", [])
if isinstance(entries, list):
for entry in entries[:max_results]:
if isinstance(entry, dict):
results.append(_normalize_vote(entry))

return json.dumps({
"source": "abgeordnetenwatch",
"poll_id": poll_id,
"total_votes": len(results),
"votes": results,
}, ensure_ascii=False, indent=2)


def search_sidejobs(
politician_id: int | None = None,
max_results: int = 50,
) -> str:
"""Search sidejobs (Nebeneinkünfte)."""
params: dict[str, Any] = {
"range_end": min(max_results, 200),
}
if politician_id is not None:
params["politician"] = politician_id

url = _build_url("sidejobs", params)

try:
data = _api_request(url, timeout=30)
except ConnectorError as exc:
return json.dumps({"error": str(exc), "politician_id": politician_id})

results: list[dict[str, Any]] = []
entries = data.get("data", [])
if isinstance(entries, list):
for entry in entries[:max_results]:
if isinstance(entry, dict):
results.append(_normalize_sidejob(entry))

return json.dumps({
"source": "abgeordnetenwatch",
"politician_id": politician_id,
"total_results": len(results),
"sidejobs": results,
}, ensure_ascii=False, indent=2)
Loading