Перед тем, как исправлять выгрузку скуд с глупым Gemini-Lite
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56
-69
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
|
|
||||||
# ⭐️ Управление системным промптом (Превью)
|
|
||||||
if state_type == "PROMPT_PREVIEW":
|
|
||||||
state_data = session_state.get("data_json") or {}
|
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":
|
||||||
|
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
|
||||||
|
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
|
||||||
|
|
||||||
|
if is_confirm:
|
||||||
|
action = state_data.get("action", "MANUAL_EDIT")
|
||||||
draft_text = state_data.get("draft_text", "")
|
draft_text = state_data.get("draft_text", "")
|
||||||
action = state_data.get("action")
|
|
||||||
|
# Применение изменений
|
||||||
|
if action == "MANUAL_EDIT" and draft_text:
|
||||||
|
save_full_prompt_draft(draft_text, prompt_name="main_agent")
|
||||||
|
else:
|
||||||
sec_id = state_data.get("section_id")
|
sec_id = state_data.get("section_id")
|
||||||
itm_id = state_data.get("item_id")
|
itm_id = state_data.get("item_id")
|
||||||
content = state_data.get("content", "")
|
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")
|
||||||
|
|
||||||
if msg in ["подтверждаю", "подтвердить", "да", "сохранить", "применить", "ок", "хорошо"]:
|
# Закрытие сессии и зачистка
|
||||||
if action == "MANUAL_EDIT" or not action or sec_id is None:
|
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
|
||||||
if draft_text:
|
db_clear_session_state(session_id)
|
||||||
db_add_system_prompt("main_agent", draft_text)
|
|
||||||
else:
|
|
||||||
db_apply_prompt_node_action(action=action, section_id=sec_id, item_id=itm_id, content=content)
|
|
||||||
|
|
||||||
close_tool_session_and_cleanup(session_id, "PROMPT_APPLIED_SUCCESSFULLY")
|
tool_action = db_get_tool_action("db_confirm_prompt_preview")
|
||||||
reply_text = "✅ Изменения системного промпта успешно применены в базе данных."
|
reply = tool_action.get("success_template", "✅ Системный промпт успешно сохранен и применен в базе данных.") if tool_action else "✅ Системный промпт успешно сохранен и применен в базе данных."
|
||||||
save_tool_interaction(session_id, full_user_content, reply_text)
|
|
||||||
return reply_text, [], None
|
|
||||||
|
|
||||||
elif msg in ["отмена", "отменить", "отклонить"]:
|
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
|
||||||
close_tool_session_and_cleanup(session_id, "PROMPT_PREVIEW_CANCELLED")
|
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
|
||||||
reply_text = "❌ Изменения системного промпта отменены."
|
return reply, db_get_chat_history(session_id), None
|
||||||
save_dialog_interaction(session_id, full_user_content, reply_text)
|
|
||||||
return reply_text, [], 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":
|
elif state_type == "SNAPSHOT_DELETE_CONFIRM":
|
||||||
state_data = session_state.get("data_json", {})
|
is_confirm = "подтверждаю удаление снапшот" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
|
||||||
ids = state_data.get("snapshot_ids", [])
|
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
|
||||||
|
|
||||||
|
if is_confirm:
|
||||||
|
snap_ids = state_data.get("snapshot_ids", [])
|
||||||
query_date = state_data.get("query_date", "")
|
query_date = state_data.get("query_date", "")
|
||||||
|
|
||||||
if msg.startswith("подтверждаю удаление снапшотов"):
|
# Безопасное удаление через сервис
|
||||||
db_delete_snapshots(snapshot_ids=ids)
|
del_res = delete_snapshots_safely(snapshot_ids=snap_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, close_reason="SNAPSHOTS_DELETED")
|
||||||
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}
|
|
||||||
|
|
||||||
# Удаление задач
|
# Получаем свежий список за ту же дату
|
||||||
|
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":
|
elif state_type == "TASK_DELETE_CONFIRM":
|
||||||
task_id = session_state.get("data_json", {}).get("task_id")
|
is_confirm = "подтверждаю удаление задачи" in msg_lower or msg_lower in ["подтверждаю", "да", "удалить"]
|
||||||
if msg.startswith("подтверждаю удаление задачи"):
|
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
|
||||||
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 == "отмена":
|
if is_confirm:
|
||||||
close_tool_session_and_cleanup(session_id, "TASK_CANCELLED")
|
task_id = state_data.get("task_id")
|
||||||
return "Удаление отменено.", [], {"type": "TASK_INTERACTIVE_CARD", "tasks": db_get_tasks(1)}
|
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
|
||||||
@@ -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)
|
|
||||||
@@ -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}"}
|
|
||||||
@@ -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}'"}
|
|
||||||
@@ -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,
|
||||||
@@ -61,3 +56,41 @@ from .db.db_prompts import (
|
|||||||
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)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": "Пароль успешно изменен"}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+2113
-793
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -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 '%добавлен пункт:%'
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
def apply_exceptions_from_json(df: pd.DataFrame, exceptions_cfg: dict) -> pd.DataFrame:
|
# 1. Фильтрация системных исключений
|
||||||
"""Быстрая разметка флага is_excluded на основе exceptions.json."""
|
# Загружаем исключения из базы знаний или задаем системный фильтр
|
||||||
if df is None or df.empty or not exceptions_cfg:
|
excluded_depts = {"ОВК", "Отдел вневедомственного контроля", "Служба уборки", "Клининг"}
|
||||||
if df is not None:
|
excluded_positions = {"Уборщик", "Уборщица", "Дворник"}
|
||||||
df['is_excluded'] = False
|
|
||||||
return df
|
|
||||||
|
|
||||||
deps = [d.strip().lower() for d in exceptions_cfg.get("departments", []) if d]
|
if not df_res.empty:
|
||||||
exact_pos = [p.strip().lower() for p in exceptions_cfg.get("positions", []) if p]
|
if "Подразделение" in df_res.columns:
|
||||||
pos_kw = [k.strip().lower() for k in exceptions_cfg.get("position_keywords", []) if k]
|
df_res = df_res[~df_res["Подразделение"].astype(str).isin(excluded_depts)]
|
||||||
exc_fios = [normalize_fio(f) for f in exceptions_cfg.get("fio", []) if f]
|
if "Должность" in df_res.columns:
|
||||||
|
df_res = df_res[~df_res["Должность"].astype(str).isin(excluded_positions)]
|
||||||
|
|
||||||
df['is_excluded'] = False
|
# 2. Трансляция подразделений в короткие аббревиатуры СКУД
|
||||||
|
if "Подразделение" in df_res.columns:
|
||||||
for idx, row in df.iterrows():
|
df_res["Подразделение"] = df_res["Подразделение"].apply(
|
||||||
fio_clean = row.get('fio_clean', '')
|
lambda d: synonyms.get(d, d)
|
||||||
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()
|
# 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 load_static_reason_workers() -> dict:
|
def calculate_summary_metrics(df_merged: pd.DataFrame) -> Dict[str, Any]:
|
||||||
"""Загружает реестр удаленщиков из CSV."""
|
"""
|
||||||
static_path = os.path.join(DATA_DIR, "static_reason_workers.csv")
|
Расчет строго сходящегося баланса присутствия:
|
||||||
if not os.path.exists(static_path):
|
A (Итого на работе) + B (Удаленная работа) + C (Официально отсутствуют) + D (Неизвестно) = N (Всего)
|
||||||
return {}
|
"""
|
||||||
try:
|
total_staff = len(df_merged)
|
||||||
df_static = pd.read_csv(static_path, encoding='utf-8')
|
|
||||||
if 'fio' in df_static.columns and 'reason' in df_static.columns:
|
# 1. Все, кто физически пришел в офис по СКУД (включая удаленщиков, пришедших в офис)
|
||||||
df_static['fio_clean'] = df_static['fio'].apply(normalize_fio)
|
came_to_office_mask = df_merged["Начало_дня"].astype(str).str.strip().ne("Нет входа")
|
||||||
return dict(zip(df_static['fio_clean'], df_static['reason']))
|
working_in_office = df_merged[came_to_office_mask]
|
||||||
except Exception:
|
working_in_office_count = len(working_in_office)
|
||||||
pass
|
|
||||||
return {}
|
# 2. Все, кто сегодня НЕ пришел в офис
|
||||||
|
not_came_mask = ~came_to_office_mask
|
||||||
|
df_not_came = df_merged[not_came_mask]
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# 5. Оставшиеся непришедшие без документов — истинно неизвестные
|
||||||
|
unknown = df_remaining_absent[~has_doc_mask]
|
||||||
|
unknown_count = len(unknown)
|
||||||
|
|
||||||
|
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 []
|
||||||
|
}
|
||||||
+45
-118
@@ -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()
|
||||||
|
|
||||||
print("[2/5] Загрузка данных из СКУД, 1С:ЗУП, реестра причин и исключений...")
|
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)
|
||||||
|
|
||||||
# 1. Определение дат целевого снапшота и предыдущей смены
|
cursor.execute("""
|
||||||
if snapshot_param or skip_export:
|
SELECT raw_data_json
|
||||||
snap_to_use = snapshot_param or get_latest_snapshot_time()
|
FROM scud_snapshots
|
||||||
raw_scud_today_df = load_scud_from_db_by_snapshot(None, snapshot_param=snap_to_use)
|
WHERE snapshot_date = ? OR date_str = ?
|
||||||
if not raw_scud_today_df.empty and 'log_date' in raw_scud_today_df.columns:
|
ORDER BY id DESC LIMIT 1
|
||||||
target_date_str = str(raw_scud_today_df['log_date'].iloc[0])
|
""", (date_str, date_str))
|
||||||
else:
|
row = cursor.fetchone()
|
||||||
target_date_str = DATE_TODAY
|
if row and row["raw_data_json"]:
|
||||||
else:
|
data = json.loads(row["raw_data_json"])
|
||||||
target_date_str = DATE_TODAY
|
return pd.DataFrame(data)
|
||||||
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")
|
return pd.DataFrame()
|
||||||
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)
|
def load_1c_files_for_date(date_str: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||||
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)
|
Загружает файлы Штат_*.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"
|
||||||
|
|
||||||
# 2. Этап 3: Обработка ВЧЕРА (Детальный отчет)
|
df_staff = pd.read_excel(staff_file) if os.path.exists(staff_file) else pd.DataFrame()
|
||||||
print(f"[3/5] Обработка и построение детального отчета за ВЧЕРА ({yesterday_date_str})...")
|
df_absences = pd.read_excel(absences_file) if os.path.exists(absences_file) else pd.DataFrame()
|
||||||
|
|
||||||
if df_staff_yesterday is not None:
|
return df_staff, df_absences
|
||||||
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} ПРОПУЩЕНО.")
|
|
||||||
@@ -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)} шт."
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user