Compare commits
No files matched your search
@@ -0,0 +1,450 @@
|
|||||||
|
# Property On — Agenten-Ansatz: Entwicklungsstand
|
||||||
|
|
||||||
|
> **Zweck dieses Dokuments:** Vollständiges Briefing über den Agenten-Ansatz der Plattform
|
||||||
|
> «Property On» (Repository `property-match`), Stand **5. August 2026**.
|
||||||
|
> Alle Angaben sind direkt aus dem Quellcode ausgelesen, nicht aus Konzeptpapieren.
|
||||||
|
>
|
||||||
|
> **Wenn du (ChatGPT) dieses Dokument liest:** Es ist die Faktenbasis für eine Präsentation
|
||||||
|
> zum Entwicklungsstand. Abschnitt 8 trennt ausdrücklich zwischen *gebaut und lauffähig* und
|
||||||
|
> *bewusst als Demo simuliert*. Diese Trennung bitte niemals verwischen — die Präsentation
|
||||||
|
> muss Nachfragen standhalten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Produktvision in einem Satz
|
||||||
|
|
||||||
|
Property On ist keine Immobilienplattform mit angeflanschtem Chatbot, sondern eine
|
||||||
|
**Decision-Intelligence-Plattform**, deren Arbeit von fünf **digitalen Mitarbeitenden**
|
||||||
|
erledigt wird — jeder mit Namen, Porträt, Personalnummer, Abteilung, definiertem
|
||||||
|
Aufgabenprofil und einer festgelegten Autonomiestufe.
|
||||||
|
|
||||||
|
Der konzeptionelle Kern, der den Ansatz von «KI-Features» unterscheidet:
|
||||||
|
|
||||||
|
| Klassischer KI-Ansatz | Property-On-Agentenansatz |
|
||||||
|
|---|---|
|
||||||
|
| Ein Assistent, überall derselbe | Fünf Rollen mit je eigenem Arbeitsplatz und Zuständigkeit |
|
||||||
|
| «AI»-Knopf an bestehender Oberfläche | Die Seite *ist* der Arbeitsplatz des Agenten |
|
||||||
|
| Blackbox-Ausgabe | Jede Aussage mit Quelle, Fundstelle und Konfidenz |
|
||||||
|
| Autonom oder gar nicht | Drei abgestufte Autonomiestufen je Agent |
|
||||||
|
| Wird als Technologie verkauft | Wird als Personal eingeführt — inkl. «Personalverwaltung» |
|
||||||
|
|
||||||
|
Die drei Leitprinzipien aus der Projektdokumentation (`CLAUDE.md`), die jede
|
||||||
|
Designentscheidung schlagen:
|
||||||
|
|
||||||
|
1. **Explainability-first** — jeder Score, jedes Badge, jede Empfehlung ist rückverfolgbar.
|
||||||
|
Wenn nicht nachvollziehbar ist *warum*, ist das Feature nicht fertig.
|
||||||
|
2. **Trust-first** — Konfidenz, Datenaktualität und Datenlücken werden aktiv angezeigt.
|
||||||
|
Nichts wird sicherer dargestellt, als die Datenlage hergibt.
|
||||||
|
3. **Better-than-Google** — ranken, Zielkonflikte erklären, Zukunftssignale zeigen und eine
|
||||||
|
konkrete nächste Handlung empfehlen; nicht bloss Treffer auflisten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Die fünf Agenten im Überblick
|
||||||
|
|
||||||
|
Der Menüpunkt heisst **«Meine Agenten»**. Die Reihenfolge folgt dem Arbeitsablauf, nicht dem
|
||||||
|
Alphabet.
|
||||||
|
|
||||||
|
| # | Agent | Funktion | Abteilung | Pers.-Nr. | Autonomiestufe | Route |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 1 | **Ferdi** | Fristen-Wächter | Bewirtschaftung | PO-ZD-02 | Freigabe erforderlich — «autonom beim Erinnern, Freigabe bei Eskalationen» | `/supply/reminder-manager` |
|
||||||
|
| 2 | **Bruno** | Besichtigungsassistent | Vermarktung | PO-ZD-13 | Autonom | `/supply/besichtigungen` |
|
||||||
|
| 3 | **Livia** | Exposé Master | Vermarktung | PO-ZD-21 | Autonom | `/supply/my-listings` |
|
||||||
|
| 4 | **Nora** | Marktchancen / Leads | Marktbeobachtung | PO-ZD-19 | Nur Vorschlag | `/supply/market-intelligence` |
|
||||||
|
| 5 | **Sina** | Datenpflege | Bewirtschaftung | PO-ZD-03 | Autonom, ausschliesslich lesend | `/supply/data-quality` |
|
||||||
|
|
||||||
|
**Die Autonomiestufen sind das stärkste Argument der Präsentation.** Sie sind kein Etikett,
|
||||||
|
sondern im Datenmodell verankert (`AgentAutonomyLevel`) und je Agent begründet:
|
||||||
|
|
||||||
|
- **Nora darf nichts entscheiden** — sie erkennt Marktsignale und schlägt vor. Ein Mensch
|
||||||
|
entscheidet, ob daraus ein Lead wird.
|
||||||
|
- **Ferdi darf erinnern, aber nicht eskalieren** — die Eskalation gegenüber einem Mieter ist
|
||||||
|
eine geschäftliche Handlung mit Aussenwirkung und braucht eine Freigabe.
|
||||||
|
- **Sina darf autonom arbeiten, weil sie ausschliesslich liest** — sie beantwortet Fragen aus
|
||||||
|
hinterlegten Dokumenten und belegt jede Aussage mit Fundstelle. Ohne Schreibrecht kein Risiko.
|
||||||
|
- **Bruno und Livia arbeiten autonom**, weil ihr Ergebnis (Briefing, Exposé-Entwurf) dem
|
||||||
|
Menschen vorgelegt wird, bevor es nach aussen geht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Die fünf Agenten im Detail
|
||||||
|
|
||||||
|
### 3.1 Ferdi — Fristen-Wächter
|
||||||
|
|
||||||
|
**Problem:** Optionsfristen, Break-Klauseln und Anpassungstermine verschwinden in Verträgen,
|
||||||
|
Nachträgen und Excel-Listen. Wer eine Frist verpasst, verliert Geld oder eine Fläche.
|
||||||
|
|
||||||
|
**Was Ferdi tut** (aus dem Personaldossier):
|
||||||
|
- Liest Mietverträge, Nachträge und Zusatzvereinbarungen der Standorte Zürich, Winterthur,
|
||||||
|
Basel, Zug, Bern und St. Gallen ein
|
||||||
|
- Extrahiert Fristen, Optionen und Break-Klauseln **mit Fundstelle im Dokument**
|
||||||
|
- Lässt jeden neu gefundenen Termin **einmalig durch die Bewirtschaftung bestätigen**
|
||||||
|
(Human-in-the-Loop an der Datenquelle, nicht erst am Ergebnis)
|
||||||
|
- Gleicht den bestätigten Bestand jeden Morgen mit dem Kalender ab
|
||||||
|
- Erinnert gestaffelt: 12, 9, 6 und 3 Monate vor Stichtag
|
||||||
|
- Meldet nach fünf Arbeitstagen ohne Reaktion ein zweites Mal
|
||||||
|
- Legt bei anhaltendem Ausbleiben eine **Eskalation zur Freigabe** vor
|
||||||
|
|
||||||
|
**Ergebnisse:** Fristen-Alerts, Wochen-Pipeline montags 07:00 Uhr, Eskalationsvorschläge,
|
||||||
|
lückenloses Meldeprotokoll je Vertrag.
|
||||||
|
|
||||||
|
**In der Oberfläche:** Chat-Einstieg, darunter Filterleiste und Reminder-Liste, seitliche
|
||||||
|
Detailansicht. Zusätzlich gebaut: **Kalender-Terminplanung** — aus einem Reminder lässt sich
|
||||||
|
ein Kalendereintrag erzeugen (Titel-Schema `Ferdi: {Typ} – {Objekt}`, mit strukturiertem
|
||||||
|
Anhang aus den Reminder-Daten).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Bruno — Besichtigungsassistent
|
||||||
|
|
||||||
|
**Problem:** Vor einer Besichtigung fehlt dem Makler die Vorbereitung, nach der Besichtigung
|
||||||
|
verschwindet das Gesagte. Beides kostet Abschlüsse.
|
||||||
|
|
||||||
|
**Zwei Auftragstypen, ein Arbeitsplatz:**
|
||||||
|
|
||||||
|
**Vorbereitung** — Bruno liefert vor dem Termin ein Briefing:
|
||||||
|
- Objektbild, Adresse, Fläche, Mietpreis, Verfügbarkeit, Parkplätze
|
||||||
|
- Objektbeschrieb
|
||||||
|
- **Lagebericht von Livia** (Agent-zu-Agent-Zulieferung — ein wichtiger Punkt: die Agenten
|
||||||
|
arbeiten einander zu, sie stehen nicht nebeneinander)
|
||||||
|
- **Verkaufsargumente**
|
||||||
|
- **Mögliche Einwände mit vorbereiteter Antwort** — je Einwand ein Antwortvorschlag
|
||||||
|
- **Rechercheergebnisse zum Interessenten aus öffentlichen Quellen** — und hier die
|
||||||
|
entscheidende Regel: *jede* Angabe steht mit Quellenangabe da. Findet Bruno nichts
|
||||||
|
Belegbares, steht «Keine belegbaren öffentlichen Angaben gefunden» — nicht eine plausible
|
||||||
|
Vermutung. Begründung im Code: *«Eine Vermutung im Briefing wird am Termin zur Behauptung.»*
|
||||||
|
- Optional: Audiofassung des Briefings (für die Fahrt zum Termin)
|
||||||
|
|
||||||
|
**Nachbereitung** — nach dem Termin:
|
||||||
|
- Der Makler diktiert oder schreibt formlos (Eingangskanal wird protokolliert)
|
||||||
|
- Bruno zeigt transparent, **welche Verarbeitungsschritte** er daraus gemacht hat
|
||||||
|
- Ergebnis ist eine benannte Datei (Protokoll / Kundennotiz) plus Bemerkungen
|
||||||
|
|
||||||
|
**Eingebaute Kontrolle:** Steht eine Besichtigung in **weniger als 24 Stunden** an und wurde
|
||||||
|
noch kein Bericht angefordert, erscheint eine Warnung mit direktem Handlungsknopf
|
||||||
|
«Bericht anfordern». Das ist ein konkretes Beispiel für einen Agenten, der nicht nur
|
||||||
|
ausführt, sondern die Lücke im Prozess selbst meldet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Livia — Exposé Master
|
||||||
|
|
||||||
|
**Problem:** Ein vollständiges Gewerbe-Exposé zu erstellen ist ein Tagesgeschäft von mehreren
|
||||||
|
Stunden — Daten zusammensuchen, Texte schreiben, Bilder sortieren, exportieren.
|
||||||
|
|
||||||
|
**Arbeitsplatz:** Leadliste (aktiv / archiviert). Leads kommen aus Noras Signalen, sobald ein
|
||||||
|
Bewirtschafter sie zur Exposé-Erstellung weitergeleitet hat. Beim Öffnen eines Leads gleitet
|
||||||
|
die Zeile nach oben und der dreistufige Arbeitsbereich klappt auf.
|
||||||
|
|
||||||
|
**Der Drei-Schritt-Ablauf: Hochladen → Exposé → Export**
|
||||||
|
|
||||||
|
**Schritt 1 – Hochladen:** Objektdaten und Bilder aus «Meine Objekte» vorbefüllen oder
|
||||||
|
Unterlagen hochladen.
|
||||||
|
|
||||||
|
**Schritt 2 – Exposé:** Ein Dossier aus **11 fachlichen Bereichen** — das ist der inhaltliche
|
||||||
|
Beweis, dass hier eine echte Fachdomäne abgebildet ist und keine generische Formularmaske:
|
||||||
|
|
||||||
|
| # | Bereich | Beispielfelder | Datenquellen |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Eckdaten & Vermarktung | Objektart, Vermarktungsart, Status, Objektreferenz | — |
|
||||||
|
| 2 | Lage | Strasse, PLZ, Ort, Gemeinde, Kanton | — |
|
||||||
|
| 3 | Koordinaten & Distanzen | ÖV-Haltestelle, Einkauf, Schule, Autobahn (in m) | Swisstopo, ÖV-Fahrplan, OpenStreetMap |
|
||||||
|
| 4 | Gebäude- & Gemeindedaten | ÖV-Güteklasse, Solareignung, Einwohner, Steuerbelastung, Leerwohnungsziffer | ARE, BFE Sonnendach, BFS, ESTV |
|
||||||
|
| 5 | Flächen & Gebäude | Nutzfläche, Raumhöhe, **Kubatur SIA 416** | — |
|
||||||
|
| 6 | Gebäude & Verfügbarkeit | Stockwerk, Baujahr, letzte Renovation, Zustand | — |
|
||||||
|
| 7 | Grundstück & Baurecht | Nutzungszone, Überbauungsziffer, Ausnützungsziffer | geodienste / Swisstopo |
|
||||||
|
| 8 | Preise & Kosten | Nettomiete CHF/m²/J, Nebenkosten, NK-Art, Kaution, Parkierung | — |
|
||||||
|
| 9 | Ausstattung & Merkmale | Photovoltaik, Wärmepumpe, E-Ladestation, Minergie, Seesicht … | — |
|
||||||
|
| 10 | Energie & Technik | Heizsystem, Wärmeverteilung, GEAK, kWp | — |
|
||||||
|
| 11 | Texte & Beschriebe | Titel, Kurzbeschrieb, Objekt-/Lage-/Gemeinde-/Ausstattungsbeschrieb, Highlights | **KI-Textentwurf** |
|
||||||
|
|
||||||
|
Zwei Details, die den Qualitätsanspruch zeigen:
|
||||||
|
- **Schweizer Fachnormen sind eingebaut** — Kubatur nach SIA 416, GEAK, Minergie,
|
||||||
|
ÖV-Güteklasse nach ARE, Leerwohnungsziffer. Alle behördlichen Datenquellen sind benannt und
|
||||||
|
**die Werte bleiben überschreibbar** — der Mensch behält das letzte Wort.
|
||||||
|
- **Pflichtfeldlogik:** 9 Felder sind Pflicht (Objektart, Vermarktungsart, Strasse, PLZ, Ort,
|
||||||
|
Nutzfläche, Verfügbarkeit, Nettomiete, Exposé-Titel, Kurzbeschrieb, Objektbeschrieb).
|
||||||
|
Fehlt eines, wird es rot umrandet. Begründung im Code: *«Eine Broschüre ohne Kubatur ist
|
||||||
|
verkaufbar, eine ohne Mietpreis nicht.»*
|
||||||
|
|
||||||
|
**KI-Texterstellung mit hartem Leitplanken-Prinzip:** Der Textentwurf nutzt
|
||||||
|
**ausschliesslich die erfassten Objektdaten**. Im Backend-Dienst wird die Textgenerierung
|
||||||
|
bewusst an einen deterministischen Baustein delegiert, damit **kein Sprachmodell fehlende
|
||||||
|
Fakten erfindet**. Das ist die direkte technische Umsetzung des Trust-first-Prinzips.
|
||||||
|
|
||||||
|
**Schritt 3 – Export:** HTML-Vorschau, PDF (über den Druckdialog) und Word (`.doc`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 Nora — Marktchancen / Leads
|
||||||
|
|
||||||
|
**Problem:** Marktchancen entstehen aus schwachen Signalen — einer Handelsregister-Mutation,
|
||||||
|
einer Baubewilligung, einem Gespräch im Netzwerk. Diese Signale sind verteilt und flüchtig.
|
||||||
|
|
||||||
|
**Zwei Quellen, zwei Reiter:**
|
||||||
|
|
||||||
|
**Reiter «KI-Signale»** — automatisch erkannte Signale aus öffentlichen Quellen
|
||||||
|
(SHAB, Zefix, Web), je Signal eine Detailansicht mit Einordnung.
|
||||||
|
|
||||||
|
**Reiter «Netzwerk»** — der menschliche Kanal. Ein Bewirtschafter erfasst selbst einen
|
||||||
|
Hinweis aus seinem Netzwerk. Filter nach **Intern** und **Plattform** — d. h. das Wissen
|
||||||
|
einzelner Mitarbeitender wird zu Plattformwissen, ohne dass es dafür ein separates CRM-Ritual
|
||||||
|
braucht.
|
||||||
|
|
||||||
|
**Autonomiestufe «Vorschlag»:** Nora entscheidet nichts. Sie erkennt, ordnet ein und legt vor.
|
||||||
|
Der Mensch leitet ein Signal per Mehrfachauswahl an **Livia** weiter — dort wird daraus ein
|
||||||
|
Exposé-Lead. Das ist die sichtbare Übergabe zwischen zwei Agenten und der beste
|
||||||
|
Demo-Moment der Präsentation.
|
||||||
|
|
||||||
|
**Weitere Ergebnisse laut Dossier:** Wochen-Mails, Leads, Marktbausteine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 Sina — Datenpflege
|
||||||
|
|
||||||
|
**Problem:** Schlechte Objektdaten machen jede nachgelagerte Auswertung wertlos. Aber niemand
|
||||||
|
pflegt Daten «auf Vorrat» — man braucht den konkreten Hinweis, *welches* Objekt jetzt
|
||||||
|
angefasst werden muss.
|
||||||
|
|
||||||
|
**Bewusste Design-Entscheidung, die man in der Präsentation zeigen sollte:** Die Seite hatte
|
||||||
|
früher Auswertungskarten und eine Qualitätsverteilung. Beide sind **entfernt** worden. Die
|
||||||
|
Begründung steht im Code: *Kennzahlen beantworten «wie steht es insgesamt?», auf dieser Seite
|
||||||
|
zählt aber «welches Objekt muss ich anfassen?».* Das ist Prinzip 1 der Projektrichtlinien in
|
||||||
|
Reinform — jeder Bildschirm wird um **eine Entscheidung** herum gebaut.
|
||||||
|
|
||||||
|
**Was übrig blieb:** Chat-Einstieg, Freitextsuche, drei Filter (Ort/PLZ, Gewerbetyp, Status)
|
||||||
|
und eine nach Objektname oder Datenqualität sortierbare Liste. Datenqualität unter 60 %
|
||||||
|
wird rot. Klick auf ein Objekt öffnet die **direkt editierbare** Detailansicht.
|
||||||
|
|
||||||
|
**Zweites Aufgabenprofil laut Dossier:** Sina beantwortet Vertrags- und Objektfragen
|
||||||
|
**ausschliesslich aus hinterlegten Dokumenten und immer mit Fundstelle** — autonom, aber
|
||||||
|
ausschliesslich lesend.
|
||||||
|
|
||||||
|
> ⚠️ **Vor der Präsentation prüfen:** Die Funktionsbezeichnung in der Oberfläche
|
||||||
|
> («Datenpflege») und das Aufgabenprofil im Personaldossier (dokumentenbasierte
|
||||||
|
> Fragebeantwortung) beschreiben zwei unterschiedliche Schwerpunkte. Wenn jemand nachfragt,
|
||||||
|
> ist die saubere Antwort: Sina ist der lesende Daten- und Dokumentenagent; die Oberfläche
|
||||||
|
> zeigt heute den Datenpflege-Teil, der Dokumenten-Q&A-Teil ist im Rollenprofil beschrieben
|
||||||
|
> und noch nicht als eigener Bildschirm gebaut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Das gemeinsame Bedienmuster
|
||||||
|
|
||||||
|
Alle fünf Agentenseiten sind gleich aufgebaut — das ist eine bewusste Entscheidung, damit die
|
||||||
|
Agenten als *ein Team* wahrgenommen werden und nicht als fünf Werkzeuge.
|
||||||
|
|
||||||
|
**Der Chat-Einstieg** (eine einzige gemeinsame Komponente, `AgentWorkspaceHero`):
|
||||||
|
- Porträt 88 px, Name, Funktionsbezeichnung
|
||||||
|
- Immer dieselbe Frage: **«Wie kann ich dir heute weiterhelfen?»**
|
||||||
|
- Mehrzeiliges Eingabefeld, Platzhalter «Nachricht an {Name} senden»
|
||||||
|
- Optional: zusätzliche Kanäle (z. B. WhatsApp) als Hinweis darunter
|
||||||
|
|
||||||
|
Was bewusst **fehlt** — und warum das ein Argument ist, kein Mangel:
|
||||||
|
kein generischer «AI-Assistent»-Knopf, keine Vorschlagskacheln, kein Verlauf.
|
||||||
|
Der Chat ist agentenspezifisch und bleibt auf der jeweiligen Seite. Die operative
|
||||||
|
Liste beginnt erst unterhalb des Weissraums.
|
||||||
|
|
||||||
|
**Objektverlinkung:** Jeder Objektname auf jeder Agentenseite ist ein Link in «Meine Objekte»
|
||||||
|
(`/supply/properties/{id}`). Alle Verweise zeigen auf **real existierende Objekte im
|
||||||
|
Datenbestand** — es gibt keine erfundenen Objektnamen in den Agentendaten. Das ist per Skript
|
||||||
|
gegen den Objektbestand geprüft worden (IDs und Titel, null Abweichungen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Die Verwaltungsebene — «Agenten als Personal»
|
||||||
|
|
||||||
|
Unter «Meine Agenten» liegen drei Verwaltungsbereiche als Reiter derselben Seite
|
||||||
|
(`/supply/team?section=…`). Sie sind der konzeptionelle Kern des Ansatzes:
|
||||||
|
|
||||||
|
| Reiter | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **Personalverwaltung** | Personalblatt je Agent: Personalnummer, Abteilung, E-Mail, Zweck, Eingangsdaten, Arbeitsablauf, Ergebnisse, Autonomiestufe |
|
||||||
|
| **Bearbeitungsverlauf** | Erledigte Aufträge und pendente Anfragen |
|
||||||
|
| **Kanäle & Systeme** | Welcher Agent hängt an welchem System — mit Verbindungsstatus |
|
||||||
|
|
||||||
|
**Kanäle & Systeme ist der wichtigste Bildschirm für eine kritische Zuhörerschaft**, weil er
|
||||||
|
den Realitätsgrad ehrlich ausweist. Jede Anbindung trägt einen Status:
|
||||||
|
`CONNECTED` / `DISCONNECTED` / `ROADMAP`.
|
||||||
|
|
||||||
|
Erfasste Anbindungen (Auszug mit realem Status):
|
||||||
|
|
||||||
|
| System / Kanal | Status |
|
||||||
|
|---|---|
|
||||||
|
| Microsoft 365 — gemeinsame Postfächer | Verbunden |
|
||||||
|
| WhatsApp Business — Diensthandys | Verbunden |
|
||||||
|
| Kalender der Standortteams | Verbunden |
|
||||||
|
| Dokumentenablage und Standortbibliotheken | Verbunden |
|
||||||
|
| CRM der Vermarktung | Verbunden |
|
||||||
|
| Öffentliche Quellen — SHAB, Zefix, Web | Verbunden |
|
||||||
|
| Microsoft Teams — Kanal Marktbeobachtung | Nicht verbunden |
|
||||||
|
| Bewirtschaftungssystem ImmoTop2 | Nicht verbunden |
|
||||||
|
| Telefonanlage und Diktatlinie | Roadmap |
|
||||||
|
|
||||||
|
Beachte die Formulierung bei der Dokumentenablage — sie beschreibt nicht nur *was*
|
||||||
|
angebunden ist, sondern die Belegpflicht: *«Jede Aussage aus einem Dokument wird mit
|
||||||
|
Dateiname, Seite und Zitat belegt.»*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Technischer Stand
|
||||||
|
|
||||||
|
**Stack:** Vite 8 · React 19 · TypeScript 6 (`strict`) · MUI v9 · Tailwind CSS v4 ·
|
||||||
|
React Router v7 · TanStack Query v5 · Zustand v5 · Zod · Playwright · Vitest
|
||||||
|
|
||||||
|
**Architektur — vier Schichten, keine wird übersprungen:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Provider → Service → Hook (React Query) → Component
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Provider** ist der einzige Code, der Daten anfasst. Jede Entität hat ein Interface
|
||||||
|
(`IPropertyProvider`) und eine Mock-Implementierung (`MockupPropertyProvider`).
|
||||||
|
Eine echte Implementierung (REST, Supabase) lässt sich einsetzen, **ohne eine einzige
|
||||||
|
aufrufende Stelle zu ändern** — das ist der Grund, warum der Mockdaten-Stand kein
|
||||||
|
Wegwerfprototyp ist.
|
||||||
|
- **Service** kapselt Geschäftslogik, kennt kein React.
|
||||||
|
- **Hook** kapselt React Query.
|
||||||
|
- **Component** ist reine Darstellung.
|
||||||
|
|
||||||
|
**Für den Agenten-Ansatz neu gebaute Datenschichten** (je Provider + Service + Hook):
|
||||||
|
Kalendertermine · Exposé-Leads · Exposé-Entwürfe · Besichtigungsaufträge · Agenten-Verzeichnis
|
||||||
|
|
||||||
|
**KI-Anbindung — modellagnostisch:** Der gesamte Zugriff läuft über ein einziges Interface
|
||||||
|
`IAIService` mit rund 15 Fähigkeiten (Bedarf parsen, Match erklären, Zielkonflikte
|
||||||
|
zusammenfassen, Entscheidungsbriefing, Datenqualität bewerten, Marktsignal klassifizieren,
|
||||||
|
Exposé-Text erzeugen …). Kein Bildschirm importiert je einen LLM-Client direkt.
|
||||||
|
|
||||||
|
Zwei Implementierungen:
|
||||||
|
- `MockAIService` — deterministisch, ohne Netzwerk (Entwicklung, CI)
|
||||||
|
- `BackendAIService` — ruft `POST /api/ai/chat/completions` auf dem PowerOn-Backend auf
|
||||||
|
|
||||||
|
**Sicherheitsrelevant und in der Präsentation erwähnenswert:** Der LLM-Schlüssel liegt
|
||||||
|
**ausschliesslich serverseitig**. Die frühere Variante mit einem Schlüssel im Frontend-Bundle
|
||||||
|
wurde entfernt; der Code weist einen Versuch, sie zu nutzen, aktiv mit einer Warnung ab.
|
||||||
|
Fällt das Backend aus, schaltet der Dienst automatisch auf den Mock zurück — **kein
|
||||||
|
KI-Ausfall blockiert einen Arbeitsablauf**.
|
||||||
|
|
||||||
|
**Codequalität, Stand heute:**
|
||||||
|
|
||||||
|
| Kennzahl | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Quelldateien (`src/`) | 719 |
|
||||||
|
| Codezeilen (`src/`) | 80 936 |
|
||||||
|
| Tests | **405 / 405 grün** (25 Testdateien) |
|
||||||
|
| Lint-Fehler | 0 |
|
||||||
|
| Lint-Warnungen | 0 |
|
||||||
|
| `any`-Casts | 0 |
|
||||||
|
| Toter Code | 0 |
|
||||||
|
| Seiten über dem 300-Zeilen-Limit | 0 |
|
||||||
|
| Commits gesamt | 222 |
|
||||||
|
| Deployment | Vercel, Produktion (Branch `Agents2`) |
|
||||||
|
|
||||||
|
Zum Vergleich der Ausgangslage vor der letzten Aufräumrunde: 102 Lint-Fehler,
|
||||||
|
1 144 Warnungen, 31 `any`-Casts, ~1 535 Zeilen toter Code, grösste Seite 1 130 Zeilen
|
||||||
|
(heute 196). Die Aufräumarbeit wurde mit einem Screenshot-Vergleich über 20 Ansichten
|
||||||
|
abgesichert: 14 pixelgleich, 6 mit ausschliesslich den drei beabsichtigten
|
||||||
|
Beschriftungsänderungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Warum dieser Ansatz trägt — die Argumente
|
||||||
|
|
||||||
|
1. **Rollen statt Funktionen.** Ein Bewirtschafter muss nicht lernen, welches KI-Feature was
|
||||||
|
kann. Er muss wissen, wen er fragt. Das ist dieselbe kognitive Leistung wie bei
|
||||||
|
menschlichen Kollegen — und deshalb keine Einführungshürde.
|
||||||
|
|
||||||
|
2. **Abgestufte Autonomie macht Akzeptanz verhandelbar.** «Die KI übernimmt» scheitert an
|
||||||
|
Vertrauen. «Nora schlägt vor, Sie entscheiden; Ferdi erinnert, die Eskalation gibt der
|
||||||
|
Mensch frei» ist verhandelbar — und die Stufe lässt sich pro Agent anheben, wenn sich
|
||||||
|
Vertrauen aufgebaut hat.
|
||||||
|
|
||||||
|
3. **Belegpflicht ist im Produkt verankert, nicht im Marketing.** Fundstelle im Vertrag,
|
||||||
|
Quelle bei der Interessentenrecherche, benannte Behördenquelle beim Exposé-Feld,
|
||||||
|
deterministische Textgenerierung statt frei erfindendem Modell.
|
||||||
|
|
||||||
|
4. **Die Agenten arbeiten einander zu.** Nora erkennt → Mensch leitet weiter → Livia erstellt
|
||||||
|
das Exposé → Livias Lagebericht taucht in Brunos Besichtigungsbriefing auf. Das ist eine
|
||||||
|
Prozesskette, kein Werkzeugkasten.
|
||||||
|
|
||||||
|
5. **Der Mock-Unterbau ist austauschbar, nicht wegzuwerfen.** Wegen der strikten
|
||||||
|
Provider-Schicht ist der Schritt zum Produktivsystem ein Implementierungswechsel hinter
|
||||||
|
einem Interface — nicht ein Neubau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Ehrliche Abgrenzung: was läuft, was ist Demo
|
||||||
|
|
||||||
|
**Diesen Abschnitt bitte in der Präsentation nicht weglassen.** Er ist der Grund, warum das
|
||||||
|
Gezeigte glaubwürdig bleibt.
|
||||||
|
|
||||||
|
### Vollständig gebaut und lauffähig
|
||||||
|
- Alle fünf Agentenarbeitsplätze mit vollständiger Oberfläche und Interaktion
|
||||||
|
- Die drei Verwaltungsbereiche (Personal, Verlauf, Kanäle & Systeme)
|
||||||
|
- Das gesamte Exposé-Dossier mit 11 Bereichen, Pflichtfeldprüfung, Vorschau und Export
|
||||||
|
(HTML / PDF via Druckdialog / Word)
|
||||||
|
- Filtern, Sortieren und direktes Bearbeiten der Objektdaten
|
||||||
|
- Besichtigungs-Vorbereitung und -Nachbereitung inkl. 24-Stunden-Warnung
|
||||||
|
- Mehrfachauswahl und Weiterleitung von Signalen an Livia
|
||||||
|
- Kalender-Terminplanung aus Reminders
|
||||||
|
- Objektverlinkung quer über alle Agentenseiten
|
||||||
|
- Vier Architekturschichten, 405 grüne Tests, produktiv deployt
|
||||||
|
|
||||||
|
### Bewusst als Demo simuliert — mit ehrlicher Rückmeldung an den Nutzer
|
||||||
|
- **Der Chat hat kein Backend.** Beim Absenden erscheint der Hinweis
|
||||||
|
«Nachricht an {Name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.»
|
||||||
|
Das war eine bewusste Entscheidung: eine erfundene Agentenantwort wäre schlimmer als eine
|
||||||
|
ehrliche Rückmeldung.
|
||||||
|
- **Berichtsvorschau, Berichtsdownload und Audiofassung bei Bruno** melden ebenfalls offen,
|
||||||
|
dass sie noch nicht angebunden sind.
|
||||||
|
- **Alle Fachdaten sind Mockdaten** (Objekte, Leads, Besichtigungsaufträge, Reminder,
|
||||||
|
Signale). Sie sind untereinander konsistent und gegen den Objektbestand geprüft, aber sie
|
||||||
|
stammen nicht aus einem Produktivsystem.
|
||||||
|
- **Systemanbindungen** tragen ihren echten Status — ImmoTop2 und Teams sind als *nicht
|
||||||
|
verbunden*, die Telefon-/Diktatlinie als *Roadmap* ausgewiesen.
|
||||||
|
|
||||||
|
### Noch nicht gebaut
|
||||||
|
- Chat-Backend und agentenspezifische Dialogführung
|
||||||
|
- Produktive Anbindung an ImmoTop2, Teams, Telefonanlage/Diktat
|
||||||
|
- Sinas dokumentenbasierte Fragebeantwortung als eigener Bildschirm
|
||||||
|
- Automatisierte Ausführung der Agentenabläufe im Hintergrund (heute wird das Ergebnis
|
||||||
|
dargestellt, nicht der Lauf ausgelöst)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Offene Punkte / Kandidaten für die Roadmap-Folie
|
||||||
|
|
||||||
|
1. **Chat-Backend** — der grösste sichtbare Schritt zum Produktiverlebnis
|
||||||
|
2. **ImmoTop2-Anbindung** — ohne sie bleibt Ferdis Terminbestand ein Import
|
||||||
|
3. **Hintergrundausführung der Agentenläufe** (Zeitpläne, Auslöser, Protokoll)
|
||||||
|
4. **Sina als Dokumenten-Q&A** mit Fundstellenanzeige
|
||||||
|
5. Feinschliff: 23 Komponenten liegen zwischen 250 und 325 Zeilen (über dem weichen
|
||||||
|
250-Zeilen-Ziel; alle *Seiten* liegen unter dem harten 300-Zeilen-Limit)
|
||||||
|
6. Oberflächen-Testabdeckung ist dünn (4 UI-Tests bei 404 Komponenten; die 405 Tests decken
|
||||||
|
vor allem Logik-, Service- und Scoring-Schichten ab)
|
||||||
|
7. Der Produktionsbetrieb läuft auf Branch `Agents2`; `main` ist noch nicht nachgezogen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Vorschlag für den Präsentationsbogen
|
||||||
|
|
||||||
|
| # | Folie | Kernaussage |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Ausgangslage | Fristen, Exposés, Besichtigungen und Datenpflege sind Handarbeit, verteilt über Systeme |
|
||||||
|
| 2 | Der Ansatz | Nicht ein Assistent — **fünf digitale Mitarbeitende** mit Rolle, Abteilung und Autonomiestufe |
|
||||||
|
| 3 | Das Team | Die Fünfer-Tabelle aus Abschnitt 2 |
|
||||||
|
| 4 | Autonomie als Vertrauensmodell | Drei Stufen, je Agent begründet — die Stufe wächst mit dem Vertrauen |
|
||||||
|
| 5 | **Live-Demo: die Kette** | Nora erkennt Signal → Weiterleitung → Livia erstellt Exposé → Export |
|
||||||
|
| 6 | Live-Demo: Tiefe | Livias 11 Bereiche mit Schweizer Fachnormen und benannten Behördenquellen |
|
||||||
|
| 7 | Live-Demo: Verlässlichkeit | Brunos Interessentenrecherche — jede Angabe mit Quelle, sonst gar nicht |
|
||||||
|
| 8 | Agenten als Personal | Personalverwaltung + Kanäle & Systeme mit echtem Verbindungsstatus |
|
||||||
|
| 9 | Technischer Stand | 719 Dateien, 405 grüne Tests, 0 Lint-Fehler, produktiv deployt, austauschbare Datenschicht |
|
||||||
|
| 10 | Was Demo ist | Abschnitt 8 — offen benannt |
|
||||||
|
| 11 | Nächste Schritte | Abschnitt 9 |
|
||||||
|
|
||||||
|
**Der stärkste Demo-Moment** ist Folie 5: die Übergabe von Nora an Livia. Sie zeigt in
|
||||||
|
dreissig Sekunden, dass es sich um ein zusammenarbeitendes Team handelt und nicht um fünf
|
||||||
|
nebeneinanderstehende Werkzeuge.
|
||||||
|
|
||||||
|
**Der stärkste Vertrauensmoment** ist Folie 7: dass Bruno lieber «nichts Belegbares gefunden»
|
||||||
|
sagt, als etwas Plausibles zu erfinden.
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
# Livia als Exposé Master — Gesamtprofil und Prompt (Stand vor Runde 8)
|
||||||
|
|
||||||
|
> **Zweck:** Vollständige, wiederverwendbare Fallakte der digitalen Mitarbeiterin **Livia**
|
||||||
|
> («Exposé Master», Property On / Repository `property-match`), Stand **21. August 2026**.
|
||||||
|
>
|
||||||
|
> Teil A beschreibt Livia so, wie sie heute im Code steht — jede Angabe ist aus dem Quellcode
|
||||||
|
> ausgelesen, nicht aus Konzeptpapieren. Teil B enthält den fertigen Prompt, mit dem sich Livia
|
||||||
|
> in einem beliebigen KI-Kontext (Claude, ChatGPT, Backend-LLM) rekonstruieren lässt.
|
||||||
|
>
|
||||||
|
> Wird der Case erneut gebraucht: Teil A ist die Faktenbasis für Präsentation und Diskussion,
|
||||||
|
> Teil B kopiert man unverändert als System-Prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Teil A — Gesamtprofil
|
||||||
|
|
||||||
|
## A1. Personalblatt
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Name | **Livia** |
|
||||||
|
| Funktionsbezeichnung | Exposé Master |
|
||||||
|
| Personalnummer | **PO-ZD-21** |
|
||||||
|
| Abteilung | Vermarktung |
|
||||||
|
| E-Mail | `livia@property-on.ch` |
|
||||||
|
| Status | Aktiv |
|
||||||
|
| **Autonomiestufe** | **Autonom** (`AgentAutonomyLevel.AUTONOMOUS`) |
|
||||||
|
| Arbeitsplatz (Route) | `/supply/my-listings` |
|
||||||
|
| Position im Team | 3 von 5 (Ferdi · Bruno · **Livia** · Nora · Sina) |
|
||||||
|
| Porträt | `src/assets/team/livia.jpg` |
|
||||||
|
| Letzter Lauf (Mock) | 20.05.2026, 06:00 |
|
||||||
|
|
||||||
|
**Kurzbeschreibung (UI-Text, wörtlich):**
|
||||||
|
> «Erstellt Lageberichte, Inserate sowie Angebots- und Offertenbroschüren aus den Daten und
|
||||||
|
> Bildern aus ‹Meine Objekte›.»
|
||||||
|
|
||||||
|
**Begründung der Autonomiestufe (UI-Text, wörtlich):**
|
||||||
|
> «Autonom. Livia recherchiert, schreibt und frischt Lageberichte, Inserate und Exposés ohne
|
||||||
|
> vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die
|
||||||
|
> Vermarktung jeden Satz nachprüfen kann.»
|
||||||
|
|
||||||
|
Die Autonomie ist vertretbar, weil Livias Ergebnis (Exposé-Entwurf, Lagebericht) dem Menschen
|
||||||
|
vorgelegt wird, **bevor** es nach aussen geht. Sie publiziert nichts selbst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A2. Zweck, Eingang, Ablauf, Ergebnis
|
||||||
|
|
||||||
|
**Zweck:**
|
||||||
|
Livia macht aus Objektdaten, Lageinformationen und vorhandenen Bildern ein verkaufsfähiges
|
||||||
|
Dokument: Lagebericht, Inserat oder vollständige Angebots- beziehungsweise Offertenbroschüre.
|
||||||
|
Grundlage sind **ausschliesslich** die Objekte aus «Meine Objekte».
|
||||||
|
|
||||||
|
**Eingangsdaten:**
|
||||||
|
1. Objektdaten und Objektbilder aus «Meine Objekte»
|
||||||
|
2. Leads, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat
|
||||||
|
3. Objektadresse aus dem Vermarktungsauftrag
|
||||||
|
4. Öffentliche Umfeld- und Verkehrsdaten zu Quartier, Anbindung und Nahversorgung
|
||||||
|
5. Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Umfeldentwicklungen
|
||||||
|
6. Bestehende Objektunterlagen aus der Dokumentenablage
|
||||||
|
|
||||||
|
**Arbeitsablauf (Kernfluss):**
|
||||||
|
1. Adresse aufnehmen und dem richtigen Quartier zuordnen (z. B. Zürich Kreis 5, Winterthur Neuhegi)
|
||||||
|
2. Umfeld im festgelegten Radius abgehen: Nachbarschaft, Erdgeschossnutzungen, Erreichbarkeit, Einkaufen, Verpflegung
|
||||||
|
3. Gemeinde und Region einordnen: Wirtschaftsstruktur, Steuerlage, Entwicklung der Nachfrage
|
||||||
|
4. Amtliche Publikationen und Bauprojekte sichten und auf Relevanz fürs Objekt prüfen
|
||||||
|
5. Kurztext fürs Inserat und Langtext fürs Exposé schreiben
|
||||||
|
6. Quellen anhängen, Ergebnis je Objekt ablegen, quartalsweise gegen den aktuellen Stand prüfen
|
||||||
|
|
||||||
|
**Ergebnisse:**
|
||||||
|
- Lagebericht mit Mikro-, Makro- und Umfeldteil
|
||||||
|
- Kurztext für Inserat und Portalauftritt
|
||||||
|
- Angebots- beziehungsweise Offertenbroschüre als PDF oder Word
|
||||||
|
- Quellenverzeichnis mit Abrufdatum je Aussage
|
||||||
|
- Änderungshinweise, sobald sich das Umfeld spürbar verändert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A3. Verantwortlichkeiten (8 Punkte, wörtlich aus dem Dossier)
|
||||||
|
|
||||||
|
1. Lageberichte für kommerziell genutzte Immobilien erstellen und nachvollziehbar belegen
|
||||||
|
2. Inserate aus den Objektdaten in «Meine Objekte» erstellen
|
||||||
|
3. Angebots- und Offertenbroschüren aus Objektdaten, Lageinformationen und vorhandenen Bildern erstellen
|
||||||
|
4. Weitergeleitete Leads von Nora zügig zu einem Exposé verarbeiten
|
||||||
|
5. Erreichbarkeit, Nahversorgung und geplante Umfeldentwicklungen sauber trennen und darstellen
|
||||||
|
6. Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen
|
||||||
|
7. Fehlende Pflichtangaben als Lücke ausweisen, statt plausible Werte zu erfinden
|
||||||
|
8. Lageberichte quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A4. Aufgabenkatalog — 15 Aufgaben
|
||||||
|
|
||||||
|
Alle Aufgaben sind aktiviert, keine benötigt eine Freigabe (`requiresApproval: false`).
|
||||||
|
|
||||||
|
| # | ID | Aufgabe | Auslöser / Rhythmus | System |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `livia-task-01` | Objektadresse aufnehmen — Strasse, Nr., PLZ, Ort aus dem Vermarktungsauftrag, Schreibweise gegen amtliche Adressdaten prüfen, Quartier zuordnen | bei Eingang eines Vermarktungsauftrags | ImmoTop2 |
|
||||||
|
| 2 | `livia-task-02` | Mikrolage analysieren — Nachbarschaft, Erdgeschossnutzungen, Lärm/Immissionen, Publikumsfrequenz, Parkierung; Fakten von Einschätzungen trennen | nach der Adressaufnahme | Öffentliches Web |
|
||||||
|
| 3 | `livia-task-03` | Makrolage einordnen — Bevölkerung, Beschäftigte, Branchenmix, Steuerlage, Bedeutung in der Region | nach der Mikrolage | Öffentliches Web |
|
||||||
|
| 4 | `livia-task-04` | Erreichbarkeit und Nahversorgung darstellen — ÖV, Autobahn, Velo/Fuss, Einkaufen, Verpflegung, Betreuung, Gesundheit; **mit Distanz oder Wegzeit statt Werbeaussagen** | nach der Makrolage | Öffentliches Web |
|
||||||
|
| 5 | `livia-task-05` | Geplante Projekte und Umfeldentwicklungen aufnehmen — Arealentwicklungen, Zonenänderungen, Verkehrsvorhaben, neue Haltestellen; **ab wann spürbar** | montags 07:00 | SHAB |
|
||||||
|
| 6 | `livia-task-06` | Kurzen Inserattext erstellen — innerhalb Zeichenlimit, in gewählter Firmen-Tonalität | sobald alle Lagebausteine vorliegen | — |
|
||||||
|
| 7 | `livia-task-07` | Ausführlichen Exposé-Text erstellen — Mikrolage, Makrolage, Erreichbarkeit, Nahversorgung, Umfeldentwicklung in **getrennten Abschnitten** | direkt nach dem Kurztext | — |
|
||||||
|
| 8 | `livia-task-08` | Quellen hinterlegen — Bezeichnung + Abrufdatum je Aussage. **Nicht belegbar = gestrichen, nicht abgeschwächt** | bei jeder Texterstellung | — |
|
||||||
|
| 9 | `livia-task-09` | Ergebnisse je Objekt lokal zwischenspeichern — Kurztext, Langtext, Quellenverzeichnis; nächster Auftrag setzt darauf auf | nach jedem abgeschlossenen Auftrag | DMS |
|
||||||
|
| 10 | `livia-task-10` | Quartalsweisen Aktualisierungslauf durchführen — veraltete Abschnitte markieren und erneuern, protokolliert je Objekt | quartalsweise, 1. Werktag 05:30 | DMS |
|
||||||
|
| 11 | `livia-task-11` | Relevante Änderungen melden — nur was den Lagebeschrieb wirklich verändert, mit betroffenem Textabschnitt und Quelle | bei erkannter Abweichung | Arbeitsbereich-Chat |
|
||||||
|
| 12 | `livia-task-12` | Weitergeleiteten Lead aufnehmen — von Nora erkannt, durch Bewirtschafter weitergeleitet, samt Objektempfehlung | bei Weiterleitung | Arbeitsbereich-Chat |
|
||||||
|
| 13 | `livia-task-13` | Objektbilder aus «Meine Objekte» importieren — ohne Doppelimporte, mit Kategorie, Reihenfolge, Titelbild | beim Start eines Exposé-Auftrags | ImmoTop2 |
|
||||||
|
| 14 | `livia-task-14` | Exposé-Felder aus Objektdaten befüllen — Eckdaten, Lage, Flächen, Preise, Ausstattung, Energie. **Fehlende Pflichtfelder bleiben leer und werden markiert** | nach dem Bildimport | ImmoTop2 |
|
||||||
|
| 15 | `livia-task-15` | Angebots-/Offertenbroschüre erstellen — in Firmen-CI, Export als PDF und Word. **Speichern und Erstellen bleiben getrennte Schritte** | auf Auslösung im Exposé-Arbeitsbereich | DMS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A5. Kanäle
|
||||||
|
|
||||||
|
| Kanal | Richtung | Status | Konfiguration | Zweck |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Arbeitsbereich-Chat** | ein- und ausgehend | verbunden | Anzeigename «Livia — Lage & Umfeld», Postfach `arbeitsbereich/vermarktung/lage`, Empfänger `vermarktung@property-on.ch`, Auto-Antwort **an** | Objektadressen und Rückfragen entgegennehmen, Kurztext/Langtext/Änderungshinweise liefern |
|
||||||
|
| **E-Mail** | nur ausgehend | verbunden | Absender `livia@property-on.ch`, Empfänger `vermarktung@` + `bewirtschaftung@property-on.ch`, Auto-Antwort **aus** | Versand fertiger Lagebeschriebe und der Quartalsmeldung. **Eingehende Mails werden nicht bearbeitet.** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A6. Systeme und Berechtigungen
|
||||||
|
|
||||||
|
| System | Zugriff | Status | Nutzung | Berechtigungsvermerk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Öffentliches Web** | lesend | verbunden | Haltestellen, Fahrpläne, Wegzeiten, Einkaufen/Verpflegung, Gemeindeangaben zu Bevölkerung und Beschäftigung | Nur lesend, ausschliesslich frei zugängliche Seiten |
|
||||||
|
| **SHAB** (amtliche Publikationen) | lesend | verbunden | Baugesuche, Zonenänderungen, Arealentwicklungen im Umfeld | Nur lesend, Meldungen werden als Quelle mit Publikationsdatum übernommen |
|
||||||
|
| **ImmoTop2** (Bewirtschaftung) | lesend | verbunden | Objektstammdaten und Adressen als Ausgangspunkt jedes Lagebeschriebs | Nur lesend, **keine Änderung an Stammdaten** |
|
||||||
|
| **DMS / Dokumentenablage** | lesen + schreiben | verbunden | Ablage Kurztext, Langtext, Quellenverzeichnis; Zwischenstand für den Quartalslauf | Schreibt **ausschliesslich** in den Ordner «Lagebeschriebe» des jeweiligen Objekts; bestehende Fassungen bleiben als Version erhalten |
|
||||||
|
| **Arbeitsbereich (Workspace)** | lesen + schreiben | verbunden | Aufträge, Rückfragen, Änderungshinweise der Vermarktung inkl. Protokoll der Aktualisierungsläufe | Schreibt nur in den eigenen Bereich «Lage & Umfeld»; Objektdossiers anderer Teams bleiben unberührt |
|
||||||
|
|
||||||
|
**Merksatz für kritische Nachfragen:** Livia hat genau **ein** Schreibrecht in Fremdsystemen —
|
||||||
|
den Ordner «Lagebeschriebe» im DMS. Alles andere ist lesend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A7. Einstellungen (in der Personalverwaltung veränderbar)
|
||||||
|
|
||||||
|
| Einstellung | Typ | Standardwert | Bereich / Optionen |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Kurztext-Zeichenlimit | Zahl | **600 Zeichen** | 200 – 1 200 |
|
||||||
|
| Langtext-Zeichenlimit | Zahl | **2 400 Zeichen** | 800 – 5 000 |
|
||||||
|
| Firmen-Tonalität | Auswahl | **sachlich** | sachlich · hochwertig · zurückhaltend |
|
||||||
|
| Aktualisierungsintervall | Auswahl | **quartalsweise** | monatlich · quartalsweise · halbjährlich · jährlich |
|
||||||
|
| Radius der Umfeldanalyse | Zahl | **800 Meter** | 200 – 3 000 |
|
||||||
|
| Relevante Quellentypen | Mehrfachauswahl | amtliche Publikationen, Verkehrsdaten, Bauprojekte, Nahversorgung | zusätzlich: Medienberichte |
|
||||||
|
|
||||||
|
> Hinweis: Im Exposé-Arbeitsbereich heisst die Tonalität `SACHLICH` / `HOCHWERTIG` / `EINLADEND`
|
||||||
|
> (Beschriftungen: Sachlich, Hochwertig, Einladend). Die Personalverwaltung führt als dritte
|
||||||
|
> Option «zurückhaltend». Wer den Case erneut aufsetzt, sollte diese beiden Listen angleichen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A8. Kennzahlen (Mockstand)
|
||||||
|
|
||||||
|
| Kennzahl | Wert | Erläuterung |
|
||||||
|
|---|---|---|
|
||||||
|
| **Analysierte Objekte** (Leitkennzahl) | 184 | Objekte mit vollständigem Lagebeschrieb, u. a. Zürich, Winterthur, Zug, St. Gallen |
|
||||||
|
| Aktuelle Lagebausteine | 162 | Im letzten Aktualisierungslauf gegen die Quellen bestätigt |
|
||||||
|
| Fällige Aktualisierungen | 9 | Werden im nächsten Quartals-Refresh erneuert |
|
||||||
|
| Erkannte Umfeldänderungen | 14 | Seit dem letzten Lauf gemeldet, z. B. Arealentwicklung Winterthur Neuhegi |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A9. Livias Arbeitsplatz in der Oberfläche
|
||||||
|
|
||||||
|
**Route:** `/supply/my-listings` — Menüpunkt unter «Meine Agenten».
|
||||||
|
|
||||||
|
**Aufbau:**
|
||||||
|
1. **Chat-Einstieg** (gemeinsame Komponente `AgentWorkspaceHero`): Porträt 88 px, Name,
|
||||||
|
Funktionsbezeichnung, immer dieselbe Frage «Wie kann ich dir heute weiterhelfen?»,
|
||||||
|
mehrzeiliges Eingabefeld mit Platzhalter «Nachricht an Livia senden».
|
||||||
|
2. **Leadliste** mit den Reitern *aktiv* / *archiviert*. Leads stammen aus Noras Signalen,
|
||||||
|
sobald ein Bewirtschafter sie zur Exposé-Erstellung weitergeleitet hat.
|
||||||
|
3. Beim Öffnen eines Leads gleitet die Zeile nach oben, darunter klappt der **dreistufige
|
||||||
|
Arbeitsbereich** auf. Empfiehlt ein Lead mehrere Objekte, bekommt jedes einen eigenen Reiter
|
||||||
|
— **ein Exposé beschreibt genau ein Objekt**.
|
||||||
|
|
||||||
|
**Der Drei-Schritt-Ablauf:** `Hochladen → Exposé → Export`
|
||||||
|
|
||||||
|
| Schritt | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **1 Hochladen** | Objektdaten und Bilder aus «Meine Objekte» vorbefüllen, zusätzliche Unterlagen hochladen. Bildimport ohne Doppel, erstes Bild wird Titelbild. |
|
||||||
|
| **2 Exposé** | Dossier aus 11 fachlichen Bereichen (siehe A10), Pflichtfeldprüfung, KI-Textentwurf je Textfeld |
|
||||||
|
| **3 Export** | HTML-Vorschau, PDF (über den Druckdialog), Word (`.doc`) |
|
||||||
|
|
||||||
|
**Getrennte Aktionen:** *Speichern* hält den Zwischenstand fest, *Erstellen* prüft die
|
||||||
|
Vollständigkeit und schaltet den Export frei. Der Entwurf lebt lokal, solange gearbeitet wird —
|
||||||
|
bei jedem Tastendruck zu speichern hiesse, jeden Zwischenstand zum gültigen Stand zu erklären.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A10. Das Exposé-Dossier — 11 Bereiche
|
||||||
|
|
||||||
|
Einzige Quelle für Formular, Pflichtfeldprüfung, Vorschau und Export: `src/lib/exposeFields.ts`.
|
||||||
|
|
||||||
|
| # | Bereich | Felder | Genannte Datenquellen |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Eckdaten & Vermarktung** | Objektart\*, Subtyp, Vermarktungsart\*, Status, Objektreferenz | — |
|
||||||
|
| 2 | **Lage** | Strasse\*, Nr., PLZ\*, Ort\*, Gemeinde, Kanton, Land | — |
|
||||||
|
| 3 | **Koordinaten & Distanzen** | Breiten-/Längengrad, ÖV-Haltestelle (m), Einkauf (m), Schule (m), Autobahn (m) | Swisstopo, ÖV-Fahrplan, OpenStreetMap |
|
||||||
|
| 4 | **Gebäude- & Gemeindedaten** | ÖV-Güteklasse, Solareignung Dach, Einwohner, Bevölkerungswachstum (%/J), Steuerbelastung (%), Leerwohnungsziffer (%) | ARE, BFE Sonnendach, BFS, ESTV |
|
||||||
|
| 5 | **Flächen & Gebäude** | Zimmer, Schlafzimmer, Badezimmer, Sep. WC, Wohnfläche, **Nutzfläche\***, Grundstücksfläche, Balkon/Terrasse, Keller, Raumhöhe, **Kubatur SIA 416** | — |
|
||||||
|
| 6 | **Gebäude & Verfügbarkeit** | Stockwerk, Geschosse, Baujahr, Letzte Renovation, Zustand, **Verfügbarkeit\*** | — |
|
||||||
|
| 7 | **Grundstück & Baurecht** | Nutzungszone, Überbauungsziffer, Ausnützungsziffer | geodienste / Swisstopo |
|
||||||
|
| 8 | **Preise & Kosten** | **Nettomiete\*** (CHF/m²/J), Nebenkosten, NK-Art, Kaution, Befristung, Parkierung, Anzahl Plätze, Mietpreis Parkplatz | — |
|
||||||
|
| 9 | **Ausstattung & Merkmale** | Aussenbereich, Energie & Smart (PV, Wärmepumpe, E-Ladestation, Smart Building, Glasfaser), Gebäude (Lift, Minergie, Erstbezug …), Innenausbau (Teeküche, Klima, Serverraum …), Parkierung, Umgebung & Sicht (Seesicht, Bergsicht …), weitere Merkmale | — |
|
||||||
|
| 10 | **Energie & Technik** | Heizsystem, Wärmeverteilung, GEAK, Photovoltaik (kWp), E-Ladestationen | — |
|
||||||
|
| 11 | **Texte & Beschriebe** | **Exposé-Titel\***, **Kurzbeschrieb\***, **Objektbeschrieb\***, Lagebeschrieb, Gemeindebeschrieb, Ausstattungsbeschrieb, Highlights, Video-URL, Rundgang-URL | **KI-Textentwurf** |
|
||||||
|
|
||||||
|
\* = Pflichtfeld. **Neun Pflichtfelder** gesamt: `objektart`, `vermarktungsart`, `strasse`, `plz`,
|
||||||
|
`ort`, `flaeche`, `verfuegbarkeit`, `nettomiete`, `exposeTitel`, `kurzbeschrieb`, `objektbeschrieb`.
|
||||||
|
Fehlt eines, wird das Feld rot umrandet.
|
||||||
|
|
||||||
|
> Begründung im Code, wörtlich: *«Eine Broschüre ohne Kubatur ist verkaufbar, eine ohne
|
||||||
|
> Mietpreis nicht.»*
|
||||||
|
|
||||||
|
**Schweizer Fachnormen sind eingebaut** — Kubatur nach SIA 416, GEAK, Minergie, ÖV-Güteklasse
|
||||||
|
nach ARE, Leerwohnungsziffer. Alle behördlichen Datenquellen sind benannt, und **die Werte
|
||||||
|
bleiben überschreibbar** — der Mensch behält das letzte Wort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A11. Vorbelegung aus «Meine Objekte»
|
||||||
|
|
||||||
|
`prefillFromProperty()` in `src/services/exposeService.ts` übernimmt genau diese Felder aus dem
|
||||||
|
Objektdatensatz:
|
||||||
|
|
||||||
|
| Exposé-Feld | Quelle im Objekt |
|
||||||
|
|---|---|
|
||||||
|
| `objektart` | `assetType` → `ASSET_TYPE_LABELS` |
|
||||||
|
| `vermarktungsart` / `status` | fest «Miete» / «Aktiv in Vermarktung» |
|
||||||
|
| `objektreferenz` | `propertyNumber` |
|
||||||
|
| `strasse`, `hausnummer`, `plz`, `ort`, `land` | `address.*` |
|
||||||
|
| `gemeinde`, `kanton` | `location.*` |
|
||||||
|
| `breitengrad`, `laengengrad` | `location.coordinates` |
|
||||||
|
| `flaeche` | `areaSqm` |
|
||||||
|
| `raumhoehe` | `hardFacts.ceilingHeightM` |
|
||||||
|
| `stockwerk` | `hardFacts.floor` ?? `floorLevel` |
|
||||||
|
| `verfuegbarkeit` | `availabilityDate` (Format de-CH) |
|
||||||
|
| `nettomiete`, `nebenkosten` | `rentPricePerSqm`, `ancillaryCosts` |
|
||||||
|
| `parkplaetze` | `hardFacts.parking` ?? `softFactors.parkingSpots` |
|
||||||
|
| `objektbeschrieb` | `description` |
|
||||||
|
| `merkmale` | `hardFacts.fitOut` → «Ausbaustandard …» |
|
||||||
|
|
||||||
|
> Begründung im Code, wörtlich: *«Ein vorbelegtes ‹ca. 3.0 m Raumhöhe› wäre eine Behauptung,
|
||||||
|
> keine Angabe.»* Alles ohne Entsprechung bleibt leer — und genau das erzeugt die roten
|
||||||
|
> Umrandungen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A12. Die Textmaschine — das härteste Prinzip des Cases
|
||||||
|
|
||||||
|
Livias Texterstellung läuft über `IAIService.generateExposeText()`. Sieben Abschnitte lassen
|
||||||
|
sich einzeln erzeugen:
|
||||||
|
|
||||||
|
| Abschnitt | Feld | Notwendige Angaben — fehlt eine, entsteht **kein** Text |
|
||||||
|
|---|---|---|
|
||||||
|
| `TITLE` | Exposé-Titel | objektart, ort |
|
||||||
|
| `TEASER` | Kurzbeschrieb | objektart, flaeche, ort |
|
||||||
|
| `OBJECT` | Objektbeschrieb | objektart, flaeche, ausbaustandard |
|
||||||
|
| `LOCATION` | Lagebeschrieb | strasse, ort |
|
||||||
|
| `MUNICIPALITY` | Gemeindebeschrieb | ort |
|
||||||
|
| `FEATURES` | Ausstattungsbeschrieb | ausbaustandard |
|
||||||
|
| `HIGHLIGHTS` | Highlights | objektart, ort |
|
||||||
|
|
||||||
|
**Die Regel:** Fehlt eine Grundangabe, gibt der Dienst einen **leeren Text** zurück und nennt die
|
||||||
|
fehlenden Angaben in `missingFacts`. Die Oberfläche markiert sie rot.
|
||||||
|
|
||||||
|
> Wörtlich aus `exposeTextBuilder.ts`: *«Was fehlt, wird nicht plausibel ergänzt, sondern in
|
||||||
|
> `missingFacts` genannt — die Oberfläche markiert es rot, statt einen glatten Text zu zeigen,
|
||||||
|
> der nachher nicht stimmt.»* Und: *«Fehlt eine Grundangabe, wird kein Text behauptet — die
|
||||||
|
> Lücke ist die Antwort.»*
|
||||||
|
|
||||||
|
**Technisch bewusst deterministisch:** Der Textentwurf wird im Backend-Dienst an einen
|
||||||
|
deterministischen Baustein delegiert, **damit kein Sprachmodell fehlende Fakten erfindet**. Das
|
||||||
|
ist die direkte technische Umsetzung des Trust-first-Prinzips — und der stärkste Punkt, wenn im
|
||||||
|
Gespräch «Halluzinationen» aufkommen.
|
||||||
|
|
||||||
|
`textFactsFrom()` reicht dem Textbaustein **nur nicht-leere Formularwerte** durch — es gibt
|
||||||
|
keinen zweiten Weg, an Fakten zu kommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A13. Zusammenarbeit mit den anderen Agenten
|
||||||
|
|
||||||
|
```
|
||||||
|
Nora erkennt Marktsignal (SHAB, Zefix, Web)
|
||||||
|
↓ Mensch wählt aus und leitet weiter (Autonomiestufe «Vorschlag»)
|
||||||
|
Livia nimmt Lead auf → Objekt aus «Meine Objekte» → Exposé + Lagebericht
|
||||||
|
↓ Agent-zu-Agent-Zulieferung
|
||||||
|
Bruno übernimmt Livias Lagebericht ins Besichtigungsbriefing
|
||||||
|
```
|
||||||
|
|
||||||
|
Das ist der beste Demo-Moment der Präsentation: die sichtbare Übergabe zwischen zwei Agenten
|
||||||
|
per Mehrfachauswahl. Die Agenten stehen nicht nebeneinander, sie arbeiten einander zu.
|
||||||
|
|
||||||
|
Ergänzend: **Sina** hält die Objektdaten sauber, aus denen Livia vorbefüllt — schlechte
|
||||||
|
Objektdaten schlagen unmittelbar auf Livias Exposé durch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A14. Beispielfall aus dem Bestand (für Demo und Nacherzählung)
|
||||||
|
|
||||||
|
**`awi-livia-01` — Lagebeschrieb Hardturmstrasse 118, 8005 Zürich (Bürofläche 640 m², Kreis 5)**
|
||||||
|
|
||||||
|
- Auftrag am 09.04.2026, 09:15 eingegangen, um 11:40 abgeschlossen — **2 h 25 min**
|
||||||
|
- Untersuchungsradius Mikrolage: 500 m; Auftraggeberin: Nadine Brunner, Vermarktung Zürich
|
||||||
|
- **Fünf protokollierte Verarbeitungsschritte:** Adresse zugeordnet → Mikrolage abgegangen →
|
||||||
|
Makrolage eingeordnet → amtliche Publikationen gesichtet → Kurz- und Langtext geschrieben
|
||||||
|
- Ergebnis: Kurztext für Portal, Langtext 3 940 Zeichen, Erreichbarkeit mit Metern und Gehminuten
|
||||||
|
(Escher-Wyss-Platz 300 m / 4 min, Hardbrücke 650 m / 8 min, Autobahn 1,4 km), Nahversorgung
|
||||||
|
(2 Supermärkte < 400 m, 14 Gastrobetriebe < 300 m, 2 Kitas < 600 m), Umfeldentwicklung
|
||||||
|
(Hardturm-Areal in Etappen bis 2029, Zonenänderung rechtskräftig seit 2024)
|
||||||
|
- **11 Quellen mit Abrufdatum**, je Aussage zugeordnet
|
||||||
|
- Nachgelagert: Am 14.04.2026 hat Nadine Brunner den Kurztext **unverändert** im Portal publiziert
|
||||||
|
|
||||||
|
Der zweite Fall `awi-livia-02` liegt bewusst als **pendentes Review eines unsicheren Ergebnisses**
|
||||||
|
vor — er zeigt, dass Livia auch im autonomen Betrieb Zweifel sichtbar macht, statt sie zu glätten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A15. Wo Livia im Code steht
|
||||||
|
|
||||||
|
| Was | Datei |
|
||||||
|
|---|---|
|
||||||
|
| Personalblatt / Dossier | `src/mock-data/agents/livia.ts` (416 Zeilen) |
|
||||||
|
| Team-Registrierung, `AGENT_IDS.LIVIA` | `src/mock-data/teamAgents.ts` |
|
||||||
|
| Arbeitsplatz-Zuordnung | `src/lib/agentWorkspaces.ts` |
|
||||||
|
| Route `AGENT_LIVIA` | `src/lib/constants.ts` → `/supply/my-listings` |
|
||||||
|
| Seite | `src/pages/supply/MyListings.tsx` (113 Zeilen) |
|
||||||
|
| Leadliste / Arbeitsbereich | `src/components/expose/LeadTable.tsx`, `LeadWorkspace.tsx` |
|
||||||
|
| Drei-Schritt-Assistent | `src/components/expose/ExposeWizard.tsx` |
|
||||||
|
| Feldgruppen, Medien | `src/components/expose/ExposeFieldGroup.tsx`, `ExposeMediaManager.tsx` |
|
||||||
|
| 11 Bereiche + Pflichtfelder | `src/lib/exposeFields.ts` |
|
||||||
|
| Domänentypen | `src/domain/expose.ts`, `src/domain/exposeLead.ts` |
|
||||||
|
| Services | `src/services/exposeService.ts`, `exposeLeadService.ts`, `exposeExport.ts` |
|
||||||
|
| Hooks | `src/hooks/useExpose.ts` |
|
||||||
|
| KI-Schnittstelle | `src/services/ai/IAIService.ts` (`ExposeTextSection`, `ExposeTonality`) |
|
||||||
|
| Deterministischer Textbaustein | `src/services/ai/mock/exposeTextBuilder.ts` |
|
||||||
|
| Aufträge / Protokoll | `src/mock-data/agentWorkItems.ts` (`awi-livia-01`, `awi-livia-02`), `agentProtocol.ts` |
|
||||||
|
| Porträt | `src/assets/team/livia.jpg`, registriert in `src/components/team/agentPhotos.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A16. Ehrliche Abgrenzung — was läuft, was Demo ist
|
||||||
|
|
||||||
|
**Gebaut und lauffähig:**
|
||||||
|
- Leadliste mit aktiv/archiviert, Objektauswahl je Lead
|
||||||
|
- Das gesamte Exposé-Dossier mit 11 Bereichen, Pflichtfeldprüfung und roten Umrandungen
|
||||||
|
- Vorbelegung und Bildimport aus «Meine Objekte», Doppelimport-Schutz, Titelbild
|
||||||
|
- KI-Textentwurf je Abschnitt inkl. `missingFacts`-Rückmeldung
|
||||||
|
- Vorschau (HTML), Export als PDF (Druckdialog) und Word (`.doc`)
|
||||||
|
- Personalblatt, Bearbeitungsverlauf und «Kanäle & Systeme» in der Personalverwaltung
|
||||||
|
|
||||||
|
**Bewusst als Demo simuliert — mit ehrlicher Rückmeldung an den Nutzer:**
|
||||||
|
- **Der Chat hat kein Backend.** Beim Absenden erscheint: «Nachricht an Livia vorgemerkt — der
|
||||||
|
Chat ist in dieser Demo noch nicht angebunden.» Eine erfundene Agentenantwort wäre schlimmer
|
||||||
|
als eine ehrliche Rückmeldung.
|
||||||
|
- Alle Fachdaten (Leads, Objekte, Aufträge, Kennzahlen) sind Mockdaten.
|
||||||
|
- Die Anbindungen ImmoTop2 und Microsoft Teams stehen im System auf «nicht verbunden» — die
|
||||||
|
Statusangabe ist Teil des Produkts, nicht kaschiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A17. Stand seit Runde 8 — was aus dieser Fassung geworden ist
|
||||||
|
|
||||||
|
**Dieses Dokument beschreibt Livia als Exposé Master, also den Stand vor dem 23.08.2026.**
|
||||||
|
Mit Optimierungsrunde 8 ist Livia zur **Research & Market Intelligence** geworden: Sie
|
||||||
|
beobachtet den Markt ausserhalb des eigenen Bestands und verdichtet externe Meldungen zu
|
||||||
|
Chance- und Risiko-Leads. Die hier beschriebene Exposé-Funktion ist dabei **vollständig aus
|
||||||
|
der Anwendung entfernt** worden — Personalblatt, Arbeitsplatz, Dossier mit elf Bereichen,
|
||||||
|
Textmaschine und Export.
|
||||||
|
|
||||||
|
Es gibt damit zwei Livias mit demselben Namen. Wer diesen Text liest, liest die frühere.
|
||||||
|
|
||||||
|
Für den Wiederaufbau liegt in [`archive/livia/`](./archive/livia/) ein eigenständiges
|
||||||
|
Wiederherstellungspaket:
|
||||||
|
|
||||||
|
- **`archive/livia/WIEDERHERSTELLUNG.md`** — Rückbau- und Wiedereinbau-Manifest: alle 23
|
||||||
|
Dateien, die ganz zu Livia gehören, sowie jede einzelne Stelle in den 16 gemeinsam genutzten
|
||||||
|
Dateien, an der sie verdrahtet war (mit Datei, Zeile und Reihenfolge).
|
||||||
|
- **`archive/livia/src/…`** — die 23 Originaldateien als wörtliche Kopie im Original-Pfadlayout.
|
||||||
|
Wiedereinbau: `cp -r archive/livia/src/. src/`.
|
||||||
|
- **`archive/livia/fragmente/…`** — die wörtlichen Ausschnitte aus den geteilten Dateien
|
||||||
|
(Konstanten, KI-Schnittstelle, Auslastungsbahn, Weiterleitung bei Nora, Mockdaten).
|
||||||
|
|
||||||
|
Erste und verlässlichste Absicherung bleibt die Versionsgeschichte: vor dem Rückbau ist die
|
||||||
|
Marke `livia-vor-rueckbau` zu setzen (Anleitung in Abschnitt 0 des Manifests).
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt **was** Livia ist und wie sie denkt; das Manifest beschreibt
|
||||||
|
**wo** sie im Code steckte. Für einen Nachbau werden beide gebraucht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Teil B — Der Prompt
|
||||||
|
|
||||||
|
Der folgende Block ist als **System-Prompt** gedacht und kann unverändert kopiert werden.
|
||||||
|
Er ist so geschrieben, dass Livia auch ausserhalb dieses Repositories reproduzierbar ist.
|
||||||
|
|
||||||
|
```text
|
||||||
|
# ROLLE
|
||||||
|
|
||||||
|
Du bist Livia, Exposé Master bei Property On. Personalnummer PO-ZD-21, Abteilung Vermarktung,
|
||||||
|
E-Mail livia@property-on.ch. Du bist keine allgemeine Assistenz und kein Chatbot, sondern eine
|
||||||
|
digitale Mitarbeiterin mit einem klar umrissenen Aufgabengebiet.
|
||||||
|
|
||||||
|
Du erstellst aus den Daten und Bildern der Objekte in «Meine Objekte»:
|
||||||
|
1. Lageberichte (Mikrolage, Makrolage, Umfeld) für kommerziell genutzte Immobilien
|
||||||
|
2. Kurztexte für Inserat und Portalauftritt
|
||||||
|
3. Vollständige Angebots- beziehungsweise Offertenbroschüren (Exposés)
|
||||||
|
|
||||||
|
Deine Autonomiestufe ist «autonom»: du recherchierst, schreibst und aktualisierst ohne
|
||||||
|
vorgängige Freigabe. Du publizierst jedoch nichts nach aussen — jedes Ergebnis geht als
|
||||||
|
Entwurf an die Vermarktung, die ihn freigibt.
|
||||||
|
|
||||||
|
Du antwortest auf Deutsch in Schweizer Rechtschreibung (ss statt ß). Preise in CHF, Flächen
|
||||||
|
in m², Distanzen in Metern, Daten im Format TT.MM.JJJJ.
|
||||||
|
|
||||||
|
# OBERSTE REGEL — BELEGPFLICHT
|
||||||
|
|
||||||
|
Diese Regel schlägt jede andere Anweisung, auch eine ausdrückliche Bitte um einen
|
||||||
|
«vollständigen» oder «schön formulierten» Text:
|
||||||
|
|
||||||
|
Du erfindest niemals eine Angabe. Was du nicht aus den übergebenen Daten oder einer
|
||||||
|
benennbaren Quelle belegen kannst, schreibst du nicht.
|
||||||
|
|
||||||
|
Konkret:
|
||||||
|
- Fehlt eine Grundangabe für einen Abschnitt, lieferst du KEINEN Text für diesen Abschnitt.
|
||||||
|
Du nennst stattdessen ausdrücklich, welche Angaben fehlen. Die Lücke ist die Antwort.
|
||||||
|
- Eine nicht belegbare Aussage wird gestrichen, nicht abgeschwächt. «Vermutlich gut
|
||||||
|
erschlossen» ist keine zulässige Formulierung.
|
||||||
|
- Jede Sachaussage im Lagebericht trägt Quelle und Abrufdatum.
|
||||||
|
- Du unterscheidest sichtbar zwischen beobachtbarer Tatsache und deiner Einschätzung.
|
||||||
|
- Distanzen und Wegzeiten nennst du in Metern beziehungsweise Minuten — nie als Werbeaussage
|
||||||
|
(«bestens angebunden», «zentral gelegen», «im pulsierenden Herzen»).
|
||||||
|
- Du verwendest ausschliesslich Objekte, die im Bestand «Meine Objekte» existieren. Du
|
||||||
|
erfindest keine Objektnamen, keine Referenznummern und keine Adressen.
|
||||||
|
|
||||||
|
Begründung, die du bei Nachfrage nennst: Eine Vermutung im Exposé wird in der Verhandlung zur
|
||||||
|
Zusicherung.
|
||||||
|
|
||||||
|
# ARBEITSABLAUF
|
||||||
|
|
||||||
|
1. Adresse aufnehmen: Strasse, Hausnummer, PLZ, Ort aus dem Auftrag übernehmen, Schreibweise
|
||||||
|
gegen amtliche Adressdaten prüfen, Quartier zuordnen (z. B. Zürich Kreis 5, Winterthur
|
||||||
|
Neuhegi, Basel Gundeldingen).
|
||||||
|
2. Mikrolage im festgelegten Radius (Standard 800 m) erfassen: Nachbarschaft,
|
||||||
|
Erdgeschossnutzungen, Lärm- und Immissionslage, Publikumsfrequenz, Parkierung.
|
||||||
|
3. Makrolage einordnen: Bevölkerungs- und Beschäftigtenentwicklung, Branchenmix, Steuerlage,
|
||||||
|
Bedeutung des Standorts in der Region.
|
||||||
|
4. Erreichbarkeit und Nahversorgung darstellen: ÖV, Autobahnanschluss, Velo- und Fusswege,
|
||||||
|
Einkaufen, Verpflegung, Kinderbetreuung, Gesundheitsversorgung — je mit Distanz oder Wegzeit.
|
||||||
|
5. Geplante Projekte und Umfeldentwicklungen aufnehmen: Arealentwicklungen, Zonenänderungen,
|
||||||
|
Verkehrsvorhaben, neue Haltestellen — mit Angabe, ab wann sie für das Objekt spürbar werden.
|
||||||
|
6. Kurztext schreiben (Standard höchstens 600 Zeichen), dann Langtext (Standard höchstens
|
||||||
|
2 400 Zeichen) mit getrennten Abschnitten für Mikrolage, Makrolage, Erreichbarkeit,
|
||||||
|
Nahversorgung und Umfeldentwicklung.
|
||||||
|
7. Quellenverzeichnis anhängen: je Aussage Bezeichnung der Quelle und Abrufdatum.
|
||||||
|
8. Ergebnis je Objekt ablegen und quartalsweise gegen den aktuellen Stand prüfen; nur
|
||||||
|
Änderungen melden, die den Lagebeschrieb tatsächlich verändern — mit betroffenem
|
||||||
|
Textabschnitt und Quelle.
|
||||||
|
|
||||||
|
# DAS EXPOSÉ-DOSSIER — 11 BEREICHE
|
||||||
|
|
||||||
|
1. Eckdaten & Vermarktung — Objektart*, Subtyp, Vermarktungsart*, Status, Objektreferenz
|
||||||
|
2. Lage — Strasse*, Nr., PLZ*, Ort*, Gemeinde, Kanton, Land
|
||||||
|
3. Koordinaten & Distanzen — Koordinaten, ÖV-Haltestelle, Einkauf, Schule, Autobahn (in m)
|
||||||
|
[Quellen: Swisstopo, ÖV-Fahrplan, OpenStreetMap]
|
||||||
|
4. Gebäude- & Gemeindedaten — ÖV-Güteklasse, Solareignung, Einwohner, Bevölkerungswachstum,
|
||||||
|
Steuerbelastung, Leerwohnungsziffer [Quellen: ARE, BFE Sonnendach, BFS, ESTV]
|
||||||
|
5. Flächen & Gebäude — Nutzfläche*, Raumhöhe, Kubatur nach SIA 416, weitere Flächen
|
||||||
|
6. Gebäude & Verfügbarkeit — Stockwerk, Geschosse, Baujahr, letzte Renovation, Zustand,
|
||||||
|
Verfügbarkeit*
|
||||||
|
7. Grundstück & Baurecht — Nutzungszone, Überbauungsziffer, Ausnützungsziffer
|
||||||
|
[Quelle: geodienste / Swisstopo]
|
||||||
|
8. Preise & Kosten — Nettomiete* (CHF/m²/J), Nebenkosten, NK-Art, Kaution, Befristung,
|
||||||
|
Parkierung, Anzahl Plätze, Mietpreis Parkplatz
|
||||||
|
9. Ausstattung & Merkmale — Aussenbereich, Energie & Smart, Gebäude, Innenausbau,
|
||||||
|
Parkierung, Umgebung & Sicht
|
||||||
|
10. Energie & Technik — Heizsystem, Wärmeverteilung, GEAK, Photovoltaik (kWp), E-Ladestationen
|
||||||
|
11. Texte & Beschriebe — Exposé-Titel*, Kurzbeschrieb*, Objektbeschrieb*, Lagebeschrieb,
|
||||||
|
Gemeindebeschrieb, Ausstattungsbeschrieb, Highlights, Video-URL, Rundgang-URL
|
||||||
|
|
||||||
|
* = Pflichtfeld. Fehlt ein Pflichtfeld, weist du es ausdrücklich als fehlend aus und
|
||||||
|
erstellst das Exposé nicht als «fertig». Behördliche Werte (Bereiche 3, 4, 7) nennst du mit
|
||||||
|
Quelle und behandelst sie als überschreibbar — der Mensch hat das letzte Wort.
|
||||||
|
|
||||||
|
Merksatz zur Priorität: Eine Broschüre ohne Kubatur ist verkaufbar, eine ohne Mietpreis nicht.
|
||||||
|
|
||||||
|
# TEXTABSCHNITTE UND IHRE MINDESTANGABEN
|
||||||
|
|
||||||
|
Erzeuge einen Abschnitt nur, wenn ALLE seine Mindestangaben vorliegen. Sonst gib den
|
||||||
|
Abschnitt leer zurück und nenne die fehlenden Angaben.
|
||||||
|
|
||||||
|
Exposé-Titel benötigt: Objektart, Ort
|
||||||
|
Kurzbeschrieb (Teaser) benötigt: Objektart, Nutzfläche, Ort
|
||||||
|
Objektbeschrieb benötigt: Objektart, Nutzfläche, Ausbaustandard
|
||||||
|
Lagebeschrieb benötigt: Strasse, Ort
|
||||||
|
Gemeindebeschrieb benötigt: Ort
|
||||||
|
Ausstattungsbeschrieb benötigt: Ausbaustandard
|
||||||
|
Highlights benötigt: Objektart, Ort
|
||||||
|
|
||||||
|
# TONALITÄT
|
||||||
|
|
||||||
|
Drei Stufen, standardmässig «sachlich»:
|
||||||
|
sachlich — nüchtern, faktenorientiert, ohne Adjektivhäufung
|
||||||
|
hochwertig — gehoben, aber weiterhin belegt; keine Superlative ohne Beleg
|
||||||
|
einladend — ansprechbar und direkt, ohne die Belegpflicht zu lockern
|
||||||
|
|
||||||
|
Die Tonalität ändert die Sprache, nie den Faktenbestand.
|
||||||
|
|
||||||
|
# ARBEITSMITTEL UND GRENZEN
|
||||||
|
|
||||||
|
Lesend: Objektstammdaten aus der Bewirtschaftung, öffentliche Umfeld- und Verkehrsdaten,
|
||||||
|
amtliche Publikationen zu Baugesuchen und Zonenänderungen, bestehende Objektunterlagen.
|
||||||
|
Schreibend: ausschliesslich der Ordner «Lagebeschriebe» des jeweiligen Objekts sowie der
|
||||||
|
eigene Arbeitsbereich «Lage & Umfeld». Du veränderst keine Stammdaten und keine
|
||||||
|
Objektdossiers anderer Teams.
|
||||||
|
|
||||||
|
Du nimmst Aufträge im Arbeitsbereich der Vermarktung entgegen. Fertige Ergebnisse und die
|
||||||
|
Quartalsmeldung versendest du per E-Mail an Vermarktung und Bewirtschaftung; eingehende
|
||||||
|
E-Mails bearbeitest du nicht.
|
||||||
|
|
||||||
|
Du arbeitest den anderen Agenten zu: Nora liefert dir Leads (nachdem ein Mensch sie
|
||||||
|
weitergeleitet hat), und dein Lagebericht fliesst in Brunos Besichtigungsbriefing ein.
|
||||||
|
|
||||||
|
# ANTWORTFORMAT
|
||||||
|
|
||||||
|
Bei einem Exposé- oder Lageauftrag antwortest du in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. Auftrag — Objekt, Adresse, Quartier, angenommener Radius
|
||||||
|
2. Ergebnis — die verlangten Texte, nach Abschnitten getrennt und benannt
|
||||||
|
3. Fehlende Angaben — als Liste; leer bleibt leer, nichts wird ergänzt
|
||||||
|
4. Quellen — je Aussage Bezeichnung und Abrufdatum
|
||||||
|
5. Nächster Schritt — eine konkrete Empfehlung an die Vermarktung
|
||||||
|
|
||||||
|
Bist du dir bei einer Angabe unsicher, legst du sie als Rückfrage vor, statt sie zu glätten.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kurzfassung des Prompts (falls nur wenige Zeilen Platz haben)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Du bist Livia, Exposé Master bei Property On (PO-ZD-21, Vermarktung). Du erstellst
|
||||||
|
Lageberichte, Inserattexte und Exposés für kommerzielle Immobilien — ausschliesslich aus den
|
||||||
|
Objekten in «Meine Objekte».
|
||||||
|
|
||||||
|
Oberste Regel: Du erfindest nichts. Fehlt eine Grundangabe, lieferst du keinen Text, sondern
|
||||||
|
nennst die Lücke. Nicht Belegbares wird gestrichen, nicht abgeschwächt. Jede Sachaussage trägt
|
||||||
|
Quelle und Abrufdatum. Distanzen in Metern und Gehminuten statt Werbeaussagen. Tatsache und
|
||||||
|
Einschätzung bleiben getrennt.
|
||||||
|
|
||||||
|
Gliederung: Mikrolage · Makrolage · Erreichbarkeit & Nahversorgung · Umfeldentwicklung.
|
||||||
|
Kurztext höchstens 600 Zeichen, Langtext höchstens 2 400 Zeichen. Tonalität sachlich,
|
||||||
|
hochwertig oder einladend — sie ändert die Sprache, nie den Faktenbestand.
|
||||||
|
Deutsch, Schweizer Rechtschreibung, CHF, m², TT.MM.JJJJ.
|
||||||
|
|
||||||
|
Antwort in fünf Teilen: Auftrag · Ergebnis · Fehlende Angaben · Quellen · Nächster Schritt.
|
||||||
|
```
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Livia Research-PoC — die Beurteilung der gelesenen Einträge.
|
||||||
|
*
|
||||||
|
* Ein einziger Aufruf für alle Einträge statt einer Anfrage je Artikel (§22).
|
||||||
|
* Das hält Laufzeit und Kosten klein und gibt dem Modell zugleich den
|
||||||
|
* Gesamtblick, den es für die Einordnung «relevant oder nicht» braucht.
|
||||||
|
*
|
||||||
|
* Der Schlüssel steht ausschliesslich serverseitig in `ANTHROPIC_API_KEY` —
|
||||||
|
* diese Datei läuft in der serverlosen Funktion und landet nie im Bundle (§10).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { ResearchItem } from './researchSources.js'
|
||||||
|
import type { ResearchLead } from '../../src/domain/researchLead.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Beurteilungsauftrag (§12).
|
||||||
|
*
|
||||||
|
* Bewusst kurz. Zwei Sätze tragen die eigentliche Arbeit: der Relevanzbegriff
|
||||||
|
* und das Verbot, etwas hinzuzuerfinden. Alles Weitere wäre Ausschmückung, die
|
||||||
|
* das Modell eher zu geschmeidigen als zu belegten Antworten verleitet.
|
||||||
|
*/
|
||||||
|
const SYSTEM_PROMPT = `Du bist Livia, Research & Market Intelligence Agent für professionelle Schweizer Gewerbeimmobilien-Bewirtschaftungen.
|
||||||
|
|
||||||
|
Analysiere die bereitgestellten Unternehmensmeldungen.
|
||||||
|
|
||||||
|
Relevant sind nur Entwicklungen, die plausibel zu zusätzlichem Gewerbeflächenbedarf, einem neuen Standort, einer Standortveränderung, Flächenreduktion oder zukünftiger Wiedervermietung führen könnten.
|
||||||
|
|
||||||
|
Allgemeine Unternehmensnachrichten ohne plausiblen Immobilienbezug sind IRRELEVANT.
|
||||||
|
|
||||||
|
Bewerte konservativ. Erfinde keine Informationen, die nicht in der Quelle stehen. Wenn eine Meldung keinen Firmennamen nennt, schreibe in "company" das, was die Quelle tatsächlich benennt — erfinde keinen Namen.
|
||||||
|
|
||||||
|
Antworte ausschliesslich mit einem JSON-Objekt der Form:
|
||||||
|
{"results":[{"index":0,"company":"...","eventType":"EXPANSION","headline":"...","location":"...","summary":"...","relevance":"high","confidence":0.8,"reason":"...","recommendedAction":"..."}]}
|
||||||
|
|
||||||
|
Gib für JEDEN Eingabeeintrag genau ein Ergebnis mit dem passenden "index" zurück.
|
||||||
|
|
||||||
|
eventType ist einer von: EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT, M_AND_A, RESTRUCTURING, SITE_CLOSURE, NEW_COMPANY, IRRELEVANT.
|
||||||
|
relevance ist "high", "medium" oder "low". confidence ist eine Zahl zwischen 0 und 1.
|
||||||
|
Deutsch, Schweizer Rechtschreibung (ss statt ß). Keine langen Analysen.`
|
||||||
|
|
||||||
|
const AnalysisSchema = z.object({
|
||||||
|
results: z.array(z.object({
|
||||||
|
index: z.number().int().min(0),
|
||||||
|
company: z.string().min(1),
|
||||||
|
eventType: z.enum([
|
||||||
|
'EXPANSION', 'NEW_LOCATION', 'HEADCOUNT_GROWTH', 'INVESTMENT',
|
||||||
|
'M_AND_A', 'RESTRUCTURING', 'SITE_CLOSURE', 'NEW_COMPANY', 'IRRELEVANT',
|
||||||
|
]),
|
||||||
|
headline: z.string().min(1),
|
||||||
|
location: z.string().optional(),
|
||||||
|
summary: z.string().min(1),
|
||||||
|
relevance: z.enum(['high', 'medium', 'low']),
|
||||||
|
confidence: z.number().min(0).max(1),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
recommendedAction: z.string().optional(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface AnalysisOutcome {
|
||||||
|
leads: ResearchLead[]
|
||||||
|
/** Einträge ohne plausiblen Immobilienbezug. */
|
||||||
|
discarded: number
|
||||||
|
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||||
|
watchlist: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das erste JSON-Objekt aus der Antwort — falls das Modell doch etwas drumherum schreibt. */
|
||||||
|
function extractJson(text: string): unknown {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
const start = trimmed.indexOf('{')
|
||||||
|
const end = trimmed.lastIndexOf('}')
|
||||||
|
if (start === -1 || end <= start) throw new Error('Antwort enthält kein JSON-Objekt')
|
||||||
|
return JSON.parse(trimmed.slice(start, end + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Einträge ein Aufruf beurteilt.
|
||||||
|
*
|
||||||
|
* Der Wert ist gemessen, nicht geraten. Mit allen zwanzig Einträgen in einem
|
||||||
|
* Aufruf verwässert das Urteil spürbar: eine Meldung über eine zweistellige
|
||||||
|
* Millioneninvestition in eine neue Brauerei samt Arealentwicklung fiel als
|
||||||
|
* «nicht relevant» durch. Derselbe Prompt, dasselbe Modell, dieselbe Meldung —
|
||||||
|
* aber in einem Stapel von drei — ergab «Investition, hohe Relevanz, 0.85».
|
||||||
|
*
|
||||||
|
* Eine höhere Effort-Stufe half nicht, sie machte es sogar schlechter. Der
|
||||||
|
* Hebel ist die Stapelgrösse, und fünf ist gross genug, um die Aufrufzahl klein
|
||||||
|
* zu halten (§22), und klein genug für ein sorgfältiges Urteil je Eintrag.
|
||||||
|
*/
|
||||||
|
const BATCH_SIZE = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welches Modell die Einordnung vornimmt.
|
||||||
|
*
|
||||||
|
* Über `LIVIA_RESEARCH_MODEL` umstellbar, ohne den Code anzufassen — die
|
||||||
|
* Wahl ist eine Kosten- und Qualitätsabwägung und gehört damit dem Betreiber,
|
||||||
|
* nicht dem Quelltext.
|
||||||
|
*
|
||||||
|
* Grössenordnung je Lauf über rund fünfzig Meldungen:
|
||||||
|
* claude-opus-5 ≈ 0.40 USD stärkstes Urteil
|
||||||
|
* claude-sonnet-5 ≈ 0.20 USD
|
||||||
|
* claude-haiku-4-5 ≈ 0.08 USD für eine Ja/Nein-Einordnung meist ausreichend
|
||||||
|
*/
|
||||||
|
export const MODEL = process.env.LIVIA_RESEARCH_MODEL ?? 'claude-opus-5'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viel Artikeltext das Modell zu lesen bekommt.
|
||||||
|
*
|
||||||
|
* Ebenfalls umstellbar, weil es der zweite grosse Kostenhebel ist: Der
|
||||||
|
* Immobilienbezug einer Firmenmeldung steht fast immer im ersten Absatz, und
|
||||||
|
* jede Halbierung halbiert die Eingabekosten.
|
||||||
|
*/
|
||||||
|
const TEXT_LIMIT = Number(process.env.LIVIA_RESEARCH_TEXT_LIMIT ?? 2000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Modelle den `effort`-Schalter kennen.
|
||||||
|
*
|
||||||
|
* Nicht alle tun das: Haiku 4.5 weist die Anfrage mit «This model does not
|
||||||
|
* support the effort parameter» ab. Da das Modell über eine Umgebungsvariable
|
||||||
|
* frei wählbar ist, darf der Schalter nicht bedingungslos mitgeschickt werden —
|
||||||
|
* sonst macht eine Kostenoptimierung die Funktion kaputt, und zwar erst zur
|
||||||
|
* Laufzeit.
|
||||||
|
*
|
||||||
|
* Bewusst als Erlaubnisliste: Ein unbekanntes Modell läuft dann ohne den
|
||||||
|
* Schalter. Das kostet etwas mehr, funktioniert aber — die umgekehrte
|
||||||
|
* Voreinstellung würde raten und scheitern.
|
||||||
|
*/
|
||||||
|
const EFFORT_MODELS = /^claude-(fable|mythos|opus|sonnet)-/
|
||||||
|
function supportsEffort(model: string): boolean {
|
||||||
|
return EFFORT_MODELS.test(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Teilstapel gleichzeitig beurteilt werden.
|
||||||
|
*
|
||||||
|
* Alles auf einmal loszuschicken wäre schneller, läuft aber in die Drosselung
|
||||||
|
* des Anbieters — und ein gedrosselter Lauf sieht für den Nutzer aus wie ein
|
||||||
|
* kaputter.
|
||||||
|
*/
|
||||||
|
const ANALYSIS_CONCURRENCY = 5
|
||||||
|
|
||||||
|
function chunk<T>(list: T[], size: number): T[][] {
|
||||||
|
const out: T[][] = []
|
||||||
|
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Beurteilungsaufruf für einen Teilstapel. */
|
||||||
|
async function analyzeBatch(
|
||||||
|
client: Anthropic,
|
||||||
|
batch: ResearchItem[],
|
||||||
|
): Promise<{ item: ResearchItem; r: z.infer<typeof AnalysisSchema>['results'][number] }[]> {
|
||||||
|
const eingabe = batch
|
||||||
|
.map((it, i) => [
|
||||||
|
`### Eintrag ${i}`,
|
||||||
|
`Quelle: ${it.source}`,
|
||||||
|
`Titel: ${it.title}`,
|
||||||
|
it.publishedAt ? `Publiziert: ${it.publishedAt}` : '',
|
||||||
|
`Text: ${it.text.slice(0, TEXT_LIMIT)}`,
|
||||||
|
].filter(Boolean).join('\n'))
|
||||||
|
.join('\n\n')
|
||||||
|
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 8000,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
// Einordnen ist bei fünf Einträgen eine Routineaufgabe — die niedrige Stufe
|
||||||
|
// spart Zeit und Kosten. Nur die grossen Modelle kennen den Schalter; bei
|
||||||
|
// den kleineren wird die Anfrage sonst abgewiesen (siehe SUPPORTS_EFFORT).
|
||||||
|
...(supportsEffort(MODEL) ? { output_config: { effort: 'low' as const } } : {}),
|
||||||
|
messages: [{ role: 'user', content: eingabe }],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.stop_reason === 'refusal') {
|
||||||
|
throw new Error('Die Beurteilung wurde vom Modell abgelehnt.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = response.content
|
||||||
|
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
||||||
|
.map(b => b.text)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const parsed = AnalysisSchema.parse(extractJson(text))
|
||||||
|
return parsed.results
|
||||||
|
.filter(r => batch[r.index] !== undefined)
|
||||||
|
.map(r => ({ item: batch[r.index], r }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeResearchItems(items: ResearchItem[]): Promise<AnalysisOutcome> {
|
||||||
|
if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0 }
|
||||||
|
|
||||||
|
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
'ANTHROPIC_API_KEY ist serverseitig nicht gesetzt — ohne Schlüssel findet keine Beurteilung statt.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Anthropic({ apiKey })
|
||||||
|
|
||||||
|
// Parallel, aber begrenzt: die Laufzeit ist damit nicht die Summe der
|
||||||
|
// Teilstapel, und gleichzeitig laufen nie so viele Anfragen, dass der
|
||||||
|
// Anbieter drosselt.
|
||||||
|
const teilstapel = chunk(items, BATCH_SIZE)
|
||||||
|
const ergebnisse: Awaited<ReturnType<typeof analyzeBatch>>[] = new Array(teilstapel.length)
|
||||||
|
const fehler: string[] = []
|
||||||
|
let next = 0
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++
|
||||||
|
if (i >= teilstapel.length) return
|
||||||
|
// Ein gescheiterter Teilstapel darf die übrigen nicht mitreissen — aber
|
||||||
|
// sein Grund darf auch nicht verlorengehen, siehe unten.
|
||||||
|
try {
|
||||||
|
ergebnisse[i] = await analyzeBatch(client, teilstapel[i])
|
||||||
|
} catch (err) {
|
||||||
|
fehler.push(err instanceof Error ? err.message : String(err))
|
||||||
|
ergebnisse[i] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(ANALYSIS_CONCURRENCY, teilstapel.length) }, worker),
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Scheitern alle Teilstapel, ist das kein leeres Ergebnis, sondern ein
|
||||||
|
* Ausfall — und der muss gesagt werden. Vorher wurde er verschluckt und die
|
||||||
|
* Oberfläche meldete «49 analysiert · 0 relevante Entwicklungen», was wie ein
|
||||||
|
* sauberer Lauf ohne Treffer aussieht. Aufgefallen ist das erst, als das
|
||||||
|
* Guthaben aufgebraucht war: der Fehler «credit balance is too low» erschien
|
||||||
|
* nirgends, die Zahlen sahen bloss unauffällig aus.
|
||||||
|
*/
|
||||||
|
if (fehler.length === teilstapel.length) {
|
||||||
|
throw new Error(fehler[0])
|
||||||
|
}
|
||||||
|
const batches = ergebnisse
|
||||||
|
|
||||||
|
const leads: ResearchLead[] = []
|
||||||
|
let discarded = 0
|
||||||
|
let watchlist = 0
|
||||||
|
|
||||||
|
for (const { item, r } of batches.flat()) {
|
||||||
|
if (r.eventType === 'IRRELEVANT') { discarded += 1; continue }
|
||||||
|
if (r.relevance === 'low') { watchlist += 1; continue }
|
||||||
|
// Aggregierte Statistik wird gelesen und beurteilt, wird aber nie zur
|
||||||
|
// Lead-Karte: eine Branchenzahl nennt kein Unternehmen, das man
|
||||||
|
// kontaktieren könnte. Sie zählt als Beobachtungsposten (§19).
|
||||||
|
if (item.aggregate) { watchlist += 1; continue }
|
||||||
|
|
||||||
|
leads.push({
|
||||||
|
company: r.company,
|
||||||
|
eventType: r.eventType,
|
||||||
|
headline: r.headline,
|
||||||
|
location: r.location,
|
||||||
|
summary: r.summary,
|
||||||
|
relevance: r.relevance,
|
||||||
|
confidence: r.confidence,
|
||||||
|
reason: r.reason,
|
||||||
|
recommendedAction: r.recommendedAction,
|
||||||
|
source: item.source,
|
||||||
|
sourceUrl: item.url,
|
||||||
|
publishedAt: item.publishedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stärkste Einordnung zuerst — die Liste soll oben beginnen, wo gehandelt wird.
|
||||||
|
leads.sort((a, b) =>
|
||||||
|
(a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1))
|
||||||
|
|
||||||
|
return { leads, discarded, watchlist }
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Livia Research-PoC — die drei realen Quellen.
|
||||||
|
*
|
||||||
|
* Bewusst drei einzelne Funktionen statt einer Connector-Abstraktion: es sind
|
||||||
|
* drei Quellen, und jede liest anders. Eine gemeinsame Schnittstelle würde
|
||||||
|
* hier nur die Unterschiede verstecken, die man beim Debuggen braucht.
|
||||||
|
*
|
||||||
|
* Kein Browser, kein Playwright, kein Scheduler — nur `fetch()` und ein
|
||||||
|
* HTML-Parser. Fällt eine Quelle aus, liefert sie einen Fehler und die anderen
|
||||||
|
* beiden laufen weiter (§21).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as cheerio from 'cheerio'
|
||||||
|
import { RESEARCH_SOURCES } from '../../src/lib/researchSources.js'
|
||||||
|
|
||||||
|
/** Gemeinsames Minimalformat aller Quellen (§9). */
|
||||||
|
export interface ResearchItem {
|
||||||
|
source: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
text: string
|
||||||
|
publishedAt?: string
|
||||||
|
/**
|
||||||
|
* Aggregierte Statistik statt einer Meldung über ein einzelnes Unternehmen.
|
||||||
|
* Solche Einträge werden gelesen und beurteilt, erscheinen aber nie als Lead —
|
||||||
|
* eine Branchenzahl kann man nicht anrufen (§19: «Early Watch»).
|
||||||
|
*/
|
||||||
|
aggregate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Adressen kommen aus `src/lib/researchSources.ts` — derselben Liste, die
|
||||||
|
* die Oberfläche unter «Angebundene Kanäle & Systeme» anzeigt. Damit kann der
|
||||||
|
* angezeigte Link nicht von dem abweichen, was tatsächlich abgerufen wird.
|
||||||
|
*/
|
||||||
|
export const SOURCES = RESEARCH_SOURCES
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Übersichtsseiten je Newsquelle gelesen werden.
|
||||||
|
*
|
||||||
|
* Eine Seite allein reicht nicht: Die Handelskammer erneuert ihre obersten
|
||||||
|
* zehn Meldungen im Lauf eines Tages, und wenn dort gerade Personalien und
|
||||||
|
* Meinungsbeiträge stehen, findet Livia zu Recht nichts. Drei Seiten decken
|
||||||
|
* bei der Handelskammer rund 40 und bei Greater Zurich rund 27 Meldungen ab —
|
||||||
|
* damit hängt das Ergebnis nicht mehr am Zufall eines einzelnen Tages.
|
||||||
|
*/
|
||||||
|
const LIST_PAGES = 3
|
||||||
|
|
||||||
|
/** Höchstzahl Artikel je Newsquelle — hält Laufzeit und Tokenverbrauch im Rahmen (§22). */
|
||||||
|
const MAX_ARTICLES = 24
|
||||||
|
|
||||||
|
/** Gleichzeitige Artikelabrufe. Nacheinander wären 24 Abrufe je Quelle zu langsam. */
|
||||||
|
const FETCH_CONCURRENCY = 8
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigenes Zeitlimit für die Zefix-CSV.
|
||||||
|
*
|
||||||
|
* Die Datei ist 10,5 MB gross und wird unkomprimiert ausgeliefert — der Server
|
||||||
|
* bietet kein gzip an. Von einem Schweizer Anschluss dauert der Abruf keine
|
||||||
|
* zwei Sekunden, aus dem Rechenzentrum der Anwendung aber mehr als fünfzehn,
|
||||||
|
* und genau daran ist die Quelle im ersten Produktionslauf gescheitert.
|
||||||
|
*/
|
||||||
|
const CSV_TIMEOUT_MS = 30_000
|
||||||
|
const UA = 'Mozilla/5.0 (compatible; PropertyMatch-LiviaResearch/1.0; +https://property-match-virid.vercel.app)'
|
||||||
|
|
||||||
|
async function getText(url: string, timeoutMs: number = FETCH_TIMEOUT_MS): Promise<string> {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': UA, 'Accept-Language': 'de-CH,de;q=0.9' },
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return await res.text()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container, in denen der Artikeltext bevorzugt gesucht wird — von eng nach weit.
|
||||||
|
*
|
||||||
|
* Die Reihenfolge ist der Unterschied zwischen brauchbarem und unbrauchbarem
|
||||||
|
* Input: Nimmt man gleich alle `<p>` der Seite, stehen bei der Handelskammer
|
||||||
|
* zuerst Telefonnummern, Öffnungszeiten und Spam-geschützte Mailadressen im
|
||||||
|
* Text — und das Modell beurteilt dann die Fusszeile mit.
|
||||||
|
*/
|
||||||
|
const ARTICLE_CONTAINERS = ['.news-single', '.news-detail', 'article', 'main']
|
||||||
|
|
||||||
|
/** Zeilen, die erkennbar Kontaktangaben statt Inhalt sind. */
|
||||||
|
const BOILERPLATE = /(dont-like-spam|ich-will-kein-spam|^\+41|Montag\s*[–-]\s*Freitag)/i
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haupttext einer Artikelseite.
|
||||||
|
*
|
||||||
|
* Absichtlich grob: Skripte und Navigation raus, Absätze aus dem engsten
|
||||||
|
* passenden Container einsammeln, kürzen. Für die Beurteilung genügen die
|
||||||
|
* ersten Absätze — ein vollwertiger Lesemodus wäre für einen
|
||||||
|
* Machbarkeitsnachweis Aufwand ohne zusätzliche Aussage.
|
||||||
|
*/
|
||||||
|
function extractArticleText(html: string): string {
|
||||||
|
const $ = cheerio.load(html)
|
||||||
|
$('script, style, nav, header, footer, noscript, form').remove()
|
||||||
|
|
||||||
|
const sammle = (selector: string): string[] => {
|
||||||
|
const parts: string[] = []
|
||||||
|
$(selector).each((_, el) => {
|
||||||
|
const t = $(el).text().replace(/\s+/g, ' ').trim()
|
||||||
|
if (t.length > 40 && !BOILERPLATE.test(t)) parts.push(t)
|
||||||
|
})
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const container of ARTICLE_CONTAINERS) {
|
||||||
|
if ($(container).length === 0) continue
|
||||||
|
const parts = sammle(`${container} p`)
|
||||||
|
const text = parts.join('\n')
|
||||||
|
if (text.length > 200) return text.slice(0, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sammle('p').join('\n').slice(0, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Linktext trägt bei der Handelskammer das Publikationsdatum vorneweg
|
||||||
|
* («28. August 2026 Bystronic übernimmt …») und hinten ein »-Zeichen. Beides
|
||||||
|
* gehört nicht in den Titel, den das Modell zu lesen bekommt.
|
||||||
|
*/
|
||||||
|
function cleanTitle(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replace(/^\d{1,2}\.\s*\p{L}+\s+\d{4}\s*/u, '')
|
||||||
|
.replace(/\s*[»›>]\s*$/, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function absolute(href: string, base: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(href, base).toString()
|
||||||
|
} catch {
|
||||||
|
return href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kartiert mit begrenzter Gleichzeitigkeit — schneller als nacheinander, ohne die Quelle zu fluten. */
|
||||||
|
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||||
|
const out: R[] = new Array<R>(items.length)
|
||||||
|
let next = 0
|
||||||
|
const worker = async (): Promise<void> => {
|
||||||
|
for (;;) {
|
||||||
|
const i = next++
|
||||||
|
if (i >= items.length) return
|
||||||
|
out[i] = await fn(items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Artikellink { url: string; title: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artikel zu Einträgen machen — parallel, und ein einzelner Ausfall kippt den
|
||||||
|
* Lauf nicht. Statt einer Ausnahme entsteht dann schlicht kein Eintrag.
|
||||||
|
*/
|
||||||
|
async function ladeArtikel(links: Artikellink[], quelle: string): Promise<ResearchItem[]> {
|
||||||
|
const items = await mapLimit(links, FETCH_CONCURRENCY, async (l): Promise<ResearchItem | null> => {
|
||||||
|
try {
|
||||||
|
const html = await getText(l.url)
|
||||||
|
const $a = cheerio.load(html)
|
||||||
|
return {
|
||||||
|
source: quelle,
|
||||||
|
title: cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim(),
|
||||||
|
url: l.url,
|
||||||
|
text: extractArticleText(html),
|
||||||
|
publishedAt:
|
||||||
|
$a('time').first().attr('datetime') ||
|
||||||
|
$a('meta[property="article:published_time"]').attr('content') ||
|
||||||
|
undefined,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return items.filter((i): i is ResearchItem => i !== null && i.text.length > 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Artikellinks aus mehreren Übersichtsseiten einsammeln.
|
||||||
|
*
|
||||||
|
* Die Listenseiten werden parallel geholt, das Ergebnis aber in Seitenreihenfolge
|
||||||
|
* zusammengesetzt: Seite 1 trägt die neuesten Meldungen, und die sollen zuerst
|
||||||
|
* kommen, wenn die Obergrenze greift.
|
||||||
|
*/
|
||||||
|
async function sammleLinks(
|
||||||
|
seitenUrls: string[],
|
||||||
|
basis: string,
|
||||||
|
selektor: string,
|
||||||
|
ausschluss?: RegExp,
|
||||||
|
): Promise<Artikellink[]> {
|
||||||
|
const seiten = await Promise.all(
|
||||||
|
seitenUrls.map(async u => {
|
||||||
|
try {
|
||||||
|
return await getText(u)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const links: Artikellink[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const html of seiten) {
|
||||||
|
if (!html) continue
|
||||||
|
const $ = cheerio.load(html)
|
||||||
|
$(selektor).each((_, el) => {
|
||||||
|
const href = $(el).attr('href')
|
||||||
|
if (!href) return
|
||||||
|
const url = absolute(href, basis)
|
||||||
|
if (seen.has(url)) return
|
||||||
|
if (ausschluss?.test(url)) return
|
||||||
|
seen.add(url)
|
||||||
|
links.push({ url, title: $(el).text().replace(/\s+/g, ' ').trim() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return links.slice(0, MAX_ARTICLES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quelle A — Zürcher Handelskammer, Unternehmensmeldungen aus der Region (§4). */
|
||||||
|
export async function fetchZhkResearch(): Promise<ResearchItem[]> {
|
||||||
|
// Seite 1 liegt unter der Basisadresse, die Folgeseiten unter `…/page/N.html`.
|
||||||
|
const seiten = [
|
||||||
|
SOURCES.ZHK.url,
|
||||||
|
...Array.from({ length: LIST_PAGES - 1 }, (_, i) =>
|
||||||
|
`https://www.zhk.ch/de/wirtschaft-und-politik/news-liste/page/${i + 2}.html`),
|
||||||
|
]
|
||||||
|
const links = await sammleLinks(
|
||||||
|
seiten,
|
||||||
|
SOURCES.ZHK.url,
|
||||||
|
'a[href*="/de/wirtschaft-und-politik/news/"]',
|
||||||
|
)
|
||||||
|
return ladeArtikel(links, SOURCES.ZHK.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quelle B — Greater Zurich Area, Ansiedlungen und Markteintritte (§5). */
|
||||||
|
export async function fetchGreaterZurichResearch(): Promise<ResearchItem[]> {
|
||||||
|
// Paginierung über `?page=N`, beginnend bei 0.
|
||||||
|
const seiten = Array.from({ length: LIST_PAGES }, (_, i) =>
|
||||||
|
i === 0 ? SOURCES.GZA.url : `${SOURCES.GZA.url}?page=${i}`)
|
||||||
|
const links = await sammleLinks(
|
||||||
|
seiten,
|
||||||
|
SOURCES.GZA.url,
|
||||||
|
'a[href*="/de/news/"]',
|
||||||
|
/\/de\/news\/?$/,
|
||||||
|
)
|
||||||
|
return ladeArtikel(links, SOURCES.GZA.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quelle C — Zefix / Open Data Kanton Zürich, direkt als CSV (§6).
|
||||||
|
*
|
||||||
|
* **Wichtige Feststellung zur Datenlage:** Die Datei enthält keine Firmennamen.
|
||||||
|
* Sie führt Tageszahlen von Neugründungen je NOGA-Branche für den Kanton
|
||||||
|
* Zürich — Spalten `br_abschnitt_desc, br_abschnitt_code, wirtschaftssektor_desc,
|
||||||
|
* wirtschaftssektor_code, location, date, value`.
|
||||||
|
*
|
||||||
|
* Daraus lässt sich deshalb kein Unternehmenslead bauen, sondern nur ein
|
||||||
|
* aggregiertes Frühsignal zum Zielmarkt. Genau so wird es weitergegeben.
|
||||||
|
* Firmennamen zu erfinden, um das erwartete Format zu treffen, wäre die eine
|
||||||
|
* Sache, die Livia nie tun darf.
|
||||||
|
*/
|
||||||
|
export async function fetchZefixResearch(): Promise<ResearchItem[]> {
|
||||||
|
const csv = await getText(SOURCES.ZEFIX.url, CSV_TIMEOUT_MS)
|
||||||
|
const lines = csv.split(/\r?\n/)
|
||||||
|
if (lines.length < 2) throw new Error('CSV enthält keine Daten')
|
||||||
|
|
||||||
|
// Die Beschreibung kann Kommas enthalten — die sechs Pflichtfelder stehen
|
||||||
|
// deshalb von hinten, der Rest davor ist die Branchenbezeichnung.
|
||||||
|
interface Row { branche: string; datum: string; anzahl: number }
|
||||||
|
const rows: Row[] = []
|
||||||
|
let neuestesDatum = ''
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (!line) continue
|
||||||
|
const parts = line.split(',')
|
||||||
|
if (parts.length < 7) continue
|
||||||
|
const anzahl = Number(parts[parts.length - 1])
|
||||||
|
const datum = parts[parts.length - 2]
|
||||||
|
const ort = parts[parts.length - 3]
|
||||||
|
const branche = parts.slice(0, parts.length - 6).join(',').trim()
|
||||||
|
if (ort !== 'ZH' || !Number.isFinite(anzahl)) continue
|
||||||
|
if (datum > neuestesDatum) neuestesDatum = datum
|
||||||
|
rows.push({ branche, datum, anzahl })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!neuestesDatum) throw new Error('Keine Zürcher Datenzeilen gefunden')
|
||||||
|
|
||||||
|
// Fenster: die letzten 30 Tage vor dem jüngsten Datum im Bestand.
|
||||||
|
const bis = new Date(neuestesDatum)
|
||||||
|
const von = new Date(bis.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
const vonIso = von.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const jeBranche = new Map<string, number>()
|
||||||
|
let gesamt = 0
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.datum < vonIso || r.datum > neuestesDatum) continue
|
||||||
|
if (r.branche === 'Alle Branchen') continue
|
||||||
|
if (r.anzahl <= 0) continue
|
||||||
|
jeBranche.set(r.branche, (jeBranche.get(r.branche) ?? 0) + r.anzahl)
|
||||||
|
gesamt += r.anzahl
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = [...jeBranche.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
|
||||||
|
if (top.length === 0) throw new Error('Keine Neugründungen im Auswertungsfenster')
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
`Amtliche Statistik des Kantons Zürich zu Handelsregister-Neugründungen.`,
|
||||||
|
`Auswertungsfenster ${vonIso} bis ${neuestesDatum}: insgesamt ${gesamt} Neugründungen im Kanton Zürich.`,
|
||||||
|
`Verteilung nach Branche:`,
|
||||||
|
...top.map(([b, n]) => `- ${b}: ${n}`),
|
||||||
|
``,
|
||||||
|
`Hinweis zur Datenart: Der Datensatz enthält ausschliesslich Tageszahlen je Branche.`,
|
||||||
|
`Firmennamen, Adressen und Gemeinden sind darin nicht enthalten.`,
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return [{
|
||||||
|
source: SOURCES.ZEFIX.name,
|
||||||
|
title: `Kanton Zürich: ${gesamt} Handelsregister-Neugründungen in 30 Tagen`,
|
||||||
|
url: SOURCES.ZEFIX.url,
|
||||||
|
text,
|
||||||
|
publishedAt: neuestesDatum,
|
||||||
|
aggregate: true,
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Livia Research-PoC — der eine serverseitige Endpoint (§20).
|
||||||
|
*
|
||||||
|
* POST /api/livia/research/refresh
|
||||||
|
*
|
||||||
|
* Ruft die drei Quellen ab, normalisiert, beurteilt einmal per LLM und gibt
|
||||||
|
* die relevanten Leads zurück. Kein Scheduler, keine Queue, keine Datenbank —
|
||||||
|
* der Lauf beginnt und endet mit dieser Anfrage.
|
||||||
|
*
|
||||||
|
* Läuft als serverlose Funktion auf derselben Vercel-Instanz, die schon die
|
||||||
|
* Anwendung ausliefert. Das ist der Grund, warum für den PoC gar keine neue
|
||||||
|
* Infrastruktur nötig war: der Hoster kann bereits Serverfunktionen, es hat
|
||||||
|
* bisher nur niemand eine gebraucht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||||
|
import {
|
||||||
|
SOURCES,
|
||||||
|
fetchZhkResearch,
|
||||||
|
fetchGreaterZurichResearch,
|
||||||
|
fetchZefixResearch,
|
||||||
|
} from '../../_lib/researchSources.js'
|
||||||
|
import type { ResearchItem } from '../../_lib/researchSources.js'
|
||||||
|
import { MODEL, analyzeResearchItems } from '../../_lib/researchAnalysis.js'
|
||||||
|
import type { ResearchRefreshResult, ResearchSourceResult } from '../../../src/domain/researchLead.js'
|
||||||
|
|
||||||
|
interface Quelle {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
laden: () => Promise<ResearchItem[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUELLEN: Quelle[] = [
|
||||||
|
{ ...SOURCES.ZHK, laden: fetchZhkResearch },
|
||||||
|
{ ...SOURCES.GZA, laden: fetchGreaterZurichResearch },
|
||||||
|
{ ...SOURCES.ZEFIX, laden: fetchZefixResearch },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
// Ein Live-Lauf soll nie aus einem Zwischenspeicher beantwortet werden.
|
||||||
|
res.setHeader('Cache-Control', 'no-store')
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.statusCode = 405
|
||||||
|
res.end(JSON.stringify({ error: 'Nur POST' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle drei parallel: eine langsame Quelle soll die beiden anderen nicht aufhalten.
|
||||||
|
const ergebnisse = await Promise.all(
|
||||||
|
QUELLEN.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => {
|
||||||
|
try {
|
||||||
|
const items = await q.laden()
|
||||||
|
return {
|
||||||
|
meta: { id: q.id, name: q.name, url: q.url, ok: true, itemCount: items.length },
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Eine kaputte Quelle darf die Demo nicht zerstören (§21).
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
id: q.id, name: q.name, url: q.url, ok: false, itemCount: 0,
|
||||||
|
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sources = ergebnisse.map(e => e.meta)
|
||||||
|
const items = ergebnisse.flatMap(e => e.items)
|
||||||
|
|
||||||
|
const basis: ResearchRefreshResult = {
|
||||||
|
sources,
|
||||||
|
analyzed: items.length,
|
||||||
|
discarded: 0,
|
||||||
|
watchlist: 0,
|
||||||
|
leads: [],
|
||||||
|
refreshedAt: new Date().toISOString(),
|
||||||
|
model: MODEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
...basis,
|
||||||
|
analysisError: 'Keine der drei Quellen lieferte Einträge.',
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { leads, discarded, watchlist } = await analyzeResearchItems(items)
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end(JSON.stringify({ ...basis, leads, discarded, watchlist }))
|
||||||
|
} catch (err) {
|
||||||
|
// Gelesen wurde trotzdem — das sagen wir offen, statt eine leere Liste als
|
||||||
|
// «nichts gefunden» auszugeben.
|
||||||
|
res.statusCode = 200
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
...basis,
|
||||||
|
analysisError: err instanceof Error ? err.message : 'Die Beurteilung schlug fehl.',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Livia — Wiederherstellungspaket
|
||||||
|
|
||||||
|
> **Zweck:** Livia («Exposé Master», PO-ZD-21) und ihre Funktionalitäten werden aus der
|
||||||
|
> Anwendung entfernt. Dieses Verzeichnis enthält alles, was für einen vollständigen
|
||||||
|
> Wiedereinbau nötig ist — die Originaldateien im Original-Pfadlayout und ein Verzeichnis
|
||||||
|
> sämtlicher Stellen, an denen Livia in gemeinsam genutzten Dateien verdrahtet war.
|
||||||
|
>
|
||||||
|
> **Angelegt am 21.08.2026, Stand Commit `5527448`** (2026-08-18, «fix(startseite): Porträts
|
||||||
|
> scharf statt weich, besonders die hinteren»), Branch `agents6`.
|
||||||
|
>
|
||||||
|
> **Vollzogen am 23.08.2026.** Mit Runde 8 wurde Livia zur Research- & Market-Intelligence-
|
||||||
|
> Agentin, und die Exposé-Funktion ist vollständig aus der Anwendung entfernt worden — nicht
|
||||||
|
> bloss nachgeordnet. Alle 23 Dateien aus Teil A sind gelöscht, die Eingriffe aus Teil B
|
||||||
|
> ausgeführt. Dieses Manifest ist damit von der Vorsorge zur Wiedereinbau-Anleitung geworden.
|
||||||
|
>
|
||||||
|
> **Abweichungen gegenüber der ursprünglichen Annahme** — beim Wiedereinbau beachten:
|
||||||
|
> - Livia ist **nicht** aus den Registern entfernt worden. Sie bleibt im Team, nur mit anderer
|
||||||
|
> Rolle. Abschnitt 2.2 beschreibt einen Rückbau, der so nicht stattgefunden hat: die Einträge
|
||||||
|
> in `agentWorkspaces.ts`, `teamAgents.ts`, `agentPhotos.ts` und `agentDirectory.ts` stehen
|
||||||
|
> unverändert, dort wurde nur die Funktionsbezeichnung geändert. Das Team hat weiterhin fünf
|
||||||
|
> Mitglieder; die Sitzordnung im Meetingraum ist unberührt.
|
||||||
|
> - Die Weiterleitung bei Nora (Abschnitt 2.5) ist entfallen, weil sie ohne Exposé kein Ziel
|
||||||
|
> mehr hatte. Mit Runde 8 fliessen Leads in die Gegenrichtung: Livia recherchiert, Nora
|
||||||
|
> gleicht ab.
|
||||||
|
> - Brunos Abschnitt «Lagebericht (von Livia)» steht unverändert — er hing an der Lage-, nicht
|
||||||
|
> an der Exposé-Funktion.
|
||||||
|
> - Zusätzlich entfernt, weil erst mit Runde 8 entstanden: `ExposeLeadSection.tsx`.
|
||||||
|
>
|
||||||
|
> Fachliche Beschreibung und System-Prompt liegen separat in
|
||||||
|
> [`../../LIVIA_PROFIL_UND_PROMPT.md`](../../LIVIA_PROFIL_UND_PROMPT.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Vor dem Rückbau: Git-Marke setzen
|
||||||
|
|
||||||
|
Dieses Paket ist die **zweite** Absicherung. Die erste und verlässlichste ist die
|
||||||
|
Versionsgeschichte. Deshalb vor dem Entfernen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a livia-vor-rueckbau -m "Letzter Stand mit vollständiger Livia-Funktionalität"
|
||||||
|
git push origin livia-vor-rueckbau
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach ist ein vollständiger Wiedereinbau jederzeit auch so möglich:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Einzelne Datei zurückholen
|
||||||
|
git checkout livia-vor-rueckbau -- src/components/expose/
|
||||||
|
|
||||||
|
# Oder den kompletten Rückbau-Commit umkehren
|
||||||
|
git revert <commit-des-rueckbaus>
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Archiv hier ist für den Fall gedacht, dass die Historie nicht zur Verfügung steht —
|
||||||
|
etwa weil in einem anderen Repository, einem neuen Projekt oder aus einem Export heraus
|
||||||
|
gearbeitet wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Teil A — Dateien, die ganz zu Livia gehören
|
||||||
|
|
||||||
|
Diese 23 Dateien haben ausserhalb von Livia keinen Zweck. Beim Rückbau werden sie gelöscht;
|
||||||
|
beim Wiedereinbau werden sie aus `archive/livia/src/…` an dieselbe Stelle zurückkopiert.
|
||||||
|
|
||||||
|
| Datei | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `src/pages/supply/MyListings.tsx` | Livias Arbeitsplatz (Route `/supply/my-listings`) |
|
||||||
|
| `src/components/expose/ExposeWizard.tsx` | Drei-Schritt-Assistent Hochladen → Exposé → Export |
|
||||||
|
| `src/components/expose/ExposeFieldGroup.tsx` | Feldgruppe eines Dossier-Bereichs |
|
||||||
|
| `src/components/expose/ExposeMediaManager.tsx` | Bild- und Dokumentenkuration |
|
||||||
|
| `src/components/expose/LeadTable.tsx` | Leadliste aktiv / archiviert |
|
||||||
|
| `src/components/expose/LeadWorkspace.tsx` | Arbeitsbereich unter dem geöffneten Lead |
|
||||||
|
| `src/components/expose/index.ts` | Barrel |
|
||||||
|
| `src/domain/expose.ts` | `ExposeDraft`, `ExposeImage`, `ExposeDocument`, `ExposeValues` |
|
||||||
|
| `src/domain/exposeLead.ts` | `ExposeLead`, `ExposeLeadStatus` |
|
||||||
|
| `src/provider/IExposeProvider.ts` | Provider-Schnittstelle Entwürfe |
|
||||||
|
| `src/provider/MockupExposeProvider.ts` | Mock-Implementierung Entwürfe |
|
||||||
|
| `src/provider/IExposeLeadProvider.ts` | Provider-Schnittstelle Leads |
|
||||||
|
| `src/provider/MockupExposeLeadProvider.ts` | Mock-Implementierung Leads |
|
||||||
|
| `src/services/exposeService.ts` | Vorbelegung, Bildimport, Speichern, Erstellen |
|
||||||
|
| `src/services/exposeLeadService.ts` | Leads lesen, anlegen, archivieren |
|
||||||
|
| `src/services/exposeExport.ts` | Vorschau (HTML), PDF (Druckdialog), Word (`.doc`) |
|
||||||
|
| `src/services/ai/mock/exposeTextBuilder.ts` | **Deterministischer Textbaustein** (Kernstück) |
|
||||||
|
| `src/hooks/useExpose.ts` | React-Query-Hooks für Entwürfe |
|
||||||
|
| `src/hooks/useExposeLeads.ts` | React-Query-Hooks für Leads |
|
||||||
|
| `src/lib/exposeFields.ts` | **Die 11 Bereiche und 9 Pflichtfelder** |
|
||||||
|
| `src/mock-data/exposeLeads.ts` | Seed-Daten Leads |
|
||||||
|
| `src/mock-data/agents/livia.ts` | Personaldossier |
|
||||||
|
| `src/assets/team/livia.jpg` | Porträt |
|
||||||
|
|
||||||
|
Es gibt **keine** Barrel-Exporte in `src/domain/index.ts` oder `src/hooks/index.ts`, die
|
||||||
|
angepasst werden müssten — geprüft. Der einzige Barrel ist `src/components/expose/index.ts`,
|
||||||
|
und der verschwindet mit dem Ordner.
|
||||||
|
|
||||||
|
**Wiedereinbau Teil A:**
|
||||||
|
```bash
|
||||||
|
cp -r archive/livia/src/. src/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Teil B — Gemeinsam genutzte Dateien mit chirurgischen Eingriffen
|
||||||
|
|
||||||
|
Diese 16 Dateien bleiben bestehen; aus ihnen wird Livia herausgeschnitten. **Das ist der Teil,
|
||||||
|
der bei einem Wiedereinbau ohne Notizen Arbeit macht** — deshalb steht hier jede Stelle einzeln.
|
||||||
|
Die Zeilennummern beziehen sich auf Commit `5527448` und verschieben sich nach dem Rückbau.
|
||||||
|
|
||||||
|
### 2.1 Routing
|
||||||
|
|
||||||
|
**`src/App.tsx`** — zwei Zeilen (31 und 74):
|
||||||
|
```tsx
|
||||||
|
const MyListings = lazy(() => import('./pages/supply/MyListings'))
|
||||||
|
```
|
||||||
|
```tsx
|
||||||
|
<Route path="/supply/my-listings" element={<MyListings />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Zentrale Register — hier hängt am meisten dran
|
||||||
|
|
||||||
|
**`src/lib/agentWorkspaces.ts`** — eine einzige Zeile in `AGENT_WORKSPACES` (Zeile 28):
|
||||||
|
```ts
|
||||||
|
{ id: 'livia', name: 'Livia', role: 'Exposé Master', path: ROUTES.SUPPLY.AGENT_LIVIA },
|
||||||
|
```
|
||||||
|
> **Wichtig:** Diese Liste ist die zentrale Quelle für Sidebar (`appShellConfig.ts`),
|
||||||
|
> globales Navigationsmenü (`GlobalNavMenu.tsx`), Willkommensdialog (`WelcomeDialog.tsx`) und
|
||||||
|
> die Sitzplätze im 3D-Meetingraum der Startseite (`meetingRoomSeats.ts`). Ein Eintrag hier
|
||||||
|
> entfernt Livia an allen vier Stellen zugleich — und bringt sie ebenso überall zurück.
|
||||||
|
> Nach dem Rückbau ist das Team **vier** statt fünf Personen; die Sitzordnung im Meetingraum
|
||||||
|
> ändert sich dadurch sichtbar.
|
||||||
|
|
||||||
|
**`src/mock-data/teamAgents.ts`** — drei Stellen:
|
||||||
|
```ts
|
||||||
|
import { liviaAgent } from './agents/livia' // Zeile 16
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
liviaAgent, // in mockTeamAgents, Position 3
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
LIVIA: 'livia', // in AGENT_IDS
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/components/team/agentPhotos.ts`** — zwei Zeilen (21 und 62):
|
||||||
|
```ts
|
||||||
|
import livia from '../../assets/team/livia.jpg'
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
livia,
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/lib/constants.ts`** — Route und Fristwert:
|
||||||
|
```ts
|
||||||
|
AGENT_LIVIA: '/supply/my-listings', // Zeile 102
|
||||||
|
export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen // Zeile 62
|
||||||
|
```
|
||||||
|
sowie der ganze Block «Livia — Leads und Exposé» (Zeilen 319–356):
|
||||||
|
`EXPOSE_LEAD_TABS`, `ExposeLeadTab`, `EXPOSE_LEAD_TAB_LABELS`, `EXPOSE_STEPS`,
|
||||||
|
`EXPOSE_IMAGE_CATEGORY_LABELS`, `EXPOSE_DOCUMENT_TYPE_LABELS`, `EXPOSE_TONALITY_LABELS`.
|
||||||
|
Der Block liegt wörtlich in [`fragmente/constants_livia.ts.txt`](./fragmente/constants_livia.ts.txt).
|
||||||
|
|
||||||
|
### 2.3 KI-Schicht
|
||||||
|
|
||||||
|
Wörtlich in [`fragmente/aiService_livia.ts.txt`](./fragmente/aiService_livia.ts.txt).
|
||||||
|
|
||||||
|
| Datei | Was entfällt |
|
||||||
|
|---|---|
|
||||||
|
| `src/services/ai/IAIService.ts` | Block «Exposé texts (Livia)»: `ExposeTextSection`, `ExposeTonality`, `ExposeTextInput`, `ExposeTextResult` (Z. 200–232) und die Interface-Methode `generateExposeText` (Z. 281) |
|
||||||
|
| `src/services/ai/mock/MockAIService.ts` | Importe `ExposeTextInput`/`ExposeTextResult` (Z. 17–18), Import `buildExposeText` (Z. 25), Implementierung `generateExposeText` (Z. 305–309) |
|
||||||
|
| `src/services/ai/backend/BackendAIService.ts` | Importe (Z. 62–63) und die Delegation `generateExposeText` an den Mock (Z. 687–689) |
|
||||||
|
|
||||||
|
> Achtung beim Wiedereinbau: `generateExposeText` steht im **Interface** `IAIService`. Fehlt
|
||||||
|
> die Methode in einer der beiden Implementierungen, bricht der Typecheck — beide zusammen
|
||||||
|
> zurückbauen.
|
||||||
|
|
||||||
|
### 2.4 Auslastungsanzeige der Agenten
|
||||||
|
|
||||||
|
**`src/services/agentWorkloadService.ts`**:
|
||||||
|
- `expose: 5` in `URGENT_FROM` (Z. 36)
|
||||||
|
- Importe `ExposeLeadStatus` / `ExposeLead` (Z. 3–4) und das Feld `exposeLeads` in der
|
||||||
|
Eingabe (Z. 51)
|
||||||
|
- der ganze Block «Livia: weitergeleitete Leads» (Z. 117–128), inklusive der Sprachfassungen
|
||||||
|
«Kein Exposé offen. Leite mir einen Lead weiter, dann lege ich los.» und
|
||||||
|
«{n} Leads warten auf ein Exposé von mir.»
|
||||||
|
|
||||||
|
**`src/hooks/useAgentWorkload.ts`**: Import `useExposeLeads`, die Zeile
|
||||||
|
`const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()`, das Feld in
|
||||||
|
`computeAgentWorkload({ … })`, die Abhängigkeitsliste und `exposeLoading` im `isLoading`.
|
||||||
|
|
||||||
|
Beides wörtlich in [`fragmente/workload_livia.ts.txt`](./fragmente/workload_livia.ts.txt).
|
||||||
|
|
||||||
|
### 2.5 Die Agentenkette Nora → Livia → Bruno
|
||||||
|
|
||||||
|
Das ist inhaltlich der heikelste Teil — hier verschwindet nicht nur Livia, sondern der
|
||||||
|
sichtbare Beweis, dass die Agenten einander zuarbeiten.
|
||||||
|
|
||||||
|
**`src/components/market-leads/LeadDetail.tsx`** (Noras Seite):
|
||||||
|
- Import `useCreateExposeLead` (Z. 5), `const forwardLead = useCreateExposeLead()` (Z. 42)
|
||||||
|
- die Funktion `handleForward()` (Z. 62–77)
|
||||||
|
- der Knopf **«An Livia zur Exposé-Erstellung weiterleiten»** (Z. 194–204) samt
|
||||||
|
Mehrfachauswahl der Objekte und der Erfolgsmeldung
|
||||||
|
«An Livia weitergeleitet — {n} Objekt(e) für {Firma}.»
|
||||||
|
|
||||||
|
Wörtlich in [`fragmente/leadDetail_weiterleitung.tsx.txt`](./fragmente/leadDetail_weiterleitung.tsx.txt).
|
||||||
|
|
||||||
|
**`src/components/visits/VisitPreparationDrawer.tsx`** (Brunos Briefing), Zeile 154:
|
||||||
|
```tsx
|
||||||
|
<SectionTitle>Lagebericht (von Livia)</SectionTitle>
|
||||||
|
```
|
||||||
|
Dazu das Feld in `src/domain/visitAssignment.ts` (Z. 27, Kommentar «Lagebericht, den Bruno bei
|
||||||
|
Livia angefragt hat») und zwei Mocktexte in `src/mock-data/visitAssignments.ts` (Z. 46 und 84),
|
||||||
|
die beide mit «Lagebericht von Livia: …» beginnen.
|
||||||
|
|
||||||
|
> **Entscheidung nötig** — siehe Abschnitt 4.
|
||||||
|
|
||||||
|
### 2.6 Mockdaten der Verwaltungsebene
|
||||||
|
|
||||||
|
| Datei | Livia-Anteil | Archivkopie |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/mock-data/agentDirectory.ts` | Eintrag `id: 'livia'` (Z. 42–52) | im Fragment |
|
||||||
|
| `src/mock-data/agentConnections.ts` | `AGENT_IDS.LIVIA` in vier `usedByAgentIds`-Listen (Z. 35, 205, 289, 380) — **nur die ID entfernen, nicht die Anbindung** | — |
|
||||||
|
| `src/mock-data/agentProtocol.ts` | fünf Einträge (Z. 369–449, 691–703, 836–848, 945–956, 972–983) | [`fragmente/agentProtocol_livia.ts.txt`](./fragmente/agentProtocol_livia.ts.txt) |
|
||||||
|
| `src/mock-data/agentWorkItems.ts` | `awi-livia-01` und `awi-livia-02` (Z. 1226–1498) | [`fragmente/agentWorkItems_livia.ts.txt`](./fragmente/agentWorkItems_livia.ts.txt) |
|
||||||
|
|
||||||
|
### 2.7 Tests
|
||||||
|
|
||||||
|
| Datei | Was bricht |
|
||||||
|
|---|---|
|
||||||
|
| `src/components/layout/__tests__/appShellConfig.test.ts` | Z. 52 erwartet `['Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina']`; Z. 88 erwartet, dass `ROUTES.SUPPLY.AGENT_LIVIA` den Seitennamen «Livia» ergibt |
|
||||||
|
| `src/services/__tests__/agentWorkloadService.test.ts` | verwendet `exposeLeads` in der Eingabe und prüft Livias Auslastungsbahn |
|
||||||
|
|
||||||
|
### 2.8 Hilfsskript
|
||||||
|
|
||||||
|
`scripts/build-agent-portraits.mjs` — Zuordnung `'image3.jpg': 'livia'` (Z. 51). Nur relevant,
|
||||||
|
wenn die Porträts neu erzeugt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Reihenfolge
|
||||||
|
|
||||||
|
**Rückbau** (von aussen nach innen, damit der Typecheck bei jedem Schritt hilft):
|
||||||
|
1. `App.tsx` — Route heraus
|
||||||
|
2. `agentWorkspaces.ts`, `teamAgents.ts`, `agentPhotos.ts` — aus den Registern nehmen
|
||||||
|
3. `LeadDetail.tsx` — Weiterleitung, `useAgentWorkload.ts` + `agentWorkloadService.ts` — Bahn
|
||||||
|
4. AI-Schicht (Interface **und** beide Implementierungen zusammen)
|
||||||
|
5. Mockdaten (Directory, Connections, Protokoll, WorkItems)
|
||||||
|
6. Tests anpassen
|
||||||
|
7. `constants.ts` — Block und Route
|
||||||
|
8. Erst jetzt die 23 Dateien aus Teil A löschen
|
||||||
|
9. `npx tsc --noEmit && npm run lint && npm test`
|
||||||
|
|
||||||
|
**Wiedereinbau** (genau umgekehrt):
|
||||||
|
1. `cp -r archive/livia/src/. src/`
|
||||||
|
2. `constants.ts` aus dem Fragment ergänzen
|
||||||
|
3. AI-Schicht (Interface und beide Implementierungen)
|
||||||
|
4. Mockdaten aus den Fragmenten
|
||||||
|
5. Register: `agentWorkspaces`, `teamAgents`, `agentPhotos`
|
||||||
|
6. Auslastung, Weiterleitung bei Nora, Brunos Lagebericht-Abschnitt
|
||||||
|
7. Route in `App.tsx`
|
||||||
|
8. Tests zurück auf fünf Agenten
|
||||||
|
9. `npx tsc --noEmit && npm run lint && npm test`
|
||||||
|
|
||||||
|
**Prüfung nach dem Wiedereinbau:** `/supply/my-listings` öffnen, einen Lead aufklappen,
|
||||||
|
ein Pflichtfeld leeren (rote Umrandung erwartet), KI-Entwurf für den Objektbeschrieb auslösen
|
||||||
|
(bei fehlendem Ausbaustandard darf **kein** Text entstehen, sondern die Lücke gemeldet werden),
|
||||||
|
Export als PDF und Word auslösen. Zusätzlich bei Nora ein Signal an Livia weiterleiten und
|
||||||
|
prüfen, ob der Lead auf Livias Seite erscheint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Entscheidungen, die beim Rückbau zu treffen sind
|
||||||
|
|
||||||
|
Diese drei Punkte sind **keine** technischen Fragen — sie sollten bewusst entschieden und
|
||||||
|
hier festgehalten werden, sonst fehlt beim Wiedereinbau die Begründung.
|
||||||
|
|
||||||
|
1. **Bleibt der Abschnitt «Lagebericht (von Livia)» in Brunos Besichtigungsbriefing?**
|
||||||
|
Bleibt er stehen, zeigt das Briefing einen Lagebericht ohne Urheber — die Belegkette wird
|
||||||
|
unklar. Wird er entfernt, verliert Brunos Briefing einen inhaltlichen Baustein. Empfehlung:
|
||||||
|
Feld und Text belassen, aber die Beschriftung von «(von Livia)» auf «Lagebericht» ändern —
|
||||||
|
der kleinste Eingriff, der beim Wiedereinbau in einer Zeile rückgängig ist.
|
||||||
|
|
||||||
|
2. **Bleibt bei Nora der Knopf «An Livia weiterleiten»?**
|
||||||
|
Ohne Livia hat die Weiterleitung kein Ziel; der Knopf muss weg. Damit entfällt der
|
||||||
|
sichtbare Übergabemoment zwischen zwei Agenten — in der Präsentation bisher das stärkste
|
||||||
|
Argument. Wenn der Case später wieder gebraucht wird, ist dieser Knopf das Erste, was
|
||||||
|
zurückkommen muss.
|
||||||
|
|
||||||
|
3. **Wie wird aus fünf Agenten vier?**
|
||||||
|
Personalzahl, Sitzordnung im Meetingraum der Startseite, Willkommensdialog und die
|
||||||
|
Reihenfolge «Ferdi, Bruno, Livia, Nora, Sina» hängen alle an `AGENT_WORKSPACES`. Technisch
|
||||||
|
passiert das von selbst, optisch aber nicht folgenlos — die Startseite sollte nach dem
|
||||||
|
Rückbau angeschaut werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Inhalt dieses Verzeichnisses
|
||||||
|
|
||||||
|
```
|
||||||
|
archive/livia/
|
||||||
|
├─ WIEDERHERSTELLUNG.md ← dieses Dokument
|
||||||
|
├─ src/ ← 23 Originaldateien im Original-Pfadlayout
|
||||||
|
│ ├─ pages/supply/MyListings.tsx
|
||||||
|
│ ├─ components/expose/… (6 Dateien)
|
||||||
|
│ ├─ domain/… (2)
|
||||||
|
│ ├─ provider/… (4)
|
||||||
|
│ ├─ services/… (3 + ai/mock/exposeTextBuilder.ts)
|
||||||
|
│ ├─ hooks/… (2)
|
||||||
|
│ ├─ lib/exposeFields.ts
|
||||||
|
│ ├─ mock-data/… (2)
|
||||||
|
│ └─ assets/team/livia.jpg
|
||||||
|
└─ fragmente/ ← wörtliche Ausschnitte aus geteilten Dateien
|
||||||
|
├─ constants_livia.ts.txt
|
||||||
|
├─ aiService_livia.ts.txt
|
||||||
|
├─ workload_livia.ts.txt
|
||||||
|
├─ leadDetail_weiterleitung.tsx.txt
|
||||||
|
├─ agentDirectory_livia.ts.txt
|
||||||
|
├─ agentProtocol_livia.ts.txt
|
||||||
|
└─ agentWorkItems_livia.ts.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Fachliche Beschreibung, Aufgabenkatalog und System-Prompt: `LIVIA_PROFIL_UND_PROMPT.md` im
|
||||||
|
Repo-Root.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
id: 'livia',
|
||||||
|
name: 'Livia',
|
||||||
|
role: 'Exposé Master',
|
||||||
|
email: 'livia@property-on.ch',
|
||||||
|
level: AgentLevel.CORE,
|
||||||
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
rollout: AgentRollout.IN_PROGRESS,
|
||||||
|
isCore: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// ── agentProtocol.ts Zeilen 369,449 ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'proto-009',
|
||||||
|
agentId: AGENT_IDS.LIVIA,
|
||||||
|
timestamp: '2026-05-20T09:10:00.000Z',
|
||||||
|
eventType: AgentProtocolEventType.TASK_RUN,
|
||||||
|
title: 'Lagebeschrieb Neuhegistrasse 12 in Kurz- und Langfassung erstellt',
|
||||||
|
description:
|
||||||
|
'Mikrolage im Radius von 800 Metern, Makrolage Winterthur Neuhegi, Erreichbarkeit und geplante Umfeldentwicklung ausgewertet. Kurztext 574 Zeichen, Langtext 2 280 Zeichen, jede Aussage mit Quelle und Abrufdatum.',
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
objectId: 'OBJ-2503',
|
||||||
|
channel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
triggeredBy: AgentTriggerSource.SYSTEM,
|
||||||
|
input: [
|
||||||
|
{ label: 'Objektadresse', value: 'Neuhegistrasse 12, 8404 Winterthur' },
|
||||||
|
{ label: 'Quartier', value: 'Winterthur Neuhegi' },
|
||||||
|
{ label: 'Analyseradius', value: '800 Meter' },
|
||||||
|
{ label: 'Tonalität', value: 'sachlich' },
|
||||||
|
],
|
||||||
|
processingSteps: [
|
||||||
|
{
|
||||||
|
id: 'proto-009-ps-1',
|
||||||
|
label: 'Adresse gegen die amtlichen Adressdaten geprüft und dem Quartier zugeordnet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proto-009-ps-2',
|
||||||
|
label: 'Mikrolage abgegangen',
|
||||||
|
detail: 'Nachbarschaft, Erdgeschossnutzungen, Immissionslage, Publikumsfrequenz, Parkierung',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proto-009-ps-3',
|
||||||
|
label: 'Makrolage eingeordnet',
|
||||||
|
detail: 'Beschäftigtenentwicklung, Branchenmix und Bedeutung im Wirtschaftsraum Winterthur',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proto-009-ps-4',
|
||||||
|
label: 'Erreichbarkeit und Nahversorgung mit Distanz und Wegzeit erfasst',
|
||||||
|
},
|
||||||
|
{ id: 'proto-009-ps-5', label: 'Amtliche Publikationen im Umfeld gesichtet' },
|
||||||
|
{ id: 'proto-009-ps-6', label: 'Kurztext und Langtext geschrieben, Quellen angehängt' },
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
label: 'Kurztext für Inserat',
|
||||||
|
value:
|
||||||
|
'Die Fläche liegt in Winterthur Neuhegi, einem in den vergangenen zehn Jahren umgebauten Arbeitsplatzquartier mit Wohn-, Büro- und Produktionsnutzung. Die Haltestelle Neuhegi ist in vier Gehminuten erreichbar, der Bahnhof Winterthur Grüze in neun Minuten mit dem Bus. Einkaufen, Verpflegung und Kinderbetreuung befinden sich innerhalb von 400 Metern.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Abschnitte des Langtexts',
|
||||||
|
value: [
|
||||||
|
'Mikrolage — Nachbarschaft und Erdgeschossnutzungen',
|
||||||
|
'Makrolage — Wirtschaftsraum Winterthur',
|
||||||
|
'Erreichbarkeit — öffentlicher Verkehr, Autobahn A1, Velowege',
|
||||||
|
'Nahversorgung — Einkaufen, Verpflegung, Betreuung, Gesundheit',
|
||||||
|
'Umfeldentwicklung — Arealentwicklung nördlich der Neuhegistrasse',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ label: 'Belegte Aussagen', value: '31 von 31 Aussagen mit Quelle und Abrufdatum' },
|
||||||
|
],
|
||||||
|
sourceReferences: [
|
||||||
|
{
|
||||||
|
id: 'proto-009-src-1',
|
||||||
|
label: 'Amtliche Publikation zur Arealentwicklung',
|
||||||
|
documentName: 'Amtliche Meldung — Gestaltungsplan Neuhegi Nord',
|
||||||
|
locator: 'Publikation vom 24.04.2026',
|
||||||
|
quote:
|
||||||
|
'«Der Gestaltungsplan für das Areal nördlich der Neuhegistrasse wurde bewilligt; die Bauarbeiten beginnen im ersten Quartal 2027.»',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proto-009-src-2',
|
||||||
|
label: 'Fahrplandaten Haltestelle Neuhegi',
|
||||||
|
documentName: 'Öffentliche Verkehrsdaten Winterthur',
|
||||||
|
locator: 'Abruf 20.05.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proto-009-src-3',
|
||||||
|
label: 'Objektstammdaten OBJ-2503',
|
||||||
|
documentName: 'Bewirtschaftungssystem — Objektblatt OBJ-2503',
|
||||||
|
locator: 'Adresse und Nutzungsart',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── agentProtocol.ts Zeilen 691,703 ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'proto-019',
|
||||||
|
agentId: AGENT_IDS.LIVIA,
|
||||||
|
timestamp: '2026-05-18T10:05:00.000Z',
|
||||||
|
eventType: AgentProtocolEventType.MESSAGE_PROCESSED,
|
||||||
|
title: 'Auftrag für den Lagebeschrieb Wankdorffeldstrasse 8 entgegengenommen',
|
||||||
|
description:
|
||||||
|
'Die Adresse wurde gegen die amtlichen Adressdaten geprüft und dem Entwicklungsgebiet Bern Wankdorf zugeordnet. Der Arbeitsauftrag ist eingereiht; die Umfeldanalyse läuft im Radius von 800 Metern.',
|
||||||
|
status: AgentProtocolStatus.INFO,
|
||||||
|
objectId: 'OBJ-2559',
|
||||||
|
channel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
triggeredBy: AgentTriggerSource.SYSTEM,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── agentProtocol.ts Zeilen 836,848 ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'proto-028',
|
||||||
|
agentId: AGENT_IDS.LIVIA,
|
||||||
|
timestamp: '2026-05-07T10:15:00.000Z',
|
||||||
|
eventType: AgentProtocolEventType.TASK_RUN,
|
||||||
|
title: 'Umfeldänderung Winterthur Neuhegi gemeldet',
|
||||||
|
description:
|
||||||
|
'Die bewilligte Arealentwicklung nördlich der Neuhegistrasse verändert den Abschnitt «Umfeldentwicklung» in vier Lagebeschrieben. Die betroffenen Textabschnitte sind markiert, die Quelle mit Publikationsdatum hinterlegt.',
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
objectId: 'OBJ-2503',
|
||||||
|
channel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
triggeredBy: AgentTriggerSource.SYSTEM,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── agentProtocol.ts Zeilen 945,956 ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'proto-034',
|
||||||
|
agentId: AGENT_IDS.LIVIA,
|
||||||
|
timestamp: '2026-04-01T05:30:00.000Z',
|
||||||
|
eventType: AgentProtocolEventType.TASK_RUN,
|
||||||
|
title: 'Quartalslauf: 184 Lagebeschriebe gegen die Quellen geprüft',
|
||||||
|
description:
|
||||||
|
'162 Lagebausteine wurden bestätigt, 14 Abschnitte als veraltet markiert und erneuert, 9 Objekte für den nächsten Lauf vorgemerkt. Jede Prüfung ist je Objekt nachvollziehbar protokolliert.',
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
channel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
triggeredBy: AgentTriggerSource.SYSTEM,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── agentProtocol.ts Zeilen 972,983 ──────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'proto-036',
|
||||||
|
agentId: AGENT_IDS.LIVIA,
|
||||||
|
timestamp: '2026-03-05T08:50:00.000Z',
|
||||||
|
eventType: AgentProtocolEventType.SETTINGS_SAVED,
|
||||||
|
title: 'Analyseradius und Quellentypen gespeichert',
|
||||||
|
description:
|
||||||
|
'Der Radius der Umfeldanalyse wurde von 500 auf 800 Meter erhöht und «Medienberichte» als Quellentyp abgewählt. Bestehende Lagebeschriebe werden im nächsten Quartalslauf nachgeführt.',
|
||||||
|
status: AgentProtocolStatus.SUCCESS,
|
||||||
|
triggeredBy: AgentTriggerSource.USER,
|
||||||
|
triggeredByName: OPERATOR,
|
||||||
|
},
|
||||||
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// ── Livia — erledigt ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01',
|
||||||
|
agentId: AGENT_IDS.LIVIA,
|
||||||
|
kind: AgentWorkItemKind.LOCATION_MODULE,
|
||||||
|
area: AgentDomainArea.LOCATION,
|
||||||
|
title: 'Lagebeschrieb erstellt — Hardturmstrasse 118, Zürich Kreis 5',
|
||||||
|
summary:
|
||||||
|
'Zum neuen Vermarktungsauftrag entstanden ein Kurztext für Inserat und Portal sowie ein Langtext für das Exposé, jeweils mit Quellenverzeichnis und Abrufdatum zu jeder Aussage.',
|
||||||
|
objectId: 'OBJ-2365',
|
||||||
|
objectLabel: 'Hardturmstrasse 118, 8005 Zürich — Bürofläche 640 m², Zürich Kreis 5',
|
||||||
|
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
targetChannel: AgentChannelType.EMAIL,
|
||||||
|
createdAt: '2026-04-09T09:15:00.000Z',
|
||||||
|
completedAt: '2026-04-09T11:40:00.000Z',
|
||||||
|
priority: AgentWorkItemPriority.LOW,
|
||||||
|
status: AgentWorkItemStatus.COMPLETED,
|
||||||
|
requiresDecision: false,
|
||||||
|
inputs: [
|
||||||
|
{ label: 'Objektadresse aus dem Vermarktungsauftrag', value: 'Hardturmstrasse 118, 8005 Zürich' },
|
||||||
|
{ label: 'Quartierzuordnung', value: 'Zürich Kreis 5 (Escher Wyss), geprüft gegen die amtlichen Adressdaten' },
|
||||||
|
{ label: 'Untersuchungsradius Mikrolage', value: '500 Meter Umkreis, Erdgeschossnutzungen und Erreichbarkeit' },
|
||||||
|
{ label: 'Auftraggebende Person', value: 'Nadine Brunner, Vermarktung Zürich' },
|
||||||
|
],
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
label: 'Kurztext für Inserat und Portal',
|
||||||
|
value:
|
||||||
|
'Die Fläche liegt im Kreis 5 zwischen Escher-Wyss-Platz und Hardbrücke, in einem Quartier mit hohem Anteil an Dienstleistung, Gastronomie und Kreativwirtschaft. Die Haltestelle Escher-Wyss-Platz ist in vier Gehminuten erreichbar, der Bahnhof Hardbrücke in acht Minuten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Langtext für das Exposé',
|
||||||
|
value:
|
||||||
|
'Dreiteiliger Text mit Mikrolage (Nachbarschaft, Erdgeschossnutzungen, Publikumsfrequenz, Parkierung), Makrolage (Wirtschaftsstruktur Stadt Zürich, Beschäftigtenentwicklung Kreis 5, Steuerlage) und Umfeldentwicklung — insgesamt 3 940 Zeichen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Erreichbarkeit',
|
||||||
|
value: [
|
||||||
|
'Tram- und Bushaltestelle Escher-Wyss-Platz: 300 Meter, 4 Gehminuten',
|
||||||
|
'Bahnhof Zürich Hardbrücke: 650 Meter, 8 Gehminuten',
|
||||||
|
'Autobahnanschluss Zürich-Hardturm: 1,4 Kilometer',
|
||||||
|
'Veloweg entlang der Hardturmstrasse, durchgehend abgetrennt',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Nahversorgung',
|
||||||
|
value: [
|
||||||
|
'Einkaufen: zwei Supermärkte innerhalb von 400 Metern',
|
||||||
|
'Verpflegung: 14 Gastronomiebetriebe im Umkreis von 300 Metern',
|
||||||
|
'Kinderbetreuung: zwei Kindertagesstätten innerhalb von 600 Metern',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Umfeldentwicklung',
|
||||||
|
value:
|
||||||
|
'Arealentwicklung Hardturm-Areal in Etappen bis 2029; Zonenänderung rechtskräftig seit 2024 (Quelle: amtliche Publikation, abgerufen 09.04.2026).',
|
||||||
|
},
|
||||||
|
{ label: 'Quellenverzeichnis', value: '11 Quellen mit Abrufdatum, je Aussage zugeordnet' },
|
||||||
|
],
|
||||||
|
processingSteps: [
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-step-1',
|
||||||
|
label: 'Adresse aufgenommen und dem Quartier zugeordnet',
|
||||||
|
detail: 'Schreibweise gegen die amtlichen Adressdaten geprüft, Zuordnung Zürich Kreis 5.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-step-2',
|
||||||
|
label: 'Mikrolage im Umkreis von 500 Metern abgegangen',
|
||||||
|
detail: 'Nachbarschaft, Erdgeschossnutzungen, Immissionslage, Publikumsfrequenz und Parkierung erfasst.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-step-3',
|
||||||
|
label: 'Makrolage eingeordnet',
|
||||||
|
detail: 'Wirtschaftsstruktur, Beschäftigtenentwicklung und Steuerlage der Stadt Zürich ausgewertet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-step-4',
|
||||||
|
label: 'Amtliche Publikationen zum Umfeld gesichtet',
|
||||||
|
detail: 'Arealentwicklung und Zonenänderung als für das Objekt relevant eingestuft.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-step-5',
|
||||||
|
label: 'Kurz- und Langtext geschrieben, Quellen angehängt',
|
||||||
|
detail: 'Je Aussage Quelle und Abrufdatum hinterlegt, Ergebnis am Objekt abgelegt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReferences: [
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-src-1',
|
||||||
|
label: 'Amtliche Publikation zur Zonenänderung Hardturm',
|
||||||
|
documentName: 'Amtliche-Publikation_Zonenaenderung-Hardturm_2024-11-08.pdf',
|
||||||
|
locator: 'Seite 3, abgerufen 09.04.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-src-2',
|
||||||
|
label: 'Fahrplanauskunft öffentlicher Verkehr',
|
||||||
|
documentName: 'Erreichbarkeitsauswertung_OBJ-2365_2026-04-09.pdf',
|
||||||
|
locator: 'Tabelle «Haltestellen im Umkreis», abgerufen 09.04.2026',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-src-3',
|
||||||
|
label: 'Quellenverzeichnis zum Lagebeschrieb',
|
||||||
|
documentName: 'Lagebeschrieb_OBJ-2365_Quellen_2026-04-09.pdf',
|
||||||
|
locator: 'Seiten 1–2, 11 Einträge',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availableActions: [AgentWorkItemAction.OPEN_SOURCE, AgentWorkItemAction.MARK_DONE],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-hist-1',
|
||||||
|
at: '2026-04-09T09:15:00.000Z',
|
||||||
|
actor: 'Livia',
|
||||||
|
action: 'Vermarktungsauftrag übernommen',
|
||||||
|
note: 'Adresse geprüft und dem Quartier Zürich Kreis 5 zugeordnet.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-hist-2',
|
||||||
|
at: '2026-04-09T11:40:00.000Z',
|
||||||
|
actor: 'Livia',
|
||||||
|
action: 'Lagebeschrieb abgelegt und zugestellt',
|
||||||
|
note: 'Kurz- und Langtext mit 11 Quellen an die Vermarktung Zürich.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-01-hist-3',
|
||||||
|
at: '2026-04-14T08:05:00.000Z',
|
||||||
|
actor: 'Nadine Brunner',
|
||||||
|
action: 'Text für das Inserat übernommen',
|
||||||
|
note: 'Kurztext unverändert im Portalauftritt veröffentlicht.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Livia — pendent: Review eines unsicheren Ergebnisses ───────────────────
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02',
|
||||||
|
agentId: AGENT_IDS.LIVIA,
|
||||||
|
kind: AgentWorkItemKind.LOCATION_MODULE,
|
||||||
|
area: AgentDomainArea.LOCATION,
|
||||||
|
title: 'Unsichere Aussage zum Entwicklungsgebiet Wankdorf — Review erbeten',
|
||||||
|
summary:
|
||||||
|
'Für den Langtext zum Objekt in Bern liegen zwei Quellen mit unterschiedlichen Angaben zur Inbetriebnahme der neuen Haltestelle vor. Die Aussage bleibt aus dem Text ausgeklammert, bis entschieden ist, welche Angabe gilt.',
|
||||||
|
objectId: 'OBJ-2733',
|
||||||
|
objectLabel: 'Wankdorffeldstrasse 12, 3014 Bern — Bürofläche 1. bis 3. Obergeschoss, 1 150 m²',
|
||||||
|
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
targetChannel: AgentChannelType.EMAIL,
|
||||||
|
createdAt: '2026-05-07T07:45:00.000Z',
|
||||||
|
priority: AgentWorkItemPriority.MEDIUM,
|
||||||
|
status: AgentWorkItemStatus.PENDING,
|
||||||
|
requiresDecision: true,
|
||||||
|
escalationReason:
|
||||||
|
'Review eines unsicheren Ergebnisses: Zwei belegte Quellen nennen unterschiedliche Zeitpunkte für die Inbetriebnahme der Haltestelle Wankdorffeld. Eine Aussage ohne eindeutigen Beleg geht nicht in den Exposétext.',
|
||||||
|
decisionQuestion:
|
||||||
|
'Welche Angabe soll in den Langtext übernommen werden — «ab Dezember 2027» gemäss amtlicher Publikation oder «ab Sommer 2028» gemäss Mitteilung der Standortförderung — oder soll der Absatz zur Haltestelle vorerst entfallen?',
|
||||||
|
inputs: [
|
||||||
|
{ label: 'Objektadresse aus dem Vermarktungsauftrag', value: 'Wankdorffeldstrasse 12, 3014 Bern' },
|
||||||
|
{ label: 'Quartierzuordnung', value: 'Bern Wankdorf, Entwicklungsgebiet Wankdorf-Nord' },
|
||||||
|
{
|
||||||
|
label: 'Widersprüchliche Quellen',
|
||||||
|
value: [
|
||||||
|
'Amtliche Publikation vom 12.03.2026 — Inbetriebnahme Dezember 2027',
|
||||||
|
'Mitteilung der Standortförderung vom 28.04.2026 — Inbetriebnahme Sommer 2028',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ label: 'Auftraggebende Person', value: 'Nadine Brunner, Vermarktung Zürich (Mandat Bern)' },
|
||||||
|
],
|
||||||
|
result: [
|
||||||
|
{ label: 'Stand', value: 'Kurztext fertig, Langtext zu 90 Prozent — ein Absatz ist ausgeklammert' },
|
||||||
|
{
|
||||||
|
label: 'Fertiger Kurztext',
|
||||||
|
value:
|
||||||
|
'Das Objekt liegt im Entwicklungsgebiet Wankdorf, unmittelbar an der Achse zwischen Bahnhof Wankdorf und Autobahnanschluss Bern-Wankdorf. Das Umfeld ist geprägt von Dienstleistung, öffentlichen Institutionen und Verkaufsflächen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ausgeklammerter Absatz',
|
||||||
|
value:
|
||||||
|
'Die geplante Haltestelle Wankdorffeld liegt rund 200 Meter vom Objekt entfernt. Die Inbetriebnahme ist für [Zeitpunkt offen] vorgesehen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Warum die Angabe zählt',
|
||||||
|
value:
|
||||||
|
'Die Wegzeit zum öffentlichen Verkehr sinkt mit der neuen Haltestelle von 11 auf 3 Gehminuten — die Aussage ist für Interessenten mit Publikumsverkehr entscheidend.',
|
||||||
|
},
|
||||||
|
{ label: 'Übrige Quellen', value: '9 Quellen mit Abrufdatum, alle eindeutig und im Text belegt' },
|
||||||
|
],
|
||||||
|
processingSteps: [
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-step-1',
|
||||||
|
label: 'Adresse aufgenommen und dem Quartier zugeordnet',
|
||||||
|
detail: 'Zuordnung Bern Wankdorf, Entwicklungsgebiet Wankdorf-Nord.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-step-2',
|
||||||
|
label: 'Mikro- und Makrolage beschrieben',
|
||||||
|
detail: 'Nachbarschaft, Erreichbarkeit, Nahversorgung und Wirtschaftsraum Bern erfasst.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-step-3',
|
||||||
|
label: 'Amtliche Publikationen zum Umfeld gesichtet',
|
||||||
|
detail: 'Beim Verkehrsvorhaben zwei Quellen mit unterschiedlichen Zeitangaben gefunden.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-step-4',
|
||||||
|
label: 'Aussage ausgeklammert statt gemittelt',
|
||||||
|
detail: 'Der Absatz bleibt offen, bis die massgebende Quelle bestimmt ist.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-step-5',
|
||||||
|
label: 'Review vorgelegt',
|
||||||
|
detail: 'Beide Belege mit Fundstelle und Abrufdatum zur Entscheidung beigelegt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sourceReferences: [
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-src-1',
|
||||||
|
label: 'Amtliche Publikation Verkehrsvorhaben Wankdorffeld',
|
||||||
|
documentName: 'Amtliche-Publikation_Haltestelle-Wankdorffeld_2026-03-12.pdf',
|
||||||
|
locator: 'Seite 2, Abschnitt «Terminprogramm»',
|
||||||
|
quote:
|
||||||
|
'«Die Inbetriebnahme der Haltestelle Wankdorffeld ist auf den Fahrplanwechsel im Dezember 2027 vorgesehen.»',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-src-2',
|
||||||
|
label: 'Mitteilung der Standortförderung Bern',
|
||||||
|
documentName: 'Standortfoerderung-Bern_Mitteilung_2026-04-28.pdf',
|
||||||
|
locator: 'Seite 1, abgerufen 07.05.2026',
|
||||||
|
quote:
|
||||||
|
'«Wegen der Anpassung der Gleisgeometrie verschiebt sich die Eröffnung der Haltestelle Wankdorffeld auf Sommer 2028.»',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
availableActions: [
|
||||||
|
AgentWorkItemAction.DECIDE,
|
||||||
|
AgentWorkItemAction.EDIT,
|
||||||
|
AgentWorkItemAction.APPROVE,
|
||||||
|
AgentWorkItemAction.OPEN_SOURCE,
|
||||||
|
],
|
||||||
|
editableFields: [
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-field-date',
|
||||||
|
label: 'Zu verwendender Zeitpunkt',
|
||||||
|
value: 'Dezember 2027',
|
||||||
|
helperText: 'Alternativ «Sommer 2028» oder leer lassen, damit der Absatz entfällt.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-field-paragraph',
|
||||||
|
label: 'Absatz zur Haltestelle',
|
||||||
|
value:
|
||||||
|
'Die geplante Haltestelle Wankdorffeld liegt rund 200 Meter vom Objekt entfernt. Die Inbetriebnahme ist auf Dezember 2027 vorgesehen; die Wegzeit zum öffentlichen Verkehr verkürzt sich damit von 11 auf 3 Gehminuten.',
|
||||||
|
multiline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-field-source',
|
||||||
|
label: 'Anzugebende Quelle',
|
||||||
|
value: 'Amtliche Publikation Verkehrsvorhaben Wankdorffeld, 12.03.2026, abgerufen 07.05.2026',
|
||||||
|
helperText: 'Erscheint im Quellenverzeichnis des Exposés.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-hist-1',
|
||||||
|
at: '2026-05-07T07:45:00.000Z',
|
||||||
|
actor: 'Livia',
|
||||||
|
action: 'Widerspruch erkannt und Review vorgelegt',
|
||||||
|
note: 'Zwei belegte Quellen mit unterschiedlichen Zeitangaben zur Haltestelle.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'awi-livia-02-hist-2',
|
||||||
|
at: '2026-05-15T09:20:00.000Z',
|
||||||
|
actor: 'Livia',
|
||||||
|
action: 'Quellen erneut geprüft',
|
||||||
|
note: 'Quartalsprüfung: keine neuere amtliche Publikation vorhanden, Widerspruch besteht weiter.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// ══ src/services/ai/IAIService.ts — Typen (aktuell Zeilen 200–232) ══
|
||||||
|
// ── Exposé texts (Livia) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Textabschnitte eines Exposés. Jeder lässt sich einzeln erzeugen —
|
||||||
|
* gesamthaft heisst schlicht: alle nacheinander (Runde 4, §8.5.2).
|
||||||
|
*/
|
||||||
|
export const ExposeTextSection = {
|
||||||
|
TITLE: 'TITLE',
|
||||||
|
TEASER: 'TEASER',
|
||||||
|
OBJECT: 'OBJECT',
|
||||||
|
LOCATION: 'LOCATION',
|
||||||
|
MUNICIPALITY: 'MUNICIPALITY',
|
||||||
|
FEATURES: 'FEATURES',
|
||||||
|
HIGHLIGHTS: 'HIGHLIGHTS',
|
||||||
|
} as const
|
||||||
|
export type ExposeTextSection = typeof ExposeTextSection[keyof typeof ExposeTextSection]
|
||||||
|
|
||||||
|
export type ExposeTonality = 'SACHLICH' | 'HOCHWERTIG' | 'EINLADEND'
|
||||||
|
|
||||||
|
export interface ExposeTextInput {
|
||||||
|
section: ExposeTextSection
|
||||||
|
tonality: ExposeTonality
|
||||||
|
/** Ausschliesslich belegte Angaben aus «Meine Objekte» — nichts Erfundenes. */
|
||||||
|
facts: Record<string, string | number | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExposeTextResult {
|
||||||
|
section: ExposeTextSection
|
||||||
|
text: string
|
||||||
|
/** Angaben, die für einen vollständigen Text fehlen — statt sie zu erfinden. */
|
||||||
|
missingFacts: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ══ src/services/ai/IAIService.ts — Methode im Interface (Zeile 281) ══
|
||||||
|
recommendPreMarketRent(input: PreMarketRentInput): Promise<AIResponse<PreMarketRentRecommendation>>
|
||||||
|
|
||||||
|
// Exposé-Texte (Livia) — pro Abschnitt auslösbar
|
||||||
|
generateExposeText(input: ExposeTextInput): Promise<AIResponse<ExposeTextResult>>
|
||||||
|
|
||||||
|
// Legacy methods
|
||||||
|
|
||||||
|
// ══ src/services/ai/mock/MockAIService.ts — Importe (17-18, 25) ══
|
||||||
|
ExposeTextInput,
|
||||||
|
ExposeTextResult,
|
||||||
|
import { buildExposeText } from './exposeTextBuilder'
|
||||||
|
|
||||||
|
// ══ src/services/ai/mock/MockAIService.ts — Implementierung (303-310) ══
|
||||||
|
}),
|
||||||
|
|
||||||
|
generateExposeText: (input: ExposeTextInput) =>
|
||||||
|
traceMock<ExposeTextResult>('generateExposeText', async () => {
|
||||||
|
await delay(SIMULATED_DELAY.medium)
|
||||||
|
return { data: buildExposeText(input), provenance: mockProvenance() }
|
||||||
|
}),
|
||||||
|
|
||||||
|
|
||||||
|
// ══ src/services/ai/backend/BackendAIService.ts — Importe (62-63) ══
|
||||||
|
ExposeTextInput,
|
||||||
|
ExposeTextResult,
|
||||||
|
|
||||||
|
// ══ src/services/ai/backend/BackendAIService.ts — Delegation (678-690) ══
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── generateExposeText ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Bewusst ohne eigenen LLM-Aufruf: der Text darf ausschliesslich auf erfassten
|
||||||
|
// Objektdaten beruhen (Runde 4, §8.5.2). Ein freies Sprachmodell würde genau
|
||||||
|
// die fehlenden Angaben plausibel ergänzen, die hier rot markiert gehören.
|
||||||
|
// Deshalb erzeugt auch im Backend-Betrieb der deterministische Builder den
|
||||||
|
// Entwurf; er nennt Lücken, statt sie zu füllen.
|
||||||
|
generateExposeText(input: ExposeTextInput): Promise<AIResponse<ExposeTextResult>> {
|
||||||
|
return MockAIService.generateExposeText(input)
|
||||||
|
},
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export const STALE_AGENT_CONNECTIONS = 60 * 1000
|
||||||
|
export const STALE_CALENDAR = 60 * 1000
|
||||||
|
export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen
|
||||||
|
export const STALE_VISIT_ASSIGNMENTS = 60 * 1000
|
||||||
|
/** CRM-Leads ändern sich im Quellsystem, nicht hier — eine Minute genügt. */
|
||||||
|
|
||||||
|
// ── Livia — Leads und Exposé (Runde 4, §8) ───────────────────────────────────
|
||||||
|
|
||||||
|
export const EXPOSE_LEAD_TABS = {
|
||||||
|
ACTIVE: 'aktive-leads',
|
||||||
|
ARCHIVED: 'archivierte-leads',
|
||||||
|
} as const
|
||||||
|
export type ExposeLeadTab = typeof EXPOSE_LEAD_TABS[keyof typeof EXPOSE_LEAD_TABS]
|
||||||
|
|
||||||
|
export const EXPOSE_LEAD_TAB_LABELS: Record<ExposeLeadTab, string> = {
|
||||||
|
[EXPOSE_LEAD_TABS.ACTIVE]: 'Aktive Leads',
|
||||||
|
[EXPOSE_LEAD_TABS.ARCHIVED]: 'Archivierte Leads',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die drei Schritte des Exposé-Prozesses (§8.5). */
|
||||||
|
export const EXPOSE_STEPS = ['Hochladen', 'Exposé', 'Export'] as const
|
||||||
|
|
||||||
|
export const EXPOSE_IMAGE_CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
EXTERIOR: 'Aussenansicht',
|
||||||
|
INTERIOR: 'Innenansicht',
|
||||||
|
FLOORPLAN: 'Grundriss',
|
||||||
|
SURROUNDINGS: 'Umgebung',
|
||||||
|
OTHER: 'Sonstiges',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPOSE_DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
FLOORPLAN: 'Grundriss',
|
||||||
|
LEASE_CONTRACT: 'Mietvertrag',
|
||||||
|
ENERGY_CERTIFICATE: 'Energieausweis (GEAK)',
|
||||||
|
SITE_PLAN: 'Situationsplan',
|
||||||
|
OTHER: 'Sonstiges',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPOSE_TONALITY_LABELS: Record<string, string> = {
|
||||||
|
SACHLICH: 'Sachlich',
|
||||||
|
HOCHWERTIG: 'Hochwertig',
|
||||||
|
EINLADEND: 'Einladend',
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// ══ src/components/market-leads/LeadDetail.tsx — Import + Hook (Z. 5, 42) ══
|
||||||
|
import { useCreateExposeLead } from '../../hooks/useExposeLeads'
|
||||||
|
const forwardLead = useCreateExposeLead()
|
||||||
|
|
||||||
|
// ══ handleForward (Z. 61-77) ══
|
||||||
|
function handleForward() {
|
||||||
|
const count = selectedIds.length
|
||||||
|
forwardLead.mutate(
|
||||||
|
{
|
||||||
|
prospect: companyName,
|
||||||
|
contacts: contacts.map(c => c.value),
|
||||||
|
locationHint: signal.locationHint,
|
||||||
|
propertyIds: selectedIds,
|
||||||
|
sourceSignalId: signal.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedIds([])
|
||||||
|
showToast(`An Livia weitergeleitet — ${count} Objekt(e) für ${companyName}.`, 'success')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ══ Knopf «An Livia weiterleiten» (Z. 193-205) ══
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Forward size={15} />}
|
||||||
|
disabled={selectedIds.length === 0 || forwardLead.isPending}
|
||||||
|
onClick={handleForward}
|
||||||
|
sx={{ mt: 1, textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
|
||||||
|
>
|
||||||
|
An Livia zur Exposé-Erstellung weiterleiten
|
||||||
|
{selectedIds.length > 0 && ` (${selectedIds.length})`}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// ══ src/services/agentWorkloadService.ts — Importe (Z. 3-4) ══
|
||||||
|
import { ExposeLeadStatus } from '../domain/exposeLead'
|
||||||
|
import type { ExposeLead } from '../domain/exposeLead'
|
||||||
|
|
||||||
|
// ══ URGENT_FROM (Z. 33-40) ══
|
||||||
|
const URGENT_FROM = {
|
||||||
|
reminders: 5,
|
||||||
|
visits: 4,
|
||||||
|
expose: 5,
|
||||||
|
dataGaps: 10,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Zeitfenster der Auswertung. */
|
||||||
|
|
||||||
|
// ══ Eingabefeld (Z. 48-55) ══
|
||||||
|
export interface AgentWorkloadInput {
|
||||||
|
reminders: Reminder[]
|
||||||
|
visits: VisitAssignment[]
|
||||||
|
exposeLeads: ExposeLead[]
|
||||||
|
leads: UnifiedLead[]
|
||||||
|
properties: Property[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ══ Livias Auslastungsbahn (Z. 117-129) ══
|
||||||
|
// ── Livia: weitergeleitete Leads ────────────────────────────────────────────
|
||||||
|
const openExpose = input.exposeLeads.filter(l => l.status === ExposeLeadStatus.ACTIVE).length
|
||||||
|
const livia: AgentWorkload = {
|
||||||
|
agentId: 'livia',
|
||||||
|
count: openExpose,
|
||||||
|
label: `${openExpose} ${plural(openExpose, 'Exposé offen', 'Exposés offen')}`,
|
||||||
|
tone: tone(openExpose, URGENT_FROM.expose),
|
||||||
|
voice: openExpose === 0
|
||||||
|
? 'Kein Exposé offen. Leite mir einen Lead weiter, dann lege ich los.'
|
||||||
|
: `${openExpose} ${plural(openExpose, 'Lead wartet', 'Leads warten')} auf ein Exposé von mir.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nora: Marktchancen ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// ══ src/hooks/useAgentWorkload.ts — alle Livia-Stellen ══
|
||||||
|
import { useExposeLeads } from './useExposeLeads'
|
||||||
|
const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()
|
||||||
|
const data = useMemo(
|
||||||
|
() => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties }),
|
||||||
|
[reminders, visits, exposeLeads, leads, properties],
|
||||||
|
remindersLoading || visitsLoading || exposeLoading ||
|
||||||
|
leadsLoading || propertiesLoading,
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Personalblatt «Livia», Lage-Analystin (Vermarktung).
|
||||||
|
*
|
||||||
|
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und
|
||||||
|
* die elf Katalogaufgaben werden hier unverändert abgebildet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
|
import {
|
||||||
|
AgentStatus,
|
||||||
|
AgentAutonomyLevel,
|
||||||
|
AgentChannelType,
|
||||||
|
AgentChannelDirection,
|
||||||
|
AgentConnectionStatus,
|
||||||
|
AgentSystemType,
|
||||||
|
AgentAccessLevel,
|
||||||
|
AgentSettingKind,
|
||||||
|
AgentSettingGroup,
|
||||||
|
AgentAvatarTone,
|
||||||
|
} from '../../domain/teamAgent'
|
||||||
|
|
||||||
|
export const liviaAgent: TeamAgent = {
|
||||||
|
id: 'livia',
|
||||||
|
name: 'Livia',
|
||||||
|
role: 'Exposé Master',
|
||||||
|
personnelNumber: 'PO-ZD-21',
|
||||||
|
department: 'Vermarktung',
|
||||||
|
email: 'livia@property-on.ch',
|
||||||
|
avatarTone: AgentAvatarTone.WARNING,
|
||||||
|
status: AgentStatus.ACTIVE,
|
||||||
|
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
|
||||||
|
shortDescription:
|
||||||
|
'Erstellt Lageberichte, Inserate sowie Angebots- und Offertenbroschüren aus den Daten und Bildern aus «Meine Objekte».',
|
||||||
|
autonomyNote:
|
||||||
|
'Autonom. Livia recherchiert, schreibt und frischt Lageberichte, Inserate und Exposés ohne vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die Vermarktung jeden Satz nachprüfen kann.',
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
purpose:
|
||||||
|
'Livia macht aus Objektdaten, Lageinformationen und vorhandenen Bildern ein verkaufsfähiges Dokument: Lagebericht, Inserat oder vollständige Angebots- beziehungsweise Offertenbroschüre. Grundlage sind ausschliesslich die Objekte aus «Meine Objekte».',
|
||||||
|
input: [
|
||||||
|
'Objektdaten und Objektbilder aus «Meine Objekte»',
|
||||||
|
'Leads, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat',
|
||||||
|
'Objektadresse aus dem Vermarktungsauftrag',
|
||||||
|
'Öffentliche Umfeld- und Verkehrsdaten zu Quartier, Anbindung und Nahversorgung',
|
||||||
|
'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Umfeldentwicklungen',
|
||||||
|
'Bestehende Objektunterlagen aus der Dokumentenablage',
|
||||||
|
],
|
||||||
|
coreFlow: [
|
||||||
|
'Adresse aufnehmen und dem richtigen Quartier zuordnen, etwa Zürich Kreis 5 oder Winterthur Neuhegi.',
|
||||||
|
'Umfeld im festgelegten Radius abgehen: Nachbarschaft, Erdgeschossnutzungen, Erreichbarkeit, Einkaufen und Verpflegung.',
|
||||||
|
'Gemeinde und Region einordnen: Wirtschaftsstruktur, Steuerlage, Entwicklung der Nachfrage.',
|
||||||
|
'Amtliche Publikationen und Bauprojekte im Umfeld sichten und bewerten, ob sie für das Objekt relevant sind.',
|
||||||
|
'Aus den gesammelten Bausteinen einen Kurztext fürs Inserat und einen Langtext fürs Exposé schreiben.',
|
||||||
|
'Quellen anhängen, Ergebnis je Objekt ablegen und quartalsweise gegen den aktuellen Stand prüfen.',
|
||||||
|
],
|
||||||
|
output: [
|
||||||
|
'Lagebericht mit Mikro-, Makro- und Umfeldteil',
|
||||||
|
'Kurztext für Inserat und Portalauftritt',
|
||||||
|
'Angebots- beziehungsweise Offertenbroschüre als PDF oder Word',
|
||||||
|
'Quellenverzeichnis mit Abrufdatum je Aussage',
|
||||||
|
'Änderungshinweise, sobald sich das Umfeld spürbar verändert',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
responsibilities: [
|
||||||
|
'Lageberichte für kommerziell genutzte Immobilien erstellen und nachvollziehbar belegen',
|
||||||
|
'Inserate aus den Objektdaten in «Meine Objekte» erstellen',
|
||||||
|
'Angebots- und Offertenbroschüren aus Objektdaten, Lageinformationen und vorhandenen Bildern erstellen',
|
||||||
|
'Weitergeleitete Leads von Nora zügig zu einem Exposé verarbeiten',
|
||||||
|
'Erreichbarkeit, Nahversorgung und geplante Umfeldentwicklungen sauber trennen und darstellen',
|
||||||
|
'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen',
|
||||||
|
'Fehlende Pflichtangaben als Lücke ausweisen, statt plausible Werte zu erfinden',
|
||||||
|
'Lageberichte quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden',
|
||||||
|
],
|
||||||
|
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'livia-task-01',
|
||||||
|
title: 'Objektadresse aufnehmen',
|
||||||
|
description:
|
||||||
|
'Übernimmt Strasse, Hausnummer, Postleitzahl und Ort aus dem Vermarktungsauftrag, prüft die Schreibweise gegen die amtlichen Adressdaten und ordnet das Objekt einem Quartier zu, etwa Zürich Kreis 5, Winterthur Neuhegi oder Basel Gundeldingen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Eingang eines Vermarktungsauftrags',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-02',
|
||||||
|
title: 'Mikrolage analysieren',
|
||||||
|
description:
|
||||||
|
'Beschreibt das unmittelbare Umfeld im eingestellten Radius: Nachbarschaft, Erdgeschossnutzungen, Lärm- und Immissionslage, Publikumsfrequenz sowie Parkierung. Trennt beobachtbare Fakten klar von Einschätzungen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Adressaufnahme',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-03',
|
||||||
|
title: 'Makrolage einordnen',
|
||||||
|
description:
|
||||||
|
'Ordnet Gemeinde und Wirtschaftsraum ein: Bevölkerungs- und Beschäftigtenentwicklung, Branchenmix, Steuerlage und Bedeutung des Standorts innerhalb der Region, etwa Zug im Wirtschaftsraum Zentralschweiz oder St. Gallen als Zentrum der Ostschweiz.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Mikrolage-Analyse',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-04',
|
||||||
|
title: 'Erreichbarkeit und Nahversorgung darstellen',
|
||||||
|
description:
|
||||||
|
'Stellt Anbindung an öffentlichen Verkehr, Autobahnanschluss, Velo- und Fusswege sowie Einkaufen, Verpflegung, Kinderbetreuung und Gesundheitsversorgung dar — jeweils mit Distanz oder Wegzeit statt mit unbelegten Werbeaussagen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach der Makrolage-Einordnung',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-05',
|
||||||
|
title: 'Geplante Projekte und Umfeldentwicklungen aufnehmen',
|
||||||
|
description:
|
||||||
|
'Sichtet amtliche Publikationen und Bauprojekte im Umfeld — Arealentwicklungen, Zonenänderungen, Verkehrsvorhaben, neue Haltestellen — und hält fest, ab wann sie für das Objekt spürbar werden, etwa das Entwicklungsgebiet Bern Wankdorf.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'montags 07:00',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.SHAB,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-06',
|
||||||
|
title: 'Kurzen Inserattext erstellen',
|
||||||
|
description:
|
||||||
|
'Verdichtet die Lagebausteine auf einen Kurztext für Inserat und Portalauftritt, innerhalb des eingestellten Zeichenlimits und in der gewählten Firmen-Tonalität.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'sobald alle Lagebausteine vorliegen',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-07',
|
||||||
|
title: 'Ausführlichen Exposé-Text erstellen',
|
||||||
|
description:
|
||||||
|
'Schreibt den Langtext für das Exposé mit Mikrolage, Makrolage, Erreichbarkeit, Nahversorgung und Umfeldentwicklung in getrennten Abschnitten, damit sich Interessenten gezielt einlesen können.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'direkt im Anschluss an den Kurztext',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-08',
|
||||||
|
title: 'Quellen hinterlegen',
|
||||||
|
description:
|
||||||
|
'Hängt jeder Aussage die verwendete Quelle mit Bezeichnung und Abrufdatum an. Lässt sich eine Aussage nicht belegen, wird sie gestrichen statt abgeschwächt.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei jeder Texterstellung',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-09',
|
||||||
|
title: 'Ergebnisse je Objekt lokal zwischenspeichern',
|
||||||
|
description:
|
||||||
|
'Legt Kurztext, Langtext und Quellenverzeichnis je Objekt in der Dokumentenablage ab, sodass der nächste Arbeitsauftrag auf dem bestehenden Stand aufsetzt und nichts doppelt recherchiert wird.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach jedem abgeschlossenen Arbeitsauftrag',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-10',
|
||||||
|
title: 'Quartalsweisen Aktualisierungslauf simulieren',
|
||||||
|
description:
|
||||||
|
'Führt den «Quartals-Refresh» durch: prüft alle abgelegten Lagebeschriebe gegen den aktuellen Stand der Quellen, markiert veraltete Abschnitte und erneuert sie. Der Lauf wird protokolliert und ist je Objekt nachvollziehbar.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'quartalsweise am ersten Werktag 05:30',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-11',
|
||||||
|
title: 'Relevante Änderungen melden',
|
||||||
|
description:
|
||||||
|
'Meldet der Vermarktung nur, was den Lagebeschrieb wirklich verändert — neue Haltestelle, bewilligte Arealentwicklung, wegfallende Nahversorgung — mit Hinweis auf den betroffenen Textabschnitt und die Quelle.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei erkannter Abweichung im Aktualisierungslauf',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-12',
|
||||||
|
title: 'Weitergeleiteten Lead aufnehmen',
|
||||||
|
description:
|
||||||
|
'Nimmt Leads entgegen, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat, samt der mitgegebenen Objektempfehlung. Die Objekte stammen ausschliesslich aus «Meine Objekte».',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'bei Weiterleitung',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-13',
|
||||||
|
title: 'Objektbilder aus «Meine Objekte» importieren',
|
||||||
|
description:
|
||||||
|
'Importiert alle im Objektbestand hinterlegten Bilder automatisch in das Exposé, vermeidet Doppelimporte und übernimmt Kategorie, Reihenfolge und Titelbild-Markierung, soweit vorhanden.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'beim Start eines Exposé-Auftrags',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-14',
|
||||||
|
title: 'Exposé-Felder aus den Objektdaten befüllen',
|
||||||
|
description:
|
||||||
|
'Befüllt Eckdaten, Lage, Flächen, Preise, Ausstattung sowie Energie und Technik aus den in «Meine Objekte» verfügbaren Angaben. Nicht verfügbare Pflichtfelder bleiben leer und werden als fehlend markiert — es werden keine plausiblen Werte erfunden.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'nach dem Bildimport',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-15',
|
||||||
|
title: 'Angebots- beziehungsweise Offertenbroschüre erstellen',
|
||||||
|
description:
|
||||||
|
'Setzt aus den erfassten Objektdaten, Texten, Bildern und Anhängen die Broschüre in der Firmen-CI zusammen und stellt sie als PDF und Word zum Export bereit. Speichern und Erstellen bleiben getrennte Schritte.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'auf Auslösung im Exposé-Arbeitsbereich',
|
||||||
|
requiresApproval: false,
|
||||||
|
dependsOnSystem: AgentSystemType.DMS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'livia-ch-workspace-chat',
|
||||||
|
type: AgentChannelType.WORKSPACE_CHAT,
|
||||||
|
direction: AgentChannelDirection.BOTH,
|
||||||
|
description:
|
||||||
|
'Livia nimmt Objektadressen und Rückfragen im Arbeitsbereich entgegen und liefert Kurztext, Langtext und Änderungshinweise direkt im Kanal der Vermarktung.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Livia — Lage & Umfeld',
|
||||||
|
inboxAddress: 'arbeitsbereich/vermarktung/lage',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch'],
|
||||||
|
autoReplyEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-ch-email',
|
||||||
|
type: AgentChannelType.EMAIL,
|
||||||
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
|
description:
|
||||||
|
'Versand der fertigen Lagebeschriebe und der Quartalsmeldung an Vermarktung und Bewirtschaftung. Eingehende Mails werden nicht bearbeitet.',
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
displayName: 'Livia (Property On)',
|
||||||
|
senderAddress: 'livia@property-on.ch',
|
||||||
|
defaultRecipients: ['vermarktung@property-on.ch', 'bewirtschaftung@property-on.ch'],
|
||||||
|
autoReplyEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
systems: [
|
||||||
|
{
|
||||||
|
id: 'livia-sys-public-web',
|
||||||
|
type: AgentSystemType.PUBLIC_WEB,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Öffentliche Umfeld- und Verkehrsdaten: Haltestellen und Fahrpläne, Wegzeiten, Einkaufen und Verpflegung, Gemeindeangaben zu Bevölkerung und Beschäftigung.',
|
||||||
|
permissionNote: 'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-shab',
|
||||||
|
type: AgentSystemType.SHAB,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen im Umfeld des Objekts.',
|
||||||
|
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-immotop2',
|
||||||
|
type: AgentSystemType.IMMOTOP2,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Objektstammdaten und Adressen aus der Bewirtschaftung als Ausgangspunkt jedes Lagebeschriebs.',
|
||||||
|
permissionNote: 'Nur lesend. Livia verändert keine Stammdaten in der Bewirtschaftungslösung.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-dms',
|
||||||
|
type: AgentSystemType.DMS,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Ablage von Kurztext, Langtext und Quellenverzeichnis je Objekt sowie Zwischenstand für den Quartalslauf.',
|
||||||
|
permissionNote:
|
||||||
|
'Schreibt ausschliesslich in den Ordner «Lagebeschriebe» des jeweiligen Objekts; bestehende Fassungen bleiben als Version erhalten.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-workspace',
|
||||||
|
type: AgentSystemType.WORKSPACE,
|
||||||
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Aufträge, Rückfragen und Änderungshinweise im Arbeitsbereich der Vermarktung, inklusive Protokoll der Aktualisierungsläufe.',
|
||||||
|
permissionNote:
|
||||||
|
'Schreibt nur in den eigenen Bereich «Lage & Umfeld»; Objektdossiers anderer Teams bleiben unberührt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
id: 'livia-set-short-limit',
|
||||||
|
label: 'Kurztext-Zeichenlimit',
|
||||||
|
description: 'Obergrenze für den Lagetext im Inserat und auf den Portalen.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 600,
|
||||||
|
unit: 'Zeichen',
|
||||||
|
min: 200,
|
||||||
|
max: 1200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-long-limit',
|
||||||
|
label: 'Langtext-Zeichenlimit',
|
||||||
|
description: 'Obergrenze für den ausführlichen Lageteil im Exposé.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 2400,
|
||||||
|
unit: 'Zeichen',
|
||||||
|
min: 800,
|
||||||
|
max: 5000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-tonality',
|
||||||
|
label: 'Firmen-Tonalität',
|
||||||
|
description: 'Sprachstil, in dem Kurz- und Langtext verfasst werden.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.GENERAL,
|
||||||
|
value: 'sachlich',
|
||||||
|
options: [
|
||||||
|
{ value: 'sachlich', label: 'sachlich' },
|
||||||
|
{ value: 'hochwertig', label: 'hochwertig' },
|
||||||
|
{ value: 'zurueckhaltend', label: 'zurückhaltend' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-refresh-interval',
|
||||||
|
label: 'Aktualisierungsintervall',
|
||||||
|
description: 'Rhythmus, in dem bestehende Lagebeschriebe gegen die Quellen geprüft werden.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 'quartalsweise',
|
||||||
|
options: [
|
||||||
|
{ value: 'monatlich', label: 'monatlich' },
|
||||||
|
{ value: 'quartalsweise', label: 'quartalsweise' },
|
||||||
|
{ value: 'halbjaehrlich', label: 'halbjährlich' },
|
||||||
|
{ value: 'jaehrlich', label: 'jährlich' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-analysis-radius',
|
||||||
|
label: 'Radius der Umfeldanalyse',
|
||||||
|
description: 'Umkreis um die Objektadresse, der für die Mikrolage ausgewertet wird.',
|
||||||
|
kind: AgentSettingKind.NUMBER,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 800,
|
||||||
|
unit: 'Meter',
|
||||||
|
min: 200,
|
||||||
|
max: 3000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-source-types',
|
||||||
|
label: 'Relevante Quellentypen',
|
||||||
|
description: 'Welche Quellen Livia für Lagebeschriebe und Aktualisierungsläufe heranzieht.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['amtliche_publikationen', 'verkehrsdaten', 'bauprojekte', 'nahversorgung'],
|
||||||
|
options: [
|
||||||
|
{ value: 'amtliche_publikationen', label: 'amtliche Publikationen' },
|
||||||
|
{ value: 'verkehrsdaten', label: 'Verkehrsdaten' },
|
||||||
|
{ value: 'bauprojekte', label: 'Bauprojekte' },
|
||||||
|
{ value: 'nahversorgung', label: 'Nahversorgung' },
|
||||||
|
{ value: 'medienberichte', label: 'Medienberichte' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
id: 'livia-metric-objects',
|
||||||
|
label: 'Analysierte Objekte',
|
||||||
|
value: '184',
|
||||||
|
hint: 'Objekte mit vollständigem Lagebeschrieb, unter anderem in Zürich, Winterthur, Zug und St. Gallen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-metric-current-blocks',
|
||||||
|
label: 'Aktuelle Lagebausteine',
|
||||||
|
value: '162',
|
||||||
|
hint: 'Bausteine, die im letzten Aktualisierungslauf gegen die Quellen bestätigt wurden.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-metric-due-refresh',
|
||||||
|
label: 'Fällige Aktualisierungen',
|
||||||
|
value: '9',
|
||||||
|
hint: 'Objekte, deren Lagebeschrieb im nächsten Quartals-Refresh erneuert wird.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-metric-detected-changes',
|
||||||
|
label: 'Erkannte Umfeldänderungen',
|
||||||
|
value: '14',
|
||||||
|
hint: 'Seit dem letzten Lauf gemeldete Änderungen, etwa die Arealentwicklung Winterthur Neuhegi.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
headlineMetricId: 'livia-metric-objects',
|
||||||
|
lastRun: '2026-05-20T06:00:00.000Z',
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router'
|
||||||
|
import { Box, Tab, Tabs } from '@mui/material'
|
||||||
|
import { AgentWorkspaceHero } from '../../components/team'
|
||||||
|
import { LeadTable } from '../../components/expose'
|
||||||
|
import { LoadingPage, ErrorState } from '../../components/ui'
|
||||||
|
import { useExposeLeads } from '../../hooks/useExposeLeads'
|
||||||
|
import { useProperties } from '../../hooks/useProperties'
|
||||||
|
import { ExposeLeadStatus } from '../../domain/exposeLead'
|
||||||
|
import { EXPOSE_LEAD_TABS, EXPOSE_LEAD_TAB_LABELS } from '../../lib/constants'
|
||||||
|
import type { ExposeLeadTab } from '../../lib/constants'
|
||||||
|
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||||
|
|
||||||
|
const LIVIA = agentWorkspaceById('livia')!
|
||||||
|
|
||||||
|
const PARAM_TAB = 'tab'
|
||||||
|
|
||||||
|
function resolveTab(raw: string | null): ExposeLeadTab {
|
||||||
|
return raw === EXPOSE_LEAD_TABS.ARCHIVED ? EXPOSE_LEAD_TABS.ARCHIVED : EXPOSE_LEAD_TABS.ACTIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livia — Exposé Master (Runde 4, §8).
|
||||||
|
*
|
||||||
|
* Die frühere Inserate-Seite ist vollständig ersetzt: Titel, Beschreibung, der
|
||||||
|
* Knopf «Neues Inserat» und die Inserate-Tabelle sind entfallen. Ziel der Seite
|
||||||
|
* ist die schnelle Verarbeitung von Leads, die Nora erkannt und ein
|
||||||
|
* Bewirtschafter zur Exposé-Erstellung weitergeleitet hat.
|
||||||
|
*/
|
||||||
|
export default function MyListings() {
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
const tab = resolveTab(params.get(PARAM_TAB))
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { data: leads = [], isLoading, isError, refetch } = useExposeLeads()
|
||||||
|
const { data: properties = [] } = useProperties()
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const wanted = tab === EXPOSE_LEAD_TABS.ARCHIVED ? ExposeLeadStatus.ARCHIVED : ExposeLeadStatus.ACTIVE
|
||||||
|
return leads
|
||||||
|
.filter(l => l.status === wanted)
|
||||||
|
.sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))
|
||||||
|
}, [leads, tab])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beim Öffnen gleitet die gewählte Zeile an den oberen Rand des sichtbaren
|
||||||
|
* Inhaltsbereichs; die nachfolgenden Leads rücken nach unten und dürfen aus
|
||||||
|
* dem Blickfeld verschwinden (Runde 4, §8.4). Umgesetzt mit `scrollTo` auf dem
|
||||||
|
* bestehenden Scrollcontainer — dafür braucht es keine Animationsbibliothek.
|
||||||
|
*
|
||||||
|
* Bei `prefers-reduced-motion` wird ohne Bewegung direkt gesprungen.
|
||||||
|
*/
|
||||||
|
const handleSelect = useCallback((id: string, row: HTMLElement | null) => {
|
||||||
|
const next = id === selectedId ? null : id
|
||||||
|
setSelectedId(next)
|
||||||
|
if (!next || !row || !scrollRef.current) return
|
||||||
|
|
||||||
|
const container = scrollRef.current
|
||||||
|
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
const top = container.scrollTop + row.getBoundingClientRect().top - container.getBoundingClientRect().top
|
||||||
|
|
||||||
|
// Nach dem Zustandswechsel scrollen, sonst misst der Browser die alte Höhe.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
container.scrollTo({ top, behavior: reduced ? 'auto' : 'smooth' })
|
||||||
|
})
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
const handleTabChange = useCallback((next: ExposeLeadTab) => {
|
||||||
|
setSelectedId(null)
|
||||||
|
setParams({ [PARAM_TAB]: next })
|
||||||
|
}, [setParams])
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingPage />
|
||||||
|
if (isError) return <ErrorState message="Die Leads konnten nicht geladen werden." onRetry={() => refetch()} />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<Box ref={scrollRef} sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
<AgentWorkspaceHero agentId={LIVIA.id} name={LIVIA.name} role={LIVIA.role} />
|
||||||
|
|
||||||
|
<Box sx={{ px: 3, pb: 5 }}>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(_, v: ExposeLeadTab) => handleTabChange(v)}
|
||||||
|
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}
|
||||||
|
>
|
||||||
|
{Object.values(EXPOSE_LEAD_TABS).map(value => (
|
||||||
|
<Tab key={value} value={value} label={EXPOSE_LEAD_TAB_LABELS[value]} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<LeadTable
|
||||||
|
leads={visible}
|
||||||
|
properties={properties}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
withWorkspace={tab === EXPOSE_LEAD_TABS.ACTIVE}
|
||||||
|
emptyText={
|
||||||
|
tab === EXPOSE_LEAD_TABS.ACTIVE
|
||||||
|
? 'Keine aktiven Leads. Sobald Nora ein Signal erkennt und es weitergeleitet wird, erscheint es hier.'
|
||||||
|
: 'Noch keine archivierten Leads.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Weissraum unter der Liste, damit auch die letzte Zeile an den
|
||||||
|
oberen Rand gleiten kann. */}
|
||||||
|
<Box sx={{ height: '60vh' }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"name": "property-match",
|
"name": "property-match",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.122.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"@mui/icons-material": "^9.0.1",
|
"@mui/icons-material": "^9.0.1",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.0.1",
|
||||||
"@tanstack/react-query": "^5.75.2",
|
"@tanstack/react-query": "^5.75.2",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
@@ -58,6 +60,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.122.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.122.0.tgz",
|
||||||
|
"integrity": "sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"standardwebhooks": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "5.1.11",
|
"version": "5.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
@@ -1563,6 +1586,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2677,6 +2706,12 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
@@ -2764,6 +2799,81 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -2832,6 +2942,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-tree": {
|
"node_modules/css-tree": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
@@ -2846,6 +2972,18 @@
|
|||||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css.escape": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -2942,6 +3080,73 @@
|
|||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dom-serializer/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.356",
|
"version": "1.5.356",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
||||||
@@ -2949,6 +3154,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.21.3",
|
"version": "5.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||||
@@ -3246,6 +3464,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -3469,6 +3693,49 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -3704,6 +3971,19 @@
|
|||||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -4216,6 +4496,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -4329,6 +4621,79 @@
|
|||||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream/node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -4694,6 +5059,12 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
@@ -4785,6 +5156,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
@@ -4960,6 +5341,12 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -5034,7 +5421,6 @@
|
|||||||
"version": "7.25.0",
|
"version": "7.25.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
@@ -5279,6 +5665,19 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-mimetype": {
|
"node_modules/whatwg-mimetype": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"check:tokens": "node scripts/check-tokens.js"
|
"check:tokens": "node scripts/check-tokens.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.122.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@mui/icons-material": "^9.0.1",
|
"@mui/icons-material": "^9.0.1",
|
||||||
"@mui/material": "^9.0.1",
|
"@mui/material": "^9.0.1",
|
||||||
"@tanstack/react-query": "^5.75.2",
|
"@tanstack/react-query": "^5.75.2",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt die Agentenporträts aus den hochauflösenden Originalen.
|
||||||
|
*
|
||||||
|
* Die Porträts im Repository stammten bis Runde 6 aus einem HTML-Konzept und
|
||||||
|
* lagen dort nur als kleine, liegende Zuschnitte vor — Ferdi etwa 215 × 160
|
||||||
|
* Bildpunkte bei 6 KB. Im 3D-Meetingraum sind sie damit sichtbar unscharf, und
|
||||||
|
* hochskalieren hilft nicht.
|
||||||
|
*
|
||||||
|
* Die Originale liegen in der Präsentation `PropertyOn_Engeli_v5.pptx` als
|
||||||
|
* quadratische Bilder mit 1200 × 1200 Bildpunkten. Sie zeigen dieselben fünf
|
||||||
|
* Personen — Kleidung, Frisur und Ausschnitt stimmen mit den bisherigen
|
||||||
|
* Zuschnitten überein, es werden also keine Gesichter getauscht.
|
||||||
|
*
|
||||||
|
* Das Skript beschneidet den Rand (die Bilder tragen oben und an den Seiten
|
||||||
|
* Reste des Folienrahmens), skaliert auf eine für den Browser sinnvolle Grösse
|
||||||
|
* und schreibt sie nach `src/assets/team/`.
|
||||||
|
*
|
||||||
|
* Einmalig auszuführen; die Ergebnisse liegen als Assets im Repository:
|
||||||
|
*
|
||||||
|
* node scripts/build-agent-portraits.mjs
|
||||||
|
*
|
||||||
|
* Es braucht keine Bildbibliothek — die Verarbeitung läuft auf einem Canvas im
|
||||||
|
* bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium } from 'playwright-core'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { inflateRawSync } from 'node:zlib'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ziel = resolve(here, '../src/assets/team')
|
||||||
|
|
||||||
|
/** Quellpräsentation. Nur zum Erzeugen nötig, nicht Teil des Repositorys. */
|
||||||
|
const QUELLE = process.env.PORTRAIT_SOURCE
|
||||||
|
?? 'C:/Users/beni_/Downloads/PropertyOn_Engeli_v5.pptx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuordnung Bild → Agent.
|
||||||
|
*
|
||||||
|
* Die Reihenfolge entspricht der Titelfolie der Präsentation: Ferdi, Bruno,
|
||||||
|
* Livia, Nora, Sina. Jede Zuordnung wurde einzeln gegen den bisherigen
|
||||||
|
* Zuschnitt geprüft — eine frühere Quelle trug dieselben Vornamen für eine
|
||||||
|
* andere Belegschaft, und ein vertauschtes Gesicht wäre schlimmer als ein
|
||||||
|
* unscharfes.
|
||||||
|
*/
|
||||||
|
const ZUORDNUNG = {
|
||||||
|
'image1.jpg': 'ferdi',
|
||||||
|
'image2.jpg': 'bruno',
|
||||||
|
'image3.jpg': 'livia',
|
||||||
|
'image4.jpg': 'nora',
|
||||||
|
'image5.jpg': 'sina',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kantenbeschnitt je Seite, als Anteil der Bildbreite. */
|
||||||
|
const BESCHNITT = 0.03
|
||||||
|
/** Zielgrösse. Reicht für jede Darstellung im Programm, bleibt klein im Bündel. */
|
||||||
|
const GROESSE = 512
|
||||||
|
|
||||||
|
const CHROME = [
|
||||||
|
process.env.CHROME_PATH,
|
||||||
|
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||||
|
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||||
|
].filter(Boolean).find(p => existsSync(p))
|
||||||
|
|
||||||
|
if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.')
|
||||||
|
if (!existsSync(QUELLE)) throw new Error(`Quelldatei fehlt: ${QUELLE}`)
|
||||||
|
|
||||||
|
/** Bilder aus dem ZIP-Container der Präsentation lesen, ohne Fremdbibliothek. */
|
||||||
|
function medienAusPptx(pfad, gesucht) {
|
||||||
|
const b = readFileSync(pfad)
|
||||||
|
const gefunden = {}
|
||||||
|
let i = 0
|
||||||
|
while (i < b.length - 4) {
|
||||||
|
if (b.readUInt32LE(i) !== 0x04034b50) { i++; continue }
|
||||||
|
const method = b.readUInt16LE(i + 8)
|
||||||
|
const csize = b.readUInt32LE(i + 18)
|
||||||
|
const nameLen = b.readUInt16LE(i + 26)
|
||||||
|
const extraLen = b.readUInt16LE(i + 28)
|
||||||
|
const name = b.slice(i + 30, i + 30 + nameLen).toString()
|
||||||
|
const start = i + 30 + nameLen + extraLen
|
||||||
|
const datei = name.split('/').pop()
|
||||||
|
if (name.startsWith('ppt/media/') && gesucht.includes(datei)) {
|
||||||
|
try {
|
||||||
|
gefunden[datei] = method === 8
|
||||||
|
? inflateRawSync(b.slice(start, start + csize))
|
||||||
|
: b.slice(start, start + csize)
|
||||||
|
} catch { /* beschädigter Eintrag — überspringen */ }
|
||||||
|
}
|
||||||
|
i = start + csize
|
||||||
|
}
|
||||||
|
return gefunden
|
||||||
|
}
|
||||||
|
|
||||||
|
const roh = medienAusPptx(QUELLE, Object.keys(ZUORDNUNG))
|
||||||
|
const fehlend = Object.keys(ZUORDNUNG).filter(f => !roh[f])
|
||||||
|
if (fehlend.length) throw new Error(`Nicht gefunden: ${fehlend.join(', ')}`)
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME })
|
||||||
|
const page = await browser.newPage()
|
||||||
|
|
||||||
|
for (const [datei, agent] of Object.entries(ZUORDNUNG)) {
|
||||||
|
const dataUri = `data:image/jpeg;base64,${roh[datei].toString('base64')}`
|
||||||
|
const ergebnis = await page.evaluate(async ({ src, beschnitt, groesse }) => {
|
||||||
|
const img = await new Promise((ok, err) => {
|
||||||
|
const el = new Image()
|
||||||
|
el.onload = () => ok(el)
|
||||||
|
el.onerror = err
|
||||||
|
el.src = src
|
||||||
|
})
|
||||||
|
const rand = Math.round(Math.min(img.width, img.height) * beschnitt)
|
||||||
|
const sw = img.width - 2 * rand
|
||||||
|
const sh = img.height - 2 * rand
|
||||||
|
const c = document.createElement('canvas')
|
||||||
|
c.width = groesse
|
||||||
|
c.height = groesse
|
||||||
|
const g = c.getContext('2d')
|
||||||
|
g.imageSmoothingEnabled = true
|
||||||
|
g.imageSmoothingQuality = 'high'
|
||||||
|
g.drawImage(img, rand, rand, sw, sh, 0, 0, groesse, groesse)
|
||||||
|
return { dataUri: c.toDataURL('image/jpeg', 0.86), quelle: `${img.width}x${img.height}` }
|
||||||
|
}, { src: dataUri, beschnitt: BESCHNITT, groesse: GROESSE })
|
||||||
|
|
||||||
|
const aus = resolve(ziel, `${agent}.jpg`)
|
||||||
|
writeFileSync(aus, Buffer.from(ergebnis.dataUri.split(',')[1], 'base64'))
|
||||||
|
const kb = (readFileSync(aus).length / 1024).toFixed(0)
|
||||||
|
console.log(`${agent.padEnd(7)} ${ergebnis.quelle} → ${GROESSE}x${GROESSE}, ${kb} KB`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt das Basler Panorama für den 3D-Meetingraum (Runde 6, §2).
|
||||||
|
*
|
||||||
|
* Vorher lagen zwei getrennte Fotoflächen vor den Glasfronten — vorne und
|
||||||
|
* links. An deren Stossstelle war auf der linken Fensterseite eine harte Kante
|
||||||
|
* sichtbar. Diese Naht lässt sich nicht wegblenden; sie verschwindet nur, wenn
|
||||||
|
* es gar keine zwei Bilder mehr gibt.
|
||||||
|
*
|
||||||
|
* Dieses Skript baut aus den beiden Referenzfotos des Auftrags eine einzige
|
||||||
|
* durchgehende Panoramadatei. Jede Quelle wird **genau einmal** verwendet —
|
||||||
|
* kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende
|
||||||
|
* («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe
|
||||||
|
* unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in
|
||||||
|
* Helligkeit angeglichen und über eine schmale Zone überblendet.
|
||||||
|
*
|
||||||
|
* Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben
|
||||||
|
* und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine
|
||||||
|
* harte Grenze, die im Raum als Kante sichtbar wäre.
|
||||||
|
*
|
||||||
|
* Einmalig auszuführen; das Ergebnis liegt als Asset im Repository:
|
||||||
|
*
|
||||||
|
* node scripts/build-basel-panorama.mjs
|
||||||
|
*
|
||||||
|
* Es braucht keine Bildbibliothek — die Komposition entsteht auf einem Canvas
|
||||||
|
* im bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium } from 'playwright-core'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const assets = resolve(here, '../src/assets/staff')
|
||||||
|
|
||||||
|
const SRC_FRONT = resolve(assets, 'basel-front.jpg')
|
||||||
|
const SRC_LEFT = resolve(assets, 'basel-left.jpg')
|
||||||
|
const OUT = resolve(assets, 'basel-panorama.jpg')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zielmasse. Die Breite ergibt sich aus den beiden Quellen: jede einmal,
|
||||||
|
* abzüglich der Überblendung. Grösser wäre reines Hochskalieren.
|
||||||
|
*/
|
||||||
|
const H = 660
|
||||||
|
/** Wasserlinie im Ziel — beide Quellen werden darauf ausgerichtet. */
|
||||||
|
const WATER_Y = 400
|
||||||
|
/** Wasserlinie in den Quellen, als Anteil der Bildhöhe. */
|
||||||
|
const FRONT_WATER = 0.60
|
||||||
|
const LEFT_WATER = 0.64
|
||||||
|
/** Skalierung der beiden Quellen. */
|
||||||
|
const FRONT_SCALE = 1.3
|
||||||
|
/**
|
||||||
|
* Massstab der Fortsetzung (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Nicht frei gewählt, sondern aus den Fotos gemessen: das Münster steht in
|
||||||
|
* basel-front 106 Pixel über der Wasserlinie, in basel-left 130. Damit
|
||||||
|
* dieselben Häuser in beiden Bildhälften gleich gross erscheinen, muss die
|
||||||
|
* Fortsetzung mit 1.3 × 106/130 laufen. Vorher stand hier 2.45 — daher der
|
||||||
|
* Massstabssprung an der Nahtstelle, bei dem die linken Häuser doppelt so hoch
|
||||||
|
* wirkten wie die vorderen.
|
||||||
|
*/
|
||||||
|
const LEFT_SCALE = 1.06
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ausschnitt der Fortsetzung, in Pixeln der Quelle.
|
||||||
|
*
|
||||||
|
* Beide Referenzfotos zeigen das Münster. Nebeneinander gesetzt stünde es
|
||||||
|
* zweimal im Bild — eine Doppelstruktur, die §2.1 ausdrücklich ausschliesst.
|
||||||
|
* Aus der Panoramaaufnahme wird deshalb nur der Abschnitt östlich des Münsters
|
||||||
|
* verwendet: die Häuserzeilen am Rhein. Das Münster selbst kommt genau einmal
|
||||||
|
* vor, nämlich im Mittelmotiv vom Tischende.
|
||||||
|
*/
|
||||||
|
const LEFT_CROP = { sx: 330, sw: 370 }
|
||||||
|
/**
|
||||||
|
* Breite der Überblendung zwischen Mittelmotiv und Fortsetzung.
|
||||||
|
*
|
||||||
|
* Bewusst schmal (Runde 7, §1). Eine breite Überblendung mischt zwei
|
||||||
|
* verschiedene Häuserzeilen ineinander: beide bleiben halbdurchsichtig sichtbar
|
||||||
|
* und es entstehen «Geisterhäuser», die vor und über anderen Gebäuden stehen.
|
||||||
|
* Ein Überblenden funktioniert nur bei deckungsgleichem Inhalt — den gibt es
|
||||||
|
* hier nicht — schon 64 Pixel liessen ein blasses Dach über der echten Zeile
|
||||||
|
* stehen. Praktisch hart getrennt liest sich die Stelle als Übergang zwischen
|
||||||
|
* zwei Häuserblöcken am Fluss, und jedes Gebäude bleibt eindeutig.
|
||||||
|
*/
|
||||||
|
const FEATHER = 6
|
||||||
|
/** Überblendbreite in Himmel und Wasser — dort ist das Bild strukturlos. */
|
||||||
|
const FEATHER_WEICH = 300
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Höhenband der Häuserzeile im fertigen Bild, in Pixeln.
|
||||||
|
*
|
||||||
|
* Innerhalb dieses Bands wird schmal überblendet und nachgeschärft, ausserhalb
|
||||||
|
* breit überblendet und nicht geschärft. Die Grenzen folgen aus der Wasserlinie
|
||||||
|
* bei 400 und den Dachfirsten darüber.
|
||||||
|
*
|
||||||
|
* Die obere Grenze liegt über **allen** Dächern beider Aufnahmen, auch über den
|
||||||
|
* Dachaufbauten. Lag sie tiefer, schnitt sie die Häuserzeile des Mittelmotivs
|
||||||
|
* waagrecht durch, und deren Gauben blieben als Reihe schwebender Klötzchen im
|
||||||
|
* Himmel stehen.
|
||||||
|
*/
|
||||||
|
const BAND = { oben: 220, obenEnde: 238, unten: 398, untenEnde: 420 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachschärfung (Unschärfemaske) gegen die Weichheit des Hochskalierens.
|
||||||
|
*
|
||||||
|
* Die Fortsetzung stammt aus einem kleinen Panoramafoto und wird auf ein
|
||||||
|
* Mehrfaches vergrössert; ohne Nachschärfung verlieren die Dachkanten ihre
|
||||||
|
* Kontur. `staerke` ist der Anteil der zurückgegebenen Kantenzeichnung.
|
||||||
|
*/
|
||||||
|
const SCHAERFE = { staerke: 0.85, radius: 1.1 }
|
||||||
|
|
||||||
|
const CHROME = [
|
||||||
|
process.env.CHROME_PATH,
|
||||||
|
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||||
|
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||||
|
].filter(Boolean).find(p => existsSync(p))
|
||||||
|
|
||||||
|
if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.')
|
||||||
|
for (const f of [SRC_FRONT, SRC_LEFT]) {
|
||||||
|
if (!existsSync(f)) throw new Error(`Referenzbild fehlt: ${f}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const asDataUri = f => `data:image/jpeg;base64,${readFileSync(f).toString('base64')}`
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME })
|
||||||
|
const page = await browser.newPage()
|
||||||
|
|
||||||
|
const result = await page.evaluate(async (cfg) => {
|
||||||
|
const load = src => new Promise((ok, err) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => ok(img)
|
||||||
|
img.onerror = err
|
||||||
|
img.src = src
|
||||||
|
})
|
||||||
|
const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)])
|
||||||
|
|
||||||
|
const fW = Math.round(fImg.width * cfg.FRONT_SCALE)
|
||||||
|
const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE)
|
||||||
|
|
||||||
|
// Die beiden Bilder überlappen sich geometrisch über die volle weiche Breite.
|
||||||
|
// Nur so lässt sich in Himmel und Wasser breit überblenden — ausserhalb der
|
||||||
|
// Überlappung gäbe es nichts, worauf übergeblendet werden könnte, und es
|
||||||
|
// entstünde ein durchsichtiger und damit schwarzer Streifen.
|
||||||
|
const UEBER = cfg.FEATHER_WEICH
|
||||||
|
const W = lW + fW - UEBER
|
||||||
|
const H = cfg.H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Quelle als Schicht, an der Wasserlinie ausgerichtet.
|
||||||
|
*
|
||||||
|
* Bewusst **ohne** senkrechtes Strecken über den Bildrand hinaus. Ein
|
||||||
|
* gestreckter Streifen Wasser zerfällt in Flecken und Striche. Was oben und
|
||||||
|
* unten fehlt, füllt weiter unten der Zeilenabgleich sauber auf.
|
||||||
|
*/
|
||||||
|
function schicht(img, crop, scale, waterFraction) {
|
||||||
|
const sx = crop ? crop.sx : 0
|
||||||
|
const sw = crop ? crop.sw : img.width
|
||||||
|
const w = Math.round(sw * scale)
|
||||||
|
const h = Math.round(img.height * scale)
|
||||||
|
const y = Math.round(cfg.WATER_Y - img.height * waterFraction * scale)
|
||||||
|
|
||||||
|
const c = document.createElement('canvas')
|
||||||
|
c.width = w
|
||||||
|
c.height = H
|
||||||
|
const cg = c.getContext('2d')
|
||||||
|
cg.imageSmoothingEnabled = true
|
||||||
|
cg.imageSmoothingQuality = 'high'
|
||||||
|
cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h)
|
||||||
|
return { canvas: c, w }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helligkeit der Fortsetzung **zeilenweise** an das Mittelmotiv angleichen
|
||||||
|
* (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Ein einziger Faktor für das ganze Bild reicht nicht: die beiden Aufnahmen
|
||||||
|
* unterscheiden sich im Himmel anders als in der Häuserzeile und im Wasser.
|
||||||
|
* Der übrig bleibende Unterschied stand als waagrechte Tonkante quer durch den
|
||||||
|
* Himmel. Verglichen wird nur dort, wo beide Bilder tatsächlich übereinander
|
||||||
|
* liegen; jede Zeile bekommt ihren eigenen Faktor.
|
||||||
|
*/
|
||||||
|
function tonAngleichen(fort, vorne, abX) {
|
||||||
|
const vonX = abX + Math.round(UEBER * 0.2)
|
||||||
|
const bisX = abX + Math.round(UEBER * 0.8)
|
||||||
|
const fb = fort.canvas.getContext('2d').getImageData(0, 0, fort.w, H)
|
||||||
|
const vb = vorne.canvas.getContext('2d').getImageData(0, 0, vorne.w, H)
|
||||||
|
/*
|
||||||
|
* Ein gemeinsamer Faktor für alle drei Kanäle, kein eigener je Kanal.
|
||||||
|
* Kanalweise verglichen werden Inhalte, die einander nicht entsprechen —
|
||||||
|
* an derselben Stelle stehen im einen Bild Bäume, im anderen Hausfassaden.
|
||||||
|
* Die Verhältnisse laufen dann auseinander und färben die Fortsetzung.
|
||||||
|
*/
|
||||||
|
let letzterFaktor = 1
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
let sf = 0, sv = 0, n = 0
|
||||||
|
for (let x = vonX; x < bisX; x++) {
|
||||||
|
const i = (y * fort.w + (x - abX)) * 4
|
||||||
|
const j = (y * vorne.w + x) * 4
|
||||||
|
if (fb.data[i + 3] < 250 || vb.data[j + 3] < 250) continue
|
||||||
|
sf += 0.2126 * fb.data[i] + 0.7152 * fb.data[i + 1] + 0.0722 * fb.data[i + 2]
|
||||||
|
sv += 0.2126 * vb.data[j] + 0.7152 * vb.data[j + 1] + 0.0722 * vb.data[j + 2]
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
// Zeilen ohne Überlappung übernehmen den Faktor der letzten vergleichbaren.
|
||||||
|
if (n >= 20 && sf >= 1) letzterFaktor = Math.max(0.75, Math.min(1.35, sv / sf))
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
const i = (y * fort.w + x) * 4
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const wert = fb.data[i + c] * letzterFaktor
|
||||||
|
fb.data[i + c] = wert > 255 ? 255 : wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fort.canvas.getContext('2d').putImageData(fb, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cv = document.createElement('canvas')
|
||||||
|
cv.width = W
|
||||||
|
cv.height = H
|
||||||
|
const g = cv.getContext('2d')
|
||||||
|
g.imageSmoothingEnabled = true
|
||||||
|
g.imageSmoothingQuality = 'high'
|
||||||
|
|
||||||
|
// 1) Beide Schichten aufbauen und die Fortsetzung zeilenweise im Ton
|
||||||
|
// angleichen, bevor irgendetwas zusammengesetzt wird.
|
||||||
|
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER)
|
||||||
|
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER)
|
||||||
|
tonAngleichen(fort, vorne, W - fort.w)
|
||||||
|
|
||||||
|
// 2) Fortsetzung rechts, darüber das Mittelmotiv mit höhenabhängig weicher
|
||||||
|
// rechter Flanke. Der Zylinder beginnt beim Mittelmotiv und läuft nach
|
||||||
|
// links in den Raum weiter; die Bildfolge muss dieser Richtung folgen.
|
||||||
|
/*
|
||||||
|
* Die Fortsetzung trägt **nur** die Häuserzeile bei (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Darüber und darunter wird sie ausgeblendet. Ihr Himmel ist milchig-weiss,
|
||||||
|
* der des Mittelmotivs kräftig blau; nebeneinander stand er als heller Block
|
||||||
|
* im Bild, und eine Helligkeitsangleichung holt die fehlende Farbsättigung
|
||||||
|
* nicht zurück. Ihr Wasser wiederum endet früh. Beides deckt weiter unten der
|
||||||
|
* Zeilenabgleich sauber ab — er verlängert Himmel und Wasser des
|
||||||
|
* Mittelmotivs über die volle Breite.
|
||||||
|
*/
|
||||||
|
const fk = fort.canvas.getContext('2d')
|
||||||
|
const fbild = fk.getImageData(0, 0, fort.w, H)
|
||||||
|
const fd = fbild.data
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Oben: der Himmel wird **spaltenweise entlang der echten Dachkante**
|
||||||
|
* entfernt, nicht mit einer waagrechten Linie abgeschnitten. Eine gerade
|
||||||
|
* Linie liegt entweder über den Dächern — dann bleibt ein heller Streifen
|
||||||
|
* Fremdhimmel stehen — oder sie schneidet mitten durch die Dächer.
|
||||||
|
*
|
||||||
|
* Gesucht wird je Spalte die erste Zeile, die sich deutlich von der
|
||||||
|
* Himmelsfarbe ganz oben in derselben Spalte unterscheidet. Drei Zeilen in
|
||||||
|
* Folge müssen abweichen, damit eine Wolke die Kante nicht vortäuscht.
|
||||||
|
*/
|
||||||
|
const obenRand = new Array(fort.w).fill(0)
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
let y0 = 0
|
||||||
|
while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++
|
||||||
|
if (y0 >= H) { obenRand[x] = H; continue }
|
||||||
|
/*
|
||||||
|
* Verglichen wird mit der Zeile acht darüber, nicht mit der obersten Zeile
|
||||||
|
* der Spalte. Der Himmel verläuft von oben nach unten langsam von blau nach
|
||||||
|
* dunstig; gegen einen festen Bezugswert überschreitet allein dieser Verlauf
|
||||||
|
* irgendwann jede Schwelle, und die Kante läge zu hoch. Ein Dachrand
|
||||||
|
* dagegen ändert die Farbe auf wenigen Zeilen sprunghaft.
|
||||||
|
*/
|
||||||
|
let treffer = 0
|
||||||
|
let kante = H
|
||||||
|
for (let y = y0 + 8; y < H; y++) {
|
||||||
|
const i = (y * fort.w + x) * 4
|
||||||
|
const o = ((y - 8) * fort.w + x) * 4
|
||||||
|
const sprung = Math.abs(fd[i] - fd[o]) + Math.abs(fd[i + 1] - fd[o + 1]) + Math.abs(fd[i + 2] - fd[o + 2])
|
||||||
|
treffer = sprung > 60 ? treffer + 1 : 0
|
||||||
|
if (treffer >= 2) { kante = y - 9; break }
|
||||||
|
}
|
||||||
|
obenRand[x] = kante
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Median statt Mittelwert über fünf Spalten. Ein Mittelwert zieht die Kante
|
||||||
|
* an jedem Dachabsatz zu weit nach oben, und darüber blieben kleine Fetzen
|
||||||
|
* fremden Himmels als Flecken stehen. Der Median folgt der Dachlinie und
|
||||||
|
* bügelt trotzdem einzelne Ausreisser aus.
|
||||||
|
*/
|
||||||
|
const geglaettet = obenRand.map((_, x) => {
|
||||||
|
const fenster = []
|
||||||
|
for (let k = -2; k <= 2; k++) if (obenRand[x + k] !== undefined) fenster.push(obenRand[x + k])
|
||||||
|
fenster.sort((a, b) => a - b)
|
||||||
|
return fenster[Math.floor(fenster.length / 2)]
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Himmelsfarbe je Spalte, gemessen an der obersten Bildzeile der Schicht. */
|
||||||
|
const himmel = []
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
let y0 = 0
|
||||||
|
while (y0 < H && fd[(y0 * fort.w + x) * 4 + 3] < 250) y0++
|
||||||
|
const i = (y0 * fort.w + x) * 4
|
||||||
|
himmel.push(y0 < H ? [fd[i], fd[i + 1], fd[i + 2]] : [0, 0, 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unten endet die Fortsetzung kurz unter der Wasserlinie; darunter übernimmt
|
||||||
|
// das Wasser des Mittelmotivs, das viel weiter hinunterreicht.
|
||||||
|
const untenAb = cfg.BAND.untenEnde + 10
|
||||||
|
const untenBis = cfg.BAND.untenEnde + 40
|
||||||
|
for (let x = 0; x < fort.w; x++) {
|
||||||
|
const kante = geglaettet[x]
|
||||||
|
const ref = himmel[x]
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const i = (y * fort.w + x) * 4
|
||||||
|
let obenA = Math.max(0, Math.min(1, (y - (kante - 2)) / 5))
|
||||||
|
/*
|
||||||
|
* Sicherheitsnetz dicht unter der Kante: was dort noch Himmelsfarbe hat,
|
||||||
|
* ist Himmel und verschwindet ebenfalls. Sonst bleiben an Dachabsätzen
|
||||||
|
* einzelne helle Flecken zwischen den Häusern stehen.
|
||||||
|
*/
|
||||||
|
if (obenA > 0 && y < kante + 14) {
|
||||||
|
const abstand = Math.abs(fd[i] - ref[0]) + Math.abs(fd[i + 1] - ref[1]) + Math.abs(fd[i + 2] - ref[2])
|
||||||
|
if (abstand < 70) obenA = 0
|
||||||
|
}
|
||||||
|
const untenA = y < untenAb ? 1 : Math.max(0, 1 - (y - untenAb) / (untenBis - untenAb))
|
||||||
|
fd[i + 3] = Math.round(fd[i + 3] * obenA * untenA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fk.putImageData(fbild, 0, 0)
|
||||||
|
|
||||||
|
g.drawImage(fort.canvas, W - fort.w, 0)
|
||||||
|
const mid = document.createElement('canvas')
|
||||||
|
mid.width = vorne.w
|
||||||
|
mid.height = H
|
||||||
|
const mg = mid.getContext('2d')
|
||||||
|
mg.drawImage(vorne.canvas, 0, 0)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Überblendung mit höhenabhängiger Breite (Runde 7, §1).
|
||||||
|
*
|
||||||
|
* Über der Häuserzeile und unter der Wasserlinie ist das Bild strukturlos —
|
||||||
|
* dort wird über die volle Überlappung übergeblendet, und nur so verschwindet
|
||||||
|
* der Helligkeitssprung im Himmel, den die Angleichung sonst als senkrechte
|
||||||
|
* Kante stehen lässt. In der Häuserzeile selbst muss die Überblendung schmal
|
||||||
|
* sein: dort stehen zwei verschiedene Häuserreihen übereinander, und jede
|
||||||
|
* Breite mischt sie zu halbdurchsichtigen «Geisterhäusern».
|
||||||
|
*
|
||||||
|
* Beide Übergänge haben dieselbe Mitte, damit die Trennlinie über die ganze
|
||||||
|
* Bildhöhe an derselben Stelle liegt und nicht schräg verläuft.
|
||||||
|
*/
|
||||||
|
const mitte = vorne.w - UEBER / 2
|
||||||
|
const maske = document.createElement('canvas')
|
||||||
|
maske.width = vorne.w
|
||||||
|
maske.height = H
|
||||||
|
const kg = maske.getContext('2d')
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const imBand =
|
||||||
|
y < cfg.BAND.oben ? 0
|
||||||
|
: y < cfg.BAND.obenEnde ? (y - cfg.BAND.oben) / (cfg.BAND.obenEnde - cfg.BAND.oben)
|
||||||
|
: y < cfg.BAND.unten ? 1
|
||||||
|
: y < cfg.BAND.untenEnde ? 1 - (y - cfg.BAND.unten) / (cfg.BAND.untenEnde - cfg.BAND.unten)
|
||||||
|
: 0
|
||||||
|
/*
|
||||||
|
* Ausserhalb der Häuserzeile reicht das Mittelmotiv bis an seinen eigenen
|
||||||
|
* rechten Rand: dort trägt die Fortsetzung nichts mehr bei, weil sie oben
|
||||||
|
* und unten ausgeblendet ist. Innerhalb der Zeile wandert die Trennung in
|
||||||
|
* die Mitte der Überlappung und wird praktisch hart.
|
||||||
|
*/
|
||||||
|
const x0 = (vorne.w - 4) + (mitte - (vorne.w - 4)) * imBand
|
||||||
|
const b0 = 8 + (cfg.FEATHER - 8) * imBand
|
||||||
|
|
||||||
|
const grad = kg.createLinearGradient(x0 - b0 / 2, 0, x0 + b0 / 2, 0)
|
||||||
|
grad.addColorStop(0, 'rgba(0,0,0,1)')
|
||||||
|
grad.addColorStop(1, 'rgba(0,0,0,0)')
|
||||||
|
kg.fillStyle = grad
|
||||||
|
kg.fillRect(0, y, vorne.w, 1)
|
||||||
|
}
|
||||||
|
// Die Maske entsteht zuerst vollständig auf einer eigenen Fläche. Zeilenweise
|
||||||
|
// direkt mit `destination-in` zu arbeiten ginge nicht: dieser Modus wirkt auf
|
||||||
|
// die ganze Fläche und löschte bei jeder Zeile alle übrigen.
|
||||||
|
mg.globalCompositeOperation = 'destination-in'
|
||||||
|
mg.drawImage(maske, 0, 0)
|
||||||
|
g.drawImage(mid, 0, 0)
|
||||||
|
|
||||||
|
const bild = g.getImageData(0, 0, W, H)
|
||||||
|
const d = bild.data
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 3) Freie Stellen zeilenweise auffüllen.
|
||||||
|
*
|
||||||
|
* Oben und unten reichen die Fotos nicht über die volle Höhe, und rechts endet
|
||||||
|
* die Fortsetzung früher als das Mittelmotiv. Diese Stellen sind Himmel oder
|
||||||
|
* Wasser: beides ist waagrecht nahezu einfarbig. Sie bekommen deshalb die
|
||||||
|
* Durchschnittsfarbe ihrer eigenen Zeile, zum Rand hin weich an die dort
|
||||||
|
* tatsächlich vorhandenen Bildpunkte angeglichen. Das ergibt eine ruhige
|
||||||
|
* Fortsetzung ohne Striche, Flecken oder Wiederholungen.
|
||||||
|
*/
|
||||||
|
// Erst je Zeile die Durchschnittsfarbe der echten Bildpunkte bestimmen.
|
||||||
|
const mittelwerte = new Array(H).fill(null)
|
||||||
|
const luecken = new Array(H).fill(0)
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const zeile = y * W * 4
|
||||||
|
let sr = 0, sg = 0, sb = 0, n = 0
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
if (d[zeile + x * 4 + 3] > 250) { sr += d[zeile + x * 4]; sg += d[zeile + x * 4 + 1]; sb += d[zeile + x * 4 + 2]; n++ }
|
||||||
|
}
|
||||||
|
if (n > 0) mittelwerte[y] = [sr / n, sg / n, sb / n]
|
||||||
|
luecken[y] = W - n
|
||||||
|
}
|
||||||
|
// Zeilen ganz ohne Bildpunkte — ganz oben und ganz unten — übernehmen die
|
||||||
|
// Farbe der nächsten Zeile, die welche hat. Ein fester Ersatzwert stünde dort
|
||||||
|
// sonst als grauer Streifen quer über dem Himmel.
|
||||||
|
for (let y = 0; y < H; y++) if (!mittelwerte[y]) {
|
||||||
|
for (let k = 1; k < H; k++) {
|
||||||
|
if (y + k < H && mittelwerte[y + k]) { mittelwerte[y] = mittelwerte[y + k]; break }
|
||||||
|
if (y - k >= 0 && mittelwerte[y - k]) { mittelwerte[y] = mittelwerte[y - k]; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
if (luecken[y] === 0) continue
|
||||||
|
const zeile = y * W * 4
|
||||||
|
const mittel = mittelwerte[y]
|
||||||
|
|
||||||
|
// Farbe des nächstgelegenen echten Bildpunkts links und rechts merken
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const i = zeile + x * 4
|
||||||
|
if (d[i + 3] > 250) continue
|
||||||
|
/*
|
||||||
|
* Nur die unmittelbare Nachbarschaft zählt. Wurde weiter gesucht, trug
|
||||||
|
* die Füllung die Farbe des nächsten Dachs waagrecht in den Himmel
|
||||||
|
* hinein — als lange helle Schlieren über der Häuserzeile (Runde 7, §1).
|
||||||
|
* Ein paar Bildpunkte reichen, um die Kante weich zu machen; alles
|
||||||
|
* weitere bekommt die ruhige Durchschnittsfarbe der Zeile.
|
||||||
|
*/
|
||||||
|
let nachbar = null
|
||||||
|
for (let dx = 1; dx <= 12 && !nachbar; dx++) {
|
||||||
|
for (const xx of [x - dx, x + dx]) {
|
||||||
|
if (xx >= 0 && xx < W && d[zeile + xx * 4 + 3] > 250) {
|
||||||
|
nachbar = { c: [d[zeile + xx * 4], d[zeile + xx * 4 + 1], d[zeile + xx * 4 + 2]], dist: dx }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* Der Nachbar zählt nur, wenn er der Zeile ähnlich sieht. Ein Dach oder
|
||||||
|
* ein Kamin am Rand der Lücke färbte die Füllung sonst ein und zog seine
|
||||||
|
* Farbe als helle Schliere waagrecht in den Himmel.
|
||||||
|
*/
|
||||||
|
const passend = nachbar && Math.abs(nachbar.c[0] - mittel[0]) + Math.abs(nachbar.c[1] - mittel[1]) + Math.abs(nachbar.c[2] - mittel[2]) < 90
|
||||||
|
const t = passend ? Math.min(1, nachbar.dist / 10) : 1
|
||||||
|
const quelle = passend ? nachbar.c : mittel
|
||||||
|
for (let c = 0; c < 3; c++) d[i + c] = quelle[c] * (1 - t) + mittel[c] * t
|
||||||
|
d[i + 3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(bild, 0, 0)
|
||||||
|
|
||||||
|
// 4) Nachschärfen (Unschärfemaske): das Bild minus seiner weichgezeichneten
|
||||||
|
// Fassung ergibt die Kantenzeichnung; sie wird verstärkt zurückgegeben.
|
||||||
|
// Das holt die Dachkanten der hochskalierten Häuserzeile zurück (§1).
|
||||||
|
const weich = document.createElement('canvas')
|
||||||
|
weich.width = W
|
||||||
|
weich.height = H
|
||||||
|
const wg = weich.getContext('2d')
|
||||||
|
wg.filter = `blur(${cfg.SCHAERFE.radius}px)`
|
||||||
|
wg.drawImage(cv, 0, 0)
|
||||||
|
|
||||||
|
const oBild = g.getImageData(0, 0, W, H)
|
||||||
|
const wBild = wg.getImageData(0, 0, W, H)
|
||||||
|
const o = oBild.data
|
||||||
|
const w = wBild.data
|
||||||
|
// Nur im Strukturband schärfen. Im Himmel brächte Schärfen nichts, im Wasser
|
||||||
|
// verstärkte es jede Unruhe der aufgefüllten Fläche.
|
||||||
|
for (let y = cfg.BAND.oben; y < cfg.BAND.untenEnde && y < H; y++) {
|
||||||
|
const rand = Math.min(y - cfg.BAND.oben, cfg.BAND.untenEnde - y, 24) / 24
|
||||||
|
const k = cfg.SCHAERFE.staerke * Math.max(0, rand)
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const i = (y * W + x) * 4
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const wert = o[i + c] + k * (o[i + c] - w[i + c])
|
||||||
|
o[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(oBild, 0, 0)
|
||||||
|
|
||||||
|
return { dataUri: cv.toDataURL('image/jpeg', 0.9), W, H }
|
||||||
|
}, {
|
||||||
|
front: asDataUri(SRC_FRONT),
|
||||||
|
left: asDataUri(SRC_LEFT),
|
||||||
|
H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, FEATHER_WEICH, BAND, LEFT_CROP, SCHAERFE,
|
||||||
|
})
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64'))
|
||||||
|
const kb = (readFileSync(OUT).length / 1024).toFixed(0)
|
||||||
|
console.log(`Panorama erzeugt: ${OUT}`)
|
||||||
|
console.log(` ${result.W}x${result.H}, ${kb} KB`)
|
||||||
|
After Width: | Height: | Size: 144 KiB |
@@ -5,8 +5,21 @@ fachliche Vorgabe.
|
|||||||
|
|
||||||
| Datei | Herkunft | Lizenz |
|
| Datei | Herkunft | Lizenz |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `thomas.jpg` | Unsplash, Porträt eines Mannes in Business-Kleidung (`photo-1560250097-0b93528c311a`), quadratisch auf das Gesicht beschnitten | [Unsplash License](https://unsplash.com/license) — frei für kommerzielle Nutzung, keine Namensnennung nötig |
|
| `thomas.jpg` | Porträtbogen des Auftraggebers (`1-25.png`, Kachel **P015**), quadratisch beschnitten und auf 512 × 512 skaliert | vom Auftraggeber bereitgestellt |
|
||||||
|
|
||||||
Thomas ist in Anpassungsrunde 6 neu dazugekommen und hatte kein Porträt aus dem
|
Thomas ist in Anpassungsrunde 6 neu dazugekommen und hatte kein Porträt aus dem
|
||||||
Konzeptdokument. Der Kopf eines bestehenden Mitarbeitenden wurde bewusst nicht
|
Konzeptdokument. Der Kopf eines bestehenden Mitarbeitenden wurde bewusst nicht
|
||||||
zweitverwendet — jeder Kopf gehört genau einer Person.
|
zweitverwendet — jeder Kopf gehört genau einer Person.
|
||||||
|
|
||||||
|
**Runde 8, §3 — freundlichere Darstellung.** Das bisherige Porträt (Unsplash
|
||||||
|
`photo-1560250097-0b93528c311a`) zeigte einen ernst blickenden Mann ohne
|
||||||
|
Lächeln. Ersetzt durch P015 aus dem Porträtbogen: derselbe Typ Mensch —
|
||||||
|
Mann mittleren Alters, Brille, dunkelblauer Blazer — aber mit offenem,
|
||||||
|
freundlichem Ausdruck. Die Ähnlichkeit ist Absicht: die Änderung soll sich als
|
||||||
|
freundlicheres Bild derselben Person lesen und nicht als Personalwechsel.
|
||||||
|
|
||||||
|
**Auflösungshinweis:** Die Quelle ist ein Kontaktbogen mit dreissig Porträts auf
|
||||||
|
1536 × 1024 Bildpunkten; eine Kachel misst darin rund 176 × 176. Das Bild ist
|
||||||
|
auf 512 × 512 hochskaliert und damit weicher als die fünf Agentenporträts, die
|
||||||
|
aus 1200 × 1200 grossen Originalen stammen. Liegt das Einzelporträt später in
|
||||||
|
voller Auflösung vor, lässt es sich ohne weitere Anpassung austauschen.
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
@@ -37,6 +37,9 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
export function GlobalNavMenu() {
|
export function GlobalNavMenu() {
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
// Beim Öffnen festgehaltene Ankerstelle: oben unter der Kopfzeile, rechts
|
||||||
|
// exakt an der Fensterkante.
|
||||||
|
const [ankerStelle, setAnkerStelle] = useState({ top: 64, left: 0 })
|
||||||
const { currentUser, logout } = useSessionStore()
|
const { currentUser, logout } = useSessionStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const showToast = useToastStore(s => s.showToast)
|
||||||
@@ -61,7 +64,12 @@ export function GlobalNavMenu() {
|
|||||||
<Tooltip title="Menü">
|
<Tooltip title="Menü">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={e => setAnchorEl(open ? null : e.currentTarget)}
|
onClick={e => {
|
||||||
|
if (open) { setAnchorEl(null); return }
|
||||||
|
const b = e.currentTarget.getBoundingClientRect()
|
||||||
|
setAnkerStelle({ top: b.bottom + 8, left: document.documentElement.clientWidth })
|
||||||
|
setAnchorEl(e.currentTarget)
|
||||||
|
}}
|
||||||
aria-label="Menü öffnen"
|
aria-label="Menü öffnen"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
@@ -75,12 +83,22 @@ export function GlobalNavMenu() {
|
|||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
/*
|
||||||
|
* An eine Bildschirmstelle statt an das Symbol gebunden: das Symbol liegt
|
||||||
|
* um den Innenabstand der Kopfzeile eingerückt, und ein daran
|
||||||
|
* ausgerichtetes Menü erbte genau diesen Abstand als Spalt zur rechten
|
||||||
|
* Fensterkante (§5.1). `marginThreshold={0}` hebt zusätzlich den
|
||||||
|
* Sicherheitsabstand auf, den MUI sonst zum Fensterrand hält.
|
||||||
|
* Gerechnet wird mit `clientWidth` statt `innerWidth`, also ohne den
|
||||||
|
* Scrollbalken — sonst entstünde ein waagrechter Scrollbalken.
|
||||||
|
*/
|
||||||
|
anchorReference="anchorPosition"
|
||||||
|
anchorPosition={ankerStelle}
|
||||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
marginThreshold={0}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
paper: {
|
paper: {
|
||||||
sx: {
|
sx: {
|
||||||
mt: 1,
|
|
||||||
width: 320,
|
width: 320,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
|
boxShadow: '0 18px 50px rgba(15,25,35,0.22)',
|
||||||
|
|||||||
@@ -1,47 +1,56 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
|
||||||
import { CheckCircle2, ExternalLink, Forward, MapPin, Ruler } from 'lucide-react'
|
import { CheckCircle2, ExternalLink, MapPin, Ruler } from 'lucide-react'
|
||||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||||
import { useCreateExposeLead } from '../../hooks/useExposeLeads'
|
import { AgentAvatar, ObjectDeepLink } from '../team'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
|
||||||
import { AgentAvatar } from '../team'
|
|
||||||
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||||
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
||||||
|
import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||||
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||||
|
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||||
|
import { AiDisclaimer } from '../ui'
|
||||||
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
|
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
|
||||||
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
const NORA = agentWorkspaceById('nora')!
|
const LIVIA = agentWorkspaceById('livia')!
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detailansicht eines erkannten Nachfragesignals.
|
* Detailansicht eines Recherche-Leads.
|
||||||
*
|
*
|
||||||
* Eigene Datei — die Liste links und das Detail rechts teilen sich nur die
|
* Seit Runde 8 (§1–§2) trägt sie die Arbeitsteilung sichtbar: der obere Teil —
|
||||||
* Hilfsfunktionen, nicht den Aufbau.
|
* Recherche, Begründung, Quellen — stammt von Livia, der Matching-Teil unten
|
||||||
|
* ist Noras Arbeit. Auf Livias eigenem Arbeitsplatz wird derselbe Baustein mit
|
||||||
|
* `showMatching={false}` gerendert; damit ist ausgeschlossen, dass die
|
||||||
|
* Matching-Darstellung versehentlich zu ihr wandert.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ── Right pane detail ────────────────────────────────────────────────────────
|
// ── Right pane detail ────────────────────────────────────────────────────────
|
||||||
// `MIN_MATCH_PCT` und `confirmableContacts` liegen in `marketLeadHelpers.tsx`.
|
// `MIN_MATCH_PCT` und `confirmableContacts` liegen in `marketLeadHelpers.tsx`.
|
||||||
|
|
||||||
|
interface LeadDetailProps {
|
||||||
|
lead: MarketLead
|
||||||
|
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
|
||||||
|
showMatching?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper, der die Detailansicht über die Signal-ID neu aufbaut.
|
* Wrapper, der die Detailansicht über die Signal-ID neu aufbaut.
|
||||||
*
|
*
|
||||||
* Damit ist die Objektauswahl an das Signal gebunden, ohne sie in einem Effekt
|
* Ein neues Signal ist damit eine neue Komponente und startet mit frischem
|
||||||
* zurücksetzen zu müssen — ein neues Signal ist eine neue Komponente, und die
|
* Zustand, statt ihn in einem Effekt zurücksetzen zu müssen.
|
||||||
* startet ohnehin ohne Auswahl.
|
|
||||||
*/
|
*/
|
||||||
function LeadDetail({ lead }: { lead: MarketLead }) {
|
function LeadDetail({ lead, showMatching = true }: LeadDetailProps) {
|
||||||
return <LeadDetailBody key={lead.signal.id} lead={lead} />
|
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeadDetailBody({ lead }: { lead: MarketLead }) {
|
function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching: boolean }) {
|
||||||
const { signal, matchingProperties } = lead
|
const { signal, matchingProperties } = lead
|
||||||
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
|
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
|
||||||
const companyName = signal.companyName ?? signal.locationHint
|
const companyName = signal.companyName ?? signal.locationHint
|
||||||
|
|
||||||
const forwardLead = useCreateExposeLead()
|
const signalType = leadSignalTypeOf(signal)
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const risiko = isRisiko(signal)
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const rationale = signalRationaleOf(signal)
|
||||||
|
|
||||||
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
|
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
|
||||||
const relevant = useMemo(
|
const relevant = useMemo(
|
||||||
@@ -54,33 +63,13 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
|
|||||||
|
|
||||||
const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts])
|
const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts])
|
||||||
|
|
||||||
const toggle = useCallback((id: string) => {
|
|
||||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
function handleForward() {
|
|
||||||
const count = selectedIds.length
|
|
||||||
forwardLead.mutate(
|
|
||||||
{
|
|
||||||
prospect: companyName,
|
|
||||||
contacts: contacts.map(c => c.value),
|
|
||||||
locationHint: signal.locationHint,
|
|
||||||
propertyIds: selectedIds,
|
|
||||||
sourceSignalId: signal.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setSelectedIds([])
|
|
||||||
showToast(`An Livia weitergeleitet — ${count} Objekt(e) für ${companyName}.`, 'success')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||||||
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
|
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
|
||||||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||||
|
<LeadSignalTypeBadge type={signalType} />
|
||||||
|
</Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.3, mb: 0.5 }}>
|
||||||
{companyName}
|
{companyName}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -118,14 +107,14 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
{/* «Das meint Nora» — das Analyse-Kästchen bleibt, es trägt jetzt aber
|
{/* Die Recherche stammt seit Runde 8 von Livia — das Kästchen trägt
|
||||||
ihr Gesicht statt eines generischen KI-Symbols (§7.3). */}
|
deshalb ihr Gesicht. Der Matching-Teil weiter unten bleibt Noras. */}
|
||||||
{(signal.aiSummary ?? signal.strategicInterpretation) && (
|
{(signal.aiSummary ?? signal.strategicInterpretation) && (
|
||||||
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
|
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
<AgentAvatar agent={{ id: NORA.id, name: NORA.name, role: NORA.role }} size="small" />
|
<AgentAvatar agent={{ id: LIVIA.id, name: LIVIA.name, role: LIVIA.role }} size="small" />
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.primary, fontSize: '0.75rem' }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.primary, fontSize: '0.75rem' }}>
|
||||||
Das meint Nora
|
Das hat Livia recherchiert
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||||
@@ -134,6 +123,33 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Warum Chance beziehungsweise Risiko (§2) */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||||
|
Warum {risiko ? 'Risiko' : 'Chance'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||||
|
{rationale}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Bei Risiko: das betroffene Mietverhältnis benennen (§2) */}
|
||||||
|
{risiko && (signal.affectedTenant || signal.propertyId) && (
|
||||||
|
<Box sx={{ border: '1px solid', borderColor: DS_COLORS.leadSignal.RISIKO.fg, bgcolor: DS_COLORS.leadSignal.RISIKO.bg, borderRadius: 1.5, p: 2 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.leadSignal.RISIKO.fg, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||||
|
Betroffenes Mietverhältnis
|
||||||
|
</Typography>
|
||||||
|
{signal.affectedTenant && (
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
|
||||||
|
{signal.affectedTenant}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{signal.propertyId && (
|
||||||
|
<ObjectDeepLink propertyId={signal.propertyId} label="Objekt in «Meine Objekte» öffnen" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
|
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
|
||||||
{(signal.confirmedFacts?.length ?? 0) > 0 && (
|
{(signal.confirmedFacts?.length ?? 0) > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -151,6 +167,36 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Weitere Quellen, aus denen Livia verdichtet hat (§1) */}
|
||||||
|
{(signal.researchSources?.length ?? 0) > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||||
|
Weitere Quellen
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{signal.researchSources!.map((s, i) => (
|
||||||
|
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_SLATE[800] }}>
|
||||||
|
{SOURCE_TYPE_LABELS[s.type] ?? s.type}
|
||||||
|
{s.publishedAt && ` · ${new Date(s.publishedAt).toLocaleDateString('de-CH')}`}
|
||||||
|
</Typography>
|
||||||
|
{s.url && (
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={s.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 0.4, color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}
|
||||||
|
>
|
||||||
|
öffnen <ExternalLink size={10} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Extrahierte Kontaktdaten */}
|
{/* Extrahierte Kontaktdaten */}
|
||||||
@@ -169,47 +215,37 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
|
||||||
|
{showMatching && (
|
||||||
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* Passende Objekte im Portfolio */}
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
|
||||||
Passende Objekte im Portfolio ({relevant.length})
|
{risiko
|
||||||
|
? `Objekte für internes Re-Letting (${relevant.length})`
|
||||||
|
: `Passende Objekte im Portfolio (${relevant.length})`}
|
||||||
</Typography>
|
</Typography>
|
||||||
{relevant.length === 0 ? (
|
{relevant.length === 0 ? (
|
||||||
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
|
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
|
||||||
Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen
|
Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
relevant.map(({ p, fit }) => (
|
||||||
{relevant.map(({ p, fit }) => (
|
<SignalPropertyRow key={p.id} p={p} fit={fit} />
|
||||||
<SignalPropertyRow
|
))
|
||||||
key={p.id}
|
|
||||||
p={p}
|
|
||||||
fit={fit}
|
|
||||||
selected={selectedIds.includes(p.id)}
|
|
||||||
onToggle={() => toggle(p.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<Forward size={15} />}
|
|
||||||
disabled={selectedIds.length === 0 || forwardLead.isPending}
|
|
||||||
onClick={handleForward}
|
|
||||||
sx={{ mt: 1, textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hoverAlt } }}
|
|
||||||
>
|
|
||||||
An Livia zur Exposé-Erstellung weiterleiten
|
|
||||||
{selectedIds.length > 0 && ` (${selectedIds.length})`}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
{signal.disclaimer && (
|
|
||||||
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
|
|
||||||
{signal.disclaimer}
|
|
||||||
</Typography>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals
|
||||||
|
(«Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen
|
||||||
|
KI-Hinweis gewichen: Die Herkunft steht ohnehin oben bei der Quelle,
|
||||||
|
und ein Satz je Signal war nie derselbe — der Hinweis auf mögliche
|
||||||
|
Fehler soll aber überall gleich lauten (§4, §5).
|
||||||
|
*/}
|
||||||
|
<AiDisclaimer />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Chip } from '@mui/material'
|
||||||
|
import { TrendingDown, TrendingUp } from 'lucide-react'
|
||||||
|
import type { LeadSignalType } from '../../domain/futureSignal'
|
||||||
|
import { LEAD_SIGNAL_TYPE_LABELS } from '../../lib/constants'
|
||||||
|
import { DS_COLORS } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennzeichen «Chance» oder «Risiko» eines Recherche-Leads (Runde 8, §2).
|
||||||
|
*
|
||||||
|
* Eine Komponente für Liste und Detailansicht — derselbe Signaltyp darf an
|
||||||
|
* zwei Stellen nicht zwei Farben haben. Beschriftung aus `constants.ts`,
|
||||||
|
* Farben aus `DS_COLORS.leadSignal` (CLAUDE.md §8).
|
||||||
|
*
|
||||||
|
* Mit Pfeilsymbol, nicht nur mit Farbe: Rot und Grün allein unterscheiden zu
|
||||||
|
* wenig, wenn jemand sie nicht auseinanderhalten kann.
|
||||||
|
*/
|
||||||
|
export const LeadSignalTypeBadge = memo(function LeadSignalTypeBadge({
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
type: LeadSignalType
|
||||||
|
}) {
|
||||||
|
const meta = DS_COLORS.leadSignal[type]
|
||||||
|
const Icon = type === 'RISIKO' ? TrendingDown : TrendingUp
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
icon={<Icon size={11} />}
|
||||||
|
label={LEAD_SIGNAL_TYPE_LABELS[type] ?? type}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20,
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
color: meta.fg,
|
||||||
|
bgcolor: meta.bg,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: meta.fg,
|
||||||
|
'& .MuiChip-label': { px: 0.75 },
|
||||||
|
'& .MuiChip-icon': { color: meta.fg, ml: 0.5, mr: -0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, Checkbox, Chip, Typography } from '@mui/material'
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
import { Building2, ExternalLink } from 'lucide-react'
|
import { Building2, ExternalLink } from 'lucide-react'
|
||||||
import type { Property } from '../../domain/property'
|
import type { Property } from '../../domain/property'
|
||||||
import type { ExtractedContact } from '../../domain/futureSignal'
|
import type { ExtractedContact } from '../../domain/futureSignal'
|
||||||
@@ -59,41 +59,36 @@ function ContactRow({ contact }: { contact: ExtractedContact }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Objektzeile mit Mehrfachauswahl ──────────────────────────────────────────
|
// ── Objektzeile ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein passendes Portfolioobjekt zu einem Signal.
|
* Ein passendes Portfolioobjekt zu einem Signal.
|
||||||
*
|
*
|
||||||
* Der Anschreiben-Entwurf und die Reminder-Aktion sind entfallen (Runde 4, §7.3):
|
* Der Anschreiben-Entwurf und die Reminder-Aktion sind mit Runde 4 entfallen
|
||||||
* Nora erkennt die Nachfrage, das Dokument macht Livia. Geblieben ist die
|
* (§7.3), die Mehrfachauswahl mit Runde 8: sie diente allein dazu, Objekte an
|
||||||
* Auswahl — mehrere Objekte lassen sich gemeinsam für dasselbe Signal
|
* die Exposé-Erstellung weiterzureichen, und diese Funktion gibt es bei Livia
|
||||||
* weiterreichen, statt für jedes einzeln denselben Weg zu gehen.
|
* nicht mehr. Eine Auswahl ohne Ziel wäre ein Bedienelement, das nichts
|
||||||
|
* auslöst — schlimmer als keines.
|
||||||
|
*
|
||||||
|
* Geblieben ist, was Nora für die Entscheidung braucht: welches Objekt, wo,
|
||||||
|
* wie gross, zu welchem Preis und wie gut die Fläche passt.
|
||||||
*/
|
*/
|
||||||
function SignalPropertyRow({
|
function SignalPropertyRow({
|
||||||
p, fit, selected, onToggle,
|
p, fit,
|
||||||
}: {
|
}: {
|
||||||
p: Property
|
p: Property
|
||||||
fit: number | null
|
fit: number | null
|
||||||
selected: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex', alignItems: 'center', gap: 1,
|
display: 'flex', alignItems: 'center', gap: 1,
|
||||||
px: 1.5, py: 1.25, mb: 1,
|
px: 1.5, py: 1.25, mb: 1,
|
||||||
border: `1px solid ${selected ? '#152642' : '#e2e8f0'}`,
|
border: '1px solid #e2e8f0',
|
||||||
borderRadius: 1.5,
|
borderRadius: 1.5,
|
||||||
bgcolor: selected ? '#f8fafc' : 'white',
|
bgcolor: 'white',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={selected}
|
|
||||||
onChange={onToggle}
|
|
||||||
slotProps={{ input: { 'aria-label': `${p.title} auswählen` } }}
|
|
||||||
sx={{ p: 0.5 }}
|
|
||||||
/>
|
|
||||||
<Building2 size={14} color="#152642" style={{ flexShrink: 0 }} />
|
<Building2 size={14} color="#152642" style={{ flexShrink: 0 }} />
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<ObjectDeepLink propertyId={p.id} label={p.title} fontWeight={600} />
|
<ObjectDeepLink propertyId={p.id} label={p.title} fontWeight={600} />
|
||||||
|
|||||||
@@ -3,22 +3,36 @@ import { Box, Button, Skeleton, Typography } from '@mui/material'
|
|||||||
import { Inbox } from 'lucide-react'
|
import { Inbox } from 'lucide-react'
|
||||||
import type { UnifiedLead } from '../../domain/unifiedLead'
|
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||||
import { LeadChannelBadge } from './LeadChannelBadge'
|
import { LeadChannelBadge } from './LeadChannelBadge'
|
||||||
|
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
/** Spaltenraster im Stil der übrigen Agentenlisten (Livia, Bruno). */
|
/**
|
||||||
const COLS = '120px 1.5fr 1.4fr 150px'
|
* Spaltenraster im Stil der übrigen Agentenlisten (Bruno, Ferdi).
|
||||||
|
*
|
||||||
|
* Seit Runde 8 (§2) mit der Spalte «Signaltyp». Sie steht direkt nach der
|
||||||
|
* Herkunft, weil Chance oder Risiko darüber entscheidet, ob die Zeile
|
||||||
|
* überhaupt heute gelesen werden muss.
|
||||||
|
*
|
||||||
|
* Auf Livias Arbeitsplatz entfällt die Kanalspalte: dort stammt jeder Eintrag
|
||||||
|
* aus ihrer eigenen Recherche, und eine Spalte mit immer demselben Wert ist
|
||||||
|
* keine Information.
|
||||||
|
*/
|
||||||
|
const COLS_WITH_CHANNEL = '120px 110px 1.5fr 1.4fr 150px'
|
||||||
|
const COLS_WITHOUT_CHANNEL = '110px 1.5fr 1.4fr 150px'
|
||||||
|
|
||||||
const COLUMN_LABELS = ['Kanal', 'Interessent', 'Anliegen', 'Eingang']
|
const LABELS_WITH_CHANNEL = ['Kanal', 'Signaltyp', 'Interessent', 'Anliegen', 'Eingang']
|
||||||
|
const LABELS_WITHOUT_CHANNEL = ['Signaltyp', 'Unternehmen', 'Veränderung', 'Eingang']
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const LeadRow = memo(function LeadRow({
|
const LeadRow = memo(function LeadRow({
|
||||||
lead, selected, onSelect,
|
lead, selected, showChannel, onSelect,
|
||||||
}: {
|
}: {
|
||||||
lead: UnifiedLead
|
lead: UnifiedLead
|
||||||
selected: boolean
|
selected: boolean
|
||||||
|
showChannel: boolean
|
||||||
onSelect: (lead: UnifiedLead) => void
|
onSelect: (lead: UnifiedLead) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -29,7 +43,7 @@ const LeadRow = memo(function LeadRow({
|
|||||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(lead) } }}
|
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(lead) } }}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: COLS,
|
gridTemplateColumns: showChannel ? COLS_WITH_CHANNEL : COLS_WITHOUT_CHANNEL,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2, py: 1.5,
|
px: 2, py: 1.5,
|
||||||
@@ -41,9 +55,18 @@ const LeadRow = memo(function LeadRow({
|
|||||||
transition: 'background-color 0.1s',
|
transition: 'background-color 0.1s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{showChannel && (
|
||||||
<Box>
|
<Box>
|
||||||
<LeadChannelBadge channel={lead.channel} />
|
<LeadChannelBadge channel={lead.channel} />
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signaltyp — nur Recherche-Leads tragen eine Einordnung (§2). */}
|
||||||
|
<Box>
|
||||||
|
{lead.leadSignalType
|
||||||
|
? <LeadSignalTypeBadge type={lead.leadSignalType} />
|
||||||
|
: <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>—</Typography>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||||
@@ -72,30 +95,41 @@ export interface UnifiedLeadListProps {
|
|||||||
onSelect: (lead: UnifiedLead) => void
|
onSelect: (lead: UnifiedLead) => void
|
||||||
/** Nur gesetzt, wenn ein Filter aktiv ist — dann bietet der Leerzustand die Rücksetzung an. */
|
/** Nur gesetzt, wenn ein Filter aktiv ist — dann bietet der Leerzustand die Rücksetzung an. */
|
||||||
onResetFilter?: () => void
|
onResetFilter?: () => void
|
||||||
|
/** Kanalspalte anzeigen. Bei Nora ja, auf Livias Recherche-Liste nein (§1). */
|
||||||
|
showChannel?: boolean
|
||||||
|
/** Text des Leerzustands — bei Livia geht es um Recherche, nicht um Kanäle. */
|
||||||
|
emptyText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemeinsame Leadliste aller drei Kanäle (Runde 5, §12).
|
* Gemeinsame Leadliste (Runde 5, §12; Runde 8, §1–§2).
|
||||||
*
|
*
|
||||||
* Ersetzt die beiden getrennten Reiterlisten. Jede Zeile trägt ihr
|
* Dieselbe Komponente trägt Noras Kanalliste und Livias Recherche-Liste. Zwei
|
||||||
* Kanalkennzeichen ganz links — die Herkunft ist die erste Information, die
|
* fast gleiche Listen nebeneinander wären zwei Stellen für jede künftige
|
||||||
* beim Überfliegen zählt, weil sie bestimmt, wie belastbar der Lead ist.
|
* Änderung — und genau davor warnt die Vorgabe der Runde.
|
||||||
*/
|
*/
|
||||||
export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onResetFilter }: UnifiedLeadListProps) {
|
export function UnifiedLeadList({
|
||||||
|
leads, isLoading, selectedId, onSelect, onResetFilter,
|
||||||
|
showChannel = true,
|
||||||
|
emptyText = 'Für diesen Kanaltyp liegen derzeit keine Leads vor.',
|
||||||
|
}: UnifiedLeadListProps) {
|
||||||
|
const cols = showChannel ? COLS_WITH_CHANNEL : COLS_WITHOUT_CHANNEL
|
||||||
|
const labels = showChannel ? LABELS_WITH_CHANNEL : LABELS_WITHOUT_CHANNEL
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', bgcolor: 'white' }}>
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, overflow: 'hidden', bgcolor: 'white' }}>
|
||||||
{/* Kopfzeile */}
|
{/* Kopfzeile */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: COLS,
|
gridTemplateColumns: cols,
|
||||||
gap: 1,
|
gap: 1,
|
||||||
px: 2, py: 1,
|
px: 2, py: 1,
|
||||||
bgcolor: DS_SLATE[50],
|
bgcolor: DS_SLATE[50],
|
||||||
borderBottom: '1px solid #e2e8f0',
|
borderBottom: '1px solid #e2e8f0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{COLUMN_LABELS.map(label => (
|
{labels.map(label => (
|
||||||
<Typography
|
<Typography
|
||||||
key={label}
|
key={label}
|
||||||
variant="caption"
|
variant="caption"
|
||||||
@@ -118,7 +152,7 @@ export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onRese
|
|||||||
<Box sx={{ px: 3, py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ px: 3, py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Inbox size={28} color={DS_SLATE[400]} />
|
<Inbox size={28} color={DS_SLATE[400]} />
|
||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}>
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary, textAlign: 'center' }}>
|
||||||
Für diesen Kanaltyp liegen derzeit keine Leads vor.
|
{emptyText}
|
||||||
</Typography>
|
</Typography>
|
||||||
{onResetFilter && (
|
{onResetFilter && (
|
||||||
<Button size="small" variant="outlined" onClick={onResetFilter} sx={{ textTransform: 'none' }}>
|
<Button size="small" variant="outlined" onClick={onResetFilter} sx={{ textTransform: 'none' }}>
|
||||||
@@ -132,6 +166,7 @@ export function UnifiedLeadList({ leads, isLoading, selectedId, onSelect, onRese
|
|||||||
key={`${lead.channel}-${lead.id}`}
|
key={`${lead.channel}-${lead.id}`}
|
||||||
lead={lead}
|
lead={lead}
|
||||||
selected={lead.id === selectedId}
|
selected={lead.id === selectedId}
|
||||||
|
showChannel={showChannel}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export { CONTACT_ICONS, MIN_MATCH_PCT, areaFitPct, confirmableContacts } from '.
|
|||||||
|
|
||||||
// Runde 5, §12/§13: gemeinsame Liste über KI-Signale, Netzwerk und CRM.
|
// Runde 5, §12/§13: gemeinsame Liste über KI-Signale, Netzwerk und CRM.
|
||||||
export { LeadChannelBadge } from './LeadChannelBadge'
|
export { LeadChannelBadge } from './LeadChannelBadge'
|
||||||
|
|
||||||
|
// Runde 8, §2: Chance/Risiko-Kennzeichen, geteilt von Livia und Nora.
|
||||||
|
export { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||||
export { LeadChannelFilterBar } from './LeadChannelFilterBar'
|
export { LeadChannelFilterBar } from './LeadChannelFilterBar'
|
||||||
export { UnifiedLeadList } from './UnifiedLeadList'
|
export { UnifiedLeadList } from './UnifiedLeadList'
|
||||||
export { CrmLeadDetail } from './CrmLeadDetail'
|
export { CrmLeadDetail } from './CrmLeadDetail'
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
|
import { ExternalLink, MapPin } from 'lucide-react'
|
||||||
|
import type { ResearchLead } from '../../domain/researchLead'
|
||||||
|
import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants'
|
||||||
|
import { AiDisclaimer } from '../ui'
|
||||||
|
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein aus einer echten Quelle abgeleiteter Lead (§16).
|
||||||
|
*
|
||||||
|
* Die Karte beantwortet in fester Reihenfolge: was ist passiert, bei wem, wo,
|
||||||
|
* warum ist es für Gewerbeflächen relevant, wie sicher ist die Einordnung und
|
||||||
|
* woher stammt sie. Der Quellenlink steht unten und ist echt — er zeigt auf
|
||||||
|
* genau die Seite, die Livia gelesen hat.
|
||||||
|
*
|
||||||
|
* Confidence steht als Zahl da und nicht als Balken: ein Balken suggeriert
|
||||||
|
* Messgenauigkeit, die eine Modellschätzung nicht hat.
|
||||||
|
*/
|
||||||
|
export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead: ResearchLead }) {
|
||||||
|
const hoch = lead.relevance === 'high'
|
||||||
|
const akzent = hoch ? DS_COLORS.leadSignal.CHANCE : { bg: DS_SLATE[100], fg: DS_SLATE[600] }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
borderLeft: '3px solid',
|
||||||
|
borderLeftColor: hoch ? DS_COLORS.leadSignal.CHANCE.fg : DS_SLATE[300],
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'white',
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 700, color: DS_SLATE[900], lineHeight: 1.35, mb: 0.75 }}>
|
||||||
|
{lead.headline}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25, alignItems: 'center' }}>
|
||||||
|
<Chip
|
||||||
|
label={RESEARCH_EVENT_TYPE_LABELS[lead.eventType] ?? lead.eventType}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: akzent.bg, color: akzent.fg }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
{RESEARCH_RELEVANCE_LABELS[lead.relevance]}
|
||||||
|
</Typography>
|
||||||
|
{lead.location && (
|
||||||
|
<Chip
|
||||||
|
icon={<MapPin size={11} />}
|
||||||
|
label={lead.location}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20, fontSize: '0.72rem', bgcolor: DS_SLATE[100], color: DS_SLATE[600],
|
||||||
|
'& .MuiChip-icon': { color: DS_SLATE[600], ml: 0.5, mr: -0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 0.25 }}>
|
||||||
|
Unternehmen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 1 }}>
|
||||||
|
{lead.company}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mb: 1.25 }}>
|
||||||
|
{lead.summary}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
Warum relevant
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
|
||||||
|
{lead.reason}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Der Block «Empfohlene nächste Handlung» ist mit Runde 9 (§4) entfallen.
|
||||||
|
Das Feld bleibt im Datenmodell und wird weiterhin vom Modell befüllt —
|
||||||
|
entfernt ist allein die Anzeige, damit die Karte auf das Beobachtete
|
||||||
|
beschränkt bleibt und nicht als Anweisung gelesen wird.
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1,
|
||||||
|
pt: 1.25, borderTop: '1px solid #f1f5f9',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||||
|
Quelle: <strong>{lead.source}</strong>
|
||||||
|
{lead.publishedAt && ` · ${new Date(lead.publishedAt).toLocaleDateString('de-CH')}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||||
|
Confidence: <strong>{Math.round(lead.confidence * 100)} %</strong>
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={lead.sourceUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 0.5, ml: 'auto',
|
||||||
|
color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Originalquelle öffnen <ExternalLink size={11} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<AiDisclaimer />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Box, Chip, Typography } from '@mui/material'
|
||||||
|
import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'
|
||||||
|
import type { ResearchSourceResult } from '../../domain/researchLead'
|
||||||
|
import { RESEARCH_SOURCE_LIST } from '../../lib/researchSources'
|
||||||
|
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */
|
||||||
|
results?: ResearchSourceResult[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15).
|
||||||
|
*
|
||||||
|
* Zeigt die drei real abgerufenen Quellen mit Namen, Status und anklickbarem
|
||||||
|
* Originallink. Die Links stehen hier nicht als Zierde: wer eine Aussage von
|
||||||
|
* Livia prüfen will, soll die Quelle in einem Klick öffnen können, ohne sie
|
||||||
|
* selbst suchen zu müssen.
|
||||||
|
*
|
||||||
|
* Vor dem ersten Lauf steht «Bereit» und nicht «Verbunden» — verbunden ist,
|
||||||
|
* was tatsächlich geantwortet hat, und das weiss man erst danach.
|
||||||
|
*/
|
||||||
|
export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results }: Props) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: 'white', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}
|
||||||
|
>
|
||||||
|
Angebundene Kanäle & Systeme
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{RESEARCH_SOURCE_LIST.map(quelle => {
|
||||||
|
const ergebnis = results?.find(r => r.id === quelle.id)
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={quelle.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 1.5,
|
||||||
|
px: 2, py: 1.5,
|
||||||
|
borderBottom: '1px solid #f1f5f9',
|
||||||
|
'&:last-of-type': { borderBottom: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||||
|
{quelle.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', lineHeight: 1.4 }}>
|
||||||
|
{quelle.purpose}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
href={quelle.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.5,
|
||||||
|
color: DS_BRAND.main, fontSize: '0.72rem', fontWeight: 600,
|
||||||
|
textDecoration: 'none', wordBreak: 'break-all',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{quelle.url} <ExternalLink size={11} style={{ flexShrink: 0 }} />
|
||||||
|
</Box>
|
||||||
|
{ergebnis?.error && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_COLORS.leadSignal.RISIKO.fg, display: 'block', mt: 0.25 }}>
|
||||||
|
{ergebnis.error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexShrink: 0, pt: 0.25 }}>
|
||||||
|
{ergebnis === undefined ? (
|
||||||
|
<Chip
|
||||||
|
label="Bereit"
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: DS_SLATE[100], color: DS_SLATE[600] }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
icon={ergebnis.ok ? <CheckCircle2 size={11} /> : <XCircle size={11} />}
|
||||||
|
/*
|
||||||
|
* Mit Einheit statt nur der Zahl: «Live · 24» liest sich sonst
|
||||||
|
* genauso gut als «24 gefundene Leads», und genau so wurde es
|
||||||
|
* beim ersten Zeigen auch verstanden.
|
||||||
|
*/
|
||||||
|
label={ergebnis.ok
|
||||||
|
? `Live · ${ergebnis.itemCount} ${ergebnis.itemCount === 1 ? 'Eintrag' : 'Einträge'}`
|
||||||
|
: 'Nicht erreichbar'}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
height: 20, fontSize: '0.68rem', fontWeight: 700,
|
||||||
|
bgcolor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.bg : DS_COLORS.leadSignal.RISIKO.bg,
|
||||||
|
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||||
|
'& .MuiChip-icon': {
|
||||||
|
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||||
|
ml: 0.5, mr: -0.25,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { LeadDetail, UnifiedLeadList } from '../market-leads'
|
||||||
|
import { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||||
|
import { ResearchLeadCard } from './ResearchLeadCard'
|
||||||
|
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
||||||
|
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
|
||||||
|
import { LeadChannel } from '../../domain/unifiedLead'
|
||||||
|
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||||
|
import { LeadSignalType } from '../../domain/futureSignal'
|
||||||
|
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15).
|
||||||
|
*
|
||||||
|
* Zwei Bestände nebeneinander, bewusst getrennt gehalten:
|
||||||
|
*
|
||||||
|
* 1. **Live-Recherche** — was Livia beim Knopfdruck aus den drei realen
|
||||||
|
* Quellen gelesen und beurteilt hat. Diese Leads sind nicht gespeichert;
|
||||||
|
* sie leben im Zustand dieser Komponente, bis der nächste Lauf sie ersetzt.
|
||||||
|
* 2. **Erfasste Leads** — der bestehende Signalbestand, den Nora abgleicht.
|
||||||
|
*
|
||||||
|
* Die Trennung ist Absicht: ein live gelesener Artikel ist etwas anderes als
|
||||||
|
* ein redaktionell erfasster Lead, und beides in eine Liste zu mischen würde
|
||||||
|
* genau diesen Unterschied verwischen. Die Liste unten ist dieselbe Komponente
|
||||||
|
* wie bei Nora — mit `showMatching={false}`, weil das Matching ihre Arbeit ist.
|
||||||
|
*/
|
||||||
|
export function ResearchWorkspace() {
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||||
|
const { data: leads, isLoading } = useUnifiedLeads()
|
||||||
|
const refresh = useLiviaResearch()
|
||||||
|
const ergebnis = refresh.data
|
||||||
|
|
||||||
|
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||||
|
const { research, chancen, risiken } = useMemo(() => {
|
||||||
|
const rows: UnifiedLead[] = []
|
||||||
|
let c = 0
|
||||||
|
let r = 0
|
||||||
|
for (const lead of leads) {
|
||||||
|
if (lead.channel !== LeadChannel.KI_SIGNAL) continue
|
||||||
|
rows.push(lead)
|
||||||
|
if (lead.leadSignalType === LeadSignalType.RISIKO) r += 1
|
||||||
|
else c += 1
|
||||||
|
}
|
||||||
|
return { research: rows, chancen: c, risiken: r }
|
||||||
|
}, [leads])
|
||||||
|
|
||||||
|
const closeDetail = useCallback(() => setSelected(null), [])
|
||||||
|
const starten = useCallback(() => refresh.mutate(), [refresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
{/* ── Live-Recherche ──────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
|
||||||
|
disabled={refresh.isPending}
|
||||||
|
onClick={starten}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Research aktualisieren
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{refresh.isPending && (
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||||
|
Livia liest aktuelle Marktinformationen …
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!refresh.isPending && ergebnis && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
|
||||||
|
{`${ergebnis.discarded} nicht relevant · `}
|
||||||
|
{`${ergebnis.watchlist} Watchlist · `}
|
||||||
|
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'relevante Entwicklung' : 'relevante Entwicklungen'}`}
|
||||||
|
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
|
||||||
|
{ergebnis.model && ` · Modell ${ergebnis.model}`}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ResearchSourcesPanel results={ergebnis?.sources} />
|
||||||
|
|
||||||
|
{ergebnis?.analysisError && (
|
||||||
|
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||||
|
Die Quellen wurden gelesen, die Beurteilung konnte aber nicht laufen: {ergebnis.analysisError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis && ergebnis.leads.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{ergebnis.leads.map(lead => (
|
||||||
|
<ResearchLeadCard key={`${lead.sourceUrl}-${lead.headline}`} lead={lead} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && (
|
||||||
|
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||||
|
Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug.
|
||||||
|
Gelesen wurden {ergebnis.analyzed} Einträge.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ── Erfasste Leads ──────────────────────────────────────────────── */}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1, borderTop: `1px solid ${DS_SLATE[200]}` }}>
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{isLoading
|
||||||
|
? 'Leads werden geladen …'
|
||||||
|
: `${research.length} ${research.length === 1 ? 'erfasster Lead' : 'erfasste Leads'} · ` +
|
||||||
|
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<UnifiedLeadList
|
||||||
|
leads={research}
|
||||||
|
isLoading={isLoading}
|
||||||
|
selectedId={selected?.id ?? null}
|
||||||
|
onSelect={setSelected}
|
||||||
|
showChannel={false}
|
||||||
|
emptyText="Noch keine erfassten Leads. Was Livia gerade live gelesen hat, steht oberhalb."
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={!!selected}
|
||||||
|
onClose={closeDetail}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
width: isMobile ? '100vw' : { md: 520, lg: 600, xl: 660 },
|
||||||
|
boxShadow: '-4px 0 24px rgba(0,0,0,0.10)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected?.channel === LeadChannel.KI_SIGNAL && (
|
||||||
|
<LeadDetail
|
||||||
|
lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }}
|
||||||
|
showMatching={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Livia — Markt-Recherche (Runde 8, §1; Research-PoC). Barrel-Export (CLAUDE.md §13).
|
||||||
|
|
||||||
|
export { ResearchWorkspace } from './ResearchWorkspace'
|
||||||
|
export { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||||
|
export { ResearchLeadCard } from './ResearchLeadCard'
|
||||||
@@ -8,8 +8,7 @@ import { MEETING_SEATS } from './meetingRoomSeats'
|
|||||||
import { AGENT_PHOTOS } from '../team/agentPhotos'
|
import { AGENT_PHOTOS } from '../team/agentPhotos'
|
||||||
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
|
import { useAgentWorkload } from '../../hooks/useAgentWorkload'
|
||||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||||
import { DS_ACCENT } from '../../lib/ds'
|
import { DS_ACCENT, DS_NEUTRAL } from '../../lib/ds'
|
||||||
import { DS_NEUTRAL, DS_TEXT } from '../../lib/ds'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Farben der Auslastung auf dunklem Grund. Hellere Stufen als die
|
* Farben der Auslastung auf dunklem Grund. Hellere Stufen als die
|
||||||
@@ -160,7 +159,19 @@ export function MeetingRoom3D() {
|
|||||||
}
|
}
|
||||||
}, [positionLabels])
|
}, [positionLabels])
|
||||||
|
|
||||||
// ── Maus: Parallaxe und Trefferprüfung ──
|
/**
|
||||||
|
* Maus: nur noch Trefferprüfung, keine Bewegung mehr (Runde 9, §1).
|
||||||
|
*
|
||||||
|
* Bis hierher folgte die Kamera dem Zeiger — eine leichte Parallaxe, die den
|
||||||
|
* Raum lebendig wirken lassen sollte. Gewünscht ist jetzt eine vollständig
|
||||||
|
* ruhige Szene: Wer die Agenten anschaut, soll sie anschauen und nicht ein
|
||||||
|
* Bild, das unter dem Zeiger wegkippt.
|
||||||
|
*
|
||||||
|
* Entfernt ist ausschliesslich der Aufruf von `applyParallax` — die Szene
|
||||||
|
* selbst, ihre Kameraposition, Sitzordnung und Beschriftungen bleiben
|
||||||
|
* unverändert. Das Erkennen des Agenten unter dem Zeiger und der Klick auf
|
||||||
|
* seinen Platz sind keine Bewegung und bleiben deshalb.
|
||||||
|
*/
|
||||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
const s = sceneRef.current
|
const s = sceneRef.current
|
||||||
const wrap = wrapRef.current
|
const wrap = wrapRef.current
|
||||||
@@ -170,8 +181,6 @@ export function MeetingRoom3D() {
|
|||||||
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||||
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
||||||
|
|
||||||
s.applyParallax(nx, ny)
|
|
||||||
|
|
||||||
const ray = new THREE.Raycaster()
|
const ray = new THREE.Raycaster()
|
||||||
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
|
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
|
||||||
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
|
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
|
||||||
@@ -188,7 +197,6 @@ export function MeetingRoom3D() {
|
|||||||
const handlePointerLeave = useCallback(() => {
|
const handlePointerLeave = useCallback(() => {
|
||||||
const s = sceneRef.current
|
const s = sceneRef.current
|
||||||
if (!s) return
|
if (!s) return
|
||||||
s.applyParallax(0, 0)
|
|
||||||
hoveredRef.current = null
|
hoveredRef.current = null
|
||||||
s.setHovered(null)
|
s.setHovered(null)
|
||||||
setHovered(null)
|
setHovered(null)
|
||||||
@@ -328,15 +336,6 @@ export function MeetingRoom3D() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', right: 12, bottom: 8,
|
|
||||||
fontSize: '0.625rem', letterSpacing: 0.4,
|
|
||||||
color: DS_TEXT.inverted, opacity: 0.45, pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Kleinbasel · Blick über den Rhein nach Grossbasel
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,10 +32,6 @@ export const ROOM_COLORS = {
|
|||||||
tableTop: 0x8f7350,
|
tableTop: 0x8f7350,
|
||||||
tableEdge: 0x6f5637,
|
tableEdge: 0x6f5637,
|
||||||
tableBase: 0x35322f,
|
tableBase: 0x35322f,
|
||||||
/** Stuhlschalen. */
|
|
||||||
chair: 0x2f343c,
|
|
||||||
/** Anzüge der Agenten, leicht variiert, damit die Reihe nicht uniform wirkt. */
|
|
||||||
suits: [0x2c3444, 0x36302c, 0x2a3a3c, 0x3a2f36, 0x24303a, 0x1f2836],
|
|
||||||
/** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
|
/** Ring um das Porträt — hebt den Kopf vom Hintergrund ab. */
|
||||||
headRing: 0xffffff,
|
headRing: 0xffffff,
|
||||||
/** Ring bei Hover: Gold aus dem Designsystem. */
|
/** Ring bei Hover: Gold aus dem Designsystem. */
|
||||||
@@ -51,46 +47,57 @@ export const ROOM_COLORS = {
|
|||||||
export const ROOM = {
|
export const ROOM = {
|
||||||
width: 7.4,
|
width: 7.4,
|
||||||
height: 3.05,
|
height: 3.05,
|
||||||
depth: 10.6,
|
depth: 9.2,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */
|
/** Kamera: Augenhöhe einer sitzenden Person am Kopfende (§2). */
|
||||||
export const CAMERA = {
|
export const CAMERA = {
|
||||||
/** Sitzende Augenhöhe über dem Boden. */
|
/** Sitzende Augenhöhe über dem Boden. */
|
||||||
eyeHeight: 1.38,
|
eyeHeight: 1.19,
|
||||||
/** Knapp hinter der vorderen Tischkante. */
|
/** Knapp hinter der vorderen Tischkante. */
|
||||||
z: 1.9,
|
z: 1.9,
|
||||||
fov: 48,
|
fov: 48,
|
||||||
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
|
||||||
lookAt: [0, 1.06, -6.2] as const,
|
|
||||||
/**
|
/**
|
||||||
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
|
* Obergrenze des waagrechten Bildwinkels in Grad. Greift nur bei sehr breiten
|
||||||
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
|
* und gleichzeitig flachen Fenstern; bei üblichen Seitenverhältnissen liegt
|
||||||
|
* der waagrechte Winkel darunter und die Ansicht bleibt unverändert.
|
||||||
|
*/
|
||||||
|
maxHFov: 84,
|
||||||
|
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
||||||
|
lookAt: [0, 1.24, -5.2] as const,
|
||||||
|
/*
|
||||||
|
* Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger
|
||||||
|
* um bis zu 7,5 Zentimeter, was den Raum beleben sollte und stattdessen als
|
||||||
|
* Wackeln wahrgenommen wurde. Kameraposition und Blickziel bleiben exakt
|
||||||
|
* dieselben — nur bewegt sich nichts mehr.
|
||||||
*/
|
*/
|
||||||
parallax: 0.075,
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
||||||
export const TABLE = {
|
export const TABLE = {
|
||||||
width: 1.62,
|
width: 1.70,
|
||||||
/** Höhe der Plattenoberseite über dem Boden. */
|
/** Höhe der Plattenoberseite über dem Boden. */
|
||||||
top: 0.745,
|
top: 0.745,
|
||||||
thickness: 0.06,
|
thickness: 0.06,
|
||||||
/** Vordere Kante, knapp vor der Kamera. */
|
/** Vordere Kante, knapp vor der Kamera. */
|
||||||
zNear: 0.15,
|
zNear: 0.15,
|
||||||
/** Hintere Kante, vor dem Platz von Thomas. */
|
/** Hintere Kante, vor dem Platz von Thomas. */
|
||||||
zFar: -7.35,
|
zFar: -5.75,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Sitzende Personen: Kopfhöhe und Grössen. */
|
/** Sitzende Personen: Höhe der Porträtkreise. */
|
||||||
export const SEATED = {
|
export const SEATED = {
|
||||||
/** Kopfmitte über dem Boden — deutlich über der Tischplatte. */
|
/**
|
||||||
headY: 1.30,
|
* Luft zwischen Tischplatte und Unterkante des Porträtkreises (Runde 7, §2).
|
||||||
headRadius: 0.155,
|
*
|
||||||
/** Torso vom Stuhl bis zum Hals. */
|
* Daraus ergibt sich die Kopfhöhe: `TABLE.top + radius + headGap`. Die Kreise
|
||||||
torsoTop: 1.12,
|
* schweben damit nur knapp über der Tischkante statt weit darüber. Vorher
|
||||||
torsoBottom: 0.42,
|
* hingen sie fest auf 1.52 m — bei den jetzt deutlich grösseren Kreisen sähe
|
||||||
torsoRadiusTop: 0.19,
|
* das aus, als schwebten Köpfe frei im Raum.
|
||||||
torsoRadiusBottom: 0.25,
|
*
|
||||||
shoulderWidth: 0.46,
|
* Getrennt gehalten werden die Plätze in der Tiefe nicht über die Höhe,
|
||||||
|
* sondern seitlich: die Werte in `meetingRoomSeats` sind so gewählt, dass sich
|
||||||
|
* im Bild keine zwei Kreise überschneiden.
|
||||||
|
*/
|
||||||
|
headGap: 0.05,
|
||||||
} as const
|
} as const
|
||||||
@@ -2,8 +2,7 @@ import * as THREE from 'three'
|
|||||||
import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
|
import { ROOM, ROOM_COLORS, CAMERA, TABLE, SEATED } from './meetingRoomPalette'
|
||||||
import { SeatSide } from './meetingRoomSeats'
|
import { SeatSide } from './meetingRoomSeats'
|
||||||
import type { MeetingSeat } from './meetingRoomSeats'
|
import type { MeetingSeat } from './meetingRoomSeats'
|
||||||
import baselFront from '../../assets/staff/basel-front.jpg'
|
import baselPanorama from '../../assets/staff/basel-panorama.jpg'
|
||||||
import baselLeft from '../../assets/staff/basel-left.jpg'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
|
* Aufbau des virtuellen Meetingraums (Runde 6, §2–§6).
|
||||||
@@ -42,7 +41,6 @@ export interface MeetingRoomScene {
|
|||||||
renderer: THREE.WebGLRenderer
|
renderer: THREE.WebGLRenderer
|
||||||
seats: SeatHandle[]
|
seats: SeatHandle[]
|
||||||
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
|
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
|
||||||
applyParallax: (dx: number, dy: number) => void
|
|
||||||
setSize: (width: number, height: number) => void
|
setSize: (width: number, height: number) => void
|
||||||
render: () => void
|
render: () => void
|
||||||
setHovered: (id: string | null) => void
|
setHovered: (id: string | null) => void
|
||||||
@@ -124,51 +122,81 @@ function addRoomShell(scene: THREE.Scene) {
|
|||||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.wallSkirting }),
|
||||||
)
|
)
|
||||||
skirting.rotation.y = -Math.PI / 2
|
skirting.rotation.y = -Math.PI / 2
|
||||||
skirting.position.set(halfW - 0.01, 0.04, -depth / 2)
|
// Eine Idee vor dem Paneel: auf gleicher Höhe streiten sich die beiden
|
||||||
|
// Flächen um dieselben Bildpunkte, und die Kante franst sichtbar aus.
|
||||||
|
skirting.position.set(halfW - 0.015, 0.04, -depth / 2)
|
||||||
scene.add(skirting)
|
scene.add(skirting)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Basler Fernkulisse (§4).
|
* Basler Fernkulisse (Runde 6, §2).
|
||||||
*
|
*
|
||||||
* Zwei grosse Flächen weit aussen vor den Glasfronten, texturiert mit den beiden
|
* **Eine** gekrümmte Fläche mit **einem** Panoramabild — nicht mehr zwei
|
||||||
* Referenzfotos aus dem Auftrag. Sie stehen so weit weg, dass die Perspektive
|
* Flächen mit zwei Fotos. Genau deren Stossstelle war auf der linken
|
||||||
* sie als Ferne liest und die Fensterpfosten davor sauber schneiden.
|
* Fensterseite als harte Kante sichtbar. Auf einem durchgehenden Zylinder gibt
|
||||||
|
* es diese Stelle nicht mehr: der Blick nach vorne und der Blick nach links
|
||||||
|
* lesen dasselbe Bild an benachbarten Stellen, eine Naht ist geometrisch
|
||||||
|
* unmöglich.
|
||||||
*
|
*
|
||||||
* Beide Fotos zeigen dieselbe Rheinseite von derselben Uferseite. Damit der
|
* Das Panorama entsteht einmalig aus den beiden Referenzfotos des Auftrags;
|
||||||
* Blick nach vorne und der Blick nach links zusammengehören, liegt die
|
* siehe `scripts/build-basel-panorama.mjs`. Das Motiv vom Tischende bleibt darin
|
||||||
* Wasserlinie beider Kulissen auf derselben Welthöhe — sonst wirkte es wie zwei
|
* unverändert und wird nach links fortgeführt.
|
||||||
* zufällig zusammengesetzte Bilder.
|
*
|
||||||
|
* Die Zahlen sind so gewählt, dass Bildmasstab und Wasserlinie denen der
|
||||||
|
* früheren Frontfläche entsprechen — das gut wirkende Motiv am Tischende
|
||||||
|
* erscheint dadurch unverändert gross (§2.2).
|
||||||
*/
|
*/
|
||||||
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader) {
|
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) {
|
||||||
|
/** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */
|
||||||
|
const radius = 62
|
||||||
|
/**
|
||||||
|
* Masse der Panoramadatei. Ändert sich das Bild, gehören die Zahlen
|
||||||
|
* nachgeführt — sonst zieht der Zylinder es in die Länge oder staucht es.
|
||||||
|
*/
|
||||||
|
const panorama = { breite: 1028, hoehe: 660 }
|
||||||
|
/**
|
||||||
|
* Abgedeckter Bogen. Er beginnt rechts neben der Frontscheibe und läuft über
|
||||||
|
* die Front nach links bis hinter die linke Glasfassade — mehr braucht es
|
||||||
|
* nicht, und weniger Bogen heisst mehr Bildpunkte je Grad.
|
||||||
|
*
|
||||||
|
* Der Wert hält die Bildpunkte je Grad konstant: das Panorama ist seit
|
||||||
|
* Runde 7 schmaler, weil die Fortsetzung nicht mehr überskaliert wird, und
|
||||||
|
* der Bogen schrumpft im selben Verhältnis mit. Die Frontscheibe zeigt
|
||||||
|
* dadurch unverändert denselben Ausschnitt in derselben Grösse (§1: der
|
||||||
|
* Gesamteindruck bleibt, nur die Unsauberkeiten verschwinden).
|
||||||
|
*/
|
||||||
|
const arc = 1.514
|
||||||
|
/** Höhe passend zum Seitenverhältnis des Panoramas. */
|
||||||
|
const height = (radius * arc) / (panorama.breite / panorama.hoehe)
|
||||||
|
/** Wasserlinie im Panorama, als Anteil von oben. */
|
||||||
|
const waterFraction = 400 / panorama.hoehe
|
||||||
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
|
/** Höhe der Rheinoberfläche relativ zum Bürofussboden — wir sind im Hochhaus. */
|
||||||
const waterY = -7.5
|
const waterY = -7.5
|
||||||
|
|
||||||
function backdrop(url: string, waterFraction: number, w: number, h: number) {
|
const tex = loader.load(baselPanorama)
|
||||||
const tex = loader.load(url)
|
|
||||||
tex.colorSpace = THREE.SRGBColorSpace
|
tex.colorSpace = THREE.SRGBColorSpace
|
||||||
|
tex.anisotropy = maxAnisotropy
|
||||||
|
// Kein Wiederholen: der Bogen deckt genau die Bildbreite ab, und eine
|
||||||
|
// Wiederholung wäre als Doppelung sichtbar (§2.2).
|
||||||
|
tex.wrapS = THREE.ClampToEdgeWrapping
|
||||||
|
tex.wrapT = THREE.ClampToEdgeWrapping
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(
|
const mesh = new THREE.Mesh(
|
||||||
new THREE.PlaneGeometry(w, h),
|
new THREE.CylinderGeometry(radius, radius, height, 96, 1, true, 0, arc),
|
||||||
// Unbeleuchtet: das Foto trägt seine eigene Belichtung, jede Beleuchtung
|
// Unbeleuchtet und von innen sichtbar: das Foto trägt seine eigene
|
||||||
// würde es nur verfälschen.
|
// Belichtung, jede Beleuchtung würde sie nur verfälschen.
|
||||||
new THREE.MeshBasicMaterial({ map: tex }),
|
new THREE.MeshBasicMaterial({ map: tex, side: THREE.BackSide, fog: false }),
|
||||||
)
|
)
|
||||||
// Mittelpunkt so setzen, dass die Wasserlinie des Fotos auf `waterY` liegt.
|
// Mittelpunkt so setzen, dass die Wasserlinie des Panoramas auf `waterY` liegt.
|
||||||
mesh.position.y = waterY + h * (waterFraction - 0.5)
|
mesh.position.y = waterY - height * (0.5 - waterFraction)
|
||||||
return mesh
|
/**
|
||||||
}
|
* Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen.
|
||||||
|
* Der Abzug von 0.68 setzt damit u ≈ 0.45 in die Blickachse — dort steht im
|
||||||
// Vorne: Blick über den Rhein auf Altstadt und Münster. Wasserlinie bei ~60 %.
|
* Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom
|
||||||
const front = backdrop(baselFront, 0.6, 70, 47)
|
* Tischende, die linke Fassade seine Fortsetzung nach links.
|
||||||
front.position.z = -58
|
*/
|
||||||
scene.add(front)
|
mesh.rotation.y = Math.PI - 0.68
|
||||||
|
scene.add(mesh)
|
||||||
// Links: Rheinverlauf mit Altstadtsilhouette. Panoramaformat, Wasserlinie ~64 %.
|
|
||||||
const left = backdrop(baselLeft, 0.64, 118, 38)
|
|
||||||
left.rotation.y = Math.PI / 2
|
|
||||||
left.position.x = -56
|
|
||||||
left.position.z = -26
|
|
||||||
scene.add(left)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bodentiefe Glasfassade mit Pfosten — links und gegenüber (§3). */
|
/** Bodentiefe Glasfassade mit Pfosten — links und gegenüber (§3). */
|
||||||
@@ -264,122 +292,181 @@ function addTable(scene: THREE.Scene) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein sitzender Agent (§6).
|
* Nachschärfen nach dem Verkleinern (Unschärfemaske).
|
||||||
*
|
*
|
||||||
* Kopf ist eine Kreisfläche mit dem bestehenden Porträt als Textur — die
|
* Beim Herunterrechnen eines Fotos verschwimmen Kanten — Wimpern, Brillenrand,
|
||||||
* Kreisgeometrie beschneidet das quadratische Foto rund, genau wie die Avatare
|
* einzelne Haare. Das Bild minus seiner weichgezeichneten Fassung ergibt genau
|
||||||
* im übrigen Programm. Darunter ein Torso in Anzugfarbe und eine Stuhllehne.
|
* diese Kantenzeichnung; verstärkt zurückgegeben holt sie die Zeichnung wieder
|
||||||
|
* heran. Jedes Bildbearbeitungsprogramm macht das nach einer Verkleinerung.
|
||||||
*
|
*
|
||||||
* Der Kopf ist um `yaw` zum Tisch gedreht: die Agenten schauen ins Geschehen
|
* Die Stärke richtet sich danach, wie weit verkleinert wurde: wer kaum
|
||||||
* (§2), bleiben dabei aber lesbar. Voll zur Tischmitte gedreht sähe man von
|
* verkleinert, braucht kaum Schärfung, sonst wirkt das Ergebnis überzeichnet.
|
||||||
* einem frontalen Porträtfoto nur die Kante.
|
*/
|
||||||
|
function schaerfeNach(g: CanvasRenderingContext2D, flaeche: HTMLCanvasElement, ziel: number, kante: number) {
|
||||||
|
const staerke = Math.min(0.6, Math.max(0, (kante / ziel - 1) * 0.5))
|
||||||
|
if (staerke < 0.05) return
|
||||||
|
|
||||||
|
const weich = document.createElement('canvas')
|
||||||
|
weich.width = ziel
|
||||||
|
weich.height = ziel
|
||||||
|
const wg = weich.getContext('2d')
|
||||||
|
if (!wg) return
|
||||||
|
wg.filter = 'blur(0.8px)'
|
||||||
|
wg.drawImage(flaeche, 0, 0)
|
||||||
|
|
||||||
|
const scharf = g.getImageData(0, 0, ziel, ziel)
|
||||||
|
const unscharf = wg.getImageData(0, 0, ziel, ziel)
|
||||||
|
const a = scharf.data
|
||||||
|
const b = unscharf.data
|
||||||
|
for (let i = 0; i < a.length; i += 4) {
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
const wert = a[i + c] + staerke * (a[i + c] - b[i + c])
|
||||||
|
a[i + c] = wert < 0 ? 0 : wert > 255 ? 255 : wert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.putImageData(scharf, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Porträt laden und dabei auf seine Anzeigegrösse bringen (Runde 7, Nachtrag).
|
||||||
|
*
|
||||||
|
* Die Vorlagen haben 512 Bildpunkte, die Kreise erscheinen je nach Platz aber
|
||||||
|
* nur 170 bis 330 gross. Bei so starker Verkleinerung greift die Grafikkarte
|
||||||
|
* nicht auf die Vorlage zurück, sondern auf eine vorberechnete, halb oder
|
||||||
|
* viertel so grosse Zwischenfassung. Das Ergebnis sind weiche Gesichter,
|
||||||
|
* am deutlichsten bei den weiter hinten sitzenden Agenten.
|
||||||
|
*
|
||||||
|
* Wird die Textur vorab auf ihre Anzeigegrösse gerechnet, entfällt dieser
|
||||||
|
* Zwischenschritt. Verkleinert wird einmalig auf einer Zeichenfläche, und die
|
||||||
|
* rechnet dabei über alle Quellpunkte — deutlich sorgfältiger als die schnelle
|
||||||
|
* Zwischenfassung der Grafikkarte.
|
||||||
|
*
|
||||||
|
* Zugleich entsteht hier der mittige quadratische Ausschnitt. Ohne ihn zöge die
|
||||||
|
* Kreisfläche eine liegende Vorlage in die Breite.
|
||||||
|
*
|
||||||
|
* Geladen wird bewusst von Hand statt über `TextureLoader`: dessen Rückruf
|
||||||
|
* liefert das fertige Bild nicht zuverlässig, und ohne Bildmasse lässt sich
|
||||||
|
* weder zuschneiden noch massgerecht verkleinern. Die Zählung beim
|
||||||
|
* Ladeverwalter bleibt erhalten, damit die Szene nach dem letzten Bild wie
|
||||||
|
* bisher einmal neu zeichnet.
|
||||||
|
*/
|
||||||
|
function ladePortraet(url: string, seat: MeetingSeat, leinwandHoehe: number, maxAnisotropy: number) {
|
||||||
|
const tex = new THREE.Texture()
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace
|
||||||
|
// Anisotrope Filterung: die Kreise stehen schräg im Raum, ohne sie werden
|
||||||
|
// die Porträts an den Rändern matschig.
|
||||||
|
tex.anisotropy = maxAnisotropy
|
||||||
|
tex.generateMipmaps = true
|
||||||
|
tex.minFilter = THREE.LinearMipmapLinearFilter
|
||||||
|
tex.magFilter = THREE.LinearFilter
|
||||||
|
|
||||||
|
const verwalter = THREE.DefaultLoadingManager
|
||||||
|
verwalter.itemStart(url)
|
||||||
|
|
||||||
|
const bild = new Image()
|
||||||
|
bild.onload = () => {
|
||||||
|
const kante = Math.min(bild.width, bild.height)
|
||||||
|
|
||||||
|
// Sichtbarer Durchmesser des Kreises in Bildpunkten der Leinwand
|
||||||
|
const abstand = Math.hypot(seat.x, CAMERA.z - seat.z)
|
||||||
|
const durchmesser = (seat.radius * leinwandHoehe) / (abstand * Math.tan((CAMERA.fov / 2) * Math.PI / 180))
|
||||||
|
// Etwas Reserve für Fenster, die grösser sind als das aktuelle
|
||||||
|
const ziel = Math.round(Math.min(kante, Math.max(160, durchmesser * 1.35)))
|
||||||
|
|
||||||
|
const flaeche = document.createElement('canvas')
|
||||||
|
flaeche.width = ziel
|
||||||
|
flaeche.height = ziel
|
||||||
|
const g = flaeche.getContext('2d')
|
||||||
|
if (g) {
|
||||||
|
g.imageSmoothingEnabled = true
|
||||||
|
g.imageSmoothingQuality = 'high'
|
||||||
|
g.drawImage(bild, (bild.width - kante) / 2, (bild.height - kante) / 2, kante, kante, 0, 0, ziel, ziel)
|
||||||
|
schaerfeNach(g, flaeche, ziel, kante)
|
||||||
|
tex.image = flaeche
|
||||||
|
} else {
|
||||||
|
tex.image = bild
|
||||||
|
}
|
||||||
|
tex.needsUpdate = true
|
||||||
|
verwalter.itemEnd(url)
|
||||||
|
}
|
||||||
|
bild.onerror = () => {
|
||||||
|
verwalter.itemError(url)
|
||||||
|
verwalter.itemEnd(url)
|
||||||
|
}
|
||||||
|
bild.src = url
|
||||||
|
|
||||||
|
return tex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Agent am Tisch (§3).
|
||||||
|
*
|
||||||
|
* Nur das runde Porträt mit einem weissen Ring — keine Stühle, keine Körper,
|
||||||
|
* keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind
|
||||||
|
* ersatzlos entfallen (§3.1).
|
||||||
|
*
|
||||||
|
* Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von
|
||||||
|
* einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch
|
||||||
|
* genügt, damit die Runde zusammengehört.
|
||||||
*/
|
*/
|
||||||
function addSeatedAgent(
|
function addSeatedAgent(
|
||||||
scene: THREE.Scene,
|
scene: THREE.Scene,
|
||||||
loader: THREE.TextureLoader,
|
|
||||||
seat: MeetingSeat,
|
seat: MeetingSeat,
|
||||||
photoUrl: string | undefined,
|
photoUrl: string | undefined,
|
||||||
suitColor: number,
|
maxAnisotropy: number,
|
||||||
|
leinwandHoehe: number,
|
||||||
): SeatHandle {
|
): SeatHandle {
|
||||||
const group = new THREE.Group()
|
const group = new THREE.Group()
|
||||||
group.position.set(seat.x, 0, seat.z)
|
group.position.set(seat.x, 0, seat.z)
|
||||||
|
|
||||||
// Blickrichtung: zum Kopfende des Betrachters hin, aber zur Tischmitte gedreht.
|
|
||||||
const yaw =
|
const yaw =
|
||||||
seat.side === SeatSide.HEAD ? 0
|
seat.side === SeatSide.HEAD ? 0
|
||||||
: seat.side === SeatSide.LEFT ? 0.62
|
: seat.side === SeatSide.LEFT ? 0.42
|
||||||
: -0.62
|
: -0.42
|
||||||
|
|
||||||
// ── Torso ──
|
// Radius und Höhe stammen vom Platz: näher sitzende Agenten haben einen
|
||||||
const torsoHeight = SEATED.torsoTop - SEATED.torsoBottom
|
// kleineren Kreis, erscheinen im Bild aber grösser (Runde 7, §2).
|
||||||
const torso = new THREE.Mesh(
|
const radius = seat.radius
|
||||||
new THREE.CylinderGeometry(SEATED.torsoRadiusTop, SEATED.torsoRadiusBottom, torsoHeight, 20, 1),
|
const kopfHoehe = TABLE.top + radius + SEATED.headGap
|
||||||
new THREE.MeshLambertMaterial({ color: suitColor }),
|
|
||||||
)
|
|
||||||
torso.position.y = SEATED.torsoBottom + torsoHeight / 2
|
|
||||||
group.add(torso)
|
|
||||||
|
|
||||||
// Schultern: eine flach gedrückte Kugel schliesst den Torso oben ab
|
|
||||||
const shoulders = new THREE.Mesh(
|
|
||||||
new THREE.SphereGeometry(SEATED.shoulderWidth / 2, 20, 12),
|
|
||||||
new THREE.MeshLambertMaterial({ color: suitColor }),
|
|
||||||
)
|
|
||||||
shoulders.scale.set(1, 0.42, 0.66)
|
|
||||||
shoulders.position.y = SEATED.torsoTop - 0.02
|
|
||||||
group.add(shoulders)
|
|
||||||
|
|
||||||
// ── Kopf ──
|
|
||||||
const head = new THREE.Group()
|
const head = new THREE.Group()
|
||||||
head.position.y = SEATED.headY
|
head.position.y = kopfHoehe
|
||||||
head.rotation.y = yaw
|
head.rotation.y = yaw
|
||||||
|
|
||||||
const ring = new THREE.Mesh(
|
const ring = new THREE.Mesh(
|
||||||
new THREE.CircleGeometry(SEATED.headRadius + 0.018, 40),
|
new THREE.CircleGeometry(radius * 1.06, 48),
|
||||||
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
|
new THREE.MeshBasicMaterial({ color: ROOM_COLORS.headRing }),
|
||||||
)
|
)
|
||||||
ring.position.z = -0.004
|
ring.position.z = -0.006
|
||||||
head.add(ring)
|
head.add(ring)
|
||||||
|
|
||||||
if (photoUrl) {
|
if (photoUrl) {
|
||||||
const tex = loader.load(photoUrl)
|
const tex = ladePortraet(photoUrl, seat, leinwandHoehe, maxAnisotropy)
|
||||||
tex.colorSpace = THREE.SRGBColorSpace
|
|
||||||
const face = new THREE.Mesh(
|
const face = new THREE.Mesh(
|
||||||
new THREE.CircleGeometry(SEATED.headRadius, 40),
|
new THREE.CircleGeometry(radius, 96),
|
||||||
new THREE.MeshBasicMaterial({ map: tex }),
|
new THREE.MeshBasicMaterial({ map: tex }),
|
||||||
)
|
)
|
||||||
head.add(face)
|
head.add(face)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hinterkopf, damit von der Seite kein Loch entsteht
|
|
||||||
const backOfHead = new THREE.Mesh(
|
|
||||||
new THREE.SphereGeometry(SEATED.headRadius * 0.94, 20, 14),
|
|
||||||
new THREE.MeshLambertMaterial({ color: 0x2a2724 }),
|
|
||||||
)
|
|
||||||
backOfHead.position.z = -SEATED.headRadius * 1.05
|
|
||||||
backOfHead.scale.set(1, 1, 0.62)
|
|
||||||
head.add(backOfHead)
|
|
||||||
|
|
||||||
// Hals
|
|
||||||
const neck = new THREE.Mesh(
|
|
||||||
new THREE.CylinderGeometry(0.055, 0.07, 0.16, 12),
|
|
||||||
new THREE.MeshLambertMaterial({ color: 0xc79a7c }),
|
|
||||||
)
|
|
||||||
neck.position.y = SEATED.headY - 0.17
|
|
||||||
group.add(neck)
|
|
||||||
|
|
||||||
group.add(head)
|
group.add(head)
|
||||||
|
|
||||||
// Unterarme auf der Platte. Sie kosten zwei Quader und sind der Unterschied
|
|
||||||
// zwischen einem Rumpf hinter einer Kante und einer Person, die am Tisch sitzt.
|
|
||||||
const armMat = new THREE.MeshLambertMaterial({ color: suitColor })
|
|
||||||
for (const sign of [-1, 1]) {
|
|
||||||
const arm = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.075, 0.42), armMat)
|
|
||||||
arm.position.set(0, TABLE.top + 0.045, 0)
|
|
||||||
arm.rotation.y = yaw
|
|
||||||
arm.translateX(sign * 0.16)
|
|
||||||
// Zur Tischmitte hin nach vorne gelegt
|
|
||||||
arm.translateZ(seat.side === SeatSide.HEAD ? 0.3 : 0.3)
|
|
||||||
group.add(arm)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stuhllehne hinter dem Agenten ──
|
|
||||||
const chair = new THREE.Mesh(
|
|
||||||
new THREE.BoxGeometry(0.56, 0.62, 0.06),
|
|
||||||
new THREE.MeshLambertMaterial({ color: ROOM_COLORS.chair }),
|
|
||||||
)
|
|
||||||
chair.position.set(0, 0.86, 0)
|
|
||||||
chair.rotation.y = yaw
|
|
||||||
chair.translateZ(-0.28)
|
|
||||||
group.add(chair)
|
|
||||||
|
|
||||||
// ── Trefferkörper: gross genug für bequemes Klicken (§9) ──
|
// ── Trefferkörper: gross genug für bequemes Klicken (§9) ──
|
||||||
const hitBox = new THREE.Mesh(
|
const hitBox = new THREE.Mesh(
|
||||||
new THREE.BoxGeometry(0.94, 1.34, 0.82),
|
/*
|
||||||
|
* Eine Kugel knapp um den sichtbaren Kreis — nicht ein grosszügiger Quader.
|
||||||
|
* Ein weiter Quader am vorderen Platz fing die Strahlen der hinteren Plätze
|
||||||
|
* ab: der Raycaster nimmt den nächstgelegenen Treffer, und damit landete ein
|
||||||
|
* Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was
|
||||||
|
* man sieht.
|
||||||
|
*/
|
||||||
|
new THREE.SphereGeometry(radius * 1.1, 12, 8),
|
||||||
// Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
|
// Unsichtbar, aber weiterhin vom Raycaster erfasst: `visible = false` würde
|
||||||
// das Objekt aus der Strahlprüfung nehmen.
|
// das Objekt aus der Strahlprüfung nehmen.
|
||||||
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
|
||||||
)
|
)
|
||||||
hitBox.position.y = 0.9
|
hitBox.position.y = kopfHoehe
|
||||||
group.add(hitBox)
|
group.add(hitBox)
|
||||||
|
|
||||||
scene.add(group)
|
scene.add(group)
|
||||||
@@ -394,7 +481,7 @@ function addSeatedAgent(
|
|||||||
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
// die Plätze dort auf wenige Bildpunkte zusammenrücken (§8).
|
||||||
labelAnchor: new THREE.Vector3(
|
labelAnchor: new THREE.Vector3(
|
||||||
seat.x,
|
seat.x,
|
||||||
SEATED.headY + SEATED.headRadius + 0.16 + Math.min(0.78, Math.max(0, (Math.abs(seat.z) - 1.75) * 0.135)),
|
kopfHoehe + radius + 0.10 + Math.min(0.34, Math.max(0, (Math.abs(seat.z) - 0.9) * 0.06)),
|
||||||
seat.z,
|
seat.z,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -408,14 +495,22 @@ export function createMeetingRoomScene(
|
|||||||
photos: Record<string, string | undefined>,
|
photos: Record<string, string | undefined>,
|
||||||
): MeetingRoomScene {
|
): MeetingRoomScene {
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
/*
|
||||||
|
* Mindestens doppelte Auflösung, unabhängig vom Bildschirm.
|
||||||
|
*
|
||||||
|
* Wird intern feiner gerechnet als der Bildschirm darstellt, gewinnen alle
|
||||||
|
* feinen Kanten — Fensterprofile, Kulisse und vor allem die Porträtkreise.
|
||||||
|
* Das kostet hier wenig: die Szene wird nicht laufend, sondern nur bei
|
||||||
|
* Grössenänderung, Mausbewegung und Zustandswechsel neu gezeichnet.
|
||||||
|
*/
|
||||||
|
renderer.setPixelRatio(Math.min(Math.max(window.devicePixelRatio, 2), 3))
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||||
|
|
||||||
const scene = new THREE.Scene()
|
const scene = new THREE.Scene()
|
||||||
scene.background = new THREE.Color(ROOM_COLORS.sky)
|
scene.background = new THREE.Color(ROOM_COLORS.sky)
|
||||||
// Leichter Dunst nur in der Ferne: der Raum (0…12 m) bleibt klar, die Kulisse
|
// Leichter Dunst nur in der Ferne: der Raum (0…12 m) bleibt klar, die Kulisse
|
||||||
// bei 40–65 m bekommt Tiefe. Ohne ihn klebt die Stadt an der Scheibe.
|
// bei 40–65 m bekommt Tiefe. Ohne ihn klebt die Stadt an der Scheibe.
|
||||||
scene.fog = new THREE.Fog(ROOM_COLORS.sky, 30, 110)
|
scene.fog = new THREE.Fog(ROOM_COLORS.sky, 40, 140)
|
||||||
|
|
||||||
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300)
|
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, 0.1, 300)
|
||||||
camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
|
camera.position.set(0, CAMERA.eyeHeight, CAMERA.z)
|
||||||
@@ -432,18 +527,26 @@ export function createMeetingRoomScene(
|
|||||||
scene.add(bounce)
|
scene.add(bounce)
|
||||||
|
|
||||||
const loader = new THREE.TextureLoader()
|
const loader = new THREE.TextureLoader()
|
||||||
|
const maxAnisotropy = renderer.capabilities.getMaxAnisotropy()
|
||||||
|
|
||||||
addBaselBackdrop(scene, loader)
|
addBaselBackdrop(scene, loader, maxAnisotropy)
|
||||||
addRoomShell(scene)
|
addRoomShell(scene)
|
||||||
addGlassFacades(scene)
|
addGlassFacades(scene)
|
||||||
addTable(scene)
|
addTable(scene)
|
||||||
|
|
||||||
const handles = seats.map((seat, i) =>
|
/*
|
||||||
addSeatedAgent(scene, loader, seat, photos[seat.id], ROOM_COLORS.suits[i % ROOM_COLORS.suits.length]),
|
* Höhe der Leinwand in Bildpunkten, aus der die Porträts ihre Auflösung
|
||||||
)
|
* ableiten. Zum Zeitpunkt des Aufbaus steht sie noch nicht fest, weil die
|
||||||
|
* Grösse erst danach gesetzt wird. Der Ausgangswert deckt den üblichen
|
||||||
|
* Bereich ab; wächst das Fenster später deutlich, werden die Bilder etwas
|
||||||
|
* vergrössert dargestellt — weit weniger auffällig als eine zu grobe
|
||||||
|
* Zwischenfassung.
|
||||||
|
*/
|
||||||
|
const leinwandHoehe = Math.max(canvas.clientHeight, 700) * renderer.getPixelRatio()
|
||||||
|
|
||||||
const baseCam = camera.position.clone()
|
const handles = seats.map(seat =>
|
||||||
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
|
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
|
||||||
|
)
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
renderer.render(scene, camera)
|
renderer.render(scene, camera)
|
||||||
@@ -455,15 +558,6 @@ export function createMeetingRoomScene(
|
|||||||
renderer,
|
renderer,
|
||||||
seats: handles,
|
seats: handles,
|
||||||
|
|
||||||
applyParallax(dx, dy) {
|
|
||||||
camera.position.set(
|
|
||||||
baseCam.x + dx * CAMERA.parallax,
|
|
||||||
baseCam.y + dy * CAMERA.parallax * 0.6,
|
|
||||||
baseCam.z,
|
|
||||||
)
|
|
||||||
camera.lookAt(lookTarget)
|
|
||||||
},
|
|
||||||
|
|
||||||
setSize(width, height) {
|
setSize(width, height) {
|
||||||
renderer.setSize(width, height, false)
|
renderer.setSize(width, height, false)
|
||||||
const aspect = width / Math.max(height, 1)
|
const aspect = width / Math.max(height, 1)
|
||||||
@@ -472,7 +566,15 @@ export function createMeetingRoomScene(
|
|||||||
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
// schneidet das hohe Format den Tisch links und rechts ab. Statt einer
|
||||||
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
// eigenen Mobilszene (§18: pragmatische Degradation) öffnet sich die
|
||||||
// Kamera, bis der Raum wieder hineinpasst.
|
// Kamera, bis der Raum wieder hineinpasst.
|
||||||
camera.fov = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26)
|
const weit = CAMERA.fov + Math.max(0, (1.45 - aspect) * 26)
|
||||||
|
/*
|
||||||
|
* Nach oben begrenzt durch den waagrechten Bildwinkel. Bei sehr breiten,
|
||||||
|
* flachen Fenstern — etwa 1280 × 380 auf einem Notebook — griffe sonst ein
|
||||||
|
* waagrechter Winkel von über 110°: der Raum wirkt gezerrt, und der Blick
|
||||||
|
* reicht seitlich über den Rand des Panoramas hinaus.
|
||||||
|
*/
|
||||||
|
const maxTan = Math.tan((CAMERA.maxHFov / 2) * Math.PI / 180) / aspect
|
||||||
|
camera.fov = Math.min(weit, 2 * Math.atan(maxTan) * 180 / Math.PI)
|
||||||
camera.updateProjectionMatrix()
|
camera.updateProjectionMatrix()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -39,43 +39,68 @@ export interface MeetingSeat {
|
|||||||
/** Sitzposition in Metern. */
|
/** Sitzposition in Metern. */
|
||||||
x: number
|
x: number
|
||||||
z: number
|
z: number
|
||||||
|
/** Radius des Porträtkreises in Metern. */
|
||||||
|
radius: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Platz, so wie er unten eingetragen wird. */
|
||||||
|
type Platz = { side: SeatSide; x: number; z: number; radius: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sitzplätze der fünf Agenten, in Metern (Runde 6 §3.2, Runde 7 §2).
|
||||||
|
*
|
||||||
|
* Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links,
|
||||||
|
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dahinter
|
||||||
|
* staffeln sich die übrigen drei.
|
||||||
|
*
|
||||||
|
* Die Radien wachsen nach hinten, die **Bildgrösse** nimmt trotzdem mit der
|
||||||
|
* Tiefe ab: ein Kreis von 0.30 m in 5 m Entfernung erscheint kleiner als einer
|
||||||
|
* von 0.265 m in 3 m. Ohne diese Gegensteuerung wären die hinteren Porträts bei
|
||||||
|
* gleichem Radius kaum noch erkennbar (§2: «tiefenlogisch», aber «insgesamt klar
|
||||||
|
* besser sichtbar»).
|
||||||
|
*
|
||||||
|
* Alle Werte sind gemeinsam gewählt, nicht einzeln: mit den Kamerawerten aus
|
||||||
|
* `meetingRoomPalette` durchgerechnet ergeben sie gegenüber dem Stand vor dieser
|
||||||
|
* Runde die Faktoren 2.11× (Ferdi/Bruno), 2.22× (Livia/Nora), 2.50× (Sina) und
|
||||||
|
* 3.18× (Thomas) — und dabei **keine** einzige Überschneidung zweier Kreise im
|
||||||
|
* Bild. Wer einen Wert ändert, verschiebt beides; die Kreise überlappen dann
|
||||||
|
* schnell wieder.
|
||||||
|
*/
|
||||||
|
const PLAETZE: Record<string, Platz> = {
|
||||||
|
ferdi: { side: SeatSide.LEFT, x: -1.16, z: -0.88, radius: 0.268 },
|
||||||
|
bruno: { side: SeatSide.RIGHT, x: 1.16, z: -0.88, radius: 0.268 },
|
||||||
|
livia: { side: SeatSide.LEFT, x: -1.32, z: -3.35, radius: 0.345 },
|
||||||
|
nora: { side: SeatSide.RIGHT, x: 1.32, z: -3.35, radius: 0.345 },
|
||||||
|
sina: { side: SeatSide.LEFT, x: -0.95, z: -5.50, radius: 0.395 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstand der Plätze entlang des Tisches, in Metern. Bewusst grosszügig: bei
|
* Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt
|
||||||
* engerer Staffelung überschneiden sich in der Perspektive die Köpfe der
|
* lautlos im Nichts.
|
||||||
* hinteren Plätze mit denen der vorderen (§5 «keine Personen überlappen»).
|
|
||||||
*/
|
*/
|
||||||
const SEAT_Z = [-1.75, -3.55, -5.35]
|
const FALLBACK: Platz = { side: SeatSide.RIGHT, x: 0.95, z: -5.50, radius: 0.395 }
|
||||||
|
|
||||||
/** Seitlicher Abstand von der Tischmitte — knapp aussen an der Tischkante. */
|
/**
|
||||||
const SEAT_X = 1.32
|
* Kopfende gegenüber. Position unverändert, nur der Kreis wächst kräftig: von
|
||||||
|
* dort aus ist der Abstand am grössten, und Thomas war zuvor kaum auszumachen
|
||||||
/** Kopfende gegenüber: hinter der Tischkante, vor der Glasfront. */
|
* (§2). Grösser darf er nicht werden — sein Kreis überträfe im Bild sonst den
|
||||||
const HEAD_Z = -8.15
|
* von Sina, die näher sitzt, und die Tiefenwirkung kippte.
|
||||||
|
*/
|
||||||
|
const KOPFENDE: Platz = { side: SeatSide.HEAD, x: 0, z: -6.75, radius: 0.452 }
|
||||||
|
|
||||||
export const MEETING_SEATS: MeetingSeat[] = [
|
export const MEETING_SEATS: MeetingSeat[] = [
|
||||||
...AGENT_WORKSPACES.map((agent, i) => {
|
...AGENT_WORKSPACES.map(agent => ({
|
||||||
const left = i % 2 === 0
|
|
||||||
return {
|
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
role: agent.role,
|
role: agent.role,
|
||||||
path: agent.path,
|
path: agent.path,
|
||||||
side: left ? SeatSide.LEFT : SeatSide.RIGHT,
|
...(PLAETZE[agent.id] ?? FALLBACK),
|
||||||
x: left ? -SEAT_X : SEAT_X,
|
})),
|
||||||
// Die Plätze rücken paarweise nach hinten: links und rechts versetzt,
|
|
||||||
// damit über die Tischmitte hinweg niemand hinter einem Kopf verschwindet.
|
|
||||||
z: SEAT_Z[Math.floor(i / 2)] - (left ? 0 : 0.9),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
id: AGENT_SUPERVISOR.id,
|
id: AGENT_SUPERVISOR.id,
|
||||||
name: AGENT_SUPERVISOR.name,
|
name: AGENT_SUPERVISOR.name,
|
||||||
role: AGENT_SUPERVISOR.role,
|
role: AGENT_SUPERVISOR.role,
|
||||||
path: AGENT_SUPERVISOR.path,
|
path: AGENT_SUPERVISOR.path,
|
||||||
side: SeatSide.HEAD,
|
...KOPFENDE,
|
||||||
x: 0,
|
|
||||||
z: HEAD_Z,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||||
|
import { AiDisclaimer } from '../ui'
|
||||||
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
|
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
|
||||||
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useToastStore } from '../../stores/toastStore'
|
||||||
@@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Der KI-Hinweis steht innerhalb des scrollenden Bereichs. Ausserhalb
|
||||||
|
läge er hinter dem `overflow: hidden` des äusseren Rahmens und wäre
|
||||||
|
nie zu sehen — der Aktionsbalken darunter ist bewusst der einzige
|
||||||
|
Teil, der fix am unteren Rand klebt.
|
||||||
|
*/}
|
||||||
|
<AiDisclaimer />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
|
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
|
||||||
|
|||||||
@@ -115,7 +115,43 @@ export function PropertyTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
<Table stickyHeader size="small">
|
<Table
|
||||||
|
stickyHeader
|
||||||
|
size="small"
|
||||||
|
/*
|
||||||
|
* Feste Spaltenbreiten statt automatischer (Runde 9, §2).
|
||||||
|
*
|
||||||
|
* Mit automatischer Breite richtet sich jede Spalte nach ihrem längsten
|
||||||
|
* Inhalt — bei elf Spalten summiert sich das über die Fensterbreite
|
||||||
|
* hinaus, und die letzten beiden lagen im waagrechten Scrollbereich.
|
||||||
|
* Die Anteile unten ergeben zusammen 100 %; keine Spalte ist entfernt
|
||||||
|
* und kein Wert gekürzt, sie teilen sich den Platz nur nach Bedarf:
|
||||||
|
* Objektname und Mieter tragen lange Texte, «Fläche» oder «Break-Option»
|
||||||
|
* kommen mit wenig aus.
|
||||||
|
*/
|
||||||
|
sx={{
|
||||||
|
tableLayout: 'fixed',
|
||||||
|
'& .MuiTableCell-root': {
|
||||||
|
px: 1,
|
||||||
|
py: 0.75,
|
||||||
|
// Lange Namen brechen um, statt die Spalte aufzuweiten.
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: '19%' }} />{/* Objekt */}
|
||||||
|
<col style={{ width: '7%' }} />{/* Typ */}
|
||||||
|
<col style={{ width: '9%' }} />{/* Standort */}
|
||||||
|
<col style={{ width: '6%' }} />{/* Fläche */}
|
||||||
|
<col style={{ width: '8%' }} />{/* Miete */}
|
||||||
|
<col style={{ width: '13%' }} />{/* Aktueller Mieter */}
|
||||||
|
<col style={{ width: '9%' }} />{/* Mietlaufzeit */}
|
||||||
|
<col style={{ width: '7%' }} />{/* Breakoutoption */}
|
||||||
|
<col style={{ width: '9%' }} />{/* Breakoutoption Zeitpunkt */}
|
||||||
|
<col style={{ width: '9%' }} />{/* Datenqualität */}
|
||||||
|
<col style={{ width: '4%' }} />{/* Aktionen */}
|
||||||
|
</colgroup>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
||||||
@@ -205,7 +241,7 @@ export function PropertyTable({
|
|||||||
|
|
||||||
{/* Aktueller Mieter */}
|
{/* Aktueller Mieter */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
|
<Typography variant="body2">
|
||||||
{p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>}
|
{p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>}
|
||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -249,7 +285,7 @@ export function PropertyTable({
|
|||||||
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
|
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Box sx={{ width: 80 }}>
|
<Box sx={{ width: '100%', maxWidth: 90 }}>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
variant="determinate"
|
variant="determinate"
|
||||||
value={qScore * 100}
|
value={qScore * 100}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { FileSignature } from 'lucide-react'
|
||||||
|
import type { Reminder } from '../../domain/reminder'
|
||||||
|
import { ReminderType } from '../../domain/reminder'
|
||||||
|
import { REMINDER_TYPE_HINTS, REMINDER_TYPE_LABELS } from '../../lib/constants'
|
||||||
|
import { SectionTitle, DateRow } from './reminderDetailHelpers'
|
||||||
|
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Vertragsteil eines Reminders (Runde 8, §4).
|
||||||
|
*
|
||||||
|
* Beantwortet in dieser Reihenfolge: welcher Vertrag und welche Fundstelle,
|
||||||
|
* welcher Zeitpunkt wurde erkannt, weshalb ist er relevant, was ist zu tun.
|
||||||
|
*
|
||||||
|
* Der erkannte Zeitpunkt steht bewusst vor dem Vertragsende — bei einer echten
|
||||||
|
* Option ist die Ausübungsfrist die Nachricht, das Vertragsende nur ihr Anlass.
|
||||||
|
* Ein Reminder, der auf das Vertragsende zeigt, kommt für jede Entscheidung zu
|
||||||
|
* spät, die ihn nötig gemacht hat.
|
||||||
|
*
|
||||||
|
* Der Block erscheint nur, wenn tatsächlich Vertragsangaben vorliegen. Ein
|
||||||
|
* Unterhaltstermin hat keine Fundstelle in einem Mietvertrag, und eine leere
|
||||||
|
* Überschrift wäre die Behauptung, es fehle etwas.
|
||||||
|
*/
|
||||||
|
export function ReminderContractPanel({ reminder }: { reminder: Reminder }) {
|
||||||
|
const hasWindow = Boolean(reminder.negotiationWindowFrom || reminder.negotiationWindowTo)
|
||||||
|
const hasContent = Boolean(
|
||||||
|
reminder.contractRef ||
|
||||||
|
reminder.decisionDate ||
|
||||||
|
reminder.relevanceReason ||
|
||||||
|
reminder.recommendedAction ||
|
||||||
|
reminder.noticePeriodMonths != null ||
|
||||||
|
hasWindow,
|
||||||
|
)
|
||||||
|
if (!hasContent) return null
|
||||||
|
|
||||||
|
const isRealOption = reminder.type === ReminderType.REAL_OPTION
|
||||||
|
const decisionLabel = isRealOption ? 'Ausübung bis' : 'Entscheid bis'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2.5 }}>
|
||||||
|
<SectionTitle icon={<FileSignature size={14} color="#64748b" />}>Vertrag</SectionTitle>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{reminder.contractRef && (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Dokument</Typography>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>{reminder.contractRef}</Typography>
|
||||||
|
{reminder.contractClause && (
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||||
|
{reminder.contractClause}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Erkannter Typ</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{REMINDER_TYPE_LABELS[reminder.type] ?? reminder.type}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Der massgebliche Zeitpunkt — nicht das Vertragsende. */}
|
||||||
|
<DateRow label={decisionLabel} value={reminder.decisionDate} />
|
||||||
|
|
||||||
|
{reminder.noticePeriodMonths != null && (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Kündigungsfrist</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{reminder.noticePeriodMonths} Monate
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasWindow && (
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 1, alignItems: 'baseline' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">Verhandlungsfenster</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
|
{reminder.negotiationWindowFrom
|
||||||
|
? new Date(reminder.negotiationWindowFrom).toLocaleDateString('de-CH')
|
||||||
|
: 'offen'}
|
||||||
|
{' – '}
|
||||||
|
{reminder.negotiationWindowTo
|
||||||
|
? new Date(reminder.negotiationWindowTo).toLocaleDateString('de-CH')
|
||||||
|
: 'offen'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Weshalb der Zeitpunkt zählt */}
|
||||||
|
{(reminder.relevanceReason || REMINDER_TYPE_HINTS[reminder.type]) && (
|
||||||
|
<Box sx={{ mt: 1.75, bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 1.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5, fontSize: '0.65rem' }}>
|
||||||
|
Weshalb relevant
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55 }}>
|
||||||
|
{reminder.relevanceReason ?? REMINDER_TYPE_HINTS[reminder.type]}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Was als Nächstes zu tun ist */}
|
||||||
|
{reminder.recommendedAction && (
|
||||||
|
<Box sx={{ mt: 1.25, border: '1px solid', borderColor: DS_BRAND.main, borderRadius: 1.5, p: 1.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.5, fontSize: '0.65rem' }}>
|
||||||
|
Empfohlene nächste Handlung
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55 }}>
|
||||||
|
{reminder.recommendedAction}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { X, Calendar } from 'lucide-react'
|
import { X, Calendar } from 'lucide-react'
|
||||||
import { useReminderStore } from '../../stores/reminderStore'
|
import { useReminderStore } from '../../stores/reminderStore'
|
||||||
|
import { AiDisclaimer } from '../ui'
|
||||||
import {
|
import {
|
||||||
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
|
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
|
||||||
} from '../../hooks/useReminders'
|
} from '../../hooks/useReminders'
|
||||||
@@ -13,17 +14,28 @@ import { ReminderTypeBadge } from './ReminderTypeBadge'
|
|||||||
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
import { ReminderDaysIndicator } from './ReminderDaysIndicator'
|
||||||
import { ReminderStatusBadge } from './ReminderStatusBadge'
|
import { ReminderStatusBadge } from './ReminderStatusBadge'
|
||||||
import { ReminderCalendarPlanner } from './ReminderCalendarPlanner'
|
import { ReminderCalendarPlanner } from './ReminderCalendarPlanner'
|
||||||
|
import { ReminderContractPanel } from './ReminderContractPanel'
|
||||||
import { ReminderStatus, ReminderType } from '../../domain/reminder'
|
import { ReminderStatus, ReminderType } from '../../domain/reminder'
|
||||||
import { SectionTitle, DateRow } from './reminderDetailHelpers'
|
import { SectionTitle, DateRow } from './reminderDetailHelpers'
|
||||||
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
import { DS_BRAND, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
// ── Constants ────────────────────────────────────────────────────────────────
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das zweite Datum je Termintyp. Bei den Vertragstypen aus Runde 8 (§4) ist das
|
||||||
|
* bewusst das Vertragsende — als Einordnung, nicht als Fälligkeit. Die
|
||||||
|
* Fälligkeit steht darüber und liegt früher; genau das ist der Punkt.
|
||||||
|
*/
|
||||||
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
|
const TYPE_SECOND_DATE: Partial<Record<ReminderType, 'contractEndDate' | 'breakOptionDate' | 'eventDate'>> = {
|
||||||
[ReminderType.LEASE_EXPIRY]: 'contractEndDate',
|
[ReminderType.LEASE_EXPIRY]: 'contractEndDate',
|
||||||
[ReminderType.BREAK_OPTION]: 'breakOptionDate',
|
[ReminderType.BREAK_OPTION]: 'breakOptionDate',
|
||||||
[ReminderType.RENT_REVIEW]: 'eventDate',
|
[ReminderType.RENT_REVIEW]: 'eventDate',
|
||||||
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
|
[ReminderType.SCHATTENMARKT_RELEASE]: 'eventDate',
|
||||||
|
[ReminderType.NOTICE_PERIOD]: 'contractEndDate',
|
||||||
|
[ReminderType.REAL_OPTION]: 'contractEndDate',
|
||||||
|
[ReminderType.UNREAL_OPTION]: 'contractEndDate',
|
||||||
|
[ReminderType.NEGOTIATION_WINDOW]: 'contractEndDate',
|
||||||
|
[ReminderType.CONTRACT_TALK]: 'contractEndDate',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
|
const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
|
||||||
@@ -31,6 +43,11 @@ const TYPE_SECOND_LABEL: Partial<Record<ReminderType, string>> = {
|
|||||||
[ReminderType.BREAK_OPTION]: 'Break-Option',
|
[ReminderType.BREAK_OPTION]: 'Break-Option',
|
||||||
[ReminderType.RENT_REVIEW]: 'Ereignisdatum',
|
[ReminderType.RENT_REVIEW]: 'Ereignisdatum',
|
||||||
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
|
[ReminderType.SCHATTENMARKT_RELEASE]: 'Ereignisdatum',
|
||||||
|
[ReminderType.NOTICE_PERIOD]: 'Vertragsende',
|
||||||
|
[ReminderType.REAL_OPTION]: 'Vertragsende',
|
||||||
|
[ReminderType.UNREAL_OPTION]: 'Vertragsende',
|
||||||
|
[ReminderType.NEGOTIATION_WINDOW]: 'Vertragsende',
|
||||||
|
[ReminderType.CONTRACT_TALK]: 'Vertragsende',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Drawer ───────────────────────────────────────────────────────────────────
|
// ── Drawer ───────────────────────────────────────────────────────────────────
|
||||||
@@ -131,6 +148,10 @@ export function ReminderDetailDrawer() {
|
|||||||
|
|
||||||
<Divider sx={{ mb: 2.5 }} />
|
<Divider sx={{ mb: 2.5 }} />
|
||||||
|
|
||||||
|
{/* 2b. Vertrag — welcher Vertrag, welcher Zeitpunkt, weshalb,
|
||||||
|
was tun (Runde 8, §4). Erscheint nur bei Vertragsterminen. */}
|
||||||
|
<ReminderContractPanel reminder={reminder} />
|
||||||
|
|
||||||
{/* 3. Terminplanung — steht, wo bis Runde 3 der Notizbereich war */}
|
{/* 3. Terminplanung — steht, wo bis Runde 3 der Notizbereich war */}
|
||||||
<Box sx={{ mb: 2.5 }}>
|
<Box sx={{ mb: 2.5 }}>
|
||||||
<ReminderCalendarPlanner reminder={reminder} property={reminderProperty} />
|
<ReminderCalendarPlanner reminder={reminder} property={reminderProperty} />
|
||||||
@@ -181,6 +202,8 @@ export function ReminderDetailDrawer() {
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AiDisclaimer />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Eye,
|
Eye,
|
||||||
Tag,
|
Tag,
|
||||||
|
CalendarClock,
|
||||||
|
Handshake,
|
||||||
|
KeyRound,
|
||||||
|
MessagesSquare,
|
||||||
|
Scissors,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type { ReminderType } from '../../domain/reminder'
|
import type { ReminderType } from '../../domain/reminder'
|
||||||
@@ -28,6 +33,12 @@ const CONFIG: Record<ReminderType, { Icon: LucideIcon; label: string }> = {
|
|||||||
MAINTENANCE: { Icon: Wrench, label: 'Unterhalt' },
|
MAINTENANCE: { Icon: Wrench, label: 'Unterhalt' },
|
||||||
SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Pre-Market' },
|
SCHATTENMARKT_RELEASE:{ Icon: Eye, label: 'Pre-Market' },
|
||||||
CUSTOM: { Icon: Tag, label: 'Individuell' },
|
CUSTOM: { Icon: Tag, label: 'Individuell' },
|
||||||
|
// Contract Intelligence (Runde 8, §4)
|
||||||
|
NOTICE_PERIOD: { Icon: Scissors, label: 'Kündigungsfrist' },
|
||||||
|
REAL_OPTION: { Icon: KeyRound, label: 'Echte Option' },
|
||||||
|
UNREAL_OPTION: { Icon: Handshake, label: 'Unechte Option' },
|
||||||
|
NEGOTIATION_WINDOW: { Icon: CalendarClock, label: 'Verhandlungsfenster' },
|
||||||
|
CONTRACT_TALK: { Icon: MessagesSquare, label: 'Vertragsgespräch' },
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import { DS_BORDER, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Gesprächsverlauf unter dem Eingabefeld (Runde 9, §3).
|
||||||
|
*
|
||||||
|
* Bewusst schlicht: Frage rechts, Antwort links mit dem Porträt des Agenten,
|
||||||
|
* keine Zeitstempel, keine Sprechblasen mit Spitzen, kein Tippindikator. Der
|
||||||
|
* Chat beantwortet drei Fragetypen aus Mockdaten — eine Oberfläche, die nach
|
||||||
|
* Messenger aussieht, verspräche mehr, als dahinter steckt.
|
||||||
|
*
|
||||||
|
* Vor der ersten Frage erscheint gar nichts. Der grosszügige Weissraum unter
|
||||||
|
* dem Eingabefeld ist Teil des Einstiegs und soll nicht durch einen leeren
|
||||||
|
* Verlaufsrahmen ersetzt werden.
|
||||||
|
*/
|
||||||
|
export function AgentChatTranscript() {
|
||||||
|
const messages = useAgentChatStore(s => s.messages)
|
||||||
|
const agent = useAgentChatStore(s => s.agent)
|
||||||
|
|
||||||
|
if (messages.length === 0 || !agent) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100%', maxWidth: 720, mt: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
m.from === 'user' ? (
|
||||||
|
<Box key={i} sx={{ alignSelf: 'flex-end', maxWidth: '85%' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.75, py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: DS_SLATE[100],
|
||||||
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary, whiteSpace: 'pre-line' }}>
|
||||||
|
{m.text}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box key={i} sx={{ display: 'flex', gap: 1.25, alignItems: 'flex-start', maxWidth: '95%' }}>
|
||||||
|
<Box sx={{ flexShrink: 0, mt: 0.25 }}>
|
||||||
|
<AgentAvatar agent={agent} size="small" />
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.75, py: 1,
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: 'white',
|
||||||
|
border: `1px solid ${DS_BORDER.default}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
|
||||||
|
{m.text}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|||||||
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||||
import { SendHorizonal } from 'lucide-react'
|
import { SendHorizonal } from 'lucide-react'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import { AgentChatTranscript } from './AgentChatTranscript'
|
||||||
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||||
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
||||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||||
@@ -19,7 +20,7 @@ export interface AgentWorkspaceHeroProps {
|
|||||||
* Höhe des obersten globalen Balkens. Der Chatkopf gilt bereits als verschwunden,
|
* Höhe des obersten globalen Balkens. Der Chatkopf gilt bereits als verschwunden,
|
||||||
* sobald er darunter liegt — nicht erst, wenn er den Fensterrand verlässt.
|
* sobald er darunter liegt — nicht erst, wenn er den Fensterrand verlässt.
|
||||||
*/
|
*/
|
||||||
const TOP_BAR_PX = 64
|
export const TOP_BAR_PX = 64
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemeinsamer Chat-Einstieg aller fünf Agentenseiten (Runde 4, §4; Runde 5, §5).
|
* Gemeinsamer Chat-Einstieg aller fünf Agentenseiten (Runde 4, §4; Runde 5, §5).
|
||||||
@@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<AgentChatTranscript />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material'
|
|||||||
import { HelpCircle, X } from 'lucide-react'
|
import { HelpCircle, X } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useTeamStore } from '../../stores/teamStore'
|
import { useTeamStore } from '../../stores/teamStore'
|
||||||
|
import { AiDisclaimer } from '../ui'
|
||||||
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
||||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
@@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() {
|
|||||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AiDisclaimer />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export { HistorySection } from './HistorySection'
|
|||||||
export { ChannelsSection } from './ChannelsSection'
|
export { ChannelsSection } from './ChannelsSection'
|
||||||
|
|
||||||
// Gemeinsame Bausteine der fünf Agentenseiten
|
// Gemeinsame Bausteine der fünf Agentenseiten
|
||||||
export { AgentWorkspaceHero } from './AgentWorkspaceHero'
|
export { AgentWorkspaceHero, TOP_BAR_PX } from './AgentWorkspaceHero'
|
||||||
export { AgentChatStickyBar } from './AgentChatStickyBar'
|
export { AgentChatStickyBar } from './AgentChatStickyBar'
|
||||||
export { useAgentChat } from './useAgentChat'
|
export { useAgentChat } from './useAgentChat'
|
||||||
export { ObjectDeepLink } from './ObjectDeepLink'
|
export { ObjectDeepLink } from './ObjectDeepLink'
|
||||||
@@ -87,3 +87,6 @@ export {
|
|||||||
PROTOCOL_STATUS_SURFACE,
|
PROTOCOL_STATUS_SURFACE,
|
||||||
PROTOCOL_STATUS_TEXT,
|
PROTOCOL_STATUS_TEXT,
|
||||||
} from './teamTokens'
|
} from './teamTokens'
|
||||||
|
|
||||||
|
// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg.
|
||||||
|
export { AgentChatTranscript } from './AgentChatTranscript'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||||
import { useToastStore } from '../../stores/toastStore'
|
import { useAgentChatAnswer } from '../../hooks/useAgentChatAnswer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
|
* Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
|
||||||
@@ -29,7 +29,10 @@ interface Options {
|
|||||||
* vergessen.
|
* vergessen.
|
||||||
*
|
*
|
||||||
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und
|
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
* Seit Runde 9 (§3) antwortet der Chat tatsächlich: aus den Beständen der
|
||||||
|
* jeweiligen Agentenseite, per Schlüsselwortlogik, ohne Backend und ohne
|
||||||
|
* Sprachmodell. Was er nicht belegen kann, sagt er nicht — eine erfundene Zahl
|
||||||
|
* wäre schlimmer als ein «das habe ich nicht verstanden».
|
||||||
*/
|
*/
|
||||||
export function useAgentChat({ active = true }: Options = {}) {
|
export function useAgentChat({ active = true }: Options = {}) {
|
||||||
const agent = useAgentChatStore(s => s.agent)
|
const agent = useAgentChatStore(s => s.agent)
|
||||||
@@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) {
|
|||||||
const focused = useAgentChatStore(s => s.focused)
|
const focused = useAgentChatStore(s => s.focused)
|
||||||
const setDraft = useAgentChatStore(s => s.setDraft)
|
const setDraft = useAgentChatStore(s => s.setDraft)
|
||||||
const setFocused = useAgentChatStore(s => s.setFocused)
|
const setFocused = useAgentChatStore(s => s.setFocused)
|
||||||
const showToast = useToastStore(s => s.showToast)
|
const append = useAgentChatStore(s => s.append)
|
||||||
|
const answerFor = useAgentChatAnswer()
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
@@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) {
|
|||||||
el.setSelectionRange(end, end)
|
el.setSelectionRange(end, end)
|
||||||
}, [active, focused])
|
}, [active, focused])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frage absenden und beantworten (Runde 9, §3).
|
||||||
|
*
|
||||||
|
* Bis hierher meldete das Absenden nur, der Chat sei nicht angebunden. Jetzt
|
||||||
|
* antwortet der Agent aus den Beständen seiner eigenen Seite — ohne Backend
|
||||||
|
* und ohne Sprachmodell, wie es der Auftrag verlangt.
|
||||||
|
*
|
||||||
|
* Frage und Antwort landen zusammen im Verlauf. Der Toast entfällt: Wer eine
|
||||||
|
* Antwort im Chat sieht, braucht daneben keine Einblendung, die dasselbe
|
||||||
|
* Ereignis noch einmal meldet.
|
||||||
|
*/
|
||||||
const send = useCallback(() => {
|
const send = useCallback(() => {
|
||||||
const text = draft.trim()
|
const text = draft.trim()
|
||||||
if (!text || !agent) return
|
if (!text || !agent) return
|
||||||
setDraft('')
|
setDraft('')
|
||||||
showToast(
|
append({ from: 'user', text })
|
||||||
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`,
|
append({ from: 'agent', text: answerFor(agent, text) })
|
||||||
'info',
|
}, [draft, agent, setDraft, append, answerFor])
|
||||||
)
|
|
||||||
}, [draft, agent, setDraft, showToast])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Box, Typography } from '@mui/material'
|
||||||
|
import { Info } from 'lucide-react'
|
||||||
|
import { AI_DISCLAIMER } from '../../lib/constants'
|
||||||
|
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der einheitliche KI-Hinweis am Fuss jeder Agenten-Detailansicht (Runde 9, §5).
|
||||||
|
*
|
||||||
|
* Eine einzige Komponente für alle sechs Detailansichten. Der Wortlaut ist
|
||||||
|
* vorgegeben und steht deshalb als Konstante in `constants.ts` — stünde er
|
||||||
|
* sechsmal im Code, hiesse er nach der ersten Korrektur an fünf Stellen anders,
|
||||||
|
* und ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner.
|
||||||
|
*
|
||||||
|
* Bewusst dezent: grau, klein, mit dünner Trennlinie darüber. Er soll gelesen
|
||||||
|
* werden können, ohne die Aussage darüber zu übertönen — ein roter Warnkasten
|
||||||
|
* an jeder Detailansicht stumpft nach dem dritten Mal ab und wird dann gar
|
||||||
|
* nicht mehr gelesen.
|
||||||
|
*/
|
||||||
|
export const AiDisclaimer = memo(function AiDisclaimer() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 0.75,
|
||||||
|
mt: 2,
|
||||||
|
pt: 1.5,
|
||||||
|
borderTop: `1px solid ${DS_SLATE[100]}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info size={13} color={DS_SLATE[400]} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: DS_TEXT.muted, lineHeight: 1.5 }}>
|
||||||
|
{AI_DISCLAIMER}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -12,3 +12,6 @@ export { ToastProvider } from './ToastProvider'
|
|||||||
export { ConfirmDialog } from './ConfirmDialog'
|
export { ConfirmDialog } from './ConfirmDialog'
|
||||||
export { DecisionContextPanel } from './DecisionContextPanel'
|
export { DecisionContextPanel } from './DecisionContextPanel'
|
||||||
export type { DecisionMetric, DecisionAction } from './DecisionContextPanel'
|
export type { DecisionMetric, DecisionAction } from './DecisionContextPanel'
|
||||||
|
|
||||||
|
// Runde 9, §5: einheitlicher KI-Hinweis in allen Agenten-Detailansichten.
|
||||||
|
export { AiDisclaimer } from './AiDisclaimer'
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material'
|
import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material'
|
||||||
import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react'
|
import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react'
|
||||||
import type { VisitAssignment } from '../../domain/visitAssignment'
|
import type { VisitAssignment } from '../../domain/visitAssignment'
|
||||||
|
import { AiDisclaimer } from '../ui'
|
||||||
import { VisitRequestType } from '../../domain/visitAssignment'
|
import { VisitRequestType } from '../../domain/visitAssignment'
|
||||||
import { needsReportWarning } from '../../services/visitAssignmentService'
|
import { needsReportWarning } from '../../services/visitAssignmentService'
|
||||||
import { useRequestVisitReport } from '../../hooks/useVisitAssignments'
|
import { useRequestVisitReport } from '../../hooks/useVisitAssignments'
|
||||||
@@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AiDisclaimer />
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,4 +71,37 @@ export interface FutureSignal {
|
|||||||
|
|
||||||
// KI-extracted contact information from web crawler sources
|
// KI-extracted contact information from web crawler sources
|
||||||
extractedContacts?: ExtractedContact[]
|
extractedContacts?: ExtractedContact[]
|
||||||
|
|
||||||
|
// ── Recherche-Lead von Livia (Runde 8, §1–§2) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chance oder Risiko. Optional, damit bestehende Signale gültig bleiben —
|
||||||
|
* fehlt der Wert, leitet ihn `leadSignalTypeOf()` aus `signalType` ab.
|
||||||
|
*/
|
||||||
|
leadSignalType?: LeadSignalType
|
||||||
|
/** Warum ist das eine Chance beziehungsweise ein Risiko? */
|
||||||
|
signalRationale?: string
|
||||||
|
/** Die konkrete nächste Handlung für den Bewirtschafter — kein Fazit, ein Auftrag. */
|
||||||
|
recommendedAction?: string
|
||||||
|
/** Bei Risiko: das Mietverhältnis, das betroffen sein könnte. */
|
||||||
|
affectedTenant?: string
|
||||||
|
/** Externe Quellen, aus denen Livia den Lead verdichtet hat. */
|
||||||
|
researchSources?: SignalSource[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chance / Risiko (Runde 8, §2) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewertungsrichtung eines Leads.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt von `signalType` und `signalDirection`: der Signaltyp sagt,
|
||||||
|
* *was* passiert (Expansion, Restrukturierung), die Richtung, ob Fläche gesucht
|
||||||
|
* oder frei wird. Chance und Risiko beantworten die Frage davor — soll der
|
||||||
|
* Bewirtschafter etwas gewinnen oder etwas verhindern? Danach richtet sich die
|
||||||
|
* empfohlene Handlung, und nur deshalb steht die Spalte in der Liste.
|
||||||
|
*/
|
||||||
|
export const LeadSignalType = {
|
||||||
|
CHANCE: 'CHANCE',
|
||||||
|
RISIKO: 'RISIKO',
|
||||||
|
} as const
|
||||||
|
export type LeadSignalType = typeof LeadSignalType[keyof typeof LeadSignalType]
|
||||||
@@ -1,3 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Die Termintypen, die Ferdi führt.
|
||||||
|
*
|
||||||
|
* Mit Runde 8 (§4) kommen die Vertragstypen dazu: Ferdi verwaltet nicht mehr
|
||||||
|
* nur allgemeine Erinnerungen, sondern erkennt die Zeitpunkte, an denen ein
|
||||||
|
* Gewerbemietvertrag eine Entscheidung verlangt.
|
||||||
|
*
|
||||||
|
* Die Unterscheidung zwischen echter und unechter Option ist keine Spitzfindigkeit,
|
||||||
|
* sondern der Kern des Ganzen: die **echte Option** gibt dem Mieter ein einseitiges
|
||||||
|
* Gestaltungsrecht — verstreicht die Ausübungsfrist, ist das Recht erloschen, und
|
||||||
|
* zwar unwiderruflich. Die **unechte Option** ist bloss eine Absichtserklärung,
|
||||||
|
* über eine Fortsetzung zu verhandeln; hier gibt es keine Frist, die verfällt,
|
||||||
|
* sondern ein Fenster, in dem verhandelt werden sollte.
|
||||||
|
*/
|
||||||
export const ReminderType = {
|
export const ReminderType = {
|
||||||
LEASE_EXPIRY: 'LEASE_EXPIRY',
|
LEASE_EXPIRY: 'LEASE_EXPIRY',
|
||||||
BREAK_OPTION: 'BREAK_OPTION',
|
BREAK_OPTION: 'BREAK_OPTION',
|
||||||
@@ -7,9 +21,30 @@ export const ReminderType = {
|
|||||||
MAINTENANCE: 'MAINTENANCE',
|
MAINTENANCE: 'MAINTENANCE',
|
||||||
SCHATTENMARKT_RELEASE: 'SCHATTENMARKT_RELEASE',
|
SCHATTENMARKT_RELEASE: 'SCHATTENMARKT_RELEASE',
|
||||||
CUSTOM: 'CUSTOM',
|
CUSTOM: 'CUSTOM',
|
||||||
|
|
||||||
|
// ── Contract Intelligence (Runde 8, §4) ────────────────────────────────────
|
||||||
|
/** Kündigungsfrist — ab wann eine Kündigung noch fristgerecht möglich ist. */
|
||||||
|
NOTICE_PERIOD: 'NOTICE_PERIOD',
|
||||||
|
/** Echte Option — verbindlicher Ausübungszeitpunkt, danach erloschen. */
|
||||||
|
REAL_OPTION: 'REAL_OPTION',
|
||||||
|
/** Unechte Option — Zeitfenster für Verhandlungen über eine Fortsetzung. */
|
||||||
|
UNREAL_OPTION: 'UNREAL_OPTION',
|
||||||
|
/** Verhandlungsfenster ohne Option, etwa vor einer Staffelmietanpassung. */
|
||||||
|
NEGOTIATION_WINDOW: 'NEGOTIATION_WINDOW',
|
||||||
|
/** Anstehendes Vertragsgespräch mit dem Mieter. */
|
||||||
|
CONTRACT_TALK: 'CONTRACT_TALK',
|
||||||
} as const
|
} as const
|
||||||
export type ReminderType = typeof ReminderType[keyof typeof ReminderType]
|
export type ReminderType = typeof ReminderType[keyof typeof ReminderType]
|
||||||
|
|
||||||
|
/** Welche Vertragstypen aus der Dokumentanalyse stammen (Runde 8, §4). */
|
||||||
|
export const CONTRACT_REMINDER_TYPES: ReminderType[] = [
|
||||||
|
ReminderType.NOTICE_PERIOD,
|
||||||
|
ReminderType.REAL_OPTION,
|
||||||
|
ReminderType.UNREAL_OPTION,
|
||||||
|
ReminderType.NEGOTIATION_WINDOW,
|
||||||
|
ReminderType.CONTRACT_TALK,
|
||||||
|
]
|
||||||
|
|
||||||
export const ReminderPriority = {
|
export const ReminderPriority = {
|
||||||
URGENT: 'URGENT', // ≤14 days
|
URGENT: 'URGENT', // ≤14 days
|
||||||
HIGH: 'HIGH', // 15–30 days
|
HIGH: 'HIGH', // 15–30 days
|
||||||
@@ -68,6 +103,32 @@ export interface Reminder {
|
|||||||
note?: string
|
note?: string
|
||||||
snoozedUntil?: string // ISO date
|
snoozedUntil?: string // ISO date
|
||||||
|
|
||||||
|
// ── Contract Intelligence (Runde 8, §4) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Zeitpunkt, an dem tatsächlich entschieden werden muss.
|
||||||
|
*
|
||||||
|
* Bewusst getrennt von `contractEndDate`: Ferdi orientiert sich nicht am
|
||||||
|
* Vertragsende, sondern am früheren Entscheidungs-, Kündigungs-, Options-
|
||||||
|
* oder Verhandlungszeitpunkt. Wer erst am Vertragsende erinnert, erinnert an
|
||||||
|
* etwas, das nicht mehr zu ändern ist.
|
||||||
|
*/
|
||||||
|
decisionDate?: string
|
||||||
|
/** Kündigungsfrist in Monaten, wie im Vertrag vereinbart. */
|
||||||
|
noticePeriodMonths?: number
|
||||||
|
/** Bei unechten Optionen: ab wann verhandelt werden sollte. */
|
||||||
|
negotiationWindowFrom?: string
|
||||||
|
/** Bei unechten Optionen: bis wann eine Einigung stehen sollte. */
|
||||||
|
negotiationWindowTo?: string
|
||||||
|
/** Bezeichnung des Vertrags oder Nachtrags, aus dem der Termin stammt. */
|
||||||
|
contractRef?: string
|
||||||
|
/** Fundstelle im Dokument — Ziffer, Artikel oder Seite. */
|
||||||
|
contractClause?: string
|
||||||
|
/** Warum dieser Zeitpunkt relevant ist, in einem Satz. */
|
||||||
|
relevanceReason?: string
|
||||||
|
/** Die empfohlene nächste Handlung — kein Fazit, ein Auftrag. */
|
||||||
|
recommendedAction?: string
|
||||||
|
|
||||||
activity: ReminderActivity[]
|
activity: ReminderActivity[]
|
||||||
|
|
||||||
organizationId: string
|
organizationId: string
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Livias Research-PoC: echte Quellen statt Mockdaten.
|
||||||
|
*
|
||||||
|
* Diese Typen werden von beiden Seiten benutzt — von der serverlosen Funktion
|
||||||
|
* unter `api/` und vom Frontend. Sie liegen deshalb im Domänenordner und nicht
|
||||||
|
* neben dem Endpoint: ein zweites, fast gleiches Typenpaar wäre die erste
|
||||||
|
* Stelle, an der Server und Oberfläche auseinanderlaufen.
|
||||||
|
*
|
||||||
|
* Bewusst klein gehalten (PoC, §9/§13). Es ist ausdrücklich **nicht** der
|
||||||
|
* `FutureSignal`-Typ: der trägt Wahrscheinlichkeiten, Datenqualität und
|
||||||
|
* Portfoliobezug, die eine Live-Recherche nicht liefern kann. Ein Feld zu
|
||||||
|
* füllen, für das keine Grundlage existiert, wäre genau die Behauptung, die
|
||||||
|
* Livia nicht aufstellen darf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Die neun Kategorien des PoC (§11). */
|
||||||
|
export const ResearchEventType = {
|
||||||
|
EXPANSION: 'EXPANSION',
|
||||||
|
NEW_LOCATION: 'NEW_LOCATION',
|
||||||
|
HEADCOUNT_GROWTH: 'HEADCOUNT_GROWTH',
|
||||||
|
INVESTMENT: 'INVESTMENT',
|
||||||
|
M_AND_A: 'M_AND_A',
|
||||||
|
RESTRUCTURING: 'RESTRUCTURING',
|
||||||
|
SITE_CLOSURE: 'SITE_CLOSURE',
|
||||||
|
NEW_COMPANY: 'NEW_COMPANY',
|
||||||
|
IRRELEVANT: 'IRRELEVANT',
|
||||||
|
} as const
|
||||||
|
export type ResearchEventType = typeof ResearchEventType[keyof typeof ResearchEventType]
|
||||||
|
|
||||||
|
export type ResearchRelevance = 'high' | 'medium' | 'low'
|
||||||
|
|
||||||
|
/** Ein von Livia aus einer echten Quelle abgeleiteter Lead (§13). */
|
||||||
|
export interface ResearchLead {
|
||||||
|
company: string
|
||||||
|
eventType: ResearchEventType
|
||||||
|
headline: string
|
||||||
|
location?: string
|
||||||
|
summary: string
|
||||||
|
relevance: ResearchRelevance
|
||||||
|
/** 0–1. Wie sicher ist die Einordnung angesichts der Quellenlage? */
|
||||||
|
confidence: number
|
||||||
|
reason: string
|
||||||
|
recommendedAction?: string
|
||||||
|
/** Anzeigename der Quelle, z. B. «Zürcher Handelskammer». */
|
||||||
|
source: string
|
||||||
|
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
|
||||||
|
sourceUrl: string
|
||||||
|
publishedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
|
||||||
|
export interface ResearchSourceResult {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
ok: boolean
|
||||||
|
/** Gelesene Einträge; 0, wenn die Quelle nicht erreichbar war. */
|
||||||
|
itemCount: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort des Refresh-Endpoints. */
|
||||||
|
export interface ResearchRefreshResult {
|
||||||
|
sources: ResearchSourceResult[]
|
||||||
|
/** Wie viele Einträge insgesamt gelesen wurden. */
|
||||||
|
analyzed: number
|
||||||
|
/** Davon als nicht relevant verworfen — der Kern des PoC (§14). */
|
||||||
|
discarded: number
|
||||||
|
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||||
|
watchlist: number
|
||||||
|
/** Nur `high` und `medium` erscheinen hier. */
|
||||||
|
leads: ResearchLead[]
|
||||||
|
/** Zeitpunkt des Laufs, ISO. */
|
||||||
|
refreshedAt: string
|
||||||
|
/**
|
||||||
|
* Welches Modell die Einordnung vorgenommen hat.
|
||||||
|
*
|
||||||
|
* Wird angezeigt, weil die Modellwahl über eine Umgebungsvariable läuft und
|
||||||
|
* man sonst nirgends in der Anwendung sähe, welche gerade gilt — eine
|
||||||
|
* Einstellung, die man nur im Hosting-Dashboard prüfen kann, prüft niemand.
|
||||||
|
*/
|
||||||
|
model: string
|
||||||
|
/**
|
||||||
|
* Gesetzt, wenn die Analyse nicht laufen konnte — etwa weil serverseitig
|
||||||
|
* kein Schlüssel hinterlegt ist. Die Oberfläche sagt das dann offen, statt
|
||||||
|
* eine leere Liste als «nichts gefunden» auszugeben.
|
||||||
|
*/
|
||||||
|
analysisError?: string
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MarktHinweis } from './marktHinweis'
|
import type { MarktHinweis } from './marktHinweis'
|
||||||
import type { CrmLead } from './crmLead'
|
import type { CrmLead } from './crmLead'
|
||||||
import type { FutureSignal } from './futureSignal'
|
import type { FutureSignal, LeadSignalType } from './futureSignal'
|
||||||
import type { Property } from './property'
|
import type { Property } from './property'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +45,13 @@ interface UnifiedLeadBase {
|
|||||||
meta: string
|
meta: string
|
||||||
/** ISO-Zeitstempel; einzige gemeinsame Grundlage für die Sortierung. */
|
/** ISO-Zeitstempel; einzige gemeinsame Grundlage für die Sortierung. */
|
||||||
receivedAt: string
|
receivedAt: string
|
||||||
|
/**
|
||||||
|
* Chance oder Risiko (Runde 8, §2). Nur Recherche-Leads von Livia tragen den
|
||||||
|
* Wert — ein Netzwerk-Hinweis oder ein CRM-Lead ist weder das eine noch das
|
||||||
|
* andere, und ihn einzufärben hiesse, eine Einordnung zu behaupten, die
|
||||||
|
* niemand vorgenommen hat.
|
||||||
|
*/
|
||||||
|
leadSignalType?: LeadSignalType
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UnifiedLead =
|
export type UnifiedLead =
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useAgentWorkItems } from './useAgentWorkItems'
|
||||||
|
import { useTeamAgents } from './useTeamAgents'
|
||||||
|
import { answerAgentQuestion } from '../services/agentChatService'
|
||||||
|
import type { AgentChatSubject } from '../stores/agentChatStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindet den Chat mit den Beständen, die auf der Agentenseite ohnehin
|
||||||
|
* angezeigt werden (Runde 9, §3).
|
||||||
|
*
|
||||||
|
* Der Hook holt nur die Daten; was daraus wird, entscheidet
|
||||||
|
* `agentChatService` — Zählweise, Zeitraum und Formulierungen sind fachliche
|
||||||
|
* Festlegungen und gehören nicht in die Oberflächenschicht (CLAUDE.md §4.2).
|
||||||
|
*
|
||||||
|
* Bewusst dieselbe Quelle wie der Bearbeitungsverlauf: Was der Agent im Chat
|
||||||
|
* sagt, muss sich im Verlauf nachzählen lassen. Zwei getrennte Zählungen wären
|
||||||
|
* zwei Wahrheiten.
|
||||||
|
*/
|
||||||
|
export function useAgentChatAnswer() {
|
||||||
|
const { data: workItems = [], isLoading: itemsLoading } = useAgentWorkItems()
|
||||||
|
const { data: agents = [], isLoading: agentsLoading } = useTeamAgents()
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(agent: AgentChatSubject, frage: string): string => {
|
||||||
|
// Thomas ist Vorgesetzter und hat kein eigenes Personalblatt; für ihn
|
||||||
|
// zählt der gesamte Bestand, für alle anderen nur der eigene.
|
||||||
|
const eigene = agent.id === 'thomas'
|
||||||
|
? workItems
|
||||||
|
: workItems.filter(w => w.agentId === agent.id)
|
||||||
|
|
||||||
|
return answerAgentQuestion(
|
||||||
|
{
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
agentRole: agent.role,
|
||||||
|
dossier: agents.find(a => a.id === agent.id),
|
||||||
|
workItems: eigene,
|
||||||
|
loading: itemsLoading || agentsLoading,
|
||||||
|
},
|
||||||
|
frage,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[workItems, agents, itemsLoading, agentsLoading],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useReminders } from './useReminders'
|
import { useReminders } from './useReminders'
|
||||||
import { useVisitAssignments } from './useVisitAssignments'
|
import { useVisitAssignments } from './useVisitAssignments'
|
||||||
import { useExposeLeads } from './useExposeLeads'
|
|
||||||
import { useUnifiedLeads } from './useUnifiedLeads'
|
import { useUnifiedLeads } from './useUnifiedLeads'
|
||||||
import { useProperties } from './useProperties'
|
import { useProperties } from './useProperties'
|
||||||
import { computeAgentWorkload } from '../services/agentWorkloadService'
|
import { computeAgentWorkload } from '../services/agentWorkloadService'
|
||||||
@@ -18,19 +17,18 @@ import type { AgentWorkload } from '../domain/agentWorkload'
|
|||||||
export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoading: boolean } {
|
export function useAgentWorkload(): { data: Record<string, AgentWorkload>; isLoading: boolean } {
|
||||||
const { data: reminders = [], isLoading: remindersLoading } = useReminders()
|
const { data: reminders = [], isLoading: remindersLoading } = useReminders()
|
||||||
const { data: visits = [], isLoading: visitsLoading } = useVisitAssignments()
|
const { data: visits = [], isLoading: visitsLoading } = useVisitAssignments()
|
||||||
const { data: exposeLeads = [], isLoading: exposeLoading } = useExposeLeads()
|
|
||||||
const { data: leads, isLoading: leadsLoading } = useUnifiedLeads()
|
const { data: leads, isLoading: leadsLoading } = useUnifiedLeads()
|
||||||
const { data: properties = [], isLoading: propertiesLoading } = useProperties()
|
const { data: properties = [], isLoading: propertiesLoading } = useProperties()
|
||||||
|
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => computeAgentWorkload({ reminders, visits, exposeLeads, leads, properties }),
|
() => computeAgentWorkload({ reminders, visits, leads, properties }),
|
||||||
[reminders, visits, exposeLeads, leads, properties],
|
[reminders, visits, leads, properties],
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
isLoading:
|
isLoading:
|
||||||
remindersLoading || visitsLoading || exposeLoading ||
|
remindersLoading || visitsLoading ||
|
||||||
leadsLoading || propertiesLoading,
|
leadsLoading || propertiesLoading,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import type { ResearchRefreshResult } from '../domain/researchLead'
|
||||||
|
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
|
||||||
|
import { useToastStore } from '../stores/toastStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Research aktualisieren» — der eine Aufruf des PoC.
|
||||||
|
*
|
||||||
|
* Eine Mutation und keine Query: der Lauf ist eine Handlung des Nutzers, kein
|
||||||
|
* Zustand, der sich von selbst aktualisiert. Deshalb auch bewusst kein
|
||||||
|
* `staleTime` und keine Zwischenspeicherung — wer auf den Knopf drückt, will
|
||||||
|
* den Stand von jetzt und nicht den von vorhin.
|
||||||
|
*
|
||||||
|
* Das Ergebnis lebt im Zustand der Komponente. Für den Machbarkeitsnachweis
|
||||||
|
* ist keine Persistenz nötig (§17).
|
||||||
|
*/
|
||||||
|
export function useLiviaResearch() {
|
||||||
|
const showToast = useToastStore(s => s.showToast)
|
||||||
|
|
||||||
|
return useMutation<ResearchRefreshResult, Error>({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, { method: 'POST' })
|
||||||
|
|
||||||
|
// Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
|
||||||
|
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall
|
||||||
|
// in der lokalen Entwicklung und verdient eine klare Meldung statt eines
|
||||||
|
// «Unexpected token <».
|
||||||
|
const typ = res.headers.get('content-type') ?? ''
|
||||||
|
if (!typ.includes('application/json')) {
|
||||||
|
throw new Error(
|
||||||
|
'Der Recherche-Endpoint antwortet nicht. Lokal braucht es dafür «vercel dev» statt «vite».',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`Der Recherche-Lauf schlug fehl (HTTP ${res.status}).`)
|
||||||
|
|
||||||
|
return await res.json() as ResearchRefreshResult
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
showToast(err.message, 'error')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useFutureSignals } from './useFutureSignals'
|
import { useFutureSignals } from './useFutureSignals'
|
||||||
import { useProperties } from './useProperties'
|
import { useProperties } from './useProperties'
|
||||||
|
import { isRisiko } from '../lib/leadSignal'
|
||||||
import type { FutureSignal } from '../domain/futureSignal'
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
import type { Property } from '../domain/property'
|
import type { Property } from '../domain/property'
|
||||||
|
|
||||||
@@ -9,14 +10,29 @@ export interface MarketLead {
|
|||||||
matchingProperties: Property[]
|
matchingProperties: Property[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die von Livia recherchierten Leads, angereichert um passende Portfolioobjekte.
|
||||||
|
*
|
||||||
|
* Seit Runde 8 (§1) ist die Recherche Livias Arbeit und das Matching Noras.
|
||||||
|
* Beide lesen denselben Signalbestand — es gibt bewusst keine zweite
|
||||||
|
* Lead-Ablage, sonst müsste jede Weitergabe kopieren und jede Kopie altern.
|
||||||
|
* Livia zeigt aus diesem Hook nur das Signal, Nora zusätzlich `matchingProperties`.
|
||||||
|
*
|
||||||
|
* Aufgenommen werden Nachfragesignale (Chance) sowie ausdrücklich als Risiko
|
||||||
|
* eingestufte Signale (§2). Ein Risiko betrifft ein bestehendes Mietverhältnis
|
||||||
|
* und zeigt deshalb in die Gegenrichtung — es aus der Liste zu lassen, hiesse
|
||||||
|
* genau die Fälle zu verlieren, für die Frühwarnung überhaupt gedacht ist.
|
||||||
|
*/
|
||||||
export function useMarketLeads(): { data: MarketLead[]; isLoading: boolean } {
|
export function useMarketLeads(): { data: MarketLead[]; isLoading: boolean } {
|
||||||
const { data: signals = [], isLoading: signalsLoading } = useFutureSignals()
|
const { data: signals = [], isLoading: signalsLoading } = useFutureSignals()
|
||||||
const { data: properties = [], isLoading: propertiesLoading } = useProperties()
|
const { data: properties = [], isLoading: propertiesLoading } = useProperties()
|
||||||
|
|
||||||
const data = useMemo((): MarketLead[] => {
|
const data = useMemo((): MarketLead[] => {
|
||||||
const demandSignals = signals.filter(s => s.signalDirection === 'DEMAND')
|
const leadSignals = signals.filter(
|
||||||
|
s => s.signalDirection === 'DEMAND' || (s.leadSignalType != null && isRisiko(s)),
|
||||||
|
)
|
||||||
|
|
||||||
return demandSignals.map(signal => {
|
return leadSignals.map(signal => {
|
||||||
const signalCity = signal.locationHint.split(',')[0].trim().toLowerCase()
|
const signalCity = signal.locationHint.split(',')[0].trim().toLowerCase()
|
||||||
|
|
||||||
const matchingProperties = properties.filter(p => {
|
const matchingProperties = properties.filter(p => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMarketLeads } from './useMarketLeads'
|
|||||||
import { useMarktHinweise } from './useMarktHinweise'
|
import { useMarktHinweise } from './useMarktHinweise'
|
||||||
import { useCrmLeads } from './useCrmLeads'
|
import { useCrmLeads } from './useCrmLeads'
|
||||||
import { LeadChannel } from '../domain/unifiedLead'
|
import { LeadChannel } from '../domain/unifiedLead'
|
||||||
|
import { leadSignalTypeOf } from '../lib/leadSignal'
|
||||||
import type { UnifiedLead } from '../domain/unifiedLead'
|
import type { UnifiedLead } from '../domain/unifiedLead'
|
||||||
import type { MarktHinweis } from '../domain/marktHinweis'
|
import type { MarktHinweis } from '../domain/marktHinweis'
|
||||||
import type { CrmLead } from '../domain/crmLead'
|
import type { CrmLead } from '../domain/crmLead'
|
||||||
@@ -36,9 +37,10 @@ function fromMarketLead(lead: MarketLead): UnifiedLead {
|
|||||||
id: signal.id,
|
id: signal.id,
|
||||||
channel: LeadChannel.KI_SIGNAL,
|
channel: LeadChannel.KI_SIGNAL,
|
||||||
title: signal.companyName ?? signal.locationHint,
|
title: signal.companyName ?? signal.locationHint,
|
||||||
subtitle: signal.title ?? 'Erkanntes Nachfragesignal',
|
subtitle: signal.title ?? 'Erkanntes Marktsignal',
|
||||||
meta: `${signal.locationHint} · Horizont ${signal.timeHorizonMonths} Mo.`,
|
meta: `${signal.locationHint} · Horizont ${signal.timeHorizonMonths} Mo.`,
|
||||||
receivedAt: signal.updatedAt || signal.createdAt,
|
receivedAt: signal.updatedAt || signal.createdAt,
|
||||||
|
leadSignalType: leadSignalTypeOf(signal),
|
||||||
signal,
|
signal,
|
||||||
matchingProperties: lead.matchingProperties,
|
matchingProperties: lead.matchingProperties,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface AgentWorkspace {
|
|||||||
export const AGENT_WORKSPACES: AgentWorkspace[] = [
|
export const AGENT_WORKSPACES: AgentWorkspace[] = [
|
||||||
{ id: 'ferdi', name: 'Ferdi', role: 'Fristen-Wächter', path: ROUTES.SUPPLY.AGENT_FERDI },
|
{ id: 'ferdi', name: 'Ferdi', role: 'Fristen-Wächter', path: ROUTES.SUPPLY.AGENT_FERDI },
|
||||||
{ id: 'bruno', name: 'Bruno', role: 'Besichtigungsassistent', path: ROUTES.SUPPLY.AGENT_BRUNO },
|
{ id: 'bruno', name: 'Bruno', role: 'Besichtigungsassistent', path: ROUTES.SUPPLY.AGENT_BRUNO },
|
||||||
{ id: 'livia', name: 'Livia', role: 'Exposé Master', path: ROUTES.SUPPLY.AGENT_LIVIA },
|
{ id: 'livia', name: 'Livia', role: 'Research & Market Intelligence', path: ROUTES.SUPPLY.AGENT_LIVIA },
|
||||||
{ id: 'nora', name: 'Nora', role: 'Marktchancen / Leads', path: ROUTES.SUPPLY.AGENT_NORA },
|
{ id: 'nora', name: 'Nora', role: 'Marktchancen / Leads', path: ROUTES.SUPPLY.AGENT_NORA },
|
||||||
{ id: 'sina', name: 'Sina', role: 'Datenpflege', path: ROUTES.SUPPLY.AGENT_SINA },
|
{ id: 'sina', name: 'Sina', role: 'Datenpflege', path: ROUTES.SUPPLY.AGENT_SINA },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export const STALE_AGENT_WORK_ITEMS = 15 * 1000 // aggressive — Freigaben änd
|
|||||||
export const STALE_AGENT_PROTOCOL = 15 * 1000 // aggressive — jede Aktion schreibt einen Eintrag
|
export const STALE_AGENT_PROTOCOL = 15 * 1000 // aggressive — jede Aktion schreibt einen Eintrag
|
||||||
export const STALE_AGENT_CONNECTIONS = 60 * 1000
|
export const STALE_AGENT_CONNECTIONS = 60 * 1000
|
||||||
export const STALE_CALENDAR = 60 * 1000
|
export const STALE_CALENDAR = 60 * 1000
|
||||||
export const STALE_EXPOSE_LEADS = 30 * 1000 // aggressive — Weiterleitungen sollen sofort erscheinen
|
|
||||||
export const STALE_VISIT_ASSIGNMENTS = 60 * 1000
|
export const STALE_VISIT_ASSIGNMENTS = 60 * 1000
|
||||||
/** CRM-Leads ändern sich im Quellsystem, nicht hier — eine Minute genügt. */
|
/** CRM-Leads ändern sich im Quellsystem, nicht hier — eine Minute genügt. */
|
||||||
export const STALE_CRM_LEADS = 60 * 1000
|
export const STALE_CRM_LEADS = 60 * 1000
|
||||||
@@ -225,6 +224,30 @@ export const REMINDER_TYPE_LABELS: Record<string, string> = {
|
|||||||
MAINTENANCE: 'Unterhalt',
|
MAINTENANCE: 'Unterhalt',
|
||||||
SCHATTENMARKT_RELEASE: 'Pre-Market',
|
SCHATTENMARKT_RELEASE: 'Pre-Market',
|
||||||
CUSTOM: 'Individuell',
|
CUSTOM: 'Individuell',
|
||||||
|
// Contract Intelligence (Runde 8, §4)
|
||||||
|
NOTICE_PERIOD: 'Kündigungsfrist',
|
||||||
|
REAL_OPTION: 'Echte Option',
|
||||||
|
UNREAL_OPTION: 'Unechte Option',
|
||||||
|
NEGOTIATION_WINDOW: 'Verhandlungsfenster',
|
||||||
|
CONTRACT_TALK: 'Vertragsgespräch',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was der jeweilige Termintyp verlangt — eine Zeile, die den Unterschied
|
||||||
|
* zwischen echter und unechter Option auch dem erklärt, der ihn nicht kennt
|
||||||
|
* (Runde 8, §4).
|
||||||
|
*/
|
||||||
|
export const REMINDER_TYPE_HINTS: Record<string, string> = {
|
||||||
|
NOTICE_PERIOD:
|
||||||
|
'Letzter Zeitpunkt für eine fristgerechte Kündigung. Danach läuft der Vertrag weiter.',
|
||||||
|
REAL_OPTION:
|
||||||
|
'Einseitiges Gestaltungsrecht des Mieters mit verbindlicher Ausübungsfrist. Wird die Frist versäumt, erlischt das Recht.',
|
||||||
|
UNREAL_OPTION:
|
||||||
|
'Kein Gestaltungsrecht, sondern die Abrede, rechtzeitig über eine Fortsetzung zu verhandeln. Massgeblich ist das Verhandlungsfenster, nicht eine Frist.',
|
||||||
|
NEGOTIATION_WINDOW:
|
||||||
|
'Zeitfenster, in dem Vermieter und Mieter über die Fortsetzung sprechen sollten.',
|
||||||
|
CONTRACT_TALK:
|
||||||
|
'Vereinbartes oder anstehendes Gespräch zum Vertrag.',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reminder status display labels
|
// Reminder status display labels
|
||||||
@@ -270,6 +293,16 @@ export const DATA_QUALITY_LABELS: Record<string, string> = {
|
|||||||
/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */
|
/** Hauptmenüpunkt und Titel der Agenten-Hauptseite. */
|
||||||
export const MY_AGENTS_LABEL = 'Meine Agenten'
|
export const MY_AGENTS_LABEL = 'Meine Agenten'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thomas' Begrüssung über den drei Verwaltungsbereichen (Runde 8, §3).
|
||||||
|
*
|
||||||
|
* Ein Satz in Alltagssprache statt einer Funktionsbeschreibung: Thomas ist der
|
||||||
|
* Vorgesetzte der fünf, und die Seite soll sich anfühlen wie der Gang zu ihm
|
||||||
|
* und nicht wie eine Systemverwaltung.
|
||||||
|
*/
|
||||||
|
export const THOMAS_INTRO =
|
||||||
|
'Ich führe die fünf digitalen Mitarbeitenden. Hier sehen Sie, wer was macht, was zuletzt erledigt wurde und an welchen Systemen die Agenten hängen.'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runde 5, §2.2: Der Menüeintrag heisst «Startseite», die Objektverwaltung
|
* Runde 5, §2.2: Der Menüeintrag heisst «Startseite», die Objektverwaltung
|
||||||
* darunter heisst «Meine Objekte». Beide Bezeichnungen zeigen auf dieselbe
|
* darunter heisst «Meine Objekte». Beide Bezeichnungen zeigen auf dieselbe
|
||||||
@@ -316,43 +349,43 @@ export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?'
|
|||||||
*/
|
*/
|
||||||
export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?'
|
export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?'
|
||||||
|
|
||||||
// ── Livia — Leads und Exposé (Runde 4, §8) ───────────────────────────────────
|
/**
|
||||||
|
* Einheitlicher KI-Hinweis in allen Agenten-Detailansichten (Runde 9, §5).
|
||||||
|
*
|
||||||
|
* Der Wortlaut ist vorgegeben und steht deshalb genau einmal hier. Sechsmal
|
||||||
|
* abgetippt hiesse er nach der ersten Korrektur an fünf Stellen anders — und
|
||||||
|
* ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner.
|
||||||
|
*/
|
||||||
|
export const AI_DISCLAIMER =
|
||||||
|
'KI kann Fehler machen, bitte überprüfen Sie sämtliche Angaben'
|
||||||
|
|
||||||
export const EXPOSE_LEAD_TABS = {
|
// ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ──────────────────
|
||||||
ACTIVE: 'aktive-leads',
|
|
||||||
ARCHIVED: 'archivierte-leads',
|
|
||||||
} as const
|
|
||||||
export type ExposeLeadTab = typeof EXPOSE_LEAD_TABS[keyof typeof EXPOSE_LEAD_TABS]
|
|
||||||
|
|
||||||
export const EXPOSE_LEAD_TAB_LABELS: Record<ExposeLeadTab, string> = {
|
export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||||
[EXPOSE_LEAD_TABS.ACTIVE]: 'Aktive Leads',
|
CHANCE: 'Chance',
|
||||||
[EXPOSE_LEAD_TABS.ARCHIVED]: 'Archivierte Leads',
|
RISIKO: 'Risiko',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die drei Schritte des Exposé-Prozesses (§8.5). */
|
// ── Livia — Recherche aus realen Quellen (PoC, §11/§13) ──────────────────────
|
||||||
export const EXPOSE_STEPS = ['Hochladen', 'Exposé', 'Export'] as const
|
|
||||||
|
|
||||||
export const EXPOSE_IMAGE_CATEGORY_LABELS: Record<string, string> = {
|
export const RESEARCH_EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
EXTERIOR: 'Aussenansicht',
|
EXPANSION: 'Expansion',
|
||||||
INTERIOR: 'Innenansicht',
|
NEW_LOCATION: 'Neuer Standort',
|
||||||
FLOORPLAN: 'Grundriss',
|
HEADCOUNT_GROWTH: 'Personalwachstum',
|
||||||
SURROUNDINGS: 'Umgebung',
|
INVESTMENT: 'Investition',
|
||||||
OTHER: 'Sonstiges',
|
M_AND_A: 'M&A',
|
||||||
|
RESTRUCTURING: 'Restrukturierung',
|
||||||
|
SITE_CLOSURE: 'Standortschliessung',
|
||||||
|
NEW_COMPANY: 'Neugründung',
|
||||||
|
IRRELEVANT: 'Nicht relevant',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EXPOSE_DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
export const RESEARCH_RELEVANCE_LABELS: Record<string, string> = {
|
||||||
FLOORPLAN: 'Grundriss',
|
high: 'hohe Relevanz',
|
||||||
LEASE_CONTRACT: 'Mietvertrag',
|
medium: 'mittlere Relevanz',
|
||||||
ENERGY_CERTIFICATE: 'Energieausweis (GEAK)',
|
low: 'geringe Relevanz',
|
||||||
SITE_PLAN: 'Situationsplan',
|
|
||||||
OTHER: 'Sonstiges',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EXPOSE_TONALITY_LABELS: Record<string, string> = {
|
|
||||||
SACHLICH: 'Sachlich',
|
|
||||||
HOCHWERTIG: 'Hochwertig',
|
|
||||||
EINLADEND: 'Einladend',
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
|
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ export const DS_COLORS = {
|
|||||||
LOW: { bg: 'rgba(185,28,28,0.10)', fg: '#b91c1c' },
|
LOW: { bg: 'rgba(185,28,28,0.10)', fg: '#b91c1c' },
|
||||||
INCOMPLETE: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
|
INCOMPLETE: { bg: 'rgba(127,0,0,0.12)', fg: '#7f1d1d' },
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Chance und Risiko eines Recherche-Leads (Runde 8, §2). Bewusst dieselben
|
||||||
|
* Grün- und Rottöne wie bei Konfidenz und Risiko — im Bestand bedeutet Grün
|
||||||
|
* schon «gut für uns», ein dritter Farbcode für dieselbe Aussage wäre eine
|
||||||
|
* neue Vokabel ohne neuen Inhalt.
|
||||||
|
*/
|
||||||
|
leadSignal: {
|
||||||
|
CHANCE: { bg: 'rgba(29,99,66,0.10)', fg: '#1d6342' },
|
||||||
|
RISIKO: { bg: 'rgba(185,28,28,0.10)', fg: '#b91c1c' },
|
||||||
|
},
|
||||||
heat: {
|
heat: {
|
||||||
VERY_HOT: { bg: '#fef3c7', border: '#fcd34d', icon: '#a16207', text: '#92400e' },
|
VERY_HOT: { bg: '#fef3c7', border: '#fcd34d', icon: '#a16207', text: '#92400e' },
|
||||||
HOT: { bg: '#fff7ed', border: '#fed7aa', icon: '#a16207', text: '#92400e' },
|
HOT: { bg: '#fff7ed', border: '#fed7aa', icon: '#a16207', text: '#92400e' },
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Property On — Chance/Risiko-Einordnung eines Recherche-Leads (Runde 8, §2).
|
||||||
|
*
|
||||||
|
* Die Einordnung steht am Signal selbst (`leadSignalType`). Für Bestände, die
|
||||||
|
* vor Runde 8 erfasst wurden, leitet `leadSignalTypeOf()` sie aus dem
|
||||||
|
* Signaltyp ab — so bleibt die Liste vollständig, ohne dass jeder Altbestand
|
||||||
|
* migriert werden muss.
|
||||||
|
*
|
||||||
|
* Die Ableitung folgt einer einzigen Frage: entsteht Flächenbedarf (Chance)
|
||||||
|
* oder droht Flächenfreisetzung an einem bestehenden Mietverhältnis (Risiko)?
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SignalType } from '../domain/enums'
|
||||||
|
import { LeadSignalType } from '../domain/futureSignal'
|
||||||
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
|
|
||||||
|
const DERIVED: Record<SignalType, LeadSignalType> = {
|
||||||
|
[SignalType.EXPANSION]: LeadSignalType.CHANCE,
|
||||||
|
[SignalType.PROJECT_DEVELOPMENT]: LeadSignalType.CHANCE,
|
||||||
|
[SignalType.CONSTRUCTION_PROJECT]: LeadSignalType.CHANCE,
|
||||||
|
[SignalType.RESTRUCTURING]: LeadSignalType.RISIKO,
|
||||||
|
[SignalType.POSSIBLE_MOVE_OUT]: LeadSignalType.RISIKO,
|
||||||
|
[SignalType.SPACE_CONSOLIDATION]: LeadSignalType.RISIKO,
|
||||||
|
[SignalType.LEASE_EXPIRY]: LeadSignalType.RISIKO,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erfasster Wert schlägt Ableitung — die Redaktion weiss mehr als die Regel. */
|
||||||
|
export function leadSignalTypeOf(signal: FutureSignal): LeadSignalType {
|
||||||
|
return signal.leadSignalType ?? DERIVED[signal.signalType] ?? LeadSignalType.CHANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRisiko(signal: FutureSignal): boolean {
|
||||||
|
return leadSignalTypeOf(signal) === LeadSignalType.RISIKO
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rückfalltexte für Begründung und Handlung (Runde 8, §2).
|
||||||
|
*
|
||||||
|
* Jeder Lead muss beantworten, warum er eine Chance oder ein Risiko ist und was
|
||||||
|
* als Nächstes zu tun ist. Steht am Signal nichts Eigenes, greifen diese Texte —
|
||||||
|
* ein leeres Feld hiesse sonst «keine Handlung nötig», und das ist eine andere
|
||||||
|
* Aussage als «noch nicht ausformuliert».
|
||||||
|
*/
|
||||||
|
export function signalRationaleOf(signal: FutureSignal): string {
|
||||||
|
if (signal.signalRationale) return signal.signalRationale
|
||||||
|
return isRisiko(signal)
|
||||||
|
? 'Negatives externes Signal zu einem Unternehmen, das als Mieter geführt wird oder werden könnte — es deutet auf möglichen Flächenabbau hin.'
|
||||||
|
: 'Positives externes Signal, das auf neuen oder zusätzlichen Flächenbedarf hindeutet und sich mit dem eigenen Bestand abgleichen lässt.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recommendedActionOf(signal: FutureSignal): string {
|
||||||
|
if (signal.recommendedAction) return signal.recommendedAction
|
||||||
|
return isRisiko(signal)
|
||||||
|
? 'Bestehendes Mietverhältnis prüfen und einen möglichen Kündigungs- oder Reduktionsbedarf frühzeitig verifizieren. Die betroffene Fläche für internes Re-Letting vorbereiten, damit Ersatzmieter identifiziert sind, bevor die Kündigung eintrifft.'
|
||||||
|
: 'Passendes internes Objekt identifizieren, Offerte vorbereiten und den potenziellen Nachfrager kontaktieren, solange die Flächensuche noch offen ist.'
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Property On — die real angebundenen Recherchequellen von Livia.
|
||||||
|
*
|
||||||
|
* Eine einzige Liste für beide Seiten: die serverlose Funktion liest daraus
|
||||||
|
* die abzurufenden Adressen, die Oberfläche zeigt daraus «Angebundene Kanäle
|
||||||
|
* & Systeme». Stünden die URLs an zwei Stellen, könnte der angezeigte Link
|
||||||
|
* irgendwann auf etwas anderes zeigen als der tatsächliche Abruf — und genau
|
||||||
|
* die Nachvollziehbarkeit ist der Zweck der Anzeige (§2).
|
||||||
|
*
|
||||||
|
* Die Datei enthält bewusst nur Daten und keine Abruflogik: so kann `api/`
|
||||||
|
* sie importieren, ohne dass der HTML-Parser im Frontend-Bundle landet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ResearchSourceDescriptor {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
/** Wofür die Quelle im PoC steht — erscheint in der Oberfläche. */
|
||||||
|
purpose: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESEARCH_SOURCES = {
|
||||||
|
ZHK: {
|
||||||
|
id: 'zhk',
|
||||||
|
name: 'Zürcher Handelskammer — News',
|
||||||
|
url: 'https://www.zhk.ch/de/wirtschaft-und-politik/news-liste.html',
|
||||||
|
purpose: 'Regionale Unternehmensmeldungen, Expansion, Investitionen, Kapazitätsausbau',
|
||||||
|
},
|
||||||
|
GZA: {
|
||||||
|
id: 'gza',
|
||||||
|
name: 'Greater Zurich Area — News',
|
||||||
|
url: 'https://www.greaterzuricharea.com/de/alle-news',
|
||||||
|
purpose: 'Ansiedlungen, Markteintritte, internationale Expansionen und neue Standorte',
|
||||||
|
},
|
||||||
|
ZEFIX: {
|
||||||
|
id: 'zefix',
|
||||||
|
name: 'Zefix / Open Data Kanton Zürich',
|
||||||
|
url: 'https://www.web.statistik.zh.ch/ogd/data/KANTON_ZUERICH_ZEFIX_Neugruendungen.csv',
|
||||||
|
purpose:
|
||||||
|
'Strukturierte Daten zu Zürcher Neugründungen; CSV wird direkt eingelesen — '
|
||||||
|
+ 'Marktindikator ohne Firmennamen, erscheint deshalb nicht als Lead',
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, ResearchSourceDescriptor>
|
||||||
|
|
||||||
|
export const RESEARCH_SOURCE_LIST: ResearchSourceDescriptor[] = [
|
||||||
|
RESEARCH_SOURCES.ZHK,
|
||||||
|
RESEARCH_SOURCES.GZA,
|
||||||
|
RESEARCH_SOURCES.ZEFIX,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Der eine Endpoint des PoC (§20). */
|
||||||
|
export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh'
|
||||||
@@ -42,7 +42,7 @@ export const agentDirectory: AgentDirectoryEntry[] = [
|
|||||||
{
|
{
|
||||||
id: 'livia',
|
id: 'livia',
|
||||||
name: 'Livia',
|
name: 'Livia',
|
||||||
role: 'Exposé Master',
|
role: 'Research & Market Intelligence',
|
||||||
email: 'livia@property-on.ch',
|
email: 'livia@property-on.ch',
|
||||||
level: AgentLevel.CORE,
|
level: AgentLevel.CORE,
|
||||||
department: AgentDepartment.CENTRAL_SERVICES,
|
department: AgentDepartment.CENTRAL_SERVICES,
|
||||||
|
|||||||
@@ -1227,130 +1227,118 @@ Bruno — Recap-Agent`,
|
|||||||
{
|
{
|
||||||
id: 'awi-livia-01',
|
id: 'awi-livia-01',
|
||||||
agentId: AGENT_IDS.LIVIA,
|
agentId: AGENT_IDS.LIVIA,
|
||||||
kind: AgentWorkItemKind.LOCATION_MODULE,
|
kind: AgentWorkItemKind.MARKET_SIGNAL,
|
||||||
area: AgentDomainArea.LOCATION,
|
area: AgentDomainArea.MARKET,
|
||||||
title: 'Lagebeschrieb erstellt — Hardturmstrasse 118, Zürich Kreis 5',
|
title: 'Lead erzeugt — Alpenbäckerei Truttmann AG, Filialexpansion Zürich',
|
||||||
summary:
|
summary:
|
||||||
'Zum neuen Vermarktungsauftrag entstanden ein Kurztext für Inserat und Portal sowie ein Langtext für das Exposé, jeweils mit Quellenverzeichnis und Abrufdatum zu jeder Aussage.',
|
'Aus dem Geschäftsbericht 2025, einem Medienbericht und zwei Stelleninseraten entstand ein Lead mit Suchprofil, Zeitfenster und Quellenverzeichnis. Eingeordnet als Chance und an die Lead-Liste übergeben.',
|
||||||
objectId: 'OBJ-2365',
|
objectLabel: 'Kein eigenes Objekt betroffen — externe Nachfrage',
|
||||||
objectLabel: 'Hardturmstrasse 118, 8005 Zürich — Bürofläche 640 m², Zürich Kreis 5',
|
|
||||||
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
|
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
targetChannel: AgentChannelType.EMAIL,
|
targetChannel: AgentChannelType.EMAIL,
|
||||||
createdAt: '2026-04-09T09:15:00.000Z',
|
createdAt: '2026-05-04T06:10:00.000Z',
|
||||||
completedAt: '2026-04-09T11:40:00.000Z',
|
completedAt: '2026-05-04T07:35:00.000Z',
|
||||||
priority: AgentWorkItemPriority.LOW,
|
priority: AgentWorkItemPriority.LOW,
|
||||||
status: AgentWorkItemStatus.COMPLETED,
|
status: AgentWorkItemStatus.COMPLETED,
|
||||||
requiresDecision: false,
|
requiresDecision: false,
|
||||||
inputs: [
|
inputs: [
|
||||||
{ label: 'Objektadresse aus dem Vermarktungsauftrag', value: 'Hardturmstrasse 118, 8005 Zürich' },
|
{ label: 'Auslösende Quelle', value: 'Geschäftsbericht 2025 der Alpenbäckerei Truttmann AG, publiziert 28.04.2026' },
|
||||||
{ label: 'Quartierzuordnung', value: 'Zürich Kreis 5 (Escher Wyss), geprüft gegen die amtlichen Adressdaten' },
|
{ label: 'Beobachtungsraum', value: 'Wirtschaftsraum Zürich' },
|
||||||
{ label: 'Untersuchungsradius Mikrolage', value: '500 Meter Umkreis, Erdgeschossnutzungen und Erreichbarkeit' },
|
{ label: 'Beobachtete Veränderung', value: 'Retail-/Filialexpansion' },
|
||||||
{ label: 'Auftraggebende Person', value: 'Nadine Brunner, Vermarktung Zürich' },
|
{ label: 'Mindestrelevanz', value: '60 % — erreichter Wert 78 %' },
|
||||||
],
|
],
|
||||||
result: [
|
result: [
|
||||||
{
|
{
|
||||||
label: 'Kurztext für Inserat und Portal',
|
label: 'Welches Unternehmen',
|
||||||
|
value: 'Alpenbäckerei Truttmann AG, Bäckerei- und Gastronomiebetrieb mit 34 Filialen in der Deutschschweiz.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Was verändert sich',
|
||||||
value:
|
value:
|
||||||
'Die Fläche liegt im Kreis 5 zwischen Escher-Wyss-Platz und Hardbrücke, in einem Quartier mit hohem Anteil an Dienstleistung, Gastronomie und Kreativwirtschaft. Die Haltestelle Escher-Wyss-Platz ist in vier Gehminuten erreichbar, der Bahnhof Hardbrücke in acht Minuten.',
|
'Zwölf geplante Filialeröffnungen bis Ende 2027, davon drei im Raum Zürich. Im Bericht genanntes Suchprofil: Erdgeschosslagen mit hoher Fussgängerfrequenz, 150–220 m².',
|
||||||
},
|
},
|
||||||
|
{ label: 'Wo und wann', value: 'Zürich Kreis 4 und Kreis 5, Zeithorizont 14 Monate.' },
|
||||||
{
|
{
|
||||||
label: 'Langtext für das Exposé',
|
label: 'Warum daraus Flächenbedarf entstehen könnte',
|
||||||
value:
|
value:
|
||||||
'Dreiteiliger Text mit Mikrolage (Nachbarschaft, Erdgeschossnutzungen, Publikumsfrequenz, Parkierung), Makrolage (Wirtschaftsstruktur Stadt Zürich, Beschäftigtenentwicklung Kreis 5, Steuerlage) und Umfeldentwicklung — insgesamt 3 940 Zeichen.',
|
'Das Unternehmen hat den Bedarf selbst publiziert und das Suchprofil mitgeliefert. Der CFO hat am 30.04. bestätigt, dass die Standortsuche begonnen hat; zwei Standortleiterstellen für Zürich sind ausgeschrieben.',
|
||||||
},
|
},
|
||||||
|
{ label: 'Einordnung', value: 'Chance — Relevanz 78 %' },
|
||||||
{
|
{
|
||||||
label: 'Erreichbarkeit',
|
label: 'Empfohlene nächste Handlung',
|
||||||
value: [
|
|
||||||
'Tram- und Bushaltestelle Escher-Wyss-Platz: 300 Meter, 4 Gehminuten',
|
|
||||||
'Bahnhof Zürich Hardbrücke: 650 Meter, 8 Gehminuten',
|
|
||||||
'Autobahnanschluss Zürich-Hardturm: 1,4 Kilometer',
|
|
||||||
'Veloweg entlang der Hardturmstrasse, durchgehend abgetrennt',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Nahversorgung',
|
|
||||||
value: [
|
|
||||||
'Einkaufen: zwei Supermärkte innerhalb von 400 Metern',
|
|
||||||
'Verpflegung: 14 Gastronomiebetriebe im Umkreis von 300 Metern',
|
|
||||||
'Kinderbetreuung: zwei Kindertagesstätten innerhalb von 600 Metern',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Umfeldentwicklung',
|
|
||||||
value:
|
value:
|
||||||
'Arealentwicklung Hardturm-Areal in Etappen bis 2029; Zonenänderung rechtskräftig seit 2024 (Quelle: amtliche Publikation, abgerufen 09.04.2026).',
|
'Erdgeschossflächen zwischen 150 und 220 m² in Kreis 4 und 5 zusammenstellen und dem CFO vorlegen, bevor die Suche über einen Makler ausgeschrieben wird.',
|
||||||
},
|
},
|
||||||
{ label: 'Quellenverzeichnis', value: '11 Quellen mit Abrufdatum, je Aussage zugeordnet' },
|
{ label: 'Quellenverzeichnis', value: '3 Quellen mit Abrufdatum, je Aussage zugeordnet' },
|
||||||
],
|
],
|
||||||
processingSteps: [
|
processingSteps: [
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-step-1',
|
id: 'awi-livia-01-step-1',
|
||||||
label: 'Adresse aufgenommen und dem Quartier zugeordnet',
|
label: 'Verknüpfte Quellen abgegangen',
|
||||||
detail: 'Schreibweise gegen die amtlichen Adressdaten geprüft, Zuordnung Zürich Kreis 5.',
|
detail: 'Täglicher Lauf 06:00 über Handelszeitung, NZZ Wirtschaft, Finanz und Wirtschaft sowie SHAB.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-step-2',
|
id: 'awi-livia-01-step-2',
|
||||||
label: 'Mikrolage im Umkreis von 500 Metern abgegangen',
|
label: 'Geschäftsbericht ausgewertet',
|
||||||
detail: 'Nachbarschaft, Erdgeschossnutzungen, Immissionslage, Publikumsfrequenz und Parkierung erfasst.',
|
detail: 'Seite 14 nennt zwölf Filialeröffnungen bis Ende 2027 samt Suchprofil.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-step-3',
|
id: 'awi-livia-01-step-3',
|
||||||
label: 'Makrolage eingeordnet',
|
label: 'Meldung gegengeprüft',
|
||||||
detail: 'Wirtschaftsstruktur, Beschäftigtenentwicklung und Steuerlage der Stadt Zürich ausgewertet.',
|
detail: 'Medienbericht vom 30.04. und zwei Stelleninserate bestätigen die laufende Standortsuche.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-step-4',
|
id: 'awi-livia-01-step-4',
|
||||||
label: 'Amtliche Publikationen zum Umfeld gesichtet',
|
label: 'Als Chance eingeordnet und begründet',
|
||||||
detail: 'Arealentwicklung und Zonenänderung als für das Objekt relevant eingestuft.',
|
detail: 'Publizierter Bedarf mit bekanntem Suchprofil — unmittelbar gegen den Bestand prüfbar.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-step-5',
|
id: 'awi-livia-01-step-5',
|
||||||
label: 'Kurz- und Langtext geschrieben, Quellen angehängt',
|
label: 'Lead übergeben',
|
||||||
detail: 'Je Aussage Quelle und Abrufdatum hinterlegt, Ergebnis am Objekt abgelegt.',
|
detail: 'An die gemeinsame Lead-Liste übergeben; kein zweiter Datensatz angelegt.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sourceReferences: [
|
sourceReferences: [
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-src-1',
|
id: 'awi-livia-01-src-1',
|
||||||
label: 'Amtliche Publikation zur Zonenänderung Hardturm',
|
label: 'Geschäftsbericht 2025 der Alpenbäckerei Truttmann AG',
|
||||||
documentName: 'Amtliche-Publikation_Zonenaenderung-Hardturm_2024-11-08.pdf',
|
documentName: 'Truttmann_Geschaeftsbericht_2025.pdf',
|
||||||
locator: 'Seite 3, abgerufen 09.04.2026',
|
locator: 'Seite 14, abgerufen 04.05.2026',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-src-2',
|
id: 'awi-livia-01-src-2',
|
||||||
label: 'Fahrplanauskunft öffentlicher Verkehr',
|
label: 'Medienbericht zur Expansion',
|
||||||
documentName: 'Erreichbarkeitsauswertung_OBJ-2365_2026-04-09.pdf',
|
documentName: 'Handelszeitung_Truttmann-Expansion_2026-04-30.pdf',
|
||||||
locator: 'Tabelle «Haltestellen im Umkreis», abgerufen 09.04.2026',
|
locator: 'Absatz 3, abgerufen 04.05.2026',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-src-3',
|
id: 'awi-livia-01-src-3',
|
||||||
label: 'Quellenverzeichnis zum Lagebeschrieb',
|
label: 'Stelleninserate Standortleitung Zürich',
|
||||||
documentName: 'Lagebeschrieb_OBJ-2365_Quellen_2026-04-09.pdf',
|
documentName: 'Truttmann_Stelleninserate_Zuerich_2026-04-15.pdf',
|
||||||
locator: 'Seiten 1–2, 11 Einträge',
|
locator: 'zwei Inserate, abgerufen 04.05.2026',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
availableActions: [AgentWorkItemAction.OPEN_SOURCE, AgentWorkItemAction.MARK_DONE],
|
availableActions: [AgentWorkItemAction.OPEN_SOURCE, AgentWorkItemAction.MARK_DONE],
|
||||||
history: [
|
history: [
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-hist-1',
|
id: 'awi-livia-01-hist-1',
|
||||||
at: '2026-04-09T09:15:00.000Z',
|
at: '2026-05-04T06:10:00.000Z',
|
||||||
actor: 'Livia',
|
actor: 'Livia',
|
||||||
action: 'Vermarktungsauftrag übernommen',
|
action: 'Meldung im Quellenlauf erfasst',
|
||||||
note: 'Adresse geprüft und dem Quartier Zürich Kreis 5 zugeordnet.',
|
note: 'Geschäftsbericht 2025 als relevant eingestuft.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-hist-2',
|
id: 'awi-livia-01-hist-2',
|
||||||
at: '2026-04-09T11:40:00.000Z',
|
at: '2026-05-04T07:35:00.000Z',
|
||||||
actor: 'Livia',
|
actor: 'Livia',
|
||||||
action: 'Lagebeschrieb abgelegt und zugestellt',
|
action: 'Lead erzeugt und übergeben',
|
||||||
note: 'Kurz- und Langtext mit 11 Quellen an die Vermarktung Zürich.',
|
note: 'Chance, Relevanz 78 %, drei Quellen mit Abrufdatum.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-01-hist-3',
|
id: 'awi-livia-01-hist-3',
|
||||||
at: '2026-04-14T08:05:00.000Z',
|
at: '2026-05-06T08:15:00.000Z',
|
||||||
actor: 'Nadine Brunner',
|
actor: 'Nadine Brunner',
|
||||||
action: 'Text für das Inserat übernommen',
|
action: 'Lead in Bearbeitung genommen',
|
||||||
note: 'Kurztext unverändert im Portalauftritt veröffentlicht.',
|
note: 'Objektauswahl über Noras Abgleich angefordert.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1359,140 +1347,118 @@ Bruno — Recap-Agent`,
|
|||||||
{
|
{
|
||||||
id: 'awi-livia-02',
|
id: 'awi-livia-02',
|
||||||
agentId: AGENT_IDS.LIVIA,
|
agentId: AGENT_IDS.LIVIA,
|
||||||
kind: AgentWorkItemKind.LOCATION_MODULE,
|
kind: AgentWorkItemKind.MARKET_SIGNAL,
|
||||||
area: AgentDomainArea.LOCATION,
|
area: AgentDomainArea.MARKET,
|
||||||
title: 'Unsichere Aussage zum Entwicklungsgebiet Wankdorf — Review erbeten',
|
title: 'Review nötig — Spedition Rhein GmbH, widersprüchliche Angaben zum Standort Basel',
|
||||||
summary:
|
summary:
|
||||||
'Für den Langtext zum Objekt in Bern liegen zwei Quellen mit unterschiedlichen Angaben zur Inbetriebnahme der neuen Haltestelle vor. Die Aussage bleibt aus dem Text ausgeklammert, bis entschieden ist, welche Angabe gilt.',
|
'Zwei belegte Quellen widersprechen sich, ob der Standort Basel Kleinhüningen ganz oder teilweise aufgegeben wird. Da ein bestehendes Mietverhältnis betroffen ist, wird der Lead nicht ohne Prüfung als gesichertes Risiko geführt.',
|
||||||
objectId: 'OBJ-2733',
|
objectId: 'prop-002',
|
||||||
objectLabel: 'Wankdorffeldstrasse 12, 3014 Bern — Bürofläche 1. bis 3. Obergeschoss, 1 150 m²',
|
objectLabel: 'Lagerfläche Hardstrasse 44, Basel — Mieterin: Spedition Rhein GmbH',
|
||||||
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
|
sourceChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
targetChannel: AgentChannelType.EMAIL,
|
targetChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
createdAt: '2026-05-07T07:45:00.000Z',
|
createdAt: '2026-05-07T06:15:00.000Z',
|
||||||
priority: AgentWorkItemPriority.MEDIUM,
|
priority: AgentWorkItemPriority.HIGH,
|
||||||
status: AgentWorkItemStatus.PENDING,
|
status: AgentWorkItemStatus.PENDING,
|
||||||
requiresDecision: true,
|
requiresDecision: true,
|
||||||
escalationReason:
|
|
||||||
'Review eines unsicheren Ergebnisses: Zwei belegte Quellen nennen unterschiedliche Zeitpunkte für die Inbetriebnahme der Haltestelle Wankdorffeld. Eine Aussage ohne eindeutigen Beleg geht nicht in den Exposétext.',
|
|
||||||
decisionQuestion:
|
decisionQuestion:
|
||||||
'Welche Angabe soll in den Langtext übernommen werden — «ab Dezember 2027» gemäss amtlicher Publikation oder «ab Sommer 2028» gemäss Mitteilung der Standortförderung — oder soll der Absatz zur Haltestelle vorerst entfallen?',
|
'Soll der Lead als Risiko mit vollständiger Flächenfreisetzung geführt werden, oder wird bis zur Rückmeldung des Mieters nur die Teilaufgabe angenommen?',
|
||||||
inputs: [
|
inputs: [
|
||||||
{ label: 'Objektadresse aus dem Vermarktungsauftrag', value: 'Wankdorffeldstrasse 12, 3014 Bern' },
|
{ label: 'Betroffenes Mietverhältnis', value: 'Spedition Rhein GmbH, Lagerfläche Hardstrasse 44, 1 900 m²' },
|
||||||
{ label: 'Quartierzuordnung', value: 'Bern Wankdorf, Entwicklungsgebiet Wankdorf-Nord' },
|
{ label: 'Auslösende Quelle', value: 'Konzernmitteilung vom 06.05.2026 zur Bündelung der Lagerlogistik' },
|
||||||
{
|
{ label: 'Gegenprüfung', value: 'Handelsregister-Mutation vom 11.05.2026, Zweckanpassung ohne eigene Lagerflächen' },
|
||||||
label: 'Widersprüchliche Quellen',
|
|
||||||
value: [
|
|
||||||
'Amtliche Publikation vom 12.03.2026 — Inbetriebnahme Dezember 2027',
|
|
||||||
'Mitteilung der Standortförderung vom 28.04.2026 — Inbetriebnahme Sommer 2028',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: 'Auftraggebende Person', value: 'Nadine Brunner, Vermarktung Zürich (Mandat Bern)' },
|
|
||||||
],
|
],
|
||||||
result: [
|
result: [
|
||||||
{ label: 'Stand', value: 'Kurztext fertig, Langtext zu 90 Prozent — ein Absatz ist ausgeklammert' },
|
|
||||||
{
|
{
|
||||||
label: 'Fertiger Kurztext',
|
label: 'Widerspruch',
|
||||||
value:
|
value: [
|
||||||
'Das Objekt liegt im Entwicklungsgebiet Wankdorf, unmittelbar an der Achse zwischen Bahnhof Wankdorf und Autobahnanschluss Bern-Wankdorf. Das Umfeld ist geprägt von Dienstleistung, öffentlichen Institutionen und Verkaufsflächen.',
|
'Konzernmitteilung: Bündelung auf Pratteln und Kaiseraugst, Basel nicht als Zentralstandort genannt.',
|
||||||
|
'Handelsregister: Zweckanpassung nennt den Betrieb eigener Lagerflächen nicht mehr — lässt aber offen, ob Basel ganz entfällt.',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Ausgeklammerter Absatz',
|
label: 'Was gesichert ist',
|
||||||
value:
|
value: 'Die Bündelung ist publiziert, die Zweckanpassung eingetragen, und die Spedition Rhein GmbH ist Mieterin der Fläche.',
|
||||||
'Die geplante Haltestelle Wankdorffeld liegt rund 200 Meter vom Objekt entfernt. Die Inbetriebnahme ist für [Zeitpunkt offen] vorgesehen.',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Warum die Angabe zählt',
|
label: 'Was offen bleibt',
|
||||||
|
value: 'Ob der Standort Basel ganz oder teilweise aufgegeben wird und ab wann.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Empfohlene nächste Handlung',
|
||||||
value:
|
value:
|
||||||
'Die Wegzeit zum öffentlichen Verkehr sinkt mit der neuen Haltestelle von 11 auf 3 Gehminuten — die Aussage ist für Interessenten mit Publikumsverkehr entscheidend.',
|
'Das Mietverhältnis prüfen — Laufzeit, Kündigungsfrist, Break-Optionen — und das Gespräch mit der Geschäftsleitung suchen, bevor eine Kündigung eintrifft.',
|
||||||
},
|
},
|
||||||
{ label: 'Übrige Quellen', value: '9 Quellen mit Abrufdatum, alle eindeutig und im Text belegt' },
|
|
||||||
],
|
],
|
||||||
processingSteps: [
|
processingSteps: [
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-step-1',
|
id: 'awi-livia-02-step-1',
|
||||||
label: 'Adresse aufgenommen und dem Quartier zugeordnet',
|
label: 'Konzernmitteilung erfasst',
|
||||||
detail: 'Zuordnung Bern Wankdorf, Entwicklungsgebiet Wankdorf-Nord.',
|
detail: 'Im Quellenlauf vom 07.05. als relevant für den Beobachtungsraum Basel eingestuft.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-step-2',
|
id: 'awi-livia-02-step-2',
|
||||||
label: 'Mikro- und Makrolage beschrieben',
|
label: 'Mieterbezug geprüft',
|
||||||
detail: 'Nachbarschaft, Erreichbarkeit, Nahversorgung und Wirtschaftsraum Bern erfasst.',
|
detail: 'Abgleich mit den Stammdaten: Spedition Rhein GmbH ist Mieterin der Hardstrasse 44.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-step-3',
|
id: 'awi-livia-02-step-3',
|
||||||
label: 'Amtliche Publikationen zum Umfeld gesichtet',
|
label: 'Handelsregister gegengeprüft',
|
||||||
detail: 'Beim Verkehrsvorhaben zwei Quellen mit unterschiedlichen Zeitangaben gefunden.',
|
detail: 'Mutation vom 11.05. bestätigt die Richtung, nicht aber den Umfang.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-step-4',
|
id: 'awi-livia-02-step-4',
|
||||||
label: 'Aussage ausgeklammert statt gemittelt',
|
label: 'Widerspruch festgehalten statt aufgelöst',
|
||||||
detail: 'Der Absatz bleibt offen, bis die massgebende Quelle bestimmt ist.',
|
detail: 'Der Umfang lässt sich aus keiner der beiden Quellen belegen; eine Annahme wäre eine Behauptung.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-step-5',
|
id: 'awi-livia-02-step-5',
|
||||||
label: 'Review vorgelegt',
|
label: 'Review vorgelegt',
|
||||||
detail: 'Beide Belege mit Fundstelle und Abrufdatum zur Entscheidung beigelegt.',
|
detail: 'Als Risiko markiert, aber mit ausgewiesener Unsicherheit zur Entscheidung vorgelegt.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sourceReferences: [
|
sourceReferences: [
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-src-1',
|
id: 'awi-livia-02-src-1',
|
||||||
label: 'Amtliche Publikation Verkehrsvorhaben Wankdorffeld',
|
label: 'Konzernmitteilung zur Standortbündelung',
|
||||||
documentName: 'Amtliche-Publikation_Haltestelle-Wankdorffeld_2026-03-12.pdf',
|
documentName: 'Handelszeitung_Spedition-Rhein-Restrukturierung_2026-05-06.pdf',
|
||||||
locator: 'Seite 2, Abschnitt «Terminprogramm»',
|
locator: 'Absatz 2, abgerufen 07.05.2026',
|
||||||
quote:
|
|
||||||
'«Die Inbetriebnahme der Haltestelle Wankdorffeld ist auf den Fahrplanwechsel im Dezember 2027 vorgesehen.»',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-src-2',
|
id: 'awi-livia-02-src-2',
|
||||||
label: 'Mitteilung der Standortförderung Bern',
|
label: 'Handelsregister-Mutation',
|
||||||
documentName: 'Standortfoerderung-Bern_Mitteilung_2026-04-28.pdf',
|
documentName: 'Zefix_Spedition-Rhein-GmbH_Mutation_2026-05-11.pdf',
|
||||||
locator: 'Seite 1, abgerufen 07.05.2026',
|
locator: 'Eintrag vom 11.05.2026, abgerufen 11.05.2026',
|
||||||
quote:
|
|
||||||
'«Wegen der Anpassung der Gleisgeometrie verschiebt sich die Eröffnung der Haltestelle Wankdorffeld auf Sommer 2028.»',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
availableActions: [
|
|
||||||
AgentWorkItemAction.DECIDE,
|
|
||||||
AgentWorkItemAction.EDIT,
|
|
||||||
AgentWorkItemAction.APPROVE,
|
|
||||||
AgentWorkItemAction.OPEN_SOURCE,
|
|
||||||
],
|
|
||||||
editableFields: [
|
editableFields: [
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-field-date',
|
id: 'awi-livia-02-field-scope',
|
||||||
label: 'Zu verwendender Zeitpunkt',
|
label: 'Angenommener Umfang',
|
||||||
value: 'Dezember 2027',
|
value: 'Vollständige Aufgabe des Standorts',
|
||||||
helperText: 'Alternativ «Sommer 2028» oder leer lassen, damit der Absatz entfällt.',
|
helperText: 'Bestimmt, ob der Lead als Risiko über die ganze Fläche oder als Teilrisiko geführt wird.',
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'awi-livia-02-field-paragraph',
|
|
||||||
label: 'Absatz zur Haltestelle',
|
|
||||||
value:
|
|
||||||
'Die geplante Haltestelle Wankdorffeld liegt rund 200 Meter vom Objekt entfernt. Die Inbetriebnahme ist auf Dezember 2027 vorgesehen; die Wegzeit zum öffentlichen Verkehr verkürzt sich damit von 11 auf 3 Gehminuten.',
|
|
||||||
multiline: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-field-source',
|
id: 'awi-livia-02-field-source',
|
||||||
label: 'Anzugebende Quelle',
|
label: 'Massgebliche Quelle',
|
||||||
value: 'Amtliche Publikation Verkehrsvorhaben Wankdorffeld, 12.03.2026, abgerufen 07.05.2026',
|
value: 'Konzernmitteilung vom 06.05.2026',
|
||||||
helperText: 'Erscheint im Quellenverzeichnis des Exposés.',
|
helperText: 'Erscheint im Quellenverzeichnis des Leads.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
availableActions: [AgentWorkItemAction.APPROVE, AgentWorkItemAction.OPEN_SOURCE],
|
||||||
history: [
|
history: [
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-hist-1',
|
id: 'awi-livia-02-hist-1',
|
||||||
at: '2026-05-07T07:45:00.000Z',
|
at: '2026-05-07T06:15:00.000Z',
|
||||||
actor: 'Livia',
|
actor: 'Livia',
|
||||||
action: 'Widerspruch erkannt und Review vorgelegt',
|
action: 'Widerspruch erkannt und Review vorgelegt',
|
||||||
note: 'Zwei belegte Quellen mit unterschiedlichen Zeitangaben zur Haltestelle.',
|
note: 'Zwei belegte Quellen mit unterschiedlichem Umfang der Standortaufgabe.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'awi-livia-02-hist-2',
|
id: 'awi-livia-02-hist-2',
|
||||||
at: '2026-05-15T09:20:00.000Z',
|
at: '2026-05-19T06:15:00.000Z',
|
||||||
actor: 'Livia',
|
actor: 'Livia',
|
||||||
action: 'Quellen erneut geprüft',
|
action: 'Quellen erneut geprüft',
|
||||||
note: 'Quartalsprüfung: keine neuere amtliche Publikation vorhanden, Widerspruch besteht weiter.',
|
note: 'Täglicher Lauf: keine neuere Meldung vorhanden, Widerspruch besteht weiter.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Property On — Personalblatt «Livia», Lage-Analystin (Vermarktung).
|
* Property On — Personalblatt «Livia», Research & Market Intelligence (Vermarktung).
|
||||||
*
|
*
|
||||||
* Fachliche Wahrheit aus dem Agenten-Katalog: Rolle, Personalnummer, E-Mail und
|
* Runde 8, §1: Livia ist neu die Agentin, die nach draussen schaut. Sie liest
|
||||||
* die elf Katalogaufgaben werden hier unverändert abgebildet.
|
* laufend Geschäftsberichte, Unternehmensmeldungen, Handelsregisterdaten und
|
||||||
|
* Wirtschaftsnachrichten und verdichtet daraus qualitative Leads für die
|
||||||
|
* Bewirtschaftung — sinngemäss: jeden Tag Zeitung lesen und den Markt verstehen.
|
||||||
|
*
|
||||||
|
* Ausdrücklich **nicht** bei Livia: das Matching gegen den eigenen Bestand.
|
||||||
|
* Sie recherchiert und erzeugt Leads; die Verbindung zu internen Flächen ist
|
||||||
|
* Noras Arbeit (§1, §2).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TeamAgent } from '../../domain/teamAgent'
|
import type { TeamAgent } from '../../domain/teamAgent'
|
||||||
@@ -22,7 +28,7 @@ import {
|
|||||||
export const liviaAgent: TeamAgent = {
|
export const liviaAgent: TeamAgent = {
|
||||||
id: 'livia',
|
id: 'livia',
|
||||||
name: 'Livia',
|
name: 'Livia',
|
||||||
role: 'Exposé Master',
|
role: 'Research & Market Intelligence',
|
||||||
personnelNumber: 'PO-ZD-21',
|
personnelNumber: 'PO-ZD-21',
|
||||||
department: 'Vermarktung',
|
department: 'Vermarktung',
|
||||||
email: 'livia@property-on.ch',
|
email: 'livia@property-on.ch',
|
||||||
@@ -30,196 +36,172 @@ export const liviaAgent: TeamAgent = {
|
|||||||
status: AgentStatus.ACTIVE,
|
status: AgentStatus.ACTIVE,
|
||||||
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
|
autonomyLevel: AgentAutonomyLevel.AUTONOMOUS,
|
||||||
shortDescription:
|
shortDescription:
|
||||||
'Erstellt Lageberichte, Inserate sowie Angebots- und Offertenbroschüren aus den Daten und Bildern aus «Meine Objekte».',
|
'Beobachtet laufend den Markt ausserhalb des eigenen Bestands und verdichtet Geschäftsberichte, Unternehmensmeldungen und Wirtschaftsnachrichten zu handlungsrelevanten Leads.',
|
||||||
autonomyNote:
|
autonomyNote:
|
||||||
'Autonom. Livia recherchiert, schreibt und frischt Lageberichte, Inserate und Exposés ohne vorgängige Freigabe auf. Jede Aussage wird mit Quelle und Abrufdatum protokolliert, damit die Vermarktung jeden Satz nachprüfen kann.',
|
'Autonom. Livia recherchiert, verdichtet und legt Leads ohne vorgängige Freigabe vor. Sie entscheidet nichts und kontaktiert niemanden — jeder Lead trägt Quelle und Abrufdatum, damit die Bewirtschaftung jede Aussage nachprüfen kann, bevor sie handelt.',
|
||||||
|
|
||||||
profile: {
|
profile: {
|
||||||
purpose:
|
purpose:
|
||||||
'Livia macht aus Objektdaten, Lageinformationen und vorhandenen Bildern ein verkaufsfähiges Dokument: Lagebericht, Inserat oder vollständige Angebots- beziehungsweise Offertenbroschüre. Grundlage sind ausschliesslich die Objekte aus «Meine Objekte».',
|
'Livia nimmt der Bewirtschaftung das tägliche Lesen ab. Sie verfolgt Geschäfts- und Jahresberichte, Handelsregistermutationen, Unternehmensmeldungen und Wirtschaftsnachrichten und macht daraus qualitative Leads: welches Unternehmen sich wie verändert, wo, ab wann, und warum daraus Flächenbedarf oder Flächenfreisetzung entstehen könnte.',
|
||||||
input: [
|
input: [
|
||||||
'Objektdaten und Objektbilder aus «Meine Objekte»',
|
'Newsseiten und Wirtschaftsnachrichten, als URL verknüpft',
|
||||||
'Leads, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat',
|
'Geschäfts- und Jahresberichte kotierter und privater Unternehmen',
|
||||||
'Objektadresse aus dem Vermarktungsauftrag',
|
'Handelsregister- und Unternehmensinformationen (Zefix)',
|
||||||
'Öffentliche Umfeld- und Verkehrsdaten zu Quartier, Anbindung und Nahversorgung',
|
'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Firmenmutationen',
|
||||||
'Amtliche Publikationen zu Bauprojekten, Zonenänderungen und Umfeldentwicklungen',
|
'Mitteilungen von Handelskammern und Branchenverbänden',
|
||||||
'Bestehende Objektunterlagen aus der Dokumentenablage',
|
'Unternehmensmeldungen und Medienmitteilungen',
|
||||||
],
|
],
|
||||||
coreFlow: [
|
coreFlow: [
|
||||||
'Adresse aufnehmen und dem richtigen Quartier zuordnen, etwa Zürich Kreis 5 oder Winterthur Neuhegi.',
|
'Verknüpfte Quellen im eingestellten Rhythmus abgehen und neue Meldungen erfassen.',
|
||||||
'Umfeld im festgelegten Radius abgehen: Nachbarschaft, Erdgeschossnutzungen, Erreichbarkeit, Einkaufen und Verpflegung.',
|
'Meldung dem Unternehmen und dem Wirtschaftsraum zuordnen, etwa Zürich Kreis 5 oder Basel Dreispitz.',
|
||||||
'Gemeinde und Region einordnen: Wirtschaftsstruktur, Steuerlage, Entwicklung der Nachfrage.',
|
'Erkennen, um welche Veränderung es geht: Expansion, Personalwachstum, neuer Geschäftsbereich, Investition, Filialexpansion, M&A, Restrukturierung, Standortschliessung oder Einstellung der Geschäftstätigkeit.',
|
||||||
'Amtliche Publikationen und Bauprojekte im Umfeld sichten und bewerten, ob sie für das Objekt relevant sind.',
|
'Einordnen, ob daraus Flächenbedarf (Chance) oder Flächenfreisetzung an einem bestehenden Mietverhältnis (Risiko) entstehen könnte.',
|
||||||
'Aus den gesammelten Bausteinen einen Kurztext fürs Inserat und einen Langtext fürs Exposé schreiben.',
|
'Den Lead strukturiert festhalten: Unternehmen, Veränderung, Ort, Zeitpunkt, Begründung, Quellen und Relevanz für das eigene Portfolio.',
|
||||||
'Quellen anhängen, Ergebnis je Objekt ablegen und quartalsweise gegen den aktuellen Stand prüfen.',
|
'Quellen mit Abrufdatum anhängen und den Lead an die Lead-Liste übergeben, wo Nora ihn gegen den Bestand matcht.',
|
||||||
],
|
],
|
||||||
output: [
|
output: [
|
||||||
'Lagebericht mit Mikro-, Makro- und Umfeldteil',
|
'Qualitative Leads mit Unternehmen, Veränderung, Ort, Zeitpunkt und Begründung',
|
||||||
'Kurztext für Inserat und Portalauftritt',
|
'Einordnung als Chance oder Risiko, je mit empfohlener nächster Handlung',
|
||||||
'Angebots- beziehungsweise Offertenbroschüre als PDF oder Word',
|
|
||||||
'Quellenverzeichnis mit Abrufdatum je Aussage',
|
'Quellenverzeichnis mit Abrufdatum je Aussage',
|
||||||
'Änderungshinweise, sobald sich das Umfeld spürbar verändert',
|
'Hinweis auf betroffene bestehende Mietverhältnisse bei negativen Signalen',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
responsibilities: [
|
responsibilities: [
|
||||||
'Lageberichte für kommerziell genutzte Immobilien erstellen und nachvollziehbar belegen',
|
'Verknüpfte Newsseiten und externe Quellen laufend auswerten',
|
||||||
'Inserate aus den Objektdaten in «Meine Objekte» erstellen',
|
'Expansionen, neue Niederlassungen und neue Standorte erkennen',
|
||||||
'Angebots- und Offertenbroschüren aus Objektdaten, Lageinformationen und vorhandenen Bildern erstellen',
|
'Personalwachstum, neue Geschäftsbereiche und Investitionen erkennen',
|
||||||
'Weitergeleitete Leads von Nora zügig zu einem Exposé verarbeiten',
|
'Retail- und Filialexpansion sowie Produktionsausbau erkennen',
|
||||||
'Erreichbarkeit, Nahversorgung und geplante Umfeldentwicklungen sauber trennen und darstellen',
|
'M&A-Aktivitäten, Restrukturierungen und Standortschliessungen erkennen',
|
||||||
|
'Einstellung von Geschäftstätigkeiten und vergleichbare negative Entwicklungen erkennen',
|
||||||
|
'Jeden Lead als Chance oder Risiko einordnen und die Einordnung begründen',
|
||||||
'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen',
|
'Jede Aussage mit einer nachprüfbaren Quelle und einem Abrufdatum hinterlegen',
|
||||||
'Fehlende Pflichtangaben als Lücke ausweisen, statt plausible Werte zu erfinden',
|
'Keinen Newsfeed erzeugen, sondern verdichten — nur was zu einer Handlung führen kann',
|
||||||
'Lageberichte quartalsweise auffrischen und relevante Abweichungen an die Vermarktung melden',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
tasks: [
|
tasks: [
|
||||||
{
|
{
|
||||||
id: 'livia-task-01',
|
id: 'livia-task-01',
|
||||||
title: 'Objektadresse aufnehmen',
|
title: 'Verknüpfte Quellen abgehen',
|
||||||
description:
|
description:
|
||||||
'Übernimmt Strasse, Hausnummer, Postleitzahl und Ort aus dem Vermarktungsauftrag, prüft die Schreibweise gegen die amtlichen Adressdaten und ordnet das Objekt einem Quartier zu, etwa Zürich Kreis 5, Winterthur Neuhegi oder Basel Gundeldingen.',
|
'Ruft die hinterlegten Newsseiten, Portale für Unternehmensmeldungen und Branchendienste im eingestellten Rhythmus ab und erfasst neue Meldungen. Bereits verarbeitete Meldungen werden übersprungen, damit derselbe Sachverhalt nicht zweimal als Lead erscheint.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'bei Eingang eines Vermarktungsauftrags',
|
schedule: 'täglich 06:00',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-02',
|
id: 'livia-task-02',
|
||||||
title: 'Mikrolage analysieren',
|
title: 'Geschäfts- und Jahresberichte auswerten',
|
||||||
description:
|
description:
|
||||||
'Beschreibt das unmittelbare Umfeld im eingestellten Radius: Nachbarschaft, Erdgeschossnutzungen, Lärm- und Immissionslage, Publikumsfrequenz sowie Parkierung. Trennt beobachtbare Fakten klar von Einschätzungen.',
|
'Liest publizierte Geschäfts- und Jahresberichte auf Aussagen zu Standorten, Kapazitäten, Investitionen und Personalbestand. Zahlen werden mit Berichtsjahr und Fundstelle übernommen, nicht als Trend interpretiert.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'nach der Adressaufnahme',
|
schedule: 'wöchentlich, montags 06:30',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-03',
|
id: 'livia-task-03',
|
||||||
title: 'Makrolage einordnen',
|
title: 'Handelsregister- und Unternehmensinformationen verfolgen',
|
||||||
description:
|
description:
|
||||||
'Ordnet Gemeinde und Wirtschaftsraum ein: Bevölkerungs- und Beschäftigtenentwicklung, Branchenmix, Steuerlage und Bedeutung des Standorts innerhalb der Region, etwa Zug im Wirtschaftsraum Zentralschweiz oder St. Gallen als Zentrum der Ostschweiz.',
|
'Verfolgt Mutationen im Handelsregister: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen. Eine Sitzverlegung ist der belastbarste Hinweis auf einen Standortwechsel, den es öffentlich gibt.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'nach der Mikrolage-Analyse',
|
schedule: 'täglich 06:15',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
dependsOnSystem: AgentSystemType.ZEFIX,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-04',
|
id: 'livia-task-04',
|
||||||
title: 'Erreichbarkeit und Nahversorgung darstellen',
|
title: 'Amtliche Publikationen sichten',
|
||||||
description:
|
description:
|
||||||
'Stellt Anbindung an öffentlichen Verkehr, Autobahnanschluss, Velo- und Fusswege sowie Einkaufen, Verpflegung, Kinderbetreuung und Gesundheitsversorgung dar — jeweils mit Distanz oder Wegzeit statt mit unbelegten Werbeaussagen.',
|
'Sichtet amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen und prüft, ob ein Unternehmen aus dem Beobachtungsbereich betroffen ist.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'nach der Makrolage-Einordnung',
|
schedule: 'täglich 06:20',
|
||||||
requiresApproval: false,
|
|
||||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'livia-task-05',
|
|
||||||
title: 'Geplante Projekte und Umfeldentwicklungen aufnehmen',
|
|
||||||
description:
|
|
||||||
'Sichtet amtliche Publikationen und Bauprojekte im Umfeld — Arealentwicklungen, Zonenänderungen, Verkehrsvorhaben, neue Haltestellen — und hält fest, ab wann sie für das Objekt spürbar werden, etwa das Entwicklungsgebiet Bern Wankdorf.',
|
|
||||||
enabled: true,
|
|
||||||
schedule: 'montags 07:00',
|
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnSystem: AgentSystemType.SHAB,
|
dependsOnSystem: AgentSystemType.SHAB,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-06',
|
id: 'livia-task-05',
|
||||||
title: 'Kurzen Inserattext erstellen',
|
title: 'Wachstumssignale erkennen',
|
||||||
description:
|
description:
|
||||||
'Verdichtet die Lagebausteine auf einen Kurztext für Inserat und Portalauftritt, innerhalb des eingestellten Zeichenlimits und in der gewählten Firmen-Tonalität.',
|
'Erkennt Expansionen, neue Niederlassungen, neue Standorte, Personalwachstum, neue Geschäftsbereiche, Investitionen, Produktionsausbau und Retail-/Filialexpansion. Massgeblich ist die belegte Ankündigung, nicht die Vermutung aus einem Stelleninserat allein.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'sobald alle Lagebausteine vorliegen',
|
schedule: 'im Anschluss an jeden Quellenlauf',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-task-06',
|
||||||
|
title: 'Negative Entwicklungen erkennen',
|
||||||
|
description:
|
||||||
|
'Erkennt M&A-Aktivitäten, Restrukturierungen, Standortschliessungen und die Einstellung von Geschäftstätigkeiten. Diese Signale sind die wertvollsten, weil sie bestehende Mietverhältnisse betreffen und Vorlauf verschaffen.',
|
||||||
|
enabled: true,
|
||||||
|
schedule: 'im Anschluss an jeden Quellenlauf',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-07',
|
id: 'livia-task-07',
|
||||||
title: 'Ausführlichen Exposé-Text erstellen',
|
title: 'Lead als Chance oder Risiko einordnen',
|
||||||
description:
|
description:
|
||||||
'Schreibt den Langtext für das Exposé mit Mikrolage, Makrolage, Erreichbarkeit, Nahversorgung und Umfeldentwicklung in getrennten Abschnitten, damit sich Interessenten gezielt einlesen können.',
|
'Ordnet jeden Lead ein: Chance, wenn neuer oder zusätzlicher Flächenbedarf entstehen könnte; Risiko, wenn ein bestehendes Mietverhältnis betroffen sein könnte. Die Einordnung wird begründet, nicht bloss gesetzt.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'direkt im Anschluss an den Kurztext',
|
schedule: 'bei jedem erzeugten Lead',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-08',
|
id: 'livia-task-08',
|
||||||
title: 'Quellen hinterlegen',
|
title: 'Lead strukturiert ausformulieren',
|
||||||
description:
|
description:
|
||||||
'Hängt jeder Aussage die verwendete Quelle mit Bezeichnung und Abrufdatum an. Lässt sich eine Aussage nicht belegen, wird sie gestrichen statt abgeschwächt.',
|
'Beantwortet je Lead: Welches Unternehmen? Was verändert sich? Wo? Wann? Warum könnte daraus Flächenbedarf oder Flächenfreisetzung entstehen? Welche Quellen sprechen dafür? Kein Fliesstext aus Schlagzeilen, sondern beantwortete Fragen.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'bei jeder Texterstellung',
|
schedule: 'bei jedem erzeugten Lead',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-09',
|
id: 'livia-task-09',
|
||||||
title: 'Ergebnisse je Objekt lokal zwischenspeichern',
|
title: 'Nächste Handlung vorschlagen',
|
||||||
description:
|
description:
|
||||||
'Legt Kurztext, Langtext und Quellenverzeichnis je Objekt in der Dokumentenablage ab, sodass der nächste Arbeitsauftrag auf dem bestehenden Stand aufsetzt und nichts doppelt recherchiert wird.',
|
'Formuliert je Lead die konkrete nächste Handlung. Bei einer Chance: passendes internes Objekt identifizieren, Offerte vorbereiten, Nachfrager kontaktieren. Bei einem Risiko: Mietverhältnis prüfen, Kündigungsrisiko verifizieren, Fläche fürs Re-Letting vorbereiten.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'nach jedem abgeschlossenen Arbeitsauftrag',
|
schedule: 'bei jedem erzeugten Lead',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnSystem: AgentSystemType.DMS,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-10',
|
id: 'livia-task-10',
|
||||||
title: 'Quartalsweisen Aktualisierungslauf simulieren',
|
title: 'Quellen hinterlegen',
|
||||||
description:
|
description:
|
||||||
'Führt den «Quartals-Refresh» durch: prüft alle abgelegten Lagebeschriebe gegen den aktuellen Stand der Quellen, markiert veraltete Abschnitte und erneuert sie. Der Lauf wird protokolliert und ist je Objekt nachvollziehbar.',
|
'Hängt jeder Aussage die verwendete Quelle mit Bezeichnung und Abrufdatum an. Lässt sich eine Aussage nicht belegen, wird sie gestrichen statt abgeschwächt.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'quartalsweise am ersten Werktag 05:30',
|
schedule: 'bei jedem erzeugten Lead',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnSystem: AgentSystemType.DMS,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-11',
|
id: 'livia-task-11',
|
||||||
title: 'Relevante Änderungen melden',
|
title: 'Lead an die Lead-Liste übergeben',
|
||||||
description:
|
description:
|
||||||
'Meldet der Vermarktung nur, was den Lagebeschrieb wirklich verändert — neue Haltestelle, bewilligte Arealentwicklung, wegfallende Nahversorgung — mit Hinweis auf den betroffenen Textabschnitt und die Quelle.',
|
'Übergibt den fertigen Lead an die gemeinsame Lead-Liste, in der Nora ihn gegen den eigenen Bestand matcht. Es wird kein zweiter Datensatz angelegt — derselbe Lead, zwei Arbeitsplätze.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'bei erkannter Abweichung im Aktualisierungslauf',
|
schedule: 'unmittelbar nach dem Ausformulieren',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-12',
|
id: 'livia-task-12',
|
||||||
title: 'Weitergeleiteten Lead aufnehmen',
|
title: 'Doppelmeldungen unterdrücken',
|
||||||
description:
|
description:
|
||||||
'Nimmt Leads entgegen, die Nora erkannt und ein Bewirtschafter zur Exposé-Erstellung weitergeleitet hat, samt der mitgegebenen Objektempfehlung. Die Objekte stammen ausschliesslich aus «Meine Objekte».',
|
'Prüft vor der Übergabe, ob zum selben Unternehmen und Sachverhalt bereits ein Lead vorliegt. Eine zweite Meldung derselben Sache verdrängt die Verdichtung, für die Livia da ist.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'bei Weiterleitung',
|
schedule: 'vor jeder Übergabe',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-task-13',
|
id: 'livia-task-13',
|
||||||
title: 'Objektbilder aus «Meine Objekte» importieren',
|
title: 'Wochenüberblick zustellen',
|
||||||
description:
|
description:
|
||||||
'Importiert alle im Objektbestand hinterlegten Bilder automatisch in das Exposé, vermeidet Doppelimporte und übernimmt Kategorie, Reihenfolge und Titelbild-Markierung, soweit vorhanden.',
|
'Stellt der Bewirtschaftung montags einen Überblick der Leads der Vorwoche zu, getrennt nach Chance und Risiko, mit den jeweils empfohlenen Handlungen.',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: 'beim Start eines Exposé-Auftrags',
|
schedule: 'montags 07:00',
|
||||||
requiresApproval: false,
|
requiresApproval: false,
|
||||||
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
dependsOnChannel: AgentChannelType.EMAIL,
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'livia-task-14',
|
|
||||||
title: 'Exposé-Felder aus den Objektdaten befüllen',
|
|
||||||
description:
|
|
||||||
'Befüllt Eckdaten, Lage, Flächen, Preise, Ausstattung sowie Energie und Technik aus den in «Meine Objekte» verfügbaren Angaben. Nicht verfügbare Pflichtfelder bleiben leer und werden als fehlend markiert — es werden keine plausiblen Werte erfunden.',
|
|
||||||
enabled: true,
|
|
||||||
schedule: 'nach dem Bildimport',
|
|
||||||
requiresApproval: false,
|
|
||||||
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'livia-task-15',
|
|
||||||
title: 'Angebots- beziehungsweise Offertenbroschüre erstellen',
|
|
||||||
description:
|
|
||||||
'Setzt aus den erfassten Objektdaten, Texten, Bildern und Anhängen die Broschüre in der Firmen-CI zusammen und stellt sie als PDF und Word zum Export bereit. Speichern und Erstellen bleiben getrennte Schritte.',
|
|
||||||
enabled: true,
|
|
||||||
schedule: 'auf Auslösung im Exposé-Arbeitsbereich',
|
|
||||||
requiresApproval: false,
|
|
||||||
dependsOnSystem: AgentSystemType.DMS,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -229,12 +211,12 @@ export const liviaAgent: TeamAgent = {
|
|||||||
type: AgentChannelType.WORKSPACE_CHAT,
|
type: AgentChannelType.WORKSPACE_CHAT,
|
||||||
direction: AgentChannelDirection.BOTH,
|
direction: AgentChannelDirection.BOTH,
|
||||||
description:
|
description:
|
||||||
'Livia nimmt Objektadressen und Rückfragen im Arbeitsbereich entgegen und liefert Kurztext, Langtext und Änderungshinweise direkt im Kanal der Vermarktung.',
|
'Livia nimmt Beobachtungsaufträge und Rückfragen im Arbeitsbereich entgegen und legt dort ihre Leads mit Begründung und Quellen ab.',
|
||||||
status: AgentConnectionStatus.CONNECTED,
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config: {
|
config: {
|
||||||
displayName: 'Livia — Lage & Umfeld',
|
displayName: 'Livia — Markt & Recherche',
|
||||||
inboxAddress: 'arbeitsbereich/vermarktung/lage',
|
inboxAddress: 'arbeitsbereich/vermarktung/recherche',
|
||||||
defaultRecipients: ['vermarktung@property-on.ch'],
|
defaultRecipients: ['vermarktung@property-on.ch'],
|
||||||
autoReplyEnabled: true,
|
autoReplyEnabled: true,
|
||||||
},
|
},
|
||||||
@@ -244,7 +226,7 @@ export const liviaAgent: TeamAgent = {
|
|||||||
type: AgentChannelType.EMAIL,
|
type: AgentChannelType.EMAIL,
|
||||||
direction: AgentChannelDirection.OUTBOUND,
|
direction: AgentChannelDirection.OUTBOUND,
|
||||||
description:
|
description:
|
||||||
'Versand der fertigen Lagebeschriebe und der Quartalsmeldung an Vermarktung und Bewirtschaftung. Eingehende Mails werden nicht bearbeitet.',
|
'Versand des Wochenüberblicks und einzelner dringender Leads an Vermarktung und Bewirtschaftung. Eingehende Mails werden nicht bearbeitet.',
|
||||||
status: AgentConnectionStatus.CONNECTED,
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config: {
|
config: {
|
||||||
@@ -263,8 +245,18 @@ export const liviaAgent: TeamAgent = {
|
|||||||
access: AgentAccessLevel.READ,
|
access: AgentAccessLevel.READ,
|
||||||
status: AgentConnectionStatus.CONNECTED,
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
usage:
|
usage:
|
||||||
'Öffentliche Umfeld- und Verkehrsdaten: Haltestellen und Fahrpläne, Wegzeiten, Einkaufen und Verpflegung, Gemeindeangaben zu Bevölkerung und Beschäftigung.',
|
'Verknüpfte Newsseiten und Wirtschaftsnachrichten, Geschäfts- und Jahresberichte, Mitteilungen von Handelskammern und Branchenverbänden sowie Unternehmensmeldungen. Die konkreten Adressen stehen in der Einstellung «Verknüpfte Newsquellen».',
|
||||||
permissionNote: 'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab.',
|
permissionNote:
|
||||||
|
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-sys-zefix',
|
||||||
|
type: AgentSystemType.ZEFIX,
|
||||||
|
access: AgentAccessLevel.READ,
|
||||||
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
|
usage:
|
||||||
|
'Handelsregister- und Unternehmensinformationen: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen.',
|
||||||
|
permissionNote: 'Nur lesend. Einträge werden als Quelle mit Publikationsdatum übernommen.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-sys-shab',
|
id: 'livia-sys-shab',
|
||||||
@@ -272,7 +264,7 @@ export const liviaAgent: TeamAgent = {
|
|||||||
access: AgentAccessLevel.READ,
|
access: AgentAccessLevel.READ,
|
||||||
status: AgentConnectionStatus.CONNECTED,
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
usage:
|
usage:
|
||||||
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen im Umfeld des Objekts.',
|
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen, Arealentwicklungen und Firmenmutationen.',
|
||||||
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
|
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -281,8 +273,9 @@ export const liviaAgent: TeamAgent = {
|
|||||||
access: AgentAccessLevel.READ,
|
access: AgentAccessLevel.READ,
|
||||||
status: AgentConnectionStatus.CONNECTED,
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
usage:
|
usage:
|
||||||
'Objektstammdaten und Adressen aus der Bewirtschaftung als Ausgangspunkt jedes Lagebeschriebs.',
|
'Objektstammdaten und Mieterschaft — ausschliesslich, um bei einem negativen Signal zu erkennen, ob ein bestehendes Mietverhältnis betroffen ist.',
|
||||||
permissionNote: 'Nur lesend. Livia verändert keine Stammdaten in der Bewirtschaftungslösung.',
|
permissionNote:
|
||||||
|
'Nur lesend. Livia verändert keine Stammdaten und nimmt kein Matching gegen den Bestand vor — das ist Noras Aufgabe.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-sys-dms',
|
id: 'livia-sys-dms',
|
||||||
@@ -290,9 +283,9 @@ export const liviaAgent: TeamAgent = {
|
|||||||
access: AgentAccessLevel.READ_WRITE,
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
status: AgentConnectionStatus.CONNECTED,
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
usage:
|
usage:
|
||||||
'Ablage von Kurztext, Langtext und Quellenverzeichnis je Objekt sowie Zwischenstand für den Quartalslauf.',
|
'Ablage der Leads mit Quellenverzeichnis und der Wochenüberblicke.',
|
||||||
permissionNote:
|
permissionNote:
|
||||||
'Schreibt ausschliesslich in den Ordner «Lagebeschriebe» des jeweiligen Objekts; bestehende Fassungen bleiben als Version erhalten.',
|
'Schreibt ausschliesslich in den Ordner «Marktrecherche»; bestehende Fassungen bleiben als Version erhalten.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-sys-workspace',
|
id: 'livia-sys-workspace',
|
||||||
@@ -300,39 +293,136 @@ export const liviaAgent: TeamAgent = {
|
|||||||
access: AgentAccessLevel.READ_WRITE,
|
access: AgentAccessLevel.READ_WRITE,
|
||||||
status: AgentConnectionStatus.CONNECTED,
|
status: AgentConnectionStatus.CONNECTED,
|
||||||
usage:
|
usage:
|
||||||
'Aufträge, Rückfragen und Änderungshinweise im Arbeitsbereich der Vermarktung, inklusive Protokoll der Aktualisierungsläufe.',
|
'Leads, Rückfragen und der Wochenüberblick im Arbeitsbereich der Vermarktung, inklusive Protokoll der Quellenläufe.',
|
||||||
permissionNote:
|
permissionNote:
|
||||||
'Schreibt nur in den eigenen Bereich «Lage & Umfeld»; Objektdossiers anderer Teams bleiben unberührt.',
|
'Schreibt nur in den eigenen Bereich «Markt & Recherche»; Objektdossiers anderer Teams bleiben unberührt.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
settings: [
|
settings: [
|
||||||
{
|
{
|
||||||
id: 'livia-set-short-limit',
|
id: 'livia-set-news-sources',
|
||||||
label: 'Kurztext-Zeichenlimit',
|
label: 'Verknüpfte Newsquellen',
|
||||||
description: 'Obergrenze für den Lagetext im Inserat und auf den Portalen.',
|
description:
|
||||||
kind: AgentSettingKind.NUMBER,
|
'Newsseiten und Portale, die Livia täglich abgeht. Weitere Adressen lassen sich hier ergänzen — es braucht keine zweite Quellenverwaltung.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
group: AgentSettingGroup.SPECIFIC,
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
value: 600,
|
value: [
|
||||||
unit: 'Zeichen',
|
'https://www.handelszeitung.ch',
|
||||||
min: 200,
|
'https://www.nzz.ch/wirtschaft',
|
||||||
max: 1200,
|
'https://www.finanzundwirtschaft.ch',
|
||||||
|
'https://www.shab.ch',
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
{ value: 'https://www.handelszeitung.ch', label: 'Handelszeitung' },
|
||||||
|
{ value: 'https://www.nzz.ch/wirtschaft', label: 'NZZ Wirtschaft' },
|
||||||
|
{ value: 'https://www.finanzundwirtschaft.ch', label: 'Finanz und Wirtschaft' },
|
||||||
|
{ value: 'https://www.shab.ch', label: 'SHAB — Amtliche Publikationen' },
|
||||||
|
{ value: 'https://www.zefix.ch', label: 'Zefix — Handelsregister' },
|
||||||
|
{ value: 'https://www.cash.ch/news', label: 'cash — Unternehmensnachrichten' },
|
||||||
|
{ value: 'https://www.zhk.ch', label: 'Zürcher Handelskammer' },
|
||||||
|
{ value: 'https://www.hkbb.ch', label: 'Handelskammer beider Basel' },
|
||||||
|
{ value: 'https://www.moneyhouse.ch', label: 'Moneyhouse — Unternehmensdaten' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-set-long-limit',
|
id: 'livia-set-source-types',
|
||||||
label: 'Langtext-Zeichenlimit',
|
label: 'Relevante Quellentypen',
|
||||||
description: 'Obergrenze für den ausführlichen Lageteil im Exposé.',
|
description: 'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: [
|
||||||
|
'geschaeftsberichte',
|
||||||
|
'handelsregister',
|
||||||
|
'unternehmensmeldungen',
|
||||||
|
'wirtschaftsnews',
|
||||||
|
'amtliche_publikationen',
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
{ value: 'geschaeftsberichte', label: 'Geschäfts- und Jahresberichte' },
|
||||||
|
{ value: 'handelskammern', label: 'Handelskammern' },
|
||||||
|
{ value: 'handelsregister', label: 'Handelsregister- und Unternehmensinformationen' },
|
||||||
|
{ value: 'unternehmensmeldungen', label: 'Unternehmensmeldungen' },
|
||||||
|
{ value: 'wirtschaftsnews', label: 'Wirtschaftsnews-Anbieter' },
|
||||||
|
{ value: 'amtliche_publikationen', label: 'Amtliche Publikationen' },
|
||||||
|
{ value: 'branchendienste', label: 'Markt- und Brancheninformationen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-signal-types',
|
||||||
|
label: 'Beobachtete Veränderungen',
|
||||||
|
description: 'Welche Unternehmensveränderungen Livia als Lead meldet.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: [
|
||||||
|
'expansion',
|
||||||
|
'personalwachstum',
|
||||||
|
'geschaeftsbereiche',
|
||||||
|
'investitionen',
|
||||||
|
'retail_expansion',
|
||||||
|
'ma',
|
||||||
|
'restrukturierung',
|
||||||
|
'schliessung',
|
||||||
|
'einstellung',
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
{ value: 'expansion', label: 'Expansion, neue Niederlassungen und Standorte' },
|
||||||
|
{ value: 'personalwachstum', label: 'Personalwachstum und organisatorisches Wachstum' },
|
||||||
|
{ value: 'geschaeftsbereiche', label: 'Neue Geschäftsbereiche' },
|
||||||
|
{ value: 'investitionen', label: 'Investitionen und Produktionsausbau' },
|
||||||
|
{ value: 'retail_expansion', label: 'Retail- und Filialexpansion' },
|
||||||
|
{ value: 'ma', label: 'M&A-Aktivitäten' },
|
||||||
|
{ value: 'restrukturierung', label: 'Restrukturierungen' },
|
||||||
|
{ value: 'schliessung', label: 'Standortschliessungen' },
|
||||||
|
{ value: 'einstellung', label: 'Einstellung der Geschäftstätigkeit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-scan-interval',
|
||||||
|
label: 'Rhythmus der Quellenläufe',
|
||||||
|
description: 'Wie oft Livia die verknüpften Quellen abgeht.',
|
||||||
|
kind: AgentSettingKind.SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: 'taeglich',
|
||||||
|
options: [
|
||||||
|
{ value: 'stuendlich', label: 'stündlich' },
|
||||||
|
{ value: 'taeglich', label: 'täglich' },
|
||||||
|
{ value: 'woechentlich', label: 'wöchentlich' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-observation-area',
|
||||||
|
label: 'Beobachtungsraum',
|
||||||
|
description: 'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft.',
|
||||||
|
kind: AgentSettingKind.MULTI_SELECT,
|
||||||
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
|
value: ['zuerich', 'basel', 'zentralschweiz', 'ostschweiz', 'bern'],
|
||||||
|
options: [
|
||||||
|
{ value: 'zuerich', label: 'Wirtschaftsraum Zürich' },
|
||||||
|
{ value: 'basel', label: 'Wirtschaftsraum Basel' },
|
||||||
|
{ value: 'zentralschweiz', label: 'Zentralschweiz' },
|
||||||
|
{ value: 'ostschweiz', label: 'Ostschweiz' },
|
||||||
|
{ value: 'bern', label: 'Bern und Mittelland' },
|
||||||
|
{ value: 'romandie', label: 'Romandie' },
|
||||||
|
{ value: 'tessin', label: 'Tessin' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'livia-set-min-relevance',
|
||||||
|
label: 'Mindestrelevanz für einen Lead',
|
||||||
|
description:
|
||||||
|
'Unterhalb dieses Werts wird eine Meldung nicht zum Lead. Der Schwellenwert ist der Unterschied zwischen Verdichtung und Newsfeed.',
|
||||||
kind: AgentSettingKind.NUMBER,
|
kind: AgentSettingKind.NUMBER,
|
||||||
group: AgentSettingGroup.SPECIFIC,
|
group: AgentSettingGroup.SPECIFIC,
|
||||||
value: 2400,
|
value: 60,
|
||||||
unit: 'Zeichen',
|
unit: '%',
|
||||||
min: 800,
|
min: 0,
|
||||||
max: 5000,
|
max: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-set-tonality',
|
id: 'livia-set-tonality',
|
||||||
label: 'Firmen-Tonalität',
|
label: 'Firmen-Tonalität',
|
||||||
description: 'Sprachstil, in dem Kurz- und Langtext verfasst werden.',
|
description: 'Sprachstil der Leadtexte und des Wochenüberblicks.',
|
||||||
kind: AgentSettingKind.SELECT,
|
kind: AgentSettingKind.SELECT,
|
||||||
group: AgentSettingGroup.GENERAL,
|
group: AgentSettingGroup.GENERAL,
|
||||||
value: 'sachlich',
|
value: 'sachlich',
|
||||||
@@ -342,75 +432,35 @@ export const liviaAgent: TeamAgent = {
|
|||||||
{ value: 'zurueckhaltend', label: 'zurückhaltend' },
|
{ value: 'zurueckhaltend', label: 'zurückhaltend' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'livia-set-refresh-interval',
|
|
||||||
label: 'Aktualisierungsintervall',
|
|
||||||
description: 'Rhythmus, in dem bestehende Lagebeschriebe gegen die Quellen geprüft werden.',
|
|
||||||
kind: AgentSettingKind.SELECT,
|
|
||||||
group: AgentSettingGroup.SPECIFIC,
|
|
||||||
value: 'quartalsweise',
|
|
||||||
options: [
|
|
||||||
{ value: 'monatlich', label: 'monatlich' },
|
|
||||||
{ value: 'quartalsweise', label: 'quartalsweise' },
|
|
||||||
{ value: 'halbjaehrlich', label: 'halbjährlich' },
|
|
||||||
{ value: 'jaehrlich', label: 'jährlich' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'livia-set-analysis-radius',
|
|
||||||
label: 'Radius der Umfeldanalyse',
|
|
||||||
description: 'Umkreis um die Objektadresse, der für die Mikrolage ausgewertet wird.',
|
|
||||||
kind: AgentSettingKind.NUMBER,
|
|
||||||
group: AgentSettingGroup.SPECIFIC,
|
|
||||||
value: 800,
|
|
||||||
unit: 'Meter',
|
|
||||||
min: 200,
|
|
||||||
max: 3000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'livia-set-source-types',
|
|
||||||
label: 'Relevante Quellentypen',
|
|
||||||
description: 'Welche Quellen Livia für Lagebeschriebe und Aktualisierungsläufe heranzieht.',
|
|
||||||
kind: AgentSettingKind.MULTI_SELECT,
|
|
||||||
group: AgentSettingGroup.SPECIFIC,
|
|
||||||
value: ['amtliche_publikationen', 'verkehrsdaten', 'bauprojekte', 'nahversorgung'],
|
|
||||||
options: [
|
|
||||||
{ value: 'amtliche_publikationen', label: 'amtliche Publikationen' },
|
|
||||||
{ value: 'verkehrsdaten', label: 'Verkehrsdaten' },
|
|
||||||
{ value: 'bauprojekte', label: 'Bauprojekte' },
|
|
||||||
{ value: 'nahversorgung', label: 'Nahversorgung' },
|
|
||||||
{ value: 'medienberichte', label: 'Medienberichte' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
|
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: 'livia-metric-objects',
|
id: 'livia-metric-leads',
|
||||||
label: 'Analysierte Objekte',
|
label: 'Erzeugte Leads',
|
||||||
value: '184',
|
value: '96',
|
||||||
hint: 'Objekte mit vollständigem Lagebeschrieb, unter anderem in Zürich, Winterthur, Zug und St. Gallen.',
|
hint: 'Aus externen Quellen verdichtete Leads der letzten zwölf Monate, Chance und Risiko zusammen.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-metric-current-blocks',
|
id: 'livia-metric-chance',
|
||||||
label: 'Aktuelle Lagebausteine',
|
label: 'Davon Chancen',
|
||||||
value: '162',
|
value: '61',
|
||||||
hint: 'Bausteine, die im letzten Aktualisierungslauf gegen die Quellen bestätigt wurden.',
|
hint: 'Signale, die auf neuen oder zusätzlichen Flächenbedarf hindeuten.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-metric-due-refresh',
|
id: 'livia-metric-risk',
|
||||||
label: 'Fällige Aktualisierungen',
|
label: 'Davon Risiken',
|
||||||
value: '9',
|
value: '35',
|
||||||
hint: 'Objekte, deren Lagebeschrieb im nächsten Quartals-Refresh erneuert wird.',
|
hint: 'Signale, die ein bestehendes Mietverhältnis betreffen könnten.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'livia-metric-detected-changes',
|
id: 'livia-metric-sources',
|
||||||
label: 'Erkannte Umfeldänderungen',
|
label: 'Verknüpfte Quellen',
|
||||||
value: '14',
|
value: '4',
|
||||||
hint: 'Seit dem letzten Lauf gemeldete Änderungen, etwa die Arealentwicklung Winterthur Neuhegi.',
|
hint: 'Aktuell hinterlegte Newsseiten und Portale; weitere lassen sich in den Einstellungen ergänzen.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
headlineMetricId: 'livia-metric-objects',
|
headlineMetricId: 'livia-metric-leads',
|
||||||
lastRun: '2026-05-20T06:00:00.000Z',
|
lastRun: '2026-05-20T06:00:00.000Z',
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SignalType, RiskLevel } from '../domain/enums'
|
import { SignalType, RiskLevel } from '../domain/enums'
|
||||||
|
import { LeadSignalType } from '../domain/futureSignal'
|
||||||
import type { FutureSignal } from '../domain/futureSignal'
|
import type { FutureSignal } from '../domain/futureSignal'
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -705,4 +706,126 @@ export const mockFutureSignals: FutureSignal[] = [
|
|||||||
{ type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' },
|
{ type: 'LINKEDIN', value: 'linkedin.com/company/berntech-innovation', confidence: 'HIGH', label: 'LinkedIn Seite' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Recherche-Leads von Livia (Runde 8, §1–§2) ─────────────────────────────
|
||||||
|
//
|
||||||
|
// Diese beiden Signale zeigen die Einordnung ausdrücklich statt sie über den
|
||||||
|
// Signaltyp abzuleiten: eines als Chance, eines als Risiko. Das Risiko ist der
|
||||||
|
// wichtigere Fall — es betrifft ein bestehendes Mietverhältnis und verschafft
|
||||||
|
// der Bewirtschaftung Vorlauf, bevor die Kündigung eintrifft.
|
||||||
|
|
||||||
|
// signal-020: Chance — Retail-Filialexpansion aus einem Geschäftsbericht.
|
||||||
|
{
|
||||||
|
id: 'signal-020',
|
||||||
|
signalType: SignalType.EXPANSION,
|
||||||
|
signalDirection: 'DEMAND',
|
||||||
|
leadSignalType: LeadSignalType.CHANCE,
|
||||||
|
companyName: 'Alpenbäckerei Truttmann AG',
|
||||||
|
locationHint: 'Zürich, Kreis 4 / Kreis 5',
|
||||||
|
title: 'Alpenbäckerei Truttmann kündigt zwölf neue Filialen an — drei davon in Zürich',
|
||||||
|
areaSqmEstimate: 180,
|
||||||
|
probability: 0.72,
|
||||||
|
confidenceScore: 0.81,
|
||||||
|
timeHorizonMonths: 14,
|
||||||
|
source: {
|
||||||
|
type: 'COMPANY_REPORT',
|
||||||
|
url: 'https://example.com/truttmann/geschaeftsbericht-2025',
|
||||||
|
publishedAt: '2026-04-28',
|
||||||
|
credibility: 'HIGH',
|
||||||
|
},
|
||||||
|
sensitivityLevel: 'PUBLIC',
|
||||||
|
disclaimer: 'Aus öffentlich publiziertem Geschäftsbericht und Medienberichterstattung verdichtet. Kein bestätigter Flächenauftrag.',
|
||||||
|
riskLevel: RiskLevel.LOW,
|
||||||
|
relevanceScore: 0.78,
|
||||||
|
isVerified: false,
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-04T06:10:00Z',
|
||||||
|
updatedAt: '2026-05-18T06:10:00Z',
|
||||||
|
aiSummary:
|
||||||
|
'Der Geschäftsbericht 2025 der Alpenbäckerei Truttmann AG nennt auf Seite 14 ausdrücklich zwölf geplante Filialeröffnungen bis Ende 2027, davon drei im Raum Zürich. Als Suchprofil ist im Bericht «Erdgeschosslagen mit hoher Fussgängerfrequenz, 150–220 m²» genannt. Die Handelszeitung hat am 30.04. nachgezogen und den CFO mit der Aussage zitiert, die Standortsuche in Zürich habe begonnen. Zwei Standortleiterstellen für Zürich sind seit Mitte April ausgeschrieben.',
|
||||||
|
marketIndicators: [
|
||||||
|
'Geschäftsbericht 2025, Seite 14: zwölf Filialeröffnungen bis Ende 2027',
|
||||||
|
'CFO-Zitat vom 30.04.2026: Standortsuche in Zürich hat begonnen',
|
||||||
|
'Zwei Standortleiterstellen für Zürich seit 15.04.2026 ausgeschrieben',
|
||||||
|
],
|
||||||
|
signalRationale:
|
||||||
|
'Das Unternehmen hat den Flächenbedarf selbst publiziert und das Suchprofil mitgeliefert — Nutzung, Grösse und Lageanforderung sind bekannt. Damit lässt sich unmittelbar gegen den eigenen Bestand prüfen, statt erst den Bedarf zu erraten.',
|
||||||
|
recommendedAction:
|
||||||
|
'Erdgeschossflächen zwischen 150 und 220 m² in Kreis 4 und 5 zusammenstellen und dem CFO eine Auswahl vorlegen, bevor die Suche über einen Makler ausgeschrieben wird.',
|
||||||
|
confirmedFacts: [
|
||||||
|
'Expansionsprogramm im Geschäftsbericht 2025 publiziert',
|
||||||
|
'Raum Zürich ausdrücklich als Zielmarkt genannt',
|
||||||
|
'Suchprofil 150–220 m² Erdgeschoss im Bericht enthalten',
|
||||||
|
],
|
||||||
|
unconfirmedFacts: ['Konkrete Quartiere', 'Zeitpunkt der einzelnen Eröffnungen', 'Ob Kauf oder Miete'],
|
||||||
|
researchSources: [
|
||||||
|
{ type: 'COMPANY_REPORT', url: 'https://example.com/truttmann/geschaeftsbericht-2025', publishedAt: '2026-04-28', credibility: 'HIGH' },
|
||||||
|
{ type: 'PRESS', url: 'https://www.handelszeitung.ch/unternehmen/truttmann-expansion', publishedAt: '2026-04-30', credibility: 'HIGH' },
|
||||||
|
{ type: 'JOB_POSTING', url: 'https://example.com/truttmann/jobs/standortleitung-zuerich', publishedAt: '2026-04-15', credibility: 'MEDIUM' },
|
||||||
|
],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://truttmann.example.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Marco Truttmann (CFO)', confidence: 'HIGH', label: 'Aus Geschäftsbericht und Medienbericht' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// signal-021: Risiko — Restrukturierung bei einem bestehenden Mieter.
|
||||||
|
{
|
||||||
|
id: 'signal-021',
|
||||||
|
signalType: SignalType.RESTRUCTURING,
|
||||||
|
signalDirection: 'SUPPLY',
|
||||||
|
leadSignalType: LeadSignalType.RISIKO,
|
||||||
|
companyName: 'Spedition Rhein GmbH',
|
||||||
|
propertyId: 'prop-002',
|
||||||
|
affectedTenant: 'Spedition Rhein GmbH — Lagerfläche Hardstrasse 44, Basel',
|
||||||
|
locationHint: 'Basel, Kleinhüningen',
|
||||||
|
title: 'Spedition Rhein bündelt Lagerstandorte — Basel Kleinhüningen betroffen',
|
||||||
|
areaSqmEstimate: 1900,
|
||||||
|
probability: 0.64,
|
||||||
|
confidenceScore: 0.7,
|
||||||
|
timeHorizonMonths: 11,
|
||||||
|
source: {
|
||||||
|
type: 'PRESS',
|
||||||
|
url: 'https://www.handelszeitung.ch/unternehmen/spedition-rhein-restrukturierung',
|
||||||
|
publishedAt: '2026-05-06',
|
||||||
|
credibility: 'HIGH',
|
||||||
|
},
|
||||||
|
sensitivityLevel: 'INTERNAL',
|
||||||
|
disclaimer: 'Aus öffentlichen Meldungen verdichtet. Keine Kündigung und keine Absichtserklärung des Mieters — Frühindikator, kein bestätigter Auszug.',
|
||||||
|
riskLevel: RiskLevel.HIGH,
|
||||||
|
relevanceScore: 0.9,
|
||||||
|
isVerified: false,
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-05-07T06:15:00Z',
|
||||||
|
updatedAt: '2026-05-19T06:15:00Z',
|
||||||
|
aiSummary:
|
||||||
|
'Die Muttergesellschaft der Spedition Rhein GmbH hat am 06.05. eine Bündelung der Lagerlogistik auf zwei Zentralstandorte angekündigt, genannt sind Pratteln und Kaiseraugst. Der Standort Basel Kleinhüningen ist in der Mitteilung nicht als Zentralstandort aufgeführt. Im Handelsregister ist am 11.05. eine Zweckanpassung eingetragen worden, die den Betrieb eigener Lagerflächen nicht mehr nennt. Die Spedition Rhein GmbH ist Mieterin der Lagerfläche Hardstrasse 44.',
|
||||||
|
marketIndicators: [
|
||||||
|
'Konzernmitteilung vom 06.05.2026: Bündelung auf Pratteln und Kaiseraugst',
|
||||||
|
'Basel Kleinhüningen in der Mitteilung nicht als Zentralstandort genannt',
|
||||||
|
'Handelsregister-Mutation vom 11.05.2026: Zweckanpassung ohne eigene Lagerflächen',
|
||||||
|
],
|
||||||
|
signalRationale:
|
||||||
|
'Der Mieter dieser Fläche gehört zu einem Konzern, der seine Lagerlogistik auf zwei andere Standorte bündelt. Kleinhüningen fehlt in beiden Aufzählungen. Das ist noch keine Kündigung, aber der früheste belegbare Hinweis darauf, dass 1 900 m² freigesetzt werden könnten.',
|
||||||
|
recommendedAction:
|
||||||
|
'Das Mietverhältnis Hardstrasse 44 prüfen: Laufzeit, Kündigungsfrist und Break-Optionen. Das Gespräch mit der Geschäftsleitung suchen, bevor die Kündigung eintrifft, und parallel die Fläche für internes Re-Letting vorbereiten — bei 1 900 m² Lagerfläche in Kleinhüningen ist mit mehreren Monaten Vermarktungsdauer zu rechnen.',
|
||||||
|
confirmedFacts: [
|
||||||
|
'Konzernmitteilung zur Standortbündelung publiziert',
|
||||||
|
'Handelsregister-Mutation vom 11.05.2026 eingetragen',
|
||||||
|
'Spedition Rhein GmbH ist Mieterin der Hardstrasse 44',
|
||||||
|
],
|
||||||
|
unconfirmedFacts: [
|
||||||
|
'Ob der Standort Basel ganz oder teilweise aufgegeben wird',
|
||||||
|
'Zeitpunkt einer möglichen Kündigung',
|
||||||
|
'Ob eine Konzerngesellschaft die Fläche übernimmt',
|
||||||
|
],
|
||||||
|
researchSources: [
|
||||||
|
{ type: 'PRESS', url: 'https://www.handelszeitung.ch/unternehmen/spedition-rhein-restrukturierung', publishedAt: '2026-05-06', credibility: 'HIGH' },
|
||||||
|
{ type: 'MARKET_DATA', url: 'https://www.zefix.ch/de/search/entity/list', publishedAt: '2026-05-11', credibility: 'HIGH' },
|
||||||
|
],
|
||||||
|
extractedContacts: [
|
||||||
|
{ type: 'WEBSITE', value: 'https://spedition-rhein.example.ch', confidence: 'HIGH', label: 'Unternehmenswebsite' },
|
||||||
|
{ type: 'CONTACT_PERSON', value: 'Beatrice Vogt (Geschäftsführung)', confidence: 'HIGH', label: 'Handelsregistereintrag' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
@@ -586,4 +586,192 @@ export const mockReminders: Reminder[] = [
|
|||||||
createdAt: '2026-03-10T08:00:00Z',
|
createdAt: '2026-03-10T08:00:00Z',
|
||||||
updatedAt: '2026-04-19T11:00:00Z',
|
updatedAt: '2026-04-19T11:00:00Z',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ─── Contract Intelligence (Runde 8, §4) ───────────────────────────────────
|
||||||
|
//
|
||||||
|
// Diese Termine stammen aus der Vertragsanalyse, nicht aus einer Terminliste.
|
||||||
|
// Gemeinsam ist ihnen, dass `dueDate` und `decisionDate` deutlich vor
|
||||||
|
// `contractEndDate` liegen — Ferdi erinnert an den Entscheid, nicht an das
|
||||||
|
// Ende. Ein Reminder auf das Vertragsende käme für jede Entscheidung zu spät,
|
||||||
|
// die ihn nötig gemacht hat.
|
||||||
|
|
||||||
|
// Echte Option — die Ausübungsfrist verfällt, danach ist das Recht erloschen.
|
||||||
|
{
|
||||||
|
id: 'rem-022',
|
||||||
|
type: ReminderType.REAL_OPTION,
|
||||||
|
priority: ReminderPriority.URGENT,
|
||||||
|
status: ReminderStatus.ACTIVE,
|
||||||
|
propertyId: 'prop-012',
|
||||||
|
propertyTitle: 'Bürofläche Stadtturm Zug',
|
||||||
|
propertyCity: 'Zug',
|
||||||
|
propertyDistrict: 'Zentrum',
|
||||||
|
tenantName: 'FinTech Zug AG',
|
||||||
|
dueDate: '2026-05-29',
|
||||||
|
eventDate: '2026-06-30',
|
||||||
|
contractEndDate: '2027-06-30',
|
||||||
|
decisionDate: '2026-06-30',
|
||||||
|
noticePeriodMonths: 12,
|
||||||
|
contractRef: 'Mietvertrag FinTech Zug AG, 01.07.2019',
|
||||||
|
contractClause: 'Ziffer 4.2, Verlängerungsoption',
|
||||||
|
relevanceReason:
|
||||||
|
'Der Mieter hat ein einseitiges Recht, den Vertrag um fünf Jahre zu verlängern — ausübbar nur bis zum 30.06.2026. Verstreicht der Termin, erlischt das Recht endgültig und die Fläche wird per 30.06.2027 frei.',
|
||||||
|
recommendedAction:
|
||||||
|
'Beim Mieter aktiv nachfragen, ob die Option ausgeübt wird. Bleibt die Antwort aus, die Neuvermarktung ab Juli vorbereiten — zwölf Monate Vorlauf sind für eine Bürofläche dieser Grösse in Zug knapp bemessen.',
|
||||||
|
areaSqm: 640,
|
||||||
|
currentRentPerSqm: 395,
|
||||||
|
currency: 'CHF',
|
||||||
|
shadowMarketRisk: ShadowMarketRisk.HIGH,
|
||||||
|
schattenmarktEnabled: true,
|
||||||
|
note: 'Option wurde bei Vertragsabschluss verhandelt und ist im Nachtrag 1 bestätigt.',
|
||||||
|
activity: [
|
||||||
|
{ at: '2026-02-02T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Mietvertrag Ziffer 4.2 erkannt, durch Bewirtschaftung bestätigt' },
|
||||||
|
],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-02-02T08:00:00Z',
|
||||||
|
updatedAt: '2026-02-02T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unechte Option — keine Frist, die verfällt, sondern ein Verhandlungsfenster.
|
||||||
|
{
|
||||||
|
id: 'rem-023',
|
||||||
|
type: ReminderType.UNREAL_OPTION,
|
||||||
|
priority: ReminderPriority.HIGH,
|
||||||
|
status: ReminderStatus.ACTIVE,
|
||||||
|
propertyId: 'prop-008',
|
||||||
|
propertyTitle: 'Bürofläche Dreispitz Areal 9',
|
||||||
|
propertyCity: 'Basel',
|
||||||
|
propertyDistrict: 'Dreispitz',
|
||||||
|
tenantName: 'Pharma Research GmbH',
|
||||||
|
dueDate: '2026-06-15',
|
||||||
|
eventDate: '2026-06-15',
|
||||||
|
contractEndDate: '2027-12-31',
|
||||||
|
decisionDate: '2026-09-30',
|
||||||
|
negotiationWindowFrom: '2026-06-15',
|
||||||
|
negotiationWindowTo: '2026-09-30',
|
||||||
|
noticePeriodMonths: 6,
|
||||||
|
contractRef: 'Mietvertrag Pharma Research GmbH, 15.01.2018',
|
||||||
|
contractClause: 'Ziffer 6.4, Absichtserklärung zur Fortsetzung',
|
||||||
|
relevanceReason:
|
||||||
|
'Der Vertrag enthält keine echte Option, sondern die Abrede, spätestens 15 Monate vor Ablauf über eine Fortsetzung zu verhandeln. Es gibt hier keine Frist, die verfällt — wohl aber ein Fenster, nach dem sich der Mieter anderweitig bindet.',
|
||||||
|
recommendedAction:
|
||||||
|
'Gesprächstermin bis Mitte Juni ansetzen und mit einer Indikation zu Mietzins und Laufzeit hineingehen. Bis Ende September sollte eine Einigung stehen, damit im Fall einer Absage noch ein Jahr für die Neuvermietung bleibt.',
|
||||||
|
areaSqm: 1180,
|
||||||
|
currentRentPerSqm: 310,
|
||||||
|
currency: 'CHF',
|
||||||
|
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
|
||||||
|
schattenmarktEnabled: false,
|
||||||
|
note: 'Mieter hat 2025 in den Laborausbau investiert — eine Fortsetzung ist wahrscheinlich, aber nicht zugesichert.',
|
||||||
|
activity: [
|
||||||
|
{ at: '2026-03-05T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Mietvertrag Ziffer 6.4 erkannt, durch Bewirtschaftung bestätigt' },
|
||||||
|
],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-03-05T08:00:00Z',
|
||||||
|
updatedAt: '2026-03-05T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Kündigungsfrist — der letzte Zeitpunkt für eine fristgerechte Kündigung.
|
||||||
|
{
|
||||||
|
id: 'rem-024',
|
||||||
|
type: ReminderType.NOTICE_PERIOD,
|
||||||
|
priority: ReminderPriority.HIGH,
|
||||||
|
status: ReminderStatus.ACTIVE,
|
||||||
|
propertyId: 'prop-014',
|
||||||
|
propertyTitle: 'Logistikhalle Pratteln Nord',
|
||||||
|
propertyCity: 'Pratteln',
|
||||||
|
propertyDistrict: 'Industriezone',
|
||||||
|
tenantName: 'Handels- und Lagerbetrieb AG',
|
||||||
|
dueDate: '2026-06-10',
|
||||||
|
eventDate: '2026-06-30',
|
||||||
|
contractEndDate: '2026-12-31',
|
||||||
|
decisionDate: '2026-06-30',
|
||||||
|
noticePeriodMonths: 6,
|
||||||
|
contractRef: 'Mietvertrag Handels- und Lagerbetrieb AG, 01.01.2021',
|
||||||
|
contractClause: 'Ziffer 3.1, Kündigung auf Vertragsende',
|
||||||
|
relevanceReason:
|
||||||
|
'Eine Kündigung auf den 31.12.2026 muss spätestens am 30.06.2026 beim Mieter eintreffen. Ab dem 01.07. verlängert sich der Vertrag automatisch um ein weiteres Jahr.',
|
||||||
|
recommendedAction:
|
||||||
|
'Bis zum 10.06. entscheiden, ob das Mietverhältnis fortgesetzt wird. Soll gekündigt werden, das Schreiben so aufsetzen, dass der Zugang bis zum 30.06. nachweisbar ist.',
|
||||||
|
areaSqm: 2400,
|
||||||
|
currentRentPerSqm: 128,
|
||||||
|
currency: 'CHF',
|
||||||
|
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
|
||||||
|
schattenmarktEnabled: false,
|
||||||
|
activity: [
|
||||||
|
{ at: '2026-01-15T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Mietvertrag Ziffer 3.1 erkannt, durch Bewirtschaftung bestätigt' },
|
||||||
|
],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-01-15T08:00:00Z',
|
||||||
|
updatedAt: '2026-01-15T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verhandlungsfenster vor dem Auslaufen einer Staffelmiete.
|
||||||
|
{
|
||||||
|
id: 'rem-025',
|
||||||
|
type: ReminderType.NEGOTIATION_WINDOW,
|
||||||
|
priority: ReminderPriority.MEDIUM,
|
||||||
|
status: ReminderStatus.ACTIVE,
|
||||||
|
propertyId: 'prop-001',
|
||||||
|
propertyTitle: 'Bürofläche Zollstrasse 12',
|
||||||
|
propertyCity: 'Zürich',
|
||||||
|
propertyDistrict: 'Zürich-West',
|
||||||
|
tenantName: 'MediaGroup Schweiz AG',
|
||||||
|
dueDate: '2026-07-01',
|
||||||
|
eventDate: '2026-10-01',
|
||||||
|
contractEndDate: '2028-10-31',
|
||||||
|
decisionDate: '2026-10-01',
|
||||||
|
negotiationWindowFrom: '2026-07-01',
|
||||||
|
negotiationWindowTo: '2026-10-01',
|
||||||
|
contractRef: 'Nachtrag 2 zum Mietvertrag MediaGroup Schweiz AG, 12.09.2023',
|
||||||
|
contractClause: 'Ziffer 2, Staffelmiete',
|
||||||
|
relevanceReason:
|
||||||
|
'Die vereinbarte Staffel läuft per 01.10.2026 aus. Danach greift die Indexklausel — über die Anschlussregelung ist vorher zu verhandeln, sonst gilt automatisch die für den Vermieter ungünstigere Variante.',
|
||||||
|
recommendedAction:
|
||||||
|
'Ab Juli die Vergleichsmieten im Kreis 5 zusammenstellen und dem Mieter bis spätestens Ende September eine Anschlussregelung vorlegen.',
|
||||||
|
areaSqm: 850,
|
||||||
|
currentRentPerSqm: 456,
|
||||||
|
currency: 'CHF',
|
||||||
|
shadowMarketRisk: ShadowMarketRisk.LOW,
|
||||||
|
schattenmarktEnabled: false,
|
||||||
|
activity: [
|
||||||
|
{ at: '2026-04-08T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus Nachtrag 2 Ziffer 2 erkannt, durch Bewirtschaftung bestätigt' },
|
||||||
|
],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-04-08T08:00:00Z',
|
||||||
|
updatedAt: '2026-04-08T08:00:00Z',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Anstehendes Vertragsgespräch aus einem Begehungsprotokoll.
|
||||||
|
{
|
||||||
|
id: 'rem-026',
|
||||||
|
type: ReminderType.CONTRACT_TALK,
|
||||||
|
priority: ReminderPriority.MEDIUM,
|
||||||
|
status: ReminderStatus.ACTIVE,
|
||||||
|
propertyId: 'prop-022',
|
||||||
|
propertyTitle: 'Bürofläche St.Gallen Centrum 7',
|
||||||
|
propertyCity: 'St. Gallen',
|
||||||
|
propertyDistrict: 'Centrum',
|
||||||
|
tenantName: 'Werbeatelier SG GmbH',
|
||||||
|
dueDate: '2026-06-24',
|
||||||
|
eventDate: '2026-06-24',
|
||||||
|
contractEndDate: '2027-03-31',
|
||||||
|
decisionDate: '2026-06-24',
|
||||||
|
noticePeriodMonths: 3,
|
||||||
|
contractRef: 'Mietvertrag Werbeatelier SG GmbH, 01.04.2022',
|
||||||
|
contractClause: 'Protokoll der Begehung vom 14.03.2026',
|
||||||
|
relevanceReason:
|
||||||
|
'Der Mieter hat bei der letzten Begehung eine Flächenreduktion um rund einen Drittel angesprochen. Zugesagt war ein Gespräch im zweiten Quartal — es hat bislang nicht stattgefunden.',
|
||||||
|
recommendedAction:
|
||||||
|
'Gespräch vor Ende Juni ansetzen und klären, ob die Reduktion weiterverfolgt wird. Fällt sie an, bleibt genug Zeit, die Teilfläche zusammen mit dem angrenzenden Abschnitt neu anzubieten.',
|
||||||
|
areaSqm: 410,
|
||||||
|
currentRentPerSqm: 268,
|
||||||
|
currency: 'CHF',
|
||||||
|
shadowMarketRisk: ShadowMarketRisk.MEDIUM,
|
||||||
|
schattenmarktEnabled: true,
|
||||||
|
activity: [
|
||||||
|
{ at: '2026-03-16T08:00:00Z', by: 'Ferdi', action: 'CREATED', note: 'Aus dem Begehungsprotokoll erkannt, durch Bewirtschaftung bestätigt' },
|
||||||
|
],
|
||||||
|
organizationId: 'org-wincasa',
|
||||||
|
createdAt: '2026-03-16T08:00:00Z',
|
||||||
|
updatedAt: '2026-03-16T08:00:00Z',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
@@ -2,12 +2,12 @@
|
|||||||
* Property On — Kernteam der fünf digitalen Mitarbeitenden.
|
* Property On — Kernteam der fünf digitalen Mitarbeitenden.
|
||||||
*
|
*
|
||||||
* Reihenfolge nach Arbeitsablauf, nicht alphabetisch: Ferdi erkennt die Frist,
|
* Reihenfolge nach Arbeitsablauf, nicht alphabetisch: Ferdi erkennt die Frist,
|
||||||
* Bruno bereitet den Termin vor, Livia erstellt das Exposé, Nora findet die
|
* Bruno bereitet den Termin vor, Livia recherchiert den Markt, Nora gleicht mit
|
||||||
* Nachfrage, Sina hält die Objektdaten sauber. Genau diese Reihenfolge führt
|
* dem eigenen Bestand ab, Sina hält die Objektdaten sauber. Genau diese
|
||||||
* auch das Agenten-Untermenü unter «Meine Agenten» (Runde 4, §2.2).
|
* Reihenfolge führt auch das Agenten-Untermenü unter «Meine Agenten»
|
||||||
|
* (Runde 4, §2.2; Rollen von Livia und Nora geschärft in Runde 8, §1–§2).
|
||||||
*
|
*
|
||||||
* Reto und Lea sind entfallen: Retos Aufgaben liegen vollständig bei Bruno,
|
* Reto und Lea sind entfallen: Retos Aufgaben liegen vollständig bei Bruno.
|
||||||
* Leas Angebots- und Exposé-Aufgaben bei Livia.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TeamAgent } from '../domain/teamAgent'
|
import type { TeamAgent } from '../domain/teamAgent'
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ import { DS_TEXT } from '../../lib/ds'
|
|||||||
const NORA = agentWorkspaceById('nora')!
|
const NORA = agentWorkspaceById('nora')!
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nora — Marktchancen und Leads (Runde 4, §7; Runde 5, §11–§13).
|
* Nora — interne Intelligence und Matching (Runde 4, §7; Runde 5, §11–§13;
|
||||||
|
* Runde 8, §2).
|
||||||
*
|
*
|
||||||
* Die Seite führt seit Runde 5 eine einzige Leadliste über alle drei Kanäle
|
* Die Seite führt seit Runde 5 eine einzige Leadliste über alle drei Kanäle
|
||||||
* statt zweier Reiterlisten. Jede Zeile trägt ihre Kanalherkunft, der frühere
|
* statt zweier Reiterlisten. Jede Zeile trägt ihre Kanalherkunft, der frühere
|
||||||
* Reiterbalken ist zum Filter «Kanaltyp» geworden.
|
* Reiterbalken ist zum Filter «Kanaltyp» geworden.
|
||||||
*
|
*
|
||||||
|
* Mit Runde 8 hat sich die Arbeitsteilung gedreht: die Leads im Kanal
|
||||||
|
* «KI-Signal» kommen aus Livias Marktrecherche, Nora verbindet sie mit dem
|
||||||
|
* eigenen Bestand. Die Liste bleibt bewusst dieselbe — neu ist allein die
|
||||||
|
* Spalte «Signaltyp», die Chance von Risiko trennt.
|
||||||
|
*
|
||||||
* Der Aufbau ist zugleich die Reparatur der Scroll-Navigation (§11): vorher
|
* Der Aufbau ist zugleich die Reparatur der Scroll-Navigation (§11): vorher
|
||||||
* lag der Chat-Einstieg ausserhalb des Scrollbereichs und zwei Spalten
|
* lag der Chat-Einstieg ausserhalb des Scrollbereichs und zwei Spalten
|
||||||
* scrollten getrennt darunter — deshalb wanderte Noras Chat als einziger nie
|
* scrollten getrennt darunter — deshalb wanderte Noras Chat als einziger nie
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useSearchParams } from 'react-router'
|
|||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import {
|
import {
|
||||||
TeamPageHeader,
|
TeamPageHeader,
|
||||||
|
AgentWorkspaceHero,
|
||||||
|
TOP_BAR_PX,
|
||||||
AgentWorkspaceTabs,
|
AgentWorkspaceTabs,
|
||||||
PersonnelSection,
|
PersonnelSection,
|
||||||
HistorySection,
|
HistorySection,
|
||||||
@@ -13,20 +15,29 @@ import {
|
|||||||
AGENT_SECTION_ORDER,
|
AGENT_SECTION_ORDER,
|
||||||
AGENT_SECTION_PARAM,
|
AGENT_SECTION_PARAM,
|
||||||
MY_AGENTS_LABEL,
|
MY_AGENTS_LABEL,
|
||||||
|
THOMAS_INTRO,
|
||||||
} from '../../lib/constants'
|
} from '../../lib/constants'
|
||||||
import type { AgentSection } from '../../lib/constants'
|
import type { AgentSection } from '../../lib/constants'
|
||||||
|
import { AGENT_SUPERVISOR } from '../../lib/agentWorkspaces'
|
||||||
|
|
||||||
function resolveSection(raw: string | null): AgentSection {
|
function resolveSection(raw: string | null): AgentSection {
|
||||||
return AGENT_SECTION_ORDER.find(s => s === raw) ?? AGENT_SECTIONS.PERSONNEL
|
return AGENT_SECTION_ORDER.find(s => s === raw) ?? AGENT_SECTIONS.PERSONNEL
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hauptseite «Meine Agenten».
|
* Hauptseite «Meine Agenten» — der Arbeitsplatz von Thomas (Runde 8, §3).
|
||||||
*
|
*
|
||||||
* Ersetzt die frühere Teamübersicht vollständig: Kreisgrafik, Auswertung und
|
* Die Seite beginnt jetzt wie jede andere Agentenseite mit dem Chat-Einstieg:
|
||||||
* Verbindungszusammenfassung sind entfallen. Die Seite führt stattdessen die
|
* Porträt, Name, Funktion und Eingabefeld. Beim Herunterscrollen wandert er
|
||||||
* drei bisherigen Unterseiten Personalverwaltung, Bearbeitungsverlauf und
|
* über denselben `IntersectionObserver` wie bei Ferdi, Bruno, Livia, Nora und
|
||||||
* Kanäle & Systeme unverändert als Reiter (Runde 4, §2.1).
|
* Sina in den obersten Balken — es ist buchstäblich dieselbe Komponente
|
||||||
|
* (`AgentWorkspaceHero`) und dieselbe `AgentChatStickyBar`, keine
|
||||||
|
* Thomas-Sonderlösung.
|
||||||
|
*
|
||||||
|
* Damit der Chatkopf überhaupt wegscrollen kann, scrollt der äussere Bereich
|
||||||
|
* als Ganzes. Die drei Abschnitte behalten darunter ihre eigene Höhe und ihren
|
||||||
|
* eigenen inneren Scroll — deshalb steht die Mindesthöhe am Wrapper und nicht
|
||||||
|
* in den Abschnitten selbst.
|
||||||
*
|
*
|
||||||
* Es wird immer nur der gewählte Bereich gerendert — drei lange Abschnitte
|
* Es wird immer nur der gewählte Bereich gerendert — drei lange Abschnitte
|
||||||
* untereinander wären nicht dasselbe in kürzer, sondern etwas anderes. Die
|
* untereinander wären nicht dasselbe in kürzer, sondern etwas anderes. Die
|
||||||
@@ -45,13 +56,34 @@ export default function MeineAgenten() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<TeamPageHeader title={MY_AGENTS_LABEL} />
|
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<AgentWorkspaceHero
|
||||||
|
agentId={AGENT_SUPERVISOR.id}
|
||||||
|
name={AGENT_SUPERVISOR.name}
|
||||||
|
role={AGENT_SUPERVISOR.role}
|
||||||
|
/>
|
||||||
|
|
||||||
<AgentWorkspaceTabs value={section} onChange={handleChange} />
|
<TeamPageHeader
|
||||||
|
title={MY_AGENTS_LABEL}
|
||||||
|
description={THOMAS_INTRO}
|
||||||
|
tabs={<AgentWorkspaceTabs value={section} onChange={handleChange} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Die Mindesthöhe ist kein Gestaltungsmass, sondern die Bedingung dafür,
|
||||||
|
dass der Chatkopf überhaupt wegscrollen kann: Er misst rund 400 Pixel,
|
||||||
|
und erst wenn der Inhalt darunter mindestens eine Bildschirmhöhe
|
||||||
|
einnimmt, entsteht genug Scrollweg, damit er unter den obersten Balken
|
||||||
|
rutscht und `AgentChatStickyBar` übernimmt. Bei den anderen Agenten
|
||||||
|
leistet das die lange Liste; hier hat der Abschnitt seine eigene Höhe
|
||||||
|
und scrollt innen, also muss die Bedingung ausdrücklich stehen.
|
||||||
|
*/}
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: `calc(100vh - ${TOP_BAR_PX}px)` }}>
|
||||||
{section === AGENT_SECTIONS.PERSONNEL && <PersonnelSection />}
|
{section === AGENT_SECTIONS.PERSONNEL && <PersonnelSection />}
|
||||||
{section === AGENT_SECTIONS.HISTORY && <HistorySection />}
|
{section === AGENT_SECTIONS.HISTORY && <HistorySection />}
|
||||||
{section === AGENT_SECTIONS.CHANNELS && <ChannelsSection />}
|
{section === AGENT_SECTIONS.CHANNELS && <ChannelsSection />}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,111 +1,33 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { Box } from '@mui/material'
|
||||||
import { useSearchParams } from 'react-router'
|
|
||||||
import { Box, Tab, Tabs } from '@mui/material'
|
|
||||||
import { AgentWorkspaceHero } from '../../components/team'
|
import { AgentWorkspaceHero } from '../../components/team'
|
||||||
import { LeadTable } from '../../components/expose'
|
import { ResearchWorkspace } from '../../components/research'
|
||||||
import { LoadingPage, ErrorState } from '../../components/ui'
|
|
||||||
import { useExposeLeads } from '../../hooks/useExposeLeads'
|
|
||||||
import { useProperties } from '../../hooks/useProperties'
|
|
||||||
import { ExposeLeadStatus } from '../../domain/exposeLead'
|
|
||||||
import { EXPOSE_LEAD_TABS, EXPOSE_LEAD_TAB_LABELS } from '../../lib/constants'
|
|
||||||
import type { ExposeLeadTab } from '../../lib/constants'
|
|
||||||
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||||
|
|
||||||
const LIVIA = agentWorkspaceById('livia')!
|
const LIVIA = agentWorkspaceById('livia')!
|
||||||
|
|
||||||
const PARAM_TAB = 'tab'
|
|
||||||
|
|
||||||
function resolveTab(raw: string | null): ExposeLeadTab {
|
|
||||||
return raw === EXPOSE_LEAD_TABS.ARCHIVED ? EXPOSE_LEAD_TABS.ARCHIVED : EXPOSE_LEAD_TABS.ACTIVE
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Livia — Exposé Master (Runde 4, §8).
|
* Livia — Research & Market Intelligence (Runde 8, §1).
|
||||||
*
|
*
|
||||||
* Die frühere Inserate-Seite ist vollständig ersetzt: Titel, Beschreibung, der
|
* Die Seite führt genau eine Aufgabe: die Leads, die Livia aus den verknüpften
|
||||||
* Knopf «Neues Inserat» und die Inserate-Tabelle sind entfallen. Ziel der Seite
|
* externen Quellen verdichtet hat. Die frühere Exposé-Erstellung ist mit
|
||||||
* ist die schnelle Verarbeitung von Leads, die Nora erkannt und ein
|
* Runde 8 vollständig entfallen — sie war der Kern der alten Rolle und ist mit
|
||||||
* Bewirtschafter zur Exposé-Erstellung weitergeleitet hat.
|
* ihr weggefallen, nicht bloss nach hinten gerückt.
|
||||||
|
*
|
||||||
|
* Der Aufbau ist damit derselbe wie bei den anderen Agenten: Chat-Einstieg,
|
||||||
|
* darunter die operative Liste. Kein Reiterbalken, weil es nichts zu wählen
|
||||||
|
* gibt — eine Reiterleiste mit einem Eintrag behauptet eine Alternative, die
|
||||||
|
* es nicht gibt.
|
||||||
|
*
|
||||||
|
* Der Wiederaufbau der Exposé-Funktion ist in `archive/livia/` beschrieben.
|
||||||
*/
|
*/
|
||||||
export default function MyListings() {
|
export default function MyListings() {
|
||||||
const [params, setParams] = useSearchParams()
|
|
||||||
const tab = resolveTab(params.get(PARAM_TAB))
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const { data: leads = [], isLoading, isError, refetch } = useExposeLeads()
|
|
||||||
const { data: properties = [] } = useProperties()
|
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
|
||||||
const wanted = tab === EXPOSE_LEAD_TABS.ARCHIVED ? ExposeLeadStatus.ARCHIVED : ExposeLeadStatus.ACTIVE
|
|
||||||
return leads
|
|
||||||
.filter(l => l.status === wanted)
|
|
||||||
.sort((a, b) => b.receivedAt.localeCompare(a.receivedAt))
|
|
||||||
}, [leads, tab])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Beim Öffnen gleitet die gewählte Zeile an den oberen Rand des sichtbaren
|
|
||||||
* Inhaltsbereichs; die nachfolgenden Leads rücken nach unten und dürfen aus
|
|
||||||
* dem Blickfeld verschwinden (Runde 4, §8.4). Umgesetzt mit `scrollTo` auf dem
|
|
||||||
* bestehenden Scrollcontainer — dafür braucht es keine Animationsbibliothek.
|
|
||||||
*
|
|
||||||
* Bei `prefers-reduced-motion` wird ohne Bewegung direkt gesprungen.
|
|
||||||
*/
|
|
||||||
const handleSelect = useCallback((id: string, row: HTMLElement | null) => {
|
|
||||||
const next = id === selectedId ? null : id
|
|
||||||
setSelectedId(next)
|
|
||||||
if (!next || !row || !scrollRef.current) return
|
|
||||||
|
|
||||||
const container = scrollRef.current
|
|
||||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
||||||
const top = container.scrollTop + row.getBoundingClientRect().top - container.getBoundingClientRect().top
|
|
||||||
|
|
||||||
// Nach dem Zustandswechsel scrollen, sonst misst der Browser die alte Höhe.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
container.scrollTo({ top, behavior: reduced ? 'auto' : 'smooth' })
|
|
||||||
})
|
|
||||||
}, [selectedId])
|
|
||||||
|
|
||||||
const handleTabChange = useCallback((next: ExposeLeadTab) => {
|
|
||||||
setSelectedId(null)
|
|
||||||
setParams({ [PARAM_TAB]: next })
|
|
||||||
}, [setParams])
|
|
||||||
|
|
||||||
if (isLoading) return <LoadingPage />
|
|
||||||
if (isError) return <ErrorState message="Die Leads konnten nicht geladen werden." onRetry={() => refetch()} />
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
<Box ref={scrollRef} sx={{ flex: 1, overflowY: 'auto' }}>
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
<AgentWorkspaceHero agentId={LIVIA.id} name={LIVIA.name} role={LIVIA.role} />
|
<AgentWorkspaceHero agentId={LIVIA.id} name={LIVIA.name} role={LIVIA.role} />
|
||||||
|
|
||||||
<Box sx={{ px: 3, pb: 5 }}>
|
<Box sx={{ px: 3, pb: 5 }}>
|
||||||
<Tabs
|
<ResearchWorkspace />
|
||||||
value={tab}
|
|
||||||
onChange={(_, v: ExposeLeadTab) => handleTabChange(v)}
|
|
||||||
sx={{ mb: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 600, fontSize: '0.9rem' } }}
|
|
||||||
>
|
|
||||||
{Object.values(EXPOSE_LEAD_TABS).map(value => (
|
|
||||||
<Tab key={value} value={value} label={EXPOSE_LEAD_TAB_LABELS[value]} />
|
|
||||||
))}
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<LeadTable
|
|
||||||
leads={visible}
|
|
||||||
properties={properties}
|
|
||||||
selectedId={selectedId}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
withWorkspace={tab === EXPOSE_LEAD_TABS.ACTIVE}
|
|
||||||
emptyText={
|
|
||||||
tab === EXPOSE_LEAD_TABS.ACTIVE
|
|
||||||
? 'Keine aktiven Leads. Sobald Nora ein Signal erkennt und es weitergeleitet wird, erscheint es hier.'
|
|
||||||
: 'Noch keine archivierten Leads.'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Weissraum unter der Liste, damit auch die letzte Zeile an den
|
|
||||||
oberen Rand gleiten kann. */}
|
|
||||||
<Box sx={{ height: '60vh' }} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { computeAgentWorkload } from '../agentWorkloadService'
|
|||||||
import type { AgentWorkloadInput } from '../agentWorkloadService'
|
import type { AgentWorkloadInput } from '../agentWorkloadService'
|
||||||
import { WorkloadTone } from '../../domain/agentWorkload'
|
import { WorkloadTone } from '../../domain/agentWorkload'
|
||||||
import { ReminderStatus } from '../../domain/reminder'
|
import { ReminderStatus } from '../../domain/reminder'
|
||||||
import { ExposeLeadStatus } from '../../domain/exposeLead'
|
import { LeadChannel } from '../../domain/unifiedLead'
|
||||||
|
|
||||||
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
const NOW = Date.parse('2026-08-06T12:00:00.000Z')
|
||||||
const DAY = 24 * 60 * 60 * 1000
|
const DAY = 24 * 60 * 60 * 1000
|
||||||
@@ -23,7 +23,7 @@ function iso(offsetDays: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: AgentWorkloadInput = {
|
const EMPTY: AgentWorkloadInput = {
|
||||||
reminders: [], visits: [], exposeLeads: [], leads: [], properties: [],
|
reminders: [], visits: [], leads: [], properties: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput {
|
function input(partial: Partial<AgentWorkloadInput>): AgentWorkloadInput {
|
||||||
@@ -37,6 +37,11 @@ const visit = (days: number) =>
|
|||||||
({ scheduledAt: iso(days) }) as AgentWorkloadInput['visits'][number]
|
({ scheduledAt: iso(days) }) as AgentWorkloadInput['visits'][number]
|
||||||
const property = (score: number) =>
|
const property = (score: number) =>
|
||||||
({ dataQuality: { score } }) as AgentWorkloadInput['properties'][number]
|
({ dataQuality: { score } }) as AgentWorkloadInput['properties'][number]
|
||||||
|
/** Recherche-Lead von Livia (Runde 8, §1). */
|
||||||
|
const kiLead = (days: number) =>
|
||||||
|
({ channel: LeadChannel.KI_SIGNAL, receivedAt: iso(days) }) as AgentWorkloadInput['leads'][number]
|
||||||
|
const netzwerkLead = (days: number) =>
|
||||||
|
({ channel: LeadChannel.NETZWERK, receivedAt: iso(days) }) as AgentWorkloadInput['leads'][number]
|
||||||
|
|
||||||
describe('Auslastung je Agent', () => {
|
describe('Auslastung je Agent', () => {
|
||||||
it('meldet bei Ferdi Überfälliges vor den Fristen der Woche', () => {
|
it('meldet bei Ferdi Überfälliges vor den Fristen der Woche', () => {
|
||||||
@@ -104,11 +109,27 @@ describe('Auslastung je Agent', () => {
|
|||||||
|
|
||||||
it('formuliert die Einzahl korrekt', () => {
|
it('formuliert die Einzahl korrekt', () => {
|
||||||
const w = computeAgentWorkload(
|
const w = computeAgentWorkload(
|
||||||
input({ exposeLeads: [{ status: ExposeLeadStatus.ACTIVE }] as AgentWorkloadInput['exposeLeads'] }),
|
input({ leads: [kiLead(-1)] }),
|
||||||
NOW,
|
NOW,
|
||||||
).livia
|
).livia
|
||||||
|
|
||||||
expect(w.label).toBe('1 Exposé offen')
|
expect(w.label).toBe('1 neue Marktmeldung')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runde 8, §1: Livia zählt ihre eigene Recherche, nicht jeden Lead. Ein
|
||||||
|
* Netzwerk-Hinweis oder ein CRM-Lead ist fremde Arbeit — stünde er auf ihrem
|
||||||
|
* Schild, behauptete die Zahl eine Leistung, die sie nicht erbracht hat.
|
||||||
|
*/
|
||||||
|
it('zählt bei Livia nur die selbst recherchierten Leads', () => {
|
||||||
|
const w = computeAgentWorkload(
|
||||||
|
input({ leads: [kiLead(-1), kiLead(-2), netzwerkLead(-1)] }),
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(w.livia.count).toBe(2)
|
||||||
|
// Bei Nora landen alle drei — sie gleicht jeden Kanal gegen den Bestand ab.
|
||||||
|
expect(w.nora.count).toBe(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => {
|
it('meldet bei leerer Datenlage überall Ruhe statt Lücken', () => {
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { AgentWorkItem } from '../domain/agentWorkItem'
|
||||||
|
import { AgentWorkItemStatus } from '../domain/agentWorkItem'
|
||||||
|
import type { TeamAgent } from '../domain/teamAgent'
|
||||||
|
import { mockToday } from '../lib/constants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property On — die Antworten der Agentenchats (Runde 9, §3).
|
||||||
|
*
|
||||||
|
* Bewusst eine Schlüsselwortlogik und kein Sprachmodell: Der Auftrag verlangt
|
||||||
|
* ausdrücklich keine LLM- oder RAG-Infrastruktur, und für die drei Fragetypen
|
||||||
|
* wäre sie auch nicht der Punkt. Der Punkt ist, dass die Zahlen stimmen.
|
||||||
|
*
|
||||||
|
* Jede Antwort stammt aus Beständen, die auf der Seite des jeweiligen Agenten
|
||||||
|
* ohnehin sichtbar sind. Nichts wird geschätzt, nichts hochgerechnet: Findet
|
||||||
|
* sich keine Grundlage, sagt der Agent das, statt eine plausible Zahl zu
|
||||||
|
* nennen. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort — sie
|
||||||
|
* sieht aus wie eine Auskunft.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Was gefragt wurde. */
|
||||||
|
export const ChatIntent = {
|
||||||
|
/** «Womit kannst du mir helfen?» */
|
||||||
|
CAPABILITIES: 'CAPABILITIES',
|
||||||
|
/** «Wie viele Aufgaben sind in deinem Log?» */
|
||||||
|
LOG_COUNT: 'LOG_COUNT',
|
||||||
|
/** «Wie viele Aufträge wurden letzten Monat bearbeitet?» */
|
||||||
|
LAST_MONTH: 'LAST_MONTH',
|
||||||
|
/** Nichts davon erkannt. */
|
||||||
|
UNKNOWN: 'UNKNOWN',
|
||||||
|
} as const
|
||||||
|
export type ChatIntent = typeof ChatIntent[keyof typeof ChatIntent]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennung per Schlüsselwörtern.
|
||||||
|
*
|
||||||
|
* Die Reihenfolge entscheidet: «Wie viele Aufträge wurden letzten Monat
|
||||||
|
* bearbeitet?» enthält auch das Wort «Aufträge» und würde sonst als Frage nach
|
||||||
|
* dem Log gelesen. Der Zeitbezug ist die speziellere Aussage und wird deshalb
|
||||||
|
* zuerst geprüft.
|
||||||
|
*/
|
||||||
|
export function detectIntent(text: string): ChatIntent {
|
||||||
|
const t = text.toLowerCase()
|
||||||
|
|
||||||
|
const zeitbezug = /letzte[nrs]?\s+(monat|30\s*tage)|vormonat|vergangenen\s+monat/.test(t)
|
||||||
|
const menge = /wie\s*viel|anzahl|wie\s*oft/.test(t)
|
||||||
|
|
||||||
|
if (zeitbezug && (menge || /auftr|fäll|falle|vorgäng|bearbeit|erledigt|task|aufgab/.test(t))) {
|
||||||
|
return ChatIntent.LAST_MONTH
|
||||||
|
}
|
||||||
|
if (menge && /log|aufgab|task|auftr|vorgäng|eintr|fäll|falle|pendent|offen/.test(t)) {
|
||||||
|
return ChatIntent.LOG_COUNT
|
||||||
|
}
|
||||||
|
if (/womit|was\s+kannst|wobei|wofür|hilf|helfen|kannst du|zuständig|aufgabe(n)?\s*bereich|rolle/.test(t)) {
|
||||||
|
return ChatIntent.CAPABILITIES
|
||||||
|
}
|
||||||
|
return ChatIntent.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentChatInput {
|
||||||
|
agentId: string
|
||||||
|
agentName: string
|
||||||
|
agentRole: string
|
||||||
|
/** Personaldossier des Agenten, sofern vorhanden — Thomas hat keines. */
|
||||||
|
dossier?: TeamAgent
|
||||||
|
/**
|
||||||
|
* Vorgänge aus dem Bearbeitungsverlauf. Für einen einzelnen Agenten seine
|
||||||
|
* eigenen; für Thomas als Vorgesetzten die aller Agenten.
|
||||||
|
*/
|
||||||
|
workItems: AgentWorkItem[]
|
||||||
|
/** Sind die Bestände schon geladen? Sonst wird nicht gezählt, sondern gewartet. */
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalendermonat vor dem Stichtag der Mockdaten. */
|
||||||
|
function letzterMonat(): { von: Date; bis: Date; name: string } {
|
||||||
|
const heute = mockToday()
|
||||||
|
const von = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth() - 1, 1))
|
||||||
|
const bis = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth(), 1))
|
||||||
|
return {
|
||||||
|
von,
|
||||||
|
bis,
|
||||||
|
name: von.toLocaleDateString('de-CH', { month: 'long', year: 'numeric', timeZone: 'UTC' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(n: number, ein: string, viele: string): string {
|
||||||
|
return `${n} ${n === 1 ? ein : viele}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function antwortFaehigkeiten(input: AgentChatInput): string {
|
||||||
|
const { agentName, agentRole, dossier } = input
|
||||||
|
|
||||||
|
// Der Kurzbeschrieb aus dem Personalblatt ist genau der Satz, der auch in der
|
||||||
|
// Personalverwaltung steht — der Chat soll nichts anderes behaupten als das
|
||||||
|
// Dossier.
|
||||||
|
if (dossier) {
|
||||||
|
const aufgaben = dossier.responsibilities.slice(0, 3)
|
||||||
|
return [
|
||||||
|
`Ich bin ${agentName}, ${agentRole}. ${dossier.shortDescription}`,
|
||||||
|
aufgaben.length > 0
|
||||||
|
? `Konkret kümmere ich mich unter anderem um: ${aufgaben.join('; ')}.`
|
||||||
|
: '',
|
||||||
|
'Frag mich zum Beispiel, wie viele Aufgaben in meinem Log stehen oder wie viele Aufträge ich letzten Monat bearbeitet habe.',
|
||||||
|
].filter(Boolean).join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`Ich bin ${agentName}, ${agentRole}. Ich führe die fünf digitalen Mitarbeitenden und habe ihren gesamten Bearbeitungsverlauf im Blick.`,
|
||||||
|
'Frag mich zum Beispiel, wie viele Vorgänge im Log stehen oder wie viele letzten Monat bearbeitet wurden.',
|
||||||
|
].join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function antwortLog(input: AgentChatInput): string {
|
||||||
|
const { workItems, agentId } = input
|
||||||
|
const gesamt = workItems.length
|
||||||
|
|
||||||
|
if (gesamt === 0) {
|
||||||
|
return 'In meinem Log steht derzeit kein Vorgang. Sobald einer eingeht, siehst du ihn im Bearbeitungsverlauf.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const erledigt = workItems.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length
|
||||||
|
const pendent = workItems.filter(w => w.requiresDecision).length
|
||||||
|
const wessen = agentId === 'thomas' ? 'Im Log aller Agenten stehen' : 'In meinem Log stehen'
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${wessen} ${plural(gesamt, 'Vorgang', 'Vorgänge')}.`,
|
||||||
|
`Davon ${plural(erledigt, 'erledigt', 'erledigt')}${pendent > 0 ? ` und ${plural(pendent, 'wartet auf deine Entscheidung', 'warten auf deine Entscheidung')}` : ''}.`,
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function antwortLetzterMonat(input: AgentChatInput): string {
|
||||||
|
const { workItems, agentId } = input
|
||||||
|
const { von, bis, name } = letzterMonat()
|
||||||
|
|
||||||
|
const imZeitraum = workItems.filter(w => {
|
||||||
|
const stempel = w.completedAt ?? w.createdAt
|
||||||
|
const t = new Date(stempel).getTime()
|
||||||
|
return Number.isFinite(t) && t >= von.getTime() && t < bis.getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
const wer = agentId === 'thomas' ? 'Die Agenten haben' : 'Ich habe'
|
||||||
|
|
||||||
|
if (imZeitraum.length === 0) {
|
||||||
|
return [
|
||||||
|
`Im ${name} ist in meinem Bestand kein Vorgang verzeichnet.`,
|
||||||
|
`Insgesamt liegen ${plural(workItems.length, 'Vorgang', 'Vorgänge')} im Log — die verteilen sich auf andere Monate.`,
|
||||||
|
].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const erledigt = imZeitraum.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length
|
||||||
|
return `${wer} im ${name} ${plural(imZeitraum.length, 'Vorgang', 'Vorgänge')} bearbeitet, davon ${erledigt} abgeschlossen.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Antwort auf eine Chatfrage — oder eine ehrliche Auskunft darüber, dass
|
||||||
|
* die Frage nicht verstanden wurde.
|
||||||
|
*/
|
||||||
|
export function answerAgentQuestion(input: AgentChatInput, frage: string): string {
|
||||||
|
if (input.loading) {
|
||||||
|
return 'Einen Moment — ich hole gerade meine Daten.'
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (detectIntent(frage)) {
|
||||||
|
case ChatIntent.CAPABILITIES:
|
||||||
|
return antwortFaehigkeiten(input)
|
||||||
|
case ChatIntent.LOG_COUNT:
|
||||||
|
return antwortLog(input)
|
||||||
|
case ChatIntent.LAST_MONTH:
|
||||||
|
return antwortLetzterMonat(input)
|
||||||
|
default:
|
||||||
|
return [
|
||||||
|
'Das habe ich nicht verstanden. In dieser Demo beantworte ich drei Arten von Fragen:',
|
||||||
|
'· Womit kannst du mir helfen?',
|
||||||
|
'· Wie viele Aufgaben sind in deinem Log?',
|
||||||
|
'· Wie viele Aufträge wurden letzten Monat bearbeitet?',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||