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:
parent
fe933a713f
commit
ce97300a12
11 changed files with 681 additions and 49 deletions
187
lib/data/today_story_loader.dart
Normal file
187
lib/data/today_story_loader.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
|
|||
/// Studio's own build version. Bump on every UI commit so the
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// and quick-glance proof that you're seeing the current build.
|
||||
const String kStudioVersion = '0.30.0';
|
||||
const String kStudioVersion = '0.31.0';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
|||
|
||||
import '../data/hub.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../data/today_story_loader.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
|
@ -38,6 +39,11 @@ class _StorePageState extends State<StorePage> {
|
|||
/// session. Not persisted — the next Studio launch shows it
|
||||
/// again so a release-bumped story has a chance to be seen.
|
||||
bool _todayDismissed = false;
|
||||
/// Loaded once at init from `~/.fai/today/active.yaml`; falls
|
||||
/// back to the compiled-in story when the pipeline hasn't
|
||||
/// produced an accepted candidate yet.
|
||||
late final TodayStoryData _todayStory =
|
||||
TodayStoryLoader.loadOrFallback(_kFallbackTodayStory);
|
||||
late Future<List<StoreItem>> _future;
|
||||
|
||||
/// Active UI locale, read from the app-wide MaterialApp
|
||||
|
|
@ -265,11 +271,11 @@ class _StorePageState extends State<StorePage> {
|
|||
children: [
|
||||
if (showToday) ...[
|
||||
_StoreTodayHero(
|
||||
story: _kCurrentTodayStory,
|
||||
story: _todayStory,
|
||||
locale: _locale,
|
||||
onDismiss: () =>
|
||||
setState(() => _todayDismissed = true),
|
||||
onCta: _kCurrentTodayStory.cta == _TodayCta.openSettings
|
||||
onCta: _todayStory.cta == TodayCta.openSettings
|
||||
? () => FaiSettingsDialog.show(context)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -1934,47 +1940,11 @@ class _ScreenshotPlaceholder extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Editorial action attached to a `_TodayStory`. Kept as an
|
||||
/// enum (not a closure) so the story list stays `const` and
|
||||
/// thus shippable as a frozen, audit-friendly artefact.
|
||||
enum _TodayCta { openSettings }
|
||||
|
||||
/// Editorial story shown in the Today-hero strip. One story
|
||||
/// is curated per release in [_kCurrentTodayStory] — operators
|
||||
/// who launch Studio see the same narrative until the next
|
||||
/// version bump. Bilingual content is shipped inline so KRITIS
|
||||
/// deployments don't need to pull translations from a CMS.
|
||||
class _TodayStory {
|
||||
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 _TodayStory({
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// The story shown by Studio v0.30.x. Update when the
|
||||
/// release-narrative changes — this is the editorial role of
|
||||
/// the App Store Today tab, kept lightweight: one story, one
|
||||
/// CTA, refreshed per release.
|
||||
const _TodayStory _kCurrentTodayStory = _TodayStory(
|
||||
/// Compiled-in fallback story shown when
|
||||
/// `~/.fai/today/active.yaml` is absent or fails schema
|
||||
/// validation. The pipeline operator can override at any time
|
||||
/// without recompiling Studio — see docs/today-pipeline.md.
|
||||
const TodayStoryData _kFallbackTodayStory = TodayStoryData(
|
||||
badgeEn: 'CAPABILITY FEDERATION',
|
||||
badgeDe: 'CAPABILITY-FEDERATION',
|
||||
titleEn: 'Two MCP servers away from a richer hub',
|
||||
|
|
@ -1986,7 +1956,7 @@ const _TodayStory _kCurrentTodayStory = _TodayStory(
|
|||
ctaLabelEn: 'Open MCP settings',
|
||||
ctaLabelDe: 'MCP-Einstellungen öffnen',
|
||||
icon: Icons.hub_outlined,
|
||||
cta: _TodayCta.openSettings,
|
||||
cta: TodayCta.openSettings,
|
||||
);
|
||||
|
||||
/// Editorial hero strip — the first surface a browsing operator
|
||||
|
|
@ -1995,7 +1965,7 @@ const _TodayStory _kCurrentTodayStory = _TodayStory(
|
|||
/// bullets. Auto-hides whenever a filter is active so a
|
||||
/// purposeful search isn't pushed below the fold.
|
||||
class _StoreTodayHero extends StatelessWidget {
|
||||
final _TodayStory story;
|
||||
final TodayStoryData story;
|
||||
final String locale;
|
||||
final VoidCallback onDismiss;
|
||||
/// CTA action — null hides the button. Wired by the parent
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue