feat(livia): Runde 10 Prompt 2 — eine Leadliste, dynamische Quellen, zeitliche Relevanz

Livia arbeitet nicht mehr neben dem Leadbestand, sondern in ihn hinein.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-09-12 23:42:59 +02:00
1 parent 1c4b3f7dd8
commit 30daa77937
33 files changed
+2275 -301

No files matched your search

+103 -10
View File
@@ -1,10 +1,10 @@
import { useMemo } from 'react'
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, MapPin, Ruler } from 'lucide-react'
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, MapPin, Ruler, Target } from 'lucide-react'
import type { MarketLead } from '../../hooks/useMarketLeads'
import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
import { SOURCE_TYPE_LABELS, TEMPORAL_CLASS_LABELS } from '../../lib/constants'
import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
@@ -31,6 +31,12 @@ interface LeadDetailProps {
lead: MarketLead
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
showMatching?: boolean
/**
* Übergabe an Nora (Runde 10, §2.14). Nur gesetzt, wo der Knopf hingehört —
* auf Noras eigener Seite wäre «Matching starten» hier ein zweiter Weg zu
* derselben Handlung, die dort schon im Kopf des Matching-Bereichs steht.
*/
onStartMatching?: (signalId: string) => void
}
/**
@@ -39,11 +45,22 @@ interface LeadDetailProps {
* Ein neues Signal ist damit eine neue Komponente und startet mit frischem
* Zustand, statt ihn in einem Effekt zurücksetzen zu müssen.
*/
function LeadDetail({ lead, showMatching = true }: LeadDetailProps) {
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} />
function LeadDetail({ lead, showMatching = true, onStartMatching }: LeadDetailProps) {
return (
<LeadDetailBody
key={lead.signal.id}
lead={lead}
showMatching={showMatching}
onStartMatching={onStartMatching}
/>
)
}
function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching: boolean }) {
function LeadDetailBody({ lead, showMatching, onStartMatching }: {
lead: MarketLead
showMatching: boolean
onStartMatching?: (signalId: string) => void
}) {
const { signal, matchingProperties } = lead
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
const companyName = signal.companyName ?? signal.locationHint
@@ -133,19 +150,95 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
</Typography>
</Box>
{/* Bei Risiko: das betroffene Mietverhältnis benennen (§2) */}
{risiko && (signal.affectedTenant || signal.propertyId) && (
{/*
Bei Risiko: das betroffene Mietverhältnis benennen (§2, Runde 10 §2.13A).
Seit Runde 10 steht der Kasten auch dann, wenn nichts zuordenbar ist —
mit genau diesem Satz. Ein fehlender Kasten liesse offen, ob niemand
nachgesehen hat oder ob nichts betroffen ist; eine beliebige
Mock-Immobilie zu zeigen wäre die schlechtere Antwort von beiden.
*/}
{risiko && (
<Box sx={{ border: '1px solid', borderColor: DS_COLORS.leadSignal.RISIKO.fg, bgcolor: DS_COLORS.leadSignal.RISIKO.bg, borderRadius: 1.5, p: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.leadSignal.RISIKO.fg, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Betroffenes Mietverhältnis
Betroffenes Objekt
</Typography>
{signal.affectedTenant && (
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
{signal.affectedTenant}
</Typography>
)}
{signal.propertyId && (
{signal.propertyId ? (
<ObjectDeepLink propertyId={signal.propertyId} label="Objekt in «Meine Objekte» öffnen" />
) : (
!signal.affectedTenant && (
<Typography variant="body2" sx={{ color: DS_SLATE[800] }}>
Kein internes Objekt eindeutig zugeordnet.
</Typography>
)
)}
</Box>
)}
{/* Zeitliche Einordnung mit Fundstelle (Runde 10, §2.9) */}
{signal.temporalClass && (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Zeitliche Relevanz
</Typography>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={TEMPORAL_CLASS_LABELS[signal.temporalClass]}
size="small"
sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[700], fontSize: '0.75rem', fontWeight: 600 }}
/>
{signal.eventDate && (
<Typography variant="caption" sx={{ color: DS_SLATE[600] }}>
Ereigniszeit laut Quelle: {signal.eventDate}
</Typography>
)}
</Box>
{signal.temporalEvidence && (
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mt: 0.75 }}>
{signal.temporalEvidence}
</Typography>
)}
</Box>
)}
{/*
Semantisches Suchprofil (Runde 10, §§2.13B/2.14).
Es steht nur bei Chance-Leads, weil es einen Flächenbedarf beschreibt —
bei einem Risiko gibt es keinen Nachfrager, für den man suchen könnte.
Der Text ist derselbe, den Nora abgleicht; erzeugt wird er einmal, von
Livia. Ein zweiter Text an dieser Stelle wären zwei Aussagen über
denselben Bedarf.
*/}
{!risiko && signal.semanticSearchProfile && (
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
Semantisches Suchprofil
</Typography>
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.65, whiteSpace: 'pre-line' }}>
{signal.semanticSearchProfile}
</Typography>
{/*
«Matching starten» übergibt denselben Lead an Nora — über die
Lead-ID, nicht über eine Kopie. Das Kennzeichen im Zustand sagt
Nora, dass der Lauf sofort starten darf; wer den Lead später
normal öffnet, löst nichts von selbst aus (§2.14).
*/}
{onStartMatching && (
<Button
variant="contained"
size="small"
startIcon={<Target size={14} aria-hidden />}
onClick={() => onStartMatching(signal.id)}
sx={{ textTransform: 'none', fontWeight: 600, mt: 1.5 }}
>
Matching starten
</Button>
)}
</Box>
)}