Livia arbeitet nicht mehr neben dem Leadbestand, sondern in ihn hinein. - Systemzugänge tragen Titel, Beschreibung und Adresse; die Liste zeigt je Eintrag nur noch Aktiv/Inaktiv und Lesen/Schreiben — kein Berechtigungsbalken, kein Auge-Icon, keine Lese-/Schreibgruppen - Die Quellenregistry ist dynamisch: Der Lauf liest genau die aktiven Lesezugänge mit Adresse aus Livias Personalblatt. Für die drei gepflegten Quellen greift weiterhin ihr eigener Leser, für alles andere ein allgemeiner — eine Übersichtsseite, bis zu zehn Unterseiten, kein Spider. Freie Adressen werden auf http/https geprüft und gegen interne Netze gesperrt - Manuell abgelegte PDF- und Word-Dokumente fliessen in denselben Lauf. Text wird beim Ablegen nativ extrahiert (ZIP + Flate über DecompressionStream, ohne neue Abhängigkeit), Datei und Text liegen in IndexedDB - Neuer harter Filter «Zeitliche Relevanz»: das Modell beurteilt die Ereigniszeit der Veränderung, nicht das Publikationsdatum. «unknown» fällt bewusst nicht durch — ein erfundenes Datum wäre die schlechtere Antwort - Quellentypen und Beobachtungsraum sind Chips mit Freitext; jeder Wert lässt sich einzeln entfernen, auch die vorgegebenen - Info-Knopf bei «Zeitliche Relevanz» und «Mindestrelevanz» erklärt, wie der Wert entsteht — ohne erfundene Prozentgewichte - Gefundene Leads gehen in den zentralen Signalbestand statt in eine zweite Ergebnisliste; die Herkunft steht am Signal (origin: RESEARCH_RUN) - Der Lauf erzeugt einen echten Protokolleintrag mit den Zahlen des Laufs - Lead-Detail: zeitliche Einordnung mit Fundstelle, semantisches Suchprofil bei Chancen mit «Matching starten», und bei Risiko ausdrücklich «Kein internes Objekt eindeutig zugeordnet» statt einer beliebigen Mock-Immobilie - Aufgabenliste nach §11 bereinigt, «Unsichere Signale in Review Queue legen» von Nora übernommen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
14 KiB
TypeScript
321 lines
14 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, TemporalClass } from '../../src/domain/researchLead.js'
|
||
import { passtZeitlich } from '../../src/lib/temporalFilter.js'
|
||
|
||
/**
|
||
* Der Beurteilungsauftrag (§12, erweitert in Runde 10 §§2.9/2.13).
|
||
*
|
||
* Bewusst kurz gehalten. Drei Dinge tragen die eigentliche Arbeit: der
|
||
* Relevanzbegriff, das Verbot, etwas hinzuzuerfinden, und die Trennung von
|
||
* Publikationsdatum und Ereigniszeit.
|
||
*
|
||
* Eine Funktion und keine Konstante, weil der Auftrag das heutige Datum
|
||
* enthält: «in drei Wochen» lässt sich nur gegen ein Heute beurteilen, und das
|
||
* Modell kennt seines nicht verlässlich.
|
||
*/
|
||
function systemPrompt(): string {
|
||
const heute = new Date().toISOString().slice(0, 10)
|
||
return `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":"...","temporalClass":"future","temporalEvidence":"...","eventDate":"2026-11","semanticSearchProfile":"..."}]}
|
||
|
||
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.
|
||
|
||
ZEITLICHE EINORDNUNG ("temporalClass"):
|
||
Beurteile, WANN die beschriebene Veränderung stattfindet — nicht, wann der Artikel erschienen ist.
|
||
Das Publikationsdatum ist dafür irrelevant. Heute ist ${heute}.
|
||
- "past": Die Veränderung ist bereits abgeschlossen (Umzug erfolgt, Standort eröffnet oder geschlossen, Transaktion vollzogen).
|
||
- "current": Die Veränderung läuft gerade oder steht innerhalb der nächsten 8 Wochen bevor.
|
||
- "future": Die Veränderung liegt mehr als 8 Wochen voraus ODER ist angekündigt ohne kurzfristige Umsetzung.
|
||
- "unknown": Die Quelle erlaubt keine belastbare zeitliche Aussage. Erfinde dann KEIN Datum.
|
||
Beispiel: Ein Bericht aus Q1 kündigt einen Standort für Q3 an => "future".
|
||
Beispiel: Ein heute erschienener Artikel beschreibt einen Umzug vom letzten Frühling => "past".
|
||
|
||
"temporalEvidence": die Textstelle oder Formulierung aus der Quelle, auf die sich deine Einordnung stützt. Ein Satz.
|
||
"eventDate": nur setzen, wenn die Quelle ein Datum oder einen Zeitraum nennt (z. B. "2026-11" oder "2026-11-15"). Sonst weglassen.
|
||
|
||
SEMANTISCHES SUCHPROFIL ("semanticSearchProfile"):
|
||
Nur bei EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT und NEW_COMPANY — also dort, wo Flächenbedarf entstehen könnte.
|
||
Formuliere ein zusammenhängendes Nachfrageprofil in ganzen Sätzen (4–8 Sätze), das beschreibt, welche Fläche dieses Unternehmen vermutlich sucht.
|
||
Nenne, soweit aus der Quelle ableitbar: Unternehmen, Anlass, wahrscheinliche Nutzungsart, geografischer Suchraum, Flächenbedarf, zeitlicher Horizont, Standort- und Infrastrukturanforderungen, Erreichbarkeit.
|
||
Schliesse mit einem Satz, der ausdrücklich benennt, was aus der Quelle NICHT ableitbar ist.
|
||
Erfinde nichts. Was die Quelle nicht hergibt, gehört in den Unsicherheitssatz — nicht ins Profil.
|
||
|
||
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(),
|
||
// Fehlt die zeitliche Einordnung, gilt «unknown» — das ist die ehrliche
|
||
// Antwort und nicht ein aus dem Publikationsdatum geratener Wert.
|
||
temporalClass: z.enum(['past', 'current', 'future', 'unknown']).default('unknown'),
|
||
temporalEvidence: z.string().default(''),
|
||
eventDate: z.string().optional(),
|
||
semanticSearchProfile: 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
|
||
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Zeitfenster lag (§2.9). */
|
||
temporalFiltered: 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
|
||
*/
|
||
export 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)
|
||
|
||
/**
|
||
* Welche Modelle den `effort`-Schalter kennen.
|
||
*
|
||
* Nicht alle tun das: Haiku 4.5 weist die Anfrage mit «This model does not
|
||
* support the effort parameter» ab. Da das Modell über eine Umgebungsvariable
|
||
* frei wählbar ist, darf der Schalter nicht bedingungslos mitgeschickt werden —
|
||
* sonst macht eine Kostenoptimierung die Funktion kaputt, und zwar erst zur
|
||
* Laufzeit.
|
||
*
|
||
* Bewusst als Erlaubnisliste: Ein unbekanntes Modell läuft dann ohne den
|
||
* Schalter. Das kostet etwas mehr, funktioniert aber — die umgekehrte
|
||
* Voreinstellung würde raten und scheitern.
|
||
*/
|
||
const EFFORT_MODELS = /^claude-(fable|mythos|opus|sonnet)-/
|
||
function supportsEffort(model: string): boolean {
|
||
return EFFORT_MODELS.test(model)
|
||
}
|
||
|
||
/**
|
||
* 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: systemPrompt(),
|
||
// Einordnen ist bei fünf Einträgen eine Routineaufgabe — die niedrige Stufe
|
||
// spart Zeit und Kosten. Nur die grossen Modelle kennen den Schalter; bei
|
||
// den kleineren wird die Anfrage sonst abgewiesen (siehe SUPPORTS_EFFORT).
|
||
...(supportsEffort(MODEL) ? { output_config: { effort: 'low' as const } } : {}),
|
||
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[],
|
||
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung». */
|
||
temporalFilter: TemporalClass[] = [],
|
||
): Promise<AnalysisOutcome> {
|
||
if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0, temporalFiltered: 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
|
||
let temporalFiltered = 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 }
|
||
if (!passtZeitlich(r.temporalClass, temporalFilter)) { temporalFiltered += 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,
|
||
temporalClass: r.temporalClass,
|
||
temporalEvidence: r.temporalEvidence,
|
||
eventDate: r.eventDate,
|
||
semanticSearchProfile: r.semanticSearchProfile,
|
||
})
|
||
}
|
||
|
||
// 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, temporalFiltered }
|
||
}
|