Compare commits
14 Commits
codex/proj
...
feature/cb
| Author | SHA1 | Date | |
|---|---|---|---|
| efa55fe74b | |||
| 0eedcfcfbd | |||
| 1d398ebb00 | |||
| 9cf6a7e940 | |||
| b8b9ec75b3 | |||
| c94131effc | |||
| d9b4f19af3 | |||
| d4ee76948f | |||
| cf54bc2246 | |||
| a67bdbfaa4 | |||
| f4a376ff4b | |||
| ba96b3e633 | |||
| caaba36a0c | |||
| 2921dbc152 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Local credentials and secrets
|
# Local credentials and secrets
|
||||||
credentials.txt
|
credentials.json
|
||||||
|
|
||||||
# Python/runtime artifacts (for the upcoming bot implementation)
|
# Python/runtime artifacts (for the upcoming bot implementation)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -10,3 +10,6 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Local application archives
|
||||||
|
data/
|
||||||
|
|||||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Акылдаш
|
||||||
|
|
||||||
|
Акылдаш — проект юридической информационно-аналитической платформы. Цель —
|
||||||
|
собирать правовые источники на законных основаниях, сохранять их происхождение
|
||||||
|
и версии, готовить данные для поиска и RAG, а затем предоставлять результаты
|
||||||
|
через API и пользовательский интерфейс со ссылками на первоисточники.
|
||||||
|
|
||||||
|
Telegram-бот — только часть рабочего окружения команды, а не основной продукт.
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и первая backend-функция
|
||||||
|
версии `0.1.2`: возобновляемая выгрузка документов из ЦБД Минюста КР.
|
||||||
|
|
||||||
|
| Компонент | Версия | Состояние |
|
||||||
|
|---|---:|---|
|
||||||
|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||||
|
| Backend | `0.1.2` | реализована выгрузка документов ЦБД Минюста КР |
|
||||||
|
| Frontend | — | ещё не создан |
|
||||||
|
| Сбор и обработка правовых данных | `0.1.2` | реализован архиватор ЦБД Минюста КР |
|
||||||
|
| RAG и база знаний | — | ещё не созданы |
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/ структурированная документация проекта
|
||||||
|
decisions/ принятые архитектурные и продуктовые решения
|
||||||
|
operations/ состояние проекта и рабочие процессы
|
||||||
|
product/ назначение, границы и развитие продукта
|
||||||
|
team/ материалы для команды
|
||||||
|
tools/
|
||||||
|
telegram-bot/ бот рабочего Telegram-пространства
|
||||||
|
backend/
|
||||||
|
ingestion/ получение и обновление правовых источников
|
||||||
|
```
|
||||||
|
|
||||||
|
Каталоги для загрузки и обработки источников, RAG, backend и frontend будут
|
||||||
|
создаваться с первой реальной задачей в соответствующей области. Это позволит
|
||||||
|
выбрать структуру по фактическим требованиям, а не поддерживать пустой каркас.
|
||||||
|
|
||||||
|
Начать знакомство с проектом: [документация](docs/README.md) и
|
||||||
|
[обзор продукта](docs/product/project-overview.md).
|
||||||
|
|
||||||
|
## Конфиденциальные материалы
|
||||||
|
|
||||||
|
Секреты, персональные данные, договоры и материалы по правовой защите проекта
|
||||||
|
не должны храниться в этом репозитории. Для них нужен отдельный закрытый
|
||||||
|
репозиторий или защищённое хранилище с минимально необходимыми правами доступа,
|
||||||
|
журналированием и резервным копированием. Здесь допустимы только несекретные
|
||||||
|
правила и ссылки на такие материалы без раскрытия их содержания.
|
||||||
|
|
||||||
|
## Проверка Telegram-бота
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tools/telegram-bot -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
64
backend/README.md
Normal file
64
backend/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Backend Акылдаш
|
||||||
|
|
||||||
|
Версия: `0.1.2`
|
||||||
|
|
||||||
|
Первая backend-область проекта — загрузка правовых документов из официального
|
||||||
|
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||||
|
`ingestion/minjust_cbd.py`: его можно запускать как CLI сейчас и импортировать
|
||||||
|
из будущего планировщика backend без запуска отдельного процесса.
|
||||||
|
|
||||||
|
## Пробная загрузка
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 backend/ingestion/minjust_cbd.py --limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
В интерактивном терминале отображаются процент, количество документов,
|
||||||
|
ошибки, скорость и примерное оставшееся время.
|
||||||
|
|
||||||
|
Полная загрузка выполняется без `--limit`. По умолчанию архив сохраняется в
|
||||||
|
`data/minjust-cbd`, который исключён из Git. Повторный запуск пропускает уже
|
||||||
|
загруженные документы; `--refresh` принудительно проверяет их заново.
|
||||||
|
Если временный идентификатор списка API истечёт во время многодневной загрузки,
|
||||||
|
скрипт пересоздаст список на текущей странице и продолжит автоматически.
|
||||||
|
|
||||||
|
В версии `0.1.0` обновление существующих документов выполняется полной проверкой
|
||||||
|
через `--refresh`. Инкрементальную проверку по `lastmod` из sitemap следует
|
||||||
|
добавить вместе с backend-планировщиком, когда будет определена частота запуска.
|
||||||
|
|
||||||
|
## Защита API
|
||||||
|
|
||||||
|
Запросы выполняются последовательно, по умолчанию не чаще одного в секунду.
|
||||||
|
Timeout одного ответа — 60 секунд, число попыток — 5, задержка между повторами
|
||||||
|
растёт экспоненциально. При HTTP 429 учитывается заголовок `Retry-After`.
|
||||||
|
Лимит действует на один процесс, поэтому одновременно следует запускать только
|
||||||
|
один экземпляр загрузчика.
|
||||||
|
|
||||||
|
Более осторожный режим:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 backend/ingestion/minjust_cbd.py --requests-per-second 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Вызов из будущего backend
|
||||||
|
|
||||||
|
```python
|
||||||
|
from ingestion.minjust_cbd import sync_archive
|
||||||
|
|
||||||
|
result = sync_archive(output_path)
|
||||||
|
```
|
||||||
|
|
||||||
|
Планировщик, очередь задач и PostgreSQL пока не добавлены: модуль не зависит от
|
||||||
|
выбора будущего backend-фреймворка.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=backend python3 -m unittest backend/test_minjust_cbd.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Backend v0.1.2 · Frontend — не создан
|
||||||
373
backend/ingestion/minjust_cbd.py
Normal file
373
backend/ingestion/minjust_cbd.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Download and archive documents from the Kyrgyz Republic Ministry of Justice."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
|
APP_VERSION = "0.1.2"
|
||||||
|
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||||
|
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||||
|
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||||
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CbdClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
requests_per_second: float = 1,
|
||||||
|
timeout: int = 60,
|
||||||
|
retries: int = 5,
|
||||||
|
) -> None:
|
||||||
|
if requests_per_second <= 0:
|
||||||
|
raise ValueError("requests_per_second must be greater than zero")
|
||||||
|
if timeout <= 0 or retries <= 0:
|
||||||
|
raise ValueError("timeout and retries must be greater than zero")
|
||||||
|
# ponytail: per-process limit; add a distributed lock before multiple workers.
|
||||||
|
self.interval = 1 / requests_per_second
|
||||||
|
self.timeout = timeout
|
||||||
|
self.retries = retries
|
||||||
|
self.last_request = 0.0
|
||||||
|
self.total_documents = 0
|
||||||
|
|
||||||
|
def request_json(self, method: str, parameters: Iterable[tuple[str, object]] = ()):
|
||||||
|
query = urllib.parse.urlencode(list(parameters), doseq=True)
|
||||||
|
extension = "" if method == "CheckAvailable" else ".json"
|
||||||
|
url = f"{API_BASE_URL}{method}{extension}" + (f"?{query}" if query else "")
|
||||||
|
for attempt in range(self.retries):
|
||||||
|
retry_delay = 2**attempt
|
||||||
|
wait = self.interval - (time.monotonic() - self.last_request)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": f"Akyldash/{APP_VERSION} (+https://cbd.minjust.gov.kg)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.last_request = time.monotonic()
|
||||||
|
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||||
|
body = response.read()
|
||||||
|
return json.loads(body) if body else None
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if error.code != 429 and error.code < 500:
|
||||||
|
raise
|
||||||
|
if error.code == 429:
|
||||||
|
try:
|
||||||
|
retry_delay = max(
|
||||||
|
retry_delay, float(error.headers.get("Retry-After", 0))
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
except (TimeoutError, urllib.error.URLError):
|
||||||
|
pass
|
||||||
|
if attempt + 1 < self.retries:
|
||||||
|
time.sleep(retry_delay)
|
||||||
|
raise RuntimeError(f"Ministry of Justice API request failed: {url}")
|
||||||
|
|
||||||
|
def check_available(self) -> None:
|
||||||
|
self.request_json("CheckAvailable")
|
||||||
|
|
||||||
|
def document_codes(self, limit: int | None = None, page_size: int = 1000):
|
||||||
|
def query_page(page_number: int):
|
||||||
|
return self.request_json(
|
||||||
|
"GetDocumentListByQuery",
|
||||||
|
(
|
||||||
|
("Property", "Code"),
|
||||||
|
("PageSize", page_size),
|
||||||
|
("PageNumber", page_number),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
first = query_page(1)
|
||||||
|
yielded = 0
|
||||||
|
page = first
|
||||||
|
page_number = 1
|
||||||
|
self.total_documents = min(first["TotalCount"], limit or first["TotalCount"])
|
||||||
|
while True:
|
||||||
|
for document in page["Documents"]:
|
||||||
|
yield document["Code"]
|
||||||
|
yielded += 1
|
||||||
|
if limit is not None and yielded >= limit:
|
||||||
|
return
|
||||||
|
if yielded >= page["TotalCount"]:
|
||||||
|
return
|
||||||
|
page_number += 1
|
||||||
|
try:
|
||||||
|
page = self.request_json(
|
||||||
|
"GetDocumentListById",
|
||||||
|
(
|
||||||
|
("DocumentListId", first["Id"]),
|
||||||
|
("Property", "Code"),
|
||||||
|
("PageSize", page_size),
|
||||||
|
("PageNumber", page_number),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
if error.code != 404:
|
||||||
|
raise
|
||||||
|
first = page = query_page(page_number)
|
||||||
|
self.total_documents = min(
|
||||||
|
first["TotalCount"], limit or first["TotalCount"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def document(self, code: int) -> dict:
|
||||||
|
return self.request_json(
|
||||||
|
"GetDocument",
|
||||||
|
(
|
||||||
|
("Code", code),
|
||||||
|
("Editions.Select", "all"),
|
||||||
|
("Editions.Data", "all"),
|
||||||
|
("Editions.Images.Select", "all"),
|
||||||
|
("Editions.Images.Data", "all"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SyncResult:
|
||||||
|
discovered: int = 0
|
||||||
|
downloaded: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
seconds = max(0, round(seconds))
|
||||||
|
hours, seconds = divmod(seconds, 3600)
|
||||||
|
minutes, seconds = divmod(seconds, 60)
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def progress_line(
|
||||||
|
result: SyncResult, total: int, elapsed: float, width: int = 24
|
||||||
|
) -> str:
|
||||||
|
processed = result.discovered
|
||||||
|
fraction = processed / total if total else 0
|
||||||
|
filled = min(width, round(width * fraction))
|
||||||
|
rate = processed / elapsed if elapsed > 0 else 0
|
||||||
|
eta = (total - processed) / rate if rate else 0
|
||||||
|
return (
|
||||||
|
f"[{'#' * filled}{'-' * (width - filled)}] {fraction:6.2%} "
|
||||||
|
f"{processed}/{total} downloaded={result.downloaded} "
|
||||||
|
f"skipped={result.skipped} failed={result.failed} "
|
||||||
|
f"rate={rate:.2f}/s ETA={format_duration(eta)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def terminal_progress() -> Callable[[SyncResult, int], None]:
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
def update(result: SyncResult, total: int) -> None:
|
||||||
|
line = progress_line(result, total, time.monotonic() - started)
|
||||||
|
print(
|
||||||
|
f"\r{line}",
|
||||||
|
end="\n" if result.discovered >= total else "",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return update
|
||||||
|
|
||||||
|
|
||||||
|
def connect_manifest(path: Path) -> sqlite3.Connection:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(path)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
code INTEGER PRIMARY KEY,
|
||||||
|
source_sha256 TEXT NOT NULL,
|
||||||
|
fetched_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS errors (
|
||||||
|
code INTEGER PRIMARY KEY,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
failed_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write(path: Path, content: bytes) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
|
||||||
|
temporary.write(content)
|
||||||
|
temporary_path = Path(temporary.name)
|
||||||
|
os.replace(temporary_path, path)
|
||||||
|
|
||||||
|
|
||||||
|
def json_bytes(value: object) -> bytes:
|
||||||
|
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
|
||||||
|
|
||||||
|
|
||||||
|
def archive_document(root: Path, document: dict) -> str:
|
||||||
|
code = int(document["Code"])
|
||||||
|
source = json.dumps(document, ensure_ascii=False, sort_keys=True).encode()
|
||||||
|
source_sha256 = hashlib.sha256(source).hexdigest()
|
||||||
|
directory = root / "documents" / str(code)
|
||||||
|
editions = document.get("Editions") or []
|
||||||
|
metadata = {key: value for key, value in document.items() if key != "Editions"}
|
||||||
|
atomic_write(directory / "metadata.json", json_bytes(metadata))
|
||||||
|
|
||||||
|
for edition in editions:
|
||||||
|
edition_directory = directory / "editions" / str(int(edition["Code"]))
|
||||||
|
edition_metadata = {key: value for key, value in edition.items() if key != "Data"}
|
||||||
|
edition_metadata["Images"] = [
|
||||||
|
{key: value for key, value in image.items() if key != "Data"}
|
||||||
|
for image in edition.get("Images") or []
|
||||||
|
]
|
||||||
|
atomic_write(edition_directory / "metadata.json", json_bytes(edition_metadata))
|
||||||
|
|
||||||
|
for source_language, filename in LANGUAGES.items():
|
||||||
|
html = (edition.get("Data") or {}).get(source_language)
|
||||||
|
if html:
|
||||||
|
atomic_write(edition_directory / f"{filename}.html", html.encode())
|
||||||
|
|
||||||
|
for image in edition.get("Images") or []:
|
||||||
|
if not image.get("Data"):
|
||||||
|
continue
|
||||||
|
language = IMAGE_LANGUAGES.get(image.get("Lang"), "unknown")
|
||||||
|
filename = Path(str(image.get("Name") or "image").replace("\\", "/")).name
|
||||||
|
if filename in {"", ".", ".."}:
|
||||||
|
raise ValueError(f"Invalid image filename in document {code}")
|
||||||
|
atomic_write(
|
||||||
|
edition_directory / "images" / language / filename,
|
||||||
|
base64.b64decode(image["Data"], validate=True),
|
||||||
|
)
|
||||||
|
return source_sha256
|
||||||
|
|
||||||
|
|
||||||
|
def sync_archive(
|
||||||
|
output: Path,
|
||||||
|
client: CbdClient | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
refresh: bool = False,
|
||||||
|
progress: Callable[[SyncResult, int], None] | None = None,
|
||||||
|
) -> SyncResult:
|
||||||
|
client = client or CbdClient()
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = connect_manifest(output / "manifest.sqlite3")
|
||||||
|
known = {row[0] for row in connection.execute("SELECT code FROM documents")}
|
||||||
|
discovered = downloaded = skipped = failed = 0
|
||||||
|
|
||||||
|
client.check_available()
|
||||||
|
for code in client.document_codes(limit):
|
||||||
|
discovered += 1
|
||||||
|
# ponytail: refresh scans all documents; use sitemap lastmod when scheduled
|
||||||
|
# update checks become frequent enough for the extra mapping logic to pay off.
|
||||||
|
if code in known and not refresh:
|
||||||
|
skipped += 1
|
||||||
|
if progress:
|
||||||
|
progress(
|
||||||
|
SyncResult(discovered, downloaded, skipped, failed),
|
||||||
|
client.total_documents,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
document = client.document(code)
|
||||||
|
checksum = archive_document(output, document)
|
||||||
|
fetched_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
with connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO documents (code, source_sha256, fetched_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(code) DO UPDATE SET
|
||||||
|
source_sha256 = excluded.source_sha256,
|
||||||
|
fetched_at = excluded.fetched_at
|
||||||
|
""",
|
||||||
|
(int(document["Code"]), checksum, fetched_at),
|
||||||
|
)
|
||||||
|
connection.execute("DELETE FROM errors WHERE code = ?", (code,))
|
||||||
|
downloaded += 1
|
||||||
|
except Exception as error: # Continue the long-running archive after one bad record.
|
||||||
|
with connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(code) DO UPDATE SET
|
||||||
|
message = excluded.message,
|
||||||
|
failed_at = excluded.failed_at
|
||||||
|
""",
|
||||||
|
(code, str(error), datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
failed += 1
|
||||||
|
if progress:
|
||||||
|
progress(
|
||||||
|
SyncResult(discovered, downloaded, skipped, failed),
|
||||||
|
client.total_documents,
|
||||||
|
)
|
||||||
|
if not progress and discovered % 100 == 0:
|
||||||
|
LOGGER.info(
|
||||||
|
"discovered=%s downloaded=%s skipped=%s failed=%s",
|
||||||
|
discovered,
|
||||||
|
downloaded,
|
||||||
|
skipped,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
connection.close()
|
||||||
|
return SyncResult(discovered, downloaded, skipped, failed)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--output", type=Path, default=Path("data/minjust-cbd"))
|
||||||
|
parser.add_argument("--limit", type=int, help="download only the first N documents")
|
||||||
|
parser.add_argument("--refresh", action="store_true", help="redownload known documents")
|
||||||
|
parser.add_argument("--requests-per-second", type=float, default=1)
|
||||||
|
parser.add_argument("--timeout", type=int, default=60)
|
||||||
|
parser.add_argument("--retries", type=int, default=5)
|
||||||
|
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
arguments = parse_args()
|
||||||
|
if arguments.limit is not None and arguments.limit <= 0:
|
||||||
|
raise SystemExit("--limit must be greater than zero")
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
result = sync_archive(
|
||||||
|
arguments.output,
|
||||||
|
CbdClient(
|
||||||
|
requests_per_second=arguments.requests_per_second,
|
||||||
|
timeout=arguments.timeout,
|
||||||
|
retries=arguments.retries,
|
||||||
|
),
|
||||||
|
arguments.limit,
|
||||||
|
arguments.refresh,
|
||||||
|
terminal_progress() if sys.stderr.isatty() else None,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"discovered={result.discovered} downloaded={result.downloaded} "
|
||||||
|
f"skipped={result.skipped} failed={result.failed}\n"
|
||||||
|
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
|
||||||
|
)
|
||||||
|
return int(result.failed > 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
133
backend/test_minjust_cbd.py
Normal file
133
backend/test_minjust_cbd.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from email.message import Message
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from ingestion.minjust_cbd import CbdClient, SyncResult, progress_line, sync_archive
|
||||||
|
|
||||||
|
|
||||||
|
class MinjustCbdTest(unittest.TestCase):
|
||||||
|
def test_archives_document_and_resumes(self):
|
||||||
|
image = base64.b64encode(b"image").decode()
|
||||||
|
document = {
|
||||||
|
"Code": 1,
|
||||||
|
"Name": {"Rus": "Закон", "Kyr": "Мыйзам"},
|
||||||
|
"Editions": [
|
||||||
|
{
|
||||||
|
"Code": 10,
|
||||||
|
"Name": {"Rus": "01.01.2026", "Kyr": "01.01.2026"},
|
||||||
|
"Data": {"Rus": "<p>Закон</p>", "Kyr": "<p>Мыйзам</p>"},
|
||||||
|
"Images": [
|
||||||
|
{
|
||||||
|
"Lang": "Russian",
|
||||||
|
"Name": "image001.jpg",
|
||||||
|
"Data": image,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
client = Mock()
|
||||||
|
client.total_documents = 1
|
||||||
|
client.document_codes.return_value = [1]
|
||||||
|
client.document.return_value = document
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
output = Path(directory)
|
||||||
|
first = sync_archive(output, client)
|
||||||
|
second = sync_archive(output, client)
|
||||||
|
refreshed = sync_archive(output, client, refresh=True)
|
||||||
|
|
||||||
|
self.assertEqual(first.downloaded, 1)
|
||||||
|
self.assertEqual(second.skipped, 1)
|
||||||
|
self.assertEqual(refreshed.downloaded, 1)
|
||||||
|
self.assertEqual(client.document.call_count, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
(output / "documents/1/editions/10/ru.html").read_text(),
|
||||||
|
"<p>Закон</p>",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(output / "documents/1/editions/10/images/ru/image001.jpg").read_bytes(),
|
||||||
|
b"image",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_uses_stable_list_id_for_next_page(self):
|
||||||
|
client = CbdClient(requests_per_second=1000)
|
||||||
|
client.request_json = Mock(
|
||||||
|
side_effect=[
|
||||||
|
{
|
||||||
|
"Id": "list-id",
|
||||||
|
"TotalCount": 3,
|
||||||
|
"Documents": [{"Code": 1}, {"Code": 2}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "list-id",
|
||||||
|
"TotalCount": 3,
|
||||||
|
"Documents": [{"Code": 3}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(list(client.document_codes(page_size=2)), [1, 2, 3])
|
||||||
|
self.assertEqual(
|
||||||
|
client.request_json.call_args_list[1].args[0], "GetDocumentListById"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recreates_expired_list_at_current_page(self):
|
||||||
|
client = CbdClient(requests_per_second=1000)
|
||||||
|
client.request_json = Mock(
|
||||||
|
side_effect=[
|
||||||
|
{
|
||||||
|
"Id": "expired-list",
|
||||||
|
"TotalCount": 3,
|
||||||
|
"Documents": [{"Code": 1}, {"Code": 2}],
|
||||||
|
},
|
||||||
|
urllib.error.HTTPError("https://example.test", 404, "", {}, None),
|
||||||
|
{
|
||||||
|
"Id": "new-list",
|
||||||
|
"TotalCount": 3,
|
||||||
|
"Documents": [{"Code": 3}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(list(client.document_codes(page_size=2)), [1, 2, 3])
|
||||||
|
recovery_call = client.request_json.call_args_list[2]
|
||||||
|
self.assertEqual(recovery_call.args[0], "GetDocumentListByQuery")
|
||||||
|
self.assertIn(("PageNumber", 2), recovery_call.args[1])
|
||||||
|
|
||||||
|
def test_formats_progress_with_rate_and_eta(self):
|
||||||
|
line = progress_line(
|
||||||
|
SyncResult(discovered=50, downloaded=48, skipped=1, failed=1),
|
||||||
|
total=100,
|
||||||
|
elapsed=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("50.00%", line)
|
||||||
|
self.assertIn("rate=2.00/s", line)
|
||||||
|
self.assertIn("ETA=00:00:25", line)
|
||||||
|
|
||||||
|
@patch("ingestion.minjust_cbd.time.sleep")
|
||||||
|
@patch("ingestion.minjust_cbd.urllib.request.urlopen")
|
||||||
|
def test_respects_retry_after_on_rate_limit(self, urlopen, sleep):
|
||||||
|
headers = Message()
|
||||||
|
headers["Retry-After"] = "7"
|
||||||
|
response = Mock()
|
||||||
|
response.read.return_value = b"{}"
|
||||||
|
response.__enter__ = Mock(return_value=response)
|
||||||
|
response.__exit__ = Mock(return_value=False)
|
||||||
|
urlopen.side_effect = [
|
||||||
|
urllib.error.HTTPError("https://example.test", 429, "", headers, None),
|
||||||
|
response,
|
||||||
|
]
|
||||||
|
|
||||||
|
CbdClient(requests_per_second=1000, retries=2).request_json("Example")
|
||||||
|
|
||||||
|
self.assertIn(((7.0,), {}), [(call.args, call.kwargs) for call in sleep.call_args_list])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# Статус проекта
|
|
||||||
|
|
||||||
Последняя проверка: 2026-07-31
|
|
||||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
|
||||||
|
|
||||||
## Краткий итог
|
|
||||||
|
|
||||||
Telegram-инфраструктура подготовлена для начала разработки бота. Супергруппа и бот существуют, бот добавлен в группу, Privacy Mode отключён, темы созданы и проверены через Telegram Bot API. Исходный код бота пока не создан.
|
|
||||||
|
|
||||||
Ближайшая цель — реализовать минимальный архиватор сообщений с сохранением метаданных и Markdown-экспортом обсуждений.
|
|
||||||
|
|
||||||
## Уже сделано
|
|
||||||
|
|
||||||
### Telegram
|
|
||||||
|
|
||||||
- Создана супергруппа `Акылдаш`.
|
|
||||||
- Создан бот `help_clerk_bot`.
|
|
||||||
- Бот добавлен в супергруппу с необходимыми правами.
|
|
||||||
- Privacy Mode бота отключён.
|
|
||||||
- Созданы и проверены рабочие темы.
|
|
||||||
- Проверено подключение к Telegram Bot API.
|
|
||||||
- Webhook у бота не установлен; доступен режим long polling через `getUpdates`.
|
|
||||||
|
|
||||||
### ID тем
|
|
||||||
|
|
||||||
| Тема | `message_thread_id` | Состояние |
|
|
||||||
|---|---:|---|
|
|
||||||
| Общее | отсутствует | стандартная тема |
|
|
||||||
| MVP | `2` | подтверждено тестовым сообщением |
|
|
||||||
| Решения | `4` | подтверждено тестовым сообщением |
|
|
||||||
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
|
||||||
| Работа с ИИ | `8` | подтверждено тестовым сообщением |
|
|
||||||
|
|
||||||
### Документация и правила
|
|
||||||
|
|
||||||
- Подготовлен общий план реализации в `TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md`.
|
|
||||||
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-mvp-boundaries-and-rules.md`.
|
|
||||||
- В MVP не входит автоматический вызов API языковой модели.
|
|
||||||
- Анализ экспортов выполняется вручную в обычном ChatGPT.
|
|
||||||
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
|
|
||||||
|
|
||||||
### Доступы
|
|
||||||
|
|
||||||
- Локальные доступы находятся в `credentials.txt`.
|
|
||||||
- `credentials.txt` добавлен в `.gitignore`.
|
|
||||||
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
|
||||||
|
|
||||||
## Пока не сделано
|
|
||||||
|
|
||||||
### Репозиторий и приложение
|
|
||||||
|
|
||||||
- Не создан исходный код бота.
|
|
||||||
- Не определена и не зафиксирована структура Python-проекта.
|
|
||||||
- Локальный каталог `.git` пуст; локальный Git-репозиторий ещё нужно корректно инициализировать или привязать к удалённому репозиторию Gitea.
|
|
||||||
- Не настроены Docker-файлы для Synology.
|
|
||||||
- Не настроен CI/CD.
|
|
||||||
|
|
||||||
### Хранилище
|
|
||||||
|
|
||||||
- Не подключена база данных.
|
|
||||||
- Не утверждена окончательная схема PostgreSQL.
|
|
||||||
- Не определены сроки хранения сообщений, вложений и экспортов.
|
|
||||||
- Не определён согласованный список пользователей с доступом к архиву.
|
|
||||||
|
|
||||||
### Функции бота
|
|
||||||
|
|
||||||
- Приём и сохранение сообщений.
|
|
||||||
- Сохранение автора, даты, темы, ID сообщения и reply-связи.
|
|
||||||
- Сохранение доступных вложений.
|
|
||||||
- Ручная пометка сообщений для экспорта.
|
|
||||||
- Экспорт темы или диапазона в Markdown.
|
|
||||||
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
|
||||||
- Возврат отчёта пользователя в Telegram.
|
|
||||||
- Команды `/help`, `/status` и команда экспорта.
|
|
||||||
|
|
||||||
### Группа и рабочий процесс
|
|
||||||
|
|
||||||
- Не подготовлены закреплённые сообщения с правилами тем.
|
|
||||||
- Не проверены сценарии закрытия темы `MVP` после фиксации состава первой версии.
|
|
||||||
- Не настроены уведомления Gitea.
|
|
||||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
|
||||||
|
|
||||||
## Важные неизвестные
|
|
||||||
|
|
||||||
Перед реализацией постоянного развёртывания нужно получить или принять решения по следующим вопросам:
|
|
||||||
|
|
||||||
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
|
||||||
2. Имя и URL удалённого репозитория Gitea.
|
|
||||||
3. Список пользователей Telegram, которым разрешён экспорт и работа с архивом.
|
|
||||||
4. Правила хранения и удаления сообщений и вложений.
|
|
||||||
5. Способ запуска бота: long polling на старте или webhook после развёртывания.
|
|
||||||
6. Нужен ли SQLite для локальной разработки как временное хранилище до подключения PostgreSQL.
|
|
||||||
|
|
||||||
## Следующий этап
|
|
||||||
|
|
||||||
### Stage 2 — бот-архиватор
|
|
||||||
|
|
||||||
Рекомендуемый порядок:
|
|
||||||
|
|
||||||
1. Создать каркас Python-приложения и конфигурацию через переменные окружения.
|
|
||||||
2. Добавить фильтр группы `-1004242041275` и разрешённых тем `2`, `4`, `6`, `8`.
|
|
||||||
3. Реализовать приём сообщений и сохранение метаданных.
|
|
||||||
4. Добавить минимальную схему хранилища.
|
|
||||||
5. Реализовать ручную отметку сообщений.
|
|
||||||
6. Реализовать Markdown-экспорт с контекстом, авторами, датами, reply-связями и вложениями.
|
|
||||||
7. Добавить команды справки и состояния.
|
|
||||||
8. Проверить работу на реальном обсуждении и зафиксировать результат в этом документе.
|
|
||||||
|
|
||||||
## История изменений статуса
|
|
||||||
|
|
||||||
### 2026-07-31
|
|
||||||
|
|
||||||
- Создана супергруппа и добавлен бот.
|
|
||||||
- Отключён Privacy Mode.
|
|
||||||
- Определены ID тем: `2`, `4`, `6`, `8`.
|
|
||||||
- Подтверждено отсутствие webhook.
|
|
||||||
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
|
||||||
|
|
||||||
32
docs/README.md
Normal file
32
docs/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Документация Акылдаш
|
||||||
|
|
||||||
|
## Продукт
|
||||||
|
|
||||||
|
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
|
||||||
|
правила работы с данными.
|
||||||
|
|
||||||
|
## Решения
|
||||||
|
|
||||||
|
- [Решение 001](decisions/001-telegram-workspace-mvp.md) — границы MVP
|
||||||
|
Telegram-инфраструктуры.
|
||||||
|
|
||||||
|
## Эксплуатация и рабочий процесс
|
||||||
|
|
||||||
|
- [Текущий статус](operations/project-status.md) — выполненные шаги, риски и
|
||||||
|
ближайшие действия.
|
||||||
|
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
|
||||||
|
роли и этапы развития командного окружения.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- [Выгрузка ЦБД Минюста КР](../backend/README.md) — запуск, хранение и проверка
|
||||||
|
загрузчика правовых документов.
|
||||||
|
|
||||||
|
## Команда
|
||||||
|
|
||||||
|
- [Навыки работы с ИИ](team/ai-skills-for-beginners.md) — короткие практики для
|
||||||
|
начинающих.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
18
docs/decisions/001-mvp-boundaries-and-rules.md → docs/decisions/001-telegram-workspace-mvp.md
Executable file → Normal file
18
docs/decisions/001-mvp-boundaries-and-rules.md → docs/decisions/001-telegram-workspace-mvp.md
Executable file → Normal file
@@ -11,7 +11,7 @@ Telegram не является хранилищем проекта, а бот и
|
|||||||
|
|
||||||
## Что входит в MVP
|
## Что входит в MVP
|
||||||
|
|
||||||
- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`.
|
- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение`, `Работа с ИИ` и `Отчеты`.
|
||||||
- Тема `MVP` используется только для обсуждения границ первой версии. После фиксации MVP тема закрывается.
|
- Тема `MVP` используется только для обсуждения границ первой версии. После фиксации MVP тема закрывается.
|
||||||
- Тема `Обсуждение` используется для рабочих гипотез и вопросов, которые ещё не стали подтверждёнными решениями.
|
- Тема `Обсуждение` используется для рабочих гипотез и вопросов, которые ещё не стали подтверждёнными решениями.
|
||||||
- В теме `Решения` бот публикует итоговые сводки и подтверждённые решения после завершения обсуждения. Сообщения бота являются единственным редактируемым источником итогового текста; пользователи не редактируют сообщения бота.
|
- В теме `Решения` бот публикует итоговые сводки и подтверждённые решения после завершения обсуждения. Сообщения бота являются единственным редактируемым источником итогового текста; пользователи не редактируют сообщения бота.
|
||||||
@@ -20,6 +20,7 @@ Telegram не является хранилищем проекта, а бот и
|
|||||||
- Для сообщения сохраняются текст, автор, дата и время, тема, идентификатор сообщения и связь с ответом.
|
- Для сообщения сохраняются текст, автор, дата и время, тема, идентификатор сообщения и связь с ответом.
|
||||||
- Экспорт выполняется по ручной отметке участника или по явно заданному диапазону темы.
|
- Экспорт выполняется по ручной отметке участника или по явно заданному диапазону темы.
|
||||||
- Экспорт формируется в Markdown и включает контекст, сообщения по времени, ссылки и доступные вложения.
|
- Экспорт формируется в Markdown и включает контекст, сообщения по времени, ссылки и доступные вложения.
|
||||||
|
- Markdown публикуется в закрытой для сообщений участников теме `Отчеты`. В исходной теме бот оставляет заметную отсечку, прямую ссылку и общий с отчётом уникальный хэштег.
|
||||||
- Участник вручную загружает экспорт в ChatGPT и возвращает результат в Telegram.
|
- Участник вручную загружает экспорт в ChatGPT и возвращает результат в Telegram.
|
||||||
- Gitea-уведомления на старте можно публиковать в `Общее` либо в отдельную тему после появления такой потребности. События ограничиваются PR, задачами, дедлайнами и сбоями важных проверок.
|
- Gitea-уведомления на старте можно публиковать в `Общее` либо в отдельную тему после появления такой потребности. События ограничиваются PR, задачами, дедлайнами и сбоями важных проверок.
|
||||||
- Подтверждённые решения и изменения документации фиксируются в Git/Gitea обычным review-процессом.
|
- Подтверждённые решения и изменения документации фиксируются в Git/Gitea обычным review-процессом.
|
||||||
@@ -46,15 +47,16 @@ Telegram не является хранилищем проекта, а бот и
|
|||||||
- Используются темы `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`; тема `Общее` остаётся стандартной.
|
- Используются темы `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`; тема `Общее` остаётся стандартной.
|
||||||
- `MVP` закрывается после фиксации состава первой версии.
|
- `MVP` закрывается после фиксации состава первой версии.
|
||||||
- Итог обсуждения формирует бот и публикует в `Решения`.
|
- Итог обсуждения формирует бот и публикует в `Решения`.
|
||||||
|
- Файлы экспортов публикуются в теме `Отчеты`; исходные темы не засоряются файлами.
|
||||||
- `Работа с ИИ` предназначена только для публикаций бота и будет содержать best practice, а не обычный FAQ.
|
- `Работа с ИИ` предназначена только для публикаций бота и будет содержать best practice, а не обычный FAQ.
|
||||||
- Остальные параметры первой версии из этого документа считаются утверждёнными, если команда не изменит их отдельным решением.
|
- Остальные параметры первой версии из этого документа считаются утверждёнными, если команда не изменит их отдельным решением.
|
||||||
|
|
||||||
## Граница между ботом и ИИ
|
## Граница между ботом и ИИ
|
||||||
|
|
||||||
- Бот без обращения к API языковой модели полностью выполняет сбор сообщений, сортировку, добавление метаданных, подготовку вложений и формирование Markdown-экспорта.
|
- Бот без обращения к API языковой модели полностью выполняет сбор сообщений, сортировку, добавление метаданных, подготовку вложений и формирование Markdown-экспорта.
|
||||||
- Бот добавляет к экспорту готовую инструкцию для ручной загрузки в обычный ChatGPT.
|
- На следующем этапе бот должен добавлять к экспорту готовую инструкцию для ручной загрузки в обычный ChatGPT.
|
||||||
- Выделение решений, задач, рисков, противоречий и открытых вопросов выполняется в ChatGPT вручную через подписку участника.
|
- Выделение решений, задач, рисков, противоречий и открытых вопросов выполняется в ChatGPT вручную через подписку участника.
|
||||||
- Бот может принять возвращённый человеком отчёт, опубликовать его в Telegram и связать с исходным обсуждением.
|
- На следующем этапе бот должен принимать возвращённый человеком отчёт, публиковать его в Telegram и связывать с исходным обсуждением.
|
||||||
- Бот не выдаёт эвристический или шаблонный результат за выполненный ИИ-анализ. Поиск ключевых слов и ручные метки допустимы только как вспомогательная навигация.
|
- Бот не выдаёт эвристический или шаблонный результат за выполненный ИИ-анализ. Поиск ключевых слов и ручные метки допустимы только как вспомогательная навигация.
|
||||||
|
|
||||||
## Техническое замечание о правах тем
|
## Техническое замечание о правах тем
|
||||||
@@ -63,13 +65,17 @@ Telegram позволяет запретить пользователям отп
|
|||||||
|
|
||||||
## Инфраструктурные предпосылки
|
## Инфраструктурные предпосылки
|
||||||
|
|
||||||
- Доступы для настройки находятся в `credentials.txt`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
|
- Доступы для настройки находятся в локальном `credentials.json`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
|
||||||
- Для проекта можно создать открытый репозиторий в Gitea.
|
- Для проекта можно создать открытый репозиторий в Gitea.
|
||||||
- В Gitea необходимо настроить защиту `main`: прямые push запрещены, изменения проходят через pull request и проверки CI/CD.
|
- В Gitea необходимо настроить защиту `main`: прямые push запрещены, изменения проходят через pull request и проверки CI/CD.
|
||||||
- Развёртывание бота планируется на Docker-инфраструктуре Synology.
|
- Бот развёрнут в Container Manager на Synology с постоянным SQLite-томом и политикой автоперезапуска; конфигурацию развёртывания ещё предстоит зафиксировать в репозитории.
|
||||||
- PostgreSQL будет использоваться как хранилище бота; параметры доступа к базе будут предоставлены отдельно.
|
- PostgreSQL будет использоваться как хранилище бота; параметры доступа к базе будут предоставлены отдельно.
|
||||||
- Почта может использоваться как дополнительный канал доставки файлов и отчётов, но не как основное хранилище.
|
- Почта может использоваться как дополнительный канал доставки файлов и отчётов, но не как основное хранилище.
|
||||||
|
|
||||||
## Критерий завершения Stage 0
|
## Критерий завершения Stage 0
|
||||||
|
|
||||||
Границы MVP, состав тем и правила работы с данными подтверждены. Остаётся реализовать жизненный цикл тем и экспортов, после чего можно проектировать схему хранения и команды бота, не меняя назначение первой версии по ходу реализации.
|
Критерий выполнен: границы MVP зафиксированы, темы созданы, первая версия бота и экспортов реализована. Полный рабочий цикл будет принят командой после проверки на реальном обсуждении.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
181
docs/operations/project-status.md
Normal file
181
docs/operations/project-status.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Статус проекта
|
||||||
|
|
||||||
|
Последняя проверка: 2026-08-06
|
||||||
|
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||||
|
|
||||||
|
- Telegram-бот: `0.2.2`
|
||||||
|
- Telegram-бот на Synology: `0.2.1`
|
||||||
|
- Backend: `0.1.2`
|
||||||
|
- Frontend: не создан
|
||||||
|
|
||||||
|
## Краткий итог
|
||||||
|
|
||||||
|
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Создана первая backend-функция: возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР.
|
||||||
|
|
||||||
|
Ближайшая цель — расширить пилотную выборку ЦБД, затем добавить инкрементальную проверку sitemap при создании backend-планировщика.
|
||||||
|
|
||||||
|
## Уже сделано
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
|
||||||
|
- Создана супергруппа `Акылдаш`.
|
||||||
|
- Создан бот `help_clerk_bot`.
|
||||||
|
- Бот добавлен в супергруппу с необходимыми правами.
|
||||||
|
- Privacy Mode бота отключён.
|
||||||
|
- Созданы и проверены рабочие темы.
|
||||||
|
- Проверено подключение к Telegram Bot API.
|
||||||
|
- Webhook у бота не установлен; доступен режим long polling через `getUpdates`.
|
||||||
|
|
||||||
|
### ID тем
|
||||||
|
|
||||||
|
| Тема | `message_thread_id` | Состояние |
|
||||||
|
|---|---:|---|
|
||||||
|
| Общее | отсутствует | приветствие закреплено, тема закрыта |
|
||||||
|
| MVP | `2` | подтверждено тестовым сообщением |
|
||||||
|
| Решения | `4` | подтверждено тестовым сообщением |
|
||||||
|
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
||||||
|
| Работа с ИИ | `8` | материалы опубликованы, тема закрыта |
|
||||||
|
| Отчеты | `37` | экспорт проверен, тема закрыта |
|
||||||
|
|
||||||
|
### Документация и правила
|
||||||
|
|
||||||
|
- Подготовлен общий план реализации в `docs/operations/telegram-workspace-plan.md`.
|
||||||
|
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-telegram-workspace-mvp.md`.
|
||||||
|
- Добавлены обзор всей платформы и единый индекс документации.
|
||||||
|
- В MVP не входит автоматический вызов API языковой модели.
|
||||||
|
- Анализ экспортов выполняется вручную в обычном ChatGPT.
|
||||||
|
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
|
||||||
|
- Подготовлены и опубликованы краткие навыки работы с ИИ для начинающих.
|
||||||
|
- В `Общее` опубликовано и закреплено приветствие с назначением тем и описанием бота.
|
||||||
|
|
||||||
|
### Доступы
|
||||||
|
|
||||||
|
- Локальные доступы находятся в `credentials.json`.
|
||||||
|
- `credentials.json` добавлен в `.gitignore`.
|
||||||
|
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
||||||
|
|
||||||
|
### Репозиторий и приложение
|
||||||
|
|
||||||
|
- Реализован бот-секретарь версии `0.2.2` без внешних Python-зависимостей.
|
||||||
|
- Код бота выделен из корня репозитория в `tools/telegram-bot`.
|
||||||
|
- Сообщения и полные Telegram-метаданные сохраняются в SQLite.
|
||||||
|
- Добавлены команды `/help`, `/status` и `/export`.
|
||||||
|
- Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`.
|
||||||
|
- Первый `/export` охватывает всю сохранённую тему, последующие начинаются после последней успешно созданной отсечки.
|
||||||
|
- Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`).
|
||||||
|
- Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта.
|
||||||
|
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
||||||
|
- Репозиторий организован как основа всего проекта, а не отдельного бота.
|
||||||
|
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
|
||||||
|
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.1.2` без внешних зависимостей.
|
||||||
|
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
|
||||||
|
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
|
||||||
|
|
||||||
|
### Развёртывание
|
||||||
|
|
||||||
|
- Бот постоянно запущен на Synology в контейнере `akyldash-bot`.
|
||||||
|
- Используется официальный образ `python:3.11-slim` и long polling.
|
||||||
|
- Контейнер работает без root, с read-only root filesystem и политикой `unless-stopped`.
|
||||||
|
- Доступ к Telegram Bot API идёт через отдельный закрытый прокси-контейнер без опубликованных наружу портов.
|
||||||
|
- Код, закрытый env-файл и SQLite хранятся в `/volume1/docker/akyldash`.
|
||||||
|
|
||||||
|
## Пока не сделано
|
||||||
|
|
||||||
|
### Развёртывание и сопровождение
|
||||||
|
|
||||||
|
- Docker-конфигурация развёртывания не хранится в репозитории.
|
||||||
|
- Не настроен CI/CD.
|
||||||
|
|
||||||
|
### Хранилище
|
||||||
|
|
||||||
|
- PostgreSQL не подключён; первая версия использует SQLite.
|
||||||
|
- Не утверждена окончательная схема PostgreSQL.
|
||||||
|
- Не определены сроки хранения сообщений, вложений и экспортов.
|
||||||
|
|
||||||
|
### Функции бота
|
||||||
|
|
||||||
|
- Скачивание файлов вложений; сейчас сохраняются их Telegram-метаданные.
|
||||||
|
- Ручная пометка отдельных сообщений для экспорта.
|
||||||
|
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
||||||
|
- Возврат отчёта пользователя в Telegram.
|
||||||
|
|
||||||
|
### Группа и рабочий процесс
|
||||||
|
|
||||||
|
- Не проверены сценарии закрытия темы `MVP` после фиксации состава первой версии.
|
||||||
|
- Не настроены уведомления Gitea.
|
||||||
|
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
||||||
|
|
||||||
|
## Важные неизвестные
|
||||||
|
|
||||||
|
По мере реального использования нужно принять решения по следующим вопросам:
|
||||||
|
|
||||||
|
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
||||||
|
2. Правила хранения и удаления сообщений и вложений.
|
||||||
|
3. Нужен ли webhook после постоянного развёртывания; первая версия работает через long polling.
|
||||||
|
|
||||||
|
## Следующий этап
|
||||||
|
|
||||||
|
### Фиксация MVP и проверка полного цикла
|
||||||
|
|
||||||
|
Рекомендуемый порядок:
|
||||||
|
|
||||||
|
1. Настроить резервное копирование `/volume1/docker/akyldash/data`.
|
||||||
|
2. Зафиксировать состав MVP с командой и закрыть тему `MVP`.
|
||||||
|
3. Провести одно реальное обсуждение с ответами, правками и вложением.
|
||||||
|
4. Экспортировать обсуждение и вручную проанализировать Markdown в ChatGPT.
|
||||||
|
5. Вернуть подтверждённый итог в тему `Решения` и зафиксировать его в Git/Gitea.
|
||||||
|
6. Записать обнаруженные неудобства и только после этого выбирать следующую доработку.
|
||||||
|
|
||||||
|
## История изменений статуса
|
||||||
|
|
||||||
|
### 2026-08-06
|
||||||
|
|
||||||
|
- Добавлен терминальный прогрессбар со скоростью и расчётным временем завершения.
|
||||||
|
- Обработка HTTP 429 учитывает рекомендованную сервером задержку `Retry-After`.
|
||||||
|
- Добавлено автоматическое восстановление после истечения серверного списка документов.
|
||||||
|
- Версия backend обновлена до `0.1.2`.
|
||||||
|
|
||||||
|
### 2026-08-05
|
||||||
|
|
||||||
|
- Создана область `backend/ingestion` для получения правовых источников.
|
||||||
|
- Реализована возобновляемая выгрузка ЦБД Минюста КР версии `0.1.0`.
|
||||||
|
- Проверены двуязычные редакции, изображения, SQLite-манифест и повторный запуск.
|
||||||
|
|
||||||
|
### 2026-08-03
|
||||||
|
|
||||||
|
- Репозиторий перестроен под весь проект юридической платформы; Telegram-бот перенесён в `tools/telegram-bot`.
|
||||||
|
- Созданы обзор продукта и структурированный индекс документации; будущие каталоги решено создавать по фактическим задачам.
|
||||||
|
- Закрытые юридические материалы решено хранить отдельно от основного репозитория.
|
||||||
|
- Версия Telegram-бота обновлена до `0.2.2` из-за изменения структуры запуска.
|
||||||
|
- Экспорт без параметров переведён с периода в семь дней на диапазон после предыдущей отсечки.
|
||||||
|
- Бот развёрнут в Container Manager на Synology; автозапуск проверен перезапуском.
|
||||||
|
- В теме `Работа с ИИ` опубликована первоначальная библиотека практик и отдельный материал о безопасной работе с Codex.
|
||||||
|
- В `Общее` опубликовано и закреплено приветствие; тема закрыта для сообщений.
|
||||||
|
- Локальный файл доступов переведён из `credentials.txt` в `credentials.json`.
|
||||||
|
|
||||||
|
### 2026-08-02
|
||||||
|
|
||||||
|
- Экспорт ограничен Telegram ID владельца бота.
|
||||||
|
- Технические ответы на корневое сообщение темы исключены из Markdown.
|
||||||
|
- Экспорт перенесён в тему `Отчеты`, добавлены отсечки и навигационные хэштеги.
|
||||||
|
- Проверена живая публикация отчёта и отсечки через Telegram Bot API.
|
||||||
|
- Бот версии `0.2.0` развёрнут в Container Manager на Synology.
|
||||||
|
- Версия приложения обновлена до `0.2.0`.
|
||||||
|
|
||||||
|
### 2026-08-01
|
||||||
|
|
||||||
|
- Восстановлена связь локального каталога с репозиторием Gitea.
|
||||||
|
- Реализован бот-секретарь версии `0.1.0` с SQLite и Markdown-экспортом.
|
||||||
|
- Проверены токен `help_clerk_bot` и доступ к супергруппе `Акылдаш`.
|
||||||
|
|
||||||
|
### 2026-07-31
|
||||||
|
|
||||||
|
- Создана супергруппа и добавлен бот.
|
||||||
|
- Отключён Privacy Mode.
|
||||||
|
- Определены ID тем: `2`, `4`, `6`, `8`.
|
||||||
|
- Подтверждено отсутствие webhook.
|
||||||
|
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
68
TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md → docs/operations/telegram-workspace-plan.md
Executable file → Normal file
68
TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md → docs/operations/telegram-workspace-plan.md
Executable file → Normal file
@@ -276,57 +276,59 @@ docs/
|
|||||||
|
|
||||||
## 9. Этапы реализации
|
## 9. Этапы реализации
|
||||||
|
|
||||||
|
Обозначения: `[x]` — выполнено, `[ ]` — ещё не выполнено.
|
||||||
|
|
||||||
### Этап 0. Границы и правила
|
### Этап 0. Границы и правила
|
||||||
|
|
||||||
- утвердить назначение Telegram, Git, бота и ChatGPT;
|
- [x] утвердить назначение Telegram, Git, бота и ChatGPT;
|
||||||
- выбрать темы, которые бот слушает;
|
- [x] выбрать темы, которые бот слушает;
|
||||||
- определить, что можно собирать автоматически;
|
- [x] определить, что можно собирать автоматически;
|
||||||
- определить правила обработки персональных и конфиденциальных данных;
|
- [x] определить правила обработки персональных и конфиденциальных данных;
|
||||||
- определить формат решений, задач и экспортов.
|
- [x] определить формат решений, задач и экспортов.
|
||||||
|
|
||||||
### Этап 1. Рабочая Telegram-группа
|
### Этап 1. Рабочая Telegram-группа
|
||||||
|
|
||||||
- создать супергруппу и темы;
|
- [x] создать супергруппу и темы;
|
||||||
- подготовить закреплённые сообщения;
|
- [x] подготовить и закрепить приветствие с навигацией;
|
||||||
- создать первоначальный FAQ;
|
- [x] создать первоначальную библиотеку практик работы с ИИ;
|
||||||
- согласовать правила обсуждений и фиксации решений.
|
- [x] согласовать правила обсуждений и фиксации решений.
|
||||||
|
|
||||||
### Этап 2. Бот-архиватор
|
### Этап 2. Бот-архиватор
|
||||||
|
|
||||||
- реализовать приём сообщений;
|
- [x] реализовать приём сообщений;
|
||||||
- сохранить метаданные и вложения;
|
- [x] сохранять сообщения и метаданные вложений;
|
||||||
- добавить ручную отметку для экспорта;
|
- [ ] добавить ручную отметку отдельных сообщений для экспорта;
|
||||||
- сформировать Markdown-экспорт;
|
- [x] сформировать Markdown-экспорт;
|
||||||
- проверить работу с темами и ответами.
|
- [x] проверить работу с темами и ответами.
|
||||||
|
|
||||||
### Этап 3. Уведомления
|
### Этап 3. Уведомления
|
||||||
|
|
||||||
- подключить события Gitea;
|
- [ ] подключить события Gitea;
|
||||||
- настроить фильтрацию;
|
- [ ] настроить фильтрацию;
|
||||||
- направлять уведомления в отдельную тему;
|
- [ ] направлять уведомления в отдельную тему;
|
||||||
- добавить напоминания о задачах и сроках.
|
- [ ] добавить напоминания о задачах и сроках.
|
||||||
|
|
||||||
### Этап 4. ИИ-подготовка без API
|
### Этап 4. ИИ-подготовка без API
|
||||||
|
|
||||||
- добавить шаблоны запросов для анализа;
|
- [ ] добавить шаблоны запросов для анализа;
|
||||||
- создавать готовые файлы для загрузки в ChatGPT;
|
- [x] создавать готовые файлы для загрузки в ChatGPT;
|
||||||
- добавить возврат отчёта в Telegram;
|
- [ ] добавить возврат отчёта в Telegram;
|
||||||
- проверить несколько реальных обсуждений.
|
- [ ] проверить несколько реальных обсуждений.
|
||||||
|
|
||||||
### Этап 5. Связка с Git/Gitea
|
### Этап 5. Связка с Git/Gitea
|
||||||
|
|
||||||
- подготовить структуру документации;
|
- [x] подготовить структуру документации;
|
||||||
- настроить ссылки на задачи, PR и документы;
|
- [ ] настроить ссылки на задачи, PR и документы;
|
||||||
- определить процесс переноса подтверждённых решений в Git;
|
- [x] определить процесс переноса подтверждённых решений в Git;
|
||||||
- добавить сценарии обновления документации через ИИ.
|
- [ ] добавить сценарии обновления документации через ИИ.
|
||||||
|
|
||||||
### Этап 6. Улучшение по фактическому использованию
|
### Этап 6. Улучшение по фактическому использованию
|
||||||
|
|
||||||
- удалить невостребованные функции;
|
- [ ] удалить невостребованные функции;
|
||||||
- расширить FAQ реальными сценариями команды;
|
- [ ] расширить библиотеку практик реальными сценариями команды;
|
||||||
- улучшить формат отчётов;
|
- [ ] улучшить формат отчётов;
|
||||||
- добавить новые уведомления только при наличии потребности;
|
- [ ] добавить новые уведомления только при наличии потребности;
|
||||||
- оценить необходимость API и автоматического ИИ-анализа.
|
- [ ] оценить необходимость API и автоматического ИИ-анализа.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -393,3 +395,7 @@ Git сохраняет актуальную версию
|
|||||||
```
|
```
|
||||||
|
|
||||||
Цель инфраструктуры — не заставить специалистов изучать больше технологий, а сделать технологии удобным продолжением их профессиональной работы.
|
Цель инфраструктуры — не заставить специалистов изучать больше технологий, а сделать технологии удобным продолжением их профессиональной работы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
47
docs/product/project-overview.md
Normal file
47
docs/product/project-overview.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Обзор проекта
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Акылдаш — юридическая информационно-аналитическая платформа. Она должна помочь
|
||||||
|
пользователю находить применимые нормы и материалы, понимать их актуальность и
|
||||||
|
получать ответ со ссылками на проверяемые первоисточники. Платформа не заменяет
|
||||||
|
профессиональное юридическое заключение.
|
||||||
|
|
||||||
|
## Основные области
|
||||||
|
|
||||||
|
1. Получение открытых или лицензированных законов, актов и иных правовых
|
||||||
|
источников.
|
||||||
|
2. Очистка, нормализация, связывание редакций и сохранение происхождения данных.
|
||||||
|
3. Хранение структурированных документов и поисковых индексов.
|
||||||
|
4. Формирование базы знаний и RAG с обязательными ссылками на источники.
|
||||||
|
5. Backend для доступа к данным и функциям платформы.
|
||||||
|
6. Frontend для поиска, анализа и работы с результатами.
|
||||||
|
7. Инструменты рабочего окружения команды, включая Telegram-бота.
|
||||||
|
|
||||||
|
Физическая структура каждой области появится вместе с её первой задачей. До
|
||||||
|
этого список служит картой продукта, а не обещанием заранее выбранной
|
||||||
|
архитектуры.
|
||||||
|
|
||||||
|
## Обязательные принципы
|
||||||
|
|
||||||
|
- Источник, дата получения, редакция и лицензия документа должны быть
|
||||||
|
прослеживаемыми.
|
||||||
|
- Ответ системы должен отделять найденный факт от вывода модели и ссылаться на
|
||||||
|
конкретный первоисточник.
|
||||||
|
- Актуальность правовых данных должна проверяться до использования в ответе.
|
||||||
|
- Существенные юридические выводы проверяет человек.
|
||||||
|
- Секреты и персональные данные не попадают в Git, логи и тестовые наборы.
|
||||||
|
- Данные загружаются только при наличии законного основания и с соблюдением
|
||||||
|
условий источника.
|
||||||
|
|
||||||
|
## Правовая защита проекта
|
||||||
|
|
||||||
|
Договоры, заявки, материалы об интеллектуальной собственности и другие
|
||||||
|
закрытые юридические документы следует хранить отдельно от исходного кода: в
|
||||||
|
закрытом репозитории или защищённом документном хранилище. Доступ выдаётся
|
||||||
|
поимённо и только тем, кому он нужен. В основном репозитории можно хранить
|
||||||
|
несекретный реестр документов, правила доступа и ссылки на место хранения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
183
docs/team/ai-skills-for-beginners.md
Normal file
183
docs/team/ai-skills-for-beginners.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Важные навыки работы с ИИ для начинающих
|
||||||
|
|
||||||
|
Каждый раздел ниже — отдельное готовое сообщение для темы `Работа с ИИ`.
|
||||||
|
Текст можно публиковать без заголовков этого файла: название, хэштеги и пояснение уже входят в сообщение.
|
||||||
|
|
||||||
|
## 1. Ставьте задачу через результат
|
||||||
|
|
||||||
|
#работа_с_ии #промпт #цель
|
||||||
|
|
||||||
|
Пишите не тему, а желаемый результат. Вместо «расскажи о договоре» — «составь краткий список рисков договора для руководителя». Чем яснее результат, тем меньше случайных ответов.
|
||||||
|
|
||||||
|
## 2. Давайте нужный контекст
|
||||||
|
|
||||||
|
#работа_с_ии #контекст #источники
|
||||||
|
|
||||||
|
Укажите, для кого готовится результат, что уже известно и какие файлы или сообщения использовать. Не заставляйте ИИ угадывать факты, которые можно дать сразу.
|
||||||
|
|
||||||
|
## 3. Называйте ограничения
|
||||||
|
|
||||||
|
#работа_с_ии #ограничения #точность
|
||||||
|
|
||||||
|
Сразу укажите важные рамки: объём, срок, язык, допустимые источники и что нельзя менять. Пример: «до одной страницы, только по приложенным документам, без вымышленных данных».
|
||||||
|
|
||||||
|
## 4. Задавайте формат ответа
|
||||||
|
|
||||||
|
#работа_с_ии #формат #результат
|
||||||
|
|
||||||
|
Скажите, в каком виде нужен ответ: список, таблица, письмо, план или Markdown. Укажите желаемую длину и аудиторию. Это полезнее просьбы «ответь красиво».
|
||||||
|
|
||||||
|
## 5. Определяйте, что значит «готово»
|
||||||
|
|
||||||
|
#работа_с_ии #критерии #готовность
|
||||||
|
|
||||||
|
Добавьте проверяемый критерий завершения: «все риски связаны с пунктами договора», «у каждой задачи есть ответственный и срок», «тесты проходят». ИИ должен понимать финиш задачи.
|
||||||
|
|
||||||
|
## 6. Показывайте пример хорошего результата
|
||||||
|
|
||||||
|
#работа_с_ии #пример #качество
|
||||||
|
|
||||||
|
Если важны стиль или структура, приложите короткий образец. Один хороший пример обычно работает лучше длинного описания вроде «сделай профессионально».
|
||||||
|
|
||||||
|
## 7. Просите уточнить неясное
|
||||||
|
|
||||||
|
#работа_с_ии #уточнение #вопросы
|
||||||
|
|
||||||
|
Если задача допускает разные трактовки, напишите: «Сначала задай до трёх важных вопросов, затем приступай». Это снижает риск получить уверенный ответ не на тот вопрос.
|
||||||
|
|
||||||
|
## 8. Отделяйте факты от предположений
|
||||||
|
|
||||||
|
#работа_с_ии #факты #проверка
|
||||||
|
|
||||||
|
Просите отдельно отмечать подтверждённые факты, выводы и неизвестные данные. Для актуальной информации требуйте ссылки и дату проверки. Уверенный тон не является доказательством.
|
||||||
|
|
||||||
|
## 9. Для сложной задачи сначала просите план
|
||||||
|
|
||||||
|
#работа_с_ии #план #сложная_задача
|
||||||
|
|
||||||
|
Сначала попросите изучить материалы и предложить короткий план. После вашего подтверждения ИИ выполняет работу. Так ошибки направления обнаруживаются до больших изменений.
|
||||||
|
|
||||||
|
## 10. Делите большую работу на этапы
|
||||||
|
|
||||||
|
#работа_с_ии #этапы #контроль
|
||||||
|
|
||||||
|
Разбивайте задачу на небольшие проверяемые результаты: анализ → проект → проверка → финал. Подтверждайте важные этапы отдельно, особенно перед публикацией или изменением данных.
|
||||||
|
|
||||||
|
## 11. Используйте персональные инструкции
|
||||||
|
|
||||||
|
#работа_с_ии #персональные_инструкции #настройка
|
||||||
|
|
||||||
|
Персональные инструкции — ваши постоянные предпочтения для всех чатов: язык, стиль, желаемая краткость и способ объяснения. Не храните там пароли и правила одного конкретного проекта.
|
||||||
|
|
||||||
|
## 12. Настраивайте инструкции проекта
|
||||||
|
|
||||||
|
#работа_с_ии #инструкции_проекта #проект
|
||||||
|
|
||||||
|
Для каждого проекта отдельно зафиксируйте цель, термины, источники истины, правила работы и критерии готовности. В ChatGPT используйте инструкции проекта, в Codex — репозиторный `AGENTS.md`.
|
||||||
|
|
||||||
|
## 13. Делайте инструкции короткими и проверяемыми
|
||||||
|
|
||||||
|
#работа_с_ии #инструкции #порядок
|
||||||
|
|
||||||
|
Пишите конкретно: «перед изменением создай ветку», а не «работай правильно». Удаляйте устаревшие и противоречивые правила. Добавляйте новое правило после повторяющейся ошибки, а не заранее.
|
||||||
|
|
||||||
|
## 14. Один чат — один понятный результат
|
||||||
|
|
||||||
|
#работа_с_ии #чат #контекст
|
||||||
|
|
||||||
|
В одном чате держите одну связанную цель. Для отдельной задачи начинайте новый чат; для настоящего ответвления создавайте копию или fork. Перегруженный контекст ухудшает качество.
|
||||||
|
|
||||||
|
## 15. Храните решения вне переписки
|
||||||
|
|
||||||
|
#работа_с_ии #решения #источник_истины
|
||||||
|
|
||||||
|
Чат помогает думать, но не должен быть единственным хранилищем решения. Подтверждённый результат переносите в документ, задачу или Git, где видны актуальная версия и история изменений.
|
||||||
|
|
||||||
|
## 16. Понимайте назначение агентов
|
||||||
|
|
||||||
|
#работа_с_ии #агенты #делегирование
|
||||||
|
|
||||||
|
Агенты — отдельные исполнители для ограниченных частей большой задачи. Они полезны, когда несколько независимых исследований или проверок можно выполнить параллельно.
|
||||||
|
|
||||||
|
## 17. Давайте агенту одну ограниченную задачу
|
||||||
|
|
||||||
|
#работа_с_ии #агенты #постановка_задачи
|
||||||
|
|
||||||
|
Каждому агенту задайте одну цель, входные материалы, границы и формат результата. Хорошо: «проверь только риски безопасности и верни пять находок со ссылками».
|
||||||
|
|
||||||
|
## 18. Не используйте агентов без необходимости
|
||||||
|
|
||||||
|
#работа_с_ии #агенты #простота
|
||||||
|
|
||||||
|
Для маленькой или строго последовательной задачи один исполнитель лучше. Несколько агентов тратят больше ресурсов, могут дублировать работу и конфликтовать при одновременном изменении одних файлов.
|
||||||
|
|
||||||
|
## 19. Оставляйте итог главному исполнителю
|
||||||
|
|
||||||
|
#работа_с_ии #агенты #итог
|
||||||
|
|
||||||
|
Агенты возвращают краткие выводы, а главный исполнитель сравнивает их, устраняет противоречия и готовит единый ответ. Не склеивайте сырые результаты без общей проверки.
|
||||||
|
|
||||||
|
## 20. Заранее задавайте правило остановки
|
||||||
|
|
||||||
|
#работа_с_ии #зацикливание #стоп
|
||||||
|
|
||||||
|
Для сложной задачи напишите: «После двух неудачных попыток остановись, перечисли проверенное, назови блокер и предложи другой подход». Это не даёт ИИ бесконечно повторять одно решение.
|
||||||
|
|
||||||
|
## 21. После неудачи меняйте метод, а не формулировку
|
||||||
|
|
||||||
|
#работа_с_ии #ошибка #диагностика
|
||||||
|
|
||||||
|
Если подход не сработал, попросите назвать причину и собрать новые доказательства. Повтор той же команды другими словами редко помогает. Нужна новая гипотеза или дополнительный источник данных.
|
||||||
|
|
||||||
|
## 22. Очищайте перегруженный контекст
|
||||||
|
|
||||||
|
#работа_с_ии #контекст #перезапуск
|
||||||
|
|
||||||
|
Если ИИ путает старые решения, попросите кратко зафиксировать цель, факты, принятые решения и открытый вопрос. Затем продолжите из этой сводки в новом чате.
|
||||||
|
|
||||||
|
## 23. Всегда проверяйте результат
|
||||||
|
|
||||||
|
#работа_с_ии #проверка #качество
|
||||||
|
|
||||||
|
Просите ИИ выполнить самопроверку, но важные утверждения проверяйте сами по первичным источникам. Для кода нужны тесты, для расчётов — пересчёт, для документов — сверка цитат и реквизитов.
|
||||||
|
|
||||||
|
## 24. Не передавайте лишние данные
|
||||||
|
|
||||||
|
#работа_с_ии #приватность #персональные_данные
|
||||||
|
|
||||||
|
Перед загрузкой удалите лишние ФИО, контакты, пароли, ключи и конфиденциальные сведения. Передавайте минимальный объём данных, необходимый для задачи, и учитывайте правила вашей организации.
|
||||||
|
|
||||||
|
## 25. Отделяйте анализ от действия
|
||||||
|
|
||||||
|
#работа_с_ии #безопасность #подтверждение
|
||||||
|
|
||||||
|
Просьба «проанализируй» не должна означать «отправь, удали или опубликуй». Для внешних и необратимых действий требуйте отдельный план, предварительный просмотр и явное подтверждение человека.
|
||||||
|
|
||||||
|
## 26. Подключайте только нужные инструменты
|
||||||
|
|
||||||
|
#работа_с_ии #инструменты #доступ
|
||||||
|
|
||||||
|
Давайте ИИ доступ только к тем файлам, сервисам и правам, которые нужны сейчас. Начните с чтения; разрешение на запись, отправку и удаление выдавайте отдельно.
|
||||||
|
|
||||||
|
## 27. Превращайте повторяемую работу в шаблон
|
||||||
|
|
||||||
|
#работа_с_ии #шаблон #автоматизация
|
||||||
|
|
||||||
|
Если удачный запрос используется регулярно, сохраните его как шаблон. Когда процесс стал стабильным и проверенным, оформите его как навык или автоматизацию. Не автоматизируйте ещё не понятный процесс.
|
||||||
|
|
||||||
|
## 28. Завершайте работу короткой приёмкой
|
||||||
|
|
||||||
|
#работа_с_ии #приемка #финальная_проверка
|
||||||
|
|
||||||
|
В конце спросите: «Что сделано, что проверено, что осталось и какие есть риски?» Сравните ответ с первоначальной целью и критериями готовности, прежде чем принимать результат.
|
||||||
|
|
||||||
|
## Справочные материалы
|
||||||
|
|
||||||
|
- [Prompting](https://learn.chatgpt.com/docs/prompting)
|
||||||
|
- [Personalize ChatGPT](https://learn.chatgpt.com/docs/personalize)
|
||||||
|
- [Subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents)
|
||||||
|
- [Custom instructions with AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
8
tools/telegram-bot/.env.example
Normal file
8
tools/telegram-bot/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
TELEGRAM_BOT_TOKEN=replace-me
|
||||||
|
TELEGRAM_CHAT_ID=-1004242041275
|
||||||
|
TELEGRAM_OWNER_ID=87262245
|
||||||
|
TELEGRAM_REPORT_THREAD_ID=37
|
||||||
|
TELEGRAM_ALLOWED_THREAD_IDS=0,2,4,6,8
|
||||||
|
BOT_DATABASE=data/secretary.sqlite3
|
||||||
|
APP_TIMEZONE=Asia/Bishkek
|
||||||
|
LOG_LEVEL=INFO
|
||||||
51
tools/telegram-bot/README.md
Normal file
51
tools/telegram-bot/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Telegram-бот Акылдаш
|
||||||
|
|
||||||
|
Версия: `0.2.2`
|
||||||
|
|
||||||
|
Бот сохраняет сообщения разрешённых тем Telegram в локальную SQLite-базу и
|
||||||
|
выгружает обсуждения в Markdown. Внешние Python-зависимости не требуются.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Требуется Python 3.11 или новее.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/telegram-bot
|
||||||
|
export TELEGRAM_BOT_TOKEN='...'
|
||||||
|
export TELEGRAM_CHAT_ID='-1004242041275'
|
||||||
|
export TELEGRAM_OWNER_ID='87262245'
|
||||||
|
export TELEGRAM_REPORT_THREAD_ID='37'
|
||||||
|
export TELEGRAM_ALLOWED_THREAD_IDS='0,2,4,6,8'
|
||||||
|
python3 bot.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Доступные команды: `/help`, `/status`, `/export`. Экспорт доступен только
|
||||||
|
пользователю с Telegram ID из `TELEGRAM_OWNER_ID`. Первый `/export` выгружает
|
||||||
|
всю сохранённую тему, последующие — сообщения после предыдущей успешно
|
||||||
|
созданной отсечки.
|
||||||
|
|
||||||
|
Markdown публикуется в теме `TELEGRAM_REPORT_THREAD_ID`, а в исходной теме
|
||||||
|
остаётся отсечка со ссылкой и общим хэштегом отчёта.
|
||||||
|
|
||||||
|
База по умолчанию хранится в `data/secretary.sqlite3`. Сообщения из других
|
||||||
|
групп и тем не сохраняются. Полный ответ Telegram сохраняется в базе, поэтому
|
||||||
|
метаданные вложений остаются доступными для последующего скачивания.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Synology
|
||||||
|
|
||||||
|
Контейнер `akyldash-bot` работает на образе `python:3.11-slim` через закрытый
|
||||||
|
прокси-контейнер, с политикой перезапуска `unless-stopped`. Постоянные данные
|
||||||
|
находятся в `/volume1/docker/akyldash/data`.
|
||||||
|
|
||||||
|
При обновлении развёртывания файл `tools/telegram-bot/bot.py` копируется в
|
||||||
|
каталог контейнера как `bot.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.1.2 · Frontend — не создан
|
||||||
458
tools/telegram-bot/bot.py
Executable file
458
tools/telegram-bot/bot.py
Executable file
@@ -0,0 +1,458 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal Telegram secretary: archive allowed topics and export them to Markdown."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
|
||||||
|
APP_NAME = "Акылдаш"
|
||||||
|
APP_VERSION = "0.2.2"
|
||||||
|
FOOTER = f"{APP_NAME} v{APP_VERSION}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
token: str
|
||||||
|
chat_id: int
|
||||||
|
owner_id: int
|
||||||
|
report_thread_id: int
|
||||||
|
thread_ids: frozenset[int]
|
||||||
|
database: Path
|
||||||
|
timezone: ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||||||
|
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "").strip()
|
||||||
|
owner_id = os.environ.get("TELEGRAM_OWNER_ID", "").strip()
|
||||||
|
report_thread_id = os.environ.get("TELEGRAM_REPORT_THREAD_ID", "").strip()
|
||||||
|
if not token or not chat_id or not owner_id or not report_thread_id:
|
||||||
|
raise SystemExit(
|
||||||
|
"Set TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_OWNER_ID "
|
||||||
|
"and TELEGRAM_REPORT_THREAD_ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
threads = frozenset(
|
||||||
|
int(value.strip())
|
||||||
|
for value in os.environ.get(
|
||||||
|
"TELEGRAM_ALLOWED_THREAD_IDS", "0,2,4,6,8"
|
||||||
|
).split(",")
|
||||||
|
if value.strip()
|
||||||
|
)
|
||||||
|
return Config(
|
||||||
|
token=token,
|
||||||
|
chat_id=int(chat_id),
|
||||||
|
owner_id=int(owner_id),
|
||||||
|
report_thread_id=int(report_thread_id),
|
||||||
|
thread_ids=threads,
|
||||||
|
database=Path(os.environ.get("BOT_DATABASE", "data/secretary.sqlite3")),
|
||||||
|
timezone=ZoneInfo(os.environ.get("APP_TIMEZONE", "Asia/Bishkek")),
|
||||||
|
)
|
||||||
|
except (ValueError, ZoneInfoNotFoundError) as error:
|
||||||
|
raise SystemExit(f"Invalid configuration: {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def connect(database: Path) -> sqlite3.Connection:
|
||||||
|
database.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(database)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
chat_id INTEGER NOT NULL,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
thread_id INTEGER NOT NULL,
|
||||||
|
sent_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
update_id INTEGER NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (chat_id, message_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS messages_thread_date
|
||||||
|
ON messages (chat_id, thread_id, sent_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS state (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def archive_update(
|
||||||
|
connection: sqlite3.Connection, update: dict, config: Config
|
||||||
|
) -> dict | None:
|
||||||
|
update_id = int(update["update_id"])
|
||||||
|
message = update.get("message") or update.get("edited_message")
|
||||||
|
|
||||||
|
with connection:
|
||||||
|
if message:
|
||||||
|
chat_id = int(message["chat"]["id"])
|
||||||
|
thread_id = int(message.get("message_thread_id", 0))
|
||||||
|
if chat_id == config.chat_id and thread_id in config.thread_ids:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO messages (
|
||||||
|
chat_id, message_id, thread_id, sent_at, updated_at,
|
||||||
|
update_id, payload
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (chat_id, message_id) DO UPDATE SET
|
||||||
|
thread_id = excluded.thread_id,
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
update_id = excluded.update_id,
|
||||||
|
payload = excluded.payload
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
chat_id,
|
||||||
|
int(message["message_id"]),
|
||||||
|
thread_id,
|
||||||
|
int(message["date"]),
|
||||||
|
int(message.get("edit_date", message["date"])),
|
||||||
|
update_id,
|
||||||
|
json.dumps(message, ensure_ascii=False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = None
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO state (key, value) VALUES ('next_update_id', ?)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value
|
||||||
|
""",
|
||||||
|
(str(update_id + 1),),
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def next_update_id(connection: sqlite3.Connection) -> int:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT value FROM state WHERE key = 'next_update_id'"
|
||||||
|
).fetchone()
|
||||||
|
return int(row["value"]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def export_checkpoint(
|
||||||
|
connection: sqlite3.Connection, chat_id: int, thread_id: int
|
||||||
|
) -> int:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT value FROM state WHERE key = ?",
|
||||||
|
(f"export_checkpoint:{chat_id}:{thread_id}",),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["value"]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def save_export_checkpoint(
|
||||||
|
connection: sqlite3.Connection, chat_id: int, thread_id: int, message_id: int
|
||||||
|
) -> None:
|
||||||
|
with connection:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO state (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value
|
||||||
|
""",
|
||||||
|
(f"export_checkpoint:{chat_id}:{thread_id}", str(message_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_request(
|
||||||
|
config: Config, method: str, payload: dict, timeout: int = 65
|
||||||
|
) -> object:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"https://api.telegram.org/bot{config.token}/{method}",
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
result = json.load(response)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(result.get("description", "Telegram API error"))
|
||||||
|
return result["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def send_text(config: Config, message: dict, text: str) -> None:
|
||||||
|
payload = {
|
||||||
|
"chat_id": config.chat_id,
|
||||||
|
"text": f"{text}\n\n{FOOTER}",
|
||||||
|
"reply_parameters": {"message_id": message["message_id"]},
|
||||||
|
}
|
||||||
|
if message.get("message_thread_id"):
|
||||||
|
payload["message_thread_id"] = message["message_thread_id"]
|
||||||
|
telegram_request(config, "sendMessage", payload)
|
||||||
|
|
||||||
|
|
||||||
|
def send_document(
|
||||||
|
config: Config, thread_id: int, filename: str, content: bytes, caption: str
|
||||||
|
) -> dict:
|
||||||
|
boundary = uuid.uuid4().hex
|
||||||
|
fields = {
|
||||||
|
"chat_id": str(config.chat_id),
|
||||||
|
"message_thread_id": str(thread_id),
|
||||||
|
"caption": caption,
|
||||||
|
}
|
||||||
|
|
||||||
|
body = bytearray()
|
||||||
|
for name, value in fields.items():
|
||||||
|
body.extend(
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
|
||||||
|
f"\r\n\r\n{value}\r\n".encode()
|
||||||
|
)
|
||||||
|
body.extend(
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"document\"; "
|
||||||
|
f"filename=\"{filename}\"\r\nContent-Type: text/markdown; charset=utf-8"
|
||||||
|
f"\r\n\r\n".encode()
|
||||||
|
)
|
||||||
|
body.extend(content)
|
||||||
|
body.extend(f"\r\n--{boundary}--\r\n".encode())
|
||||||
|
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"https://api.telegram.org/bot{config.token}/sendDocument",
|
||||||
|
data=bytes(body),
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=65) as response:
|
||||||
|
result = json.load(response)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise RuntimeError(result.get("description", "Telegram API error"))
|
||||||
|
return result["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def author(message: dict) -> str:
|
||||||
|
sender = message.get("from", {})
|
||||||
|
name = " ".join(
|
||||||
|
part for part in (sender.get("first_name"), sender.get("last_name")) if part
|
||||||
|
)
|
||||||
|
username = sender.get("username")
|
||||||
|
if name and username:
|
||||||
|
return f"{name} (@{username})"
|
||||||
|
return name or (f"@{username}" if username else "Неизвестный участник")
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_descriptions(message: dict) -> list[str]:
|
||||||
|
descriptions: list[str] = []
|
||||||
|
if message.get("photo"):
|
||||||
|
descriptions.append("фотография")
|
||||||
|
for field, label in (
|
||||||
|
("document", "документ"),
|
||||||
|
("video", "видео"),
|
||||||
|
("audio", "аудио"),
|
||||||
|
("voice", "голосовое сообщение"),
|
||||||
|
("animation", "анимация"),
|
||||||
|
("sticker", "стикер"),
|
||||||
|
):
|
||||||
|
attachment = message.get(field)
|
||||||
|
if attachment:
|
||||||
|
name = attachment.get("file_name") or attachment.get("emoji")
|
||||||
|
descriptions.append(f"{label}: {name}" if name else label)
|
||||||
|
return descriptions
|
||||||
|
|
||||||
|
|
||||||
|
def message_link(chat_id: int, message_id: int) -> str:
|
||||||
|
return f"https://t.me/c/{str(chat_id).removeprefix('-100')}/{message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def export_markdown(
|
||||||
|
connection: sqlite3.Connection,
|
||||||
|
config: Config,
|
||||||
|
thread_id: int,
|
||||||
|
days: int | None,
|
||||||
|
now: int | None = None,
|
||||||
|
after_message_id: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
parameters: list[int] = [config.chat_id, thread_id]
|
||||||
|
condition = ""
|
||||||
|
if after_message_id is not None:
|
||||||
|
condition = " AND message_id > ?"
|
||||||
|
parameters.append(after_message_id)
|
||||||
|
elif days is not None:
|
||||||
|
condition = " AND sent_at >= ?"
|
||||||
|
parameters.append((now or int(time.time())) - days * 86400)
|
||||||
|
rows = connection.execute(
|
||||||
|
f"""
|
||||||
|
SELECT message_id, sent_at, payload FROM messages
|
||||||
|
WHERE chat_id = ? AND thread_id = ?{condition}
|
||||||
|
ORDER BY sent_at, message_id
|
||||||
|
""",
|
||||||
|
parameters,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if after_message_id is not None:
|
||||||
|
period = (
|
||||||
|
"с начала архива"
|
||||||
|
if after_message_id == 0
|
||||||
|
else "после предыдущей отсечки"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
period = "за всё время" if days is None else f"за последние {days} дн."
|
||||||
|
lines = [
|
||||||
|
"# Обсуждение",
|
||||||
|
"",
|
||||||
|
f"Тема: `{thread_id or 'Общее'}` ",
|
||||||
|
f"Период: {period} ",
|
||||||
|
f"Экспортировано: {datetime.now(config.timezone):%Y-%m-%d %H:%M %Z}",
|
||||||
|
"",
|
||||||
|
"## Сообщения",
|
||||||
|
]
|
||||||
|
for row in rows:
|
||||||
|
message = json.loads(row["payload"])
|
||||||
|
text = message.get("text") or message.get("caption") or "[Служебное событие Telegram]"
|
||||||
|
if text.split(maxsplit=1)[0].split("@", 1)[0] in {"/help", "/status", "/export"}:
|
||||||
|
continue
|
||||||
|
sent_at = datetime.fromtimestamp(row["sent_at"], timezone.utc).astimezone(
|
||||||
|
config.timezone
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
f"### {author(message)} — {sent_at:%Y-%m-%d %H:%M}",
|
||||||
|
"",
|
||||||
|
f"[Открыть сообщение]({message_link(config.chat_id, row['message_id'])})",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
reply = message.get("reply_to_message", {}).get("message_id")
|
||||||
|
if reply and reply != message.get("message_thread_id"):
|
||||||
|
lines.append(f"Ответ на сообщение: #{reply}")
|
||||||
|
lines.extend(["", text])
|
||||||
|
attachments = attachment_descriptions(message)
|
||||||
|
if attachments:
|
||||||
|
lines.extend(["", "Вложения:", *[f"- {item}" for item in attachments]])
|
||||||
|
|
||||||
|
lines.extend(["", "---", FOOTER, ""])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_export_days(parts: list[str]) -> int | None:
|
||||||
|
if len(parts) == 1:
|
||||||
|
return 7
|
||||||
|
if parts[1].lower() in {"all", "все"}:
|
||||||
|
return None
|
||||||
|
days = int(parts[1])
|
||||||
|
if not 1 <= days <= 3650:
|
||||||
|
raise ValueError
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def handle_command(
|
||||||
|
connection: sqlite3.Connection, config: Config, message: dict
|
||||||
|
) -> None:
|
||||||
|
text = message.get("text", "")
|
||||||
|
if not text.startswith("/"):
|
||||||
|
return
|
||||||
|
parts = text.split()
|
||||||
|
command = parts[0].split("@", 1)[0].lower()
|
||||||
|
thread_id = int(message.get("message_thread_id", 0))
|
||||||
|
|
||||||
|
if command == "/help":
|
||||||
|
send_text(
|
||||||
|
config,
|
||||||
|
message,
|
||||||
|
"Я сохраняю обсуждения этой группы.\n"
|
||||||
|
"/status — количество сохранённых сообщений\n"
|
||||||
|
"/export — экспорт текущей темы после предыдущей отсечки",
|
||||||
|
)
|
||||||
|
elif command == "/status":
|
||||||
|
topic_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM messages WHERE chat_id = ? AND thread_id = ?",
|
||||||
|
(config.chat_id, thread_id),
|
||||||
|
).fetchone()[0]
|
||||||
|
total_count = connection.execute(
|
||||||
|
"SELECT COUNT(*) FROM messages WHERE chat_id = ?", (config.chat_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
send_text(
|
||||||
|
config,
|
||||||
|
message,
|
||||||
|
f"Сохранено сообщений: {topic_count} в этой теме, {total_count} всего.",
|
||||||
|
)
|
||||||
|
elif command == "/export":
|
||||||
|
if int(message.get("from", {}).get("id", 0)) != config.owner_id:
|
||||||
|
send_text(config, message, "Экспорт доступен только владельцу бота.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
days = parse_export_days(parts) if len(parts) > 1 else None
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
send_text(config, message, "Использование: /export [1–3650|все]")
|
||||||
|
return
|
||||||
|
checkpoint = (
|
||||||
|
None
|
||||||
|
if len(parts) > 1
|
||||||
|
else export_checkpoint(connection, config.chat_id, thread_id)
|
||||||
|
)
|
||||||
|
markdown = export_markdown(
|
||||||
|
connection,
|
||||||
|
config,
|
||||||
|
thread_id,
|
||||||
|
days,
|
||||||
|
after_message_id=checkpoint,
|
||||||
|
)
|
||||||
|
stamp = datetime.now(config.timezone).strftime("%Y%m%d-%H%M")
|
||||||
|
report_tag = f"#report_{message['message_id']}"
|
||||||
|
report = send_document(
|
||||||
|
config,
|
||||||
|
config.report_thread_id,
|
||||||
|
f"discussion-{thread_id}-{stamp}.md",
|
||||||
|
markdown.encode(),
|
||||||
|
f"{report_tag}\nИсходное обсуждение: "
|
||||||
|
f"{message_link(config.chat_id, message['message_id'])}\n\n{FOOTER}",
|
||||||
|
)
|
||||||
|
send_text(
|
||||||
|
config,
|
||||||
|
message,
|
||||||
|
"━━━━━━━━━━━━━━━━\n"
|
||||||
|
"✅ ОБСУЖДЕНИЕ ЗАВЕРШЕНО\n"
|
||||||
|
f"{report_tag}\n"
|
||||||
|
f"Отчёт: {message_link(config.chat_id, report['message_id'])}\n"
|
||||||
|
"━━━━━━━━━━━━━━━━",
|
||||||
|
)
|
||||||
|
save_export_checkpoint(
|
||||||
|
connection, config.chat_id, thread_id, int(message["message_id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
config = load_config()
|
||||||
|
connection = connect(config.database)
|
||||||
|
logging.info("Starting %s with database %s", FOOTER, config.database)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
updates = telegram_request(
|
||||||
|
config,
|
||||||
|
"getUpdates",
|
||||||
|
{
|
||||||
|
"offset": next_update_id(connection),
|
||||||
|
"timeout": 50,
|
||||||
|
"allowed_updates": ["message", "edited_message"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for update in updates:
|
||||||
|
message = archive_update(connection, update, config)
|
||||||
|
if message:
|
||||||
|
handle_command(connection, config, message)
|
||||||
|
except (urllib.error.URLError, TimeoutError, RuntimeError, OSError, ValueError):
|
||||||
|
logging.exception("Polling failed; retrying in 5 seconds")
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.environ.get("LOG_LEVEL", "INFO"),
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logging.info("Stopped")
|
||||||
238
tools/telegram-bot/test_bot.py
Normal file
238
tools/telegram-bot/test_bot.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from bot import (
|
||||||
|
APP_VERSION,
|
||||||
|
Config,
|
||||||
|
archive_update,
|
||||||
|
connect,
|
||||||
|
export_markdown,
|
||||||
|
handle_command,
|
||||||
|
next_update_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SecretaryTest(unittest.TestCase):
|
||||||
|
def test_archives_allowed_topic_and_exports_reply(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Config(
|
||||||
|
token="test",
|
||||||
|
chat_id=-1004242041275,
|
||||||
|
owner_id=7,
|
||||||
|
report_thread_id=37,
|
||||||
|
thread_ids=frozenset({2}),
|
||||||
|
database=Path(directory) / "bot.sqlite3",
|
||||||
|
timezone=ZoneInfo("Asia/Bishkek"),
|
||||||
|
)
|
||||||
|
connection = connect(config.database)
|
||||||
|
message = {
|
||||||
|
"message_id": 12,
|
||||||
|
"message_thread_id": 2,
|
||||||
|
"date": 1_700_000_000,
|
||||||
|
"chat": {"id": config.chat_id},
|
||||||
|
"from": {"id": 7, "first_name": "Айжан"},
|
||||||
|
"text": "Зафиксируем это решение.",
|
||||||
|
"reply_to_message": {"message_id": 11},
|
||||||
|
}
|
||||||
|
|
||||||
|
archived = archive_update(
|
||||||
|
connection, {"update_id": 40, "message": message}, config
|
||||||
|
)
|
||||||
|
markdown = export_markdown(
|
||||||
|
connection, config, thread_id=2, days=None, now=1_700_000_001
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(archived, message)
|
||||||
|
self.assertEqual(next_update_id(connection), 41)
|
||||||
|
self.assertIn("Айжан", markdown)
|
||||||
|
self.assertIn("Зафиксируем это решение.", markdown)
|
||||||
|
self.assertIn("Ответ на сообщение: #11", markdown)
|
||||||
|
self.assertIn(f"Акылдаш v{APP_VERSION}", markdown)
|
||||||
|
|
||||||
|
def test_ignores_other_chat_but_advances_offset(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Config(
|
||||||
|
token="test",
|
||||||
|
chat_id=-1,
|
||||||
|
owner_id=7,
|
||||||
|
report_thread_id=37,
|
||||||
|
thread_ids=frozenset({0}),
|
||||||
|
database=Path(directory) / "bot.sqlite3",
|
||||||
|
timezone=ZoneInfo("UTC"),
|
||||||
|
)
|
||||||
|
connection = connect(config.database)
|
||||||
|
update = {
|
||||||
|
"update_id": 5,
|
||||||
|
"message": {
|
||||||
|
"message_id": 1,
|
||||||
|
"date": 1,
|
||||||
|
"chat": {"id": -2},
|
||||||
|
"text": "Не наша группа",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertIsNone(archive_update(connection, update, config))
|
||||||
|
self.assertEqual(next_update_id(connection), 6)
|
||||||
|
self.assertEqual(connection.execute("SELECT COUNT(*) FROM messages").fetchone()[0], 0)
|
||||||
|
|
||||||
|
def test_omits_forum_topic_root_reply_from_export(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Config(
|
||||||
|
token="test",
|
||||||
|
chat_id=-1,
|
||||||
|
owner_id=7,
|
||||||
|
report_thread_id=37,
|
||||||
|
thread_ids=frozenset({2}),
|
||||||
|
database=Path(directory) / "bot.sqlite3",
|
||||||
|
timezone=ZoneInfo("UTC"),
|
||||||
|
)
|
||||||
|
connection = connect(config.database)
|
||||||
|
message = {
|
||||||
|
"message_id": 12,
|
||||||
|
"message_thread_id": 2,
|
||||||
|
"date": 1,
|
||||||
|
"chat": {"id": config.chat_id},
|
||||||
|
"from": {"id": config.owner_id},
|
||||||
|
"text": "Сообщение темы",
|
||||||
|
"reply_to_message": {"message_id": 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
archive_update(connection, {"update_id": 1, "message": message}, config)
|
||||||
|
markdown = export_markdown(connection, config, 2, None)
|
||||||
|
|
||||||
|
self.assertNotIn("Ответ на сообщение: #2", markdown)
|
||||||
|
|
||||||
|
def test_rejects_export_from_non_owner(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Config(
|
||||||
|
token="test",
|
||||||
|
chat_id=-1,
|
||||||
|
owner_id=7,
|
||||||
|
report_thread_id=37,
|
||||||
|
thread_ids=frozenset({0}),
|
||||||
|
database=Path(directory) / "bot.sqlite3",
|
||||||
|
timezone=ZoneInfo("UTC"),
|
||||||
|
)
|
||||||
|
connection = connect(config.database)
|
||||||
|
message = {
|
||||||
|
"message_id": 1,
|
||||||
|
"date": 1,
|
||||||
|
"chat": {"id": config.chat_id},
|
||||||
|
"from": {"id": 8},
|
||||||
|
"text": "/export все",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("bot.send_text") as send_text, patch(
|
||||||
|
"bot.send_document"
|
||||||
|
) as send_document:
|
||||||
|
handle_command(connection, config, message)
|
||||||
|
|
||||||
|
send_text.assert_called_once_with(
|
||||||
|
config, message, "Экспорт доступен только владельцу бота."
|
||||||
|
)
|
||||||
|
send_document.assert_not_called()
|
||||||
|
|
||||||
|
def test_sends_report_to_reports_topic_and_marks_discussion(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Config(
|
||||||
|
token="test",
|
||||||
|
chat_id=-100123,
|
||||||
|
owner_id=7,
|
||||||
|
report_thread_id=37,
|
||||||
|
thread_ids=frozenset({2}),
|
||||||
|
database=Path(directory) / "bot.sqlite3",
|
||||||
|
timezone=ZoneInfo("UTC"),
|
||||||
|
)
|
||||||
|
connection = connect(config.database)
|
||||||
|
message = {
|
||||||
|
"message_id": 12,
|
||||||
|
"message_thread_id": 2,
|
||||||
|
"date": 1,
|
||||||
|
"chat": {"id": config.chat_id},
|
||||||
|
"from": {"id": config.owner_id},
|
||||||
|
"text": "/export",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("bot.send_text") as send_text, patch(
|
||||||
|
"bot.send_document", return_value={"message_id": 99}
|
||||||
|
) as send_document:
|
||||||
|
handle_command(connection, config, message)
|
||||||
|
|
||||||
|
document = send_document.call_args.args
|
||||||
|
self.assertEqual(document[1], config.report_thread_id)
|
||||||
|
self.assertIn("#report_12", document[4])
|
||||||
|
self.assertIn("https://t.me/c/123/12", document[4])
|
||||||
|
marker = send_text.call_args.args[2]
|
||||||
|
self.assertIn("ОБСУЖДЕНИЕ ЗАВЕРШЕНО", marker)
|
||||||
|
self.assertIn("#report_12", marker)
|
||||||
|
self.assertIn("https://t.me/c/123/99", marker)
|
||||||
|
|
||||||
|
def test_next_export_starts_after_previous_cutoff(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Config(
|
||||||
|
token="test",
|
||||||
|
chat_id=-100123,
|
||||||
|
owner_id=7,
|
||||||
|
report_thread_id=37,
|
||||||
|
thread_ids=frozenset({2}),
|
||||||
|
database=Path(directory) / "bot.sqlite3",
|
||||||
|
timezone=ZoneInfo("UTC"),
|
||||||
|
)
|
||||||
|
connection = connect(config.database)
|
||||||
|
|
||||||
|
def archive(message_id, text):
|
||||||
|
archive_update(
|
||||||
|
connection,
|
||||||
|
{
|
||||||
|
"update_id": message_id,
|
||||||
|
"message": {
|
||||||
|
"message_id": message_id,
|
||||||
|
"message_thread_id": 2,
|
||||||
|
"date": message_id,
|
||||||
|
"chat": {"id": config.chat_id},
|
||||||
|
"from": {"id": config.owner_id},
|
||||||
|
"text": text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
|
||||||
|
archive(10, "Первое обсуждение")
|
||||||
|
with patch("bot.send_text"), patch(
|
||||||
|
"bot.send_document",
|
||||||
|
side_effect=[{"message_id": 90}, {"message_id": 91}],
|
||||||
|
) as send_document:
|
||||||
|
handle_command(
|
||||||
|
connection,
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"message_id": 12,
|
||||||
|
"message_thread_id": 2,
|
||||||
|
"from": {"id": config.owner_id},
|
||||||
|
"text": "/export",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
archive(13, "Второе обсуждение")
|
||||||
|
handle_command(
|
||||||
|
connection,
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
"message_id": 14,
|
||||||
|
"message_thread_id": 2,
|
||||||
|
"from": {"id": config.owner_id},
|
||||||
|
"text": "/export",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
first_export = send_document.call_args_list[0].args[3].decode()
|
||||||
|
second_export = send_document.call_args_list[1].args[3].decode()
|
||||||
|
self.assertIn("Первое обсуждение", first_export)
|
||||||
|
self.assertNotIn("Первое обсуждение", second_export)
|
||||||
|
self.assertIn("Второе обсуждение", second_export)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
BIN
План_выгрузки_документов_ЦБД_Минюста_КР_v0.1.0.pdf
Normal file
BIN
План_выгрузки_документов_ЦБД_Минюста_КР_v0.1.0.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user