Compare commits

..
12 Commits
Author SHA1 Message Date
Benjamin SutterandClaude Opus 5 c6f3b2d6e3 feat(agenten): Runde 9 — statisches Sitzungszimmer, kompakte Tabelle, Chats, KI-Hinweis
**Sitzungszimmer statisch.** Die Kamera folgte dem Zeiger um bis zu 7,5
Zentimeter — als Belebung gedacht, als Wackeln wahrgenommen. Entfernt ist nur
der Aufruf der Parallaxe samt der nun unbenutzten Methode; Kameraposition,
Blickziel, Sitzordnung, Bilder und Beschriftungen sind unverändert. Das
Erkennen des Agenten unter dem Zeiger und der Klick auf seinen Platz bleiben —
das ist keine Bewegung.

**«Meine Objekte» ohne Querscrollen.** Bei automatischer Spaltenbreite richtet
sich jede Spalte nach ihrem längsten Inhalt; bei elf Spalten summierte sich das
über die Fensterbreite hinaus. Jetzt feste Anteile, die zusammen 100 % ergeben,
schmaleres Zellenpolster und Wortumbruch statt `nowrap` beim Mieter. Keine
Spalte entfernt, kein Wert gekürzt.

**Chats antworten.** Bisher meldete das Absenden nur, der Chat sei nicht
angebunden. Jetzt beantwortet jeder Agent — Thomas eingeschlossen — drei
Fragetypen aus dem Bearbeitungsverlauf seiner eigenen Seite: Rolle, Anzahl
Vorgänge im Log, Vorgänge im Vormonat. Schlüsselwortlogik statt Sprachmodell,
wie gefordert.

Die Zeitfrage wird vor der Mengenfrage geprüft: «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. Findet sich im Zeitraum nichts, sagt der Agent das
und nennt zum Vergleich den Gesamtbestand — statt eine plausible Zahl zu
erfinden. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort: sie
sieht aus wie eine Auskunft.

Der Verlauf lebt nur, solange die Seite offen ist. Ein Verlauf, der Tage
überdauert, weckte die Erwartung, der Agent erinnere sich.

**KI-Hinweis überall gleich.** «KI kann Fehler machen, bitte überprüfen Sie
sämtliche Angaben» steht als eine Konstante und eine Komponente in allen sechs
Detailansichten. Bei Livia ersetzt er den Quellenvermerk und den Block
«Empfohlene nächste Handlung»; die Herkunft steht ohnehin oben bei der Quelle.

Geprüft im Browser gegen den Produktionsbau: Szene reagiert nicht mehr auf
Mausbewegung (Pixelvergleich), Tabelle 11 Spalten ohne Querscrollen
(scrollWidth = clientWidth), alle sechs Chats antworten agentenspezifisch mit
Zahlen, die den Mockdaten entsprechen, unbekannte Fragen werden sauber
abgewiesen, Hinweis in allen sechs Detailansichten vorhanden, 0 Laufzeitfehler.
423/423 Tests grün, tsc und eslint sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:52:43 +02:00
Benjamin SutterandClaude Opus 5 f2926d6bcb fix(livia): effort-Schalter nur an Modelle, die ihn kennen
Nach der Umstellung auf Haiku 4.5 scheiterte jeder Beurteilungsaufruf mit
«This model does not support the effort parameter». Der Schalter war für Opus
gesetzt und wurde bedingungslos mitgeschickt — bei einem Modell, das über eine
Umgebungsvariable frei wählbar ist, darf das nicht sein. Eine
Kostenoptimierung, die die Funktion zur Laufzeit kaputtmacht, ist keine.

Der Schalter geht jetzt nur noch an Modelle, die ihn kennen. Bewusst als
Erlaubnisliste: ein unbekanntes Modell läuft ohne ihn — das kostet etwas mehr,
funktioniert aber. Die umgekehrte Voreinstellung würde raten und scheitern.

Ergebnis des ersten vollständigen Haiku-Laufs auf Produktion: 49 Einträge, 38
verworfen, 2 Watchlist, 9 Leads, davon 5 mit hoher Relevanz — mehr als Opus 5
im Vergleichslauf am selben Bestand fand, bei einem Zehntel der Kosten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:12:28 +02:00
Benjamin SutterandClaude Opus 5 f0ef9c57d4 feat(livia): das verwendete Modell steht im Ergebnis
Die Modellwahl läuft über eine Umgebungsvariable im Hosting-Dashboard. Damit
sah man nirgends in der Anwendung, welches Modell gerade urteilt — und eine
Einstellung, die man nur im Dashboard prüfen kann, prüft niemand.

