diff --git a/api/_lib/researchAnalysis.ts b/api/_lib/researchAnalysis.ts index efc2f77..760e342 100644 --- a/api/_lib/researchAnalysis.ts +++ b/api/_lib/researchAnalysis.ts @@ -75,19 +75,33 @@ function extractJson(text: string): unknown { return JSON.parse(trimmed.slice(start, end + 1)) } -export async function analyzeResearchItems(items: ResearchItem[]): Promise { - 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(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['results'][number] }[]> { + const eingabe = batch .map((it, i) => [ `### Eintrag ${i}`, `Quelle: ${it.source}`, @@ -99,10 +113,10 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise batch[r.index] !== undefined) + .map(r => ({ item: batch[r.index], r })) +} + +export async function analyzeResearchItems(items: ResearchItem[]): Promise { + 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 } diff --git a/api/_lib/researchSources.ts b/api/_lib/researchSources.ts index acde4d4..ccd6d1a 100644 --- a/api/_lib/researchSources.ts +++ b/api/_lib/researchSources.ts @@ -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 { 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 { 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, diff --git a/vercel.json b/vercel.json index 3f6464c..c0be6a7 100644 --- a/vercel.json +++ b/vercel.json @@ -2,5 +2,10 @@ "buildCommand": "npx vite build", "outputDirectory": "dist", "framework": null, + "functions": { + "api/**/*.ts": { + "maxDuration": 60 + } + }, "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }