Compare commits

..

5 commits

Author SHA1 Message Date
46b4003d36 chore(store): Recl∆Im module store index + staged bundles
store/store.yaml lists all 8 modules (status published, DE+EN
tagline/description, category, tags, license, repo) with real .fai
bundle sha256 from chain pack. Bundles are unsigned for now per chain
(require_signatures off; FAI_SIGNING_KEY untouched; Recl∆Im key +
per-store pinning later) and staged under store/bundles (gitignored).
Only mirror hosting remains.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:25:39 +02:00
a022f3100a feat(flow): runnable durchstich-full + jurist approval prompt
Earlier durchstich-full never parsed (inline maps in with:). Rewrite
to the runnable step set (normalize/deontic/citations/cohort/frust);
skm/benefit stay documented-out pending the SDK json-input path. Give
the system.approval step a clear prompt (Studio shows it as the
approval-card heading).

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:25:39 +02:00
aabff2b140 feat(ui): scope, freshness and jurist-review badges + live empty-state
Adds three dark-first chip badges in the TierBadge pattern:
- Jurisdiction (Berlin/DE/EU/International) — derived from the ELI,
  surfaced on the norms list, detail header and heatmap chips/hover.
- Freshness (aktuell/geaendert/ungeprueft) — flags norms amended
  upstream (standDate + sourceSha256); renders only when stale.
- Jurist review — marks evaluations a trusted jurist has confirmed
  (optional; the figure stands on its own).
Models gain Jurisdiction/Freshness enums, Norm.freshness/supersededNote
and Evaluation.reviewedBy/reviewedAt; HubRepository parses them from
the flow bag (ELI fallback). HeatmapPage shows a real empty-state
(hub-down vs no-evaluations) instead of a blank plot, and connect()
no longer throws on a failed probe.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:25:39 +02:00
9dfa418e92 fix(ui): never hang on a blank frame when the hub is unreachable
main() awaited the hub health probe before runApp(); a gRPC-web
error out of HubRepository.connect propagated and left the app on a
white page. Guard the settings+probe in main() with a MockRepository
fallback so the first frame always paints.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:25:39 +02:00
136d3b48c6 perf(ui): stop the nav-rail brand pulse to fix web scroll jank
The DeltaMark ran an endless 9s breathing animation (60fps repaint)
inside the persistent nav rail, so it repainted on every scroll
frame and stuttered scrolling on Flutter web. Make the rail mark
static (animated: false) and isolate the animated variant (landing
page) behind a RepaintBoundary.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-19 02:25:38 +02:00
20 changed files with 932 additions and 40 deletions

5
.gitignore vendored
View file

@ -12,3 +12,8 @@ Thumbs.db
# Local-only scratch # Local-only scratch
/scratch/ /scratch/
/.local/ /.local/
.playwright-mcp/
*.log
reclaim-landing.png
app/build/
store/bundles/

View file

