Skip to main content
Glama
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)