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:
kpa39l
2026-09-18 05:40:59 +00:00
parent 745680a3a0
commit 4a166fa9d6
9 changed files with 530 additions and 29 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-18
+151
View File
@@ -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 закладки «Перевод» нет
+11
View File
@@ -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