Der Refresh gibt das Modell jetzt zurück, die Kopfzeile zeigt es neben dem
Zeitstempel. Wer wissen will, ob die Umstellung angekommen ist, sieht es dort,
wo er ohnehin hinschaut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:28:04 +02:00
Benjamin SutterandClaude Opus 5 c19f071acc feat(livia): Modell und Textlänge über Umgebungsvariablen einstellbar
Das Modell stand fest im Code, obwohl es die grösste Kostenstellschraube ist —
ein Lauf über fünfzig Meldungen kostet mit Opus 5 rund 0.40 USD, mit Haiku 4.5
etwa 0.08. Wer die Anwendung betreibt, soll diese Abwägung treffen können, ohne
den Quelltext anzufassen und neu zu deployen.

`LIVIA_RESEARCH_MODEL` wählt das Modell, `LIVIA_RESEARCH_TEXT_LIMIT` die Menge
Artikeltext je Meldung — der zweite grosse Hebel, weil der Immobilienbezug einer
Firmenmeldung fast immer im ersten Absatz steht und jede Halbierung die
Eingabekosten halbiert. Beide haben Vorgabewerte; ohne gesetzte Variablen
verhält sich alles wie bisher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:18:11 +02:00
Benjamin SutterandClaude Opus 5 e986e9f274 fix(livia): ein Totalausfall der Beurteilung wird nicht mehr verschwiegen
Beim Messen des Tokenverbrauchs fiel auf, dass ein Lauf «49 Informationen
analysiert · 0 nicht relevant · 0 Watchlist · 0 relevante Entwicklungen»
meldete — was wie ein sauberer Lauf ohne Treffer aussieht. Tatsächlich war
jeder einzelne Beurteilungsaufruf mit «Your credit balance is too low»
gescheitert. Der `catch` um den Teilstapel fing den Fehler ab, damit ein
Ausfall nicht die übrigen mitreisst, und verlor dabei den Grund.

Scheitern alle Teilstapel, ist das kein leeres Ergebnis, sondern ein Ausfall.
Der Grund wird jetzt weitergereicht und erscheint als Warnung über der Liste.
Scheitern nur einzelne, bleibt es beim bisherigen Verhalten: die übrigen Leads
werden gezeigt.

Das ist die unangenehmste Fehlerklasse überhaupt — eine, die wie ein Ergebnis
aussieht. In einer Präsentation hätte niemand gemerkt, dass Livia gar nicht
gearbeitet hat.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen; der Lauf meldet den Fehler
jetzt sichtbar statt eine leere Liste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:14:36 +02:00
Benjamin SutterandClaude Opus 5 a028d1a21d feat(livia): Zefix als Marktindikator statt als Lead
Die Zefix-CSV nennt keine Firmennamen, sondern Tageszahlen je Branche. Als
Lead-Karte zwischen sechs echten Firmenmeldungen war sie ein Fremdkörper: Man
kann eine Branchenzahl nicht anrufen, und genau das verspricht eine Lead-Karte
mit «Empfohlene nächste Handlung».

Aggregierte Einträge werden weiterhin gelesen und beurteilt — die Quelle bleibt
angebunden und belegt, dass Livia auch strukturierte Daten verarbeitet —, zählen
aber als Beobachtungsposten statt als Lead. Das entspricht der «Early
Watch»-Einordnung der Vorgabe (§19), und die Zahlen der Kopfzeile gehen
weiterhin auf.

Die Quellenbeschreibung sagt jetzt selbst, warum dort keine Karte erscheint,
statt dass sich das jemand aus dem Ausbleiben erschliessen muss.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Build erfolgreich,
Live-Lauf 49 Einträge — Zefix erscheint nicht mehr als Lead, Summe aus
verworfen, Watchlist und Leads ergibt die Gesamtzahl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:07:47 +02:00
Benjamin SutterandClaude Opus 5 97b7110a44 fix(livia): Einheit an der Quellenanzeige
«Live · 24» liest sich genauso gut als «24 gefundene Leads» — beim ersten
Zeigen wurde es auch so verstanden. Die Zahl trägt jetzt ihre Einheit, mit
Einzahl bei genau einem Eintrag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 19:02:29 +02:00
Benjamin SutterandClaude Opus 5 31a098f219 fix(livia): eigenes Zeitlimit für die Zefix-CSV
Im Produktionslauf fiel Zefix mit «This operation was aborted» aus, während
beide Newsquellen sauber lieferten. Ursache ist die Dateigrösse: 10,5 MB,
unkomprimiert, weil der Server kein gzip anbietet. Von einem Schweizer
Anschluss dauert der Abruf 1,7 Sekunden, aus dem Rechenzentrum der Anwendung
mehr als die fünfzehn, die für die Newsquellen reichlich bemessen sind.

Die CSV bekommt deshalb dreissig Sekunden, die HTML-Abrufe behalten fünfzehn.
Beides läuft parallel, der Ausfall einer Quelle bleibt folgenlos für die
anderen — nur war es bisher immer dieselbe, die ausfiel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:16:12 +02:00
Benjamin SutterandClaude Opus 5 f58d841e7d feat(livia): drei Übersichtsseiten je Newsquelle statt einer
Eine Seite reicht nicht. Die Handelskammer erneuert ihre obersten zehn
Meldungen im Lauf eines Tages; stehen dort gerade Personalien und
Meinungsbeiträge, findet Livia zu Recht nichts, und die Demo wirkt schwach,
obwohl die Mechanik stimmt. Genau das war zwischen zwei Läufen zu beobachten.

Neu werden je Quelle drei Übersichtsseiten gelesen — bei der Handelskammer über
`…/page/N.html`, bei Greater Zurich über `?page=N` —, gedeckelt auf 24 Artikel
je Quelle. Die Artikel werden parallel geholt statt nacheinander, die
Beurteilung läuft in begrenzt parallelen Fünferstapeln.

Wirkung, gemessen am selben Tag: aus 20 gelesenen Einträgen und 1 Lead werden
49 Einträge und 8 Leads, darunter zwei mit hoher Relevanz — eine
Arealentwicklung über 38 000 m² und eine Standorteröffnung. Laufzeit 30
Sekunden statt 16, weiterhin klar innerhalb des 60-Sekunden-Limits.

Die Begrenzung der Gleichzeitigkeit ist kein Detail: alle Teilstapel 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. Ein
gescheiterter Teilstapel reisst die übrigen nicht mehr mit.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Build erfolgreich,
Live-Lauf 49 Einträge aus 3/3 Quellen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 18:10:58 +02:00
Benjamin SutterandClaude Opus 5 f53f3be1e8 fix(livia): Dateiendungen in den Importen der serverlosen Funktion
Erster Produktionslauf schlug mit HTTP 500 fehl, lokal lief derselbe Code
grün. Ursache: Vercel kompiliert jede Datei einzeln statt sie zu bündeln, und
weil das Projekt `"type": "module"` ist, verlangt Nodes ESM-Lader explizite
Dateiendungen in relativen Importen. Vite löst im Entwicklungsbetrieb
grosszügiger auf und verdeckt das.

Das ist genau die Klasse Fehler, die kein Typecheck und kein Test findet — nur
ein echter Aufruf gegen die deployte Funktion.

Geprüft gegen Produktion: HTTP 200, 20 Einträge aus 3/3 Quellen in 24
Sekunden, Beurteilung läuft, Originalquellen verlinkt, keine Laufzeitfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:41:48 +02:00
Benjamin SutterandClaude Opus 5 93ecc852e3 fix(livia): Beurteilung in Fünferstapeln statt einem Zwanzigerstapel
Der erste Live-Lauf mit Schlüssel deckte auf, dass ein einziger Aufruf über
alle zwanzig Einträge das Urteil verwässert. Eine Meldung über eine
zweistellige Millioneninvestition in eine neue Brauerei samt Arealentwicklung
fiel als «nicht relevant» durch; derselbe Prompt, dasselbe Modell und dieselbe
Meldung ergaben in einem Stapel von drei «Investition, hohe Relevanz, 0.85».

Eine höhere Effort-Stufe half nicht, sie machte es messbar schlechter. Der
Hebel ist die Stapelgrösse. Die Teilstapel laufen parallel — die Laufzeit ist
damit die des langsamsten und nicht deren Summe: 48 Sekunden vorher, 16 danach.

Das ist zugleich die Voraussetzung fürs Deployment: die serverlose Funktion
hat voreingestellt 10 Sekunden Zeit. `vercel.json` hebt das Limit nun
ausdrücklich auf 60 Sekunden an, was auch bei einer langsamen Quelle reicht.

Ausserdem: Der Linktext der Handelskammer trägt das Publikationsdatum vorneweg
und ein »-Zeichen hinten. Beides stand bisher im Titel, den das Modell zu lesen
bekam, und gehört dort nicht hin.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Live-Lauf 20 Einträge aus
3/3 Quellen in 17 Sekunden. Die Urteile sind stichprobenweise gegen die
Originalartikel geprüft und tragfähig — eine Bäckereieröffnung in
Westaustralien und eine Übernahme in Thailand werden zutreffend als ohne
Schweizer Flächenbezug verworfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 17:32:04 +02:00
Benjamin SutterandClaude Opus 5 2a0ba287f6 feat(livia): Research-PoC — drei reale Zürcher Quellen live
Livia liest jetzt tatsächlich aus dem Netz statt aus Mockdaten. Ein Knopf
«Research aktualisieren» ruft serverseitig drei Quellen ab, beurteilt die
Einträge in einem einzigen LLM-Aufruf und zeigt nur an, was plausibel zu
Flächenbedarf oder Flächenfreisetzung führen könnte.

Kein Crawler, kein Scheduler, keine Queue, keine Datenbank. Der Hoster kann
bereits serverlose Funktionen — es hat bisher nur niemand eine gebraucht.
Deshalb genügt eine einzige Datei unter `api/`, drei Abruffunktionen, eine
Normalisierung und ein Analyseaufruf.

Quellen, alle drei live geprüft:
- Zürcher Handelskammer, 10 Artikel
- Greater Zurich Area, 9 Artikel
- Zefix / Open Data Kanton Zürich, CSV direkt

Zur Zefix-Quelle eine Feststellung, die von der Aufgabenstellung abweicht: die
CSV enthält keine Firmennamen. Sie führt Tageszahlen von Neugründungen je
NOGA-Branche für den Kanton Zürich. Daraus lässt sich kein Unternehmenslead
bauen, sondern nur ein aggregiertes Frühsignal zum Zielmarkt — und genau so
wird es übergeben. Namen zu erfinden, um das erwartete Format zu treffen, wäre
die eine Sache, die Livia nie tun darf.

Die drei Quellen stehen im Frontend unter «Angebundene Kanäle & Systeme» mit
Status und anklickbarem Originallink; jeder erzeugte Lead verlinkt zusätzlich
die Seite, aus der er stammt. Die Adressen kommen aus derselben Liste, die der
Abruf benutzt — der angezeigte Link kann damit nicht von dem abweichen, was
tatsächlich gelesen wurde.

Der Textextraktor sucht den Artikeltext im engsten passenden Container. Ohne
das standen bei der Handelskammer Telefonnummern und Öffnungszeiten am Anfang
jedes Textes, und das Modell hätte die Fusszeile mitbeurteilt.

Der LLM-Schlüssel liegt ausschliesslich serverseitig in `ANTHROPIC_API_KEY`.
Fehlt er, sagt die Oberfläche offen, dass gelesen, aber nicht beurteilt wurde —
statt eine leere Liste als «nichts gefunden» auszugeben.

Für die lokale Entwicklung mountet ein Vite-Plugin dieselbe Handler-Datei, die
Vercel in der Produktion ausliefert; es gibt keine zweite Fassung des
Endpoints. `tsconfig.api.json` nimmt `api/` in `tsc -b` auf, damit ein
Tippfehler dort nicht erst beim Deployment auffällt.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, 423/423 Tests grün, Build
erfolgreich, Live-Lauf im Browser mit 20 real gelesenen Einträgen aus 3/3
Quellen ohne Laufzeitfehler. Die LLM-Beurteilung selbst ist mangels Schlüssel
noch ungetestet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:23:35 +02:00
35 changed files with 2196 additions and 83 deletions

No files matched your search

