fix(livia): Beurteilung in Fünferstapeln statt einem Zwanzigerstapel

Der erste Live-Lauf mit Schlüssel deckte auf, dass ein einziger Aufruf über
alle zwanzig Einträge das Urteil verwässert. Eine Meldung über eine
zweistellige Millioneninvestition in eine neue Brauerei samt Arealentwicklung
fiel als «nicht relevant» durch; derselbe Prompt, dasselbe Modell und dieselbe
Meldung ergaben in einem Stapel von drei «Investition, hohe Relevanz, 0.85».

Eine höhere Effort-Stufe half nicht, sie machte es messbar schlechter. Der
Hebel ist die Stapelgrösse. Die Teilstapel laufen parallel — die Laufzeit ist
damit die des langsamsten und nicht deren Summe: 48 Sekunden vorher, 16 danach.

Das ist zugleich die Voraussetzung fürs Deployment: die serverlose Funktion
hat voreingestellt 10 Sekunden Zeit. `vercel.json` hebt das Limit nun
ausdrücklich auf 60 Sekunden an, was auch bei einer langsamen Quelle reicht.

Ausserdem: Der Linktext der Handelskammer trägt das Publikationsdatum vorneweg
und ein »-Zeichen hinten. Beides stand bisher im Titel, den das Modell zu lesen
bekam, und gehört dort nicht hin.

Geprüft: tsc 0 Fehler, eslint 0 Fehler/0 Warnungen, Live-Lauf 20 Einträge aus
3/3 Quellen in 17 Sekunden. Die Urteile sind stichprobenweise gegen die
Originalartikel geprüft und tragfähig — eine Bäckereieröffnung in
Westaustralien und eine Übernahme in Thailand werden zutreffend als ohne
Schweizer Flächenbezug verworfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-30 17:32:04 +02:00
1 parent 2a0ba287f6
commit 93ecc852e3
3 files changed
+70 -20

No files matched your search

+51 -18
View File
@@ -75,19 +75,33 @@ function extractJson(text: string): unknown {
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 }
/**
* 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
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.',
)
}
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
}
const client = new Anthropic({ apiKey })
const eingabe = items
/** 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}`,
@@ -99,10 +113,10 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
const response = await client.messages.create({
model: 'claude-opus-5',
max_tokens: 16000,
max_tokens: 8000,
system: SYSTEM_PROMPT,
// Einordnen ist eine Routineaufgabe — niedrige Stufe spart Zeit und Kosten,
// ohne das Modell zu wechseln.
// 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 }],
})
@@ -117,15 +131,34 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
.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 r of parsed.results) {
const item = items[r.index]
if (!item) continue
for (const { item, r } of batches.flat()) {
if (r.eventType === 'IRRELEVANT') { discarded += 1; continue }
if (r.relevance === 'low') { watchlist += 1; continue }
+14 -2
View File
@@ -94,6 +94,18 @@ function extractArticleText(html: string): string {
return sammle('p').join('\n').slice(0, 2500)
}
/**
* Der Linktext trägt bei der Handelskammer das Publikationsdatum vorneweg
* («28. August 2026 Bystronic übernimmt …») und hinten ein »-Zeichen. Beides
* gehört nicht in den Titel, den das Modell zu lesen bekommt.
*/
function cleanTitle(raw: string): string {
return raw
.replace(/^\d{1,2}\.\s*\p{L}+\s+\d{4}\s*/u, '')
.replace(/\s*[»›>]\s*$/, '')
.trim()
}
function absolute(href: string, base: string): string {
try {
return new URL(href, base).toString()
@@ -125,7 +137,7 @@ export async function fetchZhkResearch(): Promise<ResearchItem[]> {
try {
const html = await getText(l.url)
const $a = cheerio.load(html)
const title = l.title || $a('h1').first().text().replace(/\s+/g, ' ').trim()
const title = cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim()
const published =
$a('time').first().attr('datetime') ||
$a('meta[property="article:published_time"]').attr('content') ||
@@ -166,7 +178,7 @@ export async function fetchGreaterZurichResearch(): Promise<ResearchItem[]> {
try {
const html = await getText(l.url)
const $a = cheerio.load(html)
const title = l.title || $a('h1').first().text().replace(/\s+/g, ' ').trim()
const title = cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim()
items.push({
source: SOURCES.GZA.name,
title,
+5
View File
@@ -2,5 +2,10 @@
"buildCommand": "npx vite build",
"outputDirectory": "dist",
"framework": null,
"functions": {
"api/**/*.ts": {
"maxDuration": 60
}
},
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}