Files
kpa39l 4a166fa9d6 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 символов.
2026-09-18 05:40:59 +00:00

151 lines
7.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- Кнопка «Перевести» скрывается, когда перевод уже есть (вместо неё — закладка «Перевод»).