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