feat(livia): Research-PoC — drei reale Zürcher Quellen live
Livia liest jetzt tatsächlich aus dem Netz statt aus Mockdaten. Ein Knopf «Research aktualisieren» ruft serverseitig drei Quellen ab, beurteilt die Einträge in einem einzigen LLM-Aufruf und zeigt nur an, was plausibel zu Flächenbedarf oder Flächenfreisetzung führen könnte. Kein Crawler, kein Scheduler, keine Queue, keine Datenbank. Der Hoster kann bereits serverlose Funktionen — es hat bisher nur niemand eine gebraucht. Deshalb genügt eine einzige Datei unter `api/`, drei Abruffunktionen, eine Normalisierung und ein Analyseaufruf. Quellen, alle drei live geprüft: - Zürcher Handelskammer, 10 Artikel - Greater Zurich Area, 9 Artikel - Zefix / Open Data Kanton Zürich, CSV direkt Zur Zefix-Quelle eine Feststellung, die von der Aufgabenstellung abweicht: die CSV enthält keine Firmennamen. Sie führt Tageszahlen von Neugründungen je NOGA-Branche für den Kanton Zürich. Daraus lässt sich kein Unternehmenslead bauen, sondern nur ein aggregiertes Frühsignal zum Zielmarkt — und genau so wird es übergeben. Namen zu erfinden, um das erwartete Format zu treffen, wäre die eine Sache, die Livia nie tun darf. Die drei Quellen stehen im Frontend unter «Angebundene Kanäle & Systeme» mit Status und anklickbarem Originallink; jeder erzeugte Lead verlinkt zusätzlich die Seite, aus der er stammt. Die Adressen kommen aus derselben Liste, die der Abruf benutzt — der angezeigte Link kann damit nicht von dem abweichen, was tatsächlich gelesen wurde. Der Textextraktor sucht den Artikeltext im engsten passenden Container. Ohne das standen bei der Handelskammer Telefonnummern und Öffnungszeiten am Anfang jedes Textes, und das Modell hätte die Fusszeile mitbeurteilt. Der LLM-Schlüssel liegt ausschliesslich serverseitig in `ANTHROPIC_API_KEY`. Fehlt er, sagt die Oberfläche offen, dass gelesen, aber nicht beurteilt wurde — statt eine leere Liste als «nichts gefunden» auszugeben. Für die lokale Entwicklung mountet ein Vite-Plugin dieselbe Handler-Datei, die Vercel in der Produktion ausliefert; es gibt keine zweite Fassung des Endpoints. `tsconfig.api.json` nimmt `api/` in `tsc -b` auf, damit ein Tippfehler dort nicht erst beim Deployment auffällt. Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün, Build erfolgreich, Live-Lauf im Browser mit 20 real gelesenen Einträgen aus 3/3 Quellen ohne Laufzeitfehler. Die LLM-Beurteilung selbst ist mangels Schlüssel noch ungetestet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
37b6270f50
commit
2a0ba287f6
16 files changed
+1509
-31
No files matched your search
@@ -0,0 +1,124 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { ExternalLink, MapPin } from 'lucide-react'
|
||||
import type { ResearchLead } from '../../domain/researchLead'
|
||||
import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Ein aus einer echten Quelle abgeleiteter Lead (§16).
|
||||
*
|
||||
* Die Karte beantwortet in fester Reihenfolge: was ist passiert, bei wem, wo,
|
||||
* warum ist es für Gewerbeflächen relevant, wie sicher ist die Einordnung und
|
||||
* woher stammt sie. Der Quellenlink steht unten und ist echt — er zeigt auf
|
||||
* genau die Seite, die Livia gelesen hat.
|
||||
*
|
||||
* Confidence steht als Zahl da und nicht als Balken: ein Balken suggeriert
|
||||
* Messgenauigkeit, die eine Modellschätzung nicht hat.
|
||||
*/
|
||||
export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead: ResearchLead }) {
|
||||
const hoch = lead.relevance === 'high'
|
||||
const akzent = hoch ? DS_COLORS.leadSignal.CHANCE : { bg: DS_SLATE[100], fg: DS_SLATE[600] }
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: hoch ? DS_COLORS.leadSignal.CHANCE.fg : DS_SLATE[300],
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'white',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, color: DS_SLATE[900], lineHeight: 1.35, mb: 0.75 }}>
|
||||
{lead.headline}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={RESEARCH_EVENT_TYPE_LABELS[lead.eventType] ?? lead.eventType}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: akzent.bg, color: akzent.fg }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{RESEARCH_RELEVANCE_LABELS[lead.relevance]}
|
||||
</Typography>
|
||||
{lead.location && (
|
||||
<Chip
|
||||
icon={<MapPin size={11} />}
|
||||
label={lead.location}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20, fontSize: '0.72rem', bgcolor: DS_SLATE[100], color: DS_SLATE[600],
|
||||
'& .MuiChip-icon': { color: DS_SLATE[600], ml: 0.5, mr: -0.25 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 0.25 }}>
|
||||
Unternehmen
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 1 }}>
|
||||
{lead.company}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mb: 1.25 }}>
|
||||
{lead.summary}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||
>
|
||||
Warum relevant
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: lead.recommendedAction ? 1.25 : 1.5 }}>
|
||||
{lead.reason}
|
||||
</Typography>
|
||||
|
||||
{lead.recommendedAction && (
|
||||
<>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||
>
|
||||
Empfohlene nächste Handlung
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
|
||||
{lead.recommendedAction}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1,
|
||||
pt: 1.25, borderTop: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||
Quelle: <strong>{lead.source}</strong>
|
||||
{lead.publishedAt && ` · ${new Date(lead.publishedAt).toLocaleDateString('de-CH')}`}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||
Confidence: <strong>{Math.round(lead.confidence * 100)} %</strong>
|
||||
</Typography>
|
||||
<Box
|
||||
component="a"
|
||||
href={lead.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, ml: 'auto',
|
||||
color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Originalquelle öffnen <ExternalLink size={11} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'
|
||||
import type { ResearchSourceResult } from '../../domain/researchLead'
|
||||
import { RESEARCH_SOURCE_LIST } from '../../lib/researchSources'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
/** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */
|
||||
results?: ResearchSourceResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15).
|
||||
*
|
||||
* Zeigt die drei real abgerufenen Quellen mit Namen, Status und anklickbarem
|
||||
* Originallink. Die Links stehen hier nicht als Zierde: wer eine Aussage von
|
||||
* Livia prüfen will, soll die Quelle in einem Klick öffnen können, ohne sie
|
||||
* selbst suchen zu müssen.
|
||||
*
|
||||
* Vor dem ersten Lauf steht «Bereit» und nicht «Verbunden» — verbunden ist,
|
||||
* was tatsächlich geantwortet hat, und das weiss man erst danach.
|
||||
*/
|
||||
export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results }: Props) {
|
||||
return (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: 'white', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}
|
||||
>
|
||||
Angebundene Kanäle & Systeme
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{RESEARCH_SOURCE_LIST.map(quelle => {
|
||||
const ergebnis = results?.find(r => r.id === quelle.id)
|
||||
return (
|
||||
<Box
|
||||
key={quelle.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.5,
|
||||
px: 2, py: 1.5,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
'&:last-of-type': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||
{quelle.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', lineHeight: 1.4 }}>
|
||||
{quelle.purpose}
|
||||
</Typography>
|
||||
<Box
|
||||
component="a"
|
||||
href={quelle.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.5,
|
||||
color: DS_BRAND.main, fontSize: '0.72rem', fontWeight: 600,
|
||||
textDecoration: 'none', wordBreak: 'break-all',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{quelle.url} <ExternalLink size={11} style={{ flexShrink: 0 }} />
|
||||
</Box>
|
||||
{ergebnis?.error && (
|
||||
<Typography variant="caption" sx={{ color: DS_COLORS.leadSignal.RISIKO.fg, display: 'block', mt: 0.25 }}>
|
||||
{ergebnis.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, pt: 0.25 }}>
|
||||
{ergebnis === undefined ? (
|
||||
<Chip
|
||||
label="Bereit"
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: DS_SLATE[100], color: DS_SLATE[600] }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
icon={ergebnis.ok ? <CheckCircle2 size={11} /> : <XCircle size={11} />}
|
||||
label={ergebnis.ok ? `Live · ${ergebnis.itemCount}` : 'Nicht erreichbar'}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20, fontSize: '0.68rem', fontWeight: 700,
|
||||
bgcolor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.bg : DS_COLORS.leadSignal.RISIKO.bg,
|
||||
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
border: '1px solid',
|
||||
borderColor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
'& .MuiChip-icon': {
|
||||
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
ml: 0.5, mr: -0.25,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -1,27 +1,30 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
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 { LeadChannel } from '../../domain/unifiedLead'
|
||||
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||
import { LeadSignalType } from '../../domain/futureSignal'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1).
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15).
|
||||
*
|
||||
* Die Liste ist dieselbe Komponente wie bei Nora — bewusst, damit sich beide
|
||||
* Arbeitsplätze gleich lesen und eine spätere Änderung nur an einer Stelle
|
||||
* anfällt. Zwei Unterschiede: die Kanalspalte entfällt, weil hier jeder Eintrag
|
||||
* aus Livias eigener Recherche stammt, und die Detailansicht läuft mit
|
||||
* `showMatching={false}`.
|
||||
* Zwei Bestände nebeneinander, bewusst getrennt gehalten:
|
||||
*
|
||||
* Damit ist die Grenze zwischen den beiden Agentinnen im Code gezogen und nicht
|
||||
* bloss in der Beschriftung: Livia recherchiert und verdichtet, das Matching
|
||||
* gegen den eigenen Bestand bleibt Noras Arbeit (§1, §2).
|
||||
* 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.
|
||||
*
|
||||
* Gelesen wird aus demselben Bestand, den Nora anzeigt. Es gibt keine zweite
|
||||
* Lead-Ablage und keine Übergabekopie — ein Lead, zwei Arbeitsplätze.
|
||||
* 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.
|
||||
*/
|
||||
export function ResearchWorkspace() {
|
||||
const theme = useTheme()
|
||||
@@ -29,6 +32,8 @@ export function ResearchWorkspace() {
|
||||
|
||||
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||
const { data: leads, isLoading } = useUnifiedLeads()
|
||||
const refresh = useLiviaResearch()
|
||||
const ergebnis = refresh.data
|
||||
|
||||
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||
const { research, chancen, risiken } = useMemo(() => {
|
||||
@@ -45,24 +50,82 @@ export function ResearchWorkspace() {
|
||||
}, [leads])
|
||||
|
||||
const closeDetail = useCallback(() => setSelected(null), [])
|
||||
const starten = useCallback(() => refresh.mutate(), [refresh])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{isLoading
|
||||
? 'Leads werden geladen …'
|
||||
: `${research.length} ${research.length === 1 ? 'Lead' : 'Leads'} aus externen Quellen · ` +
|
||||
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* ── Live-Recherche ──────────────────────────────────────────────── */}
|
||||
<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}
|
||||
onClick={starten}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Research aktualisieren
|
||||
</Button>
|
||||
|
||||
<UnifiedLeadList
|
||||
leads={research}
|
||||
isLoading={isLoading}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
showChannel={false}
|
||||
emptyText="Noch keine Leads aus der Marktbeobachtung. Sobald eine verknüpfte Quelle eine relevante Veränderung meldet, erscheint sie hier."
|
||||
/>
|
||||
{refresh.isPending && (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Livia liest aktuelle Marktinformationen …
|
||||
</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'}`}
|
||||
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResearchSourcesPanel results={ergebnis?.sources} />
|
||||
|
||||
{ergebnis?.analysisError && (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||
Die Quellen wurden gelesen, die Beurteilung konnte aber nicht laufen: {ergebnis.analysisError}
|
||||
</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.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Erfasste Leads ──────────────────────────────────────────────── */}
|
||||
<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
|
||||
? 'Leads werden geladen …'
|
||||
: `${research.length} ${research.length === 1 ? 'erfasster Lead' : 'erfasste Leads'} · ` +
|
||||
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
||||
</Typography>
|
||||
|
||||
<UnifiedLeadList
|
||||
leads={research}
|
||||
isLoading={isLoading}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
showChannel={false}
|
||||
emptyText="Noch keine erfassten Leads. Was Livia gerade live gelesen hat, steht oberhalb."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Livia — Markt-Recherche (Runde 8, §1). Barrel-Export (CLAUDE.md §13).
|
||||
// Livia — Markt-Recherche (Runde 8, §1; Research-PoC). Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
export { ResearchWorkspace } from './ResearchWorkspace'
|
||||
export { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||
export { ResearchLeadCard } from './ResearchLeadCard'
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Property On — Livias Research-PoC: echte Quellen statt Mockdaten.
|
||||
*
|
||||
* Diese Typen werden von beiden Seiten benutzt — von der serverlosen Funktion
|
||||
* unter `api/` und vom Frontend. Sie liegen deshalb im Domänenordner und nicht
|
||||
* neben dem Endpoint: ein zweites, fast gleiches Typenpaar wäre die erste
|
||||
* Stelle, an der Server und Oberfläche auseinanderlaufen.
|
||||
*
|
||||
* Bewusst klein gehalten (PoC, §9/§13). Es ist ausdrücklich **nicht** der
|
||||
* `FutureSignal`-Typ: der trägt Wahrscheinlichkeiten, Datenqualität und
|
||||
* Portfoliobezug, die eine Live-Recherche nicht liefern kann. Ein Feld zu
|
||||
* füllen, für das keine Grundlage existiert, wäre genau die Behauptung, die
|
||||
* Livia nicht aufstellen darf.
|
||||
*/
|
||||
|
||||
/** Die neun Kategorien des PoC (§11). */
|
||||
export const ResearchEventType = {
|
||||
EXPANSION: 'EXPANSION',
|
||||
NEW_LOCATION: 'NEW_LOCATION',
|
||||
HEADCOUNT_GROWTH: 'HEADCOUNT_GROWTH',
|
||||
INVESTMENT: 'INVESTMENT',
|
||||
M_AND_A: 'M_AND_A',
|
||||
RESTRUCTURING: 'RESTRUCTURING',
|
||||
SITE_CLOSURE: 'SITE_CLOSURE',
|
||||
NEW_COMPANY: 'NEW_COMPANY',
|
||||
IRRELEVANT: 'IRRELEVANT',
|
||||
} as const
|
||||
export type ResearchEventType = typeof ResearchEventType[keyof typeof ResearchEventType]
|
||||
|
||||
export type ResearchRelevance = 'high' | 'medium' | 'low'
|
||||
|
||||
/** Ein von Livia aus einer echten Quelle abgeleiteter Lead (§13). */
|
||||
export interface ResearchLead {
|
||||
company: string
|
||||
eventType: ResearchEventType
|
||||
headline: string
|
||||
location?: string
|
||||
summary: string
|
||||
relevance: ResearchRelevance
|
||||
/** 0–1. Wie sicher ist die Einordnung angesichts der Quellenlage? */
|
||||
confidence: number
|
||||
reason: string
|
||||
recommendedAction?: string
|
||||
/** Anzeigename der Quelle, z. B. «Zürcher Handelskammer». */
|
||||
source: string
|
||||
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
|
||||
sourceUrl: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
/** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
|
||||
export interface ResearchSourceResult {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
ok: boolean
|
||||
/** Gelesene Einträge; 0, wenn die Quelle nicht erreichbar war. */
|
||||
itemCount: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Antwort des Refresh-Endpoints. */
|
||||
export interface ResearchRefreshResult {
|
||||
sources: ResearchSourceResult[]
|
||||
/** Wie viele Einträge insgesamt gelesen wurden. */
|
||||
analyzed: number
|
||||
/** Davon als nicht relevant verworfen — der Kern des PoC (§14). */
|
||||
discarded: number
|
||||
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||
watchlist: number
|
||||
/** Nur `high` und `medium` erscheinen hier. */
|
||||
leads: ResearchLead[]
|
||||
/** Zeitpunkt des Laufs, ISO. */
|
||||
refreshedAt: string
|
||||
/**
|
||||
* Gesetzt, wenn die Analyse nicht laufen konnte — etwa weil serverseitig
|
||||
* kein Schlüssel hinterlegt ist. Die Oberfläche sagt das dann offen, statt
|
||||
* eine leere Liste als «nichts gefunden» auszugeben.
|
||||
*/
|
||||
analysisError?: string
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { ResearchRefreshResult } from '../domain/researchLead'
|
||||
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
/**
|
||||
* «Research aktualisieren» — der eine Aufruf des PoC.
|
||||
*
|
||||
* Eine Mutation und keine Query: der Lauf ist eine Handlung des Nutzers, kein
|
||||
* Zustand, der sich von selbst aktualisiert. Deshalb auch bewusst kein
|
||||
* `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).
|
||||
*/
|
||||
export function useLiviaResearch() {
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
return useMutation<ResearchRefreshResult, Error>({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, { method: 'POST' })
|
||||
|
||||
// Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
|
||||
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall
|
||||
// in der lokalen Entwicklung und verdient eine klare Meldung statt eines
|
||||
// «Unexpected token <».
|
||||
const typ = res.headers.get('content-type') ?? ''
|
||||
if (!typ.includes('application/json')) {
|
||||
throw new Error(
|
||||
'Der Recherche-Endpoint antwortet nicht. Lokal braucht es dafür «vercel dev» statt «vite».',
|
||||
)
|
||||
}
|
||||
if (!res.ok) throw new Error(`Der Recherche-Lauf schlug fehl (HTTP ${res.status}).`)
|
||||
|
||||
return await res.json() as ResearchRefreshResult
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -356,6 +356,26 @@ export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
RISIKO: 'Risiko',
|
||||
}
|
||||
|
||||
// ── Livia — Recherche aus realen Quellen (PoC, §11/§13) ──────────────────────
|
||||
|
||||
export const RESEARCH_EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
EXPANSION: 'Expansion',
|
||||
NEW_LOCATION: 'Neuer Standort',
|
||||
HEADCOUNT_GROWTH: 'Personalwachstum',
|
||||
INVESTMENT: 'Investition',
|
||||
M_AND_A: 'M&A',
|
||||
RESTRUCTURING: 'Restrukturierung',
|
||||
SITE_CLOSURE: 'Standortschliessung',
|
||||
NEW_COMPANY: 'Neugründung',
|
||||
IRRELEVANT: 'Nicht relevant',
|
||||
}
|
||||
|
||||
export const RESEARCH_RELEVANCE_LABELS: Record<string, string> = {
|
||||
high: 'hohe Relevanz',
|
||||
medium: 'mittlere Relevanz',
|
||||
low: 'geringe Relevanz',
|
||||
}
|
||||
|
||||
|
||||
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Property On — die real angebundenen Recherchequellen von Livia.
|
||||
*
|
||||
* Eine einzige Liste für beide Seiten: die serverlose Funktion liest daraus
|
||||
* die abzurufenden Adressen, die Oberfläche zeigt daraus «Angebundene Kanäle
|
||||
* & Systeme». Stünden die URLs an zwei Stellen, könnte der angezeigte Link
|
||||
* irgendwann auf etwas anderes zeigen als der tatsächliche Abruf — und genau
|
||||
* die Nachvollziehbarkeit ist der Zweck der Anzeige (§2).
|
||||
*
|
||||
* Die Datei enthält bewusst nur Daten und keine Abruflogik: so kann `api/`
|
||||
* sie importieren, ohne dass der HTML-Parser im Frontend-Bundle landet.
|
||||
*/
|
||||
|
||||
export interface ResearchSourceDescriptor {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
/** Wofür die Quelle im PoC steht — erscheint in der Oberfläche. */
|
||||
purpose: string
|
||||
}
|
||||
|
||||
export const RESEARCH_SOURCES = {
|
||||
ZHK: {
|
||||
id: 'zhk',
|
||||
name: 'Zürcher Handelskammer — News',
|
||||
url: 'https://www.zhk.ch/de/wirtschaft-und-politik/news-liste.html',
|
||||
purpose: 'Regionale Unternehmensmeldungen, Expansion, Investitionen, Kapazitätsausbau',
|
||||
},
|
||||
GZA: {
|
||||
id: 'gza',
|
||||
name: 'Greater Zurich Area — News',
|
||||
url: 'https://www.greaterzuricharea.com/de/alle-news',
|
||||
purpose: 'Ansiedlungen, Markteintritte, internationale Expansionen und neue Standorte',
|
||||
},
|
||||
ZEFIX: {
|
||||
id: 'zefix',
|
||||
name: 'Zefix / Open Data Kanton Zürich',
|
||||
url: 'https://www.web.statistik.zh.ch/ogd/data/KANTON_ZUERICH_ZEFIX_Neugruendungen.csv',
|
||||
purpose: 'Strukturierte Daten zu Zürcher Neugründungen; CSV wird direkt eingelesen',
|
||||
},
|
||||
} as const satisfies Record<string, ResearchSourceDescriptor>
|
||||
|
||||
export const RESEARCH_SOURCE_LIST: ResearchSourceDescriptor[] = [
|
||||
RESEARCH_SOURCES.ZHK,
|
||||
RESEARCH_SOURCES.GZA,
|
||||
RESEARCH_SOURCES.ZEFIX,
|
||||
]
|
||||
|
||||
/** Der eine Endpoint des PoC (§20). */
|
||||
export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh'
|
||||
Reference in new issue
Block a user