Compare commits

...
26 Commits
Author SHA1 Message Date
Benjamin SutterandClaude Opus 5 eb12119d91 docs(livia): Kostenschätzung für Sonnet 5 korrigiert
Im Kommentar zur Modellwahl stand für claude-sonnet-5 rund 0.20 USD je Lauf.
Nach den aktuellen Listenpreisen (2/10 USD je Million Token) sind es rund 0.16 —
genau das Doppelte von Haiku 4.5, nicht das Zweieinhalbfache. Die Preisbasis
steht jetzt im Kommentar, damit die Zahlen beim nächsten Preiswechsel prüfbar
bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:24:25 +02:00
Benjamin SutterandClaude Opus 5 f390caaddc fix(livia,nora): Runde 11 — Quellensynchronisation, Filter, Tabellenbreite, Objektdetail
1. Livia: die Systemverwaltung ist jetzt die einzige Quellenbasis.
   `ResearchSourcesPanel` rendert nicht mehr die statische RESEARCH_SOURCE_LIST,
   sondern die übergebenen Quellen aus `leseQuellenAus(agent.systems)` — dieselbe
   Ableitung, die auch Zählung und Lauf verwenden. Die zweite Liste ist entfernt.
   Damit erscheint ein frisch angebundenes System ohne Reload, verschwindet beim
   Deaktivieren, beim Wechsel auf «Schreiben» und beim Löschen. Die Zahl der
   aktiven Lesequellen steht neu auch nach einem Lauf da — vorher trat sie hinter
   das Ergebnis zurück, und die Wirkung eines Umschaltens war nicht sichtbar.

2. Nora: Filter nach Matchqualität und Standort in der bestehenden Filterleiste.
   Die Schwellen sind die bereits geführten SCORE_STRONG/SCORE_MODERATE — dieselben,
   nach denen sich die Farbe des Match-Werts richtet. Die Ortsliste wird aus den
   tatsächlichen Ergebnissen abgeleitet, nicht hartcodiert. Beide filtern auf dem
   bereits berechneten Wert und kombinieren mit allen bestehenden Filtern.

3. Nora: Tabelle passt ohne Querscrollen in die Content-Breite.
   `wordBreak: 'break-word'` war die Ursache der zerhackten Kopfzeilen («AKTI/ONE/N»,
   «JAH/R») — ersetzt durch Umbruch an Wortgrenzen. «Aktionen» hatte 4 % und war
   schmaler als sein eigener Kopftext; Anteile neu verteilt, Zellabstand von 1 auf
   0.75. «Miete CHF/m²/Jahr» bricht explizit nach «Miete», «Breakoutoption» heisst
   in zwei Wörtern «Breakout Option» und kann dadurch umbrechen. Keine Spalte
   entfernt, keine Skalierung.

4. Nora: Objektdetail aus der Trefferliste.
   Wiederverwendet die bestehende PropertyDetailView in derselben Schublade wie
   «Meine Objekte», geladen über die Objekt-ID. Zeilenklick und Auge öffnen
   dasselbe Objekt. Das Öffnen löst kein Matching aus, erzeugt keinen
   Protokolleintrag und erhöht die Kennzahl nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:03:04 +02:00
Benjamin SutterandClaude Opus 5 15c5719762 fix(nora): Runde 10 Prompt 4 — Befunde aus dem Integrationsdurchlauf
Vier Dinge, die erst im Browser auffielen:

- **Match-Werte von 100 % ohne Deckung.** Ein Objekt, von dem nur die
  Verfügbarkeit bekannt war, stand auf 100 %. Der Wert wird jetzt nach
  Abdeckung gedämpft: gemessen gegen die vier Kernkriterien, die immer im
  Nenner stehen. Fehlende Angaben zählen weiterhin nicht als Nichterfüllung —
  sie senken die Abdeckung, und die dämpft bis auf 40 %.
- **«Belegte Objekte einbeziehen» hatte nichts einzubeziehen.** Der Bestand
  führte kein einziges belegtes Objekt: Der Status sagte überall «bald frei»,
  obwohl 27 Objekte einen laufenden Mietvertrag tragen. Die Belegung kommt
  jetzt aus dem Vertrag — läuft er länger als zwölf Monate, ist die Fläche
  belegt. Dazu acht Objekte mit langlaufendem Vertrag ergänzt, damit der
  Schalter überhaupt etwas bewirkt.
- **Sortierbare Spaltenköpfe ohne Wirkung.** In der Trefferliste waren sie
  anklickbar und taten nichts. Die Sortierung greift jetzt, Match-absteigend
  bleibt die Vorgabe.
- **Noras Beschrieb sprach noch von Wochen-Mail und Review-Queue**, während
  Aufgaben und Systeme längst das Matching beschrieben.

Geprüft im Browser gegen den Produktionsbuild: Leaddetail auf ganzer Seite,
Matching-Lauf, Match-Spalte ohne waagrechte Bildlaufleiste, Schalter für
belegte Objekte (90 -> 98 Kandidaten), genau ein Protokolleintrag je Lauf,
Übergabe aus Livia mit genau einem selbsttätigen Lauf, Dokumentenablage samt
Textgewinnung und Reload-Persistenz, Chat ohne Seitensprung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:34:16 +02:00
Benjamin SutterandClaude Opus 5 c6011ac093 feat(nora): Runde 10 Prompt 3 — Property Matching gegen den eigenen Bestand
Nora arbeitet jetzt mit dem Objektbestand statt mit Quellen.

- Lead-Detail auf ganzer Seite statt in der Schublade; darunter der neue
  Bereich «Property Match». Derselbe Baustein wie bisher, nur mit einem
  Layoutschalter — keine zweite Detailansicht
- «Das hat Livia recherchiert» heisst «Erstelltes Suchprofil» und zeigt
  lead.semanticSearchProfile. Ein Kasten, nicht zwei: Bestandsleads fallen auf
  ihren bisherigen Text zurück
- Matching-Logik in features/matching/leadPropertyMatcher.ts: Das Suchprofil
  wird nach Merkmalen gelesen, die darin tatsächlich vorkommen, und gegen das
  Objekt geprüft. Ein fehlendes Objektmerkmal geht nicht in die Rechnung ein —
  es gilt als nicht bekannt, nicht als erfüllt. Deterministisch, kein Zufall
- Trefferliste ist dieselbe Tabelle wie «Meine Objekte», ergänzt um eine erste
  Spalte «Match». Die übrigen Spalten werden anteilig gestaucht, damit keine
  waagrechte Bildlaufleiste entsteht
- «Belegte Objekte einbeziehen», Vorgabe aus; Umschalten rechnet neu
- MatchRun als eigene Entität mit Persistenz. Genau ein Eintrag je Lauf, auch
  bei der Übergabe aus Livia — eine Sperre verhindert den zweiten Lauf durch
  Neuaufbau der Komponente
- Aus Livia übergebene Leads starten das Matching einmal; ein normal geöffneter
  Lead startet nichts
- Kennzahlen «Anzahl durchgeführte Matchings» und «Anzahl betroffene Objekte»
  kommen aus dem laufenden Stand, nicht aus dem Katalog
- Aufgaben, Systeme (ERP statt Zefix/SHAB/Webquellen) und Einstellungen auf
  das Matching zugeschnitten
- Info-Knopf erklärt den Match-Wert entlang der tatsächlichen Rechnung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:02:06 +02:00
Benjamin SutterandClaude Opus 5 314a6a36e3 test(livia): Textgewinnung aus PDF und DOCX gegen erzeugte Prüfdateien
Die Extraktion ist selbst gebaut; ein stiller Fehlschlag sähe aus wie ein
Dokument ohne Inhalt. Die Prüfdateien entstehen zur Laufzeit, damit im Test
sichtbar bleibt, welche Struktur erwartet wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:44:54 +02:00
Benjamin SutterandClaude Opus 5 30daa77937 feat(livia): Runde 10 Prompt 2 — eine Leadliste, dynamische Quellen, zeitliche Relevanz
Livia arbeitet nicht mehr neben dem Leadbestand, sondern in ihn hinein.

- Systemzugänge tragen Titel, Beschreibung und Adresse; die Liste zeigt je
  Eintrag nur noch Aktiv/Inaktiv und Lesen/Schreiben — kein Berechtigungsbalken,
  kein Auge-Icon, keine Lese-/Schreibgruppen
- Die Quellenregistry ist dynamisch: Der Lauf liest genau die aktiven
  Lesezugänge mit Adresse aus Livias Personalblatt. Für die drei gepflegten
  Quellen greift weiterhin ihr eigener Leser, für alles andere ein allgemeiner —
  eine Übersichtsseite, bis zu zehn Unterseiten, kein Spider. Freie Adressen
  werden auf http/https geprüft und gegen interne Netze gesperrt
- Manuell abgelegte PDF- und Word-Dokumente fliessen in denselben Lauf. Text
  wird beim Ablegen nativ extrahiert (ZIP + Flate über DecompressionStream,
  ohne neue Abhängigkeit), Datei und Text liegen in IndexedDB
- Neuer harter Filter «Zeitliche Relevanz»: das Modell beurteilt die
  Ereigniszeit der Veränderung, nicht das Publikationsdatum. «unknown» fällt
  bewusst nicht durch — ein erfundenes Datum wäre die schlechtere Antwort
- Quellentypen und Beobachtungsraum sind Chips mit Freitext; jeder Wert lässt
  sich einzeln entfernen, auch die vorgegebenen
- Info-Knopf bei «Zeitliche Relevanz» und «Mindestrelevanz» erklärt, wie der
  Wert entsteht — ohne erfundene Prozentgewichte
- Gefundene Leads gehen in den zentralen Signalbestand statt in eine zweite
  Ergebnisliste; die Herkunft steht am Signal (origin: RESEARCH_RUN)
- Der Lauf erzeugt einen echten Protokolleintrag mit den Zahlen des Laufs
- Lead-Detail: zeitliche Einordnung mit Fundstelle, semantisches Suchprofil bei
  Chancen mit «Matching starten», und bei Risiko ausdrücklich «Kein internes
  Objekt eindeutig zugeordnet» statt einer beliebigen Mock-Immobilie
- Aufgabenliste nach §11 bereinigt, «Unsichere Signale in Review Queue legen»
  von Nora übernommen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:42:59 +02:00
Benjamin SutterandClaude Opus 5 1c4b3f7dd8 feat(agenten): Runde 10 Prompt 1 — gemeinsame Bearbeitung für Aufgaben, Systeme und Kanäle
Aufgaben, Systeme und Kanäle werden alle drei als Liste geführt und ab jetzt
auch gleich bearbeitet: anlegen, ändern, entfernen, zu- und abschalten — dort,
wo man sie sieht. Die Maske und die drei Listengriffe stehen einmal zentral
statt dreimal je Reiter; sonst unterscheiden sie sich nach der ersten Korrektur.

- AgentEntityDialog: eine Maske für alle drei Entitäten, über den React-
  Schlüssel je Eintrag neu aufgebaut statt über einen Effekt zurückgesetzt
- agentEntityEditing: upsertById, removeById, newEntityId als reine Funktionen
- Systeme sind über Provider, Service und Hook schreibbar (saveSystems) und
  tragen neu einen Aktiv-Schalter
- Systemreiter: eine einzige Liste statt «Lesende»/«Schreibende Systemzugänge»,
  kein Einleitungsbalken mehr; Aktiv, Zugriff und Verbindung stehen je Eintrag
- Thomas steht als «Agentenverwalter» in der Navigation, zählt aber nicht als
  sechster Agent in Sitzungszimmer, Auslastung und Begrüssung
- Chatverlauf scrollt in sich, ohne die Seite springen zu lassen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 22:41:34 +02:00
Benjamin SutterandClaude Opus 5 c6f3b2d6e3 feat(agenten): Runde 9 — statisches Sitzungszimmer, kompakte Tabelle, Chats, KI-Hinweis
**Sitzungszimmer statisch.** Die Kamera folgte dem Zeiger um bis zu 7,5
Zentimeter — als Belebung gedacht, als Wackeln wahrgenommen. Entfernt ist nur
der Aufruf der Parallaxe samt der nun unbenutzten Methode; Kameraposition,
Blickziel, Sitzordnung, Bilder und Beschriftungen sind unverändert. Das
Erkennen des Agenten unter dem Zeiger und der Klick auf seinen Platz bleiben —
das ist keine Bewegung.

**«Meine Objekte» ohne Querscrollen.** Bei automatischer Spaltenbreite richtet
sich jede Spalte nach ihrem längsten Inhalt; bei elf Spalten summierte sich das
über die Fensterbreite hinaus. Jetzt feste Anteile, die zusammen 100 % ergeben,
schmaleres Zellenpolster und Wortumbruch statt `nowrap` beim Mieter. Keine
Spalte entfernt, kein Wert gekürzt.

**Chats antworten.** Bisher meldete das Absenden nur, der Chat sei nicht
angebunden. Jetzt beantwortet jeder Agent — Thomas eingeschlossen — drei
Fragetypen aus dem Bearbeitungsverlauf seiner eigenen Seite: Rolle, Anzahl
Vorgänge im Log, Vorgänge im Vormonat. Schlüsselwortlogik statt Sprachmodell,
wie gefordert.

Die Zeitfrage wird vor der Mengenfrage geprüft: «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. Findet sich im Zeitraum nichts, sagt der Agent das
und nennt zum Vergleich den Gesamtbestand — statt eine plausible Zahl zu
erfinden. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort: sie
sieht aus wie eine Auskunft.

Der Verlauf lebt nur, solange die Seite offen ist. Ein Verlauf, der Tage
überdauert, weckte die Erwartung, der Agent erinnere sich.

**KI-Hinweis überall gleich.** «KI kann Fehler machen, bitte überprüfen Sie
sämtliche Angaben» steht als eine Konstante und eine Komponente in allen sechs
Detailansichten. Bei Livia ersetzt er den Quellenvermerk und den Block
«Empfohlene nächste Handlung»; die Herkunft steht ohnehin oben bei der Quelle.

Geprüft im Browser gegen den Produktionsbau: Szene reagiert nicht mehr auf
Mausbewegung (Pixelvergleich), Tabelle 11 Spalten ohne Querscrollen
(scrollWidth = clientWidth), alle sechs Chats antworten agentenspezifisch mit
Zahlen, die den Mockdaten entsprechen, unbekannte Fragen werden sauber
abgewiesen, Hinweis in allen sechs Detailansichten vorhanden, 0 Laufzeitfehler.
423/423 Tests grün, tsc und eslint sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:52:43 +02:00
Benjamin SutterandClaude Opus 5 f2926d6bcb fix(livia): effort-Schalter nur an Modelle, die ihn kennen
Nach der Umstellung auf Haiku 4.5 scheiterte jeder Beurteilungsaufruf mit
«This model does not support the effort parameter». Der Schalter war für Opus
gesetzt und wurde bedingungslos mitgeschickt — bei einem Modell, das über eine
Umgebungsvariable frei wählbar ist, darf das nicht sein. Eine
Kostenoptimierung, die die Funktion zur Laufzeit kaputtmacht, ist keine.

Der Schalter geht jetzt nur noch an Modelle, die ihn kennen. Bewusst als
Erlaubnisliste: ein unbekanntes Modell läuft ohne ihn — das kostet etwas mehr,
funktioniert aber. Die umgekehrte Voreinstellung würde raten und scheitern.

Ergebnis des ersten vollständigen Haiku-Laufs auf Produktion: 49 Einträge, 38
verworfen, 2 Watchlist, 9 Leads, davon 5 mit hoher Relevanz — mehr als Opus 5
im Vergleichslauf am selben Bestand fand, bei einem Zehntel der Kosten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:12:28 +02:00
Benjamin SutterandClaude Opus 5 f0ef9c57d4 feat(livia): das verwendete Modell steht im Ergebnis
Die Modellwahl läuft über eine Umgebungsvariable im Hosting-Dashboard. Damit
sah man nirgends in der Anwendung, welches Modell gerade urteilt — und eine
Einstellung, die man nur im Dashboard prüfen kann, prüft niemand.

Der Refresh gibt das Modell jetzt zurück, die Kopfzeile zeigt es neben dem
Zeitstempel. Wer wissen will, ob die Umstellung angekommen ist, sieht es dort,
wo er ohnehin hinschaut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:28:04 +02:00
Benjamin SutterandClaude Opus 5 c19f071acc feat(livia): Modell und Textlänge über Umgebungsvariablen einstellbar
Das Modell stand fest im Code, obwohl es die grösste Kostenstellschraube ist —
ein Lauf über fünfzig Meldungen kostet mit Opus 5 rund 0.40 USD, mit Haiku 4.5
etwa 0.08. Wer die Anwendung betreibt, soll diese Abwägung treffen können, ohne
den Quelltext anzufassen und neu zu deployen.

`LIVIA_RESEARCH_MODEL` wählt das Modell, `LIVIA_RESEARCH_TEXT_LIMIT` die Menge
Artikeltext je Meldung — der zweite grosse Hebel, weil der Immobilienbezug einer
Firmenmeldung fast immer im ersten Absatz steht und jede Halbierung die
Eingabekosten halbiert. Beide haben Vorgabewerte; ohne gesetzte Variablen
verhält sich alles wie bisher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:18:11 +02:00
Benjamin SutterandClaude Opus 5 e986e9f274 fix(livia): ein Totalausfall der Beurteilung wird nicht mehr verschwiegen
Beim Messen des Tokenverbrauchs fiel auf, dass ein Lauf «49 Informationen
analysiert · 0 nicht relevant · 0 Watchlist · 0 relevante Entwicklungen»
meldete — was wie ein sauberer Lauf ohne Treffer aussieht. Tatsächlich war
jeder einzelne Beurteilungsaufruf mit «Your credit balance is too low»
gescheitert. Der `catch` um den Teilstapel fing den Fehler ab, damit ein
Ausfall nicht die übrigen mitreisst, und verlor dabei den Grund.

Scheitern alle Teilstapel, ist das kein leeres Ergebnis, sondern ein Ausfall.
Der Grund wird jetzt weitergereicht und erscheint als Warnung über der Liste.
Scheitern nur einzelne, bleibt es beim bisherigen Verhalten: die übrigen Leads
werden gezeigt.

Das ist die unangenehmste Fehlerklasse überhaupt — eine, die wie ein Ergebnis
aussieht. In einer Präsentation hätte niemand gemerkt, dass Livia gar nicht
gearbeitet hat.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen; der Lauf meldet den Fehler
jetzt sichtbar statt eine leere Liste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:14:36 +02:00
Benjamin SutterandClaude Opus 5 a028d1a21d feat(livia): Zefix als Marktindikator statt als Lead
Die Zefix-CSV nennt keine Firmennamen, sondern Tageszahlen je Branche. Als
Lead-Karte zwischen sechs echten Firmenmeldungen war sie ein Fremdkörper: Man
kann eine Branchenzahl nicht anrufen, und genau das verspricht eine Lead-Karte
mit «Empfohlene nächste Handlung».

Aggregierte Einträge werden weiterhin gelesen und beurteilt — die Quelle bleibt
angebunden und belegt, dass Livia auch strukturierte Daten verarbeitet —, zählen
aber als Beobachtungsposten statt als Lead. Das entspricht der «Early
Watch»-Einordnung der Vorgabe (§19), und die Zahlen der Kopfzeile gehen
weiterhin auf.

Die Quellenbeschreibung sagt jetzt selbst, warum dort keine Karte erscheint,
statt dass sich das jemand aus dem Ausbleiben erschliessen muss.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Build erfolgreich,
Live-Lauf 49 Einträge — Zefix erscheint nicht mehr als Lead, Summe aus
verworfen, Watchlist und Leads ergibt die Gesamtzahl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:07:47 +02:00
Benjamin SutterandClaude Opus 5 97b7110a44 fix(livia): Einheit an der Quellenanzeige
«Live · 24» liest sich genauso gut als «24 gefundene Leads» — beim ersten
Zeigen wurde es auch so verstanden. Die Zahl trägt jetzt ihre Einheit, mit
Einzahl bei genau einem Eintrag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:02:29 +02:00
Benjamin SutterandClaude Opus 5 31a098f219 fix(livia): eigenes Zeitlimit für die Zefix-CSV
Im Produktionslauf fiel Zefix mit «This operation was aborted» aus, während
beide Newsquellen sauber lieferten. Ursache ist die Dateigrösse: 10,5 MB,
unkomprimiert, weil der Server kein gzip anbietet. Von einem Schweizer
Anschluss dauert der Abruf 1,7 Sekunden, aus dem Rechenzentrum der Anwendung
mehr als die fünfzehn, die für die Newsquellen reichlich bemessen sind.

Die CSV bekommt deshalb dreissig Sekunden, die HTML-Abrufe behalten fünfzehn.
Beides läuft parallel, der Ausfall einer Quelle bleibt folgenlos für die
anderen — nur war es bisher immer dieselbe, die ausfiel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:16:12 +02:00
Benjamin SutterandClaude Opus 5 f58d841e7d feat(livia): drei Übersichtsseiten je Newsquelle statt einer
Eine Seite reicht nicht. Die Handelskammer erneuert ihre obersten zehn
Meldungen im Lauf eines Tages; stehen dort gerade Personalien und
Meinungsbeiträge, findet Livia zu Recht nichts, und die Demo wirkt schwach,
obwohl die Mechanik stimmt. Genau das war zwischen zwei Läufen zu beobachten.

Neu werden je Quelle drei Übersichtsseiten gelesen — bei der Handelskammer über
`…/page/N.html`, bei Greater Zurich über `?page=N` —, gedeckelt auf 24 Artikel
je Quelle. Die Artikel werden parallel geholt statt nacheinander, die
Beurteilung läuft in begrenzt parallelen Fünferstapeln.

Wirkung, gemessen am selben Tag: aus 20 gelesenen Einträgen und 1 Lead werden
49 Einträge und 8 Leads, darunter zwei mit hoher Relevanz — eine
Arealentwicklung über 38 000 m² und eine Standorteröffnung. Laufzeit 30
Sekunden statt 16, weiterhin klar innerhalb des 60-Sekunden-Limits.

Die Begrenzung der Gleichzeitigkeit ist kein Detail: alle Teilstapel 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. Ein
gescheiterter Teilstapel reisst die übrigen nicht mehr mit.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Build erfolgreich,
Live-Lauf 49 Einträge aus 3/3 Quellen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:10:58 +02:00
Benjamin SutterandClaude Opus 5 f53f3be1e8 fix(livia): Dateiendungen in den Importen der serverlosen Funktion
Erster Produktionslauf schlug mit HTTP 500 fehl, lokal lief derselbe Code
grün. Ursache: Vercel kompiliert jede Datei einzeln statt sie zu bündeln, und
weil das Projekt `"type": "module"` ist, verlangt Nodes ESM-Lader explizite
Dateiendungen in relativen Importen. Vite löst im Entwicklungsbetrieb
grosszügiger auf und verdeckt das.

Das ist genau die Klasse Fehler, die kein Typecheck und kein Test findet — nur
ein echter Aufruf gegen die deployte Funktion.

Geprüft gegen Produktion: HTTP 200, 20 Einträge aus 3/3 Quellen in 24
Sekunden, Beurteilung läuft, Originalquellen verlinkt, keine Laufzeitfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:41:48 +02:00
Benjamin SutterandClaude Opus 5 93ecc852e3 fix(livia): Beurteilung in Fünferstapeln statt einem Zwanzigerstapel
Der erste Live-Lauf mit Schlüssel deckte auf, dass ein einziger Aufruf über
alle zwanzig Einträge das Urteil verwässert. Eine Meldung über eine
zweistellige Millioneninvestition in eine neue Brauerei samt Arealentwicklung
fiel als «nicht relevant» durch; derselbe Prompt, dasselbe Modell und dieselbe
Meldung ergaben in einem Stapel von drei «Investition, hohe Relevanz, 0.85».

Eine höhere Effort-Stufe half nicht, sie machte es messbar schlechter. Der
Hebel ist die Stapelgrösse. Die Teilstapel laufen parallel — die Laufzeit ist
damit die des langsamsten und nicht deren Summe: 48 Sekunden vorher, 16 danach.

Das ist zugleich die Voraussetzung fürs Deployment: die serverlose Funktion
hat voreingestellt 10 Sekunden Zeit. `vercel.json` hebt das Limit nun
ausdrücklich auf 60 Sekunden an, was auch bei einer langsamen Quelle reicht.

Ausserdem: Der Linktext der Handelskammer trägt das Publikationsdatum vorneweg
und ein »-Zeichen hinten. Beides stand bisher im Titel, den das Modell zu lesen
bekam, und gehört dort nicht hin.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Live-Lauf 20 Einträge aus
3/3 Quellen in 17 Sekunden. Die Urteile sind stichprobenweise gegen die
Originalartikel geprüft und tragfähig — eine Bäckereieröffnung in
Westaustralien und eine Übernahme in Thailand werden zutreffend als ohne
Schweizer Flächenbezug verworfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:32:04 +02:00
Benjamin SutterandClaude Opus 5 2a0ba287f6 feat(livia): Research-PoC — drei reale Zürcher Quellen live
Livia liest jetzt tatsächlich aus dem Netz statt aus Mockdaten. Ein Knopf
«Research aktualisieren» ruft serverseitig drei Quellen ab, beurteilt die
Einträge in einem einzigen LLM-Aufruf und zeigt nur an, was plausibel zu
Flächenbedarf oder Flächenfreisetzung führen könnte.

Kein Crawler, kein Scheduler, keine Queue, keine Datenbank. Der Hoster kann
bereits serverlose Funktionen — es hat bisher nur niemand eine gebraucht.
Deshalb genügt eine einzige Datei unter `api/`, drei Abruffunktionen, eine
Normalisierung und ein Analyseaufruf.

Quellen, alle drei live geprüft:
- Zürcher Handelskammer, 10 Artikel
- Greater Zurich Area, 9 Artikel
- Zefix / Open Data Kanton Zürich, CSV direkt

Zur Zefix-Quelle eine Feststellung, die von der Aufgabenstellung abweicht: die
CSV enthält keine Firmennamen. Sie führt Tageszahlen von Neugründungen je
NOGA-Branche für den Kanton Zürich. Daraus lässt sich kein Unternehmenslead
bauen, sondern nur ein aggregiertes Frühsignal zum Zielmarkt — und genau so
wird es übergeben. Namen zu erfinden, um das erwartete Format zu treffen, wäre
die eine Sache, die Livia nie tun darf.

Die drei Quellen stehen im Frontend unter «Angebundene Kanäle & Systeme» mit
Status und anklickbarem Originallink; jeder erzeugte Lead verlinkt zusätzlich
die Seite, aus der er stammt. Die Adressen kommen aus derselben Liste, die der
Abruf benutzt — der angezeigte Link kann damit nicht von dem abweichen, was
tatsächlich gelesen wurde.

Der Textextraktor sucht den Artikeltext im engsten passenden Container. Ohne
das standen bei der Handelskammer Telefonnummern und Öffnungszeiten am Anfang
jedes Textes, und das Modell hätte die Fusszeile mitbeurteilt.

Der LLM-Schlüssel liegt ausschliesslich serverseitig in `ANTHROPIC_API_KEY`.
Fehlt er, sagt die Oberfläche offen, dass gelesen, aber nicht beurteilt wurde —
statt eine leere Liste als «nichts gefunden» auszugeben.

Für die lokale Entwicklung mountet ein Vite-Plugin dieselbe Handler-Datei, die
Vercel in der Produktion ausliefert; es gibt keine zweite Fassung des
Endpoints. `tsconfig.api.json` nimmt `api/` in `tsc -b` auf, damit ein
Tippfehler dort nicht erst beim Deployment auffällt.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün, Build
erfolgreich, Live-Lauf im Browser mit 20 real gelesenen Einträgen aus 3/3
Quellen ohne Laufzeitfehler. Die LLM-Beurteilung selbst ist mangels Schlüssel
noch ungetestet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:23:35 +02:00
Benjamin SutterandClaude Opus 5 37b6270f50 feat(thomas): freundlicheres Porträt (Runde 8, §3)
Das bisherige Bild zeigte einen ernst blickenden Mann ohne Lächeln — die
Vorgabe «visuell deutlich freundlicher» war damit nicht erfüllt, auch nachdem
Thomas den Chat-Einstieg der übrigen Agenten bekommen hatte. Ein freundlicher
Rahmen um ein strenges Gesicht ändert nichts am ersten Eindruck.

Neu ist Kachel P015 aus dem Porträtbogen des Auftraggebers: derselbe Typ
Mensch — Mann mittleren Alters, Brille, dunkelblauer Blazer — aber mit offenem
Ausdruck. Die Ähnlichkeit ist Absicht, damit die Änderung sich als
freundlicheres Bild derselben Person liest und nicht als Personalwechsel.
Bestehender Bildstil bleibt: neutraler heller Hintergrund, gleicher
Bildausschnitt, gleiche Kantenlänge wie die fünf Agentenporträts.

