feat(studio): daily Today-Hero proposal pipeline (v0.31.0)

Cross-store research (Apple, Play, Steam, Docker, VS Code,
Chrome Web Store, Flathub) consistently rewards editorial
curation over algorithmic recommendations — but manual
copywriting per release does not survive a solo-dev cadence.
This commit lands a daily-build pipeline so the Today-Hero
card stays fresh without operator hand-edits per release.

Pipeline shape (full design in docs/today-pipeline.md):

1. tools/today/collect.sh aggregates "what happened in the
   last 24 hours" across the F∆I monorepos: git log per repo,
   store-index seed.yaml diffs, architecture/system-gaps doc
   changes, Studio release tags, and (opt-in) audit-log
   highlights. Outputs plain text.

2. tools/today/propose.sh feeds the signal summary plus
   prompt.template.md to the operator's already-configured
   System-AI (Ollama default; OpenAI-compatible endpoints
   work via env-var override). Drafts N candidate stories as
   YAML files under ~/.fai/today/proposals/<date>/.

3. tools/today/accept.sh validates a chosen candidate against
   the today/v1 schema and the no-marketing-speak banned-word
   list, then atomic-renames it into ~/.fai/today/active.yaml.

4. Studio reads active.yaml at store-page init via the new
   TodayStoryLoader (lib/data/today_story_loader.dart). On any
   failure (file missing, schema mismatch, banned-words hit,
   parse error) it falls back to the compiled-in
   _kFallbackTodayStory so KRITIS deployments and fresh
   installs always render something sensible.

Trust + audit:
- All proposed and accepted stories live as plain YAML on disk.
- The pipeline calls only the operator's already-configured
  System-AI; it never reaches a CMS, never phones home, works
  air-gapped if the System-AI does.
- The bash accept gate AND the Dart loader both enforce the
  banned-word list — a hand-edited active.yaml that bypassed
  the shell still won't reach the UI.
- Removing the cron entry disables the pipeline; Studio falls
  back to the const story and continues to work.

Cron / launchd / systemd recipes documented in
tools/today/README.md.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-08 12:59:33 +02:00
parent fe933a713f
commit ce97300a12
11 changed files with 681 additions and 49 deletions

View file

@ -0,0 +1,187 @@
// Today-Hero story loader.
//
// Reads ~/.fai/today/active.yaml when present, validates the
// schema, and hands the parsed bilingual story back to the
// store page. On any failure (file missing, schema mismatch,
// banned-words hit, parse error) the caller falls back to the
// compiled-in const story so KRITIS / fresh installs always
// have something to render.
//
// See docs/today-pipeline.md for the full design rationale and
// tools/today/ for the operator-facing CLI.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
/// Editorial action attached to a Today story. The set is kept
/// closed and ABI-stable adding a new variant without bumping
/// the schema would let an old Studio render an unknown action
/// as a no-op button.
enum TodayCta { none, openSettings }
/// Bilingual story rendered by the Today-Hero card. Either
/// loaded from disk or supplied as a const fallback.
@immutable
class TodayStoryData {
final String badgeEn;
final String badgeDe;
final String titleEn;
final String titleDe;
final String bodyEn;
final String bodyDe;
final String ctaLabelEn;
final String ctaLabelDe;
final IconData icon;
final TodayCta cta;
const TodayStoryData({
required this.badgeEn,
required this.badgeDe,
required this.titleEn,
required this.titleDe,
required this.bodyEn,
required this.bodyDe,
required this.ctaLabelEn,
required this.ctaLabelDe,
required this.icon,
required this.cta,
});
}
/// Static loader. Pure I/O + parsing, no widget concerns. The
/// store page calls `loadOrFallback` once per fetch and feeds
/// the result into `_StoreTodayHero`.
class TodayStoryLoader {
/// Resolves to `~/.fai/today/active.yaml` on every supported
/// host. The pipeline writes here from [tools/today/accept.sh].
static String activePath() {
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'';
return p.join(home, '.fai', 'today', 'active.yaml');
}
/// Reads and validates the active story. Returns `fallback`
/// when the file is missing, malformed, or fails schema /
/// banned-words checks. Loud failures stay on disk in the
/// pipeline tooling Studio quietly falls back so the UI
/// never crashes on bad operator input.
static TodayStoryData loadOrFallback(TodayStoryData fallback) {
try {
final f = File(activePath());
if (!f.existsSync()) return fallback;
final raw = f.readAsStringSync();
final story = _parse(raw);
return story ?? fallback;
} catch (_) {
return fallback;
}
}
static TodayStoryData? _parse(String raw) {
final dynamic doc = loadYaml(raw);
if (doc is! YamlMap) return null;
if (doc['schema']?.toString() != 'today/v1') return null;
String s(String key) => (doc[key] ?? '').toString().trim();
if (_bannedWords.hasMatch(
'${s('title_en')} ${s('title_de')} ${s('body_en')} ${s('body_de')}',
)) {
return null;
}
final required = [
'badge_en',
'badge_de',
'title_en',
'title_de',
'body_en',
'body_de',
'icon',
'cta',
];
for (final k in required) {
if (s(k).isEmpty) return null;
}
final cta = _ctaFor(s('cta'));
if (cta == null) return null;
if (cta != TodayCta.none) {
if (s('cta_label_en').isEmpty || s('cta_label_de').isEmpty) {
return null;
}
}
final icon = _iconFor(s('icon'));
if (icon == null) return null;
return TodayStoryData(
badgeEn: s('badge_en'),
badgeDe: s('badge_de'),
titleEn: s('title_en'),
titleDe: s('title_de'),
bodyEn: s('body_en'),
bodyDe: s('body_de'),
ctaLabelEn: s('cta_label_en'),
ctaLabelDe: s('cta_label_de'),
icon: icon,
cta: cta,
);
}
/// Same banned-word list as `tools/today/accept.sh`. Kept in
/// sync by hand a candidate that bypassed the shell gate
/// (e.g. a hand-edited active.yaml) still won't reach the UI.
static final RegExp _bannedWords = RegExp(
r'\b('
r'viral|killer|powerful|just\s+works|revolutionary|game[- ]changing|'
r'seamless|next[- ]generation|cutting[- ]edge|world[- ]class|unprecedented'
r')\b',
caseSensitive: false,
);
static TodayCta? _ctaFor(String s) {
switch (s) {
case 'none':
return TodayCta.none;
case 'openSettings':
return TodayCta.openSettings;
default:
return null;
}
}
/// Whitelist of icons the prompt template is allowed to
/// produce. Keeping the set explicit means an LLM hallucinated
/// icon name fails the load (and we fall back) rather than
/// rendering a random glyph.
static IconData? _iconFor(String name) {
switch (name) {
case 'hub_outlined':
return Icons.hub_outlined;
case 'account_tree_outlined':
return Icons.account_tree_outlined;
case 'shield_outlined':
return Icons.shield_outlined;
case 'extension':
return Icons.extension;
case 'bolt':
return Icons.bolt;
case 'menu_book_outlined':
return Icons.menu_book_outlined;
case 'security':
return Icons.security;
case 'auto_awesome':
return Icons.auto_awesome;
case 'rocket_launch':
return Icons.rocket_launch;
case 'timeline_outlined':
return Icons.timeline_outlined;
default:
return null;
}
}
}