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>
)}
+89 -25
View File
@@ -1,30 +1,45 @@
import { useCallback, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
import { RefreshCw } from 'lucide-react'
import { LeadDetail, UnifiedLeadList } from '../market-leads'
import { ResearchSourcesPanel } from './ResearchSourcesPanel'
import { ResearchLeadCard } from './ResearchLeadCard'
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
import { useResearchDocuments } from '../../hooks/useResearchDocuments'
import { useTeamAgent } from '../../hooks/useTeamAgents'
import { leseQuellenAus } from '../../lib/agentSystems'
import { LeadChannel } from '../../domain/unifiedLead'
import type { UnifiedLead } from '../../domain/unifiedLead'
import { LeadSignalType } from '../../domain/futureSignal'
import { TemporalClass } from '../../domain/researchLead'
import type { TemporalClass as TemporalClassValue } from '../../domain/researchLead'
import { ROUTES } from '../../lib/constants'
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
/** Kennung der Einstellung, die die erlaubten Ereigniszeiten führt (§2.9). */
const TEMPORAL_SETTING_ID = 'livia-set-temporal-relevance'
const GUELTIGE_KLASSEN = new Set<string>(Object.values(TemporalClass))
/**
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15).
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15; Runde 10, §2.12).
*
* Zwei Bestände nebeneinander, bewusst getrennt gehalten:
* Seit Runde 10 gibt es hier **eine** Leadliste. Bis dahin standen die frisch
* gelesenen Leads als eigene Karten über dem erfassten Bestand — technisch
* sauber getrennt, in der Benutzung aber unbrauchbar: Dieselbe Firma sah oben
* anders aus als unten, die Filter griffen nur auf der einen Hälfte, und Nora
* bekam die neuen Leads gar nicht zu sehen.
*
* 1. **Live-Recherche** — was Livia beim Knopfdruck aus den drei realen
* Quellen gelesen und beurteilt hat. Diese Leads sind nicht gespeichert;
* sie leben im Zustand dieser Komponente, bis der nächste Lauf sie ersetzt.
* 2. **Erfasste Leads** — der bestehende Signalbestand, den Nora abgleicht.
* Jetzt wandert ein Lauf direkt in den zentralen Signalbestand. Was oben steht,
* ist nur noch der Bericht über den Lauf selbst: welche Quellen antworteten,
* wie viel gelesen und wie viel verworfen wurde. Die Leads stehen dort, wo alle
* Leads stehen.
*
* Die Trennung ist Absicht: ein live gelesener Artikel ist etwas anderes als
* ein redaktionell erfasster Lead, und beides in eine Liste zu mischen würde
* genau diesen Unterschied verwischen. Die Liste unten ist dieselbe Komponente
* wie bei Nora — mit `showMatching={false}`, weil das Matching ihre Arbeit ist.
* Welche Quellen abgerufen werden, entscheidet nicht diese Komponente, sondern
* Livias Personalblatt: jeder aktive Lesezugang mit Adresse, dazu die manuell
* abgelegten Dokumente. Damit ist die Anzeige unter «Systeme» keine Behauptung,
* sondern die Eingabe des Laufs.
*/
export function ResearchWorkspace() {
const theme = useTheme()
@@ -32,6 +47,8 @@ export function ResearchWorkspace() {
const [selected, setSelected] = useState<UnifiedLead | null>(null)
const { data: leads, isLoading } = useUnifiedLeads()
const { data: livia } = useTeamAgent('livia')
const { data: dokumente = [] } = useResearchDocuments()
const refresh = useLiviaResearch()
const ergebnis = refresh.data
@@ -49,18 +66,53 @@ export function ResearchWorkspace() {
return { research: rows, chancen: c, risiken: r }
}, [leads])
/** Die Lesequellen kommen aus dem gespeicherten Stand, nicht aus einer festen Liste. */
const quellen = useMemo(() => leseQuellenAus(livia?.systems ?? []), [livia?.systems])
/** Die erlaubten Ereigniszeiten stehen als Einstellung am Personalblatt. */
const zeitfenster = useMemo((): TemporalClassValue[] => {
const wert = livia?.settings.find(s => s.id === TEMPORAL_SETTING_ID)?.value
if (!Array.isArray(wert)) return []
return wert.filter((v): v is TemporalClassValue => GUELTIGE_KLASSEN.has(v))
}, [livia?.settings])
const navigate = useNavigate()
/**
* Übergabe an Nora (Runde 10, §2.14).
*
* Weitergereicht wird nur die Lead-ID — Nora liest denselben Eintrag aus
* demselben Bestand, es entsteht keine Kopie. Das Kennzeichen `matching=1`
* sagt ihr, dass der Lauf sofort starten darf; ohne dieses Kennzeichen läuft
* beim normalen Öffnen nichts von selbst.
*
* Beides steht in der Adresse und nicht im Navigationszustand: So überlebt
* die Übergabe ein Neuladen, und der Link lässt sich weitergeben.
*/
const handleStartMatching = useCallback(
(signalId: string) => {
navigate(`${ROUTES.SUPPLY.AGENT_NORA}?lead=${encodeURIComponent(signalId)}&matching=1`)
},
[navigate],
)
const closeDetail = useCallback(() => setSelected(null), [])
const starten = useCallback(() => refresh.mutate(), [refresh])
const starten = useCallback(
() => refresh.mutate({ sources: quellen, documents: dokumente, temporalClasses: zeitfenster }),
[refresh, quellen, dokumente, zeitfenster],
)
const keineQuellen = quellen.length === 0 && dokumente.length === 0
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── Live-Recherche ──────────────────────────────────────────────── */}
{/* ── Der Lauf ────────────────────────────────────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="contained"
startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
disabled={refresh.isPending}
disabled={refresh.isPending || keineQuellen}
onClick={starten}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
@@ -73,18 +125,35 @@ export function ResearchWorkspace() {
</Typography>
)}
{!refresh.isPending && !ergebnis && !keineQuellen && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`}
{dokumente.length > 0 &&
` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`}
</Typography>
)}
{!refresh.isPending && ergebnis && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
{`${ergebnis.discarded} nicht relevant · `}
{`${ergebnis.watchlist} Watchlist · `}
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'relevante Entwicklung' : 'relevante Entwicklungen'}`}
{ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `}
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead übernommen' : 'Leads übernommen'}`}
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
{ergebnis.model && ` · Modell ${ergebnis.model}`}
</Typography>
)}
</Box>
{keineQuellen && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt.
Quellen werden im Personalblatt unter «Kanäle &amp; Systeme» angebunden, Dokumente unter
«Einstellungen».
</Alert>
)}
<ResearchSourcesPanel results={ergebnis?.sources} />
{ergebnis?.analysisError && (
@@ -93,23 +162,17 @@ export function ResearchWorkspace() {
</Alert>
)}
{ergebnis && ergebnis.leads.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{ergebnis.leads.map(lead => (
<ResearchLeadCard key={`${lead.sourceUrl}-${lead.headline}`} lead={lead} />
))}
</Box>
)}
{ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug.
Gelesen wurden {ergebnis.analyzed} Einträge.
{ergebnis.temporalFiltered > 0 &&
` ${ergebnis.temporalFiltered} davon fielen durch die eingestellte zeitliche Relevanz.`}
</Alert>
)}
</Box>
{/* ── Erfasste Leads ──────────────────────────────────────────────── */}
{/* ── Die eine Leadliste ──────────────────────────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1, borderTop: `1px solid ${DS_SLATE[200]}` }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{isLoading
@@ -124,7 +187,7 @@ export function ResearchWorkspace() {
selectedId={selected?.id ?? null}
onSelect={setSelected}
showChannel={false}
emptyText="Noch keine erfassten Leads. Was Livia gerade live gelesen hat, steht oberhalb."
emptyText="Noch keine Leads. «Research aktualisieren» liest die angebundenen Quellen und legt gefundene Leads hier ab."
/>
</Box>
@@ -145,6 +208,7 @@ export function ResearchWorkspace() {
<LeadDetail
lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }}
showMatching={false}
onStartMatching={handleStartMatching}
/>
)}
</Drawer>
+123
View File
@@ -0,0 +1,123 @@
/**
* Property On — Mehrfachauswahl als entfernbare Chips (Runde 10, §§2.7–2.9).
*
* Eine Komponente für alle drei Stellen — Quellentypen, Beobachtungsraum und
* zeitliche Relevanz. Sie unterscheiden sich nur darin, ob eigene Werte erlaubt
* sind; die Bedienung ist überall dieselbe, und das ist der Punkt: Wer gelernt
* hat, wie man einen Quellentyp entfernt, weiss es auch beim Beobachtungsraum.
*
* Der eine Unterschied zum bisherigen Auswahlfeld: Jeder gewählte Wert lässt
* sich einzeln wegklicken — auch die, die von Anfang an gesetzt waren. Vorher
* konnte man Vorgaben nur über das Auswahlmenü loswerden, was niemand fand.
*/
import { memo, useCallback, useState } from 'react'
import { Box, Chip, MenuItem, TextField, Typography } from '@mui/material'
import { Plus } from 'lucide-react'
import type { AgentSettingOption } from '../../domain/teamAgent'
import { DS_TEXT } from '../../lib/ds'
interface Props {
value: string[]
options: AgentSettingOption[]
/** Freitext erlauben — bei fachlich abgeschlossenen Listen aus. */
allowCustom: boolean
disabled?: boolean
ariaLabel: string
onChange: (next: string[]) => void
}
/** Ein selbst erfasster Wert hat keine Option — dann ist er selbst sein Etikett. */
function etikett(value: string, options: AgentSettingOption[]): string {
return options.find(o => o.value === value)?.label ?? value
}
export const AgentChipsField = memo(function AgentChipsField({
value, options, allowCustom, disabled, ariaLabel, onChange,
}: Props) {
const [eingabe, setEingabe] = useState('')
const offeneOptionen = options.filter(o => !value.includes(o.value))
const entfernen = useCallback(
(v: string) => onChange(value.filter(e => e !== v)),
[value, onChange],
)
const hinzufuegen = useCallback(
(v: string) => {
const sauber = v.trim()
// Leeres nicht speichern, Doppeltes nicht anlegen — beides würde die
// Liste nur verlängern, ohne etwas auszusagen.
if (!sauber || value.includes(sauber)) return
onChange([...value, sauber])
},
[value, onChange],
)
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }} role="list" aria-label={ariaLabel}>
{value.length === 0 && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted, py: 0.5 }}>
Nichts ausgewählt — damit gilt keine Einschränkung.
</Typography>
)}
{value.map(v => (
<Chip
key={v}
role="listitem"
size="small"
label={etikett(v, options)}
onDelete={disabled ? undefined : () => entfernen(v)}
disabled={disabled}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{offeneOptionen.length > 0 && (
<TextField
select
size="small"
disabled={disabled}
value=""
label="Auswählen"
sx={{ minWidth: 220, flex: 1 }}
onChange={e => hinzufuegen(e.target.value)}
>
{offeneOptionen.map(o => (
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
{o.label}
</MenuItem>
))}
</TextField>
)}
{allowCustom && (
<TextField
size="small"
disabled={disabled}
value={eingabe}
label="Eigener Eintrag"
placeholder="Tippen und Enter"
sx={{ minWidth: 220, flex: 1 }}
onChange={e => setEingabe(e.target.value)}
onKeyDown={e => {
if (e.key !== 'Enter') return
// Sonst schickt Enter das umgebende Formular ab.
e.preventDefault()
hinzufuegen(eingabe)
setEingabe('')
}}
slotProps={{
input: {
endAdornment: <Plus size={14} color={DS_TEXT.muted} aria-hidden />,
},
}}
/>
)}
</Box>
</Box>
)
})
+34 -3
View File
@@ -25,6 +25,27 @@ export interface EntityField {
export type EntityDraft = Record<string, string>
/**
* Eine Adresse wird als Quelle tatsächlich abgerufen — sie muss deshalb an der
* Maske scheitern und nicht erst im Recherchelauf. Geprüft wird nur das
* Schema: ob die Seite antwortet, kann erst der Abruf sagen, und ein Formular,
* das Erreichbarkeit behauptet, wäre eine Behauptung zu viel.
*/
function urlFehler(wert: string): string | null {
const text = wert.trim()
if (text === '') return null
let parsed: URL
try {
parsed = new URL(text)
} catch {
return 'Bitte eine vollständige Adresse angeben, z. B. https://www.beispiel.ch/news.'
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return 'Nur http- und https-Adressen können gelesen werden.'
}
return null
}
interface Props {
open: boolean
/**
@@ -85,9 +106,17 @@ function DialogBody({
const fehlend = fields.filter(f => f.required && !(draft[f.key] ?? '').trim()).map(f => f.key)
// Formfehler je Feld, unabhängig davon, ob das Feld leer geblieben ist.
const formfehler: Record<string, string> = {}
for (const f of fields) {
if (f.type !== 'url') continue
const meldung = urlFehler(draft[f.key] ?? '')
if (meldung) formfehler[f.key] = meldung
}
function handleSave() {
setTouched(true)
if (fehlend.length > 0) return
if (fehlend.length > 0 || Object.keys(formfehler).length > 0) return
onSave(draft, active)
}
@@ -107,11 +136,13 @@ function DialogBody({
onChange={e => setDraft(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={f.placeholder}
required={f.required}
error={touched && fehlend.includes(f.key)}
error={touched && (fehlend.includes(f.key) || f.key in formfehler)}
helperText={
touched && fehlend.includes(f.key)
? 'Dieses Feld wird gebraucht.'
: f.helperText
: touched && formfehler[f.key]
? formfehler[f.key]
: f.helperText
}
size="small"
fullWidth
+68 -3
View File
@@ -11,14 +11,16 @@
import { memo, useCallback, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
// prettier-ignore
import { Box, Button, Chip, FormControlLabel, FormHelperText, InputAdornment, MenuItem, Select, Switch, TextField, Typography } from '@mui/material'
import { AlertTriangle, Lock } from 'lucide-react'
import { Box, Button, Chip, FormControlLabel, FormHelperText, IconButton, InputAdornment, MenuItem, Popover, Select, Switch, TextField, Typography } from '@mui/material'
import { AlertTriangle, Info, Lock } from 'lucide-react'
import { z } from 'zod'
import type { AgentSetting, TeamAgent } from '../../domain/teamAgent'
import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent'
import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { useSaveAgentSettings } from '../../hooks/useTeamAgents'
import { AgentChipsField } from './AgentChipsField'
import { ResearchDocumentsField } from './ResearchDocumentsField'
type SettingValue = AgentSetting['value']
@@ -53,6 +55,14 @@ function buildSettingsSchema(settings: AgentSetting[]) {
shape[setting.id] = field
} else if (setting.kind === AgentSettingKind.MULTI_SELECT) {
shape[setting.id] = z.array(z.string()).min(1, 'Mindestens ein Eintrag ist erforderlich.')
} else if (setting.kind === AgentSettingKind.CHIPS) {
// Bewusst ohne Mindestzahl: Eine leere Auswahl bedeutet «keine
// Einschränkung» und ist damit eine gültige Aussage, kein Fehler.
shape[setting.id] = z.array(z.string())
} else if (setting.kind === AgentSettingKind.DOCUMENTS) {
// Die Dokumente liegen in IndexedDB und nicht im Entwurf dieses Reiters;
// hier steht nur der Platz, an dem sie erscheinen.
shape[setting.id] = z.any()
} else if (setting.kind === AgentSettingKind.BOOLEAN) {
shape[setting.id] = z.boolean()
} else {
@@ -62,6 +72,40 @@ function buildSettingsSchema(settings: AgentSetting[]) {
return z.object(shape)
}
// ── Erklärung zu einem Wert (Runde 10, §2.10) ─────────────────────────────────
/**
* Ein Popover und kein Tooltip: Der Text ist mehrere Sätze lang, und ein
* Tooltip verschwindet, sobald man den Zeiger bewegt, um ihn zu Ende zu lesen.
*/
function SettingInfoButton({ label, text }: { label: string; text: string }) {
const [anker, setAnker] = useState<HTMLElement | null>(null)
return (
<>
<IconButton
size="small"
aria-label={`Wie entsteht «${label}»?`}
onClick={(e) => setAnker(e.currentTarget)}
sx={{ color: DS_TEXT.muted, p: 0.25, ...FOCUS_SX }}
>
<Info size={14} />
</IconButton>
<Popover
open={anker !== null}
anchorEl={anker}
onClose={() => setAnker(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: { sx: { maxWidth: 460, p: 2, boxShadow: DS_SHADOW.panel } } }}
>
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>
{text}
</Typography>
</Popover>
</>
)
}
// ── Eine Einstellung ──────────────────────────────────────────────────────────
interface RowProps {
@@ -126,6 +170,24 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
</>
)
break
case AgentSettingKind.CHIPS:
control = (
<>
<AgentChipsField
value={Array.isArray(setting.value) ? setting.value : []}
options={options}
allowCustom={setting.allowCustom === true}
disabled={locked}
ariaLabel={setting.label}
onChange={(next) => onChange(setting.id, next)}
/>
{error && <FormHelperText error>{error}</FormHelperText>}
</>
)
break
case AgentSettingKind.DOCUMENTS:
control = <ResearchDocumentsField />
break
default:
control = (
<TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined}
@@ -135,7 +197,10 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
return (
<Box sx={{ display: 'grid', gap: 0.5, py: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography>
{setting.infoText && <SettingInfoButton label={setting.label} text={setting.infoText} />}
</Box>
{setting.description && (
<Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography>
)}
+58 -134
View File
@@ -1,5 +1,5 @@
/**
* Property On — Reiter «Systeme» im Personalblatt (§10, Runde 10 §3).
* Property On — Reiter «Systeme» im Personalblatt (§10, Runde 10 §§1–3).
*
* Zeigt und pflegt die Fachsysteme und Datenquellen eines digitalen
* Mitarbeiters. Seit Runde 10 ist der Reiter nicht mehr nur Anzeige: Zugänge
@@ -7,120 +7,52 @@
*
* Bewusst eine einzige Liste statt getrennter Gruppen «lesend» und
* «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser
* Mitarbeiter», nicht «wie viele davon sind lesend». Der Zugriffstyp steht
* weiterhin an jedem Eintrag — als Badge, als Icon und als Klartextsatz, denn
* Farbe allein ist kein zulässiger Statusträger (§2.3, §18).
* Mitarbeiter», nicht «wie viele davon sind lesend». Jede Zeile trägt rechts
* oben genau zwei Angaben — ob sie genutzt wird und ob gelesen oder geschrieben
* wird. Beide sind Text, nicht nur Farbe (§2.3, §18).
*/
import { memo, useCallback, useState } from 'react'
import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material'
import { Eye, KeyRound, PenLine, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { Box, Button, IconButton, Link, Switch, Tooltip, Typography } from '@mui/material'
import { ExternalLink, Pencil, Plus, Trash2 } from 'lucide-react'
import {
AgentAccessLevel,
AgentConnectionStatus,
AgentSystemType,
} from '../../domain/teamAgent'
import type { AgentSystem, TeamAgent } from '../../domain/teamAgent'
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
import { GenericBadge } from '../shared/GenericBadge'
import { AgentEntityDialog } from './AgentEntityDialog'
import type { EntityDraft, EntityField } from './AgentEntityDialog'
import { newEntityId, removeById, upsertById } from './agentEntityEditing'
import { useSaveAgentSystems } from '../../hooks/useTeamAgents'
import {
AGENT_ACCESS_LABELS,
AGENT_CONNECTION_STATUS_LABELS,
AGENT_SYSTEM_LABELS,
} from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
// ── Darstellung je Zugriffstyp ────────────────────────────────────────────────
interface AccessAppearance {
bg: string
border: string
text: string
icon: LucideIcon
/** Klartext neben dem Icon, damit der Zugriff nicht nur über die Farbe lesbar ist. */
marker: string
}
const ACCESS_APPEARANCE: Record<AgentAccessLevel, AccessAppearance> = {
[AgentAccessLevel.READ]: {
bg: DS_BG.surface,
border: DS_BORDER.default,
text: DS_TEXT.secondary,
icon: Eye,
marker: 'Liest nur — verändert in diesem System nichts',
},
[AgentAccessLevel.WRITE]: {
bg: DS_SURFACE.warning.bg,
border: DS_SURFACE.warning.border,
text: DS_TEXT.warningDark,
icon: KeyRound,
marker: 'Schreibzugriff — legt in diesem System Daten ab',
},
[AgentAccessLevel.READ_WRITE]: {
bg: DS_SURFACE.info.bg,
border: DS_SURFACE.info.border,
text: DS_TEXT.infoDark,
icon: PenLine,
marker: 'Lese- und Schreibzugriff — verändert bestehende Daten',
},
}
/** Ohne Feld gilt ein Zugang als aktiv — der Katalog führt die Zugänge ohne Schalter. */
function istAktiv(system: AgentSystem): boolean {
return system.enabled !== false
}
import { AGENT_ACCESS_LABELS } from '../../lib/constants'
import { istAktiv, istSchreibend, systemTitel } from '../../lib/agentSystems'
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
// ── Maske ─────────────────────────────────────────────────────────────────────
const SYSTEM_FIELDS: EntityField[] = [
{ key: 'title', label: 'Titel der Quelle', required: true },
{ key: 'description', label: 'Beschreibung der Quelle', type: 'multiline', required: true },
{
key: 'type',
label: 'System',
type: 'select',
required: true,
options: Object.values(AgentSystemType).map((t) => ({
value: t,
label: AGENT_SYSTEM_LABELS[t] ?? t,
})),
key: 'url',
label: 'Link / URL',
type: 'url',
placeholder: 'https://…',
helperText: 'Eine aktive Lesequelle mit Adresse wird im Recherchelauf tatsächlich abgerufen.',
},
{
key: 'access',
label: 'Zugriff',
type: 'select',
required: true,
options: Object.values(AgentAccessLevel).map((a) => ({
value: a,
label: AGENT_ACCESS_LABELS[a] ?? a,
})),
options: [
{ value: AgentAccessLevel.READ, label: AGENT_ACCESS_LABELS[AgentAccessLevel.READ] },
{ value: AgentAccessLevel.WRITE, label: AGENT_ACCESS_LABELS[AgentAccessLevel.WRITE] },
],
helperText: 'Schreibrechte verändern Kundendaten in der Bewirtschaftung.',
},
{
key: 'status',
label: 'Verbindung',
type: 'select',
required: true,
options: Object.values(AgentConnectionStatus).map((s) => ({
value: s,
label: AGENT_CONNECTION_STATUS_LABELS[s] ?? s,
})),
helperText: 'Ein neu erfasster Zugang gilt erst als verbunden, wenn er eingerichtet ist.',
},
{
key: 'usage',
label: 'Wofür wird das System genutzt?',
type: 'multiline',
required: true,
},
{
key: 'permissionNote',
label: 'Berechtigungshinweis',
placeholder: 'z. B. Schreibt erst nach Freigabe',
},
]
// ── Eine Systemzeile ──────────────────────────────────────────────────────────
@@ -139,30 +71,28 @@ interface RowProps {
* und jeder Reiterwechsel sonst die gesamte Liste neu zeichnen würde (§10.2).
*/
const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onEdit, onDelete }: RowProps) {
const look = ACCESS_APPEARANCE[system.access]
const AccessIcon = look.icon
const aktiv = istAktiv(system)
const label = AGENT_SYSTEM_LABELS[system.type] ?? system.type
const schreibend = istSchreibend(system)
const titel = systemTitel(system)
return (
<Box
component="li"
sx={{
border: `1px solid ${look.border}`,
border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2,
// Ein abgeschalteter Zugang tritt zurück, bleibt aber vollständig lesbar.
bgcolor: aktiv ? look.bg : DS_BG.subtle,
bgcolor: aktiv ? DS_BG.surface : DS_BG.subtle,
opacity: aktiv ? 1 : 0.75,
p: 1.75,
display: 'grid',
gap: 0.875,
gap: 0.75,
}}
>
{/* Kopfzeile: System, Aktivzustand, Zugriffstyp, Verbindungsstatus */}
{/* Kopfzeile: Titel links, genau zwei Angaben rechts (§2.1B) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} />
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{label}
{titel}
</Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto', alignItems: 'center' }}>
<GenericBadge
@@ -170,8 +100,10 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
semanticVariant={aktiv ? 'confidenceHigh' : 'muted'}
bold
/>
<AgentAccessBadge access={system.access} />
<AgentConnectionStatusBadge status={system.status} />
<GenericBadge
label={schreibend ? 'Schreiben' : 'Lesen'}
semanticVariant={schreibend ? 'confidenceMedium' : 'muted'}
/>
{editing && (
<>
<Tooltip title="System bearbeiten">
@@ -189,36 +121,22 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
</Box>
</Box>
{/* Zugriffstyp im Klartext — nie nur farblich */}
<Typography variant="body2" sx={{ color: look.text, fontWeight: 600 }}>
{look.marker}
</Typography>
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{system.usage}
</Typography>
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */}
{system.permissionNote && (
<Box
sx={{
display: 'flex',
gap: 0.75,
alignItems: 'flex-start',
p: 1,
borderRadius: 1.5,
bgcolor: DS_BG.surface,
border: `1px solid ${DS_BORDER.default}`,
}}
{/* Die Adresse steht offen da — sie ist genau das, was abgerufen wird. */}
{system.url && (
<Link
href={system.url}
target="_blank"
rel="noopener noreferrer"
variant="caption"
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.brand, width: 'fit-content' }}
>
<ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
<Box component="span" sx={{ fontWeight: 700 }}>
Berechtigung:{' '}
</Box>
{system.permissionNote}
</Typography>
</Box>
{system.url}
<ExternalLink size={11} aria-hidden />
</Link>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -227,7 +145,7 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
checked={aktiv}
disabled={busy}
onChange={(_, checked) => onToggle(system.id, checked)}
slotProps={{ input: { 'aria-label': `Systemzugang ${label} aktivieren` } }}
slotProps={{ input: { 'aria-label': `Systemzugang ${titel} aktivieren` } }}
/>
<Typography variant="caption" sx={{ color: aktiv ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 600 }}>
{aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'}
@@ -273,14 +191,19 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
const handleDialogSave = useCallback(
(werte: EntityDraft, active: boolean) => {
const bestehend = bearbeitet !== 'new' ? systems.find((s) => s.id === bearbeitet) : undefined
const url = werte.url?.trim() ?? ''
const eintrag: AgentSystem = {
// Der Typ bleibt beim Bearbeiten erhalten; selbst erfasste Quellen sind
// öffentliche Webquellen, weil genau das über die Adresse gelesen wird.
type: bestehend?.type ?? AgentSystemType.PUBLIC_WEB,
status: bestehend?.status ?? AgentConnectionStatus.CONNECTED,
permissionNote: bestehend?.permissionNote ?? '',
id: bestehend?.id ?? newEntityId(agent.id, 'system'),
type: werte.type as AgentSystemType,
title: werte.title.trim(),
usage: werte.description.trim(),
access: werte.access as AgentAccessLevel,
status: werte.status as AgentConnectionStatus,
usage: werte.usage.trim(),
permissionNote: werte.permissionNote?.trim() ?? '',
enabled: active,
...(url ? { url } : {}),
}
speichern(upsertById(systems, eintrag))
setBearbeitet(null)
@@ -361,11 +284,12 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
title={inBearbeitung ? 'System bearbeiten' : 'Neues System anbinden'}
fields={SYSTEM_FIELDS}
initial={{
type: inBearbeitung?.type ?? AgentSystemType.DMS,
access: inBearbeitung?.access ?? AgentAccessLevel.READ,
status: inBearbeitung?.status ?? AgentConnectionStatus.DISCONNECTED,
usage: inBearbeitung?.usage ?? '',
permissionNote: inBearbeitung?.permissionNote ?? '',
title: inBearbeitung ? systemTitel(inBearbeitung) : '',
description: inBearbeitung?.usage ?? '',
url: inBearbeitung?.url ?? '',
access: inBearbeitung && istSchreibend(inBearbeitung)
? AgentAccessLevel.WRITE
: AgentAccessLevel.READ,
}}
activeLabel="Zugang aktiv"
initialActive={inBearbeitung ? istAktiv(inBearbeitung) : true}
@@ -0,0 +1,155 @@
/**
* Property On — «Newsquellen manuell ablegen» (Runde 10, §2.6).
*
* Ersetzt die Liste verknüpfter Newsadressen im Einstellungsbereich. Der Grund
* für den Tausch: Die Adressen standen dort als Auswahlfeld, ohne dass sie
* irgendetwas bewirkt hätten — abgerufen wird, was unter «Systeme» als aktiver
* Lesezugang steht. Zwei Orte für dieselbe Frage, von denen einer wirkungslos
* war, ist schlechter als einer.
*
* Was hier abgelegt wird, fliesst dagegen tatsächlich in den nächsten Lauf:
* Der Text wird beim Ablegen einmal aus der Datei gewonnen und geht zusammen
* mit den gelesenen Seiten in dieselbe Beurteilung.
*
* Die Datei selbst verlässt den Browser nie — an den Server geht nur ihr Text.
*/
import { memo, useCallback, useRef } from 'react'
import { Box, Button, CircularProgress, IconButton, Tooltip, Typography } from '@mui/material'
import { AlertTriangle, FileText, Trash2, Upload } from 'lucide-react'
import {
RESEARCH_DOCUMENT_LABELS,
RESEARCH_DOCUMENT_MAX_BYTES,
} from '../../domain/researchDocument'
import type { ResearchDocument } from '../../domain/researchDocument'
import {
useRemoveResearchDocument,
useResearchDocuments,
useUploadResearchDocument,
} from '../../hooks/useResearchDocuments'
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
const FOCUS_SX = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
function groesse(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function datum(iso: string): string {
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
const DokumentZeile = memo(function DokumentZeile({
doc, onRemove, busy,
}: { doc: ResearchDocument; onRemove: (id: string) => void; busy: boolean }) {
const unbrauchbar = Boolean(doc.extractionNote)
return (
<Box
component="li"
sx={{
display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.25,
border: `1px solid ${unbrauchbar ? DS_SURFACE.warning.border : DS_BORDER.default}`,
borderRadius: 2,
bgcolor: unbrauchbar ? DS_SURFACE.warning.bg : DS_BG.surface,
}}
>
<FileText size={15} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 600, fontSize: '0.8125rem', color: DS_TEXT.primary, wordBreak: 'break-word' }}>
{doc.name}
</Typography>
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block' }}>
{RESEARCH_DOCUMENT_LABELS[doc.kind]} · {groesse(doc.size)} · abgelegt am {datum(doc.uploadedAt)}
</Typography>
{doc.extractionNote && (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', mt: 0.5 }}>
<AlertTriangle size={12} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark }}>
{doc.extractionNote}
</Typography>
</Box>
)}
</Box>
<Tooltip title="Dokument entfernen">
<span>
<IconButton size="small" disabled={busy} onClick={() => onRemove(doc.id)} sx={{ color: DS_TEXT.secondary }}>
<Trash2 size={14} />
</IconButton>
</span>
</Tooltip>
</Box>
)
})
export function ResearchDocumentsField() {
const { data: dokumente = [], isLoading } = useResearchDocuments()
const upload = useUploadResearchDocument()
const remove = useRemoveResearchDocument()
const inputRef = useRef<HTMLInputElement>(null)
const handleFiles = useCallback(
(files: FileList | null) => {
if (!files) return
// Nacheinander: Die Extraktion ist rechenintensiv, und mehrere gleichzeitig
// würden den Hauptthread für Sekunden blockieren.
for (const file of Array.from(files)) upload.mutate(file)
// Ohne Zurücksetzen liesse sich dieselbe Datei nicht erneut wählen.
if (inputRef.current) inputRef.current.value = ''
},
[upload],
)
const handleRemove = useCallback((id: string) => remove.mutate(id), [remove])
return (
<Box sx={{ display: 'grid', gap: 1.25 }}>
<input
ref={inputRef}
type="file"
accept=".pdf,.docx"
multiple
hidden
onChange={e => handleFiles(e.target.files)}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Button
size="small"
variant="outlined"
disabled={upload.isPending}
startIcon={upload.isPending
? <CircularProgress size={13} color="inherit" />
: <Upload size={14} aria-hidden />}
onClick={() => inputRef.current?.click()}
sx={{ textTransform: 'none', ...FOCUS_SX }}
>
{upload.isPending ? 'Wird gelesen …' : 'Dokument ablegen'}
</Button>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
PDF oder Word (.docx), höchstens {RESEARCH_DOCUMENT_MAX_BYTES / 1024 / 1024} MB je Datei
</Typography>
</Box>
{isLoading ? (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Dokumente werden geladen …</Typography>
) : dokumente.length === 0 ? (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
Noch nichts abgelegt. Abgelegte Dokumente gehen zusammen mit den aktiven Lesequellen in den
nächsten Recherchelauf.
</Typography>
) : (
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 0.75 }}>
{dokumente.map(doc => (
<DokumentZeile key={doc.id} doc={doc} onRemove={handleRemove} busy={remove.isPending} />
))}
</Box>
)}
</Box>
)
}
+33
View File
@@ -1,4 +1,5 @@
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
import type { TemporalClass } from './researchLead'
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
@@ -87,6 +88,38 @@ export interface FutureSignal {
affectedTenant?: string
/** Externe Quellen, aus denen Livia den Lead verdichtet hat. */
researchSources?: SignalSource[]
// ── Runde 10 ────────────────────────────────────────────────────────────────
/**
* Wann die beschriebene Veränderung stattfindet (§2.9).
*
* Ausdrücklich nicht dasselbe wie `source.publishedAt` und auch nicht
* dasselbe wie `timeHorizonMonths`: Der Horizont ist eine Schätzung in
* Monaten, diese Klasse eine belegte Einordnung mit Fundstelle. Optional,
* weil Bestandssignale sie nicht tragen — fehlt sie, gilt der Lead als
* zeitlich nicht eingeordnet und fällt durch keinen Zeitfilter.
*/
temporalClass?: TemporalClass
/** Die Textstelle, die die zeitliche Einordnung trägt. Ohne sie ist sie nicht prüfbar. */
temporalEvidence?: string
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
eventDate?: string
/**
* Ausformuliertes Nachfrageprofil (§2.13B).
*
* Einzige Wahrheit für Livia und Nora: Livia erstellt es, Nora gleicht es
* gegen den Bestand ab. Nora erzeugt bewusst keinen eigenen Text — zwei
* Fassungen desselben Profils wären zwei Aussagen über denselben Bedarf.
*/
semanticSearchProfile?: string
/**
* Herkunft des Signals. `RESEARCH_RUN` markiert Leads, die ein
* Recherchelauf erzeugt hat — sie stehen in derselben Liste wie die
* erfassten und unterscheiden sich nur durch dieses Feld, nicht durch eine
* eigene Ablage (§2.12).
*/
origin?: 'SEED' | 'RESEARCH_RUN'
}
// ── Chance / Risiko (Runde 8, §2) ────────────────────────────────────────────
+55
View File
@@ -0,0 +1,55 @@
/**
* Property On — manuell abgelegte Newsquellen (Runde 10, §2.6).
*
* Nicht jede Quelle ist eine Webseite. Ein Geschäftsbericht, ein
* Verbandsrundschreiben, ein Protokoll aus einer Gemeindeversammlung — das
* kommt als PDF oder Word-Datei auf den Tisch und hat keine Adresse, die
* Livia abrufen könnte.
*
* Solche Dokumente sind deshalb kein eigener Datentyp mit eigener Behandlung,
* sondern eine zweite Art, Text in denselben Recherchelauf zu geben. Was am
* Ende beurteilt wird, ist in beiden Fällen Text mit einem Namen und einer
* Herkunft.
*/
export const ResearchDocumentKind = {
PDF: 'PDF',
DOCX: 'DOCX',
} as const
export type ResearchDocumentKind = typeof ResearchDocumentKind[keyof typeof ResearchDocumentKind]
export const RESEARCH_DOCUMENT_LABELS: Record<ResearchDocumentKind, string> = {
PDF: 'PDF',
DOCX: 'Word',
}
/** Was von einem abgelegten Dokument geführt wird. */
export interface ResearchDocument {
id: string
/** Dateiname, wie er beim Ablegen hiess — die einzige Bezeichnung, die der Nutzer wiedererkennt. */
name: string
kind: ResearchDocumentKind
/** Grösse in Bytes; wird angezeigt, damit ein leeres Dokument auffällt. */
size: number
uploadedAt: string
/**
* Der extrahierte Text.
*
* Er wird beim Ablegen einmal gewonnen und danach nie wieder — die
* Extraktion ist der teure Teil, und ihn bei jedem Lauf zu wiederholen
* würde denselben Text ein zweites Mal ergeben.
*/
text: string
/**
* Gesetzt, wenn aus der Datei kein brauchbarer Text zu holen war (etwa ein
* gescanntes PDF ohne Textebene). Das Dokument bleibt in der Liste — mit
* Hinweis, statt stillschweigend nichts beizutragen.
*/
extractionNote?: string
}
/** Obergrenze je Datei. Grössere Dateien sprengen den Speicher des Browsers. */
export const RESEARCH_DOCUMENT_MAX_BYTES = 15 * 1024 * 1024
/** Ab wie vielen Zeichen ein extrahierter Text als brauchbar gilt. */
export const RESEARCH_DOCUMENT_MIN_CHARS = 200
+61
View File
@@ -46,6 +46,24 @@ export interface ResearchLead {
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
sourceUrl: string
publishedAt?: string
// ── Runde 10 ────────────────────────────────────────────────────────────────
/** Zeitliche Dimension der beschriebenen Veränderung (§2.9). */
temporalClass: TemporalClass
/** Der Satz aus der Quelle, der die Einordnung trägt — ohne ihn ist sie nicht prüfbar. */
temporalEvidence: string
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
eventDate?: string
/**
* Ausformuliertes Nachfrageprofil für Chance-Leads (§2.13B).
*
* Kein Stichwortfeld: Nora gleicht es gegen den Objektbestand ab, und dafür
* muss darin stehen, was die Quelle hergibt — einschliesslich dessen, was sie
* nicht hergibt. Ein Profil, das Lücken verschweigt, führt zu Treffern, die
* niemand belegen kann.
*/
semanticSearchProfile?: string
}
/** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
@@ -68,6 +86,8 @@ export interface ResearchRefreshResult {
discarded: number
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
watchlist: number
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Fenster lag (§2.9). */
temporalFiltered: number
/** Nur `high` und `medium` erscheinen hier. */
leads: ResearchLead[]
/** Zeitpunkt des Laufs, ISO. */
@@ -87,3 +107,44 @@ export interface ResearchRefreshResult {
*/
analysisError?: string
}
// ── Zeitliche Relevanz (Runde 10, §2.9) ───────────────────────────────────────
/**
* Wann die beschriebene Veränderung stattfindet — **nicht**, wann der Artikel
* erschienen ist.
*
* Das ist der ganze Punkt dieser Klassifikation: Ein Jahresbericht vom Januar
* kann einen Standort für das dritte Quartal ankündigen (`FUTURE`), und eine
* Meldung von heute kann einen Umzug vom Frühling beschreiben (`PAST`). Wer
* nach Publikationsdatum filtert, bekommt in beiden Fällen das Gegenteil
* dessen, was er sucht.
*
* `UNKNOWN` ist ausdrücklich erlaubt. Wenn die Quelle keine belastbare
* zeitliche Aussage enthält, ist «weiss ich nicht» die richtige Antwort —
* ein erfundenes Datum wäre die falsche.
*/
export const TemporalClass = {
PAST: 'past',
CURRENT: 'current',
FUTURE: 'future',
UNKNOWN: 'unknown',
} as const
export type TemporalClass = typeof TemporalClass[keyof typeof TemporalClass]
/** Reihenfolge im Einstellungsbereich — von abgeschlossen nach angekündigt. */
export const TEMPORAL_FILTER_ORDER: TemporalClass[] = [
TemporalClass.PAST,
TemporalClass.CURRENT,
TemporalClass.FUTURE,
]
/**
* Die Grenze zwischen «jetzt» und «später», in Wochen.
*
* Acht Wochen sind keine willkürliche Zahl: Sie entsprechen ungefähr der
* Vorlaufzeit, die eine Bewirtschaftung braucht, um auf einen Flächenbedarf
* überhaupt noch mit einem Angebot zu reagieren. Alles darunter ist ein
* laufender Vorgang, alles darüber eine Planung.
*/
export const TEMPORAL_CURRENT_WEEKS = 8
+40
View File
@@ -120,6 +120,19 @@ export interface AgentSystem {
* die Zugänge ohne Schalter führt — fehlt das Feld, gilt der Zugang als aktiv.
*/
enabled?: boolean
/**
* Eigener Titel der Quelle (Runde 10, §2.1). Der Katalog benennt seine
* Zugänge über `type`; selbst angebundene Quellen haben keinen passenden
* Typ und tragen ihren Namen deshalb hier. Fehlt der Titel, gilt das Label
* des Typs — so bleiben die Katalogeinträge unverändert.
*/
title?: string
/**
* Adresse der Quelle. Bei einer aktiven Lesequelle ist das die Seite, die
* Livias Recherchelauf tatsächlich abruft — der angezeigte Link und der
* gelesene Link sind damit derselbe Wert.
*/
url?: string
/** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */
usage: string
/** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */
@@ -149,6 +162,18 @@ export const AgentSettingKind = {
MULTI_SELECT: 'MULTI_SELECT',
BOOLEAN: 'BOOLEAN',
TIME: 'TIME',
/**
* Mehrfachauswahl als Chips, jeder einzeln entfernbar (Runde 10, §§2.7–2.9).
*
* Der Unterschied zu `MULTI_SELECT` ist nicht bloss die Darstellung: Ein
* Auswahlfeld kann nur anbieten, was jemand vorher als Option hinterlegt
* hat. Quellentypen und Beobachtungsräume sind aber genau das, was sich von
* Haus zu Haus unterscheidet — sie brauchen ein Feld, in das man schreiben
* kann, was man meint.
*/
CHIPS: 'CHIPS',
/** Abgelegte Dokumente als Datenbasis der Recherche (Runde 10, §2.6). */
DOCUMENTS: 'DOCUMENTS',
} as const
export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind]
@@ -179,6 +204,21 @@ export interface AgentSetting {
/** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */
locked?: boolean
lockedReason?: string
/**
* Erklärt auf Knopfdruck, wie ein Wert zustande kommt (Runde 10, §2.10).
*
* Gehört ans Datenmodell und nicht in die Komponente: Was ein Schwellenwert
* bedeutet, weiss das Personalblatt, nicht das Eingabefeld. Und ein Text,
* der eine Berechnung beschreibt, muss neben der Berechnung stehen, damit
* beide zusammen geändert werden.
*/
infoText?: string
/**
* Nur bei `CHIPS`: ob eigene Werte erfasst werden dürfen. Bei einer
* fachlich abgeschlossenen Liste — etwa den drei Zeitfenstern — bleibt es
* aus, sonst entstünde ein vierter Wert, den niemand auswerten kann.
*/
allowCustom?: boolean
}
// ── Kennzahlen & Profil ───────────────────────────────────────────────────────
+66 -7
View File
@@ -1,8 +1,23 @@
import { useMutation } from '@tanstack/react-query'
import type { ResearchRefreshResult } from '../domain/researchLead'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { ResearchRefreshResult, TemporalClass } from '../domain/researchLead'
import type { ResearchSourceDescriptor } from '../lib/researchSources'
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
import { futureSignalService } from '../services/futureSignalService'
import { agentProtocolService } from '../services/agentProtocolService'
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
import type { ResearchDocument } from '../domain/researchDocument'
import { useToastStore } from '../stores/toastStore'
/** Was ein Lauf mitbekommt — alles aus dem gespeicherten Stand, nichts hartcodiert. */
export interface ResearchRunInput {
/** Aktive Lesezugänge mit Adresse, abgeleitet aus Livias Systemen (§2.3). */
sources: ResearchSourceDescriptor[]
/** Manuell abgelegte Dokumente mit bereits extrahiertem Text (§2.6). */
documents: ResearchDocument[]
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung» (§2.9). */
temporalClasses: TemporalClass[]
}
/**
* «Research aktualisieren» — der eine Aufruf des PoC.
*
@@ -11,15 +26,33 @@ import { useToastStore } from '../stores/toastStore'
* `staleTime` und keine Zwischenspeicherung — wer auf den Knopf drückt, will
* den Stand von jetzt und nicht den von vorhin.
*
* Das Ergebnis lebt im Zustand der Komponente. Für den Machbarkeitsnachweis
* ist keine Persistenz nötig (§17).
* Seit Runde 10 endet der Lauf nicht mit der Antwort. Drei Dinge passieren
* danach, und zwar hier, weil sie zusammengehören (§§2.5/2.12):
*
* 1. Die gefundenen Leads gehen in den **zentralen** Signalbestand — es gibt
* keine zweite Ergebnisliste mehr.
* 2. Der Lauf erzeugt einen **echten** Protokolleintrag mit der Zahl der
* gelesenen Quellen und der gefundenen Leads.
* 3. Die Leadliste wird neu geladen, damit das Ergebnis sofort dort steht, wo
* es hingehört.
*/
export function useLiviaResearch() {
const showToast = useToastStore(s => s.showToast)
const queryClient = useQueryClient()
return useMutation<ResearchRefreshResult, Error>({
mutationFn: async () => {
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, { method: 'POST' })
return useMutation<ResearchRefreshResult, Error, ResearchRunInput>({
mutationFn: async ({ sources, documents, temporalClasses }) => {
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sources: sources.map(q => ({ id: q.id, name: q.name, url: q.url })),
// Nur der extrahierte Text geht an den Server; die Datei selbst
// bleibt im Browser. Sie hochzuladen wäre Datenversand ohne Nutzen.
documents: documents.map(d => ({ id: d.id, name: d.name, text: d.text })),
temporalClasses,
}),
})
// Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall
@@ -35,6 +68,32 @@ export function useLiviaResearch() {
return await res.json() as ResearchRefreshResult
},
onSuccess: async (ergebnis) => {
if (ergebnis.leads.length > 0) {
await futureSignalService.importResearchLeads(ergebnis.leads, ergebnis.refreshedAt)
await queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
}
// Der Eintrag trägt die Zahlen des Laufs, nicht einen festen Text —
// ein Protokoll, das immer dasselbe sagt, protokolliert nichts (§2.5).
const gelesen = ergebnis.sources.filter(s => s.ok).length
await agentProtocolService.logUserAction({
agentId: 'livia',
eventType: AgentProtocolEventType.TASK_RUN,
title: 'Research aktualisiert',
description:
`${gelesen} von ${ergebnis.sources.length} ${ergebnis.sources.length === 1 ? 'Quelle' : 'Quellen'} gelesen · ` +
`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Eintrag' : 'Einträge'} beurteilt · ` +
`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead' : 'Leads'} in die Leadliste übernommen` +
(ergebnis.temporalFiltered > 0
? ` · ${ergebnis.temporalFiltered} wegen der zeitlichen Relevanz ausgeschlossen`
: ''),
status: ergebnis.analysisError ? AgentProtocolStatus.WARNING : AgentProtocolStatus.SUCCESS,
})
await queryClient.invalidateQueries({ queryKey: ['agentProtocol'] })
},
onError: (err) => {
showToast(err.message, 'error')
},
+50
View File
@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { researchDocumentService } from '../services/researchDocumentService'
import { STALE_SIGNALS } from '../lib/constants'
import { useToastStore } from '../stores/toastStore'
const KEY = ['researchDocuments']
/** Die manuell abgelegten Newsquellen (Runde 10, §2.6). */
export function useResearchDocuments() {
return useQuery({
queryKey: KEY,
queryFn: () => researchDocumentService.getAll(),
staleTime: STALE_SIGNALS,
select: (res) => res.data,
})
}
export function useUploadResearchDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (file: File) => researchDocumentService.upload(file),
onSuccess: (res) => {
void queryClient.invalidateQueries({ queryKey: KEY })
// Ein Dokument ohne brauchbaren Text wird abgelegt, trägt aber nichts
// bei — das muss beim Ablegen gesagt werden und nicht erst, wenn der
// Lauf keine Leads daraus findet.
if (res.data.extractionNote) {
useToastStore.getState().showToast(res.data.extractionNote, 'warning')
} else {
useToastStore.getState().showToast(`«${res.data.name}» abgelegt.`, 'success')
}
},
onError: (err: Error) => {
useToastStore.getState().showToast(err.message, 'error')
},
})
}
export function useRemoveResearchDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => researchDocumentService.remove(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: KEY })
},
onError: () => {
useToastStore.getState().showToast('Das Dokument konnte nicht entfernt werden.', 'error')
},
})
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Welche Systemzugänge ein Recherchelauf tatsächlich abruft (Runde 10, §2.3).
*
* Der Test hält die Zusage fest, die das Personalblatt dem Nutzer gibt: Was
* dort als aktive Lesequelle mit Adresse steht, wird gelesen — und nichts sonst.
*/
import { describe, expect, it } from 'vitest'
import { istAktiv, istSchreibend, leseQuellenAus, systemTitel } from '../agentSystems'
import {
AgentAccessLevel,
AgentConnectionStatus,
AgentSystemType,
} from '../../domain/teamAgent'
import type { AgentSystem } from '../../domain/teamAgent'
function system(overrides: Partial<AgentSystem> = {}): AgentSystem {
return {
id: 'sys-1',
type: AgentSystemType.PUBLIC_WEB,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage: 'Regionale Unternehmensmeldungen.',
permissionNote: '',
title: 'Beispielquelle',
url: 'https://www.beispiel.ch/news',
...overrides,
}
}
describe('istAktiv', () => {
it('gilt ohne Feld als aktiv — der Katalog führt seine Zugänge ohne Schalter', () => {
expect(istAktiv(system({ enabled: undefined }))).toBe(true)
})
it('ist nur bei ausdrücklichem false abgeschaltet', () => {
expect(istAktiv(system({ enabled: false }))).toBe(false)
expect(istAktiv(system({ enabled: true }))).toBe(true)
})
})
describe('istSchreibend', () => {
it('zählt READ_WRITE als schreibend', () => {
expect(istSchreibend(system({ access: AgentAccessLevel.READ_WRITE }))).toBe(true)
expect(istSchreibend(system({ access: AgentAccessLevel.WRITE }))).toBe(true)
expect(istSchreibend(system({ access: AgentAccessLevel.READ }))).toBe(false)
})
})
describe('systemTitel', () => {
it('nimmt den eigenen Titel, wenn einer gesetzt ist', () => {
expect(systemTitel(system({ title: 'Handelskammer' }))).toBe('Handelskammer')
})
it('fällt auf das Katalog-Label zurück', () => {
expect(systemTitel(system({ title: undefined, type: AgentSystemType.ZEFIX }))).toBe('Zefix')
})
it('ignoriert einen Titel aus lauter Leerzeichen', () => {
expect(systemTitel(system({ title: ' ', type: AgentSystemType.ZEFIX }))).toBe('Zefix')
})
})
describe('leseQuellenAus', () => {
it('nimmt aktive Lesezugänge mit Adresse auf', () => {
const quellen = leseQuellenAus([system()])
expect(quellen).toHaveLength(1)
expect(quellen[0]).toMatchObject({ id: 'sys-1', name: 'Beispielquelle', url: 'https://www.beispiel.ch/news' })
})
it('lässt abgeschaltete Zugänge weg', () => {
expect(leseQuellenAus([system({ enabled: false })])).toHaveLength(0)
})
it('lässt schreibende Zugänge weg', () => {
expect(leseQuellenAus([system({ access: AgentAccessLevel.READ_WRITE })])).toHaveLength(0)
})
it('lässt Zugänge ohne Adresse weg — es gibt nichts zu holen', () => {
expect(leseQuellenAus([system({ url: undefined })])).toHaveLength(0)
expect(leseQuellenAus([system({ url: ' ' })])).toHaveLength(0)
})
it('behält die Reihenfolge des Personalblatts', () => {
const quellen = leseQuellenAus([
system({ id: 'a', title: 'Erste' }),
system({ id: 'b', title: 'Zweite' }),
system({ id: 'c', title: 'Dritte', enabled: false }),
])
expect(quellen.map(q => q.name)).toEqual(['Erste', 'Zweite'])
})
})
@@ -0,0 +1,112 @@
/**
* Vom Recherche-Lead zum Signal im zentralen Bestand (Runde 10, §2.12).
*
* Der Schwerpunkt liegt auf zwei Zusagen, die die Anwendung sonst still
* brechen würde: Ein erneuter Lauf über denselben Artikel darf keine Dublette
* erzeugen, und das Publikationsdatum darf die zeitliche Einordnung nicht
* überschreiben.
*/
import { describe, expect, it } from 'vitest'
import { researchLeadId, researchLeadToSignal } from '../researchLeadMapper'
import { ResearchEventType, TemporalClass } from '../../domain/researchLead'
import type { ResearchLead } from '../../domain/researchLead'
import { LeadSignalType } from '../../domain/futureSignal'
const LAUF = '2026-09-12T08:00:00.000Z'
function lead(overrides: Partial<ResearchLead> = {}): ResearchLead {
return {
company: 'Muster AG',
eventType: ResearchEventType.EXPANSION,
headline: 'Muster AG eröffnet zweiten Standort',
location: 'Zürich',
summary: 'Die Muster AG plant einen zusätzlichen Standort im Raum Zürich.',
relevance: 'high',
confidence: 0.8,
reason: 'Konkrete Standortankündigung mit Flächenbezug.',
source: 'Zürcher Handelskammer',
sourceUrl: 'https://www.zhk.ch/de/news/muster-ag',
temporalClass: TemporalClass.FUTURE,
temporalEvidence: 'Die Eröffnung ist für das dritte Quartal angekündigt.',
...overrides,
}
}
describe('researchLeadId', () => {
it('vergibt für denselben Artikel dieselbe Kennung', () => {
expect(researchLeadId(lead())).toBe(researchLeadId(lead()))
})
it('unterscheidet zwei Artikel derselben Quelle', () => {
const a = researchLeadId(lead())
const b = researchLeadId(lead({ headline: 'Muster AG schliesst Werk Nord' }))
expect(a).not.toBe(b)
})
it('unterscheidet denselben Titel aus zwei Quellen', () => {
const a = researchLeadId(lead())
const b = researchLeadId(lead({ sourceUrl: 'https://www.greaterzuricharea.com/de/news/muster' }))
expect(a).not.toBe(b)
})
})
describe('researchLeadToSignal', () => {
it('macht aus einer Expansion eine Chance in Nachfragerichtung', () => {
const signal = researchLeadToSignal(lead(), LAUF)
expect(signal.leadSignalType).toBe(LeadSignalType.CHANCE)
expect(signal.signalDirection).toBe('DEMAND')
expect(signal.origin).toBe('RESEARCH_RUN')
})
it('macht aus einer Standortschliessung ein Risiko', () => {
const signal = researchLeadToSignal(lead({ eventType: ResearchEventType.SITE_CLOSURE }), LAUF)
expect(signal.leadSignalType).toBe(LeadSignalType.RISIKO)
})
it('übernimmt die zeitliche Einordnung samt Fundstelle', () => {
const signal = researchLeadToSignal(lead(), LAUF)
expect(signal.temporalClass).toBe(TemporalClass.FUTURE)
expect(signal.temporalEvidence).toContain('dritte')
})
it('leitet den Zeithorizont aus der Ereigniszeit ab, nicht aus dem Publikationsdatum', () => {
// Heute publiziert, Ereignis aber abgeschlossen: kein Horizont in der Zukunft.
const vergangen = researchLeadToSignal(
lead({ temporalClass: TemporalClass.PAST, publishedAt: LAUF }),
LAUF,
)
const zukuenftig = researchLeadToSignal(
lead({ temporalClass: TemporalClass.FUTURE, publishedAt: '2026-01-05' }),
LAUF,
)
expect(vergangen.timeHorizonMonths).toBe(0)
expect(zukuenftig.timeHorizonMonths).toBeGreaterThan(vergangen.timeHorizonMonths)
})
it('erfindet kein Ereignisdatum, wenn die Quelle keines nennt', () => {
const signal = researchLeadToSignal(lead({ temporalClass: TemporalClass.UNKNOWN }), LAUF)
expect(signal.eventDate).toBeUndefined()
})
it('reicht das semantische Suchprofil unverändert weiter', () => {
const profil = 'Für die Muster AG wird ein Gewerbestandort im Raum Zürich gesucht. Nicht ableitbar ist die Fläche.'
const signal = researchLeadToSignal(lead({ semanticSearchProfile: profil }), LAUF)
expect(signal.semanticSearchProfile).toBe(profil)
})
it('behält die Originalquelle als anklickbaren Beleg', () => {
const signal = researchLeadToSignal(lead(), LAUF)
expect(signal.source.url).toBe('https://www.zhk.ch/de/news/muster-ag')
expect(signal.evidence?.sourceUrls).toEqual(['https://www.zhk.ch/de/news/muster-ag'])
})
})
+73
View File
@@ -0,0 +1,73 @@
/**
* Der harte Zeitfilter (Runde 10, §2.9).
*
* Die Fälle stammen aus der Anforderung selbst: eine bereits erfolgte
* Eröffnung, eine in drei Wochen, eine in vier Monaten — und der Fall, der den
* ganzen Filter begründet: ein heute erschienener Artikel über ein Ereignis von
* vor einem halben Jahr.
*/
import { describe, expect, it } from 'vitest'
import { passtZeitlich } from '../temporalFilter'
import { TemporalClass } from '../../domain/researchLead'
const ALLE = [TemporalClass.PAST, TemporalClass.CURRENT, TemporalClass.FUTURE]
describe('passtZeitlich', () => {
it('lässt ohne Auswahl alles durch — keine Auswahl ist keine Einschränkung', () => {
for (const klasse of [...ALLE, TemporalClass.UNKNOWN]) {
expect(passtZeitlich(klasse, [])).toBe(true)
}
})
it('lässt nur die gewählten Fenster durch', () => {
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.FUTURE])).toBe(true)
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.FUTURE])).toBe(false)
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.FUTURE])).toBe(false)
})
it('wirkt bei mehreren Fenstern als Oder', () => {
const gewaehlt = [TemporalClass.CURRENT, TemporalClass.FUTURE]
expect(passtZeitlich(TemporalClass.CURRENT, gewaehlt)).toBe(true)
expect(passtZeitlich(TemporalClass.FUTURE, gewaehlt)).toBe(true)
expect(passtZeitlich(TemporalClass.PAST, gewaehlt)).toBe(false)
})
it('verwirft einen Lead ohne belegbare Ereigniszeit nicht', () => {
// Ein erfundenes Datum wäre die schlechtere Antwort als ein sichtbarer Lead
// mit dem Vermerk, dass die Ereigniszeit offen ist.
expect(passtZeitlich(TemporalClass.UNKNOWN, [TemporalClass.FUTURE])).toBe(true)
})
it('behandelt ein Bestandssignal ohne Einordnung wie «unbekannt»', () => {
expect(passtZeitlich(undefined, [TemporalClass.PAST])).toBe(true)
})
describe('die Fälle aus der Anforderung', () => {
it('bereits erfolgte Eröffnung gehört zu «Vergangene Leads»', () => {
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.PAST])).toBe(true)
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.CURRENT, TemporalClass.FUTURE])).toBe(false)
})
it('Eröffnung in drei Wochen gehört zu «Aktuelle Leads»', () => {
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.CURRENT])).toBe(true)
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.PAST, TemporalClass.FUTURE])).toBe(false)
})
it('angekündigte Eröffnung in vier Monaten gehört zu «Zukünftige Leads»', () => {
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.FUTURE])).toBe(true)
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.PAST, TemporalClass.CURRENT])).toBe(false)
})
it('heute publiziert, Ereignis vor sechs Monaten — das ist vergangen, nicht aktuell', () => {
// Der Filter kennt nur die Klasse; dass sie aus der Ereigniszeit und
// nicht aus dem Publikationsdatum stammt, sichert der Auftrag an das
// Modell. Hier wird festgehalten, was daraus folgen muss.
const klasse = TemporalClass.PAST
expect(passtZeitlich(klasse, [TemporalClass.PAST])).toBe(true)
expect(passtZeitlich(klasse, [TemporalClass.CURRENT])).toBe(false)
})
})
})
+55
View File
@@ -0,0 +1,55 @@
/**
* Property On — was sich aus einem Systemzugang ablesen lässt (Runde 10, §§2.1–2.3).
*
* Diese Ableitungen stehen bewusst ausserhalb der Reiter-Komponenten: Das
* Personalblatt zeigt sie an, und Livias Recherchelauf entscheidet nach
* denselben Regeln, welche Adresse er tatsächlich abruft. Zwei Fassungen
* derselben Regel wären die Stelle, an der Anzeige und Abruf auseinanderlaufen —
* und genau deren Übereinstimmung ist der Zweck der Anzeige.
*/
import { AgentAccessLevel } from '../domain/teamAgent'
import type { AgentSystem } from '../domain/teamAgent'
import { AGENT_SYSTEM_LABELS } from './constants'
import type { ResearchSourceDescriptor } from './researchSources'
/** Ohne Feld gilt ein Zugang als aktiv — der Katalog führt die Zugänge ohne Schalter. */
export function istAktiv(system: AgentSystem): boolean {
return system.enabled !== false
}
/** Eigener Titel schlägt das Katalog-Label; nur so lassen sich freie Quellen benennen. */
export function systemTitel(system: AgentSystem): string {
return system.title?.trim() || AGENT_SYSTEM_LABELS[system.type] || system.type
}
/** Schreibend ist alles, was nicht ausschliesslich liest. */
export function istSchreibend(system: AgentSystem): boolean {
return system.access !== AgentAccessLevel.READ
}
/**
* Die Systemzugänge, die ein Recherchelauf tatsächlich abruft.
*
* Drei Bedingungen, alle drei sichtbar im Personalblatt: der Zugang ist
* aktiv, er liest, und er hat eine Adresse. Ein Zugang ohne Adresse — etwa
* die Objektdatenbank — wird nicht abgerufen, weil es nichts zu holen gibt;
* ihn trotzdem als Quelle zu zählen, wäre eine hartcodierte Statusanzeige
* ohne Deckung im Datenmodell.
*/
export function leseQuellenAus(systems: AgentSystem[]): ResearchSourceDescriptor[] {
const quellen: ResearchSourceDescriptor[] = []
for (const system of systems) {
if (!istAktiv(system)) continue
if (istSchreibend(system)) continue
const url = system.url?.trim()
if (!url) continue
quellen.push({
id: system.id,
name: systemTitel(system),
url,
purpose: system.usage,
})
}
return quellen
}
+14
View File
@@ -634,3 +634,17 @@ export const FIT_OUT_LABELS: Record<string, string> = {
FULL: 'Ausgebaut · CAT B',
PREMIUM: 'Vollausgebaut · Plug & Play',
}
/**
* Zeitliche Relevanz eines Leads (Runde 10, §2.9).
*
* Die Begriffe beschreiben die Ereigniszeit der Veränderung, nicht das
* Publikationsdatum — deshalb «Veränderung abgeschlossen» und nicht
* «älterer Artikel».
*/
export const TEMPORAL_CLASS_LABELS: Record<string, string> = {
past: 'Veränderung abgeschlossen',
current: 'Läuft oder steht kurz bevor',
future: 'Angekündigt, später',
unknown: 'Ereigniszeit nicht belegbar',
}
+233
View File
@@ -0,0 +1,233 @@
/**
* Property On — Text aus abgelegten Dokumenten gewinnen (Runde 10, §2.6).
*
* Bewusst ohne neue Abhängigkeit. Der Browser bringt mit `DecompressionStream`
* bereits alles mit, was für beide Formate nötig ist:
*
* - **DOCX** ist ein ZIP-Archiv. Darin liegt `word/document.xml`, und der
* sichtbare Text steht in `<w:t>`-Elementen. Mehr braucht es nicht.
* - **PDF** trägt seinen Text in Inhaltsströmen, meist mit Flate gepackt. Die
* Operatoren `Tj` und `TJ` setzen ihn.
*
* Was diese Datei ausdrücklich **nicht** ist: ein vollwertiger Dokumentenleser.
* Sie kennt keine Schriftkodierungen jenseits der üblichen, keine Tabellen, kein
* Layout und keine gescannten Seiten. Für die Beurteilung durch ein Sprachmodell
* genügt Fliesstext in der richtigen Reihenfolge — und wo sie scheitert, sagt
* sie das, statt eine leere Zeichenkette als «Dokument ohne Inhalt» auszugeben.
*
* Eine Bibliothek wie pdf.js würde mehr können. Sie wäre auch rund ein Megabyte
* gross, und der Machbarkeitsnachweis steht und fällt nicht an der Frage, ob
* ein zweispaltiges PDF sauber ausgelesen wird.
*/
import {
RESEARCH_DOCUMENT_MIN_CHARS,
ResearchDocumentKind,
} from '../domain/researchDocument'
export interface ExtractionResult {
text: string
/** Gesetzt, wenn der Text nicht brauchbar ist — die Datei bleibt trotzdem abgelegt. */
note?: string
}
// ── Gemeinsames ───────────────────────────────────────────────────────────────
async function inflate(bytes: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream(format))
const puffer = await new Response(stream).arrayBuffer()
return new Uint8Array(puffer)
}
function normalisiere(text: string): string {
return text
.replace(/\r\n?/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
// ── DOCX ──────────────────────────────────────────────────────────────────────
/**
* Eine Datei aus einem ZIP-Archiv holen.
*
* Gelesen wird über das zentrale Verzeichnis am Dateiende — das ist der einzige
* Ort im ZIP-Format, an dem die Einträge verlässlich vollständig stehen. Die
* lokalen Kopfzeilen davor lassen Grössenangaben offen, wenn das Archiv im
* Datenstrom geschrieben wurde, und genau das tun viele Textverarbeitungen.
*/
async function zipEintrag(bytes: Uint8Array, pfad: string): Promise<Uint8Array | null> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
// Ende des zentralen Verzeichnisses suchen (Signatur 0x06054b50), von hinten.
let eocd = -1
for (let i = bytes.length - 22; i >= 0 && i > bytes.length - 65_557; i--) {
if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break }
}
if (eocd === -1) return null
const anzahl = view.getUint16(eocd + 10, true)
let offset = view.getUint32(eocd + 16, true)
const encoder = new TextDecoder()
for (let n = 0; n < anzahl; n++) {
if (view.getUint32(offset, true) !== 0x02014b50) return null
const methode = view.getUint16(offset + 10, true)
const gepackt = view.getUint32(offset + 20, true)
const nameLen = view.getUint16(offset + 28, true)
const extraLen = view.getUint16(offset + 30, true)
const kommentarLen = view.getUint16(offset + 32, true)
const lokal = view.getUint32(offset + 42, true)
const name = encoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLen))
if (name === pfad) {
// Die lokale Kopfzeile sagt, wie lang Name und Zusatzfeld dort sind —
// sie dürfen von den Angaben im Verzeichnis abweichen.
const lokalNameLen = view.getUint16(lokal + 26, true)
const lokalExtraLen = view.getUint16(lokal + 28, true)
const start = lokal + 30 + lokalNameLen + lokalExtraLen
const daten = bytes.subarray(start, start + gepackt)
return methode === 0 ? daten : await inflate(daten, 'deflate-raw')
}
offset += 46 + nameLen + extraLen + kommentarLen
}
return null
}
async function ausDocx(bytes: Uint8Array): Promise<ExtractionResult> {
const xmlBytes = await zipEintrag(bytes, 'word/document.xml')
if (!xmlBytes) {
return { text: '', note: 'Die Datei konnte nicht als Word-Dokument gelesen werden.' }
}
const xml = new TextDecoder().decode(xmlBytes)
const absaetze: string[] = []
// Je Absatz den Text seiner Textläufe zusammensetzen — sonst klebt der ganze
// Text in einer einzigen Zeile und das Modell verliert die Gliederung.
for (const [, inhalt] of xml.matchAll(/<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g)) {
const teile = [...inhalt.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map(m => m[1])
if (teile.length === 0) continue
const zeile = teile
.join('')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
.trim()
if (zeile) absaetze.push(zeile)
}
return { text: normalisiere(absaetze.join('\n')) }
}
// ── PDF ───────────────────────────────────────────────────────────────────────
/** Zeichenfolgen aus den Textoperatoren eines Inhaltsstroms. */
function pdfTextAusStrom(strom: string): string {
const zeilen: string[] = []
// `(…) Tj` und `[(…) -250 (…)] TJ` — beide setzen sichtbaren Text.
const muster = /(\[(?:[^\][\\]|\\.)*\]|\((?:[^\\()]|\\.)*\))\s*(TJ|Tj)/g
for (const treffer of strom.matchAll(muster)) {
const block = treffer[1]
const stuecke: string[] = []
for (const s of block.matchAll(/\(((?:[^\\()]|\\.)*)\)/g)) {
stuecke.push(
s[1]
.replace(/\\([nrtbf])/g, (_, c: string) =>
({ n: '\n', r: '\n', t: ' ', b: '', f: '\n' }[c] ?? ''))
.replace(/\\(\d{1,3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8)))
.replace(/\\(.)/g, '$1'),
)
}
const zeile = stuecke.join('').trim()
if (zeile) zeilen.push(zeile)
}
return zeilen.join('\n')
}
async function ausPdf(bytes: Uint8Array): Promise<ExtractionResult> {
// Die Datei als Latin-1 lesen: So bleibt jedes Byte ein Zeichen, und die
// Byte-Offsets der Ströme stimmen mit den Zeichen-Offsets überein.
const roh = new TextDecoder('latin1').decode(bytes)
const teile: string[] = []
const streamMuster = /stream\r?\n?/g
let m: RegExpExecArray | null
while ((m = streamMuster.exec(roh)) !== null) {
const start = m.index + m[0].length
const ende = roh.indexOf('endstream', start)
if (ende === -1) break
streamMuster.lastIndex = ende
const inhalt = roh.slice(start, ende)
// Vor dem Strom steht das Objektwörterbuch — es sagt, ob gepackt wurde.
const kopf = roh.slice(Math.max(0, m.index - 400), m.index)
let text: string
if (/FlateDecode/.test(kopf)) {
try {
const gepackt = Uint8Array.from(inhalt, c => c.charCodeAt(0) & 0xff)
text = new TextDecoder('latin1').decode(await inflate(gepackt, 'deflate'))
} catch {
// Bilder und Schriftdateien sind ebenfalls Flate-Ströme und scheitern
// hier reihenweise — das ist kein Fehler, sondern der Normalfall.
continue
}
} else if (/Image|Font|XObject/.test(kopf)) {
continue
} else {
text = inhalt
}
const gefunden = pdfTextAusStrom(text)
if (gefunden) teile.push(gefunden)
}
const text = normalisiere(teile.join('\n'))
if (text.length < RESEARCH_DOCUMENT_MIN_CHARS) {
return {
text,
note:
'Aus diesem PDF liess sich kein zusammenhängender Text gewinnen — vermutlich ein Scan ohne Textebene. '
+ 'Das Dokument fliesst deshalb nicht in die Recherche ein.',
}
}
return { text }
}
// ── Einstieg ──────────────────────────────────────────────────────────────────
export function kindOf(fileName: string): ResearchDocumentKind | null {
const lower = fileName.toLowerCase()
if (lower.endsWith('.pdf')) return ResearchDocumentKind.PDF
if (lower.endsWith('.docx')) return ResearchDocumentKind.DOCX
return null
}
export async function extractDocumentText(
file: File,
kind: ResearchDocumentKind,
): Promise<ExtractionResult> {
const bytes = new Uint8Array(await file.arrayBuffer())
try {
const ergebnis = kind === ResearchDocumentKind.PDF
? await ausPdf(bytes)
: await ausDocx(bytes)
if (!ergebnis.note && ergebnis.text.length < RESEARCH_DOCUMENT_MIN_CHARS) {
return {
text: ergebnis.text,
note: 'Das Dokument enthält zu wenig Text, um daraus einen Lead abzuleiten.',
}
}
return ergebnis
} catch (err) {
return {
text: '',
note: `Die Datei liess sich nicht lesen: ${err instanceof Error ? err.message : 'unbekannter Fehler'}.`,
}
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Property On — aus einem Recherche-Lead wird ein Signal im zentralen Bestand
* (Runde 10, §2.12).
*
* Bis Runde 9 standen die frisch gelesenen Leads in einer eigenen Liste neben
* den erfassten. Das war zwar ehrlich — live gelesen ist etwas anderes als
* redaktionell erfasst —, aber es hatte einen Preis: Nora konnte mit ihnen
* nichts anfangen, die Filter griffen nicht, und derselbe Sachverhalt sah je
* nach Liste anders aus.
*
* Seit Runde 10 gibt es deshalb genau einen Leadbestand. Die Herkunft geht
* dabei nicht verloren, sie steht am Signal (`origin: 'RESEARCH_RUN'`) —
* unterschieden wird über ein Feld, nicht über eine zweite Ablage.
*/
import { RiskLevel, SignalType } from '../domain/enums'
import { LeadSignalType } from '../domain/futureSignal'
import type { FutureSignal } from '../domain/futureSignal'
import { ResearchEventType } from '../domain/researchLead'
import type { ResearchLead, ResearchRelevance } from '../domain/researchLead'
/**
* Welcher Signaltyp zu welchem Ereignis gehört.
*
* Die Recherche kennt neun Ereignisarten, der Bestand sieben Signaltypen —
* die Zuordnung ist deshalb nicht eins zu eins und steht hier explizit, statt
* irgendwo geraten zu werden.
*/
const SIGNAL_TYPE_BY_EVENT: Record<ResearchEventType, SignalType> = {
[ResearchEventType.EXPANSION]: SignalType.EXPANSION,
[ResearchEventType.NEW_LOCATION]: SignalType.EXPANSION,
[ResearchEventType.HEADCOUNT_GROWTH]: SignalType.EXPANSION,
[ResearchEventType.INVESTMENT]: SignalType.PROJECT_DEVELOPMENT,
[ResearchEventType.NEW_COMPANY]: SignalType.EXPANSION,
[ResearchEventType.M_AND_A]: SignalType.SPACE_CONSOLIDATION,
[ResearchEventType.RESTRUCTURING]: SignalType.RESTRUCTURING,
[ResearchEventType.SITE_CLOSURE]: SignalType.POSSIBLE_MOVE_OUT,
// Irrelevantes wird nie zum Lead; der Eintrag hält die Abbildung vollständig.
[ResearchEventType.IRRELEVANT]: SignalType.EXPANSION,
}
/** Ereignisse, aus denen Flächenbedarf entsteht — alles andere betrifft Bestehendes. */
const CHANCE_EVENTS = new Set<ResearchEventType>([
ResearchEventType.EXPANSION,
ResearchEventType.NEW_LOCATION,
ResearchEventType.HEADCOUNT_GROWTH,
ResearchEventType.INVESTMENT,
ResearchEventType.NEW_COMPANY,
])
/**
* Zeithorizont in Monaten aus der Ereigniszeit.
*
* Bewusst grob und bewusst nicht aus dem Publikationsdatum gerechnet: Der
* Horizont ist eine Anzeigegrösse der Liste, die belastbare Aussage steht in
* `temporalClass` und `temporalEvidence`. Eine Nachkommastelle würde hier eine
* Genauigkeit vortäuschen, die die Quelle nicht hergibt.
*/
const HORIZON_MONTHS: Record<string, number> = {
past: 0,
current: 2,
future: 9,
unknown: 6,
}
const RELEVANCE_SCORE: Record<ResearchRelevance, number> = {
high: 85,
medium: 65,
low: 40,
}
/**
* Eine stabile Kennung für denselben Sachverhalt.
*
* Sie muss über Läufe hinweg gleich bleiben: Wer zweimal auf «Research
* aktualisieren» drückt, soll denselben Artikel nicht zweimal in der Liste
* finden. Grundlage sind Quelladresse und Überschrift — beides ändert sich
* nicht, solange es derselbe Artikel ist.
*/
export function researchLeadId(lead: ResearchLead): string {
const roh = `${lead.sourceUrl}|${lead.headline}`
let hash = 0
for (let i = 0; i < roh.length; i++) {
hash = (hash * 31 + roh.charCodeAt(i)) | 0
}
return `research-${(hash >>> 0).toString(36)}`
}
export function researchLeadToSignal(lead: ResearchLead, laufZeitpunkt: string): FutureSignal {
const istChance = CHANCE_EVENTS.has(lead.eventType)
const relevanz = RELEVANCE_SCORE[lead.relevance]
return {
id: researchLeadId(lead),
signalType: SIGNAL_TYPE_BY_EVENT[lead.eventType],
companyName: lead.company,
locationHint: lead.location ?? 'Ort nicht genannt',
probability: lead.confidence,
confidenceScore: lead.confidence,
timeHorizonMonths: HORIZON_MONTHS[lead.temporalClass] ?? 6,
source: {
type: 'PRESS',
url: lead.sourceUrl,
publishedAt: lead.publishedAt,
// Eine namentlich genannte, direkt verlinkte Quelle ist belastbar; die
// Unsicherheit der Einordnung steckt in `confidenceScore`, nicht hier.
credibility: 'HIGH',
},
sensitivityLevel: 'PUBLIC',
disclaimer:
'Aus einer öffentlich zugänglichen Quelle abgeleitet. Vor jeder Ansprache am Original prüfen.',
riskLevel: istChance ? RiskLevel.LOW : RiskLevel.MEDIUM,
relevanceScore: relevanz,
isVerified: false,
createdAt: laufZeitpunkt,
updatedAt: laufZeitpunkt,
title: lead.headline,
aiSummary: lead.summary,
evidence: {
summary: lead.summary,
sourceUrls: [lead.sourceUrl],
extractedAt: laufZeitpunkt,
},
// Chance-Leads zeigen in die Nachfragerichtung — nur so nimmt die
// gemeinsame Leadliste sie auf. Risiken kommen über `leadSignalType` hinein.
signalDirection: istChance ? 'DEMAND' : 'SUPPLY',
leadSignalType: istChance ? LeadSignalType.CHANCE : LeadSignalType.RISIKO,
signalRationale: lead.reason,
recommendedAction: lead.recommendedAction,
temporalClass: lead.temporalClass,
temporalEvidence: lead.temporalEvidence || undefined,
eventDate: lead.eventDate,
semanticSearchProfile: lead.semanticSearchProfile,
origin: 'RESEARCH_RUN',
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Property On — der harte Zeitfilter (Runde 10, §2.9).
*
* Die Regel steht hier und nicht nur in der serverlosen Funktion, weil sie an
* zwei Stellen gilt: Der Lauf wendet sie an, damit unpassende Leads gar nicht
* erst entstehen, und die Anzeige wendet sie auf den bereits erfassten Bestand
* an. Zwei Fassungen derselben Regel wären zwei unterschiedliche Antworten auf
* dieselbe Frage.
*
* Zur Deutung von `unknown`: Ein Lead ohne belastbare zeitliche Aussage fällt
* **nicht** durch. Das ist eine bewusste Entscheidung — die Alternative wäre,
* ihn anhand des Publikationsdatums einzusortieren, und genau das ist der
* Fehler, den dieser Filter verhindern soll. Sichtbar bleibt er mit dem
* Vermerk, dass seine Ereigniszeit offen ist.
*/
import type { TemporalClass } from '../domain/researchLead'
export function passtZeitlich(
klasse: TemporalClass | undefined,
erlaubt: TemporalClass[],
): boolean {
// Keine Auswahl heisst keine Einschränkung — nicht «nichts anzeigen».
if (erlaubt.length === 0) return true
// Ein Bestandssignal ohne Einordnung verhält sich wie «unknown».
if (klasse === undefined || klasse === 'unknown') return true
return erlaubt.includes(klasse)
}
+87 -88
View File
@@ -24,6 +24,7 @@ import {
AgentSettingGroup,
AgentAvatarTone,
} from '../../domain/teamAgent'
import { RESEARCH_SOURCES } from '../../lib/researchSources'
export const liviaAgent: TeamAgent = {
id: 'livia',
@@ -90,36 +91,6 @@ export const liviaAgent: TeamAgent = {
requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
},
{
id: 'livia-task-02',
title: 'Geschäfts- und Jahresberichte auswerten',
description:
'Liest publizierte Geschäfts- und Jahresberichte auf Aussagen zu Standorten, Kapazitäten, Investitionen und Personalbestand. Zahlen werden mit Berichtsjahr und Fundstelle übernommen, nicht als Trend interpretiert.',
enabled: true,
schedule: 'wöchentlich, montags 06:30',
requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
},
{
id: 'livia-task-03',
title: 'Handelsregister- und Unternehmensinformationen verfolgen',
description:
'Verfolgt Mutationen im Handelsregister: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen. Eine Sitzverlegung ist der belastbarste Hinweis auf einen Standortwechsel, den es öffentlich gibt.',
enabled: true,
schedule: 'täglich 06:15',
requiresApproval: false,
dependsOnSystem: AgentSystemType.ZEFIX,
},
{
id: 'livia-task-04',
title: 'Amtliche Publikationen sichten',
description:
'Sichtet amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen und prüft, ob ein Unternehmen aus dem Beobachtungsbereich betroffen ist.',
enabled: true,
schedule: 'täglich 06:20',
requiresApproval: false,
dependsOnSystem: AgentSystemType.SHAB,
},
{
id: 'livia-task-05',
title: 'Wachstumssignale erkennen',
@@ -129,15 +100,6 @@ export const liviaAgent: TeamAgent = {
schedule: 'im Anschluss an jeden Quellenlauf',
requiresApproval: false,
},
{
id: 'livia-task-06',
title: 'Negative Entwicklungen erkennen',
description:
'Erkennt M&A-Aktivitäten, Restrukturierungen, Standortschliessungen und die Einstellung von Geschäftstätigkeiten. Diese Signale sind die wertvollsten, weil sie bestehende Mietverhältnisse betreffen und Vorlauf verschaffen.',
enabled: true,
schedule: 'im Anschluss an jeden Quellenlauf',
requiresApproval: false,
},
{
id: 'livia-task-07',
title: 'Lead als Chance oder Risiko einordnen',
@@ -184,6 +146,16 @@ export const liviaAgent: TeamAgent = {
requiresApproval: false,
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
},
{
id: 'livia-task-14',
title: 'Unsichere Signale in Review Queue legen',
description:
'Liegt die Konfidenz unter der eingestellten Schwelle oder widersprechen sich die Quellen, legt Livia den Lead in die Review-Queue der Marktbeobachtung. Solche Leads erscheinen nicht in der Lead-Liste, bevor jemand sie geprüft hat. Die Aufgabe gehörte bis Runde 10 zu Nora — sie beurteilt die Quellenlage, und die kennt Livia, nicht das Matching.',
enabled: true,
schedule: 'sobald die Konfidenz unter der Review-Schwelle liegt',
requiresApproval: false,
dependsOnSystem: AgentSystemType.WORKSPACE,
},
{
id: 'livia-task-12',
title: 'Doppelmeldungen unterdrücken',
@@ -193,16 +165,6 @@ export const liviaAgent: TeamAgent = {
schedule: 'vor jeder Übergabe',
requiresApproval: false,
},
{
id: 'livia-task-13',
title: 'Wochenüberblick zustellen',
description:
'Stellt der Bewirtschaftung montags einen Überblick der Leads der Vorwoche zu, getrennt nach Chance und Risiko, mit den jeweils empfohlenen Handlungen.',
enabled: true,
schedule: 'montags 07:00',
requiresApproval: false,
dependsOnChannel: AgentChannelType.EMAIL,
},
],
channels: [
@@ -239,13 +201,33 @@ export const liviaAgent: TeamAgent = {
],
systems: [
/*
* Die drei realen Quellen stehen seit Runde 10 (§2.3) einzeln hier statt
* gesammelt hinter einem Eintrag «Öffentliche Webquellen». Der Grund ist
* nicht Kosmetik: Der Recherchelauf liest genau diese Liste. Was hier als
* aktiver Lesezugang mit Adresse steht, wird abgerufen — und nur das.
* Ein Sammeleintrag ohne eigene Adresse wäre eine Statusanzeige ohne
* Deckung im Datenmodell.
*/
{
id: 'livia-sys-public-web',
id: 'livia-sys-zhk',
type: AgentSystemType.PUBLIC_WEB,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage:
'Verknüpfte Newsseiten und Wirtschaftsnachrichten, Geschäfts- und Jahresberichte, Mitteilungen von Handelskammern und Branchenverbänden sowie Unternehmensmeldungen. Die konkreten Adressen stehen in der Einstellung «Verknüpfte Newsquellen».',
title: RESEARCH_SOURCES.ZHK.name,
url: RESEARCH_SOURCES.ZHK.url,
usage: RESEARCH_SOURCES.ZHK.purpose,
permissionNote:
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
},
{
id: 'livia-sys-gza',
type: AgentSystemType.PUBLIC_WEB,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
title: RESEARCH_SOURCES.GZA.name,
url: RESEARCH_SOURCES.GZA.url,
usage: RESEARCH_SOURCES.GZA.purpose,
permissionNote:
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
},
@@ -254,19 +236,11 @@ export const liviaAgent: TeamAgent = {
type: AgentSystemType.ZEFIX,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage:
'Handelsregister- und Unternehmensinformationen: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen.',
title: RESEARCH_SOURCES.ZEFIX.name,
url: RESEARCH_SOURCES.ZEFIX.url,
usage: RESEARCH_SOURCES.ZEFIX.purpose,
permissionNote: 'Nur lesend. Einträge werden als Quelle mit Publikationsdatum übernommen.',
},
{
id: 'livia-sys-shab',
type: AgentSystemType.SHAB,
access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED,
usage:
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen, Arealentwicklungen und Firmenmutationen.',
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
},
{
id: 'livia-sys-immotop2',
type: AgentSystemType.IMMOTOP2,
@@ -301,35 +275,21 @@ export const liviaAgent: TeamAgent = {
settings: [
{
id: 'livia-set-news-sources',
label: 'Verknüpfte Newsquellen',
id: 'livia-set-news-documents',
label: 'Newsquellen manuell ablegen',
description:
'Newsseiten und Portale, die Livia täglich abgeht. Weitere Adressen lassen sich hier ergänzen — es braucht keine zweite Quellenverwaltung.',
kind: AgentSettingKind.MULTI_SELECT,
'Geschäftsberichte, Rundschreiben und andere Unterlagen als PDF oder Word. Ihr Text geht zusammen mit den aktiven Lesequellen aus «Systeme» in denselben Recherchelauf. Die Datei bleibt im Browser — an die Beurteilung geht nur ihr Text.',
kind: AgentSettingKind.DOCUMENTS,
group: AgentSettingGroup.SPECIFIC,
value: [
'https://www.handelszeitung.ch',
'https://www.nzz.ch/wirtschaft',
'https://www.finanzundwirtschaft.ch',
'https://www.shab.ch',
],
options: [
{ value: 'https://www.handelszeitung.ch', label: 'Handelszeitung' },
{ value: 'https://www.nzz.ch/wirtschaft', label: 'NZZ Wirtschaft' },
{ value: 'https://www.finanzundwirtschaft.ch', label: 'Finanz und Wirtschaft' },
{ value: 'https://www.shab.ch', label: 'SHAB — Amtliche Publikationen' },
{ value: 'https://www.zefix.ch', label: 'Zefix — Handelsregister' },
{ value: 'https://www.cash.ch/news', label: 'cash — Unternehmensnachrichten' },
{ value: 'https://www.zhk.ch', label: 'Zürcher Handelskammer' },
{ value: 'https://www.hkbb.ch', label: 'Handelskammer beider Basel' },
{ value: 'https://www.moneyhouse.ch', label: 'Moneyhouse — Unternehmensdaten' },
],
value: [],
},
{
id: 'livia-set-source-types',
label: 'Relevante Quellentypen',
description: 'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht.',
kind: AgentSettingKind.MULTI_SELECT,
description:
'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht. Eigene Typen lassen sich frei erfassen.',
kind: AgentSettingKind.CHIPS,
allowCustom: true,
group: AgentSettingGroup.SPECIFIC,
value: [
'geschaeftsberichte',
@@ -393,8 +353,10 @@ export const liviaAgent: TeamAgent = {
{
id: 'livia-set-observation-area',
label: 'Beobachtungsraum',
description: 'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft.',
kind: AgentSettingKind.MULTI_SELECT,
description:
'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft. Eigene Räume lassen sich frei erfassen.',
kind: AgentSettingKind.CHIPS,
allowCustom: true,
group: AgentSettingGroup.SPECIFIC,
value: ['zuerich', 'basel', 'zentralschweiz', 'ostschweiz', 'bern'],
options: [
@@ -407,6 +369,36 @@ export const liviaAgent: TeamAgent = {
{ value: 'tessin', label: 'Tessin' },
],
},
{
/*
* Der harte Zeitfilter (Runde 10, §2.9).
*
* Er steht bewusst zwischen Beobachtungsraum und Mindestrelevanz: Beide
* schränken ein, aber unterschiedlich. Der Raum sagt «wo», dieser Filter
* «wann» — und die Relevanz danach «wie stark». Alle drei aktiv zu
* lassen ist die Vorgabe, damit ein bestehender Demo-Stand nicht
* unbemerkt Ergebnisse verliert.
*/
id: 'livia-set-temporal-relevance',
label: 'Zeitliche Relevanz',
description:
'Welche Ereigniszeiten ein Lead haben darf. Massgeblich ist, wann die beschriebene Veränderung stattfindet — nicht, wann der Artikel erschienen ist.',
kind: AgentSettingKind.CHIPS,
allowCustom: false,
group: AgentSettingGroup.SPECIFIC,
value: ['past', 'current', 'future'],
options: [
{ value: 'past', label: 'Vergangene Leads' },
{ value: 'current', label: 'Aktuelle Leads' },
{ value: 'future', label: 'Zukünftige Leads' },
],
infoText:
'Vergangen: Die Veränderung ist abgeschlossen — das Unternehmen ist umgezogen, der Standort eröffnet oder geschlossen. '
+ 'Aktuell: Sie läuft gerade oder steht innerhalb von acht Wochen bevor. '
+ 'Zukünftig: Sie liegt weiter voraus oder wurde ohne kurzfristige Umsetzung angekündigt. '
+ 'Massgeblich ist immer die Ereigniszeit, nie das Publikationsdatum: Ein Jahresbericht vom Januar kann einen Standort für das dritte Quartal ankündigen — das ist ein zukünftiger Lead. '
+ 'Lässt eine Quelle keine belastbare zeitliche Aussage zu, wird kein Datum erfunden; der Lead erscheint mit dem Vermerk, dass die Ereigniszeit offen ist.',
},
{
id: 'livia-set-min-relevance',
label: 'Mindestrelevanz für einen Lead',
@@ -418,6 +410,13 @@ export const liviaAgent: TeamAgent = {
unit: '%',
min: 0,
max: 100,
infoText:
'Die KI bewertet, wie stark der Inhalt auf eine für Property Match relevante räumliche oder geschäftliche Veränderung hinweist. '
+ 'Berücksichtigt werden insbesondere die Eindeutigkeit des Signals, der Bezug zu Standort beziehungsweise Flächenbedarf, '
+ 'die Passung zum definierten Beobachtungsraum und den Quellentypen sowie die Qualität und Konkretheit der verfügbaren Informationen. '
+ 'Die zeitliche Relevanz wird separat als harter Filter angewendet und verändert den Relevanzwert nicht. '
+ 'Im aktuellen Stand vergibt die Beurteilung drei Stufen — hoch, mittel und tief. Leads der tiefen Stufe erscheinen nicht in der Liste, '
+ 'sondern zählen als Beobachtungsposten. Eine Gewichtung einzelner Kriterien in Prozent gibt es nicht.',
},
{
id: 'livia-set-tonality',
-10
View File
@@ -101,16 +101,6 @@ export const noraAgent: TeamAgent = {
schedule: 'bei jeder Klassifizierung',
requiresApproval: false,
},
{
id: 'nora-task-04',
title: 'unsichere Signale in eine Review-Queue legen',
description:
'Liegt die Konfidenz unter der eingestellten Review-Schwelle oder widersprechen sich die Quellen, legt Nora das Signal in die Review-Queue der Marktbeobachtung. Solche Signale erscheinen weder in der Wochen-Mail noch in der Lead-Liste, bevor jemand sie geprüft hat.',
enabled: true,
schedule: 'sobald die Konfidenz unter der Review-Schwelle liegt',
requiresApproval: false,
dependsOnSystem: AgentSystemType.WORKSPACE,
},
{
id: 'nora-task-05',
title: 'Signale Regionen und Objekten zuordnen',
+5
View File
@@ -17,4 +17,9 @@ export interface IFutureSignalProvider {
getByProperty(propertyId: string): Promise<FutureSignal[]>
verify(id: string, verifiedBy: string): Promise<FutureSignal>
updateReviewStatus(id: string, status: ReviewStatus): Promise<FutureSignal>
/**
* Leads eines Recherchelaufs in den Bestand aufnehmen (Runde 10, §2.12).
* Gibt zurück, wie viele erzeugte Leads danach insgesamt geführt werden.
*/
addResearchSignals(signals: FutureSignal[]): Promise<number>
}
+18
View File
@@ -0,0 +1,18 @@
import type { ResearchDocument } from '../domain/researchDocument'
/**
* Ablage der manuell hinterlegten Newsquellen (Runde 10, §2.6).
*
* Eine eigene Schnittstelle, obwohl die übrigen Demo-Bestände im Local Storage
* liegen: Dokumente sind um Grössenordnungen grösser als eine Konfiguration,
* und der Local Storage ist bei rund fünf Megabyte zu Ende. Eine PDF-Ablage
* dort hinein zu zwingen hiesse, beim dritten Dokument stillschweigend nichts
* mehr zu speichern.
*/
export interface IResearchDocumentProvider {
getAll(): Promise<ResearchDocument[]>
add(doc: ResearchDocument, blob: Blob): Promise<void>
remove(id: string): Promise<void>
/** Die Originaldatei — für das Herunterladen aus der Liste. */
getBlob(id: string): Promise<Blob | null>
}
@@ -0,0 +1,80 @@
/**
* Property On — die abgelegten Dokumente liegen in IndexedDB (Runde 10, §2.6).
*
* Keine Mockup-Variante und kein zweiter Backend-Dienst: Die Anwendung läuft
* im Browser, und der Browser hat eine Ablage, die auch Dateien verträgt. Ein
* Server nur für die Dateihaltung wäre Infrastruktur für einen einzigen Zweck.
*
* Aufgeteilt in zwei Speicher: die Metadaten samt extrahiertem Text in `meta`,
* die Originaldatei in `blobs`. So kann die Liste aufgebaut werden, ohne dass
* zwanzig Megabyte PDF durch den Speicher wandern — gebraucht wird die Datei
* erst, wenn jemand sie herunterlädt.
*/
import type { IResearchDocumentProvider } from './IResearchDocumentProvider'
import type { ResearchDocument } from '../domain/researchDocument'
const DB_NAME = 'property-on'
const DB_VERSION = 1
const STORE_META = 'research-document-meta'
const STORE_BLOBS = 'research-document-blobs'
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_META)) db.createObjectStore(STORE_META, { keyPath: 'id' })
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error ?? new Error('IndexedDB liess sich nicht öffnen.'))
})
}
/** Eine Transaktion als Versprechen — IndexedDB spricht sonst nur in Ereignissen. */
function alsVersprechen<T>(
req: IDBRequest<T>,
tx: IDBTransaction,
): Promise<T> {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error ?? new Error('Zugriff auf die Dokumentenablage fehlgeschlagen.'))
tx.onabort = () => reject(tx.error ?? new Error('Die Dokumentenablage brach die Transaktion ab.'))
})
}
export const IndexedDbResearchDocumentProvider: IResearchDocumentProvider = {
async getAll() {
const db = await openDb()
const tx = db.transaction(STORE_META, 'readonly')
const alle = await alsVersprechen(tx.objectStore(STORE_META).getAll() as IDBRequest<ResearchDocument[]>, tx)
db.close()
// Neuestes zuerst — wer eben etwas abgelegt hat, sucht es oben.
return alle.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
},
async add(doc, blob) {
const db = await openDb()
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
tx.objectStore(STORE_META).put(doc)
await alsVersprechen(tx.objectStore(STORE_BLOBS).put(blob, doc.id), tx)
db.close()
},
async remove(id) {
const db = await openDb()
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
tx.objectStore(STORE_META).delete(id)
await alsVersprechen(tx.objectStore(STORE_BLOBS).delete(id), tx)
db.close()
},
async getBlob(id) {
const db = await openDb()
const tx = db.transaction(STORE_BLOBS, 'readonly')
const blob = await alsVersprechen(tx.objectStore(STORE_BLOBS).get(id) as IDBRequest<Blob | undefined>, tx)
db.close()
return blob ?? null
},
}
+30 -1
View File
@@ -2,14 +2,27 @@ import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignal
import type { FutureSignal } from '../domain/futureSignal'
import type { ReviewStatus } from '../domain/enums'
import { mockFutureSignals } from '../mock-data/futureSignals'
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
// Mutation overrides — applied on top of the live mockFutureSignals import.
// Reading directly from mockFutureSignals (not a copied store) ensures that
// Vite HMR changes to the mock data are always reflected immediately.
const mutationOverrides = new Map<string, Partial<FutureSignal>>()
/**
* Leads aus Rechercheläufen (Runde 10, §2.12).
*
* Sie liegen in derselben Ablage wie der Seed und werden von `resolve()` in
* dieselbe Liste gegeben — es gibt bewusst keinen zweiten Bestand, den Nora
* separat abfragen müsste. Getrennt ist nur die Persistenz: der Seed steht im
* Code, die erzeugten Leads im Local Storage, damit ein Reload sie behält.
*/
let researchSignals: FutureSignal[] =
loadVersioned<FutureSignal[]>(TEAM_STORAGE_KEYS.RESEARCH_LEADS) ?? []
function resolve(): FutureSignal[] {
return mockFutureSignals.map(s => {
// Erzeugte Leads zuerst: der jüngste Lauf steht oben, wo man ihn sucht.
return [...researchSignals, ...mockFutureSignals].map(s => {
const override = mutationOverrides.get(s.id)
return override ? { ...s, ...override } : s
})
@@ -43,4 +56,20 @@ export const MockupFutureSignalProvider: IFutureSignalProvider = {
mutationOverrides.set(id, { ...existing, reviewStatus: status, updatedAt: new Date().toISOString() })
return resolve().find(s => s.id === id)!
},
/**
* Leads eines Recherchelaufs aufnehmen.
*
* Gleiche Kennung heisst gleicher Sachverhalt: Ein zweiter Lauf über
* denselben Artikel ersetzt den bestehenden Eintrag, statt ihn zu
* verdoppeln. Sonst wüchse die Liste bei jedem Knopfdruck um Dubletten, und
* niemand könnte mehr sagen, welcher Stand gilt.
*/
async addResearchSignals(signals: FutureSignal[]) {
const bekannt = new Map(researchSignals.map(s => [s.id, s]))
for (const s of signals) bekannt.set(s.id, s)
researchSignals = [...bekannt.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
persistVersioned(TEAM_STORAGE_KEYS.RESEARCH_LEADS, researchSignals)
return researchSignals.length
},
}
+5 -1
View File
@@ -17,6 +17,10 @@ export const TEAM_STORAGE_KEYS = {
WORK_ITEMS: `${PREFIX}work-items`,
PROTOCOL: `${PREFIX}protocol`,
CONNECTIONS: `${PREFIX}connections`,
/** Von einem Recherchelauf erzeugte Leads (Runde 10, §2.12). */
RESEARCH_LEADS: `${PREFIX}research-leads`,
/** Manuell abgelegte Newsquellen als Dokumente (Runde 10, §2.6). */
RESEARCH_DOCUMENTS: `${PREFIX}research-documents`,
} as const
export type TeamStorageKey = typeof TEAM_STORAGE_KEYS[keyof typeof TEAM_STORAGE_KEYS]
@@ -51,7 +55,7 @@ export function persist<T>(key: TeamStorageKey, value: T): void {
* unter `src/domain/` ändert — sonst überlagert ein alter Local-Storage-Eintrag
* die neuen Mockdaten und die Demo zeigt stillschweigend veraltete Inhalte.
*/
export const TEAM_SCHEMA_VERSION = 1
export const TEAM_SCHEMA_VERSION = 2
interface PersistedEnvelope<T> {
version: number
+24
View File
@@ -5,6 +5,8 @@ import type { ReviewStatus } from '../domain/enums'
import type { FutureSignalSummary } from '../domain/dashboard'
import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors'
import { researchLeadToSignal } from '../lib/researchLeadMapper'
import type { ResearchLead } from '../domain/researchLead'
const provider = MockupFutureSignalProvider
@@ -50,6 +52,28 @@ export const futureSignalService = {
}
},
/**
* Die Leads eines Recherchelaufs in den zentralen Bestand übernehmen
* (Runde 10, §2.12).
*
* Die Umwandlung von Recherche-Lead zu Signal passiert bewusst hier und
* nicht in der Komponente: Sie ist fachliche Logik, und sie muss für jeden
* künftigen Auslöser eines Laufs — Knopfdruck, Zeitplan, Chat — dieselbe
* sein.
*/
async importResearchLeads(
leads: ResearchLead[],
refreshedAt: string,
): Promise<ItemResponse<{ imported: number; total: number }>> {
try {
const signals = leads.map(l => researchLeadToSignal(l, refreshedAt))
const total = await provider.addResearchSignals(signals)
return { data: { imported: signals.length, total } }
} catch (err) {
throwServiceError(err)
}
},
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
try {
const data = await provider.getByProperty(propertyId)
+65
View File
@@ -0,0 +1,65 @@
/**
* Property On — manuell abgelegte Newsquellen (Runde 10, §2.6).
*
* Der Dienst nimmt die Datei entgegen, gewinnt einmal ihren Text und legt
* beides ab. Die Textextraktion gehört hierher und nicht in die Komponente:
* Sie ist fachliche Arbeit, und sie soll für jeden künftigen Weg ins System —
* Upload, Mailanhang, Ablageordner — dieselbe sein.
*/
import { IndexedDbResearchDocumentProvider } from '../provider/IndexedDbResearchDocumentProvider'
import type { ResearchDocument } from '../domain/researchDocument'
import { RESEARCH_DOCUMENT_MAX_BYTES } from '../domain/researchDocument'
import { extractDocumentText, kindOf } from '../lib/documentText'
import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors'
const provider = IndexedDbResearchDocumentProvider
export const researchDocumentService = {
async getAll(): Promise<ListResponse<ResearchDocument>> {
try {
const data = await provider.getAll()
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
} catch (err) {
throwServiceError(err)
}
},
async upload(file: File): Promise<ItemResponse<ResearchDocument>> {
const kind = kindOf(file.name)
if (!kind) {
throw new Error('Nur PDF- und Word-Dateien (.pdf, .docx) können abgelegt werden.')
}
if (file.size > RESEARCH_DOCUMENT_MAX_BYTES) {
throw new Error(
`Die Datei ist zu gross (${Math.round(file.size / 1024 / 1024)} MB). Höchstens ${RESEARCH_DOCUMENT_MAX_BYTES / 1024 / 1024} MB.`,
)
}
try {
const { text, note } = await extractDocumentText(file, kind)
const doc: ResearchDocument = {
id: `doc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
kind,
size: file.size,
uploadedAt: new Date().toISOString(),
text,
...(note ? { extractionNote: note } : {}),
}
await provider.add(doc, file)
return { data: doc }
} catch (err) {
throwServiceError(err)
}
},
async remove(id: string): Promise<void> {
try {
await provider.remove(id)
} catch (err) {
throwServiceError(err)
}
},
}