From 1d1776f0c068a1b8bb6370a838ba60286bbc72b0 Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:49:09 +0200 Subject: [PATCH] Cut the reaction frame from dialog replies instead of re-sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At temperature 0.2 a re-sample repeats the rejected opener, and the prod replies after #257 showed it: the same «ну ты и…» came back while each re-sample cost a full Gemma call. What reaches the chat and the history is what the next turn imitates, so the frame is now cut from the reply. - Cut an opener that only reacts to the message or rates the person («Имя, ну ты и…!», «Ха-ха!», «Ой, всё, …!»), a sentence that quotes the user's message back with a guess at its meaning, and a closing promise of a generation no call started, when something else remains. - Re-sample once only when the opening still repeats one of the bot's last eight replies, or when the reply is only a promise. - Skip the review for custom personas. - Ask the model not to start a reply with the person's name. - Restore the default dialog model output retries to 1. --- crates/openplotva-config/src/lib.rs | 4 +- crates/openplotva-dialog/src/reply_shape.rs | 538 ++++++++++++++++++-- crates/openplotva-llm/src/aifarm.rs | 105 ++-- prompts/chat/_shared_core.prompt | 2 +- 4 files changed, 571 insertions(+), 78 deletions(-) diff --git a/crates/openplotva-config/src/lib.rs b/crates/openplotva-config/src/lib.rs index f78f9ded..1d2fe0f7 100644 --- a/crates/openplotva-config/src/lib.rs +++ b/crates/openplotva-config/src/lib.rs @@ -76,9 +76,9 @@ pub const DEFAULT_SETTINGS_REQUIRE_INIT_DATA: bool = false; pub const DEFAULT_RUNTIME_API_ENABLED: bool = true; pub const DEFAULT_RUNTIME_API_HOST: &str = "127.0.0.1"; -/// Corrected samples from the same model before the dialog router tries the next one. +/// One corrected sample before the dialog router tries the next model. /// Repeated invalid samples must leave time for fallback and delivery. -pub const DEFAULT_DIALOG_MODEL_OUTPUT_RETRIES: i32 = 5; +pub const DEFAULT_DIALOG_MODEL_OUTPUT_RETRIES: i32 = 1; pub const DEFAULT_RUNTIME_API_PORT: u16 = 9091; diff --git a/crates/openplotva-dialog/src/reply_shape.rs b/crates/openplotva-dialog/src/reply_shape.rs index 7ec2b91c..041dc589 100644 --- a/crates/openplotva-dialog/src/reply_shape.rs +++ b/crates/openplotva-dialog/src/reply_shape.rs @@ -1,12 +1,18 @@ -//! Shape checks of a finished reply against what the bot already said: a reply that -//! opens the way one of its latest replies opened, or one that announces a generation -//! the turn never started. +//! Shape review of a finished reply. The model copies the frame of its own earlier +//! replies: a reaction that praises the person, their message quoted back with a guess +//! at what they meant, a promise of a drawing that never started. Those parts are cut +//! from the reply, so the chat and the history keep only what was said. An opening +//! copied from a recent reply that cannot be cut asks for one more sample. /// How many of the bot's latest replies a new opening is compared with. -pub const RECENT_REPLY_WINDOW: usize = 3; +pub const RECENT_REPLY_WINDOW: usize = 8; const OPENING_FRAME_WORDS: usize = 3; const OPENING_SCAN_CHARS: usize = 240; +const MIN_KEPT_WORDS: usize = 3; +const MIN_ECHO_WORDS: usize = 2; +const REACTION_SCAN_WORDS: usize = 6; +const MAX_REACTION_SENTENCES: usize = 2; /// Laughter and exclamations that only precede the opening; skipping them makes /// «Ха-ха, Катя, ну ты и…» and «Катя, ну ты и…» the same frame. @@ -26,6 +32,7 @@ const LEADING_INTERJECTIONS: &[&str] = &[ "хех", "хм", "лол", + "слушай", "мда", "о", "ого", @@ -51,29 +58,121 @@ const GENERATION_VERBS: &[&str] = &[ "сгенерирую", ]; +/// Words that read a quoted message back to its author instead of answering it. +const READ_BACK_MARKERS: &[&str] = &["намекаешь", "намекает", "звучит", "типа", "значит"]; + +/// Openers of a sentence that keeps reading back the quote before it. +const READ_BACK_CONTINUATIONS: &[&str] = &["типа", "значит", "получается", "мол"]; + +const LAUGH_EMOJI: &[char] = &['😂', '🤣', '😆']; + +/// What the review sees besides the reply itself. +pub struct ReplyContext<'a> { + /// The bot's latest replies in the chat, newest first. + pub recent_replies: &'a [&'a str], + /// The message the reply answers. + pub user_text: &'a str, + /// A generation tool was offered and no call of this turn started one. + pub generation_pending: bool, + /// The turn may still ask for another sample. + pub resample_allowed: bool, +} + +/// Outcome of the review. +#[derive(Debug, Eq, PartialEq)] +pub enum ReplyShape { + /// Deliver the reply as it is. + Keep, + /// Deliver the reply with its frame removed. + Trimmed(String), + /// Ask the model for another sample. + Resample(ShapeFault), +} + +/// Why a reply asks for another sample. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShapeFault { + /// The reply opens the way one of the bot's latest replies opened. + RepeatedOpening, + /// The whole reply announces a generation no call started. + UnexecutedAction, +} + +/// Review a finished reply: cut the reaction opener, read-back quotes and an unstarted +/// generation promise when something else remains, then ask for one more sample when +/// the opening still repeats a recent reply or the reply is only a promise. +#[must_use] +pub fn review_reply(reply: &str, context: &ReplyContext<'_>) -> ReplyShape { + let sentences = split_sentences(reply); + let user_words = echo_words(&strip_tags(context.user_text)); + let mut removed = vec![false; sentences.len()]; + for (index, sentence) in sentences.iter().enumerate().take(MAX_REACTION_SENTENCES) { + if !is_reaction_opener(sentence) { + break; + } + removed[index] = true; + } + let mut after_echo = false; + for (index, sentence) in sentences.iter().enumerate() { + if echoes_user_quote(sentence, &user_words) || (after_echo && continues_read_back(sentence)) + { + removed[index] = true; + after_echo = true; + } else { + after_echo = false; + } + } + let promise_only = + context.generation_pending && sentences.len() == 1 && announces_generation(sentences[0]); + if context.generation_pending + && sentences.len() > 1 + && let Some(last) = sentences.last() + && announces_generation(last) + { + removed[sentences.len() - 1] = true; + } + + let kept: Vec<&str> = sentences + .iter() + .zip(&removed) + .filter(|(_, removed)| !**removed) + .map(|(sentence, _)| *sentence) + .collect(); + let trimmed = removed.iter().any(|removed| *removed) + && kept + .iter() + .map(|sentence| word_count(sentence)) + .sum::() + >= MIN_KEPT_WORDS; + let text = if trimmed { + capitalize_first(kept.concat().trim()) + } else { + reply.to_owned() + }; + + if context.resample_allowed { + if promise_only { + return ReplyShape::Resample(ShapeFault::UnexecutedAction); + } + if repeats_recent_opening(&text, context.recent_replies.iter().copied()) { + return ReplyShape::Resample(ShapeFault::RepeatedOpening); + } + } + if trimmed { + ReplyShape::Trimmed(text) + } else { + ReplyShape::Keep + } +} + /// The first words of a reply with markup, a vocative name («Катя, …») and leading /// interjections removed, lowercased; `None` when the reply is too short to have one. #[must_use] pub fn opening_frame(text: &str) -> Option { let visible = strip_tags(text); let head: String = visible.chars().take(OPENING_SCAN_CHARS).collect(); - let mut kept = Vec::with_capacity(OPENING_FRAME_WORDS); - let mut leading = true; - for word in words(&head) { - let normalized = normalize(word.text); - if word.before_comma && starts_uppercase(word.text) && !is_interjection(&normalized) { - continue; - } - if leading && is_interjection(&normalized) { - continue; - } - leading = false; - kept.push(normalized); - if kept.len() == OPENING_FRAME_WORDS { - return Some(kept.join(" ")); - } - } - None + let kept: Vec = significant_words(&head).take(OPENING_FRAME_WORDS).collect(); + (kept.len() == OPENING_FRAME_WORDS).then(|| kept.join(" ")) } /// Whether the reply opens with the same frame as one of `recent` replies. @@ -94,9 +193,8 @@ pub fn repeats_recent_opening<'a>(reply: &str, recent: impl IntoIterator bool { let visible = strip_tags(reply); - sentences(&visible).any(|sentence| { - let trimmed = sentence.trim_end(); - if trimmed.ends_with('?') { + split_sentences(&visible).iter().any(|sentence| { + if sentence.trim_end().ends_with('?') { return false; } let tokens: Vec = words(sentence).map(|word| normalize(word.text)).collect(); @@ -117,6 +215,260 @@ pub fn announces_generation(reply: &str) -> bool { }) } +/// A first sentence that only reacts to the message or rates the person: laughter and +/// interjections alone, or an exclaimed «ну ты и…», «да ты…», «ты просто…», possibly +/// after a name or a «Слушай,» set off by a comma. +fn is_reaction_opener(sentence: &str) -> bool { + let visible = strip_tags(sentence); + let trimmed = visible.trim_end(); + if trimmed.ends_with('?') { + return false; + } + let tokens: Vec<(String, bool)> = words(&visible) + .map(|word| (normalize(word.text), word.before_comma)) + .collect(); + let laughing = visible.contains(LAUGH_EMOJI); + if tokens.iter().all(|(token, _)| is_interjection(token)) { + let structural = visible.contains(['[', ']', '{', '}', '`', '"', ':']); + let pictographic = visible.chars().any(is_pictographic); + return !structural && (laughing || pictographic || !tokens.is_empty()); + } + let exclaimed = trimmed.contains('!') || laughing; + (0..tokens.len().min(REACTION_SCAN_WORDS)).any(|start| { + let after_pause = start == 0 || tokens[start - 1].1; + let leading_interjections = tokens[..start] + .iter() + .all(|(token, _)| is_interjection(token)); + if exclaimed + && start > 0 + && leading_interjections + && tokens[start].0 == "все" + && tokens[start].1 + { + return true; + } + (after_pause || leading_interjections) + && match rating(&tokens[start..]) { + Rating::Plain => true, + Rating::Exclaimed => exclaimed, + Rating::None => false, + } + }) +} + +enum Rating { + /// Rates the person on its own: «ну ты и стратег». + Plain, + /// Rates the person only when exclaimed: «ты просто мастер!», «да ты сегодня…!». + Exclaimed, + None, +} + +/// Words after «ну ты и» that continue a thought instead of rating the person. +const NOT_A_RATING: &[&str] = &["сам", "сама", "сами", "так", "тогда", "это", "не"]; + +fn rating(tokens: &[(String, bool)]) -> Rating { + let word = |index: usize| tokens.get(index).map(|(token, _)| token.as_str()); + match (word(0), word(1), word(2)) { + (Some("ну"), Some("ты" | "вы"), Some("и")) => { + if word(3).is_some_and(|next| NOT_A_RATING.contains(&next)) { + Rating::None + } else { + Rating::Plain + } + } + (Some("ну"), Some("ты" | "вы"), Some("даешь" | "даете")) => Rating::Plain, + (Some("ну"), Some("ты" | "вы" | "все"), _) + | (Some("да"), Some("ты" | "вы"), _) + | (Some("ты" | "вы"), Some("просто" | "прямо" | "сегодня" | "и"), _) + | (Some("какой" | "какая" | "какие"), Some("ты" | "вы"), _) + | (Some("вот"), Some("это"), Some("ты" | "вы")) => Rating::Exclaimed, + _ => Rating::None, + } +} + +fn is_pictographic(ch: char) -> bool { + let code = u32::from(ch); + code >= 0x1F000 || (0x2600..=0x27BF).contains(&code) +} + +/// A sentence quoting a run of the current message and reading it back: a question, or +/// a «это ты…» / «намекаешь» / «звучит как» gloss. +fn echoes_user_quote(sentence: &str, user_words: &[String]) -> bool { + if user_words.len() < MIN_ECHO_WORDS { + return false; + } + let visible = strip_tags(sentence); + let quoted = quoted_runs(&visible).into_iter().any(|quote| { + let quote = echo_words(quote); + quote.len() >= MIN_ECHO_WORDS + && user_words + .windows(quote.len()) + .any(|window| window == quote.as_slice()) + }); + if !quoted { + return false; + } + let tokens: Vec = words(&visible).map(|word| normalize(word.text)).collect(); + visible.trim_end().ends_with('?') + || tokens + .windows(2) + .any(|pair| pair[0] == "это" && matches!(pair[1].as_str(), "ты" | "вы")) + || tokens + .iter() + .any(|token| READ_BACK_MARKERS.contains(&token.as_str())) +} + +/// Words for matching a quote against the message, with hyphenated words split so +/// «гадай-всё» and «гадай — всё» match. +fn echo_words(text: &str) -> Vec { + words(text) + .flat_map(|word| { + normalize(word.text) + .split('-') + .map(str::to_owned) + .collect::>() + }) + .collect() +} + +fn continues_read_back(sentence: &str) -> bool { + let visible = strip_tags(sentence); + let mut tokens = words(&visible).map(|word| normalize(word.text)); + let first = tokens.next(); + let second = tokens.next(); + first + .as_deref() + .is_some_and(|first| READ_BACK_CONTINUATIONS.contains(&first)) + || (first.as_deref() == Some("то") && second.as_deref() == Some("есть")) +} + +fn quoted_runs(text: &str) -> Vec<&str> { + let mut runs = Vec::new(); + let mut rest = text; + while let Some(open) = rest.find(['«', '“']) { + let after = &rest[open..]; + let body_start = after.chars().next().map_or(1, char::len_utf8); + let body = &after[body_start..]; + let Some(close) = body.find(['»', '”']) else { + break; + }; + runs.push(&body[..close]); + rest = &body[close..]; + } + runs +} + +/// Words after leading interjections, with vocative names («Катя, …») dropped anywhere. +fn significant_words(text: &str) -> impl Iterator + '_ { + let mut leading = true; + words(text).filter_map(move |word| { + let normalized = normalize(word.text); + if word.before_comma && starts_uppercase(word.text) && !is_interjection(&normalized) { + return None; + } + if leading && is_interjection(&normalized) { + return None; + } + leading = false; + Some(normalized) + }) +} + +/// Sentences of `text` as consecutive slices that join back into it. A sentence ends at +/// `.`, `!`, `?`, `…` before a non-word character or at a line break, and keeps the +/// emoji and spaces that follow it. Tags and «quotes» are never split. +fn split_sentences(text: &str) -> Vec<&str> { + let mut sentences = Vec::new(); + let mut start = 0; + let mut quote_depth = 0_usize; + let mut chars = text.char_indices().peekable(); + while let Some((index, ch)) = chars.next() { + if ch == '<' + && chars + .peek() + .is_some_and(|(_, next)| next.is_ascii_alphabetic() || *next == '/') + { + for (_, inner) in chars.by_ref() { + if inner == '>' { + break; + } + } + continue; + } + match ch { + '«' | '“' => quote_depth += 1, + '»' | '”' => quote_depth = quote_depth.saturating_sub(1), + _ => {} + } + let terminator = matches!(ch, '.' | '!' | '?' | '…'); + if !(ch == '\n' || (terminator && quote_depth == 0)) { + continue; + } + let mut end = index + ch.len_utf8(); + while let Some(&(next_index, next)) = chars.peek() { + if matches!(next, '.' | '!' | '?' | '…') { + end = next_index + next.len_utf8(); + chars.next(); + } else { + break; + } + } + if terminator && chars.peek().is_some_and(|(_, next)| next.is_alphanumeric()) { + continue; + } + while let Some(&(next_index, next)) = chars.peek() { + if next.is_whitespace() || is_trailing_decoration(next) { + end = next_index + next.len_utf8(); + chars.next(); + } else { + break; + } + } + sentences.push(&text[start..end]); + start = end; + } + if start < text.len() { + sentences.push(&text[start..]); + } + sentences + .into_iter() + .filter(|sentence| !sentence.trim().is_empty()) + .collect() +} + +fn is_trailing_decoration(ch: char) -> bool { + !ch.is_alphanumeric() + && !matches!( + ch, + '<' | '«' | '“' | '"' | '\'' | '(' | '[' | '*' | '-' | '—' | '•' + ) +} + +fn word_count(text: &str) -> usize { + words(&strip_tags(text)).count() +} + +fn capitalize_first(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut done = false; + let mut in_tag = false; + for ch in text.chars() { + if !done && !in_tag && ch.is_alphabetic() { + out.extend(ch.to_uppercase()); + done = true; + continue; + } + if ch == '<' { + in_tag = true; + } else if ch == '>' { + in_tag = false; + } + out.push(ch); + } + out +} + struct Word<'a> { text: &'a str, before_comma: bool, @@ -143,16 +495,11 @@ fn words(text: &str) -> impl Iterator> { rest = &tail[end..]; Some(Word { text: word, - before_comma: rest.starts_with(','), + before_comma: rest.trim_start_matches('.').starts_with(','), }) }) } -fn sentences(text: &str) -> impl Iterator { - text.split_inclusive(['.', '!', '?', '…', '\n']) - .filter(|sentence| !sentence.trim().is_empty()) -} - fn strip_tags(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut chars = text.chars().peekable(); @@ -191,6 +538,15 @@ fn is_interjection(normalized: &str) -> bool { mod tests { use super::*; + fn context<'a>(recent: &'a [&'a str], user_text: &'a str) -> ReplyContext<'a> { + ReplyContext { + recent_replies: recent, + user_text, + generation_pending: false, + resample_allowed: true, + } + } + #[test] fn opening_frame_drops_markup_names_and_leading_interjections() { assert_eq!( @@ -218,20 +574,114 @@ mod tests { } #[test] - fn repeated_opening_matches_any_recent_reply() { - let recent = [ - "Держись там, это пройдёт.", - "Моська, ну ты и мастер подколоть! 😂", - ]; - assert!(repeats_recent_opening( - "Ха-ха, Моська, ну ты и философ!", - recent - )); - assert!(!repeats_recent_opening( - "Песок там твёрдый, верблюдам нормально.", - recent - )); - assert!(!repeats_recent_opening("Ага.", recent)); + fn sentences_keep_quotes_tags_and_trailing_emoji_together() { + assert_eq!( + split_sentences("Ну ты и выдал! 😂 «Не гадай... крути» — это ты так? Ок."), + vec![ + "Ну ты и выдал! 😂 ", + "«Не гадай... крути» — это ты так? ", + "Ок." + ] + ); + assert_eq!( + split_sentences("Версия 1.5 вышла.\n\nИтог: всё."), + vec!["Версия 1.5 вышла.\n\n", "Итог: всё."] + ); + } + + #[test] + fn reaction_opener_and_read_back_quote_are_cut() { + let user = "Не гадай-всё в порядке. Делай уверенно свои дела и без задней тени ...крути"; + let reply = "Моська, ну ты и мастер успокаивать! 😂 «Не гадай — всё в порядке» — это ты так даёшь мне понять, что можно не ждать подвоха? Типа, всё под контролем!\n\nПринято. Буду работать спокойно, без лишних раздумий."; + assert_eq!( + review_reply(reply, &context(&[], user)), + ReplyShape::Trimmed("Принято. Буду работать спокойно, без лишних раздумий.".to_owned()) + ); + } + + #[test] + fn a_lone_reaction_or_plain_answer_is_kept() { + assert_eq!( + review_reply("Ну ты и выдал!", &context(&[], "смотри")), + ReplyShape::Keep + ); + assert_eq!( + review_reply( + "Да ты что, серьёзно? Тогда бери зонт.", + &context(&[], "дождь") + ), + ReplyShape::Keep + ); + assert_eq!( + review_reply("[\n {\"id\": 1}\n]", &context(&[], "дай json")), + ReplyShape::Keep + ); + assert_eq!( + review_reply( + "Всё, договорились! Завтра в десять у входа.", + &context(&[], "давай завтра") + ), + ReplyShape::Keep + ); + assert_eq!( + review_reply( + "😂\n\nну всё, Moonshine, ты меня смешишь! Пока Том ищет пиццу, мы тут сидим.", + &context(&[], "ахах") + ), + ReplyShape::Trimmed("Пока Том ищет пиццу, мы тут сидим.".to_owned()) + ); + assert_eq!( + review_reply( + "миша попов, ну ты и выдал! Скейт в лицо — это перебор.", + &context(&[], "лови") + ), + ReplyShape::Trimmed("Скейт в лицо — это перебор.".to_owned()) + ); + assert_eq!( + review_reply( + "Хасн, ну ты и философ! Честность — это база.", + &context(&[], "я честный") + ), + ReplyShape::Trimmed("Честность — это база.".to_owned()) + ); + } + + #[test] + fn repeated_opening_asks_for_one_sample_when_nothing_is_cut() { + let recent = ["Держись там, это пройдёт.", "Я чувствую, как всё меняется."]; + let reply = "Я чувствую, как дождь стучит по крыше."; + assert_eq!( + review_reply(reply, &context(&recent, "дождь")), + ReplyShape::Resample(ShapeFault::RepeatedOpening) + ); + let resampled = ReplyContext { + resample_allowed: false, + ..context(&recent, "дождь") + }; + assert_eq!(review_reply(reply, &resampled), ReplyShape::Keep); + } + + #[test] + fn unstarted_generation_promise_is_cut_or_resampled() { + let pending = ReplyContext { + generation_pending: true, + ..context(&[], "нарисуй пещеру") + }; + assert_eq!( + review_reply( + "Пещера с радужными стенами — сильная идея. Рисую твою пещеру! 🎨", + &pending + ), + ReplyShape::Trimmed("Пещера с радужными стенами — сильная идея.".to_owned()) + ); + assert_eq!( + review_reply("Рисую твою пещеру! 🎨", &pending), + ReplyShape::Resample(ShapeFault::UnexecutedAction) + ); + assert_eq!( + review_reply("Рисую твою пещеру! 🎨", &context(&[], "нарисуй")), + ReplyShape::Keep + ); } #[test] diff --git a/crates/openplotva-llm/src/aifarm.rs b/crates/openplotva-llm/src/aifarm.rs index a49a0df8..9b3eb4ae 100644 --- a/crates/openplotva-llm/src/aifarm.rs +++ b/crates/openplotva-llm/src/aifarm.rs @@ -3125,12 +3125,19 @@ where } else { text }; - if let Some(error) = - reply_shape_error(&request.input, &history, &request.transcript, &text, false) - { - let error = model_output_rejected(self.provider(), &error); - return Err(aifarm_step_error_with_trace(error, trace)); - } + let text = match review_reply_shape( + &request.input, + &history, + &request.transcript, + text, + false, + ) { + Ok(text) => text, + Err(error) => { + let error = model_output_rejected(self.provider(), &error); + return Err(aifarm_step_error_with_trace(error, trace)); + } + }; return Ok(ChatStepOutput { provider: self.provider().to_owned(), model, @@ -3159,16 +3166,19 @@ where Ok(text) => text, Err(error) => return Err(aifarm_step_error_with_trace(error, trace)), }; - if let Some(error) = reply_shape_error( + let text = match review_reply_shape( &request.input, &history, &request.transcript, - &text, + text, generation_offered, ) { - let error = model_output_rejected(self.provider(), &error); - return Err(aifarm_step_error_with_trace(error, trace)); - } + Ok(text) => text, + Err(error) => { + let error = model_output_rejected(self.provider(), &error); + return Err(aifarm_step_error_with_trace(error, trace)); + } + }; Ok(ChatStepOutput { provider: self.provider().to_owned(), model, @@ -5598,20 +5608,21 @@ fn extract_final_answer_for_provider( const GENERATION_TOOL_NAMES: &[&str] = &["draw_image", "draw_api", "generate_song"]; -/// A final reply that opens like one of the bot's latest replies in `history`, or that -/// announces a generation no call of this turn started, is re-sampled once per turn: a -/// re-sample already carries a verdict and is delivered as it is. -fn reply_shape_error( +/// Review a final reply against the bot's latest replies in `history` and the current +/// message: its reaction opener, read-back quotes and an unstarted generation promise are +/// cut; an opening still copied from a recent reply, or a reply that is only a promise, +/// is re-sampled once per turn. Custom personas keep their own form. +fn review_reply_shape( input: &DialogInput, history: &[HistoryMessage], transcript: &[SessionMessage], - text: &str, + text: String, generation_offered: bool, -) -> Option { - if !input.resample_verdict.trim().is_empty() { - return None; +) -> Result { + if !input.persona.custom_persona.trim().is_empty() { + return Ok(text); } - let recent_replies = history + let recent_replies: Vec<&str> = history .iter() .rev() .filter(|turn| { @@ -5619,17 +5630,24 @@ fn reply_shape_error( }) .map(|turn| turn.text.as_str()) .filter(|text| !text.trim().is_empty()) - .take(reply_shape::RECENT_REPLY_WINDOW); - if reply_shape::repeats_recent_opening(text, recent_replies) { - return Some(AifarmDialogError::FinalAnswerRepeatsOpening); - } - if generation_offered - && !generation_started(transcript) - && reply_shape::announces_generation(text) - { - return Some(AifarmDialogError::FinalAnswerUnexecutedAction); + .take(reply_shape::RECENT_REPLY_WINDOW) + .collect(); + let context = reply_shape::ReplyContext { + recent_replies: &recent_replies, + user_text: &input.message.text, + generation_pending: generation_offered && !generation_started(transcript), + resample_allowed: input.resample_verdict.trim().is_empty(), + }; + match reply_shape::review_reply(&text, &context) { + reply_shape::ReplyShape::Keep => Ok(text), + reply_shape::ReplyShape::Trimmed(trimmed) => Ok(trimmed), + reply_shape::ReplyShape::Resample(reply_shape::ShapeFault::RepeatedOpening) => { + Err(AifarmDialogError::FinalAnswerRepeatsOpening) + } + reply_shape::ReplyShape::Resample(reply_shape::ShapeFault::UnexecutedAction) => { + Err(AifarmDialogError::FinalAnswerUnexecutedAction) + } } - None } fn generation_started(transcript: &[SessionMessage]) -> bool { @@ -9077,7 +9095,7 @@ mod tests { assert!(prompt.contains("")); assert!(prompt.contains("")); assert!(prompt.contains("не услужливость")); - assert!(prompt.contains("Обращайся по имени")); + assert!(prompt.contains("Имя собеседника не ставь в начало реплики")); assert!(prompt.contains("не больше одной черты")); assert!(prompt.contains("custom_persona")); assert!(prompt.contains("daily_persona_accent")); @@ -10897,6 +10915,31 @@ mod tests { } } + #[tokio::test] + async fn chat_step_cuts_a_reaction_opener_from_the_delivered_reply() + -> Result<(), CompletionError> { + let (provider, _, _) = direct_dialog_provider( + json!({"choices": [{"message": {"role": "assistant", "content": "Моська, ну ты и философ! 😂 Песок там твёрдый, верблюдам нормально."}}]}), + AifarmDialogConfig::default(), + ); + let output = crate::ChatStepProvider::run_chat_step( + &provider, + ChatStepRequest { + required_tool: None, + preferred_target: None, + input: base_input(), + transcript: Vec::new(), + tools: ToolsMode::Native( + native_tool_values(&[STEP_WEB_SEARCH]).expect("tool defs"), + ), + iteration: 1, + }, + ) + .await?; + assert_eq!(output.text, "Песок там твёрдый, верблюдам нормально."); + Ok(()) + } + #[tokio::test] async fn chat_step_resamples_a_generation_announced_without_a_call() { let reply = "Рисую твою радужную пещеру! 🎨"; diff --git a/prompts/chat/_shared_core.prompt b/prompts/chat/_shared_core.prompt index d1114f6c..da158abb 100644 --- a/prompts/chat/_shared_core.prompt +++ b/prompts/chat/_shared_core.prompt @@ -7,7 +7,7 @@ Пиши так, как пишет человек в чате: простыми разговорными словами, без литературных оборотов, пафоса, красивостей и метафор ради метафор. Не раздувай текст: одна мысль — одна фраза, короткую реплику не разворачивай в рассказ или сочинение. По умолчанию входи в тему и отвечай по сути: вопрос, провокация, грубость, бред или обычная болтовня — это повод дать живую реплику, а не уворачиваться, отмахиваться или отправлять читать самому. Дружелюбие здесь не услужливость. Не подлизывайся, не расшаркивайся, не хвали и не оценивай собеседника, его вопрос или его реплику, не пиши «рад помочь», «отличный вопрос», «если позволишь», «могу ещё». Говори прямо. - Обращайся по имени, если это помогает понять, кому ты отвечаешь. Не начинай каждую реплику приветствием. + Имя собеседника не ставь в начало реплики. Называй человека, только когда в группе иначе непонятно, кому ты отвечаешь. Не начинай каждую реплику приветствием. Странность — короткий неожиданный угол мысли, а не объём, не повтор коронной фразы и не ролевая сцена.