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
+2273
-299
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.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, MapPin, Ruler } from 'lucide-react'
|
||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, MapPin, Ruler, Target } from 'lucide-react'
|
||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||
import { AgentAvatar, ObjectDeepLink } from '../team'
|
||||
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
||||
import { SOURCE_TYPE_LABELS, TEMPORAL_CLASS_LABELS } from '../../lib/constants'
|
||||
import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||
@@ -31,6 +31,12 @@ interface LeadDetailProps {
|
||||
lead: MarketLead
|
||||
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
|
||||
showMatching?: boolean
|
||||
/**
|
||||
* Übergabe an Nora (Runde 10, §2.14). Nur gesetzt, wo der Knopf hingehört —
|
||||
* auf Noras eigener Seite wäre «Matching starten» hier ein zweiter Weg zu
|
||||
* derselben Handlung, die dort schon im Kopf des Matching-Bereichs steht.
|
||||
*/
|
||||
onStartMatching?: (signalId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,11 +45,22 @@ interface LeadDetailProps {
|
||||
* Ein neues Signal ist damit eine neue Komponente und startet mit frischem
|
||||
* Zustand, statt ihn in einem Effekt zurücksetzen zu müssen.
|
||||
*/
|
||||
function LeadDetail({ lead, showMatching = true }: LeadDetailProps) {
|
||||
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} />
|
||||
function LeadDetail({ lead, showMatching = true, onStartMatching }: LeadDetailProps) {
|
||||
return (
|
||||
<LeadDetailBody
|
||||
key={lead.signal.id}
|
||||
lead={lead}
|
||||
showMatching={showMatching}
|
||||
onStartMatching={onStartMatching}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching: boolean }) {
|
||||
function LeadDetailBody({ lead, showMatching, onStartMatching }: {
|
||||
lead: MarketLead
|
||||
showMatching: boolean
|
||||
onStartMatching?: (signalId: string) => void
|
||||
}) {
|
||||
const { signal, matchingProperties } = lead
|
||||
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
|
||||
const companyName = signal.companyName ?? signal.locationHint
|
||||
@@ -133,19 +150,95 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Bei Risiko: das betroffene Mietverhältnis benennen (§2) */}
|
||||
{risiko && (signal.affectedTenant || signal.propertyId) && (
|
||||
{/*
|
||||
Bei Risiko: das betroffene Mietverhältnis benennen (§2, Runde 10 §2.13A).
|
||||
Seit Runde 10 steht der Kasten auch dann, wenn nichts zuordenbar ist —
|
||||
mit genau diesem Satz. Ein fehlender Kasten liesse offen, ob niemand
|
||||
nachgesehen hat oder ob nichts betroffen ist; eine beliebige
|
||||
Mock-Immobilie zu zeigen wäre die schlechtere Antwort von beiden.
|
||||
*/}
|
||||
{risiko && (
|
||||
<Box sx={{ border: '1px solid', borderColor: DS_COLORS.leadSignal.RISIKO.fg, bgcolor: DS_COLORS.leadSignal.RISIKO.bg, borderRadius: 1.5, p: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.leadSignal.RISIKO.fg, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Betroffenes Mietverhältnis
|
||||
Betroffenes Objekt
|
||||
</Typography>
|
||||
{signal.affectedTenant && (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
|
||||
{signal.affectedTenant}
|
||||
</Typography>
|
||||
)}
|
||||
{signal.propertyId && (
|
||||
{signal.propertyId ? (
|
||||
<ObjectDeepLink propertyId={signal.propertyId} label="Objekt in «Meine Objekte» öffnen" />
|
||||
) : (
|
||||
!signal.affectedTenant && (
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800] }}>
|
||||
Kein internes Objekt eindeutig zugeordnet.
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Zeitliche Einordnung mit Fundstelle (Runde 10, §2.9) */}
|
||||
{signal.temporalClass && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Zeitliche Relevanz
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={TEMPORAL_CLASS_LABELS[signal.temporalClass]}
|
||||
size="small"
|
||||
sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[700], fontSize: '0.75rem', fontWeight: 600 }}
|
||||
/>
|
||||
{signal.eventDate && (
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[600] }}>
|
||||
Ereigniszeit laut Quelle: {signal.eventDate}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{signal.temporalEvidence && (
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mt: 0.75 }}>
|
||||
{signal.temporalEvidence}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Semantisches Suchprofil (Runde 10, §§2.13B/2.14).
|
||||
|
||||
Es steht nur bei Chance-Leads, weil es einen Flächenbedarf beschreibt —
|
||||
bei einem Risiko gibt es keinen Nachfrager, für den man suchen könnte.
|
||||
Der Text ist derselbe, den Nora abgleicht; erzeugt wird er einmal, von
|
||||
Livia. Ein zweiter Text an dieser Stelle wären zwei Aussagen über
|
||||
denselben Bedarf.
|
||||
*/}
|
||||
{!risiko && signal.semanticSearchProfile && (
|
||||
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Semantisches Suchprofil
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.65, whiteSpace: 'pre-line' }}>
|
||||
{signal.semanticSearchProfile}
|
||||
</Typography>
|
||||
|
||||
{/*
|
||||
«Matching starten» übergibt denselben Lead an Nora — über die
|
||||
Lead-ID, nicht über eine Kopie. Das Kennzeichen im Zustand sagt
|
||||
Nora, dass der Lauf sofort starten darf; wer den Lead später
|
||||
normal öffnet, löst nichts von selbst aus (§2.14).
|
||||
*/}
|
||||
{onStartMatching && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<Target size={14} aria-hidden />}
|
||||
onClick={() => onStartMatching(signal.id)}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, mt: 1.5 }}
|
||||
>
|
||||
Matching starten
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { LeadDetail, UnifiedLeadList } from '../market-leads'
|
||||
import { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||
import { ResearchLeadCard } from './ResearchLeadCard'
|
||||
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
||||
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
|
||||
import { useResearchDocuments } from '../../hooks/useResearchDocuments'
|
||||
import { useTeamAgent } from '../../hooks/useTeamAgents'
|
||||
import { leseQuellenAus } from '../../lib/agentSystems'
|
||||
import { LeadChannel } from '../../domain/unifiedLead'
|
||||
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||
import { LeadSignalType } from '../../domain/futureSignal'
|
||||
import { TemporalClass } from '../../domain/researchLead'
|
||||
import type { TemporalClass as TemporalClassValue } from '../../domain/researchLead'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/** Kennung der Einstellung, die die erlaubten Ereigniszeiten führt (§2.9). */
|
||||
const TEMPORAL_SETTING_ID = 'livia-set-temporal-relevance'
|
||||
|
||||
const GUELTIGE_KLASSEN = new Set<string>(Object.values(TemporalClass))
|
||||
|
||||
/**
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15).
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15; Runde 10, §2.12).
|
||||
*
|
||||
* Zwei Bestände nebeneinander, bewusst getrennt gehalten:
|
||||
* Seit Runde 10 gibt es hier **eine** Leadliste. Bis dahin standen die frisch
|
||||
* gelesenen Leads als eigene Karten über dem erfassten Bestand — technisch
|
||||
* sauber getrennt, in der Benutzung aber unbrauchbar: Dieselbe Firma sah oben
|
||||
* anders aus als unten, die Filter griffen nur auf der einen Hälfte, und Nora
|
||||
* bekam die neuen Leads gar nicht zu sehen.
|
||||
*
|
||||
* 1. **Live-Recherche** — was Livia beim Knopfdruck aus den drei realen
|
||||
* Quellen gelesen und beurteilt hat. Diese Leads sind nicht gespeichert;
|
||||
* sie leben im Zustand dieser Komponente, bis der nächste Lauf sie ersetzt.
|
||||
* 2. **Erfasste Leads** — der bestehende Signalbestand, den Nora abgleicht.
|
||||
* Jetzt wandert ein Lauf direkt in den zentralen Signalbestand. Was oben steht,
|
||||
* ist nur noch der Bericht über den Lauf selbst: welche Quellen antworteten,
|
||||
* wie viel gelesen und wie viel verworfen wurde. Die Leads stehen dort, wo alle
|
||||
* Leads stehen.
|
||||
*
|
||||
* Die Trennung ist Absicht: ein live gelesener Artikel ist etwas anderes als
|
||||
* ein redaktionell erfasster Lead, und beides in eine Liste zu mischen würde
|
||||
* genau diesen Unterschied verwischen. Die Liste unten ist dieselbe Komponente
|
||||
* wie bei Nora — mit `showMatching={false}`, weil das Matching ihre Arbeit ist.
|
||||
* Welche Quellen abgerufen werden, entscheidet nicht diese Komponente, sondern
|
||||
* Livias Personalblatt: jeder aktive Lesezugang mit Adresse, dazu die manuell
|
||||
* abgelegten Dokumente. Damit ist die Anzeige unter «Systeme» keine Behauptung,
|
||||
* sondern die Eingabe des Laufs.
|
||||
*/
|
||||
export function ResearchWorkspace() {
|
||||
const theme = useTheme()
|
||||
@@ -32,6 +47,8 @@ export function ResearchWorkspace() {
|
||||
|
||||
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||
const { data: leads, isLoading } = useUnifiedLeads()
|
||||
const { data: livia } = useTeamAgent('livia')
|
||||
const { data: dokumente = [] } = useResearchDocuments()
|
||||
const refresh = useLiviaResearch()
|
||||
const ergebnis = refresh.data
|
||||
|
||||
@@ -49,18 +66,53 @@ export function ResearchWorkspace() {
|
||||
return { research: rows, chancen: c, risiken: r }
|
||||
}, [leads])
|
||||
|
||||
/** Die Lesequellen kommen aus dem gespeicherten Stand, nicht aus einer festen Liste. */
|
||||
const quellen = useMemo(() => leseQuellenAus(livia?.systems ?? []), [livia?.systems])
|
||||
|
||||
/** Die erlaubten Ereigniszeiten stehen als Einstellung am Personalblatt. */
|
||||
const zeitfenster = useMemo((): TemporalClassValue[] => {
|
||||
const wert = livia?.settings.find(s => s.id === TEMPORAL_SETTING_ID)?.value
|
||||
if (!Array.isArray(wert)) return []
|
||||
return wert.filter((v): v is TemporalClassValue => GUELTIGE_KLASSEN.has(v))
|
||||
}, [livia?.settings])
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
/**
|
||||
* Übergabe an Nora (Runde 10, §2.14).
|
||||
*
|
||||
* Weitergereicht wird nur die Lead-ID — Nora liest denselben Eintrag aus
|
||||
* demselben Bestand, es entsteht keine Kopie. Das Kennzeichen `matching=1`
|
||||
* sagt ihr, dass der Lauf sofort starten darf; ohne dieses Kennzeichen läuft
|
||||
* beim normalen Öffnen nichts von selbst.
|
||||
*
|
||||
* Beides steht in der Adresse und nicht im Navigationszustand: So überlebt
|
||||
* die Übergabe ein Neuladen, und der Link lässt sich weitergeben.
|
||||
*/
|
||||
const handleStartMatching = useCallback(
|
||||
(signalId: string) => {
|
||||
navigate(`${ROUTES.SUPPLY.AGENT_NORA}?lead=${encodeURIComponent(signalId)}&matching=1`)
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const closeDetail = useCallback(() => setSelected(null), [])
|
||||
const starten = useCallback(() => refresh.mutate(), [refresh])
|
||||
const starten = useCallback(
|
||||
() => refresh.mutate({ sources: quellen, documents: dokumente, temporalClasses: zeitfenster }),
|
||||
[refresh, quellen, dokumente, zeitfenster],
|
||||
)
|
||||
|
||||
const keineQuellen = quellen.length === 0 && dokumente.length === 0
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* ── Live-Recherche ──────────────────────────────────────────────── */}
|
||||
{/* ── Der Lauf ────────────────────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
|
||||
disabled={refresh.isPending}
|
||||
disabled={refresh.isPending || keineQuellen}
|
||||
onClick={starten}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
@@ -73,18 +125,35 @@ export function ResearchWorkspace() {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!refresh.isPending && !ergebnis && !keineQuellen && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`}
|
||||
{dokumente.length > 0 &&
|
||||
` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!refresh.isPending && ergebnis && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
|
||||
{`${ergebnis.discarded} nicht relevant · `}
|
||||
{`${ergebnis.watchlist} Watchlist · `}
|
||||
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'relevante Entwicklung' : 'relevante Entwicklungen'}`}
|
||||
{ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `}
|
||||
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead übernommen' : 'Leads übernommen'}`}
|
||||
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
|
||||
{ergebnis.model && ` · Modell ${ergebnis.model}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{keineQuellen && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt.
|
||||
Quellen werden im Personalblatt unter «Kanäle & Systeme» angebunden, Dokumente unter
|
||||
«Einstellungen».
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ResearchSourcesPanel results={ergebnis?.sources} />
|
||||
|
||||
{ergebnis?.analysisError && (
|
||||
@@ -93,23 +162,17 @@ export function ResearchWorkspace() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{ergebnis && ergebnis.leads.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{ergebnis.leads.map(lead => (
|
||||
<ResearchLeadCard key={`${lead.sourceUrl}-${lead.headline}`} lead={lead} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug.
|
||||
Gelesen wurden {ergebnis.analyzed} Einträge.
|
||||
{ergebnis.temporalFiltered > 0 &&
|
||||
` ${ergebnis.temporalFiltered} davon fielen durch die eingestellte zeitliche Relevanz.`}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Erfasste Leads ──────────────────────────────────────────────── */}
|
||||
{/* ── Die eine Leadliste ──────────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1, borderTop: `1px solid ${DS_SLATE[200]}` }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{isLoading
|
||||
@@ -124,7 +187,7 @@ export function ResearchWorkspace() {
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
showChannel={false}
|
||||
emptyText="Noch keine erfassten Leads. Was Livia gerade live gelesen hat, steht oberhalb."
|
||||
emptyText="Noch keine Leads. «Research aktualisieren» liest die angebundenen Quellen und legt gefundene Leads hier ab."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -145,6 +208,7 @@ export function ResearchWorkspace() {
|
||||
<LeadDetail
|
||||
lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }}
|
||||
showMatching={false}
|
||||
onStartMatching={handleStartMatching}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Property On — Mehrfachauswahl als entfernbare Chips (Runde 10, §§2.7–2.9).
|
||||
*
|
||||
* Eine Komponente für alle drei Stellen — Quellentypen, Beobachtungsraum und
|
||||
* zeitliche Relevanz. Sie unterscheiden sich nur darin, ob eigene Werte erlaubt
|
||||
* sind; die Bedienung ist überall dieselbe, und das ist der Punkt: Wer gelernt
|
||||
* hat, wie man einen Quellentyp entfernt, weiss es auch beim Beobachtungsraum.
|
||||
*
|
||||
* Der eine Unterschied zum bisherigen Auswahlfeld: Jeder gewählte Wert lässt
|
||||
* sich einzeln wegklicken — auch die, die von Anfang an gesetzt waren. Vorher
|
||||
* konnte man Vorgaben nur über das Auswahlmenü loswerden, was niemand fand.
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { Box, Chip, MenuItem, TextField, Typography } from '@mui/material'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { AgentSettingOption } from '../../domain/teamAgent'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
value: string[]
|
||||
options: AgentSettingOption[]
|
||||
/** Freitext erlauben — bei fachlich abgeschlossenen Listen aus. */
|
||||
allowCustom: boolean
|
||||
disabled?: boolean
|
||||
ariaLabel: string
|
||||
onChange: (next: string[]) => void
|
||||
}
|
||||
|
||||
/** Ein selbst erfasster Wert hat keine Option — dann ist er selbst sein Etikett. */
|
||||
function etikett(value: string, options: AgentSettingOption[]): string {
|
||||
return options.find(o => o.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export const AgentChipsField = memo(function AgentChipsField({
|
||||
value, options, allowCustom, disabled, ariaLabel, onChange,
|
||||
}: Props) {
|
||||
const [eingabe, setEingabe] = useState('')
|
||||
|
||||
const offeneOptionen = options.filter(o => !value.includes(o.value))
|
||||
|
||||
const entfernen = useCallback(
|
||||
(v: string) => onChange(value.filter(e => e !== v)),
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
const hinzufuegen = useCallback(
|
||||
(v: string) => {
|
||||
const sauber = v.trim()
|
||||
// Leeres nicht speichern, Doppeltes nicht anlegen — beides würde die
|
||||
// Liste nur verlängern, ohne etwas auszusagen.
|
||||
if (!sauber || value.includes(sauber)) return
|
||||
onChange([...value, sauber])
|
||||
},
|
||||
[value, onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }} role="list" aria-label={ariaLabel}>
|
||||
{value.length === 0 && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, py: 0.5 }}>
|
||||
Nichts ausgewählt — damit gilt keine Einschränkung.
|
||||
</Typography>
|
||||
)}
|
||||
{value.map(v => (
|
||||
<Chip
|
||||
key={v}
|
||||
role="listitem"
|
||||
size="small"
|
||||
label={etikett(v, options)}
|
||||
onDelete={disabled ? undefined : () => entfernen(v)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{offeneOptionen.length > 0 && (
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
value=""
|
||||
label="Auswählen"
|
||||
sx={{ minWidth: 220, flex: 1 }}
|
||||
onChange={e => hinzufuegen(e.target.value)}
|
||||
>
|
||||
{offeneOptionen.map(o => (
|
||||
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
|
||||
{o.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
|
||||
{allowCustom && (
|
||||
<TextField
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
value={eingabe}
|
||||
label="Eigener Eintrag"
|
||||
placeholder="Tippen und Enter"
|
||||
sx={{ minWidth: 220, flex: 1 }}
|
||||
onChange={e => setEingabe(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return
|
||||
// Sonst schickt Enter das umgebende Formular ab.
|
||||
e.preventDefault()
|
||||
hinzufuegen(eingabe)
|
||||
setEingabe('')
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: <Plus size={14} color={DS_TEXT.muted} aria-hidden />,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -25,6 +25,27 @@ export interface EntityField {
|
||||
|
||||
export type EntityDraft = Record<string, string>
|
||||
|
||||
/**
|
||||
* Eine Adresse wird als Quelle tatsächlich abgerufen — sie muss deshalb an der
|
||||
* Maske scheitern und nicht erst im Recherchelauf. Geprüft wird nur das
|
||||
* Schema: ob die Seite antwortet, kann erst der Abruf sagen, und ein Formular,
|
||||
* das Erreichbarkeit behauptet, wäre eine Behauptung zu viel.
|
||||
*/
|
||||
function urlFehler(wert: string): string | null {
|
||||
const text = wert.trim()
|
||||
if (text === '') return null
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(text)
|
||||
} catch {
|
||||
return 'Bitte eine vollständige Adresse angeben, z. B. https://www.beispiel.ch/news.'
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return 'Nur http- und https-Adressen können gelesen werden.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
/**
|
||||
@@ -85,9 +106,17 @@ function DialogBody({
|
||||
|
||||
const fehlend = fields.filter(f => f.required && !(draft[f.key] ?? '').trim()).map(f => f.key)
|
||||
|
||||
// Formfehler je Feld, unabhängig davon, ob das Feld leer geblieben ist.
|
||||
const formfehler: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
if (f.type !== 'url') continue
|
||||
const meldung = urlFehler(draft[f.key] ?? '')
|
||||
if (meldung) formfehler[f.key] = meldung
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setTouched(true)
|
||||
if (fehlend.length > 0) return
|
||||
if (fehlend.length > 0 || Object.keys(formfehler).length > 0) return
|
||||
onSave(draft, active)
|
||||
}
|
||||
|
||||
@@ -107,10 +136,12 @@ function DialogBody({
|
||||
onChange={e => setDraft(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
placeholder={f.placeholder}
|
||||
required={f.required}
|
||||
error={touched && fehlend.includes(f.key)}
|
||||
error={touched && (fehlend.includes(f.key) || f.key in formfehler)}
|
||||
helperText={
|
||||
touched && fehlend.includes(f.key)
|
||||
? 'Dieses Feld wird gebraucht.'
|
||||
: touched && formfehler[f.key]
|
||||
? formfehler[f.key]
|
||||
: f.helperText
|
||||
}
|
||||
size="small"
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
// prettier-ignore
|
||||
import { Box, Button, Chip, FormControlLabel, FormHelperText, InputAdornment, MenuItem, Select, Switch, TextField, Typography } from '@mui/material'
|
||||
import { AlertTriangle, Lock } from 'lucide-react'
|
||||
import { Box, Button, Chip, FormControlLabel, FormHelperText, IconButton, InputAdornment, MenuItem, Popover, Select, Switch, TextField, Typography } from '@mui/material'
|
||||
import { AlertTriangle, Info, Lock } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
import type { AgentSetting, TeamAgent } from '../../domain/teamAgent'
|
||||
import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent'
|
||||
import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants'
|
||||
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { useSaveAgentSettings } from '../../hooks/useTeamAgents'
|
||||
import { AgentChipsField } from './AgentChipsField'
|
||||
import { ResearchDocumentsField } from './ResearchDocumentsField'
|
||||
|
||||
type SettingValue = AgentSetting['value']
|
||||
|
||||
@@ -53,6 +55,14 @@ function buildSettingsSchema(settings: AgentSetting[]) {
|
||||
shape[setting.id] = field
|
||||
} else if (setting.kind === AgentSettingKind.MULTI_SELECT) {
|
||||
shape[setting.id] = z.array(z.string()).min(1, 'Mindestens ein Eintrag ist erforderlich.')
|
||||
} else if (setting.kind === AgentSettingKind.CHIPS) {
|
||||
// Bewusst ohne Mindestzahl: Eine leere Auswahl bedeutet «keine
|
||||
// Einschränkung» und ist damit eine gültige Aussage, kein Fehler.
|
||||
shape[setting.id] = z.array(z.string())
|
||||
} else if (setting.kind === AgentSettingKind.DOCUMENTS) {
|
||||
// Die Dokumente liegen in IndexedDB und nicht im Entwurf dieses Reiters;
|
||||
// hier steht nur der Platz, an dem sie erscheinen.
|
||||
shape[setting.id] = z.any()
|
||||
} else if (setting.kind === AgentSettingKind.BOOLEAN) {
|
||||
shape[setting.id] = z.boolean()
|
||||
} else {
|
||||
@@ -62,6 +72,40 @@ function buildSettingsSchema(settings: AgentSetting[]) {
|
||||
return z.object(shape)
|
||||
}
|
||||
|
||||
// ── Erklärung zu einem Wert (Runde 10, §2.10) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ein Popover und kein Tooltip: Der Text ist mehrere Sätze lang, und ein
|
||||
* Tooltip verschwindet, sobald man den Zeiger bewegt, um ihn zu Ende zu lesen.
|
||||
*/
|
||||
function SettingInfoButton({ label, text }: { label: string; text: string }) {
|
||||
const [anker, setAnker] = useState<HTMLElement | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Wie entsteht «${label}»?`}
|
||||
onClick={(e) => setAnker(e.currentTarget)}
|
||||
sx={{ color: DS_TEXT.muted, p: 0.25, ...FOCUS_SX }}
|
||||
>
|
||||
<Info size={14} />
|
||||
</IconButton>
|
||||
<Popover
|
||||
open={anker !== null}
|
||||
anchorEl={anker}
|
||||
onClose={() => setAnker(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
slotProps={{ paper: { sx: { maxWidth: 460, p: 2, boxShadow: DS_SHADOW.panel } } }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>
|
||||
{text}
|
||||
</Typography>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Eine Einstellung ──────────────────────────────────────────────────────────
|
||||
|
||||
interface RowProps {
|
||||
@@ -126,6 +170,24 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
|
||||
</>
|
||||
)
|
||||
break
|
||||
case AgentSettingKind.CHIPS:
|
||||
control = (
|
||||
<>
|
||||
<AgentChipsField
|
||||
value={Array.isArray(setting.value) ? setting.value : []}
|
||||
options={options}
|
||||
allowCustom={setting.allowCustom === true}
|
||||
disabled={locked}
|
||||
ariaLabel={setting.label}
|
||||
onChange={(next) => onChange(setting.id, next)}
|
||||
/>
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
</>
|
||||
)
|
||||
break
|
||||
case AgentSettingKind.DOCUMENTS:
|
||||
control = <ResearchDocumentsField />
|
||||
break
|
||||
default:
|
||||
control = (
|
||||
<TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined}
|
||||
@@ -135,7 +197,10 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 0.5, py: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography>
|
||||
{setting.infoText && <SettingInfoButton label={setting.label} text={setting.infoText} />}
|
||||
</Box>
|
||||
{setting.description && (
|
||||
<Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Property On — Reiter «Systeme» im Personalblatt (§10, Runde 10 §3).
|
||||
* Property On — Reiter «Systeme» im Personalblatt (§10, Runde 10 §§1–3).
|
||||
*
|
||||
* Zeigt und pflegt die Fachsysteme und Datenquellen eines digitalen
|
||||
* Mitarbeiters. Seit Runde 10 ist der Reiter nicht mehr nur Anzeige: Zugänge
|
||||
@@ -7,120 +7,52 @@
|
||||
*
|
||||
* Bewusst eine einzige Liste statt getrennter Gruppen «lesend» und
|
||||
* «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser
|
||||
* Mitarbeiter», nicht «wie viele davon sind lesend». Der Zugriffstyp steht
|
||||
* weiterhin an jedem Eintrag — als Badge, als Icon und als Klartextsatz, denn
|
||||
* Farbe allein ist kein zulässiger Statusträger (§2.3, §18).
|
||||
* Mitarbeiter», nicht «wie viele davon sind lesend». Jede Zeile trägt rechts
|
||||
* oben genau zwei Angaben — ob sie genutzt wird und ob gelesen oder geschrieben
|
||||
* wird. Beide sind Text, nicht nur Farbe (§2.3, §18).
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material'
|
||||
import { Eye, KeyRound, PenLine, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Box, Button, IconButton, Link, Switch, Tooltip, Typography } from '@mui/material'
|
||||
import { ExternalLink, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
AgentAccessLevel,
|
||||
AgentConnectionStatus,
|
||||
AgentSystemType,
|
||||
} from '../../domain/teamAgent'
|
||||
import type { AgentSystem, TeamAgent } from '../../domain/teamAgent'
|
||||
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
|
||||
import { GenericBadge } from '../shared/GenericBadge'
|
||||
import { AgentEntityDialog } from './AgentEntityDialog'
|
||||
import type { EntityDraft, EntityField } from './AgentEntityDialog'
|
||||
import { newEntityId, removeById, upsertById } from './agentEntityEditing'
|
||||
import { useSaveAgentSystems } from '../../hooks/useTeamAgents'
|
||||
import {
|
||||
AGENT_ACCESS_LABELS,
|
||||
AGENT_CONNECTION_STATUS_LABELS,
|
||||
AGENT_SYSTEM_LABELS,
|
||||
} from '../../lib/constants'
|
||||
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
// ── Darstellung je Zugriffstyp ────────────────────────────────────────────────
|
||||
|
||||
interface AccessAppearance {
|
||||
bg: string
|
||||
border: string
|
||||
text: string
|
||||
icon: LucideIcon
|
||||
/** Klartext neben dem Icon, damit der Zugriff nicht nur über die Farbe lesbar ist. */
|
||||
marker: string
|
||||
}
|
||||
|
||||
const ACCESS_APPEARANCE: Record<AgentAccessLevel, AccessAppearance> = {
|
||||
[AgentAccessLevel.READ]: {
|
||||
bg: DS_BG.surface,
|
||||
border: DS_BORDER.default,
|
||||
text: DS_TEXT.secondary,
|
||||
icon: Eye,
|
||||
marker: 'Liest nur — verändert in diesem System nichts',
|
||||
},
|
||||
[AgentAccessLevel.WRITE]: {
|
||||
bg: DS_SURFACE.warning.bg,
|
||||
border: DS_SURFACE.warning.border,
|
||||
text: DS_TEXT.warningDark,
|
||||
icon: KeyRound,
|
||||
marker: 'Schreibzugriff — legt in diesem System Daten ab',
|
||||
},
|
||||
[AgentAccessLevel.READ_WRITE]: {
|
||||
bg: DS_SURFACE.info.bg,
|
||||
border: DS_SURFACE.info.border,
|
||||
text: DS_TEXT.infoDark,
|
||||
icon: PenLine,
|
||||
marker: 'Lese- und Schreibzugriff — verändert bestehende Daten',
|
||||
},
|
||||
}
|
||||
|
||||
/** Ohne Feld gilt ein Zugang als aktiv — der Katalog führt die Zugänge ohne Schalter. */
|
||||
function istAktiv(system: AgentSystem): boolean {
|
||||
return system.enabled !== false
|
||||
}
|
||||
import { AGENT_ACCESS_LABELS } from '../../lib/constants'
|
||||
import { istAktiv, istSchreibend, systemTitel } from '../../lib/agentSystems'
|
||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
// ── Maske ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SYSTEM_FIELDS: EntityField[] = [
|
||||
{ key: 'title', label: 'Titel der Quelle', required: true },
|
||||
{ key: 'description', label: 'Beschreibung der Quelle', type: 'multiline', required: true },
|
||||
{
|
||||
key: 'type',
|
||||
label: 'System',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: Object.values(AgentSystemType).map((t) => ({
|
||||
value: t,
|
||||
label: AGENT_SYSTEM_LABELS[t] ?? t,
|
||||
})),
|
||||
key: 'url',
|
||||
label: 'Link / URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://…',
|
||||
helperText: 'Eine aktive Lesequelle mit Adresse wird im Recherchelauf tatsächlich abgerufen.',
|
||||
},
|
||||
{
|
||||
key: 'access',
|
||||
label: 'Zugriff',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: Object.values(AgentAccessLevel).map((a) => ({
|
||||
value: a,
|
||||
label: AGENT_ACCESS_LABELS[a] ?? a,
|
||||
})),
|
||||
options: [
|
||||
{ value: AgentAccessLevel.READ, label: AGENT_ACCESS_LABELS[AgentAccessLevel.READ] },
|
||||
{ value: AgentAccessLevel.WRITE, label: AGENT_ACCESS_LABELS[AgentAccessLevel.WRITE] },
|
||||
],
|
||||
helperText: 'Schreibrechte verändern Kundendaten in der Bewirtschaftung.',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Verbindung',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: Object.values(AgentConnectionStatus).map((s) => ({
|
||||
value: s,
|
||||
label: AGENT_CONNECTION_STATUS_LABELS[s] ?? s,
|
||||
})),
|
||||
helperText: 'Ein neu erfasster Zugang gilt erst als verbunden, wenn er eingerichtet ist.',
|
||||
},
|
||||
{
|
||||
key: 'usage',
|
||||
label: 'Wofür wird das System genutzt?',
|
||||
type: 'multiline',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'permissionNote',
|
||||
label: 'Berechtigungshinweis',
|
||||
placeholder: 'z. B. Schreibt erst nach Freigabe',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Eine Systemzeile ──────────────────────────────────────────────────────────
|
||||
@@ -139,30 +71,28 @@ interface RowProps {
|
||||
* und jeder Reiterwechsel sonst die gesamte Liste neu zeichnen würde (§10.2).
|
||||
*/
|
||||
const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onEdit, onDelete }: RowProps) {
|
||||
const look = ACCESS_APPEARANCE[system.access]
|
||||
const AccessIcon = look.icon
|
||||
const aktiv = istAktiv(system)
|
||||
const label = AGENT_SYSTEM_LABELS[system.type] ?? system.type
|
||||
const schreibend = istSchreibend(system)
|
||||
const titel = systemTitel(system)
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{
|
||||
border: `1px solid ${look.border}`,
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
borderRadius: 2,
|
||||
// Ein abgeschalteter Zugang tritt zurück, bleibt aber vollständig lesbar.
|
||||
bgcolor: aktiv ? look.bg : DS_BG.subtle,
|
||||
bgcolor: aktiv ? DS_BG.surface : DS_BG.subtle,
|
||||
opacity: aktiv ? 1 : 0.75,
|
||||
p: 1.75,
|
||||
display: 'grid',
|
||||
gap: 0.875,
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile: System, Aktivzustand, Zugriffstyp, Verbindungsstatus */}
|
||||
{/* Kopfzeile: Titel links, genau zwei Angaben rechts (§2.1B) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} />
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
||||
{label}
|
||||
{titel}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto', alignItems: 'center' }}>
|
||||
<GenericBadge
|
||||
@@ -170,8 +100,10 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
|
||||
semanticVariant={aktiv ? 'confidenceHigh' : 'muted'}
|
||||
bold
|
||||
/>
|
||||
<AgentAccessBadge access={system.access} />
|
||||
<AgentConnectionStatusBadge status={system.status} />
|
||||
<GenericBadge
|
||||
label={schreibend ? 'Schreiben' : 'Lesen'}
|
||||
semanticVariant={schreibend ? 'confidenceMedium' : 'muted'}
|
||||
/>
|
||||
{editing && (
|
||||
<>
|
||||
<Tooltip title="System bearbeiten">
|
||||
@@ -189,36 +121,22 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Zugriffstyp im Klartext — nie nur farblich */}
|
||||
<Typography variant="body2" sx={{ color: look.text, fontWeight: 600 }}>
|
||||
{look.marker}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
{system.usage}
|
||||
</Typography>
|
||||
|
||||
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */}
|
||||
{system.permissionNote && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.75,
|
||||
alignItems: 'flex-start',
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: DS_BG.surface,
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
}}
|
||||
{/* Die Adresse steht offen da — sie ist genau das, was abgerufen wird. */}
|
||||
{system.url && (
|
||||
<Link
|
||||
href={system.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="caption"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.brand, width: 'fit-content' }}
|
||||
>
|
||||
<ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
|
||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||
Berechtigung:{' '}
|
||||
</Box>
|
||||
{system.permissionNote}
|
||||
</Typography>
|
||||
</Box>
|
||||
{system.url}
|
||||
<ExternalLink size={11} aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
@@ -227,7 +145,7 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
|
||||
checked={aktiv}
|
||||
disabled={busy}
|
||||
onChange={(_, checked) => onToggle(system.id, checked)}
|
||||
slotProps={{ input: { 'aria-label': `Systemzugang ${label} aktivieren` } }}
|
||||
slotProps={{ input: { 'aria-label': `Systemzugang ${titel} aktivieren` } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: aktiv ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 600 }}>
|
||||
{aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'}
|
||||
@@ -273,14 +191,19 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
|
||||
const handleDialogSave = useCallback(
|
||||
(werte: EntityDraft, active: boolean) => {
|
||||
const bestehend = bearbeitet !== 'new' ? systems.find((s) => s.id === bearbeitet) : undefined
|
||||
const url = werte.url?.trim() ?? ''
|
||||
const eintrag: AgentSystem = {
|
||||
// Der Typ bleibt beim Bearbeiten erhalten; selbst erfasste Quellen sind
|
||||
// öffentliche Webquellen, weil genau das über die Adresse gelesen wird.
|
||||
type: bestehend?.type ?? AgentSystemType.PUBLIC_WEB,
|
||||
status: bestehend?.status ?? AgentConnectionStatus.CONNECTED,
|
||||
permissionNote: bestehend?.permissionNote ?? '',
|
||||
id: bestehend?.id ?? newEntityId(agent.id, 'system'),
|
||||
type: werte.type as AgentSystemType,
|
||||
title: werte.title.trim(),
|
||||
usage: werte.description.trim(),
|
||||
access: werte.access as AgentAccessLevel,
|
||||
status: werte.status as AgentConnectionStatus,
|
||||
usage: werte.usage.trim(),
|
||||
permissionNote: werte.permissionNote?.trim() ?? '',
|
||||
enabled: active,
|
||||
...(url ? { url } : {}),
|
||||
}
|
||||
speichern(upsertById(systems, eintrag))
|
||||
setBearbeitet(null)
|
||||
@@ -361,11 +284,12 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
|
||||
title={inBearbeitung ? 'System bearbeiten' : 'Neues System anbinden'}
|
||||
fields={SYSTEM_FIELDS}
|
||||
initial={{
|
||||
type: inBearbeitung?.type ?? AgentSystemType.DMS,
|
||||
access: inBearbeitung?.access ?? AgentAccessLevel.READ,
|
||||
status: inBearbeitung?.status ?? AgentConnectionStatus.DISCONNECTED,
|
||||
usage: inBearbeitung?.usage ?? '',
|
||||
permissionNote: inBearbeitung?.permissionNote ?? '',
|
||||
title: inBearbeitung ? systemTitel(inBearbeitung) : '',
|
||||
description: inBearbeitung?.usage ?? '',
|
||||
url: inBearbeitung?.url ?? '',
|
||||
access: inBearbeitung && istSchreibend(inBearbeitung)
|
||||
? AgentAccessLevel.WRITE
|
||||
: AgentAccessLevel.READ,
|
||||
}}
|
||||
activeLabel="Zugang aktiv"
|
||||
initialActive={inBearbeitung ? istAktiv(inBearbeitung) : true}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Property On — «Newsquellen manuell ablegen» (Runde 10, §2.6).
|
||||
*
|
||||
* Ersetzt die Liste verknüpfter Newsadressen im Einstellungsbereich. Der Grund
|
||||
* für den Tausch: Die Adressen standen dort als Auswahlfeld, ohne dass sie
|
||||
* irgendetwas bewirkt hätten — abgerufen wird, was unter «Systeme» als aktiver
|
||||
* Lesezugang steht. Zwei Orte für dieselbe Frage, von denen einer wirkungslos
|
||||
* war, ist schlechter als einer.
|
||||
*
|
||||
* Was hier abgelegt wird, fliesst dagegen tatsächlich in den nächsten Lauf:
|
||||
* Der Text wird beim Ablegen einmal aus der Datei gewonnen und geht zusammen
|
||||
* mit den gelesenen Seiten in dieselbe Beurteilung.
|
||||
*
|
||||
* Die Datei selbst verlässt den Browser nie — an den Server geht nur ihr Text.
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useRef } from 'react'
|
||||
import { Box, Button, CircularProgress, IconButton, Tooltip, Typography } from '@mui/material'
|
||||
import { AlertTriangle, FileText, Trash2, Upload } from 'lucide-react'
|
||||
import {
|
||||
RESEARCH_DOCUMENT_LABELS,
|
||||
RESEARCH_DOCUMENT_MAX_BYTES,
|
||||
} from '../../domain/researchDocument'
|
||||
import type { ResearchDocument } from '../../domain/researchDocument'
|
||||
import {
|
||||
useRemoveResearchDocument,
|
||||
useResearchDocuments,
|
||||
useUploadResearchDocument,
|
||||
} from '../../hooks/useResearchDocuments'
|
||||
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const FOCUS_SX = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
|
||||
|
||||
function groesse(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function datum(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
const DokumentZeile = memo(function DokumentZeile({
|
||||
doc, onRemove, busy,
|
||||
}: { doc: ResearchDocument; onRemove: (id: string) => void; busy: boolean }) {
|
||||
const unbrauchbar = Boolean(doc.extractionNote)
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1, p: 1.25,
|
||||
border: `1px solid ${unbrauchbar ? DS_SURFACE.warning.border : DS_BORDER.default}`,
|
||||
borderRadius: 2,
|
||||
bgcolor: unbrauchbar ? DS_SURFACE.warning.bg : DS_BG.surface,
|
||||
}}
|
||||
>
|
||||
<FileText size={15} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: '0.8125rem', color: DS_TEXT.primary, wordBreak: 'break-word' }}>
|
||||
{doc.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary, display: 'block' }}>
|
||||
{RESEARCH_DOCUMENT_LABELS[doc.kind]} · {groesse(doc.size)} · abgelegt am {datum(doc.uploadedAt)}
|
||||
</Typography>
|
||||
|
||||
{doc.extractionNote && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'flex-start', mt: 0.5 }}>
|
||||
<AlertTriangle size={12} color={DS_TEXT.warning} aria-hidden style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.warningDark }}>
|
||||
{doc.extractionNote}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Tooltip title="Dokument entfernen">
|
||||
<span>
|
||||
<IconButton size="small" disabled={busy} onClick={() => onRemove(doc.id)} sx={{ color: DS_TEXT.secondary }}>
|
||||
<Trash2 size={14} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
export function ResearchDocumentsField() {
|
||||
const { data: dokumente = [], isLoading } = useResearchDocuments()
|
||||
const upload = useUploadResearchDocument()
|
||||
const remove = useRemoveResearchDocument()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(files: FileList | null) => {
|
||||
if (!files) return
|
||||
// Nacheinander: Die Extraktion ist rechenintensiv, und mehrere gleichzeitig
|
||||
// würden den Hauptthread für Sekunden blockieren.
|
||||
for (const file of Array.from(files)) upload.mutate(file)
|
||||
// Ohne Zurücksetzen liesse sich dieselbe Datei nicht erneut wählen.
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
},
|
||||
[upload],
|
||||
)
|
||||
|
||||
const handleRemove = useCallback((id: string) => remove.mutate(id), [remove])
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 1.25 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.docx"
|
||||
multiple
|
||||
hidden
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={upload.isPending}
|
||||
startIcon={upload.isPending
|
||||
? <CircularProgress size={13} color="inherit" />
|
||||
: <Upload size={14} aria-hidden />}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
sx={{ textTransform: 'none', ...FOCUS_SX }}
|
||||
>
|
||||
{upload.isPending ? 'Wird gelesen …' : 'Dokument ablegen'}
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
PDF oder Word (.docx), höchstens {RESEARCH_DOCUMENT_MAX_BYTES / 1024 / 1024} MB je Datei
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isLoading ? (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>Dokumente werden geladen …</Typography>
|
||||
) : dokumente.length === 0 ? (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
Noch nichts abgelegt. Abgelegte Dokumente gehen zusammen mit den aktiven Lesequellen in den
|
||||
nächsten Recherchelauf.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 0.75 }}>
|
||||
{dokumente.map(doc => (
|
||||
<DokumentZeile key={doc.id} doc={doc} onRemove={handleRemove} busy={remove.isPending} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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) ────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import type { ResearchRefreshResult } from '../domain/researchLead'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { ResearchRefreshResult, TemporalClass } from '../domain/researchLead'
|
||||
import type { ResearchSourceDescriptor } from '../lib/researchSources'
|
||||
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import { agentProtocolService } from '../services/agentProtocolService'
|
||||
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
/** Was ein Lauf mitbekommt — alles aus dem gespeicherten Stand, nichts hartcodiert. */
|
||||
export interface ResearchRunInput {
|
||||
/** Aktive Lesezugänge mit Adresse, abgeleitet aus Livias Systemen (§2.3). */
|
||||
sources: ResearchSourceDescriptor[]
|
||||
/** Manuell abgelegte Dokumente mit bereits extrahiertem Text (§2.6). */
|
||||
documents: ResearchDocument[]
|
||||
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung» (§2.9). */
|
||||
temporalClasses: TemporalClass[]
|
||||
}
|
||||
|
||||
/**
|
||||
* «Research aktualisieren» — der eine Aufruf des PoC.
|
||||
*
|
||||
@@ -11,15 +26,33 @@ import { useToastStore } from '../stores/toastStore'
|
||||
* `staleTime` und keine Zwischenspeicherung — wer auf den Knopf drückt, will
|
||||
* den Stand von jetzt und nicht den von vorhin.
|
||||
*
|
||||
* Das Ergebnis lebt im Zustand der Komponente. Für den Machbarkeitsnachweis
|
||||
* ist keine Persistenz nötig (§17).
|
||||
* Seit Runde 10 endet der Lauf nicht mit der Antwort. Drei Dinge passieren
|
||||
* danach, und zwar hier, weil sie zusammengehören (§§2.5/2.12):
|
||||
*
|
||||
* 1. Die gefundenen Leads gehen in den **zentralen** Signalbestand — es gibt
|
||||
* keine zweite Ergebnisliste mehr.
|
||||
* 2. Der Lauf erzeugt einen **echten** Protokolleintrag mit der Zahl der
|
||||
* gelesenen Quellen und der gefundenen Leads.
|
||||
* 3. Die Leadliste wird neu geladen, damit das Ergebnis sofort dort steht, wo
|
||||
* es hingehört.
|
||||
*/
|
||||
export function useLiviaResearch() {
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<ResearchRefreshResult, Error>({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, { method: 'POST' })
|
||||
return useMutation<ResearchRefreshResult, Error, ResearchRunInput>({
|
||||
mutationFn: async ({ sources, documents, temporalClasses }) => {
|
||||
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sources: sources.map(q => ({ id: q.id, name: q.name, url: q.url })),
|
||||
// Nur der extrahierte Text geht an den Server; die Datei selbst
|
||||
// bleibt im Browser. Sie hochzuladen wäre Datenversand ohne Nutzen.
|
||||
documents: documents.map(d => ({ id: d.id, name: d.name, text: d.text })),
|
||||
temporalClasses,
|
||||
}),
|
||||
})
|
||||
|
||||
// Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
|
||||
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall
|
||||
@@ -35,6 +68,32 @@ export function useLiviaResearch() {
|
||||
|
||||
return await res.json() as ResearchRefreshResult
|
||||
},
|
||||
|
||||
onSuccess: async (ergebnis) => {
|
||||
if (ergebnis.leads.length > 0) {
|
||||
await futureSignalService.importResearchLeads(ergebnis.leads, ergebnis.refreshedAt)
|
||||
await queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||
}
|
||||
|
||||
// Der Eintrag trägt die Zahlen des Laufs, nicht einen festen Text —
|
||||
// ein Protokoll, das immer dasselbe sagt, protokolliert nichts (§2.5).
|
||||
const gelesen = ergebnis.sources.filter(s => s.ok).length
|
||||
await agentProtocolService.logUserAction({
|
||||
agentId: 'livia',
|
||||
eventType: AgentProtocolEventType.TASK_RUN,
|
||||
title: 'Research aktualisiert',
|
||||
description:
|
||||
`${gelesen} von ${ergebnis.sources.length} ${ergebnis.sources.length === 1 ? 'Quelle' : 'Quellen'} gelesen · ` +
|
||||
`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Eintrag' : 'Einträge'} beurteilt · ` +
|
||||
`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead' : 'Leads'} in die Leadliste übernommen` +
|
||||
(ergebnis.temporalFiltered > 0
|
||||
? ` · ${ergebnis.temporalFiltered} wegen der zeitlichen Relevanz ausgeschlossen`
|
||||
: ''),
|
||||
status: ergebnis.analysisError ? AgentProtocolStatus.WARNING : AgentProtocolStatus.SUCCESS,
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['agentProtocol'] })
|
||||
},
|
||||
|
||||
onError: (err) => {
|
||||
showToast(err.message, 'error')
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { researchDocumentService } from '../services/researchDocumentService'
|
||||
import { STALE_SIGNALS } from '../lib/constants'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const KEY = ['researchDocuments']
|
||||
|
||||
/** Die manuell abgelegten Newsquellen (Runde 10, §2.6). */
|
||||
export function useResearchDocuments() {
|
||||
return useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => researchDocumentService.getAll(),
|
||||
staleTime: STALE_SIGNALS,
|
||||
select: (res) => res.data,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUploadResearchDocument() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => researchDocumentService.upload(file),
|
||||
onSuccess: (res) => {
|
||||
void queryClient.invalidateQueries({ queryKey: KEY })
|
||||
// Ein Dokument ohne brauchbaren Text wird abgelegt, trägt aber nichts
|
||||
// bei — das muss beim Ablegen gesagt werden und nicht erst, wenn der
|
||||
// Lauf keine Leads daraus findet.
|
||||
if (res.data.extractionNote) {
|
||||
useToastStore.getState().showToast(res.data.extractionNote, 'warning')
|
||||
} else {
|
||||
useToastStore.getState().showToast(`«${res.data.name}» abgelegt.`, 'success')
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
useToastStore.getState().showToast(err.message, 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveResearchDocument() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => researchDocumentService.remove(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: KEY })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Das Dokument konnte nicht entfernt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Welche Systemzugänge ein Recherchelauf tatsächlich abruft (Runde 10, §2.3).
|
||||
*
|
||||
* Der Test hält die Zusage fest, die das Personalblatt dem Nutzer gibt: Was
|
||||
* dort als aktive Lesequelle mit Adresse steht, wird gelesen — und nichts sonst.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { istAktiv, istSchreibend, leseQuellenAus, systemTitel } from '../agentSystems'
|
||||
import {
|
||||
AgentAccessLevel,
|
||||
AgentConnectionStatus,
|
||||
AgentSystemType,
|
||||
} from '../../domain/teamAgent'
|
||||
import type { AgentSystem } from '../../domain/teamAgent'
|
||||
|
||||
function system(overrides: Partial<AgentSystem> = {}): AgentSystem {
|
||||
return {
|
||||
id: 'sys-1',
|
||||
type: AgentSystemType.PUBLIC_WEB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage: 'Regionale Unternehmensmeldungen.',
|
||||
permissionNote: '',
|
||||
title: 'Beispielquelle',
|
||||
url: 'https://www.beispiel.ch/news',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('istAktiv', () => {
|
||||
it('gilt ohne Feld als aktiv — der Katalog führt seine Zugänge ohne Schalter', () => {
|
||||
expect(istAktiv(system({ enabled: undefined }))).toBe(true)
|
||||
})
|
||||
|
||||
it('ist nur bei ausdrücklichem false abgeschaltet', () => {
|
||||
expect(istAktiv(system({ enabled: false }))).toBe(false)
|
||||
expect(istAktiv(system({ enabled: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('istSchreibend', () => {
|
||||
it('zählt READ_WRITE als schreibend', () => {
|
||||
expect(istSchreibend(system({ access: AgentAccessLevel.READ_WRITE }))).toBe(true)
|
||||
expect(istSchreibend(system({ access: AgentAccessLevel.WRITE }))).toBe(true)
|
||||
expect(istSchreibend(system({ access: AgentAccessLevel.READ }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('systemTitel', () => {
|
||||
it('nimmt den eigenen Titel, wenn einer gesetzt ist', () => {
|
||||
expect(systemTitel(system({ title: 'Handelskammer' }))).toBe('Handelskammer')
|
||||
})
|
||||
|
||||
it('fällt auf das Katalog-Label zurück', () => {
|
||||
expect(systemTitel(system({ title: undefined, type: AgentSystemType.ZEFIX }))).toBe('Zefix')
|
||||
})
|
||||
|
||||
it('ignoriert einen Titel aus lauter Leerzeichen', () => {
|
||||
expect(systemTitel(system({ title: ' ', type: AgentSystemType.ZEFIX }))).toBe('Zefix')
|
||||
})
|
||||
})
|
||||
|
||||
describe('leseQuellenAus', () => {
|
||||
it('nimmt aktive Lesezugänge mit Adresse auf', () => {
|
||||
const quellen = leseQuellenAus([system()])
|
||||
|
||||
expect(quellen).toHaveLength(1)
|
||||
expect(quellen[0]).toMatchObject({ id: 'sys-1', name: 'Beispielquelle', url: 'https://www.beispiel.ch/news' })
|
||||
})
|
||||
|
||||
it('lässt abgeschaltete Zugänge weg', () => {
|
||||
expect(leseQuellenAus([system({ enabled: false })])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lässt schreibende Zugänge weg', () => {
|
||||
expect(leseQuellenAus([system({ access: AgentAccessLevel.READ_WRITE })])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lässt Zugänge ohne Adresse weg — es gibt nichts zu holen', () => {
|
||||
expect(leseQuellenAus([system({ url: undefined })])).toHaveLength(0)
|
||||
expect(leseQuellenAus([system({ url: ' ' })])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('behält die Reihenfolge des Personalblatts', () => {
|
||||
const quellen = leseQuellenAus([
|
||||
system({ id: 'a', title: 'Erste' }),
|
||||
system({ id: 'b', title: 'Zweite' }),
|
||||
system({ id: 'c', title: 'Dritte', enabled: false }),
|
||||
])
|
||||
|
||||
expect(quellen.map(q => q.name)).toEqual(['Erste', 'Zweite'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Vom Recherche-Lead zum Signal im zentralen Bestand (Runde 10, §2.12).
|
||||
*
|
||||
* Der Schwerpunkt liegt auf zwei Zusagen, die die Anwendung sonst still
|
||||
* brechen würde: Ein erneuter Lauf über denselben Artikel darf keine Dublette
|
||||
* erzeugen, und das Publikationsdatum darf die zeitliche Einordnung nicht
|
||||
* überschreiben.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { researchLeadId, researchLeadToSignal } from '../researchLeadMapper'
|
||||
import { ResearchEventType, TemporalClass } from '../../domain/researchLead'
|
||||
import type { ResearchLead } from '../../domain/researchLead'
|
||||
import { LeadSignalType } from '../../domain/futureSignal'
|
||||
|
||||
const LAUF = '2026-09-12T08:00:00.000Z'
|
||||
|
||||
function lead(overrides: Partial<ResearchLead> = {}): ResearchLead {
|
||||
return {
|
||||
company: 'Muster AG',
|
||||
eventType: ResearchEventType.EXPANSION,
|
||||
headline: 'Muster AG eröffnet zweiten Standort',
|
||||
location: 'Zürich',
|
||||
summary: 'Die Muster AG plant einen zusätzlichen Standort im Raum Zürich.',
|
||||
relevance: 'high',
|
||||
confidence: 0.8,
|
||||
reason: 'Konkrete Standortankündigung mit Flächenbezug.',
|
||||
source: 'Zürcher Handelskammer',
|
||||
sourceUrl: 'https://www.zhk.ch/de/news/muster-ag',
|
||||
temporalClass: TemporalClass.FUTURE,
|
||||
temporalEvidence: 'Die Eröffnung ist für das dritte Quartal angekündigt.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('researchLeadId', () => {
|
||||
it('vergibt für denselben Artikel dieselbe Kennung', () => {
|
||||
expect(researchLeadId(lead())).toBe(researchLeadId(lead()))
|
||||
})
|
||||
|
||||
it('unterscheidet zwei Artikel derselben Quelle', () => {
|
||||
const a = researchLeadId(lead())
|
||||
const b = researchLeadId(lead({ headline: 'Muster AG schliesst Werk Nord' }))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('unterscheidet denselben Titel aus zwei Quellen', () => {
|
||||
const a = researchLeadId(lead())
|
||||
const b = researchLeadId(lead({ sourceUrl: 'https://www.greaterzuricharea.com/de/news/muster' }))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('researchLeadToSignal', () => {
|
||||
it('macht aus einer Expansion eine Chance in Nachfragerichtung', () => {
|
||||
const signal = researchLeadToSignal(lead(), LAUF)
|
||||
|
||||
expect(signal.leadSignalType).toBe(LeadSignalType.CHANCE)
|
||||
expect(signal.signalDirection).toBe('DEMAND')
|
||||
expect(signal.origin).toBe('RESEARCH_RUN')
|
||||
})
|
||||
|
||||
it('macht aus einer Standortschliessung ein Risiko', () => {
|
||||
const signal = researchLeadToSignal(lead({ eventType: ResearchEventType.SITE_CLOSURE }), LAUF)
|
||||
|
||||
expect(signal.leadSignalType).toBe(LeadSignalType.RISIKO)
|
||||
})
|
||||
|
||||
it('übernimmt die zeitliche Einordnung samt Fundstelle', () => {
|
||||
const signal = researchLeadToSignal(lead(), LAUF)
|
||||
|
||||
expect(signal.temporalClass).toBe(TemporalClass.FUTURE)
|
||||
expect(signal.temporalEvidence).toContain('dritte')
|
||||
})
|
||||
|
||||
it('leitet den Zeithorizont aus der Ereigniszeit ab, nicht aus dem Publikationsdatum', () => {
|
||||
// Heute publiziert, Ereignis aber abgeschlossen: kein Horizont in der Zukunft.
|
||||
const vergangen = researchLeadToSignal(
|
||||
lead({ temporalClass: TemporalClass.PAST, publishedAt: LAUF }),
|
||||
LAUF,
|
||||
)
|
||||
const zukuenftig = researchLeadToSignal(
|
||||
lead({ temporalClass: TemporalClass.FUTURE, publishedAt: '2026-01-05' }),
|
||||
LAUF,
|
||||
)
|
||||
|
||||
expect(vergangen.timeHorizonMonths).toBe(0)
|
||||
expect(zukuenftig.timeHorizonMonths).toBeGreaterThan(vergangen.timeHorizonMonths)
|
||||
})
|
||||
|
||||
it('erfindet kein Ereignisdatum, wenn die Quelle keines nennt', () => {
|
||||
const signal = researchLeadToSignal(lead({ temporalClass: TemporalClass.UNKNOWN }), LAUF)
|
||||
|
||||
expect(signal.eventDate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reicht das semantische Suchprofil unverändert weiter', () => {
|
||||
const profil = 'Für die Muster AG wird ein Gewerbestandort im Raum Zürich gesucht. Nicht ableitbar ist die Fläche.'
|
||||
const signal = researchLeadToSignal(lead({ semanticSearchProfile: profil }), LAUF)
|
||||
|
||||
expect(signal.semanticSearchProfile).toBe(profil)
|
||||
})
|
||||
|
||||
it('behält die Originalquelle als anklickbaren Beleg', () => {
|
||||
const signal = researchLeadToSignal(lead(), LAUF)
|
||||
|
||||
expect(signal.source.url).toBe('https://www.zhk.ch/de/news/muster-ag')
|
||||
expect(signal.evidence?.sourceUrls).toEqual(['https://www.zhk.ch/de/news/muster-ag'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Der harte Zeitfilter (Runde 10, §2.9).
|
||||
*
|
||||
* Die Fälle stammen aus der Anforderung selbst: eine bereits erfolgte
|
||||
* Eröffnung, eine in drei Wochen, eine in vier Monaten — und der Fall, der den
|
||||
* ganzen Filter begründet: ein heute erschienener Artikel über ein Ereignis von
|
||||
* vor einem halben Jahr.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { passtZeitlich } from '../temporalFilter'
|
||||
import { TemporalClass } from '../../domain/researchLead'
|
||||
|
||||
const ALLE = [TemporalClass.PAST, TemporalClass.CURRENT, TemporalClass.FUTURE]
|
||||
|
||||
describe('passtZeitlich', () => {
|
||||
it('lässt ohne Auswahl alles durch — keine Auswahl ist keine Einschränkung', () => {
|
||||
for (const klasse of [...ALLE, TemporalClass.UNKNOWN]) {
|
||||
expect(passtZeitlich(klasse, [])).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('lässt nur die gewählten Fenster durch', () => {
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.FUTURE])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.FUTURE])).toBe(false)
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.FUTURE])).toBe(false)
|
||||
})
|
||||
|
||||
it('wirkt bei mehreren Fenstern als Oder', () => {
|
||||
const gewaehlt = [TemporalClass.CURRENT, TemporalClass.FUTURE]
|
||||
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, gewaehlt)).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, gewaehlt)).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.PAST, gewaehlt)).toBe(false)
|
||||
})
|
||||
|
||||
it('verwirft einen Lead ohne belegbare Ereigniszeit nicht', () => {
|
||||
// Ein erfundenes Datum wäre die schlechtere Antwort als ein sichtbarer Lead
|
||||
// mit dem Vermerk, dass die Ereigniszeit offen ist.
|
||||
expect(passtZeitlich(TemporalClass.UNKNOWN, [TemporalClass.FUTURE])).toBe(true)
|
||||
})
|
||||
|
||||
it('behandelt ein Bestandssignal ohne Einordnung wie «unbekannt»', () => {
|
||||
expect(passtZeitlich(undefined, [TemporalClass.PAST])).toBe(true)
|
||||
})
|
||||
|
||||
describe('die Fälle aus der Anforderung', () => {
|
||||
it('bereits erfolgte Eröffnung gehört zu «Vergangene Leads»', () => {
|
||||
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.PAST])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.CURRENT, TemporalClass.FUTURE])).toBe(false)
|
||||
})
|
||||
|
||||
it('Eröffnung in drei Wochen gehört zu «Aktuelle Leads»', () => {
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.CURRENT])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.PAST, TemporalClass.FUTURE])).toBe(false)
|
||||
})
|
||||
|
||||
it('angekündigte Eröffnung in vier Monaten gehört zu «Zukünftige Leads»', () => {
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.FUTURE])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.PAST, TemporalClass.CURRENT])).toBe(false)
|
||||
})
|
||||
|
||||
it('heute publiziert, Ereignis vor sechs Monaten — das ist vergangen, nicht aktuell', () => {
|
||||
// Der Filter kennt nur die Klasse; dass sie aus der Ereigniszeit und
|
||||
// nicht aus dem Publikationsdatum stammt, sichert der Auftrag an das
|
||||
// Modell. Hier wird festgehalten, was daraus folgen muss.
|
||||
const klasse = TemporalClass.PAST
|
||||
|
||||
expect(passtZeitlich(klasse, [TemporalClass.PAST])).toBe(true)
|
||||
expect(passtZeitlich(klasse, [TemporalClass.CURRENT])).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Property On — was sich aus einem Systemzugang ablesen lässt (Runde 10, §§2.1–2.3).
|
||||
*
|
||||
* Diese Ableitungen stehen bewusst ausserhalb der Reiter-Komponenten: Das
|
||||
* Personalblatt zeigt sie an, und Livias Recherchelauf entscheidet nach
|
||||
* denselben Regeln, welche Adresse er tatsächlich abruft. Zwei Fassungen
|
||||
* derselben Regel wären die Stelle, an der Anzeige und Abruf auseinanderlaufen —
|
||||
* und genau deren Übereinstimmung ist der Zweck der Anzeige.
|
||||
*/
|
||||
|
||||
import { AgentAccessLevel } from '../domain/teamAgent'
|
||||
import type { AgentSystem } from '../domain/teamAgent'
|
||||
import { AGENT_SYSTEM_LABELS } from './constants'
|
||||
import type { ResearchSourceDescriptor } from './researchSources'
|
||||
|
||||
/** Ohne Feld gilt ein Zugang als aktiv — der Katalog führt die Zugänge ohne Schalter. */
|
||||
export function istAktiv(system: AgentSystem): boolean {
|
||||
return system.enabled !== false
|
||||
}
|
||||
|
||||
/** Eigener Titel schlägt das Katalog-Label; nur so lassen sich freie Quellen benennen. */
|
||||
export function systemTitel(system: AgentSystem): string {
|
||||
return system.title?.trim() || AGENT_SYSTEM_LABELS[system.type] || system.type
|
||||
}
|
||||
|
||||
/** Schreibend ist alles, was nicht ausschliesslich liest. */
|
||||
export function istSchreibend(system: AgentSystem): boolean {
|
||||
return system.access !== AgentAccessLevel.READ
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Systemzugänge, die ein Recherchelauf tatsächlich abruft.
|
||||
*
|
||||
* Drei Bedingungen, alle drei sichtbar im Personalblatt: der Zugang ist
|
||||
* aktiv, er liest, und er hat eine Adresse. Ein Zugang ohne Adresse — etwa
|
||||
* die Objektdatenbank — wird nicht abgerufen, weil es nichts zu holen gibt;
|
||||
* ihn trotzdem als Quelle zu zählen, wäre eine hartcodierte Statusanzeige
|
||||
* ohne Deckung im Datenmodell.
|
||||
*/
|
||||
export function leseQuellenAus(systems: AgentSystem[]): ResearchSourceDescriptor[] {
|
||||
const quellen: ResearchSourceDescriptor[] = []
|
||||
for (const system of systems) {
|
||||
if (!istAktiv(system)) continue
|
||||
if (istSchreibend(system)) continue
|
||||
const url = system.url?.trim()
|
||||
if (!url) continue
|
||||
quellen.push({
|
||||
id: system.id,
|
||||
name: systemTitel(system),
|
||||
url,
|
||||
purpose: system.usage,
|
||||
})
|
||||
}
|
||||
return quellen
|
||||
}
|
||||
@@ -634,3 +634,17 @@ export const FIT_OUT_LABELS: Record<string, string> = {
|
||||
FULL: 'Ausgebaut · CAT B',
|
||||
PREMIUM: 'Vollausgebaut · Plug & Play',
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitliche Relevanz eines Leads (Runde 10, §2.9).
|
||||
*
|
||||
* Die Begriffe beschreiben die Ereigniszeit der Veränderung, nicht das
|
||||
* Publikationsdatum — deshalb «Veränderung abgeschlossen» und nicht
|
||||
* «älterer Artikel».
|
||||
*/
|
||||
export const TEMPORAL_CLASS_LABELS: Record<string, string> = {
|
||||
past: 'Veränderung abgeschlossen',
|
||||
current: 'Läuft oder steht kurz bevor',
|
||||
future: 'Angekündigt, später',
|
||||
unknown: 'Ereigniszeit nicht belegbar',
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Property On — Text aus abgelegten Dokumenten gewinnen (Runde 10, §2.6).
|
||||
*
|
||||
* Bewusst ohne neue Abhängigkeit. Der Browser bringt mit `DecompressionStream`
|
||||
* bereits alles mit, was für beide Formate nötig ist:
|
||||
*
|
||||
* - **DOCX** ist ein ZIP-Archiv. Darin liegt `word/document.xml`, und der
|
||||
* sichtbare Text steht in `<w:t>`-Elementen. Mehr braucht es nicht.
|
||||
* - **PDF** trägt seinen Text in Inhaltsströmen, meist mit Flate gepackt. Die
|
||||
* Operatoren `Tj` und `TJ` setzen ihn.
|
||||
*
|
||||
* Was diese Datei ausdrücklich **nicht** ist: ein vollwertiger Dokumentenleser.
|
||||
* Sie kennt keine Schriftkodierungen jenseits der üblichen, keine Tabellen, kein
|
||||
* Layout und keine gescannten Seiten. Für die Beurteilung durch ein Sprachmodell
|
||||
* genügt Fliesstext in der richtigen Reihenfolge — und wo sie scheitert, sagt
|
||||
* sie das, statt eine leere Zeichenkette als «Dokument ohne Inhalt» auszugeben.
|
||||
*
|
||||
* Eine Bibliothek wie pdf.js würde mehr können. Sie wäre auch rund ein Megabyte
|
||||
* gross, und der Machbarkeitsnachweis steht und fällt nicht an der Frage, ob
|
||||
* ein zweispaltiges PDF sauber ausgelesen wird.
|
||||
*/
|
||||
|
||||
import {
|
||||
RESEARCH_DOCUMENT_MIN_CHARS,
|
||||
ResearchDocumentKind,
|
||||
} from '../domain/researchDocument'
|
||||
|
||||
export interface ExtractionResult {
|
||||
text: string
|
||||
/** Gesetzt, wenn der Text nicht brauchbar ist — die Datei bleibt trotzdem abgelegt. */
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Gemeinsames ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function inflate(bytes: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
|
||||
const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream(format))
|
||||
const puffer = await new Response(stream).arrayBuffer()
|
||||
return new Uint8Array(puffer)
|
||||
}
|
||||
|
||||
function normalisiere(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ── DOCX ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Eine Datei aus einem ZIP-Archiv holen.
|
||||
*
|
||||
* Gelesen wird über das zentrale Verzeichnis am Dateiende — das ist der einzige
|
||||
* Ort im ZIP-Format, an dem die Einträge verlässlich vollständig stehen. Die
|
||||
* lokalen Kopfzeilen davor lassen Grössenangaben offen, wenn das Archiv im
|
||||
* Datenstrom geschrieben wurde, und genau das tun viele Textverarbeitungen.
|
||||
*/
|
||||
async function zipEintrag(bytes: Uint8Array, pfad: string): Promise<Uint8Array | null> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
|
||||
// Ende des zentralen Verzeichnisses suchen (Signatur 0x06054b50), von hinten.
|
||||
let eocd = -1
|
||||
for (let i = bytes.length - 22; i >= 0 && i > bytes.length - 65_557; i--) {
|
||||
if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break }
|
||||
}
|
||||
if (eocd === -1) return null
|
||||
|
||||
const anzahl = view.getUint16(eocd + 10, true)
|
||||
let offset = view.getUint32(eocd + 16, true)
|
||||
|
||||
const encoder = new TextDecoder()
|
||||
for (let n = 0; n < anzahl; n++) {
|
||||
if (view.getUint32(offset, true) !== 0x02014b50) return null
|
||||
const methode = view.getUint16(offset + 10, true)
|
||||
const gepackt = view.getUint32(offset + 20, true)
|
||||
const nameLen = view.getUint16(offset + 28, true)
|
||||
const extraLen = view.getUint16(offset + 30, true)
|
||||
const kommentarLen = view.getUint16(offset + 32, true)
|
||||
const lokal = view.getUint32(offset + 42, true)
|
||||
const name = encoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLen))
|
||||
|
||||
if (name === pfad) {
|
||||
// Die lokale Kopfzeile sagt, wie lang Name und Zusatzfeld dort sind —
|
||||
// sie dürfen von den Angaben im Verzeichnis abweichen.
|
||||
const lokalNameLen = view.getUint16(lokal + 26, true)
|
||||
const lokalExtraLen = view.getUint16(lokal + 28, true)
|
||||
const start = lokal + 30 + lokalNameLen + lokalExtraLen
|
||||
const daten = bytes.subarray(start, start + gepackt)
|
||||
return methode === 0 ? daten : await inflate(daten, 'deflate-raw')
|
||||
}
|
||||
|
||||
offset += 46 + nameLen + extraLen + kommentarLen
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function ausDocx(bytes: Uint8Array): Promise<ExtractionResult> {
|
||||
const xmlBytes = await zipEintrag(bytes, 'word/document.xml')
|
||||
if (!xmlBytes) {
|
||||
return { text: '', note: 'Die Datei konnte nicht als Word-Dokument gelesen werden.' }
|
||||
}
|
||||
|
||||
const xml = new TextDecoder().decode(xmlBytes)
|
||||
const absaetze: string[] = []
|
||||
// Je Absatz den Text seiner Textläufe zusammensetzen — sonst klebt der ganze
|
||||
// Text in einer einzigen Zeile und das Modell verliert die Gliederung.
|
||||
for (const [, inhalt] of xml.matchAll(/<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g)) {
|
||||
const teile = [...inhalt.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map(m => m[1])
|
||||
if (teile.length === 0) continue
|
||||
const zeile = teile
|
||||
.join('')
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
if (zeile) absaetze.push(zeile)
|
||||
}
|
||||
|
||||
return { text: normalisiere(absaetze.join('\n')) }
|
||||
}
|
||||
|
||||
// ── PDF ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zeichenfolgen aus den Textoperatoren eines Inhaltsstroms. */
|
||||
function pdfTextAusStrom(strom: string): string {
|
||||
const zeilen: string[] = []
|
||||
|
||||
// `(…) Tj` und `[(…) -250 (…)] TJ` — beide setzen sichtbaren Text.
|
||||
const muster = /(\[(?:[^\][\\]|\\.)*\]|\((?:[^\\()]|\\.)*\))\s*(TJ|Tj)/g
|
||||
for (const treffer of strom.matchAll(muster)) {
|
||||
const block = treffer[1]
|
||||
const stuecke: string[] = []
|
||||
for (const s of block.matchAll(/\(((?:[^\\()]|\\.)*)\)/g)) {
|
||||
stuecke.push(
|
||||
s[1]
|
||||
.replace(/\\([nrtbf])/g, (_, c: string) =>
|
||||
({ n: '\n', r: '\n', t: ' ', b: '', f: '\n' }[c] ?? ''))
|
||||
.replace(/\\(\d{1,3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8)))
|
||||
.replace(/\\(.)/g, '$1'),
|
||||
)
|
||||
}
|
||||
const zeile = stuecke.join('').trim()
|
||||
if (zeile) zeilen.push(zeile)
|
||||
}
|
||||
|
||||
return zeilen.join('\n')
|
||||
}
|
||||
|
||||
async function ausPdf(bytes: Uint8Array): Promise<ExtractionResult> {
|
||||
// Die Datei als Latin-1 lesen: So bleibt jedes Byte ein Zeichen, und die
|
||||
// Byte-Offsets der Ströme stimmen mit den Zeichen-Offsets überein.
|
||||
const roh = new TextDecoder('latin1').decode(bytes)
|
||||
const teile: string[] = []
|
||||
|
||||
const streamMuster = /stream\r?\n?/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = streamMuster.exec(roh)) !== null) {
|
||||
const start = m.index + m[0].length
|
||||
const ende = roh.indexOf('endstream', start)
|
||||
if (ende === -1) break
|
||||
streamMuster.lastIndex = ende
|
||||
|
||||
const inhalt = roh.slice(start, ende)
|
||||
// Vor dem Strom steht das Objektwörterbuch — es sagt, ob gepackt wurde.
|
||||
const kopf = roh.slice(Math.max(0, m.index - 400), m.index)
|
||||
|
||||
let text: string
|
||||
if (/FlateDecode/.test(kopf)) {
|
||||
try {
|
||||
const gepackt = Uint8Array.from(inhalt, c => c.charCodeAt(0) & 0xff)
|
||||
text = new TextDecoder('latin1').decode(await inflate(gepackt, 'deflate'))
|
||||
} catch {
|
||||
// Bilder und Schriftdateien sind ebenfalls Flate-Ströme und scheitern
|
||||
// hier reihenweise — das ist kein Fehler, sondern der Normalfall.
|
||||
continue
|
||||
}
|
||||
} else if (/Image|Font|XObject/.test(kopf)) {
|
||||
continue
|
||||
} else {
|
||||
text = inhalt
|
||||
}
|
||||
|
||||
const gefunden = pdfTextAusStrom(text)
|
||||
if (gefunden) teile.push(gefunden)
|
||||
}
|
||||
|
||||
const text = normalisiere(teile.join('\n'))
|
||||
if (text.length < RESEARCH_DOCUMENT_MIN_CHARS) {
|
||||
return {
|
||||
text,
|
||||
note:
|
||||
'Aus diesem PDF liess sich kein zusammenhängender Text gewinnen — vermutlich ein Scan ohne Textebene. '
|
||||
+ 'Das Dokument fliesst deshalb nicht in die Recherche ein.',
|
||||
}
|
||||
}
|
||||
return { text }
|
||||
}
|
||||
|
||||
// ── Einstieg ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function kindOf(fileName: string): ResearchDocumentKind | null {
|
||||
const lower = fileName.toLowerCase()
|
||||
if (lower.endsWith('.pdf')) return ResearchDocumentKind.PDF
|
||||
if (lower.endsWith('.docx')) return ResearchDocumentKind.DOCX
|
||||
return null
|
||||
}
|
||||
|
||||
export async function extractDocumentText(
|
||||
file: File,
|
||||
kind: ResearchDocumentKind,
|
||||
): Promise<ExtractionResult> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
try {
|
||||
const ergebnis = kind === ResearchDocumentKind.PDF
|
||||
? await ausPdf(bytes)
|
||||
: await ausDocx(bytes)
|
||||
|
||||
if (!ergebnis.note && ergebnis.text.length < RESEARCH_DOCUMENT_MIN_CHARS) {
|
||||
return {
|
||||
text: ergebnis.text,
|
||||
note: 'Das Dokument enthält zu wenig Text, um daraus einen Lead abzuleiten.',
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
} catch (err) {
|
||||
return {
|
||||
text: '',
|
||||
note: `Die Datei liess sich nicht lesen: ${err instanceof Error ? err.message : 'unbekannter Fehler'}.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Property On — aus einem Recherche-Lead wird ein Signal im zentralen Bestand
|
||||
* (Runde 10, §2.12).
|
||||
*
|
||||
* Bis Runde 9 standen die frisch gelesenen Leads in einer eigenen Liste neben
|
||||
* den erfassten. Das war zwar ehrlich — live gelesen ist etwas anderes als
|
||||
* redaktionell erfasst —, aber es hatte einen Preis: Nora konnte mit ihnen
|
||||
* nichts anfangen, die Filter griffen nicht, und derselbe Sachverhalt sah je
|
||||
* nach Liste anders aus.
|
||||
*
|
||||
* Seit Runde 10 gibt es deshalb genau einen Leadbestand. Die Herkunft geht
|
||||
* dabei nicht verloren, sie steht am Signal (`origin: 'RESEARCH_RUN'`) —
|
||||
* unterschieden wird über ein Feld, nicht über eine zweite Ablage.
|
||||
*/
|
||||
|
||||
import { RiskLevel, SignalType } from '../domain/enums'
|
||||
import { LeadSignalType } from '../domain/futureSignal'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import { ResearchEventType } from '../domain/researchLead'
|
||||
import type { ResearchLead, ResearchRelevance } from '../domain/researchLead'
|
||||
|
||||
/**
|
||||
* Welcher Signaltyp zu welchem Ereignis gehört.
|
||||
*
|
||||
* Die Recherche kennt neun Ereignisarten, der Bestand sieben Signaltypen —
|
||||
* die Zuordnung ist deshalb nicht eins zu eins und steht hier explizit, statt
|
||||
* irgendwo geraten zu werden.
|
||||
*/
|
||||
const SIGNAL_TYPE_BY_EVENT: Record<ResearchEventType, SignalType> = {
|
||||
[ResearchEventType.EXPANSION]: SignalType.EXPANSION,
|
||||
[ResearchEventType.NEW_LOCATION]: SignalType.EXPANSION,
|
||||
[ResearchEventType.HEADCOUNT_GROWTH]: SignalType.EXPANSION,
|
||||
[ResearchEventType.INVESTMENT]: SignalType.PROJECT_DEVELOPMENT,
|
||||
[ResearchEventType.NEW_COMPANY]: SignalType.EXPANSION,
|
||||
[ResearchEventType.M_AND_A]: SignalType.SPACE_CONSOLIDATION,
|
||||
[ResearchEventType.RESTRUCTURING]: SignalType.RESTRUCTURING,
|
||||
[ResearchEventType.SITE_CLOSURE]: SignalType.POSSIBLE_MOVE_OUT,
|
||||
// Irrelevantes wird nie zum Lead; der Eintrag hält die Abbildung vollständig.
|
||||
[ResearchEventType.IRRELEVANT]: SignalType.EXPANSION,
|
||||
}
|
||||
|
||||
/** Ereignisse, aus denen Flächenbedarf entsteht — alles andere betrifft Bestehendes. */
|
||||
const CHANCE_EVENTS = new Set<ResearchEventType>([
|
||||
ResearchEventType.EXPANSION,
|
||||
ResearchEventType.NEW_LOCATION,
|
||||
ResearchEventType.HEADCOUNT_GROWTH,
|
||||
ResearchEventType.INVESTMENT,
|
||||
ResearchEventType.NEW_COMPANY,
|
||||
])
|
||||
|
||||
/**
|
||||
* Zeithorizont in Monaten aus der Ereigniszeit.
|
||||
*
|
||||
* Bewusst grob und bewusst nicht aus dem Publikationsdatum gerechnet: Der
|
||||
* Horizont ist eine Anzeigegrösse der Liste, die belastbare Aussage steht in
|
||||
* `temporalClass` und `temporalEvidence`. Eine Nachkommastelle würde hier eine
|
||||
* Genauigkeit vortäuschen, die die Quelle nicht hergibt.
|
||||
*/
|
||||
const HORIZON_MONTHS: Record<string, number> = {
|
||||
past: 0,
|
||||
current: 2,
|
||||
future: 9,
|
||||
unknown: 6,
|
||||
}
|
||||
|
||||
const RELEVANCE_SCORE: Record<ResearchRelevance, number> = {
|
||||
high: 85,
|
||||
medium: 65,
|
||||
low: 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine stabile Kennung für denselben Sachverhalt.
|
||||
*
|
||||
* Sie muss über Läufe hinweg gleich bleiben: Wer zweimal auf «Research
|
||||
* aktualisieren» drückt, soll denselben Artikel nicht zweimal in der Liste
|
||||
* finden. Grundlage sind Quelladresse und Überschrift — beides ändert sich
|
||||
* nicht, solange es derselbe Artikel ist.
|
||||
*/
|
||||
export function researchLeadId(lead: ResearchLead): string {
|
||||
const roh = `${lead.sourceUrl}|${lead.headline}`
|
||||
let hash = 0
|
||||
for (let i = 0; i < roh.length; i++) {
|
||||
hash = (hash * 31 + roh.charCodeAt(i)) | 0
|
||||
}
|
||||
return `research-${(hash >>> 0).toString(36)}`
|
||||
}
|
||||
|
||||
export function researchLeadToSignal(lead: ResearchLead, laufZeitpunkt: string): FutureSignal {
|
||||
const istChance = CHANCE_EVENTS.has(lead.eventType)
|
||||
const relevanz = RELEVANCE_SCORE[lead.relevance]
|
||||
|
||||
return {
|
||||
id: researchLeadId(lead),
|
||||
signalType: SIGNAL_TYPE_BY_EVENT[lead.eventType],
|
||||
companyName: lead.company,
|
||||
locationHint: lead.location ?? 'Ort nicht genannt',
|
||||
probability: lead.confidence,
|
||||
confidenceScore: lead.confidence,
|
||||
timeHorizonMonths: HORIZON_MONTHS[lead.temporalClass] ?? 6,
|
||||
|
||||
source: {
|
||||
type: 'PRESS',
|
||||
url: lead.sourceUrl,
|
||||
publishedAt: lead.publishedAt,
|
||||
// Eine namentlich genannte, direkt verlinkte Quelle ist belastbar; die
|
||||
// Unsicherheit der Einordnung steckt in `confidenceScore`, nicht hier.
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'PUBLIC',
|
||||
disclaimer:
|
||||
'Aus einer öffentlich zugänglichen Quelle abgeleitet. Vor jeder Ansprache am Original prüfen.',
|
||||
riskLevel: istChance ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
relevanceScore: relevanz,
|
||||
isVerified: false,
|
||||
createdAt: laufZeitpunkt,
|
||||
updatedAt: laufZeitpunkt,
|
||||
|
||||
title: lead.headline,
|
||||
aiSummary: lead.summary,
|
||||
evidence: {
|
||||
summary: lead.summary,
|
||||
sourceUrls: [lead.sourceUrl],
|
||||
extractedAt: laufZeitpunkt,
|
||||
},
|
||||
|
||||
// Chance-Leads zeigen in die Nachfragerichtung — nur so nimmt die
|
||||
// gemeinsame Leadliste sie auf. Risiken kommen über `leadSignalType` hinein.
|
||||
signalDirection: istChance ? 'DEMAND' : 'SUPPLY',
|
||||
leadSignalType: istChance ? LeadSignalType.CHANCE : LeadSignalType.RISIKO,
|
||||
signalRationale: lead.reason,
|
||||
recommendedAction: lead.recommendedAction,
|
||||
|
||||
temporalClass: lead.temporalClass,
|
||||
temporalEvidence: lead.temporalEvidence || undefined,
|
||||
eventDate: lead.eventDate,
|
||||
semanticSearchProfile: lead.semanticSearchProfile,
|
||||
origin: 'RESEARCH_RUN',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Property On — der harte Zeitfilter (Runde 10, §2.9).
|
||||
*
|
||||
* Die Regel steht hier und nicht nur in der serverlosen Funktion, weil sie an
|
||||
* zwei Stellen gilt: Der Lauf wendet sie an, damit unpassende Leads gar nicht
|
||||
* erst entstehen, und die Anzeige wendet sie auf den bereits erfassten Bestand
|
||||
* an. Zwei Fassungen derselben Regel wären zwei unterschiedliche Antworten auf
|
||||
* dieselbe Frage.
|
||||
*
|
||||
* Zur Deutung von `unknown`: Ein Lead ohne belastbare zeitliche Aussage fällt
|
||||
* **nicht** durch. Das ist eine bewusste Entscheidung — die Alternative wäre,
|
||||
* ihn anhand des Publikationsdatums einzusortieren, und genau das ist der
|
||||
* Fehler, den dieser Filter verhindern soll. Sichtbar bleibt er mit dem
|
||||
* Vermerk, dass seine Ereigniszeit offen ist.
|
||||
*/
|
||||
|
||||
import type { TemporalClass } from '../domain/researchLead'
|
||||
|
||||
export function passtZeitlich(
|
||||
klasse: TemporalClass | undefined,
|
||||
erlaubt: TemporalClass[],
|
||||
): boolean {
|
||||
// Keine Auswahl heisst keine Einschränkung — nicht «nichts anzeigen».
|
||||
if (erlaubt.length === 0) return true
|
||||
// Ein Bestandssignal ohne Einordnung verhält sich wie «unknown».
|
||||
if (klasse === undefined || klasse === 'unknown') return true
|
||||
return erlaubt.includes(klasse)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AgentSettingGroup,
|
||||
AgentAvatarTone,
|
||||
} from '../../domain/teamAgent'
|
||||
import { RESEARCH_SOURCES } from '../../lib/researchSources'
|
||||
|
||||
export const liviaAgent: TeamAgent = {
|
||||
id: 'livia',
|
||||
@@ -90,36 +91,6 @@ export const liviaAgent: TeamAgent = {
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-02',
|
||||
title: 'Geschäfts- und Jahresberichte auswerten',
|
||||
description:
|
||||
'Liest publizierte Geschäfts- und Jahresberichte auf Aussagen zu Standorten, Kapazitäten, Investitionen und Personalbestand. Zahlen werden mit Berichtsjahr und Fundstelle übernommen, nicht als Trend interpretiert.',
|
||||
enabled: true,
|
||||
schedule: 'wöchentlich, montags 06:30',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-03',
|
||||
title: 'Handelsregister- und Unternehmensinformationen verfolgen',
|
||||
description:
|
||||
'Verfolgt Mutationen im Handelsregister: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen. Eine Sitzverlegung ist der belastbarste Hinweis auf einen Standortwechsel, den es öffentlich gibt.',
|
||||
enabled: true,
|
||||
schedule: 'täglich 06:15',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.ZEFIX,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-04',
|
||||
title: 'Amtliche Publikationen sichten',
|
||||
description:
|
||||
'Sichtet amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen und prüft, ob ein Unternehmen aus dem Beobachtungsbereich betroffen ist.',
|
||||
enabled: true,
|
||||
schedule: 'täglich 06:20',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.SHAB,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-05',
|
||||
title: 'Wachstumssignale erkennen',
|
||||
@@ -129,15 +100,6 @@ export const liviaAgent: TeamAgent = {
|
||||
schedule: 'im Anschluss an jeden Quellenlauf',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-06',
|
||||
title: 'Negative Entwicklungen erkennen',
|
||||
description:
|
||||
'Erkennt M&A-Aktivitäten, Restrukturierungen, Standortschliessungen und die Einstellung von Geschäftstätigkeiten. Diese Signale sind die wertvollsten, weil sie bestehende Mietverhältnisse betreffen und Vorlauf verschaffen.',
|
||||
enabled: true,
|
||||
schedule: 'im Anschluss an jeden Quellenlauf',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-07',
|
||||
title: 'Lead als Chance oder Risiko einordnen',
|
||||
@@ -184,6 +146,16 @@ export const liviaAgent: TeamAgent = {
|
||||
requiresApproval: false,
|
||||
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-14',
|
||||
title: 'Unsichere Signale in Review Queue legen',
|
||||
description:
|
||||
'Liegt die Konfidenz unter der eingestellten Schwelle oder widersprechen sich die Quellen, legt Livia den Lead in die Review-Queue der Marktbeobachtung. Solche Leads erscheinen nicht in der Lead-Liste, bevor jemand sie geprüft hat. Die Aufgabe gehörte bis Runde 10 zu Nora — sie beurteilt die Quellenlage, und die kennt Livia, nicht das Matching.',
|
||||
enabled: true,
|
||||
schedule: 'sobald die Konfidenz unter der Review-Schwelle liegt',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-12',
|
||||
title: 'Doppelmeldungen unterdrücken',
|
||||
@@ -193,16 +165,6 @@ export const liviaAgent: TeamAgent = {
|
||||
schedule: 'vor jeder Übergabe',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-13',
|
||||
title: 'Wochenüberblick zustellen',
|
||||
description:
|
||||
'Stellt der Bewirtschaftung montags einen Überblick der Leads der Vorwoche zu, getrennt nach Chance und Risiko, mit den jeweils empfohlenen Handlungen.',
|
||||
enabled: true,
|
||||
schedule: 'montags 07:00',
|
||||
requiresApproval: false,
|
||||
dependsOnChannel: AgentChannelType.EMAIL,
|
||||
},
|
||||
],
|
||||
|
||||
channels: [
|
||||
@@ -239,13 +201,33 @@ export const liviaAgent: TeamAgent = {
|
||||
],
|
||||
|
||||
systems: [
|
||||
/*
|
||||
* Die drei realen Quellen stehen seit Runde 10 (§2.3) einzeln hier statt
|
||||
* gesammelt hinter einem Eintrag «Öffentliche Webquellen». Der Grund ist
|
||||
* nicht Kosmetik: Der Recherchelauf liest genau diese Liste. Was hier als
|
||||
* aktiver Lesezugang mit Adresse steht, wird abgerufen — und nur das.
|
||||
* Ein Sammeleintrag ohne eigene Adresse wäre eine Statusanzeige ohne
|
||||
* Deckung im Datenmodell.
|
||||
*/
|
||||
{
|
||||
id: 'livia-sys-public-web',
|
||||
id: 'livia-sys-zhk',
|
||||
type: AgentSystemType.PUBLIC_WEB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Verknüpfte Newsseiten und Wirtschaftsnachrichten, Geschäfts- und Jahresberichte, Mitteilungen von Handelskammern und Branchenverbänden sowie Unternehmensmeldungen. Die konkreten Adressen stehen in der Einstellung «Verknüpfte Newsquellen».',
|
||||
title: RESEARCH_SOURCES.ZHK.name,
|
||||
url: RESEARCH_SOURCES.ZHK.url,
|
||||
usage: RESEARCH_SOURCES.ZHK.purpose,
|
||||
permissionNote:
|
||||
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
|
||||
},
|
||||
{
|
||||
id: 'livia-sys-gza',
|
||||
type: AgentSystemType.PUBLIC_WEB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
title: RESEARCH_SOURCES.GZA.name,
|
||||
url: RESEARCH_SOURCES.GZA.url,
|
||||
usage: RESEARCH_SOURCES.GZA.purpose,
|
||||
permissionNote:
|
||||
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
|
||||
},
|
||||
@@ -254,19 +236,11 @@ export const liviaAgent: TeamAgent = {
|
||||
type: AgentSystemType.ZEFIX,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Handelsregister- und Unternehmensinformationen: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen.',
|
||||
title: RESEARCH_SOURCES.ZEFIX.name,
|
||||
url: RESEARCH_SOURCES.ZEFIX.url,
|
||||
usage: RESEARCH_SOURCES.ZEFIX.purpose,
|
||||
permissionNote: 'Nur lesend. Einträge werden als Quelle mit Publikationsdatum übernommen.',
|
||||
},
|
||||
{
|
||||
id: 'livia-sys-shab',
|
||||
type: AgentSystemType.SHAB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen, Arealentwicklungen und Firmenmutationen.',
|
||||
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
|
||||
},
|
||||
{
|
||||
id: 'livia-sys-immotop2',
|
||||
type: AgentSystemType.IMMOTOP2,
|
||||
@@ -301,35 +275,21 @@ export const liviaAgent: TeamAgent = {
|
||||
|
||||
settings: [
|
||||
{
|
||||
id: 'livia-set-news-sources',
|
||||
label: 'Verknüpfte Newsquellen',
|
||||
id: 'livia-set-news-documents',
|
||||
label: 'Newsquellen manuell ablegen',
|
||||
description:
|
||||
'Newsseiten und Portale, die Livia täglich abgeht. Weitere Adressen lassen sich hier ergänzen — es braucht keine zweite Quellenverwaltung.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
'Geschäftsberichte, Rundschreiben und andere Unterlagen als PDF oder Word. Ihr Text geht zusammen mit den aktiven Lesequellen aus «Systeme» in denselben Recherchelauf. Die Datei bleibt im Browser — an die Beurteilung geht nur ihr Text.',
|
||||
kind: AgentSettingKind.DOCUMENTS,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: [
|
||||
'https://www.handelszeitung.ch',
|
||||
'https://www.nzz.ch/wirtschaft',
|
||||
'https://www.finanzundwirtschaft.ch',
|
||||
'https://www.shab.ch',
|
||||
],
|
||||
options: [
|
||||
{ value: 'https://www.handelszeitung.ch', label: 'Handelszeitung' },
|
||||
{ value: 'https://www.nzz.ch/wirtschaft', label: 'NZZ Wirtschaft' },
|
||||
{ value: 'https://www.finanzundwirtschaft.ch', label: 'Finanz und Wirtschaft' },
|
||||
{ value: 'https://www.shab.ch', label: 'SHAB — Amtliche Publikationen' },
|
||||
{ value: 'https://www.zefix.ch', label: 'Zefix — Handelsregister' },
|
||||
{ value: 'https://www.cash.ch/news', label: 'cash — Unternehmensnachrichten' },
|
||||
{ value: 'https://www.zhk.ch', label: 'Zürcher Handelskammer' },
|
||||
{ value: 'https://www.hkbb.ch', label: 'Handelskammer beider Basel' },
|
||||
{ value: 'https://www.moneyhouse.ch', label: 'Moneyhouse — Unternehmensdaten' },
|
||||
],
|
||||
value: [],
|
||||
},
|
||||
{
|
||||
id: 'livia-set-source-types',
|
||||
label: 'Relevante Quellentypen',
|
||||
description: 'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
description:
|
||||
'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht. Eigene Typen lassen sich frei erfassen.',
|
||||
kind: AgentSettingKind.CHIPS,
|
||||
allowCustom: true,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: [
|
||||
'geschaeftsberichte',
|
||||
@@ -393,8 +353,10 @@ export const liviaAgent: TeamAgent = {
|
||||
{
|
||||
id: 'livia-set-observation-area',
|
||||
label: 'Beobachtungsraum',
|
||||
description: 'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
description:
|
||||
'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft. Eigene Räume lassen sich frei erfassen.',
|
||||
kind: AgentSettingKind.CHIPS,
|
||||
allowCustom: true,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: ['zuerich', 'basel', 'zentralschweiz', 'ostschweiz', 'bern'],
|
||||
options: [
|
||||
@@ -407,6 +369,36 @@ export const liviaAgent: TeamAgent = {
|
||||
{ value: 'tessin', label: 'Tessin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/*
|
||||
* Der harte Zeitfilter (Runde 10, §2.9).
|
||||
*
|
||||
* Er steht bewusst zwischen Beobachtungsraum und Mindestrelevanz: Beide
|
||||
* schränken ein, aber unterschiedlich. Der Raum sagt «wo», dieser Filter
|
||||
* «wann» — und die Relevanz danach «wie stark». Alle drei aktiv zu
|
||||
* lassen ist die Vorgabe, damit ein bestehender Demo-Stand nicht
|
||||
* unbemerkt Ergebnisse verliert.
|
||||
*/
|
||||
id: 'livia-set-temporal-relevance',
|
||||
label: 'Zeitliche Relevanz',
|
||||
description:
|
||||
'Welche Ereigniszeiten ein Lead haben darf. Massgeblich ist, wann die beschriebene Veränderung stattfindet — nicht, wann der Artikel erschienen ist.',
|
||||
kind: AgentSettingKind.CHIPS,
|
||||
allowCustom: false,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: ['past', 'current', 'future'],
|
||||
options: [
|
||||
{ value: 'past', label: 'Vergangene Leads' },
|
||||
{ value: 'current', label: 'Aktuelle Leads' },
|
||||
{ value: 'future', label: 'Zukünftige Leads' },
|
||||
],
|
||||
infoText:
|
||||
'Vergangen: Die Veränderung ist abgeschlossen — das Unternehmen ist umgezogen, der Standort eröffnet oder geschlossen. '
|
||||
+ 'Aktuell: Sie läuft gerade oder steht innerhalb von acht Wochen bevor. '
|
||||
+ 'Zukünftig: Sie liegt weiter voraus oder wurde ohne kurzfristige Umsetzung angekündigt. '
|
||||
+ 'Massgeblich ist immer die Ereigniszeit, nie das Publikationsdatum: Ein Jahresbericht vom Januar kann einen Standort für das dritte Quartal ankündigen — das ist ein zukünftiger Lead. '
|
||||
+ 'Lässt eine Quelle keine belastbare zeitliche Aussage zu, wird kein Datum erfunden; der Lead erscheint mit dem Vermerk, dass die Ereigniszeit offen ist.',
|
||||
},
|
||||
{
|
||||
id: 'livia-set-min-relevance',
|
||||
label: 'Mindestrelevanz für einen Lead',
|
||||
@@ -418,6 +410,13 @@ export const liviaAgent: TeamAgent = {
|
||||
unit: '%',
|
||||
min: 0,
|
||||
max: 100,
|
||||
infoText:
|
||||
'Die KI bewertet, wie stark der Inhalt auf eine für Property Match relevante räumliche oder geschäftliche Veränderung hinweist. '
|
||||
+ 'Berücksichtigt werden insbesondere die Eindeutigkeit des Signals, der Bezug zu Standort beziehungsweise Flächenbedarf, '
|
||||
+ 'die Passung zum definierten Beobachtungsraum und den Quellentypen sowie die Qualität und Konkretheit der verfügbaren Informationen. '
|
||||
+ 'Die zeitliche Relevanz wird separat als harter Filter angewendet und verändert den Relevanzwert nicht. '
|
||||
+ 'Im aktuellen Stand vergibt die Beurteilung drei Stufen — hoch, mittel und tief. Leads der tiefen Stufe erscheinen nicht in der Liste, '
|
||||
+ 'sondern zählen als Beobachtungsposten. Eine Gewichtung einzelner Kriterien in Prozent gibt es nicht.',
|
||||
},
|
||||
{
|
||||
id: 'livia-set-tonality',
|
||||
|
||||
@@ -101,16 +101,6 @@ export const noraAgent: TeamAgent = {
|
||||
schedule: 'bei jeder Klassifizierung',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-04',
|
||||
title: 'unsichere Signale in eine Review-Queue legen',
|
||||
description:
|
||||
'Liegt die Konfidenz unter der eingestellten Review-Schwelle oder widersprechen sich die Quellen, legt Nora das Signal in die Review-Queue der Marktbeobachtung. Solche Signale erscheinen weder in der Wochen-Mail noch in der Lead-Liste, bevor jemand sie geprüft hat.',
|
||||
enabled: true,
|
||||
schedule: 'sobald die Konfidenz unter der Review-Schwelle liegt',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-05',
|
||||
title: 'Signale Regionen und Objekten zuordnen',
|
||||
|
||||
@@ -17,4 +17,9 @@ export interface IFutureSignalProvider {
|
||||
getByProperty(propertyId: string): Promise<FutureSignal[]>
|
||||
verify(id: string, verifiedBy: string): Promise<FutureSignal>
|
||||
updateReviewStatus(id: string, status: ReviewStatus): Promise<FutureSignal>
|
||||
/**
|
||||
* Leads eines Recherchelaufs in den Bestand aufnehmen (Runde 10, §2.12).
|
||||
* Gibt zurück, wie viele erzeugte Leads danach insgesamt geführt werden.
|
||||
*/
|
||||
addResearchSignals(signals: FutureSignal[]): Promise<number>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
|
||||
/**
|
||||
* Ablage der manuell hinterlegten Newsquellen (Runde 10, §2.6).
|
||||
*
|
||||
* Eine eigene Schnittstelle, obwohl die übrigen Demo-Bestände im Local Storage
|
||||
* liegen: Dokumente sind um Grössenordnungen grösser als eine Konfiguration,
|
||||
* und der Local Storage ist bei rund fünf Megabyte zu Ende. Eine PDF-Ablage
|
||||
* dort hinein zu zwingen hiesse, beim dritten Dokument stillschweigend nichts
|
||||
* mehr zu speichern.
|
||||
*/
|
||||
export interface IResearchDocumentProvider {
|
||||
getAll(): Promise<ResearchDocument[]>
|
||||
add(doc: ResearchDocument, blob: Blob): Promise<void>
|
||||
remove(id: string): Promise<void>
|
||||
/** Die Originaldatei — für das Herunterladen aus der Liste. */
|
||||
getBlob(id: string): Promise<Blob | null>
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Property On — die abgelegten Dokumente liegen in IndexedDB (Runde 10, §2.6).
|
||||
*
|
||||
* Keine Mockup-Variante und kein zweiter Backend-Dienst: Die Anwendung läuft
|
||||
* im Browser, und der Browser hat eine Ablage, die auch Dateien verträgt. Ein
|
||||
* Server nur für die Dateihaltung wäre Infrastruktur für einen einzigen Zweck.
|
||||
*
|
||||
* Aufgeteilt in zwei Speicher: die Metadaten samt extrahiertem Text in `meta`,
|
||||
* die Originaldatei in `blobs`. So kann die Liste aufgebaut werden, ohne dass
|
||||
* zwanzig Megabyte PDF durch den Speicher wandern — gebraucht wird die Datei
|
||||
* erst, wenn jemand sie herunterlädt.
|
||||
*/
|
||||
|
||||
import type { IResearchDocumentProvider } from './IResearchDocumentProvider'
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
|
||||
const DB_NAME = 'property-on'
|
||||
const DB_VERSION = 1
|
||||
const STORE_META = 'research-document-meta'
|
||||
const STORE_BLOBS = 'research-document-blobs'
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_META)) db.createObjectStore(STORE_META, { keyPath: 'id' })
|
||||
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB liess sich nicht öffnen.'))
|
||||
})
|
||||
}
|
||||
|
||||
/** Eine Transaktion als Versprechen — IndexedDB spricht sonst nur in Ereignissen. */
|
||||
function alsVersprechen<T>(
|
||||
req: IDBRequest<T>,
|
||||
tx: IDBTransaction,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('Zugriff auf die Dokumentenablage fehlgeschlagen.'))
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Die Dokumentenablage brach die Transaktion ab.'))
|
||||
})
|
||||
}
|
||||
|
||||
export const IndexedDbResearchDocumentProvider: IResearchDocumentProvider = {
|
||||
async getAll() {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction(STORE_META, 'readonly')
|
||||
const alle = await alsVersprechen(tx.objectStore(STORE_META).getAll() as IDBRequest<ResearchDocument[]>, tx)
|
||||
db.close()
|
||||
// Neuestes zuerst — wer eben etwas abgelegt hat, sucht es oben.
|
||||
return alle.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||
},
|
||||
|
||||
async add(doc, blob) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
|
||||
tx.objectStore(STORE_META).put(doc)
|
||||
await alsVersprechen(tx.objectStore(STORE_BLOBS).put(blob, doc.id), tx)
|
||||
db.close()
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
|
||||
tx.objectStore(STORE_META).delete(id)
|
||||
await alsVersprechen(tx.objectStore(STORE_BLOBS).delete(id), tx)
|
||||
db.close()
|
||||
},
|
||||
|
||||
async getBlob(id) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction(STORE_BLOBS, 'readonly')
|
||||
const blob = await alsVersprechen(tx.objectStore(STORE_BLOBS).get(id) as IDBRequest<Blob | undefined>, tx)
|
||||
db.close()
|
||||
return blob ?? null
|
||||
},
|
||||
}
|
||||
@@ -2,14 +2,27 @@ import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignal
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import { mockFutureSignals } from '../mock-data/futureSignals'
|
||||
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
|
||||
|
||||
// Mutation overrides — applied on top of the live mockFutureSignals import.
|
||||
// Reading directly from mockFutureSignals (not a copied store) ensures that
|
||||
// Vite HMR changes to the mock data are always reflected immediately.
|
||||
const mutationOverrides = new Map<string, Partial<FutureSignal>>()
|
||||
|
||||
/**
|
||||
* Leads aus Rechercheläufen (Runde 10, §2.12).
|
||||
*
|
||||
* Sie liegen in derselben Ablage wie der Seed und werden von `resolve()` in
|
||||
* dieselbe Liste gegeben — es gibt bewusst keinen zweiten Bestand, den Nora
|
||||
* separat abfragen müsste. Getrennt ist nur die Persistenz: der Seed steht im
|
||||
* Code, die erzeugten Leads im Local Storage, damit ein Reload sie behält.
|
||||
*/
|
||||
let researchSignals: FutureSignal[] =
|
||||
loadVersioned<FutureSignal[]>(TEAM_STORAGE_KEYS.RESEARCH_LEADS) ?? []
|
||||
|
||||
function resolve(): FutureSignal[] {
|
||||
return mockFutureSignals.map(s => {
|
||||
// Erzeugte Leads zuerst: der jüngste Lauf steht oben, wo man ihn sucht.
|
||||
return [...researchSignals, ...mockFutureSignals].map(s => {
|
||||
const override = mutationOverrides.get(s.id)
|
||||
return override ? { ...s, ...override } : s
|
||||
})
|
||||
@@ -43,4 +56,20 @@ export const MockupFutureSignalProvider: IFutureSignalProvider = {
|
||||
mutationOverrides.set(id, { ...existing, reviewStatus: status, updatedAt: new Date().toISOString() })
|
||||
return resolve().find(s => s.id === id)!
|
||||
},
|
||||
|
||||
/**
|
||||
* Leads eines Recherchelaufs aufnehmen.
|
||||
*
|
||||
* Gleiche Kennung heisst gleicher Sachverhalt: Ein zweiter Lauf über
|
||||
* denselben Artikel ersetzt den bestehenden Eintrag, statt ihn zu
|
||||
* verdoppeln. Sonst wüchse die Liste bei jedem Knopfdruck um Dubletten, und
|
||||
* niemand könnte mehr sagen, welcher Stand gilt.
|
||||
*/
|
||||
async addResearchSignals(signals: FutureSignal[]) {
|
||||
const bekannt = new Map(researchSignals.map(s => [s.id, s]))
|
||||
for (const s of signals) bekannt.set(s.id, s)
|
||||
researchSignals = [...bekannt.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
persistVersioned(TEAM_STORAGE_KEYS.RESEARCH_LEADS, researchSignals)
|
||||
return researchSignals.length
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export const TEAM_STORAGE_KEYS = {
|
||||
WORK_ITEMS: `${PREFIX}work-items`,
|
||||
PROTOCOL: `${PREFIX}protocol`,
|
||||
CONNECTIONS: `${PREFIX}connections`,
|
||||
/** Von einem Recherchelauf erzeugte Leads (Runde 10, §2.12). */
|
||||
RESEARCH_LEADS: `${PREFIX}research-leads`,
|
||||
/** Manuell abgelegte Newsquellen als Dokumente (Runde 10, §2.6). */
|
||||
RESEARCH_DOCUMENTS: `${PREFIX}research-documents`,
|
||||
} as const
|
||||
|
||||
export type TeamStorageKey = typeof TEAM_STORAGE_KEYS[keyof typeof TEAM_STORAGE_KEYS]
|
||||
@@ -51,7 +55,7 @@ export function persist<T>(key: TeamStorageKey, value: T): void {
|
||||
* unter `src/domain/` ändert — sonst überlagert ein alter Local-Storage-Eintrag
|
||||
* die neuen Mockdaten und die Demo zeigt stillschweigend veraltete Inhalte.
|
||||
*/
|
||||
export const TEAM_SCHEMA_VERSION = 1
|
||||
export const TEAM_SCHEMA_VERSION = 2
|
||||
|
||||
interface PersistedEnvelope<T> {
|
||||
version: number
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { ReviewStatus } from '../domain/enums'
|
||||
import type { FutureSignalSummary } from '../domain/dashboard'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
import { researchLeadToSignal } from '../lib/researchLeadMapper'
|
||||
import type { ResearchLead } from '../domain/researchLead'
|
||||
|
||||
const provider = MockupFutureSignalProvider
|
||||
|
||||
@@ -50,6 +52,28 @@ export const futureSignalService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Die Leads eines Recherchelaufs in den zentralen Bestand übernehmen
|
||||
* (Runde 10, §2.12).
|
||||
*
|
||||
* Die Umwandlung von Recherche-Lead zu Signal passiert bewusst hier und
|
||||
* nicht in der Komponente: Sie ist fachliche Logik, und sie muss für jeden
|
||||
* künftigen Auslöser eines Laufs — Knopfdruck, Zeitplan, Chat — dieselbe
|
||||
* sein.
|
||||
*/
|
||||
async importResearchLeads(
|
||||
leads: ResearchLead[],
|
||||
refreshedAt: string,
|
||||
): Promise<ItemResponse<{ imported: number; total: number }>> {
|
||||
try {
|
||||
const signals = leads.map(l => researchLeadToSignal(l, refreshedAt))
|
||||
const total = await provider.addResearchSignals(signals)
|
||||
return { data: { imported: signals.length, total } }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
||||
try {
|
||||
const data = await provider.getByProperty(propertyId)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Property On — manuell abgelegte Newsquellen (Runde 10, §2.6).
|
||||
*
|
||||
* Der Dienst nimmt die Datei entgegen, gewinnt einmal ihren Text und legt
|
||||
* beides ab. Die Textextraktion gehört hierher und nicht in die Komponente:
|
||||
* Sie ist fachliche Arbeit, und sie soll für jeden künftigen Weg ins System —
|
||||
* Upload, Mailanhang, Ablageordner — dieselbe sein.
|
||||
*/
|
||||
|
||||
import { IndexedDbResearchDocumentProvider } from '../provider/IndexedDbResearchDocumentProvider'
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
import { RESEARCH_DOCUMENT_MAX_BYTES } from '../domain/researchDocument'
|
||||
import { extractDocumentText, kindOf } from '../lib/documentText'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
|
||||
const provider = IndexedDbResearchDocumentProvider
|
||||
|
||||
export const researchDocumentService = {
|
||||
async getAll(): Promise<ListResponse<ResearchDocument>> {
|
||||
try {
|
||||
const data = await provider.getAll()
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async upload(file: File): Promise<ItemResponse<ResearchDocument>> {
|
||||
const kind = kindOf(file.name)
|
||||
if (!kind) {
|
||||
throw new Error('Nur PDF- und Word-Dateien (.pdf, .docx) können abgelegt werden.')
|
||||
}
|
||||
if (file.size > RESEARCH_DOCUMENT_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Die Datei ist zu gross (${Math.round(file.size / 1024 / 1024)} MB). Höchstens ${RESEARCH_DOCUMENT_MAX_BYTES / 1024 / 1024} MB.`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { text, note } = await extractDocumentText(file, kind)
|
||||
const doc: ResearchDocument = {
|
||||
id: `doc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: file.name,
|
||||
kind,
|
||||
size: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
text,
|
||||
...(note ? { extractionNote: note } : {}),
|
||||
}
|
||||
await provider.add(doc, file)
|
||||
return { data: doc }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
try {
|
||||
await provider.remove(id)
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user