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
14
app/assets/norms/bauo-bln-60.xml
Normal file
14
app/assets/norms/bauo-bln-60.xml
Normal 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>
|
||||||
14
app/assets/norms/dsgvo-art30.xml
Normal file
14
app/assets/norms/dsgvo-art30.xml
Normal 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>
|
||||||
14
app/assets/norms/gewo-14.xml
Normal file
14
app/assets/norms/gewo-14.xml
Normal 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>
|
||||||
14
app/assets/norms/kassensichv.xml
Normal file
14
app/assets/norms/kassensichv.xml
Normal 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>
|
||||||
14
app/assets/norms/milog-17.xml
Normal file
14
app/assets/norms/milog-17.xml
Normal 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>
|
||||||
|
|
@ -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 Ch∆In hub over gRPC.
|
/// Live `EvaluationRepository` backed by a Ch∆In 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-Recl∆Im
|
/// 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 {
|
||||||
|
final eval = await _evaluate(input);
|
||||||
if (eval != null) evals.add(eval);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
169
app/lib/data/live_inputs.dart
Normal file
169
app/lib/data/live_inputs.dart
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import 'models.dart';
|
||||||
|
|
||||||
|
/// The Recl∆Im 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 § 1–4', 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 § 60–69', 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.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
@ -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
78
app/tool/live_smoke.dart
Normal 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();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue