feat(live): real live mode via HubClient.submit → outputs

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-19 02:39:15 +02:00
parent 46b4003d36
commit 425a0e5790
9 changed files with 451 additions and 108 deletions

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="jlr-BauOBE2005V25P60">
<metadaten>
<jurabk>BauO Bln</jurabk>
<enbez>§ 60 ff</enbez>
<titel>Bauantrag</titel>
</metadaten>
<textdaten><text>
(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.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="CELEX32016R0679A30">
<metadaten>
<jurabk>DSGVO</jurabk>
<enbez>Art. 30</enbez>
<titel>Verzeichnis von Verarbeitungstätigkeiten</titel>
</metadaten>
<textdaten><text>
(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.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="BJNR000020869BJNE002500314">
<metadaten>
<jurabk>GewO</jurabk>
<enbez>§ 14</enbez>
<titel>Anzeigepflicht</titel>
</metadaten>
<textdaten><text>
(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.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="BJNR396000016">
<metadaten>
<jurabk>KassenSichV</jurabk>
<enbez>§ 1 ff</enbez>
<titel>Kassensicherungsverordnung</titel>
</metadaten>
<textdaten><text>
(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.
</text></textdaten>
</norm>
</dokumente>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<dokumente>
<norm doknr="BJNR181010014BJNE001700000">
<metadaten>
<jurabk>MiLoG</jurabk>
<enbez>§ 17</enbez>
<titel>Dokumentationspflichten</titel>
</metadaten>
<textdaten><text>
(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.
</text></textdaten>
</norm>
</dokumente>

View file

@ -1,24 +1,26 @@
import 'dart:convert'; import 'dart:convert';
import 'package:chain_client_sdk/chain_client_sdk.dart' as chain; import 'package:chain_client_sdk/chain_client_sdk.dart' as chain;
import 'package:flutter/services.dart' show rootBundle;
import 'hub_endpoint.dart'; import 'hub_endpoint.dart';
import 'live_inputs.dart';
import 'models.dart'; import 'models.dart';
import 'repository.dart'; import 'repository.dart';
/// Live `EvaluationRepository` backed by a ChIn hub over gRPC. /// Live `EvaluationRepository` backed by a ChIn hub over gRPC.
/// ///
/// Reads flow.completed events from the hub's audit log, parses /// For each pilot norm ([reclaimNormInputs]) it submits the inline
/// each event's `detail` JSON as a Recl∆Im-shaped output bag /// [reclaimEvaluateFlow] via `HubClient.submit` and maps the returned
/// (see flows/durchstich-gewo-14.yaml), and converts the bag to /// `outputs` to an [Evaluation]. This is the correct path: the hub's
/// an Evaluation the UI can render. Events from non-ReclIm /// audit log stores only hashes, never the output payloads outputs
/// flows are silently skipped (detected by absence of the /// come back ONLY from the Submit response. skm/benefit get their
/// `norm` + `skm` keys). /// structured input as JSON (`submit(jsonInputs: )`), so the heatmap
/// gets real Schaden/Nutzen axes.
/// ///
/// Sort order matches MockRepository: highest /Jahr first. /// Sort order matches MockRepository: highest /Jahr first. With an
/// If the hub has zero matching flow.completed events the list /// unhealthy hub `list()` returns empty and the UI shows a 'hub-down'
/// returns empty the UI shows a "noch keine Auswertungen, ' /// empty-state no silent mock fallback.
/// `chain run flows/durchstich-gewo-14.yaml` aufrufen"-Hinweis.
class HubRepository implements EvaluationRepository { class HubRepository implements EvaluationRepository {
HubRepository._(this._client, this.settings, this._healthy); HubRepository._(this._client, this.settings, this._healthy);
@ -61,14 +63,14 @@ class HubRepository implements EvaluationRepository {
@override @override
Future<List<Evaluation>> list() async { Future<List<Evaluation>> list() async {
if (!_healthy) return const []; if (!_healthy) return const [];
final events = await _client.eventLog(
limit: 200,
types: const ['flow.completed'],
);
final evals = <Evaluation>[]; final evals = <Evaluation>[];
for (final e in events) { for (final input in reclaimNormInputs) {
final eval = _parse(e); try {
if (eval != null) evals.add(eval); 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)); evals.sort((a, b) => b.skmEurPerYear.compareTo(a.skmEurPerYear));
return evals; return evals;
@ -90,94 +92,141 @@ class HubRepository implements EvaluationRepository {
String get hubLabel => '${settings.url}' String get hubLabel => '${settings.url}'
'${_healthy ? '' : ' — Verbindung gestört'}'; '${_healthy ? '' : ' — Verbindung gestört'}';
/// Decode one flow.completed event into an Evaluation, or /// Run one norm through the flow and assemble its [Evaluation].
/// return null when the event doesn't look like a Recl∆Im Future<Evaluation?> _evaluate(NormLiveInput input) async {
/// flow. We're permissive about field absence so a partially final asset = await rootBundle.load(input.assetPath);
/// failed flow run still surfaces what it produced. final content = asset.buffer.asUint8List(
Evaluation? _parse(chain.LoggedEvent event) { asset.offsetInBytes,
Map<String, dynamic>? bag; asset.lengthInBytes,
try { );
final decoded = jsonDecode(event.detail);
if (decoded is Map<String, dynamic>) bag = decoded;
} catch (_) {
return null;
}
if (bag == null) return null;
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; 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( final norm = Norm(
eli: eli, eli: (normJson['eli'] as String?) ?? 'eli/unknown',
title: (normJson['title'] as String?) ?? '(ohne Titel)', title: (normJson['title'] as String?) ?? '(ohne Titel)',
jurabk: (normJson['jurabk'] as String?) ?? '', jurabk: (normJson['jurabk'] as String?) ?? '',
paragraph: (normJson['paragraph'] as String?) ?? '', paragraph: (normJson['paragraph'] as String?) ?? '',
jurisdiction: JurisdictionLabel.parse( // Geltungsbereich/Freshness can't be derived from the bund
normJson['scope'] as String?, // adapter for EU/Berlin norms take the curated override.
eli: eli, jurisdiction: input.jurisdiction,
), freshness: input.freshness,
standDate: _parseTimestamp(event.timestamp), supersededNote: input.supersededNote,
standDate: DateTime.now(),
sourceUrl: (normJson['source_url'] as String?) ?? '', sourceUrl: (normJson['source_url'] as String?) ?? '',
sourceSha256: (normJson['source_sha256'] as String?) ?? '', sourceSha256: (normJson['source_sha256'] as String?) ?? '',
freshness: _parseFreshness(normJson['freshness']),
supersededNote: normJson['superseded_note'] as String?,
paragraphs: _parseParagraphs(normJson['paragraphs']), paragraphs: _parseParagraphs(normJson['paragraphs']),
); );
final cohortJson = _asMap(bag['cohort']); final skmEur = _asDouble(skm?['total_eur_per_year']);
final affected = _asInt(cohortJson?['count']); final tierLowest = _parseTier(skm?['tier_lowest']);
// 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( return Evaluation(
norm: norm, norm: norm,
skmEurPerYear: skmEur, skmEurPerYear: skmEur,
benefitScore: benefit, benefitScore: _asDouble(benefit?['total']),
affectedCount: affected, affectedCount: _asInt(cohort?['count']),
frustScore: frust, frustScore: _asDouble(frust?['composite_frust']),
tierLowest: tierLowest, tierLowest: tierLowest,
duties: _parseDuties(bag['duties']), duties: _parseDuties(out['duties']),
sources: _parseSources(cohortJson), sources: _liveSources(cohort),
auditEventId: event.eventId, auditEventId: _invocationId(resp),
notes: 'Live aus Hub-Flow ${event.flowName} ' reviewedBy: input.reviewedBy,
'(execution ${event.flowExecution.substring(0, 8.clamp(0, event.flowExecution.length))}).', reviewedAt: input.reviewedAt,
// Jurist sign-off is recorded by the approval, not the flow notes: 'Live aus Hub-Flow reclaim-evaluate.'
// bag. Until the review-rewrite wires approvals in, these stay '${input.supersededNote != null ? '\n${input.supersededNote}' : ''}',
// null (= unreviewed) unless the bag carries them explicitly. componentSummary: _componentSummary(skm, cohort),
reviewedBy: bag['reviewed_by'] as String?,
reviewedAt: DateTime.tryParse(
(bag['reviewed_at'] as String?) ?? ''),
); );
} }
static Freshness _parseFreshness(dynamic v) { /// Decode `SubmitResponse.outputs` into plain Dart maps. Each output
switch ((v as String?)?.trim().toLowerCase()) { /// Payload serializes (proto3-json) as `{json: {value: "<jsonstr>"}}`
case 'superseded' || 'amended' || 'geändert' || 'stale': /// (module `with_json`) or `{text: ""}`; we json-decode the inner
return Freshness.superseded; /// string.
case 'unknown' || 'ungeprüft': Map<String, Map<String, dynamic>> _decodeOutputs(chain.SubmitResponse r) {
return Freshness.unknown; final out = <String, Map<String, dynamic>>{};
default: final j = r.toProto3Json();
return Freshness.current; 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<String, dynamic>) out[entry.key] = decoded;
} catch (_) {}
} }
return out;
} }
static Map<String, dynamic>? _asMap(dynamic v) => static String _invocationId(chain.SubmitResponse r) {
v is Map<String, dynamic> ? v : null; 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<Source> _liveSources(Map<String, dynamic>? cohort) {
final out = <Source>[];
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<String> _componentSummary(
Map<String, dynamic>? skm,
Map<String, dynamic>? cohort,
) {
final lines = <String>[];
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) { static double _asDouble(dynamic v) {
if (v is num) return v.toDouble(); 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<NormParagraph> _parseParagraphs(dynamic v) { static List<NormParagraph> _parseParagraphs(dynamic v) {
if (v is! List) return const []; if (v is! List) return const [];
return v.whereType<Map<String, dynamic>>().map((p) { return v.whereType<Map<String, dynamic>>().map((p) {
@ -214,9 +260,8 @@ class HubRepository implements EvaluationRepository {
}).toList(); }).toList();
} }
static List<Duty> _parseDuties(dynamic v) { static List<Duty> _parseDuties(Map<String, dynamic>? v) {
final outer = _asMap(v); final inner = v?['duties'];
final inner = outer?['duties'];
if (inner is! List) return const []; if (inner is! List) return const [];
return inner.whereType<Map<String, dynamic>>().map((d) { return inner.whereType<Map<String, dynamic>>().map((d) {
return Duty( return Duty(
@ -230,24 +275,4 @@ class HubRepository implements EvaluationRepository {
); );
}).toList(); }).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<Source> _parseSources(Map<String, dynamic>? cohort) {
final out = <Source>[];
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;
}
} }

View file

@ -0,0 +1,169 @@
import 'models.dart';
/// The ReclIm 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<String, Object?> duties;
/// `{ schutz, markt, rechtssicherheit, eu_binnenmarkt, sicherheit,
/// umwelt, einnahmen }` (each 0..5) for law.benefit_score.
final Map<String, Object?> ratings;
final Jurisdiction jurisdiction;
final Freshness freshness;
final String? supersededNote;
final String? reviewedBy;
final DateTime? reviewedAt;
}
Map<String, Object?> _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<String, Object?> _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<NormLiveInput> 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 § 14', 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 § 6069', 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.',
),
];

View file

@ -32,6 +32,7 @@ flutter:
assets: assets:
- assets/fixtures/ - assets/fixtures/
- assets/norms/
# Bundled fonts — offline-first, KRITIS-compatible, no Google # Bundled fonts — offline-first, KRITIS-compatible, no Google
# Fonts runtime fetch. setup.sh downloads SIL-OFL-licensed TTFs # Fonts runtime fetch. setup.sh downloads SIL-OFL-licensed TTFs

78
app/tool/live_smoke.dart Normal file
View file

@ -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<String, Map<String, dynamic>> decode(chain.SubmitResponse r) {
final out = <String, Map<String, dynamic>>{};
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<String, dynamic>) out[e.key] = d;
} catch (_) {}
}
return out;
}
Future<void> 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();
}