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>
187 lines
7.1 KiB
TypeScript
187 lines
7.1 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.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
|
|
|
|
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, 2000)}`,
|
|
].filter(Boolean).join('\n'))
|
|
.join('\n\n')
|
|
|
|
const response = await client.messages.create({
|
|
model: 'claude-opus-5',
|
|
max_tokens: 8000,
|
|
system: SYSTEM_PROMPT,
|
|
// Einordnen ist bei fünf Einträgen eine Routineaufgabe — die 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))
|
|
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: die Laufzeit ist damit die des langsamsten Teilstapels und nicht
|
|
// deren Summe — das hält den Aufruf innerhalb des Zeitlimits der Funktion.
|
|
const batches = await Promise.all(
|
|
chunk(items, BATCH_SIZE).map(b => analyzeBatch(client, b)),
|
|
)
|
|
|
|
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 }
|
|
|
|
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 }
|
|
}
|