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>
154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
/**
|
|
* Livia Research-PoC — die Beurteilung der gelesenen Einträge.
|
|
*
|
|
* Ein einziger Aufruf für alle Einträge statt einer Anfrage je Artikel (§22).
|
|
* Das hält Laufzeit und Kosten klein und gibt dem Modell zugleich den
|
|
* Gesamtblick, den es für die Einordnung «relevant oder nicht» braucht.
|
|
*
|
|
* Der Schlüssel steht ausschliesslich serverseitig in `ANTHROPIC_API_KEY` —
|
|
* diese Datei läuft in der serverlosen Funktion und landet nie im Bundle (§10).
|
|
*/
|
|
|
|
import Anthropic from '@anthropic-ai/sdk'
|
|
import { z } from 'zod'
|
|
import type { ResearchItem } from './researchSources'
|
|
import type { ResearchLead } from '../../src/domain/researchLead'
|
|
|
|
/**
|
|
* Der Beurteilungsauftrag (§12).
|
|
*
|
|
* Bewusst kurz. Zwei Sätze tragen die eigentliche Arbeit: der Relevanzbegriff
|
|
* und das Verbot, etwas hinzuzuerfinden. Alles Weitere wäre Ausschmückung, die
|
|
* das Modell eher zu geschmeidigen als zu belegten Antworten verleitet.
|
|
*/
|
|
const SYSTEM_PROMPT = `Du bist Livia, Research & Market Intelligence Agent für professionelle Schweizer Gewerbeimmobilien-Bewirtschaftungen.
|
|
|
|
Analysiere die bereitgestellten Unternehmensmeldungen.
|
|
|
|
Relevant sind nur Entwicklungen, die plausibel zu zusätzlichem Gewerbeflächenbedarf, einem neuen Standort, einer Standortveränderung, Flächenreduktion oder zukünftiger Wiedervermietung führen könnten.
|
|
|
|
Allgemeine Unternehmensnachrichten ohne plausiblen Immobilienbezug sind IRRELEVANT.
|
|
|
|
Bewerte konservativ. Erfinde keine Informationen, die nicht in der Quelle stehen. Wenn eine Meldung keinen Firmennamen nennt, schreibe in "company" das, was die Quelle tatsächlich benennt — erfinde keinen Namen.
|
|
|
|
Antworte ausschliesslich mit einem JSON-Objekt der Form:
|
|
{"results":[{"index":0,"company":"...","eventType":"EXPANSION","headline":"...","location":"...","summary":"...","relevance":"high","confidence":0.8,"reason":"...","recommendedAction":"..."}]}
|
|
|
|
Gib für JEDEN Eingabeeintrag genau ein Ergebnis mit dem passenden "index" zurück.
|
|
|
|
eventType ist einer von: EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT, M_AND_A, RESTRUCTURING, SITE_CLOSURE, NEW_COMPANY, IRRELEVANT.
|
|
relevance ist "high", "medium" oder "low". confidence ist eine Zahl zwischen 0 und 1.
|
|
Deutsch, Schweizer Rechtschreibung (ss statt ß). Keine langen Analysen.`
|
|
|
|
const AnalysisSchema = z.object({
|
|
results: z.array(z.object({
|
|
index: z.number().int().min(0),
|
|
company: z.string().min(1),
|
|
eventType: z.enum([
|
|
'EXPANSION', 'NEW_LOCATION', 'HEADCOUNT_GROWTH', 'INVESTMENT',
|
|
'M_AND_A', 'RESTRUCTURING', 'SITE_CLOSURE', 'NEW_COMPANY', 'IRRELEVANT',
|
|
]),
|
|
headline: z.string().min(1),
|
|
location: z.string().optional(),
|
|
summary: z.string().min(1),
|
|
relevance: z.enum(['high', 'medium', 'low']),
|
|
confidence: z.number().min(0).max(1),
|
|
reason: z.string().min(1),
|
|
recommendedAction: z.string().optional(),
|
|
})),
|
|
})
|
|
|
|
export interface AnalysisOutcome {
|
|
leads: ResearchLead[]
|
|
/** Einträge ohne plausiblen Immobilienbezug. */
|
|
discarded: number
|
|
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
|
watchlist: number
|
|
}
|
|
|
|
/** Das erste JSON-Objekt aus der Antwort — falls das Modell doch etwas drumherum schreibt. */
|
|
function extractJson(text: string): unknown {
|
|
const trimmed = text.trim()
|
|
const start = trimmed.indexOf('{')
|
|
const end = trimmed.lastIndexOf('}')
|
|
if (start === -1 || end <= start) throw new Error('Antwort enthält kein JSON-Objekt')
|
|
return JSON.parse(trimmed.slice(start, end + 1))
|
|
}
|
|
|
|
export async function analyzeResearchItems(items: ResearchItem[]): Promise<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 })
|
|
|
|
const eingabe = items
|
|
.map((it, i) => [
|
|
`### Eintrag ${i}`,
|
|
`Quelle: ${it.source}`,
|
|
`Titel: ${it.title}`,
|
|
it.publishedAt ? `Publiziert: ${it.publishedAt}` : '',
|
|
`Text: ${it.text.slice(0, 2000)}`,
|
|
].filter(Boolean).join('\n'))
|
|
.join('\n\n')
|
|
|
|
const response = await client.messages.create({
|
|
model: 'claude-opus-5',
|
|
max_tokens: 16000,
|
|
system: SYSTEM_PROMPT,
|
|
// Einordnen ist eine Routineaufgabe — niedrige Stufe spart Zeit und Kosten,
|
|
// ohne das Modell zu wechseln.
|
|
output_config: { effort: 'low' },
|
|
messages: [{ role: 'user', content: eingabe }],
|
|
})
|
|
|
|
if (response.stop_reason === 'refusal') {
|
|
throw new Error('Die Beurteilung wurde vom Modell abgelehnt.')
|
|
}
|
|
|
|
const text = response.content
|
|
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
|
.map(b => b.text)
|
|
.join('')
|
|
|
|
const parsed = AnalysisSchema.parse(extractJson(text))
|
|
|
|
const leads: ResearchLead[] = []
|
|
let discarded = 0
|
|
let watchlist = 0
|
|
|
|
for (const r of parsed.results) {
|
|
const item = items[r.index]
|
|
if (!item) continue
|
|
|
|
if (r.eventType === 'IRRELEVANT') { discarded += 1; continue }
|
|
if (r.relevance === 'low') { watchlist += 1; continue }
|
|
|
|
leads.push({
|
|
company: r.company,
|
|
eventType: r.eventType,
|
|
headline: r.headline,
|
|
location: r.location,
|
|
summary: r.summary,
|
|
relevance: r.relevance,
|
|
confidence: r.confidence,
|
|
reason: r.reason,
|
|
recommendedAction: r.recommendedAction,
|
|
source: item.source,
|
|
sourceUrl: item.url,
|
|
publishedAt: item.publishedAt,
|
|
})
|
|
}
|
|
|
|
// Stärkste Einordnung zuerst — die Liste soll oben beginnen, wo gehandelt wird.
|
|
leads.sort((a, b) =>
|
|
(a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1))
|
|
|
|
return { leads, discarded, watchlist }
|
|
}
|