('/api/generations/provider-analytics'),
+ ])
+ stories.value = storyList
+ providerAnalytics.value = analytics
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -81,6 +115,14 @@ function getStoryPath(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
}
+function formatLatency(value?: number | null) {
+ return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
+}
+
+function formatCost(value?: number | null) {
+ return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
+}
+
onMounted(() => {
fetchStories()
if (router.currentRoute.value.query.openCreate) {
@@ -160,6 +202,42 @@ onMounted(() => {
+
+
+
+
Provider 运营摘要
+
+ 生成、资源补全和失败恢复留下的供应商调用轨迹。
+
+
+
+
+
成功率
+
{{ providerSuccessRate }}%
+
+
+
平均耗时
+
{{ formatLatency(providerAnalytics.avg_latency_ms) }}
+
+
+
预估成本
+
{{ formatCost(providerAnalytics.estimated_cost_usd) }}
+
+
+
调用次数
+
{{ providerAnalytics.total_calls }}
+
+
+
+
+ 当前样本中最前面的能力组合是 {{ topProvider.capability }} / {{ topProvider.adapter }},成功 {{ topProvider.success_count }} 次,失败 {{ topProvider.failure_count }} 次。
+
+
+
float | None:
return None
-async def get_story_provider_stats(
- db: AsyncSession,
- *,
- story_id: int,
- user_id: str,
-) -> dict[str, Any]:
- """Aggregate provider call telemetry from all user-owned jobs for one story."""
-
- events = (
- await db.execute(
- select(GenerationJobEvent)
- .join(GenerationJob, GenerationJobEvent.job_id == GenerationJob.id)
- .where(
- GenerationJob.story_id == story_id,
- GenerationJob.user_id == user_id,
- GenerationJobEvent.event_type.in_(
- ["provider_call_succeeded", "provider_call_failed"]
- ),
- )
- .order_by(GenerationJobEvent.id)
- )
- ).scalars().all()
+def _aggregate_provider_events(events: list[GenerationJobEvent]) -> dict[str, Any]:
+ """Aggregate provider telemetry from provider call events."""
by_key: dict[tuple[str, str], dict[str, Any]] = {}
total_latency = 0.0
@@ -363,7 +343,6 @@ async def get_story_provider_stats(
)
return {
- "story_id": story_id,
"total_calls": successful_calls + failed_calls,
"successful_calls": successful_calls,
"failed_calls": failed_calls,
@@ -371,3 +350,66 @@ async def get_story_provider_stats(
"estimated_cost_usd": round(total_cost, 6),
"by_provider": by_provider,
}
+
+
+async def get_story_provider_stats(
+ db: AsyncSession,
+ *,
+ story_id: int,
+ user_id: str,
+) -> dict[str, Any]:
+ """Aggregate provider call telemetry from all user-owned jobs for one story."""
+
+ events = (
+ await db.execute(
+ select(GenerationJobEvent)
+ .join(GenerationJob, GenerationJobEvent.job_id == GenerationJob.id)
+ .where(
+ GenerationJob.story_id == story_id,
+ GenerationJob.user_id == user_id,
+ GenerationJobEvent.event_type.in_(
+ ["provider_call_succeeded", "provider_call_failed"]
+ ),
+ )
+ .order_by(GenerationJobEvent.id)
+ )
+ ).scalars().all()
+
+ return {"story_id": story_id, **_aggregate_provider_events(events)}
+
+
+async def get_user_provider_analytics(
+ db: AsyncSession,
+ *,
+ user_id: str,
+) -> dict[str, Any]:
+ """Aggregate provider telemetry across all stories owned by one user."""
+
+ events = (
+ await db.execute(
+ select(GenerationJobEvent)
+ .join(GenerationJob, GenerationJobEvent.job_id == GenerationJob.id)
+ .where(
+ GenerationJob.user_id == user_id,
+ GenerationJobEvent.event_type.in_(
+ ["provider_call_succeeded", "provider_call_failed"]
+ ),
+ )
+ .order_by(GenerationJobEvent.id)
+ )
+ ).scalars().all()
+
+ job_count, story_count = (
+ await db.execute(
+ select(
+ func.count(GenerationJob.id),
+ func.count(distinct(GenerationJob.story_id)),
+ ).where(GenerationJob.user_id == user_id)
+ )
+ ).one()
+
+ return {
+ **_aggregate_provider_events(events),
+ "job_count": job_count,
+ "story_count": story_count,
+ }
diff --git a/backend/tests/test_generation_jobs.py b/backend/tests/test_generation_jobs.py
index f03133f..367816d 100644
--- a/backend/tests/test_generation_jobs.py
+++ b/backend/tests/test_generation_jobs.py
@@ -431,3 +431,123 @@ async def test_story_provider_stats_aggregate_job_events(
]
finally:
app.dependency_overrides.clear()
+
+
+async def test_user_provider_analytics_aggregate_across_stories(
+ db_session,
+ auth_token,
+ degraded_story_with_text,
+ test_story,
+):
+ async def override_get_db():
+ yield db_session
+
+ app.dependency_overrides[get_db] = override_get_db
+
+ image_job = await create_generation_job(
+ db_session,
+ user_id=degraded_story_with_text.user_id,
+ output_mode="asset_retry",
+ input_type="image",
+ request_payload={"assets": ["image"]},
+ story_id=degraded_story_with_text.id,
+ )
+ await record_generation_event(
+ db_session,
+ job=image_job,
+ story_id=degraded_story_with_text.id,
+ event_type="provider_call_succeeded",
+ status="succeeded",
+ metadata={
+ "capability": "image",
+ "adapter": "demo",
+ "strategy": "priority",
+ "latency_ms": 42,
+ "estimated_cost_usd": 0.01,
+ },
+ )
+ await record_generation_event(
+ db_session,
+ job=image_job,
+ story_id=degraded_story_with_text.id,
+ event_type="provider_call_failed",
+ status="failed",
+ metadata={
+ "capability": "image",
+ "adapter": "cqtai",
+ "strategy": "priority",
+ "latency_ms": 120,
+ "error": "timeout",
+ },
+ )
+
+ audio_job = await create_generation_job(
+ db_session,
+ user_id=test_story.user_id,
+ output_mode="asset_retry",
+ input_type="audio",
+ request_payload={"assets": ["audio"]},
+ story_id=test_story.id,
+ )
+ await record_generation_event(
+ db_session,
+ job=audio_job,
+ story_id=test_story.id,
+ event_type="provider_call_succeeded",
+ status="succeeded",
+ metadata={
+ "capability": "tts",
+ "adapter": "edge_tts",
+ "strategy": "priority",
+ "latency_ms": 18,
+ "estimated_cost_usd": 0.003,
+ },
+ )
+
+ transport = ASGITransport(app=app)
+ try:
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ client.cookies.set("access_token", auth_token)
+
+ response = await client.get("/api/generations/provider-analytics")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["job_count"] == 2
+ assert data["story_count"] == 2
+ assert data["total_calls"] == 3
+ assert data["successful_calls"] == 2
+ assert data["failed_calls"] == 1
+ assert data["avg_latency_ms"] == 60.0
+ assert data["estimated_cost_usd"] == 0.013
+ assert data["by_provider"] == [
+ {
+ "capability": "image",
+ "adapter": "cqtai",
+ "call_count": 1,
+ "success_count": 0,
+ "failure_count": 1,
+ "avg_latency_ms": 120.0,
+ "estimated_cost_usd": 0.0,
+ },
+ {
+ "capability": "image",
+ "adapter": "demo",
+ "call_count": 1,
+ "success_count": 1,
+ "failure_count": 0,
+ "avg_latency_ms": 42.0,
+ "estimated_cost_usd": 0.01,
+ },
+ {
+ "capability": "tts",
+ "adapter": "edge_tts",
+ "call_count": 1,
+ "success_count": 1,
+ "failure_count": 0,
+ "avg_latency_ms": 18.0,
+ "estimated_cost_usd": 0.003,
+ },
+ ]
+ finally:
+ app.dependency_overrides.clear()
diff --git a/docs/planning/demo-checklist.md b/docs/planning/demo-checklist.md
index fc72223..be99cc7 100644
--- a/docs/planning/demo-checklist.md
+++ b/docs/planning/demo-checklist.md
@@ -56,6 +56,7 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- [ ] 普通故事 provider stats 返回成功率、耗时和成本字段
- [ ] 普通故事封面 retry 后 `image_status=ready`
- [ ] 故事详情页能看到生成轨迹和 Provider 调用结果
+- [ ] 故事库能看到跨故事 Provider 运营摘要
- [ ] `/api/generations` 能生成绘本
- [ ] 绘本生成响应返回 `generation_job_id`,且 story job history 可查询
- [ ] 绘本 provider stats 返回成功率、耗时和成本字段
@@ -123,7 +124,7 @@ DreamWeaver 是面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲
### 2:20 - 3:00 取舍与下一步
-求职版优先稳定闭环和可解释性,不做支付、多租户和复杂监控。现在 job/event 已能查询 workflow、资产补全、provider 调用轨迹和聚合指标,用户端和管理端也能展示生成轨迹;下一步会迁移到后台 worker 和进度轮询。
+求职版优先稳定闭环和可解释性,不做支付、多租户和复杂监控。现在 job/event 已能查询 workflow、资产补全、provider 调用轨迹和聚合指标,用户端和管理端也能展示生成轨迹与跨故事 Provider 运营摘要;下一步会迁移到后台 worker。
---
diff --git a/docs/planning/demo-validation-log.md b/docs/planning/demo-validation-log.md
index f9b8ead..10f2204 100644
--- a/docs/planning/demo-validation-log.md
+++ b/docs/planning/demo-validation-log.md
@@ -6,6 +6,10 @@
补充验证:
+- 新增跨故事 Provider analytics 后,`backend/.venv/bin/python -m pytest backend/tests -q` 通过,83 个测试通过。
+- 用户端与管理端 `npm run build` 均通过;生成轨迹组件已支持未终止任务自动轮询。
+- `docker compose up -d --build` 已再次用当前代码重建本地演示栈。
+- `./scripts/demo_smoke.sh` 再次通过,并新增断言 `GET /api/generations/provider-analytics` 可以返回跨故事总调用、成功率、任务数、故事数和 Provider 明细。
- 后端新增 `partial_ready`、`text_status` 与迁移 `0012_story_text_status` 后,`backend/.venv/bin/python -m pytest backend/tests -q` 通过,82 个测试通过。
- `backend/.venv/bin/python -m ruff check backend/app backend/tests backend/alembic/versions/0012_add_story_text_status_and_partial_ready.py` 通过。
- 用户端与管理端 `npm run build` 均通过。
diff --git a/docs/product/unified-generation-workflow-prd.md b/docs/product/unified-generation-workflow-prd.md
index b25e6ba..67771ec 100644
--- a/docs/product/unified-generation-workflow-prd.md
+++ b/docs/product/unified-generation-workflow-prd.md
@@ -63,6 +63,8 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- Provider failover 已记录到 job event,包含 capability、adapter、strategy、latency 和 estimated cost
- Provider 调用已可按故事聚合为成功率、平均耗时、预估成本和 adapter 明细
- generation job 响应已提供 `progress_percent`、`progress_label` 和 `is_terminal`,前端可直接用于进度条和轮询
+- 已新增跨故事 Provider 运营摘要 `GET /api/generations/provider-analytics`,故事库可展示总调用、成功率、平均耗时、预估成本和任务/故事覆盖数
+- 用户端与管理端生成轨迹组件会在任务未终止时自动轮询,为后续后台 worker 进度流保留前端形态
- `POST /api/generations` 响应已返回 `generation_job_id`,smoke 脚本会验证 job 查询与 story job history
- 用户端与管理端的故事详情页、绘本阅读页已接入生成轨迹,展示生成/重试任务、关键事件、Provider 调用结果和聚合指标
- 故事详情页封面补全已切换到统一资产重试入口
@@ -72,11 +74,11 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 仍可继续减少兼容层分支
- 统一资产重试入口已覆盖普通故事封面、绘本缺失插图和故事音频,后续可继续扩展更细的资产级审计
-- 后台异步 worker 进度流、跨故事 Provider 运营分析和断点续跑仍属于后续生产化增强
+- 后台异步 worker 执行、断点续跑、跨时间窗口筛选和更完整的 Provider 运营分析仍属于后续生产化增强
### What This Means
-这份 PRD 仍然保留目标态设计,但主干能力已经可在当前代码中演示。当前最适合的继续方式,是继续把同步请求迁移到可复用的后台任务与运营分析视角,而不是继续扩大功能范围。
+这份 PRD 仍然保留目标态设计,但主干能力已经可在当前代码中演示。当前最适合的继续方式,是继续把同步请求迁移到后台 worker,并把当前首版运营摘要扩展为可筛选、可对比的分析视角,而不是继续扩大功能范围。
---
@@ -87,7 +89,7 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
DreamWeaver 当前存在以下工作流层面问题:
1. **生成入口已建立,内部路径正在收束**
- 当前前端已切到 `/api/generations`,旧的 `/api/stories/generate`、`/api/stories/generate/full`、`/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存、封面补全、绘本插图补全和音频补全 helper,并用 `AssetCompletionResult` 表达资产补全结果。generation job/event 已落库并可查询,Provider 调用轨迹和聚合指标也已进入用户端与管理端展示。下一步重点是为后台异步 worker 与运营成本分析复用这些事件。
+ 当前前端已切到 `/api/generations`,旧的 `/api/stories/generate`、`/api/stories/generate/full`、`/api/storybook/generate` 仍作为兼容入口保留。service 内部已抽取上下文准备、主记录保存、封面补全、绘本插图补全和音频补全 helper,并用 `AssetCompletionResult` 表达资产补全结果。generation job/event 已落库并可查询,Provider 调用轨迹、单故事聚合指标和跨故事运营摘要也已进入用户端与管理端展示。下一步重点是为后台异步 worker 复用这些事件。
2. **保存与资产补全过程正在统一**
文本故事和绘本已拥有更清晰的主记录保存 helper;普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。服务层已经能表达资产任务结果,并会把统一入口、资产重试、绘本逐页插图和音频生成的关键节点写入 job event。
diff --git a/docs/technical/generation-job-state.md b/docs/technical/generation-job-state.md
index 82b7f52..62cd783 100644
--- a/docs/technical/generation-job-state.md
+++ b/docs/technical/generation-job-state.md
@@ -19,8 +19,9 @@
- `GET /api/generations/jobs/{job_id}`:查询单次生成/补全任务及其事件流。
- `GET /api/generations/{story_id}/jobs`:查询某个故事或绘本的生成与重试历史。
- `GET /api/generations/{story_id}/provider-stats`:按故事聚合 Provider 调用成功率、平均耗时、预估成本和 adapter 明细。
+- `GET /api/generations/provider-analytics`:按当前用户聚合跨故事 Provider 调用、任务数、故事数、成功率、平均耗时和预估成本。
-job 响应会返回 `progress_percent`、`progress_label` 和 `is_terminal`,用户端与管理端已经消费这些查询入口,在故事详情页和绘本阅读页展示最近任务、任务历史、事件时间线、进度条和 Provider 聚合指标。
+job 响应会返回 `progress_percent`、`progress_label` 和 `is_terminal`,用户端与管理端已经消费这些查询入口,在故事详情页和绘本阅读页展示最近任务、任务历史、事件时间线、进度条和 Provider 聚合指标;当任务未终止时,前端会自动轮询,为后台 worker 进度流预留体验形态。
## 现有状态模型
@@ -50,9 +51,8 @@ job 响应会返回 `progress_percent`、`progress_label` 和 `is_terminal`,
当前已有两层记录,未来可以继续扩展字段和事件颗粒度:
-- 将 job/event 查询继续接入真正异步生成时的进度条。
-- 将当前按故事聚合的 provider 指标扩展为跨用户、跨时间窗口的运营分析。
-- 将当前同步生成请求迁移到后台 worker 后,复用现有 job 查询接口做轮询进度。
+- 将同步生成请求迁移到真正异步 worker 后,继续复用现有 job 查询和前端轮询进度条。
+- 将当前跨故事 provider 指标扩展为跨时间窗口、跨用户和失败原因维度的运营分析。
## 面试表达
diff --git a/frontend/src/components/GenerationTrace.vue b/frontend/src/components/GenerationTrace.vue
index 233ab91..a4e87c9 100644
--- a/frontend/src/components/GenerationTrace.vue
+++ b/frontend/src/components/GenerationTrace.vue
@@ -1,5 +1,5 @@
diff --git a/frontend/src/types/generation.ts b/frontend/src/types/generation.ts
index c0b1d68..9e6a9f3 100644
--- a/frontend/src/types/generation.ts
+++ b/frontend/src/types/generation.ts
@@ -49,3 +49,14 @@ export interface GenerationProviderStats {
estimated_cost_usd: number
by_provider: GenerationProviderStat[]
}
+
+export interface GenerationProviderAnalytics {
+ total_calls: number
+ successful_calls: number
+ failed_calls: number
+ avg_latency_ms: number | null
+ estimated_cost_usd: number
+ job_count: number
+ story_count: number
+ by_provider: GenerationProviderStat[]
+}
diff --git a/frontend/src/views/MyStories.vue b/frontend/src/views/MyStories.vue
index 691a40f..77847ba 100644
--- a/frontend/src/views/MyStories.vue
+++ b/frontend/src/views/MyStories.vue
@@ -7,6 +7,7 @@ import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
+import type { GenerationProviderAnalytics } from '../types/generation'
import {
getAssetStatusMeta,
getGenerationStatusMeta,
@@ -37,6 +38,7 @@ interface StoryItem {
const router = useRouter()
const stories = ref([])
+const providerAnalytics = ref(null)
const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
@@ -48,10 +50,22 @@ const readableCount = computed(() =>
const attentionCount = computed(() =>
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length,
)
+const providerSuccessRate = computed(() => {
+ if (!providerAnalytics.value?.total_calls) return null
+ return Math.round(
+ (providerAnalytics.value.successful_calls / providerAnalytics.value.total_calls) * 100,
+ )
+})
+const topProvider = computed(() => providerAnalytics.value?.by_provider[0] ?? null)
async function fetchStories() {
try {
- stories.value = await api.get('/api/stories')
+ const [storyList, analytics] = await Promise.all([
+ api.get('/api/stories'),
+ api.get('/api/generations/provider-analytics'),
+ ])
+ stories.value = storyList
+ providerAnalytics.value = analytics
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -84,6 +98,14 @@ function getStoryLink(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
}
+function formatLatency(value?: number | null) {
+ return typeof value === 'number' ? `${Math.round(value)}ms` : '暂无'
+}
+
+function formatCost(value?: number | null) {
+ return typeof value === 'number' ? `$${value.toFixed(4)}` : '$0.0000'
+}
+
onMounted(() => {
void fetchStories()
@@ -158,6 +180,42 @@ onMounted(() => {
+
+
+
+
Provider 运营摘要
+
+ 最近生成和资源补全留下的供应商调用轨迹。
+
+
+
+
+
成功率
+
{{ providerSuccessRate }}%
+
+
+
平均耗时
+
{{ formatLatency(providerAnalytics.avg_latency_ms) }}
+
+
+
预估成本
+
{{ formatCost(providerAnalytics.estimated_cost_usd) }}
+
+
+
调用次数
+
{{ providerAnalytics.total_calls }}
+
+
+
+
+ 当前样本中最前面的能力组合是 {{ topProvider.capability }} / {{ topProvider.adapter }},成功 {{ topProvider.success_count }} 次,失败 {{ topProvider.failure_count }} 次。
+
+
+
= 2 and (map(.id) | index("'"$storyboo
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 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"