diff --git a/data/scud_orion_ai.db b/data/scud_orion_ai.db index a78f4a4..a36ea3a 100644 Binary files a/data/scud_orion_ai.db and b/data/scud_orion_ai.db differ diff --git a/data/scud_orion_ai.db-shm b/data/scud_orion_ai.db-shm new file mode 100644 index 0000000..d436032 Binary files /dev/null and b/data/scud_orion_ai.db-shm differ diff --git a/data/scud_orion_ai.db-wal b/data/scud_orion_ai.db-wal new file mode 100644 index 0000000..ff1bdc9 Binary files /dev/null and b/data/scud_orion_ai.db-wal differ diff --git a/main_etl.py b/main_etl.py index e6bb2be..720d4bb 100644 --- a/main_etl.py +++ b/main_etl.py @@ -2,92 +2,79 @@ =============================================================================== FILE: main_etl.py PROJECT: SCUD Orion AI (Unified Architecture) -ROLE: Главная CLI-точка входа для запуска ежедневного контроллинга СКУД ⟷ 1С. - -AI-CONTEXT-ANCHORS: - - ANCHOR[MAIN_CLI_ENTRY]: Парсинг CLI-флагов и запуск конвейера. +ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП. =============================================================================== """ +import os 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 +import logging +from datetime import datetime, timedelta + +from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date +from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics +from services.scud_etl.anomaly_detector import detect_registry_anomalies +from services.text_reporter.service import format_controlling_summary_markdown + +# Корректные импорты из папки services/ +from services.scud_export import run_scud_export_today_and_yesterday +from services.share_copier import sync_1c_files_from_share +from services.excel_exporter import build_daily_summary_excel, build_detailed_yesterday_excel + +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s") -# 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("ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С") print("=" * 60) - init_db() + now = datetime.now() + today_str = now.strftime("%d.%m.%Y") + yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y") - # Определение режима работы и вывод статусов - 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}'") + # [Этап 0] Выгрузка свежих данных СКУД + print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...") + run_scud_export_today_and_yesterday() - has_today_1c = True + # [Этап 0.5] Синхронизация файлов с шары 1С + print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...") + sync_1c_files_from_share() - # Прямой экспорт и проверка сетевой шары (если не режим снапшотов) - 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.") + # [Этап 1-2] Загрузка данных + print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...") + df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True) + df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False) - copy_1c_files_from_share() - print("[1/5] Проверка актуальности и свежести входных данных...") - is_valid, warnings, errors, has_today_1c = check_file_freshness() + df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str) + df_staff_today, df_abs_today = load_1c_files_for_date(today_str) - if warnings: - print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---") - for w in warnings: - print(f" • {w}") - print("-" * 45) + # [Этап 3] Детальный отчет за вчера (на базе финального Y-снапшота) + print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...") + df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday) + build_detailed_yesterday_excel(df_merged_yesterday, yesterday_str) - 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)...") + # [Этап 4] Сводка за сегодня + print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...") + df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today) + metrics_today = calculate_summary_metrics(df_merged_today) + anomalies_today = detect_registry_anomalies(df_merged_today) + build_daily_summary_excel(df_merged_today, metrics_today, today_str) - # Запуск конвейера - run_controlling_pipeline( - snapshot_param=args.snapshot, - skip_export=args.skip_export, - debug=args.debug, - has_today_1c=has_today_1c - ) + # [Этап 5] Формирование текстового отчета и вывод в консоль + print(f"\n[5/5] Формирование Markdown-сводки...") + summary_md = format_controlling_summary_markdown(today_str, metrics_today, anomalies_today) + + os.makedirs("output", exist_ok=True) + md_file_path = f"output/Сводка_контроллинга_{today_str}.md" + with open(md_file_path, "w", encoding="utf-8") as f: + f.write(summary_md) + print(f"[✓] Текстовый отчет сохранен в: {md_file_path}") + + print("\n" + "=" * 60) + print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:") + print("=" * 60) + print(summary_md) if __name__ == "__main__": diff --git a/modules/ai_engine/__init__.py b/modules/ai_engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/ai_engine/context_builder.py b/modules/ai_engine/context_builder.py new file mode 100644 index 0000000..5b0fd48 --- /dev/null +++ b/modules/ai_engine/context_builder.py @@ -0,0 +1,84 @@ +""" +=============================================================================== +FILE: modules/ai_engine/context_builder.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine +ROLE: Динамическая сборка системного промпта из БД, календаря и контекста сессий. + +AI-CONTEXT-ANCHORS: + - ANCHOR[CONTEXT_BUILDER_MAIN]: Формирование полного системного контекста для LLM. +=============================================================================== +""" + +from typing import Dict, Any, Optional +from services.prompts.service import get_active_system_prompt +from modules.web_api.llm.core.calendar_utils import get_dynamic_calendar_context + + +# ANCHOR[CONTEXT_BUILDER_MAIN] +def build_agent_system_context( + user_id: int, + session_state: Optional[Dict[str, Any]] +) -> str: + """Формирует полный динамический системный контекст агента с правилами из БД.""" + + # 1. Загружаем актуальный базовый системный промпт из SQLite БД + base_system_prompt = get_active_system_prompt() + + # 2. Серверный календарь и контекст дат + calendar_context = get_dynamic_calendar_context() + user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость" + + # 3. Контекст текущего активного стейта сессии + current_state_type = session_state.get("state_type") if session_state else None + state_data = session_state.get("data_json") or {} if session_state else {} + if not isinstance(state_data, dict): + state_data = {} + + active_state_context = "" + if current_state_type == "PROMPT_PREVIEW": + active_state_context = ( + "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n" + "- Открыт предпросмотр изменений промпта. Для любых правок вызывай db_prompt_node_edit.\n" + ) + elif current_state_type == "PROMPT_FOLLOWUP": + active_state_context = ( + "\n[ТЕКУЩИЙ РЕЖИМ: СЕССИЯ РЕДАКТИРОВАНИЯ ПРОМПТА]\n" + "- Оператор просматривает или редактирует системный промпт.\n" + "- На любые команды вида 'удали пункт X.Y' или 'удали X.Y' ТЫ ОБЯЗАН ВЫЗВАТЬ db_prompt_node_edit с action='DELETE', section_id=X, item_id=Y.\n" + "- На любые команды 'добавь пункт X.Y ...' вызывай action='ADD'.\n" + "- Запрещено путать ADD и DELETE.\n" + ) + elif current_state_type == "SNAPSHOTS_VIEW": + active_date = state_data.get("query_date", "выбранную дату") + active_state_context = ( + f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n" + f"- Сейчас на экране отображаются снапшоты за {active_date}.\n" + f"- Ты можешь форматировать, фильтровать или анализировать этот текущий срез.\n" + f"- Если оператор запрашивает ДРУГУЮ дату или день недели (например: 'за вчера', 'а за 13.08') — " + f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_snapshots(date_str=...).\n" + f"- Запрещено генерировать текст за другую дату по памяти.\n" + ) + elif current_state_type == "SNAPSHOT_DELETE_CONFIRM": + active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ]\n" + elif current_state_type == "TASK_DELETE_CONFIRM": + active_state_context = "\n[ВНИМАНИЕ: ОЖИДАЕТСЯ ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ]\n" + + # 4. Сборка полного системного сообщения + return ( + f"{base_system_prompt}\n\n" + f"СТРОГИЕ ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ:\n" + f"1. Для системного промпта:\n" + f" - 'добавь X.Y Текст' -> СРАЗУ вызывай db_prompt_node_edit(action='ADD', section_id=X, item_id=Y, content='Текст').\n" + f" - 'удали X.Y' -> СРАЗУ вызывай db_prompt_node_edit(action='DELETE', section_id=X, item_id=Y, content='').\n" + f" - 'покажи системный промпт' -> СРАЗУ вызывай db_get_system_prompt().\n" + f"2. Для задач:\n" + f" - 'удали задачу N' -> СРАЗУ вызывай db_tasks_edit(action='DELETE', task_id='N').\n" + f" - 'возьми в работу N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='IN_PROGRESS').\n" + f" - 'заверши N' / 'готово N' -> СРАЗУ вызывай db_tasks_edit(action='UPDATE', task_id='N', status='COMPLETED').\n" + f"3. Запрещено задавать вопросы и писать подтверждения текстом — СРАЗУ вызывай соответствующий инструмент!\n\n" + f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n" + f"- Пользователь: {user_info}\n" + f"- {calendar_context}\n" + f"{active_state_context}" + ) \ No newline at end of file diff --git a/modules/ai_engine/handlers/__init__.py b/modules/ai_engine/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/ai_engine/handlers/prompt_handler.py b/modules/ai_engine/handlers/prompt_handler.py new file mode 100644 index 0000000..02ca6a8 --- /dev/null +++ b/modules/ai_engine/handlers/prompt_handler.py @@ -0,0 +1,98 @@ +""" +=============================================================================== +FILE: modules/ai_engine/handlers/prompt_handler.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine / handlers +ROLE: Изолированная обработка команд управления системным промптом. +=============================================================================== +""" + +from typing import Dict, Any, Tuple, Optional +from services.prompts.service import get_active_system_prompt, create_prompt_preview +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history +from modules.web_api.llm.db.db_prompts import db_set_session_state + + +# ANCHOR[PROMPT_HANDLER_DISPATCH] +def handle_prompt_call( + fn_name: str, + fn_args: Dict[str, Any], + session_id: str +) -> Tuple[str, list, Optional[Dict[str, Any]]]: + """Обрабатывает вызовы просмотра и изменения системного промпта.""" + + # 1. Просмотр промпта с кнопкой быстрого перехода в редактор + if fn_name == "db_get_system_prompt": + active_prompt = get_active_system_prompt() + reply_text = f"Актуальный системный промпт:\n\n{active_prompt}" + + # Сохраняем состояние сессии для возможности мгновенного редактирования и подтверждения + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": active_prompt, + "action": "MANUAL_EDIT", + "idle_turns": 0 + }) + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + + return reply_text, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": active_prompt, + "baseline_prompt": active_prompt, + "buttons": [ + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}, + {"label": "Готово", "value": "нет, спасибо", "style": "secondary"} + ] + } + + # 2. Предпросмотр точечных или пакетных изменений (ADD / EDIT / DELETE / BATCH_DELETE) + action = str(fn_args.get("action", "ADD")).upper() + nodes_list = fn_args.get("nodes_list", []) + delete_nodes_tuples = [] + + if nodes_list: + for n_str in nodes_list: + parts = str(n_str).strip().split(".") + if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit(): + delete_nodes_tuples.append((int(parts[0]), int(parts[1]))) + + sec_id = None + itm_id = None + try: + if fn_args.get("section_id") is not None: + sec_id = int(str(fn_args.get("section_id")).strip()) + if fn_args.get("item_id") is not None: + itm_id = int(str(fn_args.get("item_id")).strip()) + except Exception: + pass + + content = str(fn_args.get("content", "")).strip() + + merged_prompt, diff_html, baseline_prompt = create_prompt_preview( + action=action, + section_id=sec_id, + item_id=itm_id, + content=content, + delete_nodes=delete_nodes_tuples if delete_nodes_tuples else None + ) + + db_set_session_state(session_id, "PROMPT_PREVIEW", { + "draft_text": merged_prompt, + "action": "MANUAL_EDIT", + "section_id": sec_id, + "item_id": itm_id, + "content": content, + "idle_turns": 0 + }) + + preview_reply = f"Предпросмотр изменений системного промпта:\n\n{diff_html}\n\nДля применения подтвердите действие, отредактируйте или отмените." + db_save_chat_message(session_id, "assistant", preview_reply, is_ephemeral=1) + return preview_reply, db_get_chat_history(session_id), { + "type": "PROMPT_PREVIEW", + "raw_draft": merged_prompt, + "baseline_prompt": baseline_prompt, + "buttons": [ + {"label": "Подтвердить", "value": "подтверждаю", "style": "primary"}, + {"label": "Отменить", "value": "отмена", "style": "danger"}, + {"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"} + ] + } \ No newline at end of file diff --git a/modules/ai_engine/handlers/snapshot_handler.py b/modules/ai_engine/handlers/snapshot_handler.py new file mode 100644 index 0000000..3b7b425 --- /dev/null +++ b/modules/ai_engine/handlers/snapshot_handler.py @@ -0,0 +1,91 @@ +""" +=============================================================================== +FILE: modules/ai_engine/handlers/snapshot_handler.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine / handlers +ROLE: Изолированная обработка запросов просмотра и удаления срезов СКУД. + +AI-CONTEXT-ANCHORS: + - ANCHOR[SNAPSHOT_HANDLER_DISPATCH]: Обработка db_get_snapshots и db_delete_snapshots. +=============================================================================== +""" + +from typing import Dict, Any, Tuple, Optional, List +from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely +from modules.web_api.llm.core.calendar_utils import parse_relative_date_ru +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history +from modules.web_api.llm.db.db_prompts import db_set_session_state + + +# ANCHOR[SNAPSHOT_HANDLER_DISPATCH] +def handle_snapshots_call( + fn_name: str, + fn_args: Dict[str, Any], + session_id: str, + user_message: str, + state_data: Dict[str, Any] +) -> Tuple[str, list, Optional[Dict[str, Any]]]: + """Обрабатывает запросы реестра и безопасного удаления срезов.""" + + # 1. Получение срезов + if fn_name == "db_get_snapshots": + date_param = fn_args.get("date_str") + if not date_param and user_message: + date_param = parse_relative_date_ru(user_message) + + snapshots_res = get_snapshots_registry(date_str=date_param) + query_date = snapshots_res.get("query_date", "выбранную дату") + + db_set_session_state(session_id, "SNAPSHOTS_VIEW", snapshots_res) + reply_text = f"Реестр срезов СКУД за {query_date}:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": snapshots_res + } + + # 2. Удаление срезов (двухфазное подтверждение) + raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str") + raw_ids = fn_args.get("snapshot_ids") or [] + is_confirmed = fn_args.get("confirmed", False) + + if raw_id and not raw_ids: + if isinstance(raw_id, str) and "," in raw_id: + raw_ids = [s.strip() for s in raw_id.split(",")] + else: + raw_ids = [raw_id] + + safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")] + if not safe_ids: + reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), None + + if not is_confirmed: + query_date = state_data.get("query_date", "") + db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", { + "snapshot_ids": safe_ids, + "query_date": query_date, + "idle_turns": 0 + }) + ids_str = ", ".join(safe_ids) + reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOT_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + else: + delete_snapshots_safely(snapshot_ids=safe_ids) + query_date = state_data.get("query_date", "") + updated_data = get_snapshots_registry(date_str=query_date) + + reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт." + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_data + } \ No newline at end of file diff --git a/modules/ai_engine/handlers/task_handler.py b/modules/ai_engine/handlers/task_handler.py new file mode 100644 index 0000000..f5d7e39 --- /dev/null +++ b/modules/ai_engine/handlers/task_handler.py @@ -0,0 +1,97 @@ +""" +=============================================================================== +FILE: modules/ai_engine/handlers/task_handler.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: modules / ai_engine / handlers +ROLE: Изолированная обработка вызовов инструментов задач (get, edit, delete, export). + +AI-CONTEXT-ANCHORS: + - ANCHOR[TASK_HANDLER_DISPATCH]: Обработка вызовов db_get_tasks и db_tasks_edit. +=============================================================================== +""" + +from typing import Dict, Any, Tuple, Optional +from services.tasks.service import get_tasks, execute_task_action +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history +from modules.web_api.llm.db.db_prompts import db_set_session_state + + +# ANCHOR[TASK_HANDLER_DISPATCH] +def handle_tasks_call( + fn_name: str, + fn_args: Dict[str, Any], + user_id: int, + session_id: str +) -> Tuple[str, list, Optional[Dict[str, Any]]]: + """Обрабатывает нативные tool-вызовы по задачам.""" + + # 1. Просмотр задач + if fn_name == "db_get_tasks": + raw_tasks = get_tasks(user_id, status=fn_args.get("status")) + reply_text = "Вот интерактивный список ваших текущих задач:" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + # 2. Модификация / Удаление / Экспорт + action = (fn_args.get("action") or "UPDATE").upper() + if fn_name == "db_add_task": action = "ADD" + elif fn_name == "db_delete_task": action = "DELETE" + elif fn_name == "db_update_task_status": action = "UPDATE" + + # Двухфазное подтверждение удаления + if action == "DELETE": + task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip() + db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0}) + reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?" + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_DELETE_CONFIRM", + "buttons": [ + {"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"}, + {"label": "Отмена", "value": "отмена", "style": "secondary"} + ] + } + + # Экспорт в Markdown + elif action == "EXPORT": + export_res = execute_task_action( + user_id=user_id, + action="EXPORT", + filename=fn_args.get("filename"), + status=fn_args.get("status") + ) + reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0) + + action_payload = None + if export_res.get("status") == "success": + action_payload = { + "type": "FILE_DOWNLOAD_CARD", + "filename": export_res.get("filename"), + "download_url": export_res.get("download_url"), + "tasks_count": export_res.get("tasks_count") + } + return reply_text, db_get_chat_history(session_id), action_payload + + # Создание и обновление + else: + res = execute_task_action( + user_id=user_id, + action=action, + task_id=fn_args.get("task_id"), + title=fn_args.get("title"), + priority=fn_args.get("priority", "MEDIUM"), + status=fn_args.get("status"), + module=fn_args.get("module", "general"), + due_date=fn_args.get("due_date") + ) + raw_tasks = get_tasks(user_id) + reply_text = res.get("message", "Операция над задачами выполнена.") + db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1) + return reply_text, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } \ No newline at end of file diff --git a/modules/web_api/llm/core/fast_path.py b/modules/web_api/llm/core/fast_path.py index f023a3e..b33e1c8 100644 --- a/modules/web_api/llm/core/fast_path.py +++ b/modules/web_api/llm/core/fast_path.py @@ -1,87 +1,168 @@ """ +=============================================================================== FILE: modules/web_api/llm/core/fast_path.py -ROLE: Детерминированный технический конвейер (НЕ для ИИ-размышлений). - Выполняет только кнопки подтверждения для удаления задач, снапшотов и промптов. +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: web_api / llm / core +ROLE: Детерминированный мгновенный перехват нажатий кнопок подтверждения + (без задержек LLM и обращения к Ollama). + +AI-CONTEXT-ANCHORS: + - ANCHOR[FAST_PATH_MAIN]: Точка входа handle_fast_path_intercept. +=============================================================================== """ +import json +import logging from typing import Dict, Any, Tuple, Optional -from llm.db_tools import ( - db_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 +# Прямые вызовы чистых доменных сервисов +from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt +from services.tasks.service import get_tasks, delete_task +from services.snapshots.service import get_snapshots_registry, delete_snapshots_safely + +# Чат и сессии +from modules.web_api.llm.db.db_chat import db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages +from modules.web_api.llm.db.db_prompts import db_set_session_state, db_clear_session_state, db_get_tool_action +from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup + +logger = logging.getLogger("FAST_PATH") + + +# ANCHOR[FAST_PATH_MAIN] def handle_fast_path_intercept( - session_id: str, - user_message: str, - full_user_content: str, + session_id: str, + user_message: str, + full_user_content: str, session_state: Optional[Dict[str, Any]] ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: - + """ + Мгновенный перехват нажатий кнопок подтверждения (Fast-Path). + Возвращает (reply, history, action_type) или None, если требуется передать управление LLM. + """ if not session_state: return None state_type = session_state.get("state_type") - msg = user_message.strip().lower() + state_data = session_state.get("data_json") or {} + if not isinstance(state_data, dict): + state_data = {} - # ⭐️ Управление системным промптом (Превью) + msg_lower = user_message.strip().lower() + + # ------------------------------------------------------------------------- + # 1. ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ СИСТЕМНОГО ПРОМПТА + # ------------------------------------------------------------------------- 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", "") + is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"] - 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) + if is_confirm: + action = state_data.get("action", "MANUAL_EDIT") + draft_text = state_data.get("draft_text", "") + + # Применение изменений + if action == "MANUAL_EDIT" and draft_text: + save_full_prompt_draft(draft_text, prompt_name="main_agent") else: - db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content) + sec_id = state_data.get("section_id") + itm_id = state_data.get("item_id") + content = state_data.get("content", "") + if sec_id is not None and itm_id is not None: + apply_prompt_action(action=action, section_id=sec_id, item_id=itm_id, content=content) + elif draft_text: + save_full_prompt_draft(draft_text, prompt_name="main_agent") - 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 + # Закрытие сессии и зачистка + close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY") + db_clear_session_state(session_id) - 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)} + tool_action = db_get_tool_action("db_confirm_prompt_preview") + reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных." - elif msg == "отмена": - close_tool_session_and_cleanup(session_id, "TASK_CANCELLED") - return "Удаление отменено.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)} + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_CANCELLED") + db_clear_session_state(session_id) + + tool_action = db_get_tool_action("db_cancel_prompt_preview") + reply = tool_action.get("success_template", "❌ Изменения системного промпта отменены.") if tool_action else "❌ Изменения системного промпта отменены." + + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + # ------------------------------------------------------------------------- + # 2. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ СНАПШОТОВ СКУД + # ------------------------------------------------------------------------- + elif state_type == "SNAPSHOT_DELETE_CONFIRM": + is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет"] + + if is_confirm: + snap_ids = state_data.get("snapshot_ids", []) + query_date = state_data.get("query_date", "") + + # Безопасное удаление через сервис + del_res = delete_snapshots_safely(snapshot_ids=snap_ids) + + close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOTS_DELETED") + + # Получаем свежий список за ту же дату + updated_data = get_snapshots_registry(date_str=query_date if query_date else None) + db_set_session_state(session_id, "SNAPSHOTS_VIEW", updated_data) + + reply = f"✅ Успешно удалено снапшотов: {len(snap_ids)} шт." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + + return reply, db_get_chat_history(session_id), { + "type": "SNAPSHOTS_CARD", + "data": updated_data + } + + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="SNAPSHOT_DELETE_CANCELLED") + db_clear_session_state(session_id) + + reply = "❌ Удаление снапшотов отменено." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None + + # ------------------------------------------------------------------------- + # 3. ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧИ + # ------------------------------------------------------------------------- + elif state_type == "TASK_DELETE_CONFIRM": + is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"] + is_cancel = msg_lower in ["отмена", "отменить", "нет"] + + if is_confirm: + task_id = state_data.get("task_id") + delete_task(user_id=1, task_id=str(task_id)) + + close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETED") + db_clear_session_state(session_id) + + raw_tasks = get_tasks(user_id=1) + reply = f"🗑 Задача #{task_id} удалена." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=1) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1) + + return reply, db_get_chat_history(session_id), { + "type": "TASK_INTERACTIVE_CARD", + "tasks": raw_tasks + } + + elif is_cancel: + close_tool_session_and_cleanup(session_id, close_reason="TASK_DELETE_CANCELLED") + db_clear_session_state(session_id) + + reply = "❌ Удаление задачи отменено." + db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0) + db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0) + return reply, db_get_chat_history(session_id), None return None \ No newline at end of file diff --git a/modules/web_api/llm/core/prompt_merger.py b/modules/web_api/llm/core/prompt_merger.py deleted file mode 100644 index 5407901..0000000 --- a/modules/web_api/llm/core/prompt_merger.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -=============================================================================== -FILE: modules/web_api/llm/core/prompt_merger.py -PROJECT: SCUD Orion AI (Unified Architecture) -MODULE: web_api / llm / core -ROLE: Алгоритмы парсинга, предпросмотра, слияния и визуального выделения правок. -=============================================================================== -""" - -# ANCHOR[PROMPT_MERGER_IMPORTS] -import re -import logging - -logger = logging.getLogger("PROMPT_MERGER") - - -# ANCHOR[PROMPT_MERGE_ENGINE] -def build_prompt_preview_merge(current_prompt: str, user_message: str, proposed_text: str) -> str: - """ - Вычисляет результирующий чистый текст промпта для записи в БД. - """ - user_msg_lower = user_message.lower() - - # 1. Сценарий удаления пункта - if any(w in user_msg_lower for w in ["удали", "стереть", "убрать", "вырежи", "удалить"]): - target_num_match = re.search(r'\d+(\.\d+)*', user_message) - target_num = target_num_match.group(0) if target_num_match else "" - - lines = current_prompt.splitlines() - if target_num: - new_lines = [line for line in lines if not line.strip().startswith(f"{target_num}.")] - else: - new_lines = lines - return "\n".join(new_lines) - - # 2. Сценарий добавления или замены пункта - if proposed_text: - if len(proposed_text) < 500: - clean_item = proposed_text.strip() - for prefix in ["добавь пункт", "добавить пункт", "вставь пункт", "добавь"]: - if prefix in clean_item.lower(): - clean_item = re.sub(prefix, "", clean_item, flags=re.IGNORECASE).strip(" .:") - - lines = current_prompt.splitlines() - new_lines = [] - inserted = False - for line in lines: - new_lines.append(line) - if "3.3." in line and not inserted: - item_str = clean_item if re.match(r'^\d+\.\d+\.', clean_item) else f"3.4. {clean_item}" - new_lines.append(f" {item_str}") - inserted = True - if not inserted: - new_lines.append(f" {clean_item}") - return "\n".join(new_lines) - - return proposed_text or current_prompt - - -def highlight_prompt_diff(old_prompt: str, new_prompt: str) -> str: - """ - Генерирует текст с подсветкой добавленных/измененных строк HTML-классами Tailwind. - """ - old_lines_set = {line.strip() for line in old_prompt.splitlines() if line.strip()} - diff_lines = [] - - for line in new_prompt.splitlines(): - if line.strip() and line.strip() not in old_lines_set: - # Выделяем новую или измененную строку красным цветом - diff_lines.append(f'{line}') - else: - diff_lines.append(line) - - return "\n".join(diff_lines) \ No newline at end of file diff --git a/modules/web_api/llm/db/db_snapshots.py b/modules/web_api/llm/db/db_snapshots.py deleted file mode 100644 index 2c6e94f..0000000 --- a/modules/web_api/llm/db/db_snapshots.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -=============================================================================== -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}"} \ No newline at end of file diff --git a/modules/web_api/llm/db/db_tasks.py b/modules/web_api/llm/db/db_tasks.py deleted file mode 100644 index 855b0e3..0000000 --- a/modules/web_api/llm/db/db_tasks.py +++ /dev/null @@ -1,297 +0,0 @@ -""" -=============================================================================== -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}'"} \ No newline at end of file diff --git a/modules/web_api/llm/db_tools.py b/modules/web_api/llm/db_tools.py index fdfcea6..c2a5b9b 100644 --- a/modules/web_api/llm/db_tools.py +++ b/modules/web_api/llm/db_tools.py @@ -11,6 +11,7 @@ AI-CONTEXT-ANCHORS: """ # ANCHOR[FACADE_EXPORTS] +from typing import Optional, List, Dict, Any from core.connection import DB_PATH, get_connection as get_db_connection # Домен: Задачи @@ -28,16 +29,10 @@ 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, + save_full_prompt_draft, 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, @@ -60,4 +55,42 @@ from .db.db_prompts import ( db_get_anomalies, db_get_reference ) -from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time \ No newline at end of file +from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time + + +def db_add_system_prompt(name_or_text: str, draft_text: str = None) -> None: + """Совместимая обертка для сохранения системного промпта.""" + if draft_text is not None: + save_full_prompt_draft(draft_text, prompt_name=name_or_text) + else: + save_full_prompt_draft(name_or_text, prompt_name="main_agent") + + +def db_get_snapshots(session_id: str = "web_session_main", date_str: str = None, original_user_message: str = "") -> Dict[str, Any]: + """Совместимый фасад выборки снапшотов с сохранением стейта сессии.""" + from services.snapshots.service import get_snapshots_registry + from .core.calendar_utils import parse_relative_date_ru + + clean_date = (date_str or "").strip() + if not clean_date and original_user_message: + clean_date = parse_relative_date_ru(original_user_message) + + res = get_snapshots_registry(date_str=clean_date if clean_date else None) + db_set_session_state(session_id=session_id, state_type="SNAPSHOTS_VIEW", data=res) + return res + + +def db_delete_snapshots(snapshot_id: str = None, snapshot_ids: List[str] = None, day_str: str = None) -> Dict[str, Any]: + """Совместимый фасад безопасного удаления снапшотов.""" + from services.snapshots.service import delete_snapshots_safely + + target_ids = [] + if snapshot_ids: + target_ids.extend(snapshot_ids) + if snapshot_id: + if isinstance(snapshot_id, str) and "," in snapshot_id: + target_ids.extend([s.strip() for s in snapshot_id.split(",")]) + else: + target_ids.append(snapshot_id) + + return delete_snapshots_safely(snapshot_ids=target_ids) \ No newline at end of file diff --git a/modules/web_api/llm/schemas.py b/modules/web_api/llm/schemas.py index 9c23433..dfe1567 100644 --- a/modules/web_api/llm/schemas.py +++ b/modules/web_api/llm/schemas.py @@ -19,32 +19,37 @@ TOOLS_SCHEMA = [ "function": { "name": "db_prompt_node_edit", "description": ( - "Управление элементами системного промпта (добавление, изменение, удаление). " - "Если пользователь пишет 'удали 2.9' или 'удали пункт 2.9', вызывай action='DELETE', section_id=2, item_id=9. " - "Действие action может быть ADD, EDIT или DELETE." + "Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n" + "Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n" + "При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент." ), "parameters": { "type": "object", "properties": { "action": { "type": "string", - "enum": ["ADD", "EDIT", "DELETE"], - "description": "Действие: ADD (добавить), EDIT (изменить), DELETE (удалить)" + "enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"], + "description": "Тип действия" }, "section_id": { "type": "integer", - "description": "Номер раздела из точки (например, 2 из 2.9)" + "description": "Номер раздела (например 1)" }, "item_id": { "type": "integer", - "description": "Номер пункта из точки (например, 9 из 2.9)" + "description": "Номер пункта (например 8)" + }, + "nodes_list": { + "type": "array", + "items": {"type": "string"}, + "description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']" }, "content": { "type": "string", - "description": "Текст пункта (для DELETE пустая строка)" + "description": "Текст пункта" } }, - "required": ["action", "section_id", "item_id"] + "required": ["action"] } } }, @@ -81,7 +86,14 @@ TOOLS_SCHEMA = [ "type": "function", "function": { "name": "db_tasks_edit", - "description": "Единый инструмент управления задачами: создание (ADD), изменение статуса/дедлайна (UPDATE), удаление (DELETE) и экспорт задач в Markdown-файл (EXPORT).", + "description": ( + "Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n" + "СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n" + "- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n" + "- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n" + "- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n" + "- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент." + ), "parameters": { "type": "object", "properties": { @@ -92,7 +104,7 @@ TOOLS_SCHEMA = [ }, "task_id": { "type": "string", - "description": "Номер задачи (для UPDATE и DELETE, например: '35')" + "description": "Номер задачи (например: '37')" }, "title": { "type": "string", @@ -100,8 +112,8 @@ TOOLS_SCHEMA = [ }, "status": { "type": "string", - "enum": ["IN_PROGRESS", "COMPLETED", "PLANNED"], - "description": "Статус задачи" + "enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"], + "description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)" }, "priority": { "type": "string", diff --git a/modules/web_api/main.py b/modules/web_api/main.py index a5317cd..fc65afd 100644 --- a/modules/web_api/main.py +++ b/modules/web_api/main.py @@ -16,6 +16,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) +ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../")) + +for p in [ROOT_DIR, CURRENT_DIR]: + if p not in sys.path: + sys.path.insert(0, p) + from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse diff --git a/modules/web_api/routers/auth.py b/modules/web_api/routers/auth.py index 73ba0c4..94ff0b1 100644 --- a/modules/web_api/routers/auth.py +++ b/modules/web_api/routers/auth.py @@ -5,8 +5,6 @@ ROLE: Аутентификация, валидация JWT-токенов и у =============================================================================== """ -# ANCHOR[AUTH_ROUTER_IMPORTS] -import sqlite3 import logging from datetime import datetime, timedelta from typing import Dict, Any, Optional @@ -17,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel -from llm.db_tools import DB_PATH +from core.connection import get_connection JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure" ALGORITHM = "HS256" @@ -27,11 +25,10 @@ security = HTTPBearer() router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) -# ANCHOR[AUTH_DB_HELPERS] + def get_db(): - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - return conn + return get_connection(row_factory=True) + def create_access_token(user_id: int, username: str, is_admin: bool) -> str: payload = { @@ -42,6 +39,7 @@ def create_access_token(user_id: int, username: str, is_admin: bool) -> str: } return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM) + def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: try: token = credentials.credentials @@ -58,21 +56,20 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit headers={"WWW-Authenticate": "Bearer"}, ) -# ANCHOR[AUTH_SCHEMAS] + class AuthRequest(BaseModel): username: str password: str + class ChangePasswordRequest(BaseModel): old_password: str new_password: str -# ANCHOR[AUTH_ENDPOINTS] + @router.post("/login") def login(req: AuthRequest): username = req.username.strip().lower() - logging.info(f"===> Попытка входа для пользователя: {username}") - conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) @@ -80,15 +77,14 @@ def login(req: AuthRequest): conn.close() if not user or not pwd_context.verify(req.password, user["password_hash"]): - logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") is_admin = bool(user["is_admin"]) or (user["username"] == "puh") token = create_access_token(user["id"], user["username"], is_admin) - logging.info(f"===> УСПЕХ: Авторизован пользователь {username}") return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin} + @router.post("/change-password") def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): if not req.new_password or len(req.new_password) < 4: @@ -108,5 +104,4 @@ def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = D conn.commit() conn.close() - logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}") return {"status": "success", "message": "Пароль успешно изменен"} \ No newline at end of file diff --git a/modules/web_api/routers/chat.py b/modules/web_api/routers/chat.py index 1dce55e..df4b303 100644 --- a/modules/web_api/routers/chat.py +++ b/modules/web_api/routers/chat.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from pydantic import BaseModel from .auth import get_current_user -from llm.agent import process_chat_message +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 diff --git a/modules/web_api/routers/tasks.py b/modules/web_api/routers/tasks.py index 80054a6..3b0ca06 100644 --- a/modules/web_api/routers/tasks.py +++ b/modules/web_api/routers/tasks.py @@ -3,19 +3,20 @@ FILE: modules/web_api/routers/tasks.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / routers -ROLE: REST API эндпоинты реестра задач (получение, создание и обновление). +ROLE: REST API эндпоинты реестра задач. =============================================================================== """ -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException 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 +from services.tasks.service import get_tasks, add_task, update_task_details router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"]) + class TaskCreateRequest(BaseModel): title: str priority: Optional[str] = "MEDIUM" @@ -23,6 +24,7 @@ class TaskCreateRequest(BaseModel): due_date: Optional[str] = None status: Optional[str] = "BACKLOG" + class TaskUpdateRequest(BaseModel): title: Optional[str] = None priority: Optional[str] = None @@ -31,7 +33,6 @@ class TaskUpdateRequest(BaseModel): 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 @@ -40,13 +41,13 @@ def resolve_user_id(current_user: Dict[str, Any]) -> int: @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)} + return {"tasks": 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( + res = add_task( user_id=user_id, module=req.module, title=req.title, @@ -62,7 +63,7 @@ async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(ge @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( + res = update_task_details( user_id=user_id, task_id=task_id, title=req.title, diff --git a/project_code_snapshot.md b/project_code_snapshot.md index f36017d..2358265 100644 --- a/project_code_snapshot.md +++ b/project_code_snapshot.md @@ -117,251 +117,70 @@ def load_exceptions(): ## File: `./main_etl.py` ```py -import os +""" +=============================================================================== +FILE: main_etl.py +PROJECT: SCUD Orion AI (Unified Architecture) +ROLE: Главная CLI-точка входа для запуска ежедневного контроллинга СКУД ⟷ 1С. + +AI-CONTEXT-ANCHORS: + - ANCHOR[MAIN_CLI_ENTRY]: Парсинг CLI-флагов и запуск конвейера. +=============================================================================== +""" + import sys -import json import argparse -import pandas as pd -from datetime import datetime, timedelta - -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR, DATA_DIR, normalize_fio -from services.scud_export import run_export +from 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.data_loader import load_1c_data_smart, load_scud_data -from services.excel_exporter import generate_summary_excel, generate_detailed_excel -from services.ai_verifier import ai_verify_scud_against_staff, analyze_scud_mass_failure_ai -from services.text_reporter import generate_markdown_report -from services.feedback_loop import review_ai_decisions -from services.knowledge_base import load_knowledge_base -from core.database import ( - init_db, - save_scud_to_db, - save_staff_to_db, - save_absences_to_db, - save_anomalies_to_db, - load_scud_from_db_by_snapshot, - load_staff_from_db, - load_absences_from_db, - get_latest_snapshot_time, - has_scud_logs_for_date -) - - -def load_exceptions_config(): - """Загружает файл exceptions.json из корня проекта.""" - root_dir = os.path.dirname(os.path.abspath(__file__)) - json_path = os.path.join(root_dir, "exceptions.json") - if not os.path.exists(json_path): - return {} - try: - with open(json_path, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - print(f"[⚠️] Ошибка чтения exceptions.json: {e}") - return {} - - -def apply_exceptions_from_json(df, exceptions_cfg): - """ - Быстрая и эффективная разметка флага is_excluded=True на основе exceptions.json. - Выполняется мгновенно без цикличных HTTP-запросов к ИИ. - """ - if df is None or df.empty or not exceptions_cfg: - if df is not None: - df['is_excluded'] = False - return df - - deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d] - exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] - pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k] - exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] - - df['is_excluded'] = False - - for idx, row in df.iterrows(): - fio_clean = row.get('fio_clean', '') - dep_1c = str(row.get('Подразделение', '')).lower() - dep_scud = str(row.get('department_scud', row.get('department', ''))).lower() - pos = str(row.get('Должность', '')).lower() - - is_fio_exc = fio_clean in exc_fios - is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False - - # Быстрая проверка отделов по подстрокам - is_dep_exc = False - if deps: - is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps) - - if is_fio_exc or is_dep_exc or is_pos_exc: - df.at[idx, 'is_excluded'] = True - - return df - - -def filter_report_dataframe(merged_df): - """ - Исключает сотрудников из списка исключений и сотрудников без пропуска из детального отчета, - ЕСЛИ у них нет официального документа отсутствия из 1С:ЗУП. - """ - if merged_df is None or merged_df.empty: - return merged_df - - has_1c_reason = ( - merged_df['Вид_отсутствия'].notna() & - (merged_df['Вид_отсутствия'].astype(str).str.strip() != '') & - (~merged_df['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ) - is_not_excluded = merged_df.get('is_excluded', False) == False - is_not_no_pass = merged_df.get('no_scud_pass', False) == False - - filtered_df = merged_df[(is_not_excluded & is_not_no_pass) | has_1c_reason].copy() - return filtered_df - - -def load_static_reason_workers(): - """Загружает реестр удалёнщиков и статических причин из CSV.""" - static_path = os.path.join(DATA_DIR, "static_reason_workers.csv") - if not os.path.exists(static_path): - return {} - try: - df_static = pd.read_csv(static_path, encoding='utf-8') - if 'fio' in df_static.columns and 'reason' in df_static.columns: - df_static['fio_clean'] = df_static['fio'].apply(normalize_fio) - return dict(zip(df_static['fio_clean'], df_static['reason'])) - except Exception as e: - print(f"[⚠️] Ошибка чтения static_reason_workers.csv: {e}") - return {} - - -def detect_all_anomalies(merged_df, static_reasons_dict, kb_rules, scud_fios_set=None): - """ - Автоматически выявляет истинные аномалии СКУД ⟷ 1С. - """ - anomalies = [] - kb_rules_text = " ".join(kb_rules).lower() if kb_rules else "" - - ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк'] - - for idx, row in merged_df.iterrows(): - fio = row.get('Сотрудник', row.get('fio_clean', '')) - fio_clean = row.get('fio_clean', '') - is_present = row.get('Пришел', False) - is_exc = row.get('is_excluded', False) - reason_1c = str(row.get('Вид_отсутствия', '')).strip() - has_1c_reason = pd.notna(row.get('Вид_отсутствия')) and reason_1c != '' and not reason_1c.startswith('Исключение') - anom_flag = row.get('anomaly_flag', 'NONE') - - is_fio_whitelisted_in_kb = fio_clean.lower() in kb_rules_text - - if is_present and has_1c_reason: - reason_lower = reason_1c.lower() - is_allowed_trip = any(kw in reason_lower for kw in ALLOWED_WORK_TRIP_KEYWORDS) - - if not is_allowed_trip and not is_fio_whitelisted_in_kb: - anomalies.append({ - "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", - "fio": fio, - "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" - }) - - if is_exc and not has_1c_reason: - continue - - if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': - first_act = row.get('Первая_активность', '—') - anomalies.append({ - "type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА", - "fio": fio, - "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}" - }) - - if scud_fios_set is not None: - if fio_clean not in scud_fios_set and not has_1c_reason: - anomalies.append({ - "type": "АНОМАЛИЯ УЧЕТА: СОТРУДНИК ОТСУТСТВУЕТ В СКУД ОРИОН PRO", - "fio": fio, - "details": f"Сотрудник числится в Штатном расписании 1С ({row.get('Подразделение', '—')}), но полностью отсутствует в базе СКУД Орион Pro (профиль не создан или карта не выдана)" - }) - - return anomalies - - -def aggregate_scud_by_employee(df, debug=False): - """Агрегирует проходы СКУД по уникальным сотрудникам.""" - if df is None or df.empty or 'fio_clean' not in df.columns: - return df - - aggregated = [] - for fio_clean, group in df.groupby('fio_clean', sort=False): - is_present = group['Пришел'].any() if 'Пришел' in group.columns else False - - if is_present and 'Пришел' in group.columns: - present_rows = group[group['Пришел'] == True] - best_row = present_rows.iloc[0].to_dict() if not present_rows.empty else group.iloc[0].to_dict() - else: - best_row = group.iloc[0].to_dict() - - best_row['Пришел'] = is_present - aggregated.append(best_row) - - return pd.DataFrame(aggregated) +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_v2) +Система автоматизированного контроллинга СКУД ⟷ 1С:ЗУП (scud_orion_ai) ПРИМЕРЫ ЗАПУСКА: - python main.py -- Обычный дневной запуск - python main.py --skip-export -- Расчет отчета по ПОСЛЕДНЕМУ имеющемуся снапшоту из SQLite - python main.py --snapshot 20260805-001 -- Расчет отчета строго по ID снапшота - python main.py -d -- Запуск в режиме расширенной отладки (DEBUG) + 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="Запуск в режиме отладки с выводом подробных логов") - parser.add_argument('--skip-export', action='store_true', help="Пропустить выгрузку СКУД из MS SQL и построить отчет по последнему снапшоту") - parser.add_argument('--snapshot', type=str, default=None, help="Составной ID снапшота или время создания") + parser = argparse.ArgumentParser(description=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() - DEBUG = args.debug - print("=" * 60) - print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if DEBUG else ''}") + print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if args.debug else ''}") print("=" * 60) init_db() - kb_data = load_knowledge_base() - kb_rules = kb_data.get("rules", []) - exceptions_cfg = load_exceptions_config() + # Определение режима работы и вывод статусов if args.snapshot: - snapshot_param = args.snapshot - print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{snapshot_param}'") + print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{args.snapshot}'") elif args.skip_export: - snapshot_param = get_latest_snapshot_time() - print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{snapshot_param}')") + last_snap = get_latest_snapshot_time() + print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{last_snap}')") else: - snapshot_param = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{snapshot_param}'") + 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=DEBUG) + run_export(save_xlsx=True, debug=args.debug) except Exception as e: - print(f"[⚠️] Ошибка автоэкспорта из БД: {e}. Переходим к записям в SQLite.") - else: - print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") + print(f"[⚠️] Ошибка автоэкспорта из MS SQL: {e}. Переходим к записям SQLite.") - # ⚡️ ПРОВЕРКА ШАРЫ ТОЛЬКО ДЛЯ ОБЫЧНОГО ДНЕВНОГО ЗАПУСКА - if not args.snapshot and not args.skip_export: copy_1c_files_from_share() - print("[1/5] Проверка актуальности и свежести входных данных...") is_valid, warnings, errors, has_today_1c = check_file_freshness() @@ -372,319 +191,22 @@ def main(): print("-" * 45) if not is_valid: - print("\n" + "!" * 60) - print("🛑 ОСТАНОВКА ВЫПОЛНЕНИЯ: Отсутствуют критически важные файлы за вчерашний день!") + print("🛑 ОСТАНОВКА: Отсутствуют критически важные файлы за вчера!") for e in errors: print(f" {e}") - print("!" * 60) sys.exit(1) - print("[✓] Проверка доступности данных успешно пройдена!\n") else: + print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...") print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...") - has_today_1c = True - print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") - - # 🎯 ВЫЧИСЛЕНИЕ ДАТ СНАПШОТА И ДАТ НАКАНУНЕ - if args.snapshot or args.skip_export: - raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snapshot_param) - - if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns: - target_date_str = str(raw_scud_today_df['log_date'].iloc[0]) - else: - target_date_str = DATE_TODAY - - dt_target = datetime.strptime(target_date_str, "%d.%m.%Y") - if dt_target.weekday() == 0: # Понедельник -> Пятница - dt_yesterday = dt_target - timedelta(days=3) - else: - dt_yesterday = dt_target - timedelta(days=1) - yesterday_date_str = dt_yesterday.strftime("%d.%m.%Y") - - print(f"[📸] СНАПШОТ ОПРЕДЕЛЕН: Целевая дата = {target_date_str}, Накануне = {yesterday_date_str}") - - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) - - # Гарантированная загрузка кадров 1С за обе даты (БД + локальный фолбэк) - df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True) - df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True) - else: - target_date_str = DATE_TODAY - yesterday_date_str = DATE_YESTERDAY - - raw_scud_today_df = load_scud_from_db_by_snapshot(target_date_str, snapshot_param=snapshot_param) - raw_scud_yesterday_df = load_scud_from_db_by_snapshot(yesterday_date_str, snapshot_param=None) - - df_staff_today, df_absent_today = load_1c_data_smart(DATE_TODAY, use_db=False) - df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(DATE_YESTERDAY, use_db=False) - - if df_staff_yesterday is not None: - save_staff_to_db(df_staff_yesterday, yesterday_date_str) - if df_absent_yesterday is not None: - save_absences_to_db(df_absent_yesterday, yesterday_date_str) - - if df_staff_today is not None: - save_staff_to_db(df_staff_today, target_date_str) - if df_absent_today is not None: - save_absences_to_db(df_absent_today, target_date_str) - - staff_fios_yesterday_clean = df_staff_yesterday['fio_clean'].dropna().tolist() if df_staff_yesterday is not None else [] - static_reasons_dict = load_static_reason_workers() - - # ============================================================ - # 🎯 ЧАСТЬ 1: ДЕТАЛЬНЫЙ ОТЧЕТ ЗА ВЧЕРА (ДЕНЬ НАКАНУНЕ) - # ============================================================ - print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...") - - if not raw_scud_yesterday_df.empty and 'Пришел' not in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['Пришел'] = raw_scud_yesterday_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_yesterday_df.columns else False - - scud_yesterday_unrecognized = raw_scud_yesterday_df[~raw_scud_yesterday_df['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() if not raw_scud_yesterday_df.empty else [] - - fio_mapping_scud_y = ai_verify_scud_against_staff(scud_yesterday_unrecognized, staff_fios_yesterday_clean) - if fio_mapping_scud_y: - raw_scud_yesterday_df['fio_clean'] = raw_scud_yesterday_df['fio_clean'].apply( - lambda x: fio_mapping_scud_y[x]['staff_fio'] if x in fio_mapping_scud_y else x - ) - - if df_absent_yesterday is not None and not df_absent_yesterday.empty: - absent_unrecognized_yesterday = df_absent_yesterday[~df_absent_yesterday['fio_clean'].isin(staff_fios_yesterday_clean)]['fio_clean'].tolist() - if absent_unrecognized_yesterday: - fio_mapping_absent_y = ai_verify_scud_against_staff(absent_unrecognized_yesterday, staff_fios_yesterday_clean) - if fio_mapping_absent_y: - df_absent_yesterday['fio_clean'] = df_absent_yesterday['fio_clean'].apply( - lambda x: fio_mapping_absent_y[x]['staff_fio'] if x in fio_mapping_absent_y else x - ) - - raw_scud_yesterday_df = aggregate_scud_by_employee(raw_scud_yesterday_df, debug=DEBUG) - - # ⭐️ ФОРМИРУЕМ МЕРДЖ ИСКЛЮЧИТЕЛЬНО НА БАЗЕ ШТАТА ЗА ПРОШЛЫЙ ДЕНЬ - merged_yesterday = df_staff_yesterday.copy() if (df_staff_yesterday is not None and not df_staff_yesterday.empty) else (df_staff_today.copy() if df_staff_today is not None else pd.DataFrame()) - - if not merged_yesterday.empty: - if not raw_scud_yesterday_df.empty: - if 'Подразделение' in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['Подразделение'] - elif 'department' in raw_scud_yesterday_df.columns: - raw_scud_yesterday_df['department_scud'] = raw_scud_yesterday_df['department'] - else: - raw_scud_yesterday_df['department_scud'] = '' - - merged_yesterday = merged_yesterday.merge( - raw_scud_yesterday_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']], - on='fio_clean', how='left' - ) - - if 'Вид_отсутствия' in merged_yesterday.columns: - merged_yesterday = merged_yesterday.drop(columns=['Вид_отсутствия']) - - # Присоединяем отсутствия СТРОГО за дату yesterday_date_str - if df_absent_yesterday is not None and not df_absent_yesterday.empty: - merged_yesterday = merged_yesterday.merge( - df_absent_yesterday[['fio_clean', 'Вид_отсутствия']], - on='fio_clean', - how='left' - ) - - if 'Пришел' not in merged_yesterday.columns: - merged_yesterday['Пришел'] = False - else: - merged_yesterday['Пришел'] = merged_yesterday['Пришел'].fillna(False) - - if 'Сотрудник' not in merged_yesterday.columns: - merged_yesterday['Сотрудник'] = merged_yesterday.get('ФИО', merged_yesterday['fio_clean']) - - if static_reasons_dict: - for fio_clean, reason_val in static_reasons_dict.items(): - mask_yesterday = ( - (merged_yesterday['Пришел'] == False) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday['fio_clean'] == fio_clean) - ) - merged_yesterday.loc[mask_yesterday, 'Вид_отсутствия'] = reason_val - - merged_yesterday = apply_exceptions_from_json(merged_yesterday, exceptions_cfg) - mask_exc_yesterday = ( - (merged_yesterday['Пришел'] == False) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday.get('is_excluded', False) == True) + # Запуск конвейера + run_controlling_pipeline( + snapshot_param=args.snapshot, + skip_export=args.skip_export, + debug=args.debug, + has_today_1c=has_today_1c ) - merged_yesterday.loc[mask_exc_yesterday, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - - scud_fios_yesterday_set = set(raw_scud_yesterday_df['fio_clean'].dropna().tolist()) if not raw_scud_yesterday_df.empty else set() - merged_yesterday['no_scud_pass'] = ( - (~merged_yesterday['fio_clean'].isin(scud_fios_yesterday_set)) & - (merged_yesterday['Вид_отсутствия'].isna() | (merged_yesterday['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_yesterday.get('is_excluded', False) == False) - ) - - anomalies_yesterday_list = detect_all_anomalies(merged_yesterday, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_yesterday_set) - save_anomalies_to_db(anomalies_yesterday_list, yesterday_date_str) - - filtered_yesterday = filter_report_dataframe(merged_yesterday) - generate_detailed_excel(merged_df=filtered_yesterday, date_str=yesterday_date_str) - - yesterday_dt_obj = datetime.strptime(yesterday_date_str, "%d.%m.%Y") - yesterday_22_str = yesterday_dt_obj.strftime("%Y-%m-%d 22:00:00") - save_scud_to_db(merged_yesterday, yesterday_date_str, snapshot_time=yesterday_22_str, is_yesterday=True) - print(f"[✓] Детальный отчет за вчера сформирован и зафиксирован в SQLite за {yesterday_date_str}") - - # ============================================================ - # 🎯 ЧАСТЬ 2: ЕЖЕДНЕВНАЯ СВОДКА (ЗА ЦЕЛЕВОЙ ДЕНЬ СНАПШОТА) - # ============================================================ - if not args.snapshot and not args.skip_export: - save_scud_to_db(raw_scud_today_df, target_date_str, snapshot_time=snapshot_param, is_yesterday=False) - - if has_today_1c and df_staff_today is not None and df_absent_today is not None: - print(f"\n[4/5] Обработка и построение Ежедневной сводки за {target_date_str}...") - staff_fios_today_clean = df_staff_today['fio_clean'].dropna().tolist() - - if not raw_scud_today_df.empty and 'Пришел' not in raw_scud_today_df.columns: - raw_scud_today_df['Пришел'] = raw_scud_today_df['is_present'].astype(int) == 1 if 'is_present' in raw_scud_today_df.columns else False - - scud_unrecognized = raw_scud_today_df[~raw_scud_today_df['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() if not raw_scud_today_df.empty else [] - fio_mapping_scud = ai_verify_scud_against_staff(scud_unrecognized, staff_fios_today_clean) - if fio_mapping_scud: - raw_scud_today_df['fio_clean'] = raw_scud_today_df['fio_clean'].apply( - lambda x: fio_mapping_scud[x]['staff_fio'] if x in fio_mapping_scud else x - ) - - absent_unrecognized_today = df_absent_today[~df_absent_today['fio_clean'].isin(staff_fios_today_clean)]['fio_clean'].tolist() - if absent_unrecognized_today: - fio_mapping_absent = ai_verify_scud_against_staff(absent_unrecognized_today, staff_fios_today_clean) - if fio_mapping_absent: - df_absent_today['fio_clean'] = df_absent_today['fio_clean'].apply( - lambda x: fio_mapping_absent[x]['staff_fio'] if x in fio_mapping_absent else x - ) - - raw_scud_today_df = aggregate_scud_by_employee(raw_scud_today_df, debug=DEBUG) - - merged_today = df_staff_today.copy() - if not raw_scud_today_df.empty: - if 'Подразделение' in raw_scud_today_df.columns: - raw_scud_today_df['department_scud'] = raw_scud_today_df['Подразделение'] - elif 'department' in raw_scud_today_df.columns: - raw_scud_today_df['department_scud'] = raw_scud_today_df['department'] - else: - raw_scud_today_df['department_scud'] = '' - - merged_today = merged_today.merge( - raw_scud_today_df[['fio_clean', 'Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag', 'department_scud']], - on='fio_clean', how='left' - ) - - if df_absent_today is not None and not df_absent_today.empty: - merged_today = merged_today.merge(df_absent_today[['fio_clean', 'Вид_отсутствия']], on='fio_clean', how='left') - - if 'Пришел' not in merged_today.columns: - merged_today['Пришел'] = False - else: - merged_today['Пришел'] = merged_today['Пришел'].fillna(False) - - if 'Сотрудник' not in merged_today.columns: - merged_today['Сотрудник'] = merged_today.get('ФИО', merged_today['fio_clean']) - - if static_reasons_dict: - for fio_clean, reason_val in static_reasons_dict.items(): - mask_today = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today['fio_clean'] == fio_clean) - ) - merged_today.loc[mask_today, 'Вид_отсутствия'] = reason_val - - merged_today = apply_exceptions_from_json(merged_today, exceptions_cfg) - mask_exc_today = ( - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today.get('is_excluded', False) == True) - ) - merged_today.loc[mask_exc_today, 'Вид_отсутствия'] = 'Исключение (ОВК/Подрядчики)' - - scud_fios_today_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() - merged_today['no_scud_pass'] = ( - (~merged_today['fio_clean'].isin(scud_fios_today_set)) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (merged_today.get('is_excluded', False) == False) - ) - - scud_fios_set = set(raw_scud_today_df['fio_clean'].dropna().tolist()) if not raw_scud_today_df.empty else set() - anomalies_list = detect_all_anomalies(merged_today, static_reasons_dict, kb_rules, scud_fios_set=scud_fios_set) - - mass_failure_today = analyze_scud_mass_failure_ai(raw_scud_today_df) - if mass_failure_today and mass_failure_today.get("is_mass_failure"): - print("\n" + "!" * 60) - print(f"🚨 ВНИМАНИЕ! ИИ ОБНАРУЖИЛ ОПЕРАТИВНЫЙ СБОЙ ТУРНИКЕТОВ ВХОДА СЕГОДНЯ ({mass_failure_today['anomaly_percent']}% СМЕНЫ)") - print(mass_failure_today["alert_text"]) - print("!" * 60 + "\n") - - save_anomalies_to_db(anomalies_list, target_date_str) - - is_no_pass_today = merged_today['no_scud_pass'] == True if 'no_scud_pass' in merged_today.columns else False - is_exc_today = merged_today.get('is_excluded', False) == True - - absent_explained = merged_today[ - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].notna()) & - (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ] - - absent_unexplained = merged_today[ - (merged_today['Пришел'] == False) & - (merged_today['Вид_отсутствия'].isna() | (merged_today['Вид_отсутствия'].astype(str).str.strip() == '')) & - (~is_no_pass_today) & - (~is_exc_today) - ] - - scud_present_but_absent_in_1c = merged_today[ - (~is_exc_today) & - (merged_today['Пришел'] == True) & - (merged_today['Вид_отсутствия'].notna()) & - (~merged_today['Вид_отсутствия'].astype(str).str.startswith('Исключение')) - ] - - print("[5/5] Запуск ИИ-аудитора и построение Ежедневной сводки...") - filtered_anomalies_list = [ - a for a in anomalies_list - if "ОТСУТСТВУЕТ В СКУД" not in a.get('type', '') - ] - - report_text = generate_markdown_report( - merged_df=merged_today, - absent_explained=absent_explained, - absent_unexplained=absent_unexplained, - scud_present_but_absent_in_1c=scud_present_but_absent_in_1c, - anomalies_list=filtered_anomalies_list, - raw_scud_df=raw_scud_today_df, - raw_absent_df=df_absent_today, - date_str=target_date_str - ) - - generate_summary_excel(merged_df=merged_today, date_str=target_date_str) - - md_report_path = os.path.join(OUTPUT_DIR, f"Сводка_контроллинга_{target_date_str}.md") - with open(md_report_path, "w", encoding="utf-8") as f: - f.write(report_text) - print(f"\n[✓] Текстовый отчет сохранен в: {md_report_path}") - - suspicious_cases = [] - for a in anomalies_list: - if "ОФИЦИАЛЬНОМ ОТСУТСТВИИ" not in a.get('type', ''): - suspicious_cases.append({ - 'fio_target': a['fio'], - 'reason': f"{a['type']}: {a['details']}" - }) - - if suspicious_cases: - review_ai_decisions(report_text, suspicious_cases) - - print("\n" + "=" * 60) - print("ГОТОВАЯ ТЕКСТОВАЯ СВОДКА ИИ-АУДИТОРА:") - print("=" * 60) - print(report_text) - else: - print(f"\n[ℹ️] Формирование Ежедневной сводки за {target_date_str} ПРОПУЩЕНО.") if __name__ == "__main__": @@ -1785,7 +1307,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): if not unrecognized_scud_fios or not staff_fios: return {} - # 🛡 Исключаем точные совпадения (чтобы ИИ не совершал ложных замен) + # Исключаем точные совпадения staff_fios_set = set(staff_fios) real_unrecognized = [f for f in unrecognized_scud_fios if f not in staff_fios_set] @@ -1797,7 +1319,7 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): prompt = f""" Ты — кадровый аудитор безопасности СКУД. {rules_context} -В СКУД записаны неопознанные ФИО: {json.dumps(unrecognized_scud_fios, ensure_ascii=False)} +В СКУД записаны неопознанные ФИО: {json.dumps(real_unrecognized, ensure_ascii=False)} В официальном Штатном расписании 1С записаны ЭТАЛОНЫ: {json.dumps(staff_fios, ensure_ascii=False)} СТРОГИЕ ПРАВИЛА: @@ -1807,40 +1329,59 @@ def ai_verify_scud_against_staff(unrecognized_scud_fios, staff_fios): 4. В поле "warning" опиши обнаруженную опечатку. 5. Запрещено выдумывать опечатки и объединять разных людей/однофамильцев! -ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ JSON! - -Формат ответа: +ОТВЕЧАЙ ТОЛЬКО ИСКЛЮЧИТЕЛЬНО В ФОРМАТЕ ВАЛИДНОГО JSON: {{ "verified_matches": [ {{ "scud_fio": "ФИО из СКУД", "staff_fio": "эталон ФИО из Штат 1С", - "warning": "Описание опечатки в СКУД или 'Точное совпадение (без опечаток)'" + "warning": "Описание опечатки в СКУД" }} ] }} """ raw_response = ask_ollama( prompt, - system_prompt="Ты — JSON API. Выдавай ТОЛЬКО валидный JSON без markdown-разметки." + system_prompt="Ты — строгий JSON API генератор. Отвечай только валидным JSON объектом без пояснительного текста." ) mapping = {} + if not raw_response: + return mapping + + # Фаза 1: Попытка прямого разбора с санитарной очисткой try: match = re.search(r'\{.*\}', raw_response, re.DOTALL) if match: json_str = match.group(0) - json_str = re.sub(r'\}\s*[^}\]]*$', '}', json_str) + # Убираем висячие запятые: {"a": 1,} -> {"a": 1} + json_str = re.sub(r',\s*([\}\]])', r'\1', json_str) + # Заменяем одинарные кавычки в ключах/значениях на двойные при необходимости + json_str = re.sub(r"(?<=\{|\,)\s*'([^']+)'\s*:", r'"\1":', json_str) + data = json.loads(json_str) for item in data.get("verified_matches", []): scud_f = item.get("scud_fio") staff_f = item.get("staff_fio") warn = item.get("warning", "Точное совпадение (без опечаток)") - if scud_f and staff_f: + if scud_f and staff_f and staff_f in staff_fios_set: mapping[scud_f] = {"staff_fio": staff_f, "warning": warn} + return mapping + except Exception: + pass + + # Фаза 2: Резервный Regex-парсер (если JSON синтаксически сломан, но пары ключ-значение есть) + try: + pattern = r'["\']scud_fio["\']\s*:\s*["\']([^"\']+)["\'].*?["\']staff_fio["\']\s*:\s*["\']([^"\']+)["\']' + matches = re.findall(pattern, raw_response, re.DOTALL) + for scud_f, staff_f in matches: + scud_clean = scud_f.strip() + staff_clean = staff_f.strip() + if staff_clean in staff_fios_set: + mapping[scud_clean] = {"staff_fio": staff_clean, "warning": "Восстановлено парсером опечаток"} except Exception as e: - print(f"[!] Ошибка разбора JSON от ИИ при сверке опечаток: {e}") - + print(f"[!] Ошибка резервного парсинга опечаток: {e}") + return mapping @@ -3360,36 +2901,1305 @@ def sync_zup_to_excel(target_date) -> bool: 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/database.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 -import json -import pandas as pd -from datetime import datetime from config import DATA_DIR DB_PATH = os.path.join(DATA_DIR, "scud_orion_ai.db") -def get_connection(): - """Создает подключение к базе данных SQLite с оптимизированными настройками.""" - conn = sqlite3.connect(DB_PATH) +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_db(): - """Создает структуру таблиц и индексов в базе данных.""" +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, @@ -3410,11 +4220,7 @@ def init_db(): ); """) - cursor.execute("PRAGMA table_info(scud_logs);") - cols = [col[1] for col in cursor.fetchall()] - if 'snapshot_id' not in cols: - cursor.execute("ALTER TABLE scud_logs ADD COLUMN snapshot_id TEXT DEFAULT NULL;") - + # 2. Кадровые реестры 1С cursor.execute(""" CREATE TABLE IF NOT EXISTS zup_staff ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -3438,6 +4244,7 @@ def init_db(): ); """) + # 3. Аномалии и база знаний cursor.execute(""" CREATE TABLE IF NOT EXISTS anomalies_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -3459,23 +4266,95 @@ def init_db(): ); """) + 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() +``` - sync_knowledge_base_to_db() +## File: `./core/repositories/scud_repo.py` +```py +""" +=============================================================================== +FILE: core/repositories/scud_repo.py +ROLE: Репозиторий логов СКУД, сохранение и загрузка снапшотов. +=============================================================================== +""" -def has_scud_logs_for_date(date_str): - """Проверяет, есть ли в базе данные СКУД за указанную дату.""" +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): - """Проверяет, зафиксирован ли уже ИТОГОВЫЙ вчерашний снапшот с индексом Y/22:00.""" + +def has_yesterday_final_snapshot(date_str: str) -> bool: with get_connection() as conn: cursor = conn.cursor() cursor.execute( @@ -3485,11 +4364,7 @@ def has_yesterday_final_snapshot(date_str): return cursor.fetchone() is not None -def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False): - """ - Генерирует датированный составной ID снапшота (YYYYMMDD-NNN или YYYYMMDD-NNN). - Префикс 'Y' присваивается автоматически, если дата логов (date_str) предшествует дате снятия (snapshot_time). - """ +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") @@ -3497,7 +4372,6 @@ def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False): dt_snap = datetime.now().date() date_prefix = dt_snap.strftime("%Y%m%d") - # Автоматическая проверка: если дата логов раньше даты выгрузки — проставляем Y if date_str: try: dt_log = datetime.strptime(date_str, "%d.%m.%Y").date() @@ -3510,7 +4384,6 @@ def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False): 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", @@ -3527,15 +4400,13 @@ def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False): return row[0] cursor.execute(""" - SELECT snapshot_id - FROM scud_logs + 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(): @@ -3544,20 +4415,17 @@ def get_or_create_snapshot_id(snapshot_time, date_str=None, is_yesterday=False): return f"{prefix}{date_prefix}-{next_seq:03d}" -def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False): - """Сохраняет логи СКУД с автоматическим присвоением составного snapshot_id.""" +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") - # Передаем date_str в генератор snapshot_id snapshot_id = get_or_create_snapshot_id(snapshot_time, date_str=date_str, is_yesterday=is_yesterday) - data_to_insert = [] - for _, r in df_scud.iterrows(): - data_to_insert.append(( + data_to_insert = [ + ( date_str, r.get('Сотрудник', r.get('fio_raw', '')), r.get('fio_clean', ''), @@ -3571,7 +4439,9 @@ def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False): r.get('anomaly_flag', 'NONE'), snapshot_time, snapshot_id - )) + ) + for _, r in df_scud.iterrows() + ] with get_connection() as conn: cursor = conn.cursor() @@ -3587,7 +4457,7 @@ def save_scud_to_db(df_scud, date_str, snapshot_time=None, is_yesterday=False): conn.commit() -def get_latest_snapshot_time(date_str=None): +def get_latest_snapshot_time(date_str: str = None): with get_connection() as conn: cursor = conn.cursor() if date_str: @@ -3598,239 +4468,103 @@ def get_latest_snapshot_time(date_str=None): return row[0] if row else None -def load_scud_from_db_by_snapshot(date_str, snapshot_param=None): - """ - Загружает логи СКУД по ID снапшота. - Если snapshot_param не задан (или искался прошлый день), приоритетно выбирает - финальный вечерний снапшот, начинающийся с буквы 'Y' (срез на 22:00) за дату date_str. - """ +def load_scud_from_db_by_snapshot(date_str: str, snapshot_param: str = None) -> pd.DataFrame: with get_connection() as conn: df = pd.DataFrame() - - # 1. Если передан конкретный ID снапшота (например, '20260810-001') if snapshot_param: - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE snapshot_id = ?", - conn, params=(str(snapshot_param),) - ) + df = pd.read_sql_query("SELECT * FROM scud_logs WHERE snapshot_id = ?", conn, params=(str(snapshot_param),)) - # 2. Если по snapshot_param ничего не найдено ИЛИ snapshot_param=None (запрос за вчерашний день): if df.empty and date_str: cursor = conn.cursor() - - # ⭐️ ПРИОРИТЕТ 1: Ищем срез, начальный символ которого 'Y' (вечерний зафиксированный Y-снапшот) - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", - (date_str,) - ) + cursor.execute("SELECT snapshot_id FROM scud_logs WHERE log_date = ? AND snapshot_id LIKE 'Y%' ORDER BY id DESC LIMIT 1", (date_str,)) row = cursor.fetchone() - - # ⭐️ ПРИОРИТЕТ 2: Если Y-снапшот не найден, берем самый свежий обычный if not row: - cursor.execute( - "SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", - (date_str,) - ) + cursor.execute("SELECT snapshot_id FROM scud_logs WHERE log_date = ? ORDER BY id DESC LIMIT 1", (date_str,)) row = cursor.fetchone() if row and row[0]: - target_snap_id = row[0] - df = pd.read_sql_query( - "SELECT * FROM scud_logs WHERE snapshot_id = ?", - conn, params=(target_snap_id,) - ) + 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': 'Пришел' + 'department': 'Подразделение', 'position': 'Должность', 'fio': 'Сотрудник', + 'time_in': 'Начало_дня', 'first_activity': 'Первая_активность', 'time_out': 'Конец_дня', + 'duration': 'Находился_в_здании', 'is_present': 'Пришел' } - # Переименовываем имеющиеся колонки df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns}) - - # Заполняем недостающие колонки значениями по умолчанию, если их не было в БД - required_cols = ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag'] - for col in required_cols: + for col in ['Пришел', 'Начало_дня', 'Первая_активность', 'Конец_дня', 'Находился_в_здании', 'anomaly_flag']: if col not in df.columns: - if col == 'Пришел': - df[col] = False - else: - df[col] = '—' - - # Приводим тип флага Пришел к bool + df[col] = False if col == 'Пришел' else '—' if 'Пришел' in df.columns: df['Пришел'] = df['Пришел'].astype(bool) return df -def get_available_snapshots(date_str=None): - """ - Возвращает список снапшотов. - Сортировка по времени snapshot_time DESC. - """ + +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: - cursor.execute(""" - SELECT - snapshot_id, - log_date, - snapshot_time, - COUNT(*) as cnt - FROM scud_logs - WHERE log_date = ? AND snapshot_time IS NOT NULL - GROUP BY snapshot_id, log_date, snapshot_time - ORDER BY snapshot_time DESC - """, (date_str,)) - else: - cursor.execute(""" - SELECT - snapshot_id, - log_date, - snapshot_time, - COUNT(*) as cnt - FROM scud_logs - WHERE snapshot_time IS NOT NULL - GROUP BY snapshot_id, log_date, snapshot_time - ORDER BY snapshot_time DESC - """) + 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 save_staff_to_db(df_staff, date_str): +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_to_insert = [ + 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_to_insert) + cursor.executemany("INSERT INTO zup_staff (snapshot_date, fio, fio_clean, department, position) VALUES (?, ?, ?, ?, ?)", data) conn.commit() -def save_absences_to_db(df_absent, date_str): - if df_absent is None or df_absent.empty: - return - data_to_insert = [ - (date_str, r.get('ФИО', r.get('fio_clean', '')), r.get('fio_clean', ''), r.get('Вид_отсутствия', '')) - for _, r in df_absent.iterrows() - ] - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM zup_absences WHERE absence_date = ?", (date_str,)) - cursor.executemany("INSERT INTO zup_absences (absence_date, fio, fio_clean, absence_type) VALUES (?, ?, ?, ?)", data_to_insert) - conn.commit() - - -def save_anomalies_to_db(anomalies_list, date_str): - if not anomalies_list: - return - data_to_insert = [ - (date_str, a.get('fio', ''), a.get('type', ''), a.get('details', '')) - for a in anomalies_list - ] - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM anomalies_history WHERE anomaly_date = ?", (date_str,)) - cursor.executemany("INSERT INTO anomalies_history (anomaly_date, fio, anomaly_type, details) VALUES (?, ?, ?, ?)", data_to_insert) - conn.commit() - - -def get_all_rules_from_db(): - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT rule_text FROM ai_knowledge_base") - return [r[0] for r in cursor.fetchall()] - - -def add_rule_to_db(rule_text, added_by="Human"): - if not rule_text or not rule_text.strip(): - return - with get_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute("INSERT OR IGNORE INTO ai_knowledge_base (rule_text, added_by) VALUES (?, ?)", (rule_text.strip(), added_by)) - conn.commit() - except Exception as e: - print(f"[⚠️] Ошибка записи правила в БД: {e}") - - -def sync_knowledge_base_to_db(): - pass - -def delete_snapshot_by_id(snapshot_id: str): - """Удаляет конкретный снапшот из таблицы scud_logs по его ID.""" - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("DELETE FROM scud_logs WHERE snapshot_id = ?", (snapshot_id,)) - deleted_count = cursor.rowcount - conn.commit() - print(f"[✓] Удален снапшот [{snapshot_id}]. Удалено строк: {deleted_count}") - return deleted_count - -def delete_snapshots_by_date(date_str: str): - """Удаляет все снапшоты за указанную дату (например, '04.08.2026').""" - with get_connection() as conn: - cursor = conn.cursor() - # Удаляем по log_date или по дате внутри snapshot_id - cursor.execute("DELETE FROM scud_logs WHERE log_date = ? OR snapshot_id LIKE ?", (date_str, f"%{date_str.replace('.', '')}%")) - deleted_count = cursor.rowcount - conn.commit() - print(f"[✓] Удалены все снапшоты за дату [{date_str}]. Удалено строк: {deleted_count}") - return deleted_count -def init_department_synonyms_db(): - """Создает таблицу синонимов отделов в БД SQLite.""" - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute(""" - CREATE TABLE IF NOT EXISTS department_synonyms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - short_name TEXT UNIQUE NOT NULL, - full_name TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ); - """) - # По умолчанию добавляем ОВК -> Отдел внутреннего контроля - cursor.execute(""" - INSERT OR IGNORE INTO department_synonyms (short_name, full_name) - VALUES ('овк', 'отдел внутреннего контроля') - """) - conn.commit() - - -def get_department_synonyms_dict(): - """Возвращает словарь всех изученных синонимов {short_name: full_name}.""" - init_department_synonyms_db() - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute("SELECT LOWER(short_name), LOWER(full_name) FROM department_synonyms") - return {row[0]: row[1] for row in cursor.fetchall()} - - -def add_department_synonym_to_db(short_name, full_name): - """Сохраняет новую пару синонимов отдела в базу SQLite.""" - init_department_synonyms_db() - with get_connection() as conn: - cursor = conn.cursor() - cursor.execute( - "INSERT OR REPLACE INTO department_synonyms (short_name, full_name) VALUES (?, ?)", - (short_name.strip().lower(), full_name.strip().lower()) - ) - conn.commit() - print(f"[✓] В базу SQLite добавлен новый синоним отдела: '{short_name}' ⟷ '{full_name}'") - -def load_staff_from_db(date_str): - """Загружает Штатное расписание 1С из базы SQLite за указанную дату.""" +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 = ?", @@ -3838,15 +4572,69 @@ def load_staff_from_db(date_str): ) return df if not df.empty else None -def load_absences_from_db(date_str): - """Загружает документальные отсутствия 1С из базы SQLite за указанную дату.""" + +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` @@ -3879,6 +4667,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if CURRENT_DIR not in sys.path: sys.path.insert(0, CURRENT_DIR) +ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../")) + +for p in [ROOT_DIR, CURRENT_DIR]: + if p not in sys.path: + sys.path.insert(0, p) + from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse @@ -6070,7 +6864,7 @@ from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException from pydantic import BaseModel from .auth import get_current_user -from llm.agent import process_chat_message +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 @@ -6834,43 +7628,62 @@ def process_chat_message( FILE: modules/web_api/llm/db_tools.py PROJECT: SCUD Orion AI (Unified Architecture) MODULE: web_api / llm -ROLE: Фасадная точка доступа ко всем функциям базы данных SQLite. +ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade). + +AI-CONTEXT-ANCHORS: + - ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API. =============================================================================== """ -from .db.connection import DB_PATH, get_db_connection -from .db.db_prompts import ( - db_get_active_system_prompt, - db_add_system_prompt, - db_apply_prompt_node_action, - db_get_tool_action, - db_set_session_state, - db_get_session_state, - db_clear_session_state, - db_get_rules, - db_get_stats, - db_get_anomalies, - db_get_reference +# ANCHOR[FACADE_EXPORTS] +from core.connection import DB_PATH, get_connection as get_db_connection + +# Домен: Задачи +from services.tasks.service import ( + get_tasks as db_get_tasks, + add_task as db_add_task, + update_task_details as db_update_task_details, + delete_task as db_delete_task, + execute_task_action as db_tasks_edit ) +from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown +from services.tasks.repository import normalize_task_id + +# Домен: Системный промпт +from services.prompts.service import ( + get_active_system_prompt as db_get_active_system_prompt, + apply_prompt_action as db_apply_prompt_node_action, + save_full_prompt_draft as db_add_system_prompt, + create_prompt_preview +) + +# Домен: Снапшоты СКУД +from services.snapshots.service import ( + get_snapshots_registry as db_get_snapshots, + delete_snapshots_safely as db_delete_snapshots +) + +# Домен: База знаний +from services.knowledge.service import ( + get_rules as db_get_rules, + add_rule as db_add_rule +) + +# Чат, сессии, статистика from .db.db_chat import ( db_save_chat_message, db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history ) -from .db.db_tasks import ( - normalize_task_id, - db_get_tasks, - db_add_task, - db_update_task_status, - db_update_task_details, - db_delete_task, - db_export_tasks_markdown, - db_tasks_edit -) -from .db.db_snapshots import ( - db_get_snapshots, - db_delete_snapshots +from .db.db_prompts import ( + db_get_tool_action, + db_set_session_state, + db_get_session_state, + db_clear_session_state, + db_get_stats, + db_get_anomalies, + db_get_reference ) from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time ``` @@ -8385,3 +9198,510 @@ def inject_tools_if_needed(user_message: str, raw_text_content: str, tool_calls: 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 + } +``` + diff --git a/scripts/db_cli.py b/scripts/db_cli.py index e24cec3..40cce04 100644 --- a/scripts/db_cli.py +++ b/scripts/db_cli.py @@ -333,11 +333,6 @@ def print_chat_messages(session_id=None, limit=50): def purge_chat_context(session_id=None, purge_all=False): - """ - Очистка контекста сообщений: - - По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий. - - purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии. - """ with get_connection() as conn: cursor = conn.cursor() @@ -357,6 +352,8 @@ def purge_chat_context(session_id=None, purge_all=False): DELETE FROM chat_messages WHERE is_ephemeral = 1 OR content LIKE '%Предпросмотр изменений%' + OR content LIKE '%Актуальный системный промпт:%' + OR content LIKE '%1. РОЛЬ И ЗАДАЧИ АССИСТЕНТА%' OR content LIKE '%Удален пункт:%' OR content LIKE '%добавлен пункт:%' """ diff --git a/services/scud_etl/anomaly_detector.py b/services/scud_etl/anomaly_detector.py index 2ab382d..fbd16cf 100644 --- a/services/scud_etl/anomaly_detector.py +++ b/services/scud_etl/anomaly_detector.py @@ -3,70 +3,49 @@ 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]: Проверка физического присутствия в отпуске и перемещений без входа. +ROLE: Детектирование истинных аномалий и конфликтов реестров. + (Приход удаленщиков и командированных в офис аномалией НЕ является). =============================================================================== """ +import logging +from typing import List, Dict, Any import pandas as pd -from typing import List, Dict, Any, Optional -ALLOWED_WORK_TRIP_KEYWORDS = ['командировк', 'разъездн', 'поездк'] +logger = logging.getLogger("SCUD_ANOMALY") -# 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С.""" +def detect_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]: + """ + Выявляет реальные аномалии: + - Приход в офис во время отпуска или больничного листа. + - Ошибки считывателей СКУД (наличие выхода при отсутствии отметки входа). + """ 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') + for _, row in df_merged.iterrows(): + fio = row.get("fio_clean") or row.get("Сотрудник", "") + start_day = str(row.get("Начало_дня", "")).strip() + end_day = str(row.get("Конец_дня", "")).strip() + reason = str(row.get("причина отсутствия", "")).strip() + reason_lower = reason.lower() - 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: + # 1. Приход в офис при отпуске / больничном + if start_day != "Нет входа" and reason and reason != "nan": + # Удаленная работа и командировки разрешены для работы в офисе + if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower): anomalies.append({ - "type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ", "fio": fio, - "details": f"Сотрудник пришел по СКУД, но в 1С оформлен документ: '{reason_1c}'" + "type": "PHYSICAL_PRESENCE_DURING_ABSENCE", + "description": f"Сотрудник пришел по СКУД ({start_day}), но в 1С оформлен документ: '{reason}'." }) - if is_exc and not has_1c_reason: - continue - - # 2. Перемещение внутри здания без отметки входа на КПП - if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY': - first_act = row.get('Первая_активность', '—') + # 2. Аномалия оборудования (есть выход без входа) + if start_day == "Нет входа" and end_day != "Нет выхода": anomalies.append({ - "type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА", "fio": fio, - "details": f"Отсутствует регистрация входа на КПП при зафиксированной первой активности в {first_act}" + "type": "SCUD_EQUIPMENT_ANOMALY", + "description": f"Отсутствует отметка утреннего входа при наличии выхода ({end_day})." }) - # 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 \ No newline at end of file diff --git a/services/scud_etl/merger.py b/services/scud_etl/merger.py index c168984..bfd9707 100644 --- a/services/scud_etl/merger.py +++ b/services/scud_etl/merger.py @@ -3,112 +3,110 @@ 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]: Сборка итогового датасета для сводок и отчетов. +ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов + подразделений и расчет сходящегося баланса присутствия. =============================================================================== """ -import os -import json +import logging +from typing import Dict, Any, List import pandas as pd -from config import normalize_fio, DATA_DIR + +# Используем правильные имена функций из services/knowledge/service.py +from services.knowledge.service import get_department_synonyms_dict, get_rules + +logger = logging.getLogger("SCUD_MERGER") -# 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 merge_scud_and_1c( + df_scud: pd.DataFrame, + df_staff_1c: pd.DataFrame, + df_absences_1c: pd.DataFrame +) -> pd.DataFrame: + """ + Объединяет реестры СКУД и 1С:ЗУП: + - Применяет исключения (уборщики, ОВК и др.). + - Устанавливает короткие аббревиатуры подразделений (КО, ОАН, РУК и др.). + - Привязывает кадровые документы отсутствий из 1С. + """ + # Получаем словарь синонимов + synonyms = get_department_synonyms_dict() + + df_res = df_scud.copy() + if "Сотрудник" in df_res.columns: + df_res["fio_clean"] = df_res["Сотрудник"].astype(str).str.strip() + + # 1. Фильтрация системных исключений + # Загружаем исключения из базы знаний или задаем системный фильтр + excluded_depts = {"ОВК", "Отдел вневедомственного контроля", "Служба уборки", "Клининг"} + excluded_positions = {"Уборщик", "Уборщица", "Дворник"} + + if not df_res.empty: + if "Подразделение" in df_res.columns: + df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)] + if "Должность" in df_res.columns: + df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)] + + # 2. Трансляция подразделений в короткие аббревиатуры СКУД + if "Подразделение" in df_res.columns: + df_res["Подразделение"] = df_res["Подразделение"].apply( + lambda d: synonyms.get(d, d) + ) + + # 3. Привязка документов отсутствий из 1С + absences_map = {} + if not df_absences_1c.empty and "Сотрудник" in df_absences_1c.columns and "Причина" in df_absences_1c.columns: + for _, row in df_absences_1c.iterrows(): + fio = str(row["Сотрудник"]).strip() + reason = str(row["Причина"]).strip() + absences_map[fio] = reason + + df_res["причина отсутствия"] = df_res["fio_clean"].map(absences_map) + + return df_res -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 +def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]: + """ + Расчет строго сходящегося баланса присутствия: + A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего) + """ + total_staff = len(df_merged) - 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] + # 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис) + came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа") + working_in_office = df_merged[came_to_office_mask] + working_in_office_count = len(working_in_office) - df['is_excluded'] = False + # 2. Все, кто сегодня НЕ пришел в офис + not_came_mask = ~came_to_office_mask + df_not_came = df_merged[not_came_mask] - 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() + # 3. Из непришедших выделяем удаленщиков (работают из дома) + reason_series = df_not_came["причина отсутствия"].astype(str).str.lower() + is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False) + + remote_home = df_not_came[is_remote_mask] + remote_home_count = len(remote_home) - 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 + # 4. Из оставшихся непришедших выделяем официальные отсутствия (отпуск, больничный, командировка и т.д.) + df_remaining_absent = df_not_came[~is_remote_mask] + has_doc_mask = df_remaining_absent["причина отсутствия"].notna() & \ + df_remaining_absent["причина отсутствия"].ne("") & \ + df_remaining_absent["причина отсутствия"].ne("nan") + + official_absent = df_remaining_absent[has_doc_mask] + official_absent_count = len(official_absent) - if is_fio_exc or is_dep_exc or is_pos_exc: - df.at[idx, 'is_excluded'] = True + # 5. Оставшиеся непришедшие без документов — истинно неизвестные + unknown = df_remaining_absent[~has_doc_mask] + unknown_count = len(unknown) - 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 {} \ No newline at end of file + return { + "total_staff": total_staff, + "working_in_office_count": working_in_office_count, + "remote_home_count": remote_home_count, + "official_absent_count": official_absent_count, + "unknown_count": unknown_count, + "unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else [] + } \ No newline at end of file diff --git a/services/scud_etl/pipeline.py b/services/scud_etl/pipeline.py index 6c118b5..f0f783f 100644 --- a/services/scud_etl/pipeline.py +++ b/services/scud_etl/pipeline.py @@ -3,137 +3,64 @@ 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-конвейера. +ROLE: Оркестрация выборки снапшотов из SQLite и загрузки кадровых файлов 1С. =============================================================================== """ import os -from datetime import datetime, timedelta +import json +import logging +from typing import Optional 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 core.connection import get_connection -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 +logger = logging.getLogger("SCUD_PIPELINE") -# 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() +def load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame: + """ + Извлекает срез за дату из SQLite. Для отчета за вчера строго ищет + финальный вечерний срез Y (с зафиксированными выходами за 22:00:00). + """ + with get_connection(row_factory=True) as conn: + cursor = conn.cursor() + + if prefer_final_y: + cursor.execute(""" + SELECT raw_data_json + FROM scud_snapshots + WHERE (snapshot_date = ? OR date_str = ?) AND (snapshot_id LIKE 'Y%' OR snapshot_time LIKE '22:00%') + ORDER BY id DESC LIMIT 1 + """, (date_str, date_str)) + row = cursor.fetchone() + if row and row["raw_data_json"]: + data = json.loads(row["raw_data_json"]) + return pd.DataFrame(data) - print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...") + cursor.execute(""" + SELECT raw_data_json + FROM scud_snapshots + WHERE snapshot_date = ? OR date_str = ? + ORDER BY id DESC LIMIT 1 + """, (date_str, date_str)) + row = cursor.fetchone() + if row and row["raw_data_json"]: + data = json.loads(row["raw_data_json"]) + return pd.DataFrame(data) - # 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) + return pd.DataFrame() - 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") +def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Загружает файлы Штат_*.xlsx и Отсутствия_*.xlsx за указанную дату из data/1c/. + """ + formatted_date = date_str.replace(".", "_") + staff_file = f"data/1c/Штат_{formatted_date}.xlsx" + absences_file = f"data/1c/Отсутствия_{formatted_date}.xlsx" - 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) + df_staff = pd.read_excel(staff_file) if os.path.exists(staff_file) else pd.DataFrame() + df_absences = pd.read_excel(absences_file) if os.path.exists(absences_file) else pd.DataFrame() - # 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} ПРОПУЩЕНО.") \ No newline at end of file + return df_staff, df_absences \ No newline at end of file diff --git a/services/snapshots/service.py b/services/snapshots/service.py new file mode 100644 index 0000000..b3c174f --- /dev/null +++ b/services/snapshots/service.py @@ -0,0 +1,69 @@ +""" +=============================================================================== +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)} шт." + } \ No newline at end of file diff --git a/services/text_reporter/__init__.py b/services/text_reporter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/text_reporter/service.py b/services/text_reporter/service.py new file mode 100644 index 0000000..a031c8e --- /dev/null +++ b/services/text_reporter/service.py @@ -0,0 +1,49 @@ +""" +=============================================================================== +FILE: services/text_reporter/service.py +PROJECT: SCUD Orion AI (Unified Architecture) +MODULE: services / text_reporter +ROLE: Формирование текстовой сводки ИИ-аудитора для вывода в консоль и сохранения в MD. +=============================================================================== +""" + +from typing import Dict, Any, List + + +def format_controlling_summary_markdown( + target_date: str, + metrics: Dict[str, Any], + anomalies: List[Dict[str, Any]] +) -> str: + """Генерирует Markdown-текст ежедневной сводки контроллинга.""" + lines = [ + f"**Сводка контроллинга СКУД и 1С:ЗУП на {target_date}**\n", + f"- Всего сотрудников по штату: **{metrics['total_staff']}**", + f"- Итого на работе (в офисе): **{metrics['working_in_office_count']}**", + f"- Удаленная работа (из дома): **{metrics['remote_home_count']}**", + f"- Официально отсутствуют: **{metrics['official_absent_count']}**", + f"- Неизвестно (истинно неотмеченные): **{metrics['unknown_count']}** чел.", + f"- Выявлено аномалий/конфликтов реестров: **{len(anomalies)}** шт.\n" + ] + + # Блок аномалий + lines.append(f"#### 🚨 Выявленные ИИ аномалии и конфликты источников ({len(anomalies)}):") + if anomalies: + for idx, a in enumerate(anomalies, 1): + lines.append(f"{idx}. {a['fio']}: {a['description']}") + else: + lines.append("Конфликтов и аномалий реестров не обнаружено.") + lines.append("") + + # Блок неизвестных + unknown_list = metrics.get("unknown_list", []) + lines.append(f"#### ❓ Неизвестные случаи ({len(unknown_list)}):") + if unknown_list: + for idx, u in enumerate(unknown_list, 1): + dept = u.get("Подразделение") or "—" + pos = u.get("Должность") or "—" + lines.append(f"{idx}. {u.get('fio_clean')} — {dept}, {pos}") + else: + lines.append("Все отсутствия подтверждены документами.") + + return "\n".join(lines) \ No newline at end of file