feat: add provider analytics summary
This commit is contained in:
@@ -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}` | 删除故事 |
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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` 均通过。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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 查询接口做轮询进度。
|
|
||||||
|
|
||||||
## 面试表达
|
## 面试表达
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user