Перед тем, как исправлять выгрузку скуд с глупым Gemini-Lite

This commit is contained in:
2026-08-21 13:13:36 +03:00
parent 7bf1af7c8f
commit baf7c69395
28 changed files with 3064 additions and 1723 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56 -69
View File
@@ -2,92 +2,79 @@
=============================================================================== ===============================================================================
FILE: main_etl.py FILE: main_etl.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
ROLE: Главная CLI-точка входа для запуска ежедневного контроллинга СКУД ⟷ 1С. ROLE: Главная точка входа ETL-конвейера СКУД ⟷ 1С:ЗУП.
AI-CONTEXT-ANCHORS:
- ANCHOR[MAIN_CLI_ENTRY]: Парсинг CLI-флагов и запуск конвейера.
=============================================================================== ===============================================================================
""" """
import os
import sys import sys
import argparse import logging
from datetime import datetime from datetime import datetime, timedelta
from core.database import init_db, get_latest_snapshot_time
from services.share_copier import copy_1c_files_from_share from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.data_validator import check_file_freshness from services.scud_etl.merger import merge_scud_and_1c, calculate_summary_metrics
from services.scud_export import run_export from services.scud_etl.anomaly_detector import detect_registry_anomalies
from services.scud_etl.pipeline import run_controlling_pipeline 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(): 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("=" * 60)
print(f"ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С {'[DEBUG MODE]' if args.debug else ''}") print("ЗАПУСК СИСТЕМЫ МОДУЛЬНОГО КОНТРОЛЛИНГА СКУД ⟷ 1С")
print("=" * 60) print("=" * 60)
init_db() now = datetime.now()
today_str = now.strftime("%d.%m.%Y")
yesterday_str = (now - timedelta(days=1)).strftime("%d.%m.%Y")
# Определение режима работы и вывод статусов # [Этап 0] Выгрузка свежих данных СКУД
if args.snapshot: print(f"\n[0/5] Экспорт данных СКУД за {today_str} и {yesterday_str}...")
print(f"[📸] РЕЖИМ СНАПШОТА: Расчет отчета строго по срезу '{args.snapshot}'") run_scud_export_today_and_yesterday()
elif args.skip_export:
last_snap = get_latest_snapshot_time()
print(f"[📸] РЕЖИМ --skip-export: Используем последний снапшот из SQLite ('{last_snap}')")
else:
current_snap = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[📸] СФОРМИРОВАН НОВЫЙ СНАПШОТ: '{current_snap}'")
has_today_1c = True # [Этап 0.5] Синхронизация файлов с шары 1С
print(f"\n[0.5/5] Проверка и копирование файлов 1С с шары...")
sync_1c_files_from_share()
# Прямой экспорт и проверка сетевой шары (если не режим снапшотов) # [Этап 1-2] Загрузка данных
if not args.skip_export and not args.snapshot: print(f"\n[1-2/5] Загрузка срезов: Сегодня = {today_str}, Накануне = {yesterday_str}...")
try: df_scud_yesterday = load_best_snapshot_for_date(yesterday_str, prefer_final_y=True)
run_export(save_xlsx=True, debug=args.debug) df_scud_today = load_best_snapshot_for_date(today_str, prefer_final_y=False)
except Exception as e:
print(f"[⚠️] Ошибка автоэкспорта из MS SQL: {e}. Переходим к записям SQLite.")
copy_1c_files_from_share() df_staff_yesterday, df_abs_yesterday = load_1c_files_for_date(yesterday_str)
print("[1/5] Проверка актуальности и свежести входных данных...") df_staff_today, df_abs_today = load_1c_files_for_date(today_str)
is_valid, warnings, errors, has_today_1c = check_file_freshness()
if warnings: # [Этап 3] Детальный отчет за вчера (на базе финального Y-снапшота)
print("\n--- ⚠️ ПРЕДУПРЕЖДЕНИЯ ОБ АКТУАЛЬНОСТИ ---") print(f"\n[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_str})...")
for w in warnings: df_merged_yesterday = merge_scud_and_1c(df_scud_yesterday, df_staff_yesterday, df_abs_yesterday)
print(f" • {w}") build_detailed_yesterday_excel(df_merged_yesterday, yesterday_str)
print("-" * 45)
if not is_valid: # [Этап 4] Сводка за сегодня
print("🛑 ОСТАНОВКА: Отсутствуют критически важные файлы за вчера!") print(f"\n[4/5] Обработка и построение Ежедневной сводки за {today_str}...")
for e in errors: df_merged_today = merge_scud_and_1c(df_scud_today, df_staff_today, df_abs_today)
print(f" {e}") metrics_today = calculate_summary_metrics(df_merged_today)
sys.exit(1) anomalies_today = detect_registry_anomalies(df_merged_today)
print("[✓] Проверка доступности данных успешно пройдена!\n") build_daily_summary_excel(df_merged_today, metrics_today, today_str)
else:
print("[0/5] Пропуск прямого экспорта из MS SQL (чтение из базы SQLite)...")
print("[1/5] Пропуск проверки сетевой шары (все данные читаются из SQLite)...")
# Запуск конвейера # [Этап 5] Формирование текстового отчета и вывод в консоль
run_controlling_pipeline( print(f"\n[5/5] Формирование Markdown-сводки...")
snapshot_param=args.snapshot, summary_md = format_controlling_summary_markdown(today_str, metrics_today, anomalies_today)
skip_export=args.skip_export,
debug=args.debug, os.makedirs("output", exist_ok=True)
has_today_1c=has_today_1c 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__": if __name__ == "__main__":
View File
+84
View File
@@ -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}"
)
@@ -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"}
]
}
@@ -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
}
@@ -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
}
+146 -65
View File
@@ -1,87 +1,168 @@
""" """
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py 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 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( def handle_fast_path_intercept(
session_id: str, session_id: str,
user_message: str, user_message: str,
full_user_content: str, full_user_content: str,
session_state: Optional[Dict[str, Any]] session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]: ) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
"""
Мгновенный перехват нажатий кнопок подтверждения (Fast-Path).
Возвращает (reply, history, action_type) или None, если требуется передать управление LLM.
"""
if not session_state: if not session_state:
return None return None
state_type = session_state.get("state_type") 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": if state_type == "PROMPT_PREVIEW":
state_data = session_state.get("data_json") or {} is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
draft_text = state_data.get("draft_text", "") is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
action = state_data.get("action")
sec_id = state_data.get("section_id")
itm_id = state_data.get("item_id")
content = state_data.get("content", "")
if msg in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]: if is_confirm:
if action == "MANUAL_EDIT" or not action or sec_id is None: action = state_data.get("action", "MANUAL_EDIT")
if draft_text: draft_text = state_data.get("draft_text", "")
db_add_system_prompt("main_agent", draft_text)
# Применение изменений
if action == "MANUAL_EDIT" and draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
else: 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 = "✅ Изменения системного промпта успешно применены в базе данных." close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
save_tool_interaction(session_id, full_user_content, reply_text) db_clear_session_state(session_id)
return reply_text, [], None
elif msg in ["отмена", "отменить", "отклонить"]: tool_action = db_get_tool_action("db_confirm_prompt_preview")
close_tool_session_and_cleanup(session_id, "PROMPT_PREVIEW_CANCELLED") reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных."
reply_text = "❌ Изменения системного промпта отменены."
save_dialog_interaction(session_id, full_user_content, reply_text)
return reply_text, [], None
# Удаление снапшотов
elif state_type == "SNAPSHOT_DELETE_CONFIRM":
state_data = session_state.get("data_json", {})
ids = state_data.get("snapshot_ids", [])
query_date = state_data.get("query_date", "")
if msg.startswith("подтверждаю удаление снапшотов"):
db_delete_snapshots(snapshot_ids=ids)
close_tool_session_and_cleanup(session_id, "SNAPSHOTS_DELETED")
updated_data = db_get_snapshots(session_id=session_id, date_str=query_date)
return f"✅ Удалено снапшотов: {len(ids)}", [], {"type": "SNAPSHOTS_CARD", "data": updated_data}
elif msg == "отмена":
close_tool_session_and_cleanup(session_id, "SNAPSHOT_CANCELLED")
updated_data = db_get_snapshots(session_id=session_id, date_str=query_date)
return "Удаление отменено.", [], {"type": "SNAPSHOTS_CARD", "data": updated_data}
# Удаление задач
elif state_type == "TASK_DELETE_CONFIRM":
task_id = session_state.get("data_json", {}).get("task_id")
if msg.startswith("подтверждаю удаление задачи"):
db_delete_task(1, str(task_id))
close_tool_session_and_cleanup(session_id, "TASK_DELETED")
return f"Задача #{task_id} удалена.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)}
elif msg == "отмена": db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
close_tool_session_and_cleanup(session_id, "TASK_CANCELLED") db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
return "Удаление отменено.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)} 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 return None
-74
View File
@@ -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'<span class="text-rose-600 font-bold bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200">{line}</span>')
else:
diff_lines.append(line)
return "\n".join(diff_lines)
-112
View File
@@ -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}"}
-297
View File
@@ -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}'"}
+41 -8
View File
@@ -11,6 +11,7 @@ AI-CONTEXT-ANCHORS:
""" """
# ANCHOR[FACADE_EXPORTS] # ANCHOR[FACADE_EXPORTS]
from typing import Optional, List, Dict, Any
from core.connection import DB_PATH, get_connection as get_db_connection 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 ( from services.prompts.service import (
get_active_system_prompt as db_get_active_system_prompt, get_active_system_prompt as db_get_active_system_prompt,
apply_prompt_action as db_apply_prompt_node_action, 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 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 ( from services.knowledge.service import (
get_rules as db_get_rules, get_rules as db_get_rules,
@@ -60,4 +55,42 @@ from .db.db_prompts import (
db_get_anomalies, db_get_anomalies,
db_get_reference db_get_reference
) )
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time 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)
+25 -13
View File
@@ -19,32 +19,37 @@ TOOLS_SCHEMA = [
"function": { "function": {
"name": "db_prompt_node_edit", "name": "db_prompt_node_edit",
"description": ( "description": (
"Управление элементами системного промпта (добавление, изменение, удаление). " "Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
"Если пользователь пишет 'удали 2.9' или 'удали пункт 2.9', вызывай action='DELETE', section_id=2, item_id=9. " "Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
"Действие action может быть ADD, EDIT или DELETE." "При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"action": { "action": {
"type": "string", "type": "string",
"enum": ["ADD", "EDIT", "DELETE"], "enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
"description": "Действие: ADD (добавить), EDIT (изменить), DELETE (удалить)" "description": "Тип действия"
}, },
"section_id": { "section_id": {
"type": "integer", "type": "integer",
"description": "Номер раздела из точки (например, 2 из 2.9)" "description": "Номер раздела (например 1)"
}, },
"item_id": { "item_id": {
"type": "integer", "type": "integer",
"description": "Номер пункта из точки (например, 9 из 2.9)" "description": "Номер пункта (например 8)"
},
"nodes_list": {
"type": "array",
"items": {"type": "string"},
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
}, },
"content": { "content": {
"type": "string", "type": "string",
"description": "Текст пункта (для DELETE пустая строка)" "description": "Текст пункта"
} }
}, },
"required": ["action", "section_id", "item_id"] "required": ["action"]
} }
} }
}, },
@@ -81,7 +86,14 @@ TOOLS_SCHEMA = [
"type": "function", "type": "function",
"function": { "function": {
"name": "db_tasks_edit", "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": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -92,7 +104,7 @@ TOOLS_SCHEMA = [
}, },
"task_id": { "task_id": {
"type": "string", "type": "string",
"description": "Номер задачи (для UPDATE и DELETE, например: '35')" "description": "Номер задачи (например: '37')"
}, },
"title": { "title": {
"type": "string", "type": "string",
@@ -100,8 +112,8 @@ TOOLS_SCHEMA = [
}, },
"status": { "status": {
"type": "string", "type": "string",
"enum": ["IN_PROGRESS", "COMPLETED", "PLANNED"], "enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
"description": "Статус задачи" "description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
}, },
"priority": { "priority": {
"type": "string", "type": "string",
+6
View File
@@ -16,6 +16,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path: if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR) 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 import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
+9 -14
View File
@@ -5,8 +5,6 @@ ROLE: Аутентификация, валидация JWT-токенов и у
=============================================================================== ===============================================================================
""" """
# ANCHOR[AUTH_ROUTER_IMPORTS]
import sqlite3
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
@@ -17,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel 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" JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256" ALGORITHM = "HS256"
@@ -27,11 +25,10 @@ security = HTTPBearer()
router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
# ANCHOR[AUTH_DB_HELPERS]
def get_db(): def get_db():
conn = sqlite3.connect(DB_PATH) return get_connection(row_factory=True)
conn.row_factory = sqlite3.Row
return conn
def create_access_token(user_id: int, username: str, is_admin: bool) -> str: def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = { 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) return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]: def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try: try:
token = credentials.credentials token = credentials.credentials
@@ -58,21 +56,20 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(securit
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# ANCHOR[AUTH_SCHEMAS]
class AuthRequest(BaseModel): class AuthRequest(BaseModel):
username: str username: str
password: str password: str
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
old_password: str old_password: str
new_password: str new_password: str
# ANCHOR[AUTH_ENDPOINTS]
@router.post("/login") @router.post("/login")
def login(req: AuthRequest): def login(req: AuthRequest):
username = req.username.strip().lower() username = req.username.strip().lower()
logging.info(f"===> Попытка входа для пользователя: {username}")
conn = get_db() conn = get_db()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,)) cursor.execute("SELECT id, username, password_hash, is_admin FROM users WHERE username = ?", (username,))
@@ -80,15 +77,14 @@ def login(req: AuthRequest):
conn.close() conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]): if not user or not pwd_context.verify(req.password, user["password_hash"]):
logging.warning(f"===> Ошибка: Неверный логин или пароль для {username}")
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль") raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh") is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin) 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} return {"status": "success", "token": token, "username": user["username"], "is_admin": is_admin}
@router.post("/change-password") @router.post("/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)): 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: 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.commit()
conn.close() conn.close()
logging.info(f"Пароль успешно изменен для пользователя ID: {current_user['id']}")
return {"status": "success", "message": "Пароль успешно изменен"} return {"status": "success", "message": "Пароль успешно изменен"}
+1 -1
View File
@@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from .auth import get_current_user 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.file_parser import extract_text_from_file
from llm.db_tools import db_set_session_state, db_get_session_state from llm.db_tools import db_set_session_state, db_get_session_state
+8 -7
View File
@@ -3,19 +3,20 @@
FILE: modules/web_api/routers/tasks.py FILE: modules/web_api/routers/tasks.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers 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 pydantic import BaseModel
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from routers.auth import get_current_user 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"]) router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
class TaskCreateRequest(BaseModel): class TaskCreateRequest(BaseModel):
title: str title: str
priority: Optional[str] = "MEDIUM" priority: Optional[str] = "MEDIUM"
@@ -23,6 +24,7 @@ class TaskCreateRequest(BaseModel):
due_date: Optional[str] = None due_date: Optional[str] = None
status: Optional[str] = "BACKLOG" status: Optional[str] = "BACKLOG"
class TaskUpdateRequest(BaseModel): class TaskUpdateRequest(BaseModel):
title: Optional[str] = None title: Optional[str] = None
priority: Optional[str] = None priority: Optional[str] = None
@@ -31,7 +33,6 @@ class TaskUpdateRequest(BaseModel):
def resolve_user_id(current_user: Dict[str, Any]) -> int: def resolve_user_id(current_user: Dict[str, Any]) -> int:
"""Извлекает корректный ID пользователя из JWT payload или ставит дефолтный 1."""
if not current_user: if not current_user:
return 1 return 1
return current_user.get("id") or current_user.get("user_id") or 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("") @router.get("")
async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)): async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
user_id = resolve_user_id(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("") @router.post("")
async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)): async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user) user_id = resolve_user_id(current_user)
res = db_add_task( res = add_task(
user_id=user_id, user_id=user_id,
module=req.module, module=req.module,
title=req.title, title=req.title,
@@ -62,7 +63,7 @@ async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(ge
@router.patch("/{task_id}") @router.patch("/{task_id}")
async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)): async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user) user_id = resolve_user_id(current_user)
res = db_update_task_details( res = update_task_details(
user_id=user_id, user_id=user_id,
task_id=task_id, task_id=task_id,
title=req.title, title=req.title,
+2116 -796
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -333,11 +333,6 @@ def print_chat_messages(session_id=None, limit=50):
def purge_chat_context(session_id=None, purge_all=False): def purge_chat_context(session_id=None, purge_all=False):
"""
Очистка контекста сообщений:
- По умолчанию: удаляет эфемерные сообщения, осиротевшие превью и сбрасывает стейты сессий.
- purge_all=True (--all): полностью очищает всю таблицу chat_messages и сбрасывает сессии.
"""
with get_connection() as conn: with get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -357,6 +352,8 @@ def purge_chat_context(session_id=None, purge_all=False):
DELETE FROM chat_messages DELETE FROM chat_messages
WHERE is_ephemeral = 1 WHERE is_ephemeral = 1
OR content LIKE '%Предпросмотр изменений%' OR content LIKE '%Предпросмотр изменений%'
OR content LIKE '%Актуальный системный промпт:%'
OR content LIKE '%1. РОЛЬ И ЗАДАЧИ АССИСТЕНТА%'
OR content LIKE '%Удален пункт:%' OR content LIKE '%Удален пункт:%'
OR content LIKE '%добавлен пункт:%' OR content LIKE '%добавлен пункт:%'
""" """
+27 -48
View File
@@ -3,70 +3,49 @@
FILE: services/scud_etl/anomaly_detector.py FILE: services/scud_etl/anomaly_detector.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl MODULE: services / scud_etl
ROLE: Автоматическое выявление аномалий и конфликтов между 1С:ЗУП и СКУД. ROLE: Детектирование истинных аномалий и конфликтов реестров.
(Приход удаленщиков и командированных в офис аномалией НЕ является).
AI-CONTEXT-ANCHORS:
- ANCHOR[ANOMALY_DETECTOR_CORE]: Проверка физического присутствия в отпуске и перемещений без входа.
=============================================================================== ===============================================================================
""" """
import logging
from typing import List, Dict, Any
import pandas as pd 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_registry_anomalies(df_merged: pd.DataFrame) -> List[Dict[str, Any]]:
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 = [] anomalies = []
kb_rules_text = " ".join(kb_rules).lower() if kb_rules else ""
for idx, row in merged_df.iterrows(): for _, row in df_merged.iterrows():
fio = row.get('Сотрудник', row.get('fio_clean', '')) fio = row.get("fio_clean") or row.get("Сотрудник", "")
fio_clean = row.get('fio_clean', '') start_day = str(row.get("Начало_дня", "")).strip()
is_present = row.get('Пришел', False) end_day = str(row.get("Конец_дня", "")).strip()
is_exc = row.get('is_excluded', False) reason = str(row.get("причина отсутствия", "")).strip()
reason_1c = str(row.get('Вид_отсутствия', '')).strip() reason_lower = reason.lower()
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 start_day != "Нет входа" and reason and reason != "nan":
# 1. Присутствие при официальном отсутствии # Удаленная работа и командировки разрешены для работы в офисе
if is_present and has_1c_reason: if not ("удален" in reason_lower or "дистанцион" in reason_lower or "командировк" in reason_lower or "поездк" in reason_lower):
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({ anomalies.append({
"type": "ФИЗИЧЕСКОЕ ПРИСУТСТВИЕ ПРИ ОФИЦИАЛЬНОМ ОТСУТСТВИИ",
"fio": fio, "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: # 2. Аномалия оборудования (есть выход без входа)
continue if start_day == "Нет входа" and end_day != "Нет выхода":
# 2. Перемещение внутри здания без отметки входа на КПП
if anom_flag == 'ANOMALY_NO_IN_HAS_ACTIVITY':
first_act = row.get('Первая_активность', '—')
anomalies.append({ anomalies.append({
"type": "АНОМАЛИЯ СКУД: ПЕРЕМЕЩЕНИЕ БЕЗ ВХОДА",
"fio": fio, "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 return anomalies
+93 -95
View File
@@ -3,112 +3,110 @@
FILE: services/scud_etl/merger.py FILE: services/scud_etl/merger.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl MODULE: services / scud_etl
ROLE: Агрегация проходов, сопоставление исключений и мердж таблиц 1С:ЗУП и СКУД. ROLE: Агрегация реестров СКУД и 1С, наложение исключений, нормализация кодов
подразделений и расчет сходящегося баланса присутствия.
AI-CONTEXT-ANCHORS:
- ANCHOR[MERGER_EXCEPTIONS]: Наложение флага исключений из exceptions.json.
- ANCHOR[MERGER_AGGREGATION]: Агрегация множественных проходов до уникального ФИО.
- ANCHOR[MERGER_BUILD_DATASET]: Сборка итогового датасета для сводок и отчетов.
=============================================================================== ===============================================================================
""" """
import os import logging
import json from typing import Dict, Any, List
import pandas as pd 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 merge_scud_and_1c(
def load_exceptions_config() -> dict: df_scud: pd.DataFrame,
"""Загружает exceptions.json из корня проекта.""" df_staff_1c: pd.DataFrame,
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")) df_absences_1c: pd.DataFrame
json_path = os.path.join(root_dir, "exceptions.json") ) -> pd.DataFrame:
if not os.path.exists(json_path): """
return {} Объединяет реестры СКУД и 1С:ЗУП:
try: - Применяет исключения (уборщики, ОВК и др.).
with open(json_path, 'r', encoding='utf-8') as f: - Устанавливает короткие аббревиатуры подразделений (КО, ОАН, РУК и др.).
return json.load(f) - Привязывает кадровые документы отсутствий из 1С.
except Exception: """
return {} # Получаем словарь синонимов
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: def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
"""Быстрая разметка флага is_excluded на основе exceptions.json.""" """
if df is None or df.empty or not exceptions_cfg: Расчет строго сходящегося баланса присутствия:
if df is not None: A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего)
df['is_excluded'] = False """
return df total_staff = len(df_merged)
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d] # 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис)
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p] came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k] working_in_office = df_merged[came_to_office_mask]
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f] 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(): # 3. Из непришедших выделяем удаленщиков (работают из дома)
fio_clean = row.get('fio_clean', '') reason_series = df_not_came["причина отсутствия"].astype(str).str.lower()
dep_1c = str(row.get('Подразделение', '')).lower() is_remote_mask = reason_series.str.contains("удален|дистанцион", regex=True, na=False)
dep_scud = str(row.get('department_scud', row.get('department', ''))).lower()
pos = str(row.get('Должность', '')).lower() remote_home = df_not_came[is_remote_mask]
remote_home_count = len(remote_home)
is_fio_exc = fio_clean in exc_fios # 4. Из оставшихся непришедших выделяем официальные отсутствия (отпуск, больничный, командировка и т.д.)
is_pos_exc = (pos in exact_pos) or any(k in pos for k in pos_kw if k) if pos else False df_remaining_absent = df_not_came[~is_remote_mask]
is_dep_exc = any(d in dep_1c or d in dep_scud for d in deps) if deps else False 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: # 5. Оставшиеся непришедшие без документов — истинно неизвестные
df.at[idx, 'is_excluded'] = True unknown = df_remaining_absent[~has_doc_mask]
unknown_count = len(unknown)
return df return {
"total_staff": total_staff,
"working_in_office_count": working_in_office_count,
# ANCHOR[MERGER_AGGREGATION] "remote_home_count": remote_home_count,
def aggregate_scud_by_employee(df: pd.DataFrame) -> pd.DataFrame: "official_absent_count": official_absent_count,
"""Агрегирует проходы СКУД по уникальным сотрудникам.""" "unknown_count": unknown_count,
if df is None or df.empty or 'fio_clean' not in df.columns: "unknown_list": unknown[["fio_clean", "Подразделение", "Должность"]].to_dict(orient="records") if not unknown.empty else []
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 {}
+46 -119
View File
@@ -3,137 +3,64 @@
FILE: services/scud_etl/pipeline.py FILE: services/scud_etl/pipeline.py
PROJECT: SCUD Orion AI (Unified Architecture) PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: services / scud_etl MODULE: services / scud_etl
ROLE: Оркестратор этапов контроллинга (Загрузка -> Сверка -> Отчеты -> SQLite). ROLE: Оркестрация выборки снапшотов из SQLite и загрузки кадровых файлов 1С.
AI-CONTEXT-ANCHORS:
- ANCHOR[PIPELINE_RUN_CONTROLLING]: Главная функция выполнения ETL-конвейера.
=============================================================================== ===============================================================================
""" """
import os import os
from datetime import datetime, timedelta import json
import logging
from typing import Optional
import pandas as pd import pandas as pd
from config import DATE_TODAY, DATE_YESTERDAY, OUTPUT_DIR from core.connection import get_connection
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 ( logger = logging.getLogger("SCUD_PIPELINE")
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 load_best_snapshot_for_date(date_str: str, prefer_final_y: bool = True) -> pd.DataFrame:
def run_controlling_pipeline(snapshot_param: str = None, skip_export: bool = False, debug: bool = False, has_today_1c: bool = True) -> None: """
"""Выполняет полный цикл сверки СКУД ⟷ 1С и сохранение результатов.""" Извлекает срез за дату из SQLite. Для отчета за вчера строго ищет
kb_rules = load_knowledge_base().get("rules", []) финальный вечерний срез Y (с зафиксированными выходами за 22:00:00).
exceptions_cfg = load_exceptions_config() """
static_reasons = load_static_reason_workers() 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. Определение дат целевого снапшота и предыдущей смены return pd.DataFrame()
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") 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 = pd.read_excel(staff_file) if os.path.exists(staff_file) else pd.DataFrame()
df_staff_yesterday, df_absent_yesterday = load_1c_data_smart(yesterday_date_str, use_db=True) df_absences = pd.read_excel(absences_file) if os.path.exists(absences_file) else pd.DataFrame()
df_staff_today, df_absent_today = load_1c_data_smart(target_date_str, use_db=True)
# 2. Этап 3: Обработка ВЧЕРА (Детальный отчет) return df_staff, df_absences
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} ПРОПУЩЕНО.")
+69
View File
@@ -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)} шт."
}
View File
+49
View File
@@ -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)