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 }
|
||||
}
|
||||
Reference in new issue
Block a user