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