diff --git a/app.py b/app.py index a167503c..0e8eef2d 100644 --- a/app.py +++ b/app.py @@ -406,6 +406,9 @@ app.include_router(workflowRouter) from modules.routes.routeChatPlayground import router as chatPlaygroundRouter app.include_router(chatPlaygroundRouter) +from modules.routes.routeRealEstate import router as realEstateRouter +app.include_router(realEstateRouter) + from modules.routes.routeSecurityLocal import router as localRouter app.include_router(localRouter) diff --git a/docs/PEK_datamodel_desc.md b/docs/PEK_datamodel_desc.md new file mode 100644 index 00000000..d2b94e5e --- /dev/null +++ b/docs/PEK_datamodel_desc.md @@ -0,0 +1,418 @@ +# Datenmodell Architektur-Planungs-App + +## Übersicht + +Dieses Datenmodell bildet die Grundlage für eine Schweizer Architektur-Planungs-App zur Verwaltung von Bauprojekten, Parzellen, Dokumenten und regulatorischen Kontextinformationen. + +## Wichtige Hinweise zum Datenmodell + +**Objektmodell vs. Datenbank-Repräsentation:** + +Dieses Dokument beschreibt ein **Objektmodell** für die Arbeit im Code. Es handelt sich **NICHT** um ein relationales Datenbankmodell mit Junction Tables. + +- **Im Code-Modell**: Alle Beziehungen werden als Objektreferenzen oder Listen von Objekten dargestellt (z.B. `dokumente: list[Dokument]`, `parzellen: list[Parzelle]`). +- **Für die Datenbank-Serialisierung**: Bei der Persistierung können Junction Tables verwendet werden, um n:m-Beziehungen in der Datenbank abzubilden. Dies ist jedoch ein Implementierungsdetail der Datenbank-Schicht und gehört nicht zum Hauptmodell. + +**Systemattribute:** + +Alle Datenobjekte haben automatisch die folgenden Systemattribute: +- `_createdAt`: Float (Timestamp UTC) +- `_createdBy`: String (User-ID) +- `_modifiedAt`: Float (Timestamp UTC) +- `_modifiedBy`: String (User-ID) + +**Timestamps:** +- Alle Timestamps sind im Float-Format UTC im Datenmodell gespeichert. +- Die Darstellung im UI erfolgt mit der lokalen Zeitzone des Benutzers. + +## Datenfluss-Diagramm + +```mermaid +--- +title: Hauptflüsse - Architektur-Planungs-App +--- +flowchart LR + subgraph Admin[Administrative Ebene] + Land[Land
Schweiz] + Kanton[Kanton
z.B. Zürich] + Gemeinde[Gemeinde
z.B. Zürich Stadt] + Land --> Kanton + Kanton --> Gemeinde + end + + subgraph Geo[Geografische Daten] + GeoPolylinie[GeoPolylinie
Linie/Polygon] + GeoPunkt[GeoPunkt
Koordinaten] + GeoPolylinie --> GeoPunkt + end + + subgraph Core[Kern-Business-Logik] + Projekt[Projekt
Bauprojekt] + Parzelle[Parzelle
Grundstück mit
Bauparametern] + Gemeinde --> Parzelle + Projekt --> Parzelle + Projekt --> GeoPolylinie + Parzelle --> GeoPolylinie + end + + subgraph Support[Unterstützende Daten] + Dokument[Dokument
Dateien & URLs] + Kontext[Kontext
Zusatzinfos] + end + + style Land fill:#50C878,stroke:#2D7A4A,stroke-width:2px,color:#fff + style Kanton fill:#50C878,stroke:#2D7A4A,stroke-width:2px,color:#fff + style Gemeinde fill:#50C878,stroke:#2D7A4A,stroke-width:2px,color:#fff + style Parzelle fill:#FF6B6B,stroke:#C92A2A,stroke-width:4px,color:#fff + style Projekt fill:#FF6B6B,stroke:#C92A2A,stroke-width:4px,color:#fff + style Dokument fill:#F5A623,stroke:#C17D11,stroke-width:2px,color:#fff + style GeoPolylinie fill:#F5A623,stroke:#C17D11,stroke-width:2px,color:#fff + style GeoPunkt fill:#F5A623,stroke:#C17D11,stroke-width:2px,color:#fff + style Kontext fill:#F5A623,stroke:#C17D11,stroke-width:2px,color:#fff +``` + +--- + +## Alle Datenobjekte als Tabellen + +### Übersichtstabelle + +| Objekt | Typ | Beschreibung | Hauptfelder | +|--------|-----|--------------|-------------| +| **Projekt** | Kernentität | Bauprojekt mit Status und Perimeter | id, label, statusProzess, perimeter, baulinie, parzellen | +| **Parzelle** | Hauptentität | Grundstück mit Bauparametern | id, label, plz, bauzone, AZ, BZ, perimeter, baulinie, laermschutzzone, hochwasserschutzzone, grundwasserschutzzone | +| **Dokument** | Unterstützend | Dateien und URLs mit Versionierung | id, label, dokumentTyp, quelle, mimeType, kategorienTags | +| **Kontext** | Unterstützend | Flexible Zusatzinformationen | id, thema, inhalt | +| **GeoPolylinie** | Hilfsobjekt | Geometrische Linie/Polygon | id, closed, punkte | +| **Land** | Admin | Nationale Ebene | id, label, abk | +| **Kanton** | Admin | Kantonale Ebene mit Baurecht | id, label, abk, Baureglement | +| **Gemeinde** | Admin | Gemeinde-Ebene mit BZO | id, label, plz, BZO | +| **GeoPunkt** | Hilfsobjekt | 3D-Koordinate | koordinatensystem, x, y, z, referenz | +| **GeoTag** | Enum | Geopunkt-Kategorien | - | +| **JaNein** | Enum | Drei-wertiger Status | "", "Ja", "Nein" | +| **StatusProzess** | Enum | Projektstatus | 7 Werte | +| **DokumentTyp** | Enum | Dokumenttyp | 6 Werte | + +--- + +## Zentrale Entitäten + +### 1. Projekt +**Das Kernobjekt, das ein Bauprojekt repräsentiert.** + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `label` | String | ✓ | Projektbezeichnung | +| `statusProzess` | Enum[StatusProzess] | - | Projektstatus: Eingang, Analyse, Studie, Planung, Baurechtsverfahren, Umsetzung, Archiv | +| `perimeter` | GeoPolylinie | - | Umhüllende aller Parzellen des Projektes | +| `baulinie` | GeoPolylinie | - | Baulinie des Projektes | +| `parzellen` | list[Parzelle] | - | Alle Parzellen des Projektes | +| `dokumente` | list[Dokument] | - | Projektspezifische Dokumente | +| `kontextInformationen` | list[Kontext] | - | Projektspezifische Kontextinfos | + +--- + +### 2. Parzelle +**Repräsentiert ein Grundstück mit allen baurechtlichen Eigenschaften als ein einheitliches Objekt.** + +#### Grunddaten + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `label` | String | ✓ | Parzellenbezeichnung | +| `parzellenAliasTags` | list[String] | - | Weitere Parzellennamen oder Flurnamen | +| `eigentuemerschaft` | String | - | Eigentümer der Parzelle | +| `strasseNr` | String | - | Straße und Hausnummer | +| `plz` | String | - | Postleitzahl der Parzelle (insbesondere bei Gemeinden mit mehreren PLZ) | + +#### Geografischer Kontext + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `perimeter` | GeoPolylinie | - | Parzellengrenze als geschlossene GeoPolylinie | +| `baulinie` | GeoPolylinie | - | Baulinie der Parzelle | +| `kontextGemeinde` | Gemeinde | - | Gemeinde der Parzelle | + +#### Bebauungsparameter + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `bauzone` | String | - | Bauzonenbezeichnung (z.B. W3, WG2, etc.) | +| `az` | Float | - | Ausnützungsziffer | +| `bz` | Float | - | Bebauungsziffer | +| `vollgeschossZahl` | Integer | - | Anzahl zulässiger Vollgeschosse | +| `anrechenbarDachgeschoss` | Float | - | Anrechenbarer Anteil Dachgeschoss (0.0 - 1.0) | +| `anrechenbarUntergeschoss` | Float | - | Anrechenbarer Anteil Untergeschoss (0.0 - 1.0) | +| `gebaeudehoeheMax` | Float | - | Maximale Gebäudehöhe in Metern | + +#### Abstandsregelungen + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `regelnGrenzabstand` | list[String] | - | Regelungen zum Grenzabstand | +| `regelnMehrlaengenzuschlag` | list[String] | - | Regelungen zum Mehrlängenzuschlag | +| `regelnMehrhoehenzuschlag` | list[String] | - | Regelungen zum Mehrhöhenzuschlag | + +#### Eigenschaften (Ja/Nein) + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `parzelleBebaut` | JaNein | - | Ist die Parzelle bebaut? ("", "Ja", "Nein") | +| `parzelleErschlossen` | JaNein | - | Ist die Parzelle erschlossen? ("", "Ja", "Nein") | +| `parzelleHanglage` | JaNein | - | Liegt die Parzelle in Hanglage? ("", "Ja", "Nein") | + +#### Schutzzonen + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `laermschutzzone` | String | - | Lärmschutzzone (z.B. "II") | +| `hochwasserschutzzone` | String | - | Hochwasserschutzzone (z.B. "tief") | +| `grundwasserschutzzone` | String | - | Grundwasserschutzzone | + +#### Beziehungen + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `parzellenNachbarschaft` | list[Parzelle] | - | Nachbarparzellen | +| `dokumente` | list[Dokument] | - | Parzellenspezifische Dokumente | +| `kontextInformationen` | list[Kontext] | - | Parzellenspezifische Kontextinfos | + +--- + +### 3. Dokument +**Unterstützendes Datenobjekt zur Verwaltung von Dateien und URLs mit Versionierung.** + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `label` | String | ✓ | Dokumentbezeichnung | +| `versionsbezeichnung` | String | - | Versionsnummer oder -bezeichnung (z.B. "v1.0", "Rev. A") | +| `dokumentTyp` | Enum[DokumentTyp] | - | Typ des Dokuments (siehe DokumentTyp-Enum) | +| `dokumentReferenz` | String | ✓ | Dateipfad oder URL | +| `quelle` | String | - | Quelle des Dokuments | +| `mimeType` | String | - | MIME-Type des Dokuments (z.B. "application/pdf", "image/png") | +| `kategorienTags` | list[String] | - | Kategorisierung des Dokuments | + +**Hinweis:** +Aktuelle Dokumente (z.B. aktuelle Baureglemente, BZO) können anhand des `dokumentTyp`-Attributs identifiziert werden. Die entsprechenden Dokumente finden sich in der `dokumente`-Liste der jeweiligen Entität (Kanton, Gemeinde). + +#### Beispiel-Kategorien (nicht abschliessend) + +| Kategorie | Beschreibung | +|-----------|--------------| +| `Kataster Objekte` | Amtliche Vermessung | +| `Kataster Werkeleitungen` | Leitungskataster | +| `Kataster Belastete Standorte` | Altlasten | +| `Kataster Bäume` | Baumkataster | +| `Zonenplan` | Zonenpläne | +| `Planungs- und Baugesetz (PGB)` | Kantonale Baugesetze | +| `Bau- und Zonenordnung (BZO)` | Gemeinde BZO | +| `Parkplatzverordnung` | Parkplatzregelungen | +| `Eigentümerauskunft` | Grundbuch-Auszüge Eigentümer | +| `Grundbuchauszug` | Vollständige Grundbuch-Auszüge | +| `Bauherrschaft` | Dokumente von der Bauherrschaft | +| `Planung` | Planungsdokumente | + +--- + +### 4. Geografische Entitäten + +#### GeoPolylinie +**Repräsentiert eine Linie oder ein Polygon aus mehreren GeoPunkten.** + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `closed` | Boolean | ✓ | Ist die GeoPolylinie geschlossen (Polygon)? | +| `punkte` | list[GeoPunkt] | ✓ | Liste der GeoPunkte, die die GeoPolylinie bilden | + +**Verwendung:** +- Parzellenperimeter (geschlossene GeoPolylinie) +- Baulinie (offene oder geschlossene GeoPolylinie) +- Projektperimeter (geschlossene GeoPolylinie) + +#### GeoPunkt +**Repräsentiert einen 3D-Punkt mit Referenzangabe.** + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `koordinatensystem` | String | ✓ | Koordinatensystem (z.B. "LV95", "EPSG:2056") | +| `x` | Float | ✓ | Ostwert (E) [m], typisch 2'480'000 - 2'840'000 | +| `y` | Float | ✓ | Nordwert (N) [m], typisch 1'070'000 - 1'300'000 | +| `z` | Float | - | Höhe über Meer [m] | +| `referenz` | Enum[GeoTag] | - | Kategorisierung des Punktes | + +**Verwendung:** +- Als Teil einer GeoPolylinie (Parzellenperimeter, Baulinie) +- Einzelne Referenzpunkte + +#### GeoTag (Enum) + +| Kategorie | Beschreibung | +|-----------|--------------| +| `K1` | Fixpunkt höchster Genauigkeit | +| `K2` | Fixpunkt mittlerer Genauigkeit | +| `K3` | Fixpunkt niedriger Genauigkeit | +| `Geometer` | Vom Geometer vermessener Punkt | + +**Beispiel (Zürich Hauptbahnhof):** +- `koordinatensystem`: "LV95" oder "EPSG:2056" +- `x`: 2'683'140 [m] +- `y`: 1'247'850 [m] +- `z`: 408 [m] +- `referenz`: "K1" (oder ein anderer GeoTag-Wert) + +--- + +### 5. Administrative Hierarchie + +#### Land + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `label` | String | ✓ | Landesname (z.B. "Schweiz") | +| `abk` | String | - | Abkürzung (z.B. "CH") | +| `dokumente` | list[Dokument] | - | Nationale Gesetze | +| `kontextInformationen` | list[Kontext] | - | Nationale Kontextinformationen | + +--- + +#### Kanton + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `label` | String | ✓ | Kantonsname (z.B. "Zürich") | +| `id_land` | [land] | eindeutiger Link zum land, also in welchem land kanton liegt | +| `abk` | String | - | Abkürzung (z.B. "ZH") | +| `dokumente` | list[Dokument] | - | Kantonale Dokumente | +| `kontextInformationen` | list[Kontext] | - | Kantonsspezifische Kontextinfos | + +--- + +#### Gemeinde + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `label` | String | ✓ | Gemeindename (z.B. "Zürich") | +| `id_kanton` | [kanton] | eindeutiger Link zur gemeinde, also im welchem kanton gemeinde liegt | +| `plz` | String | - | Postleitzahl (bei Gemeinden mit mehreren PLZ kann dies eine Haupt-PLZ sein) | +| `dokumente` | list[Dokument] | - | Gemeindedokumente | +| `kontextInformationen` | list[Kontext] | - | Gemeindespezifische Kontextinfos | + +**Hinweis:** +Bei Gemeinden mit mehreren Postleitzahlen (z.B. Zürich, Bern) wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst. + +--- + +### 6. Kontext +**Unterstützendes Datenobjekt für flexible Zusatzinformationen und Hinweise.** + +| Feld | Datentyp | Pflicht | Beschreibung | +|------|----------|---------|--------------| +| `id` | UUID | ✓ | Eindeutiger Identifier | +| `thema` | String | ✓ | Bezeichnung des Themas | +| `inhalt` | String | ✓ | Detaillierte Information (Text) | + +**Verwendung:** +Kontext-Objekte werden als Listen in den jeweiligen Entitäten gespeichert: +- `projekt.kontextInformationen: list[Kontext]` +- `parzelle.kontextInformationen: list[Kontext]` +- `land.kontextInformationen: list[Kontext]` +- `kanton.kontextInformationen: list[Kontext]` +- `gemeinde.kontextInformationen: list[Kontext]` + +#### Beispielthemen (nicht abschliessend) + +| Themenbereich | Beispiele | +|---------------|-----------| +| **Nutzung** | Vorgaben zur Erdgeschossnutzung (Wohnen erlaubt oder Pflicht für Gewerbe) | +| **Rechte** | Dienstbarkeiten (Wegrechte, Nähebaurechte, etc.) | +| **Parkierung** | Anforderung Parkplätze (Berechnung / Reduktionsfaktoren) | +| **Ausnützung** | Ausnützungsübertragungen | +| **Umwelt** | Schadstoffbelastungen auf Parzellen | +| **Planung** | Aktive Gestaltungspläne | +| **Lärm** | Lärmempfindlichkeitsstufen | +| **Energie** | Mögliche Wärmenutzung (Wärmeverbundnetze; Fernwärme, Anergie) | +| **Natur** | Baumbestand auf privaten Grundstücken | +| **Schutz** | Isos (Ortsbild, Schutzstatus, Denkmalschutz, Weilergebiet, etc.) | +| **Gefahren** | Naturgefahren (z.B. Objektschutzmassnahmen (Hochwasser)) | +| **Revision** | Verweis auf aktuell in oder zukünftig in Revision befindlichen Normen/Gesetze (z.B. Revision PBG mit aktuell negativer Vorwirkung) | + +**Design-Rationale:** +Das Kontext-Objekt ermöglicht flexibles Hinzufügen von projektspezifischen, parzellen-spezifischen oder regionalen Informationen ohne Schemaänderungen. + +--- + +### 7. Hilfsentitäten & Enumerationen + +#### JaNein (Enum) +**Drei-wertiger Zustand für optionale Ja/Nein-Fragen.** + +| Wert | Bedeutung | +|------|-----------| +| `""` (leer) | Unbekannt / Nicht erfasst | +| `"Ja"` | Ja / Zutreffend | +| `"Nein"` | Nein / Nicht zutreffend | + +**Verwendung:** +- `parzelleBebaut`: Ist die Parzelle bebaut? +- `parzelleErschlossen`: Ist die Parzelle erschlossen? +- `parzelleHanglage`: Liegt die Parzelle in Hanglage? + +--- + +#### StatusProzess (Enum) +**Projektstatus zur Nachverfolgung des Projektfortschritts.** + +| Wert | Beschreibung | +|------|--------------| +| `Eingang` | Projekt wurde eingereicht | +| `Analyse` | Projekt wird analysiert | +| `Studie` | Machbarkeitsstudie läuft | +| `Planung` | Planungsphase | +| `Baurechtsverfahren` | Baubewilligung läuft | +| `Umsetzung` | Bauprojekt in Umsetzung | +| `Archiv` | Projekt abgeschlossen | + +--- + +#### DokumentTyp (Enum) +**Dokumenttyp zur Kategorisierung von Dokumenten, insbesondere für kantonale und kommunale Dokumente.** + +| Wert | Beschreibung | +|------|--------------| +| `kantonBaureglementAktuell` | Aktuelles Baureglement (Kanton) | +| `kantonBaureglementRevision` | Baureglement in Revision (Kanton) | +| `kantonBauverordnungAktuell` | Aktuelle Bauverordnung (Kanton) | +| `kantonBauverordnungRevision` | Bauverordnung in Revision (Kanton) | +| `gemeindeBzoAktuell` | Aktuelle Bau- und Zonenordnung (BZO) (Gemeinde) | +| `gemeindeBzoRevision` | BZO in Revision (Gemeinde) | + +**Verwendung:** +- Dokumente in der `dokumente`-Liste von Kanton oder Gemeinde können über `dokumentTyp` identifiziert werden +- Ermöglicht die Suche nach aktuellen oder in Revision befindlichen Dokumenten + +--- + +## Q & A + +1. **Versionierung**: Sollen Änderungen an Parzellen historisiert werden? --> Vorerst nicht +2. **Mehrsprachigkeit**: Labels in DE/FR/IT? --> Wir im Pydantic Model später umgesetzt +3. **Benutzer & Rollen**: Wer darf was bearbeiten? --> In der App über Roles und Permissions gesteuert +4. **Workflow-Engine**: Für Statusübergänge und Genehmigungen? --> In der App über Workflow-Engine gesteuert +5. **Integration**: Anbindung an amtliche Geodaten (z.B. Swisstopo API)? --> In der App über Integrationen gesteuert +6. **Berechnungen**: Sollen Ausnützungsberechnungen automatisiert werden? --> In der App über Berechnungen gesteuert + +--- + +## Nächste Schritte + +1. **Validierung**: Review mit PEK +2. **Prototyp**: Implementierung der Datenmodell-Klassen +3. **GIS-Integration**: PostGIS aufsetzen, Test-Geodaten importieren +4. **API-Design**: RESTful API (FastAPI) mit OpenAPI-Dokumentation diff --git a/docs/real-estate-feature-integration-guide/01-overview.md b/docs/real-estate-feature-integration-guide/01-overview.md new file mode 100644 index 00000000..f0691689 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/01-overview.md @@ -0,0 +1,169 @@ +# Überblick und Projektstruktur + +[← Zurück zum Inhaltsverzeichnis](README.md) | [Weiter: Datenmodell erstellen →](02-datamodels.md) + +## Überblick + +Das Feature "realEstate" bietet eine **stateless API** für Real Estate-Datenbankoperationen mit AI-basierter natürlicher Sprachverarbeitung: + +### Hauptfunktionalität + +- **Natürliche Sprache → CRUD-Operationen**: User-Input wird mit AI analysiert und in entsprechende Datenbankoperationen übersetzt +- **Direkte Datenbankabfragen**: SQL-Queries können direkt ausgeführt werden +- **CRUD-Operationen**: Erstellen, Lesen, Aktualisieren und Löschen von Real Estate-Entitäten +- **PostgreSQL-Datenbankzugriff**: Über den bestehenden DatabaseConnector +- **RESTful API-Endpunkte**: Einfache, stateless Endpunkte ohne Session-Management +- **Benutzerauthentifizierung und Zugriffskontrolle**: Integriert in bestehende UAM-Struktur + +### Architektur-Ansatz + +**Stateless Design:** +- Keine Chat-Sessions notwendig (optional für zukünftige Erweiterungen) +- Jeder Request ist unabhängig +- Direkter Flow: User-Input → AI-Analyse → CRUD-Operation → Ergebnis +- Keine Query-History (kann optional später hinzugefügt werden) + +**AI-Integration:** +- Nutzt `serviceAi` für Intent-Erkennung +- Übersetzt natürliche Sprache in CRUD-Operationen +- Unterstützt CREATE, READ, UPDATE, DELETE, QUERY-Intents + +**Real Estate-Datenmodell-Entitäten:** +- **Kernentitäten**: Projekt, Parzelle +- **Unterstützend**: Dokument, Kontext +- **Geografisch**: GeoPolylinie, GeoPunkt +- **Administrativ**: Land, Kanton, Gemeinde + +### Architektur-Komponenten + +Die Architektur folgt dem Muster bestehender Features: +- **Routes** (`modules/routes/`) - API-Endpunkte (stateless) +- **Features** (`modules/features/`) - Geschäftslogik mit AI-Integration +- **Interfaces** (`modules/interfaces/`) - Datenbankzugriff (CRUD-Operationen) +- **DataModels** (`modules/datamodels/`) - Pydantic-Modelle für Real Estate-Entitäten +- **Services** (`modules/services/`) - AI-Service für Intent-Analyse + +--- + +## Projektstruktur + +``` +gateway/ +├── modules/ +│ ├── routes/ +│ │ └── routeRealEstate.py # NEU: Stateless API-Endpunkte +│ │ ├── POST /api/realestate/command # Natürliche Sprache → CRUD +│ │ └── POST /api/realestate/query # Direkte SQL-Query +│ │ +│ ├── features/ +│ │ └── realEstate/ +│ │ └── mainRealEstate.py # NEU: Feature-Logik mit AI-Integration +│ │ ├── processNaturalLanguageCommand() # Hauptfunktion +│ │ ├── analyzeUserIntent() # AI-basierte Intent-Analyse +│ │ └── executeIntentBasedOperation() # CRUD-Ausführung +│ │ +│ ├── interfaces/ +│ │ ├── interfaceDbRealEstateAccess.py # NEU: Zugriffskontrolle +│ │ ├── interfaceDbRealEstateObjects.py # NEU: CRUD-Interface +│ │ └── interfaceDbRealEstateChatObjects.py # OPTIONAL: Für Session-Support +│ │ +│ ├── datamodels/ +│ │ ├── datamodelRealEstate.py # NEU: Real Estate Datenmodelle +│ │ │ ├── Projekt, Parzelle, Dokument, etc. +│ │ └── datamodelRealEstateChat.py # OPTIONAL: Für Session-Support +│ │ +│ ├── services/ +│ │ └── serviceAi/ # BEREITS VORHANDEN +│ │ └── mainServiceAi.py # Wird für Intent-Analyse genutzt +│ │ +│ └── connectors/ +│ └── connectorDbPostgre.py # BEREITS VORHANDEN +│ +├── app.py # Router-Registrierung +├── env_dev.env # Environment-Konfiguration +└── modules/features/featuresLifecycle.py # Feature-Lifecycle +``` + +### Wichtige Dateien + +**Erforderlich:** +- `routeRealEstate.py` - API-Endpunkte (stateless) +- `mainRealEstate.py` - Feature-Logik mit AI-Integration +- `interfaceDbRealEstateAccess.py` - Zugriffskontrolle +- `interfaceDbRealEstateObjects.py` - CRUD-Interface +- `datamodelRealEstate.py` - Datenmodelle + +**Optional (für zukünftige Session-Unterstützung):** +- `interfaceDbRealEstateChatObjects.py` - Session-Management +- `datamodelRealEstateChat.py` - Session-Modelle + +--- + +## Datenfluss: User-Input → Ergebnis + +### Flow: Natürliche Sprache ohne Session + +``` +1. User sendet Request + POST /api/realestate/command + Body: { "userInput": "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" } + +2. Route empfängt Request + routeRealEstate.process_command() + → Auth: getCurrentUser() + +3. Feature-Logik verarbeitet + mainRealEstate.processNaturalLanguageCommand() + → Services initialisieren: getServices(currentUser, workflow=None) + +4. AI analysiert Intent + analyzeUserIntent(services.ai, userInput) + → services.ai.callAiPlanning(intentPrompt) + → AI gibt zurück: { "intent": "CREATE", "entity": "Projekt", "parameters": {...} } + +5. CRUD-Operation ausführen + executeIntentBasedOperation(intent, entity, parameters) + → getRealEstateInterface(currentUser) + → RealEstateObjects.createProjekt(projekt) + → DatabaseConnector.recordCreate(Projekt) + +6. Ergebnis zurückgeben + HTTP 200 OK + { "success": true, "intent": "CREATE", "result": {...} } +``` + +### Flow: Direkte SQL-Query + +``` +1. User sendet Request + POST /api/realestate/query + Body: { "queryText": "SELECT * FROM Projekt WHERE plz = '8000'" } + +2. Route empfängt Request + routeRealEstate.execute_direct_query() + → Auth: getCurrentUser() + +3. Query ausführen + getChatInterface(currentUser) + → RealEstateChatObjects.executeQuery(queryText) + → DatabaseConnector.executeQuery(sql) + +4. Ergebnis zurückgeben + HTTP 200 OK + { "rows": [...], "columns": [...], "rowCount": 15 } +``` + +--- + +## Vorteile des stateless Ansatzes + +- **Einfachheit**: Kein Session-Management notwendig +- **Performance**: Weniger Datenbank-Operationen pro Request +- **Skalierbarkeit**: Stateless Requests sind einfacher zu skalieren +- **Flexibilität**: Jeder Request ist unabhängig +- **Erweiterbarkeit**: Session-Support kann später optional hinzugefügt werden + +--- + +**Nächster Schritt:** [02-datamodels.md](02-datamodels.md) + diff --git a/docs/real-estate-feature-integration-guide/02-datamodels.md b/docs/real-estate-feature-integration-guide/02-datamodels.md new file mode 100644 index 00000000..376f5121 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/02-datamodels.md @@ -0,0 +1,976 @@ +# Schritt 1: Datenmodell erstellen + +[← Zurück zur Übersicht](README.md) | [Weiter: Interface erstellen →](03-interfaces.md) + +## Real Estate-Datenmodelle + +**Datei:** `modules/datamodels/datamodelRealEstate.py` + +Das Feature arbeitet **stateless** ohne Session-Management. Die Datenmodelle definieren die Struktur der Real Estate-Entitäten, die über die API verwaltet werden können. + +**Hinweis:** Die Real Estate-Datenmodell-Entitäten (Projekt, Parzelle, Dokument, etc.) werden in `datamodelRealEstate.py` definiert. Diese werden direkt über CRUD-Operationen verwaltet, ohne zusätzliche Chat-Interface-Modelle. + +### Warum keine Chat-Interface-Modelle? + +Das Feature arbeitet **stateless** ohne Session-Management. Alle Operationen arbeiten direkt auf den Real Estate-Datenmodellen: + +#### Stateless Design + +- **Keine Session-Modelle**: Keine `RealEstateChatSession` notwendig +- **Keine Query-History**: Queries werden nicht gespeichert (kann optional später hinzugefügt werden) +- **Direkte CRUD-Operationen**: User-Input → AI-Analyse → CRUD → Ergebnis +- **Einfache Architektur**: Weniger Komplexität, bessere Performance + +#### Real Estate-Modelle + +Die Real Estate-Modelle (`Projekt`, `Parzelle`, `Dokument`, etc.): +- Repräsentieren die **tatsächlichen Geschäftsdaten** der Immobilien-Projekte +- Werden über lange Zeiträume gepflegt und verändert +- Haben komplexe Beziehungen zueinander (Projekt → Parzellen → Dokumente) +- Werden direkt über CRUD-Operationen verwaltet + +#### Datenfluss (stateless) + +``` +User Input (natürliche Sprache) + ↓ +AI-Analyse (Intent-Erkennung) + ↓ +CRUD-Operation identifizieren + ↓ +Real Estate-Modelle + ↓ +Datenbank-Operation + ↓ +Ergebnis zurückgeben + (keine Session, keine History) +``` + +--- + +### Stateless vs. Session-basiert + +**Real Estate Feature (stateless):** +- Direkte CRUD-Operationen auf Real Estate-Modellen +- Keine Session-Modelle notwendig +- Keine Query-History +- Einfacher und schneller + +**Chat-System (session-basiert):** +- Verwendet `ChatWorkflow` für komplexe AI-Workflows +- Verwendet `ChatDocument` für Datei-Verknüpfungen +- Session-Management für Multi-Step-Operationen +- Für komplexe Workflows mit Planung und Review + +**Unterschied:** +- Real Estate Feature ist für **einfache CRUD-Operationen** optimiert +- Chat-System ist für **komplexe AI-Workflows** optimiert +- Beide können parallel existieren und für verschiedene Use Cases genutzt werden + +--- + +### Warum nicht das bestehende `ChatWorkflow` verwenden? + +Sie fragen sich vielleicht: **Kann ich nicht einfach das bestehende `ChatWorkflow` aus `datamodelChat.py` verwenden?** + +Die kurze Antwort: **Für stateless CRUD-Operationen ist `ChatWorkflow` zu komplex**. Das Real Estate Feature arbeitet ohne Session-Management und nutzt direkt die Real Estate-Modelle. + +#### Unterschiedliche Anwendungsfälle + +| **Aspekt** | **ChatWorkflow (bestehend)** | **Real Estate Feature (stateless)** | +|------------|------------------------------|-------------------------------------| +| **Zweck** | Komplexe AI-gesteuerte Workflows mit mehreren Tasks/Actions | Einfache CRUD-Operationen | +| **Komplexität** | Hoch: Tasks, Actions, Rounds, Workflow-Modi, Retries | Niedrig: Direkte CRUD-Operationen | +| **Session** | Session-Management für Multi-Step-Workflows | Keine Session, stateless | +| **Verarbeitung** | Multi-Step AI-Workflows mit Planung, Review, Iteration | Direkte CRUD: User-Input → AI-Analyse → CRUD → Ergebnis | +| **Ergebnisse** | `ChatMessage` mit `documents`, `ActionResult` | Direkte CRUD-Ergebnisse (Projekt, Parzelle, etc.) | + +#### Warum `ChatWorkflow` nicht passt: + +1. **Zu komplex**: `ChatWorkflow` hat viele Felder, die für einfache CRUD-Operationen nicht relevant sind +2. **Session-basiert**: `ChatWorkflow` benötigt Session-Management, das wir nicht brauchen +3. **Falsches Abstraktionsniveau**: `ChatWorkflow` ist für komplexe AI-Workflows, Real Estate braucht einfache CRUD-Operationen + +#### Die richtige Lösung: Direkte CRUD-Operationen + +Stattdessen arbeiten wir **direkt** mit den Real Estate-Modellen: + +```python +# Stateless CRUD-Operationen +User Input → AI-Analyse → CRUD-Operation → Ergebnis +# Keine Session, keine History, einfach und schnell +``` + +#### Wann könnte man `ChatWorkflow` verwenden? + +Sie könnten `ChatWorkflow` verwenden, wenn Sie: +- ✅ **Komplexe AI-Workflows** für Real Estate implementieren wollen (z.B. "Analysiere alle Projekte und erstelle einen Bericht") +- ✅ **Multi-Step-Verarbeitung** benötigen (z.B. "Lade Daten → Transformiere → Erstelle Visualisierung") +- ✅ **Planung und Review** brauchen (z.B. "Prüfe alle Parzellen auf Konformität") + +Aber für **einfache CRUD-Operationen** ist der stateless Ansatz die bessere Wahl. + +--- + +## Real Estate-Datenmodell-Implementierung: + +Die Real Estate-Datenmodell-Entitäten müssen separat in `modules/datamodels/datamodelRealEstate.py` implementiert werden. +Siehe `../PEK_datamodel_desc.md` für die vollständige Spezifikation aller Felder und Beziehungen (PEK ist ein Beispiel für eine Real Estate-Firma, das Modell ist aber allgemein verwendbar). + +### Wichtige Hinweise zum Datenmodell + +**Objektmodell vs. Datenbank-Repräsentation:** + +Dieses Dokument beschreibt ein **Objektmodell** für die Arbeit im Code. Es handelt sich **NICHT** um ein relationales Datenbankmodell mit Junction Tables. + +- **Im Code-Modell**: Alle Beziehungen werden als Objektreferenzen oder Listen von Objekten dargestellt (z.B. `dokumente: list[Dokument]`, `parzellen: list[Parzelle]`). +- **Für die Datenbank-Serialisierung**: Bei der Persistierung können Junction Tables verwendet werden, um n:m-Beziehungen in der Datenbank abzubilden. Dies ist jedoch ein Implementierungsdetail der Datenbank-Schicht und gehört nicht zum Hauptmodell. + +**Systemattribute:** + +Alle Datenobjekte haben automatisch die folgenden Systemattribute: +- `_createdAt`: Float (Timestamp UTC) +- `_createdBy`: String (User-ID) +- `_modifiedAt`: Float (Timestamp UTC) +- `_modifiedBy`: String (User-ID) + +**Timestamps:** +- Alle Timestamps sind im Float-Format UTC im Datenmodell gespeichert. +- Die Darstellung im UI erfolgt mit der lokalen Zeitzone des Benutzers. + +**Wichtige Punkte für die Implementierung:** +- Objektbeziehungen wie `parzellen: list[Parzelle]` werden als JSONB in PostgreSQL gespeichert +- Einzelne Objektreferenzen wie `kontextKanton: Optional[str]` werden als String-ID (Foreign Key) gespeichert +- Administrative Hierarchie: `Kanton` benötigt `id_land` (Foreign Key zu Land), `Gemeinde` benötigt `id_kanton` (Foreign Key zu Kanton) +- Alle Entitäten benötigen `mandateId` für Mandaten-Isolation +- Systemattribute (`_createdAt`, `_createdBy`, etc.) werden automatisch vom DatabaseConnector hinzugefügt + +### Datenfluss-Diagramm + +```mermaid +--- +title: Hauptflüsse - Architektur-Planungs-App +--- +flowchart LR + subgraph Admin[Administrative Ebene] + Land[Land
Schweiz] + Kanton[Kanton
z.B. Zürich] + Gemeinde[Gemeinde
z.B. Zürich Stadt] + Land --> Kanton + Kanton --> Gemeinde + end + + subgraph Geo[Geografische Daten] + GeoPolylinie[GeoPolylinie
Linie/Polygon] + GeoPunkt[GeoPunkt
Koordinaten] + GeoPolylinie --> GeoPunkt + end + + subgraph Core[Kern-Business-Logik] + Projekt[Projekt
Bauprojekt] + Parzelle[Parzelle
Grundstück mit
Bauparametern] + Gemeinde --> Parzelle + Projekt --> Parzelle + Projekt --> GeoPolylinie + Parzelle --> GeoPolylinie + end + + subgraph Support[Unterstützende Daten] + Dokument[Dokument
Dateien & URLs] + Kontext[Kontext
Zusatzinfos] + end +``` + +### Übersichtstabelle aller Entitäten + +| Objekt | Typ | Beschreibung | Hauptfelder | +|--------|-----|--------------|-------------| +| **Projekt** | Kernentität | Bauprojekt mit Status und Perimeter | id, label, statusProzess, perimeter, baulinie, parzellen | +| **Parzelle** | Hauptentität | Grundstück mit Bauparametern | id, label, plz, bauzone, AZ, BZ, perimeter, baulinie, laermschutzzone, hochwasserschutzzone, grundwasserschutzzone | +| **Dokument** | Unterstützend | Dateien und URLs mit Versionierung | id, label, dokumentTyp, quelle, mimeType, kategorienTags | +| **Kontext** | Unterstützend | Flexible Zusatzinformationen | id, thema, inhalt | +| **GeoPolylinie** | Hilfsobjekt | Geometrische Linie/Polygon | id, closed, punkte | +| **Land** | Admin | Nationale Ebene | id, label, abk | +| **Kanton** | Admin | Kantonale Ebene mit Baurecht | id, label, id_land, abk | +| **Gemeinde** | Admin | Gemeinde-Ebene mit BZO | id, label, id_kanton, plz | +| **GeoPunkt** | Hilfsobjekt | 3D-Koordinate | koordinatensystem, x, y, z, referenz | +| **GeoTag** | Enum | Geopunkt-Kategorien | K1, K2, K3, Geometer | +| **JaNein** | Enum | Drei-wertiger Status | "", "Ja", "Nein" | +| **StatusProzess** | Enum | Projektstatus | 7 Werte (Eingang, Analyse, Studie, Planung, Baurechtsverfahren, Umsetzung, Archiv) | +| **DokumentTyp** | Enum | Dokumenttyp | 6 Werte (kantonBaureglementAktuell, kantonBaureglementRevision, etc.) | + +### Beispiel: Vollständige Pydantic-Modelle für Real Estate-Entitäten + +Hier ist ein Beispiel, wie die Pydantic-Modelle für die Real Estate-Entitäten aussehen sollten: + +```python +""" +Real Estate data models for Architektur-Planungs-App. +Implements a general Swiss architecture planning data model. +(PEK is one example implementation, but the model is general-purpose) +""" + +from typing import List, Dict, Any, Optional, ForwardRef +from enum import Enum +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels +from modules.shared.timeUtils import getUtcTimestamp +import uuid + +# ===== Enums ===== + +class StatusProzess(str, Enum): + """Project process status""" + EINGANG = "Eingang" + ANALYSE = "Analyse" + STUDIE = "Studie" + PLANUNG = "Planung" + BAURECHTSVERFAHREN = "Baurechtsverfahren" + UMSETZUNG = "Umsetzung" + ARCHIV = "Archiv" + + +class DokumentTyp(str, Enum): + """Document type for categorization""" + KANTON_BAUREGLEMENT_AKTUELL = "kantonBaureglementAktuell" + KANTON_BAUREGLEMENT_REVISION = "kantonBaureglementRevision" + KANTON_BAUVERORDNUNG_AKTUELL = "kantonBauverordnungAktuell" + KANTON_BAUVERORDNUNG_REVISION = "kantonBauverordnungRevision" + GEMEINDE_BZO_AKTUELL = "gemeindeBzoAktuell" + GEMEINDE_BZO_REVISION = "gemeindeBzoRevision" + + +class JaNein(str, Enum): + """Three-valued state for optional yes/no questions""" + UNBEKANNT = "" # Empty string for unknown/not captured + JA = "Ja" + NEIN = "Nein" + + +class GeoTag(str, Enum): + """Geopoint categories""" + K1 = "K1" # Fixpunkt höchster Genauigkeit + K2 = "K2" # Fixpunkt mittlerer Genauigkeit + K3 = "K3" # Fixpunkt niedriger Genauigkeit + GEOMETER = "Geometer" # Vom Geometer vermessener Punkt + + +# ===== Helper Models (must be defined before main models) ===== + +class GeoPunkt(BaseModel): + """Represents a 3D point with reference.""" + koordinatensystem: str = Field( + description="Coordinate system (e.g. 'LV95', 'EPSG:2056')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + x: float = Field( + description="East value (E) [m], typically 2'480'000 - 2'840'000", + frontend_type="number", + frontend_readonly=False, + frontend_required=True, + ) + y: float = Field( + description="North value (N) [m], typically 1'070'000 - 1'300'000", + frontend_type="number", + frontend_readonly=False, + frontend_required=True, + ) + z: Optional[float] = Field( + None, + description="Height above sea level [m]", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + referenz: Optional[GeoTag] = Field( + None, + description="Point categorization", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + + +class GeoPolylinie(BaseModel): + """Represents a line or polygon from multiple GeoPunkte.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + ) + closed: bool = Field( + description="Is the GeoPolylinie closed (polygon)?", + frontend_type="boolean", + frontend_readonly=False, + frontend_required=True, + ) + punkte: List[GeoPunkt] = Field( + default_factory=list, + description="List of GeoPunkte forming the GeoPolylinie", + frontend_type="json", + frontend_readonly=False, + frontend_required=True, + ) + + +class Dokument(BaseModel): + """Supporting data object for file and URL management with versioning.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate this document belongs to", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Document label", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + versionsbezeichnung: Optional[str] = Field( + None, + description="Version number or designation (e.g. 'v1.0', 'Rev. A')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumentTyp: Optional[DokumentTyp] = Field( + None, + description="Document type", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + dokumentReferenz: str = Field( + description="File path or URL", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + quelle: Optional[str] = Field( + None, + description="Source of the document", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + mimeType: Optional[str] = Field( + None, + description="MIME type of the document (e.g. 'application/pdf', 'image/png')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + kategorienTags: List[str] = Field( + default_factory=list, + description="Document categorization tags", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +# Beispiel-Kategorien für Dokumente (nicht abschließend): +# - "Kataster Objekte" - Amtliche Vermessung +# - "Kataster Werkeleitungen" - Leitungskataster +# - "Kataster Belastete Standorte" - Altlasten +# - "Kataster Bäume" - Baumkataster +# - "Zonenplan" - Zonenpläne +# - "Planungs- und Baugesetz (PGB)" - Kantonale Baugesetze +# - "Bau- und Zonenordnung (BZO)" - Gemeinde BZO +# - "Parkplatzverordnung" - Parkplatzregelungen +# - "Eigentümerauskunft" - Grundbuch-Auszüge Eigentümer +# - "Grundbuchauszug" - Vollständige Grundbuch-Auszüge +# - "Bauherrschaft" - Dokumente von der Bauherrschaft +# - "Planung" - Planungsdokumente +# +# Hinweis: Aktuelle Dokumente (z.B. aktuelle Baureglemente, BZO) können anhand des +# `dokumentTyp`-Attributs identifiziert werden. Die entsprechenden Dokumente finden sich +# in der `dokumente`-Liste der jeweiligen Entität (Kanton, Gemeinde). + + +class Kontext(BaseModel): + """Supporting data object for flexible additional information.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + ) + thema: str = Field( + description="Theme designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + inhalt: str = Field( + description="Detailed information (text)", + frontend_type="textarea", + frontend_readonly=False, + frontend_required=True, + ) + + +# Beispielthemen für Kontext (nicht abschließend): +# - "Nutzung" - Vorgaben zur Erdgeschossnutzung (Wohnen erlaubt oder Pflicht für Gewerbe) +# - "Rechte" - Dienstbarkeiten (Wegrechte, Nähebaurechte, etc.) +# - "Parkierung" - Anforderung Parkplätze (Berechnung / Reduktionsfaktoren) +# - "Ausnützung" - Ausnützungsübertragungen +# - "Umwelt" - Schadstoffbelastungen auf Parzellen +# - "Planung" - Aktive Gestaltungspläne +# - "Lärm" - Lärmempfindlichkeitsstufen +# - "Energie" - Mögliche Wärmenutzung (Wärmeverbundnetze; Fernwärme, Anergie) +# - "Natur" - Baumbestand auf privaten Grundstücken +# - "Schutz" - Isos (Ortsbild, Schutzstatus, Denkmalschutz, Weilergebiet, etc.) +# - "Gefahren" - Naturgefahren (z.B. Objektschutzmassnahmen (Hochwasser)) +# - "Revision" - Verweis auf aktuell in oder zukünftig in Revision befindlichen Normen/Gesetze +# +# Verwendung: Kontext-Objekte werden als Listen in den jeweiligen Entitäten gespeichert: +# - projekt.kontextInformationen: list[Kontext] +# - parzelle.kontextInformationen: list[Kontext] +# - land.kontextInformationen: list[Kontext] +# - kanton.kontextInformationen: list[Kontext] +# - gemeinde.kontextInformationen: list[Kontext] +# +# Design-Rationale: Das Kontext-Objekt ermöglicht flexibles Hinzufügen von projektspezifischen, +# parzellen-spezifischen oder regionalen Informationen ohne Schemaänderungen. + + +class Land(BaseModel): + """National level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Country name (e.g. 'Schweiz')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + abk: Optional[str] = Field( + None, + description="Abbreviation (e.g. 'CH')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="National laws/documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="National context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Kanton(BaseModel): + """Cantonal level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Canton name (e.g. 'Zürich')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + id_land: Optional[str] = Field( + None, + description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + abk: Optional[str] = Field( + None, + description="Abbreviation (e.g. 'ZH')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Cantonal documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Canton-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Gemeinde(BaseModel): + """Municipal level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Municipality name (e.g. 'Zürich')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + id_kanton: Optional[str] = Field( + None, + description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + plz: Optional[str] = Field( + None, + description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Municipal documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Municipality-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +# ===== Main Models (use ForwardRef for circular references) ===== + +# Forward references for circular dependencies +ParzelleRef = ForwardRef('Parzelle') + + +class Parzelle(BaseModel): + """Represents a plot with all building law properties.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + + # Grunddaten + label: str = Field( + description="Plot designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + parzellenAliasTags: List[str] = Field( + default_factory=list, + description="Additional plot names or field names", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + eigentuemerschaft: Optional[str] = Field( + None, + description="Owner of the plot", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + strasseNr: Optional[str] = Field( + None, + description="Street and house number", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + plz: Optional[str] = Field( + None, + description="Postal code of the plot", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Geografischer Kontext + perimeter: Optional[GeoPolylinie] = Field( + None, + description="Plot boundary as closed GeoPolylinie", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + baulinie: Optional[GeoPolylinie] = Field( + None, + description="Building line of the plot", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextLand: Optional[str] = Field( + None, + description="Land ID (Foreign Key)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + kontextKanton: Optional[str] = Field( + None, + description="Canton ID (Foreign Key)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + kontextGemeinde: Optional[str] = Field( + None, + description="Municipality ID (Foreign Key)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Bebauungsparameter + bauzone: Optional[str] = Field( + None, + description="Building zone designation (e.g. W3, WG2, etc.)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + az: Optional[float] = Field( + None, + description="Ausnützungsziffer", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + bz: Optional[float] = Field( + None, + description="Bebauungsziffer", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + vollgeschossZahl: Optional[int] = Field( + None, + description="Number of allowed full floors", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + anrechenbarDachgeschoss: Optional[float] = Field( + None, + description="Accountable portion of attic (0.0 - 1.0)", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + anrechenbarUntergeschoss: Optional[float] = Field( + None, + description="Accountable portion of basement (0.0 - 1.0)", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + gebaeudehoeheMax: Optional[float] = Field( + None, + description="Maximum building height in meters", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + + # Abstandsregelungen + regelnGrenzabstand: List[str] = Field( + default_factory=list, + description="Regulations for boundary distance", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + regelnMehrlaengenzuschlag: List[str] = Field( + default_factory=list, + description="Regulations for additional length surcharge", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + regelnMehrhoehenzuschlag: List[str] = Field( + default_factory=list, + description="Regulations for additional height surcharge", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + # Eigenschaften (Ja/Nein) + parzelleBebaut: Optional[JaNein] = Field( + None, + description="Is the plot built?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + parzelleErschlossen: Optional[JaNein] = Field( + None, + description="Is the plot developed?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + parzelleHanglage: Optional[JaNein] = Field( + None, + description="Is the plot on a slope?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + + # Schutzzonen + laermschutzzone: Optional[str] = Field( + None, + description="Noise protection zone (e.g. 'II')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + hochwasserschutzzone: Optional[str] = Field( + None, + description="Flood protection zone (e.g. 'tief')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + grundwasserschutzzone: Optional[str] = Field( + None, + description="Groundwater protection zone", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Beziehungen (stored as JSONB in database) + parzellenNachbarschaft: List[Dict[str, Any]] = Field( + default_factory=list, + description="Neighboring plots (stored as list of Parzelle IDs or full objects)", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Plot-specific documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Plot-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Projekt(BaseModel): + """Core object representing a construction project.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Project designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + statusProzess: Optional[StatusProzess] = Field( + None, + description="Project status", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + perimeter: Optional[GeoPolylinie] = Field( + None, + description="Envelope of all plots in the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + baulinie: Optional[GeoPolylinie] = Field( + None, + description="Building line of the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + parzellen: List[Parzelle] = Field( + default_factory=list, + description="All plots of the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Project-specific documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Project-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +# Resolve forward references +Parzelle.model_rebuild() +Projekt.model_rebuild() + + +# Register labels for frontend +registerModelLabels( + "Projekt", + {"en": "Project", "fr": "Projet", "de": "Projekt"}, + { + "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, + "statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"}, + # ... more labels + }, +) + +# Similar registerModelLabels calls for all other models... +``` + +**Wichtige Hinweise zur Implementierung:** + +1. **Forward References**: Für zirkuläre Referenzen (z.B. `Parzelle` → `parzellenNachbarschaft: list[Parzelle]`) verwenden Sie `ForwardRef` oder speichern Sie nur IDs als Strings. + +2. **JSONB-Speicherung**: Listen von Objekten (`list[Parzelle]`, `list[Dokument]`) werden automatisch als JSONB gespeichert. Der DatabaseConnector erkennt `List`-Typen automatisch. + +3. **Foreign Keys**: Einzelne Objektreferenzen wie `kontextKanton: Optional[str]` werden als String-ID gespeichert. Sie können später im Interface die vollständigen Objekte laden. + +4. **MandateId**: Alle Entitäten benötigen `mandateId` für Mandaten-Isolation. + +5. **Systemattribute**: `_createdAt`, `_createdBy`, `_modifiedAt`, `_modifiedBy` werden automatisch vom DatabaseConnector hinzugefügt - Sie müssen sie nicht im Modell definieren. + +--- + +**WICHTIG:** Die obigen Real Estate-Modelle (`Projekt`, `Parzelle`, etc.) sind die **tatsächlichen Datenmodelle**, die Sie implementieren müssen. Diese werden in `modules/datamodels/datamodelRealEstate.py` erstellt. + +**Keine Chat-Interface-Modelle notwendig:** +- Das Feature arbeitet **stateless** ohne Session-Management +- Alle Operationen arbeiten direkt auf den Real Estate-Modellen +- Keine `RealEstateChatSession`, `RealEstateQuery` oder `RealEstateQueryResult` notwendig +- CRUD-Operationen werden direkt ausgeführt und Ergebnisse direkt zurückgegeben + +### Wichtige Punkte: + +1. **UUID als ID**: Alle Modelle verwenden `uuid.uuid4()` für eindeutige IDs +2. **MandateId**: Jedes Modell benötigt `mandateId` für Mandaten-Isolation +3. **Frontend-Metadaten**: `frontend_type`, `frontend_readonly`, `frontend_required` für UI-Generierung +4. **registerModelLabels**: Registriert Labels für Mehrsprachigkeit +5. **JSONB-Felder**: `Dict[str, Any]` und `List[...]` werden automatisch als JSONB in PostgreSQL gespeichert +6. **Foreign Keys**: Administrative Hierarchie wird über Foreign Keys abgebildet: + - `Kanton.id_land` → `Land.id` + - `Gemeinde.id_kanton` → `Kanton.id` + - `Parzelle.kontextLand` → `Land.id` (Optional) + - `Parzelle.kontextKanton` → `Kanton.id` (Optional) + - `Parzelle.kontextGemeinde` → `Gemeinde.id` (Optional) + +--- + +## Q & A - Häufige Fragen + +1. **Versionierung**: Sollen Änderungen an Parzellen historisiert werden? + → Vorerst nicht + +2. **Mehrsprachigkeit**: Labels in DE/FR/IT? + → Wird im Pydantic Model über `registerModelLabels` umgesetzt + +3. **Benutzer & Rollen**: Wer darf was bearbeiten? + → In der App über Roles und Permissions gesteuert (UAM-System) + +4. **Workflow-Engine**: Für Statusübergänge und Genehmigungen? + → In der App über Workflow-Engine gesteuert (optional, kann später integriert werden) + +5. **Integration**: Anbindung an amtliche Geodaten (z.B. Swisstopo API)? + → In der App über Integrationen gesteuert (optional) + +6. **Berechnungen**: Sollen Ausnützungsberechnungen automatisiert werden? + → In der App über Berechnungen gesteuert (optional) + +--- + +[← Zurück zur Übersicht](README.md) | [Weiter: Interface erstellen →](03-interfaces.md) + diff --git a/docs/real-estate-feature-integration-guide/03-interfaces.md b/docs/real-estate-feature-integration-guide/03-interfaces.md new file mode 100644 index 00000000..a90c31b9 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/03-interfaces.md @@ -0,0 +1,845 @@ +# Schritt 2: Interface erstellen + +[← Zurück: Datenmodell erstellen](02-datamodels.md) | [Weiter: Feature-Logik implementieren →](04-feature-logic.md) + +## Übersicht: Was sind Interfaces? + +**Interfaces** sind aktive Klassen, die den **Datenbankzugriff** implementieren. Sie unterscheiden sich von **Datamodels** (die nur die Datenstruktur definieren): + +| **Aspekt** | **Datamodels** | **Interfaces** | +|------------|----------------|----------------| +| **Zweck** | Definiert **WAS** (Datenstruktur) | Implementiert **WIE** (Datenzugriff) | +| **Inhalt** | Pydantic-Modelle mit Feldern und Validierung | Klassen mit CRUD-Methoden (`create`, `get`, `update`, `delete`) | +| **Beispiel** | `class Projekt(BaseModel): ...` | `def createProjekt(...) -> Projekt: ...` | +| **Aktivität** | Passiv (nur Struktur) | Aktiv (führt Operationen aus) | + +**Analogie:** +- **Datamodel** = Bauplan (beschreibt das Haus) +- **Interface** = Bauunternehmer (baut das Haus) + +--- + +## Struktur: Real Estate CRUD-Interface + +Da das Feature **stateless** arbeitet, benötigen wir nur **ein Interface** für CRUD-Operationen auf Real Estate-Entitäten: + +### Real Estate-Datenmodelle → Real Estate CRUD-Interface + +**Datamodel:** `datamodelRealEstate.py` +- `Projekt` +- `Parzelle` +- `Dokument` +- `Kanton`, `Gemeinde`, `Land` +- `GeoPolylinie`, `GeoPunkt` +- `Kontext` +- etc. + +**Interface:** `interfaceDbRealEstateObjects.py` +- `RealEstateObjects` (Haupt-Interface) +- `RealEstateAccess` (Zugriffskontrolle) +- Methoden: `createProjekt()`, `getParzelle()`, `updateDokument()`, etc. + +**Optional:** `interfaceDbRealEstateChatObjects.py` (nur für direkte SQL-Queries) +- `RealEstateChatObjects` - Für direkte Query-Ausführung ohne Session +- Methoden: `executeQuery()` - Führt SQL direkt aus + +--- + +## Warum nur ein Haupt-Interface? + +1. **Stateless Design**: + - Keine Session-Verwaltung notwendig + - Direkte CRUD-Operationen auf Real Estate-Modellen + +2. **Einfache Architektur**: + - Ein Interface für alle CRUD-Operationen + - Weniger Komplexität, bessere Wartbarkeit + +3. **Optionales Query-Interface**: + - Nur für direkte SQL-Queries (stateless) + - Keine Session-Management-Funktionen + +--- + +## Zu erstellende Dateien + +### Schritt 2a: Real Estate CRUD-Interface (ERFORDERLICH) + +**Zwei separate Dateien** (wie bei anderen Features): + +#### Datei 1: `modules/interfaces/interfaceDbRealEstateAccess.py` + +**Enthält:** +- `RealEstateAccess` - Zugriffskontrolle für Real Estate-Entitäten +- Methoden: `uam()`, `canModify()` + +**Zweck:** Prüft Zugriffsrechte und filtert Daten basierend auf Benutzerprivilegien + +#### Datei 2: `modules/interfaces/interfaceDbRealEstateObjects.py` + +**Enthält:** +- `RealEstateObjects` - Haupt-Interface für CRUD-Operationen +- `getInterface()` - Factory-Funktion +- Nutzt `RealEstateAccess` aus der Access-Datei + +**Zweck:** Verwaltet Real Estate-Entitäten (Projekt, Parzelle, Dokument, etc.) + +**Nutzt:** +- `datamodelRealEstate.py` (Projekt, Parzelle, Dokument, etc.) +- `interfaceDbRealEstateAccess.py` (für Zugriffskontrolle) + +**Wann benötigt:** Für alle CRUD-Operationen auf Real Estate-Entitäten (z.B. Projekte erstellen/bearbeiten, Parzellen verwalten). Dies ist das Haupt-Interface für das Feature. + +--- + +### Schritt 2b: Query-Interface (OPTIONAL - nur für direkte SQL-Queries) + +**Eine Datei** für stateless Query-Ausführung: + +#### Datei: `modules/interfaces/interfaceDbRealEstateChatObjects.py` + +**Enthält:** +- `RealEstateChatObjects` - Interface für direkte SQL-Query-Ausführung +- `getInterface()` - Factory-Funktion +- Methoden: `executeQuery()` - Führt SQL direkt aus (stateless) + +**Zweck:** Direkte SQL-Query-Ausführung ohne Session-Management + +**Nutzt:** +- `connectorDbPostgre.DatabaseConnector` für direkte SQL-Ausführung +- Keine Chat-Modelle (stateless) + +**Wann benötigt:** Nur wenn Sie direkte SQL-Queries ausführen möchten (z.B. für komplexe SELECT-Queries). Für CRUD-Operationen verwenden Sie das Real Estate CRUD-Interface. + +--- + +## Übersicht: Dateien und ihre Beziehungen + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DATAMODELS (Struktur) │ +├─────────────────────────────────────────────────────────────┤ +│ datamodelRealEstate.py │ +│ ├── Projekt │ +│ ├── Parzelle │ +│ ├── Dokument │ +│ ├── Kanton, Gemeinde, Land │ +│ ├── GeoPolylinie, GeoPunkt │ +│ ├── Kontext │ +│ └── ... │ +└─────────────────────────────────────────────────────────────┘ + │ + │ nutzt + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ INTERFACES (Zugriff) │ +├─────────────────────────────────────────────────────────────┤ +│ REAL ESTATE CRUD-INTERFACE (ERFORDERLICH) │ +│ │ +│ interfaceDbRealEstateAccess.py │ +│ └── RealEstateAccess │ +│ ├── uam() │ +│ └── canModify() │ +│ │ +│ interfaceDbRealEstateObjects.py │ +│ ├── RealEstateObjects │ +│ │ ├── createProjekt() │ +│ │ ├── getProjekt() │ +│ │ ├── updateProjekt() │ +│ │ ├── deleteProjekt() │ +│ │ ├── createParzelle() │ +│ │ ├── getParzelle() │ +│ │ └── ... (CRUD für alle Entitäten) │ +│ └── getInterface() │ +│ └── nutzt RealEstateAccess │ +│ │ +│ QUERY-INTERFACE (OPTIONAL - nur für direkte SQL) │ +│ │ +│ interfaceDbRealEstateChatObjects.py │ +│ ├── RealEstateChatObjects │ +│ │ └── executeQuery() # Direkte SQL-Ausführung │ +│ └── getInterface() │ +│ └── Keine Access-Klasse (stateless) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Interface-Struktur: Access vs. Objects + +Jedes Interface besteht aus **zwei Klassen**: + +### 1. `*Access` Klasse (Zugriffskontrolle) + +**Zweck:** Prüft, wer was sehen/dürfen darf + +**Methoden:** +- `uam()` - Filtert Daten basierend auf Benutzerprivilegien +- `canModify()` - Prüft, ob Benutzer ändern darf + +**Beispiel:** `RealEstateChatAccess` + +### 2. `*Objects` Klasse (Haupt-Interface) + +**Zweck:** Führt CRUD-Operationen aus + +**Methoden:** +- `create*()` - Erstellt neue Einträge +- `get*()` - Lädt Einträge +- `update*()` - Aktualisiert Einträge +- `delete*()` - Löscht Einträge + +**Nutzt:** `*Access` für Zugriffskontrolle + +**Beispiel:** `RealEstateChatObjects` + +**Warum getrennt?** +- Separation of Concerns: Zugriffskontrolle ist separate Verantwortlichkeit +- Wiederverwendbarkeit: Access-Klasse kann von mehreren Interfaces genutzt werden +- Testbarkeit: Zugriffskontrolle kann unabhängig getestet werden + +--- + +## Implementierung: Real Estate CRUD-Interface + +Das Real Estate CRUD-Interface besteht aus **zwei separaten Dateien**, genau wie bei anderen Features (`interfaceDbAppObjects.py` + `interfaceDbAppAccess.py`). + +### Datei 1: Access-Implementierung + +**Datei:** `modules/interfaces/interfaceDbRealEstateAccess.py` + +```python +""" +Access control for Real Estate interface. +Handles user access management and permission checks. +""" + +import logging +from typing import Dict, Any, List, Optional +from modules.datamodels.datamodelUam import User, UserPrivilege + +logger = logging.getLogger(__name__) + + +class RealEstateAccess: + """ + Access control class for Real Estate interface. + Handles user access management and permission checks. + """ + + def __init__(self, currentUser: User, db): + """Initialize with user context.""" + self.currentUser = currentUser + self.mandateId = currentUser.mandateId + self.userId = currentUser.id + + if not self.mandateId or not self.userId: + raise ValueError("Invalid user context: mandateId and userId are required") + + self.db = db + + def uam(self, model_class: type, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Unified user access management function that filters data based on user privileges. + + Args: + model_class: Pydantic model class for the table + recordset: Recordset to filter based on access rules + + Returns: + Filtered recordset with access control attributes + """ + from modules.datamodels.datamodelUam import UserPrivilege + + userPrivilege = self.currentUser.privilege + filtered_records = [] + + # System admins see all records + if userPrivilege == UserPrivilege.SYSADMIN: + filtered_records = recordset + # Admins see records in their mandate + elif userPrivilege == UserPrivilege.ADMIN: + filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId] + # Regular users see only their records + else: + filtered_records = [ + r for r in recordset + if r.get("mandateId", "-") == self.mandateId and r.get("_createdBy") == self.userId + ] + + # Add access control attributes + for record in filtered_records: + record["_hideView"] = False + record["_hideEdit"] = not self.canModify(model_class, record.get("id")) + record["_hideDelete"] = not self.canModify(model_class, record.get("id")) + + return filtered_records + + def canModify(self, model_class: type, recordId: Optional[str] = None) -> bool: + """Checks if the current user can modify records.""" + from modules.datamodels.datamodelUam import UserPrivilege + + userPrivilege = self.currentUser.privilege + + if userPrivilege == UserPrivilege.SYSADMIN: + return True + + if recordId is not None: + records = self.db.getRecordset(model_class, recordFilter={"id": recordId}) + if not records: + return False + + record = records[0] + + if userPrivilege == UserPrivilege.ADMIN and record.get("mandateId", "-") == self.mandateId: + return True + + if (record.get("mandateId", "-") == self.mandateId and + record.get("_createdBy") == self.userId): + return True + + return False + else: + return True # Regular users can create records +``` + +--- + +### Datei 2: Objects-Implementierung + +**Datei:** `modules/interfaces/interfaceDbRealEstateObjects.py` + +```python +""" +Interface to Real Estate database objects. +Uses PostgreSQL connector for data access with user/mandate filtering. +Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.). +""" + +import logging +from typing import Dict, Any, List, Optional +from modules.datamodels.datamodelRealEstate import ( + Projekt, + Parzelle, + Dokument, + Kanton, + Gemeinde, + Land, + GeoPolylinie, + GeoPunkt, + Kontext, + StatusProzess, +) +from modules.datamodels.datamodelUam import User +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.shared.configuration import APP_CONFIG +# Import Access-Klasse aus separater Datei +from modules.interfaces.interfaceDbRealEstateAccess import RealEstateAccess + +logger = logging.getLogger(__name__) + +# Singleton factory for Real Estate interfaces +_realEstateInterfaces = {} + + +class RealEstateObjects: + """ + Interface to Real Estate database objects. + Uses PostgreSQL connector for data access with user/mandate filtering. + Handles CRUD operations on Real Estate entities. + """ + + def __init__(self, currentUser: Optional[User] = None): + """Initializes the Real Estate Interface.""" + self.currentUser = currentUser + self.userId = currentUser.id if currentUser else None + self.mandateId = currentUser.mandateId if currentUser else None + self.access = None + + # Initialize database + self._initializeDatabase() + + # Set user context if provided + if currentUser: + self.setUserContext(currentUser) + + def _initializeDatabase(self): + """Initialize PostgreSQL database connection.""" + try: + # Get database configuration from environment + dbHost = APP_CONFIG.get("DB_APP_HOST", "localhost") + dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "poweron_app") + dbUser = APP_CONFIG.get("DB_APP_USER") + dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432)) + + # Initialize database connector + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=self.userId if self.userId else None, + ) + + logger.info(f"Real Estate database connector initialized for database: {dbDatabase}") + except Exception as e: + logger.error(f"Error initializing Real Estate database: {e}") + raise + + def setUserContext(self, currentUser: User): + """Sets the user context for the interface.""" + self.currentUser = currentUser + self.userId = currentUser.id + self.mandateId = currentUser.mandateId + + if not self.userId or not self.mandateId: + raise ValueError("Invalid user context: id and mandateId are required") + + # Initialize access control + self.access = RealEstateAccess(self.currentUser, self.db) + + # Update database context + self.db.updateContext(self.userId) + + # ===== Projekt Methods ===== + + def createProjekt(self, projekt: Projekt) -> Projekt: + """Create a new project.""" + # Ensure mandateId is set + if not projekt.mandateId: + projekt.mandateId = self.mandateId + + # Apply access control + self.access.uam(Projekt, []) + + # Save to database + self.db.recordCreate(Projekt, projekt.model_dump()) + + return projekt + + def getProjekt(self, projektId: str) -> Optional[Projekt]: + """Get a project by ID.""" + records = self.db.getRecordset( + Projekt, + recordFilter={"id": projektId} + ) + + if not records: + return None + + # Apply access control + filtered = self.access.uam(Projekt, records) + + if not filtered: + return None + + return Projekt(**filtered[0]) + + def updateProjekt(self, projektId: str, updateData: Dict[str, Any]) -> Optional[Projekt]: + """Update a project.""" + projekt = self.getProjekt(projektId) + if not projekt: + return None + + # Check if user can modify + if not self.access.canModify(Projekt, projektId): + raise PermissionError(f"User {self.userId} cannot modify project {projektId}") + + # Update fields + for key, value in updateData.items(): + if hasattr(projekt, key): + setattr(projekt, key, value) + + # Save to database + self.db.recordModify(Projekt, projektId, projekt.model_dump()) + + return projekt + + def deleteProjekt(self, projektId: str) -> bool: + """Delete a project.""" + projekt = self.getProjekt(projektId) + if not projekt: + return False + + # Check if user can modify + if not self.access.canModify(Projekt, projektId): + raise PermissionError(f"User {self.userId} cannot delete project {projektId}") + + return self.db.recordDelete(Projekt, projektId) + + # ===== Parzelle Methods ===== + + def createParzelle(self, parzelle: Parzelle) -> Parzelle: + """Create a new plot.""" + if not parzelle.mandateId: + parzelle.mandateId = self.mandateId + + self.access.uam(Parzelle, []) + self.db.recordCreate(Parzelle, parzelle.model_dump()) + + return parzelle + + def getParzelle(self, parzelleId: str) -> Optional[Parzelle]: + """Get a plot by ID.""" + records = self.db.getRecordset( + Parzelle, + recordFilter={"id": parzelleId} + ) + + if not records: + return None + + filtered = self.access.uam(Parzelle, records) + + if not filtered: + return None + + return Parzelle(**filtered[0]) + + def updateParzelle(self, parzelleId: str, updateData: Dict[str, Any]) -> Optional[Parzelle]: + """Update a plot.""" + parzelle = self.getParzelle(parzelleId) + if not parzelle: + return None + + if not self.access.canModify(Parzelle, parzelleId): + raise PermissionError(f"User {self.userId} cannot modify plot {parzelleId}") + + for key, value in updateData.items(): + if hasattr(parzelle, key): + setattr(parzelle, key, value) + + self.db.recordModify(Parzelle, parzelleId, parzelle.model_dump()) + + return parzelle + + def deleteParzelle(self, parzelleId: str) -> bool: + """Delete a plot.""" + parzelle = self.getParzelle(parzelleId) + if not parzelle: + return False + + if not self.access.canModify(Parzelle, parzelleId): + raise PermissionError(f"User {self.userId} cannot delete plot {parzelleId}") + + return self.db.recordDelete(Parzelle, parzelleId) + + # ===== Dokument Methods ===== + + def createDokument(self, dokument: Dokument) -> Dokument: + """Create a new document.""" + if not dokument.mandateId: + dokument.mandateId = self.mandateId + + self.access.uam(Dokument, []) + self.db.recordCreate(Dokument, dokument.model_dump()) + + return dokument + + def getDokument(self, dokumentId: str) -> Optional[Dokument]: + """Get a document by ID.""" + records = self.db.getRecordset( + Dokument, + recordFilter={"id": dokumentId} + ) + + if not records: + return None + + filtered = self.access.uam(Dokument, records) + + if not filtered: + return None + + return Dokument(**filtered[0]) + + # ... weitere CRUD-Methoden für andere Entitäten (Kanton, Gemeinde, Land, etc.) + + +def getInterface(currentUser: User) -> RealEstateObjects: + """ + Factory function to get or create a Real Estate interface instance for a user. + Uses singleton pattern per user. + """ + userKey = f"{currentUser.id}_{currentUser.mandateId}" + + if userKey not in _realEstateInterfaces: + _realEstateInterfaces[userKey] = RealEstateObjects(currentUser) + + return _realEstateInterfaces[userKey] +``` + +## Wichtige Punkte: + +1. **DatabaseConnector**: Nutzt `connectorDbPostgre.DatabaseConnector` für Datenbankzugriff +2. **Access Control**: `RealEstateAccess` implementiert Benutzer- und Mandaten-Filterung +3. **Singleton Pattern**: `getInterface()` erstellt pro User eine Instanz +4. **CRUD-Operationen**: `recordCreate`, `recordModify`, `recordDelete`, `getRecordset` vom Connector +5. **MandateId**: Wird automatisch gesetzt, wenn nicht vorhanden + +--- + +## Schritt 2b: Query-Interface (OPTIONAL - nur für direkte SQL-Queries) + +### Wann benötigt? + +**Kurze Antwort:** Nur wenn Sie **direkte SQL-Queries** ausführen möchten (z.B. für komplexe SELECT-Queries). Für CRUD-Operationen verwenden Sie das Real Estate CRUD-Interface. + +#### Szenario 1: Alles über CRUD-Interface (EMPFOHLEN) + +**Strukturiert und sicher:** + +```python +# User schreibt: "Erstelle Projekt 'Test'" +# Feature-Logik nutzt CRUD-Interface: +realEstateInterface = getRealEstateInterface(currentUser) +projekt = realEstateInterface.createProjekt(Projekt( + mandateId=currentUser.mandateId, # Automatisch gesetzt + label="Test" # Validierung durch Pydantic +)) +``` + +**Vorteile:** +- ✅ **Validierung**: Pydantic-Modelle prüfen alle Felder automatisch +- ✅ **Zugriffskontrolle**: `RealEstateAccess` prüft Berechtigungen +- ✅ **Sicherheit**: Kein SQL-Injection-Risiko +- ✅ **Geschäftslogik**: Automatisches Setzen von Systemfeldern (`_createdBy`, `_createdAt`) +- ✅ **Typsicherheit**: Fehler werden zur Entwicklungszeit erkannt +- ✅ **Wartbarkeit**: Zentrale CRUD-Methoden, einfach zu testen + +#### Szenario 2: Direkte SQL-Queries (OPTIONAL) + +**Nur für komplexe SELECT-Queries:** + +```python +# Für komplexe Queries, die nicht über CRUD-Methoden abgedeckt sind +chatInterface = getChatInterface(currentUser) +results = chatInterface.executeQuery( + "SELECT p.*, COUNT(parz.id) as parzellen_count FROM Projekt p LEFT JOIN Parzelle parz ON parz.projektId = p.id GROUP BY p.id" +) +``` + +**Warnung:** +- ⚠️ **Nur für SELECT-Queries**: Keine INSERT/UPDATE/DELETE über direkte SQL-Queries +- ⚠️ **Validierung erforderlich**: Queries sollten validiert werden +- ⚠️ **SQL-Injection-Risiko**: Immer Parameterisierung verwenden + +#### Empfehlung + +**Sie benötigen das Query-Interface, wenn Sie:** + +- ✅ **Komplexe SELECT-Queries** benötigen (z.B. JOINs, Aggregationen) +- ✅ **Flexible Query-Ausführung** benötigen (nicht über CRUD-Methoden abgedeckt) + +**Sie benötigen es NICHT, wenn Sie:** + +- ✅ **Nur CRUD-Operationen** benötigen (verwenden Sie das CRUD-Interface) +- ✅ **Einfache Queries** haben (können über CRUD-Methoden abgedeckt werden) + +**Für Production-Systeme:** +- **CRUD-Operationen**: Immer über CRUD-Interface +- **Komplexe Queries**: Optional über Query-Interface (nur SELECT) + +### Struktur: Query-Interface (stateless) + +**Eine Datei** für direkte SQL-Query-Ausführung: + +#### Datei: `modules/interfaces/interfaceDbRealEstateChatObjects.py` + +```python +""" +Interface for direct SQL query execution (stateless). +Uses PostgreSQL connector for direct query execution without session management. +""" + +import logging +from typing import Dict, Any, Optional +from modules.datamodels.datamodelUam import User +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.shared.configuration import APP_CONFIG + +logger = logging.getLogger(__name__) + +# Singleton factory +_realEstateChatInterfaces = {} + + +class RealEstateChatObjects: + """Interface for direct SQL query execution (stateless).""" + + def __init__(self, currentUser: Optional[User] = None): + """Initialize the Query Interface.""" + self.currentUser = currentUser + self.userId = currentUser.id if currentUser else None + self.mandateId = currentUser.mandateId if currentUser else None + + # Initialize database + self._initializeDatabase() + + # Set user context if provided + if currentUser: + self.setUserContext(currentUser) + + def _initializeDatabase(self): + """Initialize PostgreSQL database connection.""" + try: + dbHost = APP_CONFIG.get("DB_APP_HOST", "localhost") + dbDatabase = APP_CONFIG.get("DB_APP_DATABASE", "poweron_app") + dbUser = APP_CONFIG.get("DB_APP_USER") + dbPassword = APP_CONFIG.get("DB_APP_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_APP_PORT", 5432)) + + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=self.userId if self.userId else None, + ) + + logger.info(f"Real Estate Query database connector initialized for database: {dbDatabase}") + except Exception as e: + logger.error(f"Error initializing Real Estate Query database: {e}") + raise + + def setUserContext(self, currentUser: User): + """Sets the user context for the interface.""" + self.currentUser = currentUser + self.userId = currentUser.id + self.mandateId = currentUser.mandateId + + if not self.userId or not self.mandateId: + raise ValueError("Invalid user context: id and mandateId are required") + + # Update database context + self.db.updateContext(self.userId) + + # ===== Database Query Execution ===== + + def executeQuery(self, queryText: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Execute a SQL query directly on the database (stateless). + + WARNING: This method executes raw SQL. Ensure proper validation and sanitization + before calling this method. Consider implementing query whitelisting or + only allowing SELECT statements for production use. + + Args: + queryText: SQL query string (preferably SELECT only) + parameters: Optional parameters for parameterized queries + + Returns: + Dictionary with 'rows' (list of dicts), 'columns' (list of column names), + 'rowCount' (int), and 'executionTime' (float) + """ + import time + + try: + start_time = time.time() + + # Ensure connection is alive + self.db._ensure_connection() + + with self.db.connection.cursor() as cursor: + # Execute query + if parameters: + # Use parameterized query for safety + cursor.execute(queryText, parameters) + else: + cursor.execute(queryText) + + # Fetch results + rows = cursor.fetchall() + + # Convert to list of dictionaries + result_rows = [dict(row) for row in rows] + + # Get column names + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + + execution_time = time.time() - start_time + + return { + "rows": result_rows, + "columns": columns, + "rowCount": len(result_rows), + "executionTime": execution_time, + } + except Exception as e: + logger.error(f"Error executing query: {e}") + raise + + +def getInterface(currentUser: User) -> RealEstateChatObjects: + """ + Factory function to get or create a Query interface instance for a user. + Uses singleton pattern per user. + """ + userKey = f"{currentUser.id}_{currentUser.mandateId}" + + if userKey not in _realEstateChatInterfaces: + _realEstateChatInterfaces[userKey] = RealEstateChatObjects(currentUser) + + return _realEstateChatInterfaces[userKey] +``` + +### Hinweise zur Implementierung + +1. **Stateless**: Keine Session-Management-Funktionen +2. **Nur für Queries**: Primär für SELECT-Queries gedacht +3. **Sicherheit**: Immer Parameterisierung verwenden +4. **Validierung**: Queries sollten validiert werden (z.B. nur SELECT erlauben) + +### Beispiel: Beide Interfaces zusammen nutzen + +```python +from modules.interfaces.interfaceDbRealEstateChatObjects import getInterface as getChatInterface +from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface + +# CRUD-Interface für strukturierte Operationen +realEstateInterface = getRealEstateInterface(currentUser) +projekt = realEstateInterface.createProjekt(Projekt( + mandateId=currentUser.mandateId, + label="Neues Projekt" +)) + +# Query-Interface für komplexe SELECT-Queries (optional) +chatInterface = getChatInterface(currentUser) +results = chatInterface.executeQuery( + "SELECT p.*, COUNT(parz.id) as parzellen_count FROM Projekt p LEFT JOIN Parzelle parz ON parz.projektId = p.id GROUP BY p.id" +) +``` + +--- + +## Zusammenfassung: Benötigte Dateien + +### Erforderlich (für CRUD-Operationen): + +1. ✅ `modules/datamodels/datamodelRealEstate.py` + - Real Estate-Datenmodelle (Projekt, Parzelle, Dokument, etc.) + +2. ✅ `modules/interfaces/interfaceDbRealEstateAccess.py` + - Zugriffskontrolle für Real Estate-Entitäten (RealEstateAccess) + +3. ✅ `modules/interfaces/interfaceDbRealEstateObjects.py` + - Real Estate CRUD-Interface (RealEstateObjects) + - Nutzt `interfaceDbRealEstateAccess.py` + - Haupt-Interface für alle CRUD-Operationen + +### Optional (für direkte SQL-Queries): + +4. ⚠️ `modules/interfaces/interfaceDbRealEstateChatObjects.py` + - Query-Interface für direkte SQL-Ausführung (RealEstateChatObjects) + - Stateless, keine Session-Management + - Nur wenn Sie komplexe SELECT-Queries benötigen + +--- + +[← Zurück: Datenmodell erstellen](02-datamodels.md) | [Weiter: Feature-Logik implementieren →](04-feature-logic.md) + + + diff --git a/docs/real-estate-feature-integration-guide/04-feature-logic.md b/docs/real-estate-feature-integration-guide/04-feature-logic.md new file mode 100644 index 00000000..cd0b1758 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/04-feature-logic.md @@ -0,0 +1,780 @@ +# Schritt 3: Feature-Logik implementieren + +[← Zurück: Interface erstellen](03-interfaces.md) | [Weiter: Routen erstellen →](05-routes.md) + +**Datei:** `modules/features/realEstate/mainRealEstate.py` + +Die Feature-Logik enthält die Geschäftslogik für das Feature. Sie wird von den Routen aufgerufen und arbeitet **stateless** ohne Session-Management. + +## Übersicht: Stateless Feature-Logik mit AI-Integration + +Die Feature-Logik verwendet **AI**, um natürliche Sprache direkt in CRUD-Operationen zu übersetzen - ohne Session-Management: + +``` +User Input (natürliche Sprache) + ↓ +AI-Analyse (Intent-Erkennung) + ↓ +CRUD-Operation identifizieren + ↓ +Parameter extrahieren + ↓ +Interface CRUD-Methode aufrufen + ↓ +Datenbank-Operation ausführen + ↓ +Ergebnis zurückgeben (keine Session-Speicherung) +``` + +**Beispiel:** +- User: "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" +- AI analysiert → Intent: CREATE, Entity: Projekt, Parameter: {label: "Hauptstrasse 42"} +- Feature-Logik ruft auf → `interface.createProjekt(Projekt(label="Hauptstrasse 42"))` +- Ergebnis wird direkt zurückgegeben (keine Session, keine History) + +## AI-Integration: Services initialisieren + +Um AI zu verwenden, müssen Sie die **Services** initialisieren. Services sind eine zentrale Schnittstelle zu verschiedenen Systemkomponenten (AI, Chat, Database, etc.). + +### Services-Initialisierung + +```python +from modules.services import getInterface as getServices + +# Services für einen User erhalten +services = getServices(currentUser, workflow=None) + +# AI-Service verfügbar über: +aiService = services.ai # Für AI-Aufrufe +``` + +**Wichtig:** Services werden normalerweise im Feature-Logik-Modul initialisiert und an Funktionen weitergegeben. + +--- + +## AI-basierte Intent-Erkennung und CRUD-Operationen + +### Schritt 1: Intent-Analyse mit AI + +Die AI analysiert User-Input und identifiziert: +- **Intent**: CREATE, READ, UPDATE, DELETE, QUERY +- **Entity**: Projekt, Parzelle, Dokument, etc. +- **Parameter**: Extrahierte Werte aus dem User-Input + +### Schritt 2: CRUD-Operation ausführen + +Basierend auf der AI-Analyse wird die entsprechende Interface-Methode aufgerufen. + +--- + +## Beispiel-Implementierung: + +```python +""" +Real Estate feature main logic. +Handles chat interface for database queries with AI-powered natural language processing. +""" + +import logging +import json +from typing import Optional, Dict, Any, List +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelRealEstate import ( + Projekt, + Parzelle, + StatusProzess, +) +from modules.interfaces.interfaceDbRealEstateChatObjects import getInterface as getChatInterface +from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface +from modules.services import getInterface as getServices + +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 + """ + try: + chatInterface = getChatInterface(currentUser) + + # Execute query directly (no session tracking) + result = chatInterface.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)}") + 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: + # 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: {intentAnalysis}") + + # Step 2: Execute CRUD operation based on intent + result = await executeIntentBasedOperation( + currentUser=currentUser, + intent=intentAnalysis["intent"], + entity=intentAnalysis["entity"], + parameters=intentAnalysis["parameters"], + ) + + return { + "success": True, + "intent": intentAnalysis["intent"], + "entity": intentAnalysis["entity"], + "result": result, + } + + except Exception as e: + logger.error(f"Error processing natural language command: {str(e)}") + 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 + 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 + +Available entities: +- Projekt: Real estate project +- Parzelle: Plot/parcel +- Dokument: Document +- Kanton: Canton +- Gemeinde: Municipality + +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 + // For READ: include filter criteria + // For DELETE: include entity ID if mentioned + // For QUERY: include query text or natural language query + }}, + "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: "SELECT * FROM Projekt WHERE plz = '8000'" + Output: {{"intent": "QUERY", "entity": null, "parameters": {{"queryText": "SELECT * FROM Projekt WHERE plz = '8000'", "queryType": "sql"}}, "confidence": 1.0}} +""" + + try: + # Use AI planning call for structured JSON response + response = await aiService.callAiPlanning( + prompt=intentPrompt, + debugType="intentanalysis" + ) + + # Parse JSON response + intentData = json.loads(response) + + # Validate response structure + if "intent" not in intentData or "entity" not in intentData: + raise ValueError("Invalid intent analysis response structure") + + 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)}") + 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 + """ + try: + if intent == "QUERY": + # Execute database query directly (stateless) + queryText = parameters.get("queryText", "") + + 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": + 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": + parzelle = Parzelle( + mandateId=currentUser.mandateId, + label=parameters.get("label", ""), + # ... weitere Parameter + ) + created = realEstateInterface.createParzelle(parzelle) + return {"operation": "CREATE", "entity": "Parzelle", "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": + # Apply filters from parameters + projektId = parameters.get("id") + if projektId: + projekt = realEstateInterface.getProjekt(projektId) + return {"operation": "READ", "entity": "Projekt", "result": projekt.model_dump() if projekt else None} + else: + # List all projects (with optional filters) + # Note: You may need to implement getProjekte() method + raise NotImplementedError("List operation needs to be implemented") + + 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()} + + 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} + + 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)}") + raise + + +# ===== Erweiterte Query-Funktion mit AI-Unterstützung ===== + +async def executeNaturalLanguageQuery( + currentUser: User, + naturalLanguageQuery: str, +) -> Dict[str, Any]: + """ + Execute a natural language query by translating it to SQL using AI. + + Args: + currentUser: Current authenticated user + naturalLanguageQuery: Natural language query (e.g., "Zeige mir alle Projekte in Zürich") + + Returns: + Query result with metadata (stateless, no session) + """ + try: + services = getServices(currentUser, workflow=None) + aiService = services.ai + + # Step 1: Translate natural language to SQL using AI + sqlQuery = await translateNaturalLanguageToSQL(aiService, naturalLanguageQuery, currentUser.mandateId) + + logger.info(f"Translated '{naturalLanguageQuery}' to SQL: {sqlQuery}") + + # Step 2: Execute the SQL query directly (stateless) + result = await executeDirectQuery( + currentUser=currentUser, + queryText=sqlQuery, + ) + + return result + + except Exception as e: + logger.error(f"Error executing natural language query: {str(e)}") + raise + + +async def translateNaturalLanguageToSQL( + aiService, + naturalLanguageQuery: str, + mandateId: str +) -> str: + """ + Use AI to translate natural language query to SQL. + + Args: + aiService: AI service instance + naturalLanguageQuery: Natural language query + mandateId: User's mandate ID for filtering + + Returns: + SQL query string with mandateId filter applied + """ + translationPrompt = f""" +Translate the following natural language query into a valid PostgreSQL SQL SELECT statement. + +Natural Language Query: "{naturalLanguageQuery}" + +Available tables and their fields: +- Projekt: id, mandateId, label, statusProzess, perimeter, baulinie, parzellen (JSONB), dokumente (JSONB) +- Parzelle: id, mandateId, label, strasseNr, plz, bauzone, az, bz, kontextKanton, kontextGemeinde +- Dokument: id, mandateId, label, dokumentTyp, dokumentReferenz, mimeType +- Kanton: id, mandateId, label, abk +- Gemeinde: id, mandateId, label, plz + +Rules: +1. Always include 'mandateId' filter based on user context (use placeholder {{mandateId}}) +2. Only use SELECT statements (no INSERT, UPDATE, DELETE) +3. Return ONLY the SQL query, no explanations +4. Use proper PostgreSQL syntax +5. For text searches, use ILIKE for case-insensitive matching + +Examples: +- Input: "Zeige mir alle Projekte" + Output: SELECT * FROM Projekt WHERE mandateId = '{{mandateId}}' + +- Input: "Zeige mir alle Parzellen in Zürich" + Output: SELECT p.* FROM Parzelle p JOIN Gemeinde g ON p.kontextGemeinde = g.id WHERE g.label ILIKE '%Zürich%' AND p.mandateId = '{{mandateId}}' + +- Input: "Wie viele Projekte haben Status 'Planung'?" + Output: SELECT COUNT(*) as count FROM Projekt WHERE statusProzess = 'Planung' AND mandateId = '{{mandateId}}' + +Now translate this query: +""" + + try: + # Use AI planning call for SQL generation + response = await aiService.callAiPlanning( + prompt=translationPrompt, + debugType="sqltranslation" + ) + + # Clean response (remove markdown code blocks if present) + sqlQuery = response.strip() + if sqlQuery.startswith("```sql"): + sqlQuery = sqlQuery[6:] + if sqlQuery.startswith("```"): + sqlQuery = sqlQuery[3:] + if sqlQuery.endswith("```"): + sqlQuery = sqlQuery[:-3] + sqlQuery = sqlQuery.strip() + + # Replace placeholder with actual mandateId + sqlQuery = sqlQuery.replace("{{mandateId}}", mandateId) + + return sqlQuery + + except Exception as e: + logger.error(f"Error translating natural language to SQL: {str(e)}") + raise ValueError(f"Failed to translate query: {str(e)}") +``` + +## Wichtige Punkte: + +### 1. Services-Initialisierung + +- **`getServices(currentUser, workflow=None)`** - Initialisiert Services für AI-Zugriff +- **`services.ai`** - Zugriff auf AI-Service für AI-Aufrufe + +### 2. AI-Aufrufe + +- **`callAiPlanning()`** - Für strukturierte JSON-Antworten (Intent-Analyse, SQL-Übersetzung) +- **`callAiText()`** - Für einfache Text-Generierung +- **`callAiDocuments()`** - Für Dokumenten-Verarbeitung + +### 3. Intent-Analyse + +Die AI analysiert User-Input und gibt zurück: +- **Intent**: CREATE, READ, UPDATE, DELETE, QUERY +- **Entity**: Projekt, Parzelle, Dokument, etc. +- **Parameters**: Extrahierte Werte aus dem Input + +### 4. CRUD-Operationen + +Basierend auf der Intent-Analyse: +- **CREATE** → `interface.createProjekt()`, `interface.createParzelle()`, etc. +- **READ** → `interface.getProjekt()`, `interface.getParzelle()`, etc. +- **UPDATE** → `interface.updateProjekt()`, etc. +- **DELETE** → `interface.deleteProjekt()`, etc. +- **QUERY** → `interface.executeQuery()` oder `executeDatabaseQuery()` + +### 5. Natural Language to SQL + +- AI übersetzt natürliche Sprache in SQL-Queries +- Automatische Validierung und Sanitization empfohlen +- MandateId-Filter wird automatisch hinzugefügt + +### 6. Error Handling + +- Umfassendes Error Handling für AI-Aufrufe +- JSON-Parsing mit Fallback +- Logging für Debugging + +--- + +## Beispiel-Verwendung: + +```python +# In einer Route (stateless): +@router.post("/command") +async def process_command( + userInput: str = Body(...), + currentUser: User = Depends(getCurrentUser) +): + result = await processNaturalLanguageCommand( + currentUser=currentUser, + userInput=userInput + ) + return result + +# Direkte Query (stateless): +@router.post("/query") +async def execute_query( + queryText: str = Body(...), + currentUser: User = Depends(getCurrentUser) +): + result = await executeDirectQuery( + currentUser=currentUser, + queryText=queryText + ) + return result +``` + +**User-Input-Beispiele:** +- `"Erstelle ein neues Projekt namens 'Hauptstrasse 42'"` +- `"Zeige mir alle Projekte in Zürich"` +- `"Aktualisiere Projekt XYZ mit Status 'Planung'"` +- `"Wie viele Parzellen haben Bauzone W3?"` + +--- + +## Vollständiger Flow: User-Input → CRUD-Operation (stateless) + +### Beispiel: "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" + +``` +1. User sendet HTTP POST Request + POST /api/realestate/command + Body: {"userInput": "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"} + +2. Route ruft Feature-Logik auf + → processNaturalLanguageCommand(currentUser, userInput) + # Keine Session-ID notwendig! + +3. Feature-Logik initialisiert Services + → services = getServices(currentUser, workflow=None) + → aiService = services.ai + +4. AI analysiert User-Input + → analyzeUserIntent(aiService, userInput) + → AI gibt zurück: + { + "intent": "CREATE", + "entity": "Projekt", + "parameters": {"label": "Hauptstrasse 42"}, + "confidence": 0.95 + } + +5. Feature-Logik führt CRUD-Operation aus + → executeIntentBasedOperation(intent="CREATE", entity="Projekt", ...) + → realEstateInterface = getRealEstateInterface(currentUser) + → projekt = Projekt(mandateId=..., label="Hauptstrasse 42") + → created = realEstateInterface.createProjekt(projekt) + +6. Interface speichert in Datenbank + → DatabaseConnector.recordCreate(Projekt, projekt.model_dump()) + → PostgreSQL INSERT INTO Projekt ... + +7. Ergebnis wird direkt zurückgegeben + → Route gibt HTTP Response zurück + → Keine Session-Speicherung, keine History + → Frontend zeigt Erfolg +``` + +--- + +## AI-Service Methoden im Detail + +### `callAiPlanning()` - Für strukturierte Antworten + +**Verwendung:** Intent-Analyse, SQL-Übersetzung, strukturierte Daten-Extraktion + +```python +response = await aiService.callAiPlanning( + prompt=intentPrompt, + debugType="intentanalysis" # Optional: für Debug-Dateien +) +# Response ist JSON-String, muss geparst werden +intentData = json.loads(response) +``` + +**Vorteile:** +- Optimiert für strukturierte JSON-Antworten +- Verwendet beste Modelle für Planungs-Aufgaben +- Automatisches Debug-File-Writing + +### `callAiText()` - Für einfache Text-Generierung + +**Verwendung:** Text-Generierung, Zusammenfassungen, Erklärungen + +```python +response = await aiService.callAiText( + prompt="Erkläre mir...", + documents=None, # Optional: Dokumente für Kontext + options=AiCallOptions(...) +) +# Response ist direkt Text-String +``` + +### `callAiDocuments()` - Für Dokumenten-Verarbeitung + +**Verwendung:** Dokumenten-Analyse, Extraktion, Generierung mit Dokumenten-Kontext + +```python +response = await aiService.callAiDocuments( + prompt="Analysiere diese Dokumente...", + documents=[ChatDocument(...), ...], + options=AiCallOptions(...), + outputFormat="json" # Optional: Format für Output +) +``` + +--- + +## Best Practices für AI-Integration + +### 1. Prompt-Engineering + +- **Klare Struktur**: Definieren Sie genau, welche Antwort Sie erwarten +- **Beispiele**: Geben Sie Beispiele für bessere Ergebnisse +- **Format**: Spezifizieren Sie das erwartete Format (JSON, SQL, etc.) + +### 2. Error Handling + +- **JSON-Parsing**: Immer try/except für JSON-Parsing +- **Fallback**: Planen Sie Fallback-Strategien bei AI-Fehlern +- **Validierung**: Validieren Sie AI-Antworten vor Verwendung + +### 3. Sicherheit + +- **Query-Validierung**: Validieren Sie SQL-Queries vor Ausführung +- **Parameter-Sanitization**: Sanitizen Sie alle Parameter +- **MandateId-Filter**: Stellen Sie sicher, dass MandateId immer gefiltert wird + +### 4. Performance + +- **Caching**: Cache häufige AI-Antworten wenn möglich +- **Model-Auswahl**: Lassen Sie das System automatisch das beste Modell wählen +- **Async**: Nutzen Sie async/await für nicht-blockierende Operationen + +### 5. Debugging + +- **Debug-Files**: Nutzen Sie `debugType` Parameter für Debug-Dateien +- **Logging**: Loggen Sie alle AI-Aufrufe und Antworten +- **Confidence-Scores**: Nutzen Sie Confidence-Scores für Fehlerbehandlung + +--- + +## Erweiterte Features + +### Schema-Aware Prompting + +Sie können das Datenbank-Schema in Prompts einbinden: + +```python +# Lade Schema-Informationen +schemaInfo = getDatabaseSchema() # Ihre Funktion + +prompt = f""" +Available database schema: +{schemaInfo} + +User query: "{userInput}" +... +""" +``` + +### Context-Aware Operations (Optional) + +Falls Sie später Kontext zwischen Queries benötigen, können Sie optional eine Session verwenden: + +```python +# Optional: Session für Kontext (nur wenn nötig) +# Für stateless Operationen nicht notwendig + +# Falls Session gewünscht: +sessionId = parameters.get("sessionId") # Optional +if sessionId: + previousQueries = interface.getQueries(sessionId=sessionId) + context = "\n".join([q.queryText for q in previousQueries[-5:]]) +else: + context = "" # Kein Kontext bei stateless Operationen + +prompt = f""" +{context if context else ""} +User query: "{userInput}" +... +""" +``` + +### Multi-Step Operations + +Für komplexe Operationen können Sie mehrere AI-Calls machen: + +```python +# Schritt 1: Intent-Analyse +intent = await analyzeUserIntent(aiService, userInput) + +# Schritt 2: Parameter-Validierung +if intent["intent"] == "CREATE": + validatedParams = await validateParameters(aiService, intent["parameters"]) + +# Schritt 3: CRUD-Operation +result = await executeIntentBasedOperation(...) +``` + +--- + +[← Zurück: Interface erstellen](03-interfaces.md) | [Weiter: Routen erstellen →](05-routes.md) + + + diff --git a/docs/real-estate-feature-integration-guide/05-routes.md b/docs/real-estate-feature-integration-guide/05-routes.md new file mode 100644 index 00000000..04908fd7 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/05-routes.md @@ -0,0 +1,332 @@ +# Schritt 4: Routen erstellen + +[← Zurück: Feature-Logik implementieren](04-feature-logic.md) | [Weiter: Router registrieren →](06-router-registration.md) + +**Datei:** `modules/routes/routeRealEstate.py` + +Die Routen definieren die REST-API-Endpunkte für das Feature. Das Feature arbeitet **stateless** ohne Session-Management. + +## Route-Struktur + +``` +/api/realestate/ + ├── POST /command → Natürliche Sprache → CRUD-Operation + └── POST /query → Direkte SQL-Query +``` + +## Beispiel-Implementierung: + +```python +""" +Real Estate routes for the backend API. +Implements stateless endpoints for real estate database operations with AI-powered natural language processing. +""" + +import logging +from typing import Optional, Dict, Any +from fastapi import APIRouter, HTTPException, Depends, Body, Request +from modules.security.auth import limiter, getCurrentUser +from modules.datamodels.datamodelUam import User +from modules.features.realEstate.mainRealEstate import ( + processNaturalLanguageCommand, + executeDirectQuery, +) + +# Configure logger +logger = logging.getLogger(__name__) + +# Create router for real estate endpoints +router = APIRouter( + prefix="/api/realestate", + tags=["Real Estate"], + responses={404: {"description": "Not found"}} +) + + +# ===== Stateless Command Endpoint ===== + +@router.post("/command", response_model=Dict[str, Any]) +@limiter.limit("120/minute") +async def process_command( + request: Request, + userInput: str = Body(..., embed=True, description="Natural language command"), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Process natural language command and execute corresponding CRUD operation. + + Uses AI to analyze user intent and extract parameters, then executes the appropriate + CRUD operation. Works stateless without session management. + + 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'" + + Returns: + { + "success": true, + "intent": "CREATE|READ|UPDATE|DELETE|QUERY", + "entity": "Projekt|Parzelle|...|null", + "result": {...} + } + """ + try: + result = await processNaturalLanguageCommand( + currentUser=currentUser, + userInput=userInput + ) + return result + except ValueError as e: + logger.error(f"Validation error: {str(e)}") + raise HTTPException( + status_code=400, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error processing command: {str(e)}") + raise HTTPException( + status_code=500, + detail=str(e) + ) + + +# ===== Stateless Query Endpoint ===== + +@router.post("/query", response_model=Dict[str, Any]) +@limiter.limit("120/minute") +async def execute_query( + request: Request, + queryText: str = Body(..., embed=True, description="SQL query text"), + parameters: Optional[Dict[str, Any]] = Body(None, embed=True, description="Optional query parameters for parameterized queries"), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Execute a direct SQL query without session management. + + Executes the query directly and returns the result. No query history is saved. + + WARNING: This endpoint executes raw SQL queries. Ensure proper validation + and sanitization on the frontend. Consider implementing query whitelisting + or only allowing SELECT statements for production use. + + Returns: + { + "status": "success", + "rows": [...], + "columns": [...], + "rowCount": 15, + "executionTime": 0.123 + } + """ + try: + result = await executeDirectQuery( + currentUser=currentUser, + queryText=queryText, + parameters=parameters, + ) + return result + except ValueError as e: + logger.error(f"Validation error: {str(e)}") + raise HTTPException( + status_code=400, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error executing query: {str(e)}") + raise HTTPException( + status_code=500, + detail=str(e) + ) +``` + +## Wichtige Punkte: + +### 1. Stateless Design + +- **Keine Session-Management**: Alle Endpunkte arbeiten stateless +- **Direkte Verarbeitung**: User-Input wird direkt verarbeitet und Ergebnis zurückgegeben +- **Keine History**: Queries werden nicht gespeichert (kann optional später hinzugefügt werden) + +### 2. API-Endpunkte + +**`POST /api/realestate/command`** +- Verarbeitet natürliche Sprache +- Nutzt AI für Intent-Analyse +- Führt CRUD-Operationen aus +- Gibt Ergebnis direkt zurück + +**`POST /api/realestate/query`** +- Führt direkte SQL-Queries aus +- Keine Session notwendig +- Gibt Query-Ergebnis direkt zurück + +### 3. Sicherheit + +- **Rate Limiting**: `@limiter.limit("120/minute")` für API-Schutz +- **Authentication**: `Depends(getCurrentUser)` für alle Endpunkte +- **Query-Validierung**: WICHTIG - Validieren Sie SQL-Queries vor Ausführung +- **MandateId-Filter**: Wird automatisch durch Interfaces angewendet + +### 4. Error Handling + +- Umfassendes Error Handling mit HTTPException +- Unterschiedliche Status-Codes: 400 (Validation), 404 (Not Found), 500 (Server Error) +- Detaillierte Fehlermeldungen für Debugging + +### 5. Response-Struktur + +**Command-Endpunkt:** +```json +{ + "success": true, + "intent": "CREATE", + "entity": "Projekt", + "result": { + "operation": "CREATE", + "entity": "Projekt", + "result": { + "id": "projekt_123", + "label": "Hauptstrasse 42", + ... + } + } +} +``` + +**Query-Endpunkt:** +```json +{ + "status": "success", + "rows": [ + {"id": "...", "label": "...", ...} + ], + "columns": ["id", "label", ...], + "rowCount": 15, + "executionTime": 0.123 +} +``` + +--- + +## Beispiel-Requests + +### Command-Endpunkt + +```bash +# CREATE Operation +POST /api/realestate/command +Content-Type: application/json +Authorization: Bearer + +{ + "userInput": "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" +} + +# READ Operation +POST /api/realestate/command +{ + "userInput": "Zeige mir alle Projekte in Zürich" +} + +# UPDATE Operation +POST /api/realestate/command +{ + "userInput": "Aktualisiere Projekt XYZ mit Status 'Planung'" +} + +# DELETE Operation +POST /api/realestate/command +{ + "userInput": "Lösche Parzelle ABC" +} + +# QUERY Operation (SQL wird erkannt) +POST /api/realestate/command +{ + "userInput": "SELECT * FROM Projekt WHERE plz = '8000'" +} +``` + +### Query-Endpunkt + +```bash +# Direkte SQL-Query +POST /api/realestate/query +Content-Type: application/json +Authorization: Bearer + +{ + "queryText": "SELECT * FROM Projekt WHERE plz = '8000'" +} + +# Parameterized Query +POST /api/realestate/query +{ + "queryText": "SELECT * FROM Projekt WHERE plz = $1", + "parameters": {"$1": "8000"} +} +``` + +--- + +## Flow: Route → Feature-Logik + +### Command-Endpunkt Flow + +``` +POST /api/realestate/command + ↓ +routeRealEstate.process_command() + ↓ +getCurrentUser() # Auth + ↓ +processNaturalLanguageCommand(currentUser, userInput) + ↓ +mainRealEstate.processNaturalLanguageCommand() + ↓ +analyzeUserIntent() → executeIntentBasedOperation() + ↓ +return Dict mit Ergebnis +``` + +### Query-Endpunkt Flow + +``` +POST /api/realestate/query + ↓ +routeRealEstate.execute_query() + ↓ +getCurrentUser() # Auth + ↓ +executeDirectQuery(currentUser, queryText, parameters) + ↓ +mainRealEstate.executeDirectQuery() + ↓ +getChatInterface(currentUser) + ↓ +RealEstateChatObjects.executeQuery(queryText) + ↓ +DatabaseConnector.executeQuery(sql) + ↓ +return Dict mit rows, columns, rowCount +``` + +--- + +## Vorteile des stateless Ansatzes + +- **Einfachheit**: Kein Session-Management notwendig +- **Performance**: Weniger Datenbank-Operationen pro Request +- **Skalierbarkeit**: Stateless Requests sind einfacher zu skalieren +- **Flexibilität**: Jeder Request ist unabhängig +- **Schnell**: Direkte Verarbeitung ohne Overhead + +--- + +[← Zurück: Feature-Logik implementieren](04-feature-logic.md) | [Weiter: Router registrieren →](06-router-registration.md) + + + diff --git a/docs/real-estate-feature-integration-guide/06-router-registration.md b/docs/real-estate-feature-integration-guide/06-router-registration.md new file mode 100644 index 00000000..430c88b2 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/06-router-registration.md @@ -0,0 +1,45 @@ +# Schritt 5: Router registrieren + +[← Zurück: Routen erstellen](05-routes.md) | [Weiter: Environment-Konfiguration →](07-environment.md) + +**Datei:** `app.py` + +Der Router muss in der Hauptanwendung registriert werden. + +## Änderung in app.py: + +```python +# ... existing imports ... + +# Include all routers + +from modules.routes.routeAdmin import router as generalRouter +app.include_router(generalRouter) + +# ... existing routers ... + +from modules.routes.routeChatPlayground import router as chatPlaygroundRouter +app.include_router(chatPlaygroundRouter) + +# NEU: Real Estate Router hinzufügen (Chat-Interface) +from modules.routes.routeRealEstate import router as realEstateRouter +app.include_router(realEstateRouter) + +# NEU: Real Estate Data Router hinzufügen (falls CRUD-API gewünscht) +# from modules.routes.routeRealEstateData import router as realEstateDataRouter +# app.include_router(realEstateDataRouter) + +from modules.routes.routeSecurityLocal import router as localRouter +app.include_router(localRouter) + +# ... rest of routers ... +``` + +**Wichtig**: Die Reihenfolge der Router-Registrierung kann wichtig sein, wenn es Überschneidungen in den Pfaden gibt. Allgemeinere Routen sollten nach spezifischeren Routen kommen. + +--- + +[← Zurück: Routen erstellen](05-routes.md) | [Weiter: Environment-Konfiguration →](07-environment.md) + + + diff --git a/docs/real-estate-feature-integration-guide/07-environment.md b/docs/real-estate-feature-integration-guide/07-environment.md new file mode 100644 index 00000000..73765e61 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/07-environment.md @@ -0,0 +1,50 @@ +# Schritt 6: Environment-Konfiguration + +[← Zurück: Router registrieren](06-router-registration.md) | [Weiter: Feature Lifecycle →](08-lifecycle.md) + +**Datei:** `env_dev.env` + +Für das realEstate-Feature benötigen wir keine zusätzlichen Environment-Variablen, da es die bereits vorhandenen PostgreSQL-Konfigurationen nutzt: + +```env +# PostgreSQL Storage (bereits vorhanden) +DB_APP_HOST=localhost +DB_APP_DATABASE=poweron_app +DB_APP_USER=poweron_dev +DB_APP_PASSWORD_SECRET = DEV_ENC:... +DB_APP_PORT=5432 +``` + +**Optional**: Falls Sie eine separate Datenbank für Real Estate verwenden möchten, können Sie zusätzliche Variablen hinzufügen: + +```env +# Optional: Separate Real Estate Database +DB_REALESTATE_HOST=localhost +DB_REALESTATE_DATABASE=poweron_realestate +DB_REALESTATE_USER=poweron_dev +DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:... +DB_REALESTATE_PORT=5432 +``` + +In diesem Fall müssten Sie die `_initializeDatabase()` Methode im Interface anpassen: + +```python +def _initializeDatabase(self): + """Initialize PostgreSQL database connection.""" + try: + # Use Real Estate specific config if available, otherwise fall back to APP config + dbHost = APP_CONFIG.get("DB_REALESTATE_HOST") or APP_CONFIG.get("DB_APP_HOST", "localhost") + dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE") or APP_CONFIG.get("DB_APP_DATABASE", "poweron_app") + dbUser = APP_CONFIG.get("DB_REALESTATE_USER") or APP_CONFIG.get("DB_APP_USER") + dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET") or APP_CONFIG.get("DB_APP_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT") or APP_CONFIG.get("DB_APP_PORT", 5432)) + + # ... rest of initialization ... +``` + +--- + +[← Zurück: Router registrieren](06-router-registration.md) | [Weiter: Feature Lifecycle →](08-lifecycle.md) + + + diff --git a/docs/real-estate-feature-integration-guide/08-lifecycle.md b/docs/real-estate-feature-integration-guide/08-lifecycle.md new file mode 100644 index 00000000..0946e331 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/08-lifecycle.md @@ -0,0 +1,43 @@ +# Schritt 7: Feature Lifecycle (optional) + +[← Zurück: Environment-Konfiguration](07-environment.md) | [Weiter: Datenbank-Schema →](09-database-schema.md) + +**Datei:** `modules/features/featuresLifecycle.py` + +Falls Ihr Feature Hintergrundprozesse oder Initialisierung beim Start benötigt, können Sie diese hier hinzufügen: + +```python +async def start() -> None: + """ Start feature triggers and background managers """ + + # Provide Event User + rootInterface = getRootInterface() + eventUser = rootInterface.getUserByUsername("event") + + # ... existing features ... + + # Feature RealEstate (optional) + # from modules.features.realEstate import mainRealEstate + # mainRealEstate.initializeFeature(eventUser) + # logger.info("Real Estate feature initialized") + + return True + + +async def stop() -> None: + """ Stop feature triggers and background managers """ + + # Feature RealEstate cleanup (optional) + # from modules.features.realEstate import mainRealEstate + # mainRealEstate.cleanupFeature() + # logger.info("Real Estate feature cleaned up") + + return True +``` + +--- + +[← Zurück: Environment-Konfiguration](07-environment.md) | [Weiter: Datenbank-Schema →](09-database-schema.md) + + + diff --git a/docs/real-estate-feature-integration-guide/09-database-schema.md b/docs/real-estate-feature-integration-guide/09-database-schema.md new file mode 100644 index 00000000..bd094003 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/09-database-schema.md @@ -0,0 +1,247 @@ +# Datenbank-Schema + +[← Zurück: Feature Lifecycle](08-lifecycle.md) | [Weiter: Sicherheitshinweise →](10-security.md) + +Die Datenbank-Tabellen werden automatisch vom `DatabaseConnector` erstellt, basierend auf den Pydantic-Modellen: + +## Chat-Interface Tabellen: + +- **RealEstateQuery**: Speichert Abfragen +- **RealEstateQueryResult**: Speichert Abfrageergebnisse (mit JSONB für `rowData`) +- **RealEstateChatSession**: Speichert Chat-Sessions + +## Real Estate-Datenmodell Tabellen: + +Die folgenden Tabellen werden basierend auf den Real Estate-Datenmodell-Entitäten erstellt: + +- **Projekt**: Bauprojekte (mit `parzellen`, `dokumente`, `kontextInformationen` als JSONB) +- **Parzelle**: Grundstücke mit Bauparametern (mit `parzellenNachbarschaft`, `dokumente`, `kontextInformationen` als JSONB) +- **Dokument**: Dateien und URLs +- **Kontext**: Zusatzinformationen +- **GeoPolylinie**: Geometrische Linien/Polygone (mit `punkte` als JSONB) +- **GeoPunkt**: 3D-Koordinaten +- **Land**: Nationale Ebene (mit `dokumente`, `kontextInformationen` als JSONB) +- **Kanton**: Kantonale Ebene (mit `dokumente`, `kontextInformationen` als JSONB) +- **Gemeinde**: Gemeinde-Ebene (mit `dokumente`, `kontextInformationen` als JSONB) + +--- + +## Automatische Tabellenerstellung + +### Wie funktioniert die automatische Tabellenerstellung? + +Der `DatabaseConnector` erstellt Tabellen **automatisch beim ersten Zugriff** auf ein Pydantic-Modell. Sie müssen keine SQL-CREATE-TABLE-Statements manuell schreiben. + +#### 1. Ablauf der Tabellenerstellung: + +``` +1. Code ruft z.B. `db.recordCreate(Projekt, projekt_data)` auf + ↓ +2. DatabaseConnector ruft `_ensureTableExists(Projekt)` auf + ↓ +3. Prüft ob Tabelle "Projekt" existiert (über information_schema) + ↓ +4. Wenn NICHT vorhanden: + → Ruft `_create_table_from_model()` auf + → Extrahiert Felder aus Pydantic-Modell mit `_get_model_fields()` + → Mappt Python-Typen zu SQL-Typen + → Erstellt CREATE TABLE Statement + → Führt SQL aus + → Erstellt Indexes für Foreign Keys +``` + +#### 2. Typ-Mapping (Python → PostgreSQL): + +Der `DatabaseConnector` mappt automatisch Pydantic-Feldtypen zu PostgreSQL-Datentypen: + +| Python/Pydantic Typ | PostgreSQL Typ | Beispiel | +|---------------------|----------------|----------| +| `str` oder `Optional[str]` | `TEXT` | `label: str` → `"label" TEXT` | +| `int` | `INTEGER` | `vollgeschossZahl: int` → `"vollgeschossZahl" INTEGER` | +| `float` | `DOUBLE PRECISION` | `az: float` → `"az" DOUBLE PRECISION` | +| `bool` | `BOOLEAN` | `closed: bool` → `"closed" BOOLEAN` | +| `Dict[str, Any]` oder `dict` | `JSONB` | `parameters: Dict[str, Any]` → `"parameters" JSONB` | +| `List[...]` oder `list` | `JSONB` | `parzellen: List[Parzelle]` → `"parzellen" JSONB` | +| `Optional[Enum]` | `TEXT` | `statusProzess: StatusProzess` → `"statusProzess" TEXT` | + +**Spezielle Felder:** +- Felder mit Namen `*Id` (z.B. `kontextKantonId`) erhalten automatisch einen Index +- Systemfelder werden automatisch hinzugefügt: `_createdAt`, `_createdBy`, `_modifiedAt`, `_modifiedBy` + +#### 3. Beispiel: CREATE TABLE Statement + +Für das `Projekt`-Modell würde automatisch folgendes SQL erstellt: + +```sql +CREATE TABLE IF NOT EXISTS "Projekt" ( + "id" VARCHAR(255) PRIMARY KEY, + "mandateId" TEXT, + "label" TEXT, + "statusProzess" TEXT, + "perimeter" JSONB, + "baulinie" JSONB, + "parzellen" JSONB, + "dokumente" JSONB, + "kontextInformationen" JSONB, + "_createdAt" DOUBLE PRECISION, + "_modifiedAt" DOUBLE PRECISION, + "_createdBy" VARCHAR(255), + "_modifiedBy" VARCHAR(255) +); + +-- Automatisch erstellte Indexes für Foreign Keys: +CREATE INDEX IF NOT EXISTS "idx_Projekt_mandateId" ON "Projekt" ("mandateId"); +``` + +#### 4. Automatische Schema-Migrationen + +**Wichtig:** Der Connector unterstützt **additive Migrationen**: + +- Wenn eine Tabelle bereits existiert, werden **fehlende Spalten automatisch hinzugefügt** +- **Bestehende Spalten werden NICHT gelöscht oder geändert** +- Wenn Sie ein neues Feld zum Pydantic-Modell hinzufügen, wird es beim nächsten Zugriff automatisch als Spalte hinzugefügt + +**Beispiel:** +```python +# Ursprüngliches Modell +class Projekt(BaseModel): + id: str + label: str + statusProzess: Optional[StatusProzess] + +# Später: Neues Feld hinzugefügt +class Projekt(BaseModel): + id: str + label: str + statusProzess: Optional[StatusProzess] + beschreibung: Optional[str] # NEU + +# Beim nächsten recordCreate() wird automatisch ausgeführt: +# ALTER TABLE "Projekt" ADD COLUMN "beschreibung" TEXT +``` + +#### 5. Wann werden Tabellen erstellt? + +Tabellen werden erstellt, wenn Sie **zum ersten Mal** eine der folgenden Operationen ausführen: + +- `db.recordCreate(model_class, data)` - Erstellt Record +- `db.recordUpdate(model_class, recordId, data)` - Aktualisiert Record +- `db.getRecordset(model_class)` - Lädt Records +- `db.getRecord(model_class, recordId)` - Lädt einen Record + +**Beispiel:** +```python +# Beim ersten Aufruf wird die Tabelle "Projekt" automatisch erstellt +interface = getInterface(currentUser) +projekt = interface.createProjekt(label="Mein Projekt") +# → Tabelle "Projekt" wird jetzt in PostgreSQL erstellt +``` + +#### 6. Manuelle Tabellenerstellung (optional) + +Falls Sie Tabellen manuell erstellen möchten (z.B. für Initialisierung), können Sie: + +```python +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelRealEstate import Projekt, Parzelle + +# Connector initialisieren +db = DatabaseConnector( + dbHost="localhost", + dbDatabase="poweron_app", + dbUser="poweron_dev", + dbPassword="...", + dbPort=5432 +) + +# Tabellen explizit erstellen +db._ensureTableExists(Projekt) +db._ensureTableExists(Parzelle) +# ... weitere Modelle +``` + +#### 7. Wichtige Hinweise: + +✅ **Automatisch:** +- Tabellenerstellung beim ersten Zugriff +- Spalten-Erstellung basierend auf Pydantic-Feldern +- Index-Erstellung für Foreign Keys (`*Id` Felder) +- Systemfelder (`_createdAt`, etc.) werden automatisch hinzugefügt + +❌ **NICHT automatisch:** +- Foreign Key Constraints (werden nicht erstellt - Sie müssen sie manuell hinzufügen falls gewünscht) +- Unique Constraints (außer PRIMARY KEY auf `id`) +- Check Constraints +- Trigger oder Stored Procedures + +⚠️ **Einschränkungen:** +- **Keine Schema-Änderungen**: Wenn Sie einen Feldtyp ändern (z.B. `str` → `int`), wird die Spalte NICHT automatisch geändert +- **Keine Spalten-Löschung**: Gelöschte Felder im Modell werden nicht aus der Datenbank entfernt +- **Case-Sensitive**: Tabellennamen werden exakt wie der Klassenname verwendet (z.B. `Projekt`, nicht `projekt`) + +#### 8. Beispiel: Vollständiger Ablauf + +```python +# 1. Pydantic-Modell definieren +class Projekt(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + mandateId: str + label: str + statusProzess: Optional[StatusProzess] + parzellen: List[Parzelle] = Field(default_factory=list) + +# 2. Interface initialisieren (erstellt noch keine Tabellen) +interface = getInterface(currentUser) + +# 3. Ersten Record erstellen (erstellt jetzt die Tabelle!) +projekt = interface.createProjekt( + label="Mein erstes Projekt", + statusProzess=StatusProzess.PLANUNG +) +# → Intern wird ausgeführt: +# 1. _ensureTableExists(Projekt) aufgerufen +# 2. Tabelle "Projekt" existiert nicht → wird erstellt +# 3. CREATE TABLE "Projekt" (...) wird ausgeführt +# 4. Record wird eingefügt + +# 4. Weitere Records können jetzt ohne Tabellenerstellung erstellt werden +projekt2 = interface.createProjekt(label="Zweites Projekt") +# → Tabelle existiert bereits, nur INSERT wird ausgeführt +``` + +--- + +## Zusammenfassung: + +- ✅ **Tabellenname** = Klassenname des Pydantic-Modells (z.B. `Projekt`) +- ✅ **Spalten** = Alle Felder aus dem Pydantic-Modell +- ✅ **Typen** = Automatisch gemappt (str→TEXT, List→JSONB, etc.) +- ✅ **Systemfelder** = Automatisch hinzugefügt (`_createdAt`, `_createdBy`, etc.) +- ✅ **Indexes** = Automatisch für Felder mit `*Id` Suffix +- ✅ **Migrationen** = Additive Migrationen (neue Spalten werden hinzugefügt) +- ⚠️ **Keine Constraints** = Foreign Keys, Unique, Check müssen manuell erstellt werden + +## Beispiel-Abfragen auf Real Estate-Datenmodell: + +```sql +-- Alle Parzellen in einer bestimmten Gemeinde +SELECT * FROM Parzelle WHERE plz = '8000' ORDER BY label; + +-- Projekte mit Status "Planung" +SELECT * FROM Projekt WHERE "statusProzess" = 'Planung'; + +-- Parzellen mit bestimmter Bauzone +SELECT label, az, bz, gebaeudehoeheMax FROM Parzelle WHERE bauzone = 'W3'; + +-- Dokumente eines Projekts +SELECT * FROM Dokument WHERE id IN ( + SELECT unnest(dokumente::jsonb->>'id') FROM Projekt WHERE id = '...' +); +``` + +--- + +[← Zurück: Feature Lifecycle](08-lifecycle.md) | [Weiter: Sicherheitshinweise →](10-security.md) + + + diff --git a/docs/real-estate-feature-integration-guide/10-security.md b/docs/real-estate-feature-integration-guide/10-security.md new file mode 100644 index 00000000..f5ab6b2e --- /dev/null +++ b/docs/real-estate-feature-integration-guide/10-security.md @@ -0,0 +1,90 @@ +# Sicherheitshinweise + +[← Zurück: Datenbank-Schema](09-database-schema.md) | [Weiter: Testing →](11-testing.md) + +## ⚠️ WICHTIG: Query-Validierung + +Die aktuelle Implementierung erlaubt die Ausführung von **rohen SQL-Queries**. Für Produktion sollten Sie: + +1. **Query-Whitelisting**: Nur erlaubte Queries zulassen +2. **Nur SELECT**: Nur SELECT-Statements erlauben (keine INSERT/UPDATE/DELETE) +3. **Parameterized Queries**: Immer Parameterized Queries verwenden +4. **Query-Parsing**: SQL-Parser verwenden zur Validierung +5. **Rate Limiting**: Strikte Rate Limits setzen (bereits implementiert) + +## Beispiel für Query-Validierung: + +```python +def validateQuery(queryText: str) -> bool: + """ + Validate that query is safe to execute. + Only allows SELECT statements on Real Estate data model tables. + """ + query_lower = queryText.strip().lower() + + # Only allow SELECT statements + if not query_lower.startswith('select'): + return False + + # Block dangerous keywords + dangerous_keywords = [ + 'drop', 'delete', 'insert', 'update', 'alter', 'create', + 'truncate', 'grant', 'revoke', 'exec', 'execute', 'call' + ] + for keyword in dangerous_keywords: + if keyword in query_lower: + return False + + # Only allow queries on Real Estate data model tables + allowed_tables = [ + 'projekt', 'parzelle', 'dokument', 'kontext', + 'geopolylinie', 'geopunkt', 'land', 'kanton', 'gemeinde' + ] + + # Check if query references allowed tables + # Simple check - in production, use SQL parser + query_contains_allowed_table = any( + f'from {table}' in query_lower or f'join {table}' in query_lower + for table in allowed_tables + ) + + if not query_contains_allowed_table: + # Allow queries that don't specify table explicitly (might be subqueries) + # But log for review + logger.warning(f"Query does not reference known Real Estate tables: {queryText[:100]}") + + return True +``` + +## Erweiterte Validierung mit SQL-Parser: + +Für Produktion sollten Sie einen SQL-Parser verwenden: + +```python +from sqlparse import parse, tokens + +def validateQueryAdvanced(queryText: str) -> bool: + """Advanced query validation using SQL parser.""" + try: + parsed = parse(queryText)[0] + + # Check statement type + if parsed.get_type() != 'SELECT': + return False + + # Extract table names and validate + # Implementation depends on SQL parser library + # ... + + return True + except Exception as e: + logger.error(f"Query parsing failed: {e}") + return False +``` + +--- + +[← Zurück: Datenbank-Schema](09-database-schema.md) | [Weiter: Testing →](11-testing.md) + + + diff --git a/docs/real-estate-feature-integration-guide/11-testing.md b/docs/real-estate-feature-integration-guide/11-testing.md new file mode 100644 index 00000000..2ec33c3a --- /dev/null +++ b/docs/real-estate-feature-integration-guide/11-testing.md @@ -0,0 +1,51 @@ +# Testing + +[← Zurück: Sicherheitshinweise](10-security.md) | [Weiter: Troubleshooting →](12-troubleshooting.md) + +## Manuelle API-Tests mit curl: + +```bash +# 1. Login (erhalten Sie Token) +curl -X POST "http://localhost:8000/api/local/auth/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=youruser&password=yourpass" + +# 2. Session erstellen +curl -X POST "http://localhost:8000/api/realestate/sessions" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title": "Parzellen-Analyse Zürich"}' + +# 3. Query ausführen - Beispiel: Alle Parzellen in Zürich +curl -X POST "http://localhost:8000/api/realestate/sessions/SESSION_ID/queries" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "queryText": "SELECT label, plz, bauzone, az, bz, gebaeudehoeheMax FROM Parzelle WHERE plz = ''8000'' ORDER BY label LIMIT 20", + "queryType": "sql" + }' + +# 4. Query ausführen - Beispiel: Projekte mit Status "Planung" +curl -X POST "http://localhost:8000/api/realestate/sessions/SESSION_ID/queries" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "queryText": "SELECT id, label, \"statusProzess\" FROM Projekt WHERE \"statusProzess\" = ''Planung''", + "queryType": "sql" + }' + +# 5. Queries abrufen +curl -X GET "http://localhost:8000/api/realestate/sessions/SESSION_ID/queries" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## Swagger UI: + +Nach dem Start der Anwendung können Sie die API unter `http://localhost:8000/docs` testen. + +--- + +[← Zurück: Sicherheitshinweise](10-security.md) | [Weiter: Troubleshooting →](12-troubleshooting.md) + + + diff --git a/docs/real-estate-feature-integration-guide/12-troubleshooting.md b/docs/real-estate-feature-integration-guide/12-troubleshooting.md new file mode 100644 index 00000000..9c7b2475 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/12-troubleshooting.md @@ -0,0 +1,33 @@ +# Troubleshooting + +[← Zurück: Testing](11-testing.md) | [Weiter: Zusammenfassung →](13-summary.md) + +## Problem: Datenbankverbindung schlägt fehl + +**Lösung**: Überprüfen Sie die Environment-Variablen in `env_dev.env`: +- `DB_APP_HOST` +- `DB_APP_DATABASE` +- `DB_APP_USER` +- `DB_APP_PASSWORD_SECRET` +- `DB_APP_PORT` + +## Problem: Tabellen werden nicht erstellt + +**Lösung**: Der Connector erstellt Tabellen beim ersten Zugriff. Stellen Sie sicher, dass: +- Die Datenbank existiert +- Der Benutzer CREATE-Rechte hat +- Die Verbindung erfolgreich ist + +## Problem: Access Denied Fehler + +**Lösung**: Überprüfen Sie: +- User hat gültiges `mandateId` +- User hat entsprechende Privilegien +- Access Control Logik im Interface + +--- + +[← Zurück: Testing](11-testing.md) | [Weiter: Zusammenfassung →](13-summary.md) + + + diff --git a/docs/real-estate-feature-integration-guide/13-summary.md b/docs/real-estate-feature-integration-guide/13-summary.md new file mode 100644 index 00000000..240af342 --- /dev/null +++ b/docs/real-estate-feature-integration-guide/13-summary.md @@ -0,0 +1,136 @@ +# Zusammenfassung + +[← Zurück: Troubleshooting](12-troubleshooting.md) | [← Zurück zur Übersicht](README.md) + +## Dateinamen-Konvention: + +**Wichtig:** Die Dateien sind nach Funktionalität benannt: + +| Datei | Zweck | Enthält | +|-------|-------|---------| +| `datamodelRealEstateChat.py` | Chat-Interface Modelle | `RealEstateQuery`, `RealEstateQueryResult`, `RealEstateChatSession` | +| `datamodelRealEstate.py` | Real Estate-Datenmodelle | `Projekt`, `Parzelle`, `Dokument`, etc. (allgemein verwendbar) | +| `interfaceDbRealEstateChatObjects.py` | Chat-Interface Interface | Methoden für Sessions und Queries | +| `interfaceDbRealEstateObjects.py` | Real Estate CRUD Interface | Methoden für Projekt, Parzelle, etc. (optional) | + +**Hinweis:** Das Modell ist allgemein für alle Real Estate-Firmen verwendbar. PEK ist nur ein Beispiel. + +--- + +## Zu erstellende Dateien: + +1. **`modules/datamodels/datamodelRealEstateChat.py`** (Chat-Interface Modelle) + - Pydantic-Modelle: `RealEstateQuery`, `RealEstateQueryResult`, `RealEstateChatSession` + - Enums: `QueryStatusEnum` + +2. **`modules/datamodels/datamodelRealEstate.py`** (Real Estate-Datenmodell) + - Pydantic-Modelle: `Projekt`, `Parzelle`, `Dokument`, `Kontext`, `GeoPolylinie`, `GeoPunkt`, `Land`, `Kanton`, `Gemeinde` + - Enums: `StatusProzess`, `DokumentTyp`, `JaNein`, `GeoTag` + - Siehe `../PEK_datamodel_desc.md` für vollständige Spezifikation (PEK ist ein Beispiel, das Modell ist allgemein verwendbar) + +3. **`modules/interfaces/interfaceDbRealEstateChatObjects.py`** (Chat-Interface) + - `RealEstateChatObjects` Klasse für Datenbankzugriff (Chat-Sessions, Queries) + - `RealEstateChatAccess` Klasse für Zugriffskontrolle + - `getInterface()` Factory-Funktion + +4. **`modules/interfaces/interfaceDbRealEstateObjects.py`** (NEU - für Real Estate-Datenmodell CRUD) + - `RealEstateObjects` Klasse für CRUD-Operationen auf Real Estate-Entitäten (Projekt, Parzelle, etc.) + - `RealEstateAccess` Klasse für Zugriffskontrolle + - Methoden für Projekt, Parzelle, Dokument, etc. + - **Hinweis:** Diese Datei ist für CRUD-Operationen auf die Real Estate-Entitäten. Das Chat-Interface nutzt `interfaceDbRealEstateChatObjects.py` (siehe Punkt 3). + - **Optional:** Falls Sie eine separate CRUD-API benötigen (das Chat-Interface kann auch direkt SQL-Queries verwenden) + +5. **`modules/features/realEstate/mainRealEstate.py`** + - Feature-Logik-Funktionen: `createSession`, `executeDatabaseQuery`, etc. + +6. **`modules/routes/routeRealEstate.py`** + - FastAPI Router mit allen Endpunkten für Chat-Interface + +7. **`modules/routes/routeRealEstateData.py`** (NEU - für Real Estate-Datenmodell) + - FastAPI Router für CRUD-Operationen auf Real Estate-Entitäten + - Endpunkte für Projekt, Parzelle, Dokument, etc. + - **Optional:** Falls Sie eine separate CRUD-API benötigen (das Chat-Interface kann auch direkt SQL-Queries verwenden) + +## Zu modifizierende Dateien: + +1. **`app.py`** + - Router-Registrierung für `routeRealEstate` hinzufügen (Chat-Interface) + - Router-Registrierung für `routeRealEstateData` hinzufügen (falls CRUD-API gewünscht) + +2. **`env_dev.env`** (optional) + - Separate Datenbank-Konfiguration falls gewünscht + - PostGIS-Konfiguration falls geografische Abfragen benötigt werden + +3. **`modules/features/featuresLifecycle.py`** (optional) + - Feature-Initialisierung falls benötigt + - Initialisierung von Standard-Daten (z.B. Land "Schweiz", Kantone, Gemeinden) + +## Datenmodell-Implementierung: + +**Wichtig:** Bevor Sie das Chat-Interface nutzen können, müssen Sie die Real Estate-Datenmodell-Entitäten implementieren: + +1. **Erstellen Sie `modules/datamodels/datamodelRealEstate.py`** mit allen Entitäten aus `../PEK_datamodel_desc.md` + - **Hinweis:** PEK ist ein Beispiel für eine Real Estate-Firma, aber das Modell ist allgemein verwendbar für alle Real Estate-Firmen +2. **Beachten Sie die Objektbeziehungen**: + - `parzellen: list[Parzelle]` wird als JSONB gespeichert + - `kontextKanton: Kanton` wird als String-ID gespeichert (Foreign Key) +3. **Implementieren Sie die Enums** entsprechend der Spezifikation +4. **Testen Sie die Tabellenerstellung** durch den DatabaseConnector + +--- + +## Nächste Schritte + +1. **Real Estate-Datenmodell-Implementierung**: + - Erstellen Sie die Pydantic-Modelle für alle Real Estate-Entitäten (`Projekt`, `Parzelle`, `Dokument`, `Kontext`, `GeoPolylinie`, `GeoPunkt`, `Land`, `Kanton`, `Gemeinde`) + - Implementieren Sie die Enums (`StatusProzess`, `DokumentTyp`, `JaNein`, `GeoTag`) + - Siehe `../PEK_datamodel_desc.md` für vollständige Spezifikation (PEK ist ein Beispiel, das Modell ist allgemein verwendbar) + +2. **Query-Validierung implementieren**: Siehe [Sicherheitshinweise](10-security.md) + - Besonders wichtig für Real Estate-Datenmodell: Nur SELECT-Statements erlauben + - Whitelist für erlaubte Tabellen (Projekt, Parzelle, etc.) + +3. **Natural Language Processing**: + - Implementieren Sie NLP für `queryType="natural"` + - Beispiele: "Zeige mir alle Parzellen in Zürich" → SQL-Query + - Nutzen Sie AI-Modelle zur SQL-Generierung aus natürlicher Sprache + +4. **Geografische Abfragen**: + - PostGIS-Integration für räumliche Abfragen + - Beispiel: "Zeige alle Parzellen innerhalb eines bestimmten Perimeters" + - Nutzung von GeoPolylinie und GeoPunkt für GIS-Funktionen + +5. **Query-History**: Erweiterte Historie-Funktionen + - Speichern häufig verwendeter Queries + - Query-Templates für häufige Abfragen (z.B. "Parzellen nach Bauzone") + +6. **Export-Funktionen**: CSV/Excel-Export von Ergebnissen + - Export von Parzellen-Listen + - Export von Projekt-Übersichten + +7. **Caching**: Query-Ergebnisse cachen für wiederholte Abfragen + - Besonders für administrative Daten (Land, Kanton, Gemeinde) + +8. **Permissions**: Erweiterte Berechtigungen für bestimmte Tabellen + - Mandaten-basierte Filterung für Projekte und Parzellen + - Rollen-basierte Zugriffe (z.B. nur Leserechte für bestimmte Benutzer) + +--- + +## Architektur-Zusammenfassung + +Dieses Feature folgt dem etablierten Muster des Projekts: +- **Separation of Concerns**: Routes → Features → Interfaces → Connectors +- **Dependency Injection**: Interfaces werden über Factory-Funktionen erstellt +- **Access Control**: Mandaten- und Benutzer-basierte Filterung +- **Type Safety**: Pydantic-Modelle für Validierung +- **Async Support**: Asynchrone Verarbeitung für Skalierbarkeit + +Die Implementierung ist modular und erweiterbar. Sie können weitere Funktionen hinzufügen, ohne die bestehende Struktur zu ändern. + +--- + +[← Zurück: Troubleshooting](12-troubleshooting.md) | [← Zurück zur Übersicht](README.md) + + + diff --git a/docs/real-estate-feature-integration-guide/README.md b/docs/real-estate-feature-integration-guide/README.md new file mode 100644 index 00000000..95b0346d --- /dev/null +++ b/docs/real-estate-feature-integration-guide/README.md @@ -0,0 +1,42 @@ +# Feature Integration Guide: realEstate + +Diese Dokumentation erklärt Schritt für Schritt, wie Sie ein neues Feature "realEstate" in das Gateway-Projekt integrieren. Das Feature ermöglicht es, über ein Chat-Interface Datenbankabfragen auf Real Estate-Daten (Architektur-Planungs-App) durchzuführen. + +**Referenz:** Das zugrundeliegende Datenmodell ist in `../PEK_datamodel_desc.md` beschrieben (PEK ist ein Beispiel für eine Real Estate-Firma, das Modell ist aber allgemein verwendbar). + +## Inhaltsverzeichnis + +1. [Überblick und Projektstruktur](01-overview.md) +2. [Schritt 1: Datenmodell erstellen](02-datamodels.md) +3. [Schritt 2: Interface erstellen](03-interfaces.md) +4. [Schritt 3: Feature-Logik implementieren](04-feature-logic.md) +5. [Schritt 4: Routen erstellen](05-routes.md) +6. [Schritt 5: Router registrieren](06-router-registration.md) +7. [Schritt 6: Environment-Konfiguration](07-environment.md) +8. [Schritt 7: Feature Lifecycle (optional)](08-lifecycle.md) +9. [Datenbank-Schema und Tabellenerstellung](09-database-schema.md) +10. [Sicherheitshinweise](10-security.md) +11. [Testing](11-testing.md) +12. [Troubleshooting](12-troubleshooting.md) +13. [Zusammenfassung](13-summary.md) + +--- + +## Schnellstart + +Für eine schnelle Übersicht über alle zu erstellenden Dateien, siehe [Zusammenfassung](13-summary.md). + +## Architektur-Überblick + +Die Architektur folgt dem Muster bestehender Features wie `chatPlayground`: +- **Routes** (`modules/routes/`) - API-Endpunkte +- **Features** (`modules/features/`) - Geschäftslogik +- **Interfaces** (`modules/interfaces/`) - Datenbankzugriff +- **DataModels** (`modules/datamodels/`) - Pydantic-Modelle + +--- + +**Nächster Schritt:** [01-overview.md](01-overview.md) + + + diff --git a/env_dev.env b/env_dev.env index da72e528..df0de269 100644 --- a/env_dev.env +++ b/env_dev.env @@ -29,6 +29,13 @@ DB_MANAGEMENT_USER=poweron_dev DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9 DB_MANAGEMENT_PORT=5432 +# PostgreSQL Storage (new) +DB_REALESTATE_HOST=localhost +DB_REALESTATE_DATABASE=poweron_realestate +DB_REALESTATE_USER=poweron_dev +DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9 +DB_REALESTATE_PORT=5432 + # Security Configuration APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ== APP_TOKEN_EXPIRY=300 diff --git a/logs/debug/prompts/20251119-100038-001-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-100038-001-intentanalysis_prompt.txt new file mode 100644 index 00000000..7f329236 --- /dev/null +++ b/logs/debug/prompts/20251119-100038-001-intentanalysis_prompt.txt @@ -0,0 +1,48 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" + +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: +- Projekt: Real estate project +- Parzelle: Plot/parcel +- Dokument: Document +- Kanton: Canton +- Gemeinde: Municipality + +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 (label, statusProzess, etc.) + // For READ: include filter criteria (id, label, plz, etc.) + // For DELETE: include entity ID if mentioned + // For QUERY: include queryText if SQL is detected + }, + "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: "Aktualisiere Projekt XYZ mit Status 'Planung'" + Output: {"intent": "UPDATE", "entity": "Projekt", "parameters": {"id": "XYZ", "statusProzess": "Planung"}, "confidence": 0.85} + +- Input: "SELECT * FROM Projekt WHERE plz = '8000'" + Output: {"intent": "QUERY", "entity": null, "parameters": {"queryText": "SELECT * FROM Projekt WHERE plz = '8000'", "queryType": "sql"}, "confidence": 1.0} + +- Input: "Lösche Parzelle ABC" + Output: {"intent": "DELETE", "entity": "Parzelle", "parameters": {"id": "ABC"}, "confidence": 0.9} diff --git a/logs/debug/prompts/20251119-100041-002-intentanalysis_response.txt b/logs/debug/prompts/20251119-100041-002-intentanalysis_response.txt new file mode 100644 index 00000000..202b02aa --- /dev/null +++ b/logs/debug/prompts/20251119-100041-002-intentanalysis_response.txt @@ -0,0 +1,10 @@ +```json +{ + "intent": "CREATE", + "entity": "Projekt", + "parameters": { + "label": "Hauptstrasse 42" + }, + "confidence": 0.95 +} +``` \ No newline at end of file diff --git a/logs/debug/prompts/20251119-103736-003-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-103736-003-intentanalysis_prompt.txt new file mode 100644 index 00000000..69cfe85c --- /dev/null +++ b/logs/debug/prompts/20251119-103736-003-intentanalysis_prompt.txt @@ -0,0 +1,48 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Zeige mir alle Projekte in Zürich." + +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: +- Projekt: Real estate project +- Parzelle: Plot/parcel +- Dokument: Document +- Kanton: Canton +- Gemeinde: Municipality + +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 (label, statusProzess, etc.) + // For READ: include filter criteria (id, label, plz, etc.) + // For DELETE: include entity ID if mentioned + // For QUERY: include queryText if SQL is detected + }, + "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: "Aktualisiere Projekt XYZ mit Status 'Planung'" + Output: {"intent": "UPDATE", "entity": "Projekt", "parameters": {"id": "XYZ", "statusProzess": "Planung"}, "confidence": 0.85} + +- Input: "SELECT * FROM Projekt WHERE plz = '8000'" + Output: {"intent": "QUERY", "entity": null, "parameters": {"queryText": "SELECT * FROM Projekt WHERE plz = '8000'", "queryType": "sql"}, "confidence": 1.0} + +- Input: "Lösche Parzelle ABC" + Output: {"intent": "DELETE", "entity": "Parzelle", "parameters": {"id": "ABC"}, "confidence": 0.9} diff --git a/logs/debug/prompts/20251119-103742-004-intentanalysis_response.txt b/logs/debug/prompts/20251119-103742-004-intentanalysis_response.txt new file mode 100644 index 00000000..12bd99bc --- /dev/null +++ b/logs/debug/prompts/20251119-103742-004-intentanalysis_response.txt @@ -0,0 +1,16 @@ +```json +{ + "intent": "READ", + "entity": "Projekt", + "parameters": { + "location": "Zürich" + }, + "confidence": 0.95 +} +``` + +**Analysis:** +- **Intent**: READ - The command "Zeige mir" (Show me) clearly indicates a query/read operation +- **Entity**: Projekt - The user explicitly asks for "Projekte" (projects) +- **Parameters**: The location filter "in Zürich" is extracted as a search criterion. This could refer to a municipality (Gemeinde) named Zürich or projects located in the Zürich area +- **Confidence**: 0.95 - High confidence as the intent and entity are clearly stated, with an explicit location filter \ No newline at end of file diff --git a/logs/debug/prompts/20251119-103802-005-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-103802-005-intentanalysis_prompt.txt new file mode 100644 index 00000000..69cfe85c --- /dev/null +++ b/logs/debug/prompts/20251119-103802-005-intentanalysis_prompt.txt @@ -0,0 +1,48 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Zeige mir alle Projekte in Zürich." + +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: +- Projekt: Real estate project +- Parzelle: Plot/parcel +- Dokument: Document +- Kanton: Canton +- Gemeinde: Municipality + +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 (label, statusProzess, etc.) + // For READ: include filter criteria (id, label, plz, etc.) + // For DELETE: include entity ID if mentioned + // For QUERY: include queryText if SQL is detected + }, + "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: "Aktualisiere Projekt XYZ mit Status 'Planung'" + Output: {"intent": "UPDATE", "entity": "Projekt", "parameters": {"id": "XYZ", "statusProzess": "Planung"}, "confidence": 0.85} + +- Input: "SELECT * FROM Projekt WHERE plz = '8000'" + Output: {"intent": "QUERY", "entity": null, "parameters": {"queryText": "SELECT * FROM Projekt WHERE plz = '8000'", "queryType": "sql"}, "confidence": 1.0} + +- Input: "Lösche Parzelle ABC" + Output: {"intent": "DELETE", "entity": "Parzelle", "parameters": {"id": "ABC"}, "confidence": 0.9} diff --git a/logs/debug/prompts/20251119-103808-006-intentanalysis_response.txt b/logs/debug/prompts/20251119-103808-006-intentanalysis_response.txt new file mode 100644 index 00000000..62d89d2a --- /dev/null +++ b/logs/debug/prompts/20251119-103808-006-intentanalysis_response.txt @@ -0,0 +1,16 @@ +```json +{ + "intent": "READ", + "entity": "Projekt", + "parameters": { + "location": "Zürich" + }, + "confidence": 0.95 +} +``` + +**Analysis:** +- **Intent**: READ - The command "Zeige mir" (Show me) clearly indicates a query/read operation +- **Entity**: Projekt - The user explicitly asks for "Projekte" (projects) +- **Parameters**: The location filter "in Zürich" is extracted as a search criterion. This could refer to a municipality (Gemeinde) named Zürich or projects located in the Zürich area +- **Confidence**: 0.95 - High confidence as the intent and entity are clearly stated in the command \ No newline at end of file diff --git a/logs/debug/prompts/20251119-104317-007-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-104317-007-intentanalysis_prompt.txt new file mode 100644 index 00000000..bb7b48bc --- /dev/null +++ b/logs/debug/prompts/20251119-104317-007-intentanalysis_prompt.txt @@ -0,0 +1,88 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Zeige mir alle Projekte in Zürich." + +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) +- kontextKanton: string (canton ID, Foreign Key) +- kontextLand: string (country ID, Foreign Key) +- 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) +- Location queries (city, postal code) should use Parzelle fields (plz, kontextGemeinde), NOT Projekt fields +- 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} diff --git a/logs/debug/prompts/20251119-104324-008-intentanalysis_response.txt b/logs/debug/prompts/20251119-104324-008-intentanalysis_response.txt new file mode 100644 index 00000000..037f72d0 --- /dev/null +++ b/logs/debug/prompts/20251119-104324-008-intentanalysis_response.txt @@ -0,0 +1,18 @@ +```json +{ + "intent": "READ", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.9 +} +``` + +**Reasoning:** +- The user wants to see/display ("Zeige mir") projects, which indicates a READ intent +- The location filter "in Zürich" refers to a city/municipality +- According to the schema, **Projekt does NOT have location fields directly** - location information is stored in the associated **Parzelle** entity through the `kontextGemeinde` field +- Therefore, to find projects in Zürich, we need to query **Parzelle** with the filter `kontextGemeinde: "Zürich"` +- The system can then retrieve the associated projects through the relationship (Projekte contain Parzellen) +- Confidence is 0.9 because the intent is clear, though the user said "Projekte" while we're technically querying "Parzelle" first to filter by location \ No newline at end of file diff --git a/logs/debug/prompts/20251119-104747-009-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-104747-009-intentanalysis_prompt.txt new file mode 100644 index 00000000..bb7b48bc --- /dev/null +++ b/logs/debug/prompts/20251119-104747-009-intentanalysis_prompt.txt @@ -0,0 +1,88 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Zeige mir alle Projekte in Zürich." + +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) +- kontextKanton: string (canton ID, Foreign Key) +- kontextLand: string (country ID, Foreign Key) +- 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) +- Location queries (city, postal code) should use Parzelle fields (plz, kontextGemeinde), NOT Projekt fields +- 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} diff --git a/logs/debug/prompts/20251119-104754-010-intentanalysis_response.txt b/logs/debug/prompts/20251119-104754-010-intentanalysis_response.txt new file mode 100644 index 00000000..9df10c17 --- /dev/null +++ b/logs/debug/prompts/20251119-104754-010-intentanalysis_response.txt @@ -0,0 +1,17 @@ +```json +{ + "intent": "READ", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.9 +} +``` + +**Reasoning:** +- The user wants to see/display ("Zeige mir") projects, which indicates a READ intent +- The location filter "in Zürich" refers to a city/municipality +- According to the schema, **Projekt does NOT have location fields directly** - location information is stored in the associated **Parzelle** entity through the `kontextGemeinde` field +- Therefore, to find projects in Zürich, we need to query **Parzelle** entities with `kontextGemeinde: "Zürich"`, which will give us the plots (and their associated projects) in that municipality +- The confidence is 0.9 because the intent and location are clear, though the user said "Projekte" but we need to query via "Parzelle" due to the data model structure \ No newline at end of file diff --git a/logs/debug/prompts/20251119-104856-011-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-104856-011-intentanalysis_prompt.txt new file mode 100644 index 00000000..bbfb0711 --- /dev/null +++ b/logs/debug/prompts/20251119-104856-011-intentanalysis_prompt.txt @@ -0,0 +1,88 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Erstell eine neue Parzelle in Zürich." + +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) +- kontextKanton: string (canton ID, Foreign Key) +- kontextLand: string (country ID, Foreign Key) +- 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) +- Location queries (city, postal code) should use Parzelle fields (plz, kontextGemeinde), NOT Projekt fields +- 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} diff --git a/logs/debug/prompts/20251119-104902-012-intentanalysis_response.txt b/logs/debug/prompts/20251119-104902-012-intentanalysis_response.txt new file mode 100644 index 00000000..f18102ca --- /dev/null +++ b/logs/debug/prompts/20251119-104902-012-intentanalysis_response.txt @@ -0,0 +1,17 @@ +```json +{ + "intent": "CREATE", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.9 +} +``` + +**Analysis:** +- **Intent**: CREATE - The user explicitly wants to create ("Erstell") a new entity +- **Entity**: Parzelle - The user specifies "eine neue Parzelle" (a new plot/parcel) +- **Parameters**: + - `kontextGemeinde`: "Zürich" - The location (municipality) where the plot should be created +- **Confidence**: 0.9 - High confidence as the intent and entity are clearly stated, though only minimal information (location) is provided for the creation \ No newline at end of file diff --git a/logs/debug/prompts/20251119-105033-013-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-105033-013-intentanalysis_prompt.txt new file mode 100644 index 00000000..f5c8b8af --- /dev/null +++ b/logs/debug/prompts/20251119-105033-013-intentanalysis_prompt.txt @@ -0,0 +1,88 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Zeige mir alle Parzellen in Zürich." + +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) +- kontextKanton: string (canton ID, Foreign Key) +- kontextLand: string (country ID, Foreign Key) +- 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) +- Location queries (city, postal code) should use Parzelle fields (plz, kontextGemeinde), NOT Projekt fields +- 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} diff --git a/logs/debug/prompts/20251119-105039-014-intentanalysis_response.txt b/logs/debug/prompts/20251119-105039-014-intentanalysis_response.txt new file mode 100644 index 00000000..0e156b67 --- /dev/null +++ b/logs/debug/prompts/20251119-105039-014-intentanalysis_response.txt @@ -0,0 +1,17 @@ +```json +{ + "intent": "READ", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.95 +} +``` + +**Reasoning:** +- The user command "Zeige mir alle Parzellen in Zürich" translates to "Show me all plots in Zurich" +- **Intent**: READ - The user wants to query/retrieve existing entities +- **Entity**: Parzelle - The user explicitly asks for "Parzellen" (plots/parcels) +- **Parameters**: The location filter "in Zürich" maps to the `kontextGemeinde` field of the Parzelle entity, which stores the municipality information +- **Confidence**: 0.95 - High confidence because the intent is clear (show/display), the entity is explicitly mentioned (Parzellen), and the location parameter (Zürich) clearly maps to the kontextGemeinde field \ No newline at end of file diff --git a/logs/debug/prompts/20251119-110433-015-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-110433-015-intentanalysis_prompt.txt new file mode 100644 index 00000000..6c53a768 --- /dev/null +++ b/logs/debug/prompts/20251119-110433-015-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Zeige mir alle Parzellen in Zürich." + +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} diff --git a/logs/debug/prompts/20251119-110440-016-intentanalysis_response.txt b/logs/debug/prompts/20251119-110440-016-intentanalysis_response.txt new file mode 100644 index 00000000..331d501b --- /dev/null +++ b/logs/debug/prompts/20251119-110440-016-intentanalysis_response.txt @@ -0,0 +1,17 @@ +```json +{ + "intent": "READ", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.95 +} +``` + +**Explanation:** +- **Intent**: READ - The user wants to query/view existing data ("Zeige mir" = "Show me") +- **Entity**: Parzelle - The user explicitly asks for "Parzellen" (plots/parcels) +- **Parameters**: + - `kontextGemeinde`: "Zürich" - The location filter is specified as Zürich, which is a municipality (Gemeinde). According to the schema, location information for Parzellen is stored in the `kontextGemeinde` field +- **Confidence**: 0.95 - High confidence as the command is clear and unambiguous, directly requesting plots in a specific location \ No newline at end of file diff --git a/logs/debug/prompts/20251119-125234-017-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-125234-017-intentanalysis_prompt.txt new file mode 100644 index 00000000..41ca3352 --- /dev/null +++ b/logs/debug/prompts/20251119-125234-017-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Füge die Gemeinde Zürich in das System hinzu." + +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} diff --git a/logs/debug/prompts/20251119-125239-018-intentanalysis_response.txt b/logs/debug/prompts/20251119-125239-018-intentanalysis_response.txt new file mode 100644 index 00000000..e278914c --- /dev/null +++ b/logs/debug/prompts/20251119-125239-018-intentanalysis_response.txt @@ -0,0 +1,16 @@ +```json +{ + "intent": "CREATE", + "entity": "Gemeinde", + "parameters": { + "label": "Zürich" + }, + "confidence": 0.95 +} +``` + +**Analysis:** +- **Intent**: CREATE - The verb "Füge...hinzu" (add) clearly indicates the user wants to create a new entity +- **Entity**: Gemeinde - The user explicitly mentions "die Gemeinde Zürich" (the municipality Zürich) +- **Parameters**: The municipality name "Zürich" is extracted as the label/identifier +- **Confidence**: 0.95 - High confidence due to clear intent and explicit entity mention \ No newline at end of file diff --git a/logs/debug/prompts/20251119-125657-019-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-125657-019-intentanalysis_prompt.txt new file mode 100644 index 00000000..41ca3352 --- /dev/null +++ b/logs/debug/prompts/20251119-125657-019-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Füge die Gemeinde Zürich in das System hinzu." + +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} diff --git a/logs/debug/prompts/20251119-125702-020-intentanalysis_response.txt b/logs/debug/prompts/20251119-125702-020-intentanalysis_response.txt new file mode 100644 index 00000000..069e404e --- /dev/null +++ b/logs/debug/prompts/20251119-125702-020-intentanalysis_response.txt @@ -0,0 +1,16 @@ +```json +{ + "intent": "CREATE", + "entity": "Gemeinde", + "parameters": { + "label": "Zürich" + }, + "confidence": 0.95 +} +``` + +**Analysis:** +- **Intent**: CREATE - The user explicitly wants to add ("Füge...hinzu") a new entity to the system +- **Entity**: Gemeinde - The user specifically mentions "die Gemeinde Zürich" (the municipality Zürich) +- **Parameters**: The municipality name "Zürich" is extracted as the label/identifier +- **Confidence**: 0.95 - High confidence as the command is clear and unambiguous with explicit CREATE intent and entity type \ No newline at end of file diff --git a/logs/debug/prompts/20251119-125750-021-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-125750-021-intentanalysis_prompt.txt new file mode 100644 index 00000000..853df98b --- /dev/null +++ b/logs/debug/prompts/20251119-125750-021-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Füge ein neues Projekt mit Parzelle in Zürich hinzu." + +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} diff --git a/logs/debug/prompts/20251119-125756-022-intentanalysis_response.txt b/logs/debug/prompts/20251119-125756-022-intentanalysis_response.txt new file mode 100644 index 00000000..85c1db10 --- /dev/null +++ b/logs/debug/prompts/20251119-125756-022-intentanalysis_response.txt @@ -0,0 +1,20 @@ +```json +{ + "intent": "CREATE", + "entity": "Projekt", + "parameters": { + "parzellen": [ + { + "kontextGemeinde": "Zürich" + } + ] + }, + "confidence": 0.85 +} +``` + +**Reasoning:** +- The user wants to create ("Füge hinzu") a new project ("neues Projekt") +- The project should include a plot ("mit Parzelle") in Zürich +- Since Projekt contains Parzellen as a nested list, and the location information (Zürich) belongs to Parzelle (via kontextGemeinde field), I've structured the parameters to reflect this relationship +- The confidence is 0.85 because while the intent is clear, the user hasn't provided other typical project details like a label/name for the project itself \ No newline at end of file diff --git a/logs/debug/prompts/20251119-141840-023-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-141840-023-intentanalysis_prompt.txt new file mode 100644 index 00000000..69983369 --- /dev/null +++ b/logs/debug/prompts/20251119-141840-023-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Füge die Gemeinde Winterthur hinzu" + +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} diff --git a/logs/debug/prompts/20251119-141848-024-intentanalysis_response.txt b/logs/debug/prompts/20251119-141848-024-intentanalysis_response.txt new file mode 100644 index 00000000..5b3aa3d5 --- /dev/null +++ b/logs/debug/prompts/20251119-141848-024-intentanalysis_response.txt @@ -0,0 +1,16 @@ +```json +{ + "intent": "CREATE", + "entity": "Gemeinde", + "parameters": { + "label": "Winterthur" + }, + "confidence": 0.95 +} +``` + +**Analysis:** +- **Intent**: CREATE - The verb "Füge...hinzu" (add) clearly indicates the user wants to create a new entity +- **Entity**: Gemeinde - The user explicitly mentions "die Gemeinde Winterthur" (the municipality Winterthur) +- **Parameters**: The name/label of the municipality is "Winterthur" +- **Confidence**: 0.95 - High confidence as the command is clear and unambiguous \ No newline at end of file diff --git a/logs/debug/prompts/20251119-144901-025-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-144901-025-intentanalysis_prompt.txt new file mode 100644 index 00000000..b13d0efe --- /dev/null +++ b/logs/debug/prompts/20251119-144901-025-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Wie viele Projekte in Zürich gibt es?" + +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} diff --git a/logs/debug/prompts/20251119-144909-026-intentanalysis_response.txt b/logs/debug/prompts/20251119-144909-026-intentanalysis_response.txt new file mode 100644 index 00000000..59197959 --- /dev/null +++ b/logs/debug/prompts/20251119-144909-026-intentanalysis_response.txt @@ -0,0 +1,17 @@ +```json +{ + "intent": "READ", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.9 +} +``` + +**Reasoning:** +- The user asks "How many projects in Zürich are there?" which is a READ/query operation +- Since location information (city) is stored in Parzelle via `kontextGemeinde`, not directly in Projekt, we need to query Parzelle entities +- The parameter `kontextGemeinde` is set to "Zürich" to filter plots by municipality +- To get projects in Zürich, the system will need to find all Parzellen with kontextGemeinde="Zürich" and then identify the associated projects +- Confidence is 0.9 because the intent is clear, though the user asks about "projects" while we're querying "Parzelle" (which is the correct approach given the data model) \ No newline at end of file diff --git a/logs/debug/prompts/20251119-145121-027-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-145121-027-intentanalysis_prompt.txt new file mode 100644 index 00000000..6f20254d --- /dev/null +++ b/logs/debug/prompts/20251119-145121-027-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "welche projekte in Zürich gibt es?" + +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} diff --git a/logs/debug/prompts/20251119-145129-028-intentanalysis_response.txt b/logs/debug/prompts/20251119-145129-028-intentanalysis_response.txt new file mode 100644 index 00000000..b6c6eaae --- /dev/null +++ b/logs/debug/prompts/20251119-145129-028-intentanalysis_response.txt @@ -0,0 +1,17 @@ +```json +{ + "intent": "READ", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.9 +} +``` + +**Reasoning:** +- The user is asking "which projects in Zürich exist?" which is a READ/query operation +- Since location information (city/municipality) is stored in Parzelle (via `kontextGemeinde`), not directly in Projekt, we need to query Parzelle entities +- "Zürich" is the municipality name that maps to the `kontextGemeinde` field +- The system will need to find all Parzellen in Zürich and then retrieve their associated Projekte +- High confidence (0.9) as the intent and location are clearly stated \ No newline at end of file diff --git a/logs/debug/prompts/20251119-145151-029-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-145151-029-intentanalysis_prompt.txt new file mode 100644 index 00000000..c2b228d9 --- /dev/null +++ b/logs/debug/prompts/20251119-145151-029-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Welche Projekte in Zürich gibt es?" + +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} diff --git a/logs/debug/prompts/20251119-145202-030-intentanalysis_response.txt b/logs/debug/prompts/20251119-145202-030-intentanalysis_response.txt new file mode 100644 index 00000000..4cc5d8b0 --- /dev/null +++ b/logs/debug/prompts/20251119-145202-030-intentanalysis_response.txt @@ -0,0 +1,18 @@ +```json +{ + "intent": "READ", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich" + }, + "confidence": 0.9 +} +``` + +**Reasoning:** +- The user is asking "Which projects in Zürich exist?" (Welche Projekte in Zürich gibt es?) +- This is a READ operation (querying for existing data) +- Since location information (city/municipality) is stored in **Parzelle** via the `kontextGemeinde` field, not directly in Projekt, we need to query Parzelle entities +- The parameter `kontextGemeinde` is set to "Zürich" to filter plots by municipality +- To get projects in Zürich, the system will need to find Parzellen with kontextGemeinde="Zürich" and then retrieve their associated Projekte +- Confidence is 0.9 because the intent is clear, though the user asked about "Projekte" while we're technically querying via "Parzelle" due to the data model structure \ No newline at end of file diff --git a/logs/debug/prompts/20251119-145222-031-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-145222-031-intentanalysis_prompt.txt new file mode 100644 index 00000000..d67478d1 --- /dev/null +++ b/logs/debug/prompts/20251119-145222-031-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Welche Gemeinden haben wir bereits hinzugefügt?" + +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} diff --git a/logs/debug/prompts/20251119-145229-032-intentanalysis_response.txt b/logs/debug/prompts/20251119-145229-032-intentanalysis_response.txt new file mode 100644 index 00000000..4e0657b7 --- /dev/null +++ b/logs/debug/prompts/20251119-145229-032-intentanalysis_response.txt @@ -0,0 +1,14 @@ +```json +{ + "intent": "READ", + "entity": "Gemeinde", + "parameters": {}, + "confidence": 0.95 +} +``` + +**Reasoning:** +- The user is asking "Which municipalities have we already added?" (Welche Gemeinden haben wir bereits hinzugefügt?) +- This is clearly a READ operation to query existing Gemeinde entities +- No specific filters are mentioned, so the user wants to see all Gemeinde records +- The confidence is high (0.95) because the intent and entity are explicitly stated in the question \ No newline at end of file diff --git a/logs/debug/prompts/20251119-145449-033-intentanalysis_prompt.txt b/logs/debug/prompts/20251119-145449-033-intentanalysis_prompt.txt new file mode 100644 index 00000000..26aa68b0 --- /dev/null +++ b/logs/debug/prompts/20251119-145449-033-intentanalysis_prompt.txt @@ -0,0 +1,89 @@ + +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "Füge eine neue Parzelle hinzu die in Zürich liegt und die Adresse Ueberlandstrasse 11 8050 hat." + +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} diff --git a/logs/debug/prompts/20251119-145452-034-intentanalysis_response.txt b/logs/debug/prompts/20251119-145452-034-intentanalysis_response.txt new file mode 100644 index 00000000..379e6909 --- /dev/null +++ b/logs/debug/prompts/20251119-145452-034-intentanalysis_response.txt @@ -0,0 +1,12 @@ +```json +{ + "intent": "CREATE", + "entity": "Parzelle", + "parameters": { + "kontextGemeinde": "Zürich", + "strasseNr": "Ueberlandstrasse 11", + "plz": "8050" + }, + "confidence": 0.95 +} +``` \ No newline at end of file diff --git a/modules/datamodels/datamodelRealEstate.py b/modules/datamodels/datamodelRealEstate.py new file mode 100644 index 00000000..fa9e717e --- /dev/null +++ b/modules/datamodels/datamodelRealEstate.py @@ -0,0 +1,667 @@ +""" +Real Estate data models for Architektur-Planungs-App. +Implements a general Swiss architecture planning data model. +(PEK is one example implementation, but the model is general-purpose) +""" + +from typing import List, Dict, Any, Optional, ForwardRef +from enum import Enum +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels +from modules.shared.timeUtils import getUtcTimestamp +import uuid + +# ===== Enums ===== + +class StatusProzess(str, Enum): + """Project process status""" + EINGANG = "Eingang" + ANALYSE = "Analyse" + STUDIE = "Studie" + PLANUNG = "Planung" + BAURECHTSVERFAHREN = "Baurechtsverfahren" + UMSETZUNG = "Umsetzung" + ARCHIV = "Archiv" + + +class DokumentTyp(str, Enum): + """Document type for categorization""" + KANTON_BAUREGLEMENT_AKTUELL = "kantonBaureglementAktuell" + KANTON_BAUREGLEMENT_REVISION = "kantonBaureglementRevision" + KANTON_BAUVERORDNUNG_AKTUELL = "kantonBauverordnungAktuell" + KANTON_BAUVERORDNUNG_REVISION = "kantonBauverordnungRevision" + GEMEINDE_BZO_AKTUELL = "gemeindeBzoAktuell" + GEMEINDE_BZO_REVISION = "gemeindeBzoRevision" + + +class JaNein(str, Enum): + """Three-valued state for optional yes/no questions""" + UNBEKANNT = "" # Empty string for unknown/not captured + JA = "Ja" + NEIN = "Nein" + + +class GeoTag(str, Enum): + """Geopoint categories""" + K1 = "K1" # Fixpunkt höchster Genauigkeit + K2 = "K2" # Fixpunkt mittlerer Genauigkeit + K3 = "K3" # Fixpunkt niedriger Genauigkeit + GEOMETER = "Geometer" # Vom Geometer vermessener Punkt + + +# ===== Helper Models (must be defined before main models) ===== + +class GeoPunkt(BaseModel): + """Represents a 3D point with reference.""" + koordinatensystem: str = Field( + description="Coordinate system (e.g. 'LV95', 'EPSG:2056')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + x: float = Field( + description="East value (E) [m], typically 2'480'000 - 2'840'000", + frontend_type="number", + frontend_readonly=False, + frontend_required=True, + ) + y: float = Field( + description="North value (N) [m], typically 1'070'000 - 1'300'000", + frontend_type="number", + frontend_readonly=False, + frontend_required=True, + ) + z: Optional[float] = Field( + None, + description="Height above sea level [m]", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + referenz: Optional[GeoTag] = Field( + None, + description="Point categorization", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + + +class GeoPolylinie(BaseModel): + """Represents a line or polygon from multiple GeoPunkte.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + ) + closed: bool = Field( + description="Is the GeoPolylinie closed (polygon)?", + frontend_type="boolean", + frontend_readonly=False, + frontend_required=True, + ) + punkte: List[GeoPunkt] = Field( + default_factory=list, + description="List of GeoPunkte forming the GeoPolylinie", + frontend_type="json", + frontend_readonly=False, + frontend_required=True, + ) + + +class Dokument(BaseModel): + """Supporting data object for file and URL management with versioning.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate this document belongs to", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Document label", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + versionsbezeichnung: Optional[str] = Field( + None, + description="Version number or designation (e.g. 'v1.0', 'Rev. A')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumentTyp: Optional[DokumentTyp] = Field( + None, + description="Document type", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + dokumentReferenz: str = Field( + description="File path or URL", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + quelle: Optional[str] = Field( + None, + description="Source of the document", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + mimeType: Optional[str] = Field( + None, + description="MIME type of the document (e.g. 'application/pdf', 'image/png')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + kategorienTags: List[str] = Field( + default_factory=list, + description="Document categorization tags", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Kontext(BaseModel): + """Supporting data object for flexible additional information.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + ) + thema: str = Field( + description="Theme designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + inhalt: str = Field( + description="Detailed information (text)", + frontend_type="textarea", + frontend_readonly=False, + frontend_required=True, + ) + + +class Land(BaseModel): + """National level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Country name (e.g. 'Schweiz')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + abk: Optional[str] = Field( + None, + description="Abbreviation (e.g. 'CH')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="National laws/documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="National context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Kanton(BaseModel): + """Cantonal level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Canton name (e.g. 'Zürich')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + id_land: Optional[str] = Field( + None, + description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + abk: Optional[str] = Field( + None, + description="Abbreviation (e.g. 'ZH')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Cantonal documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Canton-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Gemeinde(BaseModel): + """Municipal level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Municipality name (e.g. 'Zürich')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + id_kanton: Optional[str] = Field( + None, + description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + plz: Optional[str] = Field( + None, + description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Municipal documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Municipality-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +# ===== Main Models (use ForwardRef for circular references) ===== + +# Forward references for circular dependencies +ParzelleRef = ForwardRef('Parzelle') + + +class Parzelle(BaseModel): + """Represents a plot with all building law properties.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + + # Grunddaten + label: str = Field( + description="Plot designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + parzellenAliasTags: List[str] = Field( + default_factory=list, + description="Additional plot names or field names", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + eigentuemerschaft: Optional[str] = Field( + None, + description="Owner of the plot", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + strasseNr: Optional[str] = Field( + None, + description="Street and house number", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + plz: Optional[str] = Field( + None, + description="Postal code of the plot", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Geografischer Kontext + perimeter: Optional[GeoPolylinie] = Field( + None, + description="Plot boundary as closed GeoPolylinie", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + baulinie: Optional[GeoPolylinie] = Field( + None, + description="Building line of the plot", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + kontextGemeinde: Optional[str] = Field( + None, + description="Municipality ID (Foreign Key)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Bebauungsparameter + bauzone: Optional[str] = Field( + None, + description="Building zone designation (e.g. W3, WG2, etc.)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + az: Optional[float] = Field( + None, + description="Ausnützungsziffer", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + bz: Optional[float] = Field( + None, + description="Bebauungsziffer", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + vollgeschossZahl: Optional[int] = Field( + None, + description="Number of allowed full floors", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + anrechenbarDachgeschoss: Optional[float] = Field( + None, + description="Accountable portion of attic (0.0 - 1.0)", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + anrechenbarUntergeschoss: Optional[float] = Field( + None, + description="Accountable portion of basement (0.0 - 1.0)", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + gebaeudehoeheMax: Optional[float] = Field( + None, + description="Maximum building height in meters", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + + # Abstandsregelungen + regelnGrenzabstand: List[str] = Field( + default_factory=list, + description="Regulations for boundary distance", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + regelnMehrlaengenzuschlag: List[str] = Field( + default_factory=list, + description="Regulations for additional length surcharge", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + regelnMehrhoehenzuschlag: List[str] = Field( + default_factory=list, + description="Regulations for additional height surcharge", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + # Eigenschaften (Ja/Nein) + parzelleBebaut: Optional[JaNein] = Field( + None, + description="Is the plot built?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + parzelleErschlossen: Optional[JaNein] = Field( + None, + description="Is the plot developed?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + parzelleHanglage: Optional[JaNein] = Field( + None, + description="Is the plot on a slope?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + + # Schutzzonen + laermschutzzone: Optional[str] = Field( + None, + description="Noise protection zone (e.g. 'II')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + hochwasserschutzzone: Optional[str] = Field( + None, + description="Flood protection zone (e.g. 'tief')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + grundwasserschutzzone: Optional[str] = Field( + None, + description="Groundwater protection zone", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Beziehungen (stored as JSONB in database) + parzellenNachbarschaft: List[Dict[str, Any]] = Field( + default_factory=list, + description="Neighboring plots (stored as list of Parzelle IDs or full objects)", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Plot-specific documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Plot-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Projekt(BaseModel): + """Core object representing a construction project.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Project designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + statusProzess: Optional[StatusProzess] = Field( + None, + description="Project status", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + perimeter: Optional[GeoPolylinie] = Field( + None, + description="Envelope of all plots in the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + baulinie: Optional[GeoPolylinie] = Field( + None, + description="Building line of the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + parzellen: List[Parzelle] = Field( + default_factory=list, + description="All plots of the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Project-specific documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Project-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +# Resolve forward references +Parzelle.model_rebuild() +Projekt.model_rebuild() + + +# Register labels for frontend +registerModelLabels( + "Projekt", + {"en": "Project", "fr": "Projet", "de": "Projekt"}, + { + "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, + "statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, + }, +) + +registerModelLabels( + "Parzelle", + {"en": "Plot", "fr": "Parcelle", "de": "Parzelle"}, + { + "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, + }, +) + +registerModelLabels( + "Dokument", + {"en": "Document", "fr": "Document", "de": "Dokument"}, + { + "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, + }, +) + diff --git a/modules/features/realEstate/__init__.py b/modules/features/realEstate/__init__.py new file mode 100644 index 00000000..48368b52 --- /dev/null +++ b/modules/features/realEstate/__init__.py @@ -0,0 +1,4 @@ +""" +Real Estate feature module. +""" + diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py new file mode 100644 index 00000000..5396a20a --- /dev/null +++ b/modules/features/realEstate/mainRealEstate.py @@ -0,0 +1,769 @@ +""" +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 + diff --git a/modules/interfaces/interfaceDbRealEstateAccess.py b/modules/interfaces/interfaceDbRealEstateAccess.py new file mode 100644 index 00000000..9a25293f --- /dev/null +++ b/modules/interfaces/interfaceDbRealEstateAccess.py @@ -0,0 +1,87 @@ +""" +Access control for Real Estate interface. +Handles user access management and permission checks. +""" + +import logging +from typing import Dict, Any, List, Optional +from modules.datamodels.datamodelUam import User, UserPrivilege + +logger = logging.getLogger(__name__) + + +class RealEstateAccess: + """ + Access control class for Real Estate interface. + Handles user access management and permission checks. + """ + + def __init__(self, currentUser: User, db): + """Initialize with user context.""" + self.currentUser = currentUser + self.mandateId = currentUser.mandateId + self.userId = currentUser.id + self.privilege = currentUser.privilege + + if not self.mandateId or not self.userId: + raise ValueError("Invalid user context: mandateId and userId are required") + + self.db = db + + def uam(self, model_class: type, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Unified user access management function that filters data based on user privileges. + + Args: + model_class: Pydantic model class for the table + recordset: Recordset to filter based on access rules + + Returns: + Filtered recordset with access control attributes + """ + filtered_records = [] + + # System admins see all records + if self.privilege == UserPrivilege.SYSADMIN: + filtered_records = recordset + # Admins see records in their mandate + elif self.privilege == UserPrivilege.ADMIN: + filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId] + # Regular users see only their records + else: + filtered_records = [ + r for r in recordset + if r.get("mandateId", "-") == self.mandateId and r.get("_createdBy") == self.userId + ] + + # Add access control attributes + for record in filtered_records: + record["_hideView"] = False + record["_hideEdit"] = not self.canModify(model_class, record.get("id")) + record["_hideDelete"] = not self.canModify(model_class, record.get("id")) + + return filtered_records + + def canModify(self, model_class: type, recordId: Optional[str] = None) -> bool: + """Checks if the current user can modify records.""" + if self.privilege == UserPrivilege.SYSADMIN: + return True + + if recordId is not None: + records = self.db.getRecordset(model_class, recordFilter={"id": recordId}) + if not records: + return False + + record = records[0] + + if self.privilege == UserPrivilege.ADMIN and record.get("mandateId", "-") == self.mandateId: + return True + + if (record.get("mandateId", "-") == self.mandateId and + record.get("_createdBy") == self.userId): + return True + + return False + else: + return True # Regular users can create records + diff --git a/modules/interfaces/interfaceDbRealEstateObjects.py b/modules/interfaces/interfaceDbRealEstateObjects.py new file mode 100644 index 00000000..2c6d1554 --- /dev/null +++ b/modules/interfaces/interfaceDbRealEstateObjects.py @@ -0,0 +1,713 @@ +""" +Interface to Real Estate database objects. +Uses PostgreSQL connector for data access with user/mandate filtering. +Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.). +""" + +import logging +from typing import Dict, Any, List, Optional +from modules.datamodels.datamodelRealEstate import ( + Projekt, + Parzelle, + Dokument, + Kanton, + Gemeinde, + Land, + GeoPolylinie, + GeoPunkt, + Kontext, + StatusProzess, +) +from modules.datamodels.datamodelUam import User +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.shared.configuration import APP_CONFIG +# Import Access-Klasse aus separater Datei +from modules.interfaces.interfaceDbRealEstateAccess import RealEstateAccess + +logger = logging.getLogger(__name__) + +# Singleton factory for Real Estate interfaces +_realEstateInterfaces = {} + + +class RealEstateObjects: + """ + Interface to Real Estate database objects. + Uses PostgreSQL connector for data access with user/mandate filtering. + Handles CRUD operations on Real Estate entities. + """ + + def __init__(self, currentUser: Optional[User] = None): + """Initializes the Real Estate Interface.""" + self.currentUser = currentUser + self.userId = currentUser.id if currentUser else None + self.mandateId = currentUser.mandateId if currentUser else None + self.access = None + + # Initialize database + self._initializeDatabase() + + # Set user context if provided + if currentUser: + self.setUserContext(currentUser) + + def _initializeDatabase(self): + """Initialize PostgreSQL database connection.""" + try: + # Get database configuration from environment + dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost") + dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate") + dbUser = APP_CONFIG.get("DB_REALESTATE_USER") + dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432)) + + # Initialize database connector + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=self.userId if self.userId else None, + ) + + # Initialize database system (creates database and system table if needed) + # Note: This is also called in DatabaseConnector.__init__, but we call it explicitly + # for consistency with other interfaces and to ensure proper initialization + self.db.initDbSystem() + + # Ensure all supporting tables are created (Land, Kanton, Gemeinde, Dokument) + # These tables are needed for foreign key relationships + self._ensureSupportingTablesExist() + + logger.info(f"Real Estate database connector initialized for database: {dbDatabase}") + except Exception as e: + logger.error(f"Error initializing Real Estate database: {e}") + raise + + def _ensureSupportingTablesExist(self): + """Ensure all supporting tables (Land, Kanton, Gemeinde, Dokument) are created.""" + try: + # These tables are created on-demand when first accessed, but we ensure they exist here + # to avoid errors when resolving location names to IDs + self.db._ensureTableExists(Land) + self.db._ensureTableExists(Kanton) + self.db._ensureTableExists(Gemeinde) + self.db._ensureTableExists(Dokument) + logger.debug("Supporting tables (Land, Kanton, Gemeinde, Dokument) verified/created") + except Exception as e: + logger.warning(f"Error ensuring supporting tables exist: {e}") + # Don't raise - tables will be created on-demand anyway + + def setUserContext(self, currentUser: User): + """Sets the user context for the interface.""" + self.currentUser = currentUser + self.userId = currentUser.id + self.mandateId = currentUser.mandateId + + if not self.userId or not self.mandateId: + raise ValueError("Invalid user context: id and mandateId are required") + + # Initialize access control + self.access = RealEstateAccess(self.currentUser, self.db) + + # Update database context + self.db.updateContext(self.userId) + + # ===== Projekt Methods ===== + + def createProjekt(self, projekt: Projekt) -> Projekt: + """Create a new project.""" + # Ensure mandateId is set + if not projekt.mandateId: + projekt.mandateId = self.mandateId + + # Apply access control + self.access.uam(Projekt, []) + + # Save to database + self.db.recordCreate(Projekt, projekt.model_dump()) + + return projekt + + def getProjekt(self, projektId: str) -> Optional[Projekt]: + """Get a project by ID.""" + records = self.db.getRecordset( + Projekt, + recordFilter={"id": projektId} + ) + + if not records: + return None + + # Apply access control + filtered = self.access.uam(Projekt, records) + + if not filtered: + return None + + return Projekt(**filtered[0]) + + def getProjekte(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Projekt]: + """Get all projects matching the filter.""" + records = self.db.getRecordset(Projekt, recordFilter=recordFilter or {}) + + # Apply access control + filtered = self.access.uam(Projekt, records) + + return [Projekt(**r) for r in filtered] + + def updateProjekt(self, projektId: str, updateData: Dict[str, Any]) -> Optional[Projekt]: + """Update a project.""" + projekt = self.getProjekt(projektId) + if not projekt: + return None + + # Check if user can modify + if not self.access.canModify(Projekt, projektId): + raise PermissionError(f"User {self.userId} cannot modify project {projektId}") + + # Update fields + for key, value in updateData.items(): + if hasattr(projekt, key): + setattr(projekt, key, value) + + # Save to database + self.db.recordModify(Projekt, projektId, projekt.model_dump()) + + return projekt + + def deleteProjekt(self, projektId: str) -> bool: + """Delete a project.""" + projekt = self.getProjekt(projektId) + if not projekt: + return False + + # Check if user can modify + if not self.access.canModify(Projekt, projektId): + raise PermissionError(f"User {self.userId} cannot delete project {projektId}") + + return self.db.recordDelete(Projekt, projektId) + + # ===== Parzelle Methods ===== + + def createParzelle(self, parzelle: Parzelle) -> Parzelle: + """Create a new plot.""" + if not parzelle.mandateId: + parzelle.mandateId = self.mandateId + + self.access.uam(Parzelle, []) + self.db.recordCreate(Parzelle, parzelle.model_dump()) + + return parzelle + + def getParzelle(self, parzelleId: str) -> Optional[Parzelle]: + """Get a plot by ID.""" + records = self.db.getRecordset( + Parzelle, + recordFilter={"id": parzelleId} + ) + + if not records: + return None + + filtered = self.access.uam(Parzelle, records) + + if not filtered: + return None + + return Parzelle(**filtered[0]) + + def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]: + """Get all plots matching the filter.""" + # Resolve location names to IDs if needed + if recordFilter: + recordFilter = self._resolveLocationFilters(recordFilter) + + records = self.db.getRecordset(Parzelle, recordFilter=recordFilter or {}) + + # Apply access control + filtered = self.access.uam(Parzelle, records) + + return [Parzelle(**r) for r in filtered] + + def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]: + """ + Resolve location names to IDs for foreign key fields. + Only handles kontextGemeinde (Parzelle → Gemeinde). + Note: Parzelle does NOT have direct links to Kanton or Land. + The relationship is: Parzelle → Gemeinde → Kanton → Land + """ + resolvedFilter = recordFilter.copy() + + # Resolve Gemeinde name to ID + # This is the only direct location link on Parzelle + if "kontextGemeinde" in resolvedFilter: + gemeindeValue = resolvedFilter["kontextGemeinde"] + # Check if it's a name (not a UUID-like string) + if not self._isUUID(gemeindeValue): + gemeindeId = self._resolveGemeindeByName(gemeindeValue) + if gemeindeId: + resolvedFilter["kontextGemeinde"] = gemeindeId + logger.debug(f"Resolved Gemeinde name '{gemeindeValue}' to ID '{gemeindeId}'") + else: + logger.warning(f"Gemeinde '{gemeindeValue}' not found, filter may return no results") + # Keep the original value - query will return empty if not found + + # Note: kontextKanton and kontextLand are NOT fields on Parzelle + # If they appear in the filter, they will be filtered out by the validation in mainRealEstate.py + + return resolvedFilter + + def _isUUID(self, value: str) -> bool: + """Check if a string looks like a UUID.""" + import re + uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) + return bool(uuid_pattern.match(value)) + + def _resolveGemeindeByName(self, name: str) -> Optional[str]: + """Resolve Gemeinde name to ID by looking up in Gemeinde table.""" + try: + # First try exact match + gemeinden = self.db.getRecordset( + Gemeinde, + recordFilter={"label": name} + ) + if gemeinden: + gemeindeId = gemeinden[0].get("id") + logger.debug(f"Found Gemeinde '{name}' with ID '{gemeindeId}'") + return gemeindeId + + # If no exact match, try case-insensitive search via SQL query + # This handles cases where the name might have different casing + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "Gemeinde" WHERE LOWER("label") = LOWER(%s) LIMIT 1', + (name,) + ) + result = cursor.fetchone() + if result: + # psycopg2 returns tuples, so result[0] is the id + gemeindeId = result[0] + logger.debug(f"Found Gemeinde '{name}' (case-insensitive) with ID '{gemeindeId}'") + return gemeindeId + + logger.warning(f"Gemeinde '{name}' not found in database") + return None + except Exception as e: + logger.error(f"Error resolving Gemeinde by name '{name}': {e}", exc_info=True) + return None + + def _resolveKantonByName(self, name: str) -> Optional[str]: + """Resolve Kanton name to ID by looking up in Kanton table.""" + try: + # First try exact match + kantone = self.db.getRecordset( + Kanton, + recordFilter={"label": name} + ) + if kantone: + kantonId = kantone[0].get("id") + logger.debug(f"Found Kanton '{name}' with ID '{kantonId}'") + return kantonId + + # Try case-insensitive search + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "Kanton" WHERE LOWER("label") = LOWER(%s) LIMIT 1', + (name,) + ) + result = cursor.fetchone() + if result: + # psycopg2 returns tuples, so result[0] is the id + kantonId = result[0] + logger.debug(f"Found Kanton '{name}' (case-insensitive) with ID '{kantonId}'") + return kantonId + + logger.warning(f"Kanton '{name}' not found in database") + return None + except Exception as e: + logger.error(f"Error resolving Kanton by name '{name}': {e}", exc_info=True) + return None + + def _resolveLandByName(self, name: str) -> Optional[str]: + """Resolve Land name to ID by looking up in Land table.""" + try: + # First try exact match + laender = self.db.getRecordset( + Land, + recordFilter={"label": name} + ) + if laender: + landId = laender[0].get("id") + logger.debug(f"Found Land '{name}' with ID '{landId}'") + return landId + + # Try case-insensitive search + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "Land" WHERE LOWER("label") = LOWER(%s) LIMIT 1', + (name,) + ) + result = cursor.fetchone() + if result: + # psycopg2 returns tuples, so result[0] is the id + landId = result[0] + logger.debug(f"Found Land '{name}' (case-insensitive) with ID '{landId}'") + return landId + + logger.warning(f"Land '{name}' not found in database") + return None + except Exception as e: + logger.error(f"Error resolving Land by name '{name}': {e}", exc_info=True) + return None + + def updateParzelle(self, parzelleId: str, updateData: Dict[str, Any]) -> Optional[Parzelle]: + """Update a plot.""" + parzelle = self.getParzelle(parzelleId) + if not parzelle: + return None + + if not self.access.canModify(Parzelle, parzelleId): + raise PermissionError(f"User {self.userId} cannot modify plot {parzelleId}") + + for key, value in updateData.items(): + if hasattr(parzelle, key): + setattr(parzelle, key, value) + + self.db.recordModify(Parzelle, parzelleId, parzelle.model_dump()) + + return parzelle + + def deleteParzelle(self, parzelleId: str) -> bool: + """Delete a plot.""" + parzelle = self.getParzelle(parzelleId) + if not parzelle: + return False + + if not self.access.canModify(Parzelle, parzelleId): + raise PermissionError(f"User {self.userId} cannot delete plot {parzelleId}") + + return self.db.recordDelete(Parzelle, parzelleId) + + # ===== Dokument Methods ===== + + def createDokument(self, dokument: Dokument) -> Dokument: + """Create a new document.""" + if not dokument.mandateId: + dokument.mandateId = self.mandateId + + self.access.uam(Dokument, []) + self.db.recordCreate(Dokument, dokument.model_dump()) + + return dokument + + def getDokument(self, dokumentId: str) -> Optional[Dokument]: + """Get a document by ID.""" + records = self.db.getRecordset( + Dokument, + recordFilter={"id": dokumentId} + ) + + if not records: + return None + + filtered = self.access.uam(Dokument, records) + + if not filtered: + return None + + return Dokument(**filtered[0]) + + def getDokumente(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Dokument]: + """Get all documents matching the filter.""" + records = self.db.getRecordset(Dokument, recordFilter=recordFilter or {}) + filtered = self.access.uam(Dokument, records) + return [Dokument(**r) for r in filtered] + + def updateDokument(self, dokumentId: str, updateData: Dict[str, Any]) -> Optional[Dokument]: + """Update a document.""" + dokument = self.getDokument(dokumentId) + if not dokument: + return None + + if not self.access.canModify(Dokument, dokumentId): + raise PermissionError(f"User {self.userId} cannot modify document {dokumentId}") + + for key, value in updateData.items(): + if hasattr(dokument, key): + setattr(dokument, key, value) + + self.db.recordModify(Dokument, dokumentId, dokument.model_dump()) + return dokument + + def deleteDokument(self, dokumentId: str) -> bool: + """Delete a document.""" + dokument = self.getDokument(dokumentId) + if not dokument: + return False + + if not self.access.canModify(Dokument, dokumentId): + raise PermissionError(f"User {self.userId} cannot delete document {dokumentId}") + + return self.db.recordDelete(Dokument, dokumentId) + + # ===== Gemeinde Methods ===== + + def createGemeinde(self, gemeinde: Gemeinde) -> Gemeinde: + """Create a new municipality.""" + if not gemeinde.mandateId: + gemeinde.mandateId = self.mandateId + + self.access.uam(Gemeinde, []) + self.db.recordCreate(Gemeinde, gemeinde.model_dump()) + + return gemeinde + + def getGemeinde(self, gemeindeId: str) -> Optional[Gemeinde]: + """Get a municipality by ID.""" + records = self.db.getRecordset( + Gemeinde, + recordFilter={"id": gemeindeId} + ) + + if not records: + return None + + filtered = self.access.uam(Gemeinde, records) + + if not filtered: + return None + + return Gemeinde(**filtered[0]) + + def getGemeinden(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Gemeinde]: + """Get all municipalities matching the filter.""" + records = self.db.getRecordset(Gemeinde, recordFilter=recordFilter or {}) + filtered = self.access.uam(Gemeinde, records) + return [Gemeinde(**r) for r in filtered] + + def updateGemeinde(self, gemeindeId: str, updateData: Dict[str, Any]) -> Optional[Gemeinde]: + """Update a municipality.""" + gemeinde = self.getGemeinde(gemeindeId) + if not gemeinde: + return None + + if not self.access.canModify(Gemeinde, gemeindeId): + raise PermissionError(f"User {self.userId} cannot modify municipality {gemeindeId}") + + for key, value in updateData.items(): + if hasattr(gemeinde, key): + setattr(gemeinde, key, value) + + self.db.recordModify(Gemeinde, gemeindeId, gemeinde.model_dump()) + return gemeinde + + def deleteGemeinde(self, gemeindeId: str) -> bool: + """Delete a municipality.""" + gemeinde = self.getGemeinde(gemeindeId) + if not gemeinde: + return False + + if not self.access.canModify(Gemeinde, gemeindeId): + raise PermissionError(f"User {self.userId} cannot delete municipality {gemeindeId}") + + return self.db.recordDelete(Gemeinde, gemeindeId) + + # ===== Kanton Methods ===== + + def createKanton(self, kanton: Kanton) -> Kanton: + """Create a new canton.""" + if not kanton.mandateId: + kanton.mandateId = self.mandateId + + self.access.uam(Kanton, []) + self.db.recordCreate(Kanton, kanton.model_dump()) + + return kanton + + def getKanton(self, kantonId: str) -> Optional[Kanton]: + """Get a canton by ID.""" + records = self.db.getRecordset( + Kanton, + recordFilter={"id": kantonId} + ) + + if not records: + return None + + filtered = self.access.uam(Kanton, records) + + if not filtered: + return None + + return Kanton(**filtered[0]) + + def getKantone(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Kanton]: + """Get all cantons matching the filter.""" + records = self.db.getRecordset(Kanton, recordFilter=recordFilter or {}) + filtered = self.access.uam(Kanton, records) + return [Kanton(**r) for r in filtered] + + def updateKanton(self, kantonId: str, updateData: Dict[str, Any]) -> Optional[Kanton]: + """Update a canton.""" + kanton = self.getKanton(kantonId) + if not kanton: + return None + + if not self.access.canModify(Kanton, kantonId): + raise PermissionError(f"User {self.userId} cannot modify canton {kantonId}") + + for key, value in updateData.items(): + if hasattr(kanton, key): + setattr(kanton, key, value) + + self.db.recordModify(Kanton, kantonId, kanton.model_dump()) + return kanton + + def deleteKanton(self, kantonId: str) -> bool: + """Delete a canton.""" + kanton = self.getKanton(kantonId) + if not kanton: + return False + + if not self.access.canModify(Kanton, kantonId): + raise PermissionError(f"User {self.userId} cannot delete canton {kantonId}") + + return self.db.recordDelete(Kanton, kantonId) + + # ===== Land Methods ===== + + def createLand(self, land: Land) -> Land: + """Create a new country.""" + if not land.mandateId: + land.mandateId = self.mandateId + + self.access.uam(Land, []) + self.db.recordCreate(Land, land.model_dump()) + + return land + + def getLand(self, landId: str) -> Optional[Land]: + """Get a country by ID.""" + records = self.db.getRecordset( + Land, + recordFilter={"id": landId} + ) + + if not records: + return None + + filtered = self.access.uam(Land, records) + + if not filtered: + return None + + return Land(**filtered[0]) + + def getLaender(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Land]: + """Get all countries matching the filter.""" + records = self.db.getRecordset(Land, recordFilter=recordFilter or {}) + filtered = self.access.uam(Land, records) + return [Land(**r) for r in filtered] + + def updateLand(self, landId: str, updateData: Dict[str, Any]) -> Optional[Land]: + """Update a country.""" + land = self.getLand(landId) + if not land: + return None + + if not self.access.canModify(Land, landId): + raise PermissionError(f"User {self.userId} cannot modify country {landId}") + + for key, value in updateData.items(): + if hasattr(land, key): + setattr(land, key, value) + + self.db.recordModify(Land, landId, land.model_dump()) + return land + + def deleteLand(self, landId: str) -> bool: + """Delete a country.""" + land = self.getLand(landId) + if not land: + return False + + if not self.access.canModify(Land, landId): + raise PermissionError(f"User {self.userId} cannot delete country {landId}") + + return self.db.recordDelete(Land, landId) + + # ===== Direct Query Execution (stateless) ===== + + def executeQuery(self, queryText: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Execute a SQL query directly on the database (stateless). + + WARNING: This method executes raw SQL. Ensure proper validation and sanitization + before calling this method. Consider implementing query whitelisting or + only allowing SELECT statements for production use. + + Args: + queryText: SQL query string (preferably SELECT only) + parameters: Optional parameters for parameterized queries + + Returns: + Dictionary with 'rows' (list of dicts), 'columns' (list of column names), + 'rowCount' (int), and 'executionTime' (float) + """ + import time + + try: + start_time = time.time() + + # Ensure connection is alive + self.db._ensure_connection() + + with self.db.connection.cursor() as cursor: + # Execute query + if parameters: + # Use parameterized query for safety + cursor.execute(queryText, parameters) + else: + cursor.execute(queryText) + + # Fetch results + rows = cursor.fetchall() + + # Convert to list of dictionaries + result_rows = [] + if rows: + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + result_rows = [dict(zip(columns, row)) for row in rows] + else: + columns = [] + + execution_time = time.time() - start_time + + return { + "rows": result_rows, + "columns": columns, + "rowCount": len(result_rows), + "executionTime": execution_time, + } + except Exception as e: + logger.error(f"Error executing query: {e}", exc_info=True) + raise + + +def getInterface(currentUser: User) -> RealEstateObjects: + """ + Factory function to get or create a Real Estate interface instance for a user. + Uses singleton pattern per user. + """ + userKey = f"{currentUser.id}_{currentUser.mandateId}" + + if userKey not in _realEstateInterfaces: + _realEstateInterfaces[userKey] = RealEstateObjects(currentUser) + + return _realEstateInterfaces[userKey] + diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py new file mode 100644 index 00000000..b5e34ef3 --- /dev/null +++ b/modules/routes/routeRealEstate.py @@ -0,0 +1,637 @@ +""" +Real Estate routes for the backend API. +Implements stateless endpoints for real estate database operations with AI-powered natural language processing. +""" + +import logging +import json +from typing import Optional, Dict, Any, List, Union +from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status + +# Import auth modules +from modules.security.auth import limiter, getCurrentUser + +# Import models +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata +from modules.datamodels.datamodelRealEstate import ( + Projekt, + Parzelle, + Dokument, + Gemeinde, + Kanton, + Land, +) + +# Import interfaces +from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface + +# Import feature logic +from modules.features.realEstate.mainRealEstate import ( + processNaturalLanguageCommand, + executeDirectQuery, +) + +# Import attribute utilities for model schema +from modules.shared.attributeUtils import getModelAttributeDefinitions + +# Configure logger +logger = logging.getLogger(__name__) + +# Create router for real estate endpoints +router = APIRouter( + prefix="/api/realestate", + tags=["Real Estate"], + responses={ + 404: {"description": "Not found"}, + 400: {"description": "Bad request"}, + 401: {"description": "Unauthorized"}, + 403: {"description": "Forbidden"}, + 500: {"description": "Internal server error"} + } +) + + +@router.post("/command", response_model=Dict[str, Any]) +@limiter.limit("120/minute") +async def process_command( + request: Request, + userInput: str = Body(..., embed=True, description="Natural language command"), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Process natural language command and execute corresponding CRUD operation. + + Uses AI to analyze user intent and extract parameters, then executes the appropriate + CRUD operation. Works stateless without session management. + + 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'" + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Returns: + { + "success": true, + "intent": "CREATE|READ|UPDATE|DELETE|QUERY", + "entity": "Projekt|Parzelle|...|null", + "result": {...} + } + """ + try: + # Validate CSRF token (middleware also checks, but explicit validation for better error messages) + csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") + if not csrf_token: + logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.debug(f"User input: {userInput}") + + # Process natural language command with AI + result = await processNaturalLanguageCommand( + currentUser=currentUser, + userInput=userInput + ) + + return result + + except ValueError as e: + logger.error(f"Validation error in process_command: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Validation error: {str(e)}" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error processing command: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing command: {str(e)}" + ) + +@router.post("/query", response_model=Dict[str, Any]) +@limiter.limit("120/minute") +async def execute_query( + request: Request, + body: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Execute a direct SQL query without session management. + + Executes the query directly and returns the result. No query history is saved. + + Request body: + { + "queryText": "SELECT * FROM Projekt WHERE plz = '8000'", + "parameters": { // Optional + "$1": "8000" + } + } + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + WARNING: This endpoint executes raw SQL queries. Ensure proper validation + and sanitization on the frontend. Consider implementing query whitelisting + or only allowing SELECT statements for production use. + + Returns: + { + "status": "success", + "rows": [...], + "columns": [...], + "rowCount": 15, + "executionTime": 0.123 + } + """ + try: + # Validate CSRF token (middleware also checks, but explicit validation for better error messages) + csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") + if not csrf_token: + logger.warning(f"CSRF token missing for POST /api/realestate/query from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for POST /api/realestate/query from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/query from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Extract fields from body + queryText = body.get("queryText") + if not queryText: + raise ValueError("queryText is required") + + parameters = body.get("parameters") + + logger.info(f"Processing query request from user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.debug(f"Query text: {queryText}") + if parameters: + logger.debug(f"Query parameters: {parameters}") + + # Execute direct query + result = await executeDirectQuery( + currentUser=currentUser, + queryText=queryText, + parameters=parameters, + ) + + return result + + except ValueError as e: + logger.error(f"Validation error in execute_query: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Validation error: {str(e)}" + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error executing query: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error executing query: {str(e)}" + ) + + +@router.get("/tables", response_model=Dict[str, Any]) +@limiter.limit("120/minute") +async def get_available_tables( + request: Request, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Get all available real estate tables. + + Returns a list of available table names with their descriptions. + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Example: + - GET /api/realestate/tables + """ + try: + # Validate CSRF token if provided + csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") + if not csrf_token: + logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})") + + # Define available tables with descriptions + tables = [ + { + "name": "Projekt", + "description": "Real estate projects", + "model": "Projekt" + }, + { + "name": "Parzelle", + "description": "Plots/parcels", + "model": "Parzelle" + }, + { + "name": "Dokument", + "description": "Documents", + "model": "Dokument" + }, + { + "name": "Gemeinde", + "description": "Municipalities", + "model": "Gemeinde" + }, + { + "name": "Kanton", + "description": "Cantons", + "model": "Kanton" + }, + { + "name": "Land", + "description": "Countries", + "model": "Land" + }, + ] + + return { + "tables": tables, + "count": len(tables) + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting available tables: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting available tables: {str(e)}" + ) + + +@router.get("/table/{table}", response_model=PaginatedResponse[Any]) +@limiter.limit("120/minute") +async def get_table_data( + request: Request, + table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), + currentUser: User = Depends(getCurrentUser) +) -> PaginatedResponse[Dict[str, Any]]: + """ + Get all data from a specific real estate table with optional pagination. + + Available tables: + - Projekt: Real estate projects + - Parzelle: Plots/parcels + - Dokument: Documents + - Gemeinde: Municipalities + - Kanton: Cantons + - Land: Countries + + Query Parameters: + - pagination: JSON-encoded PaginationParams object, or None for no pagination + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Examples: + - GET /api/realestate/table/Projekt (no pagination - returns all items) + - GET /api/realestate/table/Parzelle?pagination={"page":1,"pageSize":10,"sort":[]} + - GET /api/realestate/table/Gemeinde?pagination={"page":2,"pageSize":20,"sort":[{"field":"label","direction":"asc"}]} + """ + try: + # Validate CSRF token if provided + csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") + if not csrf_token: + logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})") + + # Map table names to model classes and getter methods + table_mapping = { + "Projekt": (Projekt, "getProjekte"), + "Parzelle": (Parzelle, "getParzellen"), + "Dokument": (Dokument, "getDokumente"), + "Gemeinde": (Gemeinde, "getGemeinden"), + "Kanton": (Kanton, "getKantone"), + "Land": (Land, "getLaender"), + } + + # Validate table name + if table not in table_mapping: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" + ) + + # Get interface and fetch data + realEstateInterface = getRealEstateInterface(currentUser) + model_class, method_name = table_mapping[table] + getter_method = getattr(realEstateInterface, method_name) + + # Fetch all records (no filter for now) + records = getter_method(recordFilter=None) + + # Keep records as model instances (like routeDataFiles does with FileItem) + # FastAPI will automatically serialize Pydantic models to JSON + items = records + + # If table is empty, create an empty instance with all fields set to None/empty + # This allows the frontend to extract column structure from the response + # All fields will be None/empty - no IDs or other values generated + if not items: + try: + # Get all model fields + model_fields = model_class.model_fields + empty_values = {} + + # Set all fields to None - explicitly set every field to None + # This ensures no default_factory is called and no IDs are generated + for field_name in model_fields.keys(): + empty_values[field_name] = None + + # Create instance with all None values + # Use model_validate with allow_none=True or construct directly + empty_instance = model_class.model_construct(**empty_values) + items = [empty_instance] + logger.debug(f"Created empty instance for {table} with all fields set to None") + except Exception as e: + logger.warning(f"Could not create empty instance for {table}: {str(e)}. Returning empty list.") + items = [] + + # Parse pagination parameter + paginationParams = None + if pagination: + try: + paginationDict = json.loads(pagination) + paginationParams = PaginationParams(**paginationDict) if paginationDict else None + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid pagination parameter: {str(e)}" + ) + + # Apply pagination if requested + if paginationParams: + # Apply sorting if specified + if paginationParams.sort: + for sort_field in reversed(paginationParams.sort): # Reverse to apply in priority order + field_name = sort_field.field + direction = sort_field.direction.lower() + + def sort_key(item): + # Access attribute from model instance + value = getattr(item, field_name, None) + # Handle None values - put them at the end for asc, at the start for desc + if value is None: + return (1, None) # Use tuple to ensure None values sort consistently + return (0, value) + + items.sort(key=sort_key, reverse=(direction == "desc")) + + # Apply pagination + total_items = len(items) + total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize # Ceiling division + start_idx = (paginationParams.page - 1) * paginationParams.pageSize + end_idx = start_idx + paginationParams.pageSize + paginated_items = items[start_idx:end_idx] + + return PaginatedResponse( + items=paginated_items, + pagination=PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=total_items, + totalPages=total_pages, + sort=paginationParams.sort, + filters=paginationParams.filters + ) + ) + else: + # No pagination - return all items (as model instances, like routeDataFiles) + return PaginatedResponse( + items=items, + pagination=None + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting table data for '{table}': {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error getting table data: {str(e)}" + ) + + +@router.post("/table/{table}", response_model=Dict[str, Any]) +@limiter.limit("120/minute") +async def create_table_record( + request: Request, + table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), + data: Dict[str, Any] = Body(..., description="Record data to create"), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Create a new record in a specific real estate table. + + Available tables: + - Projekt: Real estate projects + - Parzelle: Plots/parcels + - Dokument: Documents + - Gemeinde: Municipalities + - Kanton: Cantons + - Land: Countries + + Request Body: + - JSON object with fields matching the table's data model + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Examples: + - POST /api/realestate/table/Projekt + Body: {"label": "Hauptstrasse 42", "statusProzess": "Eingang"} + - POST /api/realestate/table/Parzelle + Body: {"label": "Parzelle 1", "strasseNr": "Hauptstrasse 42", "plz": "8000", "bauzone": "W3"} + """ + try: + # Validate CSRF token + csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") + if not csrf_token: + logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.debug(f"Record data: {data}") + + # Map table names to model classes and create methods + table_mapping = { + "Projekt": (Projekt, "createProjekt"), + "Parzelle": (Parzelle, "createParzelle"), + "Dokument": (Dokument, "createDokument"), + "Gemeinde": (Gemeinde, "createGemeinde"), + "Kanton": (Kanton, "createKanton"), + "Land": (Land, "createLand"), + } + + # Validate table name + if table not in table_mapping: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" + ) + + # Get interface + realEstateInterface = getRealEstateInterface(currentUser) + model_class, method_name = table_mapping[table] + create_method = getattr(realEstateInterface, method_name) + + # Ensure mandateId is set (will be set by interface if missing) + if "mandateId" not in data: + data["mandateId"] = currentUser.mandateId + + # Create model instance from data + try: + model_instance = model_class(**data) + except Exception as e: + logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid data for {table}: {str(e)}" + ) + + # Create record + try: + created_record = create_method(model_instance) + + # Convert to dictionary for response + if hasattr(created_record, 'model_dump'): + return created_record.model_dump() + else: + return created_record + + except Exception as e: + logger.error(f"Error creating {table} record: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating {table} record: {str(e)}" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating record in table '{table}': {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating record: {str(e)}" + ) +