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:
1 parent
1c4b3f7dd8
commit
30daa77937
33 files changed
+2275
-301
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}]
|
||||
}
|
||||
Reference in new issue
Block a user