# 📦 ОПТИМИЗИРОВАННЫЙ СЛЕПОК ИСХОДНОГО КОДА scud_orion_ai ## File: `./config.py` ```py import os import re from datetime import datetime, timedelta BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, "data") SCUD_DIR = os.path.join(DATA_DIR, "scud") ZUP_1C_DIR = os.path.join(DATA_DIR, "1c") OUTPUT_DIR = os.path.join(BASE_DIR, "output") # Путь к намонтированной сетевой шаре 1С в Linux (вместо \\storage\SCUD\Обмен\Штат) SHARE_1C_DIR = "/mnt/scud_share" for folder in [DATA_DIR, SCUD_DIR, ZUP_1C_DIR, OUTPUT_DIR]: os.makedirs(folder, exist_ok=True) NOW = datetime.now() DATE_TODAY = NOW.strftime("%d.%m.%Y") if NOW.weekday() == 0: DATE_YESTERDAY = (NOW - timedelta(days=3)).strftime("%d.%m.%Y") else: DATE_YESTERDAY = (NOW - timedelta(days=1)).strftime("%d.%m.%Y") # --- Настройки LLM / Ollama --- OLLAMA_URL = "http://192.168.11.3:11434/api/generate" OLLAMA_MODEL = "qwen2.5:14b" MODEL_NAME = OLLAMA_MODEL KNOWLEDGE_BASE_PATH = os.path.join(DATA_DIR, "knowledge_base.json") EXCEPTIONS_PATH = os.path.join(BASE_DIR, "exceptions.json") # --- Настройки подключения к MS SQL Server (1С:ЗУП 3.1) --- ZUP_SQL_CONFIG = { "driver": "{ODBC Driver 18 for SQL Server}", # или "{ODBC Driver 17 for SQL Server}" "server": os.getenv("ZUP_SQL_SERVER", "ACCOUNT-01"), "database": os.getenv("ZUP_SQL_DB", "ZUP30"), # Замените на точное имя базы ЗУП на ACCOUNT-01 "user": os.getenv("ZUP_SQL_USER", "scud_reader"), # Логин SQL Server "password": os.getenv("ZUP_SQL_PASS", "Rhfcysq90"), # Пароль SQL Server "trust_server_certificate": "yes", # Доверять сертификату сервера "encrypt": "no" # "yes", если на MS SQL включено обязательное SSL-шифрование } def find_dated_file(prefix, date_str, search_dirs=[ZUP_1C_DIR, SCUD_DIR, DATA_DIR, "."]): """ Ищет файлы по префиксу и дате (поддерживает и 30.07.2026, и 30_07_2026). """ date_dots = date_str date_underscores = date_str.replace('.', '_') for d in search_dirs: if not os.path.exists(d): continue for f in os.listdir(d): if f.endswith('.xlsx') or f.endswith('.csv'): if f.lower().startswith(prefix.lower()): if date_dots in f or date_underscores in f: return os.path.join(d, f) return None def normalize_fio(fio): if not fio or not isinstance(fio, str): return "" fio_clean = re.sub(r'\(.*?\)', '', fio) fio_clean = fio_clean.replace('\xa0', ' ') parts = fio_clean.strip().split() return " ".join(parts).title() def clean_scud_fio_light(fio_str): return normalize_fio(fio_str) def load_exceptions(): import json if os.path.exists(EXCEPTIONS_PATH): try: with open(EXCEPTIONS_PATH, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {"fio": [], "departments": [], "positions": [], "position_keywords": []} ``` ## File: `./exceptions.json` ```json { "departments": [ "ОВК" ], "positions": [ "Уборщик производственных помещений", "Уборщик служебных помещений" ], "fio": [ "Таткало Валерий Валерьевич", "Петренюк Андрей Германович", "Чуркина Елена Геннадьевна", "Михалев Сергей Геннадьевич" ], "position_keywords": [ "уборщик", "клинер", "дворник", "гардероб", "рабочий по обслуживанию" ] } ``` ## File: `./main_etl.py` ```py """ =============================================================================== FILE: main_etl.py PROJECT: SCUD Orion AI (Unified Architecture) ROLE: Главная CLI-точка входа для запуска ежедневного контроллинга СКУД ⟷ 1С. AI-CONTEXT-ANCHORS: - ANCHOR[MAIN_CLI_ENTRY]: Парсинг CLI-флагов и запуск конвейера. =============================================================================== """ import sys import argparse from datetime import datetime from core.database import init_db, get_latest_snapshot_time from services.share_copier import copy_1c_files_from_share from services.data_validator import check_file_freshness from services.scud_export import run_export from services.scud_etl.pipeline import run_controlling_pipeline # ANCHOR[MAIN_CLI_ENTRY] def main(): help_text = """ Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai) ПРИМЕРЫ ЗАПУСКА: python main_etl.py -- Обычный дневной запуск python main_etl.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite python main_etl.py --snapshot 20260820-001 -- Расчет отчета строго по ID снапшота python main_etl.py -d -- Запуск в режиме расширенной отладки (DEBUG) """ parser = argparse.ArgumentParser(description=help_text, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('-d', '--debug', action='store_true', help="Режим отладки (DEBUG)") parser.add_argument('--skip-export', action='store_true', help="Расчет отчета по последнему снапшоту из SQLite") parser.add_argument('--snapshot', type=str, default=None, help="ID конкретного снапшота") args = parser.parse_args() print("=" * 60) print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if args.debug else ''}") print("=" * 60) init_db() # Определение режима работы и вывод статусов if args.snapshot: print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{args.snapshot}'") elif args.skip_export: last_snap = get_latest_snapshot_time() print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{last_snap}')") else: current_snap = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{current_snap}'") has_today_1c = True # Прямой экспорт и проверка сетевой шары (если не режим снапшотов) if not args.skip_export and not args.snapshot: try: run_export(save_xlsx=True, debug=args.debug) except Exception as e: print(f"[⚠️] Ошибка автоэкспорта из MS SQL: {e}. Переходим к записям SQLite.") 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("🛑 ОСТАНОВКА: Отсутствуют критически важные файлы за вчера!") for e in errors: print(f" {e}") sys.exit(1) print("[✓] Проверка доступности данных успешно пройдена!\n") else: print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...") # Запуск конвейера run_controlling_pipeline( snapshot_param=args.snapshot, skip_export=args.skip_export, debug=args.debug, has_today_1c=has_today_1c ) if __name__ == "__main__": main() ``` ## File: `./scripts/__init__.py` ```py ``` ## File: `./scripts/add_rule_ai.py` ```py import sys import os # Добавляем корень проекта в путь поиска модулей Python sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.database import get_connection rule_text = ( "Сотрудники, присутствующие в 1С:ЗУП, но отсутствующие в СКУД Орион Pro, " "являются аномалией синхронизации профилей. ИИ должен запрашивать у СБ статус выдачи пропуска." ) with get_connection() as conn: cursor = conn.cursor() cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text) VALUES (?)", (rule_text,)) conn.commit() print("✓ Правило успешно внесено в SQLite БД!") ``` ## File: `./scripts/db_cli.py` ```py import os import sys import argparse import sqlite3 import pandas as pd from datetime import datetime sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from config import DATA_DIR, DATE_TODAY, OUTPUT_DIR from core.database import ( get_connection, get_available_snapshots, get_all_rules_from_db, load_scud_from_db_by_snapshot, get_latest_snapshot_time ) DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") def print_tool_actions(): """Выводит реестр декларативных действий инструментов и шаблоны кнопок.""" print("\n" + "=" * 110) print("🧰 ДЕКЛАРАТИВНЫЙ РЕЕСТР ДЕЙСТВИЙ ИНСТРУМЕНТОВ (tool_action_registry):") print("=" * 110) with get_connection() as conn: cursor = conn.cursor() 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) 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'] for t in tables: try: cursor.execute(f"SELECT COUNT(*) FROM {t}") cnt = cursor.fetchone()[0] print(f" • Таблица [{t:<20}]: {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 if not snap_id.startswith("Y"): formatted_snap_id = f" {snap_id}" else: formatted_snap_id = snap_id print(f"{formatted_snap_id:<16} | {snap_time:<24} | {slice_datetime_str:<20} | {count:<8}") print("=" * 105 + "\n") def delete_snapshot_by_id(snapshot_id: str): """Удаляет конкретный снапшот из таблицы scud_logs по его ID.""" with get_connection() as conn: cursor = conn.cursor() cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) deleted_count = cursor.rowcount conn.commit() print(f"\n[✓] Успешно удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}\n") return deleted_count def delete_snapshots_by_date(date_str: str): """Удаляет все снапшоты за указанную дату (например, '04.08.2026').""" with get_connection() as conn: cursor = conn.cursor() cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) deleted_count = cursor.rowcount conn.commit() print(f"\n[✓] Успешно удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}\n") return deleted_count def inspect_scud(snapshot_id=None, date_str=None, export_xlsx=None): """Инспектирует логи СКУД за выбранный снапшот или дату и опционально сохраняет XLSX.""" target_date = date_str if date_str else DATE_TODAY print("\n" + "=" * 90) if snapshot_id: print(f"🔍 ИНСПЕКЦИЯ СКУД ПО СНАПШОТУ [{snapshot_id}] (Дата: {target_date}):") else: print(f"🔍 ИНСПЕКЦИЯ СКУД ЗА ТЕКУЩУЮ ДАТУ [{target_date}] (ПОСЛЕДНИЙ СРЕЗ):") print("=" * 90) df = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_id) if df.empty: print("Записи СКУД не найдены.") print("=" * 90 + "\n") return total = len(df) present_cnt = len(df[df['Пришел'] == True]) if 'Пришел' in df.columns else 0 absent_cnt = total - present_cnt print(f"Всего записей: {total} | Пришли: {present_cnt} | Не пришли: {absent_cnt}") print("-" * 90) cols_to_show = ['fio', 'department', 'position', 'time_in', 'first_activity', 'time_out', 'is_present', 'anomaly_flag', 'snapshot_id'] existing_cols = [c for c in cols_to_show if c in df.columns] print(df[existing_cols].head(30).to_string(index=False)) if len(df) > 30: print(f"\n... и ещё {len(df) - 30} строк.") if export_xlsx: out_path = export_xlsx if export_xlsx.endswith('.xlsx') else f"{export_xlsx}.xlsx" if not os.path.isabs(out_path): out_path = os.path.join(OUTPUT_DIR, out_path) df.to_excel(out_path, index=False) print("\n" + "*" * 90) print(f"[✓] УСПЕШНЫЙ ЭКСПОРТ ДЕБАГ-ФАЙЛА В EXCEL: {out_path}") print("*" * 90) print("=" * 90 + "\n") def print_absences(date_str=None): """Выводит список официально отсутствующих сотрудников из 1С:ЗУП за выбранный день.""" target_date = date_str if date_str else DATE_TODAY print("\n" + "=" * 90) print(f"📋 ОФИЦИАЛЬНЫЕ ОТСУТСТВИЯ ИЗ 1С:ЗУП ЗА ДАТУ [{target_date}]:") print("=" * 90) with get_connection() as conn: df = pd.read_sql_query( "SELECT fio as 'ФИО', absence_type as 'Причина отсутствия 1С' FROM zup_absences WHERE absence_date = ? ORDER BY absence_type, fio", conn, params=(target_date,) ) if df.empty: print(f"Записи об отсутствиях 1С за {target_date} в базе не найдены.") else: print(f"Всего зафиксировано документов 1С: {len(df)}") print("-" * 90) print(df.to_string(index=False)) print("=" * 90 + "\n") def print_anomalies(): """Выводит список аномалий СКУД из БД.""" print("\n" + "=" * 80) print("🚨 ИСТОРИЯ НАЙДЕННЫХ АНОМАЛИЙ СКУД ⟷ 1С:") print("=" * 80) with get_connection() as conn: df = pd.read_sql_query("SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history ORDER BY id DESC LIMIT 50", conn) if df.empty: print("Аномалии не найдены.") else: print(df.to_string(index=False)) print("=" * 80 + "\n") def print_rules(): """Выводит правила базы знаний ИИ.""" rules = get_all_rules_from_db() print("\n" + "=" * 80) print("🧠 ПРАВИЛА БАЗЫ ЗНАНИЙ ИИ:") print("=" * 80) if not rules: print("База знаний пуста.") else: for idx, r in enumerate(rules, 1): print(f" {idx}. {r}") print("=" * 80 + "\n") def dump_all_to_excel(out_filename="db_dump_full.xlsx"): """Дампит всю базу SQLite во многостраничный Excel.""" out_path = os.path.join(OUTPUT_DIR, out_filename) print(f"\n[🔄] Создание полного дампа БД в файл: {out_path} ...") with get_connection() as conn, pd.ExcelWriter(out_path, engine='openpyxl') as writer: for table in ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'chat_messages', 'session_states', 'system_prompt_nodes', 'tasks']: 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") 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 или всех за выбранный день ПРИМЕРЫ ЗАПУСКА: 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 """ 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'], help="Основная команда") parser.add_argument('action', nargs='?', default=None, help="Дополнительное действие (например, 'del', 'purge') или session_id для контекста") parser.add_argument('param', nargs='?', default=None, help="Параметр (дата, ID снапшота, session_id, '--all' или имя файла)") 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.param or args.action print_snapshots_list(date_str=date_val) elif args.command == 'scud': inspect_scud(snapshot_id=args.snapshot, date_str=args.param, export_xlsx=args.export_xlsx) elif args.command == 'absences': date_val = args.param or args.action print_absences(date_str=date_val) elif args.command == 'anomalies': print_anomalies() elif args.command == 'rules': print_rules() elif args.command == 'prompts': print_system_prompts() elif args.command == 'sessions': print_session_states() elif args.command == '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.param if args.param else "db_dump_full.xlsx" dump_all_to_excel(filename) elif args.command == 'snapshot': if args.action == 'del': if args.day: delete_snapshots_by_date(args.day) elif args.param: delete_snapshot_by_id(args.param) else: print("\n[❌] Ошибка: Не указан ID снапшота или параметр --day для удаления.") print("Пример: python scripts/db_cli.py snapshot del Y20260805-007\n") else: print(f"\n[❌] Ошибка: Неизвестное действие '{args.action}' для команды snapshot.") print("Используйте: python scripts/db_cli.py snapshot del [ID или --day 'ДД.ММ.ГГГГ']\n") else: print("\n[❌] Ошибка: Неизвестная команда.") print(HELP_TEXT) if __name__ == "__main__": main() ``` ## File: `./scripts/fix_snapshots.py` ```py import sqlite3 import os from datetime import datetime DB_PATH = 'data/scud_orion_ai.db' def parse_to_date(date_str): """Надежное преобразование даты логов и времени снапшота в объект date.""" if not date_str: return None date_str = str(date_str).split(" ")[0].strip() if "." in date_str: try: return datetime.strptime(date_str, "%d.%m.%Y").date() except ValueError: return None elif "-" in date_str: try: return datetime.strptime(date_str, "%Y-%m-%d").date() except ValueError: return None return None def fix_snapshots(): """ Переиндексирует снапшоты в таблице scud_logs. Нумерация веников (001, 002, 003...) сквозная внутри каждого ДНЯ СОЗДАНИЯ. Префикс Y выставляется только если дата логов < даты создания. """ if not os.path.exists(DB_PATH): print(f"База данных {DB_PATH} не найдена.") return conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() # Группируем по уникальным выгрузкам cursor.execute(""" SELECT snapshot_time, log_date FROM scud_logs WHERE snapshot_time IS NOT NULL GROUP BY snapshot_time, log_date ORDER BY snapshot_time ASC, log_date ASC """) rows = cursor.fetchall() if not rows: print("Снапшоты не найдены.") conn.close() return print("=== ИСПРАВЛЕНИЕ СКВОЗНОЙ НУМЕРАЦИИ ВНУТРИ ДНЯ СОЗДАНИЯ ===") # Счетчик текущего порядкового номера strictly за ДЕНЬ СОЗДАНИЯ (YYYYMMDD) seq_counters = {} for snap_time_raw, log_date_raw in rows: d_snap = parse_to_date(snap_time_raw) d_log = parse_to_date(log_date_raw) if not d_snap or not d_log: continue date_prefix = d_snap.strftime("%Y%m%d") # Если дата логов строго раньше даты создания снапшота — ставим Y is_yesterday = (d_log < d_snap) prefix = "Y" if is_yesterday else "" # Приращиваем сквозной счетчик за ЭТОТ ДЕНЬ СОЗДАНИЯ seq_counters[date_prefix] = seq_counters.get(date_prefix, 0) + 1 seq_num = seq_counters[date_prefix] new_id = f"{prefix}{date_prefix}-{seq_num:03d}" cursor.execute( "UPDATE scud_logs SET snapshot_id = ? WHERE snapshot_time = ? AND log_date = ?", (new_id, snap_time_raw, log_date_raw) ) print(f" • Срез создания: {snap_time_raw} | Логи за: {log_date_raw} ===> Назначен ID: [{new_id}]") conn.commit() conn.close() print("\n[✓] Переиндексация внутри дней создания успешно выполнена!") if __name__ == "__main__": fix_snapshots() ``` ## File: `./scripts/init_tool_registry.py` ```py """ Скрипт инициализации таблицы tool_action_registry в SQLite БД. """ import os import sys import json import sqlite3 BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db") def init_tool_action_registry(): conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() # 1. Создаем таблицу cursor.execute(""" CREATE TABLE IF NOT EXISTS tool_action_registry ( id INTEGER PRIMARY KEY AUTOINCREMENT, tool_name TEXT UNIQUE NOT NULL, category TEXT DEFAULT 'general', bypass_llm INTEGER DEFAULT 1, success_template TEXT NOT NULL, follow_up_question TEXT, action_type TEXT DEFAULT 'BUTTONS', buttons_json TEXT, is_active INTEGER DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); """) # 2. Начальное наполнение базовыми сценариями seed_tools = [ ( "db_confirm_prompt_preview", "prompt", 1, "✅ Системный промпт успешно сохранен и применен в базе данных.", "Желаете продолжить работу с системным промптом?", "FOLLOW_UP_ACTION", json.dumps([ {"label": "Показать промпт", "value": "покажи системный промпт"}, {"label": "Готово", "value": "нет, спасибо"} ], ensure_ascii=False) ), ( "db_cancel_prompt_preview", "prompt", 1, "❌ Изменения системного промпта отменены.", "Желаете продолжить работу с системным промптом?", "FOLLOW_UP_ACTION", json.dumps([ {"label": "Показать промпт", "value": "покажи системный промпт"}, {"label": "Готово", "value": "нет, спасибо"} ], ensure_ascii=False) ), ( "db_delete_task", "tasks", 1, "🗑 Задача успешно удалена из вашего реестра.", "Хотите посмотреть обновленный список задач?", "FOLLOW_UP_ACTION", json.dumps([ {"label": "Показать задачи", "value": "покажи список задач"}, {"label": "Готово", "value": "нет"} ], ensure_ascii=False) ), ( "db_delete_snapshots", "snapshots", 1, "🗑 Выбранные снапшоты успешно удалены из базы данных.", "Вывести актуальный список снапшотов?", "FOLLOW_UP_ACTION", json.dumps([ {"label": "Показать снапшоты", "value": "покажи список снапшотов"}, {"label": "Готово", "value": "нет"} ], ensure_ascii=False) ) ] for item in seed_tools: cursor.execute(""" INSERT INTO tool_action_registry ( tool_name, category, bypass_llm, success_template, follow_up_question, action_type, buttons_json ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(tool_name) DO UPDATE SET category = excluded.category, bypass_llm = excluded.bypass_llm, success_template = excluded.success_template, follow_up_question = excluded.follow_up_question, action_type = excluded.action_type, buttons_json = excluded.buttons_json, updated_at = CURRENT_TIMESTAMP """, item) conn.commit() conn.close() print("[✓] Таблица tool_action_registry успешно создана и наполнена!") if __name__ == "__main__": init_tool_action_registry() ``` ## File: `./scripts/diagnostics/inspect_db.py` ```py import os import sqlite3 # Автопоиск файла базы данных в проекте db_path = 'data/scud_orion_ai.db' if os.path.exists('data/scud_orion_ai.db') else 'scud_orion_ai.db' print("=" * 80) print(f"🔍 ДИАГНОСТИКА СУБД SQLITE: {db_path}") print("=" * 80) if not os.path.exists(db_path): print(f"❌ Файл базы данных {db_path} не найден!") exit(1) conn = sqlite3.connect(db_path) cursor = conn.cursor() # 1. Список всех таблиц и колонок cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") tables = [t[0] for t in cursor.fetchall()] print("\n📋 СТРУКТУРА ТАБЛИЦ И КОЛИЧЕСТВО ЗАПИСЕЙ:") print("-" * 80) for t_name in tables: cursor.execute(f"PRAGMA table_info({t_name})") cols = [c[1] for c in cursor.fetchall()] cursor.execute(f"SELECT COUNT(*) FROM {t_name}") count = cursor.fetchone()[0] print(f"• [{t_name:<20}] — {count:>6} строк | Колонки: {cols}") # 2. Просмотр правил Базы Знаний if 'ai_knowledge_base' in tables: print("\n" + "=" * 80) print("🧠 АКТУАЛЬНЫЕ ПРАВИЛА БАЗЫ ЗНАНИЙ (ai_knowledge_base):") print("=" * 80) cursor.execute("SELECT id, rule_text, added_by FROM ai_knowledge_base ORDER BY id ASC") rules = cursor.fetchall() if not rules: print("Таблица ai_knowledge_base пуста.") else: for r_id, r_text, r_author in rules: print(f" {r_id}. [{r_author}] {r_text}\n") conn.close() print("=" * 80) ``` ## File: `./scripts/diagnostics/inspect_ephemeral.py` ```py import os import sqlite3 DB_PATH = os.path.join(os.path.dirname(__file__), "../../data/scud_orion_ai.db") if not os.path.exists(DB_PATH): DB_PATH = "data/scud_orion_ai.db" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() print("=" * 90) print(f"🔍 ИНСПЕКЦИЯ СООБЩЕНИЙ В БАЗЕ: {DB_PATH}") print("=" * 90) # 1. Проверяем наличие колонки is_ephemeral cursor.execute("PRAGMA table_info(chat_messages);") cols = [c[1] for c in cursor.fetchall()] if "is_ephemeral" not in cols: print("❌ Колонка is_ephemeral отсутствует в таблице chat_messages!") conn.close() exit(1) # 2. Считаем общее количество и распределение cursor.execute("SELECT COUNT(*) FROM chat_messages") total_count = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 1") ephemeral_count = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM chat_messages WHERE is_ephemeral = 0") permanent_count = cursor.fetchone()[0] print(f"Всего записей в истории: {total_count}") print(f" • Постоянные (is_ephemeral = 0): {permanent_count}") print(f" • Фантомные / Эфемерные (is_ephemeral = 1): {ephemeral_count}") print("-" * 90) # 3. Выводим все фантомные записи if ephemeral_count == 0: print("В базе нет фантомных записей (is_ephemeral = 1 пуст).") else: print(f"📋 СПИСОК ВСЕХ ФАНТОМНЫХ СООБЩЕНИЙ ({ephemeral_count} шт.):") cursor.execute(""" SELECT id, session_id, role, created_at, content FROM chat_messages WHERE is_ephemeral = 1 ORDER BY id ASC """) for row in cursor.fetchall(): msg_id, session_id, role, created_at, content = row print(f"\n[ID: {msg_id}] | Сессия: {session_id} | Роль: {role} | Время: {created_at}") print("Содержимое:") print(content.strip()) print("-" * 70) conn.close() print("=" * 90) ``` ## File: `./scripts/diagnostics/inspect_files.py` ```py import os print("=" * 80) print("📂 ТЕКУЩЕЕ СОСТОЯНИЕ ФАЙЛОВ ПРОЕКТА (scud_ai)") print("=" * 80) total_files = 0 total_size = 0 for root, dirs, files in os.walk('.'): # Исключаем служебные каталоги dirs[:] = [d for d in dirs if d not in ['.git', '__pycache__', 'venv', '.venv', 'extracted_project']] for f in files: p = os.path.join(root, f) size = os.path.getsize(p) total_files += 1 total_size += size print(f"{p:<55} ({size:>10,} bytes)".replace(',', ' ')) print("-" * 80) print(f"ИТОГО: файлов: {total_files} | Общий объем: {total_size / (1024 * 1024):.2f} MB") print("=" * 80) ``` ## File: `./scripts/diagnostics/make_code_snapshot.py` ```py import os OUTPUT_SNAPSHOT = "project_code_snapshot.md" # Расширения файлов для включения в снимок (только исходный код) ALLOWED_EXTENSIONS = {'.py', '.json', '.sh', '.ini', '.js', '.html', '.css', '.sql'} # Исключаемые каталоги (убираем docs, кэши, архивы и окружения) EXCLUDE_DIRS = { '.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project', 'docs', 'data' } # Исключаемые файлы EXCLUDE_FILES = { OUTPUT_SNAPSHOT, 'api_code_snapshot.md', 'project_code_snapshot.md', 'scud_orion_ai_v2.tar.gz', 'scud_context_api.tar.gz', 'context_memory.db' } print(f"🔄 Сборка оптимизированного слепка кода в {OUTPUT_SNAPSHOT}...") with open(OUTPUT_SNAPSHOT, 'w', encoding='utf-8') as out: out.write("# 📦 ОПТИМИЗИРОВАННЫЙ СЛЕПОК ИСХОДНОГО КОДА scud_orion_ai\n\n") for root, dirs, files in os.walk('.'): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] for file in sorted(files): ext = os.path.splitext(file)[1].lower() if ext in ALLOWED_EXTENSIONS and file not in EXCLUDE_FILES: filepath = os.path.join(root, file) out.write(f"## File: `{filepath}`\n") out.write("```" + ext.replace('.', '') + "\n") try: with open(filepath, 'r', encoding='utf-8', errors='replace') as f: out.write(f.read()) except Exception as e: out.write(f"// Ошибка чтения файла: {e}\n") out.write("\n```\n\n") size_kb = os.path.getsize(OUTPUT_SNAPSHOT) / 1024 print(f"✓ Слепок успешно создан: {OUTPUT_SNAPSHOT} ({size_kb:.1f} KB)") ``` ## File: `./scripts/diagnostics/show_tree.py` ```py import os EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} def print_tree(startpath): print("=" * 60) print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") print("=" * 60) for root, dirs, files in os.walk(startpath): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] level = root.replace(startpath, '').count(os.sep) indent = ' ' * 4 * (level) print(f'{indent}📁 {os.path.basename(root)}/') subindent = ' ' * 4 * (level + 1) for f in sorted(files): if not f.endswith('.pyc'): print(f'{subindent}📄 {f}') print("=" * 60) if __name__ == "__main__": print_tree('.') ``` ## File: `./services/__init__.py` ```py ``` ## File: `./services/ai_verifier.py` ```py import json import re import requests import pandas as pd from config import OLLAMA_URL, OLLAMA_MODEL from core.database import get_all_rules_from_db from core.database import get_department_synonyms_dict, add_department_synonym_to_db def resolve_department_exception_ai(dept_1c, dept_scud, exception_departments): """ Универсальный сопоставитель отделов. Проверяет 1С, СКУД, локальную базу синонимов SQLite и задействует ИИ для сложных случайных аббревиатур. """ if not exception_departments: return False d_1c = str(dept_1c).strip().lower() if dept_1c else "" d_scud = str(dept_scud).strip().lower() if dept_scud else "" exc_list = [d.strip().lower() for d in exception_departments] # 1. Прямая проверка: если точное имя или подстрока уже совпали в 1С или СКУД for exc in exc_list: if exc and (exc in d_1c or exc in d_scud or d_1c in exc or d_scud in exc): return True # 2. Проверка по сохраненной Базе Знаний синонимов из SQLite synonyms = get_department_synonyms_dict() for exc in exc_list: # Если в БД зафиксировано: 'овк' -> 'отдел внутреннего контроля' full_from_db = synonyms.get(exc, "") if full_from_db and (full_from_db in d_1c or full_from_db in d_scud): return True # 3. Умный ИИ-арбитраж (если отдел спорный и еще не сохранен в БД) if d_1c or d_scud: dept_to_check = d_1c if d_1c else d_scud prompt = f""" Ты — кадровый аналитик. Проверь, является ли отдел сотрудника "{dept_to_check}" тем же самым подразделением, что и один из отделов-исключений: {exception_departments}? Примеры: - "Отдел внутреннего контроля" — это "ОВК" (Да) - "Отдел технического обеспечения" — это "ОТО" (Да) Ответь СТРОГО в формате JSON: {{ "is_match": true/false, "matched_exception": "Название из списка исключений", "explanation": "краткое объяснение" }} """ try: raw_res = ask_ollama(prompt, system_prompt="Отвечай только валидным JSON.") match = re.search(r'\{.*\}', raw_res, re.DOTALL) if match: data = json.loads(match.group(0)) if data.get("is_match"): matched_exc = data.get("matched_exception", "").lower() # Запоминаем открытую ИИ связь в SQLite навсегда! add_department_synonym_to_db(matched_exc, dept_to_check) return True except Exception as e: print(f"[⚠️] Ошибка ИИ-арбитража отделов: {e}") return False def ask_ollama(prompt, system_prompt=None): """ Универсальная функция отправки запросов к локальной модели Ollama (Qwen 2.5). """ payload = { "model": OLLAMA_MODEL, "prompt": f"{system_prompt}\n\n{prompt}" if system_prompt else prompt, "stream": False } try: response = requests.post(OLLAMA_URL, json=payload, timeout=120) if response.status_code == 200: data = response.json() if "response" in data: return data["response"].strip() elif "message" in data and "content" in data["message"]: return data["message"]["content"].strip() else: return f"⚠️ Неизвестная структура ответа Ollama: {data}" else: return f"⚠️ Ошибка Ollama (Код {response.status_code}): {response.text}" except requests.exceptions.ConnectionError: return "⚠️ Не удалось подключиться к Ollama. Проверьте, запущен ли сервис (ollama run qwen2.5:14b)." except Exception as e: return f"⚠️ Ошибка при обращении к Ollama: {e}" def get_system_rules_context(): """ Загружает динамические системные правила компании напрямую из Базы Данных SQLite. """ rules = get_all_rules_from_db() if not rules: return "" rules_text = "\n".join([f"- {r}" for r in rules]) return f"\nОБЯЗАТЕЛЬНЫЕ ГЛОБАЛЬНЫЕ ПРАВИЛА И ПРИОРИТЕТЫ КОМПАНИИ (ИЗ SQLITE БД):\n{rules_text}\n" def analyze_scud_mass_failure_ai(df_scud): """ Оценивает процент аномалий 'ANOMALY_NO_IN_HAS_ACTIVITY' в выгрузке. Если процент аномалий превышает 5% от смены или 10 человек, вызывает ИИ для генерации критического алерта. """ if df_scud is None or df_scud.empty: return None total_records = len(df_scud) anomaly_rows = df_scud[df_scud.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY'] anomaly_count = len(anomaly_rows) if total_records == 0: return None anomaly_percent = round((anomaly_count / total_records) * 100, 1) if anomaly_percent > 5.0 or anomaly_count >= 10: rules_context = get_system_rules_context() prompt = f""" {rules_context} ВНИМАНИЕ! Проведён анализ смены СКУД: - Всего записей за смену: {total_records} - Выявлено сотрудников без утреннего входа, но с зафиксированной дневной активностью: {anomaly_count} ({anomaly_percent}% от смены) Сформируй понятное предупреждение для Администратора СКУД и Руководителя. Объясни, что это критический массовый сбой турникетов/контроллеров входа на КПП, и порекомендуй действия. """ alert_text = ask_ollama(prompt, system_prompt="Ты — ИИ-аналитик контроллинга СКУД. Отвечай кратко и строго по делу.") return { "is_mass_failure": True, "anomaly_count": anomaly_count, "anomaly_percent": anomaly_percent, "alert_text": alert_text } return { "is_mass_failure": False, "anomaly_count": anomaly_count, "anomaly_percent": anomaly_percent, "alert_text": "Массовых сбоев оборудования не зафиксировано." } def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): if not unrecognized_scud_fios or not staff_fios: return {} # Исключаем точные совпадения staff_fios_set = set(staff_fios) real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set] if not real_unrecognized: return {} rules_context = get_system_rules_context() prompt = f""" Ты — кадровый аудитор безопасности СКУД. {rules_context} В СКУД записаны неопознанные ФИО: {json.dumps(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/data_loader.py` ```py import os import pandas as pd import warnings from config import ( normalize_fio, clean_scud_fio_light, load_exceptions, DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR ) # Импортируем прямую SQL-выгрузку отсутствий из 1С и функции работы с SQLite from services.zup_extractor import fetch_zup_absences_from_sql from core.database import ( load_scud_from_db_by_snapshot, load_staff_from_db, load_absences_from_db ) warnings.filterwarnings('ignore', category=UserWarning, module='pandas') def is_excluded(fio, dept, pos, exceptions): """Проверяет, входит ли сотрудник или должность в список исключений.""" fio_clean = normalize_fio(fio) dept_str = str(dept).strip().upper() if pd.notna(dept) else "" pos_str = str(pos).strip().lower() if pd.notna(pos) else "" if fio_clean in exceptions["fio"] or dept_str in exceptions["departments"] or pos_str in exceptions["positions"]: return True if any(keyword in pos_str for keyword in exceptions["position_keywords"]): return True return False def load_staff_data(date_str): """ Загружает файл Штатного расписания 1С за конкретную дату из папки 1c. """ filepath = find_dated_file("Штат", date_str) if not filepath: fallback_path = os.path.join(ZUP_1C_DIR, "штат.xlsx") if os.path.exists(fallback_path): filepath = fallback_path else: return None try: df_raw = pd.read_excel(filepath, skiprows=8) # Индексы столбцов в файле 1С: 1 — ФИО, 5 — Подразделение, 12 — Должность df_staff = df_raw.iloc[:, [1, 5, 12]].copy() df_staff.columns = ['ФИО', 'Подразделение', 'Должность'] df_staff = df_staff.dropna(subset=['ФИО']).reset_index(drop=True) df_staff = df_staff[~df_staff['ФИО'].astype(str).str.contains('Всего|Организация|Сотрудник|ФИО', case=False, na=False)] df_staff['fio_clean'] = df_staff['ФИО'].apply(normalize_fio) return df_staff except Exception as e: print(f"[❌] Ошибка загрузки штата из {filepath}: {e}") return None def load_absent_data(date_str): """ Загружает документально подтвержденные отсутствия сотрудников НАПРЯМУЮ из базы MS SQL 1С:ЗУП за указанную дату, а также автоматически обогащает их удаленщиками из static_reason_workers.csv. """ df_absent = None try: df_absent = fetch_zup_absences_from_sql(date_str) if df_absent is not None and not df_absent.empty: df_absent = df_absent.dropna(subset=['ФИО', 'Вид_отсутствия']) df_absent = df_absent[~df_absent['ФИО'].astype(str).str.contains('АО "ЛЕНМОРНИИПРОЕКТ"|Сотрудник', case=False, na=False)] df_absent['fio_clean'] = df_absent['ФИО'].apply(normalize_fio) df_absent = df_absent[['fio_clean', 'Вид_отсутствия']].dropna(subset=['fio_clean']) else: df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) except Exception as e: print(f"[⚠️] Ошибка SQL-выгрузки отсутствий за {date_str}: {e}") df_absent = pd.DataFrame(columns=['fio_clean', 'Вид_отсутствия']) # ⭐️ ОБОГАЩЕНИЕ УДАЛЕНЩИКАМИ ИЗ static_reason_workers.csv try: from config import DATA_DIR static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") if os.path.exists(static_path): df_static = pd.read_csv(static_path, encoding='utf-8') if 'fio' in df_static.columns and 'reason' in df_static.columns: df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) existing_fios = set(df_absent['fio_clean'].dropna().tolist()) if not df_absent.empty else set() new_rows = [] for _, s_row in df_static.iterrows(): if s_row['fio_clean'] not in existing_fios: new_rows.append({ 'fio_clean': s_row['fio_clean'], 'Вид_отсутствия': s_row['reason'] }) if new_rows: df_absent = pd.concat([df_absent, pd.DataFrame(new_rows)], ignore_index=True) except Exception as e: print(f"[⚠️] Ошибка обогащения удаленщиками в load_absent_data: {e}") return df_absent if not df_absent.empty else None def load_1c_data_smart(date_str, use_db=False): """ Универсальный загрузчик данных 1С (Штат и Отсутствия). Если use_db=True, приоритетно извлекает сохраненные данные из SQLite БД за целевую дату указанного снапшота. """ df_staff = None df_absent = None if use_db: df_staff = load_staff_from_db(date_str) df_absent = load_absences_from_db(date_str) # Если в базе нет записей за эту дату или use_db=False — загружаем из файлов/SQL if df_staff is None: df_staff = load_staff_data(date_str) if df_absent is None: df_absent = load_absent_data(date_str) return df_staff, df_absent def process_scud_anomalies(df_scud, exceptions=None): """ Вычисляет 'Первую активность' и маркирует аномалии СКУД. """ if exceptions is None: exceptions = load_exceptions() start_col = 'Начало_дня' if 'Начало_дня' in df_scud.columns else ('Начало дня' if 'Начало дня' in df_scud.columns else None) end_col = 'Конец_дня' if 'Конец_дня' in df_scud.columns else ('Конец дня' if 'Конец дня' in df_scud.columns else None) status_col = 'Статус' if 'Статус' in df_scud.columns else None first_activities = [] anomaly_flags = [] is_present_flags = [] for _, row in df_scud.iterrows(): fio = str(row.get('Сотрудник', row.get('fio_raw', ''))).strip() fio_clean = normalize_fio(fio) dept = str(row.get('Подразделение', '')).strip() pos = str(row.get('Должность', '')).strip() val_start = str(row.get(start_col, '')).strip() if start_col else '' val_end = str(row.get(end_col, '')).strip() if end_col else '' val_status = str(row.get(status_col, '')).strip().lower() if status_col else '' raw_first_event = row.get('raw_first_event', None) if pd.isna(raw_first_event) or str(raw_first_event).strip() in ['', 'None', 'nan', '00:00:00']: raw_first_event = None has_no_in = (val_start in ['нет входа', 'none', 'nan', '', '00:00:00', '00:00']) has_no_out = (val_end in ['нет выхода', 'none', 'nan', '', '00:00:00', '00:00']) first_act = '—' anom_flag = 'NONE' is_present = not ('отсутствовал' in val_status or has_no_in) if has_no_in: if raw_first_event is not None or not has_no_out: first_act = str(raw_first_event) if raw_first_event else val_end anom_flag = 'ANOMALY_NO_IN_HAS_ACTIVITY' if not is_excluded(fio, dept, pos, exceptions): is_present = False else: first_act = '—' is_present = False first_activities.append(first_act) anomaly_flags.append(anom_flag) is_present_flags.append(is_present) df_scud['Первая_активность'] = first_activities df_scud['anomaly_flag'] = anomaly_flags df_scud['Пришел'] = is_present_flags return df_scud def load_scud_data(target_date_type="today", snapshot_param=None): """ Загружает логи СКУД за 'today' или 'yesterday' НАПРЯМУЮ из локальной базы SQLite. """ target_date = DATE_TODAY if target_date_type == "today" else DATE_YESTERDAY df_scud = load_scud_from_db_by_snapshot(target_date, snapshot_param=snapshot_param) if df_scud is None or df_scud.empty: df_scud = load_scud_from_db_by_snapshot(DATE_TODAY, snapshot_param=snapshot_param) if df_scud is None or df_scud.empty: print(f"[ℹ️] В базе SQLite / файлах отсутствует СКУД за {target_date}. Возвращаем пустой набор.") return pd.DataFrame() exceptions = load_exceptions() if 'fio_clean' not in df_scud.columns: fio_raw_col = 'fio' if 'fio' in df_scud.columns else ('Сотрудник' if 'Сотрудник' in df_scud.columns else df_scud.columns[0]) df_scud['fio_raw'] = df_scud[fio_raw_col].astype(str) df_scud['fio_clean'] = df_scud['fio_raw'].apply(clean_scud_fio_light) if 'Начало_дня' not in df_scud.columns and 'time_in' in df_scud.columns: df_scud['Начало_дня'] = df_scud['time_in'] if 'Конец_дня' not in df_scud.columns and 'time_out' in df_scud.columns: df_scud['Конец_дня'] = df_scud['time_out'] if 'Первая_активность' not in df_scud.columns and 'first_activity' in df_scud.columns: df_scud['Первая_активность'] = df_scud['first_activity'] if 'Пришел' not in df_scud.columns and 'is_present' in df_scud.columns: df_scud['Пришел'] = df_scud['is_present'].astype(bool) df_scud['is_excluded'] = df_scud.apply( lambda r: is_excluded(r.get('fio', r.get('Сотрудник', '')), r.get('department', r.get('Подразделение', '')), r.get('position', r.get('Должность', '')), exceptions), axis=1 ) return df_scud def load_all_data(): """ Загружает слитные датасеты за Сегодня и за Вчера напрямую из базы SQLite и 1С. """ df_scud_today = load_scud_data(target_date_type="today") try: df_scud_yesterday = load_scud_data(target_date_type="yesterday") except FileNotFoundError: df_scud_yesterday = df_scud_today.copy() df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY) df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY) return ( df_scud_today, df_scud_yesterday, df_staff_today, df_absent_today, df_staff_yesterday, df_absent_yesterday ) ``` ## File: `./services/data_validator.py` ```py import os import time from datetime import datetime from config import DATE_TODAY, DATE_YESTERDAY, find_dated_file, ZUP_1C_DIR, SCUD_DIR def check_file_freshness(max_age_hours=24): """ Проверяет существование входных файлов 1С и СКУД. Возвращает флаги доступности файлов за Сегодня и Вчера. """ warnings = [] errors = [] # 1. Проверка файлов 1С за СЕГОДНЯ staff_today = find_dated_file("Штат", DATE_TODAY) absent_today = find_dated_file("Отсутствия", DATE_TODAY) has_today_1c = bool(staff_today and absent_today) if not has_today_1c: warnings.append( f"Файлы 1С за СЕГОДНЯ ({DATE_TODAY}) еще не выложены на сетевую шару. " f"Формирование Ежедневной сводки за сегодня будет временно пропущено." ) # 2. Проверка файлов 1С за ВЧЕРА (критически важны для детального отчета) staff_yesterday = find_dated_file("Штат", DATE_YESTERDAY) absent_yesterday = find_dated_file("Отсутствия", DATE_YESTERDAY) if not staff_yesterday or not absent_yesterday: errors.append( f"Отсутствуют файлы 1С за ВЧЕРА ({DATE_YESTERDAY}) в папке {ZUP_1C_DIR}. " f"Невозможно построить детальный отчет за прошлую смену." ) # 3. Проверка файлов СКУД scud_today = find_dated_file("Сотрудники", DATE_TODAY, search_dirs=[SCUD_DIR]) if not scud_today: warnings.append(f"Сегодняшний файл СКУД за {DATE_TODAY} еще не сформирован в {SCUD_DIR}") is_valid = len(errors) == 0 return is_valid, warnings, errors, has_today_1c ``` ## File: `./services/excel_exporter.py` ```py import math import os import time import openpyxl import pandas as pd from datetime import datetime from openpyxl import Workbook from openpyxl.styles import Font, Alignment, PatternFill, Border, Side from openpyxl.utils import get_column_letter from config import OUTPUT_DIR # Словарь месяцев для текстового формата в названиях файлов MONTHS_RU = { 1: "января", 2: "февраля", 3: "марта", 4: "апреля", 5: "мая", 6: "июня", 7: "июля", 8: "августа", 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря" } def format_date_ru(date_str): """Преобразует дату формата '27.07.2026' в '27 июля 2026'""" try: dt = datetime.strptime(date_str, "%d.%m.%Y") return f"{dt.day} {MONTHS_RU[dt.month]} {dt.year}" except Exception: return date_str # Границы ячеек THIN_SIDE = Side(border_style="thin", color="D3D3D3") THIN_BORDER = Border(left=THIN_SIDE, right=THIN_SIDE, top=THIN_SIDE, bottom=THIN_SIDE) # Палитра заливки для Сводки FILL_HEADER = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") FILL_TOTAL_LIST = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid") FILL_UNEXPLAINED = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_PRESENT = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") FILL_ANOMALY = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") FILL_NO_PASS = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Мягкий пастельно-голубой для "Нет пропуска" CATEGORY_PASTEL_COLORS = ["FFF2CC", "E1D5E7", "E1F5FE", "FFF0F5", "E8F8F5", "FCF3CF"] # Палитра для Детального отчета YELLOW_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid") # Обычные отсутствия LIGHT_RED_FILL = PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid") # Потенциальные прогулы GREEN_FILL = PatternFill(start_color="E2EFDA", end_color="E2EFDA", fill_type="solid") # Аномалии (пришел в отпуске) LIGHT_BLUE_FILL = PatternFill(start_color="E1F5FE", end_color="E1F5FE", fill_type="solid") # Бледно-голубой для "Нет пропуска" def calculate_deviation(time_in_building_str, reason="", norm_hours=8, lunch_minutes=30): """Рассчитывает точное отклонение от нормы с учетом обеда 30 мин и уважительных причин""" if pd.notna(reason) and isinstance(reason, str) and reason.strip() != "": return "0:00" if not isinstance(time_in_building_str, str) or time_in_building_str in ['00:00', '0', '', 'None', 'nan', 'NaN']: return f"-{norm_hours}:00" try: parts = time_in_building_str.strip().split(':') hh = int(parts[0]) mm = int(parts[1]) if len(parts) > 1 else 0 total_in_building_minutes = hh * 60 + mm if total_in_building_minutes == 0: return f"-{norm_hours}:00" work_minutes = max(0, total_in_building_minutes - lunch_minutes) norm_minutes = norm_hours * 60 diff = work_minutes - norm_minutes if diff == 0: return "0:00" sign = "-" if diff < 0 else "" abs_diff = abs(diff) res_hh = abs_diff // 60 res_mm = abs_diff % 60 return f"{sign}{res_hh}:{res_mm:02d}" except Exception: return f"-{norm_hours}:00" def apply_borders_to_cell(cell, border=THIN_BORDER): cell.border = border def format_row_cells(ws, r_num, fill_obj, is_bold=False, align_b="right", bold_font=None, wrap_b=False): cell_a = ws.cell(row=r_num, column=1) cell_b = ws.cell(row=r_num, column=2) if fill_obj: cell_a.fill = fill_obj cell_b.fill = fill_obj apply_borders_to_cell(cell_a) apply_borders_to_cell(cell_b) if is_bold and bold_font: cell_a.font = bold_font cell_b.font = bold_font if align_b: cell_b.alignment = Alignment(horizontal=align_b, vertical="center", wrap_text=wrap_b) # --- 1. СВОДКА НА СЕГОДНЯ --- def generate_summary_excel(merged_df, date_str="27.07.2026", filename=None): if not filename: filename = f"{format_date_ru(date_str)} сводка.xlsx" output_path = os.path.join(OUTPUT_DIR, filename) wb = Workbook() ws = wb.active ws.title = "Лист_1" ws.sheet_properties.outlinePr.summaryBelow = False ws.sheet_properties.outlinePr.summaryRight = False ws.sheet_properties.outlinePr.showOutlineSymbols = True ws.sheet_view.showOutlineSymbols = True bold_font = Font(name="Calibri", size=11, bold=True) # 1. Шапка ws.cell(row=1, column=1, value="Сводка на") ws.cell(row=1, column=2, value=date_str) format_row_cells(ws, 1, FILL_HEADER, is_bold=True, bold_font=bold_font) apply_borders_to_cell(ws.cell(row=2, column=1)) apply_borders_to_cell(ws.cell(row=2, column=2)) # 2. По списку ws.cell(row=3, column=1, value="По списку") ws.cell(row=3, column=2, value=len(merged_df)) format_row_cells(ws, 3, FILL_TOTAL_LIST, is_bold=True, bold_font=bold_font) current_row = 4 # Подготовка флагов для точной фильтрации is_no_pass = merged_df['no_scud_pass'] == True if 'no_scud_pass' in merged_df.columns else False is_exc = merged_df.get('is_excluded', False) == True # 3. НЕИЗВЕСТНО (Исключаем категорию "Нет пропуска" и "Исключения ОВК/Охрана") unexplained = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].isna() | (merged_df['Вид_отсутствия'].astype(str).str.strip() == '')) & (~is_no_pass) & (~is_exc) ] ws.cell(row=current_row, column=1, value="неизвестно") ws.cell(row=current_row, column=2, value=len(unexplained)) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=True, bold_font=bold_font) current_row += 1 for fio in sorted(unexplained['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_UNEXPLAINED, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = False current_row += 1 # 4. РАЗДЕЛ: НЕТ ПРОПУСКА (Строго один независимый блок) no_pass_df = merged_df[is_no_pass] if 'no_scud_pass' in merged_df.columns else pd.DataFrame() ws.cell(row=current_row, column=1, value="Нет пропуска") ws.cell(row=current_row, column=2, value=len(no_pass_df)) format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=True, bold_font=bold_font) current_row += 1 if not no_pass_df.empty: for fio in sorted(no_pass_df['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_NO_PASS, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = False current_row += 1 # 5. КАТЕГОРИИ ОТСУТСТВИЙ (Свернуты hidden=True) absent_only = merged_df[ (merged_df['Пришел'] == False) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) ] absent_groups = absent_only.groupby('Вид_отсутствия') for idx_cat, (cat_name, group) in enumerate(absent_groups): hex_color = CATEGORY_PASTEL_COLORS[idx_cat % len(CATEGORY_PASTEL_COLORS)] cat_fill = PatternFill(start_color=hex_color, end_color=hex_color, fill_type="solid") ws.cell(row=current_row, column=1, value=cat_name) ws.cell(row=current_row, column=2, value=len(group)) format_row_cells(ws, current_row, cat_fill, is_bold=True, bold_font=bold_font) current_row += 1 for fio in sorted(group['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, cat_fill, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 # 6. ИТОГО НА РАБОТЕ (Свернут hidden=True) is_working_mask = (merged_df['Пришел'] == True) | ( merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) ) present = merged_df[is_working_mask] ws.cell(row=current_row, column=1, value="Итого на работе") ws.cell(row=current_row, column=2, value=len(present)) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=True, bold_font=bold_font) current_row += 1 if not present.empty: for fio in sorted(present['Сотрудник'].dropna().unique()): ws.cell(row=current_row, column=1, value=fio) format_row_cells(ws, current_row, FILL_PRESENT, is_bold=False) ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 # 7. АНОМАЛИИ СКУД И 1С (ОВК и Подрядчики из исключений СУДА НЕ ПОПАДАЮТ) anomalies = merged_df[ (~is_exc) & ( ((merged_df['Пришел'] == True) & (merged_df['Вид_отсутствия'].notna()) & (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение'))) | (merged_df.get('anomaly_flag', 'NONE') == 'ANOMALY_NO_IN_HAS_ACTIVITY') ) ] ws.cell(row=current_row, column=1, value="Аномалии СКУД и 1С") ws.cell(row=current_row, column=2, value=len(anomalies)) format_row_cells(ws, current_row, FILL_ANOMALY, is_bold=True, bold_font=bold_font) current_row += 1 chars_per_line_b = 30 if not anomalies.empty: for _, row in anomalies.iterrows(): fio = row.get('Сотрудник', '') reason = row.get('Вид_отсутствия', '') anom_flag = row.get('anomaly_flag', 'NONE') if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': first_act = row.get('Первая_активность', '—') reason_text = f"🚨 АНОМАЛИЯ СКУД: Нет входа (первая активность: {first_act})" else: reason_text = f"В 1С: {reason}" cell_a = ws.cell(row=current_row, column=1, value=f"{fio}") cell_b = ws.cell(row=current_row, column=2, value=reason_text) cell_a.fill = FILL_ANOMALY cell_b.fill = FILL_ANOMALY apply_borders_to_cell(cell_a) apply_borders_to_cell(cell_b) cell_b.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cell_a.alignment = Alignment(horizontal="left", vertical="center") if len(reason_text) > chars_per_line_b: lines_count = math.ceil(len(reason_text) / chars_per_line_b) ws.row_dimensions[current_row].height = max(lines_count * 18, 22) else: ws.row_dimensions[current_row].height = 20 ws.row_dimensions[current_row].outlineLevel = 1 ws.row_dimensions[current_row].hidden = True current_row += 1 ws.column_dimensions['A'].width = 45.0 ws.column_dimensions['B'].width = 38.0 try: wb.save(output_path) print(f"[✓] Ежедневная сводка сохранена: {output_path}") except PermissionError: alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_path = os.path.join(OUTPUT_DIR, alt_filename) wb.save(alt_path) print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") # --- 2. ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА --- def generate_detailed_excel(merged_df, date_str="26.07.2026", filename=None): if not filename: filename = f"{format_date_ru(date_str)} отчет.xlsx" output_path = os.path.join(OUTPUT_DIR, filename) wb = Workbook() ws = wb.active ws.title = "Детальный_отчет" ws["B2"] = "Дата:" ws["D2"] = date_str ws["B2"].font = Font(name="Arial", size=10, bold=True) ws["D2"].font = Font(name="Arial", size=10, bold=True) headers = [ "№", "ФИО", "Подразделение", "время входа", "первая активность", "время выхода", "находился в здании", "причина отсутствия", "норма", "отклонение от нормы" ] ws.append([]) ws.append(headers) header_fill = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid") for col_idx in range(1, len(headers) + 1): cell = ws.cell(row=4, column=col_idx) cell.fill = header_fill cell.font = Font(name="Arial", size=10, bold=True) cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) apply_borders_to_cell(cell) start_col = 'Начало дня' if 'Начало дня' in merged_df.columns else 'Начало_дня' end_col = 'Конец дня' if 'Конец дня' in merged_df.columns else 'Конец_дня' hours_col = 'Часы' if 'Часы' in merged_df.columns else 'Находился_в_здании' chars_per_line_h = 24 for idx, row in merged_df.reset_index(drop=True).iterrows(): is_present = row.get('Пришел', False) absence_reason = row.get('Вид_отсутствия', '') has_reason = pd.notna(absence_reason) and str(absence_reason).strip() != '' anom_flag = row.get('anomaly_flag', 'NONE') in_building_str = str(row.get(hours_col, '00:00')) first_act_val = str(row.get('Первая_активность', '—')).strip() has_first_act = first_act_val not in ['—', '', 'None', 'nan'] dept_scud_val = row.get('department_scud', row.get('department', row.get('Подразделение', ''))) deviation_val = calculate_deviation(in_building_str, reason=absence_reason if has_reason else "", norm_hours=8, lunch_minutes=30) ws.append([ idx + 1, row.get('Сотрудник', ''), dept_scud_val, row.get(start_col, 'Нет входа'), first_act_val, row.get(end_col, 'Нет выхода'), in_building_str, absence_reason if has_reason else '', 8, deviation_val ]) row_num = 5 + idx # ЗАЛИВКА СТРОК: if is_present and has_reason: row_fill = GREEN_FILL elif not is_present and has_reason: row_fill = YELLOW_FILL elif not is_present and not has_reason and not has_first_act: # Розово-красный подсвечивает исключительно неизвестные случаи (потенциальные прогулы) row_fill = LIGHT_RED_FILL else: row_fill = None val_h_str = str(absence_reason) if has_reason else "" if len(val_h_str) > chars_per_line_h: needed_lines = math.ceil(len(val_h_str) / chars_per_line_h) ws.row_dimensions[row_num].height = max(needed_lines * 18, 22) else: ws.row_dimensions[row_num].height = 20 for col_idx in range(1, len(headers) + 1): cell = ws.cell(row=row_num, column=col_idx) apply_borders_to_cell(cell) if row_fill: cell.fill = row_fill if col_idx == 8: cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) elif col_idx in [1, 4, 5, 6, 7, 9, 10]: cell.alignment = Alignment(horizontal="center", vertical="center") else: cell.alignment = Alignment(horizontal="left", vertical="center") # Динамический компактный автоподгон ширины колонок for col in ws.columns: col_letter = get_column_letter(col[0].column) max_len = 0 for cell in col: if cell.value is not None: cell_lines = str(cell.value).split("\n") line_max = max(len(line) for line in cell_lines) if line_max > max_len: max_len = line_max optimal_width = max(max_len + 2, 8) if optimal_width > 35: optimal_width = 35 ws.column_dimensions[col_letter].width = optimal_width try: wb.save(output_path) print(f"[✓] Детальный отчет сохранен: {output_path}") except PermissionError: alt_filename = filename.replace(".xlsx", f"_{int(time.time())}.xlsx") alt_path = os.path.join(OUTPUT_DIR, alt_filename) wb.save(alt_path) print(f"[⚠️] Файл открыт в Excel! Сохранено как: {alt_path}") def export_raw_scud(df_scud, filename="СКУД_Сырые_данные.xlsx"): output_path = os.path.join(OUTPUT_DIR, filename) df_scud.to_excel(output_path, index=False) ``` ## File: `./services/feedback_loop.py` ```py import os import json from core.database import add_rule_to_db try: from services.knowledge_base import save_rule except ImportError: try: from services.knowledge_base import add_rule as save_rule except ImportError: save_rule = None def review_ai_decisions(report_text, suspicious_cases): """ Интерактивный консольный модуль обучения (Human-in-the-Loop). Запрашивает подтверждение оператора ТОЛЬКО по нетипичным/сложным случаям. """ if not suspicious_cases: print("\n[✓] Все аномалии валидированы автоматически. Спорных вопросов для оператора нет.") return print("\n" + "=" * 60) print("РЕЖИМ ОБУЧЕНИЯ И ПРОВЕРКИ РЕШЕНИЙ ИИ (HUMAN-IN-THE-LOOP)") print("=" * 60) for idx, case in enumerate(suspicious_cases, 1): fio_target = case.get('fio_target', 'Неизвестно') reason = case.get('reason', 'Нет описания') print(f"\n[?] Спорный случай {idx}/{len(suspicious_cases)}: {fio_target}") print(f" Детали: {reason}") user_input = input("Подтверждаете аномалию? (д / да / н / нет / или введите текст правила): ").strip() user_input_clean = user_input.lower() # Расширенный список вариантов "ДА" if user_input_clean in ['д', 'да', 'da', 'yes', 'y', 'ок', 'ага', '+']: print(" [✓] Решение подтверждено оператором.") elif user_input_clean in ['н', 'нет', 'no', 'n', '-']: print(" [ℹ️] Решение отклонено оператором.") elif user_input: # Если оператор обучает систему синониму отделов if "отдел" in user_input_clean or "овк" in user_input_clean: from core.database import add_department_synonym_to_db add_department_synonym_to_db(user_input.strip(), "отдел внутреннего контроля") new_rule = f"Правило: {user_input}" add_rule_to_db(new_rule, added_by="Human_Admin") print(f" [✓] Новое правило записано в Базу Знаний SQLite: {new_rule}") else: print(" [ℹ️] Запись пропущена.") print("\n[✓] ЭТАП ОБУЧЕНИЯ ЗАВЕРШЕН.") ``` ## File: `./services/knowledge_base.py` ```py import os from core.database import get_all_rules_from_db, add_rule_to_db def load_knowledge_base(): """ Загружает актуальные правила Базы Знаний компании напрямую из базы данных SQLite. """ rules = get_all_rules_from_db() return { "rules": rules if rules else [], "fio_corrections": {} } def add_rule_to_kb(new_rule): """ Добавляет новое принятое человеком правило в SQLite таблицу ai_knowledge_base. """ if not new_rule or not new_rule.strip(): return add_rule_to_db(new_rule.strip(), added_by="Human") print(f"[✓] База знаний SQLite успешно обновлена! Новое правило: {new_rule.strip()}") ``` ## File: `./services/scud_export.py` ```py """ Модуль автоматического экспорта данных СКУД (Orion) из MS SQL Server в SQLite и Excel. Добавлена фиксация Первой Активности (без учета направления) и среза по времени. """ import argparse import logging import os import sys import warnings from datetime import datetime, timedelta import pandas as pd import pyodbc from openpyxl.utils import get_column_letter from config import SCUD_DIR, clean_scud_fio_light, load_exceptions from core.database import save_scud_to_db, has_scud_logs_for_date, has_yesterday_final_snapshot warnings.filterwarnings("ignore", message="pandas only supports SQLAlchemy connectable") SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_DIR = os.path.join(SCRIPT_DIR, "..", "logs") os.makedirs(LOG_DIR, exist_ok=True) TODAY_DATE_STR = datetime.now().strftime("%d.%m.%Y") LOG_FILE = os.path.join(LOG_DIR, f"export_{TODAY_DATE_STR}.log") logger = logging.getLogger("scud_export") logger.setLevel(logging.INFO) if not logger.handlers: _file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8") _console_handler = logging.StreamHandler(sys.stdout) _formatter = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S") _file_handler.setFormatter(_formatter) _console_handler.setFormatter(_formatter) logger.addHandler(_file_handler) logger.addHandler(_console_handler) def log(message: str, level: str = "INFO"): level_map = { "INFO": logging.INFO, "ERROR": logging.ERROR, "WARNING": logging.WARNING, "SUCCESS": logging.INFO, } if level == "SUCCESS": logger.info(f"[SUCCESS] {message}") else: logger.log(level_map.get(level, logging.INFO), message) SERVER_NAME = r"172.16.31.221\SQL" DATABASE_NAME = "Orion-14.01.21-1" SQL_USER = "sa" SQL_PASSWORD = "123456" ODBC_DRIVER = "ODBC Driver 18 for SQL Server" SQL_QUERY_TEMPLATE = r""" DECLARE @InputDate DATE = '{target_date}'; DECLARE @TargetDate DATE = @InputDate; DECLARE @StartDate DATETIME = CAST(@TargetDate AS DATETIME); DECLARE @EndDate DATETIME = DATEADD(SECOND, -1, DATEADD(DAY, 1, @StartDate)); WITH DailyLogs AS ( SELECT log.HozOrgan AS EmployeeID, log.TimeVal, log.Event, log.Mode, CASE WHEN log.Mode = 2 OR log.Event IN (29, 27, 33) THEN 'OUT' WHEN log.Mode = 1 OR log.Event IN (28, 26, 32) THEN 'IN' ELSE 'OTHER' END AS Direction, ROW_NUMBER() OVER (PARTITION BY log.HozOrgan ORDER BY log.TimeVal DESC) AS RowNumDesc FROM pLogData log WITH (NOLOCK) WHERE log.TimeVal BETWEEN @StartDate AND @EndDate AND log.HozOrgan IS NOT NULL AND log.HozOrgan > 0 AND log.Event IN (26, 27, 28, 29, 32, 33, 54, 55, 64, 65) ), Passages AS ( SELECT EmployeeID, MIN(TimeVal) AS FirstRawEvent, MAX(TimeVal) AS LastRawEvent, MIN(CASE WHEN Direction = 'IN' THEN TimeVal END) AS FirstIn, MAX(CASE WHEN Direction = 'OUT' THEN TimeVal END) AS LastOut, MAX(CASE WHEN RowNumDesc = 1 THEN Direction END) AS LastEventType FROM DailyLogs GROUP BY EmployeeID ) SELECT N'ЛЕНМОРНИИПРОЕКТ' AS [Фирма], ISNULL(CAST(div.Name AS NVARCHAR(255)), N'Без подразделения') AS [Подразделение], LTRIM(RTRIM( ISNULL(CAST(p.Name AS NVARCHAR(255)), N'') + CASE WHEN p.FirstName IS NOT NULL AND CAST(p.FirstName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.FirstName AS NVARCHAR(255)) ELSE N'' END + CASE WHEN p.MidName IS NOT NULL AND CAST(p.MidName AS NVARCHAR(255)) <> '' THEN N' ' + CAST(p.MidName AS NVARCHAR(255)) ELSE N'' END )) AS [Сотрудник], ISNULL(CAST(post.Name AS NVARCHAR(255)), N'—') AS [Должность], ISNULL(CAST(p.TabNumber AS NVARCHAR(50)), N'—') AS [Таб_№], CONVERT(VARCHAR(10), @TargetDate, 104) AS [Дата], ISNULL(CAST(CONVERT(VARCHAR(8), pass.FirstIn, 108) AS NVARCHAR(20)), N'Нет входа') AS [Начало_дня], CASE WHEN pass.FirstIn IS NULL AND pass.FirstRawEvent IS NOT NULL THEN CAST(CONVERT(VARCHAR(8), pass.FirstRawEvent, 108) AS NVARCHAR(20)) ELSE N'—' END AS [Первая_активность], CASE WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN N'Нет выхода' WHEN pass.LastOut IS NOT NULL AND pass.LastOut > pass.FirstIn THEN CAST(CONVERT(VARCHAR(8), pass.LastOut, 108) AS NVARCHAR(20)) WHEN @TargetDate < CAST(GETDATE() AS DATE) AND pass.LastRawEvent IS NOT NULL AND pass.LastRawEvent > ISNULL(pass.FirstIn, pass.FirstRawEvent) THEN CAST(CONVERT(VARCHAR(8), pass.LastRawEvent, 108) AS NVARCHAR(20)) ELSE N'Нет выхода' END AS [Конец_дня], CASE WHEN pass.EmployeeID IS NOT NULL AND (pass.FirstIn IS NOT NULL OR pass.FirstRawEvent IS NOT NULL) THEN RIGHT('0' + CAST(DATEDIFF(MINUTE, ISNULL(pass.FirstIn, pass.FirstRawEvent), CASE WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() ELSE ISNULL(pass.LastOut, pass.LastRawEvent) END) / 60 AS VARCHAR), 2) + ':' + RIGHT('0' + CAST(DATEDIFF(MINUTE, ISNULL(pass.FirstIn, pass.FirstRawEvent), CASE WHEN @TargetDate = CAST(GETDATE() AS DATE) AND (pass.LastEventType = 'IN' OR pass.LastOut IS NULL OR pass.LastOut <= pass.FirstIn) THEN GETDATE() ELSE ISNULL(pass.LastOut, pass.LastRawEvent) END) % 60 AS VARCHAR), 2) ELSE N'00:00' END AS [Находился_в_здании], CASE WHEN pass.EmployeeID IS NOT NULL THEN N'Присутствовал' ELSE N'Отсутствовал (Нет событий)' END AS [Статус] FROM pList p WITH (NOLOCK) LEFT JOIN PDivision div WITH (NOLOCK) ON p.Section = div.ID LEFT JOIN PPost post WITH (NOLOCK) ON p.Post = post.ID LEFT JOIN Passages pass ON p.ID = pass.EmployeeID WHERE ISNULL(p.StatusRecord, 0) = 0 AND p.DateTimeInArchive IS NULL AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Аренд%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'Без подразделения', N'') AND p.Name NOT LIKE N'бр.%' AND p.Name NOT LIKE N'Гость%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT IN (N'БГИ', N'КНР') AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Рабоч%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Врем%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практика%' AND ISNULL(CAST(div.Name AS NVARCHAR(255)), N'') NOT LIKE N'тест%' AND ISNULL(CAST(post.Name AS NVARCHAR(255)), N'') NOT LIKE N'Практикант%' ORDER BY p.Name ASC; """ def auto_fit_columns(file_path: str, sheet_name: str, padding: float = 2.0, min_width: float = 8.0, max_width: float = 60.0): from openpyxl import load_workbook wb = load_workbook(file_path) ws = wb[sheet_name] for col_cells in ws.columns: max_len = 0 col_letter = get_column_letter(col_cells[0].column) for cell in col_cells: if cell.value is not None: cell_len = len(str(cell.value)) if cell_len > max_len: max_len = cell_len width = max(min_width, min(max_len + padding, max_width)) ws.column_dimensions[col_letter].width = width wb.save(file_path) def get_targets(input_date: str | None): targets = [] if input_date: try: parsed = datetime.strptime(input_date, "%d.%m.%Y").date() targets.append({"name": "Указанная дата", "date": parsed}) except ValueError: log(f"ОШИБКА: Неверный формат даты '{input_date}'. Используйте ДД.ММ.ГГГГ", "ERROR") sys.exit(1) else: now = datetime.now() yesterday = (now - timedelta(days=3 if now.weekday() == 0 else 1)).date() today = now.date() targets.append({"name": "Вчера", "date": yesterday}) targets.append({"name": "Сегодня", "date": today}) return targets def run_export(input_date: str | None = None, save_xlsx: bool = True, debug: bool = False): if debug: logger.setLevel(logging.DEBUG) log("=== ВКЛЮЧЕН РЕЖИМ ОТЛАДКИ (DEBUG MODE) ===", "WARNING") log("=== [ЭТАП 0] Выгрузка свежих данных СКУД напрямую из БД Орион ===") os.makedirs(SCUD_DIR, exist_ok=True) targets = get_targets(input_date) conn_str = ( f"DRIVER={{{ODBC_DRIVER}}};" f"SERVER={SERVER_NAME};" f"DATABASE={DATABASE_NAME};" f"UID={SQL_USER};" f"PWD={SQL_PASSWORD};" f"TrustServerCertificate=yes;" f"Encrypt=no;" ) success = True for target in targets: processing_date = target["date"] processing_date_str = processing_date.strftime("%d.%m.%Y") period_label = target["name"] is_yesterday = (period_label == "Вчера") # ⚡️ ПРОВЕРКА НАЛИЧИЯ ВЧЕРАШНЕГО ДНЯ В SQLITE: # Если это вчерашний день и его снапшот уже зафиксирован в SQLite — пропускаем тяжелый запрос к MS SQL if is_yesterday and has_yesterday_final_snapshot(processing_date_str): log(f"[ℹ️] Вчерашний день ({processing_date_str}) уже зафиксирован финишным снапшотом Y. Пропускаем запрос к MS SQL.") continue if is_yesterday: snapshot_time = f"{processing_date.strftime('%Y-%m-%d')} 22:00:00" else: snapshot_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log(f"--- Обработка периода: {period_label} ({processing_date_str}) --- [Снапшот: {snapshot_time}]") sql_query = SQL_QUERY_TEMPLATE.format(target_date=processing_date.strftime("%Y-%m-%d")) connection = None try: connection = pyodbc.connect(conn_str, timeout=120) df = pd.read_sql(sql_query, connection) log(f"База вернула {len(df)} строк за {processing_date_str}") if len(df) > 0: df['fio_clean'] = df['Сотрудник'].apply(clean_scud_fio_light) df['anomaly_flag'] = 'NONE' mask_anomaly = (df['Начало_дня'] == 'Нет входа') & (df['Первая_активность'] != '—') df.loc[mask_anomaly, 'anomaly_flag'] = 'ANOMALY_NO_IN_HAS_ACTIVITY' df['Пришел'] = df['Статус'].str.contains('Присутствовал', case=False, na=False) & (~mask_anomaly) save_scud_to_db(df, processing_date_str, snapshot_time=snapshot_time, is_yesterday=is_yesterday) log(f"[✓] Записи за {processing_date_str} успешно сохранены в SQLite!", "SUCCESS") if save_xlsx: file_name = f"Сотрудники_{processing_date_str}.xlsx" file_path = os.path.join(SCUD_DIR, file_name) if os.path.exists(file_path): try: os.remove(file_path) except OSError as e: log(f"ОШИБКА при удалении старого файла {file_name}: {e}", "ERROR") df.to_excel(file_path, sheet_name="Отчет", index=False, engine="openpyxl") auto_fit_columns(file_path, sheet_name="Отчет") log(f"[✓] Успешно экспортирован файл: data/scud/{file_name}", "SUCCESS") else: log(f"Запрос за {processing_date_str} вернул 0 строк.", "WARNING") except Exception as e: log(f"🛑 ОШИБКА выгрузки СКУД за {processing_date_str}: {e}", "ERROR") success = False finally: if connection is not None: connection.close() log("=== Выгрузка СКУД завершена ===\n") return success if __name__ == "__main__": help_text = """ Модуль прямого экспорта данных СКУД Орион Pro (MS SQL Server) в SQLite и Excel. ПРИМЕРЫ ЗАПУСКА: python services/scud_export.py -- Автоматическая выгрузка за Сегодня и Вчера python services/scud_export.py --date 05.08.2026 -- Точечная выгрузка СКУД за конкретную дату python services/scud_export.py --no-xlsx -- Сохранение логов СКУД ТОЛЬКО в SQLite без дублирования в XLSX python services/scud_export.py -d -- Запуск в режиме отладки MS SQL запросов (DEBUG) """ parser = argparse.ArgumentParser( description=help_text, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument("--date", dest="input_date", default=None, help="Дата выгрузки в формате ДД.ММ.ГГГГ (по умолчанию выгружаются Вчера и Сегодня)") parser.add_argument("-d", "--debug", action="store_true", help="Включить подробные логи отладки подключения и запросов к MS SQL") parser.add_argument("--no-xlsx", dest="save_xlsx", action="store_false", default=True, help="Отключить генерирование промежуточных XLSX-файлов в папке data/scud/") args = parser.parse_args() run_export(args.input_date, save_xlsx=args.save_xlsx, debug=args.debug) ``` ## File: `./services/share_copier.py` ```py import os import shutil from config import DATE_TODAY, DATE_YESTERDAY, ZUP_1C_DIR, SHARE_1C_DIR def find_file_strictly_on_share(prefix, date_str): """ Ищет файл со строгой привязкой ТОЛЬКО к сетевой шаре SHARE_1C_DIR, не обращаясь к локальным папкам. """ if not os.path.exists(SHARE_1C_DIR): return None date_dots = date_str date_underscores = date_str.replace('.', '_') try: for f in os.listdir(SHARE_1C_DIR): if f.endswith('.xlsx') or f.endswith('.csv'): if f.lower().startswith(prefix.lower()): if date_dots in f or date_underscores in f: return os.path.join(SHARE_1C_DIR, f) except Exception as e: print(f" [⚠️] Ошибка чтения сетевой шары: {e}") return None def copy_1c_files_from_share(): r""" Копирует свежие файлы 1С (Штат и Отсутствия) за Сегодня и Вчера из сетевой шары \\storage\SCUD\Обмен\Штат в локальную папку data/1c/ """ print(f"[0.5/5] Проверка и копирование файлов 1С с шары: {SHARE_1C_DIR}...") if not os.path.exists(SHARE_1C_DIR): print(f"[⚠️] Сетевой шар недоступен или путь не найден: {SHARE_1C_DIR}") print(" Используем ранее сохраненные локальные файлы из data/1c/\n") return False dates_to_copy = [DATE_TODAY, DATE_YESTERDAY] prefixes = ["Штат", "Отсутствия"] copied_count = 0 os.makedirs(ZUP_1C_DIR, exist_ok=True) for d_str in dates_to_copy: for prefix in prefixes: remote_file = find_file_strictly_on_share(prefix, d_str) if remote_file and os.path.exists(remote_file): filename = os.path.basename(remote_file) local_target_path = os.path.join(ZUP_1C_DIR, filename) try: shutil.copy2(remote_file, local_target_path) print(f" [✓] Успешно скопирован с шары: {filename} -> data/1c/") copied_count += 1 except Exception as e: print(f" [⚠️] Ошибка копирования {filename}: {e}") else: d_fmt = d_str.replace('.', '_') print(f" [ℹ️] На сетевой шаре отсутствует {prefix} за {d_str} ({prefix}_{d_fmt}.xlsx)") if copied_count > 0: print(f"[✓] Успешно скопировано файлов с сетевой шары: {copied_count} шт.\n") else: print("[ℹ️] Новых файлов за указанные даты на сетевой шаре не обнаружено.\n") return True ``` ## File: `./services/text_reporter.py` ```py import json import difflib import re from services.ai_verifier import ask_ollama from services.knowledge_base import load_knowledge_base def find_python_fuzzy_matches(unexplained_df, raw_absent_df): """ Точный поиск совпадений ФИО силами Python (без галлюцинаций ИИ). Сравнивает фамилию и полное имя с порогом сходства >= 0.75. """ if unexplained_df.empty or raw_absent_df is None or raw_absent_df.empty: return [] absent_dict = {} for idx, r in raw_absent_df.iterrows(): raw_fio = str(r.get('ФИО', '')) clean_fio = str(r.get('fio_clean', '')) reason = str(r.get('Вид_отсутствия', '')) if clean_fio and clean_fio not in ['Ао "Ленморниипроект"', 'Сотрудник', 'Nan', 'None']: absent_dict[clean_fio] = {'raw_fio': raw_fio, 'reason': reason} matches = [] unexplained_fios = unexplained_df['fio_clean'].unique() if 'fio_clean' in unexplained_df.columns else [] for fio in unexplained_fios: fio_parts = fio.split() if not fio_parts: continue surname = fio_parts[0].lower() for abs_clean, abs_info in absent_dict.items(): abs_parts = abs_clean.split() if not abs_parts: continue abs_surname = abs_parts[0].lower() if surname == abs_surname: ratio = difflib.SequenceMatcher(None, fio.lower(), abs_clean.lower()).ratio() if ratio >= 0.75: matches.append({ "target_fio": fio, "found_in_1c_raw": abs_info['raw_fio'], "reason_1c": abs_info['reason'] }) break return matches def generate_markdown_report(merged_df, absent_explained, absent_unexplained, scud_present_but_absent_in_1c, anomalies_list=None, raw_scud_df=None, raw_staff_df=None, raw_absent_df=None, date_str="05.08.2026"): # 1. Загрузка Базы Знаний kb = load_knowledge_base() custom_rules = kb.get("rules", []) custom_rules_str = "\n".join([f"- {r}" for r in custom_rules]) if custom_rules else "Специфических правил компании пока нет." # 2. Предварительный расчет ключевых метрик (РЕШАЕТ NameError: total_staff) total_staff = len(merged_df) is_working_mask = (merged_df['Пришел'] == True) | ( merged_df['Вид_отсутствия'].astype(str).str.lower().str.contains('командировк|удален|дистанцион|разъездн', regex=True, na=False) ) present_cnt = len(merged_df[is_working_mask]) explained_cnt = len(absent_explained) unexplained_cnt = len(absent_unexplained) anomalies_cnt = len(anomalies_list) if anomalies_list else 0 # 3. Точный расчет неточных совпадений через Python fio_mismatches_python = find_python_fuzzy_matches(absent_unexplained, raw_absent_df) # 4. Формирование списка неизвестных с приоритетом аббревиатуры СКУД unexplained_list = [] if not absent_unexplained.empty: for idx, r in absent_unexplained.iterrows(): fio = r.get('Сотрудник', r.get('fio_clean', '')) dept_scud = str(r.get('department_scud', '')).strip() dept_1c = str(r.get('Подразделение', '')).strip() dept = dept_scud if (dept_scud and dept_scud.lower() != 'nan' and dept_scud != '—') else dept_1c if not dept: dept = '—' pos = r.get('Должность', '—') unexplained_list.append(f"{fio} — {dept}, {pos}") # 5. Сборка промпта для Ollama prompt = f""" Ты — старший аудитор кадровой безопасности и контроллинга СКУД. Сформируй итоговую сводку кадрового контроля на {date_str}. 📌 БАЗА ЗНАНИЙ И ПРАВИЛА ПРЕДПРИЯТИЯ: {custom_rules_str} 🚨 ВХОДНЫЕ МЕТРИКИ: - Всего сотрудников: {total_staff} - Работают (офис / удаленка / командировки): {present_cnt} - Официально отсутствуют: {explained_cnt} - Неизвестно (истинно неотмеченные): {unexplained_cnt} - Выявлено аномалий/конфликтов реестров: {anomalies_cnt} --- 🚨 ПОДТВЕРЖДЁННЫЕ АНОМАЛИИ ({anomalies_cnt} шт): {json.dumps(anomalies_list, ensure_ascii=False, indent=2)} --- ⚠️ ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО В 1С ({len(fio_mismatches_python)} шт): {json.dumps(fio_mismatches_python, ensure_ascii=False, indent=2)} --- 📊 СПИСОК НЕИЗВЕСТНЫХ СЛУЧАЕВ ({unexplained_cnt} чел): {json.dumps(unexplained_list, ensure_ascii=False, indent=2)} СТРОГИЕ ИНСТРУКЦИИ ДЛЯ ИИ: 1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО придумывать несуществующие совпадения ФИО или приписывать суффиксы "(осн.)". Используй ТОЛЬКО массив `ПОДТВЕРЖДЁННЫЕ PYTHON ОШИБКИ СОПОСТАВЛЕНИЯ ФИО`. Если этот массив пуст, напиши в этом разделе: "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено." 2. В разделе "Неизвестные случаи" выведи НУМЕРОВАННЫЙ СПИСОК всех {unexplained_cnt} человек ровно в том виде, в котором они переданы выше. 3. В разделе "Рекомендации" дай 2-3 конкретные системные рекомендации для кадровой службы. НЕ ПЕРЕЧИСЛЯЙ конкретные ФИО в тексте рекомендаций. СТРОГИЙ ШАБЛОН ОТВЕТА: **Сводка контроллинга СКУД и 1С:ЗУП на {date_str}** - Всего офисных сотрудников: **{total_staff}** - Работают (офис / удаленка / командировки): **{present_cnt}** - Официально отсутствуют: **{explained_cnt}** - Неизвестно (истинно неотмеченные): **{unexplained_cnt}** чел. - Выявлено аномалий/конфликтов реестров: **{anomalies_cnt}** шт. #### 🚨 Выявленные ИИ аномалии и конфликты источников ({anomalies_cnt}): (Описание аномалий из anomalies_list. Если их нет — "Аномалий не обнаружено.") #### ⚠️ Подозрения на ошибки сопоставления ФИО и несоответствия 1С: (Выведи данные ИСКЛЮЧИТЕЛЬНО из массива fio_mismatches_python. Если он пуст — "Ошибок сопоставления ФИО и неточностей в 1С не обнаружено.") #### Неизвестные случаи: {unexplained_cnt} (Выведи нумерованный список всех {unexplained_cnt} человек) ### Точечные рекомендации: (2-3 системные рекомендации без указания ФИО сотрудников) """ sys_prompt = "Ты — русскоязычный кадровый аудитор. Пиши СТРОГО на русском языке по предоставленному Markdown-шаблону. Категорически запрещено выводить JSON или китайские символы." return ask_ollama(prompt, system_prompt=sys_prompt) ``` ## File: `./services/zup_extractor.py` ```py import os import logging from datetime import date, datetime import pyodbc import pandas as pd from config import DATA_DIR, normalize_fio, ZUP_SQL_CONFIG logger = logging.getLogger(__name__) def get_zup_connection_string() -> str: """Формирует строку подключения pyodbc к MS SQL Server из config.py.""" return ( f"DRIVER={ZUP_SQL_CONFIG['driver']};" f"SERVER={ZUP_SQL_CONFIG['server']};" f"DATABASE={ZUP_SQL_CONFIG['database']};" f"UID={ZUP_SQL_CONFIG['user']};" f"PWD={ZUP_SQL_CONFIG['password']};" f"TrustServerCertificate={ZUP_SQL_CONFIG.get('trust_server_certificate', 'yes')};" f"Encrypt={ZUP_SQL_CONFIG.get('encrypt', 'no')};" ) def fetch_zup_absences_from_sql(target_date) -> pd.DataFrame: """ Извлекает оперативные отсутствия и действующие декреты из MS SQL 1С:ЗУП 3.1 на указанную дату (принимает как datetime.date, так и строку 'DD.MM.YYYY' / 'DD_MM_YYYY'). """ if isinstance(target_date, str): clean_date_str = target_date.replace('_', '.') try: target_date = datetime.strptime(clean_date_str, "%d.%m.%Y").date() except ValueError: logger.error(f"[❌] Неверный формат даты для SQL-запроса: {target_date}. Ожидался DD.MM.YYYY") return pd.DataFrame() query = """ DECLARE @TargetDate DATE = ?; -- 1. Оперативные отсутствия (Отпуска, Командировки, Больничные, Отгулы) SELECT LTRIM(RTRIM(ref_emp._Description)) AS [ФИО], CASE state._Fld16925RRef WHEN 0x9C10B2452D414FDF4A90E2B2AB81D3F7 THEN N'Отпуск основной' WHEN 0xBA63FCF94B4AD0664ED369D2E6505D67 THEN N'Командировка' WHEN 0x8C3B61F23954155A40EB0108FC0932DB THEN N'Болезнь' WHEN 0x853001C18D0965EE4B2702405C94054A THEN N'Отпуск неоплачиваемый по разрешению работодателя' WHEN 0xB7335AEFD8708C3E462861FC59489A38 THEN N'Отпуск по беременности и родам' ELSE N'Другое отсутствие' END AS [Вид_отсутствия] FROM dbo._InfoRg16921 state WITH (NOLOCK) INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK) ON state._Fld16922RRef = ref_emp._IDRRef WHERE @TargetDate BETWEEN CAST(CASE WHEN YEAR(state._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16926) ELSE state._Fld16926 END AS DATE) AND CAST(CASE WHEN YEAR(state._Fld16927) > 3000 THEN DATEADD(YEAR, -2000, state._Fld16927) ELSE state._Fld16927 END AS DATE) UNION ALL -- 2. Динамический выбор ДЕЙСТВУЮЩИХ декретниц по уходу за ребенком SELECT active_state.fio AS [ФИО], N'Отпуск по уходу за ребенком' AS [Вид_отсутствия] FROM ( SELECT LTRIM(RTRIM(ref_emp._Description)) AS fio, all_states._Fld16925RRef AS state_guid, CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) AS date_start, ROW_NUMBER() OVER ( PARTITION BY all_states._Fld16922RRef ORDER BY all_states._Fld16926 DESC ) AS rn FROM dbo._InfoRg16921 all_states WITH (NOLOCK) INNER JOIN dbo._Reference299 ref_emp WITH (NOLOCK) ON all_states._Fld16922RRef = ref_emp._IDRRef WHERE CAST(CASE WHEN YEAR(all_states._Fld16926) > 3000 THEN DATEADD(YEAR, -2000, all_states._Fld16926) ELSE all_states._Fld16926 END AS DATE) <= @TargetDate ) active_state WHERE active_state.rn = 1 AND active_state.state_guid = 0xA4FBA038663B3C2A48DA151C262855E1 AND active_state.date_start >= DATEADD(YEAR, -3, @TargetDate) ORDER BY [ФИО] ASC; """ try: conn_str = get_zup_connection_string() with pyodbc.connect(conn_str, timeout=5) as conn: df = pd.read_sql(query, conn, params=[target_date]) if not df.empty: df['fio_clean'] = df['ФИО'].apply(normalize_fio) return df except Exception as e: logger.error(f"[❌] Ошибка SQL-выгрузки отсутствий за {target_date}: {e}") return pd.DataFrame() def fetch_zup_staff_from_sql() -> pd.DataFrame: """Резервная выгрузка штата из MS SQL.""" query = """ SELECT DISTINCT LTRIM(RTRIM(ref_emp._Description)) AS [ФИО], N'Организация' AS [Подразделение], N'Сотрудник' AS [Должность] FROM dbo._Reference299 ref_emp WITH (NOLOCK) WHERE ref_emp._Description <> '' AND ref_emp._Marked = 0x00 ORDER BY [ФИО] ASC; """ try: conn_str = get_zup_connection_string() with pyodbc.connect(conn_str, timeout=5) as conn: df = pd.read_sql(query, conn) if not df.empty: df['fio_clean'] = df['ФИО'].apply(normalize_fio) return df except Exception as e: logger.error(f"[❌] Ошибка выгрузки штата из MS SQL: {e}") return pd.DataFrame() def sync_zup_to_excel(target_date) -> bool: """Создает дамп в Excel при необходимости.""" try: if isinstance(target_date, str): clean_date_str = target_date.replace('_', '.') target_date_obj = datetime.strptime(clean_date_str, "%d.%m.%Y").date() else: target_date_obj = target_date date_str_file = target_date_obj.strftime("%d_%m_%Y") c_1c_dir = os.path.join(DATA_DIR, "1c") os.makedirs(c_1c_dir, exist_ok=True) df_staff = fetch_zup_staff_from_sql() df_absences = fetch_zup_absences_from_sql(target_date_obj) staff_excel_path = os.path.join(c_1c_dir, f"Штат_{date_str_file}.xlsx") absences_excel_path = os.path.join(c_1c_dir, f"Отсутствия_{date_str_file}.xlsx") with pd.ExcelWriter(staff_excel_path, engine='openpyxl') as writer: dummy_headers = pd.DataFrame([[""] * 13] * 8) dummy_headers.to_excel(writer, index=False, header=False) df_staff[['ФИО', 'Подразделение', 'Должность']].to_excel(writer, startrow=8, index=False) with pd.ExcelWriter(absences_excel_path, engine='openpyxl') as writer: dummy_headers = pd.DataFrame([[""] * 3] * 3) dummy_headers.to_excel(writer, index=False, header=False) df_absences[['ФИО', 'Вид_отсутствия']].to_excel(writer, startrow=3, index=False) return True except Exception as e: logger.error(f"[❌] Ошибка прямого импорта из MS SQL 1С: {e}") return False ``` ## File: `./services/knowledge/__init__.py` ```py ``` ## 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/prompts/__init__.py` ```py ``` ## File: `./services/prompts/diff_engine.py` ```py """ =============================================================================== 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 ``` ## File: `./services/prompts/repository.py` ```py """ =============================================================================== 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() ``` ## File: `./services/prompts/service.py` ```py """ =============================================================================== 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 ``` ## File: `./services/scud_etl/__init__.py` ```py ``` ## 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: Автоматическое выявление аномалий и конфликтов между 1С:ЗУП и СКУД. AI-CONTEXT-ANCHORS: - ANCHOR[ANOMALY_DETECTOR_CORE]: Проверка физического присутствия в отпуске и перемещений без входа. =============================================================================== """ import pandas as pd from typing import List, Dict, Any, Optional ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк'] # ANCHOR[ANOMALY_DETECTOR_CORE] def detect_all_anomalies( merged_df: pd.DataFrame, static_reasons_dict: dict, kb_rules: List[str], scud_fios_set: Optional[set] = None ) -> List[Dict[str, Any]]: """Выявляет конфликты и аномалии между источниками СКУД и 1С.""" anomalies = [] kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" 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 = fio_clean.lower() in kb_rules_text # 1. Присутствие при официальном отсутствии if is_present and has_1c_reason: is_allowed_trip = any(kw in reason_1c.lower() for kw in ALLOWED_WORK_TRIP_KEYWORDS) if not is_allowed_trip and not is_fio_whitelisted: anomalies.append({ "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", "fio": fio, "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" }) if is_exc and not has_1c_reason: continue # 2. Перемещение внутри здания без отметки входа на КПП if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': first_act = row.get('Первая_активность', '—') anomalies.append({ "type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА", "fio": fio, "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}" }) # 3. Сотрудник в штате 1С, но карты/профиля в СКУД нет 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('Подразделение', '—')}), но отсутствует в СКУД" }) return anomalies ``` ## File: `./services/scud_etl/merger.py` ```py """ =============================================================================== FILE: services/scud_etl/merger.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: services / scud_etl ROLE: Агрегация проходов, сопоставление исключений и мердж таблиц 1С:ЗУП и СКУД. AI-CONTEXT-ANCHORS: - ANCHOR[MERGER_EXCEPTIONS]: Наложение флага исключений из exceptions.json. - ANCHOR[MERGER_AGGREGATION]: Агрегация множественных проходов до уникального ФИО. - ANCHOR[MERGER_BUILD_DATASET]: Сборка итогового датасета для сводок и отчетов. =============================================================================== """ import os import json import pandas as pd from config import normalize_fio, DATA_DIR # ANCHOR[MERGER_EXCEPTIONS] def load_exceptions_config() -> dict: """Загружает exceptions.json из корня проекта.""" root_dir = os.path.abspath(os.path.join(os.path.dirname(__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: return {} def apply_exceptions_from_json(df: pd.DataFrame, exceptions_cfg: dict) -> pd.DataFrame: """Быстрая разметка флага is_excluded на основе exceptions.json.""" 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 = any(d in dep_1c or d in dep_scud for d in deps) if deps else False if is_fio_exc or is_dep_exc or is_pos_exc: df.at[idx, 'is_excluded'] = True return df # ANCHOR[MERGER_AGGREGATION] def aggregate_scud_by_employee(df: pd.DataFrame) -> pd.DataFrame: """Агрегирует проходы СКУД по уникальным сотрудникам.""" if df is None or df.empty or 'fio_clean' not in df.columns: return df aggregated = [] for fio_clean, group in df.groupby('fio_clean', sort=False): is_present = group['Пришел'].any() if 'Пришел' in group.columns else False if is_present and 'Пришел' in group.columns: present_rows = group[group['Пришел'] == True] best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict() else: best_row = group.iloc[0].to_dict() best_row['Пришел'] = is_present aggregated.append(best_row) return pd.DataFrame(aggregated) def filter_report_dataframe(merged_df: pd.DataFrame) -> pd.DataFrame: """Исключает подрядчиков и сотрудников без пропуска из детального отчета.""" 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 return merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy() def load_static_reason_workers() -> dict: """Загружает реестр удаленщиков из 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: pass return {} ``` ## File: `./services/scud_etl/pipeline.py` ```py """ =============================================================================== FILE: services/scud_etl/pipeline.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: services / scud_etl ROLE: Оркестратор этапов контроллинга (Загрузка -> Сверка -> Отчеты -> SQLite). AI-CONTEXT-ANCHORS: - ANCHOR[PIPELINE_RUN_CONTROLLING]: Главная функция выполнения ETL-конвейера. =============================================================================== """ import os from datetime import datetime, timedelta import pandas as pd from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR from core.database import ( save_scud_to_db, save_staff_to_db, save_absences_to_db, save_anomalies_to_db, load_scud_from_db_by_snapshot, get_latest_snapshot_time ) from services.data_loader import load_1c_data_smart 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 .merger import ( load_exceptions_config, apply_exceptions_from_json, aggregate_scud_by_employee, filter_report_dataframe, load_static_reason_workers ) from .anomaly_detector import detect_all_anomalies # ANCHOR[PIPELINE_RUN_CONTROLLING] def run_controlling_pipeline(snapshot_param: str = None, skip_export: bool = False, debug: bool = False, has_today_1c: bool = True) -> None: """Выполняет полный цикл сверки СКУД ⟷ 1С и сохранение результатов.""" kb_rules = load_knowledge_base().get("rules", []) exceptions_cfg = load_exceptions_config() static_reasons = load_static_reason_workers() print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") # 1. Определение дат целевого снапшота и предыдущей смены if snapshot_param or skip_export: snap_to_use = snapshot_param or get_latest_snapshot_time() raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snap_to_use) 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 else: target_date_str = DATE_TODAY raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param) dt_target = datetime.strptime(target_date_str, "%d.%m.%Y") dt_yesterday = dt_target - timedelta(days=3 if dt_target.weekday() == 0 else 1) yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y") print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}\n") raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True) df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True) # 2. Этап 3: Обработка ВЧЕРА (Детальный отчет) print(f"[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...") 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) # Проверка опечаток ФИО через AI-аудитор staff_fios_y_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else [] if not raw_scud_yesterday_df.empty: raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False unrecog = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_y_clean)]['fio_clean'].tolist() fio_map = ai_verify_scud_against_staff(unrecog, staff_fios_y_clean) if fio_map: raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply(lambda x: fio_map[x]['staff_fio'] if x in fio_map else x) raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df) merged_y = (df_staff_yesterday.copy() if df_staff_yesterday is not None else pd.DataFrame()) if not merged_y.empty: if not raw_scud_yesterday_df.empty: merged_y = merged_y.merge( raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']], on='fio_clean', how='left' ) if df_absent_yesterday is not None and not df_absent_yesterday.empty: merged_y = merged_y.merge(df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') merged_y['Пришел'] = merged_y['Пришел'].fillna(False) if 'Пришел' in merged_y.columns else False if 'Сотрудник' not in merged_y.columns: merged_y['Сотрудник'] = merged_y.get('ФИО', merged_y['fio_clean']) merged_y = apply_exceptions_from_json(merged_y, exceptions_cfg) scud_fios_y = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set() anomalies_y = detect_all_anomalies(merged_y, static_reasons, kb_rules, scud_fios_set=scud_fios_y) save_anomalies_to_db(anomalies_y, yesterday_date_str) filtered_y = filter_report_dataframe(merged_y) generate_detailed_excel(merged_df=filtered_y, date_str=yesterday_date_str) save_scud_to_db(merged_y, yesterday_date_str, snapshot_time=f"{dt_yesterday.strftime('%Y-%m-%d')} 22:00:00", is_yesterday=True) print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}") # 3. Этап 4 & 5: Обработка СЕГОДНЯ (Ежедневная сводка) 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}...") save_staff_to_db(df_staff_today, target_date_str) save_absences_to_db(df_absent_today, target_date_str) raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df) merged_t = df_staff_today.copy() if not raw_scud_today_df.empty: merged_t = merged_t.merge( raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']], on='fio_clean', how='left' ) merged_t = merged_t.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') merged_t['Пришел'] = merged_t['Пришел'].fillna(False) if 'Пришел' in merged_t.columns else False if 'Сотрудник' not in merged_t.columns: merged_t['Сотрудник'] = merged_t.get('ФИО', merged_t['fio_clean']) merged_t = apply_exceptions_from_json(merged_t, exceptions_cfg) scud_fios_t = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() anomalies_t = detect_all_anomalies(merged_t, static_reasons, kb_rules, scud_fios_set=scud_fios_t) save_anomalies_to_db(anomalies_t, target_date_str) print("[5/5] Сохранение Ежедневной сводки...") generate_summary_excel(merged_df=merged_t, date_str=target_date_str) print(f"[✓] Ежедневная сводка сохранена за {target_date_str}") else: print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.") ``` ## File: `./services/scud_etl/sql_queries.py` ```py """ =============================================================================== FILE: services/scud_etl/sql_queries.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: services / scud_etl ROLE: Хранилище сырых SQL-шаблонов для выгрузки из MS SQL Server (СКУД Орион Pro). AI-CONTEXT-ANCHORS: - ANCHOR[SQL_SCUD_EXPORT_TEMPLATE]: T-SQL запрос с расчетом первой активности и длительности. =============================================================================== """ # ANCHOR[SQL_SCUD_EXPORT_TEMPLATE] SCUD_EXPORT_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; """ ``` ## File: `./services/snapshots/__init__.py` ```py ``` ## 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/__init__.py` ```py ``` ## File: `./services/tasks/exporter.py` ```py """ =============================================================================== 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)})." } ``` ## 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}'"} ``` ## File: `./core/__init__.py` ```py ``` ## 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 ); """) # 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() 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 ``` ## 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: `./modules/__init__.py` ```py ``` ## File: `./modules/web_api/__init__.py` ```py ``` ## File: `./modules/web_api/main.py` ```py """ =============================================================================== FILE: modules/web_api/main.py PROJECT: SCUD Orion AI (Unified Repository) MODULE: web_api (Main Application Entry Point) ROLE: Инициализация FastAPI приложения, подключение роутеров и статики. =============================================================================== """ # ANCHOR[APP_INIT_IMPORTS] import os import sys import logging 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 from fastapi.exceptions import RequestValidationError 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( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler()] ) STATIC_DIR = os.path.join(CURRENT_DIR, "static") app = FastAPI(title="SCUD Orion AI Context API", version="2.5") if os.path.exists(STATIC_DIR): app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") @app.exception_handler(RequestValidationError) async def validation_exception_handler(request, exc): logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}") return JSONResponse( status_code=422, content={"detail": exc.errors(), "body": str(exc)} ) # ANCHOR[ROUTER_REGISTRATION] 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("/") def read_root(): index_path = os.path.join(STATIC_DIR, "index.html") if os.path.exists(index_path): return FileResponse(index_path) raise HTTPException(status_code=404, detail="Frontend index.html not found") @app.get("/favicon.ico") async def favicon(): file_path = os.path.join(STATIC_DIR, "favicon.ico") if os.path.exists(file_path): return FileResponse(file_path) raise HTTPException(status_code=404) @app.get("/{file_path:path}") def serve_static_fallback(file_path: str): clean_path = file_path.lstrip("/") target = os.path.join(STATIC_DIR, clean_path) if os.path.isfile(target): if clean_path.endswith(".js"): return FileResponse(target, media_type="application/javascript") elif clean_path.endswith(".css"): return FileResponse(target, media_type="text/css") return FileResponse(target) filename = os.path.basename(clean_path) for root, _, files in os.walk(STATIC_DIR): if filename in files: full_path = os.path.join(root, filename) media = "application/javascript" if filename.endswith(".js") else "text/css" return FileResponse(full_path, media_type=media) raise HTTPException(status_code=404, detail="File not found") ``` ## File: `./modules/web_api/scripts/clear_history.py` ```py """ Скрипт полной очистки истории диалогов и сессионных состояний SQLite. """ import os import sqlite3 # Рассчитываем путь к общей БД data/scud_orion_ai.db в корне проекта BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db") def clear_chat_history(): if not os.path.exists(DB_PATH): print(f"❌ База данных не найдена по адресу: {DB_PATH}") return conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() # Очищаем таблицы сообщений и стейтов try: cursor.execute("DELETE FROM chat_messages;") except sqlite3.OperationalError: pass try: cursor.execute("DELETE FROM session_states;") except sqlite3.OperationalError: pass try: cursor.execute("DELETE FROM chat_sessions;") except sqlite3.OperationalError: pass conn.commit() conn.close() print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!") if __name__ == "__main__": clear_chat_history() ``` ## File: `./modules/web_api/scripts/diagnostics/show_tree.py` ```py import os EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'} def print_tree(startpath): print("=" * 60) print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА") print("=" * 60) for root, dirs, files in os.walk(startpath): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] level = root.replace(startpath, '').count(os.sep) indent = ' ' * 4 * (level) print(f'{indent}📁 {os.path.basename(root)}/') subindent = ' ' * 4 * (level + 1) for f in sorted(files): if not f.endswith('.pyc'): print(f'{subindent}📄 {f}') print("=" * 60) if __name__ == "__main__": print_tree('.') ``` ## File: `./modules/web_api/static/index.html` ```html SCUD Orion AI — Context Manager

