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..fe29767 --- /dev/null +++ b/core/repositories/scud_repo.py @@ -0,0 +1,199 @@ +""" +=============================================================================== +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() + if snapshot_param: + df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(str(snapshot_param),)) + + if df.empty and date_str: + cursor = conn.cursor() + 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 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]: + 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': 'Конец_дня', + 'duration': 'Находился_в_здании', 'is_present': 'Пришел' + } + df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) + 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..0bcdae7 --- /dev/null +++ b/core/schema.py @@ -0,0 +1,151 @@ +""" +=============================================================================== +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 + ); + """) + + # 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 b4b3361..ae356bf 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py index f0264b1..fdfcea6 100644 --- a/modules/web_api/llm/db_tools.py +++ b/modules/web_api/llm/db_tools.py @@ -3,42 +3,61 @@ FILE: modules/web_api/llm/db_tools.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm -ROLE: Фасадная точка доступа ко всем функциям базы данных SQLite. +ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade). + +AI-CONTEXT-ANCHORS: + - ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API. =============================================================================== """ -from .db.connection import DB_PATH, get_db_connection -from .db.db_prompts import ( - db_get_active_system_prompt, - db_add_system_prompt, - db_apply_prompt_node_action, - db_get_tool_action, - db_set_session_state, - db_get_session_state, - db_clear_session_state, - db_get_rules, - db_get_stats, - db_get_anomalies, - db_get_reference +# ANCHOR[FACADE_EXPORTS] +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 as db_add_system_prompt, + create_prompt_preview +) + +# Домен: Снапшоты СКУД +from services.snapshots.service import ( + get_snapshots_registry as db_get_snapshots, + delete_snapshots_safely as db_delete_snapshots +) + +# Домен: База знаний +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_tasks import ( - normalize_task_id, - db_get_tasks, - db_add_task, - db_update_task_status, - db_update_task_details, - db_delete_task, - db_export_tasks_markdown, - db_tasks_edit -) -from .db.db_snapshots import ( - db_get_snapshots, - db_delete_snapshots +from .db.db_prompts import ( + db_get_tool_action, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_stats, + db_get_anomalies, + db_get_reference ) from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time \ No newline at end of file diff --git a/services/ai_verifier.py b/services/ai_verifier.py index b33b361..91b20d6 100644 --- a/services/ai_verifier.py +++ b/services/ai_verifier.py @@ -151,7 +151,7 @@ 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] @@ -163,7 +163,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): prompt = f""" Ты — кадровый аудитор безопасности СКУД. {rules_context} -В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)} +В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)} В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)} СТРОГИЕ ПРАВИЛА: @@ -173,40 +173,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): 4. В поле "warning" опиши обнаруженную опечатку. 5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев! -ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON! - -Формат ответа: +ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON: {{ "verified_matches": [ {{ "scud_fio": "ФИО из СКУД", "staff_fio": "эталон ФИО из Штат 1С", - "warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'" + "warning": "Описание опечатки в СКУД" }} ] }} """ raw_response = ask_ollama( prompt, - system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки." + 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) - json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str) + # Убираем висячие запятые: {"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: + 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"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}") - + print(f"[!] Ошибка резервного парсинга опечаток: {e}") + return mapping diff --git a/services/knowledge/__init__.py b/services/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/knowledge/service.py b/services/knowledge/service.py new file mode 100644 index 0000000..c0f09a0 --- /dev/null +++ b/services/knowledge/service.py @@ -0,0 +1,37 @@ +""" +=============================================================================== +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) \ No newline at end of file diff --git a/services/prompts/__init__.py b/services/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/prompts/diff_engine.py b/services/prompts/diff_engine.py new file mode 100644 index 0000000..ab0c677 --- /dev/null +++ b/services/prompts/diff_engine.py @@ -0,0 +1,77 @@ +""" +=============================================================================== +FILE: services/prompts/diff_engine.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / prompts +ROLE: Формирование превью изменений промпта и генерация визуального HTML-Diff. + +AI-CONTEXT-ANCHORS: + - ANCHOR[PROMPT_DIFF_BUILDER]: Генерация черновика и HTML-разметки изменений. +=============================================================================== +""" + +from typing import Tuple, Dict +from core.connection import get_connection + + +# ANCHOR[PROMPT_DIFF_BUILDER] +def build_prompt_diff(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> Tuple[str, str]: + """ + Возвращает кортеж (merged_draft_text, diff_html_for_ui). + """ + act = (action or "ADD").upper() + + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = ? AND is_active = 1 + ORDER BY section_id, item_id + """, (prompt_name,)) + existing_nodes = cursor.fetchall() + + nodes_dict = {(r["section_id"], r["item_id"]): r["content"] for r in existing_nodes} + + # Формируем словарь для чистого текста + nodes_dict_for_draft = {k: v for k, v in nodes_dict.items() if k != (section_id, item_id)} if act == "DELETE" else dict(nodes_dict) + if act != "DELETE": + nodes_dict_for_draft[(section_id, item_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 act != "DELETE": + display_nodes[(section_id, item_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 == section_id and i_id == item_id: + if act == "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) + return merged_prompt, diff_html \ No newline at end of file diff --git a/services/prompts/repository.py b/services/prompts/repository.py new file mode 100644 index 0000000..2ea9b7f --- /dev/null +++ b/services/prompts/repository.py @@ -0,0 +1,114 @@ +""" +=============================================================================== +FILE: services/prompts/repository.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / prompts +ROLE: Реляционное хранилище узлов системного промпта (таблица system_prompt_nodes). + +AI-CONTEXT-ANCHORS: + - ANCHOR[PROMPT_REPO_GET_ACTIVE]: Сборка активного промпта из узлов БД. + - ANCHOR[PROMPT_REPO_APPLY_ACTION]: Точечная вставка / изменение / удаление узла. +=============================================================================== +""" + +import re +import logging +from typing import List, Tuple, Dict, Any +from core.connection import get_connection + +logger = logging.getLogger("PROMPT_REPO") + + +# ANCHOR[PROMPT_REPO_GET_ACTIVE] +def repo_get_active_prompt(prompt_name: str = "main_agent") -> str: + """Собирает структурированный текст системного промпта из активных узлов.""" + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT section_id, item_id, content + FROM system_prompt_nodes + WHERE prompt_name = ? AND is_active = 1 + ORDER BY section_id ASC, item_id ASC + """, (prompt_name,)) + rows = cursor.fetchall() + + if not rows: + return "Ты — ИИ-ассистент SCUD Orion AI." + + lines = [] + current_section = None + + for r in rows: + sec_id = r["section_id"] + itm_id = r["item_id"] + content = r["content"] + + if itm_id == 0: + if current_section is not None: + lines.append("") + lines.append(f"{sec_id}. {content}") + current_section = sec_id + else: + lines.append(f" {sec_id}.{itm_id}. {content}") + + return "\n".join(lines) + + +# ANCHOR[PROMPT_REPO_APPLY_ACTION] +def repo_apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent") -> None: + """Точечно применяет действие (ADD / UPDATE / DELETE) над узлом промпта.""" + with get_connection() as conn: + cursor = conn.cursor() + action_clean = (action or "").upper() + if action_clean in ["ADD", "UPDATE", "EDIT"]: + cursor.execute(""" + INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET + content = excluded.content, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, (prompt_name, section_id, item_id, content)) + elif action_clean == "DELETE": + cursor.execute(""" + DELETE FROM system_prompt_nodes + WHERE prompt_name = ? AND section_id = ? AND item_id = ? + """, (prompt_name, section_id, item_id)) + conn.commit() + + +def repo_save_full_prompt(prompt_text: str, prompt_name: str = "main_agent") -> None: + """Парсит и полностью перезаписывает все узлы промпта из сырого текста.""" + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (prompt_name,)) + + current_sec = 1 + current_itm = 0 + + for raw_line in prompt_text.splitlines(): + clean_line = re.sub(r'<[^>]+>', '', raw_line).strip() + if not clean_line: + continue + + sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line) + sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line) + + if sub_match: + current_sec = int(sub_match.group(1)) + current_itm = int(sub_match.group(2)) + content = sub_match.group(3).strip() + elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]): + current_sec = int(sec_match.group(1)) + current_itm = 0 + content = sec_match.group(2).strip() + else: + current_itm += 1 + content = clean_line + + cursor.execute(""" + INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active) + VALUES (?, ?, ?, ?, 1) + """, (prompt_name, current_sec, current_itm, content)) + + conn.commit() \ No newline at end of file diff --git a/services/prompts/service.py b/services/prompts/service.py new file mode 100644 index 0000000..2906e29 --- /dev/null +++ b/services/prompts/service.py @@ -0,0 +1,37 @@ +""" +=============================================================================== +FILE: services/prompts/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / prompts +ROLE: Единый доменный сервис управления системным промптом. +=============================================================================== +""" + +from typing import Tuple, Dict, Any +from .repository import repo_get_active_prompt, repo_apply_prompt_action, repo_save_full_prompt +from .diff_engine import build_prompt_diff + + +def get_active_system_prompt() -> str: + """Получить текущий активный системный промпт.""" + return repo_get_active_prompt() + + +def apply_prompt_action(action: str, section_id: int, item_id: int, content: str = "") -> None: + """Применить точечное изменение к узлу промпта.""" + repo_apply_prompt_action(action, section_id, item_id, content) + + +def save_full_prompt_draft(draft_text: str) -> None: + """Сохранить полный черновик промпта.""" + repo_save_full_prompt(draft_text) + + +def create_prompt_preview(action: str, section_id: int, item_id: int, content: str = "") -> Tuple[str, str, str]: + """ + Формирует черновик и diff. + Возвращает (merged_draft, diff_html, baseline_prompt). + """ + baseline = repo_get_active_prompt() + draft, diff_html = build_prompt_diff(action, section_id, item_id, content) + return draft, diff_html, baseline \ No newline at end of file diff --git a/services/snapshots/__init__.py b/services/snapshots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/tasks/__init__.py b/services/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/tasks/exporter.py b/services/tasks/exporter.py new file mode 100644 index 0000000..54307da --- /dev/null +++ b/services/tasks/exporter.py @@ -0,0 +1,101 @@ +""" +=============================================================================== +FILE: services/tasks/exporter.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / tasks +ROLE: Экспорт бэклога задач в форматированный Markdown файл (ROADMAP). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_EXPORT_MARKDOWN]: Построение структуры Markdown с чекбоксами. +=============================================================================== +""" + +import os +import uuid +import logging +from datetime import datetime +from typing import Dict, Any, Optional, List +from config import OUTPUT_DIR +from .repository import repo_get_tasks + +logger = logging.getLogger("TASK_EXPORTER") + + +# ANCHOR[TASK_EXPORT_MARKDOWN] +def export_tasks_to_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]: + """Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/.""" + tasks = repo_get_tasks(user_id) + if not tasks: + return {"status": "error", "message": "Список задач пуст, экспорт отменен"} + + # 1. Фильтрация задач по статусу + if status_filter and status_filter.upper() != "ALL": + tgt = status_filter.upper() + if tgt in ["COMPLETED", "DONE", "ВЫПОЛНЕННЫЕ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["COMPLETED", "DONE"]] + elif tgt in ["IN_PROGRESS", "PROGRESS", "В РАБОТЕ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["IN_PROGRESS", "PROGRESS"]] + elif tgt in ["BACKLOG", "PLANNED", "В ПЛАНАХ"]: + tasks = [t for t in tasks if str(t.get("status", "")).upper() in ["BACKLOG", "PLANNED"]] + + if not tasks: + return {"status": "error", "message": f"Нет задач с фильтром '{status_filter}' для экспорта"} + + target_filename = filename.strip() if (filename and filename.strip()) else "ROADMAP.md" + if not target_filename.endswith(".md"): + target_filename = f"{target_filename}.md" + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M") + + # 2. Группировка по модулям + modules: Dict[str, List[Dict[str, Any]]] = {} + for t in tasks: + mod = t.get("module") or "general" + modules.setdefault(mod, []).append(t) + + md_lines = [ + "# 🗺️ Дорожная карта задач проекта (ROADMAP)\n", + f"> **Сформировано:** {now_str} | **Всего задач:** {len(tasks)}\n", + "---\n" + ] + + for mod_name, mod_tasks in sorted(modules.items()): + md_lines.append(f"## Модуль `{mod_name}`\n") + for t in sorted(mod_tasks, key=lambda x: x.get("id", 0)): + status = str(t.get("status", "BACKLOG")).upper() + is_done = status in ["COMPLETED", "DONE"] + is_progress = status in ["IN_PROGRESS", "PROGRESS"] + + check_box = "[x]" if is_done else "[ ]" + t_id = t.get("id") + title = t.get("title", "Без названия") + prio = t.get("priority", "MEDIUM") + due = f" *(срок: {t['due_date']})*" if t.get("due_date") else "" + status_tag = " `[В РАБОТЕ]`" if is_progress else (" `[ЗАВЕРШЕНО]`" if is_done else "") + + md_lines.append(f"- {check_box} **#{t_id}** [{prio}]{status_tag} {title}{due}") + + md_lines.append("\n---\n") + + content = "\n".join(md_lines) + + # 3. Сохранение файла в изолированную сессионную папку + tool_dir = os.path.join(OUTPUT_DIR, "web", "tasks_export") + os.makedirs(tool_dir, exist_ok=True) + + session_token = uuid.uuid4().hex[:8] + session_dir = os.path.join(tool_dir, session_token) + os.makedirs(session_dir, exist_ok=True) + + filepath = os.path.join(session_dir, target_filename) + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + return { + "status": "success", + "filename": target_filename, + "filepath": filepath, + "download_url": f"/api/v1/files/download/tasks_export/{session_token}/{target_filename}", + "tasks_count": len(tasks), + "message": f"Отчет успешно сформирован в файл `{target_filename}` (всего задач: {len(tasks)})." + } \ No newline at end of file diff --git a/services/tasks/repository.py b/services/tasks/repository.py new file mode 100644 index 0000000..4c51c73 --- /dev/null +++ b/services/tasks/repository.py @@ -0,0 +1,181 @@ +""" +=============================================================================== +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} удалена"} \ No newline at end of file diff --git a/services/tasks/service.py b/services/tasks/service.py new file mode 100644 index 0000000..ada0787 --- /dev/null +++ b/services/tasks/service.py @@ -0,0 +1,72 @@ +""" +=============================================================================== +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}'"} \ No newline at end of file