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

+117 -8
View File
@@ -19,10 +19,15 @@ import {
fetchZhkResearch,
fetchGreaterZurichResearch,
fetchZefixResearch,
fetchGenericSource,
} from '../../_lib/researchSources.js'
import type { ResearchItem } from '../../_lib/researchSources.js'
import { MODEL, analyzeResearchItems } from '../../_lib/researchAnalysis.js'
import type { ResearchRefreshResult, ResearchSourceResult } from '../../../src/domain/researchLead.js'
import type {
ResearchRefreshResult,
ResearchSourceResult,
TemporalClass,
} from '../../../src/domain/researchLead.js'
interface Quelle {
id: string
@@ -31,12 +36,97 @@ interface Quelle {
laden: () => Promise<ResearchItem[]>
}
const QUELLEN: Quelle[] = [
/**
* Für diese drei Adressen gibt es einen eigens geschriebenen Leser.
*
* Der Schlüssel ist die Adresse und nicht die Kennung: Ein Zugang, den der
* Nutzer im Personalblatt anlegt, bekommt eine eigene Kennung, zeigt aber
* vielleicht auf dieselbe Seite. Dann soll er den guten Leser bekommen und
* nicht den allgemeinen.
*/
const SPEZIALLESER = new Map<string, () => Promise<ResearchItem[]>>([
[SOURCES.ZHK.url, fetchZhkResearch],
[SOURCES.GZA.url, fetchGreaterZurichResearch],
[SOURCES.ZEFIX.url, fetchZefixResearch],
])
/** Die gepflegten drei — Rückfall, wenn die Anfrage keine Quellen mitbringt. */
const STANDARDQUELLEN: Quelle[] = [
{ ...SOURCES.ZHK, laden: fetchZhkResearch },
{ ...SOURCES.GZA, laden: fetchGreaterZurichResearch },
{ ...SOURCES.ZEFIX, laden: fetchZefixResearch },
]
/** Was die Oberfläche mitschickt (Runde 10, §§2.3/2.6/2.9). */
interface RefreshBody {
sources?: { id: string; name: string; url: string }[]
documents?: { id: string; name: string; text: string }[]
temporalClasses?: TemporalClass[]
}
/** Höchstzahl frei angebundener Quellen je Lauf — schützt Laufzeit und Kosten. */
const MAX_SOURCES = 8
/** Höchstzahl abgelegter Dokumente je Lauf. */
const MAX_DOCUMENTS = 12
/** Wie viel Text ein abgelegtes Dokument beisteuert. */
const DOC_TEXT_LIMIT = 12_000
async function leseBody(req: IncomingMessage): Promise<RefreshBody> {
const stuecke: Buffer[] = []
for await (const chunk of req) stuecke.push(chunk as Buffer)
if (stuecke.length === 0) return {}
try {
return JSON.parse(Buffer.concat(stuecke).toString('utf8')) as RefreshBody
} catch {
// Ein unlesbarer Rumpf ist kein Grund, den Lauf abzubrechen — dann gelten
// eben die gepflegten Standardquellen.
return {}
}
}
/**
* Aus den übergebenen Adressen die Leseaufträge bauen.
*
* Genau hier wird die Quellenregistry dynamisch: Was das Personalblatt als
* aktiven Lesezugang mit Adresse führt, landet in dieser Liste — mit dem
* spezialisierten Leser, wenn es einen gibt, und sonst mit dem allgemeinen.
*/
function baueQuellen(body: RefreshBody): Quelle[] {
const angefragt = (body.sources ?? []).filter(q => q.url && q.name).slice(0, MAX_SOURCES)
if (angefragt.length === 0) return STANDARDQUELLEN
return angefragt.map((q): Quelle => {
const speziell = SPEZIALLESER.get(q.url)
return {
id: q.id,
name: q.name,
url: q.url,
laden: speziell ?? (() => fetchGenericSource(q)),
}
})
}
/**
* Abgelegte Dokumente als Einträge — dieselbe Beurteilung wie bei den Seiten.
*
* Das ist der ganze Mechanismus aus §2.6: Ein hochgeladenes PDF unterscheidet
* sich für die Analyse in nichts von einem gelesenen Artikel, sobald sein Text
* extrahiert ist. Ein zweiter Analysepfad dafür wäre eine zweite Stelle, an der
* sich die Beurteilung ändern könnte.
*/
function dokumenteAlsEintraege(body: RefreshBody): ResearchItem[] {
return (body.documents ?? [])
.filter(d => typeof d.text === 'string' && d.text.trim().length > 120)
.slice(0, MAX_DOCUMENTS)
.map(d => ({
source: 'Manuell abgelegtes Dokument',
title: d.name,
// Es gibt keine Webadresse — die Kennung des Dokuments tritt an ihre Stelle.
url: `dokument:${d.id}`,
text: d.text.slice(0, DOC_TEXT_LIMIT),
}))
}
export default async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
// Ein Live-Lauf soll nie aus einem Zwischenspeicher beantwortet werden.
@@ -48,9 +138,13 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
return
}
// Alle drei parallel: eine langsame Quelle soll die beiden anderen nicht aufhalten.
const body = await leseBody(req)
const quellen = baueQuellen(body)
const dokumente = dokumenteAlsEintraege(body)
// Alle parallel: eine langsame Quelle soll die anderen nicht aufhalten.
const ergebnisse = await Promise.all(
QUELLEN.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => {
quellen.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => {
try {
const items = await q.laden()
return {
@@ -71,13 +165,27 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
)
const sources = ergebnisse.map(e => e.meta)
const items = ergebnisse.flatMap(e => e.items)
// Abgelegte Dokumente erscheinen als eigene Zeile in der Quellenübersicht —
// wer sie hochgeladen hat, soll sehen, dass sie tatsächlich gelesen wurden.
if (dokumente.length > 0) {
sources.push({
id: 'dokumente',
name: `Manuell abgelegte Dokumente (${dokumente.length})`,
url: '',
ok: true,
itemCount: dokumente.length,
})
}
const items = [...ergebnisse.flatMap(e => e.items), ...dokumente]
const basis: ResearchRefreshResult = {
sources,
analyzed: items.length,
discarded: 0,
watchlist: 0,
temporalFiltered: 0,
leads: [],
refreshedAt: new Date().toISOString(),
model: MODEL,
@@ -87,15 +195,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
res.statusCode = 200
res.end(JSON.stringify({
...basis,
analysisError: 'Keine der drei Quellen lieferte Einträge.',
analysisError: 'Keine der angebundenen Quellen lieferte Einträge.',
}))
return
}
try {
const { leads, discarded, watchlist } = await analyzeResearchItems(items)
const { leads, discarded, watchlist, temporalFiltered } =
await analyzeResearchItems(items, body.temporalClasses ?? [])
res.statusCode = 200
res.end(JSON.stringify({ ...basis, leads, discarded, watchlist }))
res.end(JSON.stringify({ ...basis, leads, discarded, watchlist, temporalFiltered }))
} catch (err) {
// Gelesen wurde trotzdem — das sagen wir offen, statt eine leere Liste als
// «nichts gefunden» auszugeben.