fixed complete langgraph workflow and information fetching
This commit is contained in:
parent
3ff3cfd51c
commit
69aa73ed73
15 changed files with 2592 additions and 653 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
89
modules/connectors/connectorZhWfsParcels.py
Normal file
89
modules/connectors/connectorZhWfsParcels.py
Normal 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": []}
|
||||
|
|
@ -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
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,60 +2333,69 @@ 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 (m²) 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}")
|
||||
|
||||
# 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
180
modules/features/realEstate/parcelSelectionService.py
Normal file
180
modules/features/realEstate/parcelSelectionService.py
Normal 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
|
||||
377
modules/features/realEstate/realEstateGemeindeService.py
Normal file
377
modules/features/realEstate/realEstateGemeindeService.py
Normal 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
|
||||
|
|
@ -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", "")
|
||||
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
|
||||
common_municipalities = {
|
||||
351: "Bern",
|
||||
261: "Zuerich",
|
||||
6621: "Geneve",
|
||||
2701: "Basel",
|
||||
5586: "Lausanne",
|
||||
1061: "Luzern",
|
||||
3203: "Winterthur",
|
||||
230: "St. Gallen",
|
||||
5192: "Lugano",
|
||||
1367: "Schwyz"
|
||||
# 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}")
|
||||
|
||||
if bfsnr and bfsnr in common_municipalities:
|
||||
# Expanded common municipalities fallback
|
||||
if not municipality_name and bfsnr:
|
||||
common_municipalities = {
|
||||
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 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']} m²")
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue