From 2a0ba287f6fabf0ed93ff9fa23a29e945a3d5273 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Sun, 30 Aug 2026 14:23:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(livia):=20Research-PoC=20=E2=80=94=20drei?= =?UTF-8?q?=20reale=20Z=C3=BCrcher=20Quellen=20live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/_lib/researchAnalysis.ts | 153 +++++++ api/_lib/researchSources.ts | 262 ++++++++++++ api/livia/research/refresh.ts | 107 +++++ package-lock.json | 401 +++++++++++++++++- package.json | 2 + src/components/research/ResearchLeadCard.tsx | 124 ++++++ .../research/ResearchSourcesPanel.tsx | 107 +++++ src/components/research/ResearchWorkspace.tsx | 119 ++++-- src/components/research/index.ts | 4 +- src/domain/researchLead.ts | 81 ++++ src/hooks/useLiviaResearch.ts | 42 ++ src/lib/constants.ts | 20 + src/lib/researchSources.ts | 50 +++ tsconfig.api.json | 30 ++ tsconfig.json | 3 +- vite.config.ts | 35 ++ 16 files changed, 1509 insertions(+), 31 deletions(-) create mode 100644 api/_lib/researchAnalysis.ts create mode 100644 api/_lib/researchSources.ts create mode 100644 api/livia/research/refresh.ts create mode 100644 src/components/research/ResearchLeadCard.tsx create mode 100644 src/components/research/ResearchSourcesPanel.tsx create mode 100644 src/domain/researchLead.ts create mode 100644 src/hooks/useLiviaResearch.ts create mode 100644 src/lib/researchSources.ts create mode 100644 tsconfig.api.json diff --git a/api/_lib/researchAnalysis.ts b/api/_lib/researchAnalysis.ts new file mode 100644 index 0000000..efc2f77 --- /dev/null +++ b/api/_lib/researchAnalysis.ts @@ -0,0 +1,153 @@ +/** + * Livia Research-PoC — die Beurteilung der gelesenen Einträge. + * + * Ein einziger Aufruf für alle Einträge statt einer Anfrage je Artikel (§22). + * Das hält Laufzeit und Kosten klein und gibt dem Modell zugleich den + * Gesamtblick, den es für die Einordnung «relevant oder nicht» braucht. + * + * Der Schlüssel steht ausschliesslich serverseitig in `ANTHROPIC_API_KEY` — + * diese Datei läuft in der serverlosen Funktion und landet nie im Bundle (§10). + */ + +import Anthropic from '@anthropic-ai/sdk' +import { z } from 'zod' +import type { ResearchItem } from './researchSources' +import type { ResearchLead } from '../../src/domain/researchLead' + +/** + * Der Beurteilungsauftrag (§12). + * + * Bewusst kurz. Zwei Sätze tragen die eigentliche Arbeit: der Relevanzbegriff + * und das Verbot, etwas hinzuzuerfinden. Alles Weitere wäre Ausschmückung, die + * das Modell eher zu geschmeidigen als zu belegten Antworten verleitet. + */ +const SYSTEM_PROMPT = `Du bist Livia, Research & Market Intelligence Agent für professionelle Schweizer Gewerbeimmobilien-Bewirtschaftungen. + +Analysiere die bereitgestellten Unternehmensmeldungen. + +Relevant sind nur Entwicklungen, die plausibel zu zusätzlichem Gewerbeflächenbedarf, einem neuen Standort, einer Standortveränderung, Flächenreduktion oder zukünftiger Wiedervermietung führen könnten. + +Allgemeine Unternehmensnachrichten ohne plausiblen Immobilienbezug sind IRRELEVANT. + +Bewerte konservativ. Erfinde keine Informationen, die nicht in der Quelle stehen. Wenn eine Meldung keinen Firmennamen nennt, schreibe in "company" das, was die Quelle tatsächlich benennt — erfinde keinen Namen. + +Antworte ausschliesslich mit einem JSON-Objekt der Form: +{"results":[{"index":0,"company":"...","eventType":"EXPANSION","headline":"...","location":"...","summary":"...","relevance":"high","confidence":0.8,"reason":"...","recommendedAction":"..."}]} + +Gib für JEDEN Eingabeeintrag genau ein Ergebnis mit dem passenden "index" zurück. + +eventType ist einer von: EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT, M_AND_A, RESTRUCTURING, SITE_CLOSURE, NEW_COMPANY, IRRELEVANT. +relevance ist "high", "medium" oder "low". confidence ist eine Zahl zwischen 0 und 1. +Deutsch, Schweizer Rechtschreibung (ss statt ß). Keine langen Analysen.` + +const AnalysisSchema = z.object({ + results: z.array(z.object({ + index: z.number().int().min(0), + company: z.string().min(1), + eventType: z.enum([ + 'EXPANSION', 'NEW_LOCATION', 'HEADCOUNT_GROWTH', 'INVESTMENT', + 'M_AND_A', 'RESTRUCTURING', 'SITE_CLOSURE', 'NEW_COMPANY', 'IRRELEVANT', + ]), + headline: z.string().min(1), + location: z.string().optional(), + summary: z.string().min(1), + relevance: z.enum(['high', 'medium', 'low']), + confidence: z.number().min(0).max(1), + reason: z.string().min(1), + recommendedAction: z.string().optional(), + })), +}) + +export interface AnalysisOutcome { + leads: ResearchLead[] + /** Einträge ohne plausiblen Immobilienbezug. */ + discarded: number + /** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */ + watchlist: number +} + +/** Das erste JSON-Objekt aus der Antwort — falls das Modell doch etwas drumherum schreibt. */ +function extractJson(text: string): unknown { + const trimmed = text.trim() + const start = trimmed.indexOf('{') + const end = trimmed.lastIndexOf('}') + if (start === -1 || end <= start) throw new Error('Antwort enthält kein JSON-Objekt') + return JSON.parse(trimmed.slice(start, end + 1)) +} + +export async function analyzeResearchItems(items: ResearchItem[]): Promise { + if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0 } + + const apiKey = process.env.ANTHROPIC_API_KEY + if (!apiKey) { + throw new Error( + 'ANTHROPIC_API_KEY ist serverseitig nicht gesetzt — ohne Schlüssel findet keine Beurteilung statt.', + ) + } + + const client = new Anthropic({ apiKey }) + + const eingabe = items + .map((it, i) => [ + `### Eintrag ${i}`, + `Quelle: ${it.source}`, + `Titel: ${it.title}`, + it.publishedAt ? `Publiziert: ${it.publishedAt}` : '', + `Text: ${it.text.slice(0, 2000)}`, + ].filter(Boolean).join('\n')) + .join('\n\n') + + const response = await client.messages.create({ + model: 'claude-opus-5', + max_tokens: 16000, + system: SYSTEM_PROMPT, + // Einordnen ist eine Routineaufgabe — niedrige Stufe spart Zeit und Kosten, + // ohne das Modell zu wechseln. + output_config: { effort: 'low' }, + messages: [{ role: 'user', content: eingabe }], + }) + + if (response.stop_reason === 'refusal') { + throw new Error('Die Beurteilung wurde vom Modell abgelehnt.') + } + + const text = response.content + .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .map(b => b.text) + .join('') + + const parsed = AnalysisSchema.parse(extractJson(text)) + + const leads: ResearchLead[] = [] + let discarded = 0 + let watchlist = 0 + + for (const r of parsed.results) { + const item = items[r.index] + if (!item) continue + + if (r.eventType === 'IRRELEVANT') { discarded += 1; continue } + if (r.relevance === 'low') { watchlist += 1; continue } + + leads.push({ + company: r.company, + eventType: r.eventType, + headline: r.headline, + location: r.location, + summary: r.summary, + relevance: r.relevance, + confidence: r.confidence, + reason: r.reason, + recommendedAction: r.recommendedAction, + source: item.source, + sourceUrl: item.url, + publishedAt: item.publishedAt, + }) + } + + // Stärkste Einordnung zuerst — die Liste soll oben beginnen, wo gehandelt wird. + leads.sort((a, b) => + (a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1)) + + return { leads, discarded, watchlist } +} diff --git a/api/_lib/researchSources.ts b/api/_lib/researchSources.ts new file mode 100644 index 0000000..acde4d4 --- /dev/null +++ b/api/_lib/researchSources.ts @@ -0,0 +1,262 @@ +/** + * Livia Research-PoC — die drei realen Quellen. + * + * Bewusst drei einzelne Funktionen statt einer Connector-Abstraktion: es sind + * drei Quellen, und jede liest anders. Eine gemeinsame Schnittstelle würde + * hier nur die Unterschiede verstecken, die man beim Debuggen braucht. + * + * Kein Browser, kein Playwright, kein Scheduler — nur `fetch()` und ein + * HTML-Parser. Fällt eine Quelle aus, liefert sie einen Fehler und die anderen + * beiden laufen weiter (§21). + */ + +import * as cheerio from 'cheerio' +import { RESEARCH_SOURCES } from '../../src/lib/researchSources' + +/** Gemeinsames Minimalformat aller Quellen (§9). */ +export interface ResearchItem { + source: string + title: string + url: string + text: string + publishedAt?: string +} + +/** + * Die Adressen kommen aus `src/lib/researchSources.ts` — derselben Liste, die + * die Oberfläche unter «Angebundene Kanäle & Systeme» anzeigt. Damit kann der + * angezeigte Link nicht von dem abweichen, was tatsächlich abgerufen wird. + */ +export const SOURCES = RESEARCH_SOURCES + +/** Höchstzahl Artikel je Newsquelle — hält Laufzeit und Tokenverbrauch klein (§22). */ +const MAX_ARTICLES = 10 +const FETCH_TIMEOUT_MS = 15_000 +const UA = 'Mozilla/5.0 (compatible; PropertyMatch-LiviaResearch/1.0; +https://property-match-virid.vercel.app)' + +async function getText(url: string, extraHeaders: Record = {}): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS) + try { + const res = await fetch(url, { + headers: { 'User-Agent': UA, 'Accept-Language': 'de-CH,de;q=0.9', ...extraHeaders }, + redirect: 'follow', + signal: ctrl.signal, + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return await res.text() + } finally { + clearTimeout(timer) + } +} + +/** + * Container, in denen der Artikeltext bevorzugt gesucht wird — von eng nach weit. + * + * Die Reihenfolge ist der Unterschied zwischen brauchbarem und unbrauchbarem + * Input: Nimmt man gleich alle `

` der Seite, stehen bei der Handelskammer + * zuerst Telefonnummern, Öffnungszeiten und Spam-geschützte Mailadressen im + * Text — und das Modell beurteilt dann die Fusszeile mit. + */ +const ARTICLE_CONTAINERS = ['.news-single', '.news-detail', 'article', 'main'] + +/** Zeilen, die erkennbar Kontaktangaben statt Inhalt sind. */ +const BOILERPLATE = /(dont-like-spam|ich-will-kein-spam|^\+41|Montag\s*[–-]\s*Freitag)/i + +/** + * Haupttext einer Artikelseite. + * + * Absichtlich grob: Skripte und Navigation raus, Absätze aus dem engsten + * passenden Container einsammeln, kürzen. Für die Beurteilung genügen die + * ersten Absätze — ein vollwertiger Lesemodus wäre für einen + * Machbarkeitsnachweis Aufwand ohne zusätzliche Aussage. + */ +function extractArticleText(html: string): string { + const $ = cheerio.load(html) + $('script, style, nav, header, footer, noscript, form').remove() + + const sammle = (selector: string): string[] => { + const parts: string[] = [] + $(selector).each((_, el) => { + const t = $(el).text().replace(/\s+/g, ' ').trim() + if (t.length > 40 && !BOILERPLATE.test(t)) parts.push(t) + }) + return parts + } + + for (const container of ARTICLE_CONTAINERS) { + if ($(container).length === 0) continue + const parts = sammle(`${container} p`) + const text = parts.join('\n') + if (text.length > 200) return text.slice(0, 2500) + } + + return sammle('p').join('\n').slice(0, 2500) +} + +function absolute(href: string, base: string): string { + try { + return new URL(href, base).toString() + } catch { + return href + } +} + +/** Quelle A — Zürcher Handelskammer, Unternehmensmeldungen aus der Region (§4). */ +export async function fetchZhkResearch(): Promise { + const listHtml = await getText(SOURCES.ZHK.url) + const $ = cheerio.load(listHtml) + + const links: { url: string; title: string }[] = [] + const seen = new Set() + $('a[href*="/de/wirtschaft-und-politik/news/"]').each((_, el) => { + const href = $(el).attr('href') + if (!href) return + const url = absolute(href, SOURCES.ZHK.url) + if (seen.has(url)) return + seen.add(url) + const title = $(el).text().replace(/\s+/g, ' ').trim() + links.push({ url, title }) + }) + + const chosen = links.slice(0, MAX_ARTICLES) + const items: ResearchItem[] = [] + for (const l of chosen) { + try { + const html = await getText(l.url) + const $a = cheerio.load(html) + const title = l.title || $a('h1').first().text().replace(/\s+/g, ' ').trim() + const published = + $a('time').first().attr('datetime') || + $a('meta[property="article:published_time"]').attr('content') || + undefined + items.push({ + source: SOURCES.ZHK.name, + title, + url: l.url, + text: extractArticleText(html), + publishedAt: published, + }) + } catch { + // Ein einzelner Artikel darf den Lauf nicht kippen. + } + } + return items +} + +/** Quelle B — Greater Zurich Area, Ansiedlungen und Markteintritte (§5). */ +export async function fetchGreaterZurichResearch(): Promise { + const listHtml = await getText(SOURCES.GZA.url) + const $ = cheerio.load(listHtml) + + const links: { url: string; title: string }[] = [] + const seen = new Set() + $('a[href*="/de/news/"]').each((_, el) => { + const href = $(el).attr('href') + if (!href) return + const url = absolute(href, SOURCES.GZA.url) + if (seen.has(url) || /\/de\/news\/?$/.test(url)) return + seen.add(url) + links.push({ url, title: $(el).text().replace(/\s+/g, ' ').trim() }) + }) + + const chosen = links.slice(0, MAX_ARTICLES) + const items: ResearchItem[] = [] + for (const l of chosen) { + try { + const html = await getText(l.url) + const $a = cheerio.load(html) + const title = l.title || $a('h1').first().text().replace(/\s+/g, ' ').trim() + items.push({ + source: SOURCES.GZA.name, + title, + url: l.url, + text: extractArticleText(html), + publishedAt: + $a('time').first().attr('datetime') || + $a('meta[property="article:published_time"]').attr('content') || + undefined, + }) + } catch { + // siehe oben + } + } + return items +} + +/** + * Quelle C — Zefix / Open Data Kanton Zürich, direkt als CSV (§6). + * + * **Wichtige Feststellung zur Datenlage:** Die Datei enthält keine Firmennamen. + * Sie führt Tageszahlen von Neugründungen je NOGA-Branche für den Kanton + * Zürich — Spalten `br_abschnitt_desc, br_abschnitt_code, wirtschaftssektor_desc, + * wirtschaftssektor_code, location, date, value`. + * + * Daraus lässt sich deshalb kein Unternehmenslead bauen, sondern nur ein + * aggregiertes Frühsignal zum Zielmarkt. Genau so wird es weitergegeben. + * Firmennamen zu erfinden, um das erwartete Format zu treffen, wäre die eine + * Sache, die Livia nie tun darf. + */ +export async function fetchZefixResearch(): Promise { + const csv = await getText(SOURCES.ZEFIX.url) + const lines = csv.split(/\r?\n/) + if (lines.length < 2) throw new Error('CSV enthält keine Daten') + + // Die Beschreibung kann Kommas enthalten — die sechs Pflichtfelder stehen + // deshalb von hinten, der Rest davor ist die Branchenbezeichnung. + interface Row { branche: string; datum: string; anzahl: number } + const rows: Row[] = [] + let neuestesDatum = '' + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line) continue + const parts = line.split(',') + if (parts.length < 7) continue + const anzahl = Number(parts[parts.length - 1]) + const datum = parts[parts.length - 2] + const ort = parts[parts.length - 3] + const branche = parts.slice(0, parts.length - 6).join(',').trim() + if (ort !== 'ZH' || !Number.isFinite(anzahl)) continue + if (datum > neuestesDatum) neuestesDatum = datum + rows.push({ branche, datum, anzahl }) + } + + if (!neuestesDatum) throw new Error('Keine Zürcher Datenzeilen gefunden') + + // Fenster: die letzten 30 Tage vor dem jüngsten Datum im Bestand. + const bis = new Date(neuestesDatum) + const von = new Date(bis.getTime() - 30 * 24 * 60 * 60 * 1000) + const vonIso = von.toISOString().slice(0, 10) + + const jeBranche = new Map() + let gesamt = 0 + for (const r of rows) { + if (r.datum < vonIso || r.datum > neuestesDatum) continue + if (r.branche === 'Alle Branchen') continue + if (r.anzahl <= 0) continue + jeBranche.set(r.branche, (jeBranche.get(r.branche) ?? 0) + r.anzahl) + gesamt += r.anzahl + } + + const top = [...jeBranche.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8) + if (top.length === 0) throw new Error('Keine Neugründungen im Auswertungsfenster') + + const text = [ + `Amtliche Statistik des Kantons Zürich zu Handelsregister-Neugründungen.`, + `Auswertungsfenster ${vonIso} bis ${neuestesDatum}: insgesamt ${gesamt} Neugründungen im Kanton Zürich.`, + `Verteilung nach Branche:`, + ...top.map(([b, n]) => `- ${b}: ${n}`), + ``, + `Hinweis zur Datenart: Der Datensatz enthält ausschliesslich Tageszahlen je Branche.`, + `Firmennamen, Adressen und Gemeinden sind darin nicht enthalten.`, + ].join('\n') + + return [{ + source: SOURCES.ZEFIX.name, + title: `Kanton Zürich: ${gesamt} Handelsregister-Neugründungen in 30 Tagen`, + url: SOURCES.ZEFIX.url, + text, + publishedAt: neuestesDatum, + }] +} diff --git a/api/livia/research/refresh.ts b/api/livia/research/refresh.ts new file mode 100644 index 0000000..c29dce8 --- /dev/null +++ b/api/livia/research/refresh.ts @@ -0,0 +1,107 @@ +/** + * Livia Research-PoC — der eine serverseitige Endpoint (§20). + * + * POST /api/livia/research/refresh + * + * Ruft die drei Quellen ab, normalisiert, beurteilt einmal per LLM und gibt + * die relevanten Leads zurück. Kein Scheduler, keine Queue, keine Datenbank — + * der Lauf beginnt und endet mit dieser Anfrage. + * + * Läuft als serverlose Funktion auf derselben Vercel-Instanz, die schon die + * Anwendung ausliefert. Das ist der Grund, warum für den PoC gar keine neue + * Infrastruktur nötig war: der Hoster kann bereits Serverfunktionen, es hat + * bisher nur niemand eine gebraucht. + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' +import { + SOURCES, + fetchZhkResearch, + fetchGreaterZurichResearch, + fetchZefixResearch, +} from '../../_lib/researchSources' +import type { ResearchItem } from '../../_lib/researchSources' +import { analyzeResearchItems } from '../../_lib/researchAnalysis' +import type { ResearchRefreshResult, ResearchSourceResult } from '../../../src/domain/researchLead' + +interface Quelle { + id: string + name: string + url: string + laden: () => Promise +} + +const QUELLEN: Quelle[] = [ + { ...SOURCES.ZHK, laden: fetchZhkResearch }, + { ...SOURCES.GZA, laden: fetchGreaterZurichResearch }, + { ...SOURCES.ZEFIX, laden: fetchZefixResearch }, +] + +export default async function handler(req: IncomingMessage, res: ServerResponse): Promise { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + // Ein Live-Lauf soll nie aus einem Zwischenspeicher beantwortet werden. + res.setHeader('Cache-Control', 'no-store') + + if (req.method !== 'POST') { + res.statusCode = 405 + res.end(JSON.stringify({ error: 'Nur POST' })) + return + } + + // Alle drei parallel: eine langsame Quelle soll die beiden anderen nicht aufhalten. + const ergebnisse = await Promise.all( + QUELLEN.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => { + try { + const items = await q.laden() + return { + meta: { id: q.id, name: q.name, url: q.url, ok: true, itemCount: items.length }, + items, + } + } catch (err) { + // Eine kaputte Quelle darf die Demo nicht zerstören (§21). + return { + meta: { + id: q.id, name: q.name, url: q.url, ok: false, itemCount: 0, + error: err instanceof Error ? err.message : 'Unbekannter Fehler', + }, + items: [], + } + } + }), + ) + + const sources = ergebnisse.map(e => e.meta) + const items = ergebnisse.flatMap(e => e.items) + + const basis: ResearchRefreshResult = { + sources, + analyzed: items.length, + discarded: 0, + watchlist: 0, + leads: [], + refreshedAt: new Date().toISOString(), + } + + if (items.length === 0) { + res.statusCode = 200 + res.end(JSON.stringify({ + ...basis, + analysisError: 'Keine der drei Quellen lieferte Einträge.', + })) + return + } + + try { + const { leads, discarded, watchlist } = await analyzeResearchItems(items) + res.statusCode = 200 + res.end(JSON.stringify({ ...basis, leads, discarded, watchlist })) + } catch (err) { + // Gelesen wurde trotzdem — das sagen wir offen, statt eine leere Liste als + // «nichts gefunden» auszugeben. + res.statusCode = 200 + res.end(JSON.stringify({ + ...basis, + analysisError: err instanceof Error ? err.message : 'Die Beurteilung schlug fehl.', + })) + } +} diff --git a/package-lock.json b/package-lock.json index 22bcb7e..2bdf3e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "property-match", "version": "0.0.0", "dependencies": { + "@anthropic-ai/sdk": "^0.122.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -16,6 +17,7 @@ "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", "@tanstack/react-query": "^5.75.2", + "cheerio": "^1.2.0", "leaflet": "^1.9.4", "lucide-react": "^0.511.0", "react": "^19.2.6", @@ -58,6 +60,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.122.0.tgz", + "integrity": "sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -1563,6 +1586,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2677,6 +2706,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -2764,6 +2799,81 @@ "node": ">=18" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2832,6 +2942,22 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2846,6 +2972,18 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -2942,6 +3080,73 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.356", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", @@ -2949,6 +3154,19 @@ "dev": true, "license": "ISC" }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", @@ -3246,6 +3464,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3469,6 +3693,49 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3704,6 +3971,19 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -4216,6 +4496,18 @@ "dev": true, "license": "MIT" }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4329,6 +4621,79 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4694,6 +5059,12 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4785,6 +5156,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -4960,6 +5341,12 @@ "node": ">=20" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -5034,7 +5421,6 @@ "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -5279,6 +5665,19 @@ "node": ">=20" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", diff --git a/package.json b/package.json index 3c472c8..2e4dbe7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "check:tokens": "node scripts/check-tokens.js" }, "dependencies": { + "@anthropic-ai/sdk": "^0.122.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -22,6 +23,7 @@ "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", "@tanstack/react-query": "^5.75.2", + "cheerio": "^1.2.0", "leaflet": "^1.9.4", "lucide-react": "^0.511.0", "react": "^19.2.6", diff --git a/src/components/research/ResearchLeadCard.tsx b/src/components/research/ResearchLeadCard.tsx new file mode 100644 index 0000000..78fab97 --- /dev/null +++ b/src/components/research/ResearchLeadCard.tsx @@ -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 ( + + + {lead.headline} + + + + + + {RESEARCH_RELEVANCE_LABELS[lead.relevance]} + + {lead.location && ( + } + 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 }, + }} + /> + )} + + + + Unternehmen + + + {lead.company} + + + + {lead.summary} + + + + Warum relevant + + + {lead.reason} + + + {lead.recommendedAction && ( + <> + + Empfohlene nächste Handlung + + + {lead.recommendedAction} + + + )} + + + + Quelle: {lead.source} + {lead.publishedAt && ` · ${new Date(lead.publishedAt).toLocaleDateString('de-CH')}`} + + + Confidence: {Math.round(lead.confidence * 100)} % + + + Originalquelle öffnen + + + + ) +}) diff --git a/src/components/research/ResearchSourcesPanel.tsx b/src/components/research/ResearchSourcesPanel.tsx new file mode 100644 index 0000000..4c7c803 --- /dev/null +++ b/src/components/research/ResearchSourcesPanel.tsx @@ -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 ( + + + + Angebundene Kanäle & Systeme + + + + {RESEARCH_SOURCE_LIST.map(quelle => { + const ergebnis = results?.find(r => r.id === quelle.id) + return ( + + + + {quelle.name} + + + {quelle.purpose} + + + {quelle.url} + + {ergebnis?.error && ( + + {ergebnis.error} + + )} + + + + {ergebnis === undefined ? ( + + ) : ( + : } + 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, + }, + }} + /> + )} + + + ) + })} + + ) +}) diff --git a/src/components/research/ResearchWorkspace.tsx b/src/components/research/ResearchWorkspace.tsx index 0ddafb6..cbd4f03 100644 --- a/src/components/research/ResearchWorkspace.tsx +++ b/src/components/research/ResearchWorkspace.tsx @@ -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(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 ( - - - {isLoading - ? 'Leads werden geladen …' - : `${research.length} ${research.length === 1 ? 'Lead' : 'Leads'} aus externen Quellen · ` + - `${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`} - + + {/* ── Live-Recherche ──────────────────────────────────────────────── */} + + + - + {refresh.isPending && ( + + Livia liest aktuelle Marktinformationen … + + )} + + {!refresh.isPending && ergebnis && ( + + {`${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' })}`} + + )} + + + + + {ergebnis?.analysisError && ( + + Die Quellen wurden gelesen, die Beurteilung konnte aber nicht laufen: {ergebnis.analysisError} + + )} + + {ergebnis && ergebnis.leads.length > 0 && ( + + {ergebnis.leads.map(lead => ( + + ))} + + )} + + {ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && ( + + Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug. + Gelesen wurden {ergebnis.analyzed} Einträge. + + )} + + + {/* ── Erfasste Leads ──────────────────────────────────────────────── */} + + + {isLoading + ? 'Leads werden geladen …' + : `${research.length} ${research.length === 1 ? 'erfasster Lead' : 'erfasste Leads'} · ` + + `${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`} + + + + s.showToast) + + return useMutation({ + 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') + }, + }) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 3f16c00..8ebe6d6 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -356,6 +356,26 @@ export const LEAD_SIGNAL_TYPE_LABELS: Record = { RISIKO: 'Risiko', } +// ── Livia — Recherche aus realen Quellen (PoC, §11/§13) ────────────────────── + +export const RESEARCH_EVENT_TYPE_LABELS: Record = { + 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 = { + high: 'hohe Relevanz', + medium: 'mittlere Relevanz', + low: 'geringe Relevanz', +} + // ── Bruno — Besichtigungsaufträge (Runde 4, §9) ────────────────────────────── diff --git a/src/lib/researchSources.ts b/src/lib/researchSources.ts new file mode 100644 index 0000000..cdd78be --- /dev/null +++ b/src/lib/researchSources.ts @@ -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 + +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' diff --git a/tsconfig.api.json b/tsconfig.api.json new file mode 100644 index 0000000..5180d1e --- /dev/null +++ b/tsconfig.api.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.api.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + /* + * Die serverlosen Funktionen unter `api/` teilen Typen mit dem Frontend — + * deshalb sind die geteilten Dateien aus `src/` mitaufgeführt. Ohne dieses + * eigene Projekt liefe `tsc -b` an `api/` vorbei, und ein Tippfehler im + * Endpoint fiele erst beim Deployment auf. + */ + "include": ["api", "src/domain/researchLead.ts", "src/lib/researchSources.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..c25c89f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.api.json" } ] } diff --git a/vite.config.ts b/vite.config.ts index c7d55d1..1886efd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,46 @@ import { defineConfig } from 'vite' +import type { Plugin } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +/** + * Livias Recherche-Endpoint in der lokalen Entwicklung. + * + * In der Produktion liefert Vercel `api/livia/research/refresh.ts` als + * serverlose Funktion aus. `vite` kennt keine Serverfunktionen — ohne diesen + * Zugang liefe der Aufruf in den `/api`-Proxy darunter und käme als HTML + * zurück. Das Plugin lädt dieselbe Datei über Vites SSR-Lader und ruft sie mit + * demselben `(req, res)` auf, das auch Vercel übergibt; es gibt also keine + * zweite Fassung des Endpoints, die auseinanderlaufen könnte. + * + * Nur für `serve` — in den Produktionsbau geht davon nichts ein. + */ +function liviaResearchDevEndpoint(): Plugin { + const PFAD = '/api/livia/research/refresh' + return { + name: 'livia-research-dev-endpoint', + apply: 'serve', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith(PFAD)) return next() + void server + .ssrLoadModule('/api/livia/research/refresh.ts') + .then(mod => (mod as { default: (q: typeof req, s: typeof res) => Promise }).default(req, res)) + .catch((err: unknown) => { + res.statusCode = 500 + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })) + }) + }) + }, + } +} + export default defineConfig({ plugins: [ react(), tailwindcss(), + liviaResearchDevEndpoint(), ], build: { /**