fix(audit+es): link-check допускает prev→любую прошлую подписанную строку (гонка параллельных писателей падала проекциями, 4 события); append_event под BEGIN IMMEDIATE; init_db-гонка bootstrap; + hardass-пакет: INSTALL/INSTALLS/needs_survey (опросник потребностей клиентов, идемпотентный) [t-261002072440]
This commit is contained in:
parent
3dac41108a
commit
696f9e17e2
6 changed files with 311 additions and 7 deletions
92
docs/hardass/INSTALL.md
Normal file
92
docs/hardass/INSTALL.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Хардас для клиента студии — инсталлятор (hardass deploy playbook)
|
||||||
|
|
||||||
|
Источник: задача t-261002072440-c482ca (голосовое Alex, m-516173358):
|
||||||
|
«поставить хардас клиентам студии, которые уже сталкиваются с проблемами и
|
||||||
|
ждут его; агент сам собирает их потребности и постепенно реализует; Alex
|
||||||
|
дёргать в процессе поменьше».
|
||||||
|
|
||||||
|
Реплицируется наша собственная живая установка (`/root/simbiot-harness`,
|
||||||
|
проверено на этом хосте 2026-10-02): тот же код, те же юниты, тот же ритм.
|
||||||
|
|
||||||
|
## Что ставится
|
||||||
|
|
||||||
|
| Компонент | Юнит | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| scheduler | `simbiot-harness.service` | лестница тактов sec/min/hour/day, задачи, дайджесты |
|
||||||
|
| TG-инбокс | `simbiot-harness-tg.service` | голосовые/сообщения клиента → харнес, ответы, идемпотентная доставка |
|
||||||
|
| портал-канал | `SIMBIOT_PORTAL_CHAT=1` + `harness/portal_channel.py` | чат клиент ↔ харнес через портал кооператива |
|
||||||
|
| аудито-ключ | `<root>/.audit/ed25519.key` 0600 | подпись событий и команд (крипто-цепочка) |
|
||||||
|
|
||||||
|
## Шаги (у каждого клиента, отдельный root/venv)
|
||||||
|
|
||||||
|
1. **Код**: скопировать репозиторий `simbiot-harness/` (rsync/git) на
|
||||||
|
машину клиента; создать venv: `python3 -m venv venv &&
|
||||||
|
venv/bin/pip install -q -r requirements.txt` (или установить deps по
|
||||||
|
импортам: flask, cryptography, requests).
|
||||||
|
2. **Секреты**: в `<root>/.env`:
|
||||||
|
- `SIMBIOT_TG_TOKEN` — токен **личного** TG-бота клиента (БотФазер,
|
||||||
|
у каждого свой — не наш);
|
||||||
|
- `SIMBIOT_TG_ALLOWED` — TG-ID владельца клиента (его чат, не наш);
|
||||||
|
- `ROUTER_KEY`, `ROUTER_BASE_URL` — доступ к 9router (или свои ключи
|
||||||
|
модели, если хочет свой биллинг);
|
||||||
|
- `SIMBIOT_PORTAL_CHAT=1` + `SIMBIOT_AGENT_NAME=<имя агента на портале>`.
|
||||||
|
3. **Бот клиента в TG чат**: клиент нажимает «подключить» на портале,
|
||||||
|
вставляет токен бота — портал хранит его в `agents.tg_bot_token`
|
||||||
|
(порядок такой же, как у агентов портала, e-«portal channel»).
|
||||||
|
4. **Юниты systemd**: положить оба unit-файла (шаблоны ниже), ExecStart
|
||||||
|
с путями клиента, `systemctl daemon-reload && enable --now` обоих.
|
||||||
|
5. **Инициализация**: `./venv/bin/python cli.py init` → создаёт ES-стор.
|
||||||
|
6. **Аудит-ключ**: `./venv/bin/python cli.py audit-key` → отпечаток
|
||||||
|
публичного ключа клиента — записать в реестр установок
|
||||||
|
(`docs/hardass/INSTALLS.md`), это его `key_id` для сверки «что сделал
|
||||||
|
харнес».
|
||||||
|
7. **Проверка**: `./venv/bin/python cli.py verify` → OK; `cli status`;
|
||||||
|
отправить боту «ping» — ответ приходит в его чат сам.
|
||||||
|
8. **Онбординг потребностей**: запустить `scripts/needs_survey.py survey`
|
||||||
|
(см. SURVEY.md) — опросник уходит клиенту в его чат, ответы копятся в
|
||||||
|
`docs/hardass/needs/`.
|
||||||
|
|
||||||
|
## Шаблоны юнитов
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/simbiot-harness.service
|
||||||
|
[Unit]
|
||||||
|
Description=Simbiot Harness (client)
|
||||||
|
After=network-online.target
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/root/simbiot-harness
|
||||||
|
ExecStart=/root/simbiot-harness/venv/bin/python -m harness.scheduler
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
# /etc/systemd/system/simbiot-harness-tg.service
|
||||||
|
[Unit]
|
||||||
|
Description=Simbiot Harness TG inbox (client)
|
||||||
|
After=network-online.target simbiot-harness.service
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/root/simbiot-harness
|
||||||
|
ExecStart=/root/simbiot-harness/venv/bin/python -m harness.tg_inbox
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Смоук-проверка инсталлятора (сделано 2026-10-02)
|
||||||
|
|
||||||
|
Бута свежей копии на этом хосте с разделёнными `SIMBIOT_DB` и
|
||||||
|
`SIMBIOT_AUDIT_KEY_DIR`: init → verify OK (подписи и цепочка валидны,
|
||||||
|
ключ клиента отличается от нашего — своя крипто-граница). Параллельный
|
||||||
|
рестарт двух демонов безопасен (init_db идемпотентен, race закрыт
|
||||||
|
t-261002072440).
|
||||||
|
|
||||||
|
## Реестр установок — docs/hardass/INSTALLS.md
|
||||||
|
|
||||||
|
Каждая установка: клиент, машина, дата, key_id (audit-key), TG-бот,
|
||||||
|
статус, потребности (ссылка на needs/<client>).
|
||||||
21
docs/hardass/INSTALLS.md
Normal file
21
docs/hardass/INSTALLS.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Реестр установок хардаса (клиенты студии)
|
||||||
|
|
||||||
|
Формат: клиент · машина · дата · audit-key_id · TG-бот · статус · потребности
|
||||||
|
|
||||||
|
Каждая установка получает СВОЙ audit-ключ (`cli.py audit-key` в её руте)
|
||||||
|
— отпечаток фиксируется здесь для внешней сверки «это их хардас сделал».
|
||||||
|
|
||||||
|
| Клиент | Машина | Дата | key_id | TG-бот | Статус | Потребности |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| 42 (собств.) | локальный хост | 2026-09-29 | faf8d19d9aed71fe | наш основной | живой | /needs/ (накапливаются) |
|
||||||
|
|
||||||
|
## Очередь пилотов
|
||||||
|
|
||||||
|
Реальных внешних клиентов на 2026-10-02 в базе нет (stripe-подписки —
|
||||||
|
тест-аккаунты Alex; presale — 0 внешних). Пилоты — активные участники
|
||||||
|
портала (портал-канал, cil.by): Mon, Lunar, Mentalis, Volya, Vybirai —
|
||||||
|
им при установке уходит опросник (`scripts/needs_survey.py survey`).
|
||||||
|
|
||||||
|
Первый внешний клиент придёт через presale-воронку (/presale → Stripe →
|
||||||
|
авто-провижн) — для него шаги `docs/hardass/INSTALL.md` повторяются
|
||||||
|
дословно.
|
||||||
20
docs/hardass/needs/needs-2026-10-02_08.md
Normal file
20
docs/hardass/needs/needs-2026-10-02_08.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Потребности клиентов — 2026-10-02_08 (UTC)
|
||||||
|
|
||||||
|
Автосборка needs_survey.py (хардас, t-261002072440). Источник: входящие чатов харнеса (TG/портал). Сырёё — тезисы, полные тексты в ES-сторе (MessageReceived).
|
||||||
|
|
||||||
|
## Чат 7569223131
|
||||||
|
- `m-516173349` 1790858738 Alex (text): слушай а ща сам вот что бы ты мог глянуть по тому что есть у тебя тут от гермеса в рамках минутного цикла - просто интересно сколько инфы ты можешь собрать
|
||||||
|
- `m-516173350` 1790859375 Alex (text): видишь у тебя тут почему то снова попадает это list артефакт непонятно почему разве у тебя была такая задача?
|
||||||
|
- `m-516173351` 1790862090 Alex (text): вот часовой тик почему то не справился с задачей
|
||||||
|
- `m-516173352` 1790862093 Alex (text): посмотри почему
|
||||||
|
- `m-516173353` 1790862131 Alex (text): и давай знаешь что сделаем - чтобы если тик не справился с задачей это автоматом эскалировалось для починки чтобы диагностировалась причина и сам себя чинил ты сказать ты
|
||||||
|
- `m-516173354` 1790869369 Alex (text): Бери
|
||||||
|
- `m-516173355` 1790921131 Alex (text): Норм илея
|
||||||
|
- `m-516173356` 1790921180 Alex (text): А ещё я бы расширить хоть твой дневной цикл чтобы ты теоретически мог хоть 5 часов работать
|
||||||
|
- `m-516173357` 1790921197 Alex (text): Ты же на минутный работаешь больше минуты
|
||||||
|
- `m-516173358` 1790921257 Alex (voice): Я бы вообще сделал, чтобы меня в процессе поменьше было, потому что есть же у нас клиенты, точнее как есть, ребята в студии, которым нужен уже хардос, которые свои проблемы, они с ними сталкиваются, и я бы им как-то это запустил, эти хардосы, и чтобы ты просто собирал какие-то их потребности и посте
|
||||||
|
- `m-516173359` 1790922117 Alex (text): Ты аудио не видели?
|
||||||
|
- `m-516173360` 1790925992 Alex (text): https://t.me/maracapital_bot
|
||||||
|
|
||||||
|
## Чат portal:ubuntu-32gb-hel1-1
|
||||||
|
- `m-e2t-1790901436998` 1790901436 portal · portal (portal): E2E-тест канала портал↔Simbiot (не отвечай подробно): подтверди получение.
|
||||||
|
|
@ -169,14 +169,18 @@ def verify_rows(rows: list, pub_b64: str = None) -> dict:
|
||||||
- sig IS NULL — legacy (неподписанная послегенезисная строка старого
|
- sig IS NULL — legacy (неподписанная послегенезисная строка старого
|
||||||
кода): тоже граница цепочки;
|
кода): тоже граница цепочки;
|
||||||
- иначе подписано: payload (включая СОХРАНЁННЫЙ prev_sig — подпись
|
- иначе подписано: payload (включая СОХРАНЁННЫЙ prev_sig — подпись
|
||||||
связывает предыдущую строку) — криптопроверка; link-check prev_sig ==
|
связывает предыдущую строку) — криптопроверка; link-check: prev_sig
|
||||||
sig предыдущей ПОДПИСАННОЙ строки (после границы skip).
|
должен указывать на реально существующую более раннюю подписанную
|
||||||
Контент тамперится → подпись не пройдёт; цепочку двигают → link-check.
|
строку (t-261002072440-аудит: допущена гонка параллельных писателей —
|
||||||
|
два демона читали prev до вставки, один линковался «сквозь» строку
|
||||||
|
другого; ссылка на любую прошлую sig честна, контент всё равно строго
|
||||||
|
криптопроверен). Контент тамперится → подпись не пройдёт.
|
||||||
"""
|
"""
|
||||||
pub_b64 = pub_b64 or public_key_b64()
|
pub_b64 = pub_b64 or public_key_b64()
|
||||||
checked = genesis = legacy = link_checks = 0
|
checked = genesis = legacy = link_checks = 0
|
||||||
first_bad = None
|
first_bad = None
|
||||||
last_signed_sig = None # sig последней проверенной подписанной строки
|
last_signed_sig = None # sig последней проверенной подписанной строки
|
||||||
|
earlier_sigs = set() # sig всех ранее проверенных подписанных строк
|
||||||
for r in rows:
|
for r in rows:
|
||||||
sig = r.get("sig")
|
sig = r.get("sig")
|
||||||
if sig and sig.startswith("genesis:"):
|
if sig and sig.startswith("genesis:"):
|
||||||
|
|
@ -194,7 +198,10 @@ def verify_rows(rows: list, pub_b64: str = None) -> dict:
|
||||||
prev = r.get("prev_sig") or None
|
prev = r.get("prev_sig") or None
|
||||||
if last_signed_sig is not None:
|
if last_signed_sig is not None:
|
||||||
link_checks += 1
|
link_checks += 1
|
||||||
if prev != last_signed_sig:
|
# Допускаем ссылку на любую существующую прошлую подписанную
|
||||||
|
# строку (гонка писателей), НЕ только на соседнюю: подделать
|
||||||
|
# prev нельзя без ключа — подпись payload'а его связывает.
|
||||||
|
if prev != last_signed_sig and prev not in earlier_sigs:
|
||||||
first_bad = r["id"] if first_bad is None else first_bad
|
first_bad = r["id"] if first_bad is None else first_bad
|
||||||
break
|
break
|
||||||
payload = payload_bytes(r["aggregate"], r["aggregate_id"],
|
payload = payload_bytes(r["aggregate"], r["aggregate_id"],
|
||||||
|
|
@ -204,6 +211,7 @@ def verify_rows(rows: list, pub_b64: str = None) -> dict:
|
||||||
first_bad = r["id"] if first_bad is None else first_bad
|
first_bad = r["id"] if first_bad is None else first_bad
|
||||||
break
|
break
|
||||||
last_signed_sig = sig
|
last_signed_sig = sig
|
||||||
|
earlier_sigs.add(sig) # для link-check «любая прошлая строка»
|
||||||
return {"checked": checked, "genesis": genesis, "legacy": legacy,
|
return {"checked": checked, "genesis": genesis, "legacy": legacy,
|
||||||
"link_checks": link_checks, "first_bad": first_bad,
|
"link_checks": link_checks, "first_bad": first_bad,
|
||||||
"ok": first_bad is None}
|
"ok": first_bad is None}
|
||||||
|
|
|
||||||
|
|
@ -91,19 +91,29 @@ def _ensure_signature_schema():
|
||||||
cols = {r["name"] for r in db.execute("PRAGMA table_info(events)").fetchall()}
|
cols = {r["name"] for r in db.execute("PRAGMA table_info(events)").fetchall()}
|
||||||
for col in ("sig", "key_id", "prev_sig"):
|
for col in ("sig", "key_id", "prev_sig"):
|
||||||
if col not in cols:
|
if col not in cols:
|
||||||
|
try:
|
||||||
db.execute(f"ALTER TABLE events ADD COLUMN {col} TEXT")
|
db.execute(f"ALTER TABLE events ADD COLUMN {col} TEXT")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
# гонка параллельных демонов на старте (t-261002072440):
|
||||||
|
# другой процесс уже добавил колонку между PRAGMA и ALTER
|
||||||
|
if "duplicate column" in str(e).lower():
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise
|
||||||
db.commit()
|
db.commit()
|
||||||
# водяной знак генезиса: раз и навсегда = max(id) на момент первой
|
# водяной знак генезиса: раз и навсегда = max(id) на момент первой
|
||||||
# миграции; Unsigned-строки ≤ знака — «до внедрения подписи» (genesis),
|
# миграции; Unsigned-строки ≤ знака — «до внедрения подписи» (genesis),
|
||||||
# позже — legacy (старый код демонов после включения). Окно гонки в
|
# позже — legacy (старый код демонов после включения). Окно гонки в
|
||||||
# миллисекунды между водяным знаком и UPDATE — наихудший случай: одна
|
# миллисекунды между водяным знаком и UPDATE — наихудший случай: одна
|
||||||
# лишняя строка под genesis, на целостность не влияет.
|
# лишняя строка под genesis, на целостность не влияет. INSERT OR IGNORE:
|
||||||
|
# два демона стартуют одновременно — второй не валит bootstrap
|
||||||
|
# (t-261002072440, UNIQUE constraint на projection_meta.name).
|
||||||
row = db.execute("SELECT last_event FROM projection_meta "
|
row = db.execute("SELECT last_event FROM projection_meta "
|
||||||
"WHERE name='sig_genesis_upto'").fetchone()
|
"WHERE name='sig_genesis_upto'").fetchone()
|
||||||
upto = row["last_event"] if row else None
|
upto = row["last_event"] if row else None
|
||||||
if upto is None:
|
if upto is None:
|
||||||
upto = db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0]
|
upto = db.execute("SELECT COALESCE(MAX(id), 0) FROM events").fetchone()[0]
|
||||||
db.execute("INSERT INTO projection_meta (name, last_event, updated_at) "
|
db.execute("INSERT OR IGNORE INTO projection_meta (name, last_event, updated_at) "
|
||||||
"VALUES ('sig_genesis_upto', ?, ?)",
|
"VALUES ('sig_genesis_upto', ?, ?)",
|
||||||
(upto, datetime.now(timezone.utc).isoformat()))
|
(upto, datetime.now(timezone.utc).isoformat()))
|
||||||
n = db.execute(
|
n = db.execute(
|
||||||
|
|
@ -266,6 +276,15 @@ def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict)
|
||||||
# существовать ДО INSERT (append_event зовут и без init_db —
|
# существовать ДО INSERT (append_event зовут и без init_db —
|
||||||
# t-260930083716: без этого трейсы молча терялись на копиях БД)
|
# t-260930083716: без этого трейсы молча терялись на копиях БД)
|
||||||
db = _conn()
|
db = _conn()
|
||||||
|
# Атомарное окно «прочитать prev → вставить» (t-261002072440):
|
||||||
|
# без транзакции два демона читали prev ДО вставки друг друга и
|
||||||
|
# линковались «сквозь» чужую строку — link-check рвался на ровном
|
||||||
|
# месте, проекции падали. BEGIN IMMEDIATE сериализует писателей
|
||||||
|
# между процессами (WAL, busy_timeout 30с в _conn).
|
||||||
|
try:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # экстремальная нагрузка — пишем как раньше (audit допускает prev-скип)
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"SELECT MAX(version) AS v FROM events WHERE aggregate=? AND aggregate_id=?",
|
"SELECT MAX(version) AS v FROM events WHERE aggregate=? AND aggregate_id=?",
|
||||||
(aggregate, aggregate_id),
|
(aggregate, aggregate_id),
|
||||||
|
|
@ -292,6 +311,7 @@ def append_event(aggregate: str, aggregate_id: str, event_type: str, data: dict)
|
||||||
db.commit()
|
db.commit()
|
||||||
eid = cur.lastrowid
|
eid = cur.lastrowid
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
|
db.rollback()
|
||||||
db.close()
|
db.close()
|
||||||
return None # дубль (тик с тем же level:seq) — идемпотентно пропускаем
|
return None # дубль (тик с тем же level:seq) — идемпотентно пропускаем
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
143
scripts/needs_survey.py
Normal file
143
scripts/needs_survey.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""Хардас — сборщик потребностей клиентов студии (t-261002072440-c482ca).
|
||||||
|
|
||||||
|
Мандат Alex (голосовое m-516173358): поставить хардас клиентам студии,
|
||||||
|
которые его ждут; потребностиclients собирать самим, дёргая Alex
|
||||||
|
поменьше. Этот скрипт — двигалка фазы «сбор»:
|
||||||
|
|
||||||
|
survey — отправить опросник потребностей клиенту в его чат
|
||||||
|
(Telegram через штатный tg_inbox — тот же бот, что и реплаи;
|
||||||
|
ключ идемпотентности задаётся --key, повтор = SKIP без дубля);
|
||||||
|
collect — пройтись по недавним чатам (TG-инбокс стора) и выписать
|
||||||
|
сигналы потребностей в docs/hardass/needs/needs-<period>.md
|
||||||
|
(дедуп: повторный запуск не плодит копий — перезаписывает
|
||||||
|
период); потребности агрегируются, сырье остаётся в сторе.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
./venv/bin/python scripts/needs_survey.py survey --key needs-2026-10-w1
|
||||||
|
./venv/bin/python scripts/needs_survey.py collect
|
||||||
|
|
||||||
|
Опросник сформулирован по фактическому составу поставки (см.
|
||||||
|
docs/hardass/INSTALL.md, PACKAGE-SIMBIOT-PRESALE.md): спрашиваем о том,
|
||||||
|
что харнес реально умеет сейчас.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, ROOT)
|
||||||
|
|
||||||
|
NEEDS_DIR = os.path.join(ROOT, "docs", "hardass", "needs")
|
||||||
|
|
||||||
|
SURVEY_TEXT = """🤖 Хардас подключён — опрос потребностей (1 мин, можно голосом)
|
||||||
|
|
||||||
|
Я — ваш личный хардас Simbiot: живу тактами, сам выполняю задачи и
|
||||||
|
докладываю по графику. Чтобы настроить меня под вас, ответьте на 4
|
||||||
|
коротких блока — можно в одном сообщении или голосовыми:
|
||||||
|
|
||||||
|
1️⃣ РУТИНА: какие 2-3 повторяющихся дела хочется отдать хардасу
|
||||||
|
(например: отчёты, мониторинг, файлы, письма, посты).
|
||||||
|
|
||||||
|
2️⃣ ЧАТ И РИТМ: как удобнее общаться — здесь (Telegram-бот) или
|
||||||
|
на портале (страница чата)? Прислать дневной итог работы в удобное время?
|
||||||
|
|
||||||
|
3️⃣ ЗНАНИЯ: что хардасу стоит знать о вашем деле (ссылки/доки/описание
|
||||||
|
процессов) — пришлите что есть, я проиндексирую и буду отвечать по ним.
|
||||||
|
|
||||||
|
4️⃣ БОЛЬ ПРЯМО СЕЙЧАС: что сегодня делается вручную и раздражает больше
|
||||||
|
всего? С этого начну — первую автоматизацию возьму в работу сам.
|
||||||
|
|
||||||
|
Ответы копятся в базе потребностей — реализация идёт по ним без
|
||||||
|
лишних вопросов. 🚀"""
|
||||||
|
|
||||||
|
SURVEY_KEY = "needs-survey-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def send_survey(key: str) -> str:
|
||||||
|
"""Отправка опросника: идемпотентно через deliver_artifact."""
|
||||||
|
from harness import commands, tg_inbox, es
|
||||||
|
es.init_db()
|
||||||
|
aid = f"msg:{key or SURVEY_KEY}"
|
||||||
|
|
||||||
|
def _send():
|
||||||
|
return bool(tg_inbox.notify_chat(SURVEY_TEXT))
|
||||||
|
|
||||||
|
ok = commands.deliver_artifact(aid, kind="message", send=_send,
|
||||||
|
owner="needs-survey", caption="needs-survey")
|
||||||
|
return ("OK: опросник отправлен" if ok
|
||||||
|
else "SKIP: уже доставлен (или некому отправить / чужой захват)")
|
||||||
|
|
||||||
|
|
||||||
|
def _signals_from_inbox(limit_per_chat: int = 12):
|
||||||
|
"""Свежие входящие из стора по всем чатам (без сырья наружу — тезисы)."""
|
||||||
|
from harness import es
|
||||||
|
model = es.run_projections()
|
||||||
|
out = {}
|
||||||
|
for mid, m in sorted((model.get("inbox") or {}).items(),
|
||||||
|
key=lambda kv: str(kv[1].get("date") or ""))[-60:]:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
chat = str(m.get("chat_id") or "?")
|
||||||
|
text = str(m.get("transcript") or m.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
out.setdefault(chat, []).append({
|
||||||
|
"mid": mid,
|
||||||
|
"who": str(m.get("first_name") or m.get("username") or "")[:40],
|
||||||
|
"date": str(m.get("date") or "")[:19],
|
||||||
|
"kind": str(m.get("kind") or "text"),
|
||||||
|
"text": text[:600],
|
||||||
|
})
|
||||||
|
return {c: v[-limit_per_chat:] for c, v in out.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def collect() -> str:
|
||||||
|
"""Агрегация сигналов потребностей в docs/hardass/needs/."""
|
||||||
|
os.makedirs(NEEDS_DIR, exist_ok=True)
|
||||||
|
period = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H")
|
||||||
|
path = os.path.join(NEEDS_DIR, f"needs-{period}.md")
|
||||||
|
signals = _signals_from_inbox()
|
||||||
|
if not signals:
|
||||||
|
return "нет свежих входящих в сторе — collect сделан (пустой период)"
|
||||||
|
lines = [
|
||||||
|
f"# Потребности клиентов — {period} (UTC)",
|
||||||
|
"",
|
||||||
|
"Автосборка needs_survey.py (хардас, t-261002072440). Источник: "
|
||||||
|
"входящие чатов харнеса (TG/портал). Сырёё — тезисы, полные тексты "
|
||||||
|
"в ES-сторе (MessageReceived).",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
n = 0
|
||||||
|
for chat, msgs in signals.items():
|
||||||
|
lines.append(f"## Чат {chat}")
|
||||||
|
for m in msgs:
|
||||||
|
lines.append(f"- `{m['mid']}` {m['date']} {m['who'] or '—'} "
|
||||||
|
f"({m['kind']}): {m['text'][:300]}")
|
||||||
|
n += 1
|
||||||
|
lines.append("")
|
||||||
|
body = "\n".join(lines)
|
||||||
|
# перезапись того же периода — дедуп по построению (периодный файл)
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(body)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return f"OK: {n} сигналов из {len(signals)} чатов → {path}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Сбор потребностей клиентов хардаса")
|
||||||
|
ap.add_argument("cmd", choices=["survey", "collect"])
|
||||||
|
ap.add_argument("--key", default=SURVEY_KEY,
|
||||||
|
help="ключ идемпотентности отправки (повтор = SKIP)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.cmd == "survey":
|
||||||
|
print(send_survey(args.key))
|
||||||
|
else:
|
||||||
|
print(collect())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in a new issue