@ -26,6 +26,7 @@ class Fixtures {
title: 'Anzeigepflicht', title: 'Anzeigepflicht',
jurabk: 'GewO', jurabk: 'GewO',
paragraph: '§ 14', paragraph: '§ 14',
jurisdiction: Jurisdiction.deutschland,
standDate: DateTime.utc(2025, 12, 1), standDate: DateTime.utc(2025, 12, 1),
sourceUrl: 'https://www.gesetze-im-internet.de/gewo/__14.html', sourceUrl: 'https://www.gesetze-im-internet.de/gewo/__14.html',
sourceSha256: 'demo-${'a' * 60}', sourceSha256: 'demo-${'a' * 60}',
@ -104,6 +105,7 @@ class Fixtures {
title: 'Kassensicherungsverordnung', title: 'Kassensicherungsverordnung',
jurabk: 'KassenSichV', jurabk: 'KassenSichV',
paragraph: 'gesamt', paragraph: 'gesamt',
jurisdiction: Jurisdiction.deutschland,
standDate: DateTime.utc(2025, 11, 15), standDate: DateTime.utc(2025, 11, 15),
sourceUrl: sourceUrl:
'https://www.gesetze-im-internet.de/kassensichv/index.html', 'https://www.gesetze-im-internet.de/kassensichv/index.html',
@ -177,6 +179,8 @@ class Fixtures {
), ),
], ],
auditEventId: 'demo-evt-kassensichv', auditEventId: 'demo-evt-kassensichv',
reviewedBy: 'RA Dr. M. Kessler',
reviewedAt: DateTime.utc(2026, 1, 20),
notes: notes:
'Komponenten-Tier: Initial-Erfüllungsaufwand wurde 2016 im NKR-Stellungnahme-' 'Komponenten-Tier: Initial-Erfüllungsaufwand wurde 2016 im NKR-Stellungnahme-'
'Prozess zum Kassengesetz dokumentiert (T1 für Größenordnung). ' 'Prozess zum Kassengesetz dokumentiert (T1 für Größenordnung). '
@ -197,6 +201,7 @@ class Fixtures {
title: 'Verzeichnis von Verarbeitungstätigkeiten', title: 'Verzeichnis von Verarbeitungstätigkeiten',
jurabk: 'DSGVO', jurabk: 'DSGVO',
paragraph: 'Art. 30', paragraph: 'Art. 30',
jurisdiction: Jurisdiction.eu,
standDate: DateTime.utc(2025, 10, 1), standDate: DateTime.utc(2025, 10, 1),
sourceUrl: 'https://eur-lex.europa.eu/eli/reg/2016/679', sourceUrl: 'https://eur-lex.europa.eu/eli/reg/2016/679',
sourceSha256: 'demo-${'c' * 60}', sourceSha256: 'demo-${'c' * 60}',
@ -277,6 +282,8 @@ class Fixtures {
), ),
], ],
auditEventId: 'demo-evt-dsgvo-30', auditEventId: 'demo-evt-dsgvo-30',
reviewedBy: 'Prof. Dr. A. Lindner',
reviewedAt: DateTime.utc(2026, 2, 3),
notes: notes:
'EU-determiniert: Reform-Vorschlag muss Brüssel adressieren, nicht ' 'EU-determiniert: Reform-Vorschlag muss Brüssel adressieren, nicht '
'DE-Streichung. Komponenten-Tier: P (Fallzahl Verantwortliche) ' 'DE-Streichung. Komponenten-Tier: P (Fallzahl Verantwortliche) '
@ -299,6 +306,7 @@ class Fixtures {
title: 'Bauantrag', title: 'Bauantrag',
jurabk: 'BauO Bln', jurabk: 'BauO Bln',
paragraph: '§ 60 ff', paragraph: '§ 60 ff',
jurisdiction: Jurisdiction.berlin,
standDate: DateTime.utc(2025, 9, 1), standDate: DateTime.utc(2025, 9, 1),
sourceUrl: sourceUrl:
'https://gesetze.berlin.de/bsbe/document/jlr-BauOBE2005V25P60', 'https://gesetze.berlin.de/bsbe/document/jlr-BauOBE2005V25P60',
@ -363,6 +371,8 @@ class Fixtures {
), ),
], ],
auditEventId: 'demo-evt-berlinbauo-60', auditEventId: 'demo-evt-berlinbauo-60',
reviewedBy: 'RAin S. Brandt',
reviewedAt: DateTime.utc(2026, 1, 28),
notes: notes:
'Berlin-spezifisch, daher kein NKR-Erfüllungsaufwand auf Bundesebene. ' 'Berlin-spezifisch, daher kein NKR-Erfüllungsaufwand auf Bundesebene. '
'P aus Berliner Bauaufsichts-Statistik (T2 Handwerkskammer Berlin); ' 'P aus Berliner Bauaufsichts-Statistik (T2 Handwerkskammer Berlin); '
@ -385,9 +395,14 @@ class Fixtures {
title: 'Dokumentationspflichten', title: 'Dokumentationspflichten',
jurabk: 'MiLoG', jurabk: 'MiLoG',
paragraph: '§ 17', paragraph: '§ 17',
jurisdiction: Jurisdiction.deutschland,
standDate: DateTime.utc(2025, 11, 1), standDate: DateTime.utc(2025, 11, 1),
sourceUrl: 'https://www.gesetze-im-internet.de/milog/__17.html', sourceUrl: 'https://www.gesetze-im-internet.de/milog/__17.html',
sourceSha256: 'demo-${'e' * 60}', sourceSha256: 'demo-${'e' * 60}',
freshness: Freshness.superseded,
supersededNote: 'BEG IV hat die Dokumentationspflichten zum '
'1.1.2026 entschärft — diese Auswertung bildet die Fassung '
'davor ab und ist neu zu bewerten.',
paragraphs: const [ paragraphs: const [
NormParagraph( NormParagraph(
number: '(1)', number: '(1)',

View file

@ -36,10 +36,21 @@ class HubRepository implements EvaluationRepository {
endpoint: endpoint, endpoint: endpoint,
authToken: settings.authToken, authToken: settings.authToken,
); );
final ok = await client.healthy().timeout( // 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), const Duration(seconds: 4),
onTimeout: () => false, onTimeout: () => false,
); );
} catch (_) {
ok = false;
}
return HubRepository._(client, settings, ok); return HubRepository._(client, settings, ok);
} }
@ -97,14 +108,21 @@ class HubRepository implements EvaluationRepository {
if (normJson == null) return null; if (normJson == null) return null;
final skmJson = _asMap(bag['skm']); final skmJson = _asMap(bag['skm']);
final eli = (normJson['eli'] as String?) ?? 'eli/unknown';
final norm = Norm( final norm = Norm(
eli: (normJson['eli'] as String?) ?? 'eli/unknown', eli: eli,
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(
normJson['scope'] as String?,
eli: eli,
),
standDate: _parseTimestamp(event.timestamp), standDate: _parseTimestamp(event.timestamp),
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']),
); );
@ -138,9 +156,26 @@ class HubRepository implements EvaluationRepository {
auditEventId: event.eventId, auditEventId: event.eventId,
notes: 'Live aus Hub-Flow ${event.flowName} ' notes: 'Live aus Hub-Flow ${event.flowName} '
'(execution ${event.flowExecution.substring(0, 8.clamp(0, event.flowExecution.length))}).', '(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?) ?? ''),
); );
} }
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;
}
}
static Map<String, dynamic>? _asMap(dynamic v) => static Map<String, dynamic>? _asMap(dynamic v) =>
v is Map<String, dynamic> ? v : null; v is Map<String, dynamic> ? v : null;

View file

