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
`/`` 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готово"),
+ "Код:\nlet 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-бот. Если короткий и длинный варианты одинаково полезны, всегда выбирай короткий.