commit cc12397e293c406b575b073faf6267580c4ac788 Author: manoraga Date: Fri Aug 14 15:01:50 2026 +0300 feat(core): initial commit unified architecture (scud_ai v2.5 with modular web_api) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb84140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Python & Bytecode +__pycache__/ +*.py[cod] +*$py.class +venv/ +.venv/ + +# Environment & Secret keys +.env +*.pem + +# IDEs +.vscode/ +.idea/ +*.swp + +# Logs & Temp +logs/ +*.log +output/ +*.tmp +~$*.xlsx + +# Данные и базы данных (при необходимости исключения) +data/scud/* +data/1c/* +!data/scud/.gitkeep +!data/1c/.gitkeep +!data/static_reason_workers.csv diff --git a/config.py b/config.py new file mode 100644 index 0000000..cec1cec --- /dev/null +++ b/config.py @@ -0,0 +1,85 @@ +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") + +# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат) +SHARE_1C_DIR = "/mnt/scud_share" + +for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_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://192.168.11.3:11434/api/generate" +OLLAMA_MODEL = "qwen2.5:14b" +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": []} \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..0e0ed14 --- /dev/null +++ b/core/database.py @@ -0,0 +1,480 @@ +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 diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db new file mode 100644 index 0000000..0867d0b Binary files /dev/null and b/data/scud_orion_ai.db differ diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm new file mode 100644 index 0000000..84061a4 Binary files /dev/null and b/data/scud_orion_ai.db-shm differ diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal new file mode 100644 index 0000000..1b919fe Binary files /dev/null and b/data/scud_orion_ai.db-wal differ diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv new file mode 100644 index 0000000..49f9ef7 --- /dev/null +++ b/data/static_reason_workers.csv @@ -0,0 +1,14 @@ +fio,reason,note +Королёва Наталья Александровна,Удаленная работа,Постоянная удаленка +Николаева Ирина Леонидовна,Удаленная работа,Постоянная удаленка +Познякова Татьяна Сергеевна,Удаленная работа,Постоянная удаленка +Софьин Никита Сергеевич,Удаленная работа,Постоянная удаленка +Чуб Александр Васильевич,Удаленная работа,Постоянная удаленка +Шуличенко Иван Иванович,Удаленная работа,Постоянная удаленка +Пухаренко Юрий Владимирович,Удаленная работа,Постоянная удаленка +Пшеничный Виктор Петрович,Удаленная работа,Постоянная удаленка +Незнанова Валерия Игоревна,Удаленная работа,Постоянная удаленка +Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка +Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка +Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка +Ароян Станислав Месропович,Удаленная работа,Временная удаленка \ No newline at end of file diff --git a/exceptions.json b/exceptions.json new file mode 100644 index 0000000..742ba5f --- /dev/null +++ b/exceptions.json @@ -0,0 +1,22 @@ +{ + "departments": [ + "ОВК" + ], + "positions": [ + "Уборщик производственных помещений", + "Уборщик служебных помещений" + ], + "fio": [ + "Таткало Валерий Валерьевич", + "Петренюк Андрей Германович", + "Чуркина Елена Геннадьевна", + "Михалев Сергей Геннадьевич" + ], + "position_keywords": [ + "уборщик", + "клинер", + "дворник", + "гардероб", + "рабочий по обслуживанию" + ] +} \ No newline at end of file diff --git a/main_etl.py b/main_etl.py new file mode 100644 index 0000000..3a1b6f5 --- /dev/null +++ b/main_etl.py @@ -0,0 +1,572 @@ +import os +import sys +import json +import argparse +import pandas as pd +from datetime import datetime, timedelta + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +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) + + +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 снапшота или время создания") + + args = parser.parse_args() + DEBUG = args.debug + + print("=" * 60) + print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if 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}')") + else: + snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") + + 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.") + else: + print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") + + # ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА + if not args.snapshot and not args.skip_export: + 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("[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 + + 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") + + print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}") + + raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) + + # Гарантированная загрузка кадров 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 + + 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) + + 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) + ) + merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + + 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} ПРОПУЩЕНО.") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/web_api/__init__.py b/modules/web_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/web_api/llm/__init__.py b/modules/web_api/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py new file mode 100644 index 0000000..e46e3ad --- /dev/null +++ b/modules/web_api/llm/agent.py @@ -0,0 +1,315 @@ +""" +=============================================================================== +FILE: modules/web_api/llm/agent.py +PROJECT: SCUD Orion AI (Unified Repository) +MODULE: web_api / llm (Core Agent & Function Calling Dispatcher) +ROLE: Главный оркестратор взаимодействия с Ollama LLM (Qwen 2.5), разбор вызовов + инструментов (Function Calling), генерация превью промпта и логирование. + +AI-CONTEXT-ANCHORS & INVARIANTS: + - ANCHOR[LOGGING_CONFIG]: Явный вывод логов в stdout для мгновенной видимости + вызовов тулов в systemd journalctl. + - ANCHOR[DYNAMIC_CONTEXT]: Сборка системного контекста (календарь, сессия, промпт). + - ANCHOR[INFERENCE_OPTIONS]: Параметры инференса (repeat_penalty, ctx_size) для + предотвращения урезания длинных списков моделью Qwen 2.5. + - ANCHOR[TOOL_ROUTER]: Диспетчеризация функций SQLite (CRUD задач, снапшотов, KB). + - ANCHOR[PROMPT_MERGE_LOGIC]: Универсальный парсер точечного добавления и + удаления пунктов системного промпта в режиме предпросмотра (PROMPT_PREVIEW). + - ANCHOR[SECONDARY_PASS]: Вторичный вызов LLM для формирования текстового ответа + на основе полученного tool_result. + +DEPENDENCIES: + - modules/web_api/llm/db_tools.py (доступ к SQLite) + - modules/web_api/llm/schemas.py (TOOLS_SCHEMA) + - modules/web_api/llm/core/calendar_utils.py (get_dynamic_calendar_context) + - modules/web_api/llm/core/tool_injector.py (clean_raw_tool_tags, clean_output) +=============================================================================== +""" + +# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_IMPORTS] +import os +import sys +import json +import urllib.request +import urllib.error +import logging +import re +from typing import List, Dict, Any, Tuple, Optional + +# Импорт фасада базы данных +from .db_tools import ( + db_get_active_system_prompt, + db_add_system_prompt, + db_get_tasks, + db_update_task_status, + db_delete_task, + db_add_task, + db_get_rules, + db_set_session_state, + db_get_session_state, + db_get_snapshots, + db_delete_snapshots, + db_clear_session_state, + db_get_current_server_time, + db_save_chat_message, + db_get_chat_history, + db_get_stats, + db_get_anomalies, + db_get_session_states, + db_get_reference +) + +from .schemas import TOOLS_SCHEMA +from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru +from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed + +# --- [SECTION 2: LOGGING CONFIGURATION] --- # ANCHOR[LOGGING_CONFIG] +logger = logging.getLogger("SCUD_AGENT") +logger.setLevel(logging.INFO) +logger.propagate = False + +if not logger.handlers: + handler = logging.StreamHandler(sys.stdout) + formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + + +# --- [SECTION 3: MAIN CHAT PROCESSING PIPELINE] --- # ANCHOR[CHAT_PROCESSOR] +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]]]: + """ + Главный конвейер обработки входящего сообщения: + 1. Сохранение сообщения пользователя. + 2. Формирование системного контекста и вызов Ollama. + 3. Выполнение вызванного Tool (если сгенерирован). + 4. Вторичный проход генерации и возврат истории. + """ + logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") + + # 3.1. Обогащение текста вложением (при наличии) + full_user_content = user_message + if file_context: + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" + + db_history = db_get_chat_history(session_id, limit=20) + db_save_chat_message(session_id, "user", full_user_content) + + # 3.2. Сборка системного контекста и правил # ANCHOR[DYNAMIC_CONTEXT] + dynamic_prompt_text = db_get_active_system_prompt() + calendar_context = get_dynamic_calendar_context() + session_state = db_get_session_state(session_id) + + preview_status_note = "" + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение ('подтверждаю') или отмена ('отмена') пользователем]." + + user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + + system_prompt_content = ( + f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n" + f"Вы общаетесь с пользователем: {user_info}.\n\n" + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" + f"{calendar_context}\n\n" + f"[ПРАВИЛА И СТРОГИЕ ТРИГГЕРЫ ВЫЗОВА ИНСТРУМЕНТОВ]\n" + f"1. ТРИГГЕРЫ ПРОСМОТРА: Если запрос содержит фразы 'покажи системный промпт', 'покажи промпт', 'выведи промпт' — ТЫ ОБЯЗАН СГЕНЕРИРОВАТЬ ToolCall: db_get_system_prompt(). Категорически ЗАПРЕЩЕНО выводить текст промпта из памяти без вызова этой функции!\n" + f"2. ТРИГГЕРЫ ПРАВКИ: Если запрос содержит слова 'добавь пункт', 'удали пункт', 'измени промпт' — ТЫ ОБЯЗАН СГЕНЕРИРОВАТЬ ToolCall: db_preview_prompt_merge(prompt_text=...).\n" + f"3. ТРИГГЕРЫ ЗАДАЧ: При фразах 'покажи задачи', 'мои задачи', 'список дел' — СРАЗУ генерируй ToolCall: db_get_tasks().\n" + f"4. ЗАПРЕТ ТЕКСТА: Запрещено объяснять правила или писать названия функций текстом, если сработал триггер — просто вызывай функцию!\n\n" + f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" + ) + + # 3.3. Параметры инференса # ANCHOR[INFERENCE_OPTIONS] + llm_options = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.1, + "repeat_penalty": 1.1, + "presence_penalty": 0.5, + "top_p": 0.9 + } + + user_msg_object = {"role": "user", "content": full_user_content} + + # --- [SECTION 4: ROUTING & OLLAMA PAYLOAD] --- # ANCHOR[PAYLOAD_BUILD] + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву без отсебятины."}, + user_msg_object + ] + payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} + else: + clean_db_history = [dict(m) for m in db_history] + for m in clean_db_history: + m.pop("images", None) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} + + # --- [SECTION 5: EXECUTION & TOOL ROUTING] --- # ANCHOR[TOOL_ROUTER] + try: + req = urllib.request.Request( + OLLAMA_URL, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req) as response: + res_data = json.loads(response.read().decode("utf-8")) + msg = res_data.get("message", {}) + tool_calls = msg.get("tool_calls", []) + raw_text_content = msg.get("content", "") + + # Фоллбэк проверка через tool_injector + tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + + if tool_calls: + logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})") + messages.append(msg) + + for tool in tool_calls: + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + logger.info(f"🚀 Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") + tool_result_content = "" + + # Роутинг инструментов + if 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) + + 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_tasks": + tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) + + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: + tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, 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_session_states": + tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) + + elif fn_name == "db_delete_snapshots": + tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")), 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) + + # --- [SECTION 6: PROMPT MERGE & PREVIEW ENGINE] --- # ANCHOR[PROMPT_MERGE_LOGIC] + elif fn_name == "db_preview_prompt_merge": + proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" + if isinstance(fn_args, str): + proposed_text = fn_args + + current_prompt = db_get_active_system_prompt() + 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 + proposed_text = "\n".join(new_lines) + + # 2. ОБРАБОТКА ДОБАВЛЕНИЯ / ИЗМЕНЕНИЯ ПУНКТА + elif 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}") + proposed_text = "\n".join(new_lines) + + db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text) + preview_reply = ( + f"Ваше изменение успешно предпросмотрено. Полный обновленный системный промпт теперь выглядит так:\n\n" + f"{proposed_text}\n\n" + f"Для применения изменений подтвердите действие («подтверждаю») или отмените («отмена»)." + ) + db_save_chat_message(session_id, "assistant", preview_reply) + # Возвращаем "PROMPT_PREVIEW" как третий параметр + return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id), "PROMPT_PREVIEW" + + elif fn_name == "db_confirm_prompt_preview": + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + res = db_add_system_prompt("main_agent", session_state.get("pending_data", "")) + db_clear_session_state(session_id) + tool_result_content = json.dumps(res, ensure_ascii=False) + else: + tool_result_content = json.dumps({"status": "error", "message": "Нет активного превью для подтверждения."}, ensure_ascii=False) + + elif fn_name == "db_cancel_prompt_preview": + db_clear_session_state(session_id) + tool_result_content = json.dumps({"status": "success", "message": "Превью системного промпта отменено."}, ensure_ascii=False) + + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), 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) + + messages.append({"role": "tool", "content": tool_result_content}) + + # --- [SECTION 7: SECONDARY LLM PASS] --- # ANCHOR[SECONDARY_PASS] + second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} + sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(sec_req) as sec_response: + sec_res_data = json.loads(sec_response.read().decode("utf-8")) + raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "") + final_content = clean_raw_tool_tags(clean_output(raw_content)) + db_save_chat_message(session_id, "assistant", final_content) + return final_content, db_get_chat_history(session_id), None + + # Если вызовов функций не было + raw_str = msg.get("content", "").strip().replace("**", "") + content_str = clean_raw_tool_tags(clean_output(raw_str)) + final_reply = content_str or "Запрос обработан." + db_save_chat_message(session_id, "assistant", final_reply) + return final_reply, db_get_chat_history(session_id), None + + except Exception as ex: + logger.exception(f"Непредвиденная ошибка: {ex}") + error_reply = f"Внутренняя ошибка сервера: {ex}" + return error_reply, db_get_chat_history(session_id), None \ No newline at end of file diff --git a/modules/web_api/llm/core/__init__.py b/modules/web_api/llm/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/web_api/llm/core/calendar_utils.py b/modules/web_api/llm/core/calendar_utils.py new file mode 100644 index 0000000..ada2e39 --- /dev/null +++ b/modules/web_api/llm/core/calendar_utils.py @@ -0,0 +1,36 @@ +import re +from datetime import datetime, timedelta + +DAYS_RU = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] + +def parse_relative_date_ru(text: str) -> str: + now = datetime.now() + text_lower = text.lower() if text else "" + match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text) + if match: + return match.group(1) + if "вчера" in text_lower: + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + elif "позавчера" in text_lower: + return (now - timedelta(days=2)).strftime("%d.%m.%Y") + elif "сегодня" in text_lower: + return now.strftime("%d.%m.%Y") + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + +def get_dynamic_calendar_context() -> str: + now = datetime.now() + current_wd = now.weekday() + lines = [ + f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).", + "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):", + f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})", + f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})", + f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})", + "\nПрошедшие дни недели:" + ] + for days_back in range(1, 8): + dt = now - timedelta(days=days_back) + day_name = DAYS_RU[dt.weekday()] + label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}" + lines.append(f"• {label}: {dt.strftime('%d.%m.%Y')}") + return "\n".join(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 new file mode 100644 index 0000000..43ac077 --- /dev/null +++ b/modules/web_api/llm/core/tool_injector.py @@ -0,0 +1,39 @@ +import re +import json +import logging +from typing import List, Dict, Any + +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 = ["почемучто", "почто", "почему что"] + lower_text = text.lower() + for art in artifacts: + if lower_text.startswith(art): + text = text[len(art):].lstrip(",.!?:; -") + return text.strip() + +def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if tool_calls: + return tool_calls + if '{"name":' in raw_text_content or '' in raw_text_content: + try: + match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content) + if match: + fn_name = match.group(1) + fn_args = json.loads(match.group(2)) + return [{"function": {"name": fn_name, "arguments": fn_args}}] + except Exception as parse_err: + logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}") + return tool_calls \ No newline at end of file diff --git a/modules/web_api/llm/db/__init__.py b/modules/web_api/llm/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/web_api/llm/db/connection.py b/modules/web_api/llm/db/connection.py new file mode 100644 index 0000000..fee40db --- /dev/null +++ b/modules/web_api/llm/db/connection.py @@ -0,0 +1,17 @@ +""" +FILE: modules/web_api/llm/db/connection.py +""" +import os +import sqlite3 + +# Динамический путь к общей БД в корне проекта +BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../")) +DB_PATH = os.path.join(BASE_ROOT, "data", "scud_orion_ai.db") + +def get_db_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30.0) + 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/modules/web_api/llm/db/db_chat.py b/modules/web_api/llm/db/db_chat.py new file mode 100644 index 0000000..0a149aa --- /dev/null +++ b/modules/web_api/llm/db/db_chat.py @@ -0,0 +1,29 @@ +""" +FILE: modules/web_api/llm/db/db_chat.py +""" +from typing import List, Dict, Any +from .connection import get_db_connection + +def db_save_chat_message(session_id: str, role: str, content: str): + if not content: + return + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO chat_messages (session_id, role, content, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + """, (session_id, role, content)) + conn.commit() + conn.close() + +def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT role, content FROM chat_messages + WHERE session_id = ? + ORDER BY id DESC LIMIT ? + """, (session_id, limit)) + rows = cursor.fetchall() + conn.close() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] \ No newline at end of file diff --git a/modules/web_api/llm/db/db_prompts.py b/modules/web_api/llm/db/db_prompts.py new file mode 100644 index 0000000..f7d7334 --- /dev/null +++ b/modules/web_api/llm/db/db_prompts.py @@ -0,0 +1,127 @@ +""" +FILE: modules/web_api/llm/db/db_prompts.py +""" +import logging +from typing import List, Dict, Any, Optional +from .connection import get_db_connection + +logger = logging.getLogger("DB_PROMPTS") + +def db_get_active_system_prompt() -> str: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") + row = cursor.fetchone() + conn.close() + return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + +def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + try: + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("BEGIN IMMEDIATE;") + cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) + existing = cursor.fetchone() + if existing: + cursor.execute( + "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", + (prompt_text, name) + ) + else: + cursor.execute( + "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", + (name, prompt_text) + ) + conn.commit() + return {"status": "success", "message": "Системный промпт успешно обновлен"} + except Exception as e: + logger.error(f"Ошибка при сохранении промпта в БД: {e}") + return {"status": "error", "error": str(e)} + +def db_get_rules() -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + +def db_set_session_state(session_id: str, state_type: str, data: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO session_states (session_id, state_type, pending_data, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET + state_type = excluded.state_type, + pending_data = excluded.pending_data, + updated_at = CURRENT_TIMESTAMP + """, (session_id, state_type, data)) + conn.commit() + conn.close() + +def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + +def db_clear_session_state(session_id: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + conn.commit() + conn.close() + +def db_get_session_states() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") + rows = cursor.fetchall() + conn.close() + return {"status": "success", "active_sessions": [dict(r) for r in rows]} + +def db_get_stats() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] + stats = {} + for t in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + stats[t] = cursor.fetchone()[0] + except Exception: + stats[t] = 0 + conn.close() + return {"status": "success", "tables_stats": stats} + +def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" + params = [] + if date_str: + query += " WHERE anomaly_date = ?" + params.append(date_str) + query += " ORDER BY id DESC LIMIT ?" + params.append(limit) + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} + +def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT category, title, example_prompt, description FROM system_reference" + params = [] + if category: + query += " WHERE category = ?" + params.append(category) + query += " ORDER BY id ASC" + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} \ 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 new file mode 100644 index 0000000..6918033 --- /dev/null +++ b/modules/web_api/llm/db/db_snapshots.py @@ -0,0 +1,49 @@ +""" +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 new file mode 100644 index 0000000..4bb97bd --- /dev/null +++ b/modules/web_api/llm/db/db_tasks.py @@ -0,0 +1,82 @@ +""" +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 new file mode 100644 index 0000000..806fb07 --- /dev/null +++ b/modules/web_api/llm/db_tools.py @@ -0,0 +1,30 @@ +""" +FILE: modules/web_api/llm/db_tools.py +""" +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 +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 +from .db.db_prompts import ( + db_get_active_system_prompt, + db_add_system_prompt, + db_get_rules, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_session_states, + db_get_stats, + db_get_anomalies, + db_get_reference +) + +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 diff --git a/modules/web_api/llm/file_parser.py b/modules/web_api/llm/file_parser.py new file mode 100644 index 0000000..0bce46f --- /dev/null +++ b/modules/web_api/llm/file_parser.py @@ -0,0 +1,88 @@ +import base64 +import os +import subprocess +import logging +import pandas as pd + +logger = logging.getLogger("FILE_PARSER") + +def extract_text_from_file(file_bytes: bytes, filename: str) -> dict: + ext = os.path.splitext(filename)[1].lower() + temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}" + + with open(temp_filepath, "wb") as f: + f.write(file_bytes) + + try: + # 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM + if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']: + b64_str = base64.b64encode(file_bytes).decode('utf-8') + return { + "text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]", + "image_b64": b64_str + } + + # 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM) + elif ext == '.pdf': + cmd = ['pdftotext', temp_filepath, '-'] + res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + pdf_text = res.stdout.strip() + + img_prefix = f"/tmp/pdf_preview_{os.getpid()}" + subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True) + + page_png = f"{img_prefix}-1.png" + b64_str = None + if os.path.exists(page_png): + with open(page_png, "rb") as pf: + b64_str = base64.b64encode(pf.read()).decode('utf-8') + os.remove(page_png) + + context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]" + if pdf_text: + context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}" + + return { + "text": context_text, + "image_b64": b64_str + } + + # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv) + elif ext in ['.xlsx', '.xls', '.csv']: + if ext == '.csv': + df = pd.read_csv(temp_filepath) + else: + df = pd.read_excel(temp_filepath) + + total_rows = len(df) + df_preview = df.head(100) + table_str = df_preview.to_string(index=False) + note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else "" + return { + "text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}", + "image_b64": None + } + + # 4. ТЕКСТОВЫЕ ФАЙЛЫ + elif ext in ['.txt', '.log', '.json', '.xml', '.md']: + with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf: + return { + "text": tf.read().strip(), + "image_b64": None + } + + else: + return { + "text": f"[ОШИБКА: Формат {ext} не поддерживается]", + "image_b64": None + } + + except Exception as e: + logger.error(f"Ошибка при анализе файла {filename}: {e}") + return { + "text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]", + "image_b64": None + } + finally: + if os.path.exists(temp_filepath): + os.remove(temp_filepath) \ No newline at end of file diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py new file mode 100644 index 0000000..9e8d3ee --- /dev/null +++ b/modules/web_api/llm/schemas.py @@ -0,0 +1,221 @@ +TOOLS_SCHEMA = [ + { + "type": "function", + "function": { + "name": "db_get_tasks", + "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED." + } + } + } + } + }, + { + "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": "Изменить статус и/или срок выполнения задачи в реестре.", + "parameters": { + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"}, + "status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"}, + "due_date": {"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"] + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_snapshots", + "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!", + "parameters": { + "type": "object", + "properties": { + "date_str": { + "type": "string", + "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]." + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_current_server_time", + "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "db_add_task", + "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"] + } + } + }, + { + "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) если просит все.", + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"}, + "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"} + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_session_states", + "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.", + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": "db_get_reference", + "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.", + "parameters": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр." + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_add_system_prompt", + "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, + "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} + }, + "required": ["prompt_text"] + } + } + }, + { + "type": "function", + "function": { + "name": "db_preview_prompt_merge", + "description": "ВЫЗЫВАЙ ПРИ ЛЮБЫХ ИЗМЕНЕНИЯХ ПРОМПТА: добавление пункта ('добавь пункт...'), удаление пункта ('удали пункт 3.4', 'убери 3.4' или других номеров) или редактирование текста промпта. Передавай текст действия или номер удаляемого пункта в prompt_text.", + "parameters": { + "type": "object", + "properties": { + "prompt_text": { + "type": "string", + "description": "Текст нового пункта или команда/номер удаляемого пункта (например '3.4' или 'удали пункт 3.4')" + } + }, + "required": ["prompt_text"] + } + } + }, + { + "type": "function", + "function": { + "name": "db_confirm_prompt_preview", + "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": "db_cancel_prompt_preview", + "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", + "parameters": {"type": "object", "properties": {}} + } + } +] \ No newline at end of file diff --git a/modules/web_api/main.py b/modules/web_api/main.py new file mode 100644 index 0000000..4f66862 --- /dev/null +++ b/modules/web_api/main.py @@ -0,0 +1,316 @@ +""" +=============================================================================== +FILE: modules/web_api/main.py +PROJECT: SCUD Orion AI (Unified Repository) +MODULE: web_api (FastAPI REST Server & Context Management) +ROLE: Главный шлюз веб-интерфейса, авторизация пользователей (JWT/Bcrypt), + маршрутизация диалогов с LLM, OCR-парсинг файлов и управление задачами. + +AI-CONTEXT-ANCHORS & INVARIANTS: + - ANCHOR[SYS_PATH]: Добавляет директорию модуля в sys.path для корректных импортов + независимо от рабочей директории запуска (root или web_api). + - ANCHOR[STATIC_MOUNT]: Рассчитывает абсолютный путь к папке static/ для надежного + рендеринга интерфейса и ассетов (css/js/favicon). + - ANCHOR[AUTH_JWT]: Изолирует персональные пространства задач по user_id (sub). + - ANCHOR[CHAT_PIPELINE]: Оркестрирует пайплайн парсинга вложений (file_parser) и + генерации ответов LLM (agent.process_chat_message). + +DEPENDENCIES: + - modules/web_api/llm/agent.py (process_chat_message) + - modules/web_api/llm/db_tools.py (db_get_tasks, DB_PATH) + - modules/web_api/llm/file_parser.py (extract_text_from_file) +=============================================================================== +""" + +# --- [SECTION 1: SYSTEM PATHS & IMPORTS] --- # ANCHOR[SYS_PATH] +import os +import sys +import json +import sqlite3 +import logging +import urllib.request +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional + +# Гарантируем корректный импорт подмодулей web_api независимо от точки запуска +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +if CURRENT_DIR not in sys.path: + sys.path.insert(0, CURRENT_DIR) + +import jwt +from passlib.context import CryptContext +from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse +from fastapi.exceptions import RequestValidationError +from pydantic import BaseModel + +# Внутренние модули LLM и БД +from llm.agent import process_chat_message +from llm.db_tools import db_get_tasks, DB_PATH +from llm.file_parser import extract_text_from_file + +# --- [SECTION 2: CONFIGURATION & SECURITY] --- # ANCHOR[AUTH_CONFIG] +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler()] +) + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +STATIC_DIR = os.path.join(CURRENT_DIR, "static") + +app = FastAPI(title="SCUD Orion AI Context API", version="2.5") + +if os.path.exists(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") + return JSONResponse( + status_code=422, + content={"detail": exc.errors(), "body": str(exc)} + ) + +# --- [SECTION 3: DATABASE & TOKEN HELPERS] --- # ANCHOR[DB_HELPERS] +def get_db(): + """Создает безопасное соединение с SQLite БД модуля.""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# Pydantic-схемы валидации запросов +class AuthRequest(BaseModel): + username: str + password: str + +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + +# --- [SECTION 4: STATIC FILES & SPA ROUTES] --- # ANCHOR[STATIC_MOUNT] +@app.get("/") +def read_root(): + """Отдает главную страницу панели управления.""" + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.exists(index_path): + return FileResponse(index_path) + raise HTTPException(status_code=404, detail="Frontend index.html not found") + +@app.get("/favicon.ico") +async def favicon(): + file_path = os.path.join(STATIC_DIR, "favicon.ico") + if os.path.exists(file_path): + return FileResponse(file_path) + raise HTTPException(status_code=404) + +# --- [SECTION 5: AUTHENTICATION & USER MANAGEMENT] --- # ANCHOR[AUTH_JWT] +@app.post("/api/v1/auth/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,)) + user = cursor.fetchone() + 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} + +@app.post("/api/v1/auth/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: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") + return {"status": "success", "message": "Пароль успешно изменен"} + +@app.get("/api/v1/admin/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@app.post("/api/v1/admin/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@app.delete("/api/v1/admin/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} + +# --- [SECTION 6: TASK TRACKER & LLM CHAT PIPELINE] --- # ANCHOR[CHAT_PIPELINE] +@app.get("/api/v1/tasks") +def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + """Получить задачи текущего авторизованного пользователя.""" + return db_get_tasks(user_id=user["id"]) + +# --- ЧАТ С АВТОРИЗАЦИЕЙ --- +@app.post("/api/v1/chat") +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: dict = 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) + + 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 + ) + return {"reply": reply, "history": history, "action_type": action_type} + + +# --- ГОСТЕВОЙ ЧАТ --- +@app.post("/api/v1/chat/guest") +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + 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} + +# --- [SECTION 7: STATIC FALLBACK ROUTER] --- # ANCHOR[STATIC_FALLBACK] +@app.get("/{file_path:path}") +def serve_static_fallback(file_path: str): + clean_path = file_path.lstrip("/") + + target = os.path.join(STATIC_DIR, clean_path) + if os.path.isfile(target): + return FileResponse(target) + + filename = os.path.basename(clean_path) + target_js = os.path.join(STATIC_DIR, "js", filename) + if filename.endswith(".js") and os.path.isfile(target_js): + return FileResponse(target_js, media_type="application/javascript") + + target_css = os.path.join(STATIC_DIR, "css", filename) + if filename.endswith(".css") and os.path.isfile(target_css): + return FileResponse(target_css, media_type="text/css") + + raise HTTPException(status_code=404, detail="File not found") \ No newline at end of file diff --git a/modules/web_api/scripts/clear_history.py b/modules/web_api/scripts/clear_history.py new file mode 100644 index 0000000..6b27454 --- /dev/null +++ b/modules/web_api/scripts/clear_history.py @@ -0,0 +1,40 @@ +""" +Скрипт полной очистки истории диалогов и сессионных состояний SQLite. +""" +import os +import sqlite3 + +# Рассчитываем путь к общей БД data/scud_orion_ai.db в корне проекта +BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db") + +def clear_chat_history(): + if not os.path.exists(DB_PATH): + print(f"❌ База данных не найдена по адресу: {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Очищаем таблицы сообщений и стейтов + try: + cursor.execute("DELETE FROM chat_messages;") + except sqlite3.OperationalError: + pass + + try: + cursor.execute("DELETE FROM session_states;") + except sqlite3.OperationalError: + pass + + try: + cursor.execute("DELETE FROM chat_sessions;") + except sqlite3.OperationalError: + pass + + conn.commit() + conn.close() + print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!") + +if __name__ == "__main__": + clear_chat_history() \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_db.py b/modules/web_api/scripts/diagnostics/inspect_db.py new file mode 100644 index 0000000..4f7a02f --- /dev/null +++ b/modules/web_api/scripts/diagnostics/inspect_db.py @@ -0,0 +1,47 @@ +import os +import sqlite3 + +# Автопоиск файла базы данных в проекте +db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db' + +print("=" * 80) +print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") +print("=" * 80) + +if not os.path.exists(db_path): + print(f"❌ Файл базы данных {db_path} не найден!") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 1. Список всех таблиц и колонок +cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") +tables = [t[0] for t in cursor.fetchall()] + +print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") +print("-" * 80) +for t_name in tables: + cursor.execute(f"PRAGMA table_info({t_name})") + cols = [c[1] for c in cursor.fetchall()] + + cursor.execute(f"SELECT COUNT(*) FROM {t_name}") + count = cursor.fetchone()[0] + + print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") + +# 2. Просмотр правил Базы Знаний +if 'ai_knowledge_base' in tables: + print("\n" + "=" * 80) + print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") + print("=" * 80) + cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") + rules = cursor.fetchall() + if not rules: + print("Таблица ai_knowledge_base пуста.") + else: + for r_id, r_text, r_author in rules: + print(f" {r_id}. [{r_author}] {r_text}\n") + +conn.close() +print("=" * 80) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/inspect_files.py b/modules/web_api/scripts/diagnostics/inspect_files.py new file mode 100644 index 0000000..fa3a4f3 --- /dev/null +++ b/modules/web_api/scripts/diagnostics/inspect_files.py @@ -0,0 +1,23 @@ +import os + +print("=" * 80) +print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)") +print("=" * 80) + +total_files = 0 +total_size = 0 + +for root, dirs, files in os.walk('.'): + # Исключаем служебные каталоги + dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] + + for f in files: + p = os.path.join(root, f) + size = os.path.getsize(p) + total_files += 1 + total_size += size + print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) + +print("-" * 80) +print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") +print("=" * 80) \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/make_code_snapshot.py b/modules/web_api/scripts/diagnostics/make_code_snapshot.py new file mode 100644 index 0000000..066bb1e --- /dev/null +++ b/modules/web_api/scripts/diagnostics/make_code_snapshot.py @@ -0,0 +1,31 @@ +import os + +OUTPUT_SNAPSHOT = "api_code_snapshot.md" + +# Расширения файлов для включения в снимок +ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'} +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} +EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'} + +print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") + +with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: + out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n") + + for root, dirs, files in os.walk('.'): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + + for file in sorted(files): + ext = os.path.splitext(file)[1].lower() + if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: + filepath = os.path.join(root, file) + out.write(f"## File: `{filepath}`\n") + out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + out.write(f.read()) + except Exception as e: + out.write(f"// Ошибка чтения файла: {e}\n") + out.write("\n```\n\n") + +print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") \ No newline at end of file diff --git a/modules/web_api/scripts/diagnostics/show_tree.py b/modules/web_api/scripts/diagnostics/show_tree.py new file mode 100644 index 0000000..66bc76d --- /dev/null +++ b/modules/web_api/scripts/diagnostics/show_tree.py @@ -0,0 +1,21 @@ +import os + +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} + +def print_tree(startpath): + print("=" * 60) + print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") + print("=" * 60) + for root, dirs, files in os.walk(startpath): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + level = root.replace(startpath, '').count(os.sep) + indent = ' ' * 4 * (level) + print(f'{indent}📁 {os.path.basename(root)}/') + subindent = ' ' * 4 * (level + 1) + for f in sorted(files): + if not f.endswith('.pyc'): + print(f'{subindent}📄 {f}') + print("=" * 60) + +if __name__ == "__main__": + print_tree('.') \ No newline at end of file diff --git a/modules/web_api/static/css/styles.css b/modules/web_api/static/css/styles.css new file mode 100644 index 0000000..78f3e4d --- /dev/null +++ b/modules/web_api/static/css/styles.css @@ -0,0 +1,20 @@ +/* Плавное исчезновение текста сверху при скролле */ +.fade-scroll-top { + mask-image: linear-gradient(to bottom, transparent 0%, black 14px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px); +} + +/* Скрытие стандартного скроллбара */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */ +body { + min-height: 100vh; + min-height: -webkit-fill-available; +} diff --git a/modules/web_api/static/favicon.ico b/modules/web_api/static/favicon.ico new file mode 100644 index 0000000..8c54837 --- /dev/null +++ b/modules/web_api/static/favicon.ico @@ -0,0 +1,6 @@ +@app.get("/favicon.ico") +async def favicon(): + file_path = os.path.join("static", "favicon.ico") + if os.path.exists(file_path): + return FileResponse(file_path) + raise HTTPException(status_code=404) \ No newline at end of file diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html new file mode 100644 index 0000000..a39b46d --- /dev/null +++ b/modules/web_api/static/index.html @@ -0,0 +1,261 @@ + + + + + + SCUD Orion AI — Context Manager + + + + + + + +
+
+
+
+ +
+
+