@ -51,6 +51,138 @@ extension EvidenceTierLabel on EvidenceTier {
}; };
} }
/// Geltungsbereich the geographic/legal scope a norm belongs to.
/// Nested by widening reach: Berlin Deutschland EU Welt.
/// Every norm carries one so the UI can flag, at a glance, *which*
/// reform lever a figure speaks to (Bezirk/Senat, Bund, Brüssel,
/// or völkerrechtlich) see Studie §5.3.
enum Jurisdiction {
/// Landesrecht Berlin (BauO Bln, ASOG, ) Senat/Bezirk lever.
berlin,
/// Bundesrecht (GewO, MiLoG, KassenSichV) NKR/Bundestag lever.
deutschland,
/// EU-Recht (DSGVO, REFIT) Reform-Hebel liegt in Brüssel.
eu,
/// Völker-/Welthandelsrecht (WTO, OECD, UN) international.
international,
}
extension JurisdictionLabel on Jurisdiction {
/// Kompakt-Code für Chips und enge Layouts.
String get short => switch (this) {
Jurisdiction.berlin => 'BE',
Jurisdiction.deutschland => 'DE',
Jurisdiction.eu => 'EU',
Jurisdiction.international => 'INT',
};
/// Voll ausgeschriebener Name.
String get label => switch (this) {
Jurisdiction.berlin => 'Berlin',
Jurisdiction.deutschland => 'Deutschland',
Jurisdiction.eu => 'EU',
Jurisdiction.international => 'International',
};
/// Rechtsebene für Tooltip und Legend.
String get level => switch (this) {
Jurisdiction.berlin => 'Landesrecht Berlin',
Jurisdiction.deutschland => 'Bundesrecht',
Jurisdiction.eu => 'Recht der Europäischen Union',
Jurisdiction.international => 'Völker-/Welthandelsrecht',
};
/// Wo der Reform-Hebel ansetzt die handlungsleitende Aussage.
String get reformLever => switch (this) {
Jurisdiction.berlin =>
'Reform-Hebel: Senat / Bezirk (Land Berlin)',
Jurisdiction.deutschland =>
'Reform-Hebel: Bundestag / NKR (Bund)',
Jurisdiction.eu =>
'Reform-Hebel: Brüssel — DE-Streichung allein wirkt nicht',
Jurisdiction.international =>
'Reform-Hebel: völkerrechtlich (WTO / OECD / UN)',
};
/// Ableitung aus der ELI/CELEX-Kennung, wenn kein Feld gesetzt
/// ist (Hub-Live-Modus liefert die ELI, nicht immer den Scope).
/// eli/eu/ eu
/// eli/land/be/ berlin
/// eli/bund/ deutschland
/// eli/int|un|wto/ international
static Jurisdiction fromEli(String eli) {
final e = eli.toLowerCase();
if (e.contains('/eu/') || e.startsWith('eli/eu') || e.contains('celex')) {
return Jurisdiction.eu;
}
if (e.contains('/land/be') || e.contains('/land/berlin') ||
e.contains('/be/')) {
return Jurisdiction.berlin;
}
if (e.contains('/int/') || e.contains('/un/') || e.contains('/wto/') ||
e.contains('/oecd/')) {
return Jurisdiction.international;
}
return Jurisdiction.deutschland;
}
/// Parse a serialized scope string from a hub flow bag, falling
/// back to ELI-derivation when absent or unrecognized.
static Jurisdiction parse(String? raw, {required String eli}) {
switch (raw?.trim().toLowerCase()) {
case 'berlin' || 'be' || 'land-berlin':
return Jurisdiction.berlin;
case 'deutschland' || 'de' || 'bund' || 'bundesrecht':
return Jurisdiction.deutschland;
case 'eu' || 'europa' || 'union':
return Jurisdiction.eu;
case 'international' || 'int' || 'welt' || 'global':
return Jurisdiction.international;
default:
return fromEli(eli);
}
}
}
/// Aktualität einer Norm-Fassung relativ zu unserer Datenbasis.
/// Quelle der Wahrheit: `Norm.sourceSha256` gegen die aktuell
/// veröffentlichte Quelle + `standDate`. Surfaced als Badge, damit
/// eine veraltete Auswertung sofort erkennbar ist (Studie §5.3).
enum Freshness {
/// Unsere Fassung == aktuell veröffentlichter Stand.
current,
/// Upstream geändert/aufgehoben unsere Auswertung ist veraltet.
superseded,
/// Noch nicht gegen die Quelle abgeglichen.
unknown,
}
extension FreshnessLabel on Freshness {
String get label => switch (this) {
Freshness.current => 'aktuell',
Freshness.superseded => 'geändert',
Freshness.unknown => 'ungeprüft',
};
String get description => switch (this) {
Freshness.current =>
'Fassung entspricht dem aktuell veröffentlichten Stand.',
Freshness.superseded =>
'Quelle wurde upstream geändert — Auswertung veraltet, '
'Neubewertung nötig.',
Freshness.unknown =>
'Aktualität nicht gegen die Quelle abgeglichen.',
};
/// Stale-Zustände bekommen ein sichtbares Badge; `current` nicht.
bool get needsBadge => this != Freshness.current;
}
/// A citable source never an LLM, always a public document or /// A citable source never an LLM, always a public document or
/// dataset reference. UI surfaces these next to every figure. /// dataset reference. UI surfaces these next to every figure.
class Source { class Source {
@ -107,9 +239,12 @@ class Norm {
required this.title, required this.title,
required this.jurabk, required this.jurabk,
required this.paragraph, required this.paragraph,
required this.jurisdiction,
required this.standDate, required this.standDate,
required this.sourceUrl, required this.sourceUrl,
required this.sourceSha256, required this.sourceSha256,
this.freshness = Freshness.current,
this.supersededNote,
this.paragraphs = const [], this.paragraphs = const [],
}); });
@ -117,10 +252,23 @@ class Norm {
final String title; final String title;
final String jurabk; final String jurabk;
final String paragraph; final String paragraph;
/// Geltungsbereich Berlin / Deutschland / EU / International.
/// Surfaced as a scope badge wherever the norm appears.
final Jurisdiction jurisdiction;
final DateTime standDate; final DateTime standDate;
final String sourceUrl; final String sourceUrl;
final String sourceSha256; final String sourceSha256;
/// Aktualität dieser Fassung gegenüber der Quelle.
final Freshness freshness;
/// Optionaler Klartext-Hinweis, *was* sich geändert hat (z. B.
/// "BEG IV hat §17 zum 1.1.2026 entschärft"). Nur gesetzt, wenn
/// freshness != current.
final String? supersededNote;
/// The actual paragraphs of the norm text, normalised by /// The actual paragraphs of the norm text, normalised by
/// `text.akoma_normalize` in live mode. In demo mode the /// `text.akoma_normalize` in live mode. In demo mode the
/// fixture quotes the public source verbatim under §5 UrhG /// fixture quotes the public source verbatim under §5 UrhG
@ -143,6 +291,8 @@ class Evaluation {
required this.auditEventId, required this.auditEventId,
this.notes, this.notes,
this.componentSummary = const [], this.componentSummary = const [],
this.reviewedBy,
this.reviewedAt,
}); });
final Norm norm; final Norm norm;
@ -179,4 +329,15 @@ class Evaluation {
/// extras each carries its tier-tag inline. Empty list /// extras each carries its tier-tag inline. Empty list
/// fall back to the notes field for the list view. /// fall back to the notes field for the list view.
final List<String> componentSummary; final List<String> componentSummary;
/// Jurist who confirmed this evaluation (the `by` of the hub
/// approval). Null not yet reviewed. Jurist review is OPTIONAL:
/// the figure stands on its own; a review only *confirms* it.
final String? reviewedBy;
/// When the jurist signed off. Null not reviewed.
final DateTime? reviewedAt;
/// True once a trusted jurist has confirmed this evaluation.
bool get isReviewed => reviewedBy != null;
} }

View file

@ -9,8 +9,22 @@ import 'theme/reclaim_theme.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final settings = await HubSettings.load(); // Acquiring settings + probing the hub must never prevent the
final repository = await _pickRepository(settings); // first frame. Any failure here falls back to demo data so the
// app always paints instead of hanging on a blank page.
HubSettings settings;
EvaluationRepository repository;
try {
settings = await HubSettings.load();
repository = await _pickRepository(settings);
} catch (_) {
settings = const HubSettings(
host: HubSettings.defaultHost,
port: HubSettings.defaultPort,
secure: false,
);
repository = const MockRepository();
}
runApp(ReclaimApp(repository: repository, settings: settings)); runApp(ReclaimApp(repository: repository, settings: settings));
} }

