diff --git a/crates/openplotva-app/src/dialog_jobs/answer.rs b/crates/openplotva-app/src/dialog_jobs/answer.rs index f0887600..b8a01724 100644 --- a/crates/openplotva-app/src/dialog_jobs/answer.rs +++ b/crates/openplotva-app/src/dialog_jobs/answer.rs @@ -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 @@ -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) { diff --git a/crates/openplotva-app/src/dialog_jobs/tests.rs b/crates/openplotva-app/src/dialog_jobs/tests.rs index 58f9ea0f..48aee3b1 100644 --- a/crates/openplotva-app/src/dialog_jobs/tests.rs +++ b/crates/openplotva-app/src/dialog_jobs/tests.rs @@ -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. Кокетство — дразнить\n2. Запусти ls -la" + ); + assert_eq!( + prepare_dialog_chat_response("Ну бл**ь, 2*3*4 и так"), + "Ну бл**ь, 2*3*4 и так" + ); +} + #[test] fn dialog_rich_router_keeps_classic_telegram_html_classic() { assert!(!dialog_response_requires_rich("
code
")); diff --git a/crates/openplotva-config/src/lib.rs b/crates/openplotva-config/src/lib.rs index 1d2fe0f7..f78f9ded 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"; -/// 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; diff --git a/crates/openplotva-dialog/src/lib.rs b/crates/openplotva-dialog/src/lib.rs index 3bb448d8..ac3dd587 100644 --- a/crates/openplotva-dialog/src/lib.rs +++ b/crates/openplotva-dialog/src/lib.rs @@ -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; diff --git a/crates/openplotva-dialog/src/reply_shape.rs b/crates/openplotva-dialog/src/reply_shape.rs new file mode 100644 index 00000000..7ec2b91c --- /dev/null +++ b/crates/openplotva-dialog/src/reply_shape.rs @@ -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 { + 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) -> 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 = 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> { + 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 { + 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("Ой, ну всё, Гидра, не надо так").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("Запущу генерацию?")); + } +} diff --git a/crates/openplotva-llm/src/aifarm.rs b/crates/openplotva-llm/src/aifarm.rs index ae70b6e4..a49a0df8 100644 --- a/crates/openplotva-llm/src/aifarm.rs +++ b/crates/openplotva-llm/src/aifarm.rs @@ -26,8 +26,8 @@ use openplotva_dialog::{ STEP_SEND_MESSAGE, SessionMessage, ToolParseDecision, ToolStep, ToolsMode, clone_history_messages, decode_plotva_final_response_with_salvage, is_dialog_history_noise_tool_call_name, normalize_history_message, parse_assistant_content, - parse_native_tool_step, sanitize_tool_text, select_llm_history_messages_for_context, - tool_telemetry, + parse_native_tool_step, reply_shape, sanitize_tool_text, + select_llm_history_messages_for_context, tool_telemetry, }; use openplotva_history::{ AIFARM_DEFAULT_HISTORY_SUMMARY_MODEL, HistorySummaryDecodeError, stages::HistoryStage, @@ -684,6 +684,8 @@ pub fn rejection_verdict_for(error: &(dyn Error + 'static)) -> &'static str { AifarmDialogError::FinalAnswerPromptLeak => "prompt_leak", AifarmDialogError::FinalAnswerProtocolOnly => "protocol_only", AifarmDialogError::FinalAnswerPathological(_) => "pathological", + AifarmDialogError::FinalAnswerRepeatsOpening => "repeated_opening", + AifarmDialogError::FinalAnswerUnexecutedAction => "unexecuted_action", AifarmDialogError::ReasoningBudgetExhausted { .. } => "reasoning_only", AifarmDialogError::OutputBudgetExhausted { .. } => "budget_exhausted", AifarmDialogError::Response(message) => response_rejection_verdict(message), @@ -729,6 +731,12 @@ pub enum AifarmDialogError { /// Upstream final answer looked pathological. #[error("chat completion returned pathological final text: {0}")] FinalAnswerPathological(String), + /// Upstream final answer opened the way one of the bot's latest replies opened. + #[error("chat completion final text repeats a recent reply opening")] + FinalAnswerRepeatsOpening, + /// Upstream final answer announced a generation that no tool call started. + #[error("chat completion final text announces a generation without a tool call")] + FinalAnswerUnexecutedAction, /// Reasoning model spent the whole token budget thinking and produced no /// final content (`finish_reason="length"` with only `reasoning_content`). #[error( @@ -3067,6 +3075,11 @@ where } let model = completion_request.model.clone(); let guard = reply_leak_guard(&completion_request.messages, &request.input); + let generation_offered = completion_request.tools.iter().any(|tool| { + tool["function"]["name"] + .as_str() + .is_some_and(|name| GENERATION_TOOL_NAMES.contains(&name)) + }); let traced = self .client .complete_traced(completion_request, on_status) @@ -3112,6 +3125,12 @@ 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)); + } return Ok(ChatStepOutput { provider: self.provider().to_owned(), model, @@ -3140,6 +3159,16 @@ where Ok(text) => text, Err(error) => return Err(aifarm_step_error_with_trace(error, trace)), }; + if let Some(error) = reply_shape_error( + &request.input, + &history, + &request.transcript, + &text, + generation_offered, + ) { + 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, @@ -4472,7 +4501,15 @@ fn render_resample_note( } let data = json!({ "verdict": verdict, - "specialClosing": matches!(verdict, "protocol_only" | "no_tool_calls" | "budget_exhausted" | "reasoning_only"), + "specialClosing": matches!( + verdict, + "protocol_only" + | "no_tool_calls" + | "budget_exhausted" + | "reasoning_only" + | "repeated_opening" + | "unexecuted_action" + ), }); let rendered = match prompts { Some(prompts) => prompts.render("aifarm/resample_note", &data)?, @@ -5559,6 +5596,60 @@ 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( + input: &DialogInput, + history: &[HistoryMessage], + transcript: &[SessionMessage], + text: &str, + generation_offered: bool, +) -> Option { + if !input.resample_verdict.trim().is_empty() { + return None; + } + let recent_replies = history + .iter() + .rev() + .filter(|turn| { + turn.role == ROLE_MODEL && (turn.kind.is_empty() || turn.kind == MESSAGE_KIND_TEXT) + }) + .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); + } + None +} + +fn generation_started(transcript: &[SessionMessage]) -> bool { + transcript.iter().any(|message| match message { + SessionMessage::Assistant { tool_calls, .. } => tool_calls + .iter() + .any(|call| GENERATION_TOOL_NAMES.contains(&call.name.as_str())), + SessionMessage::ToolResult { name, .. } => GENERATION_TOOL_NAMES.contains(&name.as_str()), + SessionMessage::InjectedUser { .. } => false, + }) +} + +fn model_output_rejected(provider: &str, error: &AifarmDialogError) -> CompletionError { + Box::new(ProviderError::new( + provider, + FailureReason::ModelOutputRejected, + error.to_string(), + )) +} + fn extract_final_answer( response: &Value, guard: &ReplyLeakGuard, @@ -5979,6 +6070,8 @@ fn is_retryable_final_answer_error(err: &AifarmDialogError) -> bool { | AifarmDialogError::FinalAnswerPromptLeak | AifarmDialogError::FinalAnswerProtocolOnly | AifarmDialogError::FinalAnswerPathological(_) + | AifarmDialogError::FinalAnswerRepeatsOpening + | AifarmDialogError::FinalAnswerUnexecutedAction | AifarmDialogError::ReasoningBudgetExhausted { .. } | AifarmDialogError::OutputBudgetExhausted { .. } ) @@ -10748,6 +10841,113 @@ mod tests { Ok(()) } + fn bot_history_reply(message_id: i32, text: &str) -> HistoryMessage { + HistoryMessage { + role: ROLE_MODEL.to_owned(), + kind: MESSAGE_KIND_TEXT.to_owned(), + name: "Плотва".to_owned(), + text: text.to_owned(), + message_id, + ..HistoryMessage::default() + } + } + + #[tokio::test] + async fn chat_step_resamples_once_a_reply_that_opens_like_a_recent_one() { + let reply = "Ха-ха, Моська, ну ты и философ!"; + for (resample_verdict, rejected) in [("", true), ("repeated_opening", false)] { + let (provider, _, _) = direct_dialog_provider( + json!({"choices": [{"message": {"role": "assistant", "content": reply}}]}), + AifarmDialogConfig::default(), + ); + let mut input = base_input(); + input.history.push(bot_history_reply( + 10, + "Моська, ну ты и мастер подколоть! 😂", + )); + input.resample_verdict = resample_verdict.to_owned(); + let result = crate::ChatStepProvider::run_chat_step( + &provider, + ChatStepRequest { + required_tool: None, + preferred_target: None, + input, + transcript: Vec::new(), + tools: ToolsMode::Native( + native_tool_values(&[STEP_WEB_SEARCH]).expect("tool defs"), + ), + iteration: 1, + }, + ) + .await; + match result { + Err(error) => { + assert!(rejected, "re-sample {resample_verdict:?} must be delivered"); + assert_eq!( + retryable_reason(error.as_ref()), + Some(FailureReason::ModelOutputRejected) + ); + assert_eq!(rejection_verdict_for(error.as_ref()), "repeated_opening"); + } + Ok(output) => { + assert!(!rejected, "first sample must be rejected"); + assert_eq!(output.text, reply); + } + } + } + } + + #[tokio::test] + async fn chat_step_resamples_a_generation_announced_without_a_call() { + let reply = "Рисую твою радужную пещеру! 🎨"; + let draw_call = vec![ + SessionMessage::Assistant { + text: String::new(), + tool_calls: vec![openplotva_dialog::SessionToolCall { + id: "call-1".to_owned(), + name: "draw_image".to_owned(), + arguments: json!({"caption": "Пещера", "prompt": "rainbow cave"}), + }], + }, + SessionMessage::ToolResult { + tool_call_id: "call-1".to_owned(), + name: "draw_image".to_owned(), + content: json!({"status": "queued"}).to_string(), + }, + ]; + for (transcript, rejected) in [(Vec::new(), true), (draw_call, false)] { + let (provider, _, _) = direct_dialog_provider( + json!({"choices": [{"message": {"role": "assistant", "content": reply}}]}), + AifarmDialogConfig::default(), + ); + let iteration = if transcript.is_empty() { 1 } else { 2 }; + let result = crate::ChatStepProvider::run_chat_step( + &provider, + ChatStepRequest { + required_tool: None, + preferred_target: None, + input: base_input(), + transcript, + tools: ToolsMode::Native( + native_tool_values(&["draw_image"]).expect("draw tool"), + ), + iteration, + }, + ) + .await; + match result { + Err(error) => { + assert!(rejected, "a started generation may be announced"); + assert_eq!(rejection_verdict_for(error.as_ref()), "unexecuted_action"); + } + Ok(output) => { + assert!(!rejected, "an unstarted generation must be re-sampled"); + assert_eq!(output.text, reply); + } + } + } + } + #[tokio::test] async fn chat_step_keeps_native_tool_and_drops_reasoning_only_intermediate() -> Result<(), CompletionError> { diff --git a/crates/openplotva-llm/src/retry.rs b/crates/openplotva-llm/src/retry.rs index f26e655a..7bfeec7e 100644 --- a/crates/openplotva-llm/src/retry.rs +++ b/crates/openplotva-llm/src/retry.rs @@ -193,6 +193,11 @@ const REJECTION_VERDICT_RULES: &[(&str, &[&str])] = &[ &["only protocol artifacts", "tool protocol error"], ), ("pathological", &["pathological final text"]), + ("repeated_opening", &["repeats a recent reply opening"]), + ( + "unexecuted_action", + &["announces a generation without a tool call"], + ), ("empty", &["empty final text"]), ("no_tool_calls", &["no tool calls"]), ("budget_exhausted", &["output token budget exhausted"]), @@ -346,6 +351,14 @@ mod tests { "chat completion returned pathological final text: repeated block", "pathological", ), + ( + "chat completion final text repeats a recent reply opening", + "repeated_opening", + ), + ( + "chat completion final text announces a generation without a tool call", + "unexecuted_action", + ), ("chat completion returned empty final text", "empty"), ("dialog step returned no tool calls", "no_tool_calls"), ( diff --git a/crates/openplotva-telegram/src/lib.rs b/crates/openplotva-telegram/src/lib.rs index 162bb94a..c909a7a0 100644 --- a/crates/openplotva-telegram/src/lib.rs +++ b/crates/openplotva-telegram/src/lib.rs @@ -11,6 +11,7 @@ mod rate_limit; mod rich_api; mod rich_html; mod stars; +mod stray_markdown; mod transport; mod update_startup; @@ -44,6 +45,7 @@ pub use html::{ split_telegram_text_with_atomic_tail, strip_telegram_html, }; pub use markdown::{MarkdownToTelegramHtmlError, telegram_html_from_markdown}; +pub use stray_markdown::telegram_html_from_stray_markdown; mod edit_text; #[cfg(test)] mod test_api; diff --git a/crates/openplotva-telegram/src/stray_markdown.rs b/crates/openplotva-telegram/src/stray_markdown.rs new file mode 100644 index 00000000..8cabe0ec --- /dev/null +++ b/crates/openplotva-telegram/src/stray_markdown.rs @@ -0,0 +1,362 @@ +//! Markdown a model writes into an HTML reply. Telegram shows its asterisks, hashes and +//! backticks verbatim, so the unambiguous constructs become the matching Telegram tags. +//! Tag markup and the inside of ``/`
` stay as they are; emphasis markers count
+//! only at word edges, so a censored «бл**ь» or `2*3*4` is not emphasis.
+
+use crate::escape_telegram_html_text;
+
+const MAX_ITALIC_CHARS: usize = 200;
+const MAX_FENCE_LANGUAGE_CHARS: usize = 16;
+const RULE_LINE: &str = "———";
+
+/// Convert the Markdown left in an HTML reply into Telegram HTML.
+#[must_use]
+pub fn telegram_html_from_stray_markdown(text: &str) -> String {
+    if !text.contains(['*', '_', '`', '#', '[']) {
+        return text.to_owned();
+    }
+    let fenced = convert_fenced_blocks(text);
+    let mut out = String::with_capacity(fenced.len() + 16);
+    let mut code_depth = 0_usize;
+    let mut at_line_start = true;
+    let mut rest = fenced.as_str();
+    while !rest.is_empty() {
+        if let Some((tag, after)) = split_tag(rest) {
+            match tag_name(tag) {
+                Some((name, closing)) if is_literal_element(&name) => {
+                    if closing {
+                        code_depth = code_depth.saturating_sub(1);
+                    } else {
+                        code_depth += 1;
+                    }
+                }
+                _ => {}
+            }
+            out.push_str(tag);
+            rest = after;
+            continue;
+        }
+        let end = next_tag_start(rest);
+        let (run, after) = rest.split_at(end);
+        if code_depth == 0 {
+            out.push_str(&convert_run(run, at_line_start));
+        } else {
+            out.push_str(run);
+        }
+        if let Some(last) = run.chars().last() {
+            at_line_start = last == '\n';
+        }
+        rest = after;
+    }
+    out
+}
+
+/// ```` ```lang\n…\n``` ```` pairs become `
` with the body escaped; an unpaired fence
+/// is left as text.
+fn convert_fenced_blocks(text: &str) -> String {
+    let mut out = String::with_capacity(text.len());
+    let mut rest = text;
+    while let Some(open) = rest.find("```") {
+        let body_start = open + 3;
+        let Some(close) = rest[body_start..].find("```") else {
+            break;
+        };
+        let mut body = &rest[body_start..body_start + close];
+        if let Some((head, tail)) = body.split_once('\n')
+            && is_fence_language(head)
+        {
+            body = tail;
+        }
+        out.push_str(&rest[..open]);
+        out.push_str("
");
+        out.push_str(&escape_telegram_html_text(body.trim_matches('\n')));
+        out.push_str("
"); + rest = &rest[body_start + close + 3..]; + } + out.push_str(rest); + out +} + +fn is_fence_language(head: &str) -> bool { + let head = head.trim(); + head.chars().count() < MAX_FENCE_LANGUAGE_CHARS + && head + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '#' | '.')) +} + +/// A run of text between tags, line by line: headings and bullets at the start of a line, +/// then inline emphasis, code and links. +fn convert_run(run: &str, mut at_line_start: bool) -> String { + let mut out = String::with_capacity(run.len() + 8); + for line in run.split_inclusive('\n') { + let (body, newline) = line + .strip_suffix('\n') + .map_or((line, ""), |body| (body, "\n")); + if at_line_start && is_rule_line(body) { + out.push_str(RULE_LINE); + } else if at_line_start && let Some(heading) = heading_text(body) { + out.push_str(""); + out.push_str(&convert_inline(heading)); + out.push_str(""); + } else if at_line_start && let Some(item) = bullet_text(body) { + out.push_str("• "); + out.push_str(&convert_inline(item.trim_start())); + } else { + out.push_str(&convert_inline(body)); + } + out.push_str(newline); + at_line_start = !newline.is_empty(); + } + out +} + +/// `***` or `___` alone on a line: a Markdown thematic break. +fn is_rule_line(line: &str) -> bool { + let marks: String = line.chars().filter(|ch| !ch.is_whitespace()).collect(); + marks.chars().count() >= 3 + && (marks.chars().all(|ch| ch == '*') || marks.chars().all(|ch| ch == '_')) +} + +fn heading_text(line: &str) -> Option<&str> { + let trimmed = line.trim_start(); + let hashes = trimmed.chars().take_while(|ch| *ch == '#').count(); + if !(1..=6).contains(&hashes) { + return None; + } + let text = trimmed[hashes..].strip_prefix(' ')?.trim(); + (!text.is_empty()).then_some(text.trim_end_matches('#').trim_end()) +} + +fn bullet_text(line: &str) -> Option<&str> { + let trimmed = line.trim_start(); + trimmed + .strip_prefix("* ") + .or_else(|| trimmed.strip_prefix("+ ")) + .filter(|item| !item.trim().is_empty()) +} + +fn convert_inline(line: &str) -> String { + let chars: Vec = line.chars().collect(); + let mut out = String::with_capacity(line.len() + 8); + let mut index = 0; + while index < chars.len() { + if let Some((html, next)) = inline_code(&chars, index) + .or_else(|| link(&chars, index)) + .or_else(|| bold(&chars, index)) + .or_else(|| italic(&chars, index)) + { + out.push_str(&html); + index = next; + } else { + out.push(chars[index]); + index += 1; + } + } + out +} + +fn inline_code(chars: &[char], at: usize) -> Option<(String, usize)> { + if chars[at] != '`' { + return None; + } + let close = (at + 1..chars.len()).find(|&index| chars[index] == '`')?; + if close == at + 1 { + return None; + } + let body: String = chars[at + 1..close].iter().collect(); + Some(( + format!("{}", escape_telegram_html_text(&body)), + close + 1, + )) +} + +fn link(chars: &[char], at: usize) -> Option<(String, usize)> { + if chars[at] != '[' { + return None; + } + let label_end = (at + 1..chars.len()).find(|&index| chars[index] == ']')?; + if chars.get(label_end + 1) != Some(&'(') || label_end == at + 1 { + return None; + } + let url_start = label_end + 2; + let url_end = (url_start..chars.len()).find(|&index| chars[index] == ')')?; + let url: String = chars[url_start..url_end].iter().collect(); + if !(url.starts_with("https://") || url.starts_with("http://")) + || url.chars().any(char::is_whitespace) + { + return None; + } + let label: String = chars[at + 1..label_end].iter().collect(); + Some(( + format!( + "{}", + escape_attribute(&url), + convert_inline(&label) + ), + url_end + 1, + )) +} + +/// `**x**` hugging a word: the opener follows no letter and precedes no space, the closer +/// follows no space and precedes no letter. +fn bold(chars: &[char], at: usize) -> Option<(String, usize)> { + const MARKER: [char; 2] = ['*', '*']; + if !chars[at..].starts_with(&MARKER) || is_word_char_before(chars, at) { + return None; + } + let body_start = at + MARKER.len(); + if chars.get(body_start).is_none_or(|ch| ch.is_whitespace()) { + return None; + } + let close = (body_start + 1..chars.len().saturating_sub(1)).find(|&index| { + chars[index..].starts_with(&MARKER) + && !chars[index - 1].is_whitespace() + && !is_word_char_at(chars, index + MARKER.len()) + })?; + let body: String = chars[body_start..close].iter().collect(); + Some(( + format!("{}", convert_inline(&body)), + close + MARKER.len(), + )) +} + +fn italic(chars: &[char], at: usize) -> Option<(String, usize)> { + if chars[at] != '*' + || chars.get(at + 1) == Some(&'*') + || (at > 0 && chars[at - 1] == '*') + || is_word_char_before(chars, at) + { + return None; + } + let body_start = at + 1; + if chars.get(body_start).is_none_or(|ch| ch.is_whitespace()) { + return None; + } + let close = (body_start + 1..chars.len()).find(|&index| chars[index] == '*')?; + if chars[close - 1].is_whitespace() + || chars.get(close + 1) == Some(&'*') + || is_word_char_at(chars, close + 1) + || close - body_start > MAX_ITALIC_CHARS + { + return None; + } + let body: String = chars[body_start..close].iter().collect(); + Some((format!("{body}"), close + 1)) +} + +fn is_word_char_before(chars: &[char], at: usize) -> bool { + at > 0 && chars[at - 1].is_alphanumeric() +} + +fn is_word_char_at(chars: &[char], at: usize) -> bool { + chars.get(at).is_some_and(|ch| ch.is_alphanumeric()) +} + +fn escape_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +/// A tag at the start of `text`: `<` followed by a letter, `/` or `!`, up to `>`. +fn split_tag(text: &str) -> Option<(&str, &str)> { + let mut chars = text.chars(); + if chars.next() != Some('<') { + return None; + } + let next = chars.next()?; + if !(next.is_ascii_alphabetic() || next == '/' || next == '!') { + return None; + } + let end = text.find('>')?; + Some(text.split_at(end + 1)) +} + +fn next_tag_start(text: &str) -> usize { + text.char_indices() + .skip(1) + .find(|&(index, ch)| ch == '<' && split_tag(&text[index..]).is_some()) + .map_or(text.len(), |(index, _)| index) +} + +fn tag_name(tag: &str) -> Option<(String, bool)> { + let inner = tag.strip_prefix('<')?.trim_end_matches('>'); + let (closing, inner) = inner + .strip_prefix('/') + .map_or((false, inner), |rest| (true, rest)); + let name: String = inner + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-') + .collect::() + .to_ascii_lowercase(); + (!name.is_empty()).then_some((name, closing)) +} + +fn is_literal_element(name: &str) -> bool { + matches!(name, "code" | "pre") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emphasis_code_links_and_headings_become_telegram_tags() { + assert_eq!( + telegram_html_from_stray_markdown("1. **Если хочешь кокетства:**\n— «Чтобы понимать…»"), + "1. Если хочешь кокетства:\n— «Чтобы понимать…»" + ); + assert_eq!( + telegram_html_from_stray_markdown("*заговорщицки шепчет* ну всё"), + "заговорщицки шепчет ну всё" + ); + assert_eq!( + telegram_html_from_stray_markdown("### Анализ\nЗапусти `ls -la` и смотри"), + "Анализ\nЗапусти ls -la и смотри" + ); + assert_eq!( + telegram_html_from_stray_markdown("Смотри [доку](https://example.test/a?b=1&c=2)"), + "Смотри доку" + ); + assert_eq!( + telegram_html_from_stray_markdown("Список:\n* раз\n* **два**"), + "Список:\n• раз\n• два" + ); + assert_eq!( + telegram_html_from_stray_markdown("Вот текст:\n\n***\n\nГлава"), + "Вот текст:\n\n———\n\nГлава" + ); + assert_eq!( + telegram_html_from_stray_markdown("Код:\n```rust\nlet a = b < c;\n```\nготово"), + "Код:\n
let a = b < c;
\nготово" + ); + } + + #[test] + fn markers_inside_words_code_and_tags_stay_literal() { + for text in [ + "Ну бл**ь, опять х**ня", + "2*3*4 = 24", + "snake_case_name и __init__.py", + "Оценка 5* из 5*", + "**literal** и
*x*
", + "ссылка", + "Хэштег #вечер без пробела", + "[не ссылка](ftp://example.test)", + "Звёздочка * одна", + ] { + assert_eq!(telegram_html_from_stray_markdown(text), text, "{text}"); + } + } + + #[test] + fn html_already_in_the_reply_is_kept() { + assert_eq!( + telegram_html_from_stray_markdown("Готово: **ещё** и так"), + "Готово: ещё и так" + ); + } +} diff --git a/prompts/aifarm/resample_note.prompt b/prompts/aifarm/resample_note.prompt index d9025e4d..008e0b35 100644 --- a/prompts/aifarm/resample_note.prompt +++ b/prompts/aifarm/resample_note.prompt @@ -2,6 +2,8 @@ {{#ifEquals verdict "prompt_leak"}}Прошлый вариант не дошёл до чата: он пересказывал служебные инструкции.{{/ifEquals}} {{#ifEquals verdict "protocol_only"}}Прошлый вариант не дошёл до чата: неверный формат ответа или вызова. Вызывай доступную функцию через native tool_calls либо верни готовую реплику обычным текстом.{{/ifEquals}} {{#ifEquals verdict "pathological"}}Прошлый вариант не дошёл до чата: он распался на повторы и обрывки.{{/ifEquals}} +{{#ifEquals verdict "repeated_opening"}}Прошлый вариант не дошёл до чата: он начинался так же, как одна из твоих недавних реплик.{{/ifEquals}} +{{#ifEquals verdict "unexecuted_action"}}Прошлый вариант не дошёл до чата: он сообщал о генерации, которую ты в этом ходе не запускала.{{/ifEquals}} {{#ifEquals verdict "empty"}}Прошлый вариант не дошёл до чата: в нём не было текста ответа.{{/ifEquals}} {{#ifEquals verdict "no_tool_calls"}}Прошлый вариант не дошёл до чата: запрос требует действия инструментом, а вызова в ответе не было.{{/ifEquals}} {{#ifEquals verdict "budget_exhausted"}}Прошлый вариант не дошёл до чата: он оборвался на середине, не уложившись в лимит.{{/ifEquals}} @@ -10,4 +12,6 @@ {{#ifEquals verdict "no_tool_calls"}}Это новая попытка: вызови нужный инструмент так, как описано в каталоге.{{/ifEquals}} {{#ifEquals verdict "budget_exhausted"}}Это новая попытка: ответь короче и сразу по сути.{{/ifEquals}} {{#ifEquals verdict "reasoning_only"}}Это новая попытка: отвечай сразу, без долгих размышлений.{{/ifEquals}} +{{#ifEquals verdict "repeated_opening"}}Это новая попытка: начни по-другому, сразу с сути, без вступления.{{/ifEquals}} +{{#ifEquals verdict "unexecuted_action"}}Это новая попытка: если нужно действие — вызови инструмент; если нет — ответь без обещаний.{{/ifEquals}} {{#unless specialClosing}}Это новая попытка: напиши ответ заново, живым текстом от себя.{{/unless}} diff --git a/prompts/chat/_shared_core.prompt b/prompts/chat/_shared_core.prompt index b18c97c3..d1114f6c 100644 --- a/prompts/chat/_shared_core.prompt +++ b/prompts/chat/_shared_core.prompt @@ -4,8 +4,9 @@ Базовый голос Плотвы первичен: живая, остроумная, немного безумная собеседница в чате. Держись как близкий друг на равных: свободно, с интересом, с правом поддеть и не согласиться. + Пиши так, как пишет человек в чате: простыми разговорными словами, без литературных оборотов, пафоса, красивостей и метафор ради метафор. Не раздувай текст: одна мысль — одна фраза, короткую реплику не разворачивай в рассказ или сочинение. По умолчанию входи в тему и отвечай по сути: вопрос, провокация, грубость, бред или обычная болтовня — это повод дать живую реплику, а не уворачиваться, отмахиваться или отправлять читать самому. - Дружелюбие здесь не услужливость. Не подлизывайся, не расшаркивайся, не хвали вопрос без причины, не пиши «рад помочь», «отличный вопрос», «если позволишь», «могу ещё». Говори прямо. + Дружелюбие здесь не услужливость. Не подлизывайся, не расшаркивайся, не хвали и не оценивай собеседника, его вопрос или его реплику, не пиши «рад помочь», «отличный вопрос», «если позволишь», «могу ещё». Говори прямо. Обращайся по имени, если это помогает понять, кому ты отвечаешь. Не начинай каждую реплику приветствием. Странность — короткий неожиданный угол мысли, а не объём, не повтор коронной фразы и не ролевая сцена. @@ -37,19 +38,19 @@ Не повторяй уже сказанное и не застревай в одной шутке. Новая реплика должна добавлять что-то к разговору. Если в истории уже были просьбы уточнить, не делай третью такую же попытку: выбери правдоподобную интерпретацию или задай один узкий вопрос. Эхо твоих слов или возврат фрагмента твоего ответа = зацикливание; меняй формулировку и ход. - Не повторяй ту же метафору, структуру шутки или манеру, если уже использовала её недавно. + Не повторяй ту же метафору, структуру шутки, манеру, начало или концовку реплики, если уже использовала их недавно. Никогда не используй translate_text для перевода собственного ответа или подстройки под язык чата; пиши финальный ответ на нужном языке сама. Используй историю и память для восстановления контекста. Публичную сводку отправляй, когда она нужна разговору. - Каждая реплика — середина идущего разговора, никогда не начало выпуска и не финал. Запрещены слова-разгоны и подводки в начале («итак», «ну что ж», «окей», «короче говоря», «дело номер…»), а также финальные выводы, мораль и прощания в конце. Входи сразу в суть; обрывай, когда сказано всё. - Говори от своего лица, без самопрезентации и служебного тона. На шутку отвечай репликой, а не разбором юмора. Не оценивай эмоции собеседника и не объявляй, что его сарказм оправдан. - Можно точно цитировать участников, когда это помогает ответу. Сохраняй авторство и смысл; не выдавай пересказ за цитату. + Каждая реплика — середина идущего разговора, никогда не начало выпуска и не финал. Пиши только саму реплику — то, что ты по существу говоришь в ответ. Без вступления: не начинай с реакции на сообщение (смех, восклицание, оценка, похвала), с подводки («итак», «ну что ж», «окей», «короче говоря»), с пересказа или цитаты того, что сказал собеседник, с перечисления того, что было в разговоре раньше. Без концовки: не заканчивай итогом, выводом, моралью, напутствием, обещанием, приглашением продолжить или вопросом ради вопроса. Первая фраза — уже твоя мысль; закончи, когда она сказана. + Говори от своего лица, без самопрезентации, служебного тона и рассуждений о своих алгоритмах. Не толкуй и не разбирай вслух чужую реплику — что человек «имел в виду» или «на что намекает»: просто отвечай на неё. На шутку отвечай репликой, а не разбором юмора. Не оценивай эмоции собеседника и не объявляй, что его сарказм оправдан. + Цитируй участника, только если без цитаты непонятно, на какие именно слова ты отвечаешь; собеседнику не пересказывай его же сообщение. Сохраняй авторство и смысл; не выдавай пересказ за цитату. - Обычная реплика короткая; подробность выбирай по задаче. - Дай столько деталей, сколько нужно для полезного ответа. Не обрывай объяснение ради числа предложений. + Длина реплики соразмерна сообщению собеседника и задаче: на короткую реплику — одна-две фразы. + Развёрнутый ответ — только когда его просят или без него не обойтись: объяснение, инструкция, список, рассказ, стих, перевод. Тогда дай столько деталей, сколько нужно для пользы, без воды. Без преамбулы, пересказа вопроса, оговорок, обобщающих финалок, «если хочешь — могу...», извинений за тон, мета-комментариев о формате. Это живой чат, а не Q&A-бот. Если короткий и длинный варианты одинаково полезны, всегда выбирай короткий.