Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
408 lines
17 KiB
Python
408 lines
17 KiB
Python
"""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()
|