diff --git a/Cargo.lock b/Cargo.lock index 04a41f13..f05d4046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2637,9 +2637,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" dependencies = [ "bitflags", "getopts", diff --git a/Cargo.toml b/Cargo.toml index e1f02145..062e435d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,7 +51,7 @@ futures-util = "0.3.31" image = { version = "0.25.10", default-features = false, features = ["gif", "jpeg", "png", "webp"] } opentelemetry = "0.32.0" pgvector = "0.4.1" -pulldown-cmark = "0.13.4" +pulldown-cmark = "0.13.5" quick-xml = { version = "0.41.0", features = ["serialize"] } rand = "0.10.1" redis = "1.2.1" diff --git a/crates/openplotva-app/src/agent_tools.rs b/crates/openplotva-app/src/agent_tools.rs index aa775de7..50ce745b 100644 --- a/crates/openplotva-app/src/agent_tools.rs +++ b/crates/openplotva-app/src/agent_tools.rs @@ -273,7 +273,17 @@ impl MediaFailureExplainer { .await .ok()?; input.disable_tools = true; - input.reference_context.push(format!("Media delivery has ended with a problem: {failure}. Explain this once in your usual voice. Do not invent a cause or claim an undelivered result. Do not restart generation.")); + input.reference_context.push(format!( + "The bot supports image and music generation. It accepted this request, but delivery ended with a problem: {failure}. Tools are disabled only for this failure notice. This does not mean the bot lacks the capability." + )); + input.reference_context.push(format!( + "Original request, for context only: {}", + input.message.text + )); + let notice_task = "Briefly report the delivery problem stated above. Preserve the distinction between failed and partial delivery. Do not invent a cause or claim that the bot cannot generate music or images. Do not perform the original request, write lyrics, provide a text substitute, or restart generation. Write in the language of the original request quoted above, in your usual voice."; + input.message.text = notice_task.to_owned(); + input.message.normalized = notice_task.to_owned(); + input.message.original_text = notice_task.to_owned(); struct NoTools; impl DialogToolbox for NoTools {} let result = crate::dialog_turn::run_captured_session( diff --git a/crates/openplotva-app/src/music_jobs.rs b/crates/openplotva-app/src/music_jobs.rs index 394cc9a7..0e12a5b3 100644 --- a/crates/openplotva-app/src/music_jobs.rs +++ b/crates/openplotva-app/src/music_jobs.rs @@ -474,6 +474,7 @@ fn song_prompt_retryable_reason(error: &MusicGenerationError) -> Option Result<(), Box> { + let lyrics = "[Verse]\nMorning. Новый day.\nAt home ждёт семья."; + let response = json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": serde_json::to_string(&json!({ + "analysis": "Synthwave with clean female vocals.", + "title": "Night City", + "vocal_language": "ru", + "vocals": "female", + "genre": "synthwave", + "bpm": 102, + "key": "", + "sound": ["synth bass", "gated reverb drums", "analog pads", "arpeggiated plucks", "bright lead synth"], + "character": ["neon mood", "nostalgic 80s", "driving"], + "structure": ["synth intro", "verse over a pulsing bass", "big chorus", "outro fades on the hook"], + "vocal_style": "clean vocals with light reverb", + "references": [], + "duration_seconds": 180, + "lyrics": lyrics, + }))? + } + }] + }); + let transport = FakeTransport::new(vec![Ok(AifarmHttpResponse { + status_code: 200, + status_text: "OK".to_owned(), + body: serde_json::to_vec(&response)?, + ..AifarmHttpResponse::default() + })]); + let generator = AifarmStructuredJsonGenerator::with_transport( + AifarmStructuredJsonConfig { + client: AifarmClientConfig { + direct_url: "https://llm.example/v1/chat/completions".to_owned(), + ..AifarmClientConfig::default() + }, + ..AifarmStructuredJsonConfig::default() + }, + transport.clone(), + ) + .with_prompt_store(prompt_store_with(&[( + "music/song_director.prompt", + "{{role \"system\"}}custom song system {{topic}}{{role \"user\"}}custom song user {{request}} {{languageHint}}", + )])); + + let result = generator + .optimize_song_prompt( + openplotva_media::acestep::SongPromptRequest { + topic: "night city".to_owned(), + request_text: + "Спой ровно этот текст:\nMorning. Новый day.\nAt home ждёт семья." + .to_owned(), + language_hint: "en-US".to_owned(), + ..openplotva_media::acestep::SongPromptRequest::default() + }, + &mut |_status| {}, + ) + .await?; + + assert_eq!(result.title, "Night City"); + assert_eq!(result.vocal_language, "ru"); + assert_eq!(result.vocals, "female"); + assert_eq!(result.duration_seconds, 180); + assert!( + result + .style + .starts_with("synthwave, female clean vocals with light reverb, synth bass"), + "{}", + result.style + ); + assert!(result.style.ends_with(", 102 BPM"), "{}", result.style); + assert_eq!(result.lyrics, lyrics); + + Ok(()) + } + #[tokio::test] async fn http_client_takes_the_result_from_the_sync_submit() -> Result<(), CompletionError> { let completion_payload = diff --git a/crates/openplotva-llm/src/gemini.rs b/crates/openplotva-llm/src/gemini.rs index d8e7b4b6..82550675 100644 --- a/crates/openplotva-llm/src/gemini.rs +++ b/crates/openplotva-llm/src/gemini.rs @@ -1361,7 +1361,7 @@ where .await?; let payload: openplotva_media::acestep::SongPromptPayload = serde_json::from_value(payload).map_err(GeminiMediaPromptOptimizerError::Decode)?; - openplotva_media::acestep::normalize_song_prompt_payload(payload, &topic, &language) + openplotva_media::acestep::normalize_song_prompt_payload_for_request(payload, &request) .map_err(|error| GeminiMediaPromptOptimizerError::Tool(error.to_string())) } diff --git a/crates/openplotva-media/src/acestep.rs b/crates/openplotva-media/src/acestep.rs index 5fe4ee07..36916ebe 100644 --- a/crates/openplotva-media/src/acestep.rs +++ b/crates/openplotva-media/src/acestep.rs @@ -784,6 +784,24 @@ pub fn normalize_song_prompt_payload( payload: SongPromptPayload, requested_topic: &str, requested_language: &str, +) -> Result { + normalize_song_prompt_payload_with_source(payload, requested_topic, requested_language, "") +} + +/// Validate generated material against the listener's original text when supplied. +pub fn normalize_song_prompt_payload_for_request( + payload: SongPromptPayload, + request: &SongPromptRequest, +) -> Result { + let (topic, language) = normalize_song_prompt_input(request)?; + normalize_song_prompt_payload_with_source(payload, &topic, &language, &request.request_text) +} + +fn normalize_song_prompt_payload_with_source( + payload: SongPromptPayload, + requested_topic: &str, + requested_language: &str, + request_text: &str, ) -> Result { let topic = requested_topic.trim(); if topic.is_empty() { @@ -801,6 +819,7 @@ pub fn normalize_song_prompt_payload( )); } let lyrics = canonicalize_song_lyrics(&payload.lyrics); + let supplied_lyrics = lyrics_copied_from_request(&lyrics.text, request_text); let vocals_field = payload.vocals.trim().to_ascii_lowercase(); let instrumental = vocals_field == "instrumental" || lyrics.line_count == 0; let vocals = if instrumental { @@ -811,17 +830,19 @@ pub fn normalize_song_prompt_payload( String::new() }; if !instrumental { - if lyrics.line_count < SONG_MIN_LYRIC_LINES - || lyrics.line_count > SONG_MAX_LYRIC_LINES - || lyrics.sections < 2 - || !lyrics.has_chorus + if lyrics.line_count > SONG_MAX_LYRIC_LINES + || (!supplied_lyrics + && (lyrics.line_count < SONG_MIN_LYRIC_LINES + || lyrics.sections < 2 + || !lyrics.has_chorus)) { return Err(AceStepError::InvalidResponse( SONG_LYRICS_STRUCTURE_REJECTION.to_owned(), )); } - if !lyrics_script_matches_language(&lyrics.text, &language) - || foreign_script_line_share(&lyrics.text, &language) > SONG_MAX_FOREIGN_LINE_SHARE + if !supplied_lyrics + && (!lyrics_script_matches_language(&lyrics.text, &language) + || foreign_script_line_share(&lyrics.text, &language) > SONG_MAX_FOREIGN_LINE_SHARE) { return Err(AceStepError::InvalidResponse( SONG_LYRICS_LANGUAGE_REJECTION.to_owned(), @@ -876,6 +897,21 @@ pub fn normalize_song_prompt_payload( }) } +fn lyrics_copied_from_request(lyrics: &str, request_text: &str) -> bool { + let words = |text: &str| { + text.lines() + .filter(|line| section_marker(line.trim()).is_none()) + .flat_map(str::split_whitespace) + .map(str::to_owned) + .collect::>() + }; + let sung = words(lyrics); + !sung.is_empty() + && words(request_text) + .windows(sung.len()) + .any(|part| part == sung) +} + const fn non_zero_or(value: u32, fallback: u32) -> u32 { if value == 0 { fallback } else { value } } @@ -2627,6 +2663,61 @@ mod tests { Ok(()) } + #[test] + fn supplied_lyrics_keep_short_mixed_language_material() { + let mut payload = rap_payload(); + payload.vocal_language = "ru".to_owned(); + payload.lyrics = "[Verse]\nMorning. Новый day.\n[Chorus]\nAt home ждёт семья.".to_owned(); + let request = SongPromptRequest { + topic: "garage grime".to_owned(), + request_text: "Спой ровно этот текст:\n\nMorning. Новый day. At home ждёт семья." + .to_owned(), + ..SongPromptRequest::default() + }; + let result = super::normalize_song_prompt_payload_for_request(payload.clone(), &request) + .expect("supplied words do not need a generated song structure"); + assert_eq!( + result.lyrics, + "[Verse]\nMorning. Новый day.\n\n[Chorus]\nAt home ждёт семья." + ); + + let unrelated = SongPromptRequest { + request_text: "Write a song about city lights".to_owned(), + ..request.clone() + }; + assert!( + super::normalize_song_prompt_payload_for_request(payload.clone(), &unrelated).is_err() + ); + let missing = SongPromptRequest { + request_text: String::new(), + topic: request.request_text.clone(), + ..request.clone() + }; + assert!( + super::normalize_song_prompt_payload_for_request(payload.clone(), &missing).is_err(), + "a model-written topic is not the original lyrics" + ); + payload + .lyrics + .push_str("\nA new line the listener never supplied."); + assert!(super::normalize_song_prompt_payload_for_request(payload, &request).is_err()); + } + + #[test] + fn supplied_lyrics_do_not_bypass_the_size_limit() { + let mut payload = rap_payload(); + payload.lyrics = (0..70) + .map(|n| format!("Original lyric line {n}")) + .collect::>() + .join("\n"); + let request = SongPromptRequest { + topic: "song".to_owned(), + request_text: payload.lyrics.clone(), + ..SongPromptRequest::default() + }; + assert!(super::normalize_song_prompt_payload_for_request(payload, &request).is_err()); + } + #[test] fn song_director_payload_rejects_bad_material() { let rejection = |payload: SongPromptPayload, language: &str| { diff --git a/prompts/music/song_director.prompt b/prompts/music/song_director.prompt index 132ca6da..4b5a5ec5 100644 --- a/prompts/music/song_director.prompt +++ b/prompts/music/song_director.prompt @@ -48,6 +48,7 @@ Every tag is an English phrase of one to eight words that a producer of that gen ## 4. Write the lyrics (empty string for instrumentals) +- If the listener supplies exact lyrics, preserve every word, punctuation mark, language switch and the order. You may add section markers and line breaks. Do not add verses, ad-libs or repeated choruses unless requested. Supplied lyrics override the length, rhyme, language and chorus rules below. Choose a duration that fits the supplied text. - Same language throughout, in that language's natural word order and stress; native idioms, no calques. Names and details from the request appear as they are. - The chorus carries the title line and a hook that repeats; verses tell the story in concrete images (objects, places, actions), not abstractions. Every line should be singable in one breath. - Rhyme the way the genre does: rap uses internal and multi-syllable rhymes and rhythmic repetition; pop and rock use clean end rhymes with a lift into the chorus; metal and punk favour hard consonants and shouted chants; folk and chanson tell a story with plain exact rhymes; ambient has no lyrics.