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:
+161
-10
@@ -126,6 +126,9 @@ def _migrate():
|
||||
if "is_read" not in cols:
|
||||
conn.execute("ALTER TABLE posts ADD COLUMN is_read INTEGER DEFAULT 0")
|
||||
conn.commit()
|
||||
if "translated_text" not in cols:
|
||||
conn.execute("ALTER TABLE posts ADD COLUMN translated_text TEXT")
|
||||
conn.commit()
|
||||
finally:
|
||||
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}"
|
||||
|
||||
|
||||
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 = {
|
||||
"new": "💎 Новые",
|
||||
"selected": "⭐ Отобранные",
|
||||
@@ -337,7 +399,7 @@ def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group
|
||||
|
||||
@app.get("/candidates", response_class=HTMLResponse)
|
||||
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)
|
||||
conn = _db()
|
||||
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)
|
||||
html = tpl.get_template("candidates.html").render(
|
||||
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,
|
||||
post_sources=_post_sources(sel),
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/selected", response_class=HTMLResponse)
|
||||
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' (внешние). Полная обработка постов."""
|
||||
_require_auth(request)
|
||||
conn = _db()
|
||||
@@ -411,8 +474,9 @@ def selected(request: Request, direction: str = "", own: str = "", q: str = "",
|
||||
filters_qs = "&".join(qs)
|
||||
html = tpl.get_template("selected.html").render(
|
||||
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,
|
||||
post_sources=_post_sources(sel),
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@@ -572,24 +636,36 @@ def post_comment(post_id: int, request: Request, comment: str = Form(""),
|
||||
@app.post("/posts/{post_id}/reclassify")
|
||||
def post_reclassify(post_id: int, request: Request,
|
||||
group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""),
|
||||
src: str = Form("candidates")):
|
||||
"""Переклассификация локальной моделью (qwen)."""
|
||||
src: str = Form("candidates"), manual_direction: str = Form("")):
|
||||
"""Переклассификация локальной моделью (qwen) или ручное указание направления."""
|
||||
_require_auth(request)
|
||||
try:
|
||||
impl_reclassify(post_id)
|
||||
impl_reclassify(post_id, manual_direction)
|
||||
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), status_code=302)
|
||||
|
||||
|
||||
def impl_reclassify(post_id: int):
|
||||
"""Перезапуск классификации локальной моделью. Бросает исключение при неудаче."""
|
||||
from classifier.classify import classify_text
|
||||
def impl_reclassify(post_id: int, manual_direction: str = ""):
|
||||
"""Перезапуск классификации локальной моделью. Если manual_direction задан и каноничен —
|
||||
направление ставится напрямую, без LLM. Бросает исключение при неудаче."""
|
||||
from classifier.keywords import DIRECTIONS_CANON
|
||||
conn = _db()
|
||||
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||
if not p:
|
||||
conn.close()
|
||||
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))
|
||||
conn.execute(
|
||||
"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)
|
||||
|
||||
|
||||
@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)
|
||||
def published(request: 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)
|
||||
|
||||
|
||||
@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")
|
||||
async def sources_edit(source_id: int, request: Request):
|
||||
"""Редактирование источника: обновляем yaml (slug не меняем) + полный ре-синк."""
|
||||
|
||||
@@ -165,8 +165,22 @@
|
||||
</div>
|
||||
|
||||
<hr class="flex-shrink-0">
|
||||
<h6 class="text-muted flex-shrink-0">Оригинальный пост</h6>
|
||||
<div class="flex-grow-1 overflow-auto" style="min-height:0;">
|
||||
<div class="d-flex align-items-center justify-content-between flex-shrink-0 mb-1">
|
||||
<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 %}
|
||||
<div class="post-text mt-1">{{ selected.text | safe_html }}</div>
|
||||
{% endif %}
|
||||
@@ -185,6 +199,12 @@
|
||||
{% endif %}
|
||||
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
|
||||
</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 %}
|
||||
<div class="alert alert-info py-3">Выберите кандидата слева</div>
|
||||
@@ -230,6 +250,18 @@
|
||||
try { sessionStorage.removeItem(KEY); } catch(e) {}
|
||||
});
|
||||
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>
|
||||
{% endblock %}
|
||||
+53
-16
@@ -177,24 +177,49 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-6 d-flex flex-column" style="min-height:0;">
|
||||
<h6 class="text-muted">Оригинальный пост</h6>
|
||||
<div class="border rounded p-2 bg-light small overflow-auto flex-grow-1" style="min-height:0;">
|
||||
{% if selected.text %}
|
||||
<div class="post-text">{{ selected.text | safe_html }}</div>
|
||||
{% endif %}
|
||||
{% if selected.media_path %}
|
||||
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
|
||||
{% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %}
|
||||
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
|
||||
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
|
||||
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:50vh" alt="медиа поста">
|
||||
</a>
|
||||
{% elif media_ext in ['mp4','webm','mov'] %}
|
||||
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:50vh"></video>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
|
||||
<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;">
|
||||
{% if selected.text %}
|
||||
<div class="post-text">{{ selected.text | safe_html }}</div>
|
||||
{% endif %}
|
||||
{% if selected.media_path %}
|
||||
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
|
||||
{% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %}
|
||||
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
|
||||
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
|
||||
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:50vh" alt="медиа поста">
|
||||
</a>
|
||||
{% elif media_ext in ['mp4','webm','mov'] %}
|
||||
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:50vh"></video>
|
||||
{% else %}
|
||||
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</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>
|
||||
@@ -261,6 +286,18 @@
|
||||
try { sessionStorage.removeItem(KEY); } catch(e) {}
|
||||
});
|
||||
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>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user