View file

@ -5,6 +5,7 @@ import '../data/repository.dart';
import '../theme/reclaim_tokens.dart'; import '../theme/reclaim_tokens.dart';
import '../widgets/mode_banner.dart'; import '../widgets/mode_banner.dart';
import '../widgets/heatmap_grid.dart'; import '../widgets/heatmap_grid.dart';
import '../widgets/jurisdiction_badge.dart';
import '../widgets/reclaim_card.dart'; import '../widgets/reclaim_card.dart';
import '../widgets/tier_badge.dart'; import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart'; import 'norm_detail_page.dart';
@ -59,7 +60,9 @@ class _HeatmapPageState extends State<HeatmapPage> {
), ),
const SizedBox(height: ReclaimSpace.lg), const SizedBox(height: ReclaimSpace.lg),
Expanded( Expanded(
child: ReclaimCard( child: items.isEmpty
? _EmptyHubState(repository: widget.repository)
: ReclaimCard(
accent: true, accent: true,
expand: true, expand: true,
child: HeatmapGrid( child: HeatmapGrid(
@ -77,6 +80,7 @@ class _HeatmapPageState extends State<HeatmapPage> {
), ),
), ),
), ),
if (items.isNotEmpty) ...[
const SizedBox(height: ReclaimSpace.md), const SizedBox(height: ReclaimSpace.md),
const _LegendCard(), const _LegendCard(),
const SizedBox(height: ReclaimSpace.lg), const SizedBox(height: ReclaimSpace.lg),
@ -104,6 +108,11 @@ class _HeatmapPageState extends State<HeatmapPage> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
JurisdictionBadge(
jurisdiction: e.norm.jurisdiction,
compact: true),
const SizedBox(
width: ReclaimSpace.xs),
TierBadge( TierBadge(
tier: e.tierLowest, compact: true), tier: e.tierLowest, compact: true),
const SizedBox( const SizedBox(
@ -132,6 +141,7 @@ class _HeatmapPageState extends State<HeatmapPage> {
], ],
), ),
], ],
],
); );
}, },
), ),
@ -196,6 +206,8 @@ class _LegendCardState extends State<_LegendCard> {
symbol: '', text: 'Betroffenheit'), symbol: '', text: 'Betroffenheit'),
_MiniLabel( _MiniLabel(
symbol: '', text: 'Evidenz-Stufe'), symbol: '', text: 'Evidenz-Stufe'),
_MiniLabel(
symbol: '', text: 'Geltungsbereich'),
], ],
), ),
), ),
@ -272,6 +284,21 @@ class _LegendCardState extends State<_LegendCard> {
'SKM-Zahl T4. Petrol → warm = stark → ' 'SKM-Zahl T4. Petrol → warm = stark → '
'schwach.', 'schwach.',
), ),
SizedBox(height: ReclaimSpace.sm),
_LegendRow(
symbol: '',
axis: 'Chip-Symbol',
label: 'Geltungsbereich',
description:
'Jede Norm trägt ein Scope-Chip — '
'BE Berlin (Landesrecht, Senat/Bezirk), '
'DE Deutschland (Bundesrecht, NKR/'
'Bundestag), EU (Reform-Hebel in Brüssel), '
'INT International (WTO/OECD/UN). Es zeigt '
'an, auf welcher Ebene der Reform-Hebel '
'ansetzt: eine EU-Norm lässt sich nicht '
'durch nationale Streichung entlasten.',
),
], ],
), ),
) )
@ -285,6 +312,69 @@ class _LegendCardState extends State<_LegendCard> {
} }
} }
/// Shown in place of the heatmap when the repository returns zero
/// evaluations almost always live-hub mode against a hub that
/// has no ReclIm `flow.completed` events yet (or is unreachable).
/// Tells the reader exactly how to get data on screen instead of
/// leaving a blank plot.
class _EmptyHubState extends StatelessWidget {
const _EmptyHubState({required this.repository});
final EvaluationRepository repository;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final down = repository.mode == 'hub-down';
return ReclaimCard(
accent: true,
expand: true,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
down ? Icons.cloud_off_outlined : Icons.inbox_outlined,
size: 40,
color: ReclaimColors.mute,
),
const SizedBox(height: ReclaimSpace.md),
Text(
down
? 'Hub nicht erreichbar'
: 'Noch keine Auswertungen im Hub',
style: t.headlineSmall,
),
const SizedBox(height: ReclaimSpace.sm),
Text(
down
? 'Verbunden mit ${repository.hubLabel}. Läuft '
'`chain serve`? Sonst im Hub-Reiter den '
'Demo-Modus aktivieren — dann zeigt die '
'Heatmap das Fixture-Korpus.'
: 'Der Hub liefert keine flow.completed-Events. '
'Einen Recl∆Im-Flow laufen lassen, z. B.\n'
' chain run flows/durchstich-from-file.yaml '
'--input content=@norm.xml --input '
'cohort_id=berlin-kmu\n'
'und im Hub/Studio freigeben — oder im '
'Hub-Reiter den Demo-Modus aktivieren.',
style: t.bodyLarge?.copyWith(
color: ReclaimColors.mute,
height: 1.4,
),
),
],
),
),
),
);
}
}
class _MiniLabel extends StatelessWidget { class _MiniLabel extends StatelessWidget {
const _MiniLabel({required this.symbol, required this.text}); const _MiniLabel({required this.symbol, required this.text});

View file

@ -5,8 +5,11 @@ import '../data/repository.dart';
import '../theme/reclaim_tokens.dart'; import '../theme/reclaim_tokens.dart';
import '../widgets/mode_banner.dart'; import '../widgets/mode_banner.dart';
import '../widgets/evidence_sidebar.dart'; import '../widgets/evidence_sidebar.dart';
import '../widgets/freshness_badge.dart';
import '../widgets/jurisdiction_badge.dart';
import '../widgets/norm_text_card.dart'; import '../widgets/norm_text_card.dart';
import '../widgets/reclaim_card.dart'; import '../widgets/reclaim_card.dart';
import '../widgets/review_badge.dart';
import '../widgets/tier_badge.dart'; import '../widgets/tier_badge.dart';
class NormDetailPage extends StatelessWidget { class NormDetailPage extends StatelessWidget {
@ -63,7 +66,27 @@ class NormDetailPage extends StatelessWidget {
], ],
), ),
), ),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: [
JurisdictionBadge(
jurisdiction: e.norm.jurisdiction),
const SizedBox(height: ReclaimSpace.sm),
TierBadge(tier: e.tierLowest), TierBadge(tier: e.tierLowest),
if (e.norm.freshness.needsBadge) ...[
const SizedBox(
height: ReclaimSpace.sm),
FreshnessBadge(
freshness: e.norm.freshness,
note: e.norm.supersededNote,
),
],
const SizedBox(height: ReclaimSpace.sm),
ReviewBadge(
evaluation: e, showUnreviewed: true),
],
),
], ],
), ),
const SizedBox(height: ReclaimSpace.lg), const SizedBox(height: ReclaimSpace.lg),

