From 6a1c894ff7e0d591b3458c8751ce7f7a93d4ab9e Mon Sep 17 00:00:00 2001 From: kpa39l Date: Tue, 22 Sep 2026 17:37:07 +0000 Subject: [PATCH] =?UTF-8?q?openspec:=20change-=D1=8B=20manual-direction=20?= =?UTF-8?q?(=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B5=20=D0=BD=D0=B0=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20LLM)=20=D0=B8=20extract-post-sources=20(=D0=B8=D0=B7?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2)=20+=20sources/extract.?= =?UTF-8?q?py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../extract-post-sources/.openspec.yaml | 3 + .../changes/extract-post-sources/design.md | 125 ++++++++++++++++++ .../changes/extract-post-sources/proposal.md | 32 +++++ .../changes/manual-direction/.openspec.yaml | 3 + openspec/changes/manual-direction/design.md | 55 ++++++++ openspec/changes/manual-direction/proposal.md | 29 ++++ sources/extract.py | 69 ++++++++++ 7 files changed, 316 insertions(+) create mode 100644 openspec/changes/extract-post-sources/.openspec.yaml create mode 100644 openspec/changes/extract-post-sources/design.md create mode 100644 openspec/changes/extract-post-sources/proposal.md create mode 100644 openspec/changes/manual-direction/.openspec.yaml create mode 100644 openspec/changes/manual-direction/design.md create mode 100644 openspec/changes/manual-direction/proposal.md create mode 100644 sources/extract.py diff --git a/openspec/changes/extract-post-sources/.openspec.yaml b/openspec/changes/extract-post-sources/.openspec.yaml new file mode 100644 index 0000000..63c288a --- /dev/null +++ b/openspec/changes/extract-post-sources/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-16 +skip_specs: true \ No newline at end of file diff --git a/openspec/changes/extract-post-sources/design.md b/openspec/changes/extract-post-sources/design.md new file mode 100644 index 0000000..685674b --- /dev/null +++ b/openspec/changes/extract-post-sources/design.md @@ -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"(?\"']+)", 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 %} +
+ 🌐 Источники из поста: + {% for s in post_sources %} + {% if s.known %} + @{{ s.slug_hint or s.domain }} ✓ + {% else %} +
+ {{ hid }} + + + +
+ {% endif %} + {% endfor %} +
+{% endif %} +``` \ No newline at end of file diff --git a/openspec/changes/extract-post-sources/proposal.md b/openspec/changes/extract-post-sources/proposal.md new file mode 100644 index 0000000..6b72055 --- /dev/null +++ b/openspec/changes/extract-post-sources/proposal.md @@ -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-извлечение, бесплатно. Дообогащение модели для + одобренных — отдельная задача (по кнопке, экономия токенов). \ No newline at end of file diff --git a/openspec/changes/manual-direction/.openspec.yaml b/openspec/changes/manual-direction/.openspec.yaml new file mode 100644 index 0000000..454f4e0 --- /dev/null +++ b/openspec/changes/manual-direction/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-16 +skip_specs: true diff --git a/openspec/changes/manual-direction/design.md b/openspec/changes/manual-direction/design.md new file mode 100644 index 0000000..d409d09 --- /dev/null +++ b/openspec/changes/manual-direction/design.md @@ -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 +
+ {{ hid }} + + +
+``` + +Поведение: +- «— модель —»: прежнее поведение (LLM). +- выбор направления: установится принудительно, без LLM. diff --git a/openspec/changes/manual-direction/proposal.md b/openspec/changes/manual-direction/proposal.md new file mode 100644 index 0000000..82614e4 --- /dev/null +++ b/openspec/changes/manual-direction/proposal.md @@ -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 без «?»). diff --git a/sources/extract.py b/sources/extract.py new file mode 100644 index 0000000..d6aade3 --- /dev/null +++ b/sources/extract.py @@ -0,0 +1,69 @@ +# VESTI — извлечение потенциальных источников из текста поста (без LLM, regex). +import re +from urllib.parse import urlparse + +_MAX_PER_POST = 5 + +# t.me/handle, t.me/s/handle, telegram.me/handle +_TG_RE = re.compile( + r"(?:https?://)?(?:www\.)?(?:t\.me|telegram\.me)/(?:s/)?([a-zA-Z][a-zA-Z0-9_]{3,31})" +) +# @handle (не email, не на конце слова) +_AT_RE = re.compile(r"(?\"']+)", re.I) + +_TG_DOMAINS = ("t.me", "telegram.me") + + +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 — url самого поста (t.me/channel/postid): ссылку на него + отбрасываем (это адрес поста, а не источник). + + Возвращает список dict: {"kind": "telegram"|"site", "address": str, + "slug_hint": str|None, "domain": str|None} + максимум _MAX_PER_POST элементов. + """ + out: list[dict] = [] + seen: set[tuple] = set() + + def add(kind: str, address: str, slug_hint: str | None, domain: str | None): + 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) + if len(out) >= _MAX_PER_POST: + break + + for m in _AT_RE.finditer(text): + add("telegram", f"https://t.me/{m.group(1)}", m.group(1), None) + if len(out) >= _MAX_PER_POST: + break + + for m in _URL_RE.finditer(text): + u = m.group(1).rstrip(".,;:!?)]}\"'") + dom = _domain_of(u) + if not dom: + continue + if dom in _TG_DOMAINS or dom.endswith(".t.me"): + continue # уже обработано выше + add("site", u, None, dom) + if len(out) >= _MAX_PER_POST: + break + + return out[:_MAX_PER_POST] \ No newline at end of file