diff --git a/app/assets/norms/bauo-bln-60.xml b/app/assets/norms/bauo-bln-60.xml
deleted file mode 100644
index 1be1f3c..0000000
--- a/app/assets/norms/bauo-bln-60.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- BauO Bln
- § 60 ff
- Bauantrag
-
-
-(1) Für die Errichtung, Änderung und Nutzungsänderung baulicher Anlagen ist die Baugenehmigung erforderlich, soweit in den §§ 61 bis 65 sowie 76 und 77 nichts anderes bestimmt ist.
-(2) Der Bauantrag ist schriftlich bei der Bauaufsichtsbehörde unter Beifügung der erforderlichen Bauvorlagen einzureichen.
-
-
-
diff --git a/app/assets/norms/dsgvo-art30.xml b/app/assets/norms/dsgvo-art30.xml
deleted file mode 100644
index 940eca7..0000000
--- a/app/assets/norms/dsgvo-art30.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- DSGVO
- Art. 30
- Verzeichnis von Verarbeitungstätigkeiten
-
-
-(1) Jeder Verantwortliche und gegebenenfalls sein Vertreter führen ein Verzeichnis aller Verarbeitungstätigkeiten, die ihrer Zuständigkeit unterliegen.
-(2) Jeder Auftragsverarbeiter führt ein Verzeichnis zu allen Kategorien von im Auftrag durchgeführten Verarbeitungstätigkeiten.
-
-
-
diff --git a/app/assets/norms/gewo-14.xml b/app/assets/norms/gewo-14.xml
deleted file mode 100644
index c8985d4..0000000
--- a/app/assets/norms/gewo-14.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- GewO
- § 14
- Anzeigepflicht
-
-
-(1) Wer den selbstständigen Betrieb eines stehenden Gewerbes anfängt, muss dies der zuständigen Behörde gleichzeitig anzeigen.
-(2) Die Anzeige hat unverzüglich zu erfolgen. Sie ist nach amtlich vorgeschriebenem Vordruck zu erstatten.
-
-
-
diff --git a/app/assets/norms/kassensichv.xml b/app/assets/norms/kassensichv.xml
deleted file mode 100644
index 9e436b7..0000000
--- a/app/assets/norms/kassensichv.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- KassenSichV
- § 1 ff
- Kassensicherungsverordnung
-
-
-(1) Das elektronische Aufzeichnungssystem muss jeden einzelnen aufzeichnungspflichtigen Vorgang vollständig aufzeichnen. Die Aufzeichnungen sind durch eine zertifizierte technische Sicherheitseinrichtung unmittelbar zu protokollieren.
-(2) Den Steuerpflichtigen treffen Mitteilungspflichten gegenüber dem zuständigen Finanzamt; der Ausfall einer zertifizierten technischen Sicherheitseinrichtung ist unverzüglich anzuzeigen.
-
-
-
diff --git a/app/assets/norms/milog-17.xml b/app/assets/norms/milog-17.xml
deleted file mode 100644
index 474d64f..0000000
--- a/app/assets/norms/milog-17.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- MiLoG
- § 17
- Dokumentationspflichten
-
-
-(1) Ein Arbeitgeber, der Arbeitnehmer geringfügig oder in den Wirtschaftsbereichen des Schwarzarbeitsbekämpfungsgesetzes beschäftigt, ist verpflichtet, Beginn, Ende und Dauer der täglichen Arbeitszeit aufzuzeichnen und diese Aufzeichnungen mindestens zwei Jahre aufzubewahren.
-(2) Die Unterlagen sind im Inland in deutscher Sprache bereitzuhalten.
-
-
-
diff --git a/app/lib/data/hub_repository.dart b/app/lib/data/hub_repository.dart
index e1320da..768bd71 100644
--- a/app/lib/data/hub_repository.dart
+++ b/app/lib/data/hub_repository.dart
@@ -1,26 +1,24 @@
import 'dart:convert';
import 'package:chain_client_sdk/chain_client_sdk.dart' as chain;
-import 'package:flutter/services.dart' show rootBundle;
import 'hub_endpoint.dart';
-import 'live_inputs.dart';
import 'models.dart';
import 'repository.dart';
/// Live `EvaluationRepository` backed by a Ch∆In hub over gRPC.
///
-/// For each pilot norm ([reclaimNormInputs]) it submits the inline
-/// [reclaimEvaluateFlow] via `HubClient.submit` and maps the returned
-/// `outputs` to an [Evaluation]. This is the correct path: the hub's
-/// audit log stores only hashes, never the output payloads — outputs
-/// come back ONLY from the Submit response. skm/benefit get their
-/// structured input as JSON (`submit(jsonInputs: …)`), so the heatmap
-/// gets real Schaden/Nutzen axes.
+/// Reads flow.completed events from the hub's audit log, parses
+/// each event's `detail` JSON as a Recl∆Im-shaped output bag
+/// (see flows/durchstich-gewo-14.yaml), and converts the bag to
+/// an Evaluation the UI can render. Events from non-Recl∆Im
+/// flows are silently skipped (detected by absence of the
+/// `norm` + `skm` keys).
///
-/// Sort order matches MockRepository: highest €/Jahr first. With an
-/// unhealthy hub `list()` returns empty and the UI shows a 'hub-down'
-/// empty-state — no silent mock fallback.
+/// Sort order matches MockRepository: highest €/Jahr first.
+/// If the hub has zero matching flow.completed events the list
+/// returns empty — the UI shows a "noch keine Auswertungen, '
+/// `chain run flows/durchstich-gewo-14.yaml` aufrufen"-Hinweis.
class HubRepository implements EvaluationRepository {
HubRepository._(this._client, this.settings, this._healthy);
@@ -63,14 +61,14 @@ class HubRepository implements EvaluationRepository {
@override
Future> list() async {
if (!_healthy) return const [];
+ final events = await _client.eventLog(
+ limit: 200,
+ types: const ['flow.completed'],
+ );
final evals = [];
- for (final input in reclaimNormInputs) {
- try {
- final eval = await _evaluate(input);
- if (eval != null) evals.add(eval);
- } catch (_) {
- // One norm failing must not blank the whole heatmap.
- }
+ for (final e in events) {
+ final eval = _parse(e);
+ if (eval != null) evals.add(eval);
}
evals.sort((a, b) => b.skmEurPerYear.compareTo(a.skmEurPerYear));
return evals;
@@ -92,141 +90,94 @@ class HubRepository implements EvaluationRepository {
String get hubLabel => '${settings.url}'
'${_healthy ? '' : ' — Verbindung gestört'}';
- /// Run one norm through the flow and assemble its [Evaluation].
- Future _evaluate(NormLiveInput input) async {
- final asset = await rootBundle.load(input.assetPath);
- final content = asset.buffer.asUint8List(
- asset.offsetInBytes,
- asset.lengthInBytes,
- );
+ /// Decode one flow.completed event into an Evaluation, or
+ /// return null when the event doesn't look like a Recl∆Im
+ /// flow. We're permissive about field absence so a partially
+ /// failed flow run still surfaces what it produced.
+ Evaluation? _parse(chain.LoggedEvent event) {
+ Map? bag;
+ try {
+ final decoded = jsonDecode(event.detail);
+ if (decoded is Map) bag = decoded;
+ } catch (_) {
+ return null;
+ }
+ if (bag == null) return null;
- final resp = await _client.submit(
- flowYaml: reclaimEvaluateFlow,
- fileInputs: {'content': content},
- fileMimeTypes: {'content': 'application/xml'},
- textInputs: {'cohort_id': input.cohortId},
- jsonInputs: {'duties': input.duties, 'ratings': input.ratings},
- );
-
- final out = _decodeOutputs(resp);
- final normJson = out['norm'];
+ final normJson = _asMap(bag['norm']);
if (normJson == null) return null;
+ final skmJson = _asMap(bag['skm']);
- final skm = out['skm'];
- final benefit = out['benefit'];
- final frust = out['frust'];
- final cohort = out['cohort'];
-
+ final eli = (normJson['eli'] as String?) ?? 'eli/unknown';
final norm = Norm(
- eli: (normJson['eli'] as String?) ?? 'eli/unknown',
+ eli: eli,
title: (normJson['title'] as String?) ?? '(ohne Titel)',
jurabk: (normJson['jurabk'] as String?) ?? '',
paragraph: (normJson['paragraph'] as String?) ?? '',
- // Geltungsbereich/Freshness can't be derived from the bund
- // adapter for EU/Berlin norms — take the curated override.
- jurisdiction: input.jurisdiction,
- freshness: input.freshness,
- supersededNote: input.supersededNote,
- standDate: DateTime.now(),
+ jurisdiction: JurisdictionLabel.parse(
+ normJson['scope'] as String?,
+ eli: eli,
+ ),
+ standDate: _parseTimestamp(event.timestamp),
sourceUrl: (normJson['source_url'] as String?) ?? '',
sourceSha256: (normJson['source_sha256'] as String?) ?? '',
+ freshness: _parseFreshness(normJson['freshness']),
+ supersededNote: normJson['superseded_note'] as String?,
paragraphs: _parseParagraphs(normJson['paragraphs']),
);
- final skmEur = _asDouble(skm?['total_eur_per_year']);
- final tierLowest = _parseTier(skm?['tier_lowest']);
+ final cohortJson = _asMap(bag['cohort']);
+ final affected = _asInt(cohortJson?['count']);
+
+ // SKM may be missing if the flow only ran the normalize-/
+ // cohort-arm (e.g. flows/durchstich-from-file.yaml). Default
+ // to zero and let the tier fall back to the cohort source's
+ // tier — that's still the most-honest reading.
+ final skmEur = _asDouble(skmJson?['total_eur_per_year']);
+ final tierLowest = skmJson != null
+ ? _parseTier(skmJson['tier_lowest'])
+ : _parseTier(cohortJson?['tier']);
+
+ final benefitJson = _asMap(bag['benefit']);
+ final benefit = _asDouble(benefitJson?['total']);
+
+ final frustJson = _asMap(bag['frust']);
+ final frust = _asDouble(frustJson?['composite_frust']);
return Evaluation(
norm: norm,
skmEurPerYear: skmEur,
- benefitScore: _asDouble(benefit?['total']),
- affectedCount: _asInt(cohort?['count']),
- frustScore: _asDouble(frust?['composite_frust']),
+ benefitScore: benefit,
+ affectedCount: affected,
+ frustScore: frust,
tierLowest: tierLowest,
- duties: _parseDuties(out['duties']),
- sources: _liveSources(cohort),
- auditEventId: _invocationId(resp),
- reviewedBy: input.reviewedBy,
- reviewedAt: input.reviewedAt,
- notes: 'Live aus Hub-Flow reclaim-evaluate.'
- '${input.supersededNote != null ? '\n${input.supersededNote}' : ''}',
- componentSummary: _componentSummary(skm, cohort),
+ duties: _parseDuties(bag['duties']),
+ sources: _parseSources(cohortJson),
+ auditEventId: event.eventId,
+ notes: 'Live aus Hub-Flow ${event.flowName} '
+ '(execution ${event.flowExecution.substring(0, 8.clamp(0, event.flowExecution.length))}).',
+ // Jurist sign-off is recorded by the approval, not the flow
+ // bag. Until the review-rewrite wires approvals in, these stay
+ // null (= unreviewed) unless the bag carries them explicitly.
+ reviewedBy: bag['reviewed_by'] as String?,
+ reviewedAt: DateTime.tryParse(
+ (bag['reviewed_at'] as String?) ?? ''),
);
}
- /// Decode `SubmitResponse.outputs` into plain Dart maps. Each output
- /// Payload serializes (proto3-json) as `{json: {value: ""}}`
- /// (module `with_json`) or `{text: "…"}`; we json-decode the inner
- /// string.
- Map> _decodeOutputs(chain.SubmitResponse r) {
- final out = >{};
- final j = r.toProto3Json();
- if (j is! Map) return out;
- final outputs = j['outputs'];
- if (outputs is! Map) return out;
- for (final entry in outputs.entries) {
- final v = entry.value;
- String? raw;
- if (v is Map) {
- final js = v['json'];
- if (js is Map && js['value'] is String) {
- raw = js['value'] as String;
- } else if (v['text'] is String) {
- raw = v['text'] as String;
- }
- }
- if (raw == null) continue;
- try {
- final decoded = jsonDecode(raw);
- if (decoded is Map) out[entry.key] = decoded;
- } catch (_) {}
+ static Freshness _parseFreshness(dynamic v) {
+ switch ((v as String?)?.trim().toLowerCase()) {
+ case 'superseded' || 'amended' || 'geändert' || 'stale':
+ return Freshness.superseded;
+ case 'unknown' || 'ungeprüft':
+ return Freshness.unknown;
+ default:
+ return Freshness.current;
}
- return out;
}
- static String _invocationId(chain.SubmitResponse r) {
- try {
- final j = r.toProto3Json();
- if (j is Map) {
- final inv = j['invocationId'];
- if (inv is Map && inv['value'] is String) return inv['value'] as String;
- if (inv is String) return inv;
- }
- } catch (_) {}
- return 'live-${DateTime.now().millisecondsSinceEpoch}';
- }
-
- static List _liveSources(Map? cohort) {
- final out = [];
- final label = cohort?['source'] as String?;
- if (label != null && label.isNotEmpty) {
- out.add(Source(
- label: label,
- url: '',
- tier: _parseTier(cohort?['tier']),
- ));
- }
- return out;
- }
-
- static List _componentSummary(
- Map? skm,
- Map? cohort,
- ) {
- final lines = [];
- final items = skm?['items'];
- if (items is List && items.isNotEmpty) {
- final first = items.first;
- if (first is Map && first['formula'] is String) {
- lines.add('SKM · ${first['formula']}');
- }
- }
- final src = cohort?['source'] as String?;
- if (src != null && src.isNotEmpty) {
- lines.add('P · ${_parseTier(cohort?['tier']).short} — $src');
- }
- return lines;
- }
+ static Map? _asMap(dynamic v) =>
+ v is Map ? v : null;
static double _asDouble(dynamic v) {
if (v is num) return v.toDouble();
@@ -250,6 +201,9 @@ class HubRepository implements EvaluationRepository {
};
}
+ static DateTime _parseTimestamp(String iso) =>
+ DateTime.tryParse(iso) ?? DateTime.now();
+
static List _parseParagraphs(dynamic v) {
if (v is! List) return const [];
return v.whereType