mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
openspec: change-ы manual-direction (ручное направление без LLM) и extract-post-sources (извлечение источников из постов) + sources/extract.py
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-16
|
||||
skip_specs: true
|
||||
@@ -0,0 +1,125 @@
|
||||
# extract-post-sources
|
||||
|
||||
## 1. sources/extract.py
|
||||
|
||||
```python
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_MAX_PER_POST = 5
|
||||
|
||||
# t.me/handle, t.me/s/handle, telegram.me/handle, @handle
|
||||
_TG_RE = re.compile(
|
||||
r"(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})"
|
||||
)
|
||||
_AT_RE = re.compile(r"(?<!\w)@([a-zA-Z][a-zA-Z0-9_]{3,31})\b")
|
||||
# внешние http(s)-ссылки (не t.me/telegram.me)
|
||||
_URL_RE = re.compile(r"https?://([^\s<>\"']+)", re.I)
|
||||
|
||||
|
||||
def _domain_of(url: str) -> str:
|
||||
return (urlparse(url if "://" in url else "https://" + url).netloc or "").lower()
|
||||
|
||||
|
||||
def extract_sources_from_text(text: str, exclude_tg_url: str | None = None) -> list[dict]:
|
||||
"""Ищет TG-каналы и внешние сайты в тексте. exclude_tg_url — ссылка на сам пост
|
||||
(t.me/channel/postid) — её отбрасываем, это не источник, а адрес поста."""
|
||||
out: list[dict] = []
|
||||
seen = set()
|
||||
|
||||
def add(kind, address, slug_hint, domain):
|
||||
key = (kind, slug_hint or domain or address)
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
out.append({"kind": kind, "address": address, "slug_hint": slug_hint, "domain": domain})
|
||||
|
||||
text = (text or "")[:4000]
|
||||
for m in _TG_RE.finditer(text):
|
||||
ch = m.group(1)
|
||||
if exclude_tg_url and f"/{ch}/" in exclude_tg_url:
|
||||
continue # ссылка на сам пост
|
||||
add("telegram", f"https://t.me/{ch}", ch, None)
|
||||
for m in _AT_RE.finditer(text):
|
||||
add("telegram", f"https://t.me/{m.group(1)}", m.group(1), None)
|
||||
for m in _URL_RE.finditer(text):
|
||||
u = m.group(1).rstrip(".,;:!?)]}\"'")
|
||||
dom = _domain_of(u)
|
||||
if not dom:
|
||||
continue
|
||||
if dom in ("t.me", "telegram.me") or dom.endswith(".t.me"):
|
||||
continue # уже обработали выше
|
||||
add("site", u, None, dom)
|
||||
if len(out) >= _MAX_PER_POST:
|
||||
break
|
||||
return out[:_MAX_PER_POST]
|
||||
```
|
||||
|
||||
Ограничение `_MAX_PER_POST=5`: чтобы не захламлять карточку десятками ссылок.
|
||||
|
||||
## 2. web/app.py
|
||||
|
||||
```python
|
||||
from sources.sources import parse_address, add_or_update_source
|
||||
|
||||
def _post_sources(post: dict) -> list[dict]:
|
||||
"""Список источников, найденных в тексте поста: + флаг known (уже в реестре)."""
|
||||
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
|
||||
```
|
||||
|
||||
В `candidates()` и `selected()`: `post_sources=_post_sources(sel) if sel else []`.
|
||||
|
||||
POST `/sources/add-from-post`:
|
||||
|
||||
```python
|
||||
@app.post("/sources/add-from-post")
|
||||
def sources_add_from_post(request: Request, address: str = Form(...),
|
||||
kind: str = Form(""), crawler: str = Form("telegram"),
|
||||
src: str = Form("candidates"), selected: int = Form(0), ...):
|
||||
_require_auth(request)
|
||||
parsed = parse_address(address, crawler)
|
||||
if not parsed:
|
||||
return RedirectResponse(url=_cand_back(..., did="⚠️ Не удалось разобрать адрес"), ...)
|
||||
slug = parsed["slug"]
|
||||
with db() as conn:
|
||||
exists = conn.execute("SELECT 1 FROM sources WHERE slug=?", (slug,)).fetchone()
|
||||
if exists:
|
||||
return RedirectResponse(url=_cand_back(..., did=f"Источник {slug} уже в реестре"), ...)
|
||||
ok, res = add_or_update_source({"name": f"Из поста: {slug}", "crawler": crawler, **parsed})
|
||||
did = f"➕ Источник {res} добавлен" if ok else f"❌ {res}"
|
||||
return RedirectResponse(url=_cand_back(..., did=did), ...)
|
||||
```
|
||||
|
||||
## 3. Шаблоны
|
||||
|
||||
В candidates.html и selected.html (под бейджами, перед деталями):
|
||||
|
||||
```html
|
||||
{% if post_sources %}
|
||||
<div class="small text-muted mb-1">
|
||||
🌐 Источники из поста:
|
||||
{% for s in post_sources %}
|
||||
{% if s.known %}
|
||||
<span class="badge bg-light text-dark">@{{ s.slug_hint or s.domain }} ✓</span>
|
||||
{% else %}
|
||||
<form method="post" action="/sources/add-from-post" class="d-inline align-middle">
|
||||
{{ hid }}
|
||||
<input type="hidden" name="address" value="{{ s.address }}">
|
||||
<input type="hidden" name="crawler" value="{% if s.kind == 'telegram' %}telegram{% else %}rss{% endif %}">
|
||||
<button class="btn btn-sm btn-outline-secondary py-0" title="Добавить {{ s.address }} в источники">
|
||||
➕ @{{ s.slug_hint or s.domain }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
# extract-post-sources
|
||||
|
||||
Извлечение источников новостей из текста постов (TG-каналы/сайты) и быстрое добавление в реестр.
|
||||
|
||||
## Why
|
||||
|
||||
Пользователь: нужно извлекать источники новостей из полученных постов (каналы/сайты,
|
||||
откуда пришли новости), чтобы в следующий раз забирать новости напрямую из источника.
|
||||
Сейчас ссылки в тексте постов никак не используются: всё приходит из sources.yaml,
|
||||
а репосты/упоминания других каналов и сайтов теряются.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **sources/extract.py** (новый): `extract_sources_from_text(text) -> list[dict]` —
|
||||
находит в тексте ссылки на TG-каналы (t.me/handle, @handle, t.me/s/handle) и
|
||||
внешние сайты (https://domain), исключает ссылки на сам пост (t.me/channel/postid),
|
||||
дедуплицирует. Ограничение: не более 5 найденных на пост (шум).
|
||||
- **web/app.py**:
|
||||
- helper `_post_sources(post)` → список {kind, address, slug_hint, known} для карточки
|
||||
(known = slug_hint уже есть в sources).
|
||||
- В render candidates/selected передаёт `post_sources`.
|
||||
- POST `/sources/add-from-post`: {address} → parse_address + add_or_update_source
|
||||
(переиспользует существующий CRUD). Возвращает redirect на ту же страницу с did-сообщением.
|
||||
- **web/templates/candidates.html / selected.html**: блок «🌐 Источники из поста» под бейджами:
|
||||
для каждого найденного — `@handle` / `domain`, бейдж «в реестре» или кнопка «➕».
|
||||
|
||||
## Impact
|
||||
|
||||
- В карточках кандидатов/отобранных видны ссылки на внешние каналы/сайты из текста.
|
||||
- Один клик — добавить источник в реестр (yaml + БД, как ручное добавление).
|
||||
- Никаких LLM-вызовов — regex-извлечение, бесплатно. Дообогащение модели для
|
||||
одобренных — отдельная задача (по кнопке, экономия токенов).
|
||||
@@ -0,0 +1,3 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-16
|
||||
skip_specs: true
|
||||
@@ -0,0 +1,55 @@
|
||||
# manual-direction
|
||||
|
||||
## Design
|
||||
|
||||
### web/app.py
|
||||
|
||||
`post_reclassify(post_id, request, group_by, direction, own, q, src, manual_direction: str = Form(""))`:
|
||||
|
||||
- передать `manual_direction` в `impl_reclassify`.
|
||||
|
||||
`impl_reclassify(post_id, manual_direction: str = "")`:
|
||||
|
||||
```python
|
||||
def impl_reclassify(post_id: int, manual_direction: str = ""):
|
||||
from classifier.classify import classify_text
|
||||
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, classified=1, как словарное попадание
|
||||
conn.execute(
|
||||
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=?",
|
||||
(md, "low", 1, "", post_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return
|
||||
res = classify_text(dict(p))
|
||||
... # как раньше
|
||||
```
|
||||
|
||||
### web/templates/selected.html
|
||||
|
||||
В форме reclassify (кнопка «🤖 Обработать моделью») добавить select:
|
||||
|
||||
```html
|
||||
<form method="post" action="/posts/{{ selected.id }}/reclassify" class="d-inline-flex align-items-center gap-1">
|
||||
{{ hid }}
|
||||
<select name="manual_direction" class="form-select form-select-sm w-auto">
|
||||
<option value="">— модель —</option>
|
||||
{% for d in directions %}
|
||||
<option value="{{ d }}">{{ d }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-info" type="submit">🤖 Обработать моделью</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
Поведение:
|
||||
- «— модель —»: прежнее поведение (LLM).
|
||||
- выбор направления: установится принудительно, без LLM.
|
||||
@@ -0,0 +1,29 @@
|
||||
# manual-direction
|
||||
|
||||
Возможность вручную указывать/менять направление поста при анализе.
|
||||
|
||||
## Why
|
||||
|
||||
Пост без классификации отображается со значком «?» (published.html: `direction or '?'`).
|
||||
Кнопка «Обработать моделью» перезапускает LLM, но если модель не выдаёт каноническое
|
||||
направление — пост так и остаётся без него. Пользователю нужен способ явно задать
|
||||
направление вручную (linux/tech/politics/games/electronics/llm) — например, когда
|
||||
классификатор ошибся или не смог определить тему.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **web/app.py** `post_reclassify`/`impl_reclassify`: новый параметр `manual_direction`
|
||||
(select в форме). Если задан и входит в DIRECTIONS_CANON — направление пишется в БД
|
||||
напрямую (без LLM), `classified=1`, relevance/interest — как при словарном попадании.
|
||||
- **web/templates/selected.html**: перед кнопкой «🤖 Обработать моделью» — select
|
||||
направлений («— модель —» по умолчанию = оставить как есть). Если выбрать конкретное
|
||||
направление — задастся принудительно.
|
||||
- **web/templates/candidates.html**: аналогичный select у формы reclassify? В кандидатах
|
||||
reclassify нет (только «Отобрать»/«Отклонить») — добавление не требуется, но для
|
||||
полноты добавим select кнопке reclassify в selected.html (единственное место с reclassify).
|
||||
|
||||
## Impact
|
||||
|
||||
- Пользователь может вручную исправить/добавить направление, не дожидаясь LLM.
|
||||
- Даёт пост классификацию даже если модель «не знает» — важно для публикации
|
||||
(published.html без «?»).
|
||||
Reference in New Issue
Block a user