Beim Messen des Tokenverbrauchs fiel auf, dass ein Lauf «49 Informationen analysiert · 0 nicht relevant · 0 Watchlist · 0 relevante Entwicklungen» meldete — was wie ein sauberer Lauf ohne Treffer aussieht. Tatsächlich war jeder einzelne Beurteilungsaufruf mit «Your credit balance is too low» gescheitert. Der `catch` um den Teilstapel fing den Fehler ab, damit ein Ausfall nicht die übrigen mitreisst, und verlor dabei den Grund. Scheitern alle Teilstapel, ist das kein leeres Ergebnis, sondern ein Ausfall. Der Grund wird jetzt weitergereicht und erscheint als Warnung über der Liste. Scheitern nur einzelne, bleibt es beim bisherigen Verhalten: die übrigen Leads werden gezeigt. Das ist die unangenehmste Fehlerklasse überhaupt — eine, die wie ein Ergebnis aussieht. In einer Präsentation hätte niemand gemerkt, dass Livia gar nicht gearbeitet hat. Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen; der Lauf meldet den Fehler jetzt sichtbar statt eine leere Liste. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
232 lines
8.9 KiB
TypeScript
232 lines
8.9 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
|
|
|
|
/**
|
|
* 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, 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, 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 }
|
|
}
|