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: { /**