SCUD Orion AI

+

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

+
+
+ +
+
+ + +
+ +
+ + +
+ + + + +
+ +
+
+
Или
+
+ + +
+
+ + + + + + + + +
+
+
+ +
+
+

SCUD Orion AI

+ Task & Context API +
+
+ +
+ + + + + + + + + + + +
+
+ + +
+
+ +
+

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

+

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

+
+
+ + + + +
+
+ + + + +
+ +
+ +
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/modules/web_api/static/js/app.js b/modules/web_api/static/js/app.js new file mode 100644 index 0000000..4a727d9 --- /dev/null +++ b/modules/web_api/static/js/app.js @@ -0,0 +1,36 @@ +const AUTH_TOKEN_KEY = "scud_api_auth_token"; +const SESSION_ID = "web_session_main"; +const STORAGE_KEY = "scud_chat_input_history"; + +let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || ""; +let CURRENT_USERNAME = localStorage.getItem("scud_username") || ""; +let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true"; +let IS_GUEST = localStorage.getItem("scud_is_guest") === "true"; + +let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); +let historyIndex = -1; + +document.addEventListener("DOMContentLoaded", () => { + const userInputEl = document.getElementById("user-input"); + + if (userInputEl) { + userInputEl.addEventListener("input", function() { + this.style.height = "24px"; + const newHeight = Math.min(this.scrollHeight, 120); + this.style.height = newHeight + "px"; + }); + } + + if (IS_GUEST) { + hideAuthModal(); + updateUIState(); + } else if (API_TOKEN) { + hideAuthModal(); + updateUIState(); + if (typeof loadTasks === "function") { + loadTasks(); + } + } else { + showAuthModal(); + } +}); \ No newline at end of file diff --git a/modules/web_api/static/js/auth.js b/modules/web_api/static/js/auth.js new file mode 100644 index 0000000..21f2bbb --- /dev/null +++ b/modules/web_api/static/js/auth.js @@ -0,0 +1,308 @@ +function showAuthModal() { + const el = document.getElementById("auth-modal"); + if (el) el.classList.remove("hidden"); +} + +function hideAuthModal() { + const el = document.getElementById("auth-modal"); + if (el) el.classList.add("hidden"); +} + +async function handleLogin(e) { + if (e && e.preventDefault) e.preventDefault(); + + const usernameInput = document.getElementById("auth-username-input"); + const passwordInput = document.getElementById("auth-password-input"); + const errorEl = document.getElementById("auth-error"); + + if (!usernameInput || !passwordInput) return; + + const username = usernameInput.value.trim(); + const password = passwordInput.value; + + if (!username || !password) return; + + if (errorEl) errorEl.classList.add("hidden"); + + try { + const res = await fetch("/api/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + + const data = await res.json(); + + if (res.status === 200) { + // Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError + API_TOKEN = data.token; + CURRENT_USERNAME = data.username; + IS_ADMIN = data.is_admin; + IS_GUEST = false; + + localStorage.setItem("scud_api_auth_token", data.token); + localStorage.setItem("scud_username", data.username); + localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false"); + localStorage.removeItem("scud_is_guest"); + + hideAuthModal(); + updateUIState(); + + if (typeof loadTasks === 'function') { + loadTasks(); + } + } else { + if (errorEl) { + errorEl.innerText = data.detail || "Ошибка авторизации"; + errorEl.classList.remove("hidden"); + } + } + } catch (err) { + console.error("[Auth Error]", err); + if (errorEl) { + errorEl.innerText = "Ошибка соединения с сервером"; + errorEl.classList.remove("hidden"); + } + } +} + +function enableGuestMode() { + IS_GUEST = true; + API_TOKEN = ""; + CURRENT_USERNAME = "Гость"; + IS_ADMIN = false; + localStorage.setItem("scud_is_guest", "true"); + hideAuthModal(); + updateUIState(); +} + +function logout() { + localStorage.removeItem("scud_api_auth_token"); + localStorage.removeItem("scud_username"); + localStorage.removeItem("scud_is_admin"); + localStorage.removeItem("scud_is_guest"); + API_TOKEN = ""; + CURRENT_USERNAME = ""; + IS_ADMIN = false; + IS_GUEST = false; + showAuthModal(); +} + +function updateUIState() { + const tasksBtn = document.getElementById("tasks-drawer-btn"); + const adminBtn = document.getElementById("admin-users-btn"); + const changePwdBtn = document.getElementById("change-pwd-btn"); + const guestBadge = document.getElementById("guest-badge"); + const usernameBadge = document.getElementById("username-badge"); + + if (typeof IS_GUEST !== 'undefined' && IS_GUEST) { + if (tasksBtn) tasksBtn.classList.add("hidden"); + if (adminBtn) adminBtn.classList.add("hidden"); + if (changePwdBtn) changePwdBtn.classList.add("hidden"); + if (guestBadge) guestBadge.classList.remove("hidden"); + if (usernameBadge) usernameBadge.classList.add("hidden"); + } else { + if (tasksBtn) tasksBtn.classList.remove("hidden"); + if (changePwdBtn) changePwdBtn.classList.remove("hidden"); + if (guestBadge) guestBadge.classList.add("hidden"); + + if (usernameBadge) { + usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User"; + usernameBadge.classList.remove("hidden"); + } + + if (adminBtn) { + const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh"); + if (isAdminUser) { + adminBtn.classList.remove("hidden"); + } else { + adminBtn.classList.add("hidden"); + } + } + } +} + +function openChangePasswordModal() { + const el = document.getElementById("change-pwd-modal"); + if (el) el.classList.remove("hidden"); +} + +function closeChangePasswordModal() { + const el = document.getElementById("change-pwd-modal"); + if (el) el.classList.add("hidden"); + + const err = document.getElementById("pwd-error"); + const succ = document.getElementById("pwd-success"); + if (err) err.classList.add("hidden"); + if (succ) succ.classList.add("hidden"); + + document.getElementById("old-pwd-input").value = ""; + document.getElementById("new-pwd-input").value = ""; + const confirmInput = document.getElementById("confirm-pwd-input"); + if (confirmInput) confirmInput.value = ""; +} + +async function handleChangePassword(e) { + if (e && e.preventDefault) e.preventDefault(); + const old_password = document.getElementById("old-pwd-input").value; + const new_password = document.getElementById("new-pwd-input").value; + const confirmInput = document.getElementById("confirm-pwd-input"); + const confirm_password = confirmInput ? confirmInput.value : new_password; + const errorEl = document.getElementById("pwd-error"); + const successEl = document.getElementById("pwd-success"); + + if (errorEl) errorEl.classList.add("hidden"); + if (successEl) successEl.classList.add("hidden"); + + if (new_password !== confirm_password) { + if (errorEl) { + errorEl.innerText = "Новые пароли не совпадают"; + errorEl.classList.remove("hidden"); + } + return; + } + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ old_password, new_password }) + }); + + const data = await res.json(); + + if (res.status === 200) { + if (successEl) { + successEl.innerText = "Пароль успешно изменен!"; + successEl.classList.remove("hidden"); + } + setTimeout(closeChangePasswordModal, 1500); + } else { + if (errorEl) { + errorEl.innerText = data.detail || "Ошибка при смене пароля"; + errorEl.classList.remove("hidden"); + } + } + } catch (err) { + if (errorEl) { + errorEl.innerText = "Ошибка соединения с сервером"; + errorEl.classList.remove("hidden"); + } + } +} + +function openAdminModal() { + const el = document.getElementById("admin-modal"); + if (el) el.classList.remove("hidden"); + loadUsersList(); +} + +function closeAdminModal() { + const el = document.getElementById("admin-modal"); + if (el) el.classList.add("hidden"); +} + +async function loadUsersList() { + const listEl = document.getElementById("admin-users-list"); + if (!listEl) return; + listEl.innerHTML = '
Загрузка пользователей...
'; + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/admin/users", { + headers: { "Authorization": "Bearer " + token } + }); + const users = await res.json(); + + if (res.status === 200) { + listEl.innerHTML = users.map(u => { + const adminTag = u.is_admin ? 'ADMIN' : 'USER'; + const fullNameHtml = u.full_name ? `
${u.full_name}
` : ''; + const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—'; + const deleteBtn = u.username !== CURRENT_USERNAME ? `` : 'Вы'; + + return ` +
+
+
+ ${u.username} + ${adminTag} +
+ ${fullNameHtml} +
Создан: ${dateStr}
+
+ ${deleteBtn} +
+ `; + }).join(""); + } else { + listEl.innerHTML = `
${users.detail}
`; + } + } catch (err) { + listEl.innerHTML = '
Ошибка загрузки пользователей
'; + } +} + +async function handleCreateUser(e) { + if (e && e.preventDefault) e.preventDefault(); + const username = document.getElementById("new-user-name").value.trim(); + const password = document.getElementById("new-user-pwd").value; + const fullNameInput = document.getElementById("new-user-fullname"); + const full_name = fullNameInput ? fullNameInput.value.trim() : ""; + const adminCheckbox = document.getElementById("new-user-is-admin"); + const is_admin = adminCheckbox ? adminCheckbox.checked : false; + const msgEl = document.getElementById("admin-msg"); + + if (msgEl) msgEl.classList.add("hidden"); + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const res = await fetch("/api/v1/admin/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + token + }, + body: JSON.stringify({ username, password, full_name, is_admin }) + }); + + const data = await res.json(); + + if (res.status === 200) { + document.getElementById("new-user-name").value = ""; + document.getElementById("new-user-pwd").value = ""; + if (fullNameInput) fullNameInput.value = ""; + if (adminCheckbox) adminCheckbox.checked = false; + loadUsersList(); + } else { + if (msgEl) { + msgEl.innerText = data.detail || "Ошибка"; + msgEl.classList.remove("hidden"); + } + } + } catch (err) { + if (msgEl) { + msgEl.innerText = "Ошибка связи с сервером"; + msgEl.classList.remove("hidden"); + } + } +} + +async function deleteUser(userId, username) { + if (!confirm("Удалить пользователя " + username + "?")) return; + + try { + const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + await fetch("/api/v1/admin/users/" + userId, { + method: "DELETE", + headers: { "Authorization": "Bearer " + token } + }); + loadUsersList(); + } catch (err) { + alert("Ошибка при удалении"); + } +} \ No newline at end of file diff --git a/modules/web_api/static/js/chat.js b/modules/web_api/static/js/chat.js new file mode 100644 index 0000000..4083a01 --- /dev/null +++ b/modules/web_api/static/js/chat.js @@ -0,0 +1,303 @@ +// Вспомогательная функция для автоматического изменения высоты текстового поля +function updateInputHeight(el) { + if (!el) return; + el.style.height = "24px"; + const newHeight = Math.min(el.scrollHeight, 120); + el.style.height = newHeight + "px"; +} + +let selectedFile = null; + +function handleFileSelect(e) { + const file = e.target.files[0]; + if (!file) return; + + if (file.size > 15 * 1024 * 1024) { + alert("Файл слишком большой. Максимальный размер: 15 МБ"); + e.target.value = ""; + return; + } + + selectedFile = file; + const fileNameEl = document.getElementById("file-name-display"); + const fileSizeEl = document.getElementById("file-size-display"); + const previewContainer = document.getElementById("file-preview-container"); + + if (fileNameEl) fileNameEl.innerText = file.name; + if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; + if (previewContainer) previewContainer.classList.remove("hidden"); +} + +function clearAttachedFile() { + selectedFile = null; + const fileInput = document.getElementById("file-input"); + const previewContainer = document.getElementById("file-preview-container"); + if (fileInput) fileInput.value = ""; + if (previewContainer) previewContainer.classList.add("hidden"); +} + +// Деактивация всех старых кнопок в истории +function disableAllActionButtons() { + const allBtnContainers = document.querySelectorAll(".action-buttons-container"); + allBtnContainers.forEach(container => { + container.querySelectorAll("button").forEach(btn => { + btn.disabled = true; + btn.classList.add("opacity-40", "cursor-not-allowed"); + }); + }); +} + +// Быстрая отправка текста кнопки +function handleActionButtonClick(text) { + disableAllActionButtons(); + const input = document.getElementById("user-input"); + if (input) { + input.value = text; + sendMessage(); + } +} + +async function sendMessage(e) { + if (e && e.preventDefault) e.preventDefault(); + + const input = document.getElementById("user-input"); + const chatWindow = document.getElementById("chat-window"); + const sendBtn = document.getElementById("send-btn"); + + if (!input || !chatWindow) return; + const text = input.value.trim(); + + if (!text && !selectedFile) return; + + // Деактивируем предыдущие интерактивные кнопки + disableAllActionButtons(); + + let userDisplayHtml = escapeHtml(text); + if (selectedFile) { + userDisplayHtml = `
+ ${escapeHtml(selectedFile.name)} +
` + userDisplayHtml; + } + + const userMsgHtml = ` +
+
+ ${userDisplayHtml} +
+
+ `; + chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); + + input.value = ""; + updateInputHeight(input); + chatWindow.scrollTop = chatWindow.scrollHeight; + + if (sendBtn) { + sendBtn.disabled = true; + sendBtn.classList.add("opacity-50"); + } + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); + + const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; + + const formData = new FormData(); + formData.append("session_id", "web_session_main"); + formData.append("message", text || "Проанализируй прикрепленный файл"); + + if (selectedFile instanceof File) { + formData.append("file", selectedFile, selectedFile.name); + } + + const headers = {}; + if (!isGuest && token) { + headers["Authorization"] = "Bearer " + token; + } + + try { + const res = await fetch(endpoint, { + method: "POST", + headers: headers, + body: formData + }); + + if (res.status === 401 && !isGuest) { + if (typeof logout === 'function') logout(); + return; + } + + const data = await res.json(); + const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; + const replyText = data.reply || "Пустой ответ от нейросети"; + + // Генерация блока кнопок подтверждения при необходимости + let actionButtonsHtml = ""; + if (data.action_type === "PROMPT_PREVIEW") { + actionButtonsHtml = ` +
+ + +
+ `; + } + + const botMsgHtml = ` +
+

+ ${assistantTitle} +

+

${escapeHtml(replyText)}

+ ${actionButtonsHtml} +
+ `; + chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + + clearAttachedFile(); + + if (!isGuest && typeof loadTasks === 'function') { + loadTasks(); + } + + } catch (err) { + console.error("[Chat Error]", err); + const errorHtml = ` +
+ Ошибка связи с сервером. +
+ `; + chatWindow.insertAdjacentHTML("beforeend", errorHtml); + chatWindow.scrollTop = chatWindow.scrollHeight; + } finally { + if (sendBtn) { + sendBtn.disabled = false; + sendBtn.classList.remove("opacity-50"); + } + } +} + +function escapeHtml(text) { + if (!text) return ""; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +document.addEventListener("DOMContentLoaded", () => { + const input = document.getElementById("user-input"); + const dropZone = document.getElementById("chat-window")?.parentElement; + const dropOverlay = document.getElementById("drop-overlay"); + + if (input) { + let historyIndex = -1; + let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + const text = input.value.trim(); + if (text) { + if (localHistory.length === 0 || localHistory[0] !== text) { + localHistory.unshift(text); + if (localHistory.length > 50) localHistory.pop(); + localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); + } + historyIndex = -1; + } + sendMessage(e); + updateInputHeight(input); + return; + } + + if (e.key === "ArrowUp") { + const textBeforeCursor = input.value.substring(0, input.selectionStart); + const isFirstLine = !textBeforeCursor.includes("\n"); + + if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { + if (historyIndex < localHistory.length - 1) { + e.preventDefault(); + if (historyIndex === -1) { + input.dataset.draft = input.value; + } + historyIndex++; + input.value = localHistory[historyIndex]; + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + } + + if (e.key === "ArrowDown") { + const textAfterCursor = input.value.substring(input.selectionEnd); + const isLastLine = !textAfterCursor.includes("\n"); + + if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { + e.preventDefault(); + if (historyIndex > 0) { + historyIndex--; + input.value = localHistory[historyIndex]; + } else { + historyIndex = -1; + input.value = input.dataset.draft || ""; + } + updateInputHeight(input); + input.setSelectionRange(input.value.length, input.value.length); + } + } + }); + } + + if (dropZone && dropOverlay) { + ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }, false); + }); + + ["dragenter", "dragover"].forEach(eventName => { + dropZone.addEventListener(eventName, () => { + dropOverlay.classList.remove("hidden"); + dropOverlay.classList.add("flex"); + }, false); + }); + + ["dragleave", "drop"].forEach(eventName => { + dropZone.addEventListener(eventName, (e) => { + if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { + dropOverlay.classList.add("hidden"); + dropOverlay.classList.remove("flex"); + } + }, false); + }); + + dropZone.addEventListener("drop", (e) => { + const dt = e.dataTransfer; + const files = dt.files; + + if (files && files.length > 0) { + const file = files[0]; + handleFileSelect({ target: { files: [file] } }); + + const fileInput = document.getElementById("file-input"); + if (fileInput) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInput.files = dataTransfer.files; + } + } + }, false); + } +}); \ No newline at end of file diff --git a/modules/web_api/static/js/tasks.js b/modules/web_api/static/js/tasks.js new file mode 100644 index 0000000..36816b9 --- /dev/null +++ b/modules/web_api/static/js/tasks.js @@ -0,0 +1,145 @@ +let currentFilter = 'ALL'; +let allTasks = []; + +function toggleDrawer() { + if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return; + const drawer = document.getElementById("task-drawer"); + const backdrop = document.getElementById("drawer-backdrop"); + if (!drawer) return; + + const isHidden = drawer.classList.contains("translate-x-full"); + if (isHidden) { + drawer.classList.remove("translate-x-full"); + if (backdrop) backdrop.classList.remove("hidden"); + loadTasks(); + } else { + drawer.classList.add("translate-x-full"); + if (backdrop) backdrop.classList.add("hidden"); + } +} + +function setFilter(status) { + currentFilter = status; + ["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => { + const btn = document.getElementById(`filter-${f}`); + if (btn) { + btn.className = (f === status) + ? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap" + : "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap"; + } + }); + renderTasks(); +} + +async function loadTasks() { + const badge = document.getElementById("task-count-badge"); + const container = document.getElementById("tasks-container"); + + const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); + const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); + + if (isGuest || !token) { + if (badge) badge.innerText = "0"; + return; + } + + try { + const res = await fetch("/api/v1/tasks", { + headers: { + "Authorization": "Bearer " + token, + "Content-Type": "application/json" + } + }); + + if (res.status === 401) { + if (typeof logout === 'function') logout(); + return; + } + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + const data = await res.json(); + + // Гибкое определение структуры данных (массив или объект с ключом tasks) + if (Array.isArray(data)) { + allTasks = data; + } else if (data && Array.isArray(data.tasks)) { + allTasks = data.tasks; + } else if (data && typeof data === 'object') { + allTasks = Object.values(data).find(val => Array.isArray(val)) || []; + } else { + allTasks = []; + } + + if (badge) { + badge.innerText = allTasks.length.toString(); + } + + renderTasks(); + + } catch (err) { + console.error("[Tasks Error]", err); + if (badge) badge.innerText = "0"; + if (container) { + container.innerHTML = `
Ошибка обработки списка задач
`; + } + } +} + +function renderTasks() { + const container = document.getElementById("tasks-container"); + if (!container) return; + + if (!Array.isArray(allTasks)) { + allTasks = []; + } + + const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter); + + if (filtered.length === 0) { + container.innerHTML = `
Нет задач с выбранным фильтром
`; + return; + } + + container.innerHTML = filtered.map(t => { + let statusBadge = "bg-slate-100 text-slate-600 border-slate-200"; + let cardBg = "bg-white"; + + if (t.status === "COMPLETED") { + statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold"; + cardBg = "bg-emerald-50/20"; + } else if (t.status === "IN_PROGRESS") { + statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold"; + cardBg = "bg-amber-50/20 border-amber-200"; + } + + let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200"; + if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold"; + + let dueDateHtml = t.due_date ? ` +
+ + Срок: ${t.due_date} +
` : ""; + + return ` +
+
+
+ ${t.task_id || t.id || 'TASK'} + ${t.priority || 'MEDIUM'} +
+ ${t.status || 'BACKLOG'} +
+

${t.title || t.description || ''}

+
+ + ${t.module || 'General'} +
+ ${dueDateHtml} +
+ `; + }).join(""); +} \ No newline at end of file diff --git a/project_code_snapshot.md b/project_code_snapshot.md new file mode 100644 index 0000000..71c9bbe --- /dev/null +++ b/project_code_snapshot.md @@ -0,0 +1,4970 @@ +# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2 + +## 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") + +# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат) +SHARE_1C_DIR = "/mnt/scud_share" + +for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_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://192.168.11.3:11434/api/generate" +OLLAMA_MODEL = "qwen2.5:14b" +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": [ + "уборщик", + "клинер", + "дворник", + "гардероб", + "рабочий по обслуживанию" + ] +} +``` + +## File: `./main_etl.py` +```py +import os +import sys +import json +import argparse +import pandas as pd +from datetime import datetime, timedelta + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +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) + + +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 снапшота или время создания") + + args = parser.parse_args() + DEBUG = args.debug + + print("=" * 60) + print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if 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}')") + else: + snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") + + 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.") + else: + print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") + + # ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА + if not args.snapshot and not args.skip_export: + 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("[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 + + 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") + + print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}") + + raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) + + # Гарантированная загрузка кадров 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 + + 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) + + 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) + ) + merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' + + 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} ПРОПУЩЕНО.") + + +if __name__ == "__main__": + main() +``` + +## File: `./scripts/__init__.py` +```py + +``` + +## File: `./scripts/add_rule_ai.py` +```py +import sys +import os + +# Добавляем корень проекта в путь поиска модулей Python +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import get_connection + +rule_text = ( + "Сотрудники, присутствующие в 1С:ЗУП, но отсутствующие в СКУД Орион Pro, " + "являются аномалией синхронизации профилей. ИИ должен запрашивать у СБ статус выдачи пропуска." +) + +with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", (rule_text,)) + conn.commit() + +print("✓ Правило успешно внесено в SQLite БД!") +``` + +## 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 +from core.database import ( + get_connection, + get_available_snapshots, + get_all_rules_from_db, + load_scud_from_db_by_snapshot, + get_latest_snapshot_time +) + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +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'] + for t in tables: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + cnt = cursor.fetchone()[0] + print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + 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 + + if not snap_id.startswith("Y"): + formatted_snap_id = f" {snap_id}" + else: + formatted_snap_id = 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" + "=" * 90) + if snapshot_id: + print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):") + else: + print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):") + print("=" * 90) + + df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) + + if df.empty: + print(f"Записи СКУД не найдены.") + print("=" * 90 + "\n") + return + + total = len(df) + present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0 + absent_cnt = total - present_cnt + + print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}") + print("-" * 90) + + cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id'] + existing_cols = [c for c in cols_to_show if c in df.columns] + + print(df[existing_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" + "*" * 90) + print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}") + print("*" * 90) + + print("=" * 90 + "\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']: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table[:31], index=False) + print(f"[✓] Дамп успешно сохранен: {out_path}\n") + + +def print_system_prompts(): + """Выводит все системные промпты из базы данных.""" + print("\n" + "=" * 80) + print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") + print("=" * 80) + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") + rows = cursor.fetchall() + if not rows: + print("Таблица system_prompts пуста.") + else: + for r in rows: + print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") + print("-" * 80) + print(f"{r[4]}\n") + 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") + + +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_prompts) + sessions -- Посмотреть активные сессии и превью (session_states) + dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel + snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день + +ПРИМЕРЫ ЗАПУСКА: + 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 scud --snapshot Y20260805-007 + python scripts/db_cli.py absences 07.08.2026 + python scripts/db_cli.py prompts + python scripts/db_cli.py sessions + python scripts/db_cli.py snapshot del Y20260805-007 + python scripts/db_cli.py snapshot del --day 04.08.2026 + python scripts/db_cli.py dump my_dump.xlsx +""" + + +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'], help="Основная команда") + parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") + parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") + 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="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") + + 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.param or args.action + print_snapshots_list(date_str=date_val) + elif args.command == 'scud': + inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx) + elif args.command == 'absences': + date_val = args.param or args.action + 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 == 'dump': + filename = args.param if args.param else "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") + else: + print(f"\n[❌] Ошибка: Неизвестная команда.") + print(HELP_TEXT) + +if __name__ == "__main__": + main() +``` + +## File: `./scripts/fix_snapshots.py` +```py +import sqlite3 +import os +from datetime import datetime + +DB_PATH = 'data/scud_orion_ai.db' + + +def parse_to_date(date_str): + """Надежное преобразование даты логов и времени снапшота в объект date.""" + if not date_str: + return None + date_str = str(date_str).split(" ")[0].strip() + if "." in date_str: + try: + return datetime.strptime(date_str, "%d.%m.%Y").date() + except ValueError: + return None + elif "-" in date_str: + try: + return datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + return None + return None + + +def fix_snapshots(): + """ + Переиндексирует снапшоты в таблице scud_logs. + Нумерация веников (001, 002, 003...) сквозная внутри каждого ДНЯ СОЗДАНИЯ. + Префикс Y выставляется только если дата логов < даты создания. + """ + if not os.path.exists(DB_PATH): + print(f"База данных {DB_PATH} не найдена.") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Группируем по уникальным выгрузкам + cursor.execute(""" + SELECT snapshot_time, log_date + FROM scud_logs + WHERE snapshot_time IS NOT NULL + GROUP BY snapshot_time, log_date + ORDER BY snapshot_time ASC, log_date ASC + """) + rows = cursor.fetchall() + + if not rows: + print("Снапшоты не найдены.") + conn.close() + return + + print("=== ИСПРАВЛЕНИЕ СКВОЗНОЙ НУМЕРАЦИИ ВНУТРИ ДНЯ СОЗДАНИЯ ===") + + # Счетчик текущего порядкового номера strictly за ДЕНЬ СОЗДАНИЯ (YYYYMMDD) + seq_counters = {} + + for snap_time_raw, log_date_raw in rows: + d_snap = parse_to_date(snap_time_raw) + d_log = parse_to_date(log_date_raw) + + if not d_snap or not d_log: + continue + + date_prefix = d_snap.strftime("%Y%m%d") + + # Если дата логов строго раньше даты создания снапшота — ставим Y + is_yesterday = (d_log < d_snap) + prefix = "Y" if is_yesterday else "" + + # Приращиваем сквозной счетчик за ЭТОТ ДЕНЬ СОЗДАНИЯ + seq_counters[date_prefix] = seq_counters.get(date_prefix, 0) + 1 + seq_num = seq_counters[date_prefix] + + new_id = f"{prefix}{date_prefix}-{seq_num:03d}" + + cursor.execute( + "UPDATE scud_logs SET snapshot_id = ? WHERE snapshot_time = ? AND log_date = ?", + (new_id, snap_time_raw, log_date_raw) + ) + print(f" • Срез создания: {snap_time_raw} | Логи за: {log_date_raw} ===> Назначен ID: [{new_id}]") + + conn.commit() + conn.close() + print("\n[✓] Переиндексация внутри дней создания успешно выполнена!") + + +if __name__ == "__main__": + fix_snapshots() +``` + +## File: `./scripts/diagnostics/inspect_db.py` +```py +import os +import sqlite3 + +# Автопоиск файла базы данных в проекте +db_path = 'data/scud_orion_ai.db' if os.path.exists('data/scud_orion_ai.db') else 'scud_orion_ai.db' + +print("=" * 80) +print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") +print("=" * 80) + +if not os.path.exists(db_path): + print(f"❌ Файл базы данных {db_path} не найден!") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 1. Список всех таблиц и колонок +cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") +tables = [t[0] for t in cursor.fetchall()] + +print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") +print("-" * 80) +for t_name in tables: + cursor.execute(f"PRAGMA table_info({t_name})") + cols = [c[1] for c in cursor.fetchall()] + + cursor.execute(f"SELECT COUNT(*) FROM {t_name}") + count = cursor.fetchone()[0] + + print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") + +# 2. Просмотр правил Базы Знаний +if 'ai_knowledge_base' in tables: + print("\n" + "=" * 80) + print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") + print("=" * 80) + cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") + rules = cursor.fetchall() + if not rules: + print("Таблица ai_knowledge_base пуста.") + else: + for r_id, r_text, r_author in rules: + print(f" {r_id}. [{r_author}] {r_text}\n") + +conn.close() +print("=" * 80) +``` + +## File: `./scripts/diagnostics/inspect_files.py` +```py +import os + +print("=" * 80) +print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_ai)") +print("=" * 80) + +total_files = 0 +total_size = 0 + +for root, dirs, files in os.walk('.'): + # Исключаем служебные каталоги + dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] + + for f in files: + p = os.path.join(root, f) + size = os.path.getsize(p) + total_files += 1 + total_size += size + print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) + +print("-" * 80) +print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") +print("=" * 80) +``` + +## File: `./scripts/diagnostics/make_code_snapshot.py` +```py +import os + +OUTPUT_SNAPSHOT = "project_code_snapshot.md" + +# Расширения файлов для включения в снимок +ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} +EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} + +print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") + +with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: + out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n") + + for root, dirs, files in os.walk('.'): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + + for file in sorted(files): + ext = os.path.splitext(file)[1].lower() + if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: + filepath = os.path.join(root, file) + out.write(f"## File: `{filepath}`\n") + out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + out.write(f.read()) + except Exception as e: + out.write(f"// Ошибка чтения файла: {e}\n") + out.write("\n```\n\n") + +print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") +``` + +## File: `./scripts/diagnostics/show_tree.py` +```py +import os + +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} + +def print_tree(startpath): + print("=" * 60) + print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") + print("=" * 60) + for root, dirs, files in os.walk(startpath): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + level = root.replace(startpath, '').count(os.sep) + indent = ' ' * 4 * (level) + print(f'{indent}📁 {os.path.basename(root)}/') + subindent = ' ' * 4 * (level + 1) + for f in sorted(files): + if not f.endswith('.pyc'): + print(f'{subindent}📄 {f}') + print("=" * 60) + +if __name__ == "__main__": + print_tree('.') +``` + +## File: `./services/__init__.py` +```py + +``` + +## 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(unrecognized_scud_fios, 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 без markdown-разметки." + ) + + mapping = {} + try: + match = re.search(r'\{.*\}', raw_response, re.DOTALL) + if match: + json_str = match.group(0) + json_str = re.sub(r'\}\s*[^}\]]*$', '}', 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: + mapping[scud_f] = {"staff_fio": staff_f, "warning": warn} + except Exception as e: + print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {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/data_loader.py` +```py +import os +import pandas as pd +import warnings +from config import ( + normalize_fio, clean_scud_fio_light, load_exceptions, + DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR +) +# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite +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 is_excluded(fio, dept, pos, exceptions): + """Проверяет, входит ли сотрудник или должность в список исключений.""" + fio_clean = normalize_fio(fio) + dept_str = str(dept).strip().upper() if pd.notna(dept) else "" + pos_str = str(pos).strip().lower() if pd.notna(pos) else "" + + if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]: + return True + if any(keyword in pos_str for keyword in exceptions["position_keywords"]): + return True + return False + + +def load_staff_data(date_str): + """ + Загружает файл Штатного расписания 1С за конкретную дату из папки 1c. + """ + 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) + + # Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность + 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_data(date_str): + """ + Загружает документально подтвержденные отсутствия сотрудников + НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату, + а также автоматически обогащает их удаленщиками из static_reason_workers.csv. + """ + df_absent = None + try: + df_absent = fetch_zup_absences_from_sql(date_str) + + if df_absent is not None and not df_absent.empty: + 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']) + else: + df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) + except Exception as e: + print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}") + df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) + + # ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv + try: + from config import DATA_DIR + 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) + except Exception as e: + print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}") + + return df_absent if not df_absent.empty else None + + +def load_1c_data_smart(date_str, use_db=False): + """ + Универсальный загрузчик данных 1С (Штат и Отсутствия). + Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД + за целевую дату указанного снапшота. + """ + 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) + + # Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL + 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 + + +def process_scud_anomalies(df_scud, exceptions=None): + """ + Вычисляет 'Первую активность' и маркирует аномалии СКУД. + """ + if exceptions is None: + exceptions = load_exceptions() + + start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None) + end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None) + status_col = 'Статус' if 'Статус' in df_scud.columns else None + + first_activities = [] + anomaly_flags = [] + is_present_flags = [] + + for _, row in df_scud.iterrows(): + fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip() + fio_clean = normalize_fio(fio) + dept = str(row.get('Подразделение', '')).strip() + pos = str(row.get('Должность', '')).strip() + + val_start = str(row.get(start_col, '')).strip() if start_col else '' + val_end = str(row.get(end_col, '')).strip() if end_col else '' + val_status = str(row.get(status_col, '')).strip().lower() if status_col else '' + + raw_first_event = row.get('raw_first_event', None) + if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']: + raw_first_event = None + + has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00']) + has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00']) + + first_act = '—' + anom_flag = 'NONE' + is_present = not ('отсутствовал' in val_status or has_no_in) + + if has_no_in: + if raw_first_event is not None or not has_no_out: + first_act = str(raw_first_event) if raw_first_event else val_end + anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY' + + if not is_excluded(fio, dept, pos, exceptions): + is_present = False + else: + first_act = '—' + is_present = False + + first_activities.append(first_act) + anomaly_flags.append(anom_flag) + is_present_flags.append(is_present) + + df_scud['Первая_активность'] = first_activities + df_scud['anomaly_flag'] = anomaly_flags + df_scud['Пришел'] = is_present_flags + + return df_scud + + +def load_scud_data(target_date_type="today", snapshot_param=None): + """ + Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite. + """ + target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY + + df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param) + + if df_scud is None or df_scud.empty: + df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param) + if df_scud is None or df_scud.empty: + print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.") + return pd.DataFrame() + + exceptions = load_exceptions() + + if 'fio_clean' not in df_scud.columns: + fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0]) + df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str) + df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light) + + if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns: + df_scud['Начало_дня'] = df_scud['time_in'] + if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns: + df_scud['Конец_дня'] = df_scud['time_out'] + if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns: + df_scud['Первая_активность'] = df_scud['first_activity'] + if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns: + df_scud['Пришел'] = df_scud['is_present'].astype(bool) + + df_scud['is_excluded'] = df_scud.apply( + lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions), + axis=1 + ) + return df_scud + + +def load_all_data(): + """ + Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С. + """ + df_scud_today = load_scud_data(target_date_type="today") + try: + df_scud_yesterday = load_scud_data(target_date_type="yesterday") + except FileNotFoundError: + df_scud_yesterday = df_scud_today.copy() + + df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY) + df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY) + + return ( + df_scud_today, + df_scud_yesterday, + df_staff_today, + df_absent_today, + df_staff_yesterday, + df_absent_yesterday + ) +``` + +## File: `./services/data_validator.py` +```py +import os +import time +from datetime import datetime +from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR + + +def check_file_freshness(max_age_hours=24): + """ + Проверяет существование входных файлов 1С и СКУД. + Возвращает флаги доступности файлов за Сегодня и Вчера. + """ + warnings = [] + errors = [] + + # 1. Проверка файлов 1С за СЕГОДНЯ + staff_today = find_dated_file("Штат", DATE_TODAY) + absent_today = find_dated_file("Отсутствия", DATE_TODAY) + has_today_1c = bool(staff_today and absent_today) + + if not has_today_1c: + warnings.append( + f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. " + f"Формирование Ежедневной сводки за сегодня будет временно пропущено." + ) + + # 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета) + staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY) + absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY) + + if not staff_yesterday or not absent_yesterday: + errors.append( + f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. " + f"Невозможно построить детальный отчет за прошлую смену." + ) + + # 3. Проверка файлов СКУД + scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR]) + if not scud_today: + warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}") + + is_valid = len(errors) == 0 + return is_valid, warnings, errors, has_today_1c +``` + +## 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 OUTPUT_DIR + +# Словарь месяцев для текстового формата в названиях файлов +MONTHS_RU = { + 1: "января", 2: "февраля", 3: "марта", 4: "апреля", + 5: "мая", 6: "июня", 7: "июля", 8: "августа", + 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" +} + + +def format_date_ru(date_str): + """Преобразует дату формата '27.07.2026' в '27 июля 2026'""" + try: + dt = datetime.strptime(date_str, "%d.%m.%Y") + return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}" + except Exception: + return date_str + + +# Границы ячеек +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_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска" + +CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "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): + """Рассчитывает точное отклонение от нормы с учетом обеда 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="27.07.2026", filename=None): + if not filename: + filename = f"{format_date_ru(date_str)} сводка.xlsx" + + output_path = os.path.join(OUTPUT_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. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) + absent_only = merged_df[ + (merged_df['Пришел'] == False) & + (merged_df['Вид_отсутствия'].notna()) & + (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) + ] + 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. ИТОГО НА РАБОТЕ (Свернут hidden=True) + is_working_mask = (merged_df['Пришел'] == True) | ( + merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + ) + present = merged_df[is_working_mask] + + 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 + + # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ) + anomalies = merged_df[ + (~is_exc) & ( + ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) | + (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 + + 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(OUTPUT_DIR, alt_filename) + wb.save(alt_path) + print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + + +# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- +def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): + if not filename: + filename = f"{format_date_ru(date_str)} отчет.xlsx" + + output_path = os.path.join(OUTPUT_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 merged_df.columns else 'Начало_дня' + end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня' + hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании' + + chars_per_line_h = 24 + + for idx, row in merged_df.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(OUTPUT_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(OUTPUT_DIR, filename) + df_scud.to_excel(output_path, index=False) +``` + +## File: `./services/feedback_loop.py` +```py +import os +import json +from core.database import add_rule_to_db + +try: + from services.knowledge_base import save_rule +except ImportError: + try: + from services.knowledge_base import add_rule as save_rule + except ImportError: + save_rule = None + + +def review_ai_decisions(report_text, suspicious_cases): + """ + Интерактивный консольный модуль обучения (Human-in-the-Loop). + Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям. + """ + if not suspicious_cases: + print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.") + return + + print("\n" + "=" * 60) + print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)") + print("=" * 60) + + for idx, case in enumerate(suspicious_cases, 1): + fio_target = case.get('fio_target', 'Неизвестно') + reason = case.get('reason', 'Нет описания') + + print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}") + print(f" Детали: {reason}") + + user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip() + user_input_clean = user_input.lower() + + # Расширенный список вариантов "ДА" + if user_input_clean in ['д', 'да', 'da', 'yes', 'y', 'ок', 'ага', '+']: + print(" [✓] Решение подтверждено оператором.") + elif user_input_clean in ['н', 'нет', 'no', 'n', '-']: + print(" [ℹ️] Решение отклонено оператором.") + elif user_input: + # Если оператор обучает систему синониму отделов + if "отдел" in user_input_clean or "овк" in user_input_clean: + from core.database import add_department_synonym_to_db + add_department_synonym_to_db(user_input.strip(), "отдел внутреннего контроля") + + new_rule = f"Правило: {user_input}" + add_rule_to_db(new_rule, added_by="Human_Admin") + print(f" [✓] Новое правило записано в Базу Знаний SQLite: {new_rule}") + else: + print(" [ℹ️] Запись пропущена.") + + print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.") +``` + +## 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/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) + +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.31.221\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/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): + """ + Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ). + Сравнивает фамилию и полное имя с порогом сходства >= 0.75. + """ + 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"): + # 1. Загрузка Базы Знаний + 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 "Специфических правил компании пока нет." + + # 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff) + total_staff = len(merged_df) + is_working_mask = (merged_df['Пришел'] == True) | ( + merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + ) + present_cnt = len(merged_df[is_working_mask]) + explained_cnt = len(absent_explained) + unexplained_cnt = len(absent_unexplained) + anomalies_cnt = len(anomalies_list) if anomalies_list else 0 + + # 3. Точный расчет неточных совпадений через Python + fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) + + # 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД + 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}") + + # 5. Сборка промпта для Ollama + prompt = f""" +Ты — старший аудитор кадровой безопасности и контроллинга СКУД. +Сформируй итоговую сводку кадрового контроля на {date_str}. + +📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ: +{custom_rules_str} + +🚨 ВХОДНЫЕ МЕТРИКИ: +- Всего сотрудников: {total_staff} +- Работают (офис / удаленка / командировки): {present_cnt} +- Официально отсутствуют: {explained_cnt} +- Неизвестно (истинно неотмеченные): {unexplained_cnt} +- Выявлено аномалий/конфликтов реестров: {anomalies_cnt} + +--- +🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): +{json.dumps(anomalies_list, ensure_ascii=False, indent=2)} + +--- +⚠️ ПОДТВЕРЖДЁННЫЕ 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. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." +2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. +3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций. + +СТРОГИЙ ШАБЛОН ОТВЕТА: + +**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** + +- Всего офисных сотрудников: **{total_staff}** +- Работают (офис / удаленка / командировки): **{present_cnt}** +- Официально отсутствуют: **{explained_cnt}** +- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. +- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. + +#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): +(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.") + +#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: +(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.") + +#### Неизвестные случаи: {unexplained_cnt} +(Выведи нумерованный список всех {unexplained_cnt} человек) + +### Точечные рекомендации: +(2-3 системные рекомендации без указания ФИО сотрудников) +""" + + sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы." + return ask_ollama(prompt, system_prompt=sys_prompt) +``` + +## 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=5) 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: `./core/__init__.py` +```py + +``` + +## File: `./core/database.py` +```py +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 + +``` + +## File: `./modules/__init__.py` +```py + +``` + +## File: `./modules/web_api/__init__.py` +```py + +``` + +## File: `./modules/web_api/main.py` +```py +import json +import sqlite3 +import logging +import urllib.request +import os +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional + +import jwt +from passlib.context import CryptContext +from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from llm.agent import process_chat_message +from llm.db_tools import db_get_tasks, DB_PATH +from llm.file_parser import extract_text_from_file + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.StreamHandler()] +) + +JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" +ALGORITHM = "HS256" +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +MODEL_NAME = "qwen2.5:14b" + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def create_access_token(user_id: int, username: str, is_admin: bool) -> str: + payload = { + "sub": str(user_id), + "username": username, + "is_admin": is_admin, + "exp": datetime.utcnow() + timedelta(days=30) + } + return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + +def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: + try: + token = credentials.credentials + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + user_id = int(payload.get("sub")) + username = payload.get("username") + is_admin = bool(payload.get("is_admin", False)) + return {"id": user_id, "username": username, "is_admin": is_admin} + except Exception as e: + logging.warning(f"Auth error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Недействительный или просроченный токен авторизации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +app = FastAPI(title="SCUD Orion AI Context API") + +if os.path.exists("static"): + app.mount("/static", StaticFiles(directory="static"), name="static") +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") + return JSONResponse( + status_code=422, + content={"detail": exc.errors(), "body": str(exc)} + ) + +class AuthRequest(BaseModel): + username: str + password: str + +class CreateUserRequest(BaseModel): + username: str + password: str + full_name: Optional[str] = None + is_admin: Optional[bool] = False + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + +# === API МАРШРУТЫ === + +@app.get("/") +def read_root(): + return FileResponse("static/index.html") + +@app.get("/favicon.ico") +async def favicon(): + file_path = os.path.join("static", "favicon.ico") + if os.path.exists(file_path): + return FileResponse(file_path) + raise HTTPException(status_code=404) + + +@app.post("/api/v1/auth/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,)) + user = cursor.fetchone() + conn.close() + + if not user: + logging.warning(f"===> Ошибка: Пользователь {username} не найден") + raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") + + if 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} + +@app.post("/api/v1/auth/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: + raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) + user = cursor.fetchone() + + if not user or not pwd_context.verify(req.old_password, user["password_hash"]): + conn.close() + raise HTTPException(status_code=400, detail="Неверный старый пароль") + + new_hash = pwd_context.hash(req.new_password) + cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) + conn.commit() + conn.close() + + logging.info(f"Пароль изменен для пользователя ID: {current_user['id']}") + return {"status": "success", "message": "Пароль успешно изменен"} + +@app.get("/api/v1/admin/users") +def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") + users = [dict(r) for r in cursor.fetchall()] + conn.close() + return users + +@app.post("/api/v1/admin/users") +def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + username = req.username.strip().lower() + if not username or not req.password: + raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") + + pwd_hash = pwd_context.hash(req.password) + full_name = req.full_name.strip() if req.full_name else None + is_admin = 1 if req.is_admin else 0 + + cursor.execute( + "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", + (username, pwd_hash, full_name, is_admin) + ) + conn.commit() + conn.close() + + logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") + return {"status": "success", "message": f"Пользователь {username} создан"} + +@app.delete("/api/v1/admin/users/{user_id}") +def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): + if not current_user["is_admin"]: + raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") + + if user_id == current_user["id"]: + raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") + + conn = get_db() + cursor = conn.cursor() + cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + conn.close() + + logging.info(f"Удален пользователь ID: {user_id}") + return {"status": "success", "message": "Пользователь удален"} + +@app.get("/api/v1/tasks") +def get_tasks(user: Dict[str, Any] = Depends(get_current_user)): + return db_get_tasks(user_id=user["id"]) + +# ЧАТ С ПОДДЕРЖКОЙ ФАЙЛОВ И АВТОРИЗАЦИИ +@app.post("/api/v1/chat") +async def chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None), + current_user: dict = 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) + + reply, history = 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 + ) + return {"reply": reply, "history": history} + +@app.post("/api/v1/chat/guest") +async def guest_chat_endpoint( + session_id: str = Form("web_session_main"), + message: str = Form(""), + file: Optional[UploadFile] = File(default=None) +): + 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 = 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} + +# === СТРОГО В КОНЦЕ: ФОЛЛБЭК СТАТИКИ === + +@app.get("/{file_path:path}") +def serve_static_fallback(file_path: str): + clean_path = file_path.lstrip("/") + + target = os.path.join("static", clean_path) + if os.path.isfile(target): + return FileResponse(target) + + filename = os.path.basename(clean_path) + target_js = os.path.join("static/js", filename) + if filename.endswith(".js") and os.path.isfile(target_js): + return FileResponse(target_js, media_type="application/javascript") + + target_css = os.path.join("static/css", filename) + if filename.endswith(".css") and os.path.isfile(target_css): + return FileResponse(target_css, media_type="text/css") + + raise HTTPException(status_code=404, detail="File not found") + +``` + +## File: `./modules/web_api/scripts/clear_history.py` +```py +""" +Скрипт полной очистки истории диалогов и сессионных состояний SQLite. +""" +import os +import sqlite3 + +DB_PATH = "/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db" + +def clear_chat_history(): + if not os.path.exists(DB_PATH): + print(f"❌ База данных не найдена по адресу: {DB_PATH}") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Очищаем таблицы сообщений и стейтов + try: + cursor.execute("DELETE FROM chat_messages;") + except sqlite3.OperationalError: + pass + + try: + cursor.execute("DELETE FROM session_states;") + except sqlite3.OperationalError: + pass + + try: + cursor.execute("DELETE FROM chat_sessions;") + except sqlite3.OperationalError: + pass + + conn.commit() + conn.close() + print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!") + +if __name__ == "__main__": + clear_chat_history() + +``` + +## File: `./modules/web_api/scripts/diagnostics/inspect_db.py` +```py +import os +import sqlite3 + +# Автопоиск файла базы данных в проекте +db_path = '/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db' if os.path.exists('/home/puh/scud_orion_ai_v2/data/scud_orion_ai.db') else 'scud_orion_ai.db' + +print("=" * 80) +print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") +print("=" * 80) + +if not os.path.exists(db_path): + print(f"❌ Файл базы данных {db_path} не найден!") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 1. Список всех таблиц и колонок +cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") +tables = [t[0] for t in cursor.fetchall()] + +print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") +print("-" * 80) +for t_name in tables: + cursor.execute(f"PRAGMA table_info({t_name})") + cols = [c[1] for c in cursor.fetchall()] + + cursor.execute(f"SELECT COUNT(*) FROM {t_name}") + count = cursor.fetchone()[0] + + print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") + +# 2. Просмотр правил Базы Знаний +if 'ai_knowledge_base' in tables: + print("\n" + "=" * 80) + print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") + print("=" * 80) + cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") + rules = cursor.fetchall() + if not rules: + print("Таблица ai_knowledge_base пуста.") + else: + for r_id, r_text, r_author in rules: + print(f" {r_id}. [{r_author}] {r_text}\n") + +conn.close() +print("=" * 80) +``` + +## File: `./modules/web_api/scripts/diagnostics/inspect_files.py` +```py +import os + +print("=" * 80) +print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_orion_context)") +print("=" * 80) + +total_files = 0 +total_size = 0 + +for root, dirs, files in os.walk('.'): + # Исключаем служебные каталоги + dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] + + for f in files: + p = os.path.join(root, f) + size = os.path.getsize(p) + total_files += 1 + total_size += size + print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) + +print("-" * 80) +print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") +print("=" * 80) +``` + +## File: `./modules/web_api/scripts/diagnostics/make_code_snapshot.py` +```py +import os + +OUTPUT_SNAPSHOT = "api_code_snapshot.md" + +# Расширения файлов для включения в снимок +ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini', '.js', '.html', '.css'} +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} +EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_context_api.tar.gz', 'context_memory.db'} + +print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") + +with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: + out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_context_api\n\n") + + for root, dirs, files in os.walk('.'): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + + for file in sorted(files): + ext = os.path.splitext(file)[1].lower() + if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: + filepath = os.path.join(root, file) + out.write(f"## File: `{filepath}`\n") + out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + out.write(f.read()) + except Exception as e: + out.write(f"// Ошибка чтения файла: {e}\n") + out.write("\n```\n\n") + +print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") +``` + +## File: `./modules/web_api/scripts/diagnostics/show_tree.py` +```py +import os + +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} + +def print_tree(startpath): + print("=" * 60) + print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") + print("=" * 60) + for root, dirs, files in os.walk(startpath): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + level = root.replace(startpath, '').count(os.sep) + indent = ' ' * 4 * (level) + print(f'{indent}📁 {os.path.basename(root)}/') + subindent = ' ' * 4 * (level + 1) + for f in sorted(files): + if not f.endswith('.pyc'): + print(f'{subindent}📄 {f}') + print("=" * 60) + +if __name__ == "__main__": + print_tree('.') +``` + +## File: `./modules/web_api/llm/__init__.py` +```py + +``` + +## File: `./modules/web_api/llm/agent.py` +```py +""" +FILE: modules/web_api/llm/agent.py +""" +import json +import urllib.request +import logging +from typing import List, Dict, Any, Tuple, Optional + +from .db_tools import ( + db_get_active_system_prompt, + db_add_system_prompt, + db_get_tasks, + db_update_task_status, + db_delete_task, + db_add_task, + db_get_rules, + db_set_session_state, + db_get_session_state, + db_get_snapshots, + db_delete_snapshots, + db_clear_session_state, + db_get_current_server_time, + db_save_chat_message, + db_get_chat_history, + db_get_stats, + db_get_anomalies, + db_get_session_states, + db_get_reference +) + +from .schemas import TOOLS_SCHEMA +from .core.calendar_utils import get_dynamic_calendar_context, parse_relative_date_ru +from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("SCUD_AGENT") + +OLLAMA_URL = "http://192.168.11.3:11434/api/chat" +TEXT_MODEL = "qwen2.5:14b" +VISION_MODEL = "qwen2.5vl:7b-q8_0" + +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]]]: + logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}") + + full_user_content = user_message + if file_context: + full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" + + db_history = db_get_chat_history(session_id, limit=20) + db_save_chat_message(session_id, "user", full_user_content) + + dynamic_prompt_text = db_get_active_system_prompt() + calendar_context = get_dynamic_calendar_context() + session_state = db_get_session_state(session_id) + preview_status_note = "\n\n[АКТИВНО ПРЕВЬЮ ПРОМПТА: Ожидается подтверждение или отмена изменений пользователем]." if session_state and session_state.get("state_type") == "PROMPT_PREVIEW" else "" + user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + + system_prompt_content = ( + f"[ТЕКУЩИЙ АВТОРИЗОВАННЫЙ ПОЛЬЗОВАТЕЛЬ]\n" + f"Вы общаетесь с пользователем: {user_info}.\n" + f"При запросах 'покажи мои задачи', 'список задач' — СРАЗУ вызывай db_get_tasks.\n\n" + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" + f"{calendar_context}\n\n" + f"ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ:\n{dynamic_prompt_text}{preview_status_note}" + ) + + llm_options = { + "num_predict": 8192, + "num_ctx": 8192, + "temperature": 0.1, + "repeat_penalty": 1.1, + "presence_penalty": 0.5, + "top_p": 0.9 + } + + user_msg_object = {"role": "user", "content": full_user_content} + + if image_b64: + user_msg_object["images"] = [image_b64] + messages = [ + {"role": "system", "content": "Ты — модуль OCR. Перепиши весь текст с изображения буква в букву."}, + user_msg_object + ] + payload = {"model": VISION_MODEL, "messages": messages, "stream": False, "options": llm_options} + else: + clean_db_history = [dict(m) for m in db_history] + for m in clean_db_history: + m.pop("images", None) + messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] + payload = {"model": TEXT_MODEL, "messages": messages, "tools": TOOLS_SCHEMA, "stream": False, "options": llm_options} + + try: + req = urllib.request.Request(OLLAMA_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req) as response: + res_data = json.loads(response.read().decode("utf-8")) + msg = res_data.get("message", {}) + tool_calls = msg.get("tool_calls", []) + raw_text_content = msg.get("content", "") + + user_msg_lower = user_message.lower() + if not tool_calls: + if any(w in user_msg_lower for w in ["покажи системный промпт", "покажи промпт", "весь промпт"]): + tool_calls = [{"function": {"name": "db_get_system_prompt", "arguments": {}}}] + elif any(w in user_msg_lower for w in ["покажи снапшоты", "список снапшотов", "выведи снапшоты"]): + target_date = parse_relative_date_ru(user_message) + tool_calls = [{"function": {"name": "db_get_snapshots", "arguments": {"date_str": target_date}}}] + else: + tool_calls = inject_tools_if_needed(user_message, raw_text_content, tool_calls) + + if tool_calls: + messages.append(msg) + for tool in tool_calls: + fn_name = tool["function"]["name"] + fn_args = tool["function"].get("arguments", {}) + tool_result_content = "" + + if 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) + 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_tasks": + tool_result_content = json.dumps(db_get_tasks(user_id), ensure_ascii=False) + elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]: + tool_result_content = json.dumps({"system_prompt": db_get_active_system_prompt()}, 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_session_states": + tool_result_content = json.dumps(db_get_session_states(), ensure_ascii=False) + elif fn_name == "db_delete_snapshots": + tool_result_content = json.dumps(db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str")), 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_preview_prompt_merge": + proposed_text = fn_args.get("prompt_text") or fn_args.get("proposed_prompt") or "" + if isinstance(fn_args, str): + proposed_text = fn_args + db_set_session_state(session_id, "PROMPT_PREVIEW", proposed_text) + preview_reply = f"Ваше изменение успешно предпросмотрено:\n\n{proposed_text}\n\nДля применения подтвердите («подтверждаю») или отмените («отмена»)." + db_save_chat_message(session_id, "assistant", preview_reply) + return clean_raw_tool_tags(preview_reply), db_get_chat_history(session_id) + elif fn_name == "db_confirm_prompt_preview": + if session_state and session_state.get("state_type") == "PROMPT_PREVIEW": + res = db_add_system_prompt("main_agent", session_state.get("pending_data", "")) + db_clear_session_state(session_id) + tool_result_content = json.dumps(res, ensure_ascii=False) + else: + tool_result_content = json.dumps({"status": "error", "message": "Нет активного превью."}, ensure_ascii=False) + elif fn_name == "db_cancel_prompt_preview": + db_clear_session_state(session_id) + tool_result_content = json.dumps({"status": "success", "message": "Превью отменено."}, ensure_ascii=False) + elif fn_name == "db_get_rules": + tool_result_content = json.dumps(db_get_rules(), 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) + + messages.append({"role": "tool", "content": tool_result_content}) + + second_payload = {"model": TEXT_MODEL, "messages": messages, "stream": False, "options": llm_options} + sec_req = urllib.request.Request(OLLAMA_URL, data=json.dumps(second_payload).encode("utf-8"), headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(sec_req) as sec_response: + sec_res_data = json.loads(sec_response.read().decode("utf-8")) + raw_content = sec_res_data.get("message", {}).get("content", "").strip().replace("**", "") + final_content = clean_raw_tool_tags(clean_output(raw_content)) + db_save_chat_message(session_id, "assistant", final_content) + return final_content, db_get_chat_history(session_id) + + raw_str = msg.get("content", "").strip().replace("**", "") + content_str = clean_raw_tool_tags(clean_output(raw_str)) + final_reply = content_str or "Запрос обработан." + db_save_chat_message(session_id, "assistant", final_reply) + return final_reply, db_get_chat_history(session_id) + + except Exception as ex: + logger.exception(f"Непредвиденная ошибка: {ex}") + error_reply = f"Внутренняя ошибка сервера: {ex}" + return error_reply, db_get_chat_history(session_id) +``` + +## File: `./modules/web_api/llm/db_tools.py` +```py +""" +FILE: modules/web_api/llm/db_tools.py +""" +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 +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 +from .db.db_prompts import ( + db_get_active_system_prompt, + db_add_system_prompt, + db_get_rules, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_session_states, + db_get_stats, + db_get_anomalies, + db_get_reference +) + +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") + } +``` + +## File: `./modules/web_api/llm/file_parser.py` +```py +import base64 +import os +import subprocess +import logging +import pandas as pd + +logger = logging.getLogger("FILE_PARSER") + +def extract_text_from_file(file_bytes: bytes, filename: str) -> dict: + ext = os.path.splitext(filename)[1].lower() + temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}" + + with open(temp_filepath, "wb") as f: + f.write(file_bytes) + + try: + # 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM + if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']: + b64_str = base64.b64encode(file_bytes).decode('utf-8') + return { + "text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]", + "image_b64": b64_str + } + + # 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM) + elif ext == '.pdf': + cmd = ['pdftotext', temp_filepath, '-'] + res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + pdf_text = res.stdout.strip() + + img_prefix = f"/tmp/pdf_preview_{os.getpid()}" + subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True) + + page_png = f"{img_prefix}-1.png" + b64_str = None + if os.path.exists(page_png): + with open(page_png, "rb") as pf: + b64_str = base64.b64encode(pf.read()).decode('utf-8') + os.remove(page_png) + + context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]" + if pdf_text: + context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}" + + return { + "text": context_text, + "image_b64": b64_str + } + + # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv) + elif ext in ['.xlsx', '.xls', '.csv']: + if ext == '.csv': + df = pd.read_csv(temp_filepath) + else: + df = pd.read_excel(temp_filepath) + + total_rows = len(df) + df_preview = df.head(100) + table_str = df_preview.to_string(index=False) + note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else "" + return { + "text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}", + "image_b64": None + } + + # 4. ТЕКСТОВЫЕ ФАЙЛЫ + elif ext in ['.txt', '.log', '.json', '.xml', '.md']: + with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf: + return { + "text": tf.read().strip(), + "image_b64": None + } + + else: + return { + "text": f"[ОШИБКА: Формат {ext} не поддерживается]", + "image_b64": None + } + + except Exception as e: + logger.error(f"Ошибка при анализе файла {filename}: {e}") + return { + "text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]", + "image_b64": None + } + finally: + if os.path.exists(temp_filepath): + os.remove(temp_filepath) +``` + +## File: `./modules/web_api/llm/schemas.py` +```py +TOOLS_SCHEMA = [ + { + "type": "function", + "function": { + "name": "db_get_tasks", + "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!", + "parameters": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED." + } + } + } + } + }, + { + "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": {}} + } + }, + { + "type": "function", + "function": { + "name": "db_update_task_status", + "description": "Изменить статус и/или срок выполнения задачи в реестре.", + "parameters": { + "type": "object", + "properties": { + "task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"}, + "status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"}, + "due_date": {"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"] + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_snapshots", + "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!", + "parameters": { + "type": "object", + "properties": { + "date_str": { + "type": "string", + "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]." + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_delete_snapshots", + "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.", + "parameters": { + "type": "object", + "properties": { + "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"}, + "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"} + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_current_server_time", + "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "db_add_task", + "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"] + } + } + }, + { + "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) если просит все.", + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"}, + "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"} + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_get_session_states", + "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.", + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": "db_get_reference", + "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.", + "parameters": { + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр." + } + } + } + } + }, + { + "type": "function", + "function": { + "name": "db_add_system_prompt", + "description": "Прямое сохранение системного промпта в БД без предварительного просмотра.", + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Имя промпта, по умолчанию main_agent"}, + "prompt_text": {"type": "string", "description": "Полный текст системного промпта"} + }, + "required": ["prompt_text"] + } + } + }, + { + "type": "function", + "function": { + "name": "db_preview_prompt_merge", + "description": "Создать предварительное изменённое превью системного промпта перед сохранением.", + "parameters": { + "type": "object", + "properties": { + "prompt_text": { + "type": "string", + "description": "Новый полный или частично измененный текст системного промпта." + } + }, + "required": ["prompt_text"] + } + } + }, + { + "type": "function", + "function": { + "name": "db_confirm_prompt_preview", + "description": "Подтвердить и сохранить текущее подготовленное превью в БД. Вызывай этот инструмент, когда пользователь говорит 'подтверждаю', 'да', 'вноси', 'применяй', 'сохраняй' или одобряет превью в любой форме.", + "parameters": {"type": "object", "properties": {}} + } + }, + { + "type": "function", + "function": { + "name": "db_cancel_prompt_preview", + "description": "Отменить текущее превью системного промпта и сбросить изменения. Вызывай, когда пользователь явно отказывается от изменений.", + "parameters": {"type": "object", "properties": {}} + } + } +] +``` + +## File: `./modules/web_api/llm/db/__init__.py` +```py + +``` + +## File: `./modules/web_api/llm/db/connection.py` +```py +""" +FILE: modules/web_api/llm/db/connection.py +""" +import os +import sqlite3 + +# Динамический путь к общей БД в корне проекта +BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../")) +DB_PATH = os.path.join(BASE_ROOT, "data", "scud_orion_ai.db") + +def get_db_connection() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH, timeout=30.0) + 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: `./modules/web_api/llm/db/db_chat.py` +```py +""" +FILE: modules/web_api/llm/db/db_chat.py +""" +from typing import List, Dict, Any +from .connection import get_db_connection + +def db_save_chat_message(session_id: str, role: str, content: str): + if not content: + return + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO chat_messages (session_id, role, content, created_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + """, (session_id, role, content)) + conn.commit() + conn.close() + +def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT role, content FROM chat_messages + WHERE session_id = ? + ORDER BY id DESC LIMIT ? + """, (session_id, limit)) + rows = cursor.fetchall() + conn.close() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] +``` + +## File: `./modules/web_api/llm/db/db_prompts.py` +```py +""" +FILE: modules/web_api/llm/db/db_prompts.py +""" +import logging +from typing import List, Dict, Any, Optional +from .connection import get_db_connection + +logger = logging.getLogger("DB_PROMPTS") + +def db_get_active_system_prompt() -> str: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT prompt_text FROM system_prompts WHERE is_active = 1 ORDER BY id DESC LIMIT 1") + row = cursor.fetchone() + conn.close() + return row["prompt_text"] if row else "Ты — ИИ-ассистент SCUD Orion AI." + +def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: + try: + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute("BEGIN IMMEDIATE;") + cursor.execute("SELECT id FROM system_prompts WHERE name = ?", (name,)) + existing = cursor.fetchone() + if existing: + cursor.execute( + "UPDATE system_prompts SET prompt_text = ?, is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE name = ?", + (prompt_text, name) + ) + else: + cursor.execute( + "INSERT INTO system_prompts (name, prompt_text, is_active) VALUES (?, ?, 1)", + (name, prompt_text) + ) + conn.commit() + return {"status": "success", "message": "Системный промпт успешно обновлен"} + except Exception as e: + logger.error(f"Ошибка при сохранении промпта в БД: {e}") + return {"status": "error", "error": str(e)} + +def db_get_rules() -> List[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") + rows = cursor.fetchall() + conn.close() + return [dict(r) for r in rows] + +def db_set_session_state(session_id: str, state_type: str, data: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO session_states (session_id, state_type, pending_data, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(session_id) DO UPDATE SET + state_type = excluded.state_type, + pending_data = excluded.pending_data, + updated_at = CURRENT_TIMESTAMP + """, (session_id, state_type, data)) + conn.commit() + conn.close() + +def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT state_type, pending_data FROM session_states WHERE session_id = ?", (session_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + +def db_clear_session_state(session_id: str): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) + conn.commit() + conn.close() + +def db_get_session_states() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT session_id, state_type, updated_at FROM session_states") + rows = cursor.fetchall() + conn.close() + return {"status": "success", "active_sessions": [dict(r) for r in rows]} + +def db_get_stats() -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompts', 'session_states', 'tasks'] + stats = {} + for t in tables: + try: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + stats[t] = cursor.fetchone()[0] + except Exception: + stats[t] = 0 + conn.close() + return {"status": "success", "tables_stats": stats} + +def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" + params = [] + if date_str: + query += " WHERE anomaly_date = ?" + params.append(date_str) + query += " ORDER BY id DESC LIMIT ?" + params.append(limit) + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} + +def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: + conn = get_db_connection() + cursor = conn.cursor() + query = "SELECT category, title, example_prompt, description FROM system_reference" + params = [] + if category: + query += " WHERE category = ?" + params.append(category) + query += " ORDER BY id ASC" + cursor.execute(query, params) + rows = cursor.fetchall() + conn.close() + return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} +``` + +## File: `./modules/web_api/llm/db/db_snapshots.py` +```py +""" +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}"} +``` + +## File: `./modules/web_api/llm/db/db_tasks.py` +```py +""" +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} удалена"} +``` + +## File: `./modules/web_api/llm/core/__init__.py` +```py + +``` + +## File: `./modules/web_api/llm/core/calendar_utils.py` +```py +import re +from datetime import datetime, timedelta + +DAYS_RU = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] + +def parse_relative_date_ru(text: str) -> str: + now = datetime.now() + text_lower = text.lower() if text else "" + match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text) + if match: + return match.group(1) + if "вчера" in text_lower: + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + elif "позавчера" in text_lower: + return (now - timedelta(days=2)).strftime("%d.%m.%Y") + elif "сегодня" in text_lower: + return now.strftime("%d.%m.%Y") + return (now - timedelta(days=1)).strftime("%d.%m.%Y") + +def get_dynamic_calendar_context() -> str: + now = datetime.now() + current_wd = now.weekday() + lines = [ + f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).", + "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):", + f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})", + f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})", + f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})", + "\nПрошедшие дни недели:" + ] + for days_back in range(1, 8): + dt = now - timedelta(days=days_back) + day_name = DAYS_RU[dt.weekday()] + label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}" + lines.append(f"• {label}: {dt.strftime('%d.%m.%Y')}") + return "\n".join(lines) +``` + +## File: `./modules/web_api/llm/core/tool_injector.py` +```py +import re +import json +import logging +from typing import List, Dict, Any + +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 = ["почемучто", "почто", "почему что"] + lower_text = text.lower() + for art in artifacts: + if lower_text.startswith(art): + text = text[len(art):].lstrip(",.!?:; -") + return text.strip() + +def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + if tool_calls: + return tool_calls + if '{"name":' in raw_text_content or '' in raw_text_content: + try: + match = re.search(r'\{"name":\s*"([^"]+)",\s*"(?:params|arguments|properties)":\s*(\{.*?\})\}', raw_text_content) + if match: + fn_name = match.group(1) + fn_args = json.loads(match.group(2)) + return [{"function": {"name": fn_name, "arguments": fn_args}}] + except Exception as parse_err: + logger.debug(f"Ошибка парсинга сырого tool call: {parse_err}") + return tool_calls +``` + diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/add_rule_ai.py b/scripts/add_rule_ai.py new file mode 100644 index 0000000..dbb6930 --- /dev/null +++ b/scripts/add_rule_ai.py @@ -0,0 +1,19 @@ +import sys +import os + +# Добавляем корень проекта в путь поиска модулей Python +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import get_connection + +rule_text = ( + "Сотрудники, присутствующие в 1С:ЗУП, но отсутствующие в СКУД Орион Pro, " + "являются аномалией синхронизации профилей. ИИ должен запрашивать у СБ статус выдачи пропуска." +) + +with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", (rule_text,)) + conn.commit() + +print("✓ Правило успешно внесено в SQLite БД!") \ No newline at end of file diff --git a/scripts/db_cli.py b/scripts/db_cli.py new file mode 100644 index 0000000..5fa6101 --- /dev/null +++ b/scripts/db_cli.py @@ -0,0 +1,348 @@ +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 +from core.database import ( + get_connection, + get_available_snapshots, + get_all_rules_from_db, + load_scud_from_db_by_snapshot, + get_latest_snapshot_time +) + +DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") + + +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'] + for t in tables: + cursor.execute(f"SELECT COUNT(*) FROM {t}") + cnt = cursor.fetchone()[0] + print(f" • Таблица [{t:<18}]: {cnt:>6} записей") + 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 + + if not snap_id.startswith("Y"): + formatted_snap_id = f" {snap_id}" + else: + formatted_snap_id = 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" + "=" * 90) + if snapshot_id: + print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):") + else: + print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):") + print("=" * 90) + + df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) + + if df.empty: + print(f"Записи СКУД не найдены.") + print("=" * 90 + "\n") + return + + total = len(df) + present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0 + absent_cnt = total - present_cnt + + print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}") + print("-" * 90) + + cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id'] + existing_cols = [c for c in cols_to_show if c in df.columns] + + print(df[existing_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" + "*" * 90) + print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}") + print("*" * 90) + + print("=" * 90 + "\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']: + df = pd.read_sql_query(f"SELECT * FROM {table}", conn) + df.to_excel(writer, sheet_name=table[:31], index=False) + print(f"[✓] Дамп успешно сохранен: {out_path}\n") + + +def print_system_prompts(): + """Выводит все системные промпты из базы данных.""" + print("\n" + "=" * 80) + print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompts):") + print("=" * 80) + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT id, name, is_active, updated_at, prompt_text FROM system_prompts ORDER BY id DESC") + rows = cursor.fetchall() + if not rows: + print("Таблица system_prompts пуста.") + else: + for r in rows: + print(f"ID: {r[0]} | Name: {r[1]} | Active: {r[2]} | Updated: {r[3]}") + print("-" * 80) + print(f"{r[4]}\n") + 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") + + +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_prompts) + sessions -- Посмотреть активные сессии и превью (session_states) + dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel + snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день + +ПРИМЕРЫ ЗАПУСКА: + 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 scud --snapshot Y20260805-007 + python scripts/db_cli.py absences 07.08.2026 + python scripts/db_cli.py prompts + python scripts/db_cli.py sessions + python scripts/db_cli.py snapshot del Y20260805-007 + python scripts/db_cli.py snapshot del --day 04.08.2026 + python scripts/db_cli.py dump my_dump.xlsx +""" + + +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'], help="Основная команда") + parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del')") + parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота или имя файла)") + 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="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)") + + 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.param or args.action + print_snapshots_list(date_str=date_val) + elif args.command == 'scud': + inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx) + elif args.command == 'absences': + date_val = args.param or args.action + 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 == 'dump': + filename = args.param if args.param else "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") + else: + print(f"\n[❌] Ошибка: Неизвестная команда.") + print(HELP_TEXT) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/diagnostics/inspect_db.py b/scripts/diagnostics/inspect_db.py new file mode 100644 index 0000000..c21d018 --- /dev/null +++ b/scripts/diagnostics/inspect_db.py @@ -0,0 +1,47 @@ +import os +import sqlite3 + +# Автопоиск файла базы данных в проекте +db_path = 'data/scud_orion_ai.db' if os.path.exists('data/scud_orion_ai.db') else 'scud_orion_ai.db' + +print("=" * 80) +print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") +print("=" * 80) + +if not os.path.exists(db_path): + print(f"❌ Файл базы данных {db_path} не найден!") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 1. Список всех таблиц и колонок +cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") +tables = [t[0] for t in cursor.fetchall()] + +print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") +print("-" * 80) +for t_name in tables: + cursor.execute(f"PRAGMA table_info({t_name})") + cols = [c[1] for c in cursor.fetchall()] + + cursor.execute(f"SELECT COUNT(*) FROM {t_name}") + count = cursor.fetchone()[0] + + print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") + +# 2. Просмотр правил Базы Знаний +if 'ai_knowledge_base' in tables: + print("\n" + "=" * 80) + print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") + print("=" * 80) + cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") + rules = cursor.fetchall() + if not rules: + print("Таблица ai_knowledge_base пуста.") + else: + for r_id, r_text, r_author in rules: + print(f" {r_id}. [{r_author}] {r_text}\n") + +conn.close() +print("=" * 80) \ No newline at end of file diff --git a/scripts/diagnostics/inspect_files.py b/scripts/diagnostics/inspect_files.py new file mode 100644 index 0000000..fc4bf60 --- /dev/null +++ b/scripts/diagnostics/inspect_files.py @@ -0,0 +1,23 @@ +import os + +print("=" * 80) +print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_ai)") +print("=" * 80) + +total_files = 0 +total_size = 0 + +for root, dirs, files in os.walk('.'): + # Исключаем служебные каталоги + dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] + + for f in files: + p = os.path.join(root, f) + size = os.path.getsize(p) + total_files += 1 + total_size += size + print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) + +print("-" * 80) +print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") +print("=" * 80) \ No newline at end of file diff --git a/scripts/diagnostics/make_code_snapshot.py b/scripts/diagnostics/make_code_snapshot.py new file mode 100644 index 0000000..8942f22 --- /dev/null +++ b/scripts/diagnostics/make_code_snapshot.py @@ -0,0 +1,31 @@ +import os + +OUTPUT_SNAPSHOT = "project_code_snapshot.md" + +# Расширения файлов для включения в снимок +ALLOWED_EXTENSIONS = {'.py', '.json', '.md', '.sh', '.ini'} +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} +EXCLUDE_FILES = {OUTPUT_SNAPSHOT, 'scud_orion_ai_v2.tar.gz', 'context_memory.db'} + +print(f"🔄 Сборка полного контекстного слепка проекта в {OUTPUT_SNAPSHOT}...") + +with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: + out.write("# 📦 ПОЛНЫЙ ИСХОДНЫЙ КОД И КОНФИГУРАЦИЯ ПРОЕКТА scud_orion_ai_v2\n\n") + + for root, dirs, files in os.walk('.'): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + + for file in sorted(files): + ext = os.path.splitext(file)[1].lower() + if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: + filepath = os.path.join(root, file) + out.write(f"## File: `{filepath}`\n") + out.write("```" + (ext.replace('.', '') if ext != '.md' else '') + "\n") + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + out.write(f.read()) + except Exception as e: + out.write(f"// Ошибка чтения файла: {e}\n") + out.write("\n```\n\n") + +print(f"✓ Успешно создан слепок проекта: {OUTPUT_SNAPSHOT} ({os.path.getsize(OUTPUT_SNAPSHOT):,} bytes)") \ No newline at end of file diff --git a/scripts/diagnostics/show_tree.py b/scripts/diagnostics/show_tree.py new file mode 100644 index 0000000..66bc76d --- /dev/null +++ b/scripts/diagnostics/show_tree.py @@ -0,0 +1,21 @@ +import os + +EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} + +def print_tree(startpath): + print("=" * 60) + print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") + print("=" * 60) + for root, dirs, files in os.walk(startpath): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + level = root.replace(startpath, '').count(os.sep) + indent = ' ' * 4 * (level) + print(f'{indent}📁 {os.path.basename(root)}/') + subindent = ' ' * 4 * (level + 1) + for f in sorted(files): + if not f.endswith('.pyc'): + print(f'{subindent}📄 {f}') + print("=" * 60) + +if __name__ == "__main__": + print_tree('.') \ No newline at end of file diff --git a/scripts/fix_snapshots.py b/scripts/fix_snapshots.py new file mode 100644 index 0000000..bd9f285 --- /dev/null +++ b/scripts/fix_snapshots.py @@ -0,0 +1,90 @@ +import sqlite3 +import os +from datetime import datetime + +DB_PATH = 'data/scud_orion_ai.db' + + +def parse_to_date(date_str): + """Надежное преобразование даты логов и времени снапшота в объект date.""" + if not date_str: + return None + date_str = str(date_str).split(" ")[0].strip() + if "." in date_str: + try: + return datetime.strptime(date_str, "%d.%m.%Y").date() + except ValueError: + return None + elif "-" in date_str: + try: + return datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + return None + return None + + +def fix_snapshots(): + """ + Переиндексирует снапшоты в таблице scud_logs. + Нумерация веников (001, 002, 003...) сквозная внутри каждого ДНЯ СОЗДАНИЯ. + Префикс Y выставляется только если дата логов < даты создания. + """ + if not os.path.exists(DB_PATH): + print(f"База данных {DB_PATH} не найдена.") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Группируем по уникальным выгрузкам + cursor.execute(""" + SELECT snapshot_time, log_date + FROM scud_logs + WHERE snapshot_time IS NOT NULL + GROUP BY snapshot_time, log_date + ORDER BY snapshot_time ASC, log_date ASC + """) + rows = cursor.fetchall() + + if not rows: + print("Снапшоты не найдены.") + conn.close() + return + + print("=== ИСПРАВЛЕНИЕ СКВОЗНОЙ НУМЕРАЦИИ ВНУТРИ ДНЯ СОЗДАНИЯ ===") + + # Счетчик текущего порядкового номера strictly за ДЕНЬ СОЗДАНИЯ (YYYYMMDD) + seq_counters = {} + + for snap_time_raw, log_date_raw in rows: + d_snap = parse_to_date(snap_time_raw) + d_log = parse_to_date(log_date_raw) + + if not d_snap or not d_log: + continue + + date_prefix = d_snap.strftime("%Y%m%d") + + # Если дата логов строго раньше даты создания снапшота — ставим Y + is_yesterday = (d_log < d_snap) + prefix = "Y" if is_yesterday else "" + + # Приращиваем сквозной счетчик за ЭТОТ ДЕНЬ СОЗДАНИЯ + seq_counters[date_prefix] = seq_counters.get(date_prefix, 0) + 1 + seq_num = seq_counters[date_prefix] + + new_id = f"{prefix}{date_prefix}-{seq_num:03d}" + + cursor.execute( + "UPDATE scud_logs SET snapshot_id = ? WHERE snapshot_time = ? AND log_date = ?", + (new_id, snap_time_raw, log_date_raw) + ) + print(f" • Срез создания: {snap_time_raw} | Логи за: {log_date_raw} ===> Назначен ID: [{new_id}]") + + conn.commit() + conn.close() + print("\n[✓] Переиндексация внутри дней создания успешно выполнена!") + + +if __name__ == "__main__": + fix_snapshots() \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai_verifier.py b/services/ai_verifier.py new file mode 100644 index 0000000..b33b361 --- /dev/null +++ b/services/ai_verifier.py @@ -0,0 +1,246 @@ +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(unrecognized_scud_fios, 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 без markdown-разметки." + ) + + mapping = {} + try: + match = re.search(r'\{.*\}', raw_response, re.DOTALL) + if match: + json_str = match.group(0) + json_str = re.sub(r'\}\s*[^}\]]*$', '}', 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: + mapping[scud_f] = {"staff_fio": staff_f, "warning": warn} + except Exception as e: + print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {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 [] \ No newline at end of file diff --git a/services/data_loader.py b/services/data_loader.py new file mode 100644 index 0000000..b95221d --- /dev/null +++ b/services/data_loader.py @@ -0,0 +1,243 @@ +import os +import pandas as pd +import warnings +from config import ( + normalize_fio, clean_scud_fio_light, load_exceptions, + DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR +) +# Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite +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 is_excluded(fio, dept, pos, exceptions): + """Проверяет, входит ли сотрудник или должность в список исключений.""" + fio_clean = normalize_fio(fio) + dept_str = str(dept).strip().upper() if pd.notna(dept) else "" + pos_str = str(pos).strip().lower() if pd.notna(pos) else "" + + if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]: + return True + if any(keyword in pos_str for keyword in exceptions["position_keywords"]): + return True + return False + + +def load_staff_data(date_str): + """ + Загружает файл Штатного расписания 1С за конкретную дату из папки 1c. + """ + 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) + + # Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность + 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_data(date_str): + """ + Загружает документально подтвержденные отсутствия сотрудников + НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату, + а также автоматически обогащает их удаленщиками из static_reason_workers.csv. + """ + df_absent = None + try: + df_absent = fetch_zup_absences_from_sql(date_str) + + if df_absent is not None and not df_absent.empty: + 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']) + else: + df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) + except Exception as e: + print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}") + df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) + + # ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv + try: + from config import DATA_DIR + 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) + except Exception as e: + print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}") + + return df_absent if not df_absent.empty else None + + +def load_1c_data_smart(date_str, use_db=False): + """ + Универсальный загрузчик данных 1С (Штат и Отсутствия). + Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД + за целевую дату указанного снапшота. + """ + 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) + + # Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL + 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 + + +def process_scud_anomalies(df_scud, exceptions=None): + """ + Вычисляет 'Первую активность' и маркирует аномалии СКУД. + """ + if exceptions is None: + exceptions = load_exceptions() + + start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None) + end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None) + status_col = 'Статус' if 'Статус' in df_scud.columns else None + + first_activities = [] + anomaly_flags = [] + is_present_flags = [] + + for _, row in df_scud.iterrows(): + fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip() + fio_clean = normalize_fio(fio) + dept = str(row.get('Подразделение', '')).strip() + pos = str(row.get('Должность', '')).strip() + + val_start = str(row.get(start_col, '')).strip() if start_col else '' + val_end = str(row.get(end_col, '')).strip() if end_col else '' + val_status = str(row.get(status_col, '')).strip().lower() if status_col else '' + + raw_first_event = row.get('raw_first_event', None) + if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']: + raw_first_event = None + + has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00']) + has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00']) + + first_act = '—' + anom_flag = 'NONE' + is_present = not ('отсутствовал' in val_status or has_no_in) + + if has_no_in: + if raw_first_event is not None or not has_no_out: + first_act = str(raw_first_event) if raw_first_event else val_end + anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY' + + if not is_excluded(fio, dept, pos, exceptions): + is_present = False + else: + first_act = '—' + is_present = False + + first_activities.append(first_act) + anomaly_flags.append(anom_flag) + is_present_flags.append(is_present) + + df_scud['Первая_активность'] = first_activities + df_scud['anomaly_flag'] = anomaly_flags + df_scud['Пришел'] = is_present_flags + + return df_scud + + +def load_scud_data(target_date_type="today", snapshot_param=None): + """ + Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite. + """ + target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY + + df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param) + + if df_scud is None or df_scud.empty: + df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param) + if df_scud is None or df_scud.empty: + print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.") + return pd.DataFrame() + + exceptions = load_exceptions() + + if 'fio_clean' not in df_scud.columns: + fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0]) + df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str) + df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light) + + if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns: + df_scud['Начало_дня'] = df_scud['time_in'] + if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns: + df_scud['Конец_дня'] = df_scud['time_out'] + if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns: + df_scud['Первая_активность'] = df_scud['first_activity'] + if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns: + df_scud['Пришел'] = df_scud['is_present'].astype(bool) + + df_scud['is_excluded'] = df_scud.apply( + lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions), + axis=1 + ) + return df_scud + + +def load_all_data(): + """ + Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С. + """ + df_scud_today = load_scud_data(target_date_type="today") + try: + df_scud_yesterday = load_scud_data(target_date_type="yesterday") + except FileNotFoundError: + df_scud_yesterday = df_scud_today.copy() + + df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY) + df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY) + + return ( + df_scud_today, + df_scud_yesterday, + df_staff_today, + df_absent_today, + df_staff_yesterday, + df_absent_yesterday + ) \ No newline at end of file diff --git a/services/data_validator.py b/services/data_validator.py new file mode 100644 index 0000000..2657f41 --- /dev/null +++ b/services/data_validator.py @@ -0,0 +1,42 @@ +import os +import time +from datetime import datetime +from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR + + +def check_file_freshness(max_age_hours=24): + """ + Проверяет существование входных файлов 1С и СКУД. + Возвращает флаги доступности файлов за Сегодня и Вчера. + """ + warnings = [] + errors = [] + + # 1. Проверка файлов 1С за СЕГОДНЯ + staff_today = find_dated_file("Штат", DATE_TODAY) + absent_today = find_dated_file("Отсутствия", DATE_TODAY) + has_today_1c = bool(staff_today and absent_today) + + if not has_today_1c: + warnings.append( + f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. " + f"Формирование Ежедневной сводки за сегодня будет временно пропущено." + ) + + # 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета) + staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY) + absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY) + + if not staff_yesterday or not absent_yesterday: + errors.append( + f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. " + f"Невозможно построить детальный отчет за прошлую смену." + ) + + # 3. Проверка файлов СКУД + scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR]) + if not scud_today: + warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}") + + is_valid = len(errors) == 0 + return is_valid, warnings, errors, has_today_1c \ No newline at end of file diff --git a/services/excel_exporter.py b/services/excel_exporter.py new file mode 100644 index 0000000..f83d9ca --- /dev/null +++ b/services/excel_exporter.py @@ -0,0 +1,401 @@ +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 OUTPUT_DIR + +# Словарь месяцев для текстового формата в названиях файлов +MONTHS_RU = { + 1: "января", 2: "февраля", 3: "марта", 4: "апреля", + 5: "мая", 6: "июня", 7: "июля", 8: "августа", + 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" +} + + +def format_date_ru(date_str): + """Преобразует дату формата '27.07.2026' в '27 июля 2026'""" + try: + dt = datetime.strptime(date_str, "%d.%m.%Y") + return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}" + except Exception: + return date_str + + +# Границы ячеек +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_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") +FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска" + +CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "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): + """Рассчитывает точное отклонение от нормы с учетом обеда 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="27.07.2026", filename=None): + if not filename: + filename = f"{format_date_ru(date_str)} сводка.xlsx" + + output_path = os.path.join(OUTPUT_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. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) + absent_only = merged_df[ + (merged_df['Пришел'] == False) & + (merged_df['Вид_отсутствия'].notna()) & + (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) + ] + 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. ИТОГО НА РАБОТЕ (Свернут hidden=True) + is_working_mask = (merged_df['Пришел'] == True) | ( + merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + ) + present = merged_df[is_working_mask] + + 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 + + # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ) + anomalies = merged_df[ + (~is_exc) & ( + ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) | + (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 + + 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(OUTPUT_DIR, alt_filename) + wb.save(alt_path) + print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") + + +# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- +def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): + if not filename: + filename = f"{format_date_ru(date_str)} отчет.xlsx" + + output_path = os.path.join(OUTPUT_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 merged_df.columns else 'Начало_дня' + end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня' + hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании' + + chars_per_line_h = 24 + + for idx, row in merged_df.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(OUTPUT_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(OUTPUT_DIR, filename) + df_scud.to_excel(output_path, index=False) \ No newline at end of file diff --git a/services/feedback_loop.py b/services/feedback_loop.py new file mode 100644 index 0000000..c2b3463 --- /dev/null +++ b/services/feedback_loop.py @@ -0,0 +1,54 @@ +import os +import json +from core.database import add_rule_to_db + +try: + from services.knowledge_base import save_rule +except ImportError: + try: + from services.knowledge_base import add_rule as save_rule + except ImportError: + save_rule = None + + +def review_ai_decisions(report_text, suspicious_cases): + """ + Интерактивный консольный модуль обучения (Human-in-the-Loop). + Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям. + """ + if not suspicious_cases: + print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.") + return + + print("\n" + "=" * 60) + print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)") + print("=" * 60) + + for idx, case in enumerate(suspicious_cases, 1): + fio_target = case.get('fio_target', 'Неизвестно') + reason = case.get('reason', 'Нет описания') + + print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}") + print(f" Детали: {reason}") + + user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip() + user_input_clean = user_input.lower() + + # Расширенный список вариантов "ДА" + if user_input_clean in ['д', 'да', 'da', 'yes', 'y', 'ок', 'ага', '+']: + print(" [✓] Решение подтверждено оператором.") + elif user_input_clean in ['н', 'нет', 'no', 'n', '-']: + print(" [ℹ️] Решение отклонено оператором.") + elif user_input: + # Если оператор обучает систему синониму отделов + if "отдел" in user_input_clean or "овк" in user_input_clean: + from core.database import add_department_synonym_to_db + add_department_synonym_to_db(user_input.strip(), "отдел внутреннего контроля") + + new_rule = f"Правило: {user_input}" + add_rule_to_db(new_rule, added_by="Human_Admin") + print(f" [✓] Новое правило записано в Базу Знаний SQLite: {new_rule}") + else: + print(" [ℹ️] Запись пропущена.") + + print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.") \ No newline at end of file diff --git a/services/knowledge_base.py b/services/knowledge_base.py new file mode 100644 index 0000000..f860422 --- /dev/null +++ b/services/knowledge_base.py @@ -0,0 +1,23 @@ +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()}") \ No newline at end of file diff --git a/services/scud_export.py b/services/scud_export.py new file mode 100644 index 0000000..e340480 --- /dev/null +++ b/services/scud_export.py @@ -0,0 +1,311 @@ +""" +Модуль автоматического экспорта данных СКУД (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) + +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.31.221\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) \ No newline at end of file diff --git a/services/share_copier.py b/services/share_copier.py new file mode 100644 index 0000000..03d796d --- /dev/null +++ b/services/share_copier.py @@ -0,0 +1,69 @@ +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 \ No newline at end of file diff --git a/services/text_reporter.py b/services/text_reporter.py new file mode 100644 index 0000000..5d075b9 --- /dev/null +++ b/services/text_reporter.py @@ -0,0 +1,142 @@ +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): + """ + Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ). + Сравнивает фамилию и полное имя с порогом сходства >= 0.75. + """ + 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"): + # 1. Загрузка Базы Знаний + 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 "Специфических правил компании пока нет." + + # 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff) + total_staff = len(merged_df) + is_working_mask = (merged_df['Пришел'] == True) | ( + merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) + ) + present_cnt = len(merged_df[is_working_mask]) + explained_cnt = len(absent_explained) + unexplained_cnt = len(absent_unexplained) + anomalies_cnt = len(anomalies_list) if anomalies_list else 0 + + # 3. Точный расчет неточных совпадений через Python + fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) + + # 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД + 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}") + + # 5. Сборка промпта для Ollama + prompt = f""" +Ты — старший аудитор кадровой безопасности и контроллинга СКУД. +Сформируй итоговую сводку кадрового контроля на {date_str}. + +📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ: +{custom_rules_str} + +🚨 ВХОДНЫЕ МЕТРИКИ: +- Всего сотрудников: {total_staff} +- Работают (офис / удаленка / командировки): {present_cnt} +- Официально отсутствуют: {explained_cnt} +- Неизвестно (истинно неотмеченные): {unexplained_cnt} +- Выявлено аномалий/конфликтов реестров: {anomalies_cnt} + +--- +🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): +{json.dumps(anomalies_list, ensure_ascii=False, indent=2)} + +--- +⚠️ ПОДТВЕРЖДЁННЫЕ 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. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." +2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. +3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций. + +СТРОГИЙ ШАБЛОН ОТВЕТА: + +**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** + +- Всего офисных сотрудников: **{total_staff}** +- Работают (офис / удаленка / командировки): **{present_cnt}** +- Официально отсутствуют: **{explained_cnt}** +- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. +- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. + +#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): +(Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.") + +#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: +(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.") + +#### Неизвестные случаи: {unexplained_cnt} +(Выведи нумерованный список всех {unexplained_cnt} человек) + +### Точечные рекомендации: +(2-3 системные рекомендации без указания ФИО сотрудников) +""" + + sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы." + return ask_ollama(prompt, system_prompt=sys_prompt) \ No newline at end of file diff --git a/services/zup_extractor.py b/services/zup_extractor.py new file mode 100644 index 0000000..f765f7a --- /dev/null +++ b/services/zup_extractor.py @@ -0,0 +1,159 @@ +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=5) 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 \ No newline at end of file