测试:完成BGM提示词优化收口并切换助手模型

增加 TypeScript/Rust 共用 canonicalization fixture
补齐 BGM 跨层等值、SFX 和后端边界测试
将 BGM 补全与简化请求模型改为 gpt-5.6-luna,保留画布 Agent gpt-5.4-mini
同步权威设计、项目记忆和 T6 发布门禁记录
验证本地定向测试与真实 VectorEngine 补全/简化请求
This commit is contained in:
2026-08-05 13:35:02 +00:00
parent 69426ee61c
commit aeb5894b9d
13 changed files with 628 additions and 209 deletions
@@ -4,7 +4,7 @@ use axum::{
http::StatusCode,
response::Response,
};
use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmClient, LlmMessage, LlmRunRequest, LlmRunResponse};
use platform_llm::{LlmClient, LlmMessage, LlmRunRequest, LlmRunResponse};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::assets::{
@@ -25,6 +25,7 @@ use crate::{
};
const BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS: u32 = 1024;
const BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL: &str = "gpt-5.6-luna";
const BACKGROUND_MUSIC_PROMPT_MAX_CHARS: usize =
platform_audio::SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS;
const BACKGROUND_MUSIC_SIMPLIFICATION_MAX_CHARS: usize =
@@ -210,7 +211,7 @@ fn build_background_music_prompt_llm_request(
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(EDITOR_AGENT_GPT5_MODEL)
.with_model(BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL)
.with_openai_chat()
.with_max_output_tokens(BACKGROUND_MUSIC_PROMPT_ASSIST_MAX_OUTPUT_TOKENS)
}
@@ -466,7 +467,7 @@ mod tests {
let request =
build_background_music_prompt_llm_request("系统".to_string(), "用户".to_string());
assert_eq!(request.model.as_deref(), Some(EDITOR_AGENT_GPT5_MODEL));
assert_eq!(request.model.as_deref(), Some("gpt-5.6-luna"));
assert_eq!(request.api_kind, LlmApiKind::OpenAiChat);
assert_eq!(
request.max_output_tokens,
@@ -875,7 +876,10 @@ mod tests {
.request_line
.starts_with("POST /v1/chat/completions HTTP/1.1")
);
assert_eq!(requests[0].body["model"], EDITOR_AGENT_GPT5_MODEL);
assert_eq!(
requests[0].body["model"],
BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL
);
assert!(requests[0].body.get("tools").is_none());
assert!(requests[0].body.get("tool_choice").is_none());
let input = user_message_json(&requests[0].body);
@@ -1431,7 +1435,7 @@ mod tests {
}
json!({
"id": "chat_bgm_prompt_assist",
"model": EDITOR_AGENT_GPT5_MODEL,
"model": BACKGROUND_MUSIC_PROMPT_ASSIST_MODEL,
"choices": [choice],
})
.to_string()
@@ -529,6 +529,35 @@ pub(crate) async fn enqueue_editor_background_music_generation_for_owner(
.await
}
fn build_editor_background_music_generate_response(
audio_src: String,
generated: shared_contracts::creation_audio::GeneratedAudioAssetResponse,
normalized: NormalizedEditorBackgroundMusicRequest,
project: Option<Value>,
resource: Option<Value>,
asset: Option<Value>,
) -> assets::EditorAudioGenerateResponse {
assets::EditorAudioGenerateResponse {
ok: true,
audio_src,
object_key: generated.object_key,
asset_object_id: generated.asset_object_id,
width: EDITOR_AUDIO_WIDTH,
height: EDITOR_AUDIO_HEIGHT,
source_type: "generated".to_string(),
prompt: normalized.gpt_description_prompt.clone(),
actual_prompt: Some(normalized.gpt_description_prompt),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "background-music".to_string(),
project,
resource,
asset,
queue_state: None,
}
}
pub(crate) async fn generate_editor_background_music_for_owner(
state: AppState,
request_context: RequestContext,
@@ -586,6 +615,7 @@ pub(crate) async fn generate_editor_background_music_for_owner(
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let audio_src = generated
.audio_src
.clone()
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let (resource, asset) = persist_editor_generated_media_asset(
@@ -678,25 +708,14 @@ pub(crate) async fn generate_editor_background_music_for_owner(
Ok(json_success_body(
Some(&request_context),
assets::EditorAudioGenerateResponse {
ok: true,
build_editor_background_music_generate_response(
audio_src,
object_key: generated.object_key,
asset_object_id: generated.asset_object_id,
width: EDITOR_AUDIO_WIDTH,
height: EDITOR_AUDIO_HEIGHT,
source_type: "generated".to_string(),
prompt: normalized.gpt_description_prompt.clone(),
actual_prompt: Some(normalized.gpt_description_prompt),
model: platform_audio::SUNO_DEFAULT_MODEL.to_string(),
task_id: generated.task_id,
price_mud_points: normalized.price_mud_points,
audio_kind: "background-music".to_string(),
project: completed_project,
generated,
normalized,
completed_project,
resource,
asset,
queue_state: None,
},
),
))
}
@@ -727,15 +746,38 @@ fn editor_audio_bad_request(message: impl Into<String>) -> AppError {
#[cfg(test)]
mod tests {
use serde::Deserialize;
use serde_json::Value;
use shared_contracts::assets;
use super::{
CanonicalEditorBackgroundMusicSubmissionPayload, normalize_editor_background_music_request,
prepare_editor_background_music_queue_job,
CanonicalEditorBackgroundMusicSubmissionPayload, NormalizedEditorBackgroundMusicRequest,
build_editor_background_music_generate_response, normalize_editor_background_music_request,
normalize_editor_sound_effect_request, prepare_editor_background_music_queue_job,
};
use crate::editor_generation_queue::serialize_editor_generation_job_payload_for_test;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BackgroundMusicPromptFixture {
name: String,
input: String,
prompt: String,
char_count: usize,
effective_char_count: usize,
}
fn background_music_prompt_fixture(name: &str) -> BackgroundMusicPromptFixture {
let fixtures: Vec<BackgroundMusicPromptFixture> = serde_json::from_str(include_str!(
"../../../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json"
))
.expect("shared BGM prompt fixtures should be valid JSON");
fixtures
.into_iter()
.find(|fixture| fixture.name == name)
.unwrap_or_else(|| panic!("missing shared BGM prompt fixture: {name}"))
}
fn background_music_payload(
prompt: impl Into<String>,
make_instrumental: bool,
@@ -751,42 +793,67 @@ mod tests {
}
}
fn sound_effect_payload(
prompt: impl Into<String>,
model: Option<&str>,
duration: u8,
) -> assets::EditorSoundEffectGenerateRequest {
assets::EditorSoundEffectGenerateRequest {
prompt: prompt.into(),
model: model.map(str::to_string),
duration,
project_id: Some("project-1".to_string()),
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
}
}
#[test]
fn editor_background_music_normalization_uses_canonical_generation_prompt() {
let prompt = "\u{0085} \u{2003}森林 \r\n\u{200b}\u{feff}音乐 \u{00a0}";
let normalized =
normalize_editor_background_music_request(background_music_payload(prompt, false))
.expect("canonical BGM prompt should be accepted");
let representative = background_music_prompt_fixture("representative-complex");
let normalized = normalize_editor_background_music_request(background_music_payload(
representative.input,
false,
))
.expect("canonical BGM prompt should be accepted");
assert_eq!(
normalized.gpt_description_prompt,
"森林 \r\n\u{200b}\u{feff}音乐"
);
assert_eq!(normalized.gpt_description_prompt, representative.prompt);
assert!(normalized.make_instrumental);
for invalid_prompt in [" \r\n\u{0085}", &"乐".repeat(201)] {
for valid_prompt in ["A".to_string(), "乐".repeat(200)] {
let normalized = normalize_editor_background_music_request(background_music_payload(
valid_prompt.clone(),
true,
))
.expect("1-200 canonical code points should be accepted");
assert_eq!(normalized.gpt_description_prompt, valid_prompt);
}
let unicode_white_space = background_music_prompt_fixture("unicode-white-space-only").input;
for invalid_prompt in [String::new(), unicode_white_space, "乐".repeat(201)] {
let error = normalize_editor_background_music_request(background_music_payload(
invalid_prompt,
true,
))
.expect_err("invalid canonical BGM prompt should be rejected");
.expect_err("empty, whitespace-only, and 201-code-point prompts should be rejected");
assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
}
#[test]
fn logged_in_background_music_queue_preparation_uses_canonical_prompt_and_frozen_price() {
let representative = background_music_prompt_fixture("representative-complex");
let pricing =
crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
.expect("default model pricing should be valid");
let prepared =
CanonicalEditorBackgroundMusicSubmissionPayload::new(background_music_payload(
"\u{0085}\u{2003}森林 \r\n\u{200b}\u{feff}音乐\u{00a0}",
false,
))
.expect("logged-in submission should canonicalize")
.into_queue_job(&pricing)
.expect("logged-in queue job should prepare");
let prepared = CanonicalEditorBackgroundMusicSubmissionPayload::new(
background_music_payload(representative.input, false),
)
.expect("logged-in submission should canonicalize")
.into_queue_job(&pricing)
.expect("logged-in queue job should prepare");
let request_payload_json =
serialize_editor_generation_job_payload_for_test(&prepared.payload)
.expect("the actual queue serializer should accept the canonical payload");
@@ -795,7 +862,23 @@ mod tests {
assert_eq!(
request_payload["gptDescriptionPrompt"],
"森林 \r\n\u{200b}\u{feff}音乐"
representative.prompt
);
assert_eq!(
platform_audio::background_music_prompt_char_count(
request_payload["gptDescriptionPrompt"]
.as_str()
.expect("queue prompt should be a string"),
),
representative.char_count,
);
assert_eq!(
platform_audio::background_music_prompt_effective_char_count(
request_payload["gptDescriptionPrompt"]
.as_str()
.expect("queue prompt should be a string"),
),
representative.effective_char_count,
);
assert_eq!(request_payload["makeInstrumental"], true);
assert!(request_payload.get("priceMudPoints").is_none());
@@ -805,6 +888,114 @@ mod tests {
);
}
#[test]
fn editor_background_music_inline_response_keeps_canonical_prompt_equal() {
let representative = background_music_prompt_fixture("representative-complex");
let response = build_editor_background_music_generate_response(
"/generated-editor-audios/background-music.wav".to_string(),
shared_contracts::creation_audio::GeneratedAudioAssetResponse {
kind:
shared_contracts::creation_audio::CreationAudioGenerationKind::BackgroundMusic,
task_id: "task-background-music-1".to_string(),
provider: platform_audio::VECTOR_ENGINE_PROVIDER.to_string(),
status: "completed".to_string(),
asset_object_id: Some("asset-object-1".to_string()),
object_key: Some("generated/editor/background-music.wav".to_string()),
asset_kind: Some("editor_background_music".to_string()),
audio_src: Some("/generated-editor-audios/background-music.wav".to_string()),
},
NormalizedEditorBackgroundMusicRequest {
gpt_description_prompt: representative.prompt.clone(),
make_instrumental: true,
price_mud_points: 5,
},
Some(serde_json::json!({ "projectId": "project-1" })),
Some(serde_json::json!({ "resourceId": "resource-1" })),
Some(serde_json::json!({ "assetId": "asset-1" })),
);
assert_eq!(response.prompt, representative.prompt);
assert_eq!(
response.actual_prompt.as_deref(),
Some(response.prompt.as_str())
);
let serialized =
serde_json::to_value(response).expect("inline response should serialize to JSON");
assert_eq!(serialized["prompt"], serialized["actualPrompt"]);
assert_eq!(serialized["prompt"], representative.prompt);
}
#[test]
fn editor_sound_effect_normalizer_preserves_model_prompt_and_dynamic_price() {
let pricing =
crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
.expect("default model pricing should be valid");
let normalized = normalize_editor_sound_effect_request(sound_effect_payload(
" 金币掉落叮当声 ",
None,
7,
))
.expect("default Vidu sound effect request should normalize");
assert_eq!(normalized.prompt, "金币掉落叮当声");
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
assert_eq!(normalized.duration, 7);
assert_eq!(
normalized.price_mud_points,
pricing.sound_effect_model_mud_points(Some(platform_audio::VIDU_AUDIO_MODEL))
);
let explicit_model = normalize_editor_sound_effect_request(sound_effect_payload(
"按钮确认短促音",
Some(" audio1.0 "),
5,
))
.expect("the only supported Vidu model should be accepted");
assert_eq!(explicit_model.model, platform_audio::VIDU_AUDIO_MODEL);
let error = normalize_editor_sound_effect_request(sound_effect_payload(
"按钮确认短促音",
Some(platform_audio::SUNO_DEFAULT_MODEL),
5,
))
.expect_err("Suno text-to-sound should remain unavailable for editor SFX");
assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
#[test]
fn editor_sound_effect_normalizer_enforces_prompt_and_duration_boundaries() {
let max_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS);
let normalized = normalize_editor_sound_effect_request(sound_effect_payload(
format!(" {max_prompt} "),
None,
2,
))
.expect("1500 canonical code points and the 2-second boundary should pass");
assert_eq!(normalized.prompt, max_prompt);
assert_eq!(normalized.duration, 2);
let normalized =
normalize_editor_sound_effect_request(sound_effect_payload("环境循环音", None, 10))
.expect("the 10-second boundary should pass");
assert_eq!(normalized.duration, 10);
let overlong_prompt = "声".repeat(platform_audio::VIDU_PROMPT_MAX_CHARS + 1);
let error =
normalize_editor_sound_effect_request(sound_effect_payload(overlong_prompt, None, 5))
.expect_err("1501 canonical code points should fail");
assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST);
for duration in [1, 11] {
let error = normalize_editor_sound_effect_request(sound_effect_payload(
"环境循环音",
None,
duration,
))
.expect_err("duration outside the existing 2-10 range should fail");
assert_eq!(error.status_code(), axum::http::StatusCode::BAD_REQUEST);
}
}
#[test]
fn external_background_music_queue_preparation_preserves_the_raw_payload() {
let pricing =
@@ -12,6 +12,13 @@ use platform_audio::{
};
use serde_json::json;
fn background_music_prompt_canonicalization_fixture() -> serde_json::Value {
serde_json::from_str(include_str!(
"../../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json"
))
.expect("background music prompt canonicalization fixture should be valid JSON")
}
#[test]
fn background_music_simplification_limit_is_ten_times_the_generation_limit() {
assert_eq!(
@@ -22,59 +29,52 @@ fn background_music_simplification_limit_is_ten_times_the_generation_limit() {
}
#[test]
fn background_music_prompt_canonicalization_is_idempotent() {
let prompt = " \t\u{0085}\u{00a0}音 \r\n\u{200b}\u{feff}😀\u{2003}\n ";
let canonical = canonicalize_background_music_prompt(prompt);
fn background_music_prompt_canonicalization_matches_shared_fixture() {
let fixture = background_music_prompt_canonicalization_fixture();
let cases = fixture
.as_array()
.expect("background music prompt canonicalization fixture should be an array");
assert_eq!(canonical, "音 \r\n\u{200b}\u{feff}😀");
assert_eq!(canonicalize_background_music_prompt(canonical), canonical);
}
for case in cases {
let name = case["name"]
.as_str()
.expect("fixture case name should be a string");
let input = case["input"]
.as_str()
.expect("fixture case input should be a string");
let expected_prompt = case["prompt"]
.as_str()
.expect("fixture case prompt should be a string");
let expected_char_count = case["charCount"]
.as_u64()
.expect("fixture case charCount should be an unsigned integer")
as usize;
let expected_effective_char_count = case["effectiveCharCount"]
.as_u64()
.expect("fixture case effectiveCharCount should be an unsigned integer")
as usize;
let canonical_prompt = canonicalize_background_music_prompt(input);
#[test]
fn background_music_prompt_canonicalization_matches_unicode_white_space_test_vector() {
let unicode_white_space: String = [
'\u{0009}', '\u{000a}', '\u{000b}', '\u{000c}', '\u{000d}', '\u{0020}', '\u{0085}',
'\u{00a0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', '\u{2004}',
'\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200a}', '\u{2028}',
'\u{2029}', '\u{202f}', '\u{205f}', '\u{3000}',
]
.into_iter()
.collect();
let internal_white_space = "A \t\r\n\u{0085}\u{00a0}\u{3000}B";
let prompt = format!("{unicode_white_space}{internal_white_space}{unicode_white_space}");
assert_eq!(unicode_white_space.chars().count(), 25);
assert!(unicode_white_space.chars().all(char::is_whitespace));
assert_eq!(
canonicalize_background_music_prompt(&prompt),
internal_white_space
);
}
#[test]
fn background_music_prompt_counts_canonical_unicode_code_points_and_effective_characters() {
let prompt = " \t\n\u{00a0}\u{2003}\u{200b}😀\u{0085}";
let internal_whitespace = "A \t\n\u{00a0}\u{2003}\u{0085} B";
assert_eq!(background_music_prompt_char_count(prompt), 2);
assert_eq!(background_music_prompt_effective_char_count(prompt), 2);
assert_eq!(background_music_prompt_char_count(internal_whitespace), 9);
assert_eq!(
background_music_prompt_effective_char_count(internal_whitespace),
2
);
assert_eq!(background_music_prompt_char_count("\r\n"), 0);
assert_eq!(background_music_prompt_effective_char_count("\r\n"), 0);
assert_eq!(background_music_prompt_char_count("e\u{0301}"), 2);
assert_eq!(background_music_prompt_effective_char_count("e\u{0301}"), 2);
assert_eq!(background_music_prompt_char_count("👩‍💻"), 3);
assert_eq!(background_music_prompt_effective_char_count("👩‍💻"), 3);
assert_eq!(background_music_prompt_char_count("\u{0085}"), 0);
assert_eq!(background_music_prompt_effective_char_count("\u{0085}"), 0);
assert_eq!(background_music_prompt_char_count("\u{200b}"), 1);
assert_eq!(background_music_prompt_effective_char_count("\u{200b}"), 1);
assert_eq!(background_music_prompt_char_count("\u{feff}"), 1);
assert_eq!(background_music_prompt_effective_char_count("\u{feff}"), 1);
assert_eq!(
canonical_prompt, expected_prompt,
"canonical prompt mismatch for fixture case {name}"
);
assert_eq!(
background_music_prompt_char_count(input),
expected_char_count,
"char count mismatch for fixture case {name}"
);
assert_eq!(
background_music_prompt_effective_char_count(input),
expected_effective_char_count,
"effective char count mismatch for fixture case {name}"
);
assert_eq!(
canonicalize_background_music_prompt(canonical_prompt),
canonical_prompt,
"canonicalization should be idempotent for fixture case {name}"
);
}
}
#[test]
@@ -286,19 +286,28 @@ fn sound_effect_request_rejects_overlong_prompt() {
#[test]
fn editor_background_music_request_body_uses_gpt_description_prompt_and_instrumental() {
let fixture = background_music_prompt_canonicalization_fixture();
let representative_case = fixture
.as_array()
.expect("background music prompt canonicalization fixture should be an array")
.iter()
.find(|case| case["name"].as_str() == Some("representative-complex"))
.expect("fixture should contain the representative-complex case");
let input = representative_case["input"]
.as_str()
.expect("representative-complex input should be a string");
let expected_prompt = representative_case["prompt"]
.as_str()
.expect("representative-complex prompt should be a string");
let body = build_editor_background_music_task_body(EditorBackgroundMusicTaskRequest {
gpt_description_prompt: "\u{0085}\u{2003}轻快森林 \r\n\u{200b}\u{feff}冒险背景音乐\u{00a0}"
.to_string(),
gpt_description_prompt: input.to_string(),
make_instrumental: true,
model: None,
})
.expect("editor background music body should be valid");
assert_eq!(body["mv"], SUNO_DEFAULT_MODEL);
assert_eq!(
body["gpt_description_prompt"],
"轻快森林 \r\n\u{200b}\u{feff}冒险背景音乐"
);
assert_eq!(body["gpt_description_prompt"], expected_prompt);
assert_eq!(body["make_instrumental"], true);
assert_eq!(
body.as_object()