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>
278 lines
8.9 KiB
Dart
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();
|
|
}
|
|
}
|