diff --git a/app/assets/norms/bauo-bln-60.xml b/app/assets/norms/bauo-bln-60.xml
new file mode 100644
index 0000000..1be1f3c
--- /dev/null
+++ b/app/assets/norms/bauo-bln-60.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ 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
new file mode 100644
index 0000000..940eca7
--- /dev/null
+++ b/app/assets/norms/dsgvo-art30.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ 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
new file mode 100644
index 0000000..c8985d4
--- /dev/null
+++ b/app/assets/norms/gewo-14.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ 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
new file mode 100644
index 0000000..9e436b7
--- /dev/null
+++ b/app/assets/norms/kassensichv.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ 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
new file mode 100644
index 0000000..474d64f
--- /dev/null
+++ b/app/assets/norms/milog-17.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ 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 768bd71..e1320da 100644
--- a/app/lib/data/hub_repository.dart
+++ b/app/lib/data/hub_repository.dart
@@ -1,24 +1,26 @@
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.
///
-/// 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).
+/// 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.
///
-/// 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.
+/// 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.
class HubRepository implements EvaluationRepository {
HubRepository._(this._client, this.settings, this._healthy);
@@ -61,14 +63,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 e in events) {
- final eval = _parse(e);
- if (eval != null) evals.add(eval);
+ 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.
+ }
}
evals.sort((a, b) => b.skmEurPerYear.compareTo(a.skmEurPerYear));
return evals;
@@ -90,94 +92,141 @@ class HubRepository implements EvaluationRepository {
String get hubLabel => '${settings.url}'
'${_healthy ? '' : ' — Verbindung gestört'}';
- /// 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;
+ /// 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,
+ );
- final normJson = _asMap(bag['norm']);
+ 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'];
if (normJson == null) return null;
- final skmJson = _asMap(bag['skm']);
- final eli = (normJson['eli'] as String?) ?? 'eli/unknown';
+ final skm = out['skm'];
+ final benefit = out['benefit'];
+ final frust = out['frust'];
+ final cohort = out['cohort'];
+
final norm = Norm(
- eli: eli,
+ eli: (normJson['eli'] as String?) ?? 'eli/unknown',
title: (normJson['title'] as String?) ?? '(ohne Titel)',
jurabk: (normJson['jurabk'] as String?) ?? '',
paragraph: (normJson['paragraph'] as String?) ?? '',
- jurisdiction: JurisdictionLabel.parse(
- normJson['scope'] as String?,
- eli: eli,
- ),
- standDate: _parseTimestamp(event.timestamp),
+ // 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(),
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 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']);
+ final skmEur = _asDouble(skm?['total_eur_per_year']);
+ final tierLowest = _parseTier(skm?['tier_lowest']);
return Evaluation(
norm: norm,
skmEurPerYear: skmEur,
- benefitScore: benefit,
- affectedCount: affected,
- frustScore: frust,
+ benefitScore: _asDouble(benefit?['total']),
+ affectedCount: _asInt(cohort?['count']),
+ frustScore: _asDouble(frust?['composite_frust']),
tierLowest: tierLowest,
- 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?) ?? ''),
+ 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),
);
}
- 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;
+ /// 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 (_) {}
}
+ return out;
}
- static Map? _asMap(dynamic v) =>
- v is Map ? v : null;
+ 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 double _asDouble(dynamic v) {
if (v is num) return v.toDouble();
@@ -201,9 +250,6 @@ 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