Files
dreamweaver/scripts/demo_smoke.sh

424 lines
25 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
APP_URL="${APP_URL:-http://localhost:52080}"
BACKEND_URL="${BACKEND_URL:-http://localhost:52000}"
ADMIN_BACKEND_URL="${ADMIN_BACKEND_URL:-http://localhost:52800}"
ADMIN_AUTH="${ADMIN_AUTH:-admin:admin}"
DEV_SIGNIN_URL="${DEV_SIGNIN_URL:-$APP_URL/auth/dev/signin}"
SMOKE_AUDIO="${SMOKE_AUDIO:-0}"
SMOKE_VOICE="${SMOKE_VOICE:-0}"
SMOKE_REAL_ASR="${SMOKE_REAL_ASR:-0}"
REAL_ASR_AUDIO_FILE="${REAL_ASR_AUDIO_FILE:-}"
REAL_ASR_EXPECTED_TEXT="${REAL_ASR_EXPECTED_TEXT:-小熊和星星一起找家}"
REAL_ASR_DURATION_MS="${REAL_ASR_DURATION_MS:-2200}"
if [[ "$SMOKE_REAL_ASR" == "1" ]]; then
SMOKE_VOICE=1
fi
COOKIE_JAR="$(mktemp "${TMPDIR:-/tmp}/dreamweaver-cookie.XXXXXX")"
VOICE_SMOKE_AUDIO="$(mktemp "${TMPDIR:-/tmp}/dreamweaver-voice-audio.XXXXXX")"
REAL_ASR_SMOKE_AUDIO="${TMPDIR:-/tmp}/dreamweaver-real-asr-audio.$$.$RANDOM.m4a"
cleanup() {
rm -f "$COOKIE_JAR" "$VOICE_SMOKE_AUDIO" "$REAL_ASR_SMOKE_AUDIO" "$REAL_ASR_SMOKE_AUDIO.caf"
}
trap cleanup EXIT
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
need_cmd curl
need_cmd jq
say() {
printf '\n[%s] %s\n' "$(date '+%H:%M:%S')" "$*"
}
post_json() {
local url="$1"
local payload="$2"
curl -fsS -b "$COOKIE_JAR" -H 'Content-Type: application/json' -d "$payload" "$url"
}
post_form() {
local url="$1"
shift
curl -fsS -b "$COOKIE_JAR" "$@" "$url"
}
get_json() {
local url="$1"
curl -fsS -b "$COOKIE_JAR" "$url"
}
assert_jq() {
local json="$1"
local filter="$2"
local message="$3"
if ! jq -e "$filter" >/dev/null <<<"$json"; then
echo "Assertion failed: $message" >&2
echo "$json" | jq '.' >&2
exit 1
fi
}
curl_form_capture() {
local body_file="$1"
local status_file="$2"
local url="$3"
shift 3
local http_code
if http_code="$(curl -sS -b "$COOKIE_JAR" -o "$body_file" -w '%{http_code}' "$@" "$url")"; then
printf '%s' "$http_code" > "$status_file"
return 0
fi
printf '%s' "${http_code:-curl_failed}" > "$status_file"
return 1
}
print_json_or_raw() {
local body_file="$1"
if jq '.' "$body_file" >&2 2>/dev/null; then
return 0
fi
cat "$body_file" >&2
}
print_real_asr_diagnostics() {
local session_id="$1"
local body_file="$2"
echo "Real ASR smoke failed." >&2
echo "Required backend env: ASR_PROVIDERS=[\"openai_asr\"] or [\"openai_asr\", \"demo\"], OPENAI_API_KEY, optional OPENAI_API_BASE, VOICE_TRANSCRIPTION_MODEL, VOICE_TRANSCRIPTION_LANGUAGE." >&2
echo "Upload response:" >&2
print_json_or_raw "$body_file"
if [[ -n "$session_id" && "$session_id" != "null" ]]; then
echo "Voice session events:" >&2
if voice_diag_json="$(get_json "$APP_URL/api/voice-sessions/$session_id" 2>/dev/null)"; then
echo "$voice_diag_json" | jq '{id,status,last_error,events:[.events[] | {event_type,status,message,event_metadata}]}' >&2
fi
fi
echo "Admin ASR analytics:" >&2
if admin_asr_json="$(curl -fsS -u "$ADMIN_AUTH" "$ADMIN_BACKEND_URL/admin/providers/analytics?days=7&capability=asr" 2>/dev/null)"; then
echo "$admin_asr_json" | jq '{capability,total_calls,successful_calls,failed_calls,voice_session_count,voice_turn_count,by_provider,failure_reasons}' >&2
fi
echo "If provider rows were changed in Admin, POST /admin/providers/reload and restart the API container/process before rerunning this smoke." >&2
}
ensure_real_asr_audio() {
if [[ -n "$REAL_ASR_AUDIO_FILE" ]]; then
if [[ ! -f "$REAL_ASR_AUDIO_FILE" ]]; then
echo "REAL_ASR_AUDIO_FILE does not exist: $REAL_ASR_AUDIO_FILE" >&2
exit 1
fi
printf '%s\n' "$REAL_ASR_AUDIO_FILE"
return 0
fi
if command -v say >/dev/null 2>&1 && command -v afconvert >/dev/null 2>&1; then
if ! say -v Tingting -o "$REAL_ASR_SMOKE_AUDIO.caf" "$REAL_ASR_EXPECTED_TEXT" 2>/dev/null; then
say -o "$REAL_ASR_SMOKE_AUDIO.caf" "$REAL_ASR_EXPECTED_TEXT"
fi
afconvert -f m4af -d aac "$REAL_ASR_SMOKE_AUDIO.caf" "$REAL_ASR_SMOKE_AUDIO" >/dev/null
rm -f "$REAL_ASR_SMOKE_AUDIO.caf"
printf '%s\n' "$REAL_ASR_SMOKE_AUDIO"
return 0
fi
echo "SMOKE_REAL_ASR=1 requires REAL_ASR_AUDIO_FILE, or macOS say + afconvert to synthesize a short sample." >&2
exit 1
}
wait_for_job_story() {
local job_id="$1"
local attempts="${2:-60}"
for ((i=1; i<=attempts; i++)); do
local job_json
job_json="$(get_json "$APP_URL/api/generations/jobs/$job_id")"
if jq -e '.story_id != null' >/dev/null <<<"$job_json"; then
printf '%s\n' "$job_json"
return 0
fi
if jq -e '.is_terminal == true and .story_id == null' >/dev/null <<<"$job_json"; then
echo "Generation job finished without a persisted story: $job_id" >&2
echo "$job_json" | jq '.' >&2
exit 1
fi
sleep 1
done
echo "Timed out waiting for generation job to persist a story: $job_id" >&2
exit 1
}
say "Checking backend health"
curl -fsS "$BACKEND_URL/health" | jq -e '.status == "ok"' >/dev/null
curl -fsS "$ADMIN_BACKEND_URL/health" | jq -e '.status == "ok"' >/dev/null
say "Logging in with dev auth"
curl -fsS -c "$COOKIE_JAR" -o /dev/null -L "$DEV_SIGNIN_URL"
session_json="$(get_json "$APP_URL/auth/session")"
assert_jq "$session_json" '.user.id == "github:dev_user_001"' "dev session should be active"
say "Checking provider capability policy"
capabilities_json="$(curl -fsS -u "$ADMIN_AUTH" "$ADMIN_BACKEND_URL/admin/providers/capabilities")"
assert_jq "$capabilities_json" 'map(.capability) | sort == ["asr","image","storybook","text","tts"]' "capabilities should include text/image/tts/storybook/asr"
say "Generating text story without assets"
story_json="$(post_json "$APP_URL/api/generations" '{
"output_mode": "story",
"type": "keywords",
"data": "金色书签, 小鹿, 学会复盘",
"education_theme": "复盘与成长",
"generate_images": false
}')"
story_job_id="$(jq -r '.generation_job_id' <<<"$story_json")"
assert_jq "$story_json" '.mode == "generated" and .generation_status == "queued" and .text_status == "generating"' "story request should be accepted for background execution"
assert_jq "$story_json" '.generation_job_id != null and .generation_job_id != ""' "story generation should expose a job id"
echo "$story_json" | jq '{generation_job_id,mode,generation_status,text_status}'
say "Waiting for story main record to be persisted"
story_job_json="$(wait_for_job_story "$story_job_id")"
story_id="$(jq -r '.story_id' <<<"$story_job_json")"
story_json="$(get_json "$APP_URL/api/generations/$story_id")"
assert_jq "$story_json" '.mode == "generated" and .generation_status == "partial_ready" and .text_status == "ready"' "story should be readable before assets"
assert_jq "$story_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) != null' "story should expose image/audio as retryable assets"
echo "$story_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets}'
say "Checking story generation job events"
assert_jq "$story_job_json" '.id == "'"$story_job_id"'" and .story_id == '"$story_id"'' "story generation job should be queryable"
assert_jq "$story_job_json" '.progress_percent == 100 and .is_terminal == true' "story generation job should expose progress summary"
assert_jq "$story_job_json" '([.events[].event_type] | index("worker_started")) != null and ([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "story generation job should include workflow events"
assert_jq "$story_job_json" '([.events[].event_type] | index("provider_call_succeeded")) != null' "story generation job should include provider call events"
echo "$story_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}'
say "Checking story provider stats"
story_provider_stats_json="$(get_json "$APP_URL/api/generations/$story_id/provider-stats")"
assert_jq "$story_provider_stats_json" '.total_calls >= 1 and .successful_calls >= 1 and (.by_provider | length) >= 1' "story provider stats should summarize provider calls"
echo "$story_provider_stats_json" | jq '{story_id,total_calls,successful_calls,failed_calls,avg_latency_ms,estimated_cost_usd}'
say "Retrying story cover image"
story_image_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["image"]}')"
assert_jq "$story_image_json" '.generation_status == "partial_ready" and .image_status == "ready" and (.image_url != null)' "story cover should be ready after retry"
assert_jq "$story_image_json" '(.retryable_assets | index("image")) == null and (.retryable_assets | index("audio")) != null' "story image retry should leave only audio retryable"
echo "$story_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}'
say "Checking story audio cache status"
story_audio_status_json="$(get_json "$APP_URL/api/audio/$story_id/status")"
assert_jq "$story_audio_status_json" '.audio_ready == false and .cache_exists == false and .audio_status == "not_requested"' "story audio status should not generate audio"
echo "$story_audio_status_json" | jq '{story_id,audio_ready,cache_exists,audio_status,retryable_assets}'
if [[ "$SMOKE_AUDIO" == "1" ]]; then
say "Retrying story audio"
story_audio_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["audio"]}')"
assert_jq "$story_audio_json" '.audio_status == "ready"' "story audio should be ready after retry"
assert_jq "$story_audio_json" '(.retryable_assets | length) == 0' "story should have no retryable assets after image and audio are ready"
audio_probe="$(curl -fsS -b "$COOKIE_JAR" -o /tmp/dreamweaver-smoke-audio.mp3 -w '%{http_code} %{content_type} %{size_download}' "$APP_URL/api/audio/$story_id")"
if [[ "$audio_probe" != 200\ audio/mpeg* ]]; then
echo "Unexpected audio response: $audio_probe" >&2
exit 1
fi
story_audio_status_json="$(get_json "$APP_URL/api/audio/$story_id/status")"
assert_jq "$story_audio_status_json" '.audio_ready == true and .cache_exists == true and .cache_size_bytes > 0' "story audio should have cache metadata after generation"
echo "$story_audio_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}'
else
say "Skipping audio smoke; set SMOKE_AUDIO=1 to include TTS"
fi
if [[ "$SMOKE_VOICE" == "1" ]]; then
say "Creating voice co-creation session"
voice_session_json="$(post_json "$APP_URL/api/voice-sessions" '{}')"
voice_session_id="$(jq -r '.id' <<<"$voice_session_json")"
assert_jq "$voice_session_json" '.status == "draft" and .target_mode == "story" and .can_continue == true' "voice session should be created as a resumable draft"
echo "$voice_session_json" | jq '{id,status,target_mode,current_turn_index,can_continue,can_finalize,transcription_mode_hint}'
say "Submitting voice text fallback turn"
voice_turn_json="$(post_json "$APP_URL/api/voice-sessions/$voice_session_id/turns/fallback" '{
"transcript_text": "我想听一个小熊和星星一起找家的故事",
"duration_ms": 1200
}')"
voice_turn_id="$(jq -r '.turn_id' <<<"$voice_turn_json")"
assert_jq "$voice_turn_json" '.status != "failed" and .turn_id != null and .turn_id != ""' "voice fallback turn should be accepted"
voice_turn_detail_json="$(get_json "$APP_URL/api/voice-sessions/$voice_session_id/turns/$voice_turn_id")"
assert_jq "$voice_turn_detail_json" '.user_transcript | contains("小熊")' "voice fallback turn should keep user transcript"
assert_jq "$voice_turn_detail_json" '.assistant_text != null and .assistant_text != ""' "voice fallback turn should return assistant text"
assert_jq "$voice_turn_detail_json" '.detected_intent == "start_story" and .requires_confirmation == false' "first voice turn should start the story without confirmation"
echo "$voice_turn_detail_json" | jq '{id,status,detected_intent,requires_confirmation,assistant_audio_ready,assistant_text}'
say "Submitting voice uploaded turn with demo transcript hint"
printf 'dreamweaver-demo-audio' > "$VOICE_SMOKE_AUDIO"
voice_upload_json="$(post_form "$APP_URL/api/voice-sessions/$voice_session_id/turns" \
-F "audio_file=@${VOICE_SMOKE_AUDIO};filename=turn.webm;type=audio/webm" \
-F 'duration_ms=900' \
-F 'transcript_hint=不要让小熊害怕,让月亮姐姐帮它')"
voice_upload_turn_id="$(jq -r '.turn_id' <<<"$voice_upload_json")"
assert_jq "$voice_upload_json" '.status != "failed" and .transcription_provider == "demo"' "voice upload turn should use demo transcript hint"
voice_upload_detail_json="$(get_json "$APP_URL/api/voice-sessions/$voice_session_id/turns/$voice_upload_turn_id")"
assert_jq "$voice_upload_detail_json" '.user_transcript | contains("月亮姐姐")' "voice upload turn should expose hinted transcript"
assert_jq "$voice_upload_detail_json" '.detected_intent == "correct_story" and .assistant_text != null' "voice upload correction should continue the narrative"
assert_jq "$voice_upload_detail_json" '.user_audio_duration_ms == 900' "voice upload turn should expose user audio duration"
echo "$voice_upload_detail_json" | jq '{id,status,transcription_provider,user_audio_duration_ms,detected_intent,requires_confirmation,assistant_audio_ready,assistant_text}'
say "Checking voice session detail and analytics"
voice_detail_json="$(get_json "$APP_URL/api/voice-sessions/$voice_session_id")"
assert_jq "$voice_detail_json" '.current_turn_index >= 2 and (.recent_turns | length) >= 2 and (.events | length) >= 2 and .can_finalize == true' "voice session should include turns/events and be finalizable"
assert_jq "$voice_detail_json" '([.events[].event_type] | index("turn_transcribed")) != null and ([.events[].event_type] | index("assistant_text_ready")) != null' "voice session should record key turn events"
echo "$voice_detail_json" | jq '{id,status,current_turn_index,can_finalize,latest_detected_intent,events:([.events[].event_type] | unique)}'
voice_analytics_json="$(get_json "$APP_URL/api/voice-sessions/analytics?days=7")"
assert_jq "$voice_analytics_json" '.window_days == 7 and .total_sessions >= 1 and .total_turns >= 2 and .successful_turns >= 2' "voice analytics should include the smoke session"
assert_jq "$voice_analytics_json" '.total_user_audio_duration_ms >= 2100 and .avg_user_audio_duration_ms > 0 and .transcription_provider_counts.demo >= 1 and .transcription_provider_counts.fallback >= 1' "voice analytics should expose duration and provider distribution"
assert_jq "$voice_analytics_json" '.text_fallback_turns >= 1 and .uploaded_audio_turns >= 1 and .user_audio_turn_rate > 0 and .assistant_audio_ready_rate > 0 and .asr_success_rate > 0 and .tts_success_rate > 0' "voice analytics should expose turn mix and success rates"
echo "$voice_analytics_json" | jq '{window_days,total_sessions,total_turns,successful_turns,failed_turns,text_fallback_turns,uploaded_audio_turns,user_audio_turn_rate,assistant_audio_ready_rate,asr_success_rate,tts_success_rate,total_user_audio_duration_ms,avg_user_audio_duration_ms,transcription_provider_counts,confirmation_request_rate,turn_success_rate,finalize_conversion_rate}'
voice_demo_analytics_json="$(get_json "$APP_URL/api/voice-sessions/analytics?days=7&provider=demo")"
assert_jq "$voice_demo_analytics_json" '.provider == "demo" and .uploaded_audio_turns >= 1 and (.transcription_provider_counts | keys == ["demo"])' "voice analytics should filter by ASR provider"
voice_waiting_analytics_json="$(get_json "$APP_URL/api/voice-sessions/analytics?days=7&session_status=waiting_user")"
assert_jq "$voice_waiting_analytics_json" '.session_status == "waiting_user" and .total_sessions >= 1' "voice analytics should filter by session status"
if [[ "$SMOKE_REAL_ASR" == "1" ]]; then
say "Submitting voice uploaded turn with real OpenAI ASR"
real_asr_audio_path="$(ensure_real_asr_audio)"
real_asr_body="$(mktemp "${TMPDIR:-/tmp}/dreamweaver-real-asr-body.XXXXXX")"
real_asr_status_file="$(mktemp "${TMPDIR:-/tmp}/dreamweaver-real-asr-status.XXXXXX")"
if ! curl_form_capture "$real_asr_body" "$real_asr_status_file" "$APP_URL/api/voice-sessions/$voice_session_id/turns" \
-F "audio_file=@${real_asr_audio_path};filename=real-asr.m4a;type=audio/mp4" \
-F "duration_ms=${REAL_ASR_DURATION_MS}"; then
print_real_asr_diagnostics "$voice_session_id" "$real_asr_body"
rm -f "$real_asr_body" "$real_asr_status_file"
exit 1
fi
real_asr_status="$(cat "$real_asr_status_file")"
if [[ "$real_asr_status" != "202" ]]; then
echo "Unexpected real ASR upload HTTP status: $real_asr_status" >&2
print_real_asr_diagnostics "$voice_session_id" "$real_asr_body"
rm -f "$real_asr_body" "$real_asr_status_file"
exit 1
fi
real_asr_upload_json="$(cat "$real_asr_body")"
rm -f "$real_asr_body" "$real_asr_status_file"
real_asr_turn_id="$(jq -r '.turn_id' <<<"$real_asr_upload_json")"
assert_jq "$real_asr_upload_json" '.status != "failed" and .transcription_provider == "openai_asr"' "real ASR upload turn should use openai_asr"
real_asr_detail_json="$(get_json "$APP_URL/api/voice-sessions/$voice_session_id/turns/$real_asr_turn_id")"
assert_jq "$real_asr_detail_json" '.transcription_provider == "openai_asr"' "real ASR turn detail should keep openai_asr provider"
assert_jq "$real_asr_detail_json" '.user_transcript != null and (.user_transcript | length) > 0' "real ASR turn should expose a non-empty transcript"
assert_jq "$real_asr_detail_json" '.assistant_text != null and .assistant_text != ""' "real ASR turn should continue the narrative"
echo "$real_asr_detail_json" | jq '{id,status,transcription_provider,user_transcript,detected_intent,requires_confirmation,assistant_audio_ready,assistant_text}'
voice_openai_asr_analytics_json="$(get_json "$APP_URL/api/voice-sessions/analytics?days=7&provider=openai_asr")"
assert_jq "$voice_openai_asr_analytics_json" '.provider == "openai_asr" and .uploaded_audio_turns >= 1 and (.transcription_provider_counts.openai_asr >= 1)' "voice analytics should filter real ASR provider"
admin_asr_analytics_json="$(curl -fsS -u "$ADMIN_AUTH" "$ADMIN_BACKEND_URL/admin/providers/analytics?days=7&capability=asr")"
assert_jq "$admin_asr_analytics_json" '.capability == "asr" and .successful_calls >= 1 and ([.by_provider[].adapter] | index("openai_asr")) != null' "admin ASR analytics should include openai_asr"
echo "$admin_asr_analytics_json" | jq '{capability,total_calls,successful_calls,failed_calls,voice_session_count,voice_turn_count,by_provider,failure_reasons}'
else
say "Skipping real ASR smoke; set SMOKE_REAL_ASR=1 with backend OPENAI_API_KEY and ASR_PROVIDERS=[\"openai_asr\", \"demo\"]"
fi
say "Finalizing voice session into story"
voice_finalize_json="$(post_json "$APP_URL/api/voice-sessions/$voice_session_id/finalize" '{
"save_story": true,
"generate_cover": true,
"generate_final_audio": false
}')"
voice_story_id="$(jq -r '.story_id' <<<"$voice_finalize_json")"
assert_jq "$voice_finalize_json" '.status == "completed" and .story_id != null' "voice session should finalize into a story"
echo "$voice_finalize_json" | jq '{session_id,status,story_id,generation_job_id}'
voice_story_json="$(get_json "$APP_URL/api/generations/$voice_story_id")"
assert_jq "$voice_story_json" '.mode == "generated" and .generation_status != "failed" and .text_status == "ready"' "voice finalized story should be readable"
echo "$voice_story_json" | jq '{id,title,mode,generation_status,text_status,image_status,audio_status,retryable_assets}'
else
say "Skipping voice co-creation smoke; set SMOKE_VOICE=1 to include Voice Studio Alpha"
fi
say "Generating storybook without images"
storybook_json="$(post_json "$APP_URL/api/generations" '{
"output_mode": "storybook",
"type": "keywords",
"data": "彩虹邮局, 小刺猬, 练习说谢谢",
"education_theme": "感恩表达",
"generate_images": false,
"page_count": 6
}')"
storybook_job_id="$(jq -r '.generation_job_id' <<<"$storybook_json")"
assert_jq "$storybook_json" '.mode == "storybook" and .generation_status == "queued" and .text_status == "generating"' "storybook request should be accepted for background execution"
assert_jq "$storybook_json" '.generation_job_id != null and .generation_job_id != ""' "storybook generation should expose a job id"
echo "$storybook_json" | jq '{generation_job_id,mode,generation_status,text_status}'
say "Waiting for storybook main record to be persisted"
storybook_job_json="$(wait_for_job_story "$storybook_job_id")"
storybook_id="$(jq -r '.story_id' <<<"$storybook_job_json")"
storybook_json="$(get_json "$APP_URL/api/generations/$storybook_id")"
assert_jq "$storybook_json" '.mode == "storybook" and .generation_status == "partial_ready" and .text_status == "ready" and .image_status == "not_requested" and (.pages | length) >= 4' "storybook should be readable before images"
assert_jq "$storybook_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) == null' "storybook should expose images as retryable assets"
echo "$storybook_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length)}'
say "Checking storybook generation job events"
assert_jq "$storybook_job_json" '.id == "'"$storybook_job_id"'" and .story_id == '"$storybook_id"'' "storybook generation job should be queryable"
assert_jq "$storybook_job_json" '.progress_percent == 100 and .is_terminal == true' "storybook generation job should expose progress summary"
assert_jq "$storybook_job_json" '([.events[].event_type] | index("worker_started")) != null and ([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "storybook generation job should include workflow events"
echo "$storybook_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}'
say "Checking storybook provider stats"
storybook_provider_stats_json="$(get_json "$APP_URL/api/generations/$storybook_id/provider-stats")"
assert_jq "$storybook_provider_stats_json" '.total_calls >= 1 and .successful_calls >= 1 and (.by_provider | length) >= 1' "storybook provider stats should summarize provider calls"
echo "$storybook_provider_stats_json" | jq '{story_id,total_calls,successful_calls,failed_calls,avg_latency_ms,estimated_cost_usd}'
say "Retrying storybook images"
storybook_image_json="$(post_json "$APP_URL/api/generations/$storybook_id/retry-assets" '{"assets":["image"]}')"
assert_jq "$storybook_image_json" '.image_status == "ready" and (.pages | length) >= 4 and ([.pages[] | select(.image_url != null)] | length) == (.pages | length)' "storybook images should be ready after retry"
assert_jq "$storybook_image_json" '(.retryable_assets | length) == 0' "storybook should have no retryable assets after images are ready"
echo "$storybook_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length), ready_pages:([.pages[] | select(.image_url != null)] | length)}'
say "Checking story job history"
story_jobs_json="$(get_json "$APP_URL/api/generations/$story_id/jobs")"
storybook_jobs_json="$(get_json "$APP_URL/api/generations/$storybook_id/jobs")"
assert_jq "$story_jobs_json" 'length >= 2 and (map(.id) | index("'"$story_job_id"'")) != null' "story job history should include generation and retry jobs"
assert_jq "$storybook_jobs_json" 'length >= 2 and (map(.id) | index("'"$storybook_job_id"'")) != null' "storybook job history should include generation and retry jobs"
echo "$story_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]'
echo "$storybook_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]'
say "Checking cross-story provider analytics"
provider_analytics_json="$(get_json "$APP_URL/api/generations/provider-analytics")"
assert_jq "$provider_analytics_json" '.total_calls >= 2 and .successful_calls >= 2 and .job_count >= 4 and .story_count >= 2 and (.by_provider | length) >= 1' "provider analytics should summarize calls across generated stories"
echo "$provider_analytics_json" | jq '{total_calls,successful_calls,failed_calls,job_count,story_count,avg_latency_ms,estimated_cost_usd}'
say "Checking filtered provider analytics"
filtered_provider_analytics_json="$(get_json "$APP_URL/api/generations/provider-analytics?days=7&capability=text")"
assert_jq "$filtered_provider_analytics_json" '.window_days == 7 and .capability == "text" and .total_calls >= 1' "filtered provider analytics should honor days/capability filters"
echo "$filtered_provider_analytics_json" | jq '{window_days,capability,total_calls,successful_calls,failed_calls,failure_reasons}'
say "Checking generation ops summary"
ops_summary_json="$(get_json "$APP_URL/api/generations/ops-summary?hours=24")"
assert_jq "$ops_summary_json" '.window_hours == 24 and .active_jobs >= 0 and .stale_running_jobs >= 0 and .failed_jobs >= 0 and .asset_retry_jobs >= 2' "generation ops summary should expose recent task health"
echo "$ops_summary_json" | jq '{window_hours,stale_threshold_minutes,active_jobs,stale_running_jobs,failed_jobs,degraded_jobs,asset_retry_jobs,recent_failures}'
say "Checking story list"
list_json="$(get_json "$APP_URL/api/stories?limit=5")"
assert_jq "$list_json" "map(.id) | index($story_id) != null" "story list should include generated story"
assert_jq "$list_json" "map(.id) | index($storybook_id) != null" "story list should include generated storybook"
echo "$list_json" | jq '.[] | {id,title,mode,generation_status,image_status,audio_status,retryable_assets}'
say "DreamWeaver demo smoke passed"