Herkunft und Lizenz stehen in `HERKUNFT.md`, dort auch der Hinweis auf die
begrenzte Auflösung: 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 Porträts aus den
1200 × 1200 grossen Originalen der Präsentation.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün,
Produktionsbuild erfolgreich. Im Browser kontrolliert auf Thomas' Seite und im
Meetingraum der Startseite — kein Laufzeitfehler, kein stilistischer Bruch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:15:28 +02:00
Benjamin SutterandClaude Opus 5 a219d3f2ec feat(livia)!: Exposé-Funktion vollständig entfernt
Runde 8 hat Livia zur Research- & Market-Intelligence-Agentin gemacht. Die
Exposé-Erstellung war der Kern ihrer alten Rolle und fällt mit ihr weg — sie
wird nicht nachgeordnet weitergeführt. Ein zweiter Reiter hätte die Funktion
sichtbar gelassen und die neue Positionierung damit halbiert.

Entfernt: 23 Dateien (Arbeitsbereich, dreistufiger Assistent, Dossier mit elf
Bereichen, Provider, Services, Hooks, Feldkatalog, Mockdaten, deterministischer
Textbaustein) sowie `generateExposeText` aus `IAIService` und beiden
Implementierungen.

Livias Arbeitsplatz zeigt jetzt nur noch die Recherche-Leads — ohne
Reiterbalken, weil eine Leiste mit einem Eintrag eine Alternative behauptet,
die es nicht gibt. Ihr Personalblatt, die Bearbeitungsverlaufs-Einträge und die
Teamdokumentation sind auf die neue Rolle nachgezogen; die beiden
Beispielvorgänge zeigen jetzt eine Leaderzeugung und ein Review mit
widersprüchlichen Quellen statt Lagebeschrieb und Exposétext.

Bei Nora entfällt die Mehrfachauswahl in der Objektliste: sie diente allein der
Weiterleitung an die Exposé-Erstellung. Eine Auswahl ohne Ziel wäre ein
Bedienelement, das nichts auslöst. Die Matching-Darstellung selbst bleibt
unverändert.

Der Wiederaufbau ist in `archive/livia/` beschrieben; das Manifest hält jetzt
fest, welche seiner Annahmen eingetreten sind und welche nicht — Livia bleibt
im Team, nur die Funktion ist weg.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün,
Produktionsbuild erfolgreich, Smoke-Test über sieben Seiten ohne
Laufzeitfehler, 12/12 Abnahmekriterien erfüllt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:44:08 +02:00
Benjamin SutterandClaude Opus 5 f5451f649f fix(agenten): Thomas' Chat klappt wirklich ein, Einzahl bei Livias Zählung
Der Smoke-Test der fünf betroffenen Seiten hat zwei Fehler aus Runde 8
aufgedeckt, die Typecheck, Lint und Tests nicht sehen konnten.

Thomas' Chatkopf blieb stehen, statt in den Titelbalken zu wandern. Seine Seite
bot nur 307 Pixel Scrollweg, der Chatkopf misst aber rund 400 — der
IntersectionObserver löste deshalb nie aus. Bei den anderen Agenten leistet die
lange Liste unter dem Chat den nötigen Weg; hier haben die drei
Verwaltungsbereiche ihre eigene Höhe und scrollen innen, also muss die
Bedingung ausdrücklich stehen. Die Mindesthöhe des Abschnitts ist jetzt eine
Bildschirmhöhe abzüglich des obersten Balkens; `TOP_BAR_PX` kommt dafür aus
`AgentWorkspaceHero` statt als zweite Zahl danebenzustehen.

Livias Zählzeile schrieb «1 Risiken». Einzahl und Mehrzahl werden nun für alle
drei Werte der Zeile unterschieden.

Geprüft im Browser gegen den Produktionsbuild: fünf Seiten ohne Laufzeitfehler,
Thomas' kompakter Chat erscheint nach dem Scrollen im Balken, Chance- und
Risiko-Beispiele sichtbar, alle fünf Vertragstypen bei Ferdi vorhanden, keine
Matching-Darstellung auf Livias Seite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:22:01 +02:00
Benjamin SutterandClaude Opus 5 2037e5a463 docs(agenten): Fallakte Livia und Agenten-Briefing sichern
`LIVIA_PROFIL_UND_PROMPT.md` hält Livia als Exposé Master fest, wie sie vor
Runde 8 war: Personalblatt, Aufgabenkatalog, Kanäle und Systeme, das
Exposé-Dossier mit elf Bereichen und neun Pflichtfeldern, die
`missingFacts`-Regel der Textmaschine — dazu ein System-Prompt, mit dem sich
diese Fassung ausserhalb des Repositories rekonstruieren lässt.

`archive/livia/` ergänzt die Beschreibung um die Wiederherstellung: die 23
Dateien der Exposé-Funktion als wörtliche Kopie im Original-Pfadlayout sowie
ein Manifest jeder Stelle, an der Livia in geteilten Dateien verdrahtet war.

Beides ist mit Runde 8 nicht überholt, sondern der Referenzstand: die
Exposé-Funktion wurde nicht entfernt, sondern nachgeordnet. Das Manifest
beschreibt allerdings den Rückbau eines vollständigen Entfernens und damit ein
Szenario, das so nicht eingetreten ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:35:57 +02:00
Benjamin SutterandClaude Opus 5 ba8dc39483 feat(agenten): Runde 8 — Livia recherchiert, Nora matcht, Ferdi liest Verträge
Livia wird von der Exposé-Master zur Research- & Market-Intelligence-Agentin.
Sie schaut nach draussen: verknüpfte Newsquellen, Geschäftsberichte,
Handelsregister und amtliche Publikationen werden zu qualitativen Leads
verdichtet — Unternehmen, Veränderung, Ort, Zeitpunkt, Begründung, Quellen.
Die Exposé-Funktion bleibt vollständig erhalten, ist aber nachgeordnet und
liegt im zweiten Reiter ihres Arbeitsplatzes.

Das Matching bleibt ausdrücklich bei Nora. Beide Arbeitsplätze teilen sich
dieselbe Liste (`UnifiedLeadList`) und dieselbe Detailansicht (`LeadDetail`);
bei Livia läuft sie mit `showMatching={false}`. Damit ist die Grenze im Code
gezogen und nicht bloss in der Beschriftung. Es gibt keine zweite Lead-Ablage:
ein Lead, zwei Arbeitsplätze.

Neu ist die Einordnung Chance/Risiko als Spalte in Noras Liste und als Block
in der Detailansicht, je mit Begründung und konkreter nächster Handlung. Bei
einem Risiko wird das betroffene Mietverhältnis benannt und auf internes
Re-Letting hingewiesen. `leadSignalTypeOf()` leitet die Einordnung für
Altbestände aus dem Signaltyp ab, damit keine Migration nötig ist.

Thomas bekommt denselben Chat-Einstieg wie die fünf anderen — dieselbe
Komponente, dieselbe Sticky-Mechanik, keine Sonderlösung. Dafür scrollt seine
Seite als Ganzes; die drei Verwaltungsbereiche behalten ihren inneren Scroll.

Ferdi wird vom Reminder-Agenten zur Contract Intelligence: Kündigungsfristen,
echte und unechte Optionen, Verhandlungsfenster und Vertragsgespräche. Der
Unterschied zwischen echter und unechter Option ist der Kern — die echte
Option hat eine Frist, die verfällt, die unechte nur ein Fenster, in dem
verhandelt werden sollte. Alle neuen Termine orientieren sich am
Entscheidungszeitpunkt, nicht am Vertragsende: `decisionDate` und `dueDate`
liegen bewusst davor.

Auslastungsanzeige nachgezogen: Livia zählt ihre Recherche statt offener
Exposés, Nora den Abgleich statt des Findens.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün,
Produktionsbuild erfolgreich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 00:35:31 +02:00
Benjamin SutterandClaude Opus 5 55274488d1 fix(startseite): Porträts scharf statt weich, besonders die hinteren
Sinas Bild wirkte unscharf. Die Datei ist es nicht — sie hat wie alle
anderen 512 Bildpunkte. Ihr Kreis erscheint aber nur rund 170 Bildpunkte
gross, weil sie am weitesten hinten sitzt. Bei so starker Verkleinerung
greift die Grafikkarte auf eine vorberechnete, viertel so grosse
Zwischenfassung zurück; das Ergebnis ist ein weiches Gesicht.

Drei Änderungen:

- Jedes Porträt wird beim Laden auf seine tatsächliche Anzeigegrösse
  gerechnet (Ferdi 384, Livia 275, Sina 229, Thomas 226 statt überall
  512). Die Grafikkarte zeigt damit die Vorlage nahezu Punkt für Punkt,
  die Zwischenfassung entfällt.
- Nach dem Verkleinern wird nachgeschärft, in der Stärke abhängig davon,
  wie weit verkleinert wurde. Ohne das verliert ein herunter gerechnetes
  Foto seine Zeichnung.
- Die Szene rechnet intern mindestens doppelt so fein wie der Bildschirm
  darstellt. Das kostet wenig, weil nicht laufend gezeichnet wird, und
  hilft auch Fensterprofilen und Kulisse.

Geladen wird dafür von Hand statt über den TextureLoader von three.js:
dessen Rückruf liefert die Bildmasse nicht zuverlässig, und ohne sie
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. Der quadratische Zuschnitt sitzt
jetzt ebenfalls hier statt in den Texturkoordinaten.

422 Tests, Lint und Build sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:06:01 +02:00
Benjamin SutterandClaude Opus 5 403161283c fix(startseite): saubere Skyline links, deutlich grössere Agentenbilder
Runde 7.

§1 Hintergrund. Die Häuserzeile auf der linken Seite zeigte verwässerte
Doppelstrukturen. Drei Ursachen, alle behoben:

- Die Fortsetzung lief mit Massstab 2.45 und war damit gegenüber dem
  Hauptfoto um das 2.4-fache zu gross. Der Faktor ist jetzt aus den
  Fotos gemessen (Münsterhöhe über der Wasserlinie) und beträgt 1.06.
- Die 320 Pixel breite Überblendung mischte zwei verschiedene
  Häuserreihen zu halbdurchsichtigen Geisterhäusern. In der Häuserzeile
  wird jetzt praktisch hart getrennt, in Himmel und Wasser weiterhin
  weich — dort gibt es nichts zu vermischen.
- Die Umschaltlinie lag zwischen den Dächern, sodass die Gauben des
  Hauptfotos als Reihe schwebender Klötzchen im Himmel stehen blieben.
  Sie liegt jetzt über allen Dächern beider Aufnahmen.

Dazu: Helligkeit zeilenweise statt global angeglichen, Himmel der
Fortsetzung spaltenweise entlang der echten Dachkante entfernt, fehlende
Bildbereiche über die Zeilenfarbe statt über gestreckte Randzeilen
aufgefüllt, Häuserzeile nachgeschärft.

§2 Agentenbilder. Alle Kreise deutlich grösser, tiefenlogisch gestaffelt
und nur noch knapp über der Tischkante statt frei im Raum schwebend.
Gegenüber vorher: Ferdi und Bruno 2.11×, Livia und Nora 2.22×,
Sina 2.50×, Thomas 3.18×. Ferdi und Bruno rücken näher an die vordere
Tischkante. Radius und Höhe stehen jetzt je Platz in der Sitzordnung;
die Werte sind gemeinsam durchgerechnet, sodass sich im Bild kein
einziges Kreispaar überschneidet.

Nebenbei: waagrechter Bildwinkel nach oben begrenzt, damit flache
Fenster den Raum nicht zerren; Sockelleiste vor das Wandpaneel gesetzt
gegen die ausgefranste Kante.

Geprüft bei 1600×900, 1366×768 und 1280×620: sechs Beschriftungen ohne
Überlappung, kein Querbalken, Klick auf Ferdi führt auf seine Seite,
Menü bündig am rechten Rand. 422 Tests, Lint und Build sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:52:05 +02:00
147 changed files with 11670 additions and 1342 deletions

No files matched your search

