Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/openplotva-app/src/dialog_jobs/answer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use openplotva_dialog::{
use openplotva_telegram::{
TELEGRAM_PARSE_MODE_HTML, clean_unicode_non_printables, decode_html_entities,
ensure_telegram_safe_text, sanitize_rich_html, sanitize_telegram_html,
telegram_html_from_stray_markdown,
};

/// Answer text of one provider round. Queued generation results deliberately
Expand All @@ -32,7 +33,8 @@ pub fn prepare_dialog_chat_response(raw: &str) -> String {
return String::new();
}

let decoded = decode_html_entities(&strip_code_fences(trimmed));
let decoded =
telegram_html_from_stray_markdown(&decode_html_entities(&strip_code_fences(trimmed)));
if dialog_response_requires_rich(&decoded) {
let rich = sanitize_rich_html(&decoded).trim().to_owned();
if dialog_response_requires_rich(&rich) {
Expand Down
14 changes: 14 additions & 0 deletions crates/openplotva-app/src/dialog_jobs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2373,6 +2373,20 @@ fn prepare_dialog_chat_response_matches_go_dialog_send_sanitizer() {
);
}

#[test]
fn prepare_dialog_chat_response_turns_stray_markdown_into_telegram_html() {
assert_eq!(
prepare_dialog_chat_response(
"Выбирай:\n1. **Кокетство** — *дразнить*\n2. Запусти `ls -la`"
),
"Выбирай:\n1. <b>Кокетство</b> — <i>дразнить</i>\n2. Запусти <code>ls -la</code>"
);
assert_eq!(
prepare_dialog_chat_response("Ну бл**ь, 2*3*4 и <b>так</b>"),
"Ну бл**ь, 2*3*4 и <b>так</b>"
);
}

#[test]
fn dialog_rich_router_keeps_classic_telegram_html_classic() {
assert!(!dialog_response_requires_rich("<pre>code</pre>"));
Expand Down
4 changes: 2 additions & 2 deletions crates/openplotva-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/// One corrected sample before the dialog router tries the next model.
/// Corrected samples from the same model before the dialog router tries the next one.
/// Repeated invalid samples must leave time for fallback and delivery.
pub const DEFAULT_DIALOG_MODEL_OUTPUT_RETRIES: i32 = 1;
pub const DEFAULT_DIALOG_MODEL_OUTPUT_RETRIES: i32 = 5;

pub const DEFAULT_RUNTIME_API_PORT: u16 = 9091;

Expand Down
1 change: 1 addition & 0 deletions crates/openplotva-dialog/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod history;
mod json_codec;
mod leak_guard;
mod persona;
pub mod reply_shape;
pub mod tool_telemetry;
pub mod transcript;
pub mod turn;
Expand Down
255 changes: 255 additions & 0 deletions crates/openplotva-dialog/src/reply_shape.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
//! 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.

/// How many of the bot's latest replies a new opening is compared with.
pub const RECENT_REPLY_WINDOW: usize = 3;

const OPENING_FRAME_WORDS: usize = 3;
const OPENING_SCAN_CHARS: usize = 240;

/// Laughter and exclamations that only precede the opening; skipping them makes
/// «Ха-ха, Катя, ну ты и…» and «Катя, ну ты и…» the same frame.
const LEADING_INTERJECTIONS: &[&str] = &[
"а",
"ага",
"ах",
"ахах",
"ахаха",
"ахахах",
"вау",
"ха",
"ха-ха",
"ха-ха-ха",
"хаха",
"хе-хе",
"хех",
"хм",
"лол",
"мда",
"о",
"ого",
"ой",
"омг",
"оу",
"ох",
"угу",
"упс",
"ура",
"ух",
"эх",
];

const GENERATION_VERBS: &[&str] = &[
"рисую",
"нарисую",
"дорисую",
"перерисую",
"запускаю",
"запущу",
"генерирую",
"сгенерирую",
];

/// 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<String> {
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
}

/// Whether the reply opens with the same frame as one of `recent` replies.
#[must_use]
pub fn repeats_recent_opening<'a>(reply: &str, recent: impl IntoIterator<Item = &'a str>) -> bool {
let Some(frame) = opening_frame(reply) else {
return false;
};
recent
.into_iter()
.filter_map(opening_frame)
.any(|previous| previous == frame)
}

/// Whether a sentence of the reply states, in the first person, that a drawing or a
/// generation is happening: «Рисую…», «Запускаю генерацию», «Сейчас нарисую». Questions,
/// conditionals and negations are offers or refusals, not announcements.
#[must_use]
pub fn announces_generation(reply: &str) -> bool {
let visible = strip_tags(reply);
sentences(&visible).any(|sentence| {
let trimmed = sentence.trim_end();
if trimmed.ends_with('?') {
return false;
}
let tokens: Vec<String> = words(sentence).map(|word| normalize(word.text)).collect();
if tokens.iter().any(|token| token == "если") {
return false;
}
tokens.iter().enumerate().any(|(index, token)| {
let negated = index > 0 && tokens[index - 1] == "не";
if negated {
return false;
}
let next = tokens.get(index + 1).map(String::as_str);
GENERATION_VERBS.contains(&token.as_str())
|| (token == "начинаю"
&& matches!(next, Some("рисовать" | "генерацию" | "генерировать")))
|| (token == "иду" && next == Some("рисовать"))
})
})
}

struct Word<'a> {
text: &'a str,
before_comma: bool,
}

