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

+33
View File
@@ -1,4 +1,5 @@
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
import type { TemporalClass } from './researchLead'
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
@@ -87,6 +88,38 @@ export interface FutureSignal {
affectedTenant?: string
/** Externe Quellen, aus denen Livia den Lead verdichtet hat. */
researchSources?: SignalSource[]
// ── Runde 10 ────────────────────────────────────────────────────────────────
/**
* Wann die beschriebene Veränderung stattfindet (§2.9).
*
* Ausdrücklich nicht dasselbe wie `source.publishedAt` und auch nicht
* dasselbe wie `timeHorizonMonths`: Der Horizont ist eine Schätzung in
* Monaten, diese Klasse eine belegte Einordnung mit Fundstelle. Optional,
* weil Bestandssignale sie nicht tragen — fehlt sie, gilt der Lead als
* zeitlich nicht eingeordnet und fällt durch keinen Zeitfilter.
*/
temporalClass?: TemporalClass
/** Die Textstelle, die die zeitliche Einordnung trägt. Ohne sie ist sie nicht prüfbar. */
temporalEvidence?: string
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
eventDate?: string
/**
* Ausformuliertes Nachfrageprofil (§2.13B).
*
* Einzige Wahrheit für Livia und Nora: Livia erstellt es, Nora gleicht es
* gegen den Bestand ab. Nora erzeugt bewusst keinen eigenen Text — zwei
* Fassungen desselben Profils wären zwei Aussagen über denselben Bedarf.
*/
semanticSearchProfile?: string
/**
* Herkunft des Signals. `RESEARCH_RUN` markiert Leads, die ein
* Recherchelauf erzeugt hat — sie stehen in derselben Liste wie die
* erfassten und unterscheiden sich nur durch dieses Feld, nicht durch eine
* eigene Ablage (§2.12).
*/
origin?: 'SEED' | 'RESEARCH_RUN'
}
// ── Chance / Risiko (Runde 8, §2) ────────────────────────────────────────────
+55
View File
@@ -0,0 +1,55 @@
/**
* Property On — manuell abgelegte Newsquellen (Runde 10, §2.6).
*
* Nicht jede Quelle ist eine Webseite. Ein Geschäftsbericht, ein
* Verbandsrundschreiben, ein Protokoll aus einer Gemeindeversammlung — das
* kommt als PDF oder Word-Datei auf den Tisch und hat keine Adresse, die
* Livia abrufen könnte.
*
* Solche Dokumente sind deshalb kein eigener Datentyp mit eigener Behandlung,
* sondern eine zweite Art, Text in denselben Recherchelauf zu geben. Was am
* Ende beurteilt wird, ist in beiden Fällen Text mit einem Namen und einer
* Herkunft.
*/
export const ResearchDocumentKind = {
PDF: 'PDF',
DOCX: 'DOCX',
} as const
export type ResearchDocumentKind = typeof ResearchDocumentKind[keyof typeof ResearchDocumentKind]
export const RESEARCH_DOCUMENT_LABELS: Record<ResearchDocumentKind, string> = {
PDF: 'PDF',
DOCX: 'Word',
}
/** Was von einem abgelegten Dokument geführt wird. */
export interface ResearchDocument {
id: string
/** Dateiname, wie er beim Ablegen hiess — die einzige Bezeichnung, die der Nutzer wiedererkennt. */
name: string
kind: ResearchDocumentKind
/** Grösse in Bytes; wird angezeigt, damit ein leeres Dokument auffällt. */
size: number
uploadedAt: string
/**
* Der extrahierte Text.
*
* Er wird beim Ablegen einmal gewonnen und danach nie wieder — die
* Extraktion ist der teure Teil, und ihn bei jedem Lauf zu wiederholen
* würde denselben Text ein zweites Mal ergeben.
*/
text: string
/**
* Gesetzt, wenn aus der Datei kein brauchbarer Text zu holen war (etwa ein
* gescanntes PDF ohne Textebene). Das Dokument bleibt in der Liste — mit
* Hinweis, statt stillschweigend nichts beizutragen.
*/
extractionNote?: string
}
/** Obergrenze je Datei. Grössere Dateien sprengen den Speicher des Browsers. */
export const RESEARCH_DOCUMENT_MAX_BYTES = 15 * 1024 * 1024
/** Ab wie vielen Zeichen ein extrahierter Text als brauchbar gilt. */
export const RESEARCH_DOCUMENT_MIN_CHARS = 200
+61
View File
@@ -46,6 +46,24 @@ export interface ResearchLead {
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
sourceUrl: string
publishedAt?: string
// ── Runde 10 ────────────────────────────────────────────────────────────────
/** Zeitliche Dimension der beschriebenen Veränderung (§2.9). */
temporalClass: TemporalClass
/** Der Satz aus der Quelle, der die Einordnung trägt — ohne ihn ist sie nicht prüfbar. */
temporalEvidence: string
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
eventDate?: string
/**
* Ausformuliertes Nachfrageprofil für Chance-Leads (§2.13B).
*
* Kein Stichwortfeld: Nora gleicht es gegen den Objektbestand ab, und dafür
* muss darin stehen, was die Quelle hergibt — einschliesslich dessen, was sie
* nicht hergibt. Ein Profil, das Lücken verschweigt, führt zu Treffern, die
* niemand belegen kann.
*/
semanticSearchProfile?: string
}
/** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
@@ -68,6 +86,8 @@ export interface ResearchRefreshResult {
discarded: number
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
watchlist: number
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Fenster lag (§2.9). */
temporalFiltered: number
/** Nur `high` und `medium` erscheinen hier. */
leads: ResearchLead[]
/** Zeitpunkt des Laufs, ISO. */
@@ -87,3 +107,44 @@ export interface ResearchRefreshResult {
*/
analysisError?: string
}
// ── Zeitliche Relevanz (Runde 10, §2.9) ───────────────────────────────────────
/**
* Wann die beschriebene Veränderung stattfindet — **nicht**, wann der Artikel
* erschienen ist.
*
* Das ist der ganze Punkt dieser Klassifikation: Ein Jahresbericht vom Januar
* kann einen Standort für das dritte Quartal ankündigen (`FUTURE`), und eine
* Meldung von heute kann einen Umzug vom Frühling beschreiben (`PAST`). Wer
* nach Publikationsdatum filtert, bekommt in beiden Fällen das Gegenteil
* dessen, was er sucht.
*
* `UNKNOWN` ist ausdrücklich erlaubt. Wenn die Quelle keine belastbare
* zeitliche Aussage enthält, ist «weiss ich nicht» die richtige Antwort —
* ein erfundenes Datum wäre die falsche.
*/
export const TemporalClass = {
PAST: 'past',
CURRENT: 'current',
FUTURE: 'future',
UNKNOWN: 'unknown',
} as const
export type TemporalClass = typeof TemporalClass[keyof typeof TemporalClass]
/** Reihenfolge im Einstellungsbereich — von abgeschlossen nach angekündigt. */
export const TEMPORAL_FILTER_ORDER: TemporalClass[] = [
TemporalClass.PAST,
TemporalClass.CURRENT,
TemporalClass.FUTURE,
]
/**
* Die Grenze zwischen «jetzt» und «später», in Wochen.
*
* Acht Wochen sind keine willkürliche Zahl: Sie entsprechen ungefähr der
* Vorlaufzeit, die eine Bewirtschaftung braucht, um auf einen Flächenbedarf
* überhaupt noch mit einem Angebot zu reagieren. Alles darunter ist ein
* laufender Vorgang, alles darüber eine Planung.
*/
export const TEMPORAL_CURRENT_WEEKS = 8
+40
View File
@@ -120,6 +120,19 @@ export interface AgentSystem {
* die Zugänge ohne Schalter führt — fehlt das Feld, gilt der Zugang als aktiv.
*/
enabled?: boolean
/**
* Eigener Titel der Quelle (Runde 10, §2.1). Der Katalog benennt seine
* Zugänge über `type`; selbst angebundene Quellen haben keinen passenden
* Typ und tragen ihren Namen deshalb hier. Fehlt der Titel, gilt das Label
* des Typs — so bleiben die Katalogeinträge unverändert.
*/
title?: string
/**
* Adresse der Quelle. Bei einer aktiven Lesequelle ist das die Seite, die
* Livias Recherchelauf tatsächlich abruft — der angezeigte Link und der
* gelesene Link sind damit derselbe Wert.
*/
url?: string
/** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */
usage: string
/** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */
@@ -149,6 +162,18 @@ export const AgentSettingKind = {
MULTI_SELECT: 'MULTI_SELECT',
BOOLEAN: 'BOOLEAN',
TIME: 'TIME',
/**
* Mehrfachauswahl als Chips, jeder einzeln entfernbar (Runde 10, §§2.7–2.9).
*
* Der Unterschied zu `MULTI_SELECT` ist nicht bloss die Darstellung: Ein
* Auswahlfeld kann nur anbieten, was jemand vorher als Option hinterlegt
* hat. Quellentypen und Beobachtungsräume sind aber genau das, was sich von
* Haus zu Haus unterscheidet — sie brauchen ein Feld, in das man schreiben
* kann, was man meint.
*/
CHIPS: 'CHIPS',
/** Abgelegte Dokumente als Datenbasis der Recherche (Runde 10, §2.6). */
DOCUMENTS: 'DOCUMENTS',
} as const
export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind]
@@ -179,6 +204,21 @@ export interface AgentSetting {
/** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */
locked?: boolean
lockedReason?: string
/**
* Erklärt auf Knopfdruck, wie ein Wert zustande kommt (Runde 10, §2.10).
*
* Gehört ans Datenmodell und nicht in die Komponente: Was ein Schwellenwert
* bedeutet, weiss das Personalblatt, nicht das Eingabefeld. Und ein Text,
* der eine Berechnung beschreibt, muss neben der Berechnung stehen, damit
* beide zusammen geändert werden.
*/
infoText?: string
/**
* Nur bei `CHIPS`: ob eigene Werte erfasst werden dürfen. Bei einer
* fachlich abgeschlossenen Liste — etwa den drei Zeitfenstern — bleibt es
* aus, sonst entstünde ein vierter Wert, den niemand auswerten kann.
*/
allowCustom?: boolean
}
// ── Kennzahlen & Profil ───────────────────────────────────────────────────────