Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f3b2d6e3 | ||
|
|
f2926d6bcb | ||
|
|
f0ef9c57d4 | ||
|
|
c19f071acc | ||
|
|
e986e9f274 | ||
|
|
a028d1a21d | ||
|
|
97b7110a44 | ||
|
|
31a098f219 | ||
|
|
f58d841e7d | ||
|
|
f53f3be1e8 | ||
|
|
93ecc852e3 | ||
|
|
2a0ba287f6 |
No files matched your search
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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.js'
|
||||
import type { ResearchLead } from '../../src/domain/researchLead.js'
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Einträge ein Aufruf beurteilt.
|
||||
*
|
||||
* Der Wert ist gemessen, nicht geraten. Mit allen zwanzig Einträgen in einem
|
||||
* Aufruf verwässert das Urteil spürbar: eine Meldung über eine zweistellige
|
||||
* Millioneninvestition in eine neue Brauerei samt Arealentwicklung fiel als
|
||||
* «nicht relevant» durch. Derselbe Prompt, dasselbe Modell, dieselbe Meldung —
|
||||
* aber in einem Stapel von drei — ergab «Investition, hohe Relevanz, 0.85».
|
||||
*
|
||||
* Eine höhere Effort-Stufe half nicht, sie machte es sogar schlechter. Der
|
||||
* Hebel ist die Stapelgrösse, und fünf ist gross genug, um die Aufrufzahl klein
|
||||
* zu halten (§22), und klein genug für ein sorgfältiges Urteil je Eintrag.
|
||||
*/
|
||||
const BATCH_SIZE = 5
|
||||
|
||||
/**
|
||||
* Welches Modell die Einordnung vornimmt.
|
||||
*
|
||||
* Über `LIVIA_RESEARCH_MODEL` umstellbar, ohne den Code anzufassen — die
|
||||
* Wahl ist eine Kosten- und Qualitätsabwägung und gehört damit dem Betreiber,
|
||||
* nicht dem Quelltext.
|
||||
*
|
||||
* Grössenordnung je Lauf über rund fünfzig Meldungen:
|
||||
* claude-opus-5 ≈ 0.40 USD stärkstes Urteil
|
||||
* claude-sonnet-5 ≈ 0.20 USD
|
||||
* claude-haiku-4-5 ≈ 0.08 USD für eine Ja/Nein-Einordnung meist ausreichend
|
||||
*/
|
||||
export const MODEL = process.env.LIVIA_RESEARCH_MODEL ?? 'claude-opus-5'
|
||||
|
||||
/**
|
||||
* Wie viel Artikeltext das Modell zu lesen bekommt.
|
||||
*
|
||||
* Ebenfalls umstellbar, weil es der zweite grosse Kostenhebel ist: Der
|
||||
* Immobilienbezug einer Firmenmeldung steht fast immer im ersten Absatz, und
|
||||
* jede Halbierung halbiert die Eingabekosten.
|
||||
*/
|
||||
const TEXT_LIMIT = Number(process.env.LIVIA_RESEARCH_TEXT_LIMIT ?? 2000)
|
||||
|
||||
/**
|
||||
* Welche Modelle den `effort`-Schalter kennen.
|
||||
*
|
||||
* Nicht alle tun das: Haiku 4.5 weist die Anfrage mit «This model does not
|
||||
* support the effort parameter» ab. Da das Modell über eine Umgebungsvariable
|
||||
* frei wählbar ist, darf der Schalter nicht bedingungslos mitgeschickt werden —
|
||||
* sonst macht eine Kostenoptimierung die Funktion kaputt, und zwar erst zur
|
||||
* Laufzeit.
|
||||
*
|
||||
* Bewusst als Erlaubnisliste: Ein unbekanntes Modell läuft dann ohne den
|
||||
* Schalter. Das kostet etwas mehr, funktioniert aber — die umgekehrte
|
||||
* Voreinstellung würde raten und scheitern.
|
||||
*/
|
||||
const EFFORT_MODELS = /^claude-(fable|mythos|opus|sonnet)-/
|
||||
function supportsEffort(model: string): boolean {
|
||||
return EFFORT_MODELS.test(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Teilstapel gleichzeitig beurteilt werden.
|
||||
*
|
||||
* Alles auf einmal loszuschicken wäre schneller, läuft aber in die Drosselung
|
||||
* des Anbieters — und ein gedrosselter Lauf sieht für den Nutzer aus wie ein
|
||||
* kaputter.
|
||||
*/
|
||||
const ANALYSIS_CONCURRENCY = 5
|
||||
|
||||
function chunk<T>(list: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Ein Beurteilungsaufruf für einen Teilstapel. */
|
||||
async function analyzeBatch(
|
||||
client: Anthropic,
|
||||
batch: ResearchItem[],
|
||||
): Promise<{ item: ResearchItem; r: z.infer<typeof AnalysisSchema>['results'][number] }[]> {
|
||||
const eingabe = batch
|
||||
.map((it, i) => [
|
||||
`### Eintrag ${i}`,
|
||||
`Quelle: ${it.source}`,
|
||||
`Titel: ${it.title}`,
|
||||
it.publishedAt ? `Publiziert: ${it.publishedAt}` : '',
|
||||
`Text: ${it.text.slice(0, TEXT_LIMIT)}`,
|
||||
].filter(Boolean).join('\n'))
|
||||
.join('\n\n')
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 8000,
|
||||
system: SYSTEM_PROMPT,
|
||||
// Einordnen ist bei fünf Einträgen eine Routineaufgabe — die niedrige Stufe
|
||||
// spart Zeit und Kosten. Nur die grossen Modelle kennen den Schalter; bei
|
||||
// den kleineren wird die Anfrage sonst abgewiesen (siehe SUPPORTS_EFFORT).
|
||||
...(supportsEffort(MODEL) ? { output_config: { effort: 'low' as const } } : {}),
|
||||
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))
|
||||
return parsed.results
|
||||
.filter(r => batch[r.index] !== undefined)
|
||||
.map(r => ({ item: batch[r.index], r }))
|
||||
}
|
||||
|
||||
export async function analyzeResearchItems(items: ResearchItem[]): Promise<AnalysisOutcome> {
|
||||
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 })
|
||||
|
||||
// Parallel, aber begrenzt: die Laufzeit ist damit nicht die Summe der
|
||||
// Teilstapel, und gleichzeitig laufen nie so viele Anfragen, dass der
|
||||
// Anbieter drosselt.
|
||||
const teilstapel = chunk(items, BATCH_SIZE)
|
||||
const ergebnisse: Awaited<ReturnType<typeof analyzeBatch>>[] = new Array(teilstapel.length)
|
||||
const fehler: string[] = []
|
||||
let next = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const i = next++
|
||||
if (i >= teilstapel.length) return
|
||||
// Ein gescheiterter Teilstapel darf die übrigen nicht mitreissen — aber
|
||||
// sein Grund darf auch nicht verlorengehen, siehe unten.
|
||||
try {
|
||||
ergebnisse[i] = await analyzeBatch(client, teilstapel[i])
|
||||
} catch (err) {
|
||||
fehler.push(err instanceof Error ? err.message : String(err))
|
||||
ergebnisse[i] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(ANALYSIS_CONCURRENCY, teilstapel.length) }, worker),
|
||||
)
|
||||
|
||||
/*
|
||||
* Scheitern alle Teilstapel, ist das kein leeres Ergebnis, sondern ein
|
||||
* Ausfall — und der muss gesagt werden. Vorher wurde er verschluckt und die
|
||||
* Oberfläche meldete «49 analysiert · 0 relevante Entwicklungen», was wie ein
|
||||
* sauberer Lauf ohne Treffer aussieht. Aufgefallen ist das erst, als das
|
||||
* Guthaben aufgebraucht war: der Fehler «credit balance is too low» erschien
|
||||
* nirgends, die Zahlen sahen bloss unauffällig aus.
|
||||
*/
|
||||
if (fehler.length === teilstapel.length) {
|
||||
throw new Error(fehler[0])
|
||||
}
|
||||
const batches = ergebnisse
|
||||
|
||||
const leads: ResearchLead[] = []
|
||||
let discarded = 0
|
||||
let watchlist = 0
|
||||
|
||||
for (const { item, r } of batches.flat()) {
|
||||
if (r.eventType === 'IRRELEVANT') { discarded += 1; continue }
|
||||
if (r.relevance === 'low') { watchlist += 1; continue }
|
||||
// Aggregierte Statistik wird gelesen und beurteilt, wird aber nie zur
|
||||
// Lead-Karte: eine Branchenzahl nennt kein Unternehmen, das man
|
||||
// kontaktieren könnte. Sie zählt als Beobachtungsposten (§19).
|
||||
if (item.aggregate) { 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 }
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* 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.js'
|
||||
|
||||
/** Gemeinsames Minimalformat aller Quellen (§9). */
|
||||
export interface ResearchItem {
|
||||
source: string
|
||||
title: string
|
||||
url: string
|
||||
text: string
|
||||
publishedAt?: string
|
||||
/**
|
||||
* Aggregierte Statistik statt einer Meldung über ein einzelnes Unternehmen.
|
||||
* Solche Einträge werden gelesen und beurteilt, erscheinen aber nie als Lead —
|
||||
* eine Branchenzahl kann man nicht anrufen (§19: «Early Watch»).
|
||||
*/
|
||||
aggregate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Wie viele Übersichtsseiten je Newsquelle gelesen werden.
|
||||
*
|
||||
* Eine Seite allein reicht nicht: Die Handelskammer erneuert ihre obersten
|
||||
* zehn Meldungen im Lauf eines Tages, und wenn dort gerade Personalien und
|
||||
* Meinungsbeiträge stehen, findet Livia zu Recht nichts. Drei Seiten decken
|
||||
* bei der Handelskammer rund 40 und bei Greater Zurich rund 27 Meldungen ab —
|
||||
* damit hängt das Ergebnis nicht mehr am Zufall eines einzelnen Tages.
|
||||
*/
|
||||
const LIST_PAGES = 3
|
||||
|
||||
/** Höchstzahl Artikel je Newsquelle — hält Laufzeit und Tokenverbrauch im Rahmen (§22). */
|
||||
const MAX_ARTICLES = 24
|
||||
|
||||
/** Gleichzeitige Artikelabrufe. Nacheinander wären 24 Abrufe je Quelle zu langsam. */
|
||||
const FETCH_CONCURRENCY = 8
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Eigenes Zeitlimit für die Zefix-CSV.
|
||||
*
|
||||
* Die Datei ist 10,5 MB gross und wird unkomprimiert ausgeliefert — der Server
|
||||
* bietet kein gzip an. Von einem Schweizer Anschluss dauert der Abruf keine
|
||||
* zwei Sekunden, aus dem Rechenzentrum der Anwendung aber mehr als fünfzehn,
|
||||
* und genau daran ist die Quelle im ersten Produktionslauf gescheitert.
|
||||
*/
|
||||
const CSV_TIMEOUT_MS = 30_000
|
||||
const UA = 'Mozilla/5.0 (compatible; PropertyMatch-LiviaResearch/1.0; +https://property-match-virid.vercel.app)'
|
||||
|
||||
async function getText(url: string, timeoutMs: number = FETCH_TIMEOUT_MS): Promise<string> {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, 'Accept-Language': 'de-CH,de;q=0.9' },
|
||||
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 `<p>` 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Linktext trägt bei der Handelskammer das Publikationsdatum vorneweg
|
||||
* («28. August 2026 Bystronic übernimmt …») und hinten ein »-Zeichen. Beides
|
||||
* gehört nicht in den Titel, den das Modell zu lesen bekommt.
|
||||
*/
|
||||
function cleanTitle(raw: string): string {
|
||||
return raw
|
||||
.replace(/^\d{1,2}\.\s*\p{L}+\s+\d{4}\s*/u, '')
|
||||
.replace(/\s*[»›>]\s*$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function absolute(href: string, base: string): string {
|
||||
try {
|
||||
return new URL(href, base).toString()
|
||||
} catch {
|
||||
return href
|
||||
}
|
||||
}
|
||||
|
||||
/** Kartiert mit begrenzter Gleichzeitigkeit — schneller als nacheinander, ohne die Quelle zu fluten. */
|
||||
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array<R>(items.length)
|
||||
let next = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const i = next++
|
||||
if (i >= items.length) return
|
||||
out[i] = await fn(items[i])
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
return out
|
||||
}
|
||||
|
||||
interface Artikellink { url: string; title: string }
|
||||
|
||||
/**
|
||||
* Artikel zu Einträgen machen — parallel, und ein einzelner Ausfall kippt den
|
||||
* Lauf nicht. Statt einer Ausnahme entsteht dann schlicht kein Eintrag.
|
||||
*/
|
||||
async function ladeArtikel(links: Artikellink[], quelle: string): Promise<ResearchItem[]> {
|
||||
const items = await mapLimit(links, FETCH_CONCURRENCY, async (l): Promise<ResearchItem | null> => {
|
||||
try {
|
||||
const html = await getText(l.url)
|
||||
const $a = cheerio.load(html)
|
||||
return {
|
||||
source: quelle,
|
||||
title: cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim(),
|
||||
url: l.url,
|
||||
text: extractArticleText(html),
|
||||
publishedAt:
|
||||
$a('time').first().attr('datetime') ||
|
||||
$a('meta[property="article:published_time"]').attr('content') ||
|
||||
undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
return items.filter((i): i is ResearchItem => i !== null && i.text.length > 120)
|
||||
}
|
||||
|
||||
/**
|
||||
* Artikellinks aus mehreren Übersichtsseiten einsammeln.
|
||||
*
|
||||
* Die Listenseiten werden parallel geholt, das Ergebnis aber in Seitenreihenfolge
|
||||
* zusammengesetzt: Seite 1 trägt die neuesten Meldungen, und die sollen zuerst
|
||||
* kommen, wenn die Obergrenze greift.
|
||||
*/
|
||||
async function sammleLinks(
|
||||
seitenUrls: string[],
|
||||
basis: string,
|
||||
selektor: string,
|
||||
ausschluss?: RegExp,
|
||||
): Promise<Artikellink[]> {
|
||||
const seiten = await Promise.all(
|
||||
seitenUrls.map(async u => {
|
||||
try {
|
||||
return await getText(u)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const links: Artikellink[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const html of seiten) {
|
||||
if (!html) continue
|
||||
const $ = cheerio.load(html)
|
||||
$(selektor).each((_, el) => {
|
||||
const href = $(el).attr('href')
|
||||
if (!href) return
|
||||
const url = absolute(href, basis)
|
||||
if (seen.has(url)) return
|
||||
if (ausschluss?.test(url)) return
|
||||
seen.add(url)
|
||||
links.push({ url, title: $(el).text().replace(/\s+/g, ' ').trim() })
|
||||
})
|
||||
}
|
||||
return links.slice(0, MAX_ARTICLES)
|
||||
}
|
||||
|
||||
/** Quelle A — Zürcher Handelskammer, Unternehmensmeldungen aus der Region (§4). */
|
||||
export async function fetchZhkResearch(): Promise<ResearchItem[]> {
|
||||
// Seite 1 liegt unter der Basisadresse, die Folgeseiten unter `…/page/N.html`.
|
||||
const seiten = [
|
||||
SOURCES.ZHK.url,
|
||||
...Array.from({ length: LIST_PAGES - 1 }, (_, i) =>
|
||||
`https://www.zhk.ch/de/wirtschaft-und-politik/news-liste/page/${i + 2}.html`),
|
||||
]
|
||||
const links = await sammleLinks(
|
||||
seiten,
|
||||
SOURCES.ZHK.url,
|
||||
'a[href*="/de/wirtschaft-und-politik/news/"]',
|
||||
)
|
||||
return ladeArtikel(links, SOURCES.ZHK.name)
|
||||
}
|
||||
|
||||
/** Quelle B — Greater Zurich Area, Ansiedlungen und Markteintritte (§5). */
|
||||
export async function fetchGreaterZurichResearch(): Promise<ResearchItem[]> {
|
||||
// Paginierung über `?page=N`, beginnend bei 0.
|
||||
const seiten = Array.from({ length: LIST_PAGES }, (_, i) =>
|
||||
i === 0 ? SOURCES.GZA.url : `${SOURCES.GZA.url}?page=${i}`)
|
||||
const links = await sammleLinks(
|
||||
seiten,
|
||||
SOURCES.GZA.url,
|
||||
'a[href*="/de/news/"]',
|
||||
/\/de\/news\/?$/,
|
||||
)
|
||||
return ladeArtikel(links, SOURCES.GZA.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ResearchItem[]> {
|
||||
const csv = await getText(SOURCES.ZEFIX.url, CSV_TIMEOUT_MS)
|
||||
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<string, number>()
|
||||
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,
|
||||
aggregate: true,
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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.js'
|
||||
import type { ResearchItem } from '../../_lib/researchSources.js'
|
||||
import { MODEL, analyzeResearchItems } from '../../_lib/researchAnalysis.js'
|
||||
import type { ResearchRefreshResult, ResearchSourceResult } from '../../../src/domain/researchLead.js'
|
||||
|
||||
interface Quelle {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
laden: () => Promise<ResearchItem[]>
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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(),
|
||||
model: MODEL,
|
||||
}
|
||||
|
||||
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.',
|
||||
}))
|
||||
}
|
||||
}
|
||||
Generated
+400
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||
import { AgentAvatar, ObjectDeepLink } from '../team'
|
||||
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
||||
import { isRisiko, leadSignalTypeOf, recommendedActionOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||
import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
@@ -50,7 +51,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
const signalType = leadSignalTypeOf(signal)
|
||||
const risiko = isRisiko(signal)
|
||||
const rationale = signalRationaleOf(signal)
|
||||
const action = recommendedActionOf(signal)
|
||||
|
||||
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
|
||||
const relevant = useMemo(
|
||||
@@ -215,16 +215,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nächste Handlung — der eigentliche Zweck des Leads (§2) */}
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid', borderColor: DS_BRAND.main, borderRadius: 1.5, p: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Empfohlene nächste Handlung
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||
{action}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
|
||||
{showMatching && (
|
||||
<>
|
||||
@@ -248,11 +238,14 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
</>
|
||||
)}
|
||||
|
||||
{signal.disclaimer && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
{/*
|
||||
An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals
|
||||
(«Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen
|
||||
KI-Hinweis gewichen: Die Herkunft steht ohnehin oben bei der Quelle,
|
||||
und ein Satz je Signal war nie derselbe — der Hinweis auf mögliche
|
||||
Fehler soll aber überall gleich lauten (§4, §5).
|
||||
*/}
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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 { AiDisclaimer } from '../ui'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Ein aus einer echten Quelle abgeleiteter Lead (§16).
|
||||
*
|
||||
* Die Karte beantwortet in fester Reihenfolge: was ist passiert, bei wem, wo,
|
||||
* warum ist es für Gewerbeflächen relevant, wie sicher ist die Einordnung und
|
||||
* woher stammt sie. Der Quellenlink steht unten und ist echt — er zeigt auf
|
||||
* genau die Seite, die Livia gelesen hat.
|
||||
*
|
||||
* Confidence steht als Zahl da und nicht als Balken: ein Balken suggeriert
|
||||
* Messgenauigkeit, die eine Modellschätzung nicht hat.
|
||||
*/
|
||||
export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead: ResearchLead }) {
|
||||
const hoch = lead.relevance === 'high'
|
||||
const akzent = hoch ? DS_COLORS.leadSignal.CHANCE : { bg: DS_SLATE[100], fg: DS_SLATE[600] }
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: hoch ? DS_COLORS.leadSignal.CHANCE.fg : DS_SLATE[300],
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'white',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, color: DS_SLATE[900], lineHeight: 1.35, mb: 0.75 }}>
|
||||
{lead.headline}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={RESEARCH_EVENT_TYPE_LABELS[lead.eventType] ?? lead.eventType}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: akzent.bg, color: akzent.fg }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{RESEARCH_RELEVANCE_LABELS[lead.relevance]}
|
||||
</Typography>
|
||||
{lead.location && (
|
||||
<Chip
|
||||
icon={<MapPin size={11} />}
|
||||
label={lead.location}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20, fontSize: '0.72rem', bgcolor: DS_SLATE[100], color: DS_SLATE[600],
|
||||
'& .MuiChip-icon': { color: DS_SLATE[600], ml: 0.5, mr: -0.25 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 0.25 }}>
|
||||
Unternehmen
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 1 }}>
|
||||
{lead.company}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mb: 1.25 }}>
|
||||
{lead.summary}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||
>
|
||||
Warum relevant
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
|
||||
{lead.reason}
|
||||
</Typography>
|
||||
|
||||
{/*
|
||||
Der Block «Empfohlene nächste Handlung» ist mit Runde 9 (§4) entfallen.
|
||||
Das Feld bleibt im Datenmodell und wird weiterhin vom Modell befüllt —
|
||||
entfernt ist allein die Anzeige, damit die Karte auf das Beobachtete
|
||||
beschränkt bleibt und nicht als Anweisung gelesen wird.
|
||||
*/}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1,
|
||||
pt: 1.25, borderTop: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||
Quelle: <strong>{lead.source}</strong>
|
||||
{lead.publishedAt && ` · ${new Date(lead.publishedAt).toLocaleDateString('de-CH')}`}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||
Confidence: <strong>{Math.round(lead.confidence * 100)} %</strong>
|
||||
</Typography>
|
||||
<Box
|
||||
component="a"
|
||||
href={lead.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, ml: 'auto',
|
||||
color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Originalquelle öffnen <ExternalLink size={11} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'
|
||||
import type { ResearchSourceResult } from '../../domain/researchLead'
|
||||
import { RESEARCH_SOURCE_LIST } from '../../lib/researchSources'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
/** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */
|
||||
results?: ResearchSourceResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15).
|
||||
*
|
||||
* Zeigt die drei real abgerufenen Quellen mit Namen, Status und anklickbarem
|
||||
* Originallink. Die Links stehen hier nicht als Zierde: wer eine Aussage von
|
||||
* Livia prüfen will, soll die Quelle in einem Klick öffnen können, ohne sie
|
||||
* selbst suchen zu müssen.
|
||||
*
|
||||
* Vor dem ersten Lauf steht «Bereit» und nicht «Verbunden» — verbunden ist,
|
||||
* was tatsächlich geantwortet hat, und das weiss man erst danach.
|
||||
*/
|
||||
export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results }: Props) {
|
||||
return (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: 'white', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}
|
||||
>
|
||||
Angebundene Kanäle & Systeme
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{RESEARCH_SOURCE_LIST.map(quelle => {
|
||||
const ergebnis = results?.find(r => r.id === quelle.id)
|
||||
return (
|
||||
<Box
|
||||
key={quelle.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.5,
|
||||
px: 2, py: 1.5,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
'&:last-of-type': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||
{quelle.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', lineHeight: 1.4 }}>
|
||||
{quelle.purpose}
|
||||
</Typography>
|
||||
<Box
|
||||
component="a"
|
||||
href={quelle.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.5,
|
||||
color: DS_BRAND.main, fontSize: '0.72rem', fontWeight: 600,
|
||||
textDecoration: 'none', wordBreak: 'break-all',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{quelle.url} <ExternalLink size={11} style={{ flexShrink: 0 }} />
|
||||
</Box>
|
||||
{ergebnis?.error && (
|
||||
<Typography variant="caption" sx={{ color: DS_COLORS.leadSignal.RISIKO.fg, display: 'block', mt: 0.25 }}>
|
||||
{ergebnis.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, pt: 0.25 }}>
|
||||
{ergebnis === undefined ? (
|
||||
<Chip
|
||||
label="Bereit"
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: DS_SLATE[100], color: DS_SLATE[600] }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
icon={ergebnis.ok ? <CheckCircle2 size={11} /> : <XCircle size={11} />}
|
||||
/*
|
||||
* Mit Einheit statt nur der Zahl: «Live · 24» liest sich sonst
|
||||
* genauso gut als «24 gefundene Leads», und genau so wurde es
|
||||
* beim ersten Zeigen auch verstanden.
|
||||
*/
|
||||
label={ergebnis.ok
|
||||
? `Live · ${ergebnis.itemCount} ${ergebnis.itemCount === 1 ? 'Eintrag' : 'Einträge'}`
|
||||
: 'Nicht erreichbar'}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20, fontSize: '0.68rem', fontWeight: 700,
|
||||
bgcolor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.bg : DS_COLORS.leadSignal.RISIKO.bg,
|
||||
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
border: '1px solid',
|
||||
borderColor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
'& .MuiChip-icon': {
|
||||
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
ml: 0.5, mr: -0.25,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -1,27 +1,30 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { LeadDetail, UnifiedLeadList } from '../market-leads'
|
||||
import { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||
import { ResearchLeadCard } from './ResearchLeadCard'
|
||||
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
||||
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
|
||||
import { LeadChannel } from '../../domain/unifiedLead'
|
||||
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||
import { LeadSignalType } from '../../domain/futureSignal'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1).
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15).
|
||||
*
|
||||
* Die Liste ist dieselbe Komponente wie bei Nora — bewusst, damit sich beide
|
||||
* Arbeitsplätze gleich lesen und eine spätere Änderung nur an einer Stelle
|
||||
* anfällt. Zwei Unterschiede: die Kanalspalte entfällt, weil hier jeder Eintrag
|
||||
* aus Livias eigener Recherche stammt, und die Detailansicht läuft mit
|
||||
* `showMatching={false}`.
|
||||
* Zwei Bestände nebeneinander, bewusst getrennt gehalten:
|
||||
*
|
||||
* Damit ist die Grenze zwischen den beiden Agentinnen im Code gezogen und nicht
|
||||
* bloss in der Beschriftung: Livia recherchiert und verdichtet, das Matching
|
||||
* gegen den eigenen Bestand bleibt Noras Arbeit (§1, §2).
|
||||
* 1. **Live-Recherche** — was Livia beim Knopfdruck aus den drei realen
|
||||
* Quellen gelesen und beurteilt hat. Diese Leads sind nicht gespeichert;
|
||||
* sie leben im Zustand dieser Komponente, bis der nächste Lauf sie ersetzt.
|
||||
* 2. **Erfasste Leads** — der bestehende Signalbestand, den Nora abgleicht.
|
||||
*
|
||||
* Gelesen wird aus demselben Bestand, den Nora anzeigt. Es gibt keine zweite
|
||||
* Lead-Ablage und keine Übergabekopie — ein Lead, zwei Arbeitsplätze.
|
||||
* Die Trennung ist Absicht: ein live gelesener Artikel ist etwas anderes als
|
||||
* ein redaktionell erfasster Lead, und beides in eine Liste zu mischen würde
|
||||
* genau diesen Unterschied verwischen. Die Liste unten ist dieselbe Komponente
|
||||
* wie bei Nora — mit `showMatching={false}`, weil das Matching ihre Arbeit ist.
|
||||
*/
|
||||
export function ResearchWorkspace() {
|
||||
const theme = useTheme()
|
||||
@@ -29,6 +32,8 @@ export function ResearchWorkspace() {
|
||||
|
||||
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||
const { data: leads, isLoading } = useUnifiedLeads()
|
||||
const refresh = useLiviaResearch()
|
||||
const ergebnis = refresh.data
|
||||
|
||||
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||
const { research, chancen, risiken } = useMemo(() => {
|
||||
@@ -45,13 +50,71 @@ export function ResearchWorkspace() {
|
||||
}, [leads])
|
||||
|
||||
const closeDetail = useCallback(() => setSelected(null), [])
|
||||
const starten = useCallback(() => refresh.mutate(), [refresh])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* ── Live-Recherche ──────────────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
|
||||
disabled={refresh.isPending}
|
||||
onClick={starten}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Research aktualisieren
|
||||
</Button>
|
||||
|
||||
{refresh.isPending && (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Livia liest aktuelle Marktinformationen …
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!refresh.isPending && ergebnis && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
|
||||
{`${ergebnis.discarded} nicht relevant · `}
|
||||
{`${ergebnis.watchlist} Watchlist · `}
|
||||
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'relevante Entwicklung' : 'relevante Entwicklungen'}`}
|
||||
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
|
||||
{ergebnis.model && ` · Modell ${ergebnis.model}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ResearchSourcesPanel results={ergebnis?.sources} />
|
||||
|
||||
{ergebnis?.analysisError && (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||
Die Quellen wurden gelesen, die Beurteilung konnte aber nicht laufen: {ergebnis.analysisError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{ergebnis && ergebnis.leads.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{ergebnis.leads.map(lead => (
|
||||
<ResearchLeadCard key={`${lead.sourceUrl}-${lead.headline}`} lead={lead} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug.
|
||||
Gelesen wurden {ergebnis.analyzed} Einträge.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Erfasste Leads ──────────────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1, borderTop: `1px solid ${DS_SLATE[200]}` }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{isLoading
|
||||
? 'Leads werden geladen …'
|
||||
: `${research.length} ${research.length === 1 ? 'Lead' : 'Leads'} aus externen Quellen · ` +
|
||||
: `${research.length} ${research.length === 1 ? 'erfasster Lead' : 'erfasste Leads'} · ` +
|
||||
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
||||
</Typography>
|
||||
|
||||
@@ -61,8 +124,9 @@ export function ResearchWorkspace() {
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
showChannel={false}
|
||||
emptyText="Noch keine Leads aus der Marktbeobachtung. Sobald eine verknüpfte Quelle eine relevante Veränderung meldet, erscheint sie hier."
|
||||
emptyText="Noch keine erfassten Leads. Was Livia gerade live gelesen hat, steht oberhalb."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Livia — Markt-Recherche (Runde 8, §1). Barrel-Export (CLAUDE.md §13).
|
||||
// Livia — Markt-Recherche (Runde 8, §1; Research-PoC). Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
export { ResearchWorkspace } from './ResearchWorkspace'
|
||||
export { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||
export { ResearchLeadCard } from './ResearchLeadCard'
|
||||
@@ -159,7 +159,19 @@ export function MeetingRoom3D() {
|
||||
}
|
||||
}, [positionLabels])
|
||||
|
||||
// ── Maus: Parallaxe und Trefferprüfung ──
|
||||
/**
|
||||
* Maus: nur noch Trefferprüfung, keine Bewegung mehr (Runde 9, §1).
|
||||
*
|
||||
* Bis hierher folgte die Kamera dem Zeiger — eine leichte Parallaxe, die den
|
||||
* Raum lebendig wirken lassen sollte. Gewünscht ist jetzt eine vollständig
|
||||
* ruhige Szene: Wer die Agenten anschaut, soll sie anschauen und nicht ein
|
||||
* Bild, das unter dem Zeiger wegkippt.
|
||||
*
|
||||
* Entfernt ist ausschliesslich der Aufruf von `applyParallax` — die Szene
|
||||
* selbst, ihre Kameraposition, Sitzordnung und Beschriftungen bleiben
|
||||
* unverändert. Das Erkennen des Agenten unter dem Zeiger und der Klick auf
|
||||
* seinen Platz sind keine Bewegung und bleiben deshalb.
|
||||
*/
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const s = sceneRef.current
|
||||
const wrap = wrapRef.current
|
||||
@@ -169,8 +181,6 @@ export function MeetingRoom3D() {
|
||||
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
|
||||
s.applyParallax(nx, ny)
|
||||
|
||||
const ray = new THREE.Raycaster()
|
||||
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
|
||||
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
|
||||
@@ -187,7 +197,6 @@ export function MeetingRoom3D() {
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
const s = sceneRef.current
|
||||
if (!s) return
|
||||
s.applyParallax(0, 0)
|
||||
hoveredRef.current = null
|
||||
s.setHovered(null)
|
||||
setHovered(null)
|
||||
|
||||
@@ -65,11 +65,12 @@ export const CAMERA = {
|
||||
maxHFov: 84,
|
||||
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
||||
lookAt: [0, 1.24, -5.2] as const,
|
||||
/**
|
||||
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
|
||||
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
|
||||
/*
|
||||
* Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger
|
||||
* um bis zu 7,5 Zentimeter, was den Raum beleben sollte und stattdessen als
|
||||
* Wackeln wahrgenommen wurde. Kameraposition und Blickziel bleiben exakt
|
||||
* dieselben — nur bewegt sich nichts mehr.
|
||||
*/
|
||||
parallax: 0.075,
|
||||
} as const
|
||||
|
||||
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface MeetingRoomScene {
|
||||
renderer: THREE.WebGLRenderer
|
||||
seats: SeatHandle[]
|
||||
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
|
||||
applyParallax: (dx: number, dy: number) => void
|
||||
setSize: (width: number, height: number) => void
|
||||
render: () => void
|
||||
setHovered: (id: string | null) => void
|
||||
@@ -549,9 +548,6 @@ export function createMeetingRoomScene(
|
||||
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
|
||||
)
|
||||
|
||||
const baseCam = camera.position.clone()
|
||||
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
|
||||
|
||||
function render() {
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
@@ -562,15 +558,6 @@ export function createMeetingRoomScene(
|
||||
renderer,
|
||||
seats: handles,
|
||||
|
||||
applyParallax(dx, dy) {
|
||||
camera.position.set(
|
||||
baseCam.x + dx * CAMERA.parallax,
|
||||
baseCam.y + dy * CAMERA.parallax * 0.6,
|
||||
baseCam.z,
|
||||
)
|
||||
camera.lookAt(lookTarget)
|
||||
},
|
||||
|
||||
setSize(width, height) {
|
||||
renderer.setSize(width, height, false)
|
||||
const aspect = width / Math.max(height, 1)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { X } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
|
||||
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
@@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) {
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/*
|
||||
Der KI-Hinweis steht innerhalb des scrollenden Bereichs. Ausserhalb
|
||||
läge er hinter dem `overflow: hidden` des äusseren Rahmens und wäre
|
||||
nie zu sehen — der Aktionsbalken darunter ist bewusst der einzige
|
||||
Teil, der fix am unteren Rand klebt.
|
||||
*/}
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
|
||||
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
|
||||
|
||||
@@ -115,7 +115,43 @@ export function PropertyTable({
|
||||
|
||||
return (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table stickyHeader size="small">
|
||||
<Table
|
||||
stickyHeader
|
||||
size="small"
|
||||
/*
|
||||
* Feste Spaltenbreiten statt automatischer (Runde 9, §2).
|
||||
*
|
||||
* Mit automatischer Breite richtet sich jede Spalte nach ihrem längsten
|
||||
* Inhalt — bei elf Spalten summiert sich das über die Fensterbreite
|
||||
* hinaus, und die letzten beiden lagen im waagrechten Scrollbereich.
|
||||
* Die Anteile unten ergeben zusammen 100 %; keine Spalte ist entfernt
|
||||
* und kein Wert gekürzt, sie teilen sich den Platz nur nach Bedarf:
|
||||
* Objektname und Mieter tragen lange Texte, «Fläche» oder «Break-Option»
|
||||
* kommen mit wenig aus.
|
||||
*/
|
||||
sx={{
|
||||
tableLayout: 'fixed',
|
||||
'& .MuiTableCell-root': {
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
// Lange Namen brechen um, statt die Spalte aufzuweiten.
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: '19%' }} />{/* Objekt */}
|
||||
<col style={{ width: '7%' }} />{/* Typ */}
|
||||
<col style={{ width: '9%' }} />{/* Standort */}
|
||||
<col style={{ width: '6%' }} />{/* Fläche */}
|
||||
<col style={{ width: '8%' }} />{/* Miete */}
|
||||
<col style={{ width: '13%' }} />{/* Aktueller Mieter */}
|
||||
<col style={{ width: '9%' }} />{/* Mietlaufzeit */}
|
||||
<col style={{ width: '7%' }} />{/* Breakoutoption */}
|
||||
<col style={{ width: '9%' }} />{/* Breakoutoption Zeitpunkt */}
|
||||
<col style={{ width: '9%' }} />{/* Datenqualität */}
|
||||
<col style={{ width: '4%' }} />{/* Aktionen */}
|
||||
</colgroup>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
||||
@@ -205,7 +241,7 @@ export function PropertyTable({
|
||||
|
||||
{/* Aktueller Mieter */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="body2">
|
||||
{p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
@@ -249,7 +285,7 @@ export function PropertyTable({
|
||||
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{ width: 80 }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 90 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={qScore * 100}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { X, Calendar } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import {
|
||||
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
|
||||
} from '../../hooks/useReminders'
|
||||
@@ -201,6 +202,8 @@ export function ReminderDetailDrawer() {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { DS_BORDER, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Der Gesprächsverlauf unter dem Eingabefeld (Runde 9, §3).
|
||||
*
|
||||
* Bewusst schlicht: Frage rechts, Antwort links mit dem Porträt des Agenten,
|
||||
* keine Zeitstempel, keine Sprechblasen mit Spitzen, kein Tippindikator. Der
|
||||
* Chat beantwortet drei Fragetypen aus Mockdaten — eine Oberfläche, die nach
|
||||
* Messenger aussieht, verspräche mehr, als dahinter steckt.
|
||||
*
|
||||
* Vor der ersten Frage erscheint gar nichts. Der grosszügige Weissraum unter
|
||||
* dem Eingabefeld ist Teil des Einstiegs und soll nicht durch einen leeren
|
||||
* Verlaufsrahmen ersetzt werden.
|
||||
*/
|
||||
export function AgentChatTranscript() {
|
||||
const messages = useAgentChatStore(s => s.messages)
|
||||
const agent = useAgentChatStore(s => s.agent)
|
||||
|
||||
if (messages.length === 0 || !agent) return null
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', maxWidth: 720, mt: 2.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{messages.map((m, i) => (
|
||||
m.from === 'user' ? (
|
||||
<Box key={i} sx={{ alignSelf: 'flex-end', maxWidth: '85%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.75, py: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: DS_SLATE[100],
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, whiteSpace: 'pre-line' }}>
|
||||
{m.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1.25, alignItems: 'flex-start', maxWidth: '95%' }}>
|
||||
<Box sx={{ flexShrink: 0, mt: 0.25 }}>
|
||||
<AgentAvatar agent={agent} size="small" />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.75, py: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: 'white',
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
|
||||
{m.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { SendHorizonal } from 'lucide-react'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AgentChatTranscript } from './AgentChatTranscript'
|
||||
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
@@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<AgentChatTranscript />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material'
|
||||
import { HelpCircle, X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
@@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() {
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -87,3 +87,6 @@ export {
|
||||
PROTOCOL_STATUS_SURFACE,
|
||||
PROTOCOL_STATUS_TEXT,
|
||||
} from './teamTokens'
|
||||
|
||||
// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg.
|
||||
export { AgentChatTranscript } from './AgentChatTranscript'
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useAgentChatAnswer } from '../../hooks/useAgentChatAnswer'
|
||||
|
||||
/**
|
||||
* Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
|
||||
@@ -29,7 +29,10 @@ interface Options {
|
||||
* vergessen.
|
||||
*
|
||||
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
||||
* Seit Runde 9 (§3) antwortet der Chat tatsächlich: aus den Beständen der
|
||||
* jeweiligen Agentenseite, per Schlüsselwortlogik, ohne Backend und ohne
|
||||
* Sprachmodell. Was er nicht belegen kann, sagt er nicht — eine erfundene Zahl
|
||||
* wäre schlimmer als ein «das habe ich nicht verstanden».
|
||||
*/
|
||||
export function useAgentChat({ active = true }: Options = {}) {
|
||||
const agent = useAgentChatStore(s => s.agent)
|
||||
@@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) {
|
||||
const focused = useAgentChatStore(s => s.focused)
|
||||
const setDraft = useAgentChatStore(s => s.setDraft)
|
||||
const setFocused = useAgentChatStore(s => s.setFocused)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const append = useAgentChatStore(s => s.append)
|
||||
const answerFor = useAgentChatAnswer()
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) {
|
||||
el.setSelectionRange(end, end)
|
||||
}, [active, focused])
|
||||
|
||||
/**
|
||||
* Frage absenden und beantworten (Runde 9, §3).
|
||||
*
|
||||
* Bis hierher meldete das Absenden nur, der Chat sei nicht angebunden. Jetzt
|
||||
* antwortet der Agent aus den Beständen seiner eigenen Seite — ohne Backend
|
||||
* und ohne Sprachmodell, wie es der Auftrag verlangt.
|
||||
*
|
||||
* Frage und Antwort landen zusammen im Verlauf. Der Toast entfällt: Wer eine
|
||||
* Antwort im Chat sieht, braucht daneben keine Einblendung, die dasselbe
|
||||
* Ereignis noch einmal meldet.
|
||||
*/
|
||||
const send = useCallback(() => {
|
||||
const text = draft.trim()
|
||||
if (!text || !agent) return
|
||||
setDraft('')
|
||||
showToast(
|
||||
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`,
|
||||
'info',
|
||||
)
|
||||
}, [draft, agent, setDraft, showToast])
|
||||
append({ from: 'user', text })
|
||||
append({ from: 'agent', text: answerFor(agent, text) })
|
||||
}, [draft, agent, setDraft, append, answerFor])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Info } from 'lucide-react'
|
||||
import { AI_DISCLAIMER } from '../../lib/constants'
|
||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Der einheitliche KI-Hinweis am Fuss jeder Agenten-Detailansicht (Runde 9, §5).
|
||||
*
|
||||
* Eine einzige Komponente für alle sechs Detailansichten. Der Wortlaut ist
|
||||
* vorgegeben und steht deshalb als Konstante in `constants.ts` — stünde er
|
||||
* sechsmal im Code, hiesse er nach der ersten Korrektur an fünf Stellen anders,
|
||||
* und ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner.
|
||||
*
|
||||
* Bewusst dezent: grau, klein, mit dünner Trennlinie darüber. Er soll gelesen
|
||||
* werden können, ohne die Aussage darüber zu übertönen — ein roter Warnkasten
|
||||
* an jeder Detailansicht stumpft nach dem dritten Mal ab und wird dann gar
|
||||
* nicht mehr gelesen.
|
||||
*/
|
||||
export const AiDisclaimer = memo(function AiDisclaimer() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 0.75,
|
||||
mt: 2,
|
||||
pt: 1.5,
|
||||
borderTop: `1px solid ${DS_SLATE[100]}`,
|
||||
}}
|
||||
>
|
||||
<Info size={13} color={DS_SLATE[400]} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, lineHeight: 1.5 }}>
|
||||
{AI_DISCLAIMER}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -12,3 +12,6 @@ export { ToastProvider } from './ToastProvider'
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
export { DecisionContextPanel } from './DecisionContextPanel'
|
||||
export type { DecisionMetric, DecisionAction } from './DecisionContextPanel'
|
||||
|
||||
// Runde 9, §5: einheitlicher KI-Hinweis in allen Agenten-Detailansichten.
|
||||
export { AiDisclaimer } from './AiDisclaimer'
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material'
|
||||
import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react'
|
||||
import type { VisitAssignment } from '../../domain/visitAssignment'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { VisitRequestType } from '../../domain/visitAssignment'
|
||||
import { needsReportWarning } from '../../services/visitAssignmentService'
|
||||
import { useRequestVisitReport } from '../../hooks/useVisitAssignments'
|
||||
@@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Property On — Livias Research-PoC: echte Quellen statt Mockdaten.
|
||||
*
|
||||
* Diese Typen werden von beiden Seiten benutzt — von der serverlosen Funktion
|
||||
* unter `api/` und vom Frontend. Sie liegen deshalb im Domänenordner und nicht
|
||||
* neben dem Endpoint: ein zweites, fast gleiches Typenpaar wäre die erste
|
||||
* Stelle, an der Server und Oberfläche auseinanderlaufen.
|
||||
*
|
||||
* Bewusst klein gehalten (PoC, §9/§13). Es ist ausdrücklich **nicht** der
|
||||
* `FutureSignal`-Typ: der trägt Wahrscheinlichkeiten, Datenqualität und
|
||||
* Portfoliobezug, die eine Live-Recherche nicht liefern kann. Ein Feld zu
|
||||
* füllen, für das keine Grundlage existiert, wäre genau die Behauptung, die
|
||||
* Livia nicht aufstellen darf.
|
||||
*/
|
||||
|
||||
/** Die neun Kategorien des PoC (§11). */
|
||||
export const ResearchEventType = {
|
||||
EXPANSION: 'EXPANSION',
|
||||
NEW_LOCATION: 'NEW_LOCATION',
|
||||
HEADCOUNT_GROWTH: 'HEADCOUNT_GROWTH',
|
||||
INVESTMENT: 'INVESTMENT',
|
||||
M_AND_A: 'M_AND_A',
|
||||
RESTRUCTURING: 'RESTRUCTURING',
|
||||
SITE_CLOSURE: 'SITE_CLOSURE',
|
||||
NEW_COMPANY: 'NEW_COMPANY',
|
||||
IRRELEVANT: 'IRRELEVANT',
|
||||
} as const
|
||||
export type ResearchEventType = typeof ResearchEventType[keyof typeof ResearchEventType]
|
||||
|
||||
export type ResearchRelevance = 'high' | 'medium' | 'low'
|
||||
|
||||
/** Ein von Livia aus einer echten Quelle abgeleiteter Lead (§13). */
|
||||
export interface ResearchLead {
|
||||
company: string
|
||||
eventType: ResearchEventType
|
||||
headline: string
|
||||
location?: string
|
||||
summary: string
|
||||
relevance: ResearchRelevance
|
||||
/** 0–1. Wie sicher ist die Einordnung angesichts der Quellenlage? */
|
||||
confidence: number
|
||||
reason: string
|
||||
recommendedAction?: string
|
||||
/** Anzeigename der Quelle, z. B. «Zürcher Handelskammer». */
|
||||
source: string
|
||||
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
|
||||
sourceUrl: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
/** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
|
||||
export interface ResearchSourceResult {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
ok: boolean
|
||||
/** Gelesene Einträge; 0, wenn die Quelle nicht erreichbar war. */
|
||||
itemCount: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Antwort des Refresh-Endpoints. */
|
||||
export interface ResearchRefreshResult {
|
||||
sources: ResearchSourceResult[]
|
||||
/** Wie viele Einträge insgesamt gelesen wurden. */
|
||||
analyzed: number
|
||||
/** Davon als nicht relevant verworfen — der Kern des PoC (§14). */
|
||||
discarded: number
|
||||
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||
watchlist: number
|
||||
/** Nur `high` und `medium` erscheinen hier. */
|
||||
leads: ResearchLead[]
|
||||
/** Zeitpunkt des Laufs, ISO. */
|
||||
refreshedAt: string
|
||||
/**
|
||||
* Welches Modell die Einordnung vorgenommen hat.
|
||||
*
|
||||
* Wird angezeigt, weil die Modellwahl über eine Umgebungsvariable läuft und
|
||||
* man sonst nirgends in der Anwendung sähe, welche gerade gilt — eine
|
||||
* Einstellung, die man nur im Hosting-Dashboard prüfen kann, prüft niemand.
|
||||
*/
|
||||
model: string
|
||||
/**
|
||||
* Gesetzt, wenn die Analyse nicht laufen konnte — etwa weil serverseitig
|
||||
* kein Schlüssel hinterlegt ist. Die Oberfläche sagt das dann offen, statt
|
||||
* eine leere Liste als «nichts gefunden» auszugeben.
|
||||
*/
|
||||
analysisError?: string
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useAgentWorkItems } from './useAgentWorkItems'
|
||||
import { useTeamAgents } from './useTeamAgents'
|
||||
import { answerAgentQuestion } from '../services/agentChatService'
|
||||
import type { AgentChatSubject } from '../stores/agentChatStore'
|
||||
|
||||
/**
|
||||
* Verbindet den Chat mit den Beständen, die auf der Agentenseite ohnehin
|
||||
* angezeigt werden (Runde 9, §3).
|
||||
*
|
||||
* Der Hook holt nur die Daten; was daraus wird, entscheidet
|
||||
* `agentChatService` — Zählweise, Zeitraum und Formulierungen sind fachliche
|
||||
* Festlegungen und gehören nicht in die Oberflächenschicht (CLAUDE.md §4.2).
|
||||
*
|
||||
* Bewusst dieselbe Quelle wie der Bearbeitungsverlauf: Was der Agent im Chat
|
||||
* sagt, muss sich im Verlauf nachzählen lassen. Zwei getrennte Zählungen wären
|
||||
* zwei Wahrheiten.
|
||||
*/
|
||||
export function useAgentChatAnswer() {
|
||||
const { data: workItems = [], isLoading: itemsLoading } = useAgentWorkItems()
|
||||
const { data: agents = [], isLoading: agentsLoading } = useTeamAgents()
|
||||
|
||||
return useCallback(
|
||||
(agent: AgentChatSubject, frage: string): string => {
|
||||
// Thomas ist Vorgesetzter und hat kein eigenes Personalblatt; für ihn
|
||||
// zählt der gesamte Bestand, für alle anderen nur der eigene.
|
||||
const eigene = agent.id === 'thomas'
|
||||
? workItems
|
||||
: workItems.filter(w => w.agentId === agent.id)
|
||||
|
||||
return answerAgentQuestion(
|
||||
{
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
agentRole: agent.role,
|
||||
dossier: agents.find(a => a.id === agent.id),
|
||||
workItems: eigene,
|
||||
loading: itemsLoading || agentsLoading,
|
||||
},
|
||||
frage,
|
||||
)
|
||||
},
|
||||
[workItems, agents, itemsLoading, agentsLoading],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { ResearchRefreshResult } from '../domain/researchLead'
|
||||
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
/**
|
||||
* «Research aktualisieren» — der eine Aufruf des PoC.
|
||||
*
|
||||
* Eine Mutation und keine Query: der Lauf ist eine Handlung des Nutzers, kein
|
||||
* Zustand, der sich von selbst aktualisiert. Deshalb auch bewusst kein
|
||||
* `staleTime` und keine Zwischenspeicherung — wer auf den Knopf drückt, will
|
||||
* den Stand von jetzt und nicht den von vorhin.
|
||||
*
|
||||
* Das Ergebnis lebt im Zustand der Komponente. Für den Machbarkeitsnachweis
|
||||
* ist keine Persistenz nötig (§17).
|
||||
*/
|
||||
export function useLiviaResearch() {
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
|
||||
return useMutation<ResearchRefreshResult, Error>({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, { method: 'POST' })
|
||||
|
||||
// Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
|
||||
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall
|
||||
// in der lokalen Entwicklung und verdient eine klare Meldung statt eines
|
||||
// «Unexpected token <».
|
||||
const typ = res.headers.get('content-type') ?? ''
|
||||
if (!typ.includes('application/json')) {
|
||||
throw new Error(
|
||||
'Der Recherche-Endpoint antwortet nicht. Lokal braucht es dafür «vercel dev» statt «vite».',
|
||||
)
|
||||
}
|
||||
if (!res.ok) throw new Error(`Der Recherche-Lauf schlug fehl (HTTP ${res.status}).`)
|
||||
|
||||
return await res.json() as ResearchRefreshResult
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -349,6 +349,16 @@ export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?'
|
||||
*/
|
||||
export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?'
|
||||
|
||||
/**
|
||||
* Einheitlicher KI-Hinweis in allen Agenten-Detailansichten (Runde 9, §5).
|
||||
*
|
||||
* Der Wortlaut ist vorgegeben und steht deshalb genau einmal hier. Sechsmal
|
||||
* abgetippt hiesse er nach der ersten Korrektur an fünf Stellen anders — und
|
||||
* ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner.
|
||||
*/
|
||||
export const AI_DISCLAIMER =
|
||||
'KI kann Fehler machen, bitte überprüfen Sie sämtliche Angaben'
|
||||
|
||||
// ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ──────────────────
|
||||
|
||||
export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -356,6 +366,26 @@ export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
RISIKO: 'Risiko',
|
||||
}
|
||||
|
||||
// ── Livia — Recherche aus realen Quellen (PoC, §11/§13) ──────────────────────
|
||||
|
||||
export const RESEARCH_EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
EXPANSION: 'Expansion',
|
||||
NEW_LOCATION: 'Neuer Standort',
|
||||
HEADCOUNT_GROWTH: 'Personalwachstum',
|
||||
INVESTMENT: 'Investition',
|
||||
M_AND_A: 'M&A',
|
||||
RESTRUCTURING: 'Restrukturierung',
|
||||
SITE_CLOSURE: 'Standortschliessung',
|
||||
NEW_COMPANY: 'Neugründung',
|
||||
IRRELEVANT: 'Nicht relevant',
|
||||
}
|
||||
|
||||
export const RESEARCH_RELEVANCE_LABELS: Record<string, string> = {
|
||||
high: 'hohe Relevanz',
|
||||
medium: 'mittlere Relevanz',
|
||||
low: 'geringe Relevanz',
|
||||
}
|
||||
|
||||
|
||||
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 — '
|
||||
+ 'Marktindikator ohne Firmennamen, erscheint deshalb nicht als Lead',
|
||||
},
|
||||
} as const satisfies Record<string, ResearchSourceDescriptor>
|
||||
|
||||
export const RESEARCH_SOURCE_LIST: ResearchSourceDescriptor[] = [
|
||||
RESEARCH_SOURCES.ZHK,
|
||||
RESEARCH_SOURCES.GZA,
|
||||
RESEARCH_SOURCES.ZEFIX,
|
||||
]
|
||||
|
||||
/** Der eine Endpoint des PoC (§20). */
|
||||
export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh'
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { AgentWorkItem } from '../domain/agentWorkItem'
|
||||
import { AgentWorkItemStatus } from '../domain/agentWorkItem'
|
||||
import type { TeamAgent } from '../domain/teamAgent'
|
||||
import { mockToday } from '../lib/constants'
|
||||
|
||||
/**
|
||||
* Property On — die Antworten der Agentenchats (Runde 9, §3).
|
||||
*
|
||||
* Bewusst eine Schlüsselwortlogik und kein Sprachmodell: Der Auftrag verlangt
|
||||
* ausdrücklich keine LLM- oder RAG-Infrastruktur, und für die drei Fragetypen
|
||||
* wäre sie auch nicht der Punkt. Der Punkt ist, dass die Zahlen stimmen.
|
||||
*
|
||||
* Jede Antwort stammt aus Beständen, die auf der Seite des jeweiligen Agenten
|
||||
* ohnehin sichtbar sind. Nichts wird geschätzt, nichts hochgerechnet: Findet
|
||||
* sich keine Grundlage, sagt der Agent das, statt eine plausible Zahl zu
|
||||
* nennen. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort — sie
|
||||
* sieht aus wie eine Auskunft.
|
||||
*/
|
||||
|
||||
/** Was gefragt wurde. */
|
||||
export const ChatIntent = {
|
||||
/** «Womit kannst du mir helfen?» */
|
||||
CAPABILITIES: 'CAPABILITIES',
|
||||
/** «Wie viele Aufgaben sind in deinem Log?» */
|
||||
LOG_COUNT: 'LOG_COUNT',
|
||||
/** «Wie viele Aufträge wurden letzten Monat bearbeitet?» */
|
||||
LAST_MONTH: 'LAST_MONTH',
|
||||
/** Nichts davon erkannt. */
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const
|
||||
export type ChatIntent = typeof ChatIntent[keyof typeof ChatIntent]
|
||||
|
||||
/**
|
||||
* Erkennung per Schlüsselwörtern.
|
||||
*
|
||||
* Die Reihenfolge entscheidet: «Wie viele Aufträge wurden letzten Monat
|
||||
* bearbeitet?» enthält auch das Wort «Aufträge» und würde sonst als Frage nach
|
||||
* dem Log gelesen. Der Zeitbezug ist die speziellere Aussage und wird deshalb
|
||||
* zuerst geprüft.
|
||||
*/
|
||||
export function detectIntent(text: string): ChatIntent {
|
||||
const t = text.toLowerCase()
|
||||
|
||||
const zeitbezug = /letzte[nrs]?\s+(monat|30\s*tage)|vormonat|vergangenen\s+monat/.test(t)
|
||||
const menge = /wie\s*viel|anzahl|wie\s*oft/.test(t)
|
||||
|
||||
if (zeitbezug && (menge || /auftr|fäll|falle|vorgäng|bearbeit|erledigt|task|aufgab/.test(t))) {
|
||||
return ChatIntent.LAST_MONTH
|
||||
}
|
||||
if (menge && /log|aufgab|task|auftr|vorgäng|eintr|fäll|falle|pendent|offen/.test(t)) {
|
||||
return ChatIntent.LOG_COUNT
|
||||
}
|
||||
if (/womit|was\s+kannst|wobei|wofür|hilf|helfen|kannst du|zuständig|aufgabe(n)?\s*bereich|rolle/.test(t)) {
|
||||
return ChatIntent.CAPABILITIES
|
||||
}
|
||||
return ChatIntent.UNKNOWN
|
||||
}
|
||||
|
||||
export interface AgentChatInput {
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentRole: string
|
||||
/** Personaldossier des Agenten, sofern vorhanden — Thomas hat keines. */
|
||||
dossier?: TeamAgent
|
||||
/**
|
||||
* Vorgänge aus dem Bearbeitungsverlauf. Für einen einzelnen Agenten seine
|
||||
* eigenen; für Thomas als Vorgesetzten die aller Agenten.
|
||||
*/
|
||||
workItems: AgentWorkItem[]
|
||||
/** Sind die Bestände schon geladen? Sonst wird nicht gezählt, sondern gewartet. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
/** Kalendermonat vor dem Stichtag der Mockdaten. */
|
||||
function letzterMonat(): { von: Date; bis: Date; name: string } {
|
||||
const heute = mockToday()
|
||||
const von = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth() - 1, 1))
|
||||
const bis = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth(), 1))
|
||||
return {
|
||||
von,
|
||||
bis,
|
||||
name: von.toLocaleDateString('de-CH', { month: 'long', year: 'numeric', timeZone: 'UTC' }),
|
||||
}
|
||||
}
|
||||
|
||||
function plural(n: number, ein: string, viele: string): string {
|
||||
return `${n} ${n === 1 ? ein : viele}`
|
||||
}
|
||||
|
||||
function antwortFaehigkeiten(input: AgentChatInput): string {
|
||||
const { agentName, agentRole, dossier } = input
|
||||
|
||||
// Der Kurzbeschrieb aus dem Personalblatt ist genau der Satz, der auch in der
|
||||
// Personalverwaltung steht — der Chat soll nichts anderes behaupten als das
|
||||
// Dossier.
|
||||
if (dossier) {
|
||||
const aufgaben = dossier.responsibilities.slice(0, 3)
|
||||
return [
|
||||
`Ich bin ${agentName}, ${agentRole}. ${dossier.shortDescription}`,
|
||||
aufgaben.length > 0
|
||||
? `Konkret kümmere ich mich unter anderem um: ${aufgaben.join('; ')}.`
|
||||
: '',
|
||||
'Frag mich zum Beispiel, wie viele Aufgaben in meinem Log stehen oder wie viele Aufträge ich letzten Monat bearbeitet habe.',
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
return [
|
||||
`Ich bin ${agentName}, ${agentRole}. Ich führe die fünf digitalen Mitarbeitenden und habe ihren gesamten Bearbeitungsverlauf im Blick.`,
|
||||
'Frag mich zum Beispiel, wie viele Vorgänge im Log stehen oder wie viele letzten Monat bearbeitet wurden.',
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
function antwortLog(input: AgentChatInput): string {
|
||||
const { workItems, agentId } = input
|
||||
const gesamt = workItems.length
|
||||
|
||||
if (gesamt === 0) {
|
||||
return 'In meinem Log steht derzeit kein Vorgang. Sobald einer eingeht, siehst du ihn im Bearbeitungsverlauf.'
|
||||
}
|
||||
|
||||
const erledigt = workItems.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length
|
||||
const pendent = workItems.filter(w => w.requiresDecision).length
|
||||
const wessen = agentId === 'thomas' ? 'Im Log aller Agenten stehen' : 'In meinem Log stehen'
|
||||
|
||||
return [
|
||||
`${wessen} ${plural(gesamt, 'Vorgang', 'Vorgänge')}.`,
|
||||
`Davon ${plural(erledigt, 'erledigt', 'erledigt')}${pendent > 0 ? ` und ${plural(pendent, 'wartet auf deine Entscheidung', 'warten auf deine Entscheidung')}` : ''}.`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function antwortLetzterMonat(input: AgentChatInput): string {
|
||||
const { workItems, agentId } = input
|
||||
const { von, bis, name } = letzterMonat()
|
||||
|
||||
const imZeitraum = workItems.filter(w => {
|
||||
const stempel = w.completedAt ?? w.createdAt
|
||||
const t = new Date(stempel).getTime()
|
||||
return Number.isFinite(t) && t >= von.getTime() && t < bis.getTime()
|
||||
})
|
||||
|
||||
const wer = agentId === 'thomas' ? 'Die Agenten haben' : 'Ich habe'
|
||||
|
||||
if (imZeitraum.length === 0) {
|
||||
return [
|
||||
`Im ${name} ist in meinem Bestand kein Vorgang verzeichnet.`,
|
||||
`Insgesamt liegen ${plural(workItems.length, 'Vorgang', 'Vorgänge')} im Log — die verteilen sich auf andere Monate.`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
const erledigt = imZeitraum.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length
|
||||
return `${wer} im ${name} ${plural(imZeitraum.length, 'Vorgang', 'Vorgänge')} bearbeitet, davon ${erledigt} abgeschlossen.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Antwort auf eine Chatfrage — oder eine ehrliche Auskunft darüber, dass
|
||||
* die Frage nicht verstanden wurde.
|
||||
*/
|
||||
export function answerAgentQuestion(input: AgentChatInput, frage: string): string {
|
||||
if (input.loading) {
|
||||
return 'Einen Moment — ich hole gerade meine Daten.'
|
||||
}
|
||||
|
||||
switch (detectIntent(frage)) {
|
||||
case ChatIntent.CAPABILITIES:
|
||||
return antwortFaehigkeiten(input)
|
||||
case ChatIntent.LOG_COUNT:
|
||||
return antwortLog(input)
|
||||
case ChatIntent.LAST_MONTH:
|
||||
return antwortLetzterMonat(input)
|
||||
default:
|
||||
return [
|
||||
'Das habe ich nicht verstanden. In dieser Demo beantworte ich drei Arten von Fragen:',
|
||||
'· Womit kannst du mir helfen?',
|
||||
'· Wie viele Aufgaben sind in deinem Log?',
|
||||
'· Wie viele Aufträge wurden letzten Monat bearbeitet?',
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/** Eine Zeile im Chatverlauf. */
|
||||
export interface AgentChatMessage {
|
||||
from: 'user' | 'agent'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface AgentChatSubject {
|
||||
id: string
|
||||
name: string
|
||||
@@ -19,12 +25,21 @@ interface AgentChatState {
|
||||
* Scrollen mitten im Tippen den Fokus (§9).
|
||||
*/
|
||||
focused: boolean
|
||||
/**
|
||||
* Der Verlauf des laufenden Gesprächs (Runde 9, §3).
|
||||
*
|
||||
* Bewusst nicht gespeichert: Er lebt, solange die Seite offen ist, und ist
|
||||
* beim nächsten Besuch leer. Ein Verlauf, der Tage überdauert, weckte die
|
||||
* Erwartung, der Agent erinnere sich — und das täte er nicht.
|
||||
*/
|
||||
messages: AgentChatMessage[]
|
||||
|
||||
openFor: (agent: AgentChatSubject) => void
|
||||
close: () => void
|
||||
setDraft: (draft: string) => void
|
||||
setCompact: (compact: boolean) => void
|
||||
setFocused: (focused: boolean) => void
|
||||
append: (message: AgentChatMessage) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,16 +59,19 @@ export const useAgentChatStore = create<AgentChatState>((set) => ({
|
||||
draft: '',
|
||||
compact: false,
|
||||
focused: false,
|
||||
messages: [],
|
||||
|
||||
// Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in
|
||||
// Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen.
|
||||
// Ein Agentenwechsel setzt Entwurf und Verlauf zurück: Text für Nora gehört
|
||||
// nicht in Ferdis Eingabefeld, und ihre Antwort erst recht nicht in seinen
|
||||
// Verlauf. Dieselbe Seite erneut zu betreten erhält beides dagegen.
|
||||
openFor: (agent) =>
|
||||
set(s => (s.agent?.id === agent.id
|
||||
? { agent }
|
||||
: { agent, draft: '', compact: false, focused: false })),
|
||||
: { agent, draft: '', compact: false, focused: false, messages: [] })),
|
||||
|
||||
close: () => set({ agent: null, compact: false, focused: false }),
|
||||
setDraft: (draft) => set({ draft }),
|
||||
setCompact: (compact) => set({ compact }),
|
||||
setFocused: (focused) => set({ focused }),
|
||||
append: (message) => set(s => ({ messages: [...s.messages, message] })),
|
||||
}))
|
||||
@@ -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"]
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.api.json" }
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,10 @@
|
||||
"buildCommand": "npx vite build",
|
||||
"outputDirectory": "dist",
|
||||
"framework": null,
|
||||
"functions": {
|
||||
"api/**/*.ts": {
|
||||
"maxDuration": 60
|
||||
}
|
||||
},
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||
}
|
||||
@@ -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<void> }).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: {
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user