SilverElixir commited on
Commit
693519a
·
1 Parent(s): 217e497

Formatting like Claude: one list item per line, prompt rule plus post-fix

Browse files
lumen_formatting.py CHANGED
@@ -122,6 +122,24 @@ _BULLET_MARKER_RE = re.compile(r"^([ \t]*)[-*][ \t]+", re.MULTILINE)
122
  def _normalize_bullet_markers(text: str) -> str:
123
  return _BULLET_MARKER_RE.sub(lambda m: m.group(1) + "• ", text)
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  # ── Markdown-заголовки "#"/"##"/"###" → **жирный текст** ────────────────────
126
  # Регрессия 18.08.2026: модели пишут ### вопреки промпту. Режем ATX по CommonMark (решётки + пробел), C#/#tag не трогаем.
127
  _HEADER_MARKER_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+(.*)$", re.MULTILINE)
@@ -196,9 +214,10 @@ def _md_to_html(text: str) -> str:
196
  ДО LaTeX/таблиц/списков/markdown, иначе обратные слэши и "|"/"-" внутри
197
  реального кода (regex, пути Windows, побитовое ИЛИ) были бы испорчены.
198
  1.3. LaTeX → юникод (_scrub_latex) — код уже вынесен шагом 1.
199
- 1.4. Маркеры списков "-"/"* " → "•" (_normalize_bullet_markers) — ДО таблиц,
200
- чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой
201
- и не была принята за маркер списка.
 
202
  1.45. Markdown-цитаты "> " → сентинелы \x00BQS\x00/\x00BQE\x00 (_convert_
203
  blockquotes) — сентинелы, не сразу <blockquote>, т.к. Phase 2 экранировал
204
  бы буквальный тег; настоящий тег подставляется после Phase 3 (см. ниже).
@@ -260,6 +279,10 @@ def _md_to_html(text: str) -> str:
260
  # чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой.
261
  text = _normalize_bullet_markers(text)
262
 
 
 
 
 
263
  # ── Phase 1.41: markdown-заголовки "#"/"##"/"###" → **жирный текст** (см.
264
  # _normalize_headers выше) — после списков, до HTML-экранирования и до Phase 3
265
  # (получившийся "**...**" обрабатывается обычным bold-регэкспом на общих
 
122
  def _normalize_bullet_markers(text: str) -> str:
123
  return _BULLET_MARKER_RE.sub(lambda m: m.group(1) + "• ", text)
124
 
125
+ # Слипшиеся в один абзац буллеты ("• A ... • B ... • C", прод 22.09.2026: модель
126
+ # написала весь список сравнения в одну строку) — разносим по строкам. Только
127
+ # длинные абзацы с 3+ разделителями: короткие "чай • кофе" — обычная проза, не трогаем.
128
+ _INLINE_BULLETS_MIN_SEPS = 3
129
+ _INLINE_BULLETS_MIN_LEN = 200
130
+
131
+ def _split_inline_bullets(text: str) -> str:
132
+ out = []
133
+ for line in text.split("\n"):
134
+ if len(line) >= _INLINE_BULLETS_MIN_LEN and line.count(" • ") >= _INLINE_BULLETS_MIN_SEPS:
135
+ head, *items = line.split(" • ")
136
+ # Первый кусок — вводная фраза ("Вот моменты:"), дальше — пункты.
137
+ out.append(head.rstrip())
138
+ out.extend("• " + item.strip() for item in items if item.strip())
139
+ else:
140
+ out.append(line)
141
+ return "\n".join(out)
142
+
143
  # ── Markdown-заголовки "#"/"##"/"###" → **жирный текст** ────────────────────
144
  # Регрессия 18.08.2026: модели пишут ### вопреки промпту. Режем ATX по CommonMark (решётки + пробел), C#/#tag не трогаем.
145
  _HEADER_MARKER_RE = re.compile(r"^[ \t]*#{1,6}[ \t]+(.*)$", re.MULTILINE)
 
214
  ДО LaTeX/таблиц/списков/markdown, иначе обратные слэши и "|"/"-" внутри
215
  реального кода (regex, пути Windows, побитовое ИЛИ) были бы испорчены.
216
  1.3. LaTeX → юникод (_scrub_latex) — код уже вынесен шагом 1.
217
+ 1.4. Маркеры списков "-"/"* " → "•" (_normalize_bullet_markers) — ДО таблиц,
218
+ чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой
219
+ и не была принята за маркер списка.
220
+ 1.405. Слипшиеся "• A • B" в одном абзаце → по строкам (_split_inline_bullets).
221
  1.45. Markdown-цитаты "> " → сентинелы \x00BQS\x00/\x00BQE\x00 (_convert_
222
  blockquotes) — сентинелы, не сразу <blockquote>, т.к. Phase 2 экранировал
223
  бы буквальный тег; настоящий тег подставляется после Phase 3 (см. ниже).
 
279
  # чтобы строка-разделитель таблицы ("|---|---|") успела обработаться первой.
280
  text = _normalize_bullet_markers(text)
281
 
282
+ # ── Phase 1.405: слипшиеся "• A • B" в одном абзаце → по строкам (см.
283
+ # _split_inline_bullets выше) — после нормализации маркеров, до таблиц/escape.
284
+ text = _split_inline_bullets(text)
285
+
286
  # ── Phase 1.41: markdown-заголовки "#"/"##"/"###" → **жирный текст** (см.
