Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user