feat(livia): Runde 10 Prompt 2 — eine Leadliste, dynamische Quellen, zeitliche Relevanz

Livia arbeitet nicht mehr neben dem Leadbestand, sondern in ihn hinein.

- Systemzugänge tragen Titel, Beschreibung und Adresse; die Liste zeigt je
  Eintrag nur noch Aktiv/Inaktiv und Lesen/Schreiben — kein Berechtigungsbalken,
  kein Auge-Icon, keine Lese-/Schreibgruppen
- Die Quellenregistry ist dynamisch: Der Lauf liest genau die aktiven
  Lesezugänge mit Adresse aus Livias Personalblatt. Für die drei gepflegten
  Quellen greift weiterhin ihr eigener Leser, für alles andere ein allgemeiner —
  eine Übersichtsseite, bis zu zehn Unterseiten, kein Spider. Freie Adressen
  werden auf http/https geprüft und gegen interne Netze gesperrt
- Manuell abgelegte PDF- und Word-Dokumente fliessen in denselben Lauf. Text
  wird beim Ablegen nativ extrahiert (ZIP + Flate über DecompressionStream,
  ohne neue Abhängigkeit), Datei und Text liegen in IndexedDB
- Neuer harter Filter «Zeitliche Relevanz»: das Modell beurteilt die
  Ereigniszeit der Veränderung, nicht das Publikationsdatum. «unknown» fällt
  bewusst nicht durch — ein erfundenes Datum wäre die schlechtere Antwort
- Quellentypen und Beobachtungsraum sind Chips mit Freitext; jeder Wert lässt
  sich einzeln entfernen, auch die vorgegebenen
- Info-Knopf bei «Zeitliche Relevanz» und «Mindestrelevanz» erklärt, wie der
  Wert entsteht — ohne erfundene Prozentgewichte
- Gefundene Leads gehen in den zentralen Signalbestand statt in eine zweite
  Ergebnisliste; die Herkunft steht am Signal (origin: RESEARCH_RUN)
- Der Lauf erzeugt einen echten Protokolleintrag mit den Zahlen des Laufs
- Lead-Detail: zeitliche Einordnung mit Fundstelle, semantisches Suchprofil bei
  Chancen mit «Matching starten», und bei Risiko ausdrücklich «Kein internes
  Objekt eindeutig zugeordnet» statt einer beliebigen Mock-Immobilie
- Aufgabenliste nach §11 bereinigt, «Unsichere Signale in Review Queue legen»
  von Nora übernommen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-09-12 23:42:59 +02:00
1 parent 1c4b3f7dd8
commit 30daa77937
33 files changed
+2273 -299

No files matched your search

