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>
255 lines
9.7 KiB
TypeScript
255 lines
9.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.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
|
|
*/
|
|
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)
|
|
|
|
/**
|
|
* 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, 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, 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 }
|
|
}
|