修复画布 Agent 音频回填丢失 provider
原子提交与 worker 直接完成两条路径共用同一份结果紧凑化实现,删除重复副本。 紧凑化对 Agent 消费者保留音频必填 provider,历史上该豁免只加在 worker 副本里,原子化改造新写的副本没带上。 新增 Agent 音频紧凑结果仍可被 EditorAudioGenerateResponse 反序列化的契约测试,覆盖音效与背景音乐。
This commit is contained in:
@@ -31,6 +31,8 @@ use platform_oss::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
#[cfg(test)]
|
||||
use shared_contracts::assets::EditorAudioGenerateResponse;
|
||||
use shared_contracts::assets::{
|
||||
EDITOR_ASSET_LABEL_MAX_CHARS,
|
||||
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
|
||||
@@ -1127,11 +1129,22 @@ fn serialize_atomic_editor_generation_job_result(
|
||||
Ok(payload_json)
|
||||
}
|
||||
|
||||
fn compact_editor_generation_result(mut result: Value) -> Value {
|
||||
/// 队列结果落库前的通用紧凑化。原子提交与 worker 直接 complete 两条路径共用同一份实现,
|
||||
/// 避免其中一条单独演进后丢掉音频豁免(历史上就出现过:豁免只加在 worker 副本里,
|
||||
/// 原子化改造新写的副本没带上,导致 Agent 音效成功却回填失败)。
|
||||
pub(crate) fn compact_editor_generation_result(mut result: Value) -> Value {
|
||||
let Some(object) = result.as_object_mut() else {
|
||||
return result;
|
||||
};
|
||||
object.remove("provider");
|
||||
// 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏
|
||||
// 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
|
||||
let is_audio_result = object
|
||||
.get("audioKind")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "sound-effect" | "background-music"));
|
||||
if !is_audio_result {
|
||||
object.remove("provider");
|
||||
}
|
||||
if object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
@@ -19695,6 +19708,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 画布 Agent 音频回填读的是完整 `EditorAudioGenerateResponse`,其 `provider` 是必填。
|
||||
/// 紧凑化对 External API 消费者隐藏 provider(见上一条),但对 Agent 消费者必须保留,
|
||||
/// 否则 ElevenLabs、扣费、OSS 与画布写回全部成功之后,卡片仍会在四次回填尝试后显示失败。
|
||||
#[test]
|
||||
fn atomic_agent_audio_result_keeps_provider_for_tool_call_backfill() {
|
||||
for audio_kind in ["sound-effect", "background-music"] {
|
||||
let mut job = atomic_editor_generation_job_fixture();
|
||||
job.request_payload_json = json!({
|
||||
"generationInputs": { "source": "editor-agent" },
|
||||
})
|
||||
.to_string();
|
||||
let context = EditorGenerationQueueResultContext::from_job(&job);
|
||||
let payload: Value = serde_json::from_str(
|
||||
serialize_atomic_editor_generation_job_result(
|
||||
&context,
|
||||
&json!({
|
||||
"ok": true,
|
||||
"audioSrc": "/generated/editor/audio.mp3",
|
||||
"objectKey": "generated/editor/audio.mp3",
|
||||
"assetObjectId": "asset-object-audio",
|
||||
"width": 420,
|
||||
"height": 120,
|
||||
"sourceType": "generated",
|
||||
"prompt": "按钮点击声",
|
||||
"actualPrompt": "button click",
|
||||
"model": "eleven_text_to_sound_v2",
|
||||
"provider": "elevenlabs",
|
||||
"taskId": "job-audio-1",
|
||||
"priceMudPoints": 5,
|
||||
"audioKind": audio_kind,
|
||||
"durationSeconds": 3.5,
|
||||
"loop": true,
|
||||
}),
|
||||
)
|
||||
.expect("agent audio compact payload")
|
||||
.as_str(),
|
||||
)
|
||||
.expect("agent audio compact payload should be JSON");
|
||||
|
||||
let result = &payload["editor-agent-tool-call-result"];
|
||||
assert_eq!(result["provider"], "elevenlabs", "{audio_kind}");
|
||||
// 真正的锁:生产端紧凑化后的 payload 必须仍能被消费端的 DTO 反序列化。
|
||||
let backfilled: EditorAudioGenerateResponse = serde_json::from_value(result.clone())
|
||||
.unwrap_or_else(|error| {
|
||||
panic!("{audio_kind} agent payload must stay backfillable: {error}")
|
||||
});
|
||||
assert_eq!(backfilled.provider, "elevenlabs");
|
||||
assert_eq!(backfilled.audio_kind, audio_kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_icon_ui_results_keep_required_consumer_fields() {
|
||||
let source = include_str!("editor_project.rs");
|
||||
|
||||
@@ -44,9 +44,9 @@ use crate::{
|
||||
EditorGenerationCaller, EditorGenerationOperationContext, EditorGenerationPhaseReporter,
|
||||
EditorGenerationQueueResultContext, EditorIconSpritesheetGenerationRequest,
|
||||
EditorImageEditRequest, EditorImageGenerationRequest, EditorUiDesignAssetExtractionRequest,
|
||||
edit_editor_image_for_owner, extract_editor_ui_design_assets_for_owner,
|
||||
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
|
||||
remove_editor_image_background_for_owner,
|
||||
compact_editor_generation_result, edit_editor_image_for_owner,
|
||||
extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner,
|
||||
generate_editor_image_for_owner, remove_editor_image_background_for_owner,
|
||||
},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
@@ -1222,75 +1222,6 @@ fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_editor_generation_result(mut result: Value) -> Value {
|
||||
let Some(object) = result.as_object_mut() else {
|
||||
return result;
|
||||
};
|
||||
// 音频 Agent 使用完整音频 DTO 回填,必须保留其必填 provider;其它生成结果继续隐藏
|
||||
// 供应商和内部后处理实现。正常的用户可见模型仍然保留,以便卡片恢复原有展示。
|
||||
let is_audio_result = object
|
||||
.get("audioKind")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|kind| matches!(kind, "sound-effect" | "background-music"));
|
||||
if !is_audio_result {
|
||||
object.remove("provider");
|
||||
}
|
||||
if object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(is_editor_internal_processing_model)
|
||||
{
|
||||
object.remove("model");
|
||||
}
|
||||
if let Some(generation_inputs) = object
|
||||
.get_mut("generationInputs")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
for field in ["screenColorHex", "mattingProvider", "mattingModel"] {
|
||||
generation_inputs.remove(field);
|
||||
}
|
||||
}
|
||||
object.remove("project");
|
||||
object.remove("asset");
|
||||
object.remove("spritesheetAsset");
|
||||
for field in ["resource", "spritesheetResource"] {
|
||||
let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else {
|
||||
continue;
|
||||
};
|
||||
resource.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
|
||||
)
|
||||
});
|
||||
}
|
||||
if let Some(icon_image_srcs) = object
|
||||
.get_mut("iconImageSrcs")
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
for icon in icon_image_srcs {
|
||||
let Some(icon) = icon.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
|
||||
resource.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
|
||||
)
|
||||
});
|
||||
}
|
||||
icon.retain(|key, _| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"name" | "imageSrc" | "width" | "height" | "resource"
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn compact_external_api_generation_result(result: Value) -> Value {
|
||||
let mut result = result.get("data").cloned().unwrap_or(result);
|
||||
let Some(object) = result.as_object_mut() else {
|
||||
|
||||
Reference in New Issue
Block a user