gateway/modules/features/realEstate/mainRealEstate.py

769 lines
32 KiB
Python

"""
Real Estate feature main logic.
Handles database operations with AI-powered natural language processing.
Stateless implementation without session management.
"""
import logging
import json
from typing import Optional, Dict, Any
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRealEstate import (
Projekt,
Parzelle,
StatusProzess,
)
from modules.services import getInterface as getServices
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
logger = logging.getLogger(__name__)
# ===== Direkte Query-Ausführung (stateless) =====
async def executeDirectQuery(
currentUser: User,
queryText: str,
parameters: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Execute a database query directly without session management.
Args:
currentUser: Current authenticated user
queryText: SQL query text
parameters: Optional parameters for parameterized queries
Returns:
Dictionary containing query result (rows, columns, rowCount)
Note:
- No session or query history is saved
- Query is executed directly and result is returned
- For production, validate and sanitize queries before execution
- TODO: Implement actual database query execution via interface
"""
try:
logger.info(f"Executing direct query for user {currentUser.id} (mandate: {currentUser.mandateId})")
logger.debug(f"Query text: {queryText}")
if parameters:
logger.debug(f"Query parameters: {parameters}")
# Execute query via Real Estate interface (stateless)
realEstateInterface = getRealEstateInterface(currentUser)
result = realEstateInterface.executeQuery(queryText, parameters)
logger.info(
f"Query executed successfully: {result['rowCount']} rows in {result.get('executionTime', 0):.3f}s"
)
return {
"status": "success",
"rows": result["rows"],
"columns": result["columns"],
"rowCount": result["rowCount"],
"executionTime": result.get("executionTime", 0),
}
except Exception as e:
logger.error(f"Error executing query: {str(e)}", exc_info=True)
raise
# ===== AI-basierte Intent-Erkennung und CRUD-Operationen =====
async def processNaturalLanguageCommand(
currentUser: User,
userInput: str,
) -> Dict[str, Any]:
"""
Process natural language user input and execute corresponding CRUD operations.
Uses AI to analyze user intent and extract parameters, then executes the appropriate
CRUD operation through the interface. Works stateless without session management.
Args:
currentUser: Current authenticated user
userInput: Natural language command from user
Returns:
Dictionary containing operation result and metadata
Example user inputs:
- "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"
- "Zeige mir alle Projekte in Zürich"
- "Aktualisiere Projekt XYZ mit Status 'Planung'"
- "Lösche Parzelle ABC"
- "SELECT * FROM Projekt WHERE plz = '8000'"
"""
try:
logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {currentUser.mandateId})")
logger.debug(f"User input: {userInput}")
# Initialize services for AI access
services = getServices(currentUser, workflow=None)
aiService = services.ai
# Step 1: Analyze user intent with AI
intentAnalysis = await analyzeUserIntent(aiService, userInput)
logger.info(f"Intent analysis result: intent={intentAnalysis.get('intent')}, entity={intentAnalysis.get('entity')}")
# Step 2: Execute CRUD operation based on intent
result = await executeIntentBasedOperation(
currentUser=currentUser,
intent=intentAnalysis["intent"],
entity=intentAnalysis.get("entity"),
parameters=intentAnalysis.get("parameters", {}),
)
return {
"success": True,
"intent": intentAnalysis["intent"],
"entity": intentAnalysis.get("entity"),
"result": result,
}
except Exception as e:
logger.error(f"Error processing natural language command: {str(e)}", exc_info=True)
raise
async def analyzeUserIntent(
aiService,
userInput: str
) -> Dict[str, Any]:
"""
Use AI to analyze user input and extract intent, entity, and parameters.
Args:
aiService: AI service instance
userInput: Natural language user input
Returns:
Dictionary with 'intent', 'entity', and 'parameters'
"""
# Create a structured prompt for intent analysis with accurate field information
intentPrompt = f"""
Analyze the following user command and extract the intent, entity, and parameters.
User Command: "{userInput}"
Available intents:
- CREATE: User wants to create a new entity
- READ: User wants to read/query entities
- UPDATE: User wants to update an existing entity
- DELETE: User wants to delete an entity
- QUERY: User wants to execute a database query (SQL statements)
Available entities and their fields:
**Projekt** (Real estate project):
- id: string (primary key)
- mandateId: string (mandate ID)
- label: string (project designation/name)
- statusProzess: string enum (Eingang, Analyse, Studie, Planung, Baurechtsverfahren, Umsetzung, Archiv)
- perimeter: GeoPolylinie (geographic boundary, JSONB)
- baulinie: GeoPolylinie (building line, JSONB)
- parzellen: List[Parzelle] (plots belonging to project, JSONB)
- dokumente: List[Dokument] (documents, JSONB)
- kontextInformationen: List[Kontext] (context info, JSONB)
**Parzelle** (Plot/parcel):
- id: string (primary key)
- mandateId: string (mandate ID)
- label: string (plot designation)
- strasseNr: string (street and house number)
- plz: string (postal code)
- kontextGemeinde: string (municipality ID, Foreign Key to Gemeinde table)
- bauzone: string (building zone, e.g. W3, WG2)
- az: float (Ausnützungsziffer)
- bz: float (Bebauungsziffer)
- vollgeschossZahl: int (number of allowed full floors)
- gebaeudehoeheMax: float (maximum building height in meters)
- laermschutzzone: string (noise protection zone)
- hochwasserschutzzone: string (flood protection zone)
- grundwasserschutzzone: string (groundwater protection zone)
- parzelleBebaut: JaNein enum (is plot built)
- parzelleErschlossen: JaNein enum (is plot developed)
- parzelleHanglage: JaNein enum (is plot on slope)
**Important relationships:**
- Projekte contain Parzellen (projects have plots)
- Parzelle links to Gemeinde (via kontextGemeinde)
- Gemeinde links to Kanton (via id_kanton)
- Kanton links to Land (via id_land)
- Location queries (city, postal code) should use Parzelle.kontextGemeinde (municipality name will be resolved to ID)
- Projekt does NOT have location fields directly - location is stored in associated Parzellen
Return a JSON object with the following structure:
{{
"intent": "CREATE|READ|UPDATE|DELETE|QUERY",
"entity": "Projekt|Parzelle|Dokument|Kanton|Gemeinde|null",
"parameters": {{
// Extracted parameters from user input
// For CREATE/UPDATE: include all relevant fields using EXACT field names from above
// For READ: include filter criteria using EXACT field names (id, label, plz, kontextGemeinde, etc.)
// For DELETE: include entity ID if mentioned
// For QUERY: include queryText if SQL is detected
// IMPORTANT: Use only field names that exist in the entity definition above
}},
"confidence": 0.0-1.0 // Confidence score for the analysis
}}
Examples:
- Input: "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"
Output: {{"intent": "CREATE", "entity": "Projekt", "parameters": {{"label": "Hauptstrasse 42"}}, "confidence": 0.95}}
- Input: "Zeige mir alle Projekte"
Output: {{"intent": "READ", "entity": "Projekt", "parameters": {{}}, "confidence": 0.9}}
- Input: "Zeige mir Projekte in Zürich"
Output: {{"intent": "READ", "entity": "Parzelle", "parameters": {{"kontextGemeinde": "Zürich"}}, "confidence": 0.9}}
Note: Location queries should query Parzelle, not Projekt directly
- Input: "Zeige mir Parzellen mit PLZ 8000"
Output: {{"intent": "READ", "entity": "Parzelle", "parameters": {{"plz": "8000"}}, "confidence": 0.95}}
- Input: "Aktualisiere Projekt XYZ mit Status 'Planung'"
Output: {{"intent": "UPDATE", "entity": "Projekt", "parameters": {{"id": "XYZ", "statusProzess": "Planung"}}, "confidence": 0.85}}
- Input: "SELECT * FROM Projekt WHERE label = 'Test'"
Output: {{"intent": "QUERY", "entity": null, "parameters": {{"queryText": "SELECT * FROM Projekt WHERE label = 'Test'", "queryType": "sql"}}, "confidence": 1.0}}
- Input: "Lösche Parzelle ABC"
Output: {{"intent": "DELETE", "entity": "Parzelle", "parameters": {{"id": "ABC"}}, "confidence": 0.9}}
"""
try:
# Use AI planning call for structured JSON response
response = await aiService.callAiPlanning(
prompt=intentPrompt,
debugType="intentanalysis"
)
# Extract JSON from response (handles markdown code blocks)
jsonStart = response.find('{')
jsonEnd = response.rfind('}') + 1
if jsonStart == -1 or jsonEnd == 0:
raise ValueError("No JSON found in AI response")
jsonStr = response[jsonStart:jsonEnd]
# Parse JSON response
intentData = json.loads(jsonStr)
# Validate response structure
if "intent" not in intentData:
raise ValueError("Invalid intent analysis response: missing 'intent' field")
# Ensure parameters exists
if "parameters" not in intentData:
intentData["parameters"] = {}
logger.debug(f"Parsed intent analysis: {intentData}")
return intentData
except json.JSONDecodeError as e:
logger.error(f"Failed to parse AI intent analysis response: {e}")
logger.error(f"Raw response: {response}")
raise ValueError(f"AI returned invalid JSON: {str(e)}")
except Exception as e:
logger.error(f"Error analyzing user intent: {str(e)}", exc_info=True)
raise
async def executeIntentBasedOperation(
currentUser: User,
intent: str,
entity: Optional[str],
parameters: Dict[str, Any],
) -> Dict[str, Any]:
"""
Execute CRUD operation based on analyzed intent.
Args:
currentUser: Current authenticated user
intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY)
entity: Entity type from AI analysis
parameters: Extracted parameters from AI analysis
Returns:
Operation result
Note:
- TODO: Implement actual interface calls once datamodels are ready
- Currently returns test responses showing what would be executed
"""
try:
logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}")
logger.debug(f"Parameters: {parameters}")
if intent == "QUERY":
# Execute database query directly (stateless)
queryText = parameters.get("queryText", "")
if not queryText:
raise ValueError("QUERY intent requires queryText in parameters")
result = await executeDirectQuery(
currentUser=currentUser,
queryText=queryText,
parameters=parameters.get("queryParameters"),
)
return result
elif intent == "CREATE":
# Create new entity
realEstateInterface = getRealEstateInterface(currentUser)
if entity == "Projekt":
# Create Projekt from parameters
projekt = Projekt(
mandateId=currentUser.mandateId,
label=parameters.get("label", ""),
statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None,
)
created = realEstateInterface.createProjekt(projekt)
return {
"operation": "CREATE",
"entity": "Projekt",
"result": created.model_dump()
}
elif entity == "Parzelle":
# Create Parzelle from parameters
parzelle = Parzelle(
mandateId=currentUser.mandateId,
label=parameters.get("label", ""),
strasseNr=parameters.get("strasseNr"),
plz=parameters.get("plz"),
bauzone=parameters.get("bauzone"),
kontextGemeinde=parameters.get("kontextGemeinde"),
)
created = realEstateInterface.createParzelle(parzelle)
return {
"operation": "CREATE",
"entity": "Parzelle",
"result": created.model_dump()
}
elif entity == "Gemeinde":
# Create Gemeinde from parameters
from modules.datamodels.datamodelRealEstate import Gemeinde
gemeinde = Gemeinde(
mandateId=currentUser.mandateId,
label=parameters.get("label", ""),
id_kanton=parameters.get("id_kanton"),
plz=parameters.get("plz"),
)
created = realEstateInterface.createGemeinde(gemeinde)
return {
"operation": "CREATE",
"entity": "Gemeinde",
"result": created.model_dump()
}
elif entity == "Kanton":
# Create Kanton from parameters
from modules.datamodels.datamodelRealEstate import Kanton
kanton = Kanton(
mandateId=currentUser.mandateId,
label=parameters.get("label", ""),
id_land=parameters.get("id_land"),
abk=parameters.get("abk"),
)
created = realEstateInterface.createKanton(kanton)
return {
"operation": "CREATE",
"entity": "Kanton",
"result": created.model_dump()
}
elif entity == "Land":
# Create Land from parameters
from modules.datamodels.datamodelRealEstate import Land
land = Land(
mandateId=currentUser.mandateId,
label=parameters.get("label", ""),
abk=parameters.get("abk"),
)
created = realEstateInterface.createLand(land)
return {
"operation": "CREATE",
"entity": "Land",
"result": created.model_dump()
}
elif entity == "Dokument":
# Create Dokument from parameters
from modules.datamodels.datamodelRealEstate import Dokument
dokument = Dokument(
mandateId=currentUser.mandateId,
label=parameters.get("label", ""),
dokumentReferenz=parameters.get("dokumentReferenz", ""),
versionsbezeichnung=parameters.get("versionsbezeichnung"),
dokumentTyp=parameters.get("dokumentTyp"),
quelle=parameters.get("quelle"),
mimeType=parameters.get("mimeType"),
)
created = realEstateInterface.createDokument(dokument)
return {
"operation": "CREATE",
"entity": "Dokument",
"result": created.model_dump()
}
else:
raise ValueError(f"CREATE operation not supported for entity: {entity}")
elif intent == "READ":
# Read entities
realEstateInterface = getRealEstateInterface(currentUser)
if entity == "Projekt":
projektId = parameters.get("id")
if projektId:
# Get single Projekt by ID
projekt = realEstateInterface.getProjekt(projektId)
if not projekt:
raise ValueError(f"Projekt {projektId} not found")
return {
"operation": "READ",
"entity": "Projekt",
"result": projekt.model_dump()
}
else:
# List all Projekte (with optional filters)
# Validate filter fields against Projekt model
validProjektFields = {"id", "mandateId", "label", "statusProzess"}
recordFilter = {
k: v for k, v in parameters.items()
if k != "id" and k in validProjektFields
}
# Warn about invalid fields
invalidFields = {k: v for k, v in parameters.items() if k not in validProjektFields and k != "id"}
if invalidFields:
logger.warning(f"Invalid filter fields for Projekt ignored: {list(invalidFields.keys())}")
logger.info("Note: Location queries should use Parzelle entity, not Projekt")
projekte = realEstateInterface.getProjekte(recordFilter=recordFilter if recordFilter else None)
return {
"operation": "READ",
"entity": "Projekt",
"result": [p.model_dump() for p in projekte]
}
elif entity == "Parzelle":
parzelleId = parameters.get("id")
if parzelleId:
# Get single Parzelle by ID
parzelle = realEstateInterface.getParzelle(parzelleId)
if not parzelle:
raise ValueError(f"Parzelle {parzelleId} not found")
return {
"operation": "READ",
"entity": "Parzelle",
"result": parzelle.model_dump()
}
else:
# List all Parzellen (with optional filters)
# Validate filter fields against Parzelle model
# Note: kontextKanton and kontextLand are NOT direct fields on Parzelle
# Parzelle links to Gemeinde, Gemeinde links to Kanton, Kanton links to Land
validParzelleFields = {
"id", "mandateId", "label", "strasseNr", "plz",
"kontextGemeinde", # Only direct link - Gemeinde → Kanton → Land
"bauzone", "az", "bz", "vollgeschossZahl", "gebaeudehoeheMax",
"laermschutzzone", "hochwasserschutzzone", "grundwasserschutzzone",
"parzelleBebaut", "parzelleErschlossen", "parzelleHanglage"
}
recordFilter = {
k: v for k, v in parameters.items()
if k != "id" and k in validParzelleFields
}
# Warn about invalid fields
invalidFields = {k: v for k, v in parameters.items() if k not in validParzelleFields and k != "id"}
if invalidFields:
logger.warning(f"Invalid filter fields for Parzelle ignored: {list(invalidFields.keys())}")
parzellen = realEstateInterface.getParzellen(recordFilter=recordFilter if recordFilter else None)
return {
"operation": "READ",
"entity": "Parzelle",
"result": [p.model_dump() for p in parzellen]
}
elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde
gemeindeId = parameters.get("id")
if gemeindeId:
gemeinde = realEstateInterface.getGemeinde(gemeindeId)
if not gemeinde:
raise ValueError(f"Gemeinde {gemeindeId} not found")
return {
"operation": "READ",
"entity": "Gemeinde",
"result": gemeinde.model_dump()
}
else:
recordFilter = {k: v for k, v in parameters.items() if k != "id"}
gemeinden = realEstateInterface.getGemeinden(recordFilter=recordFilter if recordFilter else None)
return {
"operation": "READ",
"entity": "Gemeinde",
"result": [g.model_dump() for g in gemeinden]
}
elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton
kantonId = parameters.get("id")
if kantonId:
kanton = realEstateInterface.getKanton(kantonId)
if not kanton:
raise ValueError(f"Kanton {kantonId} not found")
return {
"operation": "READ",
"entity": "Kanton",
"result": kanton.model_dump()
}
else:
recordFilter = {k: v for k, v in parameters.items() if k != "id"}
kantone = realEstateInterface.getKantone(recordFilter=recordFilter if recordFilter else None)
return {
"operation": "READ",
"entity": "Kanton",
"result": [k.model_dump() for k in kantone]
}
elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land
landId = parameters.get("id")
if landId:
land = realEstateInterface.getLand(landId)
if not land:
raise ValueError(f"Land {landId} not found")
return {
"operation": "READ",
"entity": "Land",
"result": land.model_dump()
}
else:
recordFilter = {k: v for k, v in parameters.items() if k != "id"}
laender = realEstateInterface.getLaender(recordFilter=recordFilter if recordFilter else None)
return {
"operation": "READ",
"entity": "Land",
"result": [l.model_dump() for l in laender]
}
elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument
dokumentId = parameters.get("id")
if dokumentId:
dokument = realEstateInterface.getDokument(dokumentId)
if not dokument:
raise ValueError(f"Dokument {dokumentId} not found")
return {
"operation": "READ",
"entity": "Dokument",
"result": dokument.model_dump()
}
else:
recordFilter = {k: v for k, v in parameters.items() if k != "id"}
dokumente = realEstateInterface.getDokumente(recordFilter=recordFilter if recordFilter else None)
return {
"operation": "READ",
"entity": "Dokument",
"result": [d.model_dump() for d in dokumente]
}
else:
raise ValueError(f"READ operation not supported for entity: {entity}")
elif intent == "UPDATE":
# Update existing entity
realEstateInterface = getRealEstateInterface(currentUser)
if entity == "Projekt":
projektId = parameters.get("id")
if not projektId:
raise ValueError("UPDATE operation requires entity ID")
# Get existing projekt
projekt = realEstateInterface.getProjekt(projektId)
if not projekt:
raise ValueError(f"Projekt {projektId} not found")
# Update fields
updateData = {k: v for k, v in parameters.items() if k != "id"}
updated = realEstateInterface.updateProjekt(projektId, updateData)
return {
"operation": "UPDATE",
"entity": "Projekt",
"result": updated.model_dump()
}
elif entity == "Parzelle":
parzelleId = parameters.get("id")
if not parzelleId:
raise ValueError("UPDATE operation requires entity ID")
# Get existing parzelle
parzelle = realEstateInterface.getParzelle(parzelleId)
if not parzelle:
raise ValueError(f"Parzelle {parzelleId} not found")
# Update fields
updateData = {k: v for k, v in parameters.items() if k != "id"}
updated = realEstateInterface.updateParzelle(parzelleId, updateData)
return {
"operation": "UPDATE",
"entity": "Parzelle",
"result": updated.model_dump()
}
elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde
gemeindeId = parameters.get("id")
if not gemeindeId:
raise ValueError("UPDATE operation requires entity ID")
gemeinde = realEstateInterface.getGemeinde(gemeindeId)
if not gemeinde:
raise ValueError(f"Gemeinde {gemeindeId} not found")
updateData = {k: v for k, v in parameters.items() if k != "id"}
updated = realEstateInterface.updateGemeinde(gemeindeId, updateData)
return {
"operation": "UPDATE",
"entity": "Gemeinde",
"result": updated.model_dump()
}
elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton
kantonId = parameters.get("id")
if not kantonId:
raise ValueError("UPDATE operation requires entity ID")
kanton = realEstateInterface.getKanton(kantonId)
if not kanton:
raise ValueError(f"Kanton {kantonId} not found")
updateData = {k: v for k, v in parameters.items() if k != "id"}
updated = realEstateInterface.updateKanton(kantonId, updateData)
return {
"operation": "UPDATE",
"entity": "Kanton",
"result": updated.model_dump()
}
elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land
landId = parameters.get("id")
if not landId:
raise ValueError("UPDATE operation requires entity ID")
land = realEstateInterface.getLand(landId)
if not land:
raise ValueError(f"Land {landId} not found")
updateData = {k: v for k, v in parameters.items() if k != "id"}
updated = realEstateInterface.updateLand(landId, updateData)
return {
"operation": "UPDATE",
"entity": "Land",
"result": updated.model_dump()
}
elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument
dokumentId = parameters.get("id")
if not dokumentId:
raise ValueError("UPDATE operation requires entity ID")
dokument = realEstateInterface.getDokument(dokumentId)
if not dokument:
raise ValueError(f"Dokument {dokumentId} not found")
updateData = {k: v for k, v in parameters.items() if k != "id"}
updated = realEstateInterface.updateDokument(dokumentId, updateData)
return {
"operation": "UPDATE",
"entity": "Dokument",
"result": updated.model_dump()
}
else:
raise ValueError(f"UPDATE operation not supported for entity: {entity}")
elif intent == "DELETE":
# Delete entity
realEstateInterface = getRealEstateInterface(currentUser)
if entity == "Projekt":
projektId = parameters.get("id")
if not projektId:
raise ValueError("DELETE operation requires entity ID")
success = realEstateInterface.deleteProjekt(projektId)
return {
"operation": "DELETE",
"entity": "Projekt",
"success": success
}
elif entity == "Parzelle":
parzelleId = parameters.get("id")
if not parzelleId:
raise ValueError("DELETE operation requires entity ID")
success = realEstateInterface.deleteParzelle(parzelleId)
return {
"operation": "DELETE",
"entity": "Parzelle",
"success": success
}
elif entity == "Gemeinde":
from modules.datamodels.datamodelRealEstate import Gemeinde
gemeindeId = parameters.get("id")
if not gemeindeId:
raise ValueError("DELETE operation requires entity ID")
success = realEstateInterface.deleteGemeinde(gemeindeId)
return {
"operation": "DELETE",
"entity": "Gemeinde",
"success": success
}
elif entity == "Kanton":
from modules.datamodels.datamodelRealEstate import Kanton
kantonId = parameters.get("id")
if not kantonId:
raise ValueError("DELETE operation requires entity ID")
success = realEstateInterface.deleteKanton(kantonId)
return {
"operation": "DELETE",
"entity": "Kanton",
"success": success
}
elif entity == "Land":
from modules.datamodels.datamodelRealEstate import Land
landId = parameters.get("id")
if not landId:
raise ValueError("DELETE operation requires entity ID")
success = realEstateInterface.deleteLand(landId)
return {
"operation": "DELETE",
"entity": "Land",
"success": success
}
elif entity == "Dokument":
from modules.datamodels.datamodelRealEstate import Dokument
dokumentId = parameters.get("id")
if not dokumentId:
raise ValueError("DELETE operation requires entity ID")
success = realEstateInterface.deleteDokument(dokumentId)
return {
"operation": "DELETE",
"entity": "Dokument",
"success": success
}
else:
raise ValueError(f"DELETE operation not supported for entity: {entity}")
else:
raise ValueError(f"Unknown intent: {intent}")
except Exception as e:
logger.error(f"Error executing intent-based operation: {str(e)}", exc_info=True)
raise