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:
Benjamin SutterandClaude Opus 5 committed 2026-09-13 00:34:16 +02:00
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)
})
})
+95 -13
View File
@@ -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 {
+37 -34
View File
@@ -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.',
},
+45 -8
View File
@@ -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',
},