feat: record external generation runs

This commit is contained in:
kdletters
2026-05-27 21:57:34 +08:00
parent a7bba70ca5
commit 5289d81baf
13 changed files with 457 additions and 169 deletions

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::tracking::record_external_generation_run_after_success;
struct BigFishDashScopeSettings {
base_url: String,
@@ -39,52 +40,99 @@ pub(super) async fn generate_big_fish_formal_asset(
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result<String, AppError> {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
let started_at_micros = current_utc_micros();
let request_payload = json!({
"assetKind": asset_kind,
"level": level,
"motionKey": motion_key,
"sessionId": session_id,
"ownerUserId": owner_user_id,
});
let outcome = async {
let session = state
.spacetime_client()
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
.await
.map_err(map_big_fish_client_error)?;
let draft = session.draft.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": "玩法草稿尚未编译,不能生成正式图片。",
}))
})?;
let context = build_big_fish_formal_asset_context(
&session,
draft,
asset_kind,
level,
motion_key,
generated_at_micros,
)?;
let settings = require_big_fish_dashscope_settings(state)?;
let http_client = build_big_fish_dashscope_http_client(&settings)?;
let generated = create_big_fish_text_to_image_generation(
&http_client,
&settings,
context.prompt.as_str(),
context.negative_prompt.as_str(),
context.size.as_str(),
)
.await?;
let downloaded = download_big_fish_remote_image(
&http_client,
generated.image_url.as_str(),
"下载 Big Fish 正式图片失败",
context.apply_transparent_background_post_process,
)
.await?;
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
persist_big_fish_formal_asset(
state,
owner_user_id,
&context,
generated,
downloaded,
generated_at_micros,
)
.await
}
.await;
match outcome {
Ok(value) => {
record_external_generation_run_after_success(
state,
"dashscope",
"big_fish_text_to_image",
"大鱼正式图片生成",
request_payload,
started_at_micros,
true,
None,
None,
Some(json!({
"legacyPublicPath": value.clone(),
})),
)
.await;
Ok(value)
}
Err(error) => {
record_external_generation_run_after_success(
state,
"dashscope",
"big_fish_text_to_image",
"大鱼正式图片生成",
request_payload,
started_at_micros,
false,
Some(error.to_string()),
None,
None,
)
.await;
Err(error)
}
}
}
fn build_big_fish_formal_asset_context(
@@ -626,6 +674,10 @@ fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> App
}))
}
fn current_utc_micros() -> i64 {
(time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}
fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",

View File

@@ -8,6 +8,7 @@ use platform_image::{
vector_engine_images_generation_url,
};
use serde_json::{Value, json};
use time::OffsetDateTime;
use crate::{
external_api_audit::{
@@ -16,6 +17,7 @@ use crate::{
},
http_error::AppError,
state::AppState,
tracking::record_external_generation_run_after_success,
};
pub(crate) use platform_image::GPT_IMAGE_2_MODEL;
@@ -105,6 +107,14 @@ pub(crate) async fn create_openai_image_generation(
reference_images: &[String],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"candidateCount": candidate_count,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_generation(
http_client,
&settings.provider_settings(),
@@ -116,7 +126,15 @@ pub(crate) async fn create_openai_image_generation(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_generation",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn create_openai_image_edit(
@@ -128,6 +146,13 @@ pub(crate) async fn create_openai_image_edit(
reference_image: &OpenAiReferenceImage,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": 1,
});
let result = create_vector_engine_image_edit(
http_client,
&settings.provider_settings(),
@@ -138,7 +163,15 @@ pub(crate) async fn create_openai_image_edit(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_edit",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn create_openai_image_edit_with_references(
@@ -151,6 +184,14 @@ pub(crate) async fn create_openai_image_edit_with_references(
reference_images: &[OpenAiReferenceImage],
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let started_at_micros = current_utc_micros();
let request_payload = json!({
"size": size,
"candidateCount": candidate_count,
"promptChars": prompt.chars().count(),
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
"referenceImageCount": reference_images.len(),
});
let result = create_vector_engine_image_edit_with_references(
http_client,
&settings.provider_settings(),
@@ -162,7 +203,15 @@ pub(crate) async fn create_openai_image_edit_with_references(
failure_context,
)
.await;
map_platform_image_result(settings, result).await
map_platform_image_result(
settings,
result,
"image_edit_with_references",
failure_context,
request_payload,
started_at_micros,
)
.await
}
pub(crate) async fn download_remote_image(
@@ -200,19 +249,57 @@ impl OpenAiImageSettings {
}
}
async fn map_platform_image_result<T>(
async fn map_platform_image_result(
settings: &OpenAiImageSettings,
result: Result<T, PlatformImageError>,
) -> Result<T, AppError> {
result: Result<OpenAiGeneratedImages, PlatformImageError>,
operation: &'static str,
failure_context: &str,
request_payload: Value,
started_at_micros: i64,
) -> Result<OpenAiGeneratedImages, AppError> {
match result {
Ok(value) => Ok(value),
Ok(value) => {
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
VECTOR_ENGINE_PROVIDER,
operation,
failure_context,
request_payload,
started_at_micros,
true,
None,
Some(value.task_id.clone()),
Some(json!({
"imageCount": value.images.len(),
"actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
})),
)
.await;
}
Ok(value)
}
Err(error) => {
if let Some(state) = settings.external_api_audit_state.as_ref() {
record_external_generation_run_after_success(
state,
VECTOR_ENGINE_PROVIDER,
operation,
failure_context,
request_payload,
started_at_micros,
false,
Some(error.message().to_string()),
None,
None,
)
.await;
}
record_openai_image_failure_if_configured(settings, &error).await;
Err(map_platform_image_error(error))
}
}
}
pub(crate) async fn record_openai_image_failure_if_configured(
settings: &OpenAiImageSettings,
error: &PlatformImageError,
@@ -457,3 +544,7 @@ mod tests {
);
}
}
fn current_utc_micros() -> i64 {
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}

View File

@@ -62,8 +62,8 @@ use spacetime_client::{
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,

View File

@@ -53,6 +53,55 @@ struct RouteTrackingSpec {
scope_id: &'static str,
}
pub async fn record_external_generation_run_after_success(
state: &AppState,
provider: &str,
operation: &str,
request_label: &str,
request_payload: Value,
started_at_micros: i64,
success: bool,
failure_reason: Option<String>,
provider_request_id: Option<String>,
result_payload: Option<Value>,
) {
let completed_at_micros = current_utc_micros();
let duration_ms = completed_at_micros.saturating_sub(started_at_micros).max(0) / 1_000;
let mut draft = TrackingEventDraft::new("external_generation_run", "external-generation");
draft.scope_kind = RuntimeTrackingScopeKind::Module;
draft.scope_id = provider.to_string();
draft.metadata = json!({
"runId": format!("external-generation-{}", Uuid::new_v4()),
"provider": provider,
"operation": operation,
"requestLabel": request_label.trim(),
"requestPayload": request_payload,
"status": if success { "succeeded" } else { "failed" },
"success": success,
"failureReason": failure_reason,
"providerRequestId": provider_request_id,
"resultPayload": result_payload,
"startedAtMicros": started_at_micros,
"completedAtMicros": completed_at_micros,
"durationMs": duration_ms,
});
record_tracking_event_after_success(state, &external_generation_request_context(), draft).await;
}
fn current_utc_micros() -> i64 {
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
}
fn external_generation_request_context() -> RequestContext {
RequestContext::new(
format!("external-generation-{}", Uuid::new_v4()),
"external generation run".to_string(),
std::time::Duration::ZERO,
false,
)
}
pub async fn record_route_tracking_event_after_success(
state: &AppState,
request_context: &RequestContext,

View File

@@ -1,9 +1,12 @@
use serde_json::json;
use shared_contracts::creation_audio;
use crate::{http_error::AppError, state::AppState};
use crate::{
http_error::AppError, state::AppState, tracking::record_external_generation_run_after_success,
};
use super::{
clock::current_utc_iso_text,
clock::{current_utc_iso_text, current_utc_micros},
errors::{map_platform_audio_error, vector_engine_bad_gateway},
publish::wait_for_generated_audio_asset,
tasks::{create_background_music_task_response, create_sound_effect_task_response},
@@ -18,45 +21,69 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
seed: Option<u64>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let started_at_micros = current_utc_micros();
let normalized_prompt = platform_audio::normalize_limited_text(
&prompt,
"prompt",
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
let task =
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
let request_payload = json!({
"kind": "sound_effect",
"promptChars": normalized_prompt.chars().count(),
"duration": duration,
"seed": seed,
"targetEntityKind": target.entity_kind,
"targetEntityId": target.entity_id,
"targetSlot": target.slot,
"targetAssetKind": target.asset_kind,
});
let outcome = async {
let task =
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::SoundEffect,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
Ok::<_, AppError>(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: None,
updated_at: Some(current_utc_iso_text()),
})
}
.await;
record_creation_audio_generation_run(
state,
"sound_effect",
request_payload,
started_at_micros,
&outcome,
)
.await;
outcome
}
pub(crate) async fn generate_background_music_asset_for_creation(
@@ -68,6 +95,7 @@ pub(crate) async fn generate_background_music_asset_for_creation(
model: Option<String>,
target: GeneratedCreationAudioTarget,
) -> Result<creation_audio::CreationAudioAsset, AppError> {
let started_at_micros = current_utc_micros();
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
&prompt,
"prompt",
@@ -80,43 +108,111 @@ pub(crate) async fn generate_background_music_asset_for_creation(
platform_audio::SUNO_TITLE_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
let request_payload = json!({
"kind": "background_music",
"promptChars": normalized_prompt.chars().count(),
"titleChars": normalized_title.chars().count(),
"hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()),
"model": model,
"targetEntityKind": target.entity_kind,
"targetEntityId": target.entity_id,
"targetSlot": target.slot,
"targetAssetKind": target.asset_kind,
});
let outcome = async {
let task = create_background_music_task_response(
state,
normalized_prompt.clone(),
normalized_title.clone(),
tags,
model,
)
.await?;
let target = AudioAssetBindingTarget {
storage_scope: target.entity_kind.clone(),
entity_kind: target.entity_kind,
entity_id: target.entity_id,
slot: target.slot,
asset_kind: target.asset_kind,
profile_id: target.profile_id,
storage_prefix: target.storage_prefix,
};
let generated = wait_for_generated_audio_asset(
state,
owner_user_id,
task.task_id.clone(),
AudioAssetSlot::BackgroundMusic,
target,
)
.await?;
let audio_src = generated
.audio_src
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
Ok(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
Ok::<_, AppError>(creation_audio::CreationAudioAsset {
task_id: generated.task_id,
provider: generated.provider,
asset_object_id: generated.asset_object_id,
asset_kind: generated.asset_kind,
audio_src,
prompt: Some(normalized_prompt),
title: Some(normalized_title),
updated_at: Some(current_utc_iso_text()),
})
}
.await;
record_creation_audio_generation_run(
state,
"background_music",
request_payload,
started_at_micros,
&outcome,
)
.await;
outcome
}
async fn record_creation_audio_generation_run(
state: &AppState,
operation: &'static str,
request_payload: serde_json::Value,
started_at_micros: i64,
outcome: &Result<creation_audio::CreationAudioAsset, AppError>,
) {
match outcome {
Ok(asset) => {
record_external_generation_run_after_success(
state,
asset.provider.as_str(),
operation,
"创作音频生成",
request_payload,
started_at_micros,
true,
None,
Some(asset.task_id.clone()),
Some(json!({
"assetObjectId": asset.asset_object_id,
"assetKind": asset.asset_kind,
"hasAudioSrc": !asset.audio_src.trim().is_empty(),
})),
)
.await;
}
Err(error) => {
record_external_generation_run_after_success(
state,
"vector-engine-audio",
operation,
"创作音频生成",
request_payload,
started_at_micros,
false,
Some(error.to_string()),
None,
None,
)
.await;
}
}
}

View File

@@ -20,8 +20,8 @@ use shared_contracts::wooden_fish::{
WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse,
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
WoodenFishWorksResponse,
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorksResponse,
WoodenFishWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;