chain-studio/tools/today/propose.sh
flemming-it ce97300a12 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>
2026-05-08 12:59:33 +02:00

77 lines
2.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# tools/today/propose.sh
#
# Orchestrates: collect → System-AI → write candidate YAML files.
# Defaults to a local Ollama at 127.0.0.1:11434 with gemma3:4b.
# See README.md for env-var overrides.
#
# Output: ~/.fai/today/proposals/<ISO-DATE>/candidate-<n>.yaml
# Operator then runs accept.sh with the chosen file.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
DATE="$(date -u +%Y-%m-%d)"
OUT_DIR="${FAI_TODAY_OUT:-$HOME/.fai/today/proposals/$DATE}"
N="${FAI_TODAY_N:-3}"
API="${FAI_TODAY_API:-http://127.0.0.1:11434/api/generate}"
MODEL="${FAI_TODAY_MODEL:-gemma3:4b}"
KEY="${FAI_TODAY_KEY:-}"
mkdir -p "$OUT_DIR"
signals="$("$HERE/collect.sh")"
template="$(cat "$HERE/prompt.template.md")"
prompt="${template//\{\{SIGNAL_INPUT\}\}/$signals}"
call_ollama() {
# Streaming off, single completion. The /api/generate endpoint
# returns one big JSON; we extract `.response`.
jq -n --arg model "$MODEL" --arg prompt "$prompt" \
'{model: $model, prompt: $prompt, stream: false}' \
| curl -fsS -X POST -H 'Content-Type: application/json' \
--data-binary @- "$API" \
| jq -r '.response'
}
call_openai_compatible() {
local body
body=$(jq -n --arg model "$MODEL" --arg prompt "$prompt" '{
model: $model,
messages: [{role: "user", content: $prompt}],
temperature: 0.6
}')
local hdr_args=()
if [ -n "$KEY" ]; then
hdr_args=(-H "Authorization: Bearer $KEY")
fi
printf '%s' "$body" \
| curl -fsS -X POST -H 'Content-Type: application/json' \
"${hdr_args[@]}" --data-binary @- "$API" \
| jq -r '.choices[0].message.content'
}
draft_one() {
case "$API" in
*"/generate"*) call_ollama ;;
*"/chat/completions"*) call_openai_compatible ;;
*)
printf 'unknown API path in %s — aborting\n' "$API" >&2
exit 2 ;;
esac
}
# Strip ```yaml fences if the model adds them despite the prompt.
clean_yaml() {
sed -e '/^```yaml$/d' -e '/^```$/d' -e 's/^```$//'
}
for n in $(seq 1 "$N"); do
out="$OUT_DIR/candidate-$n.yaml"
printf 'Drafting candidate %d/%d via %s (%s)…\n' "$n" "$N" "$API" "$MODEL"
draft_one | clean_yaml > "$out"
printf ' → %s (%d bytes)\n' "$out" "$(wc -c < "$out")"
done
printf '\nDone. Skim with:\n ls -la %s\n for f in %s/*.yaml; do echo "=== $f ==="; cat "$f"; done\n' \
"$OUT_DIR" "$OUT_DIR"
printf '\nAccept with:\n %s/accept.sh <path-to-chosen.yaml>\n' "$HERE"