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 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() async { if (!_healthy) return const []; final evals = []; 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 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 _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: ""}}` /// (module `with_json`) or `{text: "…"}`; we json-decode the inner /// string. Map> _decodeOutputs(chain.SubmitResponse r) { final out = >{}; 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) 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 _liveSources(Map? cohort) { final out = []; 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 _componentSummary( Map? skm, Map? cohort, ) { final lines = []; 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 _parseParagraphs(dynamic v) { if (v is! List) return const []; return v.whereType>().map((p) { return NormParagraph( number: (p['number'] as String?) ?? '', text: (p['text'] as String?) ?? '', ); }).toList(); } static List _parseDuties(Map? v) { final inner = v?['duties']; if (inner is! List) return const []; return inner.whereType>().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(); } }