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>
108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
/**
|
|
* Livia Research-PoC — der eine serverseitige Endpoint (§20).
|
|
*
|
|
* POST /api/livia/research/refresh
|
|
*
|
|
* Ruft die drei Quellen ab, normalisiert, beurteilt einmal per LLM und gibt
|
|
* die relevanten Leads zurück. Kein Scheduler, keine Queue, keine Datenbank —
|
|
* der Lauf beginnt und endet mit dieser Anfrage.
|
|
*
|
|
* Läuft als serverlose Funktion auf derselben Vercel-Instanz, die schon die
|
|
* Anwendung ausliefert. Das ist der Grund, warum für den PoC gar keine neue
|
|
* Infrastruktur nötig war: der Hoster kann bereits Serverfunktionen, es hat
|
|
* bisher nur niemand eine gebraucht.
|
|
*/
|
|
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import {
|
|
SOURCES,
|
|
fetchZhkResearch,
|
|
fetchGreaterZurichResearch,
|
|
fetchZefixResearch,
|
|
} from '../../_lib/researchSources'
|
|
import type { ResearchItem } from '../../_lib/researchSources'
|
|
import { analyzeResearchItems } from '../../_lib/researchAnalysis'
|
|
import type { ResearchRefreshResult, ResearchSourceResult } from '../../../src/domain/researchLead'
|
|
|
|
interface Quelle {
|
|
id: string
|
|
name: string
|
|
url: string
|
|
laden: () => Promise<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(),
|
|
}
|
|
|
|
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.',
|
|
}))
|
|
}
|
|
}
|