feat: add provider analytics summary

This commit is contained in:
2026-04-18 22:01:34 +08:00
parent e99a7fbe14
commit 4d54c144a8
15 changed files with 437 additions and 36 deletions

View File

@@ -134,6 +134,7 @@ npm run build
| GET | `/api/generations/jobs/{job_id}` | 查询生成任务事件流 | | GET | `/api/generations/jobs/{job_id}` | 查询生成任务事件流 |
| GET | `/api/generations/{story_id}/jobs` | 查询故事生成与重试历史 | | GET | `/api/generations/{story_id}/jobs` | 查询故事生成与重试历史 |
| GET | `/api/generations/{story_id}/provider-stats` | 查询 Provider 调用聚合指标 | | GET | `/api/generations/{story_id}/provider-stats` | 查询 Provider 调用聚合指标 |
| GET | `/api/generations/provider-analytics` | 查询当前用户跨故事 Provider 运营摘要 |
| GET | `/api/stories` | 故事列表 | | GET | `/api/stories` | 故事列表 |
| GET | `/api/stories/{story_id}` | 故事详情 | | GET | `/api/stories/{story_id}` | 故事详情 |
| DELETE | `/api/stories/{story_id}` | 删除故事 | | DELETE | `/api/stories/{story_id}` | 删除故事 |

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline' import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline'
import { api } from '../api/client' import { api } from '../api/client'
import LoadingSpinner from './ui/LoadingSpinner.vue' import LoadingSpinner from './ui/LoadingSpinner.vue'
@@ -64,12 +64,18 @@ const activeJob = ref<GenerationJobDetail | null>(null)
const providerStats = ref<GenerationProviderStats | null>(null) const providerStats = ref<GenerationProviderStats | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
let refreshTimer: ReturnType<typeof setInterval> | null = null
const isDark = computed(() => props.tone === 'dark') const isDark = computed(() => props.tone === 'dark')
const latestJob = computed(() => jobs.value[0] ?? null) const latestJob = computed(() => jobs.value[0] ?? null)
const activeEvents = computed(() => activeJob.value?.events.slice(-10) ?? []) const activeEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0) const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度') const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
const shouldAutoRefresh = computed(() => {
if (activeJob.value) return !activeJob.value.is_terminal
if (latestJob.value) return !latestJob.value.is_terminal
return false
})
const providerSuccessRate = computed(() => { const providerSuccessRate = computed(() => {
if (!providerStats.value?.total_calls) return null if (!providerStats.value?.total_calls) return null
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100) return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
@@ -206,6 +212,13 @@ async function refresh() {
} }
} }
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
watch( watch(
() => props.storyId, () => props.storyId,
() => { () => {
@@ -214,6 +227,19 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(shouldAutoRefresh, (enabled) => {
stopAutoRefresh()
if (enabled) {
refreshTimer = setInterval(() => {
if (!loading.value) {
void refresh()
}
}, 2500)
}
})
onBeforeUnmount(stopAutoRefresh)
defineExpose({ refresh }) defineExpose({ refresh })
</script> </script>

View File

@@ -34,8 +34,30 @@ interface StoryItem {
last_error: string | null last_error: string | null
} }
interface GenerationProviderStat {
capability: string
adapter: string
call_count: number
success_count: number
failure_count: number
avg_latency_ms: number | null
estimated_cost_usd: number
}
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[]
}
const router = useRouter() const router = useRouter()
const stories = ref<StoryItem[]>([]) const stories = ref<StoryItem[]>([])
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -45,10 +67,22 @@ const readableCount = computed(() =>
const attentionCount = computed(() => const attentionCount = computed(() =>
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length, 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() { async function fetchStories() {
try { try {
stories.value = await api.get<StoryItem[]>('/api/stories') const [storyList, analytics] = await Promise.all([
api.get<StoryItem[]>('/api/stories'),
api.get<GenerationProviderAnalytics>('/api/generations/provider-analytics'),
])
stories.value = storyList
providerAnalytics.value = analytics
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '加载失败' error.value = e instanceof Error ? e.message : '加载失败'
} finally { } finally {
@@ -81,6 +115,14 @@ function getStoryPath(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}` 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(() => { onMounted(() => {
fetchStories() fetchStories()
if (router.currentRoute.value.query.openCreate) { if (router.currentRoute.value.query.openCreate) {
@@ -160,6 +202,42 @@ onMounted(() => {
</div> </div>
</BaseCard> </BaseCard>
<BaseCard
v-if="providerAnalytics?.total_calls"
class="mb-8"
padding="lg"
>
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-bold text-gray-800">Provider 运营摘要</h2>
<p class="mt-2 text-sm leading-6 text-gray-500">
生成资源补全和失败恢复留下的供应商调用轨迹
</p>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">成功率</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerSuccessRate }}%</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">平均耗时</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatLatency(providerAnalytics.avg_latency_ms) }}</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">预估成本</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatCost(providerAnalytics.estimated_cost_usd) }}</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">调用次数</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerAnalytics.total_calls }}</div>
</div>
</div>
</div>
<p v-if="topProvider" class="mt-4 text-sm text-gray-500">
当前样本中最前面的能力组合是 {{ topProvider.capability }} / {{ topProvider.adapter }}成功 {{ topProvider.success_count }} 失败 {{ topProvider.failure_count }}
</p>
</BaseCard>
<!-- 故事网格 --> <!-- 故事网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link <router-link

View File

@@ -19,6 +19,7 @@ from app.schemas.story_schemas import (
GenerateRequest, GenerateRequest,
GenerationJobDetailResponse, GenerationJobDetailResponse,
GenerationJobSummaryResponse, GenerationJobSummaryResponse,
GenerationProviderAnalyticsResponse,
GenerationProviderStatsResponse, GenerationProviderStatsResponse,
GenerationRequest, GenerationRequest,
GenerationResponse, GenerationResponse,
@@ -34,6 +35,7 @@ from app.services import story_service
from app.services.generation_jobs import ( from app.services.generation_jobs import (
get_generation_job_detail, get_generation_job_detail,
get_story_provider_stats, get_story_provider_stats,
get_user_provider_analytics,
list_story_generation_jobs, list_story_generation_jobs,
) )
from app.services.memory_service import build_enhanced_memory_context from app.services.memory_service import build_enhanced_memory_context
@@ -83,6 +85,18 @@ async def get_generation_job(
return await get_generation_job_detail(db, job_id=job_id, user_id=user.id) return await get_generation_job_detail(db, job_id=job_id, user_id=user.id)
@router.get(
"/generations/provider-analytics",
response_model=GenerationProviderAnalyticsResponse,
)
async def get_generation_provider_analytics(
user: User = Depends(require_user),
db: AsyncSession = Depends(get_db),
):
"""Get provider call stats aggregated across the user's generation history."""
return await get_user_provider_analytics(db, user_id=user.id)
@router.get( @router.get(
"/generations/{story_id}/jobs", "/generations/{story_id}/jobs",
response_model=list[GenerationJobSummaryResponse], response_model=list[GenerationJobSummaryResponse],

View File

@@ -222,6 +222,19 @@ class GenerationProviderStatsResponse(BaseModel):
by_provider: list[GenerationProviderStatResponse] = Field(default_factory=list) by_provider: list[GenerationProviderStatResponse] = Field(default_factory=list)
class GenerationProviderAnalyticsResponse(BaseModel):
"""Provider call stats aggregated across one user's generation history."""
total_calls: int
successful_calls: int
failed_calls: int
avg_latency_ms: float | None = None
estimated_cost_usd: float = 0.0
job_count: int
story_count: int
by_provider: list[GenerationProviderStatResponse] = Field(default_factory=list)
class AchievementItem(BaseModel): class AchievementItem(BaseModel):
"""Achievement item returned for a story.""" """Achievement item returned for a story."""

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import desc, select from sqlalchemy import desc, distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import GenerationJob, GenerationJobEvent, Story from app.db.models import GenerationJob, GenerationJobEvent, Story
@@ -272,28 +272,8 @@ def _as_float(value: Any) -> float | None:
return None return None
async def get_story_provider_stats( def _aggregate_provider_events(events: list[GenerationJobEvent]) -> dict[str, Any]:
db: AsyncSession, """Aggregate provider telemetry from provider call events."""
*,
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()
by_key: dict[tuple[str, str], dict[str, Any]] = {} by_key: dict[tuple[str, str], dict[str, Any]] = {}
total_latency = 0.0 total_latency = 0.0
@@ -363,7 +343,6 @@ async def get_story_provider_stats(
) )
return { return {
"story_id": story_id,
"total_calls": successful_calls + failed_calls, "total_calls": successful_calls + failed_calls,
"successful_calls": successful_calls, "successful_calls": successful_calls,
"failed_calls": failed_calls, "failed_calls": failed_calls,
@@ -371,3 +350,66 @@ async def get_story_provider_stats(
"estimated_cost_usd": round(total_cost, 6), "estimated_cost_usd": round(total_cost, 6),
"by_provider": by_provider, "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,
}

View File

@@ -431,3 +431,123 @@ async def test_story_provider_stats_aggregate_job_events(
] ]
finally: finally:
app.dependency_overrides.clear() 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()

View File

@@ -56,6 +56,7 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- [ ] 普通故事 provider stats 返回成功率、耗时和成本字段 - [ ] 普通故事 provider stats 返回成功率、耗时和成本字段
- [ ] 普通故事封面 retry 后 `image_status=ready` - [ ] 普通故事封面 retry 后 `image_status=ready`
- [ ] 故事详情页能看到生成轨迹和 Provider 调用结果 - [ ] 故事详情页能看到生成轨迹和 Provider 调用结果
- [ ] 故事库能看到跨故事 Provider 运营摘要
- [ ] `/api/generations` 能生成绘本 - [ ] `/api/generations` 能生成绘本
- [ ] 绘本生成响应返回 `generation_job_id`,且 story job history 可查询 - [ ] 绘本生成响应返回 `generation_job_id`,且 story job history 可查询
- [ ] 绘本 provider stats 返回成功率、耗时和成本字段 - [ ] 绘本 provider stats 返回成功率、耗时和成本字段
@@ -123,7 +124,7 @@ DreamWeaver 是面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲
### 2:20 - 3:00 取舍与下一步 ### 2:20 - 3:00 取舍与下一步
求职版优先稳定闭环和可解释性,不做支付、多租户和复杂监控。现在 job/event 已能查询 workflow、资产补全、provider 调用轨迹和聚合指标,用户端和管理端也能展示生成轨迹;下一步会迁移到后台 worker 和进度轮询 求职版优先稳定闭环和可解释性,不做支付、多租户和复杂监控。现在 job/event 已能查询 workflow、资产补全、provider 调用轨迹和聚合指标,用户端和管理端也能展示生成轨迹与跨故事 Provider 运营摘要;下一步会迁移到后台 worker。
--- ---

View File

@@ -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 个测试通过。 - 后端新增 `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` 通过。 - `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` 均通过。 - 用户端与管理端 `npm run build` 均通过。

View File

@@ -63,6 +63,8 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- Provider failover 已记录到 job event包含 capability、adapter、strategy、latency 和 estimated cost - Provider failover 已记录到 job event包含 capability、adapter、strategy、latency 和 estimated cost
- Provider 调用已可按故事聚合为成功率、平均耗时、预估成本和 adapter 明细 - Provider 调用已可按故事聚合为成功率、平均耗时、预估成本和 adapter 明细
- generation job 响应已提供 `progress_percent``progress_label``is_terminal`,前端可直接用于进度条和轮询 - 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 - `POST /api/generations` 响应已返回 `generation_job_id`smoke 脚本会验证 job 查询与 story job history
- 用户端与管理端的故事详情页、绘本阅读页已接入生成轨迹,展示生成/重试任务、关键事件、Provider 调用结果和聚合指标 - 用户端与管理端的故事详情页、绘本阅读页已接入生成轨迹,展示生成/重试任务、关键事件、Provider 调用结果和聚合指标
- 故事详情页封面补全已切换到统一资产重试入口 - 故事详情页封面补全已切换到统一资产重试入口
@@ -72,11 +74,11 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
- 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 仍可继续减少兼容层分支 - 普通故事、完整生成、绘本生成已有统一外部入口,内部 workflow 仍可继续减少兼容层分支
- 统一资产重试入口已覆盖普通故事封面、绘本缺失插图和故事音频,后续可继续扩展更细的资产级审计 - 统一资产重试入口已覆盖普通故事封面、绘本缺失插图和故事音频,后续可继续扩展更细的资产级审计
- 后台异步 worker 进度流、跨故事 Provider 运营分析和断点续跑仍属于后续生产化增强 - 后台异步 worker 执行、断点续跑、跨时间窗口筛选和更完整的 Provider 运营分析仍属于后续生产化增强
### What This Means ### What This Means
这份 PRD 仍然保留目标态设计,但主干能力已经可在当前代码中演示。当前最适合的继续方式,是继续把同步请求迁移到可复用的后台任务与运营分析视角,而不是继续扩大功能范围。 这份 PRD 仍然保留目标态设计,但主干能力已经可在当前代码中演示。当前最适合的继续方式,是继续把同步请求迁移到后台 worker并把当前首版运营摘要扩展为可筛选、可对比的分析视角,而不是继续扩大功能范围。
--- ---
@@ -87,7 +89,7 @@ DreamWeaver 当前同时支持普通故事生成、完整故事生成和绘本
DreamWeaver 当前存在以下工作流层面问题: DreamWeaver 当前存在以下工作流层面问题:
1. **生成入口已建立,内部路径正在收束** 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. **保存与资产补全过程正在统一** 2. **保存与资产补全过程正在统一**
文本故事和绘本已拥有更清晰的主记录保存 helper普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。服务层已经能表达资产任务结果并会把统一入口、资产重试、绘本逐页插图和音频生成的关键节点写入 job event。 文本故事和绘本已拥有更清晰的主记录保存 helper普通故事封面、绘本缺失插图、故事音频生成/缓存已共用各自的 asset completion helper。服务层已经能表达资产任务结果并会把统一入口、资产重试、绘本逐页插图和音频生成的关键节点写入 job event。

View File

@@ -19,8 +19,9 @@
- `GET /api/generations/jobs/{job_id}`:查询单次生成/补全任务及其事件流。 - `GET /api/generations/jobs/{job_id}`:查询单次生成/补全任务及其事件流。
- `GET /api/generations/{story_id}/jobs`:查询某个故事或绘本的生成与重试历史。 - `GET /api/generations/{story_id}/jobs`:查询某个故事或绘本的生成与重试历史。
- `GET /api/generations/{story_id}/provider-stats`:按故事聚合 Provider 调用成功率、平均耗时、预估成本和 adapter 明细。 - `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 查询继续接入真正异步生成时的进度条。 -同步生成请求迁移到真正异步 worker 后,继续复用现有 job 查询和前端轮询进度条。
- 将当前故事聚合的 provider 指标扩展为跨用户、跨时间窗口的运营分析。 - 将当前故事 provider 指标扩展为跨时间窗口、跨用户和失败原因维度的运营分析。
- 将当前同步生成请求迁移到后台 worker 后,复用现有 job 查询接口做轮询进度。
## 面试表达 ## 面试表达

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline' import { BoltIcon, ClockIcon } from '@heroicons/vue/24/outline'
import { api } from '../api/client' import { api } from '../api/client'
import type { import type {
@@ -29,12 +29,18 @@ const activeJob = ref<GenerationJobDetail | null>(null)
const providerStats = ref<GenerationProviderStats | null>(null) const providerStats = ref<GenerationProviderStats | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
let refreshTimer: ReturnType<typeof setInterval> | null = null
const isDark = computed(() => props.tone === 'dark') const isDark = computed(() => props.tone === 'dark')
const latestJob = computed(() => jobHistory.value[0] ?? null) const latestJob = computed(() => jobHistory.value[0] ?? null)
const activeJobEvents = computed(() => activeJob.value?.events.slice(-10) ?? []) const activeJobEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0) const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度') const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
const shouldAutoRefresh = computed(() => {
if (activeJob.value) return !activeJob.value.is_terminal
if (latestJob.value) return !latestJob.value.is_terminal
return false
})
const providerSuccessRate = computed(() => { const providerSuccessRate = computed(() => {
if (!providerStats.value?.total_calls) return null if (!providerStats.value?.total_calls) return null
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100) return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
@@ -195,6 +201,13 @@ async function refresh() {
} }
} }
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
watch( watch(
() => props.storyId, () => props.storyId,
() => { () => {
@@ -203,6 +216,19 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(shouldAutoRefresh, (enabled) => {
stopAutoRefresh()
if (enabled) {
refreshTimer = setInterval(() => {
if (!loading.value) {
void refresh()
}
}, 2500)
}
})
onBeforeUnmount(stopAutoRefresh)
defineExpose({ refresh }) defineExpose({ refresh })
</script> </script>

View File

@@ -49,3 +49,14 @@ export interface GenerationProviderStats {
estimated_cost_usd: number estimated_cost_usd: number
by_provider: GenerationProviderStat[] 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[]
}

View File

@@ -7,6 +7,7 @@ import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue' import BaseCard from '../components/ui/BaseCard.vue'
import EmptyState from '../components/ui/EmptyState.vue' import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import type { GenerationProviderAnalytics } from '../types/generation'
import { import {
getAssetStatusMeta, getAssetStatusMeta,
getGenerationStatusMeta, getGenerationStatusMeta,
@@ -37,6 +38,7 @@ interface StoryItem {
const router = useRouter() const router = useRouter()
const stories = ref<StoryItem[]>([]) const stories = ref<StoryItem[]>([])
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const showCreateModal = ref(false) const showCreateModal = ref(false)
@@ -48,10 +50,22 @@ const readableCount = computed(() =>
const attentionCount = computed(() => const attentionCount = computed(() =>
stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length, 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() { async function fetchStories() {
try { try {
stories.value = await api.get<StoryItem[]>('/api/stories') const [storyList, analytics] = await Promise.all([
api.get<StoryItem[]>('/api/stories'),
api.get<GenerationProviderAnalytics>('/api/generations/provider-analytics'),
])
stories.value = storyList
providerAnalytics.value = analytics
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '加载失败' error.value = e instanceof Error ? e.message : '加载失败'
} finally { } finally {
@@ -84,6 +98,14 @@ function getStoryLink(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}` 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(() => { onMounted(() => {
void fetchStories() void fetchStories()
@@ -158,6 +180,42 @@ onMounted(() => {
</div> </div>
</BaseCard> </BaseCard>
<BaseCard
v-if="providerAnalytics?.total_calls"
class="mb-8"
padding="lg"
>
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-bold text-gray-800">Provider 运营摘要</h2>
<p class="mt-2 text-sm leading-6 text-gray-500">
最近生成和资源补全留下的供应商调用轨迹
</p>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:min-w-[520px]">
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">成功率</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerSuccessRate }}%</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">平均耗时</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatLatency(providerAnalytics.avg_latency_ms) }}</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">预估成本</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ formatCost(providerAnalytics.estimated_cost_usd) }}</div>
</div>
<div class="rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
<div class="text-xs text-gray-500">调用次数</div>
<div class="mt-1 text-lg font-semibold text-gray-800">{{ providerAnalytics.total_calls }}</div>
</div>
</div>
</div>
<p v-if="topProvider" class="mt-4 text-sm text-gray-500">
当前样本中最前面的能力组合是 {{ topProvider.capability }} / {{ topProvider.adapter }}成功 {{ topProvider.success_count }} 失败 {{ topProvider.failure_count }}
</p>
</BaseCard>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<router-link <router-link
v-for="story in stories" v-for="story in stories"

View File

@@ -153,6 +153,11 @@ assert_jq "$storybook_jobs_json" 'length >= 2 and (map(.id) | index("'"$storyboo
echo "$story_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]' echo "$story_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]'
echo "$storybook_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" say "Checking story list"
list_json="$(get_json "$APP_URL/api/stories?limit=5")" 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($story_id) != null" "story list should include generated story"