fixed complete langgraph workflow and information fetching

This commit is contained in:
Ida Dittrich 2026-02-14 17:31:39 +01:00
parent 3ff3cfd51c
commit 69aa73ed73
15 changed files with 2592 additions and 653 deletions

View file

@ -75,3 +75,7 @@ PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switz
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a

View file

@ -7,6 +7,7 @@ MapServer identify endpoint for parcel data retrieval.
Endpoint: https://api3.geo.admin.ch/rest/services/api/MapServer/identify
"""
import json
import logging
import asyncio
import re
@ -29,7 +30,9 @@ class SwissTopoMapServerConnector:
# API endpoints
MAPSERVER_IDENTIFY_URL = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
MAPSERVER_FIND_URL = "https://api3.geo.admin.ch/rest/services/ech/MapServer/find"
GEOCODING_URL = "https://api3.geo.admin.ch/rest/services/api/SearchServer"
LAYER_GEMEINDE = "ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill"
# Swiss official survey layer
LAYER_AMTLICHE_VERMESSUNG = "all:ch.swisstopo-vd.amtliche-vermessung"
@ -46,7 +49,8 @@ class SwissTopoMapServerConnector:
self,
timeout: int = 30,
max_retries: int = 3,
retry_delay: float = 1.0
retry_delay: float = 1.0,
oereb_connector: Optional[Any] = None,
):
"""
Initialize MapServer connector.
@ -55,10 +59,12 @@ class SwissTopoMapServerConnector:
timeout: Request timeout in seconds
max_retries: Maximum number of retry attempts
retry_delay: Initial retry delay in seconds (exponential backoff)
oereb_connector: Optional OerebWfsConnector for zone queries (used by scraping)
"""
self.timeout = aiohttp.ClientTimeout(total=timeout)
self.max_retries = max_retries
self.retry_delay = retry_delay
self.oereb_connector = oereb_connector
logger.info("Swiss Topo MapServer Connector initialized")
@ -128,6 +134,136 @@ class SwissTopoMapServerConnector:
return municipality
async def get_gemeinde_by_name(self, gemeinde_name: str) -> Optional[Dict[str, Any]]:
"""
Fetch a single Gemeinde from Swiss Topo by name (e.g. "Zürich").
Uses Find API with exact/contains search. Returns the best match.
Returns:
Dict with keys: name, bfs_nummer, kanton, or None if not found
"""
if not gemeinde_name or not gemeinde_name.strip():
return None
search_text = gemeinde_name.strip()
# Try exact match first (Zürich)
for q in [search_text, search_text.split("(")[0].strip()]:
try:
params = {
"layer": self.LAYER_GEMEINDE,
"searchText": q,
"searchField": "gemname",
"returnGeometry": "false",
"contains": "true",
}
data = await self._make_request(self.MAPSERVER_FIND_URL, params)
results = data.get("results", [])
if not results:
continue
# Pick best match: exact name first, then by highest jahr
target_lower = (gemeinde_name or "").strip().lower()
best = None
best_score = -1
for feat in results:
attrs = feat.get("attributes", {})
gemname = attrs.get("gemname") or attrs.get("label", "")
cleaned = self._clean_municipality_name(gemname)
gde_nr = attrs.get("gde_nr")
kanton = attrs.get("kanton")
jahr = attrs.get("jahr", 0)
objektart = attrs.get("objektart", attrs.get("objval"))
if objektart is not None and int(objektart) != 11:
continue
if not gde_nr or not kanton:
continue
# Score: exact match = 100, partial = 50, else by jahr
cleaned_lower = cleaned.strip().lower()
if cleaned_lower == target_lower:
score = 1000 + jahr
elif target_lower in cleaned_lower or cleaned_lower in target_lower:
score = 500 + jahr
else:
score = jahr
if score > best_score:
best_score = score
best = {"name": cleaned, "bfs_nummer": int(gde_nr), "kanton": str(kanton)}
if best:
logger.info(f"Found Gemeinde '{best['name']}' (BFS {best['bfs_nummer']}) for search '{gemeinde_name}'")
return best
except Exception as e:
logger.warning(f"Error fetching Gemeinde '{q}': {e}")
continue
return None
async def get_all_gemeinden(self, only_current: bool = True) -> List[Dict[str, Any]]:
"""
Fetch all Swiss municipalities (Gemeinden) from the Swiss Topo MapServer.
Uses the Find API to query the municipality layer. Iterates with search
strings to collect all municipalities, then deduplicates by BFS number.
Note: layerDefs is not used - the MapServer Find API reports that
is_current_jahr/objektart are not queryable. Filtering is done client-side.
Args:
only_current: If True, keep only the latest jahr per BFS number (current municipalities).
Returns:
List of dicts with keys: name, bfs_nummer, kanton
"""
# Search strings to achieve broad coverage (Swiss municipality names)
search_chars = [
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
"ä", "ö", "ü", "é", "è", "à", "â", "î", "ô", "ç", "-", " ", "'",
]
seen: Dict[Tuple[int, str], Dict[str, Any]] = {}
for char in search_chars:
try:
params = {
"layer": self.LAYER_GEMEINDE,
"searchText": char,
"searchField": "gemname",
"returnGeometry": "false",
"contains": "true",
}
# Do not use layerDefs - is_current_jahr/objektart are not queryable on this layer
data = await self._make_request(self.MAPSERVER_FIND_URL, params)
results = data.get("results", [])
for feat in results:
attrs = feat.get("attributes", {})
gde_nr = attrs.get("gde_nr")
kanton = attrs.get("kanton")
gemname = attrs.get("gemname") or attrs.get("label", "")
jahr = attrs.get("jahr", 0)
objektart = attrs.get("objektart", attrs.get("objval"))
# Only include politische Gemeinden (objektart 11) when attribute present
if objektart is not None and only_current and int(objektart) != 11:
continue
if not gde_nr or not kanton:
continue
key = (int(gde_nr), str(kanton))
if key not in seen or jahr > (seen[key].get("_jahr") or 0):
cleaned_name = self._clean_municipality_name(gemname)
seen[key] = {
"name": cleaned_name,
"bfs_nummer": int(gde_nr),
"kanton": str(kanton),
"_jahr": jahr,
}
except Exception as e:
logger.warning(f"Error fetching gemeinden for search '{char}': {e}")
continue
result = [
{"name": v["name"], "bfs_nummer": v["bfs_nummer"], "kanton": v["kanton"]}
for v in seen.values()
]
logger.info(f"Fetched {len(result)} unique Gemeinden from Swiss Topo (only_current={only_current})")
return result
def _extract_address_from_building_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Optional[str]]:
"""
Extract address components from building layer attributes.

View file

@ -0,0 +1,89 @@
"""
Swiss Parcel (Liegenschaften) Connector
Fetches parcel data from geodienste.ch OGC API Features (Amtliche Vermessung).
Covers all of Switzerland. Returns GeoJSON in WGS84.
Uses: geodienste.ch OGC API - RESF collection (Liegenschaften)
No config override needed - this is the single working solution.
"""
import logging
from typing import Dict, Any
import requests
logger = logging.getLogger(__name__)
# geodienste.ch OGC API - RESF = Liegenschaften (parcels), all Switzerland
# API returns WGS84 directly when bbox-crs=EPSG:2056 is used
_OGC_API_BASE = "https://www.geodienste.ch/db/av_0/deu/ogcapi/collections/RESF/items"
_MAX_ITEMS = 2000
_TIMEOUT = 30
class ZhWfsParcelsConnector:
"""
Connector for Swiss parcel (Liegenschaften) data via geodienste.ch OGC API.
Returns GeoJSON FeatureCollection in WGS84.
"""
def __init__(self, timeout: int = _TIMEOUT):
self.timeout = timeout
logger.info("ZhWfsParcelsConnector initialized (geodienste.ch OGC API)")
def get_parcels_by_bbox(self, bbox: str) -> Dict[str, Any]:
"""
Fetch parcels within bounding box.
Returns GeoJSON FeatureCollection in WGS84 (EPSG:4326).
Args:
bbox: Bounding box as "minx,miny,maxx,maxy" in LV95 (EPSG:2056)
Returns:
GeoJSON FeatureCollection with geometries in WGS84
"""
try:
parts = [p.strip() for p in bbox.split(",")]
if len(parts) != 4:
raise ValueError(f"Invalid bbox: expected minx,miny,maxx,maxy, got {bbox}")
minx, miny, maxx, maxy = (float(p) for p in parts)
params = {
"f": "json",
"limit": _MAX_ITEMS,
"bbox": f"{minx},{miny},{maxx},{maxy}",
"bbox-crs": "http://www.opengis.net/def/crs/EPSG/0/2056",
}
logger.debug(f"Requesting parcels: bbox={bbox}")
resp = requests.get(_OGC_API_BASE, params=params, timeout=self.timeout)
if resp.status_code != 200:
logger.error(f"Parcel API failed: status={resp.status_code}, body={resp.text[:500]}")
return {"type": "FeatureCollection", "features": []}
data = resp.json()
# OGC API returns FeatureCollection in WGS84 directly
features = data.get("features", [])
if not features:
return {"type": "FeatureCollection", "features": []}
# Pass through - geodienste returns WGS84 GeoJSON
result = {
"type": "FeatureCollection",
"features": features,
}
logger.info(f"Returned {len(features)} parcels in WGS84")
return result
except ValueError as e:
logger.warning(f"Invalid bbox: {e}")
raise
except requests.RequestException as e:
logger.error(f"Parcel API request error: {e}")
return {"type": "FeatureCollection", "features": []}
except Exception as e:
logger.error(f"Error fetching parcels: {e}", exc_info=True)
return {"type": "FeatureCollection", "features": []}