/// Letter and digit runs, joined across inner hyphens («ха-ха», «во-первых»).
fn words(text: &str) -> impl Iterator<Item = Word<'_>> {
let mut rest = text;
std::iter::from_fn(move || {
let start = rest.find(char::is_alphanumeric)?;
let tail = &rest[start..];
let mut end = 0;
let mut chars = tail.char_indices().peekable();
while let Some((index, ch)) = chars.next() {
if ch.is_alphanumeric() {
end = index + ch.len_utf8();
} else if ch == '-' && chars.peek().is_some_and(|(_, next)| next.is_alphanumeric()) {
continue;
} else {
break;
}
}
let word = &tail[..end];
rest = &tail[end..];
Some(Word {
text: word,
before_comma: rest.starts_with(','),
})
})
}

fn sentences(text: &str) -> impl Iterator<Item = &str> {
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();
while let Some(ch) = chars.next() {
let opens_tag = ch == '<'
&& chars
.peek()
.is_some_and(|next| next.is_ascii_alphabetic() || *next == '/');
if opens_tag {
for inner in chars.by_ref() {
if inner == '>' {
break;
}
}
out.push(' ');
} else {
out.push(ch);
}
}
out
}

fn normalize(word: &str) -> String {
word.to_lowercase().replace('ё', "е")
}

fn starts_uppercase(word: &str) -> bool {
word.chars().next().is_some_and(char::is_uppercase)
}

fn is_interjection(normalized: &str) -> bool {
LEADING_INTERJECTIONS.contains(&normalized)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn opening_frame_drops_markup_names_and_leading_interjections() {
assert_eq!(
opening_frame("Моська, ну ты и мастер успокаивать! 😂").as_deref(),
Some("ну ты и")
);
assert_eq!(
opening_frame("Ха-ха, Моська, ну ты и выдала!").as_deref(),
Some("ну ты и")
);
assert_eq!(
opening_frame("😂 Евгений, ну ты и выдал!").as_deref(),
Some("ну ты и")
);
assert_eq!(
opening_frame("<b>Ой</b>, ну всё, Гидра, не надо так").as_deref(),
Some("ну все не")
);
assert_eq!(
opening_frame("Ого, да ты сегодня просто мастер").as_deref(),
Some("да ты сегодня")
);
assert_eq!(opening_frame("Кто? 🤨"), None);
assert_eq!(opening_frame("Да, верно."), None);
}

#[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));
}

#[test]
fn announced_generation_is_a_first_person_statement() {
assert!(announces_generation(
"Твой сценарий принят. Рисую твою радужную карстовую пещеру! 🎨"
));
assert!(announces_generation("Всё, не томи, запускаю."));
assert!(announces_generation(
"Сейчас нарисую новую версию с нуля, где палочки будут на месте."
));
assert!(announces_generation("Уже начинаю рисовать, жди."));
assert!(!announces_generation(
"Нарисовать? Могу, если скажешь, в каком стиле."
));
assert!(!announces_generation("Нарисую, если пришлёшь референс."));
assert!(!announces_generation("Я такое не рисую, это за гранью."));
assert!(!announces_generation("Картинка уже в чате, смотри выше."));
assert!(!announces_generation("Запущу генерацию?"));
}
}
Loading
Loading