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

@ -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 ChIn 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-ReclIm
/// 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<Evaluation>> list() async {
if (!_healthy) return const [];
final events = await _client.eventLog(
limit: 200,
types: const ['flow.completed'],
);
final evals = <Evaluation>[];
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<String, dynamic>? bag;
try {
final decoded = jsonDecode(event.detail);
if (decoded is Map<String, dynamic>) bag = decoded;
} catch (_) {
return null;
}
if (bag == null) return null;
/// Run one norm through the flow and assemble its [Evaluation].
Future<Evaluation?> _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: "<jsonstr>"}}`
/// (module `with_json`) or `{text: ""}`; we json-decode the inner
/// string.
Map<String, Map<String, dynamic>> _decodeOutputs(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 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) =>
v is Map<String, dynamic> ? 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<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) {
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) {
if (v is! List) return const [];
return v.whereType<Map<String, dynamic>>().map((p) {
@ -214,9 +260,8 @@ class HubRepository implements EvaluationRepository {
}).toList();
}
static List<Duty> _parseDuties(dynamic v) {
final outer = _asMap(v);
final inner = outer?['duties'];
static List<Duty> _parseDuties(Map<String, dynamic>? v) {
final inner = v?['duties'];
if (inner is! List) return const [];
return inner.whereType<Map<String, dynamic>>().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<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.',
),
];