View file

@ -5,9 +5,9 @@ Queries Dokument table and retrieves PDF content from ComponentObjects.
import logging
from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelRealEstate import Dokument, DokumentTyp, Gemeinde
from modules.interfaces.interfaceDbRealEstateObjects import RealEstateObjects
from modules.interfaces.interfaceDbComponentObjects import ComponentObjects
from .datamodelFeatureRealEstate import Dokument, DokumentTyp, Gemeinde
from .interfaceFeatureRealEstate import RealEstateObjects
from modules.interfaces.interfaceDbManagement import ComponentObjects
logger = logging.getLogger(__name__)
@ -128,8 +128,8 @@ class BZODocumentRetriever:
logger.warning(f"Dokument {dokument.id} has no dokumentReferenz")
return None
# Retrieve PDF bytes
pdf_bytes = self.componentInterface.getFileData(dokument.dokumentReferenz)
# Retrieve PDF bytes (unrestricted - BZO documents are public, accessible to all users)
pdf_bytes = self.componentInterface.getFileDataForPublicDocument(dokument.dokumentReferenz)
if not pdf_bytes:
logger.warning(f"Could not retrieve PDF content for file {dokument.dokumentReferenz}")

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,12 @@ RULE_TAXONOMY = {
"value_type": "numeric",
"keywords": ["max", "maximal"]
},
"building_coverage": {
"patterns": ["überbauungsziffer", "überbauungsziffer max", "uz"],
"units": ["%", "prozent"],
"value_type": "numeric",
"keywords": ["max", "maximal"]
},
"building_mass_index": {
"patterns": ["baumassenziffer", "bmz"],
"units": [],

View file

@ -467,6 +467,8 @@ class RealEstateObjects:
Dokument,
self.currentUser,
recordFilter={"id": dokumentId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
@ -482,6 +484,8 @@ class RealEstateObjects:
Dokument,
self.currentUser,
recordFilter=recordFilter or {},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
return [Dokument(**r) for r in records]
@ -538,6 +542,8 @@ class RealEstateObjects:
Gemeinde,
self.currentUser,
recordFilter={"id": gemeindeId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
@ -553,6 +559,8 @@ class RealEstateObjects:
Gemeinde,
self.currentUser,
recordFilter=recordFilter or {},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
return [Gemeinde(**r) for r in records]
@ -609,6 +617,8 @@ class RealEstateObjects:
Kanton,
self.currentUser,
recordFilter={"id": kantonId},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
@ -624,6 +634,8 @@ class RealEstateObjects:
Kanton,
self.currentUser,
recordFilter=recordFilter or {},
mandateId=self.mandateId,
featureInstanceId=self.featureInstanceId,
featureCode=self.FEATURE_CODE
)
return [Kanton(**r) for r in records]

View file

@ -13,23 +13,13 @@ FEATURE_CODE = "realestate"
FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"}
FEATURE_ICON = "mdi-home-city"
# UI Objects for RBAC catalog
# UI Objects for RBAC catalog (only map view)
UI_OBJECTS = [
{
"objectKey": "ui.feature.realestate.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"label": {"en": "Map", "de": "Karte", "fr": "Carte"},
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.realestate.projects",
"label": {"en": "Projects", "de": "Projekte", "fr": "Projets"},
"meta": {"area": "projects"}
},
{
"objectKey": "ui.feature.realestate.parcels",
"label": {"en": "Parcels", "de": "Parzellen", "fr": "Parcelles"},
"meta": {"area": "parcels"}
},
]
# Resource Objects for RBAC catalog
@ -74,10 +64,8 @@ TEMPLATE_ROLES = [
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
},
"accessRules": [
# UI access to main views - vollqualifizierte ObjectKeys
# UI access to map view
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
# Resource: create projects
@ -92,10 +80,8 @@ TEMPLATE_ROLES = [
"fr": "Visualiseur immobilier - Consulter les informations immobilières"
},
"accessRules": [
# UI access to view-only views - vollqualifizierte ObjectKeys
# UI access to map view (read-only)
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
# Read-only DATA access (my records)
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
]
@ -299,11 +285,16 @@ from .datamodelFeatureRealEstate import (
DokumentTyp,
)
from modules.services import getInterface as getServices
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever
from modules.features.realEstate.bzoExtractionLangGraph import run_extraction
from modules.features.realEstate.bzoExtractionLangGraph import run_extraction, run_bzo_params_extraction
from modules.features.realEstate.parcelSelectionService import compute_selection_summary
from modules.features.realEstate.realEstateGemeindeService import (
ensure_single_gemeinde,
fetch_bzo_for_gemeinde,
)
logger = logging.getLogger(__name__)
@ -2342,64 +2333,73 @@ async def extract_bzo_information(
currentUser: User,
gemeinde: str,
bauzone: str,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
total_area_m2: Optional[float] = None,
parcels: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""
Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde.
Retrieves BZO documents for the specified Gemeinde, extracts content using
langgraph workflow, filters by Bauzone, and uses AI to find relevant information.
When total_area_m2 or parcels are provided, runs Machbarkeitsstudie for structured output.
Args:
currentUser: Current authenticated user
gemeinde: Gemeinde name (e.g., "Zürich") or ID
bauzone: Bauzone code (e.g., "W3", "W2/30")
mandateId: Optional mandate ID for instance-scoped data (defaults to currentUser.mandateId)
featureInstanceId: Optional feature instance ID for instance-scoped data
total_area_m2: Optional total parcel area () for Machbarkeitsstudie
parcels: Optional list of parcel dicts; total area computed via compute_selection_summary if not total_area_m2
Returns:
Dictionary containing:
- bauzone: Bauzone code
- gemeinde: Gemeinde information
- extracted_content: Extracted content from PDFs
- ai_summary: AI-generated summary
- relevant_rules: Rules filtered by Bauzone
- documents_processed: List of document IDs processed
Raises:
HTTPException: If Gemeinde not found or no documents found
- bauzone, gemeinde, extracted_content, ai_summary, relevant_rules, documents_processed
- machbarkeitsstudie: Structured Machbarkeitsstudie output when total_area_m2/parcels provided
"""
try:
logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id})")
_mandateId = mandateId or (str(currentUser.mandateId) if currentUser.mandateId else None)
logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {_mandateId})")
# Get interfaces
realEstateInterface = getRealEstateInterface(currentUser)
componentInterface = getComponentInterface(currentUser)
# Get interfaces (instance-scoped when mandateId/featureInstanceId provided)
realEstateInterface = getRealEstateInterface(
currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId
)
componentInterface = getComponentInterface(
currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId
)
# Get Gemeinde - try by ID first, then by label
logger.debug(f"Attempting to retrieve Gemeinde '{gemeinde}' for mandate {currentUser.mandateId}")
logger.debug(f"Attempting to retrieve Gemeinde '{gemeinde}' for mandate {_mandateId}")
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde)
# If not found by ID, try searching by label
if not gemeinde_obj:
logger.debug(f"Gemeinde not found by ID, trying to search by label: {gemeinde}")
record_filter = {"label": gemeinde}
if _mandateId:
record_filter["mandateId"] = _mandateId
gemeinden_by_label = realEstateInterface.getGemeinden(
recordFilter={"label": gemeinde}
recordFilter=record_filter
)
if gemeinden_by_label and len(gemeinden_by_label) > 0:
gemeinde_obj = gemeinden_by_label[0]
logger.info(f"Found Gemeinde by label '{gemeinde}' with ID: {gemeinde_obj.id}")
else:
# Try to get all gemeinden to see what's available (for debugging)
all_gemeinden = realEstateInterface.getGemeinden(recordFilter=None)
logger.warning(f"Gemeinde '{gemeinde}' not found by ID or label. Total Gemeinden in database: {len(all_gemeinden)}")
if all_gemeinden:
sample_ids = [g.id for g in all_gemeinden[:5]]
sample_labels = [g.label for g in all_gemeinden[:5] if g.label]
logger.warning(f"Sample Gemeinde IDs: {sample_ids}")
if sample_labels:
logger.warning(f"Sample Gemeinde labels: {sample_labels}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Gemeinde '{gemeinde}' not found or not accessible"
)
# If still not found: fetch only this Gemeinde from Swiss Topo and create it
if not gemeinde_obj and _mandateId and featureInstanceId:
logger.info(f"Gemeinde '{gemeinde}' not in DB - fetching from Swiss Topo (this Gemeinde only)")
gemeinde_obj = await ensure_single_gemeinde(
realEstateInterface, _mandateId, featureInstanceId, gemeinde_name=gemeinde
)
if not gemeinde_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Gemeinde '{gemeinde}' not found or not accessible"
)
gemeinde_id = gemeinde_obj.id
@ -2435,6 +2435,36 @@ async def extract_bzo_information(
else:
logger.warning(f"Document {doc_id} referenced in Gemeinde but not found in database")
# If no BZO documents: auto-fetch from Tavily, then retry
if not bzo_documents and _mandateId and featureInstanceId:
logger.info(f"No BZO documents for Gemeinde '{gemeinde_obj.label}' - fetching from web")
fetched = await fetch_bzo_for_gemeinde(
realEstateInterface, componentInterface, gemeinde_obj, _mandateId, featureInstanceId
)
if fetched:
# Reload Gemeinde to get updated dokumente
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde_obj.id)
bzo_documents = []
if gemeinde_obj and gemeinde_obj.dokumente:
for doc in gemeinde_obj.dokumente:
if isinstance(doc, dict):
doc_id = doc.get("id")
doc_typ = doc.get("dokumentTyp")
else:
doc_id = doc.id if hasattr(doc, "id") else None
doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None
if doc_typ:
if isinstance(doc_typ, DokumentTyp):
is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]
elif isinstance(doc_typ, str):
is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
else:
is_bzo = str(doc_typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
if is_bzo and doc_id:
full_doc = realEstateInterface.getDokument(doc_id)
if full_doc:
bzo_documents.append(full_doc)
if not bzo_documents:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -2498,11 +2528,13 @@ async def extract_bzo_information(
)
continue
# Filter rules by Bauzone
# Filter rules by Bauzone - only rules explicitly associated with this zone
relevant_rules = filter_rules_by_bauzone(
all_extracted_content["rules"],
bauzone
)
logger.info(f"Extracting for Bauzone {bauzone}: {len(relevant_rules)} zone-specific rules, "
f"{len([t for t in all_extracted_content.get('zone_parameter_tables', []) if bauzone.upper() in str(t.get('zones', [])).upper()])} tables with zone data")
# Filter zones by Bauzone
relevant_zones = filter_zones_by_bauzone(
@ -2516,6 +2548,34 @@ async def extract_bzo_information(
bauzone
)
# Compute total_area_m2 from parcels if not provided
_total_area_m2 = total_area_m2
if _total_area_m2 is None and parcels:
selection_summary = compute_selection_summary(parcels)
_total_area_m2 = selection_summary.get("total_area_m2") or 0.0
# Extract BZO parameters for Wohnzone via LangGraph + LLM (bullet list with sources)
bzo_params_result = None
try:
services = getServices(
currentUser, workflow=None, mandateId=_mandateId, featureInstanceId=featureInstanceId
)
ai_service = services.ai
bzo_params_result = await run_bzo_params_extraction(
extracted_content=all_extracted_content,
bauzone=bauzone,
ai_service=ai_service,
gemeinde=gemeinde_obj.label,
relevant_rules=relevant_rules,
relevant_articles=relevant_articles,
total_area_m2=_total_area_m2,
)
except Exception as me:
logger.warning(f"BZO parameter extraction failed: {me}", exc_info=True)
all_extracted_content["warnings"] = all_extracted_content.get("warnings", []) + [
f"BZO-Parameter konnten nicht extrahiert werden: {str(me)}"
]
# Use AI to generate summary and find additional information
ai_summary = await generate_bauzone_ai_summary(
currentUser=currentUser,
@ -2523,7 +2583,9 @@ async def extract_bzo_information(
gemeinde=gemeinde_obj.label,
extracted_content=all_extracted_content,
relevant_rules=relevant_rules,
relevant_zones=relevant_zones
relevant_zones=relevant_zones,
mandateId=_mandateId,
featureInstanceId=featureInstanceId,
)
# Build unified summary that includes zones and articles
@ -2602,7 +2664,8 @@ async def extract_bzo_information(
"relevant_rules": relevant_rules,
"documents_processed": documents_processed,
"errors": all_extracted_content.get("errors", []),
"warnings": all_extracted_content.get("warnings", [])
"warnings": all_extracted_content.get("warnings", []),
"machbarkeitsstudie": bzo_params_result, # Same key for frontend compatibility
}
except HTTPException:
@ -2617,47 +2680,59 @@ async def extract_bzo_information(
def filter_rules_by_bauzone(rules: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]:
"""
Filter rules by Bauzone code.
Args:
rules: List of rule dictionaries from extraction
bauzone: Bauzone code to filter by (e.g., "W3", "W2/30")
Returns:
Filtered list of rules that match the Bauzone
Filter rules by Bauzone code. Only keeps rules from SINGLE-zone articles to avoid
wrong values (e.g. article with W2,W3,W5 has different values per zone - we cannot
associate a rule value with a specific zone from article text alone).
"""
relevant_rules = []
bauzone_upper = bauzone.upper()
def _zone_matches(z: str) -> bool:
zu = (z or "").upper().strip()
if not zu:
return False
if bauzone_upper in zu:
return True
if zu in bauzone_upper and len(zu) >= 2:
return True
return False
for rule in rules:
# Check if rule has zone information
table_zones = rule.get("table_zones", []) or []
zone_raw = rule.get("zone_raw")
table_zones = rule.get("table_zones", [])
# Check if rule matches Bauzone
# Rule must be zone-associated
has_zone = bool(zone_raw) or bool(table_zones)
if not has_zone:
continue
# CRITICAL: Only use rules from single-zone articles. Multi-zone articles
# (e.g. table with W2,W3,W5) have different values per zone - we cannot
# know which value applies to our zone from article text.
if len(table_zones) > 1:
# Check if ALL zones in article match our bauzone (e.g. W5, W5/50) - unlikely
matches_all = all(_zone_matches(str(z)) for z in table_zones)
if not matches_all:
continue # Ambiguous: exclude
# Zone must match our bauzone
matches = False
# Direct zone match
if zone_raw and bauzone_upper in zone_raw.upper():
if zone_raw and _zone_matches(zone_raw):
matches = True
# Table zone match
if not matches and table_zones:
for table_zone in table_zones:
if bauzone_upper in str(table_zone).upper():
for tz in table_zones:
if _zone_matches(str(tz)):
matches = True
break
# Check text snippet for Bauzone mention
if not matches:
text_snippet = rule.get("text_snippet", "")
if bauzone_upper in text_snippet.upper():
ts = (rule.get("text_snippet") or "").upper()
if bauzone_upper in ts and len(table_zones) <= 1:
matches = True
if matches:
relevant_rules.append(rule)
logger.info(f"Filtered {len(relevant_rules)} rules for Bauzone {bauzone} from {len(rules)} total rules")
logger.info(f"Filtered {len(relevant_rules)} rules for Bauzone {bauzone} from {len(rules)} total (multi-zone articles excluded)")
return relevant_rules
@ -2768,7 +2843,9 @@ async def generate_bauzone_ai_summary(
gemeinde: str,
extracted_content: Dict[str, Any],
relevant_rules: List[Dict[str, Any]],
relevant_zones: List[Dict[str, Any]]
relevant_zones: List[Dict[str, Any]],
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> str:
"""
Use AI to generate a summary of relevant information for a Bauzone.
@ -2785,8 +2862,10 @@ async def generate_bauzone_ai_summary(
AI-generated summary string
"""
try:
# Initialize AI service
services = getServices(currentUser, workflow=None)
# Initialize AI service (mandateId required for billing)
services = getServices(
currentUser, workflow=None, mandateId=mandateId, featureInstanceId=featureInstanceId
)
aiService = services.ai
# Build context from extracted content, prioritizing zone-parameter tables

View file

@ -0,0 +1,180 @@
"""
Parcel selection service: compute combined outline, total area, and Bauzone grouping.
Used for multi-parcel selection in PEK map view.
"""
import logging
from typing import Any, Dict, List, Optional
from shapely.geometry import Polygon
from shapely.ops import unary_union
from shapely.geometry.base import BaseGeometry
logger = logging.getLogger(__name__)
def _parcel_to_shapely_polygon(parcel: Dict[str, Any]) -> Optional[Polygon]:
"""
Convert a parcel dict (perimeter or geometry_geojson) to Shapely Polygon.
Returns None if conversion fails.
"""
# Try geometry_geojson first
geo = parcel.get("geometry_geojson") or parcel.get("map_view", {}).get("geometry_geojson")
if geo and isinstance(geo, dict):
geom = geo.get("geometry") or geo
if geom and isinstance(geom, dict) and geom.get("type") == "Polygon":
coords = geom.get("coordinates")
if coords and len(coords) > 0:
ring = coords[0] if isinstance(coords[0][0], list) else coords
if ring and len(ring) >= 3:
try:
poly = Polygon(ring)
if not poly.is_empty:
return poly
except Exception as e:
logger.debug(f"GeoJSON to Polygon failed: {e}")
# Try perimeter (punkte with x, y)
perimeter = parcel.get("perimeter")
if not perimeter and "parcel" in parcel:
perimeter = parcel.get("parcel", {}).get("perimeter")
if perimeter and isinstance(perimeter, dict):
punkte = perimeter.get("punkte", [])
if len(punkte) >= 3:
try:
coords = [(p.get("x"), p.get("y")) for p in punkte if "x" in p and "y" in p]
if len(coords) >= 3:
if coords[0] != coords[-1]:
coords.append(coords[0])
return Polygon(coords)
except Exception as e:
logger.debug(f"Perimeter to Polygon failed: {e}")
return None
def _shapely_to_geojson(geom: BaseGeometry) -> Dict[str, Any]:
"""
Convert Shapely geometry to GeoJSON dict (Polygon or MultiPolygon).
GeoJSON rings are closed (first point == last point).
"""
if geom is None or geom.is_empty:
return {"type": "Polygon", "coordinates": []}
if hasattr(geom, "geoms"):
# MultiPolygon: coordinates = [ [[ring1]], [[ring2]], ... ] per polygon
parts = []
for g in geom.geoms:
if not g.is_empty and hasattr(g, "exterior"):
ring = [list(c) for c in g.exterior.coords]
parts.append([ring])
return {"type": "MultiPolygon", "coordinates": parts}
elif hasattr(geom, "exterior"):
ring = [list(c) for c in geom.exterior.coords]
return {"type": "Polygon", "coordinates": [ring]}
return {"type": "Polygon", "coordinates": []}
def compute_selection_summary(parcels: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Compute combined outline, total area, and Bauzone grouping for selected parcels.
Args:
parcels: List of parcel dicts with perimeter, geometry_geojson, bauzone, area_m2.
Can have structure { "parcel": {...}, "map_view": {...} } or flat.
Returns:
{
"combined_outline_geojson": { "type": "Polygon"|"MultiPolygon", "coordinates": [...] },
"total_area_m2": float,
"bauzonen": [ { "bauzone": str, "parcels": [...], "area_m2": float } ]
}
"""
if not parcels:
return {
"combined_outline_geojson": {"type": "Polygon", "coordinates": []},
"total_area_m2": 0.0,
"bauzonen": [],
}
# Normalize: extract parcel data for geometry and Bauzone
shapely_polygons = []
parcel_records = []
for p in parcels:
# Support both { parcel: {...}, map_view: {...} } and flat { id, perimeter, ... }
flat = dict(p.get("parcel", {}), **{k: v for k, v in p.items() if k != "parcel"})
flat["map_view"] = p.get("map_view", {})
flat["geometry_geojson"] = flat.get("geometry_geojson") or flat.get("map_view", {}).get("geometry_geojson")
flat["perimeter"] = flat.get("perimeter") or flat.get("map_view", {}).get("geometry_geojson") and None
poly = _parcel_to_shapely_polygon(flat)
if poly is not None and not poly.is_empty:
shapely_polygons.append(poly)
parcel_records.append(flat)
if not shapely_polygons:
return {
"combined_outline_geojson": {"type": "Polygon", "coordinates": []},
"total_area_m2": 0.0,
"bauzonen": [],
}
# Union all polygons (keeps MultiPolygon if disconnected)
combined = unary_union(shapely_polygons)
total_area_m2 = float(combined.area) if combined and not combined.is_empty else 0.0
combined_geojson = _shapely_to_geojson(combined)
# Group parcels by Bauzone
bauzone_map: Dict[str, List[Dict]] = {}
for flat in parcel_records:
bz = flat.get("bauzone") or flat.get("parcel", {}).get("bauzone") or "Unbekannt"
if bz not in bauzone_map:
bauzone_map[bz] = []
bauzone_map[bz].append(flat)
bauzonen = []
for bz, plist in bauzone_map.items():
area_sum = sum(
float(p.get("area_m2") or p.get("parcel", {}).get("area_m2") or 0)
for p in plist
)
bauzonen.append({
"bauzone": bz,
"parcels": plist,
"area_m2": round(area_sum, 2),
})
return {
"combined_outline_geojson": combined_geojson,
"total_area_m2": round(total_area_m2, 2),
"bauzonen": bauzonen,
}
def is_parcel_adjacent_to_selection(
new_parcel: Dict[str, Any],
selected_parcels: List[Dict[str, Any]],
buffer_m: float = 0.01,
) -> bool:
"""
Check if a parcel touches (is adjacent to) any selected parcel.
Uses small buffer for floating-point tolerance.
"""
new_poly = _parcel_to_shapely_polygon(new_parcel)
if new_poly is None or new_poly.is_empty:
return False
for sel in selected_parcels:
flat = dict(sel.get("parcel", {}), **{k: v for k, v in sel.items() if k != "parcel"})
flat["map_view"] = sel.get("map_view", {})
flat["geometry_geojson"] = flat.get("geometry_geojson") or flat.get("map_view", {}).get("geometry_geojson")
sel_poly = _parcel_to_shapely_polygon(flat)
if sel_poly is None or sel_poly.is_empty:
continue
if buffer_m > 0:
sel_buf = sel_poly.buffer(buffer_m)
if new_poly.intersects(sel_buf) or new_poly.touches(sel_buf):
return True
else:
if new_poly.touches(sel_poly) or new_poly.intersects(sel_poly):
return True
return False

View file

@ -0,0 +1,377 @@
"""
Gemeinde and BZO document services for Real Estate feature.
Provides ensure/import logic used by both routes and extract_bzo_information.
"""
import asyncio
import hashlib
import json
import logging
import ssl
from typing import Any, Dict, List, Optional, Set
import aiohttp
from .datamodelFeatureRealEstate import Gemeinde, Kanton, Dokument, DokumentTyp, Kontext
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
from modules.aicore.aicorePluginTavily import AiTavily
logger = logging.getLogger(__name__)
KANTON_NAMES = {
"AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden",
"BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt",
"FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden",
"JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden",
"OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn",
"SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri",
"VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich",
}
# Known direct BZO PDF URLs for municipalities (by normalized name, lowercase)
# These are tried first to avoid SSL/HTML issues with Tavily search results
KNOWN_BZO_PDF_URLS: Dict[str, str] = {
"schlieren": "https://www.schlieren.ch/_docn/6239470/SKR_10.10_Bauordnung.pdf",
"zürich": "https://www.stadt-zuerich.ch/content/dam/stzh/portal/Deutsch/AmtlicheSammlung/Erlasse/700/100/700.100%20Bau-%20und%20Zonenordnung%20V2.pdf",
"zurich": "https://www.stadt-zuerich.ch/content/dam/stzh/portal/Deutsch/AmtlicheSammlung/Erlasse/700/100/700.100%20Bau-%20und%20Zonenordnung%20V2.pdf",
}
def _get_language_from_kanton(kanton_abk: Optional[str]) -> str:
if not kanton_abk:
return "de"
if kanton_abk.upper() in {"VD", "GE", "NE", "JU"}:
return "fr"
if kanton_abk.upper() == "TI":
return "it"
return "de"
# Swiss news/media domains to exclude from BZO search (return HTML articles, not PDFs)
_EXCLUDE_BZO_DOMAINS = [
"limmattalerzeitung.ch",
"20min.ch",
"tagesanzeiger.ch",
"nzz.ch",
"blick.ch",
"watson.ch",
"srf.ch",
"swissinfo.ch",
"zukunft-schlieren.ch", # project/development site, not official BZO
]
# Keywords that indicate the actual BZO regulation document (at least one required in URL/title)
_BZO_ORDINANCE_KEYWORDS = (
"bzo",
"zonenordnung",
"bauordnung",
"bau-und-zonenordnung",
"bau und zonenordnung",
"plan d'aménagement",
"règlement de construction",
"piano di utilizzazione",
"regolamento edilizio",
)
# Keywords that indicate articles or project docs (exclude if present in URL/title)
_BZO_ARTICLE_PROJECT_KEYWORDS = (
"ld.", # article ID (e.g. ld.2805321)
"warum", # "why" - typical in article headlines
"ruft ", # "calls [population to participate]"
"artikel", # article
"news",
"projektplanung", # project planning
"projekt/", # URL path for project pages
"/projekt",
"entwicklungsplan", # development plan (project doc)
)
def _normalize_gemeinde_for_match(name: str) -> str:
"""Normalize Gemeinde name for URL/title matching (lowercase, no umlauts)."""
if not name:
return ""
s = name.lower().strip()
s = s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
s = "".join(c for c in s if c.isalnum())
return s
def _get_bzo_search_query(gemeinde_label: str, language: str) -> str:
"""Build search query targeting BZO PDF documents (not articles)."""
if language == "fr":
return f"Plan d'aménagement local {gemeinde_label} PDF"
if language == "it":
return f"Piano di utilizzazione {gemeinde_label} PDF"
return f"Bau und Zonenordnung {gemeinde_label} PDF"
async def ensure_single_gemeinde(
interface: Any,
mandateId: str,
instanceId: str,
gemeinde_name: str,
) -> Optional[Any]:
"""
Ensure the given Gemeinde exists in DB. Fetches ONLY that one Gemeinde from Swiss Topo
and creates it if not found. No bulk import.
Returns the Gemeinde object if found/created, None otherwise.
"""
if not gemeinde_name or not gemeinde_name.strip():
return None
try:
connector = SwissTopoMapServerConnector()
gd = await connector.get_gemeinde_by_name(gemeinde_name)
except Exception as e:
logger.error(f"Error fetching Gemeinde '{gemeinde_name}' from Swiss Topo: {e}", exc_info=True)
return None
if not gd:
logger.warning(f"Gemeinde '{gemeinde_name}' not found in Swiss Topo")
return None
def find_gemeinde_by_bfs_nummer(bfs_nummer: str) -> Optional[Any]:
try:
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
for g in gemeinden:
for k in (g.kontextInformationen or []):
try:
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfs_nummer):
return g
except (json.JSONDecodeError, AttributeError):
continue
except Exception as ex:
logger.error(f"Error finding Gemeinde by BFS {bfs_nummer}: {ex}", exc_info=True)
return None
existing = find_gemeinde_by_bfs_nummer(str(gd["bfs_nummer"]))
if existing:
logger.info(f"Gemeinde '{gd['name']}' already in DB")
return existing
kanton_abk = gd.get("kanton")
kanton_id = None
if kanton_abk:
kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kanton_abk})
if kantone:
kanton_id = kantone[0].id
else:
try:
kanton_label = KANTON_NAMES.get(kanton_abk, kanton_abk)
kanton = Kanton(
mandateId=mandateId,
featureInstanceId=instanceId,
label=kanton_label,
abk=kanton_abk,
)
created_k = interface.createKanton(kanton)
if created_k and created_k.id:
kanton_id = created_k.id
except Exception as ex:
logger.error(f"Error creating Kanton {kanton_abk}: {ex}")
try:
gemeinde = Gemeinde(
mandateId=mandateId,
featureInstanceId=instanceId,
label=gd["name"],
id_kanton=kanton_id,
kontextInformationen=[
Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": gd["bfs_nummer"]}, ensure_ascii=False))
],
)
created = interface.createGemeinde(gemeinde)
if created and created.id:
logger.info(f"Created single Gemeinde '{gd['name']}' (BFS {gd['bfs_nummer']})")
return created
except Exception as ex:
logger.error(f"Error creating Gemeinde '{gd['name']}': {ex}", exc_info=True)
return None
def _extract_quelle(doc: Any) -> Optional[str]:
"""Extract quelle (source URL) from a document."""
return getattr(doc, "quelle", None) or (doc.get("quelle") if isinstance(doc, dict) else None)
async def fetch_bzo_for_gemeinde(
interface: Any,
componentInterface: Any,
gemeinde: Any,
mandateId: str,
instanceId: str,
) -> bool:
"""
Search for and download BZO documents for a single Gemeinde.
Returns True if at least one document was created.
Deduplication: re-fetches Gemeinde, skips if BZO exists, skips URLs we already have,
creates at most 1 new document per call to avoid duplicates from multiple Tavily URLs.
"""
# Re-fetch Gemeinde to get latest dokumente (avoid race with concurrent requests)
fresh = interface.getGemeinde(gemeinde.id)
if not fresh:
return False
gemeinde = fresh
existing_bzo = False
existing_quellen: Set[str] = set()
if gemeinde.dokumente:
for doc in gemeinde.dokumente:
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
label = getattr(doc, "label", None) or (doc.get("label") if isinstance(doc, dict) else None)
q = _extract_quelle(doc)
if q:
existing_quellen.add(q)
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]:
existing_bzo = True
break
if label and any(x in (label or "").upper() for x in ("BZO", "BAU UND ZONENORDNUNG", "PLAN D'AMÉNAGEMENT", "RÈGLEMENT DE CONSTRUCTION", "PIANO DI", "REGOLAMENTO EDILIZIO")):
existing_bzo = True
break
if existing_bzo:
return True
kanton_abk = None
if gemeinde.id_kanton:
k = interface.getKanton(gemeinde.id_kanton)
if k:
kanton_abk = k.abk
language = _get_language_from_kanton(kanton_abk)
search_query = _get_bzo_search_query(gemeinde.label, language)
logger.info(f"Tavily BZO search for {gemeinde.label}: {search_query}")
tavily = AiTavily()
gemeinde_normalized = _normalize_gemeinde_for_match(gemeinde.label or "")
search_results = await tavily._search(
query=search_query,
maxResults=10,
country="switzerland",
excludeDomains=_EXCLUDE_BZO_DOMAINS,
)
if not search_results:
logger.warning(f"No Tavily search results for BZO of {gemeinde.label}")
return False
logger.info(f"Tavily returned {len(search_results)} results for BZO of {gemeinde.label}")
# Filter: ONLY keep PDF URLs that are the actual BZO ordinance (not articles/project docs)
def _is_valid_bzo_result(url: str, title: str) -> bool:
combined = f"{url} {title}".lower()
combined_norm = _normalize_gemeinde_for_match(combined)
# 1. Gemeinde name MUST appear in URL or title
if not gemeinde_normalized or gemeinde_normalized not in combined_norm:
return False
# 2. MUST contain BZO ordinance keyword (actual regulation, not just "about" it)
if not any(kw in combined for kw in _BZO_ORDINANCE_KEYWORDS):
return False
# 3. EXCLUDE if it looks like an article or project planning doc
if any(kw in combined for kw in _BZO_ARTICLE_PROJECT_KEYWORDS):
return False
return True
pdf_urls = [
r.url
for r in search_results
if (r.url.lower().endswith(".pdf") or "/pdf" in r.url.lower())
and _is_valid_bzo_result(r.url, r.title or "")
]
if not pdf_urls:
logger.warning(
f"No PDF URLs with matching Gemeinde name for {gemeinde.label} "
f"(filtered {len(search_results)} results, requiring .pdf and name in URL/title)"
)
return False
# Prepend known direct PDF URLs for this Gemeinde (avoids SSL/HTML issues with Tavily results)
gemeinde_key = gemeinde.label.strip().lower() if gemeinde.label else ""
if gemeinde_key and gemeinde_key in KNOWN_BZO_PDF_URLS:
known_url = KNOWN_BZO_PDF_URLS[gemeinde_key]
pdf_urls = [known_url] + [u for u in pdf_urls if u != known_url]
logger.info(f"Using known BZO PDF URL for {gemeinde.label}")
# Use ssl.CERT_NONE to avoid CERTIFICATE_VERIFY_FAILED on Windows/corporate environments
# (same approach as routeRealEstate for external HTTP requests)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context)
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/pdf,*/*"}
timeout = aiohttp.ClientTimeout(total=30)
async def download_pdf(session: aiohttp.ClientSession, url: str) -> Optional[bytes]:
for attempt in range(3):
try:
async with session.get(url, allow_redirects=True) as resp:
if resp.status == 200:
data = await resp.read()
if data and len(data) >= 100 and data.startswith(b"%PDF"):
return data
if data.startswith(b"<") or data.startswith(b"<!DOCTYPE"):
raise Exception("Server returned HTML instead of PDF")
elif resp.status == 406 and attempt < 2:
await asyncio.sleep(2)
continue
else:
raise Exception(f"HTTP {resp.status}")
except Exception:
if attempt >= 2:
raise
await asyncio.sleep(2)
return None
created_dokumente: List[Any] = []
current_dokumente = list(gemeinde.dokumente) if gemeinde.dokumente else []
safe_name = "".join(c for c in gemeinde.label if c.isalnum() or c in (" ", "-", "_")).strip().replace(" ", "_") or "Gemeinde"
base_label = f"BZO {gemeinde.label}" if language == "de" else (f"Plan d'aménagement local {gemeinde.label}" if language == "fr" else f"Piano di utilizzazione {gemeinde.label}")
# Track content hashes to avoid duplicate PDFs from different URLs
seen_content_hashes: Set[str] = set()
async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector) as session:
for idx, pdf_url in enumerate(pdf_urls[:5]):
# Skip URL we already have
if pdf_url in existing_quellen:
logger.debug(f"Skipping duplicate URL for {gemeinde.label}: {pdf_url[:60]}...")
continue
try:
pdf_content = await download_pdf(session, pdf_url)
if not pdf_content or len(pdf_content) < 100:
continue
# Deduplicate by content hash (same PDF from different URLs)
content_hash = hashlib.sha256(pdf_content[:8192]).hexdigest()
if content_hash in seen_content_hashes:
logger.debug(f"Skipping duplicate content for {gemeinde.label} (hash match)")
continue
seen_content_hashes.add(content_hash)
file_name = f"BZO_{safe_name}.pdf"
doc_label = base_label
file_item = componentInterface.createFile(name=file_name, mimeType="application/pdf", content=pdf_content)
componentInterface.createFileData(file_item.id, pdf_content)
dokument = Dokument(
mandateId=mandateId,
featureInstanceId=instanceId,
label=doc_label,
versionsbezeichnung="Aktuell",
dokumentTyp=DokumentTyp.GEMEINDE_BZO_AKTUELL,
dokumentReferenz=file_item.id,
quelle=pdf_url,
mimeType="application/pdf",
kategorienTags=["BZO", "Bauordnung", gemeinde.label],
)
created_dok = interface.createDokument(dokument)
created_dokumente.append(created_dok)
current_dokumente.append(created_dok)
existing_quellen.add(pdf_url)
# Create at most 1 BZO document per Gemeinde to prevent duplicates
logger.info(f"Created BZO document for {gemeinde.label}, stopping (1 doc per Gemeinde)")
break
except Exception as ex:
logger.warning(f"Error downloading BZO for {gemeinde.label} from {pdf_url}: {ex}")
continue
if created_dokumente:
interface.updateGemeinde(gemeinde.id, {"dokumente": current_dokumente})
logger.info(f"Created {len(created_dokumente)} BZO document(s) for {gemeinde.label}")
return True
return False

View file

@ -3,11 +3,15 @@ Real Estate routes for the backend API.
Implements stateless endpoints for real estate database operations with AI-powered natural language processing.
"""
import logging
import asyncio
import json
import logging
import re
import aiohttp
import requests
from typing import Optional, Dict, Any, List, Union
from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
from fastapi.responses import JSONResponse
# Import auth modules
from modules.auth import limiter, getRequestContext, RequestContext
@ -25,6 +29,7 @@ from .datamodelFeatureRealEstate import (
Projekt,
Parzelle,
Dokument,
DokumentTyp,
Gemeinde,
Kanton,
Land,
@ -39,10 +44,18 @@ from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
from .mainRealEstate import (
processNaturalLanguageCommand,
create_project_with_parcel_data,
extract_bzo_information,
)
from .parcelSelectionService import compute_selection_summary, is_parcel_adjacent_to_selection
# Import Swiss Topo MapServer connector for testing
# Import Swiss Topo MapServer, ÖREB and Zurich WFS connectors
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
from modules.connectors.connectorOerebWfs import OerebWfsConnector
from modules.connectors.connectorZhWfsParcels import ZhWfsParcelsConnector
# Import ComponentObjects and Tavily for BZO document fetch
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
from modules.aicore.aicorePluginTavily import AiTavily
# Import attribute utilities for model schema
from modules.shared.attributeUtils import getModelAttributeDefinitions
@ -457,6 +470,314 @@ def delete_parcel(
raise HTTPException(status_code=500, detail="Delete failed")
# ===== Helpers for Gemeinde/BZO routes =====
def _get_language_from_kanton(kanton_abk: Optional[str]) -> str:
"""Determine language (de/fr/it) based on Kanton abbreviation."""
if not kanton_abk:
return "de"
french_cantons = {"VD", "GE", "NE", "JU"}
italian_cantons = {"TI"}
kanton_upper = kanton_abk.upper()
if kanton_upper in french_cantons:
return "fr"
if kanton_upper in italian_cantons:
return "it"
return "de"
def _get_bzo_search_query(gemeinde_label: str, language: str) -> str:
"""Generate language-specific BZO search query for a Gemeinde."""
if language == "fr":
return f"Plan d'aménagement local {gemeinde_label} OR Règlement de construction {gemeinde_label}"
if language == "it":
return f"Piano di utilizzazione {gemeinde_label} OR Regolamento edilizio {gemeinde_label}"
return f"Bau und Zonenordnung {gemeinde_label}"
# ----- Instance-scoped Gemeinde and BZO routes -----
@router.get("/{instanceId}/gemeinden", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def get_instance_gemeinden(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
only_current: bool = Query(True, description="Only current municipalities (exclude historical)"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""
Fetch all Gemeinden from Swiss Topo and save to DB for this instance.
Creates Kantone as needed. Scoped to instance mandateId.
"""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
try:
oereb_connector = OerebWfsConnector()
connector = SwissTopoMapServerConnector(oereb_connector=oereb_connector)
gemeinden_data = await connector.get_all_gemeinden(only_current=only_current)
except Exception as e:
logger.error(f"Error fetching Gemeinden from Swiss Topo: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Error fetching Gemeinden: {str(e)}")
gemeinden_created = 0
gemeinden_skipped = 0
kantone_created = 0
errors: List[str] = []
kanton_cache: Dict[str, str] = {}
def find_gemeinde_by_bfs_nummer(bfs_nummer: str) -> Optional[Any]:
try:
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
for g in gemeinden:
for k in (g.kontextInformationen or []):
try:
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfs_nummer):
return g
except (json.JSONDecodeError, AttributeError):
continue
except Exception as ex:
logger.error(f"Error finding Gemeinde by BFS {bfs_nummer}: {ex}", exc_info=True)
return None
def get_or_create_kanton(kanton_abk: str) -> Optional[str]:
nonlocal kantone_created, errors
if not kanton_abk:
return None
if kanton_abk in kanton_cache:
return kanton_cache[kanton_abk]
kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kanton_abk})
if kantone:
kanton_cache[kanton_abk] = kantone[0].id
return kantone[0].id
kanton_names = {
"AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden",
"BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt",
"FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden",
"JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden",
"OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn",
"SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri",
"VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich",
}
try:
kanton_label = kanton_names.get(kanton_abk, kanton_abk)
kanton = Kanton(
mandateId=mandateId,
featureInstanceId=instanceId,
label=kanton_label,
abk=kanton_abk,
)
created = interface.createKanton(kanton)
if created and created.id:
kanton_cache[kanton_abk] = created.id
kantone_created += 1
return created.id
except Exception as ex:
errors.append(f"Error creating Kanton {kanton_abk}: {ex}")
return None
saved_gemeinden: List[Dict[str, Any]] = []
for gd in gemeinden_data:
try:
gemeinde_name = gd.get("name")
bfs_nummer = gd.get("bfs_nummer")
kanton_abk = gd.get("kanton")
if not gemeinde_name or bfs_nummer is None:
gemeinden_skipped += 1
continue
existing = find_gemeinde_by_bfs_nummer(str(bfs_nummer))
if existing:
gemeinden_skipped += 1
saved_gemeinden.append(existing.model_dump() if hasattr(existing, "model_dump") else existing)
continue
kanton_id = get_or_create_kanton(kanton_abk) if kanton_abk else None
gemeinde = Gemeinde(
mandateId=mandateId,
featureInstanceId=instanceId,
label=gemeinde_name,
id_kanton=kanton_id,
kontextInformationen=[
Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": bfs_nummer}, ensure_ascii=False))
],
)
created = interface.createGemeinde(gemeinde)
if created and created.id:
gemeinden_created += 1
saved_gemeinden.append(created.model_dump() if hasattr(created, "model_dump") else created)
else:
errors.append(f"Failed to create Gemeinde {gemeinde_name}")
gemeinden_skipped += 1
except Exception as ex:
errors.append(f"Error processing {gd.get('name', 'Unknown')}: {str(ex)}")
gemeinden_skipped += 1
return {
"gemeinden": saved_gemeinden,
"count": len(saved_gemeinden),
"stats": {
"gemeinden_created": gemeinden_created,
"gemeinden_skipped": gemeinden_skipped,
"kantone_created": kantone_created,
"error_count": len(errors),
"errors": errors[:10],
},
}
@router.post("/{instanceId}/gemeinden/fetch-bzo-documents", response_model=Dict[str, Any])
@limiter.limit("10/hour")
async def fetch_instance_bzo_documents(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Search for and download BZO documents for all Gemeinden of this instance (1 doc per Gemeinde, no duplicates)."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
componentInterface = getComponentInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
from modules.features.realEstate.realEstateGemeindeService import fetch_bzo_for_gemeinde
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
stats = {"gemeinden_processed": 0, "documents_created": 0, "documents_skipped": 0, "errors": []}
results: List[Dict[str, Any]] = []
for gemeinde in gemeinden:
gr = {"gemeinde_id": gemeinde.id, "gemeinde_label": gemeinde.label, "status": None, "dokument_ids": [], "error": None}
try:
stats["gemeinden_processed"] += 1
fetched = await fetch_bzo_for_gemeinde(
interface, componentInterface, gemeinde, mandateId, instanceId
)
if fetched:
gr["status"] = "created"
stats["documents_created"] += 1
refreshed = interface.getGemeinde(gemeinde.id)
if refreshed and refreshed.dokumente:
for doc in refreshed.dokumente:
doc_id = getattr(doc, "id", None) or (doc.get("id") if isinstance(doc, dict) else None)
if doc_id:
gr["dokument_ids"].append(doc_id)
else:
gr["status"] = "skipped"
stats["documents_skipped"] += 1
except Exception as ex:
gr["status"] = "error"
gr["error"] = str(ex)
stats["errors"].append(f"{gemeinde.label}: {str(ex)}")
results.append(gr)
return {"success": True, "stats": stats, "results": results}
@router.get("/{instanceId}/parcel-documents", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def get_parcel_documents(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
gemeinde: str = Query(..., description="Gemeinde name (e.g. Zürich)"),
bauzone: str = Query(..., description="Bauzone code (e.g. W5)"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""
Ensure BZO document exists for Gemeinde, return documents for parcel info display.
Creates Gemeinde (Swiss Topo) and BZO (Tavily) if not in DB.
Returns documents for preview - does NOT run LangGraph.
"""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
componentInterface = getComponentInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
from modules.features.realEstate.realEstateGemeindeService import (
ensure_single_gemeinde,
fetch_bzo_for_gemeinde,
)
gemeinde_obj = None
by_label = interface.getGemeinden(recordFilter={"label": gemeinde, "mandateId": mandateId})
gemeinde_obj = by_label[0] if by_label else None
if not gemeinde_obj:
gemeinde_obj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeinde)
if not gemeinde_obj:
return {"documents": [], "error": f"Gemeinde '{gemeinde}' nicht gefunden"}
bzo_docs = []
if gemeinde_obj.dokumente:
for doc in gemeinde_obj.dokumente:
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] or str(typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision"]:
doc_id = doc.id if hasattr(doc, "id") else doc.get("id")
if doc_id:
full = interface.getDokument(doc_id)
if full and full.dokumentReferenz:
bzo_docs.append(full)
if not bzo_docs:
fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeinde_obj, mandateId, instanceId)
if fetched:
gemeinde_obj = interface.getGemeinde(gemeinde_obj.id)
if gemeinde_obj and gemeinde_obj.dokumente:
for doc in gemeinde_obj.dokumente:
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]:
doc_id = doc.id if hasattr(doc, "id") else doc.get("id")
if doc_id:
full = interface.getDokument(doc_id)
if full and full.dokumentReferenz:
bzo_docs.append(full)
result = []
for d in bzo_docs:
result.append({
"id": d.id,
"label": d.label,
"fileId": d.dokumentReferenz,
"fileName": (d.label or "BZO") + ".pdf",
"mimeType": d.mimeType or "application/pdf",
})
return {"documents": result, "gemeinde": gemeinde, "bauzone": bauzone}
@router.get("/{instanceId}/bzo-information", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def get_instance_bzo_information(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
gemeinde: str = Query(..., description="Gemeinde name or ID"),
bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"),
parcel_ids: Optional[str] = Query(None, description="Comma-separated parcel IDs; total area computed from parcels"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Extract BZO information for a Bauzone in a Gemeinde. Runs LangGraph workflow. With total_area_m2 or parcel_ids, includes Machbarkeitsstudie."""
mandateId = _validateInstanceAccess(instanceId, context)
parcels = None
if parcel_ids:
ids = [x.strip() for x in parcel_ids.split(",") if x.strip()]
if ids:
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parcels = []
for pid in ids:
p = interface.getParzelle(pid)
if p:
flat = dict(p) if hasattr(p, "keys") else (vars(p) if hasattr(p, "__dict__") else {})
parcels.append({"parcel": flat, "map_view": flat.get("map_view", {})})
return await extract_bzo_information(
currentUser=context.user,
gemeinde=gemeinde,
bauzone=bauzone,
mandateId=mandateId,
featureInstanceId=instanceId,
total_area_m2=total_area_m2,
parcels=parcels,
)
# ============================================================================
# LEGACY / STATELESS ROUTES (unchanged)
# ============================================================================
@ -995,12 +1316,38 @@ async def create_table_record(
)
@router.get("/parcel/wfs")
@limiter.limit("60/minute")
def get_parcels_wfs(
request: Request,
bbox: str = Query(..., description="Bounding box as minx,miny,maxx,maxy in LV95 (EPSG:2056)"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""
Fetch parcel geometries from geodienste.ch OGC API (Swiss Liegenschaften) within bounding box.
Returns GeoJSON FeatureCollection in WGS84 for map display.
"""
try:
connector = ZhWfsParcelsConnector()
geojson = connector.get_parcels_by_bbox(bbox)
return JSONResponse(content=geojson)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error fetching WFS parcels: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to fetch parcel data from WFS"
)
@router.get("/parcel/search", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def search_parcel(
request: Request,
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
include_bauzone: bool = Query(True, description="Include Bauzone from ÖREB WFS (zone information)"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
@ -1010,12 +1357,14 @@ async def search_parcel(
- Parcel identification (number, EGRID, etc.)
- Precise boundary geometry for map display
- Administrative context (canton, municipality)
- Bauzone (zone code from ÖREB WFS when include_bauzone=True)
- Link to official cadastral map
- Optional: Adjacent parcels
Query Parameters:
- location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string
- include_adjacent: If true, fetches information about adjacent parcels (slower)
- include_bauzone: If true, queries ÖREB WFS for zone info (Bauzone/Wohnzone)
Headers:
- X-CSRF-Token: CSRF token (required for security)
@ -1073,6 +1422,9 @@ async def search_parcel(
"y": sum_y / len(points)
}
# Extract canton early (needed for bauzone query and municipality resolution)
canton = attributes.get("ak", "")
# Extract municipality name and address from Swiss Topo data
municipality_name = None
full_address = None
@ -1126,32 +1478,82 @@ async def search_parcel(
full_address = location
logger.debug(f"Using location as address: {full_address}")
# Try to extract municipality name from address string (e.g. "Forchstrasse 6c, 8610 Uster")
if not municipality_name and full_address:
plz_municipality_match = re.search(r"\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)", full_address)
if plz_municipality_match:
extracted_municipality = plz_municipality_match.group(2).strip()
extracted_municipality = re.sub(r"[,;\.]+$", "", extracted_municipality).strip()
if extracted_municipality:
municipality_name = extracted_municipality
if not plz:
plz = plz_municipality_match.group(1)
logger.debug(f"Extracted municipality from address: {municipality_name}")
# Try to extract municipality name from BFSNR if not found
if not municipality_name:
# Common Swiss municipalities lookup (you can expand this)
bfsnr = attributes.get("bfsnr")
canton = attributes.get("ak", "")
bfsnr = attributes.get("bfsnr")
if not municipality_name and bfsnr and canton and context.mandateId:
try:
interface = getRealEstateInterface(
context.user, mandateId=str(context.mandateId), featureInstanceId=None
)
gemeinden = interface.getGemeinden(recordFilter={"mandateId": str(context.mandateId)})
for g in gemeinden:
for k in (g.kontextInformationen or []):
try:
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
if isinstance(data, dict):
bfs = data.get("bfs_nummer") or data.get("bfsnr") or data.get("municipality_code")
if str(bfs) == str(bfsnr):
municipality_name = g.label
logger.debug(f"Found Gemeinde by BFS {bfsnr} in DB: {municipality_name}")
break
except (json.JSONDecodeError, AttributeError):
continue
if municipality_name:
break
except Exception as e:
logger.debug(f"Error querying Gemeinde by BFS: {e}")
# Basic municipality lookup for common codes
# Swiss Topo geocoding to get municipality from coordinates
if not municipality_name and centroid and canton:
try:
geocode_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
params = {
"geometry": f"{centroid['x']},{centroid['y']}",
"geometryType": "esriGeometryPoint",
"layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill",
"tolerance": "0",
"returnGeometry": "false",
"sr": "2056",
"f": "json",
}
async with aiohttp.ClientSession() as session:
async with session.get(geocode_url, params=params) as resp:
if resp.status == 200:
data = await resp.json()
results = data.get("results", [])
if results:
attrs = results[0].get("attributes", {})
geo_name = attrs.get("name") or attrs.get("gemeindename") or attrs.get("label")
if geo_name:
municipality_name = connector._clean_municipality_name(str(geo_name))
logger.debug(f"Found municipality via Swiss Topo geocoding: {municipality_name}")
except Exception as e:
logger.debug(f"Error querying Swiss Topo geocoding: {e}")
# Expanded common municipalities fallback
if not municipality_name and bfsnr:
common_municipalities = {
351: "Bern",
261: "Zuerich",
6621: "Geneve",
2701: "Basel",
5586: "Lausanne",
1061: "Luzern",
3203: "Winterthur",
230: "St. Gallen",
5192: "Lugano",
1367: "Schwyz"
261: "Zürich", 198: "Pfäffikon", 191: "Uster", 3203: "Winterthur",
351: "Bern", 2701: "Basel", 6621: "Genève", 5586: "Lausanne",
1061: "Luzern", 230: "St. Gallen", 5192: "Lugano", 1367: "Schwyz",
}
if bfsnr and bfsnr in common_municipalities:
if bfsnr in common_municipalities:
municipality_name = common_municipalities[bfsnr]
logger.debug(f"Looked up municipality: {municipality_name}")
else:
# Fallback: Use canton + code
municipality_name = f"{canton}-{bfsnr}" if canton and bfsnr else "Unknown"
logger.debug(f"Looked up municipality from common list: {municipality_name}")
elif canton and bfsnr:
municipality_name = f"{canton}-{bfsnr}"
logger.debug(f"Using fallback municipality: {municipality_name}")
# Final validation: Don't use EGRID as address
@ -1160,6 +1562,29 @@ async def search_parcel(
full_address = None
logger.debug("Removed EGRID from address field")
# Query Bauzone (wohnzone) from ÖREB WFS when requested
bauzone = None
has_geometry = geometry and (geometry.get("rings") or geometry.get("coordinates"))
if include_bauzone and canton and has_geometry and centroid:
try:
logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}")
oereb_connector = OerebWfsConnector()
zone_results = await oereb_connector.query_zone_layer(
egrid=attributes.get("egris_egrid", "") or "",
x=centroid["x"],
y=centroid["y"],
canton=canton,
geometry=geometry,
)
if zone_results and len(zone_results) > 0:
zone_attrs = zone_results[0].get("attributes", {})
typ_gde_abkuerzung = zone_attrs.get("typ_gde_abkuerzung")
if typ_gde_abkuerzung:
bauzone = typ_gde_abkuerzung
logger.debug(f"Found bauzone: {bauzone} for parcel {attributes.get('label')}")
except Exception as e:
logger.warning(f"Error querying zone information: {e}", exc_info=True)
# Build parcel info
parcel_info = {
"id": attributes.get("label") or attributes.get("number"),
@ -1176,7 +1601,8 @@ async def search_parcel(
"area_m2": area_m2,
"centroid": centroid,
"geoportal_url": attributes.get("geoportal_url"),
"realestate_type": attributes.get("realestate_type")
"realestate_type": attributes.get("realestate_type"),
"bauzone": bauzone,
}
# Build map view info
@ -1310,6 +1736,172 @@ async def search_parcel(
)
@router.post("/parcel/selection-summary", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def parcel_selection_summary(
request: Request,
body: Dict[str, Any] = Body(..., description="Parcel selection data"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Compute combined outline, total area, and Bauzone grouping for selected parcels.
Request body: { "parcels": [ { parcel, map_view, perimeter, geometry_geojson, ... } ] }
"""
try:
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
)
parcels = body.get("parcels", [])
if not parcels:
return {
"combined_outline_geojson": {"type": "Polygon", "coordinates": []},
"total_area_m2": 0.0,
"bauzonen": [],
}
result = compute_selection_summary(parcels)
logger.info(f"Computed selection summary for {len(parcels)} parcels, total area {result['total_area_m2']}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error computing selection summary: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error computing selection summary: {str(e)}"
)
def _build_geometry_geojson(extracted: Dict[str, Any], parcel_info: Dict[str, Any]) -> Dict[str, Any]:
"""Build geometry_geojson from extracted perimeter for add-adjacent response."""
coords = []
if extracted.get("perimeter", {}).get("punkte"):
coords = [[[p["x"], p["y"]] for p in extracted["perimeter"]["punkte"]]]
return {
"type": "Feature",
"geometry": {"type": "Polygon", "coordinates": coords},
"properties": {"id": parcel_info["id"], "egrid": parcel_info["egrid"], "number": parcel_info["number"]},
}
@router.post("/parcel/add-adjacent", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def add_adjacent_parcel(
request: Request,
body: Dict[str, Any] = Body(..., description="Location and selected parcels"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Add an adjacent parcel to the selection. Validates that the parcel at the given
location touches the current selection.
Request body: { "location": { "x": number, "y": number }, "selected_parcels": [...] }
"""
try:
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF token missing. Please include X-CSRF-Token header."
)
location = body.get("location")
selected_parcels = body.get("selected_parcels", [])
if not location or "x" not in location or "y" not in location:
raise HTTPException(status_code=400, detail="location with x,y required")
loc_str = f"{location['x']},{location['y']}"
connector = SwissTopoMapServerConnector()
parcel_data = await connector.search_parcel(loc_str)
if not parcel_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No parcel found at this location"
)
extracted = connector.extract_parcel_attributes(parcel_data)
attributes = parcel_data.get("attributes", {})
geometry = parcel_data.get("geometry", {})
area_m2 = None
centroid = None
if extracted.get("perimeter"):
perimeter = extracted["perimeter"]
points = perimeter.get("punkte", [])
if len(points) >= 3:
area = 0
for i in range(len(points)):
j = (i + 1) % len(points)
area += points[i]["x"] * points[j]["y"]
area -= points[j]["x"] * points[i]["y"]
area_m2 = abs(area / 2)
sum_x = sum(p["x"] for p in points)
sum_y = sum(p["y"] for p in points)
centroid = {"x": sum_x / len(points), "y": sum_y / len(points)}
parcel_info = {
"id": attributes.get("label") or attributes.get("number"),
"egrid": attributes.get("egris_egrid"),
"number": attributes.get("number"),
"name": attributes.get("name"),
"identnd": attributes.get("identnd"),
"canton": attributes.get("ak"),
"municipality_code": attributes.get("bfsnr"),
"municipality_name": None,
"address": None,
"plz": None,
"perimeter": extracted.get("perimeter"),
"area_m2": area_m2,
"centroid": centroid,
"geoportal_url": attributes.get("geoportal_url"),
"realestate_type": attributes.get("realestate_type"),
"bauzone": None,
}
map_view = {
"center": centroid,
"zoom_bounds": parcel_data.get("bbox", []) and {
"min_x": parcel_data["bbox"][0],
"min_y": parcel_data["bbox"][1],
"max_x": parcel_data["bbox"][2],
"max_y": parcel_data["bbox"][3],
} or None,
"geometry_geojson": _build_geometry_geojson(extracted, parcel_info),
}
new_parcel_response = {"parcel": parcel_info, "map_view": map_view}
if not is_parcel_adjacent_to_selection(new_parcel_response, selected_parcels):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Nur angrenzende Parzellen können hinzugefügt werden"
)
bbox = parcel_data.get("bbox", [])
map_view["zoom_bounds"] = {
"min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3]
} if len(bbox) >= 4 else None
geocoded_address = parcel_data.get("geocoded_address")
if geocoded_address:
parcel_info["municipality_name"] = geocoded_address.get("municipality")
parcel_info["address"] = geocoded_address.get("full_address")
parcel_info["plz"] = geocoded_address.get("plz")
if centroid and attributes.get("ak"):
try:
oereb = OerebWfsConnector()
zone_results = await oereb.query_zone_layer(
egrid=attributes.get("egris_egrid", "") or "",
x=centroid["x"], y=centroid["y"],
canton=attributes.get("ak"),
geometry=geometry,
)
if zone_results and len(zone_results) > 0:
parcel_info["bauzone"] = zone_results[0].get("attributes", {}).get("typ_gde_abkuerzung")
except Exception as oe:
logger.debug(f"ÖREB zone query failed: {oe}")
return new_parcel_response
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding adjacent parcel: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error adding adjacent parcel: {str(e)}"
)
@router.post("/projekt/{projekt_id}/add-parcel", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def add_parcel_to_project(

View file

@ -15,7 +15,7 @@ from dataclasses import dataclass
import json
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRealEstate import (
from .datamodelFeatureRealEstate import (
Parzelle,
GeoPolylinie,
GeoPunkt,
@ -23,7 +23,7 @@ from modules.datamodels.datamodelRealEstate import (
Gemeinde,
Kanton,
)
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
from modules.connectors.connectorOerebWfs import OerebWfsConnector

View file

@ -1254,6 +1254,34 @@ class ComponentObjects:
logger.error(f"Error processing file data for {fileId}: {str(e)}")
return None
def getFileDataForPublicDocument(self, fileId: str) -> Optional[bytes]:
"""
Returns binary data for public documents (e.g. BZO) WITHOUT RBAC filtering.
Use for official/mandate documents that must be accessible to all users.
Reads FileData directly from database.
"""
try:
fileDataEntries = self.db.getRecordset(FileData, recordFilter={"id": fileId})
if not fileDataEntries:
logger.warning(f"No file data found for public document ID {fileId}")
return None
fileDataEntry = fileDataEntries[0]
if "data" not in fileDataEntry:
logger.warning(f"No data field in file data for ID {fileId}")
return None
data = fileDataEntry["data"]
base64Encoded = fileDataEntry.get("base64Encoded", False)
if base64Encoded:
return base64.b64decode(data)
# PDF/binary stored as text: try base64 decode (common for binary files)
try:
return base64.b64decode(data)
except Exception:
return data.encode("utf-8") if isinstance(data, str) else data
except Exception as e:
logger.error(f"Error retrieving public document {fileId}: {str(e)}", exc_info=True)
return None
def getFileContent(self, fileId: str) -> Optional[FilePreview]:
"""Returns the full file content if user has access."""
try:

View file

@ -2254,6 +2254,7 @@ async def get_bzo_information(
request: Request,
gemeinde: str = Query(..., description="Gemeinde name or ID"),
bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""
@ -2348,7 +2349,8 @@ async def get_bzo_information(
result = await extract_bzo_information(
currentUser=currentUser,
gemeinde=gemeinde,
bauzone=bauzone
bauzone=bauzone,
total_area_m2=total_area_m2,
)
return result

View file

@ -100,7 +100,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
from modules.features.trustee.mainTrustee import UI_OBJECTS
return UI_OBJECTS
elif featureCode == "realestate":
from modules.features.realestate.mainRealEstate import UI_OBJECTS
from modules.features.realEstate.mainRealEstate import UI_OBJECTS
return UI_OBJECTS
elif featureCode == "chatplayground":
from modules.features.chatplayground.mainChatplayground import UI_OBJECTS