/** * 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' import type { ResearchLead } from '../../src/domain/researchLead' /** * 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)) } 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 }) const eingabe = items .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: 16000, system: SYSTEM_PROMPT, // Einordnen ist eine Routineaufgabe — 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)) const leads: ResearchLead[] = [] let discarded = 0 let watchlist = 0 for (const r of parsed.results) { const item = items[r.index] if (!item) continue if (r.eventType === 'IRRELEVANT') { discarded += 1; continue } if (r.relevance === 'low') { 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 } }