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:
parent
46b4003d36
commit
425a0e5790
9 changed files with 451 additions and 108 deletions
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue