diff --git a/config.py b/config.py index cec1cec..8c4237c 100644 --- a/config.py +++ b/config.py @@ -8,11 +8,11 @@ DATA_DIR = os.path.join(BASE_DIR, "data") SCUD_DIR = os.path.join(DATA_DIR, "scud") ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") OUTPUT_DIR = os.path.join(BASE_DIR, "output") +REPORTS_DIR = os.path.join(OUTPUT_DIR, "reports") -# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат) SHARE_1C_DIR = "/mnt/scud_share" -for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]: +for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR, REPORTS_DIR]: os.makedirs(folder, exist_ok=True) NOW = datetime.now() @@ -23,32 +23,30 @@ if NOW.weekday() == 0: else: DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") -# --- Настройки LLM / Ollama --- -OLLAMA_URL = "http://192.168.11.3:11434/api/generate" -OLLAMA_MODEL = "qwen2.5:14b" +OLLAMA_URL = "http://10.121.17.227:11434/api/generate" +OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0" MODEL_NAME = OLLAMA_MODEL KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") -# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) --- ZUP_SQL_CONFIG = { - "driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}" + "driver": "{ODBC Driver 18 for SQL Server}", "server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"), - "database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01 - "user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server - "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server - "trust_server_certificate": "yes", # Доверять сертификату сервера - "encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование + "database": os.getenv("ZUP_SQL_DB", "ZUP30"), + "user": os.getenv("ZUP_SQL_USER", "scud_reader"), + "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), + "trust_server_certificate": "yes", + "encrypt": "no" } -def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]): - """ - Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026). - """ - date_dots = date_str - date_underscores = date_str.replace('.', '_') +def find_dated_file(prefix, date_str, search_dirs=None): + if search_dirs is None: + search_dirs = [ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."] + + date_dots = str(date_str).replace('_', '.') + date_underscores = date_dots.replace('.', '_') for d in search_dirs: if not os.path.exists(d): @@ -75,6 +73,18 @@ def clean_scud_fio_light(fio_str): def load_exceptions(): + """ + Приоритетно читает исключения и белый список из SQLite таблицы exceptions_registry. + При отсутствии таблицы или пустой базе выполняет fallback на exceptions.json. + """ + try: + from services.exceptions_repo import get_all_exceptions_from_db + db_exc = get_all_exceptions_from_db() + if any(db_exc.values()): + return db_exc + except Exception: + pass + import json if os.path.exists(EXCEPTIONS_PATH): try: @@ -82,4 +92,4 @@ def load_exceptions(): return json.load(f) except Exception: pass - return {"fio": [], "departments": [], "positions": [], "position_keywords": []} \ No newline at end of file + return {"fio": [], "departments": [], "positions": [], "position_keywords": [], "include_fio": []} \ No newline at end of file diff --git a/core/connection.py b/core/connection.py new file mode 100644 index 0000000..943d0b9 --- /dev/null +++ b/core/connection.py @@ -0,0 +1,27 @@ +""" +=============================================================================== +FILE: core/connection.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: Единый менеджер подключений к базе данных SQLite (WAL mode, timeouts). +=============================================================================== +""" + +import os +import sqlite3 +from config import DATA_DIR + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +def get_connection(row_factory: bool = False) -> sqlite3.Connection: + """ + Создает оптимизированное подключение к SQLite. + row_factory=True возвращает sqlite3.Row для доступа к полям по имени. + """ + conn = sqlite3.connect(DB_PATH, timeout=30.0) + if row_factory: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON;") + conn.execute("PRAGMA journal_mode = WAL;") + conn.execute("PRAGMA synchronous = NORMAL;") + return conn \ No newline at end of file diff --git a/core/database.py b/core/database.py index 0e0ed14..1dca47f 100644 --- a/core/database.py +++ b/core/database.py @@ -1,480 +1,35 @@ -import os -import sqlite3 -import json -import pandas as pd -from datetime import datetime -from config import DATA_DIR - -DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") - - -def get_connection(): - """Создает подключение к базе данных SQLite с оптимизированными настройками.""" - conn = sqlite3.connect(DB_PATH) - conn.execute("PRAGMA foreign_keys = ON;") - conn.execute("PRAGMA journal_mode = WAL;") - return conn - - -def init_db(): - """Создает структуру таблиц и индексов в базе данных.""" - with get_connection() as conn: - cursor = conn.cursor() - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS scud_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - log_date TEXT NOT NULL, - fio TEXT NOT NULL, - fio_clean TEXT NOT NULL, - department TEXT, - position TEXT, - time_in TEXT, - first_activity TEXT DEFAULT '—', - time_out TEXT, - time_in_building TEXT, - is_present INTEGER NOT NULL, - anomaly_flag TEXT DEFAULT 'NONE', - snapshot_time TEXT DEFAULT NULL, - snapshot_id TEXT DEFAULT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - - cursor.execute("PRAGMA table_info(scud_logs);") - cols = [col[1] for col in cursor.fetchall()] - if 'snapshot_id' not in cols: - cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;") - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS zup_staff ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - snapshot_date TEXT NOT NULL, - fio TEXT NOT NULL, - fio_clean TEXT NOT NULL, - department TEXT, - position TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS zup_absences ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - absence_date TEXT NOT NULL, - fio TEXT NOT NULL, - fio_clean TEXT NOT NULL, - absence_type TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS anomalies_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - anomaly_date TEXT NOT NULL, - fio TEXT NOT NULL, - anomaly_type TEXT NOT NULL, - details TEXT NOT NULL, - human_status TEXT DEFAULT 'Pending', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS ai_knowledge_base ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - rule_text TEXT UNIQUE NOT NULL, - added_by TEXT DEFAULT 'Human', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - - cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);") - - conn.commit() - - sync_knowledge_base_to_db() - -def has_scud_logs_for_date(date_str): - """Проверяет, есть ли в базе данные СКУД за указанную дату.""" - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,)) - return cursor.fetchone() is not None - -def has_yesterday_final_snapshot(date_str): - """Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00.""" - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1", - (date_str,) - ) - return cursor.fetchone() is not None - - -def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False): - """ - Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN). - Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time). - """ - try: - dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date() - date_prefix = dt_snap.strftime("%Y%m%d") - except (ValueError, TypeError): - dt_snap = datetime.now().date() - date_prefix = dt_snap.strftime("%Y%m%d") - - # Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y - if date_str: - try: - dt_log = datetime.strptime(date_str, "%d.%m.%Y").date() - if dt_log < dt_snap: - is_yesterday = True - except Exception: - pass - - prefix = "Y" if is_yesterday else "" - - with get_connection() as conn: - cursor = conn.cursor() - - if date_str: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", - (date_str, snapshot_time) - ) - else: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", - (snapshot_time,) - ) - - row = cursor.fetchone() - if row and row[0]: - return row[0] - - cursor.execute(""" - SELECT snapshot_id - FROM scud_logs - WHERE snapshot_id LIKE ? OR snapshot_id LIKE ? - ORDER BY snapshot_id DESC LIMIT 1 - """, (f"{date_prefix}-%", f"Y{date_prefix}-%")) - - last_row = cursor.fetchone() - next_seq = 1 - - if last_row and last_row[0]: - parts = last_row[0].replace("Y", "").split("-") - if len(parts) > 1 and parts[1].isdigit(): - next_seq = int(parts[1]) + 1 - - return f"{prefix}{date_prefix}-{next_seq:03d}" - - -def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False): - """Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id.""" - if df_scud is None or df_scud.empty: - return - - if not snapshot_time: - snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # Передаем date_str в генератор snapshot_id - snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday) - - data_to_insert = [] - for _, r in df_scud.iterrows(): - data_to_insert.append(( - date_str, - r.get('Сотрудник', r.get('fio_raw', '')), - r.get('fio_clean', ''), - r.get('Подразделение', ''), - r.get('Должность', ''), - r.get('Начало_дня', 'Нет входа'), - r.get('Первая_активность', '—'), - r.get('Конец_дня', 'Нет выхода'), - r.get('Находился_в_здании', '00:00'), - 1 if r.get('Пришел', False) else 0, - r.get('anomaly_flag', 'NONE'), - snapshot_time, - snapshot_id - )) - - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time)) - cursor.executemany(""" - INSERT INTO scud_logs ( - log_date, fio, fio_clean, department, position, - time_in, first_activity, time_out, time_in_building, - is_present, anomaly_flag, snapshot_time, snapshot_id - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, data_to_insert) - conn.commit() - - -def get_latest_snapshot_time(date_str=None): - with get_connection() as conn: - cursor = conn.cursor() - if date_str: - cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,)) - else: - cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1") - row = cursor.fetchone() - return row[0] if row else None - - -def load_scud_from_db_by_snapshot(date_str, snapshot_param=None): - """ - Загружает логи СКУД по ID снапшота. - Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает - финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str. - """ - with get_connection() as conn: - df = pd.DataFrame() - - # 1. Если передан конкретный ID снапшота (например, '20260810-001') - if snapshot_param: - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE snapshot_id = ?", - conn, params=(str(snapshot_param),) - ) - - # 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день): - if df.empty and date_str: - cursor = conn.cursor() - - # ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот) - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", - (date_str,) - ) - row = cursor.fetchone() - - # ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный - if not row: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", - (date_str,) - ) - row = cursor.fetchone() - - if row and row[0]: - target_snap_id = row[0] - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE snapshot_id = ?", - conn, params=(target_snap_id,) - ) - - # Приводим названия колонок к стандарту - if not df.empty: - rename_map = { - 'department': 'Подразделение', - 'position': 'Должность', - 'fio': 'Сотрудник', - 'time_in': 'Начало_дня', - 'first_activity': 'Первая_активность', - 'time_out': 'Конец_дня', - 'duration': 'Находился_в_здании', - 'is_present': 'Пришел' - } - # Переименовываем имеющиеся колонки - df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) - - # Заполняем недостающие колонки значениями по умолчанию, если их не было в БД - required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag'] - for col in required_cols: - if col not in df.columns: - if col == 'Пришел': - df[col] = False - else: - df[col] = '—' - - # Приводим тип флага Пришел к bool - if 'Пришел' in df.columns: - df['Пришел'] = df['Пришел'].astype(bool) - - return df - -def get_available_snapshots(date_str=None): - """ - Возвращает список снапшотов. - Сортировка по времени snapshot_time DESC. - """ - with get_connection() as conn: - cursor = conn.cursor() - if date_str: - cursor.execute(""" - SELECT - snapshot_id, - log_date, - snapshot_time, - COUNT(*) as cnt - FROM scud_logs - WHERE log_date = ? AND snapshot_time IS NOT NULL - GROUP BY snapshot_id, log_date, snapshot_time - ORDER BY snapshot_time DESC - """, (date_str,)) - else: - cursor.execute(""" - SELECT - snapshot_id, - log_date, - snapshot_time, - COUNT(*) as cnt - FROM scud_logs - WHERE snapshot_time IS NOT NULL - GROUP BY snapshot_id, log_date, snapshot_time - ORDER BY snapshot_time DESC - """) - return cursor.fetchall() - - -def save_staff_to_db(df_staff, date_str): - if df_staff is None or df_staff.empty: - return - data_to_insert = [ - (date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', '')) - for _, r in df_staff.iterrows() - ] - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,)) - cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data_to_insert) - conn.commit() - - -def save_absences_to_db(df_absent, date_str): - if df_absent is None or df_absent.empty: - return - data_to_insert = [ - (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', '')) - for _, r in df_absent.iterrows() - ] - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,)) - cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert) - conn.commit() - - -def save_anomalies_to_db(anomalies_list, date_str): - if not anomalies_list: - return - data_to_insert = [ - (date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) - for a in anomalies_list - ] - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,)) - cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert) - conn.commit() - - -def get_all_rules_from_db(): - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT rule_text FROM ai_knowledge_base") - return [r[0] for r in cursor.fetchall()] - - -def add_rule_to_db(rule_text, added_by="Human"): - if not rule_text or not rule_text.strip(): - return - with get_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by)) - conn.commit() - except Exception as e: - print(f"[⚠️] Ошибка записи правила в БД: {e}") - - -def sync_knowledge_base_to_db(): - pass - -def delete_snapshot_by_id(snapshot_id: str): - """Удаляет конкретный снапшот из таблицы scud_logs по его ID.""" - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) - deleted_count = cursor.rowcount - conn.commit() - print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}") - return deleted_count - -def delete_snapshots_by_date(date_str: str): - """Удаляет все снапшоты за указанную дату (например, '04.08.2026').""" - with get_connection() as conn: - cursor = conn.cursor() - # Удаляем по log_date или по дате внутри snapshot_id - cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) - deleted_count = cursor.rowcount - conn.commit() - print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}") - return deleted_count -def init_department_synonyms_db(): - """Создает таблицу синонимов отделов в БД SQLite.""" - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS department_synonyms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - short_name TEXT UNIQUE NOT NULL, - full_name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - # По умолчанию добавляем ОВК -> Отдел внутреннего контроля - cursor.execute(""" - INSERT OR IGNORE INTO department_synonyms (short_name, full_name) - VALUES ('овк', 'отдел внутреннего контроля') - """) - conn.commit() - - -def get_department_synonyms_dict(): - """Возвращает словарь всех изученных синонимов {short_name: full_name}.""" - init_department_synonyms_db() - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms") - return {row[0]: row[1] for row in cursor.fetchall()} - - -def add_department_synonym_to_db(short_name, full_name): - """Сохраняет новую пару синонимов отдела в базу SQLite.""" - init_department_synonyms_db() - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute( - "INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", - (short_name.strip().lower(), full_name.strip().lower()) - ) - conn.commit() - print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'") - -def load_staff_from_db(date_str): - """Загружает Штатное расписание 1С из базы SQLite за указанную дату.""" - with get_connection() as conn: - df = pd.read_sql_query( - "SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?", - conn, params=(date_str,) - ) - return df if not df.empty else None - -def load_absences_from_db(date_str): - """Загружает документальные отсутствия 1С из базы SQLite за указанную дату.""" - with get_connection() as conn: - df = pd.read_sql_query( - "SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?", - conn, params=(date_str,) - ) - return df if not df.empty else None - \ No newline at end of file +""" +=============================================================================== +FILE: core/database.py +ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов. +=============================================================================== +""" + +from core.connection import get_connection, DB_PATH +from core.schema import init_all_tables + +from core.repositories.scud_repo import ( + has_scud_logs_for_date, + has_yesterday_final_snapshot, + get_or_create_snapshot_id, + save_scud_to_db, + get_latest_snapshot_time, + load_scud_from_db_by_snapshot, + get_available_snapshots, + delete_snapshot_by_id, + delete_snapshots_by_date +) + +from core.repositories.zup_repo import ( + save_staff_to_db, + load_staff_from_db, + save_absences_to_db, + load_absences_from_db, + save_anomalies_to_db, + get_all_rules_from_db, + add_rule_to_db, + get_department_synonyms_dict, + add_department_synonym_to_db +) + +init_db = init_all_tables \ No newline at end of file diff --git a/core/repositories/scud_repo.py b/core/repositories/scud_repo.py new file mode 100644 index 0000000..c284742 --- /dev/null +++ b/core/repositories/scud_repo.py @@ -0,0 +1,227 @@ +""" +=============================================================================== +FILE: core/repositories/scud_repo.py +ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов. +=============================================================================== +""" + +from datetime import datetime +import pandas as pd +from core.connection import get_connection + + +def has_scud_logs_for_date(date_str: str) -> bool: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,)) + return cursor.fetchone() is not None + + +def has_yesterday_final_snapshot(date_str: str) -> bool: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1", + (date_str,) + ) + return cursor.fetchone() is not None + + +def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str: + try: + dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date() + date_prefix = dt_snap.strftime("%Y%m%d") + except (ValueError, TypeError): + dt_snap = datetime.now().date() + date_prefix = dt_snap.strftime("%Y%m%d") + + if date_str: + try: + dt_log = datetime.strptime(date_str, "%d.%m.%Y").date() + if dt_log < dt_snap: + is_yesterday = True + except Exception: + pass + + prefix = "Y" if is_yesterday else "" + + with get_connection() as conn: + cursor = conn.cursor() + if date_str: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", + (date_str, snapshot_time) + ) + else: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", + (snapshot_time,) + ) + + row = cursor.fetchone() + if row and row[0]: + return row[0] + + cursor.execute(""" + SELECT snapshot_id FROM scud_logs + WHERE snapshot_id LIKE ? OR snapshot_id LIKE ? + ORDER BY snapshot_id DESC LIMIT 1 + """, (f"{date_prefix}-%", f"Y{date_prefix}-%")) + + last_row = cursor.fetchone() + next_seq = 1 + if last_row and last_row[0]: + parts = last_row[0].replace("Y", "").split("-") + if len(parts) > 1 and parts[1].isdigit(): + next_seq = int(parts[1]) + 1 + + return f"{prefix}{date_prefix}-{next_seq:03d}" + + +def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None: + if df_scud is None or df_scud.empty: + return + + if not snapshot_time: + snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday) + + data_to_insert = [ + ( + date_str, + r.get('Сотрудник', r.get('fio_raw', '')), + r.get('fio_clean', ''), + r.get('Подразделение', ''), + r.get('Должность', ''), + r.get('Начало_дня', 'Нет входа'), + r.get('Первая_активность', '—'), + r.get('Конец_дня', 'Нет выхода'), + r.get('Находился_в_здании', '00:00'), + 1 if r.get('Пришел', False) else 0, + r.get('anomaly_flag', 'NONE'), + snapshot_time, + snapshot_id + ) + for _, r in df_scud.iterrows() + ] + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time)) + cursor.executemany(""" + INSERT INTO scud_logs ( + log_date, fio, fio_clean, department, position, + time_in, first_activity, time_out, time_in_building, + is_present, anomaly_flag, snapshot_time, snapshot_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, data_to_insert) + conn.commit() + + +def get_latest_snapshot_time(date_str: str = None): + with get_connection() as conn: + cursor = conn.cursor() + if date_str: + cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,)) + else: + cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1") + row = cursor.fetchone() + return row[0] if row else None + + +def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame: + with get_connection() as conn: + df = pd.DataFrame() + + # 1. Если передан конкретный ID снапшота (например 'Y20260820-004') + if snapshot_param: + df = pd.read_sql_query( + "SELECT * FROM scud_logs WHERE snapshot_id = ?", + conn, params=(str(snapshot_param),) + ) + + # 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот) + if df.empty and date_str: + cursor = conn.cursor() + + # ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y' + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY snapshot_time DESC, id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + + # Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени + if not row: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + + if row and row[0]: + target_id = row[0] + df = pd.read_sql_query( + "SELECT * FROM scud_logs WHERE snapshot_id = ?", + conn, params=(target_id,) + ) + + if not df.empty: + rename_map = { + 'department': 'department_scud', + 'position': 'Должность', + 'fio': 'Сотрудник', + 'time_in': 'Начало_дня', + 'first_activity': 'Первая_активность', + 'time_out': 'Конец_дня', + 'time_in_building': 'Находился_в_здании', + 'is_present': 'Пришел' + } + df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) + if 'department_scud' in df.columns and 'Подразделение' not in df.columns: + df['Подразделение'] = df['department_scud'] + + for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']: + if col not in df.columns: + df[col] = False if col == 'Пришел' else '—' + if 'Пришел' in df.columns: + df['Пришел'] = df['Пришел'].astype(bool) + + return df + + +def get_available_snapshots(date_str: str = None): + with get_connection() as conn: + cursor = conn.cursor() + query = """ + SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt + FROM scud_logs + WHERE snapshot_time IS NOT NULL + """ + params = [] + if date_str: + query += " AND log_date = ?" + params.append(date_str) + query += " GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC" + cursor.execute(query, params) + return cursor.fetchall() + + +def delete_snapshot_by_id(snapshot_id: str) -> int: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + cnt = cursor.rowcount + conn.commit() + return cnt + + +def delete_snapshots_by_date(date_str: str) -> int: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) + cnt = cursor.rowcount + conn.commit() + return cnt \ No newline at end of file diff --git a/core/repositories/zup_repo.py b/core/repositories/zup_repo.py new file mode 100644 index 0000000..9d52b6e --- /dev/null +++ b/core/repositories/zup_repo.py @@ -0,0 +1,97 @@ +""" +=============================================================================== +FILE: core/repositories/zup_repo.py +ROLE: Репозиторий кадровых данных 1С:ЗУП, аномалий и базы знаний. +=============================================================================== +""" + +import pandas as pd +from typing import List, Dict, Any +from core.connection import get_connection + + +def save_staff_to_db(df_staff: pd.DataFrame, date_str: str) -> None: + if df_staff is None or df_staff.empty: + return + data = [ + (date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', '')) + for _, r in df_staff.iterrows() + ] + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,)) + cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data) + conn.commit() + + +def load_staff_from_db(date_str: str) -> pd.DataFrame: + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?", + conn, params=(date_str,) + ) + return df if not df.empty else None + + +def save_absences_to_db(df_absent: pd.DataFrame, date_str: str) -> None: + if df_absent is None or df_absent.empty: + return + data = [ + (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', '')) + for _, r in df_absent.iterrows() + ] + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,)) + cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data) + conn.commit() + + +def load_absences_from_db(date_str: str) -> pd.DataFrame: + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?", + conn, params=(date_str,) + ) + return df if not df.empty else None + + +def save_anomalies_to_db(anomalies_list: List[Dict[str, Any]], date_str: str) -> None: + if not anomalies_list: + return + data = [(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) for a in anomalies_list] + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,)) + cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data) + conn.commit() + + +def get_all_rules_from_db() -> List[str]: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT rule_text FROM ai_knowledge_base") + return [r[0] for r in cursor.fetchall()] + + +def add_rule_to_db(rule_text: str, added_by: str = "Human") -> None: + if not rule_text or not rule_text.strip(): + return + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by)) + conn.commit() + + +def get_department_synonyms_dict() -> Dict[str, str]: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms") + return {row[0]: row[1] for row in cursor.fetchall()} + + +def add_department_synonym_to_db(short_name: str, full_name: str) -> None: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", (short_name.strip().lower(), full_name.strip().lower())) + conn.commit() \ No newline at end of file diff --git a/core/schema.py b/core/schema.py new file mode 100644 index 0000000..c623dc5 --- /dev/null +++ b/core/schema.py @@ -0,0 +1,162 @@ +""" +=============================================================================== +FILE: core/schema.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: DDL-схемы таблиц, создание индексов и инициализация базы данных. +=============================================================================== +""" + +import logging +from core.connection import get_connection + +logger = logging.getLogger("DB_SCHEMA") + + +def init_all_tables() -> None: + """Инициализирует все таблицы и индексы системы.""" + with get_connection() as conn: + cursor = conn.cursor() + + # 1. Логи СКУД + cursor.execute(""" + CREATE TABLE IF NOT EXISTS scud_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + log_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT, + position TEXT, + time_in TEXT, + first_activity TEXT DEFAULT '—', + time_out TEXT, + time_in_building TEXT, + is_present INTEGER NOT NULL, + anomaly_flag TEXT DEFAULT 'NONE', + snapshot_time TEXT DEFAULT NULL, + snapshot_id TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 2. Кадровые реестры 1С + cursor.execute(""" + CREATE TABLE IF NOT EXISTS zup_staff ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT, + position TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS zup_absences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + absence_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + absence_type TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 3. Аномалии и база знаний + cursor.execute(""" + CREATE TABLE IF NOT EXISTS anomalies_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + anomaly_date TEXT NOT NULL, + fio TEXT NOT NULL, + anomaly_type TEXT NOT NULL, + details TEXT NOT NULL, + human_status TEXT DEFAULT 'Pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ai_knowledge_base ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_text TEXT UNIQUE NOT NULL, + added_by TEXT DEFAULT 'Human', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS department_synonyms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + short_name TEXT UNIQUE NOT NULL, + full_name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 4. Узлы системного промпта и сессии + cursor.execute(""" + CREATE TABLE IF NOT EXISTS system_prompt_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_name TEXT DEFAULT 'main_agent', + section_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(prompt_name, section_id, item_id) + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS session_states ( + session_id TEXT PRIMARY KEY, + state_type TEXT NOT NULL, + pending_data TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + is_ephemeral INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS exceptions_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL, -- 'department', 'position', 'fio', 'position_keyword', 'include_fio', 'remote_worker' + value TEXT NOT NULL, + comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category, value) + ); + """) + + # 5. Задачи + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT, + module TEXT DEFAULT 'general', + title TEXT NOT NULL, + priority TEXT DEFAULT 'MEDIUM', + status TEXT DEFAULT 'BACKLOG', + due_date TEXT, + user_id INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 6. Индексы + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + + conn.commit() \ No newline at end of file diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index 7900e5b..af9d01e 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm deleted file mode 100644 index ed9a005..0000000 Binary files a/data/scud_orion_ai.db-shm and /dev/null differ diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal deleted file mode 100644 index 9eb5ef3..0000000 Binary files a/data/scud_orion_ai.db-wal and /dev/null differ diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv index 49f9ef7..f7d96f6 100644 --- a/data/static_reason_workers.csv +++ b/data/static_reason_workers.csv @@ -11,4 +11,6 @@ fio,reason,note Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка -Ароян Станислав Месропович,Удаленная работа,Временная удаленка \ No newline at end of file +Чуркина Елена Геннадьевна,Удаленная работа,Постоянная удаленка +Пушков Александр Александрович,Удаленная работа,Временная удаленка с 26.08.2026 по 04.09.2026 +Сысоев Алексей Валерьевич,Удаленная работа,Временная удаленка с 25.08.2026 по 27.08.2026 \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e1ef71a..0301c1e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,64 @@ Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6]. +## [3.0.0] — 2026-08-21 +### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture) + +#### Добавлено: +- **Пакет ядра `core/`:** + - `core/connection.py`: централизованный менеджер SQLite-подключений с поддержкой WAL-режима и row_factory. + - `core/schema.py`: единая точка инициализации схемы данных и индексов. + - `core/repositories/`: низкоуровневые репозитории `scud_repo.py` и `zup_repo.py`. +- **Доменные сервисы `services/`:** + - `services/tasks/`: полная изоляция логики задач (CRUD, Markdown-экспорт, генерация `ROADMAP.md`). + - `services/prompts/`: доменный сервис системного промпта, поддержка превью, генерация HTML-Diff и пакетное удаление узлов (`BATCH_DELETE`). + - `services/snapshots/`: бизнес-логика срезов СКУД с защитой финишных Y-снапшотов. + - `services/knowledge/`: сервис базы знаний правил и синонимов отделов. + - `services/scud_etl/`: модульный ETL-конвейер (`pipeline.py`, `anomaly_detector.py`, `merger.py`, `sql_queries.py`). +- **ИИ-оркестратор `modules/ai_engine/`:** + - Декомпозиция монолитного `agent.py` на узкие обработчики `handlers/` и динамический контекст-билдер `context_builder.py`. + - Двухконтурная обработка: нативный Function Calling + семантический страхующий инжектор (`tool_injector.py`). + - Интеллектуальный Topic Drift менеджер с вежливым напоминанием на 3-й шаг и автоочисткой эфемерных сообщений на 4-й. +- **Интерфейс и UI:** + - Кнопка «✏️ Редактировать» прямо в карточке просмотра системного промпта с инлайн-редактором. + +#### Исправлено: +- Устранена утечка системного промпта в постоянный контекст (`chat_messages`). +- Исправлены ошибки несоответствия сигнатур (`TypeError: create_prompt_preview`, `save_full_prompt_draft`, `get_snapshots_registry`) в Fast-Path. +- Устранена галлюцинация слов-паразитов («Почемучка», «Почемучто») на уровне валидации ответа. +- Исправлено устаревшее противоречивое правило 2.3 в базе данных, блокировавшее интерактивное удаление задач. + +#### Удалено: +- Устаревшие файлы-дубли: `modules/web_api/llm/db/db_tasks.py`, `db_snapshots.py`, `prompt_merger.py`. + +--- + +## [2026-08-19] — Инлайн-редактор задач, интеллектуальный ContextManager и стабилизация Function Calling + +### ✨ Добавлено +* **Инлайн-редактирование задач (`task_widget.js` / `tasks.py`)**: + - Добавлена кнопка редактирования (`✏️`) на каждой карточке задачи. + - Карточка трансформируется в инлайн-форму с тремя полями: текст задачи, HTML5-пикер календаря планируемого срока и выпадающий список важности (`LOW`, `MEDIUM`, `HIGH`, `CRITICAL`). + - Добавлен REST API эндпоинт `PATCH /api/v1/tasks/{task_id}` с поддержкой комплексного обновления полей в SQLite. + - В карточки выведено отображение даты создания задачи. +* **Интеллектуальный ContextManager (`context_manager.py`)**: + - Служебные запросы и карточки инструментов сохраняются синхронными парами (`is_ephemeral = 1`) и атомарно удаляются при завершении операций. + - Свободные диалоги оператора (включая темы Topic Drift) сохраняются в постоянную историю (`is_ephemeral = 0`) и не стираются при закрытии сессий промпта/задач. +* **Поддержка Y-снапшотов (`db_snapshots.py`)**: + - Исправлен SQL-запрос `db_get_snapshots` для точного извлечения ночных и фоновых срезов с префиксом `Y` (например, `Y20260817-003`). + +### 🔧 Изменено +* **`modules/web_api/llm/agent.py`**: + - Полностью удалены эвристические текстовые фильтры и принудительные сортировки инструментов. + - Настроен нативный возврат и исполнение инструментов без зацикливания модели. + - Устранена ошибка `UnboundLocalError` при вызове справочных инструментов. +* **`modules/web_api/llm/schemas.py`**: + - Очищены описания инструментов от агрессивных директив и капса, стабилизирована селективность Function Calling для Qwen 2.5. +* **`modules/web_api/llm/core/ollama_client.py`**: + - Оптимизированы параметры генерации для Function Calling (`repeat_penalty: 1.0`, `presence_penalty: 0.0`, `temperature: 0.0`), что исключило спам несколькими вызовами инструментов на один запрос. +* **`modules/web_api/static/js/chat/task_widget.js`**: + - Фильтр по умолчанию переведен на вкладку **«В работе»** (`IN_PROGRESS`). + --- ## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 780cc22..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,61 +0,0 @@ -# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI - -## 📌 Зафиксированные вехи и концептуальные архитектурные решения - -Схема веток Gitea для развития проекта: - -main (100% стабильный релиз) - │ - ├──► feature/prompt-relational-nodes ──┐ (Перевод системного промпта на реляционные узлы в SQLite) - │ ▼ - ├──────────────────────────────────► merge to main - │ - ├──► feature/native-function-calling ──┐ (Отказ от регулярных выражений tool_injector.py в пользу нативного FC) - │ ▼ - ├──────────────────────────────────► merge to main - │ - └──► feature/ephemeral-context ────────┐ (Интеллектуальный жизненный цикл эфемерных сообщений) - ▼ - merge to main - ---- - -## 🎯 Текущий активный спринт: Нативный Function Calling и реляционный редактор промпта - -### 1. Архитектура чистого диалогового агента без регулярных выражений -* **Концепция:** Полный отказ от жестких эвристик и регулярных выражений в `tool_injector.py`. Все решения о намерениях оператора, вызове инструментов и переключении тем принимает языковая модель через семантический контекст. -* **Ключевые этапы реализации:** - * **Этап 1 (Санитарная очистка):** Превращение `tool_injector.py` в простой фильтр мусорных тегов без инжекции и перехватов команд. - * **Этап 2 (Семантическое управление сессией):** Инструктаж модели в `role: system` о поведении в состоянии `PROMPT_PREVIEW` (оценка подтверждения, отмены, правок или смены темы диалога). - * **Этап 3 (Жизненный цикл контекста):** Модель видит временные сообщения (`is_ephemeral = 1`) только во время активной ветки; при фиксации решения (`fast_path.py`) контекст очищается через `db_purge_ephemeral_messages`. - * **Этап 4 (Гибридный UI):** Поддержка как диалоговых правок, так и прямого инлайн-редактирования через форму `[✏️ Редактировать]` в окне чата. - ---- - -## 🚀 Масштабирование эфемерного контекста и сервисные инструменты - -### 2. Модуль инспекции контекста и базы данных (`db_cli.py`) -* **Статус:** `Реализовано`. -* Добавлена команда `context [session_id] [--limit N]` для просмотра сырой таблицы сообщений `chat_messages` с пометками об эфемерности. - -### 3. Модуль задач и бэклога (`tasks`) -* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`. -* Вывод интерактивных карточек задач с фильтрами без засорения постоянной истории диалога. - -### 4. Модуль срезов и архива СКУД (`snapshots`) -* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`. -* Интерактивное подтверждение удаления срезов и автоматическая очистка таблиц срезов из контекста. - -### 5. Модуль базы знаний, правил и аномалий (`kb & anomalies`) -* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`. -* Изоляция нормативных справочников от постоянной истории диалога для предотвращения залипания LLM. - ---- - -## 🔮 Долгосрочный бэклог (Future Modules) - -### 6. Интерактивный модуль планирования (Checklist & Task Planner Engine) -* Динамические проектные чек-листы с синхронизацией в таблицу `tasks`. - -### 7. Изолированная песочница кода (Code Execution Sandbox Engine) -* Запуск динамических Python/Pandas вычислений в изолированном Docker/gVisor контуре. \ No newline at end of file diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md new file mode 100644 index 0000000..1ec6ff1 --- /dev/null +++ b/docs/in_progress/ROADMAP.md @@ -0,0 +1,123 @@ +# План реализации (ROADMAP) + +## 1. Очистка от регулярок и костылей (`tool_injector.py`) +- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. +- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4]. + +--- + +## 2. Настройка контекста и инструкций сессии (`agent.py`) +- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. +- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: + - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. + - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. +- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. +- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4]. +- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4]. + +--- + +## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`) +- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. +- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. +- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. +- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4]. +- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4]. + +--- + +## 4. Инлайн-редактор в окне диалога (`core.js`) +- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. +- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. +- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4]. +- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4]. + +--- + +## 5. Тестирование и валидация системного промпта +- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. +- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. +- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. + +--- + +## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) +- [x] **Масштабирование UI задач:** + - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4]. + - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4]. + - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4]. +- [x] **Инлайн-редактирование карточки задачи:** + - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4]. + - Отображение даты создания задачи[cite: 4]. + - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4]. +- [x] **Детерминированный Fast-Path и двухфазное удаление:** + - Мгновенная смена статусов без задержек LLM[cite: 4]. + - Карточка подтверждения удаления с автоочисткой контекста[cite: 4]. +- [x] **Генерация отчетов задач в Markdown:** + - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3]. + - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3]. +- [x] **Доменная консолидация задач:** + - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 3]. + +--- + +## 7. Распространение на модуль снапшотов (`snapshots`) +- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4]. +- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4]. +- [x] **Интерактивный UI с чекбоксами и защитой срезов:** + - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3]. + - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3]. + - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 3]. +- [x] **Детерминированный Fast-Path удаления срезов:** + - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 3]. + - Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления[cite: 3]. + +--- + +## 🏗️ Раздел 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]` + +- [x] **Шаг 1. Выделение общего слоя ядра (`core/`)** + - [x] Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов. + - [x] DDL-схемы таблиц и индексов (`core/schema.py`). + - [x] Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`). + - [x] Фасад обратной совместимости (`core/database.py`). +- [x] **Шаг 2. Декомпозиция предметных доменов (`services/`)** + - [x] Сервис задач (`services/tasks/`): CRUD, валидация, markdown-экспорт (`exporter.py`). + - [x] Сервис системного промпта (`services/prompts/`): сборка узлов, вычисление diff (`diff_engine.py`), пакетное удаление (`BATCH_DELETE`). + - [x] Сервис снапшотов СКУД (`services/snapshots/`): безопасное удаление с защитой Y-снапшотов. + - [x] Сервис базы знаний (`services/knowledge/`): правила арбитража и синонимы отделов. +- [x] **Шаг 3. Рефакторинг ETL-конвейера (`services/scud_etl/`)** + - [x] Оркестратор этапов контроллинга (`pipeline.py`). + - [x] Детектор аномалий и конфликтов реестров (`anomaly_detector.py`). + - [x] Агрегация проходов и наложение исключений (`merger.py`). + - [x] Информативное консольное логирование этапов в `main_etl.py`. +- [x] **Шаг 4. Модульный ИИ-оркестратор (`modules/ai_engine/`)** + - [x] Динамический строитель системного контекста (`context_builder.py`). + - [x] Изолированные обработчики инструментов (`handlers/task_handler.py`, `handlers/prompt_handler.py`, `handlers/snapshot_handler.py`). + - [x] Компактный диспетчер диалога (`agent.py`). + - [x] Двухконтурный семантический анализатор намерений (`tool_injector.py`). +- [x] **Шаг 5. Зачистка рудиментов и стабилизация** + - [x] Устранение дубликатов в `web_api/llm/db/`. + - [x] Очистка роутеров от прямых манипуляций с базой данных. + - [x] Исправление сигнатур Fast-Path перехватчиков. + +--- + +## 📊 Раздел 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]` + +- [ ] **Интерактивный запуск отчетов из чата** + - [ ] Инструмент `db_generate_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора. + - [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`). +- [ ] **Диалоговый аудит расхождений** + - [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера». + - [ ] Быстрое внесение исключений и синонимов подразделений через диалог. + +--- + +## 10. Изолированная песочница кода (Code Execution Sandbox Engine) +- [ ] **Docker/gVisor контур:** + - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4]. + - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4]. +- [ ] **Динамические Python/Pandas вычисления:** + - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4]. + - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4]. \ No newline at end of file diff --git a/docs/in_progress/план_оптимизации_работы_с_контекстом.md b/docs/in_progress/план_оптимизации_работы_с_контекстом.md deleted file mode 100644 index 4587843..0000000 --- a/docs/in_progress/план_оптимизации_работы_с_контекстом.md +++ /dev/null @@ -1,72 +0,0 @@ -# План реализации - -## 1. Очистка от регулярок и костылей (`tool_injector.py`) -- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4]. -- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4]. - ---- - -## 2. Настройка контекста и инструкций сессии (`agent.py`) -- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4]. -- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию: - - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4]. - - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4]. -- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4]. -- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`. -- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов. - ---- - -## 3. Очистка эфемерных сообщений при завершении (`fast_path.py`) -- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4]. -- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4]. -- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4]. -- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста. - ---- - -## 4. Инлайн-редактор в окне диалога (`core.js`) -- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4]. -- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4]. -- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`). -- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика. - ---- - -## 5. Тестирование и валидация системного промпта -- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4]. -- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4]. -- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4]. - ---- - -## 6. Распространение архитектурного паттерна на модуль задач (`tasks`) -- [ ] **Масштабирование UI задач:** - - Увеличить размер окна/виджета просмотра и управления задачами (`TASK_INTERACTIVE_CARD`) по аналогии с окном редактирования системного промпта для удобного управления большим списком. -- [ ] **Интерактивное управление и превью:** - - Карточка предпросмотра параметров создаваемой задачи (`db_add_task`) с кнопками `[Подтвердить]`, `[Отменить]`, `[✏️ Редактировать]`. - - Подтверждение опасных операций (удаление задачи `db_delete_task`) с кнопками валидации. - - Follow-up фаза с выводом актуализированного списка задач из `tool_action_registry`. -- [ ] **Доменная консолидация:** - - Объединить `db_add_task`, `db_update_task_status`, `db_delete_task` в единый диспетчер `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE"], ...)`[cite: 4]. - - Обновить `TOOLS_SCHEMA` в `schemas.py`[cite: 4]. - ---- - -## 7. Распространение на модуль снапшотов (`snapshots`) и правил (`rules`) -- [ ] **Консолидация модуля `snapshots`:** - - Интерактивное подтверждение массового удаления снапшотов за дату (`db_delete_snapshots`). - - Объединить операции удаления и управления срезами в единый `db_snapshots_edit(action: ["DELETE"], ...)`[cite: 4]. - - Авто-зачистка тяжелых таблиц снапшотов из контекста при переходе к другим темам. -- [ ] **Модуль правил арбитража (`rules`):** - - Редактирование правил базы знаний через единый паттерн узлов с подсветкой diff. - ---- - -## 8. Изолированная песочница кода (Code Execution Sandbox Engine) -- [ ] **Docker/gVisor контур:** - - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups). - - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only. -- [ ] **Динамические Python/Pandas вычисления:** - - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету. - - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата. \ No newline at end of file diff --git a/etl_code_snapshot.md b/etl_code_snapshot.md new file mode 100644 index 0000000..002af36 --- /dev/null +++ b/etl_code_snapshot.md @@ -0,0 +1,3752 @@ +# 📦 ETL-СЛЕПОК ИСХОДНОГО КОДА (СКУД ⟷ 1С & DB CORE) + +## File: `./config.py` +```py +import os +import re +from datetime import datetime, timedelta + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") + +SCUD_DIR = os.path.join(DATA_DIR, "scud") +ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") +OUTPUT_DIR = os.path.join(BASE_DIR, "output") +REPORTS_DIR = os.path.join(OUTPUT_DIR, "reports") + +# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат) +SHARE_1C_DIR = "/mnt/scud_share" + +for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR, REPORTS_DIR]: + os.makedirs(folder, exist_ok=True) + +NOW = datetime.now() +DATE_TODAY = NOW.strftime("%d.%m.%Y") + +if NOW.weekday() == 0: + DATE_YESTERDAY = (NOW - timedelta(days=3)).strftime("%d.%m.%Y") +else: + DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") + +# --- Настройки LLM / Ollama --- +OLLAMA_URL = "http://10.121.17.227:11434/api/generate" +OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0" +MODEL_NAME = OLLAMA_MODEL + +KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") +EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") + +# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) --- +ZUP_SQL_CONFIG = { + "driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}" + "server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"), + "database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01 + "user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server + "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server + "trust_server_certificate": "yes", # Доверять сертификату сервера + "encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование +} + + +def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]): + """ + Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026). + """ + date_dots = date_str + date_underscores = date_str.replace('.', '_') + + for d in search_dirs: + if not os.path.exists(d): + continue + for f in os.listdir(d): + if f.endswith('.xlsx') or f.endswith('.csv'): + if f.lower().startswith(prefix.lower()): + if date_dots in f or date_underscores in f: + return os.path.join(d, f) + return None + + +def normalize_fio(fio): + if not fio or not isinstance(fio, str): + return "" + fio_clean = re.sub(r'\(.*?\)', '', fio) + fio_clean = fio_clean.replace('\xa0', ' ') + parts = fio_clean.strip().split() + return " ".join(parts).title() + + +def clean_scud_fio_light(fio_str): + return normalize_fio(fio_str) + + +def load_exceptions(): + import json + if os.path.exists(EXCEPTIONS_PATH): + try: + with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {"fio": [], "departments": [], "positions": [], "position_keywords": []} +``` + +## File: `./exceptions.json` +```json +{ + "departments": [ + "ОВК" + ], + "positions": [ + "Уборщик производственных помещений", + "Уборщик служебных помещений" + ], + "fio": [ + "Таткало Валерий Валерьевич", + "Петренюк Андрей Германович", + "Михалев Сергей Геннадьевич" + ], + "position_keywords": [ + "уборщик", + "клинер", + "дворник", + "гардероб", + "рабочий по обслуживанию" + ], + "include_fio": [ + "Тарасенко Александр Александрович", + "Журиков Михаил Николаевич" + ] +} +``` + +## File: `./main_etl.py` +```py +""" +=============================================================================== +FILE: main_etl.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП. +=============================================================================== +""" + +import os +import sys +import argparse +import logging +import pandas as pd +from datetime import datetime, timedelta + +from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date +from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics +from services.scud_etl.anomaly_detector import detect_registry_anomalies +from services.text_reporter import generate_markdown_report + +from services.scud_export import run_export +from services.share_copier import copy_1c_files_from_share +from services.excel_exporter import generate_summary_excel, generate_detailed_excel + +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") + + +def main(): + parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С") + parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки") + parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL") + parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета") + args = parser.parse_args() + + print("=" * 60) + print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}") + print("=" * 60) + + now = datetime.now() + today_str = now.strftime("%d.%m.%Y") + # ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня) + if now.weekday() == 0: + yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") + else: + yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") + + # [Этап 0] Выгрузка свежих данных СКУД + if not args.skip_export and not args.snapshot: + print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...") + run_export(debug=args.debug) + else: + print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...") + + # [Этап 0.5] Синхронизация файлов с шары 1С + if not args.snapshot and not args.skip_export: + print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...") + copy_1c_files_from_share() + else: + print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...") + + # [Этап 1-2] Загрузка данных + print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...") + df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True) + df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False) + + df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str) + df_staff_today, df_abs_today = load_1c_files_for_date(today_str) + + # [Этап 3] Детальный отчет за вчера + print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") + df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) + generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str) + + # [Этап 4] Сводка за сегодня + print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") + df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) + metrics_today = calculate_summary_metrics(df_merged_today) + anomalies_today = detect_registry_anomalies(df_merged_today) + generate_summary_excel(df_merged_today, date_str=today_str) + + # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора + print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...") + + absent_explained = df_merged_today[ + (df_merged_today['Пришел'] == False) & + (df_merged_today['Вид_отсутствия'].notna()) & + (~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) + ] + absent_unexplained = df_merged_today[ + (df_merged_today['Пришел'] == False) & + (df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & + (df_merged_today.get('is_excluded', False) == False) + ] + + summary_md = generate_markdown_report( + merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False], + absent_explained=absent_explained, + absent_unexplained=absent_unexplained, + scud_present_but_absent_in_1c=pd.DataFrame(), + anomalies_list=anomalies_today, + date_str=today_str + ) + + os.makedirs("output", exist_ok=True) + md_file_path = f"output/Сводка_контроллинга_{today_str}.md" + with open(md_file_path, "w", encoding="utf-8") as f: + f.write(summary_md) + print(f"[✓] Текстовый отчет сохранен в: {md_file_path}") + + print("\n" + "=" * 60) + print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:") + print("=" * 60) + print(summary_md) + + +if __name__ == "__main__": + main() +``` + +## File: `./run_cron_etl.sh` +```bash +#!/bin/bash +set -e + +# Переход в папку проекта +cd /home/puh/projects/scud_ai + +# Создание папки для логов +mkdir -p /home/puh/projects/scud_ai/logs + +# Фиксация старта +echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log +echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log +echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log + +# Запуск main_etl.py через интерпретатор окружения +/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1 + +# Фиксация окончания +echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log +echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log +``` + +## File: `./scripts/db_cli.py` +```py +import os +import sys +import argparse +import sqlite3 +import pandas as pd +from datetime import datetime + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio +from core.database import ( + get_connection, + get_available_snapshots, + get_all_rules_from_db, + load_scud_from_db_by_snapshot, + get_latest_snapshot_time +) +from services.exceptions_repo import ( + get_all_exceptions_from_db, + add_exception_to_db, + remove_exception_from_db, + sync_json_to_db +) + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +def print_tool_actions(): + """Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" + print("\n" + "=" * 110) + print("🧰 ДЕКЛАРАТИВНЫЙ РЕЕСТР ДЕЙСТВИЙ ИНСТРУМЕНТОВ (tool_action_registry):") + print("=" * 110) + with get_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(""" + SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json + FROM tool_action_registry + WHERE is_active = 1 + ORDER BY id ASC + """) + rows = cursor.fetchall() + if not rows: + print("Таблица tool_action_registry пуста.") + else: + for r in rows: + print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}") + print(f" • Сообщение: {r[4]}") + if r[5]: + print(f" • Вопрос: {r[5]}") + print(f" • Кнопки: {r[6]}") + print("-" * 110) + except Exception as e: + print(f"Таблица tool_action_registry недоступна: {e}") + print("=" * 110 + "\n") + + +def print_stats(): + """Выводит общую статистику по записям в таблицах БД.""" + print("\n" + "=" * 60) + print("📊 СТАТИСТИКА БАЗЫ ДАННЫХ SQLITE (scud_orion_ai.db):") + print("=" * 60) + with get_connection() as conn: + cursor = conn.cursor() + tables = [ + 'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', + 'ai_knowledge_base', 'chat_messages', 'session_states', + 'system_prompt_nodes', 'tasks', 'exceptions_registry' + ] + for t in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + cnt = cursor.fetchone()[0] + print(f" • Таблица [{t:<22}]: {cnt:>6} записей") + except Exception: + pass + print("=" * 60 + "\n") + + +def print_snapshots_list(date_str=None): + """Выводит реестр снапшотов с отображением даты и точного времени среза.""" + rows = get_available_snapshots(date_str) + + print("\n" + "=" * 105) + print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:") + print("=" * 105) + + header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата и время среза':<20} | {'Записей':<8}" + print(header) + print("-" * 105) + + if not rows: + print("Снапшотов пока нет.") + print("=" * 105 + "\n") + return + + def snapshot_sort_key(row): + snap_id = row[0] or "" + snap_time = row[2] or "" + seq_num = 0 + if "-" in snap_id: + parts = snap_id.replace("Y", "").split("-") + if len(parts) > 1 and parts[1].isdigit(): + seq_num = int(parts[1]) + return (snap_time, seq_num) + + sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True) + + for r in sorted_rows: + snap_id = r[0] if r[0] else '----------' + log_date = r[1] if r[1] else '—' + snap_time = r[2] if r[2] else '—' + count = r[3] + + time_part = "—" + if snap_time and " " in snap_time: + time_part = snap_time.split(" ")[1] + + slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date + formatted_snap_id = f" {snap_id}" if not snap_id.startswith("Y") else snap_id + + print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}") + + print("=" * 105 + "\n") + + +def delete_snapshot_by_id(snapshot_id: str): + """Удаляет конкретный снапшот из таблицы scud_logs по его ID.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + deleted_count = cursor.rowcount + conn.commit() + print(f"\n[✓] Успешно удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}\n") + return deleted_count + + +def delete_snapshots_by_date(date_str: str): + """Удаляет все снапшоты за указанную дату (например, '04.08.2026').""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) + deleted_count = cursor.rowcount + conn.commit() + print(f"\n[✓] Успешно удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}\n") + return deleted_count + + +def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None): + """Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX.""" + target_date = date_str if date_str else DATE_TODAY + + print("\n" + "=" * 115) + if snapshot_id: + print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата среза: {target_date}):") + else: + print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):") + print("=" * 115) + + df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) + + if df is None or df.empty: + print("Записи СКУД не найдены.") + print("=" * 115 + "\n") + return + + fio_col = next((c for c in ['Сотрудник', 'fio', 'fio_clean'] if c in df.columns), None) + dept_col = next((c for c in ['Подразделение', 'department_scud', 'department'] if c in df.columns), None) + pos_col = next((c for c in ['Должность', 'position'] if c in df.columns), None) + in_col = next((c for c in ['Начало_дня', 'time_in'] if c in df.columns), None) + first_act_col = next((c for c in ['Первая_активность', 'first_activity'] if c in df.columns), None) + out_col = next((c for c in ['Конец_дня', 'time_out'] if c in df.columns), None) + dur_col = next((c for c in ['Находился_в_здании', 'time_in_building', 'duration'] if c in df.columns), None) + present_col = next((c for c in ['Пришел', 'is_present'] if c in df.columns), None) + anom_col = 'anomaly_flag' if 'anomaly_flag' in df.columns else None + snap_col = 'snapshot_id' if 'snapshot_id' in df.columns else None + + total = len(df) + if present_col: + present_cnt = len(df[df[present_col].astype(str).str.lower().isin(['true', '1'])]) + else: + present_cnt = 0 + absent_cnt = total - present_cnt + + print(f"Всего записей: {total} | Присутствовали: {present_cnt} | Отсутствовали: {absent_cnt}") + print("-" * 115) + + display_cols = [c for c in [fio_col, dept_col, in_col, first_act_col, out_col, dur_col, present_col, anom_col, snap_col] if c] + print(df[display_cols].head(30).to_string(index=False)) + + if len(df) > 30: + print(f"\n... и ещё {len(df) - 30} строк.") + + if export_xlsx: + out_path = export_xlsx if export_xlsx.endswith('.xlsx') else f"{export_xlsx}.xlsx" + if not os.path.isabs(out_path): + out_path = os.path.join(OUTPUT_DIR, out_path) + + df.to_excel(out_path, index=False) + print("\n" + "*" * 115) + print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}") + print("*" * 115) + + print("=" * 115 + "\n") + + +def print_absences(date_str=None): + """Выводит список официально отсутствующих сотрудников из 1С:ЗУП за выбранный день.""" + target_date = date_str if date_str else DATE_TODAY + print("\n" + "=" * 90) + print(f"📋 ОФИЦИАЛЬНЫЕ ОТСУТСТВИЯ ИЗ 1С:ЗУП ЗА ДАТУ [{target_date}]:") + print("=" * 90) + + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', absence_type as 'Причина отсутствия 1С' FROM zup_absences WHERE absence_date = ? ORDER BY absence_type, fio", + conn, + params=(target_date,) + ) + + if df.empty: + print(f"Записи об отсутствиях 1С за {target_date} в базе не найдены.") + else: + print(f"Всего зафиксировано документов 1С: {len(df)}") + print("-" * 90) + print(df.to_string(index=False)) + + print("=" * 90 + "\n") + + +def print_anomalies(): + """Выводит список аномалий СКУД из БД.""" + print("\n" + "=" * 80) + print("🚨 ИСТОРИЯ НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:") + print("=" * 80) + with get_connection() as conn: + df = pd.read_sql_query("SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history ORDER BY id DESC LIMIT 50", conn) + if df.empty: + print("Аномалии не найдены.") + else: + print(df.to_string(index=False)) + print("=" * 80 + "\n") + + +def print_rules(): + """Выводит правила базы знаний ИИ.""" + rules = get_all_rules_from_db() + print("\n" + "=" * 80) + print("🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ:") + print("=" * 80) + if not rules: + print("База знаний пуста.") + else: + for idx, r in enumerate(rules, 1): + print(f" {idx}. {r}") + print("=" * 80 + "\n") + + +def dump_all_to_excel(out_filename="db_dump_full.xlsx"): + """Дампит всю базу SQLite во многостраничный Excel.""" + out_path = os.path.join(OUTPUT_DIR, out_filename) + print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") + with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: + for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks', 'exceptions_registry']: + try: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table[:31], index=False) + except Exception: + pass + print(f"[✓] Дамп успешно сохранен: {out_path}\n") + + +def print_system_prompts(): + """Выводит все системные промпты из базы данных.""" + print("\n" + "=" * 80) + print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):") + print("=" * 80) + with get_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC") + rows = cursor.fetchall() + except Exception: + rows = [] + + if not rows: + print("Таблица system_prompt_nodes пуста.") + else: + for r in rows: + print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}") + print(f" {r[2]}") + print("-" * 80) + print("=" * 80 + "\n") + + +def print_session_states(): + """Выводит текущие активные сессии и превью (session_states).""" + print("\n" + "=" * 80) + print("🔄 АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states):") + print("=" * 80) + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT session_id, state_type, updated_at, pending_data FROM session_states") + rows = cursor.fetchall() + if not rows: + print("Таблица session_states пуста (нет активных превью).") + else: + for r in rows: + print(f"Session: {r[0]} | Type: {r[1]} | Updated: {r[2]}") + print("-" * 80) + print(f"Pending Data:\n{r[3]}\n") + print("=" * 80 + "\n") + + +def print_chat_messages(session_id=None, limit=50): + """Выводит таблицу истории сообщений чата (контекст).""" + print("\n" + "=" * 105) + print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:") + print("=" * 105) + + with get_connection() as conn: + cursor = conn.cursor() + if session_id: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY id ASC + LIMIT ? + """, (session_id, limit)) + else: + cursor.execute(""" + SELECT id, session_id, role, content, is_ephemeral, created_at + FROM chat_messages + ORDER BY id DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + if not rows: + print("Таблица chat_messages пуста.") + else: + if not session_id: + rows = list(reversed(rows)) + + for r in rows: + msg_id, sess, role, content, ephemeral, created = r + eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else "" + print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}") + print("-" * 105) + content_preview = content if content else "" + print(f"{content_preview}") + print("=" * 105) + print("\n") + + +def purge_chat_context(session_id=None, purge_all=False): + """ + Очистка контекста сообщений: + - По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий. + - purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии. + """ + with get_connection() as conn: + cursor = conn.cursor() + + if purge_all: + if session_id: + cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute("DELETE FROM chat_messages") + cursor.execute("DELETE FROM session_states") + deleted_msgs = cursor.rowcount + conn.commit() + print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n") + return + + query = """ + DELETE FROM chat_messages + WHERE is_ephemeral = 1 + OR content LIKE '%Предпросмотр изменений%' + OR content LIKE '%Удален пункт:%' + OR content LIKE '%добавлен пункт:%' + """ + if session_id: + cursor.execute(query + " AND session_id = ?", (session_id,)) + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + else: + cursor.execute(query) + cursor.execute("DELETE FROM session_states") + + deleted_msgs = cursor.rowcount + conn.commit() + + print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n") + + +# ⭐️ Новые функции управления исключениями (Exceptions & Whitelist) +def print_exceptions(): + """Выводит реестр исключений и белый список сотрудников из базы SQLite.""" + exc = get_all_exceptions_from_db() + print("\n" + "=" * 80) + print("📋 РЕЕСТР ИСКЛЮЧЕНИЙ И БЕЛЫЙ СПИСОК (exceptions_registry):") + print("=" * 80) + for cat, items in exc.items(): + print(f"[{cat.upper()}] ({len(items)} шт.):") + if items: + for it in items: + print(f" • {it}") + else: + print(" — пусто") + print("-" * 80) + print("=" * 80 + "\n") + + +HELP_TEXT = """ +CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db) + +ДОСТУПНЫЕ КОМАНДЫ: + stats -- Общая статистика строк по всем таблицам БД + snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату) + scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel + absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП + anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С + rules -- Посмотреть правила Базы Знаний ИИ из SQLite + prompts -- Посмотреть узлы системного промпта (system_prompt_nodes) + tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry) + sessions -- Посмотреть активные сессии и превью (session_states) + context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages) + context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all) + dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel + snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день + + exceptions [list] -- Посмотреть реестр исключений и белый список (SQLite) + exceptions add -c CATEGORY -v VALUE [-m COMMENT] -- Добавить исключение (fio, include_fio, departments, positions, position_keywords) + exceptions del -c CATEGORY -v VALUE -- Удалить исключение из БД + exceptions sync -- Синхронизировать exceptions.json -> SQLite + +ПРИМЕРЫ ЗАПУСКА: + python scripts/db_cli.py stats + python scripts/db_cli.py snapshots 06.08.2026 + python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг + python scripts/db_cli.py absences 07.08.2026 + python scripts/db_cli.py prompts + python scripts/db_cli.py tools + python scripts/db_cli.py sessions + python scripts/db_cli.py context web_session_main --limit 20 + python scripts/db_cli.py context purge + python scripts/db_cli.py context purge --all + python scripts/db_cli.py context purge web_session_main --all + python scripts/db_cli.py snapshot del Y20260805-007 + python scripts/db_cli.py dump my_dump.xlsx + python scripts/db_cli.py exceptions + python scripts/db_cli.py exceptions add -c include_fio -v "Тарасенко Александр Александрович" + python scripts/db_cli.py exceptions add -c fio -v "Михалев Сергей Геннадьевич" -m "Уборщик" + python scripts/db_cli.py exceptions del -c fio -v "Михалев Сергей Геннадьевич" + python scripts/db_cli.py exceptions sync +""" + + +def main(): + if any(arg in sys.argv for arg in ['-h', '--help']): + print(HELP_TEXT) + sys.exit(0) + + parser = argparse.ArgumentParser( + description=HELP_TEXT, + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False + ) + parser.add_argument('command', nargs='?', default=None, choices=[ + 'stats', 'snapshots', 'scud', 'absences', 'anomalies', + 'rules', 'prompts', 'sessions', 'dump', 'snapshot', + 'tools', 'context', 'exceptions' + ], help="Основная команда") + parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия") + parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)") + parser.add_argument('-c', '--category', type=str, default=None, choices=['departments', 'positions', 'fio', 'position_keywords', 'include_fio'], help="Категория исключения") + parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)") + parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению") + parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции") + parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл") + parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") + parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)") + parser.add_argument('--all', action='store_true', help="Полная очистка всех сообщений (для команды context purge)") + + if len(sys.argv) == 1: + print_stats() + return + + args = parser.parse_args() + + if args.command == 'stats': + print_stats() + elif args.command == 'snapshots': + date_val = args.action or args.param + print_snapshots_list(date_str=date_val) + elif args.command == 'scud': + date_val = args.action or args.param + inspect_scud(snapshot_id=args.snapshot, date_str=date_val, export_xlsx=args.export_xlsx) + elif args.command == 'absences': + date_val = args.action or args.param + print_absences(date_str=date_val) + elif args.command == 'anomalies': + print_anomalies() + elif args.command == 'rules': + print_rules() + elif args.command == 'prompts': + print_system_prompts() + elif args.command == 'sessions': + print_session_states() + elif args.command == 'tools': + print_tool_actions() + elif args.command == 'context': + if args.action in ['purge', 'clear']: + is_all = args.all or (args.param == '--all') + sess_id = None if (args.param == '--all' or not args.param) else args.param + purge_chat_context(session_id=sess_id, purge_all=is_all) + else: + sess_id = args.action if args.action else None + print_chat_messages(session_id=sess_id, limit=args.limit) + elif args.command == 'dump': + filename = args.action or args.param or "db_dump_full.xlsx" + dump_all_to_excel(filename) + elif args.command == 'snapshot': + if args.action == 'del': + if args.day: + delete_snapshots_by_date(args.day) + elif args.param: + delete_snapshot_by_id(args.param) + else: + print("\n[❌] Ошибка: Не указан ID снапшота или параметр --day для удаления.") + print("Пример: python scripts/db_cli.py snapshot del Y20260805-007\n") + else: + print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") + print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") + elif args.command == 'exceptions': + if args.action == 'add': + if not args.category or not args.value: + print("\n[❌] Ошибка: Для добавления исключения укажите флаги -c/--category и -v/--value") + print("Пример: python scripts/db_cli.py exceptions add -c include_fio -v \"Тарасенко Александр Александрович\"\n") + return + if add_exception_to_db(args.category, args.value, args.comment): + print(f"\n[✓] Успешно добавлено исключение: [{args.category}] {args.value}\n") + else: + print(f"\n[❌] Ошибка добавления исключения [{args.category}] {args.value}\n") + elif args.action in ['del', 'delete', 'remove']: + if not args.category or not args.value: + print("\n[❌] Ошибка: Для удаления исключения укажите флаги -c/--category и -v/--value") + print("Пример: python scripts/db_cli.py exceptions del -c fio -v \"Михалев Сергей Геннадьевич\"\n") + return + if remove_exception_from_db(args.category, args.value): + print(f"\n[✓] Успешно удалено исключение: [{args.category}] {args.value}\n") + else: + print(f"\n[⚠️] Запись не найдена в базе: [{args.category}] {args.value}\n") + elif args.action == 'sync': + sync_json_to_db() + print("\n[✓] Синхронизация exceptions.json -> SQLite успешно завершена.\n") + else: + print_exceptions() + else: + print("\n[❌] Ошибка: Неизвестная команда.") + print(HELP_TEXT) + + +if __name__ == "__main__": + main() +``` + +## File: `./core/connection.py` +```py +""" +=============================================================================== +FILE: core/connection.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: Единый менеджер подключений к базе данных SQLite (WAL mode, timeouts). +=============================================================================== +""" + +import os +import sqlite3 +from config import DATA_DIR + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +def get_connection(row_factory: bool = False) -> sqlite3.Connection: + """ + Создает оптимизированное подключение к SQLite. + row_factory=True возвращает sqlite3.Row для доступа к полям по имени. + """ + conn = sqlite3.connect(DB_PATH, timeout=30.0) + if row_factory: + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON;") + conn.execute("PRAGMA journal_mode = WAL;") + conn.execute("PRAGMA synchronous = NORMAL;") + return conn +``` + +## File: `./core/database.py` +```py +""" +=============================================================================== +FILE: core/database.py +ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов. +=============================================================================== +""" + +from core.connection import get_connection, DB_PATH +from core.schema import init_all_tables + +from core.repositories.scud_repo import ( + has_scud_logs_for_date, + has_yesterday_final_snapshot, + get_or_create_snapshot_id, + save_scud_to_db, + get_latest_snapshot_time, + load_scud_from_db_by_snapshot, + get_available_snapshots, + delete_snapshot_by_id, + delete_snapshots_by_date +) + +from core.repositories.zup_repo import ( + save_staff_to_db, + load_staff_from_db, + save_absences_to_db, + load_absences_from_db, + save_anomalies_to_db, + get_all_rules_from_db, + add_rule_to_db, + get_department_synonyms_dict, + add_department_synonym_to_db +) + +init_db = init_all_tables +``` + +## File: `./core/schema.py` +```py +""" +=============================================================================== +FILE: core/schema.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: DDL-схемы таблиц, создание индексов и инициализация базы данных. +=============================================================================== +""" + +import logging +from core.connection import get_connection + +logger = logging.getLogger("DB_SCHEMA") + + +def init_all_tables() -> None: + """Инициализирует все таблицы и индексы системы.""" + with get_connection() as conn: + cursor = conn.cursor() + + # 1. Логи СКУД + cursor.execute(""" + CREATE TABLE IF NOT EXISTS scud_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + log_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT, + position TEXT, + time_in TEXT, + first_activity TEXT DEFAULT '—', + time_out TEXT, + time_in_building TEXT, + is_present INTEGER NOT NULL, + anomaly_flag TEXT DEFAULT 'NONE', + snapshot_time TEXT DEFAULT NULL, + snapshot_id TEXT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 2. Кадровые реестры 1С + cursor.execute(""" + CREATE TABLE IF NOT EXISTS zup_staff ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + department TEXT, + position TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS zup_absences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + absence_date TEXT NOT NULL, + fio TEXT NOT NULL, + fio_clean TEXT NOT NULL, + absence_type TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 3. Аномалии и база знаний + cursor.execute(""" + CREATE TABLE IF NOT EXISTS anomalies_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + anomaly_date TEXT NOT NULL, + fio TEXT NOT NULL, + anomaly_type TEXT NOT NULL, + details TEXT NOT NULL, + human_status TEXT DEFAULT 'Pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ai_knowledge_base ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_text TEXT UNIQUE NOT NULL, + added_by TEXT DEFAULT 'Human', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS department_synonyms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + short_name TEXT UNIQUE NOT NULL, + full_name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 4. Узлы системного промпта и сессии + cursor.execute(""" + CREATE TABLE IF NOT EXISTS system_prompt_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_name TEXT DEFAULT 'main_agent', + section_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + content TEXT NOT NULL, + is_active INTEGER DEFAULT 1, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(prompt_name, section_id, item_id) + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS session_states ( + session_id TEXT PRIMARY KEY, + state_type TEXT NOT NULL, + pending_data TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + is_ephemeral INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS exceptions_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL, -- 'department', 'position', 'fio', 'position_keyword', 'include_fio', 'remote_worker' + value TEXT NOT NULL, + comment TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category, value) + ); + """) + + # 5. Задачи + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT, + module TEXT DEFAULT 'general', + title TEXT NOT NULL, + priority TEXT DEFAULT 'MEDIUM', + status TEXT DEFAULT 'BACKLOG', + due_date TEXT, + user_id INTEGER DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + """) + + # 6. Индексы + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") + + conn.commit() +``` + +## File: `./core/repositories/scud_repo.py` +```py +""" +=============================================================================== +FILE: core/repositories/scud_repo.py +ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов. +=============================================================================== +""" + +from datetime import datetime +import pandas as pd +from core.connection import get_connection + + +def has_scud_logs_for_date(date_str: str) -> bool: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,)) + return cursor.fetchone() is not None + + +def has_yesterday_final_snapshot(date_str: str) -> bool: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1", + (date_str,) + ) + return cursor.fetchone() is not None + + +def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str: + try: + dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date() + date_prefix = dt_snap.strftime("%Y%m%d") + except (ValueError, TypeError): + dt_snap = datetime.now().date() + date_prefix = dt_snap.strftime("%Y%m%d") + + if date_str: + try: + dt_log = datetime.strptime(date_str, "%d.%m.%Y").date() + if dt_log < dt_snap: + is_yesterday = True + except Exception: + pass + + prefix = "Y" if is_yesterday else "" + + with get_connection() as conn: + cursor = conn.cursor() + if date_str: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", + (date_str, snapshot_time) + ) + else: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1", + (snapshot_time,) + ) + + row = cursor.fetchone() + if row and row[0]: + return row[0] + + cursor.execute(""" + SELECT snapshot_id FROM scud_logs + WHERE snapshot_id LIKE ? OR snapshot_id LIKE ? + ORDER BY snapshot_id DESC LIMIT 1 + """, (f"{date_prefix}-%", f"Y{date_prefix}-%")) + + last_row = cursor.fetchone() + next_seq = 1 + if last_row and last_row[0]: + parts = last_row[0].replace("Y", "").split("-") + if len(parts) > 1 and parts[1].isdigit(): + next_seq = int(parts[1]) + 1 + + return f"{prefix}{date_prefix}-{next_seq:03d}" + + +def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None: + if df_scud is None or df_scud.empty: + return + + if not snapshot_time: + snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday) + + data_to_insert = [ + ( + date_str, + r.get('Сотрудник', r.get('fio_raw', '')), + r.get('fio_clean', ''), + r.get('Подразделение', ''), + r.get('Должность', ''), + r.get('Начало_дня', 'Нет входа'), + r.get('Первая_активность', '—'), + r.get('Конец_дня', 'Нет выхода'), + r.get('Находился_в_здании', '00:00'), + 1 if r.get('Пришел', False) else 0, + r.get('anomaly_flag', 'NONE'), + snapshot_time, + snapshot_id + ) + for _, r in df_scud.iterrows() + ] + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time)) + cursor.executemany(""" + INSERT INTO scud_logs ( + log_date, fio, fio_clean, department, position, + time_in, first_activity, time_out, time_in_building, + is_present, anomaly_flag, snapshot_time, snapshot_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, data_to_insert) + conn.commit() + + +def get_latest_snapshot_time(date_str: str = None): + with get_connection() as conn: + cursor = conn.cursor() + if date_str: + cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,)) + else: + cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1") + row = cursor.fetchone() + return row[0] if row else None + + +def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame: + with get_connection() as conn: + df = pd.DataFrame() + + # 1. Если передан конкретный ID снапшота (например 'Y20260820-004') + if snapshot_param: + df = pd.read_sql_query( + "SELECT * FROM scud_logs WHERE snapshot_id = ?", + conn, params=(str(snapshot_param),) + ) + + # 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот) + if df.empty and date_str: + cursor = conn.cursor() + + # ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y' + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY snapshot_time DESC, id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + + # Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени + if not row: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + + if row and row[0]: + target_id = row[0] + df = pd.read_sql_query( + "SELECT * FROM scud_logs WHERE snapshot_id = ?", + conn, params=(target_id,) + ) + + if not df.empty: + rename_map = { + 'department': 'department_scud', + 'position': 'Должность', + 'fio': 'Сотрудник', + 'time_in': 'Начало_дня', + 'first_activity': 'Первая_активность', + 'time_out': 'Конец_дня', + 'time_in_building': 'Находился_в_здании', + 'is_present': 'Пришел' + } + df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) + if 'department_scud' in df.columns and 'Подразделение' not in df.columns: + df['Подразделение'] = df['department_scud'] + + for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']: + if col not in df.columns: + df[col] = False if col == 'Пришел' else '—' + if 'Пришел' in df.columns: + df['Пришел'] = df['Пришел'].astype(bool) + + return df + + +def get_available_snapshots(date_str: str = None): + with get_connection() as conn: + cursor = conn.cursor() + query = """ + SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt + FROM scud_logs + WHERE snapshot_time IS NOT NULL + """ + params = [] + if date_str: + query += " AND log_date = ?" + params.append(date_str) + query += " GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC" + cursor.execute(query, params) + return cursor.fetchall() + + +def delete_snapshot_by_id(snapshot_id: str) -> int: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) + cnt = cursor.rowcount + conn.commit() + return cnt + + +def delete_snapshots_by_date(date_str: str) -> int: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) + cnt = cursor.rowcount + conn.commit() + return cnt +``` + +## File: `./core/repositories/zup_repo.py` +```py +""" +=============================================================================== +FILE: core/repositories/zup_repo.py +ROLE: Репозиторий кадровых данных 1С:ЗУП, аномалий и базы знаний. +=============================================================================== +""" + +import pandas as pd +from typing import List, Dict, Any +from core.connection import get_connection + + +def save_staff_to_db(df_staff: pd.DataFrame, date_str: str) -> None: + if df_staff is None or df_staff.empty: + return + data = [ + (date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', '')) + for _, r in df_staff.iterrows() + ] + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,)) + cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data) + conn.commit() + + +def load_staff_from_db(date_str: str) -> pd.DataFrame: + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?", + conn, params=(date_str,) + ) + return df if not df.empty else None + + +def save_absences_to_db(df_absent: pd.DataFrame, date_str: str) -> None: + if df_absent is None or df_absent.empty: + return + data = [ + (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', '')) + for _, r in df_absent.iterrows() + ] + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,)) + cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data) + conn.commit() + + +def load_absences_from_db(date_str: str) -> pd.DataFrame: + with get_connection() as conn: + df = pd.read_sql_query( + "SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?", + conn, params=(date_str,) + ) + return df if not df.empty else None + + +def save_anomalies_to_db(anomalies_list: List[Dict[str, Any]], date_str: str) -> None: + if not anomalies_list: + return + data = [(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) for a in anomalies_list] + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,)) + cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data) + conn.commit() + + +def get_all_rules_from_db() -> List[str]: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT rule_text FROM ai_knowledge_base") + return [r[0] for r in cursor.fetchall()] + + +def add_rule_to_db(rule_text: str, added_by: str = "Human") -> None: + if not rule_text or not rule_text.strip(): + return + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by)) + conn.commit() + + +def get_department_synonyms_dict() -> Dict[str, str]: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms") + return {row[0]: row[1] for row in cursor.fetchall()} + + +def add_department_synonym_to_db(short_name: str, full_name: str) -> None: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", (short_name.strip().lower(), full_name.strip().lower())) + conn.commit() +``` + +## File: `./services/data_loader.py` +```py +""" +=============================================================================== +FILE: services/data_loader.py +ROLE: Надежная загрузка штата и отсутствий (MS SQL ЗУП -> Резервный Excel). +=============================================================================== +""" + +import os +import warnings +import pandas as pd +from config import ( + normalize_fio, clean_scud_fio_light, load_exceptions, + DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR, DATA_DIR +) +from services.zup_extractor import fetch_zup_absences_from_sql +from core.database import ( + load_scud_from_db_by_snapshot, + load_staff_from_db, + load_absences_from_db +) + +warnings.filterwarnings('ignore', category=UserWarning, module='pandas') + + +def load_staff_data(date_str): + """Загружает файл Штатного расписания 1С.""" + filepath = find_dated_file("Штат", date_str) + if not filepath: + fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx") + if os.path.exists(fallback_path): + filepath = fallback_path + else: + return None + + try: + df_raw = pd.read_excel(filepath, skiprows=8) + df_staff = df_raw.iloc[:, [1, 5, 12]].copy() + df_staff.columns = ['ФИО', 'Подразделение', 'Должность'] + + df_staff = df_staff.dropna(subset=['ФИО']).reset_index(drop=True) + df_staff = df_staff[~df_staff['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО', case=False, na=False)] + df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio) + return df_staff + except Exception as e: + print(f"[❌] Ошибка загрузки штата из {filepath}: {e}") + return None + + +def load_absent_from_excel(date_str): + """Резервное чтение файла Отсутствия_ДД_ММ_ГГГГ.xlsx.""" + filepath = find_dated_file("Отсутствия", date_str) + if not filepath: + return None + + try: + for skip in [0, 1, 2, 3, 4, 5, 8]: + df_try = pd.read_excel(filepath, skiprows=skip) + fio_col = None + reason_col = None + for col in df_try.columns: + c_str = str(col).lower() + if ('фио' in c_str or 'сотрудник' in c_str) and fio_col is None: + fio_col = col + if ('вид' in c_str or 'причина' in c_str or 'отсутств' in c_str) and reason_col is None: + reason_col = col + + if fio_col and reason_col: + df_res = df_try[[fio_col, reason_col]].copy() + df_res.columns = ['ФИО', 'Вид_отсутствия'] + df_res = df_res.dropna(subset=['ФИО', 'Вид_отсутствия']) + df_res = df_res[~df_res['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО|ЛЕНМОРНИИПРОЕКТ', case=False, na=False)] + df_res['fio_clean'] = df_res['ФИО'].apply(normalize_fio) + print(f" [✓] Резервный Excel: загружено {len(df_res)} записей отсутствий из {os.path.basename(filepath)}") + return df_res[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) + except Exception as e: + print(f" [⚠️] Ошибка чтения резервного Excel отсутствий {filepath}: {e}") + + return None + + +def load_absent_data(date_str): + """ + Загружает отсутствия из MS SQL 1С:ЗУП, а при сбое — из локального Excel. + """ + df_absent = None + try: + df_absent = fetch_zup_absences_from_sql(date_str) + if df_absent is not None and not df_absent.empty: + print(f" [✓] MS SQL ЗУП: получено {len(df_absent)} записей отсутствий за {date_str}") + df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия']) + df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)] + df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio) + df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) + except Exception as e: + print(f" [⚠️] Ошибка подключения к MS SQL ЗУП за {date_str}: {e}") + + # Резервный источник: Excel файл из 1С с сетевой шары + if df_absent is None or df_absent.empty: + df_absent = load_absent_from_excel(date_str) + + if df_absent is None: + df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) + + # Обогащение удаленщиками из static_reason_workers.csv + try: + static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") + if os.path.exists(static_path): + df_static = pd.read_csv(static_path, encoding='utf-8') + if 'fio' in df_static.columns and 'reason' in df_static.columns: + df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) + existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() + + new_rows = [] + for _, s_row in df_static.iterrows(): + if s_row['fio_clean'] not in existing_fios: + new_rows.append({ + 'fio_clean': s_row['fio_clean'], + 'Вид_отсутствия': s_row['reason'] + }) + if new_rows: + df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True) + print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows)} чел. из static_reason_workers.csv") + except Exception as e: + print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}") + + return df_absent if not df_absent.empty else None + + +def load_1c_data_smart(date_str, use_db=False): + df_staff = None + df_absent = None + + if use_db: + df_staff = load_staff_from_db(date_str) + df_absent = load_absences_from_db(date_str) + + if df_staff is None: + df_staff = load_staff_data(date_str) + if df_absent is None: + df_absent = load_absent_data(date_str) + + return df_staff, df_absent +``` + +## File: `./services/scud_export.py` +```py +""" +Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. +Добавлена фиксация Первой Активности (без учета направления) и среза по времени. +""" + +import argparse +import logging +import os +import sys +import warnings +from datetime import datetime, timedelta + +import pandas as pd +import pyodbc +from openpyxl.utils import get_column_letter + +from config import SCUD_DIR, clean_scud_fio_light, load_exceptions +from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot + +warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs") +os.makedirs(LOG_DIR, exist_ok=True) + +TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y") +LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log") + +logger = logging.getLogger("scud_export") +logger.setLevel(logging.INFO) +logger.propagate = False + +if not logger.handlers: + _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") + _console_handler = logging.StreamHandler(sys.stdout) + _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + _file_handler.setFormatter(_formatter) + _console_handler.setFormatter(_formatter) + logger.addHandler(_file_handler) + logger.addHandler(_console_handler) + + +def log(message: str, level: str = "INFO"): + level_map = { + "INFO": logging.INFO, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "SUCCESS": logging.INFO, + } + if level == "SUCCESS": + logger.info(f"[SUCCESS] {message}") + else: + logger.log(level_map.get(level, logging.INFO), message) + + +SERVER_NAME = r"172.16.200.147\SQL" +DATABASE_NAME = "Orion-14.01.21-1" +SQL_USER = "sa" +SQL_PASSWORD = "123456" +ODBC_DRIVER = "ODBC Driver 18 for SQL Server" + +SQL_QUERY_TEMPLATE = r""" +DECLARE @InputDate DATE = '{target_date}'; +DECLARE @TargetDate DATE = @InputDate; + +DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME); +DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); + +WITH DailyLogs AS ( + SELECT + log.HozOrgan AS EmployeeID, + log.TimeVal, + log.Event, + log.Mode, + CASE + WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' + WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' + ELSE 'OTHER' + END AS Direction, + ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc + FROM pLogData log WITH (NOLOCK) + WHERE log.TimeVal BETWEEN @StartDate AND @EndDate + AND log.HozOrgan IS NOT NULL + AND log.HozOrgan > 0 + AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65) +), +Passages AS ( + SELECT + EmployeeID, + MIN(TimeVal) AS FirstRawEvent, + MAX(TimeVal) AS LastRawEvent, + MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, + MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, + MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType + FROM DailyLogs + GROUP BY EmployeeID +) +SELECT + N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма], + ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение], + LTRIM(RTRIM( + ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') + + CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> '' + THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END + + CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> '' + THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END + )) AS [Сотрудник], + ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность], + ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№], + CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата], + ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня], + CASE + WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL + THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20)) + ELSE N'—' + END AS [Первая_активность], + CASE + WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) + THEN N'Нет выхода' + WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn + THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20)) + WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent) + THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20)) + ELSE N'Нет выхода' + END AS [Конец_дня], + CASE + WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN + RIGHT('0' + CAST(DATEDIFF(MINUTE, + ISNULL(pass.FirstIn, pass.FirstRawEvent), + CASE + WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() + ELSE ISNULL(pass.LastOut, pass.LastRawEvent) + END) / 60 AS VARCHAR), 2) + ':' + + RIGHT('0' + CAST(DATEDIFF(MINUTE, + ISNULL(pass.FirstIn, pass.FirstRawEvent), + CASE + WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() + ELSE ISNULL(pass.LastOut, pass.LastRawEvent) + END) % 60 AS VARCHAR), 2) + ELSE N'00:00' + END AS [Находился_в_здании], + CASE + WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал' + ELSE N'Отсутствовал (Нет событий)' + END AS [Статус] +FROM pList p WITH (NOLOCK) +LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID +LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID +LEFT JOIN Passages pass ON p.ID = pass.EmployeeID +WHERE + ISNULL(p.StatusRecord, 0) = 0 + AND p.DateTimeInArchive IS NULL + AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%' + AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'') + AND p.Name NOT LIKE N'бр.%' + AND p.Name NOT LIKE N'Гость%' + AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР') + AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%' + AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%' + AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%' + AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%' + AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%' +ORDER BY p.Name ASC; +""" + + +def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): + from openpyxl import load_workbook + + wb = load_workbook(file_path) + ws = wb[sheet_name] + + for col_cells in ws.columns: + max_len = 0 + col_letter = get_column_letter(col_cells[0].column) + for cell in col_cells: + if cell.value is not None: + cell_len = len(str(cell.value)) + if cell_len > max_len: + max_len = cell_len + width = max(min_width, min(max_len + padding, max_width)) + ws.column_dimensions[col_letter].width = width + + wb.save(file_path) + + +def get_targets(input_date: str | None): + targets = [] + if input_date: + try: + parsed = datetime.strptime(input_date, "%d.%m.%Y").date() + targets.append({"name": "Указанная дата", "date": parsed}) + except ValueError: + log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR") + sys.exit(1) + else: + now = datetime.now() + yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date() + today = now.date() + + targets.append({"name": "Вчера", "date": yesterday}) + targets.append({"name": "Сегодня", "date": today}) + + return targets + + +def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False): + if debug: + logger.setLevel(logging.DEBUG) + log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING") + + log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===") + os.makedirs(SCUD_DIR, exist_ok=True) + + targets = get_targets(input_date) + conn_str = ( + f"DRIVER={{{ODBC_DRIVER}}};" + f"SERVER={SERVER_NAME};" + f"DATABASE={DATABASE_NAME};" + f"UID={SQL_USER};" + f"PWD={SQL_PASSWORD};" + f"TrustServerCertificate=yes;" + f"Encrypt=no;" + ) + + success = True + for target in targets: + processing_date = target["date"] + processing_date_str = processing_date.strftime("%d.%m.%Y") + period_label = target["name"] + is_yesterday = (period_label == "Вчера") + + # ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE: + # Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL + if is_yesterday and has_yesterday_final_snapshot(processing_date_str): + log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.") + continue + + if is_yesterday: + snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00" + else: + snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]") + sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) + + connection = None + try: + connection = pyodbc.connect(conn_str, timeout=120) + df = pd.read_sql(sql_query, connection) + + log(f"База вернула {len(df)} строк за {processing_date_str}") + + if len(df) > 0: + df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light) + + df['anomaly_flag'] = 'NONE' + mask_anomaly = (df['Начало_дня'] == 'Нет входа') & (df['Первая_активность'] != '—') + df.loc[mask_anomaly, 'anomaly_flag'] = 'ANOMALY_NO_IN_HAS_ACTIVITY' + + df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly) + + save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday) + log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS") + + if save_xlsx: + file_name = f"Сотрудники_{processing_date_str}.xlsx" + file_path = os.path.join(SCUD_DIR, file_name) + + if os.path.exists(file_path): + try: + os.remove(file_path) + except OSError as e: + log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") + + df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") + auto_fit_columns(file_path, sheet_name="Отчет") + log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") + else: + log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") + + except Exception as e: + log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR") + success = False + finally: + if connection is not None: + connection.close() + + log("=== Выгрузка СКУД завершена ===\n") + return success + + +if __name__ == "__main__": + help_text = """ +Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel. + +ПРИМЕРЫ ЗАПУСКА: + python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера + python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату + python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX + python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG) +""" + parser = argparse.ArgumentParser( + description=help_text, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)") + parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL") + parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/") + args = parser.parse_args() + + run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) +``` + +## File: `./services/share_copier.py` +```py +import os +import shutil +from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR + + +def find_file_strictly_on_share(prefix, date_str): + """ + Ищет файл со строгой привязкой ТОЛЬКО к сетевой шаре SHARE_1C_DIR, + не обращаясь к локальным папкам. + """ + if not os.path.exists(SHARE_1C_DIR): + return None + + date_dots = date_str + date_underscores = date_str.replace('.', '_') + + try: + for f in os.listdir(SHARE_1C_DIR): + if f.endswith('.xlsx') or f.endswith('.csv'): + if f.lower().startswith(prefix.lower()): + if date_dots in f or date_underscores in f: + return os.path.join(SHARE_1C_DIR, f) + except Exception as e: + print(f" [⚠️] Ошибка чтения сетевой шары: {e}") + return None + + +def copy_1c_files_from_share(): + r""" + Копирует свежие файлы 1С (Штат и Отсутствия) за Сегодня и Вчера + из сетевой шары \\storage\SCUD\Обмен\Штат в локальную папку data/1c/ + """ + print(f"[0.5/5] Проверка и копирование файлов 1С с шары: {SHARE_1C_DIR}...") + + if not os.path.exists(SHARE_1C_DIR): + print(f"[⚠️] Сетевой шар недоступен или путь не найден: {SHARE_1C_DIR}") + print(" Используем ранее сохраненные локальные файлы из data/1c/\n") + return False + + dates_to_copy = [DATE_TODAY, DATE_YESTERDAY] + prefixes = ["Штат", "Отсутствия"] + copied_count = 0 + + os.makedirs(ZUP_1C_DIR, exist_ok=True) + + for d_str in dates_to_copy: + for prefix in prefixes: + remote_file = find_file_strictly_on_share(prefix, d_str) + + if remote_file and os.path.exists(remote_file): + filename = os.path.basename(remote_file) + local_target_path = os.path.join(ZUP_1C_DIR, filename) + + try: + shutil.copy2(remote_file, local_target_path) + print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/") + copied_count += 1 + except Exception as e: + print(f" [⚠️] Ошибка копирования {filename}: {e}") + else: + d_fmt = d_str.replace('.', '_') + print(f" [ℹ️] На сетевой шаре отсутствует {prefix} за {d_str} ({prefix}_{d_fmt}.xlsx)") + + if copied_count > 0: + print(f"[✓] Успешно скопировано файлов с сетевой шары: {copied_count} шт.\n") + else: + print("[ℹ️] Новых файлов за указанные даты на сетевой шаре не обнаружено.\n") + + return True +``` + +## File: `./services/excel_exporter.py` +```py +import math +import os +import time +import openpyxl +import pandas as pd +from datetime import datetime +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill, Border, Side +from openpyxl.utils import get_column_letter +from config import REPORTS_DIR + +MONTHS_RU_GENITIVE = { + 1: "января", 2: "февраля", 3: "марта", 4: "апреля", + 5: "мая", 6: "июня", 7: "июля", 8: "августа", + 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" +} + +MONTHS_RU_NOMINATIVE = { + 1: "январь", 2: "февраль", 3: "март", 4: "апрель", + 5: "май", 6: "июнь", 7: "июль", 8: "август", + 9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь" +} + + +def format_date_ru(date_str): + try: + dt = datetime.strptime(date_str, "%d.%m.%Y") + return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}" + except Exception: + return date_str + + +def get_dated_reports_dir(date_str): + try: + dt = datetime.strptime(date_str, "%d.%m.%Y") + year_str = str(dt.year) + month_name = MONTHS_RU_NOMINATIVE[dt.month] + except Exception: + now = datetime.now() + year_str = str(now.year) + month_name = MONTHS_RU_NOMINATIVE[now.month] + + target_dir = os.path.join(REPORTS_DIR, year_str, month_name) + os.makedirs(target_dir, exist_ok=True) + return target_dir + + +THIN_SIDE = Side(border_style="thin", color="D3D3D3") +THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) + +FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") +FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") +FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") +FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid") +FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") +FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") # Мягкий бежево-серый для исключений + +CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"] + +YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") +LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") +LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") + + +def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): + if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": + return "0:00" + + if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: + return f"-{norm_hours}:00" + + try: + parts = time_in_building_str.strip().split(':') + hh = int(parts[0]) + mm = int(parts[1]) if len(parts) > 1 else 0 + total_in_building_minutes = hh * 60 + mm + + if total_in_building_minutes == 0: + return f"-{norm_hours}:00" + + work_minutes = max(0, total_in_building_minutes - lunch_minutes) + norm_minutes = norm_hours * 60 + diff = work_minutes - norm_minutes + + if diff == 0: + return "0:00" + + sign = "-" if diff < 0 else "" + abs_diff = abs(diff) + res_hh = abs_diff // 60 + res_mm = abs_diff % 60 + + return f"{sign}{res_hh}:{res_mm:02d}" + except Exception: + return f"-{norm_hours}:00" + + +def apply_borders_to_cell(cell, border=THIN_BORDER): + cell.border = border + + +def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False): + cell_a = ws.cell(row=r_num, column=1) + cell_b = ws.cell(row=r_num, column=2) + if fill_obj: + cell_a.fill = fill_obj + cell_b.fill = fill_obj + apply_borders_to_cell(cell_a) + apply_borders_to_cell(cell_b) + if is_bold and bold_font: + cell_a.font = bold_font + cell_b.font = bold_font + if align_b: + cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b) + + +# --- 1. СВОДКА НА СЕГОДНЯ --- +def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None): + if not filename: + filename = f"{format_date_ru(date_str)} сводка.xlsx" + + target_dir = get_dated_reports_dir(date_str) + output_path = os.path.join(target_dir, filename) + wb = Workbook() + ws = wb.active + ws.title = "Лист_1" + + ws.sheet_properties.outlinePr.summaryBelow = False + ws.sheet_properties.outlinePr.summaryRight = False + ws.sheet_properties.outlinePr.showOutlineSymbols = True + ws.sheet_view.showOutlineSymbols = True + + bold_font = Font(name="Calibri", size=11, bold=True) + + # 1. Шапка + ws.cell(row=1, column=1, value="Сводка на") + ws.cell(row=1, column=2, value=date_str) + format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) + + apply_borders_to_cell(ws.cell(row=2, column=1)) + apply_borders_to_cell(ws.cell(row=2, column=2)) + + # 2. По списку + ws.cell(row=3, column=1, value="По списку") + ws.cell(row=3, column=2, value=len(merged_df)) + format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font) + + current_row = 4 + + is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False + is_exc = merged_df.get('is_excluded', False) == True + + # 3. НЕИЗВЕСТНО + unexplained = merged_df[ + (merged_df['Пришел'] == False) & + (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & + (~is_no_pass) & + (~is_exc) + ] + ws.cell(row=current_row, column=1, value="неизвестно") + ws.cell(row=current_row, column=2, value=len(unexplained)) + format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) + current_row += 1 + + for fio in sorted(unexplained['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) + format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = False + current_row += 1 + + # 4. РАЗДЕЛ: НЕТ ПРОПУСКА + no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() + + ws.cell(row=current_row, column=1, value="Нет пропуска") + ws.cell(row=current_row, column=2, value=len(no_pass_df)) + format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) + current_row += 1 + + if not no_pass_df.empty: + for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) + format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = False + current_row += 1 + + # 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений) + reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower() + is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False) + + absent_only = merged_df[ + (merged_df['Пришел'] == False) & + (merged_df['Вид_отсутствия'].notna()) & + (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & + (~is_remote_reason) & + (~is_exc) + ] + absent_groups = absent_only.groupby('Вид_отсутствия') + + for idx_cat, (cat_name, group) in enumerate(absent_groups): + hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)] + cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") + + ws.cell(row=current_row, column=1, value=cat_name) + ws.cell(row=current_row, column=2, value=len(group)) + format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) + current_row += 1 + + for fio in sorted(group['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) + format_row_cells(ws, current_row, cat_fill, is_bold=False) + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = True + current_row += 1 + + # 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД) + present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)] + + ws.cell(row=current_row, column=1, value="Итого на работе") + ws.cell(row=current_row, column=2, value=len(present)) + format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) + current_row += 1 + + if not present.empty: + for fio in sorted(present['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) + format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False) + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = True + current_row += 1 + + # 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе) + remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)] + + ws.cell(row=current_row, column=1, value="В том числе на удаленной работе") + ws.cell(row=current_row, column=2, value=len(remote_home)) + format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font) + current_row += 1 + + if not remote_home.empty: + for fio in sorted(remote_home['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) + format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False) + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = True + current_row += 1 + + # 7. АНОМАЛИИ СКУД И 1С + anomalies = merged_df[ + (~is_exc) & ( + ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & + (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) & + (~is_remote_reason) & + (~reason_clean.str.contains('командировк', na=False))) | + (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') + ) + ] + + ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") + ws.cell(row=current_row, column=2, value=len(anomalies)) + format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) + current_row += 1 + + chars_per_line_b = 30 + + if not anomalies.empty: + for _, row in anomalies.iterrows(): + fio = row.get('Сотрудник', '') + reason = row.get('Вид_отсутствия', '') + anom_flag = row.get('anomaly_flag', 'NONE') + + if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': + first_act = row.get('Первая_активность', '—') + reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})" + else: + reason_text = f"В 1С: {reason}" + + cell_a = ws.cell(row=current_row, column=1, value=f"{fio}") + cell_b = ws.cell(row=current_row, column=2, value=reason_text) + + cell_a.fill = FILL_ANOMALY + cell_b.fill = FILL_ANOMALY + apply_borders_to_cell(cell_a) + apply_borders_to_cell(cell_b) + + cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) + cell_a.alignment = Alignment(horizontal="left", vertical="center") + + if len(reason_text) > chars_per_line_b: + lines_count = math.ceil(len(reason_text) / chars_per_line_b) + ws.row_dimensions[current_row].height = max(lines_count * 18, 22) + else: + ws.row_dimensions[current_row].height = 20 + + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = True + current_row += 1 + + # ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список) + exceptions_df = merged_df[is_exc] + + ws.cell(row=current_row, column=1, value="Исключения") + ws.cell(row=current_row, column=2, value=len(exceptions_df)) + format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font) + current_row += 1 + + if not exceptions_df.empty: + for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()): + ws.cell(row=current_row, column=1, value=fio) + format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False) + ws.row_dimensions[current_row].outlineLevel = 1 + ws.row_dimensions[current_row].hidden = True + current_row += 1 + + ws.column_dimensions['A'].width = 45.0 + ws.column_dimensions['B'].width = 38.0 + + try: + wb.save(output_path) + print(f"[✓] Ежедневная сводка сохранена: {output_path}") + except PermissionError: + alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") + alt_path = os.path.join(target_dir, alt_filename) + wb.save(alt_path) + print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + + +# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- +def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None): + if not filename: + filename = f"{format_date_ru(date_str)} отчет.xlsx" + + if merged_df is not None and not merged_df.empty: + df_export = merged_df[merged_df.get('is_excluded', False) == False].copy() + else: + df_export = pd.DataFrame() + + target_dir = get_dated_reports_dir(date_str) + output_path = os.path.join(target_dir, filename) + wb = Workbook() + ws = wb.active + ws.title = "Детальный_отчет" + + ws["B2"] = "Дата:" + ws["D2"] = date_str + ws["B2"].font = Font(name="Arial", size=10, bold=True) + ws["D2"].font = Font(name="Arial", size=10, bold=True) + + headers = [ + "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", + "находился в здании", "причина отсутствия", "норма", "отклонение от нормы" + ] + ws.append([]) + ws.append(headers) + + header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") + for col_idx in range(1, len(headers) + 1): + cell = ws.cell(row=4, column=col_idx) + cell.fill = header_fill + cell.font = Font(name="Arial", size=10, bold=True) + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + apply_borders_to_cell(cell) + + start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня' + end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня' + hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании' + + chars_per_line_h = 24 + + for idx, row in df_export.reset_index(drop=True).iterrows(): + is_present = row.get('Пришел', False) + absence_reason = row.get('Вид_отсутствия', '') + has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' + anom_flag = row.get('anomaly_flag', 'NONE') + + in_building_str = str(row.get(hours_col, '00:00')) + first_act_val = str(row.get('Первая_активность', '—')).strip() + has_first_act = first_act_val not in ['—', '', 'None', 'nan'] + + dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) + deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) + + ws.append([ + idx + 1, + row.get('Сотрудник', ''), + dept_scud_val, + row.get(start_col, 'Нет входа'), + first_act_val, + row.get(end_col, 'Нет выхода'), + in_building_str, + absence_reason if has_reason else '', + 8, + deviation_val + ]) + + row_num = 5 + idx + + if is_present and has_reason: + row_fill = GREEN_FILL + elif not is_present and has_reason: + row_fill = YELLOW_FILL + elif not is_present and not has_reason and not has_first_act: + row_fill = LIGHT_RED_FILL + else: + row_fill = None + + val_h_str = str(absence_reason) if has_reason else "" + if len(val_h_str) > chars_per_line_h: + needed_lines = math.ceil(len(val_h_str) / chars_per_line_h) + ws.row_dimensions[row_num].height = max(needed_lines * 18, 22) + else: + ws.row_dimensions[row_num].height = 20 + + for col_idx in range(1, len(headers) + 1): + cell = ws.cell(row=row_num, column=col_idx) + apply_borders_to_cell(cell) + if row_fill: + cell.fill = row_fill + + if col_idx == 8: + cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) + elif col_idx in [1, 4, 5, 6, 7, 9, 10]: + cell.alignment = Alignment(horizontal="center", vertical="center") + else: + cell.alignment = Alignment(horizontal="left", vertical="center") + + for col in ws.columns: + col_letter = get_column_letter(col[0].column) + max_len = 0 + for cell in col: + if cell.value is not None: + cell_lines = str(cell.value).split("\n") + line_max = max(len(line) for line in cell_lines) + if line_max > max_len: + max_len = line_max + + optimal_width = max(max_len + 2, 8) + if optimal_width > 35: + optimal_width = 35 + ws.column_dimensions[col_letter].width = optimal_width + + try: + wb.save(output_path) + print(f"[✓] Детальный отчет сохранен: {output_path}") + except PermissionError: + alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") + alt_path = os.path.join(target_dir, alt_filename) + wb.save(alt_path) + print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + + +def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): + output_path = os.path.join(REPORTS_DIR, filename) + df_scud.to_excel(output_path, index=False) +``` + +## File: `./services/text_reporter.py` +```py +import json +import difflib +import re +from services.ai_verifier import ask_ollama +from services.knowledge_base import load_knowledge_base + + +def find_python_fuzzy_matches(unexplained_df, raw_absent_df): + if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: + return [] + + absent_dict = {} + for idx, r in raw_absent_df.iterrows(): + raw_fio = str(r.get('ФИО', '')) + clean_fio = str(r.get('fio_clean', '')) + reason = str(r.get('Вид_отсутствия', '')) + if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']: + absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason} + + matches = [] + unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else [] + + for fio in unexplained_fios: + fio_parts = fio.split() + if not fio_parts: + continue + surname = fio_parts[0].lower() + + for abs_clean, abs_info in absent_dict.items(): + abs_parts = abs_clean.split() + if not abs_parts: + continue + abs_surname = abs_parts[0].lower() + + if surname == abs_surname: + ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() + if ratio >= 0.75: + matches.append({ + "target_fio": fio, + "found_in_1c_raw": abs_info['raw_fio'], + "reason_1c": abs_info['reason'] + }) + break + + return matches + + +def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"): + kb = load_knowledge_base() + custom_rules = kb.get("rules", []) + custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." + + total_staff = len(merged_df) + + # 1. Пришедшие в офис + present_office_cnt = len(merged_df[merged_df['Пришел'] == True]) + + # 2. Не пришедшие удаленщики + reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower() + is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False) + remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason]) + + # 3. Официально отсутствующие (без удаленки) + explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))]) + + unexplained_cnt = len(absent_unexplained) + anomalies_cnt = len(anomalies_list) if anomalies_list else 0 + + fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) + + anomalies_formatted = [] + if anomalies_list: + for idx, a in enumerate(anomalies_list, 1): + anomalies_formatted.append(f"{idx}. {a.get('fio', 'Сотрудник')}: {a.get('description', '')}") + anomalies_text_block = "\n".join(anomalies_formatted) if anomalies_formatted else "Аномалий не обнаружено." + + unexplained_list = [] + if not absent_unexplained.empty: + for idx, r in absent_unexplained.iterrows(): + fio = r.get('Сотрудник', r.get('fio_clean', '')) + dept_scud = str(r.get('department_scud', '')).strip() + dept_1c = str(r.get('Подразделение', '')).strip() + dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c + if not dept: + dept = '—' + pos = r.get('Должность', '—') + unexplained_list.append(f"{fio} — {dept}, {pos}") + + prompt = f""" +Ты — старший аудитор кадровой безопасности и контроллинга СКУД. +Сформируй итоговую сводку кадрового контроля на {date_str}. + +📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ: +{custom_rules_str} + +🚨 ВХОДНЫЕ МЕТРИКИ: +- Всего сотрудников: {total_staff} +- Итого на работе (в офисе): {present_office_cnt} +- В том числе на удаленной работе: {remote_home_cnt} +- Официально отсутствуют: {explained_cnt} +- Неизвестно (истинно неотмеченные): {unexplained_cnt} +- Выявлено аномалий/конфликтов реестров: {anomalies_cnt} + +--- +🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): +{anomalies_text_block} + +--- +⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): +{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)} + +--- +📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): +{json.dumps(unexplained_list, ensure_ascii=False, indent=2)} + +СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: +1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". Если аномалий нет, напиши: "Аномалий не обнаружено." +2. В разделе "Подозрения на ошибки сопоставления ФИО" используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если он пуст, напиши: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." +3. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. +4. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы без перечисления конкретных ФИО. + +СТРОГИЙ ШАБЛОН ОТВЕТА: + +**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** + +- Всего офисных сотрудников: **{total_staff}** +- Итого на работе (в офисе): **{present_office_cnt}** +- В том числе на удаленной работе: **{remote_home_cnt}** +- Официально отсутствуют: **{explained_cnt}** +- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. +- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. + +#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): +(Выведи нумерованный текстовый список аномалий или 'Аномалий не обнаружено.') + +#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: +(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива ошибок ФИО. Если пуст — 'Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.') + +#### Неизвестные случаи: {unexplained_cnt} +(Выведи нумерованный список всех {unexplained_cnt} человек) + +### Точечные рекомендации: +(2-3 системные рекомендации) +""" + + sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке обычным форматированным текстом. Категорически запрещено выводить фигурные скобки JSON или технический код." + return ask_ollama(prompt, system_prompt=sys_prompt) +``` + +## File: `./services/exceptions_repo.py` +```py +""" +=============================================================================== +FILE: services/exceptions_repo.py +ROLE: Управление исключениями в SQLite с синхронизацией с exceptions.json. +=============================================================================== +""" + +from typing import Dict, List, Any +import json +import os +from config import EXCEPTIONS_PATH, normalize_fio +from core.connection import get_connection + + +def init_exceptions_table(): + with get_connection() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS exceptions_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL, + value TEXT NOT NULL, + comment TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(category, value) + ); + """) + conn.commit() + + +def get_all_exceptions_from_db() -> Dict[str, List[str]]: + init_exceptions_table() + cfg = {"departments": [], "positions": [], "fio": [], "position_keywords": [], "include_fio": []} + + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT category, value FROM exceptions_registry") + rows = cursor.fetchall() + + if not rows and os.path.exists(EXCEPTIONS_PATH): + # Первичная миграция из JSON в SQLite + sync_json_to_db() + return get_all_exceptions_from_db() + + for cat, val in rows: + if cat in cfg: + cfg[cat].append(val) + return cfg + + +def add_exception_to_db(category: str, value: str, comment: str = "") -> bool: + init_exceptions_table() + val_clean = normalize_fio(value) if category in ["fio", "include_fio"] else value.strip() + if not val_clean: + return False + with get_connection() as conn: + conn.execute( + "INSERT OR REPLACE INTO exceptions_registry (category, value, comment) VALUES (?, ?, ?)", + (category, val_clean, comment) + ) + conn.commit() + return True + + +def remove_exception_from_db(category: str, value: str) -> bool: + init_exceptions_table() + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM exceptions_registry WHERE category = ? AND value = ?", (category, value.strip())) + conn.commit() + return cursor.rowcount > 0 + + +def sync_json_to_db(): + """Переносит данные из exceptions.json в SQLite.""" + if not os.path.exists(EXCEPTIONS_PATH): + return + try: + with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + for cat, items in data.items(): + for item in items: + add_exception_to_db(cat, item, comment="Импорт из JSON") + except Exception as e: + print(f"[⚠️] Ошибка синхронизации JSON -> DB: {e}") +``` + +## File: `./services/zup_extractor.py` +```py +import os +import logging +from datetime import date, datetime +import pyodbc +import pandas as pd +from config import DATA_DIR, normalize_fio, ZUP_SQL_CONFIG + +logger = logging.getLogger(__name__) + + +def get_zup_connection_string() -> str: + """Формирует строку подключения pyodbc к MS SQL Server из config.py.""" + return ( + f"DRIVER={ZUP_SQL_CONFIG['driver']};" + f"SERVER={ZUP_SQL_CONFIG['server']};" + f"DATABASE={ZUP_SQL_CONFIG['database']};" + f"UID={ZUP_SQL_CONFIG['user']};" + f"PWD={ZUP_SQL_CONFIG['password']};" + f"TrustServerCertificate={ZUP_SQL_CONFIG.get('trust_server_certificate', 'yes')};" + f"Encrypt={ZUP_SQL_CONFIG.get('encrypt', 'no')};" + ) + + +def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame: + """ + Извлекает оперативные отсутствия и действующие декреты из MS SQL 1С:ЗУП 3.1 + на указанную дату (принимает как datetime.date, так и строку 'DD.MM.YYYY' / 'DD_MM_YYYY'). + """ + if isinstance(target_date, str): + clean_date_str = target_date.replace('_', '.') + try: + target_date = datetime.strptime(clean_date_str, "%d.%m.%Y").date() + except ValueError: + logger.error(f"[❌] Неверный формат даты для SQL-запроса: {target_date}. Ожидался DD.MM.YYYY") + return pd.DataFrame() + + query = """ + DECLARE @TargetDate DATE = ?; + + -- 1. Оперативные отсутствия (Отпуска, Командировки, Больничные, Отгулы) + SELECT + LTRIM(RTRIM(ref_emp._Description)) AS [ФИО], + CASE state._Fld16925RRef + WHEN 0x9C10B2452D414FDF4A90E2B2AB81D3F7 THEN N'Отпуск основной' + WHEN 0xBA63FCF94B4AD0664ED369D2E6505D67 THEN N'Командировка' + WHEN 0x8C3B61F23954155A40EB0108FC0932DB THEN N'Болезнь' + WHEN 0x853001C18D0965EE4B2702405C94054A THEN N'Отпуск неоплачиваемый по разрешению работодателя' + WHEN 0xB7335AEFD8708C3E462861FC59489A38 THEN N'Отпуск по беременности и родам' + ELSE N'Другое отсутствие' + END AS [Вид_отсутствия] + + FROM dbo._InfoRg16921 state WITH (NOLOCK) + INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK) + ON state._Fld16922RRef = ref_emp._IDRRef + + WHERE @TargetDate BETWEEN CAST(CASE WHEN YEAR(state._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16926) ELSE state._Fld16926 END AS DATE) + AND CAST(CASE WHEN YEAR(state._Fld16927) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16927) ELSE state._Fld16927 END AS DATE) + + UNION ALL + + -- 2. Динамический выбор ДЕЙСТВУЮЩИХ декретниц по уходу за ребенком + SELECT + active_state.fio AS [ФИО], + N'Отпуск по уходу за ребенком' AS [Вид_отсутствия] + + FROM ( + SELECT + LTRIM(RTRIM(ref_emp._Description)) AS fio, + all_states._Fld16925RRef AS state_guid, + CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) AS date_start, + ROW_NUMBER() OVER ( + PARTITION BY all_states._Fld16922RRef + ORDER BY all_states._Fld16926 DESC + ) AS rn + FROM dbo._InfoRg16921 all_states WITH (NOLOCK) + INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK) + ON all_states._Fld16922RRef = ref_emp._IDRRef + WHERE CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) <= @TargetDate + ) active_state + + WHERE active_state.rn = 1 + AND active_state.state_guid = 0xA4FBA038663B3C2A48DA151C262855E1 + AND active_state.date_start >= DATEADD(YEAR, -3, @TargetDate) + + ORDER BY [ФИО] ASC; + """ + + try: + conn_str = get_zup_connection_string() + with pyodbc.connect(conn_str, timeout=30) as conn: + df = pd.read_sql(query, conn, params=[target_date]) + + if not df.empty: + df['fio_clean'] = df['ФИО'].apply(normalize_fio) + return df + except Exception as e: + logger.error(f"[❌] Ошибка SQL-выгрузки отсутствий за {target_date}: {e}") + return pd.DataFrame() + + +def fetch_zup_staff_from_sql() -> pd.DataFrame: + """Резервная выгрузка штата из MS SQL.""" + query = """ + SELECT DISTINCT + LTRIM(RTRIM(ref_emp._Description)) AS [ФИО], + N'Организация' AS [Подразделение], + N'Сотрудник' AS [Должность] + FROM dbo._Reference299 ref_emp WITH (NOLOCK) + WHERE ref_emp._Description <> '' + AND ref_emp._Marked = 0x00 + ORDER BY [ФИО] ASC; + """ + try: + conn_str = get_zup_connection_string() + with pyodbc.connect(conn_str, timeout=5) as conn: + df = pd.read_sql(query, conn) + + if not df.empty: + df['fio_clean'] = df['ФИО'].apply(normalize_fio) + return df + except Exception as e: + logger.error(f"[❌] Ошибка выгрузки штата из MS SQL: {e}") + return pd.DataFrame() + + +def sync_zup_to_excel(target_date) -> bool: + """Создает дамп в Excel при необходимости.""" + try: + if isinstance(target_date, str): + clean_date_str = target_date.replace('_', '.') + target_date_obj = datetime.strptime(clean_date_str, "%d.%m.%Y").date() + else: + target_date_obj = target_date + + date_str_file = target_date_obj.strftime("%d_%m_%Y") + c_1c_dir = os.path.join(DATA_DIR, "1c") + os.makedirs(c_1c_dir, exist_ok=True) + + df_staff = fetch_zup_staff_from_sql() + df_absences = fetch_zup_absences_from_sql(target_date_obj) + + staff_excel_path = os.path.join(c_1c_dir, f"Штат_{date_str_file}.xlsx") + absences_excel_path = os.path.join(c_1c_dir, f"Отсутствия_{date_str_file}.xlsx") + + with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer: + dummy_headers = pd.DataFrame([[""] * 13] * 8) + dummy_headers.to_excel(writer, index=False, header=False) + df_staff[['ФИО', 'Подразделение', 'Должность']].to_excel(writer, startrow=8, index=False) + + with pd.ExcelWriter(absences_excel_path, engine='openpyxl') as writer: + dummy_headers = pd.DataFrame([[""] * 3] * 3) + dummy_headers.to_excel(writer, index=False, header=False) + df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False) + + return True + + except Exception as e: + logger.error(f"[❌] Ошибка прямого импорта из MS SQL 1С: {e}") + return False +``` + +## File: `./services/ai_verifier.py` +```py +import json +import re +import requests +import pandas as pd +from config import OLLAMA_URL, OLLAMA_MODEL +from core.database import get_all_rules_from_db +from core.database import get_department_synonyms_dict, add_department_synonym_to_db + +def resolve_department_exception_ai(dept_1c, dept_scud, exception_departments): + """ + Универсальный сопоставитель отделов. + Проверяет 1С, СКУД, локальную базу синонимов SQLite и задействует ИИ для сложных случайных аббревиатур. + """ + if not exception_departments: + return False + + d_1c = str(dept_1c).strip().lower() if dept_1c else "" + d_scud = str(dept_scud).strip().lower() if dept_scud else "" + exc_list = [d.strip().lower() for d in exception_departments] + + # 1. Прямая проверка: если точное имя или подстрока уже совпали в 1С или СКУД + for exc in exc_list: + if exc and (exc in d_1c or exc in d_scud or d_1c in exc or d_scud in exc): + return True + + # 2. Проверка по сохраненной Базе Знаний синонимов из SQLite + synonyms = get_department_synonyms_dict() + for exc in exc_list: + # Если в БД зафиксировано: 'овк' -> 'отдел внутреннего контроля' + full_from_db = synonyms.get(exc, "") + if full_from_db and (full_from_db in d_1c or full_from_db in d_scud): + return True + + # 3. Умный ИИ-арбитраж (если отдел спорный и еще не сохранен в БД) + if d_1c or d_scud: + dept_to_check = d_1c if d_1c else d_scud + prompt = f""" +Ты — кадровый аналитик. +Проверь, является ли отдел сотрудника "{dept_to_check}" тем же самым подразделением, что и один из отделов-исключений: {exception_departments}? + +Примеры: +- "Отдел внутреннего контроля" — это "ОВК" (Да) +- "Отдел технического обеспечения" — это "ОТО" (Да) + +Ответь СТРОГО в формате JSON: +{{ + "is_match": true/false, + "matched_exception": "Название из списка исключений", + "explanation": "краткое объяснение" +}} +""" + try: + raw_res = ask_ollama(prompt, system_prompt="Отвечай только валидным JSON.") + match = re.search(r'\{.*\}', raw_res, re.DOTALL) + if match: + data = json.loads(match.group(0)) + if data.get("is_match"): + matched_exc = data.get("matched_exception", "").lower() + # Запоминаем открытую ИИ связь в SQLite навсегда! + add_department_synonym_to_db(matched_exc, dept_to_check) + return True + except Exception as e: + print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}") + + return False + +def ask_ollama(prompt, system_prompt=None): + """ + Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5). + """ + payload = { + "model": OLLAMA_MODEL, + "prompt": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt, + "stream": False + } + + try: + response = requests.post(OLLAMA_URL, json=payload, timeout=120) + if response.status_code == 200: + data = response.json() + if "response" in data: + return data["response"].strip() + elif "message" in data and "content" in data["message"]: + return data["message"]["content"].strip() + else: + return f"⚠️ Неизвестная структура ответа Ollama: {data}" + else: + return f"⚠️ Ошибка Ollama (Код {response.status_code}): {response.text}" + except requests.exceptions.ConnectionError: + return "⚠️ Не удалось подключиться к Ollama. Проверьте, запущен ли сервис (ollama run qwen2.5:14b)." + except Exception as e: + return f"⚠️ Ошибка при обращении к Ollama: {e}" + + +def get_system_rules_context(): + """ + Загружает динамические системные правила компании напрямую из Базы Данных SQLite. + """ + rules = get_all_rules_from_db() + if not rules: + return "" + rules_text = "\n".join([f"- {r}" for r in rules]) + return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ (ИЗ SQLITE БД):\n{rules_text}\n" + + +def analyze_scud_mass_failure_ai(df_scud): + """ + Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке. + Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта. + """ + if df_scud is None or df_scud.empty: + return None + + total_records = len(df_scud) + anomaly_rows = df_scud[df_scud.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY'] + anomaly_count = len(anomaly_rows) + + if total_records == 0: + return None + + anomaly_percent = round((anomaly_count / total_records) * 100, 1) + + if anomaly_percent > 5.0 or anomaly_count >= 10: + rules_context = get_system_rules_context() + prompt = f""" +{rules_context} +ВНИМАНИЕ! Проведён анализ смены СКУД: +- Всего записей за смену: {total_records} +- Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью: {anomaly_count} ({anomaly_percent}% от смены) + +Сформируй понятное предупреждение для Администратора СКУД и Руководителя. +Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия. +""" + alert_text = ask_ollama(prompt, system_prompt="Ты — ИИ-аналитик контроллинга СКУД. Отвечай кратко и строго по делу.") + return { + "is_mass_failure": True, + "anomaly_count": anomaly_count, + "anomaly_percent": anomaly_percent, + "alert_text": alert_text + } + + return { + "is_mass_failure": False, + "anomaly_count": anomaly_count, + "anomaly_percent": anomaly_percent, + "alert_text": "Массовых сбоев оборудования не зафиксировано." + } + + +def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): + if not unrecognized_scud_fios or not staff_fios: + return {} + + # Исключаем точные совпадения + staff_fios_set = set(staff_fios) + real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set] + + if not real_unrecognized: + return {} + + rules_context = get_system_rules_context() + + prompt = f""" +Ты — кадровый аудитор безопасности СКУД. +{rules_context} +В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)} +В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)} + +СТРОГИЕ ПРАВИЛА: +1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон. +2. В СКУД допущена опечатка. +3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией. +4. В поле "warning" опиши обнаруженную опечатку. +5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев! + +ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON: +{{ + "verified_matches": [ + {{ + "scud_fio": "ФИО из СКУД", + "staff_fio": "эталон ФИО из Штат 1С", + "warning": "Описание опечатки в СКУД" + }} + ] +}} +""" + raw_response = ask_ollama( + prompt, + system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста." + ) + + mapping = {} + if not raw_response: + return mapping + + # Фаза 1: Попытка прямого разбора с санитарной очисткой + try: + match = re.search(r'\{.*\}', raw_response, re.DOTALL) + if match: + json_str = match.group(0) + # Убираем висячие запятые: {"a": 1,} -> {"a": 1} + json_str = re.sub(r',\s*([\}\]])', r'\1', json_str) + # Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости + json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str) + + data = json.loads(json_str) + for item in data.get("verified_matches", []): + scud_f = item.get("scud_fio") + staff_f = item.get("staff_fio") + warn = item.get("warning", "Точное совпадение (без опечаток)") + if scud_f and staff_f and staff_f in staff_fios_set: + mapping[scud_f] = {"staff_fio": staff_f, "warning": warn} + return mapping + except Exception: + pass + + # Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть) + try: + pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']' + matches = re.findall(pattern, raw_response, re.DOTALL) + for scud_f, staff_f in matches: + scud_clean = scud_f.strip() + staff_clean = staff_f.strip() + if staff_clean in staff_fios_set: + mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"} + except Exception as e: + print(f"[!] Ошибка резервного парсинга опечаток: {e}") + + return mapping + + +def ai_verify_department_exceptions(unexplained_df, exception_departments): + """ + Локальный ИИ проверяет, не являются ли неотмеченные отделы синонимами отделов-исключений. + """ + if unexplained_df.empty or not exception_departments: + return [] + + dept_list = unexplained_df['Подразделение'].dropna().unique().tolist() + rules_context = get_system_rules_context() + + prompt = f""" +{rules_context} +Ты — кадровый аудитор. +Список отделов-исключений компании: {json.dumps(exception_departments, ensure_ascii=False)} +Список отделов сотрудников, попавших в неизвестные: {json.dumps(dept_list, ensure_ascii=False)} + +Определи, какие из отделов сотрудников являются ПОЛНЫМИ НАЗВАНИЯМИ или СИНУНИМАМИ отделов-исключений (например: 'Отдел внутреннего контроля' — это 'ОВК'). + +Выдай ответ строго в формате JSON: +{{ + "matched_departments": ["Название отдела 1", "Название отдела 2"] +}} +""" + raw_response = ask_ollama(prompt, system_prompt="Выдавай только валидный JSON.") + + try: + match = re.search(r'\{.*\}', raw_response, re.DOTALL) + if match: + data = json.loads(match.group(0)) + return data.get("matched_departments", []) + except Exception as e: + print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}") + + return [] +``` + +## File: `./services/knowledge_base.py` +```py +import os +from core.database import get_all_rules_from_db, add_rule_to_db + + +def load_knowledge_base(): + """ + Загружает актуальные правила Базы Знаний компании напрямую из базы данных SQLite. + """ + rules = get_all_rules_from_db() + return { + "rules": rules if rules else [], + "fio_corrections": {} + } + + +def add_rule_to_kb(new_rule): + """ + Добавляет новое принятое человеком правило в SQLite таблицу ai_knowledge_base. + """ + if not new_rule or not new_rule.strip(): + return + add_rule_to_db(new_rule.strip(), added_by="Human") + print(f"[✓] База знаний SQLite успешно обновлена! Новое правило: {new_rule.strip()}") +``` + +## File: `./services/knowledge/service.py` +```py +""" +=============================================================================== +FILE: services/knowledge/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / knowledge +ROLE: Доменный сервис базы знаний, правил компании и синонимов подразделений. +=============================================================================== +""" + +from typing import List, Dict, Any +from core.repositories.zup_repo import ( + get_all_rules_from_db, + add_rule_to_db, + get_department_synonyms_dict, + add_department_synonym_to_db +) + + +def get_rules() -> List[Dict[str, Any]]: + """Получить все правила базы знаний в виде списка словарей.""" + raw_rules = get_all_rules_from_db() + return [{"id": idx, "rule_text": r} for idx, r in enumerate(raw_rules, 1)] + + +def add_rule(rule_text: str, added_by: str = "Human") -> None: + """Добавить новое правило в базу знаний.""" + add_rule_to_db(rule_text, added_by=added_by) + + +def get_synonyms() -> Dict[str, str]: + """Получить словарь синонимов отделов.""" + return get_department_synonyms_dict() + + +def register_department_synonym(short_name: str, full_name: str) -> None: + """Сохранить новую пару синонимов подразделения.""" + add_department_synonym_to_db(short_name, full_name) +``` + +## File: `./services/scud_etl/pipeline.py` +```py +""" +=============================================================================== +FILE: services/scud_etl/pipeline.py +ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов. +=============================================================================== +""" + +import os +import logging +import pandas as pd +from core.connection import get_connection +from services.data_loader import load_1c_data_smart + +logger = logging.getLogger("SCUD_PIPELINE") + + +def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame: + """Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y.""" + with get_connection() as conn: + df = pd.DataFrame() + cursor = conn.cursor() + + if prefer_final_y: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + if row and row[0]: + df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) + + if df.empty: + cursor.execute( + "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", + (date_str,) + ) + row = cursor.fetchone() + if row and row[0]: + df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],)) + + if not df.empty: + rename_map = { + 'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник', + 'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня', + 'time_in_building': 'Находился_в_здании', 'is_present': 'Пришел' + } + df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) + if 'fio_clean' not in df.columns and 'Сотрудник' in df.columns: + df['fio_clean'] = df['Сотрудник'].astype(str).str.strip() + if 'Пришел' in df.columns: + df['Пришел'] = df['Пришел'].astype(bool) + + return df + + +def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]: + df_staff, df_absences = load_1c_data_smart(date_str, use_db=False) + return (df_staff if df_staff is not None else pd.DataFrame(), + df_absences if df_absences is not None else pd.DataFrame()) +``` + +## File: `./services/scud_etl/merger.py` +```py +""" +=============================================================================== +FILE: services/scud_etl/merger.py +=============================================================================== +""" + +import logging +from typing import Dict, Any +import pandas as pd + +from services.knowledge.service import get_department_synonyms_dict +from config import normalize_fio, load_exceptions + +logger = logging.getLogger("SCUD_MERGER") + + +def merge_scud_and_1c( + df_scud: pd.DataFrame, + df_staff_1c: pd.DataFrame, + df_absences_1c: pd.DataFrame +) -> pd.DataFrame: + if (df_scud is None or df_scud.empty) and (df_staff_1c is None or df_staff_1c.empty): + return pd.DataFrame(columns=[ + 'Сотрудник', 'fio_clean', 'Подразделение', 'Должность', + 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', + 'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded' + ]) + + synonyms = get_department_synonyms_dict() + exceptions_cfg = load_exceptions() + + df_res = df_scud.copy() if df_scud is not None and not df_scud.empty else df_staff_1c.copy() + + if "Сотрудник" in df_res.columns: + df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio) + elif "ФИО" in df_res.columns: + df_res["Сотрудник"] = df_res["ФИО"] + df_res["fio_clean"] = df_res["ФИО"].apply(normalize_fio) + elif "fio_clean" not in df_res.columns: + df_res["fio_clean"] = "" + + for col, default_val in [ + ('Начало_дня', 'Нет входа'), + ('Первая_активность', '—'), + ('Конец_дня', 'Нет выхода'), + ('Находился_в_здании', '00:00'), + ('Пришел', False), + ('anomaly_flag', 'NONE') + ]: + if col not in df_res.columns: + df_res[col] = default_val + + # 1. Приведение отделов к аббревиатурам + reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()} + direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()} + all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"} + + if "Подразделение" in df_res.columns: + df_res["Подразделение"] = df_res["Подразделение"].apply( + lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip()) + ) + + # 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений + absences_map = {} + if not df_absences_1c.empty: + fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None) + reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None) + + if fio_col and reason_col: + for _, row in df_absences_1c.iterrows(): + fio = normalize_fio(str(row[fio_col])) + reason = str(row[reason_col]).strip() + if reason and reason.lower() != "nan": + absences_map[fio] = reason + + df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) + df_res["Вид_отсутствия"] = df_res["причина отсутствия"] + + # 3. Разметка исключений + exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] + exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d] + exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] + pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k] + whitelist_fios = [normalize_fio(f) for f in exceptions_cfg.get("include_fio", []) if f] + + df_res["is_excluded"] = False + for idx, row in df_res.iterrows(): + fio = row.get("fio_clean", "") + has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() != "" + + # ⭐️ Если сотрудник в белом списке ИЛИ у него есть официальное отсутствие — он НЕ исключается + if fio in whitelist_fios or has_official_absence: + df_res.at[idx, "is_excluded"] = False + continue + + dep = str(row.get("Подразделение", "")).upper() + pos = str(row.get("Должность", "")).lower() + + if fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw): + df_res.at[idx, "is_excluded"] = True + + # Чистое "Исключение" только для истинно исключенных без документов 1С + mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == "")) + df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение" + df_res.loc[mask_exc, "причина отсутствия"] = "Исключение" + + return df_res + + +def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: + active_df = df_merged[df_merged.get("is_excluded", False) == False].copy() + total_staff = len(active_df) + + # 1. Строго физически пришедшие в офис + came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа") + working_in_office = active_df[came_to_office_mask] + working_in_office_count = len(working_in_office) + + # 2. Не пришедшие в офис + df_not_came = active_df[~came_to_office_mask] + + # 3. Из не пришедших — удаленщики (работают из дома) + reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() + is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) + remote_home = df_not_came[is_remote_mask] + remote_home_count = len(remote_home) + + # 4. Из оставшихся — официальные отсутствия (отпуск, больничный, командировка) + df_remaining_absent = df_not_came[~is_remote_mask] + has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ + df_remaining_absent["причина отсутствия"].ne("") & \ + df_remaining_absent["причина отсутствия"].ne("nan") & \ + (~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение")) + official_absent_count = len(df_remaining_absent[has_doc_mask]) + + # 5. Неизвестные + unknown = df_remaining_absent[~has_doc_mask] + unknown_count = len(unknown) + + return { + "total_staff": total_staff, + "working_in_office_count": working_in_office_count, + "remote_home_count": remote_home_count, + "official_absent_count": official_absent_count, + "unknown_count": unknown_count, + "unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else [] + } +``` + +## File: `./services/scud_etl/anomaly_detector.py` +```py +""" +=============================================================================== +FILE: services/scud_etl/anomaly_detector.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / scud_etl +ROLE: Детектирование истинных аномалий и конфликтов реестров. + (Сотрудники из exceptions.json, удаленка и командировки исключены). +=============================================================================== +""" + +import logging +from typing import List, Dict, Any +import pandas as pd + +logger = logging.getLogger("SCUD_ANOMALY") + + +def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]: + """ + Выявляет реальные аномалии: + - Приход в офис во время отпуска или больничного листа. + - Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа). + """ + anomalies = [] + if df_merged is None or df_merged.empty: + return anomalies + + for _, row in df_merged.iterrows(): + # Пропускаем системные исключения + if row.get("is_excluded", False): + continue + + fio = row.get("fio_clean") or row.get("Сотрудник", "") + start_day = str(row.get("Начало_дня", "")).strip() + end_day = str(row.get("Конец_дня", "")).strip() + reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip() + reason_lower = reason.lower() + + # Пропускаем технические метки + if "исключен" in reason_lower or "овк" in reason_lower: + continue + + # ⭐️ Физический приход в офис: удаленка и командировки разрешены и НЕ являются аномалией + if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan": + if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower): + anomalies.append({ + "fio": fio, + "type": "PHYSICAL_PRESENCE_DURING_ABSENCE", + "description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'." + }) + + # Аномалия оборудования (есть выход без утреннего входа) + if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]: + anomalies.append({ + "fio": fio, + "type": "SCUD_EQUIPMENT_ANOMALY", + "description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})." + }) + + return anomalies +``` + +## File: `./services/snapshots/service.py` +```py +""" +=============================================================================== +FILE: services/snapshots/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / snapshots +ROLE: Бизнес-логика срезов СКУД (выборка, валидация Y-срезов, удаление). + +AI-CONTEXT-ANCHORS: + - ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов. + - ANCHOR[SNAPSHOT_DELETE_SAFE]: Безопасное удаление дневных срезов с защитой итоговых. +=============================================================================== +""" + +from typing import Dict, Any, Optional, List +from core.connection import get_connection +from core.repositories.scud_repo import get_available_snapshots, delete_snapshot_by_id + + +# ANCHOR[SNAPSHOT_GET_REGISTRY] +def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]: + """Возвращает реестр снапшотов за дату или за все доступные дни.""" + clean_date = date_str.strip() if date_str else "" + rows = get_available_snapshots(date_str=clean_date if clean_date else None) + + snapshots = [ + { + "snapshot_id": r[0], + "log_date": r[1], + "snapshot_time": r[2], + "record_count": r[3], + "is_final": str(r[0]).startswith("Y") + } + for r in rows + ] + + return { + "query_date": clean_date or "все", + "snapshots_count": len(snapshots), + "snapshots": snapshots + } + + +# ANCHOR[SNAPSHOT_DELETE_SAFE] +def delete_snapshots_safely(snapshot_ids: List[str]) -> Dict[str, Any]: + """ + Удаляет выбранные дневные снапшоты. + Итоговые вечерние срезы с префиксом 'Y' гарантированно защищены от удаления. + """ + if not snapshot_ids: + return {"status": "error", "message": "Не указаны ID снапшотов для удаления."} + + safe_ids = [str(s).strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")] + + if not safe_ids: + return {"status": "error", "message": "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."} + + with get_connection() as conn: + cursor = conn.cursor() + placeholders = ",".join(["?"] * len(safe_ids)) + cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids) + deleted_count = cursor.rowcount + conn.commit() + + return { + "status": "success", + "deleted_count": deleted_count, + "deleted_ids": safe_ids, + "message": f"Успешно удалено снапшотов: {len(safe_ids)} шт." + } +``` + +## File: `./services/tasks/repository.py` +```py +""" +=============================================================================== +FILE: services/tasks/repository.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / tasks +ROLE: Низкоуровневые операции к таблице tasks в SQLite (CRUD). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_REPO_GET]: Выборка задач с фильтрацией по статусу и пользователю. + - ANCHOR[TASK_REPO_ADD]: Вставка новой задачи со сквозным ID. + - ANCHOR[TASK_REPO_UPDATE]: Обновление реквизитов и статуса задачи. + - ANCHOR[TASK_REPO_DELETE]: Удаление задачи по числовому или строковому ID. +=============================================================================== +""" + +import re +from typing import List, Dict, Any, Optional +from core.connection import get_connection + + +def normalize_task_id(task_id_input: str) -> str: + """Нормализует идентификатор задачи к формату TASK-XX.""" + if not task_id_input: + return "" + clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "") + if clean_id.isdigit(): + num = int(clean_id) + return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}" + return f"TASK-{clean_id}" + + +# ANCHOR[TASK_REPO_GET] +def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: + """Получает список задач пользователя с опциональной фильтрацией по статусу.""" + conn = get_connection(row_factory=True) + cursor = conn.cursor() + + if status and status.upper() != "ALL": + target_status = status.upper() + if target_status in ["PROGRESS", "В РАБОТЕ"]: + target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: + target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ"]: + target_status = "BACKLOG" + + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED')) + ORDER BY id DESC + """, (user_id, target_status, target_status)) + else: + cursor.execute(""" + SELECT id, task_id, module, title, priority, status, due_date, created_at + FROM tasks + WHERE user_id = ? + ORDER BY id DESC + """, (user_id,)) + + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ANCHOR[TASK_REPO_ADD] +def repo_add_task( + user_id: int, + module: str, + title: str, + priority: str = "MEDIUM", + due_date: Optional[str] = None, + status: str = "BACKLOG" +) -> Dict[str, Any]: + """Добавляет новую задачу в SQLite с автогенерацией порядкового TASK-ID.""" + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT MAX(id) FROM tasks") + max_id = cursor.fetchone()[0] or 0 + new_task_id = f"TASK-{(max_id + 1):02d}" + + target_status = status.upper() if status else "BACKLOG" + if target_status in ["PROGRESS", "В РАБОТЕ"]: + target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: + target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]: + target_status = "BACKLOG" + + cursor.execute(""" + INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id)) + conn.commit() + conn.close() + return {"status": "success", "task_id": new_task_id, "id": max_id + 1} + + +# ANCHOR[TASK_REPO_UPDATE] +def repo_update_task( + user_id: int, + task_id: str, + title: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + due_date: Optional[str] = None +) -> Dict[str, Any]: + """Комплексное обновление атрибутов задачи.""" + conn = get_connection() + cursor = conn.cursor() + + clean_num = re.sub(r'\D', '', str(task_id)) + formatted_id = normalize_task_id(task_id) + + updates = [] + params = [] + + if title is not None and title.strip(): + updates.append("title = ?") + params.append(title.strip()) + + if priority is not None and priority.strip(): + updates.append("priority = ?") + params.append(priority.strip().upper()) + + if status is not None and status.strip(): + target_status = status.strip().upper() + if target_status in ["PROGRESS", "В РАБОТЕ"]: + target_status = "IN_PROGRESS" + elif target_status in ["DONE", "ГОТОВО"]: + target_status = "COMPLETED" + elif target_status in ["PLANNED", "ПЛАНЫ"]: + target_status = "BACKLOG" + updates.append("status = ?") + params.append(target_status) + + if due_date is not None: + updates.append("due_date = ?") + params.append(due_date.strip() if due_date.strip() else None) + + if not updates: + conn.close() + return {"status": "success", "message": "Нет данных для обновления"} + + params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id]) + sql = f""" + UPDATE tasks + SET {', '.join(updates)} + WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """ + cursor.execute(sql, params) + rows_affected = cursor.rowcount + conn.commit() + conn.close() + + if rows_affected == 0: + return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} + + return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"} + + +# ANCHOR[TASK_REPO_DELETE] +def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: + """Удаляет задачу по номеру ID.""" + conn = get_connection() + cursor = conn.cursor() + clean_num = re.sub(r'\D', '', str(task_id)) + formatted_id = normalize_task_id(task_id) + + cursor.execute(""" + DELETE FROM tasks + WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? + """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id)) + + deleted = cursor.rowcount + conn.commit() + conn.close() + + if deleted == 0: + return {"error": f"Задача {task_id} не найдена"} + return {"status": "success", "message": f"Задача #{task_id} удалена"} +``` + +## File: `./services/tasks/service.py` +```py +""" +=============================================================================== +FILE: services/tasks/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / tasks +ROLE: Единый доменный сервис задач (бизнес-логика и диспетчер операций). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_SERVICE_DISPATCHER]: Маршрутизация действий ADD/UPDATE/DELETE/EXPORT. +=============================================================================== +""" + +from typing import Dict, Any, Optional, List +from .repository import repo_get_tasks, repo_add_task, repo_update_task, repo_delete_task +from .exporter import export_tasks_to_markdown + + +def get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: + """Получить список задач.""" + return repo_get_tasks(user_id, status) + + +def add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None, status: str = "BACKLOG") -> Dict[str, Any]: + """Создать задачу.""" + res = repo_add_task(user_id, module, title, priority, due_date, status) + return {"status": "success", "task_id": res["task_id"], "message": f"Задача #{res['id']} создана и добавлена в планы"} + + +def update_task_details(user_id: int, task_id: str, title: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, due_date: Optional[str] = None) -> Dict[str, Any]: + """Обновить задачу.""" + return repo_update_task(user_id, task_id, title, priority, status, due_date) + + +def delete_task(user_id: int, task_id: str) -> Dict[str, Any]: + """Удалить задачу.""" + return repo_delete_task(user_id, task_id) + + +# ANCHOR[TASK_SERVICE_DISPATCHER] +def execute_task_action( + user_id: int, + action: str, + task_id: Optional[str] = None, + title: Optional[str] = None, + priority: Optional[str] = "MEDIUM", + status: Optional[str] = None, + module: Optional[str] = "general", + due_date: Optional[str] = None, + filename: Optional[str] = "ROADMAP.md" +) -> Dict[str, Any]: + """Консолидированный диспетчер операций над задачами.""" + act = (action or "").strip().upper() + + if act == "ADD": + if not title: + return {"status": "error", "message": "Для создания задачи требуется указать title"} + return add_task(user_id, module or "general", title, priority or "MEDIUM", due_date, status or "BACKLOG") + + elif act == "UPDATE": + if not task_id: + return {"status": "error", "message": "Для обновления требуется указать task_id"} + return update_task_details(user_id, str(task_id), title, priority, status, due_date) + + elif act == "DELETE": + if not task_id: + return {"status": "error", "message": "Для удаления требуется указать task_id"} + return delete_task(user_id, str(task_id)) + + elif act == "EXPORT": + return export_tasks_to_markdown(user_id, filename=filename or "ROADMAP.md", status_filter=status) + + return {"status": "error", "message": f"Неизвестное действие action='{action}'"} +``` diff --git a/exceptions.json b/exceptions.json index 742ba5f..ab43c66 100644 --- a/exceptions.json +++ b/exceptions.json @@ -7,10 +7,9 @@ "Уборщик служебных помещений" ], "fio": [ - "Таткало Валерий Валерьевич", - "Петренюк Андрей Германович", - "Чуркина Елена Геннадьевна", - "Михалев Сергей Геннадьевич" + "Таткало Валерий Валерьевич", + "Петренюк Андрей Германович", + "Михалев Сергей Геннадьевич" ], "position_keywords": [ "уборщик", @@ -18,5 +17,9 @@ "дворник", "гардероб", "рабочий по обслуживанию" + ], + "include_fio": [ + "Тарасенко Александр Александрович", + "Журиков Михаил Николаевич" ] } \ No newline at end of file diff --git a/main_etl.py b/main_etl.py index 3a1b6f5..3b6e194 100644 --- a/main_etl.py +++ b/main_etl.py @@ -1,571 +1,116 @@ +""" +=============================================================================== +FILE: main_etl.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП. +=============================================================================== +""" + import os import sys -import json import argparse +import logging import pandas as pd from datetime import datetime, timedelta -sys.path.append(os.path.dirname(os.path.abspath(__file__))) +from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date +from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics +from services.scud_etl.anomaly_detector import detect_registry_anomalies +from services.text_reporter import generate_markdown_report -from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio from services.scud_export import run_export from services.share_copier import copy_1c_files_from_share -from services.data_validator import check_file_freshness -from services.data_loader import load_1c_data_smart, load_scud_data from services.excel_exporter import generate_summary_excel, generate_detailed_excel -from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai -from services.text_reporter import generate_markdown_report -from services.feedback_loop import review_ai_decisions -from services.knowledge_base import load_knowledge_base -from core.database import ( - init_db, - save_scud_to_db, - save_staff_to_db, - save_absences_to_db, - save_anomalies_to_db, - load_scud_from_db_by_snapshot, - load_staff_from_db, - load_absences_from_db, - get_latest_snapshot_time, - has_scud_logs_for_date -) - -def load_exceptions_config(): - """Загружает файл exceptions.json из корня проекта.""" - root_dir = os.path.dirname(os.path.abspath(__file__)) - json_path = os.path.join(root_dir, "exceptions.json") - if not os.path.exists(json_path): - return {} - try: - with open(json_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - print(f"[⚠️] Ошибка чтения exceptions.json: {e}") - return {} - - -def apply_exceptions_from_json(df, exceptions_cfg): - """ - Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json. - Выполняется мгновенно без цикличных HTTP-запросов к ИИ. - """ - if df is None or df.empty or not exceptions_cfg: - if df is not None: - df['is_excluded'] = False - return df - - deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d] - exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] - pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k] - exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] - - df['is_excluded'] = False - - for idx, row in df.iterrows(): - fio_clean = row.get('fio_clean', '') - dep_1c = str(row.get('Подразделение', '')).lower() - dep_scud = str(row.get('department_scud', row.get('department', ''))).lower() - pos = str(row.get('Должность', '')).lower() - - is_fio_exc = fio_clean in exc_fios - is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False - - # Быстрая проверка отделов по подстрокам - is_dep_exc = False - if deps: - is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps) - - if is_fio_exc or is_dep_exc or is_pos_exc: - df.at[idx, 'is_excluded'] = True - - return df - - -def filter_report_dataframe(merged_df): - """ - Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета, - ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП. - """ - if merged_df is None or merged_df.empty: - return merged_df - - has_1c_reason = ( - merged_df['Вид_отсутствия'].notna() & - (merged_df['Вид_отсутствия'].astype(str).str.strip() != '') & - (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ) - is_not_excluded = merged_df.get('is_excluded', False) == False - is_not_no_pass = merged_df.get('no_scud_pass', False) == False - - filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy() - return filtered_df - - -def load_static_reason_workers(): - """Загружает реестр удалёнщиков и статических причин из CSV.""" - static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") - if not os.path.exists(static_path): - return {} - try: - df_static = pd.read_csv(static_path, encoding='utf-8') - if 'fio' in df_static.columns and 'reason' in df_static.columns: - df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) - return dict(zip(df_static['fio_clean'], df_static['reason'])) - except Exception as e: - print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}") - return {} - - -def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None): - """ - Автоматически выявляет истинные аномалии СКУД ⟷ 1С. - """ - anomalies = [] - kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" - - ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк'] - - for idx, row in merged_df.iterrows(): - fio = row.get('Сотрудник', row.get('fio_clean', '')) - fio_clean = row.get('fio_clean', '') - is_present = row.get('Пришел', False) - is_exc = row.get('is_excluded', False) - reason_1c = str(row.get('Вид_отсутствия', '')).strip() - has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение') - anom_flag = row.get('anomaly_flag', 'NONE') - - is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text - - if is_present and has_1c_reason: - reason_lower = reason_1c.lower() - is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS) - - if not is_allowed_trip and not is_fio_whitelisted_in_kb: - anomalies.append({ - "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", - "fio": fio, - "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" - }) - - if is_exc and not has_1c_reason: - continue - - if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': - first_act = row.get('Первая_активность', '—') - anomalies.append({ - "type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА", - "fio": fio, - "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}" - }) - - if scud_fios_set is not None: - if fio_clean not in scud_fios_set and not has_1c_reason: - anomalies.append({ - "type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO", - "fio": fio, - "details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)" - }) - - return anomalies - - -def aggregate_scud_by_employee(df, debug=False): - """Агрегирует проходы СКУД по уникальным сотрудникам.""" - if df is None or df.empty or 'fio_clean' not in df.columns: - return df - - aggregated = [] - for fio_clean, group in df.groupby('fio_clean', sort=False): - is_present = group['Пришел'].any() if 'Пришел' in group.columns else False - - if is_present and 'Пришел' in group.columns: - present_rows = group[group['Пришел'] == True] - best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict() - else: - best_row = group.iloc[0].to_dict() - - best_row['Пришел'] = is_present - aggregated.append(best_row) - - return pd.DataFrame(aggregated) +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") def main(): - help_text = """ -Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2) - -ПРИМЕРЫ ЗАПУСКА: - python main.py -- Обычный дневной запуск - python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite - python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота - python main.py -d -- Запуск в режиме расширенной отладки (DEBUG) -""" - parser = argparse.ArgumentParser( - description=help_text, - formatter_class=argparse.RawDescriptionHelpFormatter - ) - parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов") - parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту") - parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания") - + parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С") + parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки") + parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL") + parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета") args = parser.parse_args() - DEBUG = args.debug print("=" * 60) - print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") + print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}") print("=" * 60) - init_db() - kb_data = load_knowledge_base() - kb_rules = kb_data.get("rules", []) - exceptions_cfg = load_exceptions_config() - - if args.snapshot: - snapshot_param = args.snapshot - print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'") - elif args.skip_export: - snapshot_param = get_latest_snapshot_time() - print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')") + now = datetime.now() + today_str = now.strftime("%d.%m.%Y") + # ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня) + if now.weekday() == 0: + yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y") else: - snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") + yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") + # [Этап 0] Выгрузка свежих данных СКУД if not args.skip_export and not args.snapshot: - try: - run_export(save_xlsx=True, debug=DEBUG) - except Exception as e: - print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.") + print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...") + run_export(debug=args.debug) else: - print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") + print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...") - # ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА + # [Этап 0.5] Синхронизация файлов с шары 1С if not args.snapshot and not args.skip_export: + print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...") copy_1c_files_from_share() - - print("[1/5] Проверка актуальности и свежести входных данных...") - is_valid, warnings, errors, has_today_1c = check_file_freshness() - - if warnings: - print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---") - for w in warnings: - print(f" • {w}") - print("-" * 45) - - if not is_valid: - print("\n" + "!" * 60) - print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!") - for e in errors: - print(f" {e}") - print("!" * 60) - sys.exit(1) - - print("[✓] Проверка доступности данных успешно пройдена!\n") else: - print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...") - has_today_1c = True + print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...") - print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") - - # 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ - if args.snapshot or args.skip_export: - raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param) - - if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns: - target_date_str = str(raw_scud_today_df['log_date'].iloc[0]) - else: - target_date_str = DATE_TODAY + # [Этап 1-2] Загрузка данных + print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...") + df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True) + df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False) - dt_target = datetime.strptime(target_date_str, "%d.%m.%Y") - if dt_target.weekday() == 0: # Понедельник -> Пятница - dt_yesterday = dt_target - timedelta(days=3) - else: - dt_yesterday = dt_target - timedelta(days=1) - yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y") + df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str) + df_staff_today, df_abs_today = load_1c_files_for_date(today_str) - print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}") + # [Этап 3] Детальный отчет за вчера + print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") + df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) + generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str) - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) + # [Этап 4] Сводка за сегодня + print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") + df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) + metrics_today = calculate_summary_metrics(df_merged_today) + anomalies_today = detect_registry_anomalies(df_merged_today) + generate_summary_excel(df_merged_today, date_str=today_str) - # Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк) - df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True) - df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True) - else: - target_date_str = DATE_TODAY - yesterday_date_str = DATE_YESTERDAY + # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора + print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...") - raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param) - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) + absent_explained = df_merged_today[ + (df_merged_today['Пришел'] == False) & + (df_merged_today['Вид_отсутствия'].notna()) & + (~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) + ] + absent_unexplained = df_merged_today[ + (df_merged_today['Пришел'] == False) & + (df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & + (df_merged_today.get('is_excluded', False) == False) + ] - df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False) - df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False) - - if df_staff_yesterday is not None: - save_staff_to_db(df_staff_yesterday, yesterday_date_str) - if df_absent_yesterday is not None: - save_absences_to_db(df_absent_yesterday, yesterday_date_str) - - if df_staff_today is not None: - save_staff_to_db(df_staff_today, target_date_str) - if df_absent_today is not None: - save_absences_to_db(df_absent_today, target_date_str) - - staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else [] - static_reasons_dict = load_static_reason_workers() - - # ============================================================ - # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ) - # ============================================================ - print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...") - - if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False - - scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else [] - - fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean) - if fio_mapping_scud_y: - raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply( - lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x - ) - - if df_absent_yesterday is not None and not df_absent_yesterday.empty: - absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() - if absent_unrecognized_yesterday: - fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean) - if fio_mapping_absent_y: - df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply( - lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x - ) - - raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG) - - # ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ - merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame()) - - if not merged_yesterday.empty: - if not raw_scud_yesterday_df.empty: - if 'Подразделение' in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение'] - elif 'department' in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department'] - else: - raw_scud_yesterday_df['department_scud'] = '' - - merged_yesterday = merged_yesterday.merge( - raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']], - on='fio_clean', how='left' - ) - - if 'Вид_отсутствия' in merged_yesterday.columns: - merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия']) - - # Присоединяем отсутствия СТРОГО за дату yesterday_date_str - if df_absent_yesterday is not None and not df_absent_yesterday.empty: - merged_yesterday = merged_yesterday.merge( - df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], - on='fio_clean', - how='left' - ) - - if 'Пришел' not in merged_yesterday.columns: - merged_yesterday['Пришел'] = False - else: - merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False) - - if 'Сотрудник' not in merged_yesterday.columns: - merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean']) - - if static_reasons_dict: - for fio_clean, reason_val in static_reasons_dict.items(): - mask_yesterday = ( - (merged_yesterday['Пришел'] == False) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday['fio_clean'] == fio_clean) - ) - merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val - - merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg) - mask_exc_yesterday = ( - (merged_yesterday['Пришел'] == False) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday.get('is_excluded', False) == True) + summary_md = generate_markdown_report( + merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False], + absent_explained=absent_explained, + absent_unexplained=absent_unexplained, + scud_present_but_absent_in_1c=pd.DataFrame(), + anomalies_list=anomalies_today, + date_str=today_str ) - merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + + os.makedirs("output", exist_ok=True) + md_file_path = f"output/Сводка_контроллинга_{today_str}.md" + with open(md_file_path, "w", encoding="utf-8") as f: + f.write(summary_md) + print(f"[✓] Текстовый отчет сохранен в: {md_file_path}") - scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set() - merged_yesterday['no_scud_pass'] = ( - (~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday.get('is_excluded', False) == False) - ) - - anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set) - save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str) - - filtered_yesterday = filter_report_dataframe(merged_yesterday) - generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str) - - yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y") - yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00") - save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True) - print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}") - - # ============================================================ - # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА) - # ============================================================ - if not args.snapshot and not args.skip_export: - save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False) - - if has_today_1c and df_staff_today is not None and df_absent_today is not None: - print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...") - staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist() - - if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns: - raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False - - scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else [] - fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean) - if fio_mapping_scud: - raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply( - lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x - ) - - absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() - if absent_unrecognized_today: - fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean) - if fio_mapping_absent: - df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply( - lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x - ) - - raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG) - - merged_today = df_staff_today.copy() - if not raw_scud_today_df.empty: - if 'Подразделение' in raw_scud_today_df.columns: - raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение'] - elif 'department' in raw_scud_today_df.columns: - raw_scud_today_df['department_scud'] = raw_scud_today_df['department'] - else: - raw_scud_today_df['department_scud'] = '' - - merged_today = merged_today.merge( - raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']], - on='fio_clean', how='left' - ) - - if df_absent_today is not None and not df_absent_today.empty: - merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') - - if 'Пришел' not in merged_today.columns: - merged_today['Пришел'] = False - else: - merged_today['Пришел'] = merged_today['Пришел'].fillna(False) - - if 'Сотрудник' not in merged_today.columns: - merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean']) - - if static_reasons_dict: - for fio_clean, reason_val in static_reasons_dict.items(): - mask_today = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today['fio_clean'] == fio_clean) - ) - merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val - - merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg) - mask_exc_today = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today.get('is_excluded', False) == True) - ) - merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - - scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() - merged_today['no_scud_pass'] = ( - (~merged_today['fio_clean'].isin(scud_fios_today_set)) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today.get('is_excluded', False) == False) - ) - - scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() - anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set) - - mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df) - if mass_failure_today and mass_failure_today.get("is_mass_failure"): - print("\n" + "!" * 60) - print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)") - print(mass_failure_today["alert_text"]) - print("!" * 60 + "\n") - - save_anomalies_to_db(anomalies_list, target_date_str) - - is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False - is_exc_today = merged_today.get('is_excluded', False) == True - - absent_explained = merged_today[ - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].notna()) & - (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ] - - absent_unexplained = merged_today[ - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (~is_no_pass_today) & - (~is_exc_today) - ] - - scud_present_but_absent_in_1c = merged_today[ - (~is_exc_today) & - (merged_today['Пришел'] == True) & - (merged_today['Вид_отсутствия'].notna()) & - (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ] - - print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...") - filtered_anomalies_list = [ - a for a in anomalies_list - if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '') - ] - - report_text = generate_markdown_report( - merged_df=merged_today, - absent_explained=absent_explained, - absent_unexplained=absent_unexplained, - scud_present_but_absent_in_1c=scud_present_but_absent_in_1c, - anomalies_list=filtered_anomalies_list, - raw_scud_df=raw_scud_today_df, - raw_absent_df=df_absent_today, - date_str=target_date_str - ) - - generate_summary_excel(merged_df=merged_today, date_str=target_date_str) - - md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md") - with open(md_report_path, "w", encoding="utf-8") as f: - f.write(report_text) - print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") - - suspicious_cases = [] - for a in anomalies_list: - if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''): - suspicious_cases.append({ - 'fio_target': a['fio'], - 'reason': f"{a['type']}: {a['details']}" - }) - - if suspicious_cases: - review_ai_decisions(report_text, suspicious_cases) - - print("\n" + "=" * 60) - print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:") - print("=" * 60) - print(report_text) - else: - print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.") + print("\n" + "=" * 60) + print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:") + print("=" * 60) + print(summary_md) if __name__ == "__main__": diff --git a/modules/ROADMAP.md b/modules/ROADMAP.md new file mode 100644 index 0000000..65cbe6d --- /dev/null +++ b/modules/ROADMAP.md @@ -0,0 +1,56 @@ +# 🗺️ Дорожная карта задач проекта (ROADMAP) + +> **Сформировано:** 2026-08-19 18:40 | **Всего задач:** 16 + +--- + +## Модуль `api` + +- [x] **#35** [HIGH] `[ЗАВЕРШЕНО]` Создание REST API на FastAPI (/api/v1/snapshots, /api/v1/scud/logs, /api/v1/anomalies) + +--- + +## Модуль `core/database` + +- [x] **#22** [HIGH] `[ЗАВЕРШЕНО]` Перевод подключения 1С:ЗУП на продакшн MS SQL сервер ACCOUNT-01 + гибридный фоллбэк +- [x] **#37** [MEDIUM] `[ЗАВЕРШЕНО]` Создание модуля персистентной памяти проекта в SQLite и CLI-инструмента для фиксации архитектурных решений + +--- + +## Модуль `general` + +- [x] **#38** [HIGH] `[ЗАВЕРШЕНО]` По запросу, вывести возможности системы, которые уже настроены. Например на запрос о возможностях по СКУД. И изменить стартовую плашку. +- [ ] **#41** [MEDIUM] Полностью отказаться от tool_injector.py и перевести все вызовы на нативный Function Calling языковой модели. LLM будет основным арбитром *(срок: 2026-08-21)* +- [ ] **#44** [LOW] Тестовая-3 *(срок: 2026-08-20)* + +--- + +## Модуль `scripts` + +- [x] **#26** [MEDIUM] `[ЗАВЕРШЕНО]` Создание и выполнение скрипта сквозной переиндексации снапшотов с префиксом Y +- [x] **#27** [MEDIUM] `[ЗАВЕРШЕНО]` Реализация CLI-инспектора БД и выравнивание номеров срезов для идеальной верстки в терминале +- [ ] **#33** [MEDIUM] Автоматизация сбора срезов по расписанию (Cron / Systemd Timers на 12:00, 17:00, 19:00) + +--- + +## Модуль `services/ai_verifier` + +- [x] **#28** [HIGH] `[ЗАВЕРШЕНО]` Двойная проверка исключений по department_scud и 1С, удержание 100% приоритета кадровых документов + +--- + +## Модуль `services/scud_export` + +- [x] **#23** [HIGH] `[ЗАВЕРШЕНО]` Инцидент Равина В.Э.: Фиксация 100% приоритета сырых логов pLogData СКУД над расчетом УРВ Ориона +- [x] **#25** [HIGH] `[ЗАВЕРШЕНО]` Исправление бага дублирования номеров снапшотов (переход на вычисление MAX() по суффиксу) +- [x] **#29** [LOW] `[ЗАВЕРШЕНО]` Лаконичный вывод неразмеченных сотрудников с короткими аббревиатурами отделов СКУД (РУК, ОИЗ, ПУ и др.) +- [ ] **#30** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Извлечение полных цепочек проходов pLogData (входы/выходы за смену) +- [ ] **#31** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Расчет количества и суммарной длительности отлучек/перекуров сверх обеда + +--- + +## Модуль `services/text_reporter` + +- [ ] **#32** [MEDIUM] `[В РАБОТЕ]` Внутридневной контроль: Вывод списка системных нарушителей внутреннего распорядка в Markdown-отчет + +--- diff --git a/modules/ai_engine/__init__.py b/modules/ai_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/ai_engine/agent.py b/modules/ai_engine/agent.py new file mode 100644 index 0000000..16f55fb --- /dev/null +++ b/modules/ai_engine/agent.py @@ -0,0 +1,178 @@ +""" +=============================================================================== +FILE: modules/ai_engine/agent.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine +ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers + и менеджер свободных диалогов (Topic Drift). + +AI-CONTEXT-ANCHORS: + - ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message. +=============================================================================== +""" + +import sys +import json +import logging +from typing import List, Dict, Any, Tuple, Optional + +from modules.web_api.llm.schemas import TOOLS_SCHEMA +from modules.web_api.llm.core.ollama_client import call_ollama_chat +from modules.web_api.llm.core.fast_path import handle_fast_path_intercept +from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed +from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages +from modules.web_api.llm.db.db_prompts import ( + db_get_session_state, db_clear_session_state, db_set_session_state, + db_get_stats, db_get_anomalies, db_get_reference +) +from services.knowledge.service import get_rules + +from .context_builder import build_agent_system_context +from .handlers.task_handler import handle_tasks_call +from .handlers.prompt_handler import handle_prompt_call +from .handlers.snapshot_handler import handle_snapshots_call + +logger = logging.getLogger("AI_AGENT") +logger.setLevel(logging.INFO) + + +# ANCHOR[AGENT_PIPELINE_ENTRY] +def process_chat_message( + user_id: int, + user_message: str, + file_context: str = "", + image_b64: Optional[str] = None, + chat_history: List[Dict[str, Any]] = None, + session_id: str = "web_session_main" +) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Главный конвейер обработки сообщений чата.""" + logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_message}") + + session_state = db_get_session_state(session_id) + if not session_state: + db_purge_ephemeral_messages(session_id) + + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message + + # 1. Быстрый Fast-Path перехват кнопок подтверждения + fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) + if fast_path_res: + return fast_path_res + + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_history = db_get_chat_history(session_id, limit=20) + system_prompt = build_agent_system_context(user_id, session_state) + + user_msg_obj = {"role": "user", "content": full_user_content} + + try: + if image_b64: + user_msg_obj["images"] = [image_b64] + messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj] + msg = call_ollama_chat(messages, is_vision=True) + else: + clean_history = [dict(m) for m in db_history] + for m in clean_history: m.pop("images", None) + messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj] + msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) + + raw_reply = msg.get("content", "") + tool_calls = msg.get("tool_calls", []) + + # 2. Гибридный семантический классификатор намерений (Fallback Safety Net) + tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls) + + # 3. Исполнение инструментов через изолированные handlers + if tool_calls: + tool = tool_calls[0] + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + if isinstance(fn_args, str): + try: fn_args = json.loads(fn_args) + except Exception: fn_args = {} + + logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}") + close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") + mark_last_user_message_ephemeral(session_id) + + state_data = session_state.get("data_json") or {} if session_state else {} + + if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]: + return handle_tasks_call(fn_name, fn_args, user_id, session_id) + + elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]: + return handle_prompt_call(fn_name, fn_args, session_id) + + elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]: + return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data) + + elif fn_name == "db_get_rules": + res_str = json.dumps(get_rules(), ensure_ascii=False) + elif fn_name == "db_get_stats": + res_str = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_anomalies": + res_str = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + elif fn_name == "db_get_reference": + res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + else: + res_str = "{}" + + messages.append(msg) + messages.append({"role": "tool", "content": res_str}) + sec_msg = call_ollama_chat(messages, is_vision=False) + final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен." + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0) + return final_content, db_get_chat_history(session_id), None + + # 4. Обычный содержательный диалог и управление Topic Drift + final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан." + for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]: + if final_reply.lower().startswith(artifact): + final_reply = final_reply[len(artifact):].lstrip(",.!?:; -") + + action_payload = None + + # Обработка ответа "нет / спасибо" в режиме открытого инструмента + if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]): + close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL") + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) + return final_reply, db_get_chat_history(session_id), None + + # Инкремент счётчика шагов в сторону от инструмента (Topic Drift) + if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]: + state_type = session_state.get("state_type") + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} + + idle_turns = state_data.get("idle_turns", 0) + 1 + state_data["idle_turns"] = idle_turns + + if idle_turns >= 4: + # 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки + close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT") + elif idle_turns == 3: + # 3-й шаг: выводим вежливое напоминание с кнопками + tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД" + guard_question = f"Желаете продолжить работу с {tool_label}?" + final_reply += f"\n\n💡 *Напоминание:* {guard_question}" + action_payload = { + "type": "FOLLOW_UP_ACTION", + "buttons": [ + {"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"}, + {"label": "Завершить", "value": "нет, спасибо", "style": "secondary"} + ] + } + db_set_session_state(session_id, state_type, state_data) + else: + # 1-й и 2-й шаг: фиксируем обновленный счётчик + db_set_session_state(session_id, state_type, state_data) + + is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0 + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply) + return final_reply, db_get_chat_history(session_id), action_payload + + except Exception as ex: + logger.exception(f"Ошибка в агенте: {ex}") + return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None \ No newline at end of file diff --git a/modules/ai_engine/context_builder.py b/modules/ai_engine/context_builder.py new file mode 100644 index 0000000..5b0fd48 --- /dev/null +++ b/modules/ai_engine/context_builder.py @@ -0,0 +1,84 @@ +""" +=============================================================================== +FILE: modules/ai_engine/context_builder.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine +ROLE: Динамическая сборка системного промпта из БД, календаря и контекста сессий. + +AI-CONTEXT-ANCHORS: + - ANCHOR[CONTEXT_BUILDER_MAIN]: Формирование полного системного контекста для LLM. +=============================================================================== +""" + +from typing import Dict, Any, Optional +from services.prompts.service import get_active_system_prompt +from modules.web_api.llm.core.calendar_utils import get_dynamic_calendar_context + + +# ANCHOR[CONTEXT_BUILDER_MAIN] +def build_agent_system_context( + user_id: int, + session_state: Optional[Dict[str, Any]] +) -> str: + """Формирует полный динамический системный контекст агента с правилами из БД.""" + + # 1. Загружаем актуальный базовый системный промпт из SQLite БД + base_system_prompt = get_active_system_prompt() + + # 2. Серверный календарь и контекст дат + calendar_context = get_dynamic_calendar_context() + user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + + # 3. Контекст текущего активного стейта сессии + current_state_type = session_state.get("state_type") if session_state else None + state_data = session_state.get("data_json") or {} if session_state else {} + if not isinstance(state_data, dict): + state_data = {} + + active_state_context = "" + if current_state_type == "PROMPT_PREVIEW": + active_state_context = ( + "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n" + "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n" + ) + elif current_state_type == "PROMPT_FOLLOWUP": + active_state_context = ( + "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n" + "- Оператор просматривает или редактирует системный промпт.\n" + "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n" + "- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n" + "- Запрещено путать ADD и DELETE.\n" + ) + elif current_state_type == "SNAPSHOTS_VIEW": + active_date = state_data.get("query_date", "выбранную дату") + active_state_context = ( + f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n" + f"- Сейчас на экране отображаются снапшоты за {active_date}.\n" + f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n" + f"- Если оператор запрашивает ДРУГУЮ дату или день недели (например: 'за вчера', 'а за 13.08') — " + f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n" + f"- Запрещено генерировать текст за другую дату по памяти.\n" + ) + elif current_state_type == "SNAPSHOT_DELETE_CONFIRM": + active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n" + elif current_state_type == "TASK_DELETE_CONFIRM": + active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n" + + # 4. Сборка полного системного сообщения + return ( + f"{base_system_prompt}\n\n" + f"СТРОГИЕ ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ:\n" + f"1. Для системного промпта:\n" + f" - 'добавь X.Y Текст' -> СРАЗУ вызывай db_prompt_node_edit(action='ADD', section_id=X, item_id=Y, content='Текст').\n" + f" - 'удали X.Y' -> СРАЗУ вызывай db_prompt_node_edit(action='DELETE', section_id=X, item_id=Y, content='').\n" + f" - 'покажи системный промпт' -> СРАЗУ вызывай db_get_system_prompt().\n" + f"2. Для задач:\n" + f" - 'удали задачу N' -> СРАЗУ вызывай db_tasks_edit(action='DELETE', task_id='N').\n" + f" - 'возьми в работу N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='IN_PROGRESS').\n" + f" - 'заверши N' / 'готово N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='COMPLETED').\n" + f"3. Запрещено задавать вопросы и писать подтверждения текстом — СРАЗУ вызывай соответствующий инструмент!\n\n" + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" + f"- Пользователь: {user_info}\n" + f"- {calendar_context}\n" + f"{active_state_context}" + ) \ No newline at end of file diff --git a/modules/ai_engine/handlers/__init__.py b/modules/ai_engine/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/ai_engine/handlers/prompt_handler.py b/modules/ai_engine/handlers/prompt_handler.py new file mode 100644 index 0000000..02ca6a8 --- /dev/null +++ b/modules/ai_engine/handlers/prompt_handler.py @@ -0,0 +1,98 @@ +""" +=============================================================================== +FILE: modules/ai_engine/handlers/prompt_handler.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine / handlers +ROLE: Изолированная обработка команд управления системным промптом. +=============================================================================== +""" + +from typing import Dict, Any, Tuple, Optional +from services.prompts.service import get_active_system_prompt, create_prompt_preview +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history +from modules.web_api.llm.db.db_prompts import db_set_session_state + + +# ANCHOR[PROMPT_HANDLER_DISPATCH] +def handle_prompt_call( + fn_name: str, + fn_args: Dict[str, Any], + session_id: str +) -> Tuple[str, list, Optional[Dict[str, Any]]]: + """Обрабатывает вызовы просмотра и изменения системного промпта.""" + + # 1. Просмотр промпта с кнопкой быстрого перехода в редактор + if fn_name == "db_get_system_prompt": + active_prompt = get_active_system_prompt() + reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" + + # Сохраняем состояние сессии для возможности мгновенного редактирования и подтверждения + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": active_prompt, + "action": "MANUAL_EDIT", + "idle_turns": 0 + }) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + + return reply_text, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": active_prompt, + "baseline_prompt": active_prompt, + "buttons": [ + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}, + {"label": "Готово", "value": "нет, спасибо", "style": "secondary"} + ] + } + + # 2. Предпросмотр точечных или пакетных изменений (ADD / EDIT / DELETE / BATCH_DELETE) + action = str(fn_args.get("action", "ADD")).upper() + nodes_list = fn_args.get("nodes_list", []) + delete_nodes_tuples = [] + + if nodes_list: + for n_str in nodes_list: + parts = str(n_str).strip().split(".") + if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit(): + delete_nodes_tuples.append((int(parts[0]), int(parts[1]))) + + sec_id = None + itm_id = None + try: + if fn_args.get("section_id") is not None: + sec_id = int(str(fn_args.get("section_id")).strip()) + if fn_args.get("item_id") is not None: + itm_id = int(str(fn_args.get("item_id")).strip()) + except Exception: + pass + + content = str(fn_args.get("content", "")).strip() + + merged_prompt, diff_html, baseline_prompt = create_prompt_preview( + action=action, + section_id=sec_id, + item_id=itm_id, + content=content, + delete_nodes=delete_nodes_tuples if delete_nodes_tuples else None + ) + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": merged_prompt, + "action": "MANUAL_EDIT", + "section_id": sec_id, + "item_id": itm_id, + "content": content, + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": merged_prompt, + "baseline_prompt": baseline_prompt, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } \ No newline at end of file diff --git a/modules/ai_engine/handlers/snapshot_handler.py b/modules/ai_engine/handlers/snapshot_handler.py new file mode 100644 index 0000000..3b7b425 --- /dev/null +++ b/modules/ai_engine/handlers/snapshot_handler.py @@ -0,0 +1,91 @@ +""" +=============================================================================== +FILE: modules/ai_engine/handlers/snapshot_handler.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine / handlers +ROLE: Изолированная обработка запросов просмотра и удаления срезов СКУД. + +AI-CONTEXT-ANCHORS: + - ANCHOR[SNAPSHOT_HANDLER_DISPATCH]: Обработка db_get_snapshots и db_delete_snapshots. +=============================================================================== +""" + +from typing import Dict, Any, Tuple, Optional, List +from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely +from modules.web_api.llm.core.calendar_utils import parse_relative_date_ru +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history +from modules.web_api.llm.db.db_prompts import db_set_session_state + + +# ANCHOR[SNAPSHOT_HANDLER_DISPATCH] +def handle_snapshots_call( + fn_name: str, + fn_args: Dict[str, Any], + session_id: str, + user_message: str, + state_data: Dict[str, Any] +) -> Tuple[str, list, Optional[Dict[str, Any]]]: + """Обрабатывает запросы реестра и безопасного удаления срезов.""" + + # 1. Получение срезов + if fn_name == "db_get_snapshots": + date_param = fn_args.get("date_str") + if not date_param and user_message: + date_param = parse_relative_date_ru(user_message) + + snapshots_res = get_snapshots_registry(date_str=date_param) + query_date = snapshots_res.get("query_date", "выбранную дату") + + db_set_session_state(session_id, "SNAPSHOTS_VIEW", snapshots_res) + reply_text = f"Реестр срезов СКУД за {query_date}:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": snapshots_res + } + + # 2. Удаление срезов (двухфазное подтверждение) + raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") + raw_ids = fn_args.get("snapshot_ids") or [] + is_confirmed = fn_args.get("confirmed", False) + + if raw_id and not raw_ids: + if isinstance(raw_id, str) and "," in raw_id: + raw_ids = [s.strip() for s in raw_id.split(",")] + else: + raw_ids = [raw_id] + + safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")] + if not safe_ids: + reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None + + if not is_confirmed: + query_date = state_data.get("query_date", "") + db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", { + "snapshot_ids": safe_ids, + "query_date": query_date, + "idle_turns": 0 + }) + ids_str = ", ".join(safe_ids) + reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOT_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + else: + delete_snapshots_safely(snapshot_ids=safe_ids) + query_date = state_data.get("query_date", "") + updated_data = get_snapshots_registry(date_str=query_date) + + reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_data + } \ No newline at end of file diff --git a/modules/ai_engine/handlers/task_handler.py b/modules/ai_engine/handlers/task_handler.py new file mode 100644 index 0000000..f5d7e39 --- /dev/null +++ b/modules/ai_engine/handlers/task_handler.py @@ -0,0 +1,97 @@ +""" +=============================================================================== +FILE: modules/ai_engine/handlers/task_handler.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine / handlers +ROLE: Изолированная обработка вызовов инструментов задач (get, edit, delete, export). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_HANDLER_DISPATCH]: Обработка вызовов db_get_tasks и db_tasks_edit. +=============================================================================== +""" + +from typing import Dict, Any, Tuple, Optional +from services.tasks.service import get_tasks, execute_task_action +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history +from modules.web_api.llm.db.db_prompts import db_set_session_state + + +# ANCHOR[TASK_HANDLER_DISPATCH] +def handle_tasks_call( + fn_name: str, + fn_args: Dict[str, Any], + user_id: int, + session_id: str +) -> Tuple[str, list, Optional[Dict[str, Any]]]: + """Обрабатывает нативные tool-вызовы по задачам.""" + + # 1. Просмотр задач + if fn_name == "db_get_tasks": + raw_tasks = get_tasks(user_id, status=fn_args.get("status")) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + # 2. Модификация / Удаление / Экспорт + action = (fn_args.get("action") or "UPDATE").upper() + if fn_name == "db_add_task": action = "ADD" + elif fn_name == "db_delete_task": action = "DELETE" + elif fn_name == "db_update_task_status": action = "UPDATE" + + # Двухфазное подтверждение удаления + if action == "DELETE": + task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip() + db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0}) + reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + + # Экспорт в Markdown + elif action == "EXPORT": + export_res = execute_task_action( + user_id=user_id, + action="EXPORT", + filename=fn_args.get("filename"), + status=fn_args.get("status") + ) + reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + + action_payload = None + if export_res.get("status") == "success": + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": export_res.get("filename"), + "download_url": export_res.get("download_url"), + "tasks_count": export_res.get("tasks_count") + } + return reply_text, db_get_chat_history(session_id), action_payload + + # Создание и обновление + else: + res = execute_task_action( + user_id=user_id, + action=action, + task_id=fn_args.get("task_id"), + title=fn_args.get("title"), + priority=fn_args.get("priority", "MEDIUM"), + status=fn_args.get("status"), + module=fn_args.get("module", "general"), + due_date=fn_args.get("due_date") + ) + raw_tasks = get_tasks(user_id) + reply_text = res.get("message", "Операция над задачами выполнена.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } \ No newline at end of file diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py index a341b8b..575b4c1 100644 --- a/modules/web_api/llm/agent.py +++ b/modules/web_api/llm/agent.py @@ -3,18 +3,22 @@ FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm (Core Agent Coordinator) -ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта, - управление сессионными стейтами, Topic Drift Guard и очистка контекста. +ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling) + и управление сессионными стейтами через ContextManager. + +AI-CONTEXT-ANCHORS: + - ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты. + - ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message. + - ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста. + - ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов. + - ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем. =============================================================================== """ -# ============================================================================= -# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ -# ============================================================================= +# ANCHOR[AGENT_IMPORTS] import sys import json import logging -import re from typing import List, Dict, Any, Tuple, Optional from .db.connection import get_db_connection @@ -26,6 +30,8 @@ from .db_tools import ( db_update_task_status, db_delete_task, db_add_task, + db_tasks_edit, + db_export_tasks_markdown, db_get_rules, db_set_session_state, db_get_session_state, @@ -46,6 +52,12 @@ from .core.calendar_utils import get_dynamic_calendar_context from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed from .core.ollama_client import call_ollama_chat from .core.fast_path import handle_fast_path_intercept +from .core.context_manager import ( + save_tool_interaction, + save_dialog_interaction, + mark_last_user_message_ephemeral, + close_tool_session_and_cleanup +) logger = logging.getLogger("SCUD_AGENT") logger.setLevel(logging.INFO) @@ -58,9 +70,7 @@ if not logger.handlers: logger.addHandler(handler) -# ============================================================================= -# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА -# ============================================================================= +# ANCHOR[AGENT_MAIN_PIPELINE] def process_chat_message( user_id: int, user_message: str, @@ -70,40 +80,29 @@ def process_chat_message( session_id: str = "web_session_main" ) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]: """ - Главная функция обработки сообщения: - 1. Очищает осиротевшие эфемерные сообщения. - 2. Перехватывает быстрые кнопки (Fast-Path). - 3. Собирает контекст и передает управление Ollama. - 4. Выполняет Tool Calls и управляет счетчиком Topic Drift. + Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling. """ logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") - # 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения session_state = db_get_session_state(session_id) if not session_state: db_purge_ephemeral_messages(session_id) full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message - # Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM + # 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта) fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state) if fast_path_res: return fast_path_res - # Сохранение входящего сообщения оператора - is_user_ephemeral = 1 if session_state else 0 - db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral) + # 2. Фиксация сообщения пользователя + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_history = db_get_chat_history(session_id, limit=20) - - # ========================================================================= - # БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА - # ========================================================================= - dynamic_prompt_text = db_get_active_system_prompt() + # ANCHOR[AGENT_SYSTEM_PROMPT] calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" - # Извлечение текущего состояния и счетчика отвлечений (idle_turns) current_state_type = session_state.get("state_type") if session_state else None state_data = session_state.get("data_json") or {} if session_state else {} if not isinstance(state_data, dict): @@ -113,47 +112,61 @@ def process_chat_message( active_state_context = "" if current_state_type == "PROMPT_PREVIEW": active_state_context = ( - "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n" - "- Сейчас открыт предпросмотр изменения системного промпта.\n" - "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n" - "- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n" + "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n" + "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n" ) elif current_state_type == "PROMPT_FOLLOWUP": active_state_context = ( - "\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n" - "- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n" - "- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n" - "- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n" + "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n" + "- Оператор просматривает или редактирует системный промпт.\n" + "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n" + "- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n" + "- Запрещено путать ADD и DELETE.\n" + ) + elif current_state_type == "SNAPSHOTS_VIEW": + active_date = state_data.get("query_date", "выбранную дату") + active_state_context = ( + f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n" + f"- Сейчас на экране отображаются снапшоты за {active_date}.\n" + f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n" + f"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {active_date} (например: 'за вчера', 'а за 13.08', 'покажи за сегодня') — " + f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n" + f"- Запрещено генерировать текст за другую дату по памяти.\n" + ) + elif current_state_type == "SNAPSHOT_DELETE_CONFIRM": + active_state_context = ( + "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n" + ) + elif current_state_type == "TASK_DELETE_CONFIRM": + active_state_context = ( + "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n" ) system_prompt_content = ( - f"Ты — интеллектуальный ассистент SCUD Orion AI. " - f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n" - f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n" - f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n" - f"[ОКРУЖЕНИЕ]\n" + f"Ты — интеллектуальный ассистент SCUD Orion AI.\n" + f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n" + f"СТРОГИЕ ПРАВИЛА:\n" + f"1. К оператору всегда обращайся на Вы.\n" + f"2. Для получения данных всегда вызывай соответствующий инструмент:\n" + f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n" + f" - Удаление снапшотов -> db_delete_snapshots\n" + f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n" + f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n" + f" - Аномалии СКУД/1С -> db_get_anomalies\n" + f" - База знаний -> db_get_rules\n" + f" - Статистика БД -> db_get_stats\n" + f" - Справка -> db_get_reference\n" + f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n" + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" f"- Пользователь: {user_info}\n" f"- {calendar_context}\n" - f"{active_state_context}\n" - f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n" - f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n" - f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n" - f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n" - f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n" - f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n" - f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n" - f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n" - f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}" + f"{active_state_context}" ) user_msg_object = {"role": "user", "content": full_user_content} - - # ========================================================================= - # БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING - # ========================================================================= + # ANCHOR[AGENT_TOOL_DISPATCHER] try: - # Режим Vision (OCR) if image_b64: user_msg_object["images"] = [image_b64] messages = [ @@ -162,7 +175,6 @@ def process_chat_message( ] msg = call_ollama_chat(messages, is_vision=True) else: - # Текстовый диалог clean_db_history = [dict(m) for m in db_history] for m in clean_db_history: m.pop("images", None) @@ -171,241 +183,329 @@ def process_chat_message( raw_text_reply = msg.get("content", "") tool_calls = msg.get("tool_calls", []) - tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) - # Исполнение вызванных моделью инструментов + if not tool_calls: + tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls) + if tool_calls: logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") - messages.append(msg) - tool_names_called = [] + + tool = tool_calls[0] + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + if isinstance(fn_args, str): + try: + fn_args = json.loads(fn_args) + except Exception: + fn_args = {} - # ----------------------------------------------------------------- - # TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ: - # Если вызваны сторонние инструменты (снапшоты, задачи, статы) - # — мгновенно закрываем сессию и вычищаем эфемерный контекст - # ----------------------------------------------------------------- - is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls) - if not is_prompt_tool and session_state: - logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.") - db_clear_session_state(session_id) - db_purge_ephemeral_messages(session_id) - session_state = None + logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - for tool in tool_calls: - fn_name = tool["function"]["name"] - fn_args = tool["function"].get("arguments", {}) - tool_names_called.append(fn_name) - logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") - tool_result_content = "" + # Ротация контекста: закрываем старую сессию инструмента + close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") + session_state = None + mark_last_user_message_ephemeral(session_id) - # --- 4.1. Модуль задач --- - if fn_name == "db_get_tasks": + # 1. Задачи (Просмотр) + if fn_name == "db_get_tasks": + raw_tasks = db_get_tasks(user_id, status=fn_args.get("status")) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + # 2. Единый диспетчер задач + elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]: + action = fn_args.get("action", "UPDATE").upper() + if fn_name == "db_add_task": action = "ADD" + elif fn_name == "db_delete_task": action = "DELETE" + elif fn_name == "db_update_task_status": action = "UPDATE" + + if action == "DELETE": + task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip() + db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0}) + reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + + elif action == "EXPORT": + export_res = db_export_tasks_markdown( + user_id=user_id, + filename=fn_args.get("filename"), + status_filter=fn_args.get("status") + ) + reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + + action_payload = None + if export_res.get("status") == "success": + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": export_res.get("filename"), + "download_url": export_res.get("download_url"), + "tasks_count": export_res.get("tasks_count") + } + return reply_text, db_get_chat_history(session_id), action_payload + + else: + res = db_tasks_edit( + user_id=user_id, + action=action, + task_id=fn_args.get("task_id"), + title=fn_args.get("title"), + priority=fn_args.get("priority", "MEDIUM"), + status=fn_args.get("status"), + module=fn_args.get("module", "general"), + due_date=fn_args.get("due_date") + ) raw_tasks = db_get_tasks(user_id) - reply_text = "Вот интерактивный список ваших текущих задач:" + reply_text = res.get("message", "Операция над задачами выполнена.") db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) return reply_text, db_get_chat_history(session_id), { "type": "TASK_INTERACTIVE_CARD", "tasks": raw_tasks } - # --- 4.2. Прямой просмотр системного промпта из SQLite --- - elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: - active_prompt = db_get_active_system_prompt() - reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" - - # Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + # 3. Системный промпт + elif fn_name == "db_get_system_prompt": + active_prompt = db_get_active_system_prompt() + reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" + db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0}) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None + + elif fn_name == "db_prompt_node_edit": + action = str(fn_args.get("action", "ADD")).upper() + + try: + sec_id = int(str(fn_args.get("section_id", 3)).strip()) + except Exception: + sec_id = 3 + + try: + itm_id = int(str(fn_args.get("item_id", 1)).strip()) + except Exception: + itm_id = 1 + + content = str(fn_args.get("content", "")).strip() + baseline_prompt = db_get_active_system_prompt() + + with get_db_connection() as temp_conn: + temp_cursor = temp_conn.cursor() + temp_cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = 'main_agent' AND is_active = 1 + ORDER BY section_id, item_id + """) + existing_nodes = temp_cursor.fetchall() + + nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict) + if action != "DELETE": + nodes_dict_for_draft[(sec_id, itm_id)] = content + + draft_lines = [] + curr_sec = None + for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): + if i_id == 0: + if curr_sec is not None: draft_lines.append("") + draft_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + draft_lines.append(f" {s_id}.{i_id}. {txt}") + merged_prompt = "\n".join(draft_lines) + + diff_lines = [] + curr_sec = None + display_nodes = dict(nodes_dict) + if action != "DELETE": + display_nodes[(sec_id, itm_id)] = content + + for (s_id, i_id), txt in sorted(display_nodes.items()): + if i_id == 0: + if curr_sec is not None: diff_lines.append("") + diff_lines.append(f"{s_id}. {txt}") + curr_sec = s_id + else: + if s_id == sec_id and i_id == itm_id: + line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' if action == "DELETE" else f' {s_id}.{i_id}. {txt}' + else: + line_str = f" {s_id}.{i_id}. {txt}" + diff_lines.append(line_str) + + diff_html = "\n".join(diff_lines) + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": merged_prompt, + "action": action, + "section_id": sec_id, + "item_id": itm_id, + "content": content, + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": merged_prompt, + "baseline_prompt": baseline_prompt, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } + + # 4. Снапшоты СКУД + elif fn_name == "db_get_snapshots": + snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) + query_date = snapshots_res.get("query_date", "выбранную дату") + reply_text = f"Реестр срезов СКУД за {query_date}:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": snapshots_res + } + + elif fn_name == "db_delete_snapshots": + raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") + raw_ids = fn_args.get("snapshot_ids") or [] + is_confirmed = fn_args.get("confirmed", False) + + if raw_id and not raw_ids: + if isinstance(raw_id, str) and "," in raw_id: + raw_ids = [s.strip() for s in raw_id.split(",")] + else: + raw_ids = [raw_id] + + safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")] + if not safe_ids: + reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) return reply_text, db_get_chat_history(session_id), None - # --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) --- - elif fn_name == "db_prompt_node_edit": - action = fn_args.get("action", "ADD").upper() - sec_id = int(fn_args.get("section_id", 2)) - itm_id = int(fn_args.get("item_id", 1)) - content = fn_args.get("content", "").strip() - baseline_prompt = db_get_active_system_prompt() - - with get_db_connection() as temp_conn: - temp_cursor = temp_conn.cursor() - temp_cursor.execute(""" - SELECT section_id, item_id, content - FROM system_prompt_nodes - WHERE prompt_name = 'main_agent' AND is_active = 1 - ORDER BY section_id, item_id - """) - existing_nodes = temp_cursor.fetchall() - - nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes} - - if action == "DELETE": - nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} - else: - nodes_dict_for_draft = dict(nodes_dict) - nodes_dict_for_draft[(sec_id, itm_id)] = content - - # Формирование чистого текста для редактора - draft_lines = [] - curr_sec = None - for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()): - if i_id == 0: - if curr_sec is not None: - draft_lines.append("") - draft_lines.append(f"{s_id}. {txt}") - curr_sec = s_id - else: - draft_lines.append(f" {s_id}.{i_id}. {txt}") - merged_prompt = "\n".join(draft_lines) - - # Формирование HTML Diff с подсветкой - diff_lines = [] - curr_sec = None - display_nodes = dict(nodes_dict) - if action != "DELETE": - display_nodes[(sec_id, itm_id)] = content - - for (s_id, i_id), txt in sorted(display_nodes.items()): - if i_id == 0: - if curr_sec is not None: - diff_lines.append("") - diff_lines.append(f"{s_id}. {txt}") - curr_sec = s_id - else: - if s_id == sec_id and i_id == itm_id: - if action == "DELETE": - line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' - else: - line_str = f' {s_id}.{i_id}. {txt}' - else: - line_str = f" {s_id}.{i_id}. {txt}" - diff_lines.append(line_str) - - diff_html = "\n".join(diff_lines) - - # Фиксация предпросмотра в session_states - db_set_session_state(session_id, "PROMPT_PREVIEW", { - "draft_text": merged_prompt, - "action": action, - "section_id": sec_id, - "item_id": itm_id, - "content": content, + if not is_confirmed: + query_date = state_data.get("query_date", "") + db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", { + "snapshot_ids": safe_ids, + "query_date": query_date, "idle_turns": 0 }) - - with get_db_connection() as conn_fix: - conn_fix.cursor().execute(""" - UPDATE chat_messages - SET is_ephemeral = 1 - WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') - """, (session_id,)) - conn_fix.commit() - - preview_reply = ( - f"Предпросмотр изменений системного промпта:\n\n" - f"{diff_html}\n\n" - f"Для применения подтвердите действие, отредактируйте или отмените." - ) - - db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) - return preview_reply, db_get_chat_history(session_id), { - "type": "PROMPT_PREVIEW", - "raw_draft": merged_prompt, - "baseline_prompt": baseline_prompt, + ids_str = ", ".join(safe_ids) + reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOT_DELETE_CONFIRM", "buttons": [ - {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, - {"label": "Отменить", "value": "отмена", "style": "danger"}, - {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} ] } + else: + db_delete_snapshots(snapshot_ids=safe_ids) + query_date = state_data.get("query_date", "") + updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date) - # --- 4.4. Сервисные инструменты --- - elif fn_name == "db_get_snapshots": - snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message) - tool_result_content = json.dumps(snapshots_res, ensure_ascii=False) + reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_snapshots_res + } + + elif fn_name == "db_delete_snapshots": + raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") + raw_ids = fn_args.get("snapshot_ids") or ([raw_id] if raw_id else []) + + # Защита: итоговые Y-срезы удалять запрещено + safe_ids = [s for s in raw_ids if not str(s).startswith("Y")] + if not safe_ids: + reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None - elif fn_name == "db_get_current_server_time": - tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + # Прямое выполнение удаления в SQLite без лишних текстовых подтверждений + del_res = db_delete_snapshots(snapshot_ids=safe_ids) + + # Получаем свежий реестр за ту же дату + query_date = fn_args.get("day_str", "") + updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date) - elif fn_name == "db_get_stats": - tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_snapshots_res + } - elif fn_name == "db_get_anomalies": - tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + # 5. Сервисные запросы + elif fn_name == "db_get_current_server_time": + tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False) + elif fn_name == "db_get_stats": + tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False) + elif fn_name == "db_get_anomalies": + tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False) + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) + elif fn_name == "db_get_reference": + tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) + else: + tool_result_content = "{}" - elif fn_name == "db_get_rules": - tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False) - - elif fn_name == "db_get_reference": - tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False) - - elif fn_name == "db_add_task": - res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_update_task_status": - res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_delete_task": - res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper()) - tool_result_content = json.dumps(res, ensure_ascii=False) - - elif fn_name == "db_delete_snapshots": - res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")) - tool_result_content = json.dumps(res, ensure_ascii=False) - - messages.append({"role": "tool", "content": tool_result_content}) - - # Интерпретация результатов вызова инструментов + messages.append(msg) + messages.append({"role": "tool", "content": tool_result_content}) sec_msg = call_ollama_chat(messages, is_vision=False) raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "") - final_content = clean_raw_tool_tags(clean_output(raw_content)) + final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен." - is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] for name in tool_names_called) else 0 + for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: + if final_content.lower().startswith(artifact): + final_content = final_content[len(artifact):].lstrip(",.!?:; -") - db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral) + is_ephem = 1 if fn_name in ["db_get_snapshots", "db_get_system_prompt", "db_prompt_node_edit"] else 0 + db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_ephem) return final_content, db_get_chat_history(session_id), None - - # ===================================================================== - # БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD - # ===================================================================== + # ANCHOR[AGENT_TOPIC_DRIFT] raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "") final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан." + + for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: + if final_reply.lower().startswith(artifact): + final_reply = final_reply[len(artifact):].lstrip(",.!?:; -") + action_payload = None - # Обработка отвлечений оператора при активной сессии настройки/просмотра if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]: idle_turns += 1 - logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3") - - # Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка if idle_turns > 3: db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.") + db_purge_ephemeral_messages(session_id) action_payload = None session_state = None elif idle_turns == 3: - # На 3-м сообщении стороннего диалога вежливо спрашиваем оператора tool_action = db_get_tool_action("db_confirm_prompt_preview") guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?" buttons = tool_action.get("buttons", []) if tool_action else [] - final_reply += f"\n\n💡 *Напоминание:* {guard_question}" - action_payload = { - "type": "PROMPT_FOLLOWUP", - "buttons": buttons - } + action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons} db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns}) else: - # Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns}) - db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0)) + db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) return final_reply, db_get_chat_history(session_id), action_payload - # Обработка исключений except Exception as ex: logger.exception(f"Непредвиденная ошибка агента: {ex}") error_reply = f"Внутренняя ошибка сервера: {ex}" diff --git a/modules/web_api/llm/core/context_manager.py b/modules/web_api/llm/core/context_manager.py new file mode 100644 index 0000000..fd0dcb6 --- /dev/null +++ b/modules/web_api/llm/core/context_manager.py @@ -0,0 +1,64 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/core/context_manager.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций + инструментов и содержательного диалога с сохранением Topic Drift. +=============================================================================== +""" + +import logging +from typing import Dict, Any, Optional, List + +from ..db.connection import get_db_connection +from ..db_tools import ( + db_save_chat_message, + db_get_chat_history, + db_purge_ephemeral_messages, + db_clear_session_state, + db_set_session_state +) + +logger = logging.getLogger("CONTEXT_MANAGER") + + +def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None: + """ + Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1). + При очистке удалятся оба сообщения, не оставляя сирот. + """ + db_save_chat_message(session_id, "user", user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1) + + +def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None: + """ + Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0). + Эти сообщения остаются в истории навсегда (включая Topic Drift). + """ + db_save_chat_message(session_id, "user", user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0) + + +def mark_last_user_message_ephemeral(session_id: str) -> None: + """Помечает последнее сообщение пользователя как эфемерное при активации инструмента.""" + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE chat_messages + SET is_ephemeral = 1 + WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user') + """, (session_id,)) + conn.commit() + + +def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int: + """ + Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды. + Весь содержательный диалог сохраняется. + """ + db_clear_session_state(session_id) + deleted_count = db_purge_ephemeral_messages(session_id) + logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}") + return deleted_count \ No newline at end of file diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py index 69b6d7f..b33e1c8 100644 --- a/modules/web_api/llm/core/fast_path.py +++ b/modules/web_api/llm/core/fast_path.py @@ -3,104 +3,166 @@ FILE: modules/web_api/llm/core/fast_path.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP), - атомарная фиксация узлов и мягкая зачистка эфемерного контекста. +ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения + (без задержек LLM и обращения к Ollama). + +AI-CONTEXT-ANCHORS: + - ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept. =============================================================================== """ +import json import logging from typing import Dict, Any, Tuple, Optional -from llm.db_tools import ( - db_apply_prompt_node_action, - db_add_system_prompt, - db_set_session_state, - db_clear_session_state, - db_purge_ephemeral_messages, - db_save_chat_message, - db_get_chat_history -) +# Прямые вызовы чистых доменных сервисов +from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt +from services.tasks.service import get_tasks, delete_task +from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely + +# Чат и сессии +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages +from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action +from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup logger = logging.getLogger("FAST_PATH") +# ANCHOR[FAST_PATH_MAIN] def handle_fast_path_intercept( - session_id: str, - user_message: str, - full_user_content: str, + session_id: str, + user_message: str, + full_user_content: str, session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: - """Обрабатывает команды подтверждения, отмены и завершения сессии.""" + """ + Мгновенный перехват нажатий кнопок подтверждения (Fast-Path). + Возвращает (reply, history, action_type) или None, если требуется передать управление LLM. + """ if not session_state: return None state_type = session_state.get("state_type") - user_msg_clean = user_message.lower().strip(" .!?:;") + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} - # ========================================================================= - # ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW - # ========================================================================= + msg_lower = user_message.strip().lower() + + # ------------------------------------------------------------------------- + # 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА + # ------------------------------------------------------------------------- if state_type == "PROMPT_PREVIEW": - state_data = session_state.get("data_json") or {} - if not isinstance(state_data, dict): - state_data = {} + is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"] - draft_text = state_data.get("draft_text", "") - action = state_data.get("action") - sec_id = state_data.get("section_id") - itm_id = state_data.get("item_id") - content = state_data.get("content", "") + if is_confirm: + action = state_data.get("action", "MANUAL_EDIT") + draft_text = state_data.get("draft_text", "") - # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP - if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: - if action == "MANUAL_EDIT" or not action or sec_id is None: - if draft_text: - db_add_system_prompt("main_agent", draft_text) + # Применение изменений + if action == "MANUAL_EDIT" and draft_text: + save_full_prompt_draft(draft_text, prompt_name="main_agent") else: - db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) + sec_id = state_data.get("section_id") + itm_id = state_data.get("item_id") + content = state_data.get("content", "") + if sec_id is not None and itm_id is not None: + apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content) + elif draft_text: + save_full_prompt_draft(draft_text, prompt_name="main_agent") - logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.") + # Закрытие сессии и зачистка + close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY") + db_clear_session_state(session_id) + + tool_action = db_get_tool_action("db_confirm_prompt_preview") + reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных." - # Переводим сессию в режим ожидания продолжения/завершения - db_set_session_state(session_id, "PROMPT_FOLLOWUP", {}) + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None - reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?" - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED") + db_clear_session_state(session_id) - action_payload = { - "type": "PROMPT_FOLLOWUP", - "buttons": [ - {"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"}, - {"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"} - ] + tool_action = db_get_tool_action("db_cancel_prompt_preview") + reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены." + + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + # ------------------------------------------------------------------------- + # 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД + # ------------------------------------------------------------------------- + elif state_type == "SNAPSHOT_DELETE_CONFIRM": + is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет"] + + if is_confirm: + snap_ids = state_data.get("snapshot_ids", []) + query_date = state_data.get("query_date", "") + + # Безопасное удаление через сервис + del_res = delete_snapshots_safely(snapshot_ids=snap_ids) + + close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED") + + # Получаем свежий список за ту же дату + updated_data = get_snapshots_registry(date_str=query_date if query_date else None) + db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data) + + reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + + return reply, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_data } - return reply_text, db_get_chat_history(session_id), action_payload - # 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА - elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]: + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED") db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}") + + reply = "❌ Удаление снапшотов отменено." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None - reply_text = "Изменения системного промпта отменены. Контекст диалога чист." - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + # ------------------------------------------------------------------------- + # 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ + # ------------------------------------------------------------------------- + elif state_type == "TASK_DELETE_CONFIRM": + is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет"] - # ========================================================================= - # ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP - # ========================================================================= - elif state_type == "PROMPT_FOLLOWUP": - # Явный сигнал завершения работы - if user_msg_clean in [ - "завершить работу", "завершить", "закончить", "готово", "всё", - "все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти" - ]: + if is_confirm: + task_id = state_data.get("task_id") + delete_task(user_id=1, task_id=str(task_id)) + + close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED") db_clear_session_state(session_id) - deleted_count = db_purge_ephemeral_messages(session_id) - logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}") - reply_text = "Работа с системным промптом завершена. Чем могу помочь?" - db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) - return reply_text, db_get_chat_history(session_id), None + raw_tasks = get_tasks(user_id=1) + reply = f"🗑 Задача #{task_id} удалена." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + + return reply, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_CANCELLED") + db_clear_session_state(session_id) + + reply = "❌ Удаление задачи отменено." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None return None \ No newline at end of file diff --git a/modules/web_api/llm/core/ollama_client.py b/modules/web_api/llm/core/ollama_client.py index a6fd772..83dd592 100644 --- a/modules/web_api/llm/core/ollama_client.py +++ b/modules/web_api/llm/core/ollama_client.py @@ -14,16 +14,16 @@ from typing import Dict, Any, Optional logger = logging.getLogger("OLLAMA_CLIENT") -OLLAMA_URL = "http://192.168.11.3:11434/api/chat" -TEXT_MODEL = "qwen2.5:14b" +OLLAMA_URL = "http://10.121.17.227:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b-instruct-q8_0" VISION_MODEL = "qwen2.5vl:7b-q8_0" LLM_OPTIONS = { "num_predict": 8192, "num_ctx": 8192, - "temperature": 0.1, - "repeat_penalty": 1.1, - "presence_penalty": 0.5, + "temperature": 0.0, + "repeat_penalty": 1.0, + "presence_penalty": 0.0, "top_p": 0.9 } diff --git a/modules/web_api/llm/core/prompt_merger.py b/modules/web_api/llm/core/prompt_merger.py deleted file mode 100644 index 5407901..0000000 --- a/modules/web_api/llm/core/prompt_merger.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -=============================================================================== -FILE: modules/web_api/llm/core/prompt_merger.py -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / llm / core -ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок. -=============================================================================== -""" - -# ANCHOR[PROMPT_MERGER_IMPORTS] -import re -import logging - -logger = logging.getLogger("PROMPT_MERGER") - - -# ANCHOR[PROMPT_MERGE_ENGINE] -def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str: - """ - Вычисляет результирующий чистый текст промпта для записи в БД. - """ - user_msg_lower = user_message.lower() - - # 1. Сценарий удаления пункта - if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): - target_num_match = re.search(r'\d+(\.\d+)*', user_message) - target_num = target_num_match.group(0) if target_num_match else "" - - lines = current_prompt.splitlines() - if target_num: - new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] - else: - new_lines = lines - return "\n".join(new_lines) - - # 2. Сценарий добавления или замены пункта - if proposed_text: - if len(proposed_text) < 500: - clean_item = proposed_text.strip() - for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: - if prefix in clean_item.lower(): - clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:") - - lines = current_prompt.splitlines() - new_lines = [] - inserted = False - for line in lines: - new_lines.append(line) - if "3.3." in line and not inserted: - item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" - new_lines.append(f" {item_str}") - inserted = True - if not inserted: - new_lines.append(f" {clean_item}") - return "\n".join(new_lines) - - return proposed_text or current_prompt - - -def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str: - """ - Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind. - """ - old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()} - diff_lines = [] - - for line in new_prompt.splitlines(): - if line.strip() and line.strip() not in old_lines_set: - # Выделяем новую или измененную строку красным цветом - diff_lines.append(f'{line}') - else: - diff_lines.append(line) - - return "\n".join(diff_lines) \ No newline at end of file diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py index 3dd45c3..40381d8 100644 --- a/modules/web_api/llm/core/tool_injector.py +++ b/modules/web_api/llm/core/tool_injector.py @@ -3,9 +3,14 @@ FILE: modules/web_api/llm/core/tool_injector.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core -ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM. +ROLE: Семантический анализ намерений оператора (Intent Classifier) и + детерминированная сборка вызовов инструментов при сбоях нативного Function Calling. + +AI-CONTEXT-ANCHORS: + - ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed. =============================================================================== """ + import re import logging from typing import List, Dict, Any @@ -14,37 +19,120 @@ logger = logging.getLogger("TOOL_INJECTOR") def clean_raw_tool_tags(text: str) -> str: - """Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст.""" + """Удаляет сырые теги вызова инструментов и системный шум.""" if not text: return "" - text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text) - text = re.sub(r'.*?', '', text, flags=re.DOTALL) - text = re.sub(r'\w*\[\]\(\)', '', text) - text = re.sub(r'', '', text) - return text.strip() - - -def clean_output(text: str) -> str: - """Удаляет слова-паразиты и склейки в начале ответа.""" - if not text: - return text - - artifacts = [ - "почемучка", "почемучка,", "почемучка!", "почемучка?", - "почемучто", "почто", "почему что", "почему-то", - "здравствуйте!", "привет!" - ] - - cleaned = text.strip() - for art in artifacts: - if cleaned.lower().startswith(art): - cleaned = cleaned[len(art):].lstrip(",.!?:; -") - + cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL) + cleaned = re.sub(r'<\|.*?\|>', '', cleaned) return cleaned.strip() -def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: +def clean_output(text: str) -> str: + """Очищает маркеры форматирования.""" + return text.strip() if text else "" + + +# ANCHOR[INTENT_INJECTOR_MAIN] +def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ - Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM. + Гибридный семантический анализатор: + Если модель ответила текстом с сомнениями или пропустила tool_call, + распознает доменное намерение и конструирует синтетический tool_call. """ - return tool_calls \ No newline at end of file + if existing_tool_calls: + return existing_tool_calls + + msg_clean = user_message.strip().lower() + + # 1. СЕМАНТИКА: Просмотр системного промпта + # Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт" + if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]): + logger.info("[IntentInjector] Распознано намерение просмотра системного промпта") + return [{ + "function": { + "name": "db_get_system_prompt", + "arguments": {} + } + }] + + # 2. СЕМАНТИКА: Удаление задач + # Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37" + if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]): + task_match = re.search(r'#?\s*(\d+)', msg_clean) + if task_match: + task_id = task_match.group(1) + logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}") + return [{ + "function": { + "name": "db_tasks_edit", + "arguments": {"action": "DELETE", "task_id": task_id} + } + }] + + # 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление) + # Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта" + if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]): + node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message) + if node_matches: + formatted_nodes = [f"{s}.{i}" for s, i in node_matches] + logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}") + return [{ + "function": { + "name": "db_prompt_node_edit", + "arguments": { + "action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE", + "section_id": int(node_matches[0][0]), + "item_id": int(node_matches[0][1]), + "nodes_list": formatted_nodes, + "content": "" + } + } + }] + + # 4. СЕМАНТИКА: Добавление пункта промпта + # Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст" + if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]): + add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message) + if add_match: + sec_id = int(add_match.group(1)) + itm_id = int(add_match.group(2)) + content = add_match.group(3).strip() + logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}") + return [{ + "function": { + "name": "db_prompt_node_edit", + "arguments": { + "action": "ADD", + "section_id": sec_id, + "item_id": itm_id, + "content": content + } + } + }] + + # 5. СЕМАНТИКА: Смена статуса задач + if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]): + task_match = re.search(r'#?\s*(\d+)', msg_clean) + if task_match: + task_id = task_match.group(1) + logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}") + return [{ + "function": { + "name": "db_tasks_edit", + "arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"} + } + }] + + if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]): + task_match = re.search(r'#?\s*(\d+)', msg_clean) + if task_match: + task_id = task_match.group(1) + logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}") + return [{ + "function": { + "name": "db_tasks_edit", + "arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"} + } + }] + + return existing_tool_calls \ No newline at end of file diff --git a/modules/web_api/llm/db/db_snapshots.py b/modules/web_api/llm/db/db_snapshots.py deleted file mode 100644 index 6918033..0000000 --- a/modules/web_api/llm/db/db_snapshots.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -FILE: modules/web_api/llm/db/db_snapshots.py -""" -import json -from typing import Dict, Any, Optional -from .connection import get_db_connection -from .db_prompts import db_set_session_state - -def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]: - conn = get_db_connection() - cursor = conn.cursor() - query = "SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count FROM scud_logs " - params = [] - if date_str: - iso_date = date_str - if "." in date_str: - parts = date_str.split(".") - if len(parts) == 3: - iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" - query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? " - params.extend([date_str, iso_date, f"{iso_date}%"]) - - query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50" - cursor.execute(query, params) - rows = cursor.fetchall() - snapshots = [dict(r) for r in rows] - - result_data = { - "query_date": date_str or "все", - "snapshots_count": len(snapshots), - "snapshots": snapshots - } - db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=json.dumps(result_data, ensure_ascii=False)) - conn.close() - return result_data - -def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]: - if not snapshot_id and not day_str: - return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."} - conn = get_db_connection() - cursor = conn.cursor() - if snapshot_id: - cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) - else: - cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%")) - deleted = cursor.rowcount - conn.commit() - conn.close() - return {"status": "success", "message": f"Успешно удалено записей: {deleted}"} \ No newline at end of file diff --git a/modules/web_api/llm/db/db_tasks.py b/modules/web_api/llm/db/db_tasks.py deleted file mode 100644 index 4bb97bd..0000000 --- a/modules/web_api/llm/db/db_tasks.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -FILE: modules/web_api/llm/db/db_tasks.py -""" -import re -from typing import List, Dict, Any, Optional -from .connection import get_db_connection - -def normalize_task_id(task_id_input: str) -> str: - if not task_id_input: - return "" - clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "") - if clean_id.isdigit(): - num = int(clean_id) - return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}" - return f"TASK-{clean_id}" - -def db_get_tasks(user_id: int) -> List[Dict[str, Any]]: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute(""" - SELECT id, task_id, module, title, priority, status, due_date, created_at - FROM tasks - WHERE user_id = ? - ORDER BY id DESC - """, (user_id,)) - rows = cursor.fetchall() - conn.close() - return [dict(r) for r in rows] - -def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]: - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute("SELECT MAX(id) FROM tasks") - max_id = cursor.fetchone()[0] or 0 - new_task_id = f"TASK-{(max_id + 1):02d}" - - cursor.execute(""" - INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id) - VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?) - """, (new_task_id, module, title, priority.upper(), due_date, user_id)) - conn.commit() - conn.close() - return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"} - -def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]: - conn = get_db_connection() - cursor = conn.cursor() - formatted_id = normalize_task_id(task_id) - if due_date: - cursor.execute(""" - UPDATE tasks - SET status = ?, due_date = ? - WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? - """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id)) - else: - cursor.execute(""" - UPDATE tasks - SET status = ? - WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? - """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id)) - - if cursor.rowcount == 0: - conn.close() - return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} - conn.commit() - conn.close() - return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"} - -def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: - conn = get_db_connection() - cursor = conn.cursor() - formatted_id = normalize_task_id(task_id) - cursor.execute(""" - DELETE FROM tasks - WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ? - """, (formatted_id, f"%{task_id.strip()}", user_id)) - if cursor.rowcount == 0: - conn.close() - return {"error": f"Задача {task_id} не найдена"} - conn.commit() - conn.close() - return {"status": "success", "message": f"Задача {formatted_id} удалена"} \ No newline at end of file diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py index 0eec0d2..c2a5b9b 100644 --- a/modules/web_api/llm/db_tools.py +++ b/modules/web_api/llm/db_tools.py @@ -1,17 +1,53 @@ """ +=============================================================================== FILE: modules/web_api/llm/db_tools.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm +ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade). + +AI-CONTEXT-ANCHORS: + - ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API. +=============================================================================== """ -from datetime import datetime -from .db.connection import DB_PATH, get_db_connection -from .db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages -from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task -from .db.db_snapshots import db_get_snapshots, db_delete_snapshots + +# ANCHOR[FACADE_EXPORTS] +from typing import Optional, List, Dict, Any +from core.connection import DB_PATH, get_connection as get_db_connection + +# Домен: Задачи +from services.tasks.service import ( + get_tasks as db_get_tasks, + add_task as db_add_task, + update_task_details as db_update_task_details, + delete_task as db_delete_task, + execute_task_action as db_tasks_edit +) +from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown +from services.tasks.repository import normalize_task_id + +# Домен: Системный промпт +from services.prompts.service import ( + get_active_system_prompt as db_get_active_system_prompt, + apply_prompt_action as db_apply_prompt_node_action, + save_full_prompt_draft, + create_prompt_preview +) + +# Домен: База знаний +from services.knowledge.service import ( + get_rules as db_get_rules, + add_rule as db_add_rule +) + +# Чат, сессии, статистика +from .db.db_chat import ( + db_save_chat_message, + db_get_chat_history, + db_purge_ephemeral_messages, + db_clear_chat_history +) from .db.db_prompts import ( - db_get_active_system_prompt, - db_add_system_prompt, - db_apply_prompt_node_action, db_get_tool_action, - db_get_rules, db_set_session_state, db_get_session_state, db_clear_session_state, @@ -19,13 +55,42 @@ from .db.db_prompts import ( db_get_anomalies, db_get_reference ) +from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time -def db_get_current_server_time(): - now = datetime.now() - days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] - return { - "current_date": now.strftime("%d.%m.%Y"), - "current_time": now.strftime("%H:%M:%S"), - "day_of_week": days_ru[now.weekday()], - "iso_date": now.strftime("%Y-%m-%d") - } \ No newline at end of file + +def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None: + """Совместимая обертка для сохранения системного промпта.""" + if draft_text is not None: + save_full_prompt_draft(draft_text, prompt_name=name_or_text) + else: + save_full_prompt_draft(name_or_text, prompt_name="main_agent") + + +def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]: + """Совместимый фасад выборки снапшотов с сохранением стейта сессии.""" + from services.snapshots.service import get_snapshots_registry + from .core.calendar_utils import parse_relative_date_ru + + clean_date = (date_str or "").strip() + if not clean_date and original_user_message: + clean_date = parse_relative_date_ru(original_user_message) + + res = get_snapshots_registry(date_str=clean_date if clean_date else None) + db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res) + return res + + +def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]: + """Совместимый фасад безопасного удаления снапшотов.""" + from services.snapshots.service import delete_snapshots_safely + + target_ids = [] + if snapshot_ids: + target_ids.extend(snapshot_ids) + if snapshot_id: + if isinstance(snapshot_id, str) and "," in snapshot_id: + target_ids.extend([s.strip() for s in snapshot_id.split(",")]) + else: + target_ids.append(snapshot_id) + + return delete_snapshots_safely(snapshot_ids=target_ids) \ No newline at end of file diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index 386139e..dfe1567 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -1,50 +1,82 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/schemas.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm +ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama. + +AI-CONTEXT-ANCHORS: + - ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта. + - ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами. + - ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД. +=============================================================================== +""" + +# ANCHOR[SCHEMA_PROMPT_EDIT] TOOLS_SCHEMA = [ { "type": "function", "function": { "name": "db_prompt_node_edit", "description": ( - "ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. " - "Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. " - "При удалении: action='DELETE', section_id=X, item_id=Y, content=''. " - "Запрещено отвечать текстовым подтверждением без вызова этой функции!" + "Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n" + "Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n" + "При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент." ), "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["ADD", "UPDATE", "DELETE"], - "description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)" + "enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"], + "description": "Тип действия" }, "section_id": { "type": "integer", - "description": "Номер раздела (например: 1, 2, 3)" + "description": "Номер раздела (например 1)" }, "item_id": { "type": "integer", - "description": "Номер пункта (например: 4, 8, 9)" + "description": "Номер пункта (например 8)" + }, + "nodes_list": { + "type": "array", + "items": {"type": "string"}, + "description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']" }, "content": { "type": "string", - "description": "Текст пункта (для DELETE передается пустая строка)" + "description": "Текст пункта" } }, - "required": ["action", "section_id", "item_id"] + "required": ["action"] } } }, + { + "type": "function", + "function": { + "name": "db_get_system_prompt", + "description": "Просмотр текущего активного системного промпта ассистента из базы данных.", + "parameters": { + "type": "object", + "properties": {} + } + } + }, + # ANCHOR[SCHEMA_TASKS_EDIT] { "type": "function", "function": { "name": "db_get_tasks", - "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!", + "description": "Просмотр реестра задач и бэклога текущего пользователя.", "parameters": { "type": "object", "properties": { "status": { "type": "string", - "description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED." + "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"], + "description": "Опциональный фильтр статуса задач" } } } @@ -53,64 +85,70 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_get_rules", - "description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_get_system_prompt", - "description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } - } - }, - { - "type": "function", - "function": { - "name": "db_update_task_status", - "description": "Изменить статус и/или срок выполнения задачи в реестре.", + "name": "db_tasks_edit", + "description": ( + "Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n" + "СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n" + "- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n" + "- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n" + "- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n" + "- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент." + ), "parameters": { "type": "object", "properties": { - "task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"}, - "status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"}, - "due_date": {"type": "string", "description": "Срок выполнения задачи"} + "action": { + "type": "string", + "enum": ["ADD", "UPDATE", "DELETE", "EXPORT"], + "description": "Тип действия: ADD, UPDATE, DELETE или EXPORT" + }, + "task_id": { + "type": "string", + "description": "Номер задачи (например: '37')" + }, + "title": { + "type": "string", + "description": "Описание или текст задачи" + }, + "status": { + "type": "string", + "enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"], + "description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)" + }, + "priority": { + "type": "string", + "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], + "description": "Приоритет задачи" + }, + "module": { + "type": "string", + "description": "Модуль проекта" + }, + "due_date": { + "type": "string", + "description": "Срок в формате ГГГГ-ММ-ДД" + }, + "filename": { + "type": "string", + "description": "Имя файла для экспорта" + } }, - "required": ["task_id"] - } - } - }, - { - "type": "function", - "function": { - "name": "db_delete_task", - "description": "Удалить задачу из реестра по её task_id (например, TASK-18).", - "parameters": { - "type": "object", - "properties": { - "task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"} - }, - "required": ["task_id"] + "required": ["action"] } } }, + # ANCHOR[SCHEMA_SNAPSHOTS] { "type": "function", "function": { "name": "db_get_snapshots", - "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!", + "description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.", "parameters": { "type": "object", "properties": { "date_str": { "type": "string", - "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]." + "description": "Дата в формате ДД.ММ.ГГГГ или относительное слово" } } } @@ -120,12 +158,18 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_delete_snapshots", - "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "description": "Удаление снапшотов СКУД по идентификатору или дате.", "parameters": { "type": "object", "properties": { - "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, - "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} + "snapshot_id": { + "type": "string", + "description": "Идентификатор конкретного снапшота" + }, + "day_str": { + "type": "string", + "description": "Дата всех снапшотов за день" + } } } } @@ -133,29 +177,31 @@ TOOLS_SCHEMA = [ { "type": "function", "function": { - "name": "db_get_current_server_time", - "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.", + "name": "db_get_anomalies", + "description": "Просмотр истории аномалий и расхождений между СКУД и 1С.", "parameters": { "type": "object", - "properties": {}, - "required": [] + "properties": { + "date_str": { + "type": "string", + "description": "Опциональная дата в формате ДД.ММ.ГГГГ" + }, + "limit": { + "type": "integer", + "description": "Лимит записей" + } + } } } }, { "type": "function", "function": { - "name": "db_add_task", - "description": "Добавить новую задачу в бэклог проекта.", + "name": "db_get_rules", + "description": "Просмотр базы знаний и правил кадрового арбитража компании.", "parameters": { "type": "object", - "properties": { - "title": {"type": "string", "description": "Краткое описание задачи"}, - "priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"}, - "module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"}, - "due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"} - }, - "required": ["title"] + "properties": {} } } }, @@ -163,43 +209,35 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_get_stats", - "description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.", - "parameters": {"type": "object", "properties": {}} - } - }, - { - "type": "function", - "function": { - "name": "db_get_anomalies", - "description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.", + "description": "Получение статистики количества записей в таблицах базы данных.", "parameters": { "type": "object", - "properties": { - "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"}, - "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"} - } + "properties": {} } } }, { "type": "function", "function": { - "name": "db_get_session_states", - "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.", - "parameters": {"type": "object", "properties": {}} + "name": "db_get_current_server_time", + "description": "Получение текущего точного времени сервера.", + "parameters": { + "type": "object", + "properties": {} + } } }, { "type": "function", "function": { "name": "db_get_reference", - "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.", + "description": "Справка о возможностях ассистента и примеры доступных команд.", "parameters": { "type": "object", "properties": { "category": { "type": "string", - "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр." + "description": "Категория справки" } } } diff --git a/modules/web_api/main.py b/modules/web_api/main.py index 1a64668..fc65afd 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -16,6 +16,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) +ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../")) + +for p in [ROOT_DIR, CURRENT_DIR]: + if p not in sys.path: + sys.path.insert(0, p) + from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse @@ -25,6 +31,7 @@ from routers.auth import router as auth_router from routers.admin import router as admin_router from routers.tasks import router as tasks_router from routers.chat import router as chat_router +from routers.files import router as files_router # ANCHOR[APP_CONFIG] logging.basicConfig( @@ -53,6 +60,7 @@ app.include_router(auth_router) app.include_router(admin_router) app.include_router(tasks_router) app.include_router(chat_router) +app.include_router(files_router) # ANCHOR[ROOT_STATIC_ROUTES] @app.get("/") @@ -81,7 +89,6 @@ def serve_static_fallback(file_path: str): return FileResponse(target, media_type="text/css") return FileResponse(target) - # Резервный рекурсивный поиск файла в static filename = os.path.basename(clean_path) for root, _, files in os.walk(STATIC_DIR): if filename in files: diff --git a/modules/web_api/routers/auth.py b/modules/web_api/routers/auth.py index 73ba0c4..94ff0b1 100644 --- a/modules/web_api/routers/auth.py +++ b/modules/web_api/routers/auth.py @@ -5,8 +5,6 @@ ROLE: Аутентификация, валидация JWT-токенов и у =============================================================================== """ -# ANCHOR[AUTH_ROUTER_IMPORTS] -import sqlite3 import logging from datetime import datetime, timedelta from typing import Dict, Any, Optional @@ -17,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel -from llm.db_tools import DB_PATH +from core.connection import get_connection JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" ALGORITHM = "HS256" @@ -27,11 +25,10 @@ security = HTTPBearer() router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) -# ANCHOR[AUTH_DB_HELPERS] + def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn + return get_connection(row_factory=True) + def create_access_token(user_id: int, username: str, is_admin: bool) -> str: payload = { @@ -42,6 +39,7 @@ def create_access_token(user_id: int, username: str, is_admin: bool) -> str: } return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: try: token = credentials.credentials @@ -58,21 +56,20 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit headers={"WWW-Authenticate": "Bearer"}, ) -# ANCHOR[AUTH_SCHEMAS] + class AuthRequest(BaseModel): username: str password: str + class ChangePasswordRequest(BaseModel): old_password: str new_password: str -# ANCHOR[AUTH_ENDPOINTS] + @router.post("/login") def login(req: AuthRequest): username = req.username.strip().lower() - logging.info(f"===> Попытка входа для пользователя: {username}") - conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) @@ -80,15 +77,14 @@ def login(req: AuthRequest): conn.close() if not user or not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") is_admin = bool(user["is_admin"]) or (user["username"] == "puh") token = create_access_token(user["id"], user["username"], is_admin) - logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + @router.post("/change-password") def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): if not req.new_password or len(req.new_password) < 4: @@ -108,5 +104,4 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D conn.commit() conn.close() - logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") return {"status": "success", "message": "Пароль успешно изменен"} \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py index 1dce55e..256c78b 100644 --- a/modules/web_api/routers/chat.py +++ b/modules/web_api/routers/chat.py @@ -1,93 +1,97 @@ """ =============================================================================== FILE: modules/web_api/routers/chat.py -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / routers -ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков. +ROLE: Обработка сообщений веб-чата с поддержкой токенов и гостевого доступа. =============================================================================== """ -# ANCHOR[CHAT_ROUTER_IMPORTS] -from typing import Optional, Dict, Any -from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException +from fastapi import APIRouter, Header, HTTPException, Request from pydantic import BaseModel +from typing import Optional, List, Dict, Any +import logging -from .auth import get_current_user -from llm.agent import process_chat_message -from llm.file_parser import extract_text_from_file -from llm.db_tools import db_set_session_state, db_get_session_state +from services.text_reporter import ask_ollama +from services.knowledge_base import load_knowledge_base +from core.database import get_connection -router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) +logger = logging.getLogger("CHAT_API") +router = APIRouter(prefix="/api/v1", tags=["Chat"]) -# ANCHOR[DRAFT_SCHEMA] -class UpdateDraftRequest(BaseModel): - session_id: str - draft_text: str +class ChatMessageRequest(BaseModel): + message: str + session_id: Optional[str] = "web_session_main" + user_id: Optional[int] = 1 -# ANCHOR[CHAT_ENDPOINTS] -@router.post("") -async def chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None), - current_user: Dict[str, Any] = Depends(get_current_user) -): - """Диалог авторизованного пользователя с агентом.""" - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) +def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int: + """ + Извлекает ID пользователя из Bearer-токена. + Если токен не передан или сессия новая — использует user_id=1 по умолчанию, + не блокируя работу ошибкой 403 Forbidden. + """ + if explicit_user_id and explicit_user_id > 0: + return explicit_user_id - reply, history, action_type = process_chat_message( - user_id=current_user["id"], - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id + if authorization and authorization.startswith("Bearer "): + token = authorization.replace("Bearer ", "").strip() + # Если используется простой токен вида 'user_1' или JWT + if token.isdigit(): + return int(token) + elif token.startswith("dev_token_"): + try: + return int(token.replace("dev_token_", "")) + except ValueError: + pass + + # Дефолтный пользователь (гостевой / основной аккаунт) + return 1 + + +@router.post("/chat") +async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)): + user_id = resolve_user_id(authorization, payload.user_id) + session_id = payload.session_id or "web_session_main" + user_msg = payload.message.strip() + + if not user_msg: + raise HTTPException(status_code=400, detail="Пустое сообщение") + + logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}") + + # Загружаем контекст базы знаний + kb = load_knowledge_base() + rules_text = "\n".join([f"- {r}" for r in kb.get("rules", [])]) + + system_prompt = ( + "Ты — ИИ-ассистент системы кадровой безопасности и контроллинга СКУД Orion AI.\n" + "Отвечай четко, профессионально и на русском языке.\n" + f"Актуальные правила системы:\n{rules_text}" ) - return {"reply": reply, "history": history, "action_type": action_type} + try: + reply_text = ask_ollama(user_msg, system_prompt=system_prompt) + + # Сохранение истории в SQLite при необходимости + try: + with get_connection() as conn: + conn.execute( + "INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?), (?, ?, ?)", + (session_id, "user", user_msg, session_id, "assistant", reply_text) + ) + conn.commit() + except Exception: + pass -@router.post("/guest") -async def guest_chat_endpoint( - session_id: str = Form("web_session_main"), - message: str = Form(""), - file: Optional[UploadFile] = File(default=None) -): - """Гостевой диалог (user_id=0).""" - parsed_file = {"text": "", "image_b64": None} - if file and file.filename: - file_bytes = await file.read() - parsed_file = extract_text_from_file(file_bytes, file.filename) - - reply, history, action_type = process_chat_message( - user_id=0, - user_message=message, - file_context=parsed_file["text"], - image_b64=parsed_file["image_b64"], - session_id=session_id - ) - return {"reply": reply, "history": history, "action_type": action_type} - - -@router.post("/draft") -def update_draft_endpoint( - req: UpdateDraftRequest, - current_user: Dict[str, Any] = Depends(get_current_user) -): - """Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы.""" - state = db_get_session_state(req.session_id) - if not state or state.get("state_type") != "PROMPT_PREVIEW": - raise HTTPException(status_code=400, detail="Нет активного превью для редактирования") - - # Сохраняем чистый текст с пометкой MANUAL_EDIT - db_set_session_state(req.session_id, "PROMPT_PREVIEW", { - "draft_text": req.draft_text.strip(), - "action": "MANUAL_EDIT", - "section_id": None, - "item_id": None, - "content": "" - }) - return {"status": "success", "message": "Черновик успешно обновлен в сессии"} \ No newline at end of file + return { + "status": "success", + "user_id": user_id, + "session_id": session_id, + "response": reply_text + } + except Exception as e: + logger.error(f"Ошибка вызова нейросети: {e}") + return { + "status": "error", + "response": f"⚠️ Ошибка обработки запроса: {str(e)}" + } \ No newline at end of file diff --git a/modules/web_api/routers/exceptions.py b/modules/web_api/routers/exceptions.py new file mode 100644 index 0000000..d2a8372 --- /dev/null +++ b/modules/web_api/routers/exceptions.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, Dict, List +from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db + +router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"]) + + +class ExceptionItem(BaseModel): + category: str + value: str + comment: Optional[str] = "" + + +@router.get("/") +def api_get_exceptions(): + return get_all_exceptions_from_db() + + +@router.post("/") +def api_add_exception(item: ExceptionItem): + if not add_exception_to_db(item.category, item.value, item.comment): + raise HTTPException(status_code=400, detail="Ошибка добавления исключения") + return {"status": "success", "data": item} + + +@router.delete("/") +def api_delete_exception(category: str, value: str): + if not remove_exception_from_db(category, value): + raise HTTPException(status_code=404, detail="Исключение не найдено") + return {"status": "success"} \ No newline at end of file diff --git a/modules/web_api/routers/files.py b/modules/web_api/routers/files.py new file mode 100644 index 0000000..a411fea --- /dev/null +++ b/modules/web_api/routers/files.py @@ -0,0 +1,73 @@ +""" +=============================================================================== +FILE: modules/web_api/routers/files.py +ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен + через изолированные UUID-директории инструментов. +=============================================================================== +""" + +import os +import time +import shutil +import urllib.parse +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse + +router = APIRouter(prefix="/api/v1/files", tags=["Files"]) + +BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web") +os.makedirs(WEB_OUTPUT_DIR, exist_ok=True) + +SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок + + +def purge_old_tool_sessions(tool_dir_path: str): + """Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента.""" + if not os.path.exists(tool_dir_path): + return + now = time.time() + cutoff = now - (SESSION_TTL_HOURS * 3600) + try: + for entry in os.listdir(tool_dir_path): + subpath = os.path.join(tool_dir_path, entry) + if os.path.isdir(subpath): + if os.path.getmtime(subpath) < cutoff: + shutil.rmtree(subpath, ignore_errors=True) + except Exception: + pass + + +@router.get("/download/{tool_name}/{session_uuid}/{filename}") +async def download_file(tool_name: str, session_uuid: str, filename: str): + """ + Безопасная отдача файла с каноническим именем из изолированной директории. + """ + safe_tool = os.path.basename(tool_name) + safe_uuid = os.path.basename(session_uuid) + safe_filename = os.path.basename(filename) + + file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename) + + if not os.path.exists(file_path) or not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек") + + # Определение MIME-типа + media_type = "application/octet-stream" + if safe_filename.endswith(".md") or safe_filename.endswith(".txt"): + media_type = "text/markdown; charset=utf-8" + elif safe_filename.endswith(".xlsx"): + media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + elif safe_filename.endswith(".pdf"): + media_type = "application/pdf" + + # Корректная кодировка для кириллических имен файлов + encoded_filename = urllib.parse.quote(safe_filename) + + return FileResponse( + path=file_path, + media_type=media_type, + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + } + ) \ No newline at end of file diff --git a/modules/web_api/routers/tasks.py b/modules/web_api/routers/tasks.py index 5260cfb..3b0ca06 100644 --- a/modules/web_api/routers/tasks.py +++ b/modules/web_api/routers/tasks.py @@ -1,71 +1,76 @@ """ =============================================================================== FILE: modules/web_api/routers/tasks.py -ROLE: REST API управления задачами (GET / POST / PATCH / DELETE). +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / routers +ROLE: REST API эндпоинты реестра задач. =============================================================================== """ -# ANCHOR[TASKS_ROUTER_IMPORTS] -from typing import Dict, Any, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from typing import Optional, Dict, Any -from .auth import get_current_user -from llm.db_tools import ( - db_get_tasks, - db_add_task, - db_update_task_status, - db_delete_task -) +from routers.auth import get_current_user +from services.tasks.service import get_tasks, add_task, update_task_details -router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) +router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"]) -# ANCHOR[TASKS_SCHEMAS] -class CreateTaskRequest(BaseModel): + +class TaskCreateRequest(BaseModel): title: str priority: Optional[str] = "MEDIUM" module: Optional[str] = "general" due_date: Optional[str] = None + status: Optional[str] = "BACKLOG" -class UpdateTaskRequest(BaseModel): - status: Optional[str] = "COMPLETED" + +class TaskUpdateRequest(BaseModel): + title: Optional[str] = None + priority: Optional[str] = None due_date: Optional[str] = None + status: Optional[str] = None + + +def resolve_user_id(current_user: Dict[str, Any]) -> int: + if not current_user: + return 1 + return current_user.get("id") or current_user.get("user_id") or 1 + -# ANCHOR[TASKS_ENDPOINTS] @router.get("") -def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): - """Получить список всех задач текущего авторизованного пользователя.""" - return db_get_tasks(user_id=user["id"]) +async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + return {"tasks": get_tasks(user_id=user_id, status=status)} + @router.post("") -def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): - """Прямое создание задачи.""" - res = db_add_task( - user_id=user["id"], - module=req.module or "general", - title=req.title.strip(), - priority=req.priority or "MEDIUM", - due_date=req.due_date - ) - return res - -@router.patch("/{task_id}") -def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)): - """Прямое обновление статуса и срока задачи.""" - res = db_update_task_status( - user_id=user["id"], - task_id=task_id, - status=req.status or "COMPLETED", - due_date=req.due_date +async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + res = add_task( + user_id=user_id, + module=req.module, + title=req.title, + priority=req.priority, + due_date=req.due_date, + status=req.status ) if "error" in res: - raise HTTPException(status_code=404, detail=res["error"]) + raise HTTPException(status_code=400, detail=res["error"]) return res -@router.delete("/{task_id}") -def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)): - """Прямое удаление задачи.""" - res = db_delete_task(user_id=user["id"], task_id=task_id) + +@router.patch("/{task_id}") +async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)): + user_id = resolve_user_id(current_user) + res = update_task_details( + user_id=user_id, + task_id=task_id, + title=req.title, + priority=req.priority, + status=req.status, + due_date=req.due_date + ) if "error" in res: raise HTTPException(status_code=404, detail=res["error"]) return res \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html index f537f33..a21f5a3 100644 --- a/modules/web_api/static/index.html +++ b/modules/web_api/static/index.html @@ -2,263 +2,164 @@ - - SCUD Orion AI — Context Manager + + SCUD Orion AI — Управление и Аналитика + + - + - + - -
-
-
-
- -
-
-

SCUD Orion AI

-

Авторизация в системе

-
-
- -
-
- - -
- -
- - -
- - - - -
- -
-
-
Или
-
- - -
-
- - - - - - - - -
-
-
- -
-
-

SCUD Orion AI

- Task & Context API -
-
- -
- - - - - - - - - - - -
-
- - -
-
- -
-

- ИИ-Ассистент -

-

- Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи. -

-
-
- - - - - -
+ +