+58 -11
View File
@@ -12,16 +12,23 @@
import Anthropic from '@anthropic-ai/sdk' import Anthropic from '@anthropic-ai/sdk'
import { z } from 'zod' import { z } from 'zod'
import type { ResearchItem } from './researchSources.js' 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 * Bewusst kurz gehalten. Drei Dinge tragen die eigentliche Arbeit: der
* und das Verbot, etwas hinzuzuerfinden. Alles Weitere wäre Ausschmückung, die * Relevanzbegriff, das Verbot, etwas hinzuzuerfinden, und die Trennung von
* das Modell eher zu geschmeidigen als zu belegten Antworten verleitet. * 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. 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. 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: 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. 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. 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. 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.` Deutsch, Schweizer Rechtschreibung (ss statt ß). Keine langen Analysen.`
}
const AnalysisSchema = z.object({ const AnalysisSchema = z.object({
results: z.array(z.object({ results: z.array(z.object({
@@ -55,6 +84,12 @@ const AnalysisSchema = z.object({
confidence: z.number().min(0).max(1), confidence: z.number().min(0).max(1),
reason: z.string().min(1), reason: z.string().min(1),
recommendedAction: z.string().optional(), 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 discarded: number
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */ /** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
watchlist: number 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. */ /** 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({ const response = await client.messages.create({
model: MODEL, model: MODEL,
max_tokens: 8000, max_tokens: 8000,
system: SYSTEM_PROMPT, system: systemPrompt(),
// Einordnen ist bei fünf Einträgen eine Routineaufgabe — die niedrige Stufe // Einordnen ist bei fünf Einträgen eine Routineaufgabe — die niedrige Stufe
// spart Zeit und Kosten. Nur die grossen Modelle kennen den Schalter; bei // spart Zeit und Kosten. Nur die grossen Modelle kennen den Schalter; bei
// den kleineren wird die Anfrage sonst abgewiesen (siehe SUPPORTS_EFFORT). // den kleineren wird die Anfrage sonst abgewiesen (siehe SUPPORTS_EFFORT).
@@ -187,8 +224,12 @@ async function analyzeBatch(
.map(r => ({ item: batch[r.index], r })) .map(r => ({ item: batch[r.index], r }))
} }
export async function analyzeResearchItems(items: ResearchItem[]): Promise<AnalysisOutcome> { export async function analyzeResearchItems(
if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0 } 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 const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) { if (!apiKey) {
@@ -240,6 +281,7 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
const leads: ResearchLead[] = [] const leads: ResearchLead[] = []
let discarded = 0 let discarded = 0
let watchlist = 0 let watchlist = 0
let temporalFiltered = 0
for (const { item, r } of batches.flat()) { for (const { item, r } of batches.flat()) {
if (r.eventType === 'IRRELEVANT') { discarded += 1; continue } 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 // Lead-Karte: eine Branchenzahl nennt kein Unternehmen, das man
// kontaktieren könnte. Sie zählt als Beobachtungsposten (§19). // kontaktieren könnte. Sie zählt als Beobachtungsposten (§19).
if (item.aggregate) { watchlist += 1; continue } if (item.aggregate) { watchlist += 1; continue }
if (!passtZeitlich(r.temporalClass, temporalFilter)) { temporalFiltered += 1; continue }
leads.push({ leads.push({
company: r.company, company: r.company,
@@ -262,6 +305,10 @@ export async function analyzeResearchItems(items: ResearchItem[]): Promise<Analy
source: item.source, source: item.source,
sourceUrl: item.url, sourceUrl: item.url,
publishedAt: item.publishedAt, 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) => leads.sort((a, b) =>
(a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1)) (a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1))
return { leads, discarded, watchlist } return { leads, discarded, watchlist, temporalFiltered }
} }
+102
View File
@@ -336,3 +336,105 @@ export async function fetchZefixResearch(): Promise<ResearchItem[]> {
aggregate: true, 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,
}]
}
+117 -8
View File
@@ -19,10 +19,15 @@ import {
fetchZhkResearch, fetchZhkResearch,
fetchGreaterZurichResearch, fetchGreaterZurichResearch,
fetchZefixResearch, fetchZefixResearch,
fetchGenericSource,
} from '../../_lib/researchSources.js' } from '../../_lib/researchSources.js'
import type { ResearchItem } from '../../_lib/researchSources.js' import type { ResearchItem } from '../../_lib/researchSources.js'
import { MODEL, analyzeResearchItems } from '../../_lib/researchAnalysis.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 { interface Quelle {
id: string id: string
@@ -31,12 +36,97 @@ interface Quelle {
laden: () => Promise<ResearchItem[]> 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.ZHK, laden: fetchZhkResearch },
{ ...SOURCES.GZA, laden: fetchGreaterZurichResearch }, { ...SOURCES.GZA, laden: fetchGreaterZurichResearch },
{ ...SOURCES.ZEFIX, laden: fetchZefixResearch }, { ...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> { export default async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
res.setHeader('Content-Type', 'application/json; charset=utf-8') res.setHeader('Content-Type', 'application/json; charset=utf-8')
// Ein Live-Lauf soll nie aus einem Zwischenspeicher beantwortet werden. // Ein Live-Lauf soll nie aus einem Zwischenspeicher beantwortet werden.
@@ -48,9 +138,13 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
return 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( const ergebnisse = await Promise.all(
QUELLEN.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => { quellen.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => {
try { try {
const items = await q.laden() const items = await q.laden()
return { return {
@@ -71,13 +165,27 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
) )
const sources = ergebnisse.map(e => e.meta) 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 = { const basis: ResearchRefreshResult = {
sources, sources,
analyzed: items.length, analyzed: items.length,
discarded: 0, discarded: 0,
watchlist: 0, watchlist: 0,
temporalFiltered: 0,
leads: [], leads: [],
refreshedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(),
model: MODEL, model: MODEL,
@@ -87,15 +195,16 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)
res.statusCode = 200 res.statusCode = 200
res.end(JSON.stringify({ res.end(JSON.stringify({
...basis, ...basis,
analysisError: 'Keine der drei Quellen lieferte Einträge.', analysisError: 'Keine der angebundenen Quellen lieferte Einträge.',
})) }))
return return
} }
try { try {
const { leads, discarded, watchlist } = await analyzeResearchItems(items) const { leads, discarded, watchlist, temporalFiltered } =
await analyzeResearchItems(items, body.temporalClasses ?? [])
res.statusCode = 200 res.statusCode = 200
res.end(JSON.stringify({ ...basis, leads, discarded, watchlist })) res.end(JSON.stringify({ ...basis, leads, discarded, watchlist, temporalFiltered }))
} catch (err) { } catch (err) {
// Gelesen wurde trotzdem — das sagen wir offen, statt eine leere Liste als // Gelesen wurde trotzdem — das sagen wir offen, statt eine leere Liste als
// «nichts gefunden» auszugeben. // «nichts gefunden» auszugeben.
+103 -10
View File
@@ -1,10 +1,10 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { Alert, Box, Chip, Divider, Typography } from '@mui/material' import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
import { CheckCircle2, ExternalLink, MapPin, Ruler } from 'lucide-react' import { CheckCircle2, ExternalLink, MapPin, Ruler, Target } from 'lucide-react'
import type { MarketLead } from '../../hooks/useMarketLeads' import type { MarketLead } from '../../hooks/useMarketLeads'
import { AgentAvatar, ObjectDeepLink } from '../team' import { AgentAvatar, ObjectDeepLink } from '../team'
import { agentWorkspaceById } from '../../lib/agentWorkspaces' 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 { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers' import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge' import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
@@ -31,6 +31,12 @@ interface LeadDetailProps {
lead: MarketLead lead: MarketLead
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */ /** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
showMatching?: boolean 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 * Ein neues Signal ist damit eine neue Komponente und startet mit frischem
* Zustand, statt ihn in einem Effekt zurücksetzen zu müssen. * Zustand, statt ihn in einem Effekt zurücksetzen zu müssen.
*/ */
function LeadDetail({ lead, showMatching = true }: LeadDetailProps) { function LeadDetail({ lead, showMatching = true, onStartMatching }: LeadDetailProps) {
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} /> 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 { signal, matchingProperties } = lead
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
const companyName = signal.companyName ?? signal.locationHint const companyName = signal.companyName ?? signal.locationHint
@@ -133,19 +150,95 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
</Typography> </Typography>
</Box> </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 }}> <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' }}> <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> </Typography>
{signal.affectedTenant && ( {signal.affectedTenant && (
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}> <Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
{signal.affectedTenant} {signal.affectedTenant}
</Typography> </Typography>
)} )}
{signal.propertyId && ( {signal.propertyId ? (
<ObjectDeepLink propertyId={signal.propertyId} label="Objekt in «Meine Objekte» öffnen" /> <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> </Box>
)} )}
+89 -25
View File
@@ -1,30 +1,45 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useNavigate } from 'react-router'
import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material' import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { LeadDetail, UnifiedLeadList } from '../market-leads' import { LeadDetail, UnifiedLeadList } from '../market-leads'
import { ResearchSourcesPanel } from './ResearchSourcesPanel' import { ResearchSourcesPanel } from './ResearchSourcesPanel'
import { ResearchLeadCard } from './ResearchLeadCard'
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads' import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
import { useLiviaResearch } from '../../hooks/useLiviaResearch' 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 { LeadChannel } from '../../domain/unifiedLead'
import type { UnifiedLead } from '../../domain/unifiedLead' import type { UnifiedLead } from '../../domain/unifiedLead'
import { LeadSignalType } from '../../domain/futureSignal' 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' 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 * Jetzt wandert ein Lauf direkt in den zentralen Signalbestand. Was oben steht,
* Quellen gelesen und beurteilt hat. Diese Leads sind nicht gespeichert; * ist nur noch der Bericht über den Lauf selbst: welche Quellen antworteten,
* sie leben im Zustand dieser Komponente, bis der nächste Lauf sie ersetzt. * wie viel gelesen und wie viel verworfen wurde. Die Leads stehen dort, wo alle
* 2. **Erfasste Leads** — der bestehende Signalbestand, den Nora abgleicht. * Leads stehen.
* *
* Die Trennung ist Absicht: ein live gelesener Artikel ist etwas anderes als * Welche Quellen abgerufen werden, entscheidet nicht diese Komponente, sondern
* ein redaktionell erfasster Lead, und beides in eine Liste zu mischen würde * Livias Personalblatt: jeder aktive Lesezugang mit Adresse, dazu die manuell
* genau diesen Unterschied verwischen. Die Liste unten ist dieselbe Komponente * abgelegten Dokumente. Damit ist die Anzeige unter «Systeme» keine Behauptung,
* wie bei Nora — mit `showMatching={false}`, weil das Matching ihre Arbeit ist. * sondern die Eingabe des Laufs.
*/ */
export function ResearchWorkspace() { export function ResearchWorkspace() {
const theme = useTheme() const theme = useTheme()
@@ -32,6 +47,8 @@ export function ResearchWorkspace() {
const [selected, setSelected] = useState<UnifiedLead | null>(null) const [selected, setSelected] = useState<UnifiedLead | null>(null)
const { data: leads, isLoading } = useUnifiedLeads() const { data: leads, isLoading } = useUnifiedLeads()
const { data: livia } = useTeamAgent('livia')
const { data: dokumente = [] } = useResearchDocuments()
const refresh = useLiviaResearch() const refresh = useLiviaResearch()
const ergebnis = refresh.data const ergebnis = refresh.data
@@ -49,18 +66,53 @@ export function ResearchWorkspace() {
return { research: rows, chancen: c, risiken: r } return { research: rows, chancen: c, risiken: r }
}, [leads]) }, [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 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> <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', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Button <Button
variant="contained" variant="contained"
startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />} startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
disabled={refresh.isPending} disabled={refresh.isPending || keineQuellen}
onClick={starten} onClick={starten}
sx={{ textTransform: 'none', fontWeight: 600 }} sx={{ textTransform: 'none', fontWeight: 600 }}
> >
@@ -73,18 +125,35 @@ export function ResearchWorkspace() {
</Typography> </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 && ( {!refresh.isPending && ergebnis && (
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}> <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `} {`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
{`${ergebnis.discarded} nicht relevant · `} {`${ergebnis.discarded} nicht relevant · `}
{`${ergebnis.watchlist} Watchlist · `} {`${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' })}`} {` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
{ergebnis.model && ` · Modell ${ergebnis.model}`} {ergebnis.model && ` · Modell ${ergebnis.model}`}
</Typography> </Typography>
)} )}
</Box> </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 &amp; Systeme» angebunden, Dokumente unter
«Einstellungen».
</Alert>
)}
<ResearchSourcesPanel results={ergebnis?.sources} /> <ResearchSourcesPanel results={ergebnis?.sources} />
{ergebnis?.analysisError && ( {ergebnis?.analysisError && (
@@ -93,23 +162,17 @@ export function ResearchWorkspace() {
</Alert> </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 && ( {ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && (
<Alert severity="info" sx={{ fontSize: '0.8rem' }}> <Alert severity="info" sx={{ fontSize: '0.8rem' }}>
Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug. Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug.
Gelesen wurden {ergebnis.analyzed} Einträge. Gelesen wurden {ergebnis.analyzed} Einträge.
{ergebnis.temporalFiltered > 0 &&
` ${ergebnis.temporalFiltered} davon fielen durch die eingestellte zeitliche Relevanz.`}
</Alert> </Alert>
)} )}
</Box> </Box>
{/* ── Erfasste Leads ──────────────────────────────────────────────── */} {/* ── Die eine Leadliste ──────────────────────────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1, borderTop: `1px solid ${DS_SLATE[200]}` }}> <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 }}> <Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
{isLoading {isLoading
@@ -124,7 +187,7 @@ export function ResearchWorkspace() {
selectedId={selected?.id ?? null} selectedId={selected?.id ?? null}
onSelect={setSelected} onSelect={setSelected}
showChannel={false} 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> </Box>
@@ -145,6 +208,7 @@ export function ResearchWorkspace() {
<LeadDetail <LeadDetail
lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }} lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }}
showMatching={false} showMatching={false}
onStartMatching={handleStartMatching}
/> />
)} )}
</Drawer> </Drawer>
+123
View File
@@ -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>
)
})
+33 -2
View File
@@ -25,6 +25,27 @@ export interface EntityField {
export type EntityDraft = Record<string, string> 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 { interface Props {
open: boolean open: boolean
/** /**
@@ -85,9 +106,17 @@ function DialogBody({
const fehlend = fields.filter(f => f.required && !(draft[f.key] ?? '').trim()).map(f => f.key) 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() { function handleSave() {
setTouched(true) setTouched(true)
if (fehlend.length > 0) return if (fehlend.length > 0 || Object.keys(formfehler).length > 0) return
onSave(draft, active) onSave(draft, active)
} }
@@ -107,10 +136,12 @@ function DialogBody({
onChange={e => setDraft(prev => ({ ...prev, [f.key]: e.target.value }))} onChange={e => setDraft(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={f.placeholder} placeholder={f.placeholder}
required={f.required} required={f.required}
error={touched && fehlend.includes(f.key)} error={touched && (fehlend.includes(f.key) || f.key in formfehler)}
helperText={ helperText={
touched && fehlend.includes(f.key) touched && fehlend.includes(f.key)
? 'Dieses Feld wird gebraucht.' ? 'Dieses Feld wird gebraucht.'
: touched && formfehler[f.key]
? formfehler[f.key]
: f.helperText : f.helperText
} }
size="small" size="small"
+67 -2
View File
@@ -11,14 +11,16 @@
import { memo, useCallback, useMemo, useState } from 'react' import { memo, useCallback, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
// prettier-ignore // prettier-ignore
import { Box, Button, Chip, FormControlLabel, FormHelperText, InputAdornment, MenuItem, Select, Switch, TextField, Typography } from '@mui/material' import { Box, Button, Chip, FormControlLabel, FormHelperText, IconButton, InputAdornment, MenuItem, Popover, Select, Switch, TextField, Typography } from '@mui/material'
import { AlertTriangle, Lock } from 'lucide-react' import { AlertTriangle, Info, Lock } from 'lucide-react'
import { z } from 'zod' import { z } from 'zod'
import type { AgentSetting, TeamAgent } from '../../domain/teamAgent' import type { AgentSetting, TeamAgent } from '../../domain/teamAgent'
import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent' import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent'
import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants' import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants'
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds' import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
import { useSaveAgentSettings } from '../../hooks/useTeamAgents' import { useSaveAgentSettings } from '../../hooks/useTeamAgents'
import { AgentChipsField } from './AgentChipsField'
import { ResearchDocumentsField } from './ResearchDocumentsField'
type SettingValue = AgentSetting['value'] type SettingValue = AgentSetting['value']
@@ -53,6 +55,14 @@ function buildSettingsSchema(settings: AgentSetting[]) {
shape[setting.id] = field shape[setting.id] = field
} else if (setting.kind === AgentSettingKind.MULTI_SELECT) { } else if (setting.kind === AgentSettingKind.MULTI_SELECT) {
shape[setting.id] = z.array(z.string()).min(1, 'Mindestens ein Eintrag ist erforderlich.') 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) { } else if (setting.kind === AgentSettingKind.BOOLEAN) {
shape[setting.id] = z.boolean() shape[setting.id] = z.boolean()
} else { } else {
@@ -62,6 +72,40 @@ function buildSettingsSchema(settings: AgentSetting[]) {
return z.object(shape) 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 ────────────────────────────────────────────────────────── // ── Eine Einstellung ──────────────────────────────────────────────────────────
interface RowProps { interface RowProps {
@@ -126,6 +170,24 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
</> </>
) )
break 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: default:
control = ( control = (
<TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined} <TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined}
@@ -135,7 +197,10 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
return ( return (
<Box sx={{ display: 'grid', gap: 0.5, py: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }}> <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> <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 && ( {setting.description && (
<Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography> <Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography>
)} )}
+58 -134
View File
@@ -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 * Zeigt und pflegt die Fachsysteme und Datenquellen eines digitalen
* Mitarbeiters. Seit Runde 10 ist der Reiter nicht mehr nur Anzeige: Zugänge * 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 * Bewusst eine einzige Liste statt getrennter Gruppen «lesend» und
* «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser * «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser
* Mitarbeiter», nicht «wie viele davon sind lesend». Der Zugriffstyp steht * Mitarbeiter», nicht «wie viele davon sind lesend». Jede Zeile trägt rechts
* weiterhin an jedem Eintrag — als Badge, als Icon und als Klartextsatz, denn * oben genau zwei Angaben — ob sie genutzt wird und ob gelesen oder geschrieben
* Farbe allein ist kein zulässiger Statusträger (§2.3, §18). * wird. Beide sind Text, nicht nur Farbe (§2.3, §18).
*/ */
import { memo, useCallback, useState } from 'react' import { memo, useCallback, useState } from 'react'
import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material' import { Box, Button, IconButton, Link, Switch, Tooltip, Typography } from '@mui/material'
import { Eye, KeyRound, PenLine, Pencil, Plus, ShieldCheck, Trash2 } from 'lucide-react' import { ExternalLink, Pencil, Plus, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { import {
AgentAccessLevel, AgentAccessLevel,
AgentConnectionStatus, AgentConnectionStatus,
AgentSystemType, AgentSystemType,
} from '../../domain/teamAgent' } from '../../domain/teamAgent'
import type { AgentSystem, TeamAgent } from '../../domain/teamAgent' import type { AgentSystem, TeamAgent } from '../../domain/teamAgent'
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
import { GenericBadge } from '../shared/GenericBadge' import { GenericBadge } from '../shared/GenericBadge'
import { AgentEntityDialog } from './AgentEntityDialog' import { AgentEntityDialog } from './AgentEntityDialog'
import type { EntityDraft, EntityField } from './AgentEntityDialog' import type { EntityDraft, EntityField } from './AgentEntityDialog'
import { newEntityId, removeById, upsertById } from './agentEntityEditing' import { newEntityId, removeById, upsertById } from './agentEntityEditing'
import { useSaveAgentSystems } from '../../hooks/useTeamAgents' import { useSaveAgentSystems } from '../../hooks/useTeamAgents'
import { import { AGENT_ACCESS_LABELS } from '../../lib/constants'
AGENT_ACCESS_LABELS, import { istAktiv, istSchreibend, systemTitel } from '../../lib/agentSystems'
AGENT_CONNECTION_STATUS_LABELS, import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
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
}
// ── Maske ───────────────────────────────────────────────────────────────────── // ── Maske ─────────────────────────────────────────────────────────────────────
const SYSTEM_FIELDS: EntityField[] = [ const SYSTEM_FIELDS: EntityField[] = [
{ key: 'title', label: 'Titel der Quelle', required: true },
{ key: 'description', label: 'Beschreibung der Quelle', type: 'multiline', required: true },
{ {
key: 'type', key: 'url',
label: 'System', label: 'Link / URL',
type: 'select', type: 'url',
required: true, placeholder: 'https://…',
options: Object.values(AgentSystemType).map((t) => ({ helperText: 'Eine aktive Lesequelle mit Adresse wird im Recherchelauf tatsächlich abgerufen.',
value: t,
label: AGENT_SYSTEM_LABELS[t] ?? t,
})),
}, },
{ {
key: 'access', key: 'access',
label: 'Zugriff', label: 'Zugriff',
type: 'select', type: 'select',
required: true, required: true,
options: Object.values(AgentAccessLevel).map((a) => ({ options: [
value: a, { value: AgentAccessLevel.READ, label: AGENT_ACCESS_LABELS[AgentAccessLevel.READ] },
label: AGENT_ACCESS_LABELS[a] ?? a, { value: AgentAccessLevel.WRITE, label: AGENT_ACCESS_LABELS[AgentAccessLevel.WRITE] },
})), ],
helperText: 'Schreibrechte verändern Kundendaten in der Bewirtschaftung.', 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 ────────────────────────────────────────────────────────── // ── Eine Systemzeile ──────────────────────────────────────────────────────────
@@ -139,30 +71,28 @@ interface RowProps {
* und jeder Reiterwechsel sonst die gesamte Liste neu zeichnen würde (§10.2). * 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 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 aktiv = istAktiv(system)
const label = AGENT_SYSTEM_LABELS[system.type] ?? system.type const schreibend = istSchreibend(system)
const titel = systemTitel(system)
return ( return (
<Box <Box
component="li" component="li"
sx={{ sx={{
border: `1px solid ${look.border}`, border: `1px solid ${DS_BORDER.default}`,
borderRadius: 2, borderRadius: 2,
// Ein abgeschalteter Zugang tritt zurück, bleibt aber vollständig lesbar. // 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, opacity: aktiv ? 1 : 0.75,
p: 1.75, p: 1.75,
display: 'grid', 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' }}> <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 }}> <Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
{label} {titel}
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto', alignItems: 'center' }}> <Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto', alignItems: 'center' }}>
<GenericBadge <GenericBadge
@@ -170,8 +100,10 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
semanticVariant={aktiv ? 'confidenceHigh' : 'muted'} semanticVariant={aktiv ? 'confidenceHigh' : 'muted'}
bold bold
/> />
<AgentAccessBadge access={system.access} /> <GenericBadge
<AgentConnectionStatusBadge status={system.status} /> label={schreibend ? 'Schreiben' : 'Lesen'}
semanticVariant={schreibend ? 'confidenceMedium' : 'muted'}
/>
{editing && ( {editing && (
<> <>
<Tooltip title="System bearbeiten"> <Tooltip title="System bearbeiten">
@@ -189,36 +121,22 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
</Box> </Box>
</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 }}> <Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
{system.usage} {system.usage}
</Typography> </Typography>
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */} {/* Die Adresse steht offen da — sie ist genau das, was abgerufen wird. */}
{system.permissionNote && ( {system.url && (
<Box <Link
sx={{ href={system.url}
display: 'flex', target="_blank"
gap: 0.75, rel="noopener noreferrer"
alignItems: 'flex-start', variant="caption"
p: 1, sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.brand, width: 'fit-content' }}
borderRadius: 1.5,
bgcolor: DS_BG.surface,
border: `1px solid ${DS_BORDER.default}`,
}}
> >
<ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} /> {system.url}
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}> <ExternalLink size={11} aria-hidden />
<Box component="span" sx={{ fontWeight: 700 }}> </Link>
Berechtigung:{' '}
</Box>
{system.permissionNote}
</Typography>
</Box>
)} )}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -227,7 +145,7 @@ const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onE
checked={aktiv} checked={aktiv}
disabled={busy} disabled={busy}
onChange={(_, checked) => onToggle(system.id, checked)} 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 }}> <Typography variant="caption" sx={{ color: aktiv ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 600 }}>
{aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'} {aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'}
@@ -273,14 +191,19 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
const handleDialogSave = useCallback( const handleDialogSave = useCallback(
(werte: EntityDraft, active: boolean) => { (werte: EntityDraft, active: boolean) => {
const bestehend = bearbeitet !== 'new' ? systems.find((s) => s.id === bearbeitet) : undefined const bestehend = bearbeitet !== 'new' ? systems.find((s) => s.id === bearbeitet) : undefined
const url = werte.url?.trim() ?? ''
const eintrag: AgentSystem = { 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'), 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, access: werte.access as AgentAccessLevel,
status: werte.status as AgentConnectionStatus,
usage: werte.usage.trim(),
permissionNote: werte.permissionNote?.trim() ?? '',
enabled: active, enabled: active,
...(url ? { url } : {}),
} }
speichern(upsertById(systems, eintrag)) speichern(upsertById(systems, eintrag))
setBearbeitet(null) setBearbeitet(null)
@@ -361,11 +284,12 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
title={inBearbeitung ? 'System bearbeiten' : 'Neues System anbinden'} title={inBearbeitung ? 'System bearbeiten' : 'Neues System anbinden'}
fields={SYSTEM_FIELDS} fields={SYSTEM_FIELDS}
initial={{ initial={{
type: inBearbeitung?.type ?? AgentSystemType.DMS, title: inBearbeitung ? systemTitel(inBearbeitung) : '',
access: inBearbeitung?.access ?? AgentAccessLevel.READ, description: inBearbeitung?.usage ?? '',
status: inBearbeitung?.status ?? AgentConnectionStatus.DISCONNECTED, url: inBearbeitung?.url ?? '',
usage: inBearbeitung?.usage ?? '', access: inBearbeitung && istSchreibend(inBearbeitung)
permissionNote: inBearbeitung?.permissionNote ?? '', ? AgentAccessLevel.WRITE
: AgentAccessLevel.READ,
}} }}
activeLabel="Zugang aktiv" activeLabel="Zugang aktiv"
initialActive={inBearbeitung ? istAktiv(inBearbeitung) : true} 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>
)
}
+33
View File
@@ -1,4 +1,5 @@
import type { SignalType, RiskLevel, ReviewStatus } from './enums' import type { SignalType, RiskLevel, ReviewStatus } from './enums'
import type { TemporalClass } from './researchLead'
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON' export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW' export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
@@ -87,6 +88,38 @@ export interface FutureSignal {
affectedTenant?: string affectedTenant?: string
/** Externe Quellen, aus denen Livia den Lead verdichtet hat. */ /** Externe Quellen, aus denen Livia den Lead verdichtet hat. */
researchSources?: SignalSource[] 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) ──────────────────────────────────────────── // ── Chance / Risiko (Runde 8, §2) ────────────────────────────────────────────
+55
View File
@@ -0,0 +1,55 @@
/**
* Property On — manuell abgelegte Newsquellen (Runde 10, §2.6).
*
* Nicht jede Quelle ist eine Webseite. Ein Geschäftsbericht, ein
* Verbandsrundschreiben, ein Protokoll aus einer Gemeindeversammlung — das
* kommt als PDF oder Word-Datei auf den Tisch und hat keine Adresse, die
* Livia abrufen könnte.
*
* Solche Dokumente sind deshalb kein eigener Datentyp mit eigener Behandlung,
* sondern eine zweite Art, Text in denselben Recherchelauf zu geben. Was am
* Ende beurteilt wird, ist in beiden Fällen Text mit einem Namen und einer
* Herkunft.
*/
export const ResearchDocumentKind = {
PDF: 'PDF',
DOCX: 'DOCX',
} as const
export type ResearchDocumentKind = typeof ResearchDocumentKind[keyof typeof ResearchDocumentKind]
export const RESEARCH_DOCUMENT_LABELS: Record<ResearchDocumentKind, string> = {
PDF: 'PDF',
DOCX: 'Word',
}
/** Was von einem abgelegten Dokument geführt wird. */
export interface ResearchDocument {
id: string
/** Dateiname, wie er beim Ablegen hiess — die einzige Bezeichnung, die der Nutzer wiedererkennt. */
name: string
kind: ResearchDocumentKind
/** Grösse in Bytes; wird angezeigt, damit ein leeres Dokument auffällt. */
size: number
uploadedAt: string
/**
* Der extrahierte Text.
*
* Er wird beim Ablegen einmal gewonnen und danach nie wieder — die
* Extraktion ist der teure Teil, und ihn bei jedem Lauf zu wiederholen
* würde denselben Text ein zweites Mal ergeben.
*/
text: string
/**
* Gesetzt, wenn aus der Datei kein brauchbarer Text zu holen war (etwa ein
* gescanntes PDF ohne Textebene). Das Dokument bleibt in der Liste — mit
* Hinweis, statt stillschweigend nichts beizutragen.
*/
extractionNote?: string
}
/** Obergrenze je Datei. Grössere Dateien sprengen den Speicher des Browsers. */
export const RESEARCH_DOCUMENT_MAX_BYTES = 15 * 1024 * 1024
/** Ab wie vielen Zeichen ein extrahierter Text als brauchbar gilt. */
export const RESEARCH_DOCUMENT_MIN_CHARS = 200
+61
View File
@@ -46,6 +46,24 @@ export interface ResearchLead {
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */ /** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
sourceUrl: string sourceUrl: string
publishedAt?: 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). */ /** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
@@ -68,6 +86,8 @@ export interface ResearchRefreshResult {
discarded: number discarded: number
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */ /** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
watchlist: number watchlist: number
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Fenster lag (§2.9). */
temporalFiltered: number
/** Nur `high` und `medium` erscheinen hier. */ /** Nur `high` und `medium` erscheinen hier. */
leads: ResearchLead[] leads: ResearchLead[]
/** Zeitpunkt des Laufs, ISO. */ /** Zeitpunkt des Laufs, ISO. */
@@ -87,3 +107,44 @@ export interface ResearchRefreshResult {
*/ */
analysisError?: string analysisError?: string
} }
// ── Zeitliche Relevanz (Runde 10, §2.9) ───────────────────────────────────────
/**
* Wann die beschriebene Veränderung stattfindet — **nicht**, wann der Artikel
* erschienen ist.
*
* Das ist der ganze Punkt dieser Klassifikation: Ein Jahresbericht vom Januar
* kann einen Standort für das dritte Quartal ankündigen (`FUTURE`), und eine
* Meldung von heute kann einen Umzug vom Frühling beschreiben (`PAST`). Wer
* nach Publikationsdatum filtert, bekommt in beiden Fällen das Gegenteil
* dessen, was er sucht.
*
* `UNKNOWN` ist ausdrücklich erlaubt. Wenn die Quelle keine belastbare
* zeitliche Aussage enthält, ist «weiss ich nicht» die richtige Antwort —
* ein erfundenes Datum wäre die falsche.
*/
export const TemporalClass = {
PAST: 'past',
CURRENT: 'current',
FUTURE: 'future',
UNKNOWN: 'unknown',
} as const
export type TemporalClass = typeof TemporalClass[keyof typeof TemporalClass]
/** Reihenfolge im Einstellungsbereich — von abgeschlossen nach angekündigt. */
export const TEMPORAL_FILTER_ORDER: TemporalClass[] = [
TemporalClass.PAST,
TemporalClass.CURRENT,
TemporalClass.FUTURE,
]
/**
* Die Grenze zwischen «jetzt» und «später», in Wochen.
*
* Acht Wochen sind keine willkürliche Zahl: Sie entsprechen ungefähr der
* Vorlaufzeit, die eine Bewirtschaftung braucht, um auf einen Flächenbedarf
* überhaupt noch mit einem Angebot zu reagieren. Alles darunter ist ein
* laufender Vorgang, alles darüber eine Planung.
*/
export const TEMPORAL_CURRENT_WEEKS = 8
+40
View File
@@ -120,6 +120,19 @@ export interface AgentSystem {
* die Zugänge ohne Schalter führt — fehlt das Feld, gilt der Zugang als aktiv. * die Zugänge ohne Schalter führt — fehlt das Feld, gilt der Zugang als aktiv.
*/ */
enabled?: boolean 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. */ /** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */
usage: string usage: string
/** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */ /** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */
@@ -149,6 +162,18 @@ export const AgentSettingKind = {
MULTI_SELECT: 'MULTI_SELECT', MULTI_SELECT: 'MULTI_SELECT',
BOOLEAN: 'BOOLEAN', BOOLEAN: 'BOOLEAN',
TIME: 'TIME', 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 } as const
export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind] export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind]
@@ -179,6 +204,21 @@ export interface AgentSetting {
/** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */ /** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */
locked?: boolean locked?: boolean
lockedReason?: string 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 ─────────────────────────────────────────────────────── // ── Kennzahlen & Profil ───────────────────────────────────────────────────────
+66 -7
View File
@@ -1,8 +1,23 @@
import { useMutation } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { ResearchRefreshResult } from '../domain/researchLead' import type { ResearchRefreshResult, TemporalClass } from '../domain/researchLead'
import type { ResearchSourceDescriptor } from '../lib/researchSources'
import { RESEARCH_REFRESH_ENDPOINT } 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' 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. * «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 * `staleTime` und keine Zwischenspeicherung — wer auf den Knopf drückt, will
* den Stand von jetzt und nicht den von vorhin. * den Stand von jetzt und nicht den von vorhin.
* *
* Das Ergebnis lebt im Zustand der Komponente. Für den Machbarkeitsnachweis * Seit Runde 10 endet der Lauf nicht mit der Antwort. Drei Dinge passieren
* ist keine Persistenz nötig (§17). * 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() { export function useLiviaResearch() {
const showToast = useToastStore(s => s.showToast) const showToast = useToastStore(s => s.showToast)
const queryClient = useQueryClient()
return useMutation<ResearchRefreshResult, Error>({ return useMutation<ResearchRefreshResult, Error, ResearchRunInput>({
mutationFn: async () => { mutationFn: async ({ sources, documents, temporalClasses }) => {
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, { method: 'POST' }) 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 // Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall // 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 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) => { onError: (err) => {
showToast(err.message, 'error') showToast(err.message, 'error')
}, },
+50
View File
@@ -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')
},
})
}
+94
View File
@@ -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'])
})
})
+73
View File
@@ -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)
})
})
})
+55
View File
@@ -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
}
+14
View File
@@ -634,3 +634,17 @@ export const FIT_OUT_LABELS: Record<string, string> = {
FULL: 'Ausgebaut · CAT B', FULL: 'Ausgebaut · CAT B',
PREMIUM: 'Vollausgebaut · Plug & Play', 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',
}
+233
View File
@@ -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(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&apos;/g, "'")
.replace(/&amp;/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'}.`,
}
}
}
+140
View File
@@ -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',
}
}
+28
View File
@@ -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)
}
+87 -88
View File
@@ -24,6 +24,7 @@ import {
AgentSettingGroup, AgentSettingGroup,
AgentAvatarTone, AgentAvatarTone,
} from '../../domain/teamAgent' } from '../../domain/teamAgent'
import { RESEARCH_SOURCES } from '../../lib/researchSources'
export const liviaAgent: TeamAgent = { export const liviaAgent: TeamAgent = {
id: 'livia', id: 'livia',
@@ -90,36 +91,6 @@ export const liviaAgent: TeamAgent = {
requiresApproval: false, requiresApproval: false,
dependsOnSystem: AgentSystemType.PUBLIC_WEB, 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', id: 'livia-task-05',
title: 'Wachstumssignale erkennen', title: 'Wachstumssignale erkennen',
@@ -129,15 +100,6 @@ export const liviaAgent: TeamAgent = {
schedule: 'im Anschluss an jeden Quellenlauf', schedule: 'im Anschluss an jeden Quellenlauf',
requiresApproval: false, 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', id: 'livia-task-07',
title: 'Lead als Chance oder Risiko einordnen', title: 'Lead als Chance oder Risiko einordnen',
@@ -184,6 +146,16 @@ export const liviaAgent: TeamAgent = {
requiresApproval: false, requiresApproval: false,
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT, 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', id: 'livia-task-12',
title: 'Doppelmeldungen unterdrücken', title: 'Doppelmeldungen unterdrücken',
@@ -193,16 +165,6 @@ export const liviaAgent: TeamAgent = {
schedule: 'vor jeder Übergabe', schedule: 'vor jeder Übergabe',
requiresApproval: false, 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: [ channels: [
@@ -239,13 +201,33 @@ export const liviaAgent: TeamAgent = {
], ],
systems: [ 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, type: AgentSystemType.PUBLIC_WEB,
access: AgentAccessLevel.READ, access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
usage: title: RESEARCH_SOURCES.ZHK.name,
'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».', 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: permissionNote:
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.', '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, type: AgentSystemType.ZEFIX,
access: AgentAccessLevel.READ, access: AgentAccessLevel.READ,
status: AgentConnectionStatus.CONNECTED, status: AgentConnectionStatus.CONNECTED,
usage: title: RESEARCH_SOURCES.ZEFIX.name,
'Handelsregister- und Unternehmensinformationen: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen.', url: RESEARCH_SOURCES.ZEFIX.url,
usage: RESEARCH_SOURCES.ZEFIX.purpose,
permissionNote: 'Nur lesend. Einträge werden als Quelle mit Publikationsdatum übernommen.', 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', id: 'livia-sys-immotop2',
type: AgentSystemType.IMMOTOP2, type: AgentSystemType.IMMOTOP2,
@@ -301,35 +275,21 @@ export const liviaAgent: TeamAgent = {
settings: [ settings: [
{ {
id: 'livia-set-news-sources', id: 'livia-set-news-documents',
label: 'Verknüpfte Newsquellen', label: 'Newsquellen manuell ablegen',
description: description:
'Newsseiten und Portale, die Livia täglich abgeht. Weitere Adressen lassen sich hier ergänzen — es braucht keine zweite Quellenverwaltung.', '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.MULTI_SELECT, kind: AgentSettingKind.DOCUMENTS,
group: AgentSettingGroup.SPECIFIC, group: AgentSettingGroup.SPECIFIC,
value: [ 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' },
],
}, },
{ {
id: 'livia-set-source-types', id: 'livia-set-source-types',
label: 'Relevante Quellentypen', label: 'Relevante Quellentypen',
description: 'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht.', description:
kind: AgentSettingKind.MULTI_SELECT, 'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht. Eigene Typen lassen sich frei erfassen.',
kind: AgentSettingKind.CHIPS,
allowCustom: true,
group: AgentSettingGroup.SPECIFIC, group: AgentSettingGroup.SPECIFIC,
value: [ value: [
'geschaeftsberichte', 'geschaeftsberichte',
@@ -393,8 +353,10 @@ export const liviaAgent: TeamAgent = {
{ {
id: 'livia-set-observation-area', id: 'livia-set-observation-area',
label: 'Beobachtungsraum', label: 'Beobachtungsraum',
description: 'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft.', description:
kind: AgentSettingKind.MULTI_SELECT, '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, group: AgentSettingGroup.SPECIFIC,
value: ['zuerich', 'basel', 'zentralschweiz', 'ostschweiz', 'bern'], value: ['zuerich', 'basel', 'zentralschweiz', 'ostschweiz', 'bern'],
options: [ options: [
@@ -407,6 +369,36 @@ export const liviaAgent: TeamAgent = {
{ value: 'tessin', label: 'Tessin' }, { 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', id: 'livia-set-min-relevance',
label: 'Mindestrelevanz für einen Lead', label: 'Mindestrelevanz für einen Lead',
@@ -418,6 +410,13 @@ export const liviaAgent: TeamAgent = {
unit: '%', unit: '%',
min: 0, min: 0,
max: 100, 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', id: 'livia-set-tonality',
-10
View File
@@ -101,16 +101,6 @@ export const noraAgent: TeamAgent = {
schedule: 'bei jeder Klassifizierung', schedule: 'bei jeder Klassifizierung',
requiresApproval: false, 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', id: 'nora-task-05',
title: 'Signale Regionen und Objekten zuordnen', title: 'Signale Regionen und Objekten zuordnen',
+5
View File
@@ -17,4 +17,9 @@ export interface IFutureSignalProvider {
getByProperty(propertyId: string): Promise<FutureSignal[]> getByProperty(propertyId: string): Promise<FutureSignal[]>
verify(id: string, verifiedBy: string): Promise<FutureSignal> verify(id: string, verifiedBy: string): Promise<FutureSignal>
updateReviewStatus(id: string, status: ReviewStatus): 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>
} }
+18
View File
@@ -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
},
}
+30 -1
View File
@@ -2,14 +2,27 @@ import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignal
import type { FutureSignal } from '../domain/futureSignal' import type { FutureSignal } from '../domain/futureSignal'
import type { ReviewStatus } from '../domain/enums' import type { ReviewStatus } from '../domain/enums'
import { mockFutureSignals } from '../mock-data/futureSignals' import { mockFutureSignals } from '../mock-data/futureSignals'
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
// Mutation overrides — applied on top of the live mockFutureSignals import. // Mutation overrides — applied on top of the live mockFutureSignals import.
// Reading directly from mockFutureSignals (not a copied store) ensures that // Reading directly from mockFutureSignals (not a copied store) ensures that
// Vite HMR changes to the mock data are always reflected immediately. // Vite HMR changes to the mock data are always reflected immediately.
const mutationOverrides = new Map<string, Partial<FutureSignal>>() 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[] { 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) const override = mutationOverrides.get(s.id)
return override ? { ...s, ...override } : s return override ? { ...s, ...override } : s
}) })
@@ -43,4 +56,20 @@ export const MockupFutureSignalProvider: IFutureSignalProvider = {
mutationOverrides.set(id, { ...existing, reviewStatus: status, updatedAt: new Date().toISOString() }) mutationOverrides.set(id, { ...existing, reviewStatus: status, updatedAt: new Date().toISOString() })
return resolve().find(s => s.id === id)! 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
},
} }
+5 -1
View File
@@ -17,6 +17,10 @@ export const TEAM_STORAGE_KEYS = {
WORK_ITEMS: `${PREFIX}work-items`, WORK_ITEMS: `${PREFIX}work-items`,
PROTOCOL: `${PREFIX}protocol`, PROTOCOL: `${PREFIX}protocol`,
CONNECTIONS: `${PREFIX}connections`, 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 } as const
export type TeamStorageKey = typeof TEAM_STORAGE_KEYS[keyof typeof TEAM_STORAGE_KEYS] 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 * unter `src/domain/` ändert — sonst überlagert ein alter Local-Storage-Eintrag
* die neuen Mockdaten und die Demo zeigt stillschweigend veraltete Inhalte. * 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> { interface PersistedEnvelope<T> {
version: number version: number
+24
View File
@@ -5,6 +5,8 @@ import type { ReviewStatus } from '../domain/enums'
import type { FutureSignalSummary } from '../domain/dashboard' import type { FutureSignalSummary } from '../domain/dashboard'
import type { ListResponse, ItemResponse } from './types' import type { ListResponse, ItemResponse } from './types'
import { throwServiceError } from './errors' import { throwServiceError } from './errors'
import { researchLeadToSignal } from '../lib/researchLeadMapper'
import type { ResearchLead } from '../domain/researchLead'
const provider = MockupFutureSignalProvider 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>> { async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
try { try {
const data = await provider.getByProperty(propertyId) const data = await provider.getByProperty(propertyId)
+65
View File
@@ -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)
}
},
}