config.yaml.example
Rama main · réplica de lectura del repositorio original.
# Morning Signal — Configuration
# Copy to config.yaml and edit for your environment.
# ── S3 / Hosting ────────────────────────────────────────────────────────────
# The S3 bucket where episodes + feed are published.
# Set up with public read access or CloudFront (see README).
s3_bucket: "your-bucket-name"
s3_region: "us-west-2"
s3_prefix: "" # optional subfolder, e.g. "podcast/"
# Cloud-deploy only: S3 prefix the prompt files are loaded from when running
# with MORNING_SIGNAL_USE_SSM=1. Local runs read prompt.md from disk and ignore this.
prompts_s3_prefix: "prompts/"
# The public base URL where your files are served.
# If using CloudFront: https://d1234abcd.cloudfront.net
# If using S3 static hosting: https://your-bucket-name.s3.us-west-2.amazonaws.com
base_url: "https://your-bucket-name.s3.us-west-2.amazonaws.com"
# ── Podcast Metadata (Apple Podcasts / RSS) ─────────────────────────────────
podcast:
title: "Morning Signal"
description: "A sharp daily briefing on markets, tech, AI, and Seattle — auto-generated."
author: ""
email: "" # optional, for iTunes owner tag
language: "en-us"
category: "Business"
subcategory: "Investing"
explicit: false
artwork: "artwork.jpg" # 3000x3000 recommended. Place in project root.
# ── TTS ─────────────────────────────────────────────────────────────────────
tts:
engine: "polly" # "polly" (Amazon, uses AWS creds) or "google" (Chirp3 HD, uses GOOGLE_APPLICATION_CREDENTIALS)
# Polly (engine: polly)
polly_voice: "Ruth" # Ruth = British female, Matthew = US male, Joanna = US female
polly_engine: "neural" # "neural" (best quality) or "standard"
# Google Chirp3 HD (engine: google) — pip install '.[google]'; all Chirp3 voices same price (1M chars/mo free)
google_voice: "en-US-Chirp3-HD-Leda"
google_language: "en-US"
google_max_sentence_chars: 400 # Chirp3 HD rejects any single sentence over its internal cap; we pre-split run-ons longer than this at clause boundaries (keep < ~500)
speed: 1.0 # Playback speed multiplier; 1.0 = normal conversational pace, raise to 1.25–1.5 for fast brief
# ── Schedule ────────────────────────────────────────────────────────────────
# Dates to skip ENTIRELY (no episode, either edition) — travel days, vacations.
# ISO YYYY-MM-DD strings; malformed entries fail loud at startup. Distinct from
# the NYSE trading calendar (weekends/holidays still ship a weekend AM): a skip
# date produces nothing, and the freshness watchdog treats the absent episode
# as expected instead of alerting. `generate --force` overrides for a one-off.
# skip_dates:
# - "2026-07-09"
# - "2026-07-10"
skip_dates: []
# Scheduled per-date content overrides (OPTIONAL ADD-ON, default OFF).
# When enabled, a small schedule manifest JSON is read from S3 at generation
# time; an entry for today's date can (a) "override" — devote the whole
# episode to a scheduled deep-dive topic instead of regular programming,
# (b) "extend" — add one extra segment on top of the regular lineup, or
# (c) "skip" — produce no episode at all that day (the console-editable
# counterpart of the skip_dates list above; both sources are honored, and
# `generate --force` overrides either). Basic form is unaffected: with the
# block absent or enabled=false there is ZERO behavior change — no S3 read,
# no new dependencies, no required objects. Even when enabled, a missing
# manifest is a silent no-op; any schedule READ failure fails soft (the
# regular episode ships) with a WARN log + Telegram alert. Manifest schema:
# docs/schedule-schema.json (schema_version 1).
# schedule:
# enabled: true
# s3_bucket: "" # default: s3_bucket above
# s3_key: "schedule/schedule.json"
schedule:
enabled: false
# ── Generation ──────────────────────────────────────────────────────────────
# Active model spec via the krepis provider-agnostic adapter. Format:
# "provider:model" (or a krepis ModelSpec JSON object as a string).
# When omitted, falls back to the legacy anthropic + claude_model pair below —
# behavior-identical for pre-migration configs.
#
# Grounding depends on the transport:
# - anthropic / openrouter — server-side web_search (native tool or
# openrouter:web_search). Coverage guards + forced-search recovery apply
# (force is anthropic-only; openrouter degrades to prose forcing).
# - litellm (krepis router model groups: low/med/high/ultra) — no server-
# side search. Grounding comes from the pre-fetched news_context digest
# (enable news_context: below). The citation / required_search_topics
# guards are skipped on this path rather than falsely failing.
#
# Production (dashboard box) uses the krepis ``high`` group via the local
# LiteLLM proxy — litellm falls through the group's own chain
# (deepseek-v4-pro-max → grok-4 → qwen3-max → openrouter deepseek-v4-pro).
# Env override: MORNING_SIGNAL_LLM.
# llm: '{"provider": "litellm", "model": "high"}'
# llm: "anthropic:claude-sonnet-4-6"
claude_model: "claude-sonnet-4-6" # legacy knob — feeds the default spec
# Output-token budget. On the direct-anthropic path (no `llm:` router group
# set above) this is the only budget there is, and 8192 is a sane default.
#
# On the krepis router path (`llm: '{"provider": "litellm", ...}'`), leave
# this UNSET — omitting it lets krepis use the registry's own budget for
# whatever model the group resolves to, which model-router-policy §2 makes
# a registry-owned parameter. Setting it here OVERRIDES that row on every
# call, including for a reasoning model, where max_tokens bounds reasoning
# AND content from one shared pool — a budget sized for a non-reasoning
# answer can starve content generation entirely before it starts
# (alpha-engine-config-I6901). Uncomment only if you have measured that a
# specific model's registry row is wrong for your workload.
# max_tokens: 8192
# Hard cap on Anthropic ``web_search`` server-tool requests per episode.
# Insurance against runaway loops — Anthropic bills $10 per 1,000 search
# requests. Typical episodes use ~15 searches; 20 sits above that as a
# ceiling, not a throttle. Omit to use the in-code default of 20.
web_search_max_uses: 20
# Minimum script length, in spoken words, below which generation ABORTS the
# attempt (alpha-engine-config-I11831). A near-empty or truncated script that
# passes every other guard used to go straight to TTS/publish and was announced
# with the same check mark as a full episode (2026-09-21: ~200 words aired as a
# 53-second episode). Below the floor the attempt is treated like the tool-call
# and grounding guards: the fallback tier runs instead of the short script
# airing, and a short episode that somehow publishes is flagged in the success
# ping. Default 500 (~2:15 spoken): 2.5x the 09-21 outlier, 0.66x the lowest
# normal edition in the 30 days measured (~758 words). Either an int for every
# edition, or a mapping keyed by edition (am / pm / weekend) with an optional
# ``default``; 0 disables the floor for that edition. A malformed value falls
# back to the default rather than disabling the guard.
# min_script_words: 500
# min_script_words: {am: 500, pm: 500, weekend: 500}
# Minimum web searches an edition must perform or generation ABORTS before
# publish (fail-loud). A zero/low-search edition is almost always
# model-confabulated rather than grounded in live news — this guard stops a
# hallucinated episode from going live silently (2026-06-16 incident: a
# pre-fetched news block told the model it could skip search, so it ran 0
# searches and fabricated the whole episode). Default 1. Set to 0 only if
# your prompt legitimately needs no live web search.
min_web_searches: 1
# Per-segment search guard (optional, default none). ``min_web_searches`` above
# only asserts the edition was grounded *somewhere* — it does NOT guarantee a
# *specific* search-critical segment was covered. The failure mode this catches
# (2026-06-17): with a tight ``web_search_max_uses`` budget the model spends its
# searches on the earlier segments and reaches a no-digest segment (one with no
# pre-fetched fallback) with no budget left, then writes it from memory. Declare
# a topic per such segment, listing keywords any of which a search query must
# contain; if fewer than ``min_matches`` (default 1) searches hit a topic, the
# topic is "uncovered". Default: none (no-op).
#
# When a topic is uncovered the DEFAULT is publish-anyway + alert: the episode
# still ships (every other segment is grounded), and a flow-doctor/Telegram
# notification names the uncovered topics for async triage. Set
# ``required_search_topics_fatal: true`` to restore the legacy behaviour —
# ABORT before publish — for deployments that would rather skip an edition than
# ship one stale segment. (The global ``min_web_searches`` floor always hard-
# aborts regardless: a near-zero-search edition is hallucinated wholesale.)
# required_search_topics_fatal: false
#
# SELF-HEALING (default on): before the publish-or-abort decision, an uncovered
# topic triggers ONE bounded recovery regeneration whose user message names the
# dropped segment(s) + their keyword figures, FORCES a dedicated web search via
# tool_choice (not just an in-prose ask), and requires a written segment for
# each. The recovered draft is adopted only if it covers
# strictly more; otherwise the original draft falls through to the policy above.
# Recovery runs even in fatal mode (so a hard-abort fires only when the forced
# retry still can't cover). Coverage is judged with the SCRIPT in hand — a topic
# counts as covered only when it was both searched AND its segment actually
# aired, so an unrelated segment's search can't falsely satisfy it. Set to false
# to skip recovery and go straight to publish+alert / abort.
# required_search_topics_recover: true
#
# ``editions`` (optional) scopes a topic to specific editions — useful when a
# weekday edition has a segment (e.g. a political pulse) that the "weekend"
# edition's prompt does not. Edition values are "am", "pm", and "weekend"
# (non-trading days). A topic with an ``editions`` list is enforced ONLY for
# those editions, so a weekday-only topic never falsely aborts the weekend pod.
# Omit ``editions`` to enforce on every edition. Example for a political-pulse
# segment sourced only from Truth Social / X, weekday editions only:
#
# required_search_topics:
# - name: "Political pulse"
# keywords: ["truth social", "trump", "maga"]
# min_matches: 2
# editions: ["am", "pm"] # not "weekend" — the weekend prompt has no politics
# - name: "Seattle local"
# keywords: ["seattle", "cascades"]
# min_matches: 1
# ── Pre-fetched News Context (optional, default OFF) ────────────────────────
# When enabled, morning-signal reads a pre-built news digest JSON from S3 and
# injects it into the Claude prompt as a SUPPLEMENTARY starting reference —
# leads for the model to verify. It does NOT suppress web search: the model is
# still instructed to web-search every segment (and the min_web_searches guard
# above enforces that it does). Leave disabled (the default) to keep the
# standard web-search-only behavior — when off, this block has zero effect.
#
# The digest is a JSON object of the shape
# {"schema_version": 1, "date": "...", "sections": {"<name>": [<item>, ...]}}
# where each item has a `title` (required) plus optional `ticker`, `source`,
# `published`, `excerpt`, `url`.
#
# `required` (default true when enabled) makes the digest a HARD prerequisite:
# if it is missing, malformed, STALE (its `date` != the episode's date), or
# empty, generation ABORTS before publish — no valid fresh digest, no pod. This
# stops a soft-failed digest from silently degrading into a pod narrated without
# the news it was meant to carry. Set `required: false` to fall back to the
# fail-soft behavior (warn + generate with web search only).
news_context:
enabled: false
required: true # missing/stale/empty digest → abort the pod (set false for fail-soft)
s3_bucket: "" # bucket holding the digest JSON (required when enabled)
s3_key: "data/news_digest_daily/latest.json" # object key for the digest (default shown)
# The episode prompt is fully yours to customize: edit ``prompt.md`` (and
# ``prompt_weekend.md`` for weekend/holiday editions). See the ``data/prompt-*``
# starters (markets-only, tech-only, generic-news, local-news, blank) for
# ready-made variants to copy from.
# Max episodes to keep in the RSS feed (older episodes remain in S3 but
# drop off the feed to keep it lightweight).
feed_max_episodes: 90
# ── Notifications (optional, via Telegram / flow-doctor) ───────────────────
# When enabled, sends a one-line success ping after each successful run
# and a failure ping with traceback on any uncaught exception, both
# routed via flow-doctor's TelegramNotifier to a single bot + chat
# (optional forum topic).
#
# Setup (two minutes):
# 1. Message @BotFather → /newbot → save the bot token.
# 2. Add the bot to your chat (or create a fresh group / supergroup).
# 3. Send the bot a message, then look up the chat_id at
# https://api.telegram.org/bot<TOKEN>/getUpdates and read
# result[0].message.chat.id (negative integer for groups /
# supergroups / channels).
# 4. Set FLOW_DOCTOR_TELEGRAM_BOT_TOKEN + FLOW_DOCTOR_TELEGRAM_CHAT_ID
# in the env, OR inline the values under telegram_* below.
#
# Forum supergroups: optional `telegram_message_thread_id` routes
# notifications to a specific topic in the group — handy for fanning
# am / pm / other-flow alerts through one chat without N bots.
notifications:
enabled: false
telegram_bot_token: "" # or set FLOW_DOCTOR_TELEGRAM_BOT_TOKEN in env
telegram_chat_id: "" # or set FLOW_DOCTOR_TELEGRAM_CHAT_ID in env
telegram_message_thread_id: null # optional: forum-topic routing