feat(livia): Runde 10 Prompt 2 — eine Leadliste, dynamische Quellen, zeitliche Relevanz

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>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-09-12 23:42:59 +02:00
1 parent 1c4b3f7dd8
commit 30daa77937
33 files changed
+2275 -301

No files matched your search

+58 -11
View File
@@ -12,16 +12,23 @@
import Anthropic from '@anthropic-ai/sdk'
import { z } from 'zod'
import type { ResearchItem } from './researchSources.js'
import type { ResearchLead } from '../../src/domain/researchLead.js'
import type { ResearchLead, TemporalClass } from '../../src/domain/researchLead.js'
import { passtZeitlich } from '../../src/lib/temporalFilter.js'
/**
* Der Beurteilungsauftrag (§12).
* Der Beurteilungsauftrag (§12, erweitert in Runde 10 §§2.9/2.13).
*
* 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.
* 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.
*/
const SYSTEM_PROMPT = `Du bist Livia, Research & Market Intelligence Agent für professionelle Schweizer Gewerbeimmobilien-Bewirtschaftungen.
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.
@@ -32,13 +39,35 @@ Allgemeine Unternehmensnachrichten ohne plausiblen Immobilienbezug sind IRRELEVA
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":"..."}]}
{"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({
@@ -55,6 +84,12 @@ const AnalysisSchema = z.object({
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(),
})),
})
@@ -64,6 +99,8 @@ export interface AnalysisOutcome {
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. */
@@ -164,7 +201,7 @@ async function analyzeBatch(
const response = await client.messages.create({
model: MODEL,
max_tokens: 8000,
system: SYSTEM_PROMPT,
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).
@@ -187,8 +224,12 @@ async function analyzeBatch(
.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 }
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) {
@@ -240,6 +281,7 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
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 }
@@ -248,6 +290,7 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
// 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,
@@ -262,6 +305,10 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
source: item.source,
sourceUrl: item.url,
publishedAt: item.publishedAt,
temporalClass: r.temporalClass,
temporalEvidence: r.temporalEvidence,
eventDate: r.eventDate,
semanticSearchProfile: r.semanticSearchProfile,
})
}
@@ -269,5 +316,5 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
leads.sort((a, b) =>
(a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1))
return { leads, discarded, watchlist }
return { leads, discarded, watchlist, temporalFiltered }
}
+102
View File
@@ -336,3 +336,105 @@ export async function fetchZefixResearch(): Promise<ResearchItem[]> {
aggregate: true,
}]
}
// ── Frei angebundene Quellen (Runde 10, §2.3) ─────────────────────────────────
/**
* Adressbereiche, die eine serverlose Funktion niemals abrufen soll.
*
* Der Nutzer gibt die Adresse frei ein, und die Funktion ruft sie mit den
* Rechten des Rechenzentrums ab. Ohne diese Sperre wäre das Eingabefeld ein
* Weg, interne Dienste des Hosters anzusprechen. Geprüft wird der Hostname,
* weil eine serverlose Funktion keine Namensauflösung vorab machen kann —
* das deckt die versehentlichen Fälle und die offensichtlichen Versuche ab.
*/
const PRIVATE_HOSTS =
/^(localhost$|127\.|0\.0\.0\.0$|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?$|.*\.local$|.*\.internal$)/i
export interface QuellenAdresse {
id: string
name: string
url: string
}
/** Wirft mit einer Meldung, die so in der Oberfläche stehen darf. */
export function pruefeQuellenAdresse(url: string): URL {
let parsed: URL
try {
parsed = new URL(url)
} catch {
throw new Error('Keine gültige Adresse.')
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Nur http- und https-Adressen können gelesen werden.')
}
if (PRIVATE_HOSTS.test(parsed.hostname)) {
throw new Error('Adressen im internen Netz werden nicht abgerufen.')
}
return parsed
}
/** Höchstzahl Artikel je frei angebundener Quelle — bewusst kleiner als bei den gepflegten drei. */
const GENERIC_MAX_ARTICLES = 10
/**
* Eine beliebige Seite als Recherchequelle lesen.
*
* Bewusst kein Spider: Es wird genau eine Übersichtsseite geholt. Führt sie
* Links auf Unterseiten desselben Hosts, werden bis zu zehn davon gelesen —
* sonst zählt der Text der Seite selbst als einziger Eintrag. Das reicht für
* eine Newsseite und kostet nie mehr als elf Abrufe.
*
* Was hier ausdrücklich nicht passiert: keine zweite Ebene, keine Warteschlange,
* kein Zeitplan, kein Browser. Eine frei angebundene Quelle liefert damit
* weniger als die drei gepflegten — das ist der ehrliche Preis dafür, dass
* niemand für sie einen eigenen Leser geschrieben hat.
*/
export async function fetchGenericSource(quelle: QuellenAdresse): Promise<ResearchItem[]> {
const basis = pruefeQuellenAdresse(quelle.url)
const html = await getText(quelle.url)
const $ = cheerio.load(html)
$('script, style, nav, header, footer, noscript, form').remove()
// Kandidaten: Links auf denselben Host, mindestens zwei Pfadsegmente tief.
// Eine Startseite verlinkt so ihre Artikel; Impressum und Kontakt liegen
// typischerweise flacher und fallen dadurch von selbst heraus.
const links: Artikellink[] = []
const gesehen = new Set<string>()
$('a[href]').each((_, el) => {
const href = $(el).attr('href')
if (!href) return
let ziel: URL
try {
ziel = new URL(href, basis)
} catch {
return
}
if (ziel.hostname !== basis.hostname) return
if (ziel.pathname.split('/').filter(Boolean).length < 2) return
const url = `${ziel.origin}${ziel.pathname}`
if (url === `${basis.origin}${basis.pathname}` || gesehen.has(url)) return
const title = $(el).text().replace(/\s+/g, ' ').trim()
if (title.length < 12) return
gesehen.add(url)
links.push({ url, title })
})
if (links.length > 0) {
const items = await ladeArtikel(links.slice(0, GENERIC_MAX_ARTICLES), quelle.name)
if (items.length > 0) return items
}
// Kein brauchbarer Unterseiten-Fund: die Seite selbst ist der Eintrag.
const text = extractArticleText(html)
if (text.length < 120) {
throw new Error('Die Seite lieferte zu wenig lesbaren Text.')
}
return [{
source: quelle.name,
title: cleanTitle($('h1').first().text().replace(/\s+/g, ' ').trim()) || quelle.name,
url: quelle.url,
text,
}]
}