View file

@ -4,7 +4,10 @@ import '../data/models.dart';
import '../data/repository.dart'; import '../data/repository.dart';
import '../theme/reclaim_tokens.dart'; import '../theme/reclaim_tokens.dart';
import '../widgets/mode_banner.dart'; import '../widgets/mode_banner.dart';
import '../widgets/freshness_badge.dart';
import '../widgets/jurisdiction_badge.dart';
import '../widgets/reclaim_card.dart'; import '../widgets/reclaim_card.dart';
import '../widgets/review_badge.dart';
import '../widgets/tier_badge.dart'; import '../widgets/tier_badge.dart';
import 'norm_detail_page.dart'; import 'norm_detail_page.dart';
@ -108,7 +111,20 @@ class _NormRow extends StatelessWidget {
), ),
), ),
const SizedBox(height: ReclaimSpace.sm), const SizedBox(height: ReclaimSpace.sm),
Wrap(
spacing: ReclaimSpace.sm,
runSpacing: ReclaimSpace.xs,
children: [
JurisdictionBadge(
jurisdiction: evaluation.norm.jurisdiction),
TierBadge(tier: evaluation.tierLowest), TierBadge(tier: evaluation.tierLowest),
FreshnessBadge(
freshness: evaluation.norm.freshness,
note: evaluation.norm.supersededNote,
),
ReviewBadge(evaluation: evaluation),
],
),
], ],
), ),
), ),

View file