287
  # _normalize_headers выше) — после списков, до HTML-экранирования и до Phase 3
288
  # (получившийся "**...**" обрабатывается обычным bold-регэкспом на общих
system_prompt.py CHANGED
@@ -129,6 +129,7 @@ SYSTEM_PROMPT = (
129
  "writing HTML tags yourself will not render them; they will show to the user as visible garbage text like '<b>' right in the message. "
130
  "Write math in LaTeX markup: inline formulas as $...$ (dollar sign tight against the formula on both sides, with NO spaces right after the opening and before the closing one: '$E=mc^2$', not '$ E $'), block ones as $$...$$ on a new line. Do not format prices that way: '$80 000' or 'from $50 to $100' is plain text, not formulas. "
131
  "Mark structure with markdown headings (##, ###) — they render. "
 
132
  "For comparisons and structured data (device specs, pros/cons, timelines, etc.) use real markdown tables: a header row, a dash separator row (|---|---|), data rows: cells are short text, **bold** and `code` work inside cells.\n\n"
133
  "PROSE VS LISTS:\n"
134
  "For extended explanations, analyses, stories about what is happening, write connected prose in paragraphs; do not break it into bulleted/numbered points — a solid list of points instead of a connected explanation looks like a templated AI answer, not live speech. Format as a list only what the user really asked to enumerate or rank; comparisons across several parameters go into a real markdown table (see the FORMATTING section above). Inside ordinary text, enumerating several things is a plain phrase with commas ('among them are X, Y and Z'), not markers on a new line under each point.\n\n"
 
129
  "writing HTML tags yourself will not render them; they will show to the user as visible garbage text like '<b>' right in the message. "
130
  "Write math in LaTeX markup: inline formulas as $...$ (dollar sign tight against the formula on both sides, with NO spaces right after the opening and before the closing one: '$E=mc^2$', not '$ E $'), block ones as $$...$$ on a new line. Do not format prices that way: '$80 000' or 'from $50 to $100' is plain text, not formulas. "
131
  "Mark structure with markdown headings (##, ###) — they render. "
132
+ "Lists: one item per line, each starting with '- ' — never join several bullets into a single paragraph. "
133
  "For comparisons and structured data (device specs, pros/cons, timelines, etc.) use real markdown tables: a header row, a dash separator row (|---|---|), data rows: cells are short text, **bold** and `code` work inside cells.\n\n"
134
  "PROSE VS LISTS:\n"
135
  "For extended explanations, analyses, stories about what is happening, write connected prose in paragraphs; do not break it into bulleted/numbered points — a solid list of points instead of a connected explanation looks like a templated AI answer, not live speech. Format as a list only what the user really asked to enumerate or rank; comparisons across several parameters go into a real markdown table (see the FORMATTING section above). Inside ordinary text, enumerating several things is a plain phrase with commas ('among them are X, Y and Z'), not markers on a new line under each point.\n\n"
tests/test_lumen_formatting.py CHANGED
@@ -262,6 +262,30 @@ def test_normalize_bullet_markers_does_not_touch_table_separator_row():
262
  assert lumen_formatting._normalize_bullet_markers(text) == text
263
 
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  def test_md_to_html_full_pipeline_converts_bullet_list_with_bold():
266
  text = "* **Возмездие:** аргумент про справедливость\n* **Сдерживание:** снижает преступность"
267
  result = lumen_formatting._md_to_html(text)
 
262
  assert lumen_formatting._normalize_bullet_markers(text) == text
263
 
264
 
265
+ def test_split_inline_bullets_splits_long_run_into_lines():
266
+ # Прод 22.09.2026: модель написала весь список сравнения в один абзац через "•".
267
+ items = ["пункт %d с развёрнутым текстом для набора длины абзаца" % i for i in range(5)]
268
+ text = "Вот моменты сравнения характеристик: " + " • ".join(items)
269
+ assert len(text) >= 200
270
+ result = lumen_formatting._split_inline_bullets(text)
271
+ lines = result.split("\n")
272
+ assert lines[0] == "Вот моменты сравнения характеристик: " + items[0]
273
+ assert lines[1:] == ["• " + item for item in items[1:]]
274
+
275
+
276
+ def test_split_inline_bullets_ignores_short_prose():
277
+ # Короткие "чай • кофе" и два разделителя — обычная проза, не список.
278
+ assert lumen_formatting._split_inline_bullets("На выбор чай • кофе • сок.") == "На выбор чай • кофе • сок."
279
+ assert lumen_formatting._split_inline_bullets("Плюсы • минусы") == "Плюсы • минусы"
280
+
281
+
282
+ def test_md_to_html_splits_inline_bullets_end_to_end():
283
+ items = ["тезис номер %d с подробным раскрытием мысли" % i for i in range(4)]
284
+ text = "Итоги сравнения моделей: " + " • ".join(items)
285
+ result = lumen_formatting._md_to_html(text)
286
+ assert result.count("\n• ") == 3
287
+
288
+
289
  def test_md_to_html_full_pipeline_converts_bullet_list_with_bold():
290
  text = "* **Возмездие:** аргумент про справедливость\n* **Сдерживание:** снижает преступность"
291
  result = lumen_formatting._md_to_html(text)