SCUD Orion AI

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

Или

SCUD Orion AI

Task & Context API

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

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

``` ## File: `./modules/web_api/static/js/app.js` ```js const AUTH_TOKEN_KEY = "scud_api_auth_token"; const SESSION_ID = "web_session_main"; const STORAGE_KEY = "scud_chat_input_history"; let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || ""; let CURRENT_USERNAME = localStorage.getItem("scud_username") || ""; let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true"; let IS_GUEST = localStorage.getItem("scud_is_guest") === "true"; let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); let historyIndex = -1; document.addEventListener("DOMContentLoaded", () => { const userInputEl = document.getElementById("user-input"); if (userInputEl) { userInputEl.addEventListener("input", function() { this.style.height = "24px"; const newHeight = Math.min(this.scrollHeight, 120); this.style.height = newHeight + "px"; }); } if (IS_GUEST) { hideAuthModal(); updateUIState(); } else if (API_TOKEN) { hideAuthModal(); updateUIState(); if (typeof loadTasks === "function") { loadTasks(); } } else { showAuthModal(); } }); ``` ## File: `./modules/web_api/static/js/auth.js` ```js function showAuthModal() { const el = document.getElementById("auth-modal"); if (el) el.classList.remove("hidden"); } function hideAuthModal() { const el = document.getElementById("auth-modal"); if (el) el.classList.add("hidden"); } async function handleLogin(e) { if (e && e.preventDefault) e.preventDefault(); const usernameInput = document.getElementById("auth-username-input"); const passwordInput = document.getElementById("auth-password-input"); const errorEl = document.getElementById("auth-error"); if (!usernameInput || !passwordInput) return; const username = usernameInput.value.trim(); const password = passwordInput.value; if (!username || !password) return; if (errorEl) errorEl.classList.add("hidden"); try { const res = await fetch("/api/v1/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }); const data = await res.json(); if (res.status === 200) { // Используем прямой строковый ключ, чтобы избежать ошибки ReferenceError API_TOKEN = data.token; CURRENT_USERNAME = data.username; IS_ADMIN = data.is_admin; IS_GUEST = false; localStorage.setItem("scud_api_auth_token", data.token); localStorage.setItem("scud_username", data.username); localStorage.setItem("scud_is_admin", data.is_admin ? "true" : "false"); localStorage.removeItem("scud_is_guest"); hideAuthModal(); updateUIState(); if (typeof loadTasks === 'function') { loadTasks(); } } else { if (errorEl) { errorEl.innerText = data.detail || "Ошибка авторизации"; errorEl.classList.remove("hidden"); } } } catch (err) { console.error("[Auth Error]", err); if (errorEl) { errorEl.innerText = "Ошибка соединения с сервером"; errorEl.classList.remove("hidden"); } } } function enableGuestMode() { IS_GUEST = true; API_TOKEN = ""; CURRENT_USERNAME = "Гость"; IS_ADMIN = false; localStorage.setItem("scud_is_guest", "true"); hideAuthModal(); updateUIState(); } function logout() { localStorage.removeItem("scud_api_auth_token"); localStorage.removeItem("scud_username"); localStorage.removeItem("scud_is_admin"); localStorage.removeItem("scud_is_guest"); API_TOKEN = ""; CURRENT_USERNAME = ""; IS_ADMIN = false; IS_GUEST = false; showAuthModal(); } function updateUIState() { const tasksBtn = document.getElementById("tasks-drawer-btn"); const adminBtn = document.getElementById("admin-users-btn"); const changePwdBtn = document.getElementById("change-pwd-btn"); const guestBadge = document.getElementById("guest-badge"); const usernameBadge = document.getElementById("username-badge"); if (typeof IS_GUEST !== 'undefined' && IS_GUEST) { if (tasksBtn) tasksBtn.classList.add("hidden"); if (adminBtn) adminBtn.classList.add("hidden"); if (changePwdBtn) changePwdBtn.classList.add("hidden"); if (guestBadge) guestBadge.classList.remove("hidden"); if (usernameBadge) usernameBadge.classList.add("hidden"); } else { if (tasksBtn) tasksBtn.classList.remove("hidden"); if (changePwdBtn) changePwdBtn.classList.remove("hidden"); if (guestBadge) guestBadge.classList.add("hidden"); if (usernameBadge) { usernameBadge.innerText = (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME) ? CURRENT_USERNAME : "User"; usernameBadge.classList.remove("hidden"); } if (adminBtn) { const isAdminUser = (typeof IS_ADMIN !== 'undefined' && IS_ADMIN) || (typeof CURRENT_USERNAME !== 'undefined' && CURRENT_USERNAME === "puh"); if (isAdminUser) { adminBtn.classList.remove("hidden"); } else { adminBtn.classList.add("hidden"); } } } } function openChangePasswordModal() { const el = document.getElementById("change-pwd-modal"); if (el) el.classList.remove("hidden"); } function closeChangePasswordModal() { const el = document.getElementById("change-pwd-modal"); if (el) el.classList.add("hidden"); const err = document.getElementById("pwd-error"); const succ = document.getElementById("pwd-success"); if (err) err.classList.add("hidden"); if (succ) succ.classList.add("hidden"); document.getElementById("old-pwd-input").value = ""; document.getElementById("new-pwd-input").value = ""; const confirmInput = document.getElementById("confirm-pwd-input"); if (confirmInput) confirmInput.value = ""; } async function handleChangePassword(e) { if (e && e.preventDefault) e.preventDefault(); const old_password = document.getElementById("old-pwd-input").value; const new_password = document.getElementById("new-pwd-input").value; const confirmInput = document.getElementById("confirm-pwd-input"); const confirm_password = confirmInput ? confirmInput.value : new_password; const errorEl = document.getElementById("pwd-error"); const successEl = document.getElementById("pwd-success"); if (errorEl) errorEl.classList.add("hidden"); if (successEl) successEl.classList.add("hidden"); if (new_password !== confirm_password) { if (errorEl) { errorEl.innerText = "Новые пароли не совпадают"; errorEl.classList.remove("hidden"); } return; } try { const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); const res = await fetch("/api/v1/auth/change-password", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token }, body: JSON.stringify({ old_password, new_password }) }); const data = await res.json(); if (res.status === 200) { if (successEl) { successEl.innerText = "Пароль успешно изменен!"; successEl.classList.remove("hidden"); } setTimeout(closeChangePasswordModal, 1500); } else { if (errorEl) { errorEl.innerText = data.detail || "Ошибка при смене пароля"; errorEl.classList.remove("hidden"); } } } catch (err) { if (errorEl) { errorEl.innerText = "Ошибка соединения с сервером"; errorEl.classList.remove("hidden"); } } } function openAdminModal() { const el = document.getElementById("admin-modal"); if (el) el.classList.remove("hidden"); loadUsersList(); } function closeAdminModal() { const el = document.getElementById("admin-modal"); if (el) el.classList.add("hidden"); } async function loadUsersList() { const listEl = document.getElementById("admin-users-list"); if (!listEl) return; listEl.innerHTML = '
Загрузка пользователей...
'; try { const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); const res = await fetch("/api/v1/admin/users", { headers: { "Authorization": "Bearer " + token } }); const users = await res.json(); if (res.status === 200) { listEl.innerHTML = users.map(u => { const adminTag = u.is_admin ? 'ADMIN' : 'USER'; const fullNameHtml = u.full_name ? `
${u.full_name}
` : ''; const dateStr = u.created_at ? u.created_at.split(' ')[0] : '—'; const deleteBtn = u.username !== CURRENT_USERNAME ? `` : 'Вы'; return `
${u.username} ${adminTag}
${fullNameHtml}
Создан: ${dateStr}
${deleteBtn}
`; }).join(""); } else { listEl.innerHTML = `
${users.detail}
`; } } catch (err) { listEl.innerHTML = '
Ошибка загрузки пользователей
'; } } async function handleCreateUser(e) { if (e && e.preventDefault) e.preventDefault(); const username = document.getElementById("new-user-name").value.trim(); const password = document.getElementById("new-user-pwd").value; const fullNameInput = document.getElementById("new-user-fullname"); const full_name = fullNameInput ? fullNameInput.value.trim() : ""; const adminCheckbox = document.getElementById("new-user-is-admin"); const is_admin = adminCheckbox ? adminCheckbox.checked : false; const msgEl = document.getElementById("admin-msg"); if (msgEl) msgEl.classList.add("hidden"); try { const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); const res = await fetch("/api/v1/admin/users", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token }, body: JSON.stringify({ username, password, full_name, is_admin }) }); const data = await res.json(); if (res.status === 200) { document.getElementById("new-user-name").value = ""; document.getElementById("new-user-pwd").value = ""; if (fullNameInput) fullNameInput.value = ""; if (adminCheckbox) adminCheckbox.checked = false; loadUsersList(); } else { if (msgEl) { msgEl.innerText = data.detail || "Ошибка"; msgEl.classList.remove("hidden"); } } } catch (err) { if (msgEl) { msgEl.innerText = "Ошибка связи с сервером"; msgEl.classList.remove("hidden"); } } } async function deleteUser(userId, username) { if (!confirm("Удалить пользователя " + username + "?")) return; try { const token = typeof API_TOKEN !== 'undefined' ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); await fetch("/api/v1/admin/users/" + userId, { method: "DELETE", headers: { "Authorization": "Bearer " + token } }); loadUsersList(); } catch (err) { alert("Ошибка при удалении"); } } ``` ## File: `./modules/web_api/static/js/tasks.js` ```js /** =============================================================================== FILE: modules/web_api/static/js/tasks.js ROLE: Управление боковой панелью задач (Drawer), фильтрация и рендеринг карточек. AI-CONTEXT-ANCHORS: - ANCHOR[DRAWER_TOGGLE]: Открытие и закрытие выезжающей панели. - ANCHOR[TASKS_FILTER]: Фильтрация списка (ALL, IN_PROGRESS, BACKLOG, COMPLETED). - ANCHOR[TASKS_LOAD_FETCH]: Асинхронная загрузка задач через /api/v1/tasks. - ANCHOR[TASKS_RENDER_DOM]: Генерация HTML-карточек в боковом меню. =============================================================================== */ let currentFilter = 'ALL'; let allTasks = []; // --- [SECTION 1: DRAWER TOGGLE] --- # ANCHOR[DRAWER_TOGGLE] function toggleDrawer() { if (typeof IS_GUEST !== 'undefined' && IS_GUEST) return; const drawer = document.getElementById("task-drawer"); const backdrop = document.getElementById("drawer-backdrop"); if (!drawer) return; const isHidden = drawer.classList.contains("translate-x-full"); if (isHidden) { drawer.classList.remove("translate-x-full"); if (backdrop) backdrop.classList.remove("hidden"); loadTasks(); } else { drawer.classList.add("translate-x-full"); if (backdrop) backdrop.classList.add("hidden"); } } // --- [SECTION 2: FILTER CONTROL] --- # ANCHOR[TASKS_FILTER] function setFilter(status) { currentFilter = status; ["ALL", "IN_PROGRESS", "BACKLOG", "COMPLETED"].forEach(f => { const btn = document.getElementById(`filter-${f}`); if (btn) { btn.className = (f === status) ? "px-3 py-1.5 rounded-t-lg border-b-2 border-indigo-600 text-indigo-600 font-bold whitespace-nowrap" : "px-3 py-1.5 rounded-t-lg border-b-2 border-transparent whitespace-nowrap"; } }); renderTasks(); } // --- [SECTION 3: ASYNC DATA FETCH] --- # ANCHOR[TASKS_LOAD_FETCH] async function loadTasks() { const badge = document.getElementById("task-count-badge"); const container = document.getElementById("tasks-container"); const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); if (isGuest || !token) { if (badge) badge.innerText = "0"; return; } try { const res = await fetch("/api/v1/tasks", { headers: { "Authorization": "Bearer " + token, "Content-Type": "application/json" } }); if (res.status === 401) { if (typeof logout === 'function') logout(); return; } if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); } const data = await res.json(); if (Array.isArray(data)) { allTasks = data; } else if (data && Array.isArray(data.tasks)) { allTasks = data.tasks; } else if (data && typeof data === 'object') { allTasks = Object.values(data).find(val => Array.isArray(val)) || []; } else { allTasks = []; } if (badge) { badge.innerText = allTasks.length.toString(); } renderTasks(); } catch (err) { console.error("[Tasks Error]", err); if (badge) badge.innerText = "0"; if (container) { container.innerHTML = `
Ошибка обработки списка задач
`; } } } // --- [SECTION 4: DOM RENDERING] --- # ANCHOR[TASKS_RENDER_DOM] function renderTasks() { const container = document.getElementById("tasks-container"); if (!container) return; if (!Array.isArray(allTasks)) { allTasks = []; } const filtered = allTasks.filter(t => currentFilter === "ALL" || t.status === currentFilter); if (filtered.length === 0) { container.innerHTML = `
Нет задач с выбранным фильтром
`; return; } container.innerHTML = filtered.map(t => { let statusBadge = "bg-slate-100 text-slate-600 border-slate-200"; let statusLabel = "В планах"; let cardBg = "bg-white"; if (t.status === "COMPLETED") { statusBadge = "bg-emerald-50 text-emerald-700 border-emerald-300 font-semibold"; statusLabel = "Завершено"; cardBg = "bg-emerald-50/20"; } else if (t.status === "IN_PROGRESS") { statusBadge = "bg-amber-50 text-amber-700 border-amber-300 font-bold"; statusLabel = "В работе"; cardBg = "bg-amber-50/20 border-amber-200"; } let priorityBadge = "text-slate-500 bg-slate-100 border-slate-200"; if (t.priority === "HIGH") priorityBadge = "text-red-700 bg-red-50 border-red-200 font-bold"; let dueDateHtml = t.due_date ? `
Срок: ${t.due_date}
` : ""; return `
${t.task_id || t.id || 'TASK'} ${t.priority || 'MEDIUM'}
${statusLabel}

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

${t.module || 'General'}
${dueDateHtml}
`; }).join(""); } ``` ## File: `./modules/web_api/static/js/chat/core.js` ```js /** =============================================================================== FILE: modules/web_api/static/js/chat/core.js PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / static / js / chat ROLE: Клиентский интерфейс диалога, Drag-and-Drop вложений, блокировка ввода при активных кнопках, вызов инлайн-редактора, Diff и карточки скачивания файлов. =============================================================================== */ function updateInputHeight(el) { if (!el) return; el.style.height = "24px"; const newHeight = Math.min(el.scrollHeight, 120); el.style.height = newHeight + "px"; } let selectedFile = null; function handleFileSelect(e) { const file = e.target.files[0]; if (!file) return; if (file.size > 15 * 1024 * 1024) { alert("Файл слишком большой. Максимальный размер: 15 МБ"); e.target.value = ""; return; } selectedFile = file; const fileNameEl = document.getElementById("file-name-display"); const fileSizeEl = document.getElementById("file-size-display"); const previewContainer = document.getElementById("file-preview-container"); if (fileNameEl) fileNameEl.innerText = file.name; if (fileSizeEl) fileSizeEl.innerText = `(${(file.size / 1024).toFixed(1)} KB)`; if (previewContainer) previewContainer.classList.remove("hidden"); } function clearAttachedFile() { selectedFile = null; const fileInput = document.getElementById("file-input"); const previewContainer = document.getElementById("file-preview-container"); if (fileInput) fileInput.value = ""; if (previewContainer) previewContainer.classList.add("hidden"); } function setInputLocked(isLocked) { const input = document.getElementById("user-input"); const sendBtn = document.getElementById("send-btn"); if (input) { input.disabled = isLocked; if (isLocked) { input.placeholder = "Выберите действие с помощью кнопок выше..."; input.classList.add("cursor-not-allowed", "opacity-60", "bg-slate-200/50"); } else { input.placeholder = "Команда, вопрос или перетащите файл сюда..."; input.classList.remove("cursor-not-allowed", "opacity-60", "bg-slate-200/50"); } } if (sendBtn) { sendBtn.disabled = isLocked; if (isLocked) { sendBtn.classList.add("opacity-40", "cursor-not-allowed"); } else { sendBtn.classList.remove("opacity-40", "cursor-not-allowed"); } } } function disableAllActionButtons() { const allBtnContainers = document.querySelectorAll(".action-buttons-container"); allBtnContainers.forEach(container => { container.querySelectorAll("button").forEach(btn => { btn.disabled = true; btn.classList.add("opacity-40", "cursor-not-allowed"); }); }); } function handleActionButtonClick(text) { disableAllActionButtons(); setInputLocked(false); const input = document.getElementById("user-input"); if (input) { input.value = text; sendMessage(); } } function escapeHtml(text) { if (text === null || text === undefined) return ""; return String(text) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // --- [INLINE PROMPT EDITOR & FULL DIFF BUILDER] --- function openInlinePromptEditor(btnEl) { const cardContainer = btnEl.closest(".chat-message-card"); if (!cardContainer) return; const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); const diffView = cardContainer.querySelector(".prompt-preview-diff-view"); const textarea = cardContainer.querySelector(".inline-prompt-textarea"); if (editorContainer && textarea) { editorContainer.classList.remove("hidden"); if (diffView) { const targetHeight = Math.max(diffView.offsetHeight, 320); textarea.style.height = `${targetHeight}px`; } textarea.focus(); } } function closeInlinePromptEditor(btnEl) { const cardContainer = btnEl.closest(".chat-message-card"); if (!cardContainer) return; const editorContainer = cardContainer.querySelector(".inline-prompt-editor-container"); if (editorContainer) editorContainer.classList.add("hidden"); } function parsePromptIntoMap(text) { const map = new Map(); if (!text) return map; const lines = text.split("\n"); for (let line of lines) { let trimmed = line.trim(); if (!trimmed) continue; const match = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); if (match) { const key = `${match[1]}.${match[2]}`; map.set(key, { sec: parseInt(match[1]), itm: parseInt(match[2]), content: match[3].trim(), full: trimmed }); } } return map; } function buildHighlightedPromptHtml(newRawText, originalBaselineText) { const origMap = parsePromptIntoMap(originalBaselineText); const newMap = parsePromptIntoMap(newRawText); const formattedLines = []; const rawLines = newRawText.split("\n"); const handledNewKeys = new Set(); let currentSection = null; for (let line of rawLines) { let trimmed = line.trim(); if (!trimmed) { formattedLines.push(""); continue; } const secMatch = trimmed.match(/^(\d+)[\.\s\:\-]+([^\d].*)$/); const subMatch = trimmed.match(/^(\d+)[\.\s]+(\d+)[\.\s\:\-]*(.*)$/); if (subMatch) { const key = `${subMatch[1]}.${subMatch[2]}`; handledNewKeys.add(key); currentSection = parseInt(subMatch[1]); const cleanContent = subMatch[3].trim(); const fullItemStr = `${subMatch[1]}.${subMatch[2]}. ${cleanContent}`; if (!origMap.has(key) || origMap.get(key).content !== cleanContent) { formattedLines.push(` ${escapeHtml(fullItemStr)}`); } else { formattedLines.push(` ${escapeHtml(fullItemStr)}`); } } else if (secMatch && !anyLower(secMatch[2].slice(0, 15))) { currentSection = parseInt(secMatch[1]); formattedLines.push(escapeHtml(trimmed)); } else { formattedLines.push(escapeHtml(trimmed)); } } for (let [origKey, origObj] of origMap.entries()) { if (!handledNewKeys.has(origKey)) { const strikeMarkup = ` ${origKey}. ${escapeHtml(origObj.content)} [УДАЛЕНИЕ]`; formattedLines.push(strikeMarkup); } } return formattedLines.join("\n"); } function anyLower(str) { return /[а-яa-z]/.test(str); } async function saveManualPromptDraft(btnEl) { const cardContainer = btnEl.closest(".chat-message-card"); if (!cardContainer) return; const textarea = cardContainer.querySelector(".inline-prompt-textarea"); const previewTextEl = cardContainer.querySelector(".prompt-preview-diff-view"); if (!textarea) return; const newText = textarea.value.trim(); if (!newText) return; const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); try { const res = await fetch("/api/v1/chat/draft", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer " + token }, body: JSON.stringify({ session_id: "web_session_main", draft_text: newText }) }); if (res.ok) { closeInlinePromptEditor(btnEl); if (previewTextEl) { const baseline = cardContainer.dataset.originalBaseline || ""; const highlightedHtml = buildHighlightedPromptHtml(newText, baseline); previewTextEl.innerHTML = highlightedHtml; } } } catch (err) { alert("Ошибка сохранения черновика: " + err); } } // --- [CHAT SEND PIPELINE] --- async function sendMessage(e) { if (e && e.preventDefault) e.preventDefault(); const input = document.getElementById("user-input"); const chatWindow = document.getElementById("chat-window"); const sendBtn = document.getElementById("send-btn"); if (!input || !chatWindow) return; const text = input.value.trim(); if (!text && !selectedFile) return; disableAllActionButtons(); let userDisplayHtml = escapeHtml(text); if (selectedFile) { userDisplayHtml = `
${escapeHtml(selectedFile.name)}
` + userDisplayHtml; } const userMsgHtml = `
${userDisplayHtml}
`; chatWindow.insertAdjacentHTML("beforeend", userMsgHtml); input.value = ""; updateInputHeight(input); chatWindow.scrollTop = chatWindow.scrollHeight; if (sendBtn) { sendBtn.disabled = true; sendBtn.classList.add("opacity-50"); } const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); const isGuest = typeof IS_GUEST !== 'undefined' ? IS_GUEST : (localStorage.getItem("scud_is_guest") === "true"); const endpoint = isGuest ? "/api/v1/chat/guest" : "/api/v1/chat"; const formData = new FormData(); formData.append("session_id", "web_session_main"); formData.append("message", text || "Проанализируй прикрепленный файл"); if (selectedFile instanceof File) { formData.append("file", selectedFile, selectedFile.name); } const headers = {}; if (!isGuest && token) { headers["Authorization"] = "Bearer " + token; } try { const res = await fetch(endpoint, { method: "POST", headers: headers, body: formData }); if (res.status === 401 && !isGuest) { if (typeof logout === 'function') logout(); return; } const data = await res.json(); const assistantTitle = isGuest ? "Локальная нейросеть (Гость)" : "ИИ-Ассистент SCUD Orion AI"; const replyText = data.reply || "Пустой ответ от нейросети"; let actionButtonsHtml = ""; const actionData = data.action_type || data.action; if (actionData && actionData.buttons && actionData.buttons.length > 0) { const buttonsMarkup = actionData.buttons.map(btn => { let btnClasses = "bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-slate-700 border border-slate-300"; let iconMarkup = ''; const labelLower = (btn.label || "").toLowerCase(); const isPrimary = btn.style === "primary" || labelLower.includes("подтверд") || labelLower.startsWith("да"); const isDanger = btn.style === "danger" || labelLower.includes("отмен") || labelLower.startsWith("нет") || labelLower.includes("законч") || labelLower.includes("заверш"); const isEdit = labelLower.includes("редактир"); if (isPrimary) { btnClasses = "bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white shadow-sm"; iconMarkup = ''; } else if (isDanger) { btnClasses = "bg-rose-50 hover:bg-rose-100 active:bg-rose-200 text-rose-700 border border-rose-300"; iconMarkup = ''; } else if (isEdit) { btnClasses = "bg-indigo-50 hover:bg-indigo-100 active:bg-indigo-200 text-indigo-700 border border-indigo-300"; iconMarkup = ''; } const clickHandler = isEdit ? "openInlinePromptEditor(this)" : `handleActionButtonClick('${escapeHtml(btn.value)}')`; return ` `; }).join(""); actionButtonsHtml = `
${buttonsMarkup}
`; setInputLocked(true); } else { setInputLocked(false); } let previewEditorHtml = ""; let rawDraftText = ""; let baselineText = ""; if (actionData && actionData.type === "PROMPT_PREVIEW") { rawDraftText = actionData.raw_draft || ""; baselineText = actionData.baseline_prompt || rawDraftText; previewEditorHtml = ` `; } let interactiveWidgetHtml = ""; if (actionData && actionData.type === "TASK_INTERACTIVE_CARD" && typeof renderInteractiveTaskCard === "function") { interactiveWidgetHtml = renderInteractiveTaskCard(actionData.tasks); } let snapshotsWidgetHtml = ""; if (actionData && actionData.type === "SNAPSHOTS_CARD" && typeof renderSnapshotsCard === "function") { snapshotsWidgetHtml = renderSnapshotsCard(actionData.data); } // ⭐️ БЛОК КАРТОЧКИ СКАЧИВАНИЯ ФАЙЛА let fileDownloadHtml = ""; if (actionData && actionData.type === "FILE_DOWNLOAD_CARD") { fileDownloadHtml = `
${escapeHtml(actionData.filename)}
Готов к скачиванию (задач: ${actionData.tasks_count || '—'})
Скачать
`; } const isWideWidget = actionData && (actionData.type === "TASK_INTERACTIVE_CARD" || actionData.type === "PROMPT_PREVIEW"); const maxWidthClass = isWideWidget ? "max-w-4xl w-full" : "max-w-2xl"; const botMsgHtml = `

${assistantTitle}

${replyText}
${previewEditorHtml} ${interactiveWidgetHtml} ${snapshotsWidgetHtml} ${fileDownloadHtml} ${actionButtonsHtml}
`; chatWindow.insertAdjacentHTML("beforeend", botMsgHtml); chatWindow.scrollTop = chatWindow.scrollHeight; clearAttachedFile(); if (!isGuest && typeof loadTasks === 'function') { loadTasks(); } } catch (err) { console.error("[Chat Error]", err); const errorHtml = `
Ошибка связи с сервером.
`; chatWindow.insertAdjacentHTML("beforeend", errorHtml); chatWindow.scrollTop = chatWindow.scrollHeight; setInputLocked(false); } finally { const inputEl = document.getElementById("user-input"); if (sendBtn && (!inputEl || !inputEl.disabled)) { sendBtn.disabled = false; sendBtn.classList.remove("opacity-50"); } } } // --- [DRAG & DROP AND KEYBOARD LISTENERS] --- document.addEventListener("DOMContentLoaded", () => { const input = document.getElementById("user-input"); const dropZone = document.getElementById("chat-window")?.parentElement; const dropOverlay = document.getElementById("drop-overlay"); if (input) { let historyIndex = -1; let localHistory = JSON.parse(localStorage.getItem("scud_chat_input_history") || "[]"); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (input.disabled) return; const text = input.value.trim(); if (text) { if (localHistory.length === 0 || localHistory[0] !== text) { localHistory.unshift(text); if (localHistory.length > 50) localHistory.pop(); localStorage.setItem("scud_chat_input_history", JSON.stringify(localHistory)); } historyIndex = -1; } sendMessage(e); updateInputHeight(input); return; } if (e.key === "ArrowUp") { const textBeforeCursor = input.value.substring(0, input.selectionStart); const isFirstLine = !textBeforeCursor.includes("\n"); if (isFirstLine && input.selectionStart === 0 && localHistory.length > 0) { if (historyIndex < localHistory.length - 1) { e.preventDefault(); if (historyIndex === -1) { input.dataset.draft = input.value; } historyIndex++; input.value = localHistory[historyIndex]; updateInputHeight(input); input.setSelectionRange(input.value.length, input.value.length); } } } if (e.key === "ArrowDown") { const textAfterCursor = input.value.substring(input.selectionEnd); const isLastLine = !textAfterCursor.includes("\n"); if (isLastLine && input.selectionEnd === input.value.length && historyIndex >= 0) { e.preventDefault(); if (historyIndex > 0) { historyIndex--; input.value = localHistory[historyIndex]; } else { historyIndex = -1; input.value = input.dataset.draft || ""; } updateInputHeight(input); input.setSelectionRange(input.value.length, input.value.length); } } }); } if (dropZone && dropOverlay) { ["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => { dropZone.addEventListener(eventName, (e) => { e.preventDefault(); e.stopPropagation(); }, false); }); ["dragenter", "dragover"].forEach(eventName => { dropZone.addEventListener(eventName, () => { dropOverlay.classList.remove("hidden"); dropOverlay.classList.add("flex"); }, false); }); ["dragleave", "drop"].forEach(eventName => { dropZone.addEventListener(eventName, (e) => { if (eventName === "drop" || e.target === dropZone || !dropZone.contains(e.relatedTarget)) { dropOverlay.classList.add("hidden"); dropOverlay.classList.remove("flex"); } }, false); }); dropZone.addEventListener("drop", (e) => { const dt = e.dataTransfer; const files = dt.files; if (files && files.length > 0) { const file = files[0]; handleFileSelect({ target: { files: [file] } }); const fileInput = document.getElementById("file-input"); if (fileInput) { const dataTransfer = new DataTransfer(); dataTransfer.items.add(file); fileInput.files = dataTransfer.files; } } }, false); } }); ``` ## File: `./modules/web_api/static/js/chat/task_widget.js` ```js /* =============================================================================== FILE: modules/web_api/static/js/chat/task_widget.js ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами. =============================================================================== */ window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS'; window.currentTasksCache = window.currentTasksCache || []; function getAuthHeaders() { const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token"); const headers = { 'Content-Type': 'application/json' }; if (token) { headers['Authorization'] = 'Bearer ' + token; } return headers; } window.sendChatAction = function(actionText) { const input = document.getElementById("user-input"); if (input && typeof sendMessage === "function") { input.value = actionText; if (typeof setInputLocked === "function") setInputLocked(false); sendMessage(); } }; // ============================================================================ // РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ // ============================================================================ function renderSnapshotsCard(data) { if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return ''; const queryDate = data.query_date || 'выбранную дату'; const snapshots = data.snapshots; if (snapshots.length === 0) { return `
📸 За дату ${queryDate} сохраненных снапшотов не найдено.
`; } const rowsHtml = snapshots.map((s, idx) => { const snapId = s.snapshot_id || `ID-${idx}`; const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—'; const count = s.record_count || 0; const isFinal = snapId.startsWith('Y'); if (isFinal) { return `
#${snapId}
${snapTime} ${count} записей
Итоговый Y-срез
`; } return `
#${snapId}
${snapTime} ${count} записей
`; }).join(''); return `
Реестр срезов СКУД за ${queryDate}
Срезов: ${snapshots.length}
${rowsHtml}
`; } // ⭐️ Обработчики чекбоксов window.toggleSelectAllSnapshots = function(masterCb) { const root = masterCb.closest('.snapshots-widget-root'); if (!root) return; const checkboxes = root.querySelectorAll('.snapshot-item-checkbox'); checkboxes.forEach(cb => cb.checked = masterCb.checked); window.syncBulkDeleteFooter(root); }; window.updateSelectedSnapshots = function(itemCb) { const root = itemCb.closest('.snapshots-widget-root'); if (!root) return; window.syncBulkDeleteFooter(root); }; window.syncBulkDeleteFooter = function(root) { const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked'); const footer = root.querySelector('.snapshot-bulk-actions-footer'); const counter = root.querySelector('.bulk-selected-counter'); const masterCb = root.querySelector('.select-all-snapshots-cb'); const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox'); if (masterCb) { masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length; } if (checkboxes.length > 0) { if (footer) footer.classList.remove('hidden'); if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`; } else { if (footer) footer.classList.add('hidden'); } }; // Одиночная корзина в строке снапшота: // onclick="window.sendChatAction('удали снапшот ${snapId}')" // Кнопка пакетного удаления в подвале карточки: window.submitBulkDeleteSnapshots = function(btnEl) { const root = btnEl.closest('.snapshots-widget-root'); if (!root) return; const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value); if (selected.length === 0) return; window.sendChatAction(`удали снапшоты ${selected.join(', ')}`); }; // ============================================================================ // РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD) // ============================================================================ function renderInteractiveTaskCard(tasks) { if (!tasks || !Array.isArray(tasks)) return ''; window.currentTasksCache = tasks; const counts = { ALL: tasks.length, IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length, PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length, COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length }; const currentFilter = window.activeTaskFilter || 'IN_PROGRESS'; const filteredTasks = tasks.filter(t => { const s = (t.status || 'BACKLOG').toUpperCase(); if (currentFilter === 'ALL') return true; if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS'; if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED'; if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE'; return true; }); const getPrioBadge = (prio) => { const p = (prio || 'MEDIUM').toUpperCase(); if (p === 'HIGH' || p === 'CRITICAL') return '🔥 HIGH'; if (p === 'LOW') return '☕ LOW'; return '⚡ MEDIUM'; }; const getStatusBadge = (status) => { const s = (status || 'BACKLOG').toUpperCase(); if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '⚙️ В работе'; if (s === 'COMPLETED' || s === 'DONE') return '✓ Готово'; return '📋 В планах'; }; const taskRows = filteredTasks.map(t => { const id = t.id; const title = t.title || 'Без названия'; const isDone = t.status === 'COMPLETED' || t.status === 'DONE'; const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS'; const actionBtn = isInProgress ? `` : (!isDone ? `` : ''); return `
#${id} ${title}
${actionBtn}
${getPrioBadge(t.priority)} ${getStatusBadge(t.status)} ${t.module || 'general'} ${t.due_date ? `📅 срок: ${t.due_date}` : ''} ${t.created_at ? `создана: ${t.created_at.split(' ')[0]}` : ''}
`; }).join(''); const emptyState = `
📭
Нет задач в категории «${currentFilter}»
`; return `
${filteredTasks.length > 0 ? taskRows : emptyState}
`; } window.switchTaskFilter = function(filterName, btnEl) { window.activeTaskFilter = filterName; const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); if (root && window.currentTasksCache && window.currentTasksCache.length > 0) { root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache); } }; window.toggleCreateTaskForm = function(btnEl) { const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); if (!root) return; const form = root.querySelector('.new-task-creation-form'); if (form) { form.classList.toggle('hidden'); if (!form.classList.contains('hidden')) { const input = form.querySelector('.new-task-title'); if (input) input.focus(); } } }; window.submitCreateTask = async function(btnEl) { const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root'); if (!root) return; const titleInput = root.querySelector('.new-task-title'); const dateInput = root.querySelector('.new-task-date'); const prioInput = root.querySelector('.new-task-prio'); const statusInput = root.querySelector('.new-task-status'); if (!titleInput || !titleInput.value.trim()) { alert('Введите описание задачи'); return; } try { const res = await fetch('/api/v1/tasks', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ title: titleInput.value.trim(), due_date: dateInput ? (dateInput.value || null) : null, priority: prioInput ? prioInput.value : 'MEDIUM', status: statusInput ? statusInput.value : 'BACKLOG' }) }); if (res.ok) { window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED'; window.sendChatAction('покажи задачи'); } else { const err = await res.json(); alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка')); } } catch (e) { console.error('Ошибка создания задачи:', e); alert('Сетевая ошибка при создании задачи'); } }; window.openTaskInlineEditor = function(id) { const card = document.getElementById(`task-card-${id}`); if (!card) return; card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden')); const editor = document.getElementById(`task-editor-${id}`); if (editor) editor.classList.remove('hidden'); }; window.closeTaskInlineEditor = function(id) { const card = document.getElementById(`task-card-${id}`); if (!card) return; card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden')); const editor = document.getElementById(`task-editor-${id}`); if (editor) editor.classList.add('hidden'); }; window.saveTaskInlineEdit = async function(id) { const titleInput = document.getElementById(`task-edit-title-${id}`); const dateInput = document.getElementById(`task-edit-date-${id}`); const prioInput = document.getElementById(`task-edit-prio-${id}`); if (!titleInput || !titleInput.value.trim()) { alert('Описание задачи не может быть пустым'); return; } try { const res = await fetch(`/api/v1/tasks/${id}`, { method: 'PATCH', headers: getAuthHeaders(), body: JSON.stringify({ title: titleInput.value.trim(), due_date: dateInput ? (dateInput.value || null) : null, priority: prioInput ? prioInput.value : 'MEDIUM' }) }); if (res.ok) { window.sendChatAction('покажи задачи'); } else { const err = await res.json(); alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка')); } } catch (e) { console.error('Ошибка сохранения задачи:', e); alert('Сетевая ошибка при обновлении задачи'); } }; ``` ## File: `./modules/web_api/static/css/styles.css` ```css /* Плавное исчезновение текста сверху при скролле */ .fade-scroll-top { mask-image: linear-gradient(to bottom, transparent 0%, black 14px); -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px); } /* Скрытие стандартного скроллбара */ .no-scrollbar::-webkit-scrollbar { display: none; } .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } /* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */ body { min-height: 100vh; min-height: -webkit-fill-available; } ``` ## File: `./modules/web_api/routers/admin.py` ```py """ =============================================================================== FILE: modules/web_api/routers/admin.py ROLE: Администрирование пользователей и прав доступа. =============================================================================== """ # ANCHOR[ADMIN_ROUTER_IMPORTS] import logging from typing import Dict, Any, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from .auth import get_current_user, get_db, pwd_context router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) # ANCHOR[ADMIN_SCHEMAS] class CreateUserRequest(BaseModel): username: str password: str full_name: Optional[str] = None is_admin: Optional[bool] = False # ANCHOR[ADMIN_ENDPOINTS] @router.get("/users") def list_users(current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC") users = [dict(r) for r in cursor.fetchall()] conn.close() return users @router.post("/users") def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") username = req.username.strip().lower() if not username or not req.password: raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id FROM users WHERE username = ?", (username,)) if cursor.fetchone(): conn.close() raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует") pwd_hash = pwd_context.hash(req.password) full_name = req.full_name.strip() if req.full_name else None is_admin = 1 if req.is_admin else 0 cursor.execute( "INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)", (username, pwd_hash, full_name, is_admin) ) conn.commit() conn.close() logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}") return {"status": "success", "message": f"Пользователь {username} создан"} @router.delete("/users/{user_id}") def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): if not current_user["is_admin"]: raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.") if user_id == current_user["id"]: raise HTTPException(status_code=400, detail="Нельзя удалить самого себя") conn = get_db() cursor = conn.cursor() cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.commit() conn.close() logging.info(f"Удален пользователь ID: {user_id}") return {"status": "success", "message": "Пользователь удален"} ``` ## File: `./modules/web_api/routers/auth.py` ```py """ =============================================================================== FILE: modules/web_api/routers/auth.py ROLE: Аутентификация, валидация JWT-токенов и управление паролями. =============================================================================== """ # ANCHOR[AUTH_ROUTER_IMPORTS] import sqlite3 import logging from datetime import datetime, timedelta from typing import Dict, Any, Optional import jwt from passlib.context import CryptContext from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel from llm.db_tools import DB_PATH JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" ALGORITHM = "HS256" pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 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 def create_access_token(user_id: int, username: str, is_admin: bool) -> str: payload = { "sub": str(user_id), "username": username, "is_admin": is_admin, "exp": datetime.utcnow() + timedelta(days=30) } return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: try: token = credentials.credentials payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) user_id = int(payload.get("sub")) username = payload.get("username") is_admin = bool(payload.get("is_admin", False)) return {"id": user_id, "username": username, "is_admin": is_admin} except Exception as e: logging.warning(f"Auth error: {e}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Недействительный или просроченный токен авторизации", headers={"WWW-Authenticate": "Bearer"}, ) # 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,)) user = cursor.fetchone() conn.close() if not user or not pwd_context.verify(req.password, user["password_hash"]): logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") is_admin = bool(user["is_admin"]) or (user["username"] == "puh") token = create_access_token(user["id"], user["username"], is_admin) logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} @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: raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа") conn = get_db() cursor = conn.cursor() cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],)) user = cursor.fetchone() if not user or not pwd_context.verify(req.old_password, user["password_hash"]): conn.close() raise HTTPException(status_code=400, detail="Неверный старый пароль") new_hash = pwd_context.hash(req.new_password) cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"])) conn.commit() conn.close() logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") return {"status": "success", "message": "Пароль успешно изменен"} ``` ## File: `./modules/web_api/routers/chat.py` ```py """ =============================================================================== FILE: modules/web_api/routers/chat.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / routers ROLE: Маршрутизация диалогов с LLM и эндпоинт сохранения онлайн-черновиков. =============================================================================== """ # ANCHOR[CHAT_ROUTER_IMPORTS] from typing import Optional, Dict, Any from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from pydantic import BaseModel from .auth import get_current_user from modules.ai_engine.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 router = APIRouter(prefix="/api/v1/chat", tags=["chat"]) # ANCHOR[DRAFT_SCHEMA] class UpdateDraftRequest(BaseModel): session_id: str draft_text: str # 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) reply, history, action_type = process_chat_message( user_id=current_user["id"], user_message=message, file_context=parsed_file["text"], image_b64=parsed_file["image_b64"], session_id=session_id ) return {"reply": reply, "history": history, "action_type": action_type} @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": "Черновик успешно обновлен в сессии"} ``` ## File: `./modules/web_api/routers/files.py` ```py """ =============================================================================== 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}" } ) ``` ## File: `./modules/web_api/routers/tasks.py` ```py """ =============================================================================== FILE: modules/web_api/routers/tasks.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / routers ROLE: REST API эндпоинты реестра задач (получение, создание и обновление). =============================================================================== """ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from typing import Optional, Dict, Any from routers.auth import get_current_user from llm.db_tools import db_get_tasks, db_add_task, db_update_task_details router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"]) class TaskCreateRequest(BaseModel): title: str priority: Optional[str] = "MEDIUM" module: Optional[str] = "general" due_date: Optional[str] = None status: Optional[str] = "BACKLOG" 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: """Извлекает корректный ID пользователя из JWT payload или ставит дефолтный 1.""" if not current_user: return 1 return current_user.get("id") or current_user.get("user_id") or 1 @router.get("") async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)): user_id = resolve_user_id(current_user) return {"tasks": db_get_tasks(user_id=user_id, status=status)} @router.post("") async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)): user_id = resolve_user_id(current_user) res = db_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=400, detail=res["error"]) return res @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 = db_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 ``` ## File: `./modules/web_api/llm/__init__.py` ```py ``` ## File: `./modules/web_api/llm/agent.py` ```py """ =============================================================================== FILE: modules/web_api/llm/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm (Core Agent Coordinator) 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]: Защита контекста и обработка свободных тем. =============================================================================== """ # ANCHOR[AGENT_IMPORTS] import sys import json import logging from typing import List, Dict, Any, Tuple, Optional from .db.connection import get_db_connection from .db_tools import ( db_get_active_system_prompt, db_apply_prompt_node_action, db_get_tool_action, db_get_tasks, 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, db_clear_session_state, db_get_snapshots, db_delete_snapshots, db_get_current_server_time, db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages, db_get_stats, db_get_anomalies, db_get_reference ) from .schemas import TOOLS_SCHEMA 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) logger.propagate = False if not logger.handlers: handler = logging.StreamHandler(sys.stdout) formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) # ANCHOR[AGENT_MAIN_PIPELINE] 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]]]: """ Главный конвейер обработки входящего сообщения чата на базе нативного 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 # 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 # 2. Фиксация сообщения пользователя db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) db_history = db_get_chat_history(session_id, limit=20) # ANCHOR[AGENT_SYSTEM_PROMPT] calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" 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 = {} idle_turns = state_data.get("idle_turns", 0) 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"- Если оператор запрашивает ДРУГУЮ дату или день недели, отличную от {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.\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}" ) user_msg_object = {"role": "user", "content": full_user_content} # ANCHOR[AGENT_TOOL_DISPATCHER] try: if image_b64: user_msg_object["images"] = [image_b64] messages = [ {"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."}, user_msg_object ] 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) messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object] msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False) raw_text_reply = msg.get("content", "") tool_calls = msg.get("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)})") 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"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}") # Ротация контекста: закрываем старую сессию инструмента close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}") session_state = None mark_last_user_message_ephemeral(session_id) # 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 = 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 } # 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 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: 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) 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 # Прямое выполнение удаления в 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) 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 } # 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 = "{}" 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)) or "Запрос выполнен." for artifact in ["почемучка,", "почемучка!", "почемучка?", "почемучка", "почемучто", "почему-то"]: if final_content.lower().startswith(artifact): final_content = final_content[len(artifact):].lstrip(",.!?:; -") 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 # 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 if idle_turns > 3: db_clear_session_state(session_id) db_purge_ephemeral_messages(session_id) action_payload = None session_state = None elif idle_turns == 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} db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": idle_turns}) else: 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=0) return final_reply, db_get_chat_history(session_id), action_payload except Exception as ex: logger.exception(f"Непредвиденная ошибка агента: {ex}") error_reply = f"Внутренняя ошибка сервера: {ex}" return error_reply, db_get_chat_history(session_id), None ``` ## File: `./modules/web_api/llm/db_tools.py` ```py """ =============================================================================== 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. =============================================================================== """ # 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_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 ``` ## File: `./modules/web_api/llm/file_parser.py` ```py import base64 import os import subprocess import logging import pandas as pd logger = logging.getLogger("FILE_PARSER") def extract_text_from_file(file_bytes: bytes, filename: str) -> dict: ext = os.path.splitext(filename)[1].lower() temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}" with open(temp_filepath, "wb") as f: f.write(file_bytes) try: # 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']: b64_str = base64.b64encode(file_bytes).decode('utf-8') return { "text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]", "image_b64": b64_str } # 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM) elif ext == '.pdf': cmd = ['pdftotext', temp_filepath, '-'] res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) pdf_text = res.stdout.strip() img_prefix = f"/tmp/pdf_preview_{os.getpid()}" subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True) page_png = f"{img_prefix}-1.png" b64_str = None if os.path.exists(page_png): with open(page_png, "rb") as pf: b64_str = base64.b64encode(pf.read()).decode('utf-8') os.remove(page_png) context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]" if pdf_text: context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}" return { "text": context_text, "image_b64": b64_str } # 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv) elif ext in ['.xlsx', '.xls', '.csv']: if ext == '.csv': df = pd.read_csv(temp_filepath) else: df = pd.read_excel(temp_filepath) total_rows = len(df) df_preview = df.head(100) table_str = df_preview.to_string(index=False) note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else "" return { "text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}", "image_b64": None } # 4. ТЕКСТОВЫЕ ФАЙЛЫ elif ext in ['.txt', '.log', '.json', '.xml', '.md']: with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf: return { "text": tf.read().strip(), "image_b64": None } else: return { "text": f"[ОШИБКА: Формат {ext} не поддерживается]", "image_b64": None } except Exception as e: logger.error(f"Ошибка при анализе файла {filename}: {e}") return { "text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]", "image_b64": None } finally: if os.path.exists(temp_filepath): os.remove(temp_filepath) ``` ## File: `./modules/web_api/llm/schemas.py` ```py """ =============================================================================== 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": ( "Управление элементами системного промпта (добавление, изменение, удаление). " "Если пользователь пишет 'удали 2.9' или 'удали пункт 2.9', вызывай action='DELETE', section_id=2, item_id=9. " "Действие action может быть ADD, EDIT или DELETE." ), "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["ADD", "EDIT", "DELETE"], "description": "Действие: ADD (добавить), EDIT (изменить), DELETE (удалить)" }, "section_id": { "type": "integer", "description": "Номер раздела из точки (например, 2 из 2.9)" }, "item_id": { "type": "integer", "description": "Номер пункта из точки (например, 9 из 2.9)" }, "content": { "type": "string", "description": "Текст пункта (для DELETE пустая строка)" } }, "required": ["action", "section_id", "item_id"] } } }, { "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": "Просмотр реестра задач и бэклога текущего пользователя.", "parameters": { "type": "object", "properties": { "status": { "type": "string", "enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"], "description": "Опциональный фильтр статуса задач" } } } } }, { "type": "function", "function": { "name": "db_tasks_edit", "description": "Единый инструмент управления задачами: создание (ADD), изменение статуса/дедлайна (UPDATE), удаление (DELETE) и экспорт задач в Markdown-файл (EXPORT).", "parameters": { "type": "object", "properties": { "action": { "type": "string", "enum": ["ADD", "UPDATE", "DELETE", "EXPORT"], "description": "Тип действия: ADD, UPDATE, DELETE или EXPORT" }, "task_id": { "type": "string", "description": "Номер задачи (для UPDATE и DELETE, например: '35')" }, "title": { "type": "string", "description": "Описание или текст задачи" }, "status": { "type": "string", "enum": ["IN_PROGRESS", "COMPLETED", "PLANNED"], "description": "Статус задачи" }, "priority": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"], "description": "Приоритет задачи" }, "module": { "type": "string", "description": "Модуль проекта" }, "due_date": { "type": "string", "description": "Срок в формате ГГГГ-ММ-ДД" }, "filename": { "type": "string", "description": "Имя файла для экспорта" } }, "required": ["action"] } } }, # ANCHOR[SCHEMA_SNAPSHOTS] { "type": "function", "function": { "name": "db_get_snapshots", "description": "Получение списка снапшотов и срезов логов СКУД из базы данных за конкретную дату.", "parameters": { "type": "object", "properties": { "date_str": { "type": "string", "description": "Дата в формате ДД.ММ.ГГГГ или относительное слово" } } } } }, { "type": "function", "function": { "name": "db_delete_snapshots", "description": "Удаление снапшотов СКУД по идентификатору или дате.", "parameters": { "type": "object", "properties": { "snapshot_id": { "type": "string", "description": "Идентификатор конкретного снапшота" }, "day_str": { "type": "string", "description": "Дата всех снапшотов за день" } } } } }, { "type": "function", "function": { "name": "db_get_anomalies", "description": "Просмотр истории аномалий и расхождений между СКУД и 1С.", "parameters": { "type": "object", "properties": { "date_str": { "type": "string", "description": "Опциональная дата в формате ДД.ММ.ГГГГ" }, "limit": { "type": "integer", "description": "Лимит записей" } } } } }, { "type": "function", "function": { "name": "db_get_rules", "description": "Просмотр базы знаний и правил кадрового арбитража компании.", "parameters": { "type": "object", "properties": {} } } }, { "type": "function", "function": { "name": "db_get_stats", "description": "Получение статистики количества записей в таблицах базы данных.", "parameters": { "type": "object", "properties": {} } } }, { "type": "function", "function": { "name": "db_get_current_server_time", "description": "Получение текущего точного времени сервера.", "parameters": { "type": "object", "properties": {} } } }, { "type": "function", "function": { "name": "db_get_reference", "description": "Справка о возможностях ассистента и примеры доступных команд.", "parameters": { "type": "object", "properties": { "category": { "type": "string", "description": "Категория справки" } } } } } ] ``` ## File: `./modules/web_api/llm/db/__init__.py` ```py ``` ## File: `./modules/web_api/llm/db/connection.py` ```py """ FILE: modules/web_api/llm/db/connection.py """ import os import sqlite3 # Динамический путь к общей БД в корне проекта BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../")) DB_PATH = os.path.join(BASE_ROOT, "data", "scud_orion_ai.db") def get_db_connection() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH, timeout=30.0) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON;") conn.execute("PRAGMA journal_mode = WAL;") conn.execute("PRAGMA synchronous = NORMAL;") return conn ``` ## File: `./modules/web_api/llm/db/db_chat.py` ```py """ FILE: modules/web_api/llm/db/db_chat.py ROLE: Управление историей сообщений и очисткой эфемерного контекста. """ from typing import List, Dict, Any from .connection import get_db_connection def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None: """Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных).""" conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" INSERT INTO chat_messages (session_id, role, content, is_ephemeral) VALUES (?, ?, ?, ?) """, (session_id, role, content, is_ephemeral)) conn.commit() conn.close() def db_get_chat_history(session_id: str, limit: int = 20) -> List[Dict[str, Any]]: """Получает последние сообщения истории диалога в хронологическом порядке.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" SELECT role, content FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT ? """, (session_id, limit)) rows = cursor.fetchall() conn.close() return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] def db_purge_ephemeral_messages(session_id: str) -> int: """ Физически удаляет все временные служебные сообщения выбранной сессии после завершения сценария работы с инструментом. """ conn = get_db_connection() cursor = conn.cursor() cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,)) deleted = cursor.rowcount conn.commit() conn.close() return deleted def db_clear_chat_history(session_id: str) -> None: """Полная очистка всех сообщений сессии.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,)) conn.commit() conn.close() ``` ## File: `./modules/web_api/llm/db/db_prompts.py` ```py """ =============================================================================== FILE: modules/web_api/llm/db/db_prompts.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / db ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes), базой знаний, реестром действий и сессионными стейтами. =============================================================================== """ import re import json import logging from typing import List, Dict, Any, Optional from .connection import get_db_connection logger = logging.getLogger("DB_PROMPTS") def init_prompt_nodes_table(): """Создает реляционную таблицу узлов промпта и заполняет базовыми данными.""" with get_db_connection() as conn: cursor = conn.cursor() 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 INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);") cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'") if cursor.fetchone()[0] == 0: seed_nodes = [ # Раздел 1 (1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"), (1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."), (1, 2, "Консультация по правилам и арбитражу кадровых данных/СКУД из базы знаний (через db_get_rules)."), (1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."), (1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."), (1, 5, "Поддержка диалога с операторами и администраторами системы."), (1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."), # Раздел 2 (2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"), (2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."), (2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."), (2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."), (2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."), (2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."), (2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."), (2, 7, "Запрещено писать названия функций или код вызова текстом на экран."), (2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."), # Раздел 3 (3, 0, "ПРАВИЛА СТИЛЯ"), (3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."), (3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."), (3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.") ] cursor.executemany(""" INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content) VALUES ('main_agent', ?, ?, ?) """, seed_nodes) conn.commit() def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str: """Собирает структурированный текст промпта из реляционной таблицы узлов.""" init_prompt_nodes_table() with get_db_connection() 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 sec_id, itm_id, content in rows: 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) def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"): """Прямое добавление, изменение или удаление узла в БД.""" init_prompt_nodes_table() with get_db_connection() as conn: cursor = conn.cursor() action_clean = action.upper() if action_clean in ["ADD", "UPDATE"]: 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 db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" SELECT tool_name, category, bypass_llm, success_template, follow_up_question, action_type, buttons_json, is_ephemeral FROM tool_action_registry WHERE tool_name = ? AND is_active = 1 """, (tool_name,)) row = cursor.fetchone() conn.close() if row: res = dict(row) res["buttons"] = json.loads(res["buttons_json"]) if res.get("buttons_json") else [] return res return None def db_get_rules() -> List[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC") rows = cursor.fetchall() conn.close() return [dict(r) for r in rows] def db_set_session_state(session_id: str, state_type: str, data: Any) -> None: """Сохраняет состояние сессии в SQLite.""" conn = get_db_connection() cursor = conn.cursor() payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "") cursor.execute(""" INSERT INTO session_states (session_id, state_type, pending_data, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(session_id) DO UPDATE SET state_type = excluded.state_type, pending_data = excluded.pending_data, updated_at = CURRENT_TIMESTAMP """, (session_id, state_type, payload_str)) conn.commit() conn.close() def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]: conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,)) row = cursor.fetchone() conn.close() if not row: return None res = dict(row) raw_data = res.get("pending_data") or "" try: res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None except Exception: res["data_json"] = None return res def db_clear_session_state(session_id: str) -> None: conn = get_db_connection() cursor = conn.cursor() cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,)) conn.commit() conn.close() def db_get_stats() -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks'] stats = {} for t in tables: try: cursor.execute(f"SELECT COUNT(*) FROM {t}") stats[t] = cursor.fetchone()[0] except Exception: stats[t] = 0 conn.close() return {"status": "success", "tables_stats": stats} def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history" params = [] if date_str: query += " WHERE anomaly_date = ?" params.append(date_str) query += " ORDER BY id DESC LIMIT ?" params.append(limit) cursor.execute(query, params) rows = cursor.fetchall() conn.close() return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]} def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]: conn = get_db_connection() cursor = conn.cursor() query = "SELECT category, title, example_prompt, description FROM system_reference" params = [] if category: query += " WHERE category = ?" params.append(category) query += " ORDER BY id ASC" cursor.execute(query, params) rows = cursor.fetchall() conn.close() return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]} def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]: """Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов.""" try: init_prompt_nodes_table() with get_db_connection() as conn: cursor = conn.cursor() cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (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 # 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст") sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line) # 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ") 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) """, (name, current_sec, current_itm, content)) conn.commit() return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"} except Exception as e: logger.error(f"Ошибка при разборе промпта в узлы: {e}") return {"status": "error", "error": str(e)} ``` ## File: `./modules/web_api/llm/db/db_snapshots.py` ```py """ =============================================================================== FILE: modules/web_api/llm/db/db_snapshots.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / db ROLE: Выборка, фильтрация и пакетное удаление срезов логов СКУД в SQLite. AI-CONTEXT-ANCHORS: - ANCHOR[DB_GET_SNAPSHOTS]: Выборка снапшотов с нормализацией дат. - ANCHOR[DB_DEL_SNAPSHOTS]: Удаление снапшотов по ID, списку ID или за дату. =============================================================================== """ import json import re from typing import Dict, Any, Optional, List from .connection import get_db_connection from .db_prompts import db_set_session_state from ..core.calendar_utils import parse_relative_date_ru # ANCHOR[DB_GET_SNAPSHOTS] 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 = [] clean_date = date_str.strip() if date_str else "" if not clean_date or not re.search(r'\d{2}\.\d{2}\.\d{4}', clean_date): if original_user_message: clean_date = parse_relative_date_ru(original_user_message) if clean_date and re.search(r'\d{2}\.\d{2}\.\d{4}', clean_date): iso_date = clean_date compact_date = clean_date.replace(".", "") if "." in clean_date: parts = clean_date.split(".") if len(parts) == 3: iso_date = f"{parts[2]}-{parts[1]}-{parts[0]}" compact_date = f"{parts[2]}{parts[1]}{parts[0]}" query += """ WHERE log_date = ? OR snapshot_time LIKE ? OR snapshot_id LIKE ? OR snapshot_id LIKE ? """ params.extend([clean_date, f"{iso_date}%", f"{compact_date}-%", f"Y{compact_date}-%"]) query += """ GROUP BY snapshot_id, log_date, snapshot_time ORDER BY snapshot_time DESC, snapshot_id DESC LIMIT 50 """ cursor.execute(query, params) rows = cursor.fetchall() snapshots = [dict(r) for r in rows] result_data = { "query_date": clean_date or "все", "snapshots_count": len(snapshots), "snapshots": snapshots } db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=result_data) conn.close() return result_data # ANCHOR[DB_DEL_SNAPSHOTS] def db_delete_snapshots( snapshot_id: Optional[str] = None, snapshot_ids: Optional[List[str]] = None, day_str: Optional[str] = None ) -> Dict[str, Any]: """Удаляет один или группу дневных снапшотов с защитой итоговых Y-снапшотов.""" conn = get_db_connection() cursor = conn.cursor() deleted = 0 if snapshot_ids and isinstance(snapshot_ids, list): # Исключаем любые итоговые снапшоты, начинающиеся с Y safe_ids = [s.strip() for s in snapshot_ids if s and not str(s).strip().startswith("Y")] if safe_ids: placeholders = ",".join(["?"] * len(safe_ids)) cursor.execute(f"DELETE FROM scud_logs WHERE snapshot_id IN ({placeholders})", safe_ids) deleted = cursor.rowcount elif snapshot_id: clean_id = str(snapshot_id).strip() if clean_id.startswith("Y"): conn.close() return {"status": "error", "message": f"Итоговый срез [{clean_id}] защищен от удаления."} cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (clean_id,)) deleted = cursor.rowcount elif day_str: cursor.execute("DELETE FROM scud_logs WHERE (log_date = ? OR snapshot_id LIKE ?) AND snapshot_id NOT LIKE 'Y%'", (day_str, f"%{day_str.replace('.', '')}%")) deleted = cursor.rowcount else: conn.close() return {"status": "error", "message": "Не указаны идентификаторы для удаления."} conn.commit() conn.close() return {"status": "success", "deleted_records": deleted, "message": f"Успешно удалено записей: {deleted}"} ``` ## File: `./modules/web_api/llm/db/db_tasks.py` ```py """ =============================================================================== FILE: modules/web_api/llm/db/db_tasks.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / db ROLE: Комплексное управление задачами, единый диспетчер db_tasks_edit и генерация структурированных отчетов в Markdown. =============================================================================== """ import uuid import logging import os import re from datetime import datetime from typing import List, Dict, Any, Optional from .connection import get_db_connection logger = logging.getLogger("DB_TASKS") 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}" def db_get_tasks(user_id: int, status: Optional[str] = None) -> List[Dict[str, Any]]: """Получает список всех задач пользователя.""" conn = get_db_connection() 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] def db_add_task( user_id: int, module: str, title: str, priority: str = "MEDIUM", due_date: Optional[str] = None, status: str = "BACKLOG" ) -> Dict[str, Any]: """Добавление новой задачи со статусом по умолчанию BACKLOG (В планах).""" 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}" 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, "message": f"Задача #{max_id + 1} создана и добавлена в планы"} def db_update_task_status(user_id: int, task_id: str, status: str = "COMPLETED", due_date: Optional[str] = None) -> Dict[str, Any]: """Быстрое обновление статуса задачи.""" return db_update_task_details(user_id=user_id, task_id=task_id, status=status, due_date=due_date) def db_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]: """Комплексное обновление любых параметров задачи.""" conn = get_db_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) if cursor.rowcount == 0: conn.close() return {"error": f"Задача {task_id} не найдена или принадлежит другому пользователю"} conn.commit() conn.close() return {"status": "success", "message": f"Задача #{task_id} успешно обновлена"} def db_delete_task(user_id: int, task_id: str) -> Dict[str, Any]: """Удаление задачи.""" conn = get_db_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)) if cursor.rowcount == 0: conn.close() return {"error": f"Задача {task_id} не найдена"} conn.commit() conn.close() return {"status": "success", "message": f"Задача #{task_id} удалена"} def db_export_tasks_markdown(user_id: int, filename: Optional[str] = None, status_filter: Optional[str] = None) -> Dict[str, Any]: """Генерирует Markdown-отчет задач с сохранением в output/web/tasks_export/{uuid}/.""" tasks = db_get_tasks(user_id) if not tasks: return {"status": "error", "message": "Список задач пуст, экспорт отменен"} # Фильтрация по статусу 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_dt = datetime.now() now_str = now_dt.strftime("%Y-%m-%d %H:%M") 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) # Точный путь к корню scud_ai/output/web/tasks_export/ current_file_dir = os.path.dirname(os.path.abspath(__file__)) root_dir = os.path.abspath(os.path.join(current_file_dir, "../../../../")) tool_dir = os.path.join(root_dir, "output", "web", "tasks_export") os.makedirs(tool_dir, exist_ok=True) try: from routers.files import purge_old_tool_sessions purge_old_tool_sessions(tool_dir) except Exception: pass 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) logger.info(f"Файл успешно создан: {filepath}") 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)})." } def db_tasks_edit( 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.strip().upper() if act == "ADD": if not title: return {"status": "error", "message": "Для создания задачи требуется указать title"} return db_add_task( user_id=user_id, module=module or "general", title=title, priority=priority or "MEDIUM", due_date=due_date, status=status or "BACKLOG" ) elif act == "UPDATE": if not task_id: return {"status": "error", "message": "Для обновления требуется указать task_id"} return db_update_task_details(user_id=user_id, task_id=str(task_id), title=title, priority=priority, status=status, due_date=due_date) elif act == "DELETE": if not task_id: return {"status": "error", "message": "Для удаления требуется указать task_id"} return db_delete_task(user_id=user_id, task_id=str(task_id)) elif act == "EXPORT": return db_export_tasks_markdown(user_id=user_id, filename=filename or "ROADMAP.md") return {"status": "error", "message": f"Неизвестное действие action='{action}'"} ``` ## File: `./modules/web_api/llm/core/__init__.py` ```py ``` ## File: `./modules/web_api/llm/core/calendar_utils.py` ```py import re from datetime import datetime, timedelta DAYS_RU = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"] def parse_relative_date_ru(text: str) -> str: now = datetime.now() text_lower = text.lower() if text else "" match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text) if match: return match.group(1) if "вчера" in text_lower: return (now - timedelta(days=1)).strftime("%d.%m.%Y") elif "позавчера" in text_lower: return (now - timedelta(days=2)).strftime("%d.%m.%Y") elif "сегодня" in text_lower: return now.strftime("%d.%m.%Y") return (now - timedelta(days=1)).strftime("%d.%m.%Y") def get_dynamic_calendar_context() -> str: now = datetime.now() current_wd = now.weekday() lines = [ f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).", "\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):", f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})", f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})", f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})", "\nПрошедшие дни недели:" ] for days_back in range(1, 8): dt = now - timedelta(days=days_back) day_name = DAYS_RU[dt.weekday()] label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}" lines.append(f"• {label}: {dt.strftime('%d.%m.%Y')}") return "\n".join(lines) ``` ## File: `./modules/web_api/llm/core/context_manager.py` ```py """ =============================================================================== 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 ``` ## File: `./modules/web_api/llm/core/fast_path.py` ```py """ FILE: modules/web_api/llm/core/fast_path.py ROLE: Детерминированный технический конвейер (НЕ для ИИ-размышлений). Выполняет только кнопки подтверждения для удаления задач, снапшотов и промптов. """ from typing import Dict, Any, Tuple, Optional from llm.db_tools import ( db_delete_task, db_get_tasks, db_delete_snapshots, db_get_snapshots, db_add_system_prompt, db_apply_prompt_node_action ) from .context_manager import close_tool_session_and_cleanup, save_tool_interaction, save_dialog_interaction def handle_fast_path_intercept( session_id: str, user_message: str, full_user_content: str, session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: if not session_state: return None state_type = session_state.get("state_type") msg = user_message.strip().lower() # ⭐️ Управление системным промптом (Превью) if state_type == "PROMPT_PREVIEW": state_data = session_state.get("data_json") or {} 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 msg in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: if action == "MANUAL_EDIT" or not action or sec_id is None: if draft_text: db_add_system_prompt("main_agent", draft_text) else: db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) close_tool_session_and_cleanup(session_id, "PROMPT_APPLIED_SUCCESSFULLY") reply_text = "✅ Изменения системного промпта успешно применены в базе данных." save_tool_interaction(session_id, full_user_content, reply_text) return reply_text, [], None elif msg in ["отмена", "отменить", "отклонить"]: close_tool_session_and_cleanup(session_id, "PROMPT_PREVIEW_CANCELLED") reply_text = "❌ Изменения системного промпта отменены." save_dialog_interaction(session_id, full_user_content, reply_text) return reply_text, [], None # Удаление снапшотов elif state_type == "SNAPSHOT_DELETE_CONFIRM": state_data = session_state.get("data_json", {}) ids = state_data.get("snapshot_ids", []) query_date = state_data.get("query_date", "") if msg.startswith("подтверждаю удаление снапшотов"): db_delete_snapshots(snapshot_ids=ids) close_tool_session_and_cleanup(session_id, "SNAPSHOTS_DELETED") updated_data = db_get_snapshots(session_id=session_id, date_str=query_date) return f"✅ Удалено снапшотов: {len(ids)}", [], {"type": "SNAPSHOTS_CARD", "data": updated_data} elif msg == "отмена": close_tool_session_and_cleanup(session_id, "SNAPSHOT_CANCELLED") updated_data = db_get_snapshots(session_id=session_id, date_str=query_date) return "Удаление отменено.", [], {"type": "SNAPSHOTS_CARD", "data": updated_data} # Удаление задач elif state_type == "TASK_DELETE_CONFIRM": task_id = session_state.get("data_json", {}).get("task_id") if msg.startswith("подтверждаю удаление задачи"): db_delete_task(1, str(task_id)) close_tool_session_and_cleanup(session_id, "TASK_DELETED") return f"Задача #{task_id} удалена.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)} elif msg == "отмена": close_tool_session_and_cleanup(session_id, "TASK_CANCELLED") return "Удаление отменено.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)} return None ``` ## File: `./modules/web_api/llm/core/ollama_client.py` ```py """ =============================================================================== FILE: modules/web_api/llm/core/ollama_client.py ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama. =============================================================================== """ # ANCHOR[OLLAMA_CLIENT_IMPORTS] import json import urllib.request import urllib.error import logging 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" VISION_MODEL = "qwen2.5vl:7b-q8_0" LLM_OPTIONS = { "num_predict": 8192, "num_ctx": 8192, "temperature": 0.0, "repeat_penalty": 1.0, "presence_penalty": 0.0, "top_p": 0.9 } # ANCHOR[OLLAMA_REQUEST_DISPATCHER] def call_ollama_chat( messages: list, tools: Optional[list] = None, is_vision: bool = False, timeout: int = 120 ) -> Dict[str, Any]: """ Отправляет подготовленный массив сообщений в API Ollama. Возвращает разобранный словарь сообщения ответа или генерирует исключение. """ model_name = VISION_MODEL if is_vision else TEXT_MODEL payload = { "model": model_name, "messages": messages, "stream": False, "options": LLM_OPTIONS } if tools and not is_vision: payload["tools"] = tools req = urllib.request.Request( OLLAMA_URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(req, timeout=timeout) as response: res_data = json.loads(response.read().decode("utf-8")) return res_data.get("message", {}) ``` ## File: `./modules/web_api/llm/core/prompt_merger.py` ```py """ =============================================================================== 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) ``` ## File: `./modules/web_api/llm/core/tool_injector.py` ```py """ =============================================================================== FILE: modules/web_api/llm/core/tool_injector.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm / core ROLE: Базовая санитарная очистка артефактов без эвристик и регулярных выражений. AI-CONTEXT-ANCHORS: - ANCHOR[CLEAN_RAW_TOOLS]: Очистка строковых тегов. - ANCHOR[PASS_THROUGH_TOOLS]: Чистый проходной интерфейс инструментов. =============================================================================== """ # ANCHOR[CLEAN_RAW_TOOLS] from typing import List, Dict, Any def clean_raw_tool_tags(text: str) -> str: """Удаляет только технические теги разметки, если они попали в текст.""" if not text: return "" return text.replace("", "").replace("", "").strip() def clean_output(text: str) -> str: """Возвращает текст ответа без изменения смысла.""" if not text: return "" return text.strip() # ANCHOR[PASS_THROUGH_TOOLS] def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Чистый сквозной проход: все решения принимает исключительно языковая модель. """ return tool_calls ``` ## File: `./modules/ai_engine/__init__.py` ```py ``` ## File: `./modules/ai_engine/agent.py` ```py """ =============================================================================== FILE: modules/ai_engine/agent.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: modules / ai_engine ROLE: Лаконичный нативный оркестратор Function Calling и диспетчер handlers. 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", []) if not tool_calls: tool_calls = inject_tools_if_needed(user_message, raw_reply, tool_calls) # 2. Исполнение инструментов через изолированные 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 # Обычный текстовый диалог final_reply = clean_raw_tool_tags(clean_output(raw_reply)) or "Запрос обработан." db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0) return final_reply, db_get_chat_history(session_id), None except Exception as ex: logger.exception(f"Ошибка в агенте: {ex}") return f"Внутренняя ошибка сервера: {ex}", db_get_chat_history(session_id), None ``` ## File: `./modules/ai_engine/context_builder.py` ```py """ =============================================================================== 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 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: """Формирует строгий динамический системный промпт для агента.""" calendar_context = get_dynamic_calendar_context() user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" 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" return ( 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}" ) ``` ## File: `./modules/ai_engine/handlers/__init__.py` ```py ``` ## File: `./modules/ai_engine/handlers/prompt_handler.py` ```py """ =============================================================================== FILE: modules/ai_engine/handlers/prompt_handler.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: modules / ai_engine / handlers ROLE: Изолированная обработка команд управления системным промптом. AI-CONTEXT-ANCHORS: - ANCHOR[PROMPT_HANDLER_DISPATCH]: Обработка db_get_system_prompt и db_prompt_node_edit. =============================================================================== """ 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_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 # 2. Предпросмотр изменений (ADD / EDIT / DELETE) 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() merged_prompt, diff_html, baseline_prompt = create_prompt_preview( action=action, section_id=sec_id, item_id=itm_id, content=content ) 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"} ] } ``` ## File: `./modules/ai_engine/handlers/snapshot_handler.py` ```py """ =============================================================================== 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 } ``` ## File: `./modules/ai_engine/handlers/task_handler.py` ```py """ =============================================================================== 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 } ```