diff --git a/config.py b/config.py
index cec1cec..8c4237c 100644
--- a/config.py
+++ b/config.py
@@ -8,11 +8,11 @@ DATA_DIR = os.path.join(BASE_DIR, "data")
SCUD_DIR = os.path.join(DATA_DIR, "scud")
ZUP_1C_DIR = os.path.join(DATA_DIR, "1c")
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
+REPORTS_DIR = os.path.join(OUTPUT_DIR, "reports")
-# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат)
SHARE_1C_DIR = "/mnt/scud_share"
-for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]:
+for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR, REPORTS_DIR]:
os.makedirs(folder, exist_ok=True)
NOW = datetime.now()
@@ -23,32 +23,30 @@ if NOW.weekday() == 0:
else:
DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
-# --- Настройки LLM / Ollama ---
-OLLAMA_URL = "http://192.168.11.3:11434/api/generate"
-OLLAMA_MODEL = "qwen2.5:14b"
+OLLAMA_URL = "http://10.121.17.227:11434/api/generate"
+OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0"
MODEL_NAME = OLLAMA_MODEL
KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json")
EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json")
-# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) ---
ZUP_SQL_CONFIG = {
- "driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}"
+ "driver": "{ODBC Driver 18 for SQL Server}",
"server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
- "database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01
- "user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server
- "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server
- "trust_server_certificate": "yes", # Доверять сертификату сервера
- "encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование
+ "database": os.getenv("ZUP_SQL_DB", "ZUP30"),
+ "user": os.getenv("ZUP_SQL_USER", "scud_reader"),
+ "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"),
+ "trust_server_certificate": "yes",
+ "encrypt": "no"
}
-def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]):
- """
- Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026).
- """
- date_dots = date_str
- date_underscores = date_str.replace('.', '_')
+def find_dated_file(prefix, date_str, search_dirs=None):
+ if search_dirs is None:
+ search_dirs = [ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]
+
+ date_dots = str(date_str).replace('_', '.')
+ date_underscores = date_dots.replace('.', '_')
for d in search_dirs:
if not os.path.exists(d):
@@ -75,6 +73,18 @@ def clean_scud_fio_light(fio_str):
def load_exceptions():
+ """
+ Приоритетно читает исключения и белый список из SQLite таблицы exceptions_registry.
+ При отсутствии таблицы или пустой базе выполняет fallback на exceptions.json.
+ """
+ try:
+ from services.exceptions_repo import get_all_exceptions_from_db
+ db_exc = get_all_exceptions_from_db()
+ if any(db_exc.values()):
+ return db_exc
+ except Exception:
+ pass
+
import json
if os.path.exists(EXCEPTIONS_PATH):
try:
@@ -82,4 +92,4 @@ def load_exceptions():
return json.load(f)
except Exception:
pass
- return {"fio": [], "departments": [], "positions": [], "position_keywords": []}
\ No newline at end of file
+ return {"fio": [], "departments": [], "positions": [], "position_keywords": [], "include_fio": []}
\ No newline at end of file
diff --git a/core/connection.py b/core/connection.py
new file mode 100644
index 0000000..943d0b9
--- /dev/null
+++ b/core/connection.py
@@ -0,0 +1,27 @@
+"""
+===============================================================================
+FILE: core/connection.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+ROLE: Единый менеджер подключений к базе данных SQLite (WAL mode, timeouts).
+===============================================================================
+"""
+
+import os
+import sqlite3
+from config import DATA_DIR
+
+DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
+
+
+def get_connection(row_factory: bool = False) -> sqlite3.Connection:
+ """
+ Создает оптимизированное подключение к SQLite.
+ row_factory=True возвращает sqlite3.Row для доступа к полям по имени.
+ """
+ conn = sqlite3.connect(DB_PATH, timeout=30.0)
+ if row_factory:
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON;")
+ conn.execute("PRAGMA journal_mode = WAL;")
+ conn.execute("PRAGMA synchronous = NORMAL;")
+ return conn
\ No newline at end of file
diff --git a/core/database.py b/core/database.py
index 0e0ed14..1dca47f 100644
--- a/core/database.py
+++ b/core/database.py
@@ -1,480 +1,35 @@
-import os
-import sqlite3
-import json
-import pandas as pd
-from datetime import datetime
-from config import DATA_DIR
-
-DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
-
-
-def get_connection():
- """Создает подключение к базе данных SQLite с оптимизированными настройками."""
- conn = sqlite3.connect(DB_PATH)
- conn.execute("PRAGMA foreign_keys = ON;")
- conn.execute("PRAGMA journal_mode = WAL;")
- return conn
-
-
-def init_db():
- """Создает структуру таблиц и индексов в базе данных."""
- with get_connection() as conn:
- cursor = conn.cursor()
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS scud_logs (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- log_date TEXT NOT NULL,
- fio TEXT NOT NULL,
- fio_clean TEXT NOT NULL,
- department TEXT,
- position TEXT,
- time_in TEXT,
- first_activity TEXT DEFAULT '—',
- time_out TEXT,
- time_in_building TEXT,
- is_present INTEGER NOT NULL,
- anomaly_flag TEXT DEFAULT 'NONE',
- snapshot_time TEXT DEFAULT NULL,
- snapshot_id TEXT DEFAULT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- """)
-
- cursor.execute("PRAGMA table_info(scud_logs);")
- cols = [col[1] for col in cursor.fetchall()]
- if 'snapshot_id' not in cols:
- cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;")
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS zup_staff (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- snapshot_date TEXT NOT NULL,
- fio TEXT NOT NULL,
- fio_clean TEXT NOT NULL,
- department TEXT,
- position TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- """)
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS zup_absences (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- absence_date TEXT NOT NULL,
- fio TEXT NOT NULL,
- fio_clean TEXT NOT NULL,
- absence_type TEXT NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- """)
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS anomalies_history (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- anomaly_date TEXT NOT NULL,
- fio TEXT NOT NULL,
- anomaly_type TEXT NOT NULL,
- details TEXT NOT NULL,
- human_status TEXT DEFAULT 'Pending',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- """)
-
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS ai_knowledge_base (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- rule_text TEXT UNIQUE NOT NULL,
- added_by TEXT DEFAULT 'Human',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- """)
-
- cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
- cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
- cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
-
- conn.commit()
-
- sync_knowledge_base_to_db()
-
-def has_scud_logs_for_date(date_str):
- """Проверяет, есть ли в базе данные СКУД за указанную дату."""
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
- return cursor.fetchone() is not None
-
-def has_yesterday_final_snapshot(date_str):
- """Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00."""
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
- (date_str,)
- )
- return cursor.fetchone() is not None
-
-
-def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False):
- """
- Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN).
- Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time).
- """
- try:
- dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
- date_prefix = dt_snap.strftime("%Y%m%d")
- except (ValueError, TypeError):
- dt_snap = datetime.now().date()
- date_prefix = dt_snap.strftime("%Y%m%d")
-
- # Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y
- if date_str:
- try:
- dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
- if dt_log < dt_snap:
- is_yesterday = True
- except Exception:
- pass
-
- prefix = "Y" if is_yesterday else ""
-
- with get_connection() as conn:
- cursor = conn.cursor()
-
- if date_str:
- cursor.execute(
- "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
- (date_str, snapshot_time)
- )
- else:
- cursor.execute(
- "SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
- (snapshot_time,)
- )
-
- row = cursor.fetchone()
- if row and row[0]:
- return row[0]
-
- cursor.execute("""
- SELECT snapshot_id
- FROM scud_logs
- WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
- ORDER BY snapshot_id DESC LIMIT 1
- """, (f"{date_prefix}-%", f"Y{date_prefix}-%"))
-
- last_row = cursor.fetchone()
- next_seq = 1
-
- if last_row and last_row[0]:
- parts = last_row[0].replace("Y", "").split("-")
- if len(parts) > 1 and parts[1].isdigit():
- next_seq = int(parts[1]) + 1
-
- return f"{prefix}{date_prefix}-{next_seq:03d}"
-
-
-def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False):
- """Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id."""
- if df_scud is None or df_scud.empty:
- return
-
- if not snapshot_time:
- snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
-
- # Передаем date_str в генератор snapshot_id
- snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
-
- data_to_insert = []
- for _, r in df_scud.iterrows():
- data_to_insert.append((
- date_str,
- r.get('Сотрудник', r.get('fio_raw', '')),
- r.get('fio_clean', ''),
- r.get('Подразделение', ''),
- r.get('Должность', ''),
- r.get('Начало_дня', 'Нет входа'),
- r.get('Первая_активность', '—'),
- r.get('Конец_дня', 'Нет выхода'),
- r.get('Находился_в_здании', '00:00'),
- 1 if r.get('Пришел', False) else 0,
- r.get('anomaly_flag', 'NONE'),
- snapshot_time,
- snapshot_id
- ))
-
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
- cursor.executemany("""
- INSERT INTO scud_logs (
- log_date, fio, fio_clean, department, position,
- time_in, first_activity, time_out, time_in_building,
- is_present, anomaly_flag, snapshot_time, snapshot_id
- )
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """, data_to_insert)
- conn.commit()
-
-
-def get_latest_snapshot_time(date_str=None):
- with get_connection() as conn:
- cursor = conn.cursor()
- if date_str:
- cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
- else:
- cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
- row = cursor.fetchone()
- return row[0] if row else None
-
-
-def load_scud_from_db_by_snapshot(date_str, snapshot_param=None):
- """
- Загружает логи СКУД по ID снапшота.
- Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает
- финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str.
- """
- with get_connection() as conn:
- df = pd.DataFrame()
-
- # 1. Если передан конкретный ID снапшота (например, '20260810-001')
- if snapshot_param:
- df = pd.read_sql_query(
- "SELECT * FROM scud_logs WHERE snapshot_id = ?",
- conn, params=(str(snapshot_param),)
- )
-
- # 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день):
- if df.empty and date_str:
- cursor = conn.cursor()
-
- # ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот)
- cursor.execute(
- "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
- (date_str,)
- )
- row = cursor.fetchone()
-
- # ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный
- if not row:
- cursor.execute(
- "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
- (date_str,)
- )
- row = cursor.fetchone()
-
- if row and row[0]:
- target_snap_id = row[0]
- df = pd.read_sql_query(
- "SELECT * FROM scud_logs WHERE snapshot_id = ?",
- conn, params=(target_snap_id,)
- )
-
- # Приводим названия колонок к стандарту
- if not df.empty:
- rename_map = {
- 'department': 'Подразделение',
- 'position': 'Должность',
- 'fio': 'Сотрудник',
- 'time_in': 'Начало_дня',
- 'first_activity': 'Первая_активность',
- 'time_out': 'Конец_дня',
- 'duration': 'Находился_в_здании',
- 'is_present': 'Пришел'
- }
- # Переименовываем имеющиеся колонки
- df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
-
- # Заполняем недостающие колонки значениями по умолчанию, если их не было в БД
- required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']
- for col in required_cols:
- if col not in df.columns:
- if col == 'Пришел':
- df[col] = False
- else:
- df[col] = '—'
-
- # Приводим тип флага Пришел к bool
- if 'Пришел' in df.columns:
- df['Пришел'] = df['Пришел'].astype(bool)
-
- return df
-
-def get_available_snapshots(date_str=None):
- """
- Возвращает список снапшотов.
- Сортировка по времени snapshot_time DESC.
- """
- with get_connection() as conn:
- cursor = conn.cursor()
- if date_str:
- cursor.execute("""
- SELECT
- snapshot_id,
- log_date,
- snapshot_time,
- COUNT(*) as cnt
- FROM scud_logs
- WHERE log_date = ? AND snapshot_time IS NOT NULL
- GROUP BY snapshot_id, log_date, snapshot_time
- ORDER BY snapshot_time DESC
- """, (date_str,))
- else:
- cursor.execute("""
- SELECT
- snapshot_id,
- log_date,
- snapshot_time,
- COUNT(*) as cnt
- FROM scud_logs
- WHERE snapshot_time IS NOT NULL
- GROUP BY snapshot_id, log_date, snapshot_time
- ORDER BY snapshot_time DESC
- """)
- return cursor.fetchall()
-
-
-def save_staff_to_db(df_staff, date_str):
- if df_staff is None or df_staff.empty:
- return
- data_to_insert = [
- (date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
- for _, r in df_staff.iterrows()
- ]
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
- cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data_to_insert)
- conn.commit()
-
-
-def save_absences_to_db(df_absent, date_str):
- if df_absent is None or df_absent.empty:
- return
- data_to_insert = [
- (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
- for _, r in df_absent.iterrows()
- ]
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
- cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert)
- conn.commit()
-
-
-def save_anomalies_to_db(anomalies_list, date_str):
- if not anomalies_list:
- return
- data_to_insert = [
- (date_str, a.get('fio', ''), a.get('type', ''), a.get('details', ''))
- for a in anomalies_list
- ]
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
- cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert)
- conn.commit()
-
-
-def get_all_rules_from_db():
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT rule_text FROM ai_knowledge_base")
- return [r[0] for r in cursor.fetchall()]
-
-
-def add_rule_to_db(rule_text, added_by="Human"):
- if not rule_text or not rule_text.strip():
- return
- with get_connection() as conn:
- cursor = conn.cursor()
- try:
- cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
- conn.commit()
- except Exception as e:
- print(f"[⚠️] Ошибка записи правила в БД: {e}")
-
-
-def sync_knowledge_base_to_db():
- pass
-
-def delete_snapshot_by_id(snapshot_id: str):
- """Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
- deleted_count = cursor.rowcount
- conn.commit()
- print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}")
- return deleted_count
-
-def delete_snapshots_by_date(date_str: str):
- """Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
- with get_connection() as conn:
- cursor = conn.cursor()
- # Удаляем по log_date или по дате внутри snapshot_id
- cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
- deleted_count = cursor.rowcount
- conn.commit()
- print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}")
- return deleted_count
-def init_department_synonyms_db():
- """Создает таблицу синонимов отделов в БД SQLite."""
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("""
- CREATE TABLE IF NOT EXISTS department_synonyms (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- short_name TEXT UNIQUE NOT NULL,
- full_name TEXT NOT NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
- """)
- # По умолчанию добавляем ОВК -> Отдел внутреннего контроля
- cursor.execute("""
- INSERT OR IGNORE INTO department_synonyms (short_name, full_name)
- VALUES ('овк', 'отдел внутреннего контроля')
- """)
- conn.commit()
-
-
-def get_department_synonyms_dict():
- """Возвращает словарь всех изученных синонимов {short_name: full_name}."""
- init_department_synonyms_db()
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
- return {row[0]: row[1] for row in cursor.fetchall()}
-
-
-def add_department_synonym_to_db(short_name, full_name):
- """Сохраняет новую пару синонимов отдела в базу SQLite."""
- init_department_synonyms_db()
- with get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute(
- "INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)",
- (short_name.strip().lower(), full_name.strip().lower())
- )
- conn.commit()
- print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'")
-
-def load_staff_from_db(date_str):
- """Загружает Штатное расписание 1С из базы SQLite за указанную дату."""
- with get_connection() as conn:
- df = pd.read_sql_query(
- "SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
- conn, params=(date_str,)
- )
- return df if not df.empty else None
-
-def load_absences_from_db(date_str):
- """Загружает документальные отсутствия 1С из базы SQLite за указанную дату."""
- with get_connection() as conn:
- df = pd.read_sql_query(
- "SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
- conn, params=(date_str,)
- )
- return df if not df.empty else None
-
\ No newline at end of file
+"""
+===============================================================================
+FILE: core/database.py
+ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов.
+===============================================================================
+"""
+
+from core.connection import get_connection, DB_PATH
+from core.schema import init_all_tables
+
+from core.repositories.scud_repo import (
+ has_scud_logs_for_date,
+ has_yesterday_final_snapshot,
+ get_or_create_snapshot_id,
+ save_scud_to_db,
+ get_latest_snapshot_time,
+ load_scud_from_db_by_snapshot,
+ get_available_snapshots,
+ delete_snapshot_by_id,
+ delete_snapshots_by_date
+)
+
+from core.repositories.zup_repo import (
+ save_staff_to_db,
+ load_staff_from_db,
+ save_absences_to_db,
+ load_absences_from_db,
+ save_anomalies_to_db,
+ get_all_rules_from_db,
+ add_rule_to_db,
+ get_department_synonyms_dict,
+ add_department_synonym_to_db
+)
+
+init_db = init_all_tables
\ No newline at end of file
diff --git a/core/repositories/scud_repo.py b/core/repositories/scud_repo.py
new file mode 100644
index 0000000..c284742
--- /dev/null
+++ b/core/repositories/scud_repo.py
@@ -0,0 +1,227 @@
+"""
+===============================================================================
+FILE: core/repositories/scud_repo.py
+ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов.
+===============================================================================
+"""
+
+from datetime import datetime
+import pandas as pd
+from core.connection import get_connection
+
+
+def has_scud_logs_for_date(date_str: str) -> bool:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
+ return cursor.fetchone() is not None
+
+
+def has_yesterday_final_snapshot(date_str: str) -> bool:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
+ (date_str,)
+ )
+ return cursor.fetchone() is not None
+
+
+def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str:
+ try:
+ dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
+ date_prefix = dt_snap.strftime("%Y%m%d")
+ except (ValueError, TypeError):
+ dt_snap = datetime.now().date()
+ date_prefix = dt_snap.strftime("%Y%m%d")
+
+ if date_str:
+ try:
+ dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
+ if dt_log < dt_snap:
+ is_yesterday = True
+ except Exception:
+ pass
+
+ prefix = "Y" if is_yesterday else ""
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ if date_str:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
+ (date_str, snapshot_time)
+ )
+ else:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
+ (snapshot_time,)
+ )
+
+ row = cursor.fetchone()
+ if row and row[0]:
+ return row[0]
+
+ cursor.execute("""
+ SELECT snapshot_id FROM scud_logs
+ WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
+ ORDER BY snapshot_id DESC LIMIT 1
+ """, (f"{date_prefix}-%", f"Y{date_prefix}-%"))
+
+ last_row = cursor.fetchone()
+ next_seq = 1
+ if last_row and last_row[0]:
+ parts = last_row[0].replace("Y", "").split("-")
+ if len(parts) > 1 and parts[1].isdigit():
+ next_seq = int(parts[1]) + 1
+
+ return f"{prefix}{date_prefix}-{next_seq:03d}"
+
+
+def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None:
+ if df_scud is None or df_scud.empty:
+ return
+
+ if not snapshot_time:
+ snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
+
+ data_to_insert = [
+ (
+ date_str,
+ r.get('Сотрудник', r.get('fio_raw', '')),
+ r.get('fio_clean', ''),
+ r.get('Подразделение', ''),
+ r.get('Должность', ''),
+ r.get('Начало_дня', 'Нет входа'),
+ r.get('Первая_активность', '—'),
+ r.get('Конец_дня', 'Нет выхода'),
+ r.get('Находился_в_здании', '00:00'),
+ 1 if r.get('Пришел', False) else 0,
+ r.get('anomaly_flag', 'NONE'),
+ snapshot_time,
+ snapshot_id
+ )
+ for _, r in df_scud.iterrows()
+ ]
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
+ cursor.executemany("""
+ INSERT INTO scud_logs (
+ log_date, fio, fio_clean, department, position,
+ time_in, first_activity, time_out, time_in_building,
+ is_present, anomaly_flag, snapshot_time, snapshot_id
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, data_to_insert)
+ conn.commit()
+
+
+def get_latest_snapshot_time(date_str: str = None):
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ if date_str:
+ cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
+ else:
+ cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
+ row = cursor.fetchone()
+ return row[0] if row else None
+
+
+def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame:
+ with get_connection() as conn:
+ df = pd.DataFrame()
+
+ # 1. Если передан конкретный ID снапшота (например 'Y20260820-004')
+ if snapshot_param:
+ df = pd.read_sql_query(
+ "SELECT * FROM scud_logs WHERE snapshot_id = ?",
+ conn, params=(str(snapshot_param),)
+ )
+
+ # 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот)
+ if df.empty and date_str:
+ cursor = conn.cursor()
+
+ # ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y'
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY snapshot_time DESC, id DESC LIMIT 1",
+ (date_str,)
+ )
+ row = cursor.fetchone()
+
+ # Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени
+ if not row:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1",
+ (date_str,)
+ )
+ row = cursor.fetchone()
+
+ if row and row[0]:
+ target_id = row[0]
+ df = pd.read_sql_query(
+ "SELECT * FROM scud_logs WHERE snapshot_id = ?",
+ conn, params=(target_id,)
+ )
+
+ if not df.empty:
+ rename_map = {
+ 'department': 'department_scud',
+ 'position': 'Должность',
+ 'fio': 'Сотрудник',
+ 'time_in': 'Начало_дня',
+ 'first_activity': 'Первая_активность',
+ 'time_out': 'Конец_дня',
+ 'time_in_building': 'Находился_в_здании',
+ 'is_present': 'Пришел'
+ }
+ df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
+ if 'department_scud' in df.columns and 'Подразделение' not in df.columns:
+ df['Подразделение'] = df['department_scud']
+
+ for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']:
+ if col not in df.columns:
+ df[col] = False if col == 'Пришел' else '—'
+ if 'Пришел' in df.columns:
+ df['Пришел'] = df['Пришел'].astype(bool)
+
+ return df
+
+
+def get_available_snapshots(date_str: str = None):
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ query = """
+ SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt
+ FROM scud_logs
+ WHERE snapshot_time IS NOT NULL
+ """
+ params = []
+ if date_str:
+ query += " AND log_date = ?"
+ params.append(date_str)
+ query += " GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC"
+ cursor.execute(query, params)
+ return cursor.fetchall()
+
+
+def delete_snapshot_by_id(snapshot_id: str) -> int:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
+ cnt = cursor.rowcount
+ conn.commit()
+ return cnt
+
+
+def delete_snapshots_by_date(date_str: str) -> int:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
+ cnt = cursor.rowcount
+ conn.commit()
+ return cnt
\ No newline at end of file
diff --git a/core/repositories/zup_repo.py b/core/repositories/zup_repo.py
new file mode 100644
index 0000000..9d52b6e
--- /dev/null
+++ b/core/repositories/zup_repo.py
@@ -0,0 +1,97 @@
+"""
+===============================================================================
+FILE: core/repositories/zup_repo.py
+ROLE: Репозиторий кадровых данных 1С:ЗУП, аномалий и базы знаний.
+===============================================================================
+"""
+
+import pandas as pd
+from typing import List, Dict, Any
+from core.connection import get_connection
+
+
+def save_staff_to_db(df_staff: pd.DataFrame, date_str: str) -> None:
+ if df_staff is None or df_staff.empty:
+ return
+ data = [
+ (date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
+ for _, r in df_staff.iterrows()
+ ]
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
+ cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data)
+ conn.commit()
+
+
+def load_staff_from_db(date_str: str) -> pd.DataFrame:
+ with get_connection() as conn:
+ df = pd.read_sql_query(
+ "SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
+ conn, params=(date_str,)
+ )
+ return df if not df.empty else None
+
+
+def save_absences_to_db(df_absent: pd.DataFrame, date_str: str) -> None:
+ if df_absent is None or df_absent.empty:
+ return
+ data = [
+ (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
+ for _, r in df_absent.iterrows()
+ ]
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
+ cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data)
+ conn.commit()
+
+
+def load_absences_from_db(date_str: str) -> pd.DataFrame:
+ with get_connection() as conn:
+ df = pd.read_sql_query(
+ "SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
+ conn, params=(date_str,)
+ )
+ return df if not df.empty else None
+
+
+def save_anomalies_to_db(anomalies_list: List[Dict[str, Any]], date_str: str) -> None:
+ if not anomalies_list:
+ return
+ data = [(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) for a in anomalies_list]
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
+ cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data)
+ conn.commit()
+
+
+def get_all_rules_from_db() -> List[str]:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT rule_text FROM ai_knowledge_base")
+ return [r[0] for r in cursor.fetchall()]
+
+
+def add_rule_to_db(rule_text: str, added_by: str = "Human") -> None:
+ if not rule_text or not rule_text.strip():
+ return
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
+ conn.commit()
+
+
+def get_department_synonyms_dict() -> Dict[str, str]:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
+ return {row[0]: row[1] for row in cursor.fetchall()}
+
+
+def add_department_synonym_to_db(short_name: str, full_name: str) -> None:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", (short_name.strip().lower(), full_name.strip().lower()))
+ conn.commit()
\ No newline at end of file
diff --git a/core/schema.py b/core/schema.py
new file mode 100644
index 0000000..c623dc5
--- /dev/null
+++ b/core/schema.py
@@ -0,0 +1,162 @@
+"""
+===============================================================================
+FILE: core/schema.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+ROLE: DDL-схемы таблиц, создание индексов и инициализация базы данных.
+===============================================================================
+"""
+
+import logging
+from core.connection import get_connection
+
+logger = logging.getLogger("DB_SCHEMA")
+
+
+def init_all_tables() -> None:
+ """Инициализирует все таблицы и индексы системы."""
+ with get_connection() as conn:
+ cursor = conn.cursor()
+
+ # 1. Логи СКУД
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS scud_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ log_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ fio_clean TEXT NOT NULL,
+ department TEXT,
+ position TEXT,
+ time_in TEXT,
+ first_activity TEXT DEFAULT '—',
+ time_out TEXT,
+ time_in_building TEXT,
+ is_present INTEGER NOT NULL,
+ anomaly_flag TEXT DEFAULT 'NONE',
+ snapshot_time TEXT DEFAULT NULL,
+ snapshot_id TEXT DEFAULT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 2. Кадровые реестры 1С
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS zup_staff (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ snapshot_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ fio_clean TEXT NOT NULL,
+ department TEXT,
+ position TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS zup_absences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ absence_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ fio_clean TEXT NOT NULL,
+ absence_type TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 3. Аномалии и база знаний
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS anomalies_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ anomaly_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ anomaly_type TEXT NOT NULL,
+ details TEXT NOT NULL,
+ human_status TEXT DEFAULT 'Pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS ai_knowledge_base (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ rule_text TEXT UNIQUE NOT NULL,
+ added_by TEXT DEFAULT 'Human',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS department_synonyms (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ short_name TEXT UNIQUE NOT NULL,
+ full_name TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 4. Узлы системного промпта и сессии
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS system_prompt_nodes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ prompt_name TEXT DEFAULT 'main_agent',
+ section_id INTEGER NOT NULL,
+ item_id INTEGER NOT NULL,
+ content TEXT NOT NULL,
+ is_active INTEGER DEFAULT 1,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(prompt_name, section_id, item_id)
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS session_states (
+ session_id TEXT PRIMARY KEY,
+ state_type TEXT NOT NULL,
+ pending_data TEXT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS chat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ is_ephemeral INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS exceptions_registry (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL, -- 'department', 'position', 'fio', 'position_keyword', 'include_fio', 'remote_worker'
+ value TEXT NOT NULL,
+ comment TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(category, value)
+ );
+ """)
+
+ # 5. Задачи
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id TEXT,
+ module TEXT DEFAULT 'general',
+ title TEXT NOT NULL,
+ priority TEXT DEFAULT 'MEDIUM',
+ status TEXT DEFAULT 'BACKLOG',
+ due_date TEXT,
+ user_id INTEGER DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 6. Индексы
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
+
+ conn.commit()
\ No newline at end of file
diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db
index 7900e5b..af9d01e 100644
Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ
diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm
deleted file mode 100644
index ed9a005..0000000
Binary files a/data/scud_orion_ai.db-shm and /dev/null differ
diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal
deleted file mode 100644
index 9eb5ef3..0000000
Binary files a/data/scud_orion_ai.db-wal and /dev/null differ
diff --git a/data/static_reason_workers.csv b/data/static_reason_workers.csv
index 49f9ef7..f7d96f6 100644
--- a/data/static_reason_workers.csv
+++ b/data/static_reason_workers.csv
@@ -11,4 +11,6 @@ fio,reason,note
Ковалев Владимир Владимирович,Удаленная работа,Постоянная удаленка
Кожокарь Татьяна Юрьевна,Удаленная работа,Постоянная удаленка
Субетто Юлия Викторовна,Удаленная работа,Постоянная удаленка
-Ароян Станислав Месропович,Удаленная работа,Временная удаленка
\ No newline at end of file
+Чуркина Елена Геннадьевна,Удаленная работа,Постоянная удаленка
+Пушков Александр Александрович,Удаленная работа,Временная удаленка с 26.08.2026 по 04.09.2026
+Сысоев Алексей Валерьевич,Удаленная работа,Временная удаленка с 25.08.2026 по 27.08.2026
\ No newline at end of file
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index e1ef71a..0301c1e 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,64 @@
Все ключевые изменения архитектуры, инструментов и модулей проекта SCUD Orion AI фиксируются в данном файле[cite: 6].
+## [3.0.0] — 2026-08-21
+### 🚀 Глубокий архитектурный рефакторинг (Unified Clean Architecture)
+
+#### Добавлено:
+- **Пакет ядра `core/`:**
+ - `core/connection.py`: централизованный менеджер SQLite-подключений с поддержкой WAL-режима и row_factory.
+ - `core/schema.py`: единая точка инициализации схемы данных и индексов.
+ - `core/repositories/`: низкоуровневые репозитории `scud_repo.py` и `zup_repo.py`.
+- **Доменные сервисы `services/`:**
+ - `services/tasks/`: полная изоляция логики задач (CRUD, Markdown-экспорт, генерация `ROADMAP.md`).
+ - `services/prompts/`: доменный сервис системного промпта, поддержка превью, генерация HTML-Diff и пакетное удаление узлов (`BATCH_DELETE`).
+ - `services/snapshots/`: бизнес-логика срезов СКУД с защитой финишных Y-снапшотов.
+ - `services/knowledge/`: сервис базы знаний правил и синонимов отделов.
+ - `services/scud_etl/`: модульный ETL-конвейер (`pipeline.py`, `anomaly_detector.py`, `merger.py`, `sql_queries.py`).
+- **ИИ-оркестратор `modules/ai_engine/`:**
+ - Декомпозиция монолитного `agent.py` на узкие обработчики `handlers/` и динамический контекст-билдер `context_builder.py`.
+ - Двухконтурная обработка: нативный Function Calling + семантический страхующий инжектор (`tool_injector.py`).
+ - Интеллектуальный Topic Drift менеджер с вежливым напоминанием на 3-й шаг и автоочисткой эфемерных сообщений на 4-й.
+- **Интерфейс и UI:**
+ - Кнопка «✏️ Редактировать» прямо в карточке просмотра системного промпта с инлайн-редактором.
+
+#### Исправлено:
+- Устранена утечка системного промпта в постоянный контекст (`chat_messages`).
+- Исправлены ошибки несоответствия сигнатур (`TypeError: create_prompt_preview`, `save_full_prompt_draft`, `get_snapshots_registry`) в Fast-Path.
+- Устранена галлюцинация слов-паразитов («Почемучка», «Почемучто») на уровне валидации ответа.
+- Исправлено устаревшее противоречивое правило 2.3 в базе данных, блокировавшее интерактивное удаление задач.
+
+#### Удалено:
+- Устаревшие файлы-дубли: `modules/web_api/llm/db/db_tasks.py`, `db_snapshots.py`, `prompt_merger.py`.
+
+---
+
+## [2026-08-19] — Инлайн-редактор задач, интеллектуальный ContextManager и стабилизация Function Calling
+
+### ✨ Добавлено
+* **Инлайн-редактирование задач (`task_widget.js` / `tasks.py`)**:
+ - Добавлена кнопка редактирования (`✏️`) на каждой карточке задачи.
+ - Карточка трансформируется в инлайн-форму с тремя полями: текст задачи, HTML5-пикер календаря планируемого срока и выпадающий список важности (`LOW`, `MEDIUM`, `HIGH`, `CRITICAL`).
+ - Добавлен REST API эндпоинт `PATCH /api/v1/tasks/{task_id}` с поддержкой комплексного обновления полей в SQLite.
+ - В карточки выведено отображение даты создания задачи.
+* **Интеллектуальный ContextManager (`context_manager.py`)**:
+ - Служебные запросы и карточки инструментов сохраняются синхронными парами (`is_ephemeral = 1`) и атомарно удаляются при завершении операций.
+ - Свободные диалоги оператора (включая темы Topic Drift) сохраняются в постоянную историю (`is_ephemeral = 0`) и не стираются при закрытии сессий промпта/задач.
+* **Поддержка Y-снапшотов (`db_snapshots.py`)**:
+ - Исправлен SQL-запрос `db_get_snapshots` для точного извлечения ночных и фоновых срезов с префиксом `Y` (например, `Y20260817-003`).
+
+### 🔧 Изменено
+* **`modules/web_api/llm/agent.py`**:
+ - Полностью удалены эвристические текстовые фильтры и принудительные сортировки инструментов.
+ - Настроен нативный возврат и исполнение инструментов без зацикливания модели.
+ - Устранена ошибка `UnboundLocalError` при вызове справочных инструментов.
+* **`modules/web_api/llm/schemas.py`**:
+ - Очищены описания инструментов от агрессивных директив и капса, стабилизирована селективность Function Calling для Qwen 2.5.
+* **`modules/web_api/llm/core/ollama_client.py`**:
+ - Оптимизированы параметры генерации для Function Calling (`repeat_penalty: 1.0`, `presence_penalty: 0.0`, `temperature: 0.0`), что исключило спам несколькими вызовами инструментов на один запрос.
+* **`modules/web_api/static/js/chat/task_widget.js`**:
+ - Фильтр по умолчанию переведен на вкладку **«В работе»** (`IN_PROGRESS`).
+
---
## [2026-08-18] — Двухфазный жизненный цикл сессий, двусторонний Diff и Topic Drift Guard
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
deleted file mode 100644
index 780cc22..0000000
--- a/docs/ROADMAP.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# 🗺️ ДОРОЖНАЯ КАРТА И АРХИТЕКТУРНЫЙ РОАДМАП ПРОЕКТА SCUD ORION AI
-
-## 📌 Зафиксированные вехи и концептуальные архитектурные решения
-
-Схема веток Gitea для развития проекта:
-
-main (100% стабильный релиз)
- │
- ├──► feature/prompt-relational-nodes ──┐ (Перевод системного промпта на реляционные узлы в SQLite)
- │ ▼
- ├──────────────────────────────────► merge to main
- │
- ├──► feature/native-function-calling ──┐ (Отказ от регулярных выражений tool_injector.py в пользу нативного FC)
- │ ▼
- ├──────────────────────────────────► merge to main
- │
- └──► feature/ephemeral-context ────────┐ (Интеллектуальный жизненный цикл эфемерных сообщений)
- ▼
- merge to main
-
----
-
-## 🎯 Текущий активный спринт: Нативный Function Calling и реляционный редактор промпта
-
-### 1. Архитектура чистого диалогового агента без регулярных выражений
-* **Концепция:** Полный отказ от жестких эвристик и регулярных выражений в `tool_injector.py`. Все решения о намерениях оператора, вызове инструментов и переключении тем принимает языковая модель через семантический контекст.
-* **Ключевые этапы реализации:**
- * **Этап 1 (Санитарная очистка):** Превращение `tool_injector.py` в простой фильтр мусорных тегов без инжекции и перехватов команд.
- * **Этап 2 (Семантическое управление сессией):** Инструктаж модели в `role: system` о поведении в состоянии `PROMPT_PREVIEW` (оценка подтверждения, отмены, правок или смены темы диалога).
- * **Этап 3 (Жизненный цикл контекста):** Модель видит временные сообщения (`is_ephemeral = 1`) только во время активной ветки; при фиксации решения (`fast_path.py`) контекст очищается через `db_purge_ephemeral_messages`.
- * **Этап 4 (Гибридный UI):** Поддержка как диалоговых правок, так и прямого инлайн-редактирования через форму `[✏️ Редактировать]` в окне чата.
-
----
-
-## 🚀 Масштабирование эфемерного контекста и сервисные инструменты
-
-### 2. Модуль инспекции контекста и базы данных (`db_cli.py`)
-* **Статус:** `Реализовано`.
-* Добавлена команда `context [session_id] [--limit N]` для просмотра сырой таблицы сообщений `chat_messages` с пометками об эфемерности.
-
-### 3. Модуль задач и бэклога (`tasks`)
-* **Целевые инструменты:** `db_get_tasks`, `db_add_task`, `db_update_task_status`, `db_delete_task`.
-* Вывод интерактивных карточек задач с фильтрами без засорения постоянной истории диалога.
-
-### 4. Модуль срезов и архива СКУД (`snapshots`)
-* **Целевые инструменты:** `db_get_snapshots`, `db_delete_snapshots`.
-* Интерактивное подтверждение удаления срезов и автоматическая очистка таблиц срезов из контекста.
-
-### 5. Модуль базы знаний, правил и аномалий (`kb & anomalies`)
-* **Целевые инструменты:** `db_get_rules`, `db_get_anomalies`, `db_get_reference`.
-* Изоляция нормативных справочников от постоянной истории диалога для предотвращения залипания LLM.
-
----
-
-## 🔮 Долгосрочный бэклог (Future Modules)
-
-### 6. Интерактивный модуль планирования (Checklist & Task Planner Engine)
-* Динамические проектные чек-листы с синхронизацией в таблицу `tasks`.
-
-### 7. Изолированная песочница кода (Code Execution Sandbox Engine)
-* Запуск динамических Python/Pandas вычислений в изолированном Docker/gVisor контуре.
\ No newline at end of file
diff --git a/docs/in_progress/ROADMAP.md b/docs/in_progress/ROADMAP.md
new file mode 100644
index 0000000..1ec6ff1
--- /dev/null
+++ b/docs/in_progress/ROADMAP.md
@@ -0,0 +1,123 @@
+# План реализации (ROADMAP)
+
+## 1. Очистка от регулярок и костылей (`tool_injector.py`)
+- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
+- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4].
+
+---
+
+## 2. Настройка контекста и инструкций сессии (`agent.py`)
+- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4].
+- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
+ - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4].
+ - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4].
+- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4].
+- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`[cite: 4].
+- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов[cite: 4].
+
+---
+
+## 3. Очистка эфемерных сообщений при завершении (`fast_path.py` / `context_manager.py`)
+- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4].
+- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4].
+- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4].
+- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста[cite: 4].
+- [x] Создать `context_manager.py` для строгого разграничения служебных tool-пар (`is_ephemeral=1`) и содержательного диалога (`is_ephemeral=0`), сохраняя беседы Topic Drift в истории[cite: 4].
+
+---
+
+## 4. Инлайн-редактор в окне диалога (`core.js`)
+- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4].
+- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4].
+- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`)[cite: 4].
+- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика[cite: 4].
+
+---
+
+## 5. Тестирование и валидация системного промпта
+- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4].
+- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4].
+- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4].
+
+---
+
+## 6. Распространение архитектурного паттерна на модуль задач (`tasks`)
+- [x] **Масштабирование UI задач:**
+ - Увеличена ширина карточки до `max-w-4xl` и динамическая высота скролла до `70vh`[cite: 4].
+ - Реализованы переключатели фильтрации: «В работе» (по умолчанию), «В планах», «Готово», «Все»[cite: 4].
+ - Разделены кнопки действия: «В работу» (для плановых) и «Готово» (для текущих)[cite: 4].
+- [x] **Инлайн-редактирование карточки задачи:**
+ - Плавная трансформация карточки в 3-строчную форму (текст, дата со встроенным пикером, выпадающий список важности)[cite: 4].
+ - Отображение даты создания задачи[cite: 4].
+ - Сохранение через REST API эндпоинт `PATCH /api/v1/tasks/{id}`[cite: 4].
+- [x] **Детерминированный Fast-Path и двухфазное удаление:**
+ - Мгновенная смена статусов без задержек LLM[cite: 4].
+ - Карточка подтверждения удаления с автоочисткой контекста[cite: 4].
+- [x] **Генерация отчетов задач в Markdown:**
+ - Добавлен инструмент `db_export_tasks_markdown` для формирования Markdown-файла с группировкой по модулям и чекбоксами `[x]` / `[ ]`[cite: 3].
+ - Реализован роутер безопасной отдачи файлов с сохранением имени (`/api/v1/files/download/...`)[cite: 3].
+- [x] **Доменная консолидация задач:**
+ - Реализован консолидированный инструмент `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE", "EXPORT"], ...)`[cite: 3].
+
+---
+
+## 7. Распространение на модуль снапшотов (`snapshots`)
+- [x] **Поддержка Y-снапшотов:** универсальный парсинг и поиск по `snapshot_id LIKE 'Y%'` и срезам за 22:00:00[cite: 4].
+- [x] **Single Active Snapshot View:** сохранение активного среза в контексте для аналитики с автоматической ротацией и зачисткой при запросе новых дат[cite: 4].
+- [x] **Интерактивный UI с чекбоксами и защитой срезов:**
+ - Чекбоксы в строках дневных срезов и кнопка «Выбрать все» в шапке[cite: 3].
+ - Защита итогового вечернего среза Y (иконка замочка `🔒`, блокировка выбора)[cite: 3].
+ - Динамическая кнопка «Удалить выбранные (N)» в подвале карточки[cite: 3].
+- [x] **Детерминированный Fast-Path удаления срезов:**
+ - Двухфазное подтверждение удаления (одиночное и пакетное) с корректным счётчиком количества удаляемых элементов[cite: 3].
+ - Защита от сброса фильтра даты (`query_date`) при обновлении карточки после удаления[cite: 3].
+
+---
+
+## 🏗️ Раздел 8. Глубокий архитектурный рефакторинг ядра и сервисов `[ЗАВЕРШЕНО]`
+
+- [x] **Шаг 1. Выделение общего слоя ядра (`core/`)**
+ - [x] Изолированный пул соединений SQLite (`core/connection.py`) с поддержкой WAL и таймаутов.
+ - [x] DDL-схемы таблиц и индексов (`core/schema.py`).
+ - [x] Репозитории СКУД и 1С (`core/repositories/scud_repo.py`, `core/repositories/zup_repo.py`).
+ - [x] Фасад обратной совместимости (`core/database.py`).
+- [x] **Шаг 2. Декомпозиция предметных доменов (`services/`)**
+ - [x] Сервис задач (`services/tasks/`): CRUD, валидация, markdown-экспорт (`exporter.py`).
+ - [x] Сервис системного промпта (`services/prompts/`): сборка узлов, вычисление diff (`diff_engine.py`), пакетное удаление (`BATCH_DELETE`).
+ - [x] Сервис снапшотов СКУД (`services/snapshots/`): безопасное удаление с защитой Y-снапшотов.
+ - [x] Сервис базы знаний (`services/knowledge/`): правила арбитража и синонимы отделов.
+- [x] **Шаг 3. Рефакторинг ETL-конвейера (`services/scud_etl/`)**
+ - [x] Оркестратор этапов контроллинга (`pipeline.py`).
+ - [x] Детектор аномалий и конфликтов реестров (`anomaly_detector.py`).
+ - [x] Агрегация проходов и наложение исключений (`merger.py`).
+ - [x] Информативное консольное логирование этапов в `main_etl.py`.
+- [x] **Шаг 4. Модульный ИИ-оркестратор (`modules/ai_engine/`)**
+ - [x] Динамический строитель системного контекста (`context_builder.py`).
+ - [x] Изолированные обработчики инструментов (`handlers/task_handler.py`, `handlers/prompt_handler.py`, `handlers/snapshot_handler.py`).
+ - [x] Компактный диспетчер диалога (`agent.py`).
+ - [x] Двухконтурный семантический анализатор намерений (`tool_injector.py`).
+- [x] **Шаг 5. Зачистка рудиментов и стабилизация**
+ - [x] Устранение дубликатов в `web_api/llm/db/`.
+ - [x] Очистка роутеров от прямых манипуляций с базой данных.
+ - [x] Исправление сигнатур Fast-Path перехватчиков.
+
+---
+
+## 📊 Раздел 9. Диалоговая генерация и контроль отчетов СКУД (Conversational Reporting Engine) `[В ПЛАНАХ]`
+
+- [ ] **Интерактивный запуск отчетов из чата**
+ - [ ] Инструмент `db_generate_report(date_str, report_type)` для генерации сводки или детального отчета по требованию оператора.
+ - [ ] Автоматическая отдача карточки скачивания сформированного Excel-файла прямо в диалоге (`FILE_DOWNLOAD_CARD`).
+- [ ] **Диалоговый аудит расхождений**
+ - [ ] Инструмент точечной выборки: «Кто сегодня не пришел из отдела ОВК?», «Покажи опоздавших за вчера».
+ - [ ] Быстрое внесение исключений и синонимов подразделений через диалог.
+
+---
+
+## 10. Изолированная песочница кода (Code Execution Sandbox Engine)
+- [ ] **Docker/gVisor контур:**
+ - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups)[cite: 4].
+ - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only[cite: 4].
+- [ ] **Динамические Python/Pandas вычисления:**
+ - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету[cite: 4].
+ - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата[cite: 4].
\ No newline at end of file
diff --git a/docs/in_progress/план_оптимизации_работы_с_контекстом.md b/docs/in_progress/план_оптимизации_работы_с_контекстом.md
deleted file mode 100644
index 4587843..0000000
--- a/docs/in_progress/план_оптимизации_работы_с_контекстом.md
+++ /dev/null
@@ -1,72 +0,0 @@
-# План реализации
-
-## 1. Очистка от регулярок и костылей (`tool_injector.py`)
-- [x] Полностью удалить принудительные перехваты текста регулярными выражениями для команд добавления, редактирования и удаления пунктов[cite: 4].
-- [x] Оставить в модуле только базовую санитарную очистку сырых тегов (``)[cite: 4].
-
----
-
-## 2. Настройка контекста и инструкций сессии (`agent.py`)
-- [x] Передать управление диалогом языковой модели через системный блок `role: "system"`[cite: 4].
-- [x] При активном состоянии `PROMPT_PREVIEW` передавать модели инструкцию:
- - **Подтверждение / отмена / корректировка:** продолжать работу с превью и вызывать соответствующие инструменты[cite: 4].
- - **Смена темы:** вежливо напомнить об открытом изменении и запросить решение[cite: 4].
-- [x] Обеспечить видимость эфемерных сообщений (`is_ephemeral = 1`) для модели во время активной работы с превью[cite: 4].
-- [x] Внедрить семантический Topic Drift Guard (`idle_turns` = 3) с вопросами и кнопками из `tool_action_registry`.
-- [x] Реализовать детерминированный мгновенный сброс сессии и зачистку эфемерного контекста при вызове сторонних инструментов.
-
----
-
-## 3. Очистка эфемерных сообщений при завершении (`fast_path.py`)
-- [x] Настроить удаление временных сообщений превью (`db_purge_ephemeral_messages`) строго в момент нажатия кнопок **«Подтвердить»** или **«Отменить»**[cite: 4].
-- [x] Сбрасывать состояние сессии в базе данных после фиксации решения[cite: 4].
-- [x] Внедрить прямое точечное применение изменений через `db_apply_prompt_node_action`[cite: 4].
-- [x] Добавить обработку фазы `PROMPT_FOLLOWUP` с кнопками завершения и очистки контекста.
-
----
-
-## 4. Инлайн-редактор в окне диалога (`core.js`)
-- [x] Проверить работу блока ручного редактирования (`inline-prompt-editor-container`) с кнопками **«Сохранить правки»** и **«Свернуть»**[cite: 4].
-- [x] Обеспечить сохранение черновика через API (`/api/v1/chat/draft`) и отображение обновленного текста перед подтверждением[cite: 4].
-- [x] Реализовать двусторонний клиентский Diff-рендерер (одновременная подсветка добавленных строк и зачеркивание удаленных `[УДАЛЕНИЕ]`).
-- [x] Добавить авто-форматирование и отступы подпунктов (`X.Y.`) при ручном сохранении черновика.
-
----
-
-## 5. Тестирование и валидация системного промпта
-- [x] **Нативные вызовы:** проверить добавление, редактирование и удаление пунктов через нативные вызовы модели (`db_prompt_node_edit`)[cite: 4].
-- [x] **Контекстные сценарии:** проверить поведение модели при смене темы диалога оператором (Guardrail)[cite: 4].
-- [x] **UI и очистка:** проверить ручное редактирование через кнопку в окне чата и последующую очистку контекста (`db_purge_ephemeral_messages`)[cite: 4].
-
----
-
-## 6. Распространение архитектурного паттерна на модуль задач (`tasks`)
-- [ ] **Масштабирование UI задач:**
- - Увеличить размер окна/виджета просмотра и управления задачами (`TASK_INTERACTIVE_CARD`) по аналогии с окном редактирования системного промпта для удобного управления большим списком.
-- [ ] **Интерактивное управление и превью:**
- - Карточка предпросмотра параметров создаваемой задачи (`db_add_task`) с кнопками `[Подтвердить]`, `[Отменить]`, `[✏️ Редактировать]`.
- - Подтверждение опасных операций (удаление задачи `db_delete_task`) с кнопками валидации.
- - Follow-up фаза с выводом актуализированного списка задач из `tool_action_registry`.
-- [ ] **Доменная консолидация:**
- - Объединить `db_add_task`, `db_update_task_status`, `db_delete_task` в единый диспетчер `db_tasks_edit(action: ["ADD", "UPDATE", "DELETE"], ...)`[cite: 4].
- - Обновить `TOOLS_SCHEMA` в `schemas.py`[cite: 4].
-
----
-
-## 7. Распространение на модуль снапшотов (`snapshots`) и правил (`rules`)
-- [ ] **Консолидация модуля `snapshots`:**
- - Интерактивное подтверждение массового удаления снапшотов за дату (`db_delete_snapshots`).
- - Объединить операции удаления и управления срезами в единый `db_snapshots_edit(action: ["DELETE"], ...)`[cite: 4].
- - Авто-зачистка тяжелых таблиц снапшотов из контекста при переходе к другим темам.
-- [ ] **Модуль правил арбитража (`rules`):**
- - Редактирование правил базы знаний через единый паттерн узлов с подсветкой diff.
-
----
-
-## 8. Изолированная песочница кода (Code Execution Sandbox Engine)
-- [ ] **Docker/gVisor контур:**
- - Создание изолированного контейнера без доступа к внешней сети (network: none) с ограниченными лимитами по памяти и CPU (cgroups).
- - Настройка безопасного монтирования только необходимых CSV/Parquet-файлов данных в режиме Read-Only.
-- [ ] **Динамические Python/Pandas вычисления:**
- - Инструмент генерации и безопасного выполнения скриптов агрегации и аналитики данных СКУД / 1С на лету.
- - Перехват stdout/stderr, сбор результатов расчетов и графиков с передачей в UI-чата.
\ No newline at end of file
diff --git a/etl_code_snapshot.md b/etl_code_snapshot.md
new file mode 100644
index 0000000..002af36
--- /dev/null
+++ b/etl_code_snapshot.md
@@ -0,0 +1,3752 @@
+# 📦 ETL-СЛЕПОК ИСХОДНОГО КОДА (СКУД ⟷ 1С & DB CORE)
+
+## File: `./config.py`
+```py
+import os
+import re
+from datetime import datetime, timedelta
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+DATA_DIR = os.path.join(BASE_DIR, "data")
+
+SCUD_DIR = os.path.join(DATA_DIR, "scud")
+ZUP_1C_DIR = os.path.join(DATA_DIR, "1c")
+OUTPUT_DIR = os.path.join(BASE_DIR, "output")
+REPORTS_DIR = os.path.join(OUTPUT_DIR, "reports")
+
+# Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат)
+SHARE_1C_DIR = "/mnt/scud_share"
+
+for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR, REPORTS_DIR]:
+ os.makedirs(folder, exist_ok=True)
+
+NOW = datetime.now()
+DATE_TODAY = NOW.strftime("%d.%m.%Y")
+
+if NOW.weekday() == 0:
+ DATE_YESTERDAY = (NOW - timedelta(days=3)).strftime("%d.%m.%Y")
+else:
+ DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y")
+
+# --- Настройки LLM / Ollama ---
+OLLAMA_URL = "http://10.121.17.227:11434/api/generate"
+OLLAMA_MODEL = "qwen2.5:14b-instruct-q8_0"
+MODEL_NAME = OLLAMA_MODEL
+
+KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json")
+EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json")
+
+# --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) ---
+ZUP_SQL_CONFIG = {
+ "driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}"
+ "server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"),
+ "database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01
+ "user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server
+ "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server
+ "trust_server_certificate": "yes", # Доверять сертификату сервера
+ "encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование
+}
+
+
+def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]):
+ """
+ Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026).
+ """
+ date_dots = date_str
+ date_underscores = date_str.replace('.', '_')
+
+ for d in search_dirs:
+ if not os.path.exists(d):
+ continue
+ for f in os.listdir(d):
+ if f.endswith('.xlsx') or f.endswith('.csv'):
+ if f.lower().startswith(prefix.lower()):
+ if date_dots in f or date_underscores in f:
+ return os.path.join(d, f)
+ return None
+
+
+def normalize_fio(fio):
+ if not fio or not isinstance(fio, str):
+ return ""
+ fio_clean = re.sub(r'\(.*?\)', '', fio)
+ fio_clean = fio_clean.replace('\xa0', ' ')
+ parts = fio_clean.strip().split()
+ return " ".join(parts).title()
+
+
+def clean_scud_fio_light(fio_str):
+ return normalize_fio(fio_str)
+
+
+def load_exceptions():
+ import json
+ if os.path.exists(EXCEPTIONS_PATH):
+ try:
+ with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ pass
+ return {"fio": [], "departments": [], "positions": [], "position_keywords": []}
+```
+
+## File: `./exceptions.json`
+```json
+{
+ "departments": [
+ "ОВК"
+ ],
+ "positions": [
+ "Уборщик производственных помещений",
+ "Уборщик служебных помещений"
+ ],
+ "fio": [
+ "Таткало Валерий Валерьевич",
+ "Петренюк Андрей Германович",
+ "Михалев Сергей Геннадьевич"
+ ],
+ "position_keywords": [
+ "уборщик",
+ "клинер",
+ "дворник",
+ "гардероб",
+ "рабочий по обслуживанию"
+ ],
+ "include_fio": [
+ "Тарасенко Александр Александрович",
+ "Журиков Михаил Николаевич"
+ ]
+}
+```
+
+## File: `./main_etl.py`
+```py
+"""
+===============================================================================
+FILE: main_etl.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
+===============================================================================
+"""
+
+import os
+import sys
+import argparse
+import logging
+import pandas as pd
+from datetime import datetime, timedelta
+
+from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
+from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
+from services.scud_etl.anomaly_detector import detect_registry_anomalies
+from services.text_reporter import generate_markdown_report
+
+from services.scud_export import run_export
+from services.share_copier import copy_1c_files_from_share
+from services.excel_exporter import generate_summary_excel, generate_detailed_excel
+
+logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С")
+ parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки")
+ parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL")
+ parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета")
+ args = parser.parse_args()
+
+ print("=" * 60)
+ print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}")
+ print("=" * 60)
+
+ now = datetime.now()
+ today_str = now.strftime("%d.%m.%Y")
+ # ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня)
+ if now.weekday() == 0:
+ yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
+ else:
+ yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
+
+ # [Этап 0] Выгрузка свежих данных СКУД
+ if not args.skip_export and not args.snapshot:
+ print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
+ run_export(debug=args.debug)
+ else:
+ print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
+
+ # [Этап 0.5] Синхронизация файлов с шары 1С
+ if not args.snapshot and not args.skip_export:
+ print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
+ copy_1c_files_from_share()
+ else:
+ print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...")
+
+ # [Этап 1-2] Загрузка данных
+ print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
+ df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True)
+ df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False)
+
+ df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
+ df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
+
+ # [Этап 3] Детальный отчет за вчера
+ print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
+ df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
+ generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str)
+
+ # [Этап 4] Сводка за сегодня
+ print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
+ df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
+ metrics_today = calculate_summary_metrics(df_merged_today)
+ anomalies_today = detect_registry_anomalies(df_merged_today)
+ generate_summary_excel(df_merged_today, date_str=today_str)
+
+ # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора
+ print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
+
+ absent_explained = df_merged_today[
+ (df_merged_today['Пришел'] == False) &
+ (df_merged_today['Вид_отсутствия'].notna()) &
+ (~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
+ ]
+ absent_unexplained = df_merged_today[
+ (df_merged_today['Пришел'] == False) &
+ (df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
+ (df_merged_today.get('is_excluded', False) == False)
+ ]
+
+ summary_md = generate_markdown_report(
+ merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
+ absent_explained=absent_explained,
+ absent_unexplained=absent_unexplained,
+ scud_present_but_absent_in_1c=pd.DataFrame(),
+ anomalies_list=anomalies_today,
+ date_str=today_str
+ )
+
+ os.makedirs("output", exist_ok=True)
+ md_file_path = f"output/Сводка_контроллинга_{today_str}.md"
+ with open(md_file_path, "w", encoding="utf-8") as f:
+ f.write(summary_md)
+ print(f"[✓] Текстовый отчет сохранен в: {md_file_path}")
+
+ print("\n" + "=" * 60)
+ print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
+ print("=" * 60)
+ print(summary_md)
+
+
+if __name__ == "__main__":
+ main()
+```
+
+## File: `./run_cron_etl.sh`
+```bash
+#!/bin/bash
+set -e
+
+# Переход в папку проекта
+cd /home/puh/projects/scud_ai
+
+# Создание папки для логов
+mkdir -p /home/puh/projects/scud_ai/logs
+
+# Фиксация старта
+echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
+echo "[CRON START] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
+echo "==================================================" >> /home/puh/projects/scud_ai/logs/cron_etl.log
+
+# Запуск main_etl.py через интерпретатор окружения
+/home/puh/scud_orion_ai_v2/venv/bin/python /home/puh/projects/scud_ai/main_etl.py >> /home/puh/projects/scud_ai/logs/cron_etl.log 2>&1
+
+# Фиксация окончания
+echo "[CRON FINISH] $(date '+%Y-%m-%d %H:%M:%S')" >> /home/puh/projects/scud_ai/logs/cron_etl.log
+echo "" >> /home/puh/projects/scud_ai/logs/cron_etl.log
+```
+
+## File: `./scripts/db_cli.py`
+```py
+import os
+import sys
+import argparse
+import sqlite3
+import pandas as pd
+from datetime import datetime
+
+sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR, EXCEPTIONS_PATH, normalize_fio
+from core.database import (
+ get_connection,
+ get_available_snapshots,
+ get_all_rules_from_db,
+ load_scud_from_db_by_snapshot,
+ get_latest_snapshot_time
+)
+from services.exceptions_repo import (
+ get_all_exceptions_from_db,
+ add_exception_to_db,
+ remove_exception_from_db,
+ sync_json_to_db
+)
+
+DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
+
+
+def print_tool_actions():
+ """Выводит реестр декларативных действий инструментов и шаблоны кнопок."""
+ print("\n" + "=" * 110)
+ print("🧰 ДЕКЛАРАТИВНЫЙ РЕЕСТР ДЕЙСТВИЙ ИНСТРУМЕНТОВ (tool_action_registry):")
+ print("=" * 110)
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ try:
+ cursor.execute("""
+ SELECT id, tool_name, category, bypass_llm, success_template, follow_up_question, buttons_json
+ FROM tool_action_registry
+ WHERE is_active = 1
+ ORDER BY id ASC
+ """)
+ rows = cursor.fetchall()
+ if not rows:
+ print("Таблица tool_action_registry пуста.")
+ else:
+ for r in rows:
+ print(f"ID: {r[0]} | Tool: [{r[1]}] | Категория: {r[2]} | Bypass LLM: {'ДА (0.05с)' if r[3] else 'НЕТ'}")
+ print(f" • Сообщение: {r[4]}")
+ if r[5]:
+ print(f" • Вопрос: {r[5]}")
+ print(f" • Кнопки: {r[6]}")
+ print("-" * 110)
+ except Exception as e:
+ print(f"Таблица tool_action_registry недоступна: {e}")
+ print("=" * 110 + "\n")
+
+
+def print_stats():
+ """Выводит общую статистику по записям в таблицах БД."""
+ print("\n" + "=" * 60)
+ print("📊 СТАТИСТИКА БАЗЫ ДАННЫХ SQLITE (scud_orion_ai.db):")
+ print("=" * 60)
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ tables = [
+ 'scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history',
+ 'ai_knowledge_base', 'chat_messages', 'session_states',
+ 'system_prompt_nodes', 'tasks', 'exceptions_registry'
+ ]
+ for t in tables:
+ try:
+ cursor.execute(f"SELECT COUNT(*) FROM {t}")
+ cnt = cursor.fetchone()[0]
+ print(f" • Таблица [{t:<22}]: {cnt:>6} записей")
+ except Exception:
+ pass
+ print("=" * 60 + "\n")
+
+
+def print_snapshots_list(date_str=None):
+ """Выводит реестр снапшотов с отображением даты и точного времени среза."""
+ rows = get_available_snapshots(date_str)
+
+ print("\n" + "=" * 105)
+ print(f"📸 РЕЕСТР СОХРАНЕННЫХ СНАПШОТОВ (СВЕРХУ СВЕЖИЕ) {'ЗА ЛОГИ ' + date_str if date_str else ''}:")
+ print("=" * 105)
+
+ header = f"{'ID снапшота':<16} | {'Дата снапшота (создания)':<24} | {'Дата и время среза':<20} | {'Записей':<8}"
+ print(header)
+ print("-" * 105)
+
+ if not rows:
+ print("Снапшотов пока нет.")
+ print("=" * 105 + "\n")
+ return
+
+ def snapshot_sort_key(row):
+ snap_id = row[0] or ""
+ snap_time = row[2] or ""
+ seq_num = 0
+ if "-" in snap_id:
+ parts = snap_id.replace("Y", "").split("-")
+ if len(parts) > 1 and parts[1].isdigit():
+ seq_num = int(parts[1])
+ return (snap_time, seq_num)
+
+ sorted_rows = sorted(rows, key=snapshot_sort_key, reverse=True)
+
+ for r in sorted_rows:
+ snap_id = r[0] if r[0] else '----------'
+ log_date = r[1] if r[1] else '—'
+ snap_time = r[2] if r[2] else '—'
+ count = r[3]
+
+ time_part = "—"
+ if snap_time and " " in snap_time:
+ time_part = snap_time.split(" ")[1]
+
+ slice_datetime_str = f"{log_date} {time_part}" if time_part != "—" else log_date
+ formatted_snap_id = f" {snap_id}" if not snap_id.startswith("Y") else snap_id
+
+ print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}")
+
+ print("=" * 105 + "\n")
+
+
+def delete_snapshot_by_id(snapshot_id: str):
+ """Удаляет конкретный снапшот из таблицы scud_logs по его ID."""
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
+ deleted_count = cursor.rowcount
+ conn.commit()
+ print(f"\n[✓] Успешно удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}\n")
+ return deleted_count
+
+
+def delete_snapshots_by_date(date_str: str):
+ """Удаляет все снапшоты за указанную дату (например, '04.08.2026')."""
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
+ deleted_count = cursor.rowcount
+ conn.commit()
+ print(f"\n[✓] Успешно удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}\n")
+ return deleted_count
+
+
+def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None):
+ """Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX."""
+ target_date = date_str if date_str else DATE_TODAY
+
+ print("\n" + "=" * 115)
+ if snapshot_id:
+ print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата среза: {target_date}):")
+ else:
+ print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):")
+ print("=" * 115)
+
+ df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id)
+
+ if df is None or df.empty:
+ print("Записи СКУД не найдены.")
+ print("=" * 115 + "\n")
+ return
+
+ fio_col = next((c for c in ['Сотрудник', 'fio', 'fio_clean'] if c in df.columns), None)
+ dept_col = next((c for c in ['Подразделение', 'department_scud', 'department'] if c in df.columns), None)
+ pos_col = next((c for c in ['Должность', 'position'] if c in df.columns), None)
+ in_col = next((c for c in ['Начало_дня', 'time_in'] if c in df.columns), None)
+ first_act_col = next((c for c in ['Первая_активность', 'first_activity'] if c in df.columns), None)
+ out_col = next((c for c in ['Конец_дня', 'time_out'] if c in df.columns), None)
+ dur_col = next((c for c in ['Находился_в_здании', 'time_in_building', 'duration'] if c in df.columns), None)
+ present_col = next((c for c in ['Пришел', 'is_present'] if c in df.columns), None)
+ anom_col = 'anomaly_flag' if 'anomaly_flag' in df.columns else None
+ snap_col = 'snapshot_id' if 'snapshot_id' in df.columns else None
+
+ total = len(df)
+ if present_col:
+ present_cnt = len(df[df[present_col].astype(str).str.lower().isin(['true', '1'])])
+ else:
+ present_cnt = 0
+ absent_cnt = total - present_cnt
+
+ print(f"Всего записей: {total} | Присутствовали: {present_cnt} | Отсутствовали: {absent_cnt}")
+ print("-" * 115)
+
+ display_cols = [c for c in [fio_col, dept_col, in_col, first_act_col, out_col, dur_col, present_col, anom_col, snap_col] if c]
+ print(df[display_cols].head(30).to_string(index=False))
+
+ if len(df) > 30:
+ print(f"\n... и ещё {len(df) - 30} строк.")
+
+ if export_xlsx:
+ out_path = export_xlsx if export_xlsx.endswith('.xlsx') else f"{export_xlsx}.xlsx"
+ if not os.path.isabs(out_path):
+ out_path = os.path.join(OUTPUT_DIR, out_path)
+
+ df.to_excel(out_path, index=False)
+ print("\n" + "*" * 115)
+ print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}")
+ print("*" * 115)
+
+ print("=" * 115 + "\n")
+
+
+def print_absences(date_str=None):
+ """Выводит список официально отсутствующих сотрудников из 1С:ЗУП за выбранный день."""
+ target_date = date_str if date_str else DATE_TODAY
+ print("\n" + "=" * 90)
+ print(f"📋 ОФИЦИАЛЬНЫЕ ОТСУТСТВИЯ ИЗ 1С:ЗУП ЗА ДАТУ [{target_date}]:")
+ print("=" * 90)
+
+ with get_connection() as conn:
+ df = pd.read_sql_query(
+ "SELECT fio as 'ФИО', absence_type as 'Причина отсутствия 1С' FROM zup_absences WHERE absence_date = ? ORDER BY absence_type, fio",
+ conn,
+ params=(target_date,)
+ )
+
+ if df.empty:
+ print(f"Записи об отсутствиях 1С за {target_date} в базе не найдены.")
+ else:
+ print(f"Всего зафиксировано документов 1С: {len(df)}")
+ print("-" * 90)
+ print(df.to_string(index=False))
+
+ print("=" * 90 + "\n")
+
+
+def print_anomalies():
+ """Выводит список аномалий СКУД из БД."""
+ print("\n" + "=" * 80)
+ print("🚨 ИСТОРИЯ НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:")
+ print("=" * 80)
+ with get_connection() as conn:
+ df = pd.read_sql_query("SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history ORDER BY id DESC LIMIT 50", conn)
+ if df.empty:
+ print("Аномалии не найдены.")
+ else:
+ print(df.to_string(index=False))
+ print("=" * 80 + "\n")
+
+
+def print_rules():
+ """Выводит правила базы знаний ИИ."""
+ rules = get_all_rules_from_db()
+ print("\n" + "=" * 80)
+ print("🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ:")
+ print("=" * 80)
+ if not rules:
+ print("База знаний пуста.")
+ else:
+ for idx, r in enumerate(rules, 1):
+ print(f" {idx}. {r}")
+ print("=" * 80 + "\n")
+
+
+def dump_all_to_excel(out_filename="db_dump_full.xlsx"):
+ """Дампит всю базу SQLite во многостраничный Excel."""
+ out_path = os.path.join(OUTPUT_DIR, out_filename)
+ print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...")
+ with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer:
+ for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks', 'exceptions_registry']:
+ try:
+ df = pd.read_sql_query(f"SELECT * FROM {table}", conn)
+ df.to_excel(writer, sheet_name=table[:31], index=False)
+ except Exception:
+ pass
+ print(f"[✓] Дамп успешно сохранен: {out_path}\n")
+
+
+def print_system_prompts():
+ """Выводит все системные промпты из базы данных."""
+ print("\n" + "=" * 80)
+ print("📝 СИСТЕМНЫЕ ПРОМПТЫ (system_prompt_nodes):")
+ print("=" * 80)
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ try:
+ cursor.execute("SELECT section_id, item_id, content, is_active, updated_at FROM system_prompt_nodes ORDER BY section_id ASC, item_id ASC")
+ rows = cursor.fetchall()
+ except Exception:
+ rows = []
+
+ if not rows:
+ print("Таблица system_prompt_nodes пуста.")
+ else:
+ for r in rows:
+ print(f"Раздел {r[0]}.{r[1]} | Active: {r[3]} | Updated: {r[4]}")
+ print(f" {r[2]}")
+ print("-" * 80)
+ print("=" * 80 + "\n")
+
+
+def print_session_states():
+ """Выводит текущие активные сессии и превью (session_states)."""
+ print("\n" + "=" * 80)
+ print("🔄 АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states):")
+ print("=" * 80)
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT session_id, state_type, updated_at, pending_data FROM session_states")
+ rows = cursor.fetchall()
+ if not rows:
+ print("Таблица session_states пуста (нет активных превью).")
+ else:
+ for r in rows:
+ print(f"Session: {r[0]} | Type: {r[1]} | Updated: {r[2]}")
+ print("-" * 80)
+ print(f"Pending Data:\n{r[3]}\n")
+ print("=" * 80 + "\n")
+
+
+def print_chat_messages(session_id=None, limit=50):
+ """Выводит таблицу истории сообщений чата (контекст)."""
+ print("\n" + "=" * 105)
+ print(f"💬 ИСТОРИЯ СООБЩЕНИЙ ЧАТА (chat_messages) {f'для сессии: {session_id}' if session_id else 'все сессии'}:")
+ print("=" * 105)
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ if session_id:
+ cursor.execute("""
+ SELECT id, session_id, role, content, is_ephemeral, created_at
+ FROM chat_messages
+ WHERE session_id = ?
+ ORDER BY id ASC
+ LIMIT ?
+ """, (session_id, limit))
+ else:
+ cursor.execute("""
+ SELECT id, session_id, role, content, is_ephemeral, created_at
+ FROM chat_messages
+ ORDER BY id DESC
+ LIMIT ?
+ """, (limit,))
+
+ rows = cursor.fetchall()
+ if not rows:
+ print("Таблица chat_messages пуста.")
+ else:
+ if not session_id:
+ rows = list(reversed(rows))
+
+ for r in rows:
+ msg_id, sess, role, content, ephemeral, created = r
+ eph_marker = " [ЭФЕМЕРНОЕ]" if ephemeral else ""
+ print(f"[{msg_id}] {created} | Сессия: {sess} | Роль: {role.upper()}{eph_marker}")
+ print("-" * 105)
+ content_preview = content if content else ""
+ print(f"{content_preview}")
+ print("=" * 105)
+ print("\n")
+
+
+def purge_chat_context(session_id=None, purge_all=False):
+ """
+ Очистка контекста сообщений:
+ - По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий.
+ - purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии.
+ """
+ with get_connection() as conn:
+ cursor = conn.cursor()
+
+ if purge_all:
+ if session_id:
+ cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
+ cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
+ else:
+ cursor.execute("DELETE FROM chat_messages")
+ cursor.execute("DELETE FROM session_states")
+ deleted_msgs = cursor.rowcount
+ conn.commit()
+ print(f"\n[✓] Полная очистка истории выполнена! Удалено сообщений: {deleted_msgs}\n")
+ return
+
+ query = """
+ DELETE FROM chat_messages
+ WHERE is_ephemeral = 1
+ OR content LIKE '%Предпросмотр изменений%'
+ OR content LIKE '%Удален пункт:%'
+ OR content LIKE '%добавлен пункт:%'
+ """
+ if session_id:
+ cursor.execute(query + " AND session_id = ?", (session_id,))
+ cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
+ else:
+ cursor.execute(query)
+ cursor.execute("DELETE FROM session_states")
+
+ deleted_msgs = cursor.rowcount
+ conn.commit()
+
+ print(f"\n[✓] Умная зачистка контекста выполнена! Удалено сообщений: {deleted_msgs}\n")
+
+
+# ⭐️ Новые функции управления исключениями (Exceptions & Whitelist)
+def print_exceptions():
+ """Выводит реестр исключений и белый список сотрудников из базы SQLite."""
+ exc = get_all_exceptions_from_db()
+ print("\n" + "=" * 80)
+ print("📋 РЕЕСТР ИСКЛЮЧЕНИЙ И БЕЛЫЙ СПИСОК (exceptions_registry):")
+ print("=" * 80)
+ for cat, items in exc.items():
+ print(f"[{cat.upper()}] ({len(items)} шт.):")
+ if items:
+ for it in items:
+ print(f" • {it}")
+ else:
+ print(" — пусто")
+ print("-" * 80)
+ print("=" * 80 + "\n")
+
+
+HELP_TEXT = """
+CLI-утилита инспекции и управления SQLite базой данных СКУД (scud_orion_ai.db)
+
+ДОСТУПНЫЕ КОМАНДЫ:
+ stats -- Общая статистика строк по всем таблицам БД
+ snapshots [ДД.ММ.ГГГГ] -- Посмотреть реестр снапшотов (опционально за конкретную дату)
+ scud [ДД.ММ.ГГГГ] [--snapshot ID] [--export-xlsx NAME] -- Инспекция логов СКУД по дате/снапшоту и экспорт в Excel
+ absences [ДД.ММ.ГГГГ] -- Посмотреть список официально отсутствующих из 1С:ЗУП
+ anomalies -- Посмотреть историю найденных аномалий СКУД ⟷ 1С
+ rules -- Посмотреть правила Базы Знаний ИИ из SQLite
+ prompts -- Посмотреть узлы системного промпта (system_prompt_nodes)
+ tools -- Посмотреть реестр действий инструментов и кнопок (tool_action_registry)
+ sessions -- Посмотреть активные сессии и превью (session_states)
+ context [session_id] [--limit N] -- Посмотреть таблицу контекста сообщений чата (chat_messages)
+ context purge [session_id] [--all] -- Очистить контекст (умная зачистка или полная с флагом --all)
+ dump [output.xlsx] -- Полный дамп всех таблиц БД в многостраничный Excel
+ snapshot del [ID] или [--day ДД.ММ.ГГГГ] -- Удаление снапшота по ID или всех за выбранный день
+
+ exceptions [list] -- Посмотреть реестр исключений и белый список (SQLite)
+ exceptions add -c CATEGORY -v VALUE [-m COMMENT] -- Добавить исключение (fio, include_fio, departments, positions, position_keywords)
+ exceptions del -c CATEGORY -v VALUE -- Удалить исключение из БД
+ exceptions sync -- Синхронизировать exceptions.json -> SQLite
+
+ПРИМЕРЫ ЗАПУСКА:
+ python scripts/db_cli.py stats
+ python scripts/db_cli.py snapshots 06.08.2026
+ python scripts/db_cli.py scud 06.08.2026 --export-xlsx срез_четверг
+ python scripts/db_cli.py absences 07.08.2026
+ python scripts/db_cli.py prompts
+ python scripts/db_cli.py tools
+ python scripts/db_cli.py sessions
+ python scripts/db_cli.py context web_session_main --limit 20
+ python scripts/db_cli.py context purge
+ python scripts/db_cli.py context purge --all
+ python scripts/db_cli.py context purge web_session_main --all
+ python scripts/db_cli.py snapshot del Y20260805-007
+ python scripts/db_cli.py dump my_dump.xlsx
+ python scripts/db_cli.py exceptions
+ python scripts/db_cli.py exceptions add -c include_fio -v "Тарасенко Александр Александрович"
+ python scripts/db_cli.py exceptions add -c fio -v "Михалев Сергей Геннадьевич" -m "Уборщик"
+ python scripts/db_cli.py exceptions del -c fio -v "Михалев Сергей Геннадьевич"
+ python scripts/db_cli.py exceptions sync
+"""
+
+
+def main():
+ if any(arg in sys.argv for arg in ['-h', '--help']):
+ print(HELP_TEXT)
+ sys.exit(0)
+
+ parser = argparse.ArgumentParser(
+ description=HELP_TEXT,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ add_help=False
+ )
+ parser.add_argument('command', nargs='?', default=None, choices=[
+ 'stats', 'snapshots', 'scud', 'absences', 'anomalies',
+ 'rules', 'prompts', 'sessions', 'dump', 'snapshot',
+ 'tools', 'context', 'exceptions'
+ ], help="Основная команда")
+ parser.add_argument('action', nargs='?', default=None, help="Действие ('del', 'purge', 'add', 'sync') или дата/сессия")
+ parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, имя файла)")
+ parser.add_argument('-c', '--category', type=str, default=None, choices=['departments', 'positions', 'fio', 'position_keywords', 'include_fio'], help="Категория исключения")
+ parser.add_argument('-v', '--value', type=str, default=None, help="Значение исключения (ФИО, отдел, должность)")
+ parser.add_argument('-m', '--comment', type=str, default="", help="Комментарий к исключению")
+ parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота для инспекции")
+ parser.add_argument('--export-xlsx', type=str, default=None, help="Экспорт среза СКУД в Excel-файл")
+ parser.add_argument('--day', type=str, default=None, help="Удалить снапшоты за конкретный день (ДД.ММ.ГГГГ)")
+ parser.add_argument('--limit', type=int, default=50, help="Лимит выводимых сообщений чата (для команды context)")
+ parser.add_argument('--all', action='store_true', help="Полная очистка всех сообщений (для команды context purge)")
+
+ if len(sys.argv) == 1:
+ print_stats()
+ return
+
+ args = parser.parse_args()
+
+ if args.command == 'stats':
+ print_stats()
+ elif args.command == 'snapshots':
+ date_val = args.action or args.param
+ print_snapshots_list(date_str=date_val)
+ elif args.command == 'scud':
+ date_val = args.action or args.param
+ inspect_scud(snapshot_id=args.snapshot, date_str=date_val, export_xlsx=args.export_xlsx)
+ elif args.command == 'absences':
+ date_val = args.action or args.param
+ print_absences(date_str=date_val)
+ elif args.command == 'anomalies':
+ print_anomalies()
+ elif args.command == 'rules':
+ print_rules()
+ elif args.command == 'prompts':
+ print_system_prompts()
+ elif args.command == 'sessions':
+ print_session_states()
+ elif args.command == 'tools':
+ print_tool_actions()
+ elif args.command == 'context':
+ if args.action in ['purge', 'clear']:
+ is_all = args.all or (args.param == '--all')
+ sess_id = None if (args.param == '--all' or not args.param) else args.param
+ purge_chat_context(session_id=sess_id, purge_all=is_all)
+ else:
+ sess_id = args.action if args.action else None
+ print_chat_messages(session_id=sess_id, limit=args.limit)
+ elif args.command == 'dump':
+ filename = args.action or args.param or "db_dump_full.xlsx"
+ dump_all_to_excel(filename)
+ elif args.command == 'snapshot':
+ if args.action == 'del':
+ if args.day:
+ delete_snapshots_by_date(args.day)
+ elif args.param:
+ delete_snapshot_by_id(args.param)
+ else:
+ print("\n[❌] Ошибка: Не указан ID снапшота или параметр --day для удаления.")
+ print("Пример: python scripts/db_cli.py snapshot del Y20260805-007\n")
+ else:
+ print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.")
+ print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n")
+ elif args.command == 'exceptions':
+ if args.action == 'add':
+ if not args.category or not args.value:
+ print("\n[❌] Ошибка: Для добавления исключения укажите флаги -c/--category и -v/--value")
+ print("Пример: python scripts/db_cli.py exceptions add -c include_fio -v \"Тарасенко Александр Александрович\"\n")
+ return
+ if add_exception_to_db(args.category, args.value, args.comment):
+ print(f"\n[✓] Успешно добавлено исключение: [{args.category}] {args.value}\n")
+ else:
+ print(f"\n[❌] Ошибка добавления исключения [{args.category}] {args.value}\n")
+ elif args.action in ['del', 'delete', 'remove']:
+ if not args.category or not args.value:
+ print("\n[❌] Ошибка: Для удаления исключения укажите флаги -c/--category и -v/--value")
+ print("Пример: python scripts/db_cli.py exceptions del -c fio -v \"Михалев Сергей Геннадьевич\"\n")
+ return
+ if remove_exception_from_db(args.category, args.value):
+ print(f"\n[✓] Успешно удалено исключение: [{args.category}] {args.value}\n")
+ else:
+ print(f"\n[⚠️] Запись не найдена в базе: [{args.category}] {args.value}\n")
+ elif args.action == 'sync':
+ sync_json_to_db()
+ print("\n[✓] Синхронизация exceptions.json -> SQLite успешно завершена.\n")
+ else:
+ print_exceptions()
+ else:
+ print("\n[❌] Ошибка: Неизвестная команда.")
+ print(HELP_TEXT)
+
+
+if __name__ == "__main__":
+ main()
+```
+
+## File: `./core/connection.py`
+```py
+"""
+===============================================================================
+FILE: core/connection.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+ROLE: Единый менеджер подключений к базе данных SQLite (WAL mode, timeouts).
+===============================================================================
+"""
+
+import os
+import sqlite3
+from config import DATA_DIR
+
+DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db")
+
+
+def get_connection(row_factory: bool = False) -> sqlite3.Connection:
+ """
+ Создает оптимизированное подключение к SQLite.
+ row_factory=True возвращает sqlite3.Row для доступа к полям по имени.
+ """
+ conn = sqlite3.connect(DB_PATH, timeout=30.0)
+ if row_factory:
+ conn.row_factory = sqlite3.Row
+ conn.execute("PRAGMA foreign_keys = ON;")
+ conn.execute("PRAGMA journal_mode = WAL;")
+ conn.execute("PRAGMA synchronous = NORMAL;")
+ return conn
+```
+
+## File: `./core/database.py`
+```py
+"""
+===============================================================================
+FILE: core/database.py
+ROLE: Фасад ядра базы данных с полной обратной совместимостью импортов.
+===============================================================================
+"""
+
+from core.connection import get_connection, DB_PATH
+from core.schema import init_all_tables
+
+from core.repositories.scud_repo import (
+ has_scud_logs_for_date,
+ has_yesterday_final_snapshot,
+ get_or_create_snapshot_id,
+ save_scud_to_db,
+ get_latest_snapshot_time,
+ load_scud_from_db_by_snapshot,
+ get_available_snapshots,
+ delete_snapshot_by_id,
+ delete_snapshots_by_date
+)
+
+from core.repositories.zup_repo import (
+ save_staff_to_db,
+ load_staff_from_db,
+ save_absences_to_db,
+ load_absences_from_db,
+ save_anomalies_to_db,
+ get_all_rules_from_db,
+ add_rule_to_db,
+ get_department_synonyms_dict,
+ add_department_synonym_to_db
+)
+
+init_db = init_all_tables
+```
+
+## File: `./core/schema.py`
+```py
+"""
+===============================================================================
+FILE: core/schema.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+ROLE: DDL-схемы таблиц, создание индексов и инициализация базы данных.
+===============================================================================
+"""
+
+import logging
+from core.connection import get_connection
+
+logger = logging.getLogger("DB_SCHEMA")
+
+
+def init_all_tables() -> None:
+ """Инициализирует все таблицы и индексы системы."""
+ with get_connection() as conn:
+ cursor = conn.cursor()
+
+ # 1. Логи СКУД
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS scud_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ log_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ fio_clean TEXT NOT NULL,
+ department TEXT,
+ position TEXT,
+ time_in TEXT,
+ first_activity TEXT DEFAULT '—',
+ time_out TEXT,
+ time_in_building TEXT,
+ is_present INTEGER NOT NULL,
+ anomaly_flag TEXT DEFAULT 'NONE',
+ snapshot_time TEXT DEFAULT NULL,
+ snapshot_id TEXT DEFAULT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 2. Кадровые реестры 1С
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS zup_staff (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ snapshot_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ fio_clean TEXT NOT NULL,
+ department TEXT,
+ position TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS zup_absences (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ absence_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ fio_clean TEXT NOT NULL,
+ absence_type TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 3. Аномалии и база знаний
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS anomalies_history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ anomaly_date TEXT NOT NULL,
+ fio TEXT NOT NULL,
+ anomaly_type TEXT NOT NULL,
+ details TEXT NOT NULL,
+ human_status TEXT DEFAULT 'Pending',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS ai_knowledge_base (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ rule_text TEXT UNIQUE NOT NULL,
+ added_by TEXT DEFAULT 'Human',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS department_synonyms (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ short_name TEXT UNIQUE NOT NULL,
+ full_name TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 4. Узлы системного промпта и сессии
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS system_prompt_nodes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ prompt_name TEXT DEFAULT 'main_agent',
+ section_id INTEGER NOT NULL,
+ item_id INTEGER NOT NULL,
+ content TEXT NOT NULL,
+ is_active INTEGER DEFAULT 1,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(prompt_name, section_id, item_id)
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS session_states (
+ session_id TEXT PRIMARY KEY,
+ state_type TEXT NOT NULL,
+ pending_data TEXT,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS chat_messages (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT NOT NULL,
+ is_ephemeral INTEGER DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS exceptions_registry (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL, -- 'department', 'position', 'fio', 'position_keyword', 'include_fio', 'remote_worker'
+ value TEXT NOT NULL,
+ comment TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(category, value)
+ );
+ """)
+
+ # 5. Задачи
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id TEXT,
+ module TEXT DEFAULT 'general',
+ title TEXT NOT NULL,
+ priority TEXT DEFAULT 'MEDIUM',
+ status TEXT DEFAULT 'BACKLOG',
+ due_date TEXT,
+ user_id INTEGER DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+ """)
+
+ # 6. Индексы
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_date ON scud_logs(log_date);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_fio ON scud_logs(fio_clean);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_scud_snapshot ON scud_logs(snapshot_time);")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
+
+ conn.commit()
+```
+
+## File: `./core/repositories/scud_repo.py`
+```py
+"""
+===============================================================================
+FILE: core/repositories/scud_repo.py
+ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов.
+===============================================================================
+"""
+
+from datetime import datetime
+import pandas as pd
+from core.connection import get_connection
+
+
+def has_scud_logs_for_date(date_str: str) -> bool:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT 1 FROM scud_logs WHERE log_date = ? LIMIT 1", (date_str,))
+ return cursor.fetchone() is not None
+
+
+def has_yesterday_final_snapshot(date_str: str) -> bool:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT 1 FROM scud_logs WHERE log_date = ? AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '%22:00:00') LIMIT 1",
+ (date_str,)
+ )
+ return cursor.fetchone() is not None
+
+
+def get_or_create_snapshot_id(snapshot_time: str, date_str: str = None, is_yesterday: bool = False) -> str:
+ try:
+ dt_snap = datetime.strptime(snapshot_time, "%Y-%m-%d %H:%M:%S").date()
+ date_prefix = dt_snap.strftime("%Y%m%d")
+ except (ValueError, TypeError):
+ dt_snap = datetime.now().date()
+ date_prefix = dt_snap.strftime("%Y%m%d")
+
+ if date_str:
+ try:
+ dt_log = datetime.strptime(date_str, "%d.%m.%Y").date()
+ if dt_log < dt_snap:
+ is_yesterday = True
+ except Exception:
+ pass
+
+ prefix = "Y" if is_yesterday else ""
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ if date_str:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
+ (date_str, snapshot_time)
+ )
+ else:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE snapshot_time = ? AND snapshot_id IS NOT NULL LIMIT 1",
+ (snapshot_time,)
+ )
+
+ row = cursor.fetchone()
+ if row and row[0]:
+ return row[0]
+
+ cursor.execute("""
+ SELECT snapshot_id FROM scud_logs
+ WHERE snapshot_id LIKE ? OR snapshot_id LIKE ?
+ ORDER BY snapshot_id DESC LIMIT 1
+ """, (f"{date_prefix}-%", f"Y{date_prefix}-%"))
+
+ last_row = cursor.fetchone()
+ next_seq = 1
+ if last_row and last_row[0]:
+ parts = last_row[0].replace("Y", "").split("-")
+ if len(parts) > 1 and parts[1].isdigit():
+ next_seq = int(parts[1]) + 1
+
+ return f"{prefix}{date_prefix}-{next_seq:03d}"
+
+
+def save_scud_to_db(df_scud: pd.DataFrame, date_str: str, snapshot_time: str = None, is_yesterday: bool = False) -> None:
+ if df_scud is None or df_scud.empty:
+ return
+
+ if not snapshot_time:
+ snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday)
+
+ data_to_insert = [
+ (
+ date_str,
+ r.get('Сотрудник', r.get('fio_raw', '')),
+ r.get('fio_clean', ''),
+ r.get('Подразделение', ''),
+ r.get('Должность', ''),
+ r.get('Начало_дня', 'Нет входа'),
+ r.get('Первая_активность', '—'),
+ r.get('Конец_дня', 'Нет выхода'),
+ r.get('Находился_в_здании', '00:00'),
+ 1 if r.get('Пришел', False) else 0,
+ r.get('anomaly_flag', 'NONE'),
+ snapshot_time,
+ snapshot_id
+ )
+ for _, r in df_scud.iterrows()
+ ]
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE log_date = ? AND snapshot_time = ?", (date_str, snapshot_time))
+ cursor.executemany("""
+ INSERT INTO scud_logs (
+ log_date, fio, fio_clean, department, position,
+ time_in, first_activity, time_out, time_in_building,
+ is_present, anomaly_flag, snapshot_time, snapshot_id
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, data_to_insert)
+ conn.commit()
+
+
+def get_latest_snapshot_time(date_str: str = None):
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ if date_str:
+ cursor.execute("SELECT snapshot_time FROM scud_logs WHERE log_date = ? AND snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1", (date_str,))
+ else:
+ cursor.execute("SELECT snapshot_time FROM scud_logs WHERE snapshot_time IS NOT NULL ORDER BY snapshot_time DESC LIMIT 1")
+ row = cursor.fetchone()
+ return row[0] if row else None
+
+
+def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame:
+ with get_connection() as conn:
+ df = pd.DataFrame()
+
+ # 1. Если передан конкретный ID снапшота (например 'Y20260820-004')
+ if snapshot_param:
+ df = pd.read_sql_query(
+ "SELECT * FROM scud_logs WHERE snapshot_id = ?",
+ conn, params=(str(snapshot_param),)
+ )
+
+ # 2. Если ищем за дату (для вчерашнего дня строго ищем Y-снапшот)
+ if df.empty and date_str:
+ cursor = conn.cursor()
+
+ # ⭐️ Жесткий приоритет 1: Ищем снапшот с префиксом 'Y'
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY snapshot_time DESC, id DESC LIMIT 1",
+ (date_str,)
+ )
+ row = cursor.fetchone()
+
+ # Приоритет 2: Если Y нет (например, за сегодня), берем самый свежий по времени
+ if not row:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY snapshot_time DESC, id DESC LIMIT 1",
+ (date_str,)
+ )
+ row = cursor.fetchone()
+
+ if row and row[0]:
+ target_id = row[0]
+ df = pd.read_sql_query(
+ "SELECT * FROM scud_logs WHERE snapshot_id = ?",
+ conn, params=(target_id,)
+ )
+
+ if not df.empty:
+ rename_map = {
+ 'department': 'department_scud',
+ 'position': 'Должность',
+ 'fio': 'Сотрудник',
+ 'time_in': 'Начало_дня',
+ 'first_activity': 'Первая_активность',
+ 'time_out': 'Конец_дня',
+ 'time_in_building': 'Находился_в_здании',
+ 'is_present': 'Пришел'
+ }
+ df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
+ if 'department_scud' in df.columns and 'Подразделение' not in df.columns:
+ df['Подразделение'] = df['department_scud']
+
+ for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']:
+ if col not in df.columns:
+ df[col] = False if col == 'Пришел' else '—'
+ if 'Пришел' in df.columns:
+ df['Пришел'] = df['Пришел'].astype(bool)
+
+ return df
+
+
+def get_available_snapshots(date_str: str = None):
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ query = """
+ SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as cnt
+ FROM scud_logs
+ WHERE snapshot_time IS NOT NULL
+ """
+ params = []
+ if date_str:
+ query += " AND log_date = ?"
+ params.append(date_str)
+ query += " GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC"
+ cursor.execute(query, params)
+ return cursor.fetchall()
+
+
+def delete_snapshot_by_id(snapshot_id: str) -> int:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
+ cnt = cursor.rowcount
+ conn.commit()
+ return cnt
+
+
+def delete_snapshots_by_date(date_str: str) -> int:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%"))
+ cnt = cursor.rowcount
+ conn.commit()
+ return cnt
+```
+
+## File: `./core/repositories/zup_repo.py`
+```py
+"""
+===============================================================================
+FILE: core/repositories/zup_repo.py
+ROLE: Репозиторий кадровых данных 1С:ЗУП, аномалий и базы знаний.
+===============================================================================
+"""
+
+import pandas as pd
+from typing import List, Dict, Any
+from core.connection import get_connection
+
+
+def save_staff_to_db(df_staff: pd.DataFrame, date_str: str) -> None:
+ if df_staff is None or df_staff.empty:
+ return
+ data = [
+ (date_str, r.get('ФИО', ''), r.get('fio_clean', ''), r.get('Подразделение', ''), r.get('Должность', ''))
+ for _, r in df_staff.iterrows()
+ ]
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM zup_staff WHERE snapshot_date = ?", (date_str,))
+ cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data)
+ conn.commit()
+
+
+def load_staff_from_db(date_str: str) -> pd.DataFrame:
+ with get_connection() as conn:
+ df = pd.read_sql_query(
+ "SELECT fio as 'ФИО', fio_clean, department as 'Подразделение', position as 'Должность' FROM zup_staff WHERE snapshot_date = ?",
+ conn, params=(date_str,)
+ )
+ return df if not df.empty else None
+
+
+def save_absences_to_db(df_absent: pd.DataFrame, date_str: str) -> None:
+ if df_absent is None or df_absent.empty:
+ return
+ data = [
+ (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', ''))
+ for _, r in df_absent.iterrows()
+ ]
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,))
+ cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data)
+ conn.commit()
+
+
+def load_absences_from_db(date_str: str) -> pd.DataFrame:
+ with get_connection() as conn:
+ df = pd.read_sql_query(
+ "SELECT fio as 'ФИО', fio_clean, absence_type as 'Вид_отсутствия' FROM zup_absences WHERE absence_date = ?",
+ conn, params=(date_str,)
+ )
+ return df if not df.empty else None
+
+
+def save_anomalies_to_db(anomalies_list: List[Dict[str, Any]], date_str: str) -> None:
+ if not anomalies_list:
+ return
+ data = [(date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) for a in anomalies_list]
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,))
+ cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data)
+ conn.commit()
+
+
+def get_all_rules_from_db() -> List[str]:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT rule_text FROM ai_knowledge_base")
+ return [r[0] for r in cursor.fetchall()]
+
+
+def add_rule_to_db(rule_text: str, added_by: str = "Human") -> None:
+ if not rule_text or not rule_text.strip():
+ return
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by))
+ conn.commit()
+
+
+def get_department_synonyms_dict() -> Dict[str, str]:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms")
+ return {row[0]: row[1] for row in cursor.fetchall()}
+
+
+def add_department_synonym_to_db(short_name: str, full_name: str) -> None:
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", (short_name.strip().lower(), full_name.strip().lower()))
+ conn.commit()
+```
+
+## File: `./services/data_loader.py`
+```py
+"""
+===============================================================================
+FILE: services/data_loader.py
+ROLE: Надежная загрузка штата и отсутствий (MS SQL ЗУП -> Резервный Excel).
+===============================================================================
+"""
+
+import os
+import warnings
+import pandas as pd
+from config import (
+ normalize_fio, clean_scud_fio_light, load_exceptions,
+ DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR, DATA_DIR
+)
+from services.zup_extractor import fetch_zup_absences_from_sql
+from core.database import (
+ load_scud_from_db_by_snapshot,
+ load_staff_from_db,
+ load_absences_from_db
+)
+
+warnings.filterwarnings('ignore', category=UserWarning, module='pandas')
+
+
+def load_staff_data(date_str):
+ """Загружает файл Штатного расписания 1С."""
+ filepath = find_dated_file("Штат", date_str)
+ if not filepath:
+ fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx")
+ if os.path.exists(fallback_path):
+ filepath = fallback_path
+ else:
+ return None
+
+ try:
+ df_raw = pd.read_excel(filepath, skiprows=8)
+ df_staff = df_raw.iloc[:, [1, 5, 12]].copy()
+ df_staff.columns = ['ФИО', 'Подразделение', 'Должность']
+
+ df_staff = df_staff.dropna(subset=['ФИО']).reset_index(drop=True)
+ df_staff = df_staff[~df_staff['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО', case=False, na=False)]
+ df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio)
+ return df_staff
+ except Exception as e:
+ print(f"[❌] Ошибка загрузки штата из {filepath}: {e}")
+ return None
+
+
+def load_absent_from_excel(date_str):
+ """Резервное чтение файла Отсутствия_ДД_ММ_ГГГГ.xlsx."""
+ filepath = find_dated_file("Отсутствия", date_str)
+ if not filepath:
+ return None
+
+ try:
+ for skip in [0, 1, 2, 3, 4, 5, 8]:
+ df_try = pd.read_excel(filepath, skiprows=skip)
+ fio_col = None
+ reason_col = None
+ for col in df_try.columns:
+ c_str = str(col).lower()
+ if ('фио' in c_str or 'сотрудник' in c_str) and fio_col is None:
+ fio_col = col
+ if ('вид' in c_str or 'причина' in c_str or 'отсутств' in c_str) and reason_col is None:
+ reason_col = col
+
+ if fio_col and reason_col:
+ df_res = df_try[[fio_col, reason_col]].copy()
+ df_res.columns = ['ФИО', 'Вид_отсутствия']
+ df_res = df_res.dropna(subset=['ФИО', 'Вид_отсутствия'])
+ df_res = df_res[~df_res['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО|ЛЕНМОРНИИПРОЕКТ', case=False, na=False)]
+ df_res['fio_clean'] = df_res['ФИО'].apply(normalize_fio)
+ print(f" [✓] Резервный Excel: загружено {len(df_res)} записей отсутствий из {os.path.basename(filepath)}")
+ return df_res[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
+ except Exception as e:
+ print(f" [⚠️] Ошибка чтения резервного Excel отсутствий {filepath}: {e}")
+
+ return None
+
+
+def load_absent_data(date_str):
+ """
+ Загружает отсутствия из MS SQL 1С:ЗУП, а при сбое — из локального Excel.
+ """
+ df_absent = None
+ try:
+ df_absent = fetch_zup_absences_from_sql(date_str)
+ if df_absent is not None and not df_absent.empty:
+ print(f" [✓] MS SQL ЗУП: получено {len(df_absent)} записей отсутствий за {date_str}")
+ df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия'])
+ df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)]
+ df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio)
+ df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean'])
+ except Exception as e:
+ print(f" [⚠️] Ошибка подключения к MS SQL ЗУП за {date_str}: {e}")
+
+ # Резервный источник: Excel файл из 1С с сетевой шары
+ if df_absent is None or df_absent.empty:
+ df_absent = load_absent_from_excel(date_str)
+
+ if df_absent is None:
+ df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия'])
+
+ # Обогащение удаленщиками из static_reason_workers.csv
+ try:
+ static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
+ if os.path.exists(static_path):
+ df_static = pd.read_csv(static_path, encoding='utf-8')
+ if 'fio' in df_static.columns and 'reason' in df_static.columns:
+ df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
+ existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set()
+
+ new_rows = []
+ for _, s_row in df_static.iterrows():
+ if s_row['fio_clean'] not in existing_fios:
+ new_rows.append({
+ 'fio_clean': s_row['fio_clean'],
+ 'Вид_отсутствия': s_row['reason']
+ })
+ if new_rows:
+ df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True)
+ print(f" [✓] Реестр удаленщиков: добавлено {len(new_rows)} чел. из static_reason_workers.csv")
+ except Exception as e:
+ print(f" [⚠️] Ошибка чтения static_reason_workers.csv: {e}")
+
+ return df_absent if not df_absent.empty else None
+
+
+def load_1c_data_smart(date_str, use_db=False):
+ df_staff = None
+ df_absent = None
+
+ if use_db:
+ df_staff = load_staff_from_db(date_str)
+ df_absent = load_absences_from_db(date_str)
+
+ if df_staff is None:
+ df_staff = load_staff_data(date_str)
+ if df_absent is None:
+ df_absent = load_absent_data(date_str)
+
+ return df_staff, df_absent
+```
+
+## File: `./services/scud_export.py`
+```py
+"""
+Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel.
+Добавлена фиксация Первой Активности (без учета направления) и среза по времени.
+"""
+
+import argparse
+import logging
+import os
+import sys
+import warnings
+from datetime import datetime, timedelta
+
+import pandas as pd
+import pyodbc
+from openpyxl.utils import get_column_letter
+
+from config import SCUD_DIR, clean_scud_fio_light, load_exceptions
+from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot
+
+warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable")
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs")
+os.makedirs(LOG_DIR, exist_ok=True)
+
+TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y")
+LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log")
+
+logger = logging.getLogger("scud_export")
+logger.setLevel(logging.INFO)
+logger.propagate = False
+
+if not logger.handlers:
+ _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
+ _console_handler = logging.StreamHandler(sys.stdout)
+ _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
+ _file_handler.setFormatter(_formatter)
+ _console_handler.setFormatter(_formatter)
+ logger.addHandler(_file_handler)
+ logger.addHandler(_console_handler)
+
+
+def log(message: str, level: str = "INFO"):
+ level_map = {
+ "INFO": logging.INFO,
+ "ERROR": logging.ERROR,
+ "WARNING": logging.WARNING,
+ "SUCCESS": logging.INFO,
+ }
+ if level == "SUCCESS":
+ logger.info(f"[SUCCESS] {message}")
+ else:
+ logger.log(level_map.get(level, logging.INFO), message)
+
+
+SERVER_NAME = r"172.16.200.147\SQL"
+DATABASE_NAME = "Orion-14.01.21-1"
+SQL_USER = "sa"
+SQL_PASSWORD = "123456"
+ODBC_DRIVER = "ODBC Driver 18 for SQL Server"
+
+SQL_QUERY_TEMPLATE = r"""
+DECLARE @InputDate DATE = '{target_date}';
+DECLARE @TargetDate DATE = @InputDate;
+
+DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME);
+DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate));
+
+WITH DailyLogs AS (
+ SELECT
+ log.HozOrgan AS EmployeeID,
+ log.TimeVal,
+ log.Event,
+ log.Mode,
+ CASE
+ WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT'
+ WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN'
+ ELSE 'OTHER'
+ END AS Direction,
+ ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc
+ FROM pLogData log WITH (NOLOCK)
+ WHERE log.TimeVal BETWEEN @StartDate AND @EndDate
+ AND log.HozOrgan IS NOT NULL
+ AND log.HozOrgan > 0
+ AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65)
+),
+Passages AS (
+ SELECT
+ EmployeeID,
+ MIN(TimeVal) AS FirstRawEvent,
+ MAX(TimeVal) AS LastRawEvent,
+ MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn,
+ MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut,
+ MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType
+ FROM DailyLogs
+ GROUP BY EmployeeID
+)
+SELECT
+ N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма],
+ ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение],
+ LTRIM(RTRIM(
+ ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') +
+ CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> ''
+ THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END +
+ CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> ''
+ THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END
+ )) AS [Сотрудник],
+ ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность],
+ ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№],
+ CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата],
+ ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня],
+ CASE
+ WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL
+ THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20))
+ ELSE N'—'
+ END AS [Первая_активность],
+ CASE
+ WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn)
+ THEN N'Нет выхода'
+ WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn
+ THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20))
+ WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent)
+ THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20))
+ ELSE N'Нет выхода'
+ END AS [Конец_дня],
+ CASE
+ WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN
+ RIGHT('0' + CAST(DATEDIFF(MINUTE,
+ ISNULL(pass.FirstIn, pass.FirstRawEvent),
+ CASE
+ WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
+ ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
+ END) / 60 AS VARCHAR), 2) + ':' +
+ RIGHT('0' + CAST(DATEDIFF(MINUTE,
+ ISNULL(pass.FirstIn, pass.FirstRawEvent),
+ CASE
+ WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE()
+ ELSE ISNULL(pass.LastOut, pass.LastRawEvent)
+ END) % 60 AS VARCHAR), 2)
+ ELSE N'00:00'
+ END AS [Находился_в_здании],
+ CASE
+ WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал'
+ ELSE N'Отсутствовал (Нет событий)'
+ END AS [Статус]
+FROM pList p WITH (NOLOCK)
+LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID
+LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID
+LEFT JOIN Passages pass ON p.ID = pass.EmployeeID
+WHERE
+ ISNULL(p.StatusRecord, 0) = 0
+ AND p.DateTimeInArchive IS NULL
+ AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%'
+ AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'')
+ AND p.Name NOT LIKE N'бр.%'
+ AND p.Name NOT LIKE N'Гость%'
+ AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР')
+ AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%'
+ AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%'
+ AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%'
+ AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%'
+ AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%'
+ORDER BY p.Name ASC;
+"""
+
+
+def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0):
+ from openpyxl import load_workbook
+
+ wb = load_workbook(file_path)
+ ws = wb[sheet_name]
+
+ for col_cells in ws.columns:
+ max_len = 0
+ col_letter = get_column_letter(col_cells[0].column)
+ for cell in col_cells:
+ if cell.value is not None:
+ cell_len = len(str(cell.value))
+ if cell_len > max_len:
+ max_len = cell_len
+ width = max(min_width, min(max_len + padding, max_width))
+ ws.column_dimensions[col_letter].width = width
+
+ wb.save(file_path)
+
+
+def get_targets(input_date: str | None):
+ targets = []
+ if input_date:
+ try:
+ parsed = datetime.strptime(input_date, "%d.%m.%Y").date()
+ targets.append({"name": "Указанная дата", "date": parsed})
+ except ValueError:
+ log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR")
+ sys.exit(1)
+ else:
+ now = datetime.now()
+ yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date()
+ today = now.date()
+
+ targets.append({"name": "Вчера", "date": yesterday})
+ targets.append({"name": "Сегодня", "date": today})
+
+ return targets
+
+
+def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False):
+ if debug:
+ logger.setLevel(logging.DEBUG)
+ log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING")
+
+ log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===")
+ os.makedirs(SCUD_DIR, exist_ok=True)
+
+ targets = get_targets(input_date)
+ conn_str = (
+ f"DRIVER={{{ODBC_DRIVER}}};"
+ f"SERVER={SERVER_NAME};"
+ f"DATABASE={DATABASE_NAME};"
+ f"UID={SQL_USER};"
+ f"PWD={SQL_PASSWORD};"
+ f"TrustServerCertificate=yes;"
+ f"Encrypt=no;"
+ )
+
+ success = True
+ for target in targets:
+ processing_date = target["date"]
+ processing_date_str = processing_date.strftime("%d.%m.%Y")
+ period_label = target["name"]
+ is_yesterday = (period_label == "Вчера")
+
+ # ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE:
+ # Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL
+ if is_yesterday and has_yesterday_final_snapshot(processing_date_str):
+ log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.")
+ continue
+
+ if is_yesterday:
+ snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00"
+ else:
+ snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]")
+ sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d"))
+
+ connection = None
+ try:
+ connection = pyodbc.connect(conn_str, timeout=120)
+ df = pd.read_sql(sql_query, connection)
+
+ log(f"База вернула {len(df)} строк за {processing_date_str}")
+
+ if len(df) > 0:
+ df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light)
+
+ df['anomaly_flag'] = 'NONE'
+ mask_anomaly = (df['Начало_дня'] == 'Нет входа') & (df['Первая_активность'] != '—')
+ df.loc[mask_anomaly, 'anomaly_flag'] = 'ANOMALY_NO_IN_HAS_ACTIVITY'
+
+ df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly)
+
+ save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday)
+ log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS")
+
+ if save_xlsx:
+ file_name = f"Сотрудники_{processing_date_str}.xlsx"
+ file_path = os.path.join(SCUD_DIR, file_name)
+
+ if os.path.exists(file_path):
+ try:
+ os.remove(file_path)
+ except OSError as e:
+ log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR")
+
+ df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl")
+ auto_fit_columns(file_path, sheet_name="Отчет")
+ log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS")
+ else:
+ log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING")
+
+ except Exception as e:
+ log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR")
+ success = False
+ finally:
+ if connection is not None:
+ connection.close()
+
+ log("=== Выгрузка СКУД завершена ===\n")
+ return success
+
+
+if __name__ == "__main__":
+ help_text = """
+Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel.
+
+ПРИМЕРЫ ЗАПУСКА:
+ python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера
+ python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату
+ python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX
+ python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG)
+"""
+ parser = argparse.ArgumentParser(
+ description=help_text,
+ formatter_class=argparse.RawDescriptionHelpFormatter
+ )
+ parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)")
+ parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL")
+ parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/")
+ args = parser.parse_args()
+
+ run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug)
+```
+
+## File: `./services/share_copier.py`
+```py
+import os
+import shutil
+from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR
+
+
+def find_file_strictly_on_share(prefix, date_str):
+ """
+ Ищет файл со строгой привязкой ТОЛЬКО к сетевой шаре SHARE_1C_DIR,
+ не обращаясь к локальным папкам.
+ """
+ if not os.path.exists(SHARE_1C_DIR):
+ return None
+
+ date_dots = date_str
+ date_underscores = date_str.replace('.', '_')
+
+ try:
+ for f in os.listdir(SHARE_1C_DIR):
+ if f.endswith('.xlsx') or f.endswith('.csv'):
+ if f.lower().startswith(prefix.lower()):
+ if date_dots in f or date_underscores in f:
+ return os.path.join(SHARE_1C_DIR, f)
+ except Exception as e:
+ print(f" [⚠️] Ошибка чтения сетевой шары: {e}")
+ return None
+
+
+def copy_1c_files_from_share():
+ r"""
+ Копирует свежие файлы 1С (Штат и Отсутствия) за Сегодня и Вчера
+ из сетевой шары \\storage\SCUD\Обмен\Штат в локальную папку data/1c/
+ """
+ print(f"[0.5/5] Проверка и копирование файлов 1С с шары: {SHARE_1C_DIR}...")
+
+ if not os.path.exists(SHARE_1C_DIR):
+ print(f"[⚠️] Сетевой шар недоступен или путь не найден: {SHARE_1C_DIR}")
+ print(" Используем ранее сохраненные локальные файлы из data/1c/\n")
+ return False
+
+ dates_to_copy = [DATE_TODAY, DATE_YESTERDAY]
+ prefixes = ["Штат", "Отсутствия"]
+ copied_count = 0
+
+ os.makedirs(ZUP_1C_DIR, exist_ok=True)
+
+ for d_str in dates_to_copy:
+ for prefix in prefixes:
+ remote_file = find_file_strictly_on_share(prefix, d_str)
+
+ if remote_file and os.path.exists(remote_file):
+ filename = os.path.basename(remote_file)
+ local_target_path = os.path.join(ZUP_1C_DIR, filename)
+
+ try:
+ shutil.copy2(remote_file, local_target_path)
+ print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/")
+ copied_count += 1
+ except Exception as e:
+ print(f" [⚠️] Ошибка копирования {filename}: {e}")
+ else:
+ d_fmt = d_str.replace('.', '_')
+ print(f" [ℹ️] На сетевой шаре отсутствует {prefix} за {d_str} ({prefix}_{d_fmt}.xlsx)")
+
+ if copied_count > 0:
+ print(f"[✓] Успешно скопировано файлов с сетевой шары: {copied_count} шт.\n")
+ else:
+ print("[ℹ️] Новых файлов за указанные даты на сетевой шаре не обнаружено.\n")
+
+ return True
+```
+
+## File: `./services/excel_exporter.py`
+```py
+import math
+import os
+import time
+import openpyxl
+import pandas as pd
+from datetime import datetime
+from openpyxl import Workbook
+from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
+from openpyxl.utils import get_column_letter
+from config import REPORTS_DIR
+
+MONTHS_RU_GENITIVE = {
+ 1: "января", 2: "февраля", 3: "марта", 4: "апреля",
+ 5: "мая", 6: "июня", 7: "июля", 8: "августа",
+ 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря"
+}
+
+MONTHS_RU_NOMINATIVE = {
+ 1: "январь", 2: "февраль", 3: "март", 4: "апрель",
+ 5: "май", 6: "июнь", 7: "июль", 8: "август",
+ 9: "сентябрь", 10: "октябрь", 11: "ноябрь", 12: "декабрь"
+}
+
+
+def format_date_ru(date_str):
+ try:
+ dt = datetime.strptime(date_str, "%d.%m.%Y")
+ return f"{dt.day} {MONTHS_RU_GENITIVE[dt.month]} {dt.year}"
+ except Exception:
+ return date_str
+
+
+def get_dated_reports_dir(date_str):
+ try:
+ dt = datetime.strptime(date_str, "%d.%m.%Y")
+ year_str = str(dt.year)
+ month_name = MONTHS_RU_NOMINATIVE[dt.month]
+ except Exception:
+ now = datetime.now()
+ year_str = str(now.year)
+ month_name = MONTHS_RU_NOMINATIVE[now.month]
+
+ target_dir = os.path.join(REPORTS_DIR, year_str, month_name)
+ os.makedirs(target_dir, exist_ok=True)
+ return target_dir
+
+
+THIN_SIDE = Side(border_style="thin", color="D3D3D3")
+THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE)
+
+FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
+FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
+FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
+FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
+FILL_REMOTE = PatternFill(start_color="E8F8F5", end_color="E8F8F5", fill_type="solid")
+FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
+FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid")
+FILL_EXCEPTIONS = PatternFill(start_color="EFEBE9", end_color="EFEBE9", fill_type="solid") # Мягкий бежево-серый для исключений
+
+CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "FCF3CF"]
+
+YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
+LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid")
+GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid")
+LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid")
+
+
+def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30):
+ if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "":
+ return "0:00"
+
+ if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']:
+ return f"-{norm_hours}:00"
+
+ try:
+ parts = time_in_building_str.strip().split(':')
+ hh = int(parts[0])
+ mm = int(parts[1]) if len(parts) > 1 else 0
+ total_in_building_minutes = hh * 60 + mm
+
+ if total_in_building_minutes == 0:
+ return f"-{norm_hours}:00"
+
+ work_minutes = max(0, total_in_building_minutes - lunch_minutes)
+ norm_minutes = norm_hours * 60
+ diff = work_minutes - norm_minutes
+
+ if diff == 0:
+ return "0:00"
+
+ sign = "-" if diff < 0 else ""
+ abs_diff = abs(diff)
+ res_hh = abs_diff // 60
+ res_mm = abs_diff % 60
+
+ return f"{sign}{res_hh}:{res_mm:02d}"
+ except Exception:
+ return f"-{norm_hours}:00"
+
+
+def apply_borders_to_cell(cell, border=THIN_BORDER):
+ cell.border = border
+
+
+def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False):
+ cell_a = ws.cell(row=r_num, column=1)
+ cell_b = ws.cell(row=r_num, column=2)
+ if fill_obj:
+ cell_a.fill = fill_obj
+ cell_b.fill = fill_obj
+ apply_borders_to_cell(cell_a)
+ apply_borders_to_cell(cell_b)
+ if is_bold and bold_font:
+ cell_a.font = bold_font
+ cell_b.font = bold_font
+ if align_b:
+ cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b)
+
+
+# --- 1. СВОДКА НА СЕГОДНЯ ---
+def generate_summary_excel(merged_df, date_str="21.08.2026", filename=None):
+ if not filename:
+ filename = f"{format_date_ru(date_str)} сводка.xlsx"
+
+ target_dir = get_dated_reports_dir(date_str)
+ output_path = os.path.join(target_dir, filename)
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Лист_1"
+
+ ws.sheet_properties.outlinePr.summaryBelow = False
+ ws.sheet_properties.outlinePr.summaryRight = False
+ ws.sheet_properties.outlinePr.showOutlineSymbols = True
+ ws.sheet_view.showOutlineSymbols = True
+
+ bold_font = Font(name="Calibri", size=11, bold=True)
+
+ # 1. Шапка
+ ws.cell(row=1, column=1, value="Сводка на")
+ ws.cell(row=1, column=2, value=date_str)
+ format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font)
+
+ apply_borders_to_cell(ws.cell(row=2, column=1))
+ apply_borders_to_cell(ws.cell(row=2, column=2))
+
+ # 2. По списку
+ ws.cell(row=3, column=1, value="По списку")
+ ws.cell(row=3, column=2, value=len(merged_df))
+ format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font)
+
+ current_row = 4
+
+ is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False
+ is_exc = merged_df.get('is_excluded', False) == True
+
+ # 3. НЕИЗВЕСТНО
+ unexplained = merged_df[
+ (merged_df['Пришел'] == False) &
+ (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) &
+ (~is_no_pass) &
+ (~is_exc)
+ ]
+ ws.cell(row=current_row, column=1, value="неизвестно")
+ ws.cell(row=current_row, column=2, value=len(unexplained))
+ format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font)
+ current_row += 1
+
+ for fio in sorted(unexplained['Сотрудник'].dropna().unique()):
+ ws.cell(row=current_row, column=1, value=fio)
+ format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False)
+ ws.row_dimensions[current_row].outlineLevel = 1
+ ws.row_dimensions[current_row].hidden = False
+ current_row += 1
+
+ # 4. РАЗДЕЛ: НЕТ ПРОПУСКА
+ no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame()
+
+ ws.cell(row=current_row, column=1, value="Нет пропуска")
+ ws.cell(row=current_row, column=2, value=len(no_pass_df))
+ format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font)
+ current_row += 1
+
+ if not no_pass_df.empty:
+ for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()):
+ ws.cell(row=current_row, column=1, value=fio)
+ format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False)
+ ws.row_dimensions[current_row].outlineLevel = 1
+ ws.row_dimensions[current_row].hidden = False
+ current_row += 1
+
+ # 5. КАТЕГОРИИ ОТСУТСТВИЙ (без удаленки и исключений)
+ reason_clean = merged_df['Вид_отсутствия'].astype(str).str.lower()
+ is_remote_reason = reason_clean.str.contains('удален|дистанцион', regex=True, na=False)
+
+ absent_only = merged_df[
+ (merged_df['Пришел'] == False) &
+ (merged_df['Вид_отсутствия'].notna()) &
+ (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
+ (~is_remote_reason) &
+ (~is_exc)
+ ]
+ absent_groups = absent_only.groupby('Вид_отсутствия')
+
+ for idx_cat, (cat_name, group) in enumerate(absent_groups):
+ hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)]
+ cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid")
+
+ ws.cell(row=current_row, column=1, value=cat_name)
+ ws.cell(row=current_row, column=2, value=len(group))
+ format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font)
+ current_row += 1
+
+ for fio in sorted(group['Сотрудник'].dropna().unique()):
+ ws.cell(row=current_row, column=1, value=fio)
+ format_row_cells(ws, current_row, cat_fill, is_bold=False)
+ ws.row_dimensions[current_row].outlineLevel = 1
+ ws.row_dimensions[current_row].hidden = True
+ current_row += 1
+
+ # 6. ИТОГО НА РАБОТЕ (Строго физически пришедшие в офис по СКУД)
+ present = merged_df[(merged_df['Пришел'] == True) & (~is_exc)]
+
+ ws.cell(row=current_row, column=1, value="Итого на работе")
+ ws.cell(row=current_row, column=2, value=len(present))
+ format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font)
+ current_row += 1
+
+ if not present.empty:
+ for fio in sorted(present['Сотрудник'].dropna().unique()):
+ ws.cell(row=current_row, column=1, value=fio)
+ format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False)
+ ws.row_dimensions[current_row].outlineLevel = 1
+ ws.row_dimensions[current_row].hidden = True
+ current_row += 1
+
+ # 6.1. В ТОМ ЧИСЛЕ НА УДАЛЕННОЙ РАБОТЕ (Удаленщики из дома, которых нет в офисе)
+ remote_home = merged_df[(merged_df['Пришел'] == False) & is_remote_reason & (~is_exc)]
+
+ ws.cell(row=current_row, column=1, value="В том числе на удаленной работе")
+ ws.cell(row=current_row, column=2, value=len(remote_home))
+ format_row_cells(ws, current_row, FILL_REMOTE, is_bold=True, bold_font=bold_font)
+ current_row += 1
+
+ if not remote_home.empty:
+ for fio in sorted(remote_home['Сотрудник'].dropna().unique()):
+ ws.cell(row=current_row, column=1, value=fio)
+ format_row_cells(ws, current_row, FILL_REMOTE, is_bold=False)
+ ws.row_dimensions[current_row].outlineLevel = 1
+ ws.row_dimensions[current_row].hidden = True
+ current_row += 1
+
+ # 7. АНОМАЛИИ СКУД И 1С
+ anomalies = merged_df[
+ (~is_exc) & (
+ ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) &
+ (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) &
+ (~is_remote_reason) &
+ (~reason_clean.str.contains('командировк', na=False))) |
+ (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY')
+ )
+ ]
+
+ ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С")
+ ws.cell(row=current_row, column=2, value=len(anomalies))
+ format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font)
+ current_row += 1
+
+ chars_per_line_b = 30
+
+ if not anomalies.empty:
+ for _, row in anomalies.iterrows():
+ fio = row.get('Сотрудник', '')
+ reason = row.get('Вид_отсутствия', '')
+ anom_flag = row.get('anomaly_flag', 'NONE')
+
+ if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
+ first_act = row.get('Первая_активность', '—')
+ reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})"
+ else:
+ reason_text = f"В 1С: {reason}"
+
+ cell_a = ws.cell(row=current_row, column=1, value=f"{fio}")
+ cell_b = ws.cell(row=current_row, column=2, value=reason_text)
+
+ cell_a.fill = FILL_ANOMALY
+ cell_b.fill = FILL_ANOMALY
+ apply_borders_to_cell(cell_a)
+ apply_borders_to_cell(cell_b)
+
+ cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
+ cell_a.alignment = Alignment(horizontal="left", vertical="center")
+
+ if len(reason_text) > chars_per_line_b:
+ lines_count = math.ceil(len(reason_text) / chars_per_line_b)
+ ws.row_dimensions[current_row].height = max(lines_count * 18, 22)
+ else:
+ ws.row_dimensions[current_row].height = 20
+
+ ws.row_dimensions[current_row].outlineLevel = 1
+ ws.row_dimensions[current_row].hidden = True
+ current_row += 1
+
+ # ⭐️ 8. РАЗДЕЛ: ИСКЛЮЧЕНИЯ (В самом низу, раскрывающийся список)
+ exceptions_df = merged_df[is_exc]
+
+ ws.cell(row=current_row, column=1, value="Исключения")
+ ws.cell(row=current_row, column=2, value=len(exceptions_df))
+ format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=True, bold_font=bold_font)
+ current_row += 1
+
+ if not exceptions_df.empty:
+ for fio in sorted(exceptions_df['Сотрудник'].dropna().unique()):
+ ws.cell(row=current_row, column=1, value=fio)
+ format_row_cells(ws, current_row, FILL_EXCEPTIONS, is_bold=False)
+ ws.row_dimensions[current_row].outlineLevel = 1
+ ws.row_dimensions[current_row].hidden = True
+ current_row += 1
+
+ ws.column_dimensions['A'].width = 45.0
+ ws.column_dimensions['B'].width = 38.0
+
+ try:
+ wb.save(output_path)
+ print(f"[✓] Ежедневная сводка сохранена: {output_path}")
+ except PermissionError:
+ alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
+ alt_path = os.path.join(target_dir, alt_filename)
+ wb.save(alt_path)
+ print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
+
+
+# --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА ---
+def generate_detailed_excel(merged_df, date_str="20.08.2026", filename=None):
+ if not filename:
+ filename = f"{format_date_ru(date_str)} отчет.xlsx"
+
+ if merged_df is not None and not merged_df.empty:
+ df_export = merged_df[merged_df.get('is_excluded', False) == False].copy()
+ else:
+ df_export = pd.DataFrame()
+
+ target_dir = get_dated_reports_dir(date_str)
+ output_path = os.path.join(target_dir, filename)
+ wb = Workbook()
+ ws = wb.active
+ ws.title = "Детальный_отчет"
+
+ ws["B2"] = "Дата:"
+ ws["D2"] = date_str
+ ws["B2"].font = Font(name="Arial", size=10, bold=True)
+ ws["D2"].font = Font(name="Arial", size=10, bold=True)
+
+ headers = [
+ "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода",
+ "находился в здании", "причина отсутствия", "норма", "отклонение от нормы"
+ ]
+ ws.append([])
+ ws.append(headers)
+
+ header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
+ for col_idx in range(1, len(headers) + 1):
+ cell = ws.cell(row=4, column=col_idx)
+ cell.fill = header_fill
+ cell.font = Font(name="Arial", size=10, bold=True)
+ cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
+ apply_borders_to_cell(cell)
+
+ start_col = 'Начало дня' if 'Начало дня' in df_export.columns else 'Начало_дня'
+ end_col = 'Конец дня' if 'Конец дня' in df_export.columns else 'Конец_дня'
+ hours_col = 'Часы' if 'Часы' in df_export.columns else 'Находился_в_здании'
+
+ chars_per_line_h = 24
+
+ for idx, row in df_export.reset_index(drop=True).iterrows():
+ is_present = row.get('Пришел', False)
+ absence_reason = row.get('Вид_отсутствия', '')
+ has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != ''
+ anom_flag = row.get('anomaly_flag', 'NONE')
+
+ in_building_str = str(row.get(hours_col, '00:00'))
+ first_act_val = str(row.get('Первая_активность', '—')).strip()
+ has_first_act = first_act_val not in ['—', '', 'None', 'nan']
+
+ dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', '')))
+ deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30)
+
+ ws.append([
+ idx + 1,
+ row.get('Сотрудник', ''),
+ dept_scud_val,
+ row.get(start_col, 'Нет входа'),
+ first_act_val,
+ row.get(end_col, 'Нет выхода'),
+ in_building_str,
+ absence_reason if has_reason else '',
+ 8,
+ deviation_val
+ ])
+
+ row_num = 5 + idx
+
+ if is_present and has_reason:
+ row_fill = GREEN_FILL
+ elif not is_present and has_reason:
+ row_fill = YELLOW_FILL
+ elif not is_present and not has_reason and not has_first_act:
+ row_fill = LIGHT_RED_FILL
+ else:
+ row_fill = None
+
+ val_h_str = str(absence_reason) if has_reason else ""
+ if len(val_h_str) > chars_per_line_h:
+ needed_lines = math.ceil(len(val_h_str) / chars_per_line_h)
+ ws.row_dimensions[row_num].height = max(needed_lines * 18, 22)
+ else:
+ ws.row_dimensions[row_num].height = 20
+
+ for col_idx in range(1, len(headers) + 1):
+ cell = ws.cell(row=row_num, column=col_idx)
+ apply_borders_to_cell(cell)
+ if row_fill:
+ cell.fill = row_fill
+
+ if col_idx == 8:
+ cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
+ elif col_idx in [1, 4, 5, 6, 7, 9, 10]:
+ cell.alignment = Alignment(horizontal="center", vertical="center")
+ else:
+ cell.alignment = Alignment(horizontal="left", vertical="center")
+
+ for col in ws.columns:
+ col_letter = get_column_letter(col[0].column)
+ max_len = 0
+ for cell in col:
+ if cell.value is not None:
+ cell_lines = str(cell.value).split("\n")
+ line_max = max(len(line) for line in cell_lines)
+ if line_max > max_len:
+ max_len = line_max
+
+ optimal_width = max(max_len + 2, 8)
+ if optimal_width > 35:
+ optimal_width = 35
+ ws.column_dimensions[col_letter].width = optimal_width
+
+ try:
+ wb.save(output_path)
+ print(f"[✓] Детальный отчет сохранен: {output_path}")
+ except PermissionError:
+ alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx")
+ alt_path = os.path.join(target_dir, alt_filename)
+ wb.save(alt_path)
+ print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}")
+
+
+def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"):
+ output_path = os.path.join(REPORTS_DIR, filename)
+ df_scud.to_excel(output_path, index=False)
+```
+
+## File: `./services/text_reporter.py`
+```py
+import json
+import difflib
+import re
+from services.ai_verifier import ask_ollama
+from services.knowledge_base import load_knowledge_base
+
+
+def find_python_fuzzy_matches(unexplained_df, raw_absent_df):
+ if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty:
+ return []
+
+ absent_dict = {}
+ for idx, r in raw_absent_df.iterrows():
+ raw_fio = str(r.get('ФИО', ''))
+ clean_fio = str(r.get('fio_clean', ''))
+ reason = str(r.get('Вид_отсутствия', ''))
+ if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']:
+ absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason}
+
+ matches = []
+ unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else []
+
+ for fio in unexplained_fios:
+ fio_parts = fio.split()
+ if not fio_parts:
+ continue
+ surname = fio_parts[0].lower()
+
+ for abs_clean, abs_info in absent_dict.items():
+ abs_parts = abs_clean.split()
+ if not abs_parts:
+ continue
+ abs_surname = abs_parts[0].lower()
+
+ if surname == abs_surname:
+ ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio()
+ if ratio >= 0.75:
+ matches.append({
+ "target_fio": fio,
+ "found_in_1c_raw": abs_info['raw_fio'],
+ "reason_1c": abs_info['reason']
+ })
+ break
+
+ return matches
+
+
+def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"):
+ kb = load_knowledge_base()
+ custom_rules = kb.get("rules", [])
+ custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет."
+
+ total_staff = len(merged_df)
+
+ # 1. Пришедшие в офис
+ present_office_cnt = len(merged_df[merged_df['Пришел'] == True])
+
+ # 2. Не пришедшие удаленщики
+ reason_series = merged_df['Вид_отсутствия'].astype(str).str.lower()
+ is_remote_reason = reason_series.str.contains('удален|дистанцион', regex=True, na=False)
+ remote_home_cnt = len(merged_df[(merged_df['Пришел'] == False) & is_remote_reason])
+
+ # 3. Официально отсутствующие (без удаленки)
+ explained_cnt = len(merged_df[(merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~is_remote_reason) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))])
+
+ unexplained_cnt = len(absent_unexplained)
+ anomalies_cnt = len(anomalies_list) if anomalies_list else 0
+
+ fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df)
+
+ anomalies_formatted = []
+ if anomalies_list:
+ for idx, a in enumerate(anomalies_list, 1):
+ anomalies_formatted.append(f"{idx}. {a.get('fio', 'Сотрудник')}: {a.get('description', '')}")
+ anomalies_text_block = "\n".join(anomalies_formatted) if anomalies_formatted else "Аномалий не обнаружено."
+
+ unexplained_list = []
+ if not absent_unexplained.empty:
+ for idx, r in absent_unexplained.iterrows():
+ fio = r.get('Сотрудник', r.get('fio_clean', ''))
+ dept_scud = str(r.get('department_scud', '')).strip()
+ dept_1c = str(r.get('Подразделение', '')).strip()
+ dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c
+ if not dept:
+ dept = '—'
+ pos = r.get('Должность', '—')
+ unexplained_list.append(f"{fio} — {dept}, {pos}")
+
+ prompt = f"""
+Ты — старший аудитор кадровой безопасности и контроллинга СКУД.
+Сформируй итоговую сводку кадрового контроля на {date_str}.
+
+📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ:
+{custom_rules_str}
+
+🚨 ВХОДНЫЕ МЕТРИКИ:
+- Всего сотрудников: {total_staff}
+- Итого на работе (в офисе): {present_office_cnt}
+- В том числе на удаленной работе: {remote_home_cnt}
+- Официально отсутствуют: {explained_cnt}
+- Неизвестно (истинно неотмеченные): {unexplained_cnt}
+- Выявлено аномалий/конфликтов реестров: {anomalies_cnt}
+
+---
+🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт):
+{anomalies_text_block}
+
+---
+⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт):
+{json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)}
+
+---
+📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел):
+{json.dumps(unexplained_list, ensure_ascii=False, indent=2)}
+
+СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ:
+1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО выводить JSON! В блоке "Выявленные ИИ аномалии" выведи читаемый нумерованный список строк ровно так, как передано в разделе "ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ". Если аномалий нет, напиши: "Аномалий не обнаружено."
+2. В разделе "Подозрения на ошибки сопоставления ФИО" используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если он пуст, напиши: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено."
+3. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше.
+4. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы без перечисления конкретных ФИО.
+
+СТРОГИЙ ШАБЛОН ОТВЕТА:
+
+**Сводка контроллинга СКУД и 1С:ЗУП на {date_str}**
+
+- Всего офисных сотрудников: **{total_staff}**
+- Итого на работе (в офисе): **{present_office_cnt}**
+- В том числе на удаленной работе: **{remote_home_cnt}**
+- Официально отсутствуют: **{explained_cnt}**
+- Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел.
+- Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт.
+
+#### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}):
+(Выведи нумерованный текстовый список аномалий или 'Аномалий не обнаружено.')
+
+#### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С:
+(Выведи данные ИСКЛЮЧИТЕЛЬНО из массива ошибок ФИО. Если пуст — 'Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.')
+
+#### Неизвестные случаи: {unexplained_cnt}
+(Выведи нумерованный список всех {unexplained_cnt} человек)
+
+### Точечные рекомендации:
+(2-3 системные рекомендации)
+"""
+
+ sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке обычным форматированным текстом. Категорически запрещено выводить фигурные скобки JSON или технический код."
+ return ask_ollama(prompt, system_prompt=sys_prompt)
+```
+
+## File: `./services/exceptions_repo.py`
+```py
+"""
+===============================================================================
+FILE: services/exceptions_repo.py
+ROLE: Управление исключениями в SQLite с синхронизацией с exceptions.json.
+===============================================================================
+"""
+
+from typing import Dict, List, Any
+import json
+import os
+from config import EXCEPTIONS_PATH, normalize_fio
+from core.connection import get_connection
+
+
+def init_exceptions_table():
+ with get_connection() as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS exceptions_registry (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ category TEXT NOT NULL,
+ value TEXT NOT NULL,
+ comment TEXT DEFAULT '',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(category, value)
+ );
+ """)
+ conn.commit()
+
+
+def get_all_exceptions_from_db() -> Dict[str, List[str]]:
+ init_exceptions_table()
+ cfg = {"departments": [], "positions": [], "fio": [], "position_keywords": [], "include_fio": []}
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("SELECT category, value FROM exceptions_registry")
+ rows = cursor.fetchall()
+
+ if not rows and os.path.exists(EXCEPTIONS_PATH):
+ # Первичная миграция из JSON в SQLite
+ sync_json_to_db()
+ return get_all_exceptions_from_db()
+
+ for cat, val in rows:
+ if cat in cfg:
+ cfg[cat].append(val)
+ return cfg
+
+
+def add_exception_to_db(category: str, value: str, comment: str = "") -> bool:
+ init_exceptions_table()
+ val_clean = normalize_fio(value) if category in ["fio", "include_fio"] else value.strip()
+ if not val_clean:
+ return False
+ with get_connection() as conn:
+ conn.execute(
+ "INSERT OR REPLACE INTO exceptions_registry (category, value, comment) VALUES (?, ?, ?)",
+ (category, val_clean, comment)
+ )
+ conn.commit()
+ return True
+
+
+def remove_exception_from_db(category: str, value: str) -> bool:
+ init_exceptions_table()
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM exceptions_registry WHERE category = ? AND value = ?", (category, value.strip()))
+ conn.commit()
+ return cursor.rowcount > 0
+
+
+def sync_json_to_db():
+ """Переносит данные из exceptions.json в SQLite."""
+ if not os.path.exists(EXCEPTIONS_PATH):
+ return
+ try:
+ with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ for cat, items in data.items():
+ for item in items:
+ add_exception_to_db(cat, item, comment="Импорт из JSON")
+ except Exception as e:
+ print(f"[⚠️] Ошибка синхронизации JSON -> DB: {e}")
+```
+
+## File: `./services/zup_extractor.py`
+```py
+import os
+import logging
+from datetime import date, datetime
+import pyodbc
+import pandas as pd
+from config import DATA_DIR, normalize_fio, ZUP_SQL_CONFIG
+
+logger = logging.getLogger(__name__)
+
+
+def get_zup_connection_string() -> str:
+ """Формирует строку подключения pyodbc к MS SQL Server из config.py."""
+ return (
+ f"DRIVER={ZUP_SQL_CONFIG['driver']};"
+ f"SERVER={ZUP_SQL_CONFIG['server']};"
+ f"DATABASE={ZUP_SQL_CONFIG['database']};"
+ f"UID={ZUP_SQL_CONFIG['user']};"
+ f"PWD={ZUP_SQL_CONFIG['password']};"
+ f"TrustServerCertificate={ZUP_SQL_CONFIG.get('trust_server_certificate', 'yes')};"
+ f"Encrypt={ZUP_SQL_CONFIG.get('encrypt', 'no')};"
+ )
+
+
+def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame:
+ """
+ Извлекает оперативные отсутствия и действующие декреты из MS SQL 1С:ЗУП 3.1
+ на указанную дату (принимает как datetime.date, так и строку 'DD.MM.YYYY' / 'DD_MM_YYYY').
+ """
+ if isinstance(target_date, str):
+ clean_date_str = target_date.replace('_', '.')
+ try:
+ target_date = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
+ except ValueError:
+ logger.error(f"[❌] Неверный формат даты для SQL-запроса: {target_date}. Ожидался DD.MM.YYYY")
+ return pd.DataFrame()
+
+ query = """
+ DECLARE @TargetDate DATE = ?;
+
+ -- 1. Оперативные отсутствия (Отпуска, Командировки, Больничные, Отгулы)
+ SELECT
+ LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
+ CASE state._Fld16925RRef
+ WHEN 0x9C10B2452D414FDF4A90E2B2AB81D3F7 THEN N'Отпуск основной'
+ WHEN 0xBA63FCF94B4AD0664ED369D2E6505D67 THEN N'Командировка'
+ WHEN 0x8C3B61F23954155A40EB0108FC0932DB THEN N'Болезнь'
+ WHEN 0x853001C18D0965EE4B2702405C94054A THEN N'Отпуск неоплачиваемый по разрешению работодателя'
+ WHEN 0xB7335AEFD8708C3E462861FC59489A38 THEN N'Отпуск по беременности и родам'
+ ELSE N'Другое отсутствие'
+ END AS [Вид_отсутствия]
+
+ FROM dbo._InfoRg16921 state WITH (NOLOCK)
+ INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
+ ON state._Fld16922RRef = ref_emp._IDRRef
+
+ WHERE @TargetDate BETWEEN CAST(CASE WHEN YEAR(state._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16926) ELSE state._Fld16926 END AS DATE)
+ AND CAST(CASE WHEN YEAR(state._Fld16927) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16927) ELSE state._Fld16927 END AS DATE)
+
+ UNION ALL
+
+ -- 2. Динамический выбор ДЕЙСТВУЮЩИХ декретниц по уходу за ребенком
+ SELECT
+ active_state.fio AS [ФИО],
+ N'Отпуск по уходу за ребенком' AS [Вид_отсутствия]
+
+ FROM (
+ SELECT
+ LTRIM(RTRIM(ref_emp._Description)) AS fio,
+ all_states._Fld16925RRef AS state_guid,
+ CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) AS date_start,
+ ROW_NUMBER() OVER (
+ PARTITION BY all_states._Fld16922RRef
+ ORDER BY all_states._Fld16926 DESC
+ ) AS rn
+ FROM dbo._InfoRg16921 all_states WITH (NOLOCK)
+ INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK)
+ ON all_states._Fld16922RRef = ref_emp._IDRRef
+ WHERE CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) <= @TargetDate
+ ) active_state
+
+ WHERE active_state.rn = 1
+ AND active_state.state_guid = 0xA4FBA038663B3C2A48DA151C262855E1
+ AND active_state.date_start >= DATEADD(YEAR, -3, @TargetDate)
+
+ ORDER BY [ФИО] ASC;
+ """
+
+ try:
+ conn_str = get_zup_connection_string()
+ with pyodbc.connect(conn_str, timeout=30) as conn:
+ df = pd.read_sql(query, conn, params=[target_date])
+
+ if not df.empty:
+ df['fio_clean'] = df['ФИО'].apply(normalize_fio)
+ return df
+ except Exception as e:
+ logger.error(f"[❌] Ошибка SQL-выгрузки отсутствий за {target_date}: {e}")
+ return pd.DataFrame()
+
+
+def fetch_zup_staff_from_sql() -> pd.DataFrame:
+ """Резервная выгрузка штата из MS SQL."""
+ query = """
+ SELECT DISTINCT
+ LTRIM(RTRIM(ref_emp._Description)) AS [ФИО],
+ N'Организация' AS [Подразделение],
+ N'Сотрудник' AS [Должность]
+ FROM dbo._Reference299 ref_emp WITH (NOLOCK)
+ WHERE ref_emp._Description <> ''
+ AND ref_emp._Marked = 0x00
+ ORDER BY [ФИО] ASC;
+ """
+ try:
+ conn_str = get_zup_connection_string()
+ with pyodbc.connect(conn_str, timeout=5) as conn:
+ df = pd.read_sql(query, conn)
+
+ if not df.empty:
+ df['fio_clean'] = df['ФИО'].apply(normalize_fio)
+ return df
+ except Exception as e:
+ logger.error(f"[❌] Ошибка выгрузки штата из MS SQL: {e}")
+ return pd.DataFrame()
+
+
+def sync_zup_to_excel(target_date) -> bool:
+ """Создает дамп в Excel при необходимости."""
+ try:
+ if isinstance(target_date, str):
+ clean_date_str = target_date.replace('_', '.')
+ target_date_obj = datetime.strptime(clean_date_str, "%d.%m.%Y").date()
+ else:
+ target_date_obj = target_date
+
+ date_str_file = target_date_obj.strftime("%d_%m_%Y")
+ c_1c_dir = os.path.join(DATA_DIR, "1c")
+ os.makedirs(c_1c_dir, exist_ok=True)
+
+ df_staff = fetch_zup_staff_from_sql()
+ df_absences = fetch_zup_absences_from_sql(target_date_obj)
+
+ staff_excel_path = os.path.join(c_1c_dir, f"Штат_{date_str_file}.xlsx")
+ absences_excel_path = os.path.join(c_1c_dir, f"Отсутствия_{date_str_file}.xlsx")
+
+ with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer:
+ dummy_headers = pd.DataFrame([[""] * 13] * 8)
+ dummy_headers.to_excel(writer, index=False, header=False)
+ df_staff[['ФИО', 'Подразделение', 'Должность']].to_excel(writer, startrow=8, index=False)
+
+ with pd.ExcelWriter(absences_excel_path, engine='openpyxl') as writer:
+ dummy_headers = pd.DataFrame([[""] * 3] * 3)
+ dummy_headers.to_excel(writer, index=False, header=False)
+ df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False)
+
+ return True
+
+ except Exception as e:
+ logger.error(f"[❌] Ошибка прямого импорта из MS SQL 1С: {e}")
+ return False
+```
+
+## File: `./services/ai_verifier.py`
+```py
+import json
+import re
+import requests
+import pandas as pd
+from config import OLLAMA_URL, OLLAMA_MODEL
+from core.database import get_all_rules_from_db
+from core.database import get_department_synonyms_dict, add_department_synonym_to_db
+
+def resolve_department_exception_ai(dept_1c, dept_scud, exception_departments):
+ """
+ Универсальный сопоставитель отделов.
+ Проверяет 1С, СКУД, локальную базу синонимов SQLite и задействует ИИ для сложных случайных аббревиатур.
+ """
+ if not exception_departments:
+ return False
+
+ d_1c = str(dept_1c).strip().lower() if dept_1c else ""
+ d_scud = str(dept_scud).strip().lower() if dept_scud else ""
+ exc_list = [d.strip().lower() for d in exception_departments]
+
+ # 1. Прямая проверка: если точное имя или подстрока уже совпали в 1С или СКУД
+ for exc in exc_list:
+ if exc and (exc in d_1c or exc in d_scud or d_1c in exc or d_scud in exc):
+ return True
+
+ # 2. Проверка по сохраненной Базе Знаний синонимов из SQLite
+ synonyms = get_department_synonyms_dict()
+ for exc in exc_list:
+ # Если в БД зафиксировано: 'овк' -> 'отдел внутреннего контроля'
+ full_from_db = synonyms.get(exc, "")
+ if full_from_db and (full_from_db in d_1c or full_from_db in d_scud):
+ return True
+
+ # 3. Умный ИИ-арбитраж (если отдел спорный и еще не сохранен в БД)
+ if d_1c or d_scud:
+ dept_to_check = d_1c if d_1c else d_scud
+ prompt = f"""
+Ты — кадровый аналитик.
+Проверь, является ли отдел сотрудника "{dept_to_check}" тем же самым подразделением, что и один из отделов-исключений: {exception_departments}?
+
+Примеры:
+- "Отдел внутреннего контроля" — это "ОВК" (Да)
+- "Отдел технического обеспечения" — это "ОТО" (Да)
+
+Ответь СТРОГО в формате JSON:
+{{
+ "is_match": true/false,
+ "matched_exception": "Название из списка исключений",
+ "explanation": "краткое объяснение"
+}}
+"""
+ try:
+ raw_res = ask_ollama(prompt, system_prompt="Отвечай только валидным JSON.")
+ match = re.search(r'\{.*\}', raw_res, re.DOTALL)
+ if match:
+ data = json.loads(match.group(0))
+ if data.get("is_match"):
+ matched_exc = data.get("matched_exception", "").lower()
+ # Запоминаем открытую ИИ связь в SQLite навсегда!
+ add_department_synonym_to_db(matched_exc, dept_to_check)
+ return True
+ except Exception as e:
+ print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}")
+
+ return False
+
+def ask_ollama(prompt, system_prompt=None):
+ """
+ Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5).
+ """
+ payload = {
+ "model": OLLAMA_MODEL,
+ "prompt": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt,
+ "stream": False
+ }
+
+ try:
+ response = requests.post(OLLAMA_URL, json=payload, timeout=120)
+ if response.status_code == 200:
+ data = response.json()
+ if "response" in data:
+ return data["response"].strip()
+ elif "message" in data and "content" in data["message"]:
+ return data["message"]["content"].strip()
+ else:
+ return f"⚠️ Неизвестная структура ответа Ollama: {data}"
+ else:
+ return f"⚠️ Ошибка Ollama (Код {response.status_code}): {response.text}"
+ except requests.exceptions.ConnectionError:
+ return "⚠️ Не удалось подключиться к Ollama. Проверьте, запущен ли сервис (ollama run qwen2.5:14b)."
+ except Exception as e:
+ return f"⚠️ Ошибка при обращении к Ollama: {e}"
+
+
+def get_system_rules_context():
+ """
+ Загружает динамические системные правила компании напрямую из Базы Данных SQLite.
+ """
+ rules = get_all_rules_from_db()
+ if not rules:
+ return ""
+ rules_text = "\n".join([f"- {r}" for r in rules])
+ return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ (ИЗ SQLITE БД):\n{rules_text}\n"
+
+
+def analyze_scud_mass_failure_ai(df_scud):
+ """
+ Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке.
+ Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта.
+ """
+ if df_scud is None or df_scud.empty:
+ return None
+
+ total_records = len(df_scud)
+ anomaly_rows = df_scud[df_scud.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY']
+ anomaly_count = len(anomaly_rows)
+
+ if total_records == 0:
+ return None
+
+ anomaly_percent = round((anomaly_count / total_records) * 100, 1)
+
+ if anomaly_percent > 5.0 or anomaly_count >= 10:
+ rules_context = get_system_rules_context()
+ prompt = f"""
+{rules_context}
+ВНИМАНИЕ! Проведён анализ смены СКУД:
+- Всего записей за смену: {total_records}
+- Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью: {anomaly_count} ({anomaly_percent}% от смены)
+
+Сформируй понятное предупреждение для Администратора СКУД и Руководителя.
+Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия.
+"""
+ alert_text = ask_ollama(prompt, system_prompt="Ты — ИИ-аналитик контроллинга СКУД. Отвечай кратко и строго по делу.")
+ return {
+ "is_mass_failure": True,
+ "anomaly_count": anomaly_count,
+ "anomaly_percent": anomaly_percent,
+ "alert_text": alert_text
+ }
+
+ return {
+ "is_mass_failure": False,
+ "anomaly_count": anomaly_count,
+ "anomaly_percent": anomaly_percent,
+ "alert_text": "Массовых сбоев оборудования не зафиксировано."
+ }
+
+
+def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios):
+ if not unrecognized_scud_fios or not staff_fios:
+ return {}
+
+ # Исключаем точные совпадения
+ staff_fios_set = set(staff_fios)
+ real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set]
+
+ if not real_unrecognized:
+ return {}
+
+ rules_context = get_system_rules_context()
+
+ prompt = f"""
+Ты — кадровый аудитор безопасности СКУД.
+{rules_context}
+В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)}
+В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)}
+
+СТРОГИЕ ПРАВИЛА:
+1. Запись из Штат 1С — это 100% ПРАВИЛЬНЫЙ эталон.
+2. В СКУД допущена опечатка.
+3. Любое присутствие сотрудника по СКУД при наличии в 1С документа отсутствия (кроме командировок) является гарантированной аномалией.
+4. В поле "warning" опиши обнаруженную опечатку.
+5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев!
+
+ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON:
+{{
+ "verified_matches": [
+ {{
+ "scud_fio": "ФИО из СКУД",
+ "staff_fio": "эталон ФИО из Штат 1С",
+ "warning": "Описание опечатки в СКУД"
+ }}
+ ]
+}}
+"""
+ raw_response = ask_ollama(
+ prompt,
+ system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста."
+ )
+
+ mapping = {}
+ if not raw_response:
+ return mapping
+
+ # Фаза 1: Попытка прямого разбора с санитарной очисткой
+ try:
+ match = re.search(r'\{.*\}', raw_response, re.DOTALL)
+ if match:
+ json_str = match.group(0)
+ # Убираем висячие запятые: {"a": 1,} -> {"a": 1}
+ json_str = re.sub(r',\s*([\}\]])', r'\1', json_str)
+ # Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости
+ json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str)
+
+ data = json.loads(json_str)
+ for item in data.get("verified_matches", []):
+ scud_f = item.get("scud_fio")
+ staff_f = item.get("staff_fio")
+ warn = item.get("warning", "Точное совпадение (без опечаток)")
+ if scud_f and staff_f and staff_f in staff_fios_set:
+ mapping[scud_f] = {"staff_fio": staff_f, "warning": warn}
+ return mapping
+ except Exception:
+ pass
+
+ # Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть)
+ try:
+ pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']'
+ matches = re.findall(pattern, raw_response, re.DOTALL)
+ for scud_f, staff_f in matches:
+ scud_clean = scud_f.strip()
+ staff_clean = staff_f.strip()
+ if staff_clean in staff_fios_set:
+ mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"}
+ except Exception as e:
+ print(f"[!] Ошибка резервного парсинга опечаток: {e}")
+
+ return mapping
+
+
+def ai_verify_department_exceptions(unexplained_df, exception_departments):
+ """
+ Локальный ИИ проверяет, не являются ли неотмеченные отделы синонимами отделов-исключений.
+ """
+ if unexplained_df.empty or not exception_departments:
+ return []
+
+ dept_list = unexplained_df['Подразделение'].dropna().unique().tolist()
+ rules_context = get_system_rules_context()
+
+ prompt = f"""
+{rules_context}
+Ты — кадровый аудитор.
+Список отделов-исключений компании: {json.dumps(exception_departments, ensure_ascii=False)}
+Список отделов сотрудников, попавших в неизвестные: {json.dumps(dept_list, ensure_ascii=False)}
+
+Определи, какие из отделов сотрудников являются ПОЛНЫМИ НАЗВАНИЯМИ или СИНУНИМАМИ отделов-исключений (например: 'Отдел внутреннего контроля' — это 'ОВК').
+
+Выдай ответ строго в формате JSON:
+{{
+ "matched_departments": ["Название отдела 1", "Название отдела 2"]
+}}
+"""
+ raw_response = ask_ollama(prompt, system_prompt="Выдавай только валидный JSON.")
+
+ try:
+ match = re.search(r'\{.*\}', raw_response, re.DOTALL)
+ if match:
+ data = json.loads(match.group(0))
+ return data.get("matched_departments", [])
+ except Exception as e:
+ print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}")
+
+ return []
+```
+
+## File: `./services/knowledge_base.py`
+```py
+import os
+from core.database import get_all_rules_from_db, add_rule_to_db
+
+
+def load_knowledge_base():
+ """
+ Загружает актуальные правила Базы Знаний компании напрямую из базы данных SQLite.
+ """
+ rules = get_all_rules_from_db()
+ return {
+ "rules": rules if rules else [],
+ "fio_corrections": {}
+ }
+
+
+def add_rule_to_kb(new_rule):
+ """
+ Добавляет новое принятое человеком правило в SQLite таблицу ai_knowledge_base.
+ """
+ if not new_rule or not new_rule.strip():
+ return
+ add_rule_to_db(new_rule.strip(), added_by="Human")
+ print(f"[✓] База знаний SQLite успешно обновлена! Новое правило: {new_rule.strip()}")
+```
+
+## File: `./services/knowledge/service.py`
+```py
+"""
+===============================================================================
+FILE: services/knowledge/service.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: services / knowledge
+ROLE: Доменный сервис базы знаний, правил компании и синонимов подразделений.
+===============================================================================
+"""
+
+from typing import List, Dict, Any
+from core.repositories.zup_repo import (
+ get_all_rules_from_db,
+ add_rule_to_db,
+ get_department_synonyms_dict,
+ add_department_synonym_to_db
+)
+
+
+def get_rules() -> List[Dict[str, Any]]:
+ """Получить все правила базы знаний в виде списка словарей."""
+ raw_rules = get_all_rules_from_db()
+ return [{"id": idx, "rule_text": r} for idx, r in enumerate(raw_rules, 1)]
+
+
+def add_rule(rule_text: str, added_by: str = "Human") -> None:
+ """Добавить новое правило в базу знаний."""
+ add_rule_to_db(rule_text, added_by=added_by)
+
+
+def get_synonyms() -> Dict[str, str]:
+ """Получить словарь синонимов отделов."""
+ return get_department_synonyms_dict()
+
+
+def register_department_synonym(short_name: str, full_name: str) -> None:
+ """Сохранить новую пару синонимов подразделения."""
+ add_department_synonym_to_db(short_name, full_name)
+```
+
+## File: `./services/scud_etl/pipeline.py`
+```py
+"""
+===============================================================================
+FILE: services/scud_etl/pipeline.py
+ROLE: Выборка данных СКУД из SQLite (scud_logs) с жестким приоритетом Y-снапшотов.
+===============================================================================
+"""
+
+import os
+import logging
+import pandas as pd
+from core.connection import get_connection
+from services.data_loader import load_1c_data_smart
+
+logger = logging.getLogger("SCUD_PIPELINE")
+
+
+def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
+ """Извлекает срез СКУД. Для вчерашнего дня строго берет финальный вечерний срез Y."""
+ with get_connection() as conn:
+ df = pd.DataFrame()
+ cursor = conn.cursor()
+
+ if prefer_final_y:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1",
+ (date_str,)
+ )
+ row = cursor.fetchone()
+ if row and row[0]:
+ df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
+
+ if df.empty:
+ cursor.execute(
+ "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1",
+ (date_str,)
+ )
+ row = cursor.fetchone()
+ if row and row[0]:
+ df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(row[0],))
+
+ if not df.empty:
+ rename_map = {
+ 'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник',
+ 'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня',
+ 'time_in_building': 'Находился_в_здании', 'is_present': 'Пришел'
+ }
+ df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
+ if 'fio_clean' not in df.columns and 'Сотрудник' in df.columns:
+ df['fio_clean'] = df['Сотрудник'].astype(str).str.strip()
+ if 'Пришел' in df.columns:
+ df['Пришел'] = df['Пришел'].astype(bool)
+
+ return df
+
+
+def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
+ df_staff, df_absences = load_1c_data_smart(date_str, use_db=False)
+ return (df_staff if df_staff is not None else pd.DataFrame(),
+ df_absences if df_absences is not None else pd.DataFrame())
+```
+
+## File: `./services/scud_etl/merger.py`
+```py
+"""
+===============================================================================
+FILE: services/scud_etl/merger.py
+===============================================================================
+"""
+
+import logging
+from typing import Dict, Any
+import pandas as pd
+
+from services.knowledge.service import get_department_synonyms_dict
+from config import normalize_fio, load_exceptions
+
+logger = logging.getLogger("SCUD_MERGER")
+
+
+def merge_scud_and_1c(
+ df_scud: pd.DataFrame,
+ df_staff_1c: pd.DataFrame,
+ df_absences_1c: pd.DataFrame
+) -> pd.DataFrame:
+ if (df_scud is None or df_scud.empty) and (df_staff_1c is None or df_staff_1c.empty):
+ return pd.DataFrame(columns=[
+ 'Сотрудник', 'fio_clean', 'Подразделение', 'Должность',
+ 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании',
+ 'Пришел', 'anomaly_flag', 'причина отсутствия', 'Вид_отсутствия', 'is_excluded'
+ ])
+
+ synonyms = get_department_synonyms_dict()
+ exceptions_cfg = load_exceptions()
+
+ df_res = df_scud.copy() if df_scud is not None and not df_scud.empty else df_staff_1c.copy()
+
+ if "Сотрудник" in df_res.columns:
+ df_res["fio_clean"] = df_res["Сотрудник"].apply(normalize_fio)
+ elif "ФИО" in df_res.columns:
+ df_res["Сотрудник"] = df_res["ФИО"]
+ df_res["fio_clean"] = df_res["ФИО"].apply(normalize_fio)
+ elif "fio_clean" not in df_res.columns:
+ df_res["fio_clean"] = ""
+
+ for col, default_val in [
+ ('Начало_дня', 'Нет входа'),
+ ('Первая_активность', '—'),
+ ('Конец_дня', 'Нет выхода'),
+ ('Находился_в_здании', '00:00'),
+ ('Пришел', False),
+ ('anomaly_flag', 'NONE')
+ ]:
+ if col not in df_res.columns:
+ df_res[col] = default_val
+
+ # 1. Приведение отделов к аббревиатурам
+ reverse_synonyms = {v.lower(): k.upper() for k, v in synonyms.items()}
+ direct_synonyms = {k.lower(): k.upper() for k in synonyms.keys()}
+ all_dept_map = {**reverse_synonyms, **direct_synonyms, "отдел внутреннего контроля": "ОВК", "отдел вневедомственного контроля": "ОВК"}
+
+ if "Подразделение" in df_res.columns:
+ df_res["Подразделение"] = df_res["Подразделение"].apply(
+ lambda d: all_dept_map.get(str(d).strip().lower(), str(d).strip())
+ )
+
+ # 2. Привязка документов отсутствий из 1С ПЕРЕД разметкой исключений
+ absences_map = {}
+ if not df_absences_1c.empty:
+ fio_col = next((c for c in ["fio_clean", "ФИО", "Сотрудник"] if c in df_absences_1c.columns), None)
+ reason_col = next((c for c in ["Вид_отсутствия", "Причина", "причина отсутствия"] if c in df_absences_1c.columns), None)
+
+ if fio_col and reason_col:
+ for _, row in df_absences_1c.iterrows():
+ fio = normalize_fio(str(row[fio_col]))
+ reason = str(row[reason_col]).strip()
+ if reason and reason.lower() != "nan":
+ absences_map[fio] = reason
+
+ df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map)
+ df_res["Вид_отсутствия"] = df_res["причина отсутствия"]
+
+ # 3. Разметка исключений
+ exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
+ exc_depts = [d.strip().upper() for d in exceptions_cfg.get("departments", []) if d]
+ exc_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
+ pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
+ whitelist_fios = [normalize_fio(f) for f in exceptions_cfg.get("include_fio", []) if f]
+
+ df_res["is_excluded"] = False
+ for idx, row in df_res.iterrows():
+ fio = row.get("fio_clean", "")
+ has_official_absence = pd.notna(row.get("Вид_отсутствия")) and str(row.get("Вид_отсутствия")).strip() != ""
+
+ # ⭐️ Если сотрудник в белом списке ИЛИ у него есть официальное отсутствие — он НЕ исключается
+ if fio in whitelist_fios or has_official_absence:
+ df_res.at[idx, "is_excluded"] = False
+ continue
+
+ dep = str(row.get("Подразделение", "")).upper()
+ pos = str(row.get("Должность", "")).lower()
+
+ if fio in exc_fios or dep in exc_depts or any(d in dep for d in exc_depts) or pos in exc_pos or any(k in pos for k in pos_kw):
+ df_res.at[idx, "is_excluded"] = True
+
+ # Чистое "Исключение" только для истинно исключенных без документов 1С
+ mask_exc = (df_res["is_excluded"] == True) & (df_res["Вид_отсутствия"].isna() | (df_res["Вид_отсутствия"] == ""))
+ df_res.loc[mask_exc, "Вид_отсутствия"] = "Исключение"
+ df_res.loc[mask_exc, "причина отсутствия"] = "Исключение"
+
+ return df_res
+
+
+def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
+ active_df = df_merged[df_merged.get("is_excluded", False) == False].copy()
+ total_staff = len(active_df)
+
+ # 1. Строго физически пришедшие в офис
+ came_to_office_mask = active_df["Начало_дня"].astype(str).str.strip().ne("Нет входа")
+ working_in_office = active_df[came_to_office_mask]
+ working_in_office_count = len(working_in_office)
+
+ # 2. Не пришедшие в офис
+ df_not_came = active_df[~came_to_office_mask]
+
+ # 3. Из не пришедших — удаленщики (работают из дома)
+ reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
+ is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
+ remote_home = df_not_came[is_remote_mask]
+ remote_home_count = len(remote_home)
+
+ # 4. Из оставшихся — официальные отсутствия (отпуск, больничный, командировка)
+ df_remaining_absent = df_not_came[~is_remote_mask]
+ has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \
+ df_remaining_absent["причина отсутствия"].ne("") & \
+ df_remaining_absent["причина отсутствия"].ne("nan") & \
+ (~df_remaining_absent["причина отсутствия"].astype(str).str.startswith("Исключение"))
+ official_absent_count = len(df_remaining_absent[has_doc_mask])
+
+ # 5. Неизвестные
+ unknown = df_remaining_absent[~has_doc_mask]
+ unknown_count = len(unknown)
+
+ return {
+ "total_staff": total_staff,
+ "working_in_office_count": working_in_office_count,
+ "remote_home_count": remote_home_count,
+ "official_absent_count": official_absent_count,
+ "unknown_count": unknown_count,
+ "unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
+ }
+```
+
+## File: `./services/scud_etl/anomaly_detector.py`
+```py
+"""
+===============================================================================
+FILE: services/scud_etl/anomaly_detector.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: services / scud_etl
+ROLE: Детектирование истинных аномалий и конфликтов реестров.
+ (Сотрудники из exceptions.json, удаленка и командировки исключены).
+===============================================================================
+"""
+
+import logging
+from typing import List, Dict, Any
+import pandas as pd
+
+logger = logging.getLogger("SCUD_ANOMALY")
+
+
+def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
+ """
+ Выявляет реальные аномалии:
+ - Приход в офис во время отпуска или больничного листа.
+ - Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа).
+ """
+ anomalies = []
+ if df_merged is None or df_merged.empty:
+ return anomalies
+
+ for _, row in df_merged.iterrows():
+ # Пропускаем системные исключения
+ if row.get("is_excluded", False):
+ continue
+
+ fio = row.get("fio_clean") or row.get("Сотрудник", "")
+ start_day = str(row.get("Начало_дня", "")).strip()
+ end_day = str(row.get("Конец_дня", "")).strip()
+ reason = str(row.get("причина отсутствия", row.get("Вид_отсутствия", ""))).strip()
+ reason_lower = reason.lower()
+
+ # Пропускаем технические метки
+ if "исключен" in reason_lower or "овк" in reason_lower:
+ continue
+
+ # ⭐️ Физический приход в офис: удаленка и командировки разрешены и НЕ являются аномалией
+ if start_day not in ["Нет входа", "—", "", "nan", "None"] and reason and reason != "nan":
+ if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
+ anomalies.append({
+ "fio": fio,
+ "type": "PHYSICAL_PRESENCE_DURING_ABSENCE",
+ "description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'."
+ })
+
+ # Аномалия оборудования (есть выход без утреннего входа)
+ if start_day in ["Нет входа", "—", ""] and end_day not in ["Нет выхода", "—", "", "nan", "None"]:
+ anomalies.append({
+ "fio": fio,
+ "type": "SCUD_EQUIPMENT_ANOMALY",
+ "description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})."
+ })
+
+ return anomalies
+```
+
+## File: `./services/snapshots/service.py`
+```py
+"""
+===============================================================================
+FILE: services/snapshots/service.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: services / snapshots
+ROLE: Бизнес-логика срезов СКУД (выборка, валидация Y-срезов, удаление).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[SNAPSHOT_GET_REGISTRY]: Выборка срезов с разметкой защищенных Y-снапшотов.
+ - ANCHOR[SNAPSHOT_DELETE_SAFE]: Безопасное удаление дневных срезов с защитой итоговых.
+===============================================================================
+"""
+
+from typing import Dict, Any, Optional, List
+from core.connection import get_connection
+from core.repositories.scud_repo import get_available_snapshots, delete_snapshot_by_id
+
+
+# ANCHOR[SNAPSHOT_GET_REGISTRY]
+def get_snapshots_registry(date_str: Optional[str] = None) -> Dict[str, Any]:
+ """Возвращает реестр снапшотов за дату или за все доступные дни."""
+ clean_date = date_str.strip() if date_str else ""
+ rows = get_available_snapshots(date_str=clean_date if clean_date else None)
+
+ snapshots = [
+ {
+ "snapshot_id": r[0],
+ "log_date": r[1],
+ "snapshot_time": r[2],
+ "record_count": r[3],
+ "is_final": str(r[0]).startswith("Y")
+ }
+ for r in rows
+ ]
+
+ return {
+ "query_date": clean_date or "все",
+ "snapshots_count": len(snapshots),
+ "snapshots": snapshots
+ }
+
+
+# ANCHOR[SNAPSHOT_DELETE_SAFE]
+def delete_snapshots_safely(snapshot_ids: List[str]) -> Dict[str, Any]:
+ """
+ Удаляет выбранные дневные снапшоты.
+ Итоговые вечерние срезы с префиксом 'Y' гарантированно защищены от удаления.
+ """
+ if not snapshot_ids:
+ return {"status": "error", "message": "Не указаны ID снапшотов для удаления."}
+
+ safe_ids = [str(s).strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")]
+
+ if not safe_ids:
+ return {"status": "error", "message": "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."}
+
+ with get_connection() as conn:
+ cursor = conn.cursor()
+ placeholders = ",".join(["?"] * len(safe_ids))
+ cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids)
+ deleted_count = cursor.rowcount
+ conn.commit()
+
+ return {
+ "status": "success",
+ "deleted_count": deleted_count,
+ "deleted_ids": safe_ids,
+ "message": f"Успешно удалено снапшотов: {len(safe_ids)} шт."
+ }
+```
+
+## File: `./services/tasks/repository.py`
+```py
+"""
+===============================================================================
+FILE: services/tasks/repository.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: services / tasks
+ROLE: Низкоуровневые операции к таблице tasks в SQLite (CRUD).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[TASK_REPO_GET]: Выборка задач с фильтрацией по статусу и пользователю.
+ - ANCHOR[TASK_REPO_ADD]: Вставка новой задачи со сквозным ID.
+ - ANCHOR[TASK_REPO_UPDATE]: Обновление реквизитов и статуса задачи.
+ - ANCHOR[TASK_REPO_DELETE]: Удаление задачи по числовому или строковому ID.
+===============================================================================
+"""
+
+import re
+from typing import List, Dict, Any, Optional
+from core.connection import get_connection
+
+
+def normalize_task_id(task_id_input: str) -> str:
+ """Нормализует идентификатор задачи к формату TASK-XX."""
+ if not task_id_input:
+ return ""
+ clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "").replace("#", "")
+ if clean_id.isdigit():
+ num = int(clean_id)
+ return f"TASK-{num:02d}" if num < 100 else f"TASK-{num:03d}"
+ return f"TASK-{clean_id}"
+
+
+# ANCHOR[TASK_REPO_GET]
+def repo_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
+ """Получает список задач пользователя с опциональной фильтрацией по статусу."""
+ conn = get_connection(row_factory=True)
+ cursor = conn.cursor()
+
+ if status and status.upper() != "ALL":
+ target_status = status.upper()
+ if target_status in ["PROGRESS", "В РАБОТЕ"]:
+ target_status = "IN_PROGRESS"
+ elif target_status in ["DONE", "ГОТОВО"]:
+ target_status = "COMPLETED"
+ elif target_status in ["PLANNED", "ПЛАНЫ"]:
+ target_status = "BACKLOG"
+
+ cursor.execute("""
+ SELECT id, task_id, module, title, priority, status, due_date, created_at
+ FROM tasks
+ WHERE user_id = ? AND (status = ? OR (status = 'BACKLOG' AND ? = 'PLANNED'))
+ ORDER BY id DESC
+ """, (user_id, target_status, target_status))
+ else:
+ cursor.execute("""
+ SELECT id, task_id, module, title, priority, status, due_date, created_at
+ FROM tasks
+ WHERE user_id = ?
+ ORDER BY id DESC
+ """, (user_id,))
+
+ rows = cursor.fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+
+# ANCHOR[TASK_REPO_ADD]
+def repo_add_task(
+ user_id: int,
+ module: str,
+ title: str,
+ priority: str = "MEDIUM",
+ due_date: Optional[str] = None,
+ status: str = "BACKLOG"
+) -> Dict[str, Any]:
+ """Добавляет новую задачу в SQLite с автогенерацией порядкового TASK-ID."""
+ conn = get_connection()
+ cursor = conn.cursor()
+ cursor.execute("SELECT MAX(id) FROM tasks")
+ max_id = cursor.fetchone()[0] or 0
+ new_task_id = f"TASK-{(max_id + 1):02d}"
+
+ target_status = status.upper() if status else "BACKLOG"
+ if target_status in ["PROGRESS", "В РАБОТЕ"]:
+ target_status = "IN_PROGRESS"
+ elif target_status in ["DONE", "ГОТОВО"]:
+ target_status = "COMPLETED"
+ elif target_status in ["PLANNED", "ПЛАНЫ", "BACKLOG"]:
+ target_status = "BACKLOG"
+
+ cursor.execute("""
+ INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """, (new_task_id, module or "general", title.strip(), priority.upper(), target_status, due_date, user_id))
+ conn.commit()
+ conn.close()
+ return {"status": "success", "task_id": new_task_id, "id": max_id + 1}
+
+
+# ANCHOR[TASK_REPO_UPDATE]
+def repo_update_task(
+ user_id: int,
+ task_id: str,
+ title: Optional[str] = None,
+ priority: Optional[str] = None,
+ status: Optional[str] = None,
+ due_date: Optional[str] = None
+) -> Dict[str, Any]:
+ """Комплексное обновление атрибутов задачи."""
+ conn = get_connection()
+ cursor = conn.cursor()
+
+ clean_num = re.sub(r'\D', '', str(task_id))
+ formatted_id = normalize_task_id(task_id)
+
+ updates = []
+ params = []
+
+ if title is not None and title.strip():
+ updates.append("title = ?")
+ params.append(title.strip())
+
+ if priority is not None and priority.strip():
+ updates.append("priority = ?")
+ params.append(priority.strip().upper())
+
+ if status is not None and status.strip():
+ target_status = status.strip().upper()
+ if target_status in ["PROGRESS", "В РАБОТЕ"]:
+ target_status = "IN_PROGRESS"
+ elif target_status in ["DONE", "ГОТОВО"]:
+ target_status = "COMPLETED"
+ elif target_status in ["PLANNED", "ПЛАНЫ"]:
+ target_status = "BACKLOG"
+ updates.append("status = ?")
+ params.append(target_status)
+
+ if due_date is not None:
+ updates.append("due_date = ?")
+ params.append(due_date.strip() if due_date.strip() else None)
+
+ if not updates:
+ conn.close()
+ return {"status": "success", "message": "Нет данных для обновления"}
+
+ params.extend([clean_num, formatted_id, f"%{task_id.strip()}", user_id])
+ sql = f"""
+ UPDATE tasks
+ SET {', '.join(updates)}
+ WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
+ """
+ cursor.execute(sql, params)
+ rows_affected = cursor.rowcount
+ conn.commit()
+ conn.close()
+
+ if rows_affected == 0:
+ return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
+
+ return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"}
+
+
+# ANCHOR[TASK_REPO_DELETE]
+def repo_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
+ """Удаляет задачу по номеру ID."""
+ conn = get_connection()
+ cursor = conn.cursor()
+ clean_num = re.sub(r'\D', '', str(task_id))
+ formatted_id = normalize_task_id(task_id)
+
+ cursor.execute("""
+ DELETE FROM tasks
+ WHERE (id = ? OR UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
+ """, (clean_num, formatted_id, f"%{task_id.strip()}", user_id))
+
+ deleted = cursor.rowcount
+ conn.commit()
+ conn.close()
+
+ if deleted == 0:
+ return {"error": f"Задача {task_id} не найдена"}
+ return {"status": "success", "message": f"Задача #{task_id} удалена"}
+```
+
+## File: `./services/tasks/service.py`
+```py
+"""
+===============================================================================
+FILE: services/tasks/service.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: services / tasks
+ROLE: Единый доменный сервис задач (бизнес-логика и диспетчер операций).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[TASK_SERVICE_DISPATCHER]: Маршрутизация действий ADD/UPDATE/DELETE/EXPORT.
+===============================================================================
+"""
+
+from typing import Dict, Any, Optional, List
+from .repository import repo_get_tasks, repo_add_task, repo_update_task, repo_delete_task
+from .exporter import export_tasks_to_markdown
+
+
+def get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]:
+ """Получить список задач."""
+ return repo_get_tasks(user_id, status)
+
+
+def add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None, status: str = "BACKLOG") -> Dict[str, Any]:
+ """Создать задачу."""
+ res = repo_add_task(user_id, module, title, priority, due_date, status)
+ return {"status": "success", "task_id": res["task_id"], "message": f"Задача #{res['id']} создана и добавлена в планы"}
+
+
+def update_task_details(user_id: int, task_id: str, title: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, due_date: Optional[str] = None) -> Dict[str, Any]:
+ """Обновить задачу."""
+ return repo_update_task(user_id, task_id, title, priority, status, due_date)
+
+
+def delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
+ """Удалить задачу."""
+ return repo_delete_task(user_id, task_id)
+
+
+# ANCHOR[TASK_SERVICE_DISPATCHER]
+def execute_task_action(
+ user_id: int,
+ action: str,
+ task_id: Optional[str] = None,
+ title: Optional[str] = None,
+ priority: Optional[str] = "MEDIUM",
+ status: Optional[str] = None,
+ module: Optional[str] = "general",
+ due_date: Optional[str] = None,
+ filename: Optional[str] = "ROADMAP.md"
+) -> Dict[str, Any]:
+ """Консолидированный диспетчер операций над задачами."""
+ act = (action or "").strip().upper()
+
+ if act == "ADD":
+ if not title:
+ return {"status": "error", "message": "Для создания задачи требуется указать title"}
+ return add_task(user_id, module or "general", title, priority or "MEDIUM", due_date, status or "BACKLOG")
+
+ elif act == "UPDATE":
+ if not task_id:
+ return {"status": "error", "message": "Для обновления требуется указать task_id"}
+ return update_task_details(user_id, str(task_id), title, priority, status, due_date)
+
+ elif act == "DELETE":
+ if not task_id:
+ return {"status": "error", "message": "Для удаления требуется указать task_id"}
+ return delete_task(user_id, str(task_id))
+
+ elif act == "EXPORT":
+ return export_tasks_to_markdown(user_id, filename=filename or "ROADMAP.md", status_filter=status)
+
+ return {"status": "error", "message": f"Неизвестное действие action='{action}'"}
+```
diff --git a/exceptions.json b/exceptions.json
index 742ba5f..ab43c66 100644
--- a/exceptions.json
+++ b/exceptions.json
@@ -7,10 +7,9 @@
"Уборщик служебных помещений"
],
"fio": [
- "Таткало Валерий Валерьевич",
- "Петренюк Андрей Германович",
- "Чуркина Елена Геннадьевна",
- "Михалев Сергей Геннадьевич"
+ "Таткало Валерий Валерьевич",
+ "Петренюк Андрей Германович",
+ "Михалев Сергей Геннадьевич"
],
"position_keywords": [
"уборщик",
@@ -18,5 +17,9 @@
"дворник",
"гардероб",
"рабочий по обслуживанию"
+ ],
+ "include_fio": [
+ "Тарасенко Александр Александрович",
+ "Журиков Михаил Николаевич"
]
}
\ No newline at end of file
diff --git a/main_etl.py b/main_etl.py
index 3a1b6f5..3b6e194 100644
--- a/main_etl.py
+++ b/main_etl.py
@@ -1,571 +1,116 @@
+"""
+===============================================================================
+FILE: main_etl.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
+===============================================================================
+"""
+
import os
import sys
-import json
import argparse
+import logging
import pandas as pd
from datetime import datetime, timedelta
-sys.path.append(os.path.dirname(os.path.abspath(__file__)))
+from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
+from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
+from services.scud_etl.anomaly_detector import detect_registry_anomalies
+from services.text_reporter import generate_markdown_report
-from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio
from services.scud_export import run_export
from services.share_copier import copy_1c_files_from_share
-from services.data_validator import check_file_freshness
-from services.data_loader import load_1c_data_smart, load_scud_data
from services.excel_exporter import generate_summary_excel, generate_detailed_excel
-from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai
-from services.text_reporter import generate_markdown_report
-from services.feedback_loop import review_ai_decisions
-from services.knowledge_base import load_knowledge_base
-from core.database import (
- init_db,
- save_scud_to_db,
- save_staff_to_db,
- save_absences_to_db,
- save_anomalies_to_db,
- load_scud_from_db_by_snapshot,
- load_staff_from_db,
- load_absences_from_db,
- get_latest_snapshot_time,
- has_scud_logs_for_date
-)
-
-def load_exceptions_config():
- """Загружает файл exceptions.json из корня проекта."""
- root_dir = os.path.dirname(os.path.abspath(__file__))
- json_path = os.path.join(root_dir, "exceptions.json")
- if not os.path.exists(json_path):
- return {}
- try:
- with open(json_path, 'r', encoding='utf-8') as f:
- return json.load(f)
- except Exception as e:
- print(f"[⚠️] Ошибка чтения exceptions.json: {e}")
- return {}
-
-
-def apply_exceptions_from_json(df, exceptions_cfg):
- """
- Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json.
- Выполняется мгновенно без цикличных HTTP-запросов к ИИ.
- """
- if df is None or df.empty or not exceptions_cfg:
- if df is not None:
- df['is_excluded'] = False
- return df
-
- deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d]
- exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
- pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
- exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
-
- df['is_excluded'] = False
-
- for idx, row in df.iterrows():
- fio_clean = row.get('fio_clean', '')
- dep_1c = str(row.get('Подразделение', '')).lower()
- dep_scud = str(row.get('department_scud', row.get('department', ''))).lower()
- pos = str(row.get('Должность', '')).lower()
-
- is_fio_exc = fio_clean in exc_fios
- is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False
-
- # Быстрая проверка отделов по подстрокам
- is_dep_exc = False
- if deps:
- is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps)
-
- if is_fio_exc or is_dep_exc or is_pos_exc:
- df.at[idx, 'is_excluded'] = True
-
- return df
-
-
-def filter_report_dataframe(merged_df):
- """
- Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета,
- ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП.
- """
- if merged_df is None or merged_df.empty:
- return merged_df
-
- has_1c_reason = (
- merged_df['Вид_отсутствия'].notna() &
- (merged_df['Вид_отсутствия'].astype(str).str.strip() != '') &
- (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
- )
- is_not_excluded = merged_df.get('is_excluded', False) == False
- is_not_no_pass = merged_df.get('no_scud_pass', False) == False
-
- filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy()
- return filtered_df
-
-
-def load_static_reason_workers():
- """Загружает реестр удалёнщиков и статических причин из CSV."""
- static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
- if not os.path.exists(static_path):
- return {}
- try:
- df_static = pd.read_csv(static_path, encoding='utf-8')
- if 'fio' in df_static.columns and 'reason' in df_static.columns:
- df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
- return dict(zip(df_static['fio_clean'], df_static['reason']))
- except Exception as e:
- print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}")
- return {}
-
-
-def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None):
- """
- Автоматически выявляет истинные аномалии СКУД ⟷ 1С.
- """
- anomalies = []
- kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
-
- ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк']
-
- for idx, row in merged_df.iterrows():
- fio = row.get('Сотрудник', row.get('fio_clean', ''))
- fio_clean = row.get('fio_clean', '')
- is_present = row.get('Пришел', False)
- is_exc = row.get('is_excluded', False)
- reason_1c = str(row.get('Вид_отсутствия', '')).strip()
- has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение')
- anom_flag = row.get('anomaly_flag', 'NONE')
-
- is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text
-
- if is_present and has_1c_reason:
- reason_lower = reason_1c.lower()
- is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS)
-
- if not is_allowed_trip and not is_fio_whitelisted_in_kb:
- anomalies.append({
- "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
- "fio": fio,
- "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'"
- })
-
- if is_exc and not has_1c_reason:
- continue
-
- if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
- first_act = row.get('Первая_активность', '—')
- anomalies.append({
- "type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
- "fio": fio,
- "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}"
- })
-
- if scud_fios_set is not None:
- if fio_clean not in scud_fios_set and not has_1c_reason:
- anomalies.append({
- "type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO",
- "fio": fio,
- "details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)"
- })
-
- return anomalies
-
-
-def aggregate_scud_by_employee(df, debug=False):
- """Агрегирует проходы СКУД по уникальным сотрудникам."""
- if df is None or df.empty or 'fio_clean' not in df.columns:
- return df
-
- aggregated = []
- for fio_clean, group in df.groupby('fio_clean', sort=False):
- is_present = group['Пришел'].any() if 'Пришел' in group.columns else False
-
- if is_present and 'Пришел' in group.columns:
- present_rows = group[group['Пришел'] == True]
- best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict()
- else:
- best_row = group.iloc[0].to_dict()
-
- best_row['Пришел'] = is_present
- aggregated.append(best_row)
-
- return pd.DataFrame(aggregated)
+logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s")
def main():
- help_text = """
-Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai_v2)
-
-ПРИМЕРЫ ЗАПУСКА:
- python main.py -- Обычный дневной запуск
- python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite
- python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота
- python main.py -d -- Запуск в режиме расширенной отладки (DEBUG)
-"""
- parser = argparse.ArgumentParser(
- description=help_text,
- formatter_class=argparse.RawDescriptionHelpFormatter
- )
- parser.add_argument('-d', '--debug', action='store_true', help="Запуск в режиме отладки с выводом подробных логов")
- parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту")
- parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания")
-
+ parser = argparse.ArgumentParser(description="Модульный контроллинг СКУД ⟷ 1С")
+ parser.add_argument("-d", "--debug", action="store_true", help="Режим отладки")
+ parser.add_argument("--skip-export", action="store_true", help="Пропустить выгрузку СКУД из MS SQL")
+ parser.add_argument("--snapshot", type=str, default=None, help="ID снапшота для расчета")
args = parser.parse_args()
- DEBUG = args.debug
print("=" * 60)
- print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}")
+ print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG]' if args.debug else ''}")
print("=" * 60)
- init_db()
- kb_data = load_knowledge_base()
- kb_rules = kb_data.get("rules", [])
- exceptions_cfg = load_exceptions_config()
-
- if args.snapshot:
- snapshot_param = args.snapshot
- print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'")
- elif args.skip_export:
- snapshot_param = get_latest_snapshot_time()
- print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')")
+ now = datetime.now()
+ today_str = now.strftime("%d.%m.%Y")
+ # ⭐️ По понедельникам (weekday == 0) отчетом накануне является пятница (-3 дня)
+ if now.weekday() == 0:
+ yesterday_str = (now - timedelta(days=3)).strftime("%d.%m.%Y")
else:
- snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'")
+ yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
+ # [Этап 0] Выгрузка свежих данных СКУД
if not args.skip_export and not args.snapshot:
- try:
- run_export(save_xlsx=True, debug=DEBUG)
- except Exception as e:
- print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.")
+ print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
+ run_export(debug=args.debug)
else:
- print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
+ print("\n[0/5] Пропуск прямого экспорта СКУД из MS SQL (--skip-export)...")
- # ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА
+ # [Этап 0.5] Синхронизация файлов с шары 1С
if not args.snapshot and not args.skip_export:
+ print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
copy_1c_files_from_share()
-
- print("[1/5] Проверка актуальности и свежести входных данных...")
- is_valid, warnings, errors, has_today_1c = check_file_freshness()
-
- if warnings:
- print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---")
- for w in warnings:
- print(f" • {w}")
- print("-" * 45)
-
- if not is_valid:
- print("\n" + "!" * 60)
- print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!")
- for e in errors:
- print(f" {e}")
- print("!" * 60)
- sys.exit(1)
-
- print("[✓] Проверка доступности данных успешно пройдена!\n")
else:
- print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
- has_today_1c = True
+ print("\n[0.5/5] Пропуск синхронизации с шары (чтение локальных данных)...")
- print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
-
- # 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ
- if args.snapshot or args.skip_export:
- raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param)
-
- if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
- target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
- else:
- target_date_str = DATE_TODAY
+ # [Этап 1-2] Загрузка данных
+ print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
+ df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True)
+ df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False)
- dt_target = datetime.strptime(target_date_str, "%d.%m.%Y")
- if dt_target.weekday() == 0: # Понедельник -> Пятница
- dt_yesterday = dt_target - timedelta(days=3)
- else:
- dt_yesterday = dt_target - timedelta(days=1)
- yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y")
+ df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
+ df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
- print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}")
+ # [Этап 3] Детальный отчет за вчера
+ print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
+ df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
+ generate_detailed_excel(df_merged_yesterday, date_str=yesterday_str)
- raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
+ # [Этап 4] Сводка за сегодня
+ print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
+ df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
+ metrics_today = calculate_summary_metrics(df_merged_today)
+ anomalies_today = detect_registry_anomalies(df_merged_today)
+ generate_summary_excel(df_merged_today, date_str=today_str)
- # Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк)
- df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
- df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True)
- else:
- target_date_str = DATE_TODAY
- yesterday_date_str = DATE_YESTERDAY
+ # [Этап 5] Формирование Markdown-сводки через ИИ-аудитора
+ print(f"\n[5/5] Формирование Markdown-сводки через ИИ-аудитора (Ollama)...")
- raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param)
- raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None)
+ absent_explained = df_merged_today[
+ (df_merged_today['Пришел'] == False) &
+ (df_merged_today['Вид_отсутствия'].notna()) &
+ (~df_merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
+ ]
+ absent_unexplained = df_merged_today[
+ (df_merged_today['Пришел'] == False) &
+ (df_merged_today['Вид_отсутствия'].isna() | (df_merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
+ (df_merged_today.get('is_excluded', False) == False)
+ ]
- df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False)
- df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False)
-
- if df_staff_yesterday is not None:
- save_staff_to_db(df_staff_yesterday, yesterday_date_str)
- if df_absent_yesterday is not None:
- save_absences_to_db(df_absent_yesterday, yesterday_date_str)
-
- if df_staff_today is not None:
- save_staff_to_db(df_staff_today, target_date_str)
- if df_absent_today is not None:
- save_absences_to_db(df_absent_today, target_date_str)
-
- staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else []
- static_reasons_dict = load_static_reason_workers()
-
- # ============================================================
- # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ)
- # ============================================================
- print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
-
- if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns:
- raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False
-
- scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else []
-
- fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean)
- if fio_mapping_scud_y:
- raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(
- lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x
- )
-
- if df_absent_yesterday is not None and not df_absent_yesterday.empty:
- absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist()
- if absent_unrecognized_yesterday:
- fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean)
- if fio_mapping_absent_y:
- df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply(
- lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x
- )
-
- raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG)
-
- # ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ
- merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame())
-
- if not merged_yesterday.empty:
- if not raw_scud_yesterday_df.empty:
- if 'Подразделение' in raw_scud_yesterday_df.columns:
- raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение']
- elif 'department' in raw_scud_yesterday_df.columns:
- raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department']
- else:
- raw_scud_yesterday_df['department_scud'] = ''
-
- merged_yesterday = merged_yesterday.merge(
- raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
- on='fio_clean', how='left'
- )
-
- if 'Вид_отсутствия' in merged_yesterday.columns:
- merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия'])
-
- # Присоединяем отсутствия СТРОГО за дату yesterday_date_str
- if df_absent_yesterday is not None and not df_absent_yesterday.empty:
- merged_yesterday = merged_yesterday.merge(
- df_absent_yesterday[['fio_clean', 'Вид_отсутствия']],
- on='fio_clean',
- how='left'
- )
-
- if 'Пришел' not in merged_yesterday.columns:
- merged_yesterday['Пришел'] = False
- else:
- merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False)
-
- if 'Сотрудник' not in merged_yesterday.columns:
- merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean'])
-
- if static_reasons_dict:
- for fio_clean, reason_val in static_reasons_dict.items():
- mask_yesterday = (
- (merged_yesterday['Пришел'] == False) &
- (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
- (merged_yesterday['fio_clean'] == fio_clean)
- )
- merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val
-
- merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg)
- mask_exc_yesterday = (
- (merged_yesterday['Пришел'] == False) &
- (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
- (merged_yesterday.get('is_excluded', False) == True)
+ summary_md = generate_markdown_report(
+ merged_df=df_merged_today[df_merged_today.get('is_excluded', False) == False],
+ absent_explained=absent_explained,
+ absent_unexplained=absent_unexplained,
+ scud_present_but_absent_in_1c=pd.DataFrame(),
+ anomalies_list=anomalies_today,
+ date_str=today_str
)
- merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
+
+ os.makedirs("output", exist_ok=True)
+ md_file_path = f"output/Сводка_контроллинга_{today_str}.md"
+ with open(md_file_path, "w", encoding="utf-8") as f:
+ f.write(summary_md)
+ print(f"[✓] Текстовый отчет сохранен в: {md_file_path}")
- scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set()
- merged_yesterday['no_scud_pass'] = (
- (~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) &
- (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) &
- (merged_yesterday.get('is_excluded', False) == False)
- )
-
- anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set)
- save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str)
-
- filtered_yesterday = filter_report_dataframe(merged_yesterday)
- generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str)
-
- yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y")
- yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00")
- save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True)
- print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}")
-
- # ============================================================
- # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА)
- # ============================================================
- if not args.snapshot and not args.skip_export:
- save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False)
-
- if has_today_1c and df_staff_today is not None and df_absent_today is not None:
- print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...")
- staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist()
-
- if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns:
- raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False
-
- scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else []
- fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean)
- if fio_mapping_scud:
- raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply(
- lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x
- )
-
- absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist()
- if absent_unrecognized_today:
- fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean)
- if fio_mapping_absent:
- df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply(
- lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x
- )
-
- raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG)
-
- merged_today = df_staff_today.copy()
- if not raw_scud_today_df.empty:
- if 'Подразделение' in raw_scud_today_df.columns:
- raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение']
- elif 'department' in raw_scud_today_df.columns:
- raw_scud_today_df['department_scud'] = raw_scud_today_df['department']
- else:
- raw_scud_today_df['department_scud'] = ''
-
- merged_today = merged_today.merge(
- raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']],
- on='fio_clean', how='left'
- )
-
- if df_absent_today is not None and not df_absent_today.empty:
- merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left')
-
- if 'Пришел' not in merged_today.columns:
- merged_today['Пришел'] = False
- else:
- merged_today['Пришел'] = merged_today['Пришел'].fillna(False)
-
- if 'Сотрудник' not in merged_today.columns:
- merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean'])
-
- if static_reasons_dict:
- for fio_clean, reason_val in static_reasons_dict.items():
- mask_today = (
- (merged_today['Пришел'] == False) &
- (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
- (merged_today['fio_clean'] == fio_clean)
- )
- merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val
-
- merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg)
- mask_exc_today = (
- (merged_today['Пришел'] == False) &
- (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
- (merged_today.get('is_excluded', False) == True)
- )
- merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)'
-
- scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
- merged_today['no_scud_pass'] = (
- (~merged_today['fio_clean'].isin(scud_fios_today_set)) &
- (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
- (merged_today.get('is_excluded', False) == False)
- )
-
- scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set()
- anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set)
-
- mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df)
- if mass_failure_today and mass_failure_today.get("is_mass_failure"):
- print("\n" + "!" * 60)
- print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)")
- print(mass_failure_today["alert_text"])
- print("!" * 60 + "\n")
-
- save_anomalies_to_db(anomalies_list, target_date_str)
-
- is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False
- is_exc_today = merged_today.get('is_excluded', False) == True
-
- absent_explained = merged_today[
- (merged_today['Пришел'] == False) &
- (merged_today['Вид_отсутствия'].notna()) &
- (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
- ]
-
- absent_unexplained = merged_today[
- (merged_today['Пришел'] == False) &
- (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) &
- (~is_no_pass_today) &
- (~is_exc_today)
- ]
-
- scud_present_but_absent_in_1c = merged_today[
- (~is_exc_today) &
- (merged_today['Пришел'] == True) &
- (merged_today['Вид_отсутствия'].notna()) &
- (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение'))
- ]
-
- print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...")
- filtered_anomalies_list = [
- a for a in anomalies_list
- if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '')
- ]
-
- report_text = generate_markdown_report(
- merged_df=merged_today,
- absent_explained=absent_explained,
- absent_unexplained=absent_unexplained,
- scud_present_but_absent_in_1c=scud_present_but_absent_in_1c,
- anomalies_list=filtered_anomalies_list,
- raw_scud_df=raw_scud_today_df,
- raw_absent_df=df_absent_today,
- date_str=target_date_str
- )
-
- generate_summary_excel(merged_df=merged_today, date_str=target_date_str)
-
- md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md")
- with open(md_report_path, "w", encoding="utf-8") as f:
- f.write(report_text)
- print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}")
-
- suspicious_cases = []
- for a in anomalies_list:
- if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''):
- suspicious_cases.append({
- 'fio_target': a['fio'],
- 'reason': f"{a['type']}: {a['details']}"
- })
-
- if suspicious_cases:
- review_ai_decisions(report_text, suspicious_cases)
-
- print("\n" + "=" * 60)
- print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
- print("=" * 60)
- print(report_text)
- else:
- print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.")
+ print("\n" + "=" * 60)
+ print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:")
+ print("=" * 60)
+ print(summary_md)
if __name__ == "__main__":
diff --git a/modules/ROADMAP.md b/modules/ROADMAP.md
new file mode 100644
index 0000000..65cbe6d
--- /dev/null
+++ b/modules/ROADMAP.md
@@ -0,0 +1,56 @@
+# 🗺️ Дорожная карта задач проекта (ROADMAP)
+
+> **Сформировано:** 2026-08-19 18:40 | **Всего задач:** 16
+
+---
+
+## Модуль `api`
+
+- [x] **#35** [HIGH] `[ЗАВЕРШЕНО]` Создание REST API на FastAPI (/api/v1/snapshots, /api/v1/scud/logs, /api/v1/anomalies)
+
+---
+
+## Модуль `core/database`
+
+- [x] **#22** [HIGH] `[ЗАВЕРШЕНО]` Перевод подключения 1С:ЗУП на продакшн MS SQL сервер ACCOUNT-01 + гибридный фоллбэк
+- [x] **#37** [MEDIUM] `[ЗАВЕРШЕНО]` Создание модуля персистентной памяти проекта в SQLite и CLI-инструмента для фиксации архитектурных решений
+
+---
+
+## Модуль `general`
+
+- [x] **#38** [HIGH] `[ЗАВЕРШЕНО]` По запросу, вывести возможности системы, которые уже настроены. Например на запрос о возможностях по СКУД. И изменить стартовую плашку.
+- [ ] **#41** [MEDIUM] Полностью отказаться от tool_injector.py и перевести все вызовы на нативный Function Calling языковой модели. LLM будет основным арбитром *(срок: 2026-08-21)*
+- [ ] **#44** [LOW] Тестовая-3 *(срок: 2026-08-20)*
+
+---
+
+## Модуль `scripts`
+
+- [x] **#26** [MEDIUM] `[ЗАВЕРШЕНО]` Создание и выполнение скрипта сквозной переиндексации снапшотов с префиксом Y
+- [x] **#27** [MEDIUM] `[ЗАВЕРШЕНО]` Реализация CLI-инспектора БД и выравнивание номеров срезов для идеальной верстки в терминале
+- [ ] **#33** [MEDIUM] Автоматизация сбора срезов по расписанию (Cron / Systemd Timers на 12:00, 17:00, 19:00)
+
+---
+
+## Модуль `services/ai_verifier`
+
+- [x] **#28** [HIGH] `[ЗАВЕРШЕНО]` Двойная проверка исключений по department_scud и 1С, удержание 100% приоритета кадровых документов
+
+---
+
+## Модуль `services/scud_export`
+
+- [x] **#23** [HIGH] `[ЗАВЕРШЕНО]` Инцидент Равина В.Э.: Фиксация 100% приоритета сырых логов pLogData СКУД над расчетом УРВ Ориона
+- [x] **#25** [HIGH] `[ЗАВЕРШЕНО]` Исправление бага дублирования номеров снапшотов (переход на вычисление MAX() по суффиксу)
+- [x] **#29** [LOW] `[ЗАВЕРШЕНО]` Лаконичный вывод неразмеченных сотрудников с короткими аббревиатурами отделов СКУД (РУК, ОИЗ, ПУ и др.)
+- [ ] **#30** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Извлечение полных цепочек проходов pLogData (входы/выходы за смену)
+- [ ] **#31** [HIGH] `[В РАБОТЕ]` Внутридневной контроль: Расчет количества и суммарной длительности отлучек/перекуров сверх обеда
+
+---
+
+## Модуль `services/text_reporter`
+
+- [ ] **#32** [MEDIUM] `[В РАБОТЕ]` Внутридневной контроль: Вывод списка системных нарушителей внутреннего распорядка в Markdown-отчет
+
+---
diff --git a/modules/ai_engine/__init__.py b/modules/ai_engine/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/modules/ai_engine/agent.py b/modules/ai_engine/agent.py
new file mode 100644
index 0000000..16f55fb
--- /dev/null
+++ b/modules/ai_engine/agent.py
@@ -0,0 +1,178 @@
+"""
+===============================================================================
+FILE: modules/ai_engine/agent.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: modules / ai_engine
+ROLE: Лаконичный нативный оркестратор Function Calling, диспетчер handlers
+ и менеджер свободных диалогов (Topic Drift).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[AGENT_PIPELINE_ENTRY]: Главная точка входа process_chat_message.
+===============================================================================
+"""
+
+import sys
+import json
+import logging
+from typing import List, Dict, Any, Tuple, Optional
+
+from modules.web_api.llm.schemas import TOOLS_SCHEMA
+from modules.web_api.llm.core.ollama_client import call_ollama_chat
+from modules.web_api.llm.core.fast_path import handle_fast_path_intercept
+from modules.web_api.llm.core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
+from modules.web_api.llm.core.context_manager import mark_last_user_message_ephemeral, close_tool_session_and_cleanup
+from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
+from modules.web_api.llm.db.db_prompts import (
+ db_get_session_state, db_clear_session_state, db_set_session_state,
+ db_get_stats, db_get_anomalies, db_get_reference
+)
+from services.knowledge.service import get_rules
+
+from .context_builder import build_agent_system_context
+from .handlers.task_handler import handle_tasks_call
+from .handlers.prompt_handler import handle_prompt_call
+from .handlers.snapshot_handler import handle_snapshots_call
+
+logger = logging.getLogger("AI_AGENT")
+logger.setLevel(logging.INFO)
+
+
+# ANCHOR[AGENT_PIPELINE_ENTRY]
+def process_chat_message(
+ user_id: int,
+ user_message: str,
+ file_context: str = "",
+ image_b64: Optional[str] = None,
+ chat_history: List[Dict[str, Any]] = None,
+ session_id: str = "web_session_main"
+) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
+ """Главный конвейер обработки сообщений чата."""
+ logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
+
+ session_state = db_get_session_state(session_id)
+ if not session_state:
+ db_purge_ephemeral_messages(session_id)
+
+ full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
+
+ # 1. Быстрый Fast-Path перехват кнопок подтверждения
+ fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
+ if fast_path_res:
+ return fast_path_res
+
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
+ db_history = db_get_chat_history(session_id, limit=20)
+ system_prompt = build_agent_system_context(user_id, session_state)
+
+ user_msg_obj = {"role": "user", "content": full_user_content}
+
+ try:
+ if image_b64:
+ user_msg_obj["images"] = [image_b64]
+ messages = [{"role": "system", "content": "Строгий модуль OCR. Перепиши весь текст буква в букву."}, user_msg_obj]
+ msg = call_ollama_chat(messages, is_vision=True)
+ else:
+ clean_history = [dict(m) for m in db_history]
+ for m in clean_history: m.pop("images", None)
+ messages = [{"role": "system", "content": system_prompt}] + clean_history + [user_msg_obj]
+ msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
+
+ raw_reply = msg.get("content", "")
+ tool_calls = msg.get("tool_calls", [])
+
+ # 2. Гибридный семантический классификатор намерений (Fallback Safety Net)
+ tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls)
+
+ # 3. Исполнение инструментов через изолированные handlers
+ if tool_calls:
+ tool = tool_calls[0]
+ fn_name = tool["function"]["name"]
+ fn_args = tool["function"].get("arguments", {})
+ if isinstance(fn_args, str):
+ try: fn_args = json.loads(fn_args)
+ except Exception: fn_args = {}
+
+ logger.info(f"Вызов инструмента: {fn_name} с аргументами: {fn_args}")
+ close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
+ mark_last_user_message_ephemeral(session_id)
+
+ state_data = session_state.get("data_json") or {} if session_state else {}
+
+ if fn_name in ["db_get_tasks", "db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
+ return handle_tasks_call(fn_name, fn_args, user_id, session_id)
+
+ elif fn_name in ["db_get_system_prompt", "db_prompt_node_edit"]:
+ return handle_prompt_call(fn_name, fn_args, session_id)
+
+ elif fn_name in ["db_get_snapshots", "db_delete_snapshots"]:
+ return handle_snapshots_call(fn_name, fn_args, session_id, user_message, state_data)
+
+ elif fn_name == "db_get_rules":
+ res_str = json.dumps(get_rules(), ensure_ascii=False)
+ elif fn_name == "db_get_stats":
+ res_str = json.dumps(db_get_stats(), ensure_ascii=False)
+ elif fn_name == "db_get_anomalies":
+ res_str = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
+ elif fn_name == "db_get_reference":
+ res_str = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
+ else:
+ res_str = "{}"
+
+ messages.append(msg)
+ messages.append({"role": "tool", "content": res_str})
+ sec_msg = call_ollama_chat(messages, is_vision=False)
+ final_content = clean_raw_tool_tags(clean_output(sec_msg.get("content", ""))) or "Запрос выполнен."
+ db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
+ return final_content, db_get_chat_history(session_id), None
+
+ # 4. Обычный содержательный диалог и управление Topic Drift
+ final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан."
+ for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто,", "почемучто", "почему-то"]:
+ if final_reply.lower().startswith(artifact):
+ final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
+
+ action_payload = None
+
+ # Обработка ответа "нет / спасибо" в режиме открытого инструмента
+ if session_state and any(kw in user_message.lower() for kw in ["нет", "спасибо", "не надо", "готово", "хватит"]):
+ close_tool_session_and_cleanup(session_id, close_reason="USER_DISMISSED_TOOL")
+ db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
+ return final_reply, db_get_chat_history(session_id), None
+
+ # Инкремент счётчика шагов в сторону от инструмента (Topic Drift)
+ if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW", "SNAPSHOTS_VIEW"]:
+ state_type = session_state.get("state_type")
+ state_data = session_state.get("data_json") or {}
+ if not isinstance(state_data, dict):
+ state_data = {}
+
+ idle_turns = state_data.get("idle_turns", 0) + 1
+ state_data["idle_turns"] = idle_turns
+
+ if idle_turns >= 4:
+ # 4-й шаг не по теме: бесшумно закрываем сессию и вычищаем эфемерные карточки
+ close_tool_session_and_cleanup(session_id, close_reason="TOPIC_DRIFT_TIMEOUT")
+ elif idle_turns == 3:
+ # 3-й шаг: выводим вежливое напоминание с кнопками
+ tool_label = "системным промптом" if "PROMPT" in state_type else "снапшотами СКУД"
+ guard_question = f"Желаете продолжить работу с {tool_label}?"
+ final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
+ action_payload = {
+ "type": "FOLLOW_UP_ACTION",
+ "buttons": [
+ {"label": "Показать снова", "value": "покажи системный промпт" if "PROMPT" in state_type else "покажи снапшоты", "style": "primary"},
+ {"label": "Завершить", "value": "нет, спасибо", "style": "secondary"}
+ ]
+ }
+ db_set_session_state(session_id, state_type, state_data)
+ else:
+ # 1-й и 2-й шаг: фиксируем обновленный счётчик
+ db_set_session_state(session_id, state_type, state_data)
+
+ is_ephem_reply = 1 if "актуальный системный промпт:" in final_reply.lower() else 0
+ db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=is_ephem_reply)
+ return final_reply, db_get_chat_history(session_id), action_payload
+
+ except Exception as ex:
+ logger.exception(f"Ошибка в агенте: {ex}")
+ return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None
\ No newline at end of file
diff --git a/modules/ai_engine/context_builder.py b/modules/ai_engine/context_builder.py
new file mode 100644
index 0000000..5b0fd48
--- /dev/null
+++ b/modules/ai_engine/context_builder.py
@@ -0,0 +1,84 @@
+"""
+===============================================================================
+FILE: modules/ai_engine/context_builder.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: modules / ai_engine
+ROLE: Динамическая сборка системного промпта из БД, календаря и контекста сессий.
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[CONTEXT_BUILDER_MAIN]: Формирование полного системного контекста для LLM.
+===============================================================================
+"""
+
+from typing import Dict, Any, Optional
+from services.prompts.service import get_active_system_prompt
+from modules.web_api.llm.core.calendar_utils import get_dynamic_calendar_context
+
+
+# ANCHOR[CONTEXT_BUILDER_MAIN]
+def build_agent_system_context(
+ user_id: int,
+ session_state: Optional[Dict[str, Any]]
+) -> str:
+ """Формирует полный динамический системный контекст агента с правилами из БД."""
+
+ # 1. Загружаем актуальный базовый системный промпт из SQLite БД
+ base_system_prompt = get_active_system_prompt()
+
+ # 2. Серверный календарь и контекст дат
+ calendar_context = get_dynamic_calendar_context()
+ user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
+
+ # 3. Контекст текущего активного стейта сессии
+ current_state_type = session_state.get("state_type") if session_state else None
+ state_data = session_state.get("data_json") or {} if session_state else {}
+ if not isinstance(state_data, dict):
+ state_data = {}
+
+ active_state_context = ""
+ if current_state_type == "PROMPT_PREVIEW":
+ active_state_context = (
+ "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
+ "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
+ )
+ elif current_state_type == "PROMPT_FOLLOWUP":
+ active_state_context = (
+ "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
+ "- Оператор просматривает или редактирует системный промпт.\n"
+ "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
+ "- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
+ "- Запрещено путать ADD и DELETE.\n"
+ )
+ elif current_state_type == "SNAPSHOTS_VIEW":
+ active_date = state_data.get("query_date", "выбранную дату")
+ active_state_context = (
+ f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n"
+ f"- Сейчас на экране отображаются снапшоты за {active_date}.\n"
+ f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n"
+ f"- Если оператор запрашивает ДРУГУЮ дату или день недели (например: 'за вчера', 'а за 13.08') — "
+ f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n"
+ f"- Запрещено генерировать текст за другую дату по памяти.\n"
+ )
+ elif current_state_type == "SNAPSHOT_DELETE_CONFIRM":
+ active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n"
+ elif current_state_type == "TASK_DELETE_CONFIRM":
+ active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n"
+
+ # 4. Сборка полного системного сообщения
+ return (
+ f"{base_system_prompt}\n\n"
+ f"СТРОГИЕ ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ:\n"
+ f"1. Для системного промпта:\n"
+ f" - 'добавь X.Y Текст' -> СРАЗУ вызывай db_prompt_node_edit(action='ADD', section_id=X, item_id=Y, content='Текст').\n"
+ f" - 'удали X.Y' -> СРАЗУ вызывай db_prompt_node_edit(action='DELETE', section_id=X, item_id=Y, content='').\n"
+ f" - 'покажи системный промпт' -> СРАЗУ вызывай db_get_system_prompt().\n"
+ f"2. Для задач:\n"
+ f" - 'удали задачу N' -> СРАЗУ вызывай db_tasks_edit(action='DELETE', task_id='N').\n"
+ f" - 'возьми в работу N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='IN_PROGRESS').\n"
+ f" - 'заверши N' / 'готово N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='COMPLETED').\n"
+ f"3. Запрещено задавать вопросы и писать подтверждения текстом — СРАЗУ вызывай соответствующий инструмент!\n\n"
+ f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
+ f"- Пользователь: {user_info}\n"
+ f"- {calendar_context}\n"
+ f"{active_state_context}"
+ )
\ No newline at end of file
diff --git a/modules/ai_engine/handlers/__init__.py b/modules/ai_engine/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/modules/ai_engine/handlers/prompt_handler.py b/modules/ai_engine/handlers/prompt_handler.py
new file mode 100644
index 0000000..02ca6a8
--- /dev/null
+++ b/modules/ai_engine/handlers/prompt_handler.py
@@ -0,0 +1,98 @@
+"""
+===============================================================================
+FILE: modules/ai_engine/handlers/prompt_handler.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: modules / ai_engine / handlers
+ROLE: Изолированная обработка команд управления системным промптом.
+===============================================================================
+"""
+
+from typing import Dict, Any, Tuple, Optional
+from services.prompts.service import get_active_system_prompt, create_prompt_preview
+from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
+from modules.web_api.llm.db.db_prompts import db_set_session_state
+
+
+# ANCHOR[PROMPT_HANDLER_DISPATCH]
+def handle_prompt_call(
+ fn_name: str,
+ fn_args: Dict[str, Any],
+ session_id: str
+) -> Tuple[str, list, Optional[Dict[str, Any]]]:
+ """Обрабатывает вызовы просмотра и изменения системного промпта."""
+
+ # 1. Просмотр промпта с кнопкой быстрого перехода в редактор
+ if fn_name == "db_get_system_prompt":
+ active_prompt = get_active_system_prompt()
+ reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
+
+ # Сохраняем состояние сессии для возможности мгновенного редактирования и подтверждения
+ db_set_session_state(session_id, "PROMPT_PREVIEW", {
+ "draft_text": active_prompt,
+ "action": "MANUAL_EDIT",
+ "idle_turns": 0
+ })
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "PROMPT_PREVIEW",
+ "raw_draft": active_prompt,
+ "baseline_prompt": active_prompt,
+ "buttons": [
+ {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"},
+ {"label": "Готово", "value": "нет, спасибо", "style": "secondary"}
+ ]
+ }
+
+ # 2. Предпросмотр точечных или пакетных изменений (ADD / EDIT / DELETE / BATCH_DELETE)
+ action = str(fn_args.get("action", "ADD")).upper()
+ nodes_list = fn_args.get("nodes_list", [])
+ delete_nodes_tuples = []
+
+ if nodes_list:
+ for n_str in nodes_list:
+ parts = str(n_str).strip().split(".")
+ if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
+ delete_nodes_tuples.append((int(parts[0]), int(parts[1])))
+
+ sec_id = None
+ itm_id = None
+ try:
+ if fn_args.get("section_id") is not None:
+ sec_id = int(str(fn_args.get("section_id")).strip())
+ if fn_args.get("item_id") is not None:
+ itm_id = int(str(fn_args.get("item_id")).strip())
+ except Exception:
+ pass
+
+ content = str(fn_args.get("content", "")).strip()
+
+ merged_prompt, diff_html, baseline_prompt = create_prompt_preview(
+ action=action,
+ section_id=sec_id,
+ item_id=itm_id,
+ content=content,
+ delete_nodes=delete_nodes_tuples if delete_nodes_tuples else None
+ )
+
+ db_set_session_state(session_id, "PROMPT_PREVIEW", {
+ "draft_text": merged_prompt,
+ "action": "MANUAL_EDIT",
+ "section_id": sec_id,
+ "item_id": itm_id,
+ "content": content,
+ "idle_turns": 0
+ })
+
+ preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
+ db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
+ return preview_reply, db_get_chat_history(session_id), {
+ "type": "PROMPT_PREVIEW",
+ "raw_draft": merged_prompt,
+ "baseline_prompt": baseline_prompt,
+ "buttons": [
+ {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
+ {"label": "Отменить", "value": "отмена", "style": "danger"},
+ {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
+ ]
+ }
\ No newline at end of file
diff --git a/modules/ai_engine/handlers/snapshot_handler.py b/modules/ai_engine/handlers/snapshot_handler.py
new file mode 100644
index 0000000..3b7b425
--- /dev/null
+++ b/modules/ai_engine/handlers/snapshot_handler.py
@@ -0,0 +1,91 @@
+"""
+===============================================================================
+FILE: modules/ai_engine/handlers/snapshot_handler.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: modules / ai_engine / handlers
+ROLE: Изолированная обработка запросов просмотра и удаления срезов СКУД.
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[SNAPSHOT_HANDLER_DISPATCH]: Обработка db_get_snapshots и db_delete_snapshots.
+===============================================================================
+"""
+
+from typing import Dict, Any, Tuple, Optional, List
+from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
+from modules.web_api.llm.core.calendar_utils import parse_relative_date_ru
+from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
+from modules.web_api.llm.db.db_prompts import db_set_session_state
+
+
+# ANCHOR[SNAPSHOT_HANDLER_DISPATCH]
+def handle_snapshots_call(
+ fn_name: str,
+ fn_args: Dict[str, Any],
+ session_id: str,
+ user_message: str,
+ state_data: Dict[str, Any]
+) -> Tuple[str, list, Optional[Dict[str, Any]]]:
+ """Обрабатывает запросы реестра и безопасного удаления срезов."""
+
+ # 1. Получение срезов
+ if fn_name == "db_get_snapshots":
+ date_param = fn_args.get("date_str")
+ if not date_param and user_message:
+ date_param = parse_relative_date_ru(user_message)
+
+ snapshots_res = get_snapshots_registry(date_str=date_param)
+ query_date = snapshots_res.get("query_date", "выбранную дату")
+
+ db_set_session_state(session_id, "SNAPSHOTS_VIEW", snapshots_res)
+ reply_text = f"Реестр срезов СКУД за {query_date}:"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "SNAPSHOTS_CARD",
+ "data": snapshots_res
+ }
+
+ # 2. Удаление срезов (двухфазное подтверждение)
+ raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
+ raw_ids = fn_args.get("snapshot_ids") or []
+ is_confirmed = fn_args.get("confirmed", False)
+
+ if raw_id and not raw_ids:
+ if isinstance(raw_id, str) and "," in raw_id:
+ raw_ids = [s.strip() for s in raw_id.split(",")]
+ else:
+ raw_ids = [raw_id]
+
+ safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
+ if not safe_ids:
+ reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), None
+
+ if not is_confirmed:
+ query_date = state_data.get("query_date", "")
+ db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
+ "snapshot_ids": safe_ids,
+ "query_date": query_date,
+ "idle_turns": 0
+ })
+ ids_str = ", ".join(safe_ids)
+ reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "SNAPSHOT_DELETE_CONFIRM",
+ "buttons": [
+ {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
+ {"label": "Отмена", "value": "отмена", "style": "secondary"}
+ ]
+ }
+ else:
+ delete_snapshots_safely(snapshot_ids=safe_ids)
+ query_date = state_data.get("query_date", "")
+ updated_data = get_snapshots_registry(date_str=query_date)
+
+ reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "SNAPSHOTS_CARD",
+ "data": updated_data
+ }
\ No newline at end of file
diff --git a/modules/ai_engine/handlers/task_handler.py b/modules/ai_engine/handlers/task_handler.py
new file mode 100644
index 0000000..f5d7e39
--- /dev/null
+++ b/modules/ai_engine/handlers/task_handler.py
@@ -0,0 +1,97 @@
+"""
+===============================================================================
+FILE: modules/ai_engine/handlers/task_handler.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: modules / ai_engine / handlers
+ROLE: Изолированная обработка вызовов инструментов задач (get, edit, delete, export).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[TASK_HANDLER_DISPATCH]: Обработка вызовов db_get_tasks и db_tasks_edit.
+===============================================================================
+"""
+
+from typing import Dict, Any, Tuple, Optional
+from services.tasks.service import get_tasks, execute_task_action
+from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history
+from modules.web_api.llm.db.db_prompts import db_set_session_state
+
+
+# ANCHOR[TASK_HANDLER_DISPATCH]
+def handle_tasks_call(
+ fn_name: str,
+ fn_args: Dict[str, Any],
+ user_id: int,
+ session_id: str
+) -> Tuple[str, list, Optional[Dict[str, Any]]]:
+ """Обрабатывает нативные tool-вызовы по задачам."""
+
+ # 1. Просмотр задач
+ if fn_name == "db_get_tasks":
+ raw_tasks = get_tasks(user_id, status=fn_args.get("status"))
+ reply_text = "Вот интерактивный список ваших текущих задач:"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "TASK_INTERACTIVE_CARD",
+ "tasks": raw_tasks
+ }
+
+ # 2. Модификация / Удаление / Экспорт
+ action = (fn_args.get("action") or "UPDATE").upper()
+ if fn_name == "db_add_task": action = "ADD"
+ elif fn_name == "db_delete_task": action = "DELETE"
+ elif fn_name == "db_update_task_status": action = "UPDATE"
+
+ # Двухфазное подтверждение удаления
+ if action == "DELETE":
+ task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
+ db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
+ reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "TASK_DELETE_CONFIRM",
+ "buttons": [
+ {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
+ {"label": "Отмена", "value": "отмена", "style": "secondary"}
+ ]
+ }
+
+ # Экспорт в Markdown
+ elif action == "EXPORT":
+ export_res = execute_task_action(
+ user_id=user_id,
+ action="EXPORT",
+ filename=fn_args.get("filename"),
+ status=fn_args.get("status")
+ )
+ reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
+
+ action_payload = None
+ if export_res.get("status") == "success":
+ action_payload = {
+ "type": "FILE_DOWNLOAD_CARD",
+ "filename": export_res.get("filename"),
+ "download_url": export_res.get("download_url"),
+ "tasks_count": export_res.get("tasks_count")
+ }
+ return reply_text, db_get_chat_history(session_id), action_payload
+
+ # Создание и обновление
+ else:
+ res = execute_task_action(
+ user_id=user_id,
+ action=action,
+ task_id=fn_args.get("task_id"),
+ title=fn_args.get("title"),
+ priority=fn_args.get("priority", "MEDIUM"),
+ status=fn_args.get("status"),
+ module=fn_args.get("module", "general"),
+ due_date=fn_args.get("due_date")
+ )
+ raw_tasks = get_tasks(user_id)
+ reply_text = res.get("message", "Операция над задачами выполнена.")
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "TASK_INTERACTIVE_CARD",
+ "tasks": raw_tasks
+ }
\ No newline at end of file
diff --git a/modules/web_api/llm/agent.py b/modules/web_api/llm/agent.py
index a341b8b..575b4c1 100644
--- a/modules/web_api/llm/agent.py
+++ b/modules/web_api/llm/agent.py
@@ -3,18 +3,22 @@
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
-ROLE: Оркестратор диалога, диспетчер реляционных узлов промпта,
- управление сессионными стейтами, Topic Drift Guard и очистка контекста.
+ROLE: Нативный оркестратор диалога, диспетчер инструментов (Function Calling)
+ и управление сессионными стейтами через ContextManager.
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[AGENT_IMPORTS]: Системные и доменные импорты.
+ - ANCHOR[AGENT_MAIN_PIPELINE]: Основная точка входа process_chat_message.
+ - ANCHOR[AGENT_SYSTEM_PROMPT]: Формирование динамического контекста.
+ - ANCHOR[AGENT_TOOL_DISPATCHER]: Исполнение нативных вызовов инструментов.
+ - ANCHOR[AGENT_TOPIC_DRIFT]: Защита контекста и обработка свободных тем.
===============================================================================
"""
-# =============================================================================
-# БЛОК 1: ИМПОРТЫ И ИНИЦИАЛИЗАЦИЯ СИСТЕМНЫХ МОДУЛЕЙ
-# =============================================================================
+# ANCHOR[AGENT_IMPORTS]
import sys
import json
import logging
-import re
from typing import List, Dict, Any, Tuple, Optional
from .db.connection import get_db_connection
@@ -26,6 +30,8 @@ from .db_tools import (
db_update_task_status,
db_delete_task,
db_add_task,
+ db_tasks_edit,
+ db_export_tasks_markdown,
db_get_rules,
db_set_session_state,
db_get_session_state,
@@ -46,6 +52,12 @@ from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
+from .core.context_manager import (
+ save_tool_interaction,
+ save_dialog_interaction,
+ mark_last_user_message_ephemeral,
+ close_tool_session_and_cleanup
+)
logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO)
@@ -58,9 +70,7 @@ if not logger.handlers:
logger.addHandler(handler)
-# =============================================================================
-# БЛОК 2: ГЛАВНЫЙ КОНВЕЙЕР ОБРАБОТКИ СООБЩЕНИЙ ЧАТА
-# =============================================================================
+# ANCHOR[AGENT_MAIN_PIPELINE]
def process_chat_message(
user_id: int,
user_message: str,
@@ -70,40 +80,29 @@ def process_chat_message(
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""
- Главная функция обработки сообщения:
- 1. Очищает осиротевшие эфемерные сообщения.
- 2. Перехватывает быстрые кнопки (Fast-Path).
- 3. Собирает контекст и передает управление Ollama.
- 4. Выполняет Tool Calls и управляет счетчиком Topic Drift.
+ Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
"""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
- # 🧹 ГАРБАДЖ-КОЛЛЕКТОР: Если стейт сессии пуст, удаляем висящие эфемерные сообщения
session_state = db_get_session_state(session_id)
if not session_state:
db_purge_ephemeral_messages(session_id)
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
- # Перехват нажатия кнопок («Подтвердить», «Отменить», «Завершить») без вызова LLM
+ # 1. Быстрый перехват строго системных кнопок UI (подтверждение превью промпта)
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
- # Сохранение входящего сообщения оператора
- is_user_ephemeral = 1 if session_state else 0
- db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=is_user_ephemeral)
+ # 2. Фиксация сообщения пользователя
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_history = db_get_chat_history(session_id, limit=20)
-
- # =========================================================================
- # БЛОК 3: ДИНАМИЧЕСКАЯ СБОРКА СИСТЕМНОГО ИНСТРУКТАЖА
- # =========================================================================
- dynamic_prompt_text = db_get_active_system_prompt()
+ # ANCHOR[AGENT_SYSTEM_PROMPT]
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
- # Извлечение текущего состояния и счетчика отвлечений (idle_turns)
current_state_type = session_state.get("state_type") if session_state else None
state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict):
@@ -113,47 +112,61 @@ def process_chat_message(
active_state_context = ""
if current_state_type == "PROMPT_PREVIEW":
active_state_context = (
- "\n[ВНИМАНИЕ: АКТИВНЫЙ РЕЖИМ ПРЕДПРОСМОТРА ПРОМПТА]\n"
- "- Сейчас открыт предпросмотр изменения системного промпта.\n"
- "- Если пользователь просит изменить, скорректировать формулировку или удалить пункт — вызови инструмент db_prompt_node_edit.\n"
- "- Если пользователь переключился на другую тему — ответь на его вопрос кратко и по делу.\n"
+ "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n"
+ "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n"
)
elif current_state_type == "PROMPT_FOLLOWUP":
active_state_context = (
- "\n[ВНИМАНИЕ: АКТИВНА СЕССИЯ РЕДАКТИРОВАНИЯ СИСТЕМНОГО ПРОМПТА]\n"
- "- Оператор только что применил предыдущее изменение или запросил просмотр промпта.\n"
- "- Любые команды вида 'добавь X.Y', 'удали X.Y', 'измени X.Y' ОЗНАЧАЮТ ПРОДОЛЖЕНИЕ РАБОТЫ С СИСТЕМНЫМ ПРОМПТОМ -> ВЫЗЫВАЙ db_prompt_node_edit.\n"
- "- Если запрос оператора не ясен или относится к другой теме — ответь на него естественно.\n"
+ "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n"
+ "- Оператор просматривает или редактирует системный промпт.\n"
+ "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n"
+ "- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n"
+ "- Запрещено путать ADD и DELETE.\n"
+ )
+ elif current_state_type == "SNAPSHOTS_VIEW":
+ active_date = state_data.get("query_date", "выбранную дату")
+ active_state_context = (
+ f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n"
+ f"- Сейчас на экране отображаются снапшоты за {active_date}.\n"
+ f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n"
+ f"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {active_date} (например: 'за вчера', 'а за 13.08', 'покажи за сегодня') — "
+ f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n"
+ f"- Запрещено генерировать текст за другую дату по памяти.\n"
+ )
+ elif current_state_type == "SNAPSHOT_DELETE_CONFIRM":
+ active_state_context = (
+ "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n"
+ )
+ elif current_state_type == "TASK_DELETE_CONFIRM":
+ active_state_context = (
+ "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n"
)
system_prompt_content = (
- f"Ты — интеллектуальный ассистент SCUD Orion AI. "
- f"Ты управляешь задачами, аналитикой СКУД и системными настройками с помощью инструментов (tools).\n\n"
- f"СТРОГИЕ ПРАВИЛА СТИЛЯ:\n"
- f"- Запрещено использовать панибратские или шутливые обращения. Отвечай профессионально и строго по существу.\n\n"
- f"[ОКРУЖЕНИЕ]\n"
+ f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
+ f"Ты управляешь задачами, аналитикой СКУД и системными настройками исключительно через инструменты (tools).\n\n"
+ f"СТРОГИЕ ПРАВИЛА:\n"
+ f"1. К оператору всегда обращайся на Вы.\n"
+ f"2. Для получения данных всегда вызывай соответствующий инструмент:\n"
+ f" - Снапшоты и срезы логов СКУД за любые даты и дни недели -> db_get_snapshots\n"
+ f" - Удаление снапшотов -> db_delete_snapshots\n"
+ f" - Задачи и бэклог (просмотр, создание, смена статуса, удаление, экспорт) -> db_get_tasks, db_tasks_edit\n"
+ f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
+ f" - Аномалии СКУД/1С -> db_get_anomalies\n"
+ f" - База знаний -> db_get_rules\n"
+ f" - Статистика БД -> db_get_stats\n"
+ f" - Справка -> db_get_reference\n"
+ f"3. Запрещено сочинять данные от себя без вызова инструментов.\n\n"
+ f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
- f"{active_state_context}\n"
- f"[ПРАВИЛА ИСПОЛЬЗОВАНИЯ ИНСТРУМЕНТОВ]\n"
- f"1. Для ЛЮБЫХ операций с системным промптом ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_prompt_node_edit:\n"
- f" - 'удали 2.9' / 'удалить пункт 2.9' -> ВЫЗОВ db_prompt_node_edit(action='DELETE', section_id=2, item_id=9, content='')\n"
- f" - 'добавь 3.4 Текст' / 'добавить пункт 3.4' -> ВЫЗОВ db_prompt_node_edit(action='ADD', section_id=3, item_id=4, content='Текст')\n"
- f" - 'измени 1.2 Текст' -> ВЫЗОВ db_prompt_node_edit(action='UPDATE', section_id=1, item_id=2, content='Текст')\n"
- f" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО отвечать текстом вроде 'Удален пункт... Напишите подтверждаю'. Только Function Call!\n"
- f"2. Для просмотра системного промпта ВСЕГДА вызывай db_get_system_prompt().\n"
- f"3. Для просмотра задач ВСЕГДА вызывай db_get_tasks().\n\n"
- f"[ТЕКУЩИЙ АКТИВНЫЙ СИСТЕМНЫЙ ПРОМПТ]:\n{dynamic_prompt_text}"
+ f"{active_state_context}"
)
user_msg_object = {"role": "user", "content": full_user_content}
-
- # =========================================================================
- # БЛОК 4: ВЫЗОВ НЕЙРОСЕТИ И МАРШРУТИЗАЦИЯ FUNCTION CALLING
- # =========================================================================
+ # ANCHOR[AGENT_TOOL_DISPATCHER]
try:
- # Режим Vision (OCR)
if image_b64:
user_msg_object["images"] = [image_b64]
messages = [
@@ -162,7 +175,6 @@ def process_chat_message(
]
msg = call_ollama_chat(messages, is_vision=True)
else:
- # Текстовый диалог
clean_db_history = [dict(m) for m in db_history]
for m in clean_db_history:
m.pop("images", None)
@@ -171,241 +183,329 @@ def process_chat_message(
raw_text_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", [])
- tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
- # Исполнение вызванных моделью инструментов
+ if not tool_calls:
+ tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
+
if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
- messages.append(msg)
- tool_names_called = []
+
+ tool = tool_calls[0]
+ fn_name = tool["function"]["name"]
+ fn_args = tool["function"].get("arguments", {})
+ if isinstance(fn_args, str):
+ try:
+ fn_args = json.loads(fn_args)
+ except Exception:
+ fn_args = {}
- # -----------------------------------------------------------------
- # TOPIC DRIFT GUARD ДЛЯ ИНСТРУМЕНТОВ:
- # Если вызваны сторонние инструменты (снапшоты, задачи, статы)
- # — мгновенно закрываем сессию и вычищаем эфемерный контекст
- # -----------------------------------------------------------------
- is_prompt_tool = any(t["function"]["name"] in ["db_prompt_node_edit", "db_get_system_prompt", "db_get_system_prompts"] for t in tool_calls)
- if not is_prompt_tool and session_state:
- logger.info(f"Смена темы на инструмент {tool_calls[0]['function']['name']}. Закрываем сессию и очищаем эфемерный контекст.")
- db_clear_session_state(session_id)
- db_purge_ephemeral_messages(session_id)
- session_state = None
+ logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
- for tool in tool_calls:
- fn_name = tool["function"]["name"]
- fn_args = tool["function"].get("arguments", {})
- tool_names_called.append(fn_name)
- logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
- tool_result_content = ""
+ # Ротация контекста: закрываем старую сессию инструмента
+ close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
+ session_state = None
+ mark_last_user_message_ephemeral(session_id)
- # --- 4.1. Модуль задач ---
- if fn_name == "db_get_tasks":
+ # 1. Задачи (Просмотр)
+ if fn_name == "db_get_tasks":
+ raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
+ reply_text = "Вот интерактивный список ваших текущих задач:"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "TASK_INTERACTIVE_CARD",
+ "tasks": raw_tasks
+ }
+
+ # 2. Единый диспетчер задач
+ elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_status", "db_delete_task"]:
+ action = fn_args.get("action", "UPDATE").upper()
+ if fn_name == "db_add_task": action = "ADD"
+ elif fn_name == "db_delete_task": action = "DELETE"
+ elif fn_name == "db_update_task_status": action = "UPDATE"
+
+ if action == "DELETE":
+ task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
+ db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
+ reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "TASK_DELETE_CONFIRM",
+ "buttons": [
+ {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
+ {"label": "Отмена", "value": "отмена", "style": "secondary"}
+ ]
+ }
+
+ elif action == "EXPORT":
+ export_res = db_export_tasks_markdown(
+ user_id=user_id,
+ filename=fn_args.get("filename"),
+ status_filter=fn_args.get("status")
+ )
+ reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
+
+ action_payload = None
+ if export_res.get("status") == "success":
+ action_payload = {
+ "type": "FILE_DOWNLOAD_CARD",
+ "filename": export_res.get("filename"),
+ "download_url": export_res.get("download_url"),
+ "tasks_count": export_res.get("tasks_count")
+ }
+ return reply_text, db_get_chat_history(session_id), action_payload
+
+ else:
+ res = db_tasks_edit(
+ user_id=user_id,
+ action=action,
+ task_id=fn_args.get("task_id"),
+ title=fn_args.get("title"),
+ priority=fn_args.get("priority", "MEDIUM"),
+ status=fn_args.get("status"),
+ module=fn_args.get("module", "general"),
+ due_date=fn_args.get("due_date")
+ )
raw_tasks = db_get_tasks(user_id)
- reply_text = "Вот интерактивный список ваших текущих задач:"
+ reply_text = res.get("message", "Операция над задачами выполнена.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
- # --- 4.2. Прямой просмотр системного промпта из SQLite ---
- elif fn_name in ["db_get_system_prompt", "db_get_system_prompts"]:
- active_prompt = db_get_active_system_prompt()
- reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
-
- # Переводим сессию в PROMPT_FOLLOWUP для отслеживания Topic Drift
- db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
+ # 3. Системный промпт
+ elif fn_name == "db_get_system_prompt":
+ active_prompt = db_get_active_system_prompt()
+ reply_text = f"Актуальный системный промпт:\n\n{active_prompt}"
+ db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), None
+
+ elif fn_name == "db_prompt_node_edit":
+ action = str(fn_args.get("action", "ADD")).upper()
+
+ try:
+ sec_id = int(str(fn_args.get("section_id", 3)).strip())
+ except Exception:
+ sec_id = 3
+
+ try:
+ itm_id = int(str(fn_args.get("item_id", 1)).strip())
+ except Exception:
+ itm_id = 1
+
+ content = str(fn_args.get("content", "")).strip()
+ baseline_prompt = db_get_active_system_prompt()
+
+ with get_db_connection() as temp_conn:
+ temp_cursor = temp_conn.cursor()
+ temp_cursor.execute("""
+ SELECT section_id, item_id, content
+ FROM system_prompt_nodes
+ WHERE prompt_name = 'main_agent' AND is_active = 1
+ ORDER BY section_id, item_id
+ """)
+ existing_nodes = temp_cursor.fetchall()
+
+ nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
+ nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)} if action == "DELETE" else dict(nodes_dict)
+ if action != "DELETE":
+ nodes_dict_for_draft[(sec_id, itm_id)] = content
+
+ draft_lines = []
+ curr_sec = None
+ for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
+ if i_id == 0:
+ if curr_sec is not None: draft_lines.append("")
+ draft_lines.append(f"{s_id}. {txt}")
+ curr_sec = s_id
+ else:
+ draft_lines.append(f" {s_id}.{i_id}. {txt}")
+ merged_prompt = "\n".join(draft_lines)
+
+ diff_lines = []
+ curr_sec = None
+ display_nodes = dict(nodes_dict)
+ if action != "DELETE":
+ display_nodes[(sec_id, itm_id)] = content
+
+ for (s_id, i_id), txt in sorted(display_nodes.items()):
+ if i_id == 0:
+ if curr_sec is not None: diff_lines.append("")
+ diff_lines.append(f"{s_id}. {txt}")
+ curr_sec = s_id
+ else:
+ if s_id == sec_id and i_id == itm_id:
+ line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]' if action == "DELETE" else f' {s_id}.{i_id}. {txt}'
+ else:
+ line_str = f" {s_id}.{i_id}. {txt}"
+ diff_lines.append(line_str)
+
+ diff_html = "\n".join(diff_lines)
+
+ db_set_session_state(session_id, "PROMPT_PREVIEW", {
+ "draft_text": merged_prompt,
+ "action": action,
+ "section_id": sec_id,
+ "item_id": itm_id,
+ "content": content,
+ "idle_turns": 0
+ })
+
+ preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените."
+ db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
+ return preview_reply, db_get_chat_history(session_id), {
+ "type": "PROMPT_PREVIEW",
+ "raw_draft": merged_prompt,
+ "baseline_prompt": baseline_prompt,
+ "buttons": [
+ {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
+ {"label": "Отменить", "value": "отмена", "style": "danger"},
+ {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
+ ]
+ }
+
+ # 4. Снапшоты СКУД
+ elif fn_name == "db_get_snapshots":
+ snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
+ query_date = snapshots_res.get("query_date", "выбранную дату")
+ reply_text = f"Реестр срезов СКУД за {query_date}:"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "SNAPSHOTS_CARD",
+ "data": snapshots_res
+ }
+
+ elif fn_name == "db_delete_snapshots":
+ raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
+ raw_ids = fn_args.get("snapshot_ids") or []
+ is_confirmed = fn_args.get("confirmed", False)
+
+ if raw_id and not raw_ids:
+ if isinstance(raw_id, str) and "," in raw_id:
+ raw_ids = [s.strip() for s in raw_id.split(",")]
+ else:
+ raw_ids = [raw_id]
+
+ safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
+ if not safe_ids:
+ reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
- # --- 4.3. Реляционное изменение промпта (ADD / UPDATE / DELETE) ---
- elif fn_name == "db_prompt_node_edit":
- action = fn_args.get("action", "ADD").upper()
- sec_id = int(fn_args.get("section_id", 2))
- itm_id = int(fn_args.get("item_id", 1))
- content = fn_args.get("content", "").strip()
- baseline_prompt = db_get_active_system_prompt()
-
- with get_db_connection() as temp_conn:
- temp_cursor = temp_conn.cursor()
- temp_cursor.execute("""
- SELECT section_id, item_id, content
- FROM system_prompt_nodes
- WHERE prompt_name = 'main_agent' AND is_active = 1
- ORDER BY section_id, item_id
- """)
- existing_nodes = temp_cursor.fetchall()
-
- nodes_dict = {(sec, itm): txt for sec, itm, txt in existing_nodes}
-
- if action == "DELETE":
- nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (sec_id, itm_id)}
- else:
- nodes_dict_for_draft = dict(nodes_dict)
- nodes_dict_for_draft[(sec_id, itm_id)] = content
-
- # Формирование чистого текста для редактора
- draft_lines = []
- curr_sec = None
- for (s_id, i_id), txt in sorted(nodes_dict_for_draft.items()):
- if i_id == 0:
- if curr_sec is not None:
- draft_lines.append("")
- draft_lines.append(f"{s_id}. {txt}")
- curr_sec = s_id
- else:
- draft_lines.append(f" {s_id}.{i_id}. {txt}")
- merged_prompt = "\n".join(draft_lines)
-
- # Формирование HTML Diff с подсветкой
- diff_lines = []
- curr_sec = None
- display_nodes = dict(nodes_dict)
- if action != "DELETE":
- display_nodes[(sec_id, itm_id)] = content
-
- for (s_id, i_id), txt in sorted(display_nodes.items()):
- if i_id == 0:
- if curr_sec is not None:
- diff_lines.append("")
- diff_lines.append(f"{s_id}. {txt}")
- curr_sec = s_id
- else:
- if s_id == sec_id and i_id == itm_id:
- if action == "DELETE":
- line_str = f' {s_id}.{i_id}. {txt} [УДАЛЕНИЕ]'
- else:
- line_str = f' {s_id}.{i_id}. {txt}'
- else:
- line_str = f" {s_id}.{i_id}. {txt}"
- diff_lines.append(line_str)
-
- diff_html = "\n".join(diff_lines)
-
- # Фиксация предпросмотра в session_states
- db_set_session_state(session_id, "PROMPT_PREVIEW", {
- "draft_text": merged_prompt,
- "action": action,
- "section_id": sec_id,
- "item_id": itm_id,
- "content": content,
+ if not is_confirmed:
+ query_date = state_data.get("query_date", "")
+ db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
+ "snapshot_ids": safe_ids,
+ "query_date": query_date,
"idle_turns": 0
})
-
- with get_db_connection() as conn_fix:
- conn_fix.cursor().execute("""
- UPDATE chat_messages
- SET is_ephemeral = 1
- WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
- """, (session_id,))
- conn_fix.commit()
-
- preview_reply = (
- f"Предпросмотр изменений системного промпта:\n\n"
- f"{diff_html}\n\n"
- f"Для применения подтвердите действие, отредактируйте или отмените."
- )
-
- db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1)
- return preview_reply, db_get_chat_history(session_id), {
- "type": "PROMPT_PREVIEW",
- "raw_draft": merged_prompt,
- "baseline_prompt": baseline_prompt,
+ ids_str = ", ".join(safe_ids)
+ reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "SNAPSHOT_DELETE_CONFIRM",
"buttons": [
- {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
- {"label": "Отменить", "value": "отмена", "style": "danger"},
- {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
+ {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
+ {"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
+ else:
+ db_delete_snapshots(snapshot_ids=safe_ids)
+ query_date = state_data.get("query_date", "")
+ updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
- # --- 4.4. Сервисные инструменты ---
- elif fn_name == "db_get_snapshots":
- snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
- tool_result_content = json.dumps(snapshots_res, ensure_ascii=False)
+ reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "SNAPSHOTS_CARD",
+ "data": updated_snapshots_res
+ }
+
+ elif fn_name == "db_delete_snapshots":
+ raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
+ raw_ids = fn_args.get("snapshot_ids") or ([raw_id] if raw_id else [])
+
+ # Защита: итоговые Y-срезы удалять запрещено
+ safe_ids = [s for s in raw_ids if not str(s).startswith("Y")]
+ if not safe_ids:
+ reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), None
- elif fn_name == "db_get_current_server_time":
- tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
+ # Прямое выполнение удаления в SQLite без лишних текстовых подтверждений
+ del_res = db_delete_snapshots(snapshot_ids=safe_ids)
+
+ # Получаем свежий реестр за ту же дату
+ query_date = fn_args.get("day_str", "")
+ updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
- elif fn_name == "db_get_stats":
- tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
+ reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
+ db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ return reply_text, db_get_chat_history(session_id), {
+ "type": "SNAPSHOTS_CARD",
+ "data": updated_snapshots_res
+ }
- elif fn_name == "db_get_anomalies":
- tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
+ # 5. Сервисные запросы
+ elif fn_name == "db_get_current_server_time":
+ tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
+ elif fn_name == "db_get_stats":
+ tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
+ elif fn_name == "db_get_anomalies":
+ tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
+ elif fn_name == "db_get_rules":
+ tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
+ elif fn_name == "db_get_reference":
+ tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
+ else:
+ tool_result_content = "{}"
- elif fn_name == "db_get_rules":
- tool_result_content = json.dumps(db_get_rules(), ensure_ascii=False)
-
- elif fn_name == "db_get_reference":
- tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
-
- elif fn_name == "db_add_task":
- res = db_add_task(user_id=user_id, module=fn_args.get("module", "general"), title=fn_args.get("title"), priority=fn_args.get("priority", "MEDIUM"), due_date=fn_args.get("due_date"))
- tool_result_content = json.dumps(res, ensure_ascii=False)
-
- elif fn_name == "db_update_task_status":
- res = db_update_task_status(user_id=user_id, task_id=str(fn_args.get("task_id")), status=fn_args.get("status", "COMPLETED"), due_date=fn_args.get("due_date"))
- tool_result_content = json.dumps(res, ensure_ascii=False)
-
- elif fn_name == "db_delete_task":
- res = db_delete_task(user_id=user_id, task_id=str(fn_args.get("task_id", "")).upper())
- tool_result_content = json.dumps(res, ensure_ascii=False)
-
- elif fn_name == "db_delete_snapshots":
- res = db_delete_snapshots(snapshot_id=fn_args.get("snapshot_id"), day_str=fn_args.get("day_str"))
- tool_result_content = json.dumps(res, ensure_ascii=False)
-
- messages.append({"role": "tool", "content": tool_result_content})
-
- # Интерпретация результатов вызова инструментов
+ messages.append(msg)
+ messages.append({"role": "tool", "content": tool_result_content})
sec_msg = call_ollama_chat(messages, is_vision=False)
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
- final_content = clean_raw_tool_tags(clean_output(raw_content))
+ final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
- is_output_ephemeral = 1 if any(name in ["db_get_system_prompt", "db_get_system_prompts", "db_prompt_node_edit"] for name in tool_names_called) else 0
+ for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
+ if final_content.lower().startswith(artifact):
+ final_content = final_content[len(artifact):].lstrip(",.!?:; -")
- db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_output_ephemeral)
+ is_ephem = 1 if fn_name in ["db_get_snapshots", "db_get_system_prompt", "db_prompt_node_edit"] else 0
+ db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=is_ephem)
return final_content, db_get_chat_history(session_id), None
-
- # =====================================================================
- # БЛОК 5: ОБЫЧНЫЙ ТЕКСТОВЫЙ ОТВЕТ И СЕМАНТИЧЕСКИЙ TOPIC DRIFT GUARD
- # =====================================================================
+ # ANCHOR[AGENT_TOPIC_DRIFT]
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
+
+ for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]:
+ if final_reply.lower().startswith(artifact):
+ final_reply = final_reply[len(artifact):].lstrip(",.!?:; -")
+
action_payload = None
- # Обработка отвлечений оператора при активной сессии настройки/просмотра
if session_state and session_state.get("state_type") in ["PROMPT_FOLLOWUP", "PROMPT_PREVIEW"]:
idle_turns += 1
- logger.info(f"Topic Drift: активна сессия {session_state.get('state_type')}, шагов отвлечения: {idle_turns}/3")
-
- # Порог отвлечений превышен (> 2 шагов после напоминания) -> Полная зачистка
if idle_turns > 3:
db_clear_session_state(session_id)
- deleted_count = db_purge_ephemeral_messages(session_id)
- logger.info(f"Topic Drift Guard TTL: сессия закрыта по таймауту, очищено {deleted_count} сообщений.")
+ db_purge_ephemeral_messages(session_id)
action_payload = None
session_state = None
elif idle_turns == 3:
- # На 3-м сообщении стороннего диалога вежливо спрашиваем оператора
tool_action = db_get_tool_action("db_confirm_prompt_preview")
guard_question = tool_action.get("follow_up_question", "Желаете продолжить работу с системным промптом?") if tool_action else "Желаете продолжить работу с системным промптом?"
buttons = tool_action.get("buttons", []) if tool_action else []
-
final_reply += f"\n\n💡 *Напоминание:* {guard_question}"
- action_payload = {
- "type": "PROMPT_FOLLOWUP",
- "buttons": buttons
- }
+ action_payload = {"type": "PROMPT_FOLLOWUP", "buttons": buttons}
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns})
else:
- # Шаги 1 и 2: продолжаем обычный диалог, инкрементируя счетчик
db_set_session_state(session_id, session_state.get("state_type"), {"idle_turns": idle_turns})
- db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=(1 if session_state else 0))
+ db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), action_payload
- # Обработка исключений
except Exception as ex:
logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}"
diff --git a/modules/web_api/llm/core/context_manager.py b/modules/web_api/llm/core/context_manager.py
new file mode 100644
index 0000000..fd0dcb6
--- /dev/null
+++ b/modules/web_api/llm/core/context_manager.py
@@ -0,0 +1,64 @@
+"""
+===============================================================================
+FILE: modules/web_api/llm/core/context_manager.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: web_api / llm / core
+ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций
+ инструментов и содержательного диалога с сохранением Topic Drift.
+===============================================================================
+"""
+
+import logging
+from typing import Dict, Any, Optional, List
+
+from ..db.connection import get_db_connection
+from ..db_tools import (
+ db_save_chat_message,
+ db_get_chat_history,
+ db_purge_ephemeral_messages,
+ db_clear_session_state,
+ db_set_session_state
+)
+
+logger = logging.getLogger("CONTEXT_MANAGER")
+
+
+def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
+ """
+ Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1).
+ При очистке удалятся оба сообщения, не оставляя сирот.
+ """
+ db_save_chat_message(session_id, "user", user_content, is_ephemeral=1)
+ db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1)
+
+
+def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
+ """
+ Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0).
+ Эти сообщения остаются в истории навсегда (включая Topic Drift).
+ """
+ db_save_chat_message(session_id, "user", user_content, is_ephemeral=0)
+ db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0)
+
+
+def mark_last_user_message_ephemeral(session_id: str) -> None:
+ """Помечает последнее сообщение пользователя как эфемерное при активации инструмента."""
+ with get_db_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute("""
+ UPDATE chat_messages
+ SET is_ephemeral = 1
+ WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
+ """, (session_id,))
+ conn.commit()
+
+
+def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int:
+ """
+ Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды.
+ Весь содержательный диалог сохраняется.
+ """
+ db_clear_session_state(session_id)
+ deleted_count = db_purge_ephemeral_messages(session_id)
+ logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}")
+ return deleted_count
\ No newline at end of file
diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py
index 69b6d7f..b33e1c8 100644
--- a/modules/web_api/llm/core/fast_path.py
+++ b/modules/web_api/llm/core/fast_path.py
@@ -3,104 +3,166 @@
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
-ROLE: Двухфазный жизненный цикл сессии (PROMPT_PREVIEW -> PROMPT_FOLLOWUP),
- атомарная фиксация узлов и мягкая зачистка эфемерного контекста.
+ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения
+ (без задержек LLM и обращения к Ollama).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept.
===============================================================================
"""
+import json
import logging
from typing import Dict, Any, Tuple, Optional
-from llm.db_tools import (
- db_apply_prompt_node_action,
- db_add_system_prompt,
- db_set_session_state,
- db_clear_session_state,
- db_purge_ephemeral_messages,
- db_save_chat_message,
- db_get_chat_history
-)
+# Прямые вызовы чистых доменных сервисов
+from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
+from services.tasks.service import get_tasks, delete_task
+from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely
+
+# Чат и сессии
+from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
+from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action
+from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
logger = logging.getLogger("FAST_PATH")
+# ANCHOR[FAST_PATH_MAIN]
def handle_fast_path_intercept(
- session_id: str,
- user_message: str,
- full_user_content: str,
+ session_id: str,
+ user_message: str,
+ full_user_content: str,
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
- """Обрабатывает команды подтверждения, отмены и завершения сессии."""
+ """
+ Мгновенный перехват нажатий кнопок подтверждения (Fast-Path).
+ Возвращает (reply, history, action_type) или None, если требуется передать управление LLM.
+ """
if not session_state:
return None
state_type = session_state.get("state_type")
- user_msg_clean = user_message.lower().strip(" .!?:;")
+ state_data = session_state.get("data_json") or {}
+ if not isinstance(state_data, dict):
+ state_data = {}
- # =========================================================================
- # ФАЗА 1: ОБРАБОТКА В СОСТОЯНИИ PROMPT_PREVIEW
- # =========================================================================
+ msg_lower = user_message.strip().lower()
+
+ # -------------------------------------------------------------------------
+ # 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА
+ # -------------------------------------------------------------------------
if state_type == "PROMPT_PREVIEW":
- state_data = session_state.get("data_json") or {}
- if not isinstance(state_data, dict):
- state_data = {}
+ is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
+ is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
- draft_text = state_data.get("draft_text", "")
- action = state_data.get("action")
- sec_id = state_data.get("section_id")
- itm_id = state_data.get("item_id")
- content = state_data.get("content", "")
+ if is_confirm:
+ action = state_data.get("action", "MANUAL_EDIT")
+ draft_text = state_data.get("draft_text", "")
- # 1. ПОДТВЕРДИТЬ ИЗМЕНЕНИЯ -> ПЕРЕХОД В PROMPT_FOLLOWUP
- if user_msg_clean in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
- if action == "MANUAL_EDIT" or not action or sec_id is None:
- if draft_text:
- db_add_system_prompt("main_agent", draft_text)
+ # Применение изменений
+ if action == "MANUAL_EDIT" and draft_text:
+ save_full_prompt_draft(draft_text, prompt_name="main_agent")
else:
- db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
+ sec_id = state_data.get("section_id")
+ itm_id = state_data.get("item_id")
+ content = state_data.get("content", "")
+ if sec_id is not None and itm_id is not None:
+ apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
+ elif draft_text:
+ save_full_prompt_draft(draft_text, prompt_name="main_agent")
- logger.info("Системный промпт применен в БД. Переход в PROMPT_FOLLOWUP.")
+ # Закрытие сессии и зачистка
+ close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
+ db_clear_session_state(session_id)
+
+ tool_action = db_get_tool_action("db_confirm_prompt_preview")
+ reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных."
- # Переводим сессию в режим ожидания продолжения/завершения
- db_set_session_state(session_id, "PROMPT_FOLLOWUP", {})
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
+ db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
+ return reply, db_get_chat_history(session_id), None
- reply_text = "Системный промпт успешно сохранен и применен в базе данных. Желаете продолжить работу с системным промптом или завершить?"
- db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
+ elif is_cancel:
+ close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED")
+ db_clear_session_state(session_id)
- action_payload = {
- "type": "PROMPT_FOLLOWUP",
- "buttons": [
- {"label": "✨ Показать обновленный промпт", "value": "покажи системный промпт", "style": "secondary"},
- {"label": "🏁 Завершить работу", "value": "завершить работу", "style": "primary"}
- ]
+ tool_action = db_get_tool_action("db_cancel_prompt_preview")
+ reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены."
+
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
+ db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
+ return reply, db_get_chat_history(session_id), None
+
+ # -------------------------------------------------------------------------
+ # 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД
+ # -------------------------------------------------------------------------
+ elif state_type == "SNAPSHOT_DELETE_CONFIRM":
+ is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
+ is_cancel = msg_lower in ["отмена", "отменить", "нет"]
+
+ if is_confirm:
+ snap_ids = state_data.get("snapshot_ids", [])
+ query_date = state_data.get("query_date", "")
+
+ # Безопасное удаление через сервис
+ del_res = delete_snapshots_safely(snapshot_ids=snap_ids)
+
+ close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED")
+
+ # Получаем свежий список за ту же дату
+ updated_data = get_snapshots_registry(date_str=query_date if query_date else None)
+ db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data)
+
+ reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт."
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
+ db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
+
+ return reply, db_get_chat_history(session_id), {
+ "type": "SNAPSHOTS_CARD",
+ "data": updated_data
}
- return reply_text, db_get_chat_history(session_id), action_payload
- # 2. ОТМЕНА В МОМЕНТ ПРЕВЬЮ -> ПОЛНАЯ ЗАЧИСТКА
- elif user_msg_clean in ["отмена", "отменить", "отклонить", "назад", "стоп"]:
+ elif is_cancel:
+ close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED")
db_clear_session_state(session_id)
- deleted_count = db_purge_ephemeral_messages(session_id)
- logger.info(f"Изменения промпта отменены. Удалено эфемерных сообщений: {deleted_count}")
+
+ reply = "❌ Удаление снапшотов отменено."
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
+ db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
+ return reply, db_get_chat_history(session_id), None
- reply_text = "Изменения системного промпта отменены. Контекст диалога чист."
- db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
- return reply_text, db_get_chat_history(session_id), None
+ # -------------------------------------------------------------------------
+ # 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ
+ # -------------------------------------------------------------------------
+ elif state_type == "TASK_DELETE_CONFIRM":
+ is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
+ is_cancel = msg_lower in ["отмена", "отменить", "нет"]
- # =========================================================================
- # ФАЗА 2: ОБРАБОТКА В СОСТОЯНИИ PROMPT_FOLLOWUP
- # =========================================================================
- elif state_type == "PROMPT_FOLLOWUP":
- # Явный сигнал завершения работы
- if user_msg_clean in [
- "завершить работу", "завершить", "закончить", "готово", "всё",
- "все", "нет", "нет, спасибо", "спасибо", "хватит", "выйти"
- ]:
+ if is_confirm:
+ task_id = state_data.get("task_id")
+ delete_task(user_id=1, task_id=str(task_id))
+
+ close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED")
db_clear_session_state(session_id)
- deleted_count = db_purge_ephemeral_messages(session_id)
- logger.info(f"Сессия работы с промптом закрыта. Очищено эфемерных сообщений: {deleted_count}")
- reply_text = "Работа с системным промптом завершена. Чем могу помочь?"
- db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
- return reply_text, db_get_chat_history(session_id), None
+ raw_tasks = get_tasks(user_id=1)
+ reply = f"🗑 Задача #{task_id} удалена."
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1)
+ db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
+
+ return reply, db_get_chat_history(session_id), {
+ "type": "TASK_INTERACTIVE_CARD",
+ "tasks": raw_tasks
+ }
+
+ elif is_cancel:
+ close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_CANCELLED")
+ db_clear_session_state(session_id)
+
+ reply = "❌ Удаление задачи отменено."
+ db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
+ db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
+ return reply, db_get_chat_history(session_id), None
return None
\ No newline at end of file
diff --git a/modules/web_api/llm/core/ollama_client.py b/modules/web_api/llm/core/ollama_client.py
index a6fd772..83dd592 100644
--- a/modules/web_api/llm/core/ollama_client.py
+++ b/modules/web_api/llm/core/ollama_client.py
@@ -14,16 +14,16 @@ from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT")
-OLLAMA_URL = "http://192.168.11.3:11434/api/chat"
-TEXT_MODEL = "qwen2.5:14b"
+OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
+TEXT_MODEL = "qwen2.5:14b-instruct-q8_0"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = {
"num_predict": 8192,
"num_ctx": 8192,
- "temperature": 0.1,
- "repeat_penalty": 1.1,
- "presence_penalty": 0.5,
+ "temperature": 0.0,
+ "repeat_penalty": 1.0,
+ "presence_penalty": 0.0,
"top_p": 0.9
}
diff --git a/modules/web_api/llm/core/prompt_merger.py b/modules/web_api/llm/core/prompt_merger.py
deleted file mode 100644
index 5407901..0000000
--- a/modules/web_api/llm/core/prompt_merger.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""
-===============================================================================
-FILE: modules/web_api/llm/core/prompt_merger.py
-PROJECT: SCUD Orion AI (Unified Architecture)
-MODULE: web_api / llm / core
-ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок.
-===============================================================================
-"""
-
-# ANCHOR[PROMPT_MERGER_IMPORTS]
-import re
-import logging
-
-logger = logging.getLogger("PROMPT_MERGER")
-
-
-# ANCHOR[PROMPT_MERGE_ENGINE]
-def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str:
- """
- Вычисляет результирующий чистый текст промпта для записи в БД.
- """
- user_msg_lower = user_message.lower()
-
- # 1. Сценарий удаления пункта
- if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]):
- target_num_match = re.search(r'\d+(\.\d+)*', user_message)
- target_num = target_num_match.group(0) if target_num_match else ""
-
- lines = current_prompt.splitlines()
- if target_num:
- new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")]
- else:
- new_lines = lines
- return "\n".join(new_lines)
-
- # 2. Сценарий добавления или замены пункта
- if proposed_text:
- if len(proposed_text) < 500:
- clean_item = proposed_text.strip()
- for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]:
- if prefix in clean_item.lower():
- clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:")
-
- lines = current_prompt.splitlines()
- new_lines = []
- inserted = False
- for line in lines:
- new_lines.append(line)
- if "3.3." in line and not inserted:
- item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}"
- new_lines.append(f" {item_str}")
- inserted = True
- if not inserted:
- new_lines.append(f" {clean_item}")
- return "\n".join(new_lines)
-
- return proposed_text or current_prompt
-
-
-def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str:
- """
- Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind.
- """
- old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()}
- diff_lines = []
-
- for line in new_prompt.splitlines():
- if line.strip() and line.strip() not in old_lines_set:
- # Выделяем новую или измененную строку красным цветом
- diff_lines.append(f'{line}')
- else:
- diff_lines.append(line)
-
- return "\n".join(diff_lines)
\ No newline at end of file
diff --git a/modules/web_api/llm/core/tool_injector.py b/modules/web_api/llm/core/tool_injector.py
index 3dd45c3..40381d8 100644
--- a/modules/web_api/llm/core/tool_injector.py
+++ b/modules/web_api/llm/core/tool_injector.py
@@ -3,9 +3,14 @@
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
-ROLE: Санитарная очистка сырых артефактов и тегов из ответов LLM.
+ROLE: Семантический анализ намерений оператора (Intent Classifier) и
+ детерминированная сборка вызовов инструментов при сбоях нативного Function Calling.
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[INTENT_INJECTOR_MAIN]: Точка входа inject_tools_if_needed.
===============================================================================
"""
+
import re
import logging
from typing import List, Dict, Any
@@ -14,37 +19,120 @@ logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
- """Удаляет сырые теги вызова инструментов, если модель случайно вывела их в текст."""
+ """Удаляет сырые теги вызова инструментов и системный шум."""
if not text:
return ""
- text = re.sub(r'\{"name":\s*"db_[^}]+\}\s*()?', '', text)
- text = re.sub(r'.*?', '', text, flags=re.DOTALL)
- text = re.sub(r'\w*\[\]\(\)', '', text)
- text = re.sub(r'', '', text)
- return text.strip()
-
-
-def clean_output(text: str) -> str:
- """Удаляет слова-паразиты и склейки в начале ответа."""
- if not text:
- return text
-
- artifacts = [
- "почемучка", "почемучка,", "почемучка!", "почемучка?",
- "почемучто", "почто", "почему что", "почему-то",
- "здравствуйте!", "привет!"
- ]
-
- cleaned = text.strip()
- for art in artifacts:
- if cleaned.lower().startswith(art):
- cleaned = cleaned[len(art):].lstrip(",.!?:; -")
-
+ cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL)
+ cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
return cleaned.strip()
-def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+def clean_output(text: str) -> str:
+ """Очищает маркеры форматирования."""
+ return text.strip() if text else ""
+
+
+# ANCHOR[INTENT_INJECTOR_MAIN]
+def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
- Проходной фильтр: все решения о вызове инструментов принимает исключительно LLM.
+ Гибридный семантический анализатор:
+ Если модель ответила текстом с сомнениями или пропустила tool_call,
+ распознает доменное намерение и конструирует синтетический tool_call.
"""
- return tool_calls
\ No newline at end of file
+ if existing_tool_calls:
+ return existing_tool_calls
+
+ msg_clean = user_message.strip().lower()
+
+ # 1. СЕМАНТИКА: Просмотр системного промпта
+ # Паттерны: "покажи системный промпт", "выведи промпт", "текущий промпт", "какой сейчас системный промпт"
+ if "промпт" in msg_clean and any(kw in msg_clean for kw in ["покажи", "выведи", "какой", "дай", "текст", "актуальн"]):
+ logger.info("[IntentInjector] Распознано намерение просмотра системного промпта")
+ return [{
+ "function": {
+ "name": "db_get_system_prompt",
+ "arguments": {}
+ }
+ }]
+
+ # 2. СЕМАНТИКА: Удаление задач
+ # Паттерны: "удали задачу 37", "убери 37 задачу", "сотри таску #37", "сними с повестки задачу 37"
+ if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "сотри", "сними"]) and any(kw in msg_clean for kw in ["задач", "таск"]):
+ task_match = re.search(r'#?\s*(\d+)', msg_clean)
+ if task_match:
+ task_id = task_match.group(1)
+ logger.info(f"[IntentInjector] Распознано намерение удаления задачи: #{task_id}")
+ return [{
+ "function": {
+ "name": "db_tasks_edit",
+ "arguments": {"action": "DELETE", "task_id": task_id}
+ }
+ }]
+
+ # 3. СЕМАНТИКА: Управление системным промптом (удаление и мульти-удаление)
+ # Паттерны: "удали 1.8 и 3.4", "удали пункт 2.3", "вычеркни 1.8, 3.4 из промпта"
+ if any(kw in msg_clean for kw in ["удали", "удалить", "убери", "вычеркни", "сотри"]) and not any(kw in msg_clean for kw in ["задач", "снапшот", "срез"]):
+ node_matches = re.findall(r'(\d+)[\.\s]+(\d+)', user_message)
+ if node_matches:
+ formatted_nodes = [f"{s}.{i}" for s, i in node_matches]
+ logger.info(f"[IntentInjector] Распознано намерение удаления узлов промпта: {formatted_nodes}")
+ return [{
+ "function": {
+ "name": "db_prompt_node_edit",
+ "arguments": {
+ "action": "BATCH_DELETE" if len(formatted_nodes) > 1 else "DELETE",
+ "section_id": int(node_matches[0][0]),
+ "item_id": int(node_matches[0][1]),
+ "nodes_list": formatted_nodes,
+ "content": ""
+ }
+ }
+ }]
+
+ # 4. СЕМАНТИКА: Добавление пункта промпта
+ # Паттерны: "добавь 3.4 Текст", "добавь пункт 3.4 Текст", "впиши в 3.4 Текст"
+ if any(kw in msg_clean for kw in ["добавь", "добавить", "впиши", "запиши"]) and not any(kw in msg_clean for kw in ["задач", "таск"]):
+ add_match = re.search(r'(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)', user_message)
+ if add_match:
+ sec_id = int(add_match.group(1))
+ itm_id = int(add_match.group(2))
+ content = add_match.group(3).strip()
+ logger.info(f"[IntentInjector] Распознано намерение добавления узла промпта: {sec_id}.{itm_id}")
+ return [{
+ "function": {
+ "name": "db_prompt_node_edit",
+ "arguments": {
+ "action": "ADD",
+ "section_id": sec_id,
+ "item_id": itm_id,
+ "content": content
+ }
+ }
+ }]
+
+ # 5. СЕМАНТИКА: Смена статуса задач
+ if any(kw in msg_clean for kw in ["в работу", "начни", "стартуй", "за работу"]):
+ task_match = re.search(r'#?\s*(\d+)', msg_clean)
+ if task_match:
+ task_id = task_match.group(1)
+ logger.info(f"[IntentInjector] Распознано намерение взятия в работу задачи: #{task_id}")
+ return [{
+ "function": {
+ "name": "db_tasks_edit",
+ "arguments": {"action": "UPDATE", "task_id": task_id, "status": "IN_PROGRESS"}
+ }
+ }]
+
+ if any(kw in msg_clean for kw in ["заверши", "закрой", "готово", "выполнено"]):
+ task_match = re.search(r'#?\s*(\d+)', msg_clean)
+ if task_match:
+ task_id = task_match.group(1)
+ logger.info(f"[IntentInjector] Распознано намерение закрытия задачи: #{task_id}")
+ return [{
+ "function": {
+ "name": "db_tasks_edit",
+ "arguments": {"action": "UPDATE", "task_id": task_id, "status": "COMPLETED"}
+ }
+ }]
+
+ return existing_tool_calls
\ No newline at end of file
diff --git a/modules/web_api/llm/db/db_snapshots.py b/modules/web_api/llm/db/db_snapshots.py
deleted file mode 100644
index 6918033..0000000
--- a/modules/web_api/llm/db/db_snapshots.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""
-FILE: modules/web_api/llm/db/db_snapshots.py
-"""
-import json
-from typing import Dict, Any, Optional
-from .connection import get_db_connection
-from .db_prompts import db_set_session_state
-
-def db_get_snapshots(session_id: str = "web_session_main", date_str: Optional[str] = None, original_user_message: str = "") -> Dict[str, Any]:
- conn = get_db_connection()
- cursor = conn.cursor()
- query = "SELECT snapshot_id, log_date, snapshot_time, COUNT(*) as record_count FROM scud_logs "
- params = []
- if date_str:
- iso_date = date_str
- if "." in date_str:
- parts = date_str.split(".")
- if len(parts) == 3:
- iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}"
- query += " WHERE log_date = ? OR log_date = ? OR snapshot_time LIKE ? "
- params.extend([date_str, iso_date, f"{iso_date}%"])
-
- query += " GROUP BY snapshot_id ORDER BY id DESC LIMIT 50"
- cursor.execute(query, params)
- rows = cursor.fetchall()
- snapshots = [dict(r) for r in rows]
-
- result_data = {
- "query_date": date_str or "все",
- "snapshots_count": len(snapshots),
- "snapshots": snapshots
- }
- db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=json.dumps(result_data, ensure_ascii=False))
- conn.close()
- return result_data
-
-def db_delete_snapshots(snapshot_id: Optional[str] = None, day_str: Optional[str] = None) -> Dict[str, Any]:
- if not snapshot_id and not day_str:
- return {"status": "error", "message": "Необходимо указать snapshot_id или day_str (ДД.ММ.ГГГГ)."}
- conn = get_db_connection()
- cursor = conn.cursor()
- if snapshot_id:
- cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,))
- else:
- cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (day_str, f"%{day_str.replace('.', '')}%"))
- deleted = cursor.rowcount
- conn.commit()
- conn.close()
- return {"status": "success", "message": f"Успешно удалено записей: {deleted}"}
\ No newline at end of file
diff --git a/modules/web_api/llm/db/db_tasks.py b/modules/web_api/llm/db/db_tasks.py
deleted file mode 100644
index 4bb97bd..0000000
--- a/modules/web_api/llm/db/db_tasks.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-FILE: modules/web_api/llm/db/db_tasks.py
-"""
-import re
-from typing import List, Dict, Any, Optional
-from .connection import get_db_connection
-
-def normalize_task_id(task_id_input: str) -> str:
- if not task_id_input:
- return ""
- clean_id = str(task_id_input).strip().upper().replace("TASK-", "").replace("TASK", "")
- if clean_id.isdigit():
- num = int(clean_id)
- return f"TASK-{(num):02d}" if num < 100 else f"TASK-{(num):03d}"
- return f"TASK-{clean_id}"
-
-def db_get_tasks(user_id: int) -> List[Dict[str, Any]]:
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("""
- SELECT id, task_id, module, title, priority, status, due_date, created_at
- FROM tasks
- WHERE user_id = ?
- ORDER BY id DESC
- """, (user_id,))
- rows = cursor.fetchall()
- conn.close()
- return [dict(r) for r in rows]
-
-def db_add_task(user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None) -> Dict[str, Any]:
- conn = get_db_connection()
- cursor = conn.cursor()
- cursor.execute("SELECT MAX(id) FROM tasks")
- max_id = cursor.fetchone()[0] or 0
- new_task_id = f"TASK-{(max_id + 1):02d}"
-
- cursor.execute("""
- INSERT INTO tasks (task_id, module, title, priority, status, due_date, user_id)
- VALUES (?, ?, ?, ?, 'BACKLOG', ?, ?)
- """, (new_task_id, module, title, priority.upper(), due_date, user_id))
- conn.commit()
- conn.close()
- return {"status": "success", "task_id": new_task_id, "message": f"Задача {new_task_id} создана"}
-
-def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]:
- conn = get_db_connection()
- cursor = conn.cursor()
- formatted_id = normalize_task_id(task_id)
- if due_date:
- cursor.execute("""
- UPDATE tasks
- SET status = ?, due_date = ?
- WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
- """, (status.upper(), due_date, formatted_id, f"%{task_id.strip()}", user_id))
- else:
- cursor.execute("""
- UPDATE tasks
- SET status = ?
- WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
- """, (status.upper(), formatted_id, f"%{task_id.strip()}", user_id))
-
- if cursor.rowcount == 0:
- conn.close()
- return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"}
- conn.commit()
- conn.close()
- return {"status": "success", "message": f"Статус задачи {formatted_id} обновлен на {status.upper()}"}
-
-def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]:
- conn = get_db_connection()
- cursor = conn.cursor()
- formatted_id = normalize_task_id(task_id)
- cursor.execute("""
- DELETE FROM tasks
- WHERE (UPPER(task_id) = ? OR task_id LIKE ?) AND user_id = ?
- """, (formatted_id, f"%{task_id.strip()}", user_id))
- if cursor.rowcount == 0:
- conn.close()
- return {"error": f"Задача {task_id} не найдена"}
- conn.commit()
- conn.close()
- return {"status": "success", "message": f"Задача {formatted_id} удалена"}
\ No newline at end of file
diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py
index 0eec0d2..c2a5b9b 100644
--- a/modules/web_api/llm/db_tools.py
+++ b/modules/web_api/llm/db_tools.py
@@ -1,17 +1,53 @@
"""
+===============================================================================
FILE: modules/web_api/llm/db_tools.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: web_api / llm
+ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
+===============================================================================
"""
-from datetime import datetime
-from .db.connection import DB_PATH, get_db_connection
-from .db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages
-from .db.db_tasks import normalize_task_id, db_get_tasks, db_add_task, db_update_task_status, db_delete_task
-from .db.db_snapshots import db_get_snapshots, db_delete_snapshots
+
+# ANCHOR[FACADE_EXPORTS]
+from typing import Optional, List, Dict, Any
+from core.connection import DB_PATH, get_connection as get_db_connection
+
+# Домен: Задачи
+from services.tasks.service import (
+ get_tasks as db_get_tasks,
+ add_task as db_add_task,
+ update_task_details as db_update_task_details,
+ delete_task as db_delete_task,
+ execute_task_action as db_tasks_edit
+)
+from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
+from services.tasks.repository import normalize_task_id
+
+# Домен: Системный промпт
+from services.prompts.service import (
+ get_active_system_prompt as db_get_active_system_prompt,
+ apply_prompt_action as db_apply_prompt_node_action,
+ save_full_prompt_draft,
+ create_prompt_preview
+)
+
+# Домен: База знаний
+from services.knowledge.service import (
+ get_rules as db_get_rules,
+ add_rule as db_add_rule
+)
+
+# Чат, сессии, статистика
+from .db.db_chat import (
+ db_save_chat_message,
+ db_get_chat_history,
+ db_purge_ephemeral_messages,
+ db_clear_chat_history
+)
from .db.db_prompts import (
- db_get_active_system_prompt,
- db_add_system_prompt,
- db_apply_prompt_node_action,
db_get_tool_action,
- db_get_rules,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
@@ -19,13 +55,42 @@ from .db.db_prompts import (
db_get_anomalies,
db_get_reference
)
+from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
-def db_get_current_server_time():
- now = datetime.now()
- days_ru = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
- return {
- "current_date": now.strftime("%d.%m.%Y"),
- "current_time": now.strftime("%H:%M:%S"),
- "day_of_week": days_ru[now.weekday()],
- "iso_date": now.strftime("%Y-%m-%d")
- }
\ No newline at end of file
+
+def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None:
+ """Совместимая обертка для сохранения системного промпта."""
+ if draft_text is not None:
+ save_full_prompt_draft(draft_text, prompt_name=name_or_text)
+ else:
+ save_full_prompt_draft(name_or_text, prompt_name="main_agent")
+
+
+def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]:
+ """Совместимый фасад выборки снапшотов с сохранением стейта сессии."""
+ from services.snapshots.service import get_snapshots_registry
+ from .core.calendar_utils import parse_relative_date_ru
+
+ clean_date = (date_str or "").strip()
+ if not clean_date and original_user_message:
+ clean_date = parse_relative_date_ru(original_user_message)
+
+ res = get_snapshots_registry(date_str=clean_date if clean_date else None)
+ db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res)
+ return res
+
+
+def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]:
+ """Совместимый фасад безопасного удаления снапшотов."""
+ from services.snapshots.service import delete_snapshots_safely
+
+ target_ids = []
+ if snapshot_ids:
+ target_ids.extend(snapshot_ids)
+ if snapshot_id:
+ if isinstance(snapshot_id, str) and "," in snapshot_id:
+ target_ids.extend([s.strip() for s in snapshot_id.split(",")])
+ else:
+ target_ids.append(snapshot_id)
+
+ return delete_snapshots_safely(snapshot_ids=target_ids)
\ No newline at end of file
diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py
index 386139e..dfe1567 100644
--- a/modules/web_api/llm/schemas.py
+++ b/modules/web_api/llm/schemas.py
@@ -1,50 +1,82 @@
+"""
+===============================================================================
+FILE: modules/web_api/llm/schemas.py
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: web_api / llm
+ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.
+
+AI-CONTEXT-ANCHORS:
+ - ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
+ - ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
+ - ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
+===============================================================================
+"""
+
+# ANCHOR[SCHEMA_PROMPT_EDIT]
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_prompt_node_edit",
"description": (
- "ЕДИНСТВЕННЫЙ инструмент для добавления, изменения или УДАЛЕНИЯ пунктов системного промпта. "
- "Вызывай ВСЕГДА при фразах 'удали X.Y', 'удалить пункт X.Y', 'добавь X.Y', 'измени X.Y'. "
- "При удалении: action='DELETE', section_id=X, item_id=Y, content=''. "
- "Запрещено отвечать текстовым подтверждением без вызова этой функции!"
+ "Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
+ "Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
+ "При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
- "enum": ["ADD", "UPDATE", "DELETE"],
- "description": "ADD (добавить), UPDATE (изменить), DELETE (удалить)"
+ "enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
+ "description": "Тип действия"
},
"section_id": {
"type": "integer",
- "description": "Номер раздела (например: 1, 2, 3)"
+ "description": "Номер раздела (например 1)"
},
"item_id": {
"type": "integer",
- "description": "Номер пункта (например: 4, 8, 9)"
+ "description": "Номер пункта (например 8)"
+ },
+ "nodes_list": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
},
"content": {
"type": "string",
- "description": "Текст пункта (для DELETE передается пустая строка)"
+ "description": "Текст пункта"
}
},
- "required": ["action", "section_id", "item_id"]
+ "required": ["action"]
}
}
},
+ {
+ "type": "function",
+ "function": {
+ "name": "db_get_system_prompt",
+ "description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ }
+ }
+ },
+ # ANCHOR[SCHEMA_TASKS_EDIT]
{
"type": "function",
"function": {
"name": "db_get_tasks",
- "description": "ПОЛУЧИТЬ СПИСОК ЗАДАЧ ТЕКУЩЕГО ПОЛЬЗОВАТЕЛЯ. Вызывай СРАЗУ при запросе 'покажи мои задачи' или 'список задач'. ВАЖНОЕ ПРАВИЛО ВЫВОДА: Выводи задачи ЕДИНЫМ плоским списком (нумерованным или маркированным) по порядку ID. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО группировать задачи по статусам (В процессе, Бэклог, Завершены) или создавать подзаголовки, если оператор явно не попросил о группировке!",
+ "description": "Просмотр реестра задач и бэклога текущего пользователя.",
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
- "description": "Фильтр статуса: BACKLOG, IN_PROGRESS или COMPLETED."
+ "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
+ "description": "Опциональный фильтр статуса задач"
}
}
}
@@ -53,64 +85,70 @@ TOOLS_SCHEMA = [
{
"type": "function",
"function": {
- "name": "db_get_rules",
- "description": "ПОЛУЧИТЬ БАЗУ ЗНАНИЙ ИИ И ПРАВИЛА АРБИТРАЖА (ai_knowledge_base). Вызывай когда пользователь просит показать базу знаний, правила, инструкции или промпты.",
- "parameters": {"type": "object", "properties": {}}
- }
- },
- {
- "type": "function",
- "function": {
- "name": "db_get_system_prompt",
- "description": "ВЫЗЫВАЙ ВСЕГДА при наличии в сообщении фраз: 'покажи системный промпт', 'покажи промпт', 'выведи промпт', 'системный промпт'. Запрещено отвечать текстом без вызова этого инструмента.",
- "parameters": {
- "type": "object",
- "properties": {},
- "required": []
- }
- }
- },
- {
- "type": "function",
- "function": {
- "name": "db_update_task_status",
- "description": "Изменить статус и/или срок выполнения задачи в реестре.",
+ "name": "db_tasks_edit",
+ "description": (
+ "Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
+ "СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
+ "- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
+ "- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
+ "- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
+ "- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
+ ),
"parameters": {
"type": "object",
"properties": {
- "task_id": {"type": "string", "description": "Идентификатор задачи, например TASK-17"},
- "status": {"type": "string", "description": "Новый статус: COMPLETED, IN_PROGRESS или BACKLOG"},
- "due_date": {"type": "string", "description": "Срок выполнения задачи"}
+ "action": {
+ "type": "string",
+ "enum": ["ADD", "UPDATE", "DELETE", "EXPORT"],
+ "description": "Тип действия: ADD, UPDATE, DELETE или EXPORT"
+ },
+ "task_id": {
+ "type": "string",
+ "description": "Номер задачи (например: '37')"
+ },
+ "title": {
+ "type": "string",
+ "description": "Описание или текст задачи"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
+ "description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
+ },
+ "priority": {
+ "type": "string",
+ "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
+ "description": "Приоритет задачи"
+ },
+ "module": {
+ "type": "string",
+ "description": "Модуль проекта"
+ },
+ "due_date": {
+ "type": "string",
+ "description": "Срок в формате ГГГГ-ММ-ДД"
+ },
+ "filename": {
+ "type": "string",
+ "description": "Имя файла для экспорта"
+ }
},
- "required": ["task_id"]
- }
- }
- },
- {
- "type": "function",
- "function": {
- "name": "db_delete_task",
- "description": "Удалить задачу из реестра по её task_id (например, TASK-18).",
- "parameters": {
- "type": "object",
- "properties": {
- "task_id": {"type": "string", "description": "Идентификатор задачи для удаления, например TASK-18"}
- },
- "required": ["task_id"]
+ "required": ["action"]
}
}
},
+ # ANCHOR[SCHEMA_SNAPSHOTS]
{
"type": "function",
"function": {
"name": "db_get_snapshots",
- "description": "ПОЛУЧИТЬ АКТУАЛЬНЫЙ СПИСОК СНАПШОТОВ ИЗ БАЗЫ SQLITE. Вызывай ЭТУ ФУНКЦИЮ ВСЕГДА, даже если список снапшотов уже есть в истории чата или пользователь просит 'обновить', 'повторить запрос', 'проверить снова'. ЗАПРЕЩЕНО беречь контекст и выводить старые данные из истории!",
+ "description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.",
"parameters": {
"type": "object",
"properties": {
"date_str": {
"type": "string",
- "description": "Точная дата в формате ДД.ММ.ГГГГ (например, '12.08.2026'), взятая из [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА]."
+ "description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
}
}
}
@@ -120,12 +158,18 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_delete_snapshots",
- "description": "УДАЛИТЬ СНАПШОТ(Ы) ИЗ БАЗЫ ДАННЫХ. Вызывай, когда пользователь явно просит удалить конкретный снапшот по ID или все снапшоты за выбранный день.",
+ "description": "Удаление снапшотов СКУД по идентификатору или дате.",
"parameters": {
"type": "object",
"properties": {
- "snapshot_id": {"type": "string", "description": "Идентификатор снапшота для удаления, например Y20260805-007"},
- "day_str": {"type": "string", "description": "Дата в формате ДД.ММ.ГГГГ для удаления всех снапшотов за день"}
+ "snapshot_id": {
+ "type": "string",
+ "description": "Идентификатор конкретного снапшота"
+ },
+ "day_str": {
+ "type": "string",
+ "description": "Дата всех снапшотов за день"
+ }
}
}
}
@@ -133,29 +177,31 @@ TOOLS_SCHEMA = [
{
"type": "function",
"function": {
- "name": "db_get_current_server_time",
- "description": "ПОЛУЧИТЬ ТЕКУЩУЮ ДАТУ, ВРЕМЯ И ДЕНЬ НЕДЕЛИ СЕРВЕРА. Вызывай МГНОВЕННО при любых вопросах пользователя про точное текущее время или текущую дату.",
+ "name": "db_get_anomalies",
+ "description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
"parameters": {
"type": "object",
- "properties": {},
- "required": []
+ "properties": {
+ "date_str": {
+ "type": "string",
+ "description": "Опциональная дата в формате ДД.ММ.ГГГГ"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Лимит записей"
+ }
+ }
}
}
},
{
"type": "function",
"function": {
- "name": "db_add_task",
- "description": "Добавить новую задачу в бэклог проекта.",
+ "name": "db_get_rules",
+ "description": "Просмотр базы знаний и правил кадрового арбитража компании.",
"parameters": {
"type": "object",
- "properties": {
- "title": {"type": "string", "description": "Краткое описание задачи"},
- "priority": {"type": "string", "description": "Приоритет: HIGH, MEDIUM, LOW"},
- "module": {"type": "string", "description": "Модуль проекта, например general или services/scud_export"},
- "due_date": {"type": "string", "description": "Срок выполнения задачи, например '2026-08-07 12:00'"}
- },
- "required": ["title"]
+ "properties": {}
}
}
},
@@ -163,43 +209,35 @@ TOOLS_SCHEMA = [
"type": "function",
"function": {
"name": "db_get_stats",
- "description": "ПОЛУЧИТЬ ОБЩУЮ СТАТИСТИКУ БАЗЫ ДАННЫХ. Вызывай, когда пользователь просит показать общую статистику БД, количество записей в таблицах или размер базы.",
- "parameters": {"type": "object", "properties": {}}
- }
- },
- {
- "type": "function",
- "function": {
- "name": "db_get_anomalies",
- "description": "ПОЛУЧИТЬ ИСТОРИЮ АНОМАЛИЙ СКУД ⟷ 1С. Вызывай при запросах на просмотр аномалий или расхождений. Передавай date_str если пользователь просит аномалии за конкретный день, или увеличенный limit (например 100) если просит все.",
+ "description": "Получение статистики количества записей в таблицах базы данных.",
"parameters": {
"type": "object",
- "properties": {
- "limit": {"type": "integer", "description": "Максимальное количество записей (по умолчанию 100)"},
- "date_str": {"type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ"}
- }
+ "properties": {}
}
}
},
{
"type": "function",
"function": {
- "name": "db_get_session_states",
- "description": "ПОЛУЧИТЬ АКТИВНЫЕ СЕССИИ И ПРЕВЬЮ (session_states). Вызывай, когда пользователь просит показать текущие сессии или статус превью.",
- "parameters": {"type": "object", "properties": {}}
+ "name": "db_get_current_server_time",
+ "description": "Получение текущего точного времени сервера.",
+ "parameters": {
+ "type": "object",
+ "properties": {}
+ }
}
},
{
"type": "function",
"function": {
"name": "db_get_reference",
- "description": "ПОЛУЧИТЬ СИСТЕМНЫЙ СПРАВОЧНИК И ПРИМЕРЫ КОМАНД ДЛЯ ОПЕРАТОРА (system_reference). Вызывай ВСЕГДА, когда пользователь спрашивает про возможности ассистента, список команд, примерах промптов или справе по работе с системой.",
+ "description": "Справка о возможностях ассистента и примеры доступных команд.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
- "description": "Фильтр категории: scud, tasks, calendar или system. Если просят всё — не передавай параметр."
+ "description": "Категория справки"
}
}
}
diff --git a/modules/web_api/main.py b/modules/web_api/main.py
index 1a64668..fc65afd 100644
--- a/modules/web_api/main.py
+++ b/modules/web_api/main.py
@@ -16,6 +16,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR)
+ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
+
+for p in [ROOT_DIR, CURRENT_DIR]:
+ if p not in sys.path:
+ sys.path.insert(0, p)
+
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
@@ -25,6 +31,7 @@ from routers.auth import router as auth_router
from routers.admin import router as admin_router
from routers.tasks import router as tasks_router
from routers.chat import router as chat_router
+from routers.files import router as files_router
# ANCHOR[APP_CONFIG]
logging.basicConfig(
@@ -53,6 +60,7 @@ app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(tasks_router)
app.include_router(chat_router)
+app.include_router(files_router)
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
@@ -81,7 +89,6 @@ def serve_static_fallback(file_path: str):
return FileResponse(target, media_type="text/css")
return FileResponse(target)
- # Резервный рекурсивный поиск файла в static
filename = os.path.basename(clean_path)
for root, _, files in os.walk(STATIC_DIR):
if filename in files:
diff --git a/modules/web_api/routers/auth.py b/modules/web_api/routers/auth.py
index 73ba0c4..94ff0b1 100644
--- a/modules/web_api/routers/auth.py
+++ b/modules/web_api/routers/auth.py
@@ -5,8 +5,6 @@ ROLE: Аутентификация, валидация JWT-токенов и у
===============================================================================
"""
-# ANCHOR[AUTH_ROUTER_IMPORTS]
-import sqlite3
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
@@ -17,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
-from llm.db_tools import DB_PATH
+from core.connection import get_connection
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
@@ -27,11 +25,10 @@ security = HTTPBearer()
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
-# ANCHOR[AUTH_DB_HELPERS]
+
def get_db():
- conn = sqlite3.connect(DB_PATH)
- conn.row_factory = sqlite3.Row
- return conn
+ return get_connection(row_factory=True)
+
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
@@ -42,6 +39,7 @@ def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
+
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
@@ -58,21 +56,20 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
headers={"WWW-Authenticate": "Bearer"},
)
-# ANCHOR[AUTH_SCHEMAS]
+
class AuthRequest(BaseModel):
username: str
password: str
+
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
-# ANCHOR[AUTH_ENDPOINTS]
+
@router.post("/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
- logging.info(f"===> Попытка входа для пользователя: {username}")
-
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
@@ -80,15 +77,14 @@ def login(req: AuthRequest):
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
- logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
- logging.info(f"===> УСПЕХ: Авторизован пользователь {username}")
return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
+
@router.post("/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
@@ -108,5 +104,4 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D
conn.commit()
conn.close()
- logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"}
\ No newline at end of file
diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py
index 1dce55e..256c78b 100644
--- a/modules/web_api/routers/chat.py
+++ b/modules/web_api/routers/chat.py
@@ -1,93 +1,97 @@
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
-PROJECT: SCUD Orion AI (Unified Architecture)
-MODULE: web_api / routers
-ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков.
+ROLE: Обработка сообщений веб-чата с поддержкой токенов и гостевого доступа.
===============================================================================
"""
-# ANCHOR[CHAT_ROUTER_IMPORTS]
-from typing import Optional, Dict, Any
-from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
+from fastapi import APIRouter, Header, HTTPException, Request
from pydantic import BaseModel
+from typing import Optional, List, Dict, Any
+import logging
-from .auth import get_current_user
-from llm.agent import process_chat_message
-from llm.file_parser import extract_text_from_file
-from llm.db_tools import db_set_session_state, db_get_session_state
+from services.text_reporter import ask_ollama
+from services.knowledge_base import load_knowledge_base
+from core.database import get_connection
-router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
+logger = logging.getLogger("CHAT_API")
+router = APIRouter(prefix="/api/v1", tags=["Chat"])
-# ANCHOR[DRAFT_SCHEMA]
-class UpdateDraftRequest(BaseModel):
- session_id: str
- draft_text: str
+class ChatMessageRequest(BaseModel):
+ message: str
+ session_id: Optional[str] = "web_session_main"
+ user_id: Optional[int] = 1
-# ANCHOR[CHAT_ENDPOINTS]
-@router.post("")
-async def chat_endpoint(
- session_id: str = Form("web_session_main"),
- message: str = Form(""),
- file: Optional[UploadFile] = File(default=None),
- current_user: Dict[str, Any] = Depends(get_current_user)
-):
- """Диалог авторизованного пользователя с агентом."""
- parsed_file = {"text": "", "image_b64": None}
- if file and file.filename:
- file_bytes = await file.read()
- parsed_file = extract_text_from_file(file_bytes, file.filename)
+def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
+ """
+ Извлекает ID пользователя из Bearer-токена.
+ Если токен не передан или сессия новая — использует user_id=1 по умолчанию,
+ не блокируя работу ошибкой 403 Forbidden.
+ """
+ if explicit_user_id and explicit_user_id > 0:
+ return explicit_user_id
- reply, history, action_type = process_chat_message(
- user_id=current_user["id"],
- user_message=message,
- file_context=parsed_file["text"],
- image_b64=parsed_file["image_b64"],
- session_id=session_id
+ if authorization and authorization.startswith("Bearer "):
+ token = authorization.replace("Bearer ", "").strip()
+ # Если используется простой токен вида 'user_1' или JWT
+ if token.isdigit():
+ return int(token)
+ elif token.startswith("dev_token_"):
+ try:
+ return int(token.replace("dev_token_", ""))
+ except ValueError:
+ pass
+
+ # Дефолтный пользователь (гостевой / основной аккаунт)
+ return 1
+
+
+@router.post("/chat")
+async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)):
+ user_id = resolve_user_id(authorization, payload.user_id)
+ session_id = payload.session_id or "web_session_main"
+ user_msg = payload.message.strip()
+
+ if not user_msg:
+ raise HTTPException(status_code=400, detail="Пустое сообщение")
+
+ logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
+
+ # Загружаем контекст базы знаний
+ kb = load_knowledge_base()
+ rules_text = "\n".join([f"- {r}" for r in kb.get("rules", [])])
+
+ system_prompt = (
+ "Ты — ИИ-ассистент системы кадровой безопасности и контроллинга СКУД Orion AI.\n"
+ "Отвечай четко, профессионально и на русском языке.\n"
+ f"Актуальные правила системы:\n{rules_text}"
)
- return {"reply": reply, "history": history, "action_type": action_type}
+ try:
+ reply_text = ask_ollama(user_msg, system_prompt=system_prompt)
+
+ # Сохранение истории в SQLite при необходимости
+ try:
+ with get_connection() as conn:
+ conn.execute(
+ "INSERT INTO chat_messages (session_id, role, content) VALUES (?, ?, ?), (?, ?, ?)",
+ (session_id, "user", user_msg, session_id, "assistant", reply_text)
+ )
+ conn.commit()
+ except Exception:
+ pass
-@router.post("/guest")
-async def guest_chat_endpoint(
- session_id: str = Form("web_session_main"),
- message: str = Form(""),
- file: Optional[UploadFile] = File(default=None)
-):
- """Гостевой диалог (user_id=0)."""
- parsed_file = {"text": "", "image_b64": None}
- if file and file.filename:
- file_bytes = await file.read()
- parsed_file = extract_text_from_file(file_bytes, file.filename)
-
- reply, history, action_type = process_chat_message(
- user_id=0,
- user_message=message,
- file_context=parsed_file["text"],
- image_b64=parsed_file["image_b64"],
- session_id=session_id
- )
- return {"reply": reply, "history": history, "action_type": action_type}
-
-
-@router.post("/draft")
-def update_draft_endpoint(
- req: UpdateDraftRequest,
- current_user: Dict[str, Any] = Depends(get_current_user)
-):
- """Обновляет черновик системного промпта напрямую из интерактивной онлайн-формы."""
- state = db_get_session_state(req.session_id)
- if not state or state.get("state_type") != "PROMPT_PREVIEW":
- raise HTTPException(status_code=400, detail="Нет активного превью для редактирования")
-
- # Сохраняем чистый текст с пометкой MANUAL_EDIT
- db_set_session_state(req.session_id, "PROMPT_PREVIEW", {
- "draft_text": req.draft_text.strip(),
- "action": "MANUAL_EDIT",
- "section_id": None,
- "item_id": None,
- "content": ""
- })
- return {"status": "success", "message": "Черновик успешно обновлен в сессии"}
\ No newline at end of file
+ return {
+ "status": "success",
+ "user_id": user_id,
+ "session_id": session_id,
+ "response": reply_text
+ }
+ except Exception as e:
+ logger.error(f"Ошибка вызова нейросети: {e}")
+ return {
+ "status": "error",
+ "response": f"⚠️ Ошибка обработки запроса: {str(e)}"
+ }
\ No newline at end of file
diff --git a/modules/web_api/routers/exceptions.py b/modules/web_api/routers/exceptions.py
new file mode 100644
index 0000000..d2a8372
--- /dev/null
+++ b/modules/web_api/routers/exceptions.py
@@ -0,0 +1,31 @@
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+from typing import Optional, Dict, List
+from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db
+
+router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"])
+
+
+class ExceptionItem(BaseModel):
+ category: str
+ value: str
+ comment: Optional[str] = ""
+
+
+@router.get("/")
+def api_get_exceptions():
+ return get_all_exceptions_from_db()
+
+
+@router.post("/")
+def api_add_exception(item: ExceptionItem):
+ if not add_exception_to_db(item.category, item.value, item.comment):
+ raise HTTPException(status_code=400, detail="Ошибка добавления исключения")
+ return {"status": "success", "data": item}
+
+
+@router.delete("/")
+def api_delete_exception(category: str, value: str):
+ if not remove_exception_from_db(category, value):
+ raise HTTPException(status_code=404, detail="Исключение не найдено")
+ return {"status": "success"}
\ No newline at end of file
diff --git a/modules/web_api/routers/files.py b/modules/web_api/routers/files.py
new file mode 100644
index 0000000..a411fea
--- /dev/null
+++ b/modules/web_api/routers/files.py
@@ -0,0 +1,73 @@
+"""
+===============================================================================
+FILE: modules/web_api/routers/files.py
+ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
+ через изолированные UUID-директории инструментов.
+===============================================================================
+"""
+
+import os
+import time
+import shutil
+import urllib.parse
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse
+
+router = APIRouter(prefix="/api/v1/files", tags=["Files"])
+
+BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
+WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
+os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
+
+SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
+
+
+def purge_old_tool_sessions(tool_dir_path: str):
+ """Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
+ if not os.path.exists(tool_dir_path):
+ return
+ now = time.time()
+ cutoff = now - (SESSION_TTL_HOURS * 3600)
+ try:
+ for entry in os.listdir(tool_dir_path):
+ subpath = os.path.join(tool_dir_path, entry)
+ if os.path.isdir(subpath):
+ if os.path.getmtime(subpath) < cutoff:
+ shutil.rmtree(subpath, ignore_errors=True)
+ except Exception:
+ pass
+
+
+@router.get("/download/{tool_name}/{session_uuid}/{filename}")
+async def download_file(tool_name: str, session_uuid: str, filename: str):
+ """
+ Безопасная отдача файла с каноническим именем из изолированной директории.
+ """
+ safe_tool = os.path.basename(tool_name)
+ safe_uuid = os.path.basename(session_uuid)
+ safe_filename = os.path.basename(filename)
+
+ file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename)
+
+ if not os.path.exists(file_path) or not os.path.isfile(file_path):
+ raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
+
+ # Определение MIME-типа
+ media_type = "application/octet-stream"
+ if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
+ media_type = "text/markdown; charset=utf-8"
+ elif safe_filename.endswith(".xlsx"):
+ media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ elif safe_filename.endswith(".pdf"):
+ media_type = "application/pdf"
+
+ # Корректная кодировка для кириллических имен файлов
+ encoded_filename = urllib.parse.quote(safe_filename)
+
+ return FileResponse(
+ path=file_path,
+ media_type=media_type,
+ headers={
+ "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
+ }
+ )
\ No newline at end of file
diff --git a/modules/web_api/routers/tasks.py b/modules/web_api/routers/tasks.py
index 5260cfb..3b0ca06 100644
--- a/modules/web_api/routers/tasks.py
+++ b/modules/web_api/routers/tasks.py
@@ -1,71 +1,76 @@
"""
===============================================================================
FILE: modules/web_api/routers/tasks.py
-ROLE: REST API управления задачами (GET / POST / PATCH / DELETE).
+PROJECT: SCUD Orion AI (Unified Architecture)
+MODULE: web_api / routers
+ROLE: REST API эндпоинты реестра задач.
===============================================================================
"""
-# ANCHOR[TASKS_ROUTER_IMPORTS]
-from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
+from typing import Optional, Dict, Any
-from .auth import get_current_user
-from llm.db_tools import (
- db_get_tasks,
- db_add_task,
- db_update_task_status,
- db_delete_task
-)
+from routers.auth import get_current_user
+from services.tasks.service import get_tasks, add_task, update_task_details
-router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
+router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
-# ANCHOR[TASKS_SCHEMAS]
-class CreateTaskRequest(BaseModel):
+
+class TaskCreateRequest(BaseModel):
title: str
priority: Optional[str] = "MEDIUM"
module: Optional[str] = "general"
due_date: Optional[str] = None
+ status: Optional[str] = "BACKLOG"
-class UpdateTaskRequest(BaseModel):
- status: Optional[str] = "COMPLETED"
+
+class TaskUpdateRequest(BaseModel):
+ title: Optional[str] = None
+ priority: Optional[str] = None
due_date: Optional[str] = None
+ status: Optional[str] = None
+
+
+def resolve_user_id(current_user: Dict[str, Any]) -> int:
+ if not current_user:
+ return 1
+ return current_user.get("id") or current_user.get("user_id") or 1
+
-# ANCHOR[TASKS_ENDPOINTS]
@router.get("")
-def get_tasks(user: Dict[str, Any] = Depends(get_current_user)):
- """Получить список всех задач текущего авторизованного пользователя."""
- return db_get_tasks(user_id=user["id"])
+async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
+ user_id = resolve_user_id(current_user)
+ return {"tasks": get_tasks(user_id=user_id, status=status)}
+
@router.post("")
-def create_task_endpoint(req: CreateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
- """Прямое создание задачи."""
- res = db_add_task(
- user_id=user["id"],
- module=req.module or "general",
- title=req.title.strip(),
- priority=req.priority or "MEDIUM",
- due_date=req.due_date
- )
- return res
-
-@router.patch("/{task_id}")
-def update_task_endpoint(task_id: str, req: UpdateTaskRequest, user: Dict[str, Any] = Depends(get_current_user)):
- """Прямое обновление статуса и срока задачи."""
- res = db_update_task_status(
- user_id=user["id"],
- task_id=task_id,
- status=req.status or "COMPLETED",
- due_date=req.due_date
+async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
+ user_id = resolve_user_id(current_user)
+ res = add_task(
+ user_id=user_id,
+ module=req.module,
+ title=req.title,
+ priority=req.priority,
+ due_date=req.due_date,
+ status=req.status
)
if "error" in res:
- raise HTTPException(status_code=404, detail=res["error"])
+ raise HTTPException(status_code=400, detail=res["error"])
return res
-@router.delete("/{task_id}")
-def delete_task_endpoint(task_id: str, user: Dict[str, Any] = Depends(get_current_user)):
- """Прямое удаление задачи."""
- res = db_delete_task(user_id=user["id"], task_id=task_id)
+
+@router.patch("/{task_id}")
+async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
+ user_id = resolve_user_id(current_user)
+ res = update_task_details(
+ user_id=user_id,
+ task_id=task_id,
+ title=req.title,
+ priority=req.priority,
+ status=req.status,
+ due_date=req.due_date
+ )
if "error" in res:
raise HTTPException(status_code=404, detail=res["error"])
return res
\ No newline at end of file
diff --git a/modules/web_api/static/index.html b/modules/web_api/static/index.html
index f537f33..a21f5a3 100644
--- a/modules/web_api/static/index.html
+++ b/modules/web_api/static/index.html
@@ -2,263 +2,164 @@
-
- SCUD Orion AI — Context Manager
+
+ SCUD Orion AI — Управление и Аналитика
+
+
-
+
-
+
-
-
-
-
-
-
-
-
-
SCUD Orion AI
-
Авторизация в системе
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Или
-
-
-
-
-
-
-
-
-
-
-
- Смена пароля
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Управление пользователями
-
-
-
-
-
-
Создать нового пользователя
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Загрузка пользователей...
-
-
-
-
-
-
-
-
-
-
-
-
SCUD Orion AI
- Task & Context API
-
-
-
-
-
- Гость
-
-
-
- puh
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Перетащите файл сюда
-
Поддерживаются PDF, изображения, таблицы, TXT
-
-
-
-
- ИИ-Ассистент
-
-
- Привет! У каждого пользователя свое изолированное пространство задач. Вы можете задавать вопросы нейросети, прикреплять файлы или ставить персональные задачи.
-