news-agent
README.md
# News Agent — установка и запуск
Полностью автономная система: fetcher → MCP-сервер → claude -p по cron.
Никакого Cowork, никакого Claude Desktop. Биллится через Max-подписку.
## 0. Что у нас по итогу
```
D:\newDay\
├── config.json ← секреты и настройки (в git не попадает)
├── config.example.json ← шаблон config.json без секретов
├── sources.txt ← RSS-источники: name | url | lang | outlet | camp
├── sources.py ← единственный парсер sources.txt (fetcher, clusterer, MCP)
├── inbox.json ← fetcher пишет, MCP читает
├── clusters.json ← clusterer пишет, MCP читает (событийные кластеры)
├── judge_scores.json ← judge_stage пишет (оценки Jev по кластерам), MCP читает
├── state.json ← MCP читает/пишет атомарно
├── archive.jsonl ← новости старше 72 часов (fetcher дописывает, search_news/cite читают)
├── fetch_errors.json ← последний отчёт фетчера
├── fetcher.py ← парсер RSS
├── doh.py ← DNS-over-HTTPS шим для fetcher (опционально)
├── clusterer.py ← событийная кластеризация (эмбеддинги, CPU-only)
├── judge.py ← типизированные суждения: Choice/Score/Noul, бэкенды Jev и claude -p
├── judge_stage.py ← стадия судьи после clusterer (см. §6b)
├── jev_api_key ← ключ TypeSafe одной строкой (в git не попадает)
├── mcp_server.py ← FastMCP сервер
├── requirements.txt
├── .mcp.json ← конфиг для claude -p
├── prompts\
│ ├── watchman.md ← промпт для алертов (часовой)
│ ├── digest.md ← промпт для дайджеста (раз в сутки)
│ └── reader.md ← промпт читателя-сабагента (Sonnet), один сюжет
├── scripts\
│ ├── run-fetcher.ps1 ← каждые 15 мин (fetcher + clusterer + judge_stage)
│ ├── run-watchman.ps1 ← раз в час (ночью реже)
│ ├── run-mcp-http.ps1 ← MCP-сервер по HTTP, с логона, в цикле
│ ├── run-tunnel.ps1 ← cloudflared-туннель к нему, с логона, в цикле
│ ├── run-feedback-collector.ps1 ← каждые 15 мин; LLM только для ответов на вопросы (§6a)
│ └── sync-digest-skill.py ← собирает SKILL.md задачи дайджеста из prompts/digest.md
├── experiments\ ← лаборатории: cluster_lab.py, judge_lab.py, judge_shadow_compare.py
├── cache\ ← кеш read_full + embeddings.npz
└── logs\ ← логи запусков
```
## 1. Установка зависимостей
```powershell
cd D:\newDay
python -m pip install -r requirements.txt
```
Проверь что Python в PATH (нужен 3.11+):
```powershell
python --version
```
## 2. Заполни config.json
Скопируй `config.example.json` в `config.json` и заполни:
- `tg_bot_token` — от @BotFather
- `tg_chat_id` — основной канал «News Claude»
- `tg_log_chat_id` — лог-канал «Log Claude News»
- `tg_test_chat_id` — тестовый канал (создай если ещё нет)
- `telegraph_token` — получить так:
```powershell
curl "https://api.telegra.ph/createAccount?short_name=ClaudeNews&author_name=Claude%20News"
```
В ответе будет `access_token` — его сюда.
- `mcp_bearer_token` — любая длинная случайная строка; её ждёт HTTP-режим
MCP-сервера (`scripts/run-mcp-http.ps1`) от облачных рутин
- `doh_resolver_url` — (опционально) URL DoH-резолвера вида
`https://host/dns-query`. Если задан, fetcher резолвит все хосты через него
в обход системного DNS (помогает при DNS-блокировках провайдера). Пусто —
DoH выключен, используется системный DNS. Можно задать и переменной
окружения `NEWS_DOH_URL`, она имеет приоритет над config.json.
- `test_mode: true` — пока тестируем, потом переключим в false
- `qa_enabled` / `qa_model` / `qa_effort` / `qa_max_per_run` / `qa_timeout_s` —
(опционально, с 09.09.2026) ответы на вопросы под постами, см. §6a.
По умолчанию включено, Sonnet 5, effort medium, не больше 5 ответов за
прогон коллектора. `qa_enabled: false` выключает полностью.
- `judge_backend` / `judge_mode` / `judge_window_hours` / `judge_rpm` /
`judge_workers` / `judge_pairs` / `judge_max_states` / `judge_timeout_s` —
(с 22.09.2026) слой суждений Jev, см. §6b. По умолчанию `jev` + `shadow`:
сайдкар пишется, промпты его не видят. `judge_backend: "off"` или
`judge_mode: "off"` выключает стадию целиком.
`config.json` в `.gitignore` — токены в репозиторий не уходят.
`sources.txt` (с 22.09.2026) — пять колонок через `|`: имя, URL, язык
(`en/ru/et/uk/lv/lt/fi…`), редакция (outlet: ERR News / ERR rus / ERR est →
`ERR`, чтобы кластер считал независимые редакции, а не ленты) и лагерь
(`baltic / western / russian / ukrainian`, для подбора текстов разных
лагерей в reader_packet). Двухколоночные строки по-прежнему работают.
Читает всё `sources.py`; словарей SOURCE_LANG / SOURCE_OUTLET в коде больше
нет.
## 3. Первый прогон fetcher вручную
```powershell
cd D:\newDay
python fetcher.py
```
Должно появиться:
- `inbox.json` с массивом статей
- `fetch_errors.json` с отчётом
- В консоли — `+N` для каждого источника
- В начале лога — `DoH active (all hosts via ...)` если резолвер задан,
иначе `DoH unavailable — falling back to system DNS` (это нормально)
Если какие-то источники постоянно дают ошибку — закомментируй их в
`sources.txt` (поставь `#` в начале строки) или замени URL.
## 4. Проверь MCP-сервер вручную
```powershell
python mcp_server.py
```
Сервер запустится в stdio-режиме — он будет ждать команд через stdin.
Просто проверь что не упал с ошибкой импорта. Ctrl+C для выхода.
## 5. Тестовый прогон watchman
```powershell
cd D:\newDay
.\scripts\run-watchman.ps1
```
Что должно произойти (с 04.09.2026 — два вызова):
- Claude Code запустится в headless режиме (Sonnet 5, --effort high,
--strict-mcp-config: только news-agent)
- watchman_context → окно, alerted_24h, alerts_today, очередь, кластеры
строками (вместо get_state + cleanup_state + list_clusters)
- применит четыре фильтра; get_cluster только для o=1-кандидатов
- watchman_finish → алерты в основной канал + комментарий-методология
под каждым + лог решений + патч state одним вызовом
- ночью (01–07) скрипт сам пропускает часы, кроме 03:00 и 06:00
Лог запуска будет в `D:\newDay\logs\watchman-YYYY-MM-DD.log`.
Если что-то пошло не так — там видно где упало.
## 6. Тестовый прогон digest
Дайджест живёт не в Task Scheduler, а в задаче Claude Desktop (см. §7).
Тестовый прогон — кнопка «Run now» у задачи в Code-табе → Routines.
Что должно произойти (пайплайн с 02.09.2026 — механика на сервере):
- digest_context + digest_baltic_extra → карта суток по кластерам,
балтийский добор, anti-repeat индекс с маркерами ↻/~ в строках кластеров, статистика
- ШАГ 3.5: читатели — сабагенты Agent(model=sonnet) по промпту prompts/reader.md,
по одному на Baltic/World-сюжет, параллельно; каждый делает три вызова:
reader_packet (кластер + близнецы + полные тексты 4 статей разных
редакций одним ответом) → verify_card (цитаты/числа/расхождения, которых
нет в текстах, выбрасываются) → карта
- publish_digest(структура JSON, methodology, topics, notes) → сервер
рендерит Telegraph-HTML, публикует, шлёт пост в канал, комментарий-
методологию под ним, тех.лог в лог-канал и записывает прогон в state —
всё одним вызовом (с 04.09.2026; до этого 7.5/8/9 были отдельными
вызовами, и прогон 03.09 оборвался после публикации, не записав прогон)
- record_digest_run / send_telegram_comment / send_telegram — fallback
- с 06.09 сервер помнит, что напечатал: topics[].printed_text в записи прогона,
digest_context отдаёт anti_repeat.prev_text по совпавшим темам и флаг old=n/m
в строках кластеров; все резолвы статей идут через inbox + archive.jsonl
(статья может истечь из 72ч-окна прямо во время прогона)
- промпт задачи планировщика (SKILL.md) собирается из prompts/digest.md скриптом
scripts/sync-digest-skill.py — после правки digest.md запускать его; модель
больше не читает файл в начале прогона (04.09 это стоило 4 хода)
- prompts/digest.md переписан 05.09.2026 компактно (33k → ~29k знаков) после
двух разборов прогонов, без потери правил: ИНСТРУМЕНТЫ → ШАГ 1–9 → блок
«ИСТОЧНИКИ И ЦИТАТЫ» (все правила про цитаты, язык, [[art_id]], sources и
инлайн-атрибуцию в одном месте); anti-repeat целиком в 3.0 (общие режимы
A/B/C) и 4.0 (ужесточения World/Tech, войны); исторические пояснения
(«05.09 так случилось…») из промпта убраны — мотивация правил живёт в
памяти проекта и в git-истории. Заголовки «ШАГ N» — стабильные
перекрёстные ссылки, не перенумеровывать
Старые тулы (list_news, publish_telegraph, update_state, …) остались как
fallback. Для разовых вопросов вне прогонов есть `search_news(query, lang,
since_iso)` — полнотекстовый поиск по inbox и archive.jsonl (все термины
подстрокой, `lang` — языковая зона источника en/ru/et/uk); id из архива
работают в cite/read_full. В промпты watchman/digest он намеренно не добавлен:
им хватает контекстных тулов, а лишний ход дороже модели.
Серверные тесты: `.venv/Scripts/python.exe scripts/test_digest_tools.py`,
`test_watchman_reader_tools.py`, `test_search_news.py`, `test_fetcher_archive.py`.
Открой Telegraph-ссылку — проверь:
- TOC сверху
- Якори #Baltic / #World / #Tech кликаются
- Все ссылки рабочие (потому что они теперь не из памяти LLM)
## 6a. Вопросы под постами (Q&A, с 09.09.2026)
Два способа получить «что это такое» по непонятному термину в выпуске:
- **«Справка» в карточке.** Поле `background` в элементах baltic[]/world[]
дайджеста: 1–2 предложения общих сведений (что такое PISA, как считают
Euribor…), когда сюжет держится на термине, который карточка не
объясняет. Сервер рендерит его курсивом с подписью «Справка.» между
«Суть» и «Что подчёркивают разные стороны» — так видно, что это общие
знания модели, а не цитата из источников. Правила — в prompts/digest.md,
ШАГ 3.
- **Вопрос реплаем.** Ответь под постом в группе обсуждений сообщением с
«?» или начинающимся с «что такое / объясни / как считается / почему…».
Коллектор (`feedback_collector.py`, каждые 15 мин) распознаёт вопрос,
собирает контекст — текст разбора той темы выпуска, о которой спрашивают
(по пересечению слов с headline/entities/printed_text), плюс заголовки и
summary статей-источников из inbox/archive; для алерта — заголовок, текст
поста и статьи — и делает ОДИН вызов `claude -p` (Sonnet 5, effort medium,
`--tools ""`, без MCP, промпт через stdin). Ответ приходит реплаем в ту же
ветку обычным текстом, в течение ~15 минут. Вопросы попадают в
feedback_log.json с `type: "question"` и блоком `answer`
(text / msg_id / model или error); обычные реплаи — `type: "feedback"`.
Настройки `qa_*` в config.json; `qa_enabled: false` — выключить.
Тесты: `scripts/test_feedback_qa.py` (claude и Telegram замоканы) и
проверка рендера «Справки» в `scripts/test_digest_tools.py`.
## 6b. Слой суждений (TypeSafe Jev, с 22.09.2026)
Между кластеризатором и редакторами (Sonnet-watchman, Opus-дайджест)
стоит дешёвый типизированный судья: `judge_stage.py` запускается третьим
шагом в `run-fetcher.ps1` и для каждого кластера, активного в последние
`judge_window_hours` (30 ч), задаёт Jev вопросы из **своих** текстов:
- по одному Noul на каждый пункт `user_concerns` из config.json («событие —
реальное развитие внутри этого интереса читателя, а не упоминание темы»);
- Choice секции дайджеста `baltic / world / tech / skip`;
- Score масштаба последствий (4 конкретных уровня);
- Noul «конкретное новое событие, а не пересказ/аналитика/анонс»;
- Noul «только заявления чиновников без события».
Судится только новое/изменившееся (хеш заголовка + текстов сэмпла; кэш
ответов `cache/judge_cache.jsonl`), пары кросс-языковых близнецов из
`related_ids` получают P(same_story) как подсказку к слиянию. Ранжирование
делает код, а не модель: `composite()` в judge_stage.py собирает оценку
0..1 из распределений (интерес × масштаб × новизна × не-только-заявления,
всё умножено на 1−P(skip)); веса можно крутить без пересуживания. Правило
«отсёк бы»: P(skip) ≥ 0.7 **и** максимальный интерес < 0.3.
Результат — `judge_scores.json` (ключ = id кластера). Что с ним делает
сервер, решает `judge_mode`:
| режим | watchman_context / digest_context |
|---|---|
| `off` | сайдкар игнорируется |
| `shadow` (по умолчанию) | без изменений; каждый свежий вердикт дописывается в `logs/judge_shadow-<дата>.jsonl` |
| `rank` | в строках кластеров появляется `j=<оценка>`, порядок по ней (охват `o=` — вторичный ключ), ничего не выбрасывается |
| `filter` | `rank` + кластеры с `cut=true` не показываются (балтийские редакции и o≥3 не режутся никогда) |
Отказоустойчивость: нет ключа / SDK / сети, `judge_backend: off` — стадия
выходит с кодом 0, сайдкар не трогается; сервер считает сайдкар старше 6 ч
или покрывающий меньше половины окна отсутствующим и работает по охвату,
как раньше. Jev никогда не решает про BREAKING и ничего не шлёт.
Проверка перед включением `rank`/`filter` — теневой период 2–3 дня, потом:
```powershell
D:\newDay\.venv\Scripts\python.exe experiments\judge_shadow_compare.py
```
(сколько выборов Opus/watchman судья отсёк бы, recall@k порядка по
оценке против порядка по охвату). Лаборатория на замороженном снимке:
`experiments\judge_lab.py --data experiments\snapshot_<дата> --backend jev
--questions v2|v1`; тест — `scripts\test_judge_stage.py`.
## 7. Регистрация задач
Актуально на 05.09.2026. Пять задач в Task Scheduler и одна в Claude
Desktop; ниже — что реально зарегистрировано, а не «как задумывалось».
### Task Scheduler
Общее для всех: пользователь `docto`, «Run whether user is logged on or
not» (хранится пароль), **обычные** права (не highest), «If the task is
already running → Do not start a new instance». Ни у одной нет автоперезапуска
при падении: периодические просто ждут следующего тика, серверные крутятся
в бесконечном цикле внутри своего .ps1.
| Задача | Триггер | Действие | Заметки |
|---|---|---|---|
| `Claude News Fetcher` | каждые 15 мин, с 08:07:45 | `powershell.exe -file "D:\newDay\scripts\run-fetcher.ps1"` | fetcher + clusterer + judge_stage; «only on AC» включён, «Run task as soon as possible after a scheduled start is missed» включён; лимит 72 ч |
| `Claude Urgent News` | каждый час, с 09:00 | `powershell.exe -file "D:\newDay\scripts\run-watchman.ps1"` | watchman; ночное прореживание (01–07 только 03:00 и 06:00) — внутри скрипта, триггер об этом не знает; лимит 72 ч |
| `Claude News MCP HTTP` | при логоне | `powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File D:\newDay\scripts\run-mcp-http.ps1` | `mcp_server.py --http` на 127.0.0.1:8787 с bearer-токеном; цикл перезапускает через 10 с после падения; лимит времени снят (PT0S) |
| `Claude News Tunnel` | при логоне | `powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File D:\newDay\scripts\run-tunnel.ps1` | `bin\cloudflared.exe tunnel run news-mcp` по `cloudflared.yml`; тот же цикл; лимит снят |
| `newsday-feedback-collector` | каждые 15 мин, с 18:08 | `powershell.exe -NoProfile -ExecutionPolicy Bypass -File D:\newDay\scripts\run-feedback-collector.ps1` | `feedback_collector.py`: реплаи из группы обсуждений → `feedback_log.json`; без LLM; лимит 72 ч |
Проверить всё разом:
```powershell
Get-ScheduledTask | Where-Object { $_.TaskName -match 'Claude|newsday' } |
ForEach-Object { $i = $_ | Get-ScheduledTaskInfo
[pscustomobject]@{ Task=$_.TaskName; State=$_.State; LastRun=$i.LastRunTime;
Result=$i.LastTaskResult; Next=$i.NextRunTime } } | Format-Table -AutoSize
```
Result `0` — нормальный выход, `267009` (0x41301) — «ещё выполняется», для
двух серверных задач это штатное состояние. Менять задачи из обычной сессии
нельзя (Access is denied) — только из консоли администратора.
Рестарт MCP-сервера после правки `mcp_server.py` (туннель трогать не нужно):
```powershell
Stop-ScheduledTask "Claude News MCP HTTP"; Start-ScheduledTask "Claude News MCP HTTP"
```
Зарегистрировать задачу заново, если планировщик чистый: Task Scheduler →
Create Task, имя и триггер из таблицы, Action → Start a program →
`powershell.exe` с аргументами из таблицы, Settings → «Allow task to be run
on demand», для серверных задач снять «Stop the task if it runs longer than».
### Claude Desktop (дайджест)
Дайджест — scheduled task самого Claude Desktop (Code-таб → Routines), а не
Task Scheduler: cron `8 9 * * *` (09:08 по местному; «09:00» в тексте —
округление), запускается как обычная сессия Claude Code с cwd `D:\newDay`,
permissionMode `auto`, и его прогоны видны в списке сессий.
- Промпт задачи — `%USERPROFILE%\.claude\scheduled-tasks\newday-watchman\SKILL.md`
(имя каталога историческое). Он собирается из `prompts\digest.md` скриптом
`python scripts\sync-digest-skill.py`; править исходник, потом запустить
скрипт, руками SKILL.md не трогать.
- Реестр задач —
`%APPDATA%\Claude\claude-code-sessions\<account>\<session>\scheduled-tasks.json`;
там же поле `model` (сейчас `claude-opus-5`) и список одобренных тулов
(`approvedPermissions`); effort — `effortLevel` в `D:\newDay\.claude\settings.json`.
- Реестр живёт в профиле приложения: переустановка Claude Desktop его
стирает, SKILL.md при этом остаётся — задачу придётся создать заново.
## 8. Переключение в боевой режим
Когда тесты прошли:
1. Отредактируй `config.json` → `"test_mode": false`
2. Следующий запуск пойдёт в основной канал.
Откатиться обратно — поменять флаг и снова `true`. На лету, перезапуск
тасков не нужен.
## Архитектурные принципы (чтобы помнить как оно работает)
1. **Fetcher детерминистичен.** Только Python, без LLM. Парсит, дедуплицирует,
присваивает стабильные id (sha1 от URL). Можно перезапускать сколько угодно
раз — id у одной и той же статьи не поменяется.
1a. **Clusterer тоже детерминистичен и тоже без LLM.** Запускается после
fetcher (в том же run-fetcher.ps1, падение некритично). Эмбеддит
title+summary мультиязычной моделью multilingual-e5-base **строго на CPU**
(машина игровая, GPU не трогаем), кластеризует 72ч-окно по событиям:
базовый порог 0.90 + якорь-основатель против дрейфа центроида; комы
>30 статей пересклеиваются внутри порогом 0.93 (storyline → события);
кластеры-близнецы одного события на разных языках связываются через
related_ids (кросс-языковая похожесть систематически ниже — у
кросс-языковых кандидатов квота в related-списке). id кластера = id
первой статьи (стабилен, пока она в окне). Векторы кешируются в
cache/embeddings.npz — статья эмбеддится один раз в жизни. LLM видит
кластеры через list_clusters / get_cluster; поле outlets считает
независимые редакции (Pravda RU+UA = одна редакция), это правильная
основа для фильтра «подтверждено ≥2 источниками».
2. **MCP — единственный канал для LLM работать с данными.** Промпт не делает
web-fetch и не парсит RSS. Только `list_news`, `read_full`, `cite`,
`publish_*`, `send_*`, `get/update_state`.
2a. **Механика дайджеста живёт на сервере, у модели — только суждение.**
`digest_context` (компактная карта суток + балтийский добор по кластерам
+ готовый anti-repeat индекс и детерминированные маркеры повторов),
`publish_digest` (JSON-структура → HTML-шаблон Telegraph + пост в канал),
`record_digest_run` (запись прогона из серверного кеша digest_run_cache.json).
До этого модель сама ужимала 67 КБ кластеров скриптами, матчила 67 КБ
балтийских лент по заголовкам, строила индекс из 33 КБ state и писала
13 КБ HTML руками — ~половина 10-минутного прогона.
2b. **Читатели проверяются кодом, не доверием.** Sonnet-читатели (prompts/reader.md)
дают ×5 покрытие статей, но в эксперименте 02.09 выдумывали «дословные»
цитаты и складывали числа сами. Поэтому `verify_card` проверяет каждую
цитату подстрокой по тексту статьи (кеш read_full, до 60 КБ), число —
как отдельное число в тексте, а «расхождение» требует двух проверенных
цитат из двух статей. Что не нашлось — выбрасывается до того, как
попадёт главной модели.
3. **URL никогда не покидают MCP-сервер в виде «голого» текста.** LLM получает
только id. Когда нужно процитировать — ставит `[[art_xxx]]` плейсхолдер,
сервер при отправке/публикации заменяет на реальную ссылку. Галлюцинации
URL невозможны архитектурно.
4. **Состояние атомарно.** state.json пишется через temp + rename. Никаких
race conditions между fetcher / watchman / digest.
5. **Fallback-логика в промпте.** Если Telegraph упал — отправь короткую
версию в TG. Если list_news пустой — не отправляй ничего, пиши в лог.
## Если что-то сломалось
- Логи: `D:\newDay\logs\` — отдельный файл на каждый день и каждый таск
- Тех.отчёт fetcher: `D:\newDay\fetch_errors.json` — последний прогон
- Состояние: `D:\newDay\state.json` — можно глазами посмотреть что watchman помнит
- Inbox: `D:\newDay\inbox.json` — посмотреть что fetcher собрал
- Архив: `D:\newDay\archive.jsonl` — всё, что вышло за 72 часа (с 05.09.2026;
раньше терялось), ~2 МБ/сутки, ротации нет
## Стоимость
Биллится через подписку (Max 5x). API-биллинг = 0 (`ANTHROPIC_API_KEY`
чистится в скриптах). Что реально стоит прогон — в API-эквиваленте по
usage из транскриптов сессий (~/.claude/projects/D--newDay/*.jsonl):
- до 04.09.2026: watchman (Sonnet, 12–16 ходов, 24/сутки) ~$12/сутки;
дайджест на Fable 5.1 (43 хода, xhigh) ~$29 + 9 читателей по 28 ходов
~$11 — итого ~$52/сутки, три четверти всего расхода подписки.
- с 04.09.2026: дайджест на Opus 5 (модель задачи в реестре
%APPDATA%\Claude\claude-code-sessions\…\scheduled-tasks.json), effort
high (.claude/settings.json → effortLevel), читатели по 3 вызова,
publish_digest закрывает прогон одним вызовом; watchman на двух тулах,
--effort high, ночью 19 прогонов вместо 24. Ожидание ~$8–10/сутки.
Главный принцип: платят не за модель, а за ходы — на каждом ходу агент
заново отправляет весь контекст (40–60k токенов обвязки Claude Code +
всё накопленное). Меньше ходов = дешевле при той же модели.
## Что улучшить позже
- Добавить парсинг HTML-источников без RSS (через BeautifulSoup в fetcher.py)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues