Files
property-match/api/_lib/researchAnalysis.ts
T
Benjamin SutterandClaude Opus 5 eb12119d91 docs(livia): Kostenschätzung für Sonnet 5 korrigiert
Im Kommentar zur Modellwahl stand für claude-sonnet-5 rund 0.20 USD je Lauf.
Nach den aktuellen Listenpreisen (2/10 USD je Million Token) sind es rund 0.16 —
genau das Doppelte von Haiku 4.5, nicht das Zweieinhalbfache. Die Preisbasis
steht jetzt im Kommentar, damit die Zahlen beim nächsten Preiswechsel prüfbar
bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:24:25 +02:00

326 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, TemporalClass } from '../../src/domain/researchLead.js'
import { passtZeitlich } from '../../src/lib/temporalFilter.js'
/**
* Der Beurteilungsauftrag (§12, erweitert in Runde 10 §§2.9/2.13).
*
* Bewusst kurz gehalten. Drei Dinge tragen die eigentliche Arbeit: der
* Relevanzbegriff, das Verbot, etwas hinzuzuerfinden, und die Trennung von
* Publikationsdatum und Ereigniszeit.
*
* Eine Funktion und keine Konstante, weil der Auftrag das heutige Datum
* enthält: «in drei Wochen» lässt sich nur gegen ein Heute beurteilen, und das
* Modell kennt seines nicht verlässlich.
*/
function systemPrompt(): string {
const heute = new Date().toISOString().slice(0, 10)
return `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":"...","temporalClass":"future","temporalEvidence":"...","eventDate":"2026-11","semanticSearchProfile":"..."}]}
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.
ZEITLICHE EINORDNUNG ("temporalClass"):
Beurteile, WANN die beschriebene Veränderung stattfindet — nicht, wann der Artikel erschienen ist.
Das Publikationsdatum ist dafür irrelevant. Heute ist ${heute}.
- "past": Die Veränderung ist bereits abgeschlossen (Umzug erfolgt, Standort eröffnet oder geschlossen, Transaktion vollzogen).
- "current": Die Veränderung läuft gerade oder steht innerhalb der nächsten 8 Wochen bevor.
- "future": Die Veränderung liegt mehr als 8 Wochen voraus ODER ist angekündigt ohne kurzfristige Umsetzung.
- "unknown": Die Quelle erlaubt keine belastbare zeitliche Aussage. Erfinde dann KEIN Datum.
Beispiel: Ein Bericht aus Q1 kündigt einen Standort für Q3 an => "future".
Beispiel: Ein heute erschienener Artikel beschreibt einen Umzug vom letzten Frühling => "past".
"temporalEvidence": die Textstelle oder Formulierung aus der Quelle, auf die sich deine Einordnung stützt. Ein Satz.
"eventDate": nur setzen, wenn die Quelle ein Datum oder einen Zeitraum nennt (z. B. "2026-11" oder "2026-11-15"). Sonst weglassen.
SEMANTISCHES SUCHPROFIL ("semanticSearchProfile"):
Nur bei EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT und NEW_COMPANY — also dort, wo Flächenbedarf entstehen könnte.
Formuliere ein zusammenhängendes Nachfrageprofil in ganzen Sätzen (4–8 Sätze), das beschreibt, welche Fläche dieses Unternehmen vermutlich sucht.
Nenne, soweit aus der Quelle ableitbar: Unternehmen, Anlass, wahrscheinliche Nutzungsart, geografischer Suchraum, Flächenbedarf, zeitlicher Horizont, Standort- und Infrastrukturanforderungen, Erreichbarkeit.
Schliesse mit einem Satz, der ausdrücklich benennt, was aus der Quelle NICHT ableitbar ist.
Erfinde nichts. Was die Quelle nicht hergibt, gehört in den Unsicherheitssatz — nicht ins Profil.
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(),
// Fehlt die zeitliche Einordnung, gilt «unknown» — das ist die ehrliche
// Antwort und nicht ein aus dem Publikationsdatum geratener Wert.
temporalClass: z.enum(['past', 'current', 'future', 'unknown']).default('unknown'),
temporalEvidence: z.string().default(''),
eventDate: z.string().optional(),
semanticSearchProfile: 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
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Zeitfenster lag (§2.9). */
temporalFiltered: 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.16 USD
* claude-haiku-4-5 ≈ 0.08 USD für eine Ja/Nein-Einordnung meist ausreichend
*
* Die Verhältnisse folgen den Listenpreisen je Million Token (Eingabe/Ausgabe):
* Opus 5 bei 5/25, Sonnet 5 bei 2/10, Haiku 4.5 bei 1/5 US-Dollar. Sonnet
* kostet damit genau das Doppelte von Haiku — hier stand bisher 0.20, was eine
* ältere Preisstufe widerspiegelte.
*/
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: systemPrompt(),
// 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[],
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung». */
temporalFilter: TemporalClass[] = [],
): Promise<AnalysisOutcome> {
if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0, temporalFiltered: 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
let temporalFiltered = 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 }
if (!passtZeitlich(r.temporalClass, temporalFilter)) { temporalFiltered += 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,
temporalClass: r.temporalClass,
temporalEvidence: r.temporalEvidence,
eventDate: r.eventDate,
semanticSearchProfile: r.semanticSearchProfile,
})
}
// 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, temporalFiltered }
}