# 🌐 WEB API & FRONTEND CODE SNAPSHOT (AUTO-DISCOVERY)
## File: `./modules/web_api/llm/agent.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/agent.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm (Core Agent Coordinator)
ROLE: Нативный оркестратор диалога, диспетчер Function Calling,
передача активных срезов в контекст модели и терминальные вызовы.
===============================================================================
"""
import sys
import json
import logging
from typing import List, Dict, Any, Tuple, Optional
from .db.connection import get_db_connection
from .db_tools import (
db_get_active_system_prompt,
db_apply_prompt_node_action,
db_get_tool_action,
db_get_tasks,
db_update_task_details,
db_delete_task,
db_add_task,
db_tasks_edit,
db_export_tasks_markdown,
db_get_rules,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_get_snapshots,
db_delete_snapshots,
db_get_current_server_time,
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_get_stats,
db_get_anomalies,
db_get_reference
)
from .schemas import TOOLS_SCHEMA
from .core.calendar_utils import get_dynamic_calendar_context
from .core.tool_injector import clean_raw_tool_tags, clean_output, inject_tools_if_needed
from .core.ollama_client import call_ollama_chat
from .core.fast_path import handle_fast_path_intercept
from .core.context_manager import (
save_tool_interaction,
save_dialog_interaction,
mark_last_user_message_ephemeral,
close_tool_session_and_cleanup
)
logger = logging.getLogger("SCUD_AGENT")
logger.setLevel(logging.INFO)
logger.propagate = False
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
def process_chat_message(
user_id: int,
user_message: str,
file_context: str = "",
image_b64: Optional[str] = None,
chat_history: List[Dict[str, Any]] = None,
session_id: str = "web_session_main"
) -> Tuple[str, List[Dict[str, Any]], Optional[Dict[str, Any]]]:
"""
Главный конвейер обработки входящего сообщения чата на базе нативного Function Calling.
"""
logger.info(f"Получено сообщение от user_id={user_id}, session_id={session_id}: {user_message}")
session_state = db_get_session_state(session_id)
if not session_state:
db_purge_ephemeral_messages(session_id)
full_user_content = f"{user_message}\n\n[СОДЕРЖИМОЕ ПРИКРЕПЛЕННОГО ФАЙЛА]:\n{file_context}" if file_context else user_message
# 1. Быстрый перехват системных кнопок UI и терминальных действий без задержек LLM
fast_path_res = handle_fast_path_intercept(session_id, user_message, full_user_content, session_state)
if fast_path_res:
return fast_path_res
# 2. Фиксация сообщения пользователя
db_save_chat_message(session_id, "user", full_user_content, is_ephemeral=0)
db_history = db_get_chat_history(session_id, limit=20)
calendar_context = get_dynamic_calendar_context()
user_info = f"Пользователь ID={user_id}" if user_id != 0 else "Гость"
current_state_type = session_state.get("state_type") if session_state else None
state_data = session_state.get("data_json") or {} if session_state else {}
if not isinstance(state_data, dict):
state_data = {}
idle_turns = state_data.get("idle_turns", 0)
active_state_context = ""
if current_state_type == "PROMPT_PREVIEW":
active_state_context = "\n[ТЕКУЩИЙ РЕЖИМ: ПРЕДПРОСМОТР СИСТЕМНОГО ПРОМПТА]\n- Открыт предпросмотр изменений промпта.\n"
elif current_state_type == "SNAPSHOTS_VIEW":
active_date = state_data.get("query_date", "выбранную дату")
active_state_context = f"\n[ТЕКУЩИЙ РЕЖИМ: ПРОСМОТР СНАПШОТОВ СКУД]\n- Отображаются срезы за {active_date}.\n"
elif current_state_type == "SNAPSHOT_INSPECT":
# ⭐️ Защита от залипания: если вопрос бытовой или отвлеченный, выходим из жесткого режима инспекции
msg_l = user_message.lower().strip()
scud_terms = ["срез", "скуд", "вход", "выход", "здани", "присутств", "отсутств", "кто в", "кто сейчас", "1с", "зуп", "турникет", "карточк", "инспекци"]
if not any(t in msg_l for t in scud_terms) and len(msg_l.split()) <= 12:
db_clear_session_state(session_id)
current_state_type = None
active_state_context = ""
else:
snap_id = state_data.get("snapshot_id", "")
snap_date = state_data.get("log_date", "")
records = state_data.get("records", [])
lines = []
for r in records:
st = "Присутствовал" if r.get("is_present") else "Отсутствовал"
lines.append(f"- {r.get('fio')}: Отдел={r.get('department')}, Вход={r.get('time_in')}, Активность={r.get('first_activity')}, Выход={r.get('time_out')}, ВремяВЗдании={r.get('in_building')}, Статус={st}")
dump_str = "\n".join(lines)
active_state_context = (
f"\n[ТЕКУЩИЙ РЕЖИМ: АКТИВНА ИНСПЕКЦИЯ СРЕЗА СКУД #{snap_id} ЗА {snap_date}]\n"
f"Оператор сейчас изучает срез #{snap_id}. При любых вопросах о сотрудниках, фильтрации по входам, выходам, времени или отделам:\n"
f"1. ТЫ ОБЯЗАН ответить обычным текстом, проанализировав список ниже.\n"
f"2. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструменты (tools), такие как db_get_snapshots!\n"
f"Список сотрудников в активном срезе:\n{dump_str}\n"
)
# ⭐️ Промпт с поддержкой Topic Drift и защитой от переспросов по задачам
system_prompt_content = (
f"Ты — интеллектуальный ассистент SCUD Orion AI.\n"
f"Твоя основная роль — помощь оператору в кадровом аудите СКУД/1С, управлении задачами и настройками системы.\n\n"
f"СТРОГИЕ ПРАВИЛА:\n"
f"1. К оператору всегда обращайся на Вы.\n"
f"2. Для работы с данными системы ВСЕГДА вызывай соответствующие инструменты (tools):\n"
f" - Снапшоты и срезы СКУД -> db_get_snapshots\n"
f" - Удаление срезов -> db_delete_snapshots\n"
f" - Задачи и бэклог -> db_get_tasks, db_tasks_edit\n"
f" - Системный промпт -> db_get_system_prompt, db_prompt_node_edit\n"
f" - База знаний и регламенты -> db_get_rules\n"
f" - Аномалии СКУД/1С -> db_get_anomalies\n"
f" - Справка -> db_get_reference\n"
f"3. ЗАДАЧИ:\n"
f" - При любых запросах на просмотр задач (включая опечатки вроде 'змдачи', 'таски', 'дела', 'покажи задачи') — "
f"ТЫ ОБЯЗАН СРАЗУ ВЫЗВАТЬ db_get_tasks без лишних вопросов!\n"
f" - КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО переспрашивать у оператора фильтры, статус или категорию задач текстом! "
f"Интерактивная карточка в интерфейсе содержит все нужные фильтры.\n"
f"4. ПРАВИЛА КОМПАНИИ: Инструмент db_get_rules вызывай СТРОГО при вопросах о внутренних регламентах, "
"политиках или локальных правилах НАШЕЙ компании (например, 'покажи правила компании', 'какие у нас регламенты'). "
"При вопросах по Трудовому кодексу РФ (ТК РФ), законам РФ или общим юридическим нормам — отвечай подробно "
"из своих профессиональных знаний, не вызывая db_get_rules!"
f"5. Запрещено выдумывать факты и цифры по системе СКУД/1С без вызова инструментов.\n"
f"6. ОБЩИЙ ДИАЛОГ: На любые отвлечённые, познавательные, научные или бытовые вопросы "
f"(расстояние между планетами или городами, программирование, кругозор) отвечай полно, доброжелательно и интересно, не отказывая пользователю.\n\n"
f"[СИСТЕМНЫЙ КАЛЕНДАРЬ СЕРВЕРА]\n"
f"- Пользователь: {user_info}\n"
f"- {calendar_context}\n"
f"{active_state_context}"
)
user_msg_object = {"role": "user", "content": full_user_content}
try:
if image_b64:
user_msg_object["images"] = [image_b64]
messages = [
{"role": "system", "content": "Ты — строгий модуль OCR. Перепиши весь текст с изображения буква в букву."},
user_msg_object
]
msg = call_ollama_chat(messages, is_vision=True)
else:
clean_db_history = [dict(m) for m in db_history]
for m in clean_db_history:
m.pop("images", None)
messages = [{"role": "system", "content": system_prompt_content}] + clean_db_history + [user_msg_object]
msg = call_ollama_chat(messages, tools=TOOLS_SCHEMA, is_vision=False)
raw_text_reply = msg.get("content", "")
tool_calls = msg.get("tool_calls", [])
if not tool_calls:
tool_calls = inject_tools_if_needed(user_message, raw_text_reply, tool_calls)
if tool_calls:
logger.info(f"Ответ от Ollama получен. Tool calls: True (кол-во: {len(tool_calls)})")
tool = tool_calls[0]
fn_name = tool["function"]["name"]
fn_args = tool["function"].get("arguments", {})
if isinstance(fn_args, str):
try:
fn_args = json.loads(fn_args)
except Exception:
fn_args = {}
logger.info(f"Вызов функции (Tool): {fn_name} с аргументами: {fn_args}")
# Ротация контекста
close_tool_session_and_cleanup(session_id, close_reason=f"ACTIVATE_{fn_name}")
session_state = None
mark_last_user_message_ephemeral(session_id)
# 1. Задачи (Просмотр)
if fn_name == "db_get_tasks":
raw_tasks = db_get_tasks(user_id, status=fn_args.get("status"))
reply_text = "Вот интерактивный список ваших текущих задач:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
# 2. Единый диспетчер задач
elif fn_name in ["db_tasks_edit", "db_add_task", "db_update_task_details", "db_delete_task"]:
action = fn_args.get("action", "UPDATE").upper()
if fn_name == "db_add_task": action = "ADD"
elif fn_name == "db_delete_task": action = "DELETE"
elif fn_name == "db_update_task_status": action = "UPDATE"
if action == "DELETE":
task_id_raw = str(fn_args.get("task_id", "")).replace("#", "").replace("TASK-", "").strip()
db_set_session_state(session_id, "TASK_DELETE_CONFIRM", {"task_id": task_id_raw, "idle_turns": 0})
reply_text = f"Вы действительно хотите удалить задачу #{task_id_raw}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить #{task_id_raw}", "value": f"подтверждаю удаление задачи {task_id_raw}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
elif action == "EXPORT":
export_res = db_export_tasks_markdown(
user_id=user_id,
filename=fn_args.get("filename") or "ROADMAP.md",
status_filter=fn_args.get("status")
)
reply_text = export_res.get("message", "Отчет по задачам успешно экспортирован.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=0)
action_payload = None
if export_res.get("status") == "success":
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": export_res.get("filename"),
"download_url": export_res.get("download_url"),
"tasks_count": export_res.get("tasks_count")
}
return reply_text, db_get_chat_history(session_id), action_payload
else:
res = db_tasks_edit(
user_id=user_id,
action=action,
task_id=fn_args.get("task_id"),
title=fn_args.get("title"),
priority=fn_args.get("priority", "MEDIUM"),
status=fn_args.get("status"),
module=fn_args.get("module", "general"),
due_date=fn_args.get("due_date")
)
raw_tasks = db_get_tasks(user_id)
reply_text = res.get("message", "Операция над задачами выполнена.")
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "TASK_INTERACTIVE_CARD",
"tasks": raw_tasks
}
# 3. Системный промпт
elif fn_name == "db_get_system_prompt":
active_prompt = db_get_active_system_prompt()
reply_text = (
"📋 **АКТУАЛЬНЫЙ СИСТЕМНЫЙ ПРОМПТ ИЗ БАЗЫ ДАННЫХ:**\n\n"
f"```text\n{active_prompt}\n```\n\n"
"Вы можете добавить, отредактировать или удалить любой пункт."
)
db_set_session_state(session_id, "PROMPT_FOLLOWUP", {"idle_turns": 0})
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "PROMPT_VIEW",
"buttons": [
{"label": "✏️ Редактировать промпт", "value": "action:open_editor", "style": "primary"},
{"label": "База знаний", "value": "покажи правила компании", "style": "secondary"}
]
}
# 4. База знаний и правила компании (терминальный возврат)
elif fn_name == "db_get_rules":
rules_data = db_get_rules()
if isinstance(rules_data, dict) and "rules" in rules_data:
rules_list = rules_data["rules"]
elif isinstance(rules_data, list):
rules_list = rules_data
else:
rules_list = [str(rules_data)]
formatted_rules = "\n\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
reply_text = (
"📖 **БАЗА ЗНАНИЙ И ПРАВИЛА КОМПАНИИ (КАДРОВЫЙ АРБИТРАЖ):**\n\n"
f"```text\n{formatted_rules}\n```"
)
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "RULES_VIEW",
"buttons": [
{"label": "✏️ Редактировать правила", "value": "action:open_rules_editor", "style": "primary"},
{"label": "📋 Системный промпт", "value": "покажи системный промпт", "style": "secondary"}
]
}
# 5. Снапшоты СКУД
elif fn_name == "db_get_snapshots":
snapshots_res = db_get_snapshots(session_id=session_id, date_str=fn_args.get("date_str"), original_user_message=user_message)
query_date = snapshots_res.get("query_date", "выбранную дату")
reply_text = f"Реестр срезов СКУД за {query_date}:"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": snapshots_res
}
elif fn_name == "db_delete_snapshots":
raw_id = fn_args.get("snapshot_id") or fn_args.get("day_str")
raw_ids = fn_args.get("snapshot_ids") or []
is_confirmed = fn_args.get("confirmed", False)
if raw_id and not raw_ids:
if isinstance(raw_id, str) and "," in raw_id:
raw_ids = [s.strip() for s in raw_id.split(",")]
else:
raw_ids = [raw_id]
safe_ids = [s.strip() for s in raw_ids if s and not str(s).strip().startswith("Y")]
if not safe_ids:
reply_text = "⚠️ Итоговый срез Y защищен от удаления. Выберите дневные снапшоты."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), None
if not is_confirmed:
query_date = state_data.get("query_date", "")
db_set_session_state(session_id, "SNAPSHOT_DELETE_CONFIRM", {
"snapshot_ids": safe_ids,
"query_date": query_date,
"idle_turns": 0
})
ids_str = ", ".join(safe_ids)
reply_text = f"Вы действительно хотите удалить дневные снапшоты: {ids_str}?"
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOT_DELETE_CONFIRM",
"buttons": [
{"label": f"Удалить ({len(safe_ids)} шт.)", "value": f"подтверждаю удаление снапшотов {ids_str}", "style": "danger"},
{"label": "Отмена", "value": "отмена", "style": "secondary"}
]
}
else:
db_delete_snapshots(snapshot_ids=safe_ids)
query_date = state_data.get("query_date", "")
updated_snapshots_res = db_get_snapshots(session_id=session_id, date_str=query_date)
reply_text = f"✅ Успешно удалено снапшотов: {len(safe_ids)} шт."
db_save_chat_message(session_id, "assistant", reply_text, is_ephemeral=1)
return reply_text, db_get_chat_history(session_id), {
"type": "SNAPSHOTS_CARD",
"data": updated_snapshots_res
}
# 6. Прочие сервисные инструменты
elif fn_name == "db_get_current_server_time":
tool_result_content = json.dumps(db_get_current_server_time(), ensure_ascii=False)
elif fn_name == "db_get_stats":
tool_result_content = json.dumps(db_get_stats(), ensure_ascii=False)
elif fn_name == "db_get_anomalies":
tool_result_content = json.dumps(db_get_anomalies(limit=fn_args.get("limit", 100), date_str=fn_args.get("date_str")), ensure_ascii=False)
elif fn_name == "db_get_reference":
tool_result_content = json.dumps(db_get_reference(category=fn_args.get("category")), ensure_ascii=False)
else:
tool_result_content = "{}"
messages.append(msg)
messages.append({"role": "tool", "content": tool_result_content})
sec_msg = call_ollama_chat(messages, is_vision=False)
raw_content = sec_msg.get("content", "").strip().replace("**", "").replace("*", "")
final_content = clean_raw_tool_tags(clean_output(raw_content)) or "Запрос выполнен."
db_save_chat_message(session_id, "assistant", final_content, is_ephemeral=0)
return final_content, db_get_chat_history(session_id), None
# Свободный диалог (Topic Drift)
raw_str = msg.get("content", "").strip().replace("**", "").replace("*", "")
final_reply = clean_raw_tool_tags(clean_output(raw_str)) or "Запрос обработан."
db_save_chat_message(session_id, "assistant", final_reply, is_ephemeral=0)
return final_reply, db_get_chat_history(session_id), None
except Exception as ex:
logger.exception(f"Непредвиденная ошибка агента: {ex}")
error_reply = f"Внутренняя ошибка сервера: {ex}"
return error_reply, db_get_chat_history(session_id), None
```
## File: `./modules/web_api/llm/core/calendar_utils.py`
```py
import re
from datetime import datetime, timedelta
DAYS_RU = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"]
def parse_relative_date_ru(text: str) -> str:
now = datetime.now()
text_lower = text.lower() if text else ""
match = re.search(r'(\d{2}\.\d{2}\.\d{4})', text)
if match:
return match.group(1)
if "вчера" in text_lower:
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
elif "позавчера" in text_lower:
return (now - timedelta(days=2)).strftime("%d.%m.%Y")
elif "сегодня" in text_lower:
return now.strftime("%d.%m.%Y")
return (now - timedelta(days=1)).strftime("%d.%m.%Y")
def get_dynamic_calendar_context() -> str:
now = datetime.now()
current_wd = now.weekday()
lines = [
f"СЕГОДНЯ: {DAYS_RU[current_wd].upper()}, {now.strftime('%d.%m.%Y')} (время сервера: {now.strftime('%H:%M:%S')}).",
"\nСПРАВОЧНИК ДАТ ДЛЯ ОТВЕТОВ (БЕРИ ДАТЫ СТРОГО ОТСЮДА):",
f"• Сегодня: {now.strftime('%d.%m.%Y')} ({DAYS_RU[current_wd]})",
f"• Вчера: {(now - timedelta(days=1)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 1) % 7]})",
f"• Позавчера: {(now - timedelta(days=2)).strftime('%d.%m.%Y')} ({DAYS_RU[(current_wd - 2) % 7]})",
"\nПрошедшие дни недели:"
]
for days_back in range(1, 8):
dt = now - timedelta(days=days_back)
day_name = DAYS_RU[dt.weekday()]
label = f"Прошлый {day_name}" if dt.weekday() in [0, 1, 3, 6] else f"Прошлая {day_name}"
lines.append(f"• {label}: {dt.strftime('%d.%m.%Y')}")
return "\n".join(lines)
```
## File: `./modules/web_api/llm/core/context_manager.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/core/context_manager.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Интеллектуальный менеджер контекста: разделение служебных транзакций
инструментов и содержательного диалога с сохранением Topic Drift.
===============================================================================
"""
import logging
from typing import Dict, Any, Optional, List
from ..db.connection import get_db_connection
from ..db_tools import (
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_clear_session_state,
db_set_session_state
)
logger = logging.getLogger("CONTEXT_MANAGER")
def save_tool_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
"""
Сохраняет синхронную служебную пару инструмента (и вопрос, и ответ = 1).
При очистке удалятся оба сообщения, не оставляя сирот.
"""
db_save_chat_message(session_id, "user", user_content, is_ephemeral=1)
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=1)
def save_dialog_interaction(session_id: str, user_content: str, assistant_reply: str) -> None:
"""
Сохраняет содержательный диалог пользователя и ассистента (и вопрос, и ответ = 0).
Эти сообщения остаются в истории навсегда (включая Topic Drift).
"""
db_save_chat_message(session_id, "user", user_content, is_ephemeral=0)
db_save_chat_message(session_id, "assistant", assistant_reply, is_ephemeral=0)
def mark_last_user_message_ephemeral(session_id: str) -> None:
"""Помечает последнее сообщение пользователя как эфемерное при активации инструмента."""
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE chat_messages
SET is_ephemeral = 1
WHERE id = (SELECT MAX(id) FROM chat_messages WHERE session_id = ? AND role = 'user')
""", (session_id,))
conn.commit()
def close_tool_session_and_cleanup(session_id: str, close_reason: str = "COMPLETED") -> int:
"""
Закрывает сессию инструмента и удаляет ТОЛЬКО служебные карточки/команды.
Весь содержательный диалог сохраняется.
"""
db_clear_session_state(session_id)
deleted_count = db_purge_ephemeral_messages(session_id)
logger.info(f"[ContextManager] Сессия инструмента закрыта ({close_reason}). Очищено служебных сообщений: {deleted_count}")
return deleted_count
```
## File: `./modules/web_api/llm/core/fast_path.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/core/fast_path.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Мгновенный перехват UI-действий, инспекции срезов, экспорта и Diff-превью.
===============================================================================
"""
import difflib
import logging
from typing import Dict, Any, Tuple, Optional
from services.prompts.service import save_full_prompt_draft, apply_prompt_action, get_active_system_prompt
from services.knowledge.service import get_rules, add_rule
from services.tasks.service import get_tasks, delete_task, execute_task_action
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
from modules.web_api.llm.core.context_manager import close_tool_session_and_cleanup
logger = logging.getLogger("FAST_PATH")
def _generate_prompt_diff_html(baseline_text: str, draft_text: str) -> str:
base_lines = [line.rstrip() for line in baseline_text.strip().splitlines()]
draft_lines = [line.rstrip() for line in draft_text.strip().splitlines()]
matcher = difflib.SequenceMatcher(None, base_lines, draft_lines)
diff_html_lines = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == 'equal':
for line in base_lines[i1:i2]:
diff_html_lines.append(line)
elif tag == 'delete':
for line in base_lines[i1:i2]:
diff_html_lines.append(
f'{line} [УДАЛЕНИЕ]'
)
elif tag == 'insert':
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'{line}'
)
elif tag == 'replace':
for line in base_lines[i1:i2]:
diff_html_lines.append(
f'{line} [УДАЛЕНИЕ]'
)
for line in draft_lines[j1:j2]:
diff_html_lines.append(
f'{line}'
)
return "\n".join(diff_html_lines)
def handle_fast_path_intercept(
session_id: str,
user_message: str,
full_user_content: str,
session_state: Optional[Dict[str, Any]]
) -> Optional[Tuple[str, list, Optional[Dict[str, Any]]]]:
msg_raw = user_message.strip()
msg_lower = msg_raw.lower()
# -------------------------------------------------------------------------
# 0.0 ЭКСПОРТ ЗАДАЧ В ФАЙЛ (МГНОВЕННО, БЕЗ ЛИШНИХ ДИАЛОГОВ)
# -------------------------------------------------------------------------
if any(msg_lower.startswith(p) for p in ["экспортируй задачи", "экспорт задач", "выгрузи задачи", "скачать задачи"]):
filename = "ROADMAP.md"
if " в " in msg_lower:
parts = msg_raw.split(" в ", 1)[1].strip().split()
if parts and ("." in parts[0] or parts[0].endswith("md")):
filename = parts[0]
res = execute_task_action(user_id=1, action="EXPORT", filename=filename)
reply = res.get("message", f"Отчет по задачам сформирован в `{filename}`.")
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=0)
action_payload = {
"type": "FILE_DOWNLOAD_CARD",
"filename": res.get("filename", filename),
"download_url": res.get("download_url", "#"),
"tasks_count": res.get("tasks_count", 0)
}
return reply, db_get_chat_history(session_id), action_payload
# -------------------------------------------------------------------------
# 0.1 ИНСПЕКЦИЯ КОНКРЕТНОГО СРЕЗА СКУД (ПО ID СНАПШОТА)
# -------------------------------------------------------------------------
if msg_lower.startswith("покажи срез ") or msg_lower.startswith("инспекция среза "):
target_snap_id = msg_raw.split()[-1].replace("#", "").strip()
from core.database import load_scud_from_db_by_snapshot
df_snap = load_scud_from_db_by_snapshot(date_str="", snapshot_param=target_snap_id)
if df_snap is None or df_snap.empty:
reply = f"⚠️ Срез СКУД `#{target_snap_id}` не найден в базе данных."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), None
total_cnt = len(df_snap)
present_cnt = len(df_snap[df_snap['Пришел'] == True]) if 'Пришел' in df_snap.columns else 0
absent_cnt = total_cnt - present_cnt
snap_time = df_snap['snapshot_time'].iloc[0] if 'snapshot_time' in df_snap.columns else '—'
log_date = df_snap['log_date'].iloc[0] if 'log_date' in df_snap.columns else '—'
rows_list = []
for _, r in df_snap.iterrows():
rows_list.append({
"fio": r.get('Сотрудник', r.get('fio', '')),
"department": r.get('Подразделение', r.get('department_scud', '—')),
"time_in": r.get('Начало_дня', 'Нет входа'),
"first_activity": r.get('Первая_активность', '—'),
"time_out": r.get('Конец_дня', 'Нет выхода'),
"in_building": r.get('Находился_в_здании', '00:00'),
"is_present": bool(r.get('Пришел', False)),
"anomaly": r.get('anomaly_flag', 'NONE')
})
# Фиксация среза в памяти сессии для последующих вопросов к LLM
db_set_session_state(session_id, "SNAPSHOT_INSPECT", {
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"records": rows_list,
"idle_turns": 0
})
reply = f"🔍 **Инспекция среза #{target_snap_id}** (Дата: {log_date}, Время: {snap_time}). Всего записей: {total_cnt} (Присутствовали: {present_cnt}, Отсутствовали: {absent_cnt})."
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "SNAPSHOT_INSPECT_CARD",
"snapshot_id": target_snap_id,
"log_date": log_date,
"snapshot_time": snap_time,
"total_count": total_cnt,
"present_count": present_cnt,
"absent_count": absent_cnt,
"records": rows_list
}
# -------------------------------------------------------------------------
# 0.2 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРОМПТА
# -------------------------------------------------------------------------
if msg_lower in ["action:open_editor", "редактировать промпт", "открыть редактор"]:
active_prompt = get_active_system_prompt()
state_data = session_state.get("data_json") if session_state else {}
draft_text = state_data.get("draft_text") if isinstance(state_data, dict) and state_data.get("draft_text") else active_prompt
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": draft_text,
"action": "MANUAL_EDIT",
"idle_turns": 0
})
reply = "✏️ Внесите необходимые изменения в текст промпта и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "PROMPT_EDITOR",
"raw_draft": draft_text,
"baseline_prompt": active_prompt
}
# -------------------------------------------------------------------------
# 0.3 ОТКРЫТИЕ ИНЛАЙН-РЕДАКТОРА ПРАВИЛ
# -------------------------------------------------------------------------
if msg_lower in ["action:open_rules_editor", "редактировать правила", "изменить правила"]:
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
raw_rules_text = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": raw_rules_text,
"action": "MANUAL_EDIT_RULES",
"idle_turns": 0
})
reply = "✏️ Редактор базы знаний и правил компании. Внесите изменения и нажмите «Показать превью изменений»:"
db_save_chat_message(session_id, "assistant", reply, is_ephemeral=1)
return reply, db_get_chat_history(session_id), {
"type": "RULES_EDITOR",
"raw_draft": raw_rules_text,
"baseline_prompt": raw_rules_text
}
# -------------------------------------------------------------------------
# 0.4 ПОСТУПЛЕНИЕ ДРАФТА ПРОМПТА -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_prompt:::"):
new_draft_content = msg_raw.replace("action:save_draft_prompt:::", "").strip()
active_prompt = get_active_system_prompt()
diff_html = _generate_prompt_diff_html(active_prompt, new_draft_content)
db_set_session_state(session_id, "PROMPT_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT",
"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": new_draft_content,
"baseline_prompt": active_prompt,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_editor", "style": "secondary"}
]
}
# -------------------------------------------------------------------------
# 0.5 ПОСТУПЛЕНИЕ ДРАФТА ПРАВИЛ -> DIFF-ПРЕВЬЮ
# -------------------------------------------------------------------------
if msg_raw.startswith("action:save_draft_rules:::"):
new_draft_content = msg_raw.replace("action:save_draft_rules:::", "").strip()
rules_data = get_rules()
rules_list = rules_data.get("rules", []) if isinstance(rules_data, dict) else (rules_data if isinstance(rules_data, list) else [str(rules_data)])
baseline_rules = "\n".join([f"{i+1}. {r.get('rule_text', r) if isinstance(r, dict) else r}" for i, r in enumerate(rules_list)])
diff_html = _generate_prompt_diff_html(baseline_rules, new_draft_content)
db_set_session_state(session_id, "RULES_PREVIEW", {
"draft_text": new_draft_content,
"action": "MANUAL_EDIT_RULES",
"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": new_draft_content,
"baseline_prompt": baseline_rules,
"diff_html": diff_html,
"buttons": [
{"label": "Подтвердить", "value": "подтверждаю сохранение правил", "style": "primary"},
{"label": "Отменить", "value": "отмена", "style": "danger"},
{"label": "✏️ Редактировать", "value": "action:open_rules_editor", "style": "secondary"}
]
}
if not session_state:
return None
state_type = session_state.get("state_type")
state_data = session_state.get("data_json") or {}
if not isinstance(state_data, dict):
state_data = {}
# -------------------------------------------------------------------------
# 1. ПОДТВЕРЖДЕНИЕ ПРОМПТА
# -------------------------------------------------------------------------
if state_type == "PROMPT_PREVIEW":
is_confirm = msg_lower in ["подтверждаю", "да", "сохраняй", "применить", "ок", "подтвердить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет", "отклонить"]
if is_confirm:
draft_text = state_data.get("draft_text", "")
if draft_text:
save_full_prompt_draft(draft_text, prompt_name="main_agent")
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_APPLIED_SUCCESSFULLY")
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
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="PROMPT_EDIT_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
# -------------------------------------------------------------------------
# 2. ПОДТВЕРЖДЕНИЕ ПРАВИЛ
# -------------------------------------------------------------------------
elif state_type == "RULES_PREVIEW":
is_confirm = "сохранение правил" in msg_lower or msg_lower in ["подтверждаю", "да", "сохраняй", "применить"]
is_cancel = msg_lower in ["отмена", "отменить", "нет"]
if is_confirm:
draft_text = state_data.get("draft_text", "")
lines = [l.strip() for l in draft_text.splitlines() if l.strip()]
for line in lines:
clean_line = line
if line[0].isdigit() and "." in line[:5]:
clean_line = line.split(".", 1)[1].strip()
add_rule(clean_line)
close_tool_session_and_cleanup(session_id, close_reason="RULES_SAVED_SUCCESS")
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
elif is_cancel:
close_tool_session_and_cleanup(session_id, close_reason="RULES_EDIT_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
```
## File: `./modules/web_api/llm/core/ollama_client.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/core/ollama_client.py
ROLE: Транспортный клиент HTTP взаимодействия с локальным API Ollama.
===============================================================================
"""
# ANCHOR[OLLAMA_CLIENT_IMPORTS]
import json
import urllib.request
import urllib.error
import logging
from typing import Dict, Any, Optional
logger = logging.getLogger("OLLAMA_CLIENT")
OLLAMA_URL = "http://10.121.17.227:11434/api/chat"
TEXT_MODEL = "qwen2.5:14b"
VISION_MODEL = "qwen2.5vl:7b-q8_0"
LLM_OPTIONS = {
"num_predict": 8192,
"num_ctx": 8192,
"temperature": 0.0,
"repeat_penalty": 1.0,
"presence_penalty": 0.0,
"top_p": 0.9
}
# ANCHOR[OLLAMA_REQUEST_DISPATCHER]
def call_ollama_chat(
messages: list,
tools: Optional[list] = None,
is_vision: bool = False,
timeout: int = 120
) -> Dict[str, Any]:
"""
Отправляет подготовленный массив сообщений в API Ollama.
Возвращает разобранный словарь сообщения ответа или генерирует исключение.
"""
model_name = VISION_MODEL if is_vision else TEXT_MODEL
payload = {
"model": model_name,
"messages": messages,
"stream": False,
"options": LLM_OPTIONS
}
if tools and not is_vision:
payload["tools"] = tools
req = urllib.request.Request(
OLLAMA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as response:
res_data = json.loads(response.read().decode("utf-8"))
return res_data.get("message", {})
```
## File: `./modules/web_api/llm/core/tool_injector.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/core/tool_injector.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / core
ROLE: Базовая санитарная очистка вывода и тегов инструментов.
===============================================================================
"""
import re
import logging
from typing import List, Dict, Any
logger = logging.getLogger("TOOL_INJECTOR")
def clean_raw_tool_tags(text: str) -> str:
"""Удаляет сырые теги вызова инструментов и системный шум."""
if not text:
return ""
cleaned = re.sub(r'.*?', '', text, flags=re.DOTALL)
cleaned = re.sub(r'<\|.*?\|>', '', cleaned)
return cleaned.strip()
def clean_output(text: str) -> str:
"""Очищает маркеры форматирования."""
return text.strip() if text else ""
def inject_tools_if_needed(user_message: str, raw_reply: str, existing_tool_calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Модель управляется через TOOLS_SCHEMA и системный контекст.
Любые синтетические перехваты текста регулярными выражениями отключены согласно ROADMAP.
"""
return existing_tool_calls
```
## File: `./modules/web_api/llm/db/connection.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/db/connection.py
ROLE: Реэкспорт единого подключения к БД из core.connection.
===============================================================================
"""
from core.connection import get_connection as get_db_connection, DB_PATH
```
## File: `./modules/web_api/llm/db/db_chat.py`
```py
"""
FILE: modules/web_api/llm/db/db_chat.py
ROLE: Управление историей сообщений и очисткой эфемерного контекста.
"""
from typing import List, Dict, Any
from .connection import get_db_connection
def db_save_chat_message(session_id: str, role: str, content: str, is_ephemeral: int = 0) -> None:
"""Сохраняет сообщение в БД (is_ephemeral=1 для временных служебных шагов, 0 для постоянных)."""
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("""
INSERT INTO chat_messages (session_id, role, content, is_ephemeral)
VALUES (?, ?, ?, ?)
""", (session_id, role, content, is_ephemeral))
conn.commit()
conn.close()
def db_get_chat_history(session_id: str, limit: int = 50) -> list:
"""Возвращает историю сообщений диалога для сессии."""
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT role, content, is_ephemeral, created_at
FROM chat_messages
WHERE session_id = ?
ORDER BY id DESC
LIMIT ?
""", (session_id, limit))
rows = cursor.fetchall()
return [{"role": r["role"], "content": r["content"], "is_ephemeral": r["is_ephemeral"]} for r in reversed(rows)]
def db_purge_ephemeral_messages(session_id: str) -> int:
"""
Физически удаляет все временные служебные сообщения выбранной сессии
после завершения сценария работы с инструментом.
"""
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("DELETE FROM chat_messages WHERE session_id = ? AND is_ephemeral = 1", (session_id,))
deleted = cursor.rowcount
conn.commit()
conn.close()
return deleted
def db_clear_chat_history(session_id: str) -> None:
"""Полная очистка всех сообщений сессии."""
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
def db_clear_all_chat_context(session_id: str = None, purge_all: bool = False) -> int:
"""Удаляет сообщения чата и стейты сессий."""
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
if purge_all:
if session_id:
cursor.execute("DELETE FROM chat_messages WHERE session_id = ?", (session_id,))
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
else:
cursor.execute("DELETE FROM chat_messages")
cursor.execute("DELETE FROM session_states")
cnt = cursor.rowcount
conn.commit()
return cnt
query = """
DELETE FROM chat_messages
WHERE is_ephemeral = 1
OR content LIKE '%Предпросмотр изменений%'
OR content LIKE '%Удален пункт:%'
OR content LIKE '%добавлен пункт:%'
"""
if session_id:
cursor.execute(query + " AND session_id = ?", (session_id,))
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
else:
cursor.execute(query)
cursor.execute("DELETE FROM session_states")
cnt = cursor.rowcount
conn.commit()
return cnt
```
## File: `./modules/web_api/llm/db/db_prompts.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/db/db_prompts.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm / db
ROLE: Реляционное управление системным промптом (таблица system_prompt_nodes),
базой знаний, реестром действий и сессионными стейтами.
===============================================================================
"""
import re
import json
import logging
from typing import List, Dict, Any, Optional
from .connection import get_db_connection
logger = logging.getLogger("DB_PROMPTS")
def init_prompt_nodes_table():
"""Создает реляционную таблицу узлов промпта и заполняет базовыми данными."""
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS system_prompt_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prompt_name TEXT DEFAULT 'main_agent',
section_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
content TEXT NOT NULL,
is_active INTEGER DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(prompt_name, section_id, item_id)
);
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prompt_nodes ON system_prompt_nodes(prompt_name, section_id, item_id);")
cursor.execute("SELECT COUNT(*) FROM system_prompt_nodes WHERE prompt_name = 'main_agent'")
if cursor.fetchone()[0] == 0:
seed_nodes = [
# Раздел 1
(1, 0, "РОЛЬ И ЗАДАЧИ АССИСТЕНТА"),
(1, 1, "Управление бэклогом задач проекта (через db_get_tasks, db_add_task, db_update_task_status, db_delete_task)."),
(1, 2, "Консультация по внутренним регламентам компании (через db_get_rules). Вопросы по ТК РФ и законам поясняй напрямую."),
(1, 3, "Предоставление справки о возможностях и примерах команд (СТРОГО через db_get_reference)."),
(1, 4, "Просмотр аномалий СКУД ⟷ 1С (СТРОГО через db_get_anomalies)."),
(1, 5, "Поддержка диалога с операторами и администраторами системы."),
(1, 7, "Работа с логами, снапшотами и срезами СКУД (через db_get_snapshots)."),
# Раздел 2
(2, 0, "ПРАВИЛА ВЫЗОВА ИНСТРУМЕНТОВ И ДАТ"),
(2, 1, "ОБЯЗАТЕЛЬНЫЙ ПРЕВЬЮ-МЕРДЖ: Категорически ЗАПРЕЩЕНО изменять промпт напрямую! При ЛЮБОМ запросе пользователя на изменение системного промпта Ты ОБЯЗАН вызвать инструмент db_prompt_node_edit."),
(2, 2, "БЕЗУСЛОВНОЕ ПОДТВЕРЖДЕНИЕ ПРЕВЬЮ: Инструмент db_confirm_prompt_preview вызывается СТРОГО после того, как пользователь напишет 'подтверждаю', 'да', 'сохраняй'."),
(2, 3, "ПОДТВЕРЖДЕНИЕ УДАЛЕНИЯ ЗАДАЧ: При запросе на удаление задач КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО сразу вызывать db_delete_task! Ты ОБЯЗАН сначала спросить пользователя подтверждение."),
(2, 4, "ИСПОЛЬЗОВАНИЕ КАЛЕНДАРЯ: При любых вопросах про даты БЕРИ ТОЧНУЮ ДАТУ ИЗ [СИСТЕМНОГО КАЛЕНДАРЯ СЕРВЕРА] В НАЧАЛЕ КОНТЕКСТА."),
(2, 5, "СТРОГИЙ ВЫЗОВ АНОМАЛИЙ: При запросах аномалий или нарушений Ты ОБЯЗАН вызвать инструмент db_get_anomalies."),
(2, 6, "СТРОГИЙ ВЫЗОВ СПРАВОЧНИКА: При запросах о возможностях, примерах запросов или командах Ты ОБЯЗАН вызывать db_get_reference."),
(2, 7, "Запрещено писать названия функций или код вызова текстом на экран."),
(2, 8, "СТРОГИЙ ВЫЗОВ СНАПШОТОВ: При ЛЮБЫХ запросах про снапшоты, срезы или логи СКУД за дату/день недели Ты ОБЯЗАН вызывать инструмент db_get_snapshots."),
# Раздел 3
(3, 0, "ПРАВИЛА СТИЛЯ"),
(3, 1, "Никогда не начинай ответ со склеек или слов-паразитов."),
(3, 2, "Отвечай в чистом текстовом формате (plain text) без спецсимволов или ###, если прямо не попросили."),
(3, 3, "Сохраняй инженерный, лаконичный и профессиональный стиль.")
]
cursor.executemany("""
INSERT OR IGNORE INTO system_prompt_nodes (prompt_name, section_id, item_id, content)
VALUES ('main_agent', ?, ?, ?)
""", seed_nodes)
conn.commit()
def db_get_active_system_prompt(prompt_name: str = "main_agent") -> str:
"""Собирает структурированный текст промпта из реляционной таблицы узлов."""
init_prompt_nodes_table()
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT section_id, item_id, content
FROM system_prompt_nodes
WHERE prompt_name = ? AND is_active = 1
ORDER BY section_id ASC, item_id ASC
""", (prompt_name,))
rows = cursor.fetchall()
if not rows:
return "Ты — ИИ-ассистент SCUD Orion AI."
lines = []
current_section = None
for sec_id, itm_id, content in rows:
if itm_id == 0:
if current_section is not None:
lines.append("")
lines.append(f"{sec_id}. {content}")
current_section = sec_id
else:
lines.append(f" {sec_id}.{itm_id}. {content}")
return "\n".join(lines)
def db_apply_prompt_node_action(action: str, section_id: int, item_id: int, content: str = "", prompt_name: str = "main_agent"):
"""Прямое добавление, изменение или удаление узла в БД."""
init_prompt_nodes_table()
with get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
action_clean = action.upper()
if action_clean in ["ADD", "UPDATE"]:
cursor.execute("""
INSERT INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active, updated_at)
VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(prompt_name, section_id, item_id) DO UPDATE SET
content = excluded.content,
is_active = 1,
updated_at = CURRENT_TIMESTAMP
""", (prompt_name, section_id, item_id, content))
elif action_clean == "DELETE":
cursor.execute("""
DELETE FROM system_prompt_nodes
WHERE prompt_name = ? AND section_id = ? AND item_id = ?
""", (prompt_name, section_id, item_id))
conn.commit()
def db_get_tool_action(tool_name: str) -> Optional[Dict[str, Any]]:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("""
SELECT tool_name, category, bypass_llm, success_template,
follow_up_question, action_type, buttons_json, is_ephemeral
FROM tool_action_registry
WHERE tool_name = ? AND is_active = 1
""", (tool_name,))
row = cursor.fetchone()
conn.close()
if row:
res = dict(row)
res["buttons"] = json.loads(res["buttons_json"]) if res.get("buttons_json") else []
return res
return None
def db_get_rules() -> List[Dict[str, Any]]:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("SELECT id, rule_text FROM ai_knowledge_base ORDER BY id ASC")
rows = cursor.fetchall()
conn.close()
return [dict(r) for r in rows]
def db_set_session_state(session_id: str, state_type: str, data: Any) -> None:
"""Сохраняет состояние сессии в SQLite."""
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
payload_str = json.dumps(data, ensure_ascii=False) if isinstance(data, (dict, list)) else (str(data) if data is not None else "")
cursor.execute("""
INSERT INTO session_states (session_id, state_type, pending_data, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(session_id) DO UPDATE SET
state_type = excluded.state_type,
pending_data = excluded.pending_data,
updated_at = CURRENT_TIMESTAMP
""", (session_id, state_type, payload_str))
conn.commit()
conn.close()
def db_get_session_state(session_id: str) -> Optional[Dict[str, Any]]:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("SELECT session_id, state_type, pending_data, updated_at FROM session_states WHERE session_id = ?", (session_id,))
row = cursor.fetchone()
conn.close()
if not row:
return None
res = dict(row)
raw_data = res.get("pending_data") or ""
try:
res["data_json"] = json.loads(raw_data) if raw_data.strip().startswith(("{", "[")) else None
except Exception:
res["data_json"] = None
return res
def db_clear_session_state(session_id: str) -> None:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
cursor.execute("DELETE FROM session_states WHERE session_id = ?", (session_id,))
conn.commit()
conn.close()
def db_get_stats() -> Dict[str, Any]:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
tables = ['scud_logs', 'zup_staff', 'zup_absences', 'anomalies_history', 'ai_knowledge_base', 'system_prompt_nodes', 'session_states', 'tasks']
stats = {}
for t in tables:
try:
cursor.execute(f"SELECT COUNT(*) FROM {t}")
stats[t] = cursor.fetchone()[0]
except Exception:
stats[t] = 0
conn.close()
return {"status": "success", "tables_stats": stats}
def db_get_anomalies(limit: int = 100, date_str: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
query = "SELECT anomaly_date, fio, anomaly_type, details FROM anomalies_history"
params = []
if date_str:
query += " WHERE anomaly_date = ?"
params.append(date_str)
query += " ORDER BY id DESC LIMIT ?"
params.append(limit)
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return {"status": "success", "count": len(rows), "anomalies": [dict(r) for r in rows]}
def db_get_reference(category: Optional[str] = None) -> Dict[str, Any]:
conn = get_db_connection(row_factory=True)
cursor = conn.cursor()
query = "SELECT category, title, example_prompt, description FROM system_reference"
params = []
if category:
query += " WHERE category = ?"
params.append(category)
query += " ORDER BY id ASC"
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return {"status": "success", "count": len(rows), "reference_items": [dict(r) for r in rows]}
def db_add_system_prompt(name: str, prompt_text: str) -> Dict[str, Any]:
"""Надежный парсер: восстанавливает разделы и пункты с авто-выравниванием отступов."""
try:
init_prompt_nodes_table()
with get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM system_prompt_nodes WHERE prompt_name = ?", (name,))
current_sec = 1
current_itm = 0
for raw_line in prompt_text.splitlines():
clean_line = re.sub(r'<[^>]+>', '', raw_line).strip()
if not clean_line:
continue
# 1. Проверяем подпункт (например: "1.8. Текст", "1.8 Текст", "1. 8 Текст")
sub_match = re.match(r'^(\d+)[\.\s]+(\d+)[\.\s\:\-]+(.*)$', clean_line)
# 2. Проверяем заголовок раздела (например: "1. РОЛЬ И ЗАДАЧИ", "1 РОЛЬ И ЗАДАЧИ")
sec_match = re.match(r'^(\d+)[\.\s\:\-]+(.*)$', clean_line)
if sub_match:
current_sec = int(sub_match.group(1))
current_itm = int(sub_match.group(2))
content = sub_match.group(3).strip()
elif sec_match and not any(c.islower() for c in sec_match.group(2)[:15]):
# Заголовок раздела (обычно капсом)
current_sec = int(sec_match.group(1))
current_itm = 0
content = sec_match.group(2).strip()
else:
current_itm += 1
content = clean_line
cursor.execute("""
INSERT OR REPLACE INTO system_prompt_nodes (prompt_name, section_id, item_id, content, is_active)
VALUES (?, ?, ?, ?, 1)
""", (name, current_sec, current_itm, content))
conn.commit()
return {"status": "success", "message": "Системный промпт успешно сохранен по узлам"}
except Exception as e:
logger.error(f"Ошибка при разборе промпта в узлы: {e}")
return {"status": "error", "error": str(e)}
```
## File: `./modules/web_api/llm/db_tools.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/db_tools.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
ROLE: Фасадная точка доступа к доменным сервисам (Domain Facade).
AI-CONTEXT-ANCHORS:
- ANCHOR[FACADE_EXPORTS]: Экспорт методов предметных сервисов для LLM и API.
===============================================================================
"""
# ANCHOR[FACADE_EXPORTS]
from typing import Optional, List, Dict, Any
from core.connection import DB_PATH, get_connection as get_db_connection
# Домен: Задачи
from services.tasks.service import (
get_tasks as db_get_tasks,
add_task as db_add_task,
update_task_details as db_update_task_details,
delete_task as db_delete_task,
execute_task_action as db_tasks_edit
)
# Защитный алиас для обратной совместимости
db_update_task_status = db_update_task_details
from services.tasks.exporter import export_tasks_to_markdown as db_export_tasks_markdown
from services.tasks.repository import normalize_task_id
# Домен: Системный промпт
from services.prompts.service import (
get_active_system_prompt as db_get_active_system_prompt,
apply_prompt_action as db_apply_prompt_node_action,
save_full_prompt_draft,
create_prompt_preview
)
# Домен: База знаний
from services.knowledge.service import (
get_rules as db_get_rules,
add_rule as db_add_rule
)
# Чат, сессии, статистика
from .db.db_chat import (
db_save_chat_message,
db_get_chat_history,
db_purge_ephemeral_messages,
db_clear_chat_history
)
from .db.db_prompts import (
db_get_tool_action,
db_set_session_state,
db_get_session_state,
db_clear_session_state,
db_get_stats,
db_get_anomalies,
db_get_reference
)
from .core.calendar_utils import get_dynamic_calendar_context as db_get_current_server_time
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)
```
## File: `./modules/web_api/llm/file_parser.py`
```py
import base64
import os
import subprocess
import logging
import pandas as pd
logger = logging.getLogger("FILE_PARSER")
def extract_text_from_file(file_bytes: bytes, filename: str) -> dict:
ext = os.path.splitext(filename)[1].lower()
temp_filepath = f"/tmp/upload_{os.getpid()}_{filename}"
with open(temp_filepath, "wb") as f:
f.write(file_bytes)
try:
# 1. ИЗОБРАЖЕНИЯ (.png, .jpg, .jpeg, .bmp, .webp) -> Кодируем в Base64 для Vision LLM
if ext in ['.png', '.jpg', '.jpeg', '.bmp', '.webp']:
b64_str = base64.b64encode(file_bytes).decode('utf-8')
return {
"text": f"[ПРИКРЕПЛЕНО ИЗОБРАЖЕНИЕ: {filename}]",
"image_b64": b64_str
}
# 2. PDF ДОКУМЕНТЫ (Конвертируем 1-ю страницу в картинку для Vision LLM)
elif ext == '.pdf':
cmd = ['pdftotext', temp_filepath, '-']
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
pdf_text = res.stdout.strip()
img_prefix = f"/tmp/pdf_preview_{os.getpid()}"
subprocess.run(['pdftoppm', '-png', '-r', '200', '-f', '1', '-l', '1', temp_filepath, img_prefix], check=True)
page_png = f"{img_prefix}-1.png"
b64_str = None
if os.path.exists(page_png):
with open(page_png, "rb") as pf:
b64_str = base64.b64encode(pf.read()).decode('utf-8')
os.remove(page_png)
context_text = f"[ПРИКРЕПЛЕН ДОКУМЕНТ PDF: {filename}]"
if pdf_text:
context_text += f"\n\n[ЭЛЕКТРОННЫЙ ТЕКСТОВЫЙ СЛОЙ PDF]:\n{pdf_text}"
return {
"text": context_text,
"image_b64": b64_str
}
# 3. ЭЛЕКТРОННЫЕ ТАБЛИЦЫ (.xlsx, .xls, .csv)
elif ext in ['.xlsx', '.xls', '.csv']:
if ext == '.csv':
df = pd.read_csv(temp_filepath)
else:
df = pd.read_excel(temp_filepath)
total_rows = len(df)
df_preview = df.head(100)
table_str = df_preview.to_string(index=False)
note = f"\n(Показано первых 100 строк из {total_rows})" if total_rows > 100 else ""
return {
"text": f"[СОДЕРЖИМОЕ ТАБЛИЦЫ {filename}]:\n{table_str}{note}",
"image_b64": None
}
# 4. ТЕКСТОВЫЕ ФАЙЛЫ
elif ext in ['.txt', '.log', '.json', '.xml', '.md']:
with open(temp_filepath, 'r', encoding='utf-8', errors='replace') as tf:
return {
"text": tf.read().strip(),
"image_b64": None
}
else:
return {
"text": f"[ОШИБКА: Формат {ext} не поддерживается]",
"image_b64": None
}
except Exception as e:
logger.error(f"Ошибка при анализе файла {filename}: {e}")
return {
"text": f"[ОШИБКА ОБРАБОТКИ ФАЙЛА: {str(e)}]",
"image_b64": None
}
finally:
if os.path.exists(temp_filepath):
os.remove(temp_filepath)
```
## File: `./modules/web_api/llm/schemas.py`
```py
"""
===============================================================================
FILE: modules/web_api/llm/schemas.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / llm
ROLE: Декларативная схема нативных инструментов (Function Calling) для Ollama.
AI-CONTEXT-ANCHORS:
- ANCHOR[SCHEMA_PROMPT_EDIT]: Схема управления узлами системного промпта.
- ANCHOR[SCHEMA_TASKS_EDIT]: Консолидированная схема управления задачами.
- ANCHOR[SCHEMA_SNAPSHOTS]: Схема доступа к логам и срезам СКУД.
===============================================================================
"""
# ANCHOR[SCHEMA_PROMPT_EDIT]
TOOLS_SCHEMA = [
{
"type": "function",
"function": {
"name": "db_prompt_node_edit",
"description": (
"Управление элементами системного промпта (добавление, изменение, удаление пунктов).\n"
"Поддерживает как одиночные пункты (section_id, item_id), так и список пунктов для удаления (nodes_list=['1.8', '3.4']).\n"
"При любом запросе на удаление или добавление пунктов системного промпта ТЫ ОБЯЗАН вызвать этот инструмент."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ADD", "EDIT", "DELETE", "BATCH_DELETE"],
"description": "Тип действия"
},
"section_id": {
"type": "integer",
"description": "Номер раздела (например 1)"
},
"item_id": {
"type": "integer",
"description": "Номер пункта (например 8)"
},
"nodes_list": {
"type": "array",
"items": {"type": "string"},
"description": "Список пунктов для удаления/изменения, например ['1.8', '3.4']"
},
"content": {
"type": "string",
"description": "Текст пункта"
}
},
"required": ["action"]
}
}
},
{
"type": "function",
"function": {
"name": "db_get_system_prompt",
"description": "Просмотр текущего активного системного промпта ассистента из базы данных.",
"parameters": {
"type": "object",
"properties": {}
}
}
},
# ANCHOR[SCHEMA_TASKS_EDIT]
{
"type": "function",
"function": {
"name": "db_get_tasks",
"description": (
"Просмотр реестра задач и бэклога текущего пользователя.\n"
"Вызывай этот инструмент ВСЕГДА при любых запросах просмотра задач ('покажи задачи', 'мои задачи', опечатки 'змдачи').\n"
"Запрещено переспрашивать статус или параметры текстом: просто вызывай функцию с аргументами {}."
),
"parameters": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["ALL", "IN_PROGRESS", "PLANNED", "COMPLETED"],
"description": "Опциональный фильтр статуса задач (по умолчанию ALL)"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_tasks_edit",
"description": (
"Единый инструмент управления задачами: создание (ADD), изменение статуса/срока/названия (UPDATE), удаление (DELETE) и экспорт (EXPORT).\n"
"СТРОГИЕ ПРАВИЛА ВЫЗОВА:\n"
"- На любые фразы вида 'удали задачу N', 'удалить N', 'убери задачу N' ТЫ ОБЯЗАН СРАЗУ вызвать инструмент с action='DELETE', task_id='N'.\n"
"- На фразы 'возьми в работу N' вызывай action='UPDATE', task_id='N', status='IN_PROGRESS'.\n"
"- На фразы 'заверши N', 'готово N' вызывай action='UPDATE', task_id='N', status='COMPLETED'.\n"
"- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать текстовые вопросы или запрашивать подтверждения словами! ТЫ ОБЯЗАН СРАЗУ вызвать инструмент."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["ADD", "UPDATE", "DELETE", "EXPORT"],
"description": "Тип действия: ADD, UPDATE, DELETE или EXPORT"
},
"task_id": {
"type": "string",
"description": "Номер задачи (например: '37')"
},
"title": {
"type": "string",
"description": "Описание или текст задачи"
},
"status": {
"type": "string",
"enum": ["IN_PROGRESS", "COMPLETED", "BACKLOG"],
"description": "Новый статус задачи: IN_PROGRESS (В работу), COMPLETED (Завершено), BACKLOG (В планы)"
},
"priority": {
"type": "string",
"enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
"description": "Приоритет задачи"
},
"module": {
"type": "string",
"description": "Модуль проекта"
},
"due_date": {
"type": "string",
"description": "Срок в формате ГГГГ-ММ-ДД"
},
"filename": {
"type": "string",
"description": "Имя файла для экспорта"
}
},
"required": ["action"]
}
}
},
# ANCHOR[SCHEMA_SNAPSHOTS]
{
"type": "function",
"function": {
"name": "db_get_snapshots",
"description": (
"Получение реестра/списка доступных снапшотов (файлов срезов) СКУД.\n"
"ВЫЗЫВАТЬ ТОЛЬКО при прямом запросе на список срезов ('покажи срезы', 'какие есть снапшоты', 'срезы за дату').\n"
"КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать эту функцию, если пользователь спрашивает о людях, сотрудниках, входах или выходах внутри уже открытого среза!"
),
"parameters": {
"type": "object",
"properties": {
"date_str": {
"type": "string",
"description": "Дата в формате ДД.ММ.ГГГГ или относительное слово"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_delete_snapshots",
"description": "Удаление дневных снапшотов СКУД по идентификатору или дате.",
"parameters": {
"type": "object",
"properties": {
"snapshot_id": {
"type": "string",
"description": "Идентификатор или список идентификаторов через запятую"
},
"day_str": {
"type": "string",
"description": "Дата всех снапшотов за день"
},
"confirmed": {
"type": "boolean",
"description": "Флаг окончательного подтверждения удаления пользователем"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_anomalies",
"description": "Просмотр истории аномалий и расхождений между СКУД и 1С.",
"parameters": {
"type": "object",
"properties": {
"date_str": {
"type": "string",
"description": "Опциональная дата в формате ДД.ММ.ГГГГ"
},
"limit": {
"type": "integer",
"description": "Лимит записей"
}
}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_rules",
"description": "Просмотр базы знаний и правил кадрового арбитража компании.",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_stats",
"description": "Получение статистики количества записей в таблицах базы данных.",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_current_server_time",
"description": "Получение текущего точного времени сервера.",
"parameters": {
"type": "object",
"properties": {}
}
}
},
{
"type": "function",
"function": {
"name": "db_get_reference",
"description": "Справка о возможностях ассистента и примеры доступных команд.",
"parameters": {
"type": "object",
"properties": {
"category": {
"type": "string",
"description": "Категория справки"
}
}
}
}
}
]
```
## File: `./modules/web_api/main.py`
```py
"""
===============================================================================
FILE: modules/web_api/main.py
PROJECT: SCUD Orion AI (Unified Repository)
MODULE: web_api (Main Application Entry Point)
ROLE: Инициализация FastAPI приложения, подключение роутеров и статики.
===============================================================================
"""
# ANCHOR[APP_INIT_IMPORTS]
import os
import sys
import logging
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
if CURRENT_DIR not in sys.path:
sys.path.insert(0, CURRENT_DIR)
ROOT_DIR = os.path.abspath(os.path.join(CURRENT_DIR, "../../"))
for p in [ROOT_DIR, CURRENT_DIR]:
if p not in sys.path:
sys.path.insert(0, p)
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from routers.manual_absences import router as manual_absences_router
from routers.auth import router as auth_router
from routers.admin import router as admin_router
from routers.tasks import router as tasks_router
from routers.chat import router as chat_router
from routers.files import router as files_router
from routers.exceptions import router as exceptions_router
from routers.snapshots import router as snapshots_router
from routers.remote_workers import router as remote_workers_router
from routers.context import router as context_router
from routers.presence import router as presence_router
from routers.reports import router as reports_router
# ANCHOR[APP_CONFIG]
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()]
)
STATIC_DIR = os.path.join(CURRENT_DIR, "static")
app = FastAPI(title="SCUD Orion AI Context API", version="2.5")
if os.path.exists(STATIC_DIR):
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
logging.error(f"❌ ОШИБКА ВАЛИДАЦИИ 422 НА {request.url}: {exc.errors()}")
return JSONResponse(
status_code=422,
content={"detail": exc.errors(), "body": str(exc)}
)
# ANCHOR[ROUTER_REGISTRATION]
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(tasks_router)
app.include_router(chat_router)
app.include_router(files_router)
app.include_router(exceptions_router)
app.include_router(snapshots_router)
app.include_router(remote_workers_router)
app.include_router(context_router)
app.include_router(manual_absences_router)
app.include_router(presence_router)
app.include_router(reports_router)
# ANCHOR[ROOT_STATIC_ROUTES]
@app.get("/")
def read_root():
index_path = os.path.join(STATIC_DIR, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="Frontend index.html not found")
@app.get("/favicon.ico")
async def favicon():
file_path = os.path.join(STATIC_DIR, "favicon.ico")
if os.path.exists(file_path):
return FileResponse(file_path)
raise HTTPException(status_code=404)
@app.get("/{file_path:path}")
def serve_static_fallback(file_path: str):
clean_path = file_path.lstrip("/")
# Жесткая блокировка скрытых файлов (.env, .git) и служебных форматов
forbidden_patterns = [".env", ".git", ".yml", ".yaml", ".json", ".sql", ".php", ".bak"]
if clean_path.startswith(".") or any(p in clean_path.lower() for p in forbidden_patterns):
raise HTTPException(status_code=403, detail="Access denied")
target = os.path.join(STATIC_DIR, clean_path)
if os.path.isfile(target):
if clean_path.endswith(".js"):
return FileResponse(target, media_type="application/javascript")
elif clean_path.endswith(".css"):
return FileResponse(target, media_type="text/css")
return FileResponse(target)
filename = os.path.basename(clean_path)
for root, _, files in os.walk(STATIC_DIR):
if filename in files:
full_path = os.path.join(root, filename)
media = "application/javascript" if filename.endswith(".js") else "text/css"
return FileResponse(full_path, media_type=media)
raise HTTPException(status_code=404, detail="File not found")
```
## File: `./modules/web_api/routers/admin.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/admin.py
ROLE: Администрирование пользователей и прав доступа.
===============================================================================
"""
# ANCHOR[ADMIN_ROUTER_IMPORTS]
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from .auth import get_current_user, get_db, pwd_context
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# ANCHOR[ADMIN_SCHEMAS]
class CreateUserRequest(BaseModel):
username: str
password: str
full_name: Optional[str] = None
is_admin: Optional[bool] = False
# ANCHOR[ADMIN_ENDPOINTS]
@router.get("/users")
def list_users(current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, full_name, is_admin, created_at FROM users ORDER BY id ASC")
users = [dict(r) for r in cursor.fetchall()]
conn.close()
return users
@router.post("/users")
def create_user(req: CreateUserRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
username = req.username.strip().lower()
if not username or not req.password:
raise HTTPException(status_code=400, detail="Заполните имя пользователя и пароль")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=400, detail="Пользователь с таким именем уже существует")
pwd_hash = pwd_context.hash(req.password)
full_name = req.full_name.strip() if req.full_name else None
is_admin = 1 if req.is_admin else 0
cursor.execute(
"INSERT INTO users (username, password_hash, full_name, is_admin) VALUES (?, ?, ?, ?)",
(username, pwd_hash, full_name, is_admin)
)
conn.commit()
conn.close()
logging.info(f"Создан пользователь: {username} (admin={is_admin}) админом {current_user['username']}")
return {"status": "success", "message": f"Пользователь {username} создан"}
@router.delete("/users/{user_id}")
def delete_user(user_id: int, current_user: Dict[str, Any] = Depends(get_current_user)):
if not current_user["is_admin"]:
raise HTTPException(status_code=403, detail="Доступ запрещен. Только для администратора.")
if user_id == current_user["id"]:
raise HTTPException(status_code=400, detail="Нельзя удалить самого себя")
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
logging.info(f"Удален пользователь ID: {user_id}")
return {"status": "success", "message": "Пользователь удален"}
```
## File: `./modules/web_api/routers/auth.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/auth.py
ROLE: Аутентификация, валидация JWT-токенов и управление паролями.
===============================================================================
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
import jwt
from passlib.context import CryptContext
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from core.connection import get_connection
JWT_SECRET = "scud_jwt_secret_key_2026_orion_ai_super_secure"
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
def get_db():
return get_connection(row_factory=True)
def create_access_token(user_id: int, username: str, is_admin: bool) -> str:
payload = {
"sub": str(user_id),
"username": username,
"is_admin": is_admin,
"exp": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> Dict[str, Any]:
try:
token = credentials.credentials
payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM])
user_id = int(payload.get("sub"))
username = payload.get("username")
is_admin = bool(payload.get("is_admin", False))
return {"id": user_id, "username": username, "is_admin": is_admin}
except Exception as e:
logging.warning(f"Auth error: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный или просроченный токен авторизации",
headers={"WWW-Authenticate": "Bearer"},
)
class AuthRequest(BaseModel):
username: str
password: str
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
@router.post("/login")
def login(req: AuthRequest):
username = req.username.strip().lower()
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT id, username, password_hash, full_name, is_admin FROM users WHERE username = ?", (username,))
user = cursor.fetchone()
conn.close()
if not user or not pwd_context.verify(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="Неверное имя пользователя или пароль")
is_admin = bool(user["is_admin"]) or (user["username"] == "puh")
token = create_access_token(user["id"], user["username"], is_admin)
# Возвращаем full_name (если не задано — отдаем username)
full_name = user["full_name"] if user["full_name"] else user["username"]
return {
"status": "success",
"token": token,
"username": user["username"],
"full_name": full_name,
"user_id": user["id"],
"is_admin": is_admin
}
@router.post("/change-password")
def change_password(req: ChangePasswordRequest, current_user: Dict[str, Any] = Depends(get_current_user)):
if not req.new_password or len(req.new_password) < 4:
raise HTTPException(status_code=400, detail="Новый пароль должен содержать минимум 4 символа")
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT password_hash FROM users WHERE id = ?", (current_user["id"],))
user = cursor.fetchone()
if not user or not pwd_context.verify(req.old_password, user["password_hash"]):
conn.close()
raise HTTPException(status_code=400, detail="Неверный старый пароль")
new_hash = pwd_context.hash(req.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, current_user["id"]))
conn.commit()
conn.close()
return {"status": "success", "message": "Пароль успешно изменен"}
```
## File: `./modules/web_api/routers/chat.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/chat.py
ROLE: Полнофункциональный роутер чата с извлечением текста из PDF и сканов,
поддержкой Function Calling, Fast-Path и оптического распознавания OCR.
===============================================================================
"""
import os
import shutil
import base64
import logging
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
from pydantic import BaseModel
from llm.agent import process_chat_message
from config import BASE_DIR
logger = logging.getLogger("CHAT_API")
router = APIRouter(prefix="/api/v1", tags=["Chat"])
UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "data", "uploads")
os.makedirs(UPLOAD_TMP_DIR, exist_ok=True)
class ChatMessageRequest(BaseModel):
message: str
session_id: Optional[str] = "web_session_main"
user_id: Optional[int] = 1
def resolve_user_id(authorization: Optional[str] = None, explicit_user_id: Optional[int] = None) -> int:
if explicit_user_id and explicit_user_id > 0:
return explicit_user_id
if authorization and authorization.startswith("Bearer "):
token = authorization.replace("Bearer ", "").strip()
if token.isdigit():
return int(token)
elif token.startswith("dev_token_"):
try:
return int(token.replace("dev_token_", ""))
except ValueError:
pass
return 1
@router.post("/chat")
async def chat_endpoint(payload: ChatMessageRequest, authorization: Optional[str] = Header(None)):
user_id = resolve_user_id(authorization, payload.user_id)
session_id = payload.session_id or "web_session_main"
user_msg = payload.message.strip()
if not user_msg:
raise HTTPException(status_code=400, detail="Пустое сообщение")
logger.info(f"Сообщение от user_id={user_id}, session_id={session_id}: {user_msg}")
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
session_id=session_id
)
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
@router.post("/chat/upload")
async def chat_upload_endpoint(
file: UploadFile = File(...),
message: Optional[str] = Form(""),
session_id: Optional[str] = Form("web_session_main"),
authorization: Optional[str] = Header(None)
):
user_id = resolve_user_id(authorization, 1)
file_path = os.path.join(UPLOAD_TMP_DIR, file.filename)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
file_context = ""
image_b64 = None
fn_lower = file.filename.lower()
# 1. Текстовые форматы
if fn_lower.endswith((".txt", ".csv", ".log", ".md")):
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
file_context = f.read(6000)
except Exception as e:
logger.warning(f"Не удалось прочитать текст: {e}")
# 2. Изображения (прямой OCR)
elif fn_lower.endswith((".png", ".jpg", ".jpeg", ".webp")):
try:
with open(file_path, "rb") as f:
image_b64 = base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
logger.warning(f"Ошибка кодирования картинки в base64: {e}")
# 3. PDF документы (текстовый слой + рендеринг скана при необходимости)
elif fn_lower.endswith(".pdf"):
# Попытка извлечь встроенный текстовый слой
try:
import pypdf
reader = pypdf.PdfReader(file_path)
extracted = []
for page in reader.pages:
t = page.extract_text()
if t:
extracted.append(t)
file_context = "\n".join(extracted).strip()
except Exception:
pass
# Если текстового слоя мало (скан или фото документа), рендерим страницу в картинку для Vision OCR
if len(file_context) < 40:
try:
import fitz # PyMuPDF
doc = fitz.open(file_path)
if len(doc) > 0:
page = doc[0]
pix = page.get_pixmap(dpi=150)
img_bytes = pix.tobytes("png")
image_b64 = base64.b64encode(img_bytes).decode("utf-8")
file_context = ""
except Exception as e:
logger.warning(f"PyMuPDF не установлен или сбой рендеринга PDF: {e}")
user_msg = message.strip() or f"Распознай и проанализируй прикрепленный документ {file.filename}"
reply_text, history, action_payload = process_chat_message(
user_id=user_id,
user_message=user_msg,
file_context=file_context,
image_b64=image_b64,
session_id=session_id
)
return {
"status": "success",
"user_id": user_id,
"session_id": session_id,
"response": reply_text,
"action_payload": action_payload
}
```
## File: `./modules/web_api/routers/context.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/context.py
ROLE: REST API мониторинга состояния сессии и очистки памяти диалога.
===============================================================================
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from routers.auth import get_current_user
from modules.web_api.llm.db.db_prompts import db_get_session_state, db_clear_session_state
from modules.web_api.llm.db.db_chat import db_get_chat_history, db_purge_ephemeral_messages, db_clear_chat_history
router = APIRouter(prefix="/api/v1/context", tags=["Context"])
class SessionActionRequest(BaseModel):
session_id: Optional[str] = "web_session_main"
@router.get("/state")
def api_get_context_state(session_id: str = "web_session_main", current_user = Depends(get_current_user)):
state = db_get_session_state(session_id) or {}
history = db_get_chat_history(session_id, limit=50)
ephemeral_count = sum(1 for m in history if dict(m).get("is_ephemeral") == 1)
total_messages = len(history)
return {
"session_id": session_id,
"active_state": state.get("state_type", "IDLE"),
"state_data": state.get("data_json", {}),
"total_messages": total_messages,
"ephemeral_messages": ephemeral_count
}
@router.post("/purge-ephemeral")
def api_purge_ephemeral(req: SessionActionRequest, current_user = Depends(get_current_user)):
purged = db_purge_ephemeral_messages(req.session_id)
return {"status": "success", "purged_count": purged}
@router.post("/clear-all")
def api_clear_all_context(req: SessionActionRequest, current_user = Depends(get_current_user)):
db_clear_session_state(req.session_id)
db_clear_chat_history(req.session_id)
return {"status": "success", "message": "Контекст сессии полностью очищен"}
```
## File: `./modules/web_api/routers/exceptions.py`
```py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, List
from services.exceptions_repo import get_all_exceptions_from_db, add_exception_to_db, remove_exception_from_db
router = APIRouter(prefix="/api/v1/exceptions", tags=["Exceptions"])
class ExceptionItem(BaseModel):
category: str
value: str
comment: Optional[str] = ""
@router.get("")
@router.get("/")
def api_get_exceptions():
return get_all_exceptions_from_db()
@router.post("")
@router.post("/")
def api_add_exception(item: ExceptionItem):
if not add_exception_to_db(item.category, item.value, item.comment):
raise HTTPException(status_code=400, detail="Ошибка добавления исключения")
return {"status": "success", "data": item}
@router.delete("")
@router.delete("/")
def api_delete_exception(category: str, value: str):
if not remove_exception_from_db(category, value):
raise HTTPException(status_code=404, detail="Исключение не найдено")
return {"status": "success"}
```
## File: `./modules/web_api/routers/files.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/files.py
ROLE: Раздача сформированных отчетов и выгрузок с сохранением оригинальных имен
через изолированные UUID-директории инструментов.
===============================================================================
"""
import os
import time
import shutil
import urllib.parse
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
router = APIRouter(prefix="/api/v1/files", tags=["Files"])
BASE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
WEB_OUTPUT_DIR = os.path.join(BASE_ROOT, "output", "web")
os.makedirs(WEB_OUTPUT_DIR, exist_ok=True)
SESSION_TTL_HOURS = 24 # Срок жизни временных сессионных выгрузок
def purge_old_tool_sessions(tool_dir_path: str):
"""Удаляет временные UUID-папки старше SESSION_TTL_HOURS внутри инструмента."""
if not os.path.exists(tool_dir_path):
return
now = time.time()
cutoff = now - (SESSION_TTL_HOURS * 3600)
try:
for entry in os.listdir(tool_dir_path):
subpath = os.path.join(tool_dir_path, entry)
if os.path.isdir(subpath):
if os.path.getmtime(subpath) < cutoff:
shutil.rmtree(subpath, ignore_errors=True)
except Exception:
pass
@router.get("/download/{tool_name}/{session_uuid}/{filename}")
async def download_file(tool_name: str, session_uuid: str, filename: str):
"""
Безопасная отдача файла с каноническим именем из изолированной директории.
"""
safe_tool = os.path.basename(tool_name)
safe_uuid = os.path.basename(session_uuid)
safe_filename = os.path.basename(filename)
file_path = os.path.join(WEB_OUTPUT_DIR, safe_tool, safe_uuid, safe_filename)
if not os.path.exists(file_path) or not os.path.isfile(file_path):
raise HTTPException(status_code=404, detail="Файл не найден или срок его действия истек")
# Определение MIME-типа
media_type = "application/octet-stream"
if safe_filename.endswith(".md") or safe_filename.endswith(".txt"):
media_type = "text/markdown; charset=utf-8"
elif safe_filename.endswith(".xlsx"):
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
elif safe_filename.endswith(".pdf"):
media_type = "application/pdf"
# Корректная кодировка для кириллических имен файлов
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
path=file_path,
media_type=media_type,
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
}
)
@router.get("/download/reports/{filename}")
async def download_report_direct(filename: str):
"""Прямое скачивание отчетов из output/reports или /tmp/scud_reports."""
safe_filename = os.path.basename(filename)
# 1. Проверяем сетевую шару / постоянный каталог
target_path = os.path.join(BASE_ROOT, "output", "reports", safe_filename)
# 2. Если нет — проверяем временный буфер сборки
if not os.path.exists(target_path):
target_path = os.path.join("/tmp/scud_reports", safe_filename)
if not os.path.exists(target_path):
raise HTTPException(status_code=404, detail="Отчет не найден")
encoded_filename = urllib.parse.quote(safe_filename)
return FileResponse(
path=target_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
)
```
## File: `./modules/web_api/routers/manual_absences.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/manual_absences.py
ROLE: REST API эндпоинты для реестров "Мест. командир.", "Иное" и автокомплита.
===============================================================================
"""
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from services.manual_absences_repo import (
search_staff_suggestions,
get_static_reasons,
add_manual_absence,
delete_manual_absence,
get_manual_absences_list
)
router = APIRouter(prefix="/api/v1/manual-absences", tags=["Manual Absences"])
class AddAbsenceRequest(BaseModel):
absence_type: str # 'LOCAL_TRIP' или 'OTHER'
fio: str
reason: Optional[str] = ""
department: Optional[str] = ""
position: Optional[str] = ""
date_start: Optional[str] = None
date_end: Optional[str] = None
comment: Optional[str] = ""
@router.get("/staff-autocomplete")
def api_staff_autocomplete(q: str = Query(..., min_length=2)):
return search_staff_suggestions(q)
@router.get("/reasons")
def api_get_reasons():
return {"reasons": get_static_reasons()}
@router.get("/")
def api_list_manual_absences(type: Optional[str] = None):
return {"items": get_manual_absences_list(type)}
@router.post("/")
def api_add_manual_absence(req: AddAbsenceRequest):
reason = req.reason or ("Местная командировка" if req.absence_type == "LOCAL_TRIP" else "Иное")
res_id = add_manual_absence(
absence_type=req.absence_type,
fio=req.fio,
reason=reason,
department=req.department,
position=req.position,
date_start=req.date_start,
date_end=req.date_end,
comment=req.comment
)
if not res_id:
raise HTTPException(status_code=400, detail="Не удалось добавить запись")
return {"status": "success", "id": res_id}
@router.delete("/{item_id}")
def api_delete_manual_absence(item_id: int):
if not delete_manual_absence(item_id):
raise HTTPException(status_code=404, detail="Запись не найдена")
return {"status": "success"}
class UpdateAbsenceDatesRequest(BaseModel):
id: int
date_start: Optional[str] = None
date_end: Optional[str] = None
@router.put("/{item_id}")
def api_update_manual_absence(item_id: int, req: UpdateAbsenceDatesRequest):
with get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE manual_absences
SET date_start = ?, date_end = ?
WHERE id = ?
""", (req.date_start, req.date_end, item_id))
conn.commit()
return {"status": "success"}
```
## File: `./modules/web_api/routers/presence.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/presence.py
ROLE: REST API оперативного статуса присутствия сотрудников в здании.
===============================================================================
"""
from fastapi import APIRouter, Query
from typing import Optional, Dict, Any
from services.presence_service import get_live_presence
router = APIRouter(prefix="/api/v1/presence", tags=["Presence"])
@router.get("/live")
def api_get_live_presence(
date_str: Optional[str] = Query(None, description="Дата в формате ДД.ММ.ГГГГ"),
force_refresh: bool = Query(False, description="Принудительный опрос MS SQL Орион")
):
"""
Возвращает оперативный статус сотрудников («Кто в здании»).
По умолчанию возвращает срез моментально из локальной базы SQLite.
При force_refresh=true выполняет опрос турникетов в MS SQL Орион.
"""
return get_live_presence(date_str=date_str, force_refresh=force_refresh)
```
## File: `./modules/web_api/routers/remote_workers.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/remote_workers.py
ROLE: REST API реестра удаленщиков (CRUD, редактирование сроков, автоочистка).
===============================================================================
"""
import os
from datetime import datetime
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
from routers.auth import get_current_user
from config import BASE_DIR, DATA_DIR
router = APIRouter(prefix="/api/v1/remote-workers", tags=["RemoteWorkers"])
CSV_PATH = os.path.join(DATA_DIR, "static_reason_workers.csv")
def _load_workers() -> List[Dict[str, Any]]:
"""Читает CSV, гарантирует структуру колонок и удаляет просроченные записи."""
if not os.path.exists(CSV_PATH):
return []
try:
df = pd.read_csv(CSV_PATH, dtype=str, on_bad_lines='skip').fillna("")
for col in ["fio", "department", "reason", "date_from", "date_to"]:
if col not in df.columns:
df[col] = ""
today_date = datetime.now().date()
valid_workers = []
has_expired = False
for row in df.to_dict(orient="records"):
fio = str(row.get("fio", "")).strip()
if not fio:
continue
d_to_str = str(row.get("date_to", "")).strip()
if d_to_str and d_to_str.lower() not in ["nan", "none", ""]:
try:
d_to = datetime.strptime(d_to_str.replace('_', '.'), "%d.%m.%Y").date()
# Если срок завершился вчера или ранее — запись удаляется из файла
if today_date > d_to:
has_expired = True
continue
except ValueError:
pass
valid_workers.append(row)
# Синхронная перезапись файла при обнаружении истекших сроков
if has_expired:
_save_workers(valid_workers)
return valid_workers
except Exception:
return []
def _save_workers(workers: List[Dict[str, Any]]):
"""Сохраняет актуальный список в CSV с сохранением колонок."""
df = pd.DataFrame(workers)
for col in ["fio", "department", "reason", "date_from", "date_to"]:
if col not in df.columns:
df[col] = ""
os.makedirs(os.path.dirname(CSV_PATH), exist_ok=True)
df.to_csv(CSV_PATH, index=False, encoding="utf-8")
class RemoteWorkerItem(BaseModel):
fio: str
department: Optional[str] = "Все"
reason: Optional[str] = "Удаленная работа"
date_from: Optional[str] = ""
date_to: Optional[str] = ""
class UpdateDatesRequest(BaseModel):
fio: str
date_from: Optional[str] = ""
date_to: Optional[str] = ""
@router.get("")
def api_get_remote_workers(current_user = Depends(get_current_user)):
return {"workers": _load_workers()}
@router.post("")
def api_add_remote_worker(item: RemoteWorkerItem, current_user = Depends(get_current_user)):
workers = _load_workers()
fio_clean = item.fio.strip()
if not fio_clean:
raise HTTPException(status_code=400, detail="ФИО не может быть пустым")
# Если начало не указано — берем сегодня
today_str = datetime.now().strftime("%d.%m.%Y")
date_from = item.date_from.strip() if item.date_from and item.date_from.strip() else today_str
date_to = item.date_to.strip() if item.date_to else ""
# Проверка на совпадение ФИО (обновление существующей записи)
for w in workers:
if str(w.get("fio", "")).strip().lower() == fio_clean.lower():
w["department"] = item.department.strip() if item.department else (w.get("department") or "Все")
w["reason"] = item.reason.strip() if item.reason else (w.get("reason") or "Удаленная работа")
w["date_from"] = date_from
w["date_to"] = date_to
_save_workers(workers)
return {"status": "success", "message": "Срок удаленки обновлен", "workers": workers}
workers.append({
"fio": fio_clean,
"department": item.department.strip() if item.department else "Все",
"reason": item.reason.strip() if item.reason else "Удаленная работа",
"date_from": date_from,
"date_to": date_to
})
_save_workers(workers)
return {"status": "success", "workers": workers}
@router.put("")
def api_update_worker_dates(req: UpdateDatesRequest, current_user = Depends(get_current_user)):
"""Редактирование срока удаленки (продление или сокращение)."""
workers = _load_workers()
target_fio = req.fio.strip().lower()
found = False
for w in workers:
if str(w.get("fio", "")).strip().lower() == target_fio:
w["date_from"] = req.date_from.strip() if req.date_from is not None else w.get("date_from", "")
w["date_to"] = req.date_to.strip() if req.date_to is not None else w.get("date_to", "")
found = True
break
if not found:
raise HTTPException(status_code=404, detail="Сотрудник не найден в списке")
_save_workers(workers)
return {"status": "success", "message": "Сроки успешно изменены", "workers": workers}
@router.delete("")
def api_delete_remote_worker(fio: str, current_user = Depends(get_current_user)):
workers = _load_workers()
fio_clean = fio.strip().lower()
initial_len = len(workers)
workers = [w for w in workers if str(w.get("fio", "")).strip().lower() != fio_clean]
if len(workers) == initial_len:
raise HTTPException(status_code=404, detail="Сотрудник не найден")
_save_workers(workers)
return {"status": "success", "workers": workers}
```
## File: `./modules/web_api/routers/reports.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/reports.py
ROLE: REST API On-Demand генерации отчетов (Сводка, Детальный, Упрощенный)
с атомарной сборкой в /tmp и безопасной публикацией на сетевую шару.
===============================================================================
"""
import os
import shutil
import logging
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, HTTPException, Query
from config import DATE_TODAY, DATE_YESTERDAY
from services.scud_etl.svodka_generator import generate_svodka_service
from services.scud_etl.otchet_generator import generate_otchet_service
from services.reports.simplified_builder import generate_simplified_excel
from services.scud_etl.pipeline import load_best_snapshot_for_date, load_1c_files_for_date
from services.scud_etl.merger import merge_scud_and_1c
logger = logging.getLogger("REPORTS_API")
router = APIRouter(prefix="/api/v1/reports", tags=["Reports"])
TEMP_REPORTS_DIR = "/tmp/scud_reports"
os.makedirs(TEMP_REPORTS_DIR, exist_ok=True)
class GenerateReportRequest(BaseModel):
date: Optional[str] = None # ДД.ММ.ГГГГ
time: Optional[str] = None # ЧЧ:ММ (для сводки)
report_type: str # 'SVODKA', 'DETAILED', 'SIMPLIFIED', 'ALL'
@router.post("/generate")
def api_generate_report(req: GenerateReportRequest):
"""
Генерирует выбранный отчет на указанную дату/время и возвращает ссылки на скачивание.
"""
target_date = (req.date or DATE_TODAY).replace('_', '.')
r_type = req.report_type.upper()
results = []
# 1. Ежедневная сводка
if r_type in ["SVODKA", "ALL"]:
res_svodka = generate_svodka_service(
target_date=target_date,
target_time=req.time
)
if res_svodka.get("status") == "success":
results.append({
"type": "Сводка",
"filename": res_svodka.get("filename"),
"download_url": res_svodka.get("download_url"),
"status": "success"
})
else:
results.append({
"type": "Сводка",
"error": res_svodka.get("message"),
"status": "error"
})
# 2. Детальный отчет
if r_type in ["DETAILED", "ALL"]:
res_det = generate_otchet_service(target_date=target_date)
if res_det.get("status") == "success":
results.append({
"type": "Детальный отчет",
"filename": res_det.get("filename"),
"download_url": res_det.get("download_url"),
"status": "success"
})
else:
results.append({
"type": "Детальный отчет",
"error": res_det.get("message"),
"status": "error"
})
# 3. Упрощенный отчет
if r_type in ["SIMPLIFIED", "ALL"]:
try:
df_scud = load_best_snapshot_for_date(target_date, prefer_final_y=True)
if df_scud is not None and not df_scud.empty:
df_staff, df_abs = load_1c_files_for_date(target_date)
df_merged = merge_scud_and_1c(df_scud, df_staff, df_abs)
out_path = generate_simplified_excel(df_merged, date_str=target_date)
filename = os.path.basename(out_path)
results.append({
"type": "Упрощенный отчет",
"filename": filename,
"download_url": f"/api/v1/files/download/reports/{filename}",
"status": "success"
})
else:
results.append({
"type": "Упрощенный отчет",
"error": f"Срез СКУД за {target_date} не найден.",
"status": "error"
})
except Exception as e:
results.append({
"type": "Упрощенный отчет",
"error": str(e),
"status": "error"
})
return {
"date": target_date,
"requested_type": r_type,
"reports": results
}
```
## File: `./modules/web_api/routers/snapshots.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/snapshots.py
ROLE: Роутер срезов СКУД:
- Список срезов за период дат (/api/v1/snapshots)
- Инспекция среза (/api/v1/snapshots/{snapshot_id}/details и /inspect)
- Экспорт среза в Excel (.xlsx) и CSV (UTF-8 с BOM)
- Ручное создание среза (/create)
- Удаление срезов (пакетное через snapshot_ids)
===============================================================================
"""
import os
import io
import urllib.parse
import logging
from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse, FileResponse
from pydantic import BaseModel
import pandas as pd
from core.connection import get_connection
import config
logger = logging.getLogger("SNAPSHOTS_ROUTER")
router = APIRouter(prefix="/api/v1/snapshots", tags=["Snapshots"])
BASE_DIR = getattr(config, "BASE_DIR", Path(__file__).resolve().parent.parent.parent.parent)
SNAPSHOTS_DIR = getattr(config, "SNAPSHOTS_DIR", os.path.join(str(BASE_DIR), "exports", "snapshots"))
DATE_TODAY = getattr(config, "DATE_TODAY", datetime.now().strftime("%d.%m.%Y"))
class CreateSnapshotRequest(BaseModel):
date_str: Optional[str] = None
time_str: Optional[str] = None
class DeleteSnapshotsRequest(BaseModel):
snapshot_ids: List[str]
# =============================================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ РАСЧЕТА СТАТУСА И ВРЕМЕНИ
# =============================================================================
def parse_time_str(t_str: str) -> Optional[datetime]:
"""Парсит строку времени HH:MM[:SS] в datetime объект."""
if not t_str or str(t_str).strip() in ("Нет входа", "Нет выхода", "—", "-", "None", "nan"):
return None
for fmt in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(str(t_str).strip(), fmt)
except ValueError:
pass
return None
def calculate_row_presence(d: dict, snapshot_time_str: str) -> dict:
"""
Определяет реальный статус сотрудника и время нахождения в здании.
"""
fio = d.get("fio") or d.get("Сотрудник") or "—"
dept = d.get("department") or d.get("Подразделение") or "—"
t_in = d.get("time_in") or d.get("Начало_дня") or d.get("first_in") or "Нет входа"
first_act = d.get("first_activity") or d.get("Первая_активность") or "—"
t_out = d.get("time_out") or d.get("Конец_дня") or d.get("last_out") or "Нет выхода"
db_in_bld = d.get("in_building") or d.get("Находился_в_здании")
db_status = d.get("status") or d.get("Статус") or d.get("Пришел")
has_in = t_in not in ("Нет входа", "—", "-", "", None, "None")
has_act = first_act not in ("—", "-", "", None, "None")
has_out = t_out not in ("Нет выхода", "—", "-", "", None, "None")
# 1. Если нет отметок прохода — сотрудник отсутствовал
if not has_in and not has_act and not has_out:
final_status = "Отсутствовал (Нет событий)"
final_in_bld = "00:00"
else:
# Сотрудник присутствовал
final_status = "Присутствовал"
# Расчет времени нахождения в здании
dt_in = parse_time_str(t_in) or parse_time_str(first_act)
dt_out = parse_time_str(t_out)
if db_in_bld and db_in_bld not in ("00:00", "—", "", "None"):
final_in_bld = db_in_bld
elif dt_in:
if dt_out and dt_out >= dt_in:
diff = dt_out - dt_in
else:
# Если выхода еще нет — считаем до момента фиксации среза
dt_snap = parse_time_str(snapshot_time_str) or datetime.now()
if dt_snap >= dt_in:
diff = dt_snap - dt_in
else:
diff = timedelta(0)
total_minutes = int(diff.total_seconds() // 60)
hh = total_minutes // 60
mm = total_minutes % 60
final_in_bld = f"{hh:02d}:{mm:02d}"
else:
final_in_bld = "00:00"
# Сохраняем специальные статусы, если они зафиксированы в БД
if db_status and "Отсутств" in str(db_status):
final_status = db_status
return {
"fio": fio,
"department": dept,
"time_in": t_in,
"first_activity": first_act,
"time_out": t_out,
"in_building": final_in_bld,
"status": final_status
}
# =============================================================================
# 1. СПИСОК СРЕЗОВ (С ПОДДЕРЖКОЙ ДИАПАЗОНА ДАТ)
# =============================================================================
@router.get("", include_in_schema=False)
@router.get("/")
def list_snapshots(
date: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None
):
"""
Возвращает список срезов за диапазон дат со всеми полями для интерфейса.
"""
d_from = (date_from or date or DATE_TODAY).replace('_', '.')
d_to = (date_to or date or DATE_TODAY).replace('_', '.')
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT snapshot_id, COUNT(*) as cnt, MAX(created_at) as created_at, log_date
FROM scud_logs
WHERE (
substr(log_date, 7, 4) || '-' || substr(log_date, 4, 2) || '-' || substr(log_date, 1, 2)
BETWEEN
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
AND
substr(?, 7, 4) || '-' || substr(?, 4, 2) || '-' || substr(?, 1, 2)
)
AND snapshot_id IS NOT NULL AND snapshot_id != ''
GROUP BY snapshot_id
ORDER BY snapshot_id DESC
""", (d_from, d_from, d_from, d_to, d_to, d_to))
rows = cursor.fetchall()
items = []
for r in rows:
raw_id = r["snapshot_id"]
clean_id = str(raw_id).lstrip("#").strip()
total_cnt = r["cnt"]
time_str = "—"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
time_str = f"{parts[1][:2]}:{parts[1][2:4]}"
is_final = "FINAL" in clean_id.upper()
items.append({
"id": clean_id,
"snapshot_id": clean_id,
"label": clean_id,
"snapshot_time": time_str,
"time": time_str,
"record_count": total_cnt,
"count": total_cnt,
"records_count": total_cnt,
"is_final": is_final,
"date": r["log_date"],
"created_at": r["created_at"] or r["log_date"]
})
return {
"date_from": d_from,
"date_to": d_to,
"total_snapshots": len(items),
"snapshots": items
}
# =============================================================================
# 2. ИНСПЕКЦИЯ СРЕЗА (ДЛЯ МОДАЛЬНОГО ОКНА)
# Поддерживает оба пути: /details и /inspect
# =============================================================================
@router.get("/{snapshot_id}/details")
@router.get("/{snapshot_id}/inspect")
def inspect_snapshot(snapshot_id: str):
clean_id = snapshot_id.lstrip("#").strip()
target_date = ""
snapshot_time = "23:59:59"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts[0]) == 8 and parts[0].isdigit():
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
rows = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
# Безопасная выборка всех полей строки среза
cursor.execute("""
SELECT * FROM scud_logs
WHERE snapshot_id IN (?, ?, ?, ?)
ORDER BY id ASC
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
rows = cursor.fetchall()
# Резервный сбор на лету из scud_events_raw, если среза нет в scud_logs
if not rows and target_date:
cursor.execute("""
SELECT
fio,
department,
MIN(CASE WHEN direction = 'IN' THEN time_val END) as time_in,
NULL as first_activity,
MAX(CASE WHEN direction = 'OUT' THEN time_val END) as time_out,
'00:00' as in_building,
'Присутствовал' as status
FROM scud_events_raw
WHERE log_date = ?
GROUP BY fio_clean
ORDER BY fio ASC
""", (target_date,))
rows = cursor.fetchall()
if not rows:
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
records = []
for r in rows:
calc = calculate_row_presence(dict(r), snapshot_time)
records.append({
"hoz_organ": dict(r).get("hoz_organ") or dict(r).get("tab_num") or "",
"fio": calc["fio"],
"Сотрудник": calc["fio"],
"department": calc["department"],
"Подразделение": calc["department"],
"time_in": calc["time_in"],
"Вход": calc["time_in"],
"first_activity": calc["first_activity"],
"time_out": calc["time_out"],
"Выход": calc["time_out"],
"in_building": calc["in_building"],
"Находился_в_здании": calc["in_building"],
"status": calc["status"],
"Пришел": calc["status"]
})
return {
"status": "ok",
"snapshot_id": clean_id,
"date": target_date or DATE_TODAY,
"total_records": len(records),
"count": len(records),
"records": records,
"data": records
}
# =============================================================================
# 3. ЭКСПОРТ ДАННЫХ ИНСПЕКЦИИ СРЕЗА (EXCEL / CSV)
# =============================================================================
@router.get("/{snapshot_id}/export")
def api_export_snapshot(snapshot_id: str, format: str = Query("xlsx")):
clean_id = snapshot_id.lstrip("#").strip()
target_date = ""
snapshot_time = "23:59:59"
if "_" in clean_id:
parts = clean_id.split("_")
if len(parts[0]) == 8 and parts[0].isdigit():
target_date = f"{parts[0][6:8]}.{parts[0][4:6]}.{parts[0][:4]}"
if len(parts) > 1 and len(parts[1]) >= 4 and parts[1][:4].isdigit():
snapshot_time = f"{parts[1][:2]}:{parts[1][2:4]}:00"
rows = []
with get_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM scud_logs
WHERE snapshot_id IN (?, ?, ?, ?)
ORDER BY id ASC
""", (clean_id, f"#{clean_id}", f"#{snapshot_id}", snapshot_id))
rows = cursor.fetchall()
if not rows:
raise HTTPException(status_code=404, detail=f"Срез '{clean_id}' не найден")
export_list = []
for r in rows:
calc = calculate_row_presence(dict(r), snapshot_time)
export_list.append({
"Сотрудник": calc["fio"],
"Подразделение": calc["department"],
"Вход": calc["time_in"],
"Первая активность": calc["first_activity"],
"Выход": calc["time_out"],
"В здании": calc["in_building"],
"Статус": calc["status"]
})
out_df = pd.DataFrame(export_list)
filename_base = f"Инспекция_{clean_id}"
if format.lower() == "csv":
csv_bytes = out_df.to_csv(index=False, sep=";", encoding="utf-8-sig").encode("utf-8-sig")
filename = f"{filename_base}.csv"
encoded = urllib.parse.quote(filename)
return StreamingResponse(
io.BytesIO(csv_bytes),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
)
else:
output = io.BytesIO()
with pd.ExcelWriter(output, engine="openpyxl") as writer:
out_df.to_excel(writer, index=False, sheet_name="Срез")
output.seek(0)
filename = f"{filename_base}.xlsx"
encoded = urllib.parse.quote(filename)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"}
)
# =============================================================================
# 4. РУЧНОЕ СОЗДАНИЕ И ПАКЕТНОЕ УДАЛЕНИЕ СРЕЗОВ
# =============================================================================
@router.post("/create")
def create_snapshot(req: CreateSnapshotRequest):
from services.scud_export import run_export
target_date = (req.date_str or DATE_TODAY).replace('_', '.')
try:
run_export(input_date=target_date, save_xlsx=True, debug=False)
return {"status": "ok", "message": f"Срез за {target_date} успешно создан"}
except Exception as e:
logger.error(f"Ошибка создания среза: {e}")
raise HTTPException(status_code=500, detail=f"Не удалось создать срез: {e}")
@router.delete("", include_in_schema=False)
@router.delete("/")
def delete_snapshots(req: DeleteSnapshotsRequest):
if not req.snapshot_ids:
return {"status": "ok", "deleted": 0}
with get_connection() as conn:
cursor = conn.cursor()
for sid in req.snapshot_ids:
clean_id = sid.lstrip("#").strip()
cursor.execute("DELETE FROM scud_logs WHERE snapshot_id IN (?, ?)", (clean_id, f"#{clean_id}"))
conn.commit()
return {"status": "ok", "deleted": len(req.snapshot_ids)}
# =============================================================================
# 5. СКАЧИВАНИЕ ФИЗИЧЕСКИХ ФАЙЛОВ .XLSX (FALLBACK)
# =============================================================================
@router.get("/{filename}")
def download_snapshot_file(filename: str):
safe_filename = os.path.basename(filename)
file_path = os.path.join(SNAPSHOTS_DIR, safe_filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Файл среза не найден на диске")
return FileResponse(
path=file_path,
filename=safe_filename,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
```
## File: `./modules/web_api/routers/tasks.py`
```py
"""
===============================================================================
FILE: modules/web_api/routers/tasks.py
PROJECT: SCUD Orion AI (Unified Architecture)
MODULE: web_api / routers
ROLE: REST API эндпоинты реестра задач.
===============================================================================
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional, Dict, Any
from routers.auth import get_current_user
from services.tasks.service import get_tasks, add_task, update_task_details
router = APIRouter(prefix="/api/v1/tasks", tags=["Tasks"])
class TaskCreateRequest(BaseModel):
title: str
priority: Optional[str] = "MEDIUM"
module: Optional[str] = "general"
due_date: Optional[str] = None
status: Optional[str] = "BACKLOG"
class TaskUpdateRequest(BaseModel):
title: Optional[str] = None
priority: Optional[str] = None
due_date: Optional[str] = None
status: Optional[str] = None
def resolve_user_id(current_user: Dict[str, Any]) -> int:
if not current_user:
return 1
return current_user.get("id") or current_user.get("user_id") or 1
@router.get("")
async def get_tasks_endpoint(status: Optional[str] = None, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user)
return {"tasks": get_tasks(user_id=user_id, status=status)}
@router.post("")
async def create_task_endpoint(req: TaskCreateRequest, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user)
res = add_task(
user_id=user_id,
module=req.module,
title=req.title,
priority=req.priority,
due_date=req.due_date,
status=req.status
)
if "error" in res:
raise HTTPException(status_code=400, detail=res["error"])
return res
@router.patch("/{task_id}")
async def update_task_endpoint(task_id: str, req: TaskUpdateRequest, current_user = Depends(get_current_user)):
user_id = resolve_user_id(current_user)
res = update_task_details(
user_id=user_id,
task_id=task_id,
title=req.title,
priority=req.priority,
status=req.status,
due_date=req.due_date
)
if "error" in res:
raise HTTPException(status_code=404, detail=res["error"])
return res
```
## File: `./modules/web_api/scripts/clear_history.py`
```py
"""
Скрипт полной очистки истории диалогов и сессионных состояний SQLite.
"""
import os
import sqlite3
# Рассчитываем путь к общей БД data/scud_orion_ai.db в корне проекта
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
DB_PATH = os.path.join(BASE_DIR, "data", "scud_orion_ai.db")
def clear_chat_history():
if not os.path.exists(DB_PATH):
print(f"❌ База данных не найдена по адресу: {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Очищаем таблицы сообщений и стейтов
try:
cursor.execute("DELETE FROM chat_messages;")
except sqlite3.OperationalError:
pass
try:
cursor.execute("DELETE FROM session_states;")
except sqlite3.OperationalError:
pass
try:
cursor.execute("DELETE FROM chat_sessions;")
except sqlite3.OperationalError:
pass
conn.commit()
conn.close()
print("✓ [SUCCESS] История сообщений чата и сессионные состояния успешно очищены!")
if __name__ == "__main__":
clear_chat_history()
```
## File: `./modules/web_api/scripts/diagnostics/show_tree.py`
```py
import os
EXCLUDE_DIRS = {'.git', '__pycache__', 'venv', '.venv', 'output', 'logs', 'extracted_project'}
def print_tree(startpath):
print("=" * 60)
print("📂 ДЕРЕВО АРХИТЕКТУРЫ ПРОЕКТА")
print("=" * 60)
for root, dirs, files in os.walk(startpath):
dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
level = root.replace(startpath, '').count(os.sep)
indent = ' ' * 4 * (level)
print(f'{indent}📁 {os.path.basename(root)}/')
subindent = ' ' * 4 * (level + 1)
for f in sorted(files):
if not f.endswith('.pyc'):
print(f'{subindent}📄 {f}')
print("=" * 60)
if __name__ == "__main__":
print_tree('.')
```
## File: `./modules/web_api/static/css/styles.css`
```css
/* Плавное исчезновение текста сверху при скролле */
.fade-scroll-top {
mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 14px);
}
/* Скрытие стандартного скроллбара */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Оптимизация под мобильный viewport (борьба со скачками клавиатуры на iOS/Android) */
body {
min-height: 100vh;
min-height: -webkit-fill-available;
}
```
## File: `./modules/web_api/static/index.html`
```html
SCUD Orion AI Assistant
SCUD Orion AI Assistant
Online
Система интеллектуального аудита и контроля СКУД / 1С
ИИ-ассистент SCUD Orion AI
Привет! Вы можете задавать вопросы ассистенту, управлять системным промптом, сверять кадровые нестыковки СКУД и 1С или формировать срезы и отчеты.
```
## File: `./modules/web_api/static/js/app.js`
```js
/**
* ===============================================================================
* FILE: modules/web_api/static/js/app.js
* ROLE: Главная точка входа UI: динамическая загрузка модальных окон,
* маршрутизация авторизации, управление профилем и глобальный стейт.
* ===============================================================================
*/
const AUTH_TOKEN_KEY = "scud_api_auth_token";
const SESSION_ID = "web_session_main";
const STORAGE_KEY = "scud_chat_input_history";
let API_TOKEN = localStorage.getItem(AUTH_TOKEN_KEY) || "";
let CURRENT_USERNAME = localStorage.getItem("scud_username") || "";
let IS_ADMIN = localStorage.getItem("scud_is_admin") === "true";
let IS_GUEST = localStorage.getItem("scud_is_guest") === "true";
let inputHistory = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
let historyIndex = -1;
// ============================================================================
// 1. ДИНАМИЧЕСКАЯ ЗАГРУЗКА МОДАЛЬНЫХ ОКОН
// ============================================================================
async function loadModals() {
const modalFiles = [
'remote_worker_modal.html',
'manual_absence_modal.html',
'exception_modal.html',
'auth_modal.html',
'profile_modal.html',
'admin_modal.html',
'snapshot_inspector_modal.html',
'presence_modal.html'
];
const container = document.getElementById('modals-container');
if (!container) return;
for (const file of modalFiles) {
try {
const res = await fetch(`/static/modals/${file}?v=2.6.0`);
if (res.ok) {
const html = await res.text();
container.insertAdjacentHTML('beforeend', html);
}
} catch (e) {
console.error(`[Modals] Ошибка загрузки ${file}:`, e);
}
}
}
// ============================================================================
// 2. УПРАВЛЕНИЕ АВТОРИЗАЦИЕЙ И ПРОФИЛЕМ В UI
// ============================================================================
function showAuthModal() {
const modal = document.getElementById("auth-modal");
if (modal) modal.classList.remove("hidden");
}
function hideAuthModal() {
const modal = document.getElementById("auth-modal");
if (modal) modal.classList.add("hidden");
}
function updateUIState() {
const nameEl = document.getElementById("user-display-name");
const roleEl = document.getElementById("user-display-role");
const adminBtn = document.getElementById("admin-panel-btn");
if (nameEl) nameEl.innerText = AuthManager.getFullName();
if (roleEl) roleEl.innerText = AuthManager.isAdmin() ? "Администратор" : "Оператор";
if (adminBtn) {
if (AuthManager.isAdmin()) {
adminBtn.classList.remove("hidden");
} else {
adminBtn.classList.add("hidden");
}
}
}
async function handleLoginSubmit(e) {
e.preventDefault();
const uInput = document.getElementById("auth-username");
const pInput = document.getElementById("auth-password");
const errEl = document.getElementById("auth-error");
if (errEl) errEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: uInput.value.trim(),
password: pInput.value
})
});
if (res.ok) {
const data = await res.json();
AuthManager.setSession(data.token, data.username, data.full_name, data.is_admin, data.user_id);
hideAuthModal();
updateUIState();
if (window.SidebarManager) SidebarManager.switchTab('tasks');
} else {
const err = await res.json();
if (errEl) {
errEl.innerText = err.detail || "Неверный логин или пароль";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
}
function openProfileModal() {
const modal = document.getElementById("profile-modal");
if (modal) modal.classList.remove("hidden");
}
function closeProfileModal() {
const modal = document.getElementById("profile-modal");
if (modal) modal.classList.add("hidden");
}
async function handleChangePassword(e) {
e.preventDefault();
const oldP = document.getElementById("old-pass").value;
const newP = document.getElementById("new-pass").value;
const errEl = document.getElementById("pass-error");
if (errEl) errEl.classList.add("hidden");
try {
const res = await fetch("/api/v1/auth/change-password", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ old_password: oldP, new_password: newP })
});
if (res.ok) {
alert("Пароль успешно изменен");
closeProfileModal();
} else {
const err = await res.json();
if (errEl) {
errEl.innerText = err.detail || "Ошибка изменения пароля";
errEl.classList.remove("hidden");
}
}
} catch (e) {
if (errEl) {
errEl.innerText = "Ошибка сети";
errEl.classList.remove("hidden");
}
}
}
// ============================================================================
// 3. АДМИНИСТРИРОВАНИЕ ПОЛЬЗОВАТЕЛЕЙ
// ============================================================================
function openAdminModal() {
const modal = document.getElementById("admin-modal");
if (modal) {
modal.classList.remove("hidden");
loadAdminUsers();
}
}
function closeAdminModal() {
const modal = document.getElementById("admin-modal");
if (modal) modal.classList.add("hidden");
}
async function loadAdminUsers() {
const listEl = document.getElementById("admin-users-list");
if (!listEl) return;
listEl.innerHTML = ` Загрузка...
`;
try {
const res = await fetch("/api/v1/admin/users", { headers: AuthManager.getAuthHeaders() });
if (res.ok) {
const users = await res.json();
listEl.innerHTML = users.map(u => `
${escapeHtml(u.full_name || u.username)} (${escapeHtml(u.username)})
${u.is_admin ? 'Администратор' : 'Оператор'}
`).join('');
} else {
listEl.innerHTML = `Ошибка загрузки пользователей
`;
}
} catch (e) {
listEl.innerHTML = `Ошибка сети
`;
}
}
async function handleCreateUser(e) {
e.preventDefault();
const u = document.getElementById("new-user-username").value;
const p = document.getElementById("new-user-password").value;
const f = document.getElementById("new-user-fullname").value;
const a = document.getElementById("new-user-admin").checked;
try {
const res = await fetch("/api/v1/admin/users", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ username: u, password: p, full_name: f, is_admin: a })
});
if (res.ok) {
document.getElementById("new-user-username").value = "";
document.getElementById("new-user-password").value = "";
document.getElementById("new-user-fullname").value = "";
document.getElementById("new-user-admin").checked = false;
loadAdminUsers();
} else {
const err = await res.json();
alert(err.detail || "Ошибка создания пользователя");
}
} catch (e) {
alert("Ошибка сети");
}
}
async function deleteAdminUser(id, username) {
if (!confirm(`Удалить пользователя ${username}?`)) return;
try {
const res = await fetch(`/api/v1/admin/users/${id}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
loadAdminUsers();
} else {
const err = await res.json();
alert(err.detail || "Ошибка удаления");
}
} catch (e) {
alert("Ошибка сети");
}
}
// ============================================================================
// 4. ВСПОМОГАТЕЛЬНЫЕ ФОРМАТЕРЫ И МОДАЛКА УДАЛЕНЩИКОВ
// ============================================================================
function dmyToYmd(str) {
if (!str) return "";
const parts = str.replace(/_/g, '.').split('.');
if (parts.length === 3) return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
return "";
}
function ymdToDmy(str) {
if (!str) return "";
const parts = str.split('-');
if (parts.length === 3) return `${parts[2]}.${parts[1]}.${parts[0]}`;
return "";
}
function openRemoteWorkerModal(mode = 'ADD', fio = '', dept = 'Все', dateFrom = '', dateTo = '') {
const modal = document.getElementById("remote-worker-modal");
const titleEl = document.getElementById("remote-modal-title");
const modeInput = document.getElementById("rw-mode");
const fioInput = document.getElementById("rw-fio");
const deptInput = document.getElementById("rw-dept");
const fromInput = document.getElementById("rw-date-from");
const toInput = document.getElementById("rw-date-to");
const errEl = document.getElementById("rw-error");
const suggBox = document.getElementById("rw-fio-suggestions");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (suggBox) {
suggBox.classList.add("hidden");
suggBox.innerHTML = "";
}
if (modeInput) modeInput.value = mode;
if (mode === 'EDIT') {
if (titleEl) titleEl.innerHTML = `Изменение сроков удаленки`;
if (fioInput) {
fioInput.value = fio;
fioInput.readOnly = true;
fioInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
if (deptInput) {
deptInput.value = dept || "Все";
deptInput.readOnly = true;
deptInput.classList.add("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
if (fromInput) fromInput.value = dmyToYmd(dateFrom);
if (toInput) toInput.value = dmyToYmd(dateTo);
} else {
if (titleEl) titleEl.innerHTML = `Добавление удаленщика`;
if (fioInput) {
fioInput.value = "";
fioInput.readOnly = false;
fioInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
if (deptInput) {
deptInput.value = "Все";
deptInput.readOnly = false;
deptInput.classList.remove("bg-slate-100", "text-slate-500", "cursor-not-allowed");
}
const today = new Date().toISOString().split('T')[0];
if (fromInput) fromInput.value = today;
if (toInput) toInput.value = "";
}
modal.classList.remove("hidden");
}
function closeRemoteWorkerModal() {
const modal = document.getElementById("remote-worker-modal");
if (modal) modal.classList.add("hidden");
}
async function handleRemoteWorkerSubmit(e) {
e.preventDefault();
const mode = document.getElementById("rw-mode").value;
const fio = document.getElementById("rw-fio").value.trim();
const dept = document.getElementById("rw-dept").value.trim() || "Все";
const fromVal = ymdToDmy(document.getElementById("rw-date-from").value);
const toVal = ymdToDmy(document.getElementById("rw-date-to").value);
const errEl = document.getElementById("rw-error");
if (errEl) errEl.classList.add("hidden");
const method = (mode === 'EDIT') ? "PUT" : "POST";
const payload = (mode === 'EDIT')
? { fio: fio, date_from: fromVal, date_to: toVal }
: { fio: fio, department: dept, reason: "Удаленная работа", date_from: fromVal, date_to: toVal };
try {
const res = await fetch("/api/v1/remote-workers", {
method: method,
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify(payload)
});
if (res.ok) {
closeRemoteWorkerModal();
if (window.SidebarManager) SidebarManager.switchRegistrySubTab('remote');
} else {
const err = await res.json();
if (errEl) {
errEl.innerText = err.detail || "Ошибка сохранения";
errEl.classList.remove("hidden");
}
}
} catch (err) {
if (errEl) {
errEl.innerText = "Ошибка соединения с сервером";
errEl.classList.remove("hidden");
}
}
}
// ============================================================================
// 5. ИНИЦИАЛИЗАЦИЯ ПРИЛОЖЕНИЯ
// ============================================================================
document.addEventListener("DOMContentLoaded", async () => {
// 1. Асинхронная подгрузка модальных окон
await loadModals();
// 2. Инициализация автокомплита исключений после монтирования разметки
const excInput = document.getElementById("exception-value-input");
const excBox = document.getElementById("exception-suggestions");
if (excInput && excBox && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(excInput, "exception-suggestions");
}
// 3. Инициализация автокомплита для модалки удаленщиков
const rwFioInput = document.getElementById("rw-fio");
if (rwFioInput && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(rwFioInput, "rw-fio-suggestions");
}
//3.1 Автокомплит для местных командировок и иного
const maFioInput = document.getElementById("manual-absence-fio-input");
if (maFioInput && typeof setupStaffAutocomplete === "function") {
setupStaffAutocomplete(maFioInput, "manual-absence-suggestions");
}
// 4. Авто-высота поля ввода команд
const userInputEl = document.getElementById("user-input");
if (userInputEl) {
userInputEl.addEventListener("input", function() {
this.style.height = "24px";
const newHeight = Math.min(this.scrollHeight, 120);
this.style.height = newHeight + "px";
});
}
// 5. Проверка сессии пользователя
if (IS_GUEST) {
hideAuthModal();
updateUIState();
} else if (AuthManager && AuthManager.isAuthenticated()) {
hideAuthModal();
updateUIState();
if (typeof loadTasks === "function") {
loadTasks();
}
if (window.SidebarManager) {
SidebarManager.init();
}
} else {
showAuthModal();
}
});
```
## File: `./modules/web_api/static/js/auth.js`
```js
/**
* ===============================================================================
* FILE: modules/web_api/static/js/auth.js
* ROLE: Менеджер сессий, токенов, ФИО и авторизационных заголовков.
* ===============================================================================
*/
const AUTH_STORAGE_KEY = "scud_auth_token";
const USERNAME_STORAGE_KEY = "scud_username";
const FULLNAME_STORAGE_KEY = "scud_full_name";
const IS_ADMIN_STORAGE_KEY = "scud_is_admin";
const USER_ID_STORAGE_KEY = "scud_user_id";
const AuthManager = {
getToken() {
return localStorage.getItem(AUTH_STORAGE_KEY) || "";
},
getUserId() {
const uid = localStorage.getItem(USER_ID_STORAGE_KEY);
return uid ? parseInt(uid, 10) : 1;
},
getUsername() {
return localStorage.getItem(USERNAME_STORAGE_KEY) || "";
},
getFullName() {
return localStorage.getItem(FULLNAME_STORAGE_KEY) || this.getUsername() || "Пользователь";
},
isAdmin() {
return localStorage.getItem(IS_ADMIN_STORAGE_KEY) === "true";
},
isAuthenticated() {
return Boolean(this.getToken());
},
setSession(token, username, fullName, isAdmin, userId = 1) {
localStorage.setItem(AUTH_STORAGE_KEY, token);
localStorage.setItem(USERNAME_STORAGE_KEY, username);
localStorage.setItem(FULLNAME_STORAGE_KEY, fullName || username);
localStorage.setItem(IS_ADMIN_STORAGE_KEY, String(isAdmin));
localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
localStorage.setItem("scud_api_auth_token", token);
localStorage.setItem("auth_token", token);
},
getAuthHeaders() {
const token = this.getToken();
const headers = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
},
logout() {
console.log("[Auth] Полный выход из системы...");
localStorage.clear();
sessionStorage.clear();
document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
});
window.location.href = "/";
}
};
window.AuthManager = AuthManager;
window.logout = () => AuthManager.logout();
```
## File: `./modules/web_api/static/js/chat/core.js`
```js
/**
* ===============================================================================
* FILE: modules/web_api/static/js/chat/core.js
* ROLE: Ядро чата: полноэкранный Drag-and-Drop оверлей, авто-высота инпута (24px),
* надежный расчет скролла вопроса к верху окна, крупный шрифт text-sm.
* ===============================================================================
*/
let currentAttachedFile = null;
const CHAT_INPUT_STORAGE_KEY = "scud_chat_input_history";
let chatInputHistory = JSON.parse(localStorage.getItem(CHAT_INPUT_STORAGE_KEY) || "[]");
let chatHistoryIndex = -1;
let temporaryCurrentInput = "";
function saveCommandToHistory(commandText) {
if (!commandText || !commandText.trim()) return;
const cleanCmd = commandText.trim();
if (cleanCmd.startsWith("action:save_draft_")) return;
chatInputHistory = chatInputHistory.filter(item => item !== cleanCmd);
chatInputHistory.push(cleanCmd);
if (chatInputHistory.length > 50) chatInputHistory.shift();
localStorage.setItem(CHAT_INPUT_STORAGE_KEY, JSON.stringify(chatInputHistory));
chatHistoryIndex = -1;
}
// ⭐️ ЕДИНАЯ ФУНКЦИЯ СКРОЛЛА: выравнивание вопроса к верхней границе
function scrollToUserMessageTop() {
const container = document.getElementById("chat-messages-container");
if (!container) return;
const userBubbles = container.querySelectorAll(".user-chat-bubble");
const targetEl = userBubbles[userBubbles.length - 1];
if (!targetEl) return;
requestAnimationFrame(() => {
const targetScroll = targetEl.offsetTop - container.offsetTop - 12;
container.scrollTo({
top: Math.max(0, targetScroll),
behavior: 'smooth'
});
});
}
function updateInputHeightAndFade(textarea) {
if (!textarea) return;
if (!textarea.value || textarea.value.trim() === '') {
textarea.style.height = '24px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
return;
}
textarea.style.height = 'auto';
const minHeight = 24;
const maxHeight = 120;
const currentScrollHeight = textarea.scrollHeight;
if (currentScrollHeight <= minHeight + 2) {
textarea.style.height = minHeight + 'px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
} else if (currentScrollHeight > maxHeight) {
textarea.style.height = maxHeight + 'px';
textarea.style.overflowY = 'auto';
textarea.style.maskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
textarea.style.webkitMaskImage = 'linear-gradient(to bottom, transparent 0%, black 14px, black 100%)';
} else {
textarea.style.height = currentScrollHeight + 'px';
textarea.style.overflowY = 'hidden';
textarea.style.maskImage = 'none';
textarea.style.webkitMaskImage = 'none';
}
}
function appendUserMessage(text, filename = null) {
const container = document.getElementById("chat-messages-container");
if (!container) return;
const msgId = 'user-msg-' + Date.now();
let fileBadge = '';
if (filename) {
fileBadge = `
${escapeHtml(filename)}
`;
}
const msgHtml = `
`;
container.insertAdjacentHTML("beforeend", msgHtml);
}
function appendAssistantLoading() {
const container = document.getElementById("chat-messages-container");
if (!container) return null;
const loadingId = 'loading-' + Date.now();
const html = `
ИИ обрабатывает запрос...
`;
container.insertAdjacentHTML("beforeend", html);
return loadingId;
}
function appendAssistantMessage(text, buttons = [], actionPayload = null) {
const container = document.getElementById("chat-messages-container");
if (!container) return;
let payloadHtml = '';
let isHtmlBody = false;
if (actionPayload) {
if (actionPayload.type === 'SNAPSHOTS_CARD' && typeof renderSnapshotsCard === 'function') {
payloadHtml = renderSnapshotsCard(actionPayload.data);
} else if (actionPayload.type === 'TASK_INTERACTIVE_CARD' && typeof renderInteractiveTaskCard === 'function') {
payloadHtml = renderInteractiveTaskCard(actionPayload.tasks);
} else if (actionPayload.type === 'FILE_DOWNLOAD_CARD') {
const dlUrl = actionPayload.download_url || '#';
const fName = actionPayload.filename || 'ROADMAP.md';
const count = actionPayload.tasks_count || '';
payloadHtml = `
${escapeHtml(fName)}
Задач выгружено: ${count} шт. · Markdown
Скачать файл
`;
} else if (actionPayload.type === 'SNAPSHOT_INSPECT_CARD') {
const records = actionPayload.records || [];
const inspectTableId = 'inspect-table-' + Date.now();
const searchInputId = 'inspect-search-' + Date.now();
const rowsHtml = records.map((r, idx) => `
| ${idx + 1} |
${escapeHtml(r.fio)} |
${escapeHtml(r.department)} |
${escapeHtml(r.time_in)} |
${escapeHtml(r.first_activity)} |
${escapeHtml(r.time_out)} |
${escapeHtml(r.in_building)} |
${r.is_present ? '✓ Да' : 'Нет'} |
`).join('');
payloadHtml = `
| № |
Сотрудник |
Отдел |
Вход |
Активность |
Выход |
В здании |
Статус |
${rowsHtml}
`;
} else if (actionPayload.type === 'PROMPT_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'prompt-editor-' + Date.now();
payloadHtml = `
Инлайн-редактор системного промпта:
Прямое редактирование текста
`;
} else if (actionPayload.type === 'RULES_EDITOR') {
const draftText = actionPayload.raw_draft || actionPayload.baseline_prompt || '';
const editorId = 'rules-editor-' + Date.now();
payloadHtml = `
Редактор базы знаний и правил компании:
Прямое изменение правил кадрового арбитража
`;
} else if (actionPayload.type === 'PROMPT_PREVIEW') {
isHtmlBody = true;
}
}
let buttonsHtml = '';
if (!actionPayload || (actionPayload.type !== 'PROMPT_EDITOR' && actionPayload.type !== 'RULES_EDITOR')) {
const allButtons = (buttons && buttons.length > 0) ? buttons : (actionPayload && actionPayload.buttons ? actionPayload.buttons : []);
if (allButtons && Array.isArray(allButtons) && allButtons.length > 0) {
buttonsHtml = `
${allButtons.map(b => `
`).join('')}
`;
}
}
const bodyContent = isHtmlBody ? text : escapeHtml(text);
const html = `
ИИ-ассистент SCUD Orion AI
${bodyContent}
${payloadHtml}
${buttonsHtml}
`;
container.insertAdjacentHTML("beforeend", html);
}
window.filterInspectTable = function(tableId, query) {
const table = document.getElementById(tableId);
if (!table) return;
const q = (query || '').trim().toLowerCase();
const rows = table.querySelectorAll('.inspect-row');
rows.forEach(r => {
const fio = r.getAttribute('data-fio') || '';
const dept = r.getAttribute('data-dept') || '';
if (!q || fio.includes(q) || dept.includes(q)) {
r.classList.remove('hidden');
} else {
r.classList.add('hidden');
}
});
};
window.sendChatAction = function(actionText) {
if (!actionText || !actionText.trim()) return;
const input = document.getElementById("user-input");
if (input) {
input.value = actionText.trim();
updateInputHeightAndFade(input);
}
window.sendMessage();
};
window.submitPromptDraftToDiff = function(editorId) {
const textarea = document.getElementById(editorId);
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) {
alert("Текст системного промпта не может быть пустым");
return;
}
const command = `action:save_draft_prompt:::${newText}`;
const input = document.getElementById("user-input");
if (input) input.value = command;
window.sendMessage();
};
window.submitRulesDraftToDiff = function(editorId) {
const textarea = document.getElementById(editorId);
if (!textarea) return;
const newText = textarea.value.trim();
if (!newText) {
alert("Правила не могут быть пустыми");
return;
}
const command = `action:save_draft_rules:::${newText}`;
const input = document.getElementById("user-input");
if (input) input.value = command;
window.sendMessage();
};
window.sendMessage = async function() {
const input = document.getElementById("user-input");
if (!input) return;
const messageText = input.value.trim();
const fileToSend = currentAttachedFile;
if (!messageText && !fileToSend) return;
saveCommandToHistory(messageText);
input.value = "";
input.style.height = '24px';
input.style.overflowY = 'hidden';
input.style.maskImage = 'none';
input.style.webkitMaskImage = 'none';
window.clearAttachedFile();
// 1. Отрисовка сообщения пользователя в ленте
if (!messageText.startsWith("action:save_draft_prompt:::") && !messageText.startsWith("action:save_draft_rules:::")) {
appendUserMessage(
messageText || (fileToSend ? `Прикреплен файл: ${fileToSend.name}` : ''),
fileToSend ? fileToSend.name : null
);
} else {
appendUserMessage("Сформировать предпросмотр изменений");
}
// 2. Вставка лоадера
const loadingId = appendAssistantLoading();
// 3. Вызов точного скролла
scrollToUserMessageTop();
try {
let res;
if (fileToSend) {
const formData = new FormData();
formData.append("file", fileToSend);
formData.append("message", messageText);
formData.append("session_id", "web_session_main");
res = await fetch("/api/v1/chat/upload", {
method: "POST",
headers: {
"Authorization": AuthManager.getAuthHeaders()["Authorization"]
},
body: formData
});
if (res.status === 404) {
res = await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: messageText || `Загружен файл: ${fileToSend.name}`,
session_id: "web_session_main"
})
});
}
} else {
res = await fetch("/api/v1/chat", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
message: messageText,
session_id: "web_session_main"
})
});
}
const loaderEl = document.getElementById(loadingId);
if (loaderEl) loaderEl.remove();
if (res.ok) {
const data = await res.json();
const replyText = data.response || data.text || data.message || "Запрос выполнен.";
const buttons = data.buttons || [];
const actionPayload = data.action_payload || null;
appendAssistantMessage(replyText, buttons, actionPayload);
if (window.SidebarManager) {
SidebarManager.renderContent();
}
} else {
const err = await res.json().catch(() => ({}));
appendAssistantMessage(`⚠️ Ошибка сервера (${res.status}): ${err.detail || "Не удалось получить ответ"}`);
}
} catch (err) {
console.error("Ошибка отправки сообщения:", err);
const loaderEl = document.getElementById(loadingId);
if (loaderEl) loaderEl.remove();
appendAssistantMessage("⚠️ Ошибка соединения с сервером при отправке сообщения.");
}
};
window.clearAttachedFile = function() {
currentAttachedFile = null;
const preview = document.getElementById("file-attachment-preview");
const nameEl = document.getElementById("file-attachment-name");
const fileInput = document.getElementById("file-upload-input");
if (preview) preview.classList.add("hidden");
if (nameEl) nameEl.innerText = "";
if (fileInput) fileInput.value = "";
};
function handleFileSelected(file) {
if (!file) return;
currentAttachedFile = file;
const preview = document.getElementById("file-attachment-preview");
const nameEl = document.getElementById("file-attachment-name");
if (preview && nameEl) {
nameEl.innerText = file.name;
preview.classList.remove("hidden");
}
}
document.addEventListener("DOMContentLoaded", () => {
const input = document.getElementById("user-input");
const fileInput = document.getElementById("file-upload-input");
const overlay = document.getElementById("global-drag-overlay");
if (input) {
input.style.lineHeight = '24px';
input.style.height = '24px';
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
if (e.shiftKey) return;
e.preventDefault();
window.sendMessage();
return;
}
if (e.key === "ArrowUp") {
const isSingleLine = !input.value.includes("\n");
const isAtBeginning = input.selectionStart === 0 && input.selectionEnd === 0;
if (isSingleLine || isAtBeginning) {
if (chatInputHistory.length > 0) {
e.preventDefault();
if (chatHistoryIndex === -1) {
temporaryCurrentInput = input.value;
chatHistoryIndex = chatInputHistory.length - 1;
} else if (chatHistoryIndex > 0) {
chatHistoryIndex--;
}
input.value = chatInputHistory[chatHistoryIndex];
updateInputHeightAndFade(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
} else if (e.key === "ArrowDown") {
const isSingleLine = !input.value.includes("\n");
const isAtEnd = input.selectionStart === input.value.length;
if (isSingleLine || isAtEnd) {
if (chatHistoryIndex !== -1) {
e.preventDefault();
if (chatHistoryIndex < chatInputHistory.length - 1) {
chatHistoryIndex++;
input.value = chatInputHistory[chatHistoryIndex];
} else {
chatHistoryIndex = -1;
input.value = temporaryCurrentInput;
}
updateInputHeightAndFade(input);
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
});
input.addEventListener("input", function() {
updateInputHeightAndFade(this);
chatHistoryIndex = -1;
});
}
if (fileInput) {
fileInput.addEventListener("change", (e) => {
if (e.target.files && e.target.files[0]) {
handleFileSelected(e.target.files[0]);
}
});
}
let dragCounter = 0;
window.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
if (overlay) {
overlay.classList.remove('hidden');
}
}, false);
window.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
if (overlay) {
overlay.classList.add('hidden');
}
}
}, false);
window.addEventListener('dragover', (e) => {
e.preventDefault();
}, false);
window.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
if (overlay) {
overlay.classList.add('hidden');
}
const dt = e.dataTransfer;
if (dt && dt.files && dt.files[0]) {
handleFileSelected(dt.files[0]);
}
}, false);
});
```
## File: `./modules/web_api/static/js/chat/task_widget.js`
```js
/*
===============================================================================
FILE: modules/web_api/static/js/chat/task_widget.js
ROLE: Интерактивные виджеты задач и срезов СКУД (SNAPSHOTS_CARD) с чекбоксами.
===============================================================================
*/
window.activeTaskFilter = window.activeTaskFilter || 'IN_PROGRESS';
window.currentTasksCache = window.currentTasksCache || [];
function getAuthHeaders() {
const token = typeof API_TOKEN !== 'undefined' && API_TOKEN ? API_TOKEN : localStorage.getItem("scud_api_auth_token");
const headers = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = 'Bearer ' + token;
}
return headers;
}
window.sendChatAction = function(actionText) {
const input = document.getElementById("user-input");
if (input && typeof sendMessage === "function") {
input.value = actionText;
if (typeof setInputLocked === "function") setInputLocked(false);
sendMessage();
}
};
// ============================================================================
// РЕНДЕРИНГ КАРТОЧКИ СРЕЗОВ СКУД (SNAPSHOTS_CARD) С ЧЕКБОКСАМИ
// ============================================================================
function renderSnapshotsCard(data) {
if (!data || !data.snapshots || !Array.isArray(data.snapshots)) return '';
const queryDate = data.query_date || 'выбранную дату';
const snapshots = data.snapshots;
if (snapshots.length === 0) {
return `
📸 За дату ${queryDate} сохраненных снапшотов не найдено.
`;
}
const rowsHtml = snapshots.map((s, idx) => {
const snapId = s.snapshot_id || `ID-${idx}`;
const snapTime = s.snapshot_time ? s.snapshot_time.split(' ')[1] || s.snapshot_time : '—';
const count = s.record_count || 0;
const isFinal = snapId.startsWith('Y');
if (isFinal) {
return `
#${snapId}
${snapTime}
${count} записей
Итоговый Y-срез
`;
}
return `
`;
}).join('');
return `
`;
}
// ⭐️ Обработчики чекбоксов
window.toggleSelectAllSnapshots = function(masterCb) {
const root = masterCb.closest('.snapshots-widget-root');
if (!root) return;
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox');
checkboxes.forEach(cb => cb.checked = masterCb.checked);
window.syncBulkDeleteFooter(root);
};
window.updateSelectedSnapshots = function(itemCb) {
const root = itemCb.closest('.snapshots-widget-root');
if (!root) return;
window.syncBulkDeleteFooter(root);
};
window.syncBulkDeleteFooter = function(root) {
const checkboxes = root.querySelectorAll('.snapshot-item-checkbox:checked');
const footer = root.querySelector('.snapshot-bulk-actions-footer');
const counter = root.querySelector('.bulk-selected-counter');
const masterCb = root.querySelector('.select-all-snapshots-cb');
const allCheckboxes = root.querySelectorAll('.snapshot-item-checkbox');
if (masterCb) {
masterCb.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
}
if (checkboxes.length > 0) {
if (footer) footer.classList.remove('hidden');
if (counter) counter.innerText = `Выбрано дневных срезов: ${checkboxes.length}`;
} else {
if (footer) footer.classList.add('hidden');
}
};
// Одиночная корзина в строке снапшота:
// onclick="window.sendChatAction('удали снапшот ${snapId}')"
// Кнопка пакетного удаления в подвале карточки:
window.submitBulkDeleteSnapshots = function(btnEl) {
const root = btnEl.closest('.snapshots-widget-root');
if (!root) return;
const selected = Array.from(root.querySelectorAll('.snapshot-item-checkbox:checked')).map(cb => cb.value);
if (selected.length === 0) return;
window.sendChatAction(`удали снапшоты ${selected.join(', ')}`);
};
// ============================================================================
// РЕНДЕРИНГ КАРТОЧКИ ЗАДАЧ (TASK_INTERACTIVE_CARD)
// ============================================================================
function renderInteractiveTaskCard(tasks) {
if (!tasks || !Array.isArray(tasks)) return '';
window.currentTasksCache = tasks;
const counts = {
ALL: tasks.length,
IN_PROGRESS: tasks.filter(t => t.status === 'IN_PROGRESS' || t.status === 'PROGRESS').length,
PLANNED: tasks.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED').length,
COMPLETED: tasks.filter(t => t.status === 'COMPLETED' || t.status === 'DONE').length
};
const currentFilter = window.activeTaskFilter || 'IN_PROGRESS';
const filteredTasks = tasks.filter(t => {
const s = (t.status || 'BACKLOG').toUpperCase();
if (currentFilter === 'ALL') return true;
if (currentFilter === 'IN_PROGRESS') return s === 'IN_PROGRESS' || s === 'PROGRESS';
if (currentFilter === 'PLANNED') return s === 'BACKLOG' || s === 'PLANNED';
if (currentFilter === 'COMPLETED') return s === 'COMPLETED' || s === 'DONE';
return true;
});
const getPrioBadge = (prio) => {
const p = (prio || 'MEDIUM').toUpperCase();
if (p === 'HIGH' || p === 'CRITICAL') return '🔥 HIGH';
if (p === 'LOW') return '☕ LOW';
return '⚡ MEDIUM';
};
const getStatusBadge = (status) => {
const s = (status || 'BACKLOG').toUpperCase();
if (s === 'IN_PROGRESS' || s === 'PROGRESS') return '⚙️ В работе';
if (s === 'COMPLETED' || s === 'DONE') return '✓ Готово';
return '📋 В планах';
};
const taskRows = filteredTasks.map(t => {
const id = t.id;
const title = t.title || 'Без названия';
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
const isInProgress = t.status === 'IN_PROGRESS' || t.status === 'PROGRESS';
const actionBtn = isInProgress
? ``
: (!isDone
? ``
: '');
return `
#${id}
${title}
${actionBtn}
${getPrioBadge(t.priority)}
${getStatusBadge(t.status)}
${t.module || 'general'}
${t.due_date ? `📅 срок: ${t.due_date}` : ''}
${t.created_at ? `создана: ${t.created_at.split(' ')[0]}` : ''}
`;
}).join('');
const emptyState = `
📭
Нет задач в категории «${currentFilter}»
`;
return `
`;
}
window.switchTaskFilter = function(filterName, btnEl) {
window.activeTaskFilter = filterName;
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
if (root && window.currentTasksCache && window.currentTasksCache.length > 0) {
root.outerHTML = renderInteractiveTaskCard(window.currentTasksCache);
}
};
window.toggleCreateTaskForm = function(btnEl) {
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
if (!root) return;
const form = root.querySelector('.new-task-creation-form');
if (form) {
form.classList.toggle('hidden');
if (!form.classList.contains('hidden')) {
const input = form.querySelector('.new-task-title');
if (input) input.focus();
}
}
};
window.submitCreateTask = async function(btnEl) {
const root = btnEl ? btnEl.closest('.task-widget-root') : document.querySelector('.task-widget-root');
if (!root) return;
const titleInput = root.querySelector('.new-task-title');
const dateInput = root.querySelector('.new-task-date');
const prioInput = root.querySelector('.new-task-prio');
const statusInput = root.querySelector('.new-task-status');
if (!titleInput || !titleInput.value.trim()) {
alert('Введите описание задачи');
return;
}
try {
const res = await fetch('/api/v1/tasks', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
title: titleInput.value.trim(),
due_date: dateInput ? (dateInput.value || null) : null,
priority: prioInput ? prioInput.value : 'MEDIUM',
status: statusInput ? statusInput.value : 'BACKLOG'
})
});
if (res.ok) {
window.activeTaskFilter = (statusInput && statusInput.value === 'IN_PROGRESS') ? 'IN_PROGRESS' : 'PLANNED';
window.sendChatAction('покажи задачи');
} else {
const err = await res.json();
alert('Ошибка создания задачи: ' + (err.detail || 'Неизвестная ошибка'));
}
} catch (e) {
console.error('Ошибка создания задачи:', e);
alert('Сетевая ошибка при создании задачи');
}
};
window.openTaskInlineEditor = function(id) {
const card = document.getElementById(`task-card-${id}`);
if (!card) return;
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.add('hidden'));
const editor = document.getElementById(`task-editor-${id}`);
if (editor) editor.classList.remove('hidden');
};
window.closeTaskInlineEditor = function(id) {
const card = document.getElementById(`task-card-${id}`);
if (!card) return;
card.querySelectorAll('.task-view-mode').forEach(el => el.classList.remove('hidden'));
const editor = document.getElementById(`task-editor-${id}`);
if (editor) editor.classList.add('hidden');
};
window.saveTaskInlineEdit = async function(id) {
const titleInput = document.getElementById(`task-edit-title-${id}`);
const dateInput = document.getElementById(`task-edit-date-${id}`);
const prioInput = document.getElementById(`task-edit-prio-${id}`);
if (!titleInput || !titleInput.value.trim()) {
alert('Описание задачи не может быть пустым');
return;
}
try {
const res = await fetch(`/api/v1/tasks/${id}`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify({
title: titleInput.value.trim(),
due_date: dateInput ? (dateInput.value || null) : null,
priority: prioInput ? prioInput.value : 'MEDIUM'
})
});
if (res.ok) {
window.sendChatAction('покажи задачи');
} else {
const err = await res.json();
alert('Ошибка обновления задачи: ' + (err.detail || 'Неизвестная ошибка'));
}
} catch (e) {
console.error('Ошибка сохранения задачи:', e);
alert('Сетевая ошибка при обновлении задачи');
}
};
```
## File: `./modules/web_api/static/js/manual_absences.js`
```js
/**
* ===============================================================================
* FILE: modules/web_api/static/js/manual_absences.js
* ROLE: Модальные окна "Мест. командир.", "Иное", универсальный автокомплит ФИО
* и синхронизация с боковой панелью SidebarManager.
* ===============================================================================
*/
let activeAbsenceType = 'LOCAL_TRIP'; // 'LOCAL_TRIP' или 'OTHER'
let reasonsCache = [];
async function loadAbsenceReasons() {
try {
const res = await fetch('/api/v1/manual-absences/reasons');
if (res.ok) {
const data = await res.json();
reasonsCache = data.reasons || [];
}
} catch (e) {
console.error('Ошибка загрузки причин:', e);
}
}
// ⭐️ Открытие окна: режим создания (item = null) или редактирования (item = {...})
function openManualAbsenceModal(type, editItem = null) {
activeAbsenceType = type;
const isTrip = (type === 'LOCAL_TRIP');
const modal = document.getElementById('manual-absence-modal');
const titleEl = document.getElementById('manual-absence-modal-title-text');
const reasonBlock = document.getElementById('manual-absence-reason-block');
const reasonSelect = document.getElementById('manual-absence-reason-select');
const idInput = document.getElementById('manual-absence-id');
const fioInput = document.getElementById('manual-absence-fio-input');
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
const startDateInput = document.getElementById('manual-absence-start-date');
const endDateInput = document.getElementById('manual-absence-end-date');
const errEl = document.getElementById('manual-absence-error');
if (errEl) errEl.classList.add('hidden');
if (reasonBlock && reasonSelect) {
if (isTrip) {
reasonBlock.classList.add('hidden');
} else {
reasonBlock.classList.remove('hidden');
reasonSelect.innerHTML = reasonsCache.map(r => ``).join('');
}
}
const today = new Date().toISOString().split('T')[0];
if (editItem) {
// Режим РЕДАКТИРОВАНИЯ
if (titleEl) titleEl.innerText = isTrip ? 'Изменение сроков командировки' : 'Изменение сроков отсутствия';
if (idInput) idInput.value = editItem.id;
if (fioInput) {
fioInput.value = editItem.fio;
fioInput.readOnly = true;
fioInput.classList.add('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
}
if (deptInput) deptInput.value = editItem.department || '';
if (posInput) posInput.value = editItem.position || '';
if (startDateInput) startDateInput.value = editItem.date_start ? editItem.date_start.replace(/\./g, '-') : today;
if (endDateInput) endDateInput.value = editItem.date_end ? editItem.date_end.replace(/\./g, '-') : today;
if (reasonSelect && editItem.reason) reasonSelect.value = editItem.reason;
} else {
// Режим СОЗДАНИЯ
if (titleEl) titleEl.innerText = isTrip ? 'Добавить в командировки' : 'Добавить отсутствие';
if (idInput) idInput.value = '';
if (fioInput) {
fioInput.value = '';
fioInput.readOnly = false;
fioInput.classList.remove('bg-slate-100', 'text-slate-500', 'cursor-not-allowed');
}
if (deptInput) deptInput.value = '';
if (posInput) posInput.value = '';
if (startDateInput) startDateInput.value = today;
if (endDateInput) endDateInput.value = today;
}
if (modal) modal.classList.remove('hidden');
}
function closeManualAbsenceModal() {
const modal = document.getElementById('manual-absence-modal');
if (modal) modal.classList.add('hidden');
}
async function submitManualAbsence(event) {
if (event) event.preventDefault();
const idVal = document.getElementById('manual-absence-id')?.value.trim();
const fio = document.getElementById('manual-absence-fio-input')?.value.trim();
const deptVal = document.getElementById('manual-absence-dept')?.value.trim() || '';
const posVal = document.getElementById('manual-absence-pos')?.value.trim() || '';
const startDateVal = document.getElementById('manual-absence-start-date')?.value || null;
const endDateVal = document.getElementById('manual-absence-end-date')?.value || null;
const reasonSelect = document.getElementById('manual-absence-reason-select');
const errEl = document.getElementById('manual-absence-error');
if (!fio) {
if (errEl) { errEl.innerText = 'Укажите ФИО сотрудника'; errEl.classList.remove('hidden'); }
return;
}
const payload = {
absence_type: activeAbsenceType,
fio: fio,
department: deptVal,
position: posVal,
date_start: startDateVal,
date_end: endDateVal,
reason: activeAbsenceType === 'LOCAL_TRIP' ? 'Местная командировка' : (reasonSelect ? reasonSelect.value : 'Иное')
};
try {
let res;
if (idVal) {
// Редактирование
res = await fetch(`/api/v1/manual-absences/${idVal}`, {
method: 'PUT',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ id: parseInt(idVal), date_start: startDateVal, date_end: endDateVal })
});
} else {
// Создание
res = await fetch('/api/v1/manual-absences/', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
if (res.ok) {
closeManualAbsenceModal();
if (typeof loadManualAbsencesView === 'function') {
loadManualAbsencesView(activeAbsenceType);
}
} else {
const data = await res.json();
if (errEl) { errEl.innerText = data.detail || 'Ошибка сохранения'; errEl.classList.remove('hidden'); }
}
} catch (e) {
if (errEl) { errEl.innerText = 'Сетевая ошибка при сохранении'; errEl.classList.remove('hidden'); }
}
}
// Универсальный автокомплит сотрудников
let searchTimeout = null;
function setupStaffAutocomplete(inputEl, suggestionsBoxId) {
const box = document.getElementById(suggestionsBoxId);
if (!inputEl || !box) return;
inputEl.addEventListener('input', function() {
const val = this.value.trim();
clearTimeout(searchTimeout);
if (val.length < 2) {
box.classList.add('hidden');
box.innerHTML = '';
return;
}
searchTimeout = setTimeout(async () => {
try {
const res = await fetch(`/api/v1/manual-absences/staff-autocomplete?q=${encodeURIComponent(val)}`);
if (!res.ok) return;
const items = await res.json();
if (items.length === 0) {
box.classList.add('hidden');
return;
}
box.innerHTML = items.map(it => `
${escapeHtml(it.fio)}
${escapeHtml(it.department)} · ${escapeHtml(it.position)}
`).join('');
box.classList.remove('hidden');
} catch (e) {
console.error(e);
}
}, 200);
});
document.addEventListener('click', (e) => {
if (!inputEl.contains(e.target) && !box.contains(e.target)) {
box.classList.add('hidden');
}
});
}
function selectStaffSuggestion(fio, dept, pos, targetInputId, boxId) {
const targetFioEl = document.getElementById(targetInputId);
if (targetFioEl) targetFioEl.value = fio;
if (targetInputId === 'rw-fio') {
const rwDept = document.getElementById('rw-dept');
if (rwDept && dept && dept !== '—') rwDept.value = dept;
} else if (targetInputId === 'manual-absence-fio-input') {
const deptInput = document.getElementById('manual-absence-dept');
const posInput = document.getElementById('manual-absence-pos');
if (deptInput) deptInput.value = dept;
if (posInput) posInput.value = pos;
}
const box = document.getElementById(boxId);
if (box) box.classList.add('hidden');
}
document.addEventListener('DOMContentLoaded', () => {
loadAbsenceReasons();
});
```
## File: `./modules/web_api/static/js/presence.js`
```js
/**
* ===============================================================================
* FILE: modules/web_api/static/js/presence.js
* ROLE: Контроллер экрана оперативного мониторинга («Кто в здании прямо сейчас»).
* ===============================================================================
*/
window.PresenceManager = {
records: [],
activeTab: 'ALL',
open() {
const modal = document.getElementById('presence-modal');
if (modal) {
modal.classList.remove('hidden');
this.bindEventsOnce();
this.loadData(false);
}
},
close() {
const modal = document.getElementById('presence-modal');
if (modal) modal.classList.add('hidden');
},
bindEventsOnce() {
if (this._eventsBound) return;
this._eventsBound = true;
document.getElementById('btn-close-presence-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-presence-force-refresh')?.addEventListener('click', () => this.loadData(true));
document.querySelectorAll('.presence-tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.presence-tab-btn').forEach(b => {
b.classList.remove('bg-white', 'shadow-xs', 'text-slate-800');
b.classList.add('text-slate-600');
});
const target = e.currentTarget;
target.classList.add('bg-white', 'shadow-xs', 'text-slate-800');
target.classList.remove('text-slate-600');
this.activeTab = target.getAttribute('data-tab') || 'ALL';
this.applyFilters();
});
});
document.getElementById('presence-search-input')?.addEventListener('input', () => this.applyFilters());
document.getElementById('presence-dept-filter')?.addEventListener('change', () => this.applyFilters());
},
async loadData(forceRefresh = false) {
const subtitle = document.getElementById('presence-modal-subtitle');
const btnRefresh = document.getElementById('btn-presence-force-refresh');
if (btnRefresh && forceRefresh) {
btnRefresh.disabled = true;
btnRefresh.innerHTML = '⏳Опрос СКУД...';
}
try {
const url = `/api/v1/presence/live?force_refresh=${forceRefresh}`;
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка ответа сервера');
const data = await res.json();
this.render(data);
} catch (err) {
console.error('[Presence] Ошибка загрузки:', err);
if (subtitle) subtitle.textContent = 'Ошибка загрузки данных присутствия';
} finally {
if (btnRefresh) {
btnRefresh.disabled = false;
btnRefresh.innerHTML = '🔄Запросить из СКУД';
}
}
},
render(data) {
this.records = data.records || [];
const m = data.metrics || {};
// Заголовок
const subtitle = document.getElementById('presence-modal-subtitle');
const sourceLabel = data.data_source === 'LIVE_MSSQL' ? '🟢 Онлайн срез Орион' : '⏱️ Локальная база СКУД';
if (subtitle) {
subtitle.textContent = `${sourceLabel} на ${data.latest_event_time || data.timestamp} | Всего в штате 1С: ${m.total_staff || 0} чел.`;
}
// Плашки метрик (строго сбалансированы)
document.getElementById('metric-total').textContent = m.total_staff || 0;
document.getElementById('metric-inside').textContent = m.inside || 0;
document.getElementById('metric-outside').textContent = m.outside || 0;
document.getElementById('metric-remote').textContent = m.remote || 0;
document.getElementById('metric-absence').textContent = m.official_absence || 0;
document.getElementById('metric-not-entered').textContent = m.not_entered || 0;
document.getElementById('metric-excluded').textContent = m.excluded || 0;
// Счетчики на табах
document.getElementById('tab-cnt-all').textContent = m.total_staff || 0;
document.getElementById('tab-cnt-inside').textContent = m.inside || 0;
document.getElementById('tab-cnt-outside').textContent = m.outside || 0;
document.getElementById('tab-cnt-remote').textContent = m.remote || 0;
document.getElementById('tab-cnt-absence').textContent = m.official_absence || 0;
document.getElementById('tab-cnt-not-entered').textContent = m.not_entered || 0;
document.getElementById('tab-cnt-excluded').textContent = m.excluded || 0;
// Выпадающий список подразделений
const deptSelect = document.getElementById('presence-dept-filter');
if (deptSelect) {
const depts = Array.from(new Set(this.records.map(r => r.department).filter(Boolean))).sort();
deptSelect.innerHTML = '' +
depts.map(d => ``).join('');
}
this.applyFilters();
},
applyFilters() {
const search = (document.getElementById('presence-search-input')?.value || '').toLowerCase().trim();
const dept = document.getElementById('presence-dept-filter')?.value || 'ALL';
const filtered = this.records.filter(r => {
if (this.activeTab === 'INSIDE' && r.status !== 'INSIDE') return false;
if (this.activeTab === 'OUTSIDE' && r.status !== 'OUTSIDE') return false;
if (this.activeTab === 'REMOTE' && r.status !== 'REMOTE') return false;
if (this.activeTab === 'ABSENCE' && r.status !== 'OFFICIAL_ABSENCE') return false;
if (this.activeTab === 'NOT_ENTERED' && r.status !== 'NOT_ENTERED') return false;
if (this.activeTab === 'EXCLUDED' && r.status !== 'EXCLUDED') return false;
if (dept !== 'ALL' && r.department !== dept) return false;
if (search && !r.fio.toLowerCase().includes(search) && !r.position.toLowerCase().includes(search)) return false;
return true;
});
const tbody = document.getElementById('presence-table-body');
if (!tbody) return;
if (!filtered.length) {
tbody.innerHTML = '| Сотрудники не найдены |
';
return;
}
tbody.innerHTML = filtered.map(r => {
let badgeClass = 'bg-slate-100 text-slate-700';
if (r.status === 'INSIDE') {
badgeClass = r.is_fligel
? 'bg-emerald-100 text-emerald-900 border border-emerald-300 font-bold'
: 'bg-emerald-100 text-emerald-800 font-bold';
} else if (r.status === 'OUTSIDE') {
badgeClass = 'bg-amber-100 text-amber-800 font-bold';
} else if (r.status === 'REMOTE') {
badgeClass = 'bg-blue-100 text-blue-800 font-semibold';
} else if (r.status === 'OFFICIAL_ABSENCE') {
badgeClass = 'bg-purple-100 text-purple-800 font-medium';
} else if (r.status === 'NOT_ENTERED') {
badgeClass = 'bg-rose-100 text-rose-800 font-semibold';
} else if (r.status === 'EXCLUDED') {
badgeClass = 'bg-slate-200 text-slate-600 font-mono text-[10px]';
}
return `
| ${escapeHtml(r.fio)} |
${escapeHtml(r.department)} |
${escapeHtml(r.position)} |
${escapeHtml(r.status_label)}
|
${escapeHtml(r.last_time || '—')} |
`;
}).join('');
}
};
window.openPresenceModal = () => window.PresenceManager.open();
```
## File: `./modules/web_api/static/js/sidebar/core.js`
```js
/**
* Роутер табов сайдбара и общие утилиты.
*/
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
const SidebarManager = {
currentTab: 'tasks',
currentRegistrySubTab: 'exceptions',
init() {
this.bindEvents();
this.switchTab('tasks');
},
bindEvents() {
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.currentTarget.dataset.tab;
if (tab) this.switchTab(tab);
});
});
},
switchTab(tabName) {
this.currentTab = tabName;
// Переключение стилей кнопок табов
document.querySelectorAll('.sidebar-tab-btn, [data-tab]').forEach(btn => {
const isActive = btn.dataset.tab === tabName;
btn.classList.toggle('text-indigo-600', isActive);
btn.classList.toggle('border-indigo-600', isActive);
btn.classList.toggle('font-bold', isActive);
btn.classList.toggle('text-slate-500', !isActive);
btn.classList.toggle('border-transparent', !isActive);
});
// Переключение видимости вьюх
document.querySelectorAll('.sidebar-view').forEach(view => {
view.classList.add('hidden');
});
const targetView = document.getElementById(`sidebar-view-${tabName}`);
if (targetView) targetView.classList.remove('hidden');
// Вызов профильного загрузчика
if (tabName === 'tasks' && typeof loadTasks === 'function') {
loadTasks();
} else if (tabName === 'snapshots' && typeof loadSnapshotsView === 'function') {
loadSnapshotsView();
} else if (tabName === 'registries' && typeof switchRegistrySubTab === 'function') {
switchRegistrySubTab(this.currentRegistrySubTab);
} else if (tabName === 'prompts' && typeof loadPromptsView === 'function') {
loadPromptsView();
} else if (tabName === 'context' && typeof loadContextView === 'function') {
loadContextView();
}
}
};
document.addEventListener('DOMContentLoaded', () => {
SidebarManager.init();
});
```
## File: `./modules/web_api/static/js/sidebar/prompts_context.js`
```js
/**
* Модуль вкладок "Промпт" и "Контекст".
*/
async function loadPromptsView() {
const container = document.getElementById('prompts-content-container');
if (!container) return;
container.innerHTML = ` Загрузка промпта...
`;
try {
const res = await fetch('/api/v1/chat', {
method: 'POST',
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ message: "покажи системный промпт" })
});
const data = await res.json();
const text = data.response || "Промпт не получен";
container.innerHTML = `
Текущий системный промпт
${escapeHtml(text)}
`;
} catch (e) {
container.innerHTML = `Не удалось загрузить системный промпт
`;
}
}
async function loadContextView() {
const container = document.getElementById('context-content-container');
if (!container) return;
container.innerHTML = ` Загрузка сессии...
`;
try {
const res = await fetch('/api/v1/context/state?session_id=web_session_main', { headers: AuthManager.getAuthHeaders() });
const data = await res.json();
container.innerHTML = `
Сессия: web_session_main
${escapeHtml(data.active_state)}
${data.total_messages || 0}
Всего сообщений
${data.ephemeral_messages || 0}
Служебных (UI)
`;
} catch (e) {
container.innerHTML = `Не удалось загрузить состояние сессии
`;
}
}
async function purgeEphemeralMessages() {
try {
const res = await fetch('/api/v1/context/purge-ephemeral', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: 'web_session_main' })
});
if (res.ok) loadContextView();
} catch (e) { alert('Ошибка сети'); }
}
async function clearAllChatContext() {
if (!confirm('Полностью очистить историю сообщений диалога?')) return;
try {
const res = await fetch('/api/v1/context/clear-all', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: 'web_session_main' })
});
if (res.ok) location.reload();
} catch (e) { alert('Ошибка сети'); }
}
```
## File: `./modules/web_api/static/js/sidebar/registries.js`
```js
/**
* Модуль вкладок "Реестры": исключения, удаленщики, командировки, флигель.
*/
window.openAddExceptionModal = function(category) {
const modal = document.getElementById("exception-modal");
const catInput = document.getElementById("exception-category-input");
const valInput = document.getElementById("exception-value-input");
const commInput = document.getElementById("exception-comment-input");
const headerEl = document.getElementById("exception-modal-header-text");
const labelEl = document.getElementById("exception-value-label");
const errEl = document.getElementById("exception-error-msg");
if (!modal) return;
if (errEl) errEl.classList.add("hidden");
if (catInput) catInput.value = category;
if (valInput) { valInput.value = ""; valInput.focus(); }
if (commInput) commInput.value = "";
const titles = {
'include_fio': 'Белый список (ФИО)',
'fio': 'Исключенный сотрудник (ФИО)',
'departments': 'Исключенный отдел',
'positions': 'Исключенная должность',
'turnstile_fio': 'Правый турникет (ФИО)',
'turnstile_departments': 'Правый турникет (Отделы)',
'fligel_fio': 'Флигель (ФИО)',
'fligel_departments': 'Флигель (Отделы)'
};
const isDept = category.includes('department');
const isPos = category.includes('position');
if (headerEl) headerEl.innerText = "Добавить в " + (titles[category] || "реестр");
if (labelEl) {
labelEl.innerText = isDept ? 'Название подразделения:' : (isPos ? 'Название должности:' : 'ФИО сотрудника:');
}
modal.classList.remove("hidden");
};
window.closeExceptionModal = function() {
const modal = document.getElementById("exception-modal");
if (modal) modal.classList.add("hidden");
};
window.submitExceptionModalForm = async function(e) {
e.preventDefault();
const cat = document.getElementById("exception-category-input")?.value;
const val = document.getElementById("exception-value-input")?.value.trim();
const comm = document.getElementById("exception-comment-input")?.value.trim() || "";
const errEl = document.getElementById("exception-error-msg");
if (!cat || !val) {
if (errEl) { errEl.innerText = "Заполните поле"; errEl.classList.remove("hidden"); }
return;
}
try {
const res = await fetch("/api/v1/exceptions", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ category: cat, value: val, comment: comm })
});
if (res.ok) {
closeExceptionModal();
loadExceptionsView();
} else {
const data = await res.json();
if (errEl) { errEl.innerText = data.detail || "Ошибка сохранения"; errEl.classList.remove("hidden"); }
}
} catch (err) {
if (errEl) { errEl.innerText = "Ошибка соединения"; errEl.classList.remove("hidden"); }
}
};
function switchRegistrySubTab(subTab) {
if (window.SidebarManager) {
SidebarManager.currentRegistrySubTab = subTab;
}
document.querySelectorAll('.registry-subtab-btn').forEach(btn => {
const isActive = btn.dataset.subtab === subTab;
btn.classList.toggle('text-indigo-600', isActive);
btn.classList.toggle('bg-white', isActive);
btn.classList.toggle('shadow-xs', isActive);
btn.classList.toggle('font-bold', isActive);
btn.classList.toggle('text-slate-600', !isActive);
});
if (subTab === 'exceptions') loadExceptionsView();
else if (subTab === 'remote') loadRemoteWorkersView();
else if (subTab === 'local_trip') loadManualAbsencesView('LOCAL_TRIP');
else if (subTab === 'other') loadManualAbsencesView('OTHER');
}
async function loadExceptionsView() {
const container = document.getElementById('registry-content-container');
if (!container) return;
container.innerHTML = ' Загрузка правил...
';
try {
const res = await fetch('/api/v1/exceptions', { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка сети');
const data = await res.json();
renderExceptionsView(data || {});
} catch (e) {
container.innerHTML = 'Не удалось загрузить реестры исключений
';
}
}
function renderExceptionsView(exceptions) {
const container = document.getElementById('registry-content-container');
if (!container) return;
const renderCard = (title, list, category, badgeClass, subBadge) => {
badgeClass = badgeClass || 'bg-slate-100 text-slate-700';
subBadge = subBadge || '';
let tags = 'Список пуст';
if (list && list.length > 0) {
tags = list.map(item => {
const val = escapeHtml(item);
return '' +
'' + val + '' +
'' +
'';
}).join('');
}
const count = list ? list.length : 0;
return '' +
'
' +
'
' +
'' + title + ' (' + count + ')' +
subBadge +
'
' +
'
' +
'
' +
'
' + tags + '
' +
'
';
};
container.innerHTML =
renderCard('Белый список (ФИО)', exceptions.include_fio, 'include_fio', 'bg-indigo-50 text-indigo-700') +
renderCard('Исключенные сотрудники (ФИО)', exceptions.fio, 'fio') +
renderCard('Исключенные отделы', exceptions.departments, 'departments') +
renderCard('Исключенные должности', exceptions.positions, 'positions') +
renderCard('Пр. турникет (ФИО)', exceptions.turnstile_fio, 'turnstile_fio', 'bg-emerald-50 text-emerald-700 border border-emerald-200', 'Оба турникета') +
renderCard('Пр. турникет (Отделы)', exceptions.turnstile_departments, 'turnstile_departments', 'bg-emerald-50 text-emerald-700 border border-emerald-200', 'Оба турникета') +
renderCard('Флигель (ФИО)', exceptions.fligel_fio, 'fligel_fio', 'bg-indigo-50 text-indigo-700 border border-indigo-200', 'Дверь 23') +
renderCard('Флигель (Отделы)', exceptions.fligel_departments, 'fligel_departments', 'bg-indigo-50 text-indigo-700 border border-indigo-200', 'Дверь 23');
container.querySelectorAll('.btn-add-exc').forEach(btn => {
btn.addEventListener('click', e => {
openAddExceptionModal(e.currentTarget.getAttribute('data-add-cat'));
});
});
container.querySelectorAll('.btn-remove-exc').forEach(btn => {
btn.addEventListener('click', e => {
const target = e.currentTarget;
removeExceptionItem(target.getAttribute('data-cat'), target.getAttribute('data-val'));
});
});
}
async function removeExceptionItem(category, value) {
if (!confirm('Удалить "' + value + '" из категории "' + category + '"?')) return;
try {
const res = await fetch('/api/v1/exceptions?category=' + encodeURIComponent(category) + '&value=' + encodeURIComponent(value), {
method: 'DELETE',
headers: AuthManager.getAuthHeaders()
});
if (res.ok) loadExceptionsView();
else alert('Ошибка при удалении');
} catch (e) {
alert('Ошибка сети');
}
}
async function loadRemoteWorkersView() {
const container = document.getElementById('registry-content-container');
if (!container) return;
container.innerHTML = ' Загрузка удаленщиков...
';
try {
const res = await fetch('/api/v1/remote-workers', { headers: AuthManager.getAuthHeaders() });
const data = await res.json();
const workers = data.workers || [];
let listHtml = '';
if (workers.length === 0) {
listHtml = 'Список удаленщиков пуст
';
} else {
listHtml = workers.map(w => {
const fio = escapeHtml(w.fio);
const dept = escapeHtml(w.department || 'Все');
const dFrom = escapeHtml(w.date_from || '—');
const dTo = escapeHtml(w.date_to || 'бессрочно');
return '' +
'
' +
'
' + fio + '' +
'
' +
'' +
'' +
'
' +
'
' +
'
' +
'' + dept + '' +
'' + dFrom + ' по ' + dTo + '' +
'
' +
'
';
}).join('');
}
container.innerHTML =
'' +
'Удаленные сотрудники: ' + workers.length + '' +
'' +
'
' +
'' + listHtml + '
';
const addBtn = document.getElementById('btn-add-remote');
if (addBtn) {
addBtn.addEventListener('click', () => openRemoteWorkerModal('ADD'));
}
container.querySelectorAll('.btn-edit-remote').forEach(btn => {
btn.addEventListener('click', e => {
const t = e.currentTarget;
openRemoteWorkerModal('EDIT', t.getAttribute('data-edit-fio'), t.getAttribute('data-edit-dept'), t.getAttribute('data-edit-from'), t.getAttribute('data-edit-to'));
});
});
container.querySelectorAll('.btn-del-remote').forEach(btn => {
btn.addEventListener('click', e => {
deleteRemoteWorkerItem(e.currentTarget.getAttribute('data-del-fio'));
});
});
} catch (e) {
container.innerHTML = 'Не удалось загрузить удаленщиков
';
}
}
async function deleteRemoteWorkerItem(fio) {
if (!confirm('Удалить сотрудника "' + fio + '"?')) return;
try {
const res = await fetch('/api/v1/remote-workers?fio=' + encodeURIComponent(fio), {
method: 'DELETE',
headers: AuthManager.getAuthHeaders()
});
if (res.ok) loadRemoteWorkersView();
} catch (e) {
alert('Ошибка сети');
}
}
async function loadManualAbsencesView(type) {
const container = document.getElementById('registry-content-container');
if (!container) return;
const isTrip = (type === 'LOCAL_TRIP');
const titleText = isTrip ? 'Местные командировки' : 'Иные причины';
const btnColor = isTrip ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-purple-600 hover:bg-purple-700';
container.innerHTML = ' Загрузка...
';
try {
const res = await fetch('/api/v1/manual-absences/?type=' + encodeURIComponent(type), {
headers: AuthManager.getAuthHeaders()
});
const data = await res.json();
const items = data.items || [];
let listHtml = '';
if (items.length === 0) {
listHtml = 'Нет записей
';
} else {
listHtml = items.map(it => {
const fio = escapeHtml(it.fio);
const reason = escapeHtml(it.reason || '');
const dept = escapeHtml(it.department || '—');
const dStart = it.date_start ? it.date_start.replace(/-/g, '.') : '';
const dEnd = it.date_end ? it.date_end.replace(/-/g, '.') : '';
// Форматирование срока
let dateBadge = '';
if (dStart && dEnd && dStart === dEnd) {
dateBadge = dStart;
} else if (dStart && dEnd) {
dateBadge = `${dStart} — ${dEnd}`;
} else if (dEnd) {
dateBadge = `по ${dEnd}`;
} else if (dStart) {
dateBadge = `с ${dStart}`;
} else {
dateBadge = 'бессрочно';
}
const badgeBg = isTrip
? 'bg-indigo-50 text-indigo-700 border-indigo-200'
: 'bg-purple-50 text-purple-700 border-purple-200';
return `
${!isTrip && reason ? `
${reason}
` : ''}
${dept}
${dateBadge}
`;
}).join('');
}
container.innerHTML = `
${titleText}: ${items.length}
${listHtml}
`;
const addBtn = document.getElementById('btn-add-absence');
if (addBtn) {
addBtn.addEventListener('click', () => openManualAbsenceModal(type));
}
// Слушатели кнопок редактирования
container.querySelectorAll('.btn-edit-abs').forEach(btn => {
btn.addEventListener('click', e => {
const t = e.currentTarget;
const id = t.getAttribute('data-edit-id');
const fio = t.getAttribute('data-edit-fio');
const start = t.getAttribute('data-edit-start');
const end = t.getAttribute('data-edit-end');
openManualAbsenceModal(type, {
id: id,
fio: fio,
date_start: start,
date_end: end
});
});
});
// Слушатели кнопок удаления
container.querySelectorAll('.btn-del-abs').forEach(btn => {
btn.addEventListener('click', e => {
deleteManualAbsenceRecord(e.currentTarget.getAttribute('data-del-id'), type);
});
});
} catch (e) {
container.innerHTML = 'Ошибка загрузки
';
}
}
// Быстрое модальное окно / диалог изменения дат
async function openEditAbsenceDatesModal(id, fio, dateStart, dateEnd, type) {
const newEnd = prompt(`Укажите новую дату окончания для сотрудника:\n${fio}\n(формат ГГГГ-ММ-ДД):`, dateEnd || '');
if (newEnd === null) return;
try {
const res = await fetch(`/api/v1/manual-absences/${id}`, {
method: 'PUT',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
id: parseInt(id),
date_start: dateStart || null,
date_end: newEnd.trim() || null
})
});
if (res.ok) {
loadManualAbsencesView(type);
} else {
alert('Не удалось обновить дату');
}
} catch (err) {
alert('Ошибка сети при обновлении');
}
}
async function deleteManualAbsenceRecord(id, type) {
if (!confirm('Удалить эту запись?')) return;
try {
const res = await fetch('/api/v1/manual-absences/' + encodeURIComponent(id), {
method: 'DELETE',
headers: AuthManager.getAuthHeaders()
});
if (res.ok) loadManualAbsencesView(type);
} catch (e) {
alert('Ошибка сети');
}
}
```
## File: `./modules/web_api/static/js/sidebar/snapshots.js`
```js
/**
* Модуль вкладки "Срезы": список снапшотов по диапазону дат, создание, инспекция и генерация отчетов.
*/
// 1. Вспомогательные функции форматирования дат
function formatDateDDMMYYYY(d) {
const day = String(d.getDate()).padStart(2, '0');
const month = String(d.getMonth() + 1).padStart(2, '0');
const year = d.getFullYear();
return `${day}.${month}.${year}`;
}
function formatDateToISO(d) {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function isoToBackendFormat(isoStr) {
if (!isoStr) return '';
if (isoStr.includes('.')) return isoStr;
const [y, m, d] = isoStr.split('-');
return `${d}.${m}.${y}`;
}
// 2. Автозаполнение полей создания среза текущими датой и временем
function initManualSnapshotInputs() {
const now = new Date();
const dateInput = document.getElementById('manual-snapshot-date');
const timeInput = document.getElementById('manual-snapshot-time');
if (dateInput) {
dateInput.value = formatDateDDMMYYYY(now);
}
if (timeInput) {
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
timeInput.value = `${hh}:${mm}`;
}
}
// 3. Быстрые пресеты периода
window.setSnapshotDatePreset = function(preset) {
const today = new Date();
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (!fromInput || !toInput) return;
if (preset === 'today') {
const str = formatDateToISO(today);
fromInput.value = str;
toInput.value = str;
} else if (preset === 'yesterday') {
const y = new Date();
y.setDate(today.getDate() - 1);
const str = formatDateToISO(y);
fromInput.value = str;
toInput.value = str;
} else if (preset === 'days3') {
const start = new Date();
start.setDate(today.getDate() - 2);
fromInput.value = formatDateToISO(start);
toInput.value = formatDateToISO(today);
} else if (preset === 'days7') {
const start = new Date();
start.setDate(today.getDate() - 6);
fromInput.value = formatDateToISO(start);
toInput.value = formatDateToISO(today);
}
loadSnapshotsView();
};
// 4. Загрузка списка срезов с группировкой и сортировкой
async function loadSnapshotsView() {
// Гарантированно заполняем поля даты и времени создания среза
initManualSnapshotInputs();
const listContainer = document.getElementById('snapshots-list');
const countBadge = document.getElementById('snapshots-count-badge');
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (!listContainer) return;
const todayIso = formatDateToISO(new Date());
if (fromInput && !fromInput.value) fromInput.value = todayIso;
if (toInput && !toInput.value) toInput.value = todayIso;
const dateFrom = fromInput ? isoToBackendFormat(fromInput.value) : isoToBackendFormat(todayIso);
const dateTo = toInput ? isoToBackendFormat(toInput.value) : dateFrom;
listContainer.innerHTML = ` Загрузка срезов...
`;
try {
const url = `/api/v1/snapshots?date_from=${encodeURIComponent(dateFrom)}&date_to=${encodeURIComponent(dateTo)}`;
const res = await fetch(url, { headers: AuthManager.getAuthHeaders() });
if (!res.ok) throw new Error('Ошибка сети');
const data = await res.json();
const snapshots = data.snapshots || [];
if (countBadge) countBadge.innerText = `Срезы в базе: ${snapshots.length}`;
if (snapshots.length === 0) {
listContainer.innerHTML = `
За выбранный период срезы не найдены
`;
return;
}
// Вспомогательный ключ для сортировки от новых к старым
function getSortKey(s) {
const sid = (s.snapshot_id || s.id || '').toUpperCase();
const digits = sid.replace(/\D/g, '');
if (sid.includes('FINAL') && digits.length >= 8) {
return `${digits.substring(0, 8)}_235959`;
}
return digits.padEnd(14, '0');
}
snapshots.sort((a, b) => getSortKey(b).localeCompare(getSortKey(a)));
// Группировка по дням
const groups = {};
snapshots.forEach(s => {
const sid = s.snapshot_id || s.id || '';
let groupDate = s.date || '';
if (!groupDate) {
const digits = sid.replace(/\D/g, '');
if (digits.length >= 8) {
const y = digits.substring(0, 4);
const m = digits.substring(4, 6);
const d = digits.substring(6, 8);
groupDate = `${d}.${m}.${y}`;
} else {
groupDate = 'Другие срезы';
}
}
if (!groups[groupDate]) groups[groupDate] = [];
groups[groupDate].push(s);
});
// Сортировка дат по убыванию
const sortedDates = Object.keys(groups).sort((d1, d2) => {
const parseDate = (str) => {
const p = str.split('.');
return p.length === 3 ? new Date(p[2], p[1] - 1, p[0]).getTime() : 0;
};
return parseDate(d2) - parseDate(d1);
});
let html = '';
for (const dateLabel of sortedDates) {
const items = groups[dateLabel];
html += `
${escapeHtml(dateLabel)}
${items.length} срез.
`;
html += items.map(s => {
const sid = s.snapshot_id || s.id;
const isFinal = Boolean(s.is_final) || sid.toUpperCase().includes('FINAL');
const badgeFinal = isFinal ? `Финал Y` : '';
const cnt = s.record_count ?? s.count ?? s.records_count ?? 0;
const timeStr = s.snapshot_time || s.time || '—';
return `
${escapeHtml(sid)}
${badgeFinal}
${escapeHtml(timeStr)} · ${cnt} зап.
${!isFinal ? `
` : ''}
`;
}).join('');
}
listContainer.innerHTML = html;
} catch (e) {
listContainer.innerHTML = `Не удалось загрузить срезы
`;
}
}
// 5. Ручное создание среза
async function createSnapshotManual() {
const dateVal = document.getElementById('manual-snapshot-date')?.value || '';
const timeVal = document.getElementById('manual-snapshot-time')?.value || '';
const btn = document.getElementById('btn-create-snapshot');
if (!dateVal) { alert('Укажите дату среза'); return; }
btn.disabled = true;
btn.innerHTML = ` Создание...`;
try {
const res = await fetch('/api/v1/snapshots/create', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ date_str: dateVal, time_str: timeVal })
});
if (res.ok) {
loadSnapshotsView();
} else {
const data = await res.json();
alert(`Ошибка: ${data.detail || 'Не удалось сформировать срез'}`);
}
} catch (e) {
alert('Ошибка сети');
} finally {
btn.disabled = false;
btn.innerHTML = ` Сделать срез`;
}
}
// 6. Удаление среза
async function deleteSnapshotItem(snapshotId) {
if (!confirm(`Удалить срез ${snapshotId}?`)) return;
try {
const res = await fetch('/api/v1/snapshots', {
method: 'DELETE',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ snapshot_ids: [snapshotId] })
});
if (res.ok) loadSnapshotsView();
} catch (e) { alert('Ошибка сети'); }
}
// 7. Генерация отчетов On-Demand
window.generateReportDirect = async function(reportType) {
const dateInput = document.getElementById('manual-snapshot-date')?.value || '';
const timeInput = document.getElementById('manual-snapshot-time')?.value || '';
const labelMap = {
'SVODKA': 'Сводки',
'SIMPLIFIED': 'Упрощенного отчета',
'DETAILED': 'Детального отчета'
};
const targetLabel = labelMap[reportType] || 'отчета';
try {
const res = await fetch('/api/v1/reports/generate', {
method: 'POST',
headers: { ...AuthManager.getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({
report_type: reportType,
date: dateInput || null,
time: timeInput || null
})
});
if (!res.ok) {
alert(`Ошибка сервера (${res.status}) при создании ${targetLabel}`);
return;
}
const data = await res.json();
const rep = data.reports?.[0];
if (rep && rep.status === 'success' && rep.download_url) {
window.open(rep.download_url, '_blank');
} else {
alert(`Ошибка: ${rep?.error || 'Не удалось сформировать файл'}`);
}
} catch (e) {
console.error('[Reports] Ошибка генерации:', e);
alert('Сетевая ошибка при запросе формирования отчета');
}
};
// 8. Инициализация при первичной загрузке страницы
document.addEventListener('DOMContentLoaded', () => {
const todayIso = formatDateToISO(new Date());
const fromInput = document.getElementById('snapshots-date-from');
const toInput = document.getElementById('snapshots-date-to');
if (fromInput && !fromInput.value) fromInput.value = todayIso;
if (toInput && !toInput.value) toInput.value = todayIso;
initManualSnapshotInputs();
});
// Экспортируем в глобальную область, чтобы core.js при переключении вкладок вызывал эту функцию
window.loadSnapshotsView = loadSnapshotsView;
window.createSnapshotManual = createSnapshotManual;
window.deleteSnapshotItem = deleteSnapshotItem;
```
## File: `./modules/web_api/static/js/snapshot_inspector.js`
```js
/**
* ===============================================================================
* FILE: modules/web_api/static/js/snapshot_inspector.js
* ROLE: Управление модальным окном полноразмерной инспекции срезов СКУД
* (фильтрация по ФИО, статусам, подразделениям и табличное представление).
* ===============================================================================
*/
let currentInspectorData = [];
let currentActiveSnapshotId = null;
async function openSnapshotInspector(snapshotId) {
currentActiveSnapshotId = snapshotId;
const modal = document.getElementById("snapshot-inspector-modal");
const titleEl = document.getElementById("inspector-modal-title");
const subEl = document.getElementById("inspector-modal-subtitle");
const tbody = document.getElementById("inspector-table-body");
if (!modal) return;
modal.classList.remove("hidden");
titleEl.innerText = `Инспекция среза #${snapshotId}`;
subEl.innerText = "Загрузка данных из базы...";
tbody.innerHTML = `| Загрузка записей... |
`;
try {
const res = await fetch(`/api/v1/snapshots/${snapshotId}/details`, { headers: AuthManager.getAuthHeaders() });
if (res.ok) {
const data = await res.json();
currentInspectorData = data.records || [];
subEl.innerText = `Дата: ${data.date} · Всего записей: ${currentInspectorData.length}`;
populateDepartmentFilter(currentInspectorData);
renderInspectorTable(currentInspectorData);
} else {
tbody.innerHTML = `| Ошибка загрузки деталей среза |
`;
}
} catch (e) {
tbody.innerHTML = `| Ошибка сети |
`;
}
}
function closeSnapshotInspectorModal() {
const modal = document.getElementById("snapshot-inspector-modal");
if (modal) modal.classList.add("hidden");
}
function populateDepartmentFilter(records) {
const select = document.getElementById("inspector-filter-dept");
if (!select) return;
const depts = [...new Set(records.map(r => r.department || r.Подразделение || "Без подразделения"))].sort();
select.innerHTML = `` +
depts.map(d => ``).join('');
}
function renderInspectorTable(records) {
const tbody = document.getElementById("inspector-table-body");
const countEl = document.getElementById("inspector-records-count");
if (!tbody) return;
if (records.length === 0) {
tbody.innerHTML = `| Нет записей, соответствующих фильтрам |
`;
if (countEl) countEl.innerText = "Показано записей: 0";
return;
}
tbody.innerHTML = records.map((r, idx) => {
const fio = r.fio || r.Сотрудник || "—";
const dept = r.department || r.Подразделение || "—";
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
const firstAct = r.first_activity || r.Первая_активность || "—";
const timeOut = r.time_out || r.Конец_дня || "Нет выхода";
const duration = r.duration || r.Находился_в_здании || "00:00";
// ⭐️ Жестко определяем присутствие по факту наличия времени входа
const isPresent = timeIn && timeIn !== "Нет входа" && timeIn !== "—";
// Корректируем статус: если человек зашел, он точно присутствует
let status = r.status || r.Статус || "";
if (!status || status.includes("Отсутствовал") || status.includes("Нет событий")) {
status = isPresent ? "Присутствовал" : "Отсутствовал";
} else if (isPresent && !status.includes("Присутствовал")) {
status = "Присутствовал";
}
return `
| ${idx + 1} |
${escapeHtml(fio)} |
${escapeHtml(dept)} |
${escapeHtml(timeIn)} |
${escapeHtml(firstAct)} |
${escapeHtml(timeOut)} |
${escapeHtml(duration)} |
${escapeHtml(status)}
|
`;
}).join('');
if (countEl) countEl.innerText = `Показано записей: ${records.length} из ${currentInspectorData.length}`;
}
function filterInspectorTable() {
const query = document.getElementById("inspector-search-input")?.value.toLowerCase() || "";
const statusFilter = document.getElementById("inspector-filter-status")?.value || "ALL";
const deptFilter = document.getElementById("inspector-filter-dept")?.value || "ALL";
const filtered = currentInspectorData.filter(r => {
const fio = (r.fio || r.Сотрудник || "").toLowerCase();
const dept = r.department || r.Подразделение || "Без подразделения";
const status = r.status || r.Статус || "";
const timeIn = r.time_in || r.Начало_дня || "Нет входа";
const isPresent = status.includes("Присутствовал") || timeIn !== "Нет входа";
const matchesQuery = fio.includes(query) || dept.toLowerCase().includes(query);
const matchesDept = deptFilter === "ALL" || dept === deptFilter;
let matchesStatus = true;
if (statusFilter === "PRESENT") matchesStatus = isPresent;
if (statusFilter === "ABSENT") matchesStatus = !isPresent;
return matchesQuery && matchesDept && matchesStatus;
});
renderInspectorTable(filtered);
}
window.downloadInspectorExport = function(format = 'xlsx') {
if (!currentActiveSnapshotId) {
alert('Срез не выбран');
return;
}
const cleanId = String(currentActiveSnapshotId).replace(/^#/, '').trim();
const url = `/api/v1/snapshots/${encodeURIComponent(cleanId)}/export?format=${format}`;
window.open(url, '_blank');
};
```
## File: `./modules/web_api/static/js/tasks.js`
```js
/**
* ===============================================================================
* FILE: modules/web_api/static/js/tasks.js
* ROLE: Управление персональными задачами оператора (CRUD, фильтры, рендер).
* ===============================================================================
*/
window.escapeHtml = window.escapeHtml || function (str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
let currentTasksFilter = 'ALL';
let tasksCache = [];
function getTasksContainer() {
return document.getElementById("tasks-list-container") ||
document.getElementById("sidebar-dynamic-content") ||
document.getElementById("tasks-list");
}
async function loadTasks() {
const container = getTasksContainer();
if (!container) return;
container.innerHTML = `
Загрузка задач...
`;
try {
const res = await fetch("/api/v1/tasks", {
headers: AuthManager.getAuthHeaders()
});
if (!res.ok) {
if (res.status === 401) {
showAuthModal();
return;
}
throw new Error(`Ошибка сервера (${res.status})`);
}
const data = await res.json();
tasksCache = Array.isArray(data) ? data : (data.tasks || []);
renderTasksUI();
} catch (err) {
console.error("[Tasks] Ошибка загрузки:", err);
container.innerHTML = `
Не удалось загрузить задачи
`;
}
}
function setTaskFilter(filter) {
currentTasksFilter = filter;
renderTasksUI();
}
function renderTasksUI() {
const container = getTasksContainer();
if (!container) return;
let filtered = tasksCache;
if (currentTasksFilter === 'IN_PROGRESS') {
filtered = tasksCache.filter(t => t.status === 'IN_PROGRESS');
} else if (currentTasksFilter === 'BACKLOG') {
filtered = tasksCache.filter(t => t.status === 'BACKLOG' || t.status === 'PLANNED');
} else if (currentTasksFilter === 'COMPLETED') {
filtered = tasksCache.filter(t => t.status === 'COMPLETED' || t.status === 'DONE');
}
const filtersHtml = `
`;
const addBtnHtml = `
Задачи: ${filtered.length}
`;
if (filtered.length === 0) {
container.innerHTML = `
${filtersHtml}
${addBtnHtml}
Нет задач в выбранной категории
`;
return;
}
const itemsHtml = filtered.map(t => {
const isDone = t.status === 'COMPLETED' || t.status === 'DONE';
const priorityColors = {
'HIGH': 'bg-rose-50 text-rose-700 border-rose-200',
'MEDIUM': 'bg-amber-50 text-amber-700 border-amber-200',
'LOW': 'bg-slate-50 text-slate-600 border-slate-200'
};
const pClass = priorityColors[t.priority] || priorityColors['MEDIUM'];
return `
${escapeHtml(t.title)}
${t.priority || 'NORMAL'}
${t.task_id || ('#' + t.id)} · ${escapeHtml(t.module || 'general')}
`;
}).join('');
container.innerHTML = `
${filtersHtml}
${addBtnHtml}
${itemsHtml}
`;
}
async function toggleTaskStatus(id, currentStatus) {
const newStatus = (currentStatus === 'COMPLETED' || currentStatus === 'DONE') ? 'IN_PROGRESS' : 'COMPLETED';
try {
const res = await fetch(`/api/v1/tasks/${id}`, {
method: "PATCH",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({ status: newStatus })
});
if (res.ok) {
loadTasks();
}
} catch (e) {
console.error("Ошибка смены статуса задачи:", e);
}
}
async function deleteTaskItem(id) {
if (!confirm("Удалить эту задачу?")) return;
try {
const res = await fetch(`/api/v1/tasks/${id}`, {
method: "DELETE",
headers: AuthManager.getAuthHeaders()
});
if (res.ok) {
loadTasks();
}
} catch (e) {
alert("Ошибка сети при удалении");
}
}
async function openCreateTaskModal() {
const title = prompt("Введите описание новой задачи:");
if (!title || !title.trim()) return;
try {
const res = await fetch("/api/v1/tasks", {
method: "POST",
headers: AuthManager.getAuthHeaders(),
body: JSON.stringify({
title: title.trim(),
priority: "MEDIUM",
status: "IN_PROGRESS"
})
});
if (res.ok) {
loadTasks();
} else {
alert("Не удалось создать задачу");
}
} catch (e) {
alert("Ошибка сети");
}
}
window.loadTasks = loadTasks;
window.setTaskFilter = setTaskFilter;
```
## File: `./modules/web_api/static/modals/admin_modal.html`
```html
Управление учетными записями
Загрузка пользователей...
```
## File: `./modules/web_api/static/modals/auth_modal.html`
```html
Авторизация в системе
SCUD Orion AI Security Access
```
## File: `./modules/web_api/static/modals/exception_modal.html`
```html
```
## File: `./modules/web_api/static/modals/manual_absence_modal.html`
```html
```
## File: `./modules/web_api/static/modals/presence_modal.html`
```html
🏢
Оперативный мониторинг присутствия
Загрузка данных...
| Сотрудник |
Отдел |
Должность |
Статус |
Отметка |
| Загрузка данных... |
```
## File: `./modules/web_api/static/modals/profile_modal.html`
```html
```
## File: `./modules/web_api/static/modals/remote_worker_modal.html`
```html
Параметры удаленной работы
```
## File: `./modules/web_api/static/modals/snapshot_inspector_modal.html`
```html
Инспекция среза
Загрузка данных...
| # |
Сотрудник |
Подразделение |
Вход |
Первая акт. |
Выход |
В здании |
Статус |
| Выберите срез для инспекции |
Записей не найдено
```