+273
View File
@@ -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 }
}
+338
View File
@@ -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,
}]
}
+108
View File
@@ -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.',
}))
}
}
+400 -1
View File
@@ -8,6 +8,7 @@
"name": "property-match", "name": "property-match",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.122.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -16,6 +17,7 @@
"@mui/icons-material": "^9.0.1", "@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1", "@mui/material": "^9.0.1",
"@tanstack/react-query": "^5.75.2", "@tanstack/react-query": "^5.75.2",
"cheerio": "^1.2.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"react": "^19.2.6", "react": "^19.2.6",
@@ -58,6 +60,27 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@asamuzakjp/css-color": {
"version": "5.1.11", "version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
@@ -1563,6 +1586,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2677,6 +2706,12 @@
"require-from-string": "^2.0.2" "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": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@@ -2764,6 +2799,81 @@
"node": ">=18" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2832,6 +2942,22 @@
"node": ">= 8" "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": { "node_modules/css-tree": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "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": "^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": { "node_modules/css.escape": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -2942,6 +3080,73 @@
"csstype": "^3.0.2" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.356", "version": "1.5.356",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
@@ -2949,6 +3154,19 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/enhanced-resolve": {
"version": "5.21.3", "version": "5.21.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
@@ -3246,6 +3464,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3469,6 +3693,49 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3704,6 +3971,19 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT" "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": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -4216,6 +4496,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "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" "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": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "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" "@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": { "node_modules/saxes": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -4785,6 +5156,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/std-env": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
@@ -4960,6 +5341,12 @@
"node": ">=20" "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": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -5034,7 +5421,6 @@
"version": "7.25.0", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"
@@ -5279,6 +5665,19 @@
"node": ">=20" "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": { "node_modules/whatwg-mimetype": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+2
View File
@@ -14,6 +14,7 @@
"check:tokens": "node scripts/check-tokens.js" "check:tokens": "node scripts/check-tokens.js"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.122.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -22,6 +23,7 @@
"@mui/icons-material": "^9.0.1", "@mui/icons-material": "^9.0.1",
"@mui/material": "^9.0.1", "@mui/material": "^9.0.1",
"@tanstack/react-query": "^5.75.2", "@tanstack/react-query": "^5.75.2",
"cheerio": "^1.2.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"react": "^19.2.6", "react": "^19.2.6",
+10 -17
View File
@@ -5,9 +5,10 @@ import type { MarketLead } from '../../hooks/useMarketLeads'
import { AgentAvatar, ObjectDeepLink } from '../team' import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces' import { agentWorkspaceById } from '../../lib/agentWorkspaces'
import { SOURCE_TYPE_LABELS } from '../../lib/constants' 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 { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge' import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
import { AiDisclaimer } from '../ui'
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels' import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds' 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 signalType = leadSignalTypeOf(signal)
const risiko = isRisiko(signal) const risiko = isRisiko(signal)
const rationale = signalRationaleOf(signal) const rationale = signalRationaleOf(signal)
const action = recommendedActionOf(signal)
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt. // Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
const relevant = useMemo( const relevant = useMemo(
@@ -215,16 +215,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
)} )}
</Box> </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). */} {/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
{showMatching && ( {showMatching && (
<> <>
@@ -248,11 +238,14 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
</> </>
)} )}
{signal.disclaimer && ( {/*
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}> An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals
{signal.disclaimer} («Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen
</Typography> 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>
</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 &amp; 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>
)
})
+92 -28
View File
@@ -1,27 +1,30 @@
import { useCallback, useMemo, useState } from 'react' 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 { LeadDetail, UnifiedLeadList } from '../market-leads'
import { ResearchSourcesPanel } from './ResearchSourcesPanel'
import { ResearchLeadCard } from './ResearchLeadCard'
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads' import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
import { LeadChannel } from '../../domain/unifiedLead' import { LeadChannel } from '../../domain/unifiedLead'
import type { UnifiedLead } from '../../domain/unifiedLead' import type { UnifiedLead } from '../../domain/unifiedLead'
import { LeadSignalType } from '../../domain/futureSignal' 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 * Zwei Bestände nebeneinander, bewusst getrennt gehalten:
* 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}`.
* *
* Damit ist die Grenze zwischen den beiden Agentinnen im Code gezogen und nicht * 1. **Live-Recherche** — was Livia beim Knopfdruck aus den drei realen
* bloss in der Beschriftung: Livia recherchiert und verdichtet, das Matching * Quellen gelesen und beurteilt hat. Diese Leads sind nicht gespeichert;
* gegen den eigenen Bestand bleibt Noras Arbeit (§1, §2). * 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 * Die Trennung ist Absicht: ein live gelesener Artikel ist etwas anderes als
* Lead-Ablage und keine Übergabekopie — ein Lead, zwei Arbeitsplätze. * 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() { export function ResearchWorkspace() {
const theme = useTheme() const theme = useTheme()
@@ -29,6 +32,8 @@ export function ResearchWorkspace() {
const [selected, setSelected] = useState<UnifiedLead | null>(null) const [selected, setSelected] = useState<UnifiedLead | null>(null)
const { data: leads, isLoading } = useUnifiedLeads() 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). // Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
const { research, chancen, risiken } = useMemo(() => { const { research, chancen, risiken } = useMemo(() => {
@@ -45,24 +50,83 @@ export function ResearchWorkspace() {
}, [leads]) }, [leads])
const closeDetail = useCallback(() => setSelected(null), []) const closeDetail = useCallback(() => setSelected(null), [])
const starten = useCallback(() => refresh.mutate(), [refresh])
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}> {/* ── Live-Recherche ──────────────────────────────────────────────── */}
{isLoading <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
? 'Leads werden geladen …' <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
: `${research.length} ${research.length === 1 ? 'Lead' : 'Leads'} aus externen Quellen · ` + <Button
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`} variant="contained"
</Typography> startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
disabled={refresh.isPending}
onClick={starten}
sx={{ textTransform: 'none', fontWeight: 600 }}
>
Research aktualisieren
</Button>
<UnifiedLeadList {refresh.isPending && (
leads={research} <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
isLoading={isLoading} Livia liest aktuelle Marktinformationen …
selectedId={selected?.id ?? null} </Typography>
onSelect={setSelected} )}
showChannel={false}
emptyText="Noch keine Leads aus der Marktbeobachtung. Sobald eine verknüpfte Quelle eine relevante Veränderung meldet, erscheint sie hier." {!refresh.isPending && 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 ? 'erfasster Lead' : 'erfasste Leads'} · ` +
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
</Typography>
<UnifiedLeadList
leads={research}
isLoading={isLoading}
selectedId={selected?.id ?? null}
onSelect={setSelected}
showChannel={false}
emptyText="Noch keine erfassten Leads. Was Livia gerade live gelesen hat, steht oberhalb."
/>
</Box>
<Drawer <Drawer
anchor="right" anchor="right"
+3 -1
View File
@@ -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 { ResearchWorkspace } from './ResearchWorkspace'
export { ResearchSourcesPanel } from './ResearchSourcesPanel'
export { ResearchLeadCard } from './ResearchLeadCard'
+13 -4
View File
@@ -159,7 +159,19 @@ export function MeetingRoom3D() {
} }
}, [positionLabels]) }, [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 handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const s = sceneRef.current const s = sceneRef.current
const wrap = wrapRef.current const wrap = wrapRef.current
@@ -169,8 +181,6 @@ export function MeetingRoom3D() {
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1 const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1) const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
s.applyParallax(nx, ny)
const ray = new THREE.Raycaster() const ray = new THREE.Raycaster()
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera) ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0] const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
@@ -187,7 +197,6 @@ export function MeetingRoom3D() {
const handlePointerLeave = useCallback(() => { const handlePointerLeave = useCallback(() => {
const s = sceneRef.current const s = sceneRef.current
if (!s) return if (!s) return
s.applyParallax(0, 0)
hoveredRef.current = null hoveredRef.current = null
s.setHovered(null) s.setHovered(null)
setHovered(null) setHovered(null)
+5 -4
View File
@@ -65,11 +65,12 @@ export const CAMERA = {
maxHFov: 84, maxHFov: 84,
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */ /** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
lookAt: [0, 1.24, -5.2] as const, lookAt: [0, 1.24, -5.2] as const,
/** /*
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die * Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20). * 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 } as const
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */ /** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
-13
View File
@@ -41,7 +41,6 @@ export interface MeetingRoomScene {
renderer: THREE.WebGLRenderer renderer: THREE.WebGLRenderer
seats: SeatHandle[] seats: SeatHandle[]
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */ /** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
applyParallax: (dx: number, dy: number) => void
setSize: (width: number, height: number) => void setSize: (width: number, height: number) => void
render: () => void render: () => void
setHovered: (id: string | null) => void setHovered: (id: string | null) => void
@@ -549,9 +548,6 @@ export function createMeetingRoomScene(
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe), addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
) )
const baseCam = camera.position.clone()
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
function render() { function render() {
renderer.render(scene, camera) renderer.render(scene, camera)
} }
@@ -562,15 +558,6 @@ export function createMeetingRoomScene(
renderer, renderer,
seats: handles, 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) { setSize(width, height) {
renderer.setSize(width, height, false) renderer.setSize(width, height, false)
const aspect = width / Math.max(height, 1) const aspect = width / Math.max(height, 1)
@@ -4,6 +4,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import type { Property, UpdatePropertyInput } from '../../domain/property' import type { Property, UpdatePropertyInput } from '../../domain/property'
import { AiDisclaimer } from '../ui'
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties' import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton' import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
import { useToastStore } from '../../stores/toastStore' import { useToastStore } from '../../stores/toastStore'
@@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) {
) )
})} })}
</Box> </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> </Box>
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */} {/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
+39 -3
View File
@@ -115,7 +115,43 @@ export function PropertyTable({
return ( return (
<Box sx={{ overflowX: 'auto' }}> <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> <TableHead>
<TableRow sx={{ bgcolor: 'grey.50' }}> <TableRow sx={{ bgcolor: 'grey.50' }}>
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell> <TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
@@ -205,7 +241,7 @@ export function PropertyTable({
{/* Aktueller Mieter */} {/* Aktueller Mieter */}
<TableCell> <TableCell>
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}> <Typography variant="body2">
{p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>} {p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>}
</Typography> </Typography>
</TableCell> </TableCell>
@@ -249,7 +285,7 @@ export function PropertyTable({
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'} title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
placement="top" placement="top"
> >
<Box sx={{ width: 80 }}> <Box sx={{ width: '100%', maxWidth: 90 }}>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={qScore * 100} value={qScore * 100}
@@ -4,6 +4,7 @@ import {
} from '@mui/material' } from '@mui/material'
import { X, Calendar } from 'lucide-react' import { X, Calendar } from 'lucide-react'
import { useReminderStore } from '../../stores/reminderStore' import { useReminderStore } from '../../stores/reminderStore'
import { AiDisclaimer } from '../ui'
import { import {
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder, useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
} from '../../hooks/useReminders' } from '../../hooks/useReminders'
@@ -201,6 +202,8 @@ export function ReminderDetailDrawer() {
</Box> </Box>
</> </>
)} )}
<AiDisclaimer />
</Box> </Box>
)} )}
</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 { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
import { SendHorizonal } from 'lucide-react' import { SendHorizonal } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
import { AgentChatTranscript } from './AgentChatTranscript'
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat' import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
import { AGENT_CHAT_PROMPT } from '../../lib/constants' import { AGENT_CHAT_PROMPT } from '../../lib/constants'
import { DS_BORDER, DS_TEXT } from '../../lib/ds' import { DS_BORDER, DS_TEXT } from '../../lib/ds'
@@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
</Box> </Box>
)} )}
</Box> </Box>
<AgentChatTranscript />
</Box> </Box>
) )
} }
@@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material'
import { HelpCircle, X } from 'lucide-react' import { HelpCircle, X } from 'lucide-react'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { useTeamStore } from '../../stores/teamStore' import { useTeamStore } from '../../stores/teamStore'
import { AiDisclaimer } from '../ui'
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems' import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
import { useTeamAgents } from '../../hooks/useTeamAgents' import { useTeamAgents } from '../../hooks/useTeamAgents'
import { AgentAvatar } from './AgentAvatar' import { AgentAvatar } from './AgentAvatar'
@@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() {
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography> <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
</Box> </Box>
)} )}
<AiDisclaimer />
</Box> </Box>
)} )}
</Box> </Box>
+3
View File
@@ -87,3 +87,6 @@ export {
PROTOCOL_STATUS_SURFACE, PROTOCOL_STATUS_SURFACE,
PROTOCOL_STATUS_TEXT, PROTOCOL_STATUS_TEXT,
} from './teamTokens' } from './teamTokens'
// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg.
export { AgentChatTranscript } from './AgentChatTranscript'
+21 -8
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import type { FocusEvent, KeyboardEvent } from 'react' import type { FocusEvent, KeyboardEvent } from 'react'
import { useAgentChatStore } from '../../stores/agentChatStore' 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 * Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
@@ -29,7 +29,10 @@ interface Options {
* vergessen. * vergessen.
* *
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und * 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 = {}) { export function useAgentChat({ active = true }: Options = {}) {
const agent = useAgentChatStore(s => s.agent) const agent = useAgentChatStore(s => s.agent)
@@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) {
const focused = useAgentChatStore(s => s.focused) const focused = useAgentChatStore(s => s.focused)
const setDraft = useAgentChatStore(s => s.setDraft) const setDraft = useAgentChatStore(s => s.setDraft)
const setFocused = useAgentChatStore(s => s.setFocused) 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) const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
@@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) {
el.setSelectionRange(end, end) el.setSelectionRange(end, end)
}, [active, focused]) }, [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 send = useCallback(() => {
const text = draft.trim() const text = draft.trim()
if (!text || !agent) return if (!text || !agent) return
setDraft('') setDraft('')
showToast( append({ from: 'user', text })
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`, append({ from: 'agent', text: answerFor(agent, text) })
'info', }, [draft, agent, setDraft, append, answerFor])
)
}, [draft, agent, setDraft, showToast])
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => { (e: KeyboardEvent<HTMLDivElement>) => {
+38
View File
@@ -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>
)
})
+3
View File
@@ -12,3 +12,6 @@ export { ToastProvider } from './ToastProvider'
export { ConfirmDialog } from './ConfirmDialog' export { ConfirmDialog } from './ConfirmDialog'
export { DecisionContextPanel } from './DecisionContextPanel' export { DecisionContextPanel } from './DecisionContextPanel'
export type { DecisionMetric, DecisionAction } 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 { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material'
import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react' import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react'
import type { VisitAssignment } from '../../domain/visitAssignment' import type { VisitAssignment } from '../../domain/visitAssignment'
import { AiDisclaimer } from '../ui'
import { VisitRequestType } from '../../domain/visitAssignment' import { VisitRequestType } from '../../domain/visitAssignment'
import { needsReportWarning } from '../../services/visitAssignmentService' import { needsReportWarning } from '../../services/visitAssignmentService'
import { useRequestVisitReport } from '../../hooks/useVisitAssignments' import { useRequestVisitReport } from '../../hooks/useVisitAssignments'
@@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) {
</Box> </Box>
</> </>
)} )}
<AiDisclaimer />
</Box> </Box>
</> </>
)} )}
+89
View File
@@ -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
}
+45
View File
@@ -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],
)
}
+42
View File
@@ -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')
},
})
}
+30
View File
@@ -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?' 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) ────────────────── // ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ──────────────────
export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = { export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
@@ -356,6 +366,26 @@ export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
RISIKO: 'Risiko', 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) ────────────────────────────── // ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
+52
View File
@@ -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'
+178
View File
@@ -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')
}
}
+21 -3
View File
@@ -1,5 +1,11 @@
import { create } from 'zustand' import { create } from 'zustand'
/** Eine Zeile im Chatverlauf. */
export interface AgentChatMessage {
from: 'user' | 'agent'
text: string
}
export interface AgentChatSubject { export interface AgentChatSubject {
id: string id: string
name: string name: string
@@ -19,12 +25,21 @@ interface AgentChatState {
* Scrollen mitten im Tippen den Fokus (§9). * Scrollen mitten im Tippen den Fokus (§9).
*/ */
focused: boolean 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 openFor: (agent: AgentChatSubject) => void
close: () => void close: () => void
setDraft: (draft: string) => void setDraft: (draft: string) => void
setCompact: (compact: boolean) => void setCompact: (compact: boolean) => void
setFocused: (focused: boolean) => void setFocused: (focused: boolean) => void
append: (message: AgentChatMessage) => void
} }
/** /**
@@ -44,16 +59,19 @@ export const useAgentChatStore = create<AgentChatState>((set) => ({
draft: '', draft: '',
compact: false, compact: false,
focused: false, focused: false,
messages: [],
// Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in // Ein Agentenwechsel setzt Entwurf und Verlauf zurück: Text für Nora gehört
// Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen. // nicht in Ferdis Eingabefeld, und ihre Antwort erst recht nicht in seinen
// Verlauf. Dieselbe Seite erneut zu betreten erhält beides dagegen.
openFor: (agent) => openFor: (agent) =>
set(s => (s.agent?.id === agent.id set(s => (s.agent?.id === agent.id
? { agent } ? { agent }
: { agent, draft: '', compact: false, focused: false })), : { agent, draft: '', compact: false, focused: false, messages: [] })),
close: () => set({ agent: null, compact: false, focused: false }), close: () => set({ agent: null, compact: false, focused: false }),
setDraft: (draft) => set({ draft }), setDraft: (draft) => set({ draft }),
setCompact: (compact) => set({ compact }), setCompact: (compact) => set({ compact }),
setFocused: (focused) => set({ focused }), setFocused: (focused) => set({ focused }),
append: (message) => set(s => ({ messages: [...s.messages, message] })),
})) }))
+30
View File
@@ -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
View File
@@ -2,6 +2,7 @@
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.api.json" }
] ]
} }
+5
View File
@@ -2,5 +2,10 @@
"buildCommand": "npx vite build", "buildCommand": "npx vite build",
"outputDirectory": "dist", "outputDirectory": "dist",
"framework": null, "framework": null,
"functions": {
"api/**/*.ts": {
"maxDuration": 60
}
},
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
} }
+35
View File
@@ -1,11 +1,46 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' 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({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
tailwindcss(), tailwindcss(),
liviaResearchDevEndpoint(),
], ],
build: { build: {
/** /**