@ -50,7 +50,11 @@ class _ShellPageState extends State<ShellPage> {
), ),
child: Column( child: Column(
children: [ children: [
const DeltaMark(size: 36), // Static in the persistent nav rail: a 60fps pulse
// here repaints every scroll frame and stutters web
// scrolling. The breathing mark stays on the
// landing page (which isn't scrolled).
const DeltaMark(size: 36, animated: false),
const SizedBox(height: ReclaimSpace.sm), const SizedBox(height: ReclaimSpace.sm),
const BrandWordmark(size: 18), const BrandWordmark(size: 18),
Text( Text(

View file

@ -36,6 +36,23 @@ class ReclaimColors {
static const tierT2 = Color(0xFF6CA29A); // verband static const tierT2 = Color(0xFF6CA29A); // verband
static const tierT3 = Color(0xFFB0AC8E); // own survey static const tierT3 = Color(0xFFB0AC8E); // own survey
static const tierT4 = Color(0xFFA86F5C); // qualitative signal only static const tierT4 = Color(0xFFA86F5C); // qualitative signal only
// Scope badges Geltungsbereich of a norm. Hues widen from a
// warm-local Berlin to a cool-global teal, distinct from the
// tier palette so the two chips never read as the same thing.
// Tuned for dark-first legibility on the ink canvas (bright
// enough to read as chip text/border over inkRaised).
static const scopeBerlin = Color(0xFFD0685C); // Land Berlin (brick)
static const scopeDeutschland = Color(0xFFC79A3C); // Bund (gold)
static const scopeEu = Color(0xFF5B8DD6); // EU (union blue)
static const scopeInternational = Color(0xFF4FB89E); // global (teal)
// Freshness is our norm version current vs. the published source.
static const freshSuperseded = Color(0xFFE0973A); // amended upstream
static const freshUnknown = Color(0xFF9AA0A6); // not reconciled
// Jurist review an evaluation confirmed (signed off) by a jurist.
static const reviewConfirmed = Color(0xFF6FBE7A); // confirm green
} }
/// Spacing scale 4/8/12/16/24/32/48 multiples, matching /// Spacing scale 4/8/12/16/24/32/48 multiples, matching

View file

@ -46,17 +46,30 @@ class _DeltaMarkState extends State<DeltaMark>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( // Static mark: no controller listening, paint once.
if (!widget.animated) {
return CustomPaint(
size: Size.square(widget.size),
painter: _DeltaPainter(pulse: 1.0),
);
}
// Animated: isolate the per-frame repaint in its own layer so
// the breathing pulse never invalidates surrounding content
// (web scroll stays smooth).
return RepaintBoundary(
child: AnimatedBuilder(
animation: _controller, animation: _controller,
builder: (context, _) { builder: (context, _) {
final t = _controller.value; final t = _controller.value;
// Breathe: scale 1 1.28 1 over the cycle. // Breathe: scale 1 1.28 1 over the cycle.
final pulse = 1.0 + 0.28 * (1 - (math.cos(t * 2 * math.pi).abs())); final pulse =
1.0 + 0.28 * (1 - (math.cos(t * 2 * math.pi).abs()));
return CustomPaint( return CustomPaint(
size: Size.square(widget.size), size: Size.square(widget.size),
painter: _DeltaPainter(pulse: widget.animated ? pulse : 1.0), painter: _DeltaPainter(pulse: pulse),
); );
}, },
),
); );
} }
} }

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/reclaim_tokens.dart';
/// Freshness badge flags when a norm version is stale relative to
/// the published source (amended upstream) or hasn't been reconciled.
/// By design it renders NOTHING for [Freshness.current] (the common
/// case shouldn't add noise) unless [alwaysShow] is set. Same chip
/// shape as the scope/tier badges.
class FreshnessBadge extends StatelessWidget {
const FreshnessBadge({
super.key,
required this.freshness,
this.note,
this.compact = false,
this.alwaysShow = false,
});
final Freshness freshness;
/// Optional plain-text hint of what changed (from Norm.supersededNote).
final String? note;
final bool compact;
final bool alwaysShow;
@override
Widget build(BuildContext context) {
if (!freshness.needsBadge && !alwaysShow) {
return const SizedBox.shrink();
}
final t = Theme.of(context).textTheme;
final color = switch (freshness) {
Freshness.current => ReclaimColors.signal,
Freshness.superseded => ReclaimColors.freshSuperseded,
Freshness.unknown => ReclaimColors.freshUnknown,
};
final icon = switch (freshness) {
Freshness.current => Icons.check_circle_outline,
Freshness.superseded => Icons.history_toggle_off,
Freshness.unknown => Icons.help_outline,
};
final label = compact ? freshness.label : 'Stand: ${freshness.label}';
return Tooltip(
message: '${freshness.description}'
'${note != null && note!.isNotEmpty ? '\n$note' : ''}',
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : ReclaimSpace.sm,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
border: Border.all(color: color.withValues(alpha: 0.55)),
borderRadius: const BorderRadius.all(ReclaimRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: compact ? 12 : 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: (compact ? t.labelSmall : t.labelLarge)?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}

View file

@ -267,6 +267,7 @@ class _HeatmapPainter extends CustomPainter {
); );
_drawText( _drawText(
canvas, canvas,
'Bereich: ${e.norm.jurisdiction.short} '
'Tier: ${e.tierLowest.short} Klick → Detail', 'Tier: ${e.tierLowest.short} Klick → Detail',
box.topLeft + const Offset(10, 44), box.topLeft + const Offset(10, 44),
const TextStyle( const TextStyle(

View file

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/reclaim_tokens.dart';
/// Scope badge surfaces a norm's Geltungsbereich (Berlin /
/// Deutschland / EU / International) wherever the norm appears, so
/// the reader sees at a glance which reform lever a figure speaks
/// to. Visually mirrors [TierBadge]: a scope glyph + code in a
/// tinted chip, here with a Material icon instead of a dot.
class JurisdictionBadge extends StatelessWidget {
const JurisdictionBadge({
super.key,
required this.jurisdiction,
this.compact = false,
});
final Jurisdiction jurisdiction;
final bool compact;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final color = switch (jurisdiction) {
Jurisdiction.berlin => ReclaimColors.scopeBerlin,
Jurisdiction.deutschland => ReclaimColors.scopeDeutschland,
Jurisdiction.eu => ReclaimColors.scopeEu,
Jurisdiction.international => ReclaimColors.scopeInternational,
};
final icon = switch (jurisdiction) {
Jurisdiction.berlin => Icons.location_city_outlined,
Jurisdiction.deutschland => Icons.flag_outlined,
Jurisdiction.eu => Icons.star_outline_rounded,
Jurisdiction.international => Icons.public,
};
final label = compact
? jurisdiction.short
: '${jurisdiction.short} · ${jurisdiction.label}';
return Tooltip(
message: 'Geltungsbereich: ${jurisdiction.label} '
'(${jurisdiction.level}).\n${jurisdiction.reformLever}.',
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : ReclaimSpace.sm,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
border: Border.all(color: color.withValues(alpha: 0.55)),
borderRadius: const BorderRadius.all(ReclaimRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: compact ? 12 : 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: (compact ? t.labelSmall : t.labelLarge)?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../theme/reclaim_tokens.dart';
/// Review badge marks an evaluation a trusted jurist has confirmed
/// (signed off via the hub approval). Jurist review is OPTIONAL: an
/// evaluation stands on its own; this only adds the "bestätigt" mark.
/// Renders a confirm-green check when reviewed; renders the muted
/// "ungeprüft" state only when [showUnreviewed] is set (otherwise
/// nothing, to keep unreviewed rows clean).
class ReviewBadge extends StatelessWidget {
const ReviewBadge({
super.key,
required this.evaluation,
this.compact = false,
this.showUnreviewed = false,
});
final Evaluation evaluation;
final bool compact;
final bool showUnreviewed;
@override
Widget build(BuildContext context) {
final reviewed = evaluation.isReviewed;
if (!reviewed && !showUnreviewed) return const SizedBox.shrink();
final t = Theme.of(context).textTheme;
final color =
reviewed ? ReclaimColors.reviewConfirmed : ReclaimColors.mute;
final icon = reviewed
? Icons.verified_outlined
: Icons.radio_button_unchecked;
final label = reviewed
? (compact ? 'geprüft' : 'Jurist-geprüft')
: (compact ? 'offen' : 'ungeprüft');
final tip = reviewed
? 'Bestätigt von ${evaluation.reviewedBy}'
'${evaluation.reviewedAt != null ? ' am ${_d(evaluation.reviewedAt!)}' : ''}.\n'
'Jurist-Review ist optional — es bestätigt die Auswertung, '
'ist aber keine Voraussetzung.'
: 'Noch von keinem Juristen bestätigt. Die Auswertung gilt '
'trotzdem (Review ist optional).';
return Tooltip(
message: tip,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : ReclaimSpace.sm,
vertical: compact ? 2 : 4,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
border: Border.all(color: color.withValues(alpha: 0.55)),
borderRadius: const BorderRadius.all(ReclaimRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: compact ? 12 : 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: (compact ? t.labelSmall : t.labelLarge)?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
static String _d(DateTime d) =>
'${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}';
}

View file

@ -0,0 +1,92 @@
# Vertikaler Durchstich, Vollversion (so weit heute lauffähig).
#
# Erweitert durchstich-from-file um die Analyse-Module, deren
# Eingaben sich aus vorhergehenden Step-Outputs speisen lassen
# (alle als $ref) — Pflichten, Zitationsgraph, Lesbarkeit/Frust.
# Das flow.completed-Event trägt damit norm + duties + citations
# + frust + cohort, also die Form, die HubRepository.list() in der
# Recl∆Im-App in Evaluation-Objekte übersetzt.
#
# NICHT enthalten: econ.skm_score und law.benefit_score. Beide
# Module lesen ihren Tupel-/Ratings-Input über `require_json`,
# d.h. sie erwarten ein Payload::Json. In einem Flow lässt sich
# ein Json-Wert aber nur aus einem vorhergehenden Step-Output
# beziehen — `chain run --input name=value` erzeugt Text,
# `--input name=@file` erzeugt Bytes, nie Json, und ein Inline-
# Map-Literal in `with:` lehnt der Flow-Parser ab ("expected a
# string"). Es gibt heute also keinen Weg, skm/benefit aus CLI-
# Inputs zu treiben. Siehe Handoff an chain (json-CLI-Input).
# Sobald der Plattform-Fix steht, die skm-/benefit-Steps unten
# einkommentieren (Form: `with: { duties: $inputs.duties }` mit
# `inputs: { duties: json }`).
#
# Aufruf:
# chain run flows/durchstich-full.yaml \
# --input content=@/tmp/gewo-14-mini.xml \
# --input cohort_id=berlin-kmu
# (pausiert am system.approval-Gate — danach via Hub/Studio
# freigeben, dann wird das flow.completed-Event geschrieben.)
name: durchstich-full
inputs:
content: bytes
cohort_id: text
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
# Pending platform json-CLI-input (Handoff → chain). Form, die
# nach dem Fix gilt — `inputs:` oben um `duties: json` /
# `ratings: json` ergänzen und mit --input duties=… speisen:
# - id: skm
# use: econ.skm_score@^0
# with:
# duties: $inputs.duties
# - id: benefit
# use: law.benefit_score@^0
# with:
# ratings: $inputs.ratings
- id: review
use: system.approval@^0
with:
title: "Recl∆Im — Review GewO §14 (Live)"
prompt: >-
Juristische Freigabe: Stimmen die extrahierten Pflichten, die
Zitationen und die Norm-Wiedergabe mit dem geltenden Gesetzestext
überein? Quellen siehe Payload. Bei Freigabe gilt die Auswertung
als juristisch bestätigt.
payload: $normalize.norm
outputs:
norm: $normalize.norm
frust: $frust.score
cohort: $cohort.cohort
duties: $deontic.duties
citations: $citations.citations

1
store/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
bundles/

9
store/SHA256.local.txt Normal file
View file

@ -0,0 +1,9 @@
# Local module.wasm sha256 (cross-check; canonical sha must come from the hosted .fai bundle)
0fd01a7aa7ca2a47aa27bea7b30f8b8fe2ffc551728694c5dfbadb75bc37e91d text-akoma-normalize
722da24ebc239015af344b66a438097ef607b965badb43f91e43c4b1a9fdcc19 text-deontic-extract
3f4976db72bd7410e83b0694de3e91c09b8829d3d73fa48ddf806c170cc5c602 graph-citation-extract
ba95c610cec71a694f0c33002de836e61c1bd9b9767d014e842e083cd3eead2c text-readability-score
bb722a4416e26cc58cf3d01e5f9aaf27c61f9fa2cb86983662ee16f0c68945d9 stats-cohort-size
aefa3588c33e6977ce26d4f885d4fd75ea46ee6f479ecf1e57c1ebd33b6872a1 graph-shapley-attribution
3b63c8ae21d82da0fb47e160889fa93b34f132947c461e5c199936207a2cb6a9 econ-skm-score
1275295ec9d61cccaaeb418a580637dc6ca2d8e6b5aa25937d9471b1f534cad9 law-benefit-score

9
store/SHA256.txt Normal file
View file

@ -0,0 +1,9 @@
# .fai bundle sha256 (unsigned, chain pack 0.17.2) — 2026-06-19T00:12Z
15fa6d43bf6d18193282cb1c94c273b958e2639d91d52120eaedd83a99571765 text-akoma-normalize-0.1.0.fai
d03d3dc2bfbaa413ea4e192c6147595e21d8dafd24b5d32b853a03a38e48100d text-deontic-extract-0.1.0.fai
9bebd3dc877da9e3c89b521572000f14f007c6a9edfcc350edd44377d68acba3 graph-citation-extract-0.1.0.fai
5f1a0098bc4c3be8d20b34d848af884b539b0eda3c59ab56dfe99bf8f811090d text-readability-score-0.1.0.fai
31d35f6a7b61b9249520e3ae02fa398292375555430714b04ff0771fd2f3ad99 stats-cohort-size-0.1.0.fai
199927a6b8594da579a22d42b1ba438f4b2b6c90132a5c097c7c1d5eb8b9c319 graph-shapley-attribution-0.1.0.fai
2b887970d448c07cc53633124518d6f96672b0710d9e89fe4f9d6a236035f25e econ-skm-score-0.1.0.fai
4a82a07ba72cae4b49f1ff883b1ca92638e5f4ea19bc2b070288142056764025 law-benefit-score-0.1.0.fai

167
store/store.yaml Normal file
View file

@ -0,0 +1,167 @@
# Recl∆Im module store index — source of truth before publish.
#
# Target host (chain Studio's one-click "Recl∆Im hinzufügen" button
# points here): https://releases.chain.flemming.ai/reclaim/store.yaml
#
# STATUS (2026-06-19). Per chain: publish UNSIGNED for now —
# `require_signatures` is off by default; FAI_SIGNING_KEY untouched;
# a Recl∆Im-own signing key + per-store pinning come later.
# * sha256 — REAL, from local `chain pack` of each module.
# Bundles staged in store/bundles/, hashes mirrored
# in store/SHA256.txt. Bundles are UNSIGNED.
# * signature — intentionally absent for now (see above).
# * wasm_url — points at the intended mirror path. The ONLY
# remaining step is HOSTING the 8 .fai + this
# store.yaml at releases.chain.flemming.ai/reclaim/
# — the mirror push is chain infra (scp to the
# Hetzner host); reclaim has no SSH access. Confirm
# the canonical wasm_url host (mirror vs Forgejo
# release) with chain.
#
# NAMING NOTE for chain: every module's `provides.capability` uses an
# UNDERSCORE (`econ.skm_score`), but this index + the Studio button use
# the DASH form (`econ.skm-score`) per the handoff. install resolution
# must map dash↔underscore, or the modules must be renamed. Flagging —
# this is a chain naming-convention decision.
schema_version: 1
modules:
- name: text.akoma-normalize
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/text-akoma-normalize/v0.1.0/text-akoma-normalize-0.1.0.fai
sha256: 15fa6d43bf6d18193282cb1c94c273b958e2639d91d52120eaedd83a99571765
tagline:
en: "Normalize legal documents to Akoma Ntoso"
de: "Rechtsdokumente nach Akoma Ntoso normalisieren"
description:
en: "Normalises heterogeneous legal sources (gesetze-im-internet XML, EUR-Lex FORMEX, landesrecht-berlin HTML) into a structured Akoma Ntoso 1.0 (OASIS LegalDocML) representation."
de: "Normalisiert heterogene Rechtsquellen (gesetze-im-internet-XML, EUR-Lex-FORMEX, landesrecht-berlin-HTML) in eine strukturierte Akoma-Ntoso-1.0-Darstellung (OASIS LegalDocML)."
category: text-processing
tags: [legal, german, akoma-ntoso, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/text-akoma-normalize
- name: text.deontic-extract
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/text-deontic-extract/v0.1.0/text-deontic-extract-0.1.0.fai
sha256: d03d3dc2bfbaa413ea4e192c6147595e21d8dafd24b5d32b853a03a38e48100d
tagline:
en: "Extract duties, prohibitions and permissions from norms"
de: "Pflichten, Verbote und Erlaubnisse aus Normen extrahieren"
description:
en: "Extracts atomic deontic statements (obligation / prohibition / permission) from a normalised norm. Useful far beyond law — contracts, ISO standards, internal policies."
de: "Extrahiert atomare deontische Aussagen (Pflicht / Verbot / Erlaubnis) aus einer normalisierten Norm. Auch jenseits des Rechts nützlich — Verträge, ISO-Normen, interne Richtlinien."
category: text-processing
tags: [legal, german, nlp, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/text-deontic-extract
- name: graph.citation-extract
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/graph-citation-extract/v0.1.0/graph-citation-extract-0.1.0.fai
sha256: 9bebd3dc877da9e3c89b521572000f14f007c6a9edfcc350edd44377d68acba3
tagline:
en: "Build typed citation graphs from legal text"
de: "Typisierte Zitationsgraphen aus Rechtstext bauen"
description:
en: "Extracts typed citations (cites, verweist_iVm, …) from German legal text per the Coupette 2021 schema. Citation graphs also fit scientific papers, patents and case law."
de: "Extrahiert typisierte Zitationen (cites, verweist_iVm, …) aus deutschem Rechtstext nach dem Coupette-2021-Schema. Zitationsgraphen passen auch zu Papers, Patenten und Rechtsprechung."
category: data
tags: [legal, german, graph, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/graph-citation-extract
- name: text.readability-score
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/text-readability-score/v0.1.0/text-readability-score-0.1.0.fai
sha256: 5f1a0098bc4c3be8d20b34d848af884b539b0eda3c59ab56dfe99bf8f811090d
tagline:
en: "Composite readability and complexity score"
de: "Komposit-Score für Lesbarkeit und Komplexität"
description:
en: "Composite readability score for any text — DE-adapted Flesch + reference depth + verb distance. Flags hard-to-read government letters, terms & conditions, abstracts."
de: "Komposit-Lesbarkeitsscore für beliebige Texte — DE-adaptierter Flesch + Verweistiefe + Verbdistanz. Markiert schwer lesbare Behördenbriefe, AGB und Abstracts."
category: text-processing
tags: [german, readability, analytics, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/text-readability-score
- name: stats.cohort-size
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/stats-cohort-size/v0.1.0/stats-cohort-size-0.1.0.fai
sha256: 31d35f6a7b61b9249520e3ae02fa398292375555430714b04ff0771fd2f3ad99
tagline:
en: "Resolve addressee cohort sizes"
de: "Größe von Adressaten-Kohorten ermitteln"
description:
en: "Resolves the size of an addressee cohort (e.g. 'Berlin SMEs by WZ code'). v0.1 ships a stub table for the pilot cohorts; v0.2 adds a DESTATIS GENESIS-API adapter."
de: "Ermittelt die Größe einer Adressaten-Kohorte (z. B. 'Berliner KMU nach WZ-Code'). v0.1 mit Stub-Tabelle für die Pilot-Kohorten; v0.2 mit DESTATIS-GENESIS-API-Adapter."
category: data
tags: [statistics, german, government, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/stats-cohort-size
- name: graph.shapley-attribution
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/graph-shapley-attribution/v0.1.0/graph-shapley-attribution-0.1.0.fai
sha256: 199927a6b8594da579a22d42b1ba438f4b2b6c90132a5c097c7c1d5eb8b9c319
tagline:
en: "Shapley-style attribution on weighted graphs"
de: "Shapley-Attribution auf gewichteten Graphen"
description:
en: "Splits a target node's blame or credit among its ancestors on a weighted norm/citation graph. v0.1 proportional fallback; v0.2 full Shapley value for ≤32-node neighbourhoods."
de: "Verteilt 'Schuld' oder 'Verdienst' eines Zielknotens auf seine Vorgänger in einem gewichteten Norm-/Zitationsgraphen. v0.1 proportional; v0.2 voller Shapley-Wert für ≤32-Knoten-Nachbarschaften."
category: data
tags: [graph, analytics, attribution, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/graph-shapley-attribution
- name: econ.skm-score
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/econ-skm-score/v0.1.0/econ-skm-score-0.1.0.fai
sha256: 2b887970d448c07cc53633124518d6f96672b0710d9e89fe4f9d6a236035f25e
tagline:
en: "Standard Cost Model (SKM) bureaucracy cost"
de: "Standardkostenmodell (SKM) — Bürokratiekosten"
description:
en: "Standard Cost Model — P×F×T×h per duty, summed across a duty list (German NKR method). The EU- and OECD-recognised methodology for bureaucracy-cost measurement, used by NL ATR, UK RPC, EU REFIT."
de: "Standardkostenmodell — P×F×T×h je Pflicht, summiert über eine Pflichtenliste (NKR-Methode). EU- und OECD-anerkannte Methodik zur Bürokratiekostenmessung, genutzt von NL ATR, UK RPC, EU REFIT."
category: data
tags: [economics, legal, government, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/econ-skm-score
- name: law.benefit-score
versions:
- version: 0.1.0
status: published
wasm_url: https://releases.chain.flemming.ai/reclaim/law-benefit-score/v0.1.0/law-benefit-score-0.1.0.fai
sha256: 4a82a07ba72cae4b49f1ff883b1ca92638e5f4ea19bc2b070288142056764025
tagline:
en: "7-dimensional regulatory benefit score"
de: "7-dimensionaler Nutzen-Score für Regulierung"
description:
en: "Seven-dimensional benefit score (protection / market / legal-certainty / EU-single-market / safety / environment / revenue), each 05. Domain-bound: the dimensions are legally-politically conventioned."
de: "Siebendimensionaler Nutzen-Score (Schutz / Markt / Rechtssicherheit / EU-Binnenmarkt / Sicherheit / Umwelt / Einnahmen), je 05. Domänengebunden: die Dimensionen sind juristisch-politisch konventioniert."
category: data
tags: [legal, german, policy, offline-capable]
license: Apache-2.0
repository: https://git.flemming.ai/chain-modules/law-benefit-score
services: []
flows: []