reclaim/app/lib/data/hub_repository.dart
flemming-it 425a0e5790 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>
2026-06-19 02:39:15 +02:00

278 lines
8.9 KiB
Dart

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.
///
/// 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);
final chain.HubClient _client;
final HubSettings settings;
final bool _healthy;
static Future<HubRepository> connect(HubSettings settings) async {
final endpoint = chain.HubEndpoint(
host: settings.host,
port: settings.port,
secure: settings.secure,
);
final client = chain.HubClient(
endpoint: endpoint,
authToken: settings.authToken,
);
// The probe must NEVER throw out of connect(): main() awaits
// this before runApp(), so any uncaught error here leaves the
// app on a blank page. A timeout maps to false; so does any
// transport error (e.g. a gRPC-web call against a plain-gRPC
// hub, or a refused connection) — the app then comes up in
// 'hub-down' state and shows the empty-state hint.
bool ok;
try {
ok = await client.healthy().timeout(
const Duration(seconds: 4),
onTimeout: () => false,
);
} catch (_) {
ok = false;
}
return HubRepository._(client, settings, ok);
}
bool get isHealthy => _healthy;
chain.HubClient get client => _client;
@override
Future<List<Evaluation>> list() async {
if (!_healthy) return const [];
final evals = <Evaluation>[];
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;
}
@override
Future<Evaluation?> byEli(String eli) async {
final all = await list();
for (final e in all) {
if (e.norm.eli == eli) return e;
}
return null;
}
@override
String get mode => _healthy ? 'hub-live' : 'hub-down';
@override
String get hubLabel => '${settings.url}'
'${_healthy ? '' : ' — Verbindung gestört'}';
/// 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 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 skm = out['skm'];
final benefit = out['benefit'];
final frust = out['frust'];
final cohort = out['cohort'];
final norm = Norm(
eli: (normJson['eli'] as String?) ?? 'eli/unknown',
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(),
sourceUrl: (normJson['source_url'] as String?) ?? '',
sourceSha256: (normJson['source_sha256'] as String?) ?? '',
paragraphs: _parseParagraphs(normJson['paragraphs']),
);
final skmEur = _asDouble(skm?['total_eur_per_year']);
final tierLowest = _parseTier(skm?['tier_lowest']);
return Evaluation(
norm: norm,
skmEurPerYear: skmEur,
benefitScore: _asDouble(benefit?['total']),
affectedCount: _asInt(cohort?['count']),
frustScore: _asDouble(frust?['composite_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),
);
}
/// 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 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();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
static int _asInt(dynamic v) {
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static EvidenceTier _parseTier(dynamic v) {
final s = (v as String?)?.toUpperCase() ?? 'T4';
return switch (s) {
'T1' => EvidenceTier.t1,
'T2' => EvidenceTier.t2,
'T3' => EvidenceTier.t3,
_ => EvidenceTier.t4,
};
}
static List<NormParagraph> _parseParagraphs(dynamic v) {
if (v is! List) return const [];
return v.whereType<Map<String, dynamic>>().map((p) {
return NormParagraph(
number: (p['number'] as String?) ?? '',
text: (p['text'] as String?) ?? '',
);
}).toList();
}
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(
modality: (d['modality'] as String?) ?? '?',
addressee: (d['addressee'] as String?) ?? '',
action: (d['action'] as String?) ?? '',
frequency: (d['frequency'] as String?) ?? '',
sourceNorm: (d['source_norm'] as String?) ?? '',
authority: d['authority'] as String?,
consequence: d['consequence'] as String?,
);
}).toList();
}
}