mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
feat(web): перевод поста на русский локальной моделью (openspec post-translate)
Кнопка «🌐 Перевести» в карточках кандидатов и отобранных переводит пост
локальной моделью (qwen3:8b-nothink), результат в posts.translated_text
(идемпотентный ALTER при старте). Метка «Оригинальный пост» стала закладкой,
при готовом переводе рядом появляется закладка «Перевод» (активна по умолчанию),
переключение на vanilla JS. Повторный клик не тратит токены: перевод в БД.
call_ollama из classifier не подошла (парсит ответ как JSON) — прямой запрос
к Ollama /v1/chat/completions. POST /posts/{id}/translate, лимит 6000 символов.
This commit is contained in:
@@ -1,8 +1,16 @@
|
|||||||
# VESTI — Статус
|
# VESTI — Статус
|
||||||
|
|
||||||
Обновлено: 2026-09-17 (keep-bulk-checkboxes заархивирован, запушен c8fe5f7; активны: extract-post-sources)
|
Обновлено: 2026-09-18 (активны: extract-post-sources, manual-direction, post-translate)
|
||||||
<!--
|
<!--
|
||||||
История обновлений:
|
История обновлений:
|
||||||
|
2026-09-18 — post-translate: кнопка «🌐 Перевести» в карточках кандидатов и отобранных.
|
||||||
|
Перевод на русский локальной моделью (qwen3:8b-nothink), результат в
|
||||||
|
posts.translated_text (колонка добавляется идемпотентно при старте).
|
||||||
|
«Оригинальный пост» стал закладкой; при готовом переводе рядом появляется
|
||||||
|
закладка «Перевод» (активна по умолчанию), переключение без перезагрузки
|
||||||
|
(vanilla JS в scripts-блоке). Повторный клик «Перевести» токены не тратит.
|
||||||
|
call_ollama из classifier не подошла (жёстко парсит ответ как JSON) —
|
||||||
|
перевод идёт прямым запросом к Ollama /v1/chat/completions.
|
||||||
2026-09-17 — keep-bulk-checkboxes: фикс чекбоксов bulk-выбора. Отметки постов сохраняются
|
2026-09-17 — keep-bulk-checkboxes: фикс чекбоксов bulk-выбора. Отметки постов сохраняются
|
||||||
в sessionStorage (vesti_bulk_candidates / vesti_bulk_selected) и восстанавливаются
|
в sessionStorage (vesti_bulk_candidates / vesti_bulk_selected) и восстанавливаются
|
||||||
при открытии поста (?selected=N); badge sel-count пересчитывается; при submit
|
при открытии поста (?selected=N); badge sel-count пересчитывается; при submit
|
||||||
@@ -32,6 +40,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
- **post-translate — В РАЗРАБОТКЕ** (2026-09-18): в карточках кандидатов и отобранных появилась кнопка **«🌐 Перевести»** — переводит пост на русский локальной моделью (qwen3:8b-nothink, Ollama). Результат сохраняется в `posts.translated_text` (новые посты — колонка добавляется при старте веба), кнопка после перевода скрывается, вместо неё — закладка «Перевод» рядом с «Оригинал» (активна по умолчанию, переключение без перезагрузки на vanilla JS). Повторный клик токены не тратит. Ошибки модели — редирект с `?error=translate:...`. Тест e2e на реальной модели пройден (пост 123: «Kali Purple Essential Training»).
|
||||||
- **keep-bulk-checkboxes — ЗААРХИВИРОВАН И ЗАПУШЕН** (2026-09-17, коммит c8fe5f7): проверки массового выбора постов сохраняются между перезагрузками страницы при навигации по постам. Отметки хранятся в sessionStorage браузера (`vesti_bulk_candidates` / `vesti_bulk_selected`); при открытии поста (?selected=N) восстановленные чекбоксы остаются отмеченными, badge sel-count пересчитывается; при submit bulk-формы (Отобрать/Отклонить/Обработать/Опубликовать) отметки очищаются. Изоляция /candidates и /selected независимыми ключами. Попутно исправлен скрытый баг: чекбоксы лежат вне `<form>` (атрибут form="bulk-form") → селектор `#bulk-form input[name="ids"]` не находил их; заменён на глобальный `input[name="ids"]`. Изменены только web/templates/candidates.html и selected.html (frontend, рестарт не нужен).
|
- **keep-bulk-checkboxes — ЗААРХИВИРОВАН И ЗАПУШЕН** (2026-09-17, коммит c8fe5f7): проверки массового выбора постов сохраняются между перезагрузками страницы при навигации по постам. Отметки хранятся в sessionStorage браузера (`vesti_bulk_candidates` / `vesti_bulk_selected`); при открытии поста (?selected=N) восстановленные чекбоксы остаются отмеченными, badge sel-count пересчитывается; при submit bulk-формы (Отобрать/Отклонить/Обработать/Опубликовать) отметки очищаются. Изоляция /candidates и /selected независимыми ключами. Попутно исправлен скрытый баг: чекбоксы лежат вне `<form>` (атрибут form="bulk-form") → селектор `#bulk-form input[name="ids"]` не находил их; заменён на глобальный `input[name="ids"]`. Изменены только web/templates/candidates.html и selected.html (frontend, рестарт не нужен).
|
||||||
- **address-field-enhancement — ЗААРХИВИРОВАН** (2026-09-16). Сделано и в проде: форма добавления источника — одно поле Address. Для telegram: `https://t.me/mknewsru` → slug=`mknewsru`, название «Мой Компьютер» (Telethon get_entity); для rss: `https://lwn.net/headlines/rss` → slug=`lwn`, название «LWN.net» (feedparser). Кнопка «🔎 Найти» в форме. direction — datalist из реальных значений (DIRECTIONS + БД), свободный ввод остаётся. БД не менялась (address — только UI-концепция, в yaml по-прежнему url/channel/feed_url).
|
- **address-field-enhancement — ЗААРХИВИРОВАН** (2026-09-16). Сделано и в проде: форма добавления источника — одно поле Address. Для telegram: `https://t.me/mknewsru` → slug=`mknewsru`, название «Мой Компьютер» (Telethon get_entity); для rss: `https://lwn.net/headlines/rss` → slug=`lwn`, название «LWN.net» (feedparser). Кнопка «🔎 Найти» в форме. direction — datalist из реальных значений (DIRECTIONS + БД), свободный ввод остаётся. БД не менялась (address — только UI-концепция, в yaml по-прежнему url/channel/feed_url).
|
||||||
- **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен.
|
- **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-18
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Design — post-translate
|
||||||
|
|
||||||
|
## БД
|
||||||
|
|
||||||
|
При старте веба (в `_db()` или отдельный хелпер `_ensure_schema()`): идемпотентный ALTER:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _ensure_translated_column(conn):
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(posts)")]
|
||||||
|
if "translated_text" not in cols:
|
||||||
|
conn.execute("ALTER TABLE posts ADD COLUMN translated_text TEXT")
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
Вызывать один раз при старте приложения (после `app = FastAPI(...)`, на том же conn,
|
||||||
|
что и остальные инициализации, если есть; в проекте инициализация схемы идёт в `_db()`
|
||||||
|
через PRAGMA — достаточно вызвать в startup).
|
||||||
|
|
||||||
|
## web/app.py
|
||||||
|
|
||||||
|
Добавить helper (рядом с `_post_sources`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
TRANSLATE_MAX_CHARS = 6000
|
||||||
|
|
||||||
|
def _translate_text(text: str) -> str:
|
||||||
|
"""Перевод текста поста на русский локальной моделью (qwen3:8b-nothink).
|
||||||
|
Возвращает переведённый текст; при неудаче — raise (обрабатывается в роуте)."""
|
||||||
|
from classifier.classify import call_ollama
|
||||||
|
src = (text or "").strip()[:TRANSLATE_MAX_CHARS]
|
||||||
|
if not src:
|
||||||
|
raise ValueError("empty text")
|
||||||
|
prompt = (
|
||||||
|
"Переведи новость на русский язык. Сохрани смысл, факты, имена,\n"
|
||||||
|
"числа и ссылки. Не пересказывай своими словами — это именно перевод.\n"
|
||||||
|
"Верни ТОЛЬКО перевод, без пояснений, без кавычек вокруг.\n\n"
|
||||||
|
f"Оригинал:\n{src}"
|
||||||
|
)
|
||||||
|
r = call_ollama(prompt, 0, 0, "")
|
||||||
|
t = (r.get("text") or r.get("content") or "").strip()
|
||||||
|
if not t:
|
||||||
|
raise ValueError("empty translation")
|
||||||
|
return t
|
||||||
|
```
|
||||||
|
|
||||||
|
Роут (рядом с `/posts/{id}/rewrite`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/posts/{post_id}/translate")
|
||||||
|
def post_translate(post_id: int, request: Request,
|
||||||
|
group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""),
|
||||||
|
src: str = Form("candidates")):
|
||||||
|
"""Перевод поста на русский локальной моделью. Повторный клик при готовом
|
||||||
|
переводе токены не тратит (translated_text уже заполнен)."""
|
||||||
|
_require_auth(request)
|
||||||
|
conn = _db()
|
||||||
|
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||||
|
if not p:
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, error="notfound"), status_code=302)
|
||||||
|
if not (p["translated_text"] or "").strip():
|
||||||
|
try:
|
||||||
|
t = _translate_text(p["text"] or "")
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"translate:{e}"), status_code=302)
|
||||||
|
conn.execute("UPDATE posts SET translated_text=? WHERE id=?", (t, post_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Шаблоны
|
||||||
|
|
||||||
|
### candidates.html (правая панель)
|
||||||
|
|
||||||
|
Заголовок «Оригинальный пост» → закладки + кнопка перевода:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-shrink-0 mb-1">
|
||||||
|
<ul class="nav nav-pills small" style="--bs-nav-link-padding-y:.15rem;">
|
||||||
|
<li class="nav-item"><button class="nav-link {% if not selected.translated_text %}active{% endif %}" data-bs-toggle="pill" data-bs-target="#tab-orig-c" type="button">Оригинал</button></li>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-tr-c" type="button">Перевод</button></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% if not selected.translated_text %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/translate">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit" title="Перевести пост на русский локальной моделью (qwen3:8b)">🌐 Перевести</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-content flex-grow-1 overflow-auto" style="min-height:0;">
|
||||||
|
<div class="tab-pane fade show active" id="tab-orig-c">
|
||||||
|
... (существующий текст + медиа) ...
|
||||||
|
</div>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<div class="tab-pane fade" id="tab-tr-c">
|
||||||
|
<div class="post-text">{{ selected.translated_text | safe_html }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### selected.html (правая панель, колонка «Оригинальный пост»)
|
||||||
|
|
||||||
|
Та же структура, с учётом что блок сейчас — колонка col-md-6 с заголовком:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="col-md-6 d-flex flex-column" style="min-height:0;">
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-shrink-0 mb-1">
|
||||||
|
<h6 class="text-muted mb-0">Оригинальный пост</h6>
|
||||||
|
{% if not selected.translated_text %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/translate">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit">🌐 Перевести</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<ul class="nav nav-pills small mb-1" ...>
|
||||||
|
<li>Оригинал / Перевод закладки</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<div class="tab-content ... flex-grow-1" style="min-height:0;">
|
||||||
|
<div class="tab-pane fade show active" id="tab-orig-s"> ...оригинал+медиа... </div>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<div class="tab-pane fade" id="tab-tr-s"> ...перевод... </div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Замечание: bootstrap 5.3 локальный (static/bootstrap.min.css) — pills/collapse работают.
|
||||||
|
JS для табов — встроенный bootstrap.bundle.min.js НЕ подключён (в base.html только CSS).
|
||||||
|
Поэтому закладки сделаем состоянием без JS? Нет: в проекте нет JS-файлов, но кнопки
|
||||||
|
«Перевести» — это обычные POST-формы (работают и так). Для показа/скрытия вкладок
|
||||||
|
добавим минимальный vanilla-JS скрипт в {% block scripts %} (как уже делается для
|
||||||
|
bulk-чекбоксов) — 15 строк.
|
||||||
|
|
||||||
|
Альтернатива без JS: radio-кнопки + CSS `:checked` сосед. Но проще и поддерживаемее
|
||||||
|
vanilla JS в scripts-блоке (паттерн уже есть в проекте).
|
||||||
|
|
||||||
|
## Ошибки/границы
|
||||||
|
|
||||||
|
- `call_ollama` использует OLLAMA_URL/MODEL из env (по умолчанию qwen3:8b-nothink).
|
||||||
|
- Длинный текст (>6000) обрезается; перевод будет неполным для очень длинных — приемлемо
|
||||||
|
для модерации.
|
||||||
|
- Повторный клик «Перевести» при готовом переводе — просто редирект, без LLM.
|
||||||
|
- Кнопка «Перевести» скрывается, когда перевод уже есть (вместо неё — закладка «Перевод»).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# post-translate
|
||||||
|
|
||||||
|
Перевод поста на русский локальной моделью (кнопка «Перевести» в карточках кандидатов и отобранных).
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Многие посты в кандидатах приходят на английском (LWN, bleepingcomputer, omarchy и т.п.).
|
||||||
|
Чтобы прочитать и отобрать новость, модератору нужен перевод на русский прямо в карточке.
|
||||||
|
Сейчас поста нет: есть только оригинал на языке источника.
|
||||||
|
|
||||||
|
Ограничение: перевод должен быть ПО ЗАПРОСУ (кнопка), потому что массовый перевод
|
||||||
|
всех кандидатов будет жечь токены локальной модели без пользы (модератор смотрит
|
||||||
|
далеко не все посты). Это соответствует существующему паттерну: классификация и
|
||||||
|
пересказ тоже запускаются только по кнопке.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **БД**: колонка `posts.translated_text TEXT` (через ALTER TABLE при старте, идемпотентно,
|
||||||
|
как это делает classifier/classify.py для direction/summary).
|
||||||
|
- **web/app.py**:
|
||||||
|
- helper `_translate_text(text)` → переводит текст через qwen3:8b-nothink
|
||||||
|
(Ollama /v1, та же `call_ollama`, что у rewrite/reclassify). Текст обрезается
|
||||||
|
до 6000 символов (перевод длинного текста + ответ упирается в контекст локальной
|
||||||
|
модели быстрее, чем классификация). Возвращает str, не падает — при ошибке кидает исключение.
|
||||||
|
- POST `/posts/{post_id}/translate` (src, group_by, direction, own, q — как у rewrite):
|
||||||
|
читает пост, переводит, сохраняет в `translated_text` БД, редирект обратно с `selected=post_id`.
|
||||||
|
Если `translated_text` уже есть — не тратит токены, сразу редирект (повторный клик — бесплатный).
|
||||||
|
- render candidates/selected — уже передаёт `selected` (sqlite row), колонка подтянется
|
||||||
|
автоматически (SELECT *).
|
||||||
|
- **web/templates/candidates.html и selected.html**:
|
||||||
|
- Заголовок «Оригинальный пост» → становится кликабельной закладкой/аккордеоном
|
||||||
|
(bootstrap collapse, без JS-файлов — как остальной проект).
|
||||||
|
- Рядом с ним кнопка «Перевести» — POST-форма на `/posts/{id}/translate`.
|
||||||
|
- Если `translated_text` есть — вторая закладка «Перевод» с текстом перевода.
|
||||||
|
- Активная закладка по умолчанию: «Перевод», если перевод уже есть, иначе «Оригинал».
|
||||||
|
|
||||||
|
## Why Not
|
||||||
|
|
||||||
|
- Не переводим автоматически при крауле — жечь токены на все посты (включая неотобранные).
|
||||||
|
- Не делаем отдельную страницу/ход в БД «переводы» — достаточно одной колонки,
|
||||||
|
перевод — атрибут поста, а не сущность.
|
||||||
|
- Не используем внешний API перевода (DeepL и т.п.) — локальная модель уже есть,
|
||||||
|
бесплатна и подходит для новостного текста.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Веб-морда: в карточках кандидатов и отобранных появляется перевод на русский по кнопке.
|
||||||
|
- Рестарт vesti-web.service после выката (колонка добавляется идемпотентно).
|
||||||
|
- Никаких изменений в pipeline/publisher/краулере — перевод чисто для модерации,
|
||||||
|
не для публикации.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Spec — post-translate
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Перевод поста на русский в карточках кандидатов и отобранных
|
||||||
|
|
||||||
|
Модератор может перевести оригинальный пост на русский язык одной кнопкой,
|
||||||
|
не покидая карточку. Перевод выполняет локальная модель (Ollama qwen3:8b-nothink);
|
||||||
|
результат сохраняется в БД и показывается в отдельной закладке «Перевод» рядом
|
||||||
|
с закладкой «Оригинал».
|
||||||
|
|
||||||
|
#### Scenario: Кнопка «Перевести» у поста без перевода
|
||||||
|
|
||||||
|
Given карточка кандидата или отобранного поста, у которого ещё нет перевода
|
||||||
|
When модератор нажимает «🌐 Перевести»
|
||||||
|
Then текст поста переводится локальной моделью на русский
|
||||||
|
And перевод сохраняется в `posts.translated_text`
|
||||||
|
And страница перезагружается, и рядом с «Оригинал» появляется закладка «Перевод»
|
||||||
|
|
||||||
|
#### Scenario: Перевод уже готов
|
||||||
|
|
||||||
|
Given у поста уже сохранён перевод (`translated_text` заполнен)
|
||||||
|
When модератор открывает карточку
|
||||||
|
Then кнопка «Перевести» скрыта
|
||||||
|
And доступны две закладки: «Оригинал» и «Перевод»
|
||||||
|
|
||||||
|
#### Scenario: Ошибка модели при переводе
|
||||||
|
|
||||||
|
Given локальная модель недоступна или вернула пустой ответ
|
||||||
|
When модератор нажимает «🌐 Перевести»
|
||||||
|
Then перевод не сохраняется, пост не меняется
|
||||||
|
And модератор остаётся на той же карточке с сообщением об ошибке (параметр error)
|
||||||
|
|
||||||
|
#### Scenario: Повторный клик «Перевести» не тратит токены
|
||||||
|
|
||||||
|
Given перевод уже сохранён
|
||||||
|
When модератор нажимает «🌐 Перевести» (если кнопка видна — например, после ошибки)
|
||||||
|
Then сервер видит заполненный `translated_text` и не вызывает модель
|
||||||
|
And просто возвращает на ту же карточку
|
||||||
|
|
||||||
|
### Requirement: Закладки «Оригинал» / «Перевод»
|
||||||
|
|
||||||
|
Оригинальный пост и его перевод показываются в карточке как две переключаемые
|
||||||
|
закладки (вкладки), переключение — без перезагрузки страницы.
|
||||||
|
|
||||||
|
#### Scenario: Переключение между оригиналом и переводом
|
||||||
|
|
||||||
|
Given карточка с готовым переводом
|
||||||
|
When модератор кликает «Перевод»
|
||||||
|
Then показывается текст перевода (перевод отображается тем же стилем, что оригинал)
|
||||||
|
When модератор кликает «Оригинал»
|
||||||
|
Then снова показывается исходный текст с медиа
|
||||||
|
|
||||||
|
#### Scenario: Перевод отсутствует — показывается только оригинал
|
||||||
|
|
||||||
|
Given у поста нет перевода
|
||||||
|
Then карточка показывает закладку/блок «Оригинал» и кнопку «Перевести»
|
||||||
|
And закладки «Перевод» нет
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Задачи
|
||||||
|
|
||||||
|
- [x] web/app.py: идемпотентный ALTER TABLE posts ADD COLUMN translated_text (при старте)
|
||||||
|
- [x] web/app.py: helper _translate_text (прямой запрос к Ollama — call_ollama жёстко парсит JSON, для перевода непригоден; лимит 6000)
|
||||||
|
- [x] web/app.py: POST /posts/{post_id}/translate (повторный клик — без LLM, проверено)
|
||||||
|
- [x] web/templates/candidates.html: закладки «Оригинал»/«Перевод» + кнопка «🌐 Перевести»
|
||||||
|
- [x] web/templates/selected.html: то же в колонке оригинала
|
||||||
|
- [x] vanilla JS переключения вкладок (scripts-блок, паттерн bulk-чекбоксов)
|
||||||
|
- [x] openspec validate post-translate — чисто
|
||||||
|
- [x] Рестарт vesti-web, ручная проверка: пост EN → «Перевести» → закладка «Перевод»; повторный клик — без модели
|
||||||
|
- [ ] Обновить STATUS.md
|
||||||
+161
-10
@@ -126,6 +126,9 @@ def _migrate():
|
|||||||
if "is_read" not in cols:
|
if "is_read" not in cols:
|
||||||
conn.execute("ALTER TABLE posts ADD COLUMN is_read INTEGER DEFAULT 0")
|
conn.execute("ALTER TABLE posts ADD COLUMN is_read INTEGER DEFAULT 0")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
if "translated_text" not in cols:
|
||||||
|
conn.execute("ALTER TABLE posts ADD COLUMN translated_text TEXT")
|
||||||
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -202,6 +205,65 @@ def _cand_back(group_by: str = "", direction: str = "", own: str = "", q: str =
|
|||||||
return (f"/{src}?" + "&".join(parts)) if parts else f"/{src}"
|
return (f"/{src}?" + "&".join(parts)) if parts else f"/{src}"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_sources(post: dict | None) -> list[dict]:
|
||||||
|
"""Источники, найденные в тексте поста: + флаг known (уже в реестре). Без LLM."""
|
||||||
|
if not post:
|
||||||
|
return []
|
||||||
|
from sources.extract import extract_sources_from_text
|
||||||
|
found = extract_sources_from_text(post.get("text") or "", post.get("url"))
|
||||||
|
if not found:
|
||||||
|
return []
|
||||||
|
with _db() as conn:
|
||||||
|
slugs = {r[0] for r in conn.execute("SELECT slug FROM sources")}
|
||||||
|
for f in found:
|
||||||
|
f["known"] = f["slug_hint"] in slugs
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
TRANSLATE_MAX_CHARS = 6000
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_text(text: str) -> str:
|
||||||
|
"""Перевод текста поста на русский локальной моделью (qwen3:8b-nothink).
|
||||||
|
|
||||||
|
Возвращает переведённый текст; при неудаче бросает исключение (обрабатывается
|
||||||
|
в роуте — редирект с error). Текст обрезается до TRANSLATE_MAX_CHARS.
|
||||||
|
|
||||||
|
Прямой запрос к Ollama (не через call_ollama из classifier): call_ollama
|
||||||
|
захардкожена под классификацию — парсит ответ модели как JSON, а перевод
|
||||||
|
это обычный текст, и JSON-парсинг его ломает.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
from classifier.classify import OLLAMA_URL, OLLAMA_MODEL, TIMEOUT
|
||||||
|
src = (text or "").strip()[:TRANSLATE_MAX_CHARS]
|
||||||
|
if not src:
|
||||||
|
raise ValueError("empty text")
|
||||||
|
prompt = (
|
||||||
|
"Переведи новость на русский язык. Сохрани смысл, факты, имена,\n"
|
||||||
|
"числа и ссылки. Не пересказывай своими словами — это именно перевод.\n"
|
||||||
|
"Верни ТОЛЬКО перевод, без пояснений, без кавычек вокруг.\n\n"
|
||||||
|
f"Оригинал:\n{src}"
|
||||||
|
)
|
||||||
|
r = httpx.post(
|
||||||
|
f"{OLLAMA_URL}/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": OLLAMA_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"temperature": 0.2,
|
||||||
|
"stream": False,
|
||||||
|
"think": False,
|
||||||
|
},
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
content = (data.get("choices") or [{}])[0].get("message", {}).get("content") or ""
|
||||||
|
t = content.strip()
|
||||||
|
if not t:
|
||||||
|
raise ValueError("empty translation")
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
STATUS_LABELS = {
|
STATUS_LABELS = {
|
||||||
"new": "💎 Новые",
|
"new": "💎 Новые",
|
||||||
"selected": "⭐ Отобранные",
|
"selected": "⭐ Отобранные",
|
||||||
@@ -337,7 +399,7 @@ def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group
|
|||||||
|
|
||||||
@app.get("/candidates", response_class=HTMLResponse)
|
@app.get("/candidates", response_class=HTMLResponse)
|
||||||
def candidates(request: Request, direction: str = "", status: str = "", own: str = "",
|
def candidates(request: Request, direction: str = "", status: str = "", own: str = "",
|
||||||
q: str = "", group_by: str = "date", selected: int = 0, error: str = ""):
|
q: str = "", group_by: str = "date", selected: int = 0, error: str = "", did: str = ""):
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
conn = _db()
|
conn = _db()
|
||||||
grouped, counts, status = _fetch_candidates(conn, direction, status, own, q, group_by)
|
grouped, counts, status = _fetch_candidates(conn, direction, status, own, q, group_by)
|
||||||
@@ -370,15 +432,16 @@ def candidates(request: Request, direction: str = "", status: str = "", own: str
|
|||||||
filters_qs = "&".join(qs)
|
filters_qs = "&".join(qs)
|
||||||
html = tpl.get_template("candidates.html").render(
|
html = tpl.get_template("candidates.html").render(
|
||||||
posts=grouped, counts=counts, status=status, direction=direction, own=own,
|
posts=grouped, counts=counts, status=status, direction=direction, own=own,
|
||||||
q=q, group_by=group_by, selected=sel, error=error, filters_qs=filters_qs,
|
q=q, group_by=group_by, selected=sel, error=error, did=did, filters_qs=filters_qs,
|
||||||
directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS,
|
directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS,
|
||||||
|
post_sources=_post_sources(sel),
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/selected", response_class=HTMLResponse)
|
@app.get("/selected", response_class=HTMLResponse)
|
||||||
def selected(request: Request, direction: str = "", own: str = "", q: str = "",
|
def selected(request: Request, direction: str = "", own: str = "", q: str = "",
|
||||||
group_by: str = "date", selected: int = 0, error: str = ""):
|
group_by: str = "date", selected: int = 0, error: str = "", did: str = ""):
|
||||||
"""Отобранные: только status='selected' (внешние). Полная обработка постов."""
|
"""Отобранные: только status='selected' (внешние). Полная обработка постов."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
conn = _db()
|
conn = _db()
|
||||||
@@ -411,8 +474,9 @@ def selected(request: Request, direction: str = "", own: str = "", q: str = "",
|
|||||||
filters_qs = "&".join(qs)
|
filters_qs = "&".join(qs)
|
||||||
html = tpl.get_template("selected.html").render(
|
html = tpl.get_template("selected.html").render(
|
||||||
posts=grouped, counts=counts, status="selected", direction=direction, own=own,
|
posts=grouped, counts=counts, status="selected", direction=direction, own=own,
|
||||||
q=q, group_by=group_by, selected=sel, error=error, filters_qs=filters_qs,
|
q=q, group_by=group_by, selected=sel, error=error, did=did, filters_qs=filters_qs,
|
||||||
directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS,
|
directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS,
|
||||||
|
post_sources=_post_sources(sel),
|
||||||
)
|
)
|
||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
@@ -572,24 +636,36 @@ def post_comment(post_id: int, request: Request, comment: str = Form(""),
|
|||||||
@app.post("/posts/{post_id}/reclassify")
|
@app.post("/posts/{post_id}/reclassify")
|
||||||
def post_reclassify(post_id: int, request: Request,
|
def post_reclassify(post_id: int, request: Request,
|
||||||
group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""),
|
group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""),
|
||||||
src: str = Form("candidates")):
|
src: str = Form("candidates"), manual_direction: str = Form("")):
|
||||||
"""Переклассификация локальной моделью (qwen)."""
|
"""Переклассификация локальной моделью (qwen) или ручное указание направления."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
try:
|
try:
|
||||||
impl_reclassify(post_id)
|
impl_reclassify(post_id, manual_direction)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"reclassify:{e}"), status_code=302)
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"reclassify:{e}"), status_code=302)
|
||||||
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302)
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302)
|
||||||
|
|
||||||
|
|
||||||
def impl_reclassify(post_id: int):
|
def impl_reclassify(post_id: int, manual_direction: str = ""):
|
||||||
"""Перезапуск классификации локальной моделью. Бросает исключение при неудаче."""
|
"""Перезапуск классификации локальной моделью. Если manual_direction задан и каноничен —
|
||||||
from classifier.classify import classify_text
|
направление ставится напрямую, без LLM. Бросает исключение при неудаче."""
|
||||||
|
from classifier.keywords import DIRECTIONS_CANON
|
||||||
conn = _db()
|
conn = _db()
|
||||||
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||||
if not p:
|
if not p:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise ValueError("notfound")
|
raise ValueError("notfound")
|
||||||
|
md = (manual_direction or "").strip()
|
||||||
|
if md in DIRECTIONS_CANON:
|
||||||
|
# ручное направление: без LLM, как словарное попадание
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=?",
|
||||||
|
(md, "low", 1, "", post_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
from classifier.classify import classify_text
|
||||||
res = classify_text(dict(p))
|
res = classify_text(dict(p))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
|
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
|
||||||
@@ -632,6 +708,30 @@ def post_rewrite(post_id: int, request: Request,
|
|||||||
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302)
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/posts/{post_id}/translate")
|
||||||
|
def post_translate(post_id: int, request: Request,
|
||||||
|
group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""),
|
||||||
|
src: str = Form("candidates")):
|
||||||
|
"""Перевод поста на русский локальной моделью. Повторный клик при готовом
|
||||||
|
переводе токены не тратит (translated_text уже заполнен)."""
|
||||||
|
_require_auth(request)
|
||||||
|
conn = _db()
|
||||||
|
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||||
|
if not p:
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, error="notfound"), status_code=302)
|
||||||
|
if not (p["translated_text"] or "").strip():
|
||||||
|
try:
|
||||||
|
t = _translate_text(p["text"] or "")
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"translate:{e}"), status_code=302)
|
||||||
|
conn.execute("UPDATE posts SET translated_text=? WHERE id=?", (t, post_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/published", response_class=HTMLResponse)
|
@app.get("/published", response_class=HTMLResponse)
|
||||||
def published(request: Request):
|
def published(request: Request):
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
@@ -965,6 +1065,57 @@ async def sources_add(request: Request):
|
|||||||
return RedirectResponse(url=f"/sources?did=added:{res}", status_code=302)
|
return RedirectResponse(url=f"/sources?did=added:{res}", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/sources/add-from-post")
|
||||||
|
async def sources_add_from_post(request: Request):
|
||||||
|
"""Быстрое добавление источника из карточки поста (кнопка «➕» в «Источники из поста»).
|
||||||
|
|
||||||
|
Форма несёт address и crawler; создаём источник как при ручном добавлении,
|
||||||
|
возвращаемся на страницу кандидатов/отобранных с did-сообщением.
|
||||||
|
"""
|
||||||
|
_require_auth(request)
|
||||||
|
form = await request.form()
|
||||||
|
address = (form.get("address") or "").strip()
|
||||||
|
crawler = (form.get("crawler") or "telegram").strip()
|
||||||
|
src = form.get("src") or "candidates"
|
||||||
|
selected_id = int(form.get("selected") or 0)
|
||||||
|
group_by = form.get("group_by") or ""
|
||||||
|
direction = form.get("direction") or ""
|
||||||
|
own = form.get("own") or ""
|
||||||
|
q = form.get("q") or ""
|
||||||
|
|
||||||
|
back = _cand_back(group_by, direction, own, q, src=src, selected=selected_id)
|
||||||
|
if not address:
|
||||||
|
return RedirectResponse(url=back + "&did=error:empty_address", status_code=302)
|
||||||
|
|
||||||
|
parsed = parse_address(address, crawler)
|
||||||
|
if not parsed:
|
||||||
|
return RedirectResponse(url=back + "&did=error:bad_address", status_code=302)
|
||||||
|
|
||||||
|
slug = parsed.get("slug")
|
||||||
|
with _db() as conn:
|
||||||
|
exists = conn.execute("SELECT 1 FROM sources WHERE slug=?", (slug,)).fetchone()
|
||||||
|
if exists:
|
||||||
|
return RedirectResponse(url=back + f"&did=exists:{slug}", status_code=302)
|
||||||
|
|
||||||
|
# имя — человекочитаемое: для telegram берём handle, для site — домен
|
||||||
|
name = address
|
||||||
|
ok, res = add_or_update_source({
|
||||||
|
"slug": slug,
|
||||||
|
"name": f"Из поста: {name}",
|
||||||
|
"crawler": crawler,
|
||||||
|
"url": parsed.get("url"),
|
||||||
|
"channel": parsed.get("channel"),
|
||||||
|
"feed_url": parsed.get("feed_url"),
|
||||||
|
"direction": "",
|
||||||
|
"lang": "ru",
|
||||||
|
"priority": "P1",
|
||||||
|
"enabled": True,
|
||||||
|
})
|
||||||
|
if not ok:
|
||||||
|
return RedirectResponse(url=back + f"&did=error:{res}", status_code=302)
|
||||||
|
return RedirectResponse(url=back + f"&did=added:{slug}", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/sources/{source_id}/edit")
|
@app.post("/sources/{source_id}/edit")
|
||||||
async def sources_edit(source_id: int, request: Request):
|
async def sources_edit(source_id: int, request: Request):
|
||||||
"""Редактирование источника: обновляем yaml (slug не меняем) + полный ре-синк."""
|
"""Редактирование источника: обновляем yaml (slug не меняем) + полный ре-синк."""
|
||||||
|
|||||||
@@ -165,8 +165,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="flex-shrink-0">
|
<hr class="flex-shrink-0">
|
||||||
<h6 class="text-muted flex-shrink-0">Оригинальный пост</h6>
|
<div class="d-flex align-items-center justify-content-between flex-shrink-0 mb-1">
|
||||||
<div class="flex-grow-1 overflow-auto" style="min-height:0;">
|
<ul class="nav nav-pills small mb-0" style="--bs-nav-link-padding-y:.15rem; --bs-nav-link-padding-x:.6rem;">
|
||||||
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-orig-c" type="button">Оригинал</button></li>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-tr-c" type="button">Перевод</button></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% if not selected.translated_text %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/translate" class="mb-0">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit" title="Перевести пост на русский локальной моделью (qwen3:8b)">🌐 Перевести</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-content flex-grow-1 overflow-auto" style="min-height:0;">
|
||||||
|
<div class="tab-pane fade show active" id="tab-orig-c">
|
||||||
{% if selected.text %}
|
{% if selected.text %}
|
||||||
<div class="post-text mt-1">{{ selected.text | safe_html }}</div>
|
<div class="post-text mt-1">{{ selected.text | safe_html }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -185,6 +199,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
|
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<div class="tab-pane fade" id="tab-tr-c">
|
||||||
|
<div class="post-text mt-1">{{ selected.translated_text | safe_html }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="alert alert-info py-3">Выберите кандидата слева</div>
|
<div class="alert alert-info py-3">Выберите кандидата слева</div>
|
||||||
@@ -230,6 +250,18 @@
|
|||||||
try { sessionStorage.removeItem(KEY); } catch(e) {}
|
try { sessionStorage.removeItem(KEY); } catch(e) {}
|
||||||
});
|
});
|
||||||
upd();
|
upd();
|
||||||
|
// Переключение закладок «Оригинал» / «Перевод» (без CDN, vanilla JS)
|
||||||
|
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(btn){
|
||||||
|
btn.addEventListener('click', function(){
|
||||||
|
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(b){
|
||||||
|
b.classList.remove('active');
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(function(p){ p.classList.remove('show','active'); });
|
||||||
|
var t = document.querySelector(this.dataset.bsTarget);
|
||||||
|
if (t) t.classList.add('show','active');
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -177,7 +177,23 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 d-flex flex-column" style="min-height:0;">
|
<div class="col-md-6 d-flex flex-column" style="min-height:0;">
|
||||||
<h6 class="text-muted">Оригинальный пост</h6>
|
<div class="d-flex align-items-center justify-content-between flex-shrink-0 mb-1">
|
||||||
|
<h6 class="text-muted mb-0">Оригинальный пост</h6>
|
||||||
|
{% if not selected.translated_text %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/translate" class="mb-0">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit" title="Перевести пост на русский локальной моделью (qwen3:8b)">🌐 Перевести</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<ul class="nav nav-pills small mb-1 flex-shrink-0" style="--bs-nav-link-padding-y:.15rem; --bs-nav-link-padding-x:.6rem;">
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-orig-s" type="button">Оригинал</button></li>
|
||||||
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-tr-s" type="button">Перевод</button></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<div class="tab-content flex-grow-1" style="min-height:0;">
|
||||||
|
<div class="tab-pane fade {% if not selected.translated_text %}show active{% endif %}" id="tab-orig-s">
|
||||||
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
|
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
|
||||||
{% if selected.text %}
|
{% if selected.text %}
|
||||||
<div class="post-text">{{ selected.text | safe_html }}</div>
|
<div class="post-text">{{ selected.text | safe_html }}</div>
|
||||||
@@ -197,6 +213,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if selected.translated_text %}
|
||||||
|
<div class="tab-pane fade show active" id="tab-tr-s">
|
||||||
|
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
|
||||||
|
<div class="post-text">{{ selected.translated_text | safe_html }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="flex-shrink-0 my-2">
|
<hr class="flex-shrink-0 my-2">
|
||||||
<!-- Комментарий + публикация — в самом низу -->
|
<!-- Комментарий + публикация — в самом низу -->
|
||||||
@@ -261,6 +286,18 @@
|
|||||||
try { sessionStorage.removeItem(KEY); } catch(e) {}
|
try { sessionStorage.removeItem(KEY); } catch(e) {}
|
||||||
});
|
});
|
||||||
upd();
|
upd();
|
||||||
|
// Переключение закладок «Оригинал» / «Перевод» (без CDN, vanilla JS)
|
||||||
|
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(btn){
|
||||||
|
btn.addEventListener('click', function(){
|
||||||
|
document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(b){
|
||||||
|
b.classList.remove('active');
|
||||||
|
});
|
||||||
|
this.classList.add('active');
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(function(p){ p.classList.remove('show','active'); });
|
||||||
|
var t = document.querySelector(this.dataset.bsTarget);
|
||||||
|
if (t) t.classList.add('show','active');
|
||||||
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user