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,
|
||||
}]
|
||||
}
|
||||
@@ -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