Compare commits
No files matched your search
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Livia Research-PoC — die Beurteilung der gelesenen Einträge.
|
||||
*
|
||||
* Ein einziger Aufruf für alle Einträge statt einer Anfrage je Artikel (§22).
|
||||
* Das hält Laufzeit und Kosten klein und gibt dem Modell zugleich den
|
||||
* Gesamtblick, den es für die Einordnung «relevant oder nicht» braucht.
|
||||
*
|
||||
* Der Schlüssel steht ausschliesslich serverseitig in `ANTHROPIC_API_KEY` —
|
||||
* diese Datei läuft in der serverlosen Funktion und landet nie im Bundle (§10).
|
||||
*/
|
||||
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { z } from 'zod'
|
||||
import type { ResearchItem } from './researchSources.js'
|
||||
import type { ResearchLead, TemporalClass } from '../../src/domain/researchLead.js'
|
||||
import { passtZeitlich } from '../../src/lib/temporalFilter.js'
|
||||
|
||||
/**
|
||||
* Der Beurteilungsauftrag (§12, erweitert in Runde 10 §§2.9/2.13).
|
||||
*
|
||||
* Bewusst kurz gehalten. Drei Dinge tragen die eigentliche Arbeit: der
|
||||
* Relevanzbegriff, das Verbot, etwas hinzuzuerfinden, und die Trennung von
|
||||
* Publikationsdatum und Ereigniszeit.
|
||||
*
|
||||
* Eine Funktion und keine Konstante, weil der Auftrag das heutige Datum
|
||||
* enthält: «in drei Wochen» lässt sich nur gegen ein Heute beurteilen, und das
|
||||
* Modell kennt seines nicht verlässlich.
|
||||
*/
|
||||
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.
|
||||
|
||||
Relevant sind nur Entwicklungen, die plausibel zu zusätzlichem Gewerbeflächenbedarf, einem neuen Standort, einer Standortveränderung, Flächenreduktion oder zukünftiger Wiedervermietung führen könnten.
|
||||
|
||||
Allgemeine Unternehmensnachrichten ohne plausiblen Immobilienbezug sind IRRELEVANT.
|
||||
|
||||
Bewerte konservativ. Erfinde keine Informationen, die nicht in der Quelle stehen. Wenn eine Meldung keinen Firmennamen nennt, schreibe in "company" das, was die Quelle tatsächlich benennt — erfinde keinen Namen.
|
||||
|
||||
Antworte ausschliesslich mit einem JSON-Objekt der Form:
|
||||
{"results":[{"index":0,"company":"...","eventType":"EXPANSION","headline":"...","location":"...","summary":"...","relevance":"high","confidence":0.8,"reason":"...","recommendedAction":"...","temporalClass":"future","temporalEvidence":"...","eventDate":"2026-11","semanticSearchProfile":"..."}]}
|
||||
|
||||
Gib für JEDEN Eingabeeintrag genau ein Ergebnis mit dem passenden "index" zurück.
|
||||
|
||||
eventType ist einer von: EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT, M_AND_A, RESTRUCTURING, SITE_CLOSURE, NEW_COMPANY, IRRELEVANT.
|
||||
relevance ist "high", "medium" oder "low". confidence ist eine Zahl zwischen 0 und 1.
|
||||
|
||||
ZEITLICHE EINORDNUNG ("temporalClass"):
|
||||
Beurteile, WANN die beschriebene Veränderung stattfindet — nicht, wann der Artikel erschienen ist.
|
||||
Das Publikationsdatum ist dafür irrelevant. Heute ist ${heute}.
|
||||
- "past": Die Veränderung ist bereits abgeschlossen (Umzug erfolgt, Standort eröffnet oder geschlossen, Transaktion vollzogen).
|
||||
- "current": Die Veränderung läuft gerade oder steht innerhalb der nächsten 8 Wochen bevor.
|
||||
- "future": Die Veränderung liegt mehr als 8 Wochen voraus ODER ist angekündigt ohne kurzfristige Umsetzung.
|
||||
- "unknown": Die Quelle erlaubt keine belastbare zeitliche Aussage. Erfinde dann KEIN Datum.
|
||||
Beispiel: Ein Bericht aus Q1 kündigt einen Standort für Q3 an => "future".
|
||||
Beispiel: Ein heute erschienener Artikel beschreibt einen Umzug vom letzten Frühling => "past".
|
||||
|
||||
"temporalEvidence": die Textstelle oder Formulierung aus der Quelle, auf die sich deine Einordnung stützt. Ein Satz.
|
||||
"eventDate": nur setzen, wenn die Quelle ein Datum oder einen Zeitraum nennt (z. B. "2026-11" oder "2026-11-15"). Sonst weglassen.
|
||||
|
||||
SEMANTISCHES SUCHPROFIL ("semanticSearchProfile"):
|
||||
Nur bei EXPANSION, NEW_LOCATION, HEADCOUNT_GROWTH, INVESTMENT und NEW_COMPANY — also dort, wo Flächenbedarf entstehen könnte.
|
||||
Formuliere ein zusammenhängendes Nachfrageprofil in ganzen Sätzen (4–8 Sätze), das beschreibt, welche Fläche dieses Unternehmen vermutlich sucht.
|
||||
Nenne, soweit aus der Quelle ableitbar: Unternehmen, Anlass, wahrscheinliche Nutzungsart, geografischer Suchraum, Flächenbedarf, zeitlicher Horizont, Standort- und Infrastrukturanforderungen, Erreichbarkeit.
|
||||
Schliesse mit einem Satz, der ausdrücklich benennt, was aus der Quelle NICHT ableitbar ist.
|
||||
Erfinde nichts. Was die Quelle nicht hergibt, gehört in den Unsicherheitssatz — nicht ins Profil.
|
||||
|
||||
Deutsch, Schweizer Rechtschreibung (ss statt ß). Keine langen Analysen.`
|
||||
}
|
||||
|
||||
const AnalysisSchema = z.object({
|
||||
results: z.array(z.object({
|
||||
index: z.number().int().min(0),
|
||||
company: z.string().min(1),
|
||||
eventType: z.enum([
|
||||
'EXPANSION', 'NEW_LOCATION', 'HEADCOUNT_GROWTH', 'INVESTMENT',
|
||||
'M_AND_A', 'RESTRUCTURING', 'SITE_CLOSURE', 'NEW_COMPANY', 'IRRELEVANT',
|
||||
]),
|
||||
headline: z.string().min(1),
|
||||
location: z.string().optional(),
|
||||
summary: z.string().min(1),
|
||||
relevance: z.enum(['high', 'medium', 'low']),
|
||||
confidence: z.number().min(0).max(1),
|
||||
reason: z.string().min(1),
|
||||
recommendedAction: z.string().optional(),
|
||||
// Fehlt die zeitliche Einordnung, gilt «unknown» — das ist die ehrliche
|
||||
// Antwort und nicht ein aus dem Publikationsdatum geratener Wert.
|
||||
temporalClass: z.enum(['past', 'current', 'future', 'unknown']).default('unknown'),
|
||||
temporalEvidence: z.string().default(''),
|
||||
eventDate: z.string().optional(),
|
||||
semanticSearchProfile: z.string().optional(),
|
||||
})),
|
||||
})
|
||||
|
||||
export interface AnalysisOutcome {
|
||||
leads: ResearchLead[]
|
||||
/** Einträge ohne plausiblen Immobilienbezug. */
|
||||
discarded: number
|
||||
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||
watchlist: number
|
||||
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Zeitfenster lag (§2.9). */
|
||||
temporalFiltered: number
|
||||
}
|
||||
|
||||
/** Das erste JSON-Objekt aus der Antwort — falls das Modell doch etwas drumherum schreibt. */
|
||||
function extractJson(text: string): unknown {
|
||||
const trimmed = text.trim()
|
||||
const start = trimmed.indexOf('{')
|
||||
const end = trimmed.lastIndexOf('}')
|
||||
if (start === -1 || end <= start) throw new Error('Antwort enthält kein JSON-Objekt')
|
||||
return JSON.parse(trimmed.slice(start, end + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Einträge ein Aufruf beurteilt.
|
||||
*
|
||||
* Der Wert ist gemessen, nicht geraten. Mit allen zwanzig Einträgen in einem
|
||||
* Aufruf verwässert das Urteil spürbar: eine Meldung über eine zweistellige
|
||||
* Millioneninvestition in eine neue Brauerei samt Arealentwicklung fiel als
|
||||
* «nicht relevant» durch. Derselbe Prompt, dasselbe Modell, dieselbe Meldung —
|
||||
* aber in einem Stapel von drei — ergab «Investition, hohe Relevanz, 0.85».
|
||||
*
|
||||
* Eine höhere Effort-Stufe half nicht, sie machte es sogar schlechter. Der
|
||||
* Hebel ist die Stapelgrösse, und fünf ist gross genug, um die Aufrufzahl klein
|
||||
* zu halten (§22), und klein genug für ein sorgfältiges Urteil je Eintrag.
|
||||
*/
|
||||
const BATCH_SIZE = 5
|
||||
|
||||
/**
|
||||
* Welches Modell die Einordnung vornimmt.
|
||||
*
|
||||
* Über `LIVIA_RESEARCH_MODEL` umstellbar, ohne den Code anzufassen — die
|
||||
* Wahl ist eine Kosten- und Qualitätsabwägung und gehört damit dem Betreiber,
|
||||
* nicht dem Quelltext.
|
||||
*
|
||||
* Grössenordnung je Lauf über rund fünfzig Meldungen:
|
||||
* claude-opus-5 ≈ 0.40 USD stärkstes Urteil
|
||||
* claude-sonnet-5 ≈ 0.20 USD
|
||||
* claude-haiku-4-5 ≈ 0.08 USD für eine Ja/Nein-Einordnung meist ausreichend
|
||||
*/
|
||||
export const MODEL = process.env.LIVIA_RESEARCH_MODEL ?? 'claude-opus-5'
|
||||
|
||||
/**
|
||||
* Wie viel Artikeltext das Modell zu lesen bekommt.
|
||||
*
|
||||
* Ebenfalls umstellbar, weil es der zweite grosse Kostenhebel ist: Der
|
||||
* Immobilienbezug einer Firmenmeldung steht fast immer im ersten Absatz, und
|
||||
* jede Halbierung halbiert die Eingabekosten.
|
||||
*/
|
||||
const TEXT_LIMIT = Number(process.env.LIVIA_RESEARCH_TEXT_LIMIT ?? 2000)
|
||||
|
||||
/**
|
||||
* Welche Modelle den `effort`-Schalter kennen.
|
||||
*
|
||||
* Nicht alle tun das: Haiku 4.5 weist die Anfrage mit «This model does not
|
||||
* support the effort parameter» ab. Da das Modell über eine Umgebungsvariable
|
||||
* frei wählbar ist, darf der Schalter nicht bedingungslos mitgeschickt werden —
|
||||
* sonst macht eine Kostenoptimierung die Funktion kaputt, und zwar erst zur
|
||||
* Laufzeit.
|
||||
*
|
||||
* Bewusst als Erlaubnisliste: Ein unbekanntes Modell läuft dann ohne den
|
||||
* Schalter. Das kostet etwas mehr, funktioniert aber — die umgekehrte
|
||||
* Voreinstellung würde raten und scheitern.
|
||||
*/
|
||||
const EFFORT_MODELS = /^claude-(fable|mythos|opus|sonnet)-/
|
||||
function supportsEffort(model: string): boolean {
|
||||
return EFFORT_MODELS.test(model)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele Teilstapel gleichzeitig beurteilt werden.
|
||||
*
|
||||
* Alles auf einmal loszuschicken wäre schneller, läuft aber in die Drosselung
|
||||
* des Anbieters — und ein gedrosselter Lauf sieht für den Nutzer aus wie ein
|
||||
* kaputter.
|
||||
*/
|
||||
const ANALYSIS_CONCURRENCY = 5
|
||||
|
||||
function chunk<T>(list: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Ein Beurteilungsaufruf für einen Teilstapel. */
|
||||
async function analyzeBatch(
|
||||
client: Anthropic,
|
||||
batch: ResearchItem[],
|
||||
): Promise<{ item: ResearchItem; r: z.infer<typeof AnalysisSchema>['results'][number] }[]> {
|
||||
const eingabe = batch
|
||||
.map((it, i) => [
|
||||
`### Eintrag ${i}`,
|
||||
`Quelle: ${it.source}`,
|
||||
`Titel: ${it.title}`,
|
||||
it.publishedAt ? `Publiziert: ${it.publishedAt}` : '',
|
||||
`Text: ${it.text.slice(0, TEXT_LIMIT)}`,
|
||||
].filter(Boolean).join('\n'))
|
||||
.join('\n\n')
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: 8000,
|
||||
system: systemPrompt(),
|
||||
// Einordnen ist bei fünf Einträgen eine Routineaufgabe — die niedrige Stufe
|
||||
// spart Zeit und Kosten. Nur die grossen Modelle kennen den Schalter; bei
|
||||
// den kleineren wird die Anfrage sonst abgewiesen (siehe SUPPORTS_EFFORT).
|
||||
...(supportsEffort(MODEL) ? { output_config: { effort: 'low' as const } } : {}),
|
||||
messages: [{ role: 'user', content: eingabe }],
|
||||
})
|
||||
|
||||
if (response.stop_reason === 'refusal') {
|
||||
throw new Error('Die Beurteilung wurde vom Modell abgelehnt.')
|
||||
}
|
||||
|
||||
const text = response.content
|
||||
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('')
|
||||
|
||||
const parsed = AnalysisSchema.parse(extractJson(text))
|
||||
return parsed.results
|
||||
.filter(r => batch[r.index] !== undefined)
|
||||
.map(r => ({ item: batch[r.index], r }))
|
||||
}
|
||||
|
||||
export async function analyzeResearchItems(
|
||||
items: ResearchItem[],
|
||||
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung». */
|
||||
temporalFilter: TemporalClass[] = [],
|
||||
): Promise<AnalysisOutcome> {
|
||||
if (items.length === 0) return { leads: [], discarded: 0, watchlist: 0, temporalFiltered: 0 }
|
||||
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'ANTHROPIC_API_KEY ist serverseitig nicht gesetzt — ohne Schlüssel findet keine Beurteilung statt.',
|
||||
)
|
||||
}
|
||||
|
||||
const client = new Anthropic({ apiKey })
|
||||
|
||||
// Parallel, aber begrenzt: die Laufzeit ist damit nicht die Summe der
|
||||
// Teilstapel, und gleichzeitig laufen nie so viele Anfragen, dass der
|
||||
// Anbieter drosselt.
|
||||
const teilstapel = chunk(items, BATCH_SIZE)
|
||||
const ergebnisse: Awaited<ReturnType<typeof analyzeBatch>>[] = new Array(teilstapel.length)
|
||||
const fehler: string[] = []
|
||||
let next = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const i = next++
|
||||
if (i >= teilstapel.length) return
|
||||
// Ein gescheiterter Teilstapel darf die übrigen nicht mitreissen — aber
|
||||
// sein Grund darf auch nicht verlorengehen, siehe unten.
|
||||
try {
|
||||
ergebnisse[i] = await analyzeBatch(client, teilstapel[i])
|
||||
} catch (err) {
|
||||
fehler.push(err instanceof Error ? err.message : String(err))
|
||||
ergebnisse[i] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(ANALYSIS_CONCURRENCY, teilstapel.length) }, worker),
|
||||
)
|
||||
|
||||
/*
|
||||
* Scheitern alle Teilstapel, ist das kein leeres Ergebnis, sondern ein
|
||||
* Ausfall — und der muss gesagt werden. Vorher wurde er verschluckt und die
|
||||
* Oberfläche meldete «49 analysiert · 0 relevante Entwicklungen», was wie ein
|
||||
* sauberer Lauf ohne Treffer aussieht. Aufgefallen ist das erst, als das
|
||||
* Guthaben aufgebraucht war: der Fehler «credit balance is too low» erschien
|
||||
* nirgends, die Zahlen sahen bloss unauffällig aus.
|
||||
*/
|
||||
if (fehler.length === teilstapel.length) {
|
||||
throw new Error(fehler[0])
|
||||
}
|
||||
const batches = ergebnisse
|
||||
|
||||
const leads: ResearchLead[] = []
|
||||
let discarded = 0
|
||||
let watchlist = 0
|
||||
let temporalFiltered = 0
|
||||
|
||||
for (const { item, r } of batches.flat()) {
|
||||
if (r.eventType === 'IRRELEVANT') { discarded += 1; continue }
|
||||
if (r.relevance === 'low') { watchlist += 1; continue }
|
||||
// Aggregierte Statistik wird gelesen und beurteilt, wird aber nie zur
|
||||
// Lead-Karte: eine Branchenzahl nennt kein Unternehmen, das man
|
||||
// kontaktieren könnte. Sie zählt als Beobachtungsposten (§19).
|
||||
if (item.aggregate) { watchlist += 1; continue }
|
||||
if (!passtZeitlich(r.temporalClass, temporalFilter)) { temporalFiltered += 1; continue }
|
||||
|
||||
leads.push({
|
||||
company: r.company,
|
||||
eventType: r.eventType,
|
||||
headline: r.headline,
|
||||
location: r.location,
|
||||
summary: r.summary,
|
||||
relevance: r.relevance,
|
||||
confidence: r.confidence,
|
||||
reason: r.reason,
|
||||
recommendedAction: r.recommendedAction,
|
||||
source: item.source,
|
||||
sourceUrl: item.url,
|
||||
publishedAt: item.publishedAt,
|
||||
temporalClass: r.temporalClass,
|
||||
temporalEvidence: r.temporalEvidence,
|
||||
eventDate: r.eventDate,
|
||||
semanticSearchProfile: r.semanticSearchProfile,
|
||||
})
|
||||
}
|
||||
|
||||
// Stärkste Einordnung zuerst — die Liste soll oben beginnen, wo gehandelt wird.
|
||||
leads.sort((a, b) =>
|
||||
(a.relevance === b.relevance ? b.confidence - a.confidence : a.relevance === 'high' ? -1 : 1))
|
||||
|
||||
return { leads, discarded, watchlist, temporalFiltered }
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Livia Research-PoC — die drei realen Quellen.
|
||||
*
|
||||
* Bewusst drei einzelne Funktionen statt einer Connector-Abstraktion: es sind
|
||||
* drei Quellen, und jede liest anders. Eine gemeinsame Schnittstelle würde
|
||||
* hier nur die Unterschiede verstecken, die man beim Debuggen braucht.
|
||||
*
|
||||
* Kein Browser, kein Playwright, kein Scheduler — nur `fetch()` und ein
|
||||
* HTML-Parser. Fällt eine Quelle aus, liefert sie einen Fehler und die anderen
|
||||
* beiden laufen weiter (§21).
|
||||
*/
|
||||
|
||||
import * as cheerio from 'cheerio'
|
||||
import { RESEARCH_SOURCES } from '../../src/lib/researchSources.js'
|
||||
|
||||
/** Gemeinsames Minimalformat aller Quellen (§9). */
|
||||
export interface ResearchItem {
|
||||
source: string
|
||||
title: string
|
||||
url: string
|
||||
text: string
|
||||
publishedAt?: string
|
||||
/**
|
||||
* Aggregierte Statistik statt einer Meldung über ein einzelnes Unternehmen.
|
||||
* Solche Einträge werden gelesen und beurteilt, erscheinen aber nie als Lead —
|
||||
* eine Branchenzahl kann man nicht anrufen (§19: «Early Watch»).
|
||||
*/
|
||||
aggregate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Adressen kommen aus `src/lib/researchSources.ts` — derselben Liste, die
|
||||
* die Oberfläche unter «Angebundene Kanäle & Systeme» anzeigt. Damit kann der
|
||||
* angezeigte Link nicht von dem abweichen, was tatsächlich abgerufen wird.
|
||||
*/
|
||||
export const SOURCES = RESEARCH_SOURCES
|
||||
|
||||
/**
|
||||
* Wie viele Übersichtsseiten je Newsquelle gelesen werden.
|
||||
*
|
||||
* Eine Seite allein reicht nicht: Die Handelskammer erneuert ihre obersten
|
||||
* zehn Meldungen im Lauf eines Tages, und wenn dort gerade Personalien und
|
||||
* Meinungsbeiträge stehen, findet Livia zu Recht nichts. Drei Seiten decken
|
||||
* bei der Handelskammer rund 40 und bei Greater Zurich rund 27 Meldungen ab —
|
||||
* damit hängt das Ergebnis nicht mehr am Zufall eines einzelnen Tages.
|
||||
*/
|
||||
const LIST_PAGES = 3
|
||||
|
||||
/** Höchstzahl Artikel je Newsquelle — hält Laufzeit und Tokenverbrauch im Rahmen (§22). */
|
||||
const MAX_ARTICLES = 24
|
||||
|
||||
/** Gleichzeitige Artikelabrufe. Nacheinander wären 24 Abrufe je Quelle zu langsam. */
|
||||
const FETCH_CONCURRENCY = 8
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Eigenes Zeitlimit für die Zefix-CSV.
|
||||
*
|
||||
* Die Datei ist 10,5 MB gross und wird unkomprimiert ausgeliefert — der Server
|
||||
* bietet kein gzip an. Von einem Schweizer Anschluss dauert der Abruf keine
|
||||
* zwei Sekunden, aus dem Rechenzentrum der Anwendung aber mehr als fünfzehn,
|
||||
* und genau daran ist die Quelle im ersten Produktionslauf gescheitert.
|
||||
*/
|
||||
const CSV_TIMEOUT_MS = 30_000
|
||||
const UA = 'Mozilla/5.0 (compatible; PropertyMatch-LiviaResearch/1.0; +https://property-match-virid.vercel.app)'
|
||||
|
||||
async function getText(url: string, timeoutMs: number = FETCH_TIMEOUT_MS): Promise<string> {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, 'Accept-Language': 'de-CH,de;q=0.9' },
|
||||
redirect: 'follow',
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return await res.text()
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Container, in denen der Artikeltext bevorzugt gesucht wird — von eng nach weit.
|
||||
*
|
||||
* Die Reihenfolge ist der Unterschied zwischen brauchbarem und unbrauchbarem
|
||||
* Input: Nimmt man gleich alle `<p>` der Seite, stehen bei der Handelskammer
|
||||
* zuerst Telefonnummern, Öffnungszeiten und Spam-geschützte Mailadressen im
|
||||
* Text — und das Modell beurteilt dann die Fusszeile mit.
|
||||
*/
|
||||
const ARTICLE_CONTAINERS = ['.news-single', '.news-detail', 'article', 'main']
|
||||
|
||||
/** Zeilen, die erkennbar Kontaktangaben statt Inhalt sind. */
|
||||
const BOILERPLATE = /(dont-like-spam|ich-will-kein-spam|^\+41|Montag\s*[–-]\s*Freitag)/i
|
||||
|
||||
/**
|
||||
* Haupttext einer Artikelseite.
|
||||
*
|
||||
* Absichtlich grob: Skripte und Navigation raus, Absätze aus dem engsten
|
||||
* passenden Container einsammeln, kürzen. Für die Beurteilung genügen die
|
||||
* ersten Absätze — ein vollwertiger Lesemodus wäre für einen
|
||||
* Machbarkeitsnachweis Aufwand ohne zusätzliche Aussage.
|
||||
*/
|
||||
function extractArticleText(html: string): string {
|
||||
const $ = cheerio.load(html)
|
||||
$('script, style, nav, header, footer, noscript, form').remove()
|
||||
|
||||
const sammle = (selector: string): string[] => {
|
||||
const parts: string[] = []
|
||||
$(selector).each((_, el) => {
|
||||
const t = $(el).text().replace(/\s+/g, ' ').trim()
|
||||
if (t.length > 40 && !BOILERPLATE.test(t)) parts.push(t)
|
||||
})
|
||||
return parts
|
||||
}
|
||||
|
||||
for (const container of ARTICLE_CONTAINERS) {
|
||||
if ($(container).length === 0) continue
|
||||
const parts = sammle(`${container} p`)
|
||||
const text = parts.join('\n')
|
||||
if (text.length > 200) return text.slice(0, 2500)
|
||||
}
|
||||
|
||||
return sammle('p').join('\n').slice(0, 2500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Linktext trägt bei der Handelskammer das Publikationsdatum vorneweg
|
||||
* («28. August 2026 Bystronic übernimmt …») und hinten ein »-Zeichen. Beides
|
||||
* gehört nicht in den Titel, den das Modell zu lesen bekommt.
|
||||
*/
|
||||
function cleanTitle(raw: string): string {
|
||||
return raw
|
||||
.replace(/^\d{1,2}\.\s*\p{L}+\s+\d{4}\s*/u, '')
|
||||
.replace(/\s*[»›>]\s*$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function absolute(href: string, base: string): string {
|
||||
try {
|
||||
return new URL(href, base).toString()
|
||||
} catch {
|
||||
return href
|
||||
}
|
||||
}
|
||||
|
||||
/** Kartiert mit begrenzter Gleichzeitigkeit — schneller als nacheinander, ohne die Quelle zu fluten. */
|
||||
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array<R>(items.length)
|
||||
let next = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const i = next++
|
||||
if (i >= items.length) return
|
||||
out[i] = await fn(items[i])
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
return out
|
||||
}
|
||||
|
||||
interface Artikellink { url: string; title: string }
|
||||
|
||||
/**
|
||||
* Artikel zu Einträgen machen — parallel, und ein einzelner Ausfall kippt den
|
||||
* Lauf nicht. Statt einer Ausnahme entsteht dann schlicht kein Eintrag.
|
||||
*/
|
||||
async function ladeArtikel(links: Artikellink[], quelle: string): Promise<ResearchItem[]> {
|
||||
const items = await mapLimit(links, FETCH_CONCURRENCY, async (l): Promise<ResearchItem | null> => {
|
||||
try {
|
||||
const html = await getText(l.url)
|
||||
const $a = cheerio.load(html)
|
||||
return {
|
||||
source: quelle,
|
||||
title: cleanTitle(l.title) || $a('h1').first().text().replace(/\s+/g, ' ').trim(),
|
||||
url: l.url,
|
||||
text: extractArticleText(html),
|
||||
publishedAt:
|
||||
$a('time').first().attr('datetime') ||
|
||||
$a('meta[property="article:published_time"]').attr('content') ||
|
||||
undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
return items.filter((i): i is ResearchItem => i !== null && i.text.length > 120)
|
||||
}
|
||||
|
||||
/**
|
||||
* Artikellinks aus mehreren Übersichtsseiten einsammeln.
|
||||
*
|
||||
* Die Listenseiten werden parallel geholt, das Ergebnis aber in Seitenreihenfolge
|
||||
* zusammengesetzt: Seite 1 trägt die neuesten Meldungen, und die sollen zuerst
|
||||
* kommen, wenn die Obergrenze greift.
|
||||
*/
|
||||
async function sammleLinks(
|
||||
seitenUrls: string[],
|
||||
basis: string,
|
||||
selektor: string,
|
||||
ausschluss?: RegExp,
|
||||
): Promise<Artikellink[]> {
|
||||
const seiten = await Promise.all(
|
||||
seitenUrls.map(async u => {
|
||||
try {
|
||||
return await getText(u)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const links: Artikellink[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const html of seiten) {
|
||||
if (!html) continue
|
||||
const $ = cheerio.load(html)
|
||||
$(selektor).each((_, el) => {
|
||||
const href = $(el).attr('href')
|
||||
if (!href) return
|
||||
const url = absolute(href, basis)
|
||||
if (seen.has(url)) return
|
||||
if (ausschluss?.test(url)) return
|
||||
seen.add(url)
|
||||
links.push({ url, title: $(el).text().replace(/\s+/g, ' ').trim() })
|
||||
})
|
||||
}
|
||||
return links.slice(0, MAX_ARTICLES)
|
||||
}
|
||||
|
||||
/** Quelle A — Zürcher Handelskammer, Unternehmensmeldungen aus der Region (§4). */
|
||||
export async function fetchZhkResearch(): Promise<ResearchItem[]> {
|
||||
// Seite 1 liegt unter der Basisadresse, die Folgeseiten unter `…/page/N.html`.
|
||||
const seiten = [
|
||||
SOURCES.ZHK.url,
|
||||
...Array.from({ length: LIST_PAGES - 1 }, (_, i) =>
|
||||
`https://www.zhk.ch/de/wirtschaft-und-politik/news-liste/page/${i + 2}.html`),
|
||||
]
|
||||
const links = await sammleLinks(
|
||||
seiten,
|
||||
SOURCES.ZHK.url,
|
||||
'a[href*="/de/wirtschaft-und-politik/news/"]',
|
||||
)
|
||||
return ladeArtikel(links, SOURCES.ZHK.name)
|
||||
}
|
||||
|
||||
/** Quelle B — Greater Zurich Area, Ansiedlungen und Markteintritte (§5). */
|
||||
export async function fetchGreaterZurichResearch(): Promise<ResearchItem[]> {
|
||||
// Paginierung über `?page=N`, beginnend bei 0.
|
||||
const seiten = Array.from({ length: LIST_PAGES }, (_, i) =>
|
||||
i === 0 ? SOURCES.GZA.url : `${SOURCES.GZA.url}?page=${i}`)
|
||||
const links = await sammleLinks(
|
||||
seiten,
|
||||
SOURCES.GZA.url,
|
||||
'a[href*="/de/news/"]',
|
||||
/\/de\/news\/?$/,
|
||||
)
|
||||
return ladeArtikel(links, SOURCES.GZA.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quelle C — Zefix / Open Data Kanton Zürich, direkt als CSV (§6).
|
||||
*
|
||||
* **Wichtige Feststellung zur Datenlage:** Die Datei enthält keine Firmennamen.
|
||||
* Sie führt Tageszahlen von Neugründungen je NOGA-Branche für den Kanton
|
||||
* Zürich — Spalten `br_abschnitt_desc, br_abschnitt_code, wirtschaftssektor_desc,
|
||||
* wirtschaftssektor_code, location, date, value`.
|
||||
*
|
||||
* Daraus lässt sich deshalb kein Unternehmenslead bauen, sondern nur ein
|
||||
* aggregiertes Frühsignal zum Zielmarkt. Genau so wird es weitergegeben.
|
||||
* Firmennamen zu erfinden, um das erwartete Format zu treffen, wäre die eine
|
||||
* Sache, die Livia nie tun darf.
|
||||
*/
|
||||
export async function fetchZefixResearch(): Promise<ResearchItem[]> {
|
||||
const csv = await getText(SOURCES.ZEFIX.url, CSV_TIMEOUT_MS)
|
||||
const lines = csv.split(/\r?\n/)
|
||||
if (lines.length < 2) throw new Error('CSV enthält keine Daten')
|
||||
|
||||
// Die Beschreibung kann Kommas enthalten — die sechs Pflichtfelder stehen
|
||||
// deshalb von hinten, der Rest davor ist die Branchenbezeichnung.
|
||||
interface Row { branche: string; datum: string; anzahl: number }
|
||||
const rows: Row[] = []
|
||||
let neuestesDatum = ''
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (!line) continue
|
||||
const parts = line.split(',')
|
||||
if (parts.length < 7) continue
|
||||
const anzahl = Number(parts[parts.length - 1])
|
||||
const datum = parts[parts.length - 2]
|
||||
const ort = parts[parts.length - 3]
|
||||
const branche = parts.slice(0, parts.length - 6).join(',').trim()
|
||||
if (ort !== 'ZH' || !Number.isFinite(anzahl)) continue
|
||||
if (datum > neuestesDatum) neuestesDatum = datum
|
||||
rows.push({ branche, datum, anzahl })
|
||||
}
|
||||
|
||||
if (!neuestesDatum) throw new Error('Keine Zürcher Datenzeilen gefunden')
|
||||
|
||||
// Fenster: die letzten 30 Tage vor dem jüngsten Datum im Bestand.
|
||||
const bis = new Date(neuestesDatum)
|
||||
const von = new Date(bis.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
const vonIso = von.toISOString().slice(0, 10)
|
||||
|
||||
const jeBranche = new Map<string, number>()
|
||||
let gesamt = 0
|
||||
for (const r of rows) {
|
||||
if (r.datum < vonIso || r.datum > neuestesDatum) continue
|
||||
if (r.branche === 'Alle Branchen') continue
|
||||
if (r.anzahl <= 0) continue
|
||||
jeBranche.set(r.branche, (jeBranche.get(r.branche) ?? 0) + r.anzahl)
|
||||
gesamt += r.anzahl
|
||||
}
|
||||
|
||||
const top = [...jeBranche.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
|
||||
if (top.length === 0) throw new Error('Keine Neugründungen im Auswertungsfenster')
|
||||
|
||||
const text = [
|
||||
`Amtliche Statistik des Kantons Zürich zu Handelsregister-Neugründungen.`,
|
||||
`Auswertungsfenster ${vonIso} bis ${neuestesDatum}: insgesamt ${gesamt} Neugründungen im Kanton Zürich.`,
|
||||
`Verteilung nach Branche:`,
|
||||
...top.map(([b, n]) => `- ${b}: ${n}`),
|
||||
``,
|
||||
`Hinweis zur Datenart: Der Datensatz enthält ausschliesslich Tageszahlen je Branche.`,
|
||||
`Firmennamen, Adressen und Gemeinden sind darin nicht enthalten.`,
|
||||
].join('\n')
|
||||
|
||||
return [{
|
||||
source: SOURCES.ZEFIX.name,
|
||||
title: `Kanton Zürich: ${gesamt} Handelsregister-Neugründungen in 30 Tagen`,
|
||||
url: SOURCES.ZEFIX.url,
|
||||
text,
|
||||
publishedAt: neuestesDatum,
|
||||
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,
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Livia Research-PoC — der eine serverseitige Endpoint (§20).
|
||||
*
|
||||
* POST /api/livia/research/refresh
|
||||
*
|
||||
* Ruft die drei Quellen ab, normalisiert, beurteilt einmal per LLM und gibt
|
||||
* die relevanten Leads zurück. Kein Scheduler, keine Queue, keine Datenbank —
|
||||
* der Lauf beginnt und endet mit dieser Anfrage.
|
||||
*
|
||||
* Läuft als serverlose Funktion auf derselben Vercel-Instanz, die schon die
|
||||
* Anwendung ausliefert. Das ist der Grund, warum für den PoC gar keine neue
|
||||
* Infrastruktur nötig war: der Hoster kann bereits Serverfunktionen, es hat
|
||||
* bisher nur niemand eine gebraucht.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import {
|
||||
SOURCES,
|
||||
fetchZhkResearch,
|
||||
fetchGreaterZurichResearch,
|
||||
fetchZefixResearch,
|
||||
fetchGenericSource,
|
||||
} from '../../_lib/researchSources.js'
|
||||
import type { ResearchItem } from '../../_lib/researchSources.js'
|
||||
import { MODEL, analyzeResearchItems } from '../../_lib/researchAnalysis.js'
|
||||
import type {
|
||||
ResearchRefreshResult,
|
||||
ResearchSourceResult,
|
||||
TemporalClass,
|
||||
} from '../../../src/domain/researchLead.js'
|
||||
|
||||
interface Quelle {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
laden: () => Promise<ResearchItem[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Für diese drei Adressen gibt es einen eigens geschriebenen Leser.
|
||||
*
|
||||
* Der Schlüssel ist die Adresse und nicht die Kennung: Ein Zugang, den der
|
||||
* Nutzer im Personalblatt anlegt, bekommt eine eigene Kennung, zeigt aber
|
||||
* vielleicht auf dieselbe Seite. Dann soll er den guten Leser bekommen und
|
||||
* nicht den allgemeinen.
|
||||
*/
|
||||
const SPEZIALLESER = new Map<string, () => Promise<ResearchItem[]>>([
|
||||
[SOURCES.ZHK.url, fetchZhkResearch],
|
||||
[SOURCES.GZA.url, fetchGreaterZurichResearch],
|
||||
[SOURCES.ZEFIX.url, fetchZefixResearch],
|
||||
])
|
||||
|
||||
/** Die gepflegten drei — Rückfall, wenn die Anfrage keine Quellen mitbringt. */
|
||||
const STANDARDQUELLEN: Quelle[] = [
|
||||
{ ...SOURCES.ZHK, laden: fetchZhkResearch },
|
||||
{ ...SOURCES.GZA, laden: fetchGreaterZurichResearch },
|
||||
{ ...SOURCES.ZEFIX, laden: fetchZefixResearch },
|
||||
]
|
||||
|
||||
/** Was die Oberfläche mitschickt (Runde 10, §§2.3/2.6/2.9). */
|
||||
interface RefreshBody {
|
||||
sources?: { id: string; name: string; url: string }[]
|
||||
documents?: { id: string; name: string; text: string }[]
|
||||
temporalClasses?: TemporalClass[]
|
||||
}
|
||||
|
||||
/** Höchstzahl frei angebundener Quellen je Lauf — schützt Laufzeit und Kosten. */
|
||||
const MAX_SOURCES = 8
|
||||
/** Höchstzahl abgelegter Dokumente je Lauf. */
|
||||
const MAX_DOCUMENTS = 12
|
||||
/** Wie viel Text ein abgelegtes Dokument beisteuert. */
|
||||
const DOC_TEXT_LIMIT = 12_000
|
||||
|
||||
async function leseBody(req: IncomingMessage): Promise<RefreshBody> {
|
||||
const stuecke: Buffer[] = []
|
||||
for await (const chunk of req) stuecke.push(chunk as Buffer)
|
||||
if (stuecke.length === 0) return {}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(stuecke).toString('utf8')) as RefreshBody
|
||||
} catch {
|
||||
// Ein unlesbarer Rumpf ist kein Grund, den Lauf abzubrechen — dann gelten
|
||||
// eben die gepflegten Standardquellen.
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus den übergebenen Adressen die Leseaufträge bauen.
|
||||
*
|
||||
* Genau hier wird die Quellenregistry dynamisch: Was das Personalblatt als
|
||||
* aktiven Lesezugang mit Adresse führt, landet in dieser Liste — mit dem
|
||||
* spezialisierten Leser, wenn es einen gibt, und sonst mit dem allgemeinen.
|
||||
*/
|
||||
function baueQuellen(body: RefreshBody): Quelle[] {
|
||||
const angefragt = (body.sources ?? []).filter(q => q.url && q.name).slice(0, MAX_SOURCES)
|
||||
if (angefragt.length === 0) return STANDARDQUELLEN
|
||||
|
||||
return angefragt.map((q): Quelle => {
|
||||
const speziell = SPEZIALLESER.get(q.url)
|
||||
return {
|
||||
id: q.id,
|
||||
name: q.name,
|
||||
url: q.url,
|
||||
laden: speziell ?? (() => fetchGenericSource(q)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgelegte Dokumente als Einträge — dieselbe Beurteilung wie bei den Seiten.
|
||||
*
|
||||
* Das ist der ganze Mechanismus aus §2.6: Ein hochgeladenes PDF unterscheidet
|
||||
* sich für die Analyse in nichts von einem gelesenen Artikel, sobald sein Text
|
||||
* extrahiert ist. Ein zweiter Analysepfad dafür wäre eine zweite Stelle, an der
|
||||
* sich die Beurteilung ändern könnte.
|
||||
*/
|
||||
function dokumenteAlsEintraege(body: RefreshBody): ResearchItem[] {
|
||||
return (body.documents ?? [])
|
||||
.filter(d => typeof d.text === 'string' && d.text.trim().length > 120)
|
||||
.slice(0, MAX_DOCUMENTS)
|
||||
.map(d => ({
|
||||
source: 'Manuell abgelegtes Dokument',
|
||||
title: d.name,
|
||||
// Es gibt keine Webadresse — die Kennung des Dokuments tritt an ihre Stelle.
|
||||
url: `dokument:${d.id}`,
|
||||
text: d.text.slice(0, DOC_TEXT_LIMIT),
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function handler(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
// Ein Live-Lauf soll nie aus einem Zwischenspeicher beantwortet werden.
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
res.statusCode = 405
|
||||
res.end(JSON.stringify({ error: 'Nur POST' }))
|
||||
return
|
||||
}
|
||||
|
||||
const body = await leseBody(req)
|
||||
const quellen = baueQuellen(body)
|
||||
const dokumente = dokumenteAlsEintraege(body)
|
||||
|
||||
// Alle parallel: eine langsame Quelle soll die anderen nicht aufhalten.
|
||||
const ergebnisse = await Promise.all(
|
||||
quellen.map(async (q): Promise<{ meta: ResearchSourceResult; items: ResearchItem[] }> => {
|
||||
try {
|
||||
const items = await q.laden()
|
||||
return {
|
||||
meta: { id: q.id, name: q.name, url: q.url, ok: true, itemCount: items.length },
|
||||
items,
|
||||
}
|
||||
} catch (err) {
|
||||
// Eine kaputte Quelle darf die Demo nicht zerstören (§21).
|
||||
return {
|
||||
meta: {
|
||||
id: q.id, name: q.name, url: q.url, ok: false, itemCount: 0,
|
||||
error: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||
},
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const sources = ergebnisse.map(e => e.meta)
|
||||
|
||||
// Abgelegte Dokumente erscheinen als eigene Zeile in der Quellenübersicht —
|
||||
// wer sie hochgeladen hat, soll sehen, dass sie tatsächlich gelesen wurden.
|
||||
if (dokumente.length > 0) {
|
||||
sources.push({
|
||||
id: 'dokumente',
|
||||
name: `Manuell abgelegte Dokumente (${dokumente.length})`,
|
||||
url: '',
|
||||
ok: true,
|
||||
itemCount: dokumente.length,
|
||||
})
|
||||
}
|
||||
|
||||
const items = [...ergebnisse.flatMap(e => e.items), ...dokumente]
|
||||
|
||||
const basis: ResearchRefreshResult = {
|
||||
sources,
|
||||
analyzed: items.length,
|
||||
discarded: 0,
|
||||
watchlist: 0,
|
||||
temporalFiltered: 0,
|
||||
leads: [],
|
||||
refreshedAt: new Date().toISOString(),
|
||||
model: MODEL,
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
res.statusCode = 200
|
||||
res.end(JSON.stringify({
|
||||
...basis,
|
||||
analysisError: 'Keine der angebundenen Quellen lieferte Einträge.',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { leads, discarded, watchlist, temporalFiltered } =
|
||||
await analyzeResearchItems(items, body.temporalClasses ?? [])
|
||||
res.statusCode = 200
|
||||
res.end(JSON.stringify({ ...basis, leads, discarded, watchlist, temporalFiltered }))
|
||||
} catch (err) {
|
||||
// Gelesen wurde trotzdem — das sagen wir offen, statt eine leere Liste als
|
||||
// «nichts gefunden» auszugeben.
|
||||
res.statusCode = 200
|
||||
res.end(JSON.stringify({
|
||||
...basis,
|
||||
analysisError: err instanceof Error ? err.message : 'Die Beurteilung schlug fehl.',
|
||||
}))
|
||||
}
|
||||
}
|
||||
Generated
+400
-1
@@ -8,6 +8,7 @@
|
||||
"name": "property-match",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.122.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -16,6 +17,7 @@
|
||||
"@mui/icons-material": "^9.0.1",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tanstack/react-query": "^5.75.2",
|
||||
"cheerio": "^1.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^19.2.6",
|
||||
@@ -58,6 +60,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.122.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.122.0.tgz",
|
||||
"integrity": "sha512-GGPNftt0caaz9MDlmNQGHX8855Ojaduyy5pm9Sm1h7HalCn0cWNb5/bweadJF+4yzbal+QL6ztBa09WAAOzLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"standardwebhooks": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
@@ -1563,6 +1586,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -2677,6 +2706,12 @@
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
@@ -2764,6 +2799,81 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cheerio-select": "^2.1.0",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"encoding-sniffer": "^0.2.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"parse5": "^7.3.0",
|
||||
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||
"parse5-parser-stream": "^7.1.2",
|
||||
"undici": "^7.19.0",
|
||||
"whatwg-mimetype": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio-select": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-select": "^5.1.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cheerio/node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2832,6 +2942,22 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.1.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"domutils": "^3.0.1",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -2846,6 +2972,18 @@
|
||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
@@ -2942,6 +3080,73 @@
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.356",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
||||
@@ -2949,6 +3154,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/encoding-sniffer": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.3",
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||
@@ -3246,6 +3464,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -3469,6 +3693,49 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3704,6 +3971,19 @@
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-to-ts": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"ts-algebra": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -4216,6 +4496,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -4329,6 +4621,79 @@
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parse5": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream/node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5-parser-stream/node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4694,6 +5059,12 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -4785,6 +5156,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz",
|
||||
"integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
@@ -4960,6 +5341,12 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-algebra": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -5034,7 +5421,6 @@
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
@@ -5279,6 +5665,19 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"check:tokens": "node scripts/check-tokens.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.122.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -22,6 +23,7 @@
|
||||
"@mui/icons-material": "^9.0.1",
|
||||
"@mui/material": "^9.0.1",
|
||||
"@tanstack/react-query": "^5.75.2",
|
||||
"cheerio": "^1.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^19.2.6",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react'
|
||||
import { useSessionStore } from '../../stores/sessionStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { AgentAvatar } from '../team'
|
||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||
import { AGENT_NAV_ENTRIES } from '../../lib/agentWorkspaces'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
||||
import { DS_BRAND, DS_NEUTRAL, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
@@ -25,7 +25,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||
* rechts ausgerichtete Unterpunkte, und «Profil» mit denselben vier Aktionen,
|
||||
* die vorher im Profilmenü lagen.
|
||||
*
|
||||
* Die Agentenliste kommt aus `AGENT_WORKSPACES` — derselben zentralen
|
||||
* Die Agentenliste kommt aus `AGENT_NAV_ENTRIES` — derselben zentralen
|
||||
* Konfiguration, die auch der Meetingraum und die Seitenköpfe verwenden. Namen,
|
||||
* Funktionen und Ziele werden dadurch an genau einer Stelle gepflegt (§24,
|
||||
* Schritt 7).
|
||||
@@ -116,7 +116,7 @@ export function GlobalNavMenu() {
|
||||
<MenuSection>
|
||||
<MainItem label={MY_AGENTS_LABEL} onClick={() => go(ROUTES.SUPPLY.TEAM)} />
|
||||
{/* Unterpunkte: kleiner, eingerückt und rechts ausgerichtet (§14). */}
|
||||
{AGENT_WORKSPACES.map(agent => (
|
||||
{AGENT_NAV_ENTRIES.map(agent => (
|
||||
<SubItem key={agent.id} onClick={() => go(agent.path)}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 500, color: DS_TEXT.primary }}>
|
||||
{agent.name}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import { WORKSPACE_CONFIG, getPageNameFromPath } from '../appShellConfig'
|
||||
import { WorkspaceType } from '../../../domain/enums'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../../lib/constants'
|
||||
import { AGENT_WORKSPACES } from '../../../lib/agentWorkspaces'
|
||||
import { AGENT_NAV_ENTRIES } from '../../../lib/agentWorkspaces'
|
||||
|
||||
const supplyNav = WORKSPACE_CONFIG[WorkspaceType.SUPPLY].navItems
|
||||
|
||||
@@ -47,9 +47,9 @@ describe('Navigationseintrag «Meine Agenten»', () => {
|
||||
expect(agenten?.path).toBe(ROUTES.SUPPLY.TEAM)
|
||||
})
|
||||
|
||||
it('trägt die fünf Agenten in verbindlicher Reihenfolge', () => {
|
||||
it('trägt Thomas zuoberst und darunter die fünf in verbindlicher Reihenfolge', () => {
|
||||
const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL)
|
||||
expect(agenten?.children?.map(c => c.label)).toEqual(['Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina'])
|
||||
expect(agenten?.children?.map(c => c.label)).toEqual(['Thomas', 'Ferdi', 'Bruno', 'Livia', 'Nora', 'Sina'])
|
||||
})
|
||||
|
||||
it('gibt jedem Agenten Porträt-ID und Funktionsbezeichnung mit', () => {
|
||||
@@ -63,7 +63,7 @@ describe('Navigationseintrag «Meine Agenten»', () => {
|
||||
it('verweist mit jedem Agenten auf eine eigene Seite', () => {
|
||||
const agenten = supplyNav.find(item => item.label === MY_AGENTS_LABEL)
|
||||
const paths = agenten?.children?.map(c => c.path) ?? []
|
||||
expect(paths).toEqual(AGENT_WORKSPACES.map(a => a.path))
|
||||
expect(paths).toEqual(AGENT_NAV_ENTRIES.map(a => a.path))
|
||||
expect(new Set(paths).size).toBe(paths.length)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorkspaceType } from '../../domain/enums'
|
||||
import { HOME_NAV_LABEL, MY_AGENTS_LABEL, ROUTES } from '../../lib/constants'
|
||||
import { AGENT_WORKSPACES } from '../../lib/agentWorkspaces'
|
||||
import { AGENT_NAV_ENTRIES } from '../../lib/agentWorkspaces'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Building2,
|
||||
@@ -75,7 +75,7 @@ export const WORKSPACE_CONFIG: Record<WorkspaceType, WorkspaceConfig> = {
|
||||
icon: Users,
|
||||
// Die drei Verwaltungsbereiche sind Reiter der Hauptseite geworden;
|
||||
// hier stehen ab Runde 4 die fünf digitalen Mitarbeitenden selbst.
|
||||
children: AGENT_WORKSPACES.map(a => ({
|
||||
children: AGENT_NAV_ENTRIES.map(a => ({
|
||||
path: a.path,
|
||||
label: a.name,
|
||||
agentId: a.id,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Alert, Box, Chip, Divider, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, MapPin, Ruler } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Alert, Box, Button, Chip, Divider, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, MapPin, Ruler, Target } from 'lucide-react'
|
||||
import type { MarketLead } from '../../hooks/useMarketLeads'
|
||||
import { AgentAvatar, ObjectDeepLink } from '../team'
|
||||
import { agentWorkspaceById } from '../../lib/agentWorkspaces'
|
||||
import { SOURCE_TYPE_LABELS } from '../../lib/constants'
|
||||
import { isRisiko, leadSignalTypeOf, recommendedActionOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||
import { SOURCE_TYPE_LABELS, TEMPORAL_CLASS_LABELS } from '../../lib/constants'
|
||||
import { isRisiko, leadSignalTypeOf, signalRationaleOf } from '../../lib/leadSignal'
|
||||
import { MIN_MATCH_PCT, areaFitPct, confirmableContacts } from './marketLeadHelpers'
|
||||
import { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { ContactRow, SignalPropertyRow } from './MarketLeadPanels'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
@@ -28,8 +30,25 @@ const LIVIA = agentWorkspaceById('livia')!
|
||||
|
||||
interface LeadDetailProps {
|
||||
lead: MarketLead
|
||||
/**
|
||||
* Ganze Seite statt Schublade (Runde 10, §3.2).
|
||||
*
|
||||
* Nur ein Layoutschalter, keine zweite Ansicht: Dieselben Bausteine in
|
||||
* derselben Reihenfolge, nur ohne eigenen Bildlauf und mit mehr Platz.
|
||||
* Eine Vollbildfassung daneben zu bauen hiesse, jede künftige Änderung
|
||||
* zweimal zu machen.
|
||||
*/
|
||||
fullPage?: boolean
|
||||
/** Was unterhalb der Leadangaben erscheint — bei Nora der Property-Match-Bereich. */
|
||||
footer?: ReactNode
|
||||
/** Matching-Block anzeigen. Bei Nora ja, bei Livia nein (§1). */
|
||||
showMatching?: boolean
|
||||
/**
|
||||
* Übergabe an Nora (Runde 10, §2.14). Nur gesetzt, wo der Knopf hingehört —
|
||||
* auf Noras eigener Seite wäre «Matching starten» hier ein zweiter Weg zu
|
||||
* derselben Handlung, die dort schon im Kopf des Matching-Bereichs steht.
|
||||
*/
|
||||
onStartMatching?: (signalId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,11 +57,26 @@ interface LeadDetailProps {
|
||||
* Ein neues Signal ist damit eine neue Komponente und startet mit frischem
|
||||
* Zustand, statt ihn in einem Effekt zurücksetzen zu müssen.
|
||||
*/
|
||||
function LeadDetail({ lead, showMatching = true }: LeadDetailProps) {
|
||||
return <LeadDetailBody key={lead.signal.id} lead={lead} showMatching={showMatching} />
|
||||
function LeadDetail({ lead, showMatching = true, onStartMatching, fullPage = false, footer }: LeadDetailProps) {
|
||||
return (
|
||||
<LeadDetailBody
|
||||
key={lead.signal.id}
|
||||
lead={lead}
|
||||
showMatching={showMatching}
|
||||
onStartMatching={onStartMatching}
|
||||
fullPage={fullPage}
|
||||
footer={footer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching: boolean }) {
|
||||
function LeadDetailBody({ lead, showMatching, onStartMatching, fullPage, footer }: {
|
||||
lead: MarketLead
|
||||
showMatching: boolean
|
||||
onStartMatching?: (signalId: string) => void
|
||||
fullPage: boolean
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const { signal, matchingProperties } = lead
|
||||
const sourceLabel = SOURCE_TYPE_LABELS[signal.source.type] ?? signal.source.type
|
||||
const companyName = signal.companyName ?? signal.locationHint
|
||||
@@ -50,7 +84,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
const signalType = leadSignalTypeOf(signal)
|
||||
const risiko = isRisiko(signal)
|
||||
const rationale = signalRationaleOf(signal)
|
||||
const action = recommendedActionOf(signal)
|
||||
|
||||
// Objektvorschläge unterhalb der Schwelle werden gar nicht erst gezeigt.
|
||||
const relevant = useMemo(
|
||||
@@ -63,8 +96,13 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
|
||||
const contacts = useMemo(() => confirmableContacts(signal.extractedContacts), [signal.extractedContacts])
|
||||
|
||||
// Einzige Wahrheit ist `semanticSearchProfile`; die beiden anderen sind der
|
||||
// Rückfall für Leads, die vor Runde 10 erfasst wurden.
|
||||
const profilText = signal.semanticSearchProfile ?? signal.aiSummary ?? signal.strategicInterpretation
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', overflowY: 'auto' }}>
|
||||
// Auf der Seite scrollt die Seite, in der Schublade die Schublade.
|
||||
<Box sx={fullPage ? { width: '100%' } : { height: '100%', overflowY: 'auto' }}>
|
||||
{/* Header — ohne Wahrscheinlichkeitsangabe (§7.3) */}
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
|
||||
@@ -107,19 +145,48 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* Die Recherche stammt seit Runde 8 von Livia — das Kästchen trägt
|
||||
deshalb ihr Gesicht. Der Matching-Teil weiter unten bleibt Noras. */}
|
||||
{(signal.aiSummary ?? signal.strategicInterpretation) && (
|
||||
{/*
|
||||
Das erstellte Suchprofil (Runde 10, §§2.13B/3.1).
|
||||
|
||||
Ein Kasten, nicht zwei: Bis Runde 9 hiess er «Das hat Livia
|
||||
recherchiert» und trug ihre Zusammenfassung; seit Runde 10 steht
|
||||
darin das Nachfrageprofil, mit dem Nora arbeitet. Beides
|
||||
nebeneinander zu zeigen wären zwei Texte über denselben Bedarf, und
|
||||
beim ersten Widerspruch wüsste niemand, welcher gilt.
|
||||
|
||||
Der Rückfall auf `aiSummary` ist die Migration für Bestandsleads:
|
||||
Sie haben kein Profil, aber den Text, der bisher an dieser Stelle
|
||||
stand — er bleibt sichtbar, statt ein leeres Feld zu hinterlassen.
|
||||
*/}
|
||||
{profilText && (
|
||||
<Box sx={{ bgcolor: DS_SLATE[50], border: '1px solid #e2e8f0', borderRadius: 1.5, p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<AgentAvatar agent={{ id: LIVIA.id, name: LIVIA.name, role: LIVIA.role }} size="small" />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_TEXT.primary, fontSize: '0.75rem' }}>
|
||||
Das hat Livia recherchiert
|
||||
Erstelltes Suchprofil
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||
{signal.aiSummary ?? signal.strategicInterpretation}
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, whiteSpace: 'pre-line' }}>
|
||||
{profilText}
|
||||
</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 && !risiko && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -133,23 +200,62 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Bei Risiko: das betroffene Mietverhältnis benennen (§2) */}
|
||||
{risiko && (signal.affectedTenant || signal.propertyId) && (
|
||||
{/*
|
||||
Bei Risiko: das betroffene Mietverhältnis benennen (§2, Runde 10 §2.13A).
|
||||
Seit Runde 10 steht der Kasten auch dann, wenn nichts zuordenbar ist —
|
||||
mit genau diesem Satz. Ein fehlender Kasten liesse offen, ob niemand
|
||||
nachgesehen hat oder ob nichts betroffen ist; eine beliebige
|
||||
Mock-Immobilie zu zeigen wäre die schlechtere Antwort von beiden.
|
||||
*/}
|
||||
{risiko && (
|
||||
<Box sx={{ border: '1px solid', borderColor: DS_COLORS.leadSignal.RISIKO.fg, bgcolor: DS_COLORS.leadSignal.RISIKO.bg, borderRadius: 1.5, p: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_COLORS.leadSignal.RISIKO.fg, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Betroffenes Mietverhältnis
|
||||
Betroffenes Objekt
|
||||
</Typography>
|
||||
{signal.affectedTenant && (
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 0.25 }}>
|
||||
{signal.affectedTenant}
|
||||
</Typography>
|
||||
)}
|
||||
{signal.propertyId && (
|
||||
{signal.propertyId ? (
|
||||
<ObjectDeepLink propertyId={signal.propertyId} label="Objekt in «Meine Objekte» öffnen" />
|
||||
) : (
|
||||
!signal.affectedTenant && (
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800] }}>
|
||||
Kein internes Objekt eindeutig zugeordnet.
|
||||
</Typography>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Zeitliche Einordnung mit Fundstelle (Runde 10, §2.9) */}
|
||||
{signal.temporalClass && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Zeitliche Relevanz
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={TEMPORAL_CLASS_LABELS[signal.temporalClass]}
|
||||
size="small"
|
||||
sx={{ bgcolor: DS_SLATE[100], color: DS_SLATE[700], fontSize: '0.75rem', fontWeight: 600 }}
|
||||
/>
|
||||
{signal.eventDate && (
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[600] }}>
|
||||
Ereigniszeit laut Quelle: {signal.eventDate}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{signal.temporalEvidence && (
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mt: 0.75 }}>
|
||||
{signal.temporalEvidence}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
{/* Bestätigte Angaben — unbestätigte Fakten werden weggelassen (§7.3) */}
|
||||
{(signal.confirmedFacts?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
@@ -215,16 +321,6 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nächste Handlung — der eigentliche Zweck des Leads (§2) */}
|
||||
<Box sx={{ bgcolor: 'white', border: '1px solid', borderColor: DS_BRAND.main, borderRadius: 1.5, p: 2 }}>
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DS_BRAND.main, textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.75, fontSize: '0.65rem' }}>
|
||||
Empfohlene nächste Handlung
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6 }}>
|
||||
{action}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Matching bleibt Noras Arbeit — auf Livias Seite ausgeblendet (§1). */}
|
||||
{showMatching && (
|
||||
<>
|
||||
@@ -248,11 +344,16 @@ function LeadDetailBody({ lead, showMatching }: { lead: MarketLead; showMatching
|
||||
</>
|
||||
)}
|
||||
|
||||
{signal.disclaimer && (
|
||||
<Typography variant="caption" color="text.disabled" sx={{ lineHeight: 1.4 }}>
|
||||
{signal.disclaimer}
|
||||
</Typography>
|
||||
)}
|
||||
{/*
|
||||
An dieser Stelle stand bis Runde 9 der Quellen-Vermerk des Signals
|
||||
(«Aus öffentlichen Meldungen verdichtet …»). Er ist dem einheitlichen
|
||||
KI-Hinweis gewichen: Die Herkunft steht ohnehin oben bei der Quelle,
|
||||
und ein Satz je Signal war nie derselbe — der Hinweis auf mögliche
|
||||
Fehler soll aber überall gleich lauten (§4, §5).
|
||||
*/}
|
||||
<AiDisclaimer />
|
||||
|
||||
{footer}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Property On — Noras Property-Match-Bereich (Runde 10, §§3.3–3.8).
|
||||
*
|
||||
* Sitzt unterhalb der Leadangaben auf der Detailseite und beantwortet genau
|
||||
* eine Frage: Welche Objekte aus dem eigenen Bestand passen zu diesem Bedarf?
|
||||
*
|
||||
* Drei Entscheidungen, die hier sichtbar werden:
|
||||
*
|
||||
* 1. **Vorher steht nichts da.** Kein vorab gerechneter Vorschlag, kein
|
||||
* Beispielergebnis. Ein Matching ist eine Handlung, und was ohne Handlung
|
||||
* erscheint, hätte niemand veranlasst.
|
||||
* 2. **Die Liste ist dieselbe wie «Meine Objekte».** Dieselbe Tabelle,
|
||||
* dieselben Filter, dieselben Spalten — ergänzt um eine erste Spalte
|
||||
* «Match». Eine eigene Trefferliste zu bauen hiesse, denselben Bestand
|
||||
* zweimal unterschiedlich darzustellen.
|
||||
* 3. **Belegte Objekte bleiben aussen vor, bis jemand sie will.** Der Schalter
|
||||
* dient der Frage, ob für eine vermietete Fläche konkurrierende Nachfrage
|
||||
* besteht — eine Information, kein Vorgang.
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert, Box, Button, CircularProgress, FormControlLabel, IconButton,
|
||||
Popover, Switch, Typography,
|
||||
} from '@mui/material'
|
||||
import { Info, Target } from 'lucide-react'
|
||||
import { PropertyFilterBar } from '../supply/PropertyFilterBar'
|
||||
import type { PropertyTableFilters } from '../supply/PropertyFilterBar'
|
||||
import { PropertyTable } from '../supply/PropertyTable'
|
||||
import { ObjectDeepLink } from '../team'
|
||||
import { useProperties } from '../../hooks/useProperties'
|
||||
import { useRunMatching } from '../../hooks/useMatchRuns'
|
||||
import type { MatchLaufErgebnis } from '../../features/matching/leadPropertyMatcher'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
import type { Property } from '../../domain/property'
|
||||
import { isRisiko } from '../../lib/leadSignal'
|
||||
import { DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
const FOCUS_SX = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
|
||||
|
||||
/**
|
||||
* Der Erklärtext zum Match-Wert (§3.8).
|
||||
*
|
||||
* Er beschreibt, was `leadPropertyMatcher.ts` tatsächlich tut. Der letzte Satz
|
||||
* ist der wichtigste: Es gibt keine feste Prozentgewichtung, weil nur die
|
||||
* Kriterien gerechnet werden, die sich am jeweiligen Objekt prüfen liessen.
|
||||
*/
|
||||
const MATCH_INFO =
|
||||
'Der Match-Wert zeigt, wie gut das von Livia erstellte Nachfrageprofil mit den verfügbaren Informationen '
|
||||
+ 'eines Objekts übereinstimmt. Aus dem Suchprofil werden die Anforderungen gelesen, die darin tatsächlich '
|
||||
+ 'vorkommen — Standort, Fläche, Nutzungsart und, wo das Profil sie nennt, Erreichbarkeit, Parkierung, '
|
||||
+ 'Ausbaustand und Sichtbarkeit. Dazu kommt immer die Verfügbarkeit. '
|
||||
+ 'Fehlende Angaben werden nicht als erfüllt angenommen: Sagt der Bestand zu einem Merkmal nichts, zählt es '
|
||||
+ 'nicht als Nichterfüllung, senkt aber die Abdeckung — und je weniger geprüft werden konnte, desto stärker '
|
||||
+ 'wird der Wert gedämpft. Ein Objekt, von dem nur die Verfügbarkeit bekannt ist, erreicht deshalb keine hohen Werte. '
|
||||
+ 'Belegte Objekte sind ausgeschlossen, solange sie nicht ausdrücklich einbezogen werden. '
|
||||
+ 'Es gibt keine feste Prozentgewichtung je Kriterium, weil je Objekt unterschiedlich viel prüfbar ist.'
|
||||
|
||||
function MatchInfoButton() {
|
||||
const [anker, setAnker] = useState<HTMLElement | null>(null)
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Wie entsteht der Match-Wert?"
|
||||
onClick={(e) => setAnker(e.currentTarget)}
|
||||
sx={{ color: DS_TEXT.muted, ...FOCUS_SX }}
|
||||
>
|
||||
<Info size={15} />
|
||||
</IconButton>
|
||||
<Popover
|
||||
open={anker !== null}
|
||||
anchorEl={anker}
|
||||
onClose={() => setAnker(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
slotProps={{ paper: { sx: { maxWidth: 520, p: 2, boxShadow: DS_SHADOW.panel } } }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>
|
||||
{MATCH_INFO}
|
||||
</Typography>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
signal: FutureSignal
|
||||
/**
|
||||
* Aus Livias «Matching starten» gekommen (§3.3). Dann läuft das Matching
|
||||
* genau einmal von selbst — beim normalen Öffnen nie.
|
||||
*/
|
||||
autoStart: boolean
|
||||
}
|
||||
|
||||
export const PropertyMatchSection = memo(function PropertyMatchSection({ signal, autoStart }: Props) {
|
||||
const { data: properties = [], isLoading } = useProperties()
|
||||
const runMatching = useRunMatching()
|
||||
|
||||
const [ergebnis, setErgebnis] = useState<MatchLaufErgebnis | null>(null)
|
||||
const [includeOccupied, setIncludeOccupied] = useState(false)
|
||||
const [filters, setFilters] = useState<PropertyTableFilters>({})
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Sperre gegen einen zweiten selbsttätigen Lauf.
|
||||
*
|
||||
* React baut Komponenten im Entwicklungsmodus doppelt auf, und jeder
|
||||
* Filterwechsel rendert neu. Ohne diese Sperre stünden im Protokoll zwei
|
||||
* Einträge für eine Übergabe — und die Kennzahl zählte doppelt (§3.13).
|
||||
*/
|
||||
const autoGelaufen = useRef(false)
|
||||
|
||||
const starten = useCallback(
|
||||
(belegteMit: boolean, trigger: 'USER' | 'LIVIA_HANDOVER') => {
|
||||
runMatching.mutate(
|
||||
{ signal, properties, options: { includeOccupied: belegteMit }, trigger },
|
||||
{ onSuccess: (res) => setErgebnis(res.data.ergebnis) },
|
||||
)
|
||||
},
|
||||
[runMatching, signal, properties],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoStart || autoGelaufen.current || isLoading || properties.length === 0) return
|
||||
autoGelaufen.current = true
|
||||
starten(false, 'LIVIA_HANDOVER')
|
||||
}, [autoStart, isLoading, properties.length, starten])
|
||||
|
||||
/** Umschalten rechnet neu — die Frage hat sich geändert, also auch die Antwort. */
|
||||
const handleToggleOccupied = useCallback(
|
||||
(checked: boolean) => {
|
||||
setIncludeOccupied(checked)
|
||||
if (ergebnis) starten(checked, 'USER')
|
||||
},
|
||||
[ergebnis, starten],
|
||||
)
|
||||
|
||||
/** Match-Werte je Objekt — die Tabelle braucht sie als Karte. */
|
||||
const scores = useMemo(() => {
|
||||
if (!ergebnis) return undefined
|
||||
return new Map(ergebnis.matches.map(m => [m.property.id, m.score]))
|
||||
}, [ergebnis])
|
||||
|
||||
/**
|
||||
* Die Trefferliste — nach Match absteigend, solange nichts anderes gewählt ist.
|
||||
*
|
||||
* Filterleiste und Spaltenköpfe wirken wie in «Meine Objekte»: Sie ordnen
|
||||
* und schränken die Anzeige ein, ohne den Lauf zu verändern. Wer umsortiert,
|
||||
* sieht dieselben Objekte in anderer Reihenfolge, nicht andere Werte.
|
||||
*
|
||||
* Die Sortierung muss hier passieren und nicht in der Tabelle: Die Tabelle
|
||||
* bekommt ihre Zeilen fertig geordnet und hat auch in «Meine Objekte» nie
|
||||
* selbst sortiert. Ohne diesen Block wären die Spaltenköpfe anklickbar und
|
||||
* ohne Wirkung — schlimmer als gar keine Sortierung.
|
||||
*/
|
||||
const sichtbar = useMemo((): Property[] => {
|
||||
if (!ergebnis) return []
|
||||
const suche = filters.search?.trim().toLowerCase()
|
||||
|
||||
const gefiltert = ergebnis.matches
|
||||
.map(m => m.property)
|
||||
.filter(p => {
|
||||
if (filters.assetTypes?.length && !filters.assetTypes.includes(p.assetType)) return false
|
||||
if (filters.availabilityStatus && p.availabilityStatus !== filters.availabilityStatus) return false
|
||||
if (suche) {
|
||||
const heuhaufen = `${p.title} ${p.location.city} ${p.address.street} ${p.currentTenant ?? ''}`.toLowerCase()
|
||||
if (!heuhaufen.includes(suche)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (!filters.sortBy) return gefiltert
|
||||
|
||||
const wertVon = (p: Property): number => {
|
||||
switch (filters.sortBy) {
|
||||
case 'area': return p.areaSqm
|
||||
case 'rent': return p.rentPricePerSqm
|
||||
case 'dataQuality': return p.dataQuality.score
|
||||
case 'confidence': return p.confidenceScore
|
||||
case 'availability': return new Date(p.availabilityDate).getTime()
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
const richtung = filters.sortDir === 'asc' ? 1 : -1
|
||||
return [...gefiltert].sort((a, b) => (wertVon(a) - wertVon(b)) * richtung)
|
||||
}, [ergebnis, filters])
|
||||
|
||||
const risiko = isRisiko(signal)
|
||||
|
||||
return (
|
||||
<Box sx={{ borderTop: `1px solid ${DS_BORDER.default}`, pt: 3, mt: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 1 }}>
|
||||
<Typography component="h2" sx={{ fontWeight: 700, fontSize: '1.0625rem', color: DS_TEXT.primary }}>
|
||||
Property Match
|
||||
</Typography>
|
||||
<MatchInfoButton />
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={runMatching.isPending || isLoading}
|
||||
startIcon={runMatching.isPending
|
||||
? <CircularProgress size={13} color="inherit" />
|
||||
: <Target size={14} aria-hidden />}
|
||||
onClick={() => starten(includeOccupied, 'USER')}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, ml: { sm: 'auto' }, ...FOCUS_SX }}
|
||||
>
|
||||
{runMatching.isPending ? 'Wird abgeglichen …' : 'Matching starten'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Bei einem Risiko ist die Frage eine andere — das sagt der Text, nicht die Liste. */}
|
||||
{risiko && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem', mb: 1.5 }}>
|
||||
{signal.propertyId
|
||||
? 'Negatives Signal mit zugeordnetem Objekt. Das Matching zeigt hier Alternativen für eine mögliche Wiedervermietung.'
|
||||
: 'Negatives Signal ohne eindeutig zugeordnetes Objekt. Das Matching zeigt, welche Flächen im Bestand zum beschriebenen Bedarf passen würden.'}
|
||||
{signal.propertyId && (
|
||||
<Box sx={{ mt: 0.75 }}>
|
||||
<ObjectDeepLink propertyId={signal.propertyId} label="Betroffenes Objekt öffnen" />
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={includeOccupied}
|
||||
disabled={runMatching.isPending}
|
||||
onChange={(_, checked) => handleToggleOccupied(checked)}
|
||||
/>
|
||||
}
|
||||
label="Belegte Objekte einbeziehen"
|
||||
slotProps={{ typography: { variant: 'body2', sx: { color: DS_TEXT.secondary } } }}
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
{!ergebnis ? (
|
||||
<Box
|
||||
sx={{
|
||||
border: `1px solid ${DS_BORDER.default}`, borderRadius: 2, p: 3, textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Noch kein Abgleich durchgeführt. «Matching starten» vergleicht das Suchprofil mit dem
|
||||
Objektbestand aus «Meine Objekte».
|
||||
</Typography>
|
||||
</Box>
|
||||
) : ergebnis.matches.length === 0 ? (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||
Kein Objekt im Kandidatenkreis.
|
||||
{!includeOccupied && ' Mit «Belegte Objekte einbeziehen» wird der Kreis grösser.'}
|
||||
</Alert>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`${ergebnis.candidateCount} ${ergebnis.candidateCount === 1 ? 'Objekt' : 'Objekte'} abgeglichen · `}
|
||||
{`bester Wert ${ergebnis.matches[0].score} %`}
|
||||
{sichtbar.length !== ergebnis.matches.length && ` · ${sichtbar.length} nach Filter sichtbar`}
|
||||
</Typography>
|
||||
|
||||
<PropertyFilterBar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
<PropertyTable
|
||||
properties={sichtbar}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
matchScores={scores}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -12,3 +12,4 @@ export { LeadSignalTypeBadge } from './LeadSignalTypeBadge'
|
||||
export { LeadChannelFilterBar } from './LeadChannelFilterBar'
|
||||
export { UnifiedLeadList } from './UnifiedLeadList'
|
||||
export { CrmLeadDetail } from './CrmLeadDetail'
|
||||
export { PropertyMatchSection } from './PropertyMatchSection'
|
||||
@@ -0,0 +1,120 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { ExternalLink, MapPin } from 'lucide-react'
|
||||
import type { ResearchLead } from '../../domain/researchLead'
|
||||
import { RESEARCH_EVENT_TYPE_LABELS, RESEARCH_RELEVANCE_LABELS } from '../../lib/constants'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Ein aus einer echten Quelle abgeleiteter Lead (§16).
|
||||
*
|
||||
* Die Karte beantwortet in fester Reihenfolge: was ist passiert, bei wem, wo,
|
||||
* warum ist es für Gewerbeflächen relevant, wie sicher ist die Einordnung und
|
||||
* woher stammt sie. Der Quellenlink steht unten und ist echt — er zeigt auf
|
||||
* genau die Seite, die Livia gelesen hat.
|
||||
*
|
||||
* Confidence steht als Zahl da und nicht als Balken: ein Balken suggeriert
|
||||
* Messgenauigkeit, die eine Modellschätzung nicht hat.
|
||||
*/
|
||||
export const ResearchLeadCard = memo(function ResearchLeadCard({ lead }: { lead: ResearchLead }) {
|
||||
const hoch = lead.relevance === 'high'
|
||||
const akzent = hoch ? DS_COLORS.leadSignal.CHANCE : { bg: DS_SLATE[100], fg: DS_SLATE[600] }
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid #e2e8f0',
|
||||
borderLeft: '3px solid',
|
||||
borderLeftColor: hoch ? DS_COLORS.leadSignal.CHANCE.fg : DS_SLATE[300],
|
||||
borderRadius: 1.5,
|
||||
bgcolor: 'white',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, color: DS_SLATE[900], lineHeight: 1.35, mb: 0.75 }}>
|
||||
{lead.headline}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.25, alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={RESEARCH_EVENT_TYPE_LABELS[lead.eventType] ?? lead.eventType}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: akzent.bg, color: akzent.fg }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{RESEARCH_RELEVANCE_LABELS[lead.relevance]}
|
||||
</Typography>
|
||||
{lead.location && (
|
||||
<Chip
|
||||
icon={<MapPin size={11} />}
|
||||
label={lead.location}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20, fontSize: '0.72rem', bgcolor: DS_SLATE[100], color: DS_SLATE[600],
|
||||
'& .MuiChip-icon': { color: DS_SLATE[600], ml: 0.5, mr: -0.25 },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mb: 0.25 }}>
|
||||
Unternehmen
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], mb: 1 }}>
|
||||
{lead.company}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.6, mb: 1.25 }}>
|
||||
{lead.summary}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_SLATE[500], textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 0.25, fontSize: '0.65rem' }}
|
||||
>
|
||||
Warum relevant
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: DS_SLATE[800], lineHeight: 1.55, mb: 1.5 }}>
|
||||
{lead.reason}
|
||||
</Typography>
|
||||
|
||||
{/*
|
||||
Der Block «Empfohlene nächste Handlung» ist mit Runde 9 (§4) entfallen.
|
||||
Das Feld bleibt im Datenmodell und wird weiterhin vom Modell befüllt —
|
||||
entfernt ist allein die Anzeige, damit die Karte auf das Beobachtete
|
||||
beschränkt bleibt und nicht als Anweisung gelesen wird.
|
||||
*/}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1,
|
||||
pt: 1.25, borderTop: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||
Quelle: <strong>{lead.source}</strong>
|
||||
{lead.publishedAt && ` · ${new Date(lead.publishedAt).toLocaleDateString('de-CH')}`}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_SLATE[500] }}>
|
||||
Confidence: <strong>{Math.round(lead.confidence * 100)} %</strong>
|
||||
</Typography>
|
||||
<Box
|
||||
component="a"
|
||||
href={lead.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, ml: 'auto',
|
||||
color: DS_BRAND.main, fontSize: '0.75rem', fontWeight: 600, textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Originalquelle öffnen <ExternalLink size={11} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Chip, Typography } from '@mui/material'
|
||||
import { CheckCircle2, ExternalLink, XCircle } from 'lucide-react'
|
||||
import type { ResearchSourceResult } from '../../domain/researchLead'
|
||||
import { RESEARCH_SOURCE_LIST } from '../../lib/researchSources'
|
||||
import { DS_BRAND, DS_COLORS, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
interface Props {
|
||||
/** Ergebnisse des letzten Laufs; vor dem ersten Lauf leer. */
|
||||
results?: ResearchSourceResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* «Angebundene Kanäle & Systeme» auf Livias Arbeitsplatz (§2, §15).
|
||||
*
|
||||
* Zeigt die drei real abgerufenen Quellen mit Namen, Status und anklickbarem
|
||||
* Originallink. Die Links stehen hier nicht als Zierde: wer eine Aussage von
|
||||
* Livia prüfen will, soll die Quelle in einem Klick öffnen können, ohne sie
|
||||
* selbst suchen zu müssen.
|
||||
*
|
||||
* Vor dem ersten Lauf steht «Bereit» und nicht «Verbunden» — verbunden ist,
|
||||
* was tatsächlich geantwortet hat, und das weiss man erst danach.
|
||||
*/
|
||||
export const ResearchSourcesPanel = memo(function ResearchSourcesPanel({ results }: Props) {
|
||||
return (
|
||||
<Box sx={{ border: '1px solid #e2e8f0', borderRadius: 1.5, bgcolor: 'white', overflow: 'hidden' }}>
|
||||
<Box sx={{ px: 2, py: 1, bgcolor: DS_SLATE[50], borderBottom: '1px solid #e2e8f0' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 700, color: DS_TEXT.muted, textTransform: 'uppercase', letterSpacing: 0.5, fontSize: '0.65rem' }}
|
||||
>
|
||||
Angebundene Kanäle & Systeme
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{RESEARCH_SOURCE_LIST.map(quelle => {
|
||||
const ergebnis = results?.find(r => r.id === quelle.id)
|
||||
return (
|
||||
<Box
|
||||
key={quelle.id}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 1.5,
|
||||
px: 2, py: 1.5,
|
||||
borderBottom: '1px solid #f1f5f9',
|
||||
'&:last-of-type': { borderBottom: 'none' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_SLATE[900], lineHeight: 1.3 }}>
|
||||
{quelle.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', lineHeight: 1.4 }}>
|
||||
{quelle.purpose}
|
||||
</Typography>
|
||||
<Box
|
||||
component="a"
|
||||
href={quelle.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, mt: 0.5,
|
||||
color: DS_BRAND.main, fontSize: '0.72rem', fontWeight: 600,
|
||||
textDecoration: 'none', wordBreak: 'break-all',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{quelle.url} <ExternalLink size={11} style={{ flexShrink: 0 }} />
|
||||
</Box>
|
||||
{ergebnis?.error && (
|
||||
<Typography variant="caption" sx={{ color: DS_COLORS.leadSignal.RISIKO.fg, display: 'block', mt: 0.25 }}>
|
||||
{ergebnis.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexShrink: 0, pt: 0.25 }}>
|
||||
{ergebnis === undefined ? (
|
||||
<Chip
|
||||
label="Bereit"
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 700, bgcolor: DS_SLATE[100], color: DS_SLATE[600] }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
icon={ergebnis.ok ? <CheckCircle2 size={11} /> : <XCircle size={11} />}
|
||||
/*
|
||||
* Mit Einheit statt nur der Zahl: «Live · 24» liest sich sonst
|
||||
* genauso gut als «24 gefundene Leads», und genau so wurde es
|
||||
* beim ersten Zeigen auch verstanden.
|
||||
*/
|
||||
label={ergebnis.ok
|
||||
? `Live · ${ergebnis.itemCount} ${ergebnis.itemCount === 1 ? 'Eintrag' : 'Einträge'}`
|
||||
: 'Nicht erreichbar'}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20, fontSize: '0.68rem', fontWeight: 700,
|
||||
bgcolor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.bg : DS_COLORS.leadSignal.RISIKO.bg,
|
||||
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
border: '1px solid',
|
||||
borderColor: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
'& .MuiChip-icon': {
|
||||
color: ergebnis.ok ? DS_COLORS.leadSignal.CHANCE.fg : DS_COLORS.leadSignal.RISIKO.fg,
|
||||
ml: 0.5, mr: -0.25,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -1,27 +1,45 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Alert, Box, Button, CircularProgress, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { LeadDetail, UnifiedLeadList } from '../market-leads'
|
||||
import { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||
import { useUnifiedLeads } from '../../hooks/useUnifiedLeads'
|
||||
import { useLiviaResearch } from '../../hooks/useLiviaResearch'
|
||||
import { useResearchDocuments } from '../../hooks/useResearchDocuments'
|
||||
import { useTeamAgent } from '../../hooks/useTeamAgents'
|
||||
import { leseQuellenAus } from '../../lib/agentSystems'
|
||||
import { LeadChannel } from '../../domain/unifiedLead'
|
||||
import type { UnifiedLead } from '../../domain/unifiedLead'
|
||||
import { LeadSignalType } from '../../domain/futureSignal'
|
||||
import { DS_TEXT } from '../../lib/ds'
|
||||
import { TemporalClass } from '../../domain/researchLead'
|
||||
import type { TemporalClass as TemporalClassValue } from '../../domain/researchLead'
|
||||
import { ROUTES } from '../../lib/constants'
|
||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/** Kennung der Einstellung, die die erlaubten Ereigniszeiten führt (§2.9). */
|
||||
const TEMPORAL_SETTING_ID = 'livia-set-temporal-relevance'
|
||||
|
||||
const GUELTIGE_KLASSEN = new Set<string>(Object.values(TemporalClass))
|
||||
|
||||
/**
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1).
|
||||
* Livias Recherche-Arbeitsplatz (Runde 8, §1; Research-PoC §15; Runde 10, §2.12).
|
||||
*
|
||||
* Die Liste ist dieselbe Komponente wie bei Nora — bewusst, damit sich beide
|
||||
* Arbeitsplätze gleich lesen und eine spätere Änderung nur an einer Stelle
|
||||
* anfällt. Zwei Unterschiede: die Kanalspalte entfällt, weil hier jeder Eintrag
|
||||
* aus Livias eigener Recherche stammt, und die Detailansicht läuft mit
|
||||
* `showMatching={false}`.
|
||||
* 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.
|
||||
*
|
||||
* Damit ist die Grenze zwischen den beiden Agentinnen im Code gezogen und nicht
|
||||
* bloss in der Beschriftung: Livia recherchiert und verdichtet, das Matching
|
||||
* gegen den eigenen Bestand bleibt Noras Arbeit (§1, §2).
|
||||
* Jetzt wandert ein Lauf direkt in den zentralen Signalbestand. Was oben steht,
|
||||
* ist nur noch der Bericht über den Lauf selbst: welche Quellen antworteten,
|
||||
* wie viel gelesen und wie viel verworfen wurde. Die Leads stehen dort, wo alle
|
||||
* Leads stehen.
|
||||
*
|
||||
* Gelesen wird aus demselben Bestand, den Nora anzeigt. Es gibt keine zweite
|
||||
* Lead-Ablage und keine Übergabekopie — ein Lead, zwei Arbeitsplätze.
|
||||
* Welche Quellen abgerufen werden, entscheidet nicht diese Komponente, sondern
|
||||
* Livias Personalblatt: jeder aktive Lesezugang mit Adresse, dazu die manuell
|
||||
* abgelegten Dokumente. Damit ist die Anzeige unter «Systeme» keine Behauptung,
|
||||
* sondern die Eingabe des Laufs.
|
||||
*/
|
||||
export function ResearchWorkspace() {
|
||||
const theme = useTheme()
|
||||
@@ -29,6 +47,10 @@ export function ResearchWorkspace() {
|
||||
|
||||
const [selected, setSelected] = useState<UnifiedLead | null>(null)
|
||||
const { data: leads, isLoading } = useUnifiedLeads()
|
||||
const { data: livia } = useTeamAgent('livia')
|
||||
const { data: dokumente = [] } = useResearchDocuments()
|
||||
const refresh = useLiviaResearch()
|
||||
const ergebnis = refresh.data
|
||||
|
||||
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||
const { research, chancen, risiken } = useMemo(() => {
|
||||
@@ -44,25 +66,130 @@ export function ResearchWorkspace() {
|
||||
return { research: rows, chancen: c, risiken: r }
|
||||
}, [leads])
|
||||
|
||||
/** Die Lesequellen kommen aus dem gespeicherten Stand, nicht aus einer festen Liste. */
|
||||
const quellen = useMemo(() => leseQuellenAus(livia?.systems ?? []), [livia?.systems])
|
||||
|
||||
/** Die erlaubten Ereigniszeiten stehen als Einstellung am Personalblatt. */
|
||||
const zeitfenster = useMemo((): TemporalClassValue[] => {
|
||||
const wert = livia?.settings.find(s => s.id === TEMPORAL_SETTING_ID)?.value
|
||||
if (!Array.isArray(wert)) return []
|
||||
return wert.filter((v): v is TemporalClassValue => GUELTIGE_KLASSEN.has(v))
|
||||
}, [livia?.settings])
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
/**
|
||||
* Übergabe an Nora (Runde 10, §2.14).
|
||||
*
|
||||
* Weitergereicht wird nur die Lead-ID — Nora liest denselben Eintrag aus
|
||||
* demselben Bestand, es entsteht keine Kopie. Das Kennzeichen `matching=1`
|
||||
* sagt ihr, dass der Lauf sofort starten darf; ohne dieses Kennzeichen läuft
|
||||
* beim normalen Öffnen nichts von selbst.
|
||||
*
|
||||
* Beides steht in der Adresse und nicht im Navigationszustand: So überlebt
|
||||
* die Übergabe ein Neuladen, und der Link lässt sich weitergeben.
|
||||
*/
|
||||
const handleStartMatching = useCallback(
|
||||
(signalId: string) => {
|
||||
navigate(`${ROUTES.SUPPLY.AGENT_NORA}?lead=${encodeURIComponent(signalId)}&matching=1`)
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const closeDetail = useCallback(() => setSelected(null), [])
|
||||
const starten = useCallback(
|
||||
() => refresh.mutate({ sources: quellen, documents: dokumente, temporalClasses: zeitfenster }),
|
||||
[refresh, quellen, dokumente, zeitfenster],
|
||||
)
|
||||
|
||||
const keineQuellen = quellen.length === 0 && dokumente.length === 0
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{isLoading
|
||||
? 'Leads werden geladen …'
|
||||
: `${research.length} ${research.length === 1 ? 'Lead' : 'Leads'} aus externen Quellen · ` +
|
||||
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* ── Der Lauf ────────────────────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={refresh.isPending ? <CircularProgress size={14} color="inherit" /> : <RefreshCw size={15} />}
|
||||
disabled={refresh.isPending || keineQuellen}
|
||||
onClick={starten}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Research aktualisieren
|
||||
</Button>
|
||||
|
||||
<UnifiedLeadList
|
||||
leads={research}
|
||||
isLoading={isLoading}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
showChannel={false}
|
||||
emptyText="Noch keine Leads aus der Marktbeobachtung. Sobald eine verknüpfte Quelle eine relevante Veränderung meldet, erscheint sie hier."
|
||||
/>
|
||||
{refresh.isPending && (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Livia liest aktuelle Marktinformationen …
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!refresh.isPending && !ergebnis && !keineQuellen && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`${quellen.length} ${quellen.length === 1 ? 'aktive Lesequelle' : 'aktive Lesequellen'}`}
|
||||
{dokumente.length > 0 &&
|
||||
` · ${dokumente.length} ${dokumente.length === 1 ? 'abgelegtes Dokument' : 'abgelegte Dokumente'}`}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!refresh.isPending && ergebnis && (
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Information' : 'Informationen'} analysiert · `}
|
||||
{`${ergebnis.discarded} nicht relevant · `}
|
||||
{`${ergebnis.watchlist} Watchlist · `}
|
||||
{ergebnis.temporalFiltered > 0 && `${ergebnis.temporalFiltered} zeitlich ausgeschlossen · `}
|
||||
{`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead übernommen' : 'Leads übernommen'}`}
|
||||
{` · Stand ${new Date(ergebnis.refreshedAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' })}`}
|
||||
{ergebnis.model && ` · Modell ${ergebnis.model}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{keineQuellen && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
Für Livia ist derzeit keine aktive Lesequelle mit Adresse hinterlegt und kein Dokument abgelegt.
|
||||
Quellen werden im Personalblatt unter «Kanäle & Systeme» angebunden, Dokumente unter
|
||||
«Einstellungen».
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ResearchSourcesPanel results={ergebnis?.sources} />
|
||||
|
||||
{ergebnis?.analysisError && (
|
||||
<Alert severity="warning" sx={{ fontSize: '0.8rem' }}>
|
||||
Die Quellen wurden gelesen, die Beurteilung konnte aber nicht laufen: {ergebnis.analysisError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{ergebnis && ergebnis.leads.length === 0 && !ergebnis.analysisError && (
|
||||
<Alert severity="info" sx={{ fontSize: '0.8rem' }}>
|
||||
Aus diesem Lauf ergab sich keine Entwicklung mit plausiblem Gewerbeimmobilienbezug.
|
||||
Gelesen wurden {ergebnis.analyzed} Einträge.
|
||||
{ergebnis.temporalFiltered > 0 &&
|
||||
` ${ergebnis.temporalFiltered} davon fielen durch die eingestellte zeitliche Relevanz.`}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── Die eine Leadliste ──────────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1, borderTop: `1px solid ${DS_SLATE[200]}` }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted }}>
|
||||
{isLoading
|
||||
? 'Leads werden geladen …'
|
||||
: `${research.length} ${research.length === 1 ? 'erfasster Lead' : 'erfasste Leads'} · ` +
|
||||
`${chancen} ${chancen === 1 ? 'Chance' : 'Chancen'}, ${risiken} ${risiken === 1 ? 'Risiko' : 'Risiken'}`}
|
||||
</Typography>
|
||||
|
||||
<UnifiedLeadList
|
||||
leads={research}
|
||||
isLoading={isLoading}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
showChannel={false}
|
||||
emptyText="Noch keine Leads. «Research aktualisieren» liest die angebundenen Quellen und legt gefundene Leads hier ab."
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
@@ -81,6 +208,7 @@ export function ResearchWorkspace() {
|
||||
<LeadDetail
|
||||
lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }}
|
||||
showMatching={false}
|
||||
onStartMatching={handleStartMatching}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Livia — Markt-Recherche (Runde 8, §1). Barrel-Export (CLAUDE.md §13).
|
||||
// Livia — Markt-Recherche (Runde 8, §1; Research-PoC). Barrel-Export (CLAUDE.md §13).
|
||||
|
||||
export { ResearchWorkspace } from './ResearchWorkspace'
|
||||
export { ResearchSourcesPanel } from './ResearchSourcesPanel'
|
||||
export { ResearchLeadCard } from './ResearchLeadCard'
|
||||
@@ -159,7 +159,19 @@ export function MeetingRoom3D() {
|
||||
}
|
||||
}, [positionLabels])
|
||||
|
||||
// ── Maus: Parallaxe und Trefferprüfung ──
|
||||
/**
|
||||
* Maus: nur noch Trefferprüfung, keine Bewegung mehr (Runde 9, §1).
|
||||
*
|
||||
* Bis hierher folgte die Kamera dem Zeiger — eine leichte Parallaxe, die den
|
||||
* Raum lebendig wirken lassen sollte. Gewünscht ist jetzt eine vollständig
|
||||
* ruhige Szene: Wer die Agenten anschaut, soll sie anschauen und nicht ein
|
||||
* Bild, das unter dem Zeiger wegkippt.
|
||||
*
|
||||
* Entfernt ist ausschliesslich der Aufruf von `applyParallax` — die Szene
|
||||
* selbst, ihre Kameraposition, Sitzordnung und Beschriftungen bleiben
|
||||
* unverändert. Das Erkennen des Agenten unter dem Zeiger und der Klick auf
|
||||
* seinen Platz sind keine Bewegung und bleiben deshalb.
|
||||
*/
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const s = sceneRef.current
|
||||
const wrap = wrapRef.current
|
||||
@@ -169,8 +181,6 @@ export function MeetingRoom3D() {
|
||||
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||||
const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1)
|
||||
|
||||
s.applyParallax(nx, ny)
|
||||
|
||||
const ray = new THREE.Raycaster()
|
||||
ray.setFromCamera(new THREE.Vector2(nx, ny), s.camera)
|
||||
const hit = ray.intersectObjects(s.seats.map(h => h.hitBox), false)[0]
|
||||
@@ -187,7 +197,6 @@ export function MeetingRoom3D() {
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
const s = sceneRef.current
|
||||
if (!s) return
|
||||
s.applyParallax(0, 0)
|
||||
hoveredRef.current = null
|
||||
s.setHovered(null)
|
||||
setHovered(null)
|
||||
|
||||
@@ -65,11 +65,12 @@ export const CAMERA = {
|
||||
maxHFov: 84,
|
||||
/** Blickziel: leicht unter Augenhöhe, damit die Tischplatte Tiefe zeigt. */
|
||||
lookAt: [0, 1.24, -5.2] as const,
|
||||
/**
|
||||
* Maximaler Kameraversatz der Parallaxe in Metern. Bewusst winzig: die
|
||||
* Perspektive ist verbindlich, die Bewegung darf sie nur beleben (§20).
|
||||
/*
|
||||
* Die Parallaxe ist mit Runde 9 (§1) entfallen: Die Kamera folgte dem Zeiger
|
||||
* um bis zu 7,5 Zentimeter, was den Raum beleben sollte und stattdessen als
|
||||
* Wackeln wahrgenommen wurde. Kameraposition und Blickziel bleiben exakt
|
||||
* dieselben — nur bewegt sich nichts mehr.
|
||||
*/
|
||||
parallax: 0.075,
|
||||
} as const
|
||||
|
||||
/** Meetingtisch (§5): längliche Form, führt vom Betrachter weg. */
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface MeetingRoomScene {
|
||||
renderer: THREE.WebGLRenderer
|
||||
seats: SeatHandle[]
|
||||
/** Kamera anhand eines Zeigerversatzes (−1…1) leicht versetzen. */
|
||||
applyParallax: (dx: number, dy: number) => void
|
||||
setSize: (width: number, height: number) => void
|
||||
render: () => void
|
||||
setHovered: (id: string | null) => void
|
||||
@@ -549,9 +548,6 @@ export function createMeetingRoomScene(
|
||||
addSeatedAgent(scene, seat, photos[seat.id], maxAnisotropy, leinwandHoehe),
|
||||
)
|
||||
|
||||
const baseCam = camera.position.clone()
|
||||
const lookTarget = new THREE.Vector3(...CAMERA.lookAt)
|
||||
|
||||
function render() {
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
@@ -562,15 +558,6 @@ export function createMeetingRoomScene(
|
||||
renderer,
|
||||
seats: handles,
|
||||
|
||||
applyParallax(dx, dy) {
|
||||
camera.position.set(
|
||||
baseCam.x + dx * CAMERA.parallax,
|
||||
baseCam.y + dy * CAMERA.parallax * 0.6,
|
||||
baseCam.z,
|
||||
)
|
||||
camera.lookAt(lookTarget)
|
||||
},
|
||||
|
||||
setSize(width, height) {
|
||||
renderer.setSize(width, height, false)
|
||||
const aspect = width / Math.max(height, 1)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { X } from 'lucide-react'
|
||||
import type { Property, UpdatePropertyInput } from '../../domain/property'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { usePropertyById, useUpdateProperty } from '../../hooks/useProperties'
|
||||
import { PropertyDetailSkeleton } from './PropertyDetailSkeleton'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
@@ -217,6 +218,14 @@ export function EditableObjectOverview({ propertyId, onClose }: Props) {
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/*
|
||||
Der KI-Hinweis steht innerhalb des scrollenden Bereichs. Ausserhalb
|
||||
läge er hinter dem `overflow: hidden` des äusseren Rahmens und wäre
|
||||
nie zu sehen — der Aktionsbalken darunter ist bewusst der einzige
|
||||
Teil, der fix am unteren Rand klebt.
|
||||
*/}
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
|
||||
{/* Speichern erscheint erst bei einer Änderung, «Abbrechen» links davon. */}
|
||||
|
||||
@@ -22,9 +22,19 @@ import {
|
||||
qualityColor,
|
||||
} from './propertyHelpers'
|
||||
import { DS_SLATE } from '../../lib/ds'
|
||||
import { matchScoreHex } from '../../lib/utils'
|
||||
|
||||
interface PropertyTableProps {
|
||||
properties: Property[]
|
||||
/**
|
||||
* Match-Werte je Objekt-ID (Runde 10, §3.6).
|
||||
*
|
||||
* Ist die Karte gesetzt, bekommt die Tabelle eine erste Spalte «Match». Der
|
||||
* Rest — Spalten, Sortierung, Zeilenaufbau, Aktionen — bleibt unverändert:
|
||||
* Noras Trefferliste ist dieselbe Tabelle wie «Meine Objekte», nicht eine
|
||||
* zweite, die ihr ähnlich sieht.
|
||||
*/
|
||||
matchScores?: Map<string, number>
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
selectedId: string | null
|
||||
@@ -40,6 +50,18 @@ const COL_HEADERS = [
|
||||
'Datenqualität', 'Aktionen',
|
||||
]
|
||||
|
||||
/**
|
||||
* Spaltenanteile in Prozent, in der Reihenfolge der Kopfzeile.
|
||||
*
|
||||
* Sie ergeben zusammen 100. Kommt die Match-Spalte dazu, werden alle
|
||||
* anteilig gestaucht, statt die Tabelle zu verbreitern — eine waagrechte
|
||||
* Bildlaufleiste wäre für eine einzige Kennzahl ein schlechter Tausch.
|
||||
*/
|
||||
const COL_WIDTHS = [19, 7, 9, 6, 8, 13, 9, 7, 9, 9, 4]
|
||||
|
||||
/** Platz für «Match» in Prozent. */
|
||||
const MATCH_COL_WIDTH = 7
|
||||
|
||||
function LoadingRows() {
|
||||
return (
|
||||
<>
|
||||
@@ -100,7 +122,11 @@ export function PropertyTable({
|
||||
onViewDetail,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
matchScores,
|
||||
}: PropertyTableProps) {
|
||||
const showMatch = matchScores !== undefined
|
||||
const skalierung = showMatch ? (100 - MATCH_COL_WIDTH) / 100 : 1
|
||||
|
||||
if (isError) {
|
||||
return <Alert severity="error" sx={{ m: 3 }}>Objekte konnten nicht geladen werden.</Alert>
|
||||
}
|
||||
@@ -115,9 +141,43 @@ export function PropertyTable({
|
||||
|
||||
return (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Table stickyHeader size="small">
|
||||
<Table
|
||||
stickyHeader
|
||||
size="small"
|
||||
/*
|
||||
* Feste Spaltenbreiten statt automatischer (Runde 9, §2).
|
||||
*
|
||||
* Mit automatischer Breite richtet sich jede Spalte nach ihrem längsten
|
||||
* Inhalt — bei elf Spalten summiert sich das über die Fensterbreite
|
||||
* hinaus, und die letzten beiden lagen im waagrechten Scrollbereich.
|
||||
* Die Anteile unten ergeben zusammen 100 %; keine Spalte ist entfernt
|
||||
* und kein Wert gekürzt, sie teilen sich den Platz nur nach Bedarf:
|
||||
* Objektname und Mieter tragen lange Texte, «Fläche» oder «Break-Option»
|
||||
* kommen mit wenig aus.
|
||||
*/
|
||||
sx={{
|
||||
tableLayout: 'fixed',
|
||||
'& .MuiTableCell-root': {
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
// Lange Namen brechen um, statt die Spalte aufzuweiten.
|
||||
wordBreak: 'break-word',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<colgroup>
|
||||
{showMatch && <col style={{ width: `${MATCH_COL_WIDTH}%` }} />}
|
||||
{COL_WIDTHS.map((w, i) => (
|
||||
<col key={COL_HEADERS[i]} style={{ width: `${w * skalierung}%` }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: 'grey.50' }}>
|
||||
{showMatch && (
|
||||
<TableCell>
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>Match</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Objekt</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Typ</Typography></TableCell>
|
||||
<TableCell><Typography variant="caption" sx={{ fontWeight: 600 }}>Standort</Typography></TableCell>
|
||||
@@ -136,7 +196,7 @@ export function PropertyTable({
|
||||
<LoadingRows />
|
||||
) : properties.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11}>
|
||||
<TableCell colSpan={showMatch ? 12 : 11}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textAlign: 'center', py: 4 }}>
|
||||
Keine Objekte gefunden.
|
||||
</Typography>
|
||||
@@ -164,6 +224,18 @@ export function PropertyTable({
|
||||
'&:hover': { bgcolor: isSelected ? 'rgba(30,58,95,0.08)' : 'rgba(0,0,0,0.02)' },
|
||||
}}
|
||||
>
|
||||
{/* Match — erste Spalte, nur in Noras Trefferliste (§3.6) */}
|
||||
{showMatch && (
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 700, color: matchScoreHex(matchScores!.get(p.id) ?? 0) }}
|
||||
>
|
||||
{matchScores!.get(p.id) ?? 0} %
|
||||
</Typography>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Objekt */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, lineHeight: 1.3 }}>
|
||||
@@ -205,7 +277,7 @@ export function PropertyTable({
|
||||
|
||||
{/* Aktueller Mieter */}
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="body2">
|
||||
{p.currentTenant ?? <span style={{ color: DS_SLATE[400] }}>–</span>}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
@@ -249,7 +321,7 @@ export function PropertyTable({
|
||||
title={hasCritical ? `Kritische Felder: ${p.dataQuality.missingCriticalFields.join(', ')}` : 'Keine kritischen Lücken'}
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{ width: 80 }}>
|
||||
<Box sx={{ width: '100%', maxWidth: 90 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={qScore * 100}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import { X, Calendar } from 'lucide-react'
|
||||
import { useReminderStore } from '../../stores/reminderStore'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import {
|
||||
useReminder, useCompleteReminder, useDismissReminder, useSnoozeReminder,
|
||||
} from '../../hooks/useReminders'
|
||||
@@ -201,6 +202,8 @@ export function ReminderDetailDrawer() {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -12,14 +12,21 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||
FormControlLabel, Switch, TextField, Typography,
|
||||
FormControlLabel, IconButton, Switch, TextField, Tooltip, Typography,
|
||||
} from '@mui/material'
|
||||
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Settings2 } from 'lucide-react'
|
||||
import { ArrowDownLeft, ArrowLeftRight, ArrowUpRight, Pencil, Plus, Settings2, Trash2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { AgentChannel, TeamAgent } from '../../domain/teamAgent'
|
||||
import { AgentChannelDirection, AgentConnectionStatus } from '../../domain/teamAgent'
|
||||
import { AgentChannelDirection, AgentChannelType, AgentConnectionStatus } from '../../domain/teamAgent'
|
||||
import { AgentConnectionStatusBadge } from './AgentBadges'
|
||||
import { AGENT_CHANNEL_DIRECTION_LABELS, AGENT_CHANNEL_LABELS } from '../../lib/constants'
|
||||
import { AgentEntityDialog } from './AgentEntityDialog'
|
||||
import type { EntityDraft, EntityField } from './AgentEntityDialog'
|
||||
import { newEntityId, removeById, upsertById } from './agentEntityEditing'
|
||||
import {
|
||||
AGENT_CHANNEL_DIRECTION_LABELS,
|
||||
AGENT_CHANNEL_LABELS,
|
||||
AGENT_CONNECTION_STATUS_LABELS,
|
||||
} from '../../lib/constants'
|
||||
import { useSaveAgentChannels } from '../../hooks/useTeamAgents'
|
||||
import { DS_BG, DS_BORDER, DS_SHADOW, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
@@ -40,15 +47,65 @@ const TEST_RESULT_BY_STATUS: Record<AgentConnectionStatus, TestResult> = {
|
||||
[AgentConnectionStatus.ROADMAP]: { ok: false, message: 'Verbindung fehlgeschlagen. Dieser Kanal ist erst geplant und noch nicht eingerichtet — ein Test ist nach der Einrichtung möglich.' },
|
||||
}
|
||||
|
||||
// ── Maske zum Anlegen und Bearbeiten (Runde 10, §2A) ──────────────────────────
|
||||
|
||||
const CHANNEL_FIELDS: EntityField[] = [
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Kanal',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: Object.values(AgentChannelType).map((t) => ({
|
||||
value: t,
|
||||
label: AGENT_CHANNEL_LABELS[t] ?? t,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'direction',
|
||||
label: 'Richtung',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: Object.values(AgentChannelDirection).map((d) => ({
|
||||
value: d,
|
||||
label: AGENT_CHANNEL_DIRECTION_LABELS[d] ?? d,
|
||||
})),
|
||||
},
|
||||
{
|
||||
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 Kanal gilt erst als verbunden, wenn er eingerichtet ist.',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Wofür wird der Kanal genutzt?',
|
||||
type: 'multiline',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'displayName',
|
||||
label: 'Anzeigename',
|
||||
helperText: 'Steht so im Konfigurationsdialog.',
|
||||
},
|
||||
]
|
||||
|
||||
// ── Kanalzeile ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RowProps {
|
||||
channel: AgentChannel
|
||||
editing: boolean
|
||||
onToggle: (channelId: string, enabled: boolean) => void
|
||||
onConfigure: (channelId: string) => void
|
||||
onEdit: (channelId: string) => void
|
||||
onDelete: (channelId: string) => void
|
||||
}
|
||||
|
||||
const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }: RowProps) {
|
||||
const ChannelRow = memo(function ChannelRow({ channel, editing, onToggle, onConfigure, onEdit, onDelete }: RowProps) {
|
||||
const label = AGENT_CHANNEL_LABELS[channel.type] ?? channel.type
|
||||
const DirectionIcon = DIRECTION_ICON[channel.direction]
|
||||
|
||||
@@ -71,6 +128,20 @@ const ChannelRow = memo(function ChannelRow({ channel, onToggle, onConfigure }:
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
{AGENT_CHANNEL_DIRECTION_LABELS[channel.direction] ?? channel.direction}
|
||||
</Typography>
|
||||
{editing && (
|
||||
<>
|
||||
<Tooltip title="Kanal bearbeiten">
|
||||
<IconButton size="small" onClick={() => onEdit(channel.id)} sx={{ color: DS_TEXT.secondary }}>
|
||||
<Pencil size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Kanal entfernen">
|
||||
<IconButton size="small" onClick={() => onDelete(channel.id)} sx={{ color: DS_TEXT.secondary }}>
|
||||
<Trash2 size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -197,6 +268,10 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
|
||||
const { mutate: saveChannels } = useSaveAgentChannels()
|
||||
const [openChannelId, setOpenChannelId] = useState<string | null>(null)
|
||||
|
||||
/** `null` = Maske zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Kanals. */
|
||||
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
|
||||
const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
|
||||
|
||||
const channels = agent.channels
|
||||
|
||||
// Beide Kennzahlen in einem Durchlauf (§10.4).
|
||||
@@ -222,15 +297,72 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
|
||||
const handleConfigure = useCallback((channelId: string) => setOpenChannelId(channelId), [])
|
||||
const handleClose = useCallback(() => setOpenChannelId(null), [])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(channelId: string) => saveChannels({ id: agent.id, channels: removeById(channels, channelId) }),
|
||||
[agent.id, channels, saveChannels],
|
||||
)
|
||||
|
||||
/**
|
||||
* Anlegen und Bearbeiten teilen sich die Maske. Gespeichert wird sofort —
|
||||
* ein Kanal ist wie der Aktiv-Schalter ein Ein/Aus-Entscheid, kein Formular
|
||||
* mit Zwischenstand.
|
||||
*/
|
||||
const handleDialogSave = useCallback(
|
||||
(werte: EntityDraft, active: boolean) => {
|
||||
const bestehend = bearbeitet !== 'new' ? channels.find((c) => c.id === bearbeitet) : undefined
|
||||
const eintrag: AgentChannel = {
|
||||
id: bestehend?.id ?? newEntityId(agent.id, 'channel'),
|
||||
type: werte.type as AgentChannelType,
|
||||
direction: werte.direction as AgentChannelDirection,
|
||||
status: werte.status as AgentConnectionStatus,
|
||||
description: werte.description.trim(),
|
||||
enabled: active,
|
||||
...(bestehend?.optional ? { optional: true } : {}),
|
||||
config: {
|
||||
...(bestehend?.config ?? { defaultRecipients: [], autoReplyEnabled: false }),
|
||||
displayName: werte.displayName?.trim() || (AGENT_CHANNEL_LABELS[werte.type] ?? werte.type),
|
||||
},
|
||||
}
|
||||
saveChannels({ id: agent.id, channels: upsertById(channels, eintrag) })
|
||||
setBearbeitet(null)
|
||||
},
|
||||
[bearbeitet, channels, agent.id, saveChannels],
|
||||
)
|
||||
|
||||
const inBearbeitung = bearbeitet && bearbeitet !== 'new'
|
||||
? channels.find((c) => c.id === bearbeitet)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Über diese Kanäle erhält {agent.name} Aufträge und liefert Ergebnisse aus.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25, mb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, display: 'block', mt: 0.25 }}>
|
||||
{summary.active} von {channels.length} Kanälen aktiv · {summary.connected} verbunden
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5, mb: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Pencil size={14} aria-hidden />}
|
||||
onClick={() => setBearbeitungsmodus((v) => !v)}
|
||||
sx={{ textTransform: 'none', ...FOCUS_RING }}
|
||||
>
|
||||
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Kanäle bearbeiten'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Plus size={14} aria-hidden />}
|
||||
onClick={() => setBearbeitet('new')}
|
||||
sx={{ textTransform: 'none', ...FOCUS_RING }}
|
||||
>
|
||||
Neuen Kanal anbinden
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{channels.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.muted }}>
|
||||
Für {agent.name} ist noch kein Kanal hinterlegt.
|
||||
@@ -238,12 +370,38 @@ export function AgentChannelsTab({ agent }: { agent: TeamAgent }) {
|
||||
) : (
|
||||
<Box component="ul" sx={{ listStyle: 'none', m: 0, p: 0, display: 'grid', gap: 1.25 }}>
|
||||
{channels.map((channel) => (
|
||||
<ChannelRow key={channel.id} channel={channel} onToggle={handleToggle} onConfigure={handleConfigure} />
|
||||
<ChannelRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
editing={bearbeitungsmodus}
|
||||
onToggle={handleToggle}
|
||||
onConfigure={handleConfigure}
|
||||
onEdit={setBearbeitet}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ChannelConfigDialog channel={openChannel} onClose={handleClose} />
|
||||
|
||||
<AgentEntityDialog
|
||||
open={bearbeitet !== null}
|
||||
entityKey={bearbeitet ?? undefined}
|
||||
title={inBearbeitung ? 'Kanal bearbeiten' : 'Neuen Kanal anbinden'}
|
||||
fields={CHANNEL_FIELDS}
|
||||
initial={{
|
||||
type: inBearbeitung?.type ?? AgentChannelType.EMAIL,
|
||||
direction: inBearbeitung?.direction ?? AgentChannelDirection.BOTH,
|
||||
status: inBearbeitung?.status ?? AgentConnectionStatus.DISCONNECTED,
|
||||
description: inBearbeitung?.description ?? '',
|
||||
displayName: inBearbeitung?.config.displayName ?? '',
|
||||
}}
|
||||
activeLabel="Kanal aktiv"
|
||||
initialActive={inBearbeitung?.enabled ?? true}
|
||||
onSave={handleDialogSave}
|
||||
onClose={() => setBearbeitet(null)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { DS_BORDER, DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Höhe des Verlaufsfensters.
|
||||
*
|
||||
* Der Verlauf braucht eine feste Obergrenze, sonst wächst er mit jeder Antwort
|
||||
* und schiebt die Seite darunter weg — genau das Springen, das behoben werden
|
||||
* soll. Innerhalb dieser Höhe scrollt der Verlauf für sich.
|
||||
*/
|
||||
const TRANSCRIPT_MAX_HEIGHT = 420
|
||||
|
||||
/**
|
||||
* Der Gesprächsverlauf unter dem Eingabefeld (Runde 9, §3).
|
||||
*
|
||||
* Bewusst schlicht: Frage rechts, Antwort links mit dem Porträt des Agenten,
|
||||
* keine Zeitstempel, keine Sprechblasen mit Spitzen, kein Tippindikator. Der
|
||||
* Chat beantwortet drei Fragetypen aus Mockdaten — eine Oberfläche, die nach
|
||||
* Messenger aussieht, verspräche mehr, als dahinter steckt.
|
||||
*
|
||||
* Vor der ersten Frage erscheint gar nichts. Der grosszügige Weissraum unter
|
||||
* dem Eingabefeld ist Teil des Einstiegs und soll nicht durch einen leeren
|
||||
* Verlaufsrahmen ersetzt werden.
|
||||
*/
|
||||
export function AgentChatTranscript() {
|
||||
const messages = useAgentChatStore(s => s.messages)
|
||||
const agent = useAgentChatStore(s => s.agent)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
/** Die zuletzt gestellte Frage — an ihr richtet sich die Scrollposition aus. */
|
||||
const lastQuestionRef = useRef<HTMLDivElement>(null)
|
||||
const zuletztGescrollt = useRef(0)
|
||||
|
||||
/**
|
||||
* Nach einer Antwort den Verlauf so stellen, dass die eigene Frage oben steht
|
||||
* und die Antwort direkt darunter beginnt (Runde 10, §4).
|
||||
*
|
||||
* Ausschliesslich über `scrollTop` des Containers. Kein `scrollIntoView` und
|
||||
* kein `window.scrollTo`: Beide bewegen die Seite mit, und genau dieses
|
||||
* Wegspringen des ganzen Bildschirms war die Beschwerde. Der Browser-Viewport
|
||||
* bleibt hier unberührt.
|
||||
*
|
||||
* `useLayoutEffect`, damit die Korrektur vor dem Zeichnen sitzt — sonst sieht
|
||||
* man den Sprung einmal kurz.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
const frage = lastQuestionRef.current
|
||||
if (!container || !frage || messages.length === zuletztGescrollt.current) return
|
||||
zuletztGescrollt.current = messages.length
|
||||
// Abstand der Frage zum oberen Rand des Verlaufs, minus ein schmaler
|
||||
// Vorlauf, damit sie nicht bündig an der Kante klebt.
|
||||
container.scrollTop = Math.max(0, frage.offsetTop - container.offsetTop - 8)
|
||||
}, [messages.length])
|
||||
|
||||
// Ein Agentenwechsel leert den Verlauf; dann darf die nächste Antwort wieder
|
||||
// ausrichten, statt die alte Zählung fortzuschreiben.
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) zuletztGescrollt.current = 0
|
||||
}, [messages.length])
|
||||
|
||||
if (messages.length === 0 || !agent) return null
|
||||
|
||||
const letzteFrageIndex = messages.map(m => m.from).lastIndexOf('user')
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 720,
|
||||
mt: 2.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
// Der Verlauf scrollt für sich; die Seite darunter bleibt, wo sie ist.
|
||||
maxHeight: TRANSCRIPT_MAX_HEIGHT,
|
||||
overflowY: 'auto',
|
||||
// Ohne diesen Innenabstand klebt die Bildlaufleiste am Text.
|
||||
pr: 0.5,
|
||||
// Verhindert, dass das Scrollen am Ende des Verlaufs auf die Seite
|
||||
// durchschlägt — sonst wandert der Bildschirm doch wieder.
|
||||
overscrollBehavior: 'contain',
|
||||
}}
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
m.from === 'user' ? (
|
||||
<Box
|
||||
key={i}
|
||||
ref={i === letzteFrageIndex ? lastQuestionRef : undefined}
|
||||
sx={{ alignSelf: 'flex-end', maxWidth: '85%' }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.75, py: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: DS_SLATE[100],
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, whiteSpace: 'pre-line' }}>
|
||||
{m.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 1.25, alignItems: 'flex-start', maxWidth: '95%' }}>
|
||||
<Box sx={{ flexShrink: 0, mt: 0.25 }}>
|
||||
<AgentAvatar agent={agent} size="small" />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.75, py: 1,
|
||||
borderRadius: 2.5,
|
||||
bgcolor: 'white',
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
|
||||
{m.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||
FormControlLabel, MenuItem, Switch, TextField,
|
||||
} from '@mui/material'
|
||||
import { DS_BRAND } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Ein Feld der Bearbeitungsmaske.
|
||||
*
|
||||
* Bewusst ein winziger Beschreibungstyp statt einer Maske je Entität: Aufgaben,
|
||||
* Systeme und Kanäle unterscheiden sich nur in den Feldern, nicht im Verhalten.
|
||||
* Drei fast gleiche Dialoge wären drei Stellen für jede künftige Korrektur.
|
||||
*/
|
||||
export interface EntityField {
|
||||
key: string
|
||||
label: string
|
||||
/** `select` braucht `options`, `multiline` wird mehrzeilig dargestellt. */
|
||||
type?: 'text' | 'multiline' | 'select' | 'url'
|
||||
options?: { value: string; label: string }[]
|
||||
required?: boolean
|
||||
placeholder?: string
|
||||
helperText?: 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 {
|
||||
open: boolean
|
||||
/**
|
||||
* Kennung des bearbeiteten Eintrags — bei offenem Dialog ein Wechsel darauf
|
||||
* baut die Maske neu auf. Bewusst über den React-Schlüssel statt über einen Effekt: ein
|
||||
* Effekt würde den Entwurf nachträglich überschreiben und dabei bereits
|
||||
* getippten Text verlieren.
|
||||
*/
|
||||
entityKey?: string
|
||||
/** Überschrift, z. B. «Aufgabe bearbeiten» oder «Neues System anbinden». */
|
||||
title: string
|
||||
fields: EntityField[]
|
||||
/** Vorbelegung; beim Anlegen leer. */
|
||||
initial: EntityDraft
|
||||
/** Beschriftung des Aktiv-Schalters; entfällt, wenn nicht gesetzt. */
|
||||
activeLabel?: string
|
||||
initialActive?: boolean
|
||||
onSave: (draft: EntityDraft, active: boolean) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Maske zum Anlegen und Bearbeiten von Aufgaben, Systemen und
|
||||
* Kanälen (Runde 10, §2).
|
||||
*
|
||||
* Der Entwurf lebt lokal und geht erst beim Speichern hinaus — bei jedem
|
||||
* Tastendruck zu speichern hiesse, jeden Zwischenstand zum gültigen Stand zu
|
||||
* erklären. Über `key` auf dem Dialoginhalt entsteht die Maske bei jedem
|
||||
* Öffnen neu, statt dass ein Effekt den alten Entwurf nachträglich überschreibt.
|
||||
*/
|
||||
export function AgentEntityDialog({
|
||||
open, title, fields, initial, activeLabel, initialActive = true, entityKey, onSave, onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontSize: '1rem', fontWeight: 700 }}>{title}</DialogTitle>
|
||||
{open && (
|
||||
<DialogBody
|
||||
key={entityKey ?? 'neu'}
|
||||
fields={fields}
|
||||
initial={initial}
|
||||
activeLabel={activeLabel}
|
||||
initialActive={initialActive}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogBody({
|
||||
fields, initial, activeLabel, initialActive, onSave, onClose,
|
||||
}: Omit<Props, 'open' | 'title' | 'entityKey'>) {
|
||||
const [draft, setDraft] = useState<EntityDraft>(initial)
|
||||
const [active, setActive] = useState(initialActive ?? true)
|
||||
const [touched, setTouched] = useState(false)
|
||||
|
||||
const fehlend = fields.filter(f => f.required && !(draft[f.key] ?? '').trim()).map(f => f.key)
|
||||
|
||||
// Formfehler je Feld, unabhängig davon, ob das Feld leer geblieben ist.
|
||||
const formfehler: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
if (f.type !== 'url') continue
|
||||
const meldung = urlFehler(draft[f.key] ?? '')
|
||||
if (meldung) formfehler[f.key] = meldung
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setTouched(true)
|
||||
if (fehlend.length > 0 || Object.keys(formfehler).length > 0) return
|
||||
onSave(draft, active)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
|
||||
{fields.map(f => (
|
||||
<TextField
|
||||
key={f.key}
|
||||
select={f.type === 'select'}
|
||||
multiline={f.type === 'multiline'}
|
||||
minRows={f.type === 'multiline' ? 3 : undefined}
|
||||
type={f.type === 'url' ? 'url' : 'text'}
|
||||
label={f.label}
|
||||
value={draft[f.key] ?? ''}
|
||||
onChange={e => setDraft(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
placeholder={f.placeholder}
|
||||
required={f.required}
|
||||
error={touched && (fehlend.includes(f.key) || f.key in formfehler)}
|
||||
helperText={
|
||||
touched && fehlend.includes(f.key)
|
||||
? 'Dieses Feld wird gebraucht.'
|
||||
: touched && formfehler[f.key]
|
||||
? formfehler[f.key]
|
||||
: f.helperText
|
||||
}
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
{(f.options ?? []).map(o => (
|
||||
<MenuItem key={o.value} value={o.value} sx={{ fontSize: '0.8125rem' }}>
|
||||
{o.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
))}
|
||||
|
||||
{activeLabel && (
|
||||
<FormControlLabel
|
||||
control={<Switch size="small" checked={active} onChange={(_, v) => setActive(v)} />}
|
||||
label={active ? activeLabel : `${activeLabel} — derzeit inaktiv`}
|
||||
slotProps={{ typography: { variant: 'body2' } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} sx={{ textTransform: 'none' }}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
sx={{ textTransform: 'none', bgcolor: DS_BRAND.main, '&:hover': { bgcolor: DS_BRAND.hover } }}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,28 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import type { AgentMetric, TeamAgent } from '../../domain/teamAgent'
|
||||
import { EmptyState } from '../ui'
|
||||
import { useMatchRuns } from '../../hooks/useMatchRuns'
|
||||
import { useFutureSignals } from '../../hooks/useFutureSignals'
|
||||
import { isRisiko } from '../../lib/leadSignal'
|
||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Reiter «Kennzahlen».
|
||||
*
|
||||
* Zeigt ausschliesslich die Kennzahlen, die im Personalblatt des Mitarbeitenden
|
||||
* bereits hinterlegt sind. Es werden keine Werte berechnet, hochgerechnet oder
|
||||
* ergänzt — was der Katalog nicht führt, steht hier auch nicht.
|
||||
* Zeigt die Kennzahlen, die das Personalblatt führt. Es wird nichts
|
||||
* hochgerechnet und nichts ergänzt — was der Katalog nicht führt, steht hier
|
||||
* auch nicht.
|
||||
*
|
||||
* Ausnahme seit Runde 10 (§3.11): Noras beide Kennzahlen kommen aus dem
|
||||
* laufenden Stand der Anwendung und nicht aus dem Katalog. Ein fester Wert
|
||||
* wäre dort eine Behauptung — die eine Zahl zählt tatsächlich ausgeführte
|
||||
* Matching-Läufe, die andere tatsächlich zugeordnete Objekte.
|
||||
*/
|
||||
|
||||
/** Kennungen, deren Wert zur Laufzeit entsteht. */
|
||||
const MATCH_RUNS_METRIC = 'nora-metric-match-runs'
|
||||
const AFFECTED_OBJECTS_METRIC = 'nora-metric-affected-objects'
|
||||
const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric }) {
|
||||
return (
|
||||
<Box
|
||||
@@ -46,7 +58,46 @@ const MetricTile = memo(function MetricTile({ metric }: { metric: AgentMetric })
|
||||
})
|
||||
|
||||
export function AgentMetricsTab({ agent }: { agent: TeamAgent }) {
|
||||
if (agent.metrics.length === 0) {
|
||||
const { data: matchRuns = [] } = useMatchRuns()
|
||||
const { data: signals = [] } = useFutureSignals()
|
||||
|
||||
/**
|
||||
* Die beiden Laufzeit-Kennzahlen einsetzen.
|
||||
*
|
||||
* «Betroffene Objekte» zählt Risikosignale mit eindeutig zugeordnetem
|
||||
* Objekt — und zwar die Objekte, nicht die Signale: Zwei Meldungen über
|
||||
* dasselbe Objekt sind ein betroffenes Objekt, nicht zwei.
|
||||
*/
|
||||
const metrics = useMemo((): AgentMetric[] => {
|
||||
const laeufe = matchRuns.length
|
||||
const betroffene = new Set(
|
||||
signals.filter(sig => isRisiko(sig) && sig.propertyId).map(sig => sig.propertyId!),
|
||||
).size
|
||||
|
||||
return agent.metrics.map((m) => {
|
||||
if (m.id === MATCH_RUNS_METRIC) {
|
||||
return {
|
||||
...m,
|
||||
value: String(laeufe),
|
||||
hint: laeufe === 0
|
||||
? 'Noch kein Matching ausgeführt. Die Zahl steigt nur durch einen tatsächlichen Lauf.'
|
||||
: `Zuletzt am ${new Date(matchRuns[0].timestamp).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' })} für «${matchRuns[0].leadTitle}».`,
|
||||
}
|
||||
}
|
||||
if (m.id === AFFECTED_OBJECTS_METRIC) {
|
||||
return {
|
||||
...m,
|
||||
value: String(betroffene),
|
||||
hint: betroffene === 0
|
||||
? 'Derzeit ist keinem Risikosignal ein Objekt aus dem Bestand eindeutig zugeordnet.'
|
||||
: m.hint,
|
||||
}
|
||||
}
|
||||
return m
|
||||
})
|
||||
}, [agent.metrics, matchRuns, signals])
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Keine Kennzahlen hinterlegt"
|
||||
@@ -63,7 +114,7 @@ export function AgentMetricsTab({ agent }: { agent: TeamAgent }) {
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' },
|
||||
}}
|
||||
>
|
||||
{agent.metrics.map((metric) => (
|
||||
{metrics.map((metric) => (
|
||||
<MetricTile key={metric.id} metric={metric} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
// prettier-ignore
|
||||
import { Box, Button, Chip, FormControlLabel, FormHelperText, InputAdornment, MenuItem, Select, Switch, TextField, Typography } from '@mui/material'
|
||||
import { AlertTriangle, Lock } from 'lucide-react'
|
||||
import { Box, Button, Chip, FormControlLabel, FormHelperText, IconButton, InputAdornment, MenuItem, Popover, Select, Switch, TextField, Typography } from '@mui/material'
|
||||
import { AlertTriangle, Info, Lock } from 'lucide-react'
|
||||
import { z } from 'zod'
|
||||
import type { AgentSetting, TeamAgent } from '../../domain/teamAgent'
|
||||
import { AgentSettingGroup, AgentSettingKind } from '../../domain/teamAgent'
|
||||
import { AGENT_SETTING_GROUP_LABELS } from '../../lib/constants'
|
||||
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { useSaveAgentSettings } from '../../hooks/useTeamAgents'
|
||||
import { AgentChipsField } from './AgentChipsField'
|
||||
import { ResearchDocumentsField } from './ResearchDocumentsField'
|
||||
|
||||
type SettingValue = AgentSetting['value']
|
||||
|
||||
@@ -53,6 +55,14 @@ function buildSettingsSchema(settings: AgentSetting[]) {
|
||||
shape[setting.id] = field
|
||||
} else if (setting.kind === AgentSettingKind.MULTI_SELECT) {
|
||||
shape[setting.id] = z.array(z.string()).min(1, 'Mindestens ein Eintrag ist erforderlich.')
|
||||
} else if (setting.kind === AgentSettingKind.CHIPS) {
|
||||
// Bewusst ohne Mindestzahl: Eine leere Auswahl bedeutet «keine
|
||||
// Einschränkung» und ist damit eine gültige Aussage, kein Fehler.
|
||||
shape[setting.id] = z.array(z.string())
|
||||
} else if (setting.kind === AgentSettingKind.DOCUMENTS) {
|
||||
// Die Dokumente liegen in IndexedDB und nicht im Entwurf dieses Reiters;
|
||||
// hier steht nur der Platz, an dem sie erscheinen.
|
||||
shape[setting.id] = z.any()
|
||||
} else if (setting.kind === AgentSettingKind.BOOLEAN) {
|
||||
shape[setting.id] = z.boolean()
|
||||
} else {
|
||||
@@ -62,6 +72,40 @@ function buildSettingsSchema(settings: AgentSetting[]) {
|
||||
return z.object(shape)
|
||||
}
|
||||
|
||||
// ── Erklärung zu einem Wert (Runde 10, §2.10) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ein Popover und kein Tooltip: Der Text ist mehrere Sätze lang, und ein
|
||||
* Tooltip verschwindet, sobald man den Zeiger bewegt, um ihn zu Ende zu lesen.
|
||||
*/
|
||||
function SettingInfoButton({ label, text }: { label: string; text: string }) {
|
||||
const [anker, setAnker] = useState<HTMLElement | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Wie entsteht «${label}»?`}
|
||||
onClick={(e) => setAnker(e.currentTarget)}
|
||||
sx={{ color: DS_TEXT.muted, p: 0.25, ...FOCUS_SX }}
|
||||
>
|
||||
<Info size={14} />
|
||||
</IconButton>
|
||||
<Popover
|
||||
open={anker !== null}
|
||||
anchorEl={anker}
|
||||
onClose={() => setAnker(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
slotProps={{ paper: { sx: { maxWidth: 460, p: 2, boxShadow: DS_SHADOW.panel } } }}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, lineHeight: 1.55 }}>
|
||||
{text}
|
||||
</Typography>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Eine Einstellung ──────────────────────────────────────────────────────────
|
||||
|
||||
interface RowProps {
|
||||
@@ -126,6 +170,24 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
|
||||
</>
|
||||
)
|
||||
break
|
||||
case AgentSettingKind.CHIPS:
|
||||
control = (
|
||||
<>
|
||||
<AgentChipsField
|
||||
value={Array.isArray(setting.value) ? setting.value : []}
|
||||
options={options}
|
||||
allowCustom={setting.allowCustom === true}
|
||||
disabled={locked}
|
||||
ariaLabel={setting.label}
|
||||
onChange={(next) => onChange(setting.id, next)}
|
||||
/>
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
</>
|
||||
)
|
||||
break
|
||||
case AgentSettingKind.DOCUMENTS:
|
||||
control = <ResearchDocumentsField />
|
||||
break
|
||||
default:
|
||||
control = (
|
||||
<TextField {...shared} value={text} multiline={long} minRows={long ? 3 : undefined}
|
||||
@@ -135,7 +197,10 @@ const SettingRow = memo(function SettingRow({ setting, error, onChange }: RowPro
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 0.5, py: 1.5, borderTop: `1px solid ${DS_BORDER.muted}` }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: DS_TEXT.primary }}>{setting.label}</Typography>
|
||||
{setting.infoText && <SettingInfoButton label={setting.label} text={setting.infoText} />}
|
||||
</Box>
|
||||
{setting.description && (
|
||||
<Typography id={describedBy} variant="caption" sx={{ display: 'block', color: DS_TEXT.secondary }}>{setting.description}</Typography>
|
||||
)}
|
||||
|
||||
@@ -1,202 +1,253 @@
|
||||
/**
|
||||
* Property On — Reiter «Systeme» im Personalblatt (§10).
|
||||
* Property On — Reiter «Systeme» im Personalblatt (§10, Runde 10 §§1–3).
|
||||
*
|
||||
* Reine Transparenz: welche Fachsysteme und Datenquellen ein digitaler
|
||||
* Mitarbeiter verwenden darf. Keine Aktionen — Zugänge werden auf der Seite
|
||||
* «Kanäle & Systeme» eingerichtet, nicht hier.
|
||||
* Zeigt und pflegt die Fachsysteme und Datenquellen eines digitalen
|
||||
* Mitarbeiters. Seit Runde 10 ist der Reiter nicht mehr nur Anzeige: Zugänge
|
||||
* lassen sich hier anlegen, ändern, zu- und abschalten — dort, wo man sie sieht.
|
||||
*
|
||||
* Schreibrechte sind der einzige wirklich folgenreiche Zustand auf diesem
|
||||
* Reiter (§10.4): sie verändern Kundendaten in der Bewirtschaftung. Sie stehen
|
||||
* deshalb in einer eigenen Gruppe und tragen zusätzlich zur hervorgehobenen
|
||||
* Fläche ein Icon UND einen Klartextsatz — Farbe allein ist kein zulässiger
|
||||
* Statusträger (§2.3, §18).
|
||||
* Bewusst eine einzige Liste statt getrennter Gruppen «lesend» und
|
||||
* «schreibend»: Die Frage am Bildschirm lautet «welche Systeme nutzt dieser
|
||||
* Mitarbeiter», nicht «wie viele davon sind lesend». Jede Zeile trägt rechts
|
||||
* oben genau zwei Angaben — ob sie genutzt wird und ob gelesen oder geschrieben
|
||||
* wird. Beide sind Text, nicht nur Farbe (§2.3, §18).
|
||||
*/
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Eye, KeyRound, PenLine, ShieldCheck } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { AgentAccessLevel } from '../../domain/teamAgent'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { Box, Button, IconButton, Link, Switch, Tooltip, Typography } from '@mui/material'
|
||||
import { ExternalLink, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
AgentAccessLevel,
|
||||
AgentConnectionStatus,
|
||||
AgentSystemType,
|
||||
} from '../../domain/teamAgent'
|
||||
import type { AgentSystem, TeamAgent } from '../../domain/teamAgent'
|
||||
import { AgentAccessBadge, AgentConnectionStatusBadge } from './AgentBadges'
|
||||
import { DetailSectionTitle } from './WorkItemDetailSections'
|
||||
import { AGENT_SYSTEM_LABELS } from '../../lib/constants'
|
||||
import { DS_BG, DS_BORDER, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { GenericBadge } from '../shared/GenericBadge'
|
||||
import { AgentEntityDialog } from './AgentEntityDialog'
|
||||
import type { EntityDraft, EntityField } from './AgentEntityDialog'
|
||||
import { newEntityId, removeById, upsertById } from './agentEntityEditing'
|
||||
import { useSaveAgentSystems } from '../../hooks/useTeamAgents'
|
||||
import { AGENT_ACCESS_LABELS } from '../../lib/constants'
|
||||
import { istAktiv, istSchreibend, systemTitel } from '../../lib/agentSystems'
|
||||
import { DS_BG, DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
// ── Darstellung je Zugriffstyp ────────────────────────────────────────────────
|
||||
// ── Maske ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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',
|
||||
const SYSTEM_FIELDS: EntityField[] = [
|
||||
{ key: 'title', label: 'Titel der Quelle', required: true },
|
||||
{ key: 'description', label: 'Beschreibung der Quelle', type: 'multiline', required: true },
|
||||
{
|
||||
key: 'url',
|
||||
label: 'Link / URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://…',
|
||||
helperText: 'Eine aktive Lesequelle mit Adresse wird im Recherchelauf tatsächlich abgerufen.',
|
||||
},
|
||||
[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',
|
||||
{
|
||||
key: 'access',
|
||||
label: 'Zugriff',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: AgentAccessLevel.READ, label: AGENT_ACCESS_LABELS[AgentAccessLevel.READ] },
|
||||
{ value: AgentAccessLevel.WRITE, label: AGENT_ACCESS_LABELS[AgentAccessLevel.WRITE] },
|
||||
],
|
||||
helperText: 'Schreibrechte verändern Kundendaten in der Bewirtschaftung.',
|
||||
},
|
||||
[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',
|
||||
},
|
||||
}
|
||||
|
||||
const GROUP_TITLE_READ = 'Lesende Systemzugänge'
|
||||
const GROUP_TITLE_WRITE = 'Schreibende Systemzugänge'
|
||||
|
||||
function systemCount(count: number): string {
|
||||
return count === 1 ? '1 Fachsystem' : `${count} Fachsysteme`
|
||||
}
|
||||
]
|
||||
|
||||
// ── Eine Systemzeile ──────────────────────────────────────────────────────────
|
||||
|
||||
interface RowProps {
|
||||
system: AgentSystem
|
||||
busy: boolean
|
||||
editing: boolean
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `React.memo`, weil das Personalblatt bis zu einem Dutzend Systemzeilen führt
|
||||
* und jeder Reiterwechsel sonst die gesamte Liste neu zeichnen würde (§10.2).
|
||||
*/
|
||||
const SystemRow = memo(function SystemRow({ system }: { system: AgentSystem }) {
|
||||
const look = ACCESS_APPEARANCE[system.access]
|
||||
const AccessIcon = look.icon
|
||||
const SystemRow = memo(function SystemRow({ system, busy, editing, onToggle, onEdit, onDelete }: RowProps) {
|
||||
const aktiv = istAktiv(system)
|
||||
const schreibend = istSchreibend(system)
|
||||
const titel = systemTitel(system)
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{
|
||||
border: `1px solid ${look.border}`,
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
borderRadius: 2,
|
||||
bgcolor: look.bg,
|
||||
// Ein abgeschalteter Zugang tritt zurück, bleibt aber vollständig lesbar.
|
||||
bgcolor: aktiv ? DS_BG.surface : DS_BG.subtle,
|
||||
opacity: aktiv ? 1 : 0.75,
|
||||
p: 1.75,
|
||||
display: 'grid',
|
||||
gap: 0.875,
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
{/* Kopfzeile: System, Zugriffstyp, Verbindungsstatus */}
|
||||
{/* Kopfzeile: Titel links, genau zwei Angaben rechts (§2.1B) */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<AccessIcon size={16} color={look.text} aria-hidden style={{ flexShrink: 0 }} />
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.9375rem', color: DS_TEXT.primary }}>
|
||||
{AGENT_SYSTEM_LABELS[system.type] ?? system.type}
|
||||
{titel}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto' }}>
|
||||
<AgentAccessBadge access={system.access} />
|
||||
<AgentConnectionStatusBadge status={system.status} />
|
||||
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', ml: 'auto', alignItems: 'center' }}>
|
||||
<GenericBadge
|
||||
label={aktiv ? 'Aktiv' : 'Inaktiv'}
|
||||
semanticVariant={aktiv ? 'confidenceHigh' : 'muted'}
|
||||
bold
|
||||
/>
|
||||
<GenericBadge
|
||||
label={schreibend ? 'Schreiben' : 'Lesen'}
|
||||
semanticVariant={schreibend ? 'confidenceMedium' : 'muted'}
|
||||
/>
|
||||
{editing && (
|
||||
<>
|
||||
<Tooltip title="System bearbeiten">
|
||||
<IconButton size="small" onClick={() => onEdit(system.id)} sx={{ color: DS_TEXT.secondary }}>
|
||||
<Pencil size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="System entfernen">
|
||||
<IconButton size="small" onClick={() => onDelete(system.id)} sx={{ color: DS_TEXT.secondary }}>
|
||||
<Trash2 size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Zugriffstyp im Klartext — nie nur farblich */}
|
||||
<Typography variant="body2" sx={{ color: look.text, fontWeight: 600 }}>
|
||||
{look.marker}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
{system.usage}
|
||||
</Typography>
|
||||
|
||||
{/* Berechtigungshinweis — der Satz, der die Verantwortung klärt */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.75,
|
||||
alignItems: 'flex-start',
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: DS_BG.surface,
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={14} color={DS_TEXT.secondary} aria-hidden style={{ flexShrink: 0, marginTop: 3 }} />
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary }}>
|
||||
<Box component="span" sx={{ fontWeight: 700 }}>
|
||||
Berechtigung:{' '}
|
||||
</Box>
|
||||
{system.permissionNote}
|
||||
{/* Die Adresse steht offen da — sie ist genau das, was abgerufen wird. */}
|
||||
{system.url && (
|
||||
<Link
|
||||
href={system.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="caption"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, color: DS_TEXT.brand, width: 'fit-content' }}
|
||||
>
|
||||
{system.url}
|
||||
<ExternalLink size={11} aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={aktiv}
|
||||
disabled={busy}
|
||||
onChange={(_, checked) => onToggle(system.id, checked)}
|
||||
slotProps={{ input: { 'aria-label': `Systemzugang ${titel} aktivieren` } }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: aktiv ? DS_TEXT.success : DS_TEXT.muted, fontWeight: 600 }}>
|
||||
{aktiv ? 'Wird genutzt' : 'Derzeit abgeschaltet'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Eine Gruppe ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemGroup({ title, systems }: { title: string; systems: AgentSystem[] }) {
|
||||
if (systems.length === 0) return null
|
||||
|
||||
return (
|
||||
<Box component="section" aria-label={title}>
|
||||
<DetailSectionTitle>{title}</DetailSectionTitle>
|
||||
<Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
|
||||
{systems.map((system) => (
|
||||
<SystemRow key={system.id} system={system} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Reiter ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
|
||||
const { reading, writing } = useMemo(() => {
|
||||
const readOnly: AgentSystem[] = []
|
||||
const writable: AgentSystem[] = []
|
||||
for (const system of agent.systems) {
|
||||
if (system.access === AgentAccessLevel.READ) readOnly.push(system)
|
||||
else writable.push(system)
|
||||
}
|
||||
return { reading: readOnly, writing: writable }
|
||||
}, [agent.systems])
|
||||
const { mutate: saveSystems, isPending } = useSaveAgentSystems()
|
||||
|
||||
const intro = useMemo(() => {
|
||||
const name = `«${agent.name}»`
|
||||
if (reading.length > 0 && writing.length > 0) {
|
||||
return `${name} liest ${systemCount(reading.length)} und schreibt nach Freigabe in ${systemCount(writing.length)}.`
|
||||
}
|
||||
if (reading.length > 0) {
|
||||
return `${name} liest ${systemCount(reading.length)} und schreibt in keines davon.`
|
||||
}
|
||||
if (writing.length > 0) {
|
||||
return `${name} schreibt nach Freigabe in ${systemCount(writing.length)}; weitere Zugänge bestehen nicht.`
|
||||
}
|
||||
return `Für ${name} ist bisher kein Systemzugang hinterlegt.`
|
||||
}, [agent.name, reading.length, writing.length])
|
||||
/** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Zugangs. */
|
||||
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
|
||||
const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
|
||||
|
||||
const systems = agent.systems
|
||||
|
||||
/**
|
||||
* Anders als bei den Aufgaben wirkt hier jede Änderung sofort — ein
|
||||
* Systemzugang ist ein Ein/Aus-Entscheid wie beim Kanalreiter, kein Formular
|
||||
* mit Zwischenstand. Zwei Speicherwege nebeneinander wären zwei Wahrheiten.
|
||||
*/
|
||||
const speichern = useCallback(
|
||||
(liste: AgentSystem[]) => saveSystems({ id: agent.id, systems: liste }),
|
||||
[saveSystems, agent.id],
|
||||
)
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: string, enabled: boolean) => {
|
||||
speichern(systems.map((s) => (s.id === id ? { ...s, enabled } : s)))
|
||||
},
|
||||
[speichern, systems],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => speichern(removeById(systems, id)),
|
||||
[speichern, systems],
|
||||
)
|
||||
|
||||
const handleDialogSave = useCallback(
|
||||
(werte: EntityDraft, active: boolean) => {
|
||||
const bestehend = bearbeitet !== 'new' ? systems.find((s) => s.id === bearbeitet) : undefined
|
||||
const url = werte.url?.trim() ?? ''
|
||||
const eintrag: AgentSystem = {
|
||||
// Der Typ bleibt beim Bearbeiten erhalten; selbst erfasste Quellen sind
|
||||
// öffentliche Webquellen, weil genau das über die Adresse gelesen wird.
|
||||
type: bestehend?.type ?? AgentSystemType.PUBLIC_WEB,
|
||||
status: bestehend?.status ?? AgentConnectionStatus.CONNECTED,
|
||||
permissionNote: bestehend?.permissionNote ?? '',
|
||||
id: bestehend?.id ?? newEntityId(agent.id, 'system'),
|
||||
title: werte.title.trim(),
|
||||
usage: werte.description.trim(),
|
||||
access: werte.access as AgentAccessLevel,
|
||||
enabled: active,
|
||||
...(url ? { url } : {}),
|
||||
}
|
||||
speichern(upsertById(systems, eintrag))
|
||||
setBearbeitet(null)
|
||||
},
|
||||
[bearbeitet, systems, agent.id, speichern],
|
||||
)
|
||||
|
||||
const inBearbeitung = bearbeitet && bearbeitet !== 'new'
|
||||
? systems.find((s) => s.id === bearbeitet)
|
||||
: undefined
|
||||
|
||||
const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 2.5 }}>
|
||||
{/* Einordnung — dieser Reiter zeigt den Stand, er verändert ihn nicht */}
|
||||
<Box
|
||||
sx={{
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
borderRadius: 2,
|
||||
bgcolor: DS_BG.subtle,
|
||||
p: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.primary, fontWeight: 600 }}>
|
||||
{intro}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.secondary }}>
|
||||
Diese Übersicht zeigt den aktuellen Stand. Eingerichtet werden Systemzugänge auf der Seite «Kanäle &
|
||||
Systeme».
|
||||
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>
|
||||
Diese Systeme und Datenquellen darf {agent.name} verwenden. Ein abgeschalteter Zugang wird nicht
|
||||
mehr abgefragt.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Pencil size={14} aria-hidden />}
|
||||
onClick={() => setBearbeitungsmodus((v) => !v)}
|
||||
sx={{ textTransform: 'none', ...focusRing }}
|
||||
>
|
||||
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Systeme bearbeiten'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Plus size={14} aria-hidden />}
|
||||
onClick={() => setBearbeitet('new')}
|
||||
sx={{ textTransform: 'none', ...focusRing }}
|
||||
>
|
||||
Neues System anbinden
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{agent.systems.length === 0 ? (
|
||||
{systems.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
border: `1px solid ${DS_BORDER.default}`,
|
||||
@@ -212,11 +263,39 @@ export function AgentSystemsTab({ agent }: { agent: TeamAgent }) {
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<SystemGroup title={GROUP_TITLE_READ} systems={reading} />
|
||||
<SystemGroup title={GROUP_TITLE_WRITE} systems={writing} />
|
||||
</>
|
||||
<Box component="ul" sx={{ m: 0, p: 0, listStyle: 'none', display: 'grid', gap: 1.25 }}>
|
||||
{systems.map((system) => (
|
||||
<SystemRow
|
||||
key={system.id}
|
||||
system={system}
|
||||
busy={isPending}
|
||||
editing={bearbeitungsmodus}
|
||||
onToggle={handleToggle}
|
||||
onEdit={setBearbeitet}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AgentEntityDialog
|
||||
open={bearbeitet !== null}
|
||||
entityKey={bearbeitet ?? undefined}
|
||||
title={inBearbeitung ? 'System bearbeiten' : 'Neues System anbinden'}
|
||||
fields={SYSTEM_FIELDS}
|
||||
initial={{
|
||||
title: inBearbeitung ? systemTitel(inBearbeitung) : '',
|
||||
description: inBearbeitung?.usage ?? '',
|
||||
url: inBearbeitung?.url ?? '',
|
||||
access: inBearbeitung && istSchreibend(inBearbeitung)
|
||||
? AgentAccessLevel.WRITE
|
||||
: AgentAccessLevel.READ,
|
||||
}}
|
||||
activeLabel="Zugang aktiv"
|
||||
initialActive={inBearbeitung ? istAktiv(inBearbeitung) : true}
|
||||
onSave={handleDialogSave}
|
||||
onClose={() => setBearbeitet(null)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,14 @@
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { Box, Button, Switch, Typography } from '@mui/material'
|
||||
import { RotateCcw, Save, ShieldCheck } from 'lucide-react'
|
||||
import { Box, Button, IconButton, Switch, Tooltip, Typography } from '@mui/material'
|
||||
import { Pencil, Plus, RotateCcw, Save, ShieldCheck, Trash2 } from 'lucide-react'
|
||||
import type { AgentTask, TeamAgent } from '../../domain/teamAgent'
|
||||
import { DS_BG, DS_BORDER, DS_SHADOW, DS_SURFACE, DS_TEXT } from '../../lib/ds'
|
||||
import { GenericBadge } from '../shared/GenericBadge'
|
||||
import { AgentEntityDialog } from './AgentEntityDialog'
|
||||
import type { EntityDraft, EntityField } from './AgentEntityDialog'
|
||||
import { newEntityId, removeById, upsertById } from './agentEntityEditing'
|
||||
import { useSaveAgentTasks } from '../../hooks/useTeamAgents'
|
||||
|
||||
// ── Eine Aufgabenzeile ────────────────────────────────────────────────────────
|
||||
@@ -22,13 +25,17 @@ interface TaskRowProps {
|
||||
task: AgentTask
|
||||
busy: boolean
|
||||
onToggle: (id: string, enabled: boolean) => void
|
||||
/** Im Bearbeitungsmodus erscheinen Stift und Papierkorb an der Zeile (§2A). */
|
||||
editing: boolean
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* `React.memo` ist Pflicht: ein Mitarbeiter führt bis zu zwölf Aufgaben, und
|
||||
* jeder Schalterklick verändert den Entwurf der ganzen Liste (CLAUDE.md §10.2).
|
||||
*/
|
||||
const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
||||
const TaskRow = memo(function TaskRow({ task, busy, onToggle, editing, onEdit, onDelete }: TaskRowProps) {
|
||||
const handleChange = useCallback(
|
||||
(_event: unknown, checked: boolean) => onToggle(task.id, checked),
|
||||
[onToggle, task.id],
|
||||
@@ -71,9 +78,26 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary }}>
|
||||
{task.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||
<Typography sx={{ fontWeight: 700, fontSize: '0.875rem', lineHeight: 1.3, color: DS_TEXT.primary, flex: 1 }}>
|
||||
{task.title}
|
||||
</Typography>
|
||||
|
||||
{editing && (
|
||||
<Box sx={{ display: 'flex', gap: 0.25, flexShrink: 0 }}>
|
||||
<Tooltip title="Aufgabe bearbeiten">
|
||||
<IconButton size="small" onClick={() => onEdit(task.id)} sx={{ color: DS_TEXT.secondary }}>
|
||||
<Pencil size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Aufgabe löschen">
|
||||
<IconButton size="small" onClick={() => onDelete(task.id)} sx={{ color: DS_TEXT.secondary }}>
|
||||
<Trash2 size={14} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Zwei Zeilen genügen: die Karte soll überflogen werden, der volle
|
||||
Wortlaut steht am Element. */}
|
||||
@@ -111,10 +135,27 @@ const TaskRow = memo(function TaskRow({ task, busy, onToggle }: TaskRowProps) {
|
||||
|
||||
// ── Register «Aufgaben» ───────────────────────────────────────────────────────
|
||||
|
||||
/** Die Felder der Aufgabenmaske (Runde 10, §2A). */
|
||||
const TASK_FIELDS: EntityField[] = [
|
||||
{ key: 'title', label: 'Aufgabentitel', required: true },
|
||||
{ key: 'description', label: 'Aufgabenbeschreibung', type: 'multiline', required: true },
|
||||
{
|
||||
key: 'schedule',
|
||||
label: 'Auslöser oder Rhythmus',
|
||||
placeholder: 'z. B. täglich 06:00 oder bei Eingang',
|
||||
helperText: 'Wann die Aufgabe läuft — steht so auch im Personalblatt.',
|
||||
},
|
||||
]
|
||||
|
||||
export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
|
||||
const [draft, setDraft] = useState<AgentTask[]>(agent.tasks)
|
||||
const saveTasks = useSaveAgentTasks()
|
||||
|
||||
/** `null` = Dialog zu, `'new'` = Anlegen, sonst die ID des bearbeiteten Eintrags. */
|
||||
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
|
||||
/** Zeigt die Bearbeiten- und Löschknöpfe an den Zeilen. */
|
||||
const [bearbeitungsmodus, setBearbeitungsmodus] = useState(false)
|
||||
|
||||
/**
|
||||
* Beim Wechsel des Mitarbeiters wird der Entwurf neu aufgesetzt — sonst stünde
|
||||
* der Entwurf des zuvor gewählten Mitarbeiters in einem fremden Personalblatt.
|
||||
@@ -147,6 +188,40 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
|
||||
saveTasks.mutate({ id: agent.id, tasks: draft })
|
||||
}, [saveTasks, agent.id, draft])
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
setDraft(prev => removeById(prev, id))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Anlegen und Bearbeiten teilen sich die Maske und diesen Griff.
|
||||
*
|
||||
* Der Eintrag landet im Entwurf und nicht direkt im Bestand — gespeichert
|
||||
* wird wie bisher bewusst erst über «Änderungen speichern». Zwei
|
||||
* Speicherwege für dieselbe Liste wären zwei Wahrheiten darüber, was gerade
|
||||
* gilt.
|
||||
*/
|
||||
const handleDialogSave = useCallback((werte: EntityDraft, active: boolean) => {
|
||||
setDraft(prev => {
|
||||
const bestehend = bearbeitet !== 'new' ? prev.find(t => t.id === bearbeitet) : undefined
|
||||
const eintrag: AgentTask = {
|
||||
...(bestehend ?? {
|
||||
id: newEntityId(agent.id, 'task'),
|
||||
requiresApproval: false,
|
||||
} as AgentTask),
|
||||
title: werte.title.trim(),
|
||||
description: werte.description.trim(),
|
||||
schedule: werte.schedule?.trim() || 'auf Auslösung',
|
||||
enabled: active,
|
||||
}
|
||||
return upsertById(prev, eintrag)
|
||||
})
|
||||
setBearbeitet(null)
|
||||
}, [bearbeitet, agent.id])
|
||||
|
||||
const inBearbeitung = bearbeitet && bearbeitet !== 'new'
|
||||
? draft.find(t => t.id === bearbeitet)
|
||||
: undefined
|
||||
|
||||
const busy = saveTasks.isPending
|
||||
const focusRing = { '&:focus-visible': { outline: `2px solid ${DS_TEXT.brand}`, outlineOffset: 2 } }
|
||||
|
||||
@@ -160,6 +235,28 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
|
||||
Speichern für {agent.name} wirksam.
|
||||
</Typography>
|
||||
|
||||
{/* Auf Höhe der Überschrift, im Stil des bestehenden «Konfiguration öffnen» (§2A). */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Pencil size={14} aria-hidden />}
|
||||
onClick={() => setBearbeitungsmodus(v => !v)}
|
||||
sx={{ textTransform: 'none', ...focusRing }}
|
||||
>
|
||||
{bearbeitungsmodus ? 'Bearbeiten beenden' : 'Aufgaben bearbeiten'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Plus size={14} aria-hidden />}
|
||||
onClick={() => setBearbeitet('new')}
|
||||
sx={{ textTransform: 'none', ...focusRing }}
|
||||
>
|
||||
Neue Aufgabe
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{dirty && (
|
||||
<Box
|
||||
role="status"
|
||||
@@ -227,10 +324,34 @@ export function AgentTasksTab({ agent }: { agent: TeamAgent }) {
|
||||
}}
|
||||
>
|
||||
{draft.map((task) => (
|
||||
<TaskRow key={task.id} task={task} busy={busy} onToggle={handleToggle} />
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
busy={busy}
|
||||
onToggle={handleToggle}
|
||||
editing={bearbeitungsmodus}
|
||||
onEdit={setBearbeitet}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AgentEntityDialog
|
||||
open={bearbeitet !== null}
|
||||
title={inBearbeitung ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}
|
||||
fields={TASK_FIELDS}
|
||||
entityKey={bearbeitet ?? undefined}
|
||||
initial={{
|
||||
title: inBearbeitung?.title ?? '',
|
||||
description: inBearbeitung?.description ?? '',
|
||||
schedule: inBearbeitung?.schedule ?? '',
|
||||
}}
|
||||
activeLabel="Aufgabe aktiv"
|
||||
initialActive={inBearbeitung?.enabled ?? true}
|
||||
onSave={handleDialogSave}
|
||||
onClose={() => setBearbeitet(null)}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { Box, IconButton, TextField, Tooltip, Typography } from '@mui/material'
|
||||
import { SendHorizonal } from 'lucide-react'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
import { AgentChatTranscript } from './AgentChatTranscript'
|
||||
import { AGENT_CHAT_MARKER, useAgentChat } from './useAgentChat'
|
||||
import { AGENT_CHAT_PROMPT } from '../../lib/constants'
|
||||
import { DS_BORDER, DS_TEXT } from '../../lib/ds'
|
||||
@@ -153,6 +154,8 @@ export function AgentWorkspaceHero({ agentId, name, role, channels = [] }: Agent
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<AgentChatTranscript />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Box, Drawer, IconButton, Typography } from '@mui/material'
|
||||
import { HelpCircle, X } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useTeamStore } from '../../stores/teamStore'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { useAgentWorkItem } from '../../hooks/useAgentWorkItems'
|
||||
import { useTeamAgents } from '../../hooks/useTeamAgents'
|
||||
import { AgentAvatar } from './AgentAvatar'
|
||||
@@ -148,6 +149,8 @@ export function WorkItemDetailDrawer() {
|
||||
<Typography variant="body2" sx={{ color: DS_TEXT.secondary }}>{item.rejectionReason}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Die drei Listengriffe der Agentenverwaltung (Runde 10, §2).
|
||||
*
|
||||
* Geprüft wird, was in den Reitern Aufgaben, Systeme und Kanäle täglich
|
||||
* passiert: einen Eintrag ändern, einen anlegen, einen entfernen — und dass
|
||||
* die übrige Reihenfolge dabei unberührt bleibt.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { newEntityId, removeById, upsertById } from '../agentEntityEditing'
|
||||
|
||||
interface Eintrag {
|
||||
id: string
|
||||
titel: string
|
||||
}
|
||||
|
||||
const liste: Eintrag[] = [
|
||||
{ id: 'a', titel: 'Erster' },
|
||||
{ id: 'b', titel: 'Zweiter' },
|
||||
{ id: 'c', titel: 'Dritter' },
|
||||
]
|
||||
|
||||
describe('upsertById', () => {
|
||||
it('ersetzt einen bestehenden Eintrag an Ort und Stelle', () => {
|
||||
const ergebnis = upsertById(liste, { id: 'b', titel: 'Geändert' })
|
||||
|
||||
expect(ergebnis.map((e) => e.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(ergebnis[1].titel).toBe('Geändert')
|
||||
})
|
||||
|
||||
it('hängt einen neuen Eintrag ans Ende', () => {
|
||||
const ergebnis = upsertById(liste, { id: 'd', titel: 'Vierter' })
|
||||
|
||||
expect(ergebnis.map((e) => e.id)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('lässt die übergebene Liste unverändert', () => {
|
||||
upsertById(liste, { id: 'b', titel: 'Geändert' })
|
||||
|
||||
expect(liste[1].titel).toBe('Zweiter')
|
||||
expect(liste).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeById', () => {
|
||||
it('entfernt genau einen Eintrag', () => {
|
||||
expect(removeById(liste, 'b').map((e) => e.id)).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('lässt eine Liste ohne Treffer unverändert', () => {
|
||||
expect(removeById(liste, 'unbekannt').map((e) => e.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('newEntityId', () => {
|
||||
it('stellt Agent und Art voran, damit die Kennung zuordenbar bleibt', () => {
|
||||
expect(newEntityId('livia', 'task')).toMatch(/^livia-task-/)
|
||||
})
|
||||
|
||||
it('vergibt für jeden Aufruf eine eigene Kennung', () => {
|
||||
const kennungen = new Set(
|
||||
Array.from({ length: 50 }, () => newEntityId('nora', 'system')),
|
||||
)
|
||||
|
||||
expect(kennungen.size).toBe(50)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Property On — die gemeinsamen Listenoperationen der Agentenverwaltung
|
||||
* (Runde 10, §2).
|
||||
*
|
||||
* Aufgaben, Systeme und Kanäle werden alle drei als Liste geführt und gleich
|
||||
* bearbeitet: einen Eintrag ändern, einen anlegen, einen entfernen. Diese drei
|
||||
* Griffe stehen deshalb einmal hier statt dreimal in den Reitern — sonst
|
||||
* unterscheiden sie sich nach der ersten Korrektur.
|
||||
*
|
||||
* Bewusst reine Funktionen ohne React: Sie lassen sich so ohne Renderer prüfen,
|
||||
* und die Reiter behalten die Hoheit über ihren Entwurf.
|
||||
*/
|
||||
|
||||
interface MitId { id: string }
|
||||
|
||||
/**
|
||||
* Eintrag ersetzen oder anhängen.
|
||||
*
|
||||
* Neue Einträge kommen ans Ende und nicht an den Anfang: Wer etwas anlegt,
|
||||
* sucht es dort, wo die Liste weitergeht — und die gewachsene Reihenfolge der
|
||||
* bestehenden Einträge bleibt unangetastet.
|
||||
*/
|
||||
export function upsertById<T extends MitId>(list: T[], eintrag: T): T[] {
|
||||
const idx = list.findIndex(e => e.id === eintrag.id)
|
||||
if (idx === -1) return [...list, eintrag]
|
||||
const kopie = [...list]
|
||||
kopie[idx] = eintrag
|
||||
return kopie
|
||||
}
|
||||
|
||||
export function removeById<T extends MitId>(list: T[], id: string): T[] {
|
||||
return list.filter(e => e.id !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennung für einen neu angelegten Eintrag.
|
||||
*
|
||||
* Präfix aus Agent und Entität, damit man einer Kennung im Zweifel ansieht,
|
||||
* wohin sie gehört. `crypto.randomUUID` ist in allen Zielbrowsern vorhanden;
|
||||
* der Zeitstempel-Rückfall greift nur in Testumgebungen ohne Web-Crypto.
|
||||
*/
|
||||
export function newEntityId(agentId: string, art: string): string {
|
||||
const zufall = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID().slice(0, 8)
|
||||
: Date.now().toString(36)
|
||||
return `${agentId}-${art}-${zufall}`
|
||||
}
|
||||
@@ -87,3 +87,6 @@ export {
|
||||
PROTOCOL_STATUS_SURFACE,
|
||||
PROTOCOL_STATUS_TEXT,
|
||||
} from './teamTokens'
|
||||
|
||||
// Runde 9, §3: Gesprächsverlauf unter dem Chat-Einstieg.
|
||||
export { AgentChatTranscript } from './AgentChatTranscript'
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { FocusEvent, KeyboardEvent } from 'react'
|
||||
import { useAgentChatStore } from '../../stores/agentChatStore'
|
||||
import { useToastStore } from '../../stores/toastStore'
|
||||
import { useAgentChatAnswer } from '../../hooks/useAgentChatAnswer'
|
||||
|
||||
/**
|
||||
* Markiert beide Eingabefelder des Agentenchats. Wandert der Fokus vom einen
|
||||
@@ -29,7 +29,10 @@ interface Options {
|
||||
* vergessen.
|
||||
*
|
||||
* Das Absenden bleibt Frontend-Simulation: es existiert kein Chat-Backend, und
|
||||
* eine erfundene Antwort wäre schlimmer als eine ehrliche Rückmeldung.
|
||||
* Seit Runde 9 (§3) antwortet der Chat tatsächlich: aus den Beständen der
|
||||
* jeweiligen Agentenseite, per Schlüsselwortlogik, ohne Backend und ohne
|
||||
* Sprachmodell. Was er nicht belegen kann, sagt er nicht — eine erfundene Zahl
|
||||
* wäre schlimmer als ein «das habe ich nicht verstanden».
|
||||
*/
|
||||
export function useAgentChat({ active = true }: Options = {}) {
|
||||
const agent = useAgentChatStore(s => s.agent)
|
||||
@@ -37,7 +40,8 @@ export function useAgentChat({ active = true }: Options = {}) {
|
||||
const focused = useAgentChatStore(s => s.focused)
|
||||
const setDraft = useAgentChatStore(s => s.setDraft)
|
||||
const setFocused = useAgentChatStore(s => s.setFocused)
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const append = useAgentChatStore(s => s.append)
|
||||
const answerFor = useAgentChatAnswer()
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -54,15 +58,24 @@ export function useAgentChat({ active = true }: Options = {}) {
|
||||
el.setSelectionRange(end, end)
|
||||
}, [active, focused])
|
||||
|
||||
/**
|
||||
* Frage absenden und beantworten (Runde 9, §3).
|
||||
*
|
||||
* Bis hierher meldete das Absenden nur, der Chat sei nicht angebunden. Jetzt
|
||||
* antwortet der Agent aus den Beständen seiner eigenen Seite — ohne Backend
|
||||
* und ohne Sprachmodell, wie es der Auftrag verlangt.
|
||||
*
|
||||
* Frage und Antwort landen zusammen im Verlauf. Der Toast entfällt: Wer eine
|
||||
* Antwort im Chat sieht, braucht daneben keine Einblendung, die dasselbe
|
||||
* Ereignis noch einmal meldet.
|
||||
*/
|
||||
const send = useCallback(() => {
|
||||
const text = draft.trim()
|
||||
if (!text || !agent) return
|
||||
setDraft('')
|
||||
showToast(
|
||||
`Nachricht an ${agent.name} vorgemerkt — der Chat ist in dieser Demo noch nicht angebunden.`,
|
||||
'info',
|
||||
)
|
||||
}, [draft, agent, setDraft, showToast])
|
||||
append({ from: 'user', text })
|
||||
append({ from: 'agent', text: answerFor(agent, text) })
|
||||
}, [draft, agent, setDraft, append, answerFor])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { memo } from 'react'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { Info } from 'lucide-react'
|
||||
import { AI_DISCLAIMER } from '../../lib/constants'
|
||||
import { DS_SLATE, DS_TEXT } from '../../lib/ds'
|
||||
|
||||
/**
|
||||
* Der einheitliche KI-Hinweis am Fuss jeder Agenten-Detailansicht (Runde 9, §5).
|
||||
*
|
||||
* Eine einzige Komponente für alle sechs Detailansichten. Der Wortlaut ist
|
||||
* vorgegeben und steht deshalb als Konstante in `constants.ts` — stünde er
|
||||
* sechsmal im Code, hiesse er nach der ersten Korrektur an fünf Stellen anders,
|
||||
* und ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner.
|
||||
*
|
||||
* Bewusst dezent: grau, klein, mit dünner Trennlinie darüber. Er soll gelesen
|
||||
* werden können, ohne die Aussage darüber zu übertönen — ein roter Warnkasten
|
||||
* an jeder Detailansicht stumpft nach dem dritten Mal ab und wird dann gar
|
||||
* nicht mehr gelesen.
|
||||
*/
|
||||
export const AiDisclaimer = memo(function AiDisclaimer() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 0.75,
|
||||
mt: 2,
|
||||
pt: 1.5,
|
||||
borderTop: `1px solid ${DS_SLATE[100]}`,
|
||||
}}
|
||||
>
|
||||
<Info size={13} color={DS_SLATE[400]} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Typography variant="caption" sx={{ color: DS_TEXT.muted, lineHeight: 1.5 }}>
|
||||
{AI_DISCLAIMER}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
@@ -12,3 +12,6 @@ export { ToastProvider } from './ToastProvider'
|
||||
export { ConfirmDialog } from './ConfirmDialog'
|
||||
export { DecisionContextPanel } from './DecisionContextPanel'
|
||||
export type { DecisionMetric, DecisionAction } from './DecisionContextPanel'
|
||||
|
||||
// Runde 9, §5: einheitlicher KI-Hinweis in allen Agenten-Detailansichten.
|
||||
export { AiDisclaimer } from './AiDisclaimer'
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Alert, Box, Button, Divider, Drawer, IconButton, Typography } from '@mui/material'
|
||||
import { AlertTriangle, Download, Eye, FileText, Headphones, X } from 'lucide-react'
|
||||
import type { VisitAssignment } from '../../domain/visitAssignment'
|
||||
import { AiDisclaimer } from '../ui'
|
||||
import { VisitRequestType } from '../../domain/visitAssignment'
|
||||
import { needsReportWarning } from '../../services/visitAssignmentService'
|
||||
import { useRequestVisitReport } from '../../hooks/useVisitAssignments'
|
||||
@@ -292,6 +293,8 @@ export function VisitPreparationDrawer({ assignment, onClose }: Props) {
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AiDisclaimer />
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SignalType, RiskLevel, ReviewStatus } from './enums'
|
||||
import type { TemporalClass } from './researchLead'
|
||||
|
||||
export type ContactType = 'EMAIL' | 'PHONE' | 'WEBSITE' | 'LINKEDIN' | 'CONTACT_PERSON'
|
||||
export type ContactConfidence = 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
@@ -87,6 +88,38 @@ export interface FutureSignal {
|
||||
affectedTenant?: string
|
||||
/** Externe Quellen, aus denen Livia den Lead verdichtet hat. */
|
||||
researchSources?: SignalSource[]
|
||||
|
||||
// ── Runde 10 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wann die beschriebene Veränderung stattfindet (§2.9).
|
||||
*
|
||||
* Ausdrücklich nicht dasselbe wie `source.publishedAt` und auch nicht
|
||||
* dasselbe wie `timeHorizonMonths`: Der Horizont ist eine Schätzung in
|
||||
* Monaten, diese Klasse eine belegte Einordnung mit Fundstelle. Optional,
|
||||
* weil Bestandssignale sie nicht tragen — fehlt sie, gilt der Lead als
|
||||
* zeitlich nicht eingeordnet und fällt durch keinen Zeitfilter.
|
||||
*/
|
||||
temporalClass?: TemporalClass
|
||||
/** Die Textstelle, die die zeitliche Einordnung trägt. Ohne sie ist sie nicht prüfbar. */
|
||||
temporalEvidence?: string
|
||||
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
|
||||
eventDate?: string
|
||||
/**
|
||||
* Ausformuliertes Nachfrageprofil (§2.13B).
|
||||
*
|
||||
* Einzige Wahrheit für Livia und Nora: Livia erstellt es, Nora gleicht es
|
||||
* gegen den Bestand ab. Nora erzeugt bewusst keinen eigenen Text — zwei
|
||||
* Fassungen desselben Profils wären zwei Aussagen über denselben Bedarf.
|
||||
*/
|
||||
semanticSearchProfile?: string
|
||||
/**
|
||||
* Herkunft des Signals. `RESEARCH_RUN` markiert Leads, die ein
|
||||
* Recherchelauf erzeugt hat — sie stehen in derselben Liste wie die
|
||||
* erfassten und unterscheiden sich nur durch dieses Feld, nicht durch eine
|
||||
* eigene Ablage (§2.12).
|
||||
*/
|
||||
origin?: 'SEED' | 'RESEARCH_RUN'
|
||||
}
|
||||
|
||||
// ── Chance / Risiko (Runde 8, §2) ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Property On — ein durchgeführter Matching-Lauf (Runde 10, §§3.11/3.13).
|
||||
*
|
||||
* Der Lauf wird festgehalten, weil zwei Anzeigen davon abhängen: das Protokoll
|
||||
* und die Kennzahl «Anzahl durchgeführte Matchings». Beide sollen zählen, was
|
||||
* tatsächlich passiert ist — nicht, wie oft jemand eine Seite geöffnet hat.
|
||||
*
|
||||
* Deshalb ist ein MatchRun eine eigene Entität und kein Nebeneffekt des
|
||||
* Renderns: Ein Wert, der aus dem Rendern entsteht, wächst bei jedem
|
||||
* Neuaufbau der Komponente, und niemand könnte dem Zähler noch trauen.
|
||||
*/
|
||||
|
||||
export interface MatchRun {
|
||||
id: string
|
||||
/** Der Lead, für den gematcht wurde — dieselbe Kennung wie bei Livia. */
|
||||
leadId: string
|
||||
/** Firma oder Titel des Leads, damit das Protokoll ohne Nachschlagen lesbar ist. */
|
||||
leadTitle: string
|
||||
timestamp: string
|
||||
/** Wie viele Objekte in den Kandidatenkreis kamen. */
|
||||
candidateCount: number
|
||||
/** Ob belegte Objekte mitgeprüft wurden. */
|
||||
includedOccupied: boolean
|
||||
/** Bester erreichter Wert — die Zahl, die den Lauf zusammenfasst. */
|
||||
topScore: number
|
||||
/**
|
||||
* Ob der Lauf aus Livias «Matching starten» kam oder auf Noras Seite
|
||||
* ausgelöst wurde. Beides zählt gleich, aber beim Nachvollziehen ist der
|
||||
* Unterschied die erste Frage.
|
||||
*/
|
||||
trigger: 'LIVIA_HANDOVER' | 'USER'
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Property On — Livias Research-PoC: echte Quellen statt Mockdaten.
|
||||
*
|
||||
* Diese Typen werden von beiden Seiten benutzt — von der serverlosen Funktion
|
||||
* unter `api/` und vom Frontend. Sie liegen deshalb im Domänenordner und nicht
|
||||
* neben dem Endpoint: ein zweites, fast gleiches Typenpaar wäre die erste
|
||||
* Stelle, an der Server und Oberfläche auseinanderlaufen.
|
||||
*
|
||||
* Bewusst klein gehalten (PoC, §9/§13). Es ist ausdrücklich **nicht** der
|
||||
* `FutureSignal`-Typ: der trägt Wahrscheinlichkeiten, Datenqualität und
|
||||
* Portfoliobezug, die eine Live-Recherche nicht liefern kann. Ein Feld zu
|
||||
* füllen, für das keine Grundlage existiert, wäre genau die Behauptung, die
|
||||
* Livia nicht aufstellen darf.
|
||||
*/
|
||||
|
||||
/** Die neun Kategorien des PoC (§11). */
|
||||
export const ResearchEventType = {
|
||||
EXPANSION: 'EXPANSION',
|
||||
NEW_LOCATION: 'NEW_LOCATION',
|
||||
HEADCOUNT_GROWTH: 'HEADCOUNT_GROWTH',
|
||||
INVESTMENT: 'INVESTMENT',
|
||||
M_AND_A: 'M_AND_A',
|
||||
RESTRUCTURING: 'RESTRUCTURING',
|
||||
SITE_CLOSURE: 'SITE_CLOSURE',
|
||||
NEW_COMPANY: 'NEW_COMPANY',
|
||||
IRRELEVANT: 'IRRELEVANT',
|
||||
} as const
|
||||
export type ResearchEventType = typeof ResearchEventType[keyof typeof ResearchEventType]
|
||||
|
||||
export type ResearchRelevance = 'high' | 'medium' | 'low'
|
||||
|
||||
/** Ein von Livia aus einer echten Quelle abgeleiteter Lead (§13). */
|
||||
export interface ResearchLead {
|
||||
company: string
|
||||
eventType: ResearchEventType
|
||||
headline: string
|
||||
location?: string
|
||||
summary: string
|
||||
relevance: ResearchRelevance
|
||||
/** 0–1. Wie sicher ist die Einordnung angesichts der Quellenlage? */
|
||||
confidence: number
|
||||
reason: string
|
||||
recommendedAction?: string
|
||||
/** Anzeigename der Quelle, z. B. «Zürcher Handelskammer». */
|
||||
source: string
|
||||
/** Die tatsächlich gelesene Originalquelle — muss anklickbar sein (§2). */
|
||||
sourceUrl: string
|
||||
publishedAt?: string
|
||||
|
||||
// ── Runde 10 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zeitliche Dimension der beschriebenen Veränderung (§2.9). */
|
||||
temporalClass: TemporalClass
|
||||
/** Der Satz aus der Quelle, der die Einordnung trägt — ohne ihn ist sie nicht prüfbar. */
|
||||
temporalEvidence: string
|
||||
/** Nur gesetzt, wenn die Quelle ein Datum nennt. Nie geschätzt. */
|
||||
eventDate?: string
|
||||
/**
|
||||
* Ausformuliertes Nachfrageprofil für Chance-Leads (§2.13B).
|
||||
*
|
||||
* Kein Stichwortfeld: Nora gleicht es gegen den Objektbestand ab, und dafür
|
||||
* muss darin stehen, was die Quelle hergibt — einschliesslich dessen, was sie
|
||||
* nicht hergibt. Ein Profil, das Lücken verschweigt, führt zu Treffern, die
|
||||
* niemand belegen kann.
|
||||
*/
|
||||
semanticSearchProfile?: string
|
||||
}
|
||||
|
||||
/** Ergebnis je abgerufener Quelle — auch im Fehlerfall (§21). */
|
||||
export interface ResearchSourceResult {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
ok: boolean
|
||||
/** Gelesene Einträge; 0, wenn die Quelle nicht erreichbar war. */
|
||||
itemCount: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Antwort des Refresh-Endpoints. */
|
||||
export interface ResearchRefreshResult {
|
||||
sources: ResearchSourceResult[]
|
||||
/** Wie viele Einträge insgesamt gelesen wurden. */
|
||||
analyzed: number
|
||||
/** Davon als nicht relevant verworfen — der Kern des PoC (§14). */
|
||||
discarded: number
|
||||
/** Beobachtungswürdig, aber ohne bestätigten Flächenbezug. */
|
||||
watchlist: number
|
||||
/** Aussortiert, weil die Ereigniszeit ausserhalb der gewählten Fenster lag (§2.9). */
|
||||
temporalFiltered: number
|
||||
/** Nur `high` und `medium` erscheinen hier. */
|
||||
leads: ResearchLead[]
|
||||
/** Zeitpunkt des Laufs, ISO. */
|
||||
refreshedAt: string
|
||||
/**
|
||||
* Welches Modell die Einordnung vorgenommen hat.
|
||||
*
|
||||
* Wird angezeigt, weil die Modellwahl über eine Umgebungsvariable läuft und
|
||||
* man sonst nirgends in der Anwendung sähe, welche gerade gilt — eine
|
||||
* Einstellung, die man nur im Hosting-Dashboard prüfen kann, prüft niemand.
|
||||
*/
|
||||
model: string
|
||||
/**
|
||||
* Gesetzt, wenn die Analyse nicht laufen konnte — etwa weil serverseitig
|
||||
* kein Schlüssel hinterlegt ist. Die Oberfläche sagt das dann offen, statt
|
||||
* eine leere Liste als «nichts gefunden» auszugeben.
|
||||
*/
|
||||
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
|
||||
@@ -115,6 +115,24 @@ export interface AgentSystem {
|
||||
type: AgentSystemType
|
||||
access: AgentAccessLevel
|
||||
status: AgentConnectionStatus
|
||||
/**
|
||||
* Ob der Mitarbeiter diesen Zugang derzeit nutzt. Optional, weil der Katalog
|
||||
* die Zugänge ohne Schalter führt — fehlt das Feld, gilt der Zugang als aktiv.
|
||||
*/
|
||||
enabled?: boolean
|
||||
/**
|
||||
* Eigener Titel der Quelle (Runde 10, §2.1). Der Katalog benennt seine
|
||||
* Zugänge über `type`; selbst angebundene Quellen haben keinen passenden
|
||||
* Typ und tragen ihren Namen deshalb hier. Fehlt der Titel, gilt das Label
|
||||
* des Typs — so bleiben die Katalogeinträge unverändert.
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* Adresse der Quelle. Bei einer aktiven Lesequelle ist das die Seite, die
|
||||
* Livias Recherchelauf tatsächlich abruft — der angezeigte Link und der
|
||||
* gelesene Link sind damit derselbe Wert.
|
||||
*/
|
||||
url?: string
|
||||
/** Wofür der Agent dieses System nutzt — in Alltagssprache, ohne KI-Jargon. */
|
||||
usage: string
|
||||
/** Klartext zur Berechtigung, z. B. «Schreibt erst nach Freigabe». */
|
||||
@@ -144,6 +162,18 @@ export const AgentSettingKind = {
|
||||
MULTI_SELECT: 'MULTI_SELECT',
|
||||
BOOLEAN: 'BOOLEAN',
|
||||
TIME: 'TIME',
|
||||
/**
|
||||
* Mehrfachauswahl als Chips, jeder einzeln entfernbar (Runde 10, §§2.7–2.9).
|
||||
*
|
||||
* Der Unterschied zu `MULTI_SELECT` ist nicht bloss die Darstellung: Ein
|
||||
* Auswahlfeld kann nur anbieten, was jemand vorher als Option hinterlegt
|
||||
* hat. Quellentypen und Beobachtungsräume sind aber genau das, was sich von
|
||||
* Haus zu Haus unterscheidet — sie brauchen ein Feld, in das man schreiben
|
||||
* kann, was man meint.
|
||||
*/
|
||||
CHIPS: 'CHIPS',
|
||||
/** Abgelegte Dokumente als Datenbasis der Recherche (Runde 10, §2.6). */
|
||||
DOCUMENTS: 'DOCUMENTS',
|
||||
} as const
|
||||
export type AgentSettingKind = typeof AgentSettingKind[keyof typeof AgentSettingKind]
|
||||
|
||||
@@ -174,6 +204,21 @@ export interface AgentSetting {
|
||||
/** Fachlich nicht abschaltbar — z. B. Sinas Quellenzwang. */
|
||||
locked?: boolean
|
||||
lockedReason?: string
|
||||
/**
|
||||
* Erklärt auf Knopfdruck, wie ein Wert zustande kommt (Runde 10, §2.10).
|
||||
*
|
||||
* Gehört ans Datenmodell und nicht in die Komponente: Was ein Schwellenwert
|
||||
* bedeutet, weiss das Personalblatt, nicht das Eingabefeld. Und ein Text,
|
||||
* der eine Berechnung beschreibt, muss neben der Berechnung stehen, damit
|
||||
* beide zusammen geändert werden.
|
||||
*/
|
||||
infoText?: string
|
||||
/**
|
||||
* Nur bei `CHIPS`: ob eigene Werte erfasst werden dürfen. Bei einer
|
||||
* fachlich abgeschlossenen Liste — etwa den drei Zeitfenstern — bleibt es
|
||||
* aus, sonst entstünde ein vierter Wert, den niemand auswerten kann.
|
||||
*/
|
||||
allowCustom?: boolean
|
||||
}
|
||||
|
||||
// ── Kennzahlen & Profil ───────────────────────────────────────────────────────
|
||||
@@ -242,5 +287,7 @@ export interface UpdateTeamAgentInput {
|
||||
status?: AgentStatus
|
||||
tasks?: AgentTask[]
|
||||
channels?: AgentChannel[]
|
||||
/** Systeme sind seit Runde 10 (§2) genauso bearbeitbar wie Aufgaben und Kanäle. */
|
||||
systems?: AgentSystem[]
|
||||
settings?: AgentSetting[]
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Noras Abgleich von Nachfrageprofil und Objektbestand (Runde 10, §3.7).
|
||||
*
|
||||
* Die Tests halten drei Zusagen fest, die man dem Prozentwert sonst nicht
|
||||
* ansieht: Er ist reproduzierbar, er nimmt fehlende Objektangaben nicht als
|
||||
* erfüllt an, und belegte Objekte sind nur dabei, wenn jemand sie will.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MatchAvailability,
|
||||
bewerteObjekt,
|
||||
fuehreMatchingAus,
|
||||
leseNachfrageprofil,
|
||||
verfuegbarkeitVon,
|
||||
} from '../leadPropertyMatcher'
|
||||
import { AssetType, AvailabilityStatus, ResultType, RiskLevel } from '../../../domain/enums'
|
||||
import type { Property } from '../../../domain/property'
|
||||
import type { FutureSignal } from '../../../domain/futureSignal'
|
||||
import { SignalType } from '../../../domain/enums'
|
||||
|
||||
function objekt(overrides: Partial<Property> = {}): Property {
|
||||
return {
|
||||
id: 'p1',
|
||||
title: 'Gewerbefläche Zürich West',
|
||||
assetType: AssetType.OFFICE,
|
||||
resultType: ResultType.VERIFIED_PORTFOLIO,
|
||||
location: { city: 'Zürich', district: 'Kreis 5', country: 'CH' },
|
||||
address: { street: 'Hardstrasse', houseNumber: '10', postalCode: '8005', city: 'Zürich', country: 'CH' },
|
||||
areaSqm: 1200,
|
||||
rentPricePerSqm: 320,
|
||||
availabilityDate: '2026-10-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
|
||||
sourceType: 'MANUAL',
|
||||
confidenceScore: 0.9,
|
||||
dataQuality: { score: 0.9, missingCriticalFields: [], missingOptionalFields: [], freshness: 'FRESH', warnings: [] },
|
||||
riskLevel: RiskLevel.LOW,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
} as Property
|
||||
}
|
||||
|
||||
function signal(overrides: Partial<FutureSignal> = {}): FutureSignal {
|
||||
return {
|
||||
id: 's1',
|
||||
signalType: SignalType.EXPANSION,
|
||||
companyName: 'Muster AG',
|
||||
locationHint: 'Zürich',
|
||||
probability: 0.8,
|
||||
confidenceScore: 0.8,
|
||||
timeHorizonMonths: 9,
|
||||
source: { type: 'PRESS', credibility: 'HIGH' },
|
||||
sensitivityLevel: 'PUBLIC',
|
||||
disclaimer: '',
|
||||
riskLevel: RiskLevel.LOW,
|
||||
isVerified: false,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
updatedAt: '2026-09-01T00:00:00.000Z',
|
||||
semanticSearchProfile:
|
||||
'Für die Muster AG wird aufgrund der angekündigten Expansion ein zusätzlicher Bürostandort im Raum Zürich gesucht. '
|
||||
+ 'Erwartet wird eine Fläche von rund 1200 m². Wichtig ist eine gute Anbindung an den öffentlichen Verkehr. '
|
||||
+ 'Nicht ableitbar aus der Quelle sind Budget und gewünschter Ausbaustand.',
|
||||
...overrides,
|
||||
} as FutureSignal
|
||||
}
|
||||
|
||||
describe('verfuegbarkeitVon', () => {
|
||||
it('fasst frei und bald frei zusammen', () => {
|
||||
expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.AVAILABLE_NOW })))
|
||||
.toBe(MatchAvailability.FREI)
|
||||
expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.AVAILABLE_SOON })))
|
||||
.toBe(MatchAvailability.FREI)
|
||||
})
|
||||
|
||||
it('unterscheidet «unbekannt» von «frei»', () => {
|
||||
expect(verfuegbarkeitVon(objekt({ availabilityStatus: AvailabilityStatus.UNKNOWN })))
|
||||
.toBe(MatchAvailability.UNBEKANNT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('leseNachfrageprofil', () => {
|
||||
it('liest Ort, Fläche, Nutzung und genannte Anforderungen aus dem Profil', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
|
||||
expect(profil.orte).toContain('zürich')
|
||||
expect(profil.flaecheSqm).toBe(1200)
|
||||
expect(profil.nutzung).toContain('buero')
|
||||
expect(profil.anforderungen.oev).toBe(true)
|
||||
})
|
||||
|
||||
it('erfindet keine Anforderung, die im Profil nicht steht', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
|
||||
expect(profil.anforderungen.parkierung).toBe(false)
|
||||
expect(profil.anforderungen.sichtbarkeit).toBe(false)
|
||||
})
|
||||
|
||||
it('nimmt die erfasste Flächenschätzung vor der aus dem Fliesstext', () => {
|
||||
const profil = leseNachfrageprofil(signal({ areaSqmEstimate: 800 }))
|
||||
|
||||
expect(profil.flaecheSqm).toBe(800)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bewerteObjekt', () => {
|
||||
it('gibt einem passenden Objekt einen hohen Wert', () => {
|
||||
const { score } = bewerteObjekt(objekt(), leseNachfrageprofil(signal()))
|
||||
|
||||
expect(score).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('straft den falschen Ort ab', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
const zuerich = bewerteObjekt(objekt(), profil).score
|
||||
const genf = bewerteObjekt(
|
||||
objekt({
|
||||
location: { city: 'Genf', country: 'CH' },
|
||||
address: { street: 'Rue du Rhône', houseNumber: '1', postalCode: '1204', city: 'Genf', country: 'CH' },
|
||||
}),
|
||||
profil,
|
||||
).score
|
||||
|
||||
expect(genf).toBeLessThan(zuerich)
|
||||
})
|
||||
|
||||
it('straft eine stark abweichende Fläche ab', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
const passend = bewerteObjekt(objekt({ areaSqm: 1200 }), profil).score
|
||||
const winzig = bewerteObjekt(objekt({ areaSqm: 120 }), profil).score
|
||||
|
||||
expect(winzig).toBeLessThan(passend)
|
||||
})
|
||||
|
||||
it('nimmt ein fehlendes Merkmal nicht als erfüllt an', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
const kriterium = bewerteObjekt(objekt(), profil).kriterien.find(k => k.label === 'Erreichbarkeit')
|
||||
|
||||
// Das Objekt hat keinen Anbindungswert — das Kriterium wird geführt, aber
|
||||
// nicht bewertet. Ein `1` an dieser Stelle wäre eine erfundene Zusage.
|
||||
expect(kriterium?.erfuellung).toBeNull()
|
||||
})
|
||||
|
||||
it('bewertet nur Kriterien, die das Profil überhaupt nennt', () => {
|
||||
const knapp = signal({ semanticSearchProfile: 'Die Muster AG expandiert im Raum Zürich.' })
|
||||
const labels = bewerteObjekt(objekt(), leseNachfrageprofil(knapp)).kriterien.map(k => k.label)
|
||||
|
||||
expect(labels).toContain('Standort')
|
||||
expect(labels).not.toContain('Parkierung')
|
||||
expect(labels).not.toContain('Sichtbarkeit')
|
||||
})
|
||||
|
||||
it('liefert für dieselbe Eingabe denselben Wert', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
|
||||
expect(bewerteObjekt(objekt(), profil).score).toBe(bewerteObjekt(objekt(), profil).score)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fuehreMatchingAus', () => {
|
||||
const bestand = [
|
||||
objekt({ id: 'frei-zuerich', areaSqm: 1200 }),
|
||||
objekt({ id: 'frei-klein', areaSqm: 150 }),
|
||||
objekt({ id: 'belegt', availabilityStatus: AvailabilityStatus.OCCUPIED, currentTenant: 'Alt AG' }),
|
||||
]
|
||||
|
||||
it('lässt belegte Objekte standardmässig aussen vor', () => {
|
||||
const { matches, candidateCount } = fuehreMatchingAus(signal(), bestand, { includeOccupied: false })
|
||||
|
||||
expect(candidateCount).toBe(2)
|
||||
expect(matches.map(m => m.property.id)).not.toContain('belegt')
|
||||
})
|
||||
|
||||
it('nimmt belegte Objekte auf Wunsch dazu', () => {
|
||||
const { matches, candidateCount } = fuehreMatchingAus(signal(), bestand, { includeOccupied: true })
|
||||
|
||||
expect(candidateCount).toBe(3)
|
||||
expect(matches.map(m => m.property.id)).toContain('belegt')
|
||||
})
|
||||
|
||||
it('sortiert nach Match absteigend', () => {
|
||||
const { matches } = fuehreMatchingAus(signal(), bestand, { includeOccupied: true })
|
||||
const werte = matches.map(m => m.score)
|
||||
|
||||
expect([...werte].sort((a, b) => b - a)).toEqual(werte)
|
||||
expect(matches[0].property.id).toBe('frei-zuerich')
|
||||
})
|
||||
|
||||
it('ist stabil — zwei Läufe ergeben dieselbe Reihenfolge', () => {
|
||||
const a = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }).matches.map(m => m.property.id)
|
||||
const b = fuehreMatchingAus(signal(), bestand, { includeOccupied: true }).matches.map(m => m.property.id)
|
||||
|
||||
expect(a).toEqual(b)
|
||||
})
|
||||
|
||||
it('liefert bei leerem Bestand eine leere Liste, keinen Fehler', () => {
|
||||
const { matches, candidateCount } = fuehreMatchingAus(signal(), [], { includeOccupied: false })
|
||||
|
||||
expect(matches).toEqual([])
|
||||
expect(candidateCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Abdeckung dämpft den Wert', () => {
|
||||
it('gibt einem Objekt, von dem nur die Verfügbarkeit bekannt ist, keinen Spitzenwert', () => {
|
||||
// Genau der Fall, der im ersten Browserdurchlauf auffiel: Ein dünnes Profil
|
||||
// wirft nur ein Kriterium auf, das Objekt ist frei — und der Wert stand auf
|
||||
// 100 %. «Frei, sonst nichts geprüft» ist aber keine perfekte Passung.
|
||||
const duenn = signal({ semanticSearchProfile: undefined, aiSummary: undefined, title: undefined, locationHint: '' })
|
||||
const { score, abdeckung } = bewerteObjekt(objekt(), leseNachfrageprofil(duenn))
|
||||
|
||||
// Nur die Verfügbarkeit von vier Kernkriterien — ein Fünftel der Abdeckung.
|
||||
expect(abdeckung).toBeLessThan(0.3)
|
||||
expect(score).toBeLessThan(60)
|
||||
})
|
||||
|
||||
it('dämpft, wenn das Profil mehr verlangt, als das Objekt hergibt', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
const vollstaendig = bewerteObjekt(
|
||||
objekt({ hardFacts: { publicTransportScore: 10 } }),
|
||||
profil,
|
||||
)
|
||||
const lueckenhaft = bewerteObjekt(objekt(), profil)
|
||||
|
||||
expect(lueckenhaft.abdeckung).toBeLessThan(vollstaendig.abdeckung)
|
||||
expect(lueckenhaft.score).toBeLessThan(vollstaendig.score)
|
||||
})
|
||||
|
||||
it('wertet eine fehlende Angabe nicht als Nichterfüllung', () => {
|
||||
const profil = leseNachfrageprofil(signal())
|
||||
const ohneAngabe = bewerteObjekt(objekt(), profil)
|
||||
const schlechteAngabe = bewerteObjekt(objekt({ hardFacts: { publicTransportScore: 0 } }), profil)
|
||||
|
||||
// Unbekannt ist besser als nachweislich schlecht — sonst wäre «keine
|
||||
// Angabe» dasselbe wie «erfüllt es nicht», und das ist es nicht.
|
||||
expect(ohneAngabe.score).toBeGreaterThan(schlechteAngabe.score)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Belegung aus dem Mietvertrag (§3.4)', () => {
|
||||
const HEUTE = new Date('2026-09-13T00:00:00.000Z')
|
||||
|
||||
it('hält ein Objekt mit langlaufendem Vertrag für belegt', () => {
|
||||
// Der Bestand setzt bei solchen Objekten «AVAILABLE_SOON» — der Vertrag
|
||||
// sagt etwas anderes, und er ist die belastbarere Angabe.
|
||||
const p = objekt({
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
currentTenant: 'Dauermieter AG',
|
||||
leaseEndDate: '2029-12-31',
|
||||
})
|
||||
|
||||
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.BELEGT)
|
||||
})
|
||||
|
||||
it('hält ein Objekt mit auslaufendem Vertrag für frei', () => {
|
||||
const p = objekt({
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
currentTenant: 'Bald weg AG',
|
||||
leaseEndDate: '2026-10-31',
|
||||
})
|
||||
|
||||
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.FREI)
|
||||
})
|
||||
|
||||
it('lässt einen ausdrücklich gesetzten Status vorgehen', () => {
|
||||
const p = objekt({
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_NOW,
|
||||
currentTenant: 'Noch drin AG',
|
||||
leaseEndDate: '2029-12-31',
|
||||
})
|
||||
|
||||
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.FREI)
|
||||
})
|
||||
|
||||
it('hält einen Mieter ohne Enddatum für belegt — nicht für frei', () => {
|
||||
const p = objekt({
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
currentTenant: 'Unbefristet AG',
|
||||
leaseEndDate: undefined,
|
||||
})
|
||||
|
||||
expect(verfuegbarkeitVon(p, HEUTE)).toBe(MatchAvailability.BELEGT)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Property On — Noras Abgleich von Nachfrageprofil und Objektbestand
|
||||
* (Runde 10, §3.7).
|
||||
*
|
||||
* **Warum diese Datei und nicht `scoreCalculator.ts`:** Der bestehende Rechner
|
||||
* bewertet ein `Need` — ein strukturiertes Bedarfsprofil mit erfassten
|
||||
* Zahlenfeldern und einem Gewichtungsprofil. Livias Lead hat das nicht. Er hat
|
||||
* einen Fliesstext, den ein Modell aus einer Zeitungsmeldung abgeleitet hat,
|
||||
* und ein paar Angaben, die dabei abfielen. Diesen Text in ein `Need` zu
|
||||
* pressen hiesse, Zahlen zu erfinden, die niemand erhoben hat.
|
||||
*
|
||||
* **Wie stattdessen gerechnet wird:** Das Suchprofil wird nach Merkmalen
|
||||
* durchsucht, die darin tatsächlich vorkommen — eine Ortsangabe, eine
|
||||
* Flächenangabe, eine Nutzungsart, Anforderungen an Verkehr, Parkierung oder
|
||||
* Ausbau. Jedes gefundene Merkmal wird gegen das Objekt geprüft und trägt zum
|
||||
* Ergebnis bei. Was im Profil nicht vorkommt, wird nicht bewertet; was im
|
||||
* Objekt fehlt, gilt als **nicht bekannt** und nicht als erfüllt.
|
||||
*
|
||||
* Fehlende Objektangaben zählen deshalb nicht als Nichterfüllung — sie senken
|
||||
* aber die Abdeckung, und die Abdeckung dämpft den Wert. Sonst käme ein
|
||||
* Objekt, von dem nur die Verfügbarkeit bekannt ist, auf hundert Prozent.
|
||||
*
|
||||
* **Deterministisch.** Derselbe Lead und derselbe Bestand ergeben denselben
|
||||
* Wert. Es gibt keinen Zufall und keine Gewichtung, die nicht unten im Code
|
||||
* steht — deshalb kann der Infotext zum Match-Wert auch beschreiben, was
|
||||
* wirklich passiert.
|
||||
*/
|
||||
|
||||
import { AvailabilityStatus } from '../../domain/enums'
|
||||
import type { Property } from '../../domain/property'
|
||||
import type { FutureSignal } from '../../domain/futureSignal'
|
||||
|
||||
// ── Verfügbarkeit ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const MatchAvailability = {
|
||||
/** Sofort oder demnächst frei — der Normalfall für ein Matching. */
|
||||
FREI: 'FREI',
|
||||
/** Vermietet. Nur auf ausdrücklichen Wunsch im Kandidatenkreis (§3.5). */
|
||||
BELEGT: 'BELEGT',
|
||||
/** Der Bestand sagt nichts dazu — das ist etwas anderes als «frei». */
|
||||
UNBEKANNT: 'UNBEKANNT',
|
||||
} as const
|
||||
export type MatchAvailability = typeof MatchAvailability[keyof typeof MatchAvailability]
|
||||
|
||||
/**
|
||||
* Ab wann ein laufender Mietvertrag als «bald frei» gilt.
|
||||
*
|
||||
* Zwölf Monate sind die übliche Vorlaufzeit einer gewerblichen
|
||||
* Wiedervermietung: Wer erst bei der Kündigung anfängt zu suchen, sucht zu
|
||||
* spät.
|
||||
*/
|
||||
const BALD_FREI_MONATE = 12
|
||||
|
||||
/**
|
||||
* Verfügbarkeit eines Objekts, normalisiert (§3.4).
|
||||
*
|
||||
* Der Status allein genügt nicht, und das ist keine Feinheit: Im Bestand steht
|
||||
* bei keinem Objekt `OCCUPIED`, obwohl bei 27 ein laufender Mietvertrag mit
|
||||
* Mieter und Enddatum erfasst ist. Wer nur den Status liest, hält den ganzen
|
||||
* Bestand für frei — und der Schalter «Belegte Objekte einbeziehen» hätte
|
||||
* nichts einzubeziehen.
|
||||
*
|
||||
* Massgeblich ist deshalb der Vertrag, wo es einen gibt: Läuft er noch länger
|
||||
* als ein Jahr, ist die Fläche belegt. Läuft er demnächst aus, ist sie bald
|
||||
* frei — genau der Fall, für den die Bewirtschaftung Vorlauf braucht.
|
||||
*
|
||||
* `referenz` ist überschreibbar, damit Tests nicht vom Kalender abhängen.
|
||||
*/
|
||||
export function verfuegbarkeitVon(p: Property, referenz: Date = new Date()): MatchAvailability {
|
||||
// Ein ausdrücklich gesetzter Status hat Vorrang — er ist eine Aussage.
|
||||
if (p.availabilityStatus === AvailabilityStatus.OCCUPIED) return MatchAvailability.BELEGT
|
||||
if (p.availabilityStatus === AvailabilityStatus.AVAILABLE_NOW) return MatchAvailability.FREI
|
||||
|
||||
const ende = p.leaseEndDate ? new Date(p.leaseEndDate) : null
|
||||
if (p.currentTenant && ende && !Number.isNaN(ende.getTime())) {
|
||||
const grenze = new Date(referenz)
|
||||
grenze.setMonth(grenze.getMonth() + BALD_FREI_MONATE)
|
||||
return ende > grenze ? MatchAvailability.BELEGT : MatchAvailability.FREI
|
||||
}
|
||||
// Mieter ohne Enddatum: Der Vertrag läuft, und wann er endet, steht nirgends.
|
||||
if (p.currentTenant) return MatchAvailability.BELEGT
|
||||
|
||||
switch (p.availabilityStatus) {
|
||||
case AvailabilityStatus.AVAILABLE_SOON:
|
||||
case AvailabilityStatus.FUTURE_SIGNAL:
|
||||
return MatchAvailability.FREI
|
||||
default:
|
||||
return MatchAvailability.UNBEKANNT
|
||||
}
|
||||
}
|
||||
|
||||
// ── Merkmale aus dem Suchprofil lesen ─────────────────────────────────────────
|
||||
|
||||
/** Was sich aus einem Nachfrageprofil überhaupt herauslesen liess. */
|
||||
export interface Nachfrageprofil {
|
||||
/** Ortsnamen, die im Profil vorkommen — klein geschrieben. */
|
||||
orte: string[]
|
||||
/** Gesuchte Fläche in m², falls das Profil eine nennt. */
|
||||
flaecheSqm?: number
|
||||
/** Schlagworte zur Nutzungsart, klein geschrieben. */
|
||||
nutzung: string[]
|
||||
/** Weiche Anforderungen, die im Profil ausdrücklich stehen. */
|
||||
anforderungen: {
|
||||
oev: boolean
|
||||
parkierung: boolean
|
||||
ausbau: boolean
|
||||
sichtbarkeit: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Städte und Regionen, die im Bestand vorkommen.
|
||||
*
|
||||
* Bewusst eine feste Liste und keine Namenserkennung: Der Bestand ist der
|
||||
* Schweizer Gewerbemarkt, und ein Freitext-Abgleich auf beliebige
|
||||
* Grossbuchstaben würde bei jedem Firmennamen anschlagen.
|
||||
*/
|
||||
const ORTE = [
|
||||
'zürich', 'zurich', 'winterthur', 'basel', 'bern', 'luzern', 'zug', 'st. gallen', 'st.gallen',
|
||||
'lausanne', 'genf', 'genève', 'aarau', 'baden', 'olten', 'schlieren', 'dietikon', 'opfikon',
|
||||
'wallisellen', 'dübendorf', 'wetzikon', 'uster', 'thalwil', 'horgen', 'kloten', 'regensdorf',
|
||||
'pratteln', 'muttenz', 'allschwil', 'reinach', 'liestal', 'rotkreuz', 'cham', 'baar',
|
||||
]
|
||||
|
||||
const NUTZUNG_WORTE: Record<string, string[]> = {
|
||||
buero: ['büro', 'buero', 'office', 'verwaltung', 'arbeitsplätze', 'arbeitsplatz'],
|
||||
lager: ['lager', 'logistik', 'distribution', 'umschlag', 'warenlager'],
|
||||
produktion: ['produktion', 'fertigung', 'werkstatt', 'industrie', 'montage'],
|
||||
verkauf: ['verkauf', 'retail', 'ladenfläche', 'filiale', 'showroom', 'detailhandel'],
|
||||
gewerbe: ['gewerbe', 'gewerbefläche', 'gewerbestandort'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkmale aus einem Nachfrageprofil herauslesen.
|
||||
*
|
||||
* Zusätzlich zum Fliesstext gehen die strukturierten Angaben des Signals ein,
|
||||
* soweit vorhanden — Ortshinweis und geschätzte Fläche. Sie sind verlässlicher
|
||||
* als das, was sich aus Sätzen herauslesen lässt, und gehen deshalb vor.
|
||||
*/
|
||||
export function leseNachfrageprofil(signal: FutureSignal): Nachfrageprofil {
|
||||
const text = [
|
||||
signal.semanticSearchProfile ?? '',
|
||||
signal.aiSummary ?? '',
|
||||
signal.title ?? '',
|
||||
signal.locationHint,
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
const orte = ORTE.filter(o => text.includes(o))
|
||||
// Der erfasste Ortshinweis zählt immer mit, auch wenn er nicht in der Liste steht.
|
||||
const ortHinweis = signal.locationHint.split(/[,/(]/)[0].trim().toLowerCase()
|
||||
if (ortHinweis && ortHinweis.length > 2 && !orte.includes(ortHinweis)) orte.push(ortHinweis)
|
||||
|
||||
const nutzung = Object.entries(NUTZUNG_WORTE)
|
||||
.filter(([, worte]) => worte.some(w => text.includes(w)))
|
||||
.map(([schluessel]) => schluessel)
|
||||
|
||||
return {
|
||||
orte,
|
||||
flaecheSqm: signal.areaSqmEstimate ?? flaecheAusText(text),
|
||||
nutzung,
|
||||
anforderungen: {
|
||||
oev: /öffentlich(en)? verkehr|öv|bahnhof|s-bahn|tram|verkehrsanbindung|erreichbarkeit/.test(text),
|
||||
parkierung: /parkplat|parkier|parking|anlieferung|lastwagen|rampe/.test(text),
|
||||
ausbau: /ausbau|ausgebaut|bezugsbereit|infrastruktur|serverraum|klimatisier/.test(text),
|
||||
sichtbarkeit: /sichtbar|passanten|frequenz|schaufenster|repräsentativ|prestige/.test(text),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Flächenangabe aus dem Fliesstext — nur, wenn sie eindeutig als solche dasteht. */
|
||||
function flaecheAusText(text: string): number | undefined {
|
||||
const treffer = /(\d[\d'’.\s]{1,8})\s*(?:m²|m2|quadratmeter)/.exec(text)
|
||||
if (!treffer) return undefined
|
||||
const zahl = Number(treffer[1].replace(/[^\d]/g, ''))
|
||||
return Number.isFinite(zahl) && zahl >= 20 ? zahl : undefined
|
||||
}
|
||||
|
||||
// ── Bewertung ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Ein geprüftes Merkmal — das ist die Erklärung hinter dem Prozentwert. */
|
||||
export interface MatchKriterium {
|
||||
label: string
|
||||
/** 0–1, oder null wenn das Objekt dazu nichts sagt. */
|
||||
erfuellung: number | null
|
||||
/** Wie stark das Kriterium zählt, wenn es geprüft werden konnte. */
|
||||
gewicht: number
|
||||
/** Ein Satz, der den Wert erklärt. */
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
export interface PropertyMatch {
|
||||
property: Property
|
||||
/** 0–100. */
|
||||
score: number
|
||||
kriterien: MatchKriterium[]
|
||||
verfuegbarkeit: MatchAvailability
|
||||
/**
|
||||
* Anteil des Gewichts, das sich am Objekt tatsächlich prüfen liess (0–1).
|
||||
*
|
||||
* Sie steht neben dem Wert, weil beide zusammen erst die Aussage ergeben:
|
||||
* 80 % bei voller Abdeckung heisst etwas anderes als 80 % bei halber.
|
||||
*/
|
||||
abdeckung: number
|
||||
}
|
||||
|
||||
/** Gewichte der Kriterien. Sie stehen hier, damit der Infotext sie nicht erfinden muss. */
|
||||
const GEWICHT = {
|
||||
ort: 3,
|
||||
flaeche: 3,
|
||||
nutzung: 2,
|
||||
verfuegbarkeit: 2,
|
||||
oev: 1,
|
||||
parkierung: 1,
|
||||
ausbau: 1,
|
||||
sichtbarkeit: 1,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Gewicht der vier Kernkriterien — Standort, Fläche, Nutzungsart, Verfügbarkeit.
|
||||
*
|
||||
* Sie stehen immer im Nenner der Abdeckung, auch wenn das Profil sie gar nicht
|
||||
* nennt. Das ist der Punkt: Ein Suchprofil ohne Flächenangabe ist nicht
|
||||
* «vollständig geprüft», sondern eines, bei dem eine tragende Angabe fehlt.
|
||||
* Die weichen Kriterien zählen dagegen nur mit, wenn das Profil sie verlangt —
|
||||
* sonst würde jedes Profil ohne Parkierungswunsch gedämpft, obwohl nichts fehlt.
|
||||
*/
|
||||
const KERN_GEWICHT = GEWICHT.ort + GEWICHT.flaeche + GEWICHT.nutzung + GEWICHT.verfuegbarkeit
|
||||
|
||||
/**
|
||||
* Wie stark eine dünne Abdeckung den Wert dämpft.
|
||||
*
|
||||
* Bei voller Abdeckung bleibt der Wert unverändert, bei gar keiner sinkt er
|
||||
* auf 40 %. Die Untergrenze ist kein Rundungsartefakt: Ein Objekt, von dem nur
|
||||
* die Verfügbarkeit bekannt ist, soll unterhalb jedes belegten Treffers landen
|
||||
* und trotzdem sichtbar bleiben — «wir wissen zu wenig» ist eine Aussage,
|
||||
* «kein Treffer» wäre eine andere.
|
||||
*/
|
||||
const ABDECKUNG_BASIS = 0.4
|
||||
|
||||
/** Wie nah zwei Flächen beieinander liegen — 1 bei Gleichheit, 0 ab Faktor drei. */
|
||||
function flaechenNaehe(objekt: number, gesucht: number): number {
|
||||
if (objekt <= 0 || gesucht <= 0) return 0
|
||||
const verhaeltnis = objekt >= gesucht ? objekt / gesucht : gesucht / objekt
|
||||
if (verhaeltnis <= 1.25) return 1
|
||||
if (verhaeltnis >= 3) return 0
|
||||
return 1 - (verhaeltnis - 1.25) / 1.75
|
||||
}
|
||||
|
||||
function normiert(wert: number | undefined, max: number): number | null {
|
||||
if (wert == null) return null
|
||||
return Math.max(0, Math.min(1, wert / max))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Objekt gegen ein Nachfrageprofil bewerten.
|
||||
*
|
||||
* Gerechnet wird nur über die Kriterien, die tatsächlich geprüft werden
|
||||
* konnten. Ein Objekt ohne Angabe zur Parkierung verliert dadurch keine
|
||||
* Punkte — es bekommt aber auch keine. Der Prozentwert sagt damit «so gut
|
||||
* passt, was wir wissen», nicht «so gut passt es».
|
||||
*/
|
||||
export function bewerteObjekt(p: Property, profil: Nachfrageprofil): PropertyMatch {
|
||||
const kriterien: MatchKriterium[] = []
|
||||
const verfuegbarkeit = verfuegbarkeitVon(p)
|
||||
|
||||
// ── Standort ──
|
||||
if (profil.orte.length > 0) {
|
||||
const objektOrte = [p.location.city, p.location.district, p.location.region, p.address.city]
|
||||
.filter(Boolean).map(o => o!.toLowerCase())
|
||||
const treffer = profil.orte.some(o => objektOrte.some(z => z.includes(o) || o.includes(z)))
|
||||
kriterien.push({
|
||||
label: 'Standort',
|
||||
erfuellung: treffer ? 1 : 0,
|
||||
gewicht: GEWICHT.ort,
|
||||
hinweis: treffer
|
||||
? `${p.location.city} liegt im gesuchten Raum.`
|
||||
: `${p.location.city} liegt ausserhalb des im Profil genannten Raums.`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Fläche ──
|
||||
if (profil.flaecheSqm) {
|
||||
const naehe = flaechenNaehe(p.areaSqm, profil.flaecheSqm)
|
||||
kriterien.push({
|
||||
label: 'Fläche',
|
||||
erfuellung: naehe,
|
||||
gewicht: GEWICHT.flaeche,
|
||||
hinweis: `${p.areaSqm.toLocaleString('de-CH')} m² gegenüber gesuchten rund ${profil.flaecheSqm.toLocaleString('de-CH')} m².`,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Nutzungsart ──
|
||||
if (profil.nutzung.length > 0) {
|
||||
const objektNutzung = `${p.assetType} ${p.hardFacts?.usageType ?? ''} ${p.title}`.toLowerCase()
|
||||
const treffer = profil.nutzung.some(n =>
|
||||
objektNutzung.includes(n) || (NUTZUNG_WORTE[n] ?? []).some(w => objektNutzung.includes(w)))
|
||||
kriterien.push({
|
||||
label: 'Nutzungsart',
|
||||
erfuellung: treffer ? 1 : 0,
|
||||
gewicht: GEWICHT.nutzung,
|
||||
hinweis: treffer
|
||||
? 'Die Nutzungsart des Objekts entspricht dem Profil.'
|
||||
: 'Die Nutzungsart weicht vom Profil ab.',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Verfügbarkeit ──
|
||||
kriterien.push({
|
||||
label: 'Verfügbarkeit',
|
||||
erfuellung: verfuegbarkeit === MatchAvailability.FREI ? 1
|
||||
: verfuegbarkeit === MatchAvailability.BELEGT ? 0
|
||||
: null,
|
||||
gewicht: GEWICHT.verfuegbarkeit,
|
||||
hinweis: verfuegbarkeit === MatchAvailability.FREI
|
||||
? 'Frei oder demnächst frei werdend.'
|
||||
: verfuegbarkeit === MatchAvailability.BELEGT
|
||||
? 'Derzeit vermietet.'
|
||||
: 'Der Bestand sagt nichts zur Verfügbarkeit.',
|
||||
})
|
||||
|
||||
// ── Weiche Anforderungen, nur wenn das Profil sie nennt ──
|
||||
if (profil.anforderungen.oev) {
|
||||
const wert = normiert(p.hardFacts?.publicTransportScore ?? p.softFactors?.commuterAccessScore, 10)
|
||||
kriterien.push({
|
||||
label: 'Erreichbarkeit',
|
||||
erfuellung: wert,
|
||||
gewicht: GEWICHT.oev,
|
||||
hinweis: wert == null ? 'Zur Erreichbarkeit ist nichts erfasst.' : `Anbindungswert ${Math.round(wert * 100)} von 100.`,
|
||||
})
|
||||
}
|
||||
if (profil.anforderungen.parkierung) {
|
||||
const plaetze = p.hardFacts?.parking ?? p.softFactors?.parkingSpots
|
||||
kriterien.push({
|
||||
label: 'Parkierung',
|
||||
erfuellung: plaetze == null ? null : plaetze > 0 ? 1 : 0,
|
||||
gewicht: GEWICHT.parkierung,
|
||||
hinweis: plaetze == null
|
||||
? 'Zur Parkierung ist nichts erfasst.'
|
||||
: `${plaetze} Parkplätze erfasst.`,
|
||||
})
|
||||
}
|
||||
if (profil.anforderungen.ausbau) {
|
||||
const stufe = p.hardFacts?.fitOut
|
||||
const wert = stufe == null ? null : stufe === 'PREMIUM' ? 1 : stufe === 'FULL' ? 0.8 : stufe === 'BASIC' ? 0.5 : 0.2
|
||||
kriterien.push({
|
||||
label: 'Ausbaustand',
|
||||
erfuellung: wert,
|
||||
gewicht: GEWICHT.ausbau,
|
||||
hinweis: stufe == null ? 'Zum Ausbaustand ist nichts erfasst.' : `Ausbaustand ${stufe}.`,
|
||||
})
|
||||
}
|
||||
if (profil.anforderungen.sichtbarkeit) {
|
||||
const wert = normiert(p.softFactors?.visibilityScore ?? p.softFactors?.prestigeScore, 10)
|
||||
kriterien.push({
|
||||
label: 'Sichtbarkeit',
|
||||
erfuellung: wert,
|
||||
gewicht: GEWICHT.sichtbarkeit,
|
||||
hinweis: wert == null ? 'Zur Sichtbarkeit ist nichts erfasst.' : `Sichtbarkeitswert ${Math.round(wert * 100)} von 100.`,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Zwei Grössen, nicht eine.
|
||||
*
|
||||
* `erfuellung` sagt, wie gut das Objekt bei den Merkmalen abschneidet, die
|
||||
* sich prüfen liessen. `abdeckung` sagt, wie viel davon überhaupt prüfbar
|
||||
* war. Beides gehört in den Wert, und zwar aus einem Grund, der im ersten
|
||||
* Durchlauf sichtbar wurde: Ein Objekt, von dem nur die Verfügbarkeit
|
||||
* bekannt war, kam auf 100 % — «frei, sonst nichts geprüft» sah aus wie
|
||||
* «passt perfekt».
|
||||
*
|
||||
* Fehlende Angaben werden trotzdem nicht als Nichterfüllung gewertet; das
|
||||
* wäre die andere Unwahrheit. Sie dämpfen den Wert, und die Dämpfung reicht
|
||||
* bis 70 % — genug, damit ein gut belegtes Objekt vor einem kaum belegten
|
||||
* steht, und nicht so viel, dass ein lückenhafter Bestand jedes Ergebnis
|
||||
* unbrauchbar macht.
|
||||
*/
|
||||
let summe = 0
|
||||
let geprueft = 0
|
||||
// Die weichen Kriterien kommen nur dazu, wenn das Profil sie aufwirft.
|
||||
let verlangt = KERN_GEWICHT
|
||||
for (const k of kriterien) {
|
||||
const kern = k.label === 'Standort' || k.label === 'Fläche'
|
||||
|| k.label === 'Nutzungsart' || k.label === 'Verfügbarkeit'
|
||||
if (!kern) verlangt += k.gewicht
|
||||
if (k.erfuellung == null) continue
|
||||
summe += k.erfuellung * k.gewicht
|
||||
geprueft += k.gewicht
|
||||
}
|
||||
|
||||
const erfuellung = geprueft === 0 ? 0 : summe / geprueft
|
||||
const abdeckung = Math.min(1, geprueft / verlangt)
|
||||
const score = Math.round(erfuellung * (ABDECKUNG_BASIS + (1 - ABDECKUNG_BASIS) * abdeckung) * 100)
|
||||
|
||||
return { property: p, score, kriterien, verfuegbarkeit, abdeckung }
|
||||
}
|
||||
|
||||
export interface MatchLaufOptionen {
|
||||
/** Belegte Objekte in den Kandidatenkreis nehmen (§3.5). Vorgabe: nein. */
|
||||
includeOccupied: boolean
|
||||
}
|
||||
|
||||
export interface MatchLaufErgebnis {
|
||||
matches: PropertyMatch[]
|
||||
/** Wie viele Objekte überhaupt geprüft wurden. */
|
||||
candidateCount: number
|
||||
profil: Nachfrageprofil
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Lauf über den gesamten Bestand.
|
||||
*
|
||||
* Ausgeschlossen wird nur, was eindeutig ausgeschlossen gehört: belegte
|
||||
* Objekte, solange sie nicht ausdrücklich gewünscht sind. Alles andere wird
|
||||
* bewertet und sortiert — auch Schlechtpassendes, denn «kein Treffer» ist eine
|
||||
* andere Aussage als «nichts geprüft».
|
||||
*/
|
||||
export function fuehreMatchingAus(
|
||||
signal: FutureSignal,
|
||||
properties: Property[],
|
||||
optionen: MatchLaufOptionen,
|
||||
): MatchLaufErgebnis {
|
||||
const profil = leseNachfrageprofil(signal)
|
||||
|
||||
const kandidaten = properties.filter(p => {
|
||||
const v = verfuegbarkeitVon(p)
|
||||
if (v === MatchAvailability.BELEGT && !optionen.includeOccupied) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const matches = kandidaten
|
||||
.map(p => bewerteObjekt(p, profil))
|
||||
// Stabil sortiert: bei gleichem Wert entscheidet die Kennung, nicht der Zufall.
|
||||
.sort((a, b) => (b.score - a.score) || a.property.id.localeCompare(b.property.id))
|
||||
|
||||
return { matches, candidateCount: kandidaten.length, profil }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useAgentWorkItems } from './useAgentWorkItems'
|
||||
import { useTeamAgents } from './useTeamAgents'
|
||||
import { answerAgentQuestion } from '../services/agentChatService'
|
||||
import type { AgentChatSubject } from '../stores/agentChatStore'
|
||||
|
||||
/**
|
||||
* Verbindet den Chat mit den Beständen, die auf der Agentenseite ohnehin
|
||||
* angezeigt werden (Runde 9, §3).
|
||||
*
|
||||
* Der Hook holt nur die Daten; was daraus wird, entscheidet
|
||||
* `agentChatService` — Zählweise, Zeitraum und Formulierungen sind fachliche
|
||||
* Festlegungen und gehören nicht in die Oberflächenschicht (CLAUDE.md §4.2).
|
||||
*
|
||||
* Bewusst dieselbe Quelle wie der Bearbeitungsverlauf: Was der Agent im Chat
|
||||
* sagt, muss sich im Verlauf nachzählen lassen. Zwei getrennte Zählungen wären
|
||||
* zwei Wahrheiten.
|
||||
*/
|
||||
export function useAgentChatAnswer() {
|
||||
const { data: workItems = [], isLoading: itemsLoading } = useAgentWorkItems()
|
||||
const { data: agents = [], isLoading: agentsLoading } = useTeamAgents()
|
||||
|
||||
return useCallback(
|
||||
(agent: AgentChatSubject, frage: string): string => {
|
||||
// Thomas ist Vorgesetzter und hat kein eigenes Personalblatt; für ihn
|
||||
// zählt der gesamte Bestand, für alle anderen nur der eigene.
|
||||
const eigene = agent.id === 'thomas'
|
||||
? workItems
|
||||
: workItems.filter(w => w.agentId === agent.id)
|
||||
|
||||
return answerAgentQuestion(
|
||||
{
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
agentRole: agent.role,
|
||||
dossier: agents.find(a => a.id === agent.id),
|
||||
workItems: eigene,
|
||||
loading: itemsLoading || agentsLoading,
|
||||
},
|
||||
frage,
|
||||
)
|
||||
},
|
||||
[workItems, agents, itemsLoading, agentsLoading],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { ResearchRefreshResult, TemporalClass } from '../domain/researchLead'
|
||||
import type { ResearchSourceDescriptor } from '../lib/researchSources'
|
||||
import { RESEARCH_REFRESH_ENDPOINT } from '../lib/researchSources'
|
||||
import { futureSignalService } from '../services/futureSignalService'
|
||||
import { agentProtocolService } from '../services/agentProtocolService'
|
||||
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
/** Was ein Lauf mitbekommt — alles aus dem gespeicherten Stand, nichts hartcodiert. */
|
||||
export interface ResearchRunInput {
|
||||
/** Aktive Lesezugänge mit Adresse, abgeleitet aus Livias Systemen (§2.3). */
|
||||
sources: ResearchSourceDescriptor[]
|
||||
/** Manuell abgelegte Dokumente mit bereits extrahiertem Text (§2.6). */
|
||||
documents: ResearchDocument[]
|
||||
/** Erlaubte Ereigniszeiten; leer bedeutet «keine Einschränkung» (§2.9). */
|
||||
temporalClasses: TemporalClass[]
|
||||
}
|
||||
|
||||
/**
|
||||
* «Research aktualisieren» — der eine Aufruf des PoC.
|
||||
*
|
||||
* Eine Mutation und keine Query: der Lauf ist eine Handlung des Nutzers, kein
|
||||
* Zustand, der sich von selbst aktualisiert. Deshalb auch bewusst kein
|
||||
* `staleTime` und keine Zwischenspeicherung — wer auf den Knopf drückt, will
|
||||
* den Stand von jetzt und nicht den von vorhin.
|
||||
*
|
||||
* Seit Runde 10 endet der Lauf nicht mit der Antwort. Drei Dinge passieren
|
||||
* danach, und zwar hier, weil sie zusammengehören (§§2.5/2.12):
|
||||
*
|
||||
* 1. Die gefundenen Leads gehen in den **zentralen** Signalbestand — es gibt
|
||||
* keine zweite Ergebnisliste mehr.
|
||||
* 2. Der Lauf erzeugt einen **echten** Protokolleintrag mit der Zahl der
|
||||
* gelesenen Quellen und der gefundenen Leads.
|
||||
* 3. Die Leadliste wird neu geladen, damit das Ergebnis sofort dort steht, wo
|
||||
* es hingehört.
|
||||
*/
|
||||
export function useLiviaResearch() {
|
||||
const showToast = useToastStore(s => s.showToast)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<ResearchRefreshResult, Error, ResearchRunInput>({
|
||||
mutationFn: async ({ sources, documents, temporalClasses }) => {
|
||||
const res = await fetch(RESEARCH_REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sources: sources.map(q => ({ id: q.id, name: q.name, url: q.url })),
|
||||
// Nur der extrahierte Text geht an den Server; die Datei selbst
|
||||
// bleibt im Browser. Sie hochzuladen wäre Datenversand ohne Nutzen.
|
||||
documents: documents.map(d => ({ id: d.id, name: d.name, text: d.text })),
|
||||
temporalClasses,
|
||||
}),
|
||||
})
|
||||
|
||||
// Ohne laufende Serverfunktion liefert der Hoster die Anwendung selbst
|
||||
// zurück — dann steht hier HTML statt JSON. Das ist der häufigste Fall
|
||||
// in der lokalen Entwicklung und verdient eine klare Meldung statt eines
|
||||
// «Unexpected token <».
|
||||
const typ = res.headers.get('content-type') ?? ''
|
||||
if (!typ.includes('application/json')) {
|
||||
throw new Error(
|
||||
'Der Recherche-Endpoint antwortet nicht. Lokal braucht es dafür «vercel dev» statt «vite».',
|
||||
)
|
||||
}
|
||||
if (!res.ok) throw new Error(`Der Recherche-Lauf schlug fehl (HTTP ${res.status}).`)
|
||||
|
||||
return await res.json() as ResearchRefreshResult
|
||||
},
|
||||
|
||||
onSuccess: async (ergebnis) => {
|
||||
if (ergebnis.leads.length > 0) {
|
||||
await futureSignalService.importResearchLeads(ergebnis.leads, ergebnis.refreshedAt)
|
||||
await queryClient.invalidateQueries({ queryKey: ['futureSignals'] })
|
||||
}
|
||||
|
||||
// Der Eintrag trägt die Zahlen des Laufs, nicht einen festen Text —
|
||||
// ein Protokoll, das immer dasselbe sagt, protokolliert nichts (§2.5).
|
||||
const gelesen = ergebnis.sources.filter(s => s.ok).length
|
||||
await agentProtocolService.logUserAction({
|
||||
agentId: 'livia',
|
||||
eventType: AgentProtocolEventType.TASK_RUN,
|
||||
title: 'Research aktualisiert',
|
||||
description:
|
||||
`${gelesen} von ${ergebnis.sources.length} ${ergebnis.sources.length === 1 ? 'Quelle' : 'Quellen'} gelesen · ` +
|
||||
`${ergebnis.analyzed} ${ergebnis.analyzed === 1 ? 'Eintrag' : 'Einträge'} beurteilt · ` +
|
||||
`${ergebnis.leads.length} ${ergebnis.leads.length === 1 ? 'Lead' : 'Leads'} in die Leadliste übernommen` +
|
||||
(ergebnis.temporalFiltered > 0
|
||||
? ` · ${ergebnis.temporalFiltered} wegen der zeitlichen Relevanz ausgeschlossen`
|
||||
: ''),
|
||||
status: ergebnis.analysisError ? AgentProtocolStatus.WARNING : AgentProtocolStatus.SUCCESS,
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['agentProtocol'] })
|
||||
},
|
||||
|
||||
onError: (err) => {
|
||||
showToast(err.message, 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { matchRunService } from '../services/matchRunService'
|
||||
import type { RunMatchingInput } from '../services/matchRunService'
|
||||
import { STALE_SIGNALS } from '../lib/constants'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const KEY = ['matchRuns']
|
||||
|
||||
/** Die durchgeführten Läufe — Grundlage für Protokoll und Kennzahl (§§3.11/3.13). */
|
||||
export function useMatchRuns() {
|
||||
return useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => matchRunService.getAll(),
|
||||
staleTime: STALE_SIGNALS,
|
||||
select: (res) => res.data,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Lauf ausführen.
|
||||
*
|
||||
* Als Mutation und nicht als Query: Ein Matching ist eine Handlung, kein
|
||||
* Zustand, der sich von selbst herstellt. Genau darum zählt die Kennzahl
|
||||
* korrekt — eine Query würde bei jedem Neuaufbau der Seite erneut laufen.
|
||||
*/
|
||||
export function useRunMatching() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (input: RunMatchingInput) => matchRunService.run(input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: KEY })
|
||||
void queryClient.invalidateQueries({ queryKey: ['agentProtocol'] })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Das Matching konnte nicht ausgeführt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { researchDocumentService } from '../services/researchDocumentService'
|
||||
import { STALE_SIGNALS } from '../lib/constants'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
const KEY = ['researchDocuments']
|
||||
|
||||
/** Die manuell abgelegten Newsquellen (Runde 10, §2.6). */
|
||||
export function useResearchDocuments() {
|
||||
return useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => researchDocumentService.getAll(),
|
||||
staleTime: STALE_SIGNALS,
|
||||
select: (res) => res.data,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUploadResearchDocument() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => researchDocumentService.upload(file),
|
||||
onSuccess: (res) => {
|
||||
void queryClient.invalidateQueries({ queryKey: KEY })
|
||||
// Ein Dokument ohne brauchbaren Text wird abgelegt, trägt aber nichts
|
||||
// bei — das muss beim Ablegen gesagt werden und nicht erst, wenn der
|
||||
// Lauf keine Leads daraus findet.
|
||||
if (res.data.extractionNote) {
|
||||
useToastStore.getState().showToast(res.data.extractionNote, 'warning')
|
||||
} else {
|
||||
useToastStore.getState().showToast(`«${res.data.name}» abgelegt.`, 'success')
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
useToastStore.getState().showToast(err.message, 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveResearchDocument() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => researchDocumentService.remove(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: KEY })
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('Das Dokument konnte nicht entfernt werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { teamAgentService } from '../services/teamAgentService'
|
||||
import type { AgentTask, AgentChannel, AgentSetting } from '../domain/teamAgent'
|
||||
import type { AgentTask, AgentChannel, AgentSetting, AgentSystem } from '../domain/teamAgent'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
import { STALE_TEAM_AGENTS } from '../lib/constants'
|
||||
import { QK_AGENT_PROTOCOL } from './useAgentProtocol'
|
||||
@@ -100,3 +100,24 @@ export function useSaveAgentChannels() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Systeme speichern (Runde 10, §2).
|
||||
*
|
||||
* Bewusst dieselbe Bauart wie `useSaveAgentChannels`: Anlegen, Bearbeiten und
|
||||
* Löschen schreiben allesamt die vollständige Liste zurück. Ein Endpunkt je
|
||||
* Einzeloperation wäre bei einem Mockup-Provider mehr Schnittstelle als Nutzen.
|
||||
*/
|
||||
export function useSaveAgentSystems() {
|
||||
const invalidate = useAgentMutationInvalidation()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, systems }: { id: string; systems: AgentSystem[] }) =>
|
||||
teamAgentService.saveSystems(id, systems),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
},
|
||||
onError: () => {
|
||||
useToastStore.getState().showToast('System konnte nicht geändert werden.', 'error')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Welche Systemzugänge ein Recherchelauf tatsächlich abruft (Runde 10, §2.3).
|
||||
*
|
||||
* Der Test hält die Zusage fest, die das Personalblatt dem Nutzer gibt: Was
|
||||
* dort als aktive Lesequelle mit Adresse steht, wird gelesen — und nichts sonst.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { istAktiv, istSchreibend, leseQuellenAus, systemTitel } from '../agentSystems'
|
||||
import {
|
||||
AgentAccessLevel,
|
||||
AgentConnectionStatus,
|
||||
AgentSystemType,
|
||||
} from '../../domain/teamAgent'
|
||||
import type { AgentSystem } from '../../domain/teamAgent'
|
||||
|
||||
function system(overrides: Partial<AgentSystem> = {}): AgentSystem {
|
||||
return {
|
||||
id: 'sys-1',
|
||||
type: AgentSystemType.PUBLIC_WEB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage: 'Regionale Unternehmensmeldungen.',
|
||||
permissionNote: '',
|
||||
title: 'Beispielquelle',
|
||||
url: 'https://www.beispiel.ch/news',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('istAktiv', () => {
|
||||
it('gilt ohne Feld als aktiv — der Katalog führt seine Zugänge ohne Schalter', () => {
|
||||
expect(istAktiv(system({ enabled: undefined }))).toBe(true)
|
||||
})
|
||||
|
||||
it('ist nur bei ausdrücklichem false abgeschaltet', () => {
|
||||
expect(istAktiv(system({ enabled: false }))).toBe(false)
|
||||
expect(istAktiv(system({ enabled: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('istSchreibend', () => {
|
||||
it('zählt READ_WRITE als schreibend', () => {
|
||||
expect(istSchreibend(system({ access: AgentAccessLevel.READ_WRITE }))).toBe(true)
|
||||
expect(istSchreibend(system({ access: AgentAccessLevel.WRITE }))).toBe(true)
|
||||
expect(istSchreibend(system({ access: AgentAccessLevel.READ }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('systemTitel', () => {
|
||||
it('nimmt den eigenen Titel, wenn einer gesetzt ist', () => {
|
||||
expect(systemTitel(system({ title: 'Handelskammer' }))).toBe('Handelskammer')
|
||||
})
|
||||
|
||||
it('fällt auf das Katalog-Label zurück', () => {
|
||||
expect(systemTitel(system({ title: undefined, type: AgentSystemType.ZEFIX }))).toBe('Zefix')
|
||||
})
|
||||
|
||||
it('ignoriert einen Titel aus lauter Leerzeichen', () => {
|
||||
expect(systemTitel(system({ title: ' ', type: AgentSystemType.ZEFIX }))).toBe('Zefix')
|
||||
})
|
||||
})
|
||||
|
||||
describe('leseQuellenAus', () => {
|
||||
it('nimmt aktive Lesezugänge mit Adresse auf', () => {
|
||||
const quellen = leseQuellenAus([system()])
|
||||
|
||||
expect(quellen).toHaveLength(1)
|
||||
expect(quellen[0]).toMatchObject({ id: 'sys-1', name: 'Beispielquelle', url: 'https://www.beispiel.ch/news' })
|
||||
})
|
||||
|
||||
it('lässt abgeschaltete Zugänge weg', () => {
|
||||
expect(leseQuellenAus([system({ enabled: false })])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lässt schreibende Zugänge weg', () => {
|
||||
expect(leseQuellenAus([system({ access: AgentAccessLevel.READ_WRITE })])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('lässt Zugänge ohne Adresse weg — es gibt nichts zu holen', () => {
|
||||
expect(leseQuellenAus([system({ url: undefined })])).toHaveLength(0)
|
||||
expect(leseQuellenAus([system({ url: ' ' })])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('behält die Reihenfolge des Personalblatts', () => {
|
||||
const quellen = leseQuellenAus([
|
||||
system({ id: 'a', title: 'Erste' }),
|
||||
system({ id: 'b', title: 'Zweite' }),
|
||||
system({ id: 'c', title: 'Dritte', enabled: false }),
|
||||
])
|
||||
|
||||
expect(quellen.map(q => q.name)).toEqual(['Erste', 'Zweite'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Textgewinnung aus abgelegten Dokumenten (Runde 10, §2.6).
|
||||
*
|
||||
* Diese Tests sind wichtiger als die meisten anderen in diesem Projekt: Die
|
||||
* Extraktion ist selbst gebaut, ohne Bibliothek. Ein selbst gebauter Parser
|
||||
* scheitert nicht laut, sondern liefert stillschweigend eine leere Zeichenkette —
|
||||
* und der Recherchelauf würde dann behaupten, das Dokument enthalte nichts.
|
||||
*
|
||||
* Die Prüfdateien entstehen hier zur Laufzeit, statt als Binärdateien im Repo
|
||||
* zu liegen: So ist im Test sichtbar, welche Struktur erwartet wird.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractDocumentText, kindOf } from '../documentText'
|
||||
import { ResearchDocumentKind } from '../../domain/researchDocument'
|
||||
|
||||
// ── Prüfdateien bauen ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ein ZIP-Archiv mit einem unkomprimiert abgelegten Eintrag.
|
||||
*
|
||||
* Methode 0 («stored») genügt: Der Leser entpackt nur, wenn die Methode es
|
||||
* verlangt, und der Weg über das zentrale Verzeichnis ist in beiden Fällen
|
||||
* derselbe. Die Prüfsumme bleibt null — sie wird beim Lesen nicht geprüft.
|
||||
*/
|
||||
function baueZip(pfad: string, inhalt: string): Uint8Array {
|
||||
const enc = new TextEncoder()
|
||||
const name = enc.encode(pfad)
|
||||
const daten = enc.encode(inhalt)
|
||||
|
||||
const lokal = new Uint8Array(30 + name.length + daten.length)
|
||||
const lv = new DataView(lokal.buffer)
|
||||
lv.setUint32(0, 0x04034b50, true) // Signatur
|
||||
lv.setUint16(4, 20, true) // Version
|
||||
lv.setUint16(8, 0, true) // Methode: stored
|
||||
lv.setUint32(18, daten.length, true)
|
||||
lv.setUint32(22, daten.length, true)
|
||||
lv.setUint16(26, name.length, true)
|
||||
lokal.set(name, 30)
|
||||
lokal.set(daten, 30 + name.length)
|
||||
|
||||
const zentral = new Uint8Array(46 + name.length)
|
||||
const zv = new DataView(zentral.buffer)
|
||||
zv.setUint32(0, 0x02014b50, true)
|
||||
zv.setUint16(10, 0, true) // Methode: stored
|
||||
zv.setUint32(20, daten.length, true)
|
||||
zv.setUint32(24, daten.length, true)
|
||||
zv.setUint16(28, name.length, true)
|
||||
zv.setUint32(42, 0, true) // Offset der lokalen Kopfzeile
|
||||
zentral.set(name, 46)
|
||||
|
||||
const eocd = new Uint8Array(22)
|
||||
const ev = new DataView(eocd.buffer)
|
||||
ev.setUint32(0, 0x06054b50, true)
|
||||
ev.setUint16(8, 1, true) // Einträge auf dieser Platte
|
||||
ev.setUint16(10, 1, true) // Einträge gesamt
|
||||
ev.setUint32(12, zentral.length, true)
|
||||
ev.setUint32(16, lokal.length, true)
|
||||
|
||||
const alles = new Uint8Array(lokal.length + zentral.length + eocd.length)
|
||||
alles.set(lokal, 0)
|
||||
alles.set(zentral, lokal.length)
|
||||
alles.set(eocd, lokal.length + zentral.length)
|
||||
return alles
|
||||
}
|
||||
|
||||
function docxMit(absaetze: string[]): File {
|
||||
const body = absaetze.map(a => `<w:p><w:r><w:t>${a}</w:t></w:r></w:p>`).join('')
|
||||
const xml = `<?xml version="1.0"?><w:document><w:body>${body}</w:body></w:document>`
|
||||
return new File([baueZip('word/document.xml', xml) as BlobPart], 'bericht.docx')
|
||||
}
|
||||
|
||||
/** Ein PDF mit einem ungepackten Inhaltsstrom — der einfachste gültige Fall. */
|
||||
function pdfMit(zeilen: string[]): File {
|
||||
const strom = zeilen.map(z => `BT /F1 12 Tf 72 700 Td (${z}) Tj ET`).join('\n')
|
||||
const inhalt = [
|
||||
'%PDF-1.4',
|
||||
'1 0 obj << /Type /Catalog >> endobj',
|
||||
`2 0 obj << /Length ${strom.length} >>`,
|
||||
'stream',
|
||||
strom,
|
||||
'endstream',
|
||||
'endobj',
|
||||
'%%EOF',
|
||||
].join('\n')
|
||||
return new File([inhalt], 'bericht.pdf', { type: 'application/pdf' })
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('kindOf', () => {
|
||||
it('erkennt die beiden unterstützten Endungen, unabhängig von der Schreibweise', () => {
|
||||
expect(kindOf('Bericht.PDF')).toBe(ResearchDocumentKind.PDF)
|
||||
expect(kindOf('bericht.docx')).toBe(ResearchDocumentKind.DOCX)
|
||||
})
|
||||
|
||||
it('lehnt alles andere ab — auch das alte Word-Format', () => {
|
||||
expect(kindOf('bericht.doc')).toBeNull()
|
||||
expect(kindOf('tabelle.xlsx')).toBeNull()
|
||||
expect(kindOf('notiz.txt')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOCX', () => {
|
||||
it('gewinnt den Text der Absätze in ihrer Reihenfolge', async () => {
|
||||
const datei = docxMit([
|
||||
'Die Muster AG eröffnet im dritten Quartal einen zweiten Standort im Raum Zürich.',
|
||||
'Dafür werden rund 1200 Quadratmeter Produktionsfläche gesucht, mit Anschluss an den öffentlichen Verkehr.',
|
||||
'Der bestehende Standort in Wetzikon bleibt bestehen und wird nicht verkleinert.',
|
||||
])
|
||||
|
||||
const { text, note } = await extractDocumentText(datei, ResearchDocumentKind.DOCX)
|
||||
|
||||
expect(note).toBeUndefined()
|
||||
expect(text).toContain('Muster AG')
|
||||
expect(text).toContain('1200 Quadratmeter')
|
||||
expect(text.indexOf('Muster AG')).toBeLessThan(text.indexOf('Wetzikon'))
|
||||
})
|
||||
|
||||
it('löst die üblichen XML-Entitäten auf', async () => {
|
||||
const lang = 'Die Firma Meier & Co. sucht Fläche. '.repeat(12)
|
||||
const { text } = await extractDocumentText(docxMit([lang]), ResearchDocumentKind.DOCX)
|
||||
|
||||
expect(text).toContain('Meier & Co.')
|
||||
expect(text).not.toContain('&')
|
||||
})
|
||||
|
||||
it('meldet ein Dokument mit zu wenig Text, statt es stumm zu übergehen', async () => {
|
||||
const { note } = await extractDocumentText(docxMit(['Kurz.']), ResearchDocumentKind.DOCX)
|
||||
|
||||
expect(note).toBeTruthy()
|
||||
})
|
||||
|
||||
it('meldet eine Datei, die gar kein Word-Dokument ist', async () => {
|
||||
const kaputt = new File(['das ist kein zip'], 'bericht.docx')
|
||||
const { note } = await extractDocumentText(kaputt, ResearchDocumentKind.DOCX)
|
||||
|
||||
expect(note).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PDF', () => {
|
||||
it('gewinnt den Text aus ungepackten Inhaltsströmen', async () => {
|
||||
const datei = pdfMit([
|
||||
'Die Beispiel SA verlagert ihre Produktion von Vallorbe nach Enney.',
|
||||
'Investiert wird in eine neue Werkstatt von 1600 Quadratmetern.',
|
||||
'Die Produktion laeuft seit Anfang September.',
|
||||
'Ein weiterer Ausbau ist fuer das kommende Jahr angekuendigt.',
|
||||
])
|
||||
|
||||
const { text, note } = await extractDocumentText(datei, ResearchDocumentKind.PDF)
|
||||
|
||||
expect(note).toBeUndefined()
|
||||
expect(text).toContain('Beispiel SA')
|
||||
expect(text).toContain('1600 Quadratmetern')
|
||||
})
|
||||
|
||||
it('meldet ein PDF ohne Textebene, statt es als leer auszugeben', async () => {
|
||||
// Genau der Fall eines eingescannten Dokuments: Struktur da, Text nicht.
|
||||
const scan = new File(['%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF'], 'scan.pdf')
|
||||
|
||||
const { text, note } = await extractDocumentText(scan, ResearchDocumentKind.PDF)
|
||||
|
||||
expect(text.length).toBeLessThan(200)
|
||||
expect(note).toMatch(/Scan|Text/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Vom Recherche-Lead zum Signal im zentralen Bestand (Runde 10, §2.12).
|
||||
*
|
||||
* Der Schwerpunkt liegt auf zwei Zusagen, die die Anwendung sonst still
|
||||
* brechen würde: Ein erneuter Lauf über denselben Artikel darf keine Dublette
|
||||
* erzeugen, und das Publikationsdatum darf die zeitliche Einordnung nicht
|
||||
* überschreiben.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { researchLeadId, researchLeadToSignal } from '../researchLeadMapper'
|
||||
import { ResearchEventType, TemporalClass } from '../../domain/researchLead'
|
||||
import type { ResearchLead } from '../../domain/researchLead'
|
||||
import { LeadSignalType } from '../../domain/futureSignal'
|
||||
|
||||
const LAUF = '2026-09-12T08:00:00.000Z'
|
||||
|
||||
function lead(overrides: Partial<ResearchLead> = {}): ResearchLead {
|
||||
return {
|
||||
company: 'Muster AG',
|
||||
eventType: ResearchEventType.EXPANSION,
|
||||
headline: 'Muster AG eröffnet zweiten Standort',
|
||||
location: 'Zürich',
|
||||
summary: 'Die Muster AG plant einen zusätzlichen Standort im Raum Zürich.',
|
||||
relevance: 'high',
|
||||
confidence: 0.8,
|
||||
reason: 'Konkrete Standortankündigung mit Flächenbezug.',
|
||||
source: 'Zürcher Handelskammer',
|
||||
sourceUrl: 'https://www.zhk.ch/de/news/muster-ag',
|
||||
temporalClass: TemporalClass.FUTURE,
|
||||
temporalEvidence: 'Die Eröffnung ist für das dritte Quartal angekündigt.',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('researchLeadId', () => {
|
||||
it('vergibt für denselben Artikel dieselbe Kennung', () => {
|
||||
expect(researchLeadId(lead())).toBe(researchLeadId(lead()))
|
||||
})
|
||||
|
||||
it('unterscheidet zwei Artikel derselben Quelle', () => {
|
||||
const a = researchLeadId(lead())
|
||||
const b = researchLeadId(lead({ headline: 'Muster AG schliesst Werk Nord' }))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
it('unterscheidet denselben Titel aus zwei Quellen', () => {
|
||||
const a = researchLeadId(lead())
|
||||
const b = researchLeadId(lead({ sourceUrl: 'https://www.greaterzuricharea.com/de/news/muster' }))
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('researchLeadToSignal', () => {
|
||||
it('macht aus einer Expansion eine Chance in Nachfragerichtung', () => {
|
||||
const signal = researchLeadToSignal(lead(), LAUF)
|
||||
|
||||
expect(signal.leadSignalType).toBe(LeadSignalType.CHANCE)
|
||||
expect(signal.signalDirection).toBe('DEMAND')
|
||||
expect(signal.origin).toBe('RESEARCH_RUN')
|
||||
})
|
||||
|
||||
it('macht aus einer Standortschliessung ein Risiko', () => {
|
||||
const signal = researchLeadToSignal(lead({ eventType: ResearchEventType.SITE_CLOSURE }), LAUF)
|
||||
|
||||
expect(signal.leadSignalType).toBe(LeadSignalType.RISIKO)
|
||||
})
|
||||
|
||||
it('übernimmt die zeitliche Einordnung samt Fundstelle', () => {
|
||||
const signal = researchLeadToSignal(lead(), LAUF)
|
||||
|
||||
expect(signal.temporalClass).toBe(TemporalClass.FUTURE)
|
||||
expect(signal.temporalEvidence).toContain('dritte')
|
||||
})
|
||||
|
||||
it('leitet den Zeithorizont aus der Ereigniszeit ab, nicht aus dem Publikationsdatum', () => {
|
||||
// Heute publiziert, Ereignis aber abgeschlossen: kein Horizont in der Zukunft.
|
||||
const vergangen = researchLeadToSignal(
|
||||
lead({ temporalClass: TemporalClass.PAST, publishedAt: LAUF }),
|
||||
LAUF,
|
||||
)
|
||||
const zukuenftig = researchLeadToSignal(
|
||||
lead({ temporalClass: TemporalClass.FUTURE, publishedAt: '2026-01-05' }),
|
||||
LAUF,
|
||||
)
|
||||
|
||||
expect(vergangen.timeHorizonMonths).toBe(0)
|
||||
expect(zukuenftig.timeHorizonMonths).toBeGreaterThan(vergangen.timeHorizonMonths)
|
||||
})
|
||||
|
||||
it('erfindet kein Ereignisdatum, wenn die Quelle keines nennt', () => {
|
||||
const signal = researchLeadToSignal(lead({ temporalClass: TemporalClass.UNKNOWN }), LAUF)
|
||||
|
||||
expect(signal.eventDate).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reicht das semantische Suchprofil unverändert weiter', () => {
|
||||
const profil = 'Für die Muster AG wird ein Gewerbestandort im Raum Zürich gesucht. Nicht ableitbar ist die Fläche.'
|
||||
const signal = researchLeadToSignal(lead({ semanticSearchProfile: profil }), LAUF)
|
||||
|
||||
expect(signal.semanticSearchProfile).toBe(profil)
|
||||
})
|
||||
|
||||
it('behält die Originalquelle als anklickbaren Beleg', () => {
|
||||
const signal = researchLeadToSignal(lead(), LAUF)
|
||||
|
||||
expect(signal.source.url).toBe('https://www.zhk.ch/de/news/muster-ag')
|
||||
expect(signal.evidence?.sourceUrls).toEqual(['https://www.zhk.ch/de/news/muster-ag'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Der harte Zeitfilter (Runde 10, §2.9).
|
||||
*
|
||||
* Die Fälle stammen aus der Anforderung selbst: eine bereits erfolgte
|
||||
* Eröffnung, eine in drei Wochen, eine in vier Monaten — und der Fall, der den
|
||||
* ganzen Filter begründet: ein heute erschienener Artikel über ein Ereignis von
|
||||
* vor einem halben Jahr.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { passtZeitlich } from '../temporalFilter'
|
||||
import { TemporalClass } from '../../domain/researchLead'
|
||||
|
||||
const ALLE = [TemporalClass.PAST, TemporalClass.CURRENT, TemporalClass.FUTURE]
|
||||
|
||||
describe('passtZeitlich', () => {
|
||||
it('lässt ohne Auswahl alles durch — keine Auswahl ist keine Einschränkung', () => {
|
||||
for (const klasse of [...ALLE, TemporalClass.UNKNOWN]) {
|
||||
expect(passtZeitlich(klasse, [])).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('lässt nur die gewählten Fenster durch', () => {
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.FUTURE])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.FUTURE])).toBe(false)
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.FUTURE])).toBe(false)
|
||||
})
|
||||
|
||||
it('wirkt bei mehreren Fenstern als Oder', () => {
|
||||
const gewaehlt = [TemporalClass.CURRENT, TemporalClass.FUTURE]
|
||||
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, gewaehlt)).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, gewaehlt)).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.PAST, gewaehlt)).toBe(false)
|
||||
})
|
||||
|
||||
it('verwirft einen Lead ohne belegbare Ereigniszeit nicht', () => {
|
||||
// Ein erfundenes Datum wäre die schlechtere Antwort als ein sichtbarer Lead
|
||||
// mit dem Vermerk, dass die Ereigniszeit offen ist.
|
||||
expect(passtZeitlich(TemporalClass.UNKNOWN, [TemporalClass.FUTURE])).toBe(true)
|
||||
})
|
||||
|
||||
it('behandelt ein Bestandssignal ohne Einordnung wie «unbekannt»', () => {
|
||||
expect(passtZeitlich(undefined, [TemporalClass.PAST])).toBe(true)
|
||||
})
|
||||
|
||||
describe('die Fälle aus der Anforderung', () => {
|
||||
it('bereits erfolgte Eröffnung gehört zu «Vergangene Leads»', () => {
|
||||
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.PAST])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.PAST, [TemporalClass.CURRENT, TemporalClass.FUTURE])).toBe(false)
|
||||
})
|
||||
|
||||
it('Eröffnung in drei Wochen gehört zu «Aktuelle Leads»', () => {
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.CURRENT])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.CURRENT, [TemporalClass.PAST, TemporalClass.FUTURE])).toBe(false)
|
||||
})
|
||||
|
||||
it('angekündigte Eröffnung in vier Monaten gehört zu «Zukünftige Leads»', () => {
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.FUTURE])).toBe(true)
|
||||
expect(passtZeitlich(TemporalClass.FUTURE, [TemporalClass.PAST, TemporalClass.CURRENT])).toBe(false)
|
||||
})
|
||||
|
||||
it('heute publiziert, Ereignis vor sechs Monaten — das ist vergangen, nicht aktuell', () => {
|
||||
// Der Filter kennt nur die Klasse; dass sie aus der Ereigniszeit und
|
||||
// nicht aus dem Publikationsdatum stammt, sichert der Auftrag an das
|
||||
// Modell. Hier wird festgehalten, was daraus folgen muss.
|
||||
const klasse = TemporalClass.PAST
|
||||
|
||||
expect(passtZeitlich(klasse, [TemporalClass.PAST])).toBe(true)
|
||||
expect(passtZeitlich(klasse, [TemporalClass.CURRENT])).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Property On — was sich aus einem Systemzugang ablesen lässt (Runde 10, §§2.1–2.3).
|
||||
*
|
||||
* Diese Ableitungen stehen bewusst ausserhalb der Reiter-Komponenten: Das
|
||||
* Personalblatt zeigt sie an, und Livias Recherchelauf entscheidet nach
|
||||
* denselben Regeln, welche Adresse er tatsächlich abruft. Zwei Fassungen
|
||||
* derselben Regel wären die Stelle, an der Anzeige und Abruf auseinanderlaufen —
|
||||
* und genau deren Übereinstimmung ist der Zweck der Anzeige.
|
||||
*/
|
||||
|
||||
import { AgentAccessLevel } from '../domain/teamAgent'
|
||||
import type { AgentSystem } from '../domain/teamAgent'
|
||||
import { AGENT_SYSTEM_LABELS } from './constants'
|
||||
import type { ResearchSourceDescriptor } from './researchSources'
|
||||
|
||||
/** Ohne Feld gilt ein Zugang als aktiv — der Katalog führt die Zugänge ohne Schalter. */
|
||||
export function istAktiv(system: AgentSystem): boolean {
|
||||
return system.enabled !== false
|
||||
}
|
||||
|
||||
/** Eigener Titel schlägt das Katalog-Label; nur so lassen sich freie Quellen benennen. */
|
||||
export function systemTitel(system: AgentSystem): string {
|
||||
return system.title?.trim() || AGENT_SYSTEM_LABELS[system.type] || system.type
|
||||
}
|
||||
|
||||
/** Schreibend ist alles, was nicht ausschliesslich liest. */
|
||||
export function istSchreibend(system: AgentSystem): boolean {
|
||||
return system.access !== AgentAccessLevel.READ
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Systemzugänge, die ein Recherchelauf tatsächlich abruft.
|
||||
*
|
||||
* Drei Bedingungen, alle drei sichtbar im Personalblatt: der Zugang ist
|
||||
* aktiv, er liest, und er hat eine Adresse. Ein Zugang ohne Adresse — etwa
|
||||
* die Objektdatenbank — wird nicht abgerufen, weil es nichts zu holen gibt;
|
||||
* ihn trotzdem als Quelle zu zählen, wäre eine hartcodierte Statusanzeige
|
||||
* ohne Deckung im Datenmodell.
|
||||
*/
|
||||
export function leseQuellenAus(systems: AgentSystem[]): ResearchSourceDescriptor[] {
|
||||
const quellen: ResearchSourceDescriptor[] = []
|
||||
for (const system of systems) {
|
||||
if (!istAktiv(system)) continue
|
||||
if (istSchreibend(system)) continue
|
||||
const url = system.url?.trim()
|
||||
if (!url) continue
|
||||
quellen.push({
|
||||
id: system.id,
|
||||
name: systemTitel(system),
|
||||
url,
|
||||
purpose: system.usage,
|
||||
})
|
||||
}
|
||||
return quellen
|
||||
}
|
||||
@@ -48,6 +48,23 @@ export function agentWorkspaceById(id: string): AgentWorkspace | undefined {
|
||||
export const AGENT_SUPERVISOR: AgentWorkspace = {
|
||||
id: 'thomas',
|
||||
name: 'Thomas',
|
||||
role: 'Personalverwalter',
|
||||
role: 'Agentenverwalter',
|
||||
path: ROUTES.SUPPLY.TEAM,
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Einträge unter «Meine Agenten» in der Navigation (Runde 10, §1).
|
||||
*
|
||||
* Thomas zuoberst, darunter die fünf in Arbeitsablauf-Reihenfolge. Bewusst eine
|
||||
* eigene Liste und keine Erweiterung von `AGENT_WORKSPACES`: Sonst erschiene er
|
||||
* als sechster Mitarbeitender im Meetingraum, in der Auslastungsanzeige und im
|
||||
* Willkommensdialog. Navigation und Belegschaft sind zwei verschiedene Fragen
|
||||
* an dieselben Daten — und Thomas gehört nur in die erste.
|
||||
*
|
||||
* Sein Ziel ist dieselbe Route wie aus dem Sitzungszimmer; es gibt ihn genau
|
||||
* einmal.
|
||||
*/
|
||||
export const AGENT_NAV_ENTRIES: AgentWorkspace[] = [
|
||||
AGENT_SUPERVISOR,
|
||||
...AGENT_WORKSPACES,
|
||||
]
|
||||
@@ -349,6 +349,16 @@ export const AGENT_CHAT_PROMPT = 'Wie kann ich dir heute weiterhelfen?'
|
||||
*/
|
||||
export const AGENT_CHAT_PROMPT_SHORT = 'Wie kann ich dir helfen?'
|
||||
|
||||
/**
|
||||
* Einheitlicher KI-Hinweis in allen Agenten-Detailansichten (Runde 9, §5).
|
||||
*
|
||||
* Der Wortlaut ist vorgegeben und steht deshalb genau einmal hier. Sechsmal
|
||||
* abgetippt hiesse er nach der ersten Korrektur an fünf Stellen anders — und
|
||||
* ein Haftungshinweis, der je nach Bildschirm variiert, ist keiner.
|
||||
*/
|
||||
export const AI_DISCLAIMER =
|
||||
'KI kann Fehler machen, bitte überprüfen Sie sämtliche Angaben'
|
||||
|
||||
// ── Livia — Recherche-Leads, Chance/Risiko (Runde 8, §1–§2) ──────────────────
|
||||
|
||||
export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -356,6 +366,26 @@ export const LEAD_SIGNAL_TYPE_LABELS: Record<string, string> = {
|
||||
RISIKO: 'Risiko',
|
||||
}
|
||||
|
||||
// ── Livia — Recherche aus realen Quellen (PoC, §11/§13) ──────────────────────
|
||||
|
||||
export const RESEARCH_EVENT_TYPE_LABELS: Record<string, string> = {
|
||||
EXPANSION: 'Expansion',
|
||||
NEW_LOCATION: 'Neuer Standort',
|
||||
HEADCOUNT_GROWTH: 'Personalwachstum',
|
||||
INVESTMENT: 'Investition',
|
||||
M_AND_A: 'M&A',
|
||||
RESTRUCTURING: 'Restrukturierung',
|
||||
SITE_CLOSURE: 'Standortschliessung',
|
||||
NEW_COMPANY: 'Neugründung',
|
||||
IRRELEVANT: 'Nicht relevant',
|
||||
}
|
||||
|
||||
export const RESEARCH_RELEVANCE_LABELS: Record<string, string> = {
|
||||
high: 'hohe Relevanz',
|
||||
medium: 'mittlere Relevanz',
|
||||
low: 'geringe Relevanz',
|
||||
}
|
||||
|
||||
|
||||
// ── Bruno — Besichtigungsaufträge (Runde 4, §9) ──────────────────────────────
|
||||
|
||||
@@ -604,3 +634,17 @@ export const FIT_OUT_LABELS: Record<string, string> = {
|
||||
FULL: 'Ausgebaut · CAT B',
|
||||
PREMIUM: 'Vollausgebaut · Plug & Play',
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitliche Relevanz eines Leads (Runde 10, §2.9).
|
||||
*
|
||||
* Die Begriffe beschreiben die Ereigniszeit der Veränderung, nicht das
|
||||
* Publikationsdatum — deshalb «Veränderung abgeschlossen» und nicht
|
||||
* «älterer Artikel».
|
||||
*/
|
||||
export const TEMPORAL_CLASS_LABELS: Record<string, string> = {
|
||||
past: 'Veränderung abgeschlossen',
|
||||
current: 'Läuft oder steht kurz bevor',
|
||||
future: 'Angekündigt, später',
|
||||
unknown: 'Ereigniszeit nicht belegbar',
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Property On — Text aus abgelegten Dokumenten gewinnen (Runde 10, §2.6).
|
||||
*
|
||||
* Bewusst ohne neue Abhängigkeit. Der Browser bringt mit `DecompressionStream`
|
||||
* bereits alles mit, was für beide Formate nötig ist:
|
||||
*
|
||||
* - **DOCX** ist ein ZIP-Archiv. Darin liegt `word/document.xml`, und der
|
||||
* sichtbare Text steht in `<w:t>`-Elementen. Mehr braucht es nicht.
|
||||
* - **PDF** trägt seinen Text in Inhaltsströmen, meist mit Flate gepackt. Die
|
||||
* Operatoren `Tj` und `TJ` setzen ihn.
|
||||
*
|
||||
* Was diese Datei ausdrücklich **nicht** ist: ein vollwertiger Dokumentenleser.
|
||||
* Sie kennt keine Schriftkodierungen jenseits der üblichen, keine Tabellen, kein
|
||||
* Layout und keine gescannten Seiten. Für die Beurteilung durch ein Sprachmodell
|
||||
* genügt Fliesstext in der richtigen Reihenfolge — und wo sie scheitert, sagt
|
||||
* sie das, statt eine leere Zeichenkette als «Dokument ohne Inhalt» auszugeben.
|
||||
*
|
||||
* Eine Bibliothek wie pdf.js würde mehr können. Sie wäre auch rund ein Megabyte
|
||||
* gross, und der Machbarkeitsnachweis steht und fällt nicht an der Frage, ob
|
||||
* ein zweispaltiges PDF sauber ausgelesen wird.
|
||||
*/
|
||||
|
||||
import {
|
||||
RESEARCH_DOCUMENT_MIN_CHARS,
|
||||
ResearchDocumentKind,
|
||||
} from '../domain/researchDocument'
|
||||
|
||||
export interface ExtractionResult {
|
||||
text: string
|
||||
/** Gesetzt, wenn der Text nicht brauchbar ist — die Datei bleibt trotzdem abgelegt. */
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Gemeinsames ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function inflate(bytes: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
|
||||
const stream = new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream(format))
|
||||
const puffer = await new Response(stream).arrayBuffer()
|
||||
return new Uint8Array(puffer)
|
||||
}
|
||||
|
||||
function normalisiere(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ── DOCX ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Eine Datei aus einem ZIP-Archiv holen.
|
||||
*
|
||||
* Gelesen wird über das zentrale Verzeichnis am Dateiende — das ist der einzige
|
||||
* Ort im ZIP-Format, an dem die Einträge verlässlich vollständig stehen. Die
|
||||
* lokalen Kopfzeilen davor lassen Grössenangaben offen, wenn das Archiv im
|
||||
* Datenstrom geschrieben wurde, und genau das tun viele Textverarbeitungen.
|
||||
*/
|
||||
async function zipEintrag(bytes: Uint8Array, pfad: string): Promise<Uint8Array | null> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
|
||||
// Ende des zentralen Verzeichnisses suchen (Signatur 0x06054b50), von hinten.
|
||||
let eocd = -1
|
||||
for (let i = bytes.length - 22; i >= 0 && i > bytes.length - 65_557; i--) {
|
||||
if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break }
|
||||
}
|
||||
if (eocd === -1) return null
|
||||
|
||||
const anzahl = view.getUint16(eocd + 10, true)
|
||||
let offset = view.getUint32(eocd + 16, true)
|
||||
|
||||
const encoder = new TextDecoder()
|
||||
for (let n = 0; n < anzahl; n++) {
|
||||
if (view.getUint32(offset, true) !== 0x02014b50) return null
|
||||
const methode = view.getUint16(offset + 10, true)
|
||||
const gepackt = view.getUint32(offset + 20, true)
|
||||
const nameLen = view.getUint16(offset + 28, true)
|
||||
const extraLen = view.getUint16(offset + 30, true)
|
||||
const kommentarLen = view.getUint16(offset + 32, true)
|
||||
const lokal = view.getUint32(offset + 42, true)
|
||||
const name = encoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLen))
|
||||
|
||||
if (name === pfad) {
|
||||
// Die lokale Kopfzeile sagt, wie lang Name und Zusatzfeld dort sind —
|
||||
// sie dürfen von den Angaben im Verzeichnis abweichen.
|
||||
const lokalNameLen = view.getUint16(lokal + 26, true)
|
||||
const lokalExtraLen = view.getUint16(lokal + 28, true)
|
||||
const start = lokal + 30 + lokalNameLen + lokalExtraLen
|
||||
const daten = bytes.subarray(start, start + gepackt)
|
||||
return methode === 0 ? daten : await inflate(daten, 'deflate-raw')
|
||||
}
|
||||
|
||||
offset += 46 + nameLen + extraLen + kommentarLen
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function ausDocx(bytes: Uint8Array): Promise<ExtractionResult> {
|
||||
const xmlBytes = await zipEintrag(bytes, 'word/document.xml')
|
||||
if (!xmlBytes) {
|
||||
return { text: '', note: 'Die Datei konnte nicht als Word-Dokument gelesen werden.' }
|
||||
}
|
||||
|
||||
const xml = new TextDecoder().decode(xmlBytes)
|
||||
const absaetze: string[] = []
|
||||
// Je Absatz den Text seiner Textläufe zusammensetzen — sonst klebt der ganze
|
||||
// Text in einer einzigen Zeile und das Modell verliert die Gliederung.
|
||||
for (const [, inhalt] of xml.matchAll(/<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g)) {
|
||||
const teile = [...inhalt.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map(m => m[1])
|
||||
if (teile.length === 0) continue
|
||||
const zeile = teile
|
||||
.join('')
|
||||
.replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
if (zeile) absaetze.push(zeile)
|
||||
}
|
||||
|
||||
return { text: normalisiere(absaetze.join('\n')) }
|
||||
}
|
||||
|
||||
// ── PDF ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zeichenfolgen aus den Textoperatoren eines Inhaltsstroms. */
|
||||
function pdfTextAusStrom(strom: string): string {
|
||||
const zeilen: string[] = []
|
||||
|
||||
// `(…) Tj` und `[(…) -250 (…)] TJ` — beide setzen sichtbaren Text.
|
||||
const muster = /(\[(?:[^\][\\]|\\.)*\]|\((?:[^\\()]|\\.)*\))\s*(TJ|Tj)/g
|
||||
for (const treffer of strom.matchAll(muster)) {
|
||||
const block = treffer[1]
|
||||
const stuecke: string[] = []
|
||||
for (const s of block.matchAll(/\(((?:[^\\()]|\\.)*)\)/g)) {
|
||||
stuecke.push(
|
||||
s[1]
|
||||
.replace(/\\([nrtbf])/g, (_, c: string) =>
|
||||
({ n: '\n', r: '\n', t: ' ', b: '', f: '\n' }[c] ?? ''))
|
||||
.replace(/\\(\d{1,3})/g, (_, o: string) => String.fromCharCode(parseInt(o, 8)))
|
||||
.replace(/\\(.)/g, '$1'),
|
||||
)
|
||||
}
|
||||
const zeile = stuecke.join('').trim()
|
||||
if (zeile) zeilen.push(zeile)
|
||||
}
|
||||
|
||||
return zeilen.join('\n')
|
||||
}
|
||||
|
||||
async function ausPdf(bytes: Uint8Array): Promise<ExtractionResult> {
|
||||
// Die Datei als Latin-1 lesen: So bleibt jedes Byte ein Zeichen, und die
|
||||
// Byte-Offsets der Ströme stimmen mit den Zeichen-Offsets überein.
|
||||
const roh = new TextDecoder('latin1').decode(bytes)
|
||||
const teile: string[] = []
|
||||
|
||||
const streamMuster = /stream\r?\n?/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = streamMuster.exec(roh)) !== null) {
|
||||
const start = m.index + m[0].length
|
||||
const ende = roh.indexOf('endstream', start)
|
||||
if (ende === -1) break
|
||||
streamMuster.lastIndex = ende
|
||||
|
||||
const inhalt = roh.slice(start, ende)
|
||||
// Vor dem Strom steht das Objektwörterbuch — es sagt, ob gepackt wurde.
|
||||
const kopf = roh.slice(Math.max(0, m.index - 400), m.index)
|
||||
|
||||
let text: string
|
||||
if (/FlateDecode/.test(kopf)) {
|
||||
try {
|
||||
const gepackt = Uint8Array.from(inhalt, c => c.charCodeAt(0) & 0xff)
|
||||
text = new TextDecoder('latin1').decode(await inflate(gepackt, 'deflate'))
|
||||
} catch {
|
||||
// Bilder und Schriftdateien sind ebenfalls Flate-Ströme und scheitern
|
||||
// hier reihenweise — das ist kein Fehler, sondern der Normalfall.
|
||||
continue
|
||||
}
|
||||
} else if (/Image|Font|XObject/.test(kopf)) {
|
||||
continue
|
||||
} else {
|
||||
text = inhalt
|
||||
}
|
||||
|
||||
const gefunden = pdfTextAusStrom(text)
|
||||
if (gefunden) teile.push(gefunden)
|
||||
}
|
||||
|
||||
const text = normalisiere(teile.join('\n'))
|
||||
if (text.length < RESEARCH_DOCUMENT_MIN_CHARS) {
|
||||
return {
|
||||
text,
|
||||
note:
|
||||
'Aus diesem PDF liess sich kein zusammenhängender Text gewinnen — vermutlich ein Scan ohne Textebene. '
|
||||
+ 'Das Dokument fliesst deshalb nicht in die Recherche ein.',
|
||||
}
|
||||
}
|
||||
return { text }
|
||||
}
|
||||
|
||||
// ── Einstieg ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function kindOf(fileName: string): ResearchDocumentKind | null {
|
||||
const lower = fileName.toLowerCase()
|
||||
if (lower.endsWith('.pdf')) return ResearchDocumentKind.PDF
|
||||
if (lower.endsWith('.docx')) return ResearchDocumentKind.DOCX
|
||||
return null
|
||||
}
|
||||
|
||||
export async function extractDocumentText(
|
||||
file: File,
|
||||
kind: ResearchDocumentKind,
|
||||
): Promise<ExtractionResult> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
try {
|
||||
const ergebnis = kind === ResearchDocumentKind.PDF
|
||||
? await ausPdf(bytes)
|
||||
: await ausDocx(bytes)
|
||||
|
||||
if (!ergebnis.note && ergebnis.text.length < RESEARCH_DOCUMENT_MIN_CHARS) {
|
||||
return {
|
||||
text: ergebnis.text,
|
||||
note: 'Das Dokument enthält zu wenig Text, um daraus einen Lead abzuleiten.',
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
} catch (err) {
|
||||
return {
|
||||
text: '',
|
||||
note: `Die Datei liess sich nicht lesen: ${err instanceof Error ? err.message : 'unbekannter Fehler'}.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Property On — aus einem Recherche-Lead wird ein Signal im zentralen Bestand
|
||||
* (Runde 10, §2.12).
|
||||
*
|
||||
* Bis Runde 9 standen die frisch gelesenen Leads in einer eigenen Liste neben
|
||||
* den erfassten. Das war zwar ehrlich — live gelesen ist etwas anderes als
|
||||
* redaktionell erfasst —, aber es hatte einen Preis: Nora konnte mit ihnen
|
||||
* nichts anfangen, die Filter griffen nicht, und derselbe Sachverhalt sah je
|
||||
* nach Liste anders aus.
|
||||
*
|
||||
* Seit Runde 10 gibt es deshalb genau einen Leadbestand. Die Herkunft geht
|
||||
* dabei nicht verloren, sie steht am Signal (`origin: 'RESEARCH_RUN'`) —
|
||||
* unterschieden wird über ein Feld, nicht über eine zweite Ablage.
|
||||
*/
|
||||
|
||||
import { RiskLevel, SignalType } from '../domain/enums'
|
||||
import { LeadSignalType } from '../domain/futureSignal'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import { ResearchEventType } from '../domain/researchLead'
|
||||
import type { ResearchLead, ResearchRelevance } from '../domain/researchLead'
|
||||
|
||||
/**
|
||||
* Welcher Signaltyp zu welchem Ereignis gehört.
|
||||
*
|
||||
* Die Recherche kennt neun Ereignisarten, der Bestand sieben Signaltypen —
|
||||
* die Zuordnung ist deshalb nicht eins zu eins und steht hier explizit, statt
|
||||
* irgendwo geraten zu werden.
|
||||
*/
|
||||
const SIGNAL_TYPE_BY_EVENT: Record<ResearchEventType, SignalType> = {
|
||||
[ResearchEventType.EXPANSION]: SignalType.EXPANSION,
|
||||
[ResearchEventType.NEW_LOCATION]: SignalType.EXPANSION,
|
||||
[ResearchEventType.HEADCOUNT_GROWTH]: SignalType.EXPANSION,
|
||||
[ResearchEventType.INVESTMENT]: SignalType.PROJECT_DEVELOPMENT,
|
||||
[ResearchEventType.NEW_COMPANY]: SignalType.EXPANSION,
|
||||
[ResearchEventType.M_AND_A]: SignalType.SPACE_CONSOLIDATION,
|
||||
[ResearchEventType.RESTRUCTURING]: SignalType.RESTRUCTURING,
|
||||
[ResearchEventType.SITE_CLOSURE]: SignalType.POSSIBLE_MOVE_OUT,
|
||||
// Irrelevantes wird nie zum Lead; der Eintrag hält die Abbildung vollständig.
|
||||
[ResearchEventType.IRRELEVANT]: SignalType.EXPANSION,
|
||||
}
|
||||
|
||||
/** Ereignisse, aus denen Flächenbedarf entsteht — alles andere betrifft Bestehendes. */
|
||||
const CHANCE_EVENTS = new Set<ResearchEventType>([
|
||||
ResearchEventType.EXPANSION,
|
||||
ResearchEventType.NEW_LOCATION,
|
||||
ResearchEventType.HEADCOUNT_GROWTH,
|
||||
ResearchEventType.INVESTMENT,
|
||||
ResearchEventType.NEW_COMPANY,
|
||||
])
|
||||
|
||||
/**
|
||||
* Zeithorizont in Monaten aus der Ereigniszeit.
|
||||
*
|
||||
* Bewusst grob und bewusst nicht aus dem Publikationsdatum gerechnet: Der
|
||||
* Horizont ist eine Anzeigegrösse der Liste, die belastbare Aussage steht in
|
||||
* `temporalClass` und `temporalEvidence`. Eine Nachkommastelle würde hier eine
|
||||
* Genauigkeit vortäuschen, die die Quelle nicht hergibt.
|
||||
*/
|
||||
const HORIZON_MONTHS: Record<string, number> = {
|
||||
past: 0,
|
||||
current: 2,
|
||||
future: 9,
|
||||
unknown: 6,
|
||||
}
|
||||
|
||||
const RELEVANCE_SCORE: Record<ResearchRelevance, number> = {
|
||||
high: 85,
|
||||
medium: 65,
|
||||
low: 40,
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine stabile Kennung für denselben Sachverhalt.
|
||||
*
|
||||
* Sie muss über Läufe hinweg gleich bleiben: Wer zweimal auf «Research
|
||||
* aktualisieren» drückt, soll denselben Artikel nicht zweimal in der Liste
|
||||
* finden. Grundlage sind Quelladresse und Überschrift — beides ändert sich
|
||||
* nicht, solange es derselbe Artikel ist.
|
||||
*/
|
||||
export function researchLeadId(lead: ResearchLead): string {
|
||||
const roh = `${lead.sourceUrl}|${lead.headline}`
|
||||
let hash = 0
|
||||
for (let i = 0; i < roh.length; i++) {
|
||||
hash = (hash * 31 + roh.charCodeAt(i)) | 0
|
||||
}
|
||||
return `research-${(hash >>> 0).toString(36)}`
|
||||
}
|
||||
|
||||
export function researchLeadToSignal(lead: ResearchLead, laufZeitpunkt: string): FutureSignal {
|
||||
const istChance = CHANCE_EVENTS.has(lead.eventType)
|
||||
const relevanz = RELEVANCE_SCORE[lead.relevance]
|
||||
|
||||
return {
|
||||
id: researchLeadId(lead),
|
||||
signalType: SIGNAL_TYPE_BY_EVENT[lead.eventType],
|
||||
companyName: lead.company,
|
||||
locationHint: lead.location ?? 'Ort nicht genannt',
|
||||
probability: lead.confidence,
|
||||
confidenceScore: lead.confidence,
|
||||
timeHorizonMonths: HORIZON_MONTHS[lead.temporalClass] ?? 6,
|
||||
|
||||
source: {
|
||||
type: 'PRESS',
|
||||
url: lead.sourceUrl,
|
||||
publishedAt: lead.publishedAt,
|
||||
// Eine namentlich genannte, direkt verlinkte Quelle ist belastbar; die
|
||||
// Unsicherheit der Einordnung steckt in `confidenceScore`, nicht hier.
|
||||
credibility: 'HIGH',
|
||||
},
|
||||
sensitivityLevel: 'PUBLIC',
|
||||
disclaimer:
|
||||
'Aus einer öffentlich zugänglichen Quelle abgeleitet. Vor jeder Ansprache am Original prüfen.',
|
||||
riskLevel: istChance ? RiskLevel.LOW : RiskLevel.MEDIUM,
|
||||
relevanceScore: relevanz,
|
||||
isVerified: false,
|
||||
createdAt: laufZeitpunkt,
|
||||
updatedAt: laufZeitpunkt,
|
||||
|
||||
title: lead.headline,
|
||||
aiSummary: lead.summary,
|
||||
evidence: {
|
||||
summary: lead.summary,
|
||||
sourceUrls: [lead.sourceUrl],
|
||||
extractedAt: laufZeitpunkt,
|
||||
},
|
||||
|
||||
// Chance-Leads zeigen in die Nachfragerichtung — nur so nimmt die
|
||||
// gemeinsame Leadliste sie auf. Risiken kommen über `leadSignalType` hinein.
|
||||
signalDirection: istChance ? 'DEMAND' : 'SUPPLY',
|
||||
leadSignalType: istChance ? LeadSignalType.CHANCE : LeadSignalType.RISIKO,
|
||||
signalRationale: lead.reason,
|
||||
recommendedAction: lead.recommendedAction,
|
||||
|
||||
temporalClass: lead.temporalClass,
|
||||
temporalEvidence: lead.temporalEvidence || undefined,
|
||||
eventDate: lead.eventDate,
|
||||
semanticSearchProfile: lead.semanticSearchProfile,
|
||||
origin: 'RESEARCH_RUN',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Property On — die real angebundenen Recherchequellen von Livia.
|
||||
*
|
||||
* Eine einzige Liste für beide Seiten: die serverlose Funktion liest daraus
|
||||
* die abzurufenden Adressen, die Oberfläche zeigt daraus «Angebundene Kanäle
|
||||
* & Systeme». Stünden die URLs an zwei Stellen, könnte der angezeigte Link
|
||||
* irgendwann auf etwas anderes zeigen als der tatsächliche Abruf — und genau
|
||||
* die Nachvollziehbarkeit ist der Zweck der Anzeige (§2).
|
||||
*
|
||||
* Die Datei enthält bewusst nur Daten und keine Abruflogik: so kann `api/`
|
||||
* sie importieren, ohne dass der HTML-Parser im Frontend-Bundle landet.
|
||||
*/
|
||||
|
||||
export interface ResearchSourceDescriptor {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
/** Wofür die Quelle im PoC steht — erscheint in der Oberfläche. */
|
||||
purpose: string
|
||||
}
|
||||
|
||||
export const RESEARCH_SOURCES = {
|
||||
ZHK: {
|
||||
id: 'zhk',
|
||||
name: 'Zürcher Handelskammer — News',
|
||||
url: 'https://www.zhk.ch/de/wirtschaft-und-politik/news-liste.html',
|
||||
purpose: 'Regionale Unternehmensmeldungen, Expansion, Investitionen, Kapazitätsausbau',
|
||||
},
|
||||
GZA: {
|
||||
id: 'gza',
|
||||
name: 'Greater Zurich Area — News',
|
||||
url: 'https://www.greaterzuricharea.com/de/alle-news',
|
||||
purpose: 'Ansiedlungen, Markteintritte, internationale Expansionen und neue Standorte',
|
||||
},
|
||||
ZEFIX: {
|
||||
id: 'zefix',
|
||||
name: 'Zefix / Open Data Kanton Zürich',
|
||||
url: 'https://www.web.statistik.zh.ch/ogd/data/KANTON_ZUERICH_ZEFIX_Neugruendungen.csv',
|
||||
purpose:
|
||||
'Strukturierte Daten zu Zürcher Neugründungen; CSV wird direkt eingelesen — '
|
||||
+ 'Marktindikator ohne Firmennamen, erscheint deshalb nicht als Lead',
|
||||
},
|
||||
} as const satisfies Record<string, ResearchSourceDescriptor>
|
||||
|
||||
export const RESEARCH_SOURCE_LIST: ResearchSourceDescriptor[] = [
|
||||
RESEARCH_SOURCES.ZHK,
|
||||
RESEARCH_SOURCES.GZA,
|
||||
RESEARCH_SOURCES.ZEFIX,
|
||||
]
|
||||
|
||||
/** Der eine Endpoint des PoC (§20). */
|
||||
export const RESEARCH_REFRESH_ENDPOINT = '/api/livia/research/refresh'
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Property On — der harte Zeitfilter (Runde 10, §2.9).
|
||||
*
|
||||
* Die Regel steht hier und nicht nur in der serverlosen Funktion, weil sie an
|
||||
* zwei Stellen gilt: Der Lauf wendet sie an, damit unpassende Leads gar nicht
|
||||
* erst entstehen, und die Anzeige wendet sie auf den bereits erfassten Bestand
|
||||
* an. Zwei Fassungen derselben Regel wären zwei unterschiedliche Antworten auf
|
||||
* dieselbe Frage.
|
||||
*
|
||||
* Zur Deutung von `unknown`: Ein Lead ohne belastbare zeitliche Aussage fällt
|
||||
* **nicht** durch. Das ist eine bewusste Entscheidung — die Alternative wäre,
|
||||
* ihn anhand des Publikationsdatums einzusortieren, und genau das ist der
|
||||
* Fehler, den dieser Filter verhindern soll. Sichtbar bleibt er mit dem
|
||||
* Vermerk, dass seine Ereigniszeit offen ist.
|
||||
*/
|
||||
|
||||
import type { TemporalClass } from '../domain/researchLead'
|
||||
|
||||
export function passtZeitlich(
|
||||
klasse: TemporalClass | undefined,
|
||||
erlaubt: TemporalClass[],
|
||||
): boolean {
|
||||
// Keine Auswahl heisst keine Einschränkung — nicht «nichts anzeigen».
|
||||
if (erlaubt.length === 0) return true
|
||||
// Ein Bestandssignal ohne Einordnung verhält sich wie «unknown».
|
||||
if (klasse === undefined || klasse === 'unknown') return true
|
||||
return erlaubt.includes(klasse)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AgentSettingGroup,
|
||||
AgentAvatarTone,
|
||||
} from '../../domain/teamAgent'
|
||||
import { RESEARCH_SOURCES } from '../../lib/researchSources'
|
||||
|
||||
export const liviaAgent: TeamAgent = {
|
||||
id: 'livia',
|
||||
@@ -90,36 +91,6 @@ export const liviaAgent: TeamAgent = {
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-02',
|
||||
title: 'Geschäfts- und Jahresberichte auswerten',
|
||||
description:
|
||||
'Liest publizierte Geschäfts- und Jahresberichte auf Aussagen zu Standorten, Kapazitäten, Investitionen und Personalbestand. Zahlen werden mit Berichtsjahr und Fundstelle übernommen, nicht als Trend interpretiert.',
|
||||
enabled: true,
|
||||
schedule: 'wöchentlich, montags 06:30',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-03',
|
||||
title: 'Handelsregister- und Unternehmensinformationen verfolgen',
|
||||
description:
|
||||
'Verfolgt Mutationen im Handelsregister: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen. Eine Sitzverlegung ist der belastbarste Hinweis auf einen Standortwechsel, den es öffentlich gibt.',
|
||||
enabled: true,
|
||||
schedule: 'täglich 06:15',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.ZEFIX,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-04',
|
||||
title: 'Amtliche Publikationen sichten',
|
||||
description:
|
||||
'Sichtet amtliche Publikationen zu Baugesuchen, Zonenänderungen und Arealentwicklungen und prüft, ob ein Unternehmen aus dem Beobachtungsbereich betroffen ist.',
|
||||
enabled: true,
|
||||
schedule: 'täglich 06:20',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.SHAB,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-05',
|
||||
title: 'Wachstumssignale erkennen',
|
||||
@@ -129,15 +100,6 @@ export const liviaAgent: TeamAgent = {
|
||||
schedule: 'im Anschluss an jeden Quellenlauf',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-06',
|
||||
title: 'Negative Entwicklungen erkennen',
|
||||
description:
|
||||
'Erkennt M&A-Aktivitäten, Restrukturierungen, Standortschliessungen und die Einstellung von Geschäftstätigkeiten. Diese Signale sind die wertvollsten, weil sie bestehende Mietverhältnisse betreffen und Vorlauf verschaffen.',
|
||||
enabled: true,
|
||||
schedule: 'im Anschluss an jeden Quellenlauf',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-07',
|
||||
title: 'Lead als Chance oder Risiko einordnen',
|
||||
@@ -184,6 +146,16 @@ export const liviaAgent: TeamAgent = {
|
||||
requiresApproval: false,
|
||||
dependsOnChannel: AgentChannelType.WORKSPACE_CHAT,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-14',
|
||||
title: 'Unsichere Signale in Review Queue legen',
|
||||
description:
|
||||
'Liegt die Konfidenz unter der eingestellten Schwelle oder widersprechen sich die Quellen, legt Livia den Lead in die Review-Queue der Marktbeobachtung. Solche Leads erscheinen nicht in der Lead-Liste, bevor jemand sie geprüft hat. Die Aufgabe gehörte bis Runde 10 zu Nora — sie beurteilt die Quellenlage, und die kennt Livia, nicht das Matching.',
|
||||
enabled: true,
|
||||
schedule: 'sobald die Konfidenz unter der Review-Schwelle liegt',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-12',
|
||||
title: 'Doppelmeldungen unterdrücken',
|
||||
@@ -193,16 +165,6 @@ export const liviaAgent: TeamAgent = {
|
||||
schedule: 'vor jeder Übergabe',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'livia-task-13',
|
||||
title: 'Wochenüberblick zustellen',
|
||||
description:
|
||||
'Stellt der Bewirtschaftung montags einen Überblick der Leads der Vorwoche zu, getrennt nach Chance und Risiko, mit den jeweils empfohlenen Handlungen.',
|
||||
enabled: true,
|
||||
schedule: 'montags 07:00',
|
||||
requiresApproval: false,
|
||||
dependsOnChannel: AgentChannelType.EMAIL,
|
||||
},
|
||||
],
|
||||
|
||||
channels: [
|
||||
@@ -239,13 +201,33 @@ export const liviaAgent: TeamAgent = {
|
||||
],
|
||||
|
||||
systems: [
|
||||
/*
|
||||
* Die drei realen Quellen stehen seit Runde 10 (§2.3) einzeln hier statt
|
||||
* gesammelt hinter einem Eintrag «Öffentliche Webquellen». Der Grund ist
|
||||
* nicht Kosmetik: Der Recherchelauf liest genau diese Liste. Was hier als
|
||||
* aktiver Lesezugang mit Adresse steht, wird abgerufen — und nur das.
|
||||
* Ein Sammeleintrag ohne eigene Adresse wäre eine Statusanzeige ohne
|
||||
* Deckung im Datenmodell.
|
||||
*/
|
||||
{
|
||||
id: 'livia-sys-public-web',
|
||||
id: 'livia-sys-zhk',
|
||||
type: AgentSystemType.PUBLIC_WEB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Verknüpfte Newsseiten und Wirtschaftsnachrichten, Geschäfts- und Jahresberichte, Mitteilungen von Handelskammern und Branchenverbänden sowie Unternehmensmeldungen. Die konkreten Adressen stehen in der Einstellung «Verknüpfte Newsquellen».',
|
||||
title: RESEARCH_SOURCES.ZHK.name,
|
||||
url: RESEARCH_SOURCES.ZHK.url,
|
||||
usage: RESEARCH_SOURCES.ZHK.purpose,
|
||||
permissionNote:
|
||||
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
|
||||
},
|
||||
{
|
||||
id: 'livia-sys-gza',
|
||||
type: AgentSystemType.PUBLIC_WEB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
title: RESEARCH_SOURCES.GZA.name,
|
||||
url: RESEARCH_SOURCES.GZA.url,
|
||||
usage: RESEARCH_SOURCES.GZA.purpose,
|
||||
permissionNote:
|
||||
'Nur lesend. Livia ruft ausschliesslich frei zugängliche Seiten ab und meldet sich nirgends an.',
|
||||
},
|
||||
@@ -254,19 +236,11 @@ export const liviaAgent: TeamAgent = {
|
||||
type: AgentSystemType.ZEFIX,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Handelsregister- und Unternehmensinformationen: Neugründungen, Sitzverlegungen, Zweckänderungen, Fusionen und Liquidationen.',
|
||||
title: RESEARCH_SOURCES.ZEFIX.name,
|
||||
url: RESEARCH_SOURCES.ZEFIX.url,
|
||||
usage: RESEARCH_SOURCES.ZEFIX.purpose,
|
||||
permissionNote: 'Nur lesend. Einträge werden als Quelle mit Publikationsdatum übernommen.',
|
||||
},
|
||||
{
|
||||
id: 'livia-sys-shab',
|
||||
type: AgentSystemType.SHAB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Amtliche Publikationen zu Baugesuchen, Zonenänderungen, Arealentwicklungen und Firmenmutationen.',
|
||||
permissionNote: 'Nur lesend. Meldungen werden als Quelle mit Publikationsdatum übernommen.',
|
||||
},
|
||||
{
|
||||
id: 'livia-sys-immotop2',
|
||||
type: AgentSystemType.IMMOTOP2,
|
||||
@@ -301,35 +275,21 @@ export const liviaAgent: TeamAgent = {
|
||||
|
||||
settings: [
|
||||
{
|
||||
id: 'livia-set-news-sources',
|
||||
label: 'Verknüpfte Newsquellen',
|
||||
id: 'livia-set-news-documents',
|
||||
label: 'Newsquellen manuell ablegen',
|
||||
description:
|
||||
'Newsseiten und Portale, die Livia täglich abgeht. Weitere Adressen lassen sich hier ergänzen — es braucht keine zweite Quellenverwaltung.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
'Geschäftsberichte, Rundschreiben und andere Unterlagen als PDF oder Word. Ihr Text geht zusammen mit den aktiven Lesequellen aus «Systeme» in denselben Recherchelauf. Die Datei bleibt im Browser — an die Beurteilung geht nur ihr Text.',
|
||||
kind: AgentSettingKind.DOCUMENTS,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: [
|
||||
'https://www.handelszeitung.ch',
|
||||
'https://www.nzz.ch/wirtschaft',
|
||||
'https://www.finanzundwirtschaft.ch',
|
||||
'https://www.shab.ch',
|
||||
],
|
||||
options: [
|
||||
{ value: 'https://www.handelszeitung.ch', label: 'Handelszeitung' },
|
||||
{ value: 'https://www.nzz.ch/wirtschaft', label: 'NZZ Wirtschaft' },
|
||||
{ value: 'https://www.finanzundwirtschaft.ch', label: 'Finanz und Wirtschaft' },
|
||||
{ value: 'https://www.shab.ch', label: 'SHAB — Amtliche Publikationen' },
|
||||
{ value: 'https://www.zefix.ch', label: 'Zefix — Handelsregister' },
|
||||
{ value: 'https://www.cash.ch/news', label: 'cash — Unternehmensnachrichten' },
|
||||
{ value: 'https://www.zhk.ch', label: 'Zürcher Handelskammer' },
|
||||
{ value: 'https://www.hkbb.ch', label: 'Handelskammer beider Basel' },
|
||||
{ value: 'https://www.moneyhouse.ch', label: 'Moneyhouse — Unternehmensdaten' },
|
||||
],
|
||||
value: [],
|
||||
},
|
||||
{
|
||||
id: 'livia-set-source-types',
|
||||
label: 'Relevante Quellentypen',
|
||||
description: 'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
description:
|
||||
'Welche Arten von Quellen Livia für die Leaderzeugung heranzieht. Eigene Typen lassen sich frei erfassen.',
|
||||
kind: AgentSettingKind.CHIPS,
|
||||
allowCustom: true,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: [
|
||||
'geschaeftsberichte',
|
||||
@@ -393,8 +353,10 @@ export const liviaAgent: TeamAgent = {
|
||||
{
|
||||
id: 'livia-set-observation-area',
|
||||
label: 'Beobachtungsraum',
|
||||
description: 'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
description:
|
||||
'Wirtschaftsräume, für die Livia Meldungen als relevant einstuft. Eigene Räume lassen sich frei erfassen.',
|
||||
kind: AgentSettingKind.CHIPS,
|
||||
allowCustom: true,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: ['zuerich', 'basel', 'zentralschweiz', 'ostschweiz', 'bern'],
|
||||
options: [
|
||||
@@ -407,6 +369,36 @@ export const liviaAgent: TeamAgent = {
|
||||
{ value: 'tessin', label: 'Tessin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
/*
|
||||
* Der harte Zeitfilter (Runde 10, §2.9).
|
||||
*
|
||||
* Er steht bewusst zwischen Beobachtungsraum und Mindestrelevanz: Beide
|
||||
* schränken ein, aber unterschiedlich. Der Raum sagt «wo», dieser Filter
|
||||
* «wann» — und die Relevanz danach «wie stark». Alle drei aktiv zu
|
||||
* lassen ist die Vorgabe, damit ein bestehender Demo-Stand nicht
|
||||
* unbemerkt Ergebnisse verliert.
|
||||
*/
|
||||
id: 'livia-set-temporal-relevance',
|
||||
label: 'Zeitliche Relevanz',
|
||||
description:
|
||||
'Welche Ereigniszeiten ein Lead haben darf. Massgeblich ist, wann die beschriebene Veränderung stattfindet — nicht, wann der Artikel erschienen ist.',
|
||||
kind: AgentSettingKind.CHIPS,
|
||||
allowCustom: false,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: ['past', 'current', 'future'],
|
||||
options: [
|
||||
{ value: 'past', label: 'Vergangene Leads' },
|
||||
{ value: 'current', label: 'Aktuelle Leads' },
|
||||
{ value: 'future', label: 'Zukünftige Leads' },
|
||||
],
|
||||
infoText:
|
||||
'Vergangen: Die Veränderung ist abgeschlossen — das Unternehmen ist umgezogen, der Standort eröffnet oder geschlossen. '
|
||||
+ 'Aktuell: Sie läuft gerade oder steht innerhalb von acht Wochen bevor. '
|
||||
+ 'Zukünftig: Sie liegt weiter voraus oder wurde ohne kurzfristige Umsetzung angekündigt. '
|
||||
+ 'Massgeblich ist immer die Ereigniszeit, nie das Publikationsdatum: Ein Jahresbericht vom Januar kann einen Standort für das dritte Quartal ankündigen — das ist ein zukünftiger Lead. '
|
||||
+ 'Lässt eine Quelle keine belastbare zeitliche Aussage zu, wird kein Datum erfunden; der Lead erscheint mit dem Vermerk, dass die Ereigniszeit offen ist.',
|
||||
},
|
||||
{
|
||||
id: 'livia-set-min-relevance',
|
||||
label: 'Mindestrelevanz für einen Lead',
|
||||
@@ -418,6 +410,13 @@ export const liviaAgent: TeamAgent = {
|
||||
unit: '%',
|
||||
min: 0,
|
||||
max: 100,
|
||||
infoText:
|
||||
'Die KI bewertet, wie stark der Inhalt auf eine für Property Match relevante räumliche oder geschäftliche Veränderung hinweist. '
|
||||
+ 'Berücksichtigt werden insbesondere die Eindeutigkeit des Signals, der Bezug zu Standort beziehungsweise Flächenbedarf, '
|
||||
+ 'die Passung zum definierten Beobachtungsraum und den Quellentypen sowie die Qualität und Konkretheit der verfügbaren Informationen. '
|
||||
+ 'Die zeitliche Relevanz wird separat als harter Filter angewendet und verändert den Relevanzwert nicht. '
|
||||
+ 'Im aktuellen Stand vergibt die Beurteilung drei Stufen — hoch, mittel und tief. Leads der tiefen Stufe erscheinen nicht in der Liste, '
|
||||
+ 'sondern zählen als Beobachtungsposten. Eine Gewichtung einzelner Kriterien in Prozent gibt es nicht.',
|
||||
},
|
||||
{
|
||||
id: 'livia-set-tonality',
|
||||
|
||||
+133
-248
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* Property On — Personalblatt «Nora», Markt-Scout.
|
||||
* Property On — Personalblatt «Nora», Marktchancen und Leads.
|
||||
*
|
||||
* Nora beobachtet öffentliche Quellen und simulierte Feeds zu den definierten
|
||||
* Regionen und Segmenten, stuft jedes Signal ein und legt unsichere Fälle in die
|
||||
* Review-Queue. Sie arbeitet ausschliesslich auf Vorschlagsbasis: Wochen-Mail,
|
||||
* Leads und Markteinschätzung gehen erst nach Freigabe durch die Marktbeobachtung
|
||||
* hinaus.
|
||||
* Seit Runde 10 (§3) arbeitet Nora nicht mehr an den Quellen, sondern am
|
||||
* Bestand: Sie nimmt die Nachfrageprofile, die Livia aus ihrer Recherche
|
||||
* ableitet, und gleicht sie gegen die eigene Objektdatenbasis ab. Das Ergebnis
|
||||
* ist ein nachvollziehbarer Match-Wert je Objekt.
|
||||
*
|
||||
* Was sie ausdrücklich **nicht** mehr tut: Quellen beobachten, Signale
|
||||
* klassifizieren, unsichere Fälle prüfen lassen und Wochenberichte versenden.
|
||||
* Das ist alles zu Livia gewandert — sie liest den Markt, Nora verbindet ihn
|
||||
* mit dem Bestand.
|
||||
*/
|
||||
|
||||
import type { TeamAgent } from '../../domain/teamAgent'
|
||||
@@ -33,124 +37,80 @@ export const noraAgent: TeamAgent = {
|
||||
status: AgentStatus.ACTIVE,
|
||||
autonomyLevel: AgentAutonomyLevel.PROPOSAL_ONLY,
|
||||
shortDescription:
|
||||
'Beobachtet Marktsignale, ordnet sie ein und erstellt Wochen-Mails, Leads und Marktbausteine.',
|
||||
autonomyNote: '«Vorschlag»',
|
||||
'Gleicht Nachfrageprofile mit dem eigenen Objektbestand ab und legt passende Objekte mit nachvollziehbarem Match-Wert vor.',
|
||||
autonomyNote:
|
||||
'«Vorschlag». Nora rechnet und ordnet, entscheidet aber nichts: Welches Objekt angeboten wird, bleibt die Sache der Bewirtschaftung.',
|
||||
|
||||
profile: {
|
||||
purpose:
|
||||
'Nora hält die Marktbeobachtung ohne tägliche Handarbeit auf dem Laufenden, indem sie Bewegungen bei kommerziell genutzten Immobilien früh erkennt, einordnet und als prüffertigen Vorschlag vorlegt.',
|
||||
'Nora beantwortet die Frage, die auf jeden Lead folgt: Haben wir dafür etwas? Sie nimmt das Nachfrageprofil, das Livia aus einer Quelle abgeleitet hat, und gleicht es mit dem eigenen Objektbestand ab — mit einem Wert je Objekt, der sich auf die geprüften Merkmale zurückführen lässt.',
|
||||
input: [
|
||||
'Regionen: Zürich, Winterthur, Zug, Basel, Bern und St. Gallen',
|
||||
'Segmente: Büro, Retail, Gastronomie, Logistik, Gewerbe und Produktion',
|
||||
'Öffentliche Quellen wie SHAB-Meldungen, Zefix-Einträge und Firmenwebseiten',
|
||||
'Simulierte Feeds zu Ausschreibungen, Standortmeldungen und Medienberichten',
|
||||
'Objekt- und Portfoliostammdaten aus dem Arbeitsbereich',
|
||||
'Nachfrageprofile aus Livias Recherche-Leads',
|
||||
'Objektdatenbasis aus «Meine Objekte» — Standort, Fläche, Nutzung, Verfügbarkeit, Ausstattung',
|
||||
'Bestehende Mietverhältnisse, um belegte von freien Flächen zu unterscheiden',
|
||||
'Laufende Vorgänge aus dem CRM, damit bekannte Nachfrage nicht doppelt erscheint',
|
||||
],
|
||||
coreFlow: [
|
||||
'Nora sichtet die freigegebenen Quellen und Feeds zu den eingestellten Regionen und Segmenten.',
|
||||
'Jede Meldung wird als Signalart eingeordnet — zum Beispiel Umzug, Expansion oder Filialschliessung.',
|
||||
'Zu jedem Signal vergibt sie eine Konfidenz und hinterlegt die Quelle mit Datum.',
|
||||
'Liegt die Konfidenz unter der Review-Schwelle, wandert das Signal in die Review-Queue statt in den Versand.',
|
||||
'Verifizierte Signale werden einer Region und, wenn möglich, einem konkreten Objekt zugeordnet.',
|
||||
'Wochen-Mail, Lead-Liste und Markteinschätzung entstehen als Vorschlag und gehen erst nach Freigabe raus.',
|
||||
'Aus dem Nachfrageprofil die Anforderungen lesen, die darin tatsächlich stehen.',
|
||||
'Den Kandidatenkreis bilden: freie und bald frei werdende Objekte, auf Wunsch auch belegte.',
|
||||
'Jedes Objekt gegen die gelesenen Anforderungen prüfen — Standort, Fläche, Nutzung, Verfügbarkeit und, wo das Profil sie nennt, Erreichbarkeit, Parkierung, Ausbau und Sichtbarkeit.',
|
||||
'Merkmale, zu denen der Bestand nichts sagt, als nicht bekannt führen statt als erfüllt.',
|
||||
'Die Übereinstimmung als Wert von 0 bis 100 ausgeben und die Liste danach ordnen.',
|
||||
'Den Lauf mit Zeitpunkt, Kandidatenzahl und bestem Wert protokollieren.',
|
||||
],
|
||||
output: [
|
||||
'Wochen-Mail mit den wichtigsten Marktsignalen, dienstags um 08:00 Uhr',
|
||||
'Einzelne Marktsignale mit Signalart, Konfidenz, Quelle und Regionsbezug',
|
||||
'Lead-Liste aus Umzugs- und Expansionssignalen zur Übergabe an das CRM',
|
||||
'Monatliche Markteinschätzung je Region und Segment',
|
||||
'Review-Queue mit den unsicheren Signalen zur Sichtung durch das Team',
|
||||
'Trefferliste der Objekte mit Match-Wert, sortiert vom besten Treffer abwärts',
|
||||
'Je Objekt die geprüften Kriterien mit ihrer Begründung',
|
||||
'Hinweis, wie viel sich am jeweiligen Objekt überhaupt prüfen liess',
|
||||
'Protokolleintrag je durchgeführtem Abgleich',
|
||||
],
|
||||
},
|
||||
|
||||
responsibilities: [
|
||||
'Laufende Beobachtung der freigegebenen Quellen und Feeds in den eingestellten Regionen',
|
||||
'Nachvollziehbare Einordnung jedes Signals nach Signalart und Konfidenz',
|
||||
'Saubere Trennung zwischen verifizierten Signalen und Fällen für die Review-Queue',
|
||||
'Zuordnung der Signale zu Regionen und, wo belegbar, zu einzelnen Objekten',
|
||||
'Vorbereitung von Wochen-Mail, Lead-Liste und monatlicher Markteinschätzung als Vorschlag',
|
||||
'Nachfrageprofile in prüfbare Anforderungen übersetzen',
|
||||
'Den Objektbestand nach diesen Anforderungen abgleichen, ohne fehlende Angaben zu erfinden',
|
||||
'Den Match-Wert so ausweisen, dass er sich auf einzelne Kriterien zurückführen lässt',
|
||||
'Belegte Objekte nur auf ausdrücklichen Wunsch in den Kandidatenkreis nehmen',
|
||||
'Jeden durchgeführten Abgleich nachvollziehbar protokollieren',
|
||||
],
|
||||
|
||||
/*
|
||||
* Runde 10, §3.10: Noras Aufgabenliste beschreibt jetzt, was sie tatsächlich
|
||||
* tut — Nachfrageprofile gegen den Bestand abgleichen. Beobachten,
|
||||
* klassifizieren, Quellen prüfen und der Wochenversand sind zu Livia
|
||||
* gewandert; sie liest die Quellen, Nora arbeitet mit dem, was daraus wird.
|
||||
*/
|
||||
tasks: [
|
||||
{
|
||||
id: 'nora-task-01',
|
||||
title: 'Quellen und Feeds simuliert überwachen',
|
||||
id: 'nora-task-10',
|
||||
title: 'Internes Matching durchführen',
|
||||
description:
|
||||
'Sichtet die freigegebenen öffentlichen Quellen und die simulierten Feeds zu den eingestellten Regionen und Segmenten. Neue Meldungen werden mit Quelle, Datum und Fundort erfasst; es wird nichts veröffentlicht und nichts weitergegeben.',
|
||||
'Wertet Nachfrageprofile aus und gleicht sie mit der internen Objektdatenbank ab. Grundlage ist derselbe Objektbestand wie in «Meine Objekte» — es gibt keine zweite Objektliste für das Matching.',
|
||||
enabled: true,
|
||||
schedule: 'täglich 06:00, zusätzlich stündlich zwischen 08:00 und 18:00',
|
||||
schedule: 'auf Anforderung, bei jedem Matching-Lauf',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.PUBLIC_WEB,
|
||||
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-02',
|
||||
title: 'Marktsignale klassifizieren',
|
||||
id: 'nora-task-11',
|
||||
title: 'Match-Score analysieren und zuordnen',
|
||||
description:
|
||||
'Ordnet jede erfasste Meldung einer Signalart zu — Umzug, Expansion, Verkleinerung, Neugründung oder Filialschliessung — und hält fest, welche Textstelle der Quelle die Einordnung stützt.',
|
||||
'Bewertet die Übereinstimmung zwischen Nachfrageprofil und Objektmerkmalen und stellt sie als nachvollziehbaren Match-Wert dar. Merkmale, zu denen der Bestand nichts sagt, gelten als nicht bekannt und nicht als erfüllt.',
|
||||
enabled: true,
|
||||
schedule: 'bei Eingang einer neuen Meldung',
|
||||
schedule: 'im Anschluss an jeden Matching-Lauf',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.IMMOTOP2,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-12',
|
||||
title: 'Potenzielle Objekte für Matching darstellen',
|
||||
description:
|
||||
'Bereitet geeignete verfügbare, bald verfügbare und — wenn ausdrücklich aktiviert — belegte Objekte gemäss den definierten Matchingparametern auf. Belegte Objekte dienen der Frage nach konkurrierender Nachfrage, nicht der Kündigung eines Mietverhältnisses.',
|
||||
enabled: true,
|
||||
schedule: 'im Anschluss an jeden Matching-Lauf',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-03',
|
||||
title: 'Konfidenz vergeben',
|
||||
description:
|
||||
'Vergibt jedem Signal einen Konfidenzwert in Prozent. Grundlage sind Verlässlichkeit der Quelle, Aktualität der Meldung und ob sich dieselbe Information in einer zweiten Quelle bestätigt.',
|
||||
enabled: true,
|
||||
schedule: 'bei jeder Klassifizierung',
|
||||
requiresApproval: false,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-04',
|
||||
title: 'unsichere Signale in eine Review-Queue legen',
|
||||
description:
|
||||
'Liegt die Konfidenz unter der eingestellten Review-Schwelle oder widersprechen sich die Quellen, legt Nora das Signal in die Review-Queue der Marktbeobachtung. Solche Signale erscheinen weder in der Wochen-Mail noch in der Lead-Liste, bevor jemand sie geprüft hat.',
|
||||
enabled: true,
|
||||
schedule: 'sobald die Konfidenz unter der Review-Schwelle liegt',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-05',
|
||||
title: 'Signale Regionen und Objekten zuordnen',
|
||||
description:
|
||||
'Verknüpft jedes verifizierte Signal mit der betroffenen Region und, sofern Adresse oder Firmensitz eindeutig belegt sind, mit einem konkreten Objekt im Portfolio. Bleibt die Zuordnung unklar, wird sie offen gelassen und als offen ausgewiesen.',
|
||||
enabled: true,
|
||||
schedule: 'bei jedem verifizierten Signal',
|
||||
requiresApproval: false,
|
||||
dependsOnSystem: AgentSystemType.WORKSPACE,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-06',
|
||||
title: 'Wochen-Mail dienstags um 08:00 Uhr erstellen',
|
||||
description:
|
||||
'Stellt die wichtigsten Signale der vergangenen Woche nach Region und Segment zusammen, jeweils mit Konfidenz und Quellenangabe. Der Entwurf geht als Vorschlag an die Marktbeobachtung und wird erst nach Freigabe an die eingetragenen Empfänger versendet.',
|
||||
enabled: true,
|
||||
schedule: 'dienstags 08:00',
|
||||
requiresApproval: true,
|
||||
dependsOnChannel: AgentChannelType.EMAIL,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-07',
|
||||
title: 'Umzugs- und Expansionssignale als Leads ausweisen',
|
||||
description:
|
||||
'Kennzeichnet Signale zu Umzug und Expansion als Lead, ergänzt Firma, Region, gesuchtes Segment und Zeithorizont und bereitet die Übergabe an das simulierte CRM vor. Übergeben wird erst nach ausdrücklicher Freigabe.',
|
||||
enabled: true,
|
||||
schedule: 'bei Erkennung eines Umzugs- oder Expansionssignals',
|
||||
requiresApproval: true,
|
||||
dependsOnSystem: AgentSystemType.CRM,
|
||||
},
|
||||
{
|
||||
id: 'nora-task-08',
|
||||
title: 'monatliche Markteinschätzung erstellen',
|
||||
description:
|
||||
'Fasst die Signale des Monats je Region und Segment zu einer Einschätzung zusammen: Bewegung im Markt, auffällige Häufungen, offene Reviews und Vergleich zum Vormonat. Der Bericht wird als Vorschlag vorgelegt und nach Freigabe verteilt.',
|
||||
enabled: true,
|
||||
schedule: 'monatlich am 1. um 07:00',
|
||||
requiresApproval: true,
|
||||
dependsOnChannel: AgentChannelType.EMAIL,
|
||||
},
|
||||
],
|
||||
|
||||
channels: [
|
||||
@@ -159,11 +119,11 @@ export const noraAgent: TeamAgent = {
|
||||
type: AgentChannelType.EMAIL,
|
||||
direction: AgentChannelDirection.OUTBOUND,
|
||||
description:
|
||||
'Versand der freigegebenen Wochen-Mail und der monatlichen Markteinschätzung an Marktbeobachtung, Akquisition und Bewirtschaftung. Nora nimmt über diesen Kanal keine Post entgegen.',
|
||||
'Versand von Matching-Ergebnissen an Vermarktung und Bewirtschaftung, wenn ein Abgleich passende Objekte ergeben hat. Nora nimmt über diesen Kanal keine Post entgegen.',
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
enabled: true,
|
||||
config: {
|
||||
displayName: 'Nora — Markt-Scout',
|
||||
displayName: 'Nora — Marktchancen und Leads',
|
||||
senderAddress: 'nora@property-on.ch',
|
||||
defaultRecipients: [
|
||||
'marktbeobachtung@property-on.ch',
|
||||
@@ -203,46 +163,31 @@ export const noraAgent: TeamAgent = {
|
||||
},
|
||||
],
|
||||
|
||||
/*
|
||||
* Runde 10, §3.12: Öffentliche Webquellen, Zefix und SHAB sind zu Livia
|
||||
* gewandert — sie beobachtet den Markt. Nora braucht den Bestand, und dafür
|
||||
* genau einen lesenden Zugang.
|
||||
*/
|
||||
systems: [
|
||||
{
|
||||
id: 'nora-sys-public-web',
|
||||
type: AgentSystemType.PUBLIC_WEB,
|
||||
id: 'nora-sys-erp',
|
||||
type: AgentSystemType.IMMOTOP2,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
title: 'ERP',
|
||||
usage:
|
||||
'Öffentlich zugängliche Firmenwebseiten, Standortmeldungen und Medienberichte aus Zürich, Winterthur, Zug, Basel, Bern und St. Gallen als Ausgangspunkt für Marktsignale.',
|
||||
'Lesender Zugriff auf die interne Objektdatenbasis für den Abgleich von Nachfrageprofilen mit verfügbaren, zukünftig verfügbaren und optional belegten Objekten.',
|
||||
permissionNote:
|
||||
'Nur Leserecht auf frei zugängliche Seiten. Nora meldet sich nirgends an, füllt keine Formulare aus und veröffentlicht nichts.',
|
||||
},
|
||||
{
|
||||
id: 'nora-sys-shab',
|
||||
type: AgentSystemType.SHAB,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Amtliche Meldungen zu Gründungen, Sitzverlegungen, Umfirmierungen und Liquidationen — die verlässlichste Grundlage für Umzugs- und Neugründungssignale.',
|
||||
permissionNote:
|
||||
'Nur Leserecht. Nora liest veröffentlichte Meldungen und übernimmt Meldungsnummer und Datum als Quellenangabe.',
|
||||
},
|
||||
{
|
||||
id: 'nora-sys-zefix',
|
||||
type: AgentSystemType.ZEFIX,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Abgleich von Firmenname, Rechtsform und eingetragenem Sitz, damit ein Signal der richtigen Firma und der richtigen Region zugeordnet wird.',
|
||||
permissionNote:
|
||||
'Nur Leserecht auf den öffentlichen Handelsregisterindex. Es werden keine Einträge erfasst oder verändert.',
|
||||
'Nur lesend. Nora verändert keine Objekt- oder Mietdaten; das Matching-Ergebnis ist eine Auswertung, kein Eintrag.',
|
||||
},
|
||||
{
|
||||
id: 'nora-sys-crm',
|
||||
type: AgentSystemType.CRM,
|
||||
access: AgentAccessLevel.WRITE,
|
||||
access: AgentAccessLevel.READ,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Ablage der freigegebenen Leads aus Umzugs- und Expansionssignalen, damit die Akquisition direkt weiterarbeiten kann.',
|
||||
permissionNote:
|
||||
'Übergibt Leads an ein simuliertes CRM. Geschrieben wird erst nach Freigabe durch die Marktbeobachtung; bestehende Datensätze werden weder überschrieben noch gelöscht.',
|
||||
'Bestehende Interessenten und laufende Vorgänge, um eine bereits bekannte Nachfrage nicht doppelt als neuen Lead zu führen.',
|
||||
permissionNote: 'Nur lesend.',
|
||||
},
|
||||
{
|
||||
id: 'nora-sys-workspace',
|
||||
@@ -250,148 +195,88 @@ export const noraAgent: TeamAgent = {
|
||||
access: AgentAccessLevel.READ_WRITE,
|
||||
status: AgentConnectionStatus.CONNECTED,
|
||||
usage:
|
||||
'Führt die Signalliste, die Review-Queue und die Entwürfe für Wochen-Mail und Markteinschätzung; liest Objekt- und Portfoliostammdaten für die Zuordnung.',
|
||||
'Matching-Ergebnisse und Laufprotokolle im Arbeitsbereich der Vermarktung.',
|
||||
permissionNote:
|
||||
'Lese- und Schreibrecht im eigenen Arbeitsbereich. Nora legt dort Signale, Reviews und Entwürfe ab — der Versand nach aussen bleibt an die Freigabe gebunden.',
|
||||
'Schreibt nur in den eigenen Bereich «Matching»; Objektdossiers bleiben unberührt.',
|
||||
},
|
||||
],
|
||||
|
||||
/*
|
||||
* Runde 10, §3.14: Die Einstellungen beschreiben jetzt das Matching und
|
||||
* sonst nichts.
|
||||
*
|
||||
* Weggefallen sind «Regionen», «Segmente», «Signalarten», «Mindestkonfidenz»
|
||||
* und «Review-Schwelle» — sie steuerten die Marktbeobachtung, und die macht
|
||||
* seit Runde 8 Livia. Ebenso die Lead-Weitergabe ans CRM und der
|
||||
* Wochenversand: Nora übergibt keine Leads mehr, sie gleicht sie ab.
|
||||
*/
|
||||
settings: [
|
||||
{
|
||||
id: 'nora-set-regions',
|
||||
label: 'Regionen',
|
||||
description: 'Nur Meldungen aus diesen Regionen werden als Marktsignal erfasst.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
group: AgentSettingGroup.GENERAL,
|
||||
value: ['ZUERICH', 'WINTERTHUR', 'ZUG', 'BASEL', 'BERN', 'ST_GALLEN'],
|
||||
options: [
|
||||
{ value: 'ZUERICH', label: 'Zürich' },
|
||||
{ value: 'WINTERTHUR', label: 'Winterthur' },
|
||||
{ value: 'ZUG', label: 'Zug' },
|
||||
{ value: 'BASEL', label: 'Basel' },
|
||||
{ value: 'BERN', label: 'Bern' },
|
||||
{ value: 'ST_GALLEN', label: 'St. Gallen' },
|
||||
{ value: 'LUZERN', label: 'Luzern' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nora-set-segments',
|
||||
label: 'Segmente',
|
||||
id: 'nora-set-include-occupied',
|
||||
label: 'Belegte Objekte standardmässig einbeziehen',
|
||||
description:
|
||||
'Nutzungsarten der kommerziell genutzten Immobilien, die Nora beobachtet.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
group: AgentSettingGroup.GENERAL,
|
||||
value: ['BUERO', 'RETAIL', 'GASTRONOMIE', 'LOGISTIK'],
|
||||
options: [
|
||||
{ value: 'BUERO', label: 'Büro' },
|
||||
{ value: 'RETAIL', label: 'Retail' },
|
||||
{ value: 'GASTRONOMIE', label: 'Gastronomie' },
|
||||
{ value: 'LOGISTIK', label: 'Logistik' },
|
||||
{ value: 'GEWERBE', label: 'Gewerbe' },
|
||||
{ value: 'PRODUKTION', label: 'Produktion' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nora-set-signal-types',
|
||||
label: 'Signalarten',
|
||||
description: 'Diese Arten von Marktbewegungen werden erfasst und eingeordnet.',
|
||||
kind: AgentSettingKind.MULTI_SELECT,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: ['UMZUG', 'EXPANSION', 'NEUGRUENDUNG', 'FILIALSCHLIESSUNG'],
|
||||
options: [
|
||||
{ value: 'UMZUG', label: 'Umzug' },
|
||||
{ value: 'EXPANSION', label: 'Expansion' },
|
||||
{ value: 'VERKLEINERUNG', label: 'Verkleinerung' },
|
||||
{ value: 'NEUGRUENDUNG', label: 'Neugründung' },
|
||||
{ value: 'FILIALSCHLIESSUNG', label: 'Filialschliessung' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nora-set-min-confidence',
|
||||
label: 'Mindestkonfidenz',
|
||||
description:
|
||||
'Ab diesem Wert gilt ein Signal als verifiziert und darf in Wochen-Mail und Lead-Liste aufgenommen werden.',
|
||||
kind: AgentSettingKind.NUMBER,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: 70,
|
||||
unit: '%',
|
||||
min: 40,
|
||||
max: 95,
|
||||
},
|
||||
{
|
||||
id: 'nora-set-review-threshold',
|
||||
label: 'Review-Schwelle',
|
||||
description:
|
||||
'Signale unterhalb dieses Werts gehen in die Review-Queue und warten auf Sichtung durch die Marktbeobachtung.',
|
||||
kind: AgentSettingKind.NUMBER,
|
||||
group: AgentSettingGroup.APPROVAL,
|
||||
value: 55,
|
||||
unit: '%',
|
||||
min: 20,
|
||||
max: 90,
|
||||
},
|
||||
{
|
||||
id: 'nora-set-lead-handover',
|
||||
label: 'Lead-Weitergabe aktiv',
|
||||
description:
|
||||
'Legt fest, ob freigegebene Umzugs- und Expansionssignale als Lead an das simulierte CRM übergeben werden.',
|
||||
'Bezieht vermietete Objekte von Anfang an in den Kandidatenkreis ein. Vorgabe ist aus: Ein belegtes Objekt ist kein Angebot, sondern die Frage, ob für eine vermietete Fläche konkurrierende Nachfrage besteht.',
|
||||
kind: AgentSettingKind.BOOLEAN,
|
||||
group: AgentSettingGroup.APPROVAL,
|
||||
value: true,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
id: 'nora-set-weekly-recipients',
|
||||
label: 'Empfänger der Wochen-Mail',
|
||||
id: 'nora-set-min-match',
|
||||
label: 'Mindest-Match für eine Empfehlung',
|
||||
description:
|
||||
'Adressen, die die freigegebene Wochen-Mail erhalten — mehrere Adressen mit Komma trennen.',
|
||||
kind: AgentSettingKind.TEXT,
|
||||
group: AgentSettingGroup.DELIVERY,
|
||||
value:
|
||||
'marktbeobachtung@property-on.ch, akquisition.zuerich@property-on.ch, bewirtschaftung.winterthur@property-on.ch',
|
||||
'Ab diesem Wert gilt ein Objekt als Empfehlung. Objekte darunter bleiben in der Liste sichtbar — sie werden nicht als Treffer hervorgehoben.',
|
||||
kind: AgentSettingKind.NUMBER,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: 60,
|
||||
unit: '%',
|
||||
min: 0,
|
||||
max: 100,
|
||||
infoText:
|
||||
'Der Match-Wert zeigt, wie gut das von Livia erstellte Nachfrageprofil mit den verfügbaren Informationen eines Objekts übereinstimmt. '
|
||||
+ 'Aus dem Suchprofil werden die Anforderungen gelesen, die darin tatsächlich vorkommen — Standort, Fläche, Nutzungsart und, wo das Profil sie nennt, '
|
||||
+ 'Erreichbarkeit, Parkierung, Ausbaustand und Sichtbarkeit. Dazu kommt immer die Verfügbarkeit. '
|
||||
+ 'Fehlende Angaben werden nicht als erfüllt angenommen: Sagt der Bestand zu einem Merkmal nichts, zählt es nicht als Nichterfüllung, '
|
||||
+ 'senkt aber die Abdeckung — und je weniger geprüft werden konnte, desto stärker wird der Wert gedämpft. '
|
||||
+ 'Belegte Objekte sind ausgeschlossen, solange sie nicht ausdrücklich einbezogen werden. '
|
||||
+ 'Es gibt keine feste Prozentgewichtung je Kriterium, weil je Objekt unterschiedlich viel prüfbar ist.',
|
||||
},
|
||||
{
|
||||
id: 'nora-set-send-time',
|
||||
label: 'Versandzeit',
|
||||
description: 'Uhrzeit des wöchentlichen Versands, jeweils dienstags.',
|
||||
kind: AgentSettingKind.TIME,
|
||||
group: AgentSettingGroup.DELIVERY,
|
||||
value: '08:00',
|
||||
id: 'nora-set-max-results',
|
||||
label: 'Höchstzahl angezeigter Objekte',
|
||||
description:
|
||||
'Begrenzt die Trefferliste je Lauf. Abgeglichen wird immer der ganze Kandidatenkreis — die Grenze betrifft nur die Anzeige.',
|
||||
kind: AgentSettingKind.NUMBER,
|
||||
group: AgentSettingGroup.SPECIFIC,
|
||||
value: 25,
|
||||
min: 5,
|
||||
max: 200,
|
||||
},
|
||||
],
|
||||
|
||||
/*
|
||||
* Runde 10, §3.11: Zwei Kennzahlen, beide aus echten Aktionen.
|
||||
*
|
||||
* Die Werte hier sind der Ausgangsstand vor dem ersten Lauf — beide null,
|
||||
* weil noch nichts passiert ist. Die Anzeige ersetzt sie zur Laufzeit durch
|
||||
* die tatsächlichen Zahlen aus den Matching-Läufen und den zugeordneten
|
||||
* Objekten. Eine erfundene Startzahl wäre genau der Fantasiewert, den diese
|
||||
* Kennzahlen nicht zeigen dürfen.
|
||||
*/
|
||||
metrics: [
|
||||
{
|
||||
id: 'nora-metric-weekly-signals',
|
||||
label: 'Signale der Woche',
|
||||
value: '128',
|
||||
hint: 'Erfasste Meldungen aus Quellen und Feeds vom 14.05.2026 bis 20.05.2026',
|
||||
id: 'nora-metric-match-runs',
|
||||
label: 'Anzahl durchgeführte Matchings',
|
||||
value: '0',
|
||||
hint: 'Zählt tatsächlich ausgeführte Matching-Läufe — weder Seitenaufrufe noch die Zahl der Leads.',
|
||||
},
|
||||
{
|
||||
id: 'nora-metric-verified-signals',
|
||||
label: 'Verifizierte Signale',
|
||||
value: '86',
|
||||
hint: 'Konfidenz von mindestens 70 % und mit einer zweiten Quelle bestätigt',
|
||||
},
|
||||
{
|
||||
id: 'nora-metric-open-reviews',
|
||||
label: 'Offene Reviews',
|
||||
value: '19',
|
||||
hint: 'Signale in der Review-Queue, ältester Eintrag seit 3 Tagen offen',
|
||||
},
|
||||
{
|
||||
id: 'nora-metric-generated-leads',
|
||||
label: 'Erzeugte Leads',
|
||||
value: '23',
|
||||
hint: 'Freigegebene Umzugs- und Expansionssignale, letzte Übergabe am 19.05.2026',
|
||||
},
|
||||
{
|
||||
id: 'nora-metric-monitored-regions',
|
||||
label: 'Überwachte Regionen',
|
||||
value: '6',
|
||||
hint: 'Zürich, Winterthur, Zug, Basel, Bern und St. Gallen',
|
||||
id: 'nora-metric-affected-objects',
|
||||
label: 'Anzahl betroffene Objekte',
|
||||
value: '0',
|
||||
hint: 'Eindeutig intern zugeordnete Objekte, die von einem Risikosignal betroffen sind.',
|
||||
},
|
||||
],
|
||||
|
||||
headlineMetricId: 'nora-metric-weekly-signals',
|
||||
headlineMetricId: 'nora-metric-match-runs',
|
||||
lastRun: '2026-05-20T06:00:00.000Z',
|
||||
}
|
||||
@@ -4109,7 +4109,7 @@ export const mockProperties: Property[] = [
|
||||
rentPricePerSqm: 182,
|
||||
totalRentMonthly: 81900,
|
||||
availabilityDate: '2026-01-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'ERP_IMPORT',
|
||||
confidenceScore: 0.92,
|
||||
dataQuality: {
|
||||
@@ -4144,6 +4144,10 @@ export const mockProperties: Property[] = [
|
||||
breakoutOption: false,
|
||||
schattenmarktRelease: { enabled: false },
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Rhein Technik AG',
|
||||
leaseTerm: '10 Jahre',
|
||||
leaseStartDate: '2021-01-01',
|
||||
leaseEndDate: '2031-12-31',
|
||||
createdAt: '2025-05-22T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
@@ -4159,7 +4163,7 @@ export const mockProperties: Property[] = [
|
||||
rentPricePerSqm: 160,
|
||||
totalRentMonthly: 96000,
|
||||
availabilityDate: '2026-07-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'ERP_IMPORT',
|
||||
confidenceScore: 0.91,
|
||||
dataQuality: {
|
||||
@@ -4194,6 +4198,10 @@ export const mockProperties: Property[] = [
|
||||
breakoutOption: false,
|
||||
schattenmarktRelease: { enabled: false },
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Seefeld Consulting AG',
|
||||
leaseTerm: '5 Jahre',
|
||||
leaseStartDate: '2023-07-01',
|
||||
leaseEndDate: '2028-06-30',
|
||||
createdAt: '2025-05-22T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
@@ -4300,7 +4308,7 @@ export const mockProperties: Property[] = [
|
||||
rentPricePerSqm: 312,
|
||||
totalRentMonthly: 51480,
|
||||
availabilityDate: '2026-07-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'ERP_IMPORT',
|
||||
confidenceScore: 0.91,
|
||||
dataQuality: {
|
||||
@@ -4335,6 +4343,10 @@ export const mockProperties: Property[] = [
|
||||
breakoutOption: false,
|
||||
schattenmarktRelease: { enabled: false },
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Lienhard Gastro GmbH',
|
||||
leaseTerm: '9 Jahre',
|
||||
leaseStartDate: '2021-01-01',
|
||||
leaseEndDate: '2030-12-31',
|
||||
createdAt: '2025-05-22T10:00:00Z',
|
||||
updatedAt: '2025-05-22T10:00:00Z',
|
||||
},
|
||||
@@ -4354,7 +4366,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 320,
|
||||
rentPricePerSqm: 380,
|
||||
availabilityDate: '2026-06-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.80,
|
||||
dataQuality: { score: 0.78, missingCriticalFields: [], missingOptionalFields: ['softFactors', 'hardFacts'], lastVerifiedAt: '2026-03-10', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
@@ -4365,6 +4377,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Helle Bürofläche im 3. OG an der Bachmattstrasse, Zürich-Altstetten. 320 m², Basisausbau, Parkplätze im Haus. 8 Minuten zur S-Bahn Altstetten.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Nordwest Handels AG',
|
||||
leaseTerm: '6 Jahre',
|
||||
leaseStartDate: '2023-10-01',
|
||||
leaseEndDate: '2029-09-30',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-02-14T09:30:00Z',
|
||||
updatedAt: '2026-02-14T09:30:00Z',
|
||||
},
|
||||
@@ -4406,7 +4423,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 240,
|
||||
rentPricePerSqm: 295,
|
||||
availabilityDate: '2026-07-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.78,
|
||||
dataQuality: { score: 0.74, missingCriticalFields: [], missingOptionalFields: ['hardFacts'], lastVerifiedAt: '2026-04-20', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
@@ -4417,6 +4434,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Repräsentative Bürofläche im 4. OG am Bollwerk, Bern. 240 m², Vollausbau, Toplage direkt beim Bahnhof Bern (3 Min.). Kein Parkplatz.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Weber Präzision GmbH',
|
||||
leaseTerm: '12 Jahre',
|
||||
leaseStartDate: '2020-10-01',
|
||||
leaseEndDate: '2032-09-30',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-04-02T14:15:00Z',
|
||||
updatedAt: '2026-04-02T14:15:00Z',
|
||||
},
|
||||
@@ -4432,7 +4454,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 185,
|
||||
rentPricePerSqm: 360,
|
||||
availabilityDate: '2026-08-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.72,
|
||||
dataQuality: { score: 0.68, missingCriticalFields: ['contactPerson'], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-02-15', freshness: DataFreshness.STALE, warnings: ['Kein Ansprechpartner hinterlegt'] },
|
||||
@@ -4443,6 +4465,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
description: 'Ladenfläche an der Stadthausstrasse Winterthur, Erdgeschoss, Schaufensterfront. 185 m², Basisausbau. Zentrale Lage, hohe Passantenfrequenz. Derzeit deaktiviert — Verhandlung sistiert.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Alpstein Pharma AG',
|
||||
leaseTerm: '7 Jahre',
|
||||
leaseStartDate: '2022-01-01',
|
||||
leaseEndDate: '2029-12-31',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-01-20T10:00:00Z',
|
||||
updatedAt: '2026-04-15T08:00:00Z',
|
||||
},
|
||||
@@ -4458,7 +4485,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 1200,
|
||||
rentPricePerSqm: 130,
|
||||
availabilityDate: '2026-06-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.88,
|
||||
dataQuality: { score: 0.85, missingCriticalFields: [], missingOptionalFields: ['softFactors'], lastVerifiedAt: '2026-05-01', freshness: DataFreshness.FRESH, warnings: [] },
|
||||
@@ -4469,6 +4496,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.LOW,
|
||||
description: 'Moderne Logistikhalle in Muttenz, 1200 m², 9 m Hallenhöhe, 4 Ladelucken, Stromanschluss 400 kVA. Direkter Autobahnanschluss A2/A3, 20 Aussenparkplätze.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Bürgin Treuhand AG',
|
||||
leaseTerm: '8 Jahre',
|
||||
leaseStartDate: '2022-07-01',
|
||||
leaseEndDate: '2030-06-30',
|
||||
breakoutOption: false,
|
||||
createdAt: '2026-04-28T16:00:00Z',
|
||||
updatedAt: '2026-04-28T16:00:00Z',
|
||||
},
|
||||
@@ -4484,7 +4516,7 @@ export const mockProperties: Property[] = [
|
||||
areaSqm: 450,
|
||||
rentPricePerSqm: 260,
|
||||
availabilityDate: '2026-09-01',
|
||||
availabilityStatus: AvailabilityStatus.AVAILABLE_SOON,
|
||||
availabilityStatus: AvailabilityStatus.OCCUPIED,
|
||||
sourceType: 'DIRECT',
|
||||
confidenceScore: 0.70,
|
||||
dataQuality: { score: 0.65, missingCriticalFields: ['rentPricePerSqm'], missingOptionalFields: ['images', 'description'], lastVerifiedAt: '2026-03-01', freshness: DataFreshness.STALE, warnings: ['Mietpreis nicht verifiziert'] },
|
||||
@@ -4495,6 +4527,11 @@ export const mockProperties: Property[] = [
|
||||
riskLevel: RiskLevel.MEDIUM,
|
||||
description: 'Bürofläche Zürichstrasse 45, Luzern-Littau. 450 m², 1. OG, Basisausbau. 8 Tiefgaragenplätze. Deaktiviert — Preisüberarbeitung ausstehend.',
|
||||
organizationId: 'org-wincasa',
|
||||
currentTenant: 'Helvetia Logistik AG',
|
||||
leaseTerm: '10 Jahre',
|
||||
leaseStartDate: '2021-04-01',
|
||||
leaseEndDate: '2031-03-31',
|
||||
breakoutOption: false,
|
||||
createdAt: '2025-12-10T08:00:00Z',
|
||||
updatedAt: '2026-03-20T12:00:00Z',
|
||||
},
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Box, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { Box, Button, Drawer, Typography, useMediaQuery, useTheme } from '@mui/material'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { AgentWorkspaceHero } from '../../components/team'
|
||||
import {
|
||||
CrmLeadDetail,
|
||||
LeadChannelFilterBar,
|
||||
LeadDetail,
|
||||
PropertyMatchSection,
|
||||
UnifiedLeadList,
|
||||
} from '../../components/market-leads'
|
||||
import { HinweisDetail, HinweisErfassenDialog } from '../../components/markt-hinweise'
|
||||
@@ -47,6 +50,31 @@ export default function MarketIntelligence() {
|
||||
|
||||
const { data: leads, isLoading } = useUnifiedLeads()
|
||||
|
||||
/*
|
||||
* Übergabe aus Livia (Runde 10, §§2.14/3.3).
|
||||
*
|
||||
* `lead` benennt denselben Eintrag aus demselben Bestand — es entsteht keine
|
||||
* Kopie. `matching=1` sagt, dass der Abgleich sofort laufen darf; ohne das
|
||||
* Kennzeichen passiert beim Öffnen nichts von selbst.
|
||||
*
|
||||
* Beides steht in der Adresse und nicht im Navigationszustand, damit die
|
||||
* Übergabe ein Neuladen überlebt und der Link weitergegeben werden kann.
|
||||
*/
|
||||
const [params, setParams] = useSearchParams()
|
||||
const uebergebeneId = params.get('lead')
|
||||
const autoMatching = params.get('matching') === '1'
|
||||
|
||||
/** Ein KI-Lead, der auf ganzer Seite gezeigt wird — Schublade nur für die anderen Kanäle. */
|
||||
const seitenLead = useMemo(() => {
|
||||
if (!uebergebeneId) return null
|
||||
const treffer = leads.find(l => l.id === uebergebeneId)
|
||||
return treffer?.channel === LeadChannel.KI_SIGNAL ? treffer : null
|
||||
}, [uebergebeneId, leads])
|
||||
|
||||
const zurueckZurListe = useCallback(() => {
|
||||
setParams({}, { replace: true })
|
||||
}, [setParams])
|
||||
|
||||
// Zählung und Filterung in einem Durchgang über dieselbe Liste (CLAUDE.md §10.4).
|
||||
const { visible, counts } = useMemo(() => {
|
||||
const tally: Record<LeadChannelFilter, number> = {
|
||||
@@ -65,6 +93,27 @@ export default function MarketIntelligence() {
|
||||
const resetFilter = useCallback(() => setChannel(LEAD_CHANNEL_ALL), [])
|
||||
const closeDetail = useCallback(() => setSelected(null), [])
|
||||
|
||||
/**
|
||||
* KI-Leads öffnen auf ganzer Seite, die übrigen Kanäle in der Schublade.
|
||||
*
|
||||
* Der Unterschied ist nicht Geschmack: Nur beim KI-Lead hängt das
|
||||
* Property-Matching daran, und das braucht die Breite. Ein Netzwerk-Hinweis
|
||||
* ist in zehn Zeilen erzählt.
|
||||
*
|
||||
* Ausdrücklich **ohne** `matching=1` — ein normal geöffneter Lead löst
|
||||
* keinen Lauf aus (§3.3).
|
||||
*/
|
||||
const handleSelect = useCallback(
|
||||
(lead: UnifiedLead) => {
|
||||
if (lead.channel === LeadChannel.KI_SIGNAL) {
|
||||
setParams({ lead: lead.id }, { replace: false })
|
||||
return
|
||||
}
|
||||
setSelected(lead)
|
||||
},
|
||||
[setParams],
|
||||
)
|
||||
|
||||
// Der neue Hinweis soll sofort auffindbar sein — auch wenn gerade nach einem
|
||||
// anderen Kanal gefiltert wird.
|
||||
const handleCreated = useCallback(() => {
|
||||
@@ -79,6 +128,32 @@ export default function MarketIntelligence() {
|
||||
Er scrollt jetzt mit und wechselt dabei in den sticky Zustand (§11). */}
|
||||
<AgentWorkspaceHero agentId={NORA.id} name={NORA.name} role={NORA.role} />
|
||||
|
||||
{/*
|
||||
Detailseite statt Schublade (Runde 10, §3.2).
|
||||
Sie tritt an die Stelle der Liste, statt daneben zu stehen: Das
|
||||
Property-Matching braucht die volle Breite, und zwei Bereiche
|
||||
nebeneinander hätten beide zu wenig.
|
||||
*/}
|
||||
{seitenLead ? (
|
||||
<Box sx={{ px: 3, py: 2.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ArrowLeft size={14} aria-hidden />}
|
||||
onClick={zurueckZurListe}
|
||||
sx={{ textTransform: 'none', mb: 1 }}
|
||||
>
|
||||
Zurück zur Leadliste
|
||||
</Button>
|
||||
|
||||
<LeadDetail
|
||||
lead={{ signal: seitenLead.signal, matchingProperties: seitenLead.matchingProperties }}
|
||||
showMatching={false}
|
||||
fullPage
|
||||
footer={<PropertyMatchSection signal={seitenLead.signal} autoStart={autoMatching} />}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<LeadChannelFilterBar
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
@@ -97,10 +172,12 @@ export default function MarketIntelligence() {
|
||||
leads={visible}
|
||||
isLoading={isLoading}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={setSelected}
|
||||
onSelect={handleSelect}
|
||||
onResetFilter={channel === LEAD_CHANNEL_ALL ? undefined : resetFilter}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Detailnavigation: pro Kanal die passende Ansicht, in einer Schublade. */}
|
||||
<Drawer
|
||||
@@ -116,9 +193,8 @@ export default function MarketIntelligence() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selected?.channel === LeadChannel.KI_SIGNAL && (
|
||||
<LeadDetail lead={{ signal: selected.signal, matchingProperties: selected.matchingProperties }} />
|
||||
)}
|
||||
{/* KI-Leads öffnen seit Runde 10 auf ganzer Seite — hier bleiben die
|
||||
beiden Kanäle, die ohne Matching auskommen (§3.2). */}
|
||||
{selected?.channel === LeadChannel.NETZWERK && <HinweisDetail hinweis={selected.hinweis} />}
|
||||
{selected?.channel === LeadChannel.CRM && <CrmLeadDetail lead={selected.crmLead} />}
|
||||
</Drawer>
|
||||
|
||||
@@ -17,4 +17,9 @@ export interface IFutureSignalProvider {
|
||||
getByProperty(propertyId: string): Promise<FutureSignal[]>
|
||||
verify(id: string, verifiedBy: string): Promise<FutureSignal>
|
||||
updateReviewStatus(id: string, status: ReviewStatus): Promise<FutureSignal>
|
||||
/**
|
||||
* Leads eines Recherchelaufs in den Bestand aufnehmen (Runde 10, §2.12).
|
||||
* Gibt zurück, wie viele erzeugte Leads danach insgesamt geführt werden.
|
||||
*/
|
||||
addResearchSignals(signals: FutureSignal[]): Promise<number>
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MatchRun } from '../domain/matchRun'
|
||||
|
||||
/** Ablage der durchgeführten Matching-Läufe (Runde 10, §§3.11/3.13). */
|
||||
export interface IMatchRunProvider {
|
||||
getAll(): Promise<MatchRun[]>
|
||||
add(run: MatchRun): Promise<MatchRun>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
|
||||
/**
|
||||
* Ablage der manuell hinterlegten Newsquellen (Runde 10, §2.6).
|
||||
*
|
||||
* Eine eigene Schnittstelle, obwohl die übrigen Demo-Bestände im Local Storage
|
||||
* liegen: Dokumente sind um Grössenordnungen grösser als eine Konfiguration,
|
||||
* und der Local Storage ist bei rund fünf Megabyte zu Ende. Eine PDF-Ablage
|
||||
* dort hinein zu zwingen hiesse, beim dritten Dokument stillschweigend nichts
|
||||
* mehr zu speichern.
|
||||
*/
|
||||
export interface IResearchDocumentProvider {
|
||||
getAll(): Promise<ResearchDocument[]>
|
||||
add(doc: ResearchDocument, blob: Blob): Promise<void>
|
||||
remove(id: string): Promise<void>
|
||||
/** Die Originaldatei — für das Herunterladen aus der Liste. */
|
||||
getBlob(id: string): Promise<Blob | null>
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Property On — die abgelegten Dokumente liegen in IndexedDB (Runde 10, §2.6).
|
||||
*
|
||||
* Keine Mockup-Variante und kein zweiter Backend-Dienst: Die Anwendung läuft
|
||||
* im Browser, und der Browser hat eine Ablage, die auch Dateien verträgt. Ein
|
||||
* Server nur für die Dateihaltung wäre Infrastruktur für einen einzigen Zweck.
|
||||
*
|
||||
* Aufgeteilt in zwei Speicher: die Metadaten samt extrahiertem Text in `meta`,
|
||||
* die Originaldatei in `blobs`. So kann die Liste aufgebaut werden, ohne dass
|
||||
* zwanzig Megabyte PDF durch den Speicher wandern — gebraucht wird die Datei
|
||||
* erst, wenn jemand sie herunterlädt.
|
||||
*/
|
||||
|
||||
import type { IResearchDocumentProvider } from './IResearchDocumentProvider'
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
|
||||
const DB_NAME = 'property-on'
|
||||
const DB_VERSION = 1
|
||||
const STORE_META = 'research-document-meta'
|
||||
const STORE_BLOBS = 'research-document-blobs'
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_META)) db.createObjectStore(STORE_META, { keyPath: 'id' })
|
||||
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB liess sich nicht öffnen.'))
|
||||
})
|
||||
}
|
||||
|
||||
/** Eine Transaktion als Versprechen — IndexedDB spricht sonst nur in Ereignissen. */
|
||||
function alsVersprechen<T>(
|
||||
req: IDBRequest<T>,
|
||||
tx: IDBTransaction,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('Zugriff auf die Dokumentenablage fehlgeschlagen.'))
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Die Dokumentenablage brach die Transaktion ab.'))
|
||||
})
|
||||
}
|
||||
|
||||
export const IndexedDbResearchDocumentProvider: IResearchDocumentProvider = {
|
||||
async getAll() {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction(STORE_META, 'readonly')
|
||||
const alle = await alsVersprechen(tx.objectStore(STORE_META).getAll() as IDBRequest<ResearchDocument[]>, tx)
|
||||
db.close()
|
||||
// Neuestes zuerst — wer eben etwas abgelegt hat, sucht es oben.
|
||||
return alle.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||
},
|
||||
|
||||
async add(doc, blob) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
|
||||
tx.objectStore(STORE_META).put(doc)
|
||||
await alsVersprechen(tx.objectStore(STORE_BLOBS).put(blob, doc.id), tx)
|
||||
db.close()
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
|
||||
tx.objectStore(STORE_META).delete(id)
|
||||
await alsVersprechen(tx.objectStore(STORE_BLOBS).delete(id), tx)
|
||||
db.close()
|
||||
},
|
||||
|
||||
async getBlob(id) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction(STORE_BLOBS, 'readonly')
|
||||
const blob = await alsVersprechen(tx.objectStore(STORE_BLOBS).get(id) as IDBRequest<Blob | undefined>, tx)
|
||||
db.close()
|
||||
return blob ?? null
|
||||
},
|
||||
}
|
||||
@@ -2,14 +2,27 @@ import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignal
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import { mockFutureSignals } from '../mock-data/futureSignals'
|
||||
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
|
||||
|
||||
// Mutation overrides — applied on top of the live mockFutureSignals import.
|
||||
// Reading directly from mockFutureSignals (not a copied store) ensures that
|
||||
// Vite HMR changes to the mock data are always reflected immediately.
|
||||
const mutationOverrides = new Map<string, Partial<FutureSignal>>()
|
||||
|
||||
/**
|
||||
* Leads aus Rechercheläufen (Runde 10, §2.12).
|
||||
*
|
||||
* Sie liegen in derselben Ablage wie der Seed und werden von `resolve()` in
|
||||
* dieselbe Liste gegeben — es gibt bewusst keinen zweiten Bestand, den Nora
|
||||
* separat abfragen müsste. Getrennt ist nur die Persistenz: der Seed steht im
|
||||
* Code, die erzeugten Leads im Local Storage, damit ein Reload sie behält.
|
||||
*/
|
||||
let researchSignals: FutureSignal[] =
|
||||
loadVersioned<FutureSignal[]>(TEAM_STORAGE_KEYS.RESEARCH_LEADS) ?? []
|
||||
|
||||
function resolve(): FutureSignal[] {
|
||||
return mockFutureSignals.map(s => {
|
||||
// Erzeugte Leads zuerst: der jüngste Lauf steht oben, wo man ihn sucht.
|
||||
return [...researchSignals, ...mockFutureSignals].map(s => {
|
||||
const override = mutationOverrides.get(s.id)
|
||||
return override ? { ...s, ...override } : s
|
||||
})
|
||||
@@ -43,4 +56,20 @@ export const MockupFutureSignalProvider: IFutureSignalProvider = {
|
||||
mutationOverrides.set(id, { ...existing, reviewStatus: status, updatedAt: new Date().toISOString() })
|
||||
return resolve().find(s => s.id === id)!
|
||||
},
|
||||
|
||||
/**
|
||||
* Leads eines Recherchelaufs aufnehmen.
|
||||
*
|
||||
* Gleiche Kennung heisst gleicher Sachverhalt: Ein zweiter Lauf über
|
||||
* denselben Artikel ersetzt den bestehenden Eintrag, statt ihn zu
|
||||
* verdoppeln. Sonst wüchse die Liste bei jedem Knopfdruck um Dubletten, und
|
||||
* niemand könnte mehr sagen, welcher Stand gilt.
|
||||
*/
|
||||
async addResearchSignals(signals: FutureSignal[]) {
|
||||
const bekannt = new Map(researchSignals.map(s => [s.id, s]))
|
||||
for (const s of signals) bekannt.set(s.id, s)
|
||||
researchSignals = [...bekannt.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
persistVersioned(TEAM_STORAGE_KEYS.RESEARCH_LEADS, researchSignals)
|
||||
return researchSignals.length
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Property On — die Matching-Läufe überleben einen Reload (Runde 10, §3.11).
|
||||
*
|
||||
* Kein Seed: Ein vorab erfundener Lauf wäre genau der Fantasiewert, den die
|
||||
* Kennzahl nicht zeigen darf. Die Liste beginnt leer und füllt sich nur durch
|
||||
* tatsächliche Läufe.
|
||||
*/
|
||||
|
||||
import type { IMatchRunProvider } from './IMatchRunProvider'
|
||||
import type { MatchRun } from '../domain/matchRun'
|
||||
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
|
||||
|
||||
let store: MatchRun[] = loadVersioned<MatchRun[]>(TEAM_STORAGE_KEYS.MATCH_RUNS) ?? []
|
||||
|
||||
export const MockupMatchRunProvider: IMatchRunProvider = {
|
||||
async getAll() {
|
||||
return [...store]
|
||||
},
|
||||
|
||||
async add(run) {
|
||||
// Neuestes zuerst — das Protokoll liest man von oben.
|
||||
store = [run, ...store]
|
||||
persistVersioned(TEAM_STORAGE_KEYS.MATCH_RUNS, store)
|
||||
return run
|
||||
},
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export const MockupTeamAgentProvider: ITeamAgentProvider = {
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.tasks ? { tasks: data.tasks } : {}),
|
||||
...(data.channels ? { channels: data.channels } : {}),
|
||||
...(data.systems ? { systems: data.systems } : {}),
|
||||
...(data.settings ? { settings: data.settings } : {}),
|
||||
}
|
||||
save()
|
||||
|
||||
@@ -17,6 +17,12 @@ export const TEAM_STORAGE_KEYS = {
|
||||
WORK_ITEMS: `${PREFIX}work-items`,
|
||||
PROTOCOL: `${PREFIX}protocol`,
|
||||
CONNECTIONS: `${PREFIX}connections`,
|
||||
/** Von einem Recherchelauf erzeugte Leads (Runde 10, §2.12). */
|
||||
RESEARCH_LEADS: `${PREFIX}research-leads`,
|
||||
/** Durchgeführte Matching-Läufe (Runde 10, §3.11). */
|
||||
MATCH_RUNS: `${PREFIX}match-runs`,
|
||||
/** Manuell abgelegte Newsquellen als Dokumente (Runde 10, §2.6). */
|
||||
RESEARCH_DOCUMENTS: `${PREFIX}research-documents`,
|
||||
} as const
|
||||
|
||||
export type TeamStorageKey = typeof TEAM_STORAGE_KEYS[keyof typeof TEAM_STORAGE_KEYS]
|
||||
@@ -51,7 +57,7 @@ export function persist<T>(key: TeamStorageKey, value: T): void {
|
||||
* unter `src/domain/` ändert — sonst überlagert ein alter Local-Storage-Eintrag
|
||||
* die neuen Mockdaten und die Demo zeigt stillschweigend veraltete Inhalte.
|
||||
*/
|
||||
export const TEAM_SCHEMA_VERSION = 1
|
||||
export const TEAM_SCHEMA_VERSION = 2
|
||||
|
||||
interface PersistedEnvelope<T> {
|
||||
version: number
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { AgentWorkItem } from '../domain/agentWorkItem'
|
||||
import { AgentWorkItemStatus } from '../domain/agentWorkItem'
|
||||
import type { TeamAgent } from '../domain/teamAgent'
|
||||
import { mockToday } from '../lib/constants'
|
||||
|
||||
/**
|
||||
* Property On — die Antworten der Agentenchats (Runde 9, §3).
|
||||
*
|
||||
* Bewusst eine Schlüsselwortlogik und kein Sprachmodell: Der Auftrag verlangt
|
||||
* ausdrücklich keine LLM- oder RAG-Infrastruktur, und für die drei Fragetypen
|
||||
* wäre sie auch nicht der Punkt. Der Punkt ist, dass die Zahlen stimmen.
|
||||
*
|
||||
* Jede Antwort stammt aus Beständen, die auf der Seite des jeweiligen Agenten
|
||||
* ohnehin sichtbar sind. Nichts wird geschätzt, nichts hochgerechnet: Findet
|
||||
* sich keine Grundlage, sagt der Agent das, statt eine plausible Zahl zu
|
||||
* nennen. Eine erfundene Zahl im Chat ist schlimmer als keine Antwort — sie
|
||||
* sieht aus wie eine Auskunft.
|
||||
*/
|
||||
|
||||
/** Was gefragt wurde. */
|
||||
export const ChatIntent = {
|
||||
/** «Womit kannst du mir helfen?» */
|
||||
CAPABILITIES: 'CAPABILITIES',
|
||||
/** «Wie viele Aufgaben sind in deinem Log?» */
|
||||
LOG_COUNT: 'LOG_COUNT',
|
||||
/** «Wie viele Aufträge wurden letzten Monat bearbeitet?» */
|
||||
LAST_MONTH: 'LAST_MONTH',
|
||||
/** Nichts davon erkannt. */
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const
|
||||
export type ChatIntent = typeof ChatIntent[keyof typeof ChatIntent]
|
||||
|
||||
/**
|
||||
* Erkennung per Schlüsselwörtern.
|
||||
*
|
||||
* Die Reihenfolge entscheidet: «Wie viele Aufträge wurden letzten Monat
|
||||
* bearbeitet?» enthält auch das Wort «Aufträge» und würde sonst als Frage nach
|
||||
* dem Log gelesen. Der Zeitbezug ist die speziellere Aussage und wird deshalb
|
||||
* zuerst geprüft.
|
||||
*/
|
||||
export function detectIntent(text: string): ChatIntent {
|
||||
const t = text.toLowerCase()
|
||||
|
||||
const zeitbezug = /letzte[nrs]?\s+(monat|30\s*tage)|vormonat|vergangenen\s+monat/.test(t)
|
||||
const menge = /wie\s*viel|anzahl|wie\s*oft/.test(t)
|
||||
|
||||
if (zeitbezug && (menge || /auftr|fäll|falle|vorgäng|bearbeit|erledigt|task|aufgab/.test(t))) {
|
||||
return ChatIntent.LAST_MONTH
|
||||
}
|
||||
if (menge && /log|aufgab|task|auftr|vorgäng|eintr|fäll|falle|pendent|offen/.test(t)) {
|
||||
return ChatIntent.LOG_COUNT
|
||||
}
|
||||
if (/womit|was\s+kannst|wobei|wofür|hilf|helfen|kannst du|zuständig|aufgabe(n)?\s*bereich|rolle/.test(t)) {
|
||||
return ChatIntent.CAPABILITIES
|
||||
}
|
||||
return ChatIntent.UNKNOWN
|
||||
}
|
||||
|
||||
export interface AgentChatInput {
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentRole: string
|
||||
/** Personaldossier des Agenten, sofern vorhanden — Thomas hat keines. */
|
||||
dossier?: TeamAgent
|
||||
/**
|
||||
* Vorgänge aus dem Bearbeitungsverlauf. Für einen einzelnen Agenten seine
|
||||
* eigenen; für Thomas als Vorgesetzten die aller Agenten.
|
||||
*/
|
||||
workItems: AgentWorkItem[]
|
||||
/** Sind die Bestände schon geladen? Sonst wird nicht gezählt, sondern gewartet. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
/** Kalendermonat vor dem Stichtag der Mockdaten. */
|
||||
function letzterMonat(): { von: Date; bis: Date; name: string } {
|
||||
const heute = mockToday()
|
||||
const von = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth() - 1, 1))
|
||||
const bis = new Date(Date.UTC(heute.getUTCFullYear(), heute.getUTCMonth(), 1))
|
||||
return {
|
||||
von,
|
||||
bis,
|
||||
name: von.toLocaleDateString('de-CH', { month: 'long', year: 'numeric', timeZone: 'UTC' }),
|
||||
}
|
||||
}
|
||||
|
||||
function plural(n: number, ein: string, viele: string): string {
|
||||
return `${n} ${n === 1 ? ein : viele}`
|
||||
}
|
||||
|
||||
function antwortFaehigkeiten(input: AgentChatInput): string {
|
||||
const { agentName, agentRole, dossier } = input
|
||||
|
||||
// Der Kurzbeschrieb aus dem Personalblatt ist genau der Satz, der auch in der
|
||||
// Personalverwaltung steht — der Chat soll nichts anderes behaupten als das
|
||||
// Dossier.
|
||||
if (dossier) {
|
||||
const aufgaben = dossier.responsibilities.slice(0, 3)
|
||||
return [
|
||||
`Ich bin ${agentName}, ${agentRole}. ${dossier.shortDescription}`,
|
||||
aufgaben.length > 0
|
||||
? `Konkret kümmere ich mich unter anderem um: ${aufgaben.join('; ')}.`
|
||||
: '',
|
||||
'Frag mich zum Beispiel, wie viele Aufgaben in meinem Log stehen oder wie viele Aufträge ich letzten Monat bearbeitet habe.',
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
return [
|
||||
`Ich bin ${agentName}, ${agentRole}. Ich führe die fünf digitalen Mitarbeitenden und habe ihren gesamten Bearbeitungsverlauf im Blick.`,
|
||||
'Frag mich zum Beispiel, wie viele Vorgänge im Log stehen oder wie viele letzten Monat bearbeitet wurden.',
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
function antwortLog(input: AgentChatInput): string {
|
||||
const { workItems, agentId } = input
|
||||
const gesamt = workItems.length
|
||||
|
||||
if (gesamt === 0) {
|
||||
return 'In meinem Log steht derzeit kein Vorgang. Sobald einer eingeht, siehst du ihn im Bearbeitungsverlauf.'
|
||||
}
|
||||
|
||||
const erledigt = workItems.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length
|
||||
const pendent = workItems.filter(w => w.requiresDecision).length
|
||||
const wessen = agentId === 'thomas' ? 'Im Log aller Agenten stehen' : 'In meinem Log stehen'
|
||||
|
||||
return [
|
||||
`${wessen} ${plural(gesamt, 'Vorgang', 'Vorgänge')}.`,
|
||||
`Davon ${plural(erledigt, 'erledigt', 'erledigt')}${pendent > 0 ? ` und ${plural(pendent, 'wartet auf deine Entscheidung', 'warten auf deine Entscheidung')}` : ''}.`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function antwortLetzterMonat(input: AgentChatInput): string {
|
||||
const { workItems, agentId } = input
|
||||
const { von, bis, name } = letzterMonat()
|
||||
|
||||
const imZeitraum = workItems.filter(w => {
|
||||
const stempel = w.completedAt ?? w.createdAt
|
||||
const t = new Date(stempel).getTime()
|
||||
return Number.isFinite(t) && t >= von.getTime() && t < bis.getTime()
|
||||
})
|
||||
|
||||
const wer = agentId === 'thomas' ? 'Die Agenten haben' : 'Ich habe'
|
||||
|
||||
if (imZeitraum.length === 0) {
|
||||
return [
|
||||
`Im ${name} ist in meinem Bestand kein Vorgang verzeichnet.`,
|
||||
`Insgesamt liegen ${plural(workItems.length, 'Vorgang', 'Vorgänge')} im Log — die verteilen sich auf andere Monate.`,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
const erledigt = imZeitraum.filter(w => w.status === AgentWorkItemStatus.COMPLETED).length
|
||||
return `${wer} im ${name} ${plural(imZeitraum.length, 'Vorgang', 'Vorgänge')} bearbeitet, davon ${erledigt} abgeschlossen.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Antwort auf eine Chatfrage — oder eine ehrliche Auskunft darüber, dass
|
||||
* die Frage nicht verstanden wurde.
|
||||
*/
|
||||
export function answerAgentQuestion(input: AgentChatInput, frage: string): string {
|
||||
if (input.loading) {
|
||||
return 'Einen Moment — ich hole gerade meine Daten.'
|
||||
}
|
||||
|
||||
switch (detectIntent(frage)) {
|
||||
case ChatIntent.CAPABILITIES:
|
||||
return antwortFaehigkeiten(input)
|
||||
case ChatIntent.LOG_COUNT:
|
||||
return antwortLog(input)
|
||||
case ChatIntent.LAST_MONTH:
|
||||
return antwortLetzterMonat(input)
|
||||
default:
|
||||
return [
|
||||
'Das habe ich nicht verstanden. In dieser Demo beantworte ich drei Arten von Fragen:',
|
||||
'· Womit kannst du mir helfen?',
|
||||
'· Wie viele Aufgaben sind in deinem Log?',
|
||||
'· Wie viele Aufträge wurden letzten Monat bearbeitet?',
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { ReviewStatus } from '../domain/enums'
|
||||
import type { FutureSignalSummary } from '../domain/dashboard'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
import { researchLeadToSignal } from '../lib/researchLeadMapper'
|
||||
import type { ResearchLead } from '../domain/researchLead'
|
||||
|
||||
const provider = MockupFutureSignalProvider
|
||||
|
||||
@@ -50,6 +52,28 @@ export const futureSignalService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Die Leads eines Recherchelaufs in den zentralen Bestand übernehmen
|
||||
* (Runde 10, §2.12).
|
||||
*
|
||||
* Die Umwandlung von Recherche-Lead zu Signal passiert bewusst hier und
|
||||
* nicht in der Komponente: Sie ist fachliche Logik, und sie muss für jeden
|
||||
* künftigen Auslöser eines Laufs — Knopfdruck, Zeitplan, Chat — dieselbe
|
||||
* sein.
|
||||
*/
|
||||
async importResearchLeads(
|
||||
leads: ResearchLead[],
|
||||
refreshedAt: string,
|
||||
): Promise<ItemResponse<{ imported: number; total: number }>> {
|
||||
try {
|
||||
const signals = leads.map(l => researchLeadToSignal(l, refreshedAt))
|
||||
const total = await provider.addResearchSignals(signals)
|
||||
return { data: { imported: signals.length, total } }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async getSignalsForProperty(propertyId: string): Promise<ListResponse<FutureSignal>> {
|
||||
try {
|
||||
const data = await provider.getByProperty(propertyId)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Property On — Matching ausführen und festhalten (Runde 10, §§3.7/3.13).
|
||||
*
|
||||
* Der Dienst tut beides in einem Schritt: rechnen und protokollieren. Das ist
|
||||
* Absicht — ein Lauf ohne Eintrag wäre eine Zahl ohne Beleg, und ein Eintrag
|
||||
* ohne Lauf wäre ein Beleg ohne Zahl. Wer das Matching auslöst, bekommt
|
||||
* zwangsläufig beides.
|
||||
*/
|
||||
|
||||
import { MockupMatchRunProvider } from '../provider/MockupMatchRunProvider'
|
||||
import { agentProtocolService } from './agentProtocolService'
|
||||
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
|
||||
import { fuehreMatchingAus } from '../features/matching/leadPropertyMatcher'
|
||||
import type { MatchLaufErgebnis, MatchLaufOptionen } from '../features/matching/leadPropertyMatcher'
|
||||
import type { MatchRun } from '../domain/matchRun'
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import type { Property } from '../domain/property'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
import { throwServiceError } from './errors'
|
||||
|
||||
const provider = MockupMatchRunProvider
|
||||
|
||||
export interface RunMatchingInput {
|
||||
signal: FutureSignal
|
||||
properties: Property[]
|
||||
options: MatchLaufOptionen
|
||||
trigger: MatchRun['trigger']
|
||||
}
|
||||
|
||||
export const matchRunService = {
|
||||
async getAll(): Promise<ListResponse<MatchRun>> {
|
||||
try {
|
||||
const data = await provider.getAll()
|
||||
return { data, meta: { total: data.length, page: 1, pageSize: data.length, hasMore: false } }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
|
||||
async run(input: RunMatchingInput): Promise<ItemResponse<{ ergebnis: MatchLaufErgebnis; run: MatchRun }>> {
|
||||
try {
|
||||
const ergebnis = fuehreMatchingAus(input.signal, input.properties, input.options)
|
||||
const jetzt = new Date().toISOString()
|
||||
|
||||
const run: MatchRun = {
|
||||
id: `match-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
leadId: input.signal.id,
|
||||
leadTitle: input.signal.companyName ?? input.signal.title ?? input.signal.locationHint,
|
||||
timestamp: jetzt,
|
||||
candidateCount: ergebnis.candidateCount,
|
||||
includedOccupied: input.options.includeOccupied,
|
||||
topScore: ergebnis.matches[0]?.score ?? 0,
|
||||
trigger: input.trigger,
|
||||
}
|
||||
await provider.add(run)
|
||||
|
||||
await agentProtocolService.logUserAction({
|
||||
agentId: 'nora',
|
||||
eventType: AgentProtocolEventType.TASK_RUN,
|
||||
title: 'Matching ausgeführt',
|
||||
description:
|
||||
`Lead «${run.leadTitle}» gegen ${run.candidateCount} ${run.candidateCount === 1 ? 'Objekt' : 'Objekte'} abgeglichen` +
|
||||
`${run.includedOccupied ? ', belegte eingeschlossen' : ''}` +
|
||||
` · bester Wert ${run.topScore} %`,
|
||||
status: AgentProtocolStatus.SUCCESS,
|
||||
})
|
||||
|
||||
return { data: { ergebnis, run } }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MockupTeamAgentProvider } from '../provider/MockupTeamAgentProvider'
|
||||
import type { TeamAgent, AgentTask, AgentChannel, AgentSetting } from '../domain/teamAgent'
|
||||
import type { TeamAgent, AgentTask, AgentChannel, AgentSetting, AgentSystem } from '../domain/teamAgent'
|
||||
import { AgentStatus } from '../domain/teamAgent'
|
||||
import { AgentProtocolEventType, AgentProtocolStatus } from '../domain/agentProtocol'
|
||||
import type { ListResponse, ItemResponse } from './types'
|
||||
@@ -95,4 +95,13 @@ export const teamAgentService = {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
/** Systeme speichern — Runde 10, §2: gleiche Mechanik wie Aufgaben und Kanäle. */
|
||||
async saveSystems(id: string, systems: AgentSystem[]): Promise<ItemResponse<TeamAgent>> {
|
||||
try {
|
||||
const data = await provider.update(id, { systems })
|
||||
return { data }
|
||||
} catch (err) {
|
||||
throwServiceError(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
/** Eine Zeile im Chatverlauf. */
|
||||
export interface AgentChatMessage {
|
||||
from: 'user' | 'agent'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface AgentChatSubject {
|
||||
id: string
|
||||
name: string
|
||||
@@ -19,12 +25,21 @@ interface AgentChatState {
|
||||
* Scrollen mitten im Tippen den Fokus (§9).
|
||||
*/
|
||||
focused: boolean
|
||||
/**
|
||||
* Der Verlauf des laufenden Gesprächs (Runde 9, §3).
|
||||
*
|
||||
* Bewusst nicht gespeichert: Er lebt, solange die Seite offen ist, und ist
|
||||
* beim nächsten Besuch leer. Ein Verlauf, der Tage überdauert, weckte die
|
||||
* Erwartung, der Agent erinnere sich — und das täte er nicht.
|
||||
*/
|
||||
messages: AgentChatMessage[]
|
||||
|
||||
openFor: (agent: AgentChatSubject) => void
|
||||
close: () => void
|
||||
setDraft: (draft: string) => void
|
||||
setCompact: (compact: boolean) => void
|
||||
setFocused: (focused: boolean) => void
|
||||
append: (message: AgentChatMessage) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,16 +59,19 @@ export const useAgentChatStore = create<AgentChatState>((set) => ({
|
||||
draft: '',
|
||||
compact: false,
|
||||
focused: false,
|
||||
messages: [],
|
||||
|
||||
// Ein Agentenwechsel setzt den Entwurf zurück: Text für Nora gehört nicht in
|
||||
// Ferdis Eingabefeld. Dieselbe Seite erneut zu betreten erhält ihn dagegen.
|
||||
// Ein Agentenwechsel setzt Entwurf und Verlauf zurück: Text für Nora gehört
|
||||
// nicht in Ferdis Eingabefeld, und ihre Antwort erst recht nicht in seinen
|
||||
// Verlauf. Dieselbe Seite erneut zu betreten erhält beides dagegen.
|
||||
openFor: (agent) =>
|
||||
set(s => (s.agent?.id === agent.id
|
||||
? { agent }
|
||||
: { agent, draft: '', compact: false, focused: false })),
|
||||
: { agent, draft: '', compact: false, focused: false, messages: [] })),
|
||||
|
||||
close: () => set({ agent: null, compact: false, focused: false }),
|
||||
setDraft: (draft) => set({ draft }),
|
||||
setCompact: (compact) => set({ compact }),
|
||||
setFocused: (focused) => set({ focused }),
|
||||
append: (message) => set(s => ({ messages: [...s.messages, message] })),
|
||||
}))
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.api.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
/*
|
||||
* Die serverlosen Funktionen unter `api/` teilen Typen mit dem Frontend —
|
||||
* deshalb sind die geteilten Dateien aus `src/` mitaufgeführt. Ohne dieses
|
||||
* eigene Projekt liefe `tsc -b` an `api/` vorbei, und ein Tippfehler im
|
||||
* Endpoint fiele erst beim Deployment auf.
|
||||
*/
|
||||
"include": ["api", "src/domain/researchLead.ts", "src/lib/researchSources.ts"]
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.api.json" }
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,10 @@
|
||||
"buildCommand": "npx vite build",
|
||||
"outputDirectory": "dist",
|
||||
"framework": null,
|
||||
"functions": {
|
||||
"api/**/*.ts": {
|
||||
"maxDuration": 60
|
||||
}
|
||||
},
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||
}
|
||||
@@ -1,11 +1,46 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
/**
|
||||
* Livias Recherche-Endpoint in der lokalen Entwicklung.
|
||||
*
|
||||
* In der Produktion liefert Vercel `api/livia/research/refresh.ts` als
|
||||
* serverlose Funktion aus. `vite` kennt keine Serverfunktionen — ohne diesen
|
||||
* Zugang liefe der Aufruf in den `/api`-Proxy darunter und käme als HTML
|
||||
* zurück. Das Plugin lädt dieselbe Datei über Vites SSR-Lader und ruft sie mit
|
||||
* demselben `(req, res)` auf, das auch Vercel übergibt; es gibt also keine
|
||||
* zweite Fassung des Endpoints, die auseinanderlaufen könnte.
|
||||
*
|
||||
* Nur für `serve` — in den Produktionsbau geht davon nichts ein.
|
||||
*/
|
||||
function liviaResearchDevEndpoint(): Plugin {
|
||||
const PFAD = '/api/livia/research/refresh'
|
||||
return {
|
||||
name: 'livia-research-dev-endpoint',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (!req.url?.startsWith(PFAD)) return next()
|
||||
void server
|
||||
.ssrLoadModule('/api/livia/research/refresh.ts')
|
||||
.then(mod => (mod as { default: (q: typeof req, s: typeof res) => Promise<void> }).default(req, res))
|
||||
.catch((err: unknown) => {
|
||||
res.statusCode = 500
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
liviaResearchDevEndpoint(),
|
||||
],
|
||||
build: {
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user