| name | lorescape-manual-daily-story |
| description | Use when the user wants to manually generate, review, regenerate, or publish a Lorescape daily story (the server's 08:00 cron is paused via DAILY_STORY_ENABLED=0), pick the next place from daily_story_places, fix/overwrite a specific date's daily story in Supabase, gather Unsplash photos of the place, resolve a missing cover image, or — only after the user publishes — render the daily-story reel of the place (place photos + aspect-switching + narration text, locally via Remotion — NOT Google Flow; via the lorescape-daily-reel skill). |
Lorescape Manual Daily Story
Overview
Interactive replacement for the paused daily-story cron. End to end:
pick a place from Supabase → Claude generates zh-TW + en stories
directly → gather real photos of the place → the user reviews everything
in chat → revise until satisfied → write to daily_stories via
publish → (only after publish) render a local Remotion Cinematic
reel of the place (place photos + aspect-switching + narration text; via
the lorescape-daily-reel skill, NOT Google Flow) → (optional) add a
zh-TW voiceover → upload to the VPS (scripts/upload_reel_to_vps.sh) for
Discord review — the publish bot posts a buttoned review message there;
you approve / schedule / publish from Discord.
Content is generated by Claude, not Gemini. Do NOT use the
generate sub-command (it calls Gemini). Follow the Claude-driven
workflow below to produce the draft JSON manually, then use publish to
write it to Supabase.
Core principle: the App has NO review gate. It shows whatever row
has the newest publish_date for the language. The moment publish
runs, users can see the story in the App. Content review must finish
BEFORE publish, never after.
Unsplash ALWAYS runs. Every place gets an Unsplash photo pass,
because these photos serve three jobs: (1) the IG/App cover image when
Wikipedia has no commercially usable lead image, (2) the visual
reference Claude uses to write an accurate video prompt, and (3) the
genuine-place check that decides whether to keep this place at all. The
search is narrowed to the place itself — only photos of the actual
site, or that directly represent it. Discard anything loosely related.
Cover image is required for the IG card. A NULL image_url makes
mapper.build_card_content return None, so the IG card can't render
and the Discord review is never posted (the App still shows the story
text, just no cover). The cover is resolved before publish (see
Image resolution).
publish also hands the story off to the Instagram flow. After
writing the rows it renders the default IG card and posts it to Discord
for review, setting discord_message_id. This default card is a
manual-only fallback — nothing auto-publishes it on any schedule, and in
the current fixed-wander workflow it's never the card that actually goes
out (see Step 8b: don't approve it). If Discord isn't configured the
hand-off is skipped (story still goes live in the App).
Carousel 一律用 wander 風格(2026-07-06 起的固定決策)。 publish 之後
必跑 Step 8b 產 wander 圖組並送審;publish 自己貼的預設 IG 卡片訊息
只是備援,不要對它按 ✅(wander 圖組走全新的 pending row + 發布 bot
流程,跟預設卡片各自獨立、互不影響;預設卡片只在真的沒送 wander 圖組
審核的日子才需要手動處理,見 backend/README.md 的 back-fill 步驟)。
The Tools
Publish (writes the reviewed draft to Supabase + Discord hand-off):
cd scripts && uv run python -m manual_daily_story publish
cd scripts && uv run python -m manual_daily_story publish --date 2026-06-12
publish reads /tmp/lorescape_daily_story_draft.json, upserts both
language rows (idempotent on publish_date+language), marks the place
used, and posts the IG card to Discord (best-effort). It writes whatever
image_url / image_attribution the draft holds, so the cover must
already be resolved in the draft.
Unsplash search (always run — folded in from the former
lorescape-unsplash-images skill):
cd scripts && uv run python -m unsplash_images
cd scripts && uv run python -m unsplash_images --date 2026-06-16
It reads the draft, runs 5 place-anchored landscape queries (every
query contains the place name), and saves results + downloaded jpgs to
marketing/outputs/daily_image/{date}/unsplash_results.json (repo root). Needs
UNSPLASH_ACCESS_KEY in scripts/.env (free demo key, 50 req/hr, from
https://unsplash.com/developers).
Workflow
Step 1 — Pick the place from the Reels calendar
選點一律依 Reels 選點 calendar,不用 FIFO picker。 The place for each
day is planned by IG data in
marketing/content-calendar/_reels-place-calendar.md, NOT the alphabetical
daily_story_places FIFO. pick_next_place is a fallback only (see below).
-
Read the calendar and find the row for the target date
(today, or --date when back-filling). Take its 「DB 標題」column —
that value equals daily_story_places.wikipedia_title_en exactly.
-
Select that place by title (not FIFO):
cd publisher && uv run python -c "
from dotenv import load_dotenv; import os; load_dotenv()
os.environ.pop('GOOGLE_API_KEY', None)
from supabase import create_client
from lorescape_publisher.config import Config
sb = create_client(Config.from_env().supabase_url, Config.from_env().supabase_service_role_key)
title = 'Himeji Castle' # ← 換成 calendar 當日的 DB 標題
row = sb.table('daily_story_places').select('id, wikipedia_title_en').eq('wikipedia_title_en', title).single().execute().data
print(row['id'], row['wikipedia_title_en'])
"
When素材不足 for the calendar place: swap within the SAME type using
the calendar's 備援池 (同類型互換) — never silently jump to an unrelated
place. This is the same call as 5c: present the options to the user first.
Fallback to FIFO only when the calendar doesn't cover the date (the
schedule has a fixed range, e.g. 07/06–08/02). If the target date is
outside that range, tell the user the calendar has expired and should be
refreshed via the lorescape-reels-planner skill; only then fall back
to place_picker.pick_next_place(sb).
Execution contexts. The ops CLIs (manual_daily_story,
unsplash_images, reel_voiceover) run from the scripts/ uv project
(cd scripts && uv run python -m <module>). The inline library snippets
in Steps 1–2 call lorescape_publisher directly, so run them in the
publisher env (cd publisher && uv run python …), where bare
load_dotenv() picks up publisher/.env.
Step 2 — Fetch Wikipedia material
Run in the publisher env (cd publisher && uv run python …):
from lorescape_publisher.daily_story import wikipedia
summary = wikipedia.fetch_summary(title)
intro = wikipedia.fetch_intro_extract(title) or summary.extract
lead = wikipedia.fetch_lead_image(title)
zh_url = wikipedia.fetch_langlink_url(title, 'zh') or summary.en_url
en_url = summary.en_url
image_url = summary.image_url if (lead and lead.is_commercial_ok) else None
image_attr= lead.attribution if (lead and lead.is_commercial_ok) else None
Use WebFetch on the Wikipedia article for richer content when the
intro extract is too short (< 300 chars).
Step 3 — Claude writes the stories
Generate both zh-TW and en content following this contract:
| Field | zh-TW spec | en spec |
|---|
paragraphs (×3) | 200–300 chars each; 起/承/合 arc | 80–130 words each; setup/development/resolution |
card_paragraphs (×3) | 60–100 chars; same arc compressed; first char = concrete noun/name | 60–100 words; same; avoid The/A/An/In/On/At/It/This/That as first word |
card_title | ≤14 chars; captures tension, not just place name | ≤28 chars |
card_title_sub | ≤20 chars | ≤50 chars |
card_pull_quote | 「」or 『』wrapped; prefer real source quote | "…" wrapped; prefer real source quote |
card_pull_quote_attrib | starts with ── | starts with — |
card_anno_roman | representative year as Roman numerals | same |
hashtags | 3–5 lowerCamelCase ASCII, no '#' | same |
Step 4 — Write the draft JSON
Save to /tmp/lorescape_daily_story_draft.json. Set
image_url/image_attribution from Step 2; if Wikipedia had no
commercial lead image, leave them null for now — Step 5 resolves them.
{
"place_id": "<uuid>",
"wikipedia_title_en": "<title>",
"image_url": "<url or null>",
"image_attribution": "<attribution or null>",
"wiki_urls": { "zh-TW": "<url>", "en": "<url>" },
"stories": {
"zh-TW": { "place_name": "", "place_location": "", "era": "",
"hashtags": [], "paragraphs": [], "card_title": "",
"card_title_sub": "", "card_paragraphs": [],
"card_pull_quote": "", "card_pull_quote_attrib": "",
"card_anno_roman": "" },
"en": { ... same fields ... }
}
}
Use a bash heredoc to write it: cat > /tmp/lorescape_daily_story_draft.json << 'EOF' ... EOF
Step 5 — Image resolution (always run Unsplash)
Run the Image resolution chain. This step ALWAYS
runs the Unsplash pass (the draft must exist first), keeps only genuine
place photos, sets the cover, and builds the photo pool the video prompt
will reference. If no genuine place photo exists from any source, it
stops and asks you before switching place.
Step 6 — Present for review
Show the user both languages in full: all paragraphs, card text, pull
quote, hashtags, and the resolved cover image (URL + source +
attribution). List the genuine place photos kept for video reference. Do
NOT summarize.
Step 7 — Iterate
Apply user feedback directly by editing the draft JSON and re-presenting.
Use sed -i '' 's/old/new/g' for simple replacements, or rewrite the
relevant section.
Step 8 — Publish after explicit approval
Only run publish after the user says 可以 / 通過 / 發布 / yes / ok:
cd scripts && uv run python -m manual_daily_story publish
Relay the verification output (row count + place_name per language) and
the IG review hand-off result (the printed message_id=… line, or the
"skipping IG review hand-off" / "posted nothing" note). 提醒使用者:
不要對這則預設卡片訊息按 ✅ — carousel 走 Step 8b 的 wander 流程。
Step 8b — Wander carousel(固定風格,publish 後必跑)
Carousel 固定用 wander 風格。跟著 lorescape-wander-carousel skill 的
文案規則與流程執行:
- 照片:直接用本次 Step 5 留下的
marketing/outputs/daily_image/{date}/ genuine place photos,
不需要另外要資料夾(張數不足 5 張時才問使用者補圖)。
- 文案:Claude 寫
marketing/outputs/daily_carousel/{date}/slides.json + caption.txt
(7–9 拍、全篇第三人稱不用「我」、cover tag_zh 用「國家・城市」格式、
一個主題詞貫穿),給使用者審稿。
- 渲染 + 目視:wander renderer CLI 產出
slide_*.jpg,
請使用者確認,要改就改 slides.json 重渲染。
- 送審:
cd scripts && uv run python -m send_carousel_for_review {date},
這一步只上傳素材、建立 pending 的 social_posts row(不貼 Discord);
發布 bot 約一分鐘內會在 wander 圖組訊息 貼出四顆按鈕(✅ 核准 /
🕘 排程 / 🚀 立即發布 / ❌ 拒絕),提醒使用者去操作。
✅ 核准後若沒排程時間不會自動發:用 🕘 排程指定 Asia/Taipei 時間,
或直接按 🚀 立即發布馬上發。❌ 拒絕或完全不理 = 當天 carousel 不發
(不會 fallback 到預設風格)。
Step 9 — Render the reel (ONLY after publish)
Gate: do NOT start until the user has decided to publish (Step 8).
The reel is a post-publish deliverable, not part of content review. Once
published, render a local Remotion Cinematic reel from this run's
carousel slides.json + genuine place photos — place-focused, aspect-
switching, narration-text style (no Google Flow). REQUIRED SUB-SKILL:
Use lorescape-daily-reel for the full pipeline (prepare → condense
narration → build_video.sh). Output:
marketing/outputs/daily_video/{date}/cinematic.mp4.
Step 10 — (optional) voiceover
Gate: only after the Step 9 reel exists and the user wants narration.
The Remotion reel already has burned-in narration text and a BGM bed —
it is a finished deliverable on its own. Only if the user wants a spoken
voiceover, generate a zh-TW TTS track and mux it over the reel (build the
reel with --lufs -28 so the music sits under the voice).
Step 11 — Upload to VPS + send for review
Once final.mp4 is confirmed, one command uploads AND submits for review:
scripts/upload_reel_to_vps.sh {date}
It rsyncs final.mp4 + narration.txt to the VPS, then runs
send_reel_for_review to stage a clean pending social_posts row (no
Discord post yet). Within about a minute the publish bot posts its own
review message in Discord with four buttons — ✅ 核准 / 🕘 排程 /
🚀 立即發布 / ❌ 拒絕 — rendering a 720p preview via ffmpeg when
final.mp4 exceeds Discord's ~9.5MB attachment limit (the full file is
still what gets published to IG). The reel has its own review,
independent of the carousel's — remind the user to act on the video
message, not the carousel one. ✅ 核准 alone doesn't publish: pick
🕘 排程 for a specific Asia/Taipei time (the modal defaults to that
day's 21:00 but any time works), or 🚀 立即發布 to publish right away.
A row that's due but never approved just gets a one-time nudge in the
channel, not a publish (manual follow-up needed after that). ❌ 拒絕 is
terminal (rejected). State lives in social_posts (media_type='reel'),
so re-runs never double-post. A re-edited video can be re-submitted by
re-running the same command — the review resets to pending with a fresh
message.
Image resolution
The goal is a genuine place photo — one a reasonable viewer accepts
as depicting THIS place or its immediate setting. Run every time.
5a — Wikipedia commercial lead image
From Step 2. Only CC0 / CC BY / CC BY-SA lead images qualify
(lead.is_commercial_ok). If present, it depicts the actual place — use
it as the cover and add it to the photo pool.
5b — Unsplash pass (ALWAYS)
Run regardless of 5a (the place photos are also needed for the video
prompt):
cd scripts && uv run python -m unsplash_images
This reads the draft, runs 5 place-anchored queries, and downloads
candidates to marketing/outputs/daily_image/{date}/. Then:
-
Look at the downloaded jpgs (read the image files) — don't judge
from descriptions alone.
-
Keep only genuine place photos. A photo qualifies only if it
shows THIS place (or its immediate, recognizable setting). Discard
anything loosely related — generic textures, unrelated cities, stock
scenes, abstract moods. It is better to keep two true shots than ten
tangential ones.
-
Cover image: if 5a gave a Wikipedia lead, keep it as the cover
(then set image_attribution per 5d
— a CC BY-SA lead needs a BY + SA line, not attribution alone).
Otherwise pick the single best landscape place photo and patch the
draft:
image_url → that photo's url
image_attribution → "<photographer> / Unsplash" (Unsplash
License — attribution not legally required but include it in the IG
caption as courtesy; keep the unsplash_page link)
-
Verify the cover URL is publicly reachable (the default-card
publish job uploads image_url to Instagram, fetched server-side):
curl -sI -A "Mozilla/5.0" '<CHOSEN_URL>' | grep -iE "HTTP/|content-type"
-
The photo pool for the video prompt = the Wikipedia lead (if any)
- every Unsplash photo you kept in step 2.
5c — Switch place when no genuine photo exists (ASK FIRST)
If neither Wikipedia nor Unsplash yields a single genuine place photo,
stop and ask the user before switching — the story is already
written and discarding it is their call. Present: the place name, that
no usable place photo was found, and the options:
- Switch place — re-run Step 1
pick_next_place for a different
place and regenerate the whole story (Steps 2–5). The abandoned place
is NOT burned: mark_place_used only runs on publish.
- Manual override — the user supplies a specific image URL, or names
a specific place to use instead.
Never silently switch places, publish with a NULL cover, or pad the pool
with loosely-related images.
5d — Cover licensing: BY and SA (set once in image_attribution)
Both the carousel caption and the reel caption build their credit line from
the draft's image_attribution (via social.caption._photo_credit). Set it
correctly once and both posts stay compliant — no --caption override
needed.
-
Unsplash / CC0 / public-domain cover → no ShareAlike; attribution not
legally required. A plain courtesy credit is enough:
"<photographer> / Unsplash".
-
CC BY / CC BY-SA cover (e.g. a Wikipedia lead image) → the IG card
composites text + tint onto the photo, so the card is an adaptation.
You must (a) attribute author + licence + source (BY), and for
BY-SA also (b) release the adapted cover under the same CC BY-SA
version and (c) note it was modified (SA). Attribution-only is NOT
enough for BY-SA. Extend image_attribution to carry the SA line, e.g.:
封面圖:<author> / CC BY-SA <ver> (via Wikimedia Commons)|本封面為改作,同依 CC BY-SA <ver> 釋出,歡迎在相同條款下轉載、改作。
Match <ver> to the actual image (2.0 / 3.0 / 4.0 — read it off
lead.attribution, don't hard-code). Scope the SA to the cover only —
do NOT declare the AI video itself CC BY-SA; the video is generated from
the Unsplash place photo, which carries no ShareAlike, so over-releasing it
gives away rights for nothing. Don't emphasise "AI-generated" in the
licence line — it isn't required for CC compliance.
-
Prefer a non-CC-BY-SA cover when you can. If a genuine Unsplash / CC0
place photo exists, using it as the cover sidesteps the SA obligation
entirely (no licence block needed). Reach for a CC BY-SA Wikipedia lead as
cover only when Unsplash has no genuine shot.
Quick Reference
| Fact | Detail |
|---|
| Draft location | /tmp/lorescape_daily_story_draft.json (no DB writes until publish) |
| Place selection | 依 marketing/content-calendar/_reels-place-calendar.md 當日「DB 標題」選點(by wikipedia_title_en);素材不足走同類型備援池;日期超出 calendar 範圍才 fallback 到 FIFO pick_next_place(並提醒用 lorescape-reels-planner 重排) |
story column | Joined card_paragraphs (short), NOT the long paragraphs — matches the cron job |
| Unsplash | ALWAYS runs; queries anchored on the place name; keep only genuine place shots |
| Image pool | Wikipedia lead (if any) + kept Unsplash shots → cover + video reference |
| Cover chain | Wikipedia commercial lead → best Unsplash place shot → ask user → switch place (never NULL at publish) |
| Cover licensing | Set image_attribution per 5d: Unsplash/CC0 = courtesy credit; CC BY-SA lead = BY + SA line (release cover under same version, note modified, scope SA to cover only, don't hype "AI"). One field feeds both carousel + reel captions |
| Unsplash key | UNSPLASH_ACCESS_KEY in backend/.env (free demo, 50 req/hr) |
| Unsplash output | marketing/outputs/daily_image/{date}/unsplash_results.json + jpgs (repo root) |
| Reel | ONLY after publish; Remotion Cinematic(place photos + aspect-switching + narration text)via the lorescape-daily-reel skill → marketing/outputs/daily_video/{date}/cinematic.mp4;voiceover 可選(Step 10) |
| Reel review + publish | scripts/upload_reel_to_vps.sh {date} rsyncs final.mp4 + narration.txt to the VPS, then send_reel_for_review stages a pending social_posts row; the publish bot posts its own buttoned review (✅核准/🕘排程/🚀立即發布/❌拒絕), independent of the carousel's. Publishes on 🚀, or once an approved row's scheduled time arrives; a due-but-unapproved row just gets one nudge, no publish. State in social_posts; manual fallback = publish-reel skill |
| Carousel 風格 | 固定 wander(Step 8b):照片用本次 daily_image、Claude 寫 slides.json 審稿後渲染、send_carousel_for_review 上傳 + 建 pending row(發布 bot 之後貼按鈕審核);按鈕操作在 bot 貼的 wander 圖組訊息上,預設卡片訊息忽略 |
| Overwriting a date | publish --date X upserts, so re-publishing the same date replaces it |
review_state | Starts pending; historically only changed by the legacy default-card publish job (python -m lorescape_backend.social.publisher) based on the Discord ✅/❌ reaction — that CLI was deleted in the 2026-07-11 publisher split (see docs/adr/0004-split-social-publisher-from-backend.md), not carried over. Not touched by the wander carousel/reel bot flow — that state lives in social_posts |
| IG review hand-off | publish posts the default card to Discord (sets discord_message_id); needs DISCORD_BOT_TOKEN + DISCORD_REVIEW_CHANNEL_ID + DISCORD_APPROVER_IDS, else skipped |
| Env flags | DAILY_STORY_PUBLISH_ENABLED (gates the publish bot's scheduled auto-publish loop; 🚀 立即發布 still works manually even when OFF; ON) — falls back to legacy DAILY_STORY_ENABLED. The 09:00 Gemini generate cron no longer exists (removed in the publisher split; generation is manual CLI only) |
| Languages | Always both zh-TW and en (App queries per language) |
Gotchas
- GOOGLE_API_KEY shadowing: when calling backend Python modules
directly, pop it first:
os.environ.pop('GOOGLE_API_KEY', None).
- Reel comes AFTER publish, never before. Don't start rendering the
reel during content review — it's a post-publish deliverable. Wait
until the user has decided to publish (Step 8), then build it from
this run's place photos via lorescape-daily-reel.
- Run Unsplash every time, even when Wikipedia has a lead image. The
place photos are needed for the video prompt and the genuine-place
check — not only as a cover fallback.
- Keep only genuine place photos. The search is narrowed to the
place name, but Unsplash can still return tangential shots. Filter
visually; discard anything that isn't clearly this place or its
setting. A misleading "place" image is worse than fewer images.
- Resolve the cover before review. A NULL
image_url reaches
publish, blocks the IG card, and skips the Discord review — the
story goes live in the App with no cover.
- A CC BY-SA cover needs SA, not just BY (see 5d).
A Wikipedia lead image composited into the IG card is an adaptation —
attribution alone leaves the ShareAlike clause unmet. Put the BY+SA line
in
image_attribution so both the carousel and reel captions carry it.
Prefer an Unsplash/CC0 cover to avoid the obligation entirely.
- Don't publish an unreviewed draft. Rewriting takes seconds; an
unreviewed story going live in the App does not. Get an explicit
"可以/通過/發布" first.
mark_place_used only runs on publish, so an abandoned draft (or
a place dropped in 5c) doesn't burn the place rotation.
generate sub-command is deprecated — it calls Gemini. Claude
writes the content instead.
- 按鈕要按在 bot 貼的 wander 圖組審核訊息上,不是
publish 貼的預設
卡片訊息上。 兩則訊息同時存在時(publish 的卡片 + Step 8b 的
圖組),發布 bot 只認 social_posts 的 wander pending row;對卡片
訊息按什麼都不會有任何效果——那則卡片沒有按鈕,也不是 bot 在管的
row。
- ~4% of places have no Wikipedia lead image (sampled on the
919-place pool, ≈ once a month) — the Unsplash pass normally covers
these; a switch place (5c) is the rare case where Unsplash is empty too.
When NOT to Use
- Diagnosing why a past story is missing/broken → use the
lorescape-debug skill (read-only) first.
- Re-enabling automatic Gemini generation → no scheduled generate job
exists anymore (removed in the publisher split); generation is manual
CLI only (
python -m lorescape_publisher.daily_story). To let manual
stories auto-post to Instagram, set DAILY_STORY_PUBLISH_ENABLED=1 in
publisher/.env instead.