"""DuckDB: единый доступ, схема, стартовые данные. DuckDB — одна запись в момент времени. Внутри одного процесса мы сериализуем все операции общим локом (чтение дёшево, объёмы личные), что полностью соответствует ТЗ (self-hosted, один файл). """ from __future__ import annotations import json import threading import time import uuid import duckdb from . import constants as C from . import config _LOCK = threading.RLock() _SCHEMA = """ CREATE TABLE IF NOT EXISTS boards ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, description VARCHAR NOT NULL DEFAULT '', color VARCHAR NOT NULL DEFAULT '#818cf8', width VARCHAR NOT NULL DEFAULT 'md', pos INTEGER NOT NULL DEFAULT 0, keywords VARCHAR NOT NULL DEFAULT '[]', prompt VARCHAR NOT NULL DEFAULT '', visible_fields VARCHAR NOT NULL DEFAULT '["budget","stack","contacts"]', collapsed BOOLEAN NOT NULL DEFAULT FALSE, suggested BOOLEAN NOT NULL DEFAULT FALSE, rules VARCHAR NOT NULL DEFAULT '{}', note VARCHAR NOT NULL DEFAULT '', created_at BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS leads ( id VARCHAR PRIMARY KEY, col VARCHAR NOT NULL, is_new BOOLEAN NOT NULL DEFAULT TRUE, is_vacancy BOOLEAN NOT NULL DEFAULT FALSE, title VARCHAR NOT NULL, summary VARCHAR NOT NULL DEFAULT '', stack VARCHAR NOT NULL DEFAULT '[]', budget_from DOUBLE, budget_to DOUBLE, budget_cur VARCHAR NOT NULL DEFAULT '', conv_from DOUBLE, conv_to DOUBLE, conv_cur VARCHAR NOT NULL DEFAULT '', contact VARCHAR NOT NULL DEFAULT '', contacts VARCHAR NOT NULL DEFAULT '[]', ch_name VARCHAR NOT NULL DEFAULT '', ch_handle VARCHAR NOT NULL DEFAULT '', ch_hue VARCHAR NOT NULL DEFAULT '#666', time_label VARCHAR NOT NULL DEFAULT '', received_at BIGINT NOT NULL, source_msg VARCHAR NOT NULL DEFAULT '', prev_col VARCHAR NOT NULL DEFAULT 'inbox', comments VARCHAR NOT NULL DEFAULT '[]', created_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_leads_col ON leads(col); CREATE TABLE IF NOT EXISTS messages ( id VARCHAR PRIMARY KEY, dialog_id VARCHAR NOT NULL, text VARCHAR NOT NULL DEFAULT '', msg_at BIGINT NOT NULL, lead_id VARCHAR ); CREATE INDEX IF NOT EXISTS idx_messages_dialog ON messages(dialog_id, msg_at); CREATE TABLE IF NOT EXISTS dialogs ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, handle VARCHAR NOT NULL DEFAULT '', kind VARCHAR NOT NULL DEFAULT 'чат', hue VARCHAR NOT NULL DEFAULT '#666', monitor BOOLEAN NOT NULL DEFAULT FALSE, last_text VARCHAR NOT NULL DEFAULT '', last_at BIGINT, updated_at BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS dedup ( hash VARCHAR PRIMARY KEY, lead_id VARCHAR, created_at BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS learning_log ( id VARCHAR PRIMARY KEY, lead_id VARCHAR NOT NULL, action VARCHAR NOT NULL, from_col VARCHAR, to_col VARCHAR, created_at BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS projects ( id VARCHAR PRIMARY KEY, stage VARCHAR NOT NULL DEFAULT 'planned', local BOOLEAN NOT NULL DEFAULT FALSE, lead_id VARCHAR, title VARCHAR NOT NULL DEFAULT '', summary VARCHAR NOT NULL DEFAULT '', stack VARCHAR NOT NULL DEFAULT '[]', budget_from DOUBLE, budget_to DOUBLE, budget_cur VARCHAR NOT NULL DEFAULT '', contact VARCHAR NOT NULL DEFAULT '', comments VARCHAR NOT NULL DEFAULT '[]', links VARCHAR NOT NULL DEFAULT '[]', files VARCHAR NOT NULL DEFAULT '[]', tz_text VARCHAR NOT NULL DEFAULT '', history VARCHAR NOT NULL DEFAULT '[]', reminder_at BIGINT, reminder_fired BOOLEAN NOT NULL DEFAULT FALSE, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_projects_stage ON projects(stage); CREATE TABLE IF NOT EXISTS rates ( id INTEGER PRIMARY KEY, rates VARCHAR NOT NULL DEFAULT '{}', source VARCHAR NOT NULL DEFAULT 'cbr', updated_at BIGINT NOT NULL ); -- Discovery: задачи поиска Telegram-каналов. keywords — JSON-список, столбцы -- search_* / found / evaluated / joined / rejected — живой прогресс по задаче. CREATE TABLE IF NOT EXISTS disc_tasks ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, description VARCHAR NOT NULL DEFAULT '', keywords VARCHAR NOT NULL DEFAULT '[]', min_subscribers INTEGER NOT NULL DEFAULT 0, lang VARCHAR NOT NULL DEFAULT 'ru', threshold INTEGER NOT NULL DEFAULT 40, sample_size INTEGER NOT NULL DEFAULT 10, plan_joins INTEGER NOT NULL DEFAULT 1, auto_join BOOLEAN NOT NULL DEFAULT FALSE, status VARCHAR NOT NULL DEFAULT 'draft', -- draft|running|paused|done|failed search_idx INTEGER NOT NULL DEFAULT 0, search_done BOOLEAN NOT NULL DEFAULT FALSE, found INTEGER NOT NULL DEFAULT 0, evaluated INTEGER NOT NULL DEFAULT 0, joined INTEGER NOT NULL DEFAULT 0, rejected INTEGER NOT NULL DEFAULT 0, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); -- Discovery: найденные кандидаты. marks/topics — JSON-списки (оценки ИИ). CREATE TABLE IF NOT EXISTS disc_candidates ( dialog_id VARCHAR PRIMARY KEY, task_id VARCHAR NOT NULL, name VARCHAR NOT NULL DEFAULT '', username VARCHAR NOT NULL DEFAULT '', kind VARCHAR NOT NULL DEFAULT 'channel', -- channel|group|forum hue VARCHAR NOT NULL DEFAULT '#666', participants INTEGER, lang_ru BOOLEAN, marks VARCHAR NOT NULL DEFAULT '[]', topics VARCHAR NOT NULL DEFAULT '[]', fit_ratio DOUBLE, status VARCHAR NOT NULL DEFAULT 'new', -- new|review|joined|rejected auto_joined BOOLEAN NOT NULL DEFAULT FALSE, join_failures INTEGER NOT NULL DEFAULT 0, -- неудачные авто-вступления подряд (3 → кандидат удаляется) created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_disc_cand_task ON disc_candidates(task_id, status); -- Discovery: чёрный список (пропускать при поиске). CREATE TABLE IF NOT EXISTS disc_blacklist ( dialog_id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL DEFAULT '', reason VARCHAR NOT NULL DEFAULT '', created_at BIGINT NOT NULL ); -- Discovery: лог событий по задаче (search|found|skip|eval|review|join_auto| -- join_manual|leave|reject|flood|error|done). CREATE TABLE IF NOT EXISTS disc_log ( id VARCHAR PRIMARY KEY, task_id VARCHAR NOT NULL, event VARCHAR NOT NULL, text VARCHAR NOT NULL DEFAULT '', created_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_disc_log_task ON disc_log(task_id, created_at); CREATE TABLE IF NOT EXISTS settings ( key VARCHAR PRIMARY KEY, value VARCHAR NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( token VARCHAR PRIMARY KEY, login VARCHAR NOT NULL, expires BIGINT NOT NULL ); CREATE TABLE IF NOT EXISTS creds ( login VARCHAR PRIMARY KEY, password_hash VARCHAR NOT NULL, salt VARCHAR NOT NULL ); -- Outbox обучения ML: действия пользователя всегда пишутся сюда (синхронно, -- локально), а фоновый воркер отправляет их в автономный ML-сервис. -- Так ML «обучается всегда» даже если сервис временно недоступен. CREATE TABLE IF NOT EXISTS ml_outbox ( id VARCHAR PRIMARY KEY, text VARCHAR NOT NULL, label VARCHAR NOT NULL, delta DOUBLE NOT NULL DEFAULT 1.0, created_at BIGINT NOT NULL ); -- Очередь входящих: все сообщения из групп попадают сюда и разбираются -- фоновым воркером. Не прошедшие фильтры удаляются сразу (не копятся). -- force=TRUE — сообщение возвращено из отсева пользователем: фильтры-отсев -- для него игнорируются (сообщение уходит на ML/ИИ и создаёт карточку). CREATE TABLE IF NOT EXISTS pipeline_msg ( id VARCHAR PRIMARY KEY, dialog_id VARCHAR NOT NULL, ch_name VARCHAR NOT NULL DEFAULT '', ch_handle VARCHAR NOT NULL DEFAULT '', ch_hue VARCHAR NOT NULL DEFAULT '#666', text VARCHAR NOT NULL, msg_id BIGINT, msg_at BIGINT NOT NULL, status VARCHAR NOT NULL DEFAULT 'new', force BOOLEAN DEFAULT FALSE, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_pipeline_status ON pipeline_msg(status, created_at); -- Отсев пайплайна (мониторинг «Обработка»): сообщения, не прошедшие этапы -- (стоп-лист/резюме/тип/без суммы/устарело/ML/ИИ). Хранится причина и «чьё» -- решение. Автоочистка раз в 3 суток + ручная очистка из UI. -- При возврате в обработку запись не удаляется: помечается returned с причиной. CREATE TABLE IF NOT EXISTS rejected_msgs ( id VARCHAR PRIMARY KEY, dialog_id VARCHAR NOT NULL DEFAULT '', msg_id BIGINT, ch_name VARCHAR NOT NULL DEFAULT '', ch_handle VARCHAR NOT NULL DEFAULT '', ch_hue VARCHAR NOT NULL DEFAULT '#666', text VARCHAR NOT NULL, stage VARCHAR NOT NULL DEFAULT '', reason VARCHAR NOT NULL DEFAULT '', kw VARCHAR NOT NULL DEFAULT '', source VARCHAR NOT NULL DEFAULT 'stop', msg_at BIGINT, rejected_at BIGINT NOT NULL, returned BOOLEAN DEFAULT FALSE, returned_at BIGINT, return_reason VARCHAR NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_rejected_rejected_at ON rejected_msgs(rejected_at); """ # DuckDB не поддерживает IF NOT EXISTS в ADD COLUMN на старых версиях; # выполняем в try для совместимости (поле нужно для правила «архив очищается # через 90 дней после помещения в архив»). _MIGRATIONS = [ "ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived_at BIGINT", "ALTER TABLE dialogs ADD COLUMN IF NOT EXISTS backfilled BOOLEAN", # исходное сообщение: id в Telegram + id диалога (для «открыть исходник») "ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_dialog_id VARCHAR DEFAULT ''", "ALTER TABLE leads ADD COLUMN IF NOT EXISTS source_msg_id BIGINT", # квалифицированные контакты: JSON-список [{type, value}] (tg/phone/email/linkedin/site) "ALTER TABLE leads ADD COLUMN IF NOT EXISTS contacts VARCHAR DEFAULT '[]'", # модель ML переезжает в отдельный контейнер — старые встроенные таблицы не нужны "DROP TABLE IF EXISTS ml_classes", "DROP TABLE IF EXISTS ml_terms", # колонки: ИИ-предложения и правила маршрутизации (DuckDB не умеет # ADD COLUMN с NOT NULL — значения трактуются как FALSE/{}/'') "ALTER TABLE boards ADD COLUMN IF NOT EXISTS suggested BOOLEAN", "ALTER TABLE boards ADD COLUMN IF NOT EXISTS rules VARCHAR", "ALTER TABLE boards ADD COLUMN IF NOT EXISTS note VARCHAR", # описание колонки (для пользователя и подсказки ИИ/ML) "ALTER TABLE boards ADD COLUMN IF NOT EXISTS description VARCHAR DEFAULT ''", # какие критерии фильтра совпали при попадании карточки в колонку (JSON) "ALTER TABLE leads ADD COLUMN IF NOT EXISTS match_hits VARCHAR DEFAULT '[]'", # тип «найм/разовое» подтверждён ИИ по контексту (маркерная эвристика — нет) "ALTER TABLE leads ADD COLUMN IF NOT EXISTS is_vacancy_known BOOLEAN DEFAULT FALSE", # возврат из отсева в обработку: force на строке очереди (фильтры игнорируются) "ALTER TABLE pipeline_msg ADD COLUMN IF NOT EXISTS force BOOLEAN DEFAULT FALSE", # отсев: исходный диалог/сообщение (для повторного возврата в очередь) и метки возврата "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS dialog_id VARCHAR DEFAULT ''", "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS msg_id BIGINT", "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS ch_hue VARCHAR DEFAULT '#666'", "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned BOOLEAN DEFAULT FALSE", "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS returned_at BIGINT", "ALTER TABLE rejected_msgs ADD COLUMN IF NOT EXISTS return_reason VARCHAR DEFAULT ''", # discovery: счётчик неудачных авто-вступлений кандидата (лимит ретраев join) "ALTER TABLE disc_candidates ADD COLUMN IF NOT EXISTS join_failures INTEGER DEFAULT 0", ] class Store: """Синглтон-доступ к DuckDB.""" def __init__(self) -> None: self._con: duckdb.DuckDBPyConnection | None = None def init(self) -> None: config.ensure_dirs() with _LOCK: self._con = duckdb.connect(str(config.DB_PATH)) for stmt in _SCHEMA.split(";"): if stmt.strip(): self._con.execute(stmt) for stmt in _MIGRATIONS: try: self._con.execute(stmt) except Exception: pass self._seed_defaults() def close(self) -> None: with _LOCK: if self._con is not None: try: self._con.close() except Exception: pass self._con = None # ── низкоуровневые примитивы ────────────────────────────────────────── def execute(self, sql: str, params: dict | list | tuple | None = None) -> None: with _LOCK: self._con.execute(sql, params or []) def query(self, sql: str, params: dict | list | tuple | None = None) -> list[dict]: with _LOCK: cur = self._con.execute(sql, params or []) cols = [d[0] for d in cur.description] rows = cur.fetchall() return [dict(zip(cols, row)) for row in rows] def query_one(self, sql: str, params: dict | list | tuple | None = None) -> dict | None: rows = self.query(sql, params) return rows[0] if rows else None def scalar(self, sql: str, params: dict | list | tuple | None = None): with _LOCK: cur = self._con.execute(sql, params or []) row = cur.fetchone() return row[0] if row else None # ── настройки ───────────────────────────────────────────────────────── def get_setting(self, key: str): row = self.query_one("SELECT value FROM settings WHERE key = ?", [key]) if row is None: default = C.DEFAULT_SETTINGS.get(key, None) return default return json.loads(row["value"]) def all_settings(self) -> dict: out = dict(C.DEFAULT_SETTINGS) for row in self.query("SELECT key, value FROM settings"): try: out[row["key"]] = json.loads(row["value"]) except Exception: pass return out def set_setting(self, key: str, value) -> None: self.execute( "INSERT INTO settings(key, value) VALUES (?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value", [key, json.dumps(value, ensure_ascii=False)], ) # ── стартовые данные ────────────────────────────────────────────────── def _seed_defaults(self) -> None: now = time.time_ns() // 1_000_000 # Колонки не создаются по умолчанию: их делает пользователь или # предлагает ИИ (см. services/suggest.py). if self.scalar("SELECT count(*) FROM rates") == 0: self.execute( "INSERT INTO rates(id, rates, source, updated_at) VALUES (1, ?, 'mock', ?)", [json.dumps(C.MOCK_RATES), now], ) # начальные настройки сохраняем только при отсутствии (значения берутся из DEFAULT_SETTINGS сами) # ── генераторы ──────────────────────────────────────────────────────── @staticmethod def uid(prefix: str = "") -> str: return f"{prefix}{uuid.uuid4().hex[:12]}" store = Store()