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>
This commit is contained in:
1 parent
c6011ac093
commit
15c5719762
5 files changed
+289
-63
No files matched your search
@@ -50,8 +50,9 @@ const MATCH_INFO =
|
||||
+ '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, geht es '
|
||||
+ 'gar nicht in die Rechnung ein — das Objekt verliert dadurch keine Punkte, gewinnt aber auch keine. '
|
||||
+ '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.'
|
||||
|
||||
@@ -141,16 +142,22 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal,
|
||||
}, [ergebnis])
|
||||
|
||||
/**
|
||||
* Die Trefferliste, nach Match absteigend.
|
||||
* Die Trefferliste — nach Match absteigend, solange nichts anderes gewählt ist.
|
||||
*
|
||||
* Die Filterleiste wirkt darauf wie in «Meine Objekte» — sie schränkt die
|
||||
* Anzeige ein, ohne den Lauf zu verändern. Wer die Auswahl ändert, sieht
|
||||
* weniger Zeilen, nicht andere Werte.
|
||||
* 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()
|
||||
return ergebnis.matches
|
||||
|
||||
const gefiltert = ergebnis.matches
|
||||
.map(m => m.property)
|
||||
.filter(p => {
|
||||
if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false
|
||||
@@ -161,6 +168,21 @@ export const PropertyMatchSection = memo(function PropertyMatchSection({ signal,
|
||||
}
|
||||
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])
|
||||
|
||||
const risiko = isRisiko(signal)
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('bewerteObjekt', () => {
|
||||
it('gibt einem passenden Objekt einen hohen Wert', () => {
|
||||
const { score } = bewerteObjekt(objekt(), leseNachfrageprofil(signal()))
|
||||
|
||||
expect(score).toBeGreaterThanOrEqual(70)
|
||||
expect(score).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('straft den falschen Ort ab', () => {
|
||||
@@ -200,3 +200,85 @@ describe('fuehreMatchingAus', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,10 @@
|
||||
* 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
|
||||
@@ -38,21 +42,48 @@ export const MatchAvailability = {
|
||||
} 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 Bestand führt fünf Zustände, für das Matching zählen drei. Die
|
||||
* Zusammenfassung steht hier einmal, damit nicht jede Stelle ihre eigene
|
||||
* Auslegung von «bald frei» entwickelt.
|
||||
* 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): MatchAvailability {
|
||||
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_NOW:
|
||||
case AvailabilityStatus.AVAILABLE_SOON:
|
||||
case AvailabilityStatus.FUTURE_SIGNAL:
|
||||
return MatchAvailability.FREI
|
||||
case AvailabilityStatus.OCCUPIED:
|
||||
return MatchAvailability.BELEGT
|
||||
default:
|
||||
return MatchAvailability.UNBEKANNT
|
||||
}
|
||||
@@ -163,6 +194,13 @@ export interface PropertyMatch {
|
||||
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. */
|
||||
@@ -177,6 +215,28 @@ const GEWICHT = {
|
||||
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
|
||||
@@ -299,18 +359,40 @@ export function bewerteObjekt(p: Property, profil: Nachfrageprofil): PropertyMat
|
||||
})
|
||||
}
|
||||
|
||||
// Nur geprüfte Kriterien zählen. Ein Objekt, zu dem gar nichts bekannt ist,
|
||||
// bekommt 0 — nicht 100, weil «nichts spricht dagegen».
|
||||
/*
|
||||
* 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 gewichte = 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
|
||||
gewichte += k.gewicht
|
||||
geprueft += k.gewicht
|
||||
}
|
||||
const score = gewichte === 0 ? 0 : Math.round((summe / gewichte) * 100)
|
||||
|
||||
return { property: p, score, kriterien, verfuegbarkeit }
|
||||
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 {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* Property On — Personalblatt «Nora», Markt-Scout.
|
||||
* Property On — Personalblatt «Nora», Marktchancen und Leads.
|
||||
*
|
||||
* Nora beobachtet öffentliche Quellen und simulierte Feeds zu den definierten
|
||||
* Regionen und Segmenten, stuft jedes Signal ein und legt unsichere Fälle in die
|
||||
* Review-Queue. Sie arbeitet ausschliesslich auf Vorschlagsbasis: Wochen-Mail,
|
||||
* Leads und Markteinschätzung gehen erst nach Freigabe durch die Marktbeobachtung
|
||||
* hinaus.
|
||||
* Seit Runde 10 (§3) arbeitet Nora nicht mehr an den Quellen, sondern am
|
||||
* Bestand: Sie nimmt die Nachfrageprofile, die Livia aus ihrer Recherche
|
||||
* ableitet, und gleicht sie gegen die eigene Objektdatenbasis ab. Das Ergebnis
|
||||
* ist ein nachvollziehbarer Match-Wert je Objekt.
|
||||
*
|
||||
* Was sie ausdrücklich **nicht** mehr tut: Quellen beobachten, Signale
|
||||
* klassifizieren, unsichere Fälle prüfen lassen und Wochenberichte versenden.
|
||||
* Das ist alles zu Livia gewandert — sie liest den Markt, Nora verbindet ihn
|
||||
* mit dem Bestand.
|
||||
*/
|
||||
|
||||
import type { TeamAgent } from '../../domain/teamAgent'
|
||||
@@ -33,42 +37,41 @@ export const noraAgent: TeamAgent = {
|
||||
status: AgentStatus.ACTIVE,
|
||||
autonomyLevel: AgentAutonomyLevel.PROPOSAL_ONLY,
|
||||
shortDescription:
|
||||
'Beobachtet Marktsignale, ordnet sie ein und erstellt Wochen-Mails, Leads und Marktbausteine.',
|
||||
autonomyNote: '«Vorschlag»',
|
||||
'Gleicht Nachfrageprofile mit dem eigenen Objektbestand ab und legt passende Objekte mit nachvollziehbarem Match-Wert vor.',
|
||||
autonomyNote:
|
||||
'«Vorschlag». Nora rechnet und ordnet, entscheidet aber nichts: Welches Objekt angeboten wird, bleibt die Sache der Bewirtschaftung.',
|
||||
|
||||
profile: {
|
||||
purpose:
|
||||
'Nora hält die Marktbeobachtung ohne tägliche Handarbeit auf dem Laufenden, indem sie Bewegungen bei kommerziell genutzten Immobilien früh erkennt, einordnet und als prüffertigen Vorschlag vorlegt.',
|
||||
'Nora beantwortet die Frage, die auf jeden Lead folgt: Haben wir dafür etwas? Sie nimmt das Nachfrageprofil, das Livia aus einer Quelle abgeleitet hat, und gleicht es mit dem eigenen Objektbestand ab — mit einem Wert je Objekt, der sich auf die geprüften Merkmale zurückführen lässt.',
|
||||
input: [
|
||||
'Regionen: Zürich, Winterthur, Zug, Basel, Bern und St. Gallen',
|
||||
'Segmente: Büro, Retail, Gastronomie, Logistik, Gewerbe und Produktion',
|
||||
'Öffentliche Quellen wie SHAB-Meldungen, Zefix-Einträge und Firmenwebseiten',
|
||||
'Simulierte Feeds zu Ausschreibungen, Standortmeldungen und Medienberichten',
|
||||
'Objekt- und Portfoliostammdaten aus dem Arbeitsbereich',
|
||||
'Nachfrageprofile aus Livias Recherche-Leads',
|
||||
'Objektdatenbasis aus «Meine Objekte» — Standort, Fläche, Nutzung, Verfügbarkeit, Ausstattung',
|
||||
'Bestehende Mietverhältnisse, um belegte von freien Flächen zu unterscheiden',
|
||||
'Laufende Vorgänge aus dem CRM, damit bekannte Nachfrage nicht doppelt erscheint',
|
||||
],
|
||||
coreFlow: [
|
||||
'Nora sichtet die freigegebenen Quellen und Feeds zu den eingestellten Regionen und Segmenten.',
|
||||
'Jede Meldung wird als Signalart eingeordnet — zum Beispiel Umzug, Expansion oder Filialschliessung.',
|
||||
'Zu jedem Signal vergibt sie eine Konfidenz und hinterlegt die Quelle mit Datum.',
|
||||
'Liegt die Konfidenz unter der Review-Schwelle, wandert das Signal in die Review-Queue statt in den Versand.',
|
||||
'Verifizierte Signale werden einer Region und, wenn möglich, einem konkreten Objekt zugeordnet.',
|
||||
'Wochen-Mail, Lead-Liste und Markteinschätzung entstehen als Vorschlag und gehen erst nach Freigabe raus.',
|
||||
'Aus dem Nachfrageprofil die Anforderungen lesen, die darin tatsächlich stehen.',
|
||||
'Den Kandidatenkreis bilden: freie und bald frei werdende Objekte, auf Wunsch auch belegte.',
|
||||
'Jedes Objekt gegen die gelesenen Anforderungen prüfen — Standort, Fläche, Nutzung, Verfügbarkeit und, wo das Profil sie nennt, Erreichbarkeit, Parkierung, Ausbau und Sichtbarkeit.',
|
||||
'Merkmale, zu denen der Bestand nichts sagt, als nicht bekannt führen statt als erfüllt.',
|
||||
'Die Übereinstimmung als Wert von 0 bis 100 ausgeben und die Liste danach ordnen.',
|
||||
'Den Lauf mit Zeitpunkt, Kandidatenzahl und bestem Wert protokollieren.',
|
||||
],
|
||||
output: [
|
||||
'Wochen-Mail mit den wichtigsten Marktsignalen, dienstags um 08:00 Uhr',
|
||||
'Einzelne Marktsignale mit Signalart, Konfidenz, Quelle und Regionsbezug',
|
||||
'Lead-Liste aus Umzugs- und Expansionssignalen zur Übergabe an das CRM',
|
||||
'Monatliche Markteinschätzung je Region und Segment',
|
||||
'Review-Queue mit den unsicheren Signalen zur Sichtung durch das Team',
|
||||
'Trefferliste der Objekte mit Match-Wert, sortiert vom besten Treffer abwärts',
|
||||
'Je Objekt die geprüften Kriterien mit ihrer Begründung',
|
||||
'Hinweis, wie viel sich am jeweiligen Objekt überhaupt prüfen liess',
|
||||
'Protokolleintrag je durchgeführtem Abgleich',
|
||||
],
|
||||
},
|
||||
|
||||
responsibilities: [
|
||||
'Laufende Beobachtung der freigegebenen Quellen und Feeds in den eingestellten Regionen',
|
||||
'Nachvollziehbare Einordnung jedes Signals nach Signalart und Konfidenz',
|
||||
'Saubere Trennung zwischen verifizierten Signalen und Fällen für die Review-Queue',
|
||||
'Zuordnung der Signale zu Regionen und, wo belegbar, zu einzelnen Objekten',
|
||||
'Vorbereitung von Wochen-Mail, Lead-Liste und monatlicher Markteinschätzung als Vorschlag',
|
||||
'Nachfrageprofile in prüfbare Anforderungen übersetzen',
|
||||
'Den Objektbestand nach diesen Anforderungen abgleichen, ohne fehlende Angaben zu erfinden',
|
||||
'Den Match-Wert so ausweisen, dass er sich auf einzelne Kriterien zurückführen lässt',
|
||||
'Belegte Objekte nur auf ausdrücklichen Wunsch in den Kandidatenkreis nehmen',
|
||||
'Jeden durchgeführten Abgleich nachvollziehbar protokollieren',
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -116,11 +119,11 @@ export const noraAgent: TeamAgent = {
|
||||
type: AgentChannelType.EMAIL,
|
||||
direction: AgentChannelDirection.OUTBOUND,
|
||||
description:
|
||||
'Versand der freigegebenen Wochen-Mail und der monatlichen Markteinschätzung an Marktbeobachtung, Akquisition und Bewirtschaftung. Nora nimmt über diesen Kanal keine Post entgegen.',
|
||||
'Versand von Matching-Ergebnissen an Vermarktung und Bewirtschaftung, wenn ein Abgleich passende Objekte ergeben hat. Nora nimmt über diesen Kanal keine Post entgegen.',
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
enabled: true,
|
||||
config: {
|
||||
displayName: 'Nora — Markt-Scout',
|
||||
displayName: 'Nora — Marktchancen und Leads',
|
||||
senderAddress: 'nora@property-on.ch',
|
||||
defaultRecipients: [
|
||||
'marktbeobachtung@property-on.ch',
|
||||
@@ -232,8 +235,8 @@ export const noraAgent: TeamAgent = {
|
||||
'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, geht es gar nicht in die Rechnung ein — '
|
||||
+ 'das Objekt verliert dadurch keine Punkte, gewinnt aber auch keine. '
|
||||
+ '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. '
|
||||
+ '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.',
|
||||
},
|
||||
|
||||
@@ -4109,7 +4109,7 @@ export const mockProperties: Property[] = [
|
||||
rentPricePerSqm: 182,
|
||||
totalRentMonthly: 81900,
|
||||
availabilityDate: '2026-01-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'ERP_IMPORT',
|
||||
confidenceScore: 0.92,
|
||||
dataQuality: {
|
||||
@@ -4144,6 +4144,10 @@ export const mockProperties: Property[] = [
|
||||
breakoutOption: false,
|
||||
schattenmarktRelease: { enabled: false },
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Rhein Technik AG',
|
||||
leaseTerm: '10 Jahre',
|
||||
leaseStartDate: '2021-01-01',
|
||||
leaseEndDate: '2031-12-31',
|
||||
createdAt: '2025-05-22T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
@@ -4159,7 +4163,7 @@ export const mockProperties: Property[] = [
|
||||
rentPricePerSqm: 160,
|
||||
totalRentMonthly: 96000,
|
||||
availabilityDate: '2026-07-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'ERP_IMPORT',
|
||||
confidenceScore: 0.91,
|
||||
dataQuality: {
|
||||
@@ -4194,6 +4198,10 @@ export const mockProperties: Property[] = [
|
||||
breakoutOption: false,
|
||||
schattenmarktRelease: { enabled: false },
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Seefeld Consulting AG',
|
||||
leaseTerm: '5 Jahre',
|
||||
leaseStartDate: '2023-07-01',
|
||||
leaseEndDate: '2028-06-30',
|
||||
createdAt: '2025-05-22T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
@@ -4300,7 +4308,7 @@ export const mockProperties: Property[] = [
|
||||
rentPricePerSqm: 312,
|
||||
totalRentMonthly: 51480,
|
||||
availabilityDate: '2026-07-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'ERP_IMPORT',
|
||||
confidenceScore: 0.91,
|
||||
dataQuality: {
|
||||
@@ -4335,6 +4343,10 @@ export const mockProperties: Property[] = [
|
||||
breakoutOption: false,
|
||||
schattenmarktRelease: { enabled: false },
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Lienhard Gastro GmbH',
|
||||
leaseTerm: '9 Jahre',
|
||||
leaseStartDate: '2021-01-01',
|
||||
leaseEndDate: '2030-12-31',
|
||||
createdAt: '2025-05-22T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
@@ -4354,7 +4366,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 320,
|
||||
rentPricePerSqm: 380,
|
||||
availabilityDate: '2026-06-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.80,
|
||||
dataQuality: { score: 0.78, missingCriticalFields: [], missingOptionalFields: ['softFactors', 'hardFacts'], lastVerifiedAt: '2026-03-10', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
@@ -4365,6 +4377,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Helle Bürofläche im 3. OG an der Bachmattstrasse, Zürich-Altstetten. 320 m², Basisausbau, Parkplätze im Haus. 8 Minuten zur S-Bahn Altstetten.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Nordwest Handels AG',
|
||||
leaseTerm: '6 Jahre',
|
||||
leaseStartDate: '2023-10-01',
|
||||
leaseEndDate: '2029-09-30',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-02-14T09:30:00Z',
|
||||
updatedAt: '2026-02-14T09:30:00Z',
|
||||
},
|
||||
@@ -4406,7 +4423,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 240,
|
||||
rentPricePerSqm: 295,
|
||||
availabilityDate: '2026-07-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.78,
|
||||
dataQuality: { score: 0.74, missingCriticalFields: [], missingOptionalFields: ['hardFacts'], lastVerifiedAt: '2026-04-20', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
@@ -4417,6 +4434,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Repräsentative Bürofläche im 4. OG am Bollwerk, Bern. 240 m², Vollausbau, Toplage direkt beim Bahnhof Bern (3 Min.). Kein Parkplatz.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Weber Präzision GmbH',
|
||||
leaseTerm: '12 Jahre',
|
||||
leaseStartDate: '2020-10-01',
|
||||
leaseEndDate: '2032-09-30',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-04-02T14:15:00Z',
|
||||
updatedAt: '2026-04-02T14:15:00Z',
|
||||
},
|
||||
@@ -4432,7 +4454,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 185,
|
||||
rentPricePerSqm: 360,
|
||||
availabilityDate: '2026-08-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.72,
|
||||
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] },
|
||||
@@ -4443,6 +4465,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
description: 'Ladenfläche an der Stadthausstrasse Winterthur, Erdgeschoss, Schaufensterfront. 185 m², Basisausbau. Zentrale Lage, hohe Passantenfrequenz. Derzeit deaktiviert — Verhandlung sistiert.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Alpstein Pharma AG',
|
||||
leaseTerm: '7 Jahre',
|
||||
leaseStartDate: '2022-01-01',
|
||||
leaseEndDate: '2029-12-31',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-01-20T10:00:00Z',
|
||||
updatedAt: '2026-04-15T08:00:00Z',
|
||||
},
|
||||
@@ -4458,7 +4485,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 1200,
|
||||
rentPricePerSqm: 130,
|
||||
availabilityDate: '2026-06-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.88,
|
||||
dataQuality: { score: 0.85, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-05-01', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
@@ -4469,6 +4496,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Moderne Logistikhalle in Muttenz, 1200 m², 9 m Hallenhöhe, 4 Ladelucken, Stromanschluss 400 kVA. Direkter Autobahnanschluss A2/A3, 20 Aussenparkplätze.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Bürgin Treuhand AG',
|
||||
leaseTerm: '8 Jahre',
|
||||
leaseStartDate: '2022-07-01',
|
||||
leaseEndDate: '2030-06-30',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-04-28T16:00:00Z',
|
||||
updatedAt: '2026-04-28T16:00:00Z',
|
||||
},
|
||||
@@ -4484,7 +4516,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 450,
|
||||
rentPricePerSqm: 260,
|
||||
availabilityDate: '2026-09-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.70,
|
||||
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] },
|
||||
@@ -4495,6 +4527,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
description: 'Bürofläche Zürichstrasse 45, Luzern-Littau. 450 m², 1. OG, Basisausbau. 8 Tiefgaragenplätze. Deaktiviert — Preisüberarbeitung ausstehend.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Helvetia Logistik AG',
|
||||
leaseTerm: '10 Jahre',
|
||||
leaseStartDate: '2021-04-01',
|
||||
leaseEndDate: '2031-03-31',
|
||||
breakoutOption: false,
|
||||
createdAt: '2025-12-10T08:00:00Z',
|
||||
updatedAt: '2026-03-20T12:00:00Z',
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user