feat(livia): Runde 10 Prompt 2 — eine Leadliste, dynamische Quellen, zeitliche Relevanz
Livia arbeitet nicht mehr neben dem Leadbestand, sondern in ihn hinein. - Systemzugänge tragen Titel, Beschreibung und Adresse; die Liste zeigt je Eintrag nur noch Aktiv/Inaktiv und Lesen/Schreiben — kein Berechtigungsbalken, kein Auge-Icon, keine Lese-/Schreibgruppen - Die Quellenregistry ist dynamisch: Der Lauf liest genau die aktiven Lesezugänge mit Adresse aus Livias Personalblatt. Für die drei gepflegten Quellen greift weiterhin ihr eigener Leser, für alles andere ein allgemeiner — eine Übersichtsseite, bis zu zehn Unterseiten, kein Spider. Freie Adressen werden auf http/https geprüft und gegen interne Netze gesperrt - Manuell abgelegte PDF- und Word-Dokumente fliessen in denselben Lauf. Text wird beim Ablegen nativ extrahiert (ZIP + Flate über DecompressionStream, ohne neue Abhängigkeit), Datei und Text liegen in IndexedDB - Neuer harter Filter «Zeitliche Relevanz»: das Modell beurteilt die Ereigniszeit der Veränderung, nicht das Publikationsdatum. «unknown» fällt bewusst nicht durch — ein erfundenes Datum wäre die schlechtere Antwort - Quellentypen und Beobachtungsraum sind Chips mit Freitext; jeder Wert lässt sich einzeln entfernen, auch die vorgegebenen - Info-Knopf bei «Zeitliche Relevanz» und «Mindestrelevanz» erklärt, wie der Wert entsteht — ohne erfundene Prozentgewichte - Gefundene Leads gehen in den zentralen Signalbestand statt in eine zweite Ergebnisliste; die Herkunft steht am Signal (origin: RESEARCH_RUN) - Der Lauf erzeugt einen echten Protokolleintrag mit den Zahlen des Laufs - Lead-Detail: zeitliche Einordnung mit Fundstelle, semantisches Suchprofil bei Chancen mit «Matching starten», und bei Risiko ausdrücklich «Kein internes Objekt eindeutig zugeordnet» statt einer beliebigen Mock-Immobilie - Aufgabenliste nach §11 bereinigt, «Unsichere Signale in Review Queue legen» von Nora übernommen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c4b3f7dd8
commit
30daa77937
33 files changed
+2275
-301
No files matched your search
@@ -17,4 +17,9 @@ export interface IFutureSignalProvider {
|
||||
getByProperty(propertyId: string): Promise<FutureSignal[]>
|
||||
verify(id: string, verifiedBy: string): Promise<FutureSignal>
|
||||
updateReviewStatus(id: string, status: ReviewStatus): Promise<FutureSignal>
|
||||
/**
|
||||
* Leads eines Recherchelaufs in den Bestand aufnehmen (Runde 10, §2.12).
|
||||
* Gibt zurück, wie viele erzeugte Leads danach insgesamt geführt werden.
|
||||
*/
|
||||
addResearchSignals(signals: FutureSignal[]): Promise<number>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
|
||||
/**
|
||||
* Ablage der manuell hinterlegten Newsquellen (Runde 10, §2.6).
|
||||
*
|
||||
* Eine eigene Schnittstelle, obwohl die übrigen Demo-Bestände im Local Storage
|
||||
* liegen: Dokumente sind um Grössenordnungen grösser als eine Konfiguration,
|
||||
* und der Local Storage ist bei rund fünf Megabyte zu Ende. Eine PDF-Ablage
|
||||
* dort hinein zu zwingen hiesse, beim dritten Dokument stillschweigend nichts
|
||||
* mehr zu speichern.
|
||||
*/
|
||||
export interface IResearchDocumentProvider {
|
||||
getAll(): Promise<ResearchDocument[]>
|
||||
add(doc: ResearchDocument, blob: Blob): Promise<void>
|
||||
remove(id: string): Promise<void>
|
||||
/** Die Originaldatei — für das Herunterladen aus der Liste. */
|
||||
getBlob(id: string): Promise<Blob | null>
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Property On — die abgelegten Dokumente liegen in IndexedDB (Runde 10, §2.6).
|
||||
*
|
||||
* Keine Mockup-Variante und kein zweiter Backend-Dienst: Die Anwendung läuft
|
||||
* im Browser, und der Browser hat eine Ablage, die auch Dateien verträgt. Ein
|
||||
* Server nur für die Dateihaltung wäre Infrastruktur für einen einzigen Zweck.
|
||||
*
|
||||
* Aufgeteilt in zwei Speicher: die Metadaten samt extrahiertem Text in `meta`,
|
||||
* die Originaldatei in `blobs`. So kann die Liste aufgebaut werden, ohne dass
|
||||
* zwanzig Megabyte PDF durch den Speicher wandern — gebraucht wird die Datei
|
||||
* erst, wenn jemand sie herunterlädt.
|
||||
*/
|
||||
|
||||
import type { IResearchDocumentProvider } from './IResearchDocumentProvider'
|
||||
import type { ResearchDocument } from '../domain/researchDocument'
|
||||
|
||||
const DB_NAME = 'property-on'
|
||||
const DB_VERSION = 1
|
||||
const STORE_META = 'research-document-meta'
|
||||
const STORE_BLOBS = 'research-document-blobs'
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_META)) db.createObjectStore(STORE_META, { keyPath: 'id' })
|
||||
if (!db.objectStoreNames.contains(STORE_BLOBS)) db.createObjectStore(STORE_BLOBS)
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('IndexedDB liess sich nicht öffnen.'))
|
||||
})
|
||||
}
|
||||
|
||||
/** Eine Transaktion als Versprechen — IndexedDB spricht sonst nur in Ereignissen. */
|
||||
function alsVersprechen<T>(
|
||||
req: IDBRequest<T>,
|
||||
tx: IDBTransaction,
|
||||
): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error ?? new Error('Zugriff auf die Dokumentenablage fehlgeschlagen.'))
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Die Dokumentenablage brach die Transaktion ab.'))
|
||||
})
|
||||
}
|
||||
|
||||
export const IndexedDbResearchDocumentProvider: IResearchDocumentProvider = {
|
||||
async getAll() {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction(STORE_META, 'readonly')
|
||||
const alle = await alsVersprechen(tx.objectStore(STORE_META).getAll() as IDBRequest<ResearchDocument[]>, tx)
|
||||
db.close()
|
||||
// Neuestes zuerst — wer eben etwas abgelegt hat, sucht es oben.
|
||||
return alle.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||
},
|
||||
|
||||
async add(doc, blob) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
|
||||
tx.objectStore(STORE_META).put(doc)
|
||||
await alsVersprechen(tx.objectStore(STORE_BLOBS).put(blob, doc.id), tx)
|
||||
db.close()
|
||||
},
|
||||
|
||||
async remove(id) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction([STORE_META, STORE_BLOBS], 'readwrite')
|
||||
tx.objectStore(STORE_META).delete(id)
|
||||
await alsVersprechen(tx.objectStore(STORE_BLOBS).delete(id), tx)
|
||||
db.close()
|
||||
},
|
||||
|
||||
async getBlob(id) {
|
||||
const db = await openDb()
|
||||
const tx = db.transaction(STORE_BLOBS, 'readonly')
|
||||
const blob = await alsVersprechen(tx.objectStore(STORE_BLOBS).get(id) as IDBRequest<Blob | undefined>, tx)
|
||||
db.close()
|
||||
return blob ?? null
|
||||
},
|
||||
}
|
||||
@@ -2,14 +2,27 @@ import type { IFutureSignalProvider, FutureSignalFilters } from './IFutureSignal
|
||||
import type { FutureSignal } from '../domain/futureSignal'
|
||||
import type { ReviewStatus } from '../domain/enums'
|
||||
import { mockFutureSignals } from '../mock-data/futureSignals'
|
||||
import { TEAM_STORAGE_KEYS, loadVersioned, persistVersioned } from './teamPersistence'
|
||||
|
||||
// Mutation overrides — applied on top of the live mockFutureSignals import.
|
||||
// Reading directly from mockFutureSignals (not a copied store) ensures that
|
||||
// Vite HMR changes to the mock data are always reflected immediately.
|
||||
const mutationOverrides = new Map<string, Partial<FutureSignal>>()
|
||||
|
||||
/**
|
||||
* Leads aus Rechercheläufen (Runde 10, §2.12).
|
||||
*
|
||||
* Sie liegen in derselben Ablage wie der Seed und werden von `resolve()` in
|
||||
* dieselbe Liste gegeben — es gibt bewusst keinen zweiten Bestand, den Nora
|
||||
* separat abfragen müsste. Getrennt ist nur die Persistenz: der Seed steht im
|
||||
* Code, die erzeugten Leads im Local Storage, damit ein Reload sie behält.
|
||||
*/
|
||||
let researchSignals: FutureSignal[] =
|
||||
loadVersioned<FutureSignal[]>(TEAM_STORAGE_KEYS.RESEARCH_LEADS) ?? []
|
||||
|
||||
function resolve(): FutureSignal[] {
|
||||
return mockFutureSignals.map(s => {
|
||||
// Erzeugte Leads zuerst: der jüngste Lauf steht oben, wo man ihn sucht.
|
||||
return [...researchSignals, ...mockFutureSignals].map(s => {
|
||||
const override = mutationOverrides.get(s.id)
|
||||
return override ? { ...s, ...override } : s
|
||||
})
|
||||
@@ -43,4 +56,20 @@ export const MockupFutureSignalProvider: IFutureSignalProvider = {
|
||||
mutationOverrides.set(id, { ...existing, reviewStatus: status, updatedAt: new Date().toISOString() })
|
||||
return resolve().find(s => s.id === id)!
|
||||
},
|
||||
|
||||
/**
|
||||
* Leads eines Recherchelaufs aufnehmen.
|
||||
*
|
||||
* Gleiche Kennung heisst gleicher Sachverhalt: Ein zweiter Lauf über
|
||||
* denselben Artikel ersetzt den bestehenden Eintrag, statt ihn zu
|
||||
* verdoppeln. Sonst wüchse die Liste bei jedem Knopfdruck um Dubletten, und
|
||||
* niemand könnte mehr sagen, welcher Stand gilt.
|
||||
*/
|
||||
async addResearchSignals(signals: FutureSignal[]) {
|
||||
const bekannt = new Map(researchSignals.map(s => [s.id, s]))
|
||||
for (const s of signals) bekannt.set(s.id, s)
|
||||
researchSignals = [...bekannt.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
persistVersioned(TEAM_STORAGE_KEYS.RESEARCH_LEADS, researchSignals)
|
||||
return researchSignals.length
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export const TEAM_STORAGE_KEYS = {
|
||||
WORK_ITEMS: `${PREFIX}work-items`,
|
||||
PROTOCOL: `${PREFIX}protocol`,
|
||||
CONNECTIONS: `${PREFIX}connections`,
|
||||
/** Von einem Recherchelauf erzeugte Leads (Runde 10, §2.12). */
|
||||
RESEARCH_LEADS: `${PREFIX}research-leads`,
|
||||
/** Manuell abgelegte Newsquellen als Dokumente (Runde 10, §2.6). */
|
||||
RESEARCH_DOCUMENTS: `${PREFIX}research-documents`,
|
||||
} as const
|
||||
|
||||
export type TeamStorageKey = typeof TEAM_STORAGE_KEYS[keyof typeof TEAM_STORAGE_KEYS]
|
||||
@@ -51,7 +55,7 @@ export function persist<T>(key: TeamStorageKey, value: T): void {
|
||||
* unter `src/domain/` ändert — sonst überlagert ein alter Local-Storage-Eintrag
|
||||
* die neuen Mockdaten und die Demo zeigt stillschweigend veraltete Inhalte.
|
||||
*/
|
||||
export const TEAM_SCHEMA_VERSION = 1
|
||||
export const TEAM_SCHEMA_VERSION = 2
|
||||
|
||||
interface PersistedEnvelope<T> {
|
||||
version: number
|
||||
|
||||
Reference in new issue
Block a user