From 425a0e5790f9a09e2ea0b51539b965fa05ba7e87 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 19 Jun 2026 02:39:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(live):=20real=20live=20mode=20via=20HubCli?= =?UTF-8?q?ent.submit=20=E2=86=92=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HubRepository.list() now runs the inline reclaim-evaluate flow per pilot norm via submit() and maps the returned outputs to Evaluation — the correct path, since the hub audit log stores only hashes, not outputs. skm/benefit get their structured input as JSON (submit jsonInputs), so the live heatmap gets real Schaden/Nutzen axes. - live_inputs.dart: the inline eval flow + the 5 pilot norms' inputs (XML asset, cohort_id, skm duties, benefit ratings) plus curated overrides the bund adapter can't derive (true Geltungsbereich for the EU/Berlin norms, freshness, jurist sign-off). - assets/norms/*.xml: 5 gesetze-im-internet-style source documents. - tool/live_smoke.dart: dev smoke test (5/5 evaluate against a live hub: Schaden 3M–450M €, Nutzen 2.0–3.8, EU/Berlin scope correct). Needs econ.skm_score's float-population fix (chain-modules commit 80f6620). Signed-off-by: flemming-it --- app/assets/norms/bauo-bln-60.xml | 14 ++ app/assets/norms/dsgvo-art30.xml | 14 ++ app/assets/norms/gewo-14.xml | 14 ++ app/assets/norms/kassensichv.xml | 14 ++ app/assets/norms/milog-17.xml | 14 ++ app/lib/data/hub_repository.dart | 241 +++++++++++++++++-------------- app/lib/data/live_inputs.dart | 169 ++++++++++++++++++++++ app/pubspec.yaml | 1 + app/tool/live_smoke.dart | 78 ++++++++++ 9 files changed, 451 insertions(+), 108 deletions(-) create mode 100644 app/assets/norms/bauo-bln-60.xml create mode 100644 app/assets/norms/dsgvo-art30.xml create mode 100644 app/assets/norms/gewo-14.xml create mode 100644 app/assets/norms/kassensichv.xml create mode 100644 app/assets/norms/milog-17.xml create mode 100644 app/lib/data/live_inputs.dart create mode 100644 app/tool/live_smoke.dart 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>().map((p) { @@ -214,9 +260,8 @@ class HubRepository implements EvaluationRepository { }).toList(); } - static List _parseDuties(dynamic v) { - final outer = _asMap(v); - final inner = outer?['duties']; + static List _parseDuties(Map? v) { + final inner = v?['duties']; if (inner is! List) return const []; return inner.whereType>().map((d) { return Duty( @@ -230,24 +275,4 @@ class HubRepository implements EvaluationRepository { ); }).toList(); } - - /// Hub-mode sources are minimal: we surface the cohort source - /// as a T1/T2 reference (the field is set by stats.cohort_size) - /// plus an audit-log pointer. Richer source attribution comes - /// when the flow has a dedicated `sources.collect` step. - static List _parseSources(Map? cohort) { - final out = []; - final label = cohort?['source'] as String?; - final tierStr = (cohort?['tier'] as String?)?.toUpperCase() ?? 'T2'; - if (label != null && label.isNotEmpty) { - final tier = switch (tierStr) { - 'T1' => EvidenceTier.t1, - 'T2' => EvidenceTier.t2, - 'T3' => EvidenceTier.t3, - _ => EvidenceTier.t4, - }; - out.add(Source(label: label, url: '', tier: tier)); - } - return out; - } } diff --git a/app/lib/data/live_inputs.dart b/app/lib/data/live_inputs.dart new file mode 100644 index 0000000..84b3444 --- /dev/null +++ b/app/lib/data/live_inputs.dart @@ -0,0 +1,169 @@ +import 'models.dart'; + +/// The Recl∆Im evaluation flow, submitted inline per norm via +/// `HubClient.submit`. No `system.approval` step — submit() returns +/// the outputs synchronously (jurist review is a separate, optional +/// after-the-fact action, not part of computing the figure). +const String reclaimEvaluateFlow = r''' +name: reclaim-evaluate +inputs: + content: bytes + cohort_id: text + duties: json + ratings: json +steps: + - id: normalize + use: text.akoma_normalize@^0 + with: + content: $inputs.content + mime: "application/xml" + - id: deontic + use: text.deontic_extract@^0 + with: + norm: $normalize.norm + - id: citations + use: graph.citation_extract@^0 + with: + norm: $normalize.norm + - id: cohort + use: stats.cohort_size@^0 + with: + cohort_id: $inputs.cohort_id + - id: frust + use: text.readability_score@^0 + with: + norm: $normalize.norm + graph: $citations.citations + - id: skm + use: econ.skm_score@^0 + with: + duties: $inputs.duties + - id: benefit + use: law.benefit_score@^0 + with: + ratings: $inputs.ratings +outputs: + norm: $normalize.norm + skm: $skm.report + benefit: $benefit.score + frust: $frust.score + cohort: $cohort.cohort + duties: $deontic.duties + citations: $citations.citations +'''; + +/// One pilot norm's live inputs + curated metadata the akoma adapter +/// can't derive (true Geltungsbereich for EU/Berlin norms, freshness, +/// jurist sign-off). The hub computes skm/benefit/frust/cohort/norm +/// from these; the overrides are merged onto the resulting Evaluation. +class NormLiveInput { + const NormLiveInput({ + required this.assetPath, + required this.cohortId, + required this.duties, + required this.ratings, + required this.jurisdiction, + this.freshness = Freshness.current, + this.supersededNote, + this.reviewedBy, + this.reviewedAt, + }); + + final String assetPath; + final String cohortId; + + /// `{ duties: [ { population, frequency, tariff_eur_per_hour, + /// time_hours_per_case, source_norm, tier } ] }` for econ.skm_score. + final Map duties; + + /// `{ schutz, markt, rechtssicherheit, eu_binnenmarkt, sicherheit, + /// umwelt, einnahmen }` (each 0..5) for law.benefit_score. + final Map ratings; + + final Jurisdiction jurisdiction; + final Freshness freshness; + final String? supersededNote; + final String? reviewedBy; + final DateTime? reviewedAt; +} + +Map _duty( + String norm, + double population, + double frequency, + double tariff, + double time, + String tier, +) => + { + 'duties': [ + { + 'population': population, + 'frequency': frequency, + 'tariff_eur_per_hour': tariff, + 'time_hours_per_case': time, + 'source_norm': norm, + 'tier': tier, + } + ] + }; + +Map _ratings(double schutz, double markt, double rs, + double eu, double sicherheit, double umwelt, double einnahmen) => + { + 'schutz': schutz, + 'markt': markt, + 'rechtssicherheit': rs, + 'eu_binnenmarkt': eu, + 'sicherheit': sicherheit, + 'umwelt': umwelt, + 'einnahmen': einnahmen, + }; + +/// The five pilot norms, live-evaluated against the hub. +final List reclaimNormInputs = [ + NormLiveInput( + assetPath: 'assets/norms/gewo-14.xml', + cohortId: 'berlin-kmu', + duties: _duty('GewO § 14', 180000, 1.0, 35.0, 0.5, 'T3'), + ratings: _ratings(2, 3, 3.5, 2, 1.5, 0, 2), + jurisdiction: Jurisdiction.deutschland, + ), + NormLiveInput( + assetPath: 'assets/norms/kassensichv.xml', + cohortId: 'gastro-de', + duties: _duty('KassenSichV § 1–4', 500000, 1.0, 40.0, 1.25, 'T2'), + ratings: _ratings(3, 3, 3.5, 2.5, 3, 1, 5), + jurisdiction: Jurisdiction.deutschland, + reviewedBy: 'RA Dr. M. Kessler', + reviewedAt: DateTime.utc(2026, 1, 20), + ), + NormLiveInput( + assetPath: 'assets/norms/dsgvo-art30.xml', + cohortId: 'de-kmu', + duties: _duty('DSGVO Art. 30', 3000000, 1.0, 50.0, 3.0, 'T1'), + ratings: _ratings(4, 4, 4.5, 4, 3, 2, 5), + jurisdiction: Jurisdiction.eu, + reviewedBy: 'Prof. Dr. A. Lindner', + reviewedAt: DateTime.utc(2026, 2, 3), + ), + NormLiveInput( + assetPath: 'assets/norms/bauo-bln-60.xml', + cohortId: 'bauwirtschaft-be', + duties: _duty('BauO Bln § 60–69', 15000, 1.0, 60.0, 80.0, 'T2'), + ratings: _ratings(4, 3, 4, 2, 4, 3, 4), + jurisdiction: Jurisdiction.berlin, + reviewedBy: 'RAin S. Brandt', + reviewedAt: DateTime.utc(2026, 1, 28), + ), + NormLiveInput( + assetPath: 'assets/norms/milog-17.xml', + cohortId: 'de-kmu', + duties: _duty('MiLoG § 17', 3000000, 220.0, 30.0, 0.014, 'T1'), + ratings: _ratings(4, 3, 4, 2, 3, 1, 5), + jurisdiction: Jurisdiction.deutschland, + freshness: Freshness.superseded, + supersededNote: 'BEG IV hat die Dokumentationspflichten zum ' + '1.1.2026 entschärft — diese Fassung ist neu zu bewerten.', + ), +]; diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c43960b..eaf0285 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -32,6 +32,7 @@ flutter: assets: - assets/fixtures/ + - assets/norms/ # Bundled fonts — offline-first, KRITIS-compatible, no Google # Fonts runtime fetch. setup.sh downloads SIL-OFL-licensed TTFs diff --git a/app/tool/live_smoke.dart b/app/tool/live_smoke.dart new file mode 100644 index 0000000..29fed74 --- /dev/null +++ b/app/tool/live_smoke.dart @@ -0,0 +1,78 @@ +// Smoke test for the live submit() path — all 5 pilot norms. +// dart run tool/live_smoke.dart (run from app/, hub on :50051) +// Reads the same flow + inputs the app uses (lib/data/live_inputs.dart), +// loads XML from assets via File (no Flutter binding here). +import 'dart:convert'; +import 'dart:io'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' as chain; + +import 'package:reclaim_app/data/live_inputs.dart'; + +Map> decode(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 e in outputs.entries) { + final v = e.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 d = jsonDecode(raw); + if (d is Map) out[e.key] = d; + } catch (_) {} + } + return out; +} + +Future main() async { + final client = chain.HubClient( + endpoint: const chain.HubEndpoint( + host: '127.0.0.1', port: 50051, secure: false), + ); + stderr.writeln('hub healthy: ${await client.healthy()}'); + + var okCount = 0; + for (final input in reclaimNormInputs) { + final content = await File(input.assetPath).readAsBytes(); + try { + final r = 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 o = decode(r); + final norm = o['norm'] ?? {}; + final skm = o['skm'] ?? {}; + final benefit = o['benefit'] ?? {}; + final cohort = o['cohort'] ?? {}; + final frust = o['frust'] ?? {}; + stdout.writeln( + '✔ ${norm['jurabk']} ${norm['paragraph']} ' + '[${input.jurisdiction.name}] ' + '— Schaden ${skm['total_eur_per_year']} € · ' + 'Nutzen ${benefit['total']} · ' + 'Frust ${frust['composite_frust']} · ' + 'Betroffene ${cohort['count']} · ' + 'Tier ${skm['tier_lowest']}', + ); + okCount++; + } catch (e) { + stdout.writeln('✗ ${input.assetPath}: $e'); + } + } + stdout.writeln('--- $okCount/${reclaimNormInputs.length} evaluated ---'); + await client.close(); +}