feat: remove Administration workspace — keep only Verwaltung + Suche

- Delete all ops page components (ReviewQueue, AIMonitoring, Governance,
  SourceMonitoring, ActivityTimeline, SignalPipeline)
- Remove OPERATIONS workspace from AppShell config, nav order, path detection
- Remove all /ops/* routes from App.tsx
- Remove WorkspaceType.OPERATIONS from allowedWorkspaces in authService,
  sessionStore, permissions
- Keep MarketIntelligence page (already moved to /supply/market-intelligence)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-19 20:32:41 +02:00
parent d22e72f945
commit d15a13e485
378 changed files with 35441 additions and 42 deletions
@@ -0,0 +1,409 @@
import type { AssistantContext, AssistantMessage, SuggestedQuestion, AssistantAction } from '../domain/assistant'
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
// ── Page-type resolution ───────────────────────────────────────────────────────
function pageType(route: string): string {
if (/\/supply\/properties\/.+/.test(route)) return 'property-detail'
if (route.includes('/supply/match-center')) return 'match-center'
if (route.includes('/supply/data-quality')) return 'data-quality'
if (route.includes('/supply/future-availability')) return 'future-availability'
if (route.includes('/supply/dashboard')) return 'supply-dashboard'
if (route.includes('/demand/results')) return 'demand-results'
if (route.includes('/demand/compare')) return 'compare'
if (route.includes('/demand/ai-search')) return 'ai-search'
if (route.includes('/ops/review-queue')) return 'review-queue'
if (route.includes('/ops/ai-monitoring')) return 'ai-monitoring'
return 'general'
}
// ── Suggestions per page type ─────────────────────────────────────────────────
const SUGGESTIONS: Record<string, SuggestedQuestion[]> = {
'property-detail': [
{ id: 'pd1', question: 'Warum passt dieses Objekt nicht gut zu aktuellen Gesuchen?', category: 'Match' },
{ id: 'pd2', question: 'Welche Daten sollte ich zuerst verbessern?', category: 'Datenqualität' },
{ id: 'pd3', question: 'Welche Suchprofile passen am besten zu diesem Objekt?', category: 'Match' },
{ id: 'pd4', question: 'Wie gross ist das Risiko, dieses Objekt nicht zu vermieten?', category: 'Risiko' },
],
'match-center': [
{ id: 'mc1', question: 'Welcher eingehende Bedarf hat die höchste Priorität?', category: 'Priorisierung' },
{ id: 'mc2', question: 'Warum hat dieser Match einen niedrigen Score?', category: 'Match' },
{ id: 'mc3', question: 'Soll ich den Kontakt für diesen Match freigeben?', category: 'Aktion' },
],
'demand-results': [
{ id: 'dr1', question: 'Warum ist dieses Ergebnis an erster Stelle?', category: 'Ranking' },
{ id: 'dr2', question: 'Was sind die grössten Kompromisse bei diesem Match?', category: 'Tradeoffs' },
{ id: 'dr3', question: 'Sollte ich alternative Standorte in Betracht ziehen?', category: 'Strategie' },
{ id: 'dr4', question: 'Welche Hardkriterien werden am häufigsten nicht erfüllt?', category: 'Analyse' },
],
'compare': [
{ id: 'co1', question: 'Welche Option ist strategisch am besten?', category: 'Empfehlung' },
{ id: 'co2', question: 'Welche Option hat das höchste Risiko?', category: 'Risiko' },
{ id: 'co3', question: 'Welche Option ist am kostengünstigsten?', category: 'Kosten' },
],
'data-quality': [
{ id: 'dq1', question: 'Was sollte ich zuerst beheben?', category: 'Priorität' },
{ id: 'dq2', question: 'Welche fehlenden Felder haben den grössten Einfluss auf Matches?', category: 'Impact' },
{ id: 'dq3', question: 'Wie verbessere ich den Datenqualitäts-Score schnell?', category: 'Optimierung' },
],
'future-availability': [
{ id: 'fa1', question: 'Warum ist dieses Signal probabilistisch und nicht bestätigt?', category: 'Erklärung' },
{ id: 'fa2', question: 'Welche Belege unterstützen dieses Signal?', category: 'Evidenz' },
{ id: 'fa3', question: 'Was muss vor der Freigabe an Demand-Nutzer geprüft werden?', category: 'Review' },
{ id: 'fa4', question: 'Wie hoch ist die Konfidenz dieses Signals?', category: 'Konfidenz' },
],
'review-queue': [
{ id: 'rq1', question: 'Welche Review-Aufgabe sollte ich zuerst bearbeiten?', category: 'Priorisierung' },
{ id: 'rq2', question: 'Was sind die Kriterien für eine Genehmigung?', category: 'Prozess' },
{ id: 'rq3', question: 'Wann sollte ich eine Aufgabe eskalieren?', category: 'Eskalation' },
],
'ai-monitoring': [
{ id: 'am1', question: 'Welche fehlgeschlagenen Outputs haben die höchste Priorität?', category: 'Fehler' },
{ id: 'am2', question: 'Was bedeutet ein Schema-Validierungsfehler?', category: 'Fehleranalyse' },
{ id: 'am3', question: 'Welche AI-Outputs brauchen eine manuelle Review?', category: 'Review' },
],
'general': [
{ id: 'g1', question: 'Wie kann ich meine Daten für bessere Matches vorbereiten?', category: 'Optimierung' },
{ id: 'g2', question: 'Was sind die wichtigsten KPIs in dieser Ansicht?', category: 'Überblick' },
{ id: 'g3', question: 'Welche nächste Aktion empfiehlst du?', category: 'Aktion' },
],
}
// ── Answer templates ───────────────────────────────────────────────────────────
type AnswerPayload = {
content: string
confidence: number
sources: string[]
actions?: AssistantAction[]
}
type Template = {
keywords: string[]
generate: (ctx: AssistantContext) => AnswerPayload
}
const entityRef = (ctx: AssistantContext) =>
ctx.selectedEntityId ? ` (${ctx.selectedEntityId})` : ''
const missingFields = (ctx: AssistantContext) =>
ctx.visibleMissingData?.slice(0, 3).join(', ') ?? 'Mietpreis/m², Verfügbarkeit'
const scoreVal = (ctx: AssistantContext, key: string, fallback = 72) =>
ctx.visibleScores?.[key] ?? fallback
const TEMPLATES: Record<string, Template[]> = {
'property-detail': [
{
keywords: ['passt', 'match', 'score', 'niedrig'],
generate: (_ctx) => ({
content: `Das Objekt${entityRef(_ctx)} erreicht einen Datenqualitätsscore von ${scoreVal(_ctx, 'quality')}%. Damit liegt es unter dem empfohlenen Schwellenwert von 70%, der für präzises Matching erforderlich ist.\n\nDie häufigsten Faktoren, die Matches verhindern:\n• Fehlende oder veraltete Felder (${missingFields(_ctx)})\n• Unklare Verfügbarkeitsangaben kritisch für zeitbasierte Gesuche\n• Fehlende Zertifizierungen, wenn Demand-Profile spezifische Anforderungen haben\n\nEmpfehlung: Qualitätsfelder priorisieren, um den Score auf ≥75% zu bringen und die Sichtbarkeit in der Trefferquote zu erhöhen.`,
confidence: 0.86,
sources: ['Datenqualität', 'Match-Score-Berechnung'],
actions: [
{ id: 'a1', label: 'Zur Datenpflege', description: 'Datenqualität dieses Objekts verbessern', actionType: 'NAVIGATE', payload: { path: '/supply/data-quality' } },
],
}),
},
{
keywords: ['verbessern', 'zuerst', 'priorität', 'beheben', 'felder'],
generate: (_ctx) => ({
content: `Für Objekt${entityRef(_ctx)} empfehle ich folgende Reihenfolge:\n\n**1. ${_ctx.visibleMissingData?.[0] ?? 'Mietpreis/m²'}** (kritisch)\nDirekte Auswirkung auf 6070% aller Bedarfsanfragen. Ohne Preisinformation kein Matching möglich.\n\n**2. ${_ctx.visibleMissingData?.[1] ?? 'Verfügbarkeitsdatum'}** (hoch)\nZeitbasierte Gesuche schliessen Objekte ohne klares Datum aus.\n\n**3. ${_ctx.visibleMissingData?.[2] ?? 'Fläche m²'}** (mittel)\nBestimmt, ob Flächenkriterien erfüllt werden.\n\nNach diesen drei Feldern sollte der Qualitätsscore um ~1520 Punkte steigen.`,
confidence: 0.91,
sources: ['Datenqualität', 'Feldgewichtung'],
actions: [
{ id: 'a2', label: 'Felder aktualisieren', description: 'Objekt-Detailansicht öffnen und Felder bearbeiten', actionType: 'NAVIGATE', payload: { path: '/supply/properties' } },
],
}),
},
{
keywords: ['suchprofile', 'gesuche', 'demand', 'passend', 'passen'],
generate: (_ctx) => ({
content: `Basierend auf dem aktuellen Objekt${entityRef(_ctx)} würden vor allem Profile mit folgenden Eigenschaften passen:\n\n• **Büro / Open Space** sofern Grundriss offen oder teilbar\n• **Mittleres Budget** (CHF 8'00014'000/Mt) entspricht typischer Preisrange\n• **Kurzfristige Verfügbarkeit** (≤3 Monate) hohe Nachfrage in diesem Segment\n\nFür genaue Profilvorschläge: Den Match-Center öffnen und die Trefferrate mit aktuellen Gesuchen prüfen.`,
confidence: 0.78,
sources: ['Match-Center', 'Demand-Profile-Analyse'],
actions: [
{ id: 'a3', label: 'Match-Center öffnen', description: 'Eingehende Bedarfe für dieses Objekt anzeigen', actionType: 'NAVIGATE', payload: { path: '/supply/match-center' } },
],
}),
},
{
keywords: ['risiko', 'risk', 'leerstand', 'vermieten'],
generate: (_ctx) => ({
content: `Das Leerstandsrisiko für Objekt${entityRef(_ctx)} hängt von drei Faktoren ab:\n\n• **Datenqualität** (${scoreVal(_ctx, 'quality')}%) Niedrige Qualität reduziert Sichtbarkeit in Suchergebnissen\n• **Marktlage** Aktuelle Signale deuten auf moderate Nachfrage in diesem Segment hin\n• **Preispositionierung** Ohne Marktpreisvergleich keine verlässliche Einschätzung möglich\n\n**Hinweis:** Diese Einschätzung basiert auf verfügbaren Metadaten. Für eine fundierte Leerstandsprognose wird eine vollständige Datenbasis empfohlen.`,
confidence: 0.71,
sources: ['Datenqualität', 'Marktindikatoren'],
}),
},
],
'demand-results': [
{
keywords: ['ersten', 'erst', 'ranking', 'warum', 'platz'],
generate: (_ctx) => ({
content: `Das erstplatzierte Ergebnis${entityRef(_ctx)} erreicht diesen Rang, weil es die meisten Hardkriterien vollständig erfüllt. Im Scoring-Modell zählen Hardkriterien mit 60% Gewichtung ein Objekt mit 5/5 Hardkriterien übertrifft alle Objekte mit auch nur einem unerfüllten Kriterium.\n\nZusätzlich fliessen Softfaktoren (40%) ein: Standortqualität, Verfügbarkeitsübereinstimmung und Ausbaustandard.\n\nFür Details zur Begründung: "Match-Erklärung" in der Detailansicht öffnen.`,
confidence: 0.89,
sources: ['Match-Score', 'Scoring-Modell'],
}),
},
{
keywords: ['kompromiss', 'trade', 'nachteil', 'tradeoff', 'opfer'],
generate: (_ctx) => ({
content: `Die grössten Kompromisse bei diesem Match:\n\n• **Preis vs. Fläche** Das Objekt liegt ggf. über Budget, bietet aber mehr Fläche als Minimum\n• **Lage vs. Ausbaustandard** Zentralere Lage geht oft mit höherem Mietpreis einher\n• **Verfügbarkeit** Falls Objekt erst in 4+ Monaten frei wird, widerspricht das kurzfristigen Bedarfen\n\n**Empfehlung:** Tradeoffs mit dem Suchenden diskutieren was ist verhandelbar, was ist ein Ausschlusskriterium?`,
confidence: 0.83,
sources: ['Match-Score', 'Hardkriterien-Analyse'],
actions: [
{ id: 'a4', label: 'Vergleichsansicht öffnen', description: 'Ergebnis mit anderen Matches vergleichen', actionType: 'NAVIGATE', payload: { path: '/demand/compare' } },
],
}),
},
{
keywords: ['alternative', 'standort', 'lage', 'andere'],
generate: (_ctx) => ({
content: `Alternative Standorte lohnen sich zu prüfen, wenn:\n\n• Die Top-Ergebnisse alle im selben Preissegment liegen und Budget ein Engpass ist\n• Die Anforderungen an Lage verhandelbar sind (z.B. Zürich 14 statt nur 1)\n• Suchprofile mit erweiterter Standorttoleranz signifikant bessere Treffer zeigen\n\n**Konkret:** Im AI-Suche-Formular die Standortangabe auf Stadtkreis oder Kanton ausweiten und neu suchen. Dies kann die Trefferanzahl um 3060% erhöhen.`,
confidence: 0.80,
sources: ['Suchanfrage-Analyse', 'Standort-Scoring'],
actions: [
{ id: 'a5', label: 'Suche anpassen', description: 'Zurück zur Flächensuche mit erweiterter Standortauswahl', actionType: 'NAVIGATE', payload: { path: '/demand/ai-search' } },
],
}),
},
{
keywords: ['hardkriterien', 'kriterien', 'nicht erfüllt', 'ausschlusskriterium'],
generate: (_ctx) => ({
content: `Häufig nicht erfüllte Hardkriterien in den aktuellen Ergebnissen:\n\n• **Flächengrösse** Viele Objekte liegen 1020% unter dem Mindestwert\n• **Verfügbarkeitsdatum** Diskrepanz zwischen gewünschtem Einzugsdatum und tatsächlicher Verfügbarkeit\n• **Parkplatzkontingent** Wenige Objekte bieten die geforderte Anzahl Stellplätze\n\nHinweis: Hardkriterien sind binär ein nicht erfülltes Kriterium schiesst ein Objekt vollständig aus dem Ranking aus, unabhängig von anderen Stärken.`,
confidence: 0.88,
sources: ['Matching-Engine', 'Kriterien-Gewichtung'],
}),
},
],
'compare': [
{
keywords: ['strategisch', 'best', 'empfehlung', 'wählen'],
generate: (_ctx) => ({
content: `Für eine strategische Empfehlung werden folgende Dimensionen gewichtet:\n\n• **Match-Score** Wie gut erfüllt das Objekt das Suchprofil?\n• **Datenqualität** Je vollständiger, desto verlässlicher die Einschätzung\n• **Zeitliche Verfügbarkeit** Passt der Einzugstermin zur Planung?\n• **Preis-Leistung** Mietpreis im Verhältnis zu Fläche und Ausstattung\n\n**Hinweis:** Die finale Entscheidung muss durch den Nutzer getroffen werden. Der Assistant kann Faktoren gewichten, aber keine verbindliche Empfehlung ohne vollständige Datenbasis abgeben.`,
confidence: 0.77,
sources: ['Vergleichsansicht', 'Match-Scores'],
}),
},
{
keywords: ['risiko', 'höchste', 'gefährlich', 'risikoreiche'],
generate: (_ctx) => ({
content: `Risikoindikatoren im Vergleich:\n\n• **Niedrige Datenqualität** (<65%) = höheres Informationsrisiko Angaben nicht verlässlich verifiziert\n• **Niedrige Konfidenz** (<60%) = Scoring-Unsicherheit Match könnte sich bei mehr Daten verschlechtern\n• **Fehlende Verfügbarkeitsangabe** = Planungsrisiko keine verbindliche Zusage möglich\n\nDas Objekt mit dem niedrigsten Konfidenz-Score trägt das höchste strukturelle Risiko, weil die Basis für den Match-Score unvollständig ist.`,
confidence: 0.84,
sources: ['Konfidenz-Scores', 'Datenqualität'],
}),
},
{
keywords: ['kosten', 'günstig', 'preis', 'effektiv', 'billiger'],
generate: (_ctx) => ({
content: `Kostenbewertung im Vergleich:\n\nDie reine Mietkosten-Betrachtung reicht nicht aus. Relevant ist der **Preis pro m²** im Verhältnis zu:\n• Ausstattungsstandard und Renovierungszustand\n• Nebenkosten und Betriebskosten\n• Lagequalität (ÖPNV, Infrastruktur)\n\nEin günstigeres Objekt mit hohem Renovierungsbedarf kann mittelfristig teurer werden als ein teureres, bezugsbereites Objekt.\n\n**Tipp:** Mietpreis/m² in der Vergleichstabelle nebeneinander stellen und Gesamtkosten über Mietdauer schätzen.`,
confidence: 0.79,
sources: ['Preisangaben', 'Kostenvergleich'],
}),
},
],
'data-quality': [
{
keywords: ['zuerst', 'priorität', 'erst', 'beheben', 'anfangen'],
generate: (_ctx) => ({
content: `**Empfohlene Prioritäten für sofortigen Impact:**\n\n1. **${_ctx.visibleMissingData?.[0] ?? 'Mietpreis/m²'}** — Kritisch\nOhne Preisinformation werden Objekte aus preissensitiven Suchanfragen ausgeschlossen.\n\n2. **${_ctx.visibleMissingData?.[1] ?? 'Verfügbarkeitsdatum'}** — Hoch\nZeitbasierte Matching-Logik erfordert ein konkretes Datum.\n\n3. **${_ctx.visibleMissingData?.[2] ?? 'Adresse / Koordinaten'}** — Mittel\nSuchradius-Filter benötigen geografische Verortung.\n\nNach diesen drei Feldern ist ein Qualitätsscore von ≥75% erreichbar der Schwellenwert für volle Matching-Sichtbarkeit.`,
confidence: 0.93,
sources: ['Feldgewichtung', 'Matching-Regeln'],
actions: [
{ id: 'a6', label: 'Objekt bearbeiten', description: 'Kritische Felder in der Objektansicht aktualisieren', actionType: 'NAVIGATE', payload: { path: '/supply/properties' } },
],
}),
},
{
keywords: ['fehlende', 'felder', 'impact', 'einfluss', 'auswirkung'],
generate: (_ctx) => ({
content: `Einfluss fehlender Felder auf Match-Trefferquote:\n\n| Feld | Ausschlussquote |\n|------|----------------|\n| Mietpreis | ~65% aller Gesuche |\n| Fläche m² | ~80% aller Gesuche |\n| Verfügbarkeit | ~50% zeitkritischer Gesuche |\n| Zertifizierungen | ~2030% spezifischer Gesuche |\n\nDie Fläche hat die grösste Ausschlussquote, da sie das primäre Hardkriterium für nahezu alle Suchprofile ist.`,
confidence: 0.90,
sources: ['Matching-Engine', 'Statistik-Analyse'],
}),
},
{
keywords: ['score', 'verbessern', 'erhöhen', 'schnell', 'steigern'],
generate: (_ctx) => ({
content: `Schnellste Wege zur Score-Verbesserung:\n\n• **Vollständigkeits-Boost** (+1520 Punkte): Die 3 wichtigsten kritischen Felder befüllen\n• **Aktualitäts-Boost** (+510 Punkte): Letzte Aktualisierung auf heute setzen\n• **Verifikations-Boost** (+10 Punkte): Quellenangaben zu Preisen und Verfügbarkeit hinzufügen\n\nHinweis: Der Qualitätsscore wird bei jeder Änderung neu berechnet. Kein Warten nötig.`,
confidence: 0.87,
sources: ['Score-Berechnung', 'Feldgewichtung'],
}),
},
],
'future-availability': [
{
keywords: ['probabilistisch', 'bestätigt', 'nicht bestätigt', 'warum', 'unbestätigt'],
generate: (_ctx) => ({
content: `**Warum ist das Signal probabilistisch?**\n\nDieses Signal basiert auf indirekten Datenquellen (Baugesuche, Stellenausschreibungen, Pressemitteilungen) nicht auf einer direkten Bestätigung durch den Vermieter oder Eigentümer.\n\nDie Verfügbarkeit ist eine **Wahrscheinlichkeitsaussage**, keine Tatsache. Das bedeutet:\n• Die Fläche ist möglicherweise noch nicht auf dem Markt\n• Die Zeitangabe kann sich verschieben\n• Eine alternative Nutzung ist nicht ausgeschlossen\n\n⚠️ Demand-Nutzern gegenüber darf dieses Signal nie als bestätigte Verfügbarkeit kommuniziert werden.`,
confidence: 0.95,
sources: ['Signal-Typ', 'Quellenklassifikation'],
}),
},
{
keywords: ['belege', 'evidence', 'beweise', 'unterstützen', 'daten'],
generate: (_ctx) => ({
content: `Belege für dieses Signal werden aus folgenden Quellen abgeleitet:\n\n• **Quellentyp** des Signals (z.B. Baugesuch, Jobausschreibung, Pressemitteilung)\n• **Erscheinungsdatum** der Quelle\n• **Konfidenzwert** basierend auf Quellenzuverlässigkeit und Korroborierung\n\nFür spezifische Belege: Signal-Detailansicht öffnen → Abschnitt "Evidenz".\n\nHinweis: Ein einzelner Beleg ohne Korroborierung senkt den Konfidenzwert. Mehrere unabhängige Quellen erhöhen ihn.`,
confidence: 0.88,
sources: ['Evidenz-Modul', 'Quellen-Klassifikation'],
actions: [
{ id: 'a7', label: 'Signal-Details öffnen', description: 'Evidenz-Abschnitt für dieses Signal anzeigen', actionType: 'NAVIGATE', payload: { path: '/supply/future-availability' } },
],
}),
},
{
keywords: ['review', 'prüfen', 'freigabe', 'zeigen', 'demand'],
generate: (_ctx) => ({
content: `**Vor der Freigabe an Demand-Nutzer empfehle ich:**\n\n1. **Konfidenz prüfen** Signal sollte ≥60% haben, sonst nur intern sichtbar lassen\n2. **Sensitivitätsstufe prüfen** CONFIDENTIAL-Signale nie extern zeigen\n3. **Review-Status** Signal muss mindestens IN_REVIEW-Status haben\n4. **Haftungshinweis** Disclaimer muss für Demand-Nutzer sichtbar sein\n\nFür die Freigabe: "Zur Prüfung senden" in der Signal-Detailansicht klicken.`,
confidence: 0.92,
sources: ['Review-Workflow', 'Disclosure-Regeln'],
actions: [
{ id: 'a8', label: 'Review Queue öffnen', description: 'Signal zur manuellen Prüfung übergeben', actionType: 'OPEN_REVIEW' },
],
}),
},
{
keywords: ['konfidenz', 'wahrscheinlichkeit', 'probability', 'genau'],
generate: (_ctx) => ({
content: `Der Konfidenzwert für dieses Signal setzt sich zusammen aus:\n\n• **Quellenqualität** (040%): Offizielle Quellen (Baugesuche, Amtsblatt) zählen höher als Pressemitteilungen\n• **Zeitnähe** (030%): Ältere Quellen werden abgewertet\n• **Korroborierung** (030%): Mehrere unabhängige Quellen erhöhen den Wert\n\nEin Wert unter 50% deutet auf unzuverlässige oder einzelne Quellen hin und sollte als "beobachtenswert, nicht aktionierbar" behandelt werden.`,
confidence: 0.85,
sources: ['Konfidenz-Berechnung', 'Quellengewichtung'],
}),
},
],
'review-queue': [
{
keywords: ['zuerst', 'priorität', 'dringend', 'welche'],
generate: (_ctx) => ({
content: `**Priorisierung der Review Queue:**\n\nEmpfohlene Reihenfolge nach Dringlichkeit:\n\n1. **CRITICAL + ESCALATED** Sofortiger Handlungsbedarf, meist rechtliche oder Compliance-Relevanz\n2. **HIGH + PENDING** Warten auf Entscheidung, können Prozesse blockieren\n3. **Fälligkeitsdatum überschritten** Unabhängig von Priorität\n4. **MEDIUM + IN_REVIEW** Bereits in Bearbeitung, weiterführen\n\nAufgaben ohne Fälligkeitsdatum und mit LOW-Priorität können gebündelt am Ende bearbeitet werden.`,
confidence: 0.90,
sources: ['Review-Queue-Regeln', 'Prioritäts-Framework'],
}),
},
{
keywords: ['kriterien', 'genehmigen', 'ablehnen', 'genehmigung'],
generate: (_ctx) => ({
content: `**Entscheidungskriterien:**\n\n✅ **Genehmigen**, wenn:\n• Alle Pflichtfelder vorhanden und plausibel\n• Konfidenz ≥65%\n• Kein offensichtlicher Datenfehler\n• Quellen verifizierbar\n\n❌ **Ablehnen**, wenn:\n• Schema-Validierungsfehler vorliegt\n• Inhalte nachweislich falsch oder irreführend\n• Datenschutz-Bedenken nicht ausgeräumt\n\n⚠️ **Mehr Daten anfordern**, wenn:\n• Wichtige Felder fehlen aber beschaffbar sind\n• Quelle unklar, aber plausibel`,
confidence: 0.93,
sources: ['Governance-Richtlinien', 'Review-Protokoll'],
}),
},
{
keywords: ['eskalier', 'eskalation', 'wann', 'hochstufen'],
generate: (_ctx) => ({
content: `**Eskalation ist angemessen wenn:**\n\n• Die Entscheidung Rechtsfolgen hat (Datenschutz, GDPR, Mietrecht)\n• Konflikte zwischen Stakeholdern nicht auf Reviewer-Ebene lösbar sind\n• Der Review-Task eine Geschäftsentscheidung mit hohem Risiko erfordert\n• Zwei Reviewer zu unterschiedlichen Ergebnissen kommen\n\nEskalierte Tasks landen bei der Organisationsleitung. Nutzung sparsam empfohlen zu viele Eskalationen entwerten das Signal.`,
confidence: 0.88,
sources: ['Eskalations-Framework', 'Governance'],
}),
},
],
'ai-monitoring': [
{
keywords: ['fehler', 'fehlgeschlagen', 'priorität', 'wichtig'],
generate: (_ctx) => ({
content: `**Fehler-Triage in der Reihenfolge:**\n\n1. **SCHEMA_VALIDATION** Höchste Priorität. Output wurde nicht an die UI geliefert. Nutzer hat möglicherweise unvollständige Informationen erhalten.\n2. **EMPTY_RESPONSE** Hoch. Funktion hat komplett versagt. Retry empfehlenswert.\n3. **INVALID_JSON** Mittel. Output war vorhanden, aber nicht verarbeitbar. Recovery oft möglich.\n4. **PROVIDER_TIMEOUT** Niedrig bis Mittel. Meist temporäres Problem. Retry oder Fallback prüfen.\n\nFür alle Fehler mit FLAGGED-Status: Review-Aufgabe erstellen, um manuellen Check zu dokumentieren.`,
confidence: 0.91,
sources: ['Fehler-Klassifikation', 'AI-Monitoring'],
actions: [
{ id: 'a9', label: 'Fehler filtern', description: 'AI-Monitoring-Tabelle auf Fehler filtern', actionType: 'NAVIGATE', payload: { path: '/ops/ai-monitoring' } },
],
}),
},
{
keywords: ['schema', 'validierung', 'schema-fehler', 'bedeutet'],
generate: (_ctx) => ({
content: `**Schema-Validierungsfehler erklärt:**\n\nEin Schema-Validierungsfehler bedeutet, dass der AI-Output zwar generiert wurde, aber nicht der erwarteten Datenstruktur entspricht.\n\n**Mögliche Ursachen:**\n• Pflichtfeld fehlt im Output (z.B. 'hardCriteria')\n• Falscher Datentyp (z.B. String statt Number)\n• Prompt-/Schema-Versions-Mismatch\n\n**Konsequenz:** Der Output wurde **nicht** an die UI ausgeliefert der Nutzer hat kein fehlerhaftes Resultat gesehen.\n\n**Massnahme:** Prompt-Version und Schema-Version prüfen, ggf. Prompt aktualisieren.`,
confidence: 0.94,
sources: ['Schema-Validierung', 'AI-Pipeline'],
}),
},
{
keywords: ['review', 'manuell', 'prüfung', 'brauchen'],
generate: (_ctx) => ({
content: `**AI-Outputs, die manuelle Review brauchen:**\n\n• Status **FLAGGED** wurde automatisch als problematisch markiert\n• Status **UNREVIEWED** + Fehler vorhanden hohe Priorität\n• Outputs mit **DECISION_BRIEF** oder **MATCH_EXPLANATION** Typ direkte Auswirkung auf Nutzerentscheidungen\n• Latenz >5s deutet auf Qualitätsprobleme hin\n\nOutput direkt in der Review Queue anlegen: "Zur Prüfung" Button in der Detail-Ansicht.`,
confidence: 0.89,
sources: ['Review-Regeln', 'AI-Monitoring'],
actions: [
{ id: 'a10', label: 'Review Queue öffnen', description: 'Zur Review Queue navigieren', actionType: 'NAVIGATE', payload: { path: '/ops/review-queue' } },
],
}),
},
],
'general': [
{
keywords: ['kpi', 'kennzahlen', 'überblick', 'metriken'],
generate: () => ({
content: `Die wichtigsten KPIs je Workspace:\n\n**Verwaltung (Supply):**\n• Datenqualitäts-Score (Ziel: ≥70%)\n• Match-Rate (Anteil Objekte mit ≥1 aktivem Match)\n\n**Suche (Demand):**\n• Trefferquote (Ergebnisse mit Score ≥70%)\n• Hardkriterien-Erfüllungsrate\n\n**Administration (Ops):**\n• Offene Review-Tasks\n• AI-Fehlerrate\n• Genehmigungsrate`,
confidence: 0.82,
sources: ['Dashboard', 'Monitoring'],
}),
},
{
keywords: ['nächste', 'aktion', 'empfehlung', 'was tun', 'handlung'],
generate: (_ctx) => ({
content: `Empfohlene nächste Aktionen basierend auf dem aktuellen Workspace:\n\n• **Datenpflege-Backlog abarbeiten** Objekte unter 65% Qualitätsscore priorisieren\n• **Review Queue prüfen** Offene CRITICAL-Tasks zuerst\n• **AI-Fehler quittieren** FLAGGED-Outputs in AI-Monitoring markieren\n\nDer Assistant kann konkretere Empfehlungen geben, wenn eine spezifische Seite (Objekt, Match, Signal) geöffnet ist.`,
confidence: 0.75,
sources: ['Kontextanalyse'],
}),
},
{
keywords: ['daten', 'vorbereiten', 'matches', 'bessere'],
generate: () => ({
content: `**Daten für bessere Matches vorbereiten:**\n\n1. **Vollständigkeit** Alle Pflichtfelder (Fläche, Preis, Verfügbarkeit, Adresse) befüllen\n2. **Aktualität** Veraltete Angaben (>6 Monate) aktualisieren\n3. **Präzision** Exakte m²-Angaben statt Schätzwerte\n4. **Kontext** Beschreibung von Ausstattung und Besonderheiten hilft der semantischen Suche\n\nJedes komplett befüllte und aktuelle Objekt erhöht die Match-Sichtbarkeit signifikant.`,
confidence: 0.88,
sources: ['Matching-Regeln', 'Best-Practices'],
}),
},
],
}
// ── Template matching ─────────────────────────────────────────────────────────
function findTemplate(question: string, pageCtx: string): Template | null {
const q = question.toLowerCase()
const bucket = TEMPLATES[pageCtx] ?? TEMPLATES['general'] ?? []
for (const t of bucket) {
if (t.keywords.some(kw => q.includes(kw))) return t
}
return bucket[0] ?? TEMPLATES['general']?.[0] ?? null
}
// ── Public service API ────────────────────────────────────────────────────────
export const aiAssistantService = {
async getSuggestions(context: AssistantContext): Promise<SuggestedQuestion[]> {
await delay(200)
const page = pageType(context.currentRoute)
return (SUGGESTIONS[page] ?? SUGGESTIONS['general']).slice(0, 4)
},
async answerQuestion(context: AssistantContext, question: string): Promise<Omit<AssistantMessage, 'id' | 'role' | 'createdAt'>> {
await delay(700 + Math.random() * 700)
const page = pageType(context.currentRoute)
const template = findTemplate(question, page) ?? findTemplate(question, 'general')
if (!template) {
return {
content: 'Zu dieser Frage liegen derzeit keine ausreichenden Kontextdaten vor. Bitte öffnen Sie eine spezifische Objekt- oder Match-Ansicht und stellen Sie die Frage erneut.',
confidence: 0.5,
sources: [],
}
}
return template.generate(context)
},
async createActionFromAnswer(_action: import('../domain/assistant').AssistantAction): Promise<{ success: boolean }> {
await delay(100)
return { success: true }
},
}
@@ -0,0 +1,24 @@
import { MockupAIMonitoringProvider } from '../provider/MockupAIMonitoringProvider'
import type { AIMonitoringFilters } from '../provider/IAIMonitoringProvider'
import type { AIOutput } from '../domain/aiOutput'
import type { ReviewStatus } from '../domain/enums'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupAIMonitoringProvider
export const aiMonitoringService = {
async getOutputs(filters?: AIMonitoringFilters): Promise<ListResponse<AIOutput>> {
const data = await provider.getOutputs(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getOutput(id: string): Promise<ItemResponse<AIOutput | null>> {
const data = await provider.getOutput(id)
return { data }
},
async updateReviewStatus(id: string, status: ReviewStatus): Promise<ItemResponse<AIOutput>> {
const data = await provider.updateReviewStatus(id, status)
return { data }
},
}
@@ -0,0 +1,424 @@
import type { ItemResponse, ServiceError } from './types'
import { ServiceErrorCode } from './types'
import type { CreateNeedInput } from '../domain/need'
import type { AssetType } from '../domain/enums'
import type { ParseNeedResult, ParsedNeedCriteria, FollowUpQuestion } from '../domain/needBuilder'
import type { UnifiedMatchResult } from '../domain/unifiedResult'
// ── Decision Brief ────────────────────────────────────────────────────────────
export interface DecisionBrief {
id: string
shortlistId: string
summary: string
sections: { title: string; body: string }[]
generatedAt: string
isDraft: true
}
function buildMockDecisionBrief(shortlistId: string): DecisionBrief {
return {
id: crypto.randomUUID(),
shortlistId,
summary: 'Die Shortlist enthält qualitativ hochwertige Matches mit starker Standortübereinstimmung. Die verfügbaren Flächen decken den Bedarf gut ab. Zwei Objekte eignen sich als Erstbesichtigungen.',
sections: [
{
title: 'Zusammenfassung der Objekte',
body: 'Die Shortlist umfasst mehrere Objekte aus dem verifizierten Portfolio. Die Matchscores liegen zwischen 74 und 88, was auf eine gute bis sehr gute Übereinstimmung mit den Suchkriterien hinweist.',
},
{
title: 'Standortbewertung',
body: 'Die Mehrheit der Objekte befindet sich in bevorzugten Lagen. Die ÖV-Anbindung ist bei allen Objekten als gut bis sehr gut einzustufen.',
},
{
title: 'Budgetanalyse',
body: 'Die Mietpreise liegen im budgetkonformen Bereich. Keine der Optionen überschreitet das maximale Budget pro m².',
},
{
title: 'Empfohlene nächste Schritte',
body: '1. Besichtigung der Top-2-Objekte vereinbaren. 2. Detaillierte Flächenpläne anfordern. 3. Vertragskonditionen prüfen lassen.',
},
],
generatedAt: new Date().toISOString(),
isDraft: true,
}
}
// ── Compare Summary ───────────────────────────────────────────────────────────
export interface ComparisonSummary {
strongestOption: { matchId: string; label: string; reason: string }
bestValue: { matchId: string; label: string; reason: string } | null
highestConfidence: { matchId: string; label: string; confidenceLevel: number }
biggestTradeoffs: string[]
missingDataWarnings: string[]
recommendedNextStep: string
}
function buildComparisonSummary(items: UnifiedMatchResult[]): ComparisonSummary {
if (items.length === 0) {
return {
strongestOption: { matchId: '', label: '', reason: 'Keine Ergebnisse' },
bestValue: null,
highestConfidence: { matchId: '', label: '', confidenceLevel: 0 },
biggestTradeoffs: [],
missingDataWarnings: [],
recommendedNextStep: 'Suchergebnisse überprüfen',
}
}
const getTitle = (item: UnifiedMatchResult) =>
item.resultType !== 'FUTURE_AVAILABILITY'
? (item as any).property?.title ?? `Match ${item.matchScore}`
: (item as any).signal?.companyName ?? 'Zukunftssignal'
const strongest = items.reduce((a, b) => a.matchScore > b.matchScore ? a : b)
const propertyItems = items.filter(i => i.resultType !== 'FUTURE_AVAILABILITY')
const bestValue = propertyItems.length > 0
? propertyItems.reduce((a, b) =>
((a as any).property?.rentPricePerSqm ?? Infinity) <= ((b as any).property?.rentPricePerSqm ?? Infinity) ? a : b
)
: null
const highestConf = items.reduce((a, b) =>
a.match.confidenceLevel >= b.match.confidenceLevel ? a : b
)
const tradeoffs = items
.flatMap(i => i.match.tradeoffs?.slice(0, 1).map(t => `${getTitle(i)}: ${t.concern}`) ?? [])
.slice(0, 3)
const missingWarnings = items
.filter(i => (i.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0) > 0)
.map(i => `${getTitle(i)}: fehlende Pflichtfelder`)
const topNextAction = strongest.match.nextBestActions?.[0]?.label ?? 'Objekt besichtigen oder Details prüfen'
return {
strongestOption: {
matchId: strongest.matchId,
label: getTitle(strongest),
reason: `Höchster Match Score (${strongest.matchScore}/100)`,
},
bestValue: bestValue
? {
matchId: bestValue.matchId,
label: getTitle(bestValue),
reason: `Niedrigster Mietpreis (CHF ${(bestValue as any).property?.rentPricePerSqm ?? ''}/m²)`,
}
: null,
highestConfidence: {
matchId: highestConf.matchId,
label: getTitle(highestConf),
confidenceLevel: highestConf.match.confidenceLevel,
},
biggestTradeoffs: tradeoffs,
missingDataWarnings: missingWarnings,
recommendedNextStep: topNextAction,
}
}
// ── Legacy types (kept for backward compatibility) ────────────────────────────
export interface CriteriaExtractionResult {
extractedCriteria: Partial<CreateNeedInput>
confidence: number
missingFields: string[]
assumptions: string[]
followUpQuestions: string[]
}
export interface AIServiceProvider {
extractCriteria(naturalLanguageInput: string): Promise<CriteriaExtractionResult>
generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]>
}
// ── Mock parse logic ──────────────────────────────────────────────────────────
function mockParseNeed(input: string): ParseNeedResult {
const lower = input.toLowerCase()
// Asset type
const assetType: AssetType | undefined =
lower.includes('büro') || lower.includes('office') ? 'OFFICE'
: lower.includes('logistik') || lower.includes('lager') ? 'LOGISTICS'
: lower.includes('retail') || lower.includes('laden') || lower.includes('shop') ? 'RETAIL'
: lower.includes('produktion') || lower.includes('gewerbe') || lower.includes('industrie') ? 'PRODUCTION'
: lower.includes('gastro') || lower.includes('restaurant') ? 'GASTRO'
: undefined
// Area
const areaRangeMatch = input.match(/(\d+)\s*[\-]\s*(\d+)\s*m[²2]/i)
const areaSingleMatch = input.match(/(\d{3,5})\s*m[²2]/i)
let areaRange: { min: number; max: number } | undefined
let areaConfidence = 0.25
if (areaRangeMatch) {
areaRange = { min: parseInt(areaRangeMatch[1]), max: parseInt(areaRangeMatch[2]) }
areaConfidence = 0.95
} else if (areaSingleMatch) {
const base = parseInt(areaSingleMatch[1])
areaRange = { min: Math.round(base * 0.8), max: Math.round(base * 1.2) }
areaConfidence = 0.70
}
// Locations
const CITIES: [string, string][] = [
['zürich', 'Zürich'], ['basel', 'Basel'], ['bern', 'Bern'], ['genf', 'Genf'],
['lausanne', 'Lausanne'], ['winterthur', 'Winterthur'], ['zug', 'Zug'],
['luzern', 'Luzern'], ['st. gallen', 'St. Gallen'], ['lugano', 'Lugano'],
['biel', 'Biel'], ['schaffhausen', 'Schaffhausen'],
]
const preferredLocations = CITIES.filter(([k]) => lower.includes(k)).map(([, v]) => v)
const locationConfidence = preferredLocations.length > 0 ? 0.88 : 0.15
// Budget
const budgetPerSqmMatch = input.match(/(\d+)\s*(?:CHF)?\s*\/\s*m[²2]/i)
const budgetMaxMatch = input.match(/(?:max\.?|bis|höchstens)\s*(?:CHF\s*)?(\d+)/i)
let budgetRange: { maxPerSqm: number; currency: string } | undefined
let budgetConfidence = 0.20
if (budgetPerSqmMatch) {
budgetRange = { maxPerSqm: parseInt(budgetPerSqmMatch[1]), currency: 'CHF' }
budgetConfidence = 0.92
} else if (budgetMaxMatch) {
budgetRange = { maxPerSqm: parseInt(budgetMaxMatch[1]), currency: 'CHF' }
budgetConfidence = 0.60
}
// Timing
const yearMatch = input.match(/(?:ab\s+)?(?:Q[1-4]\s*\/?\s*)?(\d{4})/)
const soonMatch = lower.includes('sofort') || lower.includes('asap')
let timing: ParsedNeedCriteria['timing'] | undefined
let timingConfidence = 0.20
if (soonMatch) {
timing = { earliestMoveIn: '2025-07-01', latestMoveIn: '2025-10-01', flexibleTiming: false }
timingConfidence = 0.85
} else if (yearMatch) {
timing = { earliestMoveIn: `${yearMatch[1]}-01-01`, latestMoveIn: `${yearMatch[1]}-12-31`, flexibleTiming: lower.includes('flexibel') }
timingConfidence = 0.75
}
// Must-haves
const mustHaveCriteria: string[] = []
if (lower.includes('öv') || lower.includes('bahnhof') || lower.includes('tram')) mustHaveCriteria.push('Gute ÖV-Anbindung')
if (lower.includes('parking') || lower.includes('parkplatz') || lower.includes('tiefgarage')) mustHaveCriteria.push('Parkplätze vorhanden')
if (lower.includes('klimaanlage') || lower.includes('klima')) mustHaveCriteria.push('Klimaanlage')
if (lower.includes('server') || lower.includes('rechenzentr')) mustHaveCriteria.push('Serverraum / IT-Infrastruktur')
if (lower.includes('barrierefrei')) mustHaveCriteria.push('Barrierefreiheit')
if (lower.includes('küche') || lower.includes('kantine')) mustHaveCriteria.push('Kantine / Küche')
// Soft
const prestigeImportance: 'LOW' | 'MEDIUM' | 'HIGH' | undefined =
lower.includes('prestige') || lower.includes('representativ') || lower.includes('repräsentativ') ? 'HIGH'
: lower.includes('standard') ? 'LOW'
: undefined
const parkingNeed = lower.includes('parking') || lower.includes('parkplatz')
const visibilityNeed: 'HIGH' | undefined = lower.includes('sichtbar') || lower.includes('passanten') ? 'HIGH' : undefined
const footfallNeed: 'HIGH' | undefined = lower.includes('frequenz') || lower.includes('laufkundschaft') ? 'HIGH' : undefined
// Missing fields
const missingFields: string[] = []
if (!assetType) missingFields.push('Nutzungstyp')
if (!areaRange) missingFields.push('Flächenbedarf')
if (preferredLocations.length === 0) missingFields.push('Standort')
if (!budgetRange) missingFields.push('Budget')
if (!timing) missingFields.push('Verfügbarkeitstermin')
// Assumptions
const assumptions: string[] = []
if (areaRange && areaSingleMatch && !areaRangeMatch) {
assumptions.push(`Flächenrange aus Einzelangabe (${areaSingleMatch[1]} m²) geschätzt — bitte prüfen`)
}
if (budgetRange && !budgetPerSqmMatch && budgetMaxMatch) {
assumptions.push('Budget als Pauschalangabe interpretiert — Angabe pro m² unklar')
}
if (!assetType) {
assumptions.push('Nutzungstyp konnte nicht eindeutig erkannt werden')
}
// Confidence by field
const confidenceByField: Record<string, number> = {
assetType: assetType ? 0.92 : 0.20,
areaRange: areaConfidence,
preferredLocations: locationConfidence,
budgetRange: budgetConfidence,
timing: timingConfidence,
mustHaveCriteria: mustHaveCriteria.length > 0 ? 0.85 : 0.10,
prestigeImportance: prestigeImportance ? 0.80 : 0.20,
parkingNeed: parkingNeed ? 0.90 : 0.30,
}
// Follow-up questions
const followUpQuestionCandidates: FollowUpQuestion[] = []
if (!assetType) {
followUpQuestionCandidates.push({
id: 'fq-asset-type',
questionText: 'Welchen Nutzungstyp suchen Sie?',
targetField: 'assetType',
reason: 'Der Nutzungstyp konnte nicht eindeutig erkannt werden.',
suggestedAnswerOptions: ['Büro', 'Logistik / Lager', 'Retail', 'Produktion', 'Gastro / F&B'],
importance: 'required',
})
}
if (preferredLocations.length === 0) {
followUpQuestionCandidates.push({
id: 'fq-location',
questionText: 'In welcher Region oder Stadt suchen Sie?',
targetField: 'preferredLocations',
reason: 'Kein konkreter Standort angegeben.',
suggestedAnswerOptions: ['Zürich', 'Basel', 'Bern', 'Zug', 'Luzern', 'Genf'],
importance: 'required',
})
}
if (!timing) {
followUpQuestionCandidates.push({
id: 'fq-timing',
questionText: 'Ab wann benötigen Sie die Fläche?',
targetField: 'timing',
reason: 'Kein Verfügbarkeitsdatum erkannt.',
suggestedAnswerOptions: ['Sofort', 'In 3 Monaten', 'In 6 Monaten', 'In 12 Monaten', 'Flexibel'],
importance: 'recommended',
})
}
if (!budgetRange) {
followUpQuestionCandidates.push({
id: 'fq-budget',
questionText: 'Was ist Ihr Maximalbudget pro m² und Jahr?',
targetField: 'budgetRange',
reason: 'Kein Budget erkannt.',
suggestedAnswerOptions: ['< CHF 20/m²', 'CHF 2040/m²', 'CHF 4080/m²', '> CHF 80/m²', 'Flexible'],
importance: 'recommended',
})
}
followUpQuestionCandidates.push({
id: 'fq-parking',
questionText: 'Benötigen Sie Parkplätze vor Ort?',
targetField: 'parkingNeed',
reason: 'Angabe zu Parkplatzbedarf verbessert die Matchqualität.',
suggestedAnswerOptions: ['Ja, zwingend', 'Ja, wenn möglich', 'Nein'],
importance: 'optional',
})
// Suggested weights
const suggestedWeights: Record<string, number> = {
area: 0.20,
location: preferredLocations.length > 0 ? 0.28 : 0.22,
budget: budgetRange ? 0.22 : 0.18,
timing: timing ? 0.15 : 0.12,
prestige: prestigeImportance === 'HIGH' ? 0.10 : 0.05,
accessibility: mustHaveCriteria.includes('Gute ÖV-Anbindung') ? 0.08 : 0.04,
expansionPotential: 0.03,
flexibility: lower.includes('flexibel') ? 0.07 : 0.03,
}
const rawSummary = `Bedarf analysiert: ${assetType ?? 'Typ unbekannt'} · ${preferredLocations.join(', ') || 'Standort unklar'} · ${areaRange ? `${areaRange.min}${areaRange.max}` : 'Fläche unklar'} · ${budgetRange ? `max. CHF ${budgetRange.maxPerSqm}/m²` : 'Budget unklar'}`
return {
extractedCriteria: {
assetType,
areaRange,
preferredLocations,
budgetRange,
timing,
mustHaveCriteria,
infrastructureRequirements: [],
accessibilityRequirements: [],
prestigeImportance,
flexibilityNeed: lower.includes('flexibel') ? 'HIGH' : 'MEDIUM',
expansionPotential: lower.includes('wachstum') || lower.includes('expansion'),
parkingNeed,
visibilityNeed,
footfallNeed,
},
confidenceByField,
missingFields,
assumptions,
suggestedWeights,
followUpQuestionCandidates,
rawSummary,
promptVersion: 'mock-v1.0',
schemaVersion: '1.0.0',
}
}
// ── Legacy mock provider (kept for backward compat) ───────────────────────────
const MockupAIServiceProvider: AIServiceProvider = {
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
return {
extractedCriteria: {
companyName: 'Unbekannt (bitte bestätigen)',
requiredArea: { min: 400, max: 900 },
budgetRange: { maxPerSqm: 40, currency: 'CHF' },
},
confidence: 0.72,
missingFields: ['assetType', 'timing', 'preferredLocations'],
assumptions: ['Fläche aus Zahlenangabe geschätzt', 'Budget aus Kostennennung abgeleitet'],
followUpQuestions: [
'Welchen Nutzungstyp suchen Sie (Büro, Retail, Logistik)?',
'In welchen Städten oder Regionen suchen Sie?',
'Wann möchten Sie spätestens einziehen?',
],
}
},
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
const questions: string[] = []
if (!partialNeed.assetType) questions.push('Welchen Nutzungstyp suchen Sie?')
if (!partialNeed.preferredLocations?.length) questions.push('In welchen Regionen suchen Sie?')
if (!partialNeed.timing) questions.push('Was ist Ihr gewünschter Einzugstermin?')
if (!partialNeed.budgetRange) questions.push('Was ist Ihr maximales monatliches Budget?')
return questions
},
}
const provider = MockupAIServiceProvider
const notConfiguredError = (): ServiceError => ({
code: ServiceErrorCode.AI_GENERATION_FAILED,
message: 'OpenRouter nicht konfiguriert',
})
export const openRouterAIService: AIServiceProvider = {
async extractCriteria(_input: string): Promise<CriteriaExtractionResult> {
throw notConfiguredError()
},
async generateFollowUp(_partialNeed: Partial<CreateNeedInput>): Promise<string[]> {
throw notConfiguredError()
},
}
export const aiService = {
// Legacy methods
async extractCriteria(input: string): Promise<ItemResponse<CriteriaExtractionResult>> {
const data = await provider.extractCriteria(input)
return { data }
},
async generateFollowUp(partialNeed: Partial<CreateNeedInput>): Promise<ItemResponse<string[]>> {
const data = await provider.generateFollowUp(partialNeed)
return { data }
},
// F014: Compare summary
async summarizeComparison(items: UnifiedMatchResult[]): Promise<ItemResponse<ComparisonSummary>> {
await new Promise(r => setTimeout(r, 600))
return { data: buildComparisonSummary(items) }
},
// F008 methods
async parseNeed(input: string): Promise<ItemResponse<ParseNeedResult>> {
await new Promise(r => setTimeout(r, 1400))
const data = mockParseNeed(input)
return { data }
},
async generateFollowUpQuestions(criteria: ParsedNeedCriteria): Promise<ItemResponse<FollowUpQuestion[]>> {
await new Promise(r => setTimeout(r, 600))
const result = mockParseNeed(JSON.stringify(criteria))
return { data: result.followUpQuestionCandidates }
},
async generateDecisionBrief(shortlistId: string): Promise<ItemResponse<DecisionBrief>> {
await new Promise(r => setTimeout(r, 1800))
return { data: buildMockDecisionBrief(shortlistId) }
},
}
@@ -0,0 +1,132 @@
import { useSessionStore } from '../stores/sessionStore'
import type { MockUser } from '../stores/sessionStore'
import type { ItemResponse } from './types'
import { UserRole, WorkspaceType } from '../domain/enums'
import { getPermissions, getAccessibleWorkspaces } from '../lib/permissions'
import type { Permission } from '../lib/permissions'
// Mock organizations for org switching
const MOCK_ORGANIZATIONS: { id: string; name: string }[] = [
{ id: 'org-wincasa', name: 'Wincasa AG' },
{ id: 'org-mobimo', name: 'Mobimo Management AG' },
{ id: 'org-ubs', name: 'UBS Asset Management RE' },
]
// Demo user presets per role
const DEMO_USERS: Record<UserRole, MockUser> = {
[UserRole.SUPER_ADMIN]: {
id: 'user-super',
email: 'super@ideal-sharing.ch',
name: 'Super Admin',
role: UserRole.SUPER_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
},
[UserRole.ORGANIZATION_ADMIN]: {
id: 'user-001',
email: 'admin@ideal-sharing.ch',
name: 'Admin User',
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND],
},
[UserRole.PROPERTY_MANAGER]: {
id: 'user-pm',
email: 'pm@ideal-sharing.ch',
name: 'Property Manager',
role: UserRole.PROPERTY_MANAGER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.PROPERTY_MANAGER),
},
[UserRole.REVIEWER]: {
id: 'user-rev',
email: 'reviewer@ideal-sharing.ch',
name: 'Reviewer',
role: UserRole.REVIEWER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.REVIEWER),
},
[UserRole.OWNER_VIEWER]: {
id: 'user-ov',
email: 'owner@ideal-sharing.ch',
name: 'Owner Viewer',
role: UserRole.OWNER_VIEWER,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.OWNER_VIEWER),
},
[UserRole.DEMAND_USER]: {
id: 'user-dem',
email: 'demand@ideal-sharing.ch',
name: 'Demand User',
role: UserRole.DEMAND_USER,
organizationId: 'org-mobimo',
organizationName: 'Mobimo Management AG',
allowedWorkspaces: getAccessibleWorkspaces(UserRole.DEMAND_USER),
},
}
export const authService = {
async getCurrentUser(): Promise<ItemResponse<MockUser | null>> {
const data = useSessionStore.getState().currentUser
return { data }
},
async getCurrentOrganization(): Promise<ItemResponse<{ id: string; name: string } | null>> {
const { activeOrganizationId } = useSessionStore.getState()
const org = MOCK_ORGANIZATIONS.find((o) => o.id === activeOrganizationId) ?? null
return { data: org }
},
async login(email: string, _password: string): Promise<ItemResponse<MockUser>> {
const existing = Object.values(DEMO_USERS).find((u) => u.email === email)
const user: MockUser = existing ?? {
id: 'user-001',
email,
name: 'Admin User',
role: UserRole.ORGANIZATION_ADMIN,
organizationId: 'org-wincasa',
organizationName: 'Wincasa AG',
allowedWorkspaces: [WorkspaceType.SUPPLY, WorkspaceType.DEMAND, WorkspaceType.OPERATIONS],
}
useSessionStore.getState().login(user)
return { data: user }
},
async logout(): Promise<ItemResponse<void>> {
useSessionStore.getState().logout()
return { data: undefined }
},
async isAuthenticated(): Promise<ItemResponse<boolean>> {
const data = useSessionStore.getState().isAuthenticated
return { data }
},
async switchDemoRole(role: UserRole): Promise<ItemResponse<MockUser>> {
const user = DEMO_USERS[role]
useSessionStore.getState().login(user)
return { data: user }
},
async switchOrganization(organizationId: string): Promise<ItemResponse<void>> {
const org = MOCK_ORGANIZATIONS.find((o) => o.id === organizationId)
if (org) {
const state = useSessionStore.getState()
if (state.currentUser) {
state.login({ ...state.currentUser, organizationId: org.id, organizationName: org.name })
} else {
state.switchOrganization(organizationId)
}
}
return { data: undefined }
},
async getPermissions(user: MockUser): Promise<ItemResponse<Permission[]>> {
return { data: getPermissions(user) }
},
}
@@ -0,0 +1,36 @@
import { propertyService } from './propertyService'
import { matchService } from './matchService'
import { futureSignalService } from './futureSignalService'
import { dataQualityService } from './dataQualityService'
import { reviewService } from './reviewService'
import type { DashboardData } from '../domain/dashboard'
export const dashboardService = {
async getDashboardData(): Promise<DashboardData> {
const [propRes, matchRes, signalRes, qualityRes, reviewRes] = await Promise.allSettled([
propertyService.getDashboardPropertiesSummary(),
matchService.getStrongMatches(),
futureSignalService.getSignalSummary(),
dataQualityService.getPortfolioQualitySummary(),
reviewService.getDashboardTasks(),
])
const propSummary = propRes.status === 'fulfilled' ? propRes.value : null
const strongMatches = matchRes.status === 'fulfilled' ? matchRes.value : null
const signals = signalRes.status === 'fulfilled' ? signalRes.value : null
const quality = qualityRes.status === 'fulfilled' ? qualityRes.value : null
const tasks = reviewRes.status === 'fulfilled' ? reviewRes.value : null
return {
totalProperties: propSummary?.total ?? 0,
activeProperties: propSummary?.active ?? 0,
strongMatchCount: strongMatches?.length ?? 0,
avgDataQuality: quality?.avgScore ?? 0,
futureSignals: signals,
dataQuality: quality,
reviewTasks: tasks,
strongMatches,
lastUpdated: new Date().toISOString(),
}
},
}
@@ -0,0 +1,167 @@
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import { FreshnessStatus } from '../domain/enums'
import type { DataQualitySummary } from '../domain/dashboard'
import type { DataQuality, Property } from '../domain/property'
export type RecommendedAction = {
id: string
label: string
detail: string
priority: 'HIGH' | 'MEDIUM' | 'LOW'
field?: string
}
// ── Field → Action map ────────────────────────────────────────────────────────
const FIELD_ACTION_MAP: Record<string, Omit<RecommendedAction, 'id'>> = {
'Mietpreis/m²': { label: 'Mietpreis ergänzen', detail: 'Fehlender Mietpreis schließt Objekt aus Budget-Matches aus', priority: 'HIGH', field: 'Mietpreis/m²' },
'Fläche m²': { label: 'Fläche bestätigen', detail: 'Fläche ist Hard-Kriterium für alle Matchings', priority: 'HIGH', field: 'Fläche m²' },
'Verfügbarkeit': { label: 'Verfügbarkeit bestätigen', detail: 'Timing ist entscheidend für Nachfrager mit Deadlines', priority: 'HIGH', field: 'Verfügbarkeit' },
'Adresse': { label: 'Adresse vervollständigen', detail: 'Für Standortbewertung und Kartenansicht notwendig', priority: 'HIGH', field: 'Adresse' },
'Beschreibung': { label: 'Beschreibung hinzufügen', detail: 'Verbesserter Kontext erhöht Nachfrager-Vertrauen', priority: 'MEDIUM', field: 'Beschreibung' },
'Soft Factors': { label: 'Passantenfrequenz & ESG', detail: 'Soft Factors verbessern Match-Scoring erheblich', priority: 'MEDIUM', field: 'Soft Factors' },
'Ausbaustandard': { label: 'Ausbaustandard angeben', detail: 'SHELL/BASIC/FULL/PREMIUM beeinflusst Eignung stark', priority: 'MEDIUM', field: 'Ausbaustandard' },
'Bilder': { label: 'Bilder hochladen', detail: 'Objektfotos steigern Anfragerate deutlich', priority: 'MEDIUM', field: 'Bilder' },
'Jahresmiete (CHF)': { label: 'Jahresmiete angeben', detail: 'Ergänzt Mietpreis/m² für Budgetvergleiche', priority: 'LOW', field: 'Jahresmiete (CHF)' },
'Expansionspotenzial': { label: 'Erweiterungsfläche angeben', detail: 'Wichtig für wachsende Unternehmen', priority: 'LOW', field: 'Expansionspotenzial' },
}
const FRESHNESS_ACTIONS: Record<string, RecommendedAction> = {
[FreshnessStatus.OUTDATED]: {
id: 'review_source',
label: 'Quelle überprüfen',
detail: 'Daten sind älter als 14 Tage — Verfügbarkeit könnte sich geändert haben',
priority: 'HIGH',
},
[FreshnessStatus.STALE]: {
id: 'update_data',
label: 'Daten aktualisieren',
detail: 'Daten sind 214 Tage alt — Aktualitätsscore reduziert',
priority: 'MEDIUM',
},
}
// ── Core Checks ───────────────────────────────────────────────────────────────
const CRITICAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
{ field: 'Mietpreis/m²', present: p => p.rentPricePerSqm > 0 },
{ field: 'Fläche m²', present: p => p.areaSqm > 0 },
{ field: 'Verfügbarkeit', present: p => !!p.availabilityDate },
{ field: 'Adresse', present: p => !!p.address?.street && !!p.address?.city },
]
const OPTIONAL_CHECKS: Array<{ field: string; present: (p: Property) => boolean }> = [
{ field: 'Beschreibung', present: p => !!p.description && p.description.length > 20 },
{ field: 'Soft Factors', present: p => !!(p.softFactors?.prestigeScore || p.softFactors?.footfallScore || p.softFactors?.commuterAccessScore) },
{ field: 'Ausbaustandard', present: p => !!p.hardFacts?.fitOut },
{ field: 'Bilder', present: p => (p.images?.length ?? 0) > 0 },
{ field: 'Jahresmiete (CHF)', present: p => !!p.rentChfSqmYear },
{ field: 'Expansionspotenzial', present: p => !!(p.expansionPotentialSqm || p.hardFacts) },
]
// ── Public API ────────────────────────────────────────────────────────────────
export function getMissingCriticalFields(property: Property): string[] {
const fromData = property.dataQuality?.missingCriticalFields ?? []
if (fromData.length > 0) return fromData
return CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
}
export function getQualityWarnings(property: Property): string[] {
return property.dataQuality?.warnings ?? []
}
export function getRecommendedActions(quality: DataQuality, freshness?: string): RecommendedAction[] {
const actions: RecommendedAction[] = []
for (const field of quality.missingCriticalFields) {
const def = FIELD_ACTION_MAP[field]
if (def) actions.push({ id: `fill_${field}`, ...def })
}
const fn = freshness ?? quality.freshness
if (fn && fn !== FreshnessStatus.FRESH) {
const freshnessAction = FRESHNESS_ACTIONS[fn]
if (freshnessAction) actions.push(freshnessAction)
}
for (const field of quality.missingOptionalFields) {
const def = FIELD_ACTION_MAP[field]
if (def) actions.push({ id: `fill_opt_${field}`, ...def })
}
return actions
}
export function calculatePropertyQuality(property: Property): DataQuality {
if (property.dataQuality?.qualityLevel) return property.dataQuality
const missingCritical = CRITICAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
const missingOptional = OPTIONAL_CHECKS.filter(c => !c.present(property)).map(c => c.field)
const completeness = 1 - (missingCritical.length * 0.15 + missingOptional.length * 0.05)
const confidence = property.confidenceScore ?? 0.5
const freshnessVal = property.dataQuality?.freshness ?? FreshnessStatus.OUTDATED
const freshnessFactor = freshnessVal === FreshnessStatus.FRESH ? 1 : freshnessVal === FreshnessStatus.STALE ? 0.7 : 0.4
const score = Math.min(1, Math.max(0, completeness * 0.5 + confidence * 0.3 + freshnessFactor * 0.2))
const warnings: string[] = []
if (confidence < 0.5) warnings.push('Niedrige Daten-Vertrauensscore')
if (freshnessVal === FreshnessStatus.OUTDATED) warnings.push('Daten sind veraltet (>14 Tage)')
if (missingCritical.length > 0) warnings.push(`${missingCritical.length} Pflichtfeld(er) fehlen`)
const qualityLevel = missingCritical.length > 0
? 'INCOMPLETE'
: score >= 0.8 ? 'HIGH' : score >= 0.6 ? 'MEDIUM' : 'LOW'
return {
score,
qualityLevel,
missingCriticalFields: missingCritical,
missingOptionalFields: missingOptional,
lastVerifiedAt: property.dataQuality?.lastVerifiedAt,
freshness: freshnessVal,
warnings,
}
}
// ── Portfolio summary (existing) ──────────────────────────────────────────────
export const dataQualityService = {
async getPortfolioQualitySummary(): Promise<DataQualitySummary> {
const properties = await MockupPropertyProvider.getAll()
const avgScoreRaw =
properties.length > 0
? properties.reduce((sum, p) => sum + (p.dataQuality?.score ?? 0), 0) / properties.length
: 0
const fieldCounts = properties
.flatMap(p => p.dataQuality?.missingCriticalFields ?? [])
.reduce<Record<string, number>>((acc, f) => {
acc[f] = (acc[f] ?? 0) + 1
return acc
}, {})
const topMissingFields = Object.entries(fieldCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([f]) => f)
return {
avgScore: Math.round(avgScoreRaw * 100),
critical: properties.filter(p => (p.dataQuality?.score ?? 0) < 0.5).length,
propertiesWithMissingCritical: properties.filter(
p => (p.dataQuality?.missingCriticalFields?.length ?? 0) > 0,
).length,
topMissingFields,
}
},
getMissingCriticalFields,
getQualityWarnings,
getRecommendedActions,
calculatePropertyQuality,
}
@@ -0,0 +1,56 @@
import { MockupFutureSignalProvider } from '../provider/MockupFutureSignalProvider'
import type { FutureSignalFilters } from '../provider/IFutureSignalProvider'
import type { FutureSignal } from '../domain/futureSignal'
import type { ReviewStatus } from '../domain/enums'
import type { FutureSignalSummary } from '../domain/dashboard'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupFutureSignalProvider
export const futureSignalService = {
async getAll(filters?: FutureSignalFilters): Promise<ListResponse<FutureSignal>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<FutureSignal | null>> {
const data = await provider.getById(id)
return { data }
},
async getByProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async verify(id: string, verifiedBy: string): Promise<ItemResponse<FutureSignal>> {
const data = await provider.verify(id, verifiedBy)
return { data }
},
async updateReviewStatus(id: string, status: ReviewStatus): Promise<ItemResponse<FutureSignal>> {
const data = await provider.updateReviewStatus(id, status)
return { data }
},
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getSignalSummary(): Promise<FutureSignalSummary> {
const signals = await provider.getAll()
const RESTRICTED: readonly string[] = ['CONFIDENTIAL', 'INTERNAL']
return {
total: signals.length,
highConfidence: signals.filter(s => s.confidenceScore >= 0.75).length,
restricted: signals.filter(s => RESTRICTED.includes(s.sensitivityLevel)).length,
needsReview: signals.filter(s => !s.isVerified).length,
avgTimeHorizonMonths:
signals.length > 0
? Math.round(signals.reduce((sum, s) => sum + (s.timeHorizonMonths ?? 0), 0) / signals.length)
: 0,
timeHorizonDistribution: {
short: signals.filter(s => (s.timeHorizonMonths ?? 0) <= 6).length,
medium: signals.filter(s => { const m = s.timeHorizonMonths ?? 0; return m > 6 && m <= 12 }).length,
long: signals.filter(s => (s.timeHorizonMonths ?? 0) > 12).length,
},
}
},
}
@@ -0,0 +1,82 @@
import type { ListResponse, ItemResponse } from './types'
export type ActivityEventType =
| 'PROPERTY_CREATED'
| 'PROPERTY_UPDATED'
| 'MATCH_APPROVED'
| 'MATCH_REJECTED'
| 'SIGNAL_VERIFIED'
| 'NEED_CREATED'
| 'REVIEW_REQUESTED'
| 'AI_PARSE_COMPLETED'
| 'AI_OUTPUT_REVIEWED'
| 'MATCH_GENERATED'
| 'FUTURE_SIGNAL_DETECTED'
| 'FUTURE_SIGNAL_CONVERTED'
| 'SHORTLIST_CREATED'
| 'SHORTLIST_FINALIZED'
| 'DECISION_BRIEF_CREATED'
| 'SOURCE_CRAWLED'
| 'DATA_QUALITY_FLAGGED'
| 'REVIEW_COMPLETED'
export type ActivityCategory = 'SUCHE' | 'MATCHING' | 'INTELLIGENCE' | 'REVIEW' | 'GOVERNANCE'
export interface ActivityEvent {
id: string
type: ActivityEventType
category: ActivityCategory
entityId: string
entityType: 'PROPERTY' | 'MATCH' | 'NEED' | 'SIGNAL' | 'SHORTLIST' | 'AI_OUTPUT' | 'SOURCE'
performedBy: string
isAiAction: boolean
organizationId: string
notes?: string
createdAt: string
}
const mockActivityLog: ActivityEvent[] = [
// Day 1 — 2026-05-13
{ id: 'evt-001', type: 'SOURCE_CRAWLED', category: 'INTELLIGENCE', entityId: 'src-001', entityType: 'SOURCE', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Baubewilligung-Feed Kanton ZH: 47 neue Einträge gefunden', createdAt: '2026-05-13T06:15:00Z' },
{ id: 'evt-002', type: 'FUTURE_SIGNAL_DETECTED', category: 'INTELLIGENCE', entityId: 'sig-maag', entityType: 'SIGNAL', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Potenzielle Grossfläche: Maag Areal Zürich — Wahrscheinlichkeit 82%', createdAt: '2026-05-13T06:18:00Z' },
{ id: 'evt-003', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-007', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Grundriss fehlt; Qualitätsscore 61 — unter Schwellenwert', createdAt: '2026-05-13T07:30:00Z' },
{ id: 'evt-004', type: 'PROPERTY_CREATED', category: 'GOVERNANCE', entityId: 'prop-022', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Neues Industrieobjekt Schlieren erfasst', createdAt: '2026-05-13T09:45:00Z' },
// Day 2 — 2026-05-14
{ id: 'evt-005', type: 'SOURCE_CRAWLED', category: 'INTELLIGENCE', entityId: 'src-002', entityType: 'SOURCE', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Handelsregister-Crawler: 12 Unternehmensumzüge identifiziert', createdAt: '2026-05-14T06:00:00Z' },
{ id: 'evt-006', type: 'FUTURE_SIGNAL_DETECTED', category: 'INTELLIGENCE', entityId: 'sig-alstom', entityType: 'SIGNAL', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Alstom AG Standortanalyse — interne Dokumente verweisen auf Expansionspläne', createdAt: '2026-05-14T06:05:00Z' },
{ id: 'evt-007', type: 'PROPERTY_UPDATED', category: 'GOVERNANCE', entityId: 'prop-003', entityType: 'PROPERTY', performedBy: 'manager@wincasa.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Verfügbarkeit aktualisiert: sofort verfügbar', createdAt: '2026-05-14T10:20:00Z' },
{ id: 'evt-008', type: 'REVIEW_REQUESTED', category: 'REVIEW', entityId: 'ai-out-003', entityType: 'AI_OUTPUT', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Need-Parse-Output zur manuellen Prüfung eingereicht', createdAt: '2026-05-14T14:00:00Z' },
{ id: 'evt-009', type: 'AI_OUTPUT_REVIEWED', category: 'REVIEW', entityId: 'ai-out-003', entityType: 'AI_OUTPUT', performedBy: 'reviewer@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Output genehmigt — Kriterienextraktion korrekt', createdAt: '2026-05-14T15:30:00Z' },
// Day 3 — 2026-05-15
{ id: 'evt-010', type: 'NEED_CREATED', category: 'SUCHE', entityId: 'need-042', entityType: 'NEED', performedBy: 'demand@globocorp.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Natürlichsprachliche Suche: "2500m² Büro Zürich West, offen, Rep.'+ "'" + 'resentanz-qualität"', createdAt: '2026-05-15T09:10:00Z' },
{ id: 'evt-011', type: 'AI_PARSE_COMPLETED', category: 'SUCHE', entityId: 'need-042', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Konfidenz 87% — Kriterien: OFFICE 20003000m², Zürich West, CHF 280/m², Einzug Q3 2026', createdAt: '2026-05-15T09:10:04Z' },
{ id: 'evt-012', type: 'MATCH_GENERATED', category: 'MATCHING', entityId: 'need-042', entityType: 'NEED', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: '14 Kandidaten bewertet — 3 STARK (≥80), 6 MITTEL, 5 SCHWACH', createdAt: '2026-05-15T09:10:07Z' },
{ id: 'evt-013', type: 'SIGNAL_VERIFIED', category: 'INTELLIGENCE', entityId: 'sig-maag', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Maag Areal Signal verifiziert — Baubewilligung bestätigt', createdAt: '2026-05-15T10:00:00Z' },
{ id: 'evt-014', type: 'SHORTLIST_CREATED', category: 'SUCHE', entityId: 'sl-globo-01', entityType: 'SHORTLIST', performedBy: 'demand@globocorp.ch', isAiAction: false, organizationId: 'org-wincasa', notes: '4 Objekte auf Shortlist "Zürich West Q3 2026"', createdAt: '2026-05-15T11:45:00Z' },
{ id: 'evt-015', type: 'FUTURE_SIGNAL_CONVERTED', category: 'INTELLIGENCE', entityId: 'sig-maag', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Signal in zukünftige Verfügbarkeit umgewandelt — erscheint im Unified Feed', createdAt: '2026-05-15T13:00:00Z' },
// Day 4 — 2026-05-16
{ id: 'evt-016', type: 'MATCH_APPROVED', category: 'MATCHING', entityId: 'match-003', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 91 — Starker Match Hardturmstrasse 201 × GloboCorp bestätigt', createdAt: '2026-05-16T09:00:00Z' },
{ id: 'evt-017', type: 'REVIEW_REQUESTED', category: 'REVIEW', entityId: 'sig-alstom', entityType: 'SIGNAL', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Vertrauliches Signal zur Governance-Prüfung eingereicht', createdAt: '2026-05-16T10:15:00Z' },
{ id: 'evt-018', type: 'DECISION_BRIEF_CREATED', category: 'SUCHE', entityId: 'sl-globo-01', entityType: 'SHORTLIST', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'KI-Entscheidungsbriefing für Shortlist generiert — Empfehlung: Hardturmstrasse 201', createdAt: '2026-05-16T11:30:00Z' },
{ id: 'evt-019', type: 'REVIEW_COMPLETED', category: 'REVIEW', entityId: 'sig-alstom', entityType: 'SIGNAL', performedBy: 'reviewer@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'GENEHMIGT — Vertraulichkeitsstufe bestätigt, Signal für Demand-Pipeline freigegeben', createdAt: '2026-05-16T14:00:00Z' },
{ id: 'evt-020', type: 'SHORTLIST_FINALIZED', category: 'SUCHE', entityId: 'sl-globo-01', entityType: 'SHORTLIST', performedBy: 'demand@globocorp.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Shortlist finalisiert — Kundenentscheid: Objekt 1 und 3 zur Besichtigung', createdAt: '2026-05-16T16:45:00Z' },
// Day 5 — 2026-05-17 (heute)
{ id: 'evt-021', type: 'SOURCE_CRAWLED', category: 'INTELLIGENCE', entityId: 'src-003', entityType: 'SOURCE', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'LinkedIn-Jobpostings-Crawler: 8 Expansionssignale gefunden', createdAt: '2026-05-17T06:00:00Z' },
{ id: 'evt-022', type: 'FUTURE_SIGNAL_DETECTED', category: 'INTELLIGENCE', entityId: 'sig-novartis', entityType: 'SIGNAL', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Novartis Basel: 47 Stelleninserate für "Basel Life Sciences Hub" — Flächensignal', createdAt: '2026-05-17T06:12:00Z' },
{ id: 'evt-023', type: 'MATCH_REJECTED', category: 'MATCHING', entityId: 'match-009', entityType: 'MATCH', performedBy: 'admin@ideal-sharing.ch', isAiAction: false, organizationId: 'org-wincasa', notes: 'Score 64 — zu schwach, manuell abgelehnt', createdAt: '2026-05-17T08:30:00Z' },
{ id: 'evt-024', type: 'DATA_QUALITY_FLAGGED', category: 'GOVERNANCE', entityId: 'prop-015', entityType: 'PROPERTY', performedBy: 'system@property-match', isAiAction: true, organizationId: 'org-wincasa', notes: 'Preisangabe 18 Monate alt — automatische Qualitätswarnung', createdAt: '2026-05-17T09:00:00Z' },
]
const store = [...mockActivityLog]
export const governanceService = {
async getActivityLog(organizationId?: string): Promise<ListResponse<ActivityEvent>> {
const data = organizationId ? store.filter(e => e.organizationId === organizationId) : [...store]
return { data: data.sort((a, b) => b.createdAt.localeCompare(a.createdAt)), meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async logEvent(event: Omit<ActivityEvent, 'id' | 'createdAt'>): Promise<ItemResponse<ActivityEvent>> {
const data: ActivityEvent = { id: crypto.randomUUID(), ...event, createdAt: new Date().toISOString() }
store.push(data)
return { data }
},
}
@@ -0,0 +1,41 @@
import { MockupMarketIntelligenceProvider } from '../provider/MockupMarketIntelligenceProvider'
import type { MarketSignal, MarketSignalFilters, SignalProcessingStatus } from '../domain/marketSignal'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupMarketIntelligenceProvider
export const marketIntelligenceService = {
async getSignals(filters?: MarketSignalFilters): Promise<ListResponse<MarketSignal>> {
const data = await provider.getSignals(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getSignalDetail(id: string): Promise<ItemResponse<MarketSignal | null>> {
const data = await provider.getSignalById(id)
return { data }
},
async updateSignalStatus(
id: string,
status: SignalProcessingStatus,
): Promise<ItemResponse<MarketSignal>> {
const data = await provider.updateSignalStatus(id, status)
return { data }
},
async convertToFutureSignal(
id: string,
): Promise<ItemResponse<{ futureSignalId: string }>> {
const data = await provider.convertToFutureSignal(id)
return { data }
},
async linkSignalToEntity(
id: string,
entityType: 'property' | 'need',
entityId: string,
): Promise<ItemResponse<MarketSignal>> {
const data = await provider.linkSignalToEntity(id, entityType, entityId)
return { data }
},
}
@@ -0,0 +1,95 @@
import { MockupMatchProvider } from '../provider/MockupMatchProvider'
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
import type { MatchFilters } from '../provider/IMatchProvider'
import type { Match } from '../domain/match'
import type { Need } from '../domain/need'
import type { Property } from '../domain/property'
import type { StrongMatchItem } from '../domain/dashboard'
import type { ScoreBreakdown } from '../domain/match'
import type { ListResponse, ItemResponse } from './types'
import { buildFullMatch, computeRankedMatches } from '../features/matching/rankingEngine'
const provider = MockupMatchProvider
export const matchService = {
async getAll(filters?: MatchFilters): Promise<ListResponse<Match>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Match | null>> {
const data = await provider.getById(id)
return { data }
},
async getByNeed(needId: string): Promise<ListResponse<Match>> {
const data = await provider.getByNeed(needId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getByProperty(propertyId: string): Promise<ListResponse<Match>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async approve(id: string, reviewedBy: string): Promise<ItemResponse<Match>> {
const data = await provider.approve(id, reviewedBy)
return { data }
},
async getMatchesForProperty(propertyId: string): Promise<ListResponse<Match>> {
const data = await provider.getByProperty(propertyId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getMatchDetail(id: string): Promise<ItemResponse<Match | null>> {
const data = await provider.getById(id)
return { data }
},
async getScoreBreakdown(matchId: string): Promise<ItemResponse<ScoreBreakdown | null>> {
const match = await provider.getById(matchId)
return { data: match?.scoreBreakdown ?? null }
},
// ── Engine-based methods ──────────────────────────────────────────────────
computeMatch(need: Need, property: Property): Match {
return buildFullMatch(need, property)
},
async computeMatchesForNeed(needId: string): Promise<ListResponse<Match>> {
const [need, properties] = await Promise.all([
MockupNeedProvider.getById(needId),
MockupPropertyProvider.getAll(),
])
if (!need) return { data: [], meta: { total: 0, page: 1, pageSize: 0, hasMore: false } }
const data = computeRankedMatches(need, properties)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getStrongMatches(minScore = 80): Promise<StrongMatchItem[]> {
const [matches, properties] = await Promise.all([
provider.getAll(),
MockupPropertyProvider.getAll(),
])
return matches
.filter(m => m.matchScore >= minScore)
.slice(0, 5)
.map(m => {
const prop = properties.find(p => p.id === m.propertyId)
const topFactor = m.positiveFactors?.[0]
const firstAction = m.nextBestActions?.[0]
return {
matchId: m.id,
propertyId: m.propertyId,
propertyTitle: prop?.title ?? 'Unbekanntes Objekt',
propertyAddress: prop?.address
? `${prop.address.street} ${prop.address.houseNumber}, ${prop.address.city}`
: '',
needSummary: m.needId,
matchScore: m.matchScore,
topReason: topFactor?.explanation ?? topFactor?.criterion ?? '',
missingDataCount: m.missingData?.length ?? 0,
nextBestAction: firstAction?.label ?? '',
} satisfies StrongMatchItem
})
},
}
@@ -0,0 +1,29 @@
import { MockupNeedProvider } from '../provider/MockupNeedProvider'
import type { NeedFilters } from '../provider/INeedProvider'
import type { Need, CreateNeedInput, UpdateNeedInput } from '../domain/need'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupNeedProvider
export const needService = {
async getAll(filters?: NeedFilters): Promise<ListResponse<Need>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Need | null>> {
const data = await provider.getById(id)
return { data }
},
async create(input: CreateNeedInput): Promise<ItemResponse<Need>> {
const data = await provider.create(input)
return { data }
},
async update(id: string, input: UpdateNeedInput): Promise<ItemResponse<Need>> {
const data = await provider.update(id, input)
return { data }
},
async remove(id: string): Promise<ItemResponse<void>> {
await provider.remove(id)
return { data: undefined }
},
}
@@ -0,0 +1,44 @@
import { MockupPropertyProvider } from '../provider/MockupPropertyProvider'
import type { PropertyFilters } from '../provider/IPropertyProvider'
import type { CreatePropertyInput, UpdatePropertyInput } from '../domain/property'
import type { ListResponse, ItemResponse } from './types'
import type { Property } from '../domain/property'
const provider = MockupPropertyProvider
const ACTIVE_STATUSES: readonly string[] = ['AVAILABLE_NOW', 'AVAILABLE_SOON']
export const propertyService = {
async getAll(filters?: PropertyFilters): Promise<ListResponse<Property>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Property | null>> {
const data = await provider.getById(id)
return { data }
},
async create(input: CreatePropertyInput): Promise<ItemResponse<Property>> {
const data = await provider.create(input)
return { data }
},
async update(id: string, input: UpdatePropertyInput): Promise<ItemResponse<Property>> {
const data = await provider.update(id, input)
return { data }
},
async remove(id: string): Promise<ItemResponse<void>> {
await provider.remove(id)
return { data: undefined }
},
async getDashboardPropertiesSummary(): Promise<{ total: number; active: number }> {
const data = await provider.getAll()
return {
total: data.length,
active: data.filter(p => ACTIVE_STATUSES.includes(p.availabilityStatus)).length,
}
},
async getProperties(filters?: PropertyFilters): Promise<ListResponse<Property>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
}
@@ -0,0 +1,71 @@
import { MockupReviewProvider } from '../provider/MockupReviewProvider'
import type { ReviewFilters } from '../provider/IReviewProvider'
import type { ReviewTask, ReviewTaskStatus, ReviewNote } from '../domain/review'
import type { DashboardReviewTask } from '../domain/dashboard'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupReviewProvider
export const reviewService = {
async getQueue(filters?: ReviewFilters): Promise<ListResponse<ReviewTask>> {
const data = await provider.getQueue(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getTasks(filters?: ReviewFilters): Promise<ListResponse<ReviewTask>> {
return this.getQueue(filters)
},
async getById(id: string): Promise<ItemResponse<ReviewTask | null>> {
const data = await provider.getById(id)
return { data }
},
async getTask(id: string): Promise<ItemResponse<ReviewTask | null>> {
return this.getById(id)
},
async updateStatus(id: string, status: ReviewTaskStatus, userId: string, note?: string): Promise<ItemResponse<ReviewTask>> {
const data = await provider.updateStatus(id, status, userId, note)
return { data }
},
async addNote(id: string, note: Omit<ReviewNote, 'id'>): Promise<ItemResponse<ReviewTask>> {
const data = await provider.addNote(id, note)
return { data }
},
async approve(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewTask>> {
const data = await provider.approve(id, reviewedBy, notes)
return { data }
},
async reject(id: string, reviewedBy: string, notes?: string): Promise<ItemResponse<ReviewTask>> {
const data = await provider.reject(id, reviewedBy, notes)
return { data }
},
async assign(id: string, assignTo: string): Promise<ItemResponse<ReviewTask>> {
const data = await provider.assign(id, assignTo)
return { data }
},
async createReviewTask(signalId: string): Promise<ItemResponse<{ taskId: string }>> {
const taskId = `rt-${signalId}-${Date.now()}`
return { data: { taskId } }
},
async getDashboardTasks(): Promise<DashboardReviewTask[]> {
const items = await provider.getQueue()
return items
.filter(r => r.status === 'PENDING' || r.status === 'IN_REVIEW' || r.status === 'ESCALATED')
.slice(0, 8)
.map(r => ({
id: r.id,
title: r.title,
priority: r.priority as 'HIGH' | 'MEDIUM' | 'LOW',
status: r.status,
type: r.entityType,
}))
},
}
@@ -0,0 +1,37 @@
import { MockupShortlistProvider } from '../provider/MockupShortlistProvider'
import type { ShortlistFilters } from '../provider/IShortlistProvider'
import type { Shortlist, CreateShortlistInput, UpdateShortlistInput, ShortlistItemInput } from '../domain/shortlist'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupShortlistProvider
export const shortlistService = {
async getAll(filters?: ShortlistFilters): Promise<ListResponse<Shortlist>> {
const data = await provider.getAll(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getById(id: string): Promise<ItemResponse<Shortlist | null>> {
const data = await provider.getById(id)
return { data }
},
async create(input: CreateShortlistInput): Promise<ItemResponse<Shortlist>> {
const data = await provider.create(input)
return { data }
},
async update(id: string, input: UpdateShortlistInput): Promise<ItemResponse<Shortlist>> {
const data = await provider.update(id, input)
return { data }
},
async addItem(id: string, item: ShortlistItemInput): Promise<ItemResponse<Shortlist>> {
const data = await provider.addItem(id, item)
return { data }
},
async removeItem(id: string, resultId: string): Promise<ItemResponse<Shortlist>> {
const data = await provider.removeItem(id, resultId)
return { data }
},
async remove(id: string): Promise<ItemResponse<void>> {
await provider.remove(id)
return { data: undefined }
},
}
@@ -0,0 +1,24 @@
import { MockupSignalPipelineProvider } from '../provider/MockupSignalPipelineProvider'
import type { PipelineState, AuditTrailEntry, GateType } from '../domain/signalPipeline'
import type { ItemResponse, ListResponse } from './types'
const provider = MockupSignalPipelineProvider
export const signalPipelineService = {
async getPipelineState(signalId: string): Promise<ItemResponse<PipelineState | null>> {
const data = await provider.getPipelineState(signalId)
return { data }
},
async evaluateGate(signalId: string, gateType: GateType): Promise<ItemResponse<PipelineState>> {
const data = await provider.evaluateGate(signalId, gateType)
return { data }
},
async getAuditTrail(signalId: string): Promise<ListResponse<AuditTrailEntry>> {
const data = await provider.getAuditTrail(signalId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async publishToFutureAvailability(signalId: string): Promise<ItemResponse<PipelineState>> {
const data = await provider.publishToFutureAvailability(signalId)
return { data }
},
}
@@ -0,0 +1,37 @@
import { MockupDataSourceProvider } from '../provider/MockupDataSourceProvider'
import type { DataSource, ConnectorRun, SourceStatus, TermsStatus, SourceFilters } from '../domain/dataSource'
import type { ListResponse, ItemResponse } from './types'
const provider = MockupDataSourceProvider
export const sourceService = {
async getSources(filters?: SourceFilters): Promise<ListResponse<DataSource>> {
const data = await provider.getSources(filters)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async getSource(id: string): Promise<ItemResponse<DataSource | null>> {
const data = await provider.getSource(id)
return { data }
},
async getConnectorRuns(sourceId: string): Promise<ListResponse<ConnectorRun>> {
const data = await provider.getConnectorRuns(sourceId)
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
},
async triggerMockRun(sourceId: string): Promise<ItemResponse<ConnectorRun>> {
const data = await provider.triggerMockRun(sourceId)
return { data }
},
async updateSourceStatus(id: string, status: SourceStatus): Promise<ItemResponse<DataSource>> {
const data = await provider.updateSourceStatus(id, status)
return { data }
},
async markTermsStatus(id: string, termsStatus: TermsStatus): Promise<ItemResponse<DataSource>> {
const data = await provider.markTermsStatus(id, termsStatus)
return { data }
},
}
@@ -0,0 +1,42 @@
// ── Error Codes ───────────────────────────────────────────────────────────────
export const ServiceErrorCode = {
NETWORK_ERROR: 'network_error',
UNAUTHORIZED: 'unauthorized',
FORBIDDEN: 'forbidden',
VALIDATION_ERROR: 'validation_error',
NOT_FOUND: 'not_found',
AI_GENERATION_FAILED: 'ai_generation_failed',
BACKEND_UNAVAILABLE: 'backend_unavailable',
} as const
export type ServiceErrorCode = typeof ServiceErrorCode[keyof typeof ServiceErrorCode]
export interface ServiceError {
code: ServiceErrorCode
message: string
details?: unknown
}
// ── Pagination ────────────────────────────────────────────────────────────────
export interface Pagination {
total: number
page: number
pageSize: number
hasMore: boolean
}
/** @deprecated Use Pagination */
export type ServiceMeta = Pagination
// ── Response Shapes ───────────────────────────────────────────────────────────
export interface ServiceResponse<T> {
data: T
meta?: Pagination
pagination?: Pagination
error?: ServiceError | string | null
}
export type ListResponse<T> = ServiceResponse<T[]>
export type ItemResponse<T> = ServiceResponse<T>
@@ -0,0 +1,32 @@
import type { WeightingKey } from '../domain/needBuilder'
type WeightProfile = Record<WeightingKey, number>
const PROFILES: Record<string, WeightProfile> = {
OFFICE: {
area: 0.20, location: 0.25, budget: 0.20, timing: 0.15,
prestige: 0.10, accessibility: 0.05, expansionPotential: 0.03, flexibility: 0.02,
},
LOGISTICS: {
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.02, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
},
RETAIL: {
area: 0.15, location: 0.30, budget: 0.20, timing: 0.10,
prestige: 0.12, accessibility: 0.08, expansionPotential: 0.03, flexibility: 0.02,
},
PRODUCTION: {
area: 0.30, location: 0.20, budget: 0.20, timing: 0.15,
prestige: 0.02, accessibility: 0.07, expansionPotential: 0.04, flexibility: 0.02,
},
DEFAULT: {
area: 0.25, location: 0.25, budget: 0.20, timing: 0.15,
prestige: 0.07, accessibility: 0.05, expansionPotential: 0.02, flexibility: 0.01,
},
}
export const weightingService = {
getDefaultWeights(assetType?: string): WeightProfile {
return { ...(PROFILES[assetType ?? 'DEFAULT'] ?? PROFILES.DEFAULT) }
},
}