Files
vesti/openspec/changes/post-translate/design.md
T
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

7.4 KiB
Raw Blame History

Design — post-translate

БД

При старте веба (в _db() или отдельный хелпер _ensure_schema()): идемпотентный ALTER:

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):

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):

@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 (правая панель)

Заголовок «Оригинальный пост» → закладки + кнопка перевода:

<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 с заголовком:

<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.
  • Кнопка «Перевести» скрывается, когда перевод уже есть (вместо неё — закладка «Перевод»).