+450
View File
@@ -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.
+564
View File
@@ -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.
```
+325
View File
@@ -0,0 +1,325 @@
/**
* 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, TemporalClass } from '../../src/domain/researchLead.js'
import { passtZeitlich } from '../../src/lib/temporalFilter.js'
/**
* Der Beurteilungsauftrag (§12, erweitert in Runde 10 §§2.9/2.13).
*
* Bewusst kurz gehalten. Drei Dinge tragen die eigentliche Arbeit: der
* Relevanzbegriff, das Verbot, etwas hinzuzuerfinden, und die Trennung von
* Publikationsdatum und Ereigniszeit.
*
* Eine Funktion und keine Konstante, weil der Auftrag das heutige Datum
* enthält: «in drei Wochen» lässt sich nur gegen ein Heute beurteilen, und das
* Modell kennt seines nicht verlässlich.
*/
function systemPrompt(): string {
const heute = new Date().toISOString().slice(0, 10)
return `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":"...","temporalClass":"future","temporalEvidence":"...","eventDate":"2026-11","semanticSearchProfile":"..."}]}
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.
ZEITLICHE EINORDNUNG ("temporalClass"):
Beurteile, WANN die beschriebene Veränderung stattfindet — nicht, wann der Artikel erschienen ist.
Das Publikationsdatum ist dafür irrelevant. Heute ist ${heute}.
- "past": Die Veränderung ist bereits abgeschlossen (Umzug erfolgt, Standort eröffnet oder geschlossen, Transaktion vollzogen).
- "current": Die Veränderung läuft gerade oder steht innerhalb der nächsten 8 Wochen bevor.
- "future": Die Veränderung liegt mehr als 8 Wochen voraus ODER ist angekündigt ohne kurzfristige Umsetzung.
- "unknown": Die Quelle erlaubt keine belastbare zeitliche Aussage. Erfinde dann KEIN Datum.
Beispiel: Ein Bericht aus Q1 kündigt einen Standort für Q3 an => "future".
Beispiel: Ein heute erschienener Artikel beschreibt einen Umzug vom letzten Frühling => "past".
"temporalEvidence": die Textstelle oder Formulierung aus der Quelle, auf die sich deine Einordnung stützt. Ein Satz.
"eventDate": nur setzen, wenn die Quelle ein Datum oder einen Zeitraum nennt (z. B. "2026-11" oder "2026-11-15"). Sonst weglassen.
SEMANTISCHES SUCHPROFIL ("semanticSearchProfile"):
Nur bei EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT und NEW_COMPANY — also dort, wo Flächenbedarf entstehen könnte.
Formuliere ein zusammenhängendes Nachfrageprofil in ganzen Sätzen (4–8 Sätze), das beschreibt, welche Fläche dieses Unternehmen vermutlich sucht.
Nenne, soweit aus der Quelle ableitbar: Unternehmen, Anlass, wahrscheinliche Nutzungsart, geografischer Suchraum, Flächenbedarf, zeitlicher Horizont, Standort- und Infrastrukturanforderungen, Erreichbarkeit.
Schliesse mit einem Satz, der ausdrücklich benennt, was aus der Quelle NICHT ableitbar ist.
Erfinde nichts. Was die Quelle nicht hergibt, gehört in den Unsicherheitssatz — nicht ins Profil.
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(),
// Fehlt die zeitliche Einordnung, gilt «unknown» — das ist die ehrliche
// Antwort und nicht ein aus dem Publikationsdatum geratener Wert.
temporalClass: z.enum(['past', 'current', 'future', 'unknown']).default('unknown'),
temporalEvidence: z.string().default(''),
eventDate: z.string().optional(),
semanticSearchProfile: 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
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Zeitfenster lag (§2.9). */
temporalFiltered: 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.16 USD
* claude-haiku-4-5 ≈ 0.08 USD für eine Ja/Nein-Einordnung meist ausreichend
*
* Die Verhältnisse folgen den Listenpreisen je Million Token (Eingabe/Ausgabe):
* Opus 5 bei 5/25, Sonnet 5 bei 2/10, Haiku 4.5 bei 1/5 US-Dollar. Sonnet
* kostet damit genau das Doppelte von Haiku — hier stand bisher 0.20, was eine
* ältere Preisstufe widerspiegelte.
*/
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: systemPrompt(),
// 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[],
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung». */
temporalFilter: TemporalClass[] = [],
): Promise<AnalysisOutcome> {
if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0, temporalFiltered: 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
let temporalFiltered = 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 }
if (!passtZeitlich(r.temporalClass, temporalFilter)) { temporalFiltered += 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,
temporalClass: r.temporalClass,
temporalEvidence: r.temporalEvidence,
eventDate: r.eventDate,
semanticSearchProfile: r.semanticSearchProfile,
})
}
// 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, temporalFiltered }
}
+440
View File
@@ -0,0 +1,440 @@
/**
* 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,
}]
}
// ── Frei angebundene Quellen (Runde 10, §2.3) ─────────────────────────────────
/**
* Adressbereiche, die eine serverlose Funktion niemals abrufen soll.
*
* Der Nutzer gibt die Adresse frei ein, und die Funktion ruft sie mit den
* Rechten des Rechenzentrums ab. Ohne diese Sperre wäre das Eingabefeld ein
* Weg, interne Dienste des Hosters anzusprechen. Geprüft wird der Hostname,
* weil eine serverlose Funktion keine Namensauflösung vorab machen kann —
* das deckt die versehentlichen Fälle und die offensichtlichen Versuche ab.
*/
const PRIVATE_HOSTS =
/^(localhost$|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?$|.*\.local$|.*\.internal$)/i
export interface QuellenAdresse {
id: string
name: string
url: string
}
/** Wirft mit einer Meldung, die so in der Oberfläche stehen darf. */
export function pruefeQuellenAdresse(url: string): URL {
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new Error('Keine gültige Adresse.')
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Nur http- und https-Adressen können gelesen werden.')
}
if (PRIVATE_HOSTS.test(parsed.hostname)) {
throw new Error('Adressen im internen Netz werden nicht abgerufen.')
}
return parsed
}
/** Höchstzahl Artikel je frei angebundener Quelle — bewusst kleiner als bei den gepflegten drei. */
const GENERIC_MAX_ARTICLES = 10
/**
* Eine beliebige Seite als Recherchequelle lesen.
*
* Bewusst kein Spider: Es wird genau eine Übersichtsseite geholt. Führt sie
* Links auf Unterseiten desselben Hosts, werden bis zu zehn davon gelesen —
* sonst zählt der Text der Seite selbst als einziger Eintrag. Das reicht für
* eine Newsseite und kostet nie mehr als elf Abrufe.
*
* Was hier ausdrücklich nicht passiert: keine zweite Ebene, keine Warteschlange,
* kein Zeitplan, kein Browser. Eine frei angebundene Quelle liefert damit
* weniger als die drei gepflegten — das ist der ehrliche Preis dafür, dass
* niemand für sie einen eigenen Leser geschrieben hat.
*/
export async function fetchGenericSource(quelle: QuellenAdresse): Promise<ResearchItem[]> {
const basis = pruefeQuellenAdresse(quelle.url)
const html = await getText(quelle.url)
const $ = cheerio.load(html)
$('script, style, nav, header, footer, noscript, form').remove()
// Kandidaten: Links auf denselben Host, mindestens zwei Pfadsegmente tief.
// Eine Startseite verlinkt so ihre Artikel; Impressum und Kontakt liegen
// typischerweise flacher und fallen dadurch von selbst heraus.
const links: Artikellink[] = []
const gesehen = new Set<string>()
$('a[href]').each((_, el) => {
const href = $(el).attr('href')
if (!href) return
let ziel: URL
try {
ziel = new URL(href, basis)
} catch {
return
}
if (ziel.hostname !== basis.hostname) return
if (ziel.pathname.split('/').filter(Boolean).length < 2) return
const url = `${ziel.origin}${ziel.pathname}`
if (url === `${basis.origin}${basis.pathname}` || gesehen.has(url)) return
const title = $(el).text().replace(/\s+/g, ' ').trim()
if (title.length < 12) return
gesehen.add(url)
links.push({ url, title })
})
if (links.length > 0) {
const items = await ladeArtikel(links.slice(0, GENERIC_MAX_ARTICLES), quelle.name)
if (items.length > 0) return items
}
// Kein brauchbarer Unterseiten-Fund: die Seite selbst ist der Eintrag.
const text = extractArticleText(html)
if (text.length < 120) {
throw new Error('Die Seite lieferte zu wenig lesbaren Text.')
}
return [{
source: quelle.name,
title: cleanTitle($('h1').first().text().replace(/\s+/g, ' ').trim()) || quelle.name,
url: quelle.url,
text,
}]
}
+217
View File
@@ -0,0 +1,217 @@
/**
* 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,
fetchGenericSource,
} from '../../_lib/researchSources.js'
import type { ResearchItem } from '../../_lib/researchSources.js'
import { MODEL, analyzeResearchItems } from '../../_lib/researchAnalysis.js'
import type {
ResearchRefreshResult,
ResearchSourceResult,
TemporalClass,
} from '../../../src/domain/researchLead.js'
interface Quelle {
id: string
name: string
url: string
laden: () => Promise<ResearchItem[]>
}
/**
* Für diese drei Adressen gibt es einen eigens geschriebenen Leser.
*
* Der Schlüssel ist die Adresse und nicht die Kennung: Ein Zugang, den der
* Nutzer im Personalblatt anlegt, bekommt eine eigene Kennung, zeigt aber
* vielleicht auf dieselbe Seite. Dann soll er den guten Leser bekommen und
* nicht den allgemeinen.
*/
const SPEZIALLESER = new Map<string, () => Promise<ResearchItem[]>>([
[SOURCES.ZHK.url, fetchZhkResearch],
[SOURCES.GZA.url, fetchGreaterZurichResearch],
[SOURCES.ZEFIX.url, fetchZefixResearch],
])
/** Die gepflegten drei — Rückfall, wenn die Anfrage keine Quellen mitbringt. */
const STANDARDQUELLEN: Quelle[] = [
{ ...SOURCES.ZHK, laden: fetchZhkResearch },
{ ...SOURCES.GZA, laden: fetchGreaterZurichResearch },
{ ...SOURCES.ZEFIX, laden: fetchZefixResearch },
]
/** Was die Oberfläche mitschickt (Runde 10, §§2.3/2.6/2.9). */
interface RefreshBody {
sources?: { id: string; name: string; url: string }[]
documents?: { id: string; name: string; text: string }[]
temporalClasses?: TemporalClass[]
}
/** Höchstzahl frei angebundener Quellen je Lauf — schützt Laufzeit und Kosten. */
const MAX_SOURCES = 8
/** Höchstzahl abgelegter Dokumente je Lauf. */
const MAX_DOCUMENTS = 12
/** Wie viel Text ein abgelegtes Dokument beisteuert. */
const DOC_TEXT_LIMIT = 12_000
async function leseBody(req: IncomingMessage): Promise<RefreshBody> {
const stuecke: Buffer[] = []
for await (const chunk of req) stuecke.push(chunk as Buffer)
if (stuecke.length === 0) return {}
try {
return JSON.parse(Buffer.concat(stuecke).toString('utf8')) as RefreshBody
} catch {
// Ein unlesbarer Rumpf ist kein Grund, den Lauf abzubrechen — dann gelten
// eben die gepflegten Standardquellen.
return {}
}
}
/**
* Aus den übergebenen Adressen die Leseaufträge bauen.
*
* Genau hier wird die Quellenregistry dynamisch: Was das Personalblatt als
* aktiven Lesezugang mit Adresse führt, landet in dieser Liste — mit dem
* spezialisierten Leser, wenn es einen gibt, und sonst mit dem allgemeinen.
*/
function baueQuellen(body: RefreshBody): Quelle[] {
const angefragt = (body.sources ?? []).filter(q => q.url && q.name).slice(0, MAX_SOURCES)
if (angefragt.length === 0) return STANDARDQUELLEN
return angefragt.map((q): Quelle => {
const speziell = SPEZIALLESER.get(q.url)
return {
id: q.id,
name: q.name,
url: q.url,
laden: speziell ?? (() => fetchGenericSource(q)),
}
})
}
/**
* Abgelegte Dokumente als Einträge — dieselbe Beurteilung wie bei den Seiten.
*
* Das ist der ganze Mechanismus aus §2.6: Ein hochgeladenes PDF unterscheidet
* sich für die Analyse in nichts von einem gelesenen Artikel, sobald sein Text
* extrahiert ist. Ein zweiter Analysepfad dafür wäre eine zweite Stelle, an der
* sich die Beurteilung ändern könnte.
*/
function dokumenteAlsEintraege(body: RefreshBody): ResearchItem[] {
return (body.documents ?? [])
.filter(d => typeof d.text === 'string' && d.text.trim().length > 120)
.slice(0, MAX_DOCUMENTS)
.map(d => ({
source: 'Manuell abgelegtes Dokument',
title: d.name,
// Es gibt keine Webadresse — die Kennung des Dokuments tritt an ihre Stelle.
url: `dokument:${d.id}`,
text: d.text.slice(0, DOC_TEXT_LIMIT),
}))
}
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
}
const body = await leseBody(req)
const quellen = baueQuellen(body)
const dokumente = dokumenteAlsEintraege(body)
// Alle parallel: eine langsame Quelle soll die 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)
// Abgelegte Dokumente erscheinen als eigene Zeile in der Quellenübersicht —
// wer sie hochgeladen hat, soll sehen, dass sie tatsächlich gelesen wurden.
if (dokumente.length > 0) {
sources.push({
id: 'dokumente',
name: `Manuell abgelegte Dokumente (${dokumente.length})`,
url: '',
ok: true,
itemCount: dokumente.length,
})
}
const items = [...ergebnisse.flatMap(e => e.items), ...dokumente]
const basis: ResearchRefreshResult = {
sources,
analyzed: items.length,
discarded: 0,
watchlist: 0,
temporalFiltered: 0,
leads: [],
refreshedAt: new Date().toISOString(),
model: MODEL,
}
if (items.length === 0) {
res.statusCode = 200
res.end(JSON.stringify({
...basis,
analysisError: 'Keine der angebundenen Quellen lieferte Einträge.',
}))
return
}
try {
const { leads, discarded, watchlist, temporalFiltered } =
await analyzeResearchItems(items, body.temporalClasses ?? [])
res.statusCode = 200
res.end(JSON.stringify({ ...basis, leads, discarded, watchlist, temporalFiltered }))
} 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.',
}))
}
}
+320
View File
@@ -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,
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+416
View File
@@ -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',
}
File renamed without changes.
@@ -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>
)
}
File renamed without changes.
File renamed without changes.
+400 -1
View File
@@ -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",
+2
View File
@@ -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",
+360 -54
View File
@@ -11,7 +11,7 @@
* kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende * kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende
* («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe * («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe
* unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in * unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in
* Helligkeit angeglichen und über 300 Pixel weich überblendet. * Helligkeit angeglichen und über eine schmale Zone überblendet.
* *
* Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben * Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben
* und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine * und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine
@@ -49,7 +49,17 @@ const FRONT_WATER = 0.60
const LEFT_WATER = 0.64 const LEFT_WATER = 0.64
/** Skalierung der beiden Quellen. */ /** Skalierung der beiden Quellen. */
const FRONT_SCALE = 1.3 const FRONT_SCALE = 1.3
const LEFT_SCALE = 2.45 /**
* 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. * Ausschnitt der Fortsetzung, in Pixeln der Quelle.
@@ -61,8 +71,43 @@ const LEFT_SCALE = 2.45
* vor, nämlich im Mittelmotiv vom Tischende. * vor, nämlich im Mittelmotiv vom Tischende.
*/ */
const LEFT_CROP = { sx: 330, sw: 370 } const LEFT_CROP = { sx: 330, sw: 370 }
/** Breite der Überblendung zwischen Fortsetzung und Mittelmotiv. */ /**
const FEATHER = 320 * 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 = [ const CHROME = [
process.env.CHROME_PATH, process.env.CHROME_PATH,
@@ -90,39 +135,24 @@ const result = await page.evaluate(async (cfg) => {
const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)]) const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)])
const fW = Math.round(fImg.width * cfg.FRONT_SCALE) const fW = Math.round(fImg.width * cfg.FRONT_SCALE)
const fH = Math.round(fImg.height * cfg.FRONT_SCALE)
const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE) const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE)
const lH = Math.round(lImg.height * cfg.LEFT_SCALE)
const W = lW + fW - cfg.FEATHER // 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 const H = cfg.H
/** Mittlere Helligkeit eines Quellbereichs. */
function meanLuma(img, sy, sh) {
const c = document.createElement('canvas')
c.width = 48
c.height = 48
const cg = c.getContext('2d')
cg.drawImage(img, 0, sy, img.width, sh, 0, 0, 48, 48)
const d = cg.getImageData(0, 0, 48, 48).data
let sum = 0
for (let i = 0; i < d.length; i += 4) sum += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]
return sum / (d.length / 4)
}
// Helligkeit der Fortsetzung an das Mittelmotiv angleichen. Ohne diesen
// Abgleich bliebe trotz Überblendung ein Helligkeitssprung sichtbar.
const bandF = meanLuma(fImg, fImg.height * 0.3, fImg.height * 0.45)
const bandL = meanLuma(lImg, lImg.height * 0.3, lImg.height * 0.45)
const korrektur = Math.max(0.8, Math.min(1.25, bandF / bandL))
/** /**
* Eine Quelle als Schicht über die volle Zielhöhe: das Foto an der * Eine Quelle als Schicht, an der Wasserlinie ausgerichtet.
* Wasserlinie ausgerichtet, darüber der Himmel und darunter das Wasser aus der *
* jeweiligen Randzeile gestreckt. So deckt jede Schicht die ganze Höhe ab und * Bewusst **ohne** senkrechtes Strecken über den Bildrand hinaus. Ein
* es entsteht keine harte obere oder untere Bildgrenze. * 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, brightness) { function schicht(img, crop, scale, waterFraction) {
const sx = crop ? crop.sx : 0 const sx = crop ? crop.sx : 0
const sw = crop ? crop.sw : img.width const sw = crop ? crop.sw : img.width
const w = Math.round(sw * scale) const w = Math.round(sw * scale)
@@ -135,17 +165,55 @@ const result = await page.evaluate(async (cfg) => {
const cg = c.getContext('2d') const cg = c.getContext('2d')
cg.imageSmoothingEnabled = true cg.imageSmoothingEnabled = true
cg.imageSmoothingQuality = 'high' cg.imageSmoothingQuality = 'high'
cg.save()
if (brightness !== 1) cg.filter = `brightness(${brightness.toFixed(3)})`
cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h) cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h)
cg.restore()
// Himmel nach oben, Wasser nach unten strecken
if (y > 0) cg.drawImage(c, 0, y, w, 2, 0, 0, w, y + 2)
const unten = y + h
if (unten < H) cg.drawImage(c, 0, unten - 2, w, 2, 0, unten - 2, w, H - unten + 2)
return { canvas: c, w } 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') const cv = document.createElement('canvas')
cv.width = W cv.width = W
cv.height = H cv.height = H
@@ -153,33 +221,271 @@ const result = await page.evaluate(async (cfg) => {
g.imageSmoothingEnabled = true g.imageSmoothingEnabled = true
g.imageSmoothingQuality = 'high' g.imageSmoothingQuality = 'high'
// 1) Fortsetzung rechts, einmal verwendet, helligkeitsangeglichen. Der // 1) Beide Schichten aufbauen und die Fortsetzung zeilenweise im Ton
// Zylinder beginnt beim Mittelmotiv und läuft nach links in den Raum // angleichen, bevor irgendetwas zusammengesetzt wird.
// weiter; die Bildfolge muss dieser Richtung folgen. const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER)
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER, korrektur) const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER)
g.drawImage(fort.canvas, W - fort.w, 0) tonAngleichen(fort, vorne, W - fort.w)
// 2) Mittelmotiv links, unverändert, mit weicher rechter Flanke. // 2) Fortsetzung rechts, darüber das Mittelmotiv mit höhenabhängig weicher
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER, 1) // 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') const mid = document.createElement('canvas')
mid.width = vorne.w mid.width = vorne.w
mid.height = H mid.height = H
const mg = mid.getContext('2d') const mg = mid.getContext('2d')
mg.drawImage(vorne.canvas, 0, 0) 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.globalCompositeOperation = 'destination-in'
const mGrad = mg.createLinearGradient(0, 0, vorne.w, 0) mg.drawImage(maske, 0, 0)
mGrad.addColorStop(0, 'rgba(0,0,0,1)')
mGrad.addColorStop(1 - cfg.FEATHER / vorne.w, 'rgba(0,0,0,1)')
mGrad.addColorStop(1, 'rgba(0,0,0,0)')
mg.fillStyle = mGrad
mg.fillRect(0, 0, vorne.w, H)
g.drawImage(mid, 0, 0) g.drawImage(mid, 0, 0)
return { dataUri: cv.toDataURL('image/jpeg', 0.84), W, H, korrektur } 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), front: asDataUri(SRC_FRONT),
left: asDataUri(SRC_LEFT), left: asDataUri(SRC_LEFT),
H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, LEFT_CROP, H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, FEATHER_WEICH, BAND, LEFT_CROP, SCHAERFE,
}) })
await browser.close() await browser.close()
@@ -187,4 +493,4 @@ await browser.close()
writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64')) writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64'))
const kb = (readFileSync(OUT).length / 1024).toFixed(0) const kb = (readFileSync(OUT).length / 1024).toFixed(0)
console.log(`Panorama erzeugt: ${OUT}`) console.log(`Panorama erzeugt: ${OUT}`)
console.log(` ${result.W}x${result.H}, ${kb} KB, Helligkeitsangleichung ×${result.korrektur.toFixed(3)}`) console.log(` ${result.W}x${result.H}, ${kb} KB`)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 144 KiB

+14 -1
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+3 -3
View File
@@ -5,7 +5,7 @@ import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react'
import { useSessionStore } from '../../stores/sessionStore' import { useSessionStore } from '../../stores/sessionStore'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
import { AgentAvatar } from '../team' import { AgentAvatar } from '../team'
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces' import { AGENT_NAV_ENTRIES } from '../../lib/agentWorkspaces'
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants' import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds' import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
@@ -25,7 +25,7 @@ const ROLE_LABELS: Record<string, string> = {
* rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen, * rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen,
* die vorher im Profilmenü lagen. * die vorher im Profilmenü lagen.
* *
* Die Agentenliste kommt aus `AGENT_WORKSPACES` — derselben zentralen * Die Agentenliste kommt aus `AGENT_NAV_ENTRIES` — derselben zentralen
* Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen, * Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen,
* Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24, * Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24,
* Schritt 7). * Schritt 7).
@@ -116,7 +116,7 @@ export function GlobalNavMenu() {
<MenuSection> <MenuSection>
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} /> <MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
{/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */} {/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */}
{AGENT_WORKSPACES.map(agent => ( {AGENT_NAV_ENTRIES.map(agent => (
<SubItem key={agent.id} onClick={() => go(agent.path)}> <SubItem key={agent.id} onClick={() => go(agent.path)}>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}> <Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
{agent.name} {agent.name}
@@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest'
import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig' import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig'
import { WorkspaceType } from '../../../domain/enums' import { WorkspaceType } from '../../../domain/enums'
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants' import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants'
import { AGENT_WORKSPACES } from '../../../lib/agentWorkspaces' import { AGENT_NAV_ENTRIES } from '../../../lib/agentWorkspaces'
const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems
@@ -47,9 +47,9 @@ describe('Navigationseintrag «Meine Agenten»', () => {
expect(agenten?.path).toBe(ROUTES.SUPPLY.TEAM) expect(agenten?.path).toBe(ROUTES.SUPPLY.TEAM)
}) })
it('trägt die fünf Agenten in verbindlicher Reihenfolge', () => { it('trägt Thomas zuoberst und darunter die fünf in verbindlicher Reihenfolge', () => {
const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL)
expect(agenten?.children?.map(c => c.label)).toEqual(['Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina']) expect(agenten?.children?.map(c => c.label)).toEqual(['Thomas', 'Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina'])
}) })
it('gibt jedem Agenten Porträt-ID und Funktionsbezeichnung mit', () => { it('gibt jedem Agenten Porträt-ID und Funktionsbezeichnung mit', () => {
@@ -63,7 +63,7 @@ describe('Navigationseintrag «Meine Agenten»', () => {
it('verweist mit jedem Agenten auf eine eigene Seite', () => { it('verweist mit jedem Agenten auf eine eigene Seite', () => {
const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL) const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL)
const paths = agenten?.children?.map(c => c.path) ?? [] const paths = agenten?.children?.map(c => c.path) ?? []
expect(paths).toEqual(AGENT_WORKSPACES.map(a => a.path)) expect(paths).toEqual(AGENT_NAV_ENTRIES.map(a => a.path))
expect(new Set(paths).size).toBe(paths.length) expect(new Set(paths).size).toBe(paths.length)
}) })
+2 -2
View File
@@ -1,6 +1,6 @@
import { WorkspaceType } from '../../domain/enums' import { WorkspaceType } from '../../domain/enums'
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants' import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces' import { AGENT_NAV_ENTRIES } from '../../lib/agentWorkspaces'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { import {
Building2, Building2,
@@ -75,7 +75,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
icon: Users, icon: Users,
// Die drei Verwaltungsbereiche sind Reiter der Hauptseite geworden; // Die drei Verwaltungsbereiche sind Reiter der Hauptseite geworden;
// hier stehen ab Runde 4 die fünf digitalen Mitarbeitenden selbst. // hier stehen ab Runde 4 die fünf digitalen Mitarbeitenden selbst.
children: AGENT_WORKSPACES.map(a => ({ children: AGENT_NAV_ENTRIES.map(a => ({
path: a.path, path: a.path,
label: a.name, label: a.name,
agentId: a.id, agentId: a.id,
+234 -90
View File
@@ -1,47 +1,89 @@
import { useCallback, useMemo, useState } from 'react' import { useMemo } from 'react'
import type { ReactNode } from 'react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material' import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, Forward, MapPin, Ruler } from 'lucide-react' import { CheckCircle2, ExternalLink, MapPin, Ruler, Target } 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, TEMPORAL_CLASS_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
/**
* Ganze Seite statt Schublade (Runde 10, §3.2).
*
* Nur ein Layoutschalter, keine zweite Ansicht: Dieselben Bausteine in
* derselben Reihenfolge, nur ohne eigenen Bildlauf und mit mehr Platz.
* Eine Vollbildfassung daneben zu bauen hiesse, jede künftige Änderung
* zweimal zu machen.
*/
fullPage?: boolean
/** Was unterhalb der Leadangaben erscheint — bei Nora der Property-Match-Bereich. */
footer?: ReactNode
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
showMatching?: boolean
/**
* Übergabe an Nora (Runde 10, §2.14). Nur gesetzt, wo der Knopf hingehört —
* auf Noras eigener Seite wäre «Matching starten» hier ein zweiter Weg zu
* derselben Handlung, die dort schon im Kopf des Matching-Bereichs steht.
*/
onStartMatching?: (signalId: string) => void
}
/** /**
* 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, onStartMatching, fullPage = false, footer }: LeadDetailProps) {
return <LeadDetailBody key={lead.signal.id} lead={lead} /> return (
<LeadDetailBody
key={lead.signal.id}
lead={lead}
showMatching={showMatching}
onStartMatching={onStartMatching}
fullPage={fullPage}
footer={footer}
/>
)
} }
function LeadDetailBody({ lead }: { lead: MarketLead }) { function LeadDetailBody({ lead, showMatching, onStartMatching, fullPage, footer }: {
lead: MarketLead
showMatching: boolean
onStartMatching?: (signalId: string) => void
fullPage: boolean
footer?: ReactNode
}) {
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 +96,18 @@ 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) => { // Einzige Wahrheit ist `semanticSearchProfile`; die beiden anderen sind der
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) // Rückfall für Leads, die vor Runde 10 erfasst wurden.
}, []) const profilText = signal.semanticSearchProfile ?? signal.aiSummary ?? signal.strategicInterpretation
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' }}> // Auf der Seite scrollt die Seite, in der Schublade die Schublade.
<Box sx={fullPage ? { width: '100%' } : { 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,22 +145,117 @@ 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 {/*
ihr Gesicht statt eines generischen KI-Symbols (§7.3). */} Das erstellte Suchprofil (Runde 10, §§2.13B/3.1).
{(signal.aiSummary ?? signal.strategicInterpretation) && (
Ein Kasten, nicht zwei: Bis Runde 9 hiess er «Das hat Livia
recherchiert» und trug ihre Zusammenfassung; seit Runde 10 steht
darin das Nachfrageprofil, mit dem Nora arbeitet. Beides
nebeneinander zu zeigen wären zwei Texte über denselben Bedarf, und
beim ersten Widerspruch wüsste niemand, welcher gilt.
Der Rückfall auf `aiSummary` ist die Migration für Bestandsleads:
Sie haben kein Profil, aber den Text, der bisher an dieser Stelle
stand — er bleibt sichtbar, statt ein leeres Feld zu hinterlassen.
*/}
{profilText && (
<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 Erstelltes Suchprofil
</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, whiteSpace: 'pre-line' }}>
{signal.aiSummary ?? signal.strategicInterpretation} {profilText}
</Typography> </Typography>
{/*
«Matching starten» übergibt denselben Lead an Nora — über die
Lead-ID, nicht über eine Kopie. Das Kennzeichen im Zustand sagt
Nora, dass der Lauf sofort starten darf; wer den Lead später
normal öffnet, löst nichts von selbst aus (§2.14).
*/}
{onStartMatching && !risiko && (
<Button
variant="contained"
size="small"
startIcon={<Target size={14} aria-hidden />}
onClick={() => onStartMatching(signal.id)}
sx={{ textTransform: 'none', fontWeight: 600, mt: 1.5 }}
>
Matching starten
</Button>
)}
</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, Runde 10 §2.13A).
Seit Runde 10 steht der Kasten auch dann, wenn nichts zuordenbar ist —
mit genau diesem Satz. Ein fehlender Kasten liesse offen, ob niemand
nachgesehen hat oder ob nichts betroffen ist; eine beliebige
Mock-Immobilie zu zeigen wäre die schlechtere Antwort von beiden.
*/}
{risiko && (
<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 Objekt
</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" />
) : (
!signal.affectedTenant && (
<Typography variant="body2" sx={{ color: DS_SLATE[800] }}>
Kein internes Objekt eindeutig zugeordnet.
</Typography>
)
)}
</Box>
)}
{/* Zeitliche Einordnung mit Fundstelle (Runde 10, §2.9) */}
{signal.temporalClass && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Zeitliche Relevanz
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={TEMPORAL_CLASS_LABELS[signal.temporalClass]}
size="small"
sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[700], fontSize: '0.75rem', fontWeight: 600 }}
/>
{signal.eventDate && (
<Typography variant="caption" sx={{ color: DS_SLATE[600] }}>
Ereigniszeit laut Quelle: {signal.eventDate}
</Typography>
)}
</Box>
{signal.temporalEvidence && (
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mt: 0.75 }}>
{signal.temporalEvidence}
</Typography>
)}
</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 +273,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 +321,39 @@ function LeadDetailBody({ lead }: { lead: MarketLead }) {
)} )}
</Box> </Box>
<Divider /> {/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
{showMatching && (
{/* Passende Objekte im Portfolio */} <>
<Box> <Divider />
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}> <Box>
Passende Objekte im Portfolio ({relevant.length}) <Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
</Typography> {risiko
{relevant.length === 0 ? ( ? `Objekte für internes Re-Letting (${relevant.length})`
<Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}> : `Passende Objekte im Portfolio (${relevant.length})`}
Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen </Typography>
</Alert> {relevant.length === 0 ? (
) : ( <Alert severity="warning" sx={{ fontSize: '0.8rem', py: 0.5 }}>
<> Kein Portfolioobjekt über {MIN_MATCH_PCT}% Flächenmatch — manuelle Prüfung empfohlen
{relevant.map(({ p, fit }) => ( </Alert>
<SignalPropertyRow ) : (
key={p.id} relevant.map(({ p, fit }) => (
p={p} <SignalPropertyRow key={p.id} p={p} fit={fit} />
fit={fit} ))
selected={selectedIds.includes(p.id)} )}
onToggle={() => toggle(p.id)} </Box>
/> </>
))}
<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>
{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 />
{footer}
</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} />
@@ -0,0 +1,340 @@
/**
* Property On — Noras Property-Match-Bereich (Runde 10, §§3.3–3.8).
*
* Sitzt unterhalb der Leadangaben auf der Detailseite und beantwortet genau
* eine Frage: Welche Objekte aus dem eigenen Bestand passen zu diesem Bedarf?
*
* Drei Entscheidungen, die hier sichtbar werden:
*
* 1. **Vorher steht nichts da.** Kein vorab gerechneter Vorschlag, kein
* Beispielergebnis. Ein Matching ist eine Handlung, und was ohne Handlung
* erscheint, hätte niemand veranlasst.
* 2. **Die Liste ist dieselbe wie «Meine Objekte».** Dieselbe Tabelle,
* dieselben Filter, dieselben Spalten — ergänzt um eine erste Spalte
* «Match». Eine eigene Trefferliste zu bauen hiesse, denselben Bestand
* zweimal unterschiedlich darzustellen.
* 3. **Belegte Objekte bleiben aussen vor, bis jemand sie will.** Der Schalter
* dient der Frage, ob für eine vermietete Fläche konkurrierende Nachfrage
* besteht — eine Information, kein Vorgang.
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Alert, Box, Button, CircularProgress, Drawer, FormControlLabel, IconButton,
Popover, Switch, Typography, useMediaQuery, useTheme,
} from '@mui/material'
import { Info, Target } from 'lucide-react'
import { PropertyFilterBar } from '../supply/PropertyFilterBar'
import type { PropertyTableFilters } from '../supply/PropertyFilterBar'
import { PropertyTable } from '../supply/PropertyTable'
import { PropertyDetailView } from '../supply/PropertyDetailView'
import { ObjectDeepLink } from '../team'
import { useProperties } from '../../hooks/useProperties'
import { useRunMatching } from '../../hooks/useMatchRuns'
import type { MatchLaufErgebnis } from '../../features/matching/leadPropertyMatcher'
import type { FutureSignal } from '../../domain/futureSignal'
import type { Property } from '../../domain/property'
import { isRisiko } from '../../lib/leadSignal'
import { DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
const FOCUS_SX = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
/**
* Der Erklärtext zum Match-Wert (§3.8).
*
* Er beschreibt, was `leadPropertyMatcher.ts` tatsächlich tut. Der letzte Satz
* ist der wichtigste: Es gibt keine feste Prozentgewichtung, weil nur die
* Kriterien gerechnet werden, die sich am jeweiligen Objekt prüfen liessen.
*/
const MATCH_INFO =
'Der Match-Wert zeigt, wie gut das von Livia erstellte Nachfrageprofil mit den verfügbaren Informationen '
+ 'eines Objekts übereinstimmt. Aus dem Suchprofil werden die Anforderungen gelesen, die darin tatsächlich '
+ 'vorkommen — Standort, Fläche, Nutzungsart und, wo das Profil sie nennt, Erreichbarkeit, Parkierung, '
+ 'Ausbaustand und Sichtbarkeit. Dazu kommt immer die Verfügbarkeit. '
+ 'Fehlende Angaben werden nicht als erfüllt angenommen: Sagt der Bestand zu einem Merkmal nichts, zählt es '
+ 'nicht als Nichterfüllung, senkt aber die Abdeckung — und je weniger geprüft werden konnte, desto stärker '
+ 'wird der Wert gedämpft. Ein Objekt, von dem nur die Verfügbarkeit bekannt ist, erreicht deshalb keine hohen Werte. '
+ 'Belegte Objekte sind ausgeschlossen, solange sie nicht ausdrücklich einbezogen werden. '
+ 'Es gibt keine feste Prozentgewichtung je Kriterium, weil je Objekt unterschiedlich viel prüfbar ist.'
function MatchInfoButton() {
const [anker, setAnker] = useState<HTMLElement | null>(null)
return (
<>
<IconButton
size="small"
aria-label="Wie entsteht der Match-Wert?"
onClick={(e) => setAnker(e.currentTarget)}
sx={{ color: DS_TEXT.muted, ...FOCUS_SX }}
>
<Info size={15} />
</IconButton>
<Popover
open={anker !== null}
anchorEl={anker}
onClose={() => setAnker(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: { sx: { maxWidth: 520, p: 2, boxShadow: DS_SHADOW.panel } } }}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>
{MATCH_INFO}
</Typography>
</Popover>
</>
)
}
interface Props {
signal: FutureSignal
/**
* Aus Livias «Matching starten» gekommen (§3.3). Dann läuft das Matching
* genau einmal von selbst — beim normalen Öffnen nie.
*/
autoStart: boolean
}
export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, autoStart }: Props) {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const { data: properties = [], isLoading } = useProperties()
const runMatching = useRunMatching()
const [ergebnis, setErgebnis] = useState<MatchLaufErgebnis | null>(null)
const [includeOccupied, setIncludeOccupied] = useState(false)
const [filters, setFilters] = useState<PropertyTableFilters>({})
const [selectedId, setSelectedId] = useState<string | null>(null)
/**
* Sperre gegen einen zweiten selbsttätigen Lauf.
*
* React baut Komponenten im Entwicklungsmodus doppelt auf, und jeder
* Filterwechsel rendert neu. Ohne diese Sperre stünden im Protokoll zwei
* Einträge für eine Übergabe — und die Kennzahl zählte doppelt (§3.13).
*/
const autoGelaufen = useRef(false)
const starten = useCallback(
(belegteMit: boolean, trigger: 'USER' | 'LIVIA_HANDOVER') => {
runMatching.mutate(
{ signal, properties, options: { includeOccupied: belegteMit }, trigger },
{ onSuccess: (res) => setErgebnis(res.data.ergebnis) },
)
},
[runMatching, signal, properties],
)
useEffect(() => {
if (!autoStart || autoGelaufen.current || isLoading || properties.length === 0) return
autoGelaufen.current = true
starten(false, 'LIVIA_HANDOVER')
}, [autoStart, isLoading, properties.length, starten])
/** Umschalten rechnet neu — die Frage hat sich geändert, also auch die Antwort. */
const handleToggleOccupied = useCallback(
(checked: boolean) => {
setIncludeOccupied(checked)
if (ergebnis) starten(checked, 'USER')
},
[ergebnis, starten],
)
/** Match-Werte je Objekt — die Tabelle braucht sie als Karte. */
const scores = useMemo(() => {
if (!ergebnis) return undefined
return new Map(ergebnis.matches.map(m => [m.property.id, m.score]))
}, [ergebnis])
/**
* Auswählbare Orte, aus den Ergebnissen abgeleitet (Runde 11, §2).
*
* Keine feste Ortsliste: Was der Lauf nicht gefunden hat, soll auch nicht
* auswählbar sein — ein Filter, der auf nichts zeigt, ist irreführend.
*/
const staedte = useMemo(() => {
if (!ergebnis) return []
const gesehen = new Set<string>()
for (const m of ergebnis.matches) {
const ort = m.property.location.city?.trim()
if (ort) gesehen.add(ort)
}
return [...gesehen].sort((a, b) => a.localeCompare(b, 'de-CH'))
}, [ergebnis])
/**
* Die Trefferliste — nach Match absteigend, solange nichts anderes gewählt ist.
*
* Filterleiste und Spaltenköpfe wirken wie in «Meine Objekte»: Sie ordnen
* und schränken die Anzeige ein, ohne den Lauf zu verändern. Wer umsortiert,
* sieht dieselben Objekte in anderer Reihenfolge, nicht andere Werte.
*
* Die Sortierung muss hier passieren und nicht in der Tabelle: Die Tabelle
* bekommt ihre Zeilen fertig geordnet und hat auch in «Meine Objekte» nie
* selbst sortiert. Ohne diesen Block wären die Spaltenköpfe anklickbar und
* ohne Wirkung — schlimmer als gar keine Sortierung.
*/
const sichtbar = useMemo((): Property[] => {
if (!ergebnis) return []
const suche = filters.search?.trim().toLowerCase()
const gefiltert = ergebnis.matches
.map(m => m.property)
.filter(p => {
if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false
if (filters.availabilityStatus && p.availabilityStatus !== filters.availabilityStatus) return false
// Gefiltert wird auf dem bereits berechneten Wert — er wird hier weder
// neu gerechnet noch verändert (§2).
if (filters.minMatch !== undefined && (scores?.get(p.id) ?? 0) < filters.minMatch) return false
if (filters.city && p.location.city !== filters.city) return false
if (suche) {
const heuhaufen = `${p.title} ${p.location.city} ${p.address.street} ${p.currentTenant ?? ''}`.toLowerCase()
if (!heuhaufen.includes(suche)) return false
}
return true
})
if (!filters.sortBy) return gefiltert
const wertVon = (p: Property): number => {
switch (filters.sortBy) {
case 'area': return p.areaSqm
case 'rent': return p.rentPricePerSqm
case 'dataQuality': return p.dataQuality.score
case 'confidence': return p.confidenceScore
case 'availability': return new Date(p.availabilityDate).getTime()
default: return 0
}
}
const richtung = filters.sortDir === 'asc' ? 1 : -1
return [...gefiltert].sort((a, b) => (wertVon(a) - wertVon(b)) * richtung)
}, [ergebnis, filters, scores])
const risiko = isRisiko(signal)
return (
<Box sx={{ borderTop: `1px solid ${DS_BORDER.default}`, pt: 3, mt: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 1 }}>
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.0625rem', color: DS_TEXT.primary }}>
Property Match
</Typography>
<MatchInfoButton />
<Button
variant="contained"
size="small"
disabled={runMatching.isPending || isLoading}
startIcon={runMatching.isPending
? <CircularProgress size={13} color="inherit" />
: <Target size={14} aria-hidden />}
onClick={() => starten(includeOccupied, 'USER')}
sx={{ textTransform: 'none', fontWeight: 600, ml: { sm: 'auto' }, ...FOCUS_SX }}
>
{runMatching.isPending ? 'Wird abgeglichen …' : 'Matching starten'}
</Button>
</Box>
{/* Bei einem Risiko ist die Frage eine andere — das sagt der Text, nicht die Liste. */}
{risiko && (
<Alert severity="info" sx={{ fontSize: '0.8rem', mb: 1.5 }}>
{signal.propertyId
? 'Negatives Signal mit zugeordnetem Objekt. Das Matching zeigt hier Alternativen für eine mögliche Wiedervermietung.'
: 'Negatives Signal ohne eindeutig zugeordnetes Objekt. Das Matching zeigt, welche Flächen im Bestand zum beschriebenen Bedarf passen würden.'}
{signal.propertyId && (
<Box sx={{ mt: 0.75 }}>
<ObjectDeepLink propertyId={signal.propertyId} label="Betroffenes Objekt öffnen" />
</Box>
)}
</Alert>
)}
<FormControlLabel
control={
<Switch
size="small"
checked={includeOccupied}
disabled={runMatching.isPending}
onChange={(_, checked) => handleToggleOccupied(checked)}
/>
}
label="Belegte Objekte einbeziehen"
slotProps={{ typography: { variant: 'body2', sx: { color: DS_TEXT.secondary } } }}
sx={{ mb: 1 }}
/>
{!ergebnis ? (
<Box
sx={{
border: `1px solid ${DS_BORDER.default}`, borderRadius: 2, p: 3, textAlign: 'center',
}}
>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
Noch kein Abgleich durchgeführt. «Matching starten» vergleicht das Suchprofil mit dem
Objektbestand aus «Meine Objekte».
</Typography>
</Box>
) : ergebnis.matches.length === 0 ? (
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
Kein Objekt im Kandidatenkreis.
{!includeOccupied && ' Mit «Belegte Objekte einbeziehen» wird der Kreis grösser.'}
</Alert>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`${ergebnis.candidateCount} ${ergebnis.candidateCount === 1 ? 'Objekt' : 'Objekte'} abgeglichen · `}
{`bester Wert ${ergebnis.matches[0].score} %`}
{sichtbar.length !== ergebnis.matches.length && ` · ${sichtbar.length} nach Filter sichtbar`}
</Typography>
<PropertyFilterBar
filters={filters}
onFiltersChange={setFilters}
showMatchFilter
cities={staedte}
/>
<PropertyTable
properties={sichtbar}
isLoading={false}
isError={false}
selectedId={selectedId}
onSelect={setSelectedId}
onViewDetail={setSelectedId}
filters={filters}
onFiltersChange={setFilters}
matchScores={scores}
/>
</Box>
)}
{/*
Objektdetail aus der Trefferliste (Runde 11, §4).
Bewusst dieselbe `PropertyDetailView` in derselben Schublade wie in
«Meine Objekte» — geladen über die Objekt-ID, nicht über Titel oder
Listenposition. Eine zweite Detailansicht für denselben Datensatz wäre
eine zweite Stelle, an der Objektangaben auseinanderlaufen können.
Die Schublade legt sich über die Seite, statt sie zu ersetzen: Der
Matching-Kontext bleibt darunter stehen, und das Schliessen führt
zurück in die unveränderte Trefferliste. Es läuft dabei kein Matching —
`ergebnis` wird nicht angerührt, also entsteht weder ein
Protokolleintrag noch ein Zähler-Zuwachs.
*/}
<Drawer
anchor="right"
open={selectedId !== null}
onClose={() => setSelectedId(null)}
slotProps={{
paper: {
sx: {
width: isMobile ? '100vw' : { md: 520, lg: 580, xl: 640 },
boxShadow: '-4px 0 24px rgba(0,0,0,0.10)',
},
},
}}
>
{selectedId && (
<PropertyDetailView propertyId={selectedId} onClose={() => setSelectedId(null)} />
)}
</Drawer>
</Box>
)
})
+49 -14
View File
@@ -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,8 +55,17 @@ const LeadRow = memo(function LeadRow({
transition: 'background-color 0.1s', transition: 'background-color 0.1s',
}} }}
> >
{showChannel && (
<Box>
<LeadChannelBadge channel={lead.channel} />
</Box>
)}
{/* Signaltyp — nur Recherche-Leads tragen eine Einordnung (§2). */}
<Box> <Box>
<LeadChannelBadge channel={lead.channel} /> {lead.leadSignalType
? <LeadSignalTypeBadge type={lead.leadSignalType} />
: <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>—</Typography>}
</Box> </Box>
<Box sx={{ minWidth: 0 }}> <Box sx={{ minWidth: 0 }}>
@@ -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}
/> />
))} ))}
+4
View File
@@ -6,6 +6,10 @@ 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'
export { PropertyMatchSection } from './PropertyMatchSection'
@@ -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,138 @@
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 type { ResearchSourceDescriptor } from '../../lib/researchSources'
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
interface Props {
/**
* Die Quellen, die der nächste Lauf abrufen wird.
*
* Kommen von aussen und nicht aus einer eigenen Liste: Bis Runde 11 stand
* hier `RESEARCH_SOURCE_LIST` — eine zweite, im Code gepflegte Aufzählung.
* Dadurch zeigte das Panel die drei Standardquellen, während die Zählung
* daneben bereits vier meldete und ein frisch angebundenes System nirgends
* auftauchte. Eine Anzeige, die ihre eigene Wahrheit führt, ist genau das
* Gegenteil dessen, was dieses Panel leisten soll.
*/
sources: ResearchSourceDescriptor[]
/** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */
results?: ResearchSourceResult[]
}
/**
* «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15).
*
* Zeigt die 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.
*
* Welche Quellen das sind, entscheidet das Personalblatt — die Liste kommt als
* Eigenschaft herein und stammt aus derselben Ableitung, die auch der Lauf
* verwendet (Runde 11, §1).
*
* 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({ sources, 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 &amp; Systeme
</Typography>
</Box>
{sources.length === 0 && (
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Keine aktive Lesequelle mit Adresse angebunden. Quellen werden im Personalblatt unter
«Kanäle &amp; Systeme» erfasst.
</Typography>
</Box>
)}
{sources.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,224 @@
import { useCallback, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
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 { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
import { useResearchDocuments } from '../../hooks/useResearchDocuments'
import { useTeamAgent } from '../../hooks/useTeamAgents'
import { leseQuellenAus } from '../../lib/agentSystems'
import { LeadChannel } from '../../domain/unifiedLead'
import type { UnifiedLead } from '../../domain/unifiedLead'
import { LeadSignalType } from '../../domain/futureSignal'
import { TemporalClass } from '../../domain/researchLead'
import type { TemporalClass as TemporalClassValue } from '../../domain/researchLead'
import { ROUTES } from '../../lib/constants'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
/** Kennung der Einstellung, die die erlaubten Ereigniszeiten führt (§2.9). */
const TEMPORAL_SETTING_ID = 'livia-set-temporal-relevance'
const GUELTIGE_KLASSEN = new Set<string>(Object.values(TemporalClass))
/**
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15; Runde 10, §2.12).
*
* Seit Runde 10 gibt es hier **eine** Leadliste. Bis dahin standen die frisch
* gelesenen Leads als eigene Karten über dem erfassten Bestand — technisch
* sauber getrennt, in der Benutzung aber unbrauchbar: Dieselbe Firma sah oben
* anders aus als unten, die Filter griffen nur auf der einen Hälfte, und Nora
* bekam die neuen Leads gar nicht zu sehen.
*
* Jetzt wandert ein Lauf direkt in den zentralen Signalbestand. Was oben steht,
* ist nur noch der Bericht über den Lauf selbst: welche Quellen antworteten,
* wie viel gelesen und wie viel verworfen wurde. Die Leads stehen dort, wo alle
* Leads stehen.
*
* Welche Quellen abgerufen werden, entscheidet nicht diese Komponente, sondern
* Livias Personalblatt: jeder aktive Lesezugang mit Adresse, dazu die manuell
* abgelegten Dokumente. Damit ist die Anzeige unter «Systeme» keine Behauptung,
* sondern die Eingabe des Laufs.
*/
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 { data: livia } = useTeamAgent('livia')
const { data: dokumente = [] } = useResearchDocuments()
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])
/** Die Lesequellen kommen aus dem gespeicherten Stand, nicht aus einer festen Liste. */
const quellen = useMemo(() => leseQuellenAus(livia?.systems ?? []), [livia?.systems])
/** Die erlaubten Ereigniszeiten stehen als Einstellung am Personalblatt. */
const zeitfenster = useMemo((): TemporalClassValue[] => {
const wert = livia?.settings.find(s => s.id === TEMPORAL_SETTING_ID)?.value
if (!Array.isArray(wert)) return []
return wert.filter((v): v is TemporalClassValue => GUELTIGE_KLASSEN.has(v))
}, [livia?.settings])
const navigate = useNavigate()
/**
* Übergabe an Nora (Runde 10, §2.14).
*
* Weitergereicht wird nur die Lead-ID — Nora liest denselben Eintrag aus
* demselben Bestand, es entsteht keine Kopie. Das Kennzeichen `matching=1`
* sagt ihr, dass der Lauf sofort starten darf; ohne dieses Kennzeichen läuft
* beim normalen Öffnen nichts von selbst.
*
* Beides steht in der Adresse und nicht im Navigationszustand: So überlebt
* die Übergabe ein Neuladen, und der Link lässt sich weitergeben.
*/
const handleStartMatching = useCallback(
(signalId: string) => {
navigate(`${ROUTES.SUPPLY.AGENT_NORA}?lead=${encodeURIComponent(signalId)}&matching=1`)
},
[navigate],
)
const closeDetail = useCallback(() => setSelected(null), [])
const starten = useCallback(
() => refresh.mutate({ sources: quellen, documents: dokumente, temporalClasses: zeitfenster }),
[refresh, quellen, dokumente, zeitfenster],
)
const keineQuellen = quellen.length === 0 && dokumente.length === 0
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── Der Lauf ────────────────────────────────────────────────────── */}
<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 || keineQuellen}
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>
)}
{/*
Die Zahl steht immer da, auch nach einem Lauf (Runde 11, §1.8).
Vorher trat sie hinter das Ergebnis zurück — wer danach ein System
ab- oder zuschaltete, sah die Wirkung nirgends. Sie kommt aus
derselben Ableitung wie die Liste darunter und wie der Lauf selbst;
eine gepflegte Zählvariable gibt es nicht.
*/}
{!refresh.isPending && !keineQuellen && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`}
{dokumente.length > 0 &&
` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`}
</Typography>
)}
</Box>
{!refresh.isPending && ergebnis && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`Letzter Lauf: ${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
{`${ergebnis.discarded} nicht relevant · `}
{`${ergebnis.watchlist} Watchlist · `}
{ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `}
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead übernommen' : 'Leads übernommen'}`}
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
{ergebnis.model && ` · Modell ${ergebnis.model}`}
</Typography>
)}
{keineQuellen && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt.
Quellen werden im Personalblatt unter «Kanäle &amp; Systeme» angebunden, Dokumente unter
«Einstellungen».
</Alert>
)}
<ResearchSourcesPanel sources={quellen} 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 && !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.
{ergebnis.temporalFiltered > 0 &&
` ${ergebnis.temporalFiltered} davon fielen durch die eingestellte zeitliche Relevanz.`}
</Alert>
)}
</Box>
{/* ── Die eine Leadliste ──────────────────────────────────────────── */}
<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 Leads. «Research aktualisieren» liest die angebundenen Quellen und legt gefundene Leads hier ab."
/>
</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}
onStartMatching={handleStartMatching}
/>
)}
</Drawer>
</Box>
)
}
+5
View File
@@ -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'
+13 -4
View File
@@ -159,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
@@ -169,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]
@@ -187,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)
+22 -23
View File
@@ -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. */
@@ -61,13 +57,20 @@ export const CAMERA = {
/** Knapp hinter der vorderen Tischkante. */ /** Knapp hinter der vorderen Tischkante. */
z: 1.9, z: 1.9,
fov: 48, fov: 48,
/**
* Obergrenze des waagrechten Bildwinkels in Grad. Greift nur bei sehr breiten
* 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. */ /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
lookAt: [0, 1.24, -5.2] as const, lookAt: [0, 1.24, -5.2] as const,
/** /*
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die * Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20). * 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. */
@@ -82,23 +85,19 @@ export const TABLE = {
zFar: -5.75, 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 = {
/** /**
* Höhe der Porträtkreise über dem Boden. * Luft zwischen Tischplatte und Unterkante des Porträtkreises (Runde 7, §2).
* *
* Bewusst deutlich über der Blickachse der Kamera (1.19 m). Lagen die Kreise * Daraus ergibt sich die Kopfhöhe: `TABLE.top + radius + headGap`. Die Kreise
* auf Augenhöhe, projizierten alle fünf auf fast dieselbe Bildhöhe und die * schweben damit nur knapp über der Tischkante statt weit darüber. Vorher
* hinteren verschwanden hinter den vorderen — unabhängig davon, wie weit man * hingen sie fest auf 1.52 m — bei den jetzt deutlich grösseren Kreisen sähe
* sie seitlich auseinanderzog. Erst der Höhenunterschied zur Blickachse * das aus, als schwebten Köpfe frei im Raum.
* trennt sie in der Tiefe (§3.2). *
* 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.
*/ */
headY: 1.52, headGap: 0.05,
headRadius: 0.142,
/** Torso vom Stuhl bis zum Hals. */
torsoTop: 1.12,
torsoBottom: 0.42,
torsoRadiusTop: 0.19,
torsoRadiusBottom: 0.25,
shoulderWidth: 0.46,
} as const } as const
+170 -55
View File
@@ -41,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
@@ -123,7 +122,9 @@ 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)
} }
@@ -148,16 +149,27 @@ function addRoomShell(scene: THREE.Scene) {
function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) { function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAnisotropy: number) {
/** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */ /** Abstand der Kulisse. Weit genug, dass die Perspektive sie als Ferne liest. */
const radius = 62 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 * Abgedeckter Bogen. Er beginnt rechts neben der Frontscheibe und läuft über
* die Front nach links bis hinter die linke Glasfassade — mehr braucht es * die Front nach links bis hinter die linke Glasfassade — mehr braucht es
* nicht, und weniger Bogen heisst mehr Bildpunkte je Grad. * 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 = 2.62 const arc = 1.514
/** Höhe passend zum Seitenverhältnis des Panoramas (1523 × 660). */ /** Höhe passend zum Seitenverhältnis des Panoramas. */
const height = (radius * arc) / (1523 / 660) const height = (radius * arc) / (panorama.breite / panorama.hoehe)
/** Wasserlinie im Panorama, als Anteil von oben. */ /** Wasserlinie im Panorama, als Anteil von oben. */
const waterFraction = 400 / 660 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
@@ -179,7 +191,7 @@ function addBaselBackdrop(scene: THREE.Scene, loader: THREE.TextureLoader, maxAn
mesh.position.y = waterY - height * (0.5 - waterFraction) mesh.position.y = waterY - height * (0.5 - waterFraction)
/** /**
* Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen. * Drehung: geradeaus sichtbar ist die Bildstelle u = (π − Drehung) / Bogen.
* Der Abzug von 0.68 setzt damit u ≈ 0.26 in die Blickachse — dort steht im * Der Abzug von 0.68 setzt damit u ≈ 0.45 in die Blickachse — dort steht im
* Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom * Panorama das Münster. Die Frontscheibe zeigt so das gute Motiv vom
* Tischende, die linke Fassade seine Fortsetzung nach links. * Tischende, die linke Fassade seine Fortsetzung nach links.
*/ */
@@ -279,6 +291,113 @@ function addTable(scene: THREE.Scene) {
scene.add(shadow) scene.add(shadow)
} }
/**
* Nachschärfen nach dem Verkleinern (Unschärfemaske).
*
* Beim Herunterrechnen eines Fotos verschwimmen Kanten — Wimpern, Brillenrand,
* einzelne Haare. Das Bild minus seiner weichgezeichneten Fassung ergibt genau
* diese Kantenzeichnung; verstärkt zurückgegeben holt sie die Zeichnung wieder
* heran. Jedes Bildbearbeitungsprogramm macht das nach einer Verkleinerung.
*
* Die Stärke richtet sich danach, wie weit verkleinert wurde: wer kaum
* verkleinert, braucht kaum Schärfung, sonst wirkt das Ergebnis überzeichnet.
*/
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). * Ein Agent am Tisch (§3).
* *
@@ -286,21 +405,16 @@ function addTable(scene: THREE.Scene) {
* keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind * keine Arme. Die früheren Sitzmöbel und Rümpfe wirkten unnatürlich und sind
* ersatzlos entfallen (§3.1). * ersatzlos entfallen (§3.1).
* *
* Das Porträt hängt in einer Kreisfläche. Weil die Vorlagen unterschiedliche
* Seitenverhältnisse haben, wird die Textur auf einen mittigen quadratischen
* Ausschnitt begrenzt: ohne diese Beschneidung zieht die Kreisgeometrie ein
* liegendes Foto in die Breite, was Gesichter verzerrt und Schärfe kostet (§3.3).
*
* Das Porträt schaut zum Betrachter. Voll zur Tischmitte gedreht sähe man von * 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 * einem frontalen Porträtfoto nur die Kante; eine leichte Drehung zum Tisch
* genügt, damit die Runde zusammengehört. * 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,
maxAnisotropy: 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)
@@ -310,40 +424,27 @@ function addSeatedAgent(
: seat.side === SeatSide.LEFT ? 0.42 : seat.side === SeatSide.LEFT ? 0.42
: -0.42 : -0.42
// Radius und Höhe stammen vom Platz: näher sitzende Agenten haben einen
// kleineren Kreis, erscheinen im Bild aber grösser (Runde 7, §2).
const radius = seat.radius
const kopfHoehe = TABLE.top + radius + SEATED.headGap
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.016, 48), 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, t => { const tex = ladePortraet(photoUrl, seat, leinwandHoehe, maxAnisotropy)
// Mittigen quadratischen Ausschnitt wählen, sobald die Masse bekannt sind.
const { width, height } = t.image as { width: number; height: number }
if (width > height) {
t.repeat.set(height / width, 1)
t.offset.set((1 - height / width) / 2, 0)
} else if (height > width) {
t.repeat.set(1, width / height)
t.offset.set(0, (1 - width / height) / 2)
}
t.needsUpdate = true
})
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 face = new THREE.Mesh( const face = new THREE.Mesh(
new THREE.CircleGeometry(SEATED.headRadius, 48), new THREE.CircleGeometry(radius, 96),
new THREE.MeshBasicMaterial({ map: tex }), new THREE.MeshBasicMaterial({ map: tex }),
) )
head.add(face) head.add(face)
@@ -360,12 +461,12 @@ function addSeatedAgent(
* Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was * Klick auf Livia bei Ferdi. Die Trefferfläche deckt sich jetzt mit dem, was
* man sieht. * man sieht.
*/ */
new THREE.SphereGeometry(SEATED.headRadius * 1.18, 12, 8), 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 = SEATED.headY hitBox.position.y = kopfHoehe
group.add(hitBox) group.add(hitBox)
scene.add(group) scene.add(group)
@@ -380,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,
), ),
} }
@@ -394,7 +495,15 @@ 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()
@@ -425,12 +534,19 @@ export function createMeetingRoomScene(
addGlassFacades(scene) addGlassFacades(scene)
addTable(scene) addTable(scene)
const handles = seats.map(seat => /*
addSeatedAgent(scene, loader, seat, photos[seat.id], maxAnisotropy), * 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)
@@ -442,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)
@@ -459,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()
}, },
+35 -17
View File
@@ -39,34 +39,54 @@ 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). * 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, * Ferdi und Bruno sind die direkten Nachbarn des Betrachters — einer links,
* einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dadurch * einer rechts, schräg frontal und dicht an der vorderen Tischkante. Dahinter
* entsteht hinter ihnen Platz, in dem die übrigen drei gestaffelt Raum haben, * staffeln sich die übrigen drei.
* ohne sich zu überlagern.
* *
* Vorher standen alle fünf zu weit hinten und drängten sich am Fluchtpunkt. * 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, { side: SeatSide; x: number; z: number }> = { const PLAETZE: Record<string, Platz> = {
ferdi: { side: SeatSide.LEFT, x: -1.14, z: -1.20 }, ferdi: { side: SeatSide.LEFT, x: -1.16, z: -0.88, radius: 0.268 },
bruno: { side: SeatSide.RIGHT, x: 1.14, z: -1.20 }, bruno: { side: SeatSide.RIGHT, x: 1.16, z: -0.88, radius: 0.268 },
livia: { side: SeatSide.LEFT, x: -1.42, z: -2.90 }, livia: { side: SeatSide.LEFT, x: -1.32, z: -3.35, radius: 0.345 },
nora: { side: SeatSide.RIGHT, x: 1.42, z: -2.90 }, nora: { side: SeatSide.RIGHT, x: 1.32, z: -3.35, radius: 0.345 },
sina: { side: SeatSide.LEFT, x: -1.30, z: -4.75 }, sina: { side: SeatSide.LEFT, x: -0.95, z: -5.50, radius: 0.395 },
} }
/** /**
* Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt * Notplatz für einen Agenten ohne Eintrag oben — sichtbar hinten rechts statt
* lautlos im Nichts. * lautlos im Nichts.
*/ */
const FALLBACK = { side: SeatSide.RIGHT, x: 1.30, z: -4.75 } const FALLBACK: Platz = { side: SeatSide.RIGHT, x: 0.95, z: -5.50, radius: 0.395 }
/** Kopfende gegenüber. Unverändert: die Position wirkt gut (§3.2). */ /**
const HEAD_Z = -6.75 * 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
* (§2). Grösser darf er nicht werden — sein Kreis überträfe im Bild sonst den
* 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 => ({ ...AGENT_WORKSPACES.map(agent => ({
@@ -81,8 +101,6 @@ export const MEETING_SEATS: MeetingSeat[] = [
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. */}
+71 -2
View File
@@ -10,6 +10,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { AssetType, AvailabilityStatus } from '../../domain/enums' import { AssetType, AvailabilityStatus } from '../../domain/enums'
import { getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers' import { getAssetTypeLabel, getAvailabilityLabel } from './propertyHelpers'
import { SCORE_MODERATE, SCORE_STRONG } from '../../lib/constants'
import { DS_BRAND } from '../../lib/ds' import { DS_BRAND } from '../../lib/ds'
export interface PropertyTableFilters { export interface PropertyTableFilters {
@@ -18,13 +19,47 @@ export interface PropertyTableFilters {
availabilityStatus?: string availabilityStatus?: string
sortBy?: 'dataQuality' | 'availability' | 'area' | 'rent' | 'confidence' sortBy?: 'dataQuality' | 'availability' | 'area' | 'rent' | 'confidence'
sortDir?: 'asc' | 'desc' sortDir?: 'asc' | 'desc'
/**
* Mindest-Match in Prozent (Runde 11, §2).
*
* Nur in Noras Trefferliste gesetzt — in «Meine Objekte» gibt es keinen
* Match-Wert, und ein Filter ohne Datengrundlage wäre ein Bedienelement,
* das nichts tut. Die Schwellen kommen aus `SCORE_STRONG`/`SCORE_MODERATE`;
* eigene Kategorien werden nicht erfunden.
*/
minMatch?: number
/** Ort aus den Standortdaten der Ergebnisse. */
city?: string
} }
interface PropertyFilterBarProps { interface PropertyFilterBarProps {
filters: PropertyTableFilters filters: PropertyTableFilters
onFiltersChange: (f: PropertyTableFilters) => void onFiltersChange: (f: PropertyTableFilters) => void
/**
* Schaltet den Matchqualitäts-Filter frei. Nur dort gesetzt, wo Match-Werte
* vorliegen — die Filterleiste ist sonst dieselbe wie in «Meine Objekte».
*/
showMatchFilter?: boolean
/**
* Auswählbare Orte, abgeleitet aus den tatsächlich vorliegenden Ergebnissen.
* Bewusst keine feste Ortsliste: Was nicht im Bestand vorkommt, soll auch
* nicht auswählbar sein.
*/
cities?: string[]
} }
/**
* Stufen der Matchqualität (Runde 11, §2).
*
* Die Grenzen sind die bereits im Projekt geführten Schwellen — dieselben, nach
* denen sich auch die Farbe des Match-Werts richtet. Damit stimmt der Filter
* mit dem überein, was der Nutzer in der Spalte sieht.
*/
const MATCH_OPTIONS = [
{ value: SCORE_STRONG, label: `Stark (ab ${SCORE_STRONG} %)` },
{ value: SCORE_MODERATE, label: `Moderat (ab ${SCORE_MODERATE} %)` },
] as const
const SORT_OPTIONS = [ const SORT_OPTIONS = [
{ value: 'dataQuality', label: 'Datenqualität' }, { value: 'dataQuality', label: 'Datenqualität' },
{ value: 'area', label: 'Fläche' }, { value: 'area', label: 'Fläche' },
@@ -43,12 +78,16 @@ const SUPPLY_AVAILABILITY_STATUSES = [
AvailabilityStatus.OCCUPIED, AvailabilityStatus.OCCUPIED,
] as const ] as const
export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBarProps) { export function PropertyFilterBar({
filters, onFiltersChange, showMatchFilter = false, cities = [],
}: PropertyFilterBarProps) {
const isActive = const isActive =
!!filters.search || !!filters.search ||
(filters.assetTypes?.length ?? 0) > 0 || (filters.assetTypes?.length ?? 0) > 0 ||
!!filters.availabilityStatus || !!filters.availabilityStatus ||
!!filters.sortBy !!filters.sortBy ||
filters.minMatch !== undefined ||
!!filters.city
function update(partial: Partial<PropertyTableFilters>) { function update(partial: Partial<PropertyTableFilters>) {
onFiltersChange({ ...filters, ...partial }) onFiltersChange({ ...filters, ...partial })
@@ -121,6 +160,36 @@ export function PropertyFilterBar({ filters, onFiltersChange }: PropertyFilterBa
))} ))}
</Select> </Select>
{showMatchFilter && (
<Select
value={filters.minMatch ?? 0}
onChange={e => update({ minMatch: e.target.value === 0 ? undefined : Number(e.target.value) })}
displayEmpty
size="small"
sx={{ minWidth: 160 }}
>
<MenuItem value={0}>Matchqualität (alle)</MenuItem>
{MATCH_OPTIONS.map(opt => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
)}
{cities.length > 0 && (
<Select
value={filters.city ?? ''}
onChange={e => update({ city: e.target.value || undefined })}
displayEmpty
size="small"
sx={{ minWidth: 160 }}
>
<MenuItem value="">Standort (alle)</MenuItem>
{cities.map(c => (
<MenuItem key={c} value={c}>{c}</MenuItem>
))}
</Select>
)}
<TextField <TextField
value={filters.search ?? ''} value={filters.search ?? ''}
onChange={e => update({ search: e.target.value || undefined })} onChange={e => update({ search: e.target.value || undefined })}
+109 -9
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { import {
Alert, Alert,
Box, Box,
@@ -22,9 +23,19 @@ import {
qualityColor, qualityColor,
} from './propertyHelpers' } from './propertyHelpers'
import { DS_SLATE } from '../../lib/ds' import { DS_SLATE } from '../../lib/ds'
import { matchScoreHex } from '../../lib/utils'
interface PropertyTableProps { interface PropertyTableProps {
properties: Property[] properties: Property[]
/**
* Match-Werte je Objekt-ID (Runde 10, §3.6).
*
* Ist die Karte gesetzt, bekommt die Tabelle eine erste Spalte «Match». Der
* Rest — Spalten, Sortierung, Zeilenaufbau, Aktionen — bleibt unverändert:
* Noras Trefferliste ist dieselbe Tabelle wie «Meine Objekte», nicht eine
* zweite, die ihr ähnlich sieht.
*/
matchScores?: Map<string, number>
isLoading: boolean isLoading: boolean
isError: boolean isError: boolean
selectedId: string | null selectedId: string | null
@@ -36,10 +47,26 @@ interface PropertyTableProps {
const COL_HEADERS = [ const COL_HEADERS = [
'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete CHF/m²/Jahr', 'Objekt', 'Typ', 'Standort', 'Fläche', 'Miete CHF/m²/Jahr',
'Aktueller Mieter', 'Mietlaufzeit', 'Breakoutoption', 'Breakoutoption Zeitpunkt', 'Aktueller Mieter', 'Mietlaufzeit', 'Breakout Option', 'Breakout Zeitpunkt',
'Datenqualität', 'Aktionen', 'Datenqualität', 'Aktionen',
] ]
/**
* Spaltenanteile in Prozent, in der Reihenfolge der Kopfzeile.
*
* Sie ergeben zusammen 100. Kommt die Match-Spalte dazu, werden alle
* anteilig gestaucht, statt die Tabelle zu verbreitern — eine waagrechte
* Bildlaufleiste wäre für eine einzige Kennzahl ein schlechter Tausch.
*
* In Runde 11 (§3) neu verteilt: «Aktionen» hatte 4 % und war damit schmaler
* als sein eigener Kopftext, der daraufhin zeichenweise umbrach («AKTI/ONE/N»).
* Der Platz kommt aus «Objekt» und «Standort», die beide Reserve hatten.
*/
const COL_WIDTHS = [17, 7, 8, 6, 9, 13, 8, 8, 9, 9, 6]
/** Platz für «Match» in Prozent. */
const MATCH_COL_WIDTH = 7
function LoadingRows() { function LoadingRows() {
return ( return (
<> <>
@@ -68,7 +95,8 @@ function SortableHeader({
field, label, sortBy, sortDir, onSort, field, label, sortBy, sortDir, onSort,
}: { }: {
field: PropertyTableFilters['sortBy'] field: PropertyTableFilters['sortBy']
label: string /** Knoten statt nur Text, damit ein Kopf seinen Umbruch selbst setzen kann. */
label: ReactNode
sortBy: PropertyTableFilters['sortBy'] sortBy: PropertyTableFilters['sortBy']
sortDir: PropertyTableFilters['sortDir'] sortDir: PropertyTableFilters['sortDir']
onSort: (field: PropertyTableFilters['sortBy']) => void onSort: (field: PropertyTableFilters['sortBy']) => void
@@ -100,7 +128,11 @@ export function PropertyTable({
onViewDetail, onViewDetail,
filters, filters,
onFiltersChange, onFiltersChange,
matchScores,
}: PropertyTableProps) { }: PropertyTableProps) {
const showMatch = matchScores !== undefined
const skalierung = showMatch ? (100 - MATCH_COL_WIDTH) / 100 : 1
if (isError) { if (isError) {
return <Alert severity="error" sx={{ m: 3 }}>Objekte konnten nicht geladen werden.</Alert> return <Alert severity="error" sx={{ m: 3 }}>Objekte konnten nicht geladen werden.</Alert>
} }
@@ -115,18 +147,74 @@ 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': {
// Enger als zuvor (war 1) — elf bis zwölf Spalten gewinnen dadurch
// zusammen rund 100 Pixel, ohne dass eine Zelle gedrängt wirkt.
px: 0.75,
py: 0.75,
/*
* Umbruch nur an Wortgrenzen (Runde 11, §3.7/§3.8).
*
* Vorher stand hier `wordBreak: 'break-word'`. Das trennt ein Wort
* mitten drin, sobald es nicht in die Spalte passt — in der
* Kopfzeile wurde daraus «JAH/R» und «DATENQUALIT/ÄT». Jetzt bricht
* nur überlanger Text ohne Leerzeichen, und auch das erst, wenn er
* sonst überliefe.
*/
wordBreak: 'normal',
overflowWrap: 'break-word',
hyphens: 'none',
},
// Kopfzeilen dürfen zwei Zeilen brauchen — aber an sinnvoller Stelle.
'& .MuiTableCell-head': {
lineHeight: 1.25,
verticalAlign: 'bottom',
},
}}
>
<colgroup>
{showMatch && <col style={{ width: `${MATCH_COL_WIDTH}%` }} />}
{COL_WIDTHS.map((w, i) => (
<col key={COL_HEADERS[i]} style={{ width: `${w * skalierung}%` }} />
))}
</colgroup>
<TableHead> <TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}> <TableRow sx={{ bgcolor: 'grey.50' }}>
{showMatch && (
<TableCell>
<Typography variant="caption" sx={{ fontWeight: 600 }}>Match</Typography>
</TableCell>
)}
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell>
<TableCell><SortableHeader field="area" label="Fläche (m²)" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell> <TableCell><SortableHeader field="area" label="Fläche (m²)" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell>
<TableCell><SortableHeader field="rent" label="Miete CHF/m²/Jahr" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell> <TableCell><SortableHeader field="rent" label={<>Miete<br />CHF/m²/Jahr</>} sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktueller Mieter</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktueller Mieter</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Mietlaufzeit</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Mietlaufzeit</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption</Typography></TableCell> {/*
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakoutoption Zeitpunkt</Typography></TableCell> «Breakoutoption» in zwei Wörtern (Runde 11, §3.9): Der Begriff
bleibt derselbe, nur der Umbruch hat jetzt eine Stelle, an der
er stattfinden darf.
*/}
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakout Option</Typography></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Breakout Zeitpunkt</Typography></TableCell>
<TableCell><SortableHeader field="dataQuality" label="Datenqualität" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell> <TableCell><SortableHeader field="dataQuality" label="Datenqualität" sortBy={filters.sortBy} sortDir={filters.sortDir} onSort={handleSort} /></TableCell>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Aktionen</Typography></TableCell>
</TableRow> </TableRow>
@@ -136,7 +224,7 @@ export function PropertyTable({
<LoadingRows /> <LoadingRows />
) : properties.length === 0 ? ( ) : properties.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={11}> <TableCell colSpan={showMatch ? 12 : 11}>
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 4 }}> <Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 4 }}>
Keine Objekte gefunden. Keine Objekte gefunden.
</Typography> </Typography>
@@ -164,6 +252,18 @@ export function PropertyTable({
'&:hover': { bgcolor: isSelected ? 'rgba(30,58,95,0.08)' : 'rgba(0,0,0,0.02)' }, '&:hover': { bgcolor: isSelected ? 'rgba(30,58,95,0.08)' : 'rgba(0,0,0,0.02)' },
}} }}
> >
{/* Match — erste Spalte, nur in Noras Trefferliste (§3.6) */}
{showMatch && (
<TableCell>
<Typography
variant="body2"
sx={{ fontWeight: 700, color: matchScoreHex(matchScores!.get(p.id) ?? 0) }}
>
{matchScores!.get(p.id) ?? 0} %
</Typography>
</TableCell>
)}
{/* Objekt */} {/* Objekt */}
<TableCell> <TableCell>
<Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.3 }}> <Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.3 }}>
@@ -205,7 +305,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 +349,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>
+19 -8
View File
@@ -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'
@@ -20,14 +25,20 @@ import { DS_TEXT } from '../../lib/ds'
* deshalb in derselben Standard-Schriftfarbe; unterschieden wird über das Symbol. * deshalb in derselben Standard-Schriftfarbe; unterschieden wird über das Symbol.
*/ */
const CONFIG: Record<ReminderType, { Icon: LucideIcon; label: string }> = { const CONFIG: Record<ReminderType, { Icon: LucideIcon; label: string }> = {
LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf' }, LEASE_EXPIRY: { Icon: FileText, label: 'Mietablauf' },
BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option' }, BREAK_OPTION: { Icon: ArrowRightLeft, label: 'Break-Option' },
RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung' }, RENT_REVIEW: { Icon: TrendingUp, label: 'Mietanpassung' },
INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion' }, INSPECTION: { Icon: ClipboardCheck, label: 'Inspektion' },
INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung' }, INSURANCE_RENEWAL: { Icon: Shield, label: 'Versicherung' },
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 {
+165 -7
View File
@@ -12,14 +12,21 @@
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { import {
Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
FormControlLabel, Switch, TextField, Typography, FormControlLabel, IconButton, Switch, TextField, Tooltip, Typography,
} from '@mui/material' } from '@mui/material'
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Settings2 } from 'lucide-react' import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Pencil, Plus, Settings2, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import type { AgentChannel, TeamAgent } from '../../domain/teamAgent' import type { AgentChannel, TeamAgent } from '../../domain/teamAgent'
import { AgentChannelDirection, AgentConnectionStatus } from '../../domain/teamAgent' import { AgentChannelDirection, AgentChannelType, AgentConnectionStatus } from '../../domain/teamAgent'
import { AgentConnectionStatusBadge } from './AgentBadges' import { AgentConnectionStatusBadge } from './AgentBadges'
import { AGENT_CHANNEL_DIRECTION_LABELS, AGENT_CHANNEL_LABELS } from '../../lib/constants' import { AgentEntityDialog } from './AgentEntityDialog'
import type { EntityDraft, EntityField } from './AgentEntityDialog'
import { newEntityId, removeById, upsertById } from './agentEntityEditing'
import {
AGENT_CHANNEL_DIRECTION_LABELS,
AGENT_CHANNEL_LABELS,
AGENT_CONNECTION_STATUS_LABELS,
} from '../../lib/constants'
import { useSaveAgentChannels } from '../../hooks/useTeamAgents' import { useSaveAgentChannels } from '../../hooks/useTeamAgents'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
@@ -40,15 +47,65 @@ const TEST_RESULT_BY_STATUS: Record<AgentConnectionStatus, TestResult> = {
[AgentConnectionStatus.ROADMAP]: { ok: false, message: 'Verbindung fehlgeschlagen. Dieser Kanal ist erst geplant und noch nicht eingerichtet — ein Test ist nach der Einrichtung möglich.' }, [AgentConnectionStatus.ROADMAP]: { ok: false, message: 'Verbindung fehlgeschlagen. Dieser Kanal ist erst geplant und noch nicht eingerichtet — ein Test ist nach der Einrichtung möglich.' },
} }
// ── Maske zum Anlegen und Bearbeiten (Runde 10, §2A) ──────────────────────────
const CHANNEL_FIELDS: EntityField[] = [
{
key: 'type',
label: 'Kanal',
type: 'select',
required: true,
options: Object.values(AgentChannelType).map((t) => ({
value: t,
label: AGENT_CHANNEL_LABELS[t] ?? t,
})),
},
{
key: 'direction',
label: 'Richtung',
type: 'select',
required: true,
options: Object.values(AgentChannelDirection).map((d) => ({
value: d,
label: AGENT_CHANNEL_DIRECTION_LABELS[d] ?? d,
})),
},
{
key: 'status',
label: 'Verbindung',
type: 'select',
required: true,
options: Object.values(AgentConnectionStatus).map((s) => ({
value: s,
label: AGENT_CONNECTION_STATUS_LABELS[s] ?? s,
})),
helperText: 'Ein neu erfasster Kanal gilt erst als verbunden, wenn er eingerichtet ist.',
},
{
key: 'description',
label: 'Wofür wird der Kanal genutzt?',
type: 'multiline',
required: true,
},
{
key: 'displayName',
label: 'Anzeigename',
helperText: 'Steht so im Konfigurationsdialog.',
},
]
// ── Kanalzeile ──────────────────────────────────────────────────────────────── // ── Kanalzeile ────────────────────────────────────────────────────────────────
interface RowProps { interface RowProps {
channel: AgentChannel channel: AgentChannel
editing: boolean
onToggle: (channelId: string, enabled: boolean) => void onToggle: (channelId: string, enabled: boolean) => void
onConfigure: (channelId: string) => void onConfigure: (channelId: string) => void
onEdit: (channelId: string) => void
onDelete: (channelId: string) => void
} }
const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }: RowProps) { const ChannelRow = memo(function ChannelRow({ channel, editing, onToggle, onConfigure, onEdit, onDelete }: RowProps) {
const label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type const label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type
const DirectionIcon = DIRECTION_ICON[channel.direction] const DirectionIcon = DIRECTION_ICON[channel.direction]
@@ -71,6 +128,20 @@ const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }:
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}> <Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
{AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction} {AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction}
</Typography> </Typography>
{editing && (
<>
<Tooltip title="Kanal bearbeiten">
<IconButton size="small" onClick={() => onEdit(channel.id)} sx={{ color: DS_TEXT.secondary }}>
<Pencil size={14} />
</IconButton>
</Tooltip>
<Tooltip title="Kanal entfernen">
<IconButton size="small" onClick={() => onDelete(channel.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</>
)}
</Box> </Box>
</Box> </Box>
@@ -197,6 +268,10 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
const { mutate: saveChannels } = useSaveAgentChannels() const { mutate: saveChannels } = useSaveAgentChannels()
const [openChannelId, setOpenChannelId] = useState<string | null>(null) const [openChannelId, setOpenChannelId] = useState<string | null>(null)
/** `null` = Maske zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Kanals. */
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
const channels = agent.channels const channels = agent.channels
// Beide Kennzahlen in einem Durchlauf (§10.4). // Beide Kennzahlen in einem Durchlauf (§10.4).
@@ -222,15 +297,72 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
const handleConfigure = useCallback((channelId: string) => setOpenChannelId(channelId), []) const handleConfigure = useCallback((channelId: string) => setOpenChannelId(channelId), [])
const handleClose = useCallback(() => setOpenChannelId(null), []) const handleClose = useCallback(() => setOpenChannelId(null), [])
const handleDelete = useCallback(
(channelId: string) => saveChannels({ id: agent.id, channels: removeById(channels, channelId) }),
[agent.id, channels, saveChannels],
)
/**
* Anlegen und Bearbeiten teilen sich die Maske. Gespeichert wird sofort —
* ein Kanal ist wie der Aktiv-Schalter ein Ein/Aus-Entscheid, kein Formular
* mit Zwischenstand.
*/
const handleDialogSave = useCallback(
(werte: EntityDraft, active: boolean) => {
const bestehend = bearbeitet !== 'new' ? channels.find((c) => c.id === bearbeitet) : undefined
const eintrag: AgentChannel = {
id: bestehend?.id ?? newEntityId(agent.id, 'channel'),
type: werte.type as AgentChannelType,
direction: werte.direction as AgentChannelDirection,
status: werte.status as AgentConnectionStatus,
description: werte.description.trim(),
enabled: active,
...(bestehend?.optional ? { optional: true } : {}),
config: {
...(bestehend?.config ?? { defaultRecipients: [], autoReplyEnabled: false }),
displayName: werte.displayName?.trim() || (AGENT_CHANNEL_LABELS[werte.type] ?? werte.type),
},
}
saveChannels({ id: agent.id, channels: upsertById(channels, eintrag) })
setBearbeitet(null)
},
[bearbeitet, channels, agent.id, saveChannels],
)
const inBearbeitung = bearbeitet && bearbeitet !== 'new'
? channels.find((c) => c.id === bearbeitet)
: undefined
return ( return (
<Box> <Box>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}> <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus. Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus.
</Typography> </Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25, mb: 1.5 }}> <Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25 }}>
{summary.active} von {channels.length} Kanälen aktiv · {summary.connected} verbunden {summary.active} von {channels.length} Kanälen aktiv · {summary.connected} verbunden
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5, mb: 1.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<Pencil size={14} aria-hidden />}
onClick={() => setBearbeitungsmodus((v) => !v)}
sx={{ textTransform: 'none', ...FOCUS_RING }}
>
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Kanäle bearbeiten'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Plus size={14} aria-hidden />}
onClick={() => setBearbeitet('new')}
sx={{ textTransform: 'none', ...FOCUS_RING }}
>
Neuen Kanal anbinden
</Button>
</Box>
{channels.length === 0 ? ( {channels.length === 0 ? (
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}> <Typography variant="body2" sx={{ color: DS_TEXT.muted }}>
Für {agent.name} ist noch kein Kanal hinterlegt. Für {agent.name} ist noch kein Kanal hinterlegt.
@@ -238,12 +370,38 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
) : ( ) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 1.25 }}> <Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 1.25 }}>
{channels.map((channel) => ( {channels.map((channel) => (
<ChannelRow key={channel.id} channel={channel} onToggle={handleToggle} onConfigure={handleConfigure} /> <ChannelRow
key={channel.id}
channel={channel}
editing={bearbeitungsmodus}
onToggle={handleToggle}
onConfigure={handleConfigure}
onEdit={setBearbeitet}
onDelete={handleDelete}
/>
))} ))}
</Box> </Box>
)} )}
<ChannelConfigDialog channel={openChannel} onClose={handleClose} /> <ChannelConfigDialog channel={openChannel} onClose={handleClose} />
<AgentEntityDialog
open={bearbeitet !== null}
entityKey={bearbeitet ?? undefined}
title={inBearbeitung ? 'Kanal bearbeiten' : 'Neuen Kanal anbinden'}
fields={CHANNEL_FIELDS}
initial={{
type: inBearbeitung?.type ?? AgentChannelType.EMAIL,
direction: inBearbeitung?.direction ?? AgentChannelDirection.BOTH,
status: inBearbeitung?.status ?? AgentConnectionStatus.DISCONNECTED,
description: inBearbeitung?.description ?? '',
displayName: inBearbeitung?.config.displayName ?? '',
}}
activeLabel="Kanal aktiv"
initialActive={inBearbeitung?.enabled ?? true}
onSave={handleDialogSave}
onClose={() => setBearbeitet(null)}
/>
</Box> </Box>
) )
} }
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
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'
/**
* Höhe des Verlaufsfensters.
*
* Der Verlauf braucht eine feste Obergrenze, sonst wächst er mit jeder Antwort
* und schiebt die Seite darunter weg — genau das Springen, das behoben werden
* soll. Innerhalb dieser Höhe scrollt der Verlauf für sich.
*/
const TRANSCRIPT_MAX_HEIGHT = 420
/**
* 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)
const containerRef = useRef<HTMLDivElement>(null)
/** Die zuletzt gestellte Frage — an ihr richtet sich die Scrollposition aus. */
const lastQuestionRef = useRef<HTMLDivElement>(null)
const zuletztGescrollt = useRef(0)
/**
* Nach einer Antwort den Verlauf so stellen, dass die eigene Frage oben steht
* und die Antwort direkt darunter beginnt (Runde 10, §4).
*
* Ausschliesslich über `scrollTop` des Containers. Kein `scrollIntoView` und
* kein `window.scrollTo`: Beide bewegen die Seite mit, und genau dieses
* Wegspringen des ganzen Bildschirms war die Beschwerde. Der Browser-Viewport
* bleibt hier unberührt.
*
* `useLayoutEffect`, damit die Korrektur vor dem Zeichnen sitzt — sonst sieht
* man den Sprung einmal kurz.
*/
useLayoutEffect(() => {
const container = containerRef.current
const frage = lastQuestionRef.current
if (!container || !frage || messages.length === zuletztGescrollt.current) return
zuletztGescrollt.current = messages.length
// Abstand der Frage zum oberen Rand des Verlaufs, minus ein schmaler
// Vorlauf, damit sie nicht bündig an der Kante klebt.
container.scrollTop = Math.max(0, frage.offsetTop - container.offsetTop - 8)
}, [messages.length])
// Ein Agentenwechsel leert den Verlauf; dann darf die nächste Antwort wieder
// ausrichten, statt die alte Zählung fortzuschreiben.
useEffect(() => {
if (messages.length === 0) zuletztGescrollt.current = 0
}, [messages.length])
if (messages.length === 0 || !agent) return null
const letzteFrageIndex = messages.map(m => m.from).lastIndexOf('user')
return (
<Box
ref={containerRef}
sx={{
width: '100%',
maxWidth: 720,
mt: 2.5,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
// Der Verlauf scrollt für sich; die Seite darunter bleibt, wo sie ist.
maxHeight: TRANSCRIPT_MAX_HEIGHT,
overflowY: 'auto',
// Ohne diesen Innenabstand klebt die Bildlaufleiste am Text.
pr: 0.5,
// Verhindert, dass das Scrollen am Ende des Verlaufs auf die Seite
// durchschlägt — sonst wandert der Bildschirm doch wieder.
overscrollBehavior: 'contain',
}}
>
{messages.map((m, i) => (
m.from === 'user' ? (
<Box
key={i}
ref={i === letzteFrageIndex ? lastQuestionRef : undefined}
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>
)
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Property On — Mehrfachauswahl als entfernbare Chips (Runde 10, §§2.7–2.9).
*
* Eine Komponente für alle drei Stellen — Quellentypen, Beobachtungsraum und
* zeitliche Relevanz. Sie unterscheiden sich nur darin, ob eigene Werte erlaubt
* sind; die Bedienung ist überall dieselbe, und das ist der Punkt: Wer gelernt
* hat, wie man einen Quellentyp entfernt, weiss es auch beim Beobachtungsraum.
*
* Der eine Unterschied zum bisherigen Auswahlfeld: Jeder gewählte Wert lässt
* sich einzeln wegklicken — auch die, die von Anfang an gesetzt waren. Vorher
* konnte man Vorgaben nur über das Auswahlmenü loswerden, was niemand fand.
*/
import { memo, useCallback, useState } from 'react'
import { Box, Chip, MenuItem, TextField, Typography } from '@mui/material'
import { Plus } from 'lucide-react'
import type { AgentSettingOption } from '../../domain/teamAgent'
import { DS_TEXT } from '../../lib/ds'
interface Props {
value: string[]
options: AgentSettingOption[]
/** Freitext erlauben — bei fachlich abgeschlossenen Listen aus. */
allowCustom: boolean
disabled?: boolean
ariaLabel: string
onChange: (next: string[]) => void
}
/** Ein selbst erfasster Wert hat keine Option — dann ist er selbst sein Etikett. */
function etikett(value: string, options: AgentSettingOption[]): string {
return options.find(o => o.value === value)?.label ?? value
}
export const AgentChipsField = memo(function AgentChipsField({
value, options, allowCustom, disabled, ariaLabel, onChange,
}: Props) {
const [eingabe, setEingabe] = useState('')
const offeneOptionen = options.filter(o => !value.includes(o.value))
const entfernen = useCallback(
(v: string) => onChange(value.filter(e => e !== v)),
[value, onChange],
)
const hinzufuegen = useCallback(
(v: string) => {
const sauber = v.trim()
// Leeres nicht speichern, Doppeltes nicht anlegen — beides würde die
// Liste nur verlängern, ohne etwas auszusagen.
if (!sauber || value.includes(sauber)) return
onChange([...value, sauber])
},
[value, onChange],
)
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }} role="list" aria-label={ariaLabel}>
{value.length === 0 && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, py: 0.5 }}>
Nichts ausgewählt — damit gilt keine Einschränkung.
</Typography>
)}
{value.map(v => (
<Chip
key={v}
role="listitem"
size="small"
label={etikett(v, options)}
onDelete={disabled ? undefined : () => entfernen(v)}
disabled={disabled}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{offeneOptionen.length > 0 && (
<TextField
select
size="small"
disabled={disabled}
value=""
label="Auswählen"
sx={{ minWidth: 220, flex: 1 }}
onChange={e => hinzufuegen(e.target.value)}
>
{offeneOptionen.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
{o.label}
</MenuItem>
))}
</TextField>
)}
{allowCustom && (
<TextField
size="small"
disabled={disabled}
value={eingabe}
label="Eigener Eintrag"
placeholder="Tippen und Enter"
sx={{ minWidth: 220, flex: 1 }}
onChange={e => setEingabe(e.target.value)}
onKeyDown={e => {
if (e.key !== 'Enter') return
// Sonst schickt Enter das umgebende Formular ab.
e.preventDefault()
hinzufuegen(eingabe)
setEingabe('')
}}
slotProps={{
input: {
endAdornment: <Plus size={14} color={DS_TEXT.muted} aria-hidden />,
},
}}
/>
)}
</Box>
</Box>
)
})
+182
View File
@@ -0,0 +1,182 @@
import { useState } from 'react'
import {
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
FormControlLabel, MenuItem, Switch, TextField,
} from '@mui/material'
import { DS_BRAND } from '../../lib/ds'
/**
* Ein Feld der Bearbeitungsmaske.
*
* Bewusst ein winziger Beschreibungstyp statt einer Maske je Entität: Aufgaben,
* Systeme und Kanäle unterscheiden sich nur in den Feldern, nicht im Verhalten.
* Drei fast gleiche Dialoge wären drei Stellen für jede künftige Korrektur.
*/
export interface EntityField {
key: string
label: string
/** `select` braucht `options`, `multiline` wird mehrzeilig dargestellt. */
type?: 'text' | 'multiline' | 'select' | 'url'
options?: { value: string; label: string }[]
required?: boolean
placeholder?: string
helperText?: string
}
export type EntityDraft = Record<string, string>
/**
* Eine Adresse wird als Quelle tatsächlich abgerufen — sie muss deshalb an der
* Maske scheitern und nicht erst im Recherchelauf. Geprüft wird nur das
* Schema: ob die Seite antwortet, kann erst der Abruf sagen, und ein Formular,
* das Erreichbarkeit behauptet, wäre eine Behauptung zu viel.
*/
function urlFehler(wert: string): string | null {
const text = wert.trim()
if (text === '') return null
let parsed: URL
try {
parsed = new URL(text)
} catch {
return 'Bitte eine vollständige Adresse angeben, z. B. https://www.beispiel.ch/news.'
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return 'Nur http- und https-Adressen können gelesen werden.'
}
return null
}
interface Props {
open: boolean
/**
* Kennung des bearbeiteten Eintrags — bei offenem Dialog ein Wechsel darauf
* baut die Maske neu auf. Bewusst über den React-Schlüssel statt über einen Effekt: ein
* Effekt würde den Entwurf nachträglich überschreiben und dabei bereits
* getippten Text verlieren.
*/
entityKey?: string
/** Überschrift, z. B. «Aufgabe bearbeiten» oder «Neues System anbinden». */
title: string
fields: EntityField[]
/** Vorbelegung; beim Anlegen leer. */
initial: EntityDraft
/** Beschriftung des Aktiv-Schalters; entfällt, wenn nicht gesetzt. */
activeLabel?: string
initialActive?: boolean
onSave: (draft: EntityDraft, active: boolean) => void
onClose: () => void
}
/**
* Gemeinsame Maske zum Anlegen und Bearbeiten von Aufgaben, Systemen und
* Kanälen (Runde 10, §2).
*
* Der Entwurf lebt lokal und geht erst beim Speichern hinaus — bei jedem
* Tastendruck zu speichern hiesse, jeden Zwischenstand zum gültigen Stand zu
* erklären. Über `key` auf dem Dialoginhalt entsteht die Maske bei jedem
* Öffnen neu, statt dass ein Effekt den alten Entwurf nachträglich überschreibt.
*/
export function AgentEntityDialog({
open, title, fields, initial, activeLabel, initialActive = true, entityKey, onSave, onClose,
}: Props) {
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>{title}</DialogTitle>
{open && (
<DialogBody
key={entityKey ?? 'neu'}
fields={fields}
initial={initial}
activeLabel={activeLabel}
initialActive={initialActive}
onSave={onSave}
onClose={onClose}
/>
)}
</Dialog>
)
}
function DialogBody({
fields, initial, activeLabel, initialActive, onSave, onClose,
}: Omit<Props, 'open' | 'title' | 'entityKey'>) {
const [draft, setDraft] = useState<EntityDraft>(initial)
const [active, setActive] = useState(initialActive ?? true)
const [touched, setTouched] = useState(false)
const fehlend = fields.filter(f => f.required && !(draft[f.key] ?? '').trim()).map(f => f.key)
// Formfehler je Feld, unabhängig davon, ob das Feld leer geblieben ist.
const formfehler: Record<string, string> = {}
for (const f of fields) {
if (f.type !== 'url') continue
const meldung = urlFehler(draft[f.key] ?? '')
if (meldung) formfehler[f.key] = meldung
}
function handleSave() {
setTouched(true)
if (fehlend.length > 0 || Object.keys(formfehler).length > 0) return
onSave(draft, active)
}
return (
<>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
{fields.map(f => (
<TextField
key={f.key}
select={f.type === 'select'}
multiline={f.type === 'multiline'}
minRows={f.type === 'multiline' ? 3 : undefined}
type={f.type === 'url' ? 'url' : 'text'}
label={f.label}
value={draft[f.key] ?? ''}
onChange={e => setDraft(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={f.placeholder}
required={f.required}
error={touched && (fehlend.includes(f.key) || f.key in formfehler)}
helperText={
touched && fehlend.includes(f.key)
? 'Dieses Feld wird gebraucht.'
: touched && formfehler[f.key]
? formfehler[f.key]
: f.helperText
}
size="small"
fullWidth
>
{(f.options ?? []).map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
{o.label}
</MenuItem>
))}
</TextField>
))}
{activeLabel && (
<FormControlLabel
control={<Switch size="small" checked={active} onChange={(_, v) => setActive(v)} />}
label={active ? activeLabel : `${activeLabel} — derzeit inaktiv`}
slotProps={{ typography: { variant: 'body2' } }}
/>
)}
</Box>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} sx={{ textTransform: 'none' }}>
Abbrechen
</Button>
<Button
variant="contained"
onClick={handleSave}
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
>
Speichern
</Button>
</DialogActions>
</>
)
}
+57 -6
View File
@@ -1,16 +1,28 @@
import { memo } from 'react' import { memo, useMemo } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Typography } from '@mui/material'
import type { AgentMetric, TeamAgent } from '../../domain/teamAgent' import type { AgentMetric, TeamAgent } from '../../domain/teamAgent'
import { EmptyState } from '../ui' import { EmptyState } from '../ui'
import { useMatchRuns } from '../../hooks/useMatchRuns'
import { useFutureSignals } from '../../hooks/useFutureSignals'
import { isRisiko } from '../../lib/leadSignal'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
/** /**
* Reiter «Kennzahlen». * Reiter «Kennzahlen».
* *
* Zeigt ausschliesslich die Kennzahlen, die im Personalblatt des Mitarbeitenden * Zeigt die Kennzahlen, die das Personalblatt führt. Es wird nichts
* bereits hinterlegt sind. Es werden keine Werte berechnet, hochgerechnet oder * hochgerechnet und nichts ergänzt — was der Katalog nicht führt, steht hier
* ergänzt — was der Katalog nicht führt, steht hier auch nicht. * auch nicht.
*
* Ausnahme seit Runde 10 (§3.11): Noras beide Kennzahlen kommen aus dem
* laufenden Stand der Anwendung und nicht aus dem Katalog. Ein fester Wert
* wäre dort eine Behauptung — die eine Zahl zählt tatsächlich ausgeführte
* Matching-Läufe, die andere tatsächlich zugeordnete Objekte.
*/ */
/** Kennungen, deren Wert zur Laufzeit entsteht. */
const MATCH_RUNS_METRIC = 'nora-metric-match-runs'
const AFFECTED_OBJECTS_METRIC = 'nora-metric-affected-objects'
const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric }) { const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric }) {
return ( return (
<Box <Box
@@ -46,7 +58,46 @@ const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric })
}) })
export function AgentMetricsTab({ agent }: { agent: TeamAgent }) { export function AgentMetricsTab({ agent }: { agent: TeamAgent }) {
if (agent.metrics.length === 0) { const { data: matchRuns = [] } = useMatchRuns()
const { data: signals = [] } = useFutureSignals()
/**
* Die beiden Laufzeit-Kennzahlen einsetzen.
*
* «Betroffene Objekte» zählt Risikosignale mit eindeutig zugeordnetem
* Objekt — und zwar die Objekte, nicht die Signale: Zwei Meldungen über
* dasselbe Objekt sind ein betroffenes Objekt, nicht zwei.
*/
const metrics = useMemo((): AgentMetric[] => {
const laeufe = matchRuns.length
const betroffene = new Set(
signals.filter(sig => isRisiko(sig) && sig.propertyId).map(sig => sig.propertyId!),
).size
return agent.metrics.map((m) => {
if (m.id === MATCH_RUNS_METRIC) {
return {
...m,
value: String(laeufe),
hint: laeufe === 0
? 'Noch kein Matching ausgeführt. Die Zahl steigt nur durch einen tatsächlichen Lauf.'
: `Zuletzt am ${new Date(matchRuns[0].timestamp).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })} für «${matchRuns[0].leadTitle}».`,
}
}
if (m.id === AFFECTED_OBJECTS_METRIC) {
return {
...m,
value: String(betroffene),
hint: betroffene === 0
? 'Derzeit ist keinem Risikosignal ein Objekt aus dem Bestand eindeutig zugeordnet.'
: m.hint,
}
}
return m
})
}, [agent.metrics, matchRuns, signals])
if (metrics.length === 0) {
return ( return (
<EmptyState <EmptyState
title="Keine Kennzahlen hinterlegt" title="Keine Kennzahlen hinterlegt"
@@ -63,7 +114,7 @@ export function AgentMetricsTab({ agent }: { agent: TeamAgent }) {
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' }, gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' },
}} }}
> >
{agent.metrics.map((metric) => ( {metrics.map((metric) => (
<MetricTile key={metric.id} metric={metric} /> <MetricTile key={metric.id} metric={metric} />
))} ))}
</Box> </Box>
+68 -3
View File
@@ -11,14 +11,16 @@
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
// prettier-ignore // prettier-ignore
import { Box, Button, Chip, FormControlLabel, FormHelperText, InputAdornment, MenuItem, Select, Switch, TextField, Typography } from '@mui/material' import { Box, Button, Chip, FormControlLabel, FormHelperText, IconButton, InputAdornment, MenuItem, Popover, Select, Switch, TextField, Typography } from '@mui/material'
import { AlertTriangle, Lock } from 'lucide-react' import { AlertTriangle, Info, Lock } from 'lucide-react'
import { z } from 'zod' import { z } from 'zod'
import type { AgentSetting, TeamAgent } from '../../domain/teamAgent' import type { AgentSetting, TeamAgent } from '../../domain/teamAgent'
import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent' import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent'
import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants' import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { useSaveAgentSettings } from '../../hooks/useTeamAgents' import { useSaveAgentSettings } from '../../hooks/useTeamAgents'
import { AgentChipsField } from './AgentChipsField'
import { ResearchDocumentsField } from './ResearchDocumentsField'
type SettingValue = AgentSetting['value'] type SettingValue = AgentSetting['value']
@@ -53,6 +55,14 @@ function buildSettingsSchema(settings: AgentSetting[]) {
shape[setting.id] = field shape[setting.id] = field
} else if (setting.kind === AgentSettingKind.MULTI_SELECT) { } else if (setting.kind === AgentSettingKind.MULTI_SELECT) {
shape[setting.id] = z.array(z.string()).min(1, 'Mindestens ein Eintrag ist erforderlich.') shape[setting.id] = z.array(z.string()).min(1, 'Mindestens ein Eintrag ist erforderlich.')
} else if (setting.kind === AgentSettingKind.CHIPS) {
// Bewusst ohne Mindestzahl: Eine leere Auswahl bedeutet «keine
// Einschränkung» und ist damit eine gültige Aussage, kein Fehler.
shape[setting.id] = z.array(z.string())
} else if (setting.kind === AgentSettingKind.DOCUMENTS) {
// Die Dokumente liegen in IndexedDB und nicht im Entwurf dieses Reiters;
// hier steht nur der Platz, an dem sie erscheinen.
shape[setting.id] = z.any()
} else if (setting.kind === AgentSettingKind.BOOLEAN) { } else if (setting.kind === AgentSettingKind.BOOLEAN) {
shape[setting.id] = z.boolean() shape[setting.id] = z.boolean()
} else { } else {
@@ -62,6 +72,40 @@ function buildSettingsSchema(settings: AgentSetting[]) {
return z.object(shape) return z.object(shape)
} }
// ── Erklärung zu einem Wert (Runde 10, §2.10) ─────────────────────────────────
/**
* Ein Popover und kein Tooltip: Der Text ist mehrere Sätze lang, und ein
* Tooltip verschwindet, sobald man den Zeiger bewegt, um ihn zu Ende zu lesen.
*/
function SettingInfoButton({ label, text }: { label: string; text: string }) {
const [anker, setAnker] = useState<HTMLElement | null>(null)
return (
<>
<IconButton
size="small"
aria-label={`Wie entsteht «${label}»?`}
onClick={(e) => setAnker(e.currentTarget)}
sx={{ color: DS_TEXT.muted, p: 0.25, ...FOCUS_SX }}
>
<Info size={14} />
</IconButton>
<Popover
open={anker !== null}
anchorEl={anker}
onClose={() => setAnker(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: { sx: { maxWidth: 460, p: 2, boxShadow: DS_SHADOW.panel } } }}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>
{text}
</Typography>
</Popover>
</>
)
}
// ── Eine Einstellung ────────────────────────────────────────────────────────── // ── Eine Einstellung ──────────────────────────────────────────────────────────
interface RowProps { interface RowProps {
@@ -126,6 +170,24 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
</> </>
) )
break break
case AgentSettingKind.CHIPS:
control = (
<>
<AgentChipsField
value={Array.isArray(setting.value) ? setting.value : []}
options={options}
allowCustom={setting.allowCustom === true}
disabled={locked}
ariaLabel={setting.label}
onChange={(next) => onChange(setting.id, next)}
/>
{error && <FormHelperText error>{error}</FormHelperText>}
</>
)
break
case AgentSettingKind.DOCUMENTS:
control = <ResearchDocumentsField />
break
default: default:
control = ( control = (
<TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined} <TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined}
@@ -135,7 +197,10 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
return ( return (
<Box sx={{ display: 'grid', gap: 0.5, py: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }}> <Box sx={{ display: 'grid', gap: 0.5, py: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography>
{setting.infoText && <SettingInfoButton label={setting.label} text={setting.infoText} />}
</Box>
{setting.description && ( {setting.description && (
<Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography> <Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography>
)} )}
+230 -151
View File
@@ -1,202 +1,253 @@
/** /**
* Property On — Reiter «Systeme» im Personalblatt (§10). * Property On — Reiter «Systeme» im Personalblatt (§10, Runde 10 §§1–3).
* *
* Reine Transparenz: welche Fachsysteme und Datenquellen ein digitaler * Zeigt und pflegt die Fachsysteme und Datenquellen eines digitalen
* Mitarbeiter verwenden darf. Keine Aktionen — Zugänge werden auf der Seite * Mitarbeiters. Seit Runde 10 ist der Reiter nicht mehr nur Anzeige: Zugänge
* «Kanäle & Systeme» eingerichtet, nicht hier. * lassen sich hier anlegen, ändern, zu- und abschalten — dort, wo man sie sieht.
* *
* Schreibrechte sind der einzige wirklich folgenreiche Zustand auf diesem * Bewusst eine einzige Liste statt getrennter Gruppen «lesend» und
* Reiter (§10.4): sie verändern Kundendaten in der Bewirtschaftung. Sie stehen * «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser
* deshalb in einer eigenen Gruppe und tragen zusätzlich zur hervorgehobenen * Mitarbeiter», nicht «wie viele davon sind lesend». Jede Zeile trägt rechts
* Fläche ein Icon UND einen Klartextsatz — Farbe allein ist kein zulässiger * oben genau zwei Angaben — ob sie genutzt wird und ob gelesen oder geschrieben
* Statusträger (§2.3, §18). * wird. Beide sind Text, nicht nur Farbe (§2.3, §18).
*/ */
import { memo, useMemo } from 'react' import { memo, useCallback, useState } from 'react'
import { Box, Typography } from '@mui/material' import { Box, Button, IconButton, Link, Switch, Tooltip, Typography } from '@mui/material'
import { Eye, KeyRound, PenLine, ShieldCheck } from 'lucide-react' import { ExternalLink, Pencil, Plus, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import {
import { AgentAccessLevel } from '../../domain/teamAgent' AgentAccessLevel,
AgentConnectionStatus,
AgentSystemType,
} from '../../domain/teamAgent'
import type { AgentSystem, TeamAgent } from '../../domain/teamAgent' import type { AgentSystem, TeamAgent } from '../../domain/teamAgent'
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges' import { GenericBadge } from '../shared/GenericBadge'
import { DetailSectionTitle } from './WorkItemDetailSections' import { AgentEntityDialog } from './AgentEntityDialog'
import { AGENT_SYSTEM_LABELS } from '../../lib/constants' import type { EntityDraft, EntityField } from './AgentEntityDialog'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { newEntityId, removeById, upsertById } from './agentEntityEditing'
import { useSaveAgentSystems } from '../../hooks/useTeamAgents'
import { AGENT_ACCESS_LABELS } from '../../lib/constants'
import { istAktiv, istSchreibend, systemTitel } from '../../lib/agentSystems'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
// ── Darstellung je Zugriffstyp ──────────────────────────────────────────────── // ── Maske ─────────────────────────────────────────────────────────────────────
interface AccessAppearance { const SYSTEM_FIELDS: EntityField[] = [
bg: string { key: 'title', label: 'Titel der Quelle', required: true },
border: string { key: 'description', label: 'Beschreibung der Quelle', type: 'multiline', required: true },
text: string {
icon: LucideIcon key: 'url',
/** Klartext neben dem Icon, damit der Zugriff nicht nur über die Farbe lesbar ist. */ label: 'Link / URL',
marker: string type: 'url',
} placeholder: 'https://…',
helperText: 'Eine aktive Lesequelle mit Adresse wird im Recherchelauf tatsächlich abgerufen.',
const ACCESS_APPEARANCE: Record<AgentAccessLevel, AccessAppearance> = {
[AgentAccessLevel.READ]: {
bg: DS_BG.surface,
border: DS_BORDER.default,
text: DS_TEXT.secondary,
icon: Eye,
marker: 'Liest nur — verändert in diesem System nichts',
}, },
[AgentAccessLevel.WRITE]: { {
bg: DS_SURFACE.warning.bg, key: 'access',
border: DS_SURFACE.warning.border, label: 'Zugriff',
text: DS_TEXT.warningDark, type: 'select',
icon: KeyRound, required: true,
marker: 'Schreibzugriff — legt in diesem System Daten ab', options: [
{ value: AgentAccessLevel.READ, label: AGENT_ACCESS_LABELS[AgentAccessLevel.READ] },
{ value: AgentAccessLevel.WRITE, label: AGENT_ACCESS_LABELS[AgentAccessLevel.WRITE] },
],
helperText: 'Schreibrechte verändern Kundendaten in der Bewirtschaftung.',
}, },
[AgentAccessLevel.READ_WRITE]: { ]
bg: DS_SURFACE.info.bg,
border: DS_SURFACE.info.border,
text: DS_TEXT.infoDark,
icon: PenLine,
marker: 'Lese- und Schreibzugriff — verändert bestehende Daten',
},
}
const GROUP_TITLE_READ = 'Lesende Systemzugänge'
const GROUP_TITLE_WRITE = 'Schreibende Systemzugänge'
function systemCount(count: number): string {
return count === 1 ? '1 Fachsystem' : `${count} Fachsysteme`
}
// ── Eine Systemzeile ────────────────────────────────────────────────────────── // ── Eine Systemzeile ──────────────────────────────────────────────────────────
interface RowProps {
system: AgentSystem
busy: boolean
editing: boolean
onToggle: (id: string, enabled: boolean) => void
onEdit: (id: string) => void
onDelete: (id: string) => void
}
/** /**
* `React.memo`, weil das Personalblatt bis zu einem Dutzend Systemzeilen führt * `React.memo`, weil das Personalblatt bis zu einem Dutzend Systemzeilen führt
* und jeder Reiterwechsel sonst die gesamte Liste neu zeichnen würde (§10.2). * und jeder Reiterwechsel sonst die gesamte Liste neu zeichnen würde (§10.2).
*/ */
const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) { const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onEdit, onDelete }: RowProps) {
const look = ACCESS_APPEARANCE[system.access] const aktiv = istAktiv(system)
const AccessIcon = look.icon const schreibend = istSchreibend(system)
const titel = systemTitel(system)
return ( return (
<Box <Box
component="li" component="li"
sx={{ sx={{
border: `1px solid ${look.border}`, border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2, borderRadius: 2,
bgcolor: look.bg, // Ein abgeschalteter Zugang tritt zurück, bleibt aber vollständig lesbar.
bgcolor: aktiv ? DS_BG.surface : DS_BG.subtle,
opacity: aktiv ? 1 : 0.75,
p: 1.75, p: 1.75,
display: 'grid', display: 'grid',
gap: 0.875, gap: 0.75,
}} }}
> >
{/* Kopfzeile: System, Zugriffstyp, Verbindungsstatus */} {/* Kopfzeile: Titel links, genau zwei Angaben rechts (§2.1B) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} />
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}> <Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{AGENT_SYSTEM_LABELS[system.type] ?? system.type} {titel}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto' }}> <Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto', alignItems: 'center' }}>
<AgentAccessBadge access={system.access} /> <GenericBadge
<AgentConnectionStatusBadge status={system.status} /> label={aktiv ? 'Aktiv' : 'Inaktiv'}
semanticVariant={aktiv ? 'confidenceHigh' : 'muted'}
bold
/>
<GenericBadge
label={schreibend ? 'Schreiben' : 'Lesen'}
semanticVariant={schreibend ? 'confidenceMedium' : 'muted'}
/>
{editing && (
<>
<Tooltip title="System bearbeiten">
<IconButton size="small" onClick={() => onEdit(system.id)} sx={{ color: DS_TEXT.secondary }}>
<Pencil size={14} />
</IconButton>
</Tooltip>
<Tooltip title="System entfernen">
<IconButton size="small" onClick={() => onDelete(system.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</>
)}
</Box> </Box>
</Box> </Box>
{/* Zugriffstyp im Klartext — nie nur farblich */}
<Typography variant="body2" sx={{ color: look.text, fontWeight: 600 }}>
{look.marker}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}> <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{system.usage} {system.usage}
</Typography> </Typography>
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */} {/* Die Adresse steht offen da — sie ist genau das, was abgerufen wird. */}
<Box {system.url && (
sx={{ <Link
display: 'flex', href={system.url}
gap: 0.75, target="_blank"
alignItems: 'flex-start', rel="noopener noreferrer"
p: 1, variant="caption"
borderRadius: 1.5, sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.brand, width: 'fit-content' }}
bgcolor: DS_BG.surface, >
border: `1px solid ${DS_BORDER.default}`, {system.url}
}} <ExternalLink size={11} aria-hidden />
> </Link>
<ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} /> )}
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
<Box component="span" sx={{ fontWeight: 700 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Berechtigung:{' '} <Switch
</Box> size="small"
{system.permissionNote} checked={aktiv}
disabled={busy}
onChange={(_, checked) => onToggle(system.id, checked)}
slotProps={{ input: { 'aria-label': `Systemzugang ${titel} aktivieren` } }}
/>
<Typography variant="caption" sx={{ color: aktiv ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 600 }}>
{aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>
) )
}) })
// ── Eine Gruppe ───────────────────────────────────────────────────────────────
function SystemGroup({ title, systems }: { title: string; systems: AgentSystem[] }) {
if (systems.length === 0) return null
return (
<Box component="section" aria-label={title}>
<DetailSectionTitle>{title}</DetailSectionTitle>
<Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
{systems.map((system) => (
<SystemRow key={system.id} system={system} />
))}
</Box>
</Box>
)
}
// ── Reiter ──────────────────────────────────────────────────────────────────── // ── Reiter ────────────────────────────────────────────────────────────────────
export function AgentSystemsTab({ agent }: { agent: TeamAgent }) { export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
const { reading, writing } = useMemo(() => { const { mutate: saveSystems, isPending } = useSaveAgentSystems()
const readOnly: AgentSystem[] = []
const writable: AgentSystem[] = []
for (const system of agent.systems) {
if (system.access === AgentAccessLevel.READ) readOnly.push(system)
else writable.push(system)
}
return { reading: readOnly, writing: writable }
}, [agent.systems])
const intro = useMemo(() => { /** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Zugangs. */
const name = `«${agent.name}»` const [bearbeitet, setBearbeitet] = useState<string | null>(null)
if (reading.length > 0 && writing.length > 0) { const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
return `${name} liest ${systemCount(reading.length)} und schreibt nach Freigabe in ${systemCount(writing.length)}.`
} const systems = agent.systems
if (reading.length > 0) {
return `${name} liest ${systemCount(reading.length)} und schreibt in keines davon.` /**
} * Anders als bei den Aufgaben wirkt hier jede Änderung sofort — ein
if (writing.length > 0) { * Systemzugang ist ein Ein/Aus-Entscheid wie beim Kanalreiter, kein Formular
return `${name} schreibt nach Freigabe in ${systemCount(writing.length)}; weitere Zugänge bestehen nicht.` * mit Zwischenstand. Zwei Speicherwege nebeneinander wären zwei Wahrheiten.
} */
return `Für ${name} ist bisher kein Systemzugang hinterlegt.` const speichern = useCallback(
}, [agent.name, reading.length, writing.length]) (liste: AgentSystem[]) => saveSystems({ id: agent.id, systems: liste }),
[saveSystems, agent.id],
)
const handleToggle = useCallback(
(id: string, enabled: boolean) => {
speichern(systems.map((s) => (s.id === id ? { ...s, enabled } : s)))
},
[speichern, systems],
)
const handleDelete = useCallback(
(id: string) => speichern(removeById(systems, id)),
[speichern, systems],
)
const handleDialogSave = useCallback(
(werte: EntityDraft, active: boolean) => {
const bestehend = bearbeitet !== 'new' ? systems.find((s) => s.id === bearbeitet) : undefined
const url = werte.url?.trim() ?? ''
const eintrag: AgentSystem = {
// Der Typ bleibt beim Bearbeiten erhalten; selbst erfasste Quellen sind
// öffentliche Webquellen, weil genau das über die Adresse gelesen wird.
type: bestehend?.type ?? AgentSystemType.PUBLIC_WEB,
status: bestehend?.status ?? AgentConnectionStatus.CONNECTED,
permissionNote: bestehend?.permissionNote ?? '',
id: bestehend?.id ?? newEntityId(agent.id, 'system'),
title: werte.title.trim(),
usage: werte.description.trim(),
access: werte.access as AgentAccessLevel,
enabled: active,
...(url ? { url } : {}),
}
speichern(upsertById(systems, eintrag))
setBearbeitet(null)
},
[bearbeitet, systems, agent.id, speichern],
)
const inBearbeitung = bearbeitet && bearbeitet !== 'new'
? systems.find((s) => s.id === bearbeitet)
: undefined
const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
return ( return (
<Box sx={{ display: 'grid', gap: 2.5 }}> <Box sx={{ display: 'grid', gap: 2 }}>
{/* Einordnung — dieser Reiter zeigt den Stand, er verändert ihn nicht */} <Box>
<Box <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
sx={{ Diese Systeme und Datenquellen darf {agent.name} verwenden. Ein abgeschalteter Zugang wird nicht
border: `1px solid ${DS_BORDER.default}`, mehr abgefragt.
borderRadius: 2,
bgcolor: DS_BG.subtle,
p: 1.5,
}}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
{intro}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
Diese Übersicht zeigt den aktuellen Stand. Eingerichtet werden Systemzugänge auf der Seite «Kanäle &
Systeme».
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<Pencil size={14} aria-hidden />}
onClick={() => setBearbeitungsmodus((v) => !v)}
sx={{ textTransform: 'none', ...focusRing }}
>
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Systeme bearbeiten'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Plus size={14} aria-hidden />}
onClick={() => setBearbeitet('new')}
sx={{ textTransform: 'none', ...focusRing }}
>
Neues System anbinden
</Button>
</Box>
</Box> </Box>
{agent.systems.length === 0 ? ( {systems.length === 0 ? (
<Box <Box
sx={{ sx={{
border: `1px solid ${DS_BORDER.default}`, border: `1px solid ${DS_BORDER.default}`,
@@ -212,11 +263,39 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
</Typography> </Typography>
</Box> </Box>
) : ( ) : (
<> <Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
<SystemGroup title={GROUP_TITLE_READ} systems={reading} /> {systems.map((system) => (
<SystemGroup title={GROUP_TITLE_WRITE} systems={writing} /> <SystemRow
</> key={system.id}
system={system}
busy={isPending}
editing={bearbeitungsmodus}
onToggle={handleToggle}
onEdit={setBearbeitet}
onDelete={handleDelete}
/>
))}
</Box>
)} )}
<AgentEntityDialog
open={bearbeitet !== null}
entityKey={bearbeitet ?? undefined}
title={inBearbeitung ? 'System bearbeiten' : 'Neues System anbinden'}
fields={SYSTEM_FIELDS}
initial={{
title: inBearbeitung ? systemTitel(inBearbeitung) : '',
description: inBearbeitung?.usage ?? '',
url: inBearbeitung?.url ?? '',
access: inBearbeitung && istSchreibend(inBearbeitung)
? AgentAccessLevel.WRITE
: AgentAccessLevel.READ,
}}
activeLabel="Zugang aktiv"
initialActive={inBearbeitung ? istAktiv(inBearbeitung) : true}
onSave={handleDialogSave}
onClose={() => setBearbeitet(null)}
/>
</Box> </Box>
) )
} }
+128 -7
View File
@@ -9,11 +9,14 @@
*/ */
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import { Box, Button, Switch, Typography } from '@mui/material' import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material'
import { RotateCcw, Save, ShieldCheck } from 'lucide-react' import { Pencil, Plus, RotateCcw, Save, ShieldCheck, Trash2 } from 'lucide-react'
import type { AgentTask, TeamAgent } from '../../domain/teamAgent' import type { AgentTask, TeamAgent } from '../../domain/teamAgent'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { GenericBadge } from '../shared/GenericBadge' import { GenericBadge } from '../shared/GenericBadge'
import { AgentEntityDialog } from './AgentEntityDialog'
import type { EntityDraft, EntityField } from './AgentEntityDialog'
import { newEntityId, removeById, upsertById } from './agentEntityEditing'
import { useSaveAgentTasks } from '../../hooks/useTeamAgents' import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
// ── Eine Aufgabenzeile ──────────────────────────────────────────────────────── // ── Eine Aufgabenzeile ────────────────────────────────────────────────────────
@@ -22,13 +25,17 @@ interface TaskRowProps {
task: AgentTask task: AgentTask
busy: boolean busy: boolean
onToggle: (id: string, enabled: boolean) => void onToggle: (id: string, enabled: boolean) => void
/** Im Bearbeitungsmodus erscheinen Stift und Papierkorb an der Zeile (§2A). */
editing: boolean
onEdit: (id: string) => void
onDelete: (id: string) => void
} }
/** /**
* `React.memo` ist Pflicht: ein Mitarbeiter führt bis zu zwölf Aufgaben, und * `React.memo` ist Pflicht: ein Mitarbeiter führt bis zu zwölf Aufgaben, und
* jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2). * jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2).
*/ */
const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) { const TaskRow = memo(function TaskRow({ task, busy, onToggle, editing, onEdit, onDelete }: TaskRowProps) {
const handleChange = useCallback( const handleChange = useCallback(
(_event: unknown, checked: boolean) => onToggle(task.id, checked), (_event: unknown, checked: boolean) => onToggle(task.id, checked),
[onToggle, task.id], [onToggle, task.id],
@@ -71,9 +78,26 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
</Box> </Box>
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
{task.title} <Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary, flex: 1 }}>
</Typography> {task.title}
</Typography>
{editing && (
<Box sx={{ display: 'flex', gap: 0.25, flexShrink: 0 }}>
<Tooltip title="Aufgabe bearbeiten">
<IconButton size="small" onClick={() => onEdit(task.id)} sx={{ color: DS_TEXT.secondary }}>
<Pencil size={14} />
</IconButton>
</Tooltip>
<Tooltip title="Aufgabe löschen">
<IconButton size="small" onClick={() => onDelete(task.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
{/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle {/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle
Wortlaut steht am Element. */} Wortlaut steht am Element. */}
@@ -111,10 +135,27 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
// ── Register «Aufgaben» ─────────────────────────────────────────────────────── // ── Register «Aufgaben» ───────────────────────────────────────────────────────
/** Die Felder der Aufgabenmaske (Runde 10, §2A). */
const TASK_FIELDS: EntityField[] = [
{ key: 'title', label: 'Aufgabentitel', required: true },
{ key: 'description', label: 'Aufgabenbeschreibung', type: 'multiline', required: true },
{
key: 'schedule',
label: 'Auslöser oder Rhythmus',
placeholder: 'z. B. täglich 06:00 oder bei Eingang',
helperText: 'Wann die Aufgabe läuft — steht so auch im Personalblatt.',
},
]
export function AgentTasksTab({ agent }: { agent: TeamAgent }) { export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
const [draft, setDraft] = useState<AgentTask[]>(agent.tasks) const [draft, setDraft] = useState<AgentTask[]>(agent.tasks)
const saveTasks = useSaveAgentTasks() const saveTasks = useSaveAgentTasks()
/** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Eintrags. */
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
/** Zeigt die Bearbeiten- und Löschknöpfe an den Zeilen. */
const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
/** /**
* Beim Wechsel des Mitarbeiters wird der Entwurf neu aufgesetzt — sonst stünde * Beim Wechsel des Mitarbeiters wird der Entwurf neu aufgesetzt — sonst stünde
* der Entwurf des zuvor gewählten Mitarbeiters in einem fremden Personalblatt. * der Entwurf des zuvor gewählten Mitarbeiters in einem fremden Personalblatt.
@@ -147,6 +188,40 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
saveTasks.mutate({ id: agent.id, tasks: draft }) saveTasks.mutate({ id: agent.id, tasks: draft })
}, [saveTasks, agent.id, draft]) }, [saveTasks, agent.id, draft])
const handleDelete = useCallback((id: string) => {
setDraft(prev => removeById(prev, id))
}, [])
/**
* Anlegen und Bearbeiten teilen sich die Maske und diesen Griff.
*
* Der Eintrag landet im Entwurf und nicht direkt im Bestand — gespeichert
* wird wie bisher bewusst erst über «Änderungen speichern». Zwei
* Speicherwege für dieselbe Liste wären zwei Wahrheiten darüber, was gerade
* gilt.
*/
const handleDialogSave = useCallback((werte: EntityDraft, active: boolean) => {
setDraft(prev => {
const bestehend = bearbeitet !== 'new' ? prev.find(t => t.id === bearbeitet) : undefined
const eintrag: AgentTask = {
...(bestehend ?? {
id: newEntityId(agent.id, 'task'),
requiresApproval: false,
} as AgentTask),
title: werte.title.trim(),
description: werte.description.trim(),
schedule: werte.schedule?.trim() || 'auf Auslösung',
enabled: active,
}
return upsertById(prev, eintrag)
})
setBearbeitet(null)
}, [bearbeitet, agent.id])
const inBearbeitung = bearbeitet && bearbeitet !== 'new'
? draft.find(t => t.id === bearbeitet)
: undefined
const busy = saveTasks.isPending const busy = saveTasks.isPending
const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } } const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
@@ -160,6 +235,28 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
Speichern für {agent.name} wirksam. Speichern für {agent.name} wirksam.
</Typography> </Typography>
{/* Auf Höhe der Überschrift, im Stil des bestehenden «Konfiguration öffnen» (§2A). */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
<Button
size="small"
variant="outlined"
startIcon={<Pencil size={14} aria-hidden />}
onClick={() => setBearbeitungsmodus(v => !v)}
sx={{ textTransform: 'none', ...focusRing }}
>
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Aufgaben bearbeiten'}
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Plus size={14} aria-hidden />}
onClick={() => setBearbeitet('new')}
sx={{ textTransform: 'none', ...focusRing }}
>
Neue Aufgabe
</Button>
</Box>
{dirty && ( {dirty && (
<Box <Box
role="status" role="status"
@@ -227,10 +324,34 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
}} }}
> >
{draft.map((task) => ( {draft.map((task) => (
<TaskRow key={task.id} task={task} busy={busy} onToggle={handleToggle} /> <TaskRow
key={task.id}
task={task}
busy={busy}
onToggle={handleToggle}
editing={bearbeitungsmodus}
onEdit={setBearbeitet}
onDelete={handleDelete}
/>
))} ))}
</Box> </Box>
)} )}
<AgentEntityDialog
open={bearbeitet !== null}
title={inBearbeitung ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}
fields={TASK_FIELDS}
entityKey={bearbeitet ?? undefined}
initial={{
title: inBearbeitung?.title ?? '',
description: inBearbeitung?.description ?? '',
schedule: inBearbeitung?.schedule ?? '',
}}
activeLabel="Aufgabe aktiv"
initialActive={inBearbeitung?.enabled ?? true}
onSave={handleDialogSave}
onClose={() => setBearbeitet(null)}
/>
</Box> </Box>
) )
} }
+4 -1
View File
@@ -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>
) )
} }
@@ -0,0 +1,155 @@
/**
* Property On — «Newsquellen manuell ablegen» (Runde 10, §2.6).
*
* Ersetzt die Liste verknüpfter Newsadressen im Einstellungsbereich. Der Grund
* für den Tausch: Die Adressen standen dort als Auswahlfeld, ohne dass sie
* irgendetwas bewirkt hätten — abgerufen wird, was unter «Systeme» als aktiver
* Lesezugang steht. Zwei Orte für dieselbe Frage, von denen einer wirkungslos
* war, ist schlechter als einer.
*
* Was hier abgelegt wird, fliesst dagegen tatsächlich in den nächsten Lauf:
* Der Text wird beim Ablegen einmal aus der Datei gewonnen und geht zusammen
* mit den gelesenen Seiten in dieselbe Beurteilung.
*
* Die Datei selbst verlässt den Browser nie — an den Server geht nur ihr Text.
*/
import { memo, useCallback, useRef } from 'react'
import { Box, Button, CircularProgress, IconButton, Tooltip, Typography } from '@mui/material'
import { AlertTriangle, FileText, Trash2, Upload } from 'lucide-react'
import {
RESEARCH_DOCUMENT_LABELS,
RESEARCH_DOCUMENT_MAX_BYTES,
} from '../../domain/researchDocument'
import type { ResearchDocument } from '../../domain/researchDocument'
import {
useRemoveResearchDocument,
useResearchDocuments,
useUploadResearchDocument,
} from '../../hooks/useResearchDocuments'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
const FOCUS_SX = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
function groesse(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function datum(iso: string): string {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const DokumentZeile = memo(function DokumentZeile({
doc, onRemove, busy,
}: { doc: ResearchDocument; onRemove: (id: string) => void; busy: boolean }) {
const unbrauchbar = Boolean(doc.extractionNote)
return (
<Box
component="li"
sx={{
display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.25,
border: `1px solid ${unbrauchbar ? DS_SURFACE.warning.border : DS_BORDER.default}`,
borderRadius: 2,
bgcolor: unbrauchbar ? DS_SURFACE.warning.bg : DS_BG.surface,
}}
>
<FileText size={15} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.8125rem', color: DS_TEXT.primary, wordBreak: 'break-word' }}>
{doc.name}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block' }}>
{RESEARCH_DOCUMENT_LABELS[doc.kind]} · {groesse(doc.size)} · abgelegt am {datum(doc.uploadedAt)}
</Typography>
{doc.extractionNote && (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', mt: 0.5 }}>
<AlertTriangle size={12} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark }}>
{doc.extractionNote}
</Typography>
</Box>
)}
</Box>
<Tooltip title="Dokument entfernen">
<span>
<IconButton size="small" disabled={busy} onClick={() => onRemove(doc.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</span>
</Tooltip>
</Box>
)
})
export function ResearchDocumentsField() {
const { data: dokumente = [], isLoading } = useResearchDocuments()
const upload = useUploadResearchDocument()
const remove = useRemoveResearchDocument()
const inputRef = useRef<HTMLInputElement>(null)
const handleFiles = useCallback(
(files: FileList | null) => {
if (!files) return
// Nacheinander: Die Extraktion ist rechenintensiv, und mehrere gleichzeitig
// würden den Hauptthread für Sekunden blockieren.
for (const file of Array.from(files)) upload.mutate(file)
// Ohne Zurücksetzen liesse sich dieselbe Datei nicht erneut wählen.
if (inputRef.current) inputRef.current.value = ''
},
[upload],
)
const handleRemove = useCallback((id: string) => remove.mutate(id), [remove])
return (
<Box sx={{ display: 'grid', gap: 1.25 }}>
<input
ref={inputRef}
type="file"
accept=".pdf,.docx"
multiple
hidden
onChange={e => handleFiles(e.target.files)}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
size="small"
variant="outlined"
disabled={upload.isPending}
startIcon={upload.isPending
? <CircularProgress size={13} color="inherit" />
: <Upload size={14} aria-hidden />}
onClick={() => inputRef.current?.click()}
sx={{ textTransform: 'none', ...FOCUS_SX }}
>
{upload.isPending ? 'Wird gelesen …' : 'Dokument ablegen'}
</Button>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
PDF oder Word (.docx), höchstens {RESEARCH_DOCUMENT_MAX_BYTES / 1024 / 1024} MB je Datei
</Typography>
</Box>
{isLoading ? (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Dokumente werden geladen …</Typography>
) : dokumente.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Noch nichts abgelegt. Abgelegte Dokumente gehen zusammen mit den aktiven Lesequellen in den
nächsten Recherchelauf.
</Typography>
) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 0.75 }}>
{dokumente.map(doc => (
<DokumentZeile key={doc.id} doc={doc} onRemove={handleRemove} busy={remove.isPending} />
))}
</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>
@@ -0,0 +1,67 @@
/**
* Die drei Listengriffe der Agentenverwaltung (Runde 10, §2).
*
* Geprüft wird, was in den Reitern Aufgaben, Systeme und Kanäle täglich
* passiert: einen Eintrag ändern, einen anlegen, einen entfernen — und dass
* die übrige Reihenfolge dabei unberührt bleibt.
*/
import { describe, expect, it } from 'vitest'
import { newEntityId, removeById, upsertById } from '../agentEntityEditing'
interface Eintrag {
id: string
titel: string
}
const liste: Eintrag[] = [
{ id: 'a', titel: 'Erster' },
{ id: 'b', titel: 'Zweiter' },
{ id: 'c', titel: 'Dritter' },
]
describe('upsertById', () => {
it('ersetzt einen bestehenden Eintrag an Ort und Stelle', () => {
const ergebnis = upsertById(liste, { id: 'b', titel: 'Geändert' })
expect(ergebnis.map((e) => e.id)).toEqual(['a', 'b', 'c'])
expect(ergebnis[1].titel).toBe('Geändert')
})
it('hängt einen neuen Eintrag ans Ende', () => {
const ergebnis = upsertById(liste, { id: 'd', titel: 'Vierter' })
expect(ergebnis.map((e) => e.id)).toEqual(['a', 'b', 'c', 'd'])
})
it('lässt die übergebene Liste unverändert', () => {
upsertById(liste, { id: 'b', titel: 'Geändert' })
expect(liste[1].titel).toBe('Zweiter')
expect(liste).toHaveLength(3)
})
})
describe('removeById', () => {
it('entfernt genau einen Eintrag', () => {
expect(removeById(liste, 'b').map((e) => e.id)).toEqual(['a', 'c'])
})
it('lässt eine Liste ohne Treffer unverändert', () => {
expect(removeById(liste, 'unbekannt').map((e) => e.id)).toEqual(['a', 'b', 'c'])
})
})
describe('newEntityId', () => {
it('stellt Agent und Art voran, damit die Kennung zuordenbar bleibt', () => {
expect(newEntityId('livia', 'task')).toMatch(/^livia-task-/)
})
it('vergibt für jeden Aufruf eine eigene Kennung', () => {
const kennungen = new Set(
Array.from({ length: 50 }, () => newEntityId('nora', 'system')),
)
expect(kennungen.size).toBe(50)
})
})
+47
View File
@@ -0,0 +1,47 @@
/**
* Property On — die gemeinsamen Listenoperationen der Agentenverwaltung
* (Runde 10, §2).
*
* Aufgaben, Systeme und Kanäle werden alle drei als Liste geführt und gleich
* bearbeitet: einen Eintrag ändern, einen anlegen, einen entfernen. Diese drei
* Griffe stehen deshalb einmal hier statt dreimal in den Reitern — sonst
* unterscheiden sie sich nach der ersten Korrektur.
*
* Bewusst reine Funktionen ohne React: Sie lassen sich so ohne Renderer prüfen,
* und die Reiter behalten die Hoheit über ihren Entwurf.
*/
interface MitId { id: string }
/**
* Eintrag ersetzen oder anhängen.
*
* Neue Einträge kommen ans Ende und nicht an den Anfang: Wer etwas anlegt,
* sucht es dort, wo die Liste weitergeht — und die gewachsene Reihenfolge der
* bestehenden Einträge bleibt unangetastet.
*/
export function upsertById<T extends MitId>(list: T[], eintrag: T): T[] {
const idx = list.findIndex(e => e.id === eintrag.id)
if (idx === -1) return [...list, eintrag]
const kopie = [...list]
kopie[idx] = eintrag
return kopie
}
export function removeById<T extends MitId>(list: T[], id: string): T[] {
return list.filter(e => e.id !== id)
}
/**
* Kennung für einen neu angelegten Eintrag.
*
* Präfix aus Agent und Entität, damit man einer Kennung im Zweifel ansieht,
* wohin sie gehört. `crypto.randomUUID` ist in allen Zielbrowsern vorhanden;
* der Zeitstempel-Rückfall greift nur in Testumgebungen ohne Web-Crypto.
*/
export function newEntityId(agentId: string, art: string): string {
const zufall = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().slice(0, 8)
: Date.now().toString(36)
return `${agentId}-${art}-${zufall}`
}
+4 -1
View File
@@ -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'
+21 -8
View File
@@ -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>) => {
+38
View File
@@ -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>
)
})
+3
View File
@@ -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>
</> </>
)} )}
+66
View File
@@ -1,4 +1,5 @@
import type { SignalType, RiskLevel, ReviewStatus } from './enums' import type { SignalType, RiskLevel, ReviewStatus } from './enums'
import type { TemporalClass } from './researchLead'
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON' export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW' export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
@@ -71,4 +72,69 @@ 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[]
// ── Runde 10 ────────────────────────────────────────────────────────────────
/**
* Wann die beschriebene Veränderung stattfindet (§2.9).
*
* Ausdrücklich nicht dasselbe wie `source.publishedAt` und auch nicht
* dasselbe wie `timeHorizonMonths`: Der Horizont ist eine Schätzung in
* Monaten, diese Klasse eine belegte Einordnung mit Fundstelle. Optional,
* weil Bestandssignale sie nicht tragen — fehlt sie, gilt der Lead als
* zeitlich nicht eingeordnet und fällt durch keinen Zeitfilter.
*/
temporalClass?: TemporalClass
/** Die Textstelle, die die zeitliche Einordnung trägt. Ohne sie ist sie nicht prüfbar. */
temporalEvidence?: string
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
eventDate?: string
/**
* Ausformuliertes Nachfrageprofil (§2.13B).
*
* Einzige Wahrheit für Livia und Nora: Livia erstellt es, Nora gleicht es
* gegen den Bestand ab. Nora erzeugt bewusst keinen eigenen Text — zwei
* Fassungen desselben Profils wären zwei Aussagen über denselben Bedarf.
*/
semanticSearchProfile?: string
/**
* Herkunft des Signals. `RESEARCH_RUN` markiert Leads, die ein
* Recherchelauf erzeugt hat — sie stehen in derselben Liste wie die
* erfassten und unterscheiden sich nur durch dieses Feld, nicht durch eine
* eigene Ablage (§2.12).
*/
origin?: 'SEED' | 'RESEARCH_RUN'
} }
// ── 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]
+32
View File
@@ -0,0 +1,32 @@
/**
* Property On — ein durchgeführter Matching-Lauf (Runde 10, §§3.11/3.13).
*
* Der Lauf wird festgehalten, weil zwei Anzeigen davon abhängen: das Protokoll
* und die Kennzahl «Anzahl durchgeführte Matchings». Beide sollen zählen, was
* tatsächlich passiert ist — nicht, wie oft jemand eine Seite geöffnet hat.
*
* Deshalb ist ein MatchRun eine eigene Entität und kein Nebeneffekt des
* Renderns: Ein Wert, der aus dem Rendern entsteht, wächst bei jedem
* Neuaufbau der Komponente, und niemand könnte dem Zähler noch trauen.
*/
export interface MatchRun {
id: string
/** Der Lead, für den gematcht wurde — dieselbe Kennung wie bei Livia. */
leadId: string
/** Firma oder Titel des Leads, damit das Protokoll ohne Nachschlagen lesbar ist. */
leadTitle: string
timestamp: string
/** Wie viele Objekte in den Kandidatenkreis kamen. */
candidateCount: number
/** Ob belegte Objekte mitgeprüft wurden. */
includedOccupied: boolean
/** Bester erreichter Wert — die Zahl, die den Lauf zusammenfasst. */
topScore: number
/**
* Ob der Lauf aus Livias «Matching starten» kam oder auf Noras Seite
* ausgelöst wurde. Beides zählt gleich, aber beim Nachvollziehen ist der
* Unterschied die erste Frage.
*/
trigger: 'LIVIA_HANDOVER' | 'USER'
}
+61
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
/**
* Property On — manuell abgelegte Newsquellen (Runde 10, §2.6).
*
* Nicht jede Quelle ist eine Webseite. Ein Geschäftsbericht, ein
* Verbandsrundschreiben, ein Protokoll aus einer Gemeindeversammlung — das
* kommt als PDF oder Word-Datei auf den Tisch und hat keine Adresse, die
* Livia abrufen könnte.
*
* Solche Dokumente sind deshalb kein eigener Datentyp mit eigener Behandlung,
* sondern eine zweite Art, Text in denselben Recherchelauf zu geben. Was am
* Ende beurteilt wird, ist in beiden Fällen Text mit einem Namen und einer
* Herkunft.
*/
export const ResearchDocumentKind = {
PDF: 'PDF',
DOCX: 'DOCX',
} as const
export type ResearchDocumentKind = typeof ResearchDocumentKind[keyof typeof ResearchDocumentKind]
export const RESEARCH_DOCUMENT_LABELS: Record<ResearchDocumentKind, string> = {
PDF: 'PDF',
DOCX: 'Word',
}
/** Was von einem abgelegten Dokument geführt wird. */
export interface ResearchDocument {
id: string
/** Dateiname, wie er beim Ablegen hiess — die einzige Bezeichnung, die der Nutzer wiedererkennt. */
name: string
kind: ResearchDocumentKind
/** Grösse in Bytes; wird angezeigt, damit ein leeres Dokument auffällt. */
size: number
uploadedAt: string
/**
* Der extrahierte Text.
*
* Er wird beim Ablegen einmal gewonnen und danach nie wieder — die
* Extraktion ist der teure Teil, und ihn bei jedem Lauf zu wiederholen
* würde denselben Text ein zweites Mal ergeben.
*/
text: string
/**
* Gesetzt, wenn aus der Datei kein brauchbarer Text zu holen war (etwa ein
* gescanntes PDF ohne Textebene). Das Dokument bleibt in der Liste — mit
* Hinweis, statt stillschweigend nichts beizutragen.
*/
extractionNote?: string
}
/** Obergrenze je Datei. Grössere Dateien sprengen den Speicher des Browsers. */
export const RESEARCH_DOCUMENT_MAX_BYTES = 15 * 1024 * 1024
/** Ab wie vielen Zeichen ein extrahierter Text als brauchbar gilt. */
export const RESEARCH_DOCUMENT_MIN_CHARS = 200
+150
View File
@@ -0,0 +1,150 @@
/**
* 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
// ── Runde 10 ────────────────────────────────────────────────────────────────
/** Zeitliche Dimension der beschriebenen Veränderung (§2.9). */
temporalClass: TemporalClass
/** Der Satz aus der Quelle, der die Einordnung trägt — ohne ihn ist sie nicht prüfbar. */
temporalEvidence: string
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
eventDate?: string
/**
* Ausformuliertes Nachfrageprofil für Chance-Leads (§2.13B).
*
* Kein Stichwortfeld: Nora gleicht es gegen den Objektbestand ab, und dafür
* muss darin stehen, was die Quelle hergibt — einschliesslich dessen, was sie
* nicht hergibt. Ein Profil, das Lücken verschweigt, führt zu Treffern, die
* niemand belegen kann.
*/
semanticSearchProfile?: 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
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Fenster lag (§2.9). */
temporalFiltered: 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
}
// ── Zeitliche Relevanz (Runde 10, §2.9) ───────────────────────────────────────
/**
* Wann die beschriebene Veränderung stattfindet — **nicht**, wann der Artikel
* erschienen ist.
*
* Das ist der ganze Punkt dieser Klassifikation: Ein Jahresbericht vom Januar
* kann einen Standort für das dritte Quartal ankündigen (`FUTURE`), und eine
* Meldung von heute kann einen Umzug vom Frühling beschreiben (`PAST`). Wer
* nach Publikationsdatum filtert, bekommt in beiden Fällen das Gegenteil
* dessen, was er sucht.
*
* `UNKNOWN` ist ausdrücklich erlaubt. Wenn die Quelle keine belastbare
* zeitliche Aussage enthält, ist «weiss ich nicht» die richtige Antwort —
* ein erfundenes Datum wäre die falsche.
*/
export const TemporalClass = {
PAST: 'past',
CURRENT: 'current',
FUTURE: 'future',
UNKNOWN: 'unknown',
} as const
export type TemporalClass = typeof TemporalClass[keyof typeof TemporalClass]
/** Reihenfolge im Einstellungsbereich — von abgeschlossen nach angekündigt. */
export const TEMPORAL_FILTER_ORDER: TemporalClass[] = [
TemporalClass.PAST,
TemporalClass.CURRENT,
TemporalClass.FUTURE,
]
/**
* Die Grenze zwischen «jetzt» und «später», in Wochen.
*
* Acht Wochen sind keine willkürliche Zahl: Sie entsprechen ungefähr der
* Vorlaufzeit, die eine Bewirtschaftung braucht, um auf einen Flächenbedarf
* überhaupt noch mit einem Angebot zu reagieren. Alles darunter ist ein
* laufender Vorgang, alles darüber eine Planung.
*/
export const TEMPORAL_CURRENT_WEEKS = 8
+47
View File
@@ -115,6 +115,24 @@ export interface AgentSystem {
type: AgentSystemType type: AgentSystemType
access: AgentAccessLevel access: AgentAccessLevel
status: AgentConnectionStatus status: AgentConnectionStatus
/**
* Ob der Mitarbeiter diesen Zugang derzeit nutzt. Optional, weil der Katalog
* die Zugänge ohne Schalter führt — fehlt das Feld, gilt der Zugang als aktiv.
*/
enabled?: boolean
/**
* Eigener Titel der Quelle (Runde 10, §2.1). Der Katalog benennt seine
* Zugänge über `type`; selbst angebundene Quellen haben keinen passenden
* Typ und tragen ihren Namen deshalb hier. Fehlt der Titel, gilt das Label
* des Typs — so bleiben die Katalogeinträge unverändert.
*/
title?: string
/**
* Adresse der Quelle. Bei einer aktiven Lesequelle ist das die Seite, die
* Livias Recherchelauf tatsächlich abruft — der angezeigte Link und der
* gelesene Link sind damit derselbe Wert.
*/
url?: string
/** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */ /** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */
usage: string usage: string
/** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */ /** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */
@@ -144,6 +162,18 @@ export const AgentSettingKind = {
MULTI_SELECT: 'MULTI_SELECT', MULTI_SELECT: 'MULTI_SELECT',
BOOLEAN: 'BOOLEAN', BOOLEAN: 'BOOLEAN',
TIME: 'TIME', TIME: 'TIME',
/**
* Mehrfachauswahl als Chips, jeder einzeln entfernbar (Runde 10, §§2.7–2.9).
*
* Der Unterschied zu `MULTI_SELECT` ist nicht bloss die Darstellung: Ein
* Auswahlfeld kann nur anbieten, was jemand vorher als Option hinterlegt
* hat. Quellentypen und Beobachtungsräume sind aber genau das, was sich von
* Haus zu Haus unterscheidet — sie brauchen ein Feld, in das man schreiben
* kann, was man meint.
*/
CHIPS: 'CHIPS',
/** Abgelegte Dokumente als Datenbasis der Recherche (Runde 10, §2.6). */
DOCUMENTS: 'DOCUMENTS',
} as const } as const
export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind] export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind]
@@ -174,6 +204,21 @@ export interface AgentSetting {
/** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */ /** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */
locked?: boolean locked?: boolean
lockedReason?: string lockedReason?: string
/**
* Erklärt auf Knopfdruck, wie ein Wert zustande kommt (Runde 10, §2.10).
*
* Gehört ans Datenmodell und nicht in die Komponente: Was ein Schwellenwert
* bedeutet, weiss das Personalblatt, nicht das Eingabefeld. Und ein Text,
* der eine Berechnung beschreibt, muss neben der Berechnung stehen, damit
* beide zusammen geändert werden.
*/
infoText?: string
/**
* Nur bei `CHIPS`: ob eigene Werte erfasst werden dürfen. Bei einer
* fachlich abgeschlossenen Liste — etwa den drei Zeitfenstern — bleibt es
* aus, sonst entstünde ein vierter Wert, den niemand auswerten kann.
*/
allowCustom?: boolean
} }
// ── Kennzahlen & Profil ─────────────────────────────────────────────────────── // ── Kennzahlen & Profil ───────────────────────────────────────────────────────
@@ -242,5 +287,7 @@ export interface UpdateTeamAgentInput {
status?: AgentStatus status?: AgentStatus
tasks?: AgentTask[] tasks?: AgentTask[]
channels?: AgentChannel[] channels?: AgentChannel[]
/** Systeme sind seit Runde 10 (§2) genauso bearbeitbar wie Aufgaben und Kanäle. */
systems?: AgentSystem[]
settings?: AgentSetting[] settings?: AgentSetting[]
} }
+8 -1
View File
@@ -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,284 @@
/**
* Noras Abgleich von Nachfrageprofil und Objektbestand (Runde 10, §3.7).
*
* Die Tests halten drei Zusagen fest, die man dem Prozentwert sonst nicht
* ansieht: Er ist reproduzierbar, er nimmt fehlende Objektangaben nicht als
* erfüllt an, und belegte Objekte sind nur dabei, wenn jemand sie will.
*/
import { describe, expect, it } from 'vitest'
import {
MatchAvailability,
bewerteObjekt,
fuehreMatchingAus,
leseNachfrageprofil,
verfuegbarkeitVon,
} from '../leadPropertyMatcher'
import { AssetType, AvailabilityStatus, ResultType, RiskLevel } from '../../../domain/enums'
import type { Property } from '../../../domain/property'
import type { FutureSignal } from '../../../domain/futureSignal'
import { SignalType } from '../../../domain/enums'
function objekt(overrides: Partial<Property> = {}): Property {
return {
id: 'p1',
title: 'Gewerbefläche Zürich West',
assetType: AssetType.OFFICE,
resultType: ResultType.VERIFIED_PORTFOLIO,
location: { city: 'Zürich', district: 'Kreis 5', country: 'CH' },
address: { street: 'Hardstrasse', houseNumber: '10', postalCode: '8005', city: 'Zürich', country: 'CH' },
areaSqm: 1200,
rentPricePerSqm: 320,
availabilityDate: '2026-10-01',
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
sourceType: 'MANUAL',
confidenceScore: 0.9,
dataQuality: { score: 0.9, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
riskLevel: RiskLevel.LOW,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
} as Property
}
function signal(overrides: Partial<FutureSignal> = {}): FutureSignal {
return {
id: 's1',
signalType: SignalType.EXPANSION,
companyName: 'Muster AG',
locationHint: 'Zürich',
probability: 0.8,
confidenceScore: 0.8,
timeHorizonMonths: 9,
source: { type: 'PRESS', credibility: 'HIGH' },
sensitivityLevel: 'PUBLIC',
disclaimer: '',
riskLevel: RiskLevel.LOW,
isVerified: false,
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T00:00:00.000Z',
semanticSearchProfile:
'Für die Muster AG wird aufgrund der angekündigten Expansion ein zusätzlicher Bürostandort im Raum Zürich gesucht. '
+ 'Erwartet wird eine Fläche von rund 1200 m². Wichtig ist eine gute Anbindung an den öffentlichen Verkehr. '
+ 'Nicht ableitbar aus der Quelle sind Budget und gewünschter Ausbaustand.',
...overrides,
} as FutureSignal
}
describe('verfuegbarkeitVon', () => {
it('fasst frei und bald frei zusammen', () => {
expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.AVAILABLE_NOW })))
.toBe(MatchAvailability.FREI)
expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.AVAILABLE_SOON })))
.toBe(MatchAvailability.FREI)
})
it('unterscheidet «unbekannt» von «frei»', () => {
expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.UNKNOWN })))
.toBe(MatchAvailability.UNBEKANNT)
})
})
describe('leseNachfrageprofil', () => {
it('liest Ort, Fläche, Nutzung und genannte Anforderungen aus dem Profil', () => {
const profil = leseNachfrageprofil(signal())
expect(profil.orte).toContain('zürich')
expect(profil.flaecheSqm).toBe(1200)
expect(profil.nutzung).toContain('buero')
expect(profil.anforderungen.oev).toBe(true)
})
it('erfindet keine Anforderung, die im Profil nicht steht', () => {
const profil = leseNachfrageprofil(signal())
expect(profil.anforderungen.parkierung).toBe(false)
expect(profil.anforderungen.sichtbarkeit).toBe(false)
})
it('nimmt die erfasste Flächenschätzung vor der aus dem Fliesstext', () => {
const profil = leseNachfrageprofil(signal({ areaSqmEstimate: 800 }))
expect(profil.flaecheSqm).toBe(800)
})
})
describe('bewerteObjekt', () => {
it('gibt einem passenden Objekt einen hohen Wert', () => {
const { score } = bewerteObjekt(objekt(), leseNachfrageprofil(signal()))
expect(score).toBeGreaterThanOrEqual(60)
})
it('straft den falschen Ort ab', () => {
const profil = leseNachfrageprofil(signal())
const zuerich = bewerteObjekt(objekt(), profil).score
const genf = bewerteObjekt(
objekt({
location: { city: 'Genf', country: 'CH' },
address: { street: 'Rue du Rhône', houseNumber: '1', postalCode: '1204', city: 'Genf', country: 'CH' },
}),
profil,
).score
expect(genf).toBeLessThan(zuerich)
})
it('straft eine stark abweichende Fläche ab', () => {
const profil = leseNachfrageprofil(signal())
const passend = bewerteObjekt(objekt({ areaSqm: 1200 }), profil).score
const winzig = bewerteObjekt(objekt({ areaSqm: 120 }), profil).score
expect(winzig).toBeLessThan(passend)
})
it('nimmt ein fehlendes Merkmal nicht als erfüllt an', () => {
const profil = leseNachfrageprofil(signal())
const kriterium = bewerteObjekt(objekt(), profil).kriterien.find(k => k.label === 'Erreichbarkeit')
// Das Objekt hat keinen Anbindungswert — das Kriterium wird geführt, aber
// nicht bewertet. Ein `1` an dieser Stelle wäre eine erfundene Zusage.
expect(kriterium?.erfuellung).toBeNull()
})
it('bewertet nur Kriterien, die das Profil überhaupt nennt', () => {
const knapp = signal({ semanticSearchProfile: 'Die Muster AG expandiert im Raum Zürich.' })
const labels = bewerteObjekt(objekt(), leseNachfrageprofil(knapp)).kriterien.map(k => k.label)
expect(labels).toContain('Standort')
expect(labels).not.toContain('Parkierung')
expect(labels).not.toContain('Sichtbarkeit')
})
it('liefert für dieselbe Eingabe denselben Wert', () => {
const profil = leseNachfrageprofil(signal())
expect(bewerteObjekt(objekt(), profil).score).toBe(bewerteObjekt(objekt(), profil).score)
})
})
describe('fuehreMatchingAus', () => {
const bestand = [
objekt({ id: 'frei-zuerich', areaSqm: 1200 }),
objekt({ id: 'frei-klein', areaSqm: 150 }),
objekt({ id: 'belegt', availabilityStatus: AvailabilityStatus.OCCUPIED, currentTenant: 'Alt AG' }),
]
it('lässt belegte Objekte standardmässig aussen vor', () => {
const { matches, candidateCount } = fuehreMatchingAus(signal(), bestand, { includeOccupied: false })
expect(candidateCount).toBe(2)
expect(matches.map(m => m.property.id)).not.toContain('belegt')
})
it('nimmt belegte Objekte auf Wunsch dazu', () => {
const { matches, candidateCount } = fuehreMatchingAus(signal(), bestand, { includeOccupied: true })
expect(candidateCount).toBe(3)
expect(matches.map(m => m.property.id)).toContain('belegt')
})
it('sortiert nach Match absteigend', () => {
const { matches } = fuehreMatchingAus(signal(), bestand, { includeOccupied: true })
const werte = matches.map(m => m.score)
expect([...werte].sort((a, b) => b - a)).toEqual(werte)
expect(matches[0].property.id).toBe('frei-zuerich')
})
it('ist stabil — zwei Läufe ergeben dieselbe Reihenfolge', () => {
const a = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }).matches.map(m => m.property.id)
const b = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }).matches.map(m => m.property.id)
expect(a).toEqual(b)
})
it('liefert bei leerem Bestand eine leere Liste, keinen Fehler', () => {
const { matches, candidateCount } = fuehreMatchingAus(signal(), [], { includeOccupied: false })
expect(matches).toEqual([])
expect(candidateCount).toBe(0)
})
})
describe('Abdeckung dämpft den Wert', () => {
it('gibt einem Objekt, von dem nur die Verfügbarkeit bekannt ist, keinen Spitzenwert', () => {
// Genau der Fall, der im ersten Browserdurchlauf auffiel: Ein dünnes Profil
// wirft nur ein Kriterium auf, das Objekt ist frei — und der Wert stand auf
// 100 %. «Frei, sonst nichts geprüft» ist aber keine perfekte Passung.
const duenn = signal({ semanticSearchProfile: undefined, aiSummary: undefined, title: undefined, locationHint: '' })
const { score, abdeckung } = bewerteObjekt(objekt(), leseNachfrageprofil(duenn))
// Nur die Verfügbarkeit von vier Kernkriterien — ein Fünftel der Abdeckung.
expect(abdeckung).toBeLessThan(0.3)
expect(score).toBeLessThan(60)
})
it('dämpft, wenn das Profil mehr verlangt, als das Objekt hergibt', () => {
const profil = leseNachfrageprofil(signal())
const vollstaendig = bewerteObjekt(
objekt({ hardFacts: { publicTransportScore: 10 } }),
profil,
)
const lueckenhaft = bewerteObjekt(objekt(), profil)
expect(lueckenhaft.abdeckung).toBeLessThan(vollstaendig.abdeckung)
expect(lueckenhaft.score).toBeLessThan(vollstaendig.score)
})
it('wertet eine fehlende Angabe nicht als Nichterfüllung', () => {
const profil = leseNachfrageprofil(signal())
const ohneAngabe = bewerteObjekt(objekt(), profil)
const schlechteAngabe = bewerteObjekt(objekt({ hardFacts: { publicTransportScore: 0 } }), profil)
// Unbekannt ist besser als nachweislich schlecht — sonst wäre «keine
// Angabe» dasselbe wie «erfüllt es nicht», und das ist es nicht.
expect(ohneAngabe.score).toBeGreaterThan(schlechteAngabe.score)
})
})
describe('Belegung aus dem Mietvertrag (§3.4)', () => {
const HEUTE = new Date('2026-09-13T00:00:00.000Z')
it('hält ein Objekt mit langlaufendem Vertrag für belegt', () => {
// Der Bestand setzt bei solchen Objekten «AVAILABLE_SOON» — der Vertrag
// sagt etwas anderes, und er ist die belastbarere Angabe.
const p = objekt({
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
currentTenant: 'Dauermieter AG',
leaseEndDate: '2029-12-31',
})
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.BELEGT)
})
it('hält ein Objekt mit auslaufendem Vertrag für frei', () => {
const p = objekt({
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
currentTenant: 'Bald weg AG',
leaseEndDate: '2026-10-31',
})
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.FREI)
})
it('lässt einen ausdrücklich gesetzten Status vorgehen', () => {
const p = objekt({
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
currentTenant: 'Noch drin AG',
leaseEndDate: '2029-12-31',
})
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.FREI)
})
it('hält einen Mieter ohne Enddatum für belegt — nicht für frei', () => {
const p = objekt({
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
currentTenant: 'Unbefristet AG',
leaseEndDate: undefined,
})
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.BELEGT)
})
})
@@ -0,0 +1,437 @@
/**
* Property On — Noras Abgleich von Nachfrageprofil und Objektbestand
* (Runde 10, §3.7).
*
* **Warum diese Datei und nicht `scoreCalculator.ts`:** Der bestehende Rechner
* bewertet ein `Need` — ein strukturiertes Bedarfsprofil mit erfassten
* Zahlenfeldern und einem Gewichtungsprofil. Livias Lead hat das nicht. Er hat
* einen Fliesstext, den ein Modell aus einer Zeitungsmeldung abgeleitet hat,
* und ein paar Angaben, die dabei abfielen. Diesen Text in ein `Need` zu
* pressen hiesse, Zahlen zu erfinden, die niemand erhoben hat.
*
* **Wie stattdessen gerechnet wird:** Das Suchprofil wird nach Merkmalen
* durchsucht, die darin tatsächlich vorkommen — eine Ortsangabe, eine
* Flächenangabe, eine Nutzungsart, Anforderungen an Verkehr, Parkierung oder
* Ausbau. Jedes gefundene Merkmal wird gegen das Objekt geprüft und trägt zum
* Ergebnis bei. Was im Profil nicht vorkommt, wird nicht bewertet; was im
* Objekt fehlt, gilt als **nicht bekannt** und nicht als erfüllt.
*
* Fehlende Objektangaben zählen deshalb nicht als Nichterfüllung — sie senken
* aber die Abdeckung, und die Abdeckung dämpft den Wert. Sonst käme ein
* Objekt, von dem nur die Verfügbarkeit bekannt ist, auf hundert Prozent.
*
* **Deterministisch.** Derselbe Lead und derselbe Bestand ergeben denselben
* Wert. Es gibt keinen Zufall und keine Gewichtung, die nicht unten im Code
* steht — deshalb kann der Infotext zum Match-Wert auch beschreiben, was
* wirklich passiert.
*/
import { AvailabilityStatus } from '../../domain/enums'
import type { Property } from '../../domain/property'
import type { FutureSignal } from '../../domain/futureSignal'
// ── Verfügbarkeit ─────────────────────────────────────────────────────────────
export const MatchAvailability = {
/** Sofort oder demnächst frei — der Normalfall für ein Matching. */
FREI: 'FREI',
/** Vermietet. Nur auf ausdrücklichen Wunsch im Kandidatenkreis (§3.5). */
BELEGT: 'BELEGT',
/** Der Bestand sagt nichts dazu — das ist etwas anderes als «frei». */
UNBEKANNT: 'UNBEKANNT',
} as const
export type MatchAvailability = typeof MatchAvailability[keyof typeof MatchAvailability]
/**
* Ab wann ein laufender Mietvertrag als «bald frei» gilt.
*
* Zwölf Monate sind die übliche Vorlaufzeit einer gewerblichen
* Wiedervermietung: Wer erst bei der Kündigung anfängt zu suchen, sucht zu
* spät.
*/
const BALD_FREI_MONATE = 12
/**
* Verfügbarkeit eines Objekts, normalisiert (§3.4).
*
* Der Status allein genügt nicht, und das ist keine Feinheit: Im Bestand steht
* bei keinem Objekt `OCCUPIED`, obwohl bei 27 ein laufender Mietvertrag mit
* Mieter und Enddatum erfasst ist. Wer nur den Status liest, hält den ganzen
* Bestand für frei — und der Schalter «Belegte Objekte einbeziehen» hätte
* nichts einzubeziehen.
*
* Massgeblich ist deshalb der Vertrag, wo es einen gibt: Läuft er noch länger
* als ein Jahr, ist die Fläche belegt. Läuft er demnächst aus, ist sie bald
* frei — genau der Fall, für den die Bewirtschaftung Vorlauf braucht.
*
* `referenz` ist überschreibbar, damit Tests nicht vom Kalender abhängen.
*/
export function verfuegbarkeitVon(p: Property, referenz: Date = new Date()): MatchAvailability {
// Ein ausdrücklich gesetzter Status hat Vorrang — er ist eine Aussage.
if (p.availabilityStatus === AvailabilityStatus.OCCUPIED) return MatchAvailability.BELEGT
if (p.availabilityStatus === AvailabilityStatus.AVAILABLE_NOW) return MatchAvailability.FREI
const ende = p.leaseEndDate ? new Date(p.leaseEndDate) : null
if (p.currentTenant && ende && !Number.isNaN(ende.getTime())) {
const grenze = new Date(referenz)
grenze.setMonth(grenze.getMonth() + BALD_FREI_MONATE)
return ende > grenze ? MatchAvailability.BELEGT : MatchAvailability.FREI
}
// Mieter ohne Enddatum: Der Vertrag läuft, und wann er endet, steht nirgends.
if (p.currentTenant) return MatchAvailability.BELEGT
switch (p.availabilityStatus) {
case AvailabilityStatus.AVAILABLE_SOON:
case AvailabilityStatus.FUTURE_SIGNAL:
return MatchAvailability.FREI
default:
return MatchAvailability.UNBEKANNT
}
}
// ── Merkmale aus dem Suchprofil lesen ─────────────────────────────────────────
/** Was sich aus einem Nachfrageprofil überhaupt herauslesen liess. */
export interface Nachfrageprofil {
/** Ortsnamen, die im Profil vorkommen — klein geschrieben. */
orte: string[]
/** Gesuchte Fläche in m², falls das Profil eine nennt. */
flaecheSqm?: number
/** Schlagworte zur Nutzungsart, klein geschrieben. */
nutzung: string[]
/** Weiche Anforderungen, die im Profil ausdrücklich stehen. */
anforderungen: {
oev: boolean
parkierung: boolean
ausbau: boolean
sichtbarkeit: boolean
}
}
/**
* Städte und Regionen, die im Bestand vorkommen.
*
* Bewusst eine feste Liste und keine Namenserkennung: Der Bestand ist der
* Schweizer Gewerbemarkt, und ein Freitext-Abgleich auf beliebige
* Grossbuchstaben würde bei jedem Firmennamen anschlagen.
*/
const ORTE = [
'zürich', 'zurich', 'winterthur', 'basel', 'bern', 'luzern', 'zug', 'st. gallen', 'st.gallen',
'lausanne', 'genf', 'genève', 'aarau', 'baden', 'olten', 'schlieren', 'dietikon', 'opfikon',
'wallisellen', 'dübendorf', 'wetzikon', 'uster', 'thalwil', 'horgen', 'kloten', 'regensdorf',
'pratteln', 'muttenz', 'allschwil', 'reinach', 'liestal', 'rotkreuz', 'cham', 'baar',
]
const NUTZUNG_WORTE: Record<string, string[]> = {
buero: ['büro', 'buero', 'office', 'verwaltung', 'arbeitsplätze', 'arbeitsplatz'],
lager: ['lager', 'logistik', 'distribution', 'umschlag', 'warenlager'],
produktion: ['produktion', 'fertigung', 'werkstatt', 'industrie', 'montage'],
verkauf: ['verkauf', 'retail', 'ladenfläche', 'filiale', 'showroom', 'detailhandel'],
gewerbe: ['gewerbe', 'gewerbefläche', 'gewerbestandort'],
}
/**
* Merkmale aus einem Nachfrageprofil herauslesen.
*
* Zusätzlich zum Fliesstext gehen die strukturierten Angaben des Signals ein,
* soweit vorhanden — Ortshinweis und geschätzte Fläche. Sie sind verlässlicher
* als das, was sich aus Sätzen herauslesen lässt, und gehen deshalb vor.
*/
export function leseNachfrageprofil(signal: FutureSignal): Nachfrageprofil {
const text = [
signal.semanticSearchProfile ?? '',
signal.aiSummary ?? '',
signal.title ?? '',
signal.locationHint,
].join(' ').toLowerCase()
const orte = ORTE.filter(o => text.includes(o))
// Der erfasste Ortshinweis zählt immer mit, auch wenn er nicht in der Liste steht.
const ortHinweis = signal.locationHint.split(/[,/(]/)[0].trim().toLowerCase()
if (ortHinweis && ortHinweis.length > 2 && !orte.includes(ortHinweis)) orte.push(ortHinweis)
const nutzung = Object.entries(NUTZUNG_WORTE)
.filter(([, worte]) => worte.some(w => text.includes(w)))
.map(([schluessel]) => schluessel)
return {
orte,
flaecheSqm: signal.areaSqmEstimate ?? flaecheAusText(text),
nutzung,
anforderungen: {
oev: /öffentlich(en)? verkehr|öv|bahnhof|s-bahn|tram|verkehrsanbindung|erreichbarkeit/.test(text),
parkierung: /parkplat|parkier|parking|anlieferung|lastwagen|rampe/.test(text),
ausbau: /ausbau|ausgebaut|bezugsbereit|infrastruktur|serverraum|klimatisier/.test(text),
sichtbarkeit: /sichtbar|passanten|frequenz|schaufenster|repräsentativ|prestige/.test(text),
},
}
}
/** Eine Flächenangabe aus dem Fliesstext — nur, wenn sie eindeutig als solche dasteht. */
function flaecheAusText(text: string): number | undefined {
const treffer = /(\d[\d'’.\s]{1,8})\s*(?:m²|m2|quadratmeter)/.exec(text)
if (!treffer) return undefined
const zahl = Number(treffer[1].replace(/[^\d]/g, ''))
return Number.isFinite(zahl) && zahl >= 20 ? zahl : undefined
}
// ── Bewertung ─────────────────────────────────────────────────────────────────
/** Ein geprüftes Merkmal — das ist die Erklärung hinter dem Prozentwert. */
export interface MatchKriterium {
label: string
/** 0–1, oder null wenn das Objekt dazu nichts sagt. */
erfuellung: number | null
/** Wie stark das Kriterium zählt, wenn es geprüft werden konnte. */
gewicht: number
/** Ein Satz, der den Wert erklärt. */
hinweis: string
}
export interface PropertyMatch {
property: Property
/** 0–100. */
score: number
kriterien: MatchKriterium[]
verfuegbarkeit: MatchAvailability
/**
* Anteil des Gewichts, das sich am Objekt tatsächlich prüfen liess (0–1).
*
* Sie steht neben dem Wert, weil beide zusammen erst die Aussage ergeben:
* 80 % bei voller Abdeckung heisst etwas anderes als 80 % bei halber.
*/
abdeckung: number
}
/** Gewichte der Kriterien. Sie stehen hier, damit der Infotext sie nicht erfinden muss. */
const GEWICHT = {
ort: 3,
flaeche: 3,
nutzung: 2,
verfuegbarkeit: 2,
oev: 1,
parkierung: 1,
ausbau: 1,
sichtbarkeit: 1,
} as const
/**
* Gewicht der vier Kernkriterien — Standort, Fläche, Nutzungsart, Verfügbarkeit.
*
* Sie stehen immer im Nenner der Abdeckung, auch wenn das Profil sie gar nicht
* nennt. Das ist der Punkt: Ein Suchprofil ohne Flächenangabe ist nicht
* «vollständig geprüft», sondern eines, bei dem eine tragende Angabe fehlt.
* Die weichen Kriterien zählen dagegen nur mit, wenn das Profil sie verlangt —
* sonst würde jedes Profil ohne Parkierungswunsch gedämpft, obwohl nichts fehlt.
*/
const KERN_GEWICHT = GEWICHT.ort + GEWICHT.flaeche + GEWICHT.nutzung + GEWICHT.verfuegbarkeit
/**
* Wie stark eine dünne Abdeckung den Wert dämpft.
*
* Bei voller Abdeckung bleibt der Wert unverändert, bei gar keiner sinkt er
* auf 40 %. Die Untergrenze ist kein Rundungsartefakt: Ein Objekt, von dem nur
* die Verfügbarkeit bekannt ist, soll unterhalb jedes belegten Treffers landen
* und trotzdem sichtbar bleiben — «wir wissen zu wenig» ist eine Aussage,
* «kein Treffer» wäre eine andere.
*/
const ABDECKUNG_BASIS = 0.4
/** Wie nah zwei Flächen beieinander liegen — 1 bei Gleichheit, 0 ab Faktor drei. */
function flaechenNaehe(objekt: number, gesucht: number): number {
if (objekt <= 0 || gesucht <= 0) return 0
const verhaeltnis = objekt >= gesucht ? objekt / gesucht : gesucht / objekt
if (verhaeltnis <= 1.25) return 1
if (verhaeltnis >= 3) return 0
return 1 - (verhaeltnis - 1.25) / 1.75
}
function normiert(wert: number | undefined, max: number): number | null {
if (wert == null) return null
return Math.max(0, Math.min(1, wert / max))
}
/**
* Ein Objekt gegen ein Nachfrageprofil bewerten.
*
* Gerechnet wird nur über die Kriterien, die tatsächlich geprüft werden
* konnten. Ein Objekt ohne Angabe zur Parkierung verliert dadurch keine
* Punkte — es bekommt aber auch keine. Der Prozentwert sagt damit «so gut
* passt, was wir wissen», nicht «so gut passt es».
*/
export function bewerteObjekt(p: Property, profil: Nachfrageprofil): PropertyMatch {
const kriterien: MatchKriterium[] = []
const verfuegbarkeit = verfuegbarkeitVon(p)
// ── Standort ──
if (profil.orte.length > 0) {
const objektOrte = [p.location.city, p.location.district, p.location.region, p.address.city]
.filter(Boolean).map(o => o!.toLowerCase())
const treffer = profil.orte.some(o => objektOrte.some(z => z.includes(o) || o.includes(z)))
kriterien.push({
label: 'Standort',
erfuellung: treffer ? 1 : 0,
gewicht: GEWICHT.ort,
hinweis: treffer
? `${p.location.city} liegt im gesuchten Raum.`
: `${p.location.city} liegt ausserhalb des im Profil genannten Raums.`,
})
}
// ── Fläche ──
if (profil.flaecheSqm) {
const naehe = flaechenNaehe(p.areaSqm, profil.flaecheSqm)
kriterien.push({
label: 'Fläche',
erfuellung: naehe,
gewicht: GEWICHT.flaeche,
hinweis: `${p.areaSqm.toLocaleString('de-CH')} m² gegenüber gesuchten rund ${profil.flaecheSqm.toLocaleString('de-CH')} m².`,
})
}
// ── Nutzungsart ──
if (profil.nutzung.length > 0) {
const objektNutzung = `${p.assetType} ${p.hardFacts?.usageType ?? ''} ${p.title}`.toLowerCase()
const treffer = profil.nutzung.some(n =>
objektNutzung.includes(n) || (NUTZUNG_WORTE[n] ?? []).some(w => objektNutzung.includes(w)))
kriterien.push({
label: 'Nutzungsart',
erfuellung: treffer ? 1 : 0,
gewicht: GEWICHT.nutzung,
hinweis: treffer
? 'Die Nutzungsart des Objekts entspricht dem Profil.'
: 'Die Nutzungsart weicht vom Profil ab.',
})
}
// ── Verfügbarkeit ──
kriterien.push({
label: 'Verfügbarkeit',
erfuellung: verfuegbarkeit === MatchAvailability.FREI ? 1
: verfuegbarkeit === MatchAvailability.BELEGT ? 0
: null,
gewicht: GEWICHT.verfuegbarkeit,
hinweis: verfuegbarkeit === MatchAvailability.FREI
? 'Frei oder demnächst frei werdend.'
: verfuegbarkeit === MatchAvailability.BELEGT
? 'Derzeit vermietet.'
: 'Der Bestand sagt nichts zur Verfügbarkeit.',
})
// ── Weiche Anforderungen, nur wenn das Profil sie nennt ──
if (profil.anforderungen.oev) {
const wert = normiert(p.hardFacts?.publicTransportScore ?? p.softFactors?.commuterAccessScore, 10)
kriterien.push({
label: 'Erreichbarkeit',
erfuellung: wert,
gewicht: GEWICHT.oev,
hinweis: wert == null ? 'Zur Erreichbarkeit ist nichts erfasst.' : `Anbindungswert ${Math.round(wert * 100)} von 100.`,
})
}
if (profil.anforderungen.parkierung) {
const plaetze = p.hardFacts?.parking ?? p.softFactors?.parkingSpots
kriterien.push({
label: 'Parkierung',
erfuellung: plaetze == null ? null : plaetze > 0 ? 1 : 0,
gewicht: GEWICHT.parkierung,
hinweis: plaetze == null
? 'Zur Parkierung ist nichts erfasst.'
: `${plaetze} Parkplätze erfasst.`,
})
}
if (profil.anforderungen.ausbau) {
const stufe = p.hardFacts?.fitOut
const wert = stufe == null ? null : stufe === 'PREMIUM' ? 1 : stufe === 'FULL' ? 0.8 : stufe === 'BASIC' ? 0.5 : 0.2
kriterien.push({
label: 'Ausbaustand',
erfuellung: wert,
gewicht: GEWICHT.ausbau,
hinweis: stufe == null ? 'Zum Ausbaustand ist nichts erfasst.' : `Ausbaustand ${stufe}.`,
})
}
if (profil.anforderungen.sichtbarkeit) {
const wert = normiert(p.softFactors?.visibilityScore ?? p.softFactors?.prestigeScore, 10)
kriterien.push({
label: 'Sichtbarkeit',
erfuellung: wert,
gewicht: GEWICHT.sichtbarkeit,
hinweis: wert == null ? 'Zur Sichtbarkeit ist nichts erfasst.' : `Sichtbarkeitswert ${Math.round(wert * 100)} von 100.`,
})
}
/*
* Zwei Grössen, nicht eine.
*
* `erfuellung` sagt, wie gut das Objekt bei den Merkmalen abschneidet, die
* sich prüfen liessen. `abdeckung` sagt, wie viel davon überhaupt prüfbar
* war. Beides gehört in den Wert, und zwar aus einem Grund, der im ersten
* Durchlauf sichtbar wurde: Ein Objekt, von dem nur die Verfügbarkeit
* bekannt war, kam auf 100 % — «frei, sonst nichts geprüft» sah aus wie
* «passt perfekt».
*
* Fehlende Angaben werden trotzdem nicht als Nichterfüllung gewertet; das
* wäre die andere Unwahrheit. Sie dämpfen den Wert, und die Dämpfung reicht
* bis 70 % — genug, damit ein gut belegtes Objekt vor einem kaum belegten
* steht, und nicht so viel, dass ein lückenhafter Bestand jedes Ergebnis
* unbrauchbar macht.
*/
let summe = 0
let geprueft = 0
// Die weichen Kriterien kommen nur dazu, wenn das Profil sie aufwirft.
let verlangt = KERN_GEWICHT
for (const k of kriterien) {
const kern = k.label === 'Standort' || k.label === 'Fläche'
|| k.label === 'Nutzungsart' || k.label === 'Verfügbarkeit'
if (!kern) verlangt += k.gewicht
if (k.erfuellung == null) continue
summe += k.erfuellung * k.gewicht
geprueft += k.gewicht
}
const erfuellung = geprueft === 0 ? 0 : summe / geprueft
const abdeckung = Math.min(1, geprueft / verlangt)
const score = Math.round(erfuellung * (ABDECKUNG_BASIS + (1 - ABDECKUNG_BASIS) * abdeckung) * 100)
return { property: p, score, kriterien, verfuegbarkeit, abdeckung }
}
export interface MatchLaufOptionen {
/** Belegte Objekte in den Kandidatenkreis nehmen (§3.5). Vorgabe: nein. */
includeOccupied: boolean
}
export interface MatchLaufErgebnis {
matches: PropertyMatch[]
/** Wie viele Objekte überhaupt geprüft wurden. */
candidateCount: number
profil: Nachfrageprofil
}
/**
* Der Lauf über den gesamten Bestand.
*
* Ausgeschlossen wird nur, was eindeutig ausgeschlossen gehört: belegte
* Objekte, solange sie nicht ausdrücklich gewünscht sind. Alles andere wird
* bewertet und sortiert — auch Schlechtpassendes, denn «kein Treffer» ist eine
* andere Aussage als «nichts geprüft».
*/
export function fuehreMatchingAus(
signal: FutureSignal,
properties: Property[],
optionen: MatchLaufOptionen,
): MatchLaufErgebnis {
const profil = leseNachfrageprofil(signal)
const kandidaten = properties.filter(p => {
const v = verfuegbarkeitVon(p)
if (v === MatchAvailability.BELEGT && !optionen.includeOccupied) return false
return true
})
const matches = kandidaten
.map(p => bewerteObjekt(p, profil))
// Stabil sortiert: bei gleichem Wert entscheidet die Kennung, nicht der Zufall.
.sort((a, b) => (b.score - a.score) || a.property.id.localeCompare(b.property.id))
return { matches, candidateCount: kandidaten.length, profil }
}
+45
View File
@@ -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],
)
}
+3 -5
View File
@@ -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,
} }
} }
+101
View File
@@ -0,0 +1,101 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { ResearchRefreshResult, TemporalClass } from '../domain/researchLead'
import type { ResearchSourceDescriptor } from '../lib/researchSources'
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
import { futureSignalService } from '../services/futureSignalService'
import { agentProtocolService } from '../services/agentProtocolService'
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
import type { ResearchDocument } from '../domain/researchDocument'
import { useToastStore } from '../stores/toastStore'
/** Was ein Lauf mitbekommt — alles aus dem gespeicherten Stand, nichts hartcodiert. */
export interface ResearchRunInput {
/** Aktive Lesezugänge mit Adresse, abgeleitet aus Livias Systemen (§2.3). */
sources: ResearchSourceDescriptor[]
/** Manuell abgelegte Dokumente mit bereits extrahiertem Text (§2.6). */
documents: ResearchDocument[]
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung» (§2.9). */
temporalClasses: TemporalClass[]
}
/**
* «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.
*
* Seit Runde 10 endet der Lauf nicht mit der Antwort. Drei Dinge passieren
* danach, und zwar hier, weil sie zusammengehören (§§2.5/2.12):
*
* 1. Die gefundenen Leads gehen in den **zentralen** Signalbestand — es gibt
* keine zweite Ergebnisliste mehr.
* 2. Der Lauf erzeugt einen **echten** Protokolleintrag mit der Zahl der
* gelesenen Quellen und der gefundenen Leads.
* 3. Die Leadliste wird neu geladen, damit das Ergebnis sofort dort steht, wo
* es hingehört.
*/
export function useLiviaResearch() {
const showToast = useToastStore(s => s.showToast)
const queryClient = useQueryClient()
return useMutation<ResearchRefreshResult, Error, ResearchRunInput>({
mutationFn: async ({ sources, documents, temporalClasses }) => {
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sources: sources.map(q => ({ id: q.id, name: q.name, url: q.url })),
// Nur der extrahierte Text geht an den Server; die Datei selbst
// bleibt im Browser. Sie hochzuladen wäre Datenversand ohne Nutzen.
documents: documents.map(d => ({ id: d.id, name: d.name, text: d.text })),
temporalClasses,
}),
})
// 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
},
onSuccess: async (ergebnis) => {
if (ergebnis.leads.length > 0) {
await futureSignalService.importResearchLeads(ergebnis.leads, ergebnis.refreshedAt)
await queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
}
// Der Eintrag trägt die Zahlen des Laufs, nicht einen festen Text —
// ein Protokoll, das immer dasselbe sagt, protokolliert nichts (§2.5).
const gelesen = ergebnis.sources.filter(s => s.ok).length
await agentProtocolService.logUserAction({
agentId: 'livia',
eventType: AgentProtocolEventType.TASK_RUN,
title: 'Research aktualisiert',
description:
`${gelesen} von ${ergebnis.sources.length} ${ergebnis.sources.length === 1 ? 'Quelle' : 'Quellen'} gelesen · ` +
`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Eintrag' : 'Einträge'} beurteilt · ` +
`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead' : 'Leads'} in die Leadliste übernommen` +
(ergebnis.temporalFiltered > 0
? ` · ${ergebnis.temporalFiltered} wegen der zeitlichen Relevanz ausgeschlossen`
: ''),
status: ergebnis.analysisError ? AgentProtocolStatus.WARNING : AgentProtocolStatus.SUCCESS,
})
await queryClient.invalidateQueries({ queryKey: ['agentProtocol'] })
},
onError: (err) => {
showToast(err.message, 'error')
},
})
}
+18 -2
View File
@@ -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 => {
+38
View File
@@ -0,0 +1,38 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { matchRunService } from '../services/matchRunService'
import type { RunMatchingInput } from '../services/matchRunService'
import { STALE_SIGNALS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
const KEY = ['matchRuns']
/** Die durchgeführten Läufe — Grundlage für Protokoll und Kennzahl (§§3.11/3.13). */
export function useMatchRuns() {
return useQuery({
queryKey: KEY,
queryFn: () => matchRunService.getAll(),
staleTime: STALE_SIGNALS,
select: (res) => res.data,
})
}
/**
* Einen Lauf ausführen.
*
* Als Mutation und nicht als Query: Ein Matching ist eine Handlung, kein
* Zustand, der sich von selbst herstellt. Genau darum zählt die Kennzahl
* korrekt — eine Query würde bei jedem Neuaufbau der Seite erneut laufen.
*/
export function useRunMatching() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: RunMatchingInput) => matchRunService.run(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: KEY })
void queryClient.invalidateQueries({ queryKey: ['agentProtocol'] })
},
onError: () => {
useToastStore.getState().showToast('Das Matching konnte nicht ausgeführt werden.', 'error')
},
})
}
+50
View File
@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { researchDocumentService } from '../services/researchDocumentService'
import { STALE_SIGNALS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
const KEY = ['researchDocuments']
/** Die manuell abgelegten Newsquellen (Runde 10, §2.6). */
export function useResearchDocuments() {
return useQuery({
queryKey: KEY,
queryFn: () => researchDocumentService.getAll(),
staleTime: STALE_SIGNALS,
select: (res) => res.data,
})
}
export function useUploadResearchDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (file: File) => researchDocumentService.upload(file),
onSuccess: (res) => {
void queryClient.invalidateQueries({ queryKey: KEY })
// Ein Dokument ohne brauchbaren Text wird abgelegt, trägt aber nichts
// bei — das muss beim Ablegen gesagt werden und nicht erst, wenn der
// Lauf keine Leads daraus findet.
if (res.data.extractionNote) {
useToastStore.getState().showToast(res.data.extractionNote, 'warning')
} else {
useToastStore.getState().showToast(`«${res.data.name}» abgelegt.`, 'success')
}
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message, 'error')
},
})
}
export function useRemoveResearchDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => researchDocumentService.remove(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: KEY })
},
onError: () => {
useToastStore.getState().showToast('Das Dokument konnte nicht entfernt werden.', 'error')
},
})
}
+22 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { teamAgentService } from '../services/teamAgentService' import { teamAgentService } from '../services/teamAgentService'
import type { AgentTask, AgentChannel, AgentSetting } from '../domain/teamAgent' import type { AgentTask, AgentChannel, AgentSetting, AgentSystem } from '../domain/teamAgent'
import { useToastStore } from '../stores/toastStore' import { useToastStore } from '../stores/toastStore'
import { STALE_TEAM_AGENTS } from '../lib/constants' import { STALE_TEAM_AGENTS } from '../lib/constants'
import { QK_AGENT_PROTOCOL } from './useAgentProtocol' import { QK_AGENT_PROTOCOL } from './useAgentProtocol'
@@ -100,3 +100,24 @@ export function useSaveAgentChannels() {
}, },
}) })
} }
/**
* Systeme speichern (Runde 10, §2).
*
* Bewusst dieselbe Bauart wie `useSaveAgentChannels`: Anlegen, Bearbeiten und
* Löschen schreiben allesamt die vollständige Liste zurück. Ein Endpunkt je
* Einzeloperation wäre bei einem Mockup-Provider mehr Schnittstelle als Nutzen.
*/
export function useSaveAgentSystems() {
const invalidate = useAgentMutationInvalidation()
return useMutation({
mutationFn: ({ id, systems }: { id: string; systems: AgentSystem[] }) =>
teamAgentService.saveSystems(id, systems),
onSuccess: () => {
invalidate()
},
onError: () => {
useToastStore.getState().showToast('System konnte nicht geändert werden.', 'error')
},
})
}
+3 -1
View File
